diff --git a/packages/next/src/build/webpack-config.ts b/packages/next/src/build/webpack-config.ts index 7c2a6d6797496..1fd8b3af185d4 100644 --- a/packages/next/src/build/webpack-config.ts +++ b/packages/next/src/build/webpack-config.ts @@ -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' @@ -1450,6 +1451,7 @@ export default async function getBaseWebpackConfig( './cjs/react-dom-server-legacy.browser.development.js': '{}', }, + getEdgePolyfilledModules(), handleWebpackExternalForEdgeRuntime, ] : []), diff --git a/packages/next/src/build/webpack/plugins/middleware-plugin.ts b/packages/next/src/build/webpack/plugins/middleware-plugin.ts index 229c105f7fe55..648f63ebaade8 100644 --- a/packages/next/src/build/webpack/plugins/middleware-plugin.ts +++ b/packages/next/src/build/webpack/plugins/middleware-plugin.ts @@ -858,6 +858,23 @@ export default class MiddlewarePlugin { } } +const supportedEdgePolyfills = new Set([ + 'buffer', + 'events', + 'assert', + 'util', + 'async_hooks', +]) + +export function getEdgePolyfilledModules() { + const records: Record = {} + 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) diff --git a/packages/next/src/server/web/sandbox/context.ts b/packages/next/src/server/web/sandbox/context.ts index eb0ba9fcae1c3..86b13cd98f0f3 100644 --- a/packages/next/src/server/web/sandbox/context.ts +++ b/packages/next/src/server/web/sandbox/context.ts @@ -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([ + [ + '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)) { diff --git a/test/e2e/app-dir/edge-runtime-node-compatibility/app/buffer/route.js b/test/e2e/app-dir/edge-runtime-node-compatibility/app/buffer/route.js new file mode 100644 index 0000000000000..f7c4a783f2da3 --- /dev/null +++ b/test/e2e/app-dir/edge-runtime-node-compatibility/app/buffer/route.js @@ -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' diff --git a/test/e2e/app-dir/edge-runtime-node-compatibility/app/layout.tsx b/test/e2e/app-dir/edge-runtime-node-compatibility/app/layout.tsx new file mode 100644 index 0000000000000..e7077399c03ce --- /dev/null +++ b/test/e2e/app-dir/edge-runtime-node-compatibility/app/layout.tsx @@ -0,0 +1,7 @@ +export default function Root({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ) +} diff --git a/test/e2e/app-dir/edge-runtime-node-compatibility/edge-runtime-node-compatibility.test.ts b/test/e2e/app-dir/edge-runtime-node-compatibility/edge-runtime-node-compatibility.test.ts new file mode 100644 index 0000000000000..ccc7f33aec946 --- /dev/null +++ b/test/e2e/app-dir/edge-runtime-node-compatibility/edge-runtime-node-compatibility.test.ts @@ -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', + ], + }) + }) + } +) diff --git a/test/e2e/app-dir/edge-runtime-node-compatibility/next.config.js b/test/e2e/app-dir/edge-runtime-node-compatibility/next.config.js new file mode 100644 index 0000000000000..bf49894afd400 --- /dev/null +++ b/test/e2e/app-dir/edge-runtime-node-compatibility/next.config.js @@ -0,0 +1,8 @@ +/** + * @type {import('next').NextConfig} + */ +const nextConfig = { + experimental: { appDir: true }, +} + +module.exports = nextConfig diff --git a/test/e2e/app-dir/edge-runtime-node-compatibility/pages/api/buffer.js b/test/e2e/app-dir/edge-runtime-node-compatibility/pages/api/buffer.js new file mode 100644 index 0000000000000..d847e990af7a9 --- /dev/null +++ b/test/e2e/app-dir/edge-runtime-node-compatibility/pages/api/buffer.js @@ -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), + }) +} diff --git a/test/e2e/app-dir/edge-runtime-node-compatibility/tsconfig.json b/test/e2e/app-dir/edge-runtime-node-compatibility/tsconfig.json new file mode 100644 index 0000000000000..9c9b16c24b793 --- /dev/null +++ b/test/e2e/app-dir/edge-runtime-node-compatibility/tsconfig.json @@ -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"] +}