Skip to content

Commit 1246dff

Browse files
committed
feat: convert source to native ES modules with dual ESM/CJS build (#5684)
* fix: replace fileURLToPath with require.resolve for client entry points and update exports in package.json * feat: enhance CommonJS support with dual package structure and finalize build script * test: add CommonJS bundle tests for Server class and build pipeline * docs: update changeset * fix: remove unnecessary exports for lib directory in package.json
1 parent 7dc328b commit 1246dff

9 files changed

Lines changed: 250 additions & 14 deletions

File tree

.changeset/migrate-to-esm.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,4 @@
22
"webpack-dev-server": major
33
---
44

5-
Migrate the package to ES module syntax. The package is now published as ESM-only.
5+
Convert the source to native ES modules. The package keeps `"type": "module"` and now exposes both an ESM and a CommonJS build via the `exports` field: ESM consumers `import` the native `lib/`, while CommonJS consumers `require()` a transpiled `dist/` build — so the package works from both ESM and CommonJS, including environments where `require(ESM)` is not supported.

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ client
99
!/test/client
1010

1111
examples/**/dist
12+
dist
1213

1314
coverage
1415
node_modules

babel.config.js

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,30 @@
11
export default (api) => {
2-
api.cache(true);
2+
// `api.env()` makes the resolved config cache depend on `BABEL_ENV`/`NODE_ENV`
3+
// so the `cjs` build and the default client build don't share a cache entry.
4+
const env = api.env();
5+
6+
// CommonJS build (`build:cjs`, `babel --env-name cjs`). `lib/` is authored as
7+
// native ESM; here we transpile it to CJS for the dual package. `preset-env`
8+
// with `modules: "commonjs"` rewrites `import()` into real `require()` (the
9+
// target environments don't reliably support `require(ESM)`), and
10+
// `babel-plugin-transform-import-meta` rewrites `import.meta.url` (used by
11+
// `createRequire`) for CJS.
12+
if (env === "cjs") {
13+
return {
14+
presets: [
15+
[
16+
"@babel/preset-env",
17+
{
18+
modules: "commonjs",
19+
targets: {
20+
node: "22.15.0",
21+
},
22+
},
23+
],
24+
],
25+
plugins: ["babel-plugin-transform-import-meta"],
26+
};
27+
}
328

429
return {
530
presets: [

eslint.config.mjs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import config from "eslint-config-webpack";
33
import configs from "eslint-config-webpack/configs.js";
44

55
export default defineConfig([
6-
globalIgnores(["client/**/*", "examples/**/*"]),
6+
globalIgnores(["client/**/*", "dist/**/*", "examples/**/*"]),
77
{
88
extends: [config],
99
ignores: ["client-src/**/*", "!client-src/webpack.config.js"],

lib/Server.js

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,16 @@
11
import { createRequire } from "node:module";
22
import os from "node:os";
33
import path from "node:path";
4-
import url, { fileURLToPath } from "node:url";
4+
import url from "node:url";
55
import fs from "graceful-fs";
66
import ipaddr from "ipaddr.js";
77
import { validate } from "schema-utils";
88
import schema from "./options.json" with { type: "json" };
99

10-
const require = createRequire(import.meta.url);
10+
// Named `cjsRequire` (not `require`) so it doesn't shadow the implicit CommonJS
11+
// `require` in the transpiled `dist/cjs` build, which would collide with the
12+
// `require("url")` that `babel-plugin-transform-import-meta` injects here.
13+
const cjsRequire = createRequire(import.meta.url);
1114

1215
/** @type {Promise<typeof import("webpack") | undefined> | undefined} */
1316
let webpackPeer;
@@ -1498,12 +1501,12 @@ class Server {
14981501
case "string":
14991502
// could be 'ws', or a path that should be resolved
15001503
if (clientTransport === "ws") {
1501-
clientImplementation = fileURLToPath(
1502-
import.meta.resolve("../client/clients/WebSocketClient.js"),
1504+
clientImplementation = cjsRequire.resolve(
1505+
"../client/clients/WebSocketClient.js",
15031506
);
15041507
} else {
15051508
try {
1506-
clientImplementation = require.resolve(clientTransport);
1509+
clientImplementation = cjsRequire.resolve(clientTransport);
15071510
} catch {
15081511
clientImplementationFound = false;
15091512
}
@@ -1552,7 +1555,7 @@ class Server {
15521555
.default;
15531556
} else {
15541557
try {
1555-
const mod = require(
1558+
const mod = cjsRequire(
15561559
/** @type {string} */ (
15571560
/** @type {WebSocketServerConfiguration} */
15581561
(this.options.webSocketServer).type
@@ -1590,17 +1593,17 @@ class Server {
15901593
*/
15911594

15921595
getClientEntry() {
1593-
return fileURLToPath(import.meta.resolve("../client/index.js"));
1596+
return cjsRequire.resolve("../client/index.js");
15941597
}
15951598

15961599
/**
15971600
* @returns {string | void} client hot entry
15981601
*/
15991602
getClientHotEntry() {
16001603
if (this.options.hot === "only") {
1601-
return fileURLToPath(import.meta.resolve("webpack/hot/only-dev-server"));
1604+
return cjsRequire.resolve("webpack/hot/only-dev-server");
16021605
} else if (this.options.hot) {
1603-
return fileURLToPath(import.meta.resolve("webpack/hot/dev-server"));
1606+
return cjsRequire.resolve("webpack/hot/dev-server");
16041607
}
16051608
}
16061609

@@ -2455,7 +2458,7 @@ class Server {
24552458
(this.app),
24562459
);
24572460
} else {
2458-
const mod = require(/** @type {string} */ (type));
2461+
const mod = cjsRequire(/** @type {string} */ (type));
24592462

24602463
const serverType = mod.default || mod;
24612464

package-lock.json

Lines changed: 15 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,24 @@
1212
"license": "MIT",
1313
"author": "Tobias Koppers @sokra",
1414
"type": "module",
15-
"main": "lib/Server.js",
15+
"exports": {
16+
".": {
17+
"types": "./types/lib/Server.d.ts",
18+
"import": "./lib/Server.js",
19+
"require": "./dist/Server.js",
20+
"default": "./lib/Server.js"
21+
},
22+
"./client/*": "./client/*",
23+
"./package.json": "./package.json"
24+
},
25+
"main": "./dist/Server.js",
26+
"module": "./lib/Server.js",
1627
"types": "types/lib/Server.d.ts",
1728
"bin": "bin/webpack-dev-server.js",
1829
"files": [
1930
"bin",
2031
"lib",
32+
"dist",
2133
"client",
2234
"types"
2335
],
@@ -34,6 +46,7 @@
3446
"fix": "npm-run-all -l fix:code fix:prettier",
3547
"commitlint": "commitlint --from=main",
3648
"validate:changeset": "node .changeset/changeset-validate.mjs",
49+
"build:cjs": "rimraf -g ./dist/* && babel lib --out-dir dist --env-name cjs --copy-files --no-copy-ignored && node ./scripts/finalize-cjs-build.mjs",
3750
"build:client": "rimraf -g ./client/* && babel client-src/ --out-dir client/ --ignore \"client-src/webpack.config.js\" --ignore \"client-src/modules\" && webpack --config client-src/webpack.config.js",
3851
"build:types": "rimraf -g ./types/* && tsc --declaration --emitDeclarationOnly --outDir types && node ./scripts/extend-webpack-types.js && prettier \"types/**/*.ts\" --write && prettier \"types/**/*.ts\" --write",
3952
"build": "npm-run-all -p \"build:**\"",
@@ -93,6 +106,7 @@
93106
"@types/trusted-types": "^2.0.7",
94107
"acorn": "^8.14.0",
95108
"babel-loader": "^10.0.0",
109+
"babel-plugin-transform-import-meta": "^2.3.3",
96110
"connect": "^3.7.0",
97111
"core-js": "^3.38.1",
98112
"cspell": "^8.15.5",

scripts/finalize-cjs-build.mjs

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { appendFile, writeFile } from "node:fs/promises";
2+
import path from "node:path";
3+
import { fileURLToPath } from "node:url";
4+
5+
// Finalize the CommonJS build produced by `babel lib --out-dir dist`.
6+
//
7+
// The CJS build is emitted flat into `dist/` (same depth from the package root
8+
// as `lib/`) so the relative `cjsRequire.resolve("../client/...")` calls in
9+
// `Server.js` resolve to `<root>/client` from both `lib/` and `dist/`.
10+
//
11+
// 1. Drop a `package.json` with `"type": "commonjs"` so Node treats the `.js`
12+
// files in `dist/` as CommonJS regardless of the package's root
13+
// `"type": "module"`.
14+
// 2. Babel emits the loader as `exports.default`. Append the `module.exports`
15+
// unwrap so `require("webpack-dev-server")` returns the `Server` class
16+
// directly (parity with the pre-ESM `module.exports = Server`), while
17+
// `.default` keeps pointing at it for interop.
18+
19+
const CJS_DIR = path.resolve(
20+
path.dirname(fileURLToPath(import.meta.url)),
21+
"..",
22+
"dist",
23+
);
24+
25+
await writeFile(
26+
path.join(CJS_DIR, "package.json"),
27+
`${JSON.stringify({ type: "commonjs" }, null, 2)}\n`,
28+
);
29+
30+
await appendFile(
31+
path.join(CJS_DIR, "Server.js"),
32+
"module.exports = exports.default;\nmodule.exports.default = exports.default;\n",
33+
);

test/cjs.test.js

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
import {
2+
appendFileSync,
3+
copyFileSync,
4+
mkdirSync,
5+
mkdtempSync,
6+
readFileSync,
7+
readdirSync,
8+
rmSync,
9+
writeFileSync,
10+
} from "node:fs";
11+
import { createRequire } from "node:module";
12+
import path from "node:path";
13+
import { after, before, describe, it } from "node:test";
14+
import { fileURLToPath } from "node:url";
15+
import { transformFileAsync } from "@babel/core";
16+
import { expect } from "expect";
17+
import Server from "../lib/Server.js";
18+
19+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
20+
const rootDir = path.join(__dirname, "..");
21+
const libDir = path.join(rootDir, "lib");
22+
23+
const require = createRequire(import.meta.url);
24+
25+
/**
26+
* Collect every file under `dir` recursively.
27+
* @param {string} dir directory to walk
28+
* @returns {string[]} absolute file paths
29+
*/
30+
function walk(dir) {
31+
const files = [];
32+
33+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
34+
const full = path.join(dir, entry.name);
35+
36+
if (entry.isDirectory()) {
37+
files.push(...walk(full));
38+
} else {
39+
files.push(full);
40+
}
41+
}
42+
43+
return files;
44+
}
45+
46+
/**
47+
* Reproduce the `build:cjs` pipeline against `lib/` into `outDir`: transpile
48+
* every `.js` file with Babel's `cjs` env, copy other assets, drop the
49+
* `package.json` CommonJS marker and append the `module.exports = exports.default`
50+
* unwrap that `scripts/finalize-cjs-build.mjs` writes. The result can then be
51+
* `require()`d directly.
52+
* @param {string} outDir target directory
53+
*/
54+
async function buildCjsBundle(outDir) {
55+
for (const source of walk(libDir)) {
56+
const target = path.join(outDir, path.relative(libDir, source));
57+
58+
mkdirSync(path.dirname(target), { recursive: true });
59+
60+
if (source.endsWith(".js")) {
61+
const result = await transformFileAsync(source, { envName: "cjs" });
62+
63+
writeFileSync(target, result.code);
64+
} else {
65+
copyFileSync(source, target);
66+
}
67+
}
68+
69+
writeFileSync(
70+
path.join(outDir, "package.json"),
71+
`${JSON.stringify({ type: "commonjs" }, null, 2)}\n`,
72+
);
73+
74+
appendFileSync(
75+
path.join(outDir, "Server.js"),
76+
"module.exports = exports.default;\nmodule.exports.default = exports.default;\n",
77+
);
78+
}
79+
80+
describe("cjs", () => {
81+
let bundleDir;
82+
let serverPath;
83+
let serverSource;
84+
let bundlePackage;
85+
let cjsServer;
86+
87+
before(async () => {
88+
// Build inside `node_modules` so the transpiled bare `require()`s
89+
// (`schema-utils`, `graceful-fs`, ...) resolve against the project deps.
90+
bundleDir = mkdtempSync(path.join(rootDir, "node_modules", ".wds-cjs-"));
91+
92+
await buildCjsBundle(bundleDir);
93+
94+
serverPath = path.join(bundleDir, "Server.js");
95+
serverSource = readFileSync(serverPath, "utf8");
96+
bundlePackage = JSON.parse(
97+
readFileSync(path.join(bundleDir, "package.json"), "utf8"),
98+
);
99+
cjsServer = require(serverPath);
100+
});
101+
102+
after(() => {
103+
if (bundleDir) {
104+
rmSync(bundleDir, { recursive: true, force: true });
105+
}
106+
});
107+
108+
it("should produce a require()-able CommonJS bundle", () => {
109+
expect(bundlePackage.type).toBe("commonjs");
110+
111+
// Babel's strict-mode prologue, the `exports.default` assignment and the
112+
// unwrap appended by the finalize step.
113+
expect(serverSource).toMatch(/^"use strict";/);
114+
expect(serverSource).toMatch(/exports\.default = /);
115+
expect(serverSource).toMatch(/module\.exports = exports\.default;/);
116+
117+
// No executable dynamic `import()` or `import.meta` survives in the CJS
118+
// build. Strip comments first since JSDoc type annotations legitimately
119+
// reference `import("pkg")` and `[S=import("http").Server]`.
120+
const executableCode = serverSource
121+
.replaceAll(/\/\*[\s\S]*?\*\//g, "")
122+
.replaceAll(/^\s*\/\/.*$/gm, "");
123+
124+
expect(executableCode).not.toMatch(/import\(/);
125+
expect(executableCode).not.toMatch(/import\.meta/);
126+
});
127+
128+
it("should expose the Server class through `require` (pre-ESM shape)", () => {
129+
// `require("webpack-dev-server")` returns the class directly, with
130+
// `.default` pointing back at it for ESM interop.
131+
expect(typeof cjsServer).toBe("function");
132+
expect(cjsServer.default).toBe(cjsServer);
133+
expect(cjsServer.name).toBe(Server.name);
134+
expect(cjsServer.length).toBe(Server.length);
135+
});
136+
137+
it("should match the ESM default export", () => {
138+
expect(typeof Server).toBe("function");
139+
expect(cjsServer.name).toBe("Server");
140+
// Same public statics/prototype surface as the ESM build.
141+
expect(Object.getOwnPropertyNames(cjsServer.prototype)).toEqual(
142+
Object.getOwnPropertyNames(Server.prototype),
143+
);
144+
});
145+
});

0 commit comments

Comments
 (0)