@neta-art/cohub 1.1.0 → 1.2.1
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/README.md +52 -16
- package/dist/apis/invitations.d.ts +20 -0
- package/dist/apis/invitations.js +36 -0
- package/dist/apis/models.d.ts +4 -5
- package/dist/apis/models.js +5 -13
- package/dist/apis/spaces.d.ts +65 -4
- package/dist/apis/spaces.js +156 -8
- package/dist/client.d.ts +2 -0
- package/dist/client.js +35 -5
- package/dist/environment.d.ts +22 -0
- package/dist/environment.js +37 -0
- package/dist/http.d.ts +2 -0
- package/dist/http.js +8 -3
- package/dist/index.d.ts +5 -1
- package/dist/index.js +2 -0
- package/dist/realtime.js +3 -0
- package/dist/session-patch-reducer.d.ts +49 -0
- package/dist/session-patch-reducer.js +273 -0
- package/dist/transport.d.ts +2 -0
- package/dist/transport.js +2 -1
- package/dist/types.d.ts +70 -0
- package/dist/websocket.d.ts +18 -1
- package/dist/websocket.js +167 -43
- package/package.json +7 -7
package/dist/websocket.js
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
|
-
import { realtimeEnvelopeSchema, } from "@neta-art/cohub-protocol/realtime";
|
|
1
|
+
import { realtimeCompactFrameSchema, realtimeEnvelopeSchema, WS_COMPACT_STREAM_CAPABILITY, } from "@neta-art/cohub-protocol/realtime";
|
|
2
|
+
import { resolveWebsocketUrl } from "./environment.js";
|
|
2
3
|
const createEventMap = () => ({
|
|
4
|
+
connecting: new Set(),
|
|
5
|
+
reconnecting: new Set(),
|
|
3
6
|
open: new Set(),
|
|
4
7
|
close: new Set(),
|
|
5
8
|
error: new Set(),
|
|
@@ -10,24 +13,9 @@ const createEventMap = () => ({
|
|
|
10
13
|
serverError: new Set(),
|
|
11
14
|
pong: new Set(),
|
|
12
15
|
});
|
|
13
|
-
const toWebSocketUrl = (input) => {
|
|
14
|
-
const base = (input?.trim() || "").replace(/\/$/, "");
|
|
15
|
-
if (base) {
|
|
16
|
-
if (base.startsWith("ws://") || base.startsWith("wss://"))
|
|
17
|
-
return `${base}/ws`;
|
|
18
|
-
if (base.startsWith("http://"))
|
|
19
|
-
return `${base.replace(/^http:/, "ws:")}/ws`;
|
|
20
|
-
if (base.startsWith("https://"))
|
|
21
|
-
return `${base.replace(/^https:/, "wss:")}/ws`;
|
|
22
|
-
}
|
|
23
|
-
if (typeof window !== "undefined") {
|
|
24
|
-
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
|
25
|
-
return `${protocol}//${window.location.host}/ws`;
|
|
26
|
-
}
|
|
27
|
-
return "ws://localhost:8788/ws";
|
|
28
|
-
};
|
|
16
|
+
const toWebSocketUrl = (input, env) => resolveWebsocketUrl({ url: input, env });
|
|
29
17
|
const normalizeOptions = (options = {}) => ({
|
|
30
|
-
url: toWebSocketUrl(options.url),
|
|
18
|
+
url: toWebSocketUrl(options.url, options.env),
|
|
31
19
|
autoReconnect: options.autoReconnect !== false,
|
|
32
20
|
reconnectBaseDelayMs: options.reconnectBaseDelayMs ?? 1000,
|
|
33
21
|
reconnectMaxDelayMs: options.reconnectMaxDelayMs ?? 15000,
|
|
@@ -35,6 +23,37 @@ const normalizeOptions = (options = {}) => ({
|
|
|
35
23
|
pongTimeoutMs: options.pongTimeoutMs ?? 15000,
|
|
36
24
|
debug: options.debug === true,
|
|
37
25
|
});
|
|
26
|
+
const formatCloseMessage = (code, reason) => `WebSocket closed: ${code ?? 0} ${reason || ""}`.trim();
|
|
27
|
+
const isRetryableCloseCode = (code) => {
|
|
28
|
+
if (code === 1000)
|
|
29
|
+
return false;
|
|
30
|
+
if (code === 4003)
|
|
31
|
+
return false;
|
|
32
|
+
return true;
|
|
33
|
+
};
|
|
34
|
+
const AUTH_CLOSE_REASON = "authentication failed";
|
|
35
|
+
const isRecord = (value) => Boolean(value && typeof value === "object" && !Array.isArray(value));
|
|
36
|
+
const compactFrameToPatchOperation = (frame) => {
|
|
37
|
+
if (frame.t === "d")
|
|
38
|
+
return { v: frame.v };
|
|
39
|
+
if (frame.o === "remove")
|
|
40
|
+
return { o: "remove", p: frame.p };
|
|
41
|
+
if (frame.o === "merge") {
|
|
42
|
+
return isRecord(frame.v) ? { o: "merge", p: frame.p, v: frame.v } : null;
|
|
43
|
+
}
|
|
44
|
+
if (!("v" in frame))
|
|
45
|
+
return null;
|
|
46
|
+
switch (frame.o) {
|
|
47
|
+
case "append":
|
|
48
|
+
return { o: "append", p: frame.p, v: frame.v };
|
|
49
|
+
case "replace":
|
|
50
|
+
return { o: "replace", p: frame.p, v: frame.v };
|
|
51
|
+
case "add":
|
|
52
|
+
return { o: "add", p: frame.p, v: frame.v };
|
|
53
|
+
default:
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
};
|
|
38
57
|
class WebsocketAuthError extends Error {
|
|
39
58
|
constructor(message) {
|
|
40
59
|
super(message);
|
|
@@ -54,6 +73,7 @@ export class WebsocketClient {
|
|
|
54
73
|
ws = null;
|
|
55
74
|
pingTimer = null;
|
|
56
75
|
reconnectTimer = null;
|
|
76
|
+
reconnectTimerResolver = null;
|
|
57
77
|
reconnectAttempt = 0;
|
|
58
78
|
manuallyClosed = false;
|
|
59
79
|
connectPromise = null;
|
|
@@ -61,6 +81,7 @@ export class WebsocketClient {
|
|
|
61
81
|
awaitingPong = false;
|
|
62
82
|
lastPingRequestId = null;
|
|
63
83
|
pongDeadlineAt = 0;
|
|
84
|
+
compactStreamContexts = new Map();
|
|
64
85
|
state = "idle";
|
|
65
86
|
connectionId = null;
|
|
66
87
|
listeners = createEventMap();
|
|
@@ -97,8 +118,11 @@ export class WebsocketClient {
|
|
|
97
118
|
return this.connectPromise;
|
|
98
119
|
if (this.state === "open" && this.ws?.readyState === WebSocket.OPEN)
|
|
99
120
|
return;
|
|
121
|
+
const isReconnect = this.reconnectAttempt > 0 || this.state === "reconnecting";
|
|
100
122
|
this.manuallyClosed = false;
|
|
101
|
-
this.
|
|
123
|
+
this.clearReconnectTimer();
|
|
124
|
+
this.state = isReconnect ? "reconnecting" : "connecting";
|
|
125
|
+
this.emit("connecting", { isReconnect, attempt: this.reconnectAttempt });
|
|
102
126
|
this.connectPromise = new Promise((resolve, reject) => {
|
|
103
127
|
const ws = new this.WebSocketImpl(this.url);
|
|
104
128
|
this.ws = ws;
|
|
@@ -119,7 +143,7 @@ export class WebsocketClient {
|
|
|
119
143
|
};
|
|
120
144
|
ws.onopen = async () => {
|
|
121
145
|
try {
|
|
122
|
-
this.log("connected", this.url);
|
|
146
|
+
this.log("connected", { url: this.url, isReconnect, attempt: this.reconnectAttempt });
|
|
123
147
|
this.startPingLoop();
|
|
124
148
|
await this.authenticate();
|
|
125
149
|
this.state = "open";
|
|
@@ -129,37 +153,36 @@ export class WebsocketClient {
|
|
|
129
153
|
}
|
|
130
154
|
catch (error) {
|
|
131
155
|
const authError = error instanceof Error ? error : new Error("authentication failed");
|
|
156
|
+
this.emit("error", { error: authError, recoverable: false });
|
|
132
157
|
rejectOnce(authError);
|
|
133
|
-
ws.close(4003,
|
|
158
|
+
ws.close(4003, AUTH_CLOSE_REASON);
|
|
134
159
|
}
|
|
135
160
|
};
|
|
136
161
|
ws.onmessage = (event) => {
|
|
137
162
|
this.handleMessage(event.data);
|
|
138
163
|
};
|
|
139
164
|
ws.onerror = (error) => {
|
|
140
|
-
this.emit("error", { error });
|
|
165
|
+
this.emit("error", { error, recoverable: !this.manuallyClosed });
|
|
141
166
|
};
|
|
142
167
|
ws.onclose = (event) => {
|
|
143
168
|
this.stopPingLoop();
|
|
144
|
-
const wasConnecting = this.state === "connecting";
|
|
169
|
+
const wasConnecting = this.state === "connecting" || this.state === "reconnecting";
|
|
145
170
|
this.state = "closed";
|
|
146
171
|
this.ws = null;
|
|
147
|
-
|
|
148
|
-
|
|
172
|
+
const closeError = new Error(formatCloseMessage(event.code, event.reason));
|
|
173
|
+
this.rejectAuthWaiter(closeError);
|
|
174
|
+
const willReconnect = !this.manuallyClosed && this.autoReconnect && isRetryableCloseCode(event.code);
|
|
175
|
+
this.log("closed", { code: event.code, reason: event.reason, willReconnect, wasConnecting });
|
|
149
176
|
this.emit("close", {
|
|
150
177
|
code: event.code,
|
|
151
178
|
reason: event.reason,
|
|
152
179
|
willReconnect,
|
|
153
180
|
});
|
|
154
181
|
if (wasConnecting) {
|
|
155
|
-
rejectOnce(
|
|
156
|
-
if (event.code === 4001 && willReconnect) {
|
|
157
|
-
void this.scheduleReconnect();
|
|
158
|
-
}
|
|
159
|
-
return;
|
|
182
|
+
rejectOnce(closeError);
|
|
160
183
|
}
|
|
161
184
|
if (willReconnect) {
|
|
162
|
-
void this.scheduleReconnect();
|
|
185
|
+
void this.scheduleReconnect(event.code, event.reason);
|
|
163
186
|
}
|
|
164
187
|
};
|
|
165
188
|
});
|
|
@@ -221,7 +244,10 @@ export class WebsocketClient {
|
|
|
221
244
|
if (!token)
|
|
222
245
|
throw new WebsocketAuthError("missing access token");
|
|
223
246
|
const waiter = this.createAuthWaiter();
|
|
224
|
-
this.send({
|
|
247
|
+
this.send({
|
|
248
|
+
type: "auth",
|
|
249
|
+
payload: { token, capabilities: [WS_COMPACT_STREAM_CAPABILITY] },
|
|
250
|
+
});
|
|
225
251
|
await waiter.promise;
|
|
226
252
|
}
|
|
227
253
|
createAuthWaiter() {
|
|
@@ -253,15 +279,21 @@ export class WebsocketClient {
|
|
|
253
279
|
parsed = typeof raw === "string" ? JSON.parse(raw) : JSON.parse(String(raw));
|
|
254
280
|
}
|
|
255
281
|
catch {
|
|
256
|
-
this.emit("error", { error: new Error("invalid websocket payload") });
|
|
282
|
+
this.emit("error", { error: new Error("invalid websocket payload"), recoverable: true });
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
const compactResult = realtimeCompactFrameSchema.safeParse(parsed);
|
|
286
|
+
if (compactResult.success) {
|
|
287
|
+
this.handleCompactFrame(compactResult.data);
|
|
257
288
|
return;
|
|
258
289
|
}
|
|
259
290
|
const result = realtimeEnvelopeSchema.safeParse(parsed);
|
|
260
291
|
if (!result.success) {
|
|
261
|
-
this.emit("error", { error: new Error("invalid realtime envelope") });
|
|
292
|
+
this.emit("error", { error: new Error("invalid realtime envelope"), recoverable: true });
|
|
262
293
|
return;
|
|
263
294
|
}
|
|
264
295
|
const envelope = result.data;
|
|
296
|
+
this.rememberCompactStreamContext(envelope);
|
|
265
297
|
switch (envelope.type) {
|
|
266
298
|
case "system.ready": {
|
|
267
299
|
const connectionId = typeof envelope.payload.connectionId === "string"
|
|
@@ -351,6 +383,79 @@ export class WebsocketClient {
|
|
|
351
383
|
}
|
|
352
384
|
}
|
|
353
385
|
}
|
|
386
|
+
rememberCompactStreamContext(envelope) {
|
|
387
|
+
if (envelope.type === "session.turn.patch") {
|
|
388
|
+
const payload = envelope.payload;
|
|
389
|
+
const turnId = typeof payload.turnId === "string" ? payload.turnId : null;
|
|
390
|
+
const messageId = typeof payload.messageId === "string" ? payload.messageId : null;
|
|
391
|
+
const realtimeMeta = payload._rt && typeof payload._rt === "object"
|
|
392
|
+
? payload._rt
|
|
393
|
+
: null;
|
|
394
|
+
const sid = typeof realtimeMeta?.sid === "string" && realtimeMeta.sid.trim()
|
|
395
|
+
? realtimeMeta.sid
|
|
396
|
+
: turnId ?? messageId;
|
|
397
|
+
if (!sid)
|
|
398
|
+
return;
|
|
399
|
+
this.compactStreamContexts.set(sid, {
|
|
400
|
+
spaceId: envelope.spaceId ?? null,
|
|
401
|
+
sessionId: envelope.sessionId ?? null,
|
|
402
|
+
turnId,
|
|
403
|
+
messageId,
|
|
404
|
+
anchorUserMessageId: typeof payload.anchorUserMessageId === "string"
|
|
405
|
+
? payload.anchorUserMessageId
|
|
406
|
+
: null,
|
|
407
|
+
});
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
410
|
+
if (envelope.type !== "session.message.persisted")
|
|
411
|
+
return;
|
|
412
|
+
const message = envelope.payload.message;
|
|
413
|
+
if (!message || typeof message !== "object")
|
|
414
|
+
return;
|
|
415
|
+
const meta = message.meta;
|
|
416
|
+
const turnId = typeof meta?.turnId === "string" ? meta.turnId : null;
|
|
417
|
+
if (!turnId)
|
|
418
|
+
return;
|
|
419
|
+
for (const [sid, context] of this.compactStreamContexts.entries()) {
|
|
420
|
+
if (context.turnId === turnId)
|
|
421
|
+
this.compactStreamContexts.delete(sid);
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
handleCompactFrame(frame) {
|
|
425
|
+
const context = this.compactStreamContexts.get(frame.sid);
|
|
426
|
+
if (!context?.sessionId) {
|
|
427
|
+
this.emit("error", {
|
|
428
|
+
error: new Error(`unknown compact stream: ${frame.sid}`),
|
|
429
|
+
recoverable: true,
|
|
430
|
+
});
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
433
|
+
const op = compactFrameToPatchOperation(frame);
|
|
434
|
+
if (!op) {
|
|
435
|
+
this.emit("error", {
|
|
436
|
+
error: new Error(`invalid compact stream frame: ${frame.sid}`),
|
|
437
|
+
recoverable: true,
|
|
438
|
+
});
|
|
439
|
+
return;
|
|
440
|
+
}
|
|
441
|
+
const envelope = {
|
|
442
|
+
id: `compact:${frame.sid}:${frame.s}`,
|
|
443
|
+
timestamp: Date.now(),
|
|
444
|
+
domain: "session",
|
|
445
|
+
type: "session.turn.patch",
|
|
446
|
+
spaceId: context.spaceId,
|
|
447
|
+
sessionId: context.sessionId,
|
|
448
|
+
payload: {
|
|
449
|
+
turnId: context.turnId,
|
|
450
|
+
messageId: context.messageId,
|
|
451
|
+
anchorUserMessageId: context.anchorUserMessageId,
|
|
452
|
+
seq: frame.s,
|
|
453
|
+
baseSeq: frame.b,
|
|
454
|
+
ops: [op],
|
|
455
|
+
},
|
|
456
|
+
};
|
|
457
|
+
this.emit("event", envelope);
|
|
458
|
+
}
|
|
354
459
|
startPingLoop() {
|
|
355
460
|
this.stopPingLoop();
|
|
356
461
|
this.pingTimer = setInterval(() => {
|
|
@@ -359,7 +464,7 @@ export class WebsocketClient {
|
|
|
359
464
|
if (this.awaitingPong &&
|
|
360
465
|
this.pongDeadlineAt > 0 &&
|
|
361
466
|
Date.now() > this.pongDeadlineAt) {
|
|
362
|
-
this.emit("error", { error: new Error("websocket pong timeout") });
|
|
467
|
+
this.emit("error", { error: new Error("websocket pong timeout"), recoverable: true });
|
|
363
468
|
this.ws.close(4002, "pong timeout");
|
|
364
469
|
return;
|
|
365
470
|
}
|
|
@@ -376,22 +481,41 @@ export class WebsocketClient {
|
|
|
376
481
|
this.pongDeadlineAt = 0;
|
|
377
482
|
}
|
|
378
483
|
clearReconnectTimer() {
|
|
379
|
-
if (
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
484
|
+
if (this.reconnectTimer) {
|
|
485
|
+
clearTimeout(this.reconnectTimer);
|
|
486
|
+
this.reconnectTimer = null;
|
|
487
|
+
}
|
|
488
|
+
if (this.reconnectTimerResolver) {
|
|
489
|
+
const resolve = this.reconnectTimerResolver;
|
|
490
|
+
this.reconnectTimerResolver = null;
|
|
491
|
+
resolve();
|
|
492
|
+
}
|
|
383
493
|
}
|
|
384
|
-
async scheduleReconnect() {
|
|
494
|
+
async scheduleReconnect(code, reason) {
|
|
385
495
|
this.clearReconnectTimer();
|
|
496
|
+
const attempt = this.reconnectAttempt + 1;
|
|
386
497
|
const delay = Math.min(this.reconnectBaseDelayMs * 2 ** this.reconnectAttempt, this.reconnectMaxDelayMs);
|
|
387
|
-
this.reconnectAttempt
|
|
498
|
+
this.reconnectAttempt = attempt;
|
|
499
|
+
this.state = "reconnecting";
|
|
500
|
+
this.log("schedule reconnect", { attempt, delay, code, reason });
|
|
501
|
+
this.emit("reconnecting", {
|
|
502
|
+
attempt,
|
|
503
|
+
delayMs: delay,
|
|
504
|
+
code,
|
|
505
|
+
reason,
|
|
506
|
+
});
|
|
388
507
|
await new Promise((resolve) => {
|
|
389
|
-
this.
|
|
508
|
+
this.reconnectTimerResolver = resolve;
|
|
509
|
+
this.reconnectTimer = setTimeout(() => {
|
|
510
|
+
this.reconnectTimer = null;
|
|
511
|
+
this.reconnectTimerResolver = null;
|
|
512
|
+
resolve();
|
|
513
|
+
}, delay);
|
|
390
514
|
});
|
|
391
515
|
if (this.manuallyClosed)
|
|
392
516
|
return;
|
|
393
517
|
await this.connect().catch((error) => {
|
|
394
|
-
this.emit("error", { error });
|
|
518
|
+
this.emit("error", { error, recoverable: true });
|
|
395
519
|
});
|
|
396
520
|
}
|
|
397
521
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@neta-art/cohub",
|
|
3
|
-
"version": "1.1
|
|
3
|
+
"version": "1.2.1",
|
|
4
4
|
"description": "Cohub SDK for spaces, sessions, checkpoints, and realtime agent collaboration.",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"private": false,
|
|
@@ -43,14 +43,14 @@
|
|
|
43
43
|
"dist",
|
|
44
44
|
"README.md"
|
|
45
45
|
],
|
|
46
|
-
"scripts": {
|
|
47
|
-
"build": "tsc -p tsconfig.build.json",
|
|
48
|
-
"typecheck": "tsc -p tsconfig.json --noEmit"
|
|
49
|
-
},
|
|
50
46
|
"dependencies": {
|
|
51
|
-
"@neta-art/cohub-protocol": "
|
|
47
|
+
"@neta-art/cohub-protocol": "1.2.1"
|
|
52
48
|
},
|
|
53
49
|
"devDependencies": {
|
|
54
50
|
"typescript": "^6.0.3"
|
|
51
|
+
},
|
|
52
|
+
"scripts": {
|
|
53
|
+
"build": "tsc -p tsconfig.build.json",
|
|
54
|
+
"typecheck": "tsc -p tsconfig.json --noEmit"
|
|
55
55
|
}
|
|
56
|
-
}
|
|
56
|
+
}
|