Skip to content

Commit 76cedcc

Browse files
committed
fix(rsc): convert vendored react-server-dom-webpack to ESM
The vendor-react-server-dom build plugin copies react-server-dom-webpack into dist/vendor/ as raw CJS files. These files use require() and module.exports which break on pure ESM runtimes (Cloudflare Workers, Deno Deploy) where require() does not exist. When a framework builds for Cloudflare Workers with resolve.noExternal set to true, the CJS vendor files end up in the production bundle verbatim, causing "require is not defined" and "exports is not defined" at deploy time. This is hard to debug because it only affects ESM-only runtimes in production builds; Node.js and dev mode work fine. The fix adds an esbuild conversion step after the fs.cpSync in the tsdown build config. For each CJS entry file (server.edge.js, client.edge.js, client.browser.js, etc.), it: 1. Bundles with esbuild (format: esm, bundle: true) to inline the ./cjs/*.production.js require chain 2. Replaces __require("react") with top-level ESM imports (esbuild can not lift requires from __commonJS wrappers to import statements) 3. Removes the __require shim that references the require global 4. Extracts named exports from CJS exports.xxx patterns and generates proper ESM export var { ... } = default 5. Removes the now-unused cjs/ subdirectory Session: ses_22b5a7d8affeczTeFI1DGoYPd5
1 parent bb42093 commit 76cedcc

3 files changed

Lines changed: 468 additions & 51 deletions

File tree

packages/plugin-rsc/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@
5959
"@vitejs/plugin-react": "workspace:*",
6060
"@vitejs/test-dep-cjs-and-esm": "./test-dep/cjs-and-esm",
6161
"@vitejs/test-dep-cjs-falsy-primitive": "./test-dep/cjs-falsy-primitive",
62+
"esbuild": "^0.28.0",
6263
"picocolors": "^1.1.1",
6364
"react": "^19.2.5",
6465
"react-dom": "^19.2.5",

packages/plugin-rsc/tsdown.config.ts

Lines changed: 146 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
import fs from 'node:fs'
2+
import path from 'node:path'
3+
import { build } from 'esbuild'
24
import { defineConfig } from 'tsdown'
35

46
export default defineConfig({
@@ -31,7 +33,7 @@ export default defineConfig({
3133
plugins: [
3234
{
3335
name: 'vendor-react-server-dom',
34-
buildStart() {
36+
async buildStart() {
3537
fs.rmSync('./dist/vendor/', { recursive: true, force: true })
3638
fs.mkdirSync('./dist/vendor', { recursive: true })
3739
fs.cpSync(
@@ -43,7 +45,150 @@ export default defineConfig({
4345
recursive: true,
4446
force: true,
4547
})
48+
// Convert CJS entry files to ESM so they work on pure ESM runtimes
49+
// (Cloudflare Workers, Deno Deploy) where require() is not available.
50+
// Without this, production builds that bundle vendor files (e.g. with
51+
// resolve.noExternal: true) fail with "require is not defined".
52+
await convertVendorToEsm('./dist/vendor/react-server-dom')
4653
},
4754
},
4855
],
4956
}) as any
57+
58+
// Convert CJS entry files in the vendor directory to ESM in-place using esbuild.
59+
// Each entry file (server.edge.js, client.edge.js, etc.) is a thin CJS wrapper
60+
// that require()s a ./cjs/*.production.js file. esbuild bundles them into
61+
// self-contained ESM with proper import/export statements.
62+
async function convertVendorToEsm(vendorDir: string) {
63+
const entries = fs
64+
.readdirSync(vendorDir)
65+
.filter((f) => f.endsWith('.js'))
66+
.filter((f) => {
67+
const content = fs.readFileSync(path.join(vendorDir, f), 'utf-8')
68+
return content.includes('require(') || content.includes('exports.')
69+
})
70+
71+
for (const entry of entries) {
72+
const entryPath = path.join(vendorDir, entry)
73+
const content = fs.readFileSync(entryPath, 'utf-8')
74+
75+
let result
76+
try {
77+
result = await build({
78+
entryPoints: [entryPath],
79+
bundle: true,
80+
format: 'esm',
81+
write: false,
82+
platform: 'neutral',
83+
external: [
84+
'react',
85+
'react-dom',
86+
'react/jsx-runtime',
87+
'react/jsx-dev-runtime',
88+
// Node.js built-ins for .node.js variants
89+
'node:*',
90+
'util',
91+
'crypto',
92+
'stream',
93+
'async_hooks',
94+
],
95+
define: { 'process.env.NODE_ENV': '"production"' },
96+
sourcemap: false,
97+
logLevel: 'silent',
98+
})
99+
} catch {
100+
// node-register.js and plugin.js have complex deps; skip them
101+
continue
102+
}
103+
104+
let code = result.outputFiles[0]!.text
105+
106+
// esbuild can't lift require() calls from inside __commonJS wrappers to
107+
// top-level ESM imports. Replace __require("react") etc. with references
108+
// to top-level imports we inject at the top of the file.
109+
const externalRequires = new Map<string, string>()
110+
const externals = [
111+
'react',
112+
'react-dom',
113+
'react/jsx-runtime',
114+
'react/jsx-dev-runtime',
115+
]
116+
code = code.replace(/__require\("([^"]+)"\)/g, (match, specifier) => {
117+
if (!externals.includes(specifier)) return match
118+
const varName =
119+
externalRequires.get(specifier) ??
120+
`__ext_${specifier.replace(/[^a-zA-Z0-9]/g, '_')}`
121+
externalRequires.set(specifier, varName)
122+
return varName
123+
})
124+
if (externalRequires.size > 0) {
125+
const imports = Array.from(externalRequires.entries())
126+
.map(([spec, varName]) => `import * as ${varName} from "${spec}";`)
127+
.join('\n')
128+
code = imports + '\n' + code
129+
}
130+
131+
// Remove the __require shim (references `require` which doesn't exist
132+
// in pure ESM runtimes like Cloudflare Workers)
133+
if (!code.includes('__require(')) {
134+
const lines = code.split('\n')
135+
const startIdx = lines.findIndex((l) => l.startsWith('var __require ='))
136+
if (startIdx >= 0) {
137+
let endIdx = startIdx
138+
for (let i = startIdx; i < lines.length; i++) {
139+
if (lines[i]!.startsWith('});')) {
140+
endIdx = i
141+
break
142+
}
143+
}
144+
lines.splice(startIdx, endIdx - startIdx + 1)
145+
code = lines.join('\n')
146+
}
147+
}
148+
149+
// esbuild wraps CJS as `export default require_xxx()` (single default
150+
// export). Downstream code uses named imports like
151+
// `import { renderToReadableStream } from '...'`. Extract the named
152+
// exports from the CJS source and re-export them from the default.
153+
const namedExports = extractCjsExportNames(content, entryPath)
154+
if (namedExports.length > 0) {
155+
code = code.replace(
156+
/^export default (.+);$/m,
157+
[
158+
`var __cjs_default__ = $1;`,
159+
`export default __cjs_default__;`,
160+
`export var { ${namedExports.join(', ')} } = __cjs_default__;`,
161+
].join('\n'),
162+
)
163+
}
164+
165+
fs.writeFileSync(entryPath, code)
166+
}
167+
168+
// Remove the cjs/ subdirectory since entry files no longer reference it
169+
fs.rmSync(path.join(vendorDir, 'cjs'), { recursive: true, force: true })
170+
}
171+
172+
// Extract export names from CJS files by scanning for `exports.xxx = ` patterns.
173+
// Also reads the production CJS file referenced by conditional require() wrappers.
174+
function extractCjsExportNames(content: string, filePath: string): string[] {
175+
const names = new Set<string>()
176+
for (const m of content.matchAll(/exports\.(\w+)\s*=/g)) {
177+
if (m[1] !== '__esModule') names.add(m[1]!)
178+
}
179+
const requireMatch = content.match(
180+
/require\(['"](\.\/cjs\/[^'"]+\.production[^'"]*)['"]\)/,
181+
)
182+
if (requireMatch) {
183+
try {
184+
const cjsPath = path.resolve(path.dirname(filePath), requireMatch[1]!)
185+
const cjsContent = fs.readFileSync(cjsPath, 'utf-8')
186+
for (const m of cjsContent.matchAll(/exports\.(\w+)\s*=/g)) {
187+
if (m[1] !== '__esModule') names.add(m[1]!)
188+
}
189+
} catch {
190+
// fallback to names from the entry file
191+
}
192+
}
193+
return Array.from(names)
194+
}

0 commit comments

Comments
 (0)