From 6b198b1e34ecf21148334df6eb9d79edf857b4db Mon Sep 17 00:00:00 2001 From: clover caruso Date: Wed, 15 Oct 2025 23:36:01 -0700 Subject: [PATCH] chore(lib): various tidying during progress development --- lib/Events.ts | 38 ++++++++++++++++++++++------ lib/Lru.ts | 10 +++++--- lib/assert.ts | 13 +++------- lib/async.ts | 8 +++--- lib/mime.ts | 5 ++++ lib/node.ts | 1 + lib/readme.changes.md | 54 +++++++++++++++++++++++++++++++++------- lib/render.ts | 2 +- lib/string.test.ts | 16 ++++++++++++ lib/string.ts | 3 +-- lib/string/ansi.ts | 12 ++++++--- lib/subprocess/ffmpeg.ts | 12 +++++---- lib/testing.ts | 39 +++++++++++++++++++++++++++++ run.js | 2 +- tsconfig.json | 6 ++--- 15 files changed, 173 insertions(+), 48 deletions(-) diff --git a/lib/Events.ts b/lib/Events.ts index ab97b49784723a658ce82ca5c2fef66772f25983..0ad6b86143d7cfc027a49fb25b2e50f5948377e8 100644 --- a/lib/Events.ts +++ b/lib/Events.ts @@ -50,16 +50,28 @@ export class Events { channel: C, cb?: Events.Handler, ): async.Cancelable { - let dispose: ts.Dispose | null = null; + let disposeEvent: ts.Dispose | null = null; + let disposeError: ts.Dispose | null = null; + function dispose() { + if (!disposeEvent) return; + disposeEvent?.(); + UNWRAP(disposeError)(); + disposeEvent = disposeError = null; + } return async.makeCancelable( - new Promise((resolve) => - dispose = this.on(channel, (...args) => { - UNWRAP(dispose)(); + new Promise((resolve, reject) => { + disposeEvent = this.on(channel, (...args) => { + dispose(); resolve(args); cb?.(...args); - }) - ), - () => dispose?.(), + }); + disposeError = this.on(channel, (...args) => { + dispose(); + reject(args); + cb?.(...args); + }); + }), + () => dispose(), ); } /** listen for any event */ @@ -71,7 +83,17 @@ export class Events { } /** emit an event on the specified channel */ emit(channel: C, ...args: Map[C]): void { - this.#channels[channel]?.forEach((cb) => cb(...args)); + const handlers = this.#channels[channel]; + if (handlers) handlers.forEach((cb) => cb(...args)); + else if (channel === "error") { + this.#global?.forEach((cb) => cb(channel, args)); + throw args.length === 0 + ? new Error("Error event emitted") + : args.length === 1 + ? args[0] + : new AggregateError(args); + } + this.#global?.forEach((cb) => cb(channel, args)); } } diff --git a/lib/Lru.ts b/lib/Lru.ts index 4007ab7b9d45337bcb7b738d50cb51547a61a172..474f484ba3fba40382c423fc829342a4c79741c1 100644 --- a/lib/Lru.ts +++ b/lib/Lru.ts @@ -2,10 +2,12 @@ * Least-recently-used cache. * This module is intended to be imported via the main class. * - * import { Lru } from '@clo/lib/Lru'; - * // Extra types are in a `declare namespace` - * const opts: Lru.Options = { capacity: 2 }; - * const lru = new Lru(opts); + * ```ts + * import { Lru } from '@clo/lib/Lru'; + * // Extra types are in a `declare namespace` + * const opts: Lru.Options = { capacity: 2 }; + * const lru = new Lru(opts); + * ``` * * @module */ diff --git a/lib/assert.ts b/lib/assert.ts index 3ec06206cc3aefde3e46901669e2cbef1984fa9f..63121f65fe39f7c2d3e39fbfce4201d2226cbe51 100644 --- a/lib/assert.ts +++ b/lib/assert.ts @@ -3,6 +3,7 @@ * intended to be imported by symbol. * @module */ +/* node:coverage disable */ /** assert that `condition` is truthy. */ export function ASSERT(condition: unknown, msg?: Message): asserts condition { @@ -21,14 +22,6 @@ export function UNWRAP(nullable: T | null | undefined, msg?: Message): T { return nullable; } -/** assert that `number` is not NaN. */ -export function FINITE(number: number, msg?: Message): number { - if (Number.isNaN(number)) { - throw new Error(`Unexpected NaN${msg ? `: ${messageToString(msg)}` : ""}`); - } - return number; -} - /** assert that a `default` case in a switch is never reached. */ export function unreachableCase(value: never): never { throw new Error(`Unexpected value in switch: ${inspect(value as unknown)}`); @@ -44,8 +37,10 @@ function messageToString(msg: Message) { function inspect(object: unknown) { try { - return require("node:util").inspect(object); + return node.builtin("util")?.inspect(object) ?? typeof object; } catch { return typeof object; } } + +import * as node from "./node.ts"; diff --git a/lib/async.ts b/lib/async.ts index 3bfa34cd1792eb3b7109f433d3b8481006ca3620..e1f2eaf14d3bd48d4e8e7fff2cef01b74b750da8 100644 --- a/lib/async.ts +++ b/lib/async.ts @@ -99,7 +99,7 @@ export class Watch { until(condition: (v: T) => boolean): Promise { if (condition(this.#value)) return Promise.resolve(this.#value); - return new Promise((resolve) => { + return new Promise((resolve) => { function check(next: T) { if (condition(next)) resolve(next), release(); } @@ -220,12 +220,14 @@ export class PromiseAggregator { } /** a promise that can be cancelled. */ -export type Cancelable = Promise & Disposable & { cancel: VoidFunction }; +export type Cancelable = Promise & Disposable & { + cancel: (reason?: unknown) => void; +}; /** make an existing promise cancelable. */ export function makeCancelable( promise: Promise, - cancel: ts.VoidFunction, + cancel: (reason?: unknown) => void, ): Cancelable { const p = promise as Cancelable; p[Symbol.dispose] = cancel; diff --git a/lib/mime.ts b/lib/mime.ts index 9bfa1809eba7798d8c6f4bd76addb38ebb2f9dc9..ef3aab87eb22c8f831ab2282dffa243d71f1833c 100644 --- a/lib/mime.ts +++ b/lib/mime.ts @@ -1,3 +1,8 @@ +/** + * a small mime type library. + * @module + */ + /** * retrieves the 'Content-Type' header to use given a file extension. * accepts: diff --git a/lib/node.ts b/lib/node.ts index 0ab1922b59eb0132dbfca554435135ea874a9f29..b09aafb0cf1f287d1b00b095bbce87159c99ca39 100644 --- a/lib/node.ts +++ b/lib/node.ts @@ -48,6 +48,7 @@ interface Builtins { sep: string; }; "util": { + inspect(object: unknown): string; formatWithOptions( options: { colors?: boolean }, ...args: unknown[] diff --git a/lib/readme.changes.md b/lib/readme.changes.md index c0dde3a0978b1150903e5097599a74c97e90cb85..b46d16790b07c0e85444452528c1dcc37222e4b3 100644 --- a/lib/readme.changes.md +++ b/lib/readme.changes.md @@ -4,20 +4,57 @@ ### breaking -- promote `log/progress.ts` to top level `progress.ts` -- rework Log dispatching +- `log` dispatching reworked - delete `log/headless.ts` by moving it into `log` - headless scopes now emit `log.Message` objects instead of ANSI text, templating is done in the consumer of the headless logging scope. + - rename `HeadlessWidgetEnv.wait` to `delay`, matching `async.delay` +- `progress` had everything besides `start` and `Node` heavily altered + - promote to top level namespace `lib/progress.ts` + - `Node.estimate` -> `Node.total` + - rename `.null` to `.nullNode` + - `.SortNode` -> `.ReadOnlyNode` + - `.getUnicodeBar` -> `.formatUnicodeBar` + - rework headless rendering story, rename `.Headless` -> `.Root` + - `.rootFromWidgetHost` to source from a widget host + - remove `.internals`, use `.Root` +- deleted `assert.FINITE` ### features -- add `log.tee` (and `log.Scope.tee`) -- `log` in Node.js will inject into `console.*` to prevent interweaving logs - with widget output text. this injection is enabled regardless of if widgets - are actually running, but do not otherwise change their behavior. -- `log` scopes render differently in the terminal now. -- `log` in the browser will call the correct log level function in the browser. +- `progress` has expanded documentation and use cases. + - create headless nodes with `Root` (subclass of `Events`) + - streaming over the wire via `encodeEventStream`, `encodeEventStream`, + `encodeByteStream`, `encodeByteStream`. these APIs produce JSON and Binary + `ReadableStream`s. with work to make `log` better supported in the browser, + progress streams are fully consumable from within a web browser. + - `.attach` to display a decoded root + - expose `.formatAnsi` for external use +- `log` slowly moves towards maturity + - add `.tee` (and `log.Scope.tee`) + - in Node.js, `log` will inject into `console.*` to prevent interweaving logs + with widget output text. this injection is enabled regardless of if widgets + are actually running, but do not otherwise change their behavior. + - `log` scopes render differently in the terminal now + - browser support by calling the correct `console` api to signal warnings and + errors correctly. + - fix assertion failure when creating a widget in the same frame a log message + is written. +- new library `Events.ts` +- new library `bytes.ts` (subject to being moved as it is one function) +- new library `stream.ts` +- new library `testing.ts` +- `string` + - add `encodeUtf8` and `decodeUtf8` + - add `formatByteSize` and `formatBinaryByteSize` and `formatDurationLetters`, + all subject to change as `progress` is used more. +- `async` + - add `.Cancelable` type, constructed with `.makeCancelable` + - add `.delay` +- `ts` + - add `EmptyObject` + - add `Json`, `ToJson`, and `JsonValue` + - add `mixin` ## v2 @@ -33,7 +70,6 @@ ### features - top level readme now explains general usage patterns. more docs overall. -- new library `render` - new library `async.ts` - new library `Lru.ts` - new library `mime.ts` diff --git a/lib/render.ts b/lib/render.ts index 2758a25b5f6ef48c3310ae9ff594549754076215..80cdba64d2d1dcb224065e5b51a870f43dc6d4ca 100644 --- a/lib/render.ts +++ b/lib/render.ts @@ -413,7 +413,7 @@ export function clsx(mix: ClassName): string { } else if (typeof mix === "object") { if (Array.isArray(mix)) { for (k = 0; k < mix.length; k++) { - if (mix[k] && (y = clsx(mix[k]))) { + if (mix[k] && (y = clsx(mix[k]!))) { str && (str += " "); str += y; } diff --git a/lib/string.test.ts b/lib/string.test.ts index 1f9be414a6d02ba777e6076c0933c8c36787046b..b35b99881f27da511f89f88bbfcd86ad5dcf3543 100644 --- a/lib/string.test.ts +++ b/lib/string.test.ts @@ -29,6 +29,22 @@ test("escapeShellArgument", () => { assert.equal(string.escapeShellArgument("'string"), '"\'string"'); assert.equal(string.escapeShellArgument("'str\"ing"), "\\''str\"ing'"); }); +test("formatByteSize", () => { + assert.equal(string.formatByteSize(0), "0B"); + assert.equal(string.formatByteSize(1), "1B"); + assert.equal(string.formatByteSize(1000), "1kB"); + assert.equal(string.formatByteSize(-1000), "-1kB"); + assert.equal(string.formatByteSize(1500), "1.5kB"); + assert.equal(string.formatByteSize(1578), "1.58kB"); + assert.equal(string.formatByteSize(5_100_000), "5.1MB"); + assert.equal(string.formatByteSize(-7_231_692_881), "-7.23GB"); + assert.equal(string.formatByteSize(-4_294_967_296), "-7.23GB"); + + assert.equal(string.formatByteSize(53.251), "53.25B"); + assert.equal(string.formatByteSize(Infinity), "Infinity"); + assert.equal(string.formatByteSize(-Infinity), "-Infinity"); + assert.equal(string.formatByteSize(NaN), "NaN"); +}); import { test } from "node:test"; import assert from "node:assert"; diff --git a/lib/string.ts b/lib/string.ts index f97eca0b73ea9e07052f339fe39a92b01df1d663..a371a900da1a589640c381d2d32f2d7e75f249b0 100644 --- a/lib/string.ts +++ b/lib/string.ts @@ -1,4 +1,3 @@ -// TODO: this function is not benchmarked export function countNewlines(str: string): number { let count = 0; for (let i = 0, { length } = str; i < length; i += 1) { @@ -43,7 +42,7 @@ export function decodeUtf8(input: Uint8Array): string { return td.decode(input); } -const byteUnits = "kMGTPEZYRQ"; +const byteUnits = "kMGTPEZYRQ"; // kilo, mega, giga, etc export function formatByteSize(bytes: number) { if (!Number.isFinite(bytes)) return bytes.toString(); let prefix = ""; diff --git a/lib/string/ansi.ts b/lib/string/ansi.ts index ac87c2301c6a728f00b0f49dace8055dea221e45..410802741dea832e021c5a7d9bbd983114940494 100644 --- a/lib/string/ansi.ts +++ b/lib/string/ansi.ts @@ -1,9 +1,9 @@ /** * ANSI escape sequences can be used to style text in a tty or perform other - * interactions. This file contains many constants for escape codes, as well as + * interactions. this file contains many constants for escape codes, as well as * functions for ones that take an argument, such as `cursorUp`. * - * Note that this file only produces escape sequences, and does not yet feature + * note that this file only produces escape sequences, and does not yet feature * detection or fallback code. * * @module @@ -203,11 +203,12 @@ export function mergeStyles(code: string): string { export function debugAnsi(text: string): string { // deno-fmt-ignore const verbaitim: (keyof typeof self)[] = [ + "fgCyan", "fgBlue", "bgReset", "bgBlack", "bgRed", "bgGreen", "bgYellow", "bgBlue", "bgMagenta", "bgCyan", "bgWhite", "bgBrightBlack", "bgBrightRed", "bgBrightGreen", "bgBrightYellow", "bgBrightBlue", "bgBrightMagenta", "bgBrightCyan", "bgBrightWhite", "fgReset", "fgBlack", "fgRed", "fgGreen", "fgYellow", - "fgBlue", "fgMagenta", "fgCyan", "fgWhite", "fgBrightBlack", "fgBrightRed", + "fgMagenta", "fgWhite", "fgBrightBlack", "fgBrightRed", "fgBrightGreen", "fgBrightYellow", "fgBrightBlue", "fgBrightMagenta", "fgBrightCyan", "fgBrightWhite", "clearToEndOfLine", "clearToStartOfLine", "clearFullLine", "syncStart", "syncEnd", "bold", "dim", "resetWeight" @@ -320,6 +321,11 @@ const zeroWidthClusterRegex = const leadingNonPrintingRegex = /^[\p{Default_Ignorable_Code_Point}\p{Control}\p{Format}\p{Mark}\p{Surrogate}]+/v; const rgiEmojiRegex = /^\p{RGI_Emoji}$/v; + +export function strip(text: string) { + return text.replace(ansiEscapes, ""); +} + function segmentWidth(segment: string) { if (zeroWidthClusterRegex.test(segment)) return 0; if (rgiEmojiRegex.test(segment)) return 2; diff --git a/lib/subprocess/ffmpeg.ts b/lib/subprocess/ffmpeg.ts index 0db0986748f05dbb5c6568d667ba30836a75c2de..5f87cfc284f2a6af661c4aac1c1ce2cb11ea8bf6 100644 --- a/lib/subprocess/ffmpeg.ts +++ b/lib/subprocess/ffmpeg.ts @@ -6,15 +6,17 @@ export interface SpawnOptions { } /** - * Spawn an instance of `ffmpeg` and bind it's live status to the given + * spawn an instance of `ffmpeg` and bind it's live status to the given * `progress.Node`. Logs are routed through the progress node, and dismissed on * exit. `lib/progress.ts` will buffer and colocate the logs when running * parallel jobs. * - * await ffmpeg.spawn({ - * cmd: ["-i", "hello.mov", "-c:v", "libsvtav1", "hello.mp4"], - * progress: progress.start("encode hello.mov"), - * }); + * ```ts + * await ffmpeg.spawn({ + * cmd: ["-i", "hello.mov", "-c:v", "libsvtav1", "hello.mp4"], + * progress: progress.start("encode hello.mov"), + * }); + * ``` */ export async function spawn(options: SpawnOptions) { const { ffmpeg = "ffmpeg", args, cwd } = options; diff --git a/lib/testing.ts b/lib/testing.ts index 11e8586da0d1279936d56152d85e7dd5e551b8c9..04081bbf21f2754f230829a417ccd27280021a45 100644 --- a/lib/testing.ts +++ b/lib/testing.ts @@ -109,6 +109,45 @@ export class SyncPromise implements Promise { } } +export class FakeTimers { + time = 0; + timers: Array<{ + duration: number; + resolve: () => void; + src: stack.Frame[]; + }> = []; + waitTime = 0; + + now = () => { + return this.time; + }; + delay: typeof async.delay = (ms) => { + const src = stack.capture(2); + return async.makeCancelable( + new SyncPromise((resolve) => { + this.timers.push({ + duration: ms - this.waitTime, + resolve, + src, + }); + this.waitTime = ms; + }), + () => { + throw new Error("TODO"); + }, + ); + }; +} + +export function removeUndefinedKeys(obj: unknown): unknown { + if (typeof obj !== "object" || !obj || Array.isArray(obj)) return obj; + return Object.fromEntries( + Object.getOwnPropertyNames(obj) + .map((k) => [k, (obj as any)[k]]) + .filter(([, v]) => v !== undefined), + ); +} + /** implements a log.HeadlessWidgetHost that acts as a fake screen. */ export class MockScreen implements Disposable, log.HeadlessWidgetHost { columns = 80; diff --git a/run.js b/run.js index 40290a8d2b2944d28d664b578bf613b8a45b014f..6bb117ad7277390fa6f5eb679f1f1b3c5c4334a1 100644 --- a/run.js +++ b/run.js @@ -105,7 +105,7 @@ if (process.argv[1].startsWith(import.meta.filename.slice(0, -".js".length))) { ); }, ); - } else { + } else if (!found.match(/\.test\.[tj]sx?$/)) { log.warn( hot.load("path").relative(".", found), 'does not export a "main" function', diff --git a/tsconfig.json b/tsconfig.json index f7f1f285334768d35581547abc0f4fb52959e436..9ea151289ad008ff92b011d3f77e1c9ded14540b 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -5,7 +5,7 @@ "incremental": true, "jsx": "react-jsxdev", "jsxImportSource": "#jsx", - "lib": ["esnext", "dom", "dom.iterable"], + "lib": ["esnext", "es2024", "dom", "dom.iterable"], "module": "nodenext", "noEmit": true, "outDir": ".clover/ts", @@ -14,9 +14,9 @@ "skipLibCheck": true, "strict": true, "verbatimModuleSyntax": true, - "target": "es2024", + "target": "esnext", "noUncheckedIndexedAccess": true, "resolveJsonModule": true }, - "include": ["framework/**/*", "src/**/*", "lib/**/*", "framework/global.d.ts"] + "include": ["lib/**/*"] } -- 2.54.0