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.
- package/chat.js +585 -0
- package/index.js +835 -43
- package/package.json +3 -2
- 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
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
-
|
|
29
|
-
|
|
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.
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
|
|
42
|
-
|
|
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
|
|
53
|
-
if (
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|