Skip to content
Closed
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
215 changes: 212 additions & 3 deletions packages/prerender/src/graphql/node-runner.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,228 @@
import path from 'node:path'

import { createServer, isRunnableDevEnvironment, mergeConfig } from 'vite'
import type { ViteDevServer, RunnableDevEnvironment, UserConfig } from 'vite'
import type {
Plugin,
ViteDevServer,
RunnableDevEnvironment,
UserConfig,
} from 'vite'

import { getPaths } from '@cedarjs/project-config'
import {
cedarCellTransform,
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'

// Initialize cjs-module-lexer eagerly at module load so it's available before
// any Vite transforms run. buildStart is not guaranteed to fire for all Vite
// environments (e.g. nodeRunnerEnv), so we can't rely on it for initialization.
let lexerParse: ((code: string) => { exports: string[] }) | null = null
const lexerReady: Promise<void> = import('cjs-module-lexer')
.then(({ init, parse }) =>
init().then(() => {
lexerParse = parse
}),
)
.catch(() => {
// Fall back to extractCjsNamedExports only if cjs-module-lexer is unavailable
})

/**
* Extracts named exports from CommonJS code without relying on cjs-module-lexer,
* which fails to detect exports when values are function expressions or other
* non-trivial expressions (e.g. `module.exports = { handler: function() {} }`).
*
* Handles two CJS export patterns:
* - `exports.key = value` — regex
* - `module.exports = { key: value, ... }` — brace-counting to find top-level keys
*/
function extractCjsNamedExports(code: string): string[] {
const identifierRe = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/
const namedExports = new Set<string>()

// Pattern 1: exports.key = value
const exportsAssignRe = /\bexports\.([a-zA-Z_$][a-zA-Z0-9_$]*)\s*=/g
for (const match of code.matchAll(exportsAssignRe)) {
if (match[1] !== 'default') {
namedExports.add(match[1])
}
}

// Pattern 2: module.exports = { key: value, ... }
// Brace-counting scan to capture only top-level property keys, correctly
// skipping over nested objects, function bodies, strings, and comments.
const assignMatch = code.match(/module\.exports\s*=\s*\{/)
if (assignMatch?.index !== undefined) {
const bodyStart = assignMatch.index + assignMatch[0].length
let depth = 1
let pos = bodyStart

while (pos < code.length && depth > 0) {
const ch = code[pos]

// Skip strings (single, double, template)
if (ch === '"' || ch === "'" || ch === '`') {
const quote = ch
pos++
while (pos < code.length && code[pos] !== quote) {
if (code[pos] === '\\') {
pos++
}
pos++
}
pos++
continue
}

// Skip line comments
if (ch === '/' && code[pos + 1] === '/') {
while (pos < code.length && code[pos] !== '\n') {
pos++
}
continue
}

// Skip block comments
if (ch === '/' && code[pos + 1] === '*') {
pos += 2
while (
pos < code.length &&
!(code[pos] === '*' && code[pos + 1] === '/')
) {
pos++
}
pos += 2
continue
}

if (ch === '{' || ch === '(' || ch === '[') {
depth++
pos++
continue
}

if (ch === '}' || ch === ')' || ch === ']') {
depth--
pos++
continue
}

// At depth 1 (top level of the object literal), look for `identifier:` patterns
if (depth === 1 && /[a-zA-Z_$]/.test(ch)) {
const keyMatch = code
.slice(pos)
.match(/^([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/)
if (
keyMatch &&
keyMatch[1] !== 'default' &&
identifierRe.test(keyMatch[1])
) {
namedExports.add(keyMatch[1])
pos += keyMatch[0].length
continue
}
}

pos++
}
}

return [...namedExports]
}

/**
* 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 two complementary strategies for named export detection:
* 1. `cjs-module-lexer` (initialized at module load via `lexerReady`) — handles
* esbuild-compiled packages using the `__export` + `0 && (module.exports = {...})`
* annotation pattern.
* 2. `extractCjsNamedExports` — handles hand-written CJS with function values
* that cjs-module-lexer cannot statically detect.
*
* The transform hook is intentionally synchronous: Vite 6's non-default
* environments (e.g. nodeRunnerEnv) do not reliably call async transform hooks
* or buildStart, so the lexer must be initialized at module load time instead.
*/
function cjsCompatPlugin(): Plugin {
return {
name: 'cedar-cjs-compat',
enforce: 'pre',

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
Comment on lines +160 to +169
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 No UMD guard — silently produces broken output

The original plugin detected UMD wrappers (typeof module.exports === 'object', typeof define === 'function', etc.) and threw an explicit, actionable error. The new plugin skips that check entirely: a UMD file passes the heuristic (module.exports is present), gets wrapped in the CJS shim, and produces broken runtime output with no diagnostic message. Adding the same UMD regex checks from the original plugin before the transform would preserve the fail-fast behavior.

}

// Combine both strategies: cjs-module-lexer handles esbuild-compiled
// packages; extractCjsNamedExports handles hand-written modules with
// function values that cjs-module-lexer cannot statically detect.
const namedExports = new Set(extractCjsNamedExports(code))
if (lexerParse) {
try {
const { exports } = lexerParse(code)
for (const e of exports) {
if (/^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(e) && e !== 'default') {
namedExports.add(e)
}
}
} catch {
// Ignore — extractCjsNamedExports result is still used
}
Comment on lines +176 to +186
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 __esModule leaks into named exports

The named-export filter (e !== 'default') does not exclude __esModule. Both cjs-module-lexer and extractCjsNamedExports may detect it, causing the generated wrapper to emit export const __esModule = __cjs_result__["__esModule"]. The original plugin explicitly filtered __esModule out of the named-export list with e !== '__esModule' && e !== 'default'.

}

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}
`,
Comment on lines +192 to +213
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Missing __esModule default-export unwrapping

The original cedarCjsCompatPlugin detects the __esModule: true flag (set by TypeScript's compiler and Babel when a module has a real default export) and unwraps module.exports.default before emitting export default. The new plugin always emits export default __cjs_result__, so a consumer that does import Foo from './ts-compiled-module' on a TypeScript-compiled CJS file with a default export will receive the entire module.exports wrapper object ({ __esModule: true, default: ActualFoo, ... }) instead of ActualFoo. Because exports.default = ActualFoo matches the heuristic regex, the file is transformed, and the regression is silent.

map: null,
}
},
}
}

async function createViteServer(customConfig: UserConfig = {}) {
// Ensure cjs-module-lexer is initialized before any file transforms run.
// We can't rely on buildStart for this because it isn't guaranteed to fire
// for non-default Vite environments (e.g. nodeRunnerEnv).
await lexerReady

const defaultConfig: UserConfig = {
mode: 'production',
optimizeDeps: {
Expand All @@ -36,7 +245,7 @@ async function createViteServer(customConfig: UserConfig = {}) {
],
},
plugins: [
cedarCjsCompatPlugin(),
cjsCompatPlugin(),
cedarImportDirPlugin(),
cedarAutoImportsPlugin(),
cedarjsResolveCedarStyleImportsPlugin(),
Expand Down
Loading