diff --git a/.changeset/migrate-to-esm.md b/.changeset/migrate-to-esm.md index ce9a39ffdf..2a50601837 100644 --- a/.changeset/migrate-to-esm.md +++ b/.changeset/migrate-to-esm.md @@ -2,4 +2,4 @@ "webpack-dev-server": major --- -Migrate the package to ES module syntax. The package is now published as ESM-only. +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. diff --git a/.gitignore b/.gitignore index 8a1aee341d..bf65d04489 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ client !/test/client examples/**/dist +dist coverage node_modules diff --git a/babel.config.js b/babel.config.js index 49ff61255b..04964c2cdd 100644 --- a/babel.config.js +++ b/babel.config.js @@ -1,5 +1,30 @@ export default (api) => { - api.cache(true); + // `api.env()` makes the resolved config cache depend on `BABEL_ENV`/`NODE_ENV` + // so the `cjs` build and the default client build don't share a cache entry. + const env = api.env(); + + // CommonJS build (`build:cjs`, `babel --env-name cjs`). `lib/` is authored as + // native ESM; here we transpile it to CJS for the dual package. `preset-env` + // with `modules: "commonjs"` rewrites `import()` into real `require()` (the + // target environments don't reliably support `require(ESM)`), and + // `babel-plugin-transform-import-meta` rewrites `import.meta.url` (used by + // `createRequire`) for CJS. + if (env === "cjs") { + return { + presets: [ + [ + "@babel/preset-env", + { + modules: "commonjs", + targets: { + node: "22.15.0", + }, + }, + ], + ], + plugins: ["babel-plugin-transform-import-meta"], + }; + } return { presets: [ diff --git a/eslint.config.mjs b/eslint.config.mjs index bb93d5feb3..57c7d43337 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -3,7 +3,7 @@ import config from "eslint-config-webpack"; import configs from "eslint-config-webpack/configs.js"; export default defineConfig([ - globalIgnores(["client/**/*", "examples/**/*"]), + globalIgnores(["client/**/*", "dist/**/*", "examples/**/*"]), { extends: [config], ignores: ["client-src/**/*", "!client-src/webpack.config.js"], diff --git a/lib/Server.js b/lib/Server.js index bedba64ad3..8c3335858a 100644 --- a/lib/Server.js +++ b/lib/Server.js @@ -1,13 +1,16 @@ import { createRequire } from "node:module"; import os from "node:os"; import path from "node:path"; -import url, { fileURLToPath } from "node:url"; +import url from "node:url"; import fs from "graceful-fs"; import ipaddr from "ipaddr.js"; import { validate } from "schema-utils"; import schema from "./options.json" with { type: "json" }; -const require = createRequire(import.meta.url); +// Named `cjsRequire` (not `require`) so it doesn't shadow the implicit CommonJS +// `require` in the transpiled `dist/cjs` build, which would collide with the +// `require("url")` that `babel-plugin-transform-import-meta` injects here. +const cjsRequire = createRequire(import.meta.url); /** @type {Promise | undefined} */ let webpackPeer; @@ -1498,12 +1501,12 @@ class Server { case "string": // could be 'ws', or a path that should be resolved if (clientTransport === "ws") { - clientImplementation = fileURLToPath( - import.meta.resolve("../client/clients/WebSocketClient.js"), + clientImplementation = cjsRequire.resolve( + "../client/clients/WebSocketClient.js", ); } else { try { - clientImplementation = require.resolve(clientTransport); + clientImplementation = cjsRequire.resolve(clientTransport); } catch { clientImplementationFound = false; } @@ -1552,7 +1555,7 @@ class Server { .default; } else { try { - const mod = require( + const mod = cjsRequire( /** @type {string} */ ( /** @type {WebSocketServerConfiguration} */ (this.options.webSocketServer).type @@ -1590,7 +1593,7 @@ class Server { */ getClientEntry() { - return fileURLToPath(import.meta.resolve("../client/index.js")); + return cjsRequire.resolve("../client/index.js"); } /** @@ -1598,9 +1601,9 @@ class Server { */ getClientHotEntry() { if (this.options.hot === "only") { - return fileURLToPath(import.meta.resolve("webpack/hot/only-dev-server")); + return cjsRequire.resolve("webpack/hot/only-dev-server"); } else if (this.options.hot) { - return fileURLToPath(import.meta.resolve("webpack/hot/dev-server")); + return cjsRequire.resolve("webpack/hot/dev-server"); } } @@ -2455,7 +2458,7 @@ class Server { (this.app), ); } else { - const mod = require(/** @type {string} */ (type)); + const mod = cjsRequire(/** @type {string} */ (type)); const serverType = mod.default || mod; diff --git a/package-lock.json b/package-lock.json index 4f602a8409..ac22c0e452 100644 --- a/package-lock.json +++ b/package-lock.json @@ -60,6 +60,7 @@ "@types/trusted-types": "^2.0.7", "acorn": "^8.14.0", "babel-loader": "^10.0.0", + "babel-plugin-transform-import-meta": "^2.3.3", "connect": "^3.7.0", "core-js": "^3.38.1", "cspell": "^8.15.5", @@ -6091,6 +6092,20 @@ "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" } }, + "node_modules/babel-plugin-transform-import-meta": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-import-meta/-/babel-plugin-transform-import-meta-2.3.3.tgz", + "integrity": "sha512-bbh30qz1m6ZU1ybJoNOhA2zaDvmeXMnGNBMVMDOJ1Fni4+wMBoy/j7MTRVmqAUCIcy54/rEnr9VEBsfcgbpm3Q==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@babel/template": "^7.25.9", + "tslib": "^2.8.1" + }, + "peerDependencies": { + "@babel/core": "^7.10.0" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", diff --git a/package.json b/package.json index 67aa272f68..eff66f622c 100644 --- a/package.json +++ b/package.json @@ -12,12 +12,24 @@ "license": "MIT", "author": "Tobias Koppers @sokra", "type": "module", - "main": "lib/Server.js", + "exports": { + ".": { + "types": "./types/lib/Server.d.ts", + "import": "./lib/Server.js", + "require": "./dist/Server.js", + "default": "./lib/Server.js" + }, + "./client/*": "./client/*", + "./package.json": "./package.json" + }, + "main": "./dist/Server.js", + "module": "./lib/Server.js", "types": "types/lib/Server.d.ts", "bin": "bin/webpack-dev-server.js", "files": [ "bin", "lib", + "dist", "client", "types" ], @@ -34,6 +46,7 @@ "fix": "npm-run-all -l fix:code fix:prettier", "commitlint": "commitlint --from=main", "validate:changeset": "node .changeset/changeset-validate.mjs", + "build:cjs": "rimraf -g ./dist/* && babel lib --out-dir dist --env-name cjs --copy-files --no-copy-ignored && node ./scripts/finalize-cjs-build.mjs", "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", "build:types": "rimraf -g ./types/* && tsc --declaration --emitDeclarationOnly --outDir types && node ./scripts/extend-webpack-types.js && prettier \"types/**/*.ts\" --write && prettier \"types/**/*.ts\" --write", "build": "npm-run-all -p \"build:**\"", @@ -93,6 +106,7 @@ "@types/trusted-types": "^2.0.7", "acorn": "^8.14.0", "babel-loader": "^10.0.0", + "babel-plugin-transform-import-meta": "^2.3.3", "connect": "^3.7.0", "core-js": "^3.38.1", "cspell": "^8.15.5", diff --git a/scripts/finalize-cjs-build.mjs b/scripts/finalize-cjs-build.mjs new file mode 100644 index 0000000000..ef4b61c818 --- /dev/null +++ b/scripts/finalize-cjs-build.mjs @@ -0,0 +1,33 @@ +import { appendFile, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +// Finalize the CommonJS build produced by `babel lib --out-dir dist`. +// +// The CJS build is emitted flat into `dist/` (same depth from the package root +// as `lib/`) so the relative `cjsRequire.resolve("../client/...")` calls in +// `Server.js` resolve to `/client` from both `lib/` and `dist/`. +// +// 1. Drop a `package.json` with `"type": "commonjs"` so Node treats the `.js` +// files in `dist/` as CommonJS regardless of the package's root +// `"type": "module"`. +// 2. Babel emits the loader as `exports.default`. Append the `module.exports` +// unwrap so `require("webpack-dev-server")` returns the `Server` class +// directly (parity with the pre-ESM `module.exports = Server`), while +// `.default` keeps pointing at it for interop. + +const CJS_DIR = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "..", + "dist", +); + +await writeFile( + path.join(CJS_DIR, "package.json"), + `${JSON.stringify({ type: "commonjs" }, null, 2)}\n`, +); + +await appendFile( + path.join(CJS_DIR, "Server.js"), + "module.exports = exports.default;\nmodule.exports.default = exports.default;\n", +); diff --git a/test/cjs.test.js b/test/cjs.test.js new file mode 100644 index 0000000000..c60e53decb --- /dev/null +++ b/test/cjs.test.js @@ -0,0 +1,145 @@ +import { + appendFileSync, + copyFileSync, + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { createRequire } from "node:module"; +import path from "node:path"; +import { after, before, describe, it } from "node:test"; +import { fileURLToPath } from "node:url"; +import { transformFileAsync } from "@babel/core"; +import { expect } from "expect"; +import Server from "../lib/Server.js"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const rootDir = path.join(__dirname, ".."); +const libDir = path.join(rootDir, "lib"); + +const require = createRequire(import.meta.url); + +/** + * Collect every file under `dir` recursively. + * @param {string} dir directory to walk + * @returns {string[]} absolute file paths + */ +function walk(dir) { + const files = []; + + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + + if (entry.isDirectory()) { + files.push(...walk(full)); + } else { + files.push(full); + } + } + + return files; +} + +/** + * Reproduce the `build:cjs` pipeline against `lib/` into `outDir`: transpile + * every `.js` file with Babel's `cjs` env, copy other assets, drop the + * `package.json` CommonJS marker and append the `module.exports = exports.default` + * unwrap that `scripts/finalize-cjs-build.mjs` writes. The result can then be + * `require()`d directly. + * @param {string} outDir target directory + */ +async function buildCjsBundle(outDir) { + for (const source of walk(libDir)) { + const target = path.join(outDir, path.relative(libDir, source)); + + mkdirSync(path.dirname(target), { recursive: true }); + + if (source.endsWith(".js")) { + const result = await transformFileAsync(source, { envName: "cjs" }); + + writeFileSync(target, result.code); + } else { + copyFileSync(source, target); + } + } + + writeFileSync( + path.join(outDir, "package.json"), + `${JSON.stringify({ type: "commonjs" }, null, 2)}\n`, + ); + + appendFileSync( + path.join(outDir, "Server.js"), + "module.exports = exports.default;\nmodule.exports.default = exports.default;\n", + ); +} + +describe("cjs", () => { + let bundleDir; + let serverPath; + let serverSource; + let bundlePackage; + let cjsServer; + + before(async () => { + // Build inside `node_modules` so the transpiled bare `require()`s + // (`schema-utils`, `graceful-fs`, ...) resolve against the project deps. + bundleDir = mkdtempSync(path.join(rootDir, "node_modules", ".wds-cjs-")); + + await buildCjsBundle(bundleDir); + + serverPath = path.join(bundleDir, "Server.js"); + serverSource = readFileSync(serverPath, "utf8"); + bundlePackage = JSON.parse( + readFileSync(path.join(bundleDir, "package.json"), "utf8"), + ); + cjsServer = require(serverPath); + }); + + after(() => { + if (bundleDir) { + rmSync(bundleDir, { recursive: true, force: true }); + } + }); + + it("should produce a require()-able CommonJS bundle", () => { + expect(bundlePackage.type).toBe("commonjs"); + + // Babel's strict-mode prologue, the `exports.default` assignment and the + // unwrap appended by the finalize step. + expect(serverSource).toMatch(/^"use strict";/); + expect(serverSource).toMatch(/exports\.default = /); + expect(serverSource).toMatch(/module\.exports = exports\.default;/); + + // No executable dynamic `import()` or `import.meta` survives in the CJS + // build. Strip comments first since JSDoc type annotations legitimately + // reference `import("pkg")` and `[S=import("http").Server]`. + const executableCode = serverSource + .replaceAll(/\/\*[\s\S]*?\*\//g, "") + .replaceAll(/^\s*\/\/.*$/gm, ""); + + expect(executableCode).not.toMatch(/import\(/); + expect(executableCode).not.toMatch(/import\.meta/); + }); + + it("should expose the Server class through `require` (pre-ESM shape)", () => { + // `require("webpack-dev-server")` returns the class directly, with + // `.default` pointing back at it for ESM interop. + expect(typeof cjsServer).toBe("function"); + expect(cjsServer.default).toBe(cjsServer); + expect(cjsServer.name).toBe(Server.name); + expect(cjsServer.length).toBe(Server.length); + }); + + it("should match the ESM default export", () => { + expect(typeof Server).toBe("function"); + expect(cjsServer.name).toBe("Server"); + // Same public statics/prototype surface as the ESM build. + expect(Object.getOwnPropertyNames(cjsServer.prototype)).toEqual( + Object.getOwnPropertyNames(Server.prototype), + ); + }); +});