diff --git a/lib/async.ts b/lib/async.ts index 4c9a79047c81cd16dbc2eab0a5c833a17f312432..060125c7a5f7561376ad3927b414876554e31f53 100644 --- a/lib/async.ts +++ b/lib/async.ts @@ -235,6 +235,7 @@ export function makeCancelable( /** wait `ms` milliseconds, then resolve. can be cancelled. */ export function delay(ms: number): Cancelable { + if (ms === Infinity) return makeCancelable(new Promise(() => {}), () => {}); let t: ts.Timer | null = null; const maxTimer = 0x7FFFFFFF; return makeCancelable( diff --git a/lib/log.ts b/lib/log.ts index 371f23c635e10244c1f032856a9e00ad6fcb832c..73350f333e2529338c307cb5791aa54f5d695d1f 100644 --- a/lib/log.ts +++ b/lib/log.ts @@ -1,8 +1,11 @@ /** - * by using `lib/log.ts`, an application gets easy scoped logging as well as - * integration with terminal widgets such as `lib/progress.ts`. even when these + * by using `@clo/lib/log.ts`, an application gets easy scoped logging as well + * as integration with terminal widgets such as `progress.ts`. even when these * widgets are active, using the logging interface is optional; global I/O with - * `console.*` and `process.std{out/err}` are automatically patched to play nice. + * `console.*` and `process.std{out/err}` are automatically patched to play nice + * so nearly any attempts at writing to the terminal should display fine. + * additionally, the clover library writes to a global symbol to communicate with + * other copies of this library (even across versions) to coordinate drawing. * * the pattern for using this module is to shadow the global `console` with a * per-file logging scope, which makes it impossible to use the wrong logger. @@ -20,7 +23,7 @@ * import * as console from "@clo/lib/log"; * ``` * - * now, the code reads familiarly (`console.log` is universally understood), + * now code reads familiarly (`console.log` is universally understood), * but the output is organized into relevant scopes. * * in addition to static log messages, a system for interactive I/O via the @@ -88,19 +91,31 @@ export interface RootScope extends Scope { } export const originalLogArgs = Symbol("originalLogArgs"); + +/** + * a message written in a log scope. + * this API will never be altered in a breaking way. + */ export interface Message { - level: "error" | "warn" | "info" | "debug"; - /** ANSI-styled unicode text */ + /** ANSI-styled text */ text: string; /** datetime in milliseconds since UNIX epoch */ time: number; + /** + * type of message, if known. + * @default info + */ + level?: MessageLevel; /** scope name */ scope?: string | null; - /** captured stack. */ + /** captured stack, if available */ stack?: stack.Frame[]; /** arbitrary data from the logging source. */ custom?: Partial>; - /** print a newline at the end of this log line? */ + /** + * if there is a newline at the end of this log line. + * @default true + */ newline?: boolean; /** * original logging arguments, if present. this field is indexed by a symbol @@ -109,6 +124,8 @@ export interface Message { */ [originalLogArgs]?: unknown[]; } +/** this API will never be altered in a breaking way. */ +type MessageLevel = "error" | "warn" | "info" | "debug"; // these functions implement `Scope` for the module's namespace. that means if // a function takes in `Scope`, this file's namespace satisfies that. @@ -143,29 +160,48 @@ export function scoped(name: string): Scope { } /** redirect all log messages to another writer */ export function tee(destination: DispatchFunction): ts.Dispose { - return globalLog.tee(destination); + global.tees.add(destination); + return ts.defer(() => global.tees.delete(destination)) } /** replace the default message writer */ export function replaceGlobalMessageDestination(destination: DispatchFunction) { - globalOutputFunction = destination; + global.writeMessage = destination; } -/** replace the default interactive widget host */ -export function replaceGlobalWidgetHost(widgetHost: WidgetHost) { - globalWidgetHost = widgetHost; +/** + * replace the default interactive widget host. this applies for all separately + * installed copies of the library, across versions. once the widget host has + * been activated, it must be preserved forever. + */ +export function replaceGlobalWidgetHost(host: WidgetHost) { + if (global.widget?.frozen) { + throw new Error( + `Cannot change widget host implementation, it is locked by another implementation: ${global.widget.source}`, + ); + } + global.widget = { + frozen: false, + source: stack.capture()[0]?.file ?? + ("untracable call to replaceGlobalWidgetHost in " + import.meta.url), + host, + version: 0, + }; } -/** replace the default message formatter */ -export function replaceGlobalFormatFunction( +/** + * replace the default message formatter. does not affect browsers because that + * code path does not use `formatAnsiMessage` + */ +export function replaceGlobalFormatAnsiMessage( format: (msg: Message, colors: boolean) => string, ) { - globalMessageFormatFunction = format; + global.formatAnsiMessage = format; } /** includes the trailing newline for standard log messages */ -export function formatMessage(msg: Message, colors: boolean): string { - return globalMessageFormatFunction(msg, colors); +export function formatAnsiMessage(msg: Message, colors: boolean): string { + return (global.formatAnsiMessage ?? defaultFormatAnsiMessage)(msg, colors); } /** @@ -173,6 +209,9 @@ export function formatMessage(msg: Message, colors: boolean): string { * of the log. this can be used to implement status bars, progress * indicators, and other human I/O. only 'format' is required. * + * returns `null` if the terminal is not interactive or the global renderer is + * incapable of displaying this log (such as in a browser) + * * ```ts * using _ = log.startWidget({ * format: (now) => `It is ${new Date().toString()} right now\n` @@ -185,18 +224,29 @@ export function formatMessage(msg: Message, colors: boolean): string { * import * as async from "@clo/lib/async.ts"; * ``` */ -export function startWidget(widget: Widget): ts.Dispose { - return globalWidgetHost.startWidget(widget); +export function startWidget( + widget: T, +): WidgetInstance | null { + return globalWidgetHost().startWidget(widget); } /** - * no built-in prefix or formatting. ensures the text does not interweave. data - * will be flushed in the next frame or when drawing is {@link getDrawLock|unlocked}. + * no built-in prefix, formatting, or newlline. ensures the text does not interweave. + * data will be flushed in the next frame or when drawing is {@link getDrawLock|unlocked}. */ -export function write(text: string) { - globalWidgetHost.write(text); +export function writeOutput(text: string) { + globalWidgetHost().writeOutput(text); } +// TODO: +// /** +// * no built-in prefix, formatting, or newlline. ensures the text does not interweave. +// * data will be flushed in the next frame or when drawing is {@link getDrawLock|unlocked}. +// */ +// export function writeError(text: string) { +// globalWidgetHost().writeError(text); +// } + /** write a {@linkcode Message} object directly. */ export function writeMessage(m: Message) { globalLog.writeMessage(m); @@ -211,7 +261,7 @@ export function writeMessage(m: Message) { * `"short"` which will allow more optimized use of ansi synchronization codes. */ export function getDrawLock(mode: "long" | "short"): DrawLock { - return globalWidgetHost.getDrawLock(mode); + return globalWidgetHost().getDrawLock(mode); } /** @@ -227,17 +277,25 @@ export function getDrawLock(mode: "long" | "short"): DrawLock { * you most likely do not need to call this API. */ export function ensureGlobalsArePatched(): ts.Dispose { - return globalWidgetHost.startWidget({ format: () => "" }); + if (global.widget?.frozen && global.widget?.version !== version) { + throw new Error( + `Cannot change widget host implementation, it is locked by another implementation: ${global.widget.source}`, + ); + } + const w = globalWidgetHost().startWidget?.({ format: () => "" }); + return ts.defer(() => w?.stop()); } +/** + * a non-exclusive lock to drawing + * this API will never be altered in a breaking way. + */ export interface DrawLock { /** * decides if widget drawing requires an extra newline, which is needed if * there is extra text on the line that the lock is being released on. */ - release( - cursorPosition: "cursor-start-of-line" | "cursor-middle-of-line", - ): void; + release(endState: "cursor-start-of-line" | "cursor-middle-of-line"): void; /** Assumes worst case `cursor-middle-of-line` */ [Symbol.dispose](): void; } @@ -246,29 +304,53 @@ export function headlessScope(dispatch: DispatchFunction): RootScope { return new ScopeImpl(dispatch); } -/** see {@linkcode startWidget} */ -export interface Widget { +/** + * see {@linkcode startWidget} + * this API will never be altered in a breaking way. + */ +export interface WidgetOptions { /** * return the widget's text. return null to detach the widget. * may get called more often than the specified `fps`. * supports color codes but not ansi cursor movements. */ - format( - now: ReturnType, - ): - | string - | null; - /** 'null' to never update (use 'onChange') */ - fps?: - | number - | null; /** Subscribe to manual widget updates. Call `rerender` when needed. */ - onChange?(rerender: () => void): () => void; - /** listen for keyboard events. */ - onKey?(key: string): void; + format(ctx: WidgetFormatContext): string | { text: string } | null; + /** 'null' to never update automatically */ + fps?: number | null; +} + +/** + * control for a widget (see {@linkcode startWidget}) + * this API will never be altered in a breaking way. + */ +export interface WidgetInstance { + /** the provided widget options */ + options: T; + get fps(): number | null; + set fps(fps: number | null); + /** schedules a new frame to be drawn as soon as possible */ + redraw(): void; + /** remove the widget from the screen */ + stop(): void; + /** alias of `stop` */ + [Symbol.dispose](): void; +} + +/** + * this API will never be altered in a breaking way. + */ +export interface WidgetFormatContext { + /** the current time according to the widget host */ + now: ReturnType; + /** advisory */ + width: number; + /** advisory */ + height: number; + host?: WidgetHost; } /** {@linkcode widgetHost}'s input takes terminal I/O as well as timing APIs */ -export interface WidgetHostOptions { +export interface TerminalWidgetHostOptions { /** * an exclusive lock on the terminal is held whenever widgets are active. a * secondary purpose of this is to instrument/deinstrument other code to @@ -290,10 +372,12 @@ export interface WidgetHostOptions { now: () => ReturnType; /** after resolving, `now()` should have increased by the delay time */ delay: typeof async.delay; + /** is there color support? */ + color: boolean; } /** - * When `@clo/lib` requests a lock on the terminal, the adapter provides this + * when `@clo/lib` requests a lock on the terminal, the adapter provides this * interface to communicate everything about the terminal state correctly. */ export interface TerminalLock { @@ -310,34 +394,46 @@ export interface TerminalLock { close(): void; } -/** an implementation of an ANSI-based widget host */ +/** + * an implementation of an ANSI-based widget host. + * this structure must not be altered in a breaking way. + */ export interface WidgetHost { - /** see the top-level {@linkcode writeLine} function */ - write(text: string): void; + /** see the top-level {@linkcode writeOutput} function */ + writeOutput(text: string): void; + /** see the top-level {@linkcode writeError} function */ + writeError(text: string): void; /** see the top-level {@linkcode getDrawLock} function */ getDrawLock(mode: "long" | "short"): DrawLock; /** see the top-level {@linkcode startWidget} function */ - startWidget(widget: Widget): ts.Dispose; + startWidget(widget: T): WidgetInstance | null; /** stop all widgets and remove all timers. */ cancel(): void; /** generic delay function */ delay?: typeof async.delay; /** generic now function */ now?: typeof performance.now; + /** + * what does this widget host support? + * - color: ANSI escape sequences are displayed and not stripped + * - widget: `startWidget` can meaninglyful display the widget + */ + capabilities: ReadonlyArray<"widget" | "color">; } /** @internal state */ interface WidgetState { frameTime: number; next: number; - unsub: (() => void) | null; } /** * terminal widget rendering is done by specifying all system APIs up front in * an interface, creating an instance of the "widget host". */ -export function createWidgetHost(env: WidgetHostOptions): WidgetHost { +export function createTerminalWidgetHost( + env: TerminalWidgetHostOptions, +): WidgetHost { const { lockTerminal, now, delay, writeOutputTemporaryLock } = env; let timer: async.Cancelable | null = null; @@ -349,7 +445,7 @@ export function createWidgetHost(env: WidgetHostOptions): WidgetHost { let partialLineIndex = 0; let needsToSaveCursor = false; let needsToRestoreCursor = false; - const widgets: Widget[] = []; + const widgets: WidgetOptions[] = []; const internals: WidgetState[] = []; let lines: string[] = []; let hasSyncStart = false; @@ -392,14 +488,19 @@ export function createWidgetHost(env: WidgetHostOptions): WidgetHost { let newWidgetLines: string[] = []; let next = Infinity; for (let w = 0, { length } = widgets; w < length; w += 1) { - const outText = UNWRAP(widgets[w]).format(lastFlush); - if (!outText) { + const out = UNWRAP(widgets[w]).format({ + now: lastFlush, + width: columns, + height: rows, + }); + if (!out) { widgets.splice(w, 1); - UNWRAP(internals.splice(w, 1)[0]).unsub?.(); + UNWRAP(internals.splice(w, 1)[0]); w -= 1; length -= 1; continue; } + const outText = typeof out === "string" ? out : out.text; const rowsLeft = Math.max(1, rows - newWidgetLines.length - 1); if (rowsLeft === 1) break; const lines = outText.split("\n").slice(0, rowsLeft); @@ -581,7 +682,11 @@ export function createWidgetHost(env: WidgetHostOptions): WidgetHost { } return { - write(chunk) { + writeOutput(chunk) { + if (chunk) buffer += chunk, redrawSoon(0); + }, + writeError(chunk) { + // TODO: write to stderr. when this was introduced it was not a regression from v3 if (chunk) buffer += chunk, redrawSoon(0); }, getDrawLock(mode) { @@ -615,27 +720,40 @@ export function createWidgetHost(env: WidgetHostOptions): WidgetHost { }, }; }, - startWidget(w) { - ASSERT(!widgets.includes(w), "Cannot start the same widget twice."); + startWidget(options) { + ASSERT(!widgets.includes(options), "Cannot start the same widget twice."); + let fps = options.fps ?? null; const state: WidgetState = { next: 0, - unsub: null, - frameTime: 1000 / (w.fps ?? 0), + frameTime: 1000 / (fps ?? 0), }; - widgets.push(w); + widgets.push(options); internals.push(state); - state.unsub = w.onChange?.(() => { - state.next = 0; - redrawSoon(0); - }) ?? null; redrawSoon(0); - return ts.defer(() => { - const i = widgets.indexOf(w); - if (i === -1) return; - widgets.splice(i, 1); - UNWRAP(internals.splice(i, 1)[0]).unsub?.(); - redrawSoon(0); - }); + return { + options, + get fps() { + return fps; + }, + set fps(value) { + fps = value; + state.frameTime = 1000 / (fps ?? 0); + }, + redraw() { + state.next = 0; + redrawSoon(0); + }, + stop() { + const i = widgets.indexOf(options); + if (i === -1) return; + widgets.splice(i, 1); + UNWRAP(internals.splice(i, 1)[0]); + redrawSoon(0); + }, + [Symbol.dispose]() { + this.stop(); + }, + }; }, cancel() { flushAndClear(false); @@ -643,6 +761,7 @@ export function createWidgetHost(env: WidgetHostOptions): WidgetHost { }, delay, now, + capabilities: env.color ? ["widget", "color"] : ["widget"], }; } @@ -732,7 +851,7 @@ const ScopeImpl = class Scope implements RootScope { }; writeMessage: (m: Message) => void = (m) => { - if (withinDispatch) return void globalOutputFunction(m); + if (withinDispatch) return void globalLog.#dispatch(m); withinDispatch = true; this.#dispatch(m); withinDispatch = false; @@ -771,26 +890,127 @@ const ScopeImpl = class Scope implements RootScope { } }; -let globalWidgetHost = node.process - ? /* @__PURE__*/ ((process: NonNullable) => { - const widget = createWidgetHost({ - lockTerminal() { - const { stdout, stderr } = process; - let disposed = false; +/** + * global integration is done in a special manner with IIFE expressions to + * ensure that the logic is removed when a bundler tree-shakes this file. + * additionally, this ensures that replacing a global function properly affects + * other installations. this is safe to do because these shared interfaces have + * a commitment to never change in a breaking way. + */ +interface GlobalCommunication { + readme: string; + /** urls / file paths */ + instances: string[]; + widget?: { + version: number; + frozen: boolean; + host: WidgetHost; + /** url / file path / line number / identifying information */ + source: string; + }; + /** overwrite the message writer */ + writeMessage?: DispatchFunction; + /** overwrite the message formatter */ + formatAnsiMessage?: (m: Message, colors: boolean) => string; + /** calls to the global `tee()` */ + tees: Set; +} - // patch calls to `process.std{out,err}` - // note: `pipe` uses managed calls to `write`, so this is plenty - const stdoutWrite = stdout.write; - const stderrWrite = stderr.write; - const stdoutEnd = stdout.end; - const stderrEnd = stderr.end; - const newStdoutWrite = stdout.write = widget.write; - const newStderrWrite = stderr.write = function ( - this: typeof stderr, - ...args - ) { - using lock = disposed ? null : widget.getDrawLock("short"); - stderrWrite.apply(stderr, args); +const globalSymbol = /* @__PURE__ */ Symbol.for("@clo/lib/log"); +const version = 4; +let global: GlobalCommunication = /* @__PURE__ */ (() => { + const global = + (globalThis as { [globalSymbol]?: GlobalCommunication })[globalSymbol] ??= { + readme: "this global holds shared state for \"@clo/lib/log\", to allow different instances of itself to coordinate with each other", + instances: [], + tees: new Set, + }; + global.instances.push(import.meta.url); + return global; +})(); + +function defaultFormatAnsiMessage( + { level, scope, text, newline }: Message, + colors: boolean, +) { + if (!text) return ""; + if (newline === false) return text; + const prefix = colors + // colorful + ? `${levelToAnsi[level ?? "info"]}${ + scope ? `(${scope})` : "" + }${ansi.fgReset}${ansi.dim}:${ansi.reset} ` + // colorless + : scope + ? `${level}(${scope}): ` + : `${level}: `; + return prefix + text + "\n"; +} + +let initWidgetHost = false; +function globalWidgetHost(): WidgetHost { + if ( + global.widget && ( + initWidgetHost || + global.widget.frozen || + // By default, pick the latest version of the widget host implementation. + // This is most likely to resolve the most issues as possible. To opt out of + // this, import the desired implementation and call its + // `ensureGlobalsArePatched` or `replaceGlobalWidgetHost` method. + global.widget.version >= version + ) + ) { + initWidgetHost = true; + return global.widget.host; + } + initWidgetHost = true; + global.widget = { + frozen: false, + host: node.process + ? defaultNodeProcessWidgetHost(node.process) + : defaultFallbackWidgetHost(), + source: import.meta.url, + version, + }; + return global.widget.host; +} + +export function defaultNodeProcessWidgetHost( + process: NonNullable, + forceWidgetSupport = false, +): WidgetHost { + // fallback + if (!forceWidgetSupport && !process.stderr.isTTY) { + return { + writeOutput: (string) => process.stdout.write(string), + writeError: (string) => process.stderr.write(string), + getDrawLock: () => ({ + release() {}, + [Symbol.dispose]() {}, + }), + startWidget: () => null, + cancel: () => {}, + capabilities: [], + }; + } + + const host = createTerminalWidgetHost({ + lockTerminal() { + const { stdout, stderr } = process; + let disposed = false; + + // patch calls to `process.std{out,err}` + // note: `pipe` uses managed calls to `write`, so this is plenty + const stdoutWrite = stdout.write; + const stderrWrite = stderr.write; + const stdoutEnd = stdout.end; + const stderrEnd = stderr.end; + function patchWriteMethod( + fn: (this: T, ...args: A) => R, + ) { + return function (this: T, ...args: A) { + using lock = disposed ? null : host.getDrawLock("short"); + const ret = fn.apply(this, args); if (lock) { lock.release( (typeof args[0] === "string" @@ -800,112 +1020,137 @@ let globalWidgetHost = node.process : "cursor-middle-of-line", ); } + return ret; }; - function patchEndMethod( - fn: (this: T, ...args: A) => void, - ) { - return function (this: T, ...args: A) { - using lock = disposed ? null : widget.getDrawLock("short"); - fn.apply(this, args); - // TODO: this is not handled correctly, but nobody closes - // their fucking standard error! this likely should just - // disable the library if you call end. - if (lock) lock.release("cursor-start-of-line"); - }; - } - const newStdoutEnd = stdout.end = patchEndMethod(stdoutEnd); - const newStderrEnd = stderr.end = patchEndMethod(stderrEnd); - - // non-node runtimes will typically implement console in a way that - // doesn't use `node:process`, so it must also get patched. this is - // okay because the lock is re-enterant. - function patchSyncMethod( - fn: (this: T, ...args: A) => void, - ) { - return function (this: T, ...args: A) { - using lock = disposed ? null : widget.getDrawLock("short"); - fn.apply(this, args); - if (lock) lock.release("cursor-start-of-line"); - }; - } - const console = globalThis - .console as unknown as Record void>; - const restoreConsole: [string, old: () => void, patch: () => void][] = - []; - for (const [key, old] of Object.entries(console)) { - if (typeof old !== "function") continue; - try { - const patched = console[key] = patchSyncMethod(old); - restoreConsole.push([key, old, patched]); - } catch { /* skip */ } - } - - return { - writeOutput: (string) => stdoutWrite.call(stderr, string), - writeInteractive: (string) => stderrWrite.call(stderr, string), - getSize: () => process.stderr, - temporarilyUnlock() { - // no action needed - }, - close() { - disposed = true; - // leave patches in place if something else tampered with it. - if (stdout.write === newStdoutWrite) stdout.write = stdoutWrite; - if (stderr.write === newStderrWrite) stdout.write = stderrWrite; - if (stdout.end === newStdoutEnd) stdout.end = stdoutEnd; - if (stderr.end === newStderrEnd) stdout.end = stderrEnd; - for (const [key, old, patched] of restoreConsole) { - if (console[key] === patched) console[key] = old; - } - }, + } + const newStdoutWrite = stdout.write = patchWriteMethod(stdoutWrite); + const newStderrWrite = stderr.write = patchWriteMethod(stderrWrite); + function patchEndMethod( + fn: (this: T, ...args: A) => R, + ) { + return function (this: T, ...args: A) { + using lock = disposed ? null : host.getDrawLock("short"); + const ret = fn.apply(this, args); + // TODO: this is not handled correctly, but people rarely close their + // standard I/O! this likely should just disable the library if you + // call end. i'm not particularly worried. + if (lock) lock.release("cursor-start-of-line"); + return ret; }; - }, - now: () => performance.now(), - delay: async.delay, - }); - process.addListener("beforeExit", () => widget.cancel()); - process.addListener("exit", () => widget.cancel()); + } + const newStdoutEnd = stdout.end = patchEndMethod(stdoutEnd); + const newStderrEnd = stderr.end = patchEndMethod(stderrEnd); - return widget; - })(node.process) - : /* @__PURE__ */ ((warned = false) => { - return { - write: (line: string) => console.log(line), - getDrawLock: () => ({ [Symbol.dispose]() {}, release() {} }), - startWidget: (w: Widget) => { - if (!warned) { - console.warn( - '"@clo/lib/log.ts"\'s startWidget was called in an environment ' + - "that does not support the Node.js 'process' API. Widgets " + - "will not be visible.", - ); - warned = true; - } - const close = w.onChange?.(() => {}); - return ts.defer(close ?? (() => {})); - }, - cancel: () => {}, - }; - })(); + // non-node runtimes will typically implement console in a way that + // doesn't use `node:process`, so it must also get patched. this is + // okay because the lock is re-enterant. + function patchSyncMethod( + fn: (this: T, ...args: A) => void, + ) { + return function (this: T, ...args: A) { + using lock = disposed ? null : host.getDrawLock("short"); + fn.apply(this, args); + if (lock) lock.release("cursor-start-of-line"); + }; + } + const console = globalThis + .console as Console & Record void>; + const restoreConsole: [string, old: () => void, patch: () => void][] = []; + for (const [key, old] of Object.entries(console)) { + if (typeof old !== "function") continue; + try { + const patched = console[key] = patchSyncMethod(old); + restoreConsole.push([key, old, patched]); + } catch { /* skip */ } + } -export function simpleNodeProcessWidgetHost(process: node.Process) { - return createWidgetHost({ - lockTerminal() { return { - writeOutput: (string) => process.stdout.write(string), - writeInteractive: (string) => process.stderr.write(string), + writeOutput: (string) => stdoutWrite.call(stderr, string), + writeInteractive: (string) => stderrWrite.call(stderr, string), getSize: () => process.stderr, temporarilyUnlock() { // no action needed }, close() { - // no action needed + disposed = true; + // leave patches in place if something else tampered with it. + if (stdout.write === newStdoutWrite) stdout.write = stdoutWrite; + if (stderr.write === newStderrWrite) stdout.write = stderrWrite; + if (stdout.end === newStdoutEnd) stdout.end = stdoutEnd; + if (stderr.end === newStderrEnd) stdout.end = stderrEnd; + for (const [key, old, patched] of restoreConsole) { + if (console[key] === patched) console[key] = old; + } }, }; }, now: () => performance.now(), delay: async.delay, + color: process.stderr.isTTY, }); + process.addListener("beforeExit", () => host.cancel()); + process.addListener("exit", () => host.cancel()); + return host; +} + +function defaultFallbackWidgetHost(): WidgetHost { + let warned = false; + return { + writeOutput: (line: string) => console.log(ansi.strip(line)), + writeError: (line: string) => console.error(ansi.strip(line)), + getDrawLock: () => ({ [Symbol.dispose]() {}, release() {} }), + startWidget: (options) => { + if (!warned) { + console.warn( + '"@clo/lib/log.ts"\'s startWidget was called in an environment ' + + "that does not support the Node.js 'process' API. Widgets " + + "will not be visible.", + ); + warned = true; + } + return { + options, + fps: options.fps ?? null, + redraw() {}, + stop() {}, + [Symbol.dispose]() {}, + }; + }, + cancel: () => {}, + capabilities: [], + }; +} + +/** returns a WidgetHost from `node:process`, but without patching its methods */ +export function simpleNodeProcessWidgetHost( + process: NonNullable, + forceWidgetSupport = false, +): WidgetHost { + return forceWidgetSupport || process.stderr.isTTY + ? createTerminalWidgetHost({ + lockTerminal: () => ({ + writeOutput: (string) => process.stdout.write(string), + writeInteractive: (string) => process.stderr.write(string), + getSize: () => process.stderr, + close() { + // no action needed + }, + }), + now: () => performance.now(), + delay: async.delay, + color: process.stderr.isTTY, + }) + : { + writeOutput: (string) => process.stdout.write(string), + writeError: (string) => process.stderr.write(string), + getDrawLock: () => ({ + release() {}, + [Symbol.dispose]() {}, + }), + startWidget: () => null, + cancel: () => {}, + capabilities: [], + }; } function bufferEndsInNewline(buffer: ArrayBufferView | undefined) { @@ -917,41 +1162,23 @@ function bufferEndsInNewline(buffer: ArrayBufferView | undefined) { : false; } -const levelToAnsi: Record = { +const levelToAnsi: Record = { info: `${ansi.fgBlue}info`, warn: `${ansi.fgYellow}warn`, error: `${ansi.fgRed}error`, debug: `${ansi.dim}dbg`, }; -let globalMessageFormatFunction: MessageFormatFunction = ( - { level, scope, text, newline }, - colors, -) => { - if (!text) return ""; - if (newline === false) return text; - const prefix = colors - // colorful - ? `${levelToAnsi[level]}${ - scope ? `(${scope})` : "" - }${ansi.fgReset}${ansi.dim}:${ansi.reset} ` - // colorless - : scope - ? `${level}(${scope}): ` - : `${level}: `; - return prefix + text + "\n"; -}; -let globalOutputFunction!: DispatchFunction; -const globalLog = /* @__PURE__ */ (() => { - const colors = node.process?.stderr.isTTY ?? false; - globalOutputFunction = node.process - // In Node.js, coordinate with the widget host - ? (message) => { - globalWidgetHost.write(globalMessageFormatFunction(message, colors)); - } - // Otherwise, forward to `console` - : (m) => { - let { level, [originalLogArgs]: args = [m.text], scope } = m; +const globalLog = /* @__PURE__ */ (() => + new ScopeImpl((m) => { + if (global.writeMessage) { + global.writeMessage(m); + } else if (node.process) { + globalWidgetHost()[ + (m.level ?? "info") === "info" ? "writeOutput" : "writeError" + ](formatAnsiMessage(m, node.process.stdout.isTTY)); + } else { + let { level = "info", [originalLogArgs]: args = [m.text], scope } = m; if (scope) { const arg0 = args[0]; const prefix = `[${scope}]`; @@ -959,9 +1186,9 @@ const globalLog = /* @__PURE__ */ (() => { else args.unshift(prefix); } console[level](...args); - }; - return new ScopeImpl(globalOutputFunction); -})(); + } + global.tees.forEach((cb) => cb(m)); + }))(); export type DispatchFunction = (message: Message) => void; /** diff --git a/lib/log/stack.ts b/lib/log/stack.ts index bc07f1e29f04cd82882c619007ae463888153ac4..dea85c8c17d7e8f7609e65e4f9dc0bb75f86de3d 100644 --- a/lib/log/stack.ts +++ b/lib/log/stack.ts @@ -274,7 +274,7 @@ function getPackageRoot(absPath: string) { ]; } return [ - `https://github.com/nodejs/node/blob/${process.version}/lib/`, + `https://github.com/nodejs/node/blob/${process.versions.node}/lib/`, "", absPath.slice(5) + ".js", ]; diff --git a/lib/node.ts b/lib/node.ts index 98101df52c6e8a90af123753649eaa8c08d37c19..5a4055e47f0accc465737241a75026be6219739b 100644 --- a/lib/node.ts +++ b/lib/node.ts @@ -1,8 +1,8 @@ /** * functions to load Node.js apis via `globalThis.process`. trivially bundlable * for the browser. does not depend on `@types/node` and does not intend to - * define types for the entire api. Instead, this is used for other library - * modules like `lib/log.ts` to bind to the system. + * define types for the entire api. instead, this file's types are used for + * other library modules like `lib/log.ts` to bind to the system. * * if you are using a competent bundler, you can define `globalThis.process` as * a bundling constant (esbuild: `--define`) to enable tree shaking across the @@ -10,22 +10,20 @@ * @module */ -export type ErrorCode = - | keyof typeof import("node:os").constants.errno - | (string & {}); - export const process: Process | undefined = - (globalThis as typeof globalThis & { process?: Process }).process ?? - undefined; + (globalThis as { process?: Process }).process && + (globalThis as { process?: Process }).process?.versions?.node + ? (globalThis as { process?: Process }).process + : undefined; export const isServer: boolean = !!process; /** partial types for Node.js `globalThis.process` */ -export interface Process { +interface Process { getBuiltinModule(name: K): Builtins[K] | null; - binding(name: K): Bindings[K] | null; + binding?(name: K): Bindings[K] | null; addListener(event: string, callback: () => void): this; - + versions: { node?: string; bun?: string; deno?: string }; stdout: Tty; stderr: Tty; } @@ -35,8 +33,8 @@ interface Tty { columns: number; rows: number; addListener(event: string, callback: () => void): this; - end(text?: string | Uint8Array): void; - write(text: string | Uint8Array): void; + end(text?: string | Uint8Array): this; + write(text: string | Uint8Array): boolean; } /** @@ -71,31 +69,39 @@ interface Builtins { }; }; } -/** - * Subset of Node.js binding types - */ + +/** subset of Node.js binding types */ interface Bindings { /** * key value mapping of internal module id to its source code. - * - * { "fs": "// Copyright Joyent, Inc. and other Node contributors.\n... + * ``` + * { "fs": "// Copyright Joyent, Inc. and other Node contributors.\n... + * ``` */ "natives": Partial>; } +/** return a built-in module */ export function builtin( name: K, ): Builtins[K] | undefined { return process?.getBuiltinModule(name) ?? undefined; } +/** return a built-in semi-private binding */ export function binding( name: K, ): Bindings[K] | undefined { if (!process) return undefined; try { - return process.binding(name) ?? undefined; + return process.binding?.(name) ?? undefined; } catch { return undefined; } } + +/** copy of `keyof typeof import('node:os').constants.errno` */ +// deno-fmt-ignore-next +export type ErrorCode = + | (string & {}) + | "E2BIG" | "EACCES" | "EADDRINUSE" | "EADDRNOTAVAIL" | "EAFNOSUPPORT" | "EAGAIN" | "EALREADY" | "EBADF" | "EBADMSG" | "EBUSY" | "ECANCELED" | "ECHILD" | "ECONNABORTED" | "ECONNREFUSED" | "ECONNRESET" | "EDEADLK" | "EDESTADDRREQ" | "EDOM" | "EDQUOT" | "EEXIST" | "EFAULT" | "EFBIG" | "EHOSTUNREACH" | "EIDRM" | "EILSEQ" | "EINPROGRESS" | "EINTR" | "EINVAL" | "EIO" | "EISCONN" | "EISDIR" | "ELOOP" | "EMFILE" | "EMLINK" | "EMSGSIZE" | "EMULTIHOP" | "ENAMETOOLONG" | "ENETDOWN" | "ENETRESET" | "ENETUNREACH" | "ENFILE" | "ENOBUFS" | "ENODATA" | "ENODEV" | "ENOENT" | "ENOEXEC" | "ENOLCK" | "ENOLINK" | "ENOMEM" | "ENOMSG" | "ENOPROTOOPT" | "ENOSPC" | "ENOSR" | "ENOSTR" | "ENOSYS" | "ENOTCONN" | "ENOTDIR" | "ENOTEMPTY" | "ENOTSOCK" | "ENOTSUP" | "ENOTTY" | "ENXIO" | "EOPNOTSUPP" | "EOVERFLOW" | "EPERM" | "EPIPE" | "EPROTO" | "EPROTONOSUPPORT" | "EPROTOTYPE" | "ERANGE" | "EROFS" | "ESPIPE" | "ESRCH" | "ESTALE" | "ETIME" | "ETIMEDOUT" | "ETXTBSY" | "EWOULDBLOCK" | "EXDEV" | "WSAEINTR" | "WSAEBADF" | "WSAEACCES" | "WSAEFAULT" | "WSAEINVAL" | "WSAEMFILE" | "WSAEWOULDBLOCK" | "WSAEINPROGRESS" | "WSAEALREADY" | "WSAENOTSOCK" | "WSAEDESTADDRREQ" | "WSAEMSGSIZE" | "WSAEPROTOTYPE" | "WSAENOPROTOOPT" | "WSAEPROTONOSUPPORT" | "WSAESOCKTNOSUPPORT" | "WSAEOPNOTSUPP" | "WSAEPFNOSUPPORT" | "WSAEAFNOSUPPORT" | "WSAEADDRINUSE" | "WSAEADDRNOTAVAIL" | "WSAENETDOWN" | "WSAENETUNREACH" | "WSAENETRESET" | "WSAECONNABORTED" | "WSAECONNRESET" | "WSAENOBUFS" | "WSAEISCONN" | "WSAENOTCONN" | "WSAESHUTDOWN" | "WSAETOOMANYREFS" | "WSAETIMEDOUT" | "WSAECONNREFUSED" | "WSAELOOP" | "WSAENAMETOOLONG" | "WSAEHOSTDOWN" | "WSAEHOSTUNREACH" | "WSAENOTEMPTY" | "WSAEPROCLIM" | "WSAEUSERS" | "WSAEDQUOT" | "WSAESTALE" | "WSAEREMOTE" | "WSASYSNOTREADY" | "WSAVERNOTSUPPORTED" | "WSANOTINITIALISED" | "WSAEDISCON" | "WSAENOMORE" | "WSAECANCELLED" | "WSAEINVALIDPROCTABLE" | "WSAEINVALIDPROVIDER" | "WSAEPROVIDERFAILEDINIT" | "WSASYSCALLFAILURE" | "WSASERVICE_NOT_FOUND" | "WSATYPE_NOT_FOUND" | "WSA_E_NO_MORE" | "WSA_E_CANCELLED" | "WSAEREFUSED"; diff --git a/lib/package.json b/lib/package.json new file mode 100644 index 0000000000000000000000000000000000000000..0f550cbaabd61f4c1d2c6ebe21ff458560e694b8 --- /dev/null +++ b/lib/package.json @@ -0,0 +1,4 @@ +{ + "type": "module", + "exports": { "./*": "./*.ts" } +} diff --git a/lib/progress.ts b/lib/progress.ts index 17a416158f7726880fa92421b12f3c4fa721b34d..f4a714f3a0e611fa067ec1549577813176bf24d4 100644 --- a/lib/progress.ts +++ b/lib/progress.ts @@ -37,7 +37,7 @@ * custom redirections. * * this module is under construction. while i am happy with the overall API, it - * needs more work and feature development. + * needs more work and feature development. the API of `Node` is stable, though. * * inspired by the [Zig Progress API](https://andrewkelley.me/post/zig-new-cli-progress-bar-explained.html). * @module @@ -74,7 +74,7 @@ export function start(text: string, opts?: StartOptions): Node { * * ```ts * await ffmpeg.spawn({ - * cmd: ["-i", "hello.mov", "-c:v", "@clo/libsvtav1", "hello.mp4"], + * cmd: ["-i", "hello.mov", "-c:v", "svtav1", "hello.mp4"], * progress: progress.start("encode hello.mov"), * }); * ``` @@ -705,13 +705,16 @@ function renderChildren(state: ReadOnlyNode, now: number, depth: boolean[]) { continue; } const logLines = child.logs - .map((msg) => log.formatMessage(msg, true)) + .map((msg) => log.formatAnsiMessage(msg, true)) .join("") .trim(); - if (logLines) for (const line of logLines.split("\n").slice(-Math.min(3, maxHeight))) { - item += left + (i === length - 1 && !truncated ? " " : box.line) + " " + - ansi.style(ansi.fgBrightBlack, ">") + - " " + line + "\n"; + if (logLines) { + for (const line of logLines.split("\n").slice(-Math.min(3, maxHeight))) { + item += left + (i === length - 1 && !truncated ? " " : box.line) + + " " + + ansi.style(ansi.fgBrightBlack, ">") + + " " + line + "\n"; + } } maxHeight -= h + logLines.length; out += item; @@ -765,30 +768,32 @@ export function formatUnicodeBar(progress: number, width: number): string { */ export function attachToScreen( root: Root, - { write, startWidget }: Pick< + { writeOutput, startWidget }: Pick< log.WidgetHost, - "write" | "startWidget" + "writeOutput" | "startWidget" >, ): ts.Dispose { const stack = new DisposableStack(); - - let rerender: (() => void) | null = null; - const widget: log.Widget = { - format: (now) => root.active ? formatAnsi(now, root.active) : null, - onChange: (cb) => (rerender = cb, () => rerender = null), - fps: spinnerFps, - }; + let widget: log.WidgetInstance | null = null; stack.use(root.on("change", (items) => { - if (rerender) rerender(); - else if (items.length > 0) startWidget(widget); + if (items.length > 0) { + widget ??= startWidget({ + format: ({ now }) => formatAnsi(now, root.active), + }) ?? null; + if (!widget)return; + widget.fps = items.some((x) => x.showTotal !== false && x.total > 0) + ? spinnerFps + : null; + widget.redraw(); + } else { + widget?.stop(); + widget = null; + } + })); + stack.use(root.on("node-detached-log", (msg) => { + writeOutput(log.formatAnsiMessage(msg, true)); })); - stack.use( - root.on( - "node-detached-log", - (msg) => write(log.formatMessage(msg, true)), - ), - ); stack.use(root.on("node-end", (node) => { let title = node.text; let p: ReadOnlyNode | null = node; @@ -796,8 +801,8 @@ export function attachToScreen( const { logs } = node; if (logs.length > 0) { const header = `[logs from ${title}]`; - write(ansi.style(ansi.fgBrightBlack, header) + "\n"); - write(logs.map((msg) => log.formatMessage(msg, true)).join("")); + writeOutput(ansi.style(ansi.fgBrightBlack, header) + "\n"); + writeOutput(logs.map((msg) => log.formatAnsiMessage(msg, true)).join("")); } })); @@ -878,8 +883,6 @@ export interface EncodeStreamOptions { * * if the given root adds custom event handlers, they must all have * json-serializable payloads. - * - * this function does not use recursion. */ export function encodeEventStream< Result extends ts.Json, @@ -1344,6 +1347,8 @@ interface DeltaFlags { } /** + * NOTE: this function contains bugs and its format is not yet stabilized. + * * converts a {@linkcode Root|progress.Root} into an byte stream for * communicating progress over the process or network boundary. the output is a * raw binary payload that uses an extremely compact representation for the @@ -1352,8 +1357,6 @@ interface DeltaFlags { * * if the given root adds custom event handlers, they must all have * json-serializable payloads. - * - * this function does not use recursion. */ export function encodeByteStream< Result extends ts.Json, @@ -1434,7 +1437,7 @@ function writeStreamEvent( if (messages !== undefined) { w.varUint(messages.length); for (const msg of messages) { - let level = logLevelSerialize.indexOf(msg.level); + let level = logLevelSerialize.indexOf(msg.level ?? "info"); if (level === -1) level = 0; w.u8( level + @@ -1743,6 +1746,12 @@ const globalKeyPool = new KeyPool(); const global: Ref = /** @__PURE__ */ ((root = new Root()) => (attachToScreen(root, log), root))(); +/** + * a {@linkcode Ref} to the global progress root. unlike referencing the + * namespace import, this value is tree-shakable. + */ +export const globalRoot: Ref = { start: global.start }; + /** * for testing. not covered by semver * @internal @@ -1755,7 +1764,7 @@ export const internals: { } = /** @__PURE__ */ (() => ({ readStreamEvent, writeStreamEvent, - kNode: kNode as unknown as symbol, + kNode: kNode as symbol, EncodedKey: 0 as EncodedKey, }))(); diff --git a/lib/readme.changes.md b/lib/readme.changes.md index 1f7958cea3e5893670833ee92c066e6bd060180d..1f169a95e4d4f41dc39500fc7d803a525effd27b 100644 --- a/lib/readme.changes.md +++ b/lib/readme.changes.md @@ -4,13 +4,17 @@ ### breaking +- the `.ts` suffix in the module name has been dropped - `log` - - rename `writeLine` to `write` + - rename `writeLine` to `writeOutput` and `writeError` + - `startWidget` returns `null` if the host is incapable of it + - it also no longer takes `onChange`. call `redraw` on the returned `WidgetInstance`. - rename `HeadlessWidgetHost` to `WidgetHost` - - rename `HeadlessWidgetEnv` to `WidgetHostOptions` - - rename `headlessWidgetHost` to `createWidgetHost` + - rename `headlessWidgetHost` to `createTerminalWidgetHost` + - rename `HeadlessWidgetEnv` to `TerminalWidgetHostOptions` - rename `replaceGlobalDestination` to `replaceGlobalMessageDestination` - - `WidgetHostOptions` takes a `lockTerminal` function instead of + - rename `formatMessage` to `formatAnsiMessage` + - `WidgetHostOptions` now takes a `lockTerminal` function instead of `writeInteractive`/`writeOutput` directly. the locking function returns an interface with these functions, which better aligns with how the draw lock actually works. additionally, this allows writing more accurate tty bindings @@ -20,23 +24,28 @@ - in `getDrawLock`, new required argument `mode`, which can be set to `short` or `long` to affect how synchronization works. releasing the lock requires you to give some information about where the cursor was moved to. - - messages now do not imply a newline -- `render` is now deprecated with no replacement. in the downstream `sitegen` - project, the codebase is moving to Marko after depending on both renderers. - `string/ansi` - rename `trimToWidth` to `trimForTerminal` +deprecations without removals: + +- `render.ts` will be deleted with no replacement. in the downstream `sitegen` + project, the codebase is moving to Marko after depending on both renderers. + ### features +- you can write `log` messages without a newline. on older libraries, this will + show up as a newline per message since that version was not capable of + displaying it. but done not as a breaking change. - `process.{stdout,stderr}.write` is intercepted to avoid log interweaving with `log.ts`/`progress.ts`. this is only done when a widget is created (for example, calling `progress.start`), so patches are not applied when not needed. if patching globals is undesirable, you can use an alternative widget host by calling `log.replaceGlobalWidgetHost(log.simpleNodeProcessWidgetHost(process))` - - consequences of this is that `getDrawLock` +- `log.startWidget` returns a `WidgetInstance`, including a mutable `fps` property. - `progress.ts` node gains `node.log.write("word ");` to write a message without a newline. -- `async.delay` handles timers longer than 23 days. +- `async.delay` handles timers longer than 23 days and `Infinity`. - `Lru.revive` recieves bug fixes. this function previously didn't really work. - `ansi` gets more cursor control constants diff --git a/lib/testing.ts b/lib/testing.ts index 7544c7ebdd9126f2a925a09529e3d03dd9591dce..26466c7f74e0c90a588ac5e6c9a932d873813013 100644 --- a/lib/testing.ts +++ b/lib/testing.ts @@ -166,9 +166,10 @@ export class MockScreen implements Disposable, log.WidgetHost { out: string = ""; writeCalls: WriteCall[] = []; - timers = new FakeTimers(); + timers: FakeTimers = new FakeTimers(); - write: log.WidgetHost["write"]; + writeOutput: log.WidgetHost["writeOutput"]; + writeError: log.WidgetHost["writeError"]; getDrawLock: log.WidgetHost["getDrawLock"]; startWidget: log.WidgetHost["startWidget"]; delay: log.WidgetHost["delay"]; @@ -182,7 +183,7 @@ export class MockScreen implements Disposable, log.WidgetHost { constructor({ temporaryUnlocking }: { temporaryUnlocking?: boolean } = {}) { const callerFile = UNWRAP(stack.capture()[0]); - const host = log.createWidgetHost({ + const host = log.createTerminalWidgetHost({ lockTerminal: () => { ASSERT(!this.hasTerminalLock); this.hasTerminalLock = "locked"; @@ -190,24 +191,32 @@ export class MockScreen implements Disposable, log.WidgetHost { writeInteractive: (content) => { this.stderr += content; this.out += content; - const frames = stack.capture().filter(x => x.file !== import.meta.filename); - const cutoff = frames.findIndex(f => f.file === callerFile.file && f.fn === callerFile.fn) + const frames = stack.capture().filter((x) => + x.file !== import.meta.filename + ); + const cutoff = frames.findIndex((f) => + f.file === callerFile.file && f.fn === callerFile.fn + ); this.writeCalls.push({ - kind: "interactive", - stack: cutoff === -1 ? frames : frames.slice(0, cutoff), - content, - }) + kind: "interactive", + stack: cutoff === -1 ? frames : frames.slice(0, cutoff), + content, + }); }, writeOutput: (content) => { this.stdout += content; this.out += content; - const frames = stack.capture().filter(x => x.file !== import.meta.filename); - const cutoff = frames.findIndex(f => f.file === callerFile.file && f.fn === callerFile.fn) + const frames = stack.capture().filter((x) => + x.file !== import.meta.filename + ); + const cutoff = frames.findIndex((f) => + f.file === callerFile.file && f.fn === callerFile.fn + ); this.writeCalls.push({ - kind: "output", - stack: cutoff === -1 ? frames : frames.slice(0, cutoff), - content, - }) + kind: "output", + stack: cutoff === -1 ? frames : frames.slice(0, cutoff), + content, + }); }, getSize: () => { return this; @@ -229,8 +238,10 @@ export class MockScreen implements Disposable, log.WidgetHost { }, now: this.timers.now, delay: this.timers.delay, + color: true, }); - this.write = host.write; + this.writeOutput = host.writeOutput; + this.writeError = host.writeError; this.getDrawLock = host.getDrawLock; this.startWidget = host.startWidget; this.delay = host.delay; @@ -253,7 +264,8 @@ export class MockScreen implements Disposable, log.WidgetHost { if (ms != null) { const wait = UNWRAP( this.timers.entries.shift(), - () => this.out.length > 0 ? "terminal i/o did not wait" : "no terminal i/o", + () => + this.out.length > 0 ? "terminal i/o did not wait" : "no terminal i/o", ); ASSERT( ms === wait.duration, @@ -299,18 +311,22 @@ export class MockScreen implements Disposable, log.WidgetHost { this.writeCalls = []; } - fmtWriteCalls() { + fmtWriteCalls(): string { return `\n${ansi.reset}breakdown of calls that rendered this frame:\n` + - this.writeCalls.map((call, i) => - `${i + 1}. [${call.kind}] ${ansi.debugAnsi(call.content)}\n` - + call.stack.map(frame => ansi.reset + stack.formatFrame(frame, true)).join('\n') + + this.writeCalls.map((call, i) => + `${i + 1}. [${call.kind}] ${ansi.debugAnsi(call.content)}\n` + + call.stack.map((frame) => ansi.reset + stack.formatFrame(frame, true)) + .join("\n") + `\n` - ).join('\n').replaceAll(ansi.fgReset, ansi.reset) + ).join("\n").replaceAll(ansi.fgReset, ansi.reset); } [Symbol.dispose]() { this.cancel(); } + + capabilities = ["widget", "color"] as const; + cancel() { ASSERT(this.timers.entries.length === 0, "there is a pending write!"); ASSERT(!this.stdout, "unread standard out: " + this.stdout);