@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.d.ts
ADDED
|
@@ -0,0 +1,806 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The smallest event emitter that types its listeners.
|
|
3
|
+
*
|
|
4
|
+
* Not `EventTarget`: it exists in browsers and in Node 22, but its
|
|
5
|
+
* listeners take an `Event` and the payload has to be smuggled through
|
|
6
|
+
* `CustomEvent.detail`, which types badly and reads worse at the call
|
|
7
|
+
* site. Not Node's `EventEmitter` either -- that is not in browsers.
|
|
8
|
+
*/
|
|
9
|
+
type Listener<T> = (value: T) => void;
|
|
10
|
+
declare class Emitter<Events extends Record<string, unknown>> {
|
|
11
|
+
private readonly listeners;
|
|
12
|
+
on<K extends keyof Events>(event: K, fn: Listener<Events[K]>): () => void;
|
|
13
|
+
emit<K extends keyof Events>(event: K, value: Events[K]): void;
|
|
14
|
+
clear(): void;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The wire shapes this SDK reads and writes.
|
|
19
|
+
*
|
|
20
|
+
* These mirror `docs/protocol.md`, and the names are the wire's own. When
|
|
21
|
+
* the server grows a field, the change here is additive and nothing has
|
|
22
|
+
* to be renamed on the way through -- which is the whole reason the
|
|
23
|
+
* public API uses the wire's event names too.
|
|
24
|
+
*/
|
|
25
|
+
type Frame = {
|
|
26
|
+
type: string;
|
|
27
|
+
id?: string;
|
|
28
|
+
data?: unknown;
|
|
29
|
+
};
|
|
30
|
+
type AuthUser = {
|
|
31
|
+
id: string;
|
|
32
|
+
name: string;
|
|
33
|
+
avatar?: string;
|
|
34
|
+
};
|
|
35
|
+
type Hello = {
|
|
36
|
+
connectionId: string;
|
|
37
|
+
user: AuthUser;
|
|
38
|
+
mode: 'token' | 'external';
|
|
39
|
+
serverTime: number;
|
|
40
|
+
heartbeatMs: number;
|
|
41
|
+
limits: Record<string, number>;
|
|
42
|
+
/**
|
|
43
|
+
* Absent is not zero.
|
|
44
|
+
*
|
|
45
|
+
* The server drops this field when the count could not be read, and a
|
|
46
|
+
* client that treats the absence as zero clears the badge of somebody
|
|
47
|
+
* who has unread messages. `GET /me/unread` is the recovery.
|
|
48
|
+
*/
|
|
49
|
+
unread?: {
|
|
50
|
+
total: number;
|
|
51
|
+
mentions: number;
|
|
52
|
+
roomsCapped?: boolean;
|
|
53
|
+
};
|
|
54
|
+
warnings?: string[];
|
|
55
|
+
};
|
|
56
|
+
/** What `chat.on('state')` reports. */
|
|
57
|
+
type ConnectionState = 'connecting' | 'open' | 'reconnecting' | 'closed';
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The REST half of the client.
|
|
61
|
+
*
|
|
62
|
+
* The socket carries everything live; this carries everything a client
|
|
63
|
+
* has to *ask* for -- history, the gap after a reconnect, unread counts,
|
|
64
|
+
* the reaction list. Both halves are needed and neither replaces the
|
|
65
|
+
* other: `subscribe` does not replay, and REST does not push.
|
|
66
|
+
*
|
|
67
|
+
* **It authenticates only in token mode.** A user JWT plus the `pk_` is
|
|
68
|
+
* the one REST credential a client has (`internal/auth/rest.go`). That is
|
|
69
|
+
* every connection this SDK makes, so the only way to reach this without
|
|
70
|
+
* a token is to call it before `connect()` has fetched one.
|
|
71
|
+
*/
|
|
72
|
+
type RestOptions = {
|
|
73
|
+
/** `https://chat.example.com`. Derived from the socket url when absent. */
|
|
74
|
+
url: string;
|
|
75
|
+
key: string;
|
|
76
|
+
/** Returns the current user JWT, or undefined before the first connect. */
|
|
77
|
+
token: () => Promise<string | undefined>;
|
|
78
|
+
fetch: typeof globalThis.fetch;
|
|
79
|
+
};
|
|
80
|
+
declare class Rest {
|
|
81
|
+
private readonly options;
|
|
82
|
+
constructor(options: RestOptions);
|
|
83
|
+
get<T>(path: string, query?: Record<string, string | number | undefined>): Promise<T>;
|
|
84
|
+
post<T>(path: string, body?: unknown): Promise<T>;
|
|
85
|
+
patch<T>(path: string, body?: unknown): Promise<T>;
|
|
86
|
+
put<T>(path: string, body?: unknown): Promise<T>;
|
|
87
|
+
delete<T>(path: string): Promise<T>;
|
|
88
|
+
private call;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** A message as the server renders it. */
|
|
92
|
+
type Message = {
|
|
93
|
+
id: string;
|
|
94
|
+
seq: number;
|
|
95
|
+
sender?: {
|
|
96
|
+
id: string;
|
|
97
|
+
name: string;
|
|
98
|
+
avatar?: string;
|
|
99
|
+
};
|
|
100
|
+
kind?: string;
|
|
101
|
+
body?: {
|
|
102
|
+
text?: string;
|
|
103
|
+
entities?: unknown[];
|
|
104
|
+
attachments?: unknown[];
|
|
105
|
+
};
|
|
106
|
+
meta?: Record<string, unknown>;
|
|
107
|
+
appMeta?: Record<string, unknown>;
|
|
108
|
+
createdAt?: number;
|
|
109
|
+
editedAt?: number;
|
|
110
|
+
deletedAt?: number;
|
|
111
|
+
replyTo?: string;
|
|
112
|
+
threadId?: string;
|
|
113
|
+
reactions?: {
|
|
114
|
+
emoji: string;
|
|
115
|
+
count: number;
|
|
116
|
+
}[];
|
|
117
|
+
thread?: {
|
|
118
|
+
count: number;
|
|
119
|
+
lastSeq?: number;
|
|
120
|
+
};
|
|
121
|
+
};
|
|
122
|
+
type SendInput = {
|
|
123
|
+
text?: string;
|
|
124
|
+
attachments?: unknown[];
|
|
125
|
+
entities?: unknown[];
|
|
126
|
+
replyTo?: string;
|
|
127
|
+
threadId?: string;
|
|
128
|
+
meta?: Record<string, unknown>;
|
|
129
|
+
};
|
|
130
|
+
type SendOptions = {
|
|
131
|
+
/**
|
|
132
|
+
* Overrides the generated dedup key.
|
|
133
|
+
*
|
|
134
|
+
* The SDK makes one per call, and a caller that retries at its own
|
|
135
|
+
* layer has to pass the **same** one back -- a fresh id turns one slow
|
|
136
|
+
* network into two messages.
|
|
137
|
+
*/
|
|
138
|
+
clientMessageId?: string;
|
|
139
|
+
};
|
|
140
|
+
type PublishAck = {
|
|
141
|
+
messageId: string;
|
|
142
|
+
seq: number;
|
|
143
|
+
createdAt?: number;
|
|
144
|
+
};
|
|
145
|
+
type RoomEvents = {
|
|
146
|
+
/**
|
|
147
|
+
* The room could not finish loading or catching up.
|
|
148
|
+
*
|
|
149
|
+
* Emitted rather than thrown, because the failure happens on the
|
|
150
|
+
* socket's read loop where there is no caller to throw to -- and
|
|
151
|
+
* swallowing it is what leaves a hole nothing ever fills.
|
|
152
|
+
*
|
|
153
|
+
* The retry to offer is **`room.subscribe()`**. A room that emitted
|
|
154
|
+
* this is left not-subscribed on purpose, precisely so that call does
|
|
155
|
+
* something: it re-subscribes and reloads. Marking it subscribed and
|
|
156
|
+
* telling the consumer to retry would be advice that returns
|
|
157
|
+
* immediately having done nothing, which is worse than no advice.
|
|
158
|
+
*/
|
|
159
|
+
error: Error;
|
|
160
|
+
/**
|
|
161
|
+
* The timeline was thrown away and rebuilt, because the server said
|
|
162
|
+
* this client's cursor was older than the room's retention.
|
|
163
|
+
*
|
|
164
|
+
* A consumer holding rendered messages has to drop them: the list is
|
|
165
|
+
* new, and quietly appending to the old one leaves a gap that no fill
|
|
166
|
+
* will ever close.
|
|
167
|
+
*
|
|
168
|
+
* Expect this more often than the retention window alone suggests. A
|
|
169
|
+
* resubscribe reports the top of the **contiguous** run, not the top
|
|
170
|
+
* of the list, so a client that has been carrying an unfilled hole
|
|
171
|
+
* asks from further back and crosses the retention line sooner. That
|
|
172
|
+
* is the honest outcome: the alternative is a hole the client keeps
|
|
173
|
+
* forever without ever being told.
|
|
174
|
+
*/
|
|
175
|
+
reset: Message[];
|
|
176
|
+
'message.created': any;
|
|
177
|
+
'message.updated': any;
|
|
178
|
+
'message.deleted': any;
|
|
179
|
+
'reaction.added': any;
|
|
180
|
+
'reaction.removed': any;
|
|
181
|
+
'thread.updated': any;
|
|
182
|
+
presence: any;
|
|
183
|
+
typing: any;
|
|
184
|
+
read: any;
|
|
185
|
+
'member.joined': any;
|
|
186
|
+
'member.left': any;
|
|
187
|
+
'room.updated': any;
|
|
188
|
+
'room.deleted': any;
|
|
189
|
+
custom: any;
|
|
190
|
+
/** The room's message list changed, for any reason. */
|
|
191
|
+
messages: Message[];
|
|
192
|
+
};
|
|
193
|
+
declare class Room extends Emitter<RoomEvents> {
|
|
194
|
+
/** Resolved on subscribe when the room was addressed by key. */
|
|
195
|
+
id: string | undefined;
|
|
196
|
+
readonly key: string | undefined;
|
|
197
|
+
/** The last seq the server reported for this room. */
|
|
198
|
+
lastSeq: number;
|
|
199
|
+
presence: {
|
|
200
|
+
count: number;
|
|
201
|
+
users?: unknown[];
|
|
202
|
+
capped?: boolean;
|
|
203
|
+
} | undefined;
|
|
204
|
+
private readonly chat;
|
|
205
|
+
private readonly rest;
|
|
206
|
+
private readonly timeline;
|
|
207
|
+
/** The live feed is on and history is loaded. */
|
|
208
|
+
private subscribed;
|
|
209
|
+
/**
|
|
210
|
+
* Invalidates work in flight.
|
|
211
|
+
*
|
|
212
|
+
* A subscribe is several awaits long -- the frame, then a history read
|
|
213
|
+
* -- and the socket can die in the middle of it. The promise that was
|
|
214
|
+
* in flight then completes against a **dead socket's** ack and marks
|
|
215
|
+
* the room subscribed, while the new socket has never heard of it. The
|
|
216
|
+
* room is then live nowhere and believes it is live, and no public
|
|
217
|
+
* call fixes that because every entry point short-circuits on the flag
|
|
218
|
+
* it just set.
|
|
219
|
+
*
|
|
220
|
+
* Each attempt carries the generation it started in and applies
|
|
221
|
+
* nothing once that has moved.
|
|
222
|
+
*/
|
|
223
|
+
private generation;
|
|
224
|
+
/**
|
|
225
|
+
* A subscribe was asked for at least once, whether or not it finished.
|
|
226
|
+
*
|
|
227
|
+
* `subscribed` cannot carry this. A catch-up that fails leaves a room
|
|
228
|
+
* that is *not* subscribed, and if that were the only flag a reconnect
|
|
229
|
+
* would skip it -- the room drops out of the live feed permanently and
|
|
230
|
+
* silently, which is worse than the failure that started it.
|
|
231
|
+
*/
|
|
232
|
+
private wanted;
|
|
233
|
+
private subscribing;
|
|
234
|
+
constructor(chat: ChatClient, rest: Rest, address: {
|
|
235
|
+
id?: string;
|
|
236
|
+
key?: string;
|
|
237
|
+
});
|
|
238
|
+
/** Everything this client knows about the room, in seq order. */
|
|
239
|
+
get messages(): Message[];
|
|
240
|
+
/**
|
|
241
|
+
* Reloads recent history, discarding what is held.
|
|
242
|
+
*
|
|
243
|
+
* A reconnect recovers the **message timeline** and nothing else: the
|
|
244
|
+
* only history route is `GET .../messages`, and reactions, thread
|
|
245
|
+
* counts, read cursors, membership and room metadata ride along only
|
|
246
|
+
* for the rows that page happens to contain. So a reaction added to an
|
|
247
|
+
* older message while this client was away is never seen, and no
|
|
248
|
+
* amount of gap filling will find it.
|
|
249
|
+
*
|
|
250
|
+
* The SDK does not hide that behind a background refresh, because the
|
|
251
|
+
* refresh would have to be either wasteful or wrong. It gives the
|
|
252
|
+
* consumer the one honest tool -- ask again -- and says when to use it.
|
|
253
|
+
*/
|
|
254
|
+
reload(): Promise<void>;
|
|
255
|
+
/**
|
|
256
|
+
* Starts the live feed and loads recent history.
|
|
257
|
+
*
|
|
258
|
+
* Both halves, because neither is enough: `subscribe` does not replay
|
|
259
|
+
* anything that already happened, and a history read has no way to
|
|
260
|
+
* learn about what happens next. A room that only did the first looks
|
|
261
|
+
* empty until somebody speaks.
|
|
262
|
+
*/
|
|
263
|
+
subscribe(): Promise<void>;
|
|
264
|
+
private doSubscribe;
|
|
265
|
+
/**
|
|
266
|
+
* Reads a page of history directly, without touching `messages`.
|
|
267
|
+
*
|
|
268
|
+
* The list this room keeps is the live one; this is for a consumer
|
|
269
|
+
* scrolling back, which owns its own window and does not want the
|
|
270
|
+
* bottom of the room rearranged under it.
|
|
271
|
+
*
|
|
272
|
+
* `view` defaults to the server's, which is every message including
|
|
273
|
+
* thread replies. **`view: 'main'` is a display filter, not a sync
|
|
274
|
+
* cursor** -- its last seq skips replies, so feeding it back as
|
|
275
|
+
* `since` would ask the server to resend every reply since.
|
|
276
|
+
*/
|
|
277
|
+
history(options?: {
|
|
278
|
+
before?: number;
|
|
279
|
+
after?: number;
|
|
280
|
+
limit?: number;
|
|
281
|
+
view?: 'main' | 'all' | {
|
|
282
|
+
thread: string;
|
|
283
|
+
};
|
|
284
|
+
}): Promise<Message[]>;
|
|
285
|
+
/** Adds a reaction. Idempotent, like the frame. */
|
|
286
|
+
react(messageId: string, emoji: string): Promise<void>;
|
|
287
|
+
unreact(messageId: string, emoji: string): Promise<void>;
|
|
288
|
+
/**
|
|
289
|
+
* Moves this user's read cursor.
|
|
290
|
+
*
|
|
291
|
+
* The cursor only moves forward: the server ignores a lower value and
|
|
292
|
+
* **does not call that an error**, so the ack's `advanced` is the only
|
|
293
|
+
* way to know what happened. It is returned rather than swallowed.
|
|
294
|
+
*/
|
|
295
|
+
markRead(seq: number, options?: {
|
|
296
|
+
threadId?: string;
|
|
297
|
+
}): Promise<{
|
|
298
|
+
seq: number;
|
|
299
|
+
advanced: boolean;
|
|
300
|
+
}>;
|
|
301
|
+
/**
|
|
302
|
+
* Says this user is typing.
|
|
303
|
+
*
|
|
304
|
+
* Fire and forget: the frame is ephemeral, the server throttles it,
|
|
305
|
+
* and a caller awaiting an ack on every keystroke would be doing the
|
|
306
|
+
* one thing this event is designed to avoid.
|
|
307
|
+
*
|
|
308
|
+
* **The event comes back to the sender too.** The server broadcasts it
|
|
309
|
+
* to the room without excluding whoever sent it, so a consumer drawing
|
|
310
|
+
* "X is typing" has to skip its own `userId` -- the SDK does not
|
|
311
|
+
* filter it out, because a room view that shows every typist and one
|
|
312
|
+
* that shows everyone else are both legitimate and only the consumer
|
|
313
|
+
* knows which it is drawing.
|
|
314
|
+
*/
|
|
315
|
+
typing(): void;
|
|
316
|
+
/** Joins a public or channel room. Idempotent. */
|
|
317
|
+
join(): Promise<void>;
|
|
318
|
+
/** Leaves. Idempotent from the caller's side. */
|
|
319
|
+
leave(): Promise<void>;
|
|
320
|
+
/** The full presence list, for rooms too large to send it in the ack. */
|
|
321
|
+
presenceList(): Promise<{
|
|
322
|
+
count: number;
|
|
323
|
+
users?: unknown[];
|
|
324
|
+
capped?: boolean;
|
|
325
|
+
}>;
|
|
326
|
+
/**
|
|
327
|
+
* Who reacted, one row per (user, emoji) pair.
|
|
328
|
+
*
|
|
329
|
+
* A person who put three emoji on one message is three rows, and the
|
|
330
|
+
* cursor pages over pairs for that reason: keyed on the user alone, a
|
|
331
|
+
* page boundary falling inside someone's set would drop the rest of
|
|
332
|
+
* their emoji from every page. `limit` is 50 by default and 100 at
|
|
333
|
+
* most, and a full page still carries a cursor when more follows.
|
|
334
|
+
*/
|
|
335
|
+
reactionsOf(messageId: string, options?: {
|
|
336
|
+
emoji?: string;
|
|
337
|
+
cursor?: string;
|
|
338
|
+
limit?: number;
|
|
339
|
+
}): Promise<unknown>;
|
|
340
|
+
/**
|
|
341
|
+
* Re-subscribes after a reconnect. Called by the client.
|
|
342
|
+
*
|
|
343
|
+
* The test is "was this room ever asked for", not "did the last
|
|
344
|
+
* subscribe finish". A room whose catch-up failed is in exactly the
|
|
345
|
+
* state that needs another try, and skipping it there is how a single
|
|
346
|
+
* transient 500 takes a room out of the live feed for the life of the
|
|
347
|
+
* page.
|
|
348
|
+
*/
|
|
349
|
+
resume(): Promise<void>;
|
|
350
|
+
/**
|
|
351
|
+
* Sends `subscribe` and recovers from the one error it has an answer
|
|
352
|
+
* for.
|
|
353
|
+
*
|
|
354
|
+
* `cursor_too_old` means the room's retention has passed the point
|
|
355
|
+
* this client last saw: the server cannot bridge the gap and neither
|
|
356
|
+
* can we. The only honest response is to drop what we hold and start
|
|
357
|
+
* from recent history -- and to **say so**, because a consumer that
|
|
358
|
+
* quietly appends onto its old list keeps a hole forever.
|
|
359
|
+
*/
|
|
360
|
+
private subscribeFrame;
|
|
361
|
+
unsubscribe(): Promise<void>;
|
|
362
|
+
/**
|
|
363
|
+
* The room's id, or a refusal.
|
|
364
|
+
*
|
|
365
|
+
* A room addressed by key has no id until it subscribes, and every
|
|
366
|
+
* route below builds a path out of it. Without this the path is
|
|
367
|
+
* `/v1/rooms/undefined/...` and the frame carries `roomId: undefined`
|
|
368
|
+
* -- a 404 or a validation error that says nothing about the actual
|
|
369
|
+
* mistake.
|
|
370
|
+
*/
|
|
371
|
+
private requireId;
|
|
372
|
+
/** Publishes and resolves when the server acks. */
|
|
373
|
+
send(input: SendInput, options?: SendOptions): Promise<PublishAck>;
|
|
374
|
+
/** Routes a frame the client decided belongs to this room. */
|
|
375
|
+
handle(type: keyof RoomEvents, data: any): void;
|
|
376
|
+
/**
|
|
377
|
+
* Replaces the timeline with the most recent page.
|
|
378
|
+
*
|
|
379
|
+
* `stillCurrent` guards the write, not the read. This is a replacement
|
|
380
|
+
* -- the whole point of it -- so a page fetched for a socket that has
|
|
381
|
+
* since died would throw away everything the socket that replaced it
|
|
382
|
+
* has already loaded. Marking the room subscribed is guarded for the
|
|
383
|
+
* same reason one step later; both are the same await, and the guard
|
|
384
|
+
* has to cover the part that touches shared state.
|
|
385
|
+
*/
|
|
386
|
+
private loadRecent;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* A WebSocket constructor. Injectable because the tests need one that can
|
|
391
|
+
* be made to misbehave -- an open socket that never answers, a close at a
|
|
392
|
+
* chosen moment -- and because a consumer behind a proxy or with their
|
|
393
|
+
* own instrumentation has the same need.
|
|
394
|
+
*/
|
|
395
|
+
type WebSocketLike = {
|
|
396
|
+
readonly readyState: number;
|
|
397
|
+
send: (data: string) => void;
|
|
398
|
+
close: (code?: number, reason?: string) => void;
|
|
399
|
+
addEventListener: (type: string, fn: (ev: any) => void) => void;
|
|
400
|
+
removeEventListener: (type: string, fn: (ev: any) => void) => void;
|
|
401
|
+
};
|
|
402
|
+
type WebSocketFactory = new (url: string) => WebSocketLike;
|
|
403
|
+
type ChatClientOptions = {
|
|
404
|
+
/** `wss://chat.example.com`. The `/v1/ws` path is added. */
|
|
405
|
+
url: string;
|
|
406
|
+
/**
|
|
407
|
+
* Where REST lives, when it is not the same host over http(s).
|
|
408
|
+
*
|
|
409
|
+
* Derived from `url` by default, because a consumer configuring one
|
|
410
|
+
* address and getting both is friendlier than asking twice.
|
|
411
|
+
*/
|
|
412
|
+
restURL?: string;
|
|
413
|
+
/** A `pk_` key. */
|
|
414
|
+
key: string;
|
|
415
|
+
/**
|
|
416
|
+
* The user's token, from your backend.
|
|
417
|
+
*
|
|
418
|
+
* Required, and there is no mode that does without it. The server
|
|
419
|
+
* verifies a signature and takes the `sub` inside at face value; it
|
|
420
|
+
* has no way to decide who anybody is on its own, and the modes that
|
|
421
|
+
* pretended otherwise were removed.
|
|
422
|
+
*
|
|
423
|
+
* Called again on every connect, so an expired token is replaced
|
|
424
|
+
* rather than reused. **Never sign these in the browser** -- signing
|
|
425
|
+
* needs the `sk_`, and a `sk_` in a browser is the whole app.
|
|
426
|
+
*/
|
|
427
|
+
token: () => string | Promise<string>;
|
|
428
|
+
/** external mode. **The server refuses this today** -- the verification half is unbuilt. */
|
|
429
|
+
externalToken?: () => string | Promise<string>;
|
|
430
|
+
/** Defaults to the global. See WebSocketLike. */
|
|
431
|
+
WebSocket?: WebSocketFactory;
|
|
432
|
+
/** Defaults to the global. Used by REST calls and injectable for the same reasons. */
|
|
433
|
+
fetch?: typeof globalThis.fetch;
|
|
434
|
+
};
|
|
435
|
+
type ChatClientEvents = {
|
|
436
|
+
state: ConnectionState;
|
|
437
|
+
notification: unknown;
|
|
438
|
+
'user.presence': unknown;
|
|
439
|
+
/**
|
|
440
|
+
* A frame the SDK did not route anywhere. Emitted rather than dropped
|
|
441
|
+
* so a server that grows an event is visible to a consumer before the
|
|
442
|
+
* SDK learns about it.
|
|
443
|
+
*/
|
|
444
|
+
frame: Frame;
|
|
445
|
+
};
|
|
446
|
+
declare class ChatClient extends Emitter<ChatClientEvents> {
|
|
447
|
+
state: ConnectionState;
|
|
448
|
+
user: AuthUser | undefined;
|
|
449
|
+
connectionId: string | undefined;
|
|
450
|
+
hello: Hello | undefined;
|
|
451
|
+
private readonly options;
|
|
452
|
+
private readonly makeSocket;
|
|
453
|
+
private socket;
|
|
454
|
+
private heartbeat;
|
|
455
|
+
private retryTimer;
|
|
456
|
+
private attempt;
|
|
457
|
+
/** Set by close(), and the only thing that stops the reconnect loop. */
|
|
458
|
+
private closedByCaller;
|
|
459
|
+
private nextFrameId;
|
|
460
|
+
private opening;
|
|
461
|
+
private readonly rest;
|
|
462
|
+
/**
|
|
463
|
+
* Room handles this client has handed out, keyed by how they were
|
|
464
|
+
* asked for. Nothing is ever removed: a handle a consumer holds has to
|
|
465
|
+
* keep being resumed after a reconnect.
|
|
466
|
+
*/
|
|
467
|
+
private readonly handles;
|
|
468
|
+
/**
|
|
469
|
+
* Which handle a push frame for a given room id goes to.
|
|
470
|
+
*
|
|
471
|
+
* Separate from `handles` because the two questions are different.
|
|
472
|
+
* Filing a resolved id **into** `handles` meant a second handle for
|
|
473
|
+
* the same room evicted the first -- and an evicted handle is not just
|
|
474
|
+
* unrouted, it stops being resumed at all, which is worse than the
|
|
475
|
+
* problem the eviction was solving.
|
|
476
|
+
*/
|
|
477
|
+
private readonly routes;
|
|
478
|
+
/** The token most recently sent, so REST can present the same one. */
|
|
479
|
+
private lastToken;
|
|
480
|
+
/** True once a socket has reached `hello`, so the next one is a return. */
|
|
481
|
+
private everOpened;
|
|
482
|
+
constructor(options: ChatClientOptions);
|
|
483
|
+
/** The account-level routes: the rooms this user is in, and unread. */
|
|
484
|
+
get rooms(): {
|
|
485
|
+
list: (options?: {
|
|
486
|
+
cursor?: string;
|
|
487
|
+
limit?: number;
|
|
488
|
+
}) => Promise<unknown>;
|
|
489
|
+
discover: (options?: {
|
|
490
|
+
type?: 'public' | 'channel';
|
|
491
|
+
cursor?: string;
|
|
492
|
+
limit?: number;
|
|
493
|
+
}) => Promise<unknown>;
|
|
494
|
+
members: (roomId: string) => {
|
|
495
|
+
list: (options?: {
|
|
496
|
+
cursor?: string;
|
|
497
|
+
limit?: number;
|
|
498
|
+
}) => Promise<unknown>;
|
|
499
|
+
add: (userId: string, role?: string) => Promise<void>;
|
|
500
|
+
remove: (userId: string) => Promise<void>;
|
|
501
|
+
};
|
|
502
|
+
};
|
|
503
|
+
/**
|
|
504
|
+
* The unread totals across every room this user is a member of.
|
|
505
|
+
*
|
|
506
|
+
* The field names are the route's own: `unread` and `unreadMentions`,
|
|
507
|
+
* **not** the `total`/`mentions` that `hello.unread` uses for the same
|
|
508
|
+
* two numbers. The server says it both ways and the SDK does not pick
|
|
509
|
+
* a third; renaming here would mean a consumer reading the protocol
|
|
510
|
+
* document finds a name that does not exist in either place.
|
|
511
|
+
*
|
|
512
|
+
* `roomsCapped` means the totals cover a subset -- this route does not
|
|
513
|
+
* page, so past 500 rooms it stops counting and says so.
|
|
514
|
+
*/
|
|
515
|
+
unread(): Promise<{
|
|
516
|
+
unread: number;
|
|
517
|
+
unreadMentions: number;
|
|
518
|
+
roomsCapped?: boolean;
|
|
519
|
+
rooms: {
|
|
520
|
+
roomId: string;
|
|
521
|
+
unread: number;
|
|
522
|
+
unreadMentions: number;
|
|
523
|
+
}[];
|
|
524
|
+
}>;
|
|
525
|
+
/**
|
|
526
|
+
* Updates this user's own profile.
|
|
527
|
+
*
|
|
528
|
+
* **`meta` only.** The spec's example is `me.update({ name })`, and the
|
|
529
|
+
* server answers 400 to `name` and `avatar` on this route: a name comes
|
|
530
|
+
* from the token the consumer's backend signs, or from `PUT
|
|
531
|
+
* /users/{id}` with an `sk_`. A signature that always fails is not an
|
|
532
|
+
* API, it is a trap, so it is not offered.
|
|
533
|
+
*
|
|
534
|
+
* The merge is one level deep and a `null` deletes its key -- a nested
|
|
535
|
+
* object is **replaced**, not merged into.
|
|
536
|
+
*/
|
|
537
|
+
updateMe(meta: Record<string, unknown>): Promise<unknown>;
|
|
538
|
+
/**
|
|
539
|
+
* A handle on a room by id.
|
|
540
|
+
*
|
|
541
|
+
* Handles are cached, so two callers asking for the same room get the
|
|
542
|
+
* same object and the same message list -- two lists of one room would
|
|
543
|
+
* drift the moment one of them filled a gap.
|
|
544
|
+
*/
|
|
545
|
+
room(roomId: string): Room;
|
|
546
|
+
/** The same, addressed by the app's own key. Resolved on subscribe. */
|
|
547
|
+
roomByKey(key: string): Room;
|
|
548
|
+
private roomHandle;
|
|
549
|
+
/**
|
|
550
|
+
* Files a room under its id once `subscribe` has resolved one.
|
|
551
|
+
*
|
|
552
|
+
* A room addressed by key is filed under `key:<key>`, and every push
|
|
553
|
+
* frame names a room by **id** -- so without this the handle is
|
|
554
|
+
* unreachable from the read loop and a `roomByKey` room receives its
|
|
555
|
+
* hundred messages of history and then nothing, forever. The events do
|
|
556
|
+
* not vanish silently: they come out of `chat.on('frame')` as
|
|
557
|
+
* unrouted, which is how this was found.
|
|
558
|
+
*
|
|
559
|
+
* Registering under both keys also makes `chat.room(id)` and
|
|
560
|
+
* `chat.roomByKey(key)` return the **same** handle for the same room,
|
|
561
|
+
* which is the property `roomHandle` exists for -- two lists of one
|
|
562
|
+
* room drift the moment either fills a gap.
|
|
563
|
+
*/
|
|
564
|
+
registerRoomId(roomId: string, room: Room): void;
|
|
565
|
+
/** Opens the connection and resolves when `hello` arrives. */
|
|
566
|
+
connect(): Promise<void>;
|
|
567
|
+
/**
|
|
568
|
+
* Closes for good.
|
|
569
|
+
*
|
|
570
|
+
* The distinction from a dropped socket is the whole point of the state
|
|
571
|
+
* machine: this is the only path that reaches `closed`, and a consumer
|
|
572
|
+
* showing "disconnected, retrying" versus "disconnected" needs it.
|
|
573
|
+
*/
|
|
574
|
+
close(): Promise<void>;
|
|
575
|
+
/** Sends a frame and resolves with its ack, or rejects with its error. */
|
|
576
|
+
send(type: string, data?: unknown, timeoutMs?: number): Promise<unknown>;
|
|
577
|
+
/**
|
|
578
|
+
* Frames waiting for an ack, by frame id.
|
|
579
|
+
*
|
|
580
|
+
* A table rather than "the next reply is mine": the protocol lets a
|
|
581
|
+
* client have several frames in flight, and the server answers each
|
|
582
|
+
* with the id it was given. Assuming order would pair a publish's ack
|
|
583
|
+
* with a react's.
|
|
584
|
+
*/
|
|
585
|
+
private readonly pending;
|
|
586
|
+
private setState;
|
|
587
|
+
private openOnce;
|
|
588
|
+
private onHello;
|
|
589
|
+
private onFrame;
|
|
590
|
+
/**
|
|
591
|
+
* Unwraps a `push` frame and gives it to whoever it is about.
|
|
592
|
+
*
|
|
593
|
+
* Every server-initiated event arrives inside one envelope --
|
|
594
|
+
* `{type:"push", data:{roomId?, event, data}}` -- rather than as a
|
|
595
|
+
* frame type of its own. That is deliberate on the server's side: the
|
|
596
|
+
* event name lives in the payload, so a message body containing
|
|
597
|
+
* `"type":"control"` can never be mistaken for a control frame. The
|
|
598
|
+
* cost is this hop, and the SDK pays it once here so a consumer never
|
|
599
|
+
* sees the envelope.
|
|
600
|
+
*/
|
|
601
|
+
private onPush;
|
|
602
|
+
private plannedDelayMs;
|
|
603
|
+
private startHeartbeat;
|
|
604
|
+
private scheduleReconnect;
|
|
605
|
+
/**
|
|
606
|
+
* Exponential with full jitter, or whatever the server asked for.
|
|
607
|
+
*
|
|
608
|
+
* Jitter is not decoration. A node shutting down drops every socket it
|
|
609
|
+
* holds at the same instant, and a fixed schedule brings all of them
|
|
610
|
+
* back at the same instant too -- onto the node that is still up.
|
|
611
|
+
*/
|
|
612
|
+
private nextDelay;
|
|
613
|
+
private authData;
|
|
614
|
+
private failPending;
|
|
615
|
+
private clearHeartbeat;
|
|
616
|
+
private clearTimers;
|
|
617
|
+
}
|
|
618
|
+
declare function createChatClient(options: ChatClientOptions): ChatClient;
|
|
619
|
+
|
|
620
|
+
/**
|
|
621
|
+
* Every failure this SDK raises, with the server's own code on it.
|
|
622
|
+
*
|
|
623
|
+
* The code matters more than the message. A consumer branches on
|
|
624
|
+
* `rate_limited` versus `moderation_denied` versus `unauthorized`, and a
|
|
625
|
+
* bare `Error` carrying prose forces them to match on strings the server
|
|
626
|
+
* is free to reword.
|
|
627
|
+
*/
|
|
628
|
+
declare class ChatError extends Error {
|
|
629
|
+
readonly code: string;
|
|
630
|
+
/** Present on rate limits, in milliseconds. */
|
|
631
|
+
readonly retryAfterMs?: number;
|
|
632
|
+
/** The consumer's own code, when a before_publish hook denied this. */
|
|
633
|
+
readonly appCode?: string;
|
|
634
|
+
constructor(code: string, message: string, extra?: {
|
|
635
|
+
retryAfterMs?: number;
|
|
636
|
+
appCode?: string;
|
|
637
|
+
});
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
/**
|
|
641
|
+
* The message list, in `seq` order, with its holes filled.
|
|
642
|
+
*
|
|
643
|
+
* # Why this is a module and not part of Room
|
|
644
|
+
*
|
|
645
|
+
* Everything here is a state machine over frames: what to do when they
|
|
646
|
+
* arrive out of order, when one is missing, when a fill lands after the
|
|
647
|
+
* ground moved. **A real server cannot produce those inputs on demand**
|
|
648
|
+
* -- within one connection it never reorders a room's events, its REST
|
|
649
|
+
* answers in milliseconds so "while a fill is in flight" is not a window
|
|
650
|
+
* you can aim at, and it has no way to be asked for a gap that is not
|
|
651
|
+
* really there. So the tests inject frames, and that is only possible if
|
|
652
|
+
* this thing has no socket in it.
|
|
653
|
+
*
|
|
654
|
+
* # What it guarantees
|
|
655
|
+
*
|
|
656
|
+
* `messages` is sorted by `seq` and has no duplicates. Every hole the
|
|
657
|
+
* timeline can see is either being fetched or has been fetched. A
|
|
658
|
+
* tombstone keeps its slot, because `seq` is gap-free on the server and
|
|
659
|
+
* a client that spliced a deleted message out would leave a hole its own
|
|
660
|
+
* gap detection then chases forever.
|
|
661
|
+
*/
|
|
662
|
+
/** Fetches `(after, before)` exclusive, ascending, at most `limit`. */
|
|
663
|
+
type FetchRange = (range: {
|
|
664
|
+
after: number;
|
|
665
|
+
before: number;
|
|
666
|
+
limit: number;
|
|
667
|
+
}) => Promise<Message[]>;
|
|
668
|
+
type TimelineOptions = {
|
|
669
|
+
fetchRange: FetchRange;
|
|
670
|
+
/** Called whenever the list changes. */
|
|
671
|
+
onChange?: (messages: Message[]) => void;
|
|
672
|
+
/** The server's page cap. Exposed so a test can make paging happen. */
|
|
673
|
+
pageSize?: number;
|
|
674
|
+
};
|
|
675
|
+
declare class Timeline {
|
|
676
|
+
private readonly options;
|
|
677
|
+
private items;
|
|
678
|
+
/**
|
|
679
|
+
* Deletes for messages this client has not seen yet.
|
|
680
|
+
*
|
|
681
|
+
* A `message.deleted` can arrive for a seq inside a hole. If it were
|
|
682
|
+
* dropped, the fill that follows would bring the row back **alive**,
|
|
683
|
+
* because the history read reflects the delete only if it happened
|
|
684
|
+
* before the read. The client would then show a message the server
|
|
685
|
+
* considers gone, and nothing would ever correct it.
|
|
686
|
+
*/
|
|
687
|
+
private readonly pendingDeletes;
|
|
688
|
+
/** In-flight fills, keyed by the range they cover, to avoid duplicates. */
|
|
689
|
+
private readonly fills;
|
|
690
|
+
/**
|
|
691
|
+
* Bumped by every reset.
|
|
692
|
+
*
|
|
693
|
+
* A fill started before a reset must not be applied after one: the
|
|
694
|
+
* range it was asked for described a timeline that no longer exists.
|
|
695
|
+
* Without this, a reconnect that lands mid-fill mixes the old room's
|
|
696
|
+
* rows into the new list.
|
|
697
|
+
*/
|
|
698
|
+
private epoch;
|
|
699
|
+
constructor(options: TimelineOptions);
|
|
700
|
+
get messages(): Message[];
|
|
701
|
+
/** The highest seq this timeline holds, hole or no hole. */
|
|
702
|
+
get highestSeq(): number;
|
|
703
|
+
/**
|
|
704
|
+
* The highest seq with nothing missing below it.
|
|
705
|
+
*
|
|
706
|
+
* This is the number a resubscribe must send as `since`, and it is not
|
|
707
|
+
* `highestSeq`. Holding 5,6,7,8,9,14 with 10..13 missing, the top of
|
|
708
|
+
* the list is 14 and the top of what this client actually has is 9.
|
|
709
|
+
* Sending 14 tells the server "I have everything up to 14", the
|
|
710
|
+
* catch-up computes that there is nothing to fetch, and the hole
|
|
711
|
+
* becomes permanent -- which is exactly the failure the whole gap
|
|
712
|
+
* machinery exists to prevent, arriving through the number that
|
|
713
|
+
* describes it.
|
|
714
|
+
*
|
|
715
|
+
* A hole below the first row held is not visible here and not this
|
|
716
|
+
* client's business: history begins where the last load began.
|
|
717
|
+
*/
|
|
718
|
+
get contiguousSeq(): number;
|
|
719
|
+
/**
|
|
720
|
+
* Forgets everything, including the pending deletes.
|
|
721
|
+
*
|
|
722
|
+
* Used when the server says the cursor is too old: what this timeline
|
|
723
|
+
* holds is no longer connected to what the room holds, so carrying any
|
|
724
|
+
* of it forward would leave a hole that nothing fills.
|
|
725
|
+
*/
|
|
726
|
+
discard(): void;
|
|
727
|
+
/**
|
|
728
|
+
* Replaces the window with a recent page. Used by the initial load and
|
|
729
|
+
* by recovery.
|
|
730
|
+
*
|
|
731
|
+
* Rows already held **above** the page's top survive it. A recent page
|
|
732
|
+
* is the last N messages as of the moment the server read them, which
|
|
733
|
+
* makes it evidence about the range it covers and no evidence at all
|
|
734
|
+
* about anything newer. Live frames that arrived while the page was in
|
|
735
|
+
* flight are exactly that -- newer -- and dropping them would open a
|
|
736
|
+
* hole above the page that nothing goes looking for, because a hole is
|
|
737
|
+
* only detected on the next arrival.
|
|
738
|
+
*
|
|
739
|
+
* That race is not exotic: the README points consumers at `reload()`
|
|
740
|
+
* for the moment after a reconnect, which is the moment the missed
|
|
741
|
+
* frames are arriving.
|
|
742
|
+
*
|
|
743
|
+
* A caller that wants the list genuinely emptied has `discard()`.
|
|
744
|
+
*/
|
|
745
|
+
reset(messages: Message[]): void;
|
|
746
|
+
/**
|
|
747
|
+
* Takes one live message, in order or not, and fills what it reveals.
|
|
748
|
+
*
|
|
749
|
+
* Returns the fill promise so a caller can await settling; nothing in
|
|
750
|
+
* the SDK does, because a consumer's UI should show what arrived and
|
|
751
|
+
* let the rest land when it lands.
|
|
752
|
+
*/
|
|
753
|
+
add(message: Message): Promise<void>;
|
|
754
|
+
/**
|
|
755
|
+
* Applies a `message.updated`.
|
|
756
|
+
*
|
|
757
|
+
* A row that is already a tombstone stays one. The edit and the delete
|
|
758
|
+
* can arrive in either order -- reordering is the input this module
|
|
759
|
+
* exists for -- and applying a late edit to a deleted row puts its body
|
|
760
|
+
* back on screen under a `deletedAt` that says it is gone. From the
|
|
761
|
+
* person who deleted it, that is a delete that did not work.
|
|
762
|
+
*/
|
|
763
|
+
update(message: Message): void;
|
|
764
|
+
/**
|
|
765
|
+
* Applies a `reaction.added` or `reaction.removed` to the aggregate the
|
|
766
|
+
* message carries.
|
|
767
|
+
*
|
|
768
|
+
* `Message.reactions` is filled by the history read and a consumer
|
|
769
|
+
* renders from it, so leaving it alone would give them a count that is
|
|
770
|
+
* correct at load and frozen after -- inside a live session, with the
|
|
771
|
+
* event that should have changed it arriving on another channel.
|
|
772
|
+
*
|
|
773
|
+
* The server sends the new `count` with the event, so this is a
|
|
774
|
+
* replacement rather than an increment: two clients reacting at once
|
|
775
|
+
* cannot drift the way `+1`/`-1` would.
|
|
776
|
+
*/
|
|
777
|
+
reaction(messageId: string, emoji: string, count: number): void;
|
|
778
|
+
/** Applies a `thread.updated` to the root message's aggregate. */
|
|
779
|
+
thread(rootId: string, count: number, lastSeq?: number): void;
|
|
780
|
+
/** Applies a `message.deleted`, remembering it if the row is not here. */
|
|
781
|
+
remove(id: string): void;
|
|
782
|
+
/**
|
|
783
|
+
* Fetches everything between what we have and `upTo`.
|
|
784
|
+
*
|
|
785
|
+
* Used after a reconnect, where the ack's `lastSeq` says how far the
|
|
786
|
+
* room got while we were away.
|
|
787
|
+
*/
|
|
788
|
+
catchUp(upTo: number): Promise<void>;
|
|
789
|
+
/**
|
|
790
|
+
* Walks a range, page by page, and inserts what comes back.
|
|
791
|
+
*
|
|
792
|
+
* The loop is the point. `GET .../messages` caps `limit` at 100, so a
|
|
793
|
+
* client that was away while a busy room moved 250 messages gets a
|
|
794
|
+
* third of its gap from one call -- and a single-call implementation
|
|
795
|
+
* would believe the hole was filled. That failure is silent: the list
|
|
796
|
+
* looks continuous because the missing rows were never known about.
|
|
797
|
+
*/
|
|
798
|
+
private fill;
|
|
799
|
+
/** Inserts at the seq position, replacing an existing row with that seq. */
|
|
800
|
+
private insert;
|
|
801
|
+
private applyDelete;
|
|
802
|
+
private changed;
|
|
803
|
+
}
|
|
804
|
+
declare function createTimeline(options: TimelineOptions): Timeline;
|
|
805
|
+
|
|
806
|
+
export { type AuthUser, ChatClient, type ChatClientEvents, type ChatClientOptions, ChatError, type ConnectionState, type FetchRange, type Frame, type Hello, type Message, type PublishAck, Room, type RoomEvents, type SendInput, type SendOptions, Timeline, type TimelineOptions, type WebSocketFactory, type WebSocketLike, createChatClient, createTimeline };
|