[edge] support Node.js core modules in edge runtime (#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
This commit is contained in:
Gal Schlezinger 2023-03-17 14:29:31 +02:00 committed by GitHub
parent d760c00961
commit 524bcd563b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 209 additions and 1 deletions

View file

@ -35,6 +35,7 @@ import { finalizeEntrypoint } from './entries'
import * as Log from './output/log'
import { buildConfiguration } from './webpack/config'
import MiddlewarePlugin, {
getEdgePolyfilledModules,
handleWebpackExternalForEdgeRuntime,
} from './webpack/plugins/middleware-plugin'
import BuildManifestPlugin from './webpack/plugins/build-manifest-plugin'
@ -1460,6 +1461,7 @@ export default async function getBaseWebpackConfig(
'./cjs/react-dom-server-legacy.browser.development.js':
'{}',
},
getEdgePolyfilledModules(),
handleWebpackExternalForEdgeRuntime,
]
: []),

View file

@ -858,6 +858,23 @@ export default class MiddlewarePlugin {
}
}
const supportedEdgePolyfills = new Set([
'buffer',
'events',
'assert',
'util',
'async_hooks',
])
export function getEdgePolyfilledModules() {
const records: Record<string, string> = {}
for (const mod of supportedEdgePolyfills) {
records[mod] = `commonjs node:${mod}`
records[`node:${mod}`] = `commonjs node:${mod}`
}
return records
}
export async function handleWebpackExternalForEdgeRuntime({
request,
context,
@ -869,7 +886,11 @@ export async function handleWebpackExternalForEdgeRuntime({
contextInfo: any
getResolve: () => any
}) {
if (contextInfo.issuerLayer === 'middleware' && isNodeJsModule(request)) {
if (
contextInfo.issuerLayer === 'middleware' &&
isNodeJsModule(request) &&
!supportedEdgePolyfills.has(request)
) {
// allows user to provide and use their polyfills, as we do with buffer.
try {
await getResolve()(context, request)

View file

@ -16,6 +16,11 @@ import { fetchInlineAsset } from './fetch-inline-assets'
import type { EdgeFunctionDefinition } from '../../../build/webpack/plugins/middleware-plugin'
import { UnwrapPromise } from '../../../lib/coalesced-function'
import { runInContext } from 'vm'
import BufferImplementation from 'node:buffer'
import EventsImplementation from 'node:events'
import AssertImplementation from 'node:assert'
import UtilImplementation from 'node:util'
import AsyncHooksImplementation from 'node:async_hooks'
const WEBPACK_HASH_REGEX =
/__webpack_require__\.h = function\(\) \{ return "[0-9a-f]+"; \}/g
@ -139,6 +144,45 @@ function getDecorateUnhandledRejection(runtime: EdgeRuntime) {
}
}
const NativeModuleMap = new Map<string, unknown>([
[
'node:buffer',
pick(BufferImplementation, [
'constants',
'kMaxLength',
'kStringMaxLength',
'Buffer',
'SlowBuffer',
]),
],
[
'node:events',
pick(EventsImplementation, [
'EventEmitter',
'captureRejectionSymbol',
'defaultMaxListeners',
'errorMonitor',
'listenerCount',
'on',
'once',
]),
],
[
'node:async_hooks',
pick(AsyncHooksImplementation, ['AsyncLocalStorage', 'AsyncResource']),
],
[
'node:assert',
// TODO: check if need to pick specific properties
AssertImplementation,
],
[
'node:util',
// TODO: check if need to pick specific properties
UtilImplementation,
],
])
/**
* Create a module cache specific for the provided parameters. It includes
* a runtime context, require cache and paths cache.
@ -155,6 +199,17 @@ async function createModuleContext(options: ModuleContextOptions) {
extend: (context) => {
context.process = createProcessPolyfill(options)
Object.defineProperty(context, 'require', {
enumerable: false,
value: (id: string) => {
const value = NativeModuleMap.get(id)
if (!value) {
throw TypeError('Native module not found: ' + id)
}
return value
},
})
context.__next_eval__ = function __next_eval__(fn: Function) {
const key = fn.toString()
if (!warnedEvals.has(key)) {

View file

@ -0,0 +1,17 @@
import B from 'node:buffer'
import { NextResponse } from 'next/server'
/**
* @param {Request} req
*/
export async function POST(req) {
const text = await req.text()
const buf = B.Buffer.from(text)
return NextResponse.json({
'Buffer === B.Buffer': B.Buffer === Buffer,
encoded: buf.toString('base64'),
exposedKeys: Object.keys(B),
})
}
export const runtime = 'edge'

View file

@ -0,0 +1,7 @@
export default function Root({ children }: { children: React.ReactNode }) {
return (
<html>
<body>{children}</body>
</html>
)
}

View file

@ -0,0 +1,51 @@
import { createNextDescribe } from 'e2e-utils'
createNextDescribe(
'edge runtime node compatibility',
{
files: __dirname,
},
({ next }) => {
it('[app] supports node:buffer', async () => {
const res = await next.fetch('/buffer', {
method: 'POST',
body: 'Hello, world!',
})
const json = await res.json()
expect(json).toEqual({
'Buffer === B.Buffer': true,
encoded: Buffer.from('Hello, world!').toString('base64'),
exposedKeys: [
'constants',
'kMaxLength',
'kStringMaxLength',
'Buffer',
'SlowBuffer',
],
})
})
it('[pages/api] supports node:buffer', async () => {
const res = await next.fetch('/api/buffer', {
method: 'POST',
body: 'Hello, world!',
})
const json = await res.json()
expect(json).toEqual({
'B2.Buffer === B.Buffer': true,
'Buffer === B.Buffer': true,
'typeof B.Buffer': 'function',
'typeof B2.Buffer': 'function',
'typeof Buffer': 'function',
encoded: 'SGVsbG8sIHdvcmxkIQ==',
exposedKeys: [
'constants',
'kMaxLength',
'kStringMaxLength',
'Buffer',
'SlowBuffer',
],
})
})
}
)

View file

@ -0,0 +1,8 @@
/**
* @type {import('next').NextConfig}
*/
const nextConfig = {
experimental: { appDir: true },
}
module.exports = nextConfig

View file

@ -0,0 +1,22 @@
import B from 'node:buffer'
import B2 from 'buffer'
import { NextResponse } from 'next/server'
export const config = { runtime: 'edge' }
/**
* @param {Request} req
*/
export default async function (req) {
const text = await req.text()
const buf = B.Buffer.from(text)
return NextResponse.json({
'Buffer === B.Buffer': B.Buffer === Buffer,
'B2.Buffer === B.Buffer': B.Buffer === B2.Buffer,
'typeof Buffer': typeof Buffer,
'typeof B.Buffer': typeof B.Buffer,
'typeof B2.Buffer': typeof B2.Buffer,
encoded: buf.toString('base64'),
exposedKeys: Object.keys(B),
})
}

View file

@ -0,0 +1,25 @@
{
"compilerOptions": {
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": false,
"forceConsistentCasingInFileNames": true,
"noEmit": true,
"incremental": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"plugins": [
{
"name": "next"
}
],
"strictNullChecks": true
},
"include": ["next-env.d.ts", ".next/types/**/*.ts", "**/*.ts", "**/*.tsx"],
"exclude": ["node_modules"]
}