@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/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.state = "connecting";
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, authError.message);
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
- this.rejectAuthWaiter(new Error(`WebSocket closed: ${event.code} ${event.reason || ""}`.trim()));
148
- const willReconnect = !this.manuallyClosed && this.autoReconnect;
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(new Error(`WebSocket closed: ${event.code} ${event.reason || ""}`.trim()));
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({ type: "auth", payload: { token } });
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 (!this.reconnectTimer)
380
- return;
381
- clearTimeout(this.reconnectTimer);
382
- this.reconnectTimer = null;
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 += 1;
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.reconnectTimer = setTimeout(() => resolve(), delay);
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.0",
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": "^1.0.0"
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
+ }