authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2025-10-15 23:36:01-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-01-14 01:08:17-08:00
log6b198b1e34ecf21148334df6eb9d79edf857b4db
treed040e191b916154458d0fda0ffed526bd76dbf21
parent82917100494eb8ac5a92d06fbc0dddadac986cef
signature Commit is signed but in an unrecognized format.

chore(lib): various tidying during progress development


15 files changed, 173 insertions(+), 48 deletions(-)

lib/Events.ts+30-8
...@@ -50,16 +50,28 @@ export class Events<Map extends Events.Map> {...@@ -50,16 +50,28 @@ export class Events<Map extends Events.Map> {
50 channel: C,50 channel: C,
51 cb?: Events.Handler<Map[C]>,51 cb?: Events.Handler<Map[C]>,
52 ): async.Cancelable<Map[C]> {52 ): async.Cancelable<Map[C]> {
53 let dispose: ts.Dispose | null = null;53 let disposeEvent: ts.Dispose | null = null;
54 let disposeError: ts.Dispose | null = null;
55 function dispose() {
56 if (!disposeEvent) return;
57 disposeEvent?.();
58 UNWRAP(disposeError)();
59 disposeEvent = disposeError = null;
60 }
54 return async.makeCancelable(61 return async.makeCancelable(
55 new Promise((resolve) =>62 new Promise((resolve, reject) => {
56 dispose = this.on(channel, (...args) => {63 disposeEvent = this.on(channel, (...args) => {
57 UNWRAP(dispose)();64 dispose();
58 resolve(args);65 resolve(args);
59 cb?.(...args);66 cb?.(...args);
60 })67 });
61 ),68 disposeError = this.on(channel, (...args) => {
62 () => dispose?.(),69 dispose();
70 reject(args);
71 cb?.(...args);
72 });
73 }),
74 () => dispose(),
63 );75 );
64 }76 }
65 /** listen for any event */77 /** listen for any event */
...@@ -71,7 +83,17 @@ export class Events<Map extends Events.Map> {...@@ -71,7 +83,17 @@ export class Events<Map extends Events.Map> {
71 }83 }
72 /** emit an event on the specified channel */84 /** emit an event on the specified channel */
73 emit<C extends keyof Map>(channel: C, ...args: Map[C]): void {85 emit<C extends keyof Map>(channel: C, ...args: Map[C]): void {
74 this.#channels[channel]?.forEach((cb) => cb(...args));86 const handlers = this.#channels[channel];
87 if (handlers) handlers.forEach((cb) => cb(...args));
88 else if (channel === "error") {
89 this.#global?.forEach((cb) => cb(channel, args));
90 throw args.length === 0
91 ? new Error("Error event emitted")
92 : args.length === 1
93 ? args[0]
94 : new AggregateError(args);
95 }
96 this.#global?.forEach((cb) => cb(channel, args));
75 }97 }
76}98}
7799
lib/Lru.ts+6-4
...@@ -2,10 +2,12 @@...@@ -2,10 +2,12 @@
2 * Least-recently-used cache.2 * Least-recently-used cache.
3 * This module is intended to be imported via the main class.3 * This module is intended to be imported via the main class.
4 *4 *
5 * import { Lru } from '@clo/lib/Lru';5 * ```ts
6 * // Extra types are in a `declare namespace`6 * import { Lru } from '@clo/lib/Lru';
7 * const opts: Lru.Options = { capacity: 2 };7 * // Extra types are in a `declare namespace`
8 * const lru = new Lru(opts);8 * const opts: Lru.Options = { capacity: 2 };
9 * const lru = new Lru(opts);
10 * ```
9 *11 *
10 * @module12 * @module
11 */13 */
lib/assert.ts+4-9
...@@ -3,6 +3,7 @@...@@ -3,6 +3,7 @@
3 * intended to be imported by symbol.3 * intended to be imported by symbol.
4 * @module4 * @module
5 */5 */
6/* node:coverage disable */
67
7/** assert that `condition` is truthy. */8/** assert that `condition` is truthy. */
8export function ASSERT(condition: unknown, msg?: Message): asserts condition {9export function ASSERT(condition: unknown, msg?: Message): asserts condition {
...@@ -21,14 +22,6 @@ export function UNWRAP<T>(nullable: T | null | undefined, msg?: Message): T {...@@ -21,14 +22,6 @@ export function UNWRAP<T>(nullable: T | null | undefined, msg?: Message): T {
21 return nullable;22 return nullable;
22}23}
2324
24/** assert that `number` is not NaN. */
25export function FINITE(number: number, msg?: Message): number {
26 if (Number.isNaN(number)) {
27 throw new Error(`Unexpected NaN${msg ? `: ${messageToString(msg)}` : ""}`);
28 }
29 return number;
30}
31
32/** assert that a `default` case in a switch is never reached. */25/** assert that a `default` case in a switch is never reached. */
33export function unreachableCase(value: never): never {26export function unreachableCase(value: never): never {
34 throw new Error(`Unexpected value in switch: ${inspect(value as unknown)}`);27 throw new Error(`Unexpected value in switch: ${inspect(value as unknown)}`);
...@@ -44,8 +37,10 @@ function messageToString(msg: Message) {...@@ -44,8 +37,10 @@ function messageToString(msg: Message) {
4437
45function inspect(object: unknown) {38function inspect(object: unknown) {
46 try {39 try {
47 return require("node:util").inspect(object);40 return node.builtin("util")?.inspect(object) ?? typeof object;
48 } catch {41 } catch {
49 return typeof object;42 return typeof object;
50 }43 }
51}44}
45
46import * as node from "./node.ts";
lib/async.ts+5-3
...@@ -99,7 +99,7 @@ export class Watch<T> {...@@ -99,7 +99,7 @@ export class Watch<T> {
9999
100 until(condition: (v: T) => boolean): Promise<T> {100 until(condition: (v: T) => boolean): Promise<T> {
101 if (condition(this.#value)) return Promise.resolve(this.#value);101 if (condition(this.#value)) return Promise.resolve(this.#value);
102 return new Promise((resolve) => {102 return new Promise<T>((resolve) => {
103 function check(next: T) {103 function check(next: T) {
104 if (condition(next)) resolve(next), release();104 if (condition(next)) resolve(next), release();
105 }105 }
...@@ -220,12 +220,14 @@ export class PromiseAggregator {...@@ -220,12 +220,14 @@ export class PromiseAggregator {
220}220}
221221
222/** a promise that can be cancelled. */222/** a promise that can be cancelled. */
223export type Cancelable<T> = Promise<T> & Disposable & { cancel: VoidFunction };223export type Cancelable<T> = Promise<T> & Disposable & {
224 cancel: (reason?: unknown) => void;
225};
224226
225/** make an existing promise cancelable. */227/** make an existing promise cancelable. */
226export function makeCancelable<T>(228export function makeCancelable<T>(
227 promise: Promise<T>,229 promise: Promise<T>,
228 cancel: ts.VoidFunction,230 cancel: (reason?: unknown) => void,
229): Cancelable<T> {231): Cancelable<T> {
230 const p = promise as Cancelable<T>;232 const p = promise as Cancelable<T>;
231 p[Symbol.dispose] = cancel;233 p[Symbol.dispose] = cancel;
lib/mime.ts+5
...@@ -1,3 +1,8 @@...@@ -1,3 +1,8 @@
1/**
2 * a small mime type library.
3 * @module
4 */
5
1/**6/**
2 * retrieves the 'Content-Type' header to use given a file extension.7 * retrieves the 'Content-Type' header to use given a file extension.
3 * accepts:8 * accepts:
lib/node.ts+1
...@@ -48,6 +48,7 @@ interface Builtins {...@@ -48,6 +48,7 @@ interface Builtins {
48 sep: string;48 sep: string;
49 };49 };
50 "util": {50 "util": {
51 inspect(object: unknown): string;
51 formatWithOptions(52 formatWithOptions(
52 options: { colors?: boolean },53 options: { colors?: boolean },
53 ...args: unknown[]54 ...args: unknown[]
lib/readme.changes.md+45-9
...@@ -4,20 +4,57 @@...@@ -4,20 +4,57 @@
44
5### breaking5### breaking
66
7- promote `log/progress.ts` to top level `progress.ts`7- `log` dispatching reworked
8- rework Log dispatching
9 - delete `log/headless.ts` by moving it into `log`8 - delete `log/headless.ts` by moving it into `log`
10 - headless scopes now emit `log.Message` objects instead of ANSI text,9 - headless scopes now emit `log.Message` objects instead of ANSI text,
11 templating is done in the consumer of the headless logging scope.10 templating is done in the consumer of the headless logging scope.
11 - rename `HeadlessWidgetEnv.wait` to `delay`, matching `async.delay`
12- `progress` had everything besides `start` and `Node` heavily altered
13 - promote to top level namespace `lib/progress.ts`
14 - `Node.estimate` -> `Node.total`
15 - rename `.null` to `.nullNode`
16 - `.SortNode` -> `.ReadOnlyNode`
17 - `.getUnicodeBar` -> `.formatUnicodeBar`
18 - rework headless rendering story, rename `.Headless` -> `.Root`
19 - `.rootFromWidgetHost` to source from a widget host
20 - remove `.internals`, use `.Root`
21- deleted `assert.FINITE`
1222
13### features23### features
1424
15- add `log.tee` (and `log.Scope.tee`)25- `progress` has expanded documentation and use cases.
16- `log` in Node.js will inject into `console.*` to prevent interweaving logs26 - create headless nodes with `Root` (subclass of `Events`)
17 with widget output text. this injection is enabled regardless of if widgets27 - streaming over the wire via `encodeEventStream`, `encodeEventStream`,
18 are actually running, but do not otherwise change their behavior.28 `encodeByteStream`, `encodeByteStream`. these APIs produce JSON and Binary
19- `log` scopes render differently in the terminal now.29 `ReadableStream`s. with work to make `log` better supported in the browser,
20- `log` in the browser will call the correct log level function in the browser.30 progress streams are fully consumable from within a web browser.
31 - `.attach` to display a decoded root
32 - expose `.formatAnsi` for external use
33- `log` slowly moves towards maturity
34 - add `.tee` (and `log.Scope.tee`)
35 - in Node.js, `log` will inject into `console.*` to prevent interweaving logs
36 with widget output text. this injection is enabled regardless of if widgets
37 are actually running, but do not otherwise change their behavior.
38 - `log` scopes render differently in the terminal now
39 - browser support by calling the correct `console` api to signal warnings and
40 errors correctly.
41 - fix assertion failure when creating a widget in the same frame a log message
42 is written.
43- new library `Events.ts`
44- new library `bytes.ts` (subject to being moved as it is one function)
45- new library `stream.ts`
46- new library `testing.ts`
47- `string`
48 - add `encodeUtf8` and `decodeUtf8`
49 - add `formatByteSize` and `formatBinaryByteSize` and `formatDurationLetters`,
50 all subject to change as `progress` is used more.
51- `async`
52 - add `.Cancelable` type, constructed with `.makeCancelable`
53 - add `.delay`
54- `ts`
55 - add `EmptyObject`
56 - add `Json`, `ToJson`, and `JsonValue`
57 - add `mixin`
2158
22## v259## v2
2360
...@@ -33,7 +70,6 @@...@@ -33,7 +70,6 @@
33### features70### features
3471
35- top level readme now explains general usage patterns. more docs overall.72- top level readme now explains general usage patterns. more docs overall.
36- new library `render`
37- new library `async.ts`73- new library `async.ts`
38- new library `Lru.ts`74- new library `Lru.ts`
39- new library `mime.ts`75- new library `mime.ts`
lib/render.ts+1-1
...@@ -413,7 +413,7 @@ export function clsx(mix: ClassName): string {...@@ -413,7 +413,7 @@ export function clsx(mix: ClassName): string {
413 } else if (typeof mix === "object") {413 } else if (typeof mix === "object") {
414 if (Array.isArray(mix)) {414 if (Array.isArray(mix)) {
415 for (k = 0; k < mix.length; k++) {415 for (k = 0; k < mix.length; k++) {
416 if (mix[k] && (y = clsx(mix[k]))) {416 if (mix[k] && (y = clsx(mix[k]!))) {
417 str && (str += " ");417 str && (str += " ");
418 str += y;418 str += y;
419 }419 }
lib/string.test.ts+16
...@@ -29,6 +29,22 @@ test("escapeShellArgument", () => {...@@ -29,6 +29,22 @@ test("escapeShellArgument", () => {
29 assert.equal(string.escapeShellArgument("'string"), '"\'string"');29 assert.equal(string.escapeShellArgument("'string"), '"\'string"');
30 assert.equal(string.escapeShellArgument("'str\"ing"), "\\''str\"ing'");30 assert.equal(string.escapeShellArgument("'str\"ing"), "\\''str\"ing'");
31});31});
32test("formatByteSize", () => {
33 assert.equal(string.formatByteSize(0), "0B");
34 assert.equal(string.formatByteSize(1), "1B");
35 assert.equal(string.formatByteSize(1000), "1kB");
36 assert.equal(string.formatByteSize(-1000), "-1kB");
37 assert.equal(string.formatByteSize(1500), "1.5kB");
38 assert.equal(string.formatByteSize(1578), "1.58kB");
39 assert.equal(string.formatByteSize(5_100_000), "5.1MB");
40 assert.equal(string.formatByteSize(-7_231_692_881), "-7.23GB");
41 assert.equal(string.formatByteSize(-4_294_967_296), "-7.23GB");
42
43 assert.equal(string.formatByteSize(53.251), "53.25B");
44 assert.equal(string.formatByteSize(Infinity), "Infinity");
45 assert.equal(string.formatByteSize(-Infinity), "-Infinity");
46 assert.equal(string.formatByteSize(NaN), "NaN");
47});
3248
33import { test } from "node:test";49import { test } from "node:test";
34import assert from "node:assert";50import assert from "node:assert";
lib/string.ts+1-2
...@@ -1,4 +1,3 @@...@@ -1,4 +1,3 @@
1// TODO: this function is not benchmarked
2export function countNewlines(str: string): number {1export function countNewlines(str: string): number {
3 let count = 0;2 let count = 0;
4 for (let i = 0, { length } = str; i < length; i += 1) {3 for (let i = 0, { length } = str; i < length; i += 1) {
...@@ -43,7 +42,7 @@ export function decodeUtf8(input: Uint8Array): string {...@@ -43,7 +42,7 @@ export function decodeUtf8(input: Uint8Array): string {
43 return td.decode(input);42 return td.decode(input);
44}43}
4544
46const byteUnits = "kMGTPEZYRQ";45const byteUnits = "kMGTPEZYRQ"; // kilo, mega, giga, etc
47export function formatByteSize(bytes: number) {46export function formatByteSize(bytes: number) {
48 if (!Number.isFinite(bytes)) return bytes.toString();47 if (!Number.isFinite(bytes)) return bytes.toString();
49 let prefix = "";48 let prefix = "";
lib/string/ansi.ts+9-3
...@@ -1,9 +1,9 @@...@@ -1,9 +1,9 @@
1/**1/**
2 * ANSI escape sequences can be used to style text in a tty or perform other2 * ANSI escape sequences can be used to style text in a tty or perform other
3 * interactions. This file contains many constants for escape codes, as well as3 * interactions. this file contains many constants for escape codes, as well as
4 * functions for ones that take an argument, such as `cursorUp`.4 * functions for ones that take an argument, such as `cursorUp`.
5 *5 *
6 * Note that this file only produces escape sequences, and does not yet feature6 * note that this file only produces escape sequences, and does not yet feature
7 * detection or fallback code.7 * detection or fallback code.
8 *8 *
9 * @module9 * @module
...@@ -203,11 +203,12 @@ export function mergeStyles(code: string): string {...@@ -203,11 +203,12 @@ export function mergeStyles(code: string): string {
203export function debugAnsi(text: string): string {203export function debugAnsi(text: string): string {
204 // deno-fmt-ignore204 // deno-fmt-ignore
205 const verbaitim: (keyof typeof self)[] = [205 const verbaitim: (keyof typeof self)[] = [
206 "fgCyan", "fgBlue",
206 "bgReset", "bgBlack", "bgRed", "bgGreen", "bgYellow", "bgBlue", "bgMagenta",207 "bgReset", "bgBlack", "bgRed", "bgGreen", "bgYellow", "bgBlue", "bgMagenta",
207 "bgCyan", "bgWhite", "bgBrightBlack", "bgBrightRed", "bgBrightGreen",208 "bgCyan", "bgWhite", "bgBrightBlack", "bgBrightRed", "bgBrightGreen",
208 "bgBrightYellow", "bgBrightBlue", "bgBrightMagenta", "bgBrightCyan",209 "bgBrightYellow", "bgBrightBlue", "bgBrightMagenta", "bgBrightCyan",
209 "bgBrightWhite", "fgReset", "fgBlack", "fgRed", "fgGreen", "fgYellow",210 "bgBrightWhite", "fgReset", "fgBlack", "fgRed", "fgGreen", "fgYellow",
210 "fgBlue", "fgMagenta", "fgCyan", "fgWhite", "fgBrightBlack", "fgBrightRed",211 "fgMagenta", "fgWhite", "fgBrightBlack", "fgBrightRed",
211 "fgBrightGreen", "fgBrightYellow", "fgBrightBlue", "fgBrightMagenta",212 "fgBrightGreen", "fgBrightYellow", "fgBrightBlue", "fgBrightMagenta",
212 "fgBrightCyan", "fgBrightWhite", "clearToEndOfLine", "clearToStartOfLine",213 "fgBrightCyan", "fgBrightWhite", "clearToEndOfLine", "clearToStartOfLine",
213 "clearFullLine", "syncStart", "syncEnd", "bold", "dim", "resetWeight"214 "clearFullLine", "syncStart", "syncEnd", "bold", "dim", "resetWeight"
...@@ -320,6 +321,11 @@ const zeroWidthClusterRegex =...@@ -320,6 +321,11 @@ const zeroWidthClusterRegex =
320const leadingNonPrintingRegex =321const leadingNonPrintingRegex =
321 /^[\p{Default_Ignorable_Code_Point}\p{Control}\p{Format}\p{Mark}\p{Surrogate}]+/v;322 /^[\p{Default_Ignorable_Code_Point}\p{Control}\p{Format}\p{Mark}\p{Surrogate}]+/v;
322const rgiEmojiRegex = /^\p{RGI_Emoji}$/v;323const rgiEmojiRegex = /^\p{RGI_Emoji}$/v;
324
325export function strip(text: string) {
326 return text.replace(ansiEscapes, "");
327}
328
323function segmentWidth(segment: string) {329function segmentWidth(segment: string) {
324 if (zeroWidthClusterRegex.test(segment)) return 0;330 if (zeroWidthClusterRegex.test(segment)) return 0;
325 if (rgiEmojiRegex.test(segment)) return 2;331 if (rgiEmojiRegex.test(segment)) return 2;
lib/subprocess/ffmpeg.ts+7-5
...@@ -6,15 +6,17 @@ export interface SpawnOptions {...@@ -6,15 +6,17 @@ export interface SpawnOptions {
6}6}
77
8/**8/**
9 * Spawn an instance of `ffmpeg` and bind it's live status to the given9 * spawn an instance of `ffmpeg` and bind it's live status to the given
10 * `progress.Node`. Logs are routed through the progress node, and dismissed on10 * `progress.Node`. Logs are routed through the progress node, and dismissed on
11 * exit. `lib/progress.ts` will buffer and colocate the logs when running11 * exit. `lib/progress.ts` will buffer and colocate the logs when running
12 * parallel jobs.12 * parallel jobs.
13 *13 *
14 * await ffmpeg.spawn({14 * ```ts
15 * cmd: ["-i", "hello.mov", "-c:v", "libsvtav1", "hello.mp4"],15 * await ffmpeg.spawn({
16 * progress: progress.start("encode hello.mov"),16 * cmd: ["-i", "hello.mov", "-c:v", "libsvtav1", "hello.mp4"],
17 * });17 * progress: progress.start("encode hello.mov"),
18 * });
19 * ```
18 */20 */
19export async function spawn(options: SpawnOptions) {21export async function spawn(options: SpawnOptions) {
20 const { ffmpeg = "ffmpeg", args, cwd } = options;22 const { ffmpeg = "ffmpeg", args, cwd } = options;
lib/testing.ts+39
...@@ -109,6 +109,45 @@ export class SyncPromise<T> implements Promise<T> {...@@ -109,6 +109,45 @@ export class SyncPromise<T> implements Promise<T> {
109 }109 }
110}110}
111111
112export class FakeTimers {
113 time = 0;
114 timers: Array<{
115 duration: number;
116 resolve: () => void;
117 src: stack.Frame[];
118 }> = [];
119 waitTime = 0;
120
121 now = () => {
122 return this.time;
123 };
124 delay: typeof async.delay = (ms) => {
125 const src = stack.capture(2);
126 return async.makeCancelable(
127 new SyncPromise((resolve) => {
128 this.timers.push({
129 duration: ms - this.waitTime,
130 resolve,
131 src,
132 });
133 this.waitTime = ms;
134 }),
135 () => {
136 throw new Error("TODO");
137 },
138 );
139 };
140}
141
142export function removeUndefinedKeys(obj: unknown): unknown {
143 if (typeof obj !== "object" || !obj || Array.isArray(obj)) return obj;
144 return Object.fromEntries(
145 Object.getOwnPropertyNames(obj)
146 .map((k) => [k, (obj as any)[k]])
147 .filter(([, v]) => v !== undefined),
148 );
149}
150
112/** implements a log.HeadlessWidgetHost that acts as a fake screen. */151/** implements a log.HeadlessWidgetHost that acts as a fake screen. */
113export class MockScreen implements Disposable, log.HeadlessWidgetHost {152export class MockScreen implements Disposable, log.HeadlessWidgetHost {
114 columns = 80;153 columns = 80;
run.js+1-1
...@@ -105,7 +105,7 @@ if (process.argv[1].startsWith(import.meta.filename.slice(0, -".js".length))) {...@@ -105,7 +105,7 @@ if (process.argv[1].startsWith(import.meta.filename.slice(0, -".js".length))) {
105 );105 );
106 },106 },
107 );107 );
108 } else {108 } else if (!found.match(/\.test\.[tj]sx?$/)) {
109 log.warn(109 log.warn(
110 hot.load("path").relative(".", found),110 hot.load("path").relative(".", found),
111 'does not export a "main" function',111 'does not export a "main" function',
tsconfig.json+3-3
...@@ -5,7 +5,7 @@...@@ -5,7 +5,7 @@
5 "incremental": true,5 "incremental": true,
6 "jsx": "react-jsxdev",6 "jsx": "react-jsxdev",
7 "jsxImportSource": "#jsx",7 "jsxImportSource": "#jsx",
8 "lib": ["esnext", "dom", "dom.iterable"],8 "lib": ["esnext", "es2024", "dom", "dom.iterable"],
9 "module": "nodenext",9 "module": "nodenext",
10 "noEmit": true,10 "noEmit": true,
11 "outDir": ".clover/ts",11 "outDir": ".clover/ts",
...@@ -14,9 +14,9 @@...@@ -14,9 +14,9 @@
14 "skipLibCheck": true,14 "skipLibCheck": true,
15 "strict": true,15 "strict": true,
16 "verbatimModuleSyntax": true,16 "verbatimModuleSyntax": true,
17 "target": "es2024",17 "target": "esnext",
18 "noUncheckedIndexedAccess": true,18 "noUncheckedIndexedAccess": true,
19 "resolveJsonModule": true19 "resolveJsonModule": true
20 },20 },
21 "include": ["framework/**/*", "src/**/*", "lib/**/*", "framework/global.d.ts"]21 "include": ["lib/**/*"]
22}22}