authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-03-21 18:09:56-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-03-22 22:31:46-07:00
log3e15ccfda871800cf412a0735e5cd9fd30926843
tree426b5322cd911f8a471c9367f1c098072fae5541
parent3ebe409aab2f4c6592a3fb0c70dbd28e975db51c
signature Signed by SSH key SHA256:xbd+BjjhyBfwk7GVoURf9Yx0gzDerHbvYv7SddNWmAs

feat(lib/log): global awareness of the terminal lock

libraries should not call global side effects, but for `log` and its widget feature, it is a must to call `process.stderr.write`. so when it is a library's job to touch the terminal, it has an important duty to handle all edge cases so execution and output is safe. this feature uses a symbol property on `globalThis` to allow `@clo/lib/log` to communicate with other bundled instances of itself, even across versions. it now uses this channel to ensure that: - widgets from different implementations do not interweaving with logs or each other. `replaceGlobalWidgetHost` acts on this singleton. - calling `log.tee` recieves all other globally written messages - calling `replaceGlobalMessageDestination` works - calling `replaceGlobalFormatFunction` works APIs that do not have replacer functions cannot be affected by this global sync system. to do this, most of the internal API was reworked, so that it could be marked "stable forever". i am happy with the frozen interfaces of `Message`, `WidgetHost`, and `DrawLock`, and very glad that many APIs are not caught in this (`TerminalWidgetHostOptions` for example). one example of how this can be used is with a custom runtime or TUI framework (such as `ink`). even if there is a weird dependency chain loading an old version of `@clo/lib` and calling a global directly, you can install the latest version of the library into your app and provide a proper binding via `replaceGlobalWidgetHost`. (for `ink`, that may mean providing a component where the widgets are rendered into, and integrating it with the React rendering pipeline)

8 files changed, 570 insertions(+), 298 deletions(-)

lib/async.ts+1
...@@ -235,6 +235,7 @@ export function makeCancelable<T>(...@@ -235,6 +235,7 @@ export function makeCancelable<T>(
235235
236/** wait `ms` milliseconds, then resolve. can be cancelled. */236/** wait `ms` milliseconds, then resolve. can be cancelled. */
237export function delay(ms: number): Cancelable<void> {237export function delay(ms: number): Cancelable<void> {
238 if (ms === Infinity) return makeCancelable(new Promise(() => {}), () => {});
238 let t: ts.Timer | null = null;239 let t: ts.Timer | null = null;
239 const maxTimer = 0x7FFFFFFF;240 const maxTimer = 0x7FFFFFFF;
240 return makeCancelable(241 return makeCancelable(
lib/log.ts+442-215
...@@ -1,8 +1,11 @@...@@ -1,8 +1,11 @@
1/**1/**
2 * by using `lib/log.ts`, an application gets easy scoped logging as well as2 * by using `@clo/lib/log.ts`, an application gets easy scoped logging as well
3 * integration with terminal widgets such as `lib/progress.ts`. even when these3 * as integration with terminal widgets such as `progress.ts`. even when these
4 * widgets are active, using the logging interface is optional; global I/O with4 * widgets are active, using the logging interface is optional; global I/O with
5 * `console.*` and `process.std{out/err}` are automatically patched to play nice.5 * `console.*` and `process.std{out/err}` are automatically patched to play nice
6 * so nearly any attempts at writing to the terminal should display fine.
7 * additionally, the clover library writes to a global symbol to communicate with
8 * other copies of this library (even across versions) to coordinate drawing.
6 *9 *
7 * the pattern for using this module is to shadow the global `console` with a10 * the pattern for using this module is to shadow the global `console` with a
8 * per-file logging scope, which makes it impossible to use the wrong logger.11 * per-file logging scope, which makes it impossible to use the wrong logger.
...@@ -20,7 +23,7 @@...@@ -20,7 +23,7 @@
20 * import * as console from "@clo/lib/log";23 * import * as console from "@clo/lib/log";
21 * ```24 * ```
22 *25 *
23 * now, the code reads familiarly (`console.log` is universally understood),26 * now code reads familiarly (`console.log` is universally understood),
24 * but the output is organized into relevant scopes.27 * but the output is organized into relevant scopes.
25 *28 *
26 * in addition to static log messages, a system for interactive I/O via the29 * in addition to static log messages, a system for interactive I/O via the
...@@ -88,19 +91,31 @@ export interface RootScope extends Scope {...@@ -88,19 +91,31 @@ export interface RootScope extends Scope {
88}91}
8992
90export const originalLogArgs = Symbol("originalLogArgs");93export const originalLogArgs = Symbol("originalLogArgs");
94
95/**
96 * a message written in a log scope.
97 * this API will never be altered in a breaking way.
98 */
91export interface Message {99export interface Message {
92 level: "error" | "warn" | "info" | "debug";100 /** ANSI-styled text */
93 /** ANSI-styled unicode text */
94 text: string;101 text: string;
95 /** datetime in milliseconds since UNIX epoch */102 /** datetime in milliseconds since UNIX epoch */
96 time: number;103 time: number;
104 /**
105 * type of message, if known.
106 * @default info
107 */
108 level?: MessageLevel;
97 /** scope name */109 /** scope name */
98 scope?: string | null;110 scope?: string | null;
99 /** captured stack. */111 /** captured stack, if available */
100 stack?: stack.Frame[];112 stack?: stack.Frame[];
101 /** arbitrary data from the logging source. */113 /** arbitrary data from the logging source. */
102 custom?: Partial<Record<string, ts.Json>>;114 custom?: Partial<Record<string, ts.Json>>;
103 /** print a newline at the end of this log line? */115 /**
116 * if there is a newline at the end of this log line.
117 * @default true
118 */
104 newline?: boolean;119 newline?: boolean;
105 /**120 /**
106 * original logging arguments, if present. this field is indexed by a symbol121 * original logging arguments, if present. this field is indexed by a symbol
...@@ -109,6 +124,8 @@ export interface Message {...@@ -109,6 +124,8 @@ export interface Message {
109 */124 */
110 [originalLogArgs]?: unknown[];125 [originalLogArgs]?: unknown[];
111}126}
127/** this API will never be altered in a breaking way. */
128type MessageLevel = "error" | "warn" | "info" | "debug";
112129
113// these functions implement `Scope` for the module's namespace. that means if130// these functions implement `Scope` for the module's namespace. that means if
114// a function takes in `Scope`, this file's namespace satisfies that.131// a function takes in `Scope`, this file's namespace satisfies that.
...@@ -143,29 +160,48 @@ export function scoped(name: string): Scope {...@@ -143,29 +160,48 @@ export function scoped(name: string): Scope {
143}160}
144/** redirect all log messages to another writer */161/** redirect all log messages to another writer */
145export function tee(destination: DispatchFunction): ts.Dispose {162export function tee(destination: DispatchFunction): ts.Dispose {
146 return globalLog.tee(destination);163 global.tees.add(destination);
164 return ts.defer(() => global.tees.delete(destination))
147}165}
148166
149/** replace the default message writer */167/** replace the default message writer */
150export function replaceGlobalMessageDestination(destination: DispatchFunction) {168export function replaceGlobalMessageDestination(destination: DispatchFunction) {
151 globalOutputFunction = destination;169 global.writeMessage = destination;
152}170}
153171
154/** replace the default interactive widget host */172/**
155export function replaceGlobalWidgetHost(widgetHost: WidgetHost) {173 * replace the default interactive widget host. this applies for all separately
156 globalWidgetHost = widgetHost;174 * installed copies of the library, across versions. once the widget host has
175 * been activated, it must be preserved forever.
176 */
177export function replaceGlobalWidgetHost(host: WidgetHost) {
178 if (global.widget?.frozen) {
179 throw new Error(
180 `Cannot change widget host implementation, it is locked by another implementation: ${global.widget.source}`,
181 );
182 }
183 global.widget = {
184 frozen: false,
185 source: stack.capture()[0]?.file ??
186 ("untracable call to replaceGlobalWidgetHost in " + import.meta.url),
187 host,
188 version: 0,
189 };
157}190}
158191
159/** replace the default message formatter */192/**
160export function replaceGlobalFormatFunction(193 * replace the default message formatter. does not affect browsers because that
194 * code path does not use `formatAnsiMessage`
195 */
196export function replaceGlobalFormatAnsiMessage(
161 format: (msg: Message, colors: boolean) => string,197 format: (msg: Message, colors: boolean) => string,
162) {198) {
163 globalMessageFormatFunction = format;199 global.formatAnsiMessage = format;
164}200}
165201
166/** includes the trailing newline for standard log messages */202/** includes the trailing newline for standard log messages */
167export function formatMessage(msg: Message, colors: boolean): string {203export function formatAnsiMessage(msg: Message, colors: boolean): string {
168 return globalMessageFormatFunction(msg, colors);204 return (global.formatAnsiMessage ?? defaultFormatAnsiMessage)(msg, colors);
169}205}
170206
171/**207/**
...@@ -173,6 +209,9 @@ export function formatMessage(msg: Message, colors: boolean): string {...@@ -173,6 +209,9 @@ export function formatMessage(msg: Message, colors: boolean): string {
173 * of the log. this can be used to implement status bars, progress209 * of the log. this can be used to implement status bars, progress
174 * indicators, and other human I/O. only 'format' is required.210 * indicators, and other human I/O. only 'format' is required.
175 *211 *
212 * returns `null` if the terminal is not interactive or the global renderer is
213 * incapable of displaying this log (such as in a browser)
214 *
176 * ```ts215 * ```ts
177 * using _ = log.startWidget({216 * using _ = log.startWidget({
178 * format: (now) => `It is ${new Date().toString()} right now\n`217 * format: (now) => `It is ${new Date().toString()} right now\n`
...@@ -185,18 +224,29 @@ export function formatMessage(msg: Message, colors: boolean): string {...@@ -185,18 +224,29 @@ export function formatMessage(msg: Message, colors: boolean): string {
185 * import * as async from "@clo/lib/async.ts";224 * import * as async from "@clo/lib/async.ts";
186 * ```225 * ```
187 */226 */
188export function startWidget(widget: Widget): ts.Dispose {227export function startWidget<T extends WidgetOptions>(
189 return globalWidgetHost.startWidget(widget);228 widget: T,
229): WidgetInstance<T> | null {
230 return globalWidgetHost().startWidget(widget);
190}231}
191232
192/**233/**
193 * no built-in prefix or formatting. ensures the text does not interweave. data234 * no built-in prefix, formatting, or newlline. ensures the text does not interweave.
194 * will be flushed in the next frame or when drawing is {@link getDrawLock|unlocked}.235 * data will be flushed in the next frame or when drawing is {@link getDrawLock|unlocked}.
195 */236 */
196export function write(text: string) {237export function writeOutput(text: string) {
197 globalWidgetHost.write(text);238 globalWidgetHost().writeOutput(text);
198}239}
199240
241// TODO:
242// /**
243// * no built-in prefix, formatting, or newlline. ensures the text does not interweave.
244// * data will be flushed in the next frame or when drawing is {@link getDrawLock|unlocked}.
245// */
246// export function writeError(text: string) {
247// globalWidgetHost().writeError(text);
248// }
249
200/** write a {@linkcode Message} object directly. */250/** write a {@linkcode Message} object directly. */
201export function writeMessage(m: Message) {251export function writeMessage(m: Message) {
202 globalLog.writeMessage(m);252 globalLog.writeMessage(m);
...@@ -211,7 +261,7 @@ export function writeMessage(m: Message) {...@@ -211,7 +261,7 @@ export function writeMessage(m: Message) {
211 * `"short"` which will allow more optimized use of ansi synchronization codes.261 * `"short"` which will allow more optimized use of ansi synchronization codes.
212 */262 */
213export function getDrawLock(mode: "long" | "short"): DrawLock {263export function getDrawLock(mode: "long" | "short"): DrawLock {
214 return globalWidgetHost.getDrawLock(mode);264 return globalWidgetHost().getDrawLock(mode);
215}265}
216266
217/**267/**
...@@ -227,17 +277,25 @@ export function getDrawLock(mode: "long" | "short"): DrawLock {...@@ -227,17 +277,25 @@ export function getDrawLock(mode: "long" | "short"): DrawLock {
227 * you most likely do not need to call this API.277 * you most likely do not need to call this API.
228 */278 */
229export function ensureGlobalsArePatched(): ts.Dispose {279export function ensureGlobalsArePatched(): ts.Dispose {
230 return globalWidgetHost.startWidget({ format: () => "" });280 if (global.widget?.frozen && global.widget?.version !== version) {
281 throw new Error(
282 `Cannot change widget host implementation, it is locked by another implementation: ${global.widget.source}`,
283 );
284 }
285 const w = globalWidgetHost().startWidget?.({ format: () => "" });
286 return ts.defer(() => w?.stop());
231}287}
232288
289/**
290 * a non-exclusive lock to drawing
291 * this API will never be altered in a breaking way.
292 */
233export interface DrawLock {293export interface DrawLock {
234 /**294 /**
235 * decides if widget drawing requires an extra newline, which is needed if295 * decides if widget drawing requires an extra newline, which is needed if
236 * there is extra text on the line that the lock is being released on.296 * there is extra text on the line that the lock is being released on.
237 */297 */
238 release(298 release(endState: "cursor-start-of-line" | "cursor-middle-of-line"): void;
239 cursorPosition: "cursor-start-of-line" | "cursor-middle-of-line",
240 ): void;
241 /** Assumes worst case `cursor-middle-of-line` */299 /** Assumes worst case `cursor-middle-of-line` */
242 [Symbol.dispose](): void;300 [Symbol.dispose](): void;
243}301}
...@@ -246,29 +304,53 @@ export function headlessScope(dispatch: DispatchFunction): RootScope {...@@ -246,29 +304,53 @@ export function headlessScope(dispatch: DispatchFunction): RootScope {
246 return new ScopeImpl(dispatch);304 return new ScopeImpl(dispatch);
247}305}
248306
249/** see {@linkcode startWidget} */307/**
250export interface Widget {308 * see {@linkcode startWidget}
309 * this API will never be altered in a breaking way.
310 */
311export interface WidgetOptions {
251 /**312 /**
252 * return the widget's text. return null to detach the widget.313 * return the widget's text. return null to detach the widget.
253 * may get called more often than the specified `fps`.314 * may get called more often than the specified `fps`.
254 * supports color codes but not ansi cursor movements.315 * supports color codes but not ansi cursor movements.
255 */316 */
256 format(317 format(ctx: WidgetFormatContext): string | { text: string } | null;
257 now: ReturnType<typeof performance.now>,318 /** 'null' to never update automatically */
258 ):319 fps?: number | null;
259 | string320}
260 | null;321
261 /** 'null' to never update (use 'onChange') */322/**
262 fps?:323 * control for a widget (see {@linkcode startWidget})
263 | number324 * this API will never be altered in a breaking way.
264 | null; /** Subscribe to manual widget updates. Call `rerender` when needed. */325 */
265 onChange?(rerender: () => void): () => void;326export interface WidgetInstance<T extends WidgetOptions = WidgetOptions> {
266 /** listen for keyboard events. */327 /** the provided widget options */
267 onKey?(key: string): void;328 options: T;
329 get fps(): number | null;
330 set fps(fps: number | null);
331 /** schedules a new frame to be drawn as soon as possible */
332 redraw(): void;
333 /** remove the widget from the screen */
334 stop(): void;
335 /** alias of `stop` */
336 [Symbol.dispose](): void;
337}
338
339/**
340 * this API will never be altered in a breaking way.
341 */
342export interface WidgetFormatContext {
343 /** the current time according to the widget host */
344 now: ReturnType<typeof performance.now>;
345 /** advisory */
346 width: number;
347 /** advisory */
348 height: number;
349 host?: WidgetHost;
268}350}
269351
270/** {@linkcode widgetHost}'s input takes terminal I/O as well as timing APIs */352/** {@linkcode widgetHost}'s input takes terminal I/O as well as timing APIs */
271export interface WidgetHostOptions {353export interface TerminalWidgetHostOptions {
272 /**354 /**
273 * an exclusive lock on the terminal is held whenever widgets are active. a355 * an exclusive lock on the terminal is held whenever widgets are active. a
274 * secondary purpose of this is to instrument/deinstrument other code to356 * secondary purpose of this is to instrument/deinstrument other code to
...@@ -290,10 +372,12 @@ export interface WidgetHostOptions {...@@ -290,10 +372,12 @@ export interface WidgetHostOptions {
290 now: () => ReturnType<typeof performance.now>;372 now: () => ReturnType<typeof performance.now>;
291 /** after resolving, `now()` should have increased by the delay time */373 /** after resolving, `now()` should have increased by the delay time */
292 delay: typeof async.delay;374 delay: typeof async.delay;
375 /** is there color support? */
376 color: boolean;
293}377}
294378
295/**379/**
296 * When `@clo/lib` requests a lock on the terminal, the adapter provides this380 * when `@clo/lib` requests a lock on the terminal, the adapter provides this
297 * interface to communicate everything about the terminal state correctly.381 * interface to communicate everything about the terminal state correctly.
298 */382 */
299export interface TerminalLock {383export interface TerminalLock {
...@@ -310,34 +394,46 @@ export interface TerminalLock {...@@ -310,34 +394,46 @@ export interface TerminalLock {
310 close(): void;394 close(): void;
311}395}
312396
313/** an implementation of an ANSI-based widget host */397/**
398 * an implementation of an ANSI-based widget host.
399 * this structure must not be altered in a breaking way.
400 */
314export interface WidgetHost {401export interface WidgetHost {
315 /** see the top-level {@linkcode writeLine} function */402 /** see the top-level {@linkcode writeOutput} function */
316 write(text: string): void;403 writeOutput(text: string): void;
404 /** see the top-level {@linkcode writeError} function */
405 writeError(text: string): void;
317 /** see the top-level {@linkcode getDrawLock} function */406 /** see the top-level {@linkcode getDrawLock} function */
318 getDrawLock(mode: "long" | "short"): DrawLock;407 getDrawLock(mode: "long" | "short"): DrawLock;
319 /** see the top-level {@linkcode startWidget} function */408 /** see the top-level {@linkcode startWidget} function */
320 startWidget(widget: Widget): ts.Dispose;409 startWidget<T extends WidgetOptions>(widget: T): WidgetInstance<T> | null;
321 /** stop all widgets and remove all timers. */410 /** stop all widgets and remove all timers. */
322 cancel(): void;411 cancel(): void;
323 /** generic delay function */412 /** generic delay function */
324 delay?: typeof async.delay;413 delay?: typeof async.delay;
325 /** generic now function */414 /** generic now function */
326 now?: typeof performance.now;415 now?: typeof performance.now;
416 /**
417 * what does this widget host support?
418 * - color: ANSI escape sequences are displayed and not stripped
419 * - widget: `startWidget` can meaninglyful display the widget
420 */
421 capabilities: ReadonlyArray<"widget" | "color">;
327}422}
328423
329/** @internal state */424/** @internal state */
330interface WidgetState {425interface WidgetState {
331 frameTime: number;426 frameTime: number;
332 next: number;427 next: number;
333 unsub: (() => void) | null;
334}428}
335429
336/**430/**
337 * terminal widget rendering is done by specifying all system APIs up front in431 * terminal widget rendering is done by specifying all system APIs up front in
338 * an interface, creating an instance of the "widget host".432 * an interface, creating an instance of the "widget host".
339 */433 */
340export function createWidgetHost(env: WidgetHostOptions): WidgetHost {434export function createTerminalWidgetHost(
435 env: TerminalWidgetHostOptions,
436): WidgetHost {
341 const { lockTerminal, now, delay, writeOutputTemporaryLock } = env;437 const { lockTerminal, now, delay, writeOutputTemporaryLock } = env;
342438
343 let timer: async.Cancelable<void> | null = null;439 let timer: async.Cancelable<void> | null = null;
...@@ -349,7 +445,7 @@ export function createWidgetHost(env: WidgetHostOptions): WidgetHost {...@@ -349,7 +445,7 @@ export function createWidgetHost(env: WidgetHostOptions): WidgetHost {
349 let partialLineIndex = 0;445 let partialLineIndex = 0;
350 let needsToSaveCursor = false;446 let needsToSaveCursor = false;
351 let needsToRestoreCursor = false;447 let needsToRestoreCursor = false;
352 const widgets: Widget[] = [];448 const widgets: WidgetOptions[] = [];
353 const internals: WidgetState[] = [];449 const internals: WidgetState[] = [];
354 let lines: string[] = [];450 let lines: string[] = [];
355 let hasSyncStart = false;451 let hasSyncStart = false;
...@@ -392,14 +488,19 @@ export function createWidgetHost(env: WidgetHostOptions): WidgetHost {...@@ -392,14 +488,19 @@ export function createWidgetHost(env: WidgetHostOptions): WidgetHost {
392 let newWidgetLines: string[] = [];488 let newWidgetLines: string[] = [];
393 let next = Infinity;489 let next = Infinity;
394 for (let w = 0, { length } = widgets; w < length; w += 1) {490 for (let w = 0, { length } = widgets; w < length; w += 1) {
395 const outText = UNWRAP(widgets[w]).format(lastFlush);491 const out = UNWRAP(widgets[w]).format({
396 if (!outText) {492 now: lastFlush,
493 width: columns,
494 height: rows,
495 });
496 if (!out) {
397 widgets.splice(w, 1);497 widgets.splice(w, 1);
398 UNWRAP(internals.splice(w, 1)[0]).unsub?.();498 UNWRAP(internals.splice(w, 1)[0]);
399 w -= 1;499 w -= 1;
400 length -= 1;500 length -= 1;
401 continue;501 continue;
402 }502 }
503 const outText = typeof out === "string" ? out : out.text;
403 const rowsLeft = Math.max(1, rows - newWidgetLines.length - 1);504 const rowsLeft = Math.max(1, rows - newWidgetLines.length - 1);
404 if (rowsLeft === 1) break;505 if (rowsLeft === 1) break;
405 const lines = outText.split("\n").slice(0, rowsLeft);506 const lines = outText.split("\n").slice(0, rowsLeft);
...@@ -581,7 +682,11 @@ export function createWidgetHost(env: WidgetHostOptions): WidgetHost {...@@ -581,7 +682,11 @@ export function createWidgetHost(env: WidgetHostOptions): WidgetHost {
581 }682 }
582683
583 return {684 return {
584 write(chunk) {685 writeOutput(chunk) {
686 if (chunk) buffer += chunk, redrawSoon(0);
687 },
688 writeError(chunk) {
689 // TODO: write to stderr. when this was introduced it was not a regression from v3
585 if (chunk) buffer += chunk, redrawSoon(0);690 if (chunk) buffer += chunk, redrawSoon(0);
586 },691 },
587 getDrawLock(mode) {692 getDrawLock(mode) {
...@@ -615,27 +720,40 @@ export function createWidgetHost(env: WidgetHostOptions): WidgetHost {...@@ -615,27 +720,40 @@ export function createWidgetHost(env: WidgetHostOptions): WidgetHost {
615 },720 },
616 };721 };
617 },722 },
618 startWidget(w) {723 startWidget(options) {
619 ASSERT(!widgets.includes(w), "Cannot start the same widget twice.");724 ASSERT(!widgets.includes(options), "Cannot start the same widget twice.");
725 let fps = options.fps ?? null;
620 const state: WidgetState = {726 const state: WidgetState = {
621 next: 0,727 next: 0,
622 unsub: null,728 frameTime: 1000 / (fps ?? 0),
623 frameTime: 1000 / (w.fps ?? 0),
624 };729 };
625 widgets.push(w);730 widgets.push(options);
626 internals.push(state);731 internals.push(state);
627 state.unsub = w.onChange?.(() => {
628 state.next = 0;
629 redrawSoon(0);
630 }) ?? null;
631 redrawSoon(0);732 redrawSoon(0);
632 return ts.defer(() => {733 return {
633 const i = widgets.indexOf(w);734 options,
634 if (i === -1) return;735 get fps() {
635 widgets.splice(i, 1);736 return fps;
636 UNWRAP(internals.splice(i, 1)[0]).unsub?.();737 },
637 redrawSoon(0);738 set fps(value) {
638 });739 fps = value;
740 state.frameTime = 1000 / (fps ?? 0);
741 },
742 redraw() {
743 state.next = 0;
744 redrawSoon(0);
745 },
746 stop() {
747 const i = widgets.indexOf(options);
748 if (i === -1) return;
749 widgets.splice(i, 1);
750 UNWRAP(internals.splice(i, 1)[0]);
751 redrawSoon(0);
752 },
753 [Symbol.dispose]() {
754 this.stop();
755 },
756 };
639 },757 },
640 cancel() {758 cancel() {
641 flushAndClear(false);759 flushAndClear(false);
...@@ -643,6 +761,7 @@ export function createWidgetHost(env: WidgetHostOptions): WidgetHost {...@@ -643,6 +761,7 @@ export function createWidgetHost(env: WidgetHostOptions): WidgetHost {
643 },761 },
644 delay,762 delay,
645 now,763 now,
764 capabilities: env.color ? ["widget", "color"] : ["widget"],
646 };765 };
647}766}
648767
...@@ -732,7 +851,7 @@ const ScopeImpl = class Scope implements RootScope {...@@ -732,7 +851,7 @@ const ScopeImpl = class Scope implements RootScope {
732 };851 };
733852
734 writeMessage: (m: Message) => void = (m) => {853 writeMessage: (m: Message) => void = (m) => {
735 if (withinDispatch) return void globalOutputFunction(m);854 if (withinDispatch) return void globalLog.#dispatch(m);
736 withinDispatch = true;855 withinDispatch = true;
737 this.#dispatch(m);856 this.#dispatch(m);
738 withinDispatch = false;857 withinDispatch = false;
...@@ -771,26 +890,127 @@ const ScopeImpl = class Scope implements RootScope {...@@ -771,26 +890,127 @@ const ScopeImpl = class Scope implements RootScope {
771 }890 }
772};891};
773892
774let globalWidgetHost = node.process893/**
775 ? /* @__PURE__*/ ((process: NonNullable<typeof node.process>) => {894 * global integration is done in a special manner with IIFE expressions to
776 const widget = createWidgetHost({895 * ensure that the logic is removed when a bundler tree-shakes this file.
777 lockTerminal() {896 * additionally, this ensures that replacing a global function properly affects
778 const { stdout, stderr } = process;897 * other installations. this is safe to do because these shared interfaces have
779 let disposed = false;898 * a commitment to never change in a breaking way.
780899 */
781 // patch calls to `process.std{out,err}`900interface GlobalCommunication {
782 // note: `pipe` uses managed calls to `write`, so this is plenty901 readme: string;
783 const stdoutWrite = stdout.write;902 /** urls / file paths */
784 const stderrWrite = stderr.write;903 instances: string[];
785 const stdoutEnd = stdout.end;904 widget?: {
786 const stderrEnd = stderr.end;905 version: number;
787 const newStdoutWrite = stdout.write = widget.write;906 frozen: boolean;
788 const newStderrWrite = stderr.write = function (907 host: WidgetHost;
789 this: typeof stderr,908 /** url / file path / line number / identifying information */
790 ...args909 source: string;
791 ) {910 };
792 using lock = disposed ? null : widget.getDrawLock("short");911 /** overwrite the message writer */
793 stderrWrite.apply(stderr, args);912 writeMessage?: DispatchFunction;
913 /** overwrite the message formatter */
914 formatAnsiMessage?: (m: Message, colors: boolean) => string;
915 /** calls to the global `tee()` */
916 tees: Set<DispatchFunction>;
917}
918
919const globalSymbol = /* @__PURE__ */ Symbol.for("@clo/lib/log");
920const version = 4;
921let global: GlobalCommunication = /* @__PURE__ */ (() => {
922 const global =
923 (globalThis as { [globalSymbol]?: GlobalCommunication })[globalSymbol] ??= {
924 readme: "this global holds shared state for \"@clo/lib/log\", to allow different instances of itself to coordinate with each other",
925 instances: [],
926 tees: new Set,
927 };
928 global.instances.push(import.meta.url);
929 return global;
930})();
931
932function defaultFormatAnsiMessage(
933 { level, scope, text, newline }: Message,
934 colors: boolean,
935) {
936 if (!text) return "";
937 if (newline === false) return text;
938 const prefix = colors
939 // colorful
940 ? `${levelToAnsi[level ?? "info"]}${
941 scope ? `(${scope})` : ""
942 }${ansi.fgReset}${ansi.dim}:${ansi.reset} `
943 // colorless
944 : scope
945 ? `${level}(${scope}): `
946 : `${level}: `;
947 return prefix + text + "\n";
948}
949
950let initWidgetHost = false;
951function globalWidgetHost(): WidgetHost {
952 if (
953 global.widget && (
954 initWidgetHost ||
955 global.widget.frozen ||
956 // By default, pick the latest version of the widget host implementation.
957 // This is most likely to resolve the most issues as possible. To opt out of
958 // this, import the desired implementation and call its
959 // `ensureGlobalsArePatched` or `replaceGlobalWidgetHost` method.
960 global.widget.version >= version
961 )
962 ) {
963 initWidgetHost = true;
964 return global.widget.host;
965 }
966 initWidgetHost = true;
967 global.widget = {
968 frozen: false,
969 host: node.process
970 ? defaultNodeProcessWidgetHost(node.process)
971 : defaultFallbackWidgetHost(),
972 source: import.meta.url,
973 version,
974 };
975 return global.widget.host;
976}
977
978export function defaultNodeProcessWidgetHost(
979 process: NonNullable<typeof node.process>,
980 forceWidgetSupport = false,
981): WidgetHost {
982 // fallback
983 if (!forceWidgetSupport && !process.stderr.isTTY) {
984 return {
985 writeOutput: (string) => process.stdout.write(string),
986 writeError: (string) => process.stderr.write(string),
987 getDrawLock: () => ({
988 release() {},
989 [Symbol.dispose]() {},
990 }),
991 startWidget: () => null,
992 cancel: () => {},
993 capabilities: [],
994 };
995 }
996
997 const host = createTerminalWidgetHost({
998 lockTerminal() {
999 const { stdout, stderr } = process;
1000 let disposed = false;
1001
1002 // patch calls to `process.std{out,err}`
1003 // note: `pipe` uses managed calls to `write`, so this is plenty
1004 const stdoutWrite = stdout.write;
1005 const stderrWrite = stderr.write;
1006 const stdoutEnd = stdout.end;
1007 const stderrEnd = stderr.end;
1008 function patchWriteMethod<T, R, A extends [string | Uint8Array]>(
1009 fn: (this: T, ...args: A) => R,
1010 ) {
1011 return function (this: T, ...args: A) {
1012 using lock = disposed ? null : host.getDrawLock("short");
1013 const ret = fn.apply(this, args);
794 if (lock) {1014 if (lock) {
795 lock.release(1015 lock.release(
796 (typeof args[0] === "string"1016 (typeof args[0] === "string"
...@@ -800,112 +1020,137 @@ let globalWidgetHost = node.process...@@ -800,112 +1020,137 @@ let globalWidgetHost = node.process
800 : "cursor-middle-of-line",1020 : "cursor-middle-of-line",
801 );1021 );
802 }1022 }
1023 return ret;
803 };1024 };
804 function patchEndMethod<T, A extends unknown[]>(1025 }
805 fn: (this: T, ...args: A) => void,1026 const newStdoutWrite = stdout.write = patchWriteMethod(stdoutWrite);
806 ) {1027 const newStderrWrite = stderr.write = patchWriteMethod(stderrWrite);
807 return function (this: T, ...args: A) {1028 function patchEndMethod<T, R, A extends unknown[]>(
808 using lock = disposed ? null : widget.getDrawLock("short");1029 fn: (this: T, ...args: A) => R,
809 fn.apply(this, args);1030 ) {
810 // TODO: this is not handled correctly, but nobody closes1031 return function (this: T, ...args: A) {
811 // their fucking standard error! this likely should just1032 using lock = disposed ? null : host.getDrawLock("short");
812 // disable the library if you call end.1033 const ret = fn.apply(this, args);
813 if (lock) lock.release("cursor-start-of-line");1034 // TODO: this is not handled correctly, but people rarely close their
814 };1035 // standard I/O! this likely should just disable the library if you
815 }1036 // call end. i'm not particularly worried.
816 const newStdoutEnd = stdout.end = patchEndMethod(stdoutEnd);1037 if (lock) lock.release("cursor-start-of-line");
817 const newStderrEnd = stderr.end = patchEndMethod(stderrEnd);1038 return ret;
818
819 // non-node runtimes will typically implement console in a way that
820 // doesn't use `node:process`, so it must also get patched. this is
821 // okay because the lock is re-enterant.
822 function patchSyncMethod<T, A extends unknown[]>(
823 fn: (this: T, ...args: A) => void,
824 ) {
825 return function (this: T, ...args: A) {
826 using lock = disposed ? null : widget.getDrawLock("short");
827 fn.apply(this, args);
828 if (lock) lock.release("cursor-start-of-line");
829 };
830 }
831 const console = globalThis
832 .console as unknown as Record<string, () => void>;
833 const restoreConsole: [string, old: () => void, patch: () => void][] =
834 [];
835 for (const [key, old] of Object.entries(console)) {
836 if (typeof old !== "function") continue;
837 try {
838 const patched = console[key] = patchSyncMethod(old);
839 restoreConsole.push([key, old, patched]);
840 } catch { /* skip */ }
841 }
842
843 return {
844 writeOutput: (string) => stdoutWrite.call(stderr, string),
845 writeInteractive: (string) => stderrWrite.call(stderr, string),
846 getSize: () => process.stderr,
847 temporarilyUnlock() {
848 // no action needed
849 },
850 close() {
851 disposed = true;
852 // leave patches in place if something else tampered with it.
853 if (stdout.write === newStdoutWrite) stdout.write = stdoutWrite;
854 if (stderr.write === newStderrWrite) stdout.write = stderrWrite;
855 if (stdout.end === newStdoutEnd) stdout.end = stdoutEnd;
856 if (stderr.end === newStderrEnd) stdout.end = stderrEnd;
857 for (const [key, old, patched] of restoreConsole) {
858 if (console[key] === patched) console[key] = old;
859 }
860 },
861 };1039 };
862 },1040 }
863 now: () => performance.now(),1041 const newStdoutEnd = stdout.end = patchEndMethod(stdoutEnd);
864 delay: async.delay,1042 const newStderrEnd = stderr.end = patchEndMethod(stderrEnd);
865 });
866 process.addListener("beforeExit", () => widget.cancel());
867 process.addListener("exit", () => widget.cancel());
8681043
869 return widget;1044 // non-node runtimes will typically implement console in a way that
870 })(node.process)1045 // doesn't use `node:process`, so it must also get patched. this is
871 : /* @__PURE__ */ ((warned = false) => {1046 // okay because the lock is re-enterant.
872 return {1047 function patchSyncMethod<T, A extends unknown[]>(
873 write: (line: string) => console.log(line),1048 fn: (this: T, ...args: A) => void,
874 getDrawLock: () => ({ [Symbol.dispose]() {}, release() {} }),1049 ) {
875 startWidget: (w: Widget) => {1050 return function (this: T, ...args: A) {
876 if (!warned) {1051 using lock = disposed ? null : host.getDrawLock("short");
877 console.warn(1052 fn.apply(this, args);
878 '"@clo/lib/log.ts"\'s startWidget was called in an environment ' +1053 if (lock) lock.release("cursor-start-of-line");
879 "that does not support the Node.js 'process' API. Widgets " +1054 };
880 "will not be visible.",1055 }
881 );1056 const console = globalThis
882 warned = true;1057 .console as Console & Record<string, () => void>;
883 }1058 const restoreConsole: [string, old: () => void, patch: () => void][] = [];
884 const close = w.onChange?.(() => {});1059 for (const [key, old] of Object.entries(console)) {
885 return ts.defer(close ?? (() => {}));1060 if (typeof old !== "function") continue;
886 },1061 try {
887 cancel: () => {},1062 const patched = console[key] = patchSyncMethod(old);
888 };1063 restoreConsole.push([key, old, patched]);
889 })();1064 } catch { /* skip */ }
1065 }
8901066
891export function simpleNodeProcessWidgetHost(process: node.Process) {
892 return createWidgetHost({
893 lockTerminal() {
894 return {1067 return {
895 writeOutput: (string) => process.stdout.write(string),1068 writeOutput: (string) => stdoutWrite.call(stderr, string),
896 writeInteractive: (string) => process.stderr.write(string),1069 writeInteractive: (string) => stderrWrite.call(stderr, string),
897 getSize: () => process.stderr,1070 getSize: () => process.stderr,
898 temporarilyUnlock() {1071 temporarilyUnlock() {
899 // no action needed1072 // no action needed
900 },1073 },
901 close() {1074 close() {
902 // no action needed1075 disposed = true;
1076 // leave patches in place if something else tampered with it.
1077 if (stdout.write === newStdoutWrite) stdout.write = stdoutWrite;
1078 if (stderr.write === newStderrWrite) stdout.write = stderrWrite;
1079 if (stdout.end === newStdoutEnd) stdout.end = stdoutEnd;
1080 if (stderr.end === newStderrEnd) stdout.end = stderrEnd;
1081 for (const [key, old, patched] of restoreConsole) {
1082 if (console[key] === patched) console[key] = old;
1083 }
903 },1084 },
904 };1085 };
905 },1086 },
906 now: () => performance.now(),1087 now: () => performance.now(),
907 delay: async.delay,1088 delay: async.delay,
1089 color: process.stderr.isTTY,
908 });1090 });
1091 process.addListener("beforeExit", () => host.cancel());
1092 process.addListener("exit", () => host.cancel());
1093 return host;
1094}
1095
1096function defaultFallbackWidgetHost(): WidgetHost {
1097 let warned = false;
1098 return {
1099 writeOutput: (line: string) => console.log(ansi.strip(line)),
1100 writeError: (line: string) => console.error(ansi.strip(line)),
1101 getDrawLock: () => ({ [Symbol.dispose]() {}, release() {} }),
1102 startWidget: (options) => {
1103 if (!warned) {
1104 console.warn(
1105 '"@clo/lib/log.ts"\'s startWidget was called in an environment ' +
1106 "that does not support the Node.js 'process' API. Widgets " +
1107 "will not be visible.",
1108 );
1109 warned = true;
1110 }
1111 return {
1112 options,
1113 fps: options.fps ?? null,
1114 redraw() {},
1115 stop() {},
1116 [Symbol.dispose]() {},
1117 };
1118 },
1119 cancel: () => {},
1120 capabilities: [],
1121 };
1122}
1123
1124/** returns a WidgetHost from `node:process`, but without patching its methods */
1125export function simpleNodeProcessWidgetHost(
1126 process: NonNullable<typeof node.process>,
1127 forceWidgetSupport = false,
1128): WidgetHost {
1129 return forceWidgetSupport || process.stderr.isTTY
1130 ? createTerminalWidgetHost({
1131 lockTerminal: () => ({
1132 writeOutput: (string) => process.stdout.write(string),
1133 writeInteractive: (string) => process.stderr.write(string),
1134 getSize: () => process.stderr,
1135 close() {
1136 // no action needed
1137 },
1138 }),
1139 now: () => performance.now(),
1140 delay: async.delay,
1141 color: process.stderr.isTTY,
1142 })
1143 : {
1144 writeOutput: (string) => process.stdout.write(string),
1145 writeError: (string) => process.stderr.write(string),
1146 getDrawLock: () => ({
1147 release() {},
1148 [Symbol.dispose]() {},
1149 }),
1150 startWidget: () => null,
1151 cancel: () => {},
1152 capabilities: [],
1153 };
909}1154}
9101155
911function bufferEndsInNewline(buffer: ArrayBufferView | undefined) {1156function bufferEndsInNewline(buffer: ArrayBufferView | undefined) {
...@@ -917,41 +1162,23 @@ function bufferEndsInNewline(buffer: ArrayBufferView | undefined) {...@@ -917,41 +1162,23 @@ function bufferEndsInNewline(buffer: ArrayBufferView | undefined) {
917 : false;1162 : false;
918}1163}
9191164
920const levelToAnsi: Record<Message["level"], string> = {1165const levelToAnsi: Record<MessageLevel, string> = {
921 info: `${ansi.fgBlue}info`,1166 info: `${ansi.fgBlue}info`,
922 warn: `${ansi.fgYellow}warn`,1167 warn: `${ansi.fgYellow}warn`,
923 error: `${ansi.fgRed}error`,1168 error: `${ansi.fgRed}error`,
924 debug: `${ansi.dim}dbg`,1169 debug: `${ansi.dim}dbg`,
925};1170};
9261171
927let globalMessageFormatFunction: MessageFormatFunction = (1172const globalLog = /* @__PURE__ */ (() =>
928 { level, scope, text, newline },1173 new ScopeImpl((m) => {
929 colors,1174 if (global.writeMessage) {
930) => {1175 global.writeMessage(m);
931 if (!text) return "";1176 } else if (node.process) {
932 if (newline === false) return text;1177 globalWidgetHost()[
933 const prefix = colors1178 (m.level ?? "info") === "info" ? "writeOutput" : "writeError"
934 // colorful1179 ](formatAnsiMessage(m, node.process.stdout.isTTY));
935 ? `${levelToAnsi[level]}${1180 } else {
936 scope ? `(${scope})` : ""1181 let { level = "info", [originalLogArgs]: args = [m.text], scope } = m;
937 }${ansi.fgReset}${ansi.dim}:${ansi.reset} `
938 // colorless
939 : scope
940 ? `${level}(${scope}): `
941 : `${level}: `;
942 return prefix + text + "\n";
943};
944let globalOutputFunction!: DispatchFunction;
945const globalLog = /* @__PURE__ */ (() => {
946 const colors = node.process?.stderr.isTTY ?? false;
947 globalOutputFunction = node.process
948 // In Node.js, coordinate with the widget host
949 ? (message) => {
950 globalWidgetHost.write(globalMessageFormatFunction(message, colors));
951 }
952 // Otherwise, forward to `console`
953 : (m) => {
954 let { level, [originalLogArgs]: args = [m.text], scope } = m;
955 if (scope) {1182 if (scope) {
956 const arg0 = args[0];1183 const arg0 = args[0];
957 const prefix = `[${scope}]`;1184 const prefix = `[${scope}]`;
...@@ -959,9 +1186,9 @@ const globalLog = /* @__PURE__ */ (() => {...@@ -959,9 +1186,9 @@ const globalLog = /* @__PURE__ */ (() => {
959 else args.unshift(prefix);1186 else args.unshift(prefix);
960 }1187 }
961 console[level](...args);1188 console[level](...args);
962 };1189 }
963 return new ScopeImpl(globalOutputFunction);1190 global.tees.forEach((cb) => cb(m));
964})();1191 }))();
9651192
966export type DispatchFunction = (message: Message) => void;1193export type DispatchFunction = (message: Message) => void;
967/**1194/**
lib/log/stack.ts+1-1
...@@ -274,7 +274,7 @@ function getPackageRoot(absPath: string) {...@@ -274,7 +274,7 @@ function getPackageRoot(absPath: string) {
274 ];274 ];
275 }275 }
276 return [276 return [
277 `https://github.com/nodejs/node/blob/${process.version}/lib/`,277 `https://github.com/nodejs/node/blob/${process.versions.node}/lib/`,
278 "",278 "",
279 absPath.slice(5) + ".js",279 absPath.slice(5) + ".js",
280 ];280 ];
lib/node.ts+25-19
...@@ -1,8 +1,8 @@...@@ -1,8 +1,8 @@
1/**1/**
2 * functions to load Node.js apis via `globalThis.process`. trivially bundlable2 * functions to load Node.js apis via `globalThis.process`. trivially bundlable
3 * for the browser. does not depend on `@types/node` and does not intend to3 * for the browser. does not depend on `@types/node` and does not intend to
4 * define types for the entire api. Instead, this is used for other library4 * define types for the entire api. instead, this file's types are used for
5 * modules like `lib/log.ts` to bind to the system.5 * other library modules like `lib/log.ts` to bind to the system.
6 *6 *
7 * if you are using a competent bundler, you can define `globalThis.process` as7 * if you are using a competent bundler, you can define `globalThis.process` as
8 * a bundling constant (esbuild: `--define`) to enable tree shaking across the8 * a bundling constant (esbuild: `--define`) to enable tree shaking across the
...@@ -10,22 +10,20 @@...@@ -10,22 +10,20 @@
10 * @module10 * @module
11 */11 */
1212
13export type ErrorCode =
14 | keyof typeof import("node:os").constants.errno
15 | (string & {});
16
17export const process: Process | undefined =13export const process: Process | undefined =
18 (globalThis as typeof globalThis & { process?: Process }).process ??14 (globalThis as { process?: Process }).process &&
19 undefined;15 (globalThis as { process?: Process }).process?.versions?.node
16 ? (globalThis as { process?: Process }).process
17 : undefined;
2018
21export const isServer: boolean = !!process;19export const isServer: boolean = !!process;
2220
23/** partial types for Node.js `globalThis.process` */21/** partial types for Node.js `globalThis.process` */
24export interface Process {22interface Process {
25 getBuiltinModule<K extends keyof Builtins>(name: K): Builtins[K] | null;23 getBuiltinModule<K extends keyof Builtins>(name: K): Builtins[K] | null;
26 binding<K extends keyof Bindings>(name: K): Bindings[K] | null;24 binding?<K extends keyof Bindings>(name: K): Bindings[K] | null;
27 addListener(event: string, callback: () => void): this;25 addListener(event: string, callback: () => void): this;
2826 versions: { node?: string; bun?: string; deno?: string };
29 stdout: Tty;27 stdout: Tty;
30 stderr: Tty;28 stderr: Tty;
31}29}
...@@ -35,8 +33,8 @@ interface Tty {...@@ -35,8 +33,8 @@ interface Tty {
35 columns: number;33 columns: number;
36 rows: number;34 rows: number;
37 addListener(event: string, callback: () => void): this;35 addListener(event: string, callback: () => void): this;
38 end(text?: string | Uint8Array): void;36 end(text?: string | Uint8Array): this;
39 write(text: string | Uint8Array): void;37 write(text: string | Uint8Array): boolean;
40}38}
4139
42/**40/**
...@@ -71,31 +69,39 @@ interface Builtins {...@@ -71,31 +69,39 @@ interface Builtins {
71 };69 };
72 };70 };
73}71}
74/**72
75 * Subset of Node.js binding types73/** subset of Node.js binding types */
76 */
77interface Bindings {74interface Bindings {
78 /**75 /**
79 * key value mapping of internal module id to its source code.76 * key value mapping of internal module id to its source code.
80 *77 * ```
81 * { "fs": "// Copyright Joyent, Inc. and other Node contributors.\n...78 * { "fs": "// Copyright Joyent, Inc. and other Node contributors.\n...
79 * ```
82 */80 */
83 "natives": Partial<Record<string, string>>;81 "natives": Partial<Record<string, string>>;
84}82}
8583
84/** return a built-in module */
86export function builtin<K extends keyof Builtins>(85export function builtin<K extends keyof Builtins>(
87 name: K,86 name: K,
88): Builtins[K] | undefined {87): Builtins[K] | undefined {
89 return process?.getBuiltinModule(name) ?? undefined;88 return process?.getBuiltinModule(name) ?? undefined;
90}89}
9190
91/** return a built-in semi-private binding */
92export function binding<K extends keyof Bindings>(92export function binding<K extends keyof Bindings>(
93 name: K,93 name: K,
94): Bindings[K] | undefined {94): Bindings[K] | undefined {
95 if (!process) return undefined;95 if (!process) return undefined;
96 try {96 try {
97 return process.binding(name) ?? undefined;97 return process.binding?.(name) ?? undefined;
98 } catch {98 } catch {
99 return undefined;99 return undefined;
100 }100 }
101}101}
102
103/** copy of `keyof typeof import('node:os').constants.errno` */
104// deno-fmt-ignore-next
105export type ErrorCode =
106 | (string & {})
107 | "E2BIG" | "EACCES" | "EADDRINUSE" | "EADDRNOTAVAIL" | "EAFNOSUPPORT" | "EAGAIN" | "EALREADY" | "EBADF" | "EBADMSG" | "EBUSY" | "ECANCELED" | "ECHILD" | "ECONNABORTED" | "ECONNREFUSED" | "ECONNRESET" | "EDEADLK" | "EDESTADDRREQ" | "EDOM" | "EDQUOT" | "EEXIST" | "EFAULT" | "EFBIG" | "EHOSTUNREACH" | "EIDRM" | "EILSEQ" | "EINPROGRESS" | "EINTR" | "EINVAL" | "EIO" | "EISCONN" | "EISDIR" | "ELOOP" | "EMFILE" | "EMLINK" | "EMSGSIZE" | "EMULTIHOP" | "ENAMETOOLONG" | "ENETDOWN" | "ENETRESET" | "ENETUNREACH" | "ENFILE" | "ENOBUFS" | "ENODATA" | "ENODEV" | "ENOENT" | "ENOEXEC" | "ENOLCK" | "ENOLINK" | "ENOMEM" | "ENOMSG" | "ENOPROTOOPT" | "ENOSPC" | "ENOSR" | "ENOSTR" | "ENOSYS" | "ENOTCONN" | "ENOTDIR" | "ENOTEMPTY" | "ENOTSOCK" | "ENOTSUP" | "ENOTTY" | "ENXIO" | "EOPNOTSUPP" | "EOVERFLOW" | "EPERM" | "EPIPE" | "EPROTO" | "EPROTONOSUPPORT" | "EPROTOTYPE" | "ERANGE" | "EROFS" | "ESPIPE" | "ESRCH" | "ESTALE" | "ETIME" | "ETIMEDOUT" | "ETXTBSY" | "EWOULDBLOCK" | "EXDEV" | "WSAEINTR" | "WSAEBADF" | "WSAEACCES" | "WSAEFAULT" | "WSAEINVAL" | "WSAEMFILE" | "WSAEWOULDBLOCK" | "WSAEINPROGRESS" | "WSAEALREADY" | "WSAENOTSOCK" | "WSAEDESTADDRREQ" | "WSAEMSGSIZE" | "WSAEPROTOTYPE" | "WSAENOPROTOOPT" | "WSAEPROTONOSUPPORT" | "WSAESOCKTNOSUPPORT" | "WSAEOPNOTSUPP" | "WSAEPFNOSUPPORT" | "WSAEAFNOSUPPORT" | "WSAEADDRINUSE" | "WSAEADDRNOTAVAIL" | "WSAENETDOWN" | "WSAENETUNREACH" | "WSAENETRESET" | "WSAECONNABORTED" | "WSAECONNRESET" | "WSAENOBUFS" | "WSAEISCONN" | "WSAENOTCONN" | "WSAESHUTDOWN" | "WSAETOOMANYREFS" | "WSAETIMEDOUT" | "WSAECONNREFUSED" | "WSAELOOP" | "WSAENAMETOOLONG" | "WSAEHOSTDOWN" | "WSAEHOSTUNREACH" | "WSAENOTEMPTY" | "WSAEPROCLIM" | "WSAEUSERS" | "WSAEDQUOT" | "WSAESTALE" | "WSAEREMOTE" | "WSASYSNOTREADY" | "WSAVERNOTSUPPORTED" | "WSANOTINITIALISED" | "WSAEDISCON" | "WSAENOMORE" | "WSAECANCELLED" | "WSAEINVALIDPROCTABLE" | "WSAEINVALIDPROVIDER" | "WSAEPROVIDERFAILEDINIT" | "WSASYSCALLFAILURE" | "WSASERVICE_NOT_FOUND" | "WSATYPE_NOT_FOUND" | "WSA_E_NO_MORE" | "WSA_E_CANCELLED" | "WSAEREFUSED";
lib/package.json created+4
...@@ -0,0 +1,4 @@
1{
2 "type": "module",
3 "exports": { "./*": "./*.ts" }
4}
lib/progress.ts+41-32
...@@ -37,7 +37,7 @@...@@ -37,7 +37,7 @@
37 * custom redirections.37 * custom redirections.
38 *38 *
39 * this module is under construction. while i am happy with the overall API, it39 * this module is under construction. while i am happy with the overall API, it
40 * needs more work and feature development.40 * needs more work and feature development. the API of `Node` is stable, though.
41 *41 *
42 * inspired by the [Zig Progress API](https://andrewkelley.me/post/zig-new-cli-progress-bar-explained.html).42 * inspired by the [Zig Progress API](https://andrewkelley.me/post/zig-new-cli-progress-bar-explained.html).
43 * @module43 * @module
...@@ -74,7 +74,7 @@ export function start(text: string, opts?: StartOptions): Node {...@@ -74,7 +74,7 @@ export function start(text: string, opts?: StartOptions): Node {
74 *74 *
75 * ```ts75 * ```ts
76 * await ffmpeg.spawn({76 * await ffmpeg.spawn({
77 * cmd: ["-i", "hello.mov", "-c:v", "@clo/libsvtav1", "hello.mp4"],77 * cmd: ["-i", "hello.mov", "-c:v", "svtav1", "hello.mp4"],
78 * progress: progress.start("encode hello.mov"),78 * progress: progress.start("encode hello.mov"),
79 * });79 * });
80 * ```80 * ```
...@@ -705,13 +705,16 @@ function renderChildren(state: ReadOnlyNode, now: number, depth: boolean[]) {...@@ -705,13 +705,16 @@ function renderChildren(state: ReadOnlyNode, now: number, depth: boolean[]) {
705 continue;705 continue;
706 }706 }
707 const logLines = child.logs707 const logLines = child.logs
708 .map((msg) => log.formatMessage(msg, true))708 .map((msg) => log.formatAnsiMessage(msg, true))
709 .join("")709 .join("")
710 .trim();710 .trim();
711 if (logLines) for (const line of logLines.split("\n").slice(-Math.min(3, maxHeight))) {711 if (logLines) {
712 item += left + (i === length - 1 && !truncated ? " " : box.line) + " " +712 for (const line of logLines.split("\n").slice(-Math.min(3, maxHeight))) {
713 ansi.style(ansi.fgBrightBlack, ">") +713 item += left + (i === length - 1 && !truncated ? " " : box.line) +
714 " " + line + "\n";714 " " +
715 ansi.style(ansi.fgBrightBlack, ">") +
716 " " + line + "\n";
717 }
715 }718 }
716 maxHeight -= h + logLines.length;719 maxHeight -= h + logLines.length;
717 out += item;720 out += item;
...@@ -765,30 +768,32 @@ export function formatUnicodeBar(progress: number, width: number): string {...@@ -765,30 +768,32 @@ export function formatUnicodeBar(progress: number, width: number): string {
765 */768 */
766export function attachToScreen(769export function attachToScreen(
767 root: Root,770 root: Root,
768 { write, startWidget }: Pick<771 { writeOutput, startWidget }: Pick<
769 log.WidgetHost,772 log.WidgetHost,
770 "write" | "startWidget"773 "writeOutput" | "startWidget"
771 >,774 >,
772): ts.Dispose {775): ts.Dispose {
773 const stack = new DisposableStack();776 const stack = new DisposableStack();
774777 let widget: log.WidgetInstance | null = null;
775 let rerender: (() => void) | null = null;
776 const widget: log.Widget = {
777 format: (now) => root.active ? formatAnsi(now, root.active) : null,
778 onChange: (cb) => (rerender = cb, () => rerender = null),
779 fps: spinnerFps,
780 };
781778
782 stack.use(root.on("change", (items) => {779 stack.use(root.on("change", (items) => {
783 if (rerender) rerender();780 if (items.length > 0) {
784 else if (items.length > 0) startWidget(widget);781 widget ??= startWidget({
782 format: ({ now }) => formatAnsi(now, root.active),
783 }) ?? null;
784 if (!widget)return;
785 widget.fps = items.some((x) => x.showTotal !== false && x.total > 0)
786 ? spinnerFps
787 : null;
788 widget.redraw();
789 } else {
790 widget?.stop();
791 widget = null;
792 }
793 }));
794 stack.use(root.on("node-detached-log", (msg) => {
795 writeOutput(log.formatAnsiMessage(msg, true));
785 }));796 }));
786 stack.use(
787 root.on(
788 "node-detached-log",
789 (msg) => write(log.formatMessage(msg, true)),
790 ),
791 );
792 stack.use(root.on("node-end", (node) => {797 stack.use(root.on("node-end", (node) => {
793 let title = node.text;798 let title = node.text;
794 let p: ReadOnlyNode | null = node;799 let p: ReadOnlyNode | null = node;
...@@ -796,8 +801,8 @@ export function attachToScreen(...@@ -796,8 +801,8 @@ export function attachToScreen(
796 const { logs } = node;801 const { logs } = node;
797 if (logs.length > 0) {802 if (logs.length > 0) {
798 const header = `[logs from ${title}]`;803 const header = `[logs from ${title}]`;
799 write(ansi.style(ansi.fgBrightBlack, header) + "\n");804 writeOutput(ansi.style(ansi.fgBrightBlack, header) + "\n");
800 write(logs.map((msg) => log.formatMessage(msg, true)).join(""));805 writeOutput(logs.map((msg) => log.formatAnsiMessage(msg, true)).join(""));
801 }806 }
802 }));807 }));
803808
...@@ -878,8 +883,6 @@ export interface EncodeStreamOptions {...@@ -878,8 +883,6 @@ export interface EncodeStreamOptions {
878 *883 *
879 * if the given root adds custom event handlers, they must all have884 * if the given root adds custom event handlers, they must all have
880 * json-serializable payloads.885 * json-serializable payloads.
881 *
882 * this function does not use recursion.
883 */886 */
884export function encodeEventStream<887export function encodeEventStream<
885 Result extends ts.Json,888 Result extends ts.Json,
...@@ -1344,6 +1347,8 @@ interface DeltaFlags {...@@ -1344,6 +1347,8 @@ interface DeltaFlags {
1344}1347}
13451348
1346/**1349/**
1350 * NOTE: this function contains bugs and its format is not yet stabilized.
1351 *
1347 * converts a {@linkcode Root|progress.Root} into an byte stream for1352 * converts a {@linkcode Root|progress.Root} into an byte stream for
1348 * communicating progress over the process or network boundary. the output is a1353 * communicating progress over the process or network boundary. the output is a
1349 * raw binary payload that uses an extremely compact representation for the1354 * raw binary payload that uses an extremely compact representation for the
...@@ -1352,8 +1357,6 @@ interface DeltaFlags {...@@ -1352,8 +1357,6 @@ interface DeltaFlags {
1352 *1357 *
1353 * if the given root adds custom event handlers, they must all have1358 * if the given root adds custom event handlers, they must all have
1354 * json-serializable payloads.1359 * json-serializable payloads.
1355 *
1356 * this function does not use recursion.
1357 */1360 */
1358export function encodeByteStream<1361export function encodeByteStream<
1359 Result extends ts.Json,1362 Result extends ts.Json,
...@@ -1434,7 +1437,7 @@ function writeStreamEvent(...@@ -1434,7 +1437,7 @@ function writeStreamEvent(
1434 if (messages !== undefined) {1437 if (messages !== undefined) {
1435 w.varUint(messages.length);1438 w.varUint(messages.length);
1436 for (const msg of messages) {1439 for (const msg of messages) {
1437 let level = logLevelSerialize.indexOf(msg.level);1440 let level = logLevelSerialize.indexOf(msg.level ?? "info");
1438 if (level === -1) level = 0;1441 if (level === -1) level = 0;
1439 w.u8(1442 w.u8(
1440 level +1443 level +
...@@ -1743,6 +1746,12 @@ const globalKeyPool = new KeyPool<number>();...@@ -1743,6 +1746,12 @@ const globalKeyPool = new KeyPool<number>();
1743const global: Ref =1746const global: Ref =
1744 /** @__PURE__ */ ((root = new Root()) => (attachToScreen(root, log), root))();1747 /** @__PURE__ */ ((root = new Root()) => (attachToScreen(root, log), root))();
17451748
1749/**
1750 * a {@linkcode Ref} to the global progress root. unlike referencing the
1751 * namespace import, this value is tree-shakable.
1752 */
1753export const globalRoot: Ref = { start: global.start };
1754
1746/**1755/**
1747 * for testing. not covered by semver1756 * for testing. not covered by semver
1748 * @internal1757 * @internal
...@@ -1755,7 +1764,7 @@ export const internals: {...@@ -1755,7 +1764,7 @@ export const internals: {
1755} = /** @__PURE__ */ (() => ({1764} = /** @__PURE__ */ (() => ({
1756 readStreamEvent,1765 readStreamEvent,
1757 writeStreamEvent,1766 writeStreamEvent,
1758 kNode: kNode as unknown as symbol,1767 kNode: kNode as symbol,
1759 EncodedKey: 0 as EncodedKey,1768 EncodedKey: 0 as EncodedKey,
1760}))();1769}))();
17611770
lib/readme.changes.md+18-9
...@@ -4,13 +4,17 @@...@@ -4,13 +4,17 @@
44
5### breaking5### breaking
66
7- the `.ts` suffix in the module name has been dropped
7- `log`8- `log`
8 - rename `writeLine` to `write`9 - rename `writeLine` to `writeOutput` and `writeError`
10 - `startWidget` returns `null` if the host is incapable of it
11 - it also no longer takes `onChange`. call `redraw` on the returned `WidgetInstance`.
9 - rename `HeadlessWidgetHost` to `WidgetHost`12 - rename `HeadlessWidgetHost` to `WidgetHost`
10 - rename `HeadlessWidgetEnv` to `WidgetHostOptions`13 - rename `headlessWidgetHost` to `createTerminalWidgetHost`
11 - rename `headlessWidgetHost` to `createWidgetHost`14 - rename `HeadlessWidgetEnv` to `TerminalWidgetHostOptions`
12 - rename `replaceGlobalDestination` to `replaceGlobalMessageDestination`15 - rename `replaceGlobalDestination` to `replaceGlobalMessageDestination`
13 - `WidgetHostOptions` takes a `lockTerminal` function instead of16 - rename `formatMessage` to `formatAnsiMessage`
17 - `WidgetHostOptions` now takes a `lockTerminal` function instead of
14 `writeInteractive`/`writeOutput` directly. the locking function returns an18 `writeInteractive`/`writeOutput` directly. the locking function returns an
15 interface with these functions, which better aligns with how the draw lock19 interface with these functions, which better aligns with how the draw lock
16 actually works. additionally, this allows writing more accurate tty bindings20 actually works. additionally, this allows writing more accurate tty bindings
...@@ -20,23 +24,28 @@...@@ -20,23 +24,28 @@
20 - in `getDrawLock`, new required argument `mode`, which can be set to `short`24 - in `getDrawLock`, new required argument `mode`, which can be set to `short`
21 or `long` to affect how synchronization works. releasing the lock requires25 or `long` to affect how synchronization works. releasing the lock requires
22 you to give some information about where the cursor was moved to.26 you to give some information about where the cursor was moved to.
23 - messages now do not imply a newline
24- `render` is now deprecated with no replacement. in the downstream `sitegen`
25 project, the codebase is moving to Marko after depending on both renderers.
26- `string/ansi`27- `string/ansi`
27 - rename `trimToWidth` to `trimForTerminal`28 - rename `trimToWidth` to `trimForTerminal`
2829
30deprecations without removals:
31
32- `render.ts` will be deleted with no replacement. in the downstream `sitegen`
33 project, the codebase is moving to Marko after depending on both renderers.
34
29### features35### features
3036
37- you can write `log` messages without a newline. on older libraries, this will
38 show up as a newline per message since that version was not capable of
39 displaying it. but done not as a breaking change.
31- `process.{stdout,stderr}.write` is intercepted to avoid log interweaving with40- `process.{stdout,stderr}.write` is intercepted to avoid log interweaving with
32 `log.ts`/`progress.ts`. this is only done when a widget is created (for41 `log.ts`/`progress.ts`. this is only done when a widget is created (for
33 example, calling `progress.start`), so patches are not applied when not42 example, calling `progress.start`), so patches are not applied when not
34 needed. if patching globals is undesirable, you can use an alternative widget43 needed. if patching globals is undesirable, you can use an alternative widget
35 host by calling `log.replaceGlobalWidgetHost(log.simpleNodeProcessWidgetHost(process))`44 host by calling `log.replaceGlobalWidgetHost(log.simpleNodeProcessWidgetHost(process))`
36 - consequences of this is that `getDrawLock`45- `log.startWidget` returns a `WidgetInstance`, including a mutable `fps` property.
37- `progress.ts` node gains `node.log.write("word ");` to write a message without46- `progress.ts` node gains `node.log.write("word ");` to write a message without
38 a newline.47 a newline.
39- `async.delay` handles timers longer than 23 days.48- `async.delay` handles timers longer than 23 days and `Infinity`.
40- `Lru.revive` recieves bug fixes. this function previously didn't really work.49- `Lru.revive` recieves bug fixes. this function previously didn't really work.
41- `ansi` gets more cursor control constants50- `ansi` gets more cursor control constants
4251
lib/testing.ts+38-22
...@@ -166,9 +166,10 @@ export class MockScreen implements Disposable, log.WidgetHost {...@@ -166,9 +166,10 @@ export class MockScreen implements Disposable, log.WidgetHost {
166 out: string = "";166 out: string = "";
167 writeCalls: WriteCall[] = [];167 writeCalls: WriteCall[] = [];
168168
169 timers = new FakeTimers();169 timers: FakeTimers = new FakeTimers();
170170
171 write: log.WidgetHost["write"];171 writeOutput: log.WidgetHost["writeOutput"];
172 writeError: log.WidgetHost["writeError"];
172 getDrawLock: log.WidgetHost["getDrawLock"];173 getDrawLock: log.WidgetHost["getDrawLock"];
173 startWidget: log.WidgetHost["startWidget"];174 startWidget: log.WidgetHost["startWidget"];
174 delay: log.WidgetHost["delay"];175 delay: log.WidgetHost["delay"];
...@@ -182,7 +183,7 @@ export class MockScreen implements Disposable, log.WidgetHost {...@@ -182,7 +183,7 @@ export class MockScreen implements Disposable, log.WidgetHost {
182183
183 constructor({ temporaryUnlocking }: { temporaryUnlocking?: boolean } = {}) {184 constructor({ temporaryUnlocking }: { temporaryUnlocking?: boolean } = {}) {
184 const callerFile = UNWRAP(stack.capture()[0]);185 const callerFile = UNWRAP(stack.capture()[0]);
185 const host = log.createWidgetHost({186 const host = log.createTerminalWidgetHost({
186 lockTerminal: () => {187 lockTerminal: () => {
187 ASSERT(!this.hasTerminalLock);188 ASSERT(!this.hasTerminalLock);
188 this.hasTerminalLock = "locked";189 this.hasTerminalLock = "locked";
...@@ -190,24 +191,32 @@ export class MockScreen implements Disposable, log.WidgetHost {...@@ -190,24 +191,32 @@ export class MockScreen implements Disposable, log.WidgetHost {
190 writeInteractive: (content) => {191 writeInteractive: (content) => {
191 this.stderr += content;192 this.stderr += content;
192 this.out += content;193 this.out += content;
193 const frames = stack.capture().filter(x => x.file !== import.meta.filename);194 const frames = stack.capture().filter((x) =>
194 const cutoff = frames.findIndex(f => f.file === callerFile.file && f.fn === callerFile.fn)195 x.file !== import.meta.filename
196 );
197 const cutoff = frames.findIndex((f) =>
198 f.file === callerFile.file && f.fn === callerFile.fn
199 );
195 this.writeCalls.push({200 this.writeCalls.push({
196 kind: "interactive",201 kind: "interactive",
197 stack: cutoff === -1 ? frames : frames.slice(0, cutoff),202 stack: cutoff === -1 ? frames : frames.slice(0, cutoff),
198 content,203 content,
199 })204 });
200 },205 },
201 writeOutput: (content) => {206 writeOutput: (content) => {
202 this.stdout += content;207 this.stdout += content;
203 this.out += content;208 this.out += content;
204 const frames = stack.capture().filter(x => x.file !== import.meta.filename);209 const frames = stack.capture().filter((x) =>
205 const cutoff = frames.findIndex(f => f.file === callerFile.file && f.fn === callerFile.fn)210 x.file !== import.meta.filename
211 );
212 const cutoff = frames.findIndex((f) =>
213 f.file === callerFile.file && f.fn === callerFile.fn
214 );
206 this.writeCalls.push({215 this.writeCalls.push({
207 kind: "output",216 kind: "output",
208 stack: cutoff === -1 ? frames : frames.slice(0, cutoff),217 stack: cutoff === -1 ? frames : frames.slice(0, cutoff),
209 content,218 content,
210 })219 });
211 },220 },
212 getSize: () => {221 getSize: () => {
213 return this;222 return this;
...@@ -229,8 +238,10 @@ export class MockScreen implements Disposable, log.WidgetHost {...@@ -229,8 +238,10 @@ export class MockScreen implements Disposable, log.WidgetHost {
229 },238 },
230 now: this.timers.now,239 now: this.timers.now,
231 delay: this.timers.delay,240 delay: this.timers.delay,
241 color: true,
232 });242 });
233 this.write = host.write;243 this.writeOutput = host.writeOutput;
244 this.writeError = host.writeError;
234 this.getDrawLock = host.getDrawLock;245 this.getDrawLock = host.getDrawLock;
235 this.startWidget = host.startWidget;246 this.startWidget = host.startWidget;
236 this.delay = host.delay;247 this.delay = host.delay;
...@@ -253,7 +264,8 @@ export class MockScreen implements Disposable, log.WidgetHost {...@@ -253,7 +264,8 @@ export class MockScreen implements Disposable, log.WidgetHost {
253 if (ms != null) {264 if (ms != null) {
254 const wait = UNWRAP(265 const wait = UNWRAP(
255 this.timers.entries.shift(),266 this.timers.entries.shift(),
256 () => this.out.length > 0 ? "terminal i/o did not wait" : "no terminal i/o",267 () =>
268 this.out.length > 0 ? "terminal i/o did not wait" : "no terminal i/o",
257 );269 );
258 ASSERT(270 ASSERT(
259 ms === wait.duration,271 ms === wait.duration,
...@@ -299,18 +311,22 @@ export class MockScreen implements Disposable, log.WidgetHost {...@@ -299,18 +311,22 @@ export class MockScreen implements Disposable, log.WidgetHost {
299 this.writeCalls = [];311 this.writeCalls = [];
300 }312 }
301313
302 fmtWriteCalls() {314 fmtWriteCalls(): string {
303 return `\n${ansi.reset}breakdown of calls that rendered this frame:\n` +315 return `\n${ansi.reset}breakdown of calls that rendered this frame:\n` +
304 this.writeCalls.map((call, i) =>316 this.writeCalls.map((call, i) =>
305 `${i + 1}. [${call.kind}] ${ansi.debugAnsi(call.content)}\n`317 `${i + 1}. [${call.kind}] ${ansi.debugAnsi(call.content)}\n` +
306 + call.stack.map(frame => ansi.reset + stack.formatFrame(frame, true)).join('\n') +318 call.stack.map((frame) => ansi.reset + stack.formatFrame(frame, true))
319 .join("\n") +
307 `\n`320 `\n`
308 ).join('\n').replaceAll(ansi.fgReset, ansi.reset)321 ).join("\n").replaceAll(ansi.fgReset, ansi.reset);
309 }322 }
310323
311 [Symbol.dispose]() {324 [Symbol.dispose]() {
312 this.cancel();325 this.cancel();
313 }326 }
327
328 capabilities = ["widget", "color"] as const;
329
314 cancel() {330 cancel() {
315 ASSERT(this.timers.entries.length === 0, "there is a pending write!");331 ASSERT(this.timers.entries.length === 0, "there is a pending write!");
316 ASSERT(!this.stdout, "unread standard out: " + this.stdout);332 ASSERT(!this.stdout, "unread standard out: " + this.stdout);