Skip to content

feat: Port to Netlify Frameworks API v1 #3038

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 1 commit into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
128 changes: 78 additions & 50 deletions src/build/functions/edge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,6 @@ import { pathToRegexp } from 'path-to-regexp'

import { EDGE_HANDLER_NAME, PluginContext } from '../plugin-context.js'

const writeEdgeManifest = async (ctx: PluginContext, manifest: Manifest) => {
await mkdir(ctx.edgeFunctionsDir, { recursive: true })
await writeFile(join(ctx.edgeFunctionsDir, 'manifest.json'), JSON.stringify(manifest, null, 2))
}

const copyRuntime = async (ctx: PluginContext, handlerDirectory: string): Promise<void> => {
const files = await glob('edge-runtime/**/*', {
cwd: ctx.pluginDir,
Expand Down Expand Up @@ -63,7 +58,12 @@ const augmentMatchers = (
})
}

const writeHandlerFile = async (ctx: PluginContext, { matchers, name }: NextDefinition) => {
const writeHandlerFile = async (
ctx: PluginContext,
definition: NextDefinition,
{ isFrameworksAPI }: { isFrameworksAPI: boolean },
) => {
const { name, page } = definition
const nextConfig = ctx.buildConfig
const handlerName = getHandlerName({ name })
const handlerDirectory = join(ctx.edgeFunctionsDir, handlerName)
Expand All @@ -75,7 +75,12 @@ const writeHandlerFile = async (ctx: PluginContext, { matchers, name }: NextDefi

// Writing a file with the matchers that should trigger this function. We'll
// read this file from the function at runtime.
await writeFile(join(handlerRuntimeDirectory, 'matchers.json'), JSON.stringify(matchers))
if (!isFrameworksAPI) {
await writeFile(
join(handlerRuntimeDirectory, 'matchers.json'),
JSON.stringify(definition.matchers),
)
}

// The config is needed by the edge function to match and normalize URLs. To
// avoid shipping and parsing a large file at runtime, let's strip it down to
Expand All @@ -99,22 +104,37 @@ const writeHandlerFile = async (ctx: PluginContext, { matchers, name }: NextDefi
),
)

let handlerFileContents = `
import { init as htmlRewriterInit } from './edge-runtime/vendor/deno.land/x/[email protected]/src/index.ts'
import { handleMiddleware } from './edge-runtime/middleware.ts';
import handler from './server/${name}.js';

await htmlRewriterInit({ module_or_path: Uint8Array.from(${JSON.stringify([
...htmlRewriterWasm,
])}) });

export default (req, context) => handleMiddleware(req, context, handler);
`

if (isFrameworksAPI) {
const augmentedMatchers = augmentMatchers(definition.matchers, ctx)

const config = {
path: augmentedMatchers.map((matcher) => matcher.regexp),
// TODO: this is not correct, we need to handle excluded paths
excludedPath: [],
name: name.endsWith('middleware')
? 'Next.js Middleware Handler'
: `Next.js Edge Handler: ${page}`,
generator: `${ctx.pluginName}@${ctx.pluginVersion}`,
cache: name.endsWith('middleware') ? undefined : 'manual',
}
handlerFileContents += `\nexport const config = ${JSON.stringify(config, null, 2)}`
}

// Writing the function entry file. It wraps the middleware code with the
// compatibility layer mentioned above.
await writeFile(
join(handlerDirectory, `${handlerName}.js`),
`
import { init as htmlRewriterInit } from './edge-runtime/vendor/deno.land/x/[email protected]/src/index.ts'
import { handleMiddleware } from './edge-runtime/middleware.ts';
import handler from './server/${name}.js';

await htmlRewriterInit({ module_or_path: Uint8Array.from(${JSON.stringify([
...htmlRewriterWasm,
])}) });

export default (req, context) => handleMiddleware(req, context, handler);
`,
)
await writeFile(join(handlerDirectory, `${handlerName}.js`), handlerFileContents)
}

const copyHandlerDependencies = async (
Expand Down Expand Up @@ -161,47 +181,55 @@ const copyHandlerDependencies = async (
await writeFile(outputFile, parts.join('\n'))
}

const createEdgeHandler = async (ctx: PluginContext, definition: NextDefinition): Promise<void> => {
const createEdgeHandler = async (
ctx: PluginContext,
definition: NextDefinition,
{ isFrameworksAPI }: { isFrameworksAPI: boolean },
): Promise<void> => {
await copyHandlerDependencies(ctx, definition)
await writeHandlerFile(ctx, definition)
await writeHandlerFile(ctx, definition, { isFrameworksAPI })
}

const getHandlerName = ({ name }: Pick<NextDefinition, 'name'>): string =>
`${EDGE_HANDLER_NAME}-${name.replace(/\W/g, '-')}`

const buildHandlerDefinition = (
ctx: PluginContext,
{ name, matchers, page }: NextDefinition,
): Array<ManifestFunction> => {
const functionHandlerName = getHandlerName({ name })
const functionName = name.endsWith('middleware')
? 'Next.js Middleware Handler'
: `Next.js Edge Handler: ${page}`
const cache = name.endsWith('middleware') ? undefined : ('manual' as const)
const generator = `${ctx.pluginName}@${ctx.pluginVersion}`

return augmentMatchers(matchers, ctx).map((matcher) => ({
function: functionHandlerName,
name: functionName,
pattern: matcher.regexp,
cache,
generator,
}))
}

export const clearStaleEdgeHandlers = async (ctx: PluginContext) => {
await rm(ctx.edgeFunctionsDir, { recursive: true, force: true })
}

export const createEdgeHandlers = async (ctx: PluginContext) => {
const nextManifest = await ctx.getMiddlewareManifest()
const nextDefinitions = [...Object.values(nextManifest.middleware)]
await Promise.all(nextDefinitions.map((def) => createEdgeHandler(ctx, def)))

const netlifyDefinitions = nextDefinitions.flatMap((def) => buildHandlerDefinition(ctx, def))
const netlifyManifest: Manifest = {
version: 1,
functions: netlifyDefinitions,
const isFrameworksAPI = ctx.shouldUseFrameworksAPI

await Promise.all(nextDefinitions.map((def) => createEdgeHandler(ctx, def, { isFrameworksAPI })))

if (!isFrameworksAPI) {
const netlifyDefinitions = nextDefinitions.flatMap((def) => {
const { name, matchers, page } = def
const functionHandlerName = getHandlerName({ name })
const functionName = name.endsWith('middleware')
? 'Next.js Middleware Handler'
: `Next.js Edge Handler: ${page}`
const cache = name.endsWith('middleware') ? undefined : ('manual' as const)
const generator = `${ctx.pluginName}@${ctx.pluginVersion}`

return augmentMatchers(matchers, ctx).map((matcher) => ({
function: functionHandlerName,
name: functionName,
pattern: matcher.regexp,
cache,
generator,
}))
})
const netlifyManifest: Manifest = {
version: 1,
functions: netlifyDefinitions,
}
await mkdir(ctx.edgeFunctionsDir, { recursive: true })
await writeFile(
join(ctx.edgeFunctionsDir, 'manifest.json'),
JSON.stringify(netlifyManifest, null, 2),
)
}
await writeEdgeManifest(ctx, netlifyManifest)
}
36 changes: 17 additions & 19 deletions src/build/functions/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,24 +76,6 @@ const copyHandlerDependencies = async (ctx: PluginContext) => {
})
}

const writeHandlerManifest = async (ctx: PluginContext) => {
await writeFile(
join(ctx.serverHandlerRootDir, `${SERVER_HANDLER_NAME}.json`),
JSON.stringify({
config: {
name: 'Next.js Server Handler',
generator: `${ctx.pluginName}@${ctx.pluginVersion}`,
nodeBundler: 'none',
// the folders can vary in monorepos based on the folder structure of the user so we have to glob all
includedFiles: ['**'],
includedFilesBasePath: ctx.serverHandlerRootDir,
},
version: 1,
}),
'utf-8',
)
}

const applyTemplateVariables = (template: string, variables: Record<string, string>) => {
return Object.entries(variables).reduce((acc, [key, value]) => {
return acc.replaceAll(key, value)
Expand All @@ -107,6 +89,23 @@ const getHandlerFile = async (ctx: PluginContext): Promise<string> => {
const templateVariables: Record<string, string> = {
'{{useRegionalBlobs}}': ctx.useRegionalBlobs.toString(),
}

if (ctx.shouldUseFrameworksAPI) {
const includedFiles = [
posixJoin(relative(process.cwd(), ctx.serverHandlerRootDir), '**'),
'!**/node_modules/@aws-sdk/client-s3/dist-es/runtimeConfig.browser.js',
'!**/node_modules/@aws-sdk/client-s3/dist-es/runtimeConfig.shared.js',
'!**/node_modules/@aws-sdk/client-s3/dist-es/runtimeConfig.js',
]
templateVariables[
'{{handlerConfig}}'
] = `export const config = { name: "Next.js Server Handler", generator: "${
ctx.pluginName
}@${ctx.pluginVersion}", includedFiles: ${JSON.stringify(includedFiles)}, nodeBundler: "none" };`
} else {
templateVariables['{{handlerConfig}}'] = ''
}

// In this case it is a monorepo and we need to use a own template for it
// as we have to change the process working directory
if (ctx.relativeAppDir.length !== 0) {
Expand Down Expand Up @@ -143,7 +142,6 @@ export const createServerHandler = async (ctx: PluginContext) => {
await copyNextServerCode(ctx)
await copyNextDependencies(ctx)
await copyHandlerDependencies(ctx)
await writeHandlerManifest(ctx)
await writeHandlerFile(ctx)

await verifyHandlerDirStructure(ctx)
Expand Down
29 changes: 29 additions & 0 deletions src/build/helpers/deployment.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { satisfies } from 'semver'

const FRAMEWORKS_API_BUILD_VERSION = '>=29.41.5'

/**
* Checks if the build is running with a version that supports the Frameworks API.
* @param buildVersion The build version from the Netlify context.
* @returns `true` if the build version supports the Frameworks API.
*/
export const shouldUseFrameworksAPI = (buildVersion: string): boolean =>
satisfies(buildVersion, FRAMEWORKS_API_BUILD_VERSION, { includePrerelease: true })

/**
* Defines the directory for serverless functions when using the Frameworks API.
* @returns The path to the serverless functions directory.
*/
export const getFrameworksAPIFunctionsDir = () => '.netlify/v1/functions'

/**
* Defines the directory for edge functions when using the Frameworks API.
* @returns The path to the edge functions directory.
*/
export const getFrameworksAPIEdgeFunctionsDir = () => '.netlify/v1/edge-functions'

/**
* Defines the directory for blobs when using the Frameworks API.
* @returns The path to the blobs directory.
*/
export const getFrameworksAPIBlobsDir = () => '.netlify/v1/blobs/deploy'
21 changes: 21 additions & 0 deletions src/build/plugin-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,13 @@ import type { PagesManifest } from 'next/dist/build/webpack/plugins/pages-manife
import type { NextConfigComplete } from 'next/dist/server/config-shared.js'
import { satisfies } from 'semver'

import {
getFrameworksAPIBlobsDir,
getFrameworksAPIEdgeFunctionsDir,
getFrameworksAPIFunctionsDir,
shouldUseFrameworksAPI,
} from './helpers/deployment.js'

const MODULE_DIR = fileURLToPath(new URL('.', import.meta.url))
const PLUGIN_DIR = join(MODULE_DIR, '../..')
const DEFAULT_PUBLISH_DIR = '.next'
Expand Down Expand Up @@ -142,13 +149,21 @@ export class PluginContext {
* default: `.netlify/blobs/deploy`
*/
get blobDir(): string {
if (this.shouldUseFrameworksAPI) {
return this.resolveFromPackagePath(getFrameworksAPIBlobsDir())
}

if (this.useRegionalBlobs) {
return this.resolveFromPackagePath('.netlify/deploy/v1/blobs/deploy')
}

return this.resolveFromPackagePath('.netlify/blobs/deploy')
}

get shouldUseFrameworksAPI(): boolean {
return shouldUseFrameworksAPI(this.buildVersion)
}

get buildVersion(): string {
return this.constants.NETLIFY_BUILD_VERSION || 'v0.0.0'
}
Expand All @@ -164,6 +179,9 @@ export class PluginContext {
* `.netlify/functions-internal`
*/
get serverFunctionsDir(): string {
if (this.shouldUseFrameworksAPI) {
return this.resolveFromPackagePath(getFrameworksAPIFunctionsDir())
}
return this.resolveFromPackagePath('.netlify/functions-internal')
}

Expand Down Expand Up @@ -195,6 +213,9 @@ export class PluginContext {
* `.netlify/edge-functions`
*/
get edgeFunctionsDir(): string {
if (this.shouldUseFrameworksAPI) {
return this.resolveFromPackagePath(getFrameworksAPIEdgeFunctionsDir())
}
return this.resolveFromPackagePath('.netlify/edge-functions')
}

Expand Down
5 changes: 1 addition & 4 deletions src/build/templates/handler-monorepo.tmpl.js
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,4 @@ export default async function (req, context) {
return handlerResponse
}

export const config = {
path: '/*',
preferStatic: true,
}
{{handlerConfig}}
5 changes: 1 addition & 4 deletions src/build/templates/handler.tmpl.js
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,4 @@ export default async function handler(req, context) {
return handlerResponse
}

export const config = {
path: '/*',
preferStatic: true,
}
{{handlerConfig}}
7 changes: 6 additions & 1 deletion src/run/storage/regional-blob-store.cts
Original file line number Diff line number Diff line change
Expand Up @@ -54,9 +54,14 @@ const getFetchBeforeNextPatchedIt = () =>
extendedGlobalThis[FETCH_BEFORE_NEXT_PATCHED_IT] ?? fetchBeforeNextPatchedItFallback

export const getRegionalBlobStore = (args: GetWithMetadataOptions = {}): Store => {
const useFrameworksApi = process.env.USE_FRAMEWORKS_API?.toUpperCase() === 'TRUE'
const useRegionalBlobs = process.env.USE_REGIONAL_BLOBS?.toUpperCase() === 'TRUE'

return getDeployStore({
...args,
fetch: getFetchBeforeNextPatchedIt(),
region: process.env.USE_REGIONAL_BLOBS?.toUpperCase() === 'TRUE' ? undefined : 'us-east-2',
// when using frameworks api, we don't need to specify region
// as it is handled by the build system
region: useFrameworksApi || useRegionalBlobs ? undefined : 'us-east-2',
})
}
Loading
Loading