Skip to content

Commit

Permalink
feat: add metadata and onError to edge functions (#5584)
Browse files Browse the repository at this point in the history
* feat: add metadata and `onError` to edge functions

* refactor: move `getFeatureFlagsHeader` to separate file

* refactor: move `getInvocationMetadataHeader` function

---------

Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
  • Loading branch information
eduardoboucas and kodiakhq[bot] authored Mar 21, 2023
1 parent f437d30 commit 1311734
Show file tree
Hide file tree
Showing 5 changed files with 132 additions and 12 deletions.
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

1 comment on commit 1311734

@github-actions
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📊 Benchmark results

  • Package size: 264 MB

Please sign in to comment.