Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions packages/plugin-rsc/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
2 changes: 1 addition & 1 deletion packages/plugin-rsc/src/plugins/cjs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down
64 changes: 61 additions & 3 deletions packages/plugin-rsc/src/transforms/cjs.test.ts
Original file line number Diff line number Diff line change
@@ -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<TransformCjsToEsmOptions> = {},
) {
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
}
Expand Down Expand Up @@ -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,
Expand Down
84 changes: 82 additions & 2 deletions packages/plugin-rsc/src/transforms/cjs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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<string>()
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' &&
Expand Down Expand Up @@ -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<typeof buildScopeTree>,
): 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<Node, { type: 'MemberExpression' }>,
) {
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
}
}
1 change: 1 addition & 0 deletions packages/plugin-rsc/src/transforms/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@ export * from './proxy-export'
export * from './utils'
export * from './server-action'
export * from './expand-export-all'
export * from './cjs'
Loading