From 496bdc100f7d96bdea16152c2edcc12b9b652fb3 Mon Sep 17 00:00:00 2001 From: clover caruso Date: Tue, 13 Jan 2026 23:06:24 -0800 Subject: [PATCH] feat(lib): improve browser tree-shaking if you are using a competent bundler such as esbuild, the define bundler option can be used to set `globalThis.process` to `undefined`. `node.ts` now will inline undefined properly, and also propagate to within `lib/log.ts` so that it only includes browser code paths. --- lib/async.ts | 4 +- lib/log.ts | 161 +++++++++++++++++++++++------------------------ lib/log/stack.ts | 17 ++--- lib/node.ts | 24 +++++-- 4 files changed, 107 insertions(+), 99 deletions(-) diff --git a/lib/async.ts b/lib/async.ts index dd7d69fb8b6a9ad612b131bda75045ffa35f3ceb..4c9a79047c81cd16dbc2eab0a5c833a17f312432 100644 --- a/lib/async.ts +++ b/lib/async.ts @@ -1,5 +1,3 @@ -const five_minutes = 5 * 60 * 1000; - /*** @deprecated */ interface ARCEValue { value: T; @@ -10,7 +8,7 @@ interface ARCEValue { export function RefCountedExpirable( init: () => Promise, deinit: (value: T) => void, - expire: number = five_minutes, + expire: number = 5 * 60 * 1000, ): () => Promise> { let refs = 0; let item: ARCEValue | null = null; diff --git a/lib/log.ts b/lib/log.ts index 7b2d0104442dca0eb1dc71f6c3fbc5b0552a8179..1de82a52e3786219b3f63ba8fcac37afada06d10 100644 --- a/lib/log.ts +++ b/lib/log.ts @@ -41,6 +41,9 @@ * `log.tee()` to duplicate all messages elsewhere. for example, a project may * configure logs to upload to a telemetry service. * + * if bundling for the browser, see the details in `./node.ts` on how to + * trigger tree-shaking to eliminate the node.js bindings in your bundle. + * * @module */ @@ -200,7 +203,7 @@ export function getDrawLock(mode: "long" | "short"): ts.Dispose { return globalWidgetHost.getDrawLock(mode); } -export function headlessScope(dispatch: DispatchFunction): Scope { +export function headlessScope(dispatch: DispatchFunction): RootScope { return new ScopeImpl(dispatch); } @@ -699,12 +702,76 @@ const ScopeImpl = class Scope implements RootScope { } }; -const globalWidgetHost = /* @__PURE__ */ (() => { - // TODO; write this in a more tree-shakable manner - const { process } = node; - if (!process || !process.stderr.isTTY) { - // return a no-op - let warned = false; +const globalWidgetHost = node.process + ? /* @__PURE__*/ ((process: NonNullable) => { + const widget = headlessWidgetHost({ + lockTerminal() { + const { stdout, stderr } = process; + let disposed = false; + + function patch( + fn: (this: T, ...args: A) => void, + ) { + return function (this: T, ...args: A) { + using _ = disposed ? null : widget.getDrawLock("short"); + fn.apply(this, args); + }; + } + + // 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 = patch(stdoutWrite); + const newStderrWrite = stderr.write = patch(stderrWrite); + const newStdoutEnd = stdout.end = patch(stdoutEnd); + const newStderrEnd = stderr.end = patch(stderrEnd); + + // non-node runtimes will typically implement console in a way that + // doesn't use `node:process`, so it must also get patched + 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] = patch(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; + } + }, + }; + }, + now: () => performance.now(), + delay: async.delay, + }); + process.addListener("beforeExit", () => widget.cancel()); + process.addListener("exit", () => widget.cancel()); + + return widget; + })(node.process) + : /* @__PURE__ */ ((warned = false) => { return { write: (line: string) => console.log(line), getDrawLock: () => ts.defer(() => {}), @@ -715,87 +782,14 @@ const globalWidgetHost = /* @__PURE__ */ (() => { "that does not support the Node.js 'process' API. Widgets " + "will not be visible.", ); + warned = true; } - warned = true; const close = w.onChange?.(() => {}); return ts.defer(close ?? (() => {})); }, cancel: () => {}, }; - } - const widget = headlessWidgetHost({ - lockTerminal() { - const { stdout, stderr } = process; - let disposed = false; - - function patch( - fn: (this: T, ...args: A) => void, - ) { - return function (this: T, ...args: A) { - using _ = disposed ? null : widget.getDrawLock(); - fn.apply(this, args); - }; - } - - // 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 = patch( - stdoutWrite === node.builtin("stream")?.Writable.prototype.write - ? widget.write - : stdoutWrite, - ); - const newStderrWrite = stderr.write = patch(stderrWrite); - const newStdoutEnd = stdout.end = patch(stdoutEnd); - const newStderrEnd = stderr.end = patch(stderrEnd); - - // non-node runtimes will typically implement console in a way that - // doesn't use `node:process`, so it must also get patched - 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] = patch(old); - restoreConsole.push([key, old, patched]); - } catch { /* skip */ } - } - - return { - writeOutput: (string) => stdoutWrite.call(stdout, 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; - } - }, - }; - }, - writeOutputTemporaryLock(buffer: string) { - process.stdout.write(buffer); - }, - now: () => performance.now(), - delay: async.delay, - }); - process.addListener("beforeExit", () => widget.cancel()); - process.addListener("exit", () => widget.cancel()); - - return widget; -})(); + })(); const levelToAnsi: Record = { info: `${ansi.fgBlue}info`, @@ -809,6 +803,7 @@ let globalMessageFormatFunction: MessageFormatFunction = ( colors, ) => { if (!text) return ""; + if (newline === false) return text; const prefix = colors // colorful ? `${levelToAnsi[level]}${ @@ -818,7 +813,7 @@ let globalMessageFormatFunction: MessageFormatFunction = ( : scope ? `${level}(${scope}): ` : `${level}: `; - return prefix + text + (newline !== false ? "\n" : ""); + return prefix + text + "\n"; }; let globalOutputFunction!: DispatchFunction; const globalLog = /* @__PURE__ */ (() => { diff --git a/lib/log/stack.ts b/lib/log/stack.ts index 6abc031cadb7acaeba1156737b44a64c887d949a..bc07f1e29f04cd82882c619007ae463888153ac4 100644 --- a/lib/log/stack.ts +++ b/lib/log/stack.ts @@ -102,7 +102,7 @@ export function format(error: Error, colors = false): string { .join(""); let frames = parse(error) ?? []; - if ("Bun" in globalThis) { + if (node.isServer && ("Bun" in globalThis)) { frames = frames.filter((frame) => frame.file !== "native" || ![ "moduleEvaluation", @@ -128,17 +128,20 @@ export function formatFrame(frame: Frame, colors: boolean): string { let { fn, file, line, col } = frame; let out = ""; if (file === "native") file = null; - const internal = !file || file.startsWith("node:") || + const internal = !file || (node.isServer && ( + file.startsWith("node:") || file.startsWith("bun:") || file.startsWith("internal:") || - file.startsWith("ext:"); + file.startsWith("ext:") + )); if (file) { if (file.startsWith("file://")) file = new URL(file).pathname; - const root = getPackageRoot(file); + const root = node.isServer ? getPackageRoot(file) : null; // filename if ( + node.isServer && internal && // all bun internals do not line map ("Bun" in globalThis || @@ -197,9 +200,9 @@ export function formatFrame(frame: Frame, colors: boolean): string { } } - preview: { + preview: if (node.isServer) { if (!file || !line) break preview; - const code = getSourceCode(file); + const code = node.isServer ? getSourceCode(file) : undefined; if (!code) break preview; const text = code[line - 1]; if (!text) break preview; @@ -248,7 +251,7 @@ const existCache = new Map(); function getPackageRoot(absPath: string) { const fs = node.builtin("fs"); const path = node.builtin("path"); - const process = globalThis.process; + const process = node.process; if (!fs || !path || !process) return null; const isNodeBuiltin = absPath.startsWith("node:"); diff --git a/lib/node.ts b/lib/node.ts index 43525b4126447ca3ed1d84cb8e4c7d6d10573bdb..4dc633a83d4ffdb6d8958682a2f5b380131e730a 100644 --- a/lib/node.ts +++ b/lib/node.ts @@ -3,6 +3,10 @@ * 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. + * + * if you are using a competent bundler, you can define `globalThis.process` as + * a bundling constant (esbuild: `--define`) to enable tree shaking across the + * library to only include browser code paths. * @module */ @@ -11,7 +15,10 @@ export type ErrorCode = | (string & {}); export const process: Process | undefined = - (globalThis as typeof globalThis & { process?: Process }).process; + (globalThis as typeof globalThis & { process?: Process }).process ?? + undefined; + +export const isServer: boolean = !!process; interface Process { getBuiltinModule(name: K): Builtins[K] | null; @@ -75,14 +82,19 @@ interface Bindings { "natives": Partial>; } -export function builtin(name: K): Builtins[K] | null { - return process?.getBuiltinModule(name) ?? null; +export function builtin( + name: K, +): Builtins[K] | undefined { + return process?.getBuiltinModule(name) ?? undefined; } -export function binding(name: K): Bindings[K] | null { +export function binding( + name: K, +): Bindings[K] | undefined { + if (!process) return undefined; try { - return process?.binding(name) ?? null; + return process.binding(name) ?? undefined; } catch { - return null; + return undefined; } } -- 2.54.0