@steve31415/baselib 3.57.0 → 3.58.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/README.md CHANGED
@@ -6,7 +6,7 @@ What it provides and why: `docs/SPEC.md`. How it's put together:
6
6
  `~/migration/research/base-services-design.md`.
7
7
 
8
8
  Server subpath exports: `config`, `log`, `auth`, `oauth-resource`, `connector`, `s2s`, `db`, `http`, `sync`,
9
- `app-update`, `llm`, `email`, `twitter`, `hub` (the connection hub's server core and app notifier), `code-manifest` and
9
+ `app-update`, `llm`, `email`, `twitter`, `hub` (the connection hub's server core, app notifier and daemon-token mint; `hub/testing` a local hub for tests), `hub-daemon` (the daemons' hub client), `code-manifest` and
10
10
  `client-version`. Browser exports: `log-browser`, `rum`, `slow-fetch`, `sync-browser`, `app-update-browser`, and
11
11
  `extension-self-update`.
12
12
 
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
@@ -0,0 +1,33 @@
1
+ #!/usr/bin/env node
2
+ // `baselib-test-hub` — a local connection hub for an app's Playwright boot
3
+ // (hub design §8 step 0): `AUTH_MODE=test TEST_USER=<email> baselib-test-hub
4
+ // --database-url <url> --apps radar[,todo] [--port N]`. Prints
5
+ // "test hub listening on <url>" once it accepts connections, and stops on
6
+ // SIGTERM or SIGINT. The app under test then runs with HUB_URL=<url> and
7
+ // TEST_S2S_IDENTITY=<app>-svc@test. All logic lives in ../hub/testing.ts.
8
+ import { parseArgs } from 'node:util';
9
+ import { startTestHub } from '../hub/testing.js';
10
+ const { values } = parseArgs({
11
+ options: {
12
+ 'database-url': { type: 'string' },
13
+ apps: { type: 'string' },
14
+ port: { type: 'string' },
15
+ host: { type: 'string' },
16
+ },
17
+ });
18
+ const databaseUrl = values['database-url'] ?? process.env.DATABASE_URL;
19
+ const apps = (values.apps ?? '').split(',').map((a) => a.trim()).filter(Boolean);
20
+ if (!databaseUrl || apps.length === 0) {
21
+ process.stderr.write('usage: baselib-test-hub --database-url <url> --apps <app>[,<app>] [--port N] [--host H]\n');
22
+ process.exit(2);
23
+ }
24
+ const hub = await startTestHub({
25
+ databaseUrl,
26
+ apps,
27
+ port: values.port ? Number(values.port) : undefined,
28
+ host: values.host,
29
+ });
30
+ process.stdout.write(`test hub listening on ${hub.url}\n`);
31
+ const stop = () => void hub.stop().finally(() => process.exit(0));
32
+ process.once('SIGTERM', stop);
33
+ process.once('SIGINT', stop);
@@ -1,3 +1,4 @@
1
1
  export * from './types.js';
2
2
  export * from './server.js';
3
3
  export * from './notify.js';
4
+ export * from './mint.js';
package/dist/hub/index.js CHANGED
@@ -1,3 +1,4 @@
1
1
  export * from './types.js';
2
2
  export * from './server.js';
3
3
  export * from './notify.js';
4
+ export * from './mint.js';
@@ -0,0 +1,26 @@
1
+ import { serviceFetch } from '../s2s.js';
2
+ /** The register call's bound (design §14.1: 5 s abort, mint included). */
3
+ export declare const HUB_REGISTER_TIMEOUT_MS = 5000;
4
+ export declare function isDaemonId(value: unknown): value is string;
5
+ /** The hub was unreachable or refused: the mint route answers 503 and the
6
+ * daemon treats the hub as unavailable. `status` is the hub's, if any. */
7
+ export declare class HubRegisterError extends Error {
8
+ readonly status?: number | undefined;
9
+ constructor(message: string, status?: number | undefined);
10
+ }
11
+ export interface MintDaemonHubTokenOptions {
12
+ /** The hub's canonical URL (also the OIDC audience). */
13
+ hubUrl: string;
14
+ /** The daemon's stable id, from its mint request. */
15
+ daemonId: string;
16
+ timeoutMs?: number;
17
+ /** Injectable for tests (default baselib serviceFetch). */
18
+ fetch?: typeof serviceFetch;
19
+ }
20
+ /** Make and register one daemon hub token. Throws HubRegisterError when the
21
+ * hub does not accept it (the caller answers 503), TypeError for a bad
22
+ * daemon id (the caller answers 400). */
23
+ export declare function mintDaemonHubToken(opts: MintDaemonHubTokenOptions): Promise<{
24
+ token: string;
25
+ ttlSeconds: number;
26
+ }>;
@@ -0,0 +1,53 @@
1
+ // The app side of a daemon hub token (design §14.1 as amended by §14.3 S3 and
2
+ // §14.4): the app's mint route, reached by its daemon with the daemon's own
3
+ // bearer, makes a random token, registers only its hash at the hub over s2s
4
+ // (the hub binds the registration to the calling app's service account), and
5
+ // hands the token to the daemon. The app never stores the token. The token
6
+ // authorizes nothing but parking on that app's `work` hints at the hub.
7
+ import { createHash, randomBytes } from 'node:crypto';
8
+ import { serviceFetch } from '../s2s.js';
9
+ /** The register call's bound (design §14.1: 5 s abort, mint included). */
10
+ export const HUB_REGISTER_TIMEOUT_MS = 5_000;
11
+ /** What the hub accepts as a daemon id (stable per host and app, §14.4). */
12
+ const DAEMON_ID_FORMAT = /^[A-Za-z0-9_-]{8,64}$/;
13
+ export function isDaemonId(value) {
14
+ return typeof value === 'string' && DAEMON_ID_FORMAT.test(value);
15
+ }
16
+ /** The hub was unreachable or refused: the mint route answers 503 and the
17
+ * daemon treats the hub as unavailable. `status` is the hub's, if any. */
18
+ export class HubRegisterError extends Error {
19
+ status;
20
+ constructor(message, status) {
21
+ super(message);
22
+ this.status = status;
23
+ this.name = 'HubRegisterError';
24
+ }
25
+ }
26
+ /** Make and register one daemon hub token. Throws HubRegisterError when the
27
+ * hub does not accept it (the caller answers 503), TypeError for a bad
28
+ * daemon id (the caller answers 400). */
29
+ export async function mintDaemonHubToken(opts) {
30
+ if (!isDaemonId(opts.daemonId))
31
+ throw new TypeError('daemonId must be 8-64 characters of [A-Za-z0-9_-]');
32
+ const audience = opts.hubUrl.replace(/\/$/, '');
33
+ const token = `hubt_${randomBytes(32).toString('base64url')}`;
34
+ const tokenHash = createHash('sha256').update(token).digest('hex');
35
+ let res;
36
+ try {
37
+ res = await (opts.fetch ?? serviceFetch)(`${audience}/api/register-daemon`, {
38
+ audience,
39
+ method: 'POST',
40
+ headers: { 'content-type': 'application/json' },
41
+ body: JSON.stringify({ tokenHash, daemonId: opts.daemonId }),
42
+ signal: AbortSignal.timeout(opts.timeoutMs ?? HUB_REGISTER_TIMEOUT_MS),
43
+ });
44
+ }
45
+ catch (error) {
46
+ throw new HubRegisterError(`hub register failed: ${error instanceof Error ? error.message : String(error)}`);
47
+ }
48
+ const body = (await res.json().catch(() => null));
49
+ if (res.status !== 200 && res.status !== 201)
50
+ throw new HubRegisterError(`hub register answered ${res.status}`, res.status);
51
+ const ttlSeconds = typeof body?.ttlSeconds === 'number' && body.ttlSeconds > 0 ? body.ttlSeconds : 3600;
52
+ return { token, ttlSeconds };
53
+ }
@@ -33,6 +33,7 @@ import { forwardedClientIp } from '../http.js';
33
33
  import { serializeError } from '../log.js';
34
34
  import { capSocketAge } from '../socket-age.js';
35
35
  import { DbBroadcastBridge } from '../sync/db-bridge.js';
36
+ import { isDaemonId } from './mint.js';
36
37
  import { checkHint, isAppName } from './types.js';
37
38
  /** The hub database's one table (the hub repo's first migration carries the
38
39
  * same statements). */
@@ -73,7 +74,6 @@ const HUB_SOCKET_SWEEP_MS = 30_000;
73
74
  const TOKEN_FORMAT = /^hubt_[A-Za-z0-9_-]{43}$/;
74
75
  const WEBSOCKET_KEY = /^[+/0-9A-Za-z]{22}==$/;
75
76
  const TOKEN_HASH_FORMAT = /^[0-9a-f]{64}$/;
76
- const DAEMON_ID_FORMAT = /^[A-Za-z0-9_-]{8,64}$/;
77
77
  export function createHub(app, opts) {
78
78
  const { pool, logger } = opts;
79
79
  const now = opts.now ?? (() => performance.now());
@@ -220,7 +220,7 @@ export function createHub(app, opts) {
220
220
  const daemonId = body?.daemonId;
221
221
  if (typeof tokenHash !== 'string' || !TOKEN_HASH_FORMAT.test(tokenHash))
222
222
  return c.json({ error: 'invalid tokenHash' }, 400);
223
- if (typeof daemonId !== 'string' || !DAEMON_ID_FORMAT.test(daemonId))
223
+ if (!isDaemonId(daemonId))
224
224
  return c.json({ error: 'invalid daemonId' }, 400);
225
225
  const client = await pool.connect();
226
226
  let ok = false;
@@ -0,0 +1,24 @@
1
+ import { type Logger } from '../log.js';
2
+ import { type Hub, type HubOptions } from './server.js';
3
+ /** The service-account domain of test identities: `<app>-svc@test`. */
4
+ export declare const TEST_HUB_SA_DOMAIN = "test";
5
+ export interface TestHubOptions {
6
+ /** A Postgres database for the token table and the cross-copy channel
7
+ * (an app's own test database is fine: one table, `daemon_tokens`). */
8
+ databaseUrl: string;
9
+ /** Apps whose `<app>-svc@test` identity may notify and register. */
10
+ apps: string[];
11
+ port?: number;
12
+ host?: string;
13
+ logger?: Logger;
14
+ /** Passed through (tests: short socket idle or age limits). */
15
+ hub?: Pick<HubOptions, 'socketIdle' | 'socketAge' | 'bridge' | 'now'>;
16
+ }
17
+ export interface TestHub {
18
+ /** http://<host>:<port>, no trailing slash: the app's HUB_URL. */
19
+ url: string;
20
+ hub: Hub;
21
+ stop(): Promise<void>;
22
+ }
23
+ export declare function testHubIdentity(app: string): string;
24
+ export declare function startTestHub(opts: TestHubOptions): Promise<TestHub>;
@@ -0,0 +1,71 @@
1
+ // A real hub for tests (design §8 step 0, review F-S5 and §14.3's per-app
2
+ // test identities): createHub on a database the caller owns, in test auth
3
+ // mode, so a converted app's vitest and Playwright runs exercise the
4
+ // production hub mode rather than a fake. Refuses to start outside
5
+ // AUTH_MODE=test.
6
+ //
7
+ // Identities in test mode: an app process calls the hub with serviceFetch,
8
+ // which sends X-Test-S2S-Caller from TEST_S2S_IDENTITY, so an app under test
9
+ // sets TEST_S2S_IDENTITY=<app>-svc@test and the hub derives its app from
10
+ // that. Browser sockets are the hub process's TEST_USER (or an X-Test-User
11
+ // header), from any http://localhost or http://127.0.0.1 origin.
12
+ import { once } from 'node:events';
13
+ import { serve } from '@hono/node-server';
14
+ import { createNodeWebSocket } from '@hono/node-ws';
15
+ import { Hono } from 'hono';
16
+ import pg from 'pg';
17
+ import { endPool, FLEET_POOL_DEFAULTS } from '../db.js';
18
+ import { createLogger } from '../log.js';
19
+ import { createHub, HUB_SCHEMA_SQL } from './server.js';
20
+ /** The service-account domain of test identities: `<app>-svc@test`. */
21
+ export const TEST_HUB_SA_DOMAIN = 'test';
22
+ export function testHubIdentity(app) {
23
+ return `${app}-svc@${TEST_HUB_SA_DOMAIN}`;
24
+ }
25
+ export async function startTestHub(opts) {
26
+ if (process.env.AUTH_MODE !== 'test')
27
+ throw new Error('startTestHub runs only with AUTH_MODE=test');
28
+ const pool = new pg.Pool({ connectionString: opts.databaseUrl, ...FLEET_POOL_DEFAULTS, max: 6, application_name: 'test_hub' });
29
+ pool.on('error', () => { });
30
+ await pool.query(HUB_SCHEMA_SQL);
31
+ const logger = opts.logger ?? createLogger({ app: 'hub', context: { build_id: 'test' } });
32
+ const grants = Object.fromEntries(opts.apps.map((app) => [testHubIdentity(app), ['POST /api/notify', 'POST /api/register-daemon']]));
33
+ const app = new Hono();
34
+ app.get('/health', (c) => c.json({ ok: true }));
35
+ const { injectWebSocket, upgradeWebSocket, wss } = createNodeWebSocket({ app });
36
+ const host = opts.host ?? '127.0.0.1';
37
+ // The audience is the hub's own URL; it is only an OIDC audience and a
38
+ // host for client addresses, neither of which test mode checks.
39
+ const hub = createHub(app, {
40
+ pool,
41
+ logger,
42
+ audience: `http://${host}`,
43
+ grants,
44
+ serviceAccountDomain: TEST_HUB_SA_DOMAIN,
45
+ appDomain: 'apps.test',
46
+ allowLocalOrigins: true,
47
+ upgradeWebSocket,
48
+ webSocketServer: wss,
49
+ ...opts.hub,
50
+ });
51
+ await hub.start();
52
+ const server = serve({ fetch: app.fetch, port: opts.port ?? 0, hostname: host });
53
+ injectWebSocket(server);
54
+ if (!server.listening)
55
+ await once(server, 'listening');
56
+ const url = `http://${host}:${server.address().port}`;
57
+ let stopped = false;
58
+ return {
59
+ url,
60
+ hub,
61
+ async stop() {
62
+ if (stopped)
63
+ return;
64
+ stopped = true;
65
+ await hub.stop();
66
+ server.closeAllConnections();
67
+ await new Promise((resolve) => server.close(resolve));
68
+ await endPool(pool);
69
+ },
70
+ };
71
+ }
@@ -0,0 +1,83 @@
1
+ /** How long one park lasts at the hub before it answers 204. */
2
+ export declare const HUB_PARK_SECONDS = 30;
3
+ /** The client's bound past the park (design §5.6: wait + 10 s). */
4
+ export declare const HUB_PARK_CLIENT_SLACK_MS = 10000;
5
+ /** The sleep after any hub failure before the next direct request. */
6
+ export declare const HUB_DEGRADED_SLEEP_MS = 2000;
7
+ /** Mints happen at most this often, whatever their outcome. */
8
+ export declare const HUB_MINT_FLOOR_MS: number;
9
+ /** A token is replaced once it is this old (the hub's TTL is one hour). */
10
+ export declare const HUB_TOKEN_REFRESH_MS: number;
11
+ /** Added to the caller's lookback (review S2), and the hub's cap on it. */
12
+ export declare const HUB_LOOKBACK_SLACK_MS = 1000;
13
+ export declare const HUB_LOOKBACK_MAX_MS = 60000;
14
+ /** A daemon id stable per (host, app), in the hub's accepted format. */
15
+ export declare function stableDaemonId(app: string, host?: string): string;
16
+ export interface HubDaemonLogger {
17
+ info(message: string, meta?: Record<string, unknown>): void;
18
+ warn(message: string, meta?: Record<string, unknown>): void;
19
+ }
20
+ export interface HubDaemonClientOptions {
21
+ /** The hub's base URL, e.g. https://hub.apps.snewman.net. */
22
+ hubUrl: string;
23
+ /** This daemon's app (log context only: the hub derives the app from the token). */
24
+ app: string;
25
+ /** stableDaemonId(app) in production. */
26
+ daemonId: string;
27
+ /** Ask the app for a token (its hub-token route, with the daemon's own
28
+ * bearer): one attempt, bounded by the caller (design: 10 s). Throws on
29
+ * any failure. */
30
+ mint: (daemonId: string) => Promise<{
31
+ token: string;
32
+ ttlSeconds?: number;
33
+ }>;
34
+ logger: HubDaemonLogger;
35
+ parkSeconds?: number;
36
+ degradedSleepMs?: number;
37
+ mintFloorMs?: number;
38
+ refreshAfterMs?: number;
39
+ /** Injectable for tests. */
40
+ fetch?: typeof fetch;
41
+ /** Monotonic milliseconds (tests). */
42
+ now?: () => number;
43
+ sleep?: (ms: number, signal?: AbortSignal) => Promise<void>;
44
+ }
45
+ /** hint: a `work` hint arrived; timeout: the park ran out (204); unavailable:
46
+ * the hub failed or there is no token yet (already slept); stopped: the
47
+ * caller's signal aborted. The caller's next step is its direct request. */
48
+ export type HubParkOutcome = 'hint' | 'timeout' | 'unavailable' | 'stopped';
49
+ export declare class HubDaemonClient {
50
+ private readonly base;
51
+ private readonly opts;
52
+ private readonly parkSeconds;
53
+ private readonly degradedSleepMs;
54
+ private readonly mintFloorMs;
55
+ private readonly refreshAfterMs;
56
+ private readonly fetchFn;
57
+ private readonly now;
58
+ private readonly sleepFn;
59
+ private token;
60
+ private lastMintAttemptAt;
61
+ private minting;
62
+ private mintFailures;
63
+ private degradedSince;
64
+ /** now() when the last park answered `hint`: the hint behind it is
65
+ * already acted on, so no later lookback reaches back past it. */
66
+ private lastHintAt;
67
+ constructor(opts: HubDaemonClientOptions);
68
+ /** Begin the first mint in the background. Never throws. */
69
+ start(): void;
70
+ /** True while the hub is failing (or no token could be minted). */
71
+ get degraded(): boolean;
72
+ /** The mint in flight, if any (tests). */
73
+ get pendingMint(): Promise<void> | null;
74
+ /** Park until a work hint, the park's end, or a failure. `lookbackMs` is
75
+ * the time since this iteration's direct request STARTED, on the
76
+ * caller's monotonic clock. Never throws. */
77
+ park(lookbackMs: number, signal?: AbortSignal): Promise<HubParkOutcome>;
78
+ private usableToken;
79
+ private maybeMint;
80
+ private enterDegraded;
81
+ private leaveDegraded;
82
+ private degradedSleep;
83
+ }
@@ -0,0 +1,214 @@
1
+ // A daemon's client for the connection hub's work hints (design §5.6, as
2
+ // amended by §14.1, §14.3 S3-S5 and §14.4). Dependency-free on purpose: the
3
+ // VPS daemons import only this subpath.
4
+ //
5
+ // The hub only fills idle time. Each loop iteration first makes its direct
6
+ // request to the app (the claim or peek it always made); only when that finds
7
+ // nothing does it park here. park() returns on a `work` hint, after
8
+ // HUB_PARK_SECONDS (the hub answers 204: the bound on a lost hint and the
9
+ // daemon's liveness touch, design §5.6's 30 s timer), or on any hub failure
10
+ // after sleeping HUB_DEGRADED_SLEEP_MS (review S5: a fast failure must not
11
+ // spin the loop, and for Radar every direct request is a claim). Either way
12
+ // the caller's next step is its direct request.
13
+ //
14
+ // Rules this client owns, so the three daemons cannot get them wrong:
15
+ // - It never sends Authorization: the app's device bearer stays on the
16
+ // daemon ↔ app path (review S4). The hub token goes in X-Hub-Token.
17
+ // - One attempt per park, bounded at the park plus 10 s; no retry budget.
18
+ // - The token is minted off the loop's critical path, at most once per
19
+ // HUB_MINT_FLOOR_MS whatever the outcome (review S3); until a token
20
+ // exists the loop runs degraded (direct requests every 2 s).
21
+ // - After a 401 it never presents that token again until a new mint
22
+ // (§14.4: the hub's per-address limit on bad tokens is shared by every
23
+ // daemon on the host).
24
+ // - The daemon id is stable per (host, app) (§14.4): restarts and retries
25
+ // overwrite one registration instead of using up the hub's cap of 20.
26
+ // - One WARN on entering the degraded state, one INFO on leaving it.
27
+ import { createHash } from 'node:crypto';
28
+ import { hostname } from 'node:os';
29
+ /** How long one park lasts at the hub before it answers 204. */
30
+ export const HUB_PARK_SECONDS = 30;
31
+ /** The client's bound past the park (design §5.6: wait + 10 s). */
32
+ export const HUB_PARK_CLIENT_SLACK_MS = 10_000;
33
+ /** The sleep after any hub failure before the next direct request. */
34
+ export const HUB_DEGRADED_SLEEP_MS = 2_000;
35
+ /** Mints happen at most this often, whatever their outcome. */
36
+ export const HUB_MINT_FLOOR_MS = 5 * 60_000;
37
+ /** A token is replaced once it is this old (the hub's TTL is one hour). */
38
+ export const HUB_TOKEN_REFRESH_MS = 50 * 60_000;
39
+ /** Added to the caller's lookback (review S2), and the hub's cap on it. */
40
+ export const HUB_LOOKBACK_SLACK_MS = 1_000;
41
+ export const HUB_LOOKBACK_MAX_MS = 60_000;
42
+ /** A daemon id stable per (host, app), in the hub's accepted format. */
43
+ export function stableDaemonId(app, host = hostname()) {
44
+ return `d_${createHash('sha256').update(`${host}\n${app}`).digest('base64url').slice(0, 30)}`;
45
+ }
46
+ export class HubDaemonClient {
47
+ base;
48
+ opts;
49
+ parkSeconds;
50
+ degradedSleepMs;
51
+ mintFloorMs;
52
+ refreshAfterMs;
53
+ fetchFn;
54
+ now;
55
+ sleepFn;
56
+ token = null;
57
+ lastMintAttemptAt = -Infinity;
58
+ minting = null;
59
+ mintFailures = 0;
60
+ degradedSince = null;
61
+ /** now() when the last park answered `hint`: the hint behind it is
62
+ * already acted on, so no later lookback reaches back past it. */
63
+ lastHintAt = -Infinity;
64
+ constructor(opts) {
65
+ this.opts = opts;
66
+ this.base = opts.hubUrl.replace(/\/$/, '');
67
+ this.parkSeconds = opts.parkSeconds ?? HUB_PARK_SECONDS;
68
+ this.degradedSleepMs = opts.degradedSleepMs ?? HUB_DEGRADED_SLEEP_MS;
69
+ this.mintFloorMs = opts.mintFloorMs ?? HUB_MINT_FLOOR_MS;
70
+ this.refreshAfterMs = opts.refreshAfterMs ?? HUB_TOKEN_REFRESH_MS;
71
+ this.fetchFn = opts.fetch ?? fetch;
72
+ this.now = opts.now ?? (() => performance.now());
73
+ this.sleepFn = opts.sleep ?? sleep;
74
+ }
75
+ /** Begin the first mint in the background. Never throws. */
76
+ start() {
77
+ this.maybeMint();
78
+ }
79
+ /** True while the hub is failing (or no token could be minted). */
80
+ get degraded() {
81
+ return this.degradedSince !== null;
82
+ }
83
+ /** The mint in flight, if any (tests). */
84
+ get pendingMint() {
85
+ return this.minting;
86
+ }
87
+ /** Park until a work hint, the park's end, or a failure. `lookbackMs` is
88
+ * the time since this iteration's direct request STARTED, on the
89
+ * caller's monotonic clock. Never throws. */
90
+ async park(lookbackMs, signal) {
91
+ if (signal?.aborted)
92
+ return 'stopped';
93
+ this.maybeMint();
94
+ const token = this.usableToken();
95
+ if (!token) {
96
+ // Quiet while the very first mint is still in flight; degraded after.
97
+ if (this.mintFailures > 0 || this.minting === null)
98
+ this.enterDegraded('no hub token');
99
+ return this.degradedSleep(signal);
100
+ }
101
+ // The lookback (review S2) covers the gap between the direct request and
102
+ // this park, but never reaches back past the previous `hint` answer: the
103
+ // hub still remembers that hint, and re-matching it would answer every
104
+ // park at once for the length of the lookback (a burst of direct
105
+ // requests, each a claim for Radar). Found by coder's integration test.
106
+ const sinceHint = Math.max(0, Math.floor(this.now() - this.lastHintAt));
107
+ const lookback = Math.min(Math.max(0, Math.round(lookbackMs)) + HUB_LOOKBACK_SLACK_MS, HUB_LOOKBACK_MAX_MS, sinceHint);
108
+ const url = `${this.base}/api/daemon/wait?wait=${this.parkSeconds}&lookbackMs=${lookback}`;
109
+ const bound = AbortSignal.timeout(this.parkSeconds * 1000 + HUB_PARK_CLIENT_SLACK_MS);
110
+ const startedAt = this.now();
111
+ let res;
112
+ try {
113
+ res = await this.fetchFn(url, {
114
+ headers: { 'x-hub-token': token.value },
115
+ signal: signal ? AbortSignal.any([signal, bound]) : bound,
116
+ });
117
+ }
118
+ catch (error) {
119
+ if (signal?.aborted)
120
+ return 'stopped';
121
+ this.enterDegraded('hub wait failed', { error: error instanceof Error ? error.message : String(error) });
122
+ return this.degradedSleep(signal);
123
+ }
124
+ if (res.status === 200) {
125
+ await res.json().catch(() => null);
126
+ this.lastHintAt = this.now();
127
+ this.leaveDegraded();
128
+ return 'hint';
129
+ }
130
+ await res.body?.cancel().catch(() => { });
131
+ if (res.status === 204) {
132
+ this.leaveDegraded();
133
+ // A copy that is stopping answers at once; don't spin on it. A 204
134
+ // well before the park's end is that, not a park that ran out.
135
+ if (this.now() - startedAt < (this.parkSeconds * 1000) / 2 && !signal?.aborted)
136
+ await this.sleepFn(this.degradedSleepMs, signal);
137
+ return signal?.aborted ? 'stopped' : 'timeout';
138
+ }
139
+ if (res.status === 401) {
140
+ // Never present this token again (§14.4); a new one only by a mint,
141
+ // which the floor may still hold back.
142
+ if (this.token?.value === token.value)
143
+ this.token = null;
144
+ this.enterDegraded('hub token refused', { status: 401 });
145
+ this.maybeMint();
146
+ return this.degradedSleep(signal);
147
+ }
148
+ this.enterDegraded('hub wait refused', { status: res.status });
149
+ return this.degradedSleep(signal);
150
+ }
151
+ usableToken() {
152
+ if (this.token && this.now() >= this.token.expiresAt)
153
+ this.token = null;
154
+ return this.token;
155
+ }
156
+ maybeMint() {
157
+ const t = this.now();
158
+ const needed = this.token === null || t - this.token.mintedAt >= this.refreshAfterMs;
159
+ if (!needed || this.minting || t - this.lastMintAttemptAt < this.mintFloorMs)
160
+ return;
161
+ this.lastMintAttemptAt = t;
162
+ this.minting = (async () => {
163
+ try {
164
+ const { token, ttlSeconds } = await this.opts.mint(this.opts.daemonId);
165
+ const mintedAt = this.now();
166
+ this.token = { value: token, mintedAt, expiresAt: mintedAt + (ttlSeconds ?? 3600) * 1000 };
167
+ if (this.mintFailures > 0)
168
+ this.opts.logger.info('hub token minted', { app: this.opts.app, afterFailures: this.mintFailures });
169
+ this.mintFailures = 0;
170
+ }
171
+ catch (error) {
172
+ this.mintFailures++;
173
+ this.opts.logger.warn('hub token mint failed', {
174
+ app: this.opts.app,
175
+ error: error instanceof Error ? error.message : String(error),
176
+ failures: this.mintFailures,
177
+ nextAttemptInMs: this.mintFloorMs,
178
+ });
179
+ }
180
+ finally {
181
+ this.minting = null;
182
+ }
183
+ })();
184
+ }
185
+ enterDegraded(reason, fields = {}) {
186
+ if (this.degradedSince !== null)
187
+ return;
188
+ this.degradedSince = this.now();
189
+ this.opts.logger.warn('hub wait unavailable; polling the app directly', { app: this.opts.app, reason, ...fields });
190
+ }
191
+ leaveDegraded() {
192
+ if (this.degradedSince === null)
193
+ return;
194
+ this.opts.logger.info('hub wait recovered', { app: this.opts.app, degradedForMs: Math.round(this.now() - this.degradedSince) });
195
+ this.degradedSince = null;
196
+ }
197
+ async degradedSleep(signal) {
198
+ await this.sleepFn(this.degradedSleepMs, signal);
199
+ return signal?.aborted ? 'stopped' : 'unavailable';
200
+ }
201
+ }
202
+ function sleep(ms, signal) {
203
+ return new Promise((resolve) => {
204
+ if (signal?.aborted)
205
+ return resolve();
206
+ const timer = setTimeout(done, ms);
207
+ function done() {
208
+ clearTimeout(timer);
209
+ signal?.removeEventListener('abort', done);
210
+ resolve();
211
+ }
212
+ signal?.addEventListener('abort', done, { once: true });
213
+ });
214
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@steve31415/baselib",
3
- "version": "3.57.0",
3
+ "version": "3.58.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",
@@ -69,6 +69,14 @@
69
69
  "types": "./dist/hub/index.d.ts",
70
70
  "default": "./dist/hub/index.js"
71
71
  },
72
+ "./hub/testing": {
73
+ "types": "./dist/hub/testing.d.ts",
74
+ "default": "./dist/hub/testing.js"
75
+ },
76
+ "./hub-daemon": {
77
+ "types": "./dist/hub-daemon.d.ts",
78
+ "default": "./dist/hub-daemon.js"
79
+ },
72
80
  "./socket-age": {
73
81
  "types": "./dist/socket-age.d.ts",
74
82
  "default": "./dist/socket-age.js"
@@ -112,7 +120,8 @@
112
120
  "pw-logs": "./dist/bin/pw-logs.js",
113
121
  "pw-rollback": "./dist/bin/pw-rollback.js",
114
122
  "pw-sql": "./dist/bin/pw-sql.js",
115
- "pw-code-manifest": "./dist/bin/pw-code-manifest.js"
123
+ "pw-code-manifest": "./dist/bin/pw-code-manifest.js",
124
+ "baselib-test-hub": "./dist/bin/test-hub.js"
116
125
  },
117
126
  "engines": {
118
127
  "node": "22.x"
@@ -129,6 +138,7 @@
129
138
  "dependencies": {
130
139
  "@google-cloud/cloud-sql-connector": "^1.8.2",
131
140
  "@hono/node-server": "^1.14.0",
141
+ "@hono/node-ws": "^1.3.1",
132
142
  "@modelcontextprotocol/sdk": "^1.32.1",
133
143
  "google-auth-library": "^10.5.0",
134
144
  "hono": "^4.7.0",
@@ -138,7 +148,6 @@
138
148
  "zod": "^4.6.5"
139
149
  },
140
150
  "devDependencies": {
141
- "@hono/node-ws": "^1.3.1",
142
151
  "@types/node": "^22.0.0",
143
152
  "@types/pg": "^8.11.0",
144
153
  "fake-indexeddb": "^6.2.5",