Skip to content

Commit de3a81a

Browse files
committed
use virtual CJS modules
1 parent 2031077 commit de3a81a

File tree

4 files changed

+189
-34
lines changed

4 files changed

+189
-34
lines changed

edge-runtime/lib/cjs.ts

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
import { Module, createRequire } from 'node:module'
2+
import vm from 'node:vm'
3+
import { join, dirname } from 'node:path/posix'
4+
import { fileURLToPath, pathToFileURL } from 'node:url'
5+
6+
type RegisteredModule = {
7+
source: string
8+
loaded: boolean
9+
filename: string
10+
}
11+
const registeredModules = new Map<string, RegisteredModule>()
12+
13+
const require = createRequire(import.meta.url)
14+
15+
let hookedIn = false
16+
17+
function seedCJSModuleCacheAndReturnTarget(matchedModule: RegisteredModule, parent: Module) {
18+
console.error('matched', matchedModule.filename)
19+
if (matchedModule.loaded) {
20+
return matchedModule.filename
21+
}
22+
const { source, filename } = matchedModule
23+
console.error('evaluating module', { filename })
24+
25+
const mod = new Module(filename)
26+
mod.parent = parent
27+
mod.filename = filename
28+
mod.path = dirname(filename)
29+
// @ts-expect-error - private untyped API
30+
mod.paths = Module._nodeModulePaths(mod.path)
31+
require.cache[filename] = mod
32+
33+
const wrappedSource = `(function (exports, require, module, __filename, __dirname) { ${source}\n});`
34+
const compiled = vm.runInThisContext(wrappedSource, {
35+
filename,
36+
lineOffset: 0,
37+
displayErrors: true,
38+
})
39+
compiled(mod.exports, createRequire(pathToFileURL(filename)), mod, filename, dirname(filename))
40+
mod.loaded = matchedModule.loaded = true
41+
42+
console.error('evaluated module', { filename })
43+
return filename
44+
}
45+
46+
const exts = ['.js', '.cjs', '.json']
47+
48+
function tryWithExtensions(filename: string) {
49+
// console.error('trying to match', filename)
50+
let matchedModule = registeredModules.get(filename)
51+
if (!matchedModule) {
52+
for (const ext of exts) {
53+
// require("./test") might resolve to ./test.js
54+
const targetWithExt = filename + ext
55+
56+
matchedModule = registeredModules.get(targetWithExt)
57+
if (matchedModule) {
58+
break
59+
}
60+
}
61+
}
62+
63+
return matchedModule
64+
}
65+
66+
function tryMatchingWithIndex(target: string) {
67+
console.error('trying to match', target)
68+
let matchedModule = tryWithExtensions(target)
69+
if (!matchedModule) {
70+
// require("./test") might resolve to ./test/index.js
71+
const indexTarget = join(target, 'index')
72+
matchedModule = tryWithExtensions(indexTarget)
73+
}
74+
75+
return matchedModule
76+
}
77+
78+
export function registerCJSModules(baseUrl: URL, modules: Map<string, string>) {
79+
const basePath = dirname(fileURLToPath(baseUrl))
80+
81+
for (const [filename, source] of modules.entries()) {
82+
const target = join(basePath, filename)
83+
84+
registeredModules.set(target, { source, loaded: false, filename: target })
85+
}
86+
87+
console.error([...registeredModules.values()].map((m) => m.filename))
88+
89+
if (!hookedIn) {
90+
// magic
91+
// @ts-expect-error - private untyped API
92+
const original_resolveFilename = Module._resolveFilename.bind(Module)
93+
// @ts-expect-error - private untyped API
94+
Module._resolveFilename = (...args) => {
95+
console.error(
96+
'resolving file name for specifier',
97+
args[0] ?? '--missing specifier--',
98+
'from',
99+
args[1]?.filename ?? 'unknown',
100+
)
101+
let target = args[0]
102+
let isRelative = args?.[0].startsWith('.')
103+
104+
if (isRelative) {
105+
// only handle relative require paths
106+
const requireFrom = args?.[1]?.filename
107+
108+
target = join(dirname(requireFrom), args[0])
109+
}
110+
111+
let matchedModule = tryMatchingWithIndex(target)
112+
113+
if (!isRelative && !target.startsWith('/')) {
114+
console.log('not relative, checking node_modules', args[0])
115+
for (const nodeModulePaths of args[1].paths) {
116+
const potentialPath = join(nodeModulePaths, target)
117+
console.log('checking potential path', potentialPath)
118+
matchedModule = tryMatchingWithIndex(potentialPath)
119+
if (matchedModule) {
120+
break
121+
}
122+
}
123+
}
124+
125+
if (matchedModule) {
126+
console.log('matched module', matchedModule.filename)
127+
return seedCJSModuleCacheAndReturnTarget(matchedModule, args[1])
128+
}
129+
130+
return original_resolveFilename(...args)
131+
}
132+
133+
hookedIn = true
134+
}
135+
}

src/build/functions/edge.ts

Lines changed: 52 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -214,9 +214,9 @@ export const createEdgeHandlers = async (ctx: PluginContext) => {
214214
const srcDir = join(ctx.standaloneDir, ctx.nextDistDir)
215215
// const destDir = join(ctx.edgeFunctionsDir, getHandlerName({ name }))
216216

217-
const fakeNodeModuleName = 'fake-module-with-middleware'
217+
// const fakeNodeModuleName = 'fake-module-with-middleware'
218218

219-
const fakeNodeModulePath = ctx.resolveFromPackagePath(join('node_modules', fakeNodeModuleName))
219+
// const fakeNodeModulePath = ctx.resolveFromPackagePath(join('node_modules', fakeNodeModuleName))
220220

221221
const nftFilesPath = join(process.cwd(), ctx.nextDistDir, nft)
222222
const nftManifest = JSON.parse(await readFile(nftFilesPath, 'utf8'))
@@ -246,47 +246,53 @@ export const createEdgeHandlers = async (ctx: PluginContext) => {
246246
prefixPath += `nested-${nestedIndex}/`
247247
}
248248

249+
let virtualModules = ''
249250
for (const file of files) {
250251
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-
}
252+
253+
const content = await readFile(srcPath, 'utf8')
254+
255+
virtualModules += `virtualModules.set(${JSON.stringify(join(prefixPath, file))}, ${JSON.stringify(content)});\n`
256+
257+
// const destPath = join(fakeNodeModulePath, prefixPath, file)
258+
259+
// await mkdir(dirname(destPath), { recursive: true })
260+
261+
// if (file === entry) {
262+
// const content = await readFile(srcPath, 'utf8')
263+
// await writeFile(
264+
// destPath,
265+
// // Next.js needs to be set on global even if it's possible to just require it
266+
// // so somewhat similar to existing shim we have for edge runtime
267+
// `globalThis.AsyncLocalStorage = require('node:async_hooks').AsyncLocalStorage;\n${content}`,
268+
// )
269+
// } else {
270+
// await cp(srcPath, destPath, { force: true })
271+
// }
266272
}
267273

268-
await writeFile(join(fakeNodeModulePath, 'package.json'), JSON.stringify({ type: 'commonjs' }))
274+
// await writeFile(join(fakeNodeModulePath, 'package.json'), JSON.stringify({ type: 'commonjs' }))
269275

270276
// there is `/chunks/**/*` require coming from webpack-runtime that fails esbuild due to nothing matching,
271277
// 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, '')
278+
// const dummyChunkPath = join(fakeNodeModulePath, prefixPath, 'server', 'chunks', 'dummy.js')
279+
// await mkdir(dirname(dummyChunkPath), { recursive: true })
280+
// await writeFile(dummyChunkPath, '')
275281

276282
// there is also `@opentelemetry/api` require that fails esbuild due to nothing matching,
277283
// next is try/catching it and fallback to bundled version of otel package in case of errors
278-
const otelApiPath = join(
279-
fakeNodeModulePath,
280-
'node_modules',
281-
'@opentelemetry',
282-
'api',
283-
'index.js',
284-
)
285-
await mkdir(dirname(otelApiPath), { recursive: true })
286-
await writeFile(
287-
otelApiPath,
288-
`throw new Error('this is dummy to satisfy esbuild used for npm compat using fake module')`,
289-
)
284+
// const otelApiPath = join(
285+
// fakeNodeModulePath,
286+
// 'node_modules',
287+
// '@opentelemetry',
288+
// 'api',
289+
// 'index.js',
290+
// )
291+
// await mkdir(dirname(otelApiPath), { recursive: true })
292+
// await writeFile(
293+
// otelApiPath,
294+
// `throw new Error('this is dummy to satisfy esbuild used for npm compat using fake module')`,
295+
// )
290296

291297
// await writeHandlerFile(ctx, definition)
292298

@@ -333,11 +339,23 @@ export const createEdgeHandlers = async (ctx: PluginContext) => {
333339
await writeFile(
334340
join(handlerDirectory, `${handlerName}.js`),
335341
`
342+
import { createRequire } from "node:module";
336343
import { init as htmlRewriterInit } from './edge-runtime/vendor/deno.land/x/[email protected]/src/index.ts'
337344
import { handleMiddleware } from './edge-runtime/middleware.ts';
345+
import { registerCJSModules } from "./edge-runtime/lib/cjs.ts";
346+
import { AsyncLocalStorage } from 'node:async_hooks';
347+
348+
globalThis.AsyncLocalStorage = AsyncLocalStorage;
349+
350+
// needed for path.relative and path.resolve to work
351+
Deno.cwd = () => ''
338352
339-
import * as handlerMod from '${fakeNodeModuleName}/${prefixPath}${entry}';
353+
const virtualModules = new Map();
354+
${virtualModules}
355+
registerCJSModules(import.meta.url, virtualModules);
340356
357+
const require = createRequire(import.meta.url);
358+
const handlerMod = require("./${prefixPath}/${entry}");
341359
const handler = handlerMod.default || handlerMod;
342360
343361
await htmlRewriterInit({ module_or_path: Uint8Array.from(${JSON.stringify([

tests/fixtures/hello-world-turbopack/middleware.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,4 +9,5 @@ export function middleware(request: NextRequest) {
99

1010
export const config = {
1111
matcher: '/middleware/:path*',
12+
runtime: 'nodejs',
1213
}

tests/fixtures/middleware/next.config.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ const nextConfig = {
77
webpack: (config) => {
88
// this is a trigger to generate multiple `.next/server/middleware-[hash].js` files instead of
99
// single `.next/server/middleware.js` file
10+
// this doesn't seem to actually work with Node Middleware - it result in next build failures
1011
// config.optimization.splitChunks.maxSize = 100_000
1112

1213
return config

0 commit comments

Comments
 (0)