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/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/prerendered.ts b/src/build/content/prerendered.ts index 9f1ae2fe75..a05f023bef 100644 --- a/src/build/content/prerendered.ts +++ b/src/build/content/prerendered.ts @@ -1,5 +1,5 @@ import { existsSync } from 'node:fs' -import { mkdir, readFile, writeFile } from 'node:fs/promises' +import { mkdir, readFile } from 'node:fs/promises' import { join } from 'node:path' import { trace } from '@opentelemetry/api' @@ -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)) const entry = JSON.stringify({ lastModified, value, } satisfies NetlifyCacheHandlerValue) - - await writeFile(path, entry, 'utf-8') + await ctx.setBlob(route, entry) } /** diff --git a/src/build/content/server.ts b/src/build/content/server.ts index ab7f4fc3cf..6cc58ee63f 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/content/static.ts b/src/build/content/static.ts index 4079695bd4..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 { 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,7 +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) - await writeFile(join(destDir, await encodeBlobKey(path)), html, 'utf-8') + await ctx.setBlob(path, html) }), ) } catch (error) { diff --git a/src/build/functions/edge.ts b/src/build/functions/edge.ts index 4c95ad1353..51520645a8 100644 --- a/src/build/functions/edge.ts +++ b/src/build/functions/edge.ts @@ -1,7 +1,7 @@ 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, 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' @@ -53,7 +53,23 @@ const augmentMatchers = ( }) } -const writeHandlerFile = async (ctx: PluginContext, { matchers, name }: NextDefinition) => { +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 }) const handlerDirectory = join(ctx.edgeFunctionsDir, handlerName) @@ -63,6 +79,8 @@ 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(matchers)) @@ -82,6 +100,14 @@ const writeHandlerFile = async (ctx: PluginContext, { matchers, name }: NextDefi JSON.stringify(minimalNextConfig), ) + 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. await writeFile( @@ -90,7 +116,7 @@ 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); - `, + ${isc}`, ) } @@ -136,26 +162,16 @@ 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, ): 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, })) } @@ -171,10 +187,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, + if (ctx.edgeFunctionsConfigStrategy === 'manifest') { + const netlifyDefinitions = nextDefinitions.flatMap((def) => buildHandlerDefinition(ctx, def)) + const netlifyManifest: Manifest = { + version: 1, + functions: netlifyDefinitions, + } + await writeEdgeManifest(ctx, netlifyManifest) } - await writeEdgeManifest(ctx, netlifyManifest) } diff --git a/src/build/functions/server.ts b/src/build/functions/server.ts index bd38a82162..cd477be5c6 100644 --- a/src/build/functions/server.ts +++ b/src/build/functions/server.ts @@ -105,7 +105,9 @@ 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, } // 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 @@ -143,7 +145,9 @@ export const createServerHandler = async (ctx: PluginContext) => { await copyNextServerCode(ctx) await copyNextDependencies(ctx) await copyHandlerDependencies(ctx) - await writeHandlerManifest(ctx) + if (ctx.serverHandlerConfigStrategy === 'manifest') { + await writeHandlerManifest(ctx) + } await writeHandlerFile(ctx) await verifyHandlerDirStructure(ctx) 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')) +}) diff --git a/src/build/plugin-context.ts b/src/build/plugin-context.ts index 9b0ecc199c..a0902c7d3f 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,36 +139,85 @@ 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 { - 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 { - if (!(this.featureFlags || {})['next-runtime-regional-blobs']) { - return false + #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 + const REQUIRED_BUILD_VERSION = '>=29.50.5' + this.#useFrameworksAPI = satisfies(this.buildVersion, REQUIRED_BUILD_VERSION, { + includePrerelease: true, + }) + } + + return this.#useFrameworksAPI + } + + #blobsStrategy: PluginContext['blobsStrategy'] | null = null + get blobsStrategy(): 'legacy' | 'regional' | '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 }) + return this.#blobsStrategy } /** * 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) { + return this.resolveFromPackagePath('.netlify/v1/functions') + } + return this.resolveFromPackagePath('.netlify/functions-internal') } @@ -193,14 +244,27 @@ 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 - * `.netlify/edge-functions` + * frameworks api: `.netlify/v1/edge-functions` + * legacy/default: `.netlify/edge-functions` */ get edgeFunctionsDir(): string { + if (this.useFrameworksAPI) { + return this.resolveFromPackagePath('.netlify/v1/edge-functions') + } + 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) 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}}', } 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) { 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..a46a41991c --- /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, durable', + ) + 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 9718e28711..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', { @@ -358,6 +383,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', diff --git a/tests/utils/fixture.ts b/tests/utils/fixture.ts index 18ebb328fd..24ed56156b 100644 --- a/tests/utils/fixture.ts +++ b/tests/utils/fixture.ts @@ -198,7 +198,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', }, @@ -312,28 +312,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 = {}