@dokebi/sse 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AI.md +132 -0
- package/LICENSE +21 -0
- package/README.md +83 -2
- package/dist/browser/sse-client.js +201 -0
- package/dist/client/index.d.ts +90 -0
- package/dist/client/index.js +353 -0
- package/dist/client/react.d.ts +18 -0
- package/dist/client/react.js +96 -0
- package/dist/client/vue.d.ts +15 -0
- package/dist/client/vue.js +64 -0
- package/dist/errors.d.ts +12 -0
- package/dist/errors.js +16 -0
- package/dist/gateway.d.ts +41 -0
- package/dist/gateway.js +733 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +4 -0
- package/dist/install.d.ts +18 -0
- package/dist/install.js +24 -0
- package/dist/log.d.ts +25 -0
- package/dist/log.js +102 -0
- package/dist/options.d.ts +27 -0
- package/dist/options.js +63 -0
- package/dist/plugin.d.ts +6 -0
- package/dist/plugin.js +90 -0
- package/dist/types.d.ts +132 -0
- package/dist/types.js +4 -0
- package/dist/wire.d.ts +17 -0
- package/dist/wire.js +44 -0
- package/options.schema.json +73 -0
- package/package.json +101 -4
package/dist/gateway.js
ADDED
|
@@ -0,0 +1,733 @@
|
|
|
1
|
+
import { randomBytes, randomUUID } from 'node:crypto';
|
|
2
|
+
import { setTimeout as delay } from 'node:timers/promises';
|
|
3
|
+
import { SseError } from './errors.js';
|
|
4
|
+
import { errorCode, LogThrottle } from './log.js';
|
|
5
|
+
import { OPEN_EVENT, RELOAD_EVENT, RESTART_EVENT, } from './types.js';
|
|
6
|
+
import { checkEvent, EVENT, encodeData, frame, HEADERS, ID, isLine, PING } from './wire.js';
|
|
7
|
+
/** A client that ignores `dokebi-reload` comes back this slowly (EventSource honours `retry:`). */
|
|
8
|
+
const RELOAD_RETRY_MS = 60_000;
|
|
9
|
+
/** How often the bus state is read for health and the warn/info log of a change. */
|
|
10
|
+
const BUS_WATCH_MS = 1000;
|
|
11
|
+
const MAX_TEXT = 256;
|
|
12
|
+
const text = (value) => typeof value === 'string' && value.length > 0 && value.length <= MAX_TEXT;
|
|
13
|
+
const key = (tenant, value) => `${tenant}\u0000${value}`;
|
|
14
|
+
function add(map, name, entry) {
|
|
15
|
+
const set = map.get(name);
|
|
16
|
+
if (set)
|
|
17
|
+
set.add(entry);
|
|
18
|
+
else
|
|
19
|
+
map.set(name, new Set([entry]));
|
|
20
|
+
}
|
|
21
|
+
function remove(map, name, entry) {
|
|
22
|
+
const set = map.get(name);
|
|
23
|
+
if (!set)
|
|
24
|
+
return;
|
|
25
|
+
set.delete(entry);
|
|
26
|
+
if (!set.size)
|
|
27
|
+
map.delete(name);
|
|
28
|
+
}
|
|
29
|
+
function checkRoom(room) {
|
|
30
|
+
if (!text(room))
|
|
31
|
+
throw new SseError('INVALID_ROOM', 'Rooms are strings of 1 to 256 characters.');
|
|
32
|
+
return room;
|
|
33
|
+
}
|
|
34
|
+
function checkTarget(target) {
|
|
35
|
+
const value = target;
|
|
36
|
+
if (!value ||
|
|
37
|
+
typeof value !== 'object' ||
|
|
38
|
+
(value.tenant === undefined) === (value.tenantPrefix === undefined) ||
|
|
39
|
+
(value.tenant !== undefined && !text(value.tenant)) ||
|
|
40
|
+
(value.tenantPrefix !== undefined && !text(value.tenantPrefix)) ||
|
|
41
|
+
(value.member !== undefined && !text(value.member)) ||
|
|
42
|
+
(value.room !== undefined && !text(value.room)))
|
|
43
|
+
throw new SseError('INVALID_TARGET', 'A target is { tenant } or { tenantPrefix }, with member? and room?, strings of 1 to 256 characters.');
|
|
44
|
+
return Object.freeze({
|
|
45
|
+
...(value.tenant === undefined
|
|
46
|
+
? { tenantPrefix: value.tenantPrefix }
|
|
47
|
+
: { tenant: value.tenant }),
|
|
48
|
+
...(value.member === undefined ? {} : { member: value.member }),
|
|
49
|
+
...(value.room === undefined ? {} : { room: value.room }),
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
/** Allow-list entries must be exact http(s) origins such as `https://app.example.com`. */
|
|
53
|
+
function parseOrigins(value) {
|
|
54
|
+
if (value === undefined)
|
|
55
|
+
return undefined;
|
|
56
|
+
if (value === '*')
|
|
57
|
+
return '*';
|
|
58
|
+
if (!Array.isArray(value))
|
|
59
|
+
throw new SseError('INVALID_ROUTE', 'origins must be a list or *.');
|
|
60
|
+
const result = new Set();
|
|
61
|
+
for (const item of value) {
|
|
62
|
+
const url = typeof item === 'string' && URL.canParse(item) ? new URL(item) : undefined;
|
|
63
|
+
if (!url || (url.protocol !== 'http:' && url.protocol !== 'https:') || url.origin !== item)
|
|
64
|
+
throw new SseError('INVALID_ROUTE', 'origins entries are exact origins (https://host).');
|
|
65
|
+
result.add(url.origin);
|
|
66
|
+
}
|
|
67
|
+
return result;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* The CORS answer to a request's Origin: undefined for same-origin (or non-browser) requests,
|
|
71
|
+
* headers for an allowed foreign origin, false for a forbidden one. EventSource sends Origin on
|
|
72
|
+
* cross-origin requests only, so a page's own stream needs no allow-list.
|
|
73
|
+
*/
|
|
74
|
+
function cors(origin, host, allowed) {
|
|
75
|
+
if (origin === undefined)
|
|
76
|
+
return undefined;
|
|
77
|
+
if (typeof origin !== 'string' || !URL.canParse(origin))
|
|
78
|
+
return false;
|
|
79
|
+
const url = new URL(origin);
|
|
80
|
+
if (url.protocol !== 'http:' && url.protocol !== 'https:')
|
|
81
|
+
return false;
|
|
82
|
+
const own = `${url.protocol}//${host}`;
|
|
83
|
+
if (host && URL.canParse(own) && new URL(own).host === url.host)
|
|
84
|
+
return undefined;
|
|
85
|
+
if (allowed === '*')
|
|
86
|
+
return { 'access-control-allow-origin': '*' };
|
|
87
|
+
if (allowed?.has(url.origin))
|
|
88
|
+
return {
|
|
89
|
+
'access-control-allow-origin': url.origin,
|
|
90
|
+
'access-control-allow-credentials': 'true',
|
|
91
|
+
vary: 'Origin',
|
|
92
|
+
};
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
function single(value) {
|
|
96
|
+
return Array.isArray(value) ? value[0] : value;
|
|
97
|
+
}
|
|
98
|
+
export class Gateway {
|
|
99
|
+
context;
|
|
100
|
+
web;
|
|
101
|
+
options;
|
|
102
|
+
bus;
|
|
103
|
+
api;
|
|
104
|
+
#entries = new Map();
|
|
105
|
+
#tenants = new Map();
|
|
106
|
+
#members = new Map();
|
|
107
|
+
#rooms = new Map();
|
|
108
|
+
/** onOpen/onClose calls in progress, awaited by drain (bounded by the drain deadline). */
|
|
109
|
+
#tasks = new Set();
|
|
110
|
+
#throttle;
|
|
111
|
+
/** Event ids: `<prefix>-<counter>`; the prefix tells processes apart, the counter only grows. */
|
|
112
|
+
#prefix = randomBytes(4).toString('hex');
|
|
113
|
+
/** Who published a bus message, so this process skips its own (it delivered them already). */
|
|
114
|
+
#origin = randomUUID();
|
|
115
|
+
#sequence = 0;
|
|
116
|
+
#routes = 0;
|
|
117
|
+
#frozen = false;
|
|
118
|
+
#closing = false;
|
|
119
|
+
#heartbeat;
|
|
120
|
+
#watch;
|
|
121
|
+
#subscription;
|
|
122
|
+
#health;
|
|
123
|
+
#busUp;
|
|
124
|
+
constructor(context, web, options, bus, health) {
|
|
125
|
+
this.context = context;
|
|
126
|
+
this.web = web;
|
|
127
|
+
this.options = options;
|
|
128
|
+
this.bus = bus;
|
|
129
|
+
this.#throttle = new LogThrottle(context.logger);
|
|
130
|
+
if (health) {
|
|
131
|
+
this.#health = health.register('sse', { required: false });
|
|
132
|
+
const handle = this.#health;
|
|
133
|
+
context.defer(() => handle.dispose());
|
|
134
|
+
}
|
|
135
|
+
const self = this;
|
|
136
|
+
this.api = Object.freeze({
|
|
137
|
+
route: (route) => this.#route(route),
|
|
138
|
+
publish: (target, event, data) => this.#publish(target, event, data),
|
|
139
|
+
connections: (filter) => this.#count(filter),
|
|
140
|
+
get bus() {
|
|
141
|
+
return self.options.bus;
|
|
142
|
+
},
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
/** Subscribes to the bus (setup): every process on it delivers to its own matching streams. */
|
|
146
|
+
async subscribe() {
|
|
147
|
+
if (!this.bus)
|
|
148
|
+
return;
|
|
149
|
+
const subscription = this.bus.subscribe(this.options.busEvent, (payload) => this.#fromBus(payload));
|
|
150
|
+
this.#subscription = subscription;
|
|
151
|
+
await subscription.ready;
|
|
152
|
+
this.context.logger.log('debug', 'SSE bus subscribed', {
|
|
153
|
+
bus: this.options.bus,
|
|
154
|
+
event: this.options.busEvent,
|
|
155
|
+
origin: this.#origin,
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
#route(route) {
|
|
159
|
+
if (this.#frozen || this.context.app.state !== 'starting')
|
|
160
|
+
throw new SseError('FROZEN', 'Register SSE routes during setup.');
|
|
161
|
+
if (!route ||
|
|
162
|
+
typeof route.path !== 'string' ||
|
|
163
|
+
!/^\/[^?#\s]{0,2047}$/.test(route.path) ||
|
|
164
|
+
typeof route.authenticate !== 'function' ||
|
|
165
|
+
(route.onOpen !== undefined && typeof route.onOpen !== 'function') ||
|
|
166
|
+
(route.onClose !== undefined && typeof route.onClose !== 'function') ||
|
|
167
|
+
(route.version !== undefined && (!text(route.version) || !isLine(route.version))) ||
|
|
168
|
+
(route.constraints !== undefined &&
|
|
169
|
+
(typeof route.constraints !== 'object' || route.constraints === null)))
|
|
170
|
+
throw new SseError('INVALID_ROUTE', 'Invalid SSE route.');
|
|
171
|
+
const registered = {
|
|
172
|
+
route: Object.freeze({ ...route }),
|
|
173
|
+
origins: parseOrigins(route.origins),
|
|
174
|
+
};
|
|
175
|
+
this.web.route({
|
|
176
|
+
method: 'GET',
|
|
177
|
+
url: route.path,
|
|
178
|
+
// A HEAD request would open a stream whose body Node discards: answer only GET.
|
|
179
|
+
exposeHeadRoute: false,
|
|
180
|
+
...(route.constraints ? { constraints: { ...route.constraints } } : {}),
|
|
181
|
+
handler: (request, reply) => this.#open(request, reply, registered),
|
|
182
|
+
});
|
|
183
|
+
this.#routes++;
|
|
184
|
+
this.context.logger.log('debug', 'SSE route registered', {
|
|
185
|
+
path: route.path,
|
|
186
|
+
version: route.version ?? null,
|
|
187
|
+
origins: registered.origins === '*' ? '*' : registered.origins ? registered.origins.size : null,
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
/** Why a new stream is refused now, or undefined. */
|
|
191
|
+
#refusal() {
|
|
192
|
+
if (this.#closing)
|
|
193
|
+
return 'CLOSING';
|
|
194
|
+
if (this.#entries.size >= this.options.maxConnections)
|
|
195
|
+
return 'CAPACITY';
|
|
196
|
+
return undefined;
|
|
197
|
+
}
|
|
198
|
+
async #open(request, reply, registered) {
|
|
199
|
+
const { route } = registered;
|
|
200
|
+
const logger = this.context.logger;
|
|
201
|
+
const allowed = cors(request.headers.origin, this.web.requestHost(request), registered.origins);
|
|
202
|
+
if (allowed === false) {
|
|
203
|
+
if (logger.isEnabled('debug'))
|
|
204
|
+
logger.log('debug', 'SSE origin refused', {
|
|
205
|
+
path: route.path,
|
|
206
|
+
origin: String(request.headers.origin).slice(0, 200),
|
|
207
|
+
});
|
|
208
|
+
return reply.code(403).send({ error: 'ORIGIN_FORBIDDEN' });
|
|
209
|
+
}
|
|
210
|
+
let refused = this.#refusal();
|
|
211
|
+
if (refused) {
|
|
212
|
+
logger.log('debug', 'SSE stream refused', { path: route.path, reason: refused });
|
|
213
|
+
return reply
|
|
214
|
+
.code(503)
|
|
215
|
+
.header('retry-after', '5')
|
|
216
|
+
.send({ error: 'UNAVAILABLE', reason: refused });
|
|
217
|
+
}
|
|
218
|
+
const identity = await route.authenticate(request);
|
|
219
|
+
if (!identity) {
|
|
220
|
+
if (logger.isEnabled('debug'))
|
|
221
|
+
logger.log('debug', 'SSE stream unauthorized', { path: route.path });
|
|
222
|
+
return reply.code(401).send({ error: 'UNAUTHORIZED' });
|
|
223
|
+
}
|
|
224
|
+
// An invalid identity is an application bug: dokebi-web answers 500 and logs it.
|
|
225
|
+
const rooms = this.#identity(identity);
|
|
226
|
+
const query = (request.query ?? {});
|
|
227
|
+
// The browser client asks this after EventSource failed, to tell 401/403 from an outage.
|
|
228
|
+
if (query.dokebiProbe !== undefined)
|
|
229
|
+
return reply
|
|
230
|
+
.code(204)
|
|
231
|
+
.headers(allowed ?? {})
|
|
232
|
+
.send();
|
|
233
|
+
refused = this.#refusal();
|
|
234
|
+
if (refused) {
|
|
235
|
+
logger.log('debug', 'SSE stream refused', { path: route.path, reason: refused });
|
|
236
|
+
return reply
|
|
237
|
+
.code(503)
|
|
238
|
+
.header('retry-after', '5')
|
|
239
|
+
.send({ error: 'UNAVAILABLE', reason: refused });
|
|
240
|
+
}
|
|
241
|
+
const res = reply.raw;
|
|
242
|
+
const headers = { ...HEADERS, ...allowed };
|
|
243
|
+
// D-0062: a stream of a seal host seals the data of every event, its first ones included.
|
|
244
|
+
const seal = this.web.sealOf(request);
|
|
245
|
+
const data = (json) => (seal ? seal.encode(json) : json);
|
|
246
|
+
if (route.version !== undefined) {
|
|
247
|
+
const sent = single(query.dokebiVersion) ?? single(request.headers['x-dokebi-version']);
|
|
248
|
+
if (sent !== route.version) {
|
|
249
|
+
logger.log('debug', 'SSE version refused; reload sent', {
|
|
250
|
+
path: route.path,
|
|
251
|
+
expected: route.version,
|
|
252
|
+
received: typeof sent === 'string' ? sent.slice(0, 64) : null,
|
|
253
|
+
});
|
|
254
|
+
reply.hijack();
|
|
255
|
+
res.writeHead(200, headers);
|
|
256
|
+
res.end(`retry: ${RELOAD_RETRY_MS}\n${frame(undefined, RELOAD_EVENT, data(JSON.stringify({ version: route.version })))}`);
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
const header = single(request.headers['last-event-id']);
|
|
261
|
+
const asked = header ?? single(query.lastEventId);
|
|
262
|
+
const lastEventId = typeof asked === 'string' && asked.length <= MAX_TEXT && isLine(asked) ? asked : undefined;
|
|
263
|
+
reply.hijack();
|
|
264
|
+
if (res.destroyed || !res.socket || res.socket.destroyed) {
|
|
265
|
+
logger.log('debug', 'SSE client left before the stream opened', { path: route.path });
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
res.writeHead(200, headers);
|
|
269
|
+
// The reconnect hint first, and the same value as data for clients that manage reconnects
|
|
270
|
+
// themselves (EventSource does not expose `retry:`).
|
|
271
|
+
res.write(`retry: ${this.options.retryMs}\n${frame(undefined, OPEN_EVENT, data(JSON.stringify({ retryMs: this.options.retryMs })))}`);
|
|
272
|
+
const entry = this.#add(res, identity, route, rooms, lastEventId, seal);
|
|
273
|
+
res.once('close', () => this.#closed(entry));
|
|
274
|
+
res.on('error', (error) => {
|
|
275
|
+
entry.reason ??= 'WRITE_FAILED';
|
|
276
|
+
const code = errorCode(error, 'UNKNOWN');
|
|
277
|
+
// A client that went away mid-write is not a server failure.
|
|
278
|
+
const gone = code === 'ECONNRESET' || code === 'EPIPE' || code === 'ERR_STREAM_DESTROYED';
|
|
279
|
+
this.#throttle.report(gone ? 'debug' : 'error', 'SSE stream write failed', 'STREAM_WRITE_FAILED', error, { path: route.path, reason: code });
|
|
280
|
+
});
|
|
281
|
+
if (logger.isEnabled('debug'))
|
|
282
|
+
logger.log('debug', 'SSE stream opened', {
|
|
283
|
+
stream: entry.id,
|
|
284
|
+
path: route.path,
|
|
285
|
+
tenant: entry.tenant,
|
|
286
|
+
member: entry.member,
|
|
287
|
+
rooms: rooms.length,
|
|
288
|
+
sealed: seal !== undefined,
|
|
289
|
+
lastEventId: lastEventId ?? null,
|
|
290
|
+
remote: `${request.socket.remoteAddress ?? '?'}:${request.socket.remotePort ?? '?'}`,
|
|
291
|
+
open: this.#entries.size,
|
|
292
|
+
});
|
|
293
|
+
if (route.onOpen) {
|
|
294
|
+
const onOpen = route.onOpen;
|
|
295
|
+
await this.#task(Promise.resolve()
|
|
296
|
+
.then(() => onOpen(entry.stream))
|
|
297
|
+
.catch((error) => {
|
|
298
|
+
this.#throttle.report('error', 'SSE onOpen failed; stream ended', 'SSE_ON_OPEN_FAILED', error, {
|
|
299
|
+
path: route.path,
|
|
300
|
+
stream: entry.id,
|
|
301
|
+
});
|
|
302
|
+
this.#end(entry, 'ON_OPEN_FAILED');
|
|
303
|
+
}));
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
#identity(identity) {
|
|
307
|
+
const rooms = identity.rooms ?? [];
|
|
308
|
+
if (typeof identity !== 'object' ||
|
|
309
|
+
!text(identity.tenant) ||
|
|
310
|
+
!text(identity.member) ||
|
|
311
|
+
!Array.isArray(rooms))
|
|
312
|
+
throw new SseError('INVALID_IDENTITY', 'authenticate must return { tenant, member, rooms? } with strings of 1 to 256 characters.');
|
|
313
|
+
const unique = [...new Set(rooms.map(checkRoom))];
|
|
314
|
+
if (unique.length > this.options.maxRooms)
|
|
315
|
+
throw new SseError('ROOM_LIMIT', `A stream may be in ${this.options.maxRooms} rooms.`);
|
|
316
|
+
return unique;
|
|
317
|
+
}
|
|
318
|
+
#add(res, identity, route, rooms, lastEventId, seal) {
|
|
319
|
+
const entry = {
|
|
320
|
+
id: randomUUID(),
|
|
321
|
+
res,
|
|
322
|
+
identity,
|
|
323
|
+
tenant: identity.tenant,
|
|
324
|
+
member: identity.member,
|
|
325
|
+
route,
|
|
326
|
+
rooms: new Set(),
|
|
327
|
+
opened: performance.now(),
|
|
328
|
+
lastEventId,
|
|
329
|
+
seal,
|
|
330
|
+
reason: undefined,
|
|
331
|
+
closed: false,
|
|
332
|
+
stream: undefined,
|
|
333
|
+
};
|
|
334
|
+
entry.stream = Object.freeze({
|
|
335
|
+
id: entry.id,
|
|
336
|
+
tenant: entry.tenant,
|
|
337
|
+
member: entry.member,
|
|
338
|
+
identity,
|
|
339
|
+
path: route.path,
|
|
340
|
+
lastEventId,
|
|
341
|
+
get rooms() {
|
|
342
|
+
return [...entry.rooms];
|
|
343
|
+
},
|
|
344
|
+
get closed() {
|
|
345
|
+
return entry.closed || entry.reason !== undefined;
|
|
346
|
+
},
|
|
347
|
+
send: (event, data) => {
|
|
348
|
+
checkEvent(event);
|
|
349
|
+
const json = encodeData(data, this.options.maxEventBytes);
|
|
350
|
+
return this.#event(entry, this.#nextId(), event, json);
|
|
351
|
+
},
|
|
352
|
+
join: (room) => this.#join(entry, checkRoom(room)),
|
|
353
|
+
leave: (room) => this.#leave(entry, checkRoom(room)),
|
|
354
|
+
close: () => this.#end(entry, 'SERVER_CLOSED'),
|
|
355
|
+
});
|
|
356
|
+
this.#entries.set(entry.id, entry);
|
|
357
|
+
add(this.#tenants, entry.tenant, entry);
|
|
358
|
+
add(this.#members, key(entry.tenant, entry.member), entry);
|
|
359
|
+
for (const room of rooms)
|
|
360
|
+
this.#join(entry, room);
|
|
361
|
+
return entry;
|
|
362
|
+
}
|
|
363
|
+
#join(entry, room) {
|
|
364
|
+
if (entry.closed || entry.rooms.has(room))
|
|
365
|
+
return;
|
|
366
|
+
if (entry.rooms.size >= this.options.maxRooms)
|
|
367
|
+
throw new SseError('ROOM_LIMIT', `A stream may be in ${this.options.maxRooms} rooms.`);
|
|
368
|
+
entry.rooms.add(room);
|
|
369
|
+
add(this.#rooms, key(entry.tenant, room), entry);
|
|
370
|
+
if (this.context.logger.isEnabled('debug'))
|
|
371
|
+
this.context.logger.log('debug', 'SSE room joined', {
|
|
372
|
+
stream: entry.id,
|
|
373
|
+
room: room.slice(0, 64),
|
|
374
|
+
rooms: entry.rooms.size,
|
|
375
|
+
});
|
|
376
|
+
}
|
|
377
|
+
#leave(entry, room) {
|
|
378
|
+
if (!entry.rooms.delete(room))
|
|
379
|
+
return;
|
|
380
|
+
remove(this.#rooms, key(entry.tenant, room), entry);
|
|
381
|
+
if (this.context.logger.isEnabled('debug'))
|
|
382
|
+
this.context.logger.log('debug', 'SSE room left', {
|
|
383
|
+
stream: entry.id,
|
|
384
|
+
room: room.slice(0, 64),
|
|
385
|
+
rooms: entry.rooms.size,
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
#nextId() {
|
|
389
|
+
this.#sequence++;
|
|
390
|
+
return `${this.#prefix}-${this.#sequence.toString(36)}`;
|
|
391
|
+
}
|
|
392
|
+
/**
|
|
393
|
+
* One event for one stream: the shared plain chunk, or this stream's own sealed frame (D-0062).
|
|
394
|
+
* A seal that fails ends the stream rather than send the data plain (fail closed).
|
|
395
|
+
*/
|
|
396
|
+
#event(entry, id, event, json, plain) {
|
|
397
|
+
if (!entry.seal)
|
|
398
|
+
return this.#write(entry, plain ?? Buffer.from(frame(id, event, json)));
|
|
399
|
+
let chunk;
|
|
400
|
+
try {
|
|
401
|
+
chunk = Buffer.from(frame(id, event, entry.seal.encode(json)));
|
|
402
|
+
}
|
|
403
|
+
catch (error) {
|
|
404
|
+
this.#throttle.report('error', 'SSE event seal failed; stream ended', 'SEAL_ENCODE_FAILED', error, {
|
|
405
|
+
stream: entry.id,
|
|
406
|
+
path: entry.route.path,
|
|
407
|
+
});
|
|
408
|
+
this.#end(entry, 'WRITE_FAILED', true);
|
|
409
|
+
return false;
|
|
410
|
+
}
|
|
411
|
+
return this.#write(entry, chunk);
|
|
412
|
+
}
|
|
413
|
+
/** Queues `chunk` on the stream unless that would pass maxBufferedBytes (then it ends). */
|
|
414
|
+
#write(entry, chunk) {
|
|
415
|
+
if (entry.closed || entry.reason !== undefined)
|
|
416
|
+
return false;
|
|
417
|
+
const res = entry.res;
|
|
418
|
+
// ServerResponse.writableLength counts what waits in the response and in its socket.
|
|
419
|
+
if (res.writableLength + chunk.byteLength > this.options.maxBufferedBytes) {
|
|
420
|
+
this.#throttle.report('warn', 'SSE slow consumer ended', 'SLOW_CONSUMER', undefined, {
|
|
421
|
+
stream: entry.id,
|
|
422
|
+
path: entry.route.path,
|
|
423
|
+
bufferedBytes: res.writableLength,
|
|
424
|
+
maxBufferedBytes: this.options.maxBufferedBytes,
|
|
425
|
+
});
|
|
426
|
+
// Not end(): its last chunk would wait behind the data the client is not reading.
|
|
427
|
+
this.#end(entry, 'SLOW_CONSUMER', true);
|
|
428
|
+
return false;
|
|
429
|
+
}
|
|
430
|
+
res.write(chunk);
|
|
431
|
+
return true;
|
|
432
|
+
}
|
|
433
|
+
#end(entry, reason, destroy = false) {
|
|
434
|
+
if (entry.closed || entry.reason !== undefined)
|
|
435
|
+
return;
|
|
436
|
+
entry.reason = reason;
|
|
437
|
+
if (destroy)
|
|
438
|
+
entry.res.destroy();
|
|
439
|
+
else
|
|
440
|
+
entry.res.end();
|
|
441
|
+
}
|
|
442
|
+
#closed(entry) {
|
|
443
|
+
if (entry.closed)
|
|
444
|
+
return;
|
|
445
|
+
entry.closed = true;
|
|
446
|
+
const reason = entry.reason ?? 'CLIENT_CLOSED';
|
|
447
|
+
entry.reason = reason;
|
|
448
|
+
this.#entries.delete(entry.id);
|
|
449
|
+
remove(this.#tenants, entry.tenant, entry);
|
|
450
|
+
remove(this.#members, key(entry.tenant, entry.member), entry);
|
|
451
|
+
for (const room of entry.rooms)
|
|
452
|
+
remove(this.#rooms, key(entry.tenant, room), entry);
|
|
453
|
+
if (this.context.logger.isEnabled('debug'))
|
|
454
|
+
this.context.logger.log('debug', 'SSE stream closed', {
|
|
455
|
+
stream: entry.id,
|
|
456
|
+
path: entry.route.path,
|
|
457
|
+
member: entry.member,
|
|
458
|
+
reason,
|
|
459
|
+
ms: Math.round(performance.now() - entry.opened),
|
|
460
|
+
open: this.#entries.size,
|
|
461
|
+
});
|
|
462
|
+
const onClose = entry.route.onClose;
|
|
463
|
+
if (onClose)
|
|
464
|
+
void this.#task(Promise.resolve()
|
|
465
|
+
.then(() => onClose(entry.stream, reason))
|
|
466
|
+
.catch((error) => {
|
|
467
|
+
this.#throttle.report('error', 'SSE onClose failed', 'SSE_ON_CLOSE_FAILED', error, {
|
|
468
|
+
path: entry.route.path,
|
|
469
|
+
stream: entry.id,
|
|
470
|
+
reason,
|
|
471
|
+
});
|
|
472
|
+
}));
|
|
473
|
+
}
|
|
474
|
+
/** Tracks an onOpen/onClose call (it never rejects: each logs its own failure). */
|
|
475
|
+
#task(task) {
|
|
476
|
+
this.#tasks.add(task);
|
|
477
|
+
return task.finally(() => this.#tasks.delete(task));
|
|
478
|
+
}
|
|
479
|
+
#select(target) {
|
|
480
|
+
if (target.room !== undefined)
|
|
481
|
+
return this.#rooms.get(key(target.tenant, target.room));
|
|
482
|
+
if (target.member !== undefined)
|
|
483
|
+
return this.#members.get(key(target.tenant, target.member));
|
|
484
|
+
return this.#tenants.get(target.tenant);
|
|
485
|
+
}
|
|
486
|
+
/** Writes one event to the matching streams of this process; returns how many took it. */
|
|
487
|
+
#deliver(target, event, id, json) {
|
|
488
|
+
if ('tenantPrefix' in target) {
|
|
489
|
+
// Each tenant under the prefix (D-0061); one stream belongs to one tenant, so none twice.
|
|
490
|
+
let written = 0;
|
|
491
|
+
const shared = { id, event, json, chunk: undefined };
|
|
492
|
+
for (const tenant of [...this.#tenants.keys()])
|
|
493
|
+
if (tenant.startsWith(target.tenantPrefix))
|
|
494
|
+
written += this.#writeAll(this.#select({ ...target, tenant }), target, shared);
|
|
495
|
+
return written;
|
|
496
|
+
}
|
|
497
|
+
const set = this.#select(target);
|
|
498
|
+
if (!set?.size)
|
|
499
|
+
return 0;
|
|
500
|
+
return this.#writeAll(set, target, { id, event, json, chunk: undefined });
|
|
501
|
+
}
|
|
502
|
+
/**
|
|
503
|
+
* Plain streams share one buffer, made at the first plain recipient; sealed streams each get
|
|
504
|
+
* their own frame (D-0062, like sealed WebSocket broadcasts).
|
|
505
|
+
*/
|
|
506
|
+
#writeAll(set, target, shared) {
|
|
507
|
+
let written = 0;
|
|
508
|
+
for (const entry of set ?? []) {
|
|
509
|
+
if (target.room !== undefined &&
|
|
510
|
+
target.member !== undefined &&
|
|
511
|
+
entry.member !== target.member)
|
|
512
|
+
continue;
|
|
513
|
+
if (!entry.seal)
|
|
514
|
+
shared.chunk ??= Buffer.from(frame(shared.id, shared.event, shared.json));
|
|
515
|
+
if (this.#event(entry, shared.id, shared.event, shared.json, shared.chunk))
|
|
516
|
+
written++;
|
|
517
|
+
}
|
|
518
|
+
return written;
|
|
519
|
+
}
|
|
520
|
+
async #publish(target, event, data) {
|
|
521
|
+
const checked = checkTarget(target);
|
|
522
|
+
checkEvent(event);
|
|
523
|
+
const json = encodeData(data, this.options.maxEventBytes);
|
|
524
|
+
const bus = this.bus;
|
|
525
|
+
if (bus && bus.state !== 'ready') {
|
|
526
|
+
const error = new SseError('BUS_UNAVAILABLE', `Event bus ${this.options.bus} is ${bus.state}; the event was not sent.`);
|
|
527
|
+
this.#throttle.report('error', 'SSE publish refused; bus unavailable', 'BUS_UNAVAILABLE', error, {
|
|
528
|
+
bus: this.options.bus,
|
|
529
|
+
state: bus.state,
|
|
530
|
+
});
|
|
531
|
+
throw error;
|
|
532
|
+
}
|
|
533
|
+
const id = this.#nextId();
|
|
534
|
+
const local = this.#closing ? 0 : this.#deliver(checked, event, id, json);
|
|
535
|
+
if (bus) {
|
|
536
|
+
try {
|
|
537
|
+
await bus.publish(this.options.busEvent, {
|
|
538
|
+
v: 1,
|
|
539
|
+
id,
|
|
540
|
+
origin: this.#origin,
|
|
541
|
+
target: checked,
|
|
542
|
+
event,
|
|
543
|
+
data: json,
|
|
544
|
+
});
|
|
545
|
+
}
|
|
546
|
+
catch (cause) {
|
|
547
|
+
this.#throttle.report('error', 'SSE bus publish failed', 'BUS_PUBLISH_FAILED', cause, {
|
|
548
|
+
bus: this.options.bus,
|
|
549
|
+
id,
|
|
550
|
+
local,
|
|
551
|
+
busCode: errorCode(cause, 'UNKNOWN'),
|
|
552
|
+
});
|
|
553
|
+
throw new SseError('BUS_PUBLISH_FAILED', 'The event reached this process, but publishing it on the bus failed; other processes may not have it.', { cause, id, local });
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
if (this.context.logger.isEnabled('debug'))
|
|
557
|
+
this.context.logger.log('debug', 'SSE published', {
|
|
558
|
+
id,
|
|
559
|
+
event,
|
|
560
|
+
tenant: 'tenant' in checked ? checked.tenant : null,
|
|
561
|
+
tenantPrefix: 'tenantPrefix' in checked ? checked.tenantPrefix.slice(0, 64) : null,
|
|
562
|
+
member: checked.member ?? null,
|
|
563
|
+
room: checked.room?.slice(0, 64) ?? null,
|
|
564
|
+
local,
|
|
565
|
+
bus: bus !== undefined,
|
|
566
|
+
});
|
|
567
|
+
return Object.freeze({ id, local });
|
|
568
|
+
}
|
|
569
|
+
/** A publication from the bus: delivered here unless this process published it. */
|
|
570
|
+
#fromBus(payload) {
|
|
571
|
+
const message = payload;
|
|
572
|
+
let target;
|
|
573
|
+
if (message &&
|
|
574
|
+
typeof message === 'object' &&
|
|
575
|
+
message.v === 1 &&
|
|
576
|
+
typeof message.id === 'string' &&
|
|
577
|
+
ID.test(message.id) &&
|
|
578
|
+
typeof message.origin === 'string' &&
|
|
579
|
+
typeof message.event === 'string' &&
|
|
580
|
+
EVENT.test(message.event) &&
|
|
581
|
+
typeof message.data === 'string' &&
|
|
582
|
+
isLine(message.data)) {
|
|
583
|
+
try {
|
|
584
|
+
target = checkTarget(message.target);
|
|
585
|
+
}
|
|
586
|
+
catch (error) {
|
|
587
|
+
this.#throttle.report('error', 'SSE bus message rejected', 'BUS_MESSAGE_INVALID', error, {
|
|
588
|
+
bus: this.options.bus,
|
|
589
|
+
});
|
|
590
|
+
return;
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
if (!message || !target) {
|
|
594
|
+
this.#throttle.report('error', 'SSE bus message rejected', 'BUS_MESSAGE_INVALID', undefined, {
|
|
595
|
+
bus: this.options.bus,
|
|
596
|
+
});
|
|
597
|
+
return;
|
|
598
|
+
}
|
|
599
|
+
// The publishing process wrote it to its own streams already (exactly once there).
|
|
600
|
+
if (message.origin === this.#origin || this.#closing)
|
|
601
|
+
return;
|
|
602
|
+
const local = this.#deliver(target, message.event, message.id, message.data);
|
|
603
|
+
if (this.context.logger.isEnabled('debug'))
|
|
604
|
+
this.context.logger.log('debug', 'SSE bus event delivered', {
|
|
605
|
+
id: message.id,
|
|
606
|
+
event: message.event,
|
|
607
|
+
local,
|
|
608
|
+
});
|
|
609
|
+
}
|
|
610
|
+
#count(filter = {}) {
|
|
611
|
+
if (filter === null || typeof filter !== 'object')
|
|
612
|
+
throw new SseError('INVALID_TARGET', 'connections() takes { tenant?, member?, room?, path? }.');
|
|
613
|
+
let set = this.#entries.values();
|
|
614
|
+
if (filter.tenant !== undefined) {
|
|
615
|
+
const narrowed = this.#select(filter);
|
|
616
|
+
if (!narrowed)
|
|
617
|
+
return 0;
|
|
618
|
+
set = narrowed;
|
|
619
|
+
}
|
|
620
|
+
let count = 0;
|
|
621
|
+
for (const entry of set)
|
|
622
|
+
if ((filter.member === undefined || entry.member === filter.member) &&
|
|
623
|
+
(filter.room === undefined || entry.rooms.has(filter.room)) &&
|
|
624
|
+
(filter.path === undefined || entry.route.path === filter.path))
|
|
625
|
+
count++;
|
|
626
|
+
return count;
|
|
627
|
+
}
|
|
628
|
+
#busState() {
|
|
629
|
+
const bus = this.bus;
|
|
630
|
+
if (!bus || this.#closing)
|
|
631
|
+
return;
|
|
632
|
+
const up = bus.state === 'ready';
|
|
633
|
+
if (up === this.#busUp)
|
|
634
|
+
return;
|
|
635
|
+
const first = this.#busUp === undefined;
|
|
636
|
+
this.#busUp = up;
|
|
637
|
+
if (up) {
|
|
638
|
+
this.#health?.ready();
|
|
639
|
+
if (!first)
|
|
640
|
+
this.context.logger.log('info', 'SSE bus available again', { bus: this.options.bus });
|
|
641
|
+
}
|
|
642
|
+
else {
|
|
643
|
+
this.#health?.fail('BUS_UNAVAILABLE');
|
|
644
|
+
this.context.logger.log('warn', 'SSE bus unavailable; publications fail', {
|
|
645
|
+
code: 'BUS_UNAVAILABLE',
|
|
646
|
+
bus: this.options.bus,
|
|
647
|
+
state: bus.state,
|
|
648
|
+
});
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
start() {
|
|
652
|
+
this.#frozen = true;
|
|
653
|
+
this.#heartbeat = setInterval(() => {
|
|
654
|
+
for (const entry of this.#entries.values())
|
|
655
|
+
this.#write(entry, PING);
|
|
656
|
+
}, this.options.heartbeatMs);
|
|
657
|
+
this.#heartbeat.unref();
|
|
658
|
+
if (this.bus) {
|
|
659
|
+
this.#busState();
|
|
660
|
+
this.#watch = setInterval(() => this.#busState(), BUS_WATCH_MS);
|
|
661
|
+
this.#watch.unref();
|
|
662
|
+
}
|
|
663
|
+
else
|
|
664
|
+
this.#health?.ready();
|
|
665
|
+
this.context.logger.log('debug', 'SSE gateway started', {
|
|
666
|
+
routes: this.#routes,
|
|
667
|
+
bus: this.options.bus ?? null,
|
|
668
|
+
heartbeatMs: this.options.heartbeatMs,
|
|
669
|
+
retryMs: this.options.retryMs,
|
|
670
|
+
maxConnections: this.options.maxConnections,
|
|
671
|
+
});
|
|
672
|
+
}
|
|
673
|
+
/** Sends `dokebi-restart` to every stream, ends them, and cuts the rest at the deadline. */
|
|
674
|
+
async drain(signal) {
|
|
675
|
+
const started = performance.now();
|
|
676
|
+
const logger = this.context.logger;
|
|
677
|
+
this.#closing = true;
|
|
678
|
+
this.#frozen = true;
|
|
679
|
+
this.#timers();
|
|
680
|
+
logger.log('debug', 'SSE drain started', { open: this.#entries.size, tasks: this.#tasks.size });
|
|
681
|
+
const subscription = this.#subscription;
|
|
682
|
+
this.#subscription = undefined;
|
|
683
|
+
if (subscription)
|
|
684
|
+
try {
|
|
685
|
+
await subscription.unsubscribe();
|
|
686
|
+
}
|
|
687
|
+
catch (error) {
|
|
688
|
+
this.#throttle.report('error', 'SSE bus unsubscribe failed', 'BUS_UNSUBSCRIBE_FAILED', error, {
|
|
689
|
+
bus: this.options.bus,
|
|
690
|
+
});
|
|
691
|
+
}
|
|
692
|
+
const json = JSON.stringify({ retryMs: this.options.retryMs });
|
|
693
|
+
const restart = Buffer.from(frame(undefined, RESTART_EVENT, json));
|
|
694
|
+
for (const entry of [...this.#entries.values()]) {
|
|
695
|
+
this.#event(entry, undefined, RESTART_EVENT, json, restart);
|
|
696
|
+
this.#end(entry, 'DRAIN');
|
|
697
|
+
}
|
|
698
|
+
const deadline = performance.now() + this.options.closeTimeoutMs;
|
|
699
|
+
while (this.#entries.size && performance.now() < deadline && !signal.aborted)
|
|
700
|
+
await delay(10);
|
|
701
|
+
if (this.#entries.size) {
|
|
702
|
+
logger.log('debug', 'SSE drain cutting streams', {
|
|
703
|
+
open: this.#entries.size,
|
|
704
|
+
aborted: signal.aborted,
|
|
705
|
+
});
|
|
706
|
+
for (const entry of this.#entries.values())
|
|
707
|
+
entry.res.destroy();
|
|
708
|
+
}
|
|
709
|
+
while (this.#tasks.size && !signal.aborted)
|
|
710
|
+
await delay(10);
|
|
711
|
+
this.#throttle.flush();
|
|
712
|
+
logger.log('debug', 'SSE drain finished', {
|
|
713
|
+
ms: Math.round(performance.now() - started),
|
|
714
|
+
aborted: signal.aborted,
|
|
715
|
+
});
|
|
716
|
+
}
|
|
717
|
+
#timers() {
|
|
718
|
+
if (this.#heartbeat)
|
|
719
|
+
clearInterval(this.#heartbeat);
|
|
720
|
+
if (this.#watch)
|
|
721
|
+
clearInterval(this.#watch);
|
|
722
|
+
this.#heartbeat = undefined;
|
|
723
|
+
this.#watch = undefined;
|
|
724
|
+
}
|
|
725
|
+
/** Cleanup: after a failed start no drain ran, so streams may still be open. */
|
|
726
|
+
close() {
|
|
727
|
+
this.#closing = true;
|
|
728
|
+
this.#timers();
|
|
729
|
+
for (const entry of this.#entries.values())
|
|
730
|
+
entry.res.destroy();
|
|
731
|
+
this.#throttle.flush();
|
|
732
|
+
}
|
|
733
|
+
}
|