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.
- package/README.md +7 -0
- package/app-public/default.template.html +1 -1
- package/dist/admin/nav.fragment.html +5 -0
- package/dist/admin/realtime/index.page.html +81 -0
- package/dist/admin/settings/[owner]/[name]/index.page.html +52 -1
- package/dist/admin/settings/index.page.html +12 -1
- package/dist/kempo/api/realtime/WS.js +2 -0
- package/dist/kempo/api/realtime/WS.js.map +1 -0
- package/dist/kempo/api/realtime/connections/GET.js +2 -0
- package/dist/kempo/api/realtime/connections/GET.js.map +1 -0
- package/dist/kempo/icons/credit_card.svg +1 -0
- package/dist/kempo/icons/sensors.svg +1 -0
- package/dist/kempo/realtime.js +2 -0
- package/dist/kempo/realtime.js.map +1 -0
- package/dist/kempo/sdk.js +1 -1
- package/dist/kempo/sdk.js.map +1 -1
- package/middleware/kempo.js +30 -15
- package/package.json +2 -2
- package/scripts/init-db.js +4 -1
- package/server/db/schema.js +30 -1
- package/server/sdk.js +5 -0
- package/server/utils/extensions/contentDirs.js +27 -0
- package/server/utils/realtime/Hub.js +676 -0
- package/server/utils/realtime/channels.js +247 -0
- package/server/utils/realtime/closeConnection.js +4 -0
- package/server/utils/realtime/constants.js +20 -0
- package/server/utils/realtime/duration.js +18 -0
- package/server/utils/realtime/getHub.js +15 -0
- package/server/utils/realtime/index.js +14 -0
- package/server/utils/realtime/listConnections.js +4 -0
- package/server/utils/realtime/listSubscribers.js +4 -0
- package/server/utils/realtime/pruneMessages.js +45 -0
- package/server/utils/realtime/publish.js +86 -0
- package/server/utils/realtime/sendToConnection.js +4 -0
- package/server/utils/routing/serveUnmatched.js +8 -1
- package/server/utils/settings/getPublicSettings.js +4 -2
- package/server/utils/settings/getSettingWithMetadata.js +11 -4
- package/server/utils/settings/helpers.js +8 -2
- package/server/utils/settings/listSettings.js +9 -1
- package/server/utils/settings/secretCrypto.js +41 -0
- 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
|
+
}
|