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
27 changes: 27 additions & 0 deletions packages/commonjs/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# vite-plugin-commonjs

## Usage

```tsx
import { defineConfig } from 'vite'
import { cjsModuleRunnerPlugin } from '@hiogawa/vite-plugin-commonjs'

export default defineConfig({
plugins: [
cjsModuleRunnerPlugin(),
],
})
```

## How it works

TODO

## Limitations

- Transforming `require` into `import` changes the original resolution when `require`-ed package provides both ESM and CJS exports (i.e. dual package).
- `require` is hoisted at top and lazy loaded `require` such as `try { require(...) } catch {}` pattern won't work as intended.

## Related issues

- https://github.com/vitejs/vite/issues/14158
36 changes: 36 additions & 0 deletions packages/commonjs/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
{
"name": "@hiogawa/vite-plugin-commonjs",
"version": "0.0.0",
"homepage": "https://github.com/hi-ogawa/vite-plugins/tree/main/packages/commonjs",
"repository": {
"type": "git",
"url": "git+https://github.com/hi-ogawa/vite-plugins.git",
"directory": "packages/commonjs"
},
"license": "MIT",
"type": "module",
"exports": {
".": "./dist/index.js"
},
"files": ["dist"],
"scripts": {
"dev": "tsdown --watch",
"build": "tsdown",
"prepack": "tsdown --clean",
"test": "vitest"
},
"dependencies": {
"es-module-lexer": "^1.7.0",
"estree-walker": "^3.0.3",
"magic-string": "^0.30.19",
"periscopic": "^4.0.2",
"vitefu": "^1.0.5"
},
"devDependencies": {
"@hiogawa/utils": "^1.7.0",
"@vitejs/test-dep-cjs-and-esm": "./test-dep/cjs-and-esm"
},
"peerDependencies": {
"vite": "*"
}
}
1 change: 1 addition & 0 deletions packages/commonjs/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { cjsModuleRunnerPlugin } from "./plugin";
84 changes: 84 additions & 0 deletions packages/commonjs/src/plugin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import fs from "node:fs";
import path from "node:path";
import { createDebug } from "@hiogawa/utils";
import * as esModuleLexer from "es-module-lexer";
import { type Plugin, parseAstAsync } from "vite";
import { findClosestPkgJsonPath } from "vitefu";
import { parseIdQuery } from "./shared";
import { transformCjsToEsm } from "./transforms/cjs";

const debug = createDebug("cjs");

export function cjsModuleRunnerPlugin(): Plugin[] {
const warnedPackages = new Set<string>();

return [
{
name: "cjs-module-runner-transform",
apply: "serve",
applyToEnvironment: (env) => env.config.dev.moduleRunnerTransform,
async transform(code, id) {
if (
id.includes("/node_modules/") &&
!id.startsWith(this.environment.config.cacheDir) &&
/\b(require|exports)\b/.test(code)
) {
id = parseIdQuery(id).filename;
if (!/\.[cm]?js$/.test(id)) return;

// skip genuine esm
if (id.endsWith(".mjs")) return;
if (id.endsWith(".js")) {
const pkgJsonPath = await findClosestPkgJsonPath(path.dirname(id));
if (pkgJsonPath) {
const pkgJson = JSON.parse(
fs.readFileSync(pkgJsonPath, "utf-8"),
) as { type?: string };
if (pkgJson.type === "module") return;
}
}

// skip faux esm (e.g. from "module" field)
const [, , , hasModuleSyntax] = esModuleLexer.parse(code);
if (hasModuleSyntax) return;

// warning once per package
const packageKey = extractPackageKey(id);
if (!warnedPackages.has(packageKey)) {
debug(
`non-optimized CJS dependency in '${this.environment.name}' environment: ${id}`,
);
warnedPackages.add(packageKey);
}

const ast = await parseAstAsync(code);
const result = transformCjsToEsm(code, ast, { id });
const output = result.output;
return {
code: output.toString(),
map: output.generateMap({ hires: "boundary" }),
};
}
},
},
];
}

function extractPackageKey(id: string): string {
// .../.yarn/cache/abc/... => abc
const yarnMatch = id.match(/\/.yarn\/cache\/([^/]+)/);
if (yarnMatch) {
return yarnMatch[1]!;
}
// .../node_modules/@x/y/... => @x/y
// .../node_modules/x/... => x
if (id.includes("/node_modules")) {
id = id.split("/node_modules/").at(-1)!;
let [x, y] = id.split("/");
if (x!.startsWith("@")) {
return `${x}/${y}`;
}
return x!;
}
return id;
}
29 changes: 29 additions & 0 deletions packages/commonjs/src/shared.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
type CssVirtual = {
id: string;
type: "ssr" | "rsc";
};

export function toCssVirtual({ id, type }: CssVirtual) {
// ensure other plugins treat it as a plain js file
// e.g. https://github.com/vitejs/rolldown-vite/issues/372#issuecomment-3193401601
return `virtual:vite-rsc/css?type=${type}&id=${encodeURIComponent(id)}&lang.js`;
}

export function parseCssVirtual(id: string): CssVirtual | undefined {
if (id.startsWith("\0virtual:vite-rsc/css?")) {
return parseIdQuery(id).query as any;
}
}

// https://github.com/vitejs/vite-plugin-vue/blob/06931b1ea2b9299267374cb8eb4db27c0626774a/packages/plugin-vue/src/utils/query.ts#L13
export function parseIdQuery(id: string): {
filename: string;
query: {
[k: string]: string;
};
} {
if (!id.includes("?")) return { filename: id, query: {} };
const [filename, rawQuery] = id.split(`?`, 2) as [string, string];
const query = Object.fromEntries(new URLSearchParams(rawQuery));
return { filename, query };
}
198 changes: 198 additions & 0 deletions packages/commonjs/src/transforms/cjs.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
import path from "node:path";
import { createServer, createServerModuleRunner, parseAstAsync } from "vite";
import { describe, expect, it } from "vitest";
import { transformCjsToEsm } from "./cjs";

describe(transformCjsToEsm, () => {
async function testTransform(input: string) {
const ast = await parseAstAsync(input);
const { output } = transformCjsToEsm(input, ast, { id: "/test.js" });
if (!output.hasChanged()) {
return;
}
return output.toString();
}

it("basic", async () => {
const input = `\
exports.ok = true;
`;
expect(await testTransform(input)).toMatchInlineSnapshot(`
"let __filename = "/test.js"; let __dirname = "/";
let exports = {}; const module = { exports };
exports.ok = true;

;__vite_ssr_exportAll__(module.exports);
export default module.exports;
export const __cjs_module_runner_transform = true;
"
`);
});

it("top-level re-export", async () => {
const input = `\
if (true) {
module.exports = require('./cjs/use-sync-external-store.production.js');
} else {
module.exports = require('./cjs/use-sync-external-store.development.js');
}
`;
expect(await testTransform(input)).toMatchInlineSnapshot(`
"let __filename = "/test.js"; let __dirname = "/";
let exports = {}; const module = { exports };
function __cjs_interop__(m) { return m.__cjs_module_runner_transform ? m.default : m; }
if (true) {
module.exports = (__cjs_interop__(await import('./cjs/use-sync-external-store.production.js')));
} else {
module.exports = (__cjs_interop__(await import('./cjs/use-sync-external-store.development.js')));
}

;__vite_ssr_exportAll__(module.exports);
export default module.exports;
export const __cjs_module_runner_transform = true;
"
`);
});

it("non top-level re-export", async () => {
const input = `\
"production" !== process.env.NODE_ENV && (function() {
var React = require("react");
var ReactDOM = require("react-dom");
exports.useSyncExternalStoreWithSelector = function () {}
})()
`;
expect(await testTransform(input)).toMatchInlineSnapshot(`
"let __filename = "/test.js"; let __dirname = "/";
let exports = {}; const module = { exports };
function __cjs_interop__(m) { return m.__cjs_module_runner_transform ? m.default : m; }
const __cjs_to_esm_hoist_0 = __cjs_interop__(await import("react"));
const __cjs_to_esm_hoist_1 = __cjs_interop__(await import("react-dom"));
"production" !== process.env.NODE_ENV && (function() {
var React = __cjs_to_esm_hoist_0;
var ReactDOM = __cjs_to_esm_hoist_1;
exports.useSyncExternalStoreWithSelector = function () {}
})()

;__vite_ssr_exportAll__(module.exports);
export default module.exports;
export const __cjs_module_runner_transform = true;
"
`);
});

it("edge cases", async () => {
const input = `\
const x1 = require("te" + "st");
const x2 = require("test")().test;
console.log(require("test"))

function test() {
const y1 = require("te" + "st");
const y2 = require("test")().test;
consoe.log(require("test"))
}
`;
expect(await testTransform(input)).toMatchInlineSnapshot(`
"let __filename = "/test.js"; let __dirname = "/";
let exports = {}; const module = { exports };
function __cjs_interop__(m) { return m.__cjs_module_runner_transform ? m.default : m; }
const __cjs_to_esm_hoist_0 = __cjs_interop__(await import("te" + "st"));
const __cjs_to_esm_hoist_1 = __cjs_interop__(await import("test"));
const __cjs_to_esm_hoist_2 = __cjs_interop__(await import("test"));
const x1 = (__cjs_interop__(await import("te" + "st")));
const x2 = (__cjs_interop__(await import("test")))().test;
console.log((__cjs_interop__(await import("test"))))

function test() {
const y1 = __cjs_to_esm_hoist_0;
const y2 = __cjs_to_esm_hoist_1().test;
consoe.log(__cjs_to_esm_hoist_2)
}

;__vite_ssr_exportAll__(module.exports);
export default module.exports;
export const __cjs_module_runner_transform = true;
"
`);
});

it("local require", async () => {
const input = `\
{
const require = () => {};
require("test");
}
`;
expect(await testTransform(input)).toMatchInlineSnapshot(`
"let __filename = "/test.js"; let __dirname = "/";
let exports = {}; const module = { exports };
{
const require = () => {};
require("test");
}

;__vite_ssr_exportAll__(module.exports);
export default module.exports;
export const __cjs_module_runner_transform = true;
"
`);
});

it("e2e", async () => {
const server = await createServer({
configFile: false,
logLevel: "error",
root: path.join(import.meta.dirname, "fixtures/cjs"),
plugins: [
{
name: "cjs-module-runner-transform",
async transform(code, id) {
if (id.endsWith(".cjs")) {
const ast = await parseAstAsync(code);
const { output } = transformCjsToEsm(code, ast, { id });
return {
code: output.toString(),
map: output.generateMap({ hires: "boundary" }),
};
}
},
},
],
});
const runner = createServerModuleRunner(server.environments.ssr, {
hmr: false,
});
const mod = await runner.import("/entry.mjs");
expect(mod).toMatchInlineSnapshot(`
{
"cjsGlobals": {
"test": [
"string",
"string",
],
},
"depDefault": {
"a": "a",
"b": "b",
},
"depExports": {},
"depFn": [Function],
"depFnRequire": {
"value": 3,
},
"depNamespace": {
"__cjs_module_runner_transform": true,
"a": "a",
"b": "b",
"default": {
"a": "a",
"b": "b",
},
},
"depPrimitive": "[ok]",
"dualLib": "ok",
}
`);
});
});
Loading
Loading