From 272945515473c7fecea494661eddbcfba47636fb Mon Sep 17 00:00:00 2001 From: Michal Piechowiak Date: Fri, 28 Jun 2024 12:33:58 +0200 Subject: [PATCH 01/18] migrate blobs to frameworks api --- src/build/content/prerendered.ts | 6 +++--- src/build/content/static.ts | 6 ++++-- src/build/plugin-context.ts | 9 +++++---- 3 files changed, 12 insertions(+), 9 deletions(-) diff --git a/src/build/content/prerendered.ts b/src/build/content/prerendered.ts index 9f1ae2fe75..9c2f615dcd 100644 --- a/src/build/content/prerendered.ts +++ b/src/build/content/prerendered.ts @@ -1,6 +1,6 @@ import { existsSync } from 'node:fs' import { mkdir, readFile, writeFile } from 'node:fs/promises' -import { join } from 'node:path' +import { dirname, join } from 'node:path' import { trace } from '@opentelemetry/api' import { wrapTracer } from '@opentelemetry/api/experimental' @@ -31,12 +31,12 @@ const writeCacheEntry = async ( lastModified: number, ctx: PluginContext, ): Promise => { - const path = join(ctx.blobDir, await encodeBlobKey(route)) + const path = join(ctx.blobDir, await encodeBlobKey(route), 'blob') const entry = JSON.stringify({ lastModified, value, } satisfies NetlifyCacheHandlerValue) - + await mkdir(dirname(path), { recursive: true }) await writeFile(path, entry, 'utf-8') } diff --git a/src/build/content/static.ts b/src/build/content/static.ts index 4079695bd4..30ac773666 100644 --- a/src/build/content/static.ts +++ b/src/build/content/static.ts @@ -1,6 +1,6 @@ import { existsSync } from 'node:fs' import { cp, mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises' -import { basename, join } from 'node:path' +import { basename, dirname, join } from 'node:path' import { trace } from '@opentelemetry/api' import { wrapTracer } from '@opentelemetry/api/experimental' @@ -33,7 +33,9 @@ export const copyStaticContent = async (ctx: PluginContext): Promise => { .map(async (path): Promise => { const html = await readFile(join(srcDir, path), 'utf-8') verifyNetlifyForms(ctx, html) - await writeFile(join(destDir, await encodeBlobKey(path)), html, 'utf-8') + const blobPath = join(destDir, await encodeBlobKey(path), 'blob') + await mkdir(dirname(blobPath), { recursive: true }) + await writeFile(blobPath, html, 'utf-8') }), ) } catch (error) { diff --git a/src/build/plugin-context.ts b/src/build/plugin-context.ts index a28148db96..9e3b19d223 100644 --- a/src/build/plugin-context.ts +++ b/src/build/plugin-context.ts @@ -141,11 +141,12 @@ export class PluginContext { * default: `.netlify/blobs/deploy` */ get blobDir(): string { - if (this.useRegionalBlobs) { - return this.resolveFromPackagePath('.netlify/deploy/v1/blobs/deploy') - } + return this.resolveFromPackagePath('.netlify/v1/blobs/deploy') + // if (this.useRegionalBlobs) { + // return this.resolveFromPackagePath('.netlify/deploy/v1/blobs/deploy') + // } - return this.resolveFromPackagePath('.netlify/blobs/deploy') + // return this.resolveFromPackagePath('.netlify/blobs/deploy') } get buildVersion(): string { From 5a5fcb0710b049de90ebb5300bbd9d68ccceae11 Mon Sep 17 00:00:00 2001 From: Michal Piechowiak Date: Fri, 28 Jun 2024 12:34:18 +0200 Subject: [PATCH 02/18] migrate serverless functions to frameworks api --- src/build/functions/server.ts | 38 ++++++++++---------- src/build/plugin-context.ts | 2 +- src/build/templates/handler-monorepo.tmpl.js | 5 +++ src/build/templates/handler.tmpl.js | 5 +++ 4 files changed, 31 insertions(+), 19 deletions(-) diff --git a/src/build/functions/server.ts b/src/build/functions/server.ts index dbdba6553f..deeb5d52d1 100644 --- a/src/build/functions/server.ts +++ b/src/build/functions/server.ts @@ -67,23 +67,23 @@ 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 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 writePackageMetadata = async (ctx: PluginContext) => { await writeFile( @@ -104,6 +104,8 @@ const getHandlerFile = async (ctx: PluginContext): Promise => { const templateVariables: Record = { '{{useRegionalBlobs}}': ctx.useRegionalBlobs.toString(), + '{{generator}}': `${ctx.pluginName}@${ctx.pluginVersion}`, + '{{serverHandlerRootDir}}': ctx.serverHandlerRootDir, } // 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 @@ -141,7 +143,7 @@ export const createServerHandler = async (ctx: PluginContext) => { await copyNextServerCode(ctx) await copyNextDependencies(ctx) await copyHandlerDependencies(ctx) - await writeHandlerManifest(ctx) + // await writeHandlerManifest(ctx) await writePackageMetadata(ctx) await writeHandlerFile(ctx) diff --git a/src/build/plugin-context.ts b/src/build/plugin-context.ts index 9e3b19d223..dd9c33da47 100644 --- a/src/build/plugin-context.ts +++ b/src/build/plugin-context.ts @@ -168,7 +168,7 @@ export class PluginContext { * `.netlify/functions-internal` */ get serverFunctionsDir(): string { - return this.resolveFromPackagePath('.netlify/functions-internal') + return this.resolveFromPackagePath('.netlify/v1/functions') } /** Absolute path of the server handler */ diff --git a/src/build/templates/handler-monorepo.tmpl.js b/src/build/templates/handler-monorepo.tmpl.js index 6e9b9a9a56..a13a3f294a 100644 --- a/src/build/templates/handler-monorepo.tmpl.js +++ b/src/build/templates/handler-monorepo.tmpl.js @@ -53,4 +53,9 @@ export default async function (req, context) { export const config = { path: '/*', preferStatic: true, + name: 'Next.js Server Handler', + generator: '{{generator}}', + nodeBundler: 'none', + includedFiles: ['**'], + includedFilesBasePath: '{{serverHandlerRootDir}}', } diff --git a/src/build/templates/handler.tmpl.js b/src/build/templates/handler.tmpl.js index 0b10bcd902..3381b69651 100644 --- a/src/build/templates/handler.tmpl.js +++ b/src/build/templates/handler.tmpl.js @@ -46,4 +46,9 @@ export default async function handler(req, context) { export const config = { path: '/*', preferStatic: true, + name: 'Next.js Server Handler', + generator: '{{generator}}', + nodeBundler: 'none', + includedFiles: ['**'], + includedFilesBasePath: '{{serverHandlerRootDir}}', } From dda77b79c2291265d26f8dda8a54b6f6a1b223a6 Mon Sep 17 00:00:00 2001 From: Michal Piechowiak Date: Wed, 3 Jul 2024 17:22:42 +0200 Subject: [PATCH 03/18] migrate edge functions to frameworks api --- src/build/functions/edge.ts | 78 ++++++++++++++++++++----------------- src/build/plugin-context.ts | 2 +- 2 files changed, 44 insertions(+), 36 deletions(-) diff --git a/src/build/functions/edge.ts b/src/build/functions/edge.ts index 77c79bf2e3..f9fadfc1b8 100644 --- a/src/build/functions/edge.ts +++ b/src/build/functions/edge.ts @@ -1,17 +1,17 @@ import { cp, mkdir, readFile, rm, writeFile } from 'node:fs/promises' import { dirname, join } from 'node:path' -import type { Manifest, ManifestFunction } from '@netlify/edge-functions' +import type { IntegrationsConfig } from '@netlify/edge-functions' import { glob } from 'fast-glob' import type { EdgeFunctionDefinition as NextDefinition } from 'next/dist/build/webpack/plugins/middleware-plugin.js' 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 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 => { const files = await glob('edge-runtime/**/*', { @@ -53,7 +53,7 @@ const augmentMatchers = ( }) } -const writeHandlerFile = async (ctx: PluginContext, { matchers, name }: NextDefinition) => { +const writeHandlerFile = async (ctx: PluginContext, { matchers, name, page }: NextDefinition) => { const nextConfig = ctx.buildConfig const handlerName = getHandlerName({ name }) const handlerDirectory = join(ctx.edgeFunctionsDir, handlerName) @@ -63,12 +63,11 @@ const writeHandlerFile = async (ctx: PluginContext, { matchers, name }: NextDefi // Netlify Edge Functions and the Next.js edge runtime. await copyRuntime(ctx, handlerDirectory) + const augmentedMatchers = augmentMatchers(matchers, ctx) + // 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(augmentMatchers(matchers, ctx)), - ) + await writeFile(join(handlerRuntimeDirectory, 'matchers.json'), JSON.stringify(augmentedMatchers)) // 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 @@ -93,6 +92,15 @@ const writeHandlerFile = async (ctx: PluginContext, { matchers, name }: NextDefi import {handleMiddleware} from './edge-runtime/middleware.ts'; import handler from './server/${name}.js'; export default (req, context) => handleMiddleware(req, context, handler); + + export const config = ${JSON.stringify({ + name: name.endsWith('middleware') + ? 'Next.js Middleware Handler' + : `Next.js Edge Handler: ${page}`, + pattern: augmentedMatchers.map((matcher) => matcher.regexp), + cache: name.endsWith('middleware') ? undefined : 'manual', + generator: `${ctx.pluginName}@${ctx.pluginVersion}`, + } satisfies IntegrationsConfig)}; `, ) } @@ -142,25 +150,25 @@ const createEdgeHandler = async (ctx: PluginContext, definition: NextDefinition) const getHandlerName = ({ name }: Pick): string => `${EDGE_HANDLER_NAME}-${name.replace(/\W/g, '-')}` -const buildHandlerDefinition = ( - ctx: PluginContext, - { name, matchers, page }: NextDefinition, -): Array => { - const fun = getHandlerName({ name }) - const funName = 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: fun, - name: funName, - pattern: matcher.regexp, - cache, - generator, - })) -} +// const buildHandlerDefinition = ( +// ctx: PluginContext, +// { name, matchers, page }: NextDefinition, +// ): Array => { +// const fun = getHandlerName({ name }) +// const funName = 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: fun, +// name: funName, +// pattern: matcher.regexp, +// cache, +// generator, +// })) +// } export const clearStaleEdgeHandlers = async (ctx: PluginContext) => { await rm(ctx.edgeFunctionsDir, { recursive: true, force: true }) @@ -174,10 +182,10 @@ export const createEdgeHandlers = async (ctx: PluginContext) => { ] await Promise.all(nextDefinitions.map((def) => createEdgeHandler(ctx, def))) - const netlifyDefinitions = nextDefinitions.flatMap((def) => buildHandlerDefinition(ctx, def)) - const netlifyManifest: Manifest = { - version: 1, - functions: netlifyDefinitions, - } - await writeEdgeManifest(ctx, netlifyManifest) + // const netlifyDefinitions = nextDefinitions.flatMap((def) => buildHandlerDefinition(ctx, def)) + // const netlifyManifest: Manifest = { + // version: 1, + // functions: netlifyDefinitions, + // } + // await writeEdgeManifest(ctx, netlifyManifest) } diff --git a/src/build/plugin-context.ts b/src/build/plugin-context.ts index dd9c33da47..47380bf9ba 100644 --- a/src/build/plugin-context.ts +++ b/src/build/plugin-context.ts @@ -195,7 +195,7 @@ export class PluginContext { * `.netlify/edge-functions` */ get edgeFunctionsDir(): string { - return this.resolveFromPackagePath('.netlify/edge-functions') + return this.resolveFromPackagePath('.netlify/v1/edge-functions') } /** Absolute path of the edge handler */ From 173cefe47ff2ebaedd714fff64ba74eafcb7c0a1 Mon Sep 17 00:00:00 2001 From: Michal Piechowiak Date: Wed, 3 Jul 2024 18:09:52 +0200 Subject: [PATCH 04/18] prep blobs handling to conditionally use frameworks api, but still be able to use either previous setup with regional blobs or just legacy blobs --- src/build/content/prerendered.ts | 9 ++---- src/build/content/static.ts | 9 ++---- src/build/functions/server.ts | 2 +- src/build/plugin-context.ts | 52 ++++++++++++++++++++++++++------ 4 files changed, 50 insertions(+), 22 deletions(-) diff --git a/src/build/content/prerendered.ts b/src/build/content/prerendered.ts index 9c2f615dcd..a05f023bef 100644 --- a/src/build/content/prerendered.ts +++ b/src/build/content/prerendered.ts @@ -1,6 +1,6 @@ import { existsSync } from 'node:fs' -import { mkdir, readFile, writeFile } from 'node:fs/promises' -import { dirname, join } from 'node:path' +import { mkdir, readFile } from 'node:fs/promises' +import { join } from 'node:path' import { trace } from '@opentelemetry/api' import { wrapTracer } from '@opentelemetry/api/experimental' @@ -8,7 +8,6 @@ import { glob } from 'fast-glob' import pLimit from 'p-limit' import { satisfies } from 'semver' -import { encodeBlobKey } from '../../shared/blobkey.js' import type { CachedFetchValue, NetlifyCachedAppPageValue, @@ -31,13 +30,11 @@ const writeCacheEntry = async ( lastModified: number, ctx: PluginContext, ): Promise => { - const path = join(ctx.blobDir, await encodeBlobKey(route), 'blob') const entry = JSON.stringify({ lastModified, value, } satisfies NetlifyCacheHandlerValue) - await mkdir(dirname(path), { recursive: true }) - await writeFile(path, entry, 'utf-8') + await ctx.setBlob(route, entry) } /** diff --git a/src/build/content/static.ts b/src/build/content/static.ts index 30ac773666..a155e76206 100644 --- a/src/build/content/static.ts +++ b/src/build/content/static.ts @@ -1,12 +1,11 @@ import { existsSync } from 'node:fs' -import { cp, mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises' -import { basename, dirname, join } from 'node:path' +import { cp, mkdir, readFile, rename, rm } from 'node:fs/promises' +import { basename, join } from 'node:path' import { trace } from '@opentelemetry/api' import { wrapTracer } from '@opentelemetry/api/experimental' import glob from 'fast-glob' -import { encodeBlobKey } from '../../shared/blobkey.js' import { PluginContext } from '../plugin-context.js' import { verifyNetlifyForms } from '../verification.js' @@ -33,9 +32,7 @@ export const copyStaticContent = async (ctx: PluginContext): Promise => { .map(async (path): Promise => { const html = await readFile(join(srcDir, path), 'utf-8') verifyNetlifyForms(ctx, html) - const blobPath = join(destDir, await encodeBlobKey(path), 'blob') - await mkdir(dirname(blobPath), { recursive: true }) - await writeFile(blobPath, html, 'utf-8') + await ctx.setBlob(path, html) }), ) } catch (error) { diff --git a/src/build/functions/server.ts b/src/build/functions/server.ts index deeb5d52d1..d2541e69e9 100644 --- a/src/build/functions/server.ts +++ b/src/build/functions/server.ts @@ -103,7 +103,7 @@ const getHandlerFile = async (ctx: PluginContext): Promise => { const templatesDir = join(ctx.pluginDir, 'dist/build/templates') const templateVariables: Record = { - '{{useRegionalBlobs}}': ctx.useRegionalBlobs.toString(), + '{{useRegionalBlobs}}': (ctx.blobsStrategy !== 'legacy').toString(), '{{generator}}': `${ctx.pluginName}@${ctx.pluginVersion}`, '{{serverHandlerRootDir}}': ctx.serverHandlerRootDir, } diff --git a/src/build/plugin-context.ts b/src/build/plugin-context.ts index 47380bf9ba..31e478bf19 100644 --- a/src/build/plugin-context.ts +++ b/src/build/plugin-context.ts @@ -1,7 +1,7 @@ import { existsSync, readFileSync } from 'node:fs' -import { readFile } from 'node:fs/promises' +import { mkdir, readFile, writeFile } from 'node:fs/promises' import { createRequire } from 'node:module' -import { join, relative, resolve } from 'node:path' +import { dirname, join, relative, resolve } from 'node:path' import { join as posixJoin } from 'node:path/posix' import { fileURLToPath } from 'node:url' @@ -15,6 +15,8 @@ import type { MiddlewareManifest } from 'next/dist/build/webpack/plugins/middlew import type { NextConfigComplete } from 'next/dist/server/config-shared.js' import { satisfies } from 'semver' +import { encodeBlobKey } from '../shared/blobkey.js' + const MODULE_DIR = fileURLToPath(new URL('.', import.meta.url)) const PLUGIN_DIR = join(MODULE_DIR, '../..') const DEFAULT_PUBLISH_DIR = '.next' @@ -137,30 +139,62 @@ export class PluginContext { /** * Absolute path of the directory that will be deployed to the blob store + * frameworks api: `.netlify/v1/blobs/deploy` * region aware: `.netlify/deploy/v1/blobs/deploy` * default: `.netlify/blobs/deploy` */ get blobDir(): string { - return this.resolveFromPackagePath('.netlify/v1/blobs/deploy') - // if (this.useRegionalBlobs) { - // return this.resolveFromPackagePath('.netlify/deploy/v1/blobs/deploy') - // } + switch (this.blobsStrategy) { + case 'frameworks-api': + return this.resolveFromPackagePath('.netlify/v1/blobs/deploy') + case 'regional': + return this.resolveFromPackagePath('.netlify/deploy/v1/blobs/deploy') + case 'legacy': + default: + return this.resolveFromPackagePath('.netlify/blobs/deploy') + } + } - // return this.resolveFromPackagePath('.netlify/blobs/deploy') + async setBlob(key: string, value: string) { + switch (this.blobsStrategy) { + case 'frameworks-api': { + const path = join(this.blobDir, await encodeBlobKey(key), 'blob') + await mkdir(dirname(path), { recursive: true }) + await writeFile(path, value, 'utf-8') + return + } + case 'regional': + case 'legacy': + default: { + const path = join(this.blobDir, await encodeBlobKey(key)) + await writeFile(path, value, 'utf-8') + } + } } get buildVersion(): string { return this.constants.NETLIFY_BUILD_VERSION || 'v0.0.0' } - get useRegionalBlobs(): boolean { + get useFrameworksAPI(): boolean { + // TODO: make this conditional + return true + } + + get blobsStrategy(): 'legacy' | 'regional' | 'frameworks-api' { + if (this.useFrameworksAPI) { + return 'frameworks-api' + } + if (!(this.featureFlags || {})['next-runtime-regional-blobs']) { - return false + return 'legacy' } // Region-aware blobs are only available as of CLI v17.23.5 (i.e. Build v29.41.5) const REQUIRED_BUILD_VERSION = '>=29.41.5' return satisfies(this.buildVersion, REQUIRED_BUILD_VERSION, { includePrerelease: true }) + ? 'regional' + : 'legacy' } /** From 96edce26a1d2be61c0b1a868ed475ba282f78e48 Mon Sep 17 00:00:00 2001 From: Michal Piechowiak Date: Tue, 16 Jul 2024 13:21:11 +0200 Subject: [PATCH 05/18] conditionally use frameworski api when generating serverless and edge functions --- src/build/functions/edge.ts | 84 ++++++++++++++++++----------------- src/build/functions/server.ts | 38 ++++++++-------- src/build/plugin-context.ts | 21 +++++---- 3 files changed, 77 insertions(+), 66 deletions(-) diff --git a/src/build/functions/edge.ts b/src/build/functions/edge.ts index f9fadfc1b8..b6629df28a 100644 --- a/src/build/functions/edge.ts +++ b/src/build/functions/edge.ts @@ -1,17 +1,17 @@ import { cp, mkdir, readFile, rm, writeFile } from 'node:fs/promises' import { dirname, join } from 'node:path' -import type { IntegrationsConfig } from '@netlify/edge-functions' +import type { IntegrationsConfig, Manifest, ManifestFunction } from '@netlify/edge-functions' import { glob } from 'fast-glob' import type { EdgeFunctionDefinition as NextDefinition } from 'next/dist/build/webpack/plugins/middleware-plugin.js' 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 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 => { const files = await glob('edge-runtime/**/*', { @@ -84,6 +84,17 @@ const writeHandlerFile = async (ctx: PluginContext, { matchers, name, page }: Ne JSON.stringify(minimalNextConfig), ) + const isc = ctx.useFrameworksAPI + ? `export const config = ${JSON.stringify({ + name: name.endsWith('middleware') + ? 'Next.js Middleware Handler' + : `Next.js Edge Handler: ${page}`, + pattern: augmentedMatchers.map((matcher) => matcher.regexp), + cache: name.endsWith('middleware') ? undefined : 'manual', + generator: `${ctx.pluginName}@${ctx.pluginVersion}`, + } satisfies IntegrationsConfig)};` + : `` + // Writing the function entry file. It wraps the middleware code with the // compatibility layer mentioned above. await writeFile( @@ -92,16 +103,7 @@ const writeHandlerFile = async (ctx: PluginContext, { matchers, name, page }: Ne import {handleMiddleware} from './edge-runtime/middleware.ts'; import handler from './server/${name}.js'; export default (req, context) => handleMiddleware(req, context, handler); - - export const config = ${JSON.stringify({ - name: name.endsWith('middleware') - ? 'Next.js Middleware Handler' - : `Next.js Edge Handler: ${page}`, - pattern: augmentedMatchers.map((matcher) => matcher.regexp), - cache: name.endsWith('middleware') ? undefined : 'manual', - generator: `${ctx.pluginName}@${ctx.pluginVersion}`, - } satisfies IntegrationsConfig)}; - `, + ${isc}`, ) } @@ -150,25 +152,25 @@ const createEdgeHandler = async (ctx: PluginContext, definition: NextDefinition) const getHandlerName = ({ name }: Pick): string => `${EDGE_HANDLER_NAME}-${name.replace(/\W/g, '-')}` -// const buildHandlerDefinition = ( -// ctx: PluginContext, -// { name, matchers, page }: NextDefinition, -// ): Array => { -// const fun = getHandlerName({ name }) -// const funName = 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: fun, -// name: funName, -// pattern: matcher.regexp, -// cache, -// generator, -// })) -// } +const buildHandlerDefinition = ( + ctx: PluginContext, + { name, matchers, page }: NextDefinition, +): Array => { + const fun = getHandlerName({ name }) + const funName = 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: fun, + name: funName, + pattern: matcher.regexp, + cache, + generator, + })) +} export const clearStaleEdgeHandlers = async (ctx: PluginContext) => { await rm(ctx.edgeFunctionsDir, { recursive: true, force: true }) @@ -182,10 +184,12 @@ export const createEdgeHandlers = async (ctx: PluginContext) => { ] await Promise.all(nextDefinitions.map((def) => createEdgeHandler(ctx, def))) - // const netlifyDefinitions = nextDefinitions.flatMap((def) => buildHandlerDefinition(ctx, def)) - // const netlifyManifest: Manifest = { - // version: 1, - // functions: netlifyDefinitions, - // } - // await writeEdgeManifest(ctx, netlifyManifest) + if (!ctx.useFrameworksAPI) { + const netlifyDefinitions = nextDefinitions.flatMap((def) => buildHandlerDefinition(ctx, def)) + const netlifyManifest: Manifest = { + version: 1, + functions: netlifyDefinitions, + } + await writeEdgeManifest(ctx, netlifyManifest) + } } diff --git a/src/build/functions/server.ts b/src/build/functions/server.ts index d2541e69e9..9fe5947b82 100644 --- a/src/build/functions/server.ts +++ b/src/build/functions/server.ts @@ -67,23 +67,23 @@ 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 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 writePackageMetadata = async (ctx: PluginContext) => { await writeFile( @@ -143,7 +143,9 @@ export const createServerHandler = async (ctx: PluginContext) => { await copyNextServerCode(ctx) await copyNextDependencies(ctx) await copyHandlerDependencies(ctx) - // await writeHandlerManifest(ctx) + if (!ctx.useFrameworksAPI) { + await writeHandlerManifest(ctx) + } await writePackageMetadata(ctx) await writeHandlerFile(ctx) diff --git a/src/build/plugin-context.ts b/src/build/plugin-context.ts index 31e478bf19..ac56311230 100644 --- a/src/build/plugin-context.ts +++ b/src/build/plugin-context.ts @@ -177,8 +177,9 @@ export class PluginContext { } get useFrameworksAPI(): boolean { - // TODO: make this conditional - return true + // Defining RegExp pattern in edge function inline config is only supported since 29.50.5 + const REQUIRED_BUILD_VERSION = '>=29.50.5' + return satisfies(this.buildVersion, REQUIRED_BUILD_VERSION, { includePrerelease: true }) } get blobsStrategy(): 'legacy' | 'regional' | 'frameworks-api' { @@ -186,10 +187,6 @@ export class PluginContext { return 'frameworks-api' } - if (!(this.featureFlags || {})['next-runtime-regional-blobs']) { - return 'legacy' - } - // Region-aware blobs are only available as of CLI v17.23.5 (i.e. Build v29.41.5) const REQUIRED_BUILD_VERSION = '>=29.41.5' return satisfies(this.buildVersion, REQUIRED_BUILD_VERSION, { includePrerelease: true }) @@ -202,7 +199,11 @@ export class PluginContext { * `.netlify/functions-internal` */ get serverFunctionsDir(): string { - return this.resolveFromPackagePath('.netlify/v1/functions') + if (this.useFrameworksAPI) { + return this.resolveFromPackagePath('.netlify/v1/functions') + } + + return this.resolveFromPackagePath('.netlify/functions-internal') } /** Absolute path of the server handler */ @@ -229,7 +230,11 @@ export class PluginContext { * `.netlify/edge-functions` */ get edgeFunctionsDir(): string { - return this.resolveFromPackagePath('.netlify/v1/edge-functions') + if (this.useFrameworksAPI) { + return this.resolveFromPackagePath('.netlify/v1/edge-functions') + } + + return this.resolveFromPackagePath('.netlify/edge-functions') } /** Absolute path of the edge handler */ From c9f0f419aefd04cd3946e75a030d6c346650fe92 Mon Sep 17 00:00:00 2001 From: Michal Piechowiak Date: Tue, 16 Jul 2024 13:49:55 +0200 Subject: [PATCH 06/18] memoize deployment config --- src/build/plugin-context.ts | 37 +++++++++++++++++++++++++++---------- 1 file changed, 27 insertions(+), 10 deletions(-) diff --git a/src/build/plugin-context.ts b/src/build/plugin-context.ts index ac56311230..3a507aba1c 100644 --- a/src/build/plugin-context.ts +++ b/src/build/plugin-context.ts @@ -176,22 +176,39 @@ export class PluginContext { return this.constants.NETLIFY_BUILD_VERSION || 'v0.0.0' } + // eslint-disable-next-line no-invalid-this + #useFrameworksAPI: typeof this.useFrameworksAPI | null = null get useFrameworksAPI(): boolean { - // Defining RegExp pattern in edge function inline config is only supported since 29.50.5 - const REQUIRED_BUILD_VERSION = '>=29.50.5' - return satisfies(this.buildVersion, REQUIRED_BUILD_VERSION, { includePrerelease: true }) + if (this.#useFrameworksAPI === null) { + // Defining RegExp pattern in edge function inline config is only supported since 29.50.5 + const REQUIRED_BUILD_VERSION = '>=29.50.5' + this.#useFrameworksAPI = satisfies(this.buildVersion, REQUIRED_BUILD_VERSION, { + includePrerelease: true, + }) + } + + return this.#useFrameworksAPI } + // eslint-disable-next-line no-invalid-this + #blobsStrategy: typeof this.blobsStrategy | null = null get blobsStrategy(): 'legacy' | 'regional' | 'frameworks-api' { - if (this.useFrameworksAPI) { - return 'frameworks-api' + if (this.#blobsStrategy === null) { + if (this.useFrameworksAPI) { + this.#blobsStrategy = 'frameworks-api' + } else { + // Region-aware blobs are only available as of CLI v17.23.5 (i.e. Build v29.41.5) + const REQUIRED_BUILD_VERSION = '>=29.41.5' + this.#blobsStrategy = satisfies(this.buildVersion, REQUIRED_BUILD_VERSION, { + includePrerelease: true, + }) + ? 'regional' + : 'legacy' + } + // } - // Region-aware blobs are only available as of CLI v17.23.5 (i.e. Build v29.41.5) - const REQUIRED_BUILD_VERSION = '>=29.41.5' - return satisfies(this.buildVersion, REQUIRED_BUILD_VERSION, { includePrerelease: true }) - ? 'regional' - : 'legacy' + return this.#blobsStrategy } /** From 0c91ea6151f8c35b93bc224ff7710703c555b6b4 Mon Sep 17 00:00:00 2001 From: Michal Piechowiak Date: Tue, 16 Jul 2024 13:51:36 +0200 Subject: [PATCH 07/18] try integration tests with recent build version --- tests/utils/fixture.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/utils/fixture.ts b/tests/utils/fixture.ts index 4f725af6f2..d35a22a1b7 100644 --- a/tests/utils/fixture.ts +++ b/tests/utils/fixture.ts @@ -184,7 +184,7 @@ export async function runPluginStep( // EDGE_FUNCTIONS_DIST: '.netlify/edge-functions-dist/', // CACHE_DIR: '.netlify/cache', // IS_LOCAL: true, - // NETLIFY_BUILD_VERSION: '29.23.4', + NETLIFY_BUILD_VERSION: '29.50.5', // INTERNAL_FUNCTIONS_SRC: '.netlify/functions-internal', // INTERNAL_EDGE_FUNCTIONS_SRC: '.netlify/edge-functions', }, From 869e448b6ce1f78def9d72b3ba289f68efa4cadf Mon Sep 17 00:00:00 2001 From: Michal Piechowiak Date: Tue, 16 Jul 2024 14:45:57 +0200 Subject: [PATCH 08/18] integration tests handle frameworks api --- tests/utils/fixture.ts | 60 ++++++++++++++++++++++++++++++------------ 1 file changed, 43 insertions(+), 17 deletions(-) diff --git a/tests/utils/fixture.ts b/tests/utils/fixture.ts index d35a22a1b7..123df15e1b 100644 --- a/tests/utils/fixture.ts +++ b/tests/utils/fixture.ts @@ -298,28 +298,54 @@ export async function runPlugin( ) } - await Promise.all([bundleEdgeFunctions(), bundleFunctions(), uploadBlobs(ctx, base.blobDir)]) + await Promise.all([bundleEdgeFunctions(), bundleFunctions(), uploadBlobs(ctx, base)]) return options } -export async function uploadBlobs(ctx: FixtureTestContext, blobsDir: string) { - const files = await glob('**/*', { - dot: true, - cwd: blobsDir, - }) +export async function uploadBlobs(ctx: FixtureTestContext, pluginContext: PluginContext) { + if (pluginContext.blobsStrategy === 'frameworks-api') { + const files = await glob('**/blob', { + dot: true, + cwd: pluginContext.blobDir, + }) - const keys = files.filter((file) => !basename(file).startsWith('$')) - await Promise.all( - keys.map(async (key) => { - const { dir, base } = parse(key) - const metaFile = join(blobsDir, dir, `$${base}.json`) - const metadata = await readFile(metaFile, 'utf-8') - .then((meta) => JSON.parse(meta)) - .catch(() => ({})) - await ctx.blobStore.set(key, await readFile(join(blobsDir, key), 'utf-8'), { metadata }) - }), - ) + await Promise.all( + files.map(async (blobFilePath) => { + const { dir: key } = parse(blobFilePath) + const metaFile = join(pluginContext.blobDir, key, `blob.meta.json`) + const metadata = await readFile(metaFile, 'utf-8') + .then((meta) => JSON.parse(meta)) + .catch(() => ({})) + await ctx.blobStore.set( + key, + await readFile(join(pluginContext.blobDir, blobFilePath), 'utf-8'), + { + metadata, + }, + ) + }), + ) + } else { + const files = await glob('**/*', { + dot: true, + cwd: pluginContext.blobDir, + }) + + const keys = files.filter((file) => !basename(file).startsWith('$')) + await Promise.all( + keys.map(async (key) => { + const { dir, base } = parse(key) + const metaFile = join(pluginContext.blobDir, dir, `$${base}.json`) + const metadata = await readFile(metaFile, 'utf-8') + .then((meta) => JSON.parse(meta)) + .catch(() => ({})) + await ctx.blobStore.set(key, await readFile(join(pluginContext.blobDir, key), 'utf-8'), { + metadata, + }) + }), + ) + } } const DEFAULT_FLAGS = { From 3f27a9a2c43470a38741b693648addb3dc5dc5d0 Mon Sep 17 00:00:00 2001 From: Michal Piechowiak Date: Tue, 16 Jul 2024 17:21:48 +0200 Subject: [PATCH 09/18] test: add e2e test for pre-frameworks api --- src/build/plugin-context.ts | 2 +- .../cli-before-frameworks-api-support.test.ts | 28 +++++++++++++++++++ .../middleware.ts | 9 ++++++ .../next.config.js | 8 ++++++ .../package.json | 16 +++++++++++ .../pages/index.js | 15 ++++++++++ tests/utils/create-e2e-fixture.ts | 4 +++ 7 files changed, 81 insertions(+), 1 deletion(-) create mode 100644 tests/e2e/cli-before-frameworks-api-support.test.ts create mode 100644 tests/fixtures/cli-before-frameworks-api-support/middleware.ts create mode 100644 tests/fixtures/cli-before-frameworks-api-support/next.config.js create mode 100644 tests/fixtures/cli-before-frameworks-api-support/package.json create mode 100644 tests/fixtures/cli-before-frameworks-api-support/pages/index.js diff --git a/src/build/plugin-context.ts b/src/build/plugin-context.ts index 3a507aba1c..72755c47c5 100644 --- a/src/build/plugin-context.ts +++ b/src/build/plugin-context.ts @@ -180,7 +180,7 @@ export class PluginContext { #useFrameworksAPI: typeof this.useFrameworksAPI | null = null get useFrameworksAPI(): boolean { if (this.#useFrameworksAPI === null) { - // Defining RegExp pattern in edge function inline config is only supported since 29.50.5 + // Defining RegExp pattern in edge function inline config is only supported since Build 29.50.5 / CLI 17.32.1 const REQUIRED_BUILD_VERSION = '>=29.50.5' this.#useFrameworksAPI = satisfies(this.buildVersion, REQUIRED_BUILD_VERSION, { includePrerelease: true, diff --git a/tests/e2e/cli-before-frameworks-api-support.test.ts b/tests/e2e/cli-before-frameworks-api-support.test.ts new file mode 100644 index 0000000000..06a538ccd7 --- /dev/null +++ b/tests/e2e/cli-before-frameworks-api-support.test.ts @@ -0,0 +1,28 @@ +import { expect } from '@playwright/test' +import { test } from '../utils/playwright-helpers.js' + +test('should serve 404 page when requesting non existing page (no matching route) if site is deployed with CLI not supporting frameworks API', async ({ + page, + cliBeforeFrameworksAPISupport, +}) => { + // 404 page is built and uploaded to blobs at build time + // when Next.js serves 404 it will try to fetch it from the blob store + // if request handler function is unable to get from blob store it will + // fail request handling and serve 500 error. + // This implicitly tests that request handler function is able to read blobs + // that are uploaded as part of site deploy. + // This also tests if edge middleware is working. + + const response = await page.goto(new URL('non-existing', cliBeforeFrameworksAPISupport.url).href) + const headers = response?.headers() || {} + expect(response?.status()).toBe(404) + + expect(await page.textContent('h1')).toBe('404') + + expect(headers['netlify-cdn-cache-control']).toBe( + 'no-cache, no-store, max-age=0, must-revalidate', + ) + expect(headers['cache-control']).toBe('no-cache,no-store,max-age=0,must-revalidate') + + expect(headers['x-hello-from-middleware']).toBe('hello') +}) diff --git a/tests/fixtures/cli-before-frameworks-api-support/middleware.ts b/tests/fixtures/cli-before-frameworks-api-support/middleware.ts new file mode 100644 index 0000000000..3b40120cab --- /dev/null +++ b/tests/fixtures/cli-before-frameworks-api-support/middleware.ts @@ -0,0 +1,9 @@ +import { NextResponse } from 'next/server' + +export function middleware() { + const response: NextResponse = NextResponse.next() + + response.headers.set('x-hello-from-middleware', 'hello') + + return response +} diff --git a/tests/fixtures/cli-before-frameworks-api-support/next.config.js b/tests/fixtures/cli-before-frameworks-api-support/next.config.js new file mode 100644 index 0000000000..8d2a9bf37a --- /dev/null +++ b/tests/fixtures/cli-before-frameworks-api-support/next.config.js @@ -0,0 +1,8 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = { + eslint: { + ignoreDuringBuilds: true, + }, +} + +module.exports = nextConfig diff --git a/tests/fixtures/cli-before-frameworks-api-support/package.json b/tests/fixtures/cli-before-frameworks-api-support/package.json new file mode 100644 index 0000000000..530a6c70ce --- /dev/null +++ b/tests/fixtures/cli-before-frameworks-api-support/package.json @@ -0,0 +1,16 @@ +{ + "name": "old-cli", + "version": "0.1.0", + "private": true, + "scripts": { + "postinstall": "next build", + "dev": "next dev", + "build": "next build" + }, + "dependencies": { + "next": "latest", + "netlify-cli": "17.32.0", + "react": "^18.2.0", + "react-dom": "^18.2.0" + } +} diff --git a/tests/fixtures/cli-before-frameworks-api-support/pages/index.js b/tests/fixtures/cli-before-frameworks-api-support/pages/index.js new file mode 100644 index 0000000000..70acbeca65 --- /dev/null +++ b/tests/fixtures/cli-before-frameworks-api-support/pages/index.js @@ -0,0 +1,15 @@ +export default function Home({ ssr }) { + return ( +
+
SSR: {ssr ? 'yes' : 'no'}
+
+ ) +} + +export const getServerSideProps = async () => { + return { + props: { + ssr: true, + }, + } +} diff --git a/tests/utils/create-e2e-fixture.ts b/tests/utils/create-e2e-fixture.ts index f1724c8d6a..7a94c3d437 100644 --- a/tests/utils/create-e2e-fixture.ts +++ b/tests/utils/create-e2e-fixture.ts @@ -358,6 +358,10 @@ export const fixtureFactories = { createE2EFixture('cli-before-regional-blobs-support', { expectedCliVersion: '17.21.1', }), + cliBeforeFrameworksAPISupport: () => + createE2EFixture('cli-before-frameworks-api-support', { + expectedCliVersion: '17.32.0', + }), yarnMonorepoWithPnpmLinker: () => createE2EFixture('yarn-monorepo-with-pnpm-linker', { packageManger: 'berry', From 9864c7cfb37303d8925b4e14d0beabc3cc4b9095 Mon Sep 17 00:00:00 2001 From: Michal Piechowiak Date: Tue, 16 Jul 2024 17:44:44 +0200 Subject: [PATCH 10/18] don't introduce eslint disables --- .eslintrc.cjs | 2 ++ src/build/plugin-context.ts | 7 ++----- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/.eslintrc.cjs b/.eslintrc.cjs index dbcc8975f8..9760956873 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -51,6 +51,8 @@ module.exports = { }, rules: { '@typescript-eslint/no-floating-promises': 'error', + 'no-use-before-define': 'off', + '@typescript-eslint/no-use-before-define': 'error', }, }, { diff --git a/src/build/plugin-context.ts b/src/build/plugin-context.ts index 72755c47c5..79db04faaa 100644 --- a/src/build/plugin-context.ts +++ b/src/build/plugin-context.ts @@ -176,8 +176,7 @@ export class PluginContext { return this.constants.NETLIFY_BUILD_VERSION || 'v0.0.0' } - // eslint-disable-next-line no-invalid-this - #useFrameworksAPI: typeof this.useFrameworksAPI | null = null + #useFrameworksAPI: PluginContext['useFrameworksAPI'] | null = null get useFrameworksAPI(): boolean { if (this.#useFrameworksAPI === null) { // Defining RegExp pattern in edge function inline config is only supported since Build 29.50.5 / CLI 17.32.1 @@ -190,8 +189,7 @@ export class PluginContext { return this.#useFrameworksAPI } - // eslint-disable-next-line no-invalid-this - #blobsStrategy: typeof this.blobsStrategy | null = null + #blobsStrategy: PluginContext['blobsStrategy'] | null = null get blobsStrategy(): 'legacy' | 'regional' | 'frameworks-api' { if (this.#blobsStrategy === null) { if (this.useFrameworksAPI) { @@ -205,7 +203,6 @@ export class PluginContext { ? 'regional' : 'legacy' } - // } return this.#blobsStrategy From 1d49fff97bf0efd97c4889a4df824b6590de3a1b Mon Sep 17 00:00:00 2001 From: Michal Piechowiak Date: Tue, 16 Jul 2024 17:53:16 +0200 Subject: [PATCH 11/18] test: add unit test for frameworks API dirs --- src/build/plugin-context.test.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/build/plugin-context.test.ts b/src/build/plugin-context.test.ts index 5c18c2a5a2..863538fed3 100644 --- a/src/build/plugin-context.test.ts +++ b/src/build/plugin-context.test.ts @@ -211,3 +211,20 @@ test('should use deploy configuration blobs directory when @netlify/build versio expect(ctx.blobDir).toBe(join(cwd, '.netlify/deploy/v1/blobs/deploy')) }) + +test('should use frameworks API directories when @netlify/build version supports it', () => { + const { cwd } = mockFileSystem({ + '.next/required-server-files.json': JSON.stringify({ + config: { distDir: '.next' }, + relativeAppDir: '', + } as RequiredServerFilesManifest), + }) + + const ctx = new PluginContext({ + constants: { NETLIFY_BUILD_VERSION: '29.50.5' }, + } as unknown as NetlifyPluginOptions) + + expect(ctx.blobDir).toBe(join(cwd, '.netlify/v1/blobs/deploy')) + expect(ctx.edgeFunctionsDir).toBe(join(cwd, '.netlify/v1/edge-functions')) + expect(ctx.serverFunctionsDir).toBe(join(cwd, '.netlify/v1/functions')) +}) From 4817063784e09177fd571e99d9d8f37f62e81451 Mon Sep 17 00:00:00 2001 From: Michal Piechowiak Date: Tue, 16 Jul 2024 17:56:04 +0200 Subject: [PATCH 12/18] chore: update some code comments --- src/build/plugin-context.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/build/plugin-context.ts b/src/build/plugin-context.ts index 79db04faaa..7fee1795ea 100644 --- a/src/build/plugin-context.ts +++ b/src/build/plugin-context.ts @@ -141,7 +141,7 @@ export class PluginContext { * Absolute path of the directory that will be deployed to the blob store * frameworks api: `.netlify/v1/blobs/deploy` * region aware: `.netlify/deploy/v1/blobs/deploy` - * default: `.netlify/blobs/deploy` + * legacy/default: `.netlify/blobs/deploy` */ get blobDir(): string { switch (this.blobsStrategy) { @@ -210,7 +210,8 @@ export class PluginContext { /** * Absolute path of the directory containing the files for the serverless lambda function - * `.netlify/functions-internal` + * frameworks api: `.netlify/v1/functions` + * legacy/default: `.netlify/functions-internal` */ get serverFunctionsDir(): string { if (this.useFrameworksAPI) { @@ -241,7 +242,8 @@ export class PluginContext { /** * Absolute path of the directory containing the files for deno edge functions - * `.netlify/edge-functions` + * frameworks api: `.netlify/v1/edge-functions` + * legacy/default: `.netlify/edge-functions` */ get edgeFunctionsDir(): string { if (this.useFrameworksAPI) { From aec7ac2a8e5c97096a8959437a6f796d8830b34b Mon Sep 17 00:00:00 2001 From: Michal Piechowiak Date: Tue, 16 Jul 2024 18:06:12 +0200 Subject: [PATCH 13/18] fix lint --- src/build/advanced-api-routes.ts | 86 ++++++++++++++++---------------- src/build/content/server.ts | 48 +++++++++--------- src/build/functions/edge.ts | 6 +-- 3 files changed, 70 insertions(+), 70 deletions(-) diff --git a/src/build/advanced-api-routes.ts b/src/build/advanced-api-routes.ts index 6cd024424f..6e1c45f7a7 100644 --- a/src/build/advanced-api-routes.ts +++ b/src/build/advanced-api-routes.ts @@ -35,49 +35,6 @@ interface ApiBackgroundConfig { type ApiConfig = ApiStandardConfig | ApiScheduledConfig | ApiBackgroundConfig -export async function getAPIRoutesConfigs(ctx: PluginContext) { - const functionsConfigManifestPath = join( - ctx.publishDir, - 'server', - 'functions-config-manifest.json', - ) - if (!existsSync(functionsConfigManifestPath)) { - // before https://github.com/vercel/next.js/pull/60163 this file might not have been produced if there were no API routes at all - return [] - } - - const functionsConfigManifest = JSON.parse( - await readFile(functionsConfigManifestPath, 'utf-8'), - ) as FunctionsConfigManifest - - const appDir = ctx.resolveFromSiteDir('.') - const pagesDir = join(appDir, 'pages') - const srcPagesDir = join(appDir, 'src', 'pages') - const { pageExtensions } = ctx.requiredServerFiles.config - - return Promise.all( - Object.keys(functionsConfigManifest.functions).map(async (apiRoute) => { - const filePath = getSourceFileForPage(apiRoute, [pagesDir, srcPagesDir], pageExtensions) - - const sharedFields = { - apiRoute, - filePath, - config: {} as ApiConfig, - } - - if (filePath) { - const config = await extractConfigFromFile(filePath, appDir) - return { - ...sharedFields, - config, - } - } - - return sharedFields - }), - ) -} - // Next.js already defines a default `pageExtensions` array in its `required-server-files.json` file // In case it gets `undefined`, this is a fallback const SOURCE_FILE_EXTENSIONS = ['js', 'jsx', 'ts', 'tsx'] @@ -186,3 +143,46 @@ const extractConfigFromFile = async (apiFilePath: string, appDir: string): Promi return {} } } + +export async function getAPIRoutesConfigs(ctx: PluginContext) { + const functionsConfigManifestPath = join( + ctx.publishDir, + 'server', + 'functions-config-manifest.json', + ) + if (!existsSync(functionsConfigManifestPath)) { + // before https://github.com/vercel/next.js/pull/60163 this file might not have been produced if there were no API routes at all + return [] + } + + const functionsConfigManifest = JSON.parse( + await readFile(functionsConfigManifestPath, 'utf-8'), + ) as FunctionsConfigManifest + + const appDir = ctx.resolveFromSiteDir('.') + const pagesDir = join(appDir, 'pages') + const srcPagesDir = join(appDir, 'src', 'pages') + const { pageExtensions } = ctx.requiredServerFiles.config + + return Promise.all( + Object.keys(functionsConfigManifest.functions).map(async (apiRoute) => { + const filePath = getSourceFileForPage(apiRoute, [pagesDir, srcPagesDir], pageExtensions) + + const sharedFields = { + apiRoute, + filePath, + config: {} as ApiConfig, + } + + if (filePath) { + const config = await extractConfigFromFile(filePath, appDir) + return { + ...sharedFields, + config, + } + } + + return sharedFields + }), + ) +} diff --git a/src/build/content/server.ts b/src/build/content/server.ts index 39e11ea8c6..7417432d61 100644 --- a/src/build/content/server.ts +++ b/src/build/content/server.ts @@ -29,6 +29,30 @@ function isError(error: unknown): error is NodeJS.ErrnoException { return error instanceof Error } +/** + * Generates a copy of the middleware manifest without any middleware in it. We + * do this because we'll run middleware in an edge function, and we don't want + * to run it again in the server handler. + */ +const replaceMiddlewareManifest = async (sourcePath: string, destPath: string) => { + await mkdir(dirname(destPath), { recursive: true }) + + const data = await readFile(sourcePath, 'utf8') + const manifest = JSON.parse(data) + + // TODO: Check for `manifest.version` and write an error to the system log + // when we find a value that is not equal to 2. This will alert us in case + // Next.js starts using a new format for the manifest and we're writing + // one with the old version. + const newManifest = { + ...manifest, + middleware: {}, + } + const newData = JSON.stringify(newManifest) + + await writeFile(destPath, newData) +} + /** * Copy App/Pages Router Javascript needed by the server handler */ @@ -311,30 +335,6 @@ export const copyNextDependencies = async (ctx: PluginContext): Promise => }) } -/** - * Generates a copy of the middleware manifest without any middleware in it. We - * do this because we'll run middleware in an edge function, and we don't want - * to run it again in the server handler. - */ -const replaceMiddlewareManifest = async (sourcePath: string, destPath: string) => { - await mkdir(dirname(destPath), { recursive: true }) - - const data = await readFile(sourcePath, 'utf8') - const manifest = JSON.parse(data) - - // TODO: Check for `manifest.version` and write an error to the system log - // when we find a value that is not equal to 2. This will alert us in case - // Next.js starts using a new format for the manifest and we're writing - // one with the old version. - const newManifest = { - ...manifest, - middleware: {}, - } - const newData = JSON.stringify(newManifest) - - await writeFile(destPath, newData) -} - export const verifyHandlerDirStructure = async (ctx: PluginContext) => { const runConfig = JSON.parse(await readFile(join(ctx.serverHandlerDir, RUN_CONFIG), 'utf-8')) diff --git a/src/build/functions/edge.ts b/src/build/functions/edge.ts index b6629df28a..aa933d2ccd 100644 --- a/src/build/functions/edge.ts +++ b/src/build/functions/edge.ts @@ -53,6 +53,9 @@ const augmentMatchers = ( }) } +const getHandlerName = ({ name }: Pick): string => + `${EDGE_HANDLER_NAME}-${name.replace(/\W/g, '-')}` + const writeHandlerFile = async (ctx: PluginContext, { matchers, name, page }: NextDefinition) => { const nextConfig = ctx.buildConfig const handlerName = getHandlerName({ name }) @@ -149,9 +152,6 @@ const createEdgeHandler = async (ctx: PluginContext, definition: NextDefinition) await writeHandlerFile(ctx, definition) } -const getHandlerName = ({ name }: Pick): string => - `${EDGE_HANDLER_NAME}-${name.replace(/\W/g, '-')}` - const buildHandlerDefinition = ( ctx: PluginContext, { name, matchers, page }: NextDefinition, From f3b73064b2e9a802a6e6a005f5fa5cb6f0d58f52 Mon Sep 17 00:00:00 2001 From: Michal Piechowiak Date: Wed, 17 Jul 2024 09:34:54 +0200 Subject: [PATCH 14/18] add a helper for how edge function config is defined --- src/build/functions/edge.ts | 41 ++++++++++++++++++++----------------- src/build/plugin-context.ts | 4 ++++ 2 files changed, 26 insertions(+), 19 deletions(-) diff --git a/src/build/functions/edge.ts b/src/build/functions/edge.ts index aa933d2ccd..61c8ebf042 100644 --- a/src/build/functions/edge.ts +++ b/src/build/functions/edge.ts @@ -56,6 +56,19 @@ const augmentMatchers = ( const getHandlerName = ({ name }: Pick): string => `${EDGE_HANDLER_NAME}-${name.replace(/\W/g, '-')}` +const getEdgeFunctionSharedConfig = ( + ctx: PluginContext, + { name, page }: Pick, +) => { + return { + name: name.endsWith('middleware') + ? 'Next.js Middleware Handler' + : `Next.js Edge Handler: ${page}`, + cache: name.endsWith('middleware') ? undefined : ('manual' as const), + generator: `${ctx.pluginName}@${ctx.pluginVersion}`, + } +} + const writeHandlerFile = async (ctx: PluginContext, { matchers, name, page }: NextDefinition) => { const nextConfig = ctx.buildConfig const handlerName = getHandlerName({ name }) @@ -87,16 +100,13 @@ const writeHandlerFile = async (ctx: PluginContext, { matchers, name, page }: Ne JSON.stringify(minimalNextConfig), ) - const isc = ctx.useFrameworksAPI - ? `export const config = ${JSON.stringify({ - name: name.endsWith('middleware') - ? 'Next.js Middleware Handler' - : `Next.js Edge Handler: ${page}`, - pattern: augmentedMatchers.map((matcher) => matcher.regexp), - cache: name.endsWith('middleware') ? undefined : 'manual', - generator: `${ctx.pluginName}@${ctx.pluginVersion}`, - } satisfies IntegrationsConfig)};` - : `` + const isc = + ctx.edgeFunctionsConfigStrategy === 'inline' + ? `export const config = ${JSON.stringify({ + ...getEdgeFunctionSharedConfig(ctx, { name, page }), + pattern: augmentedMatchers.map((matcher) => matcher.regexp), + } satisfies IntegrationsConfig)};` + : `` // Writing the function entry file. It wraps the middleware code with the // compatibility layer mentioned above. @@ -157,18 +167,11 @@ const buildHandlerDefinition = ( { name, matchers, page }: NextDefinition, ): Array => { const fun = getHandlerName({ name }) - const funName = 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) => ({ + ...getEdgeFunctionSharedConfig(ctx, { name, page }), function: fun, - name: funName, pattern: matcher.regexp, - cache, - generator, })) } @@ -184,7 +187,7 @@ export const createEdgeHandlers = async (ctx: PluginContext) => { ] await Promise.all(nextDefinitions.map((def) => createEdgeHandler(ctx, def))) - if (!ctx.useFrameworksAPI) { + if (ctx.edgeFunctionsConfigStrategy === 'manifest') { const netlifyDefinitions = nextDefinitions.flatMap((def) => buildHandlerDefinition(ctx, def)) const netlifyManifest: Manifest = { version: 1, diff --git a/src/build/plugin-context.ts b/src/build/plugin-context.ts index 7fee1795ea..b95d931e1a 100644 --- a/src/build/plugin-context.ts +++ b/src/build/plugin-context.ts @@ -253,6 +253,10 @@ export class PluginContext { return this.resolveFromPackagePath('.netlify/edge-functions') } + get edgeFunctionsConfigStrategy(): 'manifest' | 'inline' { + return this.useFrameworksAPI ? 'inline' : 'manifest' + } + /** Absolute path of the edge handler */ get edgeHandlerDir(): string { return join(this.edgeFunctionsDir, EDGE_HANDLER_NAME) From 7630ab42dcd02e5fb99bda3b542ec4334134f38f Mon Sep 17 00:00:00 2001 From: pieh Date: Tue, 23 Jul 2024 09:39:25 +0200 Subject: [PATCH 15/18] add a helper for how serverless function config is defined --- src/build/functions/server.ts | 2 +- src/build/plugin-context.ts | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src/build/functions/server.ts b/src/build/functions/server.ts index 4c2ea582b7..cd477be5c6 100644 --- a/src/build/functions/server.ts +++ b/src/build/functions/server.ts @@ -145,7 +145,7 @@ export const createServerHandler = async (ctx: PluginContext) => { await copyNextServerCode(ctx) await copyNextDependencies(ctx) await copyHandlerDependencies(ctx) - if (!ctx.useFrameworksAPI) { + if (ctx.serverHandlerConfigStrategy === 'manifest') { await writeHandlerManifest(ctx) } await writeHandlerFile(ctx) diff --git a/src/build/plugin-context.ts b/src/build/plugin-context.ts index 32632942d9..a0902c7d3f 100644 --- a/src/build/plugin-context.ts +++ b/src/build/plugin-context.ts @@ -244,6 +244,10 @@ export class PluginContext { return './.netlify/dist/run/handlers/server.js' } + get serverHandlerConfigStrategy(): 'manifest' | 'inline' { + return this.useFrameworksAPI ? 'inline' : 'manifest' + } + /** * Absolute path of the directory containing the files for deno edge functions * frameworks api: `.netlify/v1/edge-functions` From ee47e8b3e8153a57ce274290c28a0c9e49046f31 Mon Sep 17 00:00:00 2001 From: pieh Date: Wed, 21 Aug 2024 16:53:07 +0200 Subject: [PATCH 16/18] add next.deployStrategy otel attribute --- src/index.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/index.ts b/src/index.ts index 219a554615..2fb10da678 100644 --- a/src/index.ts +++ b/src/index.ts @@ -58,6 +58,15 @@ export const onBuild = async (options: NetlifyPluginOptions) => { verifyPublishDir(ctx) span.setAttribute('next.buildConfig', JSON.stringify(ctx.buildConfig)) + span.setAttribute( + 'next.deployStrategy', + JSON.stringify({ + useFrameworksAPI: ctx.useFrameworksAPI, + blobsStrategy: ctx.blobsStrategy, + edgeFunctionsConfigStrategy: ctx.edgeFunctionsConfigStrategy, + serverHandlerConfigStrategy: ctx.serverHandlerConfigStrategy, + }), + ) // only save the build cache if not run via the CLI if (!options.constants.IS_LOCAL) { From e3a806c8a58873f37c88b9081d090a484f07115d Mon Sep 17 00:00:00 2001 From: pieh Date: Wed, 21 Aug 2024 16:54:58 +0200 Subject: [PATCH 17/18] update new test assertion --- tests/e2e/cli-before-frameworks-api-support.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/e2e/cli-before-frameworks-api-support.test.ts b/tests/e2e/cli-before-frameworks-api-support.test.ts index 06a538ccd7..a46a41991c 100644 --- a/tests/e2e/cli-before-frameworks-api-support.test.ts +++ b/tests/e2e/cli-before-frameworks-api-support.test.ts @@ -20,7 +20,7 @@ test('should serve 404 page when requesting non existing page (no matching route expect(await page.textContent('h1')).toBe('404') expect(headers['netlify-cdn-cache-control']).toBe( - 'no-cache, no-store, max-age=0, must-revalidate', + 'no-cache, no-store, max-age=0, must-revalidate, durable', ) expect(headers['cache-control']).toBe('no-cache,no-store,max-age=0,must-revalidate') From 442441918f6c490881a62559aad0517680699bb6 Mon Sep 17 00:00:00 2001 From: pieh Date: Thu, 22 Aug 2024 14:59:03 +0200 Subject: [PATCH 18/18] validate deployment setup to ensure we are testing what we think we are testing --- tests/utils/create-e2e-fixture.ts | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/tests/utils/create-e2e-fixture.ts b/tests/utils/create-e2e-fixture.ts index d466dad246..56e871f389 100644 --- a/tests/utils/create-e2e-fixture.ts +++ b/tests/utils/create-e2e-fixture.ts @@ -24,6 +24,16 @@ export interface DeployResult { type PackageManager = 'npm' | 'pnpm' | 'yarn' | 'bun' | 'berry' +const defaultValidateDeployOutput = async (siteAbsDir: string) => { + // by default we expect Frameworks API to be used in the build + const serverHandlerDir = join(siteAbsDir, '.netlify/functions/___netlify-server-handler') + if (!existsSync(serverHandlerDir)) { + throw new Error(`Server handler not found at ${siteAbsDir}`) + } +} + +const staticExportValidateDeployOutput = defaultValidateDeployOutput //() => {} + interface E2EConfig { packageManger?: PackageManager packagePath?: string @@ -44,6 +54,10 @@ interface E2EConfig { * Site ID to deploy to. Defaults to the `NETLIFY_SITE_ID` environment variable or a default site. */ siteId?: string + /** + * + */ + validateDeployFiles?: typeof defaultValidateDeployOutput } /** @@ -84,6 +98,14 @@ export const createE2EFixture = async (fixture: string, config: E2EConfig = {}) const result = await deploySite(isolatedFixtureRoot, config) + { + const validateOutput = config.validateDeployFiles ?? defaultValidateDeployOutput + + const siteRelDir = config.cwd ?? config.packagePath ?? '' + + await validateOutput(join(isolatedFixtureRoot, siteRelDir)) + } + console.log(`🌍 Deployed site is live: ${result.url}`) deployID = result.deployID logs = result.logs @@ -307,14 +329,17 @@ async function cleanup(dest: string, deployId?: string): Promise { export const fixtureFactories = { simple: () => createE2EFixture('simple'), - outputExport: () => createE2EFixture('output-export'), + outputExport: () => + createE2EFixture('output-export', { validateDeployFiles: staticExportValidateDeployOutput }), ouputExportPublishOut: () => createE2EFixture('output-export', { publishDirectory: 'out', + validateDeployFiles: staticExportValidateDeployOutput, }), outputExportCustomDist: () => createE2EFixture('output-export-custom-dist', { publishDirectory: 'custom-dist', + validateDeployFiles: staticExportValidateDeployOutput, }), distDir: () => createE2EFixture('dist-dir', {