kempo 4.2.39 → 4.3.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 (41) hide show
  1. package/README.md +7 -0
  2. package/app-public/default.template.html +1 -1
  3. package/dist/admin/nav.fragment.html +5 -0
  4. package/dist/admin/realtime/index.page.html +81 -0
  5. package/dist/admin/settings/[owner]/[name]/index.page.html +52 -1
  6. package/dist/admin/settings/index.page.html +12 -1
  7. package/dist/kempo/api/realtime/WS.js +2 -0
  8. package/dist/kempo/api/realtime/WS.js.map +1 -0
  9. package/dist/kempo/api/realtime/connections/GET.js +2 -0
  10. package/dist/kempo/api/realtime/connections/GET.js.map +1 -0
  11. package/dist/kempo/icons/credit_card.svg +1 -0
  12. package/dist/kempo/icons/sensors.svg +1 -0
  13. package/dist/kempo/realtime.js +2 -0
  14. package/dist/kempo/realtime.js.map +1 -0
  15. package/dist/kempo/sdk.js +1 -1
  16. package/dist/kempo/sdk.js.map +1 -1
  17. package/middleware/kempo.js +30 -15
  18. package/package.json +2 -2
  19. package/scripts/init-db.js +4 -1
  20. package/server/db/schema.js +30 -1
  21. package/server/sdk.js +5 -0
  22. package/server/utils/extensions/contentDirs.js +27 -0
  23. package/server/utils/realtime/Hub.js +676 -0
  24. package/server/utils/realtime/channels.js +247 -0
  25. package/server/utils/realtime/closeConnection.js +4 -0
  26. package/server/utils/realtime/constants.js +20 -0
  27. package/server/utils/realtime/duration.js +18 -0
  28. package/server/utils/realtime/getHub.js +15 -0
  29. package/server/utils/realtime/index.js +14 -0
  30. package/server/utils/realtime/listConnections.js +4 -0
  31. package/server/utils/realtime/listSubscribers.js +4 -0
  32. package/server/utils/realtime/pruneMessages.js +45 -0
  33. package/server/utils/realtime/publish.js +86 -0
  34. package/server/utils/realtime/sendToConnection.js +4 -0
  35. package/server/utils/routing/serveUnmatched.js +8 -1
  36. package/server/utils/settings/getPublicSettings.js +4 -2
  37. package/server/utils/settings/getSettingWithMetadata.js +11 -4
  38. package/server/utils/settings/helpers.js +8 -2
  39. package/server/utils/settings/listSettings.js +9 -1
  40. package/server/utils/settings/secretCrypto.js +41 -0
  41. package/server/utils/settings/setSetting.js +31 -10
@@ -0,0 +1,676 @@
1
+ import crypto from 'crypto';
2
+ import postgres from 'postgres';
3
+ import { and, asc, eq, gt } from 'drizzle-orm';
4
+ import db from '../../db/index.js';
5
+ import { realtimeMessage, realtimeChannel } from '../../db/schema.js';
6
+ import getSession from '../auth/getSession.js';
7
+ import triggerHook from '../hooks/triggerHook.js';
8
+ import pruneMessages from './pruneMessages.js';
9
+ import { resolveChannel, authorizeSubscription, loadMessageHandler } from './channels.js';
10
+ import { BUS_CHANNEL, SESSION_CLOSE_CODE, RATE_LIMIT_CLOSE_CODE } from './constants.js';
11
+
12
+ const BACKLOG_BATCH = 500;
13
+ const DEFAULT_SESSION_CHECK_MS = 30000;
14
+ const DEFAULT_PRUNE_MS = 10 * 60 * 1000;
15
+ const DEFAULT_MAX_CONNECTIONS_PER_USER = 10;
16
+ const DEFAULT_MAX_MESSAGES_PER_SECOND = 100;
17
+ const DEFAULT_MAX_PENDING_MESSAGES = 200;
18
+ // Consecutive one-second windows over the message rate before a connection is judged abusive rather than bursty
19
+ const RATE_STRIKES_BEFORE_CLOSE = 5;
20
+
21
+ /*
22
+ The realtime state of one process: who is connected, what they are subscribed to, and the connection
23
+ that listens for messages published anywhere.
24
+
25
+ Delivery on a normal (cluster) channel has one path. A publish becomes a Postgres NOTIFY, every process
26
+ holding subscribers is listening, and each delivers to its own. A process therefore only ever talks to
27
+ its own sockets, which is what lets several kempo processes share one database and behave as one system.
28
+
29
+ A channel with scope "process" skips all of that: it delivers in memory to the subscribers held by the
30
+ process that publishes, and never touches the database, so it can carry traffic the bus cannot.
31
+ */
32
+ export default class Hub {
33
+ #databaseUrl;
34
+ #sessionCheckMs;
35
+ #pruneMs;
36
+ #maxConnectionsPerUser;
37
+ #maxMessagesPerSecond;
38
+ #maxPendingMessages;
39
+ #rateStrikes;
40
+ #log;
41
+ #subscribers = new Map();
42
+ #channels = new Map();
43
+ #listener = null;
44
+ #starting = null;
45
+ #queue = Promise.resolve();
46
+ #listenCount = 0;
47
+ #sessionTimer = null;
48
+ #pruneTimer = null;
49
+ #checkingSessions = false;
50
+
51
+ constructor({
52
+ databaseUrl = process.env.DATABASE_URL,
53
+ sessionCheckMs = Number(process.env.KEMPO_REALTIME_SESSION_CHECK_MS) || DEFAULT_SESSION_CHECK_MS,
54
+ pruneMs = Number(process.env.KEMPO_REALTIME_PRUNE_MS) || DEFAULT_PRUNE_MS,
55
+ maxConnectionsPerUser = Number(process.env.KEMPO_REALTIME_MAX_CONNECTIONS_PER_USER) || DEFAULT_MAX_CONNECTIONS_PER_USER,
56
+ maxMessagesPerSecond = Number(process.env.KEMPO_REALTIME_MAX_MESSAGES_PER_SECOND) || DEFAULT_MAX_MESSAGES_PER_SECOND,
57
+ maxPendingMessages = DEFAULT_MAX_PENDING_MESSAGES,
58
+ rateStrikes = RATE_STRIKES_BEFORE_CLOSE,
59
+ log = console
60
+ } = {}){
61
+ this.#databaseUrl = databaseUrl;
62
+ this.#sessionCheckMs = sessionCheckMs;
63
+ this.#pruneMs = pruneMs;
64
+ this.#maxConnectionsPerUser = maxConnectionsPerUser;
65
+ this.#maxMessagesPerSecond = maxMessagesPerSecond;
66
+ this.#maxPendingMessages = maxPendingMessages;
67
+ this.#rateStrikes = rateStrikes;
68
+ this.#log = log;
69
+ }
70
+
71
+ /*
72
+ Subscribers
73
+ */
74
+
75
+ addSubscriber = ({ token, user, path, deliver, close } = {}) => {
76
+ if(!token || !user?.id){
77
+ return [{ code: 401, msg: 'Authentication required' }, null];
78
+ }
79
+ if(typeof deliver !== 'function' || typeof close !== 'function'){
80
+ return [{ code: 400, msg: 'deliver and close are required' }, null];
81
+ }
82
+
83
+ let held = 0;
84
+ for(const existing of this.#subscribers.values()){
85
+ if(existing.user.id === user.id) held++;
86
+ }
87
+ if(held >= this.#maxConnectionsPerUser){
88
+ return [{ code: 429, msg: 'Too many connections for this user' }, null];
89
+ }
90
+
91
+ const id = crypto.randomUUID();
92
+ const now = new Date();
93
+ this.#subscribers.set(id, {
94
+ id,
95
+ token,
96
+ user: { id: user.id, name: user.name, email: user.email },
97
+ path,
98
+ connectedAt: now,
99
+ lastActivity: now,
100
+ deliver,
101
+ close,
102
+ subscriptions: new Map(),
103
+ queue: Promise.resolve(),
104
+ pending: 0,
105
+ rate: { start: Date.now(), count: 0, overWindows: 0 }
106
+ });
107
+
108
+ this.#startSessionTimer();
109
+ this.#fire('realtime:connected', { connectionId: id, userId: user.id, path });
110
+ return [null, { id }];
111
+ };
112
+
113
+ removeSubscriber = ({ id, reason } = {}) => {
114
+ const subscriber = this.#subscribers.get(id);
115
+ if(!subscriber) return [null, { removed: false }];
116
+
117
+ const channels = [...subscriber.subscriptions.keys()];
118
+ for(const channel of channels){
119
+ this.#forget(id, channel);
120
+ }
121
+ this.#subscribers.delete(id);
122
+ if(!this.#subscribers.size) this.#stopSessionTimer();
123
+
124
+ for(const channel of channels){
125
+ this.#fire('realtime:unsubscribed', { connectionId: id, userId: subscriber.user.id, channel, reason: 'disconnect' });
126
+ }
127
+ this.#fire('realtime:disconnected', { connectionId: id, userId: subscriber.user.id, path: subscriber.path, channels, reason });
128
+ return [null, { removed: true }];
129
+ };
130
+
131
+ touch = ({ id } = {}) => {
132
+ const subscriber = this.#subscribers.get(id);
133
+ if(subscriber) subscriber.lastActivity = new Date();
134
+ };
135
+
136
+ /*
137
+ Subscriptions
138
+ */
139
+
140
+ subscribe = async ({ id, channel, since } = {}) => {
141
+ const subscriber = this.#subscribers.get(id);
142
+ if(!subscriber){
143
+ return [{ code: 404, msg: 'Unknown subscriber' }, null];
144
+ }
145
+ if(typeof channel !== 'string' || !channel){
146
+ return [{ code: 400, msg: 'Channel is required' }, null];
147
+ }
148
+ if(since !== undefined && since !== null && (!Number.isInteger(since) || since < 0)){
149
+ return [{ code: 400, msg: 'since must be a message id' }, null];
150
+ }
151
+ if(subscriber.subscriptions.has(channel)){
152
+ return [null, { channel, subscribed: true }];
153
+ }
154
+
155
+ const config = await resolveChannel(channel);
156
+ if(!config){
157
+ return [{ code: 404, msg: `Channel "${channel}" does not exist` }, null];
158
+ }
159
+
160
+ const [authError, allowed] = await authorizeSubscription({ config, user: subscriber.user });
161
+ if(authError) return [authError, null];
162
+ if(!allowed){
163
+ return [{ code: 403, msg: 'Not allowed to subscribe to this channel' }, null];
164
+ }
165
+
166
+ const refusal = await this.#guard(subscriber, channel);
167
+ if(refusal) return [refusal, null];
168
+
169
+ /*
170
+ Load the channel's message handler now, not on the first message. Importing a handler pulls in
171
+ everything it uses, which took around 200ms in practice, and every connection's first messages queue
172
+ behind it: a visible hitch for whoever sends first after a restart. A failure here is deliberately
173
+ ignored, since sending will try again and report it to the client that hit it.
174
+ */
175
+ loadMessageHandler(config).catch(() => {});
176
+
177
+ // Only a channel that crosses processes needs the listening connection
178
+ if(config.scope !== 'process'){
179
+ try {
180
+ await this.#ensureBus();
181
+ } catch(error) {
182
+ this.#log.error(`[realtime] could not start the listening connection: ${error.message}`);
183
+ return [{ code: 503, msg: 'Realtime is unavailable' }, null];
184
+ }
185
+ }
186
+
187
+ // The connection may have closed, or the same channel been requested again, while the awaits above ran
188
+ if(!this.#subscribers.has(id)) return [{ code: 410, msg: 'Subscriber is gone' }, null];
189
+ if(subscriber.subscriptions.has(channel)) return [null, { channel, subscribed: true }];
190
+
191
+ const replay = config.persist && since !== undefined && since !== null;
192
+ const subscription = {
193
+ buffering: replay,
194
+ buffer: [],
195
+ lastId: replay ? since : null,
196
+ dropIfBackedUp: config.dropIfBackedUp
197
+ };
198
+
199
+ /*
200
+ Registered before anything is read. Messages that arrive while the backlog is being loaded are held
201
+ in the buffer rather than delivered, so none can fall between "read up to here" and "live from now".
202
+ */
203
+ subscriber.subscriptions.set(channel, subscription);
204
+ if(!this.#channels.has(channel)) this.#channels.set(channel, new Set());
205
+ this.#channels.get(channel).add(id);
206
+
207
+ this.#deliverFrame(subscriber, { type: 'subscribed', channel });
208
+
209
+ if(replay){
210
+ try {
211
+ await this.#backlog(subscriber, subscription, channel, since);
212
+ } catch(error) {
213
+ this.#log.error(`[realtime] replay failed for ${channel}: ${error.message}`);
214
+ this.unsubscribe({ id, channel });
215
+ return [{ code: 500, msg: 'Failed to replay missed messages' }, null];
216
+ }
217
+ }
218
+
219
+ this.#fire('realtime:subscribed', { connectionId: id, userId: subscriber.user.id, channel });
220
+ return [null, { channel, subscribed: true }];
221
+ };
222
+
223
+ unsubscribe = ({ id, channel, reason = 'client' } = {}) => {
224
+ const subscriber = this.#subscribers.get(id);
225
+ if(!subscriber?.subscriptions.has(channel)) return [null, { unsubscribed: false }];
226
+
227
+ subscriber.subscriptions.delete(channel);
228
+ this.#forget(id, channel);
229
+ this.#fire('realtime:unsubscribed', { connectionId: id, userId: subscriber.user.id, channel, reason });
230
+ return [null, { unsubscribed: true }];
231
+ };
232
+
233
+ /*
234
+ Messages from clients
235
+
236
+ A client sends to a channel it is subscribed to, and the channel's own handler receives it. The handler
237
+ is loaded once and kept in memory: this is the hot path, so it must never wait on the database.
238
+
239
+ A connection's messages are handled strictly in order, since input arriving out of order is wrong for
240
+ almost anything a handler does, and the number waiting is bounded so a client that outpaces its handler
241
+ is told so instead of building a queue. Sending faster than `maxMessagesPerSecond` is refused, and a
242
+ connection that does so window after window is closed, since that is abuse rather than a burst.
243
+ */
244
+
245
+ handleMessage = ({ id, channel, data } = {}) => {
246
+ const subscriber = this.#subscribers.get(id);
247
+ if(!subscriber){
248
+ return Promise.resolve([{ code: 404, msg: 'Unknown subscriber' }, null]);
249
+ }
250
+
251
+ const limited = this.#rateLimit(subscriber);
252
+ if(limited) return Promise.resolve(limited);
253
+
254
+ if(subscriber.pending >= this.#maxPendingMessages){
255
+ return Promise.resolve([{ code: 429, msg: 'Too many messages are waiting to be handled' }, null]);
256
+ }
257
+
258
+ subscriber.pending++;
259
+ const result = subscriber.queue.then(() => this.#process(subscriber, channel, data));
260
+ subscriber.queue = result.then(() => { subscriber.pending--; }, () => { subscriber.pending--; });
261
+ return result;
262
+ };
263
+
264
+ #rateLimit = (subscriber) => {
265
+ const rate = subscriber.rate;
266
+ const now = Date.now();
267
+
268
+ if(now - rate.start >= 1000){
269
+ rate.overWindows = rate.count > this.#maxMessagesPerSecond ? rate.overWindows + 1 : 0;
270
+ rate.start = now;
271
+ rate.count = 0;
272
+ }
273
+
274
+ rate.count++;
275
+ if(rate.count <= this.#maxMessagesPerSecond) return null;
276
+
277
+ if(rate.overWindows + 1 >= this.#rateStrikes){
278
+ this.#log.error(`[realtime] closing ${subscriber.id} (user ${subscriber.user.id}): message rate exceeded for ${this.#rateStrikes} windows`);
279
+ this.closeConnection({ connectionId: subscriber.id, code: RATE_LIMIT_CLOSE_CODE, reason: 'Message rate exceeded' });
280
+ }
281
+ return [{ code: 429, msg: 'Message rate exceeded' }, null];
282
+ };
283
+
284
+ #process = async (subscriber, channel, data) => {
285
+ if(!subscriber.subscriptions.has(channel)){
286
+ return [{ code: 403, msg: 'Not subscribed to this channel' }, null];
287
+ }
288
+
289
+ const config = await resolveChannel(channel);
290
+ if(!config){
291
+ return [{ code: 404, msg: `Channel "${channel}" no longer exists` }, null];
292
+ }
293
+
294
+ let handler;
295
+ try {
296
+ handler = await loadMessageHandler(config);
297
+ } catch(error) {
298
+ this.#log.error(`[realtime] the message handler for ${channel} could not be loaded: ${error.message}`);
299
+ return [{ code: 500, msg: 'Message handler unavailable' }, null];
300
+ }
301
+ if(!handler){
302
+ return [{ code: 405, msg: 'This channel does not accept messages' }, null];
303
+ }
304
+
305
+ try {
306
+ const result = await handler({ user: subscriber.user, channel, data, connectionId: subscriber.id });
307
+ return [null, result === undefined ? null : result];
308
+ } catch(error) {
309
+ // A handler refuses on purpose by throwing { code, msg }; anything else is a bug and stays private
310
+ if(Number.isInteger(error?.code) && error.code >= 400 && error.code < 600 && typeof error.msg === 'string'){
311
+ return [{ code: error.code, msg: error.msg }, null];
312
+ }
313
+ this.#log.error(`[realtime] the message handler for ${channel} threw: ${error?.message}`);
314
+ return [{ code: 500, msg: 'Message handler failed' }, null];
315
+ }
316
+ };
317
+
318
+ /*
319
+ Acting on connections. Connection ids belong to the process that holds the connection, so these only
320
+ reach connections on this process.
321
+ */
322
+
323
+ sendToConnection = ({ connectionId, data } = {}) => {
324
+ const subscriber = this.#subscribers.get(connectionId);
325
+ if(!subscriber){
326
+ return [{ code: 404, msg: 'No such connection on this process' }, null];
327
+ }
328
+ return [null, { delivered: this.#deliverFrame(subscriber, { type: 'direct', data }) }];
329
+ };
330
+
331
+ closeConnection = ({ connectionId, code = 1000, reason = '' } = {}) => {
332
+ const subscriber = this.#subscribers.get(connectionId);
333
+ if(!subscriber){
334
+ return [{ code: 404, msg: 'No such connection on this process' }, null];
335
+ }
336
+
337
+ try {
338
+ subscriber.close(code, reason);
339
+ } catch(error) {
340
+ this.#log.error(`[realtime] could not close ${connectionId}: ${error.message}`);
341
+ }
342
+ this.removeSubscriber({ id: connectionId, reason: code });
343
+ return [null, { closed: true }];
344
+ };
345
+
346
+ listSubscribers = ({ channel } = {}) => {
347
+ const ids = this.#channels.get(channel);
348
+ return [null, {
349
+ channel,
350
+ subscribers: [...(ids || [])].map(id => {
351
+ const subscriber = this.#subscribers.get(id);
352
+ return { connectionId: id, userId: subscriber.user.id, userName: subscriber.user.name };
353
+ })
354
+ }];
355
+ };
356
+
357
+ /*
358
+ Delivery without the database: to every subscriber of `channel` held by this process. Returns how
359
+ many it reached, which excludes any skipped because they are behind on a dropIfBackedUp channel.
360
+ */
361
+ deliverLocal = ({ channel, data } = {}) => {
362
+ const ids = this.#channels.get(channel);
363
+ if(!ids?.size) return 0;
364
+
365
+ let delivered = 0;
366
+ for(const id of [...ids]){
367
+ const subscriber = this.#subscribers.get(id);
368
+ const subscription = subscriber?.subscriptions.get(channel);
369
+ if(!subscription) continue;
370
+ if(this.#send(subscriber, subscription, channel, { data })) delivered++;
371
+ }
372
+ return delivered;
373
+ };
374
+
375
+ /*
376
+ Inspection
377
+ */
378
+
379
+ listConnections = () => [null, {
380
+ process: process.pid,
381
+ connections: [...this.#subscribers.values()].map(subscriber => ({
382
+ id: subscriber.id,
383
+ userId: subscriber.user.id,
384
+ userName: subscriber.user.name,
385
+ path: subscriber.path,
386
+ connectedAt: subscriber.connectedAt,
387
+ lastActivity: subscriber.lastActivity,
388
+ channels: [...subscriber.subscriptions.keys()]
389
+ })),
390
+ channels: [...this.#channels].map(([channel, ids]) => ({ channel, subscribers: ids.size }))
391
+ }];
392
+
393
+ /*
394
+ Shutdown. The listening connection is released so a process (or a test) can exit cleanly.
395
+ */
396
+ close = async () => {
397
+ this.#stopSessionTimer();
398
+ if(this.#pruneTimer) clearInterval(this.#pruneTimer);
399
+ this.#pruneTimer = null;
400
+
401
+ const listener = this.#listener;
402
+ this.#listener = null;
403
+ this.#starting = null;
404
+ this.#subscribers.clear();
405
+ this.#channels.clear();
406
+
407
+ await this.#queue.catch(() => {});
408
+ if(listener) await listener.end({ timeout: 1 }).catch(() => {});
409
+ };
410
+
411
+ /*
412
+ Hooks
413
+
414
+ Lifecycle events go through kempo's hook system so an extension can react to them. That system reads
415
+ the database on every trigger, which is fine for events that happen once per connection and would be
416
+ wrong for anything per message, which is why messages use a handler and not a hook.
417
+
418
+ They are notifications: fired without waiting, so a slow or failing handler cannot hold up a
419
+ connection, and no handler can stop what already happened. The one exception is before_subscribe.
420
+ */
421
+
422
+ #fire = (event, payload) => {
423
+ Promise.resolve(triggerHook(event, payload)).catch(error => {
424
+ this.#log.error(`[realtime] hook ${event} failed: ${error.message}`);
425
+ });
426
+ };
427
+
428
+ /*
429
+ A guard: an extension can refuse a subscription with custom logic beyond a permission, by throwing
430
+ { code, msg } (the same convention as middleware:before_page). It fails closed: a hook that errors,
431
+ or the database being unreachable, refuses the subscription instead of quietly allowing it.
432
+ */
433
+ #guard = async (subscriber, channel) => {
434
+ try {
435
+ await triggerHook('realtime:before_subscribe', {
436
+ connectionId: subscriber.id,
437
+ userId: subscriber.user.id,
438
+ user: subscriber.user,
439
+ channel
440
+ }, { bail: true });
441
+ return null;
442
+ } catch(error) {
443
+ if(Number.isInteger(error?.code) && error.code >= 400 && error.code < 600 && typeof error.msg === 'string'){
444
+ return { code: error.code, msg: error.msg };
445
+ }
446
+ this.#log.error(`[realtime] realtime:before_subscribe failed for ${channel}: ${error?.message}`);
447
+ return { code: 403, msg: 'Subscription refused' };
448
+ }
449
+ };
450
+
451
+ /*
452
+ The listening connection
453
+ */
454
+
455
+ #ensureBus = () => {
456
+ if(this.#starting) return this.#starting;
457
+
458
+ this.#starting = (async () => {
459
+ // A dedicated connection: LISTEN holds its session, so it cannot be borrowed from the query pool
460
+ const listener = postgres(this.#databaseUrl, { max: 1, onnotice: () => {} });
461
+
462
+ try {
463
+ await listener.listen(BUS_CHANNEL, payload => this.#enqueue(() => this.#notified(payload)), () => this.#listening());
464
+ } catch(error) {
465
+ await listener.end({ timeout: 1 }).catch(() => {});
466
+ this.#starting = null;
467
+ throw error;
468
+ }
469
+
470
+ this.#listener = listener;
471
+ this.#pruneTimer = setInterval(() => {
472
+ pruneMessages().catch(error => this.#log.error(`[realtime] prune failed: ${error.message}`));
473
+ }, this.#pruneMs);
474
+ this.#pruneTimer.unref?.();
475
+ })();
476
+
477
+ return this.#starting;
478
+ };
479
+
480
+ // Called each time LISTEN is established, so also after the connection drops and comes back
481
+ #listening = () => {
482
+ this.#listenCount++;
483
+ if(this.#listenCount > 1){
484
+ this.#enqueue(() => this.#catchUp());
485
+ }
486
+ };
487
+
488
+ /*
489
+ Notifications are handled strictly one at a time. A persisted message needs a database read, and two
490
+ reads in flight can finish out of order; delivering the later id first would then make the earlier one
491
+ look like a duplicate and drop it.
492
+ */
493
+ #enqueue = (task) => {
494
+ this.#queue = this.#queue.then(task).catch(error => {
495
+ this.#log.error(`[realtime] failed to handle a notification: ${error.message}`);
496
+ });
497
+ };
498
+
499
+ #notified = async (payload) => {
500
+ let message;
501
+ try {
502
+ message = JSON.parse(payload);
503
+ } catch(error) {
504
+ return;
505
+ }
506
+
507
+ const { c: channel, i: id, d: data } = message;
508
+ const subscriberIds = this.#channels.get(channel);
509
+ if(!subscriberIds?.size) return;
510
+
511
+ let value = data;
512
+ if(id !== undefined){
513
+ const [row] = await db.select({ data: realtimeMessage.data }).from(realtimeMessage).where(eq(realtimeMessage.id, id)).limit(1);
514
+ // Already pruned between the publish and now
515
+ if(!row) return;
516
+ value = row.data;
517
+ }
518
+
519
+ for(const subscriberId of [...subscriberIds]){
520
+ const subscriber = this.#subscribers.get(subscriberId);
521
+ const subscription = subscriber?.subscriptions.get(channel);
522
+ if(!subscription) continue;
523
+
524
+ if(subscription.buffering){
525
+ subscription.buffer.push({ id, data: value });
526
+ continue;
527
+ }
528
+ this.#send(subscriber, subscription, channel, { id, data: value });
529
+ }
530
+ };
531
+
532
+ /*
533
+ Anything published while the connection was down was never delivered. Persisted channels can be made
534
+ whole again from the table; a message on a channel that does not persist is simply lost, which is the
535
+ at-most-once guarantee such a channel makes.
536
+ */
537
+ #catchUp = async () => {
538
+ for(const [channel, ids] of this.#channels){
539
+ for(const id of [...ids]){
540
+ const subscriber = this.#subscribers.get(id);
541
+ const subscription = subscriber?.subscriptions.get(channel);
542
+ if(!subscription || subscription.buffering || subscription.lastId === null) continue;
543
+
544
+ const rows = await db
545
+ .select()
546
+ .from(realtimeMessage)
547
+ .where(and(eq(realtimeMessage.channel, channel), gt(realtimeMessage.id, subscription.lastId)))
548
+ .orderBy(asc(realtimeMessage.id));
549
+ for(const row of rows){
550
+ this.#send(subscriber, subscription, channel, { id: row.id, data: row.data });
551
+ }
552
+ }
553
+ }
554
+ };
555
+
556
+ /*
557
+ Replay
558
+ */
559
+
560
+ #backlog = async (subscriber, subscription, channel, since) => {
561
+ const [state] = await db
562
+ .select({ prunedThrough: realtimeChannel.prunedThrough })
563
+ .from(realtimeChannel)
564
+ .where(eq(realtimeChannel.channel, channel))
565
+ .limit(1);
566
+
567
+ let cursor = since;
568
+ const prunedThrough = state?.prunedThrough ?? 0;
569
+ if(since < prunedThrough){
570
+ this.#deliverFrame(subscriber, { type: 'gap', channel });
571
+ cursor = prunedThrough;
572
+ }
573
+
574
+ for(;;){
575
+ const rows = await db
576
+ .select()
577
+ .from(realtimeMessage)
578
+ .where(and(eq(realtimeMessage.channel, channel), gt(realtimeMessage.id, cursor)))
579
+ .orderBy(asc(realtimeMessage.id))
580
+ .limit(BACKLOG_BATCH);
581
+
582
+ for(const row of rows){
583
+ this.#send(subscriber, subscription, channel, { id: row.id, data: row.data });
584
+ cursor = row.id;
585
+ }
586
+ if(rows.length < BACKLOG_BATCH) break;
587
+ }
588
+
589
+ /*
590
+ No await from here to the end, so nothing can be delivered between draining the buffer and going
591
+ live. Buffered messages the backlog already covered are dropped by the id check in #send.
592
+ */
593
+ subscription.buffering = false;
594
+ const buffered = subscription.buffer;
595
+ subscription.buffer = [];
596
+ for(const message of buffered){
597
+ this.#send(subscriber, subscription, channel, message);
598
+ }
599
+ };
600
+
601
+ /*
602
+ Delivery
603
+ */
604
+
605
+ // Returns whether the message reached the client's socket; a duplicate, or one skipped because the client is behind, did not
606
+ #send = (subscriber, subscription, channel, { id, data }) => {
607
+ if(id !== undefined && id !== null){
608
+ if(subscription.lastId !== null && id <= subscription.lastId) return false;
609
+ subscription.lastId = id;
610
+ }
611
+
612
+ const frame = { type: 'message', channel, data };
613
+ if(id !== undefined && id !== null) frame.id = id;
614
+ return this.#deliverFrame(subscriber, frame, subscription.dropIfBackedUp ? { dropIfBackedUp: true } : undefined);
615
+ };
616
+
617
+ #deliverFrame = (subscriber, frame, options) => {
618
+ try {
619
+ return subscriber.deliver(frame, options) !== false;
620
+ } catch(error) {
621
+ this.#log.error(`[realtime] delivery to ${subscriber.id} failed: ${error.message}`);
622
+ return false;
623
+ }
624
+ };
625
+
626
+ #forget = (id, channel) => {
627
+ const ids = this.#channels.get(channel);
628
+ if(!ids) return;
629
+ ids.delete(id);
630
+ if(!ids.size) this.#channels.delete(channel);
631
+ };
632
+
633
+ /*
634
+ Sessions
635
+
636
+ A socket authenticates once, when it connects, but it can outlive the session that let it in: the
637
+ user signs out, the session expires, a password change revokes them. So the sessions behind open
638
+ sockets are re-checked on an interval, and a socket whose session has ended is closed. A database
639
+ error is not treated as an ended session, so a blip cannot disconnect everyone.
640
+ */
641
+
642
+ #startSessionTimer = () => {
643
+ if(this.#sessionTimer) return;
644
+ this.#sessionTimer = setInterval(() => this.#checkSessions(), this.#sessionCheckMs);
645
+ this.#sessionTimer.unref?.();
646
+ };
647
+
648
+ #stopSessionTimer = () => {
649
+ if(this.#sessionTimer) clearInterval(this.#sessionTimer);
650
+ this.#sessionTimer = null;
651
+ };
652
+
653
+ #checkSessions = async () => {
654
+ if(this.#checkingSessions) return;
655
+ this.#checkingSessions = true;
656
+
657
+ try {
658
+ const byToken = new Map();
659
+ for(const subscriber of this.#subscribers.values()){
660
+ if(!byToken.has(subscriber.token)) byToken.set(subscriber.token, []);
661
+ byToken.get(subscriber.token).push(subscriber);
662
+ }
663
+
664
+ for(const [token, subscribers] of byToken){
665
+ const [error] = await getSession({ token });
666
+ if(!error || error.code !== 404) continue;
667
+
668
+ for(const subscriber of subscribers){
669
+ this.closeConnection({ connectionId: subscriber.id, code: SESSION_CLOSE_CODE, reason: 'Session ended' });
670
+ }
671
+ }
672
+ } finally {
673
+ this.#checkingSessions = false;
674
+ }
675
+ };
676
+ }