From 563ddc26c1b1e5c5f95eec82765c3833be181a71 Mon Sep 17 00:00:00 2001 From: Lisa Date: Tue, 5 May 2026 09:27:30 +0200 Subject: [PATCH 01/19] refactor: replace vite-node with Vite's native RunnableDevEnvironment --- packages/cli/src/lib/exec.js | 52 +++++++++++------------------------- 1 file changed, 16 insertions(+), 36 deletions(-) diff --git a/packages/cli/src/lib/exec.js b/packages/cli/src/lib/exec.js index 849b1298b7..6572156b31 100644 --- a/packages/cli/src/lib/exec.js +++ b/packages/cli/src/lib/exec.js @@ -1,9 +1,6 @@ import path from 'node:path' -import { createServer, version as viteVersion } from 'vite' -import { ViteNodeRunner } from 'vite-node/client' -import { ViteNodeServer } from 'vite-node/server' -import { installSourcemapsSupport } from 'vite-node/source-map' +import { createServer, isRunnableDevEnvironment } from 'vite' import { getPaths, importStatementPath } from '@cedarjs/project-config' import { @@ -28,10 +25,16 @@ export async function runScriptFunction({ const server = await createServer({ mode: 'production', optimizeDeps: { - // This is recommended in the vite-node readme noDiscovery: true, include: undefined, }, + server: { + hmr: false, + watch: null, + }, + environments: { + nodeRunnerEnv: {}, + }, resolve: { alias: [ { @@ -97,49 +100,26 @@ export async function runScriptFunction({ ], }) - // For old Vite, this is needed to initialize the plugins. - if (Number(viteVersion.split('.')[0]) < 6) { - await server.pluginContainer.buildStart({}) + const env = server.environments.nodeRunnerEnv + if (!env || !isRunnableDevEnvironment(env)) { + await server.close() + throw new Error('Vite environment is not runnable.') } - const node = new ViteNodeServer(server, { - transformMode: { - ssr: [/.*/], - web: [/\/web\//], - }, - deps: { - fallbackCJS: true, - }, - }) - - // fixes stacktraces in Errors - installSourcemapsSupport({ - getSourceMap: (source) => node.getSourceMap(source), - }) - - const runner = new ViteNodeRunner({ - root: server.config.root, - base: server.config.base, - fetchModule(id) { - return node.fetchModule(id) - }, - resolveId(id, importer) { - return node.resolveId(id, importer) - }, - }) - let returnValue let scriptError = null try { - const script = await runner.executeFile(scriptPath) + const script = await env.runner.import(scriptPath) returnValue = await script[functionName](args) } catch (error) { scriptError = error } try { - const { db } = await runner.executeFile(path.join(getPaths().api.lib, 'db')) + const { db } = await env.runner.import( + path.join(getPaths().api.lib, 'db'), + ) db.$disconnect() } catch (e) { // silence From 3304f10c27c40b42407e2f837595b1f18e740f09 Mon Sep 17 00:00:00 2001 From: Lisa Date: Tue, 5 May 2026 09:27:31 +0200 Subject: [PATCH 02/19] refactor: replace vite-node with Vite's native RunnableDevEnvironment --- packages/prerender/src/graphql/node-runner.ts | 59 +++++++------------ 1 file changed, 22 insertions(+), 37 deletions(-) diff --git a/packages/prerender/src/graphql/node-runner.ts b/packages/prerender/src/graphql/node-runner.ts index 193ea33bc0..2128ce8875 100644 --- a/packages/prerender/src/graphql/node-runner.ts +++ b/packages/prerender/src/graphql/node-runner.ts @@ -1,8 +1,5 @@ -import { createServer, version as viteVersion, mergeConfig } from 'vite' -import type { ViteDevServer, UserConfig } from 'vite' -import { ViteNodeRunner } from 'vite-node/client' -import { ViteNodeServer } from 'vite-node/server' -import { installSourcemapsSupport } from 'vite-node/source-map' +import { createServer, isRunnableDevEnvironment, mergeConfig } from 'vite' +import type { ViteDevServer, RunnableDevEnvironment, UserConfig } from 'vite' import { getPaths } from '@cedarjs/project-config' import { @@ -19,10 +16,16 @@ async function createViteServer(customConfig: UserConfig = {}) { const defaultConfig: UserConfig = { mode: 'production', optimizeDeps: { - // This is recommended in the vite-node readme noDiscovery: true, include: undefined, }, + server: { + hmr: false, + watch: null, + }, + environments: { + nodeRunnerEnv: {}, + }, resolve: { alias: [ { @@ -45,17 +48,12 @@ async function createViteServer(customConfig: UserConfig = {}) { const server = await createServer(mergedConfig) - // For old Vite, this is needed to initialize the plugins. - if (Number(viteVersion.split('.')[0]) < 6) { - await server.pluginContainer.buildStart({}) - } - return server } export class NodeRunner { private viteServer?: ViteDevServer = undefined - private runner?: ViteNodeRunner = undefined + private env?: RunnableDevEnvironment = undefined private readonly customViteConfig: UserConfig constructor(customViteConfig: UserConfig = {}) { @@ -64,39 +62,26 @@ export class NodeRunner { async init() { this.viteServer = await createViteServer(this.customViteConfig) - const nodeServer = new ViteNodeServer(this.viteServer, { - transformMode: { - ssr: [/.*/], - web: [/\/web\//], - }, - deps: { - fallbackCJS: true, - }, - }) - // fixes stacktraces in Errors - installSourcemapsSupport({ - getSourceMap: (source) => nodeServer?.getSourceMap(source), - }) + const env = this.viteServer.environments.nodeRunnerEnv + if (!env || !isRunnableDevEnvironment(env)) { + throw new Error('Vite environment is not runnable.') + } - this.runner = new ViteNodeRunner({ - root: this.viteServer.config.root, - base: this.viteServer.config.base, - fetchModule(id) { - return nodeServer.fetchModule(id) - }, - resolveId(id, importer) { - return nodeServer.resolveId(id, importer) - }, - }) + this.env = env } async importFile(filePath: string) { - if (!this.runner) { + if (!this.env) { await this.init() } - return this.runner?.executeFile(filePath) + const env = this.env + if (!env) { + throw new Error('NodeRunner failed to initialize') + } + + return env.runner.import(filePath) } async close() { From d9d6e8c5481c37f2bdc2864e53c41766dc949a57 Mon Sep 17 00:00:00 2001 From: Lisa Date: Tue, 5 May 2026 09:27:33 +0200 Subject: [PATCH 03/19] refactor: replace vite-node with Vite's native RunnableDevEnvironment --- packages/cli/package.json | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/cli/package.json b/packages/cli/package.json index cbdc041152..61477478c8 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -88,7 +88,6 @@ "title-case": "3.0.3", "unionfs": "4.6.0", "uuid": "11.1.0", - "vite-node": "3.2.4", "yargs": "17.7.2" }, "devDependencies": { From 05eff97c5f8ac0adca9b41777d4628fcea33d0b6 Mon Sep 17 00:00:00 2001 From: Lisa Date: Tue, 5 May 2026 09:27:34 +0200 Subject: [PATCH 04/19] refactor: replace vite-node with Vite's native RunnableDevEnvironment --- packages/prerender/package.json | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/prerender/package.json b/packages/prerender/package.json index ea84f1ea80..be3863feb9 100644 --- a/packages/prerender/package.json +++ b/packages/prerender/package.json @@ -91,8 +91,7 @@ "rollup-plugin-swc3": "0.12.1", "unimport": "5.7.0", "unplugin-auto-import": "19.3.0", - "vite": "7.3.2", - "vite-node": "3.2.4" + "vite": "7.3.2" }, "devDependencies": { "@cedarjs/framework-tools": "workspace:*", From 0e50ec9ca4f14c2f599777585b87ad1679228b0f Mon Sep 17 00:00:00 2001 From: Lisa Date: Tue, 5 May 2026 09:31:20 +0200 Subject: [PATCH 05/19] fix: close vite server before throwing on guard failure in node-runner --- packages/prerender/src/graphql/node-runner.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/prerender/src/graphql/node-runner.ts b/packages/prerender/src/graphql/node-runner.ts index 2128ce8875..0479a2fce0 100644 --- a/packages/prerender/src/graphql/node-runner.ts +++ b/packages/prerender/src/graphql/node-runner.ts @@ -65,6 +65,7 @@ export class NodeRunner { const env = this.viteServer.environments.nodeRunnerEnv if (!env || !isRunnableDevEnvironment(env)) { + await this.viteServer.close() throw new Error('Vite environment is not runnable.') } From 0cd0ea7bc9deef7fb78405d9cf88fb1bba0c76c9 Mon Sep 17 00:00:00 2001 From: Lisa Date: Tue, 5 May 2026 09:47:00 +0200 Subject: [PATCH 06/19] chore: update yarn.lock after removing vite-node From 9ccd211fdfc6758b7e5e38087729d84742d8f3de Mon Sep 17 00:00:00 2001 From: Lisa Date: Tue, 5 May 2026 09:47:30 +0200 Subject: [PATCH 07/19] chore: update yarn.lock after removing vite-node --- yarn.lock | 2 -- 1 file changed, 2 deletions(-) diff --git a/yarn.lock b/yarn.lock index 8821d6f9d3..24c06ebf36 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2951,7 +2951,6 @@ __metadata: typescript: "npm:5.9.3" unionfs: "npm:4.6.0" uuid: "npm:11.1.0" - vite-node: "npm:3.2.4" vitest: "npm:3.2.4" yargs: "npm:17.7.2" bin: @@ -3487,7 +3486,6 @@ __metadata: unimport: "npm:5.7.0" unplugin-auto-import: "npm:19.3.0" vite: "npm:7.3.2" - vite-node: "npm:3.2.4" vitest: "npm:3.2.4" peerDependencies: react: 19.2.3 From 091169e8f1e3ce0ede279f53dd3dc704fd9ec23f Mon Sep 17 00:00:00 2001 From: Lisa Date: Tue, 5 May 2026 09:52:35 +0200 Subject: [PATCH 08/19] style: fix prettier formatting in exec.js --- packages/cli/src/lib/exec.js | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/cli/src/lib/exec.js b/packages/cli/src/lib/exec.js index 6572156b31..cb82d3e98e 100644 --- a/packages/cli/src/lib/exec.js +++ b/packages/cli/src/lib/exec.js @@ -117,9 +117,7 @@ export async function runScriptFunction({ } try { - const { db } = await env.runner.import( - path.join(getPaths().api.lib, 'db'), - ) + const { db } = await env.runner.import(path.join(getPaths().api.lib, 'db')) db.$disconnect() } catch (e) { // silence From f7abc0c65dd5034a8e3cdc025b35770564540398 Mon Sep 17 00:00:00 2001 From: Lisa Date: Tue, 5 May 2026 10:07:34 +0200 Subject: [PATCH 09/19] fix: remove .js extension stripping in src/ alias customResolver --- packages/cli/src/lib/exec.js | 13 ++----------- 1 file changed, 2 insertions(+), 11 deletions(-) diff --git a/packages/cli/src/lib/exec.js b/packages/cli/src/lib/exec.js index cb82d3e98e..c05c9ceda5 100644 --- a/packages/cli/src/lib/exec.js +++ b/packages/cli/src/lib/exec.js @@ -66,22 +66,13 @@ export async function runScriptFunction({ // from scripts/ because it doesn't know what the src/ alias is. // So we have to tell it to use the correct path based on what file // is doing the importing. - // Also, to support both imports like 'src/lib/db.js' and - // 'src/lib/db' in ts files we need to have special treatment for - // the .js extension. if (importer.startsWith(apiImportBase)) { const apiImportSrc = importStatementPath(getPaths().api.src) - let resolvedId = id.replace('src', apiImportSrc) - if (importer.endsWith('.ts') || importer.endsWith('.tsx')) { - resolvedId = resolvedId.replace(/\.jsx?$/, '') - } + const resolvedId = id.replace('src', apiImportSrc) return { id: resolvedId } } else if (importer.startsWith(webImportBase)) { const webImportSrc = importStatementPath(getPaths().web.src) - let resolvedId = id.replace('src', webImportSrc) - if (importer.endsWith('.ts') || importer.endsWith('.tsx')) { - resolvedId = resolvedId.replace(/\.jsx?$/, '') - } + const resolvedId = id.replace('src', webImportSrc) return { id: resolvedId } } From e0e91a72c864e623ebff2bb709698814c8538682 Mon Sep 17 00:00:00 2001 From: Lisa Date: Tue, 5 May 2026 10:55:44 +0200 Subject: [PATCH 10/19] fix: resolve actual file extension in src/ alias customResolver --- packages/cli/src/lib/exec.js | 27 +++++++++++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/lib/exec.js b/packages/cli/src/lib/exec.js index c05c9ceda5..972b057264 100644 --- a/packages/cli/src/lib/exec.js +++ b/packages/cli/src/lib/exec.js @@ -1,3 +1,4 @@ +import { existsSync } from 'node:fs' import path from 'node:path' import { createServer, isRunnableDevEnvironment } from 'vite' @@ -12,6 +13,24 @@ import { cedarAutoImportsPlugin, } from '@cedarjs/vite' +// When the customResolver returns an id, that id is final — Vite won't try +// alternative extensions on it. This helper maps .js/.jsx to the actual file +// on disk (e.g. db.js → db.ts in a TypeScript project). +function resolveExtension(id) { + if (existsSync(id)) { + return id + } + if (/\.jsx?$/.test(id)) { + const withoutExt = id.replace(/\.jsx?$/, '') + for (const ext of ['.ts', '.tsx', '.js', '.jsx']) { + if (existsSync(withoutExt + ext)) { + return withoutExt + ext + } + } + } + return id +} + export async function runScriptFunction({ path: scriptPath, functionName, @@ -66,14 +85,18 @@ export async function runScriptFunction({ // from scripts/ because it doesn't know what the src/ alias is. // So we have to tell it to use the correct path based on what file // is doing the importing. + // Also, to support imports like 'src/lib/db.js' in TS projects + // where only a .ts file exists, we resolve the correct extension + // ourselves — the customResolver result is final and Vite won't + // try alternative extensions on it. if (importer.startsWith(apiImportBase)) { const apiImportSrc = importStatementPath(getPaths().api.src) const resolvedId = id.replace('src', apiImportSrc) - return { id: resolvedId } + return { id: resolveExtension(resolvedId) } } else if (importer.startsWith(webImportBase)) { const webImportSrc = importStatementPath(getPaths().web.src) const resolvedId = id.replace('src', webImportSrc) - return { id: resolvedId } + return { id: resolveExtension(resolvedId) } } return null From a6b465178935f1c3e872ad9246ffec661b647ad1 Mon Sep 17 00:00:00 2001 From: Lisa Date: Tue, 5 May 2026 10:58:35 +0200 Subject: [PATCH 11/19] fix: add @rollup/plugin-commonjs to handle CJS modules in node runner --- packages/prerender/src/graphql/node-runner.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/prerender/src/graphql/node-runner.ts b/packages/prerender/src/graphql/node-runner.ts index 0479a2fce0..701ccc4402 100644 --- a/packages/prerender/src/graphql/node-runner.ts +++ b/packages/prerender/src/graphql/node-runner.ts @@ -1,3 +1,4 @@ +import commonjs from '@rollup/plugin-commonjs' import { createServer, isRunnableDevEnvironment, mergeConfig } from 'vite' import type { ViteDevServer, RunnableDevEnvironment, UserConfig } from 'vite' @@ -35,6 +36,7 @@ async function createViteServer(customConfig: UserConfig = {}) { ], }, plugins: [ + commonjs(), cedarImportDirPlugin(), cedarAutoImportsPlugin(), cedarjsResolveCedarStyleImportsPlugin(), From 1ad6a4f8f3b1c2fc6165a7d30e680e11f3fdd5fb Mon Sep 17 00:00:00 2001 From: Lisa Date: Tue, 5 May 2026 10:59:22 +0200 Subject: [PATCH 12/19] fix: also resolve extensions for bare paths in src/ alias customResolver --- packages/cli/src/lib/exec.js | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/cli/src/lib/exec.js b/packages/cli/src/lib/exec.js index 972b057264..b9f5dd5816 100644 --- a/packages/cli/src/lib/exec.js +++ b/packages/cli/src/lib/exec.js @@ -14,18 +14,18 @@ import { } from '@cedarjs/vite' // When the customResolver returns an id, that id is final — Vite won't try -// alternative extensions on it. This helper maps .js/.jsx to the actual file -// on disk (e.g. db.js → db.ts in a TypeScript project). +// alternative extensions on it. This helper resolves the actual file on disk, +// handling both bare paths (src/lib/jobs) and .js/.jsx paths that map to .ts +// files in a TypeScript project (e.g. db.js → db.ts). function resolveExtension(id) { if (existsSync(id)) { return id } - if (/\.jsx?$/.test(id)) { - const withoutExt = id.replace(/\.jsx?$/, '') - for (const ext of ['.ts', '.tsx', '.js', '.jsx']) { - if (existsSync(withoutExt + ext)) { - return withoutExt + ext - } + // Strip .js/.jsx extension if present, then try TypeScript and JS extensions + const withoutExt = /\.jsx?$/.test(id) ? id.replace(/\.jsx?$/, '') : id + for (const ext of ['.ts', '.tsx', '.js', '.jsx']) { + if (existsSync(withoutExt + ext)) { + return withoutExt + ext } } return id From df40126f9c47e17021f41bf3188cdc083d34da54 Mon Sep 17 00:00:00 2001 From: Lisa Date: Tue, 5 May 2026 11:09:40 +0200 Subject: [PATCH 13/19] fix(prerender): use fix() wrapper for commonjs plugin to avoid TS2349 error --- packages/prerender/src/graphql/node-runner.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/prerender/src/graphql/node-runner.ts b/packages/prerender/src/graphql/node-runner.ts index 701ccc4402..92138300b8 100644 --- a/packages/prerender/src/graphql/node-runner.ts +++ b/packages/prerender/src/graphql/node-runner.ts @@ -2,6 +2,8 @@ import commonjs from '@rollup/plugin-commonjs' import { createServer, isRunnableDevEnvironment, mergeConfig } from 'vite' import type { ViteDevServer, RunnableDevEnvironment, UserConfig } from 'vite' +const fix = (f: { default: T }): T => f as unknown as T + import { getPaths } from '@cedarjs/project-config' import { cedarCellTransform, @@ -36,7 +38,7 @@ async function createViteServer(customConfig: UserConfig = {}) { ], }, plugins: [ - commonjs(), + fix(commonjs)(), cedarImportDirPlugin(), cedarAutoImportsPlugin(), cedarjsResolveCedarStyleImportsPlugin(), From e8a8ab24a4b52c66014621e6e1e5c0f2a30dfd85 Mon Sep 17 00:00:00 2001 From: Tobbe Lundberg Date: Tue, 5 May 2026 11:14:51 +0200 Subject: [PATCH 14/19] Add doc comment to const fix = --- packages/prerender/src/graphql/node-runner.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/prerender/src/graphql/node-runner.ts b/packages/prerender/src/graphql/node-runner.ts index 92138300b8..2dc63ceef3 100644 --- a/packages/prerender/src/graphql/node-runner.ts +++ b/packages/prerender/src/graphql/node-runner.ts @@ -2,6 +2,7 @@ import commonjs from '@rollup/plugin-commonjs' import { createServer, isRunnableDevEnvironment, mergeConfig } from 'vite' import type { ViteDevServer, RunnableDevEnvironment, UserConfig } from 'vite' +/** @see {@link https://github.com/rollup/plugins/issues/1541} */ const fix = (f: { default: T }): T => f as unknown as T import { getPaths } from '@cedarjs/project-config' From 02c06edc4a60dabe2c2654497a9bb36b01a60f9a Mon Sep 17 00:00:00 2001 From: Lisa Date: Tue, 5 May 2026 11:41:04 +0200 Subject: [PATCH 15/19] fix(prerender): replace @rollup/plugin-commonjs with custom CJS compat plugin @rollup/plugin-commonjs requires Rollup's internal build context (isRequiredId, resolveRequireSourcesAndUpdateMeta) which doesn't exist in Vite 6's EnvironmentPluginContainer, causing all transforms to crash. Replace with a custom transform plugin that: - Detects CJS files by checking for module.exports / exports.x patterns - Wraps them with CJS globals (module, exports, require, __dirname, __filename) - Uses cjs-module-lexer (Vite transitive dep) to statically detect named exports - Re-exports named exports individually so callers can use destructuring --- packages/prerender/src/graphql/node-runner.ts | 85 +++++++++++++++++-- 1 file changed, 79 insertions(+), 6 deletions(-) diff --git a/packages/prerender/src/graphql/node-runner.ts b/packages/prerender/src/graphql/node-runner.ts index 2dc63ceef3..5c71daf7dd 100644 --- a/packages/prerender/src/graphql/node-runner.ts +++ b/packages/prerender/src/graphql/node-runner.ts @@ -1,9 +1,8 @@ -import commonjs from '@rollup/plugin-commonjs' -import { createServer, isRunnableDevEnvironment, mergeConfig } from 'vite' -import type { ViteDevServer, RunnableDevEnvironment, UserConfig } from 'vite' +import { readFileSync } from 'node:fs' +import path from 'node:path' -/** @see {@link https://github.com/rollup/plugins/issues/1541} */ -const fix = (f: { default: T }): T => f as unknown as T +import { createServer, isRunnableDevEnvironment, mergeConfig } from 'vite' +import type { Plugin, ViteDevServer, RunnableDevEnvironment, UserConfig } from 'vite' import { getPaths } from '@cedarjs/project-config' import { @@ -16,6 +15,80 @@ import { import { cedarAutoImportsPlugin } from './vite-plugin-cedar-auto-import.js' import { cedarImportDirPlugin } from './vite-plugin-cedar-import-dir.js' +/** + * A Vite plugin that transforms CommonJS files to ESM so they work with + * Vite 6's RunnableDevEnvironment / ESModulesEvaluator, which doesn't + * understand `module.exports` syntax. + * + * Uses `cjs-module-lexer` (a Vite transitive dependency) to detect named + * exports so they are individually re-exported and accessible without going + * through `.default`. + */ +function cjsCompatPlugin(): Plugin { + let lexerInitialized = false + + return { + name: 'cedar-cjs-compat', + enforce: 'pre', + async transform(code, id) { + // Only handle plain .js / .cjs files — TypeScript and JSX are already + // transformed by Vite's esbuild plugin and will be valid ESM. + if (!/\.[cm]?js$/.test(id)) { + return null + } + + // Quick heuristic: skip files that don't look like CJS + if (!/\bmodule\.exports\b|\bexports\.\w+/.test(code)) { + return null + } + + // Use cjs-module-lexer to statically extract named exports so we can + // re-export them individually, preserving the import { handler } pattern + // used by callers like getGqlHandler. + let namedExports: string[] = [] + try { + if (!lexerInitialized) { + const { init } = await import('cjs-module-lexer') + await init() + lexerInitialized = true + } + const { parse } = await import('cjs-module-lexer') + const { exports } = parse(code) + namedExports = exports.filter( + (e) => /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(e) && e !== 'default', + ) + } catch { + // If the lexer fails, fall back to default-only export + } + + const dirPath = JSON.stringify(path.dirname(id)) + const filePath = JSON.stringify(id) + + const namedExportLines = namedExports + .map((name) => `export const ${name} = __cjs_result__[${JSON.stringify(name)}]`) + .join('\n') + + return { + code: ` +import { createRequire as __createRequire__ } from 'node:module' +const require = __createRequire__(${filePath}) +const module = { exports: {} } +const exports = module.exports +const __dirname = ${dirPath} +const __filename = ${filePath} +;(function() { +${code} +}).call(module.exports) +const __cjs_result__ = module.exports +export default __cjs_result__ +${namedExportLines} +`, + map: null, + } + }, + } +} + async function createViteServer(customConfig: UserConfig = {}) { const defaultConfig: UserConfig = { mode: 'production', @@ -39,7 +112,7 @@ async function createViteServer(customConfig: UserConfig = {}) { ], }, plugins: [ - fix(commonjs)(), + cjsCompatPlugin(), cedarImportDirPlugin(), cedarAutoImportsPlugin(), cedarjsResolveCedarStyleImportsPlugin(), From 93d1016af0d944492e5a6a5653e7da76752174a0 Mon Sep 17 00:00:00 2001 From: Lisa Date: Tue, 5 May 2026 11:50:01 +0200 Subject: [PATCH 16/19] style(prerender): fix prettier formatting in node-runner.ts --- packages/prerender/src/graphql/node-runner.ts | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/packages/prerender/src/graphql/node-runner.ts b/packages/prerender/src/graphql/node-runner.ts index 5c71daf7dd..c99de0ba99 100644 --- a/packages/prerender/src/graphql/node-runner.ts +++ b/packages/prerender/src/graphql/node-runner.ts @@ -1,8 +1,12 @@ -import { readFileSync } from 'node:fs' import path from 'node:path' import { createServer, isRunnableDevEnvironment, mergeConfig } from 'vite' -import type { Plugin, ViteDevServer, RunnableDevEnvironment, UserConfig } from 'vite' +import type { + Plugin, + ViteDevServer, + RunnableDevEnvironment, + UserConfig, +} from 'vite' import { getPaths } from '@cedarjs/project-config' import { @@ -65,7 +69,10 @@ function cjsCompatPlugin(): Plugin { const filePath = JSON.stringify(id) const namedExportLines = namedExports - .map((name) => `export const ${name} = __cjs_result__[${JSON.stringify(name)}]`) + .map( + (name) => + `export const ${name} = __cjs_result__[${JSON.stringify(name)}]`, + ) .join('\n') return { From 5876eb81aa968f610f27e8838568f35018cb1db5 Mon Sep 17 00:00:00 2001 From: Tobbe Lundberg Date: Tue, 5 May 2026 12:26:41 +0200 Subject: [PATCH 17/19] Add CJS compat plugin to exec --- packages/cli/package.json | 1 + packages/cli/src/lib/exec.js | 2 + packages/prerender/src/graphql/node-runner.ts | 89 +------------------ packages/vite/src/index.ts | 1 + .../plugins/vite-plugin-cedar-cjs-compat.ts | 80 +++++++++++++++++ yarn.lock | 1 + 6 files changed, 88 insertions(+), 86 deletions(-) create mode 100644 packages/vite/src/plugins/vite-plugin-cedar-cjs-compat.ts diff --git a/packages/cli/package.json b/packages/cli/package.json index 61477478c8..22acbc38e9 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -42,6 +42,7 @@ "@cedarjs/structure": "workspace:*", "@cedarjs/telemetry": "workspace:*", "@cedarjs/utils": "workspace:*", + "@cedarjs/vite": "workspace:*", "@cedarjs/web-server": "workspace:*", "@listr2/prompt-adapter-enquirer": "4.2.1", "@opentelemetry/api": "1.9.0", diff --git a/packages/cli/src/lib/exec.js b/packages/cli/src/lib/exec.js index b9f5dd5816..1207fa40ea 100644 --- a/packages/cli/src/lib/exec.js +++ b/packages/cli/src/lib/exec.js @@ -11,6 +11,7 @@ import { cedarSwapApolloProvider, cedarImportDirPlugin, cedarAutoImportsPlugin, + cedarCjsCompatPlugin, } from '@cedarjs/vite' // When the customResolver returns an id, that id is final — Vite won't try @@ -105,6 +106,7 @@ export async function runScriptFunction({ ], }, plugins: [ + cedarCjsCompatPlugin(), cedarjsResolveCedarStyleImportsPlugin(), cedarCellTransform(), cedarjsJobPathInjectorPlugin(), diff --git a/packages/prerender/src/graphql/node-runner.ts b/packages/prerender/src/graphql/node-runner.ts index c99de0ba99..91aed90a1f 100644 --- a/packages/prerender/src/graphql/node-runner.ts +++ b/packages/prerender/src/graphql/node-runner.ts @@ -1,12 +1,5 @@ -import path from 'node:path' - import { createServer, isRunnableDevEnvironment, mergeConfig } from 'vite' -import type { - Plugin, - ViteDevServer, - RunnableDevEnvironment, - UserConfig, -} from 'vite' +import type { ViteDevServer, RunnableDevEnvironment, UserConfig } from 'vite' import { getPaths } from '@cedarjs/project-config' import { @@ -14,88 +7,12 @@ import { cedarjsResolveCedarStyleImportsPlugin, cedarjsJobPathInjectorPlugin, cedarSwapApolloProvider, + cedarCjsCompatPlugin, } from '@cedarjs/vite' import { cedarAutoImportsPlugin } from './vite-plugin-cedar-auto-import.js' import { cedarImportDirPlugin } from './vite-plugin-cedar-import-dir.js' -/** - * A Vite plugin that transforms CommonJS files to ESM so they work with - * Vite 6's RunnableDevEnvironment / ESModulesEvaluator, which doesn't - * understand `module.exports` syntax. - * - * Uses `cjs-module-lexer` (a Vite transitive dependency) to detect named - * exports so they are individually re-exported and accessible without going - * through `.default`. - */ -function cjsCompatPlugin(): Plugin { - let lexerInitialized = false - - return { - name: 'cedar-cjs-compat', - enforce: 'pre', - async transform(code, id) { - // Only handle plain .js / .cjs files — TypeScript and JSX are already - // transformed by Vite's esbuild plugin and will be valid ESM. - if (!/\.[cm]?js$/.test(id)) { - return null - } - - // Quick heuristic: skip files that don't look like CJS - if (!/\bmodule\.exports\b|\bexports\.\w+/.test(code)) { - return null - } - - // Use cjs-module-lexer to statically extract named exports so we can - // re-export them individually, preserving the import { handler } pattern - // used by callers like getGqlHandler. - let namedExports: string[] = [] - try { - if (!lexerInitialized) { - const { init } = await import('cjs-module-lexer') - await init() - lexerInitialized = true - } - const { parse } = await import('cjs-module-lexer') - const { exports } = parse(code) - namedExports = exports.filter( - (e) => /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(e) && e !== 'default', - ) - } catch { - // If the lexer fails, fall back to default-only export - } - - const dirPath = JSON.stringify(path.dirname(id)) - const filePath = JSON.stringify(id) - - const namedExportLines = namedExports - .map( - (name) => - `export const ${name} = __cjs_result__[${JSON.stringify(name)}]`, - ) - .join('\n') - - return { - code: ` -import { createRequire as __createRequire__ } from 'node:module' -const require = __createRequire__(${filePath}) -const module = { exports: {} } -const exports = module.exports -const __dirname = ${dirPath} -const __filename = ${filePath} -;(function() { -${code} -}).call(module.exports) -const __cjs_result__ = module.exports -export default __cjs_result__ -${namedExportLines} -`, - map: null, - } - }, - } -} - async function createViteServer(customConfig: UserConfig = {}) { const defaultConfig: UserConfig = { mode: 'production', @@ -119,7 +36,7 @@ async function createViteServer(customConfig: UserConfig = {}) { ], }, plugins: [ - cjsCompatPlugin(), + cedarCjsCompatPlugin(), cedarImportDirPlugin(), cedarAutoImportsPlugin(), cedarjsResolveCedarStyleImportsPlugin(), diff --git a/packages/vite/src/index.ts b/packages/vite/src/index.ts index 70e4b734b0..e6d5476fee 100644 --- a/packages/vite/src/index.ts +++ b/packages/vite/src/index.ts @@ -21,6 +21,7 @@ import { cedarMergedConfig } from './plugins/vite-plugin-merged-config.js' import { cedarSwapApolloProvider } from './plugins/vite-plugin-swap-apollo-provider.js' export { cedarAutoImportsPlugin } from './plugins/vite-plugin-cedar-auto-import.js' +export { cedarCjsCompatPlugin } from './plugins/vite-plugin-cedar-cjs-compat.js' export { cedarCellTransform } from './plugins/vite-plugin-cedar-cell.js' export { cedarEntryInjectionPlugin } from './plugins/vite-plugin-cedar-entry-injection.js' export { cedarHtmlEnvPlugin } from './plugins/vite-plugin-cedar-html-env.js' diff --git a/packages/vite/src/plugins/vite-plugin-cedar-cjs-compat.ts b/packages/vite/src/plugins/vite-plugin-cedar-cjs-compat.ts new file mode 100644 index 0000000000..98e37d4b65 --- /dev/null +++ b/packages/vite/src/plugins/vite-plugin-cedar-cjs-compat.ts @@ -0,0 +1,80 @@ +import path from 'node:path' + +import type { Plugin } from 'vite' + +/** + * A Vite plugin that transforms CommonJS files to ESM so they work with + * Vite 6's RunnableDevEnvironment / ESModulesEvaluator, which doesn't + * understand `module.exports` syntax. + * + * Uses `cjs-module-lexer` (a Vite transitive dependency) to detect named + * exports so they are individually re-exported and accessible without going + * through `.default`. + */ +export function cedarCjsCompatPlugin(): Plugin { + let lexerInitialized = false + + return { + name: 'cedar-cjs-compat', + enforce: 'pre', + async transform(code, id) { + // Only handle plain .js / .cjs files — TypeScript and JSX are already + // transformed by Vite's esbuild plugin and will be valid ESM. + if (!/\.[cm]?js$/.test(id)) { + return null + } + + // Quick heuristic: skip files that don't look like CJS + if (!/\bmodule\.exports\b|\bexports\.\w+/.test(code)) { + return null + } + + // Use cjs-module-lexer to statically extract named exports so we can + // re-export them individually, preserving the import { handler } pattern + // used by callers like getGqlHandler. + let namedExports: string[] = [] + try { + if (!lexerInitialized) { + const { init } = await import('cjs-module-lexer') + await init() + lexerInitialized = true + } + const { parse } = await import('cjs-module-lexer') + const { exports } = parse(code) + namedExports = exports.filter( + (e) => /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(e) && e !== 'default', + ) + } catch { + // If the lexer fails, fall back to default-only export + } + + const dirPath = JSON.stringify(path.dirname(id)) + const filePath = JSON.stringify(id) + + const namedExportLines = namedExports + .map( + (name) => + `export const ${name} = __cjs_result__[${JSON.stringify(name)}]`, + ) + .join('\n') + + return { + code: ` +import { createRequire as __createRequire__ } from 'node:module' +const require = __createRequire__(${filePath}) +const module = { exports: {} } +const exports = module.exports +const __dirname = ${dirPath} +const __filename = ${filePath} +;(function() { +${code} +}).call(module.exports) +const __cjs_result__ = module.exports +export default __cjs_result__ +${namedExportLines} +`, + map: null, + } + }, + } +} diff --git a/yarn.lock b/yarn.lock index 24c06ebf36..cd1be9b88d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2899,6 +2899,7 @@ __metadata: "@cedarjs/structure": "workspace:*" "@cedarjs/telemetry": "workspace:*" "@cedarjs/utils": "workspace:*" + "@cedarjs/vite": "workspace:*" "@cedarjs/web-server": "workspace:*" "@listr2/prompt-adapter-enquirer": "npm:4.2.1" "@opentelemetry/api": "npm:1.9.0" From e63e9bca198950f5812865d088c9006ee79095f4 Mon Sep 17 00:00:00 2001 From: Tobbe Lundberg Date: Tue, 5 May 2026 13:05:29 +0200 Subject: [PATCH 18/19] cjs compat plugin improvements --- .../2026-03-26-cedarjs-project-overview.md | 2 +- packages/vite/package.json | 1 + .../plugins/vite-plugin-cedar-cjs-compat.ts | 481 +++++++++++++++++- yarn.lock | 3 +- 4 files changed, 464 insertions(+), 23 deletions(-) diff --git a/docs/implementation-docs/2026-03-26-cedarjs-project-overview.md b/docs/implementation-docs/2026-03-26-cedarjs-project-overview.md index 552a82da27..0298d08bd9 100644 --- a/docs/implementation-docs/2026-03-26-cedarjs-project-overview.md +++ b/docs/implementation-docs/2026-03-26-cedarjs-project-overview.md @@ -13,7 +13,7 @@ │ USER PROJECT: api/src/ │ web/src/ │ cedar.toml │ Routes.tsx │ Cells │ └──────────────┬──────────────────────────────┬───────────────────────┘ │ │ -┌──────────────▼─────────────────────────────▼───────────────────────┐ +┌──────────────▼──────────────────────────────▼────────────────────────┐ │ CORE: cli│router│auth│web│api│graphql-server│vite│forms│prerender │ │ realtime│jobs│mailer│storage│record│codemods │ ├──────────────────────────────────────────────────────────────────────┤ diff --git a/packages/vite/package.json b/packages/vite/package.json index 9a0be568b4..5b157bc234 100644 --- a/packages/vite/package.json +++ b/packages/vite/package.json @@ -92,6 +92,7 @@ "@vitejs/plugin-react": "4.7.0", "@whatwg-node/fetch": "0.10.13", "@whatwg-node/server": "0.10.18", + "acorn": "8.16.0", "acorn-loose": "8.5.2", "ansis": "4.2.0", "buffer": "6.0.3", diff --git a/packages/vite/src/plugins/vite-plugin-cedar-cjs-compat.ts b/packages/vite/src/plugins/vite-plugin-cedar-cjs-compat.ts index 98e37d4b65..4789ce780e 100644 --- a/packages/vite/src/plugins/vite-plugin-cedar-cjs-compat.ts +++ b/packages/vite/src/plugins/vite-plugin-cedar-cjs-compat.ts @@ -1,7 +1,207 @@ import path from 'node:path' +import { parse } from 'acorn' import type { Plugin } from 'vite' +// UMD heuristics borrowed from @chialab/cjs-to-esm — matches patterns like +// `typeof module.exports === 'object'` and `typeof define === 'function'`. +const UMD_REGEXES = [ + /\btypeof\s+(module\.exports|module|exports)\s*===?\s*['"]object['"]/, + /['"]object['"]\s*===?\s*typeof\s+(module\.exports|module|exports)/, + /\btypeof\s+define\s*===?\s*['"]function['"]/, + /['"]function['"]\s*===?\s*typeof\s+define/, +] + +/** + * Minimal AST node type used during manual traversal. Every property is + * typed as `unknown` so we are forced to validate before use. + */ +interface AstNode { + type: string + [key: string]: unknown +} + +/** + * Type guard that checks whether a value is an AST node (i.e. an object + * with a `type` string property). + */ +function isAstNode(value: unknown): value is AstNode { + return value !== null && typeof value === 'object' && 'type' in value +} + +/** + * Type guard that checks whether an object has a specific property key. + */ +function hasProperty( + obj: object, + key: K, +): obj is Record { + return key in obj +} + +/** + * Walk an acorn AST and call the visitor for every node. The visitor may + * return `false` to skip descending into that node's children. + */ +function walkAst(node: unknown, visitor: (node: AstNode) => false | void) { + if (Array.isArray(node)) { + for (const child of node) { + walkAst(child, visitor) + } + return + } + + if (!isAstNode(node)) { + return + } + + const shouldDescend = visitor(node) + if (shouldDescend === false) { + return + } + + for (const key of Object.keys(node)) { + if (key === 'type' || key === 'loc' || key === 'range') { + continue + } + const child = node[key] + if (child && typeof child === 'object') { + walkAst(child, visitor) + } + } +} + +/** + * Extract the name of an Identifier node, or return `null`. + */ +function getIdentifierName(node: unknown): string | null { + if (!isAstNode(node)) { + return null + } + return node.type === 'Identifier' && typeof node.name === 'string' + ? node.name + : null +} + +/** + * Check whether a node represents `module.exports`. + */ +function isModuleExports(node: unknown): boolean { + if (!isAstNode(node)) { + return false + } + return ( + node.type === 'MemberExpression' && + getIdentifierName(node.object) === 'module' && + getIdentifierName(node.property) === 'exports' + ) +} + +/** + * Check whether a node represents a re-export: + * `module.exports = require(...)`. + */ +function isReExport(node: unknown): boolean { + if (!isAstNode(node) || node.type !== 'AssignmentExpression') { + return false + } + + return ( + isModuleExports(node.left) && + isAstNode(node.right) && + node.right.type === 'CallExpression' && + getIdentifierName(node.right.callee) === 'require' + ) +} + +/** + * Check whether a node represents `Object.defineProperty(exports, ...)`. + */ +function isObjectDefinePropertyOnExports(node: unknown): boolean { + if (!isAstNode(node) || node.type !== 'CallExpression') { + return false + } + + if (!isObjectDefineProperty(node)) { + return false + } + + const args = node.arguments + return ( + Array.isArray(args) && + args.length > 0 && + getIdentifierName(args[0]) === 'exports' + ) +} + +/** + * Check whether a node represents `module.exports = { ... }` (an object + * literal assignment). If it does, returns the ObjectExpression node so + * the caller can extract named exports from it. + */ +function getModuleExportsObjectLiteral(node: unknown): AstNode | null { + if (!isAstNode(node) || node.type !== 'AssignmentExpression') { + return null + } + + if ( + isModuleExports(node.left) && + isAstNode(node.right) && + node.right.type === 'ObjectExpression' + ) { + return node.right + } + + return null +} + +/** + * Check whether a node represents `Object.defineProperty(...)`. + */ +function isObjectDefineProperty(node: unknown): boolean { + if (!isAstNode(node) || node.type !== 'CallExpression') { + return false + } + + const callee = node.callee + if (!isAstNode(callee) || callee.type !== 'MemberExpression') { + return false + } + + return ( + getIdentifierName(callee.object) === 'Object' && + getIdentifierName(callee.property) === 'defineProperty' + ) +} + +/** + * Build a location string from an acorn node for error messages. + */ +function formatLoc(node: AstNode): string { + const loc = node.loc + if (typeof loc !== 'object' || loc === null || !hasProperty(loc, 'start')) { + return 'unknown location' + } + + const start = loc.start + if ( + typeof start !== 'object' || + start === null || + !hasProperty(start, 'line') || + !hasProperty(start, 'column') + ) { + return 'unknown location' + } + + const line = start.line + const column = start.column + if (typeof line !== 'number' || typeof column !== 'number') { + return 'unknown location' + } + + return `line ${line}, column ${column}` +} + /** * A Vite plugin that transforms CommonJS files to ESM so they work with * Vite 6's RunnableDevEnvironment / ESModulesEvaluator, which doesn't @@ -10,6 +210,16 @@ import type { Plugin } from 'vite' * Uses `cjs-module-lexer` (a Vite transitive dependency) to detect named * exports so they are individually re-exported and accessible without going * through `.default`. + * + * Known limitations (documented inline where relevant): + * - No source-map support (`map: null`). + * - Object.defineProperty(exports, key, { get: () => ... }) getters are + * evaluated eagerly at module-load time rather than lazily. + * - Properties added to a function/class after `module.exports = fn` are + * not re-exported. + * - Circular dependencies rely on Node's native behaviour via + * `createRequire` and are not handled as robustly as Rollup's synthetic + * namespace objects. */ export function cedarCjsCompatPlugin(): Plugin { let lexerInitialized = false @@ -24,14 +234,166 @@ export function cedarCjsCompatPlugin(): Plugin { return null } - // Quick heuristic: skip files that don't look like CJS - if (!/\bmodule\.exports\b|\bexports\.\w+/.test(code)) { + // Fast bail-out: if there's no CJS-looking syntax, skip parsing. + if (!/\bmodule\.exports\b|\bexports\.[a-zA-Z_$]/.test(code)) { + return null + } + + // Detect UMD wrappers via regex before parsing — these are rare and + // the regex is sufficient for the common patterns. + const isUmd = UMD_REGEXES.some((re) => re.test(code)) + if (isUmd) { + throw new Error( + `CedarJS CJS compat plugin does not support UMD modules. ` + + `File: ${id}\n` + + `If you need to load this file in a Vite RunnableDevEnvironment, ` + + `consider converting it to pure ESM or using a pre-bundled ESM ` + + `build from the package author.`, + ) + } + + let ast: ReturnType + try { + ast = parse(code, { ecmaVersion: 'latest', sourceType: 'script' }) + } catch { + // If acorn can't parse it, it's probably not plain JS (or it's + // malformed). Skip and let Vite handle it. return null } - // Use cjs-module-lexer to statically extract named exports so we can - // re-export them individually, preserving the import { handler } pattern - // used by callers like getGqlHandler. + /** + * Scan the AST for unsupported CJS patterns and return the + * `module.exports = { ... }` ObjectExpression node if found. + */ + function scanForUnsupportedPatterns(astBody: unknown): AstNode | null { + let objectLiteral: AstNode | null = null + + walkAst(astBody, (node) => { + // Stop descending into nested functions — we only care about top-level + // module-scoped statements. + if ( + node.type === 'FunctionDeclaration' || + node.type === 'FunctionExpression' || + node.type === 'ArrowFunctionExpression' + ) { + return false + } + + if (node.type !== 'ExpressionStatement') { + return undefined + } + + const expr = node.expression + if (!isAstNode(expr)) { + return undefined + } + + // 1) Re-export detection: module.exports = require(...) + if (isReExport(expr)) { + throw new Error( + `CedarJS CJS compat plugin does not support re-exports ` + + `(module.exports = require(...)). File: ${id}\n` + + `Named exports from the re-exported module would be lost. ` + + `Convert the file to explicit named exports, or import the ` + + `target module directly in the consumer.`, + ) + } + + // 2) Object.defineProperty(exports, ...) + if (isObjectDefinePropertyOnExports(expr)) { + throw new Error( + `CedarJS CJS compat plugin does not support Object.defineProperty ` + + `on exports because getters would be evaluated eagerly at load ` + + `time rather than lazily. File: ${id}\n` + + `Convert the file to plain property assignments ` + + `(exports.foo = ...) or use an ESM build of the package.`, + ) + } + + // 3) module.exports = { ... } object literal + const objectLiteralExpr = getModuleExportsObjectLiteral(expr) + if (objectLiteralExpr) { + objectLiteral = objectLiteralExpr + // Don't descend into the object literal — we already have it. + return false + } + + return undefined + }) + + return objectLiteral + } + + const objectLiteralAssignment = scanForUnsupportedPatterns(ast) + + // 4) Local `exports` shadowing — scan top-level declarations. + walkAst(ast, (node) => { + if ( + node.type === 'FunctionDeclaration' || + node.type === 'FunctionExpression' || + node.type === 'ArrowFunctionExpression' + ) { + return false + } + + if (node.type !== 'VariableDeclaration') { + return undefined + } + + const declarations = node.declarations + if (!Array.isArray(declarations)) { + return undefined + } + + for (const decl of declarations) { + if (!isAstNode(decl)) { + continue + } + + const declId = decl.id + if (!isAstNode(declId)) { + continue + } + + if ( + declId.type === 'Identifier' && + typeof declId.name === 'string' && + declId.name === 'exports' + ) { + throw new Error( + `CedarJS CJS compat plugin does not support local variables ` + + `named 'exports' because they shadow the injected CJS ` + + `globals. File: ${id}\n` + + `Rename the local variable to something else.`, + ) + } + + if (declId.type === 'ObjectPattern') { + const props = declId.properties + if (Array.isArray(props)) { + for (const prop of props) { + if ( + isAstNode(prop) && + prop.type === 'Property' && + getIdentifierName(prop.value) === 'exports' + ) { + throw new Error( + `CedarJS CJS compat plugin does not support destructuring ` + + `into a local variable named 'exports' because it shadows ` + + `the injected CJS globals. File: ${id}\n` + + `Rename the local variable to something else.`, + ) + } + } + } + } + } + + return undefined + }) + + // Use cjs-module-lexer to statically extract named exports from + // `exports.foo = ...` and `Object.defineProperty(exports, 'foo', ...)`. let namedExports: string[] = [] try { if (!lexerInitialized) { @@ -39,8 +401,8 @@ export function cedarCjsCompatPlugin(): Plugin { await init() lexerInitialized = true } - const { parse } = await import('cjs-module-lexer') - const { exports } = parse(code) + const { parse: parseLexer } = await import('cjs-module-lexer') + const { exports } = parseLexer(code) namedExports = exports.filter( (e) => /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(e) && e !== 'default', ) @@ -48,31 +410,108 @@ export function cedarCjsCompatPlugin(): Plugin { // If the lexer fails, fall back to default-only export } + // Fallback: extract named exports from `module.exports = { ... }`. + if (namedExports.length === 0 && objectLiteralAssignment) { + const props = objectLiteralAssignment.properties + if (Array.isArray(props)) { + for (const prop of props) { + if (!isAstNode(prop)) { + continue + } + + if (prop.type === 'SpreadElement') { + throw new Error( + `CedarJS CJS compat plugin encountered an unsupported ` + + `pattern in module.exports = { ... } at ` + + `${formatLoc(prop)} (spread element (...)). File: ${id}\n` + + `Convert the object literal to plain property assignments ` + + `(exports.foo = ...) so that cjs-module-lexer can detect ` + + `the named exports, or use an ESM build of the package.`, + ) + } + + if (prop.computed) { + throw new Error( + `CedarJS CJS compat plugin encountered an unsupported ` + + `pattern in module.exports = { ... } at ` + + `${formatLoc(prop)} (computed property key ([expr])). ` + + `File: ${id}\n` + + `Convert the object literal to plain property assignments ` + + `(exports.foo = ...) so that cjs-module-lexer can detect ` + + `the named exports, or use an ESM build of the package.`, + ) + } + + if (prop.method) { + throw new Error( + `CedarJS CJS compat plugin encountered an unsupported ` + + `pattern in module.exports = { ... } at ` + + `${formatLoc(prop)} (method shorthand). File: ${id}\n` + + `Convert the object literal to plain property assignments ` + + `(exports.foo = ...) so that cjs-module-lexer can detect ` + + `the named exports, or use an ESM build of the package.`, + ) + } + + if (prop.shorthand) { + throw new Error( + `CedarJS CJS compat plugin encountered an unsupported ` + + `pattern in module.exports = { ... } at ` + + `${formatLoc(prop)} (shorthand property). File: ${id}\n` + + `Convert the object literal to plain property assignments ` + + `(exports.foo = ...) so that cjs-module-lexer can detect ` + + `the named exports, or use an ESM build of the package.`, + ) + } + + const keyName = getIdentifierName(prop.key) + if (keyName && keyName !== 'default') { + namedExports.push(keyName) + } + } + } + } + const dirPath = JSON.stringify(path.dirname(id)) const filePath = JSON.stringify(id) - const namedExportLines = namedExports + const hasEsModuleFlag = namedExports.includes('__esModule') + const safeNamedExports = namedExports.filter( + (e) => e !== '__esModule' && e !== 'default', + ) + + const namedExportLines = safeNamedExports .map( (name) => `export const ${name} = __cjs_result__[${JSON.stringify(name)}]`, ) .join('\n') + // If the module sets __esModule (typical of transpiled ESM→CJS), + // unwrap the .default so that `import foo from './file'` returns the + // actual default export rather than the wrapper object. + const defaultExportLine = hasEsModuleFlag + ? `export default (__cjs_result__ != null && typeof __cjs_result__ === 'object' && 'default' in __cjs_result__ ? __cjs_result__.default : __cjs_result__)` + : `export default __cjs_result__` + return { code: ` -import { createRequire as __createRequire__ } from 'node:module' -const require = __createRequire__(${filePath}) -const module = { exports: {} } -const exports = module.exports -const __dirname = ${dirPath} -const __filename = ${filePath} -;(function() { -${code} -}).call(module.exports) -const __cjs_result__ = module.exports -export default __cjs_result__ -${namedExportLines} -`, + import { createRequire as __createRequire__ } from 'node:module' + const require = __createRequire__(${filePath}) + const module = { exports: {} } + const exports = module.exports + const __dirname = ${dirPath} + const __filename = ${filePath} + ;(function() { + ${code} + }).call(module.exports) + const __cjs_result__ = module.exports + ${defaultExportLine} + ${namedExportLines} + `, + // Source maps are not generated. If you hit a break-point issue inside + // a CJS file loaded through this plugin, the line numbers will be off + // by the number of lines in the wrapper preamble (~10). map: null, } }, diff --git a/yarn.lock b/yarn.lock index cd1be9b88d..59e93ed43b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3784,6 +3784,7 @@ __metadata: "@vitejs/plugin-react": "npm:4.7.0" "@whatwg-node/fetch": "npm:0.10.13" "@whatwg-node/server": "npm:0.10.18" + acorn: "npm:8.16.0" acorn-loose: "npm:8.5.2" ansis: "npm:4.2.0" buffer: "npm:6.0.3" @@ -12007,7 +12008,7 @@ __metadata: languageName: node linkType: hard -"acorn@npm:^8.1.0, acorn@npm:^8.11.0, acorn@npm:^8.12.1, acorn@npm:^8.14.1, acorn@npm:^8.15.0, acorn@npm:^8.16.0, acorn@npm:^8.4.1, acorn@npm:^8.8.1, acorn@npm:^8.9.0": +"acorn@npm:8.16.0, acorn@npm:^8.1.0, acorn@npm:^8.11.0, acorn@npm:^8.12.1, acorn@npm:^8.14.1, acorn@npm:^8.15.0, acorn@npm:^8.16.0, acorn@npm:^8.4.1, acorn@npm:^8.8.1, acorn@npm:^8.9.0": version: 8.16.0 resolution: "acorn@npm:8.16.0" bin: From 6f604b42d26087520d6102353d1860f1c01139c6 Mon Sep 17 00:00:00 2001 From: Tobbe Lundberg Date: Tue, 5 May 2026 14:09:54 +0200 Subject: [PATCH 19/19] fix review comment --- .../plugins/vite-plugin-cedar-cjs-compat.ts | 56 ++++++++++++++----- 1 file changed, 43 insertions(+), 13 deletions(-) diff --git a/packages/vite/src/plugins/vite-plugin-cedar-cjs-compat.ts b/packages/vite/src/plugins/vite-plugin-cedar-cjs-compat.ts index 4789ce780e..4e120bc71d 100644 --- a/packages/vite/src/plugins/vite-plugin-cedar-cjs-compat.ts +++ b/packages/vite/src/plugins/vite-plugin-cedar-cjs-compat.ts @@ -115,9 +115,15 @@ function isReExport(node: unknown): boolean { } /** - * Check whether a node represents `Object.defineProperty(exports, ...)`. + * Check whether a node represents `Object.defineProperty(exports, key, { get: … })` + * or a setter descriptor. Plain value descriptors (e.g. `__esModule` markers) + * are allowed because they are harmless and are handled correctly by the + * generated wrapper at runtime. + * + * If the descriptor is not a statically analyzable object literal we return + * `false` — a false negative is safer than breaking on harmless code. */ -function isObjectDefinePropertyOnExports(node: unknown): boolean { +function isObjectDefinePropertyWithGetterOnExports(node: unknown): boolean { if (!isAstNode(node) || node.type !== 'CallExpression') { return false } @@ -127,11 +133,31 @@ function isObjectDefinePropertyOnExports(node: unknown): boolean { } const args = node.arguments - return ( - Array.isArray(args) && - args.length > 0 && - getIdentifierName(args[0]) === 'exports' - ) + if ( + !Array.isArray(args) || + args.length < 3 || + getIdentifierName(args[0]) !== 'exports' + ) { + return false + } + + const descriptor = args[2] + if (!isAstNode(descriptor) || descriptor.type !== 'ObjectExpression') { + return false + } + + const props = descriptor.properties + if (!Array.isArray(props)) { + return false + } + + return props.some((prop) => { + if (!isAstNode(prop) || prop.type !== 'Property') { + return false + } + const name = getIdentifierName(prop.key) + return name === 'get' || name === 'set' + }) } /** @@ -213,8 +239,9 @@ function formatLoc(node: AstNode): string { * * Known limitations (documented inline where relevant): * - No source-map support (`map: null`). - * - Object.defineProperty(exports, key, { get: () => ... }) getters are - * evaluated eagerly at module-load time rather than lazily. + * - Object.defineProperty(exports, key, { get: () => ... }) with getter or + * setter descriptors are evaluated eagerly at module-load time rather than + * lazily; plain value descriptors (e.g. __esModule) are allowed. * - Properties added to a function/class after `module.exports = fn` are * not re-exported. * - Circular dependencies rely on Node's native behaviour via @@ -299,12 +326,15 @@ export function cedarCjsCompatPlugin(): Plugin { ) } - // 2) Object.defineProperty(exports, ...) - if (isObjectDefinePropertyOnExports(expr)) { + // 2) Object.defineProperty(exports, key, { get: () => ... }) — + // getter/setter descriptors are evaluated eagerly. Plain value + // descriptors (e.g. __esModule markers) are allowed. + if (isObjectDefinePropertyWithGetterOnExports(expr)) { throw new Error( `CedarJS CJS compat plugin does not support Object.defineProperty ` + - `on exports because getters would be evaluated eagerly at load ` + - `time rather than lazily. File: ${id}\n` + + `with getter/setter descriptors on exports because they would ` + + `be evaluated eagerly at load time rather than lazily. ` + + `File: ${id}\n` + `Convert the file to plain property assignments ` + `(exports.foo = ...) or use an ESM build of the package.`, )