retrace-sdk 0.13.3 → 0.15.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.
@@ -1,3 +1,10 @@
1
+ /**
2
+ * Golden cassette writer for CI regression replay (Phase 4b).
3
+ *
4
+ * After recording a run, write its cassette to a file you commit to your repo; `retrace ci replay`
5
+ * diffs a fresh run against it in CI. The JSON shape is the cross-language contract in
6
+ * `@retrace/shared` (CassetteSchema) — keep this writer in sync with it.
7
+ */
1
8
  import type { TraceRecorder } from "./recorder.js";
2
9
  export interface CassetteTolerance {
3
10
  default?: "exact" | "ignore" | "semantic" | "judge";
package/dist/cassette.js CHANGED
@@ -1,12 +1,12 @@
1
- /**
2
- * Golden cassette writer for CI regression replay (Phase 4b).
3
- *
4
- * After recording a run, write its cassette to a file you commit to your repo; `retrace ci replay`
5
- * diffs a fresh run against it in CI. The JSON shape is the cross-language contract in
6
- * `@retrace/shared` (CassetteSchema) — keep this writer in sync with it.
7
- */
8
- import { writeFileSync } from "node:fs";
9
1
  import { getActiveRecorder } from "./init.js";
2
+ // node:fs is loaded LAZILY (not a static import) so this module — re-exported from the package
3
+ // barrel — still evaluates on edge runtimes. Golden cassettes are a Node/CI feature (writing a file
4
+ // to disk), so on Node we resolve writeFileSync once at module load; on edge writeGoldenCassette
5
+ // throws a clear error. The microtask import resolves long before any real post-run cassette write.
6
+ let _writeFileSync = null;
7
+ if (typeof process !== "undefined" && process.versions?.node) {
8
+ void import("node:fs").then((m) => { _writeFileSync = m.writeFileSync; }).catch(() => { });
9
+ }
10
10
  /**
11
11
  * Write the active (or given) recorder's run to `path` as a golden cassette. Returns the cassette.
12
12
  * Throws if no recorder is active — call it inside/after your traced function, before the process
@@ -19,6 +19,9 @@ export function writeGoldenCassette(path, opts) {
19
19
  const cassette = recorder.toCassette();
20
20
  if (opts?.tolerance)
21
21
  cassette.tolerance = opts.tolerance;
22
- writeFileSync(path, JSON.stringify(cassette, null, 2));
22
+ if (!_writeFileSync) {
23
+ throw new Error("writeGoldenCassette requires a Node.js filesystem; it isn't available on this runtime (e.g. edge).");
24
+ }
25
+ _writeFileSync(path, JSON.stringify(cassette, null, 2));
23
26
  return cassette;
24
27
  }
package/dist/config.d.ts CHANGED
@@ -32,6 +32,10 @@ export interface Config {
32
32
  * the fail-closed timeout verdict. 0 = trip immediately. (The auto path is synchronous, so the
33
33
  * poll runs in the background and a denial/timeout trips the NEXT span.) */
34
34
  enforcementHoldWaitSeconds: number;
35
+ /** Environment indicator sent with every trace as `metadata.environment`. Set to "sandbox" to
36
+ * record into the auto-expiring sandbox data-namespace instead of production (the server also
37
+ * accepts an `X-Retrace-Environment` header). Env: RETRACE_ENVIRONMENT. */
38
+ environment: string | undefined;
35
39
  }
36
40
  export declare function configure(opts: Partial<Config>): Config;
37
41
  export declare function requireApiKey(): string;
package/dist/config.js CHANGED
@@ -1,18 +1,23 @@
1
+ // Runtime-safe env: edge runtimes (Cloudflare Workers, Deno/Supabase Edge) may not define a global
2
+ // `process`, so reading process.env.* directly would throw at module-evaluation time. Fall back to
3
+ // an empty map there; explicit configure({ apiKey, ... }) still works on every runtime.
4
+ const env = typeof process !== "undefined" && process.env ? process.env : {};
1
5
  const config = {
2
- apiKey: process.env.RETRACE_API_KEY || "",
3
- baseUrl: process.env.RETRACE_BASE_URL || "https://api.retraceai.tech",
6
+ apiKey: env.RETRACE_API_KEY || "",
7
+ baseUrl: env.RETRACE_BASE_URL || "https://api.retraceai.tech",
4
8
  wsUrl: "",
5
- projectId: process.env.RETRACE_PROJECT_ID || undefined,
6
- enabled: !["false", "0"].includes((process.env.RETRACE_ENABLED || "true").toLowerCase()),
7
- sampleRate: parseFloat(process.env.RETRACE_SAMPLE_RATE || "1"),
8
- sampleSeed: process.env.RETRACE_SAMPLE_SEED || undefined,
9
- transport: (["auto", "ws", "http"].includes(process.env.RETRACE_TRANSPORT || "") ? process.env.RETRACE_TRANSPORT : "auto"),
10
- strictReplay: ["true", "1"].includes((process.env.RETRACE_STRICT_REPLAY || "").toLowerCase()),
11
- maxStepsPerRun: process.env.RETRACE_MAX_STEPS_PER_RUN ? parseInt(process.env.RETRACE_MAX_STEPS_PER_RUN, 10) : undefined,
12
- maxTokensPerRun: process.env.RETRACE_MAX_TOKENS_PER_RUN ? parseInt(process.env.RETRACE_MAX_TOKENS_PER_RUN, 10) : undefined,
13
- maxUsdPerRun: process.env.RETRACE_MAX_USD_PER_RUN ? parseFloat(process.env.RETRACE_MAX_USD_PER_RUN) : undefined,
14
- serverEnforcement: ["true", "1", "yes"].includes((process.env.RETRACE_SERVER_ENFORCEMENT || "").toLowerCase()),
15
- enforcementHoldWaitSeconds: process.env.RETRACE_ENFORCEMENT_HOLD_WAIT_SECONDS ? parseInt(process.env.RETRACE_ENFORCEMENT_HOLD_WAIT_SECONDS, 10) : 0,
9
+ projectId: env.RETRACE_PROJECT_ID || undefined,
10
+ enabled: !["false", "0"].includes((env.RETRACE_ENABLED || "true").toLowerCase()),
11
+ sampleRate: parseFloat(env.RETRACE_SAMPLE_RATE || "1"),
12
+ sampleSeed: env.RETRACE_SAMPLE_SEED || undefined,
13
+ transport: (["auto", "ws", "http"].includes(env.RETRACE_TRANSPORT || "") ? env.RETRACE_TRANSPORT : "auto"),
14
+ strictReplay: ["true", "1"].includes((env.RETRACE_STRICT_REPLAY || "").toLowerCase()),
15
+ maxStepsPerRun: env.RETRACE_MAX_STEPS_PER_RUN ? parseInt(env.RETRACE_MAX_STEPS_PER_RUN, 10) : undefined,
16
+ maxTokensPerRun: env.RETRACE_MAX_TOKENS_PER_RUN ? parseInt(env.RETRACE_MAX_TOKENS_PER_RUN, 10) : undefined,
17
+ maxUsdPerRun: env.RETRACE_MAX_USD_PER_RUN ? parseFloat(env.RETRACE_MAX_USD_PER_RUN) : undefined,
18
+ serverEnforcement: ["true", "1", "yes"].includes((env.RETRACE_SERVER_ENFORCEMENT || "").toLowerCase()),
19
+ enforcementHoldWaitSeconds: env.RETRACE_ENFORCEMENT_HOLD_WAIT_SECONDS ? parseInt(env.RETRACE_ENFORCEMENT_HOLD_WAIT_SECONDS, 10) : 0,
20
+ environment: env.RETRACE_ENVIRONMENT || undefined,
16
21
  };
17
22
  config.wsUrl = config.baseUrl.replace("https://", "wss://").replace("http://", "ws://");
18
23
  export function configure(opts) {
@@ -1,3 +1,14 @@
1
+ /**
2
+ * Client-side enforcement gate (circuit breakers) — the TypeScript twin of the Python
3
+ * `EnforcementGate` (same names, same semantics).
4
+ *
5
+ * LOCAL ceilings (max steps / tokens / USD per run) are enforced entirely offline — zero network, so
6
+ * the breaker trips even when the API is unreachable, and it throws SYNCHRONOUSLY before the next
7
+ * call. When `serverEnforcement` is enabled the gate ALSO consults the server `/enforcement/check`
8
+ * endpoint; because auto-instrumentation routes spans synchronously, that call is best-effort and
9
+ * fire-and-forget — a server block trips the NEXT span (one-span lag) rather than the current one,
10
+ * and a transport failure is logged, never swallowed (server policy is authoritative at ingest).
11
+ */
1
12
  import type { Config } from "./config.js";
2
13
  /** Stable short hash of tool arguments so raw args never leave the process for a loop/debounce check. */
3
14
  export declare function hashToolArgs(args: unknown): string;
@@ -1,16 +1,25 @@
1
- /**
2
- * Client-side enforcement gate (circuit breakers) — the TypeScript twin of the Python
3
- * `EnforcementGate` (same names, same semantics).
4
- *
5
- * LOCAL ceilings (max steps / tokens / USD per run) are enforced entirely offline — zero network, so
6
- * the breaker trips even when the API is unreachable, and it throws SYNCHRONOUSLY before the next
7
- * call. When `serverEnforcement` is enabled the gate ALSO consults the server `/enforcement/check`
8
- * endpoint; because auto-instrumentation routes spans synchronously, that call is best-effort and
9
- * fire-and-forget — a server block trips the NEXT span (one-span lag) rather than the current one,
10
- * and a transport failure is logged, never swallowed (server policy is authoritative at ingest).
11
- */
12
- import { createHash } from "node:crypto";
13
1
  import { RetraceEnforcementError } from "./errors.js";
2
+ // Synchronous, dependency-free hash → 32 hex chars (cyrb53 variant, two seeds for a wider digest).
3
+ // Used ONLY for LOCAL loop/debounce dedup keys within a single run — never a security or
4
+ // cross-language boundary — so a fast non-cryptographic hash is sufficient. This keeps the gate
5
+ // synchronous (Web Crypto's subtle.digest is async) AND runtime-agnostic (no `node:crypto` import,
6
+ // which would break module evaluation on edge runtimes).
7
+ function shortHash(input) {
8
+ const pass = (seed) => {
9
+ let h1 = 0xdeadbeef ^ seed;
10
+ let h2 = 0x41c6ce57 ^ seed;
11
+ for (let i = 0; i < input.length; i++) {
12
+ const ch = input.charCodeAt(i);
13
+ h1 = Math.imul(h1 ^ ch, 2654435761);
14
+ h2 = Math.imul(h2 ^ ch, 1597334677);
15
+ }
16
+ h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507) ^ Math.imul(h2 ^ (h2 >>> 13), 3266489909);
17
+ h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507) ^ Math.imul(h1 ^ (h1 >>> 13), 3266489909);
18
+ const n = 4294967296 * (2097151 & h2) + (h1 >>> 0);
19
+ return n.toString(16).padStart(14, "0");
20
+ };
21
+ return (pass(0) + pass(0x9e3779b9)).slice(0, 32);
22
+ }
14
23
  /** Stable short hash of tool arguments so raw args never leave the process for a loop/debounce check. */
15
24
  export function hashToolArgs(args) {
16
25
  let serialized;
@@ -20,7 +29,7 @@ export function hashToolArgs(args) {
20
29
  catch {
21
30
  serialized = String(args);
22
31
  }
23
- return createHash("sha256").update(serialized).digest("hex").slice(0, 32);
32
+ return shortHash(serialized);
24
33
  }
25
34
  export class EnforcementGate {
26
35
  config;
package/dist/telemetry.js CHANGED
@@ -14,7 +14,7 @@ import { getConfig } from "./config.js";
14
14
  const ANON_ID = Math.random().toString(16).slice(2, 18);
15
15
  const DISABLED = new Set(["0", "false", "no", "off"]);
16
16
  // Keep in sync with package.json version.
17
- const SDK_VERSION = "0.13.3";
17
+ const SDK_VERSION = "0.15.0";
18
18
  function enabled() {
19
19
  return !DISABLED.has((process.env.RETRACE_TELEMETRY ?? "1").trim().toLowerCase());
20
20
  }
package/dist/trace.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { genId, nowIso, utcNow } from "./utils.js";
2
+ import { getConfig } from "./config.js";
2
3
  export var SpanType;
3
4
  (function (SpanType) {
4
5
  SpanType["LLM_CALL"] = "llm_call";
@@ -106,5 +107,13 @@ export class TraceBuilder {
106
107
  setProjectId(id) { this.data.project_id = id; }
107
108
  setSessionId(id) { this.data.session_id = id; }
108
109
  setMetadata(m) { this.data.metadata = m; }
109
- toDict() { return this.data; }
110
+ toDict() {
111
+ // Tag the run's environment (e.g. "sandbox") from config as metadata.environment so the server
112
+ // routes it to the matching data-namespace. Explicit per-trace metadata.environment always wins.
113
+ const env = getConfig().environment;
114
+ if (env && !(this.data.metadata && "environment" in this.data.metadata)) {
115
+ this.data.metadata = { ...(this.data.metadata || {}), environment: env };
116
+ }
117
+ return this.data;
118
+ }
110
119
  }
@@ -16,6 +16,7 @@ export interface Transport {
16
16
  }
17
17
  export declare class WSTransport implements Transport {
18
18
  private ws;
19
+ private connecting;
19
20
  private connected;
20
21
  private closed;
21
22
  private backoff;
package/dist/transport.js CHANGED
@@ -1,11 +1,50 @@
1
- import WebSocket from "ws";
2
1
  import { getConfig } from "./config.js";
3
2
  import { classifyServerSignal } from "./errors.js";
4
3
  // Client identifier sent on every request so the backend can attribute SDK usage/version.
5
4
  // Keep in sync with package.json on release.
6
- const CLIENT_ID = "typescript-sdk/0.13.2";
5
+ const CLIENT_ID = "typescript-sdk/0.15.0";
6
+ // ─── Runtime-agnostic WebSocket ──────────────────────────────────────────────
7
+ // Prefer the global Web `WebSocket` (Node 20+, Bun, Deno, browsers, and every edge runtime); fall
8
+ // back to the OPTIONAL `ws` package only on older Node that lacks a global. Both expose the standard
9
+ // `addEventListener`/`send`/`close`/`readyState`/`bufferedAmount` surface used here, so the transport
10
+ // is identical across runtimes. On edge `ws` is simply absent — we use the global, or (if WS can't
11
+ // connect) the HTTP transport via `fetch`.
12
+ const WS_OPEN = 1; // CONNECTING=0, OPEN=1, CLOSING=2, CLOSED=3 (web standard; `ws` matches)
13
+ let _wsCtor = null;
14
+ function resolveWebSocketCtor() {
15
+ if (_wsCtor)
16
+ return _wsCtor;
17
+ const globalWS = globalThis.WebSocket;
18
+ if (typeof globalWS === "function") {
19
+ _wsCtor = Promise.resolve(globalWS);
20
+ return _wsCtor;
21
+ }
22
+ // No global WebSocket (older Node) → try the optional `ws` package. The variable specifier +
23
+ // ignore comments stop edge bundlers from statically resolving a dep that isn't installed there;
24
+ // this branch is never reached on runtimes that ship a global WebSocket.
25
+ const pkg = ["w", "s"].join("");
26
+ _wsCtor = import(/* webpackIgnore: true */ /* @vite-ignore */ pkg)
27
+ .then((m) => {
28
+ const mod = m;
29
+ return (mod.default ?? mod);
30
+ })
31
+ .catch(() => null); // `ws` not installed (e.g. edge build) → caller falls back to HTTP
32
+ return _wsCtor;
33
+ }
34
+ // Normalize a WS message payload to a string across runtimes: the global WebSocket delivers text
35
+ // frames as a string; `ws` delivers a Buffer; ArrayBuffer/typed-array are handled defensively.
36
+ function wsDataToString(data) {
37
+ if (typeof data === "string")
38
+ return data;
39
+ if (data instanceof ArrayBuffer)
40
+ return new TextDecoder().decode(data);
41
+ if (ArrayBuffer.isView(data))
42
+ return new TextDecoder().decode(data);
43
+ return String(data?.toString?.() ?? data);
44
+ }
7
45
  export class WSTransport {
8
46
  ws = null;
47
+ connecting = false;
9
48
  connected = false;
10
49
  closed = false;
11
50
  backoff = 1000;
@@ -24,79 +63,94 @@ export class WSTransport {
24
63
  /** Whether a trace lost events to a buffer drop (so the API/replay can refuse byte-replay). */
25
64
  isTraceLossy(traceId) { return this.lossyTraces.has(traceId); }
26
65
  connect() {
27
- if (this.closed)
66
+ if (this.closed || this.ws || this.connecting)
28
67
  return;
68
+ this.connecting = true;
29
69
  const cfg = getConfig();
30
70
  const url = `${cfg.wsUrl}/ws/v1/stream`;
31
- this.ws = new WebSocket(url);
32
- this.ws.on("open", () => {
33
- // Unref the underlying socket so a short-lived script (the common SDK usage) can exit
34
- // once its work is done instead of hanging on an open WebSocket. Graceful shutdown
35
- // still drains via flush()/beforeExit.
36
- this.ws?._socket?.unref?.();
37
- this.ws.send(JSON.stringify({ type: "auth", api_key: cfg.apiKey }));
38
- });
39
- this.ws.on("message", (raw) => {
71
+ void resolveWebSocketCtor().then((Ctor) => {
72
+ this.connecting = false;
73
+ // No WebSocket implementation (edge without a global, or `ws` not installed), or we were
74
+ // closed/connected while resolving bail. In "auto" mode the fallback timer switches to HTTP.
75
+ if (!Ctor || this.closed || this.ws)
76
+ return;
77
+ let socket;
40
78
  try {
41
- const msg = JSON.parse(raw.toString());
42
- if (msg.type === "auth_ok") {
43
- this.connected = true;
44
- this.backoff = 1000;
45
- this.flushQueue();
46
- }
47
- else if (msg.type === "ping") {
48
- this.ws?.send(JSON.stringify({ type: "pong" }));
49
- }
50
- else if (msg.type === "error") {
51
- this.surfaceSignal(classifyServerSignal("error", msg.error));
52
- }
53
- else if (msg.type === "resume") {
54
- import("./resume.js").then(({ parseResumeMessage, handleResume }) => {
55
- const cmd = parseResumeMessage(msg);
56
- if (cmd)
57
- handleResume(cmd);
58
- });
59
- }
60
- else if (msg.type === "replay") {
61
- import("./replay.js").then(({ parseReplayMessage, handleReplay }) => {
62
- const cmd = parseReplayMessage(msg);
63
- if (cmd)
64
- handleReplay(cmd);
65
- });
79
+ socket = new Ctor(url);
80
+ }
81
+ catch {
82
+ return; // construction unsupported on this runtime → leave disconnected (HTTP fallback)
83
+ }
84
+ this.ws = socket;
85
+ socket.addEventListener("open", () => {
86
+ // Unref the underlying Node socket so a short-lived script can exit once its work is done
87
+ // (no-op on runtimes without `_socket`, e.g. the global WebSocket). Graceful shutdown still
88
+ // drains via flush()/beforeExit.
89
+ socket?._socket?.unref?.();
90
+ socket.send(JSON.stringify({ type: "auth", api_key: getConfig().apiKey }));
91
+ });
92
+ socket.addEventListener("message", (ev) => {
93
+ try {
94
+ const msg = JSON.parse(wsDataToString(ev.data));
95
+ if (msg.type === "auth_ok") {
96
+ this.connected = true;
97
+ this.backoff = 1000;
98
+ this.flushQueue();
99
+ }
100
+ else if (msg.type === "ping") {
101
+ socket.send(JSON.stringify({ type: "pong" }));
102
+ }
103
+ else if (msg.type === "error") {
104
+ this.surfaceSignal(classifyServerSignal("error", msg.error));
105
+ }
106
+ else if (msg.type === "resume") {
107
+ import("./resume.js").then(({ parseResumeMessage, handleResume }) => {
108
+ const cmd = parseResumeMessage(msg);
109
+ if (cmd)
110
+ handleResume(cmd);
111
+ });
112
+ }
113
+ else if (msg.type === "replay") {
114
+ import("./replay.js").then(({ parseReplayMessage, handleReplay }) => {
115
+ const cmd = parseReplayMessage(msg);
116
+ if (cmd)
117
+ handleReplay(cmd);
118
+ });
119
+ }
120
+ else if (msg.type === "halt") {
121
+ const reason = msg.data?.reason || "Guardrail triggered";
122
+ const signal = classifyServerSignal("halt", reason);
123
+ // halt CHANGES behavior, deliberately: flush what's already recorded, then STOP recording
124
+ // (close the transport). This is a hard server directive, distinct from credits_exhausted
125
+ // (which leaves recording alive so the user can swap keys / upgrade mid-run). Surfaced as a
126
+ // fatal signal — never a silent dark-out.
127
+ this.surfaceSignal(signal);
128
+ void this.flush().finally(() => this.close());
129
+ }
130
+ else {
131
+ // DEFAULT: an unhandled/unknown server message type must not be silently swallowed (the
132
+ // F-P6 class). Throttled-warn so a future message type surfaces instead of vanishing.
133
+ this.warnUnknownType(msg.type);
134
+ }
66
135
  }
67
- else if (msg.type === "halt") {
68
- const reason = msg.data?.reason || "Guardrail triggered";
69
- const signal = classifyServerSignal("halt", reason);
70
- // halt CHANGES behavior, deliberately: flush what's already recorded, then STOP recording
71
- // (close the transport). This is a hard server directive, distinct from credits_exhausted
72
- // (which leaves recording alive so the user can swap keys / upgrade mid-run). Surfaced as a
73
- // fatal signal — never a silent dark-out.
74
- this.surfaceSignal(signal);
75
- void this.flush().finally(() => this.close());
136
+ catch (e) {
137
+ // A malformed/unparseable frame must not take down the listener.
138
+ this.throttledSignalWarn("parse", `[retrace] dropped an unparseable server frame: ${e?.message ?? e}`);
76
139
  }
77
- else {
78
- // DEFAULT: an unhandled/unknown server message type must not be silently swallowed (the
79
- // F-P6 class). Throttled-warn so a future message type surfaces instead of vanishing.
80
- this.warnUnknownType(msg.type);
140
+ });
141
+ socket.addEventListener("close", () => {
142
+ this.connected = false;
143
+ this.ws = null;
144
+ if (!this.closed) {
145
+ this.reconnectTimer = setTimeout(() => this.reconnect(), this.backoff * (0.5 + Math.random() * 0.5));
146
+ // Don't let the reconnect timer keep the event loop (and the process) alive.
147
+ this.reconnectTimer?.unref?.();
148
+ this.backoff = Math.min(this.backoff * 2, 30000);
81
149
  }
82
- }
83
- catch (e) {
84
- // A malformed/unparseable frame must not take down the listener.
85
- this.throttledSignalWarn("parse", `[retrace] dropped an unparseable server frame: ${e?.message ?? e}`);
86
- }
87
- });
88
- this.ws.on("close", () => {
89
- this.connected = false;
90
- this.ws = null;
91
- if (!this.closed) {
92
- this.reconnectTimer = setTimeout(() => this.reconnect(), this.backoff * (0.5 + Math.random() * 0.5));
93
- // Don't let the reconnect timer keep the event loop (and the process) alive.
94
- this.reconnectTimer?.unref?.();
95
- this.backoff = Math.min(this.backoff * 2, 30000);
96
- }
97
- });
98
- this.ws.on("error", () => {
99
- this.ws?.close();
150
+ });
151
+ socket.addEventListener("error", () => {
152
+ socket.close();
153
+ });
100
154
  });
101
155
  }
102
156
  reconnect() {
@@ -173,7 +227,7 @@ export class WSTransport {
173
227
  }
174
228
  /** Whether there is anything worth flushing on exit. */
175
229
  hasPendingData() {
176
- return this.queue.length > 0 || (this.ws?.readyState === WebSocket.OPEN && this.ws.bufferedAmount > 0);
230
+ return this.queue.length > 0 || (this.ws?.readyState === WS_OPEN && this.ws.bufferedAmount > 0);
177
231
  }
178
232
  send(eventType, data) {
179
233
  // Unconfigured (no API key): never buffer and never reach the network. An imported-but-unused
@@ -188,7 +242,7 @@ export class WSTransport {
188
242
  return;
189
243
  }
190
244
  const item = { eventType, data, traceId, spanId };
191
- if (this.connected && this.ws?.readyState === WebSocket.OPEN) {
245
+ if (this.connected && this.ws?.readyState === WS_OPEN) {
192
246
  this.transmit(item);
193
247
  }
194
248
  else {
@@ -216,7 +270,7 @@ export class WSTransport {
216
270
  * the process before exit. Best-effort with a hard timeout. */
217
271
  async flush() {
218
272
  const start = Date.now();
219
- while (this.ws && this.ws.readyState === WebSocket.OPEN && this.ws.bufferedAmount > 0 && Date.now() - start < 2000) {
273
+ while (this.ws && this.ws.readyState === WS_OPEN && this.ws.bufferedAmount > 0 && Date.now() - start < 2000) {
220
274
  await new Promise((r) => setTimeout(r, 50));
221
275
  }
222
276
  }
@@ -502,6 +556,9 @@ export function onProcessExit(fn) {
502
556
  * listenerCount gate here; Python via a SIG_DFL/main-thread gate) — that parity is real.
503
557
  */
504
558
  export function registerProcessExitFlush(transport, budgetMs = 1500) {
559
+ const proc = globalThis.process;
560
+ if (!proc || typeof proc.on !== "function")
561
+ return;
505
562
  const drain = async (reason) => {
506
563
  for (const h of preExitHooks) {
507
564
  try {
@@ -520,20 +577,20 @@ export function registerProcessExitFlush(transport, budgetMs = 1500) {
520
577
  await transport.flush();
521
578
  };
522
579
  let draining = false;
523
- process.on("beforeExit", () => {
580
+ proc.on("beforeExit", () => {
524
581
  if (draining)
525
582
  return;
526
583
  draining = true;
527
584
  void drain("graceful").finally(() => { draining = false; });
528
585
  });
529
586
  for (const sig of ["SIGTERM", "SIGINT"]) {
530
- const userOwnsExit = process.listenerCount(sig) > 0; // captured BEFORE we register ours
531
- process.on(sig, () => {
587
+ const userOwnsExit = proc.listenerCount(sig) > 0; // captured BEFORE we register ours
588
+ proc.on(sig, () => {
532
589
  drain("signal").finally(() => {
533
590
  // Sole listener: we suppressed the default terminate, so we must exit or the process hangs.
534
591
  // User has their own handler: they own exit — never call it for them.
535
592
  if (!userOwnsExit)
536
- process.exit(sig === "SIGINT" ? 130 : 143);
593
+ proc.exit(sig === "SIGINT" ? 130 : 143);
537
594
  });
538
595
  });
539
596
  }
package/dist/utils.js CHANGED
@@ -1,7 +1,12 @@
1
- import { randomUUID } from "crypto";
1
+ // Runtime-agnostic UUID + UTF-8 byte helpers. Web Crypto (globalThis.crypto) and TextEncoder/
2
+ // TextDecoder are available on Node 20+, Bun, Deno, browsers, and every edge runtime — so we avoid
3
+ // a hard `node:crypto` import and the Node-only `Buffer`, both of which break module evaluation on
4
+ // edge runtimes (Cloudflare Workers, Vercel Edge, Supabase Edge).
2
5
  export function genId() {
3
- return randomUUID();
6
+ return globalThis.crypto.randomUUID();
4
7
  }
8
+ const _textEncoder = new TextEncoder();
9
+ const _textDecoder = new TextDecoder();
5
10
  export function nowIso() {
6
11
  return new Date().toISOString();
7
12
  }
@@ -32,9 +37,10 @@ export function shouldSample(rate, seed, key) {
32
37
  export function truncateJson(obj, maxBytes = 10240) {
33
38
  try {
34
39
  const s = JSON.stringify(obj);
35
- if (Buffer.byteLength(s) <= maxBytes)
40
+ const bytes = _textEncoder.encode(s);
41
+ if (bytes.length <= maxBytes)
36
42
  return obj;
37
- return JSON.parse(Buffer.from(s).subarray(0, maxBytes).toString());
43
+ return JSON.parse(_textDecoder.decode(bytes.subarray(0, maxBytes)));
38
44
  }
39
45
  catch {
40
46
  return String(obj).slice(0, maxBytes);
@@ -44,7 +50,7 @@ export function truncateJson(obj, maxBytes = 10240) {
44
50
  * so the server refuses to byte-replay it (the replayed value would differ from the original). */
45
51
  export function wasTruncated(obj, maxBytes = 10240) {
46
52
  try {
47
- return Buffer.byteLength(JSON.stringify(obj)) > maxBytes;
53
+ return _textEncoder.encode(JSON.stringify(obj)).length > maxBytes;
48
54
  }
49
55
  catch {
50
56
  return String(obj).length > maxBytes;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "retrace-sdk",
3
- "version": "0.13.3",
3
+ "version": "0.15.0",
4
4
  "description": "The execution replay engine for AI agents. Record, replay, fork, and share agent executions.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -53,7 +53,7 @@
53
53
  "test": "vitest run",
54
54
  "prepublishOnly": "npm run build"
55
55
  },
56
- "dependencies": {
56
+ "optionalDependencies": {
57
57
  "ws": "^8.20.1"
58
58
  },
59
59
  "peerDependencies": {