diff --git a/packages/plugin-rsc/README.md b/packages/plugin-rsc/README.md index a8780f9fc..a9fcc8812 100644 --- a/packages/plugin-rsc/README.md +++ b/packages/plugin-rsc/README.md @@ -442,6 +442,58 @@ export default defineConfig({ }) ``` +### `@vitejs/plugin-rsc/transforms` + +The CommonJS transform used by the RSC module runner is available as a +low-level framework integration API: + +```js +import { transformCjsToEsm } from '@vitejs/plugin-rsc/transforms' +import { parseAstAsync } from 'vite' + +const commonJsApplicationPlugin = { + name: 'framework:commonjs-application', + apply: 'serve', + transform: { + filter: { id: /\.cjs$/ }, + async handler(code, id) { + const ast = await parseAstAsync(code) + const { output } = transformCjsToEsm(code, ast, { + id, + output: this.environment.config.dev.moduleRunnerTransform + ? 'module-runner' + : 'esm', + }) + return { + code: output.toString(), + map: output.generateMap({ hires: 'boundary' }), + } + }, + }, +} +``` + +The `output` option defaults to `"module-runner"`. This mode uses the module +runner's `__vite_ssr_exportAll__` helper to expose runtime-generated named +exports. Use `output: "esm"` when the transformed module must run as standard +ESM, such as in a browser or another environment without Vite's module-runner +runtime. + +ESM output exposes statically assigned CommonJS names, such as `exports.foo` +and `module.exports.foo`, as named exports. Dynamic names remain available only +through the default export because ESM export names must be declared +statically. + +Both modes can emit top-level `await`, so this remains a development-oriented +framework integration transform rather than a general production CommonJS +bundler transform. + +Nested `require()` calls are hoisted to asynchronous imports to keep the +surrounding function synchronous. This does not preserve Node.js semantics +when a nested require is conditional or depends on runtime state. Frameworks +should detect or implement their own policy for dynamic `require()` calls +before invoking this transform. + ## RSC runtime (react-server-dom) API ### `@vitejs/plugin-rsc/rsc` diff --git a/packages/plugin-rsc/src/plugins/cjs.ts b/packages/plugin-rsc/src/plugins/cjs.ts index 40a6ba0f3..56b0eb8d0 100644 --- a/packages/plugin-rsc/src/plugins/cjs.ts +++ b/packages/plugin-rsc/src/plugins/cjs.ts @@ -4,7 +4,7 @@ import { createDebug } from '@hiogawa/utils' import * as esModuleLexer from 'es-module-lexer' import { parseAstAsync, type Plugin } from 'vite' import { findClosestPkgJsonPath } from 'vitefu' -import { transformCjsToEsm } from '../transforms/cjs' +import { transformCjsToEsm } from '../transforms' import { parseIdQuery } from './shared' const debug = createDebug('vite-rsc:cjs') diff --git a/packages/plugin-rsc/src/transforms/cjs.test.ts b/packages/plugin-rsc/src/transforms/cjs.test.ts index 8a71f2c53..695ba48a8 100644 --- a/packages/plugin-rsc/src/transforms/cjs.test.ts +++ b/packages/plugin-rsc/src/transforms/cjs.test.ts @@ -1,13 +1,19 @@ import path from 'node:path' import { createServer, createServerModuleRunner, parseAstAsync } from 'vite' import { describe, expect, it } from 'vitest' -import { transformCjsToEsm } from './cjs' +import { transformCjsToEsm, type TransformCjsToEsmOptions } from './index' import { debugSourceMap } from './test-utils' describe(transformCjsToEsm, () => { - async function testTransform(input: string) { + async function testTransform( + input: string, + options: Partial = {}, + ) { const ast = await parseAstAsync(input) - const { output } = transformCjsToEsm(input, ast, { id: '/test.js' }) + const { output } = transformCjsToEsm(input, ast, { + id: '/test.js', + ...options, + }) if (!output.hasChanged()) { return } @@ -143,6 +149,58 @@ function test() { `) }) + it('emits standard ESM output with statically assigned named exports', async () => { + const code = await testTransform( + `exports.foo = 'ok'; module.exports.bar = 2;`, + { output: 'esm' }, + ) + + expect(code).not.toContain('__vite_ssr_exportAll__') + expect(code).not.toContain('__cjs_module_runner_transform') + + const url = `data:text/javascript;base64,${Buffer.from(code!).toString('base64')}` + const mod = await import(url) + expect(Object.keys(mod)).toEqual(['bar', 'default', 'foo']) + expect(mod.default).toEqual({ bar: 2, foo: 'ok' }) + expect(mod.foo).toBe('ok') + expect(mod.bar).toBe(2) + }) + + it('keeps dynamic export names on the default export in ESM output', async () => { + const code = await testTransform( + `const name = 'foo'; exports[name] = 'ok';`, + { output: 'esm' }, + ) + + const url = `data:text/javascript;base64,${Buffer.from(code!).toString('base64')}` + const mod = await import(url) + expect(Object.keys(mod)).toEqual(['default']) + expect(mod.default).toEqual({ foo: 'ok' }) + }) + + it('supports null default exports in ESM output', async () => { + const code = await testTransform( + `exports.foo = 'old'; module.exports = null;`, + { + output: 'esm', + }, + ) + + const url = `data:text/javascript;base64,${Buffer.from(code!).toString('base64')}` + const mod = await import(url) + expect(mod.default).toBeNull() + expect(mod.foo).toBeUndefined() + }) + + it('ignores assignments to shadowed CommonJS bindings', async () => { + const code = await testTransform( + `function set(exports) { exports.foo = 'local' } module.exports = set;`, + { output: 'esm' }, + ) + + expect(code).not.toContain('as foo') + }) + it('e2e', async () => { const server = await createServer({ configFile: false, diff --git a/packages/plugin-rsc/src/transforms/cjs.ts b/packages/plugin-rsc/src/transforms/cjs.ts index 20cfb1193..9f00df10f 100644 --- a/packages/plugin-rsc/src/transforms/cjs.ts +++ b/packages/plugin-rsc/src/transforms/cjs.ts @@ -5,6 +5,13 @@ import { walk } from 'estree-walker' import MagicString from 'magic-string' import { buildScopeTree } from './scope' +export type CjsOutputMode = 'module-runner' | 'esm' + +export interface TransformCjsToEsmOptions { + id: string + output?: CjsOutputMode +} + // TODO: // replacing require("xxx") into import("xxx") affects Vite's resolution. @@ -30,18 +37,23 @@ const CJS_INTEROP_HELPER = __cjs_interop__.toString().replace(/\n\s*/g, '') export function transformCjsToEsm( code: string, ast: Program, - options: { id: string }, + options: TransformCjsToEsmOptions, ): { output: MagicString } { const output = new MagicString(code) const scopeTree = buildScopeTree(ast) const parentNodes: Node[] = [] const hoistedCodes: string[] = [] + const namedExports = new Set() let hoistIndex = 0 walk(ast, { enter(node) { parentNodes.push(node) + const namedExport = getAssignedExportName(node, scopeTree) + if (namedExport) { + namedExports.add(namedExport) + } if ( node.type === 'CallExpression' && node.callee.type === 'Identifier' && @@ -113,11 +125,79 @@ export function transformCjsToEsm( // TODO: can we use cjs-module-lexer to properly define named exports? // for re-exports, we need to eagerly transform dependencies though. // https://github.com/nodejs/node/blob/f3adc11e37b8bfaaa026ea85c1cf22e3a0e29ae9/lib/internal/modules/esm/translators.js#L382-L409 - output.append(` + if (options.output === 'esm') { + output.append('\n;') + let exportIndex = 0 + for (const name of namedExports) { + const binding = `__cjs_export_${exportIndex++}` + output.append( + `const ${binding}=module.exports==null?undefined:module.exports.${name};` + + `export{${binding} as ${name}};\n`, + ) + } + output.append('export default module.exports;\n') + } else { + output.append(` ;__vite_ssr_exportAll__(module.exports); export default module.exports; export const __cjs_module_runner_transform = true; `) + } return { output } } + +function getAssignedExportName( + node: Node, + scopeTree: ReturnType, +): string | undefined { + if ( + node.type !== 'AssignmentExpression' || + node.left.type !== 'MemberExpression' + ) { + return + } + + const name = getStaticMemberName(node.left) + if ( + !name || + name === 'default' || + name === '__cjs_module_runner_transform' || + !/^[$A-Z_a-z][$\w]*$/.test(name) + ) { + return + } + + if ( + node.left.object.type === 'Identifier' && + node.left.object.name === 'exports' && + !scopeTree.referenceToDeclaredScope.has(node.left.object) + ) { + return name + } + + if ( + node.left.object.type === 'MemberExpression' && + getStaticMemberName(node.left.object) === 'exports' && + node.left.object.object.type === 'Identifier' && + node.left.object.object.name === 'module' && + !scopeTree.referenceToDeclaredScope.has(node.left.object.object) + ) { + return name + } +} + +function getStaticMemberName( + node: Extract, +) { + if (!node.computed && node.property.type === 'Identifier') { + return node.property.name + } + if ( + node.computed && + node.property.type === 'Literal' && + typeof node.property.value === 'string' + ) { + return node.property.value + } +} diff --git a/packages/plugin-rsc/src/transforms/index.ts b/packages/plugin-rsc/src/transforms/index.ts index b37480dae..c7c70802a 100644 --- a/packages/plugin-rsc/src/transforms/index.ts +++ b/packages/plugin-rsc/src/transforms/index.ts @@ -4,3 +4,4 @@ export * from './proxy-export' export * from './utils' export * from './server-action' export * from './expand-export-all' +export * from './cjs'