1/**
2 * by using `@clo/lib/log`, an application gets easy scoped logging as well
3 * 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 with
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.
9 *
10 * the pattern for using this module is to shadow the global `console` with a
11 * per-file logging scope, which makes it impossible to use the wrong logger.
12 *
13 * ```ts
14 * import * as log from "@clo/lib/log";
15 * const console = log.scoped("http");
16 * console.info("Hello world!"); // info message
17 * console.log("hi!"); // debug message only
18 * ```
19 *
20 * or to use the global scope, import the module's namespace as `console`.
21 *
22 * ```ts
23 * import * as console from "@clo/lib/log";
24 * ```
25 *
26 * now code reads familiarly (`console.log` is universally understood),
27 * but the output is organized into relevant scopes.
28 *
29 * in addition to static log messages, a system for interactive I/O via the
30 * {@linkcode Widget} interface can be started with {@linkcode startWidget}.
31 * these allow showing temporary or interactive information, such as program
32 * status or input prompts. a powerful example of this system in action is
33 * `lib/progress.ts`, which uses a widget as it's default rendering backend.
34 * alongside this, a very complicated locking system is provided to ensure that
35 * widgets to not interfere with globals like `process.stdout.write`.
36 * (TODO: widgets cannot recieve "input" data yet)
37 *
38 * `lib/log.ts` offers two environment integrations:
39 * - in node.js, log messages are colored depending on the level and show
40 * widgets directly under the long using ANSI cursor controls. when the
41 * terminal is not a TTY, widgets are silent.
42 * - in browsers and other, logs are surfaced using the global `console` API
43 * and widgets are disabled.
44 *
45 * custom log integrations can be built on top of this module by calling
46 * `log.tee()` to duplicate all messages elsewhere. for example, a project may
47 * configure logs to upload to a telemetry service. these logs can carry custom
48 * metadata as well as source code traces -- amazing tools for debugging.
49 *
50 * if bundling for the browser, see the details in `./node.ts` on how to
51 * trigger tree-shaking to eliminate the node.js bindings in your web bundle.
52 *
53 * @module
54 */
55
56/**
57 * logging scopes implement part of the `Console` API. the `log` module itself
58 * satisfies this interface.
59 */
60export interface Scope {
61 /** emit an informational message */
62 info(...args: unknown[]): void;
63 /** emit an advisory message */
64 warn(...args: unknown[]): void;
65 /** emit a failure message */
66 error(...args: unknown[]): void;
67
68 /**
69 * emit a debugging message.
70 */
71 log(...args: unknown[]): void;
72 /**
73 * emit a debugging message. this method is in place for compatibility with
74 * the `console` API. prefer calling `.log` directly.
75 * @internal
76 */
77 debug(...args: unknown[]): void;
78
79 /** write a Message object directly. */
80 writeMessage(message: Message): void;
81
82 /** create a nested sub-scope */
83 scoped(name: string, custom?: Message["custom"]): Scope;
84 /** redirect the logging output of this scope somewhere else */
85 tee(writer: (message: Message) => void): ts.Dispose;
86}
87
88export interface RootScope extends Scope {
89 /** write a partial line to the output. */
90 write(text: string): void;
91}
92
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 */
99export interface Message {
100 /** ANSI-styled text */
101 text: string;
102 /** datetime in milliseconds since UNIX epoch */
103 time: number;
104 /**
105 * type of message, if known.
106 * @default info
107 */
108 level?: MessageLevel;
109 /** scope name */
110 scope?: string | null;
111 /** captured stack, if available */
112 stack?: stack.Frame[];
113 /** arbitrary data from the logging source. */
114 custom?: Partial<Record<string, ts.Json>>;
115 /**
116 * if there is a newline at the end of this log line.
117 * @default true
118 */
119 newline?: boolean;
120 /**
121 * original logging arguments, if present. this field is indexed by a symbol
122 * so that it is lost during JSON serialization, as callers are allowed to log
123 * non-serializable data.
124 */
125 [originalLogArgs]?: unknown[];
126}
127/** this API will never be altered in a breaking way. */
128type MessageLevel = "error" | "warn" | "info" | "debug";
129
130// these functions implement `Scope` for the module's namespace. that means if
131// a function takes in `Scope`, this file's namespace satisfies that.
132
133/** emit an informational message on the global log scope */
134export function info(...args: unknown[]) {
135 globalLog.info(...args);
136}
137/** emit an advisory message on the global log scope */
138export function warn(...args: unknown[]) {
139 globalLog.warn(...args);
140}
141/** emit an failure message on the global log scope */
142export function error(...args: unknown[]) {
143 globalLog.error(...args);
144}
145/** emit an debug message on the global log scope */
146export function log(...args: unknown[]) {
147 globalLog.log(...args);
148}
149/**
150 * emit an debug message on the global log scope. this method is in place for
151 * compatibility with the `console` API. prefer calling `.log` directly.
152 * @internal
153 */
154export function debug(...args: unknown[]) {
155 globalLog.debug(...args);
156}
157/** create a named logging scope, optionally carrying custom message fields */
158export function scoped(name: string, custom?: Message["custom"]): Scope {
159 return globalLog.scoped(name, custom);
160}
161/** redirect all log messages to another writer */
162export function tee(destination: DispatchFunction): ts.Dispose {
163 global.tees.add(destination);
164 return ts.defer(() => global.tees.delete(destination));
165}
166
167/** replace the default message writer */
168export function replaceGlobalMessageDestination(destination: DispatchFunction) {
169 global.writeMessage = destination;
170}
171
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 };
190}
191
192/**
193 * replace the default message formatter. does not affect browsers because that
194 * code path does not use `formatAnsiMessage`
195 */
196export function replaceGlobalFormatAnsiMessage(
197 format: (msg: Message, colors: boolean) => string,
198) {
199 global.formatAnsiMessage = format;
200}
201
202/** includes the trailing newline for standard log messages */
203export function formatAnsiMessage(msg: Message, colors: boolean): string {
204 return (global.formatAnsiMessage ?? defaultFormatAnsiMessage)(msg, colors);
205}
206
207/**
208 * a widget is an interactive display that persists at the end
209 * of the log. this can be used to implement status bars, progress
210 * indicators, and other human I/O. only 'format' is required.
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 *
215 * ```ts
216 * using _ = log.startWidget({
217 * format: (now) => `It is ${new Date().toString()} right now\n`
218 * + `A random number: ${Math.random()}`,
219 * // no trailing '\n' is needed.
220 * });
221 * await async.delay(10000);
222 *
223 * import * as log from "@clo/lib/log";
224 * import * as async from "@clo/lib/async";
225 * ```
226 */
227export function startWidget<T extends WidgetOptions>(
228 widget: T,
229): WidgetInstance<T> | null {
230 return globalWidgetHost().startWidget(widget);
231}
232
233/**
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}.
236 */
237export function writeOutput(text: string) {
238 globalWidgetHost().writeOutput(text);
239}
240
241/**
242 * like {@linkcode writeOutput}, but the text is written to the error stream
243 * (stderr in node.js). relative ordering between output and error text is
244 * preserved through the shared flush buffer.
245 */
246export function writeError(text: string) {
247 globalWidgetHost().writeError(text);
248}
249
250/** write a {@linkcode Message} object directly. */
251export function writeMessage(m: Message) {
252 globalLog.writeMessage(m);
253}
254
255/**
256 * hold a lock that stops `@clo/lib` from drawing to the terminal. this is an
257 * advanced api that is likely unneeded unless messing with external APIs that
258 * freely write to the terminal.
259 *
260 * NOTE: if the lock will be held for an extremely short amount of time, pass
261 * `"short"` which will allow more optimized use of ansi synchronization codes.
262 */
263export function getDrawLock(mode: "long" | "short"): DrawLock {
264 return globalWidgetHost().getDrawLock(mode);
265}
266
267/**
268 * call this if you are doing silly things with `process.stderr` and you want to
269 * make sure that starting a log widget (such as `@clo/lib/progress`) doesn't
270 * have interweaving bugs. in particular, you need this if your program directly
271 * writes incomplete lines to the terminal (missing a trailing `\n`).
272 *
273 * the globals are patched to track if widget text must move to a fresh line
274 * before drawing, as well as knowing to return to the original position
275 * afterwards.
276 *
277 * you most likely do not need to call this API.
278 */
279export function ensureGlobalsArePatched(): ts.Dispose {
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());
287}
288
289/**
290 * a non-exclusive lock to drawing
291 * this API will never be altered in a breaking way.
292 */
293export interface DrawLock {
294 /**
295 * decides if widget drawing requires an extra newline, which is needed if
296 * there is extra text on the line that the lock is being released on.
297 */
298 release(endState: "cursor-start-of-line" | "cursor-middle-of-line"): void;
299 /** Assumes worst case `cursor-middle-of-line` */
300 [Symbol.dispose](): void;
301}
302
303export function headlessScope(dispatch: DispatchFunction): RootScope {
304 return new ScopeImpl(dispatch);
305}
306
307/**
308 * see {@linkcode startWidget}
309 * this API will never be altered in a breaking way.
310 */
311export interface WidgetOptions {
312 /**
313 * return the widget's text. return null to detach the widget.
314 * may get called more often than the specified `fps`.
315 * supports color codes but not ansi cursor movements.
316 */
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;
350}
351
352/** {@linkcode widgetHost}'s input takes terminal I/O as well as timing APIs */
353export interface TerminalWidgetHostOptions {
354 /**
355 * an exclusive lock on the terminal is held whenever widgets are active. a
356 * secondary purpose of this is to instrument/deinstrument other code to
357 * integrate with `log.ts`'s widget lock. for example, the node.js adapter
358 * will patch `process.std{out,err}` to ensure write calls properly get a
359 * draw lock.
360 *
361 * this lock can be cleared by deactiving all widgets, or by calling
362 * {@linkcode getDrawLock} to force `@clo/lib` to stop drawing.
363 *
364 * currently, this lock must be able to be synchronously aquired at any
365 * point. if you desire an async locking function, please contact me so we
366 * can design how it would work.
367 */
368 lockTerminal: () => TerminalLock;
369 /** fast path for writing output without widgets */
370 writeOutputTemporaryLock?: (buffer: string) => void;
371 /** monotonic milliseconds */
372 now: () => ReturnType<typeof performance.now>;
373 /** after resolving, `now()` should have increased by the delay time */
374 delay: typeof async.delay;
375 /** is there color support? */
376 color: boolean;
377 /**
378 * whether text passed to `writeOutput` lands on the same screen as the
379 * interactive output. pass `false` when output is redirected to a file or
380 * pipe while the interactive stream remains a terminal; the widget cursor
381 * math then ignores log output entirely, since written rows do not
382 * displace the widget block.
383 * @default true
384 */
385 outputSharesScreen?: boolean;
386}
387
388/**
389 * when `@clo/lib` requests a lock on the terminal, the adapter provides this
390 * interface to communicate everything about the terminal state correctly.
391 */
392export interface TerminalLock {
393 /**
394 * recieves ANSI escape sequences for interactive data, as well as error log
395 * content from `writeError` (should flush immediately). the interactive
396 * stream and the error stream are the same: stderr.
397 */
398 writeInteractive(text: string): void;
399 /** recieves log content from `write` (pre-buffered; should flush immediately) */
400 writeOutput(text: string): void;
401 /**
402 * subscribe to the terminal size. the callback must fire synchronously
403 * with the current size before this function returns, and again whenever
404 * the size changes (SIGWINCH). returns an unsubscribe function. on a size
405 * change, the widget host erases its stale drawing and repaints, since a
406 * resize rewraps previously drawn rows and invalidates all relative
407 * cursor math.
408 */
409 observeSize(
410 callback: (size: { columns: number; rows: number }) => void,
411 ): () => void;
412 /** temporarily free the lock */
413 temporaryUnlock?(): () => void;
414 /** completely free the lock */
415 close(): void;
416}
417
418/**
419 * an implementation of an ANSI-based widget host.
420 * this structure must not be altered in a breaking way.
421 */
422export interface WidgetHost {
423 /** see the top-level {@linkcode writeOutput} function */
424 writeOutput(text: string): void;
425 /** see the top-level {@linkcode writeError} function */
426 writeError(text: string): void;
427 /** see the top-level {@linkcode getDrawLock} function */
428 getDrawLock(mode: "long" | "short"): DrawLock;
429 /** see the top-level {@linkcode startWidget} function */
430 startWidget<T extends WidgetOptions>(widget: T): WidgetInstance<T> | null;
431 /** stop all widgets and remove all timers. */
432 cancel(): void;
433 /** generic delay function */
434 delay?: typeof async.delay;
435 /** generic now function */
436 now?: typeof performance.now;
437 /**
438 * what does this widget host support?
439 * - color: ANSI escape sequences are displayed and not stripped
440 * - widget: `startWidget` can meaninglyful display the widget
441 */
442 capabilities: ReadonlyArray<"widget" | "color">;
443}
444
445/** @internal state */
446interface WidgetState {
447 frameTime: number;
448}
449
450// thresholds for flushing the log buffer outside the redraw timer. the 0ms
451// timer batches a synchronous burst of writes into one flush, but waiting on
452// it is wrong in two situations: the buffer growing toward the engine string
453// length limit, and a timer starved by cpu-bound work blocking the event
454// loop (it may never fire).
455const flushSyncBytes = 65536;
456const flushSyncMs = 50;
457
458/**
459 * terminal widget rendering is done by specifying all system APIs up front in
460 * an interface, creating an instance of the "widget host".
461 */
462export function createTerminalWidgetHost(
463 env: TerminalWidgetHostOptions,
464): WidgetHost {
465 const { lockTerminal, now, delay, writeOutputTemporaryLock, color } = env;
466 const outputOnScreen = env.outputSharesScreen ?? true;
467
468 let timer: async.Cancelable<void> | null = null;
469
470 let rendering = false;
471 let pendingRedraw = false;
472 let locks = 0;
473 let redrawTime = 0;
474 let lastFlush = 0;
475 // log text awaiting a flush. error text shares the queue so that relative
476 // write order is preserved, but flushes to the interactive stream (stderr)
477 // and always lands on the widget screen, while plain output rows only
478 // count when `outputSharesScreen`. consecutive same-stream writes merge
479 // into one chunk.
480 let buffer: { text: string; err: boolean }[] = [];
481 // visible width of the trailing partial log line (text since the last "\n"
482 // written to output). the cursor column is derived as `partialWidth %
483 // columns` at draw time, so the value survives resizes and wrapped lines.
484 let partialWidth = 0;
485 let needsToSaveCursor = false;
486 let needsToRestoreCursor = false;
487 const widgets: WidgetOptions[] = [];
488 const internals: WidgetState[] = [];
489 let lines: string[] = [];
490 let hasSyncStart = false;
491 let terminal: TerminalLock | null = null;
492 let size: { columns: number; rows: number } | null = null;
493 let unwatchSize: (() => void) | null = null;
494 let tempUnlock: (() => void) | null = null;
495
496 // the size subscription lives exactly as long as the terminal lock, so
497 // acquisition and closing are funneled through these two functions.
498 function acquireTerminal(): TerminalLock {
499 if (!terminal) {
500 let initial = true;
501 size = null;
502 terminal = lockTerminal();
503 unwatchSize = terminal.observeSize((next) => {
504 size = next;
505 if (!initial) handleResize();
506 });
507 ASSERT(
508 size,
509 "TerminalLock.observeSize must call back synchronously with the current size",
510 );
511 initial = false;
512 }
513 return terminal;
514 }
515 function closeTerminal() {
516 unwatchSize?.();
517 unwatchSize = null;
518 terminal?.close();
519 terminal = null;
520 }
521
522 function handleResize() {
523 // previously drawn rows have rewrapped to the new width, so the stored
524 // line count no longer matches the screen. erasing from the widget top
525 // downward is best-effort: on shrink, rows that wrapped above the cursor
526 // cannot be recovered.
527 if (lines.length > 0 && terminal) {
528 terminal.writeInteractive(
529 ansi.cursorUp(lines.length) + "\r" + ansi.clearToEndOfScreen,
530 );
531 lines = [];
532 }
533 // the save register holds pre-resize coordinates; restoring it would
534 // jump somewhere unrelated.
535 needsToRestoreCursor = false;
536 requestRedraw();
537 }
538
539 function redrawCallback() {
540 timer = null;
541 ASSERT(!rendering);
542 rendering = true;
543 // the finally is load-bearing: if a render callback or terminal write
544 // throws, `rendering` must reset, or the exit path's cancel() asserts
545 // and masks the original error.
546 try {
547 redrawCallbackInner();
548 } finally {
549 rendering = false;
550 if (pendingRedraw) {
551 pendingRedraw = false;
552 redrawSoon(0);
553 }
554 // cancel() during this render pass (an exit handler unwinding through
555 // a crashed format callback) skips its teardown; finish it here.
556 if (widgets.length === 0 && terminal && !timer && !buffer.length) {
557 lines = [];
558 closeTerminal();
559 }
560 }
561 }
562
563 function redrawCallbackInner() {
564 redrawTime = (lastFlush = now()) - 0.00001; // windows time precision workaround
565
566 // trivial path when not using widgets
567 if (!lines.length && !widgets.length) {
568 // a redraw can get scheduled with nothing to write (e.g. widgets torn
569 // down before the timer fired); it is a no-op, not an error
570 if (!buffer.length) return;
571 needsToRestoreCursor = false;
572 needsToSaveCursor = false;
573 if (
574 !terminal && writeOutputTemporaryLock && !buffer.some((c) => c.err)
575 ) {
576 const text = buffer.map((c) => c.text).join("");
577 buffer = [];
578 writeOutputTemporaryLock(text);
579 if (outputOnScreen) trackPartialWidth(text);
580 } else {
581 trackPartialWidth(flushChunks(acquireTerminal()));
582 }
583 if (hasSyncStart) {
584 acquireTerminal().writeInteractive(ansi.syncEnd);
585 hasSyncStart = false;
586 }
587 closeTerminal();
588 return;
589 }
590
591 const term = acquireTerminal();
592 const { columns, rows } = UNWRAP(size);
593 let newWidgetLines: string[] = [];
594 let next = Infinity;
595 // `widgets.length` is read live: a format callback may stop another widget
596 for (let w = 0; w < widgets.length; w += 1) {
597 const widget = UNWRAP(widgets[w]);
598 let out: string | { text: string } | null;
599 try {
600 out = widget.format({
601 now: lastFlush,
602 width: columns,
603 height: rows,
604 });
605 } catch (e) {
606 out = e instanceof Error ? stack.format(e, color) : exceptions.message(e);
607 }
608 if (out == null) {
609 widgets.splice(w, 1);
610 UNWRAP(internals.splice(w, 1)[0]);
611 w -= 1;
612 continue;
613 }
614 next = Math.min(next, UNWRAP(internals[w]).frameTime);
615 const outText = typeof out === "string" ? out : out.text;
616 // an empty string is a live widget that currently displays nothing;
617 // only `null` detaches.
618 if (outText === "") continue;
619 const rowsLeft = Math.max(1, rows - newWidgetLines.length - 1);
620 if (rowsLeft === 1) continue;
621 newWidgetLines.push(
622 ...outText.split("\n").slice(0, rowsLeft)
623 .map((line) => ansi.trimForTerminal(line, columns - 1)),
624 );
625 }
626 newWidgetLines = newWidgetLines.slice(0, rows - 1);
627 if (next < Infinity) redrawSoon(next);
628
629 const pCol = partialWidth % columns;
630
631 if (newWidgetLines.length === 0) {
632 if (lines.length > 0) {
633 term.writeInteractive(
634 (hasSyncStart ? "" : ansi.syncStart)
635 // clear the widget space
636 + (ansi.cursorUp(1) + ansi.clearFullLine)
637 .repeat(lines.length)
638 + (pCol
639 ? ansi.cursorUp(1) + ansi.cursorRight(pCol)
640 : "")
641 + (needsToRestoreCursor ? ansi.cursorRestore : ""),
642 );
643 hasSyncStart = true;
644 needsToRestoreCursor = false;
645 lines = [];
646 }
647 if (buffer.length) {
648 trackPartialWidth(flushChunks(term));
649 }
650 if (hasSyncStart) {
651 term.writeInteractive(ansi.syncEnd);
652 hasSyncStart = false;
653 }
654 // the last widget may have detached this frame; release the terminal
655 // (and its patches) instead of holding the lock until the next flush
656 if (widgets.length === 0) closeTerminal();
657 return;
658 }
659
660 // concatenation of the buffered text that lands on the widget screen,
661 // which is what the cursor math must account for
662 const screenText = buffer
663 .filter((c) => c.err || outputOnScreen)
664 .map((c) => c.text)
665 .join("");
666 if (buffer.length && screenText === "") {
667 // off-screen output (e.g. stdout redirected to a file) does not
668 // interact with the widget block; flush it plainly and fall through
669 // to a pure widget redraw
670 flushChunks(term);
671 }
672
673 if (buffer.length) {
674 // when writing a buffer alongside widgets, the screen may look like this
675 // > [existing log]
676 // > [optional partial line]
677 // > [widget line 1]
678 // > [widget line 2]
679 // > [widget line 3]
680 // > [cursor is start of this line]
681 //
682 // first, clear out the space where new lines are going to intersect.
683 // `span.rows` measures the cursor descent in physical rows, so log
684 // lines wider than the terminal are accounted for correctly.
685 const span = measureTerminalSpan(screenText, pCol, columns);
686 // if more rows are buffered than there are widgets, only some are
687 // needed. when a partial line exists, the buffer starts on its row
688 // (one above the widget block), hence the -1.
689 const clearLinesTop = Math.min(
690 lines.length,
691 span.rows
692 + (span.endWidth % columns > 0 ? 1 : 0)
693 + (pCol ? -1 : 0),
694 );
695 const oldLines = lines.slice(clearLinesTop);
696 term.writeInteractive(
697 (needsToSaveCursor ? ansi.cursorSave + "\n" : "")
698 + (hasSyncStart ? "" : ansi.syncStart)
699 + ((clearLinesTop > 0 || (pCol && lines.length > 0))
700 // clear the lines for buffer
701 ? (clearLinesTop > 0
702 ? ansi.cursorUp(lines.length - clearLinesTop + 1)
703 + ansi.clearFullLine
704 + (ansi.cursorUp(1) + ansi.clearFullLine)
705 .repeat(clearLinesTop - 1)
706 : "")
707 + (pCol && lines.length > 0
708 ? ansi.cursorUp(
709 clearLinesTop > 0 ? 1 : lines.length + 1,
710 )
711 + ansi.cursorRight(pCol)
712 : "")
713 : ""),
714 );
715 // then write the buffered log content to its streams
716 flushChunks(term);
717 term.writeInteractive(
718 // if the buffer leaves a partial line, the widgets have to go on the
719 // next line. to avoid breaking stdout, the newline gets emitted on
720 // the interactive out. (a line ending exactly on the terminal edge
721 // leaves the cursor wrap-deferred; this newline lands on the next
722 // row, which is also where `span.rows` placed the continuation.)
723 (span.endWidth > 0 ? "\n" : "")
724 // the widget text
725 + newWidgetLines.map((newLine, i) =>
726 (newLine.includes("\x1b") && !newLine.endsWith(ansi.reset)
727 ? newLine + ansi.reset
728 : newLine)
729 // clear rest of line if needed
730 + (oldLines[i]
731 && ansi.widthInTerminal(oldLines[i])
732 > ansi.widthInTerminal(newLine)
733 ? ansi.clearToEndOfLine
734 : "")
735 + "\n"
736 ).join("") + ansi.syncEnd,
737 );
738 partialWidth = span.endWidth;
739 } else {
740 const clearLinesBottom = Math.min(
741 lines.length,
742 Math.max(0, lines.length - newWidgetLines.length),
743 );
744 term.writeInteractive(
745 (needsToSaveCursor ? ansi.cursorSave + "\n" : "")
746 + (hasSyncStart ? "" : ansi.syncStart)
747 // the first draw can land just after a partial log line; widgets
748 // must move below it. (`partialWidth` is zero whenever
749 // `needsToSaveCursor` is set, since releasing a draw lock resets
750 // it, so this never combines with the cursorSave newline.)
751 + (lines.length === 0 && partialWidth > 0 ? "\n" : "")
752 // clear the bottom lines
753 + (clearLinesBottom
754 ? (ansi.cursorUp(1) + ansi.clearToEndOfLine)
755 .repeat(clearLinesBottom)
756 : "")
757 // skip up the widget space
758 + ansi.cursorUp(lines.length - clearLinesBottom)
759 // the widget text
760 + newWidgetLines.map((newLine, i) =>
761 (newLine.includes("\x1b") && !newLine.endsWith(ansi.reset)
762 ? newLine + ansi.reset
763 : newLine)
764 // clear rest of line if needed
765 + (lines[i]
766 && ansi.widthInTerminal(lines[i])
767 > ansi.widthInTerminal(newLine)
768 ? ansi.clearToEndOfLine
769 : "")
770 + "\n"
771 ).join("")
772 + ansi.syncEnd,
773 );
774 }
775 hasSyncStart = false;
776 needsToRestoreCursor ||= needsToSaveCursor;
777 needsToSaveCursor = false;
778 lines = newWidgetLines;
779 }
780
781 /**
782 * write every buffered chunk to its stream in order, returning the
783 * concatenation of the chunks that landed on the widget screen. error
784 * chunks flush through `writeInteractive` since the interactive stream is
785 * the error stream; plain output rows land on screen only when
786 * `outputSharesScreen`.
787 */
788 function flushChunks(term: TerminalLock): string {
789 let screen = "";
790 for (const chunk of buffer) {
791 if (chunk.err) {
792 term.writeInteractive(chunk.text);
793 screen += chunk.text;
794 } else {
795 term.writeOutput(chunk.text);
796 if (outputOnScreen) screen += chunk.text;
797 }
798 }
799 buffer = [];
800 return screen;
801 }
802
803 /**
804 * update `partialWidth` after `screenText` (already filtered to the chunks
805 * that landed on the widget screen) was written.
806 */
807 function trackPartialWidth(screenText: string) {
808 const i = screenText.lastIndexOf("\n");
809 partialWidth = ansi.widthInTerminal(screenText.slice(i + 1))
810 + (i === -1 ? partialWidth : 0);
811 }
812
813 function redrawSoon(ms: number) {
814 if (locks > 0 || (ms === 0 && rendering)) return;
815 const newRedrawTime = now() + ms;
816 if (timer) {
817 if (redrawTime < newRedrawTime) return;
818 timer.cancel(); // cancel previous
819 }
820 // 1ms wiggle room, generally runtimes have much larger variance on timers
821 redrawTime = newRedrawTime - 1;
822 timer = delay(ms);
823 timer.then(redrawCallback);
824 }
825
826 function requestRedraw() {
827 if (rendering) pendingRedraw = true;
828 else redrawSoon(0);
829 }
830
831 function flushAndClear(shortTermDrawLock: boolean) {
832 timer?.cancel();
833 timer = null;
834 if (lines.length > 0) {
835 const term = UNWRAP(terminal);
836 const pCol = partialWidth % UNWRAP(size).columns;
837 term.writeInteractive(
838 ansi.syncStart
839 // clear the widget space
840 + (ansi.cursorUp(1) + ansi.clearFullLine)
841 .repeat(lines.length)
842 + (pCol
843 ? ansi.cursorUp(1) + ansi.cursorRight(pCol)
844 : "")
845 + (shortTermDrawLock ? "" : ansi.syncEnd)
846 + (needsToRestoreCursor ? ansi.cursorRestore : ""),
847 );
848 needsToRestoreCursor = false;
849 lines = [];
850 hasSyncStart = shortTermDrawLock;
851 }
852 if (buffer.length > 0) {
853 if (
854 widgets.length === 0 && !terminal && writeOutputTemporaryLock
855 && !buffer.some((c) => c.err)
856 ) {
857 const text = buffer.map((c) => c.text).join("");
858 buffer = [];
859 writeOutputTemporaryLock(text);
860 if (outputOnScreen) trackPartialWidth(text);
861 } else {
862 trackPartialWidth(flushChunks(acquireTerminal()));
863 if (widgets.length === 0) closeTerminal();
864 }
865 }
866 }
867
868 /** append a write to the flush buffer, scheduling the flush */
869 function bufferChunk(chunk: string, err: boolean) {
870 const last = buffer[buffer.length - 1];
871 if (last && last.err === err) last.text += chunk;
872 else buffer.push({ text: chunk, err });
873 redrawSoon(0);
874 if (locks > 0 || rendering) return;
875 if (buffer.reduce((n, c) => n + c.text.length, 0) >= flushSyncBytes) {
876 flushSync(true);
877 } else if (timer && now() >= redrawTime + flushSyncMs) {
878 // the scheduled flush is long overdue, so the event loop is blocked;
879 // flushing inline keeps logs streaming through cpu-bound work
880 flushSync(false);
881 }
882 }
883
884 /**
885 * run the redraw flush immediately instead of waiting for the timer. with
886 * `keepPartialLine`, text after the last buffered newline stays in the
887 * buffer so a line under construction is not displayed mid-way; if the
888 * buffer holds no newline at all, everything flushes to bound memory.
889 */
890 function flushSync(keepPartialLine: boolean) {
891 let tail: typeof buffer = [];
892 if (keepPartialLine) {
893 let i = buffer.length - 1;
894 while (i >= 0 && !UNWRAP(buffer[i]).text.includes("\n")) i -= 1;
895 if (i >= 0) {
896 const edge = UNWRAP(buffer[i]);
897 const split = edge.text.lastIndexOf("\n") + 1;
898 tail = buffer.splice(i + 1);
899 if (split < edge.text.length) {
900 tail.unshift({ text: edge.text.slice(split), err: edge.err });
901 edge.text = edge.text.slice(0, split);
902 }
903 }
904 }
905 timer?.cancel();
906 timer = null;
907 redrawCallback();
908 buffer = tail;
909 if (tail.length) redrawSoon(0);
910 }
911
912 return {
913 writeOutput(chunk) {
914 if (chunk) bufferChunk(chunk, false);
915 },
916 writeError(chunk) {
917 if (chunk) bufferChunk(chunk, true);
918 },
919 getDrawLock(mode) {
920 if (rendering) ASSERT(locks === 0);
921 if (locks === 0) {
922 flushAndClear(mode === "short");
923 if (widgets.length > 0 && terminal) {
924 if (terminal.temporaryUnlock) {
925 tempUnlock = terminal.temporaryUnlock();
926 } else {
927 closeTerminal();
928 }
929 }
930 }
931 locks += 1;
932 let disposed = false;
933 return {
934 release(mode) {
935 if (disposed) return;
936 disposed = true;
937 locks -= 1;
938 needsToSaveCursor ||= mode === "cursor-middle-of-line";
939 // whatever the lock holder wrote has detached the cursor from any
940 // previously tracked partial log line. "cursor-start-of-line"
941 // states the cursor is on a fresh line; "cursor-middle-of-line"
942 // engages the save/restore dance instead.
943 partialWidth = 0;
944 if (locks === 0) {
945 tempUnlock?.();
946 tempUnlock = null;
947 if (buffer.length > 0 || widgets.length > 0) redrawSoon(0);
948 }
949 },
950 [Symbol.dispose]() {
951 this.release("cursor-middle-of-line");
952 },
953 };
954 },
955 startWidget(options) {
956 ASSERT(!widgets.includes(options), "Cannot start the same widget twice.");
957 let fps = options.fps ?? null;
958 const state: WidgetState = {
959 frameTime: 1000 / (fps ?? 0),
960 };
961 widgets.push(options);
962 internals.push(state);
963 requestRedraw();
964 return {
965 options,
966 get fps() {
967 return fps;
968 },
969 set fps(value) {
970 fps = value;
971 state.frameTime = 1000 / (fps ?? 0);
972 // a pending frame may sit beyond the new cadence; draw to reanchor
973 requestRedraw();
974 },
975 redraw: requestRedraw,
976 stop() {
977 const i = widgets.indexOf(options);
978 if (i === -1) return;
979 widgets.splice(i, 1);
980 UNWRAP(internals.splice(i, 1)[0]);
981 requestRedraw();
982 },
983 [Symbol.dispose]() {
984 this.stop();
985 },
986 };
987 },
988 cancel() {
989 // cancel may be reached from exit handlers while a redraw is on the
990 // stack (a crash inside a render callback unwinds through here); skip
991 // the flush in that case and just tear down, so the original error
992 // is the one that surfaces.
993 if (!rendering) flushAndClear(false);
994 widgets.splice(0, widgets.length);
995 internals.splice(0, internals.length);
996 timer?.cancel();
997 timer = null;
998 if (!rendering) closeTerminal();
999 },
1000 delay,
1001 now,
1002 capabilities: color ? ["widget", "color"] : ["widget"],
1003 };
1004}
1005
1006/**
1007 * compute the cursor descent (`rows`) and trailing line width (`endWidth`)
1008 * from writing `text` to a terminal `columns` wide, with the cursor starting
1009 * `startWidth` cells into a line. wrapping follows the DECAWM deferred-wrap
1010 * convention shared by modern terminals: a line of exactly `columns` cells
1011 * leaves the cursor pending on the same row, so a newline after it descends
1012 * only one row. a trailing line ending exactly on the boundary reports its
1013 * continuation point at column 0 of the next row (`endWidth % columns === 0`
1014 * with the descended row included in `rows`).
1015 */
1016function measureTerminalSpan(
1017 text: string,
1018 startWidth: number,
1019 columns: number,
1020): { rows: number; endWidth: number } {
1021 let rows = 0;
1022 let width = startWidth;
1023 const parts = text.split("\n");
1024 for (let i = 0; i < parts.length - 1; i += 1) {
1025 width += ansi.widthInTerminal(UNWRAP(parts[i]));
1026 rows += Math.max(1, Math.ceil(width / columns));
1027 width = 0;
1028 }
1029 width += ansi.widthInTerminal(UNWRAP(parts[parts.length - 1]));
1030 rows += Math.floor(width / columns);
1031 return { rows, endWidth: width };
1032}
1033
1034const logColors = node.process?.stdout.isTTY ?? false;
1035
1036let formatLine = /* @__PURE__ */ (() => {
1037 const fwo = node.builtin("util")?.formatWithOptions;
1038 if (fwo) {
1039 return (...args: unknown[]) => {
1040 if (args[0] instanceof Error) return stack.format(args[0], logColors);
1041 return fwo({ colors: logColors }, ...args);
1042 };
1043 }
1044 return (...args: unknown[]) =>
1045 args.map((arg) => {
1046 if (typeof arg === "string") return arg;
1047 try {
1048 return JSON.stringify(arg);
1049 } catch {
1050 return "[incompatible json " + typeof arg + "]";
1051 }
1052 }).join(" ");
1053})();
1054
1055let withinDispatch = false;
1056let withinStackCapture = false;
1057
1058/** this class is an implementation detail */
1059const ScopeImpl = class Scope implements RootScope {
1060 name: string | undefined;
1061 /** attached to every emitted message; see `Scope.scoped` */
1062 #custom: Message["custom"];
1063 // TODO: this abstraction implementation has low performance. making every
1064 // scope define it's own dispatch is needed to correctly implement `tee`.
1065 // since it is possible to implement this in simple and non-recursive way, i
1066 // feel okay adding the `tee` API.
1067 #dispatches: DispatchFunction[] = [];
1068 #dispatch: DispatchFunction = (m) => this.#dispatches.forEach((cb) => cb(m));
1069
1070 constructor(
1071 dispatch: (m: Message) => void,
1072 name: string | undefined = undefined,
1073 custom: Message["custom"] = undefined,
1074 ) {
1075 this.name = name;
1076 this.#custom = custom;
1077 this.#dispatches = [dispatch];
1078 }
1079
1080 #log(level: Message["level"], args: unknown[]) {
1081 let frames;
1082 if (!withinStackCapture) {
1083 withinStackCapture = true;
1084 frames = stack.capture().slice(2);
1085 withinStackCapture = false;
1086 }
1087 this.writeMessage({
1088 level,
1089 scope: this.name,
1090 custom: this.#custom,
1091 get text() {
1092 const value = formatLine(...args);
1093 Object.defineProperty(this, "text", { value });
1094 return value;
1095 },
1096 time: Date.now(),
1097 stack: frames,
1098 [originalLogArgs]: args,
1099 });
1100 }
1101
1102 info: (...args: unknown[]) => void = (...args: unknown[]) => {
1103 this.#log("info", args);
1104 };
1105 warn: (...args: unknown[]) => void = (...args: unknown[]) => {
1106 this.#log("warn", args);
1107 };
1108 error: (...args: unknown[]) => void = (...args: unknown[]) => {
1109 this.#log("error", args);
1110 };
1111 log: (...args: unknown[]) => void = (...args: unknown[]) => {
1112 this.#log("debug", args);
1113 };
1114 debug: (...args: unknown[]) => void = (...args: unknown[]) => {
1115 this.#log("debug", args);
1116 };
1117
1118 writeMessage: (m: Message) => void = (m) => {
1119 if (withinDispatch) return void globalLog.#dispatch(m);
1120 withinDispatch = true;
1121 this.#dispatch(m);
1122 withinDispatch = false;
1123 };
1124
1125 write: (text: string) => void = (text) => {
1126 let frames;
1127 if (!withinStackCapture) {
1128 withinStackCapture = true;
1129 frames = stack.capture().slice(2);
1130 withinStackCapture = false;
1131 }
1132 this.writeMessage({
1133 level: "info",
1134 scope: "",
1135 custom: this.#custom,
1136 newline: false,
1137 text,
1138 time: Date.now(),
1139 stack: frames,
1140 });
1141 };
1142
1143 scoped(name: string, custom?: Message["custom"]): Scope {
1144 const current = this.name;
1145 return new Scope(
1146 this.#dispatch,
1147 name ? current ? `${current}/${name}` : name : current,
1148 custom ? { ...this.#custom, ...custom } : this.#custom,
1149 );
1150 }
1151
1152 tee(destination: DispatchFunction) {
1153 this.#dispatches.push(destination);
1154 return ts.defer(() => this.#dispatches.splice(this.#dispatches.indexOf(destination), 1));
1155 }
1156};
1157
1158/**
1159 * global integration is done in a special manner with IIFE expressions to
1160 * ensure that the logic is removed when a bundler tree-shakes this file.
1161 * additionally, this ensures that replacing a global function properly affects
1162 * other installations. this is safe to do because these shared interfaces have
1163 * a commitment to never change in a breaking way.
1164 */
1165interface GlobalCommunication {
1166 readme: string;
1167 /** urls / file paths */
1168 instances: string[];
1169 widget?: {
1170 version: number;
1171 frozen: boolean;
1172 host: WidgetHost;
1173 /** url / file path / line number / identifying information */
1174 source: string;
1175 };
1176 /** overwrite the message writer */
1177 writeMessage?: DispatchFunction;
1178 /** overwrite the message formatter */
1179 formatAnsiMessage?: (m: Message, colors: boolean) => string;
1180 /** calls to the global `tee()` */
1181 tees: Set<DispatchFunction>;
1182}
1183
1184const globalSymbol = /* @__PURE__ */ Symbol.for("@clo/lib/log");
1185const version = 5;
1186let global: GlobalCommunication = /* @__PURE__ */ (() => {
1187 const global = (globalThis as { [globalSymbol]?: GlobalCommunication })[globalSymbol] ??= {
1188 readme:
1189 "this global holds shared state for \"@clo/lib/log\", to allow different instances of itself to coordinate with each other",
1190 instances: [],
1191 tees: new Set(),
1192 };
1193 global.instances.push(import.meta.url);
1194 return global;
1195})();
1196
1197function defaultFormatAnsiMessage(
1198 { level, scope, text, newline }: Message,
1199 colors: boolean,
1200) {
1201 if (!text) return "";
1202 if (newline === false) return text;
1203 const prefix = colors
1204 // colorful
1205 ? `${levelToAnsi[level ?? "info"]}${scope ? `(${scope})` : ""}${ansi.fgReset}${ansi.dim}:${ansi.reset} `
1206 // colorless
1207 : scope
1208 ? `${level}(${scope}): `
1209 : `${level}: `;
1210 return prefix + text + "\n";
1211}
1212
1213let initWidgetHost = false;
1214function globalWidgetHost(): WidgetHost {
1215 if (
1216 global.widget && (
1217 initWidgetHost
1218 || global.widget.frozen
1219 // By default, pick the latest version of the widget host implementation.
1220 // This is most likely to resolve the most issues as possible. To opt out of
1221 // this, import the desired implementation and call its
1222 // `ensureGlobalsArePatched` or `replaceGlobalWidgetHost` method.
1223 || global.widget.version >= version
1224 )
1225 ) {
1226 initWidgetHost = true;
1227 return global.widget.host;
1228 }
1229 initWidgetHost = true;
1230 global.widget = {
1231 frozen: false,
1232 host: node.process
1233 ? defaultNodeProcessWidgetHost(node.process)
1234 : defaultFallbackWidgetHost(),
1235 source: import.meta.url,
1236 version,
1237 };
1238 return global.widget.host;
1239}
1240
1241export function defaultNodeProcessWidgetHost(
1242 process: NonNullable<typeof node.process>,
1243 forceWidgetSupport = false,
1244): WidgetHost {
1245 // fallback
1246 if (!forceWidgetSupport && !process.stderr.isTTY) {
1247 return {
1248 writeOutput: (string) => process.stdout.write(string),
1249 writeError: (string) => process.stderr.write(string),
1250 getDrawLock: () => ({
1251 release() {},
1252 [Symbol.dispose]() {},
1253 }),
1254 startWidget: () => null,
1255 cancel: () => {},
1256 capabilities: [],
1257 };
1258 }
1259
1260 const host = createTerminalWidgetHost({
1261 lockTerminal() {
1262 const { stdout, stderr } = process;
1263 let disposed = false;
1264
1265 // patch calls to `process.std{out,err}`
1266 // note: `pipe` uses managed calls to `write`, so this is plenty
1267 const stdoutWrite = stdout.write;
1268 const stderrWrite = stderr.write;
1269 const stdoutEnd = stdout.end;
1270 const stderrEnd = stderr.end;
1271 function patchWriteMethod<T, R, A extends [string | Uint8Array]>(
1272 fn: (this: T, ...args: A) => R,
1273 touchesScreen: boolean,
1274 ) {
1275 return function(this: T, ...args: A) {
1276 using lock = disposed ? null : host.getDrawLock("short");
1277 const ret = fn.apply(this, args);
1278 if (lock) {
1279 // writes to a redirected stream never move the screen cursor
1280 lock.release(
1281 !touchesScreen
1282 || (typeof args[0] === "string"
1283 ? args[0].endsWith("\n")
1284 : bufferEndsInNewline(args[0]))
1285 ? "cursor-start-of-line"
1286 : "cursor-middle-of-line",
1287 );
1288 }
1289 return ret;
1290 };
1291 }
1292 const newStdoutWrite = stdout.write = patchWriteMethod(
1293 stdoutWrite,
1294 stdout.isTTY,
1295 );
1296 const newStderrWrite = stderr.write = patchWriteMethod(
1297 stderrWrite,
1298 true,
1299 );
1300 function patchEndMethod<T, R, A extends unknown[]>(
1301 fn: (this: T, ...args: A) => R,
1302 ) {
1303 return function(this: T, ...args: A) {
1304 using lock = disposed ? null : host.getDrawLock("short");
1305 const ret = fn.apply(this, args);
1306 // TODO: this is not handled correctly, but people rarely close their
1307 // standard I/O! this likely should just disable the library if you
1308 // call end. i'm not particularly worried.
1309 if (lock) lock.release("cursor-start-of-line");
1310 return ret;
1311 };
1312 }
1313 const newStdoutEnd = stdout.end = patchEndMethod(stdoutEnd);
1314 const newStderrEnd = stderr.end = patchEndMethod(stderrEnd);
1315
1316 // non-node runtimes will typically implement console in a way that
1317 // doesn't use `node:process`, so it must also get patched. this is
1318 // okay because the lock is re-enterant.
1319 function patchSyncMethod<T, A extends unknown[]>(
1320 fn: (this: T, ...args: A) => void,
1321 ) {
1322 return function(this: T, ...args: A) {
1323 using lock = disposed ? null : host.getDrawLock("short");
1324 fn.apply(this, args);
1325 if (lock) lock.release("cursor-start-of-line");
1326 };
1327 }
1328 const console = globalThis
1329 .console as Console & Record<string, () => void>;
1330 const restoreConsole: [string, old: () => void, patch: () => void][] = [];
1331 for (const [key, old] of Object.entries(console)) {
1332 if (typeof old !== "function") continue;
1333 try {
1334 const patched = console[key] = patchSyncMethod(old);
1335 restoreConsole.push([key, old, patched]);
1336 } catch { /* skip */ }
1337 }
1338
1339 return {
1340 // log output goes to real stdout so it stays redirectable; the
1341 // `outputSharesScreen` option tells the host whether those rows
1342 // land on the widget screen and must be counted by the cursor math
1343 writeOutput: (string) => stdoutWrite.call(stdout, string),
1344 writeInteractive: (string) => stderrWrite.call(stderr, string),
1345 observeSize(callback) {
1346 const emit = () =>
1347 callback({
1348 columns: stderr.columns ?? 80,
1349 rows: stderr.rows ?? 24,
1350 });
1351 emit();
1352 stderr.addListener("resize", emit);
1353 return () => void stderr.removeListener("resize", emit);
1354 },
1355 temporaryUnlock() {
1356 // no action needed: the patched write methods re-enter the draw
1357 // lock, which is re-entrant, so external writes flow correctly
1358 // while the lock is held.
1359 return () => {};
1360 },
1361 close() {
1362 disposed = true;
1363 // leave patches in place if something else tampered with it.
1364 if (stdout.write === newStdoutWrite) stdout.write = stdoutWrite;
1365 if (stderr.write === newStderrWrite) stderr.write = stderrWrite;
1366 if (stdout.end === newStdoutEnd) stdout.end = stdoutEnd;
1367 if (stderr.end === newStderrEnd) stderr.end = stderrEnd;
1368 for (const [key, old, patched] of restoreConsole) {
1369 if (console[key] === patched) console[key] = old;
1370 }
1371 },
1372 };
1373 },
1374 now: () => performance.now(),
1375 delay: async.delay,
1376 color: process.stderr.isTTY,
1377 outputSharesScreen: process.stdout.isTTY,
1378 });
1379 process.addListener("beforeExit", () => host.cancel());
1380 process.addListener("exit", () => host.cancel());
1381 return host;
1382}
1383
1384function defaultFallbackWidgetHost(): WidgetHost {
1385 let warned = false;
1386 return {
1387 writeOutput: (line: string) => console.log(ansi.strip(line)),
1388 writeError: (line: string) => console.error(ansi.strip(line)),
1389 getDrawLock: () => ({ [Symbol.dispose]() {}, release() {} }),
1390 startWidget: (options) => {
1391 if (!warned) {
1392 console.warn(
1393 "\"@clo/lib/log\"'s startWidget was called in an environment "
1394 + "that does not support the Node.js 'process' API. Widgets "
1395 + "will not be visible.",
1396 );
1397 warned = true;
1398 }
1399 return {
1400 options,
1401 fps: options.fps ?? null,
1402 redraw() {},
1403 stop() {},
1404 [Symbol.dispose]() {},
1405 };
1406 },
1407 cancel: () => {},
1408 capabilities: [],
1409 };
1410}
1411
1412/** returns a WidgetHost from `node:process`, but without patching its methods */
1413export function simpleNodeProcessWidgetHost(
1414 process: NonNullable<typeof node.process>,
1415 forceWidgetSupport = false,
1416): WidgetHost {
1417 return forceWidgetSupport || process.stderr.isTTY
1418 ? createTerminalWidgetHost({
1419 lockTerminal: () => ({
1420 writeOutput: (string) => process.stdout.write(string),
1421 writeInteractive: (string) => process.stderr.write(string),
1422 observeSize(callback) {
1423 const emit = () =>
1424 callback({
1425 columns: process.stderr.columns ?? 80,
1426 rows: process.stderr.rows ?? 24,
1427 });
1428 emit();
1429 process.stderr.addListener("resize", emit);
1430 return () => void process.stderr.removeListener("resize", emit);
1431 },
1432 close() {
1433 // no action needed
1434 },
1435 }),
1436 now: () => performance.now(),
1437 delay: async.delay,
1438 color: process.stderr.isTTY,
1439 outputSharesScreen: process.stdout.isTTY,
1440 })
1441 : {
1442 writeOutput: (string) => process.stdout.write(string),
1443 writeError: (string) => process.stderr.write(string),
1444 getDrawLock: () => ({
1445 release() {},
1446 [Symbol.dispose]() {},
1447 }),
1448 startWidget: () => null,
1449 cancel: () => {},
1450 capabilities: [],
1451 };
1452}
1453
1454function bufferEndsInNewline(buffer: ArrayBufferView | undefined) {
1455 return buffer
1456 ? buffer instanceof Uint8Array
1457 ? buffer.at(-1) === 0xa
1458 : new Uint8Array(buffer.buffer, buffer.byteOffset, buffer.byteLength)
1459 .at(-1) === 0xa
1460 : false;
1461}
1462
1463const levelToAnsi: Record<MessageLevel, string> = {
1464 info: `${ansi.fgBlue}info`,
1465 warn: `${ansi.fgYellow}warn`,
1466 error: `${ansi.fgRed}error`,
1467 debug: `${ansi.dim}dbg`,
1468};
1469
1470const globalLog = /* @__PURE__ */ (() =>
1471 new ScopeImpl((m) => {
1472 if (global.writeMessage) {
1473 global.writeMessage(m);
1474 } else if (node.process) {
1475 // info/debug levels land on stdout, warnings and errors on stderr;
1476 // colors are keyed off the destination stream
1477 const err = (m.level ?? "info") !== "info" && m.level !== "debug";
1478 globalWidgetHost()[err ? "writeError" : "writeOutput"](
1479 formatAnsiMessage(
1480 m,
1481 (err ? node.process.stderr : node.process.stdout).isTTY,
1482 ),
1483 );
1484 } else {
1485 let { level = "info", [originalLogArgs]: args = [m.text], scope } = m;
1486 if (scope) {
1487 const arg0 = args[0];
1488 const prefix = `[${scope}]`;
1489 if (typeof arg0 === "string") args[0] = `${prefix} ${arg0}`;
1490 else args.unshift(prefix);
1491 }
1492 console[level](...args);
1493 }
1494 global.tees.forEach((cb) => cb(m));
1495 }))();
1496
1497export type DispatchFunction = (message: Message) => void;
1498/**
1499 * agnostic to the backend. should be able to format `newline: false`
1500 * messages without a trailing newline, but not all backends may
1501 * support this.
1502 *
1503 * TODO: change this to a `write`+`flush` pattern?
1504 */
1505export type MessageFormatFunction = (
1506 message: Message,
1507 colors: boolean,
1508) => string;
1509
1510import { ASSERT, UNWRAP } from "./assert.ts";
1511import * as async from "./async.ts";
1512import * as exceptions from "./exception.ts";
1513import * as stack from "./log/stack.ts";
1514import * as node from "./node.ts";
1515import * as ansi from "./string/ansi.ts";
1516import * as ts from "./ts.ts";