Skip to content

fix(rsc): support cjs on module runner #687

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 18 commits into from
Aug 8, 2025
Merged
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
2 changes: 1 addition & 1 deletion packages/plugin-rsc/e2e/basic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ test.describe('dev-non-optimized-cjs', () => {
test('show warning', async ({ page }) => {
await page.goto(f.url())
expect(f.proc().stderr()).toContain(
`[vite-rsc] found non-optimized CJS dependency in 'ssr' environment.`,
`Found non-optimized CJS dependency in 'ssr' environment.`,
)
})
})
Expand Down
1 change: 0 additions & 1 deletion packages/plugin-rsc/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,6 @@
"magic-string": "^0.30.17",
"periscopic": "^4.0.2",
"turbo-stream": "^3.1.0",
"use-sync-external-store": "^1.5.0",
"vitefu": "^1.1.1"
},
"devDependencies": {
Expand Down
90 changes: 4 additions & 86 deletions packages/plugin-rsc/src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import {
normalizePath,
parseAstAsync,
} from 'vite'
import { crawlFrameworkPkgs, findClosestPkgJsonPath } from 'vitefu'
import { crawlFrameworkPkgs } from 'vitefu'
import vitePluginRscCore from './core/plugin'
import {
type TransformWrapExportFilter,
Expand All @@ -33,6 +33,8 @@ import {
import { generateEncryptionKey, toBase64 } from './utils/encryption-utils'
import { createRpcServer } from './utils/rpc'
import { normalizeViteImportAnalysisUrl, prepareError } from './vite-utils'
import { cjsModuleRunnerPlugin } from './plugins/cjs'
import { evalValue, parseIdQuery } from './plugins/utils'

// state for build orchestration
let serverReferences: Record<string, string> = {}
Expand Down Expand Up @@ -882,42 +884,10 @@ globalThis.AsyncLocalStorage = __viteRscAyncHooks.AsyncLocalStorage;
...(rscPluginOptions.validateImports !== false
? [validateImportPlugin()]
: []),
...vendorUseSyncExternalStorePlugin(),
scanBuildStripPlugin(),
detectNonOptimizedCjsPlugin(),
...cjsModuleRunnerPlugin(),
]
}
function detectNonOptimizedCjsPlugin(): Plugin {
return {
name: 'rsc:detect-non-optimized-cjs',
apply: 'serve',
async transform(code, id) {
if (
id.includes('/node_modules/') &&
!id.startsWith(this.environment.config.cacheDir) &&
/\b(require|exports)\b/.test(code)
) {
id = parseIdQuery(id).filename
let isEsm = id.endsWith('.mjs')
if (id.endsWith('.js')) {
const pkgJsonPath = await findClosestPkgJsonPath(path.dirname(id))
if (pkgJsonPath) {
const pkgJson = JSON.parse(
fs.readFileSync(pkgJsonPath, 'utf-8'),
) as { type?: string }
isEsm = pkgJson.type === 'module'
}
}
if (!isEsm) {
this.warn(
`[vite-rsc] found non-optimized CJS dependency in '${this.environment.name}' environment. ` +
`It is recommended to manually add the dependency to 'environments.${this.environment.name}.optimizeDeps.include'.`,
)
}
}
},
}
}

function scanBuildStripPlugin(): Plugin {
return {
Expand Down Expand Up @@ -1993,23 +1963,6 @@ function generateResourcesCode(depsCode: string) {
`
}

// https://github.com/vitejs/vite/blob/ea9aed7ebcb7f4be542bd2a384cbcb5a1e7b31bd/packages/vite/src/node/utils.ts#L1469-L1475
function evalValue<T = any>(rawValue: string): T {
const fn = new Function(`
var console, exports, global, module, process, require
return (\n${rawValue}\n)
`)
return fn()
}

// https://github.com/vitejs/vite-plugin-vue/blob/06931b1ea2b9299267374cb8eb4db27c0626774a/packages/plugin-vue/src/utils/query.ts#L13
function parseIdQuery(id: string) {
if (!id.includes('?')) return { filename: id, query: {} }
const [filename, rawQuery] = id.split(`?`, 2) as [string, string]
const query = Object.fromEntries(new URLSearchParams(rawQuery))
return { filename, query }
}

export async function transformRscCssExport(options: {
ast: Awaited<ReturnType<typeof parseAstAsync>>
code: string
Expand Down Expand Up @@ -2125,41 +2078,6 @@ function validateImportPlugin(): Plugin {
}
}

function vendorUseSyncExternalStorePlugin(): Plugin[] {
// vendor and optimize use-sync-external-store out of the box
// since this is a common enough cjs, which tends to break
// other packages (e.g. swr, @tanstack/react-store)

// https://github.com/facebook/react/blob/c499adf8c89bbfd884f4d3a58c4e510001383525/packages/use-sync-external-store/package.json#L5-L20
const exports = [
'use-sync-external-store',
'use-sync-external-store/with-selector',
'use-sync-external-store/with-selector.js',
'use-sync-external-store/shim',
'use-sync-external-store/shim/index.js',
'use-sync-external-store/shim/with-selector',
'use-sync-external-store/shim/with-selector.js',
]

return [
{
name: 'rsc:vendor-use-sync-external-store',
apply: 'serve',
config() {
return {
environments: {
ssr: {
optimizeDeps: {
include: exports.map((e) => `${PKG_NAME} > ${e}`),
},
},
},
}
},
},
]
}

function sortObject<T extends object>(o: T) {
return Object.fromEntries(
Object.entries(o).sort(([a], [b]) => a.localeCompare(b)),
Expand Down
87 changes: 87 additions & 0 deletions packages/plugin-rsc/src/plugins/cjs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { parseAstAsync, type Plugin } from 'vite'
import { parseIdQuery } from './utils'
import { findClosestPkgJsonPath } from 'vitefu'
import path from 'node:path'
import fs from 'node:fs'
import * as esModuleLexer from 'es-module-lexer'
import { transformCjsToEsm } from '../transforms/cjs'

export function cjsModuleRunnerPlugin(): Plugin[] {
// use-sync-external-store is known to work fine so don't show warning
const warnedPackages = new Set<string>(['use-sync-external-store'])

return [
{
name: 'cjs-module-runner-transform',
apply: 'serve',
applyToEnvironment: (env) => env.config.dev.moduleRunnerTransform,
async transform(code, id) {
if (
id.includes('/node_modules/') &&
!id.startsWith(this.environment.config.cacheDir) &&
/\b(require|exports)\b/.test(code)
) {
id = parseIdQuery(id).filename
if (!/\.[cm]?js$/.test(id)) return

// skip genuine esm
if (id.endsWith('.mjs')) return
if (id.endsWith('.js')) {
const pkgJsonPath = await findClosestPkgJsonPath(path.dirname(id))
if (pkgJsonPath) {
const pkgJson = JSON.parse(
fs.readFileSync(pkgJsonPath, 'utf-8'),
) as { type?: string }
if (pkgJson.type === 'module') return
}
}

// skip faux esm (e.g. from "module" field)
const [, , , hasModuleSyntax] = esModuleLexer.parse(code)
if (hasModuleSyntax) return

// warning once per package
const packageKey = extractPackageKey(id)
if (!warnedPackages.has(packageKey)) {
this.warn(
`Found non-optimized CJS dependency in '${this.environment.name}' environment. ` +
`It is recommended to add the dependency to 'environments.${this.environment.name}.optimizeDeps.include'.`,
)
warnedPackages.add(packageKey)
}

const ast = await parseAstAsync(code)
const result = transformCjsToEsm(code, ast)
const output = result.output
// 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(`__vite_ssr_exportAll__(module.exports)`)
return {
code: output.toString(),
map: output.generateMap({ hires: 'boundary' }),
}
}
},
},
]
}

function extractPackageKey(id: string): string {
// .../.yarn/cache/abc/... => abc
const yarnMatch = id.match(/\/.yarn\/cache\/([^/]+)/)
if (yarnMatch) {
return yarnMatch[1]!
}
// .../node_modules/@x/y/... => @x/y
// .../node_modules/x/... => x
if (id.includes('/node_modules')) {
id = id.split('/node_modules/').at(-1)!
let [x, y] = id.split('/')
if (x!.startsWith('@')) {
return `${x}/${y}`
}
return x!
}
return id
}
21 changes: 21 additions & 0 deletions packages/plugin-rsc/src/plugins/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
// https://github.com/vitejs/vite/blob/ea9aed7ebcb7f4be542bd2a384cbcb5a1e7b31bd/packages/vite/src/node/utils.ts#L1469-L1475
export function evalValue<T = any>(rawValue: string): T {
const fn = new Function(`
var console, exports, global, module, process, require
return (\n${rawValue}\n)
`)
return fn()
}

// https://github.com/vitejs/vite-plugin-vue/blob/06931b1ea2b9299267374cb8eb4db27c0626774a/packages/plugin-vue/src/utils/query.ts#L13
export function parseIdQuery(id: string): {
filename: string
query: {
[k: string]: string
}
} {
if (!id.includes('?')) return { filename: id, query: {} }
const [filename, rawQuery] = id.split(`?`, 2) as [string, string]
const query = Object.fromEntries(new URLSearchParams(rawQuery))
return { filename, query }
}
86 changes: 86 additions & 0 deletions packages/plugin-rsc/src/transforms/cjs.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { parseAstAsync } from 'vite'
import { describe, expect, it } from 'vitest'
import { debugSourceMap } from './test-utils'
import { transformCjsToEsm } from './cjs'

describe(transformCjsToEsm, () => {
async function testTransform(input: string) {
const ast = await parseAstAsync(input)
const { output } = transformCjsToEsm(input, ast)
if (!output.hasChanged()) {
return
}
if (process.env['DEBUG_SOURCEMAP']) {
await debugSourceMap(output)
}
return output.toString()
}

it('basic', async () => {
const input = `\
exports.ok = true;
`
expect(await testTransform(input)).toMatchInlineSnapshot(`
"const exports = {}; const module = { exports };
exports.ok = true;
"
`)
})

it('top-level re-export', async () => {
const input = `\
if (true) {
module.exports = require('./cjs/use-sync-external-store.production.js');
} else {
module.exports = require('./cjs/use-sync-external-store.development.js');
}
`
expect(await testTransform(input)).toMatchInlineSnapshot(`
"const exports = {}; const module = { exports };
if (true) {
module.exports = (await import('./cjs/use-sync-external-store.production.js'));
} else {
module.exports = (await import('./cjs/use-sync-external-store.development.js'));
}
"
`)
})

it('non top-level re-export', async () => {
const input = `\
"production" !== process.env.NODE_ENV && (function() {
var React = require("react");
var ReactDOM = require("react-dom");
exports.useSyncExternalStoreWithSelector = function () {}
})()
`
expect(await testTransform(input)).toMatchInlineSnapshot(`
"const exports = {}; const module = { exports };
const __cjs_to_esm_hoist_1 = await import("react-dom");
const __cjs_to_esm_hoist_0 = await import("react");
"production" !== process.env.NODE_ENV && (function() {
var React = __cjs_to_esm_hoist_0;
var ReactDOM = __cjs_to_esm_hoist_1;
exports.useSyncExternalStoreWithSelector = function () {}
})()
"
`)
})

it('local require', async () => {
const input = `\
{
const require = () => {};
require("test");
}
`
expect(await testTransform(input)).toMatchInlineSnapshot(`
"const exports = {}; const module = { exports };
{
const require = () => {};
require("test");
}
"
`)
})
})
Loading
Loading