Skip to content

Commit 524bcd5

Browse files
authored
[edge] support Node.js core modules in edge runtime (vercel#47191)
This PR enables Node.js core modules in edge runtime by leaving a `require` statement in the output source as externals - [x] buffer - [ ] async_hooks - [ ] util - [ ] assert - [ ] events
1 parent d760c00 commit 524bcd5

File tree

9 files changed

+209
-1
lines changed

9 files changed

+209
-1
lines changed

packages/next/src/build/webpack-config.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import { finalizeEntrypoint } from './entries'
3535
import * as Log from './output/log'
3636
import { buildConfiguration } from './webpack/config'
3737
import MiddlewarePlugin, {
38+
getEdgePolyfilledModules,
3839
handleWebpackExternalForEdgeRuntime,
3940
} from './webpack/plugins/middleware-plugin'
4041
import BuildManifestPlugin from './webpack/plugins/build-manifest-plugin'
@@ -1460,6 +1461,7 @@ export default async function getBaseWebpackConfig(
14601461
'./cjs/react-dom-server-legacy.browser.development.js':
14611462
'{}',
14621463
},
1464+
getEdgePolyfilledModules(),
14631465
handleWebpackExternalForEdgeRuntime,
14641466
]
14651467
: []),

packages/next/src/build/webpack/plugins/middleware-plugin.ts

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -858,6 +858,23 @@ export default class MiddlewarePlugin {
858858
}
859859
}
860860

861+
const supportedEdgePolyfills = new Set([
862+
'buffer',
863+
'events',
864+
'assert',
865+
'util',
866+
'async_hooks',
867+
])
868+
869+
export function getEdgePolyfilledModules() {
870+
const records: Record<string, string> = {}
871+
for (const mod of supportedEdgePolyfills) {
872+
records[mod] = `commonjs node:${mod}`
873+
records[`node:${mod}`] = `commonjs node:${mod}`
874+
}
875+
return records
876+
}
877+
861878
export async function handleWebpackExternalForEdgeRuntime({
862879
request,
863880
context,
@@ -869,7 +886,11 @@ export async function handleWebpackExternalForEdgeRuntime({
869886
contextInfo: any
870887
getResolve: () => any
871888
}) {
872-
if (contextInfo.issuerLayer === 'middleware' && isNodeJsModule(request)) {
889+
if (
890+
contextInfo.issuerLayer === 'middleware' &&
891+
isNodeJsModule(request) &&
892+
!supportedEdgePolyfills.has(request)
893+
) {
873894
// allows user to provide and use their polyfills, as we do with buffer.
874895
try {
875896
await getResolve()(context, request)

packages/next/src/server/web/sandbox/context.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,11 @@ import { fetchInlineAsset } from './fetch-inline-assets'
1616
import type { EdgeFunctionDefinition } from '../../../build/webpack/plugins/middleware-plugin'
1717
import { UnwrapPromise } from '../../../lib/coalesced-function'
1818
import { runInContext } from 'vm'
19+
import BufferImplementation from 'node:buffer'
20+
import EventsImplementation from 'node:events'
21+
import AssertImplementation from 'node:assert'
22+
import UtilImplementation from 'node:util'
23+
import AsyncHooksImplementation from 'node:async_hooks'
1924

2025
const WEBPACK_HASH_REGEX =
2126
/__webpack_require__\.h = function\(\) \{ return "[0-9a-f]+"; \}/g
@@ -139,6 +144,45 @@ function getDecorateUnhandledRejection(runtime: EdgeRuntime) {
139144
}
140145
}
141146

147+
const NativeModuleMap = new Map<string, unknown>([
148+
[
149+
'node:buffer',
150+
pick(BufferImplementation, [
151+
'constants',
152+
'kMaxLength',
153+
'kStringMaxLength',
154+
'Buffer',
155+
'SlowBuffer',
156+
]),
157+
],
158+
[
159+
'node:events',
160+
pick(EventsImplementation, [
161+
'EventEmitter',
162+
'captureRejectionSymbol',
163+
'defaultMaxListeners',
164+
'errorMonitor',
165+
'listenerCount',
166+
'on',
167+
'once',
168+
]),
169+
],
170+
[
171+
'node:async_hooks',
172+
pick(AsyncHooksImplementation, ['AsyncLocalStorage', 'AsyncResource']),
173+
],
174+
[
175+
'node:assert',
176+
// TODO: check if need to pick specific properties
177+
AssertImplementation,
178+
],
179+
[
180+
'node:util',
181+
// TODO: check if need to pick specific properties
182+
UtilImplementation,
183+
],
184+
])
185+
142186
/**
143187
* Create a module cache specific for the provided parameters. It includes
144188
* a runtime context, require cache and paths cache.
@@ -155,6 +199,17 @@ async function createModuleContext(options: ModuleContextOptions) {
155199
extend: (context) => {
156200
context.process = createProcessPolyfill(options)
157201

202+
Object.defineProperty(context, 'require', {
203+
enumerable: false,
204+
value: (id: string) => {
205+
const value = NativeModuleMap.get(id)
206+
if (!value) {
207+
throw TypeError('Native module not found: ' + id)
208+
}
209+
return value
210+
},
211+
})
212+
158213
context.__next_eval__ = function __next_eval__(fn: Function) {
159214
const key = fn.toString()
160215
if (!warnedEvals.has(key)) {
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import B from 'node:buffer'
2+
import { NextResponse } from 'next/server'
3+
4+
/**
5+
* @param {Request} req
6+
*/
7+
export async function POST(req) {
8+
const text = await req.text()
9+
const buf = B.Buffer.from(text)
10+
return NextResponse.json({
11+
'Buffer === B.Buffer': B.Buffer === Buffer,
12+
encoded: buf.toString('base64'),
13+
exposedKeys: Object.keys(B),
14+
})
15+
}
16+
17+
export const runtime = 'edge'
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
export default function Root({ children }: { children: React.ReactNode }) {
2+
return (
3+
<html>
4+
<body>{children}</body>
5+
</html>
6+
)
7+
}
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import { createNextDescribe } from 'e2e-utils'
2+
3+
createNextDescribe(
4+
'edge runtime node compatibility',
5+
{
6+
files: __dirname,
7+
},
8+
({ next }) => {
9+
it('[app] supports node:buffer', async () => {
10+
const res = await next.fetch('/buffer', {
11+
method: 'POST',
12+
body: 'Hello, world!',
13+
})
14+
const json = await res.json()
15+
expect(json).toEqual({
16+
'Buffer === B.Buffer': true,
17+
encoded: Buffer.from('Hello, world!').toString('base64'),
18+
exposedKeys: [
19+
'constants',
20+
'kMaxLength',
21+
'kStringMaxLength',
22+
'Buffer',
23+
'SlowBuffer',
24+
],
25+
})
26+
})
27+
28+
it('[pages/api] supports node:buffer', async () => {
29+
const res = await next.fetch('/api/buffer', {
30+
method: 'POST',
31+
body: 'Hello, world!',
32+
})
33+
const json = await res.json()
34+
expect(json).toEqual({
35+
'B2.Buffer === B.Buffer': true,
36+
'Buffer === B.Buffer': true,
37+
'typeof B.Buffer': 'function',
38+
'typeof B2.Buffer': 'function',
39+
'typeof Buffer': 'function',
40+
encoded: 'SGVsbG8sIHdvcmxkIQ==',
41+
exposedKeys: [
42+
'constants',
43+
'kMaxLength',
44+
'kStringMaxLength',
45+
'Buffer',
46+
'SlowBuffer',
47+
],
48+
})
49+
})
50+
}
51+
)
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
/**
2+
* @type {import('next').NextConfig}
3+
*/
4+
const nextConfig = {
5+
experimental: { appDir: true },
6+
}
7+
8+
module.exports = nextConfig
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import B from 'node:buffer'
2+
import B2 from 'buffer'
3+
import { NextResponse } from 'next/server'
4+
5+
export const config = { runtime: 'edge' }
6+
7+
/**
8+
* @param {Request} req
9+
*/
10+
export default async function (req) {
11+
const text = await req.text()
12+
const buf = B.Buffer.from(text)
13+
return NextResponse.json({
14+
'Buffer === B.Buffer': B.Buffer === Buffer,
15+
'B2.Buffer === B.Buffer': B.Buffer === B2.Buffer,
16+
'typeof Buffer': typeof Buffer,
17+
'typeof B.Buffer': typeof B.Buffer,
18+
'typeof B2.Buffer': typeof B2.Buffer,
19+
encoded: buf.toString('base64'),
20+
exposedKeys: Object.keys(B),
21+
})
22+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
{
2+
"compilerOptions": {
3+
"lib": ["dom", "dom.iterable", "esnext"],
4+
"allowJs": true,
5+
"skipLibCheck": true,
6+
"strict": false,
7+
"forceConsistentCasingInFileNames": true,
8+
"noEmit": true,
9+
"incremental": true,
10+
"esModuleInterop": true,
11+
"module": "esnext",
12+
"moduleResolution": "node",
13+
"resolveJsonModule": true,
14+
"isolatedModules": true,
15+
"jsx": "preserve",
16+
"plugins": [
17+
{
18+
"name": "next"
19+
}
20+
],
21+
"strictNullChecks": true
22+
},
23+
"include": ["next-env.d.ts", ".next/types/**/*.ts", "**/*.ts", "**/*.tsx"],
24+
"exclude": ["node_modules"]
25+
}

0 commit comments

Comments
 (0)