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> {
5050 channel: C,
5151 cb?: Events.Handler<Map[C]>,
5252 ): 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 }
5461 return async.makeCancelable(
55 new Promise((resolve) =>
56 dispose = this.on(channel, (...args) => {
57 UNWRAP(dispose)();
62 new Promise((resolve, reject) => {
63 disposeEvent = this.on(channel, (...args) => {
64 dispose();
5865 resolve(args);
5966 cb?.(...args);
60 })
61 ),
62 () => dispose?.(),
67 });
68 disposeError = this.on(channel, (...args) => {
69 dispose();
70 reject(args);
71 cb?.(...args);
72 });
73 }),
74 () => dispose(),
6375 );
6476 }
6577 /** listen for any event */
......@@ -71,7 +83,17 @@ export class Events<Map extends Events.Map> {
7183 }
7284 /** emit an event on the specified channel */
7385 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));
7597 }
7698}
7799
lib/Lru.ts+6-4
......@@ -2,10 +2,12 @@
22 * Least-recently-used cache.
33 * This module is intended to be imported via the main class.
44 *
5 * import { Lru } from '@clo/lib/Lru';
6 * // Extra types are in a `declare namespace`
7 * const opts: Lru.Options = { capacity: 2 };
8 * const lru = new Lru(opts);
5 * ```ts
6 * import { Lru } from '@clo/lib/Lru';
7 * // Extra types are in a `declare namespace`
8 * const opts: Lru.Options = { capacity: 2 };
9 * const lru = new Lru(opts);
10 * ```
911 *
1012 * @module
1113 */
lib/assert.ts+4-9
......@@ -3,6 +3,7 @@
33 * intended to be imported by symbol.
44 * @module
55 */
6/* node:coverage disable */
67
78/** assert that `condition` is truthy. */
89export function ASSERT(condition: unknown, msg?: Message): asserts condition {
......@@ -21,14 +22,6 @@ export function UNWRAP<T>(nullable: T | null | undefined, msg?: Message): T {
2122 return nullable;
2223}
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
3225/** assert that a `default` case in a switch is never reached. */
3326export function unreachableCase(value: never): never {
3427 throw new Error(`Unexpected value in switch: ${inspect(value as unknown)}`);
......@@ -44,8 +37,10 @@ function messageToString(msg: Message) {
4437
4538function inspect(object: unknown) {
4639 try {
47 return require("node:util").inspect(object);
40 return node.builtin("util")?.inspect(object) ?? typeof object;
4841 } catch {
4942 return typeof object;
5043 }
5144}
45
46import * as node from "./node.ts";
lib/async.ts+5-3
......@@ -99,7 +99,7 @@ export class Watch<T> {
9999
100100 until(condition: (v: T) => boolean): Promise<T> {
101101 if (condition(this.#value)) return Promise.resolve(this.#value);
102 return new Promise((resolve) => {
102 return new Promise<T>((resolve) => {
103103 function check(next: T) {
104104 if (condition(next)) resolve(next), release();
105105 }
......@@ -220,12 +220,14 @@ export class PromiseAggregator {
220220}
221221
222222/** 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
225227/** make an existing promise cancelable. */
226228export function makeCancelable<T>(
227229 promise: Promise<T>,
228 cancel: ts.VoidFunction,
230 cancel: (reason?: unknown) => void,
229231): Cancelable<T> {
230232 const p = promise as Cancelable<T>;
231233 p[Symbol.dispose] = cancel;
lib/mime.ts+5
......@@ -1,3 +1,8 @@
1/**
2 * a small mime type library.
3 * @module
4 */
5
16/**
27 * retrieves the 'Content-Type' header to use given a file extension.
38 * accepts:
lib/node.ts+1
......@@ -48,6 +48,7 @@ interface Builtins {
4848 sep: string;
4949 };
5050 "util": {
51 inspect(object: unknown): string;
5152 formatWithOptions(
5253 options: { colors?: boolean },
5354 ...args: unknown[]
lib/readme.changes.md+45-9
......@@ -4,20 +4,57 @@
44
55### breaking
66
7- promote `log/progress.ts` to top level `progress.ts`
8- rework Log dispatching
7- `log` dispatching reworked
98 - delete `log/headless.ts` by moving it into `log`
109 - headless scopes now emit `log.Message` objects instead of ANSI text,
1110 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
1323### features
1424
15- add `log.tee` (and `log.Scope.tee`)
16- `log` in Node.js will inject into `console.*` to prevent interweaving logs
17 with widget output text. this injection is enabled regardless of if widgets
18 are actually running, but do not otherwise change their behavior.
19- `log` scopes render differently in the terminal now.
20- `log` in the browser will call the correct log level function in the browser.
25- `progress` has expanded documentation and use cases.
26 - create headless nodes with `Root` (subclass of `Events`)
27 - streaming over the wire via `encodeEventStream`, `encodeEventStream`,
28 `encodeByteStream`, `encodeByteStream`. these APIs produce JSON and Binary
29 `ReadableStream`s. with work to make `log` better supported 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
2259## v2
2360
......@@ -33,7 +70,6 @@
3370### features
3471
3572- top level readme now explains general usage patterns. more docs overall.
36- new library `render`
3773- new library `async.ts`
3874- new library `Lru.ts`
3975- new library `mime.ts`
lib/render.ts+1-1
......@@ -413,7 +413,7 @@ export function clsx(mix: ClassName): string {
413413 } else if (typeof mix === "object") {
414414 if (Array.isArray(mix)) {
415415 for (k = 0; k < mix.length; k++) {
416 if (mix[k] && (y = clsx(mix[k]))) {
416 if (mix[k] && (y = clsx(mix[k]!))) {
417417 str && (str += " ");
418418 str += y;
419419 }
lib/string.test.ts+16
......@@ -29,6 +29,22 @@ test("escapeShellArgument", () => {
2929 assert.equal(string.escapeShellArgument("'string"), '"\'string"');
3030 assert.equal(string.escapeShellArgument("'str\"ing"), "\\''str\"ing'");
3131});
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
3349import { test } from "node:test";
3450import assert from "node:assert";
lib/string.ts+1-2
......@@ -1,4 +1,3 @@
1// TODO: this function is not benchmarked
21export function countNewlines(str: string): number {
32 let count = 0;
43 for (let i = 0, { length } = str; i < length; i += 1) {
......@@ -43,7 +42,7 @@ export function decodeUtf8(input: Uint8Array): string {
4342 return td.decode(input);
4443}
4544
46const byteUnits = "kMGTPEZYRQ";
45const byteUnits = "kMGTPEZYRQ"; // kilo, mega, giga, etc
4746export function formatByteSize(bytes: number) {
4847 if (!Number.isFinite(bytes)) return bytes.toString();
4948 let prefix = "";
lib/string/ansi.ts+9-3
......@@ -1,9 +1,9 @@
11/**
22 * 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 as
3 * interactions. this file contains many constants for escape codes, as well as
44 * functions for ones that take an argument, such as `cursorUp`.
55 *
6 * Note that this file only produces escape sequences, and does not yet feature
6 * note that this file only produces escape sequences, and does not yet feature
77 * detection or fallback code.
88 *
99 * @module
......@@ -203,11 +203,12 @@ export function mergeStyles(code: string): string {
203203export function debugAnsi(text: string): string {
204204 // deno-fmt-ignore
205205 const verbaitim: (keyof typeof self)[] = [
206 "fgCyan", "fgBlue",
206207 "bgReset", "bgBlack", "bgRed", "bgGreen", "bgYellow", "bgBlue", "bgMagenta",
207208 "bgCyan", "bgWhite", "bgBrightBlack", "bgBrightRed", "bgBrightGreen",
208209 "bgBrightYellow", "bgBrightBlue", "bgBrightMagenta", "bgBrightCyan",
209210 "bgBrightWhite", "fgReset", "fgBlack", "fgRed", "fgGreen", "fgYellow",
210 "fgBlue", "fgMagenta", "fgCyan", "fgWhite", "fgBrightBlack", "fgBrightRed",
211 "fgMagenta", "fgWhite", "fgBrightBlack", "fgBrightRed",
211212 "fgBrightGreen", "fgBrightYellow", "fgBrightBlue", "fgBrightMagenta",
212213 "fgBrightCyan", "fgBrightWhite", "clearToEndOfLine", "clearToStartOfLine",
213214 "clearFullLine", "syncStart", "syncEnd", "bold", "dim", "resetWeight"
......@@ -320,6 +321,11 @@ const zeroWidthClusterRegex =
320321const leadingNonPrintingRegex =
321322 /^[\p{Default_Ignorable_Code_Point}\p{Control}\p{Format}\p{Mark}\p{Surrogate}]+/v;
322323const rgiEmojiRegex = /^\p{RGI_Emoji}$/v;
324
325export function strip(text: string) {
326 return text.replace(ansiEscapes, "");
327}
328
323329function segmentWidth(segment: string) {
324330 if (zeroWidthClusterRegex.test(segment)) return 0;
325331 if (rgiEmojiRegex.test(segment)) return 2;
lib/subprocess/ffmpeg.ts+7-5
......@@ -6,15 +6,17 @@ export interface SpawnOptions {
66}
77
88/**
9 * Spawn an instance of `ffmpeg` and bind it's live status to the given
9 * spawn an instance of `ffmpeg` and bind it's live status to the given
1010 * `progress.Node`. Logs are routed through the progress node, and dismissed on
1111 * exit. `lib/progress.ts` will buffer and colocate the logs when running
1212 * parallel jobs.
1313 *
14 * await ffmpeg.spawn({
15 * cmd: ["-i", "hello.mov", "-c:v", "libsvtav1", "hello.mp4"],
16 * progress: progress.start("encode hello.mov"),
17 * });
14 * ```ts
15 * await ffmpeg.spawn({
16 * cmd: ["-i", "hello.mov", "-c:v", "libsvtav1", "hello.mp4"],
17 * progress: progress.start("encode hello.mov"),
18 * });
19 * ```
1820 */
1921export async function spawn(options: SpawnOptions) {
2022 const { ffmpeg = "ffmpeg", args, cwd } = options;
lib/testing.ts+39
......@@ -109,6 +109,45 @@ export class SyncPromise<T> implements Promise<T> {
109109 }
110110}
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
112151/** implements a log.HeadlessWidgetHost that acts as a fake screen. */
113152export class MockScreen implements Disposable, log.HeadlessWidgetHost {
114153 columns = 80;
run.js+1-1
......@@ -105,7 +105,7 @@ if (process.argv[1].startsWith(import.meta.filename.slice(0, -".js".length))) {
105105 );
106106 },
107107 );
108 } else {
108 } else if (!found.match(/\.test\.[tj]sx?$/)) {
109109 log.warn(
110110 hot.load("path").relative(".", found),
111111 'does not export a "main" function',
tsconfig.json+3-3
......@@ -5,7 +5,7 @@
55 "incremental": true,
66 "jsx": "react-jsxdev",
77 "jsxImportSource": "#jsx",
8 "lib": ["esnext", "dom", "dom.iterable"],
8 "lib": ["esnext", "es2024", "dom", "dom.iterable"],
99 "module": "nodenext",
1010 "noEmit": true,
1111 "outDir": ".clover/ts",
......@@ -14,9 +14,9 @@
1414 "skipLibCheck": true,
1515 "strict": true,
1616 "verbatimModuleSyntax": true,
17 "target": "es2024",
17 "target": "esnext",
1818 "noUncheckedIndexedAccess": true,
1919 "resolveJsonModule": true
2020 },
21 "include": ["framework/**/*", "src/**/*", "lib/**/*", "framework/global.d.ts"]
21 "include": ["lib/**/*"]
2222}