|
1 | | -import { cp, mkdir, readdir, readFile, rm, stat, writeFile } from 'node:fs/promises' |
2 | | -import { dirname, join, relative } from 'node:path/posix' |
| 1 | +import { rm } from 'node:fs/promises' |
3 | 2 |
|
4 | | -import type { Manifest, ManifestFunction } from '@netlify/edge-functions' |
5 | | -import { glob } from 'fast-glob' |
6 | | -import type { FunctionsConfigManifest } from 'next-with-cache-handler-v2/dist/build/index.js' |
7 | | -import type { EdgeFunctionDefinition as EdgeMiddlewareDefinition } from 'next-with-cache-handler-v2/dist/build/webpack/plugins/middleware-plugin.js' |
8 | | -import { pathToRegexp } from 'path-to-regexp' |
9 | | - |
10 | | -import { EDGE_HANDLER_NAME, PluginContext } from '../plugin-context.js' |
11 | | - |
12 | | -type NodeMiddlewareDefinitionWithOptionalMatchers = FunctionsConfigManifest['functions'][0] |
13 | | -type WithRequired<T, K extends keyof T> = T & { [P in K]-?: T[P] } |
14 | | -type NodeMiddlewareDefinition = WithRequired< |
15 | | - NodeMiddlewareDefinitionWithOptionalMatchers, |
16 | | - 'matchers' |
17 | | -> |
18 | | - |
19 | | -function nodeMiddlewareDefinitionHasMatcher( |
20 | | - definition: NodeMiddlewareDefinitionWithOptionalMatchers, |
21 | | -): definition is NodeMiddlewareDefinition { |
22 | | - return Array.isArray(definition.matchers) |
23 | | -} |
24 | | - |
25 | | -type EdgeOrNodeMiddlewareDefinition = { |
26 | | - runtime: 'nodejs' | 'edge' |
27 | | - // hoisting shared properties from underlying definitions for common handling |
28 | | - name: string |
29 | | - matchers: EdgeMiddlewareDefinition['matchers'] |
30 | | -} & ( |
31 | | - | { |
32 | | - runtime: 'nodejs' |
33 | | - functionDefinition: NodeMiddlewareDefinition |
34 | | - } |
35 | | - | { |
36 | | - runtime: 'edge' |
37 | | - functionDefinition: EdgeMiddlewareDefinition |
38 | | - } |
39 | | -) |
40 | | - |
41 | | -const writeEdgeManifest = async (ctx: PluginContext, manifest: Manifest) => { |
42 | | - await mkdir(ctx.edgeFunctionsDir, { recursive: true }) |
43 | | - await writeFile(join(ctx.edgeFunctionsDir, 'manifest.json'), JSON.stringify(manifest, null, 2)) |
44 | | -} |
45 | | - |
46 | | -const copyRuntime = async (ctx: PluginContext, handlerDirectory: string): Promise<void> => { |
47 | | - const files = await glob('edge-runtime/**/*', { |
48 | | - cwd: ctx.pluginDir, |
49 | | - ignore: ['**/*.test.ts'], |
50 | | - dot: true, |
51 | | - }) |
52 | | - await Promise.all( |
53 | | - files.map((path) => |
54 | | - cp(join(ctx.pluginDir, path), join(handlerDirectory, path), { recursive: true }), |
55 | | - ), |
56 | | - ) |
57 | | -} |
58 | | - |
59 | | -/** |
60 | | - * When i18n is enabled the matchers assume that paths _always_ include the |
61 | | - * locale. We manually add an extra matcher for the original path without |
62 | | - * the locale to ensure that the edge function can handle it. |
63 | | - * We don't need to do this for data routes because they always have the locale. |
64 | | - */ |
65 | | -const augmentMatchers = ( |
66 | | - matchers: EdgeMiddlewareDefinition['matchers'], |
67 | | - ctx: PluginContext, |
68 | | -): EdgeMiddlewareDefinition['matchers'] => { |
69 | | - const i18NConfig = ctx.buildConfig.i18n |
70 | | - if (!i18NConfig) { |
71 | | - return matchers |
72 | | - } |
73 | | - return matchers.flatMap((matcher) => { |
74 | | - if (matcher.originalSource && matcher.locale !== false) { |
75 | | - return [ |
76 | | - matcher.regexp |
77 | | - ? { |
78 | | - ...matcher, |
79 | | - // https://github.com/vercel/next.js/blob/5e236c9909a768dc93856fdfad53d4f4adc2db99/packages/next/src/build/analysis/get-page-static-info.ts#L332-L336 |
80 | | - // Next is producing pretty broad matcher for i18n locale. Presumably rest of their infrastructure protects this broad matcher |
81 | | - // from matching on non-locale paths. For us this becomes request entry point, so we need to narrow it down to just defined locales |
82 | | - // otherwise users might get unexpected matches on paths like `/api*` |
83 | | - regexp: matcher.regexp.replace(/\[\^\/\.]+/g, `(${i18NConfig.locales.join('|')})`), |
84 | | - } |
85 | | - : matcher, |
86 | | - { |
87 | | - ...matcher, |
88 | | - regexp: pathToRegexp(matcher.originalSource).source, |
89 | | - }, |
90 | | - ] |
91 | | - } |
92 | | - return matcher |
93 | | - }) |
94 | | -} |
95 | | - |
96 | | -const writeHandlerFile = async ( |
97 | | - ctx: PluginContext, |
98 | | - { matchers, name }: EdgeOrNodeMiddlewareDefinition, |
99 | | -) => { |
100 | | - const nextConfig = ctx.buildConfig |
101 | | - const handlerName = getHandlerName({ name }) |
102 | | - const handlerDirectory = join(ctx.edgeFunctionsDir, handlerName) |
103 | | - const handlerRuntimeDirectory = join(handlerDirectory, 'edge-runtime') |
104 | | - |
105 | | - // Copying the runtime files. These are the compatibility layer between |
106 | | - // Netlify Edge Functions and the Next.js edge runtime. |
107 | | - await copyRuntime(ctx, handlerDirectory) |
108 | | - |
109 | | - // Writing a file with the matchers that should trigger this function. We'll |
110 | | - // read this file from the function at runtime. |
111 | | - await writeFile(join(handlerRuntimeDirectory, 'matchers.json'), JSON.stringify(matchers)) |
112 | | - |
113 | | - // The config is needed by the edge function to match and normalize URLs. To |
114 | | - // avoid shipping and parsing a large file at runtime, let's strip it down to |
115 | | - // just the properties that the edge function actually needs. |
116 | | - const minimalNextConfig = { |
117 | | - basePath: nextConfig.basePath, |
118 | | - i18n: nextConfig.i18n, |
119 | | - trailingSlash: nextConfig.trailingSlash, |
120 | | - skipMiddlewareUrlNormalize: nextConfig.skipMiddlewareUrlNormalize, |
121 | | - } |
122 | | - |
123 | | - await writeFile( |
124 | | - join(handlerRuntimeDirectory, 'next.config.json'), |
125 | | - JSON.stringify(minimalNextConfig), |
126 | | - ) |
127 | | - |
128 | | - const htmlRewriterWasm = await readFile( |
129 | | - join( |
130 | | - ctx.pluginDir, |
131 | | - 'edge-runtime/vendor/deno.land/x/htmlrewriter@v1.0.0/pkg/htmlrewriter_bg.wasm', |
132 | | - ), |
133 | | - ) |
134 | | - |
135 | | - // Writing the function entry file. It wraps the middleware code with the |
136 | | - // compatibility layer mentioned above. |
137 | | - await writeFile( |
138 | | - join(handlerDirectory, `${handlerName}.js`), |
139 | | - ` |
140 | | - import { init as htmlRewriterInit } from './edge-runtime/vendor/deno.land/x/htmlrewriter@v1.0.0/src/index.ts' |
141 | | - import { handleMiddleware } from './edge-runtime/middleware.ts'; |
142 | | - import handler from './server/${name}.js'; |
143 | | -
|
144 | | - await htmlRewriterInit({ module_or_path: Uint8Array.from(${JSON.stringify([ |
145 | | - ...htmlRewriterWasm, |
146 | | - ])}) }); |
147 | | -
|
148 | | - export default (req, context) => handleMiddleware(req, context, handler); |
149 | | - `, |
150 | | - ) |
151 | | -} |
152 | | - |
153 | | -const copyHandlerDependenciesForEdgeMiddleware = async ( |
154 | | - ctx: PluginContext, |
155 | | - { name, env, files, wasm }: EdgeMiddlewareDefinition, |
156 | | -) => { |
157 | | - const srcDir = join(ctx.standaloneDir, ctx.nextDistDir) |
158 | | - const destDir = join(ctx.edgeFunctionsDir, getHandlerName({ name })) |
159 | | - |
160 | | - const edgeRuntimeDir = join(ctx.pluginDir, 'edge-runtime') |
161 | | - const shimPath = join(edgeRuntimeDir, 'shim/edge.js') |
162 | | - const shim = await readFile(shimPath, 'utf8') |
163 | | - |
164 | | - const parts = [shim] |
165 | | - |
166 | | - const outputFile = join(destDir, `server/${name}.js`) |
167 | | - |
168 | | - if (env) { |
169 | | - // Prepare environment variables for draft-mode (i.e. __NEXT_PREVIEW_MODE_ID, __NEXT_PREVIEW_MODE_SIGNING_KEY, __NEXT_PREVIEW_MODE_ENCRYPTION_KEY) |
170 | | - for (const [key, value] of Object.entries(env)) { |
171 | | - parts.push(`process.env.${key} = '${value}';`) |
172 | | - } |
173 | | - } |
174 | | - |
175 | | - if (wasm?.length) { |
176 | | - for (const wasmChunk of wasm ?? []) { |
177 | | - const data = await readFile(join(srcDir, wasmChunk.filePath)) |
178 | | - parts.push(`const ${wasmChunk.name} = Uint8Array.from(${JSON.stringify([...data])})`) |
179 | | - } |
180 | | - } |
181 | | - |
182 | | - for (const file of files) { |
183 | | - const entrypoint = await readFile(join(srcDir, file), 'utf8') |
184 | | - parts.push(`;// Concatenated file: ${file} \n`, entrypoint) |
185 | | - } |
186 | | - parts.push( |
187 | | - `const middlewareEntryKey = Object.keys(_ENTRIES).find(entryKey => entryKey.startsWith("middleware_${name}"));`, |
188 | | - // turbopack entries are promises so we await here to get actual entry |
189 | | - // non-turbopack entries are already resolved, so await does not change anything |
190 | | - `export default await _ENTRIES[middlewareEntryKey].default;`, |
191 | | - ) |
192 | | - await mkdir(dirname(outputFile), { recursive: true }) |
193 | | - |
194 | | - await writeFile(outputFile, parts.join('\n')) |
195 | | -} |
196 | | - |
197 | | -const NODE_MIDDLEWARE_NAME = 'node-middleware' |
198 | | -const copyHandlerDependenciesForNodeMiddleware = async (ctx: PluginContext) => { |
199 | | - const name = NODE_MIDDLEWARE_NAME |
200 | | - |
201 | | - const srcDir = join(ctx.standaloneDir, ctx.nextDistDir) |
202 | | - const destDir = join(ctx.edgeFunctionsDir, getHandlerName({ name })) |
203 | | - |
204 | | - const edgeRuntimeDir = join(ctx.pluginDir, 'edge-runtime') |
205 | | - const shimPath = join(edgeRuntimeDir, 'shim/node.js') |
206 | | - const shim = await readFile(shimPath, 'utf8') |
207 | | - |
208 | | - const parts = [shim] |
209 | | - |
210 | | - const entry = 'server/middleware.js' |
211 | | - const nft = `${entry}.nft.json` |
212 | | - const nftFilesPath = join(process.cwd(), ctx.nextDistDir, nft) |
213 | | - const nftManifest = JSON.parse(await readFile(nftFilesPath, 'utf8')) |
214 | | - |
215 | | - const files: string[] = nftManifest.files.map((file: string) => join('server', file)) |
216 | | - files.push(entry) |
217 | | - |
218 | | - // files are relative to location of middleware entrypoint |
219 | | - // we need to capture all of them |
220 | | - // they might be going to parent directories, so first we check how many directories we need to go up |
221 | | - const { maxParentDirectoriesPath, unsupportedDotNodeModules } = files.reduce( |
222 | | - (acc, file) => { |
223 | | - let dirsUp = 0 |
224 | | - let parentDirectoriesPath = '' |
225 | | - for (const part of file.split('/')) { |
226 | | - if (part === '..') { |
227 | | - dirsUp += 1 |
228 | | - parentDirectoriesPath += '../' |
229 | | - } else { |
230 | | - break |
231 | | - } |
232 | | - } |
233 | | - |
234 | | - if (file.endsWith('.node')) { |
235 | | - // C++ addons are not supported |
236 | | - acc.unsupportedDotNodeModules.push(join(srcDir, file)) |
237 | | - } |
238 | | - |
239 | | - if (dirsUp > acc.maxDirsUp) { |
240 | | - return { |
241 | | - ...acc, |
242 | | - maxDirsUp: dirsUp, |
243 | | - maxParentDirectoriesPath: parentDirectoriesPath, |
244 | | - } |
245 | | - } |
246 | | - |
247 | | - return acc |
248 | | - }, |
249 | | - { maxDirsUp: 0, maxParentDirectoriesPath: '', unsupportedDotNodeModules: [] as string[] }, |
250 | | - ) |
251 | | - |
252 | | - if (unsupportedDotNodeModules.length !== 0) { |
253 | | - throw new Error( |
254 | | - `Usage of unsupported C++ Addon(s) found in Node.js Middleware:\n${unsupportedDotNodeModules.map((file) => `- ${file}`).join('\n')}\n\nCheck https://docs.netlify.com/build/frameworks/framework-setup-guides/nextjs/overview/#limitations for more information.`, |
255 | | - ) |
256 | | - } |
257 | | - |
258 | | - const commonPrefix = relative(join(srcDir, maxParentDirectoriesPath), srcDir) |
259 | | - |
260 | | - parts.push(`const virtualModules = new Map();`) |
261 | | - |
262 | | - const handleFileOrDirectory = async (fileOrDir: string) => { |
263 | | - const srcPath = join(srcDir, fileOrDir) |
264 | | - |
265 | | - const stats = await stat(srcPath) |
266 | | - if (stats.isDirectory()) { |
267 | | - const filesInDir = await readdir(srcPath) |
268 | | - for (const fileInDir of filesInDir) { |
269 | | - await handleFileOrDirectory(join(fileOrDir, fileInDir)) |
270 | | - } |
271 | | - } else { |
272 | | - const content = await readFile(srcPath, 'utf8') |
273 | | - |
274 | | - parts.push( |
275 | | - `virtualModules.set(${JSON.stringify(join(commonPrefix, fileOrDir))}, ${JSON.stringify(content)});`, |
276 | | - ) |
277 | | - } |
278 | | - } |
279 | | - |
280 | | - for (const file of files) { |
281 | | - await handleFileOrDirectory(file) |
282 | | - } |
283 | | - parts.push(`registerCJSModules(import.meta.url, virtualModules); |
284 | | -
|
285 | | - const require = createRequire(import.meta.url); |
286 | | - const handlerMod = require("./${join(commonPrefix, entry)}"); |
287 | | - const handler = handlerMod.default || handlerMod; |
288 | | -
|
289 | | - export default handler |
290 | | - `) |
291 | | - |
292 | | - const outputFile = join(destDir, `server/${name}.js`) |
293 | | - |
294 | | - await mkdir(dirname(outputFile), { recursive: true }) |
295 | | - |
296 | | - await writeFile(outputFile, parts.join('\n')) |
297 | | -} |
298 | | - |
299 | | -const createEdgeHandler = async ( |
300 | | - ctx: PluginContext, |
301 | | - definition: EdgeOrNodeMiddlewareDefinition, |
302 | | -): Promise<void> => { |
303 | | - await (definition.runtime === 'edge' |
304 | | - ? copyHandlerDependenciesForEdgeMiddleware(ctx, definition.functionDefinition) |
305 | | - : copyHandlerDependenciesForNodeMiddleware(ctx)) |
306 | | - await writeHandlerFile(ctx, definition) |
307 | | -} |
308 | | - |
309 | | -const getHandlerName = ({ name }: Pick<EdgeMiddlewareDefinition, 'name'>): string => |
310 | | - `${EDGE_HANDLER_NAME}-${name.replace(/\W/g, '-')}` |
311 | | - |
312 | | -const buildHandlerDefinition = ( |
313 | | - ctx: PluginContext, |
314 | | - def: EdgeOrNodeMiddlewareDefinition, |
315 | | -): Array<ManifestFunction> => { |
316 | | - const functionHandlerName = getHandlerName({ name: def.name }) |
317 | | - const functionName = 'Next.js Middleware Handler' |
318 | | - const cache = def.name.endsWith('middleware') ? undefined : ('manual' as const) |
319 | | - const generator = `${ctx.pluginName}@${ctx.pluginVersion}` |
320 | | - |
321 | | - return augmentMatchers(def.matchers, ctx).map((matcher) => ({ |
322 | | - function: functionHandlerName, |
323 | | - name: functionName, |
324 | | - pattern: matcher.regexp, |
325 | | - cache, |
326 | | - generator, |
327 | | - })) |
328 | | -} |
| 3 | +import { PluginContext } from '../plugin-context.js' |
329 | 4 |
|
330 | 5 | export const clearStaleEdgeHandlers = async (ctx: PluginContext) => { |
331 | 6 | await rm(ctx.edgeFunctionsDir, { recursive: true, force: true }) |
332 | 7 | } |
333 | | - |
334 | | -export const createEdgeHandlers = async (ctx: PluginContext) => { |
335 | | - // Edge middleware |
336 | | - const nextManifest = await ctx.getMiddlewareManifest() |
337 | | - const middlewareDefinitions: EdgeOrNodeMiddlewareDefinition[] = [ |
338 | | - ...Object.values(nextManifest.middleware), |
339 | | - ].map((edgeDefinition) => { |
340 | | - return { |
341 | | - runtime: 'edge', |
342 | | - functionDefinition: edgeDefinition, |
343 | | - name: edgeDefinition.name, |
344 | | - matchers: edgeDefinition.matchers, |
345 | | - } |
346 | | - }) |
347 | | - |
348 | | - // Node middleware |
349 | | - const functionsConfigManifest = await ctx.getFunctionsConfigManifest() |
350 | | - if ( |
351 | | - functionsConfigManifest?.functions?.['/_middleware'] && |
352 | | - nodeMiddlewareDefinitionHasMatcher(functionsConfigManifest?.functions?.['/_middleware']) |
353 | | - ) { |
354 | | - middlewareDefinitions.push({ |
355 | | - runtime: 'nodejs', |
356 | | - functionDefinition: functionsConfigManifest?.functions?.['/_middleware'], |
357 | | - name: NODE_MIDDLEWARE_NAME, |
358 | | - matchers: functionsConfigManifest?.functions?.['/_middleware']?.matchers, |
359 | | - }) |
360 | | - } |
361 | | - |
362 | | - await Promise.all(middlewareDefinitions.map((def) => createEdgeHandler(ctx, def))) |
363 | | - |
364 | | - const netlifyDefinitions = middlewareDefinitions.flatMap((def) => |
365 | | - buildHandlerDefinition(ctx, def), |
366 | | - ) |
367 | | - |
368 | | - const netlifyManifest: Manifest = { |
369 | | - version: 1, |
370 | | - functions: netlifyDefinitions, |
371 | | - } |
372 | | - await writeEdgeManifest(ctx, netlifyManifest) |
373 | | -} |
0 commit comments