diff --git a/src/lib/edge-functions/bootstrap.mjs b/src/lib/edge-functions/bootstrap.mjs index e32d109e53d..4e00302db03 100644 --- a/src/lib/edge-functions/bootstrap.mjs +++ b/src/lib/edge-functions/bootstrap.mjs @@ -1,5 +1,5 @@ import { env } from 'process' -const latestBootstrapURL = 'https://64109c4552d9020008b9dadc--edge.netlify.com/bootstrap/index-combined.ts' +const latestBootstrapURL = 'https://6419767d9dc968000848742a--edge.netlify.com/bootstrap/index-combined.ts' export const getBootstrapURL = () => env.NETLIFY_EDGE_BOOTSTRAP || latestBootstrapURL diff --git a/src/lib/edge-functions/headers.mjs b/src/lib/edge-functions/headers.mjs index cf290d01ff8..6467d5c6393 100644 --- a/src/lib/edge-functions/headers.mjs +++ b/src/lib/edge-functions/headers.mjs @@ -1,6 +1,11 @@ -const headers = { +// @ts-check +import { Buffer } from 'buffer' + +export const headers = { + FeatureFlags: 'x-nf-feature-flags', ForwardedHost: 'x-forwarded-host', Functions: 'x-nf-edge-functions', + InvocationMetadata: 'x-nf-edge-functions-metadata', Geo: 'x-nf-geo', Passthrough: 'x-nf-passthrough', IP: 'x-nf-client-connection-ip', @@ -8,4 +13,24 @@ const headers = { DebugLogging: 'x-nf-debug-logging', } -export default headers +/** + * Takes an array of feature flags and produces a Base64-encoded JSON object + * that the bootstrap layer can understand. + * + * @param {Array} featureFlags + * @returns {string} + */ +export const getFeatureFlagsHeader = (featureFlags) => { + const featureFlagsObject = featureFlags.reduce((acc, flagName) => ({ ...acc, [flagName]: true }), {}) + + return Buffer.from(JSON.stringify(featureFlagsObject)).toString('base64') +} + +/** + * Takes the invocation metadata object and produces a Base64-encoded JSON + * object that the bootstrap layer can understand. + * + * @param {object} metadata + * @returns {string} + */ +export const getInvocationMetadataHeader = (metadata) => Buffer.from(JSON.stringify(metadata)).toString('base64') diff --git a/src/lib/edge-functions/proxy.mjs b/src/lib/edge-functions/proxy.mjs index 4bd7f948213..2ff30182c70 100644 --- a/src/lib/edge-functions/proxy.mjs +++ b/src/lib/edge-functions/proxy.mjs @@ -12,7 +12,7 @@ import { startSpinner, stopSpinner } from '../spinner.mjs' import { getBootstrapURL } from './bootstrap.mjs' import { DIST_IMPORT_MAP_PATH } from './consts.mjs' -import headers from './headers.mjs' +import { headers, getFeatureFlagsHeader, getInvocationMetadataHeader } from './headers.mjs' import { getInternalFunctions } from './internal.mjs' import { EdgeFunctionsRegistry } from './registry.mjs' @@ -109,7 +109,7 @@ export const initializeProxy = async ({ await registry.initialize() const url = new URL(req.url, `http://${LOCAL_HOST}:${mainPort}`) - const { functionNames, orphanedDeclarations } = registry.matchURLPath(url.pathname) + const { functionNames, invocationMetadata, orphanedDeclarations } = registry.matchURLPath(url.pathname) // If the request matches a config declaration for an Edge Function without // a matching function file, we warn the user. @@ -129,11 +129,15 @@ export const initializeProxy = async ({ return } + const featureFlags = ['edge_functions_bootstrap_failure_mode'] + req[headersSymbol] = { - [headers.Functions]: functionNames.join(','), + [headers.FeatureFlags]: getFeatureFlagsHeader(featureFlags), [headers.ForwardedHost]: `localhost:${passthroughPort}`, - [headers.Passthrough]: 'passthrough', + [headers.Functions]: functionNames.join(','), + [headers.InvocationMetadata]: getInvocationMetadataHeader(invocationMetadata), [headers.IP]: LOCAL_HOST, + [headers.Passthrough]: 'passthrough', } if (debug) { diff --git a/src/lib/edge-functions/registry.mjs b/src/lib/edge-functions/registry.mjs index 41557df8918..7de97e2aa0d 100644 --- a/src/lib/edge-functions/registry.mjs +++ b/src/lib/edge-functions/registry.mjs @@ -268,6 +268,10 @@ export class EdgeFunctionsRegistry { functionConfig: this.declarationsFromSource, functions: this.functions, }) + const invocationMetadata = { + function_config: manifest.function_config, + routes: manifest.routes.map((route) => ({ function: route.function, pattern: route.pattern })), + } const routes = [...manifest.routes, ...manifest.post_cache_routes].map((route) => ({ ...route, pattern: new RegExp(route.pattern), @@ -283,7 +287,7 @@ export class EdgeFunctionsRegistry { .map((route) => route.function) const orphanedDeclarations = this.matchURLPathAgainstOrphanedDeclarations(urlPath) - return { functionNames, orphanedDeclarations } + return { functionNames, invocationMetadata, orphanedDeclarations } } matchURLPathAgainstOrphanedDeclarations(urlPath) { diff --git a/tests/integration/100.command.dev.test.cjs b/tests/integration/100.command.dev.test.cjs index 690bca03fc1..0e4c8af9bf8 100644 --- a/tests/integration/100.command.dev.test.cjs +++ b/tests/integration/100.command.dev.test.cjs @@ -225,9 +225,17 @@ test('Serves an Edge Function with a rewrite', async (t) => { edge_functions: 'netlify/edge-functions', }, edge_functions: [ + { + function: 'hello-legacy', + path: '/hello-legacy', + }, + { + function: 'yell', + path: '/hello', + }, { function: 'hello', - path: '/edge-function', + path: '/hello', }, ], }, @@ -238,18 +246,36 @@ test('Serves an Edge Function with a rewrite', async (t) => { content: 'goodbye', }, ]) + .withEdgeFunction({ + handler: async (_, context) => { + const res = await context.next() + const text = await res.text() + + return new Response(text.toUpperCase(), res) + }, + name: 'yell', + }) .withEdgeFunction({ handler: (_, context) => context.rewrite('/goodbye'), + name: 'hello-legacy', + }) + .withEdgeFunction({ + handler: (req) => new URL('/goodbye', req.url), name: 'hello', }) await builder.buildAsync() await withDevServer({ cwd: builder.directory }, async (server) => { - const response = await got(`${server.url}/edge-function`) + const response1 = await got(`${server.url}/hello-legacy`) - t.is(response.statusCode, 200) - t.is(response.body, 'goodbye') + t.is(response1.statusCode, 200) + t.is(response1.body, 'goodbye') + + const response2 = await got(`${server.url}/hello`) + + t.is(response2.statusCode, 200) + t.is(response2.body, 'GOODBYE') }) }) }) @@ -467,6 +493,67 @@ test('Serves an Edge Function that streams the response', async (t) => { }) }) +test('When an edge function fails, serves a fallback defined by its `on_error` mode', async (t) => { + await withSiteBuilder('site-with-edge-function-that-fails', async (builder) => { + const publicDir = 'public' + builder + .withNetlifyToml({ + config: { + build: { + publish: publicDir, + edge_functions: 'netlify/edge-functions', + }, + }, + }) + .withContentFiles([ + { + path: path.join(publicDir, 'hello-1.html'), + content: 'hello from the origin', + }, + ]) + .withContentFiles([ + { + path: path.join(publicDir, 'error-page.html'), + content: 'uh-oh!', + }, + ]) + .withEdgeFunction({ + config: { onError: 'bypass', path: '/hello-1' }, + handler: () => { + // eslint-disable-next-line no-undef + ermThisWillFail() + + return new Response('I will never get here') + }, + name: 'hello-1', + }) + .withEdgeFunction({ + config: { onError: '/error-page', path: '/hello-2' }, + handler: () => { + // eslint-disable-next-line no-undef + ermThisWillFail() + + return new Response('I will never get here') + }, + name: 'hello-2', + }) + + await builder.buildAsync() + + await withDevServer({ cwd: builder.directory }, async (server) => { + const response1 = await got(`${server.url}/hello-1`) + + t.is(response1.statusCode, 200) + t.is(response1.body, 'hello from the origin') + + const response2 = await got(`${server.url}/hello-2`) + + t.is(response2.statusCode, 200) + t.is(response2.body, 'uh-oh!') + }) + }) +}) + test('redirect with country cookie', async (t) => { await withSiteBuilder('site-with-country-cookie', async (builder) => { builder