authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2025-10-26 14:20:58-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2025-10-27 01:42:01-07:00
log28ec9396072d32c89d6575dd90fedb093dc496c3
tree68ef72c4b5b9a9e5e2107f003f0bff6626e50505
parentdddd86a716404f0e1b473b740a28c2718020aca2
signature Commit is signed but in an unrecognized format.

feat(lib/log): emit `Message` objects instead of text

the logging interface now returns a Message object instead of working purely on ASCII text. messages contain the timestamp, ansi text, level, scope, stack, and any extra metadata[^1]. this change considers four consumers: - generally customizing the format of the terminal log - in browsers, the module must call the correct 'console' API - communicating rich log data across progress.ts' IPC - exporting logs to external tools for display/search/filtering in addition, `scope.tee` is introduced, which allows duplicating log output to another source, such as a file or to an external service. [^1]: custom metadata cannot be set with this change

7 files changed, 547 insertions(+), 399 deletions(-)

framework/hot.ts+2-2
......@@ -151,9 +151,9 @@ export function loadEsbuildCode(
151151 jsx: "automatic",
152152 jsxImportSource: "#jsx",
153153 jsxDev: true,
154 sourcefile: filepath,
155154 sourcemap: "inline",
156 sourceRoot: "/",
155 sourcefile: path.basename(filepath),
156 sourceRoot: path.dirname(filepath),
157157 }).code;
158158 return module._compile(src, filepath, "commonjs");
159159}
lib/log.ts+519-46
......@@ -1,28 +1,47 @@
11/**
2 * By using `lib/log.ts`, your application gets easy scoped logging as well as
2 * by using `lib/log.ts`, your application gets easy scoped logging as well as
33 * integration with terminal widgets such as `lib/progress.ts`.
44 *
5 * The pattern for using this module is to shadow the global `console` with a
6 * local scope:
5 * the pattern for using this module is to shadow the global `console` with a
6 * per-file logging scope:
77 *
8 * import * as log from "@clo/lib/log";
9 * const console = log.scoped("http");
10 * console("hi!"); // debug
8 * ```ts
9 * import * as log from "@clo/lib/log";
10 * const console = log.scoped("http");
11 * console.info("Hello world!"); // info message
12 * console.log("hi!"); // debug message only
13 * ```
1114 *
12 * Or to use the global socpe, import the namespace as `console`.
15 * or to use the global scope, import the module's namespace as `console`.
1316 *
14 * import * as console from "@clo/lib/log";
17 * ```ts
18 * import * as console from "@clo/lib/log";
19 * ```
1520 *
16 * Now, the code reads familiarly and output is organized.
21 * now, the code reads familiarly (`console.log` is universally understood),
22 * but the output is organized into relevant scopes.
1723 *
18 * This module offers a Node.js integration to have logs go to the standard
19 * output. Custom writers can be used with `lib/log/headless.ts`.
24 * in addition to static log messages, a system for interactive I/O
25 * {@linkcode Widget} is provided by calling {@linkcode startWidget}. these
26 * allow showing temporary or interactive information, such as program status
27 * or input prompts. a powerful example of this system in action is
28 * `lib/progress.ts`, which uses a log widget by default to show status.
29 * (TODO: widgets cannot recieve "input" data yet)
30 *
31 * this module offers two environment integrations:
32 * - in node.js, log messages are colored depending on the level and show
33 * widgets directly under the long using ANSI cursor controls.
34 * - otherwise, logs are surfaced using the global `console` API
35 *
36 * custom log integrations can be built on top of this module by calling
37 * `log.tee()` to duplicate all messages elsewhere. for example, a project may set up
2038 *
2139 * @module
2240 */
2341
2442/**
25 * Logging scopes implement part of the `Console` API
43 * logging scopes implement part of the `Console` API. the `log` module itself
44 * satisfies this interface.
2645 */
2746export interface Scope {
2847 /** emit an informational message */
......@@ -44,15 +63,33 @@ export interface Scope {
4463
4564 /** create a nested sub-scope */
4665 scoped(name: string): Scope;
66 /** redirect the logging output of this scope somewhere else */
67 tee(writer: (message: Message) => void): ts.Dispose;
4768}
4869
49/** custom scopes are colored based on the name */
50export function scoped(name: string): Scope {
51 return globalLog.scoped(name);
70export const originalLogArgs = Symbol("originalLogArgs");
71export interface Message {
72 level: "error" | "warn" | "info" | "debug";
73 /** ANSI-styled unicode text */
74 text: string;
75 /** datetime in milliseconds since UNIX epoch */
76 time: number;
77 /** scope name */
78 scope?: string | null;
79 /** captured stack. */
80 stack?: stack.Frame[];
81 /** arbitrary data from the logging source. */
82 custom?: Partial<Record<string, unknown>>;
83 /**
84 * original logging arguments, if present. this field is indexed by a symbol
85 * so that it is lost during JSON serialization, as callers are allowed to log
86 * non-serializable data.
87 */
88 [originalLogArgs]?: unknown[];
5289}
5390
54// these functions implement `Scope`. If a function takes in `Scope`, the
55// namespace import of this file can be passed to it.
91// these functions implement `Scope` for the module's namespace. that means if
92// a function takes in `Scope`, this file's namespace satisfies that.
5693
5794/** emit an informational message on the global log scope */
5895export function info(...args: unknown[]) {
......@@ -79,10 +116,34 @@ export function debug(...args: unknown[]) {
79116 globalLog.debug(...args);
80117}
81118
119export function scoped(name: string): Scope {
120 return globalLog.scoped(name);
121}
122
123/**
124 * a widget is an interactive display that persists at the end
125 * of the log. this can be used to implement status bars, progress
126 * indicators, and other human I/O. only 'format' is required.
127 *
128 * ```ts
129 * using _ = log.startWidget({
130 * format: (now) => `It is ${new Date().toString()} right now\n`
131 * + `A random number: ${Math.random()}`,
132 * // no trailing '\n' is needed.
133 * });
134 * await async.delay(10000);
135 *
136 * import * as log from "@clo/lib/log.ts";
137 * import * as async from "@clo/lib/async.ts";
138 * ```
139 */
140export function startWidget(widget: Widget): ts.Dispose {
141 return globalWidgetHost.startWidget(widget);
142}
143
82144/**
83145 * no built-in prefix or formatting. ensures the text does not interweave. data
84 * will be flushed in the next frame or when drawing is
85 * {@link getDrawLock|unlocked}.
146 * will be flushed in the next frame or when drawing is {@link getDrawLock|unlocked}.
86147 */
87148export function writeLine(text: string) {
88149 globalWidgetHost.writeLine(text);
......@@ -96,48 +157,403 @@ export function getDrawLock(): ts.Dispose {
96157 return globalWidgetHost.getDrawLock();
97158}
98159
99/**
100 * a widget is an interactive display that persists at the end
101 * of the log. this can be used to implement status bars, progress
102 * indicators, and other human i/o. only 'format' is required.
103 */
104export function startWidget(widget: Widget): ts.Dispose {
105 return globalWidgetHost.startWidget(widget);
160export function headlessScope(dispatch: DispatchFunction): Scope {
161 return new Scope(dispatch);
106162}
107163
164/** See {@linkcode startWidget} */
108165export interface Widget {
109166 /**
110167 * return the widget's text. return null to detach the widget.
111168 * may get called more often than the specified `fps`.
112169 * supports color codes but not ansi cursor movements.
113 */
114 format(now: ReturnType<typeof performance.now>): string | null;
115 /** 'null' to never update (use 'onChange'). defaults to 12 fps */
116 fps?: number | null;
117 /** Subscribe to manual widget updates. Call `rerender` when needed. */
170 */ format(
171 now: ReturnType<typeof performance.now>,
172 ):
173 | string
174 | null; /** 'null' to never update (use 'onChange'). defaults to 12 fps */
175 fps?:
176 | number
177 | null; /** Subscribe to manual widget updates. Call `rerender` when needed. */
118178 onChange?(rerender: () => void): () => void;
119 // /**
120 // * listen for keyboard events.
121 // */
122 // onKey?(key: string): void;
179 /** listen for keyboard events. */
180 onKey?(key: string): void;
123181}
124182
125const globalWidgetHost = /* @__PURE__ */ ((): headless.WidgetHost => {
183/** {@linkcode widgetHost}'s input takes terminal i/o as well as timing APIs */
184export interface HeadlessWidgetEnv {
185 /** Recieves ANSI escape sequences for interactive data. */
186 writeInteractive(text: string): void;
187 /** Recieves log content (from `writeLine`). */
188 writeOutput(text: string): void;
189 /** Monotonic. */
190 now(): ReturnType<typeof performance.now>;
191 /** 0ms indicates "one frame". */
192 wait(ms: number, cb: () => void): () => void;
193 /** Called often. */
194 getSize(): { columns: number; rows: number };
195 /** Called to enable input events */
196 onInput?(write: (bytes: Uint8Array | string) => void): () => void;
197}
198
199/** an implementation of an ANSI-based widget host */
200export interface HeadlessWidgetHost {
201 /** see the top-level {@linkcode writeLine} function */
202 writeLine(text: string): void;
203 /** see the top-level {@linkcode getDrawLock} function */
204 getDrawLock(): ts.Dispose;
205 /** see the top-level {@linkcode startWidget} function */
206 startWidget(widget: Widget): ts.Dispose;
207 /** stop all widgets and remove all timers. */
208 cancel(): void;
209}
210
211interface WidgetState {
212 frameTime: number;
213 next: number;
214 unsub: (() => void) | null;
215}
216
217/**
218 * terminal widget rendering is done by specifying all system APIs up front in
219 * an interface, creating an instance of the "widget host".
220 *
221 * some notes on widget rendering:
222 * - Never flush immediately (exception for
223 * {@linkcode WidgetHost["getDrawLock"]|getDrawLock}), always do it next tick.
224 * - Maximum of one `wait` call at once. When the expected time suddenly
225 * shrinks, the timer is rescheduled.
226 * - When redrawing widget lines, three tricks are done to reduce flickering:
227 * 1. Tell the terminal not to flicker (ansi.syncStart/syncEnd)
228 * 2. A simple prefix-based diffing algorithm for skipping unchanged text
229 * 3. Avoid clearing a line before redrawing it.
230 * Points 2 and 3 are used for terminals that are slow or do not support sync.
231 */
232export function headlessWidgetHost(env: HeadlessWidgetEnv): HeadlessWidgetHost {
233 const { writeOutput, writeInteractive, now, wait, getSize } = env;
234 type Cancel = ReturnType<typeof wait>;
235
236 let timer: Cancel | null = null;
237
238 let locks = 0;
239 let redrawTime = 0;
240 let lastFlush = 0;
241 let buffer = "";
242 const widgets: Widget[] = [];
243 const internals: WidgetState[] = [];
244 let lines: string[] = [];
245
246 function redrawCallback() {
247 timer = null;
248 redrawTime = (lastFlush = now()) - 0.00001; // windows time precision workaround
249
250 if (!lines.length && !widgets.length) {
251 buffer && writeOutput(buffer);
252 buffer = "";
253 return;
254 }
255
256 const { columns, rows } = getSize();
257 let newWidgetLines: string[] = [];
258 let next = Infinity;
259 if (widgets[0]) {
260 for (let w = 0, { length } = widgets; w < length; w += 1) {
261 const outText = UNWRAP(widgets[w]).format(lastFlush);
262 if (!outText) {
263 widgets.splice(w, 1);
264 UNWRAP(internals.splice(w, 1)[0]).unsub?.();
265 w -= 1;
266 length -= 1;
267 continue;
268 }
269 const rowsLeft = Math.max(1, rows - newWidgetLines.length - 1);
270 if (rowsLeft === 1) break;
271 const lines = outText.split("\n").slice(0, rowsLeft);
272 newWidgetLines.push(
273 ...lines.map((line) => ansi.trimToWidth(line, columns - 1)),
274 );
275
276 next = Math.min(next, UNWRAP(internals[w]).frameTime);
277 }
278
279 newWidgetLines = newWidgetLines.slice(0, rows - 1);
280 }
281
282 if (next < Infinity) redrawSoon(next);
283
284 if (!newWidgetLines[0]) {
285 if (lines.length) {
286 let clearLinesTop = Math.min(
287 lines.length,
288 string.countNewlines(buffer),
289 );
290 writeInteractive(
291 ansi.startOfLine +
292 // skip up the shared widget space
293 ansi.cursorUp(lines.length) +
294 // clear the lines to contain `buffer`
295 (ansi.clearFullLine + ansi.startOfNextLine)
296 .repeat(clearLinesTop) +
297 ansi.cursorUp(clearLinesTop),
298 );
299 lines = [];
300 }
301 buffer && writeOutput(buffer);
302 buffer = "";
303 return;
304 }
305
306 if (buffer) {
307 // do not perform diffing since the entire screen is moving down
308 // TODO: should i handle wrapping?
309 let clearLinesTop = Math.min(
310 lines.length,
311 string.countNewlines(buffer),
312 );
313 const oldLines = lines.slice(clearLinesTop);
314 writeInteractive(
315 ansi.syncStart + ansi.startOfLine +
316 // skip up the shared widget space
317 ansi.cursorUp(lines.length - clearLinesTop + 1) +
318 // clear the lines to contain `buffer`
319 ansi.clearFullLine +
320 (ansi.cursorUp(1) + ansi.clearFullLine)
321 .repeat(clearLinesTop - 1),
322 );
323 // then write output lines on standard out
324 writeOutput(buffer);
325 writeInteractive(
326 // the widget text
327 newWidgetLines.map((newLine, i) =>
328 (newLine.includes("\x1b") && !newLine.endsWith(ansi.reset)
329 ? newLine + ansi.reset
330 : newLine) +
331 // clear rest of line if needed
332 (oldLines[i] &&
333 ansi.widthInTerminal(oldLines[i]) >
334 ansi.widthInTerminal(newLine)
335 ? ansi.clearToEndOfLine
336 : "") +
337 "\n"
338 ).join("") + ansi.syncEnd,
339 );
340 } else {
341 const clearLinesBottom = Math.min(
342 lines.length,
343 Math.max(0, lines.length - (newWidgetLines?.length ?? 0)),
344 );
345 writeInteractive(
346 ansi.syncStart + ansi.startOfLine +
347 // clear the bottom lines
348 (clearLinesBottom
349 ? (ansi.cursorUp(1) + ansi.clearToEndOfLine)
350 .repeat(clearLinesBottom)
351 : "") +
352 // skip up the widget space
353 ansi.cursorUp(lines.length - clearLinesBottom) +
354 // the widget text
355 newWidgetLines.map((newLine, i) =>
356 (newLine.includes("\x1b") && !newLine.endsWith(ansi.reset)
357 ? newLine + ansi.reset
358 : newLine) +
359 // clear rest of line if needed
360 (lines[i] &&
361 ansi.widthInTerminal(lines[i]) >
362 ansi.widthInTerminal(newLine)
363 ? ansi.clearToEndOfLine
364 : "") +
365 "\n"
366 ).join("") +
367 ansi.syncEnd,
368 );
369 }
370 lines = newWidgetLines;
371
372 buffer = "";
373 }
374
375 function redrawSoon(ms: number) {
376 if (locks > 0 || (ms === 0 && timer)) return;
377 const newRedrawTime = now() + ms;
378 if (timer) {
379 if (redrawTime < newRedrawTime) return;
380 timer(); // cancel previous
381 timer = null;
382 }
383 redrawTime = newRedrawTime + 1;
384 timer = wait(ms, redrawCallback);
385 }
386
387 function flushAndClear() {
388 timer?.();
389 timer = null;
390 if (lines.length > 0) {
391 // clear widgets
392 }
393 if (buffer.length > 0) writeOutput(buffer), buffer = "";
394 }
395
396 return {
397 writeLine(chunk) {
398 buffer += chunk + "\n";
399 redrawSoon(0);
400 },
401 getDrawLock() {
402 if (locks === 0) flushAndClear();
403 locks += 1;
404 return ts.defer(() => {
405 locks -= 1;
406 if (locks === 0) {
407 if (buffer.length > 0) writeOutput(buffer), buffer = "";
408 // TODO: reinit widget
409 }
410 });
411 },
412 startWidget(w) {
413 if (!widgets.includes(w)) {
414 const state: WidgetState = {
415 next: 0,
416 unsub: null,
417 frameTime: 1000 / (w.fps ?? 0),
418 };
419 widgets.push(w);
420 internals.push(state);
421 state.unsub = w.onChange?.(() => {
422 state.next = 0;
423 redrawSoon(0);
424 }) ?? null;
425 redrawSoon(0);
426 }
427 return ts.defer(() => {
428 const i = widgets.indexOf(w);
429 widgets.splice(i, 1);
430 UNWRAP(internals.splice(i, 1)[0]).unsub?.();
431 redrawSoon(0);
432 });
433 },
434 cancel() {
435 flushAndClear();
436 },
437 };
438}
439
440const logColors = node.process?.stderr.isTTY ?? false;
441
442let formatLine = /* @__PURE__ */ (() => {
443 const fwo = node.builtin("util")?.formatWithOptions;
444 if (fwo) return (...args: unknown[]) => fwo({ colors: logColors }, ...args);
445 return (...args: unknown[]) =>
446 args.map((arg) => {
447 if (typeof arg === "string") return arg;
448 try {
449 return JSON.stringify(arg);
450 } catch {
451 return "[incompatible json " + typeof arg + "]";
452 }
453 }).join(" ");
454})();
455
456let withinDispatch = false;
457let withinStackCapture = false;
458
459const Scope = class Scope implements Scope {
460 name: string | undefined;
461 // TODO: this abstraction implementation has low performance. making every
462 // scope define it's own dispatch is needed to correctly implement `tee`.
463 // since it is possible to implement this in simple and non-recursive way, i
464 // feel okay adding the `tee` API.
465 #dispatches: DispatchFunction[] = [];
466 #dispatch: DispatchFunction = (m) => this.#dispatches.forEach((cb) => cb(m));
467
468 constructor(
469 dispatch: (m: Message) => void,
470 name: string | undefined = undefined,
471 ) {
472 this.name = name;
473 this.#dispatches = [dispatch];
474 }
475
476 #log(level: Message["level"], args: unknown[]) {
477 let frames;
478 if (!withinStackCapture) {
479 withinStackCapture = true;
480 frames = stack.capture().slice(2);
481 withinStackCapture = false;
482 }
483 const m = {
484 level,
485 scope: this.name,
486 get text() {
487 const value = formatLine(...args);
488 Object.defineProperty(this, "text", { value });
489 return value;
490 },
491 time: Date.now(),
492 stack: frames,
493 [originalLogArgs]: args,
494 };
495 if (withinDispatch) return void globalOutputFunction(m);
496 withinDispatch = true;
497 this.#dispatch(m);
498 withinDispatch = false;
499 }
500
501 info: (...args: unknown[]) => void = (...args: unknown[]) => {
502 this.#log("info", args);
503 };
504 warn: (...args: unknown[]) => void = (...args: unknown[]) => {
505 this.#log("warn", args);
506 };
507 error: (...args: unknown[]) => void = (...args: unknown[]) => {
508 this.#log("error", args);
509 };
510 log: (...args: unknown[]) => void = (...args: unknown[]) => {
511 this.#log("debug", args);
512 };
513 debug: (...args: unknown[]) => void = (...args: unknown[]) => {
514 this.#log("debug", args);
515 };
516
517 scoped(name: string): Scope {
518 const current = this.name;
519 return new Scope(
520 this.#dispatch,
521 name ? current ? `${current}/${name}` : name : current,
522 );
523 }
524
525 tee(destination: DispatchFunction) {
526 this.#dispatches.push(destination);
527 return ts.defer(() =>
528 this.#dispatches.splice(this.#dispatches.indexOf(destination), 1)
529 );
530 }
531};
532
533const globalWidgetHost = /* @__PURE__ */ (() => {
534 // TODO; write this in a tree-shakable manner
126535 const { process } = node;
127536 if (!process || !process.stderr.isTTY) {
537 // return a no-op
538 let warned = false;
128539 return {
129 // TODO: allow passing a level here
130 writeLine: (line) => console["log"](line),
540 writeLine: (line: string) => console.log(line),
131541 getDrawLock: () => ts.defer(() => {}),
132 startWidget: (w) => {
542 startWidget: (w: Widget) => {
543 if (!warned) {
544 console.warn(
545 '"@clo/lib/log.ts"\'s startWidget was called in an environment ' +
546 "that does not support the Node.js 'process' API. Widgets" +
547 "will not be visible.",
548 );
549 }
133550 const close = w.onChange?.(() => {});
134551 return ts.defer(close ?? (() => {}));
135552 },
136553 cancel: () => {},
137554 };
138555 }
139 const widget = headless.widgetHost({
140 // TODO:allow passing a level here to put debug, warn and errors on stderr
556 const widget = headlessWidgetHost({
141557 writeOutput: (string) => process.stdout.write(string),
142558 writeInteractive: (string) => process.stderr.write(string),
143559 now: () => performance.now(),
......@@ -149,13 +565,70 @@ const globalWidgetHost = /* @__PURE__ */ ((): headless.WidgetHost => {
149565 });
150566 process.addListener("beforeExit", () => widget.cancel());
151567 process.addListener("exit", () => widget.cancel());
568
569 // Make sure the default `console` will not interweave with widgets
570 try {
571 const console = globalThis.console as unknown as Record<string, Function>;
572 for (const key of Object.keys(console)) {
573 const fn = console[key];
574 if (typeof fn === "function") {
575 console[key] = function (...args: unknown[]) {
576 using _ = getDrawLock();
577 fn.apply(this, args);
578 };
579 }
580 }
581 } catch {}
582
152583 return widget;
153584})();
154const globalLog = /* @__PURE__ */ headless.logger({
155 log: globalWidgetHost,
156 colors: node.process?.stderr.isTTY ?? false,
157});
158585
586const levelToAnsi: Record<Message["level"], string> = {
587 info: `${ansi.fgBlue}info`,
588 warn: `${ansi.fgYellow}warn`,
589 error: `${ansi.fgRed}error`,
590 debug: `${ansi.dim}dbg`,
591};
592
593let globalOutputFunction!: DispatchFunction;
594const globalLog = /* @__PURE__ */ (() => {
595 const colors = node.process?.stderr.isTTY ?? false;
596 globalOutputFunction = node.process
597 // In Node.js, coordinate with the widget host
598 ? ({ level, scope, text }) => {
599 if (!text) return globalWidgetHost.writeLine("");
600 const prefix = colors
601 // colorful
602 ? `${levelToAnsi[level]}${
603 scope ? `(${scope})` : ""
604 }${ansi.fgReset}${ansi.dim}:${ansi.reset} `
605 // colorless
606 : scope
607 ? `${level}(${scope}): `
608 : `${level}: `;
609 globalWidgetHost.writeLine(prefix + text);
610 }
611 // Otherwise, forward to `console`
612 : (m) => {
613 let { level, [originalLogArgs]: args = [m.text], scope } = m;
614 if (scope) {
615 const arg0 = args[0];
616 const prefix = `[${scope}]`;
617 if (typeof arg0 === "string") args[0] = `${prefix} ${arg0}`;
618 else args.unshift(prefix);
619 }
620 console[level](...args);
621 };
622 return new Scope(globalOutputFunction);
623})();
624
625export interface DispatchFunction {
626 (message: Message): void;
627}
628
629import * as ansi from "lib/string/ansi.ts";
159630import * as node from "lib/node.ts";
160import * as headless from "lib/log/headless.ts";
631import * as stack from "lib/log/stack.ts";
632import * as string from "lib/string.ts";
161633import * as ts from "lib/ts.ts";
634import { UNWRAP } from "lib/assert.ts";
lib/log/headless.ts deleted-345
......@@ -1,345 +0,0 @@
1/**
2 * terminal widget rendering and logging is done by specifying all system APIs up front in an
3 * interface, creating an instance of the widget host. `lib/log.ts` fufills
4 * this interface with `node:process` and `globalThis`.
5 *
6 * this file is under heavy construction.
7 * - I am happy with the overall API, likely no changes.
8 * - There are certainly a lot of bugs.
9 *
10 * some notes on widget rendering:
11 * - Never flush immediately (exception for
12 * {@linkcode WidgetHost["getDrawLock"]|getDrawLock}), always do it next tick.
13 * - Maximum of one `wait` call at once. When the expected time suddenly
14 * shrinks, the timer is rescheduled.
15 * - When redrawing widget lines, three tricks are done to reduce flickering:
16 * 1. Tell the terminal not to flicker (ansi.syncStart/syncEnd)
17 * 2. A simple prefix-based diffing algorithm for skipping unchanged text
18 * 3. Avoid clearing a line before redrawing it.
19 * Points 2 and 3 are used for terminals that are slow or do not support sync.
20 *
21 * @module
22 */
23
24/** {@linkcode widgetHost}'s input takes terminal i/o as well as timing APIs */
25export interface WidgetHostEnv {
26 /** Recieves ANSI escape sequences for interactive data. */
27 writeInteractive(text: string): void;
28 /** Recieves log content (from `writeLine`). */
29 writeOutput(text: string): void;
30 /** Monotonic. */
31 now(): ReturnType<typeof performance.now>;
32 /** 0ms indicates "one frame". */
33 wait(ms: number, cb: () => void): () => void;
34 /** Called often. */
35 getSize(): { columns: number; rows: number };
36 // onInput?(write: (bytes: Uint8Array | string) => void): () => void;
37}
38
39/** Returned by {@linkcode widgetHost} */
40export interface WidgetHost {
41 /**
42 * no built-in prefix or formatting. ensures the text does not interweave
43 * with widgets. data will be flushed in the next frame or when drawing is
44 * unlocked.
45 */
46 writeLine(text: string): void;
47 /**
48 * while locked, no widgets will draw. prefer `writeLine`.
49 * this lock is not exclusive.
50 */
51 getDrawLock(): ts.Dispose;
52 /**
53 * a widget is an interactive display that persists at the end
54 * of the log. this can be used to implement status bars, progress
55 * indicators, and other human i/o. only `format` is required.
56 */
57 startWidget(widget: log.Widget): ts.Dispose;
58 /** stop all widgets and remove all timers. */
59 cancel(): void;
60}
61
62interface WidgetState {
63 frameTime: number;
64 next: number;
65 unsub: (() => void) | null;
66}
67
68/** Implement a widget host by providing an environment implementation. */
69export function widgetHost(env: WidgetHostEnv): WidgetHost {
70 const { writeOutput, writeInteractive, now, wait, getSize } = env;
71 type Cancel = ReturnType<typeof wait>;
72
73 let timer: Cancel | null = null;
74
75 let locks = 0;
76 let redrawTime = 0;
77 let lastFlush = 0;
78 let buffer = "";
79 const widgets: log.Widget[] = [];
80 const internals: WidgetState[] = [];
81 let lines: string[] = [];
82
83 function redrawCallback() {
84 timer = null;
85 redrawTime = (lastFlush = now()) - 0.00001; // windows time precision workaround
86
87 if (!lines.length && !widgets.length) {
88 buffer && writeOutput(buffer);
89 buffer = "";
90 return;
91 }
92
93 const { columns, rows } = getSize();
94 let newWidgetLines: string[] = [];
95 let next = Infinity;
96 if (widgets[0]) {
97 for (let w = 0, { length } = widgets; w < length; w += 1) {
98 const outText = UNWRAP(widgets[w]).format(lastFlush);
99 if (!outText) {
100 widgets.splice(w, 1);
101 UNWRAP(internals.splice(w, 1)[0]).unsub?.();
102 w -= 1;
103 length -= 1;
104 continue;
105 }
106 const rowsLeft = Math.max(1, rows - newWidgetLines.length - 1);
107 if (rowsLeft === 1) break;
108 const lines = outText.split("\n").slice(0, rowsLeft);
109 newWidgetLines.push(
110 ...lines.map((line) => ansi.trimToWidth(line, columns - 1)),
111 );
112
113 next = Math.min(next, UNWRAP(internals[w]).frameTime);
114 }
115
116 newWidgetLines = newWidgetLines.slice(0, rows - 1);
117 }
118
119 if (next < Infinity) redrawSoon(next);
120
121 if (!newWidgetLines[0]) {
122 if (lines.length) {
123 let clearLinesTop = Math.min(
124 lines.length,
125 string.countNewlines(buffer),
126 );
127 writeInteractive(
128 ansi.startOfLine +
129 // skip up the shared widget space
130 ansi.cursorUp(lines.length) +
131 // clear the lines to contain `buffer`
132 (ansi.clearFullLine + ansi.startOfNextLine)
133 .repeat(clearLinesTop) +
134 ansi.cursorUp(clearLinesTop),
135 );
136 lines = [];
137 }
138 buffer && writeOutput(buffer);
139 buffer = "";
140 return;
141 }
142
143 if (buffer) {
144 // do not perform diffing since the entire screen is moving down
145 // TODO: should i handle wrapping?
146 let clearLinesTop = Math.min(
147 lines.length,
148 string.countNewlines(buffer),
149 );
150 const oldLines = lines.slice(clearLinesTop);
151 writeInteractive(
152 ansi.syncStart + ansi.startOfLine +
153 // skip up the shared widget space
154 ansi.cursorUp(lines.length - clearLinesTop + 1) +
155 // clear the lines to contain `buffer`
156 ansi.clearFullLine +
157 (ansi.cursorUp(1) + ansi.clearFullLine)
158 .repeat(clearLinesTop - 1),
159 );
160 // then write output lines on standard out
161 writeOutput(buffer);
162 writeInteractive(
163 // the widget text
164 newWidgetLines.map((newLine, i) =>
165 (newLine.includes("\x1b") && !newLine.endsWith(ansi.reset)
166 ? newLine + ansi.reset
167 : newLine) +
168 // clear rest of line if needed
169 (oldLines[i] &&
170 ansi.widthInTerminal(oldLines[i]) >
171 ansi.widthInTerminal(newLine)
172 ? ansi.clearToEndOfLine
173 : "") +
174 "\n"
175 ).join("") + ansi.syncEnd,
176 );
177 } else {
178 const clearLinesBottom = Math.min(
179 lines.length,
180 Math.max(0, lines.length - (newWidgetLines?.length ?? 0)),
181 );
182 writeInteractive(
183 ansi.syncStart + ansi.startOfLine +
184 // clear the bottom lines
185 (clearLinesBottom
186 ? (ansi.cursorUp(1) + ansi.clearToEndOfLine)
187 .repeat(clearLinesBottom)
188 : "") +
189 // skip up the widget space
190 ansi.cursorUp(lines.length - clearLinesBottom) +
191 // the widget text
192 newWidgetLines.map((newLine, i) =>
193 (newLine.includes("\x1b") && !newLine.endsWith(ansi.reset)
194 ? newLine + ansi.reset
195 : newLine) +
196 // clear rest of line if needed
197 (lines[i] &&
198 ansi.widthInTerminal(lines[i]) >
199 ansi.widthInTerminal(newLine)
200 ? ansi.clearToEndOfLine
201 : "") +
202 "\n"
203 ).join("") +
204 ansi.syncEnd,
205 );
206 }
207 lines = newWidgetLines;
208
209 buffer = "";
210 }
211
212 function redrawSoon(ms: number) {
213 if (locks > 0 || (ms === 0 && timer)) return;
214 const newRedrawTime = now() + ms;
215 if (timer) {
216 if (redrawTime < newRedrawTime) return;
217 timer(); // cancel previous
218 timer = null;
219 }
220 redrawTime = newRedrawTime + 1;
221 timer = wait(ms, redrawCallback);
222 }
223
224 function flushAndClear() {
225 timer?.();
226 timer = null;
227 if (lines.length > 0) {
228 // clear widgets
229 }
230 if (buffer.length > 0) writeOutput(buffer), buffer = "";
231 }
232
233 return {
234 writeLine(chunk) {
235 buffer += chunk + "\n";
236 redrawSoon(0);
237 },
238 getDrawLock() {
239 if (locks === 0) flushAndClear();
240 locks += 1;
241 return ts.defer(() => {
242 locks -= 1;
243 if (locks === 0) {
244 if (buffer.length > 0) writeOutput(buffer), buffer = "";
245 // TODO: reinit widget
246 }
247 });
248 },
249 startWidget(w) {
250 if (!widgets.includes(w)) {
251 const state: WidgetState = {
252 next: 0,
253 unsub: null,
254 frameTime: 1000 / (w.fps ?? 0),
255 };
256 widgets.push(w);
257 internals.push(state);
258 state.unsub = w.onChange?.(() => {
259 state.next = 0;
260 redrawSoon(0);
261 }) ?? null;
262 redrawSoon(0);
263 }
264 return ts.defer(() => {
265 const i = widgets.indexOf(w);
266 widgets.splice(i, 1);
267 UNWRAP(internals.splice(i, 1)[0]).unsub?.();
268 redrawSoon(0);
269 });
270 },
271 cancel() {
272 flushAndClear();
273 },
274 };
275}
276
277/** Input interface for {@linkcode logger} */
278export interface LoggerOptions {
279 /** Provide either a line writer or a log writer. */
280 log: {
281 writeLine: WidgetHost["writeLine"];
282 } | {
283 writeLog: (prefix: string, level: Level, ...args: unknown[]) => void;
284 };
285 colors: boolean;
286}
287
288/** Implement a logging scope by providing an environment interface. */
289export function logger(env: LoggerOptions): log.Scope {
290 const { colors, log } = env;
291 const levels = colors
292 ? [
293 ansi.style(ansi.fgBlue, "info"),
294 ansi.style(ansi.fgYellow, "warn"),
295 ansi.style(ansi.fgRed, "error"),
296 ] as const
297 : ["info", "warn", "error"] as const;
298 const colon = colors ? ansi.style(ansi.fgBrightBlack, ":") + " " : ": ";
299 const logFn = "writeLog" in log
300 ? log.writeLog
301 : ((prefix: string, level: Level, ...args: unknown[]) => {
302 if (args.length === 0) return log.writeLine("");
303 const start = prefix
304 ? prefix + (level > 0 ? levels[level] + colon : "")
305 : (levels[level] + colon);
306 // TODO: make a more general "inspect" function
307 if (args[0] instanceof Error) {
308 log.writeLine(start + stack.format(args[0], colors));
309 } else {
310 log.writeLine(start + util.format(...args));
311 }
312 });
313
314 function scoped(name: string): log.Scope {
315 const formatted = name ? name + colon : name;
316 const fn = logFn.bind(null, formatted, 0) as Partial<log.Scope>;
317 // TODO: repair
318 fn.info = fn as log.Scope["info"];
319 fn.debug = fn as log.Scope["info"];
320 fn.log = fn as log.Scope["info"];
321 fn.warn = logFn.bind(null, formatted, 1);
322 fn.error = logFn.bind(null, formatted, 2);
323 fn.scoped = createSubScope;
324 Object.defineProperty(fn, "name", { value: name });
325 return fn as log.Scope;
326 }
327
328 function createSubScope(this: log.Scope, name: string) {
329 // @ts-ignore
330 const { name: parent } = this;
331 return scoped(parent ? parent + "/" + name : name);
332 }
333
334 return scoped("");
335}
336
337type Level = 0 | 1 | 2;
338
339import * as ansi from "lib/string/ansi.ts";
340import * as string from "lib/string.ts";
341import * as ts from "lib/ts.ts";
342import * as stack from "lib/log/stack.ts";
343import * as util from "node:util";
344import { UNWRAP } from "lib/assert.ts";
345import type * as log from "lib/log.ts";
lib/log/stack.ts+5-4
......@@ -25,7 +25,7 @@
2525 * @module
2626 */
2727
28/** Retrieved from `parse` */
28/** retrieved from `parse` */
2929export interface Frame {
3030 /** `null` if top-level code or unnamed function. */
3131 fn: string | null;
......@@ -45,10 +45,11 @@ export interface Frame {
4545 * derived from stacktracejs. this file is more refined than those two:
4646 * https://github.com/oven-sh/bun/blob/b5f31a6ee2f52ea67eabeb61f6e6e71215d55b26/src/bake/client/stack-trace.ts
4747 * https://github.com/stacktracejs/error-stack-parser/blob/9f33c224b5d7b607755eb277f9d51fcdb7287e24/error-stack-parser.js
48 */
49/**
48 *
5049 * supports parsing v8, JavaScriptCore, SpiderMonkey, and IE error stack
51 * frames, effectively working in every JavaScript environment.
50 * frames, effectively working in every JavaScript environment. leaves
51 * filesystem urls and paths intact (won't convert 'file:///' to or from a file
52 * path)
5253 */
5354export function parse(error: Error | string): Frame[] | null {
5455 const stack = (error as Error)?.stack ?? error;
lib/node.ts+6
......@@ -47,6 +47,12 @@ interface Builtins {
4747 join(...parts: string[]): string;
4848 sep: string;
4949 };
50 "util": {
51 formatWithOptions(
52 options: { colors?: boolean },
53 ...args: unknown[]
54 ): string;
55 };
5056}
5157/**
5258 * Subset of Node.js binding types
lib/readme.changes.md+13
......@@ -5,6 +5,19 @@
55### breaking
66
77- promote `log/progress.ts` to top level `progress.ts`
8- rework Log dispatching
9 - delete `log/headless.ts` by moving it into `log`
10 - headless scopes now emit `log.Message` objects instead of ANSI text,
11 templating is done in the consumer of the headless logging scope.
12
13### features
14
15- add `log.tee` (and `log.Scope.tee`)
16- `log` in Node.js will inject into `console.*` to prevent interweaving logs
17 with widget output text. this injection is enabled regardless of if widgets
18 are actually running, but do not otherwise change their behavior.
19- `log` scopes render differently in the terminal now
20- `log` in the browser will call the correct
821
922## v2
1023
run.js+2-2
......@@ -45,8 +45,8 @@ const log = hot.load("./lib/log.ts");
4545console.info = log.info;
4646console.warn = log.warn;
4747console.error = log.error;
48console.debug = log.scoped("debug");
49console["log"] = console.debug;
48console.debug = log.debug;
49console["log"] = log.log;
5050
5151process.on("uncaughtException", (error) => {
5252 console.error(error);