Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add metadata and onError to edge functions #5584

Merged
merged 4 commits into from
Mar 21, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/lib/edge-functions/bootstrap.mjs
Original file line number Diff line number Diff line change
@@ -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
29 changes: 27 additions & 2 deletions src/lib/edge-functions/headers.mjs
Original file line number Diff line number Diff line change
@@ -1,11 +1,36 @@
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',
Site: 'X-NF-Site-Info',
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<string>} 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')
12 changes: 8 additions & 4 deletions src/lib/edge-functions/proxy.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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.
Expand All @@ -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) {
Expand Down
6 changes: 5 additions & 1 deletion src/lib/edge-functions/registry.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand All @@ -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) {
Expand Down
95 changes: 91 additions & 4 deletions tests/integration/100.command.dev.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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',
},
],
},
Expand All @@ -238,18 +246,36 @@ test('Serves an Edge Function with a rewrite', async (t) => {
content: '<html>goodbye</html>',
},
])
.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, '<html>goodbye</html>')
t.is(response1.statusCode, 200)
t.is(response1.body, '<html>goodbye</html>')

const response2 = await got(`${server.url}/hello`)

t.is(response2.statusCode, 200)
t.is(response2.body, '<HTML>GOODBYE</HTML>')
})
})
})
Expand Down Expand Up @@ -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: '<html>hello from the origin</html>',
},
])
.withContentFiles([
{
path: path.join(publicDir, 'error-page.html'),
content: '<html>uh-oh!</html>',
},
])
.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, '<html>hello from the origin</html>')

const response2 = await got(`${server.url}/hello-2`)

t.is(response2.statusCode, 200)
t.is(response2.body, '<html>uh-oh!</html>')
})
})
})

test('redirect with country cookie', async (t) => {
await withSiteBuilder('site-with-country-cookie', async (builder) => {
builder
Expand Down