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.
- package/dist/transport.d.ts +2 -0
- package/dist/transport.js +68 -11
- package/dist/transport.test.d.ts +1 -0
- package/dist/transport.test.js +67 -0
- package/package.json +1 -1
- package/src/transport.test.ts +80 -0
- package/src/transport.ts +65 -10
package/dist/transport.d.ts
CHANGED
|
@@ -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
|
-
|
|
30
|
-
|
|
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:
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
@@ -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
|
-
|
|
39
|
-
|
|
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
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
}
|