@steve31415/baselib 3.45.0 → 3.46.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/dist/s2s.d.ts CHANGED
@@ -67,9 +67,20 @@ export declare function resolveS2sCaller(opts: S2sOptions & {
67
67
  export declare function s2sAuth(opts: S2sOptions): import("hono").MiddlewareHandler<any, string, {}, Response | (Response & import("hono").TypedResponse<{
68
68
  error: string;
69
69
  }, 429 | 401 | 403, "json">)>;
70
+ /** A cached ID token is replaced this long before its `exp`, as
71
+ * google-auth-library's own IdTokenClient does (its eager-refresh default).
72
+ * Tokens live an hour. */
73
+ export declare const ID_TOKEN_REFRESH_MARGIN_MS: number;
74
+ /** The longest one mint may take. On Cloud Run gcp-metadata sends the
75
+ * metadata-server request with no timeout at all; a healthy mint takes
76
+ * milliseconds. */
77
+ export declare const ID_TOKEN_MINT_TIMEOUT_MS = 5000;
70
78
  /** fetch() with an OIDC identity token for the target's canonical audience.
71
79
  * In test mode, sends the synthetic X-Test-S2S-Caller header instead
72
- * (identity from TEST_S2S_IDENTITY, default 'test-service@test'). */
80
+ * (identity from TEST_S2S_IDENTITY, default 'test-service@test'). The token
81
+ * is cached per audience until ID_TOKEN_REFRESH_MARGIN_MS before it
82
+ * expires; concurrent calls share one mint, which is bounded by
83
+ * ID_TOKEN_MINT_TIMEOUT_MS, and `init.signal` also ends the wait for it. */
73
84
  export declare function serviceFetch(url: string, opts: {
74
85
  audience: string;
75
86
  } & RequestInit): Promise<Response>;
package/dist/s2s.js CHANGED
@@ -182,10 +182,26 @@ export function s2sAuth(opts) {
182
182
  }
183
183
  // ---------------------------------------------------------------------------
184
184
  // Caller side
185
- const idTokenClients = new Map();
185
+ /** A cached ID token is replaced this long before its `exp`, as
186
+ * google-auth-library's own IdTokenClient does (its eager-refresh default).
187
+ * Tokens live an hour. */
188
+ export const ID_TOKEN_REFRESH_MARGIN_MS = 5 * 60_000;
189
+ /** The longest one mint may take. On Cloud Run gcp-metadata sends the
190
+ * metadata-server request with no timeout at all; a healthy mint takes
191
+ * milliseconds. */
192
+ export const ID_TOKEN_MINT_TIMEOUT_MS = 5_000;
193
+ // serviceFetch calls the provider directly, which bypasses IdTokenClient's
194
+ // token cache (it lives in getRequestMetadataAsync), so the cache, the
195
+ // single flight and the bound are kept here. Failures are never cached.
196
+ let idTokenProvider;
197
+ const idTokens = new Map();
198
+ const idTokenMints = new Map();
186
199
  /** fetch() with an OIDC identity token for the target's canonical audience.
187
200
  * In test mode, sends the synthetic X-Test-S2S-Caller header instead
188
- * (identity from TEST_S2S_IDENTITY, default 'test-service@test'). */
201
+ * (identity from TEST_S2S_IDENTITY, default 'test-service@test'). The token
202
+ * is cached per audience until ID_TOKEN_REFRESH_MARGIN_MS before it
203
+ * expires; concurrent calls share one mint, which is bounded by
204
+ * ID_TOKEN_MINT_TIMEOUT_MS, and `init.signal` also ends the wait for it. */
189
205
  export async function serviceFetch(url, opts) {
190
206
  const { audience, ...init } = opts;
191
207
  const headers = new Headers(init.headers);
@@ -193,17 +209,75 @@ export async function serviceFetch(url, opts) {
193
209
  headers.set('x-test-s2s-caller', process.env.TEST_S2S_IDENTITY ?? 'test-service@test');
194
210
  }
195
211
  else {
196
- let clientPromise = idTokenClients.get(audience);
197
- if (!clientPromise) {
198
- clientPromise = (async () => {
199
- const { GoogleAuth } = await import('google-auth-library');
200
- return new GoogleAuth().getIdTokenClient(audience);
201
- })();
202
- idTokenClients.set(audience, clientPromise);
203
- }
204
- const client = await clientPromise;
205
- const token = await client.idTokenProvider.fetchIdToken(audience);
212
+ const token = await abortable(idTokenFor(audience), init.signal);
206
213
  headers.set('authorization', `Bearer ${token}`);
207
214
  }
208
215
  return fetch(url, { ...init, headers });
209
216
  }
217
+ function idTokenFor(audience) {
218
+ const cached = idTokens.get(audience);
219
+ if (cached && Date.now() < cached.refreshAt)
220
+ return Promise.resolve(cached.token);
221
+ let mint = idTokenMints.get(audience);
222
+ if (!mint) {
223
+ mint = withinMs(mintIdToken(audience), ID_TOKEN_MINT_TIMEOUT_MS, `ID token mint for ${audience}`)
224
+ .then((token) => {
225
+ const expiresAt = idTokenExpiry(token);
226
+ if (expiresAt !== undefined) {
227
+ idTokens.set(audience, { token, refreshAt: expiresAt - ID_TOKEN_REFRESH_MARGIN_MS });
228
+ }
229
+ return token;
230
+ })
231
+ .finally(() => idTokenMints.delete(audience));
232
+ idTokenMints.set(audience, mint);
233
+ }
234
+ return mint;
235
+ }
236
+ async function mintIdToken(audience) {
237
+ idTokenProvider ??= (async () => {
238
+ const { GoogleAuth } = await import('google-auth-library');
239
+ return (await new GoogleAuth().getIdTokenClient(audience)).idTokenProvider;
240
+ })();
241
+ const provider = idTokenProvider;
242
+ // A failed credential lookup is retried by the next call.
243
+ provider.catch(() => {
244
+ if (idTokenProvider === provider)
245
+ idTokenProvider = undefined;
246
+ });
247
+ return (await provider).fetchIdToken(audience);
248
+ }
249
+ /** Our own token's `exp` in ms, unverified; undefined if unreadable. */
250
+ function idTokenExpiry(token) {
251
+ try {
252
+ const exp = JSON.parse(Buffer.from(token.split('.')[1] ?? '', 'base64url').toString()).exp;
253
+ return typeof exp === 'number' ? exp * 1000 : undefined;
254
+ }
255
+ catch {
256
+ return undefined;
257
+ }
258
+ }
259
+ /** `promise`, or a rejection once `ms` pass. The work itself cannot be
260
+ * cancelled (fetchIdToken takes no signal); it is only no longer awaited. */
261
+ function withinMs(promise, ms, what) {
262
+ let timer;
263
+ const timeout = new Promise((_, reject) => {
264
+ timer = setTimeout(() => reject(new Error(`${what} timed out after ${ms} ms`)), ms);
265
+ });
266
+ return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
267
+ }
268
+ /** `promise`, or a rejection with the signal's reason when it aborts (as
269
+ * fetch rejects), leaving the promise itself to its other waiters. Always
270
+ * subscribes to `promise`, so a mint no one waits for any more never
271
+ * rejects unhandled. */
272
+ function abortable(promise, signal) {
273
+ if (!signal)
274
+ return promise;
275
+ return new Promise((resolve, reject) => {
276
+ const onAbort = () => reject(signal.reason);
277
+ if (signal.aborted)
278
+ onAbort();
279
+ else
280
+ signal.addEventListener('abort', onAbort, { once: true });
281
+ promise.then(resolve, reject).finally(() => signal.removeEventListener('abort', onAbort));
282
+ });
283
+ }
@@ -6,4 +6,8 @@ export declare class ScopeBroadcaster<T> {
6
6
  * after the remaining listeners have received the message. */
7
7
  publish(scopeKey: string, message: T, except?: (message: T) => void): void;
8
8
  count(scopeKey: string): number;
9
+ /** The scopes with at least one listener: for an app that must
10
+ * re-announce to every local scope, e.g. after its database bridge may
11
+ * have missed notifications. */
12
+ scopes(): string[];
9
13
  }
@@ -44,4 +44,10 @@ export class ScopeBroadcaster {
44
44
  count(scopeKey) {
45
45
  return this.listeners.get(scopeKey)?.size ?? 0;
46
46
  }
47
+ /** The scopes with at least one listener: for an app that must
48
+ * re-announce to every local scope, e.g. after its database bridge may
49
+ * have missed notifications. */
50
+ scopes() {
51
+ return [...this.listeners.keys()];
52
+ }
47
53
  }
@@ -22,9 +22,11 @@ export interface DbBroadcastBridgeOptions {
22
22
  /** Each notification's parsed JSON payload. An invalid payload is logged
23
23
  * and skipped; a throw is logged and does not reach pg. */
24
24
  onMessage: (message: unknown) => void;
25
- /** Called after the listener reconnects (not on the first connect).
26
- * Notifications sent while it was down are lost and unknowable, so the
27
- * app re-announces availability to every local scope. */
25
+ /** Called on every connect after start() has returned: a reconnect after
26
+ * a loss, or the first success after a failed first attempt (not on a
27
+ * first attempt that succeeds). Notifications sent while nothing was
28
+ * listening are lost and unknowable, so the app re-announces availability
29
+ * to every local scope. */
28
30
  onRecovered: () => void;
29
31
  /** Skip messages whose top-level `origin` is this bridge's `originId`,
30
32
  * for an app that delivers its own writes locally and publishes them via
@@ -50,12 +52,16 @@ export declare class DbBroadcastBridge {
50
52
  private listener;
51
53
  private stopped;
52
54
  private connecting;
53
- private connectedOnce;
55
+ /** Set once start() has returned. From then on the app may have sessions
56
+ * that a missed notification leaves stale, so every later connect is a
57
+ * recovery. */
58
+ private startReturned;
54
59
  private reconnectAttempt;
55
60
  private reconnectTimer;
56
61
  private heartbeatTimer;
57
62
  constructor(options: DbBroadcastBridgeOptions);
58
- /** Connects the listener; rejects if the first connect fails. */
63
+ /** Makes the first connect attempt; never rejects. A failure is logged
64
+ * and retried on the reconnect ladder (see the header). */
59
65
  start(): Promise<void>;
60
66
  /** Idempotent. Releases the listener client back to the pool; call it
61
67
  * before the pool ends. Nothing is delivered after it resolves. */
@@ -4,6 +4,16 @@
4
4
  // (another copy, the old revision during a deploy, a job) reaches every
5
5
  // copy's sessions. Rules: ~/plasticine-way/docs/DATABASE.md "Cross-instance
6
6
  // live delivery: LISTEN/NOTIFY". Moved here from todo's src/db-broadcast.ts.
7
+ //
8
+ // Lifecycle contract. start() makes the first connect attempt and resolves
9
+ // when it ends, whether or not it connected; it never rejects. A failed first
10
+ // attempt is logged as a loss (WARN 'database broadcast listener lost',
11
+ // `atStart: true`) and enters the same reconnect ladder as a later loss, so a
12
+ // process booting while the database is unreachable neither crash-loops on a
13
+ // top-level `await start()` nor stays deaf for life. Every connect after
14
+ // start() has returned (after a loss, or after a failed first attempt) logs
15
+ // INFO 'database broadcast listener recovered' and calls onRecovered: the app
16
+ // may have been serving sessions while nothing was listening.
7
17
  import { randomUUID } from 'node:crypto';
8
18
  /** How often the bridge proves its LISTEN connection alive with a `select
9
19
  * 1`. Cloud SQL drops idle connections silently, the error surfaces only on
@@ -31,7 +41,10 @@ export class DbBroadcastBridge {
31
41
  listener = null;
32
42
  stopped = true;
33
43
  connecting = false;
34
- connectedOnce = false;
44
+ /** Set once start() has returned. From then on the app may have sessions
45
+ * that a missed notification leaves stale, so every later connect is a
46
+ * recovery. */
47
+ startReturned = false;
35
48
  reconnectAttempt = 0;
36
49
  reconnectTimer = null;
37
50
  heartbeatTimer = null;
@@ -49,12 +62,28 @@ export class DbBroadcastBridge {
49
62
  this.heartbeatTimeoutMs = options.heartbeatTimeoutMs ?? DB_BRIDGE_HEARTBEAT_TIMEOUT_MS;
50
63
  this.reconnectDelaysMs = options.reconnectDelaysMs ?? DB_BRIDGE_RECONNECT_DELAYS_MS;
51
64
  }
52
- /** Connects the listener; rejects if the first connect fails. */
65
+ /** Makes the first connect attempt; never rejects. A failure is logged
66
+ * and retried on the reconnect ladder (see the header). */
53
67
  async start() {
54
68
  if (!this.stopped)
55
69
  return;
56
70
  this.stopped = false;
57
- await this.connectListener(true);
71
+ try {
72
+ await this.connectListener();
73
+ }
74
+ catch (error) {
75
+ if (!this.stopped) {
76
+ this.logger.warn('database broadcast listener lost', {
77
+ channel: this.channel,
78
+ error: String(error),
79
+ atStart: true,
80
+ });
81
+ this.scheduleReconnect();
82
+ }
83
+ }
84
+ finally {
85
+ this.startReturned = true;
86
+ }
58
87
  }
59
88
  /** Idempotent. Releases the listener client back to the pool; call it
60
89
  * before the pool ends. Nothing is delivered after it resolves. */
@@ -86,7 +115,7 @@ export class DbBroadcastBridge {
86
115
  const payload = this.ignoreOwn ? { ...message, origin: this.originId } : message;
87
116
  await queryable.query('select pg_notify($1, $2)', [this.channel, JSON.stringify(payload)]);
88
117
  }
89
- async connectListener(initial) {
118
+ async connectListener() {
90
119
  if (this.stopped || this.connecting || this.listener)
91
120
  return;
92
121
  this.connecting = true;
@@ -122,16 +151,11 @@ export class DbBroadcastBridge {
122
151
  this.adjustPoolMin(1);
123
152
  client.on('notification', (notification) => this.onNotification(notification.payload));
124
153
  this.scheduleHeartbeat(client);
125
- const recovered = this.connectedOnce;
126
- this.connectedOnce = true;
127
154
  this.reconnectAttempt = 0;
128
- if (recovered) {
155
+ if (this.startReturned) {
129
156
  this.logger.info('database broadcast listener recovered', { channel: this.channel });
130
157
  this.callApp('onRecovered', () => this.onRecovered());
131
158
  }
132
- else if (!initial) {
133
- this.logger.info('database broadcast listener connected', { channel: this.channel });
134
- }
135
159
  }
136
160
  finally {
137
161
  this.connecting = false;
@@ -193,7 +217,7 @@ export class DbBroadcastBridge {
193
217
  const delay = delays[Math.min(this.reconnectAttempt++, delays.length - 1)];
194
218
  this.reconnectTimer = setTimeout(() => {
195
219
  this.reconnectTimer = null;
196
- void this.connectListener(false).catch((error) => {
220
+ void this.connectListener().catch((error) => {
197
221
  this.logger.warn('database broadcast reconnect failed', { channel: this.channel, error: String(error) });
198
222
  this.scheduleReconnect();
199
223
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@steve31415/baselib",
3
- "version": "3.45.0",
3
+ "version": "3.46.0",
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",