Skip to content

Commit 650e534

Browse files
committed
move edge middleware setup to adapter
1 parent 303ff66 commit 650e534

File tree

4 files changed

+208
-3
lines changed

4 files changed

+208
-3
lines changed

adapters-notes.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@
44
- In `onBuildComplete` - `config.images.remotePatterns` type is `(RemotePattern | URL)[]` but in
55
reality `URL` inputs are converted to `RemotePattern` so type should be just `RemotePattern[]`
66
- `routes.headers` does not contain immutable cache-control headers for \_next/static
7+
- `outputs.middleware` does not contain env that exist in `middleware-manifest.json` (i.e.
8+
NEXT_SERVER_ACTIONS_ENCRYPTION_KEY, **NEXT_PREVIEW_MODE_ID, **NEXT_PREVIEW_MODE_SIGNING_KEY etc)
9+
- `outputs.middleware.config.matchers` can be undefined per types - can that ever happen? Can we
10+
just have empty array instead to simplify handling.
711

812
## Plan
913

src/adapter/adapter.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
modifyConfig as modifyConfigForImageCDN,
99
onBuildComplete as onBuildCompleteForImageCDN,
1010
} from './image-cdn.js'
11+
import { onBuildComplete as onBuildCompleteForMiddleware } from './middleware.js'
1112
import { onBuildComplete as onBuildCompleteForStaticFiles } from './static.js'
1213
import { FrameworksAPIConfig } from './types.js'
1314

@@ -26,11 +27,18 @@ const adapter: NextAdapter = {
2627
return config
2728
},
2829
async onBuildComplete(nextAdapterContext) {
30+
// for dev/debugging purposes only
31+
await writeFile('./onBuildComplete.json', JSON.stringify(nextAdapterContext, null, 2))
32+
2933
console.log('onBuildComplete hook called')
3034

3135
let frameworksAPIConfig: FrameworksAPIConfig = null
3236

3337
frameworksAPIConfig = onBuildCompleteForImageCDN(nextAdapterContext, frameworksAPIConfig)
38+
frameworksAPIConfig = await onBuildCompleteForMiddleware(
39+
nextAdapterContext,
40+
frameworksAPIConfig,
41+
)
3442
frameworksAPIConfig = await onBuildCompleteForStaticFiles(
3543
nextAdapterContext,
3644
frameworksAPIConfig,
@@ -46,8 +54,6 @@ const adapter: NextAdapter = {
4654
)
4755
}
4856

49-
// for dev/debugging purposes only
50-
await writeFile('./onBuildComplete.json', JSON.stringify(nextAdapterContext, null, 2))
5157
debugger
5258
},
5359
}

src/adapter/middleware.ts

Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
import { dirname, join, parse } from 'node:path'
2+
import { fileURLToPath } from 'node:url'
3+
4+
import { pathToRegexp } from 'path-to-regexp'
5+
6+
import type { FrameworksAPIConfig, NextConfigComplete, OnBuildCompleteContext } from './types.js'
7+
import { cp, mkdir, readFile, writeFile } from 'node:fs/promises'
8+
import { glob } from 'fast-glob'
9+
10+
const NETLIFY_FRAMEWORKS_API_EDGE_FUNCTIONS = '.netlify/v1/edge-functions'
11+
const MIDDLEWARE_FUNCTION_NAME = 'middleware'
12+
13+
const MIDDLEWARE_FUNCTION_DIR = join(
14+
NETLIFY_FRAMEWORKS_API_EDGE_FUNCTIONS,
15+
MIDDLEWARE_FUNCTION_NAME,
16+
)
17+
18+
const MODULE_DIR = fileURLToPath(new URL('.', import.meta.url))
19+
const PLUGIN_DIR = join(MODULE_DIR, '../..')
20+
21+
export async function onBuildComplete(
22+
ctx: OnBuildCompleteContext,
23+
frameworksAPIConfigArg: FrameworksAPIConfig,
24+
) {
25+
const frameworksAPIConfig: FrameworksAPIConfig = frameworksAPIConfigArg ?? {}
26+
27+
const { middleware } = ctx.outputs
28+
if (!middleware) {
29+
return frameworksAPIConfig
30+
}
31+
32+
if (middleware.runtime !== 'edge') {
33+
// TODO: nodejs middleware
34+
return frameworksAPIConfig
35+
}
36+
37+
await copyHandlerDependenciesForEdgeMiddleware(middleware)
38+
await writeHandlerFile(middleware, ctx.config)
39+
40+
return frameworksAPIConfig
41+
}
42+
43+
const copyHandlerDependenciesForEdgeMiddleware = async (
44+
middleware: Required<OnBuildCompleteContext['outputs']>['middleware'],
45+
) => {
46+
// const srcDir = join(ctx.standaloneDir, ctx.nextDistDir)
47+
48+
const edgeRuntimeDir = join(PLUGIN_DIR, 'edge-runtime')
49+
const shimPath = join(edgeRuntimeDir, 'shim/edge.js')
50+
const shim = await readFile(shimPath, 'utf8')
51+
52+
const parts = [shim]
53+
54+
const outputFile = join(MIDDLEWARE_FUNCTION_DIR, `concatenated-file.js`)
55+
56+
// TODO: env is not available in outputs.middleware
57+
// if (env) {
58+
// // Prepare environment variables for draft-mode (i.e. __NEXT_PREVIEW_MODE_ID, __NEXT_PREVIEW_MODE_SIGNING_KEY, __NEXT_PREVIEW_MODE_ENCRYPTION_KEY)
59+
// for (const [key, value] of Object.entries(env)) {
60+
// parts.push(`process.env.${key} = '${value}';`)
61+
// }
62+
// }
63+
64+
for (const [relative, absolute] of Object.entries(middleware.assets)) {
65+
if (absolute.endsWith('.wasm')) {
66+
const data = await readFile(absolute)
67+
68+
const { name } = parse(relative)
69+
parts.push(`const ${name} = Uint8Array.from(${JSON.stringify([...data])})`)
70+
} else if (absolute.endsWith('.js')) {
71+
const entrypoint = await readFile(absolute, 'utf8')
72+
parts.push(`;// Concatenated file: ${relative} \n`, entrypoint)
73+
}
74+
}
75+
parts.push(
76+
`const middlewareEntryKey = Object.keys(_ENTRIES).find(entryKey => entryKey.startsWith("middleware_${middleware.id}"));`,
77+
// turbopack entries are promises so we await here to get actual entry
78+
// non-turbopack entries are already resolved, so await does not change anything
79+
`export default await _ENTRIES[middlewareEntryKey].default;`,
80+
)
81+
await mkdir(dirname(outputFile), { recursive: true })
82+
83+
await writeFile(outputFile, parts.join('\n'))
84+
}
85+
86+
const writeHandlerFile = async (
87+
middleware: Required<OnBuildCompleteContext['outputs']>['middleware'],
88+
nextConfig: NextConfigComplete,
89+
) => {
90+
const handlerRuntimeDirectory = join(MIDDLEWARE_FUNCTION_DIR, 'edge-runtime')
91+
92+
// Copying the runtime files. These are the compatibility layer between
93+
// Netlify Edge Functions and the Next.js edge runtime.
94+
await copyRuntime(MIDDLEWARE_FUNCTION_DIR)
95+
96+
// Writing a file with the matchers that should trigger this function. We'll
97+
// read this file from the function at runtime.
98+
await writeFile(
99+
join(handlerRuntimeDirectory, 'matchers.json'),
100+
JSON.stringify(middleware.config.matchers ?? []),
101+
)
102+
103+
// The config is needed by the edge function to match and normalize URLs. To
104+
// avoid shipping and parsing a large file at runtime, let's strip it down to
105+
// just the properties that the edge function actually needs.
106+
const minimalNextConfig = {
107+
basePath: nextConfig.basePath,
108+
i18n: nextConfig.i18n,
109+
trailingSlash: nextConfig.trailingSlash,
110+
skipMiddlewareUrlNormalize: nextConfig.skipMiddlewareUrlNormalize,
111+
}
112+
113+
await writeFile(
114+
join(handlerRuntimeDirectory, 'next.config.json'),
115+
JSON.stringify(minimalNextConfig),
116+
)
117+
118+
const htmlRewriterWasm = await readFile(
119+
join(
120+
PLUGIN_DIR,
121+
'edge-runtime/vendor/deno.land/x/[email protected]/pkg/htmlrewriter_bg.wasm',
122+
),
123+
)
124+
125+
// Writing the function entry file. It wraps the middleware code with the
126+
// compatibility layer mentioned above.
127+
await writeFile(
128+
join(MIDDLEWARE_FUNCTION_DIR, `middleware.js`),
129+
`
130+
import { init as htmlRewriterInit } from './edge-runtime/vendor/deno.land/x/[email protected]/src/index.ts'
131+
import { handleMiddleware } from './edge-runtime/middleware.ts';
132+
import handler from './concatenated-file.js';
133+
134+
await htmlRewriterInit({ module_or_path: Uint8Array.from(${JSON.stringify([
135+
...htmlRewriterWasm,
136+
])}) });
137+
138+
export default (req, context) => handleMiddleware(req, context, handler);
139+
140+
export const config = ${JSON.stringify({
141+
pattern: augmentMatchers(middleware, nextConfig).map((matcher) => matcher.regexp),
142+
cache: undefined,
143+
})}
144+
`,
145+
)
146+
}
147+
148+
const copyRuntime = async (handlerDirectory: string): Promise<void> => {
149+
const files = await glob('edge-runtime/**/*', {
150+
cwd: PLUGIN_DIR,
151+
ignore: ['**/*.test.ts'],
152+
dot: true,
153+
})
154+
await Promise.all(
155+
files.map((path) =>
156+
cp(join(PLUGIN_DIR, path), join(handlerDirectory, path), { recursive: true }),
157+
),
158+
)
159+
}
160+
161+
/**
162+
* When i18n is enabled the matchers assume that paths _always_ include the
163+
* locale. We manually add an extra matcher for the original path without
164+
* the locale to ensure that the edge function can handle it.
165+
* We don't need to do this for data routes because they always have the locale.
166+
*/
167+
const augmentMatchers = (
168+
middleware: Required<OnBuildCompleteContext['outputs']>['middleware'],
169+
nextConfig: NextConfigComplete,
170+
) => {
171+
const i18NConfig = nextConfig.i18n
172+
if (!i18NConfig) {
173+
return middleware.config.matchers ?? []
174+
}
175+
return (middleware.config.matchers ?? []).flatMap((matcher) => {
176+
if (matcher.originalSource && matcher.locale !== false) {
177+
return [
178+
matcher.regexp
179+
? {
180+
...matcher,
181+
// https://github.com/vercel/next.js/blob/5e236c9909a768dc93856fdfad53d4f4adc2db99/packages/next/src/build/analysis/get-page-static-info.ts#L332-L336
182+
// Next is producing pretty broad matcher for i18n locale. Presumably rest of their infrastructure protects this broad matcher
183+
// from matching on non-locale paths. For us this becomes request entry point, so we need to narrow it down to just defined locales
184+
// otherwise users might get unexpected matches on paths like `/api*`
185+
regexp: matcher.regexp.replace(/\[\^\/\.]+/g, `(${i18NConfig.locales.join('|')})`),
186+
}
187+
: matcher,
188+
{
189+
...matcher,
190+
regexp: pathToRegexp(matcher.originalSource).source,
191+
},
192+
]
193+
}
194+
return matcher
195+
})
196+
}

src/index.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,6 @@ export const onBuild = async (options: NetlifyPluginOptions) => {
9696
copyStaticContent(ctx), // this
9797
copyPrerenderedContent(ctx), // maybe this
9898
createServerHandler(ctx), // not this while we use standalone
99-
createEdgeHandlers(ctx), // this - middleware
10099
])
101100
})
102101
}

0 commit comments

Comments
 (0)