@argentic/chest-sdk 0.4.1 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/README.md +500 -73
  2. package/client/index.ts +11 -7
  3. package/client/src/api.ts +15 -8
  4. package/client/src/errors.ts +50 -0
  5. package/client/src/eventrules.ts +117 -0
  6. package/client/src/events.ts +181 -39
  7. package/client/src/files.ts +37 -7
  8. package/client/src/member.ts +15 -9
  9. package/client/src/members.ts +34 -16
  10. package/client/src/notifications.ts +84 -27
  11. package/client/src/realtime-client.ts +516 -0
  12. package/client/src/realtime.ts +118 -0
  13. package/client/src/sealed.ts +158 -0
  14. package/client/src/signed.ts +8 -4
  15. package/client/src/testing-realtime.ts +361 -0
  16. package/client/src/testing.ts +388 -89
  17. package/dist/index.d.ts +2 -0
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +11 -7
  20. package/dist/index.js.map +1 -1
  21. package/dist/src/api.d.ts +2 -0
  22. package/dist/src/api.d.ts.map +1 -1
  23. package/dist/src/api.js +14 -7
  24. package/dist/src/api.js.map +1 -1
  25. package/dist/src/errors.d.ts +18 -0
  26. package/dist/src/errors.d.ts.map +1 -1
  27. package/dist/src/errors.js +44 -0
  28. package/dist/src/errors.js.map +1 -1
  29. package/dist/src/eventrules.d.ts +23 -0
  30. package/dist/src/eventrules.d.ts.map +1 -0
  31. package/dist/src/eventrules.js +107 -0
  32. package/dist/src/eventrules.js.map +1 -0
  33. package/dist/src/events.d.ts +31 -6
  34. package/dist/src/events.d.ts.map +1 -1
  35. package/dist/src/events.js +130 -26
  36. package/dist/src/events.js.map +1 -1
  37. package/dist/src/files.d.ts +1 -0
  38. package/dist/src/files.d.ts.map +1 -1
  39. package/dist/src/files.js +34 -3
  40. package/dist/src/files.js.map +1 -1
  41. package/dist/src/member.d.ts +1 -1
  42. package/dist/src/member.d.ts.map +1 -1
  43. package/dist/src/member.js +9 -6
  44. package/dist/src/member.js.map +1 -1
  45. package/dist/src/members.d.ts +9 -2
  46. package/dist/src/members.d.ts.map +1 -1
  47. package/dist/src/members.js +29 -13
  48. package/dist/src/members.js.map +1 -1
  49. package/dist/src/notifications.d.ts +12 -1
  50. package/dist/src/notifications.d.ts.map +1 -1
  51. package/dist/src/notifications.js +60 -19
  52. package/dist/src/notifications.js.map +1 -1
  53. package/dist/src/realtime-client.d.ts +45 -0
  54. package/dist/src/realtime-client.d.ts.map +1 -0
  55. package/dist/src/realtime-client.js +453 -0
  56. package/dist/src/realtime-client.js.map +1 -0
  57. package/dist/src/realtime.d.ts +22 -0
  58. package/dist/src/realtime.d.ts.map +1 -0
  59. package/dist/src/realtime.js +99 -0
  60. package/dist/src/realtime.js.map +1 -0
  61. package/dist/src/sealed.d.ts +20 -0
  62. package/dist/src/sealed.d.ts.map +1 -0
  63. package/dist/src/sealed.js +121 -0
  64. package/dist/src/sealed.js.map +1 -0
  65. package/dist/src/signed.d.ts.map +1 -1
  66. package/dist/src/signed.js +6 -2
  67. package/dist/src/signed.js.map +1 -1
  68. package/dist/src/testing-realtime.d.ts +54 -0
  69. package/dist/src/testing-realtime.d.ts.map +1 -0
  70. package/dist/src/testing-realtime.js +376 -0
  71. package/dist/src/testing-realtime.js.map +1 -0
  72. package/dist/src/testing.d.ts +40 -3
  73. package/dist/src/testing.d.ts.map +1 -1
  74. package/dist/src/testing.js +365 -76
  75. package/dist/src/testing.js.map +1 -1
  76. package/package.json +23 -4
@@ -0,0 +1,516 @@
1
+ // The browser's side of a tool's live updates: the one module of the SDK
2
+ // that runs in a page (no node: import, nothing read from the environment).
3
+ // A page of the tool's private part (/chest) connects to the Chest on its
4
+ // own host — the member's session is the identity, no token —, joins the
5
+ // channels its tool declares and hears what reaches them: the rows of its
6
+ // feeds as they are committed and the tool's events (on), the other
7
+ // members' ephemeral messages (peers, never mixed with the Chest's), who is
8
+ // present. The connection is the Chest's: the tool may sleep meanwhile.
9
+ //
10
+ // import { connect } from "@argentic/chest-sdk/realtime/client";
11
+ // const live = connect();
12
+ // const room = live.channel("room:42");
13
+ // room.on("messages.insert", row => show(row));
14
+ // room.onJoined(({ replayed }) => replayed || refetchAfter(lastId)); // joined: fetch, unless what was missed came again
15
+ // room.onResync(() => refetchAfter(lastId)); // what was missed is not all kept: ask the tool
16
+ // room.peers.on("typing", (_, from) => showTyping(from));
17
+ // room.peers.send("typing");
18
+ // room.presence.track({ active: true });
19
+ // room.presence.on(list => showOnline(list));
20
+ // live.focus("room:42"); // the conversation on screen: the tool notifies those not watching it
21
+ // live.on("closed", reason => reason === "access_removed" ? showAccessRemoved() : location.reload());
22
+ //
23
+ // It reconnects by itself, unseen: a cut is told (status false) only once
24
+ // it lasts 3 s; it tries again 0.5 s to 30 s apart — at once when the page
25
+ // comes back to the foreground, from the cache or to the network, never
26
+ // while the browser is offline (going offline drops the connection at
27
+ // once), and no sooner than the Chest asks when it is full —, joins its channels again with where each stood, and the Chest
28
+ // gives what was missed however long the page was away: the tool's events
29
+ // of the last 2 minutes, the feeds' rows of the last 7 days — or says
30
+ // resync. A join the Chest has no room for is tried again with the same
31
+ // backoff, unseen. While connected it renews the member's session every 5
32
+ // minutes.
33
+ // It stops for good when access was removed (closed "access_removed") or
34
+ // the session ended (closed "signed_out": a reload signs in again, silently
35
+ // while the provider's session lasts). Content is the tool's: render it as
36
+ // text, never as HTML.
37
+
38
+
39
+ // The subprotocol of the Chest's realtime, and where it is.
40
+ const protocol = "chest-realtime.v1";
41
+ const path = "/_chest/realtime";
42
+ // The rhythm: the backoff's first and last delay; the ping that finds a
43
+ // dead network, how long its answer may take, and how long when the page
44
+ // wakes; how often the session is renewed; how long a cut stays untold.
45
+ const firstDelay = 500, lastDelay = 30000, pingEvery = 25000, pingWait = 10000, wakeWait = 5000, renewEvery = 300000, quietFor = 3000;
46
+
47
+ // Someone present in a channel, and the state their page tracks.
48
+ export type Present = { id: string; state: Record<string, unknown> };
49
+ // Why a connection ended for good.
50
+ export type ClosedReason = "access_removed" | "signed_out";
51
+ // What the Chest says of an event besides its payload: a feed's row has its
52
+ // position in the tool's change log, and is partial when too long to be
53
+ // carried whole (its first column only).
54
+ export type EventInfo = { pos?: number; partial?: true };
55
+ // A listener of the Chest's events on a channel: the feeds' rows and the
56
+ // tool's publishes.
57
+ export type Listener = (payload: unknown, info: EventInfo) => void;
58
+ // A listener of the other members' messages on a channel: who sent it is
59
+ // the Chest's word.
60
+ export type PeerListener = (payload: unknown, from: string) => void;
61
+ // Why a join was refused for good: "forbidden", "invalid_channel",
62
+ // "unavailable"…
63
+ export type RefusedListener = (code: string) => void;
64
+
65
+ // The grammar of a member's message: 1 to 64 of a-z 0-9 _ -, never a dot —
66
+ // dotted names are the tool's and the feeds' ("messages.insert",
67
+ // "rooms.changed"), so a member never speaks as them.
68
+ export const peerEventPattern = /^[a-z0-9_-]{1,64}$/u;
69
+
70
+ export interface Channel {
71
+ // on listens to an event the Chest delivers on the channel: a feed's row
72
+ // ("messages.insert") or what the tool publishes ("rooms.changed") —
73
+ // never a member's message, whatever its name. Each on… returns what
74
+ // stops listening.
75
+ on(event: string, listener: Listener): () => void;
76
+ // onJoined: joined (again, after a reconnect): what reaches the channel
77
+ // from now on is heard — fetch what the page shows then, unless replayed:
78
+ // what was missed came again.
79
+ onJoined(listener: (joined: { replayed: boolean }) => void): () => void;
80
+ // onResync: what the page missed is not all kept, fetch it again.
81
+ onResync(listener: () => void): () => void;
82
+ // onKicked: the member was taken out of the channel.
83
+ onKicked(listener: () => void): () => void;
84
+ // onRefused: the join was refused, and why.
85
+ onRefused(listener: RefusedListener): () => void;
86
+ // peers are the other members' ephemeral messages on the channel (typing,
87
+ // cursors) — a channel whose rule lets them send.
88
+ peers: {
89
+ // on listens to a member's message, and who sent it.
90
+ on(event: string, listener: PeerListener): () => void;
91
+ // send sends a message to the other pages of the channel, 4 KiB of JSON
92
+ // at most; nothing is sent while disconnected. A name the Chest refuses
93
+ // (a dot, an uppercase letter) throws a TypeError "invalid_event": a
94
+ // fault of the page's code.
95
+ send(event: string, payload?: unknown): void;
96
+ };
97
+ presence: {
98
+ // track sets the member present in the channel, with a state (a JSON
99
+ // object, 1 KiB at most), kept across reconnects.
100
+ track(state: Record<string, unknown>): void;
101
+ // list is who is present now, the member's own pages included once
102
+ // they track.
103
+ list(): Present[];
104
+ // on listens to every change of who is present.
105
+ on(listener: (list: Present[]) => void): () => void;
106
+ };
107
+ leave(): void;
108
+ }
109
+
110
+ export interface Live {
111
+ // channel joins a channel, once: the same name is the same channel.
112
+ channel(name: string): Channel;
113
+ // focus says which joined channel the member has on screen (a
114
+ // conversation open), null for none: kept by the Chest for the tool alone
115
+ // (realtime.online's watching), never shown to other members — and none
116
+ // while the page is hidden.
117
+ focus(name: string | null): void;
118
+ // on listens to the tool's direct events (realtime.send), to "status"
119
+ // (true once connected; false only when a cut lasts 3 s, then true when
120
+ // connected again) and to "closed" (for good).
121
+ on(event: "direct", listener: (event: string, payload: unknown) => void): () => void;
122
+ on(event: "status", listener: (connected: boolean) => void): () => void;
123
+ on(event: "closed", listener: (reason: ClosedReason) => void): () => void;
124
+ // member is the member the Chest connected the page as, once it did.
125
+ readonly member: string | undefined;
126
+ close(): void;
127
+ }
128
+
129
+ // What the page offers, where it runs in a browser.
130
+ type Listening = {
131
+ addEventListener(type: string, listener: (event: { persisted?: boolean }) => void): void;
132
+ removeEventListener(type: string, listener: (event: { persisted?: boolean }) => void): void;
133
+ };
134
+ type Page = Partial<Listening> & {
135
+ location?: { protocol: string; host: string };
136
+ document?: Listening & { visibilityState?: string };
137
+ navigator?: { onLine?: boolean };
138
+ };
139
+
140
+ type ChannelState = {
141
+ name: string;
142
+ listeners: Map<string, Set<Listener>>;
143
+ peerListeners: Map<string, Set<PeerListener>>;
144
+ joinedListeners: Set<(joined: { replayed: boolean }) => void>;
145
+ resyncListeners: Set<() => void>;
146
+ kickedListeners: Set<() => void>;
147
+ refusedListeners: Set<RefusedListener>;
148
+ presenceListeners: Set<(list: Present[]) => void>;
149
+ present: Map<string, Record<string, unknown>>;
150
+ tracked?: Record<string, unknown>;
151
+ // Where the page stands, what a re-join asks to be given from: the
152
+ // Chest's epoch and the last number seen in it (its memory), and the
153
+ // highest position of a feed's row seen (the tool's change log; 0 for
154
+ // none). epoch is undefined until joined once.
155
+ epoch: string | undefined;
156
+ seq: number;
157
+ pos: number;
158
+ joined: boolean;
159
+ kicked: boolean;
160
+ // A join the Chest had no room for: how many in a row, and the next try.
161
+ full: number;
162
+ retry: ReturnType<typeof setTimeout> | undefined;
163
+ };
164
+
165
+ // listen adds a listener to a set, and returns what removes it.
166
+ function listen<T>(set: Set<T>, listener: T): () => void {
167
+ set.add(listener);
168
+ return () => { set.delete(listener); };
169
+ }
170
+ // listenTo adds a listener of one event.
171
+ function listenTo<T>(map: Map<string, Set<T>>, event: string, listener: T): () => void {
172
+ let set = map.get(event);
173
+ if (!set) map.set(event, set = new Set());
174
+ return listen(set, listener);
175
+ }
176
+
177
+ // connect connects the page to the Chest — on its own host, or url (a
178
+ // test's) —, and stays connected until closed.
179
+ export function connect(options: { url?: string } = {}): Live {
180
+ const page = globalThis as unknown as Page;
181
+ const url = options.url ?? (page.location ? (page.location.protocol === "https:" ? "wss://" : "ws://") + page.location.host + path : "");
182
+ if (!url) throw new Error("connect runs in a page of the tool, or is given a url");
183
+ const channels = new Map<string, ChannelState>();
184
+ const direct = new Set<(event: string, payload: unknown) => void>();
185
+ const status = new Set<(connected: boolean) => void>();
186
+ const closed = new Set<(reason: ClosedReason) => void>();
187
+ const answers = new Map<number, (m: Record<string, unknown>) => void>();
188
+ // connected: the Chest said hello on the socket; asking: a question before
189
+ // connecting is on its way; told: the status the page was last told;
190
+ // focused: the channel the page has on screen, focusSent what the Chest
191
+ // was last told of it on this connection ("" none).
192
+ let socket: WebSocket | undefined, ref = 0, attempts = 0, ended = false, connected = false, asking = false;
193
+ let epoch: string | undefined, member: string | undefined, told: boolean | undefined, focused: string | null = null, focusSent = "";
194
+ let timer: ReturnType<typeof setTimeout> | undefined, quiet: ReturnType<typeof setTimeout> | undefined, silence: ReturnType<typeof setTimeout> | undefined;
195
+ let pinger: ReturnType<typeof setInterval> | undefined, renewer: ReturnType<typeof setInterval> | undefined;
196
+
197
+ const emit = <T extends unknown[]>(listeners: Set<(...args: T) => void>, ...args: T) => {
198
+ for (const listener of [...listeners]) {
199
+ try { listener(...args); } catch (error) { setTimeout(() => { throw error; }); }
200
+ }
201
+ };
202
+ const fire = <T extends unknown[]>(listeners: Map<string, Set<(...args: T) => void>>, event: string, ...args: T) => {
203
+ const set = listeners.get(event);
204
+ if (set) emit(set, ...args);
205
+ };
206
+ const tell = (up: boolean) => {
207
+ told = up;
208
+ emit(status, up);
209
+ };
210
+ const presenceChanged = (ch: ChannelState) => emit(ch.presenceListeners, [...ch.present].map(([id, state]) => ({ id, state })));
211
+ const write = (message: Record<string, unknown>, answer?: (m: Record<string, unknown>) => void): boolean => {
212
+ if (!socket || socket.readyState !== 1) return false;
213
+ if (answer) {
214
+ ref = ref % 0xffffffff + 1;
215
+ message["ref"] = ref;
216
+ answers.set(ref, answer);
217
+ }
218
+ socket.send(JSON.stringify(message));
219
+ return true;
220
+ };
221
+
222
+ // shown: the page is in the foreground (always, without a document).
223
+ const shown = () => (page.document?.visibilityState ?? "visible") === "visible";
224
+ // tellFocus tells the Chest the channel the page has on screen: the one
225
+ // focused once joined and while shown, none otherwise — only when it
226
+ // changes.
227
+ const tellFocus = () => {
228
+ const ch = focused === null ? undefined : channels.get(focused);
229
+ const now = ch?.joined && shown() ? ch.name : "";
230
+ if (now !== focusSent && write({ op: "focus", ch: now }, () => {})) focusSent = now;
231
+ };
232
+
233
+ // join joins a channel — again, from where it stood: the Chest gives
234
+ // what was missed, then what comes; or says resync. A Chest without room
235
+ // for it is asked again after the backoff, unseen.
236
+ const join = (ch: ChannelState) => {
237
+ const since = ch.epoch === undefined ? undefined : { epoch: ch.epoch, seq: ch.seq, ...(ch.pos > 0 ? { pos: ch.pos } : {}) };
238
+ write({ op: "join", ch: ch.name, ...(since ? { since } : {}) }, answer => {
239
+ if (answer["code"] === "full") {
240
+ ch.retry = setTimeout(() => {
241
+ ch.retry = undefined;
242
+ if (connected && channels.get(ch.name) === ch) join(ch);
243
+ }, delay(ch.full++));
244
+ return;
245
+ }
246
+ if (answer["op"] !== "ok") {
247
+ ch.joined = false;
248
+ emit(ch.refusedListeners, String(answer["code"]));
249
+ return;
250
+ }
251
+ ch.joined = true;
252
+ ch.full = 0;
253
+ const replayed = since !== undefined && answer["resync"] !== true;
254
+ // Replayed in the same epoch, the numbers go on from the page's own;
255
+ // otherwise from the Chest's.
256
+ if (!replayed || since.epoch !== epoch) ch.seq = answer["seq"] as number;
257
+ ch.epoch = epoch;
258
+ if (typeof answer["pos"] === "number") ch.pos = answer["pos"];
259
+ ch.present = new Map((Array.isArray(answer["presence"]) ? answer["presence"] as Present[] : []).map(p => [p.id, p.state]));
260
+ if (ch.tracked && member !== undefined) ch.present.set(member, ch.tracked);
261
+ if (ch.tracked) write({ op: "track", ch: ch.name, state: ch.tracked });
262
+ if (answer["presence"] !== undefined || since !== undefined) presenceChanged(ch);
263
+ tellFocus();
264
+ emit(ch.joinedListeners, { replayed });
265
+ if (answer["resync"] === true) emit(ch.resyncListeners);
266
+ });
267
+ };
268
+
269
+ const receive = (m: Record<string, unknown>) => {
270
+ const answer = typeof m["ref"] === "number" ? answers.get(m["ref"]) : undefined;
271
+ if (answer) {
272
+ answers.delete(m["ref"] as number);
273
+ answer(m);
274
+ return;
275
+ }
276
+ const ch = typeof m["ch"] === "string" ? channels.get(m["ch"]) : undefined;
277
+ switch (m["op"]) {
278
+ case "hello":
279
+ epoch = m["epoch"] as string;
280
+ member = m["member"] as string;
281
+ attempts = 0;
282
+ connected = true;
283
+ focusSent = "";
284
+ clearTimeout(quiet);
285
+ quiet = undefined;
286
+ if (told !== true) tell(true);
287
+ pinger = setInterval(() => check(pingWait), pingEvery);
288
+ renewer = setInterval(() => void ask(), renewEvery);
289
+ for (const c of channels.values()) if (!c.kicked) join(c);
290
+ return;
291
+ case "msg":
292
+ if (!ch || !ch.joined) return;
293
+ // A feed's row: its position in the tool's change log says whether
294
+ // the page has it already (given again by a replay).
295
+ if (typeof m["pos"] === "number") {
296
+ if (m["pos"] <= ch.pos) return;
297
+ ch.pos = m["pos"];
298
+ }
299
+ if (typeof m["seq"] === "number") ch.seq = m["seq"];
300
+ fire(ch.listeners, m["event"] as string, m["payload"], { ...(typeof m["pos"] === "number" ? { pos: m["pos"] } : {}), ...(m["partial"] === true ? { partial: true as const } : {}) });
301
+ return;
302
+ case "peer":
303
+ if (ch?.joined) fire(ch.peerListeners, m["event"] as string, m["payload"], m["from"] as string);
304
+ return;
305
+ case "presence":
306
+ if (!ch) return;
307
+ for (const p of (m["joins"] as Present[]) ?? []) ch.present.set(p.id, p.state);
308
+ for (const id of (m["leaves"] as string[]) ?? []) ch.present.delete(id);
309
+ presenceChanged(ch);
310
+ return;
311
+ case "kicked":
312
+ if (!ch) return;
313
+ ch.joined = false;
314
+ ch.kicked = true;
315
+ if (focusSent === ch.name) focusSent = "";
316
+ emit(ch.kickedListeners);
317
+ return;
318
+ case "direct":
319
+ emit(direct, m["event"] as string, m["payload"]);
320
+ }
321
+ };
322
+
323
+ // check pings the Chest: no answer within wait, the connection is dead
324
+ // and another is opened at once.
325
+ const check = (wait: number) => {
326
+ const s = socket;
327
+ if (!s || !connected) return;
328
+ clearTimeout(silence);
329
+ silence = setTimeout(() => lost(s, true), wait);
330
+ write({ op: "ping" }, () => clearTimeout(silence));
331
+ };
332
+ // unwatch stops what runs while connected.
333
+ const unwatch = () => {
334
+ clearInterval(pinger);
335
+ clearInterval(renewer);
336
+ clearTimeout(silence);
337
+ };
338
+ // lost forgets a connection that closed or went silent, tells the page
339
+ // only if no other comes within quietFor, and connects again: at once, or
340
+ // after the backoff.
341
+ const lost = (s: WebSocket, now: boolean) => {
342
+ if (socket !== s) return;
343
+ socket = undefined;
344
+ connected = false;
345
+ unwatch();
346
+ if (s.readyState <= 1) s.close(4000);
347
+ answers.clear();
348
+ for (const c of channels.values()) {
349
+ c.joined = false;
350
+ clearTimeout(c.retry);
351
+ c.retry = undefined;
352
+ }
353
+ if (told === true && quiet === undefined) quiet = setTimeout(() => { quiet = undefined; tell(false); }, quietFor);
354
+ reconnect(now ? 0 : backoff());
355
+ };
356
+ // stop stops everything, for good.
357
+ const stop = () => {
358
+ ended = true;
359
+ unwatch();
360
+ clearTimeout(timer);
361
+ clearTimeout(quiet);
362
+ for (const c of channels.values()) clearTimeout(c.retry);
363
+ if (socket && socket.readyState <= 1) socket.close(1000);
364
+ socket = undefined;
365
+ page.removeEventListener?.("online", wake);
366
+ page.removeEventListener?.("offline", gone);
367
+ page.removeEventListener?.("pageshow", restored);
368
+ page.document?.removeEventListener("visibilitychange", visible);
369
+ };
370
+ // end ends for good, and says why.
371
+ const end = (reason: ClosedReason) => {
372
+ if (ended) return;
373
+ stop();
374
+ emit(closed, reason);
375
+ };
376
+
377
+ // delay is the wait before the next attempt, the longer the more failed
378
+ // before it; backoff the wait before connecting again.
379
+ const delay = (failed: number) => Math.random() * Math.min(lastDelay, firstDelay * 2 ** failed);
380
+ const backoff = () => delay(attempts++);
381
+ // reconnect attempts again after delay (none: at once), unless an
382
+ // attempt is already on its way.
383
+ const reconnect = (delay: number) => {
384
+ if (ended || socket || asking || timer !== undefined) return;
385
+ if (delay === 0) return void attempt();
386
+ timer = setTimeout(attempt, delay);
387
+ };
388
+ // attempt asks the Chest before opening: whether the member still may
389
+ // connect, and whether it has room — when full, it waits as long as the
390
+ // Chest asks. Offline, it waits for the network ("online").
391
+ const attempt = async () => {
392
+ timer = undefined;
393
+ if (page.navigator?.onLine === false) return;
394
+ asking = true;
395
+ const answer = await ask();
396
+ asking = false;
397
+ if (ended || socket) return;
398
+ if (answer?.status === 503) return reconnect(Math.max(backoff(), answer.retryAfter * 1000));
399
+ open();
400
+ };
401
+ // ask asks the Chest over HTTPS, without upgrading, whether the member
402
+ // may connect — which renews their session: 401 ends as signed out, 403
403
+ // as access removed, 503 says when there is room again (Retry-After, in
404
+ // seconds). A network down gives no answer.
405
+ const ask = async (): Promise<{ status: number; retryAfter: number } | undefined> => {
406
+ try {
407
+ const answer = await fetch(url.replace(/^ws/u, "http"), { credentials: "same-origin", cache: "no-store" });
408
+ await answer.body?.cancel();
409
+ if (answer.status === 401) end("signed_out");
410
+ if (answer.status === 403) end("access_removed");
411
+ return { status: answer.status, retryAfter: Number(answer.headers.get("Retry-After")) || 0 };
412
+ } catch {
413
+ return undefined;
414
+ }
415
+ };
416
+
417
+ const open = () => {
418
+ const s = new WebSocket(url, protocol);
419
+ socket = s;
420
+ s.onmessage = event => {
421
+ let m: unknown;
422
+ try { m = JSON.parse(String(event.data)); } catch { return; }
423
+ if (m !== null && typeof m === "object" && !Array.isArray(m)) receive(m as Record<string, unknown>);
424
+ };
425
+ // Every close but access removed is a cut: a session that ended
426
+ // (1008 session_ended) is told by the question before connecting again.
427
+ s.onclose = event => {
428
+ if (socket === s && event.code === 1008 && event.reason === "access_removed") return end("access_removed");
429
+ lost(s, false);
430
+ };
431
+ };
432
+
433
+ // wake: the page is back — in the foreground, on the network, from the
434
+ // browser's cache. A connection is checked at once; without one, one is
435
+ // opened at once.
436
+ const wake = () => {
437
+ if (ended) return;
438
+ if (connected) return check(wakeWait);
439
+ if (socket || asking) return;
440
+ clearTimeout(timer);
441
+ timer = undefined;
442
+ attempts = 0;
443
+ reconnect(0);
444
+ };
445
+ // gone: the browser says the network is gone — a Wi-Fi left, a cable
446
+ // pulled —: the connection is dropped at once, and the page waits for it
447
+ // to come back ("online") rather than for a ping to go unanswered.
448
+ const gone = () => { if (!ended && socket) lost(socket, false); };
449
+ // visibilitychange: shown, the page wakes; shown or hidden, the Chest is
450
+ // told what it has on screen.
451
+ const visible = () => {
452
+ if (shown()) wake();
453
+ tellFocus();
454
+ };
455
+ const restored = (event: { persisted?: boolean }) => { if (event.persisted) wake(); };
456
+ page.addEventListener?.("online", wake);
457
+ page.addEventListener?.("offline", gone);
458
+ page.addEventListener?.("pageshow", restored);
459
+ page.document?.addEventListener("visibilitychange", visible);
460
+ open();
461
+
462
+ return {
463
+ get member() { return member; },
464
+ channel(name: string): Channel {
465
+ let ch = channels.get(name);
466
+ if (!ch) {
467
+ ch = { name, listeners: new Map(), peerListeners: new Map(), joinedListeners: new Set(), resyncListeners: new Set(), kickedListeners: new Set(), refusedListeners: new Set(), presenceListeners: new Set(), present: new Map(), epoch: undefined, seq: 0, pos: 0, joined: false, kicked: false, full: 0, retry: undefined };
468
+ channels.set(name, ch);
469
+ if (connected) join(ch);
470
+ }
471
+ const state = ch;
472
+ return {
473
+ on: (event, listener) => listenTo(state.listeners, event, listener),
474
+ onJoined: listener => listen(state.joinedListeners, listener),
475
+ onResync: listener => listen(state.resyncListeners, listener),
476
+ onKicked: listener => listen(state.kickedListeners, listener),
477
+ onRefused: listener => listen(state.refusedListeners, listener),
478
+ peers: {
479
+ on: (event, listener) => listenTo(state.peerListeners, event, listener),
480
+ send(event, payload) {
481
+ if (typeof event !== "string" || !peerEventPattern.test(event)) throw new TypeError("invalid_event: a member's message is 1 to 64 of a-z 0-9 _ -, without a dot");
482
+ if (state.joined) write({ op: "send", ch: state.name, event, payload: payload ?? null });
483
+ },
484
+ },
485
+ presence: {
486
+ track(value) {
487
+ state.tracked = value;
488
+ if (member !== undefined) {
489
+ state.present.set(member, value);
490
+ presenceChanged(state);
491
+ }
492
+ if (state.joined) write({ op: "track", ch: state.name, state: value });
493
+ },
494
+ list: () => [...state.present].map(([id, value]) => ({ id, state: value })),
495
+ on: listener => listen(state.presenceListeners, listener),
496
+ },
497
+ // leave leaves the channel: the Chest forgets the page's focus on it.
498
+ leave() {
499
+ channels.delete(state.name);
500
+ clearTimeout(state.retry);
501
+ if (state.joined) write({ op: "leave", ch: state.name });
502
+ state.joined = false;
503
+ if (focusSent === state.name) focusSent = "";
504
+ },
505
+ };
506
+ },
507
+ focus(name) {
508
+ focused = name;
509
+ tellFocus();
510
+ },
511
+ on(event: "direct" | "status" | "closed", listener: never): () => void {
512
+ return listen((event === "direct" ? direct : event === "status" ? status : closed) as Set<unknown>, listener);
513
+ },
514
+ close: stop,
515
+ };
516
+ }
@@ -0,0 +1,118 @@
1
+ import { ask as chest, json, refusal } from "./api.js";
2
+ import { ChestError, TooLarge, Unavailable } from "./errors.js";
3
+ import { memberIdPattern } from "./member.js";
4
+
5
+ // Live updates of the members' pages, for a server tool whose chest.json
6
+ // declares "capabilities": ["realtime"] and, under "realtime", its channels
7
+ // and the tables whose writes become events (feeds). The Chest holds every
8
+ // page's connection: the tool writes rows as always — a feed turns each
9
+ // committed insert, update or delete into "<table>.insert", ".update",
10
+ // ".delete" on its channel — and calls this module only for what is not a
11
+ // row. The pages listen with @argentic/chest-sdk/realtime/client.
12
+ //
13
+ // import * as realtime from "@argentic/chest-sdk/realtime";
14
+ // await realtime.publish("everyone", "rooms.changed", { id: 42 }); // to every page joined there
15
+ // const { reached } = await realtime.send([memberId], "unread", { room: 42, count: 3 });
16
+ // const { online, watching } = await realtime.online(roomMemberIds, { channel: "room:42" }); // notify those not watching it
17
+ // const { members } = await realtime.presence("everyone");
18
+ //
19
+ // Delivery is at most once: the tool's database is the truth, an event a
20
+ // hint that something changed. Errors: CapabilityNotGranted (403),
21
+ // TooLarge (413, a payload beyond 64 KiB), RateLimited (429: the members'
22
+ // pages fall behind; wait a second), Unavailable (503, or the Chest not
23
+ // reached), ChestError otherwise (invalid_channel — no declared channel
24
+ // has that name —, invalid_event, invalid_id, invalid_body 400).
25
+
26
+ // Someone present in a channel, and the state their page tracks.
27
+ export type Present = { id: string; state: Record<string, unknown> };
28
+
29
+ // The grammars of the Chest: a channel is segments of a-z 0-9 _ - joined by
30
+ // colons, 128 characters at most; an event 1 to 64 of a-z 0-9 . _ -.
31
+ export const channelPattern = /^[a-z0-9_-]{1,64}(?::[a-z0-9_-]{1,64})*$/u;
32
+ export const eventPattern = /^[a-z0-9._-]{1,64}$/u;
33
+ const maxChannel = 128, maxPayload = 64 << 10;
34
+
35
+ function checkChannel(channel: unknown): string {
36
+ if (typeof channel !== "string" || channel.length > maxChannel || !channelPattern.test(channel)) throw new ChestError("invalid_channel", 400, "a channel is segments of a-z 0-9 _ - joined by colons");
37
+ return channel;
38
+ }
39
+ function checkEvent(event: unknown): string {
40
+ if (typeof event !== "string" || !eventPattern.test(event)) throw new ChestError("invalid_event", 400, "an event is 1 to 64 of a-z 0-9 . _ -");
41
+ return event;
42
+ }
43
+ function checkIds(ids: Iterable<string>): string[] {
44
+ const all = [...ids];
45
+ if (all.length < 1) throw new ChestError("invalid_body", 400, "one member identifier at least");
46
+ if (!all.every(id => typeof id === "string" && memberIdPattern.test(id))) throw new ChestError("invalid_id", 400, "invalid member identifier");
47
+ return all;
48
+ }
49
+ // body is a call's JSON; a payload the Chest would refuse is refused here.
50
+ function body(fields: Record<string, unknown>): string {
51
+ const payload = JSON.stringify(fields["payload"] ?? null);
52
+ if (payload === undefined) throw new ChestError("invalid_body", 400, "a payload is JSON");
53
+ if (Buffer.byteLength(payload) > maxPayload) throw new TooLarge();
54
+ return JSON.stringify(fields);
55
+ }
56
+
57
+ const call = async (method: string, path: string, value?: string): Promise<unknown> => {
58
+ const response = await chest("realtime", method, path, value === undefined ? {} : { body: value, type: "application/json" });
59
+ if (response.status !== 200) {
60
+ if (response.status < 400) {
61
+ await response.body?.cancel();
62
+ throw new Unavailable();
63
+ }
64
+ throw await refusal(response, "realtime");
65
+ }
66
+ return json(response);
67
+ };
68
+ const record = (v: unknown): Record<string, unknown> => {
69
+ if (v === null || typeof v !== "object" || Array.isArray(v)) throw new Unavailable();
70
+ return v as Record<string, unknown>;
71
+ };
72
+ const ids = (v: unknown): string[] => {
73
+ if (!Array.isArray(v) || !v.every(id => typeof id === "string" && memberIdPattern.test(id))) throw new Unavailable();
74
+ return [...v] as string[];
75
+ };
76
+
77
+ // publish delivers an event to every page joined to a channel the tool
78
+ // declares — numbered, in order, kept a short while for a page that
79
+ // reconnects —: its number in the channel.
80
+ export async function publish(channel: string, event: string, payload?: unknown): Promise<{ seq: number }> {
81
+ const answer = record(await call("POST", "/realtime/publish", body({ channel: checkChannel(channel), event: checkEvent(event), payload })));
82
+ if (!Number.isSafeInteger(answer["seq"]) || (answer["seq"] as number) < 1) throw new Unavailable();
83
+ return { seq: answer["seq"] as number };
84
+ }
85
+
86
+ // send delivers an event to every page of these members, outside any
87
+ // channel: those it reached. The others are not online in the tool.
88
+ export async function send(memberIds: Iterable<string>, event: string, payload?: unknown): Promise<{ reached: string[] }> {
89
+ const answer = record(await call("POST", "/realtime/send", body({ members: checkIds(memberIds), event: checkEvent(event), payload })));
90
+ return { reached: ids(answer["reached"]) };
91
+ }
92
+
93
+ // online says which of these members have a page of the tool open now,
94
+ // and, given a channel, which of them watch it — a page focused on it
95
+ // (live.focus) and in the foreground: what decides whom to notify. A chat
96
+ // notifies the members online but not watching the conversation, and every
97
+ // member not online: those watching it see the message already.
98
+ export async function online(memberIds: Iterable<string>, options: { channel?: string } = {}): Promise<{ online: string[]; watching: string[] }> {
99
+ const members = checkIds(memberIds);
100
+ const channel = options.channel === undefined ? undefined : checkChannel(options.channel);
101
+ const answer = record(await call("POST", "/realtime/online", JSON.stringify({ members, ...(channel === undefined ? {} : { channel }) })));
102
+ return { online: ids(answer["online"]), watching: ids(answer["watching"]) };
103
+ }
104
+
105
+ // presence is who appears in a channel now — merged across their pages —,
106
+ // with the state they track.
107
+ export async function presence(channel: string): Promise<{ members: Present[] }> {
108
+ const answer = record(await call("GET", "/realtime/presence?channel=" + encodeURIComponent(checkChannel(channel))));
109
+ const members = answer["members"];
110
+ if (!Array.isArray(members)) throw new Unavailable();
111
+ return {
112
+ members: members.map(m => {
113
+ const p = record(m);
114
+ if (typeof p["id"] !== "string" || !memberIdPattern.test(p["id"])) throw new Unavailable();
115
+ return { id: p["id"], state: record(p["state"]) };
116
+ }),
117
+ };
118
+ }