import type { Phase, ProgressEvent } from "@packall/core"; import { Phase as Phases } from "@packall/core"; import { bar, bytes as formatBytes, duration, eta, fit, pluralize, seconds } from "./format.js"; export declare namespace ProgressRenderer { /** * Drives the animation between events, returning a function that stops it. * * The line is redrawn on a timer as well as on events because the waits worth * reassuring somebody about are exactly the ones that emit no events: the * preflight check against a dead registry produces nothing at all for ten * seconds, and a still line is indistinguishable from a hung process. * * Injected rather than called directly so a test can step the spinner by * hand; the default is a `setInterval` that is `unref`'d, since an animation * must never be the reason a finished process is still running. */ export type Ticker = (tick: () => void, intervalMs: number) => () => void; export type Options = { /** Draw in place. Set from `stderr.isTTY`. */ readonly interactive: boolean; /** Suppress everything except warnings. */ readonly quiet: boolean; readonly write: (text: string) => void; readonly now?: (() => number) | undefined; readonly columns?: number | undefined; /** Minimum gap between redraws, to avoid spending the run repainting. */ readonly frameIntervalMs?: number | undefined; /** How often the spinner advances while a phase is waiting. */ readonly tickIntervalMs?: number | undefined; /** * The preflight ceiling, so the waiting line can name what it is counting * towards. `0` shows the elapsed time alone. */ readonly preflightTimeoutMs?: number | undefined; readonly ticker?: Ticker | undefined; }; } const timerTicker: ProgressRenderer.Ticker = (tick, intervalMs) => { const handle = setInterval(tick, intervalMs); // `unref` is Node's, and it matters there: an animation must never be the // reason a finished process is still running. A browser returns a plain // number from `setInterval`, and calling `.unref()` on it throws — *after* // the interval is already registered, so the timer survives to repaint a // line for a run that the exception just killed. Hence a check rather than // an assumption: this module is documented as platform-neutral, and the one // Node-ism in it was hiding in its default. if (typeof handle === "object" && typeof handle.unref === "function") handle.unref(); return () => clearInterval(handle); }; type PhaseState = { readonly resolved: number; readonly downloaded: number; readonly downloadTotal: number; readonly bytes: number; readonly current: string; }; /** Consumes progress events and renders them. */ export class ProgressRenderer { private readonly options: Required> & { readonly write: (text: string) => void; readonly now: () => number; }; private phase: Phase | "" = ""; private phaseStartedAt = 0; /** * `-Infinity`, not `0`, so the *first* frame is never throttled away. * * With `0` the first draw is suppressed whenever the clock reads below the * frame interval — which is always true at the start of a phase under an * injected clock, and would leave the user staring at nothing for the first * frame interval of a real run. */ private lastFrameAt = Number.NEGATIVE_INFINITY; private lineIsDirty = false; /** Frames drawn by the timer; the spinner reads its glyph from this. */ private ticks = 0; /** Set while the animation is running. */ private stopTicking: (() => void) | undefined; private state: PhaseState = { resolved: 0, downloaded: 0, downloadTotal: 0, bytes: 0, current: "", }; /** Warnings are held back and printed once at the end, not interleaved. */ private readonly warnings: Array = []; constructor(options: ProgressRenderer.Options) { this.options = { interactive: options.interactive, quiet: options.quiet, columns: options.columns ?? 80, frameIntervalMs: options.frameIntervalMs ?? 80, tickIntervalMs: options.tickIntervalMs ?? DEFAULT_TICK_INTERVAL, preflightTimeoutMs: options.preflightTimeoutMs ?? 0, ticker: options.ticker ?? timerTicker, write: options.write, now: options.now ?? (() => Date.now()), }; } /** Everything collected but not yet shown. */ get collectedWarnings(): ReadonlyArray { return this.warnings; } handle(event: ProgressEvent): void { switch (event._tag) { case "PhaseStarted": { this.stopTicker(); this.endLine(); this.phase = event.phase; this.phaseStartedAt = this.options.now(); // Each phase gets its own first frame immediately; throttling should // not carry over from the previous one. this.lastFrameAt = Number.NEGATIVE_INFINITY; // The previous phase's last package is not this phase's first one, and // showing it until the first event arrives is a small lie. this.state = { ...this.state, current: "" }; if (event.phase === Phases.Download) { this.state = { ...this.state, downloadTotal: event.total ?? 0, downloaded: 0, bytes: 0, }; } this.announcePhase(event.phase, event.total); this.startTicker(event.phase); break; } case "PhaseCompleted": { this.stopTicker(); this.endLine(); this.summarizePhase(event.phase); break; } case "PackageResolved": { this.state = { ...this.state, resolved: event.resolvedCount, current: `${event.name}@${event.version}`, }; this.draw(); break; } case "DownloadStarted": { this.state = { ...this.state, current: `${event.name}@${event.version}` }; this.draw(); break; } case "DownloadCompleted": { this.state = { ...this.state, downloaded: event.completedCount, downloadTotal: event.totalCount, bytes: this.state.bytes + event.bytes, current: `${event.name}@${event.version}`, }; this.draw(true); break; } case "DownloadRetrying": { // Retries are the one thing worth interrupting the live line for: a // silent pause looks identical to a hang, which is the exact confusion // this tool exists to remove. this.endLine(); this.log( ` retrying ${event.name}@${event.version} (attempt ${event.attempt}) — ${event.reason}`, ); break; } case "ArchiveStarted": { this.state = { ...this.state, current: basename(event.path) }; this.draw(); break; } case "ArchiveCompleted": { this.endLine(); this.log(` ${basename(event.path)} ${formatBytes(event.bytes)}`); break; } case "Warning": { this.warnings.push(event.message); break; } } } /** * Stops the animation and clears any in-place line. * * Called on the way out of a run whether it succeeded or failed — a dirty * line would otherwise eat the first line of the error, and a live timer * would keep repainting over it. */ finish(): void { this.stopTicker(); this.endLine(); } /** * Advances the animation one frame. * * Public so a test can step it without a real timer; nothing else should * call it. */ tick(): void { this.ticks += 1; // Forced: the whole point of a tick is that no event arrived to draw for. this.draw(true); } /* ---------------------------------------------------------------------- */ /** * Starts repainting on a timer for phases that can go quiet. * * `done` is excluded because there is nothing left to wait for by then. */ private startTicker(phase: Phase): void { if (this.options.quiet || !this.options.interactive) return; if (!ANIMATED_PHASES.has(phase)) return; // The first frame lands now rather than a tick from now, so the line // appears the moment the phase does. this.draw(true); this.stopTicking = this.options.ticker(() => this.tick(), this.options.tickIntervalMs); } private stopTicker(): void { this.stopTicking?.(); this.stopTicking = undefined; } private announcePhase(phase: Phase, total: number | undefined): void { if (this.options.quiet) return; switch (phase) { case Phases.Preflight: // Interactively this is the live line's job — it says the same thing // and counts, so printing both would be saying it twice. if (!this.options.interactive) this.log("Checking registry…"); break; case Phases.Resolve: this.log(`Resolving ${pluralize(total ?? 0, "spec")}…`); break; case Phases.Download: this.log(`Downloading ${pluralize(total ?? 0, "package")}…`); break; case Phases.Archive: this.log("Packaging…"); break; default: break; } } private summarizePhase(phase: Phase): void { if (this.options.quiet) return; const elapsed = this.options.now() - this.phaseStartedAt; switch (phase) { case Phases.Resolve: this.log( ` resolved ${pluralize(this.state.resolved, "package")} in ${duration(elapsed)}`, ); break; case Phases.Download: this.log( ` downloaded ${pluralize(this.state.downloaded, "package")}` + ` (${formatBytes(this.state.bytes)}) in ${duration(elapsed)}`, ); break; default: break; } } /** * Draws the live line. * * Throttled, except when `force` is set — the final frame of a phase should * always land, so the last thing on screen is not a stale count. */ private draw(force = false): void { if (this.options.quiet) return; if (!this.options.interactive) { // Non-interactive output would otherwise be one line per package, which // is thousands of lines of noise in a CI log. Milestones only. return; } const now = this.options.now(); if (!force && now - this.lastFrameAt < this.options.frameIntervalMs) return; this.lastFrameAt = now; this.options.write(`${CLEAR_LINE}${this.frame()}`); this.lineIsDirty = true; } /** The current live line. Exposed for testing. */ frame(): string { const { columns } = this.options; const spinner = SPINNER[this.ticks % SPINNER.length]; const elapsed = this.options.now() - this.phaseStartedAt; if (this.phase === Phases.Download && this.state.downloadTotal > 0) { const fraction = this.state.downloaded / this.state.downloadTotal; const counts = `${this.state.downloaded}/${this.state.downloadTotal}`; const remaining = eta(this.state.downloaded, this.state.downloadTotal, elapsed); const suffix = `${counts} ${formatBytes(this.state.bytes)}${remaining ? ` ETA ${remaining}` : ""}`; const prefix = ` ${spinner} ${bar(fraction)} `; const room = Math.max(8, columns - prefix.length - suffix.length - 3); return `${prefix}${fit(this.state.current, room)} ${suffix}`; } if (this.phase === Phases.Resolve) { const suffix = `${this.state.resolved} found`; const prefix = ` ${spinner} resolving `; const room = Math.max(8, columns - prefix.length - suffix.length - 3); return `${prefix}${fit(this.state.current, room)} ${suffix}`; } // Phases with nothing to count. The clock is the whole point here: a wait // whose end you can see is a wait, and one you cannot is a hang — which is // why the deadline is shown alongside it wherever there is one. const deadline = this.phase === Phases.Preflight ? this.options.preflightTimeoutMs : 0; const suffix = deadline > 0 ? `${seconds(elapsed)} / ${seconds(deadline)}` : seconds(elapsed); const label = this.phase === "" ? undefined : labelByPhase[this.phase]; const prefix = label === undefined ? ` ${spinner} ` : ` ${spinner} ${label} `; // Nothing named yet — a bare label reads better than a label followed by a // column of padding. if (this.state.current.length === 0) return `${prefix.trimEnd()} ${suffix}`; const room = Math.max(8, columns - prefix.length - suffix.length - 3); return `${prefix}${fit(this.state.current, room)} ${suffix}`; } private endLine(): void { if (this.lineIsDirty) { this.options.write(CLEAR_LINE); this.lineIsDirty = false; } } private log(line: string): void { if (this.options.quiet) return; this.endLine(); this.options.write(`${line}\n`); } } /** Carriage return + "erase to end of line" — the in-place redraw primitive. */ const CLEAR_LINE = "\r\u001b[2K"; /** * Spinner glyphs, in order. * * Braille dots, the same set npm and pnpm animate with, so a terminal that * renders their output renders this. The block characters in `bar` * already commit to the same assumption. */ const SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] as const; /** * How often the spinner advances. * * Fast enough to read as motion, slow enough that a run is not spending its * time repainting. */ const DEFAULT_TICK_INTERVAL = 90; /** * Phases that keep a live line. * * `done` is left out: the run is over by then, and a spinner over the final * summary would be animating nothing. */ const ANIMATED_PHASES: ReadonlySet = new Set([ Phases.Preflight, Phases.Resolve, Phases.Download, Phases.Archive, ]); /** What a phase is doing, for the phases with no per-item detail to show. */ const labelByPhase: Partial> = { [Phases.Preflight]: "checking the registry", [Phases.Archive]: "packaging", }; const basename = (filePath: string): string => { const parts = filePath.split(/[/\\]/); return parts[parts.length - 1] ?? filePath; };