@kispi/chat 0.1.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/README.md +263 -0
- package/dist/chunk-T3QOUFOM.js +33 -0
- package/dist/index.cjs +1175 -0
- package/dist/index.d.cts +806 -0
- package/dist/index.d.ts +806 -0
- package/dist/index.js +1119 -0
- package/dist/server/index.cjs +311 -0
- package/dist/server/index.d.cts +208 -0
- package/dist/server/index.d.ts +208 -0
- package/dist/server/index.js +256 -0
- package/package.json +47 -0
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,1175 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
|
|
20
|
+
// src/index.ts
|
|
21
|
+
var src_exports = {};
|
|
22
|
+
__export(src_exports, {
|
|
23
|
+
ChatClient: () => ChatClient,
|
|
24
|
+
ChatError: () => ChatError,
|
|
25
|
+
Room: () => Room,
|
|
26
|
+
Timeline: () => Timeline,
|
|
27
|
+
createChatClient: () => createChatClient,
|
|
28
|
+
createTimeline: () => createTimeline
|
|
29
|
+
});
|
|
30
|
+
module.exports = __toCommonJS(src_exports);
|
|
31
|
+
|
|
32
|
+
// src/emitter.ts
|
|
33
|
+
var Emitter = class {
|
|
34
|
+
listeners = /* @__PURE__ */ new Map();
|
|
35
|
+
on(event, fn) {
|
|
36
|
+
let set = this.listeners.get(event);
|
|
37
|
+
if (set === void 0) {
|
|
38
|
+
set = /* @__PURE__ */ new Set();
|
|
39
|
+
this.listeners.set(event, set);
|
|
40
|
+
}
|
|
41
|
+
set.add(fn);
|
|
42
|
+
return () => void set.delete(fn);
|
|
43
|
+
}
|
|
44
|
+
emit(event, value) {
|
|
45
|
+
const set = this.listeners.get(event);
|
|
46
|
+
if (set === void 0) return;
|
|
47
|
+
for (const fn of [...set]) {
|
|
48
|
+
try {
|
|
49
|
+
;
|
|
50
|
+
fn(value);
|
|
51
|
+
} catch (err) {
|
|
52
|
+
queueMicrotask(() => {
|
|
53
|
+
throw err;
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
clear() {
|
|
59
|
+
this.listeners.clear();
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
// src/errors.ts
|
|
64
|
+
var ChatError = class extends Error {
|
|
65
|
+
code;
|
|
66
|
+
/** Present on rate limits, in milliseconds. */
|
|
67
|
+
retryAfterMs;
|
|
68
|
+
/** The consumer's own code, when a before_publish hook denied this. */
|
|
69
|
+
appCode;
|
|
70
|
+
constructor(code, message, extra) {
|
|
71
|
+
super(message);
|
|
72
|
+
this.name = "ChatError";
|
|
73
|
+
this.code = code;
|
|
74
|
+
if (extra?.retryAfterMs !== void 0) this.retryAfterMs = extra.retryAfterMs;
|
|
75
|
+
if (extra?.appCode !== void 0) this.appCode = extra.appCode;
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
function errorFrom(data, fallback = "the server refused the request") {
|
|
79
|
+
if (typeof data === "object" && data !== null) {
|
|
80
|
+
const d = data;
|
|
81
|
+
const inner = d["error"] ?? d;
|
|
82
|
+
const code = typeof inner["code"] === "string" ? inner["code"] : "internal";
|
|
83
|
+
const message = typeof inner["message"] === "string" ? inner["message"] : fallback;
|
|
84
|
+
const extra = {};
|
|
85
|
+
if (typeof inner["retryAfterMs"] === "number") extra.retryAfterMs = inner["retryAfterMs"];
|
|
86
|
+
if (typeof inner["appCode"] === "string") extra.appCode = inner["appCode"];
|
|
87
|
+
return new ChatError(code, message, extra);
|
|
88
|
+
}
|
|
89
|
+
return new ChatError("internal", fallback);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// src/rest.ts
|
|
93
|
+
var Rest = class {
|
|
94
|
+
options;
|
|
95
|
+
constructor(options) {
|
|
96
|
+
this.options = options;
|
|
97
|
+
}
|
|
98
|
+
async get(path, query) {
|
|
99
|
+
return this.call("GET", path + queryString(query));
|
|
100
|
+
}
|
|
101
|
+
async post(path, body) {
|
|
102
|
+
return this.call("POST", path, body);
|
|
103
|
+
}
|
|
104
|
+
async patch(path, body) {
|
|
105
|
+
return this.call("PATCH", path, body);
|
|
106
|
+
}
|
|
107
|
+
async put(path, body) {
|
|
108
|
+
return this.call("PUT", path, body);
|
|
109
|
+
}
|
|
110
|
+
async delete(path) {
|
|
111
|
+
return this.call("DELETE", path);
|
|
112
|
+
}
|
|
113
|
+
async call(method, path, body) {
|
|
114
|
+
const token = await this.options.token();
|
|
115
|
+
if (token === void 0) {
|
|
116
|
+
throw new ChatError(
|
|
117
|
+
"unauthorized",
|
|
118
|
+
"no user token yet: connect() fetches one, and REST calls before that have nothing to present"
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
const headers = {
|
|
122
|
+
Authorization: `Bearer ${token}`,
|
|
123
|
+
"X-Chat-Key": this.options.key
|
|
124
|
+
};
|
|
125
|
+
if (body !== void 0) headers["Content-Type"] = "application/json";
|
|
126
|
+
const res = await this.options.fetch(`${this.options.url}${path}`, {
|
|
127
|
+
method,
|
|
128
|
+
headers,
|
|
129
|
+
...body === void 0 ? {} : { body: JSON.stringify(body) }
|
|
130
|
+
});
|
|
131
|
+
if (res.status === 204) return void 0;
|
|
132
|
+
const text = await res.text();
|
|
133
|
+
const parsed = text === "" ? void 0 : JSON.parse(text);
|
|
134
|
+
if (!res.ok) {
|
|
135
|
+
const err = errorFrom(parsed, `${method} ${path} failed with ${res.status}`);
|
|
136
|
+
throw err;
|
|
137
|
+
}
|
|
138
|
+
return parsed;
|
|
139
|
+
}
|
|
140
|
+
};
|
|
141
|
+
function queryString(query) {
|
|
142
|
+
if (query === void 0) return "";
|
|
143
|
+
const params = new URLSearchParams();
|
|
144
|
+
for (const [k, v] of Object.entries(query)) {
|
|
145
|
+
if (v !== void 0) params.set(k, String(v));
|
|
146
|
+
}
|
|
147
|
+
const s = params.toString();
|
|
148
|
+
return s === "" ? "" : `?${s}`;
|
|
149
|
+
}
|
|
150
|
+
function restURLFrom(socketURL) {
|
|
151
|
+
if (socketURL.startsWith("wss://")) return `https://${socketURL.slice("wss://".length)}`;
|
|
152
|
+
if (socketURL.startsWith("ws://")) return `http://${socketURL.slice("ws://".length)}`;
|
|
153
|
+
return socketURL;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// src/timeline.ts
|
|
157
|
+
var MaxPageSize = 100;
|
|
158
|
+
var Timeline = class {
|
|
159
|
+
options;
|
|
160
|
+
items = [];
|
|
161
|
+
/**
|
|
162
|
+
* Deletes for messages this client has not seen yet.
|
|
163
|
+
*
|
|
164
|
+
* A `message.deleted` can arrive for a seq inside a hole. If it were
|
|
165
|
+
* dropped, the fill that follows would bring the row back **alive**,
|
|
166
|
+
* because the history read reflects the delete only if it happened
|
|
167
|
+
* before the read. The client would then show a message the server
|
|
168
|
+
* considers gone, and nothing would ever correct it.
|
|
169
|
+
*/
|
|
170
|
+
pendingDeletes = /* @__PURE__ */ new Set();
|
|
171
|
+
/** In-flight fills, keyed by the range they cover, to avoid duplicates. */
|
|
172
|
+
fills = /* @__PURE__ */ new Map();
|
|
173
|
+
/**
|
|
174
|
+
* Bumped by every reset.
|
|
175
|
+
*
|
|
176
|
+
* A fill started before a reset must not be applied after one: the
|
|
177
|
+
* range it was asked for described a timeline that no longer exists.
|
|
178
|
+
* Without this, a reconnect that lands mid-fill mixes the old room's
|
|
179
|
+
* rows into the new list.
|
|
180
|
+
*/
|
|
181
|
+
epoch = 0;
|
|
182
|
+
constructor(options) {
|
|
183
|
+
this.options = options;
|
|
184
|
+
}
|
|
185
|
+
get messages() {
|
|
186
|
+
return this.items;
|
|
187
|
+
}
|
|
188
|
+
/** The highest seq this timeline holds, hole or no hole. */
|
|
189
|
+
get highestSeq() {
|
|
190
|
+
return this.items.at(-1)?.seq ?? 0;
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* The highest seq with nothing missing below it.
|
|
194
|
+
*
|
|
195
|
+
* This is the number a resubscribe must send as `since`, and it is not
|
|
196
|
+
* `highestSeq`. Holding 5,6,7,8,9,14 with 10..13 missing, the top of
|
|
197
|
+
* the list is 14 and the top of what this client actually has is 9.
|
|
198
|
+
* Sending 14 tells the server "I have everything up to 14", the
|
|
199
|
+
* catch-up computes that there is nothing to fetch, and the hole
|
|
200
|
+
* becomes permanent -- which is exactly the failure the whole gap
|
|
201
|
+
* machinery exists to prevent, arriving through the number that
|
|
202
|
+
* describes it.
|
|
203
|
+
*
|
|
204
|
+
* A hole below the first row held is not visible here and not this
|
|
205
|
+
* client's business: history begins where the last load began.
|
|
206
|
+
*/
|
|
207
|
+
get contiguousSeq() {
|
|
208
|
+
const first = this.items[0];
|
|
209
|
+
if (first === void 0) return 0;
|
|
210
|
+
let last = first.seq;
|
|
211
|
+
for (const item of this.items) {
|
|
212
|
+
if (item.seq === last || item.seq === last + 1) {
|
|
213
|
+
last = item.seq;
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
216
|
+
break;
|
|
217
|
+
}
|
|
218
|
+
return last;
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* Forgets everything, including the pending deletes.
|
|
222
|
+
*
|
|
223
|
+
* Used when the server says the cursor is too old: what this timeline
|
|
224
|
+
* holds is no longer connected to what the room holds, so carrying any
|
|
225
|
+
* of it forward would leave a hole that nothing fills.
|
|
226
|
+
*/
|
|
227
|
+
discard() {
|
|
228
|
+
this.epoch++;
|
|
229
|
+
this.fills.clear();
|
|
230
|
+
this.pendingDeletes.clear();
|
|
231
|
+
this.items = [];
|
|
232
|
+
this.changed();
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* Replaces the window with a recent page. Used by the initial load and
|
|
236
|
+
* by recovery.
|
|
237
|
+
*
|
|
238
|
+
* Rows already held **above** the page's top survive it. A recent page
|
|
239
|
+
* is the last N messages as of the moment the server read them, which
|
|
240
|
+
* makes it evidence about the range it covers and no evidence at all
|
|
241
|
+
* about anything newer. Live frames that arrived while the page was in
|
|
242
|
+
* flight are exactly that -- newer -- and dropping them would open a
|
|
243
|
+
* hole above the page that nothing goes looking for, because a hole is
|
|
244
|
+
* only detected on the next arrival.
|
|
245
|
+
*
|
|
246
|
+
* That race is not exotic: the README points consumers at `reload()`
|
|
247
|
+
* for the moment after a reconnect, which is the moment the missed
|
|
248
|
+
* frames are arriving.
|
|
249
|
+
*
|
|
250
|
+
* A caller that wants the list genuinely emptied has `discard()`.
|
|
251
|
+
*/
|
|
252
|
+
reset(messages) {
|
|
253
|
+
this.epoch++;
|
|
254
|
+
this.fills.clear();
|
|
255
|
+
const page = [...messages].sort((a, b) => a.seq - b.seq);
|
|
256
|
+
const top = page.at(-1)?.seq ?? 0;
|
|
257
|
+
this.items = [...page, ...this.items.filter((m) => m.seq > top)];
|
|
258
|
+
for (const m of this.items) {
|
|
259
|
+
if (this.pendingDeletes.has(m.id)) this.applyDelete(m.id);
|
|
260
|
+
}
|
|
261
|
+
this.changed();
|
|
262
|
+
}
|
|
263
|
+
/**
|
|
264
|
+
* Takes one live message, in order or not, and fills what it reveals.
|
|
265
|
+
*
|
|
266
|
+
* Returns the fill promise so a caller can await settling; nothing in
|
|
267
|
+
* the SDK does, because a consumer's UI should show what arrived and
|
|
268
|
+
* let the rest land when it lands.
|
|
269
|
+
*/
|
|
270
|
+
add(message) {
|
|
271
|
+
const gapFrom = this.highestSeq;
|
|
272
|
+
this.insert(message);
|
|
273
|
+
if (gapFrom > 0 && message.seq > gapFrom + 1) {
|
|
274
|
+
return this.fill(gapFrom, message.seq);
|
|
275
|
+
}
|
|
276
|
+
return Promise.resolve();
|
|
277
|
+
}
|
|
278
|
+
/**
|
|
279
|
+
* Applies a `message.updated`.
|
|
280
|
+
*
|
|
281
|
+
* A row that is already a tombstone stays one. The edit and the delete
|
|
282
|
+
* can arrive in either order -- reordering is the input this module
|
|
283
|
+
* exists for -- and applying a late edit to a deleted row puts its body
|
|
284
|
+
* back on screen under a `deletedAt` that says it is gone. From the
|
|
285
|
+
* person who deleted it, that is a delete that did not work.
|
|
286
|
+
*/
|
|
287
|
+
update(message) {
|
|
288
|
+
const at = this.items.findIndex((m) => m.id === message.id);
|
|
289
|
+
if (at === -1) return;
|
|
290
|
+
const current = this.items[at];
|
|
291
|
+
if (current.deletedAt !== void 0) return;
|
|
292
|
+
this.items[at] = { ...current, ...message };
|
|
293
|
+
this.changed();
|
|
294
|
+
}
|
|
295
|
+
/**
|
|
296
|
+
* Applies a `reaction.added` or `reaction.removed` to the aggregate the
|
|
297
|
+
* message carries.
|
|
298
|
+
*
|
|
299
|
+
* `Message.reactions` is filled by the history read and a consumer
|
|
300
|
+
* renders from it, so leaving it alone would give them a count that is
|
|
301
|
+
* correct at load and frozen after -- inside a live session, with the
|
|
302
|
+
* event that should have changed it arriving on another channel.
|
|
303
|
+
*
|
|
304
|
+
* The server sends the new `count` with the event, so this is a
|
|
305
|
+
* replacement rather than an increment: two clients reacting at once
|
|
306
|
+
* cannot drift the way `+1`/`-1` would.
|
|
307
|
+
*/
|
|
308
|
+
reaction(messageId, emoji, count) {
|
|
309
|
+
const at = this.items.findIndex((m) => m.id === messageId);
|
|
310
|
+
if (at === -1) return;
|
|
311
|
+
const current = this.items[at];
|
|
312
|
+
const existing = current.reactions ?? [];
|
|
313
|
+
const slot = existing.findIndex((r) => r.emoji === emoji);
|
|
314
|
+
let next;
|
|
315
|
+
if (count <= 0) next = existing.filter((r) => r.emoji !== emoji);
|
|
316
|
+
else if (slot === -1) next = [...existing, { emoji, count }];
|
|
317
|
+
else next = existing.map((r, i) => i === slot ? { emoji, count } : r);
|
|
318
|
+
this.items[at] = { ...current, reactions: next };
|
|
319
|
+
this.changed();
|
|
320
|
+
}
|
|
321
|
+
/** Applies a `thread.updated` to the root message's aggregate. */
|
|
322
|
+
thread(rootId, count, lastSeq) {
|
|
323
|
+
const at = this.items.findIndex((m) => m.id === rootId);
|
|
324
|
+
if (at === -1) return;
|
|
325
|
+
const current = this.items[at];
|
|
326
|
+
this.items[at] = { ...current, thread: lastSeq === void 0 ? { count } : { count, lastSeq } };
|
|
327
|
+
this.changed();
|
|
328
|
+
}
|
|
329
|
+
/** Applies a `message.deleted`, remembering it if the row is not here. */
|
|
330
|
+
remove(id) {
|
|
331
|
+
if (!this.applyDelete(id)) this.pendingDeletes.add(id);
|
|
332
|
+
}
|
|
333
|
+
/**
|
|
334
|
+
* Fetches everything between what we have and `upTo`.
|
|
335
|
+
*
|
|
336
|
+
* Used after a reconnect, where the ack's `lastSeq` says how far the
|
|
337
|
+
* room got while we were away.
|
|
338
|
+
*/
|
|
339
|
+
async catchUp(upTo) {
|
|
340
|
+
const from = this.contiguousSeq;
|
|
341
|
+
if (upTo <= from) return;
|
|
342
|
+
await this.fill(from, upTo + 1);
|
|
343
|
+
}
|
|
344
|
+
/**
|
|
345
|
+
* Walks a range, page by page, and inserts what comes back.
|
|
346
|
+
*
|
|
347
|
+
* The loop is the point. `GET .../messages` caps `limit` at 100, so a
|
|
348
|
+
* client that was away while a busy room moved 250 messages gets a
|
|
349
|
+
* third of its gap from one call -- and a single-call implementation
|
|
350
|
+
* would believe the hole was filled. That failure is silent: the list
|
|
351
|
+
* looks continuous because the missing rows were never known about.
|
|
352
|
+
*/
|
|
353
|
+
fill(after, before) {
|
|
354
|
+
const key = `${after}:${before}`;
|
|
355
|
+
const existing = this.fills.get(key);
|
|
356
|
+
if (existing !== void 0) return existing;
|
|
357
|
+
const epoch = this.epoch;
|
|
358
|
+
const pageSize = this.options.pageSize ?? MaxPageSize;
|
|
359
|
+
const run = (async () => {
|
|
360
|
+
let cursor = after;
|
|
361
|
+
while (cursor < before - 1) {
|
|
362
|
+
const page = await this.options.fetchRange({ after: cursor, before, limit: pageSize });
|
|
363
|
+
if (this.epoch !== epoch) return;
|
|
364
|
+
if (page.length === 0) return;
|
|
365
|
+
for (const m of page) this.insert(m);
|
|
366
|
+
const highest = page.at(-1)?.seq ?? cursor;
|
|
367
|
+
if (highest <= cursor) return;
|
|
368
|
+
cursor = highest;
|
|
369
|
+
}
|
|
370
|
+
})().finally(() => {
|
|
371
|
+
if (this.fills.get(key) === run) this.fills.delete(key);
|
|
372
|
+
});
|
|
373
|
+
this.fills.set(key, run);
|
|
374
|
+
return run;
|
|
375
|
+
}
|
|
376
|
+
/** Inserts at the seq position, replacing an existing row with that seq. */
|
|
377
|
+
insert(message) {
|
|
378
|
+
const pending = this.pendingDeletes.has(message.id);
|
|
379
|
+
if (pending) this.pendingDeletes.delete(message.id);
|
|
380
|
+
const withDelete = pending ? emptied(message) : message;
|
|
381
|
+
const at = this.items.findIndex((m) => m.seq >= withDelete.seq);
|
|
382
|
+
if (at === -1) {
|
|
383
|
+
this.items.push(withDelete);
|
|
384
|
+
} else if (this.items[at]?.seq === withDelete.seq) {
|
|
385
|
+
const current = this.items[at];
|
|
386
|
+
this.items[at] = current.deletedAt !== void 0 ? current : withDelete;
|
|
387
|
+
} else {
|
|
388
|
+
this.items.splice(at, 0, withDelete);
|
|
389
|
+
}
|
|
390
|
+
this.changed();
|
|
391
|
+
}
|
|
392
|
+
applyDelete(id) {
|
|
393
|
+
const at = this.items.findIndex((m) => m.id === id);
|
|
394
|
+
if (at === -1) return false;
|
|
395
|
+
this.pendingDeletes.delete(id);
|
|
396
|
+
this.items[at] = emptied(this.items[at]);
|
|
397
|
+
this.changed();
|
|
398
|
+
return true;
|
|
399
|
+
}
|
|
400
|
+
changed() {
|
|
401
|
+
this.options.onChange?.(this.items);
|
|
402
|
+
}
|
|
403
|
+
};
|
|
404
|
+
function emptied(message) {
|
|
405
|
+
return { ...message, body: {}, deletedAt: message.deletedAt ?? Date.now() };
|
|
406
|
+
}
|
|
407
|
+
function createTimeline(options) {
|
|
408
|
+
return new Timeline(options);
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// src/room.ts
|
|
412
|
+
var HistoryPageSize = 100;
|
|
413
|
+
var Room = class extends Emitter {
|
|
414
|
+
/** Resolved on subscribe when the room was addressed by key. */
|
|
415
|
+
id;
|
|
416
|
+
key;
|
|
417
|
+
/** The last seq the server reported for this room. */
|
|
418
|
+
lastSeq = 0;
|
|
419
|
+
presence;
|
|
420
|
+
chat;
|
|
421
|
+
rest;
|
|
422
|
+
timeline;
|
|
423
|
+
/** The live feed is on and history is loaded. */
|
|
424
|
+
subscribed = false;
|
|
425
|
+
/**
|
|
426
|
+
* Invalidates work in flight.
|
|
427
|
+
*
|
|
428
|
+
* A subscribe is several awaits long -- the frame, then a history read
|
|
429
|
+
* -- and the socket can die in the middle of it. The promise that was
|
|
430
|
+
* in flight then completes against a **dead socket's** ack and marks
|
|
431
|
+
* the room subscribed, while the new socket has never heard of it. The
|
|
432
|
+
* room is then live nowhere and believes it is live, and no public
|
|
433
|
+
* call fixes that because every entry point short-circuits on the flag
|
|
434
|
+
* it just set.
|
|
435
|
+
*
|
|
436
|
+
* Each attempt carries the generation it started in and applies
|
|
437
|
+
* nothing once that has moved.
|
|
438
|
+
*/
|
|
439
|
+
generation = 0;
|
|
440
|
+
/**
|
|
441
|
+
* A subscribe was asked for at least once, whether or not it finished.
|
|
442
|
+
*
|
|
443
|
+
* `subscribed` cannot carry this. A catch-up that fails leaves a room
|
|
444
|
+
* that is *not* subscribed, and if that were the only flag a reconnect
|
|
445
|
+
* would skip it -- the room drops out of the live feed permanently and
|
|
446
|
+
* silently, which is worse than the failure that started it.
|
|
447
|
+
*/
|
|
448
|
+
wanted = false;
|
|
449
|
+
subscribing;
|
|
450
|
+
constructor(chat, rest, address) {
|
|
451
|
+
super();
|
|
452
|
+
this.chat = chat;
|
|
453
|
+
this.rest = rest;
|
|
454
|
+
this.id = address.id;
|
|
455
|
+
this.key = address.key;
|
|
456
|
+
this.timeline = createTimeline({
|
|
457
|
+
fetchRange: ({ after, before, limit }) => this.rest.get(`/v1/rooms/${this.id}/messages`, {
|
|
458
|
+
after,
|
|
459
|
+
before,
|
|
460
|
+
limit
|
|
461
|
+
}).then((page) => page.messages),
|
|
462
|
+
onChange: (messages) => this.emit("messages", messages)
|
|
463
|
+
});
|
|
464
|
+
}
|
|
465
|
+
/** Everything this client knows about the room, in seq order. */
|
|
466
|
+
get messages() {
|
|
467
|
+
return this.timeline.messages;
|
|
468
|
+
}
|
|
469
|
+
/**
|
|
470
|
+
* Reloads recent history, discarding what is held.
|
|
471
|
+
*
|
|
472
|
+
* A reconnect recovers the **message timeline** and nothing else: the
|
|
473
|
+
* only history route is `GET .../messages`, and reactions, thread
|
|
474
|
+
* counts, read cursors, membership and room metadata ride along only
|
|
475
|
+
* for the rows that page happens to contain. So a reaction added to an
|
|
476
|
+
* older message while this client was away is never seen, and no
|
|
477
|
+
* amount of gap filling will find it.
|
|
478
|
+
*
|
|
479
|
+
* The SDK does not hide that behind a background refresh, because the
|
|
480
|
+
* refresh would have to be either wasteful or wrong. It gives the
|
|
481
|
+
* consumer the one honest tool -- ask again -- and says when to use it.
|
|
482
|
+
*/
|
|
483
|
+
async reload() {
|
|
484
|
+
const gen = this.generation;
|
|
485
|
+
await this.loadRecent(() => gen === this.generation);
|
|
486
|
+
}
|
|
487
|
+
/**
|
|
488
|
+
* Starts the live feed and loads recent history.
|
|
489
|
+
*
|
|
490
|
+
* Both halves, because neither is enough: `subscribe` does not replay
|
|
491
|
+
* anything that already happened, and a history read has no way to
|
|
492
|
+
* learn about what happens next. A room that only did the first looks
|
|
493
|
+
* empty until somebody speaks.
|
|
494
|
+
*/
|
|
495
|
+
async subscribe() {
|
|
496
|
+
this.wanted = true;
|
|
497
|
+
if (this.subscribing !== void 0) return this.subscribing;
|
|
498
|
+
if (this.subscribed) return;
|
|
499
|
+
const attempt = this.doSubscribe().finally(() => {
|
|
500
|
+
if (this.subscribing === attempt) this.subscribing = void 0;
|
|
501
|
+
});
|
|
502
|
+
this.subscribing = attempt;
|
|
503
|
+
return attempt;
|
|
504
|
+
}
|
|
505
|
+
async doSubscribe() {
|
|
506
|
+
const gen = this.generation;
|
|
507
|
+
const current = () => gen === this.generation;
|
|
508
|
+
const data = this.id !== void 0 ? { roomId: this.id } : { key: this.key };
|
|
509
|
+
const since = this.timeline.contiguousSeq;
|
|
510
|
+
if (since > 0) data["since"] = since;
|
|
511
|
+
const attempt = await this.subscribeFrame(data);
|
|
512
|
+
const ack = attempt.ack;
|
|
513
|
+
if (!current()) return;
|
|
514
|
+
this.id = ack.roomId;
|
|
515
|
+
this.chat.registerRoomId(ack.roomId, this);
|
|
516
|
+
this.lastSeq = ack.lastSeq;
|
|
517
|
+
if (ack.presence !== void 0) this.presence = ack.presence;
|
|
518
|
+
if (attempt.wasReset) {
|
|
519
|
+
await this.loadRecent(current);
|
|
520
|
+
if (!current()) return;
|
|
521
|
+
this.subscribed = true;
|
|
522
|
+
this.emit("reset", this.timeline.messages);
|
|
523
|
+
return;
|
|
524
|
+
}
|
|
525
|
+
if (since > 0) {
|
|
526
|
+
await this.timeline.catchUp(ack.lastSeq);
|
|
527
|
+
if (!current()) return;
|
|
528
|
+
this.subscribed = true;
|
|
529
|
+
return;
|
|
530
|
+
}
|
|
531
|
+
if (ack.lastSeq > 0) {
|
|
532
|
+
await this.loadRecent(current);
|
|
533
|
+
}
|
|
534
|
+
if (!current()) return;
|
|
535
|
+
this.subscribed = true;
|
|
536
|
+
}
|
|
537
|
+
/**
|
|
538
|
+
* Reads a page of history directly, without touching `messages`.
|
|
539
|
+
*
|
|
540
|
+
* The list this room keeps is the live one; this is for a consumer
|
|
541
|
+
* scrolling back, which owns its own window and does not want the
|
|
542
|
+
* bottom of the room rearranged under it.
|
|
543
|
+
*
|
|
544
|
+
* `view` defaults to the server's, which is every message including
|
|
545
|
+
* thread replies. **`view: 'main'` is a display filter, not a sync
|
|
546
|
+
* cursor** -- its last seq skips replies, so feeding it back as
|
|
547
|
+
* `since` would ask the server to resend every reply since.
|
|
548
|
+
*/
|
|
549
|
+
async history(options = {}) {
|
|
550
|
+
const view = options.view === void 0 ? void 0 : typeof options.view === "string" ? options.view : `thread:${options.view.thread}`;
|
|
551
|
+
const page = await this.rest.get(`/v1/rooms/${this.requireId("reading history")}/messages`, {
|
|
552
|
+
before: options.before,
|
|
553
|
+
after: options.after,
|
|
554
|
+
limit: options.limit,
|
|
555
|
+
view
|
|
556
|
+
});
|
|
557
|
+
return page.messages;
|
|
558
|
+
}
|
|
559
|
+
/** Adds a reaction. Idempotent, like the frame. */
|
|
560
|
+
async react(messageId, emoji) {
|
|
561
|
+
await this.chat.send("react", { roomId: this.requireId("reacting"), messageId, emoji, op: "add" });
|
|
562
|
+
}
|
|
563
|
+
async unreact(messageId, emoji) {
|
|
564
|
+
await this.chat.send("react", { roomId: this.requireId("reacting"), messageId, emoji, op: "remove" });
|
|
565
|
+
}
|
|
566
|
+
/**
|
|
567
|
+
* Moves this user's read cursor.
|
|
568
|
+
*
|
|
569
|
+
* The cursor only moves forward: the server ignores a lower value and
|
|
570
|
+
* **does not call that an error**, so the ack's `advanced` is the only
|
|
571
|
+
* way to know what happened. It is returned rather than swallowed.
|
|
572
|
+
*/
|
|
573
|
+
async markRead(seq, options = {}) {
|
|
574
|
+
const data = { roomId: this.requireId("marking read"), seq };
|
|
575
|
+
if (options.threadId !== void 0) data["threadId"] = options.threadId;
|
|
576
|
+
return await this.chat.send("read", data);
|
|
577
|
+
}
|
|
578
|
+
/**
|
|
579
|
+
* Says this user is typing.
|
|
580
|
+
*
|
|
581
|
+
* Fire and forget: the frame is ephemeral, the server throttles it,
|
|
582
|
+
* and a caller awaiting an ack on every keystroke would be doing the
|
|
583
|
+
* one thing this event is designed to avoid.
|
|
584
|
+
*
|
|
585
|
+
* **The event comes back to the sender too.** The server broadcasts it
|
|
586
|
+
* to the room without excluding whoever sent it, so a consumer drawing
|
|
587
|
+
* "X is typing" has to skip its own `userId` -- the SDK does not
|
|
588
|
+
* filter it out, because a room view that shows every typist and one
|
|
589
|
+
* that shows everyone else are both legitimate and only the consumer
|
|
590
|
+
* knows which it is drawing.
|
|
591
|
+
*/
|
|
592
|
+
typing() {
|
|
593
|
+
try {
|
|
594
|
+
void this.chat.send("typing", { roomId: this.requireId("typing") }).catch(() => {
|
|
595
|
+
});
|
|
596
|
+
} catch {
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
/** Joins a public or channel room. Idempotent. */
|
|
600
|
+
async join() {
|
|
601
|
+
await this.rest.post(`/v1/rooms/${this.requireId("joining")}/join`);
|
|
602
|
+
}
|
|
603
|
+
/** Leaves. Idempotent from the caller's side. */
|
|
604
|
+
async leave() {
|
|
605
|
+
await this.rest.delete(`/v1/rooms/${this.requireId("leaving")}/members/me`);
|
|
606
|
+
}
|
|
607
|
+
/** The full presence list, for rooms too large to send it in the ack. */
|
|
608
|
+
async presenceList() {
|
|
609
|
+
return this.rest.get(`/v1/rooms/${this.requireId("reading presence")}/presence`, { full: "true" });
|
|
610
|
+
}
|
|
611
|
+
/**
|
|
612
|
+
* Who reacted, one row per (user, emoji) pair.
|
|
613
|
+
*
|
|
614
|
+
* A person who put three emoji on one message is three rows, and the
|
|
615
|
+
* cursor pages over pairs for that reason: keyed on the user alone, a
|
|
616
|
+
* page boundary falling inside someone's set would drop the rest of
|
|
617
|
+
* their emoji from every page. `limit` is 50 by default and 100 at
|
|
618
|
+
* most, and a full page still carries a cursor when more follows.
|
|
619
|
+
*/
|
|
620
|
+
async reactionsOf(messageId, options = {}) {
|
|
621
|
+
return this.rest.get(`/v1/rooms/${this.requireId("reading reactions")}/messages/${messageId}/reactions`, {
|
|
622
|
+
emoji: options.emoji,
|
|
623
|
+
cursor: options.cursor,
|
|
624
|
+
limit: options.limit
|
|
625
|
+
});
|
|
626
|
+
}
|
|
627
|
+
/**
|
|
628
|
+
* Re-subscribes after a reconnect. Called by the client.
|
|
629
|
+
*
|
|
630
|
+
* The test is "was this room ever asked for", not "did the last
|
|
631
|
+
* subscribe finish". A room whose catch-up failed is in exactly the
|
|
632
|
+
* state that needs another try, and skipping it there is how a single
|
|
633
|
+
* transient 500 takes a room out of the live feed for the life of the
|
|
634
|
+
* page.
|
|
635
|
+
*/
|
|
636
|
+
async resume() {
|
|
637
|
+
if (!this.wanted) return;
|
|
638
|
+
this.generation++;
|
|
639
|
+
this.subscribing = void 0;
|
|
640
|
+
this.subscribed = false;
|
|
641
|
+
await this.subscribe();
|
|
642
|
+
}
|
|
643
|
+
/**
|
|
644
|
+
* Sends `subscribe` and recovers from the one error it has an answer
|
|
645
|
+
* for.
|
|
646
|
+
*
|
|
647
|
+
* `cursor_too_old` means the room's retention has passed the point
|
|
648
|
+
* this client last saw: the server cannot bridge the gap and neither
|
|
649
|
+
* can we. The only honest response is to drop what we hold and start
|
|
650
|
+
* from recent history -- and to **say so**, because a consumer that
|
|
651
|
+
* quietly appends onto its old list keeps a hole forever.
|
|
652
|
+
*/
|
|
653
|
+
async subscribeFrame(data) {
|
|
654
|
+
try {
|
|
655
|
+
return { ack: await this.chat.send("subscribe", data), wasReset: false };
|
|
656
|
+
} catch (err) {
|
|
657
|
+
if (err.code !== "cursor_too_old") throw err;
|
|
658
|
+
this.timeline.discard();
|
|
659
|
+
const retry = { ...data };
|
|
660
|
+
delete retry["since"];
|
|
661
|
+
return { ack: await this.chat.send("subscribe", retry), wasReset: true };
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
async unsubscribe() {
|
|
665
|
+
if (this.id === void 0) return;
|
|
666
|
+
this.wanted = false;
|
|
667
|
+
this.subscribed = false;
|
|
668
|
+
this.generation++;
|
|
669
|
+
this.subscribing = void 0;
|
|
670
|
+
await this.chat.send("unsubscribe", { roomId: this.id });
|
|
671
|
+
}
|
|
672
|
+
/**
|
|
673
|
+
* The room's id, or a refusal.
|
|
674
|
+
*
|
|
675
|
+
* A room addressed by key has no id until it subscribes, and every
|
|
676
|
+
* route below builds a path out of it. Without this the path is
|
|
677
|
+
* `/v1/rooms/undefined/...` and the frame carries `roomId: undefined`
|
|
678
|
+
* -- a 404 or a validation error that says nothing about the actual
|
|
679
|
+
* mistake.
|
|
680
|
+
*/
|
|
681
|
+
requireId(what) {
|
|
682
|
+
if (this.id === void 0) {
|
|
683
|
+
throw new ChatError("closed", `subscribe to the room before ${what}: its id is not known yet`);
|
|
684
|
+
}
|
|
685
|
+
return this.id;
|
|
686
|
+
}
|
|
687
|
+
/** Publishes and resolves when the server acks. */
|
|
688
|
+
async send(input, options = {}) {
|
|
689
|
+
const roomId = this.requireId("sending");
|
|
690
|
+
const body = {};
|
|
691
|
+
if (input.text !== void 0) body["text"] = input.text;
|
|
692
|
+
if (input.entities !== void 0) body["entities"] = input.entities;
|
|
693
|
+
if (input.attachments !== void 0) body["attachments"] = input.attachments;
|
|
694
|
+
const frame = {
|
|
695
|
+
roomId,
|
|
696
|
+
clientMessageId: options.clientMessageId ?? newClientMessageId(),
|
|
697
|
+
body
|
|
698
|
+
};
|
|
699
|
+
if (input.replyTo !== void 0) frame["replyTo"] = input.replyTo;
|
|
700
|
+
if (input.threadId !== void 0) frame["threadId"] = input.threadId;
|
|
701
|
+
if (input.meta !== void 0) frame["meta"] = input.meta;
|
|
702
|
+
return await this.chat.send("publish", frame);
|
|
703
|
+
}
|
|
704
|
+
/** Routes a frame the client decided belongs to this room. */
|
|
705
|
+
handle(type, data) {
|
|
706
|
+
switch (type) {
|
|
707
|
+
case "message.created":
|
|
708
|
+
if (data.seq > this.lastSeq) this.lastSeq = data.seq;
|
|
709
|
+
this.timeline.add(data).catch((err) => {
|
|
710
|
+
this.subscribed = false;
|
|
711
|
+
this.emit("error", err instanceof Error ? err : new Error(String(err)));
|
|
712
|
+
});
|
|
713
|
+
break;
|
|
714
|
+
case "message.updated":
|
|
715
|
+
this.timeline.update(data);
|
|
716
|
+
break;
|
|
717
|
+
case "message.deleted": {
|
|
718
|
+
const deleted = data;
|
|
719
|
+
if (deleted.seq !== void 0 && deleted.seq > this.lastSeq) this.lastSeq = deleted.seq;
|
|
720
|
+
this.timeline.remove(deleted.id);
|
|
721
|
+
break;
|
|
722
|
+
}
|
|
723
|
+
case "reaction.added":
|
|
724
|
+
case "reaction.removed": {
|
|
725
|
+
const r = data;
|
|
726
|
+
this.timeline.reaction(r.messageId, r.emoji, r.count);
|
|
727
|
+
break;
|
|
728
|
+
}
|
|
729
|
+
case "thread.updated": {
|
|
730
|
+
const t = data;
|
|
731
|
+
this.timeline.thread(t.rootId, t.count, t.lastSeq);
|
|
732
|
+
break;
|
|
733
|
+
}
|
|
734
|
+
case "presence":
|
|
735
|
+
this.presence = data;
|
|
736
|
+
break;
|
|
737
|
+
default:
|
|
738
|
+
break;
|
|
739
|
+
}
|
|
740
|
+
this.emit(type, data);
|
|
741
|
+
}
|
|
742
|
+
/**
|
|
743
|
+
* Replaces the timeline with the most recent page.
|
|
744
|
+
*
|
|
745
|
+
* `stillCurrent` guards the write, not the read. This is a replacement
|
|
746
|
+
* -- the whole point of it -- so a page fetched for a socket that has
|
|
747
|
+
* since died would throw away everything the socket that replaced it
|
|
748
|
+
* has already loaded. Marking the room subscribed is guarded for the
|
|
749
|
+
* same reason one step later; both are the same await, and the guard
|
|
750
|
+
* has to cover the part that touches shared state.
|
|
751
|
+
*/
|
|
752
|
+
async loadRecent(stillCurrent) {
|
|
753
|
+
const page = await this.rest.get(`/v1/rooms/${this.requireId("reading history")}/messages`, {
|
|
754
|
+
limit: HistoryPageSize
|
|
755
|
+
});
|
|
756
|
+
if (!stillCurrent()) return;
|
|
757
|
+
this.timeline.reset(page.messages);
|
|
758
|
+
}
|
|
759
|
+
};
|
|
760
|
+
function newClientMessageId() {
|
|
761
|
+
const c = globalThis.crypto;
|
|
762
|
+
if (c?.randomUUID !== void 0) return `cm-${c.randomUUID()}`;
|
|
763
|
+
return `cm-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
// src/client.ts
|
|
767
|
+
var MinBackoffMs = 500;
|
|
768
|
+
var MaxBackoffMs = 3e4;
|
|
769
|
+
var ChatClient = class extends Emitter {
|
|
770
|
+
state = "closed";
|
|
771
|
+
user;
|
|
772
|
+
connectionId;
|
|
773
|
+
hello;
|
|
774
|
+
options;
|
|
775
|
+
makeSocket;
|
|
776
|
+
socket;
|
|
777
|
+
heartbeat;
|
|
778
|
+
retryTimer;
|
|
779
|
+
attempt = 0;
|
|
780
|
+
/** Set by close(), and the only thing that stops the reconnect loop. */
|
|
781
|
+
closedByCaller = false;
|
|
782
|
+
nextFrameId = 0;
|
|
783
|
+
opening;
|
|
784
|
+
rest;
|
|
785
|
+
/**
|
|
786
|
+
* Room handles this client has handed out, keyed by how they were
|
|
787
|
+
* asked for. Nothing is ever removed: a handle a consumer holds has to
|
|
788
|
+
* keep being resumed after a reconnect.
|
|
789
|
+
*/
|
|
790
|
+
handles = /* @__PURE__ */ new Map();
|
|
791
|
+
/**
|
|
792
|
+
* Which handle a push frame for a given room id goes to.
|
|
793
|
+
*
|
|
794
|
+
* Separate from `handles` because the two questions are different.
|
|
795
|
+
* Filing a resolved id **into** `handles` meant a second handle for
|
|
796
|
+
* the same room evicted the first -- and an evicted handle is not just
|
|
797
|
+
* unrouted, it stops being resumed at all, which is worse than the
|
|
798
|
+
* problem the eviction was solving.
|
|
799
|
+
*/
|
|
800
|
+
routes = /* @__PURE__ */ new Map();
|
|
801
|
+
/** The token most recently sent, so REST can present the same one. */
|
|
802
|
+
lastToken;
|
|
803
|
+
/** True once a socket has reached `hello`, so the next one is a return. */
|
|
804
|
+
everOpened = false;
|
|
805
|
+
constructor(options) {
|
|
806
|
+
super();
|
|
807
|
+
this.options = options;
|
|
808
|
+
const ctor = options.WebSocket ?? globalThis.WebSocket;
|
|
809
|
+
if (ctor === void 0) {
|
|
810
|
+
throw new TypeError(
|
|
811
|
+
"no WebSocket available: pass one as options.WebSocket (Node 22 and every current browser have a global)"
|
|
812
|
+
);
|
|
813
|
+
}
|
|
814
|
+
this.makeSocket = ctor;
|
|
815
|
+
this.rest = new Rest({
|
|
816
|
+
url: options.restURL ?? restURLFrom(options.url),
|
|
817
|
+
key: options.key,
|
|
818
|
+
token: async () => this.lastToken,
|
|
819
|
+
fetch: options.fetch ?? globalThis.fetch.bind(globalThis)
|
|
820
|
+
});
|
|
821
|
+
}
|
|
822
|
+
/** The account-level routes: the rooms this user is in, and unread. */
|
|
823
|
+
get rooms() {
|
|
824
|
+
return {
|
|
825
|
+
list: (options) => this.rest.get("/v1/me/rooms", { cursor: options?.cursor, limit: options?.limit }),
|
|
826
|
+
discover: (options) => this.rest.get("/v1/rooms", { type: options?.type, cursor: options?.cursor, limit: options?.limit }),
|
|
827
|
+
members: (roomId) => ({
|
|
828
|
+
list: (options) => this.rest.get(`/v1/rooms/${roomId}/members`, { cursor: options?.cursor, limit: options?.limit }),
|
|
829
|
+
add: async (userId, role) => {
|
|
830
|
+
await this.rest.put(`/v1/rooms/${roomId}/members/${userId}`, role === void 0 ? {} : { role });
|
|
831
|
+
},
|
|
832
|
+
remove: async (userId) => {
|
|
833
|
+
await this.rest.delete(`/v1/rooms/${roomId}/members/${userId}`);
|
|
834
|
+
}
|
|
835
|
+
})
|
|
836
|
+
};
|
|
837
|
+
}
|
|
838
|
+
/**
|
|
839
|
+
* The unread totals across every room this user is a member of.
|
|
840
|
+
*
|
|
841
|
+
* The field names are the route's own: `unread` and `unreadMentions`,
|
|
842
|
+
* **not** the `total`/`mentions` that `hello.unread` uses for the same
|
|
843
|
+
* two numbers. The server says it both ways and the SDK does not pick
|
|
844
|
+
* a third; renaming here would mean a consumer reading the protocol
|
|
845
|
+
* document finds a name that does not exist in either place.
|
|
846
|
+
*
|
|
847
|
+
* `roomsCapped` means the totals cover a subset -- this route does not
|
|
848
|
+
* page, so past 500 rooms it stops counting and says so.
|
|
849
|
+
*/
|
|
850
|
+
async unread() {
|
|
851
|
+
return this.rest.get("/v1/me/unread");
|
|
852
|
+
}
|
|
853
|
+
/**
|
|
854
|
+
* Updates this user's own profile.
|
|
855
|
+
*
|
|
856
|
+
* **`meta` only.** The spec's example is `me.update({ name })`, and the
|
|
857
|
+
* server answers 400 to `name` and `avatar` on this route: a name comes
|
|
858
|
+
* from the token the consumer's backend signs, or from `PUT
|
|
859
|
+
* /users/{id}` with an `sk_`. A signature that always fails is not an
|
|
860
|
+
* API, it is a trap, so it is not offered.
|
|
861
|
+
*
|
|
862
|
+
* The merge is one level deep and a `null` deletes its key -- a nested
|
|
863
|
+
* object is **replaced**, not merged into.
|
|
864
|
+
*/
|
|
865
|
+
async updateMe(meta) {
|
|
866
|
+
return this.rest.patch("/v1/me", { meta });
|
|
867
|
+
}
|
|
868
|
+
/**
|
|
869
|
+
* A handle on a room by id.
|
|
870
|
+
*
|
|
871
|
+
* Handles are cached, so two callers asking for the same room get the
|
|
872
|
+
* same object and the same message list -- two lists of one room would
|
|
873
|
+
* drift the moment one of them filled a gap.
|
|
874
|
+
*/
|
|
875
|
+
room(roomId) {
|
|
876
|
+
const routed = this.routes.get(roomId);
|
|
877
|
+
if (routed !== void 0) return routed;
|
|
878
|
+
return this.roomHandle(roomId, { id: roomId });
|
|
879
|
+
}
|
|
880
|
+
/** The same, addressed by the app's own key. Resolved on subscribe. */
|
|
881
|
+
roomByKey(key) {
|
|
882
|
+
return this.roomHandle(`key:${key}`, { key });
|
|
883
|
+
}
|
|
884
|
+
roomHandle(cacheKey, address) {
|
|
885
|
+
const existing = this.handles.get(cacheKey);
|
|
886
|
+
if (existing !== void 0) return existing;
|
|
887
|
+
const room = new Room(this, this.rest, address);
|
|
888
|
+
this.handles.set(cacheKey, room);
|
|
889
|
+
return room;
|
|
890
|
+
}
|
|
891
|
+
/**
|
|
892
|
+
* Files a room under its id once `subscribe` has resolved one.
|
|
893
|
+
*
|
|
894
|
+
* A room addressed by key is filed under `key:<key>`, and every push
|
|
895
|
+
* frame names a room by **id** -- so without this the handle is
|
|
896
|
+
* unreachable from the read loop and a `roomByKey` room receives its
|
|
897
|
+
* hundred messages of history and then nothing, forever. The events do
|
|
898
|
+
* not vanish silently: they come out of `chat.on('frame')` as
|
|
899
|
+
* unrouted, which is how this was found.
|
|
900
|
+
*
|
|
901
|
+
* Registering under both keys also makes `chat.room(id)` and
|
|
902
|
+
* `chat.roomByKey(key)` return the **same** handle for the same room,
|
|
903
|
+
* which is the property `roomHandle` exists for -- two lists of one
|
|
904
|
+
* room drift the moment either fills a gap.
|
|
905
|
+
*/
|
|
906
|
+
registerRoomId(roomId, room) {
|
|
907
|
+
this.routes.set(roomId, room);
|
|
908
|
+
}
|
|
909
|
+
/** Opens the connection and resolves when `hello` arrives. */
|
|
910
|
+
async connect() {
|
|
911
|
+
this.closedByCaller = false;
|
|
912
|
+
if (this.opening !== void 0) return this.opening;
|
|
913
|
+
if (this.state === "open" && this.socket !== void 0) return;
|
|
914
|
+
this.clearTimers();
|
|
915
|
+
this.opening = this.openOnce().finally(() => {
|
|
916
|
+
this.opening = void 0;
|
|
917
|
+
});
|
|
918
|
+
return this.opening;
|
|
919
|
+
}
|
|
920
|
+
/**
|
|
921
|
+
* Closes for good.
|
|
922
|
+
*
|
|
923
|
+
* The distinction from a dropped socket is the whole point of the state
|
|
924
|
+
* machine: this is the only path that reaches `closed`, and a consumer
|
|
925
|
+
* showing "disconnected, retrying" versus "disconnected" needs it.
|
|
926
|
+
*/
|
|
927
|
+
async close() {
|
|
928
|
+
this.closedByCaller = true;
|
|
929
|
+
this.clearTimers();
|
|
930
|
+
const socket = this.socket;
|
|
931
|
+
this.socket = void 0;
|
|
932
|
+
if (socket !== void 0) {
|
|
933
|
+
try {
|
|
934
|
+
socket.close(1e3, "client closed");
|
|
935
|
+
} catch {
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
this.setState("closed");
|
|
939
|
+
}
|
|
940
|
+
/** Sends a frame and resolves with its ack, or rejects with its error. */
|
|
941
|
+
send(type, data, timeoutMs = 15e3) {
|
|
942
|
+
const socket = this.socket;
|
|
943
|
+
if (socket === void 0 || this.state !== "open") {
|
|
944
|
+
return Promise.reject(new ChatError("closed", `cannot send ${type}: the connection is ${this.state}`));
|
|
945
|
+
}
|
|
946
|
+
const id = `c${++this.nextFrameId}`;
|
|
947
|
+
return new Promise((resolve, reject) => {
|
|
948
|
+
const timer = setTimeout(() => {
|
|
949
|
+
this.pending.delete(id);
|
|
950
|
+
reject(new ChatError("timeout", `no ack for ${type} within ${timeoutMs}ms`));
|
|
951
|
+
}, timeoutMs);
|
|
952
|
+
this.pending.set(id, {
|
|
953
|
+
resolve: (value) => {
|
|
954
|
+
clearTimeout(timer);
|
|
955
|
+
resolve(value);
|
|
956
|
+
},
|
|
957
|
+
reject: (err) => {
|
|
958
|
+
clearTimeout(timer);
|
|
959
|
+
reject(err);
|
|
960
|
+
}
|
|
961
|
+
});
|
|
962
|
+
socket.send(JSON.stringify({ type, id, data }));
|
|
963
|
+
});
|
|
964
|
+
}
|
|
965
|
+
/**
|
|
966
|
+
* Frames waiting for an ack, by frame id.
|
|
967
|
+
*
|
|
968
|
+
* A table rather than "the next reply is mine": the protocol lets a
|
|
969
|
+
* client have several frames in flight, and the server answers each
|
|
970
|
+
* with the id it was given. Assuming order would pair a publish's ack
|
|
971
|
+
* with a react's.
|
|
972
|
+
*/
|
|
973
|
+
pending = /* @__PURE__ */ new Map();
|
|
974
|
+
setState(next) {
|
|
975
|
+
if (this.state === next) return;
|
|
976
|
+
this.state = next;
|
|
977
|
+
this.emit("state", next);
|
|
978
|
+
}
|
|
979
|
+
async openOnce() {
|
|
980
|
+
this.setState(this.attempt === 0 ? "connecting" : "reconnecting");
|
|
981
|
+
const authData = await this.authData();
|
|
982
|
+
if (this.closedByCaller) {
|
|
983
|
+
throw new ChatError("closed", "the client was closed while connecting");
|
|
984
|
+
}
|
|
985
|
+
const socket = new this.makeSocket(`${this.options.url}/v1/ws?v=1`);
|
|
986
|
+
this.socket = socket;
|
|
987
|
+
return new Promise((resolve, reject) => {
|
|
988
|
+
let settled = false;
|
|
989
|
+
const finish = (err) => {
|
|
990
|
+
if (settled) return;
|
|
991
|
+
settled = true;
|
|
992
|
+
if (err === void 0) resolve();
|
|
993
|
+
else reject(err);
|
|
994
|
+
};
|
|
995
|
+
socket.addEventListener("message", (ev) => {
|
|
996
|
+
const frame = JSON.parse(String(ev.data));
|
|
997
|
+
if (frame.type === "hello") {
|
|
998
|
+
this.onHello(frame.data);
|
|
999
|
+
finish();
|
|
1000
|
+
return;
|
|
1001
|
+
}
|
|
1002
|
+
if (frame.type === "error" && !settled) {
|
|
1003
|
+
this.closedByCaller = true;
|
|
1004
|
+
this.setState("closed");
|
|
1005
|
+
finish(errorFrom(frame.data));
|
|
1006
|
+
return;
|
|
1007
|
+
}
|
|
1008
|
+
this.onFrame(frame);
|
|
1009
|
+
});
|
|
1010
|
+
socket.addEventListener("close", () => {
|
|
1011
|
+
if (this.socket !== void 0 && this.socket !== socket) return;
|
|
1012
|
+
this.clearTimers();
|
|
1013
|
+
this.socket = void 0;
|
|
1014
|
+
this.failPending(new ChatError("closed", "the connection closed"));
|
|
1015
|
+
if (settled) {
|
|
1016
|
+
this.scheduleReconnect();
|
|
1017
|
+
} else {
|
|
1018
|
+
finish(new ChatError("closed", "the connection closed before hello"));
|
|
1019
|
+
this.scheduleReconnect();
|
|
1020
|
+
}
|
|
1021
|
+
});
|
|
1022
|
+
socket.addEventListener("error", () => {
|
|
1023
|
+
});
|
|
1024
|
+
socket.addEventListener("open", () => {
|
|
1025
|
+
socket.send(JSON.stringify({ type: "auth", id: "auth", data: authData }));
|
|
1026
|
+
});
|
|
1027
|
+
});
|
|
1028
|
+
}
|
|
1029
|
+
onHello(hello) {
|
|
1030
|
+
this.hello = hello;
|
|
1031
|
+
this.user = hello.user;
|
|
1032
|
+
this.connectionId = hello.connectionId;
|
|
1033
|
+
this.attempt = 0;
|
|
1034
|
+
this.startHeartbeat(hello.heartbeatMs);
|
|
1035
|
+
const reconnected = this.everOpened;
|
|
1036
|
+
this.everOpened = true;
|
|
1037
|
+
this.setState("open");
|
|
1038
|
+
if (reconnected) {
|
|
1039
|
+
for (const room of this.handles.values()) {
|
|
1040
|
+
void room.resume().catch((err) => {
|
|
1041
|
+
room.emit("error", err instanceof Error ? err : new Error(String(err)));
|
|
1042
|
+
});
|
|
1043
|
+
}
|
|
1044
|
+
}
|
|
1045
|
+
}
|
|
1046
|
+
onFrame(frame) {
|
|
1047
|
+
if (frame.id !== void 0) {
|
|
1048
|
+
const waiter = this.pending.get(frame.id);
|
|
1049
|
+
if (waiter !== void 0) {
|
|
1050
|
+
this.pending.delete(frame.id);
|
|
1051
|
+
if (frame.type === "error") waiter.reject(errorFrom(frame.data));
|
|
1052
|
+
else waiter.resolve(frame.data);
|
|
1053
|
+
return;
|
|
1054
|
+
}
|
|
1055
|
+
}
|
|
1056
|
+
switch (frame.type) {
|
|
1057
|
+
case "push":
|
|
1058
|
+
this.onPush(frame);
|
|
1059
|
+
return;
|
|
1060
|
+
case "pong":
|
|
1061
|
+
return;
|
|
1062
|
+
case "reconnect":
|
|
1063
|
+
this.plannedDelayMs = frame.data?.delayMs;
|
|
1064
|
+
return;
|
|
1065
|
+
default:
|
|
1066
|
+
this.emit("frame", frame);
|
|
1067
|
+
}
|
|
1068
|
+
}
|
|
1069
|
+
/**
|
|
1070
|
+
* Unwraps a `push` frame and gives it to whoever it is about.
|
|
1071
|
+
*
|
|
1072
|
+
* Every server-initiated event arrives inside one envelope --
|
|
1073
|
+
* `{type:"push", data:{roomId?, event, data}}` -- rather than as a
|
|
1074
|
+
* frame type of its own. That is deliberate on the server's side: the
|
|
1075
|
+
* event name lives in the payload, so a message body containing
|
|
1076
|
+
* `"type":"control"` can never be mistaken for a control frame. The
|
|
1077
|
+
* cost is this hop, and the SDK pays it once here so a consumer never
|
|
1078
|
+
* sees the envelope.
|
|
1079
|
+
*/
|
|
1080
|
+
onPush(frame) {
|
|
1081
|
+
const push = frame.data;
|
|
1082
|
+
const event = push?.event;
|
|
1083
|
+
if (event === void 0) {
|
|
1084
|
+
this.emit("frame", frame);
|
|
1085
|
+
return;
|
|
1086
|
+
}
|
|
1087
|
+
if (event === "notification.message") {
|
|
1088
|
+
this.emit("notification", push?.data);
|
|
1089
|
+
return;
|
|
1090
|
+
}
|
|
1091
|
+
if (event === "user.presence") {
|
|
1092
|
+
this.emit("user.presence", push?.data);
|
|
1093
|
+
return;
|
|
1094
|
+
}
|
|
1095
|
+
const room = push?.roomId === void 0 ? void 0 : this.routes.get(push.roomId);
|
|
1096
|
+
if (room === void 0) {
|
|
1097
|
+
this.emit("frame", frame);
|
|
1098
|
+
return;
|
|
1099
|
+
}
|
|
1100
|
+
room.handle(event, push?.data);
|
|
1101
|
+
}
|
|
1102
|
+
plannedDelayMs;
|
|
1103
|
+
startHeartbeat(intervalMs) {
|
|
1104
|
+
this.clearHeartbeat();
|
|
1105
|
+
this.heartbeat = setInterval(() => {
|
|
1106
|
+
try {
|
|
1107
|
+
this.socket?.send(JSON.stringify({ type: "ping", id: `p${++this.nextFrameId}` }));
|
|
1108
|
+
} catch {
|
|
1109
|
+
}
|
|
1110
|
+
}, Math.max(1e3, intervalMs));
|
|
1111
|
+
}
|
|
1112
|
+
scheduleReconnect() {
|
|
1113
|
+
if (this.closedByCaller) return;
|
|
1114
|
+
this.setState("reconnecting");
|
|
1115
|
+
const delay = this.nextDelay();
|
|
1116
|
+
this.retryTimer = setTimeout(() => {
|
|
1117
|
+
this.opening = this.openOnce().finally(() => {
|
|
1118
|
+
this.opening = void 0;
|
|
1119
|
+
});
|
|
1120
|
+
void this.opening.catch(() => {
|
|
1121
|
+
});
|
|
1122
|
+
}, delay);
|
|
1123
|
+
}
|
|
1124
|
+
/**
|
|
1125
|
+
* Exponential with full jitter, or whatever the server asked for.
|
|
1126
|
+
*
|
|
1127
|
+
* Jitter is not decoration. A node shutting down drops every socket it
|
|
1128
|
+
* holds at the same instant, and a fixed schedule brings all of them
|
|
1129
|
+
* back at the same instant too -- onto the node that is still up.
|
|
1130
|
+
*/
|
|
1131
|
+
nextDelay() {
|
|
1132
|
+
const planned = this.plannedDelayMs;
|
|
1133
|
+
this.plannedDelayMs = void 0;
|
|
1134
|
+
if (planned !== void 0) return planned;
|
|
1135
|
+
const ceiling = Math.min(MaxBackoffMs, MinBackoffMs * 2 ** this.attempt);
|
|
1136
|
+
this.attempt++;
|
|
1137
|
+
return MinBackoffMs + Math.random() * (ceiling - MinBackoffMs);
|
|
1138
|
+
}
|
|
1139
|
+
async authData() {
|
|
1140
|
+
const data = { key: this.options.key };
|
|
1141
|
+
if (this.options.externalToken !== void 0) {
|
|
1142
|
+
data["externalToken"] = await this.options.externalToken();
|
|
1143
|
+
return data;
|
|
1144
|
+
}
|
|
1145
|
+
const token = await this.options.token();
|
|
1146
|
+
this.lastToken = token;
|
|
1147
|
+
data["token"] = token;
|
|
1148
|
+
return data;
|
|
1149
|
+
}
|
|
1150
|
+
failPending(err) {
|
|
1151
|
+
for (const [, waiter] of this.pending) waiter.reject(err);
|
|
1152
|
+
this.pending.clear();
|
|
1153
|
+
}
|
|
1154
|
+
clearHeartbeat() {
|
|
1155
|
+
if (this.heartbeat !== void 0) clearInterval(this.heartbeat);
|
|
1156
|
+
this.heartbeat = void 0;
|
|
1157
|
+
}
|
|
1158
|
+
clearTimers() {
|
|
1159
|
+
this.clearHeartbeat();
|
|
1160
|
+
if (this.retryTimer !== void 0) clearTimeout(this.retryTimer);
|
|
1161
|
+
this.retryTimer = void 0;
|
|
1162
|
+
}
|
|
1163
|
+
};
|
|
1164
|
+
function createChatClient(options) {
|
|
1165
|
+
return new ChatClient(options);
|
|
1166
|
+
}
|
|
1167
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
1168
|
+
0 && (module.exports = {
|
|
1169
|
+
ChatClient,
|
|
1170
|
+
ChatError,
|
|
1171
|
+
Room,
|
|
1172
|
+
Timeline,
|
|
1173
|
+
createChatClient,
|
|
1174
|
+
createTimeline
|
|
1175
|
+
});
|