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
20 changes: 12 additions & 8 deletions packages/vinext/src/plugins/ast-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -159,18 +159,22 @@ export function staticStringValue(value: unknown): string | null {
}

export function forEachAstChild(node: AstRecord, callback: (child: AstRecord) => void): void {
for (const [key, value] of Object.entries(node)) {
for (const key of Object.keys(node)) {
if (SKIP_CHILD_KEYS.has(key)) continue;
const child = toAstRecord(value);
if (child) {
callback(child);
continue;
}
const value = node[key];
if (typeof value !== "object" || value === null) continue;
if (Array.isArray(value)) {
for (const item of value) {
const itemNode = toAstRecord(item);
if (itemNode) callback(itemNode);
if (
typeof item === "object" &&
item !== null &&
typeof (item as AstRecord).type === "string"
) {
callback(item as AstRecord);
}
}
} else if (typeof (value as AstRecord).type === "string") {
callback(value as AstRecord);
}
}
}
Expand Down
231 changes: 231 additions & 0 deletions packages/vinext/src/plugins/commonjs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,231 @@
import MagicString from "magic-string";
import { parseAst } from "vite";
import {
collectBindingNames,
forEachAstChild,
getAstName,
hasRange,
isAstRecord,
isIdentifierNamed,
nodeArray,
scriptParserLanguage,
staticStringValue,
unwrapExpression,
type AstRecord,
} from "./ast-utils.js";
import {
collectDirectScopeBindings,
collectLoopScopeBindings,
collectSwitchScopeBindings,
collectVarScopeBindings,
createAstScope,
hasAstBinding,
isFunctionNode,
type AstScope,
} from "./ast-scope.js";
import { magicStringTransformResult, type MagicStringTransformResult } from "./transform-result.js";

const COMMONJS_PRESCAN = /\b(?:require\s*\(|module\s*\.|exports\s*[.[])/;
const IDENTIFIER_NAME_RE = /^[$_\p{ID_Start}][$\u200C\u200D\p{ID_Continue}]*$/u;

type StaticRequire = {
node: AstRecord & { start: number; end: number };
specifier: string;
};

type CommonJsAnalysis = {
requires: StaticRequire[];
hasExports: boolean;
namedExports: string[];
rootBindings: Set<string>;
};

function memberPropertyName(node: AstRecord): string | null {
const property = unwrapExpression(node.property);
if (!property) return null;
if (node.computed === true) return staticStringValue(property);
return getAstName(property);
}

function isUnboundModuleExports(node: AstRecord | null, scope: AstScope): boolean {
if (node?.type !== "MemberExpression" || hasAstBinding(scope, "module")) return false;
return (
isIdentifierNamed(unwrapExpression(node.object), "module") &&
memberPropertyName(node) === "exports"
);
}

function commonJsExportName(node: AstRecord, scope: AstScope): string | null | undefined {
if (node.type !== "MemberExpression") return undefined;
const object = unwrapExpression(node.object);
if (isIdentifierNamed(object, "exports") && !hasAstBinding(scope, "exports")) {
return memberPropertyName(node);
}
if (isUnboundModuleExports(node, scope)) return null;
if (isUnboundModuleExports(object, scope)) return memberPropertyName(node);
return undefined;
}

function analyzeCommonJs(code: string, id: string): CommonJsAnalysis | null {
if (!COMMONJS_PRESCAN.test(code)) return null;
let ast: ReturnType<typeof parseAst>;
try {
ast = parseAst(code, { lang: scriptParserLanguage(id) ?? "jsx" });
} catch {
return null;
}
const root = isAstRecord(ast) ? ast : null;
if (!root) return null;

const rootScope = createAstScope(null);
collectDirectScopeBindings(root, rootScope);
collectVarScopeBindings(root, rootScope);
const requires: StaticRequire[] = [];
const namedExports = new Set<string>();
let hasExports = false;

function visit(node: AstRecord, parentScope: AstScope): void {
let scope = parentScope;
if (isFunctionNode(node)) {
const parameterScope = createAstScope(parentScope);
collectBindingNames(node.id, parameterScope.bindings);
for (const parameter of nodeArray(node.params)) {
collectBindingNames(parameter, parameterScope.bindings);
if (isAstRecord(parameter)) visit(parameter, parameterScope);
}
const body = isAstRecord(node.body) ? node.body : null;
if (body) {
const bodyScope = createAstScope(parameterScope);
collectDirectScopeBindings(body, bodyScope);
collectVarScopeBindings(body, bodyScope);
if (body.type === "BlockStatement") {
for (const statement of nodeArray(body.body)) {
if (isAstRecord(statement)) visit(statement, bodyScope);
}
} else {
visit(body, bodyScope);
}
}
return;
}
if (node.type === "SwitchStatement") {
if (isAstRecord(node.discriminant)) visit(node.discriminant, parentScope);
const switchScope = createAstScope(parentScope);
collectSwitchScopeBindings(node, switchScope);
for (const switchCase of nodeArray(node.cases)) {
if (isAstRecord(switchCase)) visit(switchCase, switchScope);
}
return;
}
if (
(node.type === "BlockStatement" && node !== root) ||
node.type === "StaticBlock" ||
node.type === "TSModuleBlock"
) {
scope = createAstScope(parentScope);
collectDirectScopeBindings(node, scope);
if (node.type === "StaticBlock" || node.type === "TSModuleBlock") {
collectVarScopeBindings(node, scope);
}
} else if (node.type === "CatchClause") {
scope = createAstScope(parentScope);
collectBindingNames(node.param, scope.bindings);
} else if (
node.type === "ForStatement" ||
node.type === "ForInStatement" ||
node.type === "ForOfStatement"
) {
scope = createAstScope(parentScope);
collectLoopScopeBindings(node, scope);
} else if (node.type === "ClassExpression" && node.id) {
scope = createAstScope(parentScope);
collectBindingNames(node.id, scope.bindings);
}

if (node.type === "CallExpression" && hasRange(node)) {
const callee = unwrapExpression(node.callee);
const args = nodeArray(node.arguments);
const argument = unwrapExpression(args[0]);
const specifier = staticStringValue(argument);
if (
isIdentifierNamed(callee, "require") &&
!hasAstBinding(scope, "require") &&
argument &&
specifier !== null
) {
requires.push({ node, specifier });
return;
}
} else if (node.type === "AssignmentExpression") {
const left = unwrapExpression(node.left);
const exportName = left ? commonJsExportName(left, scope) : undefined;
if (exportName !== undefined) {
hasExports = true;
if (exportName && exportName !== "default" && IDENTIFIER_NAME_RE.test(exportName)) {
namedExports.add(exportName);
}
}
}

forEachAstChild(node, (child) => visit(child, scope));
}

for (const statement of nodeArray(root.body)) {
if (isAstRecord(statement)) visit(statement, rootScope);
}
return {
requires,
hasExports,
namedExports: [...namedExports],
rootBindings: rootScope.bindings,
};
}

function unusedBinding(bindings: Set<string>, base: string): string {
let name = base;
let suffix = 0;
while (bindings.has(name)) name = `${base}_${++suffix}`;
bindings.add(name);
return name;
}

/** Convert the project-local mixed CommonJS syntax that Vite's ESM module runner cannot execute. */
export function transformCommonJs(code: string, id: string): MagicStringTransformResult | null {
const analysis = analyzeCommonJs(code, id);
if (!analysis || (analysis.requires.length === 0 && !analysis.hasExports)) return null;

const output = new MagicString(code);
const bindings = new Set(analysis.rootBindings);
const imports: string[] = [];
for (const { node, specifier } of analysis.requires) {
const importName = unusedBinding(bindings, "__vinext_cjs_import__");
imports.push(`import * as ${importName} from ${JSON.stringify(specifier)};`);
output.overwrite(node.start, node.end, `(${importName}.default || ${importName})`);
}

const preamble: string[] = [];
if (analysis.hasExports) {
preamble.push("var module = { exports: {} };", "var exports = module.exports;");
}
if (imports.length > 0 || preamble.length > 0) {
output.prepend(`${[...imports, ...preamble].join("\n")}\n`);
}

if (analysis.hasExports) {
const defaultBinding = unusedBinding(bindings, "__vinext_cjs_default__");
const declarations = [
`const ${defaultBinding} = (module.exports == null ? {} : module.exports).default || module.exports;`,
];
const exports = [`${defaultBinding} as default`];
for (const name of analysis.namedExports) {
const binding = unusedBinding(bindings, `__vinext_cjs_export_${name}__`);
declarations.push(
`const ${binding} = (module.exports == null ? {} : module.exports).${name};`,
);
exports.push(`${binding} as ${name}`);
}
output.append(`\n${declarations.join("\n")}\nexport { ${exports.join(", ")} };\n`);
}

return magicStringTransformResult(output, { hires: true, source: id });
}
8 changes: 8 additions & 0 deletions tests/cjs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,14 @@ describe("CJS interop (Pages Router)", () => {
// expressions (e.g. "Random: <!-- -->4"), so use a regex.
expect(html).toMatch(/Random:.*4/);
});

it("renders a page with a patterned dynamic require", async () => {
// Ported from Next.js: test/integration/dynamic-require/test/index.test.ts
// https://github.com/vercel/next.js/blob/canary/test/integration/dynamic-require/test/index.test.ts
const { res, html } = await fetchHtml(baseUrl, "/cjs/dynamic-require");
expect(res.status).toBe(200);
expect(html).toContain("loaded");
});
});

// Ported from Next.js: test/e2e/app-dir/client-module-with-package-type/index.test.ts
Expand Down
116 changes: 116 additions & 0 deletions tests/commonjs-transform.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import { describe, expect, it } from "vite-plus/test";
import { transformCommonJs } from "../packages/vinext/src/plugins/commonjs.js";

async function evaluateCommonJs(code: string): Promise<Record<string, unknown>> {
const result = transformCommonJs(code, "/app/value.js");
if (!result) throw new Error("Expected transformed code");
const url = `data:text/javascript;base64,${Buffer.from(result.code).toString("base64")}`;
return import(url) as Promise<Record<string, unknown>>;
}

describe("transformCommonJs", () => {
it("hoists literal require calls with the existing default-first interop", () => {
const result = transformCommonJs(
`const { join } = require("node:path");\nexport const value = join("a", "b");`,
"/app/page.tsx",
);
expect(result?.code).toContain('import * as __vinext_cjs_import__ from "node:path";');
expect(result?.code).toContain(
`const { join } = (__vinext_cjs_import__.default || __vinext_cjs_import__);`,
);
});

it("exposes module.exports as the default export", () => {
const result = transformCommonJs(`module.exports = () => "cjs";`, "/app/value.js");
expect(result?.code).toContain("var module = { exports: {} };");
expect(result?.code).toContain("__vinext_cjs_default__ as default");
});

it("exposes statically named exports", () => {
const result = transformCommonJs(
`exports.Component = () => "component";\nmodule.exports.value = 42;`,
"/app/value.js",
);
expect(result?.code).toContain("__vinext_cjs_export_Component__ as Component");
expect(result?.code).toContain("__vinext_cjs_export_value__ as value");
});

// Ported from vite-plugin-commonjs v0.10.4's unrestricted named-export generation:
// https://github.com/vite-plugin/vite-plugin-commonjs/blob/v0.10.4/src/generate-export.ts
it("exposes Unicode identifier names as named exports", async () => {
const module = await evaluateCommonJs(`exports.π = 3; exports.你好 = 4;`);
expect(module.π).toBe(3);
expect(module.你好).toBe(4);
});

it("supports computed static export names but not invalid ESM names", () => {
const result = transformCommonJs(
`exports["valid"] = 1; exports["not-valid"] = 2;`,
"/app/value.js",
);
expect(result?.code).toContain("__vinext_cjs_export_valid__ as valid");
expect(result?.code).not.toContain("not-valid as");
});

it("does not rewrite shadowed CommonJS bindings", () => {
const source = `
const require = (value) => value;
const module = { exports: {} };
const exports = {};
require("local");
module.exports = "local";
exports.value = "local";
`;
expect(transformCommonJs(source, "/app/value.js")).toBeNull();
});

it("honors function and block scope shadowing", () => {
const source = `
function local(require) { return require("local"); }
{
const exports = {};
exports.value = 1;
}
const external = require("external");
`;
const result = transformCommonJs(source, "/app/value.ts");
expect(result?.code).toContain('require("local")');
expect(result?.code).toContain("exports.value = 1");
expect(result?.code).toContain('from "external"');
});

it("uses collision-safe helper bindings", () => {
const result = transformCommonJs(
`const __vinext_cjs_import__ = 1; const value = require("value");`,
"/app/value.js",
);
expect(result?.code).toContain('import * as __vinext_cjs_import___1 from "value";');
expect(result?.code).toContain("(__vinext_cjs_import___1.default || __vinext_cjs_import___1)");
});

// Ported from vite-plugin-commonjs v0.10.4, which reads the first require argument
// without rejecting additional arguments:
// https://github.com/vite-plugin/vite-plugin-commonjs/blob/v0.10.4/src/generate-import.ts
it("ignores additional require arguments", () => {
for (const source of [`require("value", "ignored");`, `require(\`value\`, "ignored");`]) {
const result = transformCommonJs(source, "/app/value.js");
expect(result?.code).toContain('from "value"');
expect(result?.code).not.toContain("ignored");
}
});

it("parses TypeScript and JSX source", () => {
const result = transformCommonJs(
`const value = require("value") as { default: string }; export default <p>{value.default}</p>;`,
"/app/page.tsx",
);
expect(result?.code).toContain('from "value"');
});

it("leaves dynamic and non-CommonJS modules unchanged", () => {
expect(
transformCommonJs(`require(\`./messages/${"${locale}"}.js\`);`, "/app/page.js"),
).toBeNull();
expect(transformCommonJs(`export default 42;`, "/app/page.js")).toBeNull();
});
});
Loading
Loading