Skip to content

Commit 139d813

Browse files
committed
[wip] feat: support node middleware
1 parent a732739 commit 139d813

File tree

5 files changed

+236
-10
lines changed

5 files changed

+236
-10
lines changed

src/build/content/server.ts

Lines changed: 37 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -133,7 +133,13 @@ export const copyNextServerCode = async (ctx: PluginContext): Promise<void> => {
133133
}
134134

135135
if (path === 'server/functions-config-manifest.json') {
136-
await verifyFunctionsConfigManifest(join(srcDir, path))
136+
try {
137+
await replaceFunctionsConfigManifest(srcPath, destPath)
138+
} catch (error) {
139+
throw new Error('Could not patch functions config manifest file', { cause: error })
140+
}
141+
142+
return
137143
}
138144

139145
await cp(srcPath, destPath, { recursive: true, force: true })
@@ -381,16 +387,41 @@ const replaceMiddlewareManifest = async (sourcePath: string, destPath: string) =
381387
await writeFile(destPath, newData)
382388
}
383389

384-
const verifyFunctionsConfigManifest = async (sourcePath: string) => {
390+
// similar to the middleware manifest, we need to patch the functions config manifest to disable
391+
// the middleware that is defined in the functions config manifest. This is needed to avoid running
392+
// the middleware in the server handler, while still allowing next server to enable some middleware
393+
// specific handling such as _next/data normalization ( https://github.com/vercel/next.js/blob/7bb72e508572237fe0d4aac5418546d4b4b3a363/packages/next/src/server/lib/router-utils/resolve-routes.ts#L395 )
394+
const replaceFunctionsConfigManifest = async (sourcePath: string, destPath: string) => {
385395
const data = await readFile(sourcePath, 'utf8')
386396
const manifest = JSON.parse(data) as FunctionsConfigManifest
387397

388398
// https://github.com/vercel/next.js/blob/8367faedd61501025299e92d43a28393c7bb50e2/packages/next/src/build/index.ts#L2465
389399
// Node.js Middleware has hardcoded /_middleware path
390-
if (manifest.functions['/_middleware']) {
391-
throw new Error(
392-
'Only Edge Runtime Middleware is supported. Node.js Middleware is not supported.',
393-
)
400+
if (manifest?.functions?.['/_middleware']?.matchers) {
401+
const newManifest = {
402+
...manifest,
403+
functions: {
404+
...manifest.functions,
405+
'/_middleware': {
406+
...manifest.functions['/_middleware'],
407+
matchers: manifest.functions['/_middleware'].matchers.map((matcher) => {
408+
return {
409+
...matcher,
410+
// matcher that won't match on anything
411+
// this is meant to disable actually running middleware in the server handler,
412+
// while still allowing next server to enable some middleware specific handling
413+
// such as _next/data normalization ( https://github.com/vercel/next.js/blob/7bb72e508572237fe0d4aac5418546d4b4b3a363/packages/next/src/server/lib/router-utils/resolve-routes.ts#L395 )
414+
regexp: '(?!.*)',
415+
}
416+
}),
417+
},
418+
},
419+
}
420+
const newData = JSON.stringify(newManifest)
421+
422+
await writeFile(destPath, newData)
423+
} else {
424+
await cp(sourcePath, destPath, { recursive: true, force: true })
394425
}
395426
}
396427

src/build/functions/edge.ts

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,11 +194,182 @@ export const clearStaleEdgeHandlers = async (ctx: PluginContext) => {
194194
}
195195

196196
export const createEdgeHandlers = async (ctx: PluginContext) => {
197+
// Edge middleware
197198
const nextManifest = await ctx.getMiddlewareManifest()
199+
// Node middleware
200+
const functionsConfigManifest = await ctx.getFunctionsConfigManifest()
201+
198202
const nextDefinitions = [...Object.values(nextManifest.middleware)]
199203
await Promise.all(nextDefinitions.map((def) => createEdgeHandler(ctx, def)))
200204

201205
const netlifyDefinitions = nextDefinitions.flatMap((def) => buildHandlerDefinition(ctx, def))
206+
207+
if (functionsConfigManifest?.functions?.['/_middleware']) {
208+
const middlewareDefinition = functionsConfigManifest?.functions?.['/_middleware']
209+
const entry = 'server/middleware.js'
210+
const nft = `${entry}.nft.json`
211+
const name = 'node-middleware'
212+
213+
// await copyHandlerDependencies(ctx, definition)
214+
const srcDir = join(ctx.standaloneDir, ctx.nextDistDir)
215+
// const destDir = join(ctx.edgeFunctionsDir, getHandlerName({ name }))
216+
217+
const fakeNodeModuleName = 'fake-module-with-middleware'
218+
219+
const fakeNodeModulePath = ctx.resolveFromPackagePath(join('node_modules', fakeNodeModuleName))
220+
221+
const nftFilesPath = join(ctx.nextDistDir, nft)
222+
const nftManifest = JSON.parse(await readFile(nftFilesPath, 'utf8'))
223+
224+
const files: string[] = nftManifest.files.map((file: string) => join('server', file))
225+
files.push(entry)
226+
227+
// files are relative to location of middleware entrypoint
228+
// we need to capture all of them
229+
// they might be going to parent directories, so first we check how many directories we need to go up
230+
const maxDirsUp = files.reduce((max, file) => {
231+
let dirsUp = 0
232+
for (const part of file.split('/')) {
233+
if (part === '..') {
234+
dirsUp += 1
235+
} else {
236+
break
237+
}
238+
}
239+
return Math.max(max, dirsUp)
240+
}, 0)
241+
242+
let prefixPath = ''
243+
for (let nestedIndex = 1; nestedIndex <= maxDirsUp; nestedIndex++) {
244+
// TODO: ideally we preserve the original directory structure
245+
// this is just hack to use arbitrary computed names to speed up hooking things up
246+
prefixPath += `nested-${nestedIndex}/`
247+
}
248+
249+
for (const file of files) {
250+
const srcPath = join(srcDir, file)
251+
const destPath = join(fakeNodeModulePath, prefixPath, file)
252+
253+
await mkdir(dirname(destPath), { recursive: true })
254+
255+
if (file === entry) {
256+
const content = await readFile(srcPath, 'utf8')
257+
await writeFile(
258+
destPath,
259+
// Next.js needs to be set on global even if it's possible to just require it
260+
// so somewhat similar to existing shim we have for edge runtime
261+
`globalThis.AsyncLocalStorage = require('node:async_hooks').AsyncLocalStorage;\n${content}`,
262+
)
263+
} else {
264+
await cp(srcPath, destPath, { force: true })
265+
}
266+
}
267+
268+
await writeFile(join(fakeNodeModulePath, 'package.json'), JSON.stringify({ type: 'commonjs' }))
269+
270+
// there is `/chunks/**/*` require coming from webpack-runtime that fails esbuild due to nothing matching,
271+
// so this ensure something does
272+
const dummyChunkPath = join(fakeNodeModulePath, prefixPath, 'server', 'chunks', 'dummy.js')
273+
await mkdir(dirname(dummyChunkPath), { recursive: true })
274+
await writeFile(dummyChunkPath, '')
275+
276+
// await writeHandlerFile(ctx, definition)
277+
278+
const nextConfig = ctx.buildConfig
279+
const handlerName = getHandlerName({ name })
280+
const handlerDirectory = join(ctx.edgeFunctionsDir, handlerName)
281+
const handlerRuntimeDirectory = join(handlerDirectory, 'edge-runtime')
282+
283+
// Copying the runtime files. These are the compatibility layer between
284+
// Netlify Edge Functions and the Next.js edge runtime.
285+
await copyRuntime(ctx, handlerDirectory)
286+
287+
// Writing a file with the matchers that should trigger this function. We'll
288+
// read this file from the function at runtime.
289+
await writeFile(
290+
join(handlerRuntimeDirectory, 'matchers.json'),
291+
JSON.stringify(middlewareDefinition.matchers ?? []),
292+
)
293+
294+
// The config is needed by the edge function to match and normalize URLs. To
295+
// avoid shipping and parsing a large file at runtime, let's strip it down to
296+
// just the properties that the edge function actually needs.
297+
const minimalNextConfig = {
298+
basePath: nextConfig.basePath,
299+
i18n: nextConfig.i18n,
300+
trailingSlash: nextConfig.trailingSlash,
301+
skipMiddlewareUrlNormalize: nextConfig.skipMiddlewareUrlNormalize,
302+
}
303+
304+
await writeFile(
305+
join(handlerRuntimeDirectory, 'next.config.json'),
306+
JSON.stringify(minimalNextConfig),
307+
)
308+
309+
const htmlRewriterWasm = await readFile(
310+
join(
311+
ctx.pluginDir,
312+
'edge-runtime/vendor/deno.land/x/[email protected]/pkg/htmlrewriter_bg.wasm',
313+
),
314+
)
315+
316+
// Writing the function entry file. It wraps the middleware code with the
317+
// compatibility layer mentioned above.
318+
await writeFile(
319+
join(handlerDirectory, `${handlerName}.js`),
320+
`
321+
import { init as htmlRewriterInit } from './edge-runtime/vendor/deno.land/x/[email protected]/src/index.ts'
322+
import { handleMiddleware } from './edge-runtime/middleware.ts';
323+
324+
import * as handlerMod from '${fakeNodeModuleName}/${prefixPath}${entry}';
325+
326+
const handler = handlerMod.default || handlerMod;
327+
328+
await htmlRewriterInit({ module_or_path: Uint8Array.from(${JSON.stringify([
329+
...htmlRewriterWasm,
330+
])}) });
331+
332+
export default (req, context) => {
333+
return handleMiddleware(req, context, handler);
334+
};
335+
`,
336+
)
337+
338+
// buildHandlerDefinition(ctx, def)
339+
const netlifyDefinitions: Manifest['functions'] = augmentMatchers(
340+
middlewareDefinition.matchers ?? [],
341+
ctx,
342+
).map((matcher) => {
343+
return {
344+
function: getHandlerName({ name }),
345+
name: `Next.js Node Middleware Handler`,
346+
pattern: matcher.regexp,
347+
cache: undefined,
348+
generator: `${ctx.pluginName}@${ctx.pluginVersion}`,
349+
}
350+
})
351+
352+
const netlifyManifest: Manifest = {
353+
version: 1,
354+
functions: netlifyDefinitions,
355+
}
356+
await writeEdgeManifest(ctx, netlifyManifest)
357+
358+
return
359+
}
360+
361+
// if (functionsConfigManifest?.functions?.['/_middleware']) {
362+
// const nextDefinition: Pick<
363+
// (typeof nextManifest.middleware)[''],
364+
// 'name' | 'env' | 'files' | 'wasm' | 'matchers'
365+
// > = {
366+
// name: 'middleware',
367+
// env: {},
368+
// files: [],
369+
// wasm: [],
370+
// }
371+
// }
372+
202373
const netlifyManifest: Manifest = {
203374
version: 1,
204375
functions: netlifyDefinitions,

src/build/plugin-context.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import type { PrerenderManifest, RoutesManifest } from 'next/dist/build/index.js
1414
import type { MiddlewareManifest } from 'next/dist/build/webpack/plugins/middleware-plugin.js'
1515
import type { PagesManifest } from 'next/dist/build/webpack/plugins/pages-manifest-plugin.js'
1616
import type { NextConfigComplete } from 'next/dist/server/config-shared.js'
17+
import type { FunctionsConfigManifest } from 'next-with-cache-handler-v2/dist/build/index.js'
1718
import { satisfies } from 'semver'
1819

1920
const MODULE_DIR = fileURLToPath(new URL('.', import.meta.url))
@@ -259,6 +260,23 @@ export class PluginContext {
259260
)
260261
}
261262

263+
/**
264+
* Get Next.js Functions Config Manifest config if it exists from the build output
265+
*/
266+
async getFunctionsConfigManifest(): Promise<FunctionsConfigManifest | null> {
267+
const functionsConfigManifestPath = join(
268+
this.publishDir,
269+
'server/functions-config-manifest.json',
270+
)
271+
272+
if (existsSync(functionsConfigManifestPath)) {
273+
return JSON.parse(await readFile(functionsConfigManifestPath, 'utf-8'))
274+
}
275+
276+
// this file might not have been produced
277+
return null
278+
}
279+
262280
// don't make private as it is handy inside testing to override the config
263281
_requiredServerFiles: RequiredServerFilesManifest | null = null
264282

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
1-
import type { NextRequest } from 'next/server'
1+
import { type NextRequest, NextResponse } from 'next/server'
2+
import { join } from 'path'
23

3-
export async function middleware(request: NextRequest) {
4-
console.log('Node.js Middleware request:', request.method, request.nextUrl.pathname)
4+
export default async function middleware(req: NextRequest) {
5+
return NextResponse.json({ message: 'Hello, world!', joined: join('a', 'b') })
56
}
67

78
export const config = {
8-
runtime: 'nodejs',
9+
// runtime: 'nodejs',
910
}

tests/fixtures/middleware-node/next.config.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,11 @@ const nextConfig = {
77
experimental: {
88
nodeMiddleware: true,
99
},
10+
webpack: (config) => {
11+
// disable minification for easier inspection of produced build output
12+
config.optimization.minimize = false
13+
return config
14+
},
1015
}
1116

1217
module.exports = nextConfig

0 commit comments

Comments
 (0)