Skip to content

Commit 7a92083

Browse files
authored
fix(rsc): support cjs on module runner (#687)
1 parent b654046 commit 7a92083

File tree

8 files changed

+263
-91
lines changed

8 files changed

+263
-91
lines changed

packages/plugin-rsc/e2e/basic.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ test.describe('dev-non-optimized-cjs', () => {
6363
test('show warning', async ({ page }) => {
6464
await page.goto(f.url())
6565
expect(f.proc().stderr()).toContain(
66-
`[vite-rsc] found non-optimized CJS dependency in 'ssr' environment.`,
66+
`Found non-optimized CJS dependency in 'ssr' environment.`,
6767
)
6868
})
6969
})

packages/plugin-rsc/package.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,6 @@
4444
"magic-string": "^0.30.17",
4545
"periscopic": "^4.0.2",
4646
"turbo-stream": "^3.1.0",
47-
"use-sync-external-store": "^1.5.0",
4847
"vitefu": "^1.1.1"
4948
},
5049
"devDependencies": {

packages/plugin-rsc/src/plugin.ts

Lines changed: 4 additions & 86 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ import {
2121
normalizePath,
2222
parseAstAsync,
2323
} from 'vite'
24-
import { crawlFrameworkPkgs, findClosestPkgJsonPath } from 'vitefu'
24+
import { crawlFrameworkPkgs } from 'vitefu'
2525
import vitePluginRscCore from './core/plugin'
2626
import {
2727
type TransformWrapExportFilter,
@@ -33,6 +33,8 @@ import {
3333
import { generateEncryptionKey, toBase64 } from './utils/encryption-utils'
3434
import { createRpcServer } from './utils/rpc'
3535
import { normalizeViteImportAnalysisUrl, prepareError } from './vite-utils'
36+
import { cjsModuleRunnerPlugin } from './plugins/cjs'
37+
import { evalValue, parseIdQuery } from './plugins/utils'
3638

3739
// state for build orchestration
3840
let serverReferences: Record<string, string> = {}
@@ -882,42 +884,10 @@ globalThis.AsyncLocalStorage = __viteRscAyncHooks.AsyncLocalStorage;
882884
...(rscPluginOptions.validateImports !== false
883885
? [validateImportPlugin()]
884886
: []),
885-
...vendorUseSyncExternalStorePlugin(),
886887
scanBuildStripPlugin(),
887-
detectNonOptimizedCjsPlugin(),
888+
...cjsModuleRunnerPlugin(),
888889
]
889890
}
890-
function detectNonOptimizedCjsPlugin(): Plugin {
891-
return {
892-
name: 'rsc:detect-non-optimized-cjs',
893-
apply: 'serve',
894-
async transform(code, id) {
895-
if (
896-
id.includes('/node_modules/') &&
897-
!id.startsWith(this.environment.config.cacheDir) &&
898-
/\b(require|exports)\b/.test(code)
899-
) {
900-
id = parseIdQuery(id).filename
901-
let isEsm = id.endsWith('.mjs')
902-
if (id.endsWith('.js')) {
903-
const pkgJsonPath = await findClosestPkgJsonPath(path.dirname(id))
904-
if (pkgJsonPath) {
905-
const pkgJson = JSON.parse(
906-
fs.readFileSync(pkgJsonPath, 'utf-8'),
907-
) as { type?: string }
908-
isEsm = pkgJson.type === 'module'
909-
}
910-
}
911-
if (!isEsm) {
912-
this.warn(
913-
`[vite-rsc] found non-optimized CJS dependency in '${this.environment.name}' environment. ` +
914-
`It is recommended to manually add the dependency to 'environments.${this.environment.name}.optimizeDeps.include'.`,
915-
)
916-
}
917-
}
918-
},
919-
}
920-
}
921891

922892
function scanBuildStripPlugin(): Plugin {
923893
return {
@@ -1993,23 +1963,6 @@ function generateResourcesCode(depsCode: string) {
19931963
`
19941964
}
19951965

1996-
// https://github.com/vitejs/vite/blob/ea9aed7ebcb7f4be542bd2a384cbcb5a1e7b31bd/packages/vite/src/node/utils.ts#L1469-L1475
1997-
function evalValue<T = any>(rawValue: string): T {
1998-
const fn = new Function(`
1999-
var console, exports, global, module, process, require
2000-
return (\n${rawValue}\n)
2001-
`)
2002-
return fn()
2003-
}
2004-
2005-
// https://github.com/vitejs/vite-plugin-vue/blob/06931b1ea2b9299267374cb8eb4db27c0626774a/packages/plugin-vue/src/utils/query.ts#L13
2006-
function parseIdQuery(id: string) {
2007-
if (!id.includes('?')) return { filename: id, query: {} }
2008-
const [filename, rawQuery] = id.split(`?`, 2) as [string, string]
2009-
const query = Object.fromEntries(new URLSearchParams(rawQuery))
2010-
return { filename, query }
2011-
}
2012-
20131966
export async function transformRscCssExport(options: {
20141967
ast: Awaited<ReturnType<typeof parseAstAsync>>
20151968
code: string
@@ -2125,41 +2078,6 @@ function validateImportPlugin(): Plugin {
21252078
}
21262079
}
21272080

2128-
function vendorUseSyncExternalStorePlugin(): Plugin[] {
2129-
// vendor and optimize use-sync-external-store out of the box
2130-
// since this is a common enough cjs, which tends to break
2131-
// other packages (e.g. swr, @tanstack/react-store)
2132-
2133-
// https://github.com/facebook/react/blob/c499adf8c89bbfd884f4d3a58c4e510001383525/packages/use-sync-external-store/package.json#L5-L20
2134-
const exports = [
2135-
'use-sync-external-store',
2136-
'use-sync-external-store/with-selector',
2137-
'use-sync-external-store/with-selector.js',
2138-
'use-sync-external-store/shim',
2139-
'use-sync-external-store/shim/index.js',
2140-
'use-sync-external-store/shim/with-selector',
2141-
'use-sync-external-store/shim/with-selector.js',
2142-
]
2143-
2144-
return [
2145-
{
2146-
name: 'rsc:vendor-use-sync-external-store',
2147-
apply: 'serve',
2148-
config() {
2149-
return {
2150-
environments: {
2151-
ssr: {
2152-
optimizeDeps: {
2153-
include: exports.map((e) => `${PKG_NAME} > ${e}`),
2154-
},
2155-
},
2156-
},
2157-
}
2158-
},
2159-
},
2160-
]
2161-
}
2162-
21632081
function sortObject<T extends object>(o: T) {
21642082
return Object.fromEntries(
21652083
Object.entries(o).sort(([a], [b]) => a.localeCompare(b)),
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import { parseAstAsync, type Plugin } from 'vite'
2+
import { parseIdQuery } from './utils'
3+
import { findClosestPkgJsonPath } from 'vitefu'
4+
import path from 'node:path'
5+
import fs from 'node:fs'
6+
import * as esModuleLexer from 'es-module-lexer'
7+
import { transformCjsToEsm } from '../transforms/cjs'
8+
9+
export function cjsModuleRunnerPlugin(): Plugin[] {
10+
// use-sync-external-store is known to work fine so don't show warning
11+
const warnedPackages = new Set<string>(['use-sync-external-store'])
12+
13+
return [
14+
{
15+
name: 'cjs-module-runner-transform',
16+
apply: 'serve',
17+
applyToEnvironment: (env) => env.config.dev.moduleRunnerTransform,
18+
async transform(code, id) {
19+
if (
20+
id.includes('/node_modules/') &&
21+
!id.startsWith(this.environment.config.cacheDir) &&
22+
/\b(require|exports)\b/.test(code)
23+
) {
24+
id = parseIdQuery(id).filename
25+
if (!/\.[cm]?js$/.test(id)) return
26+
27+
// skip genuine esm
28+
if (id.endsWith('.mjs')) return
29+
if (id.endsWith('.js')) {
30+
const pkgJsonPath = await findClosestPkgJsonPath(path.dirname(id))
31+
if (pkgJsonPath) {
32+
const pkgJson = JSON.parse(
33+
fs.readFileSync(pkgJsonPath, 'utf-8'),
34+
) as { type?: string }
35+
if (pkgJson.type === 'module') return
36+
}
37+
}
38+
39+
// skip faux esm (e.g. from "module" field)
40+
const [, , , hasModuleSyntax] = esModuleLexer.parse(code)
41+
if (hasModuleSyntax) return
42+
43+
// warning once per package
44+
const packageKey = extractPackageKey(id)
45+
if (!warnedPackages.has(packageKey)) {
46+
this.warn(
47+
`Found non-optimized CJS dependency in '${this.environment.name}' environment. ` +
48+
`It is recommended to add the dependency to 'environments.${this.environment.name}.optimizeDeps.include'.`,
49+
)
50+
warnedPackages.add(packageKey)
51+
}
52+
53+
const ast = await parseAstAsync(code)
54+
const result = transformCjsToEsm(code, ast)
55+
const output = result.output
56+
// TODO: can we use cjs-module-lexer to properly define named exports?
57+
// for re-exports, we need to eagerly transform dependencies though.
58+
// https://github.com/nodejs/node/blob/f3adc11e37b8bfaaa026ea85c1cf22e3a0e29ae9/lib/internal/modules/esm/translators.js#L382-L409
59+
output.append(`__vite_ssr_exportAll__(module.exports)`)
60+
return {
61+
code: output.toString(),
62+
map: output.generateMap({ hires: 'boundary' }),
63+
}
64+
}
65+
},
66+
},
67+
]
68+
}
69+
70+
function extractPackageKey(id: string): string {
71+
// .../.yarn/cache/abc/... => abc
72+
const yarnMatch = id.match(/\/.yarn\/cache\/([^/]+)/)
73+
if (yarnMatch) {
74+
return yarnMatch[1]!
75+
}
76+
// .../node_modules/@x/y/... => @x/y
77+
// .../node_modules/x/... => x
78+
if (id.includes('/node_modules')) {
79+
id = id.split('/node_modules/').at(-1)!
80+
let [x, y] = id.split('/')
81+
if (x!.startsWith('@')) {
82+
return `${x}/${y}`
83+
}
84+
return x!
85+
}
86+
return id
87+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
// https://github.com/vitejs/vite/blob/ea9aed7ebcb7f4be542bd2a384cbcb5a1e7b31bd/packages/vite/src/node/utils.ts#L1469-L1475
2+
export function evalValue<T = any>(rawValue: string): T {
3+
const fn = new Function(`
4+
var console, exports, global, module, process, require
5+
return (\n${rawValue}\n)
6+
`)
7+
return fn()
8+
}
9+
10+
// https://github.com/vitejs/vite-plugin-vue/blob/06931b1ea2b9299267374cb8eb4db27c0626774a/packages/plugin-vue/src/utils/query.ts#L13
11+
export function parseIdQuery(id: string): {
12+
filename: string
13+
query: {
14+
[k: string]: string
15+
}
16+
} {
17+
if (!id.includes('?')) return { filename: id, query: {} }
18+
const [filename, rawQuery] = id.split(`?`, 2) as [string, string]
19+
const query = Object.fromEntries(new URLSearchParams(rawQuery))
20+
return { filename, query }
21+
}
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import { parseAstAsync } from 'vite'
2+
import { describe, expect, it } from 'vitest'
3+
import { debugSourceMap } from './test-utils'
4+
import { transformCjsToEsm } from './cjs'
5+
6+
describe(transformCjsToEsm, () => {
7+
async function testTransform(input: string) {
8+
const ast = await parseAstAsync(input)
9+
const { output } = transformCjsToEsm(input, ast)
10+
if (!output.hasChanged()) {
11+
return
12+
}
13+
if (process.env['DEBUG_SOURCEMAP']) {
14+
await debugSourceMap(output)
15+
}
16+
return output.toString()
17+
}
18+
19+
it('basic', async () => {
20+
const input = `\
21+
exports.ok = true;
22+
`
23+
expect(await testTransform(input)).toMatchInlineSnapshot(`
24+
"const exports = {}; const module = { exports };
25+
exports.ok = true;
26+
"
27+
`)
28+
})
29+
30+
it('top-level re-export', async () => {
31+
const input = `\
32+
if (true) {
33+
module.exports = require('./cjs/use-sync-external-store.production.js');
34+
} else {
35+
module.exports = require('./cjs/use-sync-external-store.development.js');
36+
}
37+
`
38+
expect(await testTransform(input)).toMatchInlineSnapshot(`
39+
"const exports = {}; const module = { exports };
40+
if (true) {
41+
module.exports = (await import('./cjs/use-sync-external-store.production.js'));
42+
} else {
43+
module.exports = (await import('./cjs/use-sync-external-store.development.js'));
44+
}
45+
"
46+
`)
47+
})
48+
49+
it('non top-level re-export', async () => {
50+
const input = `\
51+
"production" !== process.env.NODE_ENV && (function() {
52+
var React = require("react");
53+
var ReactDOM = require("react-dom");
54+
exports.useSyncExternalStoreWithSelector = function () {}
55+
})()
56+
`
57+
expect(await testTransform(input)).toMatchInlineSnapshot(`
58+
"const exports = {}; const module = { exports };
59+
const __cjs_to_esm_hoist_1 = await import("react-dom");
60+
const __cjs_to_esm_hoist_0 = await import("react");
61+
"production" !== process.env.NODE_ENV && (function() {
62+
var React = __cjs_to_esm_hoist_0;
63+
var ReactDOM = __cjs_to_esm_hoist_1;
64+
exports.useSyncExternalStoreWithSelector = function () {}
65+
})()
66+
"
67+
`)
68+
})
69+
70+
it('local require', async () => {
71+
const input = `\
72+
{
73+
const require = () => {};
74+
require("test");
75+
}
76+
`
77+
expect(await testTransform(input)).toMatchInlineSnapshot(`
78+
"const exports = {}; const module = { exports };
79+
{
80+
const require = () => {};
81+
require("test");
82+
}
83+
"
84+
`)
85+
})
86+
})

0 commit comments

Comments
 (0)