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>(
235235
236236/** wait `ms` milliseconds, then resolve. can be cancelled. */
237237export function delay(ms: number): Cancelable<void> {
238 if (ms === Infinity) return makeCancelable(new Promise(() => {}), () => {});
238239 let t: ts.Timer | null = null;
239240 const maxTimer = 0x7FFFFFFF;
240241 return makeCancelable(
lib/log.ts+442-215
......@@ -1,8 +1,11 @@
11/**
2 * by using `lib/log.ts`, an application gets easy scoped logging as well as
3 * integration with terminal widgets such as `lib/progress.ts`. even when these
2 * by using `@clo/lib/log.ts`, an application gets easy scoped logging as well
3 * as integration with terminal widgets such as `progress.ts`. even when these
44 * 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.
69 *
710 * the pattern for using this module is to shadow the global `console` with a
811 * per-file logging scope, which makes it impossible to use the wrong logger.
......@@ -20,7 +23,7 @@
2023 * import * as console from "@clo/lib/log";
2124 * ```
2225 *
23 * now, the code reads familiarly (`console.log` is universally understood),
26 * now code reads familiarly (`console.log` is universally understood),
2427 * but the output is organized into relevant scopes.
2528 *
2629 * in addition to static log messages, a system for interactive I/O via the
......@@ -88,19 +91,31 @@ export interface RootScope extends Scope {
8891}
8992
9093export 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 */
9199export interface Message {
92 level: "error" | "warn" | "info" | "debug";
93 /** ANSI-styled unicode text */
100 /** ANSI-styled text */
94101 text: string;
95102 /** datetime in milliseconds since UNIX epoch */
96103 time: number;
104 /**
105 * type of message, if known.
106 * @default info
107 */
108 level?: MessageLevel;
97109 /** scope name */
98110 scope?: string | null;
99 /** captured stack. */
111 /** captured stack, if available */
100112 stack?: stack.Frame[];
101113 /** arbitrary data from the logging source. */
102114 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 */
104119 newline?: boolean;
105120 /**
106121 * original logging arguments, if present. this field is indexed by a symbol
......@@ -109,6 +124,8 @@ export interface Message {
109124 */
110125 [originalLogArgs]?: unknown[];
111126}
127/** this API will never be altered in a breaking way. */
128type MessageLevel = "error" | "warn" | "info" | "debug";
112129
113130// these functions implement `Scope` for the module's namespace. that means if
114131// a function takes in `Scope`, this file's namespace satisfies that.
......@@ -143,29 +160,48 @@ export function scoped(name: string): Scope {
143160}
144161/** redirect all log messages to another writer */
145162export function tee(destination: DispatchFunction): ts.Dispose {
146 return globalLog.tee(destination);
163 global.tees.add(destination);
164 return ts.defer(() => global.tees.delete(destination))
147165}
148166
149167/** replace the default message writer */
150168export function replaceGlobalMessageDestination(destination: DispatchFunction) {
151 globalOutputFunction = destination;
169 global.writeMessage = destination;
152170}
153171
154/** replace the default interactive widget host */
155export function replaceGlobalWidgetHost(widgetHost: WidgetHost) {
156 globalWidgetHost = widgetHost;
172/**
173 * replace the default interactive widget host. this applies for all separately
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 };
157190}
158191
159/** replace the default message formatter */
160export function replaceGlobalFormatFunction(
192/**
193 * replace the default message formatter. does not affect browsers because that
194 * code path does not use `formatAnsiMessage`
195 */
196export function replaceGlobalFormatAnsiMessage(
161197 format: (msg: Message, colors: boolean) => string,
162198) {
163 globalMessageFormatFunction = format;
199 global.formatAnsiMessage = format;
164200}
165201
166202/** includes the trailing newline for standard log messages */
167export function formatMessage(msg: Message, colors: boolean): string {
168 return globalMessageFormatFunction(msg, colors);
203export function formatAnsiMessage(msg: Message, colors: boolean): string {
204 return (global.formatAnsiMessage ?? defaultFormatAnsiMessage)(msg, colors);
169205}
170206
171207/**
......@@ -173,6 +209,9 @@ export function formatMessage(msg: Message, colors: boolean): string {
173209 * of the log. this can be used to implement status bars, progress
174210 * indicators, and other human I/O. only 'format' is required.
175211 *
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 *
176215 * ```ts
177216 * using _ = log.startWidget({
178217 * format: (now) => `It is ${new Date().toString()} right now\n`
......@@ -185,18 +224,29 @@ export function formatMessage(msg: Message, colors: boolean): string {
185224 * import * as async from "@clo/lib/async.ts";
186225 * ```
187226 */
188export function startWidget(widget: Widget): ts.Dispose {
189 return globalWidgetHost.startWidget(widget);
227export function startWidget<T extends WidgetOptions>(
228 widget: T,
229): WidgetInstance<T> | null {
230 return globalWidgetHost().startWidget(widget);
190231}
191232
192233/**
193 * no built-in prefix or formatting. ensures the text does not interweave. data
194 * will be flushed in the next frame or when drawing is {@link getDrawLock|unlocked}.
234 * no built-in prefix, formatting, or newlline. ensures the text does not interweave.
235 * data will be flushed in the next frame or when drawing is {@link getDrawLock|unlocked}.
195236 */
196export function write(text: string) {
197 globalWidgetHost.write(text);
237export function writeOutput(text: string) {
238 globalWidgetHost().writeOutput(text);
198239}
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
200250/** write a {@linkcode Message} object directly. */
201251export function writeMessage(m: Message) {
202252 globalLog.writeMessage(m);
......@@ -211,7 +261,7 @@ export function writeMessage(m: Message) {
211261 * `"short"` which will allow more optimized use of ansi synchronization codes.
212262 */
213263export function getDrawLock(mode: "long" | "short"): DrawLock {
214 return globalWidgetHost.getDrawLock(mode);
264 return globalWidgetHost().getDrawLock(mode);
215265}
216266
217267/**
......@@ -227,17 +277,25 @@ export function getDrawLock(mode: "long" | "short"): DrawLock {
227277 * you most likely do not need to call this API.
228278 */
229279export 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());
231287}
232288
289/**
290 * a non-exclusive lock to drawing
291 * this API will never be altered in a breaking way.
292 */
233293export interface DrawLock {
234294 /**
235295 * decides if widget drawing requires an extra newline, which is needed if
236296 * there is extra text on the line that the lock is being released on.
237297 */
238 release(
239 cursorPosition: "cursor-start-of-line" | "cursor-middle-of-line",
240 ): void;
298 release(endState: "cursor-start-of-line" | "cursor-middle-of-line"): void;
241299 /** Assumes worst case `cursor-middle-of-line` */
242300 [Symbol.dispose](): void;
243301}
......@@ -246,29 +304,53 @@ export function headlessScope(dispatch: DispatchFunction): RootScope {
246304 return new ScopeImpl(dispatch);
247305}
248306
249/** see {@linkcode startWidget} */
250export interface Widget {
307/**
308 * see {@linkcode startWidget}
309 * this API will never be altered in a breaking way.
310 */
311export interface WidgetOptions {
251312 /**
252313 * return the widget's text. return null to detach the widget.
253314 * may get called more often than the specified `fps`.
254315 * supports color codes but not ansi cursor movements.
255316 */
256 format(
257 now: ReturnType<typeof performance.now>,
258 ):
259 | string
260 | null;
261 /** 'null' to never update (use 'onChange') */
262 fps?:
263 | number
264 | null; /** Subscribe to manual widget updates. Call `rerender` when needed. */
265 onChange?(rerender: () => void): () => void;
266 /** listen for keyboard events. */
267 onKey?(key: string): void;
317 format(ctx: WidgetFormatContext): string | { text: string } | null;
318 /** 'null' to never update automatically */
319 fps?: number | null;
320}
321
322/**
323 * control for a widget (see {@linkcode startWidget})
324 * this API will never be altered in a breaking way.
325 */
326export interface WidgetInstance<T extends WidgetOptions = WidgetOptions> {
327 /** the provided widget options */
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;
268350}
269351
270352/** {@linkcode widgetHost}'s input takes terminal I/O as well as timing APIs */
271export interface WidgetHostOptions {
353export interface TerminalWidgetHostOptions {
272354 /**
273355 * an exclusive lock on the terminal is held whenever widgets are active. a
274356 * secondary purpose of this is to instrument/deinstrument other code to
......@@ -290,10 +372,12 @@ export interface WidgetHostOptions {
290372 now: () => ReturnType<typeof performance.now>;
291373 /** after resolving, `now()` should have increased by the delay time */
292374 delay: typeof async.delay;
375 /** is there color support? */
376 color: boolean;
293377}
294378
295379/**
296 * When `@clo/lib` requests a lock on the terminal, the adapter provides this
380 * when `@clo/lib` requests a lock on the terminal, the adapter provides this
297381 * interface to communicate everything about the terminal state correctly.
298382 */
299383export interface TerminalLock {
......@@ -310,34 +394,46 @@ export interface TerminalLock {
310394 close(): void;
311395}
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 */
314401export interface WidgetHost {
315 /** see the top-level {@linkcode writeLine} function */
316 write(text: string): void;
402 /** see the top-level {@linkcode writeOutput} function */
403 writeOutput(text: string): void;
404 /** see the top-level {@linkcode writeError} function */
405 writeError(text: string): void;
317406 /** see the top-level {@linkcode getDrawLock} function */
318407 getDrawLock(mode: "long" | "short"): DrawLock;
319408 /** see the top-level {@linkcode startWidget} function */
320 startWidget(widget: Widget): ts.Dispose;
409 startWidget<T extends WidgetOptions>(widget: T): WidgetInstance<T> | null;
321410 /** stop all widgets and remove all timers. */
322411 cancel(): void;
323412 /** generic delay function */
324413 delay?: typeof async.delay;
325414 /** generic now function */
326415 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">;
327422}
328423
329424/** @internal state */
330425interface WidgetState {
331426 frameTime: number;
332427 next: number;
333 unsub: (() => void) | null;
334428}
335429
336430/**
337431 * terminal widget rendering is done by specifying all system APIs up front in
338432 * an interface, creating an instance of the "widget host".
339433 */
340export function createWidgetHost(env: WidgetHostOptions): WidgetHost {
434export function createTerminalWidgetHost(
435 env: TerminalWidgetHostOptions,
436): WidgetHost {
341437 const { lockTerminal, now, delay, writeOutputTemporaryLock } = env;
342438
343439 let timer: async.Cancelable<void> | null = null;
......@@ -349,7 +445,7 @@ export function createWidgetHost(env: WidgetHostOptions): WidgetHost {
349445 let partialLineIndex = 0;
350446 let needsToSaveCursor = false;
351447 let needsToRestoreCursor = false;
352 const widgets: Widget[] = [];
448 const widgets: WidgetOptions[] = [];
353449 const internals: WidgetState[] = [];
354450 let lines: string[] = [];
355451 let hasSyncStart = false;
......@@ -392,14 +488,19 @@ export function createWidgetHost(env: WidgetHostOptions): WidgetHost {
392488 let newWidgetLines: string[] = [];
393489 let next = Infinity;
394490 for (let w = 0, { length } = widgets; w < length; w += 1) {
395 const outText = UNWRAP(widgets[w]).format(lastFlush);
396 if (!outText) {
491 const out = UNWRAP(widgets[w]).format({
492 now: lastFlush,
493 width: columns,
494 height: rows,
495 });
496 if (!out) {
397497 widgets.splice(w, 1);
398 UNWRAP(internals.splice(w, 1)[0]).unsub?.();
498 UNWRAP(internals.splice(w, 1)[0]);
399499 w -= 1;
400500 length -= 1;
401501 continue;
402502 }
503 const outText = typeof out === "string" ? out : out.text;
403504 const rowsLeft = Math.max(1, rows - newWidgetLines.length - 1);
404505 if (rowsLeft === 1) break;
405506 const lines = outText.split("\n").slice(0, rowsLeft);
......@@ -581,7 +682,11 @@ export function createWidgetHost(env: WidgetHostOptions): WidgetHost {
581682 }
582683
583684 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
585690 if (chunk) buffer += chunk, redrawSoon(0);
586691 },
587692 getDrawLock(mode) {
......@@ -615,27 +720,40 @@ export function createWidgetHost(env: WidgetHostOptions): WidgetHost {
615720 },
616721 };
617722 },
618 startWidget(w) {
619 ASSERT(!widgets.includes(w), "Cannot start the same widget twice.");
723 startWidget(options) {
724 ASSERT(!widgets.includes(options), "Cannot start the same widget twice.");
725 let fps = options.fps ?? null;
620726 const state: WidgetState = {
621727 next: 0,
622 unsub: null,
623 frameTime: 1000 / (w.fps ?? 0),
728 frameTime: 1000 / (fps ?? 0),
624729 };
625 widgets.push(w);
730 widgets.push(options);
626731 internals.push(state);
627 state.unsub = w.onChange?.(() => {
628 state.next = 0;
629 redrawSoon(0);
630 }) ?? null;
631732 redrawSoon(0);
632 return ts.defer(() => {
633 const i = widgets.indexOf(w);
634 if (i === -1) return;
635 widgets.splice(i, 1);
636 UNWRAP(internals.splice(i, 1)[0]).unsub?.();
637 redrawSoon(0);
638 });
733 return {
734 options,
735 get fps() {
736 return fps;
737 },
738 set fps(value) {
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 };
639757 },
640758 cancel() {
641759 flushAndClear(false);
......@@ -643,6 +761,7 @@ export function createWidgetHost(env: WidgetHostOptions): WidgetHost {
643761 },
644762 delay,
645763 now,
764 capabilities: env.color ? ["widget", "color"] : ["widget"],
646765 };
647766}
648767
......@@ -732,7 +851,7 @@ const ScopeImpl = class Scope implements RootScope {
732851 };
733852
734853 writeMessage: (m: Message) => void = (m) => {
735 if (withinDispatch) return void globalOutputFunction(m);
854 if (withinDispatch) return void globalLog.#dispatch(m);
736855 withinDispatch = true;
737856 this.#dispatch(m);
738857 withinDispatch = false;
......@@ -771,26 +890,127 @@ const ScopeImpl = class Scope implements RootScope {
771890 }
772891};
773892
774let globalWidgetHost = node.process
775 ? /* @__PURE__*/ ((process: NonNullable<typeof node.process>) => {
776 const widget = createWidgetHost({
777 lockTerminal() {
778 const { stdout, stderr } = process;
779 let disposed = false;
780
781 // patch calls to `process.std{out,err}`
782 // note: `pipe` uses managed calls to `write`, so this is plenty
783 const stdoutWrite = stdout.write;
784 const stderrWrite = stderr.write;
785 const stdoutEnd = stdout.end;
786 const stderrEnd = stderr.end;
787 const newStdoutWrite = stdout.write = widget.write;
788 const newStderrWrite = stderr.write = function (
789 this: typeof stderr,
790 ...args
791 ) {
792 using lock = disposed ? null : widget.getDrawLock("short");
793 stderrWrite.apply(stderr, args);
893/**
894 * global integration is done in a special manner with IIFE expressions to
895 * ensure that the logic is removed when a bundler tree-shakes this file.
896 * additionally, this ensures that replacing a global function properly affects
897 * other installations. this is safe to do because these shared interfaces have
898 * a commitment to never change in a breaking way.
899 */
900interface GlobalCommunication {
901 readme: string;
902 /** urls / file paths */
903 instances: string[];
904 widget?: {
905 version: number;
906 frozen: boolean;
907 host: WidgetHost;
908 /** url / file path / line number / identifying information */
909 source: string;
910 };
911 /** overwrite the message writer */
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);
7941014 if (lock) {
7951015 lock.release(
7961016 (typeof args[0] === "string"
......@@ -800,112 +1020,137 @@ let globalWidgetHost = node.process
8001020 : "cursor-middle-of-line",
8011021 );
8021022 }
1023 return ret;
8031024 };
804 function patchEndMethod<T, A extends unknown[]>(
805 fn: (this: T, ...args: A) => void,
806 ) {
807 return function (this: T, ...args: A) {
808 using lock = disposed ? null : widget.getDrawLock("short");
809 fn.apply(this, args);
810 // TODO: this is not handled correctly, but nobody closes
811 // their fucking standard error! this likely should just
812 // disable the library if you call end.
813 if (lock) lock.release("cursor-start-of-line");
814 };
815 }
816 const newStdoutEnd = stdout.end = patchEndMethod(stdoutEnd);
817 const newStderrEnd = stderr.end = patchEndMethod(stderrEnd);
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 },
1025 }
1026 const newStdoutWrite = stdout.write = patchWriteMethod(stdoutWrite);
1027 const newStderrWrite = stderr.write = patchWriteMethod(stderrWrite);
1028 function patchEndMethod<T, R, A extends unknown[]>(
1029 fn: (this: T, ...args: A) => R,
1030 ) {
1031 return function (this: T, ...args: A) {
1032 using lock = disposed ? null : host.getDrawLock("short");
1033 const ret = fn.apply(this, args);
1034 // TODO: this is not handled correctly, but people rarely close their
1035 // standard I/O! this likely should just disable the library if you
1036 // call end. i'm not particularly worried.
1037 if (lock) lock.release("cursor-start-of-line");
1038 return ret;
8611039 };
862 },
863 now: () => performance.now(),
864 delay: async.delay,
865 });
866 process.addListener("beforeExit", () => widget.cancel());
867 process.addListener("exit", () => widget.cancel());
1040 }
1041 const newStdoutEnd = stdout.end = patchEndMethod(stdoutEnd);
1042 const newStderrEnd = stderr.end = patchEndMethod(stderrEnd);
8681043
869 return widget;
870 })(node.process)
871 : /* @__PURE__ */ ((warned = false) => {
872 return {
873 write: (line: string) => console.log(line),
874 getDrawLock: () => ({ [Symbol.dispose]() {}, release() {} }),
875 startWidget: (w: Widget) => {
876 if (!warned) {
877 console.warn(
878 '"@clo/lib/log.ts"\'s startWidget was called in an environment ' +
879 "that does not support the Node.js 'process' API. Widgets " +
880 "will not be visible.",
881 );
882 warned = true;
883 }
884 const close = w.onChange?.(() => {});
885 return ts.defer(close ?? (() => {}));
886 },
887 cancel: () => {},
888 };
889 })();
1044 // non-node runtimes will typically implement console in a way that
1045 // doesn't use `node:process`, so it must also get patched. this is
1046 // okay because the lock is re-enterant.
1047 function patchSyncMethod<T, A extends unknown[]>(
1048 fn: (this: T, ...args: A) => void,
1049 ) {
1050 return function (this: T, ...args: A) {
1051 using lock = disposed ? null : host.getDrawLock("short");
1052 fn.apply(this, args);
1053 if (lock) lock.release("cursor-start-of-line");
1054 };
1055 }
1056 const console = globalThis
1057 .console as Console & Record<string, () => void>;
1058 const restoreConsole: [string, old: () => void, patch: () => void][] = [];
1059 for (const [key, old] of Object.entries(console)) {
1060 if (typeof old !== "function") continue;
1061 try {
1062 const patched = console[key] = patchSyncMethod(old);
1063 restoreConsole.push([key, old, patched]);
1064 } catch { /* skip */ }
1065 }
8901066
891export function simpleNodeProcessWidgetHost(process: node.Process) {
892 return createWidgetHost({
893 lockTerminal() {
8941067 return {
895 writeOutput: (string) => process.stdout.write(string),
896 writeInteractive: (string) => process.stderr.write(string),
1068 writeOutput: (string) => stdoutWrite.call(stderr, string),
1069 writeInteractive: (string) => stderrWrite.call(stderr, string),
8971070 getSize: () => process.stderr,
8981071 temporarilyUnlock() {
8991072 // no action needed
9001073 },
9011074 close() {
902 // no action needed
1075 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 }
9031084 },
9041085 };
9051086 },
9061087 now: () => performance.now(),
9071088 delay: async.delay,
1089 color: process.stderr.isTTY,
9081090 });
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 };
9091154}
9101155
9111156function bufferEndsInNewline(buffer: ArrayBufferView | undefined) {
......@@ -917,41 +1162,23 @@ function bufferEndsInNewline(buffer: ArrayBufferView | undefined) {
9171162 : false;
9181163}
9191164
920const levelToAnsi: Record<Message["level"], string> = {
1165const levelToAnsi: Record<MessageLevel, string> = {
9211166 info: `${ansi.fgBlue}info`,
9221167 warn: `${ansi.fgYellow}warn`,
9231168 error: `${ansi.fgRed}error`,
9241169 debug: `${ansi.dim}dbg`,
9251170};
9261171
927let globalMessageFormatFunction: MessageFormatFunction = (
928 { level, scope, text, newline },
929 colors,
930) => {
931 if (!text) return "";
932 if (newline === false) return text;
933 const prefix = colors
934 // colorful
935 ? `${levelToAnsi[level]}${
936 scope ? `(${scope})` : ""
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;
1172const globalLog = /* @__PURE__ */ (() =>
1173 new ScopeImpl((m) => {
1174 if (global.writeMessage) {
1175 global.writeMessage(m);
1176 } else if (node.process) {
1177 globalWidgetHost()[
1178 (m.level ?? "info") === "info" ? "writeOutput" : "writeError"
1179 ](formatAnsiMessage(m, node.process.stdout.isTTY));
1180 } else {
1181 let { level = "info", [originalLogArgs]: args = [m.text], scope } = m;
9551182 if (scope) {
9561183 const arg0 = args[0];
9571184 const prefix = `[${scope}]`;
......@@ -959,9 +1186,9 @@ const globalLog = /* @__PURE__ */ (() => {
9591186 else args.unshift(prefix);
9601187 }
9611188 console[level](...args);
962 };
963 return new ScopeImpl(globalOutputFunction);
964})();
1189 }
1190 global.tees.forEach((cb) => cb(m));
1191 }))();
9651192
9661193export type DispatchFunction = (message: Message) => void;
9671194/**
lib/log/stack.ts+1-1
......@@ -274,7 +274,7 @@ function getPackageRoot(absPath: string) {
274274 ];
275275 }
276276 return [
277 `https://github.com/nodejs/node/blob/${process.version}/lib/`,
277 `https://github.com/nodejs/node/blob/${process.versions.node}/lib/`,
278278 "",
279279 absPath.slice(5) + ".js",
280280 ];
lib/node.ts+25-19
......@@ -1,8 +1,8 @@
11/**
22 * functions to load Node.js apis via `globalThis.process`. trivially bundlable
33 * 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 library
5 * modules like `lib/log.ts` to bind to the system.
4 * define types for the entire api. instead, this file's types are used for
5 * other library modules like `lib/log.ts` to bind to the system.
66 *
77 * if you are using a competent bundler, you can define `globalThis.process` as
88 * a bundling constant (esbuild: `--define`) to enable tree shaking across the
......@@ -10,22 +10,20 @@
1010 * @module
1111 */
1212
13export type ErrorCode =
14 | keyof typeof import("node:os").constants.errno
15 | (string & {});
16
1713export const process: Process | undefined =
18 (globalThis as typeof globalThis & { process?: Process }).process ??
19 undefined;
14 (globalThis as { process?: Process }).process &&
15 (globalThis as { process?: Process }).process?.versions?.node
16 ? (globalThis as { process?: Process }).process
17 : undefined;
2018
2119export const isServer: boolean = !!process;
2220
2321/** partial types for Node.js `globalThis.process` */
24export interface Process {
22interface Process {
2523 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;
2725 addListener(event: string, callback: () => void): this;
28
26 versions: { node?: string; bun?: string; deno?: string };
2927 stdout: Tty;
3028 stderr: Tty;
3129}
......@@ -35,8 +33,8 @@ interface Tty {
3533 columns: number;
3634 rows: number;
3735 addListener(event: string, callback: () => void): this;
38 end(text?: string | Uint8Array): void;
39 write(text: string | Uint8Array): void;
36 end(text?: string | Uint8Array): this;
37 write(text: string | Uint8Array): boolean;
4038}
4139
4240/**
......@@ -71,31 +69,39 @@ interface Builtins {
7169 };
7270 };
7371}
74/**
75 * Subset of Node.js binding types
76 */
72
73/** subset of Node.js binding types */
7774interface Bindings {
7875 /**
7976 * key value mapping of internal module id to its source code.
80 *
81 * { "fs": "// Copyright Joyent, Inc. and other Node contributors.\n...
77 * ```
78 * { "fs": "// Copyright Joyent, Inc. and other Node contributors.\n...
79 * ```
8280 */
8381 "natives": Partial<Record<string, string>>;
8482}
8583
84/** return a built-in module */
8685export function builtin<K extends keyof Builtins>(
8786 name: K,
8887): Builtins[K] | undefined {
8988 return process?.getBuiltinModule(name) ?? undefined;
9089}
9190
91/** return a built-in semi-private binding */
9292export function binding<K extends keyof Bindings>(
9393 name: K,
9494): Bindings[K] | undefined {
9595 if (!process) return undefined;
9696 try {
97 return process.binding(name) ?? undefined;
97 return process.binding?.(name) ?? undefined;
9898 } catch {
9999 return undefined;
100100 }
101101}
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 @@
3737 * custom redirections.
3838 *
3939 * 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.
4141 *
4242 * inspired by the [Zig Progress API](https://andrewkelley.me/post/zig-new-cli-progress-bar-explained.html).
4343 * @module
......@@ -74,7 +74,7 @@ export function start(text: string, opts?: StartOptions): Node {
7474 *
7575 * ```ts
7676 * await ffmpeg.spawn({
77 * cmd: ["-i", "hello.mov", "-c:v", "@clo/libsvtav1", "hello.mp4"],
77 * cmd: ["-i", "hello.mov", "-c:v", "svtav1", "hello.mp4"],
7878 * progress: progress.start("encode hello.mov"),
7979 * });
8080 * ```
......@@ -705,13 +705,16 @@ function renderChildren(state: ReadOnlyNode, now: number, depth: boolean[]) {
705705 continue;
706706 }
707707 const logLines = child.logs
708 .map((msg) => log.formatMessage(msg, true))
708 .map((msg) => log.formatAnsiMessage(msg, true))
709709 .join("")
710710 .trim();
711 if (logLines) for (const line of logLines.split("\n").slice(-Math.min(3, maxHeight))) {
712 item += left + (i === length - 1 && !truncated ? " " : box.line) + " " +
713 ansi.style(ansi.fgBrightBlack, ">") +
714 " " + line + "\n";
711 if (logLines) {
712 for (const line of logLines.split("\n").slice(-Math.min(3, maxHeight))) {
713 item += left + (i === length - 1 && !truncated ? " " : box.line) +
714 " " +
715 ansi.style(ansi.fgBrightBlack, ">") +
716 " " + line + "\n";
717 }
715718 }
716719 maxHeight -= h + logLines.length;
717720 out += item;
......@@ -765,30 +768,32 @@ export function formatUnicodeBar(progress: number, width: number): string {
765768 */
766769export function attachToScreen(
767770 root: Root,
768 { write, startWidget }: Pick<
771 { writeOutput, startWidget }: Pick<
769772 log.WidgetHost,
770 "write" | "startWidget"
773 "writeOutput" | "startWidget"
771774 >,
772775): ts.Dispose {
773776 const stack = new DisposableStack();
774
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 };
777 let widget: log.WidgetInstance | null = null;
781778
782779 stack.use(root.on("change", (items) => {
783 if (rerender) rerender();
784 else if (items.length > 0) startWidget(widget);
780 if (items.length > 0) {
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));
785796 }));
786 stack.use(
787 root.on(
788 "node-detached-log",
789 (msg) => write(log.formatMessage(msg, true)),
790 ),
791 );
792797 stack.use(root.on("node-end", (node) => {
793798 let title = node.text;
794799 let p: ReadOnlyNode | null = node;
......@@ -796,8 +801,8 @@ export function attachToScreen(
796801 const { logs } = node;
797802 if (logs.length > 0) {
798803 const header = `[logs from ${title}]`;
799 write(ansi.style(ansi.fgBrightBlack, header) + "\n");
800 write(logs.map((msg) => log.formatMessage(msg, true)).join(""));
804 writeOutput(ansi.style(ansi.fgBrightBlack, header) + "\n");
805 writeOutput(logs.map((msg) => log.formatAnsiMessage(msg, true)).join(""));
801806 }
802807 }));
803808
......@@ -878,8 +883,6 @@ export interface EncodeStreamOptions {
878883 *
879884 * if the given root adds custom event handlers, they must all have
880885 * json-serializable payloads.
881 *
882 * this function does not use recursion.
883886 */
884887export function encodeEventStream<
885888 Result extends ts.Json,
......@@ -1344,6 +1347,8 @@ interface DeltaFlags {
13441347}
13451348
13461349/**
1350 * NOTE: this function contains bugs and its format is not yet stabilized.
1351 *
13471352 * converts a {@linkcode Root|progress.Root} into an byte stream for
13481353 * communicating progress over the process or network boundary. the output is a
13491354 * raw binary payload that uses an extremely compact representation for the
......@@ -1352,8 +1357,6 @@ interface DeltaFlags {
13521357 *
13531358 * if the given root adds custom event handlers, they must all have
13541359 * json-serializable payloads.
1355 *
1356 * this function does not use recursion.
13571360 */
13581361export function encodeByteStream<
13591362 Result extends ts.Json,
......@@ -1434,7 +1437,7 @@ function writeStreamEvent(
14341437 if (messages !== undefined) {
14351438 w.varUint(messages.length);
14361439 for (const msg of messages) {
1437 let level = logLevelSerialize.indexOf(msg.level);
1440 let level = logLevelSerialize.indexOf(msg.level ?? "info");
14381441 if (level === -1) level = 0;
14391442 w.u8(
14401443 level +
......@@ -1743,6 +1746,12 @@ const globalKeyPool = new KeyPool<number>();
17431746const global: Ref =
17441747 /** @__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
17461755/**
17471756 * for testing. not covered by semver
17481757 * @internal
......@@ -1755,7 +1764,7 @@ export const internals: {
17551764} = /** @__PURE__ */ (() => ({
17561765 readStreamEvent,
17571766 writeStreamEvent,
1758 kNode: kNode as unknown as symbol,
1767 kNode: kNode as symbol,
17591768 EncodedKey: 0 as EncodedKey,
17601769}))();
17611770
lib/readme.changes.md+18-9
......@@ -4,13 +4,17 @@
44
55### breaking
66
7- the `.ts` suffix in the module name has been dropped
78- `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`.
912 - rename `HeadlessWidgetHost` to `WidgetHost`
10 - rename `HeadlessWidgetEnv` to `WidgetHostOptions`
11 - rename `headlessWidgetHost` to `createWidgetHost`
13 - rename `headlessWidgetHost` to `createTerminalWidgetHost`
14 - rename `HeadlessWidgetEnv` to `TerminalWidgetHostOptions`
1215 - rename `replaceGlobalDestination` to `replaceGlobalMessageDestination`
13 - `WidgetHostOptions` takes a `lockTerminal` function instead of
16 - rename `formatMessage` to `formatAnsiMessage`
17 - `WidgetHostOptions` now takes a `lockTerminal` function instead of
1418 `writeInteractive`/`writeOutput` directly. the locking function returns an
1519 interface with these functions, which better aligns with how the draw lock
1620 actually works. additionally, this allows writing more accurate tty bindings
......@@ -20,23 +24,28 @@
2024 - in `getDrawLock`, new required argument `mode`, which can be set to `short`
2125 or `long` to affect how synchronization works. releasing the lock requires
2226 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.
2627- `string/ansi`
2728 - 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
2935### 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.
3140- `process.{stdout,stderr}.write` is intercepted to avoid log interweaving with
3241 `log.ts`/`progress.ts`. this is only done when a widget is created (for
3342 example, calling `progress.start`), so patches are not applied when not
3443 needed. if patching globals is undesirable, you can use an alternative widget
3544 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.
3746- `progress.ts` node gains `node.log.write("word ");` to write a message without
3847 a newline.
39- `async.delay` handles timers longer than 23 days.
48- `async.delay` handles timers longer than 23 days and `Infinity`.
4049- `Lru.revive` recieves bug fixes. this function previously didn't really work.
4150- `ansi` gets more cursor control constants
4251
lib/testing.ts+38-22
......@@ -166,9 +166,10 @@ export class MockScreen implements Disposable, log.WidgetHost {
166166 out: string = "";
167167 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"];
172173 getDrawLock: log.WidgetHost["getDrawLock"];
173174 startWidget: log.WidgetHost["startWidget"];
174175 delay: log.WidgetHost["delay"];
......@@ -182,7 +183,7 @@ export class MockScreen implements Disposable, log.WidgetHost {
182183
183184 constructor({ temporaryUnlocking }: { temporaryUnlocking?: boolean } = {}) {
184185 const callerFile = UNWRAP(stack.capture()[0]);
185 const host = log.createWidgetHost({
186 const host = log.createTerminalWidgetHost({
186187 lockTerminal: () => {
187188 ASSERT(!this.hasTerminalLock);
188189 this.hasTerminalLock = "locked";
......@@ -190,24 +191,32 @@ export class MockScreen implements Disposable, log.WidgetHost {
190191 writeInteractive: (content) => {
191192 this.stderr += content;
192193 this.out += content;
193 const frames = stack.capture().filter(x => x.file !== import.meta.filename);
194 const cutoff = frames.findIndex(f => f.file === callerFile.file && f.fn === callerFile.fn)
194 const frames = stack.capture().filter((x) =>
195 x.file !== import.meta.filename
196 );
197 const cutoff = frames.findIndex((f) =>
198 f.file === callerFile.file && f.fn === callerFile.fn
199 );
195200 this.writeCalls.push({
196 kind: "interactive",
197 stack: cutoff === -1 ? frames : frames.slice(0, cutoff),
198 content,
199 })
201 kind: "interactive",
202 stack: cutoff === -1 ? frames : frames.slice(0, cutoff),
203 content,
204 });
200205 },
201206 writeOutput: (content) => {
202207 this.stdout += content;
203208 this.out += content;
204 const frames = stack.capture().filter(x => x.file !== import.meta.filename);
205 const cutoff = frames.findIndex(f => f.file === callerFile.file && f.fn === callerFile.fn)
209 const frames = stack.capture().filter((x) =>
210 x.file !== import.meta.filename
211 );
212 const cutoff = frames.findIndex((f) =>
213 f.file === callerFile.file && f.fn === callerFile.fn
214 );
206215 this.writeCalls.push({
207 kind: "output",
208 stack: cutoff === -1 ? frames : frames.slice(0, cutoff),
209 content,
210 })
216 kind: "output",
217 stack: cutoff === -1 ? frames : frames.slice(0, cutoff),
218 content,
219 });
211220 },
212221 getSize: () => {
213222 return this;
......@@ -229,8 +238,10 @@ export class MockScreen implements Disposable, log.WidgetHost {
229238 },
230239 now: this.timers.now,
231240 delay: this.timers.delay,
241 color: true,
232242 });
233 this.write = host.write;
243 this.writeOutput = host.writeOutput;
244 this.writeError = host.writeError;
234245 this.getDrawLock = host.getDrawLock;
235246 this.startWidget = host.startWidget;
236247 this.delay = host.delay;
......@@ -253,7 +264,8 @@ export class MockScreen implements Disposable, log.WidgetHost {
253264 if (ms != null) {
254265 const wait = UNWRAP(
255266 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",
257269 );
258270 ASSERT(
259271 ms === wait.duration,
......@@ -299,18 +311,22 @@ export class MockScreen implements Disposable, log.WidgetHost {
299311 this.writeCalls = [];
300312 }
301313
302 fmtWriteCalls() {
314 fmtWriteCalls(): string {
303315 return `\n${ansi.reset}breakdown of calls that rendered this frame:\n` +
304 this.writeCalls.map((call, i) =>
305 `${i + 1}. [${call.kind}] ${ansi.debugAnsi(call.content)}\n`
306 + call.stack.map(frame => ansi.reset + stack.formatFrame(frame, true)).join('\n') +
316 this.writeCalls.map((call, i) =>
317 `${i + 1}. [${call.kind}] ${ansi.debugAnsi(call.content)}\n` +
318 call.stack.map((frame) => ansi.reset + stack.formatFrame(frame, true))
319 .join("\n") +
307320 `\n`
308 ).join('\n').replaceAll(ansi.fgReset, ansi.reset)
321 ).join("\n").replaceAll(ansi.fgReset, ansi.reset);
309322 }
310323
311324 [Symbol.dispose]() {
312325 this.cancel();
313326 }
327
328 capabilities = ["widget", "color"] as const;
329
314330 cancel() {
315331 ASSERT(this.timers.entries.length === 0, "there is a pending write!");
316332 ASSERT(!this.stdout, "unread standard out: " + this.stdout);