authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-01-13 23:06:24-08:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-03-21 18:03:28-07:00
log496bdc100f7d96bdea16152c2edcc12b9b652fb3
treec1f173815a45d128d82801f6bd69ee50191a5c28
parent76e57112a9715234e63ea77458888e56836e6286
signature Signed by SSH key SHA256:xbd+BjjhyBfwk7GVoURf9Yx0gzDerHbvYv7SddNWmAs

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.

4 files changed, 107 insertions(+), 99 deletions(-)

lib/async.ts+1-3
......@@ -1,5 +1,3 @@
1const five_minutes = 5 * 60 * 1000;
2
31/*** @deprecated */
42interface ARCEValue<T> {
53 value: T;
......@@ -10,7 +8,7 @@ interface ARCEValue<T> {
108export function RefCountedExpirable<T>(
119 init: () => Promise<T>,
1210 deinit: (value: T) => void,
13 expire: number = five_minutes,
11 expire: number = 5 * 60 * 1000,
1412): () => Promise<ARCEValue<T>> {
1513 let refs = 0;
1614 let item: ARCEValue<T> | null = null;
lib/log.ts+78-83
......@@ -41,6 +41,9 @@
4141 * `log.tee()` to duplicate all messages elsewhere. for example, a project may
4242 * configure logs to upload to a telemetry service.
4343 *
44 * if bundling for the browser, see the details in `./node.ts` on how to
45 * trigger tree-shaking to eliminate the node.js bindings in your bundle.
46 *
4447 * @module
4548 */
4649
......@@ -200,7 +203,7 @@ export function getDrawLock(mode: "long" | "short"): ts.Dispose {
200203 return globalWidgetHost.getDrawLock(mode);
201204}
202205
203export function headlessScope(dispatch: DispatchFunction): Scope {
206export function headlessScope(dispatch: DispatchFunction): RootScope {
204207 return new ScopeImpl(dispatch);
205208}
206209
......@@ -699,12 +702,76 @@ const ScopeImpl = class Scope implements RootScope {
699702 }
700703};
701704
702const globalWidgetHost = /* @__PURE__ */ (() => {
703 // TODO; write this in a more tree-shakable manner
704 const { process } = node;
705 if (!process || !process.stderr.isTTY) {
706 // return a no-op
707 let warned = false;
705const globalWidgetHost = node.process
706 ? /* @__PURE__*/ ((process: NonNullable<typeof node.process>) => {
707 const widget = headlessWidgetHost({
708 lockTerminal() {
709 const { stdout, stderr } = process;
710 let disposed = false;
711
712 function patch<T, A extends unknown[]>(
713 fn: (this: T, ...args: A) => void,
714 ) {
715 return function (this: T, ...args: A) {
716 using _ = disposed ? null : widget.getDrawLock("short");
717 fn.apply(this, args);
718 };
719 }
720
721 // patch calls to `process.std{out,err}`
722 // note: `pipe` uses managed calls to `write`, so this is plenty
723 const stdoutWrite = stdout.write;
724 const stderrWrite = stderr.write;
725 const stdoutEnd = stdout.end;
726 const stderrEnd = stderr.end;
727 const newStdoutWrite = stdout.write = patch(stdoutWrite);
728 const newStderrWrite = stderr.write = patch(stderrWrite);
729 const newStdoutEnd = stdout.end = patch(stdoutEnd);
730 const newStderrEnd = stderr.end = patch(stderrEnd);
731
732 // non-node runtimes will typically implement console in a way that
733 // doesn't use `node:process`, so it must also get patched
734 const console = globalThis
735 .console as unknown as Record<string, () => void>;
736 const restoreConsole: [string, old: () => void, patch: () => void][] =
737 [];
738 for (const [key, old] of Object.entries(console)) {
739 if (typeof old !== "function") continue;
740 try {
741 const patched = console[key] = patch(old);
742 restoreConsole.push([key, old, patched]);
743 } catch { /* skip */ }
744 }
745
746 return {
747 writeOutput: (string) => stdoutWrite.call(stderr, string),
748 writeInteractive: (string) => stderrWrite.call(stderr, string),
749 getSize: () => process.stderr,
750 temporarilyUnlock() {
751 // no action needed
752 },
753 close() {
754 disposed = true;
755 // leave patches in place if something else tampered with it.
756 if (stdout.write === newStdoutWrite) stdout.write = stdoutWrite;
757 if (stderr.write === newStderrWrite) stdout.write = stderrWrite;
758 if (stdout.end === newStdoutEnd) stdout.end = stdoutEnd;
759 if (stderr.end === newStderrEnd) stdout.end = stderrEnd;
760 for (const [key, old, patched] of restoreConsole) {
761 if (console[key] === patched) console[key] = old;
762 }
763 },
764 };
765 },
766 now: () => performance.now(),
767 delay: async.delay,
768 });
769 process.addListener("beforeExit", () => widget.cancel());
770 process.addListener("exit", () => widget.cancel());
771
772 return widget;
773 })(node.process)
774 : /* @__PURE__ */ ((warned = false) => {
708775 return {
709776 write: (line: string) => console.log(line),
710777 getDrawLock: () => ts.defer(() => {}),
......@@ -715,87 +782,14 @@ const globalWidgetHost = /* @__PURE__ */ (() => {
715782 "that does not support the Node.js 'process' API. Widgets " +
716783 "will not be visible.",
717784 );
785 warned = true;
718786 }
719 warned = true;
720787 const close = w.onChange?.(() => {});
721788 return ts.defer(close ?? (() => {}));
722789 },
723790 cancel: () => {},
724791 };
725 }
726 const widget = headlessWidgetHost({
727 lockTerminal() {
728 const { stdout, stderr } = process;
729 let disposed = false;
730
731 function patch<T, A extends unknown[]>(
732 fn: (this: T, ...args: A) => void,
733 ) {
734 return function (this: T, ...args: A) {
735 using _ = disposed ? null : widget.getDrawLock();
736 fn.apply(this, args);
737 };
738 }
739
740 // patch calls to `process.std{out,err}`
741 // note: `pipe` uses managed calls to `write`, so this is plenty
742 const stdoutWrite = stdout.write;
743 const stderrWrite = stderr.write;
744 const stdoutEnd = stdout.end;
745 const stderrEnd = stderr.end;
746 const newStdoutWrite = stdout.write = patch(
747 stdoutWrite === node.builtin("stream")?.Writable.prototype.write
748 ? widget.write
749 : stdoutWrite,
750 );
751 const newStderrWrite = stderr.write = patch(stderrWrite);
752 const newStdoutEnd = stdout.end = patch(stdoutEnd);
753 const newStderrEnd = stderr.end = patch(stderrEnd);
754
755 // non-node runtimes will typically implement console in a way that
756 // doesn't use `node:process`, so it must also get patched
757 const console = globalThis
758 .console as unknown as Record<string, () => void>;
759 const restoreConsole: [string, old: () => void, patch: () => void][] = [];
760 for (const [key, old] of Object.entries(console)) {
761 if (typeof old !== "function") continue;
762 try {
763 const patched = console[key] = patch(old);
764 restoreConsole.push([key, old, patched]);
765 } catch { /* skip */ }
766 }
767
768 return {
769 writeOutput: (string) => stdoutWrite.call(stdout, string),
770 writeInteractive: (string) => stderrWrite.call(stderr, string),
771 getSize: () => process.stderr,
772 temporarilyUnlock() {
773 // no action needed
774 },
775 close() {
776 disposed = true;
777 // leave patches in place if something else tampered with it.
778 if (stdout.write === newStdoutWrite) stdout.write = stdoutWrite;
779 if (stderr.write === newStderrWrite) stdout.write = stderrWrite;
780 if (stdout.end === newStdoutEnd) stdout.end = stdoutEnd;
781 if (stderr.end === newStderrEnd) stdout.end = stderrEnd;
782 for (const [key, old, patched] of restoreConsole) {
783 if (console[key] === patched) console[key] = old;
784 }
785 },
786 };
787 },
788 writeOutputTemporaryLock(buffer: string) {
789 process.stdout.write(buffer);
790 },
791 now: () => performance.now(),
792 delay: async.delay,
793 });
794 process.addListener("beforeExit", () => widget.cancel());
795 process.addListener("exit", () => widget.cancel());
796
797 return widget;
798})();
792 })();
799793
800794const levelToAnsi: Record<Message["level"], string> = {
801795 info: `${ansi.fgBlue}info`,
......@@ -809,6 +803,7 @@ let globalMessageFormatFunction: MessageFormatFunction = (
809803 colors,
810804) => {
811805 if (!text) return "";
806 if (newline === false) return text;
812807 const prefix = colors
813808 // colorful
814809 ? `${levelToAnsi[level]}${
......@@ -818,7 +813,7 @@ let globalMessageFormatFunction: MessageFormatFunction = (
818813 : scope
819814 ? `${level}(${scope}): `
820815 : `${level}: `;
821 return prefix + text + (newline !== false ? "\n" : "");
816 return prefix + text + "\n";
822817};
823818let globalOutputFunction!: DispatchFunction;
824819const globalLog = /* @__PURE__ */ (() => {
lib/log/stack.ts+10-7
......@@ -102,7 +102,7 @@ export function format(error: Error, colors = false): string {
102102 .join("");
103103
104104 let frames = parse(error) ?? [];
105 if ("Bun" in globalThis) {
105 if (node.isServer && ("Bun" in globalThis)) {
106106 frames = frames.filter((frame) =>
107107 frame.file !== "native" || ![
108108 "moduleEvaluation",
......@@ -128,17 +128,20 @@ export function formatFrame(frame: Frame, colors: boolean): string {
128128 let { fn, file, line, col } = frame;
129129 let out = "";
130130 if (file === "native") file = null;
131 const internal = !file || file.startsWith("node:") ||
131 const internal = !file || (node.isServer && (
132 file.startsWith("node:") ||
132133 file.startsWith("bun:") ||
133134 file.startsWith("internal:") ||
134 file.startsWith("ext:");
135 file.startsWith("ext:")
136 ));
135137
136138 if (file) {
137139 if (file.startsWith("file://")) file = new URL(file).pathname;
138 const root = getPackageRoot(file);
140 const root = node.isServer ? getPackageRoot(file) : null;
139141
140142 // filename
141143 if (
144 node.isServer &&
142145 internal &&
143146 // all bun internals do not line map
144147 ("Bun" in globalThis ||
......@@ -197,9 +200,9 @@ export function formatFrame(frame: Frame, colors: boolean): string {
197200 }
198201 }
199202
200 preview: {
203 preview: if (node.isServer) {
201204 if (!file || !line) break preview;
202 const code = getSourceCode(file);
205 const code = node.isServer ? getSourceCode(file) : undefined;
203206 if (!code) break preview;
204207 const text = code[line - 1];
205208 if (!text) break preview;
......@@ -248,7 +251,7 @@ const existCache = new Map<string, boolean>();
248251function getPackageRoot(absPath: string) {
249252 const fs = node.builtin("fs");
250253 const path = node.builtin("path");
251 const process = globalThis.process;
254 const process = node.process;
252255 if (!fs || !path || !process) return null;
253256
254257 const isNodeBuiltin = absPath.startsWith("node:");
lib/node.ts+18-6
......@@ -3,6 +3,10 @@
33 * for the browser. does not depend on `@types/node` and does not intend to
44 * define types for the entire api. Instead, this is used for other library
55 * modules like `lib/log.ts` to bind to the system.
6 *
7 * if you are using a competent bundler, you can define `globalThis.process` as
8 * a bundling constant (esbuild: `--define`) to enable tree shaking across the
9 * library to only include browser code paths.
610 * @module
711 */
812
......@@ -11,7 +15,10 @@ export type ErrorCode =
1115 | (string & {});
1216
1317export const process: Process | undefined =
14 (globalThis as typeof globalThis & { process?: Process }).process;
18 (globalThis as typeof globalThis & { process?: Process }).process ??
19 undefined;
20
21export const isServer: boolean = !!process;
1522
1623interface Process {
1724 getBuiltinModule<K extends keyof Builtins>(name: K): Builtins[K] | null;
......@@ -75,14 +82,19 @@ interface Bindings {
7582 "natives": Partial<Record<string, string>>;
7683}
7784
78export function builtin<K extends keyof Builtins>(name: K): Builtins[K] | null {
79 return process?.getBuiltinModule(name) ?? null;
85export function builtin<K extends keyof Builtins>(
86 name: K,
87): Builtins[K] | undefined {
88 return process?.getBuiltinModule(name) ?? undefined;
8089}
8190
82export function binding<K extends keyof Bindings>(name: K): Bindings[K] | null {
91export function binding<K extends keyof Bindings>(
92 name: K,
93): Bindings[K] | undefined {
94 if (!process) return undefined;
8395 try {
84 return process?.binding(name) ?? null;
96 return process.binding(name) ?? undefined;
8597 } catch {
86 return null;
98 return undefined;
8799 }
88100}