Skip to content

Commit f89b597

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. These use require() and module.exports which break on pure ESM runtimes (Cloudflare Workers, Deno Deploy) when bundled with resolve.noExternal: true. Adds an esbuild conversion step in the tsdown build config that converts each CJS entry file to self-contained ESM in-place, then removes the now-unused cjs/ subdirectory. Session: ses_22b5a7d8affeczTeFI1DGoYPd5
1 parent bb42093 commit f89b597

3 files changed

Lines changed: 449 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: 127 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,131 @@ export default defineConfig({
4345
recursive: true,
4446
force: true,
4547
})
48+
// Convert CJS entry files to ESM so pure ESM runtimes (Cloudflare
49+
// Workers, Deno Deploy) don't fail with "require is not defined".
50+
await convertVendorToEsm('./dist/vendor/react-server-dom')
4651
},
4752
},
4853
],
4954
}) as any
55+
56+
const EXTERNALS = [
57+
'react',
58+
'react-dom',
59+
'react/jsx-runtime',
60+
'react/jsx-dev-runtime',
61+
]
62+
63+
// Convert CJS entry files in the vendor directory to ESM in-place using esbuild.
64+
async function convertVendorToEsm(vendorDir: string) {
65+
const entries = fs
66+
.readdirSync(vendorDir)
67+
.filter((f) => f.endsWith('.js'))
68+
.filter((f) => {
69+
const content = fs.readFileSync(path.join(vendorDir, f), 'utf-8')
70+
return content.includes('require(') || content.includes('exports.')
71+
})
72+
73+
for (const entry of entries) {
74+
const entryPath = path.join(vendorDir, entry)
75+
const content = fs.readFileSync(entryPath, 'utf-8')
76+
77+
let result
78+
try {
79+
result = await build({
80+
entryPoints: [entryPath],
81+
bundle: true,
82+
format: 'esm',
83+
write: false,
84+
platform: 'neutral',
85+
external: [
86+
...EXTERNALS,
87+
'node:*',
88+
'util',
89+
'crypto',
90+
'stream',
91+
'async_hooks',
92+
],
93+
define: { 'process.env.NODE_ENV': '"production"' },
94+
sourcemap: false,
95+
logLevel: 'silent',
96+
})
97+
} catch {
98+
continue
99+
}
100+
101+
let code = result.outputFiles[0]!.text
102+
103+
// esbuild wraps CJS externals as __require("react") inside __commonJS
104+
// wrappers instead of lifting them to top-level imports. Replace with ESM.
105+
const externalRequires = new Map<string, string>()
106+
code = code.replace(/__require\("([^"]+)"\)/g, (match, specifier) => {
107+
if (!EXTERNALS.includes(specifier)) return match
108+
const varName =
109+
externalRequires.get(specifier) ??
110+
`__ext_${specifier.replace(/[^a-zA-Z0-9]/g, '_')}`
111+
externalRequires.set(specifier, varName)
112+
return varName
113+
})
114+
if (externalRequires.size > 0) {
115+
const imports = Array.from(externalRequires.entries())
116+
.map(([spec, varName]) => `import * as ${varName} from "${spec}";`)
117+
.join('\n')
118+
code = imports + '\n' + code
119+
}
120+
121+
// Remove the __require shim (references `require` which doesn't exist in ESM runtimes)
122+
if (!code.includes('__require(')) {
123+
const lines = code.split('\n')
124+
const startIdx = lines.findIndex((l) => l.startsWith('var __require ='))
125+
if (startIdx >= 0) {
126+
let endIdx = startIdx
127+
for (let i = startIdx; i < lines.length; i++) {
128+
if (lines[i]!.startsWith('});')) {
129+
endIdx = i
130+
break
131+
}
132+
}
133+
lines.splice(startIdx, endIdx - startIdx + 1)
134+
code = lines.join('\n')
135+
}
136+
}
137+
138+
// esbuild only generates `export default`. Add named exports by scanning the CJS source.
139+
const namedExports = extractCjsExportNames(content, entryPath)
140+
if (namedExports.length > 0) {
141+
code = code.replace(
142+
/^export default (.+);$/m,
143+
[
144+
`var __cjs_default__ = $1;`,
145+
`export default __cjs_default__;`,
146+
`export var { ${namedExports.join(', ')} } = __cjs_default__;`,
147+
].join('\n'),
148+
)
149+
}
150+
151+
fs.writeFileSync(entryPath, code)
152+
}
153+
154+
fs.rmSync(path.join(vendorDir, 'cjs'), { recursive: true, force: true })
155+
}
156+
157+
function extractCjsExportNames(content: string, filePath: string): string[] {
158+
const names = new Set<string>()
159+
for (const m of content.matchAll(/exports\.(\w+)\s*=/g)) {
160+
if (m[1] !== '__esModule') names.add(m[1]!)
161+
}
162+
const requireMatch = content.match(
163+
/require\(['"](\.\/cjs\/[^'"]+\.production[^'"]*)['"]\)/,
164+
)
165+
if (requireMatch) {
166+
try {
167+
const cjsPath = path.resolve(path.dirname(filePath), requireMatch[1]!)
168+
const cjsContent = fs.readFileSync(cjsPath, 'utf-8')
169+
for (const m of cjsContent.matchAll(/exports\.(\w+)\s*=/g)) {
170+
if (m[1] !== '__esModule') names.add(m[1]!)
171+
}
172+
} catch {}
173+
}
174+
return Array.from(names)
175+
}

0 commit comments

Comments
 (0)