From a2ff433689ef0e4c8719739f0203d5ce18513ebd Mon Sep 17 00:00:00 2001 From: alexander-akait <4567934+alexander-akait@users.noreply.github.com> Date: Sat, 26 Sep 2026 16:48:50 +0000 Subject: [PATCH 1/2] fix(client): ship types for the client exports, and say it is ESM MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found while starting the webpack-dev-server side of the client move: none of the five `./client/*` exports could be imported by a TypeScript consumer. `tsconfig.client.json` only ever checked the client, never emitted for it, so the exports carried no `types` condition and no declarations existed to point one at. Importing `webpack-dev-middleware/client/ws` failed outright under `noImplicitAny` (TS7016). Declarations alone were not enough. The client keeps ES module syntax — babel runs with `modules: false` so webpack can tree-shake it — but the package is CommonJS and nothing said otherwise, so under `node16`/`nodenext` resolution both the code and the new declarations resolved as CommonJS and the default import came back as the module namespace, which is not a constructor. A nested `package.json` in `client` and `types/client` states what those directories actually contain. No published path changes, which matters because `client.webSocketTransport` points at one of them. Verified from the consumer that reported it: webpack-dev-server's `lint:types-client` failed on the import before, and passes against a pack of this branch. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01UjuMAuk9o6UazjHzcAQCTA --- .changeset/client-type-declarations.md | 5 + package.json | 27 ++++- scripts/mark-client-esm.mjs | 21 ++++ tsconfig.client.build.json | 14 +++ types/client/clients/EventSourceClient.d.ts | 58 ++++++++++ types/client/clients/WebSocketClient.d.ts | 44 ++++++++ types/client/clients/createSocket.d.ts | 100 +++++++++++++++++ types/client/index.d.ts | 116 ++++++++++++++++++++ types/client/indicator.d.ts | 79 +++++++++++++ types/client/overlay.d.ts | 94 ++++++++++++++++ types/client/package.json | 3 + types/client/process-update.d.ts | 12 ++ types/client/theme.d.ts | 9 ++ types/client/utils/get-hot.d.ts | 6 + types/client/utils/log.d.ts | 15 +++ types/client/utils/reload.d.ts | 11 ++ types/client/utils/send-message.d.ts | 23 ++++ types/client/utils/strip-ansi.d.ts | 5 + 18 files changed, 636 insertions(+), 6 deletions(-) create mode 100644 .changeset/client-type-declarations.md create mode 100644 scripts/mark-client-esm.mjs create mode 100644 tsconfig.client.build.json create mode 100644 types/client/clients/EventSourceClient.d.ts create mode 100644 types/client/clients/WebSocketClient.d.ts create mode 100644 types/client/clients/createSocket.d.ts create mode 100644 types/client/index.d.ts create mode 100644 types/client/indicator.d.ts create mode 100644 types/client/overlay.d.ts create mode 100644 types/client/package.json create mode 100644 types/client/process-update.d.ts create mode 100644 types/client/theme.d.ts create mode 100644 types/client/utils/get-hot.d.ts create mode 100644 types/client/utils/log.d.ts create mode 100644 types/client/utils/reload.d.ts create mode 100644 types/client/utils/send-message.d.ts create mode 100644 types/client/utils/strip-ansi.d.ts diff --git a/.changeset/client-type-declarations.md b/.changeset/client-type-declarations.md new file mode 100644 index 000000000..a642cbac8 --- /dev/null +++ b/.changeset/client-type-declarations.md @@ -0,0 +1,5 @@ +--- +"webpack-dev-middleware": patch +--- + +Ship type declarations for the `./client`, `./client/sse`, `./client/ws`, `./client/indicator` and `./client/overlay` exports, and mark the client as the ES modules it has always been. A TypeScript consumer importing one of them got `any` — or, under `node16`/`nodenext` resolution, the module namespace instead of the default export, because the files are ES modules inside a CommonJS package with nothing saying so diff --git a/package.json b/package.json index 0b7ec258a..cf5e99217 100644 --- a/package.json +++ b/package.json @@ -21,11 +21,26 @@ "types": "./types/index.d.ts", "default": "./dist/index.js" }, - "./client": "./client/index.js", - "./client/sse": "./client/clients/EventSourceClient.js", - "./client/ws": "./client/clients/WebSocketClient.js", - "./client/indicator": "./client/indicator.js", - "./client/overlay": "./client/overlay.js", + "./client": { + "types": "./types/client/index.d.ts", + "default": "./client/index.js" + }, + "./client/sse": { + "types": "./types/client/clients/EventSourceClient.d.ts", + "default": "./client/clients/EventSourceClient.js" + }, + "./client/ws": { + "types": "./types/client/clients/WebSocketClient.d.ts", + "default": "./client/clients/WebSocketClient.js" + }, + "./client/indicator": { + "types": "./types/client/indicator.d.ts", + "default": "./client/indicator.js" + }, + "./client/overlay": { + "types": "./types/client/overlay.d.ts", + "default": "./client/overlay.js" + }, "./package.json": "./package.json" }, "main": "dist/index.js", @@ -47,7 +62,7 @@ "fix": "npm-run-all -l fix:js fix:schema-check fix:prettier", "clean": "del-cli client dist types", "prebuild": "npm run clean", - "build:types": "tsc && prettier \"types/**/*.ts\" --write", + "build:types": "tsc && tsc -p tsconfig.client.build.json && node ./scripts/mark-client-esm.mjs && prettier \"types/**/*.ts\" --write", "build:code": "babel src -d dist --copy-files", "build:client": "babel client-src -d client --copy-files", "build": "npm-run-all -p \"build:**\"", diff --git a/scripts/mark-client-esm.mjs b/scripts/mark-client-esm.mjs new file mode 100644 index 000000000..bd6742526 --- /dev/null +++ b/scripts/mark-client-esm.mjs @@ -0,0 +1,21 @@ +// The browser client keeps ESM syntax (babel runs with `modules: false` so +// webpack can tree-shake it), but the package itself is CommonJS. Without a +// marker, Node and TypeScript resolve `client/*.js` and its declarations as +// CommonJS, and a consumer importing the default gets the module namespace +// rather than the class. A nested `package.json` says what these two +// directories really contain, without renaming a published file — those paths +// are what `client.webSocketTransport` points at. +import { writeFile } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +// Not `import.meta.dirname`: this package supports node.js 20.9, which does not +// have it. +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); + +for (const dir of ["client", "types/client"]) { + await writeFile( + path.join(ROOT, dir, "package.json"), + `${JSON.stringify({ type: "module" }, null, 2)}\n`, + ); +} diff --git a/tsconfig.client.build.json b/tsconfig.client.build.json new file mode 100644 index 000000000..3d6449540 --- /dev/null +++ b/tsconfig.client.build.json @@ -0,0 +1,14 @@ +{ + // Emits the declarations for the browser client, which the `./client/*` + // exports need: without them a TypeScript consumer importing one gets `any`, + // or an error under `noImplicitAny`. Checking happens in + // `tsconfig.client.json`; this one only writes the `.d.ts` files. + "extends": "./tsconfig.client.json", + "compilerOptions": { + "noEmit": false, + "declaration": true, + "emitDeclarationOnly": true, + "rootDir": "./client-src", + "outDir": "./types/client" + } +} diff --git a/types/client/clients/EventSourceClient.d.ts b/types/client/clients/EventSourceClient.d.ts new file mode 100644 index 000000000..76a68de47 --- /dev/null +++ b/types/client/clients/EventSourceClient.d.ts @@ -0,0 +1,58 @@ +/** + * Server-Sent Events. A connection can die without the browser firing `error` + * — a proxy that stops forwarding, a laptop that slept — so this one watches + * for silence as well, and reports that as a close for the caller to reconnect. + * + * A failure is not logged here, for the same reason the WebSocket one does not + * log it: `error` fires on every routine reconnection, so saying so would be + * noise rather than news. + * @implements {CommunicationClient} + */ +export default class EventSourceClient implements CommunicationClient { + /** + * @param {string} url url to connect to + * @param {{ timeout?: number }=} options how long silence is tolerated + */ + constructor( + url: string, + options?: + | { + timeout?: number; + } + | undefined, + ); + timeout: number; + /** @type {ClientHandler | undefined} */ + openHandler: ClientHandler | undefined; + /** @type {ClientHandler | undefined} */ + closeHandler: ClientHandler | undefined; + /** @type {ClientHandler | undefined} */ + messageHandler: ClientHandler | undefined; + closed: boolean; + lastActivity: number; + client: EventSource; + timer: number; + /** + * End this connection and report it, once. + */ + handleDisconnect(): void; + /** + * @param {ClientHandler} fn called once the connection is open + */ + onOpen(fn: ClientHandler): void; + /** + * @param {ClientHandler} fn called once the connection is gone + */ + onClose(fn: ClientHandler): void; + /** + * @param {ClientHandler} fn called with each message, as a string + */ + onMessage(fn: ClientHandler): void; + /** + * Stop the watchdog and the connection, without reporting a close. + */ + close(): void; +} +export type CommunicationClient = + import("./createSocket.js").CommunicationClient; +export type ClientHandler = import("./createSocket.js").ClientHandler; diff --git a/types/client/clients/WebSocketClient.d.ts b/types/client/clients/WebSocketClient.d.ts new file mode 100644 index 000000000..3f63aaf09 --- /dev/null +++ b/types/client/clients/WebSocketClient.d.ts @@ -0,0 +1,44 @@ +/** + * A WebSocket. The browser reports a dropped connection itself, and the server + * pings to find a half-open one, so unlike Server-Sent Events this needs no + * watchdog of its own. + * + * A failure is not logged here. The `error` event carries no detail by + * specification, so it would print an opaque object, and it is followed by the + * `close` the shared socket already reports and acts on — which is also all + * Server-Sent Events say, so neither transport is noisier than the other. + * @implements {CommunicationClient} + */ +export default class WebSocketClient implements CommunicationClient { + /** + * @param {string} url url to connect to + */ + constructor(url: string); + /** @type {ClientHandler | undefined} */ + openHandler: ClientHandler | undefined; + /** @type {ClientHandler | undefined} */ + closeHandler: ClientHandler | undefined; + /** @type {ClientHandler | undefined} */ + messageHandler: ClientHandler | undefined; + closed: boolean; + client: WebSocket; + /** + * @param {ClientHandler} fn called once the connection is open + */ + onOpen(fn: ClientHandler): void; + /** + * @param {ClientHandler} fn called once the connection is gone + */ + onClose(fn: ClientHandler): void; + /** + * @param {ClientHandler} fn called with each message, as a string + */ + onMessage(fn: ClientHandler): void; + /** + * Close without reporting it, so the caller does not reconnect. + */ + close(): void; +} +export type CommunicationClient = + import("./createSocket.js").CommunicationClient; +export type ClientHandler = import("./createSocket.js").ClientHandler; diff --git a/types/client/clients/createSocket.d.ts b/types/client/clients/createSocket.d.ts new file mode 100644 index 000000000..49e98ed0e --- /dev/null +++ b/types/client/clients/createSocket.d.ts @@ -0,0 +1,100 @@ +/** + * Called with no argument for open and close, and with the message string for + * a message. + * @typedef {(data?: string) => void} ClientHandler + */ +/** + * One transport, as the page speaks it. Constructed with the url, the same + * shape webpack-dev-server's `client.webSocketTransport` has always taken, so + * a client written for that works here unchanged. + * @typedef {object} CommunicationClient + * @property {(fn: ClientHandler) => void} onOpen called once the connection is open + * @property {(fn: ClientHandler) => void} onClose called once the connection is gone + * @property {(fn: ClientHandler) => void} onMessage called with each message, as a string + * @property {() => void} close close without reporting it + */ +/** + * @typedef {new (url: string, options?: EXPECTED_ANY) => CommunicationClient} CommunicationClientConstructor + */ +/** @typedef {any} EXPECTED_ANY */ +/** + * @typedef {object} SocketOptions + * @property {number=} retries how many times to reconnect before giving up, `Infinity` to keep trying + * @property {((attempt: number) => number)=} retryDelay how long to wait before the attempt, in milliseconds + * @property {boolean=} logRetries say so before each attempt, which only a bounded number of them can afford to do + * @property {(() => void)=} onDisconnect called once per outage — on the first drop, whether or not that connection ever opened + * @property {EXPECTED_ANY=} clientOptions passed to the client's constructor + */ +/** + * Hold a connection open, reconnecting when it drops, and fan each message out + * to everyone listening. What "reconnect" costs is the transport's to say: a + * dropped WebSocket backs off, whereas Server-Sent Events retries at a steady + * interval for as long as the page is open. + * @param {CommunicationClientConstructor} Client what speaks the transport + * @param {string} url url to connect to + * @param {SocketOptions=} options how it reconnects + * @returns {{ addMessageListener: (fn: (event: { data: string }) => void) => void, close: () => void }} the socket + */ +export default function createSocket( + Client: CommunicationClientConstructor, + url: string, + options?: SocketOptions | undefined, +): { + addMessageListener: (fn: (event: { data: string }) => void) => void; + close: () => void; +}; +/** + * Called with no argument for open and close, and with the message string for + * a message. + */ +export type ClientHandler = (data?: string) => void; +/** + * One transport, as the page speaks it. Constructed with the url, the same + * shape webpack-dev-server's `client.webSocketTransport` has always taken, so + * a client written for that works here unchanged. + */ +export type CommunicationClient = { + /** + * called once the connection is open + */ + onOpen: (fn: ClientHandler) => void; + /** + * called once the connection is gone + */ + onClose: (fn: ClientHandler) => void; + /** + * called with each message, as a string + */ + onMessage: (fn: ClientHandler) => void; + /** + * close without reporting it + */ + close: () => void; +}; +export type CommunicationClientConstructor = new ( + url: string, + options?: EXPECTED_ANY, +) => CommunicationClient; +export type EXPECTED_ANY = any; +export type SocketOptions = { + /** + * how many times to reconnect before giving up, `Infinity` to keep trying + */ + retries?: number | undefined; + /** + * how long to wait before the attempt, in milliseconds + */ + retryDelay?: ((attempt: number) => number) | undefined; + /** + * say so before each attempt, which only a bounded number of them can afford to do + */ + logRetries?: boolean | undefined; + /** + * called once per outage — on the first drop, whether or not that connection ever opened + */ + onDisconnect?: (() => void) | undefined; + /** + * passed to the client's constructor + */ + clientOptions?: EXPECTED_ANY | undefined; +}; diff --git a/types/client/index.d.ts b/types/client/index.d.ts new file mode 100644 index 000000000..e06b055f1 --- /dev/null +++ b/types/client/index.d.ts @@ -0,0 +1,116 @@ +/** + * @param {Record} overrides overrides + */ +export function setOptionsAndConnect(overrides: Record): void; +/** + * Close the SSE connection for the current path and stop reconnecting. A + * later `setOptionsAndConnect` call opens a fresh connection. + */ +export function disconnect(): void; +/** + * @param {(obj: HMRPayload) => void} handler called for every incoming HMR message + */ +export function subscribeAll(handler: (obj: HMRPayload) => void): void; +/** + * @param {(obj: HMRPayload) => void} handler called for messages whose `action` is not recognized + */ +export function subscribe(handler: (obj: HMRPayload) => void): void; +/** + * @param {EXPECTED_ANY} customOverlay replacement for the default error overlay + */ +export function useCustomOverlay(customOverlay: EXPECTED_ANY): void; +export type MessageListener = (event: { data: string }) => void; +export type EXPECTED_ANY = any; +export type HMRPayload = { + name?: string; + errors: string[]; + warnings: string[]; + hash: string; + time?: number; + action?: string; + file?: string; + percent?: number; + message?: string; +}; +export type LogLevel = import("./utils/log.js").LogLevel; +/** + * Superset of webpack-dev-server's `client.overlay` object; `styles`, + * `ansiColors`, `openEditorEndpoint` and `paginate` are webpack-dev-middleware + * extensions. + */ +export type OverlayOptions = { + /** + * show build errors in the overlay + */ + errors?: (boolean | ((error: string) => boolean)) | undefined; + /** + * show build warnings in the overlay + */ + warnings?: (boolean | ((warning: string) => boolean)) | undefined; + /** + * show uncaught runtime errors and unhandled rejections in the overlay + */ + runtimeErrors?: (boolean | ((error: Error) => boolean)) | undefined; + /** + * Trusted Types policy name used for the overlay's HTML + */ + trustedTypesPolicyName?: string | undefined; + /** + * overrides for the overlay card CSS + */ + styles?: Record | undefined; + /** + * overrides for ANSI → HTML color mapping + */ + ansiColors?: Record | undefined; + /** + * endpoint the overlay calls (GET `?fileName=file:line:column`) when a file reference is clicked; empty disables it + */ + openEditorEndpoint?: string | undefined; + /** + * show one problem at a time with prev/next navigation + */ + paginate?: boolean | undefined; +}; +export type ClientOptions = { + /** + * how the events are carried, matching the server's `hot.transport` + */ + transport: "sse" | "ws"; + /** + * endpoint path + */ + path: string; + /** + * reconnection timeout in milliseconds + */ + timeout: number; + /** + * enable the in-page error overlay (same value shape as webpack-dev-server's `client.overlay`) + */ + overlay: boolean | OverlayOptions; + /** + * reload the page when HMR cannot apply the update + */ + reload: boolean; + /** + * logger level + */ + logging: LogLevel; + /** + * limit updates to this compilation name + */ + name: string; + /** + * connect immediately when the entry runs + */ + autoConnect: boolean; + /** + * how many times to reconnect before giving up, unset to use the transport's default + */ + reconnect?: number | undefined; + /** + * show an indicator while a rebuild is in progress — `true` and `"circular"` a small badge, `"linear"` a thin bar across the top of the viewport + */ + progress: boolean | "circular" | "linear"; +}; diff --git a/types/client/indicator.d.ts b/types/client/indicator.d.ts new file mode 100644 index 000000000..b4f762226 --- /dev/null +++ b/types/client/indicator.d.ts @@ -0,0 +1,79 @@ +/** + * Show the indicator (idempotent). With a percent the badge renders a + * progress ring; without one it renders a pulsing dot. + * @param {string=} text label text + * @param {number=} percent compilation progress (0-100) + * @param {string=} source who is building (e.g. a compilation name, or a + * client sharing the badge) — the badge stays until every source finished + */ +export function show( + text?: string | undefined, + percent?: number | undefined, + source?: string | undefined, +): void; +/** + * Mark one source's build as finished, or remove the indicator entirely. + * @param {string=} source when given, only that source is dropped and the + * badge stays while any other source is still building; without it the badge + * is removed unconditionally + */ +export function hide(source?: string | undefined): void; +/** + * Choose which indicator is rendered. `"circular"` is the badge this package + * has always shown; `"linear"` is the thin bar across the top of the viewport, + * so `progress` can carry the same values as webpack-dev-server's. + * @param {IndicatorType} type which indicator to render + */ +export function configure(type: IndicatorType): void; +export type EXPECTED_ANY = any; +export type IndicatorType = "circular" | "linear"; +export type IndicatorState = { + /** + * badge host element + */ + host: HTMLElement | null; + /** + * which indicator is rendered + */ + type: IndicatorType; + /** + * label inside the badge + */ + label: HTMLElement | null; + /** + * pulsing dot (indeterminate mode) + */ + dot: HTMLElement | null; + /** + * progress ring (determinate mode) + */ + ring: SVGSVGElement | null; + /** + * ring value circle + */ + ringValue: SVGCircleElement | null; + /** + * filled part of the linear indicator + */ + bar: HTMLElement | null; + /** + * the bar's sweep, when one is running + */ + barAnimation: EXPECTED_ANY; + /** + * every running animation, so motion can be stopped on request + */ + animations: EXPECTED_ANY[]; + /** + * what watches for motion being declined mid-build + */ + motionListener: EXPECTED_ANY; + /** + * the query that listener sits on, which is the only object it can be removed from + */ + motionMediaQuery: EXPECTED_ANY; + /** + * sources with a build in progress — the badge hides only when every source finished + */ + building: Record; +}; diff --git a/types/client/overlay.d.ts b/types/client/overlay.d.ts new file mode 100644 index 000000000..15f734df1 --- /dev/null +++ b/types/client/overlay.d.ts @@ -0,0 +1,94 @@ +/** + * Remove one source's problems, or the whole overlay. + * @param {string=} source when given, only that source's problems are + * dropped and the overlay re-renders the remaining union; without it the + * overlay is dismissed entirely (Escape, backdrop, close button) + */ +export function clear(source?: string | undefined): void; +/** + * @param {"errors" | "warnings"} type problem type + * @param {string[]} lines messages to render + * @param {string=} source who reports them — each source (e.g. this client, + * the webpack-dev-server client, the runtime error capture) keeps its own + * slot and the overlay renders the union of every slot + */ +export function showProblems( + type: "errors" | "warnings", + lines: string[], + source?: string | undefined, +): void; +/** + * @param {{ ansiColors?: Record, overlayStyles?: Record, trustedTypesPolicyName?: string, catchRuntimeError?: boolean | ((error: Error) => boolean), openEditorEndpoint?: string, paginate?: boolean }} options options + * @returns {{ showProblems: typeof showProblems, clear: typeof clear }} overlay api + */ +export default function configureOverlay(options: { + ansiColors?: Record; + overlayStyles?: Record; + trustedTypesPolicyName?: string; + catchRuntimeError?: boolean | ((error: Error) => boolean); + openEditorEndpoint?: string; + paginate?: boolean; +}): { + showProblems: typeof showProblems; + clear: typeof clear; +}; +export type OverlayState = { + /** + * overlay iframe + */ + frame: HTMLIFrameElement | null; + /** + * visible panel inside the iframe + */ + card: HTMLElement | null; + /** + * whether the window listeners are attached + */ + runtimeListenersAttached: boolean; + /** + * whether the host document's Escape listener is attached + */ + hostKeydownAttached: boolean; + /** + * whether the next render is the first one of a newly opened overlay + */ + focusOnRender: boolean; + /** + * page shown when paginating + */ + pageIndex: number; + /** + * what the page had focused before the overlay opened + */ + previousActiveElement: Element | null; + /** + * each reporting source's problems + */ + problemsBySource: Record< + string, + { + type: "errors" | "warnings"; + lines: string[]; + } + >; + /** + * union of every source, as displayed + */ + currentProblems: { + type: "errors" | "warnings"; + lines: string[]; + } | null; + /** + * trusted types policy + */ + trustedTypesPolicy: + | { + createHTML: (value: string) => EXPECTED_ANY; + } + | undefined; + /** + * whether (or which) runtime errors are shown — shared, so the copy that attached the window listeners honors every copy's configuration + */ + catchRuntimeError: boolean | ((error: Error) => boolean); +}; +export type EXPECTED_ANY = any; diff --git a/types/client/package.json b/types/client/package.json new file mode 100644 index 000000000..3dbc1ca59 --- /dev/null +++ b/types/client/package.json @@ -0,0 +1,3 @@ +{ + "type": "module" +} diff --git a/types/client/process-update.d.ts b/types/client/process-update.d.ts new file mode 100644 index 000000000..1554143ae --- /dev/null +++ b/types/client/process-update.d.ts @@ -0,0 +1,12 @@ +/** + * @param {string} hash latest hash from the SSE payload + * @param {{ reload?: boolean }} options client options + * @param {string=} name compilation name the payload belongs to + */ +export default function applyUpdate( + hash: string, + options: { + reload?: boolean; + }, + name?: string | undefined, +): void; diff --git a/types/client/theme.d.ts b/types/client/theme.d.ts new file mode 100644 index 000000000..fa05c469c --- /dev/null +++ b/types/client/theme.d.ts @@ -0,0 +1,9 @@ +declare namespace _default { + let panel: string; + let panelTranslucent: string; + let backdrop: string; + let text: string; + let muted: string; + let accent: string; +} +export default _default; diff --git a/types/client/utils/get-hot.d.ts b/types/client/utils/get-hot.d.ts new file mode 100644 index 000000000..ca1a94247 --- /dev/null +++ b/types/client/utils/get-hot.d.ts @@ -0,0 +1,6 @@ +/** + * Isolated so the HMR runtime can be stubbed in tests — `import.meta` cannot + * be evaluated under jest's CommonJS transform. + * @returns {webpack.Hot | undefined} the webpack HMR runtime API (`import.meta.webpackHot`) + */ +export default function getHot(): webpack.Hot | undefined; diff --git a/types/client/utils/log.d.ts b/types/client/utils/log.d.ts new file mode 100644 index 000000000..2b7992689 --- /dev/null +++ b/types/client/utils/log.d.ts @@ -0,0 +1,15 @@ +/** @typedef {false | true | "none" | "error" | "warn" | "info" | "log" | "verbose"} LogLevel */ +/** + * @param {LogLevel} level log level (or `false` for off, `true` for default) + */ +export function setLogLevel(level: LogLevel): void; +export namespace log { + let error: (...args: unknown[]) => void; + let warn: (...args: unknown[]) => void; + let info: (...args: unknown[]) => void; + let log: (...args: unknown[]) => void; + let groupCollapsed: (...args: unknown[]) => void; + let groupEnd: (...args: unknown[]) => void; +} +export type LogLevel = + false | true | "none" | "error" | "warn" | "info" | "log" | "verbose"; diff --git a/types/client/utils/reload.d.ts b/types/client/utils/reload.d.ts new file mode 100644 index 000000000..64e95dae8 --- /dev/null +++ b/types/client/utils/reload.d.ts @@ -0,0 +1,11 @@ +/** + * @returns {boolean} whether the page is on its way out + */ +export function isUnloading(): boolean; +/** + * Reload the page. While it looks like the page is leaving, the reload is held + * until that turns out to be wrong rather than performed or thrown away. + * Isolated so tests can stub it — `window.location` is not configurable in + * modern jsdom. + */ +export default function reloadPage(): void; diff --git a/types/client/utils/send-message.d.ts b/types/client/utils/send-message.d.ts new file mode 100644 index 000000000..a04da8985 --- /dev/null +++ b/types/client/utils/send-message.d.ts @@ -0,0 +1,23 @@ +/** + * Announce what the client just handled to whoever else is on the page, so a + * plugin or a framework's dev tooling can follow a build without reaching into + * this module. The `webpack` prefix and the payloads match what + * webpack-dev-server's client has always posted, because the consumers of + * these messages are the same ones. + * @param {string} type message type, without the `webpack` prefix + * @param {EXPECTED_ANY=} data payload + */ +declare function sendMessage( + type: string, + data?: EXPECTED_ANY | undefined, +): void; +declare namespace sendMessage { + /** + * Post a message exactly as given, for the one webpack-dev-server sends as a + * bare string rather than in the `{ type, data }` shape. + * @param {EXPECTED_ANY} message the message to post + */ + function raw(message: EXPECTED_ANY): void; +} +export default sendMessage; +export type EXPECTED_ANY = any; diff --git a/types/client/utils/strip-ansi.d.ts b/types/client/utils/strip-ansi.d.ts new file mode 100644 index 000000000..70530c5e9 --- /dev/null +++ b/types/client/utils/strip-ansi.d.ts @@ -0,0 +1,5 @@ +/** + * @param {string} string string possibly containing ANSI escape sequences + * @returns {string} the string without ANSI escape sequences + */ +export default function stripAnsi(string: string): string; From a552101f02417e608f10f8d03c1f1ebce883560e Mon Sep 17 00:00:00 2001 From: alexander-akait <4567934+alexander-akait@users.noreply.github.com> Date: Sat, 26 Sep 2026 17:46:45 +0000 Subject: [PATCH 2/2] fix(build): create the client directories before marking them as ESM MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review finding, and reproducible: `build` runs every `build:*` in parallel, so `build:types` can reach the marker script before babel has created `client`, and the write fails with `ENOENT`. It went unnoticed because `tsc` is the slower of the two here, so babel always happened to win — and CI happened to agree. `build:types` on its own after a `clean` fails every time rather than occasionally, which is what made it easy to confirm and now easy to check: that sequence errored before this and exits 0 after it. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01UjuMAuk9o6UazjHzcAQCTA --- scripts/mark-client-esm.mjs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/scripts/mark-client-esm.mjs b/scripts/mark-client-esm.mjs index bd6742526..ae680133c 100644 --- a/scripts/mark-client-esm.mjs +++ b/scripts/mark-client-esm.mjs @@ -5,7 +5,7 @@ // rather than the class. A nested `package.json` says what these two // directories really contain, without renaming a published file — those paths // are what `client.webSocketTransport` points at. -import { writeFile } from "node:fs/promises"; +import { mkdir, writeFile } from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; @@ -14,8 +14,14 @@ import { fileURLToPath } from "node:url"; const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); for (const dir of ["client", "types/client"]) { + const target = path.join(ROOT, dir); + + // `build` runs every `build:*` in parallel, so the directory babel writes + // `client` into may not exist yet when this runs — and `build:types` on its + // own, after a `clean`, never creates it at all. + await mkdir(target, { recursive: true }); await writeFile( - path.join(ROOT, dir, "package.json"), + path.join(target, "package.json"), `${JSON.stringify({ type: "module" }, null, 2)}\n`, ); }