Skip to content

[wip] Feat/node middleware support #3018

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

Draft
wants to merge 10 commits into
base: main
Choose a base branch
from
Draft
2 changes: 1 addition & 1 deletion .github/workflows/run-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ jobs:
elif [ "${{ github.event_name }}" = "schedule" ] || [ "${{ steps.check-labels.outputs.result }}" = "true" ]; then
echo "matrix=[\"latest\", \"canary\", \"14.2.15\", \"13.5.1\"]" >> $GITHUB_OUTPUT
else
echo "matrix=[\"latest\"]" >> $GITHUB_OUTPUT
echo "matrix=[\"canary\"]" >> $GITHUB_OUTPUT
fi

e2e:
Expand Down
135 changes: 135 additions & 0 deletions edge-runtime/lib/cjs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import { Module, createRequire } from 'node:module'
import vm from 'node:vm'
import { join, dirname } from 'node:path/posix'
import { fileURLToPath, pathToFileURL } from 'node:url'

type RegisteredModule = {
source: string
loaded: boolean
filename: string
}
const registeredModules = new Map<string, RegisteredModule>()

const require = createRequire(import.meta.url)

let hookedIn = false

function seedCJSModuleCacheAndReturnTarget(matchedModule: RegisteredModule, parent: Module) {
console.error('matched', matchedModule.filename)
if (matchedModule.loaded) {
return matchedModule.filename
}
const { source, filename } = matchedModule
console.error('evaluating module', { filename })

const mod = new Module(filename)
mod.parent = parent
mod.filename = filename
mod.path = dirname(filename)
// @ts-expect-error - private untyped API
mod.paths = Module._nodeModulePaths(mod.path)
require.cache[filename] = mod

const wrappedSource = `(function (exports, require, module, __filename, __dirname) { ${source}\n});`
const compiled = vm.runInThisContext(wrappedSource, {
filename,
lineOffset: 0,
displayErrors: true,
})
compiled(mod.exports, createRequire(pathToFileURL(filename)), mod, filename, dirname(filename))
mod.loaded = matchedModule.loaded = true

console.error('evaluated module', { filename })
return filename
}

const exts = ['.js', '.cjs', '.json']

function tryWithExtensions(filename: string) {
// console.error('trying to match', filename)
let matchedModule = registeredModules.get(filename)
if (!matchedModule) {
for (const ext of exts) {
// require("./test") might resolve to ./test.js
const targetWithExt = filename + ext

matchedModule = registeredModules.get(targetWithExt)
if (matchedModule) {
break
}
}
}

return matchedModule
}

function tryMatchingWithIndex(target: string) {
console.error('trying to match', target)
let matchedModule = tryWithExtensions(target)
if (!matchedModule) {
// require("./test") might resolve to ./test/index.js
const indexTarget = join(target, 'index')
matchedModule = tryWithExtensions(indexTarget)
}

return matchedModule
}

export function registerCJSModules(baseUrl: URL, modules: Map<string, string>) {
const basePath = dirname(fileURLToPath(baseUrl))

for (const [filename, source] of modules.entries()) {
const target = join(basePath, filename)

registeredModules.set(target, { source, loaded: false, filename: target })
}

console.error([...registeredModules.values()].map((m) => m.filename))

if (!hookedIn) {
// magic
// @ts-expect-error - private untyped API
const original_resolveFilename = Module._resolveFilename.bind(Module)
// @ts-expect-error - private untyped API
Module._resolveFilename = (...args) => {
console.error(
'resolving file name for specifier',
args[0] ?? '--missing specifier--',
'from',
args[1]?.filename ?? 'unknown',
)
let target = args[0]
let isRelative = args?.[0].startsWith('.')

if (isRelative) {
// only handle relative require paths
const requireFrom = args?.[1]?.filename

target = join(dirname(requireFrom), args[0])
}

let matchedModule = tryMatchingWithIndex(target)

if (!isRelative && !target.startsWith('/')) {
console.log('not relative, checking node_modules', args[0])
for (const nodeModulePaths of args[1].paths) {
const potentialPath = join(nodeModulePaths, target)
console.log('checking potential path', potentialPath)
matchedModule = tryMatchingWithIndex(potentialPath)
if (matchedModule) {
break
}
}
}

if (matchedModule) {
console.log('matched module', matchedModule.filename)
return seedCJSModuleCacheAndReturnTarget(matchedModule, args[1])
}

return original_resolveFilename(...args)
}

hookedIn = true
}
}
49 changes: 49 additions & 0 deletions src/build/content/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { trace } from '@opentelemetry/api'
import { wrapTracer } from '@opentelemetry/api/experimental'
import glob from 'fast-glob'
import type { MiddlewareManifest } from 'next/dist/build/webpack/plugins/middleware-plugin.js'
import type { FunctionsConfigManifest } from 'next-with-cache-handler-v2/dist/build/index.js'
import { prerelease, satisfies, lt as semverLowerThan, lte as semverLowerThanOrEqual } from 'semver'

import type { RunConfig } from '../../run/config.js'
Expand Down Expand Up @@ -131,6 +132,16 @@ export const copyNextServerCode = async (ctx: PluginContext): Promise<void> => {
return
}

if (path === 'server/functions-config-manifest.json') {
try {
await replaceFunctionsConfigManifest(srcPath, destPath)
} catch (error) {
throw new Error('Could not patch functions config manifest file', { cause: error })
}

return
}

await cp(srcPath, destPath, { recursive: true, force: true })
}),
)
Expand Down Expand Up @@ -376,6 +387,44 @@ const replaceMiddlewareManifest = async (sourcePath: string, destPath: string) =
await writeFile(destPath, newData)
}

// similar to the middleware manifest, we need to patch the functions config manifest to disable
// the middleware that is defined in the functions config manifest. This is needed to avoid running
// the middleware in the server handler, while still allowing next server to enable some middleware
// 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 )
const replaceFunctionsConfigManifest = async (sourcePath: string, destPath: string) => {
const data = await readFile(sourcePath, 'utf8')
const manifest = JSON.parse(data) as FunctionsConfigManifest

// https://github.com/vercel/next.js/blob/8367faedd61501025299e92d43a28393c7bb50e2/packages/next/src/build/index.ts#L2465
// Node.js Middleware has hardcoded /_middleware path
if (manifest?.functions?.['/_middleware']?.matchers) {
const newManifest = {
...manifest,
functions: {
...manifest.functions,
'/_middleware': {
...manifest.functions['/_middleware'],
matchers: manifest.functions['/_middleware'].matchers.map((matcher) => {
return {
...matcher,
// matcher that won't match on anything
// this is meant to disable actually running middleware in the server handler,
// while still allowing next server to enable some middleware 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 )
regexp: '(?!.*)',
}
}),
},
},
}
const newData = JSON.stringify(newManifest)

await writeFile(destPath, newData)
} else {
await cp(sourcePath, destPath, { recursive: true, force: true })
}
}

export const verifyHandlerDirStructure = async (ctx: PluginContext) => {
const { nextConfig } = JSON.parse(
await readFile(join(ctx.serverHandlerDir, RUN_CONFIG_FILE), 'utf-8'),
Expand Down
Loading
Loading