| ... | ... | @@ -1,8 +1,11 @@ |
| 1 | 1 | /** |
| 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 |
| 4 | 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. |
| 5 | * `console.*` and `process.std{out/err}` are automatically patched to play nice |
| 6 | * so nearly any attempts at writing to the terminal should display fine. |
| 7 | * additionally, the clover library writes to a global symbol to communicate with |
| 8 | * other copies of this library (even across versions) to coordinate drawing. |
| 6 | 9 | * |
| 7 | 10 | * the pattern for using this module is to shadow the global `console` with a |
| 8 | 11 | * per-file logging scope, which makes it impossible to use the wrong logger. |
| ... | ... | @@ -20,7 +23,7 @@ |
| 20 | 23 | * import * as console from "@clo/lib/log"; |
| 21 | 24 | * ``` |
| 22 | 25 | * |
| 23 | | * now, the code reads familiarly (`console.log` is universally understood), |
| 26 | * now code reads familiarly (`console.log` is universally understood), |
| 24 | 27 | * but the output is organized into relevant scopes. |
| 25 | 28 | * |
| 26 | 29 | * in addition to static log messages, a system for interactive I/O via the |
| ... | ... | @@ -88,19 +91,31 @@ export interface RootScope extends Scope { |
| 88 | 91 | } |
| 89 | 92 | |
| 90 | 93 | export 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 | */ |
| 91 | 99 | export interface Message { |
| 92 | | level: "error" | "warn" | "info" | "debug"; |
| 93 | | /** ANSI-styled unicode text */ |
| 100 | /** ANSI-styled text */ |
| 94 | 101 | text: string; |
| 95 | 102 | /** datetime in milliseconds since UNIX epoch */ |
| 96 | 103 | time: number; |
| 104 | /** |
| 105 | * type of message, if known. |
| 106 | * @default info |
| 107 | */ |
| 108 | level?: MessageLevel; |
| 97 | 109 | /** scope name */ |
| 98 | 110 | scope?: string | null; |
| 99 | | /** captured stack. */ |
| 111 | /** captured stack, if available */ |
| 100 | 112 | stack?: stack.Frame[]; |
| 101 | 113 | /** arbitrary data from the logging source. */ |
| 102 | 114 | custom?: Partial<Record<string, ts.Json>>; |
| 103 | | /** print a newline at the end of this log line? */ |
| 115 | /** |
| 116 | * if there is a newline at the end of this log line. |
| 117 | * @default true |
| 118 | */ |
| 104 | 119 | newline?: boolean; |
| 105 | 120 | /** |
| 106 | 121 | * original logging arguments, if present. this field is indexed by a symbol |
| ... | ... | @@ -109,6 +124,8 @@ export interface Message { |
| 109 | 124 | */ |
| 110 | 125 | [originalLogArgs]?: unknown[]; |
| 111 | 126 | } |
| 127 | /** this API will never be altered in a breaking way. */ |
| 128 | type MessageLevel = "error" | "warn" | "info" | "debug"; |
| 112 | 129 | |
| 113 | 130 | // these functions implement `Scope` for the module's namespace. that means if |
| 114 | 131 | // a function takes in `Scope`, this file's namespace satisfies that. |
| ... | ... | @@ -143,29 +160,48 @@ export function scoped(name: string): Scope { |
| 143 | 160 | } |
| 144 | 161 | /** redirect all log messages to another writer */ |
| 145 | 162 | export function tee(destination: DispatchFunction): ts.Dispose { |
| 146 | | return globalLog.tee(destination); |
| 163 | global.tees.add(destination); |
| 164 | return ts.defer(() => global.tees.delete(destination)) |
| 147 | 165 | } |
| 148 | 166 | |
| 149 | 167 | /** replace the default message writer */ |
| 150 | 168 | export function replaceGlobalMessageDestination(destination: DispatchFunction) { |
| 151 | | globalOutputFunction = destination; |
| 169 | global.writeMessage = destination; |
| 152 | 170 | } |
| 153 | 171 | |
| 154 | | /** replace the default interactive widget host */ |
| 155 | | export 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 | */ |
| 177 | export function replaceGlobalWidgetHost(host: WidgetHost) { |
| 178 | if (global.widget?.frozen) { |
| 179 | throw new Error( |
| 180 | `Cannot change widget host implementation, it is locked by another implementation: ${global.widget.source}`, |
| 181 | ); |
| 182 | } |
| 183 | global.widget = { |
| 184 | frozen: false, |
| 185 | source: stack.capture()[0]?.file ?? |
| 186 | ("untracable call to replaceGlobalWidgetHost in " + import.meta.url), |
| 187 | host, |
| 188 | version: 0, |
| 189 | }; |
| 157 | 190 | } |
| 158 | 191 | |
| 159 | | /** replace the default message formatter */ |
| 160 | | export function replaceGlobalFormatFunction( |
| 192 | /** |
| 193 | * replace the default message formatter. does not affect browsers because that |
| 194 | * code path does not use `formatAnsiMessage` |
| 195 | */ |
| 196 | export function replaceGlobalFormatAnsiMessage( |
| 161 | 197 | format: (msg: Message, colors: boolean) => string, |
| 162 | 198 | ) { |
| 163 | | globalMessageFormatFunction = format; |
| 199 | global.formatAnsiMessage = format; |
| 164 | 200 | } |
| 165 | 201 | |
| 166 | 202 | /** includes the trailing newline for standard log messages */ |
| 167 | | export function formatMessage(msg: Message, colors: boolean): string { |
| 168 | | return globalMessageFormatFunction(msg, colors); |
| 203 | export function formatAnsiMessage(msg: Message, colors: boolean): string { |
| 204 | return (global.formatAnsiMessage ?? defaultFormatAnsiMessage)(msg, colors); |
| 169 | 205 | } |
| 170 | 206 | |
| 171 | 207 | /** |
| ... | ... | @@ -173,6 +209,9 @@ export function formatMessage(msg: Message, colors: boolean): string { |
| 173 | 209 | * of the log. this can be used to implement status bars, progress |
| 174 | 210 | * indicators, and other human I/O. only 'format' is required. |
| 175 | 211 | * |
| 212 | * returns `null` if the terminal is not interactive or the global renderer is |
| 213 | * incapable of displaying this log (such as in a browser) |
| 214 | * |
| 176 | 215 | * ```ts |
| 177 | 216 | * using _ = log.startWidget({ |
| 178 | 217 | * format: (now) => `It is ${new Date().toString()} right now\n` |
| ... | ... | @@ -185,18 +224,29 @@ export function formatMessage(msg: Message, colors: boolean): string { |
| 185 | 224 | * import * as async from "@clo/lib/async.ts"; |
| 186 | 225 | * ``` |
| 187 | 226 | */ |
| 188 | | export function startWidget(widget: Widget): ts.Dispose { |
| 189 | | return globalWidgetHost.startWidget(widget); |
| 227 | export function startWidget<T extends WidgetOptions>( |
| 228 | widget: T, |
| 229 | ): WidgetInstance<T> | null { |
| 230 | return globalWidgetHost().startWidget(widget); |
| 190 | 231 | } |
| 191 | 232 | |
| 192 | 233 | /** |
| 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}. |
| 195 | 236 | */ |
| 196 | | export function write(text: string) { |
| 197 | | globalWidgetHost.write(text); |
| 237 | export function writeOutput(text: string) { |
| 238 | globalWidgetHost().writeOutput(text); |
| 198 | 239 | } |
| 199 | 240 | |
| 241 | // TODO: |
| 242 | // /** |
| 243 | // * no built-in prefix, formatting, or newlline. ensures the text does not interweave. |
| 244 | // * data will be flushed in the next frame or when drawing is {@link getDrawLock|unlocked}. |
| 245 | // */ |
| 246 | // export function writeError(text: string) { |
| 247 | // globalWidgetHost().writeError(text); |
| 248 | // } |
| 249 | |
| 200 | 250 | /** write a {@linkcode Message} object directly. */ |
| 201 | 251 | export function writeMessage(m: Message) { |
| 202 | 252 | globalLog.writeMessage(m); |
| ... | ... | @@ -211,7 +261,7 @@ export function writeMessage(m: Message) { |
| 211 | 261 | * `"short"` which will allow more optimized use of ansi synchronization codes. |
| 212 | 262 | */ |
| 213 | 263 | export function getDrawLock(mode: "long" | "short"): DrawLock { |
| 214 | | return globalWidgetHost.getDrawLock(mode); |
| 264 | return globalWidgetHost().getDrawLock(mode); |
| 215 | 265 | } |
| 216 | 266 | |
| 217 | 267 | /** |
| ... | ... | @@ -227,17 +277,25 @@ export function getDrawLock(mode: "long" | "short"): DrawLock { |
| 227 | 277 | * you most likely do not need to call this API. |
| 228 | 278 | */ |
| 229 | 279 | export function ensureGlobalsArePatched(): ts.Dispose { |
| 230 | | return globalWidgetHost.startWidget({ format: () => "" }); |
| 280 | if (global.widget?.frozen && global.widget?.version !== version) { |
| 281 | throw new Error( |
| 282 | `Cannot change widget host implementation, it is locked by another implementation: ${global.widget.source}`, |
| 283 | ); |
| 284 | } |
| 285 | const w = globalWidgetHost().startWidget?.({ format: () => "" }); |
| 286 | return ts.defer(() => w?.stop()); |
| 231 | 287 | } |
| 232 | 288 | |
| 289 | /** |
| 290 | * a non-exclusive lock to drawing |
| 291 | * this API will never be altered in a breaking way. |
| 292 | */ |
| 233 | 293 | export interface DrawLock { |
| 234 | 294 | /** |
| 235 | 295 | * decides if widget drawing requires an extra newline, which is needed if |
| 236 | 296 | * there is extra text on the line that the lock is being released on. |
| 237 | 297 | */ |
| 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; |
| 241 | 299 | /** Assumes worst case `cursor-middle-of-line` */ |
| 242 | 300 | [Symbol.dispose](): void; |
| 243 | 301 | } |
| ... | ... | @@ -246,29 +304,53 @@ export function headlessScope(dispatch: DispatchFunction): RootScope { |
| 246 | 304 | return new ScopeImpl(dispatch); |
| 247 | 305 | } |
| 248 | 306 | |
| 249 | | /** see {@linkcode startWidget} */ |
| 250 | | export interface Widget { |
| 307 | /** |
| 308 | * see {@linkcode startWidget} |
| 309 | * this API will never be altered in a breaking way. |
| 310 | */ |
| 311 | export interface WidgetOptions { |
| 251 | 312 | /** |
| 252 | 313 | * return the widget's text. return null to detach the widget. |
| 253 | 314 | * may get called more often than the specified `fps`. |
| 254 | 315 | * supports color codes but not ansi cursor movements. |
| 255 | 316 | */ |
| 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 | */ |
| 326 | export 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 | */ |
| 342 | export interface WidgetFormatContext { |
| 343 | /** the current time according to the widget host */ |
| 344 | now: ReturnType<typeof performance.now>; |
| 345 | /** advisory */ |
| 346 | width: number; |
| 347 | /** advisory */ |
| 348 | height: number; |
| 349 | host?: WidgetHost; |
| 268 | 350 | } |
| 269 | 351 | |
| 270 | 352 | /** {@linkcode widgetHost}'s input takes terminal I/O as well as timing APIs */ |
| 271 | | export interface WidgetHostOptions { |
| 353 | export interface TerminalWidgetHostOptions { |
| 272 | 354 | /** |
| 273 | 355 | * an exclusive lock on the terminal is held whenever widgets are active. a |
| 274 | 356 | * secondary purpose of this is to instrument/deinstrument other code to |
| ... | ... | @@ -290,10 +372,12 @@ export interface WidgetHostOptions { |
| 290 | 372 | now: () => ReturnType<typeof performance.now>; |
| 291 | 373 | /** after resolving, `now()` should have increased by the delay time */ |
| 292 | 374 | delay: typeof async.delay; |
| 375 | /** is there color support? */ |
| 376 | color: boolean; |
| 293 | 377 | } |
| 294 | 378 | |
| 295 | 379 | /** |
| 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 |
| 297 | 381 | * interface to communicate everything about the terminal state correctly. |
| 298 | 382 | */ |
| 299 | 383 | export interface TerminalLock { |
| ... | ... | @@ -310,34 +394,46 @@ export interface TerminalLock { |
| 310 | 394 | close(): void; |
| 311 | 395 | } |
| 312 | 396 | |
| 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 | */ |
| 314 | 401 | export 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; |
| 317 | 406 | /** see the top-level {@linkcode getDrawLock} function */ |
| 318 | 407 | getDrawLock(mode: "long" | "short"): DrawLock; |
| 319 | 408 | /** see the top-level {@linkcode startWidget} function */ |
| 320 | | startWidget(widget: Widget): ts.Dispose; |
| 409 | startWidget<T extends WidgetOptions>(widget: T): WidgetInstance<T> | null; |
| 321 | 410 | /** stop all widgets and remove all timers. */ |
| 322 | 411 | cancel(): void; |
| 323 | 412 | /** generic delay function */ |
| 324 | 413 | delay?: typeof async.delay; |
| 325 | 414 | /** generic now function */ |
| 326 | 415 | now?: typeof performance.now; |
| 416 | /** |
| 417 | * what does this widget host support? |
| 418 | * - color: ANSI escape sequences are displayed and not stripped |
| 419 | * - widget: `startWidget` can meaninglyful display the widget |
| 420 | */ |
| 421 | capabilities: ReadonlyArray<"widget" | "color">; |
| 327 | 422 | } |
| 328 | 423 | |
| 329 | 424 | /** @internal state */ |
| 330 | 425 | interface WidgetState { |
| 331 | 426 | frameTime: number; |
| 332 | 427 | next: number; |
| 333 | | unsub: (() => void) | null; |
| 334 | 428 | } |
| 335 | 429 | |
| 336 | 430 | /** |
| 337 | 431 | * terminal widget rendering is done by specifying all system APIs up front in |
| 338 | 432 | * an interface, creating an instance of the "widget host". |
| 339 | 433 | */ |
| 340 | | export function createWidgetHost(env: WidgetHostOptions): WidgetHost { |
| 434 | export function createTerminalWidgetHost( |
| 435 | env: TerminalWidgetHostOptions, |
| 436 | ): WidgetHost { |
| 341 | 437 | const { lockTerminal, now, delay, writeOutputTemporaryLock } = env; |
| 342 | 438 | |
| 343 | 439 | let timer: async.Cancelable<void> | null = null; |
| ... | ... | @@ -349,7 +445,7 @@ export function createWidgetHost(env: WidgetHostOptions): WidgetHost { |
| 349 | 445 | let partialLineIndex = 0; |
| 350 | 446 | let needsToSaveCursor = false; |
| 351 | 447 | let needsToRestoreCursor = false; |
| 352 | | const widgets: Widget[] = []; |
| 448 | const widgets: WidgetOptions[] = []; |
| 353 | 449 | const internals: WidgetState[] = []; |
| 354 | 450 | let lines: string[] = []; |
| 355 | 451 | let hasSyncStart = false; |
| ... | ... | @@ -392,14 +488,19 @@ export function createWidgetHost(env: WidgetHostOptions): WidgetHost { |
| 392 | 488 | let newWidgetLines: string[] = []; |
| 393 | 489 | let next = Infinity; |
| 394 | 490 | 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) { |
| 397 | 497 | widgets.splice(w, 1); |
| 398 | | UNWRAP(internals.splice(w, 1)[0]).unsub?.(); |
| 498 | UNWRAP(internals.splice(w, 1)[0]); |
| 399 | 499 | w -= 1; |
| 400 | 500 | length -= 1; |
| 401 | 501 | continue; |
| 402 | 502 | } |
| 503 | const outText = typeof out === "string" ? out : out.text; |
| 403 | 504 | const rowsLeft = Math.max(1, rows - newWidgetLines.length - 1); |
| 404 | 505 | if (rowsLeft === 1) break; |
| 405 | 506 | const lines = outText.split("\n").slice(0, rowsLeft); |
| ... | ... | @@ -581,7 +682,11 @@ export function createWidgetHost(env: WidgetHostOptions): WidgetHost { |
| 581 | 682 | } |
| 582 | 683 | |
| 583 | 684 | return { |
| 584 | | write(chunk) { |
| 685 | writeOutput(chunk) { |
| 686 | if (chunk) buffer += chunk, redrawSoon(0); |
| 687 | }, |
| 688 | writeError(chunk) { |
| 689 | // TODO: write to stderr. when this was introduced it was not a regression from v3 |
| 585 | 690 | if (chunk) buffer += chunk, redrawSoon(0); |
| 586 | 691 | }, |
| 587 | 692 | getDrawLock(mode) { |
| ... | ... | @@ -615,27 +720,40 @@ export function createWidgetHost(env: WidgetHostOptions): WidgetHost { |
| 615 | 720 | }, |
| 616 | 721 | }; |
| 617 | 722 | }, |
| 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; |
| 620 | 726 | const state: WidgetState = { |
| 621 | 727 | next: 0, |
| 622 | | unsub: null, |
| 623 | | frameTime: 1000 / (w.fps ?? 0), |
| 728 | frameTime: 1000 / (fps ?? 0), |
| 624 | 729 | }; |
| 625 | | widgets.push(w); |
| 730 | widgets.push(options); |
| 626 | 731 | internals.push(state); |
| 627 | | state.unsub = w.onChange?.(() => { |
| 628 | | state.next = 0; |
| 629 | | redrawSoon(0); |
| 630 | | }) ?? null; |
| 631 | 732 | 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 | }; |
| 639 | 757 | }, |
| 640 | 758 | cancel() { |
| 641 | 759 | flushAndClear(false); |
| ... | ... | @@ -643,6 +761,7 @@ export function createWidgetHost(env: WidgetHostOptions): WidgetHost { |
| 643 | 761 | }, |
| 644 | 762 | delay, |
| 645 | 763 | now, |
| 764 | capabilities: env.color ? ["widget", "color"] : ["widget"], |
| 646 | 765 | }; |
| 647 | 766 | } |
| 648 | 767 | |
| ... | ... | @@ -732,7 +851,7 @@ const ScopeImpl = class Scope implements RootScope { |
| 732 | 851 | }; |
| 733 | 852 | |
| 734 | 853 | writeMessage: (m: Message) => void = (m) => { |
| 735 | | if (withinDispatch) return void globalOutputFunction(m); |
| 854 | if (withinDispatch) return void globalLog.#dispatch(m); |
| 736 | 855 | withinDispatch = true; |
| 737 | 856 | this.#dispatch(m); |
| 738 | 857 | withinDispatch = false; |
| ... | ... | @@ -771,26 +890,127 @@ const ScopeImpl = class Scope implements RootScope { |
| 771 | 890 | } |
| 772 | 891 | }; |
| 773 | 892 | |
| 774 | | let 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 | */ |
| 900 | interface 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 | |
| 919 | const globalSymbol = /* @__PURE__ */ Symbol.for("@clo/lib/log"); |
| 920 | const version = 4; |
| 921 | let 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 | |
| 932 | function 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 | |
| 950 | let initWidgetHost = false; |
| 951 | function 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 | |
| 978 | export function defaultNodeProcessWidgetHost( |
| 979 | process: NonNullable<typeof node.process>, |
| 980 | forceWidgetSupport = false, |
| 981 | ): WidgetHost { |
| 982 | // fallback |
| 983 | if (!forceWidgetSupport && !process.stderr.isTTY) { |
| 984 | return { |
| 985 | writeOutput: (string) => process.stdout.write(string), |
| 986 | writeError: (string) => process.stderr.write(string), |
| 987 | getDrawLock: () => ({ |
| 988 | release() {}, |
| 989 | [Symbol.dispose]() {}, |
| 990 | }), |
| 991 | startWidget: () => null, |
| 992 | cancel: () => {}, |
| 993 | capabilities: [], |
| 994 | }; |
| 995 | } |
| 996 | |
| 997 | const host = createTerminalWidgetHost({ |
| 998 | lockTerminal() { |
| 999 | const { stdout, stderr } = process; |
| 1000 | let disposed = false; |
| 1001 | |
| 1002 | // patch calls to `process.std{out,err}` |
| 1003 | // note: `pipe` uses managed calls to `write`, so this is plenty |
| 1004 | const stdoutWrite = stdout.write; |
| 1005 | const stderrWrite = stderr.write; |
| 1006 | const stdoutEnd = stdout.end; |
| 1007 | const stderrEnd = stderr.end; |
| 1008 | function patchWriteMethod<T, R, A extends [string | Uint8Array]>( |
| 1009 | fn: (this: T, ...args: A) => R, |
| 1010 | ) { |
| 1011 | return function (this: T, ...args: A) { |
| 1012 | using lock = disposed ? null : host.getDrawLock("short"); |
| 1013 | const ret = fn.apply(this, args); |
| 794 | 1014 | if (lock) { |
| 795 | 1015 | lock.release( |
| 796 | 1016 | (typeof args[0] === "string" |
| ... | ... | @@ -800,112 +1020,137 @@ let globalWidgetHost = node.process |
| 800 | 1020 | : "cursor-middle-of-line", |
| 801 | 1021 | ); |
| 802 | 1022 | } |
| 1023 | return ret; |
| 803 | 1024 | }; |
| 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; |
| 861 | 1039 | }; |
| 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); |
| 868 | 1043 | |
| 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 | } |
| 890 | 1066 | |
| 891 | | export function simpleNodeProcessWidgetHost(process: node.Process) { |
| 892 | | return createWidgetHost({ |
| 893 | | lockTerminal() { |
| 894 | 1067 | 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), |
| 897 | 1070 | getSize: () => process.stderr, |
| 898 | 1071 | temporarilyUnlock() { |
| 899 | 1072 | // no action needed |
| 900 | 1073 | }, |
| 901 | 1074 | 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 | } |
| 903 | 1084 | }, |
| 904 | 1085 | }; |
| 905 | 1086 | }, |
| 906 | 1087 | now: () => performance.now(), |
| 907 | 1088 | delay: async.delay, |
| 1089 | color: process.stderr.isTTY, |
| 908 | 1090 | }); |
| 1091 | process.addListener("beforeExit", () => host.cancel()); |
| 1092 | process.addListener("exit", () => host.cancel()); |
| 1093 | return host; |
| 1094 | } |
| 1095 | |
| 1096 | function 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 */ |
| 1125 | export function simpleNodeProcessWidgetHost( |
| 1126 | process: NonNullable<typeof node.process>, |
| 1127 | forceWidgetSupport = false, |
| 1128 | ): WidgetHost { |
| 1129 | return forceWidgetSupport || process.stderr.isTTY |
| 1130 | ? createTerminalWidgetHost({ |
| 1131 | lockTerminal: () => ({ |
| 1132 | writeOutput: (string) => process.stdout.write(string), |
| 1133 | writeInteractive: (string) => process.stderr.write(string), |
| 1134 | getSize: () => process.stderr, |
| 1135 | close() { |
| 1136 | // no action needed |
| 1137 | }, |
| 1138 | }), |
| 1139 | now: () => performance.now(), |
| 1140 | delay: async.delay, |
| 1141 | color: process.stderr.isTTY, |
| 1142 | }) |
| 1143 | : { |
| 1144 | writeOutput: (string) => process.stdout.write(string), |
| 1145 | writeError: (string) => process.stderr.write(string), |
| 1146 | getDrawLock: () => ({ |
| 1147 | release() {}, |
| 1148 | [Symbol.dispose]() {}, |
| 1149 | }), |
| 1150 | startWidget: () => null, |
| 1151 | cancel: () => {}, |
| 1152 | capabilities: [], |
| 1153 | }; |
| 909 | 1154 | } |
| 910 | 1155 | |
| 911 | 1156 | function bufferEndsInNewline(buffer: ArrayBufferView | undefined) { |
| ... | ... | @@ -917,41 +1162,23 @@ function bufferEndsInNewline(buffer: ArrayBufferView | undefined) { |
| 917 | 1162 | : false; |
| 918 | 1163 | } |
| 919 | 1164 | |
| 920 | | const levelToAnsi: Record<Message["level"], string> = { |
| 1165 | const levelToAnsi: Record<MessageLevel, string> = { |
| 921 | 1166 | info: `${ansi.fgBlue}info`, |
| 922 | 1167 | warn: `${ansi.fgYellow}warn`, |
| 923 | 1168 | error: `${ansi.fgRed}error`, |
| 924 | 1169 | debug: `${ansi.dim}dbg`, |
| 925 | 1170 | }; |
| 926 | 1171 | |
| 927 | | let 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 | | }; |
| 944 | | let globalOutputFunction!: DispatchFunction; |
| 945 | | const 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; |
| 1172 | const 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; |
| 955 | 1182 | if (scope) { |
| 956 | 1183 | const arg0 = args[0]; |
| 957 | 1184 | const prefix = `[${scope}]`; |
| ... | ... | @@ -959,9 +1186,9 @@ const globalLog = /* @__PURE__ */ (() => { |
| 959 | 1186 | else args.unshift(prefix); |
| 960 | 1187 | } |
| 961 | 1188 | console[level](...args); |
| 962 | | }; |
| 963 | | return new ScopeImpl(globalOutputFunction); |
| 964 | | })(); |
| 1189 | } |
| 1190 | global.tees.forEach((cb) => cb(m)); |
| 1191 | }))(); |
| 965 | 1192 | |
| 966 | 1193 | export type DispatchFunction = (message: Message) => void; |
| 967 | 1194 | /** |