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 @@...@@ -1,5 +1,3 @@
1const five_minutes = 5 * 60 * 1000;
2
3/*** @deprecated */1/*** @deprecated */
4interface ARCEValue<T> {2interface ARCEValue<T> {
5 value: T;3 value: T;
...@@ -10,7 +8,7 @@ interface ARCEValue<T> {...@@ -10,7 +8,7 @@ interface ARCEValue<T> {
10export function RefCountedExpirable<T>(8export function RefCountedExpirable<T>(
11 init: () => Promise<T>,9 init: () => Promise<T>,
12 deinit: (value: T) => void,10 deinit: (value: T) => void,
13 expire: number = five_minutes,11 expire: number = 5 * 60 * 1000,
14): () => Promise<ARCEValue<T>> {12): () => Promise<ARCEValue<T>> {
15 let refs = 0;13 let refs = 0;
16 let item: ARCEValue<T> | null = null;14 let item: ARCEValue<T> | null = null;
lib/log.ts+78-83
...@@ -41,6 +41,9 @@...@@ -41,6 +41,9 @@
41 * `log.tee()` to duplicate all messages elsewhere. for example, a project may41 * `log.tee()` to duplicate all messages elsewhere. for example, a project may
42 * configure logs to upload to a telemetry service.42 * configure logs to upload to a telemetry service.
43 *43 *
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 *
44 * @module47 * @module
45 */48 */
4649
...@@ -200,7 +203,7 @@ export function getDrawLock(mode: "long" | "short"): ts.Dispose {...@@ -200,7 +203,7 @@ export function getDrawLock(mode: "long" | "short"): ts.Dispose {
200 return globalWidgetHost.getDrawLock(mode);203 return globalWidgetHost.getDrawLock(mode);
201}204}
202205
203export function headlessScope(dispatch: DispatchFunction): Scope {206export function headlessScope(dispatch: DispatchFunction): RootScope {
204 return new ScopeImpl(dispatch);207 return new ScopeImpl(dispatch);
205}208}
206209
...@@ -699,12 +702,76 @@ const ScopeImpl = class Scope implements RootScope {...@@ -699,12 +702,76 @@ const ScopeImpl = class Scope implements RootScope {
699 }702 }
700};703};
701704
702const globalWidgetHost = /* @__PURE__ */ (() => {705const globalWidgetHost = node.process
703 // TODO; write this in a more tree-shakable manner706 ? /* @__PURE__*/ ((process: NonNullable<typeof node.process>) => {
704 const { process } = node;707 const widget = headlessWidgetHost({
705 if (!process || !process.stderr.isTTY) {708 lockTerminal() {
706 // return a no-op709 const { stdout, stderr } = process;
707 let warned = false;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) => {
708 return {775 return {
709 write: (line: string) => console.log(line),776 write: (line: string) => console.log(line),
710 getDrawLock: () => ts.defer(() => {}),777 getDrawLock: () => ts.defer(() => {}),
...@@ -715,87 +782,14 @@ const globalWidgetHost = /* @__PURE__ */ (() => {...@@ -715,87 +782,14 @@ const globalWidgetHost = /* @__PURE__ */ (() => {
715 "that does not support the Node.js 'process' API. Widgets " +782 "that does not support the Node.js 'process' API. Widgets " +
716 "will not be visible.",783 "will not be visible.",
717 );784 );
785 warned = true;
718 }786 }
719 warned = true;
720 const close = w.onChange?.(() => {});787 const close = w.onChange?.(() => {});
721 return ts.defer(close ?? (() => {}));788 return ts.defer(close ?? (() => {}));
722 },789 },
723 cancel: () => {},790 cancel: () => {},
724 };791 };
725 }792 })();
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})();
799793
800const levelToAnsi: Record<Message["level"], string> = {794const levelToAnsi: Record<Message["level"], string> = {
801 info: `${ansi.fgBlue}info`,795 info: `${ansi.fgBlue}info`,
...@@ -809,6 +803,7 @@ let globalMessageFormatFunction: MessageFormatFunction = (...@@ -809,6 +803,7 @@ let globalMessageFormatFunction: MessageFormatFunction = (
809 colors,803 colors,
810) => {804) => {
811 if (!text) return "";805 if (!text) return "";
806 if (newline === false) return text;
812 const prefix = colors807 const prefix = colors
813 // colorful808 // colorful
814 ? `${levelToAnsi[level]}${809 ? `${levelToAnsi[level]}${
...@@ -818,7 +813,7 @@ let globalMessageFormatFunction: MessageFormatFunction = (...@@ -818,7 +813,7 @@ let globalMessageFormatFunction: MessageFormatFunction = (
818 : scope813 : scope
819 ? `${level}(${scope}): `814 ? `${level}(${scope}): `
820 : `${level}: `;815 : `${level}: `;
821 return prefix + text + (newline !== false ? "\n" : "");816 return prefix + text + "\n";
822};817};
823let globalOutputFunction!: DispatchFunction;818let globalOutputFunction!: DispatchFunction;
824const globalLog = /* @__PURE__ */ (() => {819const globalLog = /* @__PURE__ */ (() => {
lib/log/stack.ts+10-7
...@@ -102,7 +102,7 @@ export function format(error: Error, colors = false): string {...@@ -102,7 +102,7 @@ export function format(error: Error, colors = false): string {
102 .join("");102 .join("");
103103
104 let frames = parse(error) ?? [];104 let frames = parse(error) ?? [];
105 if ("Bun" in globalThis) {105 if (node.isServer && ("Bun" in globalThis)) {
106 frames = frames.filter((frame) =>106 frames = frames.filter((frame) =>
107 frame.file !== "native" || ![107 frame.file !== "native" || ![
108 "moduleEvaluation",108 "moduleEvaluation",
...@@ -128,17 +128,20 @@ export function formatFrame(frame: Frame, colors: boolean): string {...@@ -128,17 +128,20 @@ export function formatFrame(frame: Frame, colors: boolean): string {
128 let { fn, file, line, col } = frame;128 let { fn, file, line, col } = frame;
129 let out = "";129 let out = "";
130 if (file === "native") file = null;130 if (file === "native") file = null;
131 const internal = !file || file.startsWith("node:") ||131 const internal = !file || (node.isServer && (
132 file.startsWith("node:") ||
132 file.startsWith("bun:") ||133 file.startsWith("bun:") ||
133 file.startsWith("internal:") ||134 file.startsWith("internal:") ||
134 file.startsWith("ext:");135 file.startsWith("ext:")
136 ));
135137
136 if (file) {138 if (file) {
137 if (file.startsWith("file://")) file = new URL(file).pathname;139 if (file.startsWith("file://")) file = new URL(file).pathname;
138 const root = getPackageRoot(file);140 const root = node.isServer ? getPackageRoot(file) : null;
139141
140 // filename142 // filename
141 if (143 if (
144 node.isServer &&
142 internal &&145 internal &&
143 // all bun internals do not line map146 // all bun internals do not line map
144 ("Bun" in globalThis ||147 ("Bun" in globalThis ||
...@@ -197,9 +200,9 @@ export function formatFrame(frame: Frame, colors: boolean): string {...@@ -197,9 +200,9 @@ export function formatFrame(frame: Frame, colors: boolean): string {
197 }200 }
198 }201 }
199202
200 preview: {203 preview: if (node.isServer) {
201 if (!file || !line) break preview;204 if (!file || !line) break preview;
202 const code = getSourceCode(file);205 const code = node.isServer ? getSourceCode(file) : undefined;
203 if (!code) break preview;206 if (!code) break preview;
204 const text = code[line - 1];207 const text = code[line - 1];
205 if (!text) break preview;208 if (!text) break preview;
...@@ -248,7 +251,7 @@ const existCache = new Map<string, boolean>();...@@ -248,7 +251,7 @@ const existCache = new Map<string, boolean>();
248function getPackageRoot(absPath: string) {251function getPackageRoot(absPath: string) {
249 const fs = node.builtin("fs");252 const fs = node.builtin("fs");
250 const path = node.builtin("path");253 const path = node.builtin("path");
251 const process = globalThis.process;254 const process = node.process;
252 if (!fs || !path || !process) return null;255 if (!fs || !path || !process) return null;
253256
254 const isNodeBuiltin = absPath.startsWith("node:");257 const isNodeBuiltin = absPath.startsWith("node:");
lib/node.ts+18-6
...@@ -3,6 +3,10 @@...@@ -3,6 +3,10 @@
3 * for the browser. does not depend on `@types/node` and does not intend to3 * for the browser. does not depend on `@types/node` and does not intend to
4 * define types for the entire api. Instead, this is used for other library4 * define types for the entire api. Instead, this is used for other library
5 * modules like `lib/log.ts` to bind to the system.5 * 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.
6 * @module10 * @module
7 */11 */
812
...@@ -11,7 +15,10 @@ export type ErrorCode =...@@ -11,7 +15,10 @@ export type ErrorCode =
11 | (string & {});15 | (string & {});
1216
13export const process: Process | undefined =17export 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
16interface Process {23interface Process {
17 getBuiltinModule<K extends keyof Builtins>(name: K): Builtins[K] | null;24 getBuiltinModule<K extends keyof Builtins>(name: K): Builtins[K] | null;
...@@ -75,14 +82,19 @@ interface Bindings {...@@ -75,14 +82,19 @@ interface Bindings {
75 "natives": Partial<Record<string, string>>;82 "natives": Partial<Record<string, string>>;
76}83}
7784
78export function builtin<K extends keyof Builtins>(name: K): Builtins[K] | null {85export function builtin<K extends keyof Builtins>(
79 return process?.getBuiltinModule(name) ?? null;86 name: K,
87): Builtins[K] | undefined {
88 return process?.getBuiltinModule(name) ?? undefined;
80}89}
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;
83 try {95 try {
84 return process?.binding(name) ?? null;96 return process.binding(name) ?? undefined;
85 } catch {97 } catch {
86 return null;98 return undefined;
87 }99 }
88}100}