diff --git a/src/lib/build.cjs b/src/lib/build.cjs index d6362032f40..76e89062f23 100644 --- a/src/lib/build.cjs +++ b/src/lib/build.cjs @@ -34,6 +34,7 @@ const getBuildOptions = ({ cachedConfig, options: { context, cwd, debug, dry, js offline, cwd, featureFlags: { + edge_functions_config_export: true, functionsBundlingManifest: true, edge_functions_produce_eszip: true, project_deploy_configuration_api_use_per_function_configuration_files: true, diff --git a/src/lib/edge-functions/registry.cjs b/src/lib/edge-functions/registry.cjs index 45d9acd3afe..f329ab05da3 100644 --- a/src/lib/edge-functions/registry.cjs +++ b/src/lib/edge-functions/registry.cjs @@ -79,7 +79,12 @@ class EdgeFunctionsRegistry { /** * @type {EdgeFunctionDeclaration[]} */ - this.declarations = this.getDeclarations(config) + this.declarationsFromConfig = this.getDeclarationsFromConfig(config) + + /** + * @type {EdgeFunctionDeclaration[]} + */ + this.declarationsFromSource = [] /** * @type {Record} @@ -101,6 +106,11 @@ class EdgeFunctionsRegistry { */ this.functionPaths = new Map() + /** + * @type {EdgeFunction[]} + */ + this.functions = [] + /** * @type {Promise} */ @@ -114,13 +124,16 @@ class EdgeFunctionsRegistry { */ async build(functions) { try { - const { graph, success } = await this.runIsolate(functions, this.env) + const { functionsConfig, graph, success } = await this.runIsolate(functions, this.env, { + getFunctionsConfig: true, + }) if (!success) { throw new Error('Build error') } this.buildError = null + this.declarationsFromSource = functions.map((func, index) => ({ function: func.name, ...functionsConfig[index] })) this.processGraph(graph) } catch (error) { @@ -141,7 +154,7 @@ class EdgeFunctionsRegistry { return } - const hasDeclaration = this.declarations.some((declaration) => declaration.function === func.name) + const hasDeclaration = this.declarationsFromConfig.some((declaration) => declaration.function === func.name) // We only load the function if there's a config declaration for it. return hasDeclaration @@ -175,7 +188,7 @@ class EdgeFunctionsRegistry { } } - getDeclarations(config) { + getDeclarationsFromConfig(config) { const { edge_functions: userFunctions = [] } = config // The order is important, since we want to run user-defined functions @@ -206,10 +219,6 @@ class EdgeFunctionsRegistry { return env } - getManifest() { - return this.bundler.generateManifest({ declarations: this.declarations, functions: this.functions }) - } - async handleFileChange(path) { const matchingFunctions = new Set( [this.functionPaths.get(path), ...(this.dependencyPaths.get(path) || [])].filter(Boolean), @@ -271,39 +280,66 @@ class EdgeFunctionsRegistry { * @param {string} urlPath */ async matchURLPath(urlPath) { + const declarations = this.mergeDeclarations() + const manifest = await this.bundler.generateManifest({ + declarations, + functions: this.functions, + }) + const routes = manifest.routes.map((route) => ({ + ...route, + pattern: new RegExp(route.pattern), + })) + const functionNames = routes.filter(({ pattern }) => pattern.test(urlPath)).map((route) => route.function) + const orphanedDeclarations = await this.matchURLPathAgainstOrphanedDeclarations(urlPath) + + return { functionNames, orphanedDeclarations } + } + + async matchURLPathAgainstOrphanedDeclarations(urlPath) { // `generateManifest` will only include functions for which there is both a // function file and a config declaration, but we want to catch cases where // a config declaration exists without a matching function file. To do that // we compute a list of functions from the declarations (the `path` doesn't - // really matter) and later on match the resulting routes against the list - // of functions we have in the registry. Any functions found in the former - // but not the latter are treated as orphaned declarations. - const functions = this.declarations.map((declaration) => ({ name: declaration.function, path: '' })) + // really matter). + const functions = this.declarationsFromConfig.map((declaration) => ({ name: declaration.function, path: '' })) const manifest = await this.bundler.generateManifest({ - declarations: this.declarations, + declarations: this.declarationsFromConfig, functions, }) const routes = manifest.routes.map((route) => ({ ...route, pattern: new RegExp(route.pattern), })) - const orphanedDeclarations = new Set() const functionNames = routes - .filter(({ pattern }) => pattern.test(urlPath)) - .map((route) => { - const matchingFunction = this.functions.find(({ name }) => name === route.function) - - if (matchingFunction === undefined) { - orphanedDeclarations.add(route.function) + .filter((route) => { + const hasFunctionFile = this.functions.some((func) => func.name === route.function) - return null + if (hasFunctionFile) { + return false } - return matchingFunction.name + return route.pattern.test(urlPath) }) - .filter(Boolean) + .map((route) => route.function) - return { functionNames, orphanedDeclarations } + return functionNames + } + + // Merges declarations coming from the config and from the function sources. + mergeDeclarations() { + const declarations = [...this.declarationsFromConfig] + + this.declarationsFromSource.forEach((declarationFromSource) => { + const index = declarations.findIndex(({ function: func }) => func === declarationFromSource.function) + + if (index === -1) { + declarations.push(declarationFromSource) + } else { + declarations[index] = { ...declarations[index], ...declarationFromSource } + } + }) + + return declarations } processGraph(graph) { @@ -376,7 +412,7 @@ class EdgeFunctionsRegistry { onChange: async () => { const newConfig = await this.getUpdatedConfig() - this.declarations = this.getDeclarations(newConfig) + this.declarationsFromConfig = this.getDeclarationsFromConfig(newConfig) await this.checkForAddedOrDeletedFunctions() }, diff --git a/tests/integration/100.command.dev.test.cjs b/tests/integration/100.command.dev.test.cjs index 6fe07f18a31..60487dc7574 100644 --- a/tests/integration/100.command.dev.test.cjs +++ b/tests/integration/100.command.dev.test.cjs @@ -629,6 +629,55 @@ test('should detect deleted edge functions', async (t) => { }) }) +test('should respect in-source configuration from edge functions', async (t) => { + await withSiteBuilder('site-with-edge-functions', async (builder) => { + const publicDir = 'public' + await builder + .withNetlifyToml({ + config: { + build: { + publish: publicDir, + edge_functions: 'netlify/edge-functions', + }, + }, + }) + .withEdgeFunction({ + config: () => ({ path: '/hello-1' }), + handler: () => new Response('Hello world'), + name: 'hello', + }) + + await builder.buildAsync() + + await withDevServer({ cwd: builder.directory }, async ({ port }) => { + const res1 = await got(`http://localhost:${port}/hello-1`, { throwHttpErrors: false }) + + t.is(res1.statusCode, 200) + t.is(res1.body, 'Hello world') + + await builder + .withEdgeFunction({ + config: () => ({ path: '/hello-2' }), + handler: () => new Response('Hello world'), + name: 'hello', + }) + .buildAsync() + + const DETECT_FILE_CHANGE_DELAY = 500 + await pause(DETECT_FILE_CHANGE_DELAY) + + const res2 = await got(`http://localhost:${port}/hello-1`, { throwHttpErrors: false }) + + t.is(res2.statusCode, 404) + + const res3 = await got(`http://localhost:${port}/hello-2`, { throwHttpErrors: false }) + + t.is(res3.statusCode, 200) + t.is(res3.body, 'Hello world') + }) + }) +}) + test('should have only allowed environment variables set', async (t) => { const siteInfo = { account_slug: 'test-account', diff --git a/tests/integration/utils/site-builder.cjs b/tests/integration/utils/site-builder.cjs index beab2cb60c9..b9bba135f20 100644 --- a/tests/integration/utils/site-builder.cjs +++ b/tests/integration/utils/site-builder.cjs @@ -75,11 +75,16 @@ const createSiteBuilder = ({ siteName }) => { }) return builder }, - withEdgeFunction: ({ handler, internal = false, name = 'function', pathPrefix = '' }) => { + withEdgeFunction: ({ config, handler, internal = false, name = 'function', pathPrefix = '' }) => { const edgeFunctionsDirectory = internal ? '.netlify/edge-functions' : 'netlify/edge-functions' const dest = path.join(directory, pathPrefix, edgeFunctionsDirectory, `${name}.js`) tasks.push(async () => { - const content = typeof handler === 'string' ? handler : `export default ${handler.toString()}` + let content = typeof handler === 'string' ? handler : `export default ${handler.toString()}` + + if (config) { + content += `;export const config = ${config.toString()}` + } + await ensureDir(path.dirname(dest)) await writeFile(dest, content) })