Skip to content

Commit 920fb6b

Browse files
committed
feat: add browser support via tshy esmDialects
1 parent dff52e4 commit 920fb6b

File tree

10 files changed

+641
-2
lines changed

10 files changed

+641
-2
lines changed

.changeset/browser-support.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"agentcrumbs": minor
3+
---
4+
5+
Add browser support via tshy `esmDialects`. Bundlers that respect the `"browser"` export condition (Vite, webpack, esbuild, Next.js) automatically resolve to the browser build. Same `"agentcrumbs"` import path — no separate entry point. Adds `configure()` API for enabling tracing in the browser.

packages/agentcrumbs/.tshy/commonjs.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,11 @@
99
"exclude": [
1010
"../src/__tests__/**/*",
1111
"../src/**/*.mts",
12-
"../src/package.json"
12+
"../src/package.json",
13+
"../src/context-browser.mts",
14+
"../src/env-browser.mts",
15+
"../src/sinks/console-browser.mts",
16+
"../src/trail-browser.mts"
1317
],
1418
"compilerOptions": {
1519
"outDir": "../.tshy-build/commonjs"

packages/agentcrumbs/.tshy/esm.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,11 @@
88
],
99
"exclude": [
1010
"../src/__tests__/**/*",
11-
"../src/package.json"
11+
"../src/package.json",
12+
"../src/context-browser.mts",
13+
"../src/env-browser.mts",
14+
"../src/sinks/console-browser.mts",
15+
"../src/trail-browser.mts"
1216
],
1317
"compilerOptions": {
1418
"outDir": "../.tshy-build/esm"

packages/agentcrumbs/package.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@
66
"exports": {
77
".": "./src/index.ts"
88
},
9+
"esmDialects": [
10+
"browser"
11+
],
912
"exclude": [
1013
"src/__tests__/**/*"
1114
]
@@ -65,6 +68,10 @@
6568
"type": "module",
6669
"exports": {
6770
".": {
71+
"browser": {
72+
"types": "./dist/browser/index.d.ts",
73+
"default": "./dist/browser/index.js"
74+
},
6875
"import": {
6976
"types": "./dist/esm/index.d.ts",
7077
"default": "./dist/esm/index.js"
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
export type DebugContext = {
2+
namespace: string;
3+
contextData: Record<string, unknown>;
4+
traceId: string;
5+
depth: number;
6+
sessionId?: string;
7+
};
8+
9+
// Browser JS is single-threaded so a simple stack replaces AsyncLocalStorage.
10+
// Limitation: concurrent Promise.all branches won't isolate context.
11+
const contextStack: DebugContext[] = [];
12+
13+
export function getContext(): DebugContext | undefined {
14+
return contextStack[contextStack.length - 1];
15+
}
16+
17+
export function runWithContext<T>(ctx: DebugContext, fn: () => T): T {
18+
contextStack.push(ctx);
19+
try {
20+
const result = fn();
21+
if (result instanceof Promise) {
22+
return result.then(
23+
(val) => {
24+
contextStack.pop();
25+
return val;
26+
},
27+
(err) => {
28+
contextStack.pop();
29+
throw err;
30+
},
31+
) as T;
32+
}
33+
contextStack.pop();
34+
return result;
35+
} catch (err) {
36+
contextStack.pop();
37+
throw err;
38+
}
39+
}
Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
import type { AgentCrumbsConfig } from "./types.js";
2+
3+
const DEFAULT_PORT = 8374;
4+
5+
type ParsedConfig =
6+
| { enabled: false }
7+
| {
8+
enabled: true;
9+
app?: string;
10+
includes: RegExp[];
11+
excludes: RegExp[];
12+
port: number;
13+
format: "pretty" | "json";
14+
};
15+
16+
let cachedConfig: ParsedConfig | undefined;
17+
let cachedApp: string | undefined;
18+
19+
declare const globalThis: {
20+
__AGENTCRUMBS__?: string | AgentCrumbsConfig;
21+
__AGENTCRUMBS_APP__?: string;
22+
};
23+
24+
function namespaceToRegex(pattern: string): RegExp {
25+
const escaped = pattern
26+
.replace(/[.+?^${}()|[\]\\]/g, "\\$&")
27+
.replace(/\*/g, ".*?");
28+
return new RegExp(`^${escaped}$`);
29+
}
30+
31+
/**
32+
* Configure agentcrumbs in the browser.
33+
*
34+
* @example
35+
* configure("*") // enable all namespaces
36+
* configure("myapp:*") // enable namespaces matching pattern
37+
* configure({ ns: "*", app: "my-app", format: "pretty" })
38+
*/
39+
export function configure(config: AgentCrumbsConfig | string): void {
40+
cachedConfig = undefined;
41+
cachedApp = undefined;
42+
43+
if (typeof config === "string") {
44+
(globalThis as Record<string, unknown>).__AGENTCRUMBS__ = config;
45+
} else {
46+
(globalThis as Record<string, unknown>).__AGENTCRUMBS__ = config;
47+
}
48+
}
49+
50+
export function parseConfig(): ParsedConfig {
51+
if (cachedConfig !== undefined) return cachedConfig;
52+
53+
const raw = globalThis.__AGENTCRUMBS__;
54+
if (!raw) {
55+
cachedConfig = { enabled: false };
56+
return cachedConfig;
57+
}
58+
59+
let config: AgentCrumbsConfig;
60+
61+
if (typeof raw === "object") {
62+
config = raw;
63+
} else {
64+
// Shorthand: "1", "*", "true" → enable all
65+
if (raw === "1" || raw === "*" || raw === "true") {
66+
cachedConfig = {
67+
enabled: true,
68+
includes: [/^.*$/],
69+
excludes: [],
70+
port: DEFAULT_PORT,
71+
format: "pretty",
72+
};
73+
return cachedConfig;
74+
}
75+
76+
// Try parsing as JSON config object
77+
try {
78+
config = JSON.parse(raw) as AgentCrumbsConfig;
79+
} catch {
80+
config = { ns: raw };
81+
}
82+
}
83+
84+
const parts = config.ns.split(/[\s,]+/).filter(Boolean);
85+
const includes: RegExp[] = [];
86+
const excludes: RegExp[] = [];
87+
88+
for (const part of parts) {
89+
if (part.startsWith("-")) {
90+
excludes.push(namespaceToRegex(part.slice(1)));
91+
} else {
92+
includes.push(namespaceToRegex(part));
93+
}
94+
}
95+
96+
if (includes.length === 0) {
97+
cachedConfig = { enabled: false };
98+
return cachedConfig;
99+
}
100+
101+
cachedConfig = {
102+
enabled: true,
103+
app: config.app,
104+
includes,
105+
excludes,
106+
port: config.port ?? DEFAULT_PORT,
107+
format: config.format ?? "pretty",
108+
};
109+
return cachedConfig;
110+
}
111+
112+
export function isNamespaceEnabled(namespace: string): boolean {
113+
const config = parseConfig();
114+
if (!config.enabled) return false;
115+
116+
const included = config.includes.some((re) => re.test(namespace));
117+
if (!included) return false;
118+
119+
const excluded = config.excludes.some((re) => re.test(namespace));
120+
return !excluded;
121+
}
122+
123+
export function getCollectorUrl(): string {
124+
const config = parseConfig();
125+
const port = config.enabled ? config.port : DEFAULT_PORT;
126+
return `http://localhost:${port}/crumb`;
127+
}
128+
129+
export function getFormat(): "pretty" | "json" {
130+
const config = parseConfig();
131+
if (!config.enabled) return "pretty";
132+
return config.format;
133+
}
134+
135+
/**
136+
* Resolve the app name. Priority:
137+
* 1. `app` field from configure() config
138+
* 2. `globalThis.__AGENTCRUMBS_APP__`
139+
* 3. Fallback: "browser"
140+
*/
141+
export function getApp(): string {
142+
if (cachedApp !== undefined) return cachedApp;
143+
144+
const config = parseConfig();
145+
if (config.enabled && config.app) {
146+
cachedApp = config.app;
147+
return cachedApp;
148+
}
149+
150+
const globalApp = globalThis.__AGENTCRUMBS_APP__;
151+
if (globalApp) {
152+
cachedApp = globalApp;
153+
return cachedApp;
154+
}
155+
156+
cachedApp = "browser";
157+
return cachedApp;
158+
}
159+
160+
/** Reset cached config — useful for tests */
161+
export function resetConfig(): void {
162+
cachedConfig = undefined;
163+
}
164+
165+
/** Reset cached app — useful for tests */
166+
export function resetApp(): void {
167+
cachedApp = undefined;
168+
}

packages/agentcrumbs/src/env.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,3 +167,10 @@ export function resetConfig(): void {
167167
export function resetApp(): void {
168168
cachedApp = undefined;
169169
}
170+
171+
/** No-op in Node.js — use AGENTCRUMBS env var instead. */
172+
export function configure(
173+
_config: AgentCrumbsConfig | string,
174+
): void {
175+
// In Node.js, use AGENTCRUMBS env var instead
176+
}

packages/agentcrumbs/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
export { trail } from "./trail.js";
22
export { addSink, removeSink } from "./trail.js";
3+
export { configure } from "./env.js";
34
export { NOOP } from "./noop.js";
45
export { MemorySink } from "./sinks/memory.js";
56
export { ConsoleSink } from "./sinks/console.js";
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
import type { Crumb, Sink } from "../types.js";
2+
import { getNamespaceColor } from "../colors.js";
3+
4+
// Map ANSI 256-color indices to CSS colors for DevTools
5+
const COLOR_MAP: Record<number, string> = {
6+
1: "#cc0000",
7+
2: "#4e9a06",
8+
3: "#c4a000",
9+
4: "#3465a4",
10+
5: "#75507b",
11+
6: "#06989a",
12+
9: "#ef2929",
13+
10: "#8ae234",
14+
11: "#fce94f",
15+
12: "#729fcf",
16+
13: "#ad7fa8",
17+
14: "#34e2e2",
18+
170: "#d75fd7",
19+
196: "#ff0000",
20+
202: "#ff5f00",
21+
208: "#ff8700",
22+
};
23+
24+
function cssColor(colorIndex: number): string {
25+
return COLOR_MAP[colorIndex] ?? "#999";
26+
}
27+
28+
function formatDelta(dt: number): string {
29+
if (dt < 1000) return `+${Math.round(dt)}ms`;
30+
if (dt < 60000) return `+${(dt / 1000).toFixed(1)}s`;
31+
return `+${(dt / 60000).toFixed(1)}m`;
32+
}
33+
34+
export class ConsoleSink implements Sink {
35+
write(crumb: Crumb): void {
36+
const color = cssColor(getNamespaceColor(crumb.ns));
37+
const dt = formatDelta(crumb.dt);
38+
const depth = crumb.depth ?? 0;
39+
const pad = " ".repeat(depth);
40+
41+
const nsStyle = `color: ${color}; font-weight: bold`;
42+
const dimStyle = "color: #999";
43+
const boldStyle = "font-weight: bold";
44+
45+
let label: string;
46+
let styles: string[];
47+
48+
switch (crumb.type) {
49+
case "scope:enter":
50+
label = `%c${crumb.ns} %c${pad}%c[${crumb.msg}]%c -> enter %c${dt}`;
51+
styles = [nsStyle, "", boldStyle, dimStyle, dimStyle];
52+
break;
53+
case "scope:exit":
54+
label = `%c${crumb.ns} %c${pad}%c[${crumb.msg}]%c <- exit %c${dt}`;
55+
styles = [nsStyle, "", boldStyle, dimStyle, dimStyle];
56+
break;
57+
case "scope:error":
58+
label = `%c${crumb.ns} %c${pad}%c[${crumb.msg}]%c !! error %c${dt}`;
59+
styles = [nsStyle, "", boldStyle, "color: red", dimStyle];
60+
break;
61+
case "snapshot":
62+
label = `%c${crumb.ns} %c${pad}%csnapshot:%c ${crumb.msg} %c${dt}`;
63+
styles = [nsStyle, "", dimStyle, "", dimStyle];
64+
break;
65+
case "assert":
66+
label = `%c${crumb.ns} %c${pad}%cassert:%c ${crumb.msg} %c${dt}`;
67+
styles = [nsStyle, "", dimStyle, "", dimStyle];
68+
break;
69+
case "time":
70+
label = `%c${crumb.ns} %c${pad}%ctime:%c ${crumb.msg} %c${dt}`;
71+
styles = [nsStyle, "", dimStyle, "", dimStyle];
72+
break;
73+
case "session:start":
74+
label = `%c${crumb.ns} %c${pad}%csession start:%c ${crumb.msg} %c[${crumb.sid}] %c${dt}`;
75+
styles = [nsStyle, "", boldStyle, "", dimStyle, dimStyle];
76+
break;
77+
case "session:end":
78+
label = `%c${crumb.ns} %c${pad}%csession end:%c ${crumb.msg} %c[${crumb.sid}] %c${dt}`;
79+
styles = [nsStyle, "", boldStyle, "", dimStyle, dimStyle];
80+
break;
81+
default:
82+
label = `%c${crumb.ns} %c${pad}${crumb.msg} %c${dt}`;
83+
styles = [nsStyle, "", dimStyle];
84+
}
85+
86+
if (crumb.tags && crumb.tags.length > 0) {
87+
label += ` %c[${crumb.tags.join(", ")}]`;
88+
styles.push(dimStyle);
89+
}
90+
91+
const args: unknown[] = [label, ...styles];
92+
93+
// Pass data as an additional arg so DevTools renders it interactively
94+
if (crumb.data !== undefined) {
95+
args.push(crumb.data);
96+
}
97+
98+
// Use groupCollapsed for scope enter, groupEnd for scope exit
99+
if (crumb.type === "scope:enter") {
100+
console.groupCollapsed(...(args as [string, ...string[]]));
101+
} else if (crumb.type === "scope:exit" || crumb.type === "scope:error") {
102+
console.debug(...(args as [string, ...string[]]));
103+
console.groupEnd();
104+
} else {
105+
console.debug(...(args as [string, ...string[]]));
106+
}
107+
}
108+
}

0 commit comments

Comments
 (0)