diff --git a/src/build/functions/edge.ts b/src/build/functions/edge.ts index f3f534c35b..1550538279 100644 --- a/src/build/functions/edge.ts +++ b/src/build/functions/edge.ts @@ -1,5 +1,5 @@ -import { cp, mkdir, readFile, rm, writeFile } from 'node:fs/promises' -import { dirname, join } from 'node:path' +import { cp, mkdir, readdir, readFile, rm, writeFile } from 'node:fs/promises' +import { dirname, join, parse as parsePath } from 'node:path' import type { Manifest, ManifestFunction } from '@netlify/edge-functions' import { glob } from 'fast-glob' @@ -8,9 +8,21 @@ import { pathToRegexp } from 'path-to-regexp' import { EDGE_HANDLER_NAME, PluginContext } from '../plugin-context.js' +type ManifestFunctionWithGenerator = ManifestFunction & { generator?: string } + +const getEdgeManifestPath = (ctx: PluginContext) => join(ctx.edgeFunctionsDir, 'manifest.json') + 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)) + await writeFile(getEdgeManifestPath(ctx), JSON.stringify(manifest, null, 2)) +} + +const readEdgeManifest = async (ctx: PluginContext) => { + try { + return JSON.parse(await readFile(getEdgeManifestPath(ctx), 'utf-8')) as Manifest + } catch { + return null + } } const copyRuntime = async (ctx: PluginContext, handlerDirectory: string): Promise<void> => { @@ -145,7 +157,7 @@ const getHandlerName = ({ name }: Pick<NextDefinition, 'name'>): string => const buildHandlerDefinition = ( ctx: PluginContext, { name, matchers, page }: NextDefinition, -): Array<ManifestFunction> => { +): Array<ManifestFunctionWithGenerator> => { const fun = getHandlerName({ name }) const funName = name.endsWith('middleware') ? 'Next.js Middleware Handler' @@ -162,8 +174,39 @@ const buildHandlerDefinition = ( })) } +const clearStaleEdgeHandlers = async (ctx: PluginContext) => { + const previousManifest = await readEdgeManifest(ctx) + if (!previousManifest) { + return [] + } + + const uniqueNextRuntimeFunctions = new Set<string>() + const nonNextRuntimeFunctions: ManifestFunctionWithGenerator[] = [] + + for (const fn of previousManifest.functions as ManifestFunctionWithGenerator[]) { + if (fn?.generator?.startsWith(ctx.pluginName)) { + uniqueNextRuntimeFunctions.add(fn.function) + } else { + nonNextRuntimeFunctions.push(fn) + } + } + + if (uniqueNextRuntimeFunctions.size === 0) { + return nonNextRuntimeFunctions + } + + for (const fileOrDir of await readdir(ctx.edgeFunctionsDir, { withFileTypes: true })) { + const nameWithoutExtension = parsePath(fileOrDir.name).name + + if (uniqueNextRuntimeFunctions.has(nameWithoutExtension)) { + await rm(join(ctx.edgeFunctionsDir, fileOrDir.name), { recursive: true, force: true }) + } + } + return nonNextRuntimeFunctions +} + export const createEdgeHandlers = async (ctx: PluginContext) => { - await rm(ctx.edgeFunctionsDir, { recursive: true, force: true }) + const nonNextRuntimeFunctions = await clearStaleEdgeHandlers(ctx) const nextManifest = await ctx.getMiddlewareManifest() const nextDefinitions = [ @@ -175,7 +218,7 @@ export const createEdgeHandlers = async (ctx: PluginContext) => { const netlifyDefinitions = nextDefinitions.flatMap((def) => buildHandlerDefinition(ctx, def)) const netlifyManifest: Manifest = { version: 1, - functions: netlifyDefinitions, + functions: [...nonNextRuntimeFunctions, ...netlifyDefinitions], } await writeEdgeManifest(ctx, netlifyManifest) } diff --git a/src/build/functions/server.ts b/src/build/functions/server.ts index e34997ee06..cd1097783f 100644 --- a/src/build/functions/server.ts +++ b/src/build/functions/server.ts @@ -1,5 +1,5 @@ -import { cp, mkdir, readFile, rm, writeFile } from 'node:fs/promises' -import { join, relative } from 'node:path' +import { cp, mkdir, readdir, readFile, rm, writeFile } from 'node:fs/promises' +import { join, parse as parsePath, relative } from 'node:path' import { join as posixJoin } from 'node:path/posix' import { trace } from '@opentelemetry/api' @@ -127,12 +127,52 @@ const writeHandlerFile = async (ctx: PluginContext) => { await writeFile(join(ctx.serverHandlerRootDir, `${SERVER_HANDLER_NAME}.mjs`), handler) } +const clearStaleServerHandlers = async (ctx: PluginContext) => { + const potentialServerlessFunctionConfigFiles = await glob('**/*.json', { + deep: 2, + cwd: ctx.serverFunctionsDir, + }) + + const toRemove = new Set<string>() + + for (const potentialServerlessFunctionConfigFile of potentialServerlessFunctionConfigFiles) { + try { + const functionConfig = JSON.parse( + await readFile( + join(ctx.serverFunctionsDir, potentialServerlessFunctionConfigFile), + 'utf-8', + ), + ) + + if (functionConfig?.config?.generator?.startsWith(ctx.pluginName)) { + const parsedPath = parsePath(potentialServerlessFunctionConfigFile) + + toRemove.add(parsedPath.dir || parsedPath.name) + } + } catch { + // this might be malformatted json or json that doesn't represent function configuration + // so we just skip it in case of errors + } + } + + if (toRemove.size === 0) { + return + } + + for (const fileOrDir of await readdir(ctx.serverFunctionsDir, { withFileTypes: true })) { + const nameWithoutExtension = parsePath(fileOrDir.name).name + + if (toRemove.has(nameWithoutExtension)) { + await rm(join(ctx.serverFunctionsDir, fileOrDir.name), { recursive: true, force: true }) + } + } +} /** * Create a Netlify function to run the Next.js server */ export const createServerHandler = async (ctx: PluginContext) => { await tracer.withActiveSpan('createServerHandler', async () => { - await rm(ctx.serverFunctionsDir, { recursive: true, force: true }) + await clearStaleServerHandlers(ctx) await mkdir(join(ctx.serverHandlerDir, '.netlify'), { recursive: true }) await copyNextServerCode(ctx) diff --git a/tests/e2e/with-integrations.test.ts b/tests/e2e/with-integrations.test.ts new file mode 100644 index 0000000000..5df1582ff9 --- /dev/null +++ b/tests/e2e/with-integrations.test.ts @@ -0,0 +1,56 @@ +import { expect } from '@playwright/test' +import { test } from '../utils/playwright-helpers.js' + +test('Renders the Home page correctly', async ({ page, withIntegrations }) => { + await page.goto(withIntegrations.url) + + expect(page.locator('body')).toHaveText('Hello World') +}) + +test.describe('Should clear stale functions produced by previous builds by @netlify/plugin-nextjs', () => { + test('Serverless functions', async ({ page, withIntegrations }) => { + const response1 = await page.goto(new URL('/test/serverless/v4', withIntegrations.url).href) + expect(response1?.status()).toBe(404) + + const response2 = await page.goto(new URL('/test/serverless/v5', withIntegrations.url).href) + expect(response2?.status()).toBe(404) + }) + + test('Edge functions', async ({ page, withIntegrations }) => { + const response1 = await page.goto(new URL('/test/edge/v4', withIntegrations.url).href) + expect(response1?.status()).toBe(404) + + const response2 = await page.goto(new URL('/test/edge/v5', withIntegrations.url).href) + expect(response2?.status()).toBe(404) + }) +}) + +test.describe('Should keep functions produced by other build plugins', () => { + test('Serverless functions', async ({ page, withIntegrations }) => { + const response1 = await page.goto( + new URL('/test/serverless/integration-with-json-config', withIntegrations.url).href, + ) + expect(response1?.status()).toBe(200) + expect(await response1?.text()).toBe('Hello from /test/serverless/integration-with-json-config') + + const response2 = await page.goto( + new URL('/test/serverless/integration-with-json-config', withIntegrations.url).href, + ) + expect(response2?.status()).toBe(200) + expect(await response2?.text()).toBe('Hello from /test/serverless/integration-with-json-config') + }) + + test('Edge functions', async ({ page, withIntegrations }) => { + const response1 = await page.goto( + new URL('/test/edge/integration-in-manifest', withIntegrations.url).href, + ) + expect(response1?.status()).toBe(200) + expect(await response1?.text()).toBe('Hello from /test/edge/integration-in-manifest') + + const response2 = await page.goto( + new URL('/test/edge/integration-not-in-manifest', withIntegrations.url).href, + ) + expect(response2?.status()).toBe(200) + expect(await response2?.text()).toBe('Hello from /test/edge/integration-not-in-manifest') + }) +}) diff --git a/tests/fixtures/with-integrations/netlify.toml b/tests/fixtures/with-integrations/netlify.toml new file mode 100644 index 0000000000..4c48c42e10 --- /dev/null +++ b/tests/fixtures/with-integrations/netlify.toml @@ -0,0 +1,2 @@ +[[plugins]] +package = "/plugins/create-other-functions" diff --git a/tests/fixtures/with-integrations/next.config.js b/tests/fixtures/with-integrations/next.config.js new file mode 100644 index 0000000000..8d2a9bf37a --- /dev/null +++ b/tests/fixtures/with-integrations/next.config.js @@ -0,0 +1,8 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = { + eslint: { + ignoreDuringBuilds: true, + }, +} + +module.exports = nextConfig diff --git a/tests/fixtures/with-integrations/package.json b/tests/fixtures/with-integrations/package.json new file mode 100644 index 0000000000..4c3fe8a3ac --- /dev/null +++ b/tests/fixtures/with-integrations/package.json @@ -0,0 +1,13 @@ +{ + "name": "with-integrations", + "version": "0.1.0", + "private": true, + "scripts": { + "build": "next build" + }, + "dependencies": { + "next": "latest", + "react": "18.2.0", + "react-dom": "18.2.0" + } +} diff --git a/tests/fixtures/with-integrations/pages/index.js b/tests/fixtures/with-integrations/pages/index.js new file mode 100644 index 0000000000..c76736b1d1 --- /dev/null +++ b/tests/fixtures/with-integrations/pages/index.js @@ -0,0 +1,7 @@ +export default function Home() { + return ( + <main> + <h1>Hello World</h1> + </main> + ) +} diff --git a/tests/fixtures/with-integrations/plugins/create-other-functions/edge-functions/integration-in-manifest.mjs b/tests/fixtures/with-integrations/plugins/create-other-functions/edge-functions/integration-in-manifest.mjs new file mode 100644 index 0000000000..fa0e08423a --- /dev/null +++ b/tests/fixtures/with-integrations/plugins/create-other-functions/edge-functions/integration-in-manifest.mjs @@ -0,0 +1,3 @@ +export default function handler() { + return new Response('Hello from /test/edge/integration-in-manifest', { status: 200 }) +} diff --git a/tests/fixtures/with-integrations/plugins/create-other-functions/edge-functions/integration-not-in-manifest.mjs b/tests/fixtures/with-integrations/plugins/create-other-functions/edge-functions/integration-not-in-manifest.mjs new file mode 100644 index 0000000000..6ae83beeae --- /dev/null +++ b/tests/fixtures/with-integrations/plugins/create-other-functions/edge-functions/integration-not-in-manifest.mjs @@ -0,0 +1,7 @@ +export default function handler() { + return new Response('Hello from /test/edge/integration-not-in-manifest', { status: 200 }) +} + +export const config = { + path: '/test/edge/integration-not-in-manifest', +} diff --git a/tests/fixtures/with-integrations/plugins/create-other-functions/edge-functions/manifest.json b/tests/fixtures/with-integrations/plugins/create-other-functions/edge-functions/manifest.json new file mode 100644 index 0000000000..e666c230ed --- /dev/null +++ b/tests/fixtures/with-integrations/plugins/create-other-functions/edge-functions/manifest.json @@ -0,0 +1,24 @@ +{ + "functions": [ + { + "function": "next-runtime-v4", + "name": "next-runtime-v4", + "path": "/test/edge/v4", + "generator": "@netlify/plugin-nextjs@4.41.3" + }, + { + "function": "next-runtime-v5", + "name": "next-runtime-v5", + "path": "/test/edge/v5", + "generator": "@netlify/plugin-nextjs@5.3.3" + }, + { + "function": "integration-in-manifest", + "name": "integration-in-manifest", + "path": "/test/edge/integration-in-manifest", + "generator": "@netlify/some-integration@1.0.0" + } + ], + "layers": [], + "version": 1 +} diff --git a/tests/fixtures/with-integrations/plugins/create-other-functions/edge-functions/next-runtime-v4/next-runtime-v4.mjs b/tests/fixtures/with-integrations/plugins/create-other-functions/edge-functions/next-runtime-v4/next-runtime-v4.mjs new file mode 100644 index 0000000000..049598094a --- /dev/null +++ b/tests/fixtures/with-integrations/plugins/create-other-functions/edge-functions/next-runtime-v4/next-runtime-v4.mjs @@ -0,0 +1,5 @@ +export default function handler() { + return new Response('Hello from edge functions generated by @netlify/plugin-nextjs@4', { + status: 200, + }) +} diff --git a/tests/fixtures/with-integrations/plugins/create-other-functions/edge-functions/next-runtime-v5.mjs b/tests/fixtures/with-integrations/plugins/create-other-functions/edge-functions/next-runtime-v5.mjs new file mode 100644 index 0000000000..c9d66887dc --- /dev/null +++ b/tests/fixtures/with-integrations/plugins/create-other-functions/edge-functions/next-runtime-v5.mjs @@ -0,0 +1,5 @@ +export default function handler() { + return new Response('Hello from edge functions generated by @netlify/plugin-nextjs@5', { + status: 200, + }) +} diff --git a/tests/fixtures/with-integrations/plugins/create-other-functions/functions-internal/integration-no-json-config.mjs b/tests/fixtures/with-integrations/plugins/create-other-functions/functions-internal/integration-no-json-config.mjs new file mode 100644 index 0000000000..ebde11d0db --- /dev/null +++ b/tests/fixtures/with-integrations/plugins/create-other-functions/functions-internal/integration-no-json-config.mjs @@ -0,0 +1,7 @@ +export default function handler() { + return new Response('Hello from /test/serverless/integration-with-json-config', { status: 200 }) +} + +export const config = { + path: '/test/serverless/integration-with-json-config', +} diff --git a/tests/fixtures/with-integrations/plugins/create-other-functions/functions-internal/integration-with-json-config.json b/tests/fixtures/with-integrations/plugins/create-other-functions/functions-internal/integration-with-json-config.json new file mode 100644 index 0000000000..e8c468d4a8 --- /dev/null +++ b/tests/fixtures/with-integrations/plugins/create-other-functions/functions-internal/integration-with-json-config.json @@ -0,0 +1,7 @@ +{ + "config": { + "name": "Some integration", + "generator": "@netlify/some-integration@1.0.0" + }, + "version": 1 +} diff --git a/tests/fixtures/with-integrations/plugins/create-other-functions/functions-internal/integration-with-json-config.mjs b/tests/fixtures/with-integrations/plugins/create-other-functions/functions-internal/integration-with-json-config.mjs new file mode 100644 index 0000000000..cdc63ea00c --- /dev/null +++ b/tests/fixtures/with-integrations/plugins/create-other-functions/functions-internal/integration-with-json-config.mjs @@ -0,0 +1,7 @@ +export default function handler() { + return new Response('Hello from integration generated serverless function', { status: 200 }) +} + +export const config = { + path: '/test/serverless/integration-with-json-config', +} diff --git a/tests/fixtures/with-integrations/plugins/create-other-functions/functions-internal/next-runtime-v4/next-runtime-v4.json b/tests/fixtures/with-integrations/plugins/create-other-functions/functions-internal/next-runtime-v4/next-runtime-v4.json new file mode 100644 index 0000000000..bd03425470 --- /dev/null +++ b/tests/fixtures/with-integrations/plugins/create-other-functions/functions-internal/next-runtime-v4/next-runtime-v4.json @@ -0,0 +1,6 @@ +{ + "config": { + "generator": "@netlify/plugin-nextjs@4.41.3" + }, + "version": 1 +} diff --git a/tests/fixtures/with-integrations/plugins/create-other-functions/functions-internal/next-runtime-v4/next-runtime-v4.mjs b/tests/fixtures/with-integrations/plugins/create-other-functions/functions-internal/next-runtime-v4/next-runtime-v4.mjs new file mode 100644 index 0000000000..a511b8aebb --- /dev/null +++ b/tests/fixtures/with-integrations/plugins/create-other-functions/functions-internal/next-runtime-v4/next-runtime-v4.mjs @@ -0,0 +1,9 @@ +export default function handler() { + return new Response('Hello from edge functions generated by @netlify/plugin-nextjs@5', { + status: 200, + }) +} + +export const config = { + path: '/test/serverless/v4', +} diff --git a/tests/fixtures/with-integrations/plugins/create-other-functions/functions-internal/next-runtime-v5.json b/tests/fixtures/with-integrations/plugins/create-other-functions/functions-internal/next-runtime-v5.json new file mode 100644 index 0000000000..5745d57acd --- /dev/null +++ b/tests/fixtures/with-integrations/plugins/create-other-functions/functions-internal/next-runtime-v5.json @@ -0,0 +1,6 @@ +{ + "config": { + "generator": "@netlify/plugin-nextjs@5.3.3" + }, + "version": 1 +} diff --git a/tests/fixtures/with-integrations/plugins/create-other-functions/functions-internal/next-runtime-v5.mjs b/tests/fixtures/with-integrations/plugins/create-other-functions/functions-internal/next-runtime-v5.mjs new file mode 100644 index 0000000000..771a820020 --- /dev/null +++ b/tests/fixtures/with-integrations/plugins/create-other-functions/functions-internal/next-runtime-v5.mjs @@ -0,0 +1,9 @@ +export default function handler() { + return new Response('Hello from edge functions generated by @netlify/plugin-nextjs@5', { + status: 200, + }) +} + +export const config = { + path: '/test/serverless/v5', +} diff --git a/tests/fixtures/with-integrations/plugins/create-other-functions/index.js b/tests/fixtures/with-integrations/plugins/create-other-functions/index.js new file mode 100644 index 0000000000..495fab67a3 --- /dev/null +++ b/tests/fixtures/with-integrations/plugins/create-other-functions/index.js @@ -0,0 +1,14 @@ +const { cp } = require('node:fs/promises') +const { join } = require('node:path') + +exports.onPreBuild = async function onPreBuild({ + constants: { INTERNAL_FUNCTIONS_SRC, INTERNAL_EDGE_FUNCTIONS_SRC }, +}) { + // copying functions: + // - mocked functions to represent stale function produced by @netlify/plugin-nextjs (specified by `generator`) for v4 and v5 of runtime + // - mocked functions to represent functions produced by other build plugins (either specified by `generator` or missing `generator` metadata) + await Promise.all([ + cp(join(__dirname, 'edge-functions'), INTERNAL_EDGE_FUNCTIONS_SRC, { recursive: true }), + cp(join(__dirname, 'functions-internal'), INTERNAL_FUNCTIONS_SRC, { recursive: true }), + ]) +} diff --git a/tests/fixtures/with-integrations/plugins/create-other-functions/manifest.yml b/tests/fixtures/with-integrations/plugins/create-other-functions/manifest.yml new file mode 100644 index 0000000000..2e34b6ffbc --- /dev/null +++ b/tests/fixtures/with-integrations/plugins/create-other-functions/manifest.yml @@ -0,0 +1 @@ +name: 'simulate-integration' diff --git a/tests/utils/create-e2e-fixture.ts b/tests/utils/create-e2e-fixture.ts index eab7dc4f79..4ca773ebde 100644 --- a/tests/utils/create-e2e-fixture.ts +++ b/tests/utils/create-e2e-fixture.ts @@ -353,6 +353,7 @@ export const fixtureFactories = { createE2EFixture('cli-before-regional-blobs-support', { expectedCliVersion: '17.21.1', }), + withIntegrations: () => createE2EFixture('with-integrations'), yarnMonorepoWithPnpmLinker: () => createE2EFixture('yarn-monorepo-with-pnpm-linker', { packageManger: 'berry',