1/**
2 * a progress reporting API that allows creating CLI spinners and progress bars
3 * that can be easily nested to visualize complex, parallel work. in addition
4 * to providing rich feedback in the terminal, a headless {@linkcode Root} node
5 * can be constructed, which can be used to bind to
6 * {@link attachToDocument|the DOM} or transmit progress information
7 * {@link encodeEventStream|across a network}.
8 *
9 * the progress API takes advantage of the `using` keyword to make nodes
10 * scoped. the convention is to make functions take a {@linkcode Ref|progress.Ref},
11 * to act as the destination for the sub-node.
12 *
13 * ```ts
14 * async function doSomething(p: progress.Ref) {
15 * using node = p.start("my task");
16 * // ...
17 * await doSomethingElse(node); // node satisfies progress.Ref
18 * // ...
19 * }
20 * async function doSomethingElse(p: progress.Ref) {
21 * using node = p.start("my sub task");
22 * // ...
23 * }
24 *
25 * doSomething(progress); // the progress module satisfies progress.Ref
26 *
27 * import * as progress from "@clo/lib/progress";
28 * ```
29 *
30 * in some cases, such as in `subprocess/ffmpeg.ts`, it may make more sense
31 * to pass in a pre-started node. the `ffmpeg` module, for example, uses the
32 * starting text as a base title and decorates it with encoding stats.
33 *
34 * when using the top-level {@linkcode start|progress.start}, proper
35 * integration is made with `lib/log.ts` so that the progress tree does not
36 * intersect with regular log messages, as well as forwarding each
37 * {@linkcode Node} custom logging scope to the global log, including any
38 * custom redirections.
39 *
40 * this module is under construction. while i am happy with the overall API, it
41 * needs more work and feature development. the API of `Node` is stable, though.
42 *
43 * inspired by the [Zig Progress API](https://andrewkelley.me/post/zig-new-cli-progress-bar-explained.html).
44 *
45 * @module
46 */
47
48/**
49 * creates a new trackable unit of work, visible in the terminal as a spinner
50 * that rests at the end of the log. if `estimate` is given, a progress bar is
51 * shown.
52 */
53/* node:coverage ignore next 3 */
54export function start(text: string, opts?: StartOptions): Node {
55 return global.start(text, opts);
56}
57
58/**
59 * a reference point to create sub-items. this interface is satisfied by
60 * - the `progress` module itself
61 * - a node recieved by `p.start("label")`
62 * - a headless progress runner from `progress.headless()`
63 *
64 * prefer taking `Ref` as a function parameter, so that the function can be
65 * started at the top level, or as a sub-task of an existing node.
66 *
67 * ```ts
68 * function build(p: progress.Ref = progress) {
69 * using _ = p.start("build process");
70 * // ...
71 * }
72 * ```
73 *
74 * sometimes it makes sense to have an API recieve `Node` instead of `Ref`,
75 * specifically if the caller should have some control over the node name.
76 *
77 * ```ts
78 * await ffmpeg.spawn({
79 * cmd: ["-i", "hello.mov", "-c:v", "svtav1", "hello.mp4"],
80 * progress: progress.start("encode hello.mov"),
81 * });
82 * ```
83 */
84export interface Ref {
85 /**
86 * creates a new trackable unit of work as a child of this one.
87 * when given an estimate, a progress bar is rendered.
88 */
89 start(text: string, opts?: StartOptions): Node;
90}
91
92/**
93 * control for a progress nooe. all fields are primed with setters to trigger
94 * ui dispatch automatically. to make tools that use `progress.ts` more
95 * modular to a custom progress implementation, many fields are optional.
96 */
97export interface Node extends Ref, Disposable {
98 /** end this Node or RootNode. ending collapses children. */
99 end(this: Node): void;
100 /** one line of text display. */
101 text: string;
102 /**
103 * number of items completed. by default this is a count of items, but the
104 * `units` property can be passed to `start` to change how the `value` is
105 * interpretted when displaying this progress node.
106 */
107 value: number;
108 /**
109 * an estimate of how many items this task contains.
110 * when set above `0`, it is shown with a progress bar.
111 */
112 total: number;
113 /**
114 * defaults to increasing by 1. if incrementing causes `value >= estimate`,
115 * it also calls `end`. use `node.value += 1` if that behavior is not wanted.
116 */
117 inc(this: Node, value?: number): void;
118 /** a scoped logger set to output inline on this item. */
119 readonly log: log.RootScope;
120
121 /**
122 * a time for when this node is estimated to be completed, in milliseconds
123 * relative to the unix epoch. by default, this is automatically managed and
124 * made read-only. if `StartOptions` specifies `estimateCompletions: false`,
125 * this field may be mutated.
126 */
127 estimatedTime?: number | null;
128 /**
129 * default `true`. `false` will hides a bar + printing total. this is useful
130 * for preseving auto-end behavior of `inc` without showing the total.
131 */
132 showTotal?: boolean;
133 /**
134 * default `false`. when true, this progress item is hidden if
135 * there are no children.
136 */
137 passive?: boolean;
138 /** default `false`. when true, this progress item is hidden */
139 hidden?: boolean;
140 /** override sorting for children */
141 sortChildren?: ((a: ReadOnlyNode, b: ReadOnlyNode) => number) | null;
142 /**
143 * specify how `value` and `total` is formed into text.
144 * this has some discoverable presets on `StartOptions.units`.
145 */
146 valueFormatter?: (value: number, total: number | null) => string;
147}
148
149/** override the default field values of {@linkcode Node} on creation. */
150export interface StartOptions {
151 /** number of items already completed. */
152 value?: number | undefined | null;
153 /** when set above `0`, it is shown with a progress bar. */
154 total?: number | undefined | null;
155 /**
156 * presets for common formattings of `value` and `total`.
157 * @default "count"
158 */
159 units?: "count" | "bytes" | "percent";
160 /**
161 * customize how an estimate is given for progress bars. to make
162 * `Node.estimatedTime` mutable, pass `false` here.
163 * @default `null`, which is equivilent to `new progress.Ema()`
164 */
165 estimateCompletion?: EstimationAlgorithm | boolean | null;
166 /**
167 * when true, this progress item is hidden if there are no children.
168 * this makes sense when scheduling items with a priority queue, where a
169 * passive node is only used for grouping, but children are used for
170 * indicating status.
171 * @default false
172 */
173 passive?: boolean | undefined | null;
174 /** default `false`. when true, this progress item is hidden */
175 hidden?: boolean | undefined | null;
176 /**
177 * default `true`. `false` will hides a bar + printing estimate,
178 * but preseving auto-end behavior of `inc`
179 */
180 showTotal?: boolean | undefined | null;
181 /** specify sorting for children */
182 sortChildren?: Node["sortChildren"] | undefined | null;
183 /**
184 * specify how `value` and `total` is formed into text.
185 * this has some discoverable presets on `StartOptions.units`.
186 */
187 valueFormatter?: ValueFormatter;
188 /** pre-fill the list of log messages */
189 messages?: log.Message[];
190}
191
192/**
193 * value` will be at least zero, total will be `null` or greater than zero. a
194 * custom value formatter can be passed to {@linkcode start} or mutated on an
195 * existing node.
196 */
197export type ValueFormatter = (value: number, total: number | null) => string;
198
199/**
200 * creates a function to use as a `Node.valueFormatter` by using a single
201 * number formatter, and then joining the node's value and total with a "/",
202 * but leaving it out if there is no total.
203 */
204export function slashValueFormatter(
205 fmtNumber: (value: number) => string,
206): ValueFormatter {
207 return (value: number, total: number | null) =>
208 value > 0
209 ? fmtNumber(value)
210 + (total != null && total > 0 ? "/" + fmtNumber(total) : "")
211 : "";
212}
213
214/** the formatter used when passing `units: "count"` to {@linkcode start} */
215export const defaultValueFormatter: ValueFormatter = /* @__PURE__ */
216 slashValueFormatter(String);
217
218/** the formatter used when passing `units: "bytes"` to {@linkcode start} */
219export const bytesValueFormatter: ValueFormatter = /* @__PURE__ */
220 slashValueFormatter(string.formatByteSize);
221
222/** the formatter used when passing `units: "percent"` to {@linkcode start} */
223export function percentValueFormatter(
224 value: number,
225 total: number | null,
226): string {
227 if (total != null && total > 0) {
228 value /= total;
229 }
230 return `${Math.round(value * 1000) / 10}%`;
231}
232
233/**
234 * {@linkcode Root} allows overriding it's timing functions, which are used for
235 * mostly for rendering and streaming adapters.
236 */
237export interface RootOptions {
238 delay?: typeof async.delay;
239 /** used for debouncing and UI. time estimation does NOT use this method */
240 now?: typeof performance.now;
241}
242
243/**
244 * a headless progress node. when the state of the tree changes, the `change`
245 * event is emitted (batched with many changes). the primary use case of using
246 * a custom `Root`s is to stream a task's progress over a network or process
247 * IPC using {@linkcode encodeByteStream}/{@linkcode encodeEventStream}.
248 * another use case is to render a progress task differently.
249 *
250 * custom events may be dispatched on the `Root`, but this is only useful when
251 * streaming, since the streams will carry any custom events.
252 */
253export class Root<
254 Result = void,
255 Map extends Events.Map = ts.EmptyObject,
256> extends Events<MergeRootEvents<Result, Map>> {
257 delay: typeof async.delay;
258 now: typeof performance.now;
259 #status: "active" | "end" | "error" = "active";
260 #active: Internal[] = [];
261 #debounce: async.Cancelable<void> | null = null;
262
263 constructor({ delay, now }: RootOptions = {}) {
264 super();
265 this.delay = delay ?? async.delay;
266 this.now = now ?? performance.now.bind(performance);
267 this.on("node-end", (state) => {
268 if (state.parent) return;
269 const i = this.#active.indexOf(state as Internal);
270 ASSERT(i !== -1);
271 this.#active.splice(i, 1);
272 });
273 }
274
275 /** the caller may not mutate `Root`'s state */
276 get active(): readonly ReadOnlyNode[] {
277 return this.#active;
278 }
279
280 /**
281 * creates a new trackable unit of work at the top level of this root.
282 * when given an estimate, a progress bar is rendered.
283 */
284 start(text: string, opts?: StartOptions): Node {
285 ASSERT(this.#status === "active");
286 const [state, node] = newNode(this, text, opts);
287 this.#active.push(state);
288 this.emit("node-start", state, null);
289 return node;
290 }
291
292 /**
293 * end this root, as well as all children nodes. this emits the `end` event
294 * with the provided value, JSON-serializing it if connected via
295 * {@linkcode encodeEventStream} or {@linkcode encodeByteStream}.
296 *
297 * this function is pre-bound so that it can be passed to `then`:
298 * ```ts
299 * const root = new Root();
300 * void asyncFunction().then(root.end, root.error);
301 * ```
302 */
303 end = (result: Result): void => {
304 this.emit("end", result);
305 };
306
307 /**
308 * end this root, as well as all children nodes. this emits the `error` event.
309 */
310 error = (error: unknown): void => {
311 this.emit("error", error);
312 };
313
314 /** convert this root into a promise. */
315 asPromise(): Promise<Result> {
316 return this.once("end").then((x) => x[0]);
317 }
318
319 /**
320 * emit an event on the specified channel. behavior is not defined when manually
321 * emitting one of the built in events.
322 */
323 override emit<C extends keyof MergeRootEvents<Result, Map>>(
324 channel: C,
325 ...args: MergeRootEvents<Result, Map>[C]
326 ): void {
327 ASSERT(this.#status === "active");
328 if (channel !== "change" && rootEvents.has(channel)) this.#emitChangeSoon();
329 if (channel === "error" || channel === "end") {
330 this.#end();
331 this.#status = channel;
332 }
333 super.emit(channel, ...args);
334 }
335
336 #end() {
337 const hasActive = this.#active.length > 0 || !this.#debounce;
338 for (const active of this.#active) endNode(this, active);
339 this.#active.splice(0, this.#active.length);
340 this.#debounce?.cancel();
341 if (hasActive) this.emit("change", this.#active);
342 }
343
344 #emitChangeSoon = () => {
345 if (this.#debounce) return;
346 (this.#debounce = this.delay(1))
347 .then(() => {
348 this.#debounce = null;
349 this.emit("change", this.#active);
350 });
351 };
352}
353
354/** the built in events provided by {@linkcode Root} */
355export type RootEventMap<Result = void> = {
356 // this event is the debounced "ready to re-render" event. do not emit.
357 "change": [rootNodes: readonly ReadOnlyNode[]];
358 // these events are emitted without a debounce, and are used for general
359 // communication within `Root`'s implementation. do not emit.
360 "node-start": [newNode: ReadOnlyNode, parentNode: ReadOnlyNode | null];
361 "node-change": [node: ReadOnlyNode, key: keyof ReadOnlyNode];
362 "node-end": [node: ReadOnlyNode];
363 "node-detached-log": [msg: log.Message];
364 // these events are used for result communication. they can be sent via
365 // `emit` or the shorthand methods `end` and `error`. sending either will put
366 // the `Root` into a "done" state.
367 "end": [result: Result];
368 "error": [error: unknown];
369};
370
371const rootEvents: ReadonlySet<unknown> = new Set([
372 "change",
373 "node-start",
374 "node-change",
375 "node-end",
376 "node-detached-log",
377 "end",
378 "error",
379]);
380
381type MergeRootEvents<Result, Map extends Events.Map> = {
382 [K in keyof Map | keyof RootEventMap]: K extends keyof RootEventMap ? RootEventMap<Result>[K]
383 : Map[K];
384};
385
386/** a read-only version of {@linkcode Node}, provided to renderers. */
387export type ReadOnlyNode = Readonly<
388 & Pick<
389 Required<Node>,
390 | "text"
391 | "value"
392 | "total"
393 | "estimatedTime"
394 | "passive"
395 | "hidden"
396 | "showTotal"
397 | "sortChildren"
398 | "valueFormatter"
399 >
400 & {
401 /** unique identifier. once {@linkcode Node} is disposed, the key is reused. */
402 key: number;
403 logs: readonly log.Message[];
404 /** traverse down the tree. */
405 children: readonly ReadOnlyNode[];
406 /** traverse up the tree. */
407 parent: ReadOnlyNode | null;
408 }
409>;
410
411/** internal state is read-write. */
412interface Internal extends ts.Writeable<ReadOnlyNode> {
413 logs: log.Message[];
414 children: Internal[];
415 parent: Internal | null;
416 detached: boolean;
417}
418
419// TypeScript Test
420void function(unknown: unknown) {
421 (unknown as Internal) satisfies ReadOnlyNode;
422};
423
424/**
425 * constructs a linked `Node` and `Internal` that dispatches events to `owner`.
426 * the `Internal` is the non-reactive source of truth that can be blindly cast
427 * to `ReadOnlyNode`, and `Node` is the Node.
428 */
429function newNode<Result, EventMap extends Events.Map>(
430 owner: Root<Result, EventMap>,
431 text: string,
432 opts: StartOptions = {},
433): [Internal, Node] {
434 let estimator: EstimationAlgorithm | null = null;
435
436 const state: Internal = {
437 key: globalKeyPool.get(),
438 text,
439 value: opts.value ?? 0,
440 total: opts.total ?? 0,
441 estimatedTime: null,
442 showTotal: opts.showTotal ?? true,
443 passive: opts.passive ?? false,
444 hidden: opts.hidden ?? false,
445 logs: [...opts.messages ?? []],
446 children: [],
447 parent: null,
448 sortChildren: opts.sortChildren ?? null,
449 valueFormatter: opts.valueFormatter ?? {
450 count: defaultValueFormatter,
451 bytes: bytesValueFormatter,
452 percent: percentValueFormatter,
453 }[opts.units ?? "count"],
454 detached: false,
455 };
456
457 if (opts.estimateCompletion !== false) {
458 const { estimateCompletion } = opts;
459 if (estimateCompletion && typeof estimateCompletion === "object") {
460 estimator = estimateCompletion;
461 } else estimator = new Ema();
462 }
463 estimator?.sample(Date.now(), 0, 0);
464
465 function mutate(key: keyof ReadOnlyNode) {
466 if (state.detached) return;
467 if (
468 key === "value" && estimator && state.total > 0 && state.value > 0
469 && state.value < state.total
470 ) {
471 // originally, auto-estimation would use the Root's timing primitives,
472 // but this makes it so that `state.estimatedTime` is in terms of the
473 // Root, which is unintuitive for everyone but the renderer.
474 const time = estimator.sample(Date.now(), state.value, state.total);
475 if (time && (!state.estimatedTime || state.estimatedTime !== time)) {
476 state.estimatedTime = time;
477 owner.emit("node-change", state, "estimatedTime");
478 }
479 }
480 const { parent } = state;
481 parent?.sortChildren && parent.children.sort(parent.sortChildren);
482 owner.emit("node-change", state, key);
483 }
484
485 const scope = log.headlessScope((m) => {
486 if (state.detached) return owner.emit("node-detached-log", m);
487 state.logs.push(m);
488 owner.emit("node-change", state, "logs");
489 });
490
491 const binding: Node = {
492 start(text, opts) {
493 if (state.detached) return nullNode;
494 const [child, node] = newNode(owner, text, opts);
495 state.children.push(child);
496 child.parent = state;
497 state.sortChildren && state.children.sort(state.sortChildren);
498 owner.emit("node-start", child, state);
499 owner.emit("node-change", state, "children");
500 return node;
501 },
502 get text() {
503 return state.text;
504 },
505 set text(value) {
506 state.text = value;
507 mutate("text");
508 },
509 get value() {
510 return state.value;
511 },
512 set value(value) {
513 state.value = value;
514 mutate("value");
515 },
516 get total() {
517 return state.total;
518 },
519 set total(value) {
520 state.total = value;
521 mutate("total");
522 },
523 get showTotal() {
524 return state.showTotal;
525 },
526 set showTotal(value) {
527 state.showTotal = value;
528 mutate("showTotal");
529 },
530 get passive() {
531 return state.passive;
532 },
533 set passive(value) {
534 state.passive = value;
535 mutate("passive");
536 },
537 get hidden() {
538 return state.hidden;
539 },
540 set hidden(value) {
541 state.hidden = value;
542 mutate("hidden");
543 },
544 get sortChildren() {
545 return state.sortChildren;
546 },
547 set sortChildren(value) {
548 state.sortChildren = value;
549 mutate("sortChildren");
550 },
551 get estimatedTime() {
552 return state.estimatedTime;
553 },
554 set estimatedTime(date) {
555 if (!estimator) {
556 state.estimatedTime = date;
557 mutate("estimatedTime");
558 }
559 },
560 get valueFormatter() {
561 return state.valueFormatter;
562 },
563 set valueFormatter(value) {
564 state.valueFormatter = value;
565 mutate("valueFormatter");
566 },
567 inc(delta = 1) {
568 state.value += delta;
569 if (state.total > 0 && state.value >= state.total) {
570 endNode(owner, state);
571 } else {
572 mutate("value");
573 }
574 },
575 log: scope,
576 end: () => void endNode(owner, state),
577 [Symbol.dispose]: () => void endNode(owner, state),
578 };
579 return [state, binding];
580}
581
582const noop = () => {};
583noop[Symbol.dispose] = noop;
584
585/** minimal implementation of {@linkcode Node} that has no I/O */
586export const nullNode: Node = {
587 start: () => nullNode,
588 get text() {
589 return "[detached]";
590 },
591 set text(_) {
592 },
593 get value() {
594 return 0;
595 },
596 set value(_) {
597 },
598 get total() {
599 return 0;
600 },
601 set total(_) {
602 },
603 inc: noop,
604 log: {
605 info: noop,
606 warn: noop,
607 error: noop,
608 log: noop,
609 debug: noop,
610 writeMessage: noop,
611 write: noop,
612 scoped() {
613 return this;
614 },
615 tee: () => noop,
616 },
617 end: () => {},
618 [Symbol.dispose]: () => {},
619};
620
621function endNode<R, M extends Events.Map>(owner: Root<R, M>, state: Internal) {
622 if (state.detached) return;
623 state.children.forEach((child) => endNode(owner, child));
624 state.detached = true;
625 globalKeyPool.recycle(state.key);
626 if (state.parent) {
627 const i = state.parent.children.indexOf(state);
628 ASSERT(i !== -1);
629 state.parent.children.splice(i, 1);
630 }
631 owner.emit("node-end", state);
632 state.logs = [];
633 state.parent = null;
634}
635
636/** Convert the top level {@linkcode ReadOnlyNode|ReadOnlyNode[]} into ANSI text. */
637export function formatAnsi(now: number, list: readonly ReadOnlyNode[]): string {
638 let out = "";
639 for (const top of list) {
640 if (!isVisible(top)) continue;
641 out += renderAnsiMainLine(top, now, 0) + "\n";
642 out += renderChildren(top, now, []);
643 }
644 return out.trimEnd();
645}
646
647function isVisible(node: ReadOnlyNode): boolean {
648 return !node.hidden && (!node.passive || node.children.some(isVisible));
649}
650
651function remainingSeconds(node: ReadOnlyNode): number | null {
652 const { estimatedTime } = node;
653 if (!estimatedTime) return null;
654 const ms = estimatedTime - Date.now();
655 return ms > 1000 && !findLongerEstimate(node, estimatedTime)
656 ? Math.round(ms / 1000)
657 : null;
658}
659
660const spinnerFps = 12.5;
661const maxLogLines = 3;
662const box = { tee: "├─ ", line: "│ ", langle: "└─ " };
663const barChars = [" ", "▏", "▎", "▍", "▌", "▋", "▊", "▉"];
664const fullBar = "█";
665const spinner = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
666 .map((frame) => ansi.style(ansi.fgBlue, frame));
667
668function renderAnsiMainLine(state: ReadOnlyNode, now: number, depth: number) {
669 const { text, total, showTotal, value } = state;
670 const remaining = remainingSeconds(state);
671 const estimate = remaining ? ", " + string.formatDurationLetters(remaining) : "";
672 const valueFormatted = state.valueFormatter(value, total);
673 if (total && showTotal) {
674 return ansi.style(
675 ansi.bgBrightBlack + ansi.fgBlue,
676 formatUnicodeBar(
677 value / total,
678 Math.max(4, depth > 0 ? 12 : 25),
679 ),
680 )
681 + (valueFormatted || estimate ? ` [${valueFormatted}${estimate}] ` : " ")
682 + text;
683 }
684 const frame = Math.floor(now / (1000 / spinnerFps)) % spinner.length;
685 return ((depth === 0 ? spinner[frame] + " " : "")
686 + (valueFormatted ? `[${valueFormatted}] ` : "") + text);
687}
688
689function findLongerEstimate(state: ReadOnlyNode, estimatedTime: number): boolean {
690 return state.children.some((child) =>
691 isVisible(child)
692 && ((child.estimatedTime ?? 0) > estimatedTime || findLongerEstimate(child, estimatedTime))
693 );
694}
695
696function renderChildren(state: ReadOnlyNode, now: number, depth: boolean[]) {
697 let maxHeight = 50; // TODO: flexible layout
698 let truncated = 0;
699 let out = "";
700 const children = state.children.filter(isVisible);
701 if (children.length === 0) return "";
702 for (let i = 0, { length } = children; i < length; i += 1) {
703 const child = UNWRAP(children[i]);
704 let item = "";
705 const left = depth.map((x) => x ? box.line : " ").join("");
706 item += left + (i === length - 1 && !truncated ? box.langle : box.tee);
707 item += renderAnsiMainLine(child, now, 1 + depth.length) + "\n";
708 item += renderChildren(child, now, depth.concat(i < length - 1));
709 const h = string.countNewlines(item);
710 if (h > maxHeight) {
711 truncated += 1;
712 continue;
713 }
714 const logLines = child.logs
715 .map((msg) => log.formatAnsiMessage(msg, true))
716 .join("")
717 .trim();
718 if (logLines) {
719 for (const line of logLines.split("\n").slice(-Math.min(maxLogLines, maxHeight))) {
720 item += left + (i === length - 1 && !truncated ? " " : box.line)
721 + " "
722 + ansi.style(ansi.fgBrightBlack, ">")
723 + " " + line + "\n";
724 }
725 }
726 maxHeight -= h + string.countNewlines(logLines);
727 out += item;
728 }
729 if (truncated) {
730 out += depth.map((x) => x ? box.line : " ").join("") + box.langle;
731 out += ansi.style(ansi.fgBrightBlack, `[${truncated} more]`) + "\n";
732 }
733 return out;
734}
735
736/**
737 * This function is derived from an old program I wrote back in 2020 called `f`
738 * which did ffmpeg handling. It is probably one of the coolest progress bars
739 * ever imagined.
740 */
741export function formatUnicodeBar(progress: number, width: number): string {
742 if (progress >= 1) return fullBar.repeat(width);
743 if (progress <= 0 || Number.isNaN(progress)) return " ".repeat(width);
744
745 const wholeWidth = Math.floor(progress * width);
746 const remainderWidth = (progress * width) % 1;
747 const partWidth = Math.floor(remainderWidth * 8);
748 let partChar = barChars[partWidth];
749 if (width - wholeWidth - 1 < 0) partChar = "";
750
751 const fill = fullBar.repeat(wholeWidth);
752 const empty = " ".repeat(width - wholeWidth - 1);
753
754 return fill + partChar + empty;
755}
756
757/**
758 * configure a {@linkcode Root} to display its contents to a
759 * {@linkcode log.HeadlessWidgetHost}. this is used by the global progress
760 * instance to output to the terminal.
761 * ```ts
762 * const globalProgress = new progress.Root();
763 * progress.attachToScreen(log);
764 * ```
765 * unit tests use a mock screen instead of the terminal.
766 * ```ts
767 * const screen = new TestWidgetHost();
768 * const root = new progress.Root(screen.delay);
769 * root.attachToScreen(screen);
770 * // can use the progress API
771 * root.start("hello");
772 * // and then examine the contents
773 * screen.expectFrame(...);
774 * ```
775 */
776export function attachToScreen(
777 root: Root,
778 { writeOutput, startWidget }: Pick<
779 log.WidgetHost,
780 "writeOutput" | "startWidget"
781 >,
782): ts.Dispose {
783 const stack = new DisposableStack();
784 let widget: log.WidgetInstance | null = null;
785
786 stack.use(root.on("change", (items) => {
787 if (items.length > 0) {
788 widget ??= startWidget({
789 format: ({ now }) => formatAnsi(now, root.active),
790 }) ?? null;
791 if (!widget) return;
792 // scan for spinners and visible estimations
793 widget.fps = items.some((x) => !x.hidden && !(x.showTotal !== false && x.total > 0))
794 ? spinnerFps
795 : hasEstimate(items)
796 ? 1
797 : null;
798 widget.redraw();
799 } else {
800 widget?.stop();
801 widget = null;
802 }
803 }));
804 stack.use(root.on("node-detached-log", (msg) => {
805 writeOutput(log.formatAnsiMessage(msg, true));
806 }));
807 stack.use(root.on("node-end", (node) => {
808 let title = node.text;
809 let p: ReadOnlyNode | null = node;
810 while (p = p.parent) title = p.text + " / " + title;
811 const { logs } = node;
812 if (logs.length > 0) {
813 const header = `[logs from ${title}]`;
814 writeOutput(ansi.style(ansi.fgBrightBlack, header) + "\n");
815 writeOutput(logs.map((msg) => log.formatAnsiMessage(msg, true)).join(""));
816 }
817 }));
818
819 stack.defer(() => {
820 widget?.stop();
821 widget = null;
822 });
823
824 return ts.defer(() => stack.dispose());
825}
826
827function hasEstimate(nodes: readonly ReadOnlyNode[]): boolean {
828 return nodes.some((n) => n.estimatedTime != null || hasEstimate(n.children));
829}
830
831/**
832 * draw `root` into the shadow root of `host`, after the Windows 7 progress bar
833 * and busy cursor, on the animation frame after each change, until disposed.
834 * the shadow root keeps the page and the tree from styling each other, and the
835 * tree follows the page's `color-scheme`. `host` must accept a shadow root,
836 * such as a `div`; one rendered by {@linkcode formatHtml} is taken over.
837 * ```ts
838 * const root = new progress.Root();
839 * const detach = progress.attachToDocument(root, document.getElementById("progress")!);
840 * await build(root);
841 * detach();
842 * ```
843 */
844export function attachToDocument<R, M extends Events.Map>(
845 root: Root<R, M>,
846 host: Element,
847): ts.Dispose {
848 const shadow = host.shadowRoot ?? host.attachShadow({ mode: "open" });
849 shadow.innerHTML = formatShadow("");
850 const tree = UNWRAP(shadow.lastElementChild);
851 const template = document.createElement("template");
852 const elements = new WeakMap<ReadOnlyNode, Element>();
853 const changedAt = new WeakMap<ReadOnlyNode, number>();
854 const staleLogs = new Set<ReadOnlyNode>();
855 let frame: number | null = null;
856 let tick: ts.Timer | undefined;
857 let tickIn = Infinity;
858
859 const redraw = () => void (frame ??= requestAnimationFrame(draw));
860 function draw() {
861 frame = null;
862 for (const node of staleLogs) {
863 const logs = elements.get(node)?.children[2];
864 if (logs) logs.innerHTML = formatLogs(node.logs);
865 }
866 staleLogs.clear();
867 clearTimeout(tick);
868 tickIn = Infinity;
869 sync(tree, root.active);
870 if (tickIn !== Infinity) tick = setTimeout(redraw, tickIn);
871 }
872 function sync(list: Element, nodes: readonly ReadOnlyNode[]) {
873 let next = list.firstElementChild;
874 for (const node of nodes) {
875 if (!isVisible(node)) continue;
876 // estimates show whole seconds, rounded, so each changes on a half second
877 const ms = (node.estimatedTime ?? 0) - Date.now();
878 if (ms > 0) tickIn = Math.min(tickIn, (ms + 500) % 1000 + 5);
879 let li = elements.get(node);
880 if (li) {
881 update(li, node, changedAt);
882 } else {
883 template.innerHTML = formatNode(node, "");
884 elements.set(node, li = UNWRAP(template.content.firstElementChild));
885 changedAt.set(node, performance.now());
886 }
887 if (li === next) next = li.nextElementSibling;
888 else list.insertBefore(li, next);
889 sync(UNWRAP(li.children[1]), node.children);
890 }
891 while (next) {
892 const stale = next;
893 next = next.nextElementSibling;
894 stale.remove();
895 }
896 }
897
898 const listeners = [
899 root.on("node-start", redraw),
900 root.on("node-change", (node, key) => {
901 if (key === "logs") staleLogs.add(node);
902 redraw();
903 }),
904 root.on("node-end", redraw),
905 ];
906 draw();
907 return ts.defer(() => {
908 for (const off of listeners) off();
909 if (frame !== null) cancelAnimationFrame(frame);
910 clearTimeout(tick);
911 shadow.replaceChildren();
912 });
913}
914
915/**
916 * the markup {@linkcode attachToDocument} draws for `list`, as a declarative
917 * shadow root to place inside the element it later takes over. browsers attach
918 * it only while parsing a document, such as a server rendered page.
919 */
920export function formatHtml(list: readonly ReadOnlyNode[]): string {
921 const format = (nodes: readonly ReadOnlyNode[]): string =>
922 nodes.filter(isVisible)
923 .map((node) => formatNode(node, format(node.children)))
924 .join("");
925 return `<template shadowrootmode="open">${formatShadow(format(list))}</template>`;
926}
927
928const formatShadow = (items: string) => `<style>${css}</style><ol class=tree role=list>${items}</ol>`;
929
930/** the parts of a node's markup that change after it is drawn */
931function fields(node: ReadOnlyNode) {
932 const fraction = node.showTotal && node.total > 0
933 ? math.clamp(node.value / node.total, 0, 1)
934 : null;
935 const remaining = remainingSeconds(node);
936 return {
937 className: fraction === null ? "spin" : fraction === 1 ? "bar full" : "bar",
938 fraction,
939 count: formatCount(node),
940 eta: remaining
941 ? string.formatDurationLetters(remaining).replace(/(?<=\D)(?=\d)/g, " ") + " left"
942 : "",
943 };
944}
945
946/** `attachToDocument` relies on this layout: row, children, logs */
947function formatNode(node: ReadOnlyNode, children: string): string {
948 const { className, fraction, count, eta } = fields(node);
949 const id = `n${node.key}`;
950 const value = fraction === null
951 ? ""
952 : ` aria-valuenow="${node.value}" aria-valuemax="${node.total}" style="--p:${fraction}"`;
953 return `<li class="${className}"><div class=row>`
954 + `<div class=indicator role=progressbar aria-labelledby=${id}${value}><i></i></div>`
955 + `<span class=count><b>${esc(count[0])}</b><span>${esc(count[1])}</span></span>`
956 + `<span class=eta>${eta}</span>`
957 + `<span class=label id=${id}>${esc(node.text)}</span>`
958 + `</div><ol role=list>${children}</ol><div class=logs>${formatLogs(node.logs)}</div></li>`;
959}
960
961/**
962 * bring `li` from {@linkcode formatNode} up to date, except its children and
963 * logs. `changedAt` holds when each bar last moved.
964 */
965function update(
966 li: Element,
967 node: ReadOnlyNode,
968 changedAt: WeakMap<ReadOnlyNode, number>,
969) {
970 const { className, fraction, count, eta } = fields(node);
971 const [indicator, counter, estimate, label] = UNWRAP(li.firstElementChild).children;
972 const [value, total] = UNWRAP(counter).children;
973 const progressbar = UNWRAP(indicator);
974 setAttribute(li, "class", className);
975 setText(UNWRAP(value), count[0]);
976 setText(UNWRAP(total), count[1]);
977 setText(UNWRAP(estimate), eta);
978 setText(UNWRAP(label), node.text);
979 if (fraction === null) {
980 for (const name of ["aria-valuenow", "aria-valuemax", "style"]) setAttribute(progressbar, name, null);
981 return;
982 }
983 const shown = math.clamp(
984 Number(progressbar.getAttribute("aria-valuenow")) / Number(progressbar.getAttribute("aria-valuemax")),
985 0,
986 1,
987 );
988 setAttribute(progressbar, "aria-valuenow", String(node.value));
989 setAttribute(progressbar, "aria-valuemax", String(node.total));
990 if (fraction === shown) return;
991 // a forward glide lasts as long as the last update took to arrive, so steady
992 // progress moves steadily; going back snaps, as the original does
993 const now = performance.now();
994 const glide = fraction > shown ? Math.min(now - (changedAt.get(node) ?? now), 1000) : 0;
995 changedAt.set(node, now);
996 setAttribute(progressbar, "style", `--p:${fraction};--glide:${Math.round(glide)}ms`);
997}
998
999function setAttribute(element: Element, name: string, value: string | null) {
1000 if (element.getAttribute(name) === value) return;
1001 if (value === null) element.removeAttribute(name);
1002 else element.setAttribute(name, value);
1003}
1004
1005function setText(element: Element, text: string) {
1006 if (element.textContent !== text) element.textContent = text;
1007}
1008
1009/** `[value, total]`, split at the formatter's slash so the total can be styled apart */
1010function formatCount({ value, total, showTotal, valueFormatter }: ReadOnlyNode): [string, string] {
1011 const text = valueFormatter(value, total);
1012 const slash = text.lastIndexOf("/");
1013 if (slash === -1) return [text, ""];
1014 return [text.slice(0, slash), showTotal ? text.slice(slash + 1) : ""];
1015}
1016
1017/** the last lines of `logs`. partial writes join into lines of their own. */
1018function formatLogs(logs: readonly log.Message[]): string {
1019 let start = logs.length;
1020 for (let breaks = 0; start > 0 && breaks <= maxLogLines;) {
1021 const { text, newline } = UNWRAP(logs[--start]);
1022 breaks += string.countNewlines(text) + (newline === false ? 0 : 1);
1023 }
1024 const lines: { kind: string; html: string }[] = [];
1025 let partial: { kind: string; html: string } | null = null;
1026 for (const { text, newline, level = "info", scope } of logs.slice(start)) {
1027 ansi.toHtml(text).split("\n").forEach((html, i) => {
1028 if (newline !== false) {
1029 // a message starts its own line, so a write's trailing newline leaves no blank one
1030 if (i === 0 && partial?.html === "") lines.pop();
1031 const prefix = i === 0 && scope ? `<span class=scope>${esc(scope)}</span> ` : "";
1032 lines.push({ kind: i === 0 ? level : "more", html: prefix + html });
1033 } else if (i === 0 && partial) {
1034 partial.html += html;
1035 } else {
1036 lines.push(partial = { kind: "raw", html });
1037 }
1038 });
1039 if (newline !== false) partial = null;
1040 }
1041 while (lines.at(-1)?.html === "") lines.pop();
1042 return lines.slice(-maxLogLines)
1043 .map(({ kind, html }) => `<div class=${kind}>${html}</div>`)
1044 .join("");
1045}
1046
1047const esc = string.escapeHtmlContent;
1048
1049/** inline svg for a background image */
1050const svg = (body: string) =>
1051 `url("data:image/svg+xml,${
1052 encodeURIComponent(`<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12'>${body}</svg>`)
1053 }")`;
1054
1055// light-dark() resolves against `color-scheme`, which is inherited from the
1056// host; `all: initial` keeps every other inherited property out.
1057const css = /* @__PURE__ */ (() => `
1058:host{display:block}
1059.tree{all:initial;display:block;color-scheme:inherit;container-type:inline-size;
1060 font:12px/23px "Segoe UI",system-ui,sans-serif;color:light-dark(#1e1e1e,#e3e5e8);
1061 --line:light-dark(#a9b3bd,#5a626c);--muted:light-dark(#56616c,#a4aeb9);${ansi.htmlColors}}
1062ol{margin:0;padding:0}
1063li{display:flow-root;position:relative}
1064.tree>li+li{margin-top:4px}
1065.row{display:flex;flex-wrap:wrap;align-items:center;column-gap:6px;min-height:23px;white-space:nowrap}
1066.label{overflow:hidden;text-overflow:ellipsis;min-width:0}
1067.tree>li>.row>.label{font-size:13px;color:light-dark(#039,#8fb4ff)}
1068.count,.eta{flex:none;margin:4px 0;padding:0 6px;border-radius:8px;font-size:11px;line-height:15px;
1069 font-variant-numeric:tabular-nums;color:var(--muted);
1070 background:linear-gradient(light-dark(#fdfeff,#363b43),light-dark(#edf1f5,#2b3037));
1071 box-shadow:inset 0 0 0 1px light-dark(#d5dce4,#40474f)}
1072.count:has(>b:empty),.eta:empty{display:none}
1073.count>b{font-weight:600;color:light-dark(#2c3640,#d5dbe1)}
1074.count>span:not(:empty)::before{content:"/";margin:0 .2em;opacity:.55}
1075
1076/* bars lead the row, wider at the top level, as in the terminal. the track is
1077 a 1px frame, 6px of gloss, a stretched body, and 5px of bottom light */
1078.tree>li{--bar:176}
1079li li{--bar:96}
1080@container (width < 400px){.tree>li{--bar:112}li li{--bar:64}}
1081.bar>.row>.indicator{position:relative;flex:none;box-sizing:border-box;width:calc(var(--bar) * 1px);
1082 height:15px;margin:4px 0;border:1px solid transparent;border-radius:2px;
1083 background:
1084 linear-gradient(light-dark(#f3f3f3,#34383e),light-dark(#fcfcfc,#3b3f45) 1.5px,
1085 light-dark(#dbdbdb,#2f3237) 6px,light-dark(#cacaca,#26292d) 6px,
1086 light-dark(#cacaca,#26292d) calc(100% - 5px),light-dark(#d5d5d5,#2b2e33) calc(100% - 1px),
1087 light-dark(#eaeaea,#33373c)) padding-box,
1088 linear-gradient(light-dark(#b2b2b2,#4b5058) 0 1px,light-dark(#bdbdbd,#464b53) 1px,
1089 light-dark(#919191,#393d44) calc(100% - 1px),light-dark(#8c8c8c,#393d44) calc(100% - 1px))
1090 border-box}
1091.indicator>i{position:absolute;top:0;bottom:0;left:0;width:calc(var(--p) * 100%);overflow-x:clip;
1092 --gloss:#d2f1fc 1.5px,#addcf3 3.5px,#9ad3ef;--body:#0794d1;--rest:#1ea0d8;--edge:#035f8f;
1093 --glow:160 225 255;--shade:color-mix(in srgb,var(--edge) 40%,#0000);
1094 background:
1095 linear-gradient(90deg,#0000 1px,var(--shade) 1px,var(--shade) 5px,#0000 27px),
1096 linear-gradient(270deg,#0000 1px,var(--shade) 1px,#0000 14px),
1097 linear-gradient(var(--gloss) 6px,var(--body) 6px,var(--body) calc(100% - 5px),var(--rest));
1098 transition:width var(--glide,0s) linear}
1099/* a glow sweeps the fill at one speed for every bar, over the frame too, while
1100 the bottom light swells under it */
1101.indicator>i::before{content:"";position:absolute;left:0;right:0;bottom:0;height:5px;
1102 background:linear-gradient(#0000,rgb(var(--glow)/.6));animation:swell 2.4s ease-in-out infinite}
1103.indicator>i::after{content:"";position:absolute;top:-1px;bottom:-1px;left:0;width:64px;
1104 background:linear-gradient(90deg,#0000,rgb(var(--glow)/.03) 8.3%,rgb(var(--glow)/.08) 16.7%,
1105 rgb(var(--glow)/.2) 25%,rgb(var(--glow)/.36) 33.3%,rgb(var(--glow)/.53) 41.7%,
1106 rgb(var(--glow)/.6),rgb(var(--glow)/.53) 58.3%,rgb(var(--glow)/.36) 66.7%,
1107 rgb(var(--glow)/.2) 75%,rgb(var(--glow)/.08) 83.3%,rgb(var(--glow)/.03) 91.7%,#0000);
1108 animation:glow 2.4s linear infinite}
1109@keyframes glow{from{translate:-100%}50%,to{translate:176px}}
1110@keyframes swell{from{opacity:.4}25%{opacity:1}65%,88%{opacity:0}to{opacity:.4}}
1111:is(.full,li:has(>.logs>:is(.warn,.error)))>.row>.indicator>i::before,
1112:is(.full,li:has(>.logs>:is(.warn,.error)))>.row>.indicator>i::after{content:none}
1113li:has(>.logs>.warn)>.row>.indicator>i{
1114 --gloss:#fffdcd 1.5px,#fffba1 3.5px,#fffa8b;--body:#d2ca00;--rest:#fef400;--edge:#8f8600}
1115li:has(>.logs>.error)>.row>.indicator>i{
1116 --gloss:#ffcdcd 1.5px,#ffa1a1 3.5px,#ff8b8b;--body:#d20000;--rest:#fe0000;--edge:#8f0000}
1117
1118/* the busy ring leads a top-level row, lit from above, with a highlight
1119 running clockwise; below the top level the terminal shows no spinner */
1120.spin>.row>.indicator{position:relative;flex:none;width:15px;height:15px;margin:4px 0;border-radius:50%;
1121 background:conic-gradient(#9bc6da,#8dc2d0 30deg,#6daebd 60deg,#2b8982 90deg,#319f9d 120deg,
1122 #4dc7bb 150deg,#5ecfc5 180deg,#4fc6bb 210deg,#2da09e 240deg,#238382 270deg,#6db2bb 300deg,
1123 #91c5d1 330deg,#9bc6da);
1124 mask:radial-gradient(closest-side,#0000 61%,#000 67%)}
1125li li.spin>.row>.indicator{display:none}
1126.spin>.row>.indicator::before,.spin>.row>.indicator::after{content:"";position:absolute;inset:0;border-radius:50%}
1127.spin>.row>.indicator::before{animation:spin .9s linear infinite;
1128 background:conic-gradient(from -150deg,#0000,#2fd4dc99 40deg,#4fe6eae6 90deg,#8ff3f4 125deg,
1129 #e8fcfd 150deg,#b4eef0b3 162deg,#0000 180deg)}
1130.spin>.row>.indicator::after{background:radial-gradient(closest-side,#00323c73 64%,#00323c00 76%,
1131 #fff3 86%,#00323c00 92%,#00323c80)}
1132.spin>.row>.indicator>i{display:none}
1133@keyframes spin{to{rotate:1turn}}
1134
1135/* tree lines: a node's children hang from the start of its row, and each child
1136 is drawn with the terminal's ├─, │ and └─ */
1137li li{padding-left:22px}
1138li>ol{position:relative}
1139li>ol:not(:empty)::before{content:"";position:absolute;left:7px;top:-4px;height:5px;
1140 border-left:1px solid var(--line)}
1141li li::before,li li::after{content:"";position:absolute;left:7px;border:0 solid var(--line)}
1142li li::before{top:0;bottom:-1px;border-left-width:1px}
1143li li::after{top:11px;width:11px;border-top-width:1px}
1144li li:last-child::before{bottom:auto;height:12px}
1145
1146.logs{margin:1px 0 4px;padding:3px 8px;border-radius:4px;
1147 background:light-dark(#f3f5f8,#24282e);box-shadow:inset 0 0 0 1px light-dark(#e1e6ec,#31363d);
1148 font:11px/16px ui-monospace,"Cascadia Mono",Consolas,"SF Mono",Menlo,monospace;color:var(--muted)}
1149.logs:empty{display:none}
1150.logs>div{overflow:hidden;text-overflow:ellipsis;white-space:pre}
1151.logs>.more{padding-left:16px}
1152.logs>.debug{opacity:.7}
1153.logs>:is(.info,.warn,.error)::before{content:"";display:inline-block;width:11px;height:11px;
1154 margin-right:5px;vertical-align:-2px;background:center/contain no-repeat}
1155.logs>.info::before{background-image:${
1156 svg(
1157 "<defs><radialGradient id='g' cx='.35' cy='.3' r='.8'><stop stop-color='#7cbaf6'/><stop offset='1' stop-color='#1557b8'/></radialGradient></defs><circle cx='6' cy='6' r='5.5' fill='url(#g)' stroke='#0c3f8c' stroke-width='.6'/><rect x='5.25' y='5' width='1.5' height='4.2' rx='.5' fill='#fff'/><circle cx='6' cy='3.3' r='.9' fill='#fff'/>",
1158 )
1159}}
1160.logs>.warn::before{background-image:${
1161 svg(
1162 "<defs><linearGradient id='g' x2='0' y2='1'><stop stop-color='#ffe680'/><stop offset='1' stop-color='#f0b400'/></linearGradient></defs><path d='M6 .8l5.4 10H.6z' stroke-linejoin='round' fill='url(#g)' stroke='#b58300' stroke-width='.7'/><rect x='5.3' y='4' width='1.4' height='3.8' rx='.5' fill='#333'/><circle cx='6' cy='9.2' r='.8' fill='#333'/>",
1163 )
1164}}
1165.logs>.error::before{background-image:${
1166 svg(
1167 "<defs><radialGradient id='g' cx='.35' cy='.3' r='.8'><stop stop-color='#ff8a80'/><stop offset='1' stop-color='#c41a1a'/></radialGradient></defs><circle cx='6' cy='6' r='5.5' fill='url(#g)' stroke='#8a0f0f' stroke-width='.6'/><path d='M4 4l4 4m0-4l-4 4' stroke='#fff' stroke-width='1.5' stroke-linecap='round'/>",
1168 )
1169}}
1170.scope{color:light-dark(#7a6a9e,#b5a6d8)}
1171
1172@media (prefers-reduced-motion:reduce){
1173 .indicator>i{transition:none}
1174 .indicator>i::before,.indicator>i::after{content:none}
1175 .spin>.row>.indicator::before{animation:pulse 1.8s ease-in-out infinite alternate}
1176}
1177@keyframes pulse{from{opacity:.25}}
1178@media (forced-colors:active){
1179 .indicator{forced-color-adjust:none}
1180 .bar>.row>.indicator{background:Canvas;border-color:CanvasText}
1181 .indicator>i{background:Highlight}
1182 .indicator>i::before,.indicator>i::after{content:none}
1183 .spin>.row>.indicator{background:Highlight}
1184}
1185`)();
1186
1187const header = /* @__PURE__ */ string.encodeUtf8("clover's progress <3\n");
1188
1189/** the 'Content-Type' of {@linkcode encodeByteStream}'s return value */
1190export const contentType = "application/x-clover-progress";
1191
1192/**
1193 * opaque but JSON-compatible serialized state, either containing the entire
1194 * progress tree or just a delta update. this data structure always be future
1195 * and backwards compatbile with changes to this library. if a breaking change
1196 * is made, the APIs in this file will be renamed.
1197 *
1198 * for details on the format, read the source of {@linkcode encodeEventStream} or
1199 * {@linkcode encodeByteStream}
1200 */
1201export type StreamEvent = Array<
1202 number | StreamNode | StreamCustomEvent | StreamRootChildren
1203>;
1204const kNode = Symbol("underlyingNode");
1205/** @internal */
1206export interface StreamNode {
1207 k: EncodedKey;
1208 /** text, overwrite */
1209 t?: string | undefined;
1210 /** value, overwrite */
1211 v?: number | undefined;
1212 /** total, overwrite */
1213 e?: number | undefined;
1214 /** showTotal, overwrite */
1215 s?: boolean | undefined;
1216 /** passive, overwrite */
1217 p?: boolean | undefined;
1218 /** logs, append */
1219 l?: log.Message[] | undefined;
1220 /** hidden, overwrite */
1221 h?: boolean | undefined;
1222 /** children, overwrite */
1223 c?: EncodedKey[] | undefined;
1224 /** estimated time, in unix milliseconds, overwrite */
1225 E?: number | null | undefined;
1226 /** formatted value if not defaultValueFormatter, overwrite */
1227 V?: string | undefined;
1228
1229 /** access to the underlying node is used by {@linkcode encodeByteStream} */
1230 [kNode]?: ReadOnlyNode;
1231}
1232type StreamCustomEvent = [
1233 /** custom event name or "end" */
1234 e: "end" | string,
1235 /** custom event payload */
1236 ...args: ts.Json[],
1237];
1238type StreamRootChildren = number[];
1239
1240/** options for both {@linkcode encodeEventStream} and {@linkcode encodeByteStream} */
1241export interface EncodeStreamOptions {
1242 /**
1243 * set the minimum time between packets.
1244 * @default 1000 / 30 (30fps)
1245 */
1246 throttleMs?: number;
1247 /**
1248 * `log.ts` is capable of capturing stack traces from all log calls. by
1249 * seeing this to true, those traces will be serialized. disabled by default
1250 * for privacy reasons.
1251 */
1252 serializeStackTraces?: boolean;
1253}
1254
1255/**
1256 * converts a {@linkcode Root|progress.Root} into an object stream
1257 * for communicating progress over the process or network boundary.
1258 *
1259 * if the given root adds custom event handlers, they must all have
1260 * json-serializable payloads.
1261 */
1262export function encodeEventStream<
1263 Result extends ts.Json,
1264 Map extends { [key: string]: ts.Json[] },
1265>(
1266 // fun fact, this type is impossible to write without generics
1267 root: Root<Result, Map>,
1268 {
1269 throttleMs = 1000 / 30, /* 30fps */
1270 serializeStackTraces = false,
1271 }: EncodeStreamOptions = {},
1272): ReadableStream<StreamEvent> {
1273 const { delay, now } = root;
1274 const stack = new DisposableStack();
1275 const s = new Encoder(root, serializeStackTraces);
1276 const ready = new async.Watch(false);
1277
1278 let lastEvent = now();
1279 let timer: async.Cancelable<void> | null = null;
1280 function emitSoon() {
1281 if (timer || ready.value) return;
1282 const remaining = lastEvent + throttleMs - now();
1283 if (remaining > 0) {
1284 (timer = delay(remaining)).then(() => {
1285 timer = null;
1286 ready.value = true;
1287 lastEvent = now();
1288 });
1289 } else {
1290 ready.value = true;
1291 lastEvent = now();
1292 }
1293 }
1294 /** terminal events skip the throttle so the stream ends promptly */
1295 function emitNow() {
1296 timer?.cancel();
1297 timer = null;
1298 ready.value = true;
1299 lastEvent = now();
1300 }
1301 stack.defer(() => {
1302 if (timer) timer.cancel();
1303 s.changed.clear();
1304 s.deleted.clear();
1305 });
1306
1307 let isFirst = true;
1308 return new ReadableStream({
1309 start(controller) {
1310 if (root.active.length > 0) {
1311 controller.enqueue(s.getDelta(isFirst));
1312 isFirst = false;
1313 }
1314
1315 stack.use(s.observe(emitSoon, emitNow));
1316 },
1317 async pull(controller) {
1318 await ready.until((x) => x === true);
1319 ready.value = false;
1320 if (timer) {
1321 timer.cancel();
1322 timer = null;
1323 }
1324 controller.enqueue(s.getDelta(isFirst));
1325 isFirst = false;
1326 if (s.ended) {
1327 controller.close();
1328 stack.dispose();
1329 }
1330 },
1331 cancel() {
1332 stack.dispose();
1333 },
1334 }, { highWaterMark: 0 });
1335}
1336
1337/**
1338 * decodes a stream created by {@linkcode encodeEventStream} into managed calls
1339 * to `target.start`. when cancelling, all the managed nodes are destroyed.
1340 */
1341export function decodeEventStream<
1342 Result = void,
1343 EventMap extends Events.Map = ts.EmptyObject,
1344>(
1345 encoded: ReadableStream<StreamEvent>,
1346 target: Ref,
1347): async.Cancelable<Result> & Events<EventMap> {
1348 const { resolve, reject, promise } = Promise.withResolvers<Result>();
1349 const events = new Events<EventMap>();
1350 const ctrl = new AbortController();
1351 const decoder = new Decoder(target, events, resolve);
1352 const { signal } = ctrl;
1353 (async () => {
1354 if (signal.aborted) return;
1355 const reader = encoded.getReader();
1356 try {
1357 let hasEmittedEnd = false;
1358 while (true) {
1359 signal.throwIfAborted();
1360 const { value, done } = await reader.read();
1361 signal.throwIfAborted();
1362 if (done) break;
1363 if (decoder.processEvent(value)) hasEmittedEnd = true;
1364 }
1365 ASSERT(hasEmittedEnd, "Stream terminated early.");
1366 } catch (err) {
1367 reader.cancel(err);
1368 if (!signal.aborted) reject(err);
1369 } finally {
1370 reader.releaseLock();
1371 }
1372 })();
1373 return Object.assign(ts.mixin(promise, events), {
1374 cancel: (e?: unknown) => ctrl.abort(e),
1375 [Symbol.dispose]: () => ctrl.abort(),
1376 });
1377}
1378
1379/**
1380 * by listening to the event interface in {@linkcode Root}, compute delta
1381 * events for a progress stream. the caller is expected to fill `changed` and `deleted`
1382 */
1383class Encoder<
1384 Result extends ts.Json,
1385 EventMap extends { [key: string]: ts.Json[] },
1386> {
1387 root: Root<Result, EventMap>;
1388 pool: KeyPool<EncodedKey> = new KeyPool();
1389 keyMap: Map<ReadOnlyNode, EncodedKey> = new Map();
1390 logsLength: WeakMap<ReadOnlyNode, number> = new WeakMap();
1391 serializeStackTraces: boolean;
1392
1393 rootChildrenUpdated = true;
1394 changed = new Map<ReadOnlyNode, Set<keyof ReadOnlyNode>>();
1395 deleted = new Set<ReadOnlyNode>();
1396 /** custom and terminal ("end"/"error") events awaiting the next delta */
1397 pendingEvents: StreamCustomEvent[] = [];
1398 /** set when "end" or "error" was recorded; the stream closes after flushing */
1399 ended = false;
1400
1401 constructor(
1402 root: Root<Result, EventMap>,
1403 serializeStackTraces: boolean = false,
1404 ) {
1405 this.root = root;
1406 this.serializeStackTraces = serializeStackTraces;
1407 for (const node of root.active) {
1408 this.changed.set(node, new Set());
1409 }
1410 }
1411
1412 /**
1413 * fill `changed`, `deleted`, and `pendingEvents` from the events of the
1414 * root. `soon` is called when there is a delta worth sending, and `now`
1415 * when it is the last one.
1416 */
1417 observe(soon: () => void, now: () => void): DisposableStack {
1418 const { root } = this;
1419 const stack = new DisposableStack();
1420 // TODO: why does ts require two args
1421 stack.use(root.on("node-start", (newNode, _) => {
1422 if (!newNode.parent) this.rootChildrenUpdated = true;
1423 this.changed.set(newNode, new Set());
1424 soon();
1425 }));
1426 stack.use(root.on("node-change", (node, key) => {
1427 let set = this.changed.get(node);
1428 if (!set) this.changed.set(node, set = new Set());
1429 set.add(key);
1430 soon();
1431 }));
1432 stack.use(root.on("node-end", (node) => {
1433 if (!node.parent) this.rootChildrenUpdated = true;
1434 this.changed.delete(node);
1435 this.deleted.add(node); // handle add and remove in same frame
1436 soon();
1437 }));
1438 // custom events pass through the stream verbatim
1439 stack.use(root.onAny((channel, args) => {
1440 if (rootEvents.has(channel)) return;
1441 this.pendingEvents.push([channel as string, ...args as ts.Json[]]);
1442 soon();
1443 }));
1444 stack.use(root.on("end", (result) => {
1445 this.pendingEvents.push(["end", result]);
1446 this.ended = true;
1447 now();
1448 }));
1449 stack.use(root.on("error", (error) => {
1450 this.pendingEvents.push(["error", exceptions.message(error)]);
1451 this.ended = true;
1452 now();
1453 }));
1454 return stack;
1455 }
1456
1457 getDelta(isFirst = false) {
1458 const deleted = Array.from(
1459 this.deleted,
1460 (node) => [node, this.keyMap.get(node)] as const,
1461 )
1462 .filter(([node, dst]) => {
1463 if (dst == null) return false; // started and stopped in same tick
1464 this.pool.recycle(dst);
1465 this.keyMap.delete(node);
1466 return true;
1467 })
1468 .map((x) => UNWRAP(x[1]));
1469 ASSERT(!isFirst || deleted.length === 0);
1470
1471 const { updated = [], started = [] } = Object.groupBy(
1472 this.changed,
1473 ([node]) => this.keyMap.has(node) ? "updated" : "started",
1474 );
1475
1476 const payload: StreamEvent = [
1477 isFirst ? -1 : deleted.length,
1478 ...deleted,
1479 ];
1480
1481 if (started.length > 0) {
1482 this.beginNodesDepthFirst(
1483 started.reverse().map(([node]) => node),
1484 payload,
1485 );
1486 }
1487
1488 for (const [node, props] of updated) {
1489 let logs = undefined;
1490 if (
1491 props.has("logs")
1492 && node.logs.length > (this.logsLength.get(node) ?? 0)
1493 ) {
1494 logs = this.redactLogsIfNeeded(
1495 node.logs.slice(this.logsLength.get(node) ?? 0),
1496 );
1497 this.logsLength.set(node, node.logs.length);
1498 }
1499 payload.push({
1500 k: UNWRAP(this.keyMap.get(node)),
1501 t: props.has("text") ? node.text : undefined,
1502 v: props.has("value") ? node.value : undefined,
1503 e: props.has("total") ? node.total : undefined,
1504 s: props.has("showTotal") ? node.showTotal : undefined,
1505 p: props.has("passive") ? node.passive : undefined,
1506 l: logs,
1507 h: props.has("hidden") ? node.hidden : undefined,
1508 c: props.has("children")
1509 ? node.children.map((node) => UNWRAP(this.keyMap.get(node)))
1510 : undefined,
1511 E: props.has("estimatedTime") ? node.estimatedTime : undefined,
1512 V: node.valueFormatter !== defaultValueFormatter
1513 && (props.has("value") || props.has("total")
1514 || props.has("valueFormatter"))
1515 ? node.valueFormatter(node.value, node.total)
1516 : undefined,
1517 [kNode]: node,
1518 });
1519 }
1520
1521 if (this.rootChildrenUpdated) {
1522 payload.push(
1523 this.root.active.map((node) => UNWRAP(this.keyMap.get(node))),
1524 );
1525 }
1526
1527 payload.push(...this.pendingEvents);
1528
1529 this.deleted.clear();
1530 this.changed.clear();
1531 this.pendingEvents = [];
1532 this.rootChildrenUpdated = false;
1533
1534 return payload;
1535 }
1536
1537 beginNodesDepthFirst(
1538 nodes: ReadOnlyNode[],
1539 out: StreamEvent,
1540 ) {
1541 for (let i = 0; i < nodes.length; i += 1) {
1542 const node = UNWRAP(nodes[i]);
1543 nodes.push(...node.children);
1544 }
1545 let next;
1546 while (next = nodes.pop()) {
1547 if (!this.keyMap.has(next)) {
1548 out.push(this.beginNode(next));
1549 }
1550 }
1551 }
1552
1553 beginNode(node: ReadOnlyNode): StreamNode {
1554 const k = this.pool.get();
1555 this.keyMap.set(node, k);
1556
1557 return {
1558 k,
1559 v: node.value !== 0 ? node.value : undefined,
1560 e: node.total !== 0 ? node.total : undefined,
1561 t: node.text.length > 0 ? node.text : undefined,
1562 l: node.logs.length > 0
1563 ? (this.logsLength.set(node, node.logs.length), this.redactLogsIfNeeded(node.logs.slice()))
1564 : undefined,
1565 c: node.children.length > 0
1566 ? node.children.map((node) => UNWRAP(this.keyMap.get(node), () => [node.children, this.keyMap]))
1567 : undefined,
1568 p: node.passive === true ? true : undefined,
1569 s: node.showTotal === false ? false : undefined,
1570 h: node.hidden === true ? true : undefined,
1571 E: node.estimatedTime != null ? node.estimatedTime : undefined,
1572 V: node.valueFormatter !== defaultValueFormatter
1573 ? node.valueFormatter(node.value, node.total)
1574 : undefined,
1575 [kNode]: node,
1576 };
1577 }
1578
1579 redactLogsIfNeeded(messages: log.Message[]) {
1580 if (this.serializeStackTraces) return messages;
1581 return messages.map((x) => ({ ...x, stack: undefined }));
1582 }
1583}
1584
1585class Decoder<
1586 Result = void,
1587 EventMap extends Events.Map = ts.EmptyObject,
1588> {
1589 target: Ref;
1590 events: Events<EventMap>;
1591 resolve: (result: Result) => void;
1592 active = new Map<EncodedKey, Node>();
1593 pendingStart = new Map<EncodedKey, PendingStart>();
1594 pendingParents = new Map<EncodedKey, EncodedKey>();
1595
1596 constructor(
1597 target: Ref,
1598 events: Events<EventMap>,
1599 resolve: (result: Result) => void,
1600 ) {
1601 this.target = target;
1602 this.events = events;
1603 this.resolve = resolve;
1604 }
1605
1606 reset() {
1607 for (const v of this.active.values()) v.end();
1608 this.active.clear();
1609 }
1610
1611 /** returns whether the stream signalled "end"; throws on "error" */
1612 processEvent(event: StreamEvent): boolean {
1613 event = event.slice();
1614
1615 // delete unreferenced nodes first, since they may get re-allocated
1616 // in the next phase.
1617 const deletedCount = event.shift();
1618 ASSERT(typeof deletedCount === "number");
1619 if (deletedCount === -1) {
1620 // connection reset, delete all nodes
1621 this.reset();
1622 } else {
1623 for (const id of event.splice(0, deletedCount)) {
1624 ASSERT(typeof id === "number");
1625 UNWRAP(
1626 this.active.get(id as EncodedKey),
1627 () => `dstkey ${id} not found, ${[...this.active.keys()]}`,
1628 ).end();
1629 ASSERT(this.active.delete(id as EncodedKey));
1630 }
1631 }
1632
1633 // parse all of the events, updating all nodes and extracting creations
1634 let resolving = false;
1635 let resolvingValue: unknown = null;
1636 let updateChildren: Array<{
1637 key: EncodedKey;
1638 children: EncodedKey[];
1639 }> = [];
1640 for (const chunk of event) {
1641 ASSERT(typeof chunk === "object");
1642 if (Array.isArray(chunk)) {
1643 if (typeof chunk[0] === "string") {
1644 if (chunk[0] === "end") {
1645 resolving = true;
1646 resolvingValue = chunk[1];
1647 } else if (chunk[0] === "error") {
1648 throw new Error(`Progress stream error: ${chunk[1]}`);
1649 } else {
1650 // custom event
1651 // @ts-expect-error TODO: typescript soundness
1652 this.events.emit(...chunk);
1653 }
1654 } else {
1655 const ids = chunk as EncodedKey[];
1656 for (const id of ids) this.pendingParents.set(id, 0 as EncodedKey);
1657 }
1658 } else {
1659 // node update or creation
1660 const {
1661 k: key,
1662 t: text,
1663 v: value,
1664 e: total,
1665 s: showTotal,
1666 p: passive,
1667 h: hidden,
1668 l: messages,
1669 c: children,
1670 E: estimatedTime,
1671 V: formattedValue,
1672 } = chunk;
1673 const node = this.active.get(key);
1674 if (children) {
1675 for (const id of children) this.pendingParents.set(id, key);
1676 updateChildren.push({ key, children });
1677 }
1678 if (node) {
1679 if (text) node.text = text;
1680 if (value) node.value = value;
1681 if (total) node.total = total;
1682 if (showTotal != null) node.showTotal = showTotal;
1683 if (passive != null) node.passive = passive;
1684 if (hidden != null) node.hidden = hidden;
1685 for (const message of messages ?? []) node.log.writeMessage(message);
1686 if (estimatedTime) node.estimatedTime = estimatedTime;
1687 if (formattedValue) node.valueFormatter = () => formattedValue;
1688 for (const id of children ?? []) this.pendingParents.set(id, key);
1689 } else {
1690 ASSERT(text != null, `DstKey ${key} not active but not created`);
1691 this.pendingStart.set(key, {
1692 text,
1693 value,
1694 total,
1695 showTotal,
1696 passive,
1697 hidden,
1698 messages,
1699 estimatedTime,
1700 formattedValue,
1701 });
1702 }
1703 }
1704 }
1705
1706 let entry;
1707 while (entry = this.pendingStart.entries().next().value) {
1708 const [key, value] = entry;
1709 this.startRecursive(key, value);
1710 }
1711 this.pendingParents.clear();
1712
1713 // validate that every referenced child became active; a failure here
1714 // means the stream is desynced
1715 for (const { key, children } of updateChildren) {
1716 UNWRAP(this.active.get(key), () => `parent ${key} not active`);
1717 for (const id of children) {
1718 UNWRAP(this.active.get(id), () => `child ${id} not active`);
1719 }
1720 }
1721
1722 if (resolving) {
1723 this.reset();
1724 this.resolve(resolvingValue as Result);
1725 return true;
1726 }
1727 return false;
1728 }
1729
1730 startRecursive(key: EncodedKey, opts: PendingStart): Ref {
1731 ASSERT(this.pendingStart.delete(key));
1732 // a node whose parent linkage is missing (malformed stream) attaches at
1733 // the root rather than killing the whole decoder
1734 const parentId = this.pendingParents.get(key) ?? (0 as EncodedKey);
1735 let ref: Ref | null = parentId === 0
1736 ? this.target
1737 : this.active.get(parentId) ?? null;
1738 if (!ref) {
1739 ref = this.startRecursive(
1740 parentId,
1741 UNWRAP(this.pendingStart.get(parentId)),
1742 );
1743 }
1744 const { text, estimatedTime, formattedValue, ...options } = opts;
1745 const node = ref.start(text, {
1746 ...options,
1747 estimateCompletion: false,
1748 });
1749 this.active.set(key, node);
1750 node.estimatedTime = estimatedTime;
1751 if (formattedValue) node.valueFormatter = () => formattedValue;
1752 this.pendingParents.delete(key);
1753 return node;
1754 }
1755}
1756
1757interface PendingStart {
1758 text: string;
1759 value?: number;
1760 total?: number;
1761 showTotal?: boolean;
1762 passive?: boolean;
1763 hidden?: boolean;
1764 messages?: log.Message[];
1765 estimatedTime?: number | null;
1766 formattedValue?: string;
1767}
1768
1769/** this interface must not have more than 16 properties */
1770interface DeltaFlags {
1771 // the new status is always present since it's one bit
1772 hidden: boolean;
1773 passive: boolean;
1774 showTotal: boolean;
1775
1776 // for each packet that changed it stores out of line
1777 changedValue: boolean;
1778 changedTotal: boolean;
1779 changedLogs: boolean;
1780 changedText: boolean;
1781 changedChildren: boolean;
1782 changedEstimatedTime: boolean;
1783 changedFormattedValue: boolean;
1784
1785 estimatedTimeNonNull: boolean;
1786
1787 futureProofingPacket: boolean;
1788 deleted: boolean;
1789}
1790
1791/**
1792 * NOTE: this function contains bugs and its format is not yet stabilized.
1793 *
1794 * converts a {@linkcode Root|progress.Root} into an byte stream for
1795 * communicating progress over the process or network boundary. the output is a
1796 * raw binary payload that uses an extremely compact representation for the
1797 * nodes. if trying to transmit UTF-8, consider the JSON-encoding
1798 * {@linkcode encodeEventStream}
1799 *
1800 * if the given root adds custom event handlers, they must all have
1801 * json-serializable payloads.
1802 */
1803export function encodeByteStream<
1804 Result extends ts.Json,
1805 EventMap extends { [key: string]: ts.Json[] },
1806>(
1807 root: Root<Result, EventMap>,
1808 options?: EncodeStreamOptions,
1809): ReadableStream<Uint8Array> {
1810 const events = encodeEventStream(root, options);
1811 const transform = new TransformStream({ transform: writeStreamEvent });
1812 return events.pipeThrough(transform);
1813}
1814
1815/** convert {@linkcode StreamEvent} into a byte payload. Mutates the input event. */
1816function writeStreamEvent(
1817 event: StreamEvent,
1818 writer: stream.WriteTarget,
1819) {
1820 using w = new stream.BufferedWriter(writer);
1821
1822 let deleteCount = event.shift();
1823 ASSERT(typeof deleteCount === "number");
1824
1825 if (deleteCount === -1) {
1826 deleteCount = 0;
1827 w.write(header);
1828 } else {
1829 w.u8("\n".charCodeAt(0));
1830
1831 const deletedNodes = event.splice(0, deleteCount);
1832 w.varUint(deletedNodes.length);
1833 for (const dstKey of deletedNodes) {
1834 ASSERT(typeof dstKey === "number");
1835 w.varUint(dstKey);
1836 }
1837 }
1838
1839 const rootChildren = event.find((x): x is StreamRootChildren => Array.isArray(x) && typeof x[0] === "number");
1840 const changedNodes = event.filter((x): x is StreamNode => typeof x === "object" && !Array.isArray(x));
1841 w.varUint(changedNodes.length + +!!rootChildren);
1842 for (const obj of changedNodes) {
1843 ASSERT(typeof obj === "object" && "k" in obj);
1844 const {
1845 k: key,
1846 t: text,
1847 v: value,
1848 e: total,
1849 l: messages,
1850 c: childrenSet,
1851 E: estimatedTime,
1852 V: formattedValue,
1853 } = obj;
1854 const { hidden, showTotal, passive } = UNWRAP(obj[kNode]);
1855 ASSERT(key > 0);
1856 ASSERT(messages == null || messages.length > 0);
1857
1858 w.varUint(key);
1859 w.u16(encodeDeltaFlags({
1860 hidden,
1861 passive,
1862 showTotal,
1863 changedValue: value !== undefined,
1864 changedTotal: total !== undefined,
1865 changedLogs: messages !== undefined,
1866 changedText: text !== undefined,
1867 changedChildren: childrenSet !== undefined,
1868 changedEstimatedTime: estimatedTime !== undefined,
1869 changedFormattedValue: formattedValue !== undefined,
1870 estimatedTimeNonNull: estimatedTime !== null,
1871 }));
1872 if (value !== undefined) w.f64(value);
1873 if (total !== undefined) w.f64(total);
1874 if (text !== undefined) w.stringWithLength(text);
1875 if (messages !== undefined) {
1876 w.varUint(messages.length);
1877 for (const msg of messages) {
1878 let level = logLevelSerialize.indexOf(msg.level ?? "info");
1879 if (level === -1) level = 0;
1880 w.u8(
1881 level
1882 + (msg.newline === false ? 1 << 4 : 0)
1883 + (msg.scope ? 1 << 5 : 0)
1884 + (msg.stack && msg.stack.length > 0 ? 1 << 6 : 0)
1885 + (msg.custom !== undefined ? 1 << 7 : 0),
1886 );
1887 w.stringWithLength(msg.text);
1888 w.varUint(Math.floor(msg.time));
1889 if (msg.scope) w.stringWithLength(msg.scope);
1890 if (msg.stack && msg.stack.length > 0) {
1891 w.varUint(msg.stack.length);
1892 for (const { fn, file, line, col } of msg.stack) {
1893 w.stringWithLength(fn ?? "");
1894 w.stringWithLength(file ?? "");
1895 w.varUint(line ?? 0);
1896 w.varUint(col ?? 0);
1897 }
1898 }
1899 if (msg.custom !== undefined) {
1900 w.stringWithLength(JSON.stringify(msg.custom));
1901 }
1902 }
1903 }
1904 if (childrenSet !== undefined) {
1905 w.varUint(childrenSet.length);
1906 for (const key of childrenSet) w.varUint(key);
1907 }
1908 if (estimatedTime != null) w.varUint(Math.floor(estimatedTime));
1909 if (formattedValue !== undefined) {
1910 w.stringWithLength(formattedValue);
1911 }
1912 }
1913 if (rootChildren) {
1914 w.varUint(0);
1915 w.varUint(rootChildren.length);
1916 for (const key of rootChildren) {
1917 w.varUint(key);
1918 }
1919 }
1920
1921 const customEvents = event.filter((x): x is StreamCustomEvent => Array.isArray(x) && typeof x[0] === "string");
1922 w.varUint(customEvents.length);
1923 for (const [event, ...args] of customEvents) {
1924 w.stringWithLength(event);
1925 w.stringWithLength(JSON.stringify(args));
1926 }
1927}
1928
1929/**
1930 * decodes a stream created by {@linkcode encodeByteStream} into managed calls to
1931 * `target.start`. when cancelling, all the managed nodes are destroyed.
1932 */
1933export function decodeByteStream<
1934 Result = void,
1935 EventMap extends Events.Map = ts.EmptyObject,
1936>(
1937 encoded: ReadableStream<Uint8Array>,
1938 target: Ref,
1939): async.Cancelable<Result> & Events<EventMap> {
1940 let cancelled = false;
1941 let reader: stream.BufferedReader | null = null;
1942 return decodeEventStream<Result, EventMap>(
1943 new ReadableStream<StreamEvent>({
1944 async start(controller) {
1945 reader = new stream.BufferedReader(encoded.getReader());
1946 try {
1947 // an event boundary with no further bytes is a clean end of stream
1948 while (reader && await reader.ensureAvailableOrFalse(1)) {
1949 controller.enqueue(await readStreamEvent(reader));
1950 }
1951 if (!cancelled) controller.close();
1952 } catch (e) {
1953 if (!cancelled) {
1954 reader?.cancel(e);
1955 throw e;
1956 }
1957 } finally {
1958 reader?.releaseLock();
1959 reader = null;
1960 }
1961 },
1962 cancel(reason) {
1963 cancelled = true;
1964 reader?.releaseLock();
1965 reader = null;
1966 encoded.cancel(reason);
1967 },
1968 }),
1969 target,
1970 );
1971}
1972
1973async function readStreamEvent(r: stream.BufferedReader): Promise<StreamEvent> {
1974 const event: StreamEvent = [];
1975 const start = await r.peekU8();
1976 if (start === "\n".charCodeAt(0)) {
1977 await r.u8();
1978 let deletedCount = await r.varUint();
1979 event.push(deletedCount);
1980 while (deletedCount-- > 0) event.push(await r.varUint());
1981 } else {
1982 const readHeader = await r.readExactly(header.byteLength);
1983 if (!bytes.eql(readHeader, header)) {
1984 const m = JSON.stringify(string.decodeUtf8(readHeader));
1985 throw new Error(`Header mismatch, got ${m}`);
1986 }
1987 event.push(-1);
1988 }
1989 const changedNodes = await r.varUint();
1990 for (let i = 0; i < changedNodes; i += 1) {
1991 const key = await r.varUint();
1992 if (key === 0) {
1993 let count = await r.varUint();
1994 const array: StreamRootChildren = [];
1995 while (count-- > 0) array.push(await r.varUint());
1996 event.push(array);
1997 continue;
1998 }
1999 const flags = decodeDeltaFlags(await r.u16());
2000 const v = flags.changedValue ? await r.f64() : undefined;
2001 const e = flags.changedTotal ? await r.f64() : undefined;
2002 const t = flags.changedText ? await r.stringWithLength() : undefined;
2003 let l: undefined | log.Message[];
2004 if (flags.changedLogs) {
2005 l = [];
2006 let len = await r.varUint();
2007 while (len-- > 0) {
2008 const msgFlags = await r.u8();
2009 const level = logLevelSerialize[msgFlags & 0b1111] ?? "info";
2010 // bitwise tests: `msgFlags && (1 << 5) > 0` parsed as
2011 // `msgFlags && true`, which fabricated scope/stack/custom reads for
2012 // any message with a non-zero level and desynced the entire stream
2013 const partial = (msgFlags & (1 << 4)) !== 0;
2014 const hasScope = (msgFlags & (1 << 5)) !== 0;
2015 const hasStack = (msgFlags & (1 << 6)) !== 0;
2016 const hasCustom = (msgFlags & (1 << 7)) !== 0;
2017 const text = await r.stringWithLength();
2018 const time = await r.varUint();
2019 const scope = hasScope ? await r.stringWithLength() : undefined;
2020 let stack: stack.Frame[] | undefined = undefined;
2021 if (hasStack) {
2022 stack = [];
2023 let frames = await r.varUint();
2024 while (frames-- > 0) {
2025 const fn = await r.stringWithLength();
2026 const file = await r.stringWithLength();
2027 const line = await r.varUint();
2028 const col = await r.varUint();
2029 stack.push({
2030 fn: fn || null,
2031 file: file || null,
2032 line: line || null,
2033 col: col || null,
2034 });
2035 }
2036 }
2037 const custom = hasCustom
2038 ? JSON.parse(await r.stringWithLength())
2039 : undefined;
2040 l.push({ level, text, time, scope, stack, custom, newline: partial ? false : undefined });
2041 }
2042 }
2043 let c: undefined | EncodedKey[] = undefined;
2044 if (flags.changedChildren) {
2045 c = [];
2046 let len = await r.varUint();
2047 while (len-- > 0) c.push(await r.varUint() as EncodedKey);
2048 }
2049 const E = flags.changedEstimatedTime
2050 ? flags.estimatedTimeNonNull ? await r.varUint() : null
2051 : undefined;
2052 const V = flags.changedFormattedValue
2053 ? await r.stringWithLength()
2054 : undefined;
2055
2056 // a "v2"'s packet will go here, contents ignored by this version.
2057 if (flags.futureProofingPacket) {
2058 const len = await r.varUint();
2059 void await r.readExactly(len);
2060 }
2061
2062 event.push({
2063 k: key as EncodedKey,
2064 t,
2065 v,
2066 e,
2067 s: flags.showTotal,
2068 p: flags.passive,
2069 l,
2070 h: flags.hidden,
2071 c,
2072 E,
2073 V,
2074 });
2075 }
2076
2077 // events
2078 let eventCount = await r.varUint();
2079 while (eventCount-- > 0) {
2080 const name = await r.stringWithLength();
2081 const args = JSON.parse(await r.stringWithLength());
2082 event.push([name, ...args]);
2083 }
2084
2085 return event;
2086}
2087
2088const logLevelSerialize = ["info", "error", "warn", "debug"] as const;
2089
2090function encodeDeltaFlags(node: Partial<DeltaFlags>) {
2091 return (
2092 (node.hidden ? 1 << 0 : 0)
2093 + (node.passive ? 1 << 1 : 0)
2094 + (node.showTotal ? 1 << 2 : 0)
2095 + (node.changedValue ? 1 << 3 : 0)
2096 + (node.changedTotal ? 1 << 4 : 0)
2097 + (node.changedLogs ? 1 << 6 : 0)
2098 + (node.changedText ? 1 << 7 : 0)
2099 + (node.changedChildren ? 1 << 8 : 0)
2100 + (node.changedEstimatedTime ? 1 << 9 : 0)
2101 + (node.changedFormattedValue ? 1 << 10 : 0)
2102 + (node.estimatedTimeNonNull ? 1 << 11 : 0)
2103 + (node.futureProofingPacket ? 1 << 14 : 0)
2104 + (node.deleted ? 1 << 15 : 0)
2105 );
2106}
2107
2108function decodeDeltaFlags(flags: number): DeltaFlags {
2109 return {
2110 hidden: (flags & 1 << 0) !== 0,
2111 passive: (flags & 1 << 1) !== 0,
2112 showTotal: (flags & 1 << 2) !== 0,
2113 changedValue: (flags & 1 << 3) !== 0,
2114 changedTotal: (flags & 1 << 4) !== 0,
2115 changedLogs: (flags & 1 << 6) !== 0,
2116 changedText: (flags & 1 << 7) !== 0,
2117 changedChildren: (flags & 1 << 8) !== 0,
2118 changedEstimatedTime: (flags & 1 << 9) !== 0,
2119 changedFormattedValue: (flags & 1 << 10) !== 0,
2120 estimatedTimeNonNull: (flags & 1 << 11) !== 0,
2121 futureProofingPacket: (flags & 1 << 14) !== 0,
2122 deleted: (flags & 1 << 15) !== 0,
2123 };
2124}
2125
2126class KeyPool<T extends number> {
2127 next = 0;
2128 old: T[] = [];
2129
2130 get() {
2131 const existing = this.old.shift();
2132 if (existing) return existing as T;
2133 return (this.next += 1) as T;
2134 }
2135
2136 recycle(k: T) {
2137 this.old.push(k);
2138 }
2139}
2140
2141/** stateful estimation by providing samples */
2142export interface EstimationAlgorithm {
2143 /**
2144 * given a timestamp `time` and a progress value `progress`, return the
2145 * timestamp that the action will be finished on. called with (t, 0, 0)
2146 * to initialize the algorithm.
2147 */
2148 sample(time: number, current: number, total: number): number | null;
2149}
2150
2151/**
2152 * Exponential Moving Average.
2153 * this is the algorithm used for the default progress estimator.
2154 * https://en.wikipedia.org/wiki/Exponential_smoothing
2155 */
2156export class Ema implements EstimationAlgorithm {
2157 /** milliseconds */
2158 start: number | null = null;
2159 estimate: number | null = null;
2160 /** smoothing factor (0-1) */
2161 alpha: number;
2162 samples = 0;
2163
2164 constructor(alpha: number = 0.25) {
2165 this.alpha = alpha;
2166 }
2167
2168 sample(time: number, current: number, total: number): number | null {
2169 this.samples += 1;
2170
2171 ASSERT(current >= 0 && current <= total);
2172 if (this.start == null) {
2173 this.start = time;
2174 this.estimate = null;
2175 return null;
2176 }
2177
2178 const elapsed = time - this.start;
2179 if (elapsed === 0) return null; // too quick
2180
2181 const rate = current / elapsed;
2182 this.estimate = this.estimate != null
2183 ? (1 - this.alpha) * this.estimate + this.alpha * rate
2184 : rate;
2185 if (elapsed < 10_000 || this.estimate <= 0 || this.samples < 3) return null;
2186 const remaining = total - current;
2187 return time + remaining / this.estimate;
2188 }
2189
2190 reset(): void {
2191 this.start = null;
2192 this.estimate = null;
2193 }
2194}
2195
2196/** according to a stream */
2197type EncodedKey = number & { brand: typeof kNode };
2198
2199const globalKeyPool = new KeyPool<number>();
2200
2201/** names the pipe to a parent process that handed this one a place in its progress */
2202const ipcEnvVar = "CLOVER_PROGRESS";
2203
2204/**
2205 * the environment for a child process whose global root and log are to be
2206 * read by {@linkcode decodeAttachToRef}. `fd` is where the child has the pipe.
2207 */
2208export function ipcChildEnvironment(fd: number): Record<string, string> {
2209 return { [ipcEnvVar]: `${fd}:${UNWRAP(node.process).pid}` };
2210}
2211
2212export async function decodeAttachToRef(readable: ReadableStream<Uint8Array>, target: Ref): Promise<void> {
2213 const decoder = new Decoder(target, new Events(), noop);
2214 try {
2215 for await (const line of stream.lines(readable)) {
2216 const record: StreamEvent | log.Message = JSON.parse(line);
2217 if (Array.isArray(record)) decoder.processEvent(record);
2218 else log.writeMessage(record);
2219 }
2220 } finally {
2221 decoder.reset();
2222 }
2223}
2224
2225/**
2226 * the other end of {@linkcode decodeAttachToRef}. writes are synchronous, so that
2227 * the last words of a process are not lost when it exits.
2228 */
2229function tryAttachToParent(root: Root): boolean {
2230 const { process } = node;
2231 const fs = node.builtin("fs");
2232 const [fd, parent] = (process?.env[ipcEnvVar] ?? "").split(":").map(Number);
2233 // every descendant inherits the variable, but only the child has the pipe
2234 if (!process || !fs || !fd || process.ppid !== parent) return false;
2235
2236 const write = (record: StreamEvent | log.Message) => {
2237 try {
2238 fs.writeSync(fd, JSON.stringify(record) + "\n");
2239 } catch {
2240 // the parent is gone, and so is anyone to tell
2241 }
2242 };
2243
2244 const encoder = new Encoder(root);
2245 let timer: async.Cancelable<void> | null = null;
2246 const flush = () => {
2247 timer?.cancel();
2248 timer = null;
2249 write(encoder.getDelta());
2250 };
2251 const flushSoon = () => {
2252 if (timer) return;
2253 (timer = root.delay(1000 / 30)).then(flush);
2254 };
2255
2256 encoder.observe(flushSoon, flush);
2257 process.addListener("exit", () => timer && flush());
2258 // what changed on a node before a message is said stays before it
2259 log.replaceGlobalMessageDestination((m) => (timer && flush(), write({ ...m, stack: undefined })));
2260 return true;
2261}
2262
2263const global: Ref = /** @__PURE__ */ ((root = new Root()) => (
2264 tryAttachToParent(root) || attachToScreen(root, log), root
2265))();
2266
2267/**
2268 * a {@linkcode Ref} to the global progress root. unlike referencing the
2269 * namespace import, this value is tree-shakable.
2270 */
2271export const globalRoot: Ref = { start: (text, opts) => global.start(text, opts) };
2272
2273/**
2274 * for testing. not covered by semver
2275 * @internal
2276 */
2277export const internals: {
2278 readStreamEvent: typeof readStreamEvent;
2279 writeStreamEvent: typeof writeStreamEvent;
2280 kNode: symbol;
2281 EncodedKey: EncodedKey;
2282} = /** @__PURE__ */ (() => ({
2283 readStreamEvent,
2284 writeStreamEvent,
2285 kNode: kNode as symbol,
2286 EncodedKey: 0 as EncodedKey,
2287}))();
2288
2289import { ASSERT, UNWRAP } from "./assert.ts";
2290import * as async from "./async.ts";
2291import * as bytes from "./bytes.ts";
2292import { Events } from "./Events.ts";
2293import * as exceptions from "./exception.ts";
2294import * as log from "./log.ts";
2295import * as stack from "./log/stack.ts";
2296import * as math from "./math.ts";
2297import * as node from "./node.ts";
2298import * as stream from "./stream.ts";
2299import * as string from "./string.ts";
2300import * as ansi from "./string/ansi.ts";
2301import * as ts from "./ts.ts";