@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.
@@ -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. */
@@ -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
  }
@@ -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
@@ -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
- const t = now();
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 (lookbackMs > 0 && last && t - last.at <= lookbackMs)
317
+ if (last && last.at >= since)
287
318
  return workResponse(last.ref);
288
- if (lookbackMs > 0 && t - lastBlanketAt <= lookbackMs)
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 })), waitSeconds * 1000);
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: () => bridge.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
- await Promise.allSettled([...publishes]);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@steve31415/baselib",
3
- "version": "3.47.0",
3
+ "version": "3.47.1",
4
4
  "description": "Plasticine new-world shared platform library: logging, auth, service-to-service auth, db, HTTP, sync, app updates",
5
5
  "type": "module",
6
6
  "license": "MIT",