pingerchips-js 2.1.1 → 3.0.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.
Files changed (4) hide show
  1. package/chat.js +585 -0
  2. package/index.js +835 -43
  3. package/package.json +3 -2
  4. package/spaces.js +414 -0
package/index.js CHANGED
@@ -1,4 +1,28 @@
1
1
  import { Socket } from "phoenix";
2
+ import { decode, encode } from "@msgpack/msgpack";
3
+
4
+ function messagePackEncode(message, callback) {
5
+ callback(
6
+ encode([
7
+ message.join_ref,
8
+ message.ref,
9
+ message.topic,
10
+ message.event,
11
+ message.payload,
12
+ ]),
13
+ );
14
+ }
15
+
16
+ function messagePackDecode(payload, callback) {
17
+ if (typeof payload === "string") {
18
+ throw new Error(
19
+ "MessagePack socket received an unexpected text payload; expected binary data",
20
+ );
21
+ }
22
+
23
+ const [join_ref, ref, topic, event, messagePayload] = decode(payload);
24
+ callback({ join_ref, ref, topic, event, payload: messagePayload });
25
+ }
2
26
 
3
27
  export class Pingerchips {
4
28
  constructor(appKey, options = {}) {
@@ -9,72 +33,251 @@ export class Pingerchips {
9
33
  this.socketId = null;
10
34
  this.authInfo = options.authInfo || {};
11
35
  this._endpoint = this._resolveEndpoint();
36
+ this._socketIdPromise = null;
37
+ this._recoveryState = null;
12
38
 
13
39
  this.connect();
14
40
  }
15
41
 
16
42
  _resolveEndpoint() {
17
- return (
18
- this.options.endpoint ||
19
- (this.options.debug === "false"
20
- ? "wss://queue.pingerchips.com/socket"
21
- : "ws://localhost:4000/socket")
22
- );
43
+ if (this.options.endpoint) return this.options.endpoint;
44
+ const isProd = typeof process !== "undefined"
45
+ ? process.env.NODE_ENV === "production"
46
+ : !this.options.debug;
47
+ return isProd
48
+ ? "wss://queue.pingerchips.com/socket"
49
+ : "ws://localhost:4000/socket";
23
50
  }
24
51
 
25
52
  connect() {
26
53
  const params = { app_key: this.key, ...this.options.params };
54
+ const socketOptions = { params };
27
55
 
28
- this.socket = new Socket(this._endpoint, { params });
29
- this.socket.connect();
56
+ if (this.options.reconnectAfterMs) {
57
+ socketOptions.reconnectAfterMs = this.options.reconnectAfterMs;
58
+ }
59
+ if (this.options.rejoinAfterMs) {
60
+ socketOptions.rejoinAfterMs = this.options.rejoinAfterMs;
61
+ }
62
+ if (this.options.transport) {
63
+ socketOptions.transport = this.options.transport;
64
+ }
65
+ if (
66
+ this.options.serializer === "msgpack" ||
67
+ this.options.messageFormat === "msgpack"
68
+ ) {
69
+ Object.assign(socketOptions, {
70
+ vsn: "3.0.0",
71
+ binaryType: "arraybuffer",
72
+ encode: messagePackEncode,
73
+ decode: messagePackDecode,
74
+ });
75
+ }
30
76
 
31
- this.socket.onOpen(() => {
32
- // Socket ID comes from the server via socket params on open
33
- // read it directly from the socket transport if available,
34
- // otherwise it is set when the first channel join receives socket_id.
35
- const transportParams = this.socket.params();
77
+ if (this.socket) this.disconnect();
78
+ const socket = new Socket(this._endpoint, socketOptions);
79
+ this.socket = socket;
80
+
81
+ socket.onOpen(async () => {
82
+ if (this.socket !== socket) return;
83
+ const transportParams = socket.params();
36
84
  if (transportParams?.socket_id) {
37
85
  this.socketId = transportParams.socket_id;
38
86
  }
87
+ await this._resumeSubscriptions();
39
88
  });
40
89
 
41
- this.socket.onClose(() => {
42
- // Clear socket ID on disconnect — will be re-acquired on reconnect
90
+ socket.onClose(() => {
91
+ if (this.socket !== socket) return;
43
92
  this.socketId = null;
44
93
  this._resubscribeOnReconnect();
45
94
  });
95
+ socket.connect();
46
96
  }
47
97
 
48
- // Re-join all previously subscribed channels after reconnect.
49
- // Phoenix Socket handles reconnect automatically; we re-subscribe channels
50
- // once the socket is open again.
51
98
  _resubscribeOnReconnect() {
52
- const channelNames = Object.keys(this.channels);
53
- if (channelNames.length === 0) return;
99
+ const entries = Object.entries(this.channels);
100
+ if (entries.length === 0) return;
101
+
102
+ // Capture last-known serials / log positions before clearing stale refs
103
+ const recoveryState = {};
104
+ for (const [name, wrapper] of entries) {
105
+ if (wrapper instanceof DurableObject) {
106
+ recoveryState[name] = {
107
+ durable: true,
108
+ type: wrapper.type,
109
+ key: wrapper.key,
110
+ afterLogId: wrapper.logId,
111
+ handlers: wrapper._handlers,
112
+ };
113
+ } else {
114
+ recoveryState[name] = {
115
+ options: wrapper._subscribeOptions,
116
+ serial: wrapper._lastSerial,
117
+ };
118
+ }
119
+ }
54
120
 
55
- // Clear stale channel references — they are bound to the old socket connection
56
121
  this.channels = {};
122
+ for (const [, wrapper] of entries) wrapper.leave();
123
+ this._recoveryState = recoveryState;
124
+ }
57
125
 
58
- const attemptResubscribe = () => {
59
- if (this.socket.isConnected()) {
60
- channelNames.forEach((name) => {
61
- this.subscribe(name).catch(() => {
62
- // Silently retry on next reconnect cycle
63
- });
64
- });
65
- } else {
66
- // Wait for next open event
67
- this.socket.onOpen(() => {
68
- channelNames.forEach((name) => {
69
- this.subscribe(name).catch(() => {});
126
+ async _resumeSubscriptions() {
127
+ const recoveryState = this._recoveryState;
128
+ if (!recoveryState) return;
129
+ this._recoveryState = null;
130
+
131
+ const entries = Object.entries(recoveryState);
132
+ const needsAuth = entries.some(
133
+ ([name, s]) =>
134
+ s.durable ||
135
+ name.startsWith("private-") ||
136
+ name.startsWith("presence-"),
137
+ );
138
+ if (needsAuth) {
139
+ try {
140
+ await this.getSocketIdAsync();
141
+ } catch (err) {
142
+ console.error("[pingerchips] socket bootstrap failed:", err.message);
143
+ return;
144
+ }
145
+ }
146
+
147
+ for (const [name, s] of entries) {
148
+ if (s.durable) {
149
+ this.object(s.type, s.key, { afterLogId: s.afterLogId })
150
+ .then((obj) => {
151
+ // re-attach the handlers the previous view had
152
+ obj._handlers = s.handlers || {};
153
+ })
154
+ .catch((err) => {
155
+ console.error(
156
+ `[pingerchips] durable object resubscribe failed for ${s.type}/${s.key}:`,
157
+ err.message,
158
+ );
70
159
  });
71
- });
160
+ continue;
72
161
  }
73
- };
74
162
 
75
- attemptResubscribe();
163
+ const opts =
164
+ s.serial != null ? { ...s.options, after_serial: s.serial } : s.options;
165
+ this.subscribe(name, opts).catch((err) => {
166
+ console.error(`[pingerchips] resubscribe failed for ${name}:`, err.message);
167
+ });
168
+ }
169
+ }
170
+
171
+ async getSocketIdAsync(timeoutMs = 10000) {
172
+ if (this.socketId) return this.socketId;
173
+ if (this._socketIdPromise) return this._socketIdPromise;
174
+
175
+ const pending = this._bootstrapSocketId(timeoutMs);
176
+ this._socketIdPromise = pending;
177
+ try {
178
+ return await pending;
179
+ } finally {
180
+ if (this._socketIdPromise === pending) this._socketIdPromise = null;
181
+ }
182
+ }
183
+
184
+ async _bootstrapSocketId(timeoutMs) {
185
+ const socket = this.socket;
186
+ if (!socket) throw new Error("Socket is not available");
187
+
188
+ if (!socket.isConnected()) {
189
+ await new Promise((resolve, reject) => {
190
+ let settled = false;
191
+ let timer;
192
+ let openRef;
193
+ let closeRef;
194
+ const finish = (callback) => {
195
+ if (settled) return;
196
+ settled = true;
197
+ clearTimeout(timer);
198
+ socket.off([openRef, closeRef]);
199
+ callback();
200
+ };
201
+ openRef = socket.onOpen(() => finish(() => {
202
+ if (this.socket === socket) resolve();
203
+ else reject(new Error("Socket changed during bootstrap"));
204
+ }));
205
+ closeRef = socket.onClose(() =>
206
+ finish(() => reject(new Error("Socket closed before opening"))),
207
+ );
208
+ timer = setTimeout(
209
+ () => finish(() => reject(new Error("Socket did not connect in time"))),
210
+ timeoutMs,
211
+ );
212
+ });
213
+ }
214
+
215
+ if (this.socket !== socket || !socket.isConnected()) {
216
+ throw new Error("Socket disconnected during bootstrap");
217
+ }
218
+
219
+ const channel = socket.channel(
220
+ `app:${this.key}:room:ephemeral-pingerchips-bootstrap`,
221
+ {},
222
+ );
223
+ return new Promise((resolve, reject) => {
224
+ let settled = false;
225
+ let timer;
226
+ let closeRef;
227
+ const finish = (callback) => {
228
+ if (settled) return;
229
+ settled = true;
230
+ clearTimeout(timer);
231
+ socket.off([closeRef]);
232
+ channel.leave();
233
+ callback();
234
+ };
235
+ closeRef = socket.onClose(() =>
236
+ finish(() => reject(new Error("Socket closed during bootstrap"))),
237
+ );
238
+ timer = setTimeout(
239
+ () => finish(() => reject(new Error("Socket bootstrap timed out"))),
240
+ timeoutMs,
241
+ );
242
+
243
+ channel
244
+ .join(timeoutMs)
245
+ .receive("ok", (resp) => {
246
+ if (!resp?.socket_id) {
247
+ finish(() => reject(new Error("Socket bootstrap did not return a socket ID")));
248
+ return;
249
+ }
250
+ finish(() => {
251
+ if (this.socket !== socket) {
252
+ reject(new Error("Socket changed during bootstrap"));
253
+ return;
254
+ }
255
+ this.socketId = resp.socket_id;
256
+ resolve(this.socketId);
257
+ });
258
+ })
259
+ .receive("error", (resp) => {
260
+ finish(() => reject(new Error(`Socket bootstrap failed: ${JSON.stringify(resp)}`)));
261
+ })
262
+ .receive("timeout", () => {
263
+ finish(() => reject(new Error("Socket bootstrap timed out")));
264
+ });
265
+ });
76
266
  }
77
267
 
268
+ /**
269
+ * Subscribe to a channel.
270
+ *
271
+ * @param {string} channelName
272
+ * @param {object} [options]
273
+ * @param {object} [options.filter] - ExSift filter applied server-side per-subscription.
274
+ * Same query format as FilterNode:
275
+ * { "data.type": { "$eq": "alert" }, "metadata.event": "order" }
276
+ * @param {"fossil"|"xdelta3"|boolean} [options.delta] - Enable delta compression. Server sends
277
+ * "message:delta" diffs; client applies them automatically.
278
+ * @param {number} [options.after_serial] - Resume from this serial (connection recovery).
279
+ * @param {string} [options.auth] - Auth token for private/presence channels (overrides authEndpoint).
280
+ */
78
281
  async subscribe(channelName, options = {}) {
79
282
  if (this.channels[channelName]) {
80
283
  return this.channels[channelName];
@@ -87,7 +290,7 @@ export class Pingerchips {
87
290
  let joinParams = {};
88
291
 
89
292
  if (isPrivate || isPresence) {
90
- if (!this.options.authEndpoint) {
293
+ if (!this.options.authEndpoint && !options.auth) {
91
294
  throw new Error(
92
295
  "authEndpoint must be configured for private/presence channels",
93
296
  );
@@ -99,9 +302,16 @@ export class Pingerchips {
99
302
  );
100
303
  }
101
304
 
102
- joinParams = await this.authenticate(channelName);
305
+ joinParams = options.auth
306
+ ? { auth: options.auth }
307
+ : await this.authenticate(channelName);
103
308
  }
104
309
 
310
+ // Subscription-level options
311
+ if (options.filter != null) joinParams.filter = options.filter;
312
+ if (options.delta != null) joinParams.delta = options.delta;
313
+ if (options.after_serial != null) joinParams.after_serial = options.after_serial;
314
+
105
315
  const channel = this.socket.channel(topic, joinParams);
106
316
 
107
317
  return new Promise((resolve, reject) => {
@@ -112,7 +322,8 @@ export class Pingerchips {
112
322
  this.socketId = resp.socket_id;
113
323
  }
114
324
 
115
- const wrapper = new ChannelWrapper(channel);
325
+ const wrapper = new ChannelWrapper(channel, channelName, options, this);
326
+ if (resp.serial != null) wrapper._lastSerial = resp.serial;
116
327
  this.channels[channelName] = wrapper;
117
328
  resolve(wrapper);
118
329
  })
@@ -157,6 +368,28 @@ export class Pingerchips {
157
368
  return this.socketId;
158
369
  }
159
370
 
371
+ disconnect() {
372
+ for (const wrapper of Object.values(this.channels)) wrapper.leave();
373
+ this.channels = {};
374
+ this._recoveryState = null;
375
+ this._socketIdPromise = null;
376
+ this.socketId = null;
377
+ const socket = this.socket;
378
+ this.socket = null;
379
+ socket?.disconnect();
380
+ }
381
+
382
+ /**
383
+ * Returns the last received serial for a subscribed channel.
384
+ * Use to checkpoint durable message position across page reloads.
385
+ *
386
+ * @param {string} channelName
387
+ * @returns {number|null}
388
+ */
389
+ getLastSerial(channelName) {
390
+ return this.channels[channelName]?._lastSerial ?? null;
391
+ }
392
+
160
393
  getHttpEndpoint() {
161
394
  return this._endpoint
162
395
  .replace("ws://", "http://")
@@ -164,26 +397,346 @@ export class Pingerchips {
164
397
  .replace("/socket", "");
165
398
  }
166
399
 
400
+ /**
401
+ * Register a push device token for a user.
402
+ * NOTE: This should be called from your backend, not from the browser.
403
+ * Use the server SDK (pingerchips-js-server) for push token management.
404
+ *
405
+ * @deprecated Use PingerchipsServer.registerPushToken() from pingerchips-js-server instead.
406
+ */
407
+ async registerPushToken() {
408
+ throw new Error(
409
+ "registerPushToken has been removed from the client SDK for security reasons. " +
410
+ "Use PingerchipsServer.registerPushToken() from pingerchips-js-server instead."
411
+ );
412
+ }
413
+
414
+ /**
415
+ * @deprecated Use PingerchipsServer.unregisterPushToken() from pingerchips-js-server instead.
416
+ */
417
+ async unregisterPushToken() {
418
+ throw new Error(
419
+ "unregisterPushToken has been removed from the client SDK for security reasons. " +
420
+ "Use PingerchipsServer.unregisterPushToken() from pingerchips-js-server instead."
421
+ );
422
+ }
423
+
167
424
  unsubscribe(channelName) {
168
425
  if (this.channels[channelName]) {
169
426
  this.channels[channelName].leave();
170
427
  delete this.channels[channelName];
171
428
  }
172
429
  }
430
+
431
+ /**
432
+ * Subscribe to a Durable Object. Read-only from the browser — writes go
433
+ * through your backend (pingerchips-js-server). Resolves once the initial
434
+ * `snapshot` has arrived, so `.state` / `.get()` are ready immediately.
435
+ *
436
+ * @param {string} type - Object type (arbitrary string).
437
+ * @param {string} key - Object key (arbitrary string).
438
+ * @param {object} [options]
439
+ * @param {number} [options.afterLogId] - Resume: replay entries after this
440
+ * log_id before the snapshot.
441
+ * @param {string} [options.auth] - Pre-minted capability token
442
+ * (from server SDK authenticateObject),
443
+ * bypassing the auth endpoint.
444
+ * @param {string} [options.authEndpoint] - Override the client's authEndpoint
445
+ * for this call.
446
+ * @returns {Promise<DurableObject>}
447
+ */
448
+ async object(type, key, options = {}) {
449
+ if (!type || !key) {
450
+ throw new Error("object(type, key) requires a non-empty type and key");
451
+ }
452
+
453
+ const cacheKey = `durable:${type}:${key}`;
454
+ if (this.channels[cacheKey]) return this.channels[cacheKey];
455
+
456
+ const socketId = await this.getSocketIdAsync();
457
+
458
+ let auth = options.auth;
459
+ if (!auth) {
460
+ auth = await this.authenticateObject(type, key, socketId, options.authEndpoint);
461
+ }
462
+
463
+ const joinParams = { auth };
464
+ if (options.afterLogId != null) joinParams.after_log_id = options.afterLogId;
465
+
466
+ const topic = `durable:${this.key}:${type}:${key}`;
467
+ const channel = this.socket.channel(topic, joinParams);
468
+
469
+ return new Promise((resolve, reject) => {
470
+ const obj = new DurableObject(channel, type, key, this, cacheKey);
471
+
472
+ channel
473
+ .join()
474
+ .receive("ok", () => {
475
+ this.channels[cacheKey] = obj;
476
+ obj._whenReady().then(() => resolve(obj), reject);
477
+ })
478
+ .receive("error", (resp) => {
479
+ channel.leave();
480
+ reject(new Error(`Failed to join durable object: ${JSON.stringify(resp)}`));
481
+ })
482
+ .receive("timeout", () => {
483
+ channel.leave();
484
+ reject(new Error("Timed out joining durable object channel"));
485
+ });
486
+ });
487
+ }
488
+
489
+ /**
490
+ * Fetch a per-object capability token from your auth endpoint.
491
+ * POSTs { socket_id, object_type, object_key, auth_info } and expects
492
+ * { auth: "<capability token>" } back (see server SDK authenticateObject).
493
+ */
494
+ async authenticateObject(type, key, socketId, endpointOverride) {
495
+ const endpoint = endpointOverride || this.options.authEndpoint;
496
+ if (!endpoint) {
497
+ throw new Error(
498
+ "authEndpoint must be configured for Durable Object subscriptions " +
499
+ "(or pass an explicit `auth` token).",
500
+ );
501
+ }
502
+
503
+ const response = await fetch(endpoint, {
504
+ method: "POST",
505
+ headers: {
506
+ "Content-Type": "application/json",
507
+ ...this.options.authHeaders,
508
+ },
509
+ body: JSON.stringify({
510
+ socket_id: socketId || this.socketId,
511
+ object_type: type,
512
+ object_key: key,
513
+ auth_info: this.authInfo,
514
+ }),
515
+ });
516
+
517
+ if (!response.ok) {
518
+ const error = await response
519
+ .json()
520
+ .catch(() => ({ error: "Object authentication failed" }));
521
+ throw new Error(error.error || "Object authentication failed");
522
+ }
523
+
524
+ const data = await response.json();
525
+ if (!data || !data.auth) {
526
+ throw new Error("Auth endpoint did not return an `auth` token");
527
+ }
528
+ return data.auth;
529
+ }
530
+ }
531
+
532
+ // ---------------------------------------------------------------------------
533
+ // Delta apply — fossil and xdelta3
534
+ // Both algorithms are applied server-side on Rust NIFs; client receives
535
+ // Base64-encoded patch bytes and applies them in JS.
536
+ //
537
+ // fossil-delta JS port: https://github.com/dchest/fossil-delta-js
538
+ // xdelta3 WASM: not yet bundled — falls back to requesting a full message.
539
+ // ---------------------------------------------------------------------------
540
+
541
+ function applyFossilDelta(sourceBytes, deltaBytes) {
542
+ // Inline fossil-delta apply — same algorithm as the Rust fossil-delta crate.
543
+ // Source: adapted from dchest/fossil-delta-js (MIT)
544
+ let zDelta = deltaBytes;
545
+ let lenSrc = sourceBytes.length;
546
+ let lenDelta = zDelta.length;
547
+ let total = 0;
548
+ let i = 0;
549
+
550
+ function readInt() {
551
+ let v = 0;
552
+ while (i < lenDelta) {
553
+ let c = zDelta[i++];
554
+ if (c >= 0x80) {
555
+ v = (v << 7) | (c & 0x7f);
556
+ } else {
557
+ v = (v << 7) | c;
558
+ break;
559
+ }
560
+ }
561
+ return v;
562
+ }
563
+
564
+ let lenOut = readInt();
565
+ i++; // skip '\n'
566
+
567
+ let out = new Uint8Array(lenOut);
568
+ let outPos = 0;
569
+
570
+ while (i < lenDelta) {
571
+ let cnt = readInt();
572
+ let op = zDelta[i++];
573
+ if (op === 0x40) { // '@' copy from source
574
+ let ofst = readInt();
575
+ i++; // skip ','
576
+ out.set(sourceBytes.subarray(ofst, ofst + cnt), outPos);
577
+ outPos += cnt;
578
+ } else if (op === 0x3a) { // ':' literal
579
+ out.set(zDelta.subarray(i, i + cnt), outPos);
580
+ i += cnt;
581
+ outPos += cnt;
582
+ i++; // skip ','
583
+ } else if (op === 0x3b) { // ';' end
584
+ break;
585
+ }
586
+ }
587
+
588
+ return out.subarray(0, outPos);
173
589
  }
174
590
 
591
+ function base64ToBytes(b64) {
592
+ const bin = atob(b64);
593
+ const bytes = new Uint8Array(bin.length);
594
+ for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
595
+ return bytes;
596
+ }
597
+
598
+ // ---------------------------------------------------------------------------
599
+
175
600
  class ChannelWrapper {
176
- constructor(channel) {
601
+ constructor(channel, channelName, subscribeOptions, client) {
177
602
  this.channel = channel;
603
+ this.channelName = channelName;
604
+ this._subscribeOptions = subscribeOptions;
605
+ this._client = client;
606
+ this._lastSerial = null;
607
+
608
+ // Per-channel delta state: { base: Uint8Array, seq: number } | null
609
+ this._deltaBase = null;
610
+
611
+ // Track serial from every message for reconnect recovery
612
+ this._trackSerial = (payload) => {
613
+ if (payload?.serial != null) this._lastSerial = payload.serial;
614
+ };
615
+
616
+ channel.on("message", (payload) => {
617
+ this._trackSerial(payload);
618
+ });
619
+
620
+ // Handle delta-compressed messages from server
621
+ channel.on("message:delta", (payload) => {
622
+ const full = this._applyDelta(payload);
623
+ if (full) {
624
+ this._trackSerial(full);
625
+ // Re-emit as "message" so callers don't need to handle both events
626
+ this._emitMessage(full);
627
+ } else {
628
+ // Apply failed — ask server to reset delta state for this channel
629
+ channel.push("pingerchips:delta_sync_error", { channel: channelName });
630
+ }
631
+ });
632
+
633
+ channel.on("pingerchips:delta_compression_enabled", (payload) => {
634
+ // Server confirmed delta negotiation — reset local base
635
+ this._deltaBase = null;
636
+ });
637
+
638
+ channel.on("pingerchips:recovery_failed", (payload) => {
639
+ // Server couldn't replay — reset serial and surface to app code
640
+ this._lastSerial = null;
641
+ for (const handler of this._recoveryFailedHandlers) {
642
+ handler({
643
+ reason: payload.reason, // "position_expired" | "no_buffer"
644
+ channel: payload.channel,
645
+ afterSerial: payload.after_serial,
646
+ });
647
+ }
648
+ });
649
+
650
+ this._messageHandlers = [];
651
+ this._recoveryFailedHandlers = [];
652
+ }
653
+
654
+ /**
655
+ * Register a handler for when the server cannot replay missed messages.
656
+ * Payload: { reason: "position_expired"|"no_buffer", channel: string, afterSerial: number }
657
+ *
658
+ * Use this to decide whether to re-fetch state from your API.
659
+ *
660
+ * @param {function} callback
661
+ * @returns {this}
662
+ */
663
+ onRecoveryFailed(callback) {
664
+ this._recoveryFailedHandlers.push(callback);
665
+ return this;
666
+ }
667
+
668
+ _applyDelta(deltaPayload) {
669
+ if (!this._deltaBase) return null;
670
+
671
+ const { delta: b64, seq, algorithm } = deltaPayload;
672
+ if (!b64) return null;
673
+
674
+ try {
675
+ const deltaBytes = base64ToBytes(b64);
676
+
677
+ if (algorithm === "xdelta3") {
678
+ // xdelta3 WASM not bundled yet — signal sync error so server sends full
679
+ return null;
680
+ }
681
+
682
+ // Default: fossil
683
+ const sourceBytes = this._deltaBase.bytes;
684
+ const resultBytes = applyFossilDelta(sourceBytes, deltaBytes);
685
+ const text = new TextDecoder().decode(resultBytes);
686
+ const full = JSON.parse(text);
687
+
688
+ // Update base to the reconstructed full message
689
+ this._deltaBase = { bytes: resultBytes, seq };
690
+ return full;
691
+ } catch (err) {
692
+ console.warn("[pingerchips] delta apply failed:", err.message);
693
+ return null;
694
+ }
695
+ }
696
+
697
+ _emitMessage(payload) {
698
+ // Update delta base whenever we receive a full message
699
+ try {
700
+ const bytes = new TextEncoder().encode(JSON.stringify(payload));
701
+ this._deltaBase = { bytes, seq: payload.serial };
702
+ } catch (err) {
703
+ console.warn("[pingerchips] delta base update failed:", err.message);
704
+ }
705
+
706
+ for (const handler of this._messageHandlers) {
707
+ handler(payload);
708
+ }
178
709
  }
179
710
 
180
711
  bind(eventName, callback) {
181
- this.channel.on(eventName, callback);
712
+ if (eventName === "message") {
713
+ // Route through delta-aware path
714
+ this._messageHandlers.push(callback);
715
+
716
+ // Also hook the raw channel.on once so _emitMessage fires
717
+ if (this._messageHandlers.length === 1) {
718
+ this.channel.on("message", (payload) => this._emitMessage(payload));
719
+ }
720
+ } else {
721
+ this.channel.on(eventName, callback);
722
+ }
182
723
  return this;
183
724
  }
184
725
 
185
- unbind(eventName) {
186
- this.channel.off(eventName);
726
+ unbind(eventName, callback) {
727
+ if (eventName === "message") {
728
+ if (callback) {
729
+ this._messageHandlers = this._messageHandlers.filter((h) => h !== callback);
730
+ } else {
731
+ this._messageHandlers = [];
732
+ }
733
+ } else {
734
+ if (callback) {
735
+ this.channel.off(eventName, callback);
736
+ } else {
737
+ this.channel.off(eventName);
738
+ }
739
+ }
187
740
  return this;
188
741
  }
189
742
 
@@ -198,3 +751,242 @@ class ChannelWrapper {
198
751
  }
199
752
 
200
753
  export default Pingerchips;
754
+
755
+ export { ActiveRun, ChatSession, PingerchipsChat, View } from "./chat.js";
756
+
757
+ // Chat API lives in chat.js — import { PingerchipsChat } from "./chat.js"
758
+
759
+ // ---------------------------------------------------------------------------
760
+ // DurableObject — read-only real-time view of a Durable Object
761
+ // ---------------------------------------------------------------------------
762
+
763
+ /**
764
+ * A live, read-only view of a Durable Object. Obtained from
765
+ * `pingerchips.object(type, key)`.
766
+ *
767
+ * The channel is read-only from the browser — all writes go through your
768
+ * backend via `pingerchips-js-server`.
769
+ *
770
+ * Server events:
771
+ * snapshot { state, log_id } — full state, on join / reconnect
772
+ * change { key, value, previous?, log_id } — one slot set / incremented / deleted
773
+ * batch { changes: [{key, value, previous}], log_id } — setAll / transaction
774
+ *
775
+ * Local events you can listen for:
776
+ * "snapshot", "change", "batch", "change:{slot}"
777
+ */
778
+ export class DurableObject {
779
+ constructor(channel, type, key, client, cacheKey) {
780
+ this.channel = channel;
781
+ this.type = type;
782
+ this.key = key;
783
+ this._client = client;
784
+ this._cacheKey = cacheKey;
785
+
786
+ /** Last known state map. Updated on every snapshot / change / batch. */
787
+ this.state = {};
788
+ /** Last seen log position. Checkpoint this for resumable reconnects. */
789
+ this.logId = 0;
790
+
791
+ this._handlers = {};
792
+ this._ready = new Promise((resolve) => {
793
+ this._resolveReady = resolve;
794
+ });
795
+
796
+ channel.on("snapshot", (payload) => {
797
+ this.state = payload.state ?? {};
798
+ if (payload.log_id != null) this.logId = payload.log_id;
799
+ this._emit("snapshot", payload);
800
+ this._resolveReady?.();
801
+ this._resolveReady = null;
802
+ });
803
+
804
+ channel.on("change", (payload) => {
805
+ const { key, value } = payload;
806
+ if (value === null || value === undefined) {
807
+ const next = { ...this.state };
808
+ delete next[key];
809
+ this.state = next;
810
+ } else {
811
+ this.state = { ...this.state, [key]: value };
812
+ }
813
+ if (payload.log_id != null) this.logId = payload.log_id;
814
+ this._emit("change", payload);
815
+ this._emit(`change:${key}`, payload);
816
+ });
817
+
818
+ channel.on("batch", (payload) => {
819
+ const changes = payload.changes ?? [];
820
+ let next = { ...this.state };
821
+ for (const { key, value } of changes) {
822
+ if (value === null || value === undefined) delete next[key];
823
+ else next[key] = value;
824
+ }
825
+ this.state = next;
826
+ if (payload.log_id != null) this.logId = payload.log_id;
827
+ this._emit("batch", payload);
828
+ for (const change of changes) {
829
+ this._emit("change", { ...change, log_id: payload.log_id });
830
+ this._emit(`change:${change.key}`, { ...change, log_id: payload.log_id });
831
+ }
832
+ });
833
+ }
834
+
835
+ _whenReady() {
836
+ return this._ready;
837
+ }
838
+
839
+ /**
840
+ * Read a slot from the local cached state. Synchronous — no network call.
841
+ * @param {string} slot
842
+ * @returns {any}
843
+ */
844
+ get(slot) {
845
+ return this.state[slot];
846
+ }
847
+
848
+ /**
849
+ * Listen for an event. Built-in: "snapshot", "change", "batch",
850
+ * "change:{slot}". Returns an unsubscribe function.
851
+ * @param {string} event
852
+ * @param {function} callback
853
+ * @returns {() => void}
854
+ */
855
+ on(event, callback) {
856
+ if (!this._handlers[event]) this._handlers[event] = [];
857
+ this._handlers[event].push(callback);
858
+ return () => this.off(event, callback);
859
+ }
860
+
861
+ /**
862
+ * Remove a handler, or all handlers for an event.
863
+ * @param {string} event
864
+ * @param {function} [callback]
865
+ * @returns {this}
866
+ */
867
+ off(event, callback) {
868
+ if (!callback) {
869
+ delete this._handlers[event];
870
+ } else {
871
+ this._handlers[event] = (this._handlers[event] || []).filter(
872
+ (h) => h !== callback,
873
+ );
874
+ }
875
+ return this;
876
+ }
877
+
878
+ _emit(event, payload) {
879
+ for (const h of this._handlers[event] || []) h(payload);
880
+ }
881
+
882
+ /** Leave the channel and stop receiving events. */
883
+ unsubscribe() {
884
+ this.channel.leave();
885
+ if (this._client && this._cacheKey) {
886
+ delete this._client.channels[this._cacheKey];
887
+ }
888
+ }
889
+
890
+ /** Internal alias so `client.disconnect()` can tear this down uniformly. */
891
+ leave() {
892
+ this.channel.leave();
893
+ }
894
+ }
895
+
896
+ // ---------------------------------------------------------------------------
897
+ // ChatThreadChannel — legacy read-only channel wrapper (deprecated)
898
+ // ---------------------------------------------------------------------------
899
+
900
+ /**
901
+ * ChatThreadChannel — real-time view of a durable thread.
902
+ *
903
+ * The channel is read-only from the client. All writes (send, edit, delete,
904
+ * react, etc.) must go through your backend using pingerchips-js-server.
905
+ *
906
+ * Events from server:
907
+ * snapshot { state: object, log_id: number } — full state on join
908
+ * change { key: string, value: any, previous: any, log_id: number }
909
+ * batch { changes: [{key, value, previous}], log_id: number }
910
+ */
911
+ class ChatThreadChannel {
912
+ constructor(channel, threadId) {
913
+ this.channel = channel;
914
+ this.threadId = threadId;
915
+
916
+ // Last known state and log_id — updated on every snapshot/change/batch
917
+ this.state = {};
918
+ this.logId = 0;
919
+
920
+ this._handlers = {}; // event → [callback]
921
+
922
+ channel.on("snapshot", (payload) => {
923
+ this.state = payload.state ?? {};
924
+ this.logId = payload.log_id ?? 0;
925
+ this._emit("snapshot", payload);
926
+ });
927
+
928
+ channel.on("change", (payload) => {
929
+ this.state = { ...this.state, [payload.key]: payload.value };
930
+ if (payload.log_id != null) this.logId = payload.log_id;
931
+ this._emit("change", payload);
932
+ this._emit(`change:${payload.key}`, payload);
933
+ });
934
+
935
+ channel.on("batch", (payload) => {
936
+ const changes = payload.changes ?? [];
937
+ for (const { key, value } of changes) {
938
+ this.state = { ...this.state, [key]: value };
939
+ }
940
+ if (payload.log_id != null) this.logId = payload.log_id;
941
+ this._emit("batch", payload);
942
+ });
943
+ }
944
+
945
+ /**
946
+ * Get a slot from local state (no network call).
947
+ * @param {string} slot
948
+ * @returns {any}
949
+ */
950
+ get(slot) {
951
+ return this.state[slot];
952
+ }
953
+
954
+ /**
955
+ * Register a handler for a channel event.
956
+ * Built-in events: "snapshot", "change", "batch", "change:{slot}"
957
+ *
958
+ * @param {string} event
959
+ * @param {function} callback
960
+ * @returns {this}
961
+ */
962
+ on(event, callback) {
963
+ if (!this._handlers[event]) this._handlers[event] = [];
964
+ this._handlers[event].push(callback);
965
+ return this;
966
+ }
967
+
968
+ /**
969
+ * Remove a handler (or all handlers for an event if no callback given).
970
+ * @param {string} event
971
+ * @param {function} [callback]
972
+ * @returns {this}
973
+ */
974
+ off(event, callback) {
975
+ if (!callback) {
976
+ delete this._handlers[event];
977
+ } else {
978
+ this._handlers[event] = (this._handlers[event] || []).filter((h) => h !== callback);
979
+ }
980
+ return this;
981
+ }
982
+
983
+ _emit(event, payload) {
984
+ for (const h of this._handlers[event] || []) {
985
+ h(payload);
986
+ }
987
+ }
988
+
989
+ leave() {
990
+ this.channel.leave();
991
+ }
992
+ }