@vincentt-xr/harness 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/README.md +87 -0
  2. package/dist/client/HarnessProvider.d.ts +21 -0
  3. package/dist/client/HarnessProvider.js +64 -0
  4. package/dist/client/buffer.d.ts +28 -0
  5. package/dist/client/buffer.js +66 -0
  6. package/dist/client/index.d.ts +3 -0
  7. package/dist/client/index.js +5 -0
  8. package/dist/client/instrument.d.ts +13 -0
  9. package/dist/client/instrument.js +160 -0
  10. package/dist/client/sampler.d.ts +11 -0
  11. package/dist/client/sampler.js +72 -0
  12. package/dist/client/serialize.d.ts +32 -0
  13. package/dist/client/serialize.js +99 -0
  14. package/dist/client/trace.d.ts +31 -0
  15. package/dist/client/trace.js +87 -0
  16. package/dist/mcp/backend.d.ts +52 -0
  17. package/dist/mcp/backend.js +146 -0
  18. package/dist/mcp/cli.d.ts +2 -0
  19. package/dist/mcp/cli.js +10 -0
  20. package/dist/mcp/diagnostics.d.ts +13 -0
  21. package/dist/mcp/diagnostics.js +61 -0
  22. package/dist/mcp/server.d.ts +14 -0
  23. package/dist/mcp/server.js +140 -0
  24. package/dist/preview/cloudflared.d.ts +13 -0
  25. package/dist/preview/cloudflared.js +37 -0
  26. package/dist/preview/index.d.ts +15 -0
  27. package/dist/preview/index.js +30 -0
  28. package/dist/relay/cli.d.ts +2 -0
  29. package/dist/relay/cli.js +7 -0
  30. package/dist/relay/server.d.ts +12 -0
  31. package/dist/relay/server.js +85 -0
  32. package/dist/relay/store.d.ts +13 -0
  33. package/dist/relay/store.js +68 -0
  34. package/dist/scaffold/index.d.ts +19 -0
  35. package/dist/scaffold/index.js +72 -0
  36. package/dist/shared/config.d.ts +33 -0
  37. package/dist/shared/config.js +76 -0
  38. package/dist/shared/events.d.ts +78 -0
  39. package/dist/shared/events.js +6 -0
  40. package/package.json +60 -0
package/README.md ADDED
@@ -0,0 +1,87 @@
1
+ # @vincentt-xr/harness
2
+
3
+ The Vincentt AR dev-loop harness. It is **one always-on diagnostics channel**
4
+ between the AR app running on a phone and the coding agent running on a laptop,
5
+ so the agent can see what the phone sees — console logs, network requests,
6
+ performance — without the developer hand-ferrying DevTools traces or pasting
7
+ console output.
8
+
9
+ Agent-agnostic: the agent side is a plain **MCP server**, so any MCP-capable
10
+ agent (Claude Code, Codex, Cursor, Cline, …) uses it the same way.
11
+
12
+ ## The three parts
13
+
14
+ ```
15
+ PHONE (preview app) RELAY (laptop) AGENT (laptop)
16
+ ┌──────────────────┐ ┌────────────────┐ ┌────────────────────┐
17
+ │ HarnessProvider │ │ harness-relay │ │ harness-mcp │
18
+ │ • patch console │─ws→│ ring buffer │←──→│ diag_logs │
19
+ │ • wrap fetch/XHR │ │ per session │http│ diag_network │
20
+ │ • sample perf │ │ │ │ diag_trace │
21
+ └──────────────────┘ └────────────────┘ └────────────────────┘
22
+ src/client (npm pkg) src/relay (bin) src/mcp (bin)
23
+ ```
24
+
25
+ - **`src/client`** — the in-app half; the published npm package. `HarnessProvider`
26
+ patches `console.*`, wraps `fetch`/`XHR`, and samples `performance`, shipping
27
+ events to the relay over a WebSocket. Auto-on in a dev build, no-op (and
28
+ tree-shaken) in production.
29
+ - **`src/relay`** — a small WebSocket + HTTP server beside Vite. Holds a bounded
30
+ ring buffer of recent events per session. The phone pushes; the agent pulls.
31
+ - **`src/mcp`** — the agent-agnostic MCP server. Exposes `diag_logs`,
32
+ `diag_network`, `diag_trace` that pull from the relay.
33
+
34
+ - **`src/shared/events.ts`** — the wire contract all three import, so they can't
35
+ drift on the event shape.
36
+
37
+ ## Use it
38
+
39
+ **In the app (once):**
40
+
41
+ ```tsx
42
+ import { HarnessProvider } from "@vincentt-xr/harness";
43
+
44
+ <HarnessProvider>
45
+ <App />
46
+ </HarnessProvider>;
47
+ ```
48
+
49
+ That's the whole author-facing surface. It self-activates in dev and disappears
50
+ in the production build.
51
+
52
+ **Run the relay** beside your dev server (the preview tunnel carries its socket
53
+ to the phone):
54
+
55
+ ```
56
+ npx harness-relay # listens on :7331, ws path /__harness
57
+ ```
58
+
59
+ **Point your agent at the MCP server.** In the agent's MCP config:
60
+
61
+ ```json
62
+ {
63
+ "mcpServers": {
64
+ "vincentt-harness": { "command": "npx", "args": ["harness-mcp"] }
65
+ }
66
+ }
67
+ ```
68
+
69
+ Then, while the app is open on the phone, the agent calls `diag_logs`,
70
+ `diag_network`, `diag_trace` to read what the device is doing — no DevTools, no
71
+ copy-paste.
72
+
73
+ ## Develop
74
+
75
+ ```
76
+ pnpm install
77
+ pnpm test # unit tests (pure event-shaping, buffer, ring store, formatting, fps)
78
+ pnpm typecheck
79
+ pnpm build # emits dist/ (client = npm surface; relay/mcp = bins)
80
+ ```
81
+
82
+ ## Status
83
+
84
+ Local-first. The diagnostics limb is built and proven end-to-end
85
+ (phone → relay → agent). The platform lifecycle (per-project server-minted
86
+ tunnels, `createProject`/`publish`, auth) is a separate later phase; the same
87
+ client + relay also feed the production client-review service.
@@ -0,0 +1,21 @@
1
+ import { type ReactNode } from "react";
2
+ export interface HarnessProviderProps {
3
+ children: ReactNode;
4
+ /**
5
+ * Force diagnostics on/off. Defaults to `import.meta.env.DEV` — on in the dev
6
+ * server + preview build, off in the production bundle.
7
+ */
8
+ enabled?: boolean;
9
+ /**
10
+ * WebSocket URL of the relay. Defaults to the same host the app is served
11
+ * from on port 7331 (what `harness-relay` listens on, tunneled alongside the
12
+ * app). Override for the production review-service sink.
13
+ */
14
+ relayUrl?: string;
15
+ /** Session id grouping this preview run's events. Defaults to a per-load id. */
16
+ sessionId?: string;
17
+ captureConsole?: boolean;
18
+ captureNetwork?: boolean;
19
+ captureTrace?: boolean;
20
+ }
21
+ export declare function HarnessProvider(props: HarnessProviderProps): ReactNode;
@@ -0,0 +1,64 @@
1
+ // The one thing an app author touches. Wrap the app once:
2
+ //
3
+ // import { HarnessProvider } from "@vincentt-xr/harness";
4
+ // <HarnessProvider><App /></HarnessProvider>
5
+ //
6
+ // It self-activates in a dev build and is a no-op (and tree-shakes to nothing)
7
+ // in production, so there is no way to ship diagnostics to real users by
8
+ // accident. The relay URL and session id default to what the preview loop sets,
9
+ // but both are overridable for the production review-service path later.
10
+ import { useEffect } from "react";
11
+ function defaultRelayUrl() {
12
+ if (typeof window === "undefined")
13
+ return "ws://localhost:7331";
14
+ const proto = window.location.protocol === "https:" ? "wss" : "ws";
15
+ // Default: the relay rides the same tunnel host as the app on a fixed path,
16
+ // so one cloudflared tunnel carries both. The relay CLI serves this path.
17
+ return `${proto}://${window.location.host}/__harness`;
18
+ }
19
+ function defaultSessionId() {
20
+ if (typeof window === "undefined")
21
+ return "server";
22
+ const key = "__vincentt_harness_session";
23
+ const existing = window.sessionStorage?.getItem(key);
24
+ if (existing)
25
+ return existing;
26
+ // Per-load id; not security-sensitive, just needs to be distinct enough that
27
+ // two phones on one relay don't collide. No Math.random dependency in the
28
+ // pure layer — this imperative shell may use it.
29
+ const id = `s_${Date.now().toString(36)}_${Math.floor(Math.random() * 1e6).toString(36)}`;
30
+ window.sessionStorage?.setItem(key, id);
31
+ return id;
32
+ }
33
+ const DEV = typeof import.meta !== "undefined" &&
34
+ import.meta.env?.DEV;
35
+ export function HarnessProvider(props) {
36
+ const enabled = props.enabled ?? Boolean(DEV);
37
+ useEffect(() => {
38
+ if (!enabled)
39
+ return undefined;
40
+ let teardown;
41
+ let cancelled = false;
42
+ // Dynamic import: the instrumentation module (and its whole dependency
43
+ // chain) is only fetched when enabled, so prod builds never include it.
44
+ void import("./instrument.js").then(({ installInstrumentation }) => {
45
+ if (cancelled)
46
+ return;
47
+ teardown = installInstrumentation({
48
+ sessionId: props.sessionId ?? defaultSessionId(),
49
+ relayUrl: props.relayUrl ?? defaultRelayUrl(),
50
+ captureConsole: props.captureConsole,
51
+ captureNetwork: props.captureNetwork,
52
+ captureTrace: props.captureTrace,
53
+ });
54
+ });
55
+ return () => {
56
+ cancelled = true;
57
+ teardown?.();
58
+ };
59
+ // Instrumentation is installed once for the provider's lifetime; option
60
+ // changes mid-session are not a supported case.
61
+ // eslint-disable-next-line react-hooks/exhaustive-deps
62
+ }, [enabled]);
63
+ return props.children;
64
+ }
@@ -0,0 +1,28 @@
1
+ import type { ClientToRelay, DiagEvent } from "../shared/events.js";
2
+ export interface BufferOptions {
3
+ sessionId: string;
4
+ /** Coalesce window: events within this many ms flush together. */
5
+ flushIntervalMs?: number;
6
+ /** Hard cap on pending events; oldest dropped past this (backpressure). */
7
+ maxPending?: number;
8
+ /** Injected transport. Returns true if the batch was accepted (socket open). */
9
+ send: (batch: ClientToRelay) => boolean;
10
+ /** Injected timer so tests are deterministic; defaults to setTimeout. */
11
+ schedule?: (fn: () => void, ms: number) => void;
12
+ }
13
+ export declare class EventBuffer {
14
+ private seq;
15
+ private pending;
16
+ private flushArmed;
17
+ private readonly opts;
18
+ constructor(options: BufferOptions);
19
+ /** Next sequence number without consuming it — for makeXEvent(seq, ...). */
20
+ nextSeq(): number;
21
+ /** Stamp and enqueue an event (seq already baked in via nextSeq). */
22
+ add(event: DiagEvent): void;
23
+ private armFlush;
24
+ /** Send everything pending; keep it buffered if the transport rejects it. */
25
+ flush(): void;
26
+ /** Test/introspection helper. */
27
+ pendingCount(): number;
28
+ }
@@ -0,0 +1,66 @@
1
+ // The client's outbound buffer: assigns each event a monotonic seq, holds them
2
+ // until a flush, and coalesces flushes so a burst of console.logs becomes one
3
+ // socket message. Pure and transport-agnostic — you inject a `send` function,
4
+ // and a `now`/`schedule` so tests control time. instrument.ts wires the real
5
+ // WebSocket + setTimeout in.
6
+ export class EventBuffer {
7
+ seq = 0;
8
+ pending = [];
9
+ flushArmed = false;
10
+ opts;
11
+ constructor(options) {
12
+ this.opts = {
13
+ flushIntervalMs: 250,
14
+ maxPending: 500,
15
+ schedule: (fn, ms) => setTimeout(fn, ms),
16
+ ...options,
17
+ };
18
+ }
19
+ /** Next sequence number without consuming it — for makeXEvent(seq, ...). */
20
+ nextSeq() {
21
+ return this.seq;
22
+ }
23
+ /** Stamp and enqueue an event (seq already baked in via nextSeq). */
24
+ add(event) {
25
+ // Trust the caller used nextSeq(); advance the counter to match.
26
+ this.seq = Math.max(this.seq, event.seq + 1);
27
+ this.pending.push(event);
28
+ // Drop oldest on overflow rather than grow unbounded on a runaway logger.
29
+ if (this.pending.length > this.opts.maxPending) {
30
+ this.pending.splice(0, this.pending.length - this.opts.maxPending);
31
+ }
32
+ this.armFlush();
33
+ }
34
+ armFlush() {
35
+ if (this.flushArmed)
36
+ return;
37
+ this.flushArmed = true;
38
+ this.opts.schedule(() => {
39
+ this.flushArmed = false;
40
+ this.flush();
41
+ }, this.opts.flushIntervalMs);
42
+ }
43
+ /** Send everything pending; keep it buffered if the transport rejects it. */
44
+ flush() {
45
+ if (this.pending.length === 0)
46
+ return;
47
+ const batch = {
48
+ type: "events",
49
+ sessionId: this.opts.sessionId,
50
+ events: this.pending,
51
+ };
52
+ const accepted = this.opts.send(batch);
53
+ if (accepted) {
54
+ this.pending = [];
55
+ }
56
+ else {
57
+ // Socket not open yet — keep the events, re-arm so we retry. Bounded by
58
+ // maxPending so a never-connecting socket can't leak memory.
59
+ this.armFlush();
60
+ }
61
+ }
62
+ /** Test/introspection helper. */
63
+ pendingCount() {
64
+ return this.pending.length;
65
+ }
66
+ }
@@ -0,0 +1,3 @@
1
+ export { HarnessProvider } from "./HarnessProvider.js";
2
+ export type { HarnessProviderProps } from "./HarnessProvider.js";
3
+ export type { DiagEvent, LogEvent, NetworkEvent, TraceEvent } from "../shared/events.js";
@@ -0,0 +1,5 @@
1
+ // The published surface of @vincentt-xr/harness — the in-app half. Everything
2
+ // here is imported by app code and ships in the app bundle (dev only; prod
3
+ // tree-shakes it). The relay and MCP server are NOT exported here; they are
4
+ // run-from-bin, not imported.
5
+ export { HarnessProvider } from "./HarnessProvider.js";
@@ -0,0 +1,13 @@
1
+ export interface InstrumentOptions {
2
+ sessionId: string;
3
+ relayUrl: string;
4
+ captureConsole?: boolean;
5
+ captureNetwork?: boolean;
6
+ captureTrace?: boolean;
7
+ }
8
+ /**
9
+ * Install the diagnostics instrumentation. Returns a teardown that fully
10
+ * restores the originals — important so dev double-mounts (React StrictMode)
11
+ * don't stack patches. Idempotent per window via a guard.
12
+ */
13
+ export declare function installInstrumentation(opts: InstrumentOptions): () => void;
@@ -0,0 +1,160 @@
1
+ // The imperative shell: patch console.* and wrap fetch/XHR so every log and
2
+ // request becomes a DiagEvent handed to the buffer. Kept deliberately thin —
3
+ // all the formatting/batching decisions live in serialize.ts + buffer.ts. This
4
+ // file only does the monkey-patching and owns the WebSocket.
5
+ import { EventBuffer } from "./buffer.js";
6
+ import { startSampler } from "./sampler.js";
7
+ import { describeRequest, makeLogEvent, makeNetworkEvent } from "./serialize.js";
8
+ const LEVELS = ["log", "info", "warn", "error", "debug"];
9
+ /**
10
+ * Install the diagnostics instrumentation. Returns a teardown that fully
11
+ * restores the originals — important so dev double-mounts (React StrictMode)
12
+ * don't stack patches. Idempotent per window via a guard.
13
+ */
14
+ export function installInstrumentation(opts) {
15
+ const w = window;
16
+ if (w.__vincenttHarness)
17
+ return () => undefined;
18
+ w.__vincenttHarness = true;
19
+ const socket = openSocket(opts.relayUrl);
20
+ const buffer = new EventBuffer({
21
+ sessionId: opts.sessionId,
22
+ send: (batch) => {
23
+ if (socket.ready()) {
24
+ socket.send(JSON.stringify(batch));
25
+ return true;
26
+ }
27
+ return false;
28
+ },
29
+ });
30
+ const restores = [];
31
+ if (opts.captureConsole !== false) {
32
+ restores.push(patchConsole(buffer));
33
+ }
34
+ if (opts.captureNetwork !== false) {
35
+ restores.push(patchFetch(buffer));
36
+ restores.push(patchXHR(buffer));
37
+ }
38
+ if (opts.captureTrace !== false) {
39
+ restores.push(startSampler(buffer));
40
+ }
41
+ return () => {
42
+ restores.forEach((r) => r());
43
+ socket.close();
44
+ w.__vincenttHarness = false;
45
+ };
46
+ }
47
+ /** A tiny reconnecting WebSocket wrapper. */
48
+ function openSocket(url) {
49
+ let ws = null;
50
+ let closed = false;
51
+ const connect = () => {
52
+ if (closed)
53
+ return;
54
+ try {
55
+ ws = new WebSocket(url);
56
+ ws.onclose = () => {
57
+ ws = null;
58
+ if (!closed)
59
+ setTimeout(connect, 1000);
60
+ };
61
+ ws.onerror = () => ws?.close();
62
+ }
63
+ catch {
64
+ if (!closed)
65
+ setTimeout(connect, 1000);
66
+ }
67
+ };
68
+ connect();
69
+ return {
70
+ ready: () => ws?.readyState === WebSocket.OPEN,
71
+ send: (data) => ws?.send(data),
72
+ close: () => {
73
+ closed = true;
74
+ ws?.close();
75
+ },
76
+ };
77
+ }
78
+ function patchConsole(buffer) {
79
+ const original = {};
80
+ for (const level of LEVELS) {
81
+ // eslint-disable-next-line no-console
82
+ const orig = console[level];
83
+ original[level] = orig;
84
+ // eslint-disable-next-line no-console
85
+ console[level] = (...args) => {
86
+ try {
87
+ const seq = buffer.nextSeq();
88
+ buffer.add(makeLogEvent(level, args, seq, Date.now()));
89
+ }
90
+ catch {
91
+ // Never let instrumentation break the app's own logging.
92
+ }
93
+ orig.apply(console, args);
94
+ };
95
+ }
96
+ return () => {
97
+ for (const level of LEVELS) {
98
+ if (original[level]) {
99
+ // eslint-disable-next-line no-console
100
+ console[level] = original[level];
101
+ }
102
+ }
103
+ };
104
+ }
105
+ function patchFetch(buffer) {
106
+ if (typeof window.fetch !== "function")
107
+ return () => undefined;
108
+ const orig = window.fetch.bind(window);
109
+ window.fetch = async (input, init) => {
110
+ const desc = describeRequest(input, init);
111
+ const start = Date.now();
112
+ try {
113
+ const res = await orig(input, init);
114
+ record(buffer, desc, res.status, Date.now() - start);
115
+ return res;
116
+ }
117
+ catch (err) {
118
+ record(buffer, desc, 0, Date.now() - start, err instanceof Error ? err.message : String(err));
119
+ throw err;
120
+ }
121
+ };
122
+ return () => {
123
+ window.fetch = orig;
124
+ };
125
+ }
126
+ function patchXHR(buffer) {
127
+ const OrigXHR = window.XMLHttpRequest;
128
+ if (typeof OrigXHR !== "function")
129
+ return () => undefined;
130
+ const openOrig = OrigXHR.prototype.open;
131
+ const sendOrig = OrigXHR.prototype.send;
132
+ OrigXHR.prototype.open = function (method, url) {
133
+ this.__h = describeRequest(typeof url === "string" ? url : url.toString(), { method });
134
+ // eslint-disable-next-line prefer-rest-params
135
+ return openOrig.apply(this, arguments);
136
+ };
137
+ OrigXHR.prototype.send = function (...rest) {
138
+ const meta = this.__h;
139
+ const start = Date.now();
140
+ if (meta) {
141
+ this.addEventListener("loadend", () => {
142
+ record(buffer, meta, this.status, Date.now() - start, this.status === 0 ? "network error" : undefined);
143
+ });
144
+ }
145
+ return sendOrig.apply(this, rest);
146
+ };
147
+ return () => {
148
+ OrigXHR.prototype.open = openOrig;
149
+ OrigXHR.prototype.send = sendOrig;
150
+ };
151
+ }
152
+ function record(buffer, desc, status, durationMs, error) {
153
+ try {
154
+ const seq = buffer.nextSeq();
155
+ buffer.add(makeNetworkEvent(desc, status, durationMs, seq, Date.now(), error));
156
+ }
157
+ catch {
158
+ // swallow — diagnostics must never break the request path
159
+ }
160
+ }
@@ -0,0 +1,11 @@
1
+ import { EventBuffer } from "./buffer.js";
2
+ export interface SamplerOptions {
3
+ /** How often to emit a TraceEvent, ms. */
4
+ windowMs?: number;
5
+ }
6
+ /**
7
+ * Start sampling. Returns a teardown. Degrades gracefully where APIs are
8
+ * missing (PerformanceObserver / longtask are not universal) — fps still works
9
+ * from rAF alone, which is the signal that mattered for the readback finding.
10
+ */
11
+ export declare function startSampler(buffer: EventBuffer, opts?: SamplerOptions): () => void;
@@ -0,0 +1,72 @@
1
+ // The imperative perf sampler: drive a TraceAccumulator from real browser
2
+ // signals (rAF for frames, PerformanceObserver for long tasks + marks) and
3
+ // flush one TraceEvent per window into the buffer. Thin shell — all the math is
4
+ // in trace.ts. instrument.ts installs this alongside the console/network
5
+ // patches when the trace tier is on.
6
+ import { TraceAccumulator } from "./trace.js";
7
+ /**
8
+ * Start sampling. Returns a teardown. Degrades gracefully where APIs are
9
+ * missing (PerformanceObserver / longtask are not universal) — fps still works
10
+ * from rAF alone, which is the signal that mattered for the readback finding.
11
+ */
12
+ export function startSampler(buffer, opts = {}) {
13
+ if (typeof requestAnimationFrame !== "function")
14
+ return () => undefined;
15
+ const windowMs = opts.windowMs ?? 5000;
16
+ const acc = new TraceAccumulator();
17
+ let rafId = null;
18
+ let stopped = false;
19
+ const onFrame = (t) => {
20
+ if (stopped)
21
+ return;
22
+ acc.frame(t);
23
+ rafId = requestAnimationFrame(onFrame);
24
+ };
25
+ rafId = requestAnimationFrame(onFrame);
26
+ let observer = null;
27
+ if (typeof PerformanceObserver === "function") {
28
+ try {
29
+ observer = new PerformanceObserver((list) => {
30
+ for (const entry of list.getEntries()) {
31
+ if (entry.entryType === "longtask")
32
+ acc.task(entry.duration);
33
+ else if (entry.entryType === "mark")
34
+ acc.mark(entry.name);
35
+ }
36
+ });
37
+ // longtask isn't supported everywhere (notably iOS Safari) — try each
38
+ // type independently so one unsupported type doesn't kill the others.
39
+ trySubscribe(observer, "longtask");
40
+ trySubscribe(observer, "mark");
41
+ }
42
+ catch {
43
+ observer = null;
44
+ }
45
+ }
46
+ const timer = setInterval(() => {
47
+ if (!acc.hasSamples())
48
+ return;
49
+ const seq = buffer.nextSeq();
50
+ buffer.add(acc.summarize(performanceNow(), seq));
51
+ }, windowMs);
52
+ return () => {
53
+ stopped = true;
54
+ if (rafId !== null)
55
+ cancelAnimationFrame(rafId);
56
+ clearInterval(timer);
57
+ observer?.disconnect();
58
+ };
59
+ }
60
+ function trySubscribe(observer, type) {
61
+ try {
62
+ observer.observe({ type, buffered: true });
63
+ }
64
+ catch {
65
+ // this entry type isn't supported here; the others still work
66
+ }
67
+ }
68
+ function performanceNow() {
69
+ return typeof performance !== "undefined"
70
+ ? performance.timeOrigin + performance.now()
71
+ : Date.now();
72
+ }
@@ -0,0 +1,32 @@
1
+ import type { LogEvent, NetworkEvent } from "../shared/events.js";
2
+ /**
3
+ * Safely stringify one console argument. The phone's console is fed anything —
4
+ * Errors, DOM nodes, cyclic objects, huge arrays — and this has to survive all
5
+ * of them without throwing (a throwing logger would take the app down) and
6
+ * without shipping megabytes over the socket.
7
+ */
8
+ export declare function stringifyArg(arg: unknown, maxLen?: number): string;
9
+ /** Join console.* varargs into one message the way the devtools console would. */
10
+ export declare function formatLogArgs(args: unknown[], maxLen?: number): string;
11
+ /** Build a LogEvent from a console call. seq/t are supplied by the buffer. */
12
+ export declare function makeLogEvent(level: LogEvent["level"], args: unknown[], seq: number, t: number): LogEvent;
13
+ /** First stack frame that isn't runtime/library noise, best-effort. */
14
+ export declare function firstAppFrame(err?: Error): string | undefined;
15
+ /**
16
+ * Normalize the many shapes fetch() is called with (string, URL, Request) into
17
+ * a {method, url} pair without consuming the body.
18
+ */
19
+ export declare function describeRequest(input: string | URL | {
20
+ url?: string;
21
+ method?: string;
22
+ }, init?: {
23
+ method?: string;
24
+ }): {
25
+ method: string;
26
+ url: string;
27
+ };
28
+ /** Build a NetworkEvent from a settled request. */
29
+ export declare function makeNetworkEvent(desc: {
30
+ method: string;
31
+ url: string;
32
+ }, status: number, durationMs: number, seq: number, t: number, error?: string): NetworkEvent;
@@ -0,0 +1,99 @@
1
+ // Pure event-shaping for the in-app client. The imperative shell (patching
2
+ // console, wrapping fetch) lives in instrument.ts and calls into here, so all
3
+ // the fiddly formatting logic is testable without a browser.
4
+ /**
5
+ * Safely stringify one console argument. The phone's console is fed anything —
6
+ * Errors, DOM nodes, cyclic objects, huge arrays — and this has to survive all
7
+ * of them without throwing (a throwing logger would take the app down) and
8
+ * without shipping megabytes over the socket.
9
+ */
10
+ export function stringifyArg(arg, maxLen = 2000) {
11
+ if (typeof arg === "string")
12
+ return truncate(arg, maxLen);
13
+ if (arg instanceof Error) {
14
+ return truncate(`${arg.name}: ${arg.message}${arg.stack ? `\n${arg.stack}` : ""}`, maxLen);
15
+ }
16
+ if (typeof arg === "number" || typeof arg === "boolean" || arg == null) {
17
+ return String(arg);
18
+ }
19
+ if (typeof arg === "function")
20
+ return `[Function: ${arg.name || "anonymous"}]`;
21
+ try {
22
+ return truncate(JSON.stringify(arg, cyclicSafeReplacer()), maxLen);
23
+ }
24
+ catch {
25
+ return truncate(String(arg), maxLen);
26
+ }
27
+ }
28
+ /** Join console.* varargs into one message the way the devtools console would. */
29
+ export function formatLogArgs(args, maxLen = 2000) {
30
+ return truncate(args.map((a) => stringifyArg(a, maxLen)).join(" "), maxLen);
31
+ }
32
+ function truncate(s, maxLen) {
33
+ return s.length > maxLen ? `${s.slice(0, maxLen)}… (${s.length - maxLen} more)` : s;
34
+ }
35
+ /** A JSON.stringify replacer that renders cyclic refs as a marker, not a throw. */
36
+ function cyclicSafeReplacer() {
37
+ const seen = new WeakSet();
38
+ return (_key, value) => {
39
+ if (typeof value === "object" && value !== null) {
40
+ if (seen.has(value))
41
+ return "[Circular]";
42
+ seen.add(value);
43
+ }
44
+ return value;
45
+ };
46
+ }
47
+ /** Build a LogEvent from a console call. seq/t are supplied by the buffer. */
48
+ export function makeLogEvent(level, args, seq, t) {
49
+ const message = formatLogArgs(args);
50
+ // For errors, surface the first meaningful stack frame so the agent can jump
51
+ // to a file:line without the whole trace.
52
+ const origin = level === "error"
53
+ ? firstAppFrame(args.find((a) => a instanceof Error))
54
+ : undefined;
55
+ return origin
56
+ ? { kind: "log", level, message, seq, t, origin }
57
+ : { kind: "log", level, message, seq, t };
58
+ }
59
+ /** First stack frame that isn't runtime/library noise, best-effort. */
60
+ export function firstAppFrame(err) {
61
+ if (!err?.stack)
62
+ return undefined;
63
+ const lines = err.stack.split("\n").slice(1);
64
+ const appLine = lines.find((l) => !l.includes("node_modules") &&
65
+ !l.includes("/harness/") &&
66
+ !l.includes("node:") &&
67
+ l.trim().startsWith("at "));
68
+ return appLine?.trim();
69
+ }
70
+ /**
71
+ * Normalize the many shapes fetch() is called with (string, URL, Request) into
72
+ * a {method, url} pair without consuming the body.
73
+ */
74
+ export function describeRequest(input, init) {
75
+ let url = "";
76
+ let method = init?.method;
77
+ if (typeof input === "string")
78
+ url = input;
79
+ else if (input instanceof URL)
80
+ url = input.toString();
81
+ else {
82
+ url = input.url ?? "";
83
+ method = method ?? input.method;
84
+ }
85
+ return { method: (method ?? "GET").toUpperCase(), url };
86
+ }
87
+ /** Build a NetworkEvent from a settled request. */
88
+ export function makeNetworkEvent(desc, status, durationMs, seq, t, error) {
89
+ const base = {
90
+ kind: "network",
91
+ method: desc.method,
92
+ url: desc.url,
93
+ status,
94
+ durationMs: Math.round(durationMs),
95
+ seq,
96
+ t,
97
+ };
98
+ return error ? { ...base, error } : base;
99
+ }