retrace-sdk 0.1.5 → 0.1.6

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.
@@ -5,8 +5,10 @@ export interface Transport {
5
5
  export declare class WSTransport implements Transport {
6
6
  private ws;
7
7
  private connected;
8
+ private closed;
8
9
  private backoff;
9
10
  private queue;
11
+ get isConnected(): boolean;
10
12
  connect(): void;
11
13
  private reconnect;
12
14
  private flushQueue;
package/dist/transport.js CHANGED
@@ -3,9 +3,13 @@ import { getConfig } from "./config.js";
3
3
  export class WSTransport {
4
4
  ws = null;
5
5
  connected = false;
6
+ closed = false;
6
7
  backoff = 1000;
7
8
  queue = [];
9
+ get isConnected() { return this.connected; }
8
10
  connect() {
11
+ if (this.closed)
12
+ return;
9
13
  const cfg = getConfig();
10
14
  const url = `${cfg.wsUrl}/ws/v1/stream`;
11
15
  this.ws = new WebSocket(url);
@@ -26,15 +30,17 @@ export class WSTransport {
26
30
  this.ws.on("close", () => {
27
31
  this.connected = false;
28
32
  this.ws = null;
29
- setTimeout(() => this.reconnect(), this.backoff);
30
- this.backoff = Math.min(this.backoff * 2, 30000);
33
+ if (!this.closed) {
34
+ setTimeout(() => this.reconnect(), this.backoff);
35
+ this.backoff = Math.min(this.backoff * 2, 30000);
36
+ }
31
37
  });
32
38
  this.ws.on("error", () => {
33
39
  this.ws?.close();
34
40
  });
35
41
  }
36
42
  reconnect() {
37
- if (!this.connected && !this.ws)
43
+ if (!this.closed && !this.connected && !this.ws)
38
44
  this.connect();
39
45
  }
40
46
  flushQueue() {
@@ -49,11 +55,12 @@ export class WSTransport {
49
55
  }
50
56
  else {
51
57
  this.queue.push(msg);
52
- if (!this.ws)
58
+ if (!this.ws && !this.closed)
53
59
  this.connect();
54
60
  }
55
61
  }
56
62
  close() {
63
+ this.closed = true;
57
64
  if (this.ws) {
58
65
  this.ws.close();
59
66
  this.ws = null;
@@ -114,11 +121,61 @@ export function createTransport(mode = "auto") {
114
121
  return new HTTPTransport();
115
122
  if (mode === "ws")
116
123
  return new WSTransport();
117
- // Auto: try WS
118
- try {
119
- return new WSTransport();
120
- }
121
- catch {
122
- return new HTTPTransport();
123
- }
124
+ // Auto: start with WS, fall back to HTTP if connection fails within timeout
125
+ const ws = new WSTransport();
126
+ const http = new HTTPTransport();
127
+ let useWs = true;
128
+ let decided = false;
129
+ const buffer = [];
130
+ const fallbackTimer = setTimeout(() => {
131
+ if (!decided && !ws.isConnected) {
132
+ decided = true;
133
+ useWs = false;
134
+ ws.close();
135
+ for (const item of buffer.splice(0))
136
+ http.send(item.eventType, item.data);
137
+ }
138
+ }, 5000);
139
+ ws.connect();
140
+ const originalSend = ws.send.bind(ws);
141
+ const checkConnected = () => {
142
+ if (!decided && ws.isConnected) {
143
+ decided = true;
144
+ clearTimeout(fallbackTimer);
145
+ for (const item of buffer.splice(0))
146
+ originalSend(item.eventType, item.data);
147
+ }
148
+ };
149
+ return {
150
+ send(eventType, data) {
151
+ checkConnected();
152
+ if (decided) {
153
+ if (useWs)
154
+ originalSend(eventType, data);
155
+ else
156
+ http.send(eventType, data);
157
+ }
158
+ else {
159
+ buffer.push({ eventType, data });
160
+ }
161
+ },
162
+ close() {
163
+ clearTimeout(fallbackTimer);
164
+ if (!decided) {
165
+ // WS not yet connected — force HTTP fallback to avoid data loss
166
+ decided = true;
167
+ useWs = false;
168
+ ws.close();
169
+ for (const item of buffer.splice(0))
170
+ http.send(item.eventType, item.data);
171
+ http.close();
172
+ }
173
+ else if (useWs) {
174
+ ws.close();
175
+ }
176
+ else {
177
+ http.close();
178
+ }
179
+ },
180
+ };
124
181
  }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,67 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
+ import { configure } from "./config.js";
3
+ // Mock WebSocket to never connect (simulates unreachable WS endpoint)
4
+ const mockWsInstances = [];
5
+ vi.mock("ws", () => {
6
+ const MockWebSocket = function () {
7
+ this.readyState = 0;
8
+ this._listeners = {};
9
+ this.on = (event, cb) => { (this._listeners[event] ??= []).push(cb); return this; };
10
+ this.send = vi.fn();
11
+ this.close = vi.fn(() => {
12
+ this.readyState = 3;
13
+ for (const cb of (this._listeners["close"] ?? []))
14
+ cb();
15
+ });
16
+ mockWsInstances.push(this);
17
+ };
18
+ MockWebSocket.OPEN = 1;
19
+ return { default: MockWebSocket };
20
+ });
21
+ beforeEach(() => {
22
+ configure({ apiKey: "rt_live_test", baseUrl: "http://localhost:3001", enabled: true });
23
+ mockWsInstances.length = 0;
24
+ });
25
+ afterEach(() => {
26
+ vi.unstubAllGlobals();
27
+ });
28
+ describe("Auto transport fallback on early close()", () => {
29
+ it("flushes to HTTP when close() is called before WS connects", async () => {
30
+ const mockFetch = vi.fn().mockResolvedValue({ ok: true });
31
+ vi.stubGlobal("fetch", mockFetch);
32
+ const { createTransport } = await import("./transport.js");
33
+ const transport = createTransport("auto");
34
+ // Send trace events immediately (before WS could connect)
35
+ transport.send("trace_started", { id: "t1", name: "fast-trace", status: "running", started_at: "2026-01-01T00:00:00Z", total_tokens: 0, total_cost: 0, total_duration_ms: 0 });
36
+ transport.send("span_started", { id: "s1", trace_id: "t1", span_type: "llm_call", name: "call", started_at: "2026-01-01T00:00:00Z" });
37
+ transport.send("span_ended", { id: "s1", ended_at: "2026-01-01T00:00:01Z", output: "response" });
38
+ transport.send("trace_ended", { id: "t1", ended_at: "2026-01-01T00:00:01Z", status: "completed" });
39
+ // Close immediately — WS has NOT connected yet
40
+ transport.close();
41
+ // Should have fallen back to HTTP and flushed
42
+ expect(mockFetch).toHaveBeenCalledTimes(1);
43
+ const [url, opts] = mockFetch.mock.calls[0];
44
+ expect(url).toBe("http://localhost:3001/api/v1/traces");
45
+ const body = JSON.parse(opts.body);
46
+ expect(body.id).toBe("t1");
47
+ expect(body.name).toBe("fast-trace");
48
+ expect(body.spans).toHaveLength(1);
49
+ expect(body.spans[0].output).toBe("response");
50
+ });
51
+ it("does NOT reconnect after intentional close()", async () => {
52
+ vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: true }));
53
+ const { WSTransport } = await import("./transport.js");
54
+ const ws = new WSTransport();
55
+ ws.connect();
56
+ // Should have created a WS instance
57
+ expect(mockWsInstances.length).toBeGreaterThan(0);
58
+ const instanceCount = mockWsInstances.length;
59
+ // Close intentionally
60
+ ws.close();
61
+ // Trigger the "close" event that was registered — already fired by our mock's close()
62
+ // Wait a tick for any setTimeout reconnect to fire
63
+ await new Promise(r => setTimeout(r, 1100));
64
+ // Should NOT have created new WS instances (no reconnect)
65
+ expect(mockWsInstances.length).toBe(instanceCount);
66
+ });
67
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "retrace-sdk",
3
- "version": "0.1.5",
3
+ "version": "0.1.6",
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",
@@ -0,0 +1,80 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
+ import { configure } from "./config.js";
3
+
4
+ // Mock WebSocket to never connect (simulates unreachable WS endpoint)
5
+ const mockWsInstances: Array<Record<string, unknown>> = [];
6
+ vi.mock("ws", () => {
7
+ const MockWebSocket = function (this: Record<string, unknown>) {
8
+ this.readyState = 0;
9
+ this._listeners = {} as Record<string, Array<() => void>>;
10
+ this.on = (event: string, cb: () => void) => { ((this._listeners as Record<string, Array<() => void>>)[event] ??= []).push(cb); return this; };
11
+ this.send = vi.fn();
12
+ this.close = vi.fn(() => {
13
+ this.readyState = 3;
14
+ for (const cb of ((this._listeners as Record<string, Array<() => void>>)["close"] ?? [])) cb();
15
+ });
16
+ mockWsInstances.push(this);
17
+ } as unknown as { new(): Record<string, unknown>; OPEN: number };
18
+ MockWebSocket.OPEN = 1;
19
+ return { default: MockWebSocket };
20
+ });
21
+
22
+ beforeEach(() => {
23
+ configure({ apiKey: "rt_live_test", baseUrl: "http://localhost:3001", enabled: true });
24
+ mockWsInstances.length = 0;
25
+ });
26
+
27
+ afterEach(() => {
28
+ vi.unstubAllGlobals();
29
+ });
30
+
31
+ describe("Auto transport fallback on early close()", () => {
32
+ it("flushes to HTTP when close() is called before WS connects", async () => {
33
+ const mockFetch = vi.fn().mockResolvedValue({ ok: true });
34
+ vi.stubGlobal("fetch", mockFetch);
35
+
36
+ const { createTransport } = await import("./transport.js");
37
+ const transport = createTransport("auto");
38
+
39
+ // Send trace events immediately (before WS could connect)
40
+ transport.send("trace_started", { id: "t1", name: "fast-trace", status: "running", started_at: "2026-01-01T00:00:00Z", total_tokens: 0, total_cost: 0, total_duration_ms: 0 });
41
+ transport.send("span_started", { id: "s1", trace_id: "t1", span_type: "llm_call", name: "call", started_at: "2026-01-01T00:00:00Z" });
42
+ transport.send("span_ended", { id: "s1", ended_at: "2026-01-01T00:00:01Z", output: "response" });
43
+ transport.send("trace_ended", { id: "t1", ended_at: "2026-01-01T00:00:01Z", status: "completed" });
44
+
45
+ // Close immediately — WS has NOT connected yet
46
+ transport.close();
47
+
48
+ // Should have fallen back to HTTP and flushed
49
+ expect(mockFetch).toHaveBeenCalledTimes(1);
50
+ const [url, opts] = mockFetch.mock.calls[0];
51
+ expect(url).toBe("http://localhost:3001/api/v1/traces");
52
+ const body = JSON.parse(opts.body);
53
+ expect(body.id).toBe("t1");
54
+ expect(body.name).toBe("fast-trace");
55
+ expect(body.spans).toHaveLength(1);
56
+ expect(body.spans[0].output).toBe("response");
57
+ });
58
+
59
+ it("does NOT reconnect after intentional close()", async () => {
60
+ vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: true }));
61
+
62
+ const { WSTransport } = await import("./transport.js");
63
+ const ws = new WSTransport();
64
+ ws.connect();
65
+
66
+ // Should have created a WS instance
67
+ expect(mockWsInstances.length).toBeGreaterThan(0);
68
+ const instanceCount = mockWsInstances.length;
69
+
70
+ // Close intentionally
71
+ ws.close();
72
+
73
+ // Trigger the "close" event that was registered — already fired by our mock's close()
74
+ // Wait a tick for any setTimeout reconnect to fire
75
+ await new Promise(r => setTimeout(r, 1100));
76
+
77
+ // Should NOT have created new WS instances (no reconnect)
78
+ expect(mockWsInstances.length).toBe(instanceCount);
79
+ });
80
+ });
package/src/transport.ts CHANGED
@@ -9,10 +9,14 @@ export interface Transport {
9
9
  export class WSTransport implements Transport {
10
10
  private ws: WebSocket | null = null;
11
11
  private connected = false;
12
+ private closed = false;
12
13
  private backoff = 1000;
13
14
  private queue: string[] = [];
14
15
 
16
+ get isConnected() { return this.connected; }
17
+
15
18
  connect() {
19
+ if (this.closed) return;
16
20
  const cfg = getConfig();
17
21
  const url = `${cfg.wsUrl}/ws/v1/stream`;
18
22
  this.ws = new WebSocket(url);
@@ -35,8 +39,10 @@ export class WSTransport implements Transport {
35
39
  this.ws.on("close", () => {
36
40
  this.connected = false;
37
41
  this.ws = null;
38
- setTimeout(() => this.reconnect(), this.backoff);
39
- this.backoff = Math.min(this.backoff * 2, 30000);
42
+ if (!this.closed) {
43
+ setTimeout(() => this.reconnect(), this.backoff);
44
+ this.backoff = Math.min(this.backoff * 2, 30000);
45
+ }
40
46
  });
41
47
 
42
48
  this.ws.on("error", () => {
@@ -45,7 +51,7 @@ export class WSTransport implements Transport {
45
51
  }
46
52
 
47
53
  private reconnect() {
48
- if (!this.connected && !this.ws) this.connect();
54
+ if (!this.closed && !this.connected && !this.ws) this.connect();
49
55
  }
50
56
 
51
57
  private flushQueue() {
@@ -60,11 +66,12 @@ export class WSTransport implements Transport {
60
66
  this.ws.send(msg);
61
67
  } else {
62
68
  this.queue.push(msg);
63
- if (!this.ws) this.connect();
69
+ if (!this.ws && !this.closed) this.connect();
64
70
  }
65
71
  }
66
72
 
67
73
  close() {
74
+ this.closed = true;
68
75
  if (this.ws) {
69
76
  this.ws.close();
70
77
  this.ws = null;
@@ -124,10 +131,58 @@ export class HTTPTransport implements Transport {
124
131
  export function createTransport(mode: "ws" | "http" | "auto" = "auto"): Transport {
125
132
  if (mode === "http") return new HTTPTransport();
126
133
  if (mode === "ws") return new WSTransport();
127
- // Auto: try WS
128
- try {
129
- return new WSTransport();
130
- } catch {
131
- return new HTTPTransport();
132
- }
134
+
135
+ // Auto: start with WS, fall back to HTTP if connection fails within timeout
136
+ const ws = new WSTransport();
137
+ const http = new HTTPTransport();
138
+ let useWs = true;
139
+ let decided = false;
140
+ const buffer: Array<{ eventType: string; data: Record<string, unknown> }> = [];
141
+
142
+ const fallbackTimer = setTimeout(() => {
143
+ if (!decided && !ws.isConnected) {
144
+ decided = true;
145
+ useWs = false;
146
+ ws.close();
147
+ for (const item of buffer.splice(0)) http.send(item.eventType, item.data);
148
+ }
149
+ }, 5000);
150
+
151
+ ws.connect();
152
+
153
+ const originalSend = ws.send.bind(ws);
154
+ const checkConnected = () => {
155
+ if (!decided && ws.isConnected) {
156
+ decided = true;
157
+ clearTimeout(fallbackTimer);
158
+ for (const item of buffer.splice(0)) originalSend(item.eventType, item.data);
159
+ }
160
+ };
161
+
162
+ return {
163
+ send(eventType: string, data: Record<string, unknown>) {
164
+ checkConnected();
165
+ if (decided) {
166
+ if (useWs) originalSend(eventType, data);
167
+ else http.send(eventType, data);
168
+ } else {
169
+ buffer.push({ eventType, data });
170
+ }
171
+ },
172
+ close() {
173
+ clearTimeout(fallbackTimer);
174
+ if (!decided) {
175
+ // WS not yet connected — force HTTP fallback to avoid data loss
176
+ decided = true;
177
+ useWs = false;
178
+ ws.close();
179
+ for (const item of buffer.splice(0)) http.send(item.eventType, item.data);
180
+ http.close();
181
+ } else if (useWs) {
182
+ ws.close();
183
+ } else {
184
+ http.close();
185
+ }
186
+ },
187
+ };
133
188
  }