Skip to content
Open
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
133 changes: 131 additions & 2 deletions packages/vinext/src/server/dev-module-runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,21 @@
* ```
*/

import { ModuleRunner, ESModulesEvaluator, createNodeImportMeta } from "vite/module-runner";
import path from "node:path";
Copy link
Contributor

Choose a reason for hiding this comment

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

Minor: This regex tests the raw code string, so it can false-positive on CJS markers in string literals or comments (e.g., a JSDoc mentioning require()). Combined with the node_modules path gate, the practical risk is very low — the worst case is adding unused CJS params to an already-ESM function, which is harmless. Not blocking, just noting.

import { createRequire } from "node:module";
import { pathToFileURL } from "node:url";
import {
ModuleRunner,
ESModulesEvaluator,
createNodeImportMeta,
ssrDynamicImportKey,
ssrExportAllKey,
Copy link
Contributor

Choose a reason for hiding this comment

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

Bug: path.sep is \\ on Windows, but Vite normalizes all module paths to forward slashes via normalizePath() (which calls path.posix.normalize(slash(id))). So mod.file will always contain forward slashes like C:/Users/dev/project/node_modules/foo/index.js.

This means on Windows the check becomes modulePath.includes("\\node_modules\\") which will never match — the entire CJS compat layer silently becomes a no-op.

Suggested change
ssrExportAllKey,
(modulePath.includes("/node_modules/") || modulePath.endsWith(".cjs"))

ssrExportNameKey,
ssrImportKey,
ssrImportMetaKey,
ssrModuleExportsKey,
} from "vite/module-runner";
import type { EvaluatedModuleNode, ModuleEvaluator, ModuleRunnerContext } from "vite/module-runner";
import type { DevEnvironment } from "vite";

/**
Expand All @@ -73,6 +87,121 @@ export interface DevEnvironmentLike {
) => Promise<Record<string, unknown>>;
}

const COMMONJS_MARKERS =
/\bmodule\.exports\b|\bexports(?:\s*\[|\.[A-Za-z_$])|\brequire\s*\(|\b__dirname\b|\b__filename\b/;
const AsyncFunction = async function () {}.constructor as new (
...args: string[]
Copy link
Contributor

Choose a reason for hiding this comment

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

Nit: Worth a brief inline comment that createRequire loads files from disk directly, bypassing Vite's module graph. This is fine for node_modules (no HMR needed, no user plugins relevant), but the trade-off is worth documenting since require("./core.js") inside CJS packages will resolve sub-modules natively rather than through Vite's transform pipeline.

Something like:

// createRequire resolves sub-modules natively (bypasses Vite's module graph).
// This is intentional for node_modules — third-party deps don't need HMR
// or Vite plugin transforms.
const require = createRequire(pathToFileURL(modulePath));

) => (...args: unknown[]) => Promise<unknown>;

function shouldUseCommonJsCompat(modulePath: string, code: string): boolean {
return (
COMMONJS_MARKERS.test(code) &&
(modulePath.includes(`${path.sep}node_modules${path.sep}`) || modulePath.endsWith(".cjs"))
);
}

function replaceNamespaceExports(
target: Record<PropertyKey, unknown>,
source: Record<PropertyKey, unknown>,
): void {
for (const key of Reflect.ownKeys(target)) {
const descriptor = Object.getOwnPropertyDescriptor(target, key);
if (descriptor?.configurable) {
delete target[key];
}
}
for (const key of Reflect.ownKeys(source)) {
const existing = Object.getOwnPropertyDescriptor(target, key);
if (existing && !existing.configurable) continue;
const descriptor = Object.getOwnPropertyDescriptor(source, key);
if (descriptor) {
Object.defineProperty(target, key, descriptor);
}
}
}

function toCommonJsNamespace(value: unknown): Record<PropertyKey, unknown> {
const namespace = Object.create(null) as Record<PropertyKey, unknown>;
Object.defineProperty(namespace, Symbol.toStringTag, {
value: "Module",
enumerable: false,
configurable: false,
});
Object.defineProperty(namespace, "default", {
enumerable: true,
configurable: true,
value,
});

if (value && (typeof value === "object" || typeof value === "function")) {
for (const key of Object.keys(value)) {
if (key === "default" || key === "__esModule") continue;
Object.defineProperty(namespace, key, {
enumerable: true,
configurable: true,
get: () => value[key as keyof typeof value],
});
}
}

return namespace;
}

class CommonJsCompatEvaluator implements ModuleEvaluator {
private readonly fallback = new ESModulesEvaluator();
readonly startOffset = this.fallback.startOffset;

async runInlinedModule(
context: ModuleRunnerContext,
code: string,
mod: Readonly<EvaluatedModuleNode>,
): Promise<void> {
const modulePath = mod.file;
if (!modulePath || !path.isAbsolute(modulePath) || !shouldUseCommonJsCompat(modulePath, code)) {
await this.fallback.runInlinedModule(context, code);
return;
}

const exportsObject = context[ssrModuleExportsKey] as Record<PropertyKey, unknown>;
const module = { exports: exportsObject as unknown };
const require = createRequire(pathToFileURL(modulePath));

await new AsyncFunction(
ssrModuleExportsKey,
ssrImportMetaKey,
ssrImportKey,
ssrDynamicImportKey,
ssrExportAllKey,
ssrExportNameKey,
"module",
"exports",
"require",
"__filename",
"__dirname",
`"use strict";${code}`,
)(
context[ssrModuleExportsKey],
context[ssrImportMetaKey],
context[ssrImportKey],
context[ssrDynamicImportKey],
context[ssrExportAllKey],
context[ssrExportNameKey],
module,
module.exports,
require,
modulePath,
path.dirname(modulePath),
);

replaceNamespaceExports(exportsObject, toCommonJsNamespace(module.exports));
Object.seal(exportsObject);
}

runExternalModule(filepath: string): Promise<unknown> {
return this.fallback.runExternalModule(filepath);
}
}

/**
* Build a ModuleRunner that calls `environment.fetchModule()` directly,
* bypassing the hot channel entirely.
Expand Down Expand Up @@ -126,6 +255,6 @@ export function createDirectRunner(environment: DevEnvironmentLike | DevEnvironm
sourcemapInterceptor: false,
hmr: false,
},
new ESModulesEvaluator(),
new CommonJsCompatEvaluator(),
);
}
85 changes: 84 additions & 1 deletion tests/cjs.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,18 @@
import { describe, it, expect, beforeAll, afterAll } from "vite-plus/test";
import type { ViteDevServer } from "vite-plus";
import { createServer, type ViteDevServer } from "vite-plus";
import { APP_FIXTURE_DIR, PAGES_FIXTURE_DIR, startFixtureServer, fetchHtml } from "./helpers.js";
import vinext from "../packages/vinext/src/index.js";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";

async function linkNodeModule(rootNodeModules: string, nmDir: string, pkg: string): Promise<void> {
const dest = path.join(nmDir, pkg);
await fs.mkdir(path.dirname(dest), { recursive: true });
await fs.symlink(path.join(rootNodeModules, pkg), dest, "junction").catch((err) => {
if (err.code !== "EEXIST") throw err;
});
}

describe("CJS interop (App Router)", () => {
let server: ViteDevServer;
Expand Down Expand Up @@ -52,3 +64,74 @@ describe("CJS interop (Pages Router)", () => {
expect(html).toMatch(/Random:.*4/);
});
});

describe("CJS interop (Pages Router node_modules)", () => {
let server: ViteDevServer;
let baseUrl: string;
let tmpDir: string;

beforeAll(async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "vinext-cjs-node-modules-"));

const nmDir = path.join(tmpDir, "node_modules");
await fs.mkdir(nmDir, { recursive: true });

const rootNodeModules = path.resolve(import.meta.dirname, "../node_modules");
for (const pkg of ["react", "react-dom", "vite", "vite-plus", "vinext"]) {
await linkNodeModule(rootNodeModules, nmDir, pkg);
}

const cjsDir = path.join(nmDir, "cjs-node-package");
await fs.mkdir(cjsDir, { recursive: true });
await fs.writeFile(
path.join(cjsDir, "package.json"),
JSON.stringify({ name: "cjs-node-package", version: "1.0.0", main: "index.js" }),
);
await fs.writeFile(
path.join(cjsDir, "index.js"),
`const core = require("./core.js");
module.exports = { value: core.value };`,
);
await fs.writeFile(
path.join(cjsDir, "core.js"),
`module.exports = { value: "from-cjs-package" };`,
);

await fs.mkdir(path.join(tmpDir, "pages"), { recursive: true });
await fs.writeFile(
path.join(tmpDir, "pages", "index.tsx"),
`import dep from "cjs-node-package";

export default function Page() {
return <div id="cjs-node-modules">{dep.value}</div>;
}`,
);

server = await createServer({
root: tmpDir,
configFile: false,
plugins: [vinext({ appDir: tmpDir })],
optimizeDeps: { holdUntilCrawlEnd: true },
server: { port: 0, cors: false },
logLevel: "silent",
});

await server.listen();
const addr = server.httpServer?.address();
if (addr && typeof addr === "object") {
baseUrl = `http://localhost:${addr.port}`;
}
}, 60_000);

afterAll(async () => {
await server?.close();
await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
});

it("renders a page that imports a CommonJS package from node_modules", async () => {
const { res, html } = await fetchHtml(baseUrl, "/");
expect(res.status).toBe(200);
expect(html).toContain("cjs-node-modules");
expect(html).toContain("from-cjs-package");
});
});
Loading