@steve31415/baselib 3.47.0 → 3.47.1
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/dist/hub/notify.d.ts +4 -0
- package/dist/hub/notify.js +7 -1
- package/dist/hub/server.d.ts +21 -0
- package/dist/hub/server.js +82 -8
- package/package.json +1 -1
package/dist/hub/notify.d.ts
CHANGED
|
@@ -45,6 +45,10 @@ export declare class HubNotifyBatch {
|
|
|
45
45
|
private readonly notifier;
|
|
46
46
|
private readonly hints;
|
|
47
47
|
constructor(notifier: HubNotifier);
|
|
48
|
+
/** Coalesced per (user, kind), as design §5.3 says, except `notification`,
|
|
49
|
+
* whose payloads differ in meaning: only exact duplicates of those fold.
|
|
50
|
+
* A folded `work` keeps the first hint's ref (refs are optional pickup
|
|
51
|
+
* shortcuts; the daemon re-reads its queue either way). */
|
|
48
52
|
add(hint: AppHint): void;
|
|
49
53
|
/** Send every distinct hint (concurrently, each within the notifier's
|
|
50
54
|
* bound) and empty the batch. Never throws. */
|
package/dist/hub/notify.js
CHANGED
|
@@ -81,6 +81,8 @@ export class HubNotifier {
|
|
|
81
81
|
body: JSON.stringify({ ...hint, app: this.app }),
|
|
82
82
|
signal: AbortSignal.timeout(this.timeoutMs),
|
|
83
83
|
});
|
|
84
|
+
// An unread body holds the connection until garbage collection.
|
|
85
|
+
await res.body?.cancel().catch(() => { });
|
|
84
86
|
if (res.status === 202)
|
|
85
87
|
return null;
|
|
86
88
|
// 400/401/403/413: the hint or the grant is wrong, not the network.
|
|
@@ -97,8 +99,12 @@ export class HubNotifyBatch {
|
|
|
97
99
|
constructor(notifier) {
|
|
98
100
|
this.notifier = notifier;
|
|
99
101
|
}
|
|
102
|
+
/** Coalesced per (user, kind), as design §5.3 says, except `notification`,
|
|
103
|
+
* whose payloads differ in meaning: only exact duplicates of those fold.
|
|
104
|
+
* A folded `work` keeps the first hint's ref (refs are optional pickup
|
|
105
|
+
* shortcuts; the daemon re-reads its queue either way). */
|
|
100
106
|
add(hint) {
|
|
101
|
-
const key = JSON.stringify([hint.user, hint.kind, hint.ref ?? null, hint.payload ?? null]);
|
|
107
|
+
const key = hint.kind === 'notification' ? JSON.stringify([hint.user, hint.kind, hint.ref ?? null, hint.payload ?? null]) : JSON.stringify([hint.user, hint.kind]);
|
|
102
108
|
if (!this.hints.has(key))
|
|
103
109
|
this.hints.set(key, hint);
|
|
104
110
|
}
|
package/dist/hub/server.d.ts
CHANGED
|
@@ -21,6 +21,15 @@ export declare const DAEMON_LOOKBACK_MAX_MS = 60000;
|
|
|
21
21
|
/** 401s per client address per minute on the token path before further
|
|
22
22
|
* tokens from it are refused without a database lookup. */
|
|
23
23
|
export declare const TOKEN_FAILURES_PER_MIN = 20;
|
|
24
|
+
/** Cross-copy publishes in flight at once; past it a publish is dropped (a
|
|
25
|
+
* lost hint is tolerated) so a slow database cannot queue publishes ahead
|
|
26
|
+
* of the token lookups that share the pool. */
|
|
27
|
+
export declare const HUB_PUBLISHES_IN_FLIGHT_MAX = 16;
|
|
28
|
+
/** The largest frame a browser may send (its frames are `connect` and `ping`). */
|
|
29
|
+
export declare const HUB_SOCKET_MAX_PAYLOAD = 4096;
|
|
30
|
+
/** A socket that has sent nothing (clients ping every 30 s) for this long is
|
|
31
|
+
* closed: a half-dead socket would otherwise hold a slot for an hour. */
|
|
32
|
+
export declare const HUB_SOCKET_IDLE_MS = 180000;
|
|
24
33
|
export interface HubOptions {
|
|
25
34
|
pool: pg.Pool;
|
|
26
35
|
logger: Logger;
|
|
@@ -40,6 +49,13 @@ export interface HubOptions {
|
|
|
40
49
|
* origins (local development and Playwright). */
|
|
41
50
|
allowLocalOrigins?: boolean;
|
|
42
51
|
upgradeWebSocket: UpgradeWebSocket;
|
|
52
|
+
/** The upgrader's `ws` server (`createNodeWebSocket(...).wss`): the hub
|
|
53
|
+
* caps its incoming frame size (ws's default is 100 MiB). */
|
|
54
|
+
webSocketServer?: {
|
|
55
|
+
options: {
|
|
56
|
+
maxPayload?: number;
|
|
57
|
+
};
|
|
58
|
+
};
|
|
43
59
|
/** Passed through to requireIdentity (authUrl, whoamiFetch, verifyClient,
|
|
44
60
|
* rateLimitPerMin…). The hub admits invited users for sockets. */
|
|
45
61
|
identity?: Partial<Omit<RequireIdentityOptions, 'audience' | 'grants' | 'logger' | 'userAccess'>>;
|
|
@@ -47,6 +63,11 @@ export interface HubOptions {
|
|
|
47
63
|
bridge?: Partial<Pick<DbBroadcastBridgeOptions, 'heartbeatMs' | 'heartbeatTimeoutMs' | 'reconnectDelaysMs'>>;
|
|
48
64
|
/** Monotonic milliseconds (tests). */
|
|
49
65
|
now?: () => number;
|
|
66
|
+
/** Tests: the idle-socket limit and sweep interval. */
|
|
67
|
+
socketIdle?: {
|
|
68
|
+
idleMs: number;
|
|
69
|
+
sweepMs: number;
|
|
70
|
+
};
|
|
50
71
|
}
|
|
51
72
|
export interface Hub {
|
|
52
73
|
/** Start the cross-copy bridge. Call before the server accepts
|
package/dist/hub/server.js
CHANGED
|
@@ -58,7 +58,18 @@ export const DAEMON_LOOKBACK_MAX_MS = 60_000;
|
|
|
58
58
|
/** 401s per client address per minute on the token path before further
|
|
59
59
|
* tokens from it are refused without a database lookup. */
|
|
60
60
|
export const TOKEN_FAILURES_PER_MIN = 20;
|
|
61
|
+
/** Cross-copy publishes in flight at once; past it a publish is dropped (a
|
|
62
|
+
* lost hint is tolerated) so a slow database cannot queue publishes ahead
|
|
63
|
+
* of the token lookups that share the pool. */
|
|
64
|
+
export const HUB_PUBLISHES_IN_FLIGHT_MAX = 16;
|
|
65
|
+
/** The largest frame a browser may send (its frames are `connect` and `ping`). */
|
|
66
|
+
export const HUB_SOCKET_MAX_PAYLOAD = 4096;
|
|
67
|
+
/** A socket that has sent nothing (clients ping every 30 s) for this long is
|
|
68
|
+
* closed: a half-dead socket would otherwise hold a slot for an hour. */
|
|
69
|
+
export const HUB_SOCKET_IDLE_MS = 180_000;
|
|
70
|
+
const HUB_SOCKET_SWEEP_MS = 30_000;
|
|
61
71
|
const TOKEN_FORMAT = /^hubt_[A-Za-z0-9_-]{43}$/;
|
|
72
|
+
const WEBSOCKET_KEY = /^[+/0-9A-Za-z]{22}==$/;
|
|
62
73
|
const TOKEN_HASH_FORMAT = /^[0-9a-f]{64}$/;
|
|
63
74
|
const DAEMON_ID_FORMAT = /^[A-Za-z0-9_-]{8,64}$/;
|
|
64
75
|
export function createHub(app, opts) {
|
|
@@ -74,6 +85,8 @@ export function createHub(app, opts) {
|
|
|
74
85
|
userAccess: 'invited',
|
|
75
86
|
});
|
|
76
87
|
const serviceAccount = new RegExp(`^([a-z][a-z0-9-]*)-svc@${escapeRegExp(opts.serviceAccountDomain)}$`);
|
|
88
|
+
if (opts.webSocketServer)
|
|
89
|
+
opts.webSocketServer.options.maxPayload = HUB_SOCKET_MAX_PAYLOAD;
|
|
77
90
|
// app → user → sockets
|
|
78
91
|
const sockets = new Map();
|
|
79
92
|
const waits = new Map();
|
|
@@ -82,6 +95,8 @@ export function createHub(app, opts) {
|
|
|
82
95
|
// when the bridge last recovered: hints sent while it was deaf are unknowable
|
|
83
96
|
let lastBlanketAt = -Infinity;
|
|
84
97
|
const publishes = new Set();
|
|
98
|
+
let droppedPublishes = 0;
|
|
99
|
+
let lastDropWarnAt = -Infinity;
|
|
85
100
|
const tokenFailures = new FailureCounter(TOKEN_FAILURES_PER_MIN);
|
|
86
101
|
let stopping = false;
|
|
87
102
|
const bridge = new DbBroadcastBridge({
|
|
@@ -141,6 +156,15 @@ export function createHub(app, opts) {
|
|
|
141
156
|
waiter.finish(workResponse());
|
|
142
157
|
}
|
|
143
158
|
function publish(hint) {
|
|
159
|
+
if (publishes.size >= HUB_PUBLISHES_IN_FLIGHT_MAX) {
|
|
160
|
+
droppedPublishes++;
|
|
161
|
+
if (now() - lastDropWarnAt >= 10_000) {
|
|
162
|
+
lastDropWarnAt = now();
|
|
163
|
+
logger.warn('hub publishes dropped', { dropped: droppedPublishes, inFlight: publishes.size });
|
|
164
|
+
droppedPublishes = 0;
|
|
165
|
+
}
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
144
168
|
const p = withinMs(bridge.notify({ hint }), HUB_PUBLISH_TIMEOUT_MS, 'hub publish')
|
|
145
169
|
.catch((error) => {
|
|
146
170
|
logger.warn('hub publish failed', { app: hint.app, kind: hint.kind, error: serializeError(error) });
|
|
@@ -184,6 +208,8 @@ export function createHub(app, opts) {
|
|
|
184
208
|
return c.body(null, 202);
|
|
185
209
|
});
|
|
186
210
|
app.post('/api/register-daemon', identity, async (c) => {
|
|
211
|
+
if (stopping)
|
|
212
|
+
return c.json({ error: 'hub restarting' }, 503);
|
|
187
213
|
const caller = callerApp(c);
|
|
188
214
|
if (!caller)
|
|
189
215
|
return c.json({ error: 'app service accounts only' }, 403);
|
|
@@ -228,6 +254,9 @@ export function createHub(app, opts) {
|
|
|
228
254
|
/** Resolves the waiting app: from a daemon hub token, or (no token) from
|
|
229
255
|
* `?app=` for a granted service caller. Cookies are refused (review B1). */
|
|
230
256
|
const daemonAuth = createMiddleware(async (c, next) => {
|
|
257
|
+
// The lookback and the wait both count from arrival, before the token
|
|
258
|
+
// lookup or identity check, whose time would otherwise open a gap.
|
|
259
|
+
c.set('hubArrivedAt', now());
|
|
231
260
|
const token = c.req.header('x-hub-token');
|
|
232
261
|
if (token === undefined) {
|
|
233
262
|
const { admitted, response } = await runIdentity(c);
|
|
@@ -280,13 +309,16 @@ export function createHub(app, opts) {
|
|
|
280
309
|
if (stopping)
|
|
281
310
|
return c.body(null, 204);
|
|
282
311
|
// A work hint between the daemon's direct request and this wait being
|
|
283
|
-
// parked would otherwise be lost to the 30 s timer (review S2).
|
|
284
|
-
|
|
312
|
+
// parked would otherwise be lost to the 30 s timer (review S2). The
|
|
313
|
+
// daemon's lookbackMs covers from its direct request's start to its
|
|
314
|
+
// arrival here; this copy has seen every hint since arrival.
|
|
315
|
+
const since = c.var.hubArrivedAt - lookbackMs;
|
|
285
316
|
const last = lastWork.get(appName);
|
|
286
|
-
if (
|
|
317
|
+
if (last && last.at >= since)
|
|
287
318
|
return workResponse(last.ref);
|
|
288
|
-
if (
|
|
319
|
+
if (lastBlanketAt >= since)
|
|
289
320
|
return workResponse();
|
|
321
|
+
const remainingMs = Math.max(0, waitSeconds * 1000 - (now() - c.var.hubArrivedAt));
|
|
290
322
|
return new Promise((resolve) => {
|
|
291
323
|
let set = waits.get(appName);
|
|
292
324
|
if (!set)
|
|
@@ -306,7 +338,7 @@ export function createHub(app, opts) {
|
|
|
306
338
|
},
|
|
307
339
|
};
|
|
308
340
|
const onAbort = () => waiter.finish(new Response(null, { status: 204 }));
|
|
309
|
-
const timer = setTimeout(() => waiter.finish(new Response(null, { status: 204 })),
|
|
341
|
+
const timer = setTimeout(() => waiter.finish(new Response(null, { status: 204 })), remainingMs);
|
|
310
342
|
waiters.add(waiter);
|
|
311
343
|
if (signal.aborted)
|
|
312
344
|
onAbort();
|
|
@@ -322,6 +354,15 @@ export function createHub(app, opts) {
|
|
|
322
354
|
const appName = c.req.query('app');
|
|
323
355
|
if (!isAppName(appName))
|
|
324
356
|
return c.json({ error: 'app required' }, 400);
|
|
357
|
+
// Only a well-formed handshake may reach the upgrader: @hono/node-ws
|
|
358
|
+
// keeps the request of an upgrade that ws aborts (a bad key or version)
|
|
359
|
+
// for the life of the process.
|
|
360
|
+
const version = c.req.header('sec-websocket-version');
|
|
361
|
+
if ((c.req.header('upgrade') ?? '').toLowerCase() !== 'websocket' ||
|
|
362
|
+
!WEBSOCKET_KEY.test(c.req.header('sec-websocket-key') ?? '') ||
|
|
363
|
+
(version !== '13' && version !== '8')) {
|
|
364
|
+
return c.json({ error: 'websocket handshake required' }, 400);
|
|
365
|
+
}
|
|
325
366
|
if (!originAllowed(c.req.header('origin'), appName, opts)) {
|
|
326
367
|
logger.warn('hub socket origin refused', { app: appName, origin: c.req.header('origin') ?? null });
|
|
327
368
|
return c.json({ error: 'origin not allowed' }, 403);
|
|
@@ -332,6 +373,12 @@ export function createHub(app, opts) {
|
|
|
332
373
|
const { admitted, response } = await runIdentity(c);
|
|
333
374
|
if (!admitted)
|
|
334
375
|
refusals.set(c, response?.status ?? 401);
|
|
376
|
+
// The client may have gone while its identity was checked; the upgrader
|
|
377
|
+
// would then keep its request forever (as above). Nothing awaits between
|
|
378
|
+
// this check and the upgrade.
|
|
379
|
+
const incoming = c.env?.incoming;
|
|
380
|
+
if (incoming?.socket && !(incoming.socket.readable && incoming.socket.writable))
|
|
381
|
+
return c.body(null, 400); // no one reads it
|
|
335
382
|
await next();
|
|
336
383
|
});
|
|
337
384
|
app.get('/api/ws', originCheck, socketIdentity, opts.upgradeWebSocket((c) => {
|
|
@@ -367,7 +414,7 @@ export function createHub(app, opts) {
|
|
|
367
414
|
ws.close(4403, 'user identity required');
|
|
368
415
|
return;
|
|
369
416
|
}
|
|
370
|
-
socket = { ws, app: appName, user };
|
|
417
|
+
socket = { ws, app: appName, user, lastHeardAt: now() };
|
|
371
418
|
let users = sockets.get(appName);
|
|
372
419
|
if (!users)
|
|
373
420
|
sockets.set(appName, (users = new Map()));
|
|
@@ -379,6 +426,7 @@ export function createHub(app, opts) {
|
|
|
379
426
|
onMessage: (evt, ws) => {
|
|
380
427
|
if (!socket)
|
|
381
428
|
return;
|
|
429
|
+
socket.lastHeardAt = now();
|
|
382
430
|
let message;
|
|
383
431
|
try {
|
|
384
432
|
message = JSON.parse(String(evt.data));
|
|
@@ -397,10 +445,34 @@ export function createHub(app, opts) {
|
|
|
397
445
|
onError: remove,
|
|
398
446
|
};
|
|
399
447
|
}));
|
|
448
|
+
const idle = opts.socketIdle ?? { idleMs: HUB_SOCKET_IDLE_MS, sweepMs: HUB_SOCKET_SWEEP_MS };
|
|
449
|
+
let sweeper;
|
|
450
|
+
function sweepIdleSockets() {
|
|
451
|
+
const t = now();
|
|
452
|
+
for (const users of sockets.values()) {
|
|
453
|
+
for (const set of users.values()) {
|
|
454
|
+
for (const socket of set) {
|
|
455
|
+
if (t - socket.lastHeardAt < idle.idleMs)
|
|
456
|
+
continue;
|
|
457
|
+
try {
|
|
458
|
+
socket.ws.close(4408, 'idle');
|
|
459
|
+
}
|
|
460
|
+
catch {
|
|
461
|
+
// already closing
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
}
|
|
400
467
|
return {
|
|
401
|
-
start
|
|
468
|
+
async start() {
|
|
469
|
+
sweeper = setInterval(sweepIdleSockets, idle.sweepMs);
|
|
470
|
+
sweeper.unref?.();
|
|
471
|
+
await bridge.start();
|
|
472
|
+
},
|
|
402
473
|
async stop() {
|
|
403
474
|
stopping = true;
|
|
475
|
+
clearInterval(sweeper);
|
|
404
476
|
for (const users of sockets.values()) {
|
|
405
477
|
for (const set of users.values()) {
|
|
406
478
|
for (const socket of set) {
|
|
@@ -417,7 +489,9 @@ export function createHub(app, opts) {
|
|
|
417
489
|
for (const set of waits.values())
|
|
418
490
|
for (const waiter of [...set])
|
|
419
491
|
waiter.finish(new Response(null, { status: 204 }));
|
|
420
|
-
|
|
492
|
+
// A notify racing shutdown may add a publish while these settle.
|
|
493
|
+
while (publishes.size > 0)
|
|
494
|
+
await Promise.allSettled([...publishes]);
|
|
421
495
|
await bridge.stop();
|
|
422
496
|
},
|
|
423
497
|
stats() {
|
package/package.json
CHANGED