diff --git a/src/build/functions/edge.ts b/src/build/functions/edge.ts index af6405b57c..f69ab5dbde 100644 --- a/src/build/functions/edge.ts +++ b/src/build/functions/edge.ts @@ -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 => { const files = await glob('edge-runtime/**/*', { cwd: ctx.pluginDir, @@ -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) @@ -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 @@ -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/htmlrewriter@v1.0.0/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/htmlrewriter@v1.0.0/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 ( @@ -161,34 +181,18 @@ const copyHandlerDependencies = async ( await writeFile(outputFile, parts.join('\n')) } -const createEdgeHandler = async (ctx: PluginContext, definition: NextDefinition): Promise => { +const createEdgeHandler = async ( + ctx: PluginContext, + definition: NextDefinition, + { isFrameworksAPI }: { isFrameworksAPI: boolean }, +): Promise => { await copyHandlerDependencies(ctx, definition) - await writeHandlerFile(ctx, definition) + await writeHandlerFile(ctx, definition, { isFrameworksAPI }) } const getHandlerName = ({ name }: Pick): string => `${EDGE_HANDLER_NAME}-${name.replace(/\W/g, '-')}` -const buildHandlerDefinition = ( - ctx: PluginContext, - { name, matchers, page }: NextDefinition, -): Array => { - 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 }) } @@ -196,12 +200,36 @@ export const clearStaleEdgeHandlers = async (ctx: PluginContext) => { 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) } diff --git a/src/build/functions/server.ts b/src/build/functions/server.ts index bd38a82162..37bb102a7e 100644 --- a/src/build/functions/server.ts +++ b/src/build/functions/server.ts @@ -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) => { return Object.entries(variables).reduce((acc, [key, value]) => { return acc.replaceAll(key, value) @@ -107,6 +89,23 @@ const getHandlerFile = async (ctx: PluginContext): Promise => { const templateVariables: Record = { '{{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) { @@ -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) diff --git a/src/build/helpers/deployment.ts b/src/build/helpers/deployment.ts new file mode 100644 index 0000000000..f146ba26e0 --- /dev/null +++ b/src/build/helpers/deployment.ts @@ -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' diff --git a/src/build/plugin-context.ts b/src/build/plugin-context.ts index 9148d0dd56..133b42f508 100644 --- a/src/build/plugin-context.ts +++ b/src/build/plugin-context.ts @@ -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' @@ -142,6 +149,10 @@ 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') } @@ -149,6 +160,10 @@ export class PluginContext { 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' } @@ -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') } @@ -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') } diff --git a/src/build/templates/handler-monorepo.tmpl.js b/src/build/templates/handler-monorepo.tmpl.js index 82bd13cbf3..5319aab929 100644 --- a/src/build/templates/handler-monorepo.tmpl.js +++ b/src/build/templates/handler-monorepo.tmpl.js @@ -46,7 +46,4 @@ export default async function (req, context) { return handlerResponse } -export const config = { - path: '/*', - preferStatic: true, -} +{{handlerConfig}} diff --git a/src/build/templates/handler.tmpl.js b/src/build/templates/handler.tmpl.js index ccdf332036..4862c32364 100644 --- a/src/build/templates/handler.tmpl.js +++ b/src/build/templates/handler.tmpl.js @@ -40,7 +40,4 @@ export default async function handler(req, context) { return handlerResponse } -export const config = { - path: '/*', - preferStatic: true, -} +{{handlerConfig}} diff --git a/src/run/storage/regional-blob-store.cts b/src/run/storage/regional-blob-store.cts index 62aec1db4b..d960f669d7 100644 --- a/src/run/storage/regional-blob-store.cts +++ b/src/run/storage/regional-blob-store.cts @@ -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', }) } diff --git a/tests/integration/simple-app.test.ts b/tests/integration/simple-app.test.ts index 6fc02f387c..9c2740eaa2 100644 --- a/tests/integration/simple-app.test.ts +++ b/tests/integration/simple-app.test.ts @@ -32,6 +32,7 @@ import { decodeBlobKey, generateRandomObjectID, getBlobEntries, + getFrameworksAPIBlobEntries, startMockBlobStore, } from '../utils/helpers.js' import { nextVersionSatisfies } from '../utils/next-version-helpers.mjs' @@ -90,6 +91,59 @@ afterEach(() => { vi.unstubAllEnvs() }) +describe('Frameworks API', () => { + test('Test that the simple next app is working', async (ctx) => { + vi.stubEnv('NETLIFY_BUILD_VERSION', '29.41.5') + await createFixture('simple', ctx) + await runPlugin(ctx) + // check if the blob entries where successful set on the build plugin + const blobEntries = await getFrameworksAPIBlobEntries(ctx) + expect(blobEntries.map(({ key }) => key).sort()).toEqual([ + '404/blob', + '404.html/blob', + '500.html/blob', + 'api/cached-permanent/blob', + 'api/cached-revalidate/blob', + 'config-redirect/blob', + 'config-redirect/dest/blob', + 'config-rewrite/blob', + 'config-rewrite/dest/blob', + 'fully-static.html/blob', + 'image/local/blob', + 'image/migration-from-v4-runtime/blob', + 'image/remote-domain/blob', + 'image/remote-pattern-1/blob', + 'image/remote-pattern-2/blob', + 'index/blob', + 'other/blob', + 'route-resolves-to-not-found/blob', + ]) + + // test the function call + const home = await invokeFunction(ctx) + expect(home.statusCode).toBe(200) + expect(load(home.body)('h1').text()).toBe('Home') + + const other = await invokeFunction(ctx, { url: 'other' }) + expect(other.statusCode).toBe(200) + expect(load(other.body)('h1').text()).toBe('Other') + + const notFound = await invokeFunction(ctx, { url: 'route-resolves-to-not-found' }) + expect(notFound.statusCode).toBe(404) + // depending on Next version code found in 404 page can be either NEXT_NOT_FOUND or NEXT_HTTP_ERROR_FALLBACK + // see https://github.com/vercel/next.js/commit/997105d27ebc7bfe01b7e907cd659e5e056e637c that moved from NEXT_NOT_FOUND to NEXT_HTTP_ERROR_FALLBACK + expect( + notFound.body?.includes('NEXT_NOT_FOUND') || + notFound.body?.includes('NEXT_HTTP_ERROR_FALLBACK'), + '404 page should contain NEXT_NOT_FOUND or NEXT_HTTP_ERROR_FALLBACK code', + ).toBe(true) + + const notExisting = await invokeFunction(ctx, { url: 'non-exisitng' }) + expect(notExisting.statusCode).toBe(404) + expect(load(notExisting.body)('h1').text()).toBe('404 Not Found') + }) +}) + test('Test that the simple next app is working', async (ctx) => { await createFixture('simple', ctx) await runPlugin(ctx) diff --git a/tests/utils/fixture.ts b/tests/utils/fixture.ts index 3ddd787dcb..c4e9ac785c 100644 --- a/tests/utils/fixture.ts +++ b/tests/utils/fixture.ts @@ -281,7 +281,9 @@ export async function runPlugin( // bundle the function to get the bootstrap layer and all the important parts await zipFunctions([internalSrcFolder], ctx.functionDist, { basePath: ctx.cwd, - manifest: join(ctx.functionDist, 'manifest.json'), + manifest: base.shouldUseFrameworksAPI + ? undefined + : join(ctx.functionDist, 'manifest.json'), repositoryRoot: ctx.cwd, configFileDirectories: [internalSrcFolder], internalSrcFolder, @@ -330,28 +332,65 @@ 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, - }) - - 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 }) - }), - ) +export async function uploadBlobs(ctx: FixtureTestContext, pluginContext: PluginContext) { + if (pluginContext.shouldUseFrameworksAPI) { + 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(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, + }) + }), + ) + 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, + }) + }), + ) + } } export async function invokeFunction( diff --git a/tests/utils/helpers.ts b/tests/utils/helpers.ts index b540aea7c3..dafc7c47fd 100644 --- a/tests/utils/helpers.ts +++ b/tests/utils/helpers.ts @@ -70,6 +70,15 @@ export const getBlobEntries = async (ctx: FixtureTestContext) => { return blobs } +export const getFrameworksAPIBlobEntries = async (ctx: FixtureTestContext) => { + const blobDir = join(ctx.cwd, '.netlify', 'v1', 'blobs', 'deploy') + const files = await glob('**/*', { + dot: true, + cwd: blobDir, + }) + return files.map((key) => ({ key, etag: '', last_modified: '', size: 0 })) +} + export function getBlobServerGets(ctx: FixtureTestContext, predicate?: (key: string) => boolean) { const isString = (arg: unknown): arg is string => typeof arg === 'string' return ctx.blobServerGetSpy.mock.calls