@steve31415/baselib 3.54.1 → 3.56.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/auth.d.ts CHANGED
@@ -28,7 +28,8 @@ declare module 'hono' {
28
28
  export interface RequireIdentityOptions extends S2sOptions {
29
29
  /** Base URL of the auth service. */
30
30
  authUrl?: string;
31
- /** Injectable for tests (defaults to global fetch). */
31
+ /** Injectable for tests (default: fetch with the auth host's address
32
+ * cached in-process, fleet-dns.ts). */
32
33
  whoamiFetch?: typeof fetch;
33
34
  logger?: Logger;
34
35
  /** Distinguishes API requests (401/403 JSON) from page requests (redirect
package/dist/auth.js CHANGED
@@ -31,6 +31,7 @@ import { createMiddleware } from 'hono/factory';
31
31
  import { authMode } from './config.js';
32
32
  import { truncate } from './log-core.js';
33
33
  import { RateLimiter, resolveS2sCaller } from './s2s.js';
34
+ import { fleetAddressState, fleetFetch } from './fleet-dns.js';
34
35
  export const DEFAULT_AUTH_URL = 'https://auth.apps.snewman.net';
35
36
  export const SESSION_COOKIE = 'pw_session';
36
37
  /** The `code` in a 403 JSON body when a signed-in user is refused by the
@@ -45,7 +46,8 @@ function parseRole(value) {
45
46
  }
46
47
  export function requireIdentity(opts) {
47
48
  const authUrl = (opts.authUrl ?? DEFAULT_AUTH_URL).replace(/\/$/, '');
48
- const whoamiFetch = opts.whoamiFetch ?? fetch;
49
+ const whoamiFetch = opts.whoamiFetch ?? fleetFetch;
50
+ const authHost = new URL(authUrl).hostname;
49
51
  const hitMs = opts.cacheTtl?.hitMs ?? 600_000;
50
52
  const missMs = opts.cacheTtl?.missMs ?? 30_000;
51
53
  const policy = opts.userAccess ?? 'owner';
@@ -155,6 +157,10 @@ export function requireIdentity(opts) {
155
157
  async function callWhoami(c, key, cached, cookieHeader, joiners) {
156
158
  const started = Date.now();
157
159
  const cacheState = !cached ? 'absent' : cached.user ? 'expired-user' : 'expired-unauthenticated';
160
+ // Whether this call must look up the auth host's address (absent) or has
161
+ // it cached in-process (fresh, or stale and refreshing in the
162
+ // background); undefined for a non-fleet authUrl.
163
+ const authAddress = fleetAddressState(authHost);
158
164
  const callFields = {
159
165
  cacheState,
160
166
  ...(cached ? { entryAgeMs: started - cached.filled } : {}),
@@ -166,6 +172,7 @@ export function requireIdentity(opts) {
166
172
  path: truncate(c.req.path, 200),
167
173
  upgrade: (c.req.header('upgrade') ?? '').toLowerCase() === 'websocket',
168
174
  cookieCount: cookieHeader.split(';').filter((part) => part.includes('=')).length,
175
+ ...(authAddress ? { authAddress } : {}),
169
176
  };
170
177
  hitsSinceLastCall = 0;
171
178
  callCount++;
package/dist/db.js CHANGED
@@ -75,6 +75,19 @@ export const DEFAULT_STATEMENT_TIMEOUT_MS = 10 * 60_000;
75
75
  * outside that and well inside the 15 s checkout timeout, so a stall that
76
76
  * the timeout later kills has already been seen to be slow. */
77
77
  export const SLOW_CONNECT_MS = 2_000;
78
+ // Headroom judged 2026-11-05 (coder task cec8d1c7), for this threshold and
79
+ // `connectionTimeoutMillis` below: 6 slow-but-successful connects in the
80
+ // week to 2026-11-05 across ~16 apps, 2.1–4.4 s, at most 0.29 of the 15 s
81
+ // timeout. Two causes, both accepted: (1) Cloud SQL's IAM login itself
82
+ // taking 2.5–4.4 s with tcp/tls fast (radar, always-on CPU; ~2 a week,
83
+ // one of them a 4.4 s wait on Steve's own page load); (2) a CPU-starved
84
+ // process stretching every step alike — a LISTEN bridge reconnecting with
85
+ // no request in flight on a throttled-CPU service after the Thursday idle
86
+ // reset (chime, todo), or reader under corpus's daily 11:05 UTC burst of
87
+ // ~700 lookups at 12-way concurrency. Neither approaches the timeout, and
88
+ // a retry would not shorten a server-side login. Keep 15 s; revisit if a
89
+ // login-only stall reaches 7.5 s (half the timeout) or the login-only
90
+ // kind exceeds 4 a week (KNOWN_LOG_PATTERNS.md, "new connection was slow").
78
91
  /** Pool settings every fleet pool carries; `createPool` applies them and
79
92
  * is the way to get them (a hand-built pool spreads this, but prefer
80
93
  * `createPool` with `user` / `sessionOptions` over building one). Decided
@@ -279,7 +292,13 @@ const CONNECT_TIMING_MESSAGE = 'database pool: new connection was slow or failed
279
292
  * each step finished, in ms from the start — `tcpMs` (socket connected),
280
293
  * `tlsMs` (TLS done; the Cloud SQL connector's socket only), `authMs`
281
294
  * (the server accepted the login; with IAM authentication Cloud SQL checks
282
- * the token here). The point is the step a stall sits in: before `tcp` is
295
+ * the token here). These marks are cumulative; `stepsMs` gives each
296
+ * step's own duration (`tcp`, `tls`, `login` — login runs from the end of
297
+ * TLS, or of TCP without TLS), so a reader need not subtract (triage
298
+ * 2026-11-05 read a cumulative `authMs` as "all in the login"). Each mark
299
+ * is taken when the event loop handles the event, so a CPU-starved
300
+ * process stretches every step alike, while a slow server-side login
301
+ * shows fast tcp/tls and a long login. The point is the step a stall sits in: before `tcp` is
283
302
  * the network path, between `tcp` and `tls` the Cloud SQL front end or the
284
303
  * certificate, between `tls`/`tcp` and `authenticated` the database login.
285
304
  * pg-pool's own events cannot say this, and a handshake whose checkout
@@ -320,6 +339,14 @@ function timeNewConnections(pool, logger, options) {
320
339
  if (!err)
321
340
  this.#reached = 'ready';
322
341
  const expiry = options.credentialExpiry?.();
342
+ const { tcp, tls, authenticated } = this.#marks;
343
+ const stepsMs = {};
344
+ if (tcp !== undefined)
345
+ stepsMs.tcp = tcp;
346
+ if (tls !== undefined && tcp !== undefined)
347
+ stepsMs.tls = tls - tcp;
348
+ if (authenticated !== undefined)
349
+ stepsMs.login = authenticated - (tls ?? tcp ?? 0);
323
350
  const meta = {
324
351
  outcome: err ? 'failed' : 'slow',
325
352
  reached: this.#reached,
@@ -327,6 +354,7 @@ function timeNewConnections(pool, logger, options) {
327
354
  tcpMs: this.#marks.tcp,
328
355
  tlsMs: this.#marks.tls,
329
356
  authMs: this.#marks.authenticated,
357
+ stepsMs,
330
358
  pool: { total: pool.totalCount, idle: pool.idleCount, waiting: pool.waitingCount, max: pool.options.max },
331
359
  ...(options.credentialExpiry ? (expiry === undefined ? { credentialUnknown: true } : { credentialRemainingMs: expiry - Date.now() }) : {}),
332
360
  ...(err ? { error: err } : {}),
@@ -0,0 +1,52 @@
1
+ import { type LookupAddress } from 'node:dns';
2
+ import type { LookupFunction } from 'node:net';
3
+ import { Agent } from 'undici';
4
+ export declare const FLEET_DOMAIN_SUFFIX = ".apps.snewman.net";
5
+ /** How long a resolved fleet address is used before a background refresh.
6
+ * Long, because the address is a reserved static IP and a change is
7
+ * healed by the connect-failure path anyway. */
8
+ export declare const FLEET_ADDRESS_TTL_MS: number;
9
+ export declare function isFleetHost(hostname: string): boolean;
10
+ export type FleetAddressState = 'fresh' | 'stale' | 'absent';
11
+ export interface FleetAddressCacheOptions {
12
+ /** All addresses for a fleet host (default: dns.promises.lookup, all). */
13
+ resolve?: (hostname: string) => Promise<LookupAddress[]>;
14
+ /** Non-fleet hosts, and families the cached answer lacks (default dns.lookup). */
15
+ systemLookup?: LookupFunction;
16
+ ttlMs?: number;
17
+ now?: () => number;
18
+ /** Called when a connect failure drops an entry. */
19
+ onInvalidate?: (e: {
20
+ hostname: string;
21
+ addresses: string[];
22
+ reason: string;
23
+ }) => void;
24
+ }
25
+ export declare class FleetAddressCache {
26
+ private readonly entries;
27
+ private readonly inflight;
28
+ private readonly resolve;
29
+ private readonly systemLookup;
30
+ private readonly ttlMs;
31
+ private readonly now;
32
+ private readonly onInvalidate?;
33
+ constructor(opts?: FleetAddressCacheOptions);
34
+ /** net.connect's `lookup` option. */
35
+ readonly lookup: LookupFunction;
36
+ /** undefined for a non-fleet host. */
37
+ state(hostname: string): FleetAddressState | undefined;
38
+ /** Forget a host's address, so its next connection resolves afresh. */
39
+ invalidate(hostname: string, reason: string): void;
40
+ private refresh;
41
+ private answer;
42
+ }
43
+ /** An undici Agent whose connections resolve through `cache` and drop the
44
+ * cached address when a connection fails for an address reason. */
45
+ export declare function createFleetAgent(cache: FleetAddressCache): Agent;
46
+ /** The dispatcher baselib's server-to-server calls use. */
47
+ export declare function fleetDispatcher(): Agent;
48
+ /** The process-wide cache's state for `hostname` (undefined: not a fleet host). */
49
+ export declare function fleetAddressState(hostname: string): FleetAddressState | undefined;
50
+ /** fetch() through fleetDispatcher(). `dispatcher` is Node's (undici's)
51
+ * extension of RequestInit, absent from the DOM type. */
52
+ export declare const fleetFetch: typeof fetch;
@@ -0,0 +1,164 @@
1
+ // An in-process address cache for fleet hosts (*.apps.snewman.net), used by
2
+ // the server-to-server calls baselib makes: the session check against auth
3
+ // (whoami) and serviceFetch.
4
+ //
5
+ // Why: on Cloud Run, Node's dns.lookup goes through getaddrinfo to the
6
+ // metadata-server resolver, which keeps an answer for at most ~300 s
7
+ // whatever the record's TTL (the fleet's A records carry 3600 s since
8
+ // 2026-10-30; it made no difference). An infrequent caller therefore paid a
9
+ // ~60 ms lookup on almost every call: whoami took 113 ms p50 when the app's
10
+ // previous call was 300-3600 s earlier against 52 ms under 300 s, 79 % of
11
+ // calls in the slow bucket (2026-10-30 → 11-05,
12
+ // ~/migration/research/whoami-latency-2026-10-30.md, "Follow-up 2026-11-05").
13
+ //
14
+ // What: every fleet host resolves to the one static load-balancer address,
15
+ // so a resolved answer is kept for FLEET_ADDRESS_TTL_MS and, once older,
16
+ // still served at once while a background lookup refreshes it. A connection
17
+ // that fails in a way an address change would cause (refused, timed out,
18
+ // unreachable) drops the entry, so the next call resolves afresh: an
19
+ // address change heals after one failed call. Only the lookup changes: the
20
+ // URL, Host header and TLS server name (SNI) are still the hostname. Other
21
+ // hosts go straight to dns.lookup, uncached.
22
+ import dns from 'node:dns';
23
+ import { Agent, buildConnector } from 'undici';
24
+ import { processMeta } from './process-meta.js';
25
+ export const FLEET_DOMAIN_SUFFIX = '.apps.snewman.net';
26
+ /** How long a resolved fleet address is used before a background refresh.
27
+ * Long, because the address is a reserved static IP and a change is
28
+ * healed by the connect-failure path anyway. */
29
+ export const FLEET_ADDRESS_TTL_MS = 60 * 60_000;
30
+ /** Connect errors that a stale address would cause. Anything else (a TLS
31
+ * or HTTP failure) says nothing about the address and keeps the entry. */
32
+ const ADDRESS_FAILURE_CODES = new Set(['ECONNREFUSED', 'ETIMEDOUT', 'EHOSTUNREACH', 'ENETUNREACH', 'UND_ERR_CONNECT_TIMEOUT']);
33
+ export function isFleetHost(hostname) {
34
+ return normalize(hostname).endsWith(FLEET_DOMAIN_SUFFIX);
35
+ }
36
+ function normalize(hostname) {
37
+ return hostname.toLowerCase().replace(/\.$/, '');
38
+ }
39
+ export class FleetAddressCache {
40
+ entries = new Map();
41
+ inflight = new Map();
42
+ resolve;
43
+ systemLookup;
44
+ ttlMs;
45
+ now;
46
+ onInvalidate;
47
+ constructor(opts = {}) {
48
+ this.resolve = opts.resolve ?? ((hostname) => dns.promises.lookup(hostname, { all: true }));
49
+ this.systemLookup = opts.systemLookup ?? dns.lookup;
50
+ this.ttlMs = opts.ttlMs ?? FLEET_ADDRESS_TTL_MS;
51
+ this.now = opts.now ?? Date.now;
52
+ this.onInvalidate = opts.onInvalidate;
53
+ }
54
+ /** net.connect's `lookup` option. */
55
+ lookup = (hostname, options, callback) => {
56
+ if (!isFleetHost(hostname))
57
+ return this.systemLookup(hostname, options, callback);
58
+ const key = normalize(hostname);
59
+ const entry = this.entries.get(key);
60
+ if (entry) {
61
+ if (this.now() - entry.resolvedAt >= this.ttlMs)
62
+ this.refresh(key).catch(() => { }); // keep the old answer
63
+ return this.answer(entry, hostname, options, callback);
64
+ }
65
+ this.refresh(key).then((fresh) => this.answer(fresh, hostname, options, callback), (err) => callback(err, options.all ? [] : ''));
66
+ };
67
+ /** undefined for a non-fleet host. */
68
+ state(hostname) {
69
+ if (!isFleetHost(hostname))
70
+ return undefined;
71
+ const entry = this.entries.get(normalize(hostname));
72
+ if (!entry)
73
+ return 'absent';
74
+ return this.now() - entry.resolvedAt < this.ttlMs ? 'fresh' : 'stale';
75
+ }
76
+ /** Forget a host's address, so its next connection resolves afresh. */
77
+ invalidate(hostname, reason) {
78
+ const key = normalize(hostname);
79
+ const entry = this.entries.get(key);
80
+ if (!entry)
81
+ return;
82
+ this.entries.delete(key);
83
+ this.onInvalidate?.({ hostname: key, addresses: entry.addresses.map((a) => a.address), reason });
84
+ }
85
+ // One resolution per host at a time; a failure is never cached.
86
+ refresh(key) {
87
+ let pending = this.inflight.get(key);
88
+ if (!pending) {
89
+ pending = this.resolve(key)
90
+ .then((addresses) => {
91
+ if (addresses.length === 0)
92
+ throw Object.assign(new Error(`no address for ${key}`), { code: 'ENOTFOUND' });
93
+ const entry = { addresses, resolvedAt: this.now() };
94
+ this.entries.set(key, entry);
95
+ return entry;
96
+ })
97
+ .finally(() => this.inflight.delete(key));
98
+ this.inflight.set(key, pending);
99
+ }
100
+ return pending;
101
+ }
102
+ answer(entry, hostname, options, callback) {
103
+ const family = familyNumber(options.family);
104
+ const addresses = family ? entry.addresses.filter((a) => a.family === family) : entry.addresses;
105
+ if (addresses.length === 0)
106
+ return this.systemLookup(hostname, options, callback);
107
+ if (options.all)
108
+ callback(null, addresses);
109
+ else
110
+ callback(null, addresses[0].address, addresses[0].family);
111
+ }
112
+ }
113
+ function familyNumber(family) {
114
+ if (family === 'IPv4')
115
+ return 4;
116
+ if (family === 'IPv6')
117
+ return 6;
118
+ return family ?? 0;
119
+ }
120
+ /** The code of a connect error, looking inside the AggregateError that
121
+ * Node's happy-eyeballs connect raises when every address failed. */
122
+ function addressFailureCode(err) {
123
+ const codes = [err, ...(err.errors ?? [])].map((e) => e?.code);
124
+ return codes.find((c) => typeof c === 'string' && ADDRESS_FAILURE_CODES.has(c));
125
+ }
126
+ /** An undici Agent whose connections resolve through `cache` and drop the
127
+ * cached address when a connection fails for an address reason. */
128
+ export function createFleetAgent(cache) {
129
+ const connect = buildConnector({ lookup: cache.lookup });
130
+ return new Agent({
131
+ connect: (opts, callback) => connect(opts, (...args) => {
132
+ const err = args[0];
133
+ if (err) {
134
+ const code = addressFailureCode(err);
135
+ if (code)
136
+ cache.invalidate(opts.hostname, code);
137
+ }
138
+ callback(...args);
139
+ }),
140
+ });
141
+ }
142
+ let shared;
143
+ function sharedFleet() {
144
+ if (!shared) {
145
+ const cache = new FleetAddressCache({
146
+ // Rare (the load balancer's address would have to change), so worth
147
+ // a line. No logger reaches this module: stdout, like db.ts's backstop.
148
+ onInvalidate: (e) => console.warn('fleet address dropped after connect failure', { ...processMeta(), ...e }),
149
+ });
150
+ shared = { cache, agent: createFleetAgent(cache) };
151
+ }
152
+ return shared;
153
+ }
154
+ /** The dispatcher baselib's server-to-server calls use. */
155
+ export function fleetDispatcher() {
156
+ return sharedFleet().agent;
157
+ }
158
+ /** The process-wide cache's state for `hostname` (undefined: not a fleet host). */
159
+ export function fleetAddressState(hostname) {
160
+ return sharedFleet().cache.state(hostname);
161
+ }
162
+ /** fetch() through fleetDispatcher(). `dispatcher` is Node's (undici's)
163
+ * extension of RequestInit, absent from the DOM type. */
164
+ export const fleetFetch = (input, init) => fetch(input, { ...init, dispatcher: fleetDispatcher() });
package/dist/s2s.d.ts CHANGED
@@ -80,7 +80,8 @@ export declare const ID_TOKEN_MINT_TIMEOUT_MS = 5000;
80
80
  * (identity from TEST_S2S_IDENTITY, default 'test-service@test'). The token
81
81
  * is cached per audience until ID_TOKEN_REFRESH_MARGIN_MS before it
82
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. */
83
+ * ID_TOKEN_MINT_TIMEOUT_MS, and `init.signal` also ends the wait for it.
84
+ * Fleet hosts' addresses are cached in-process (fleet-dns.ts). */
84
85
  export declare function serviceFetch(url: string, opts: {
85
86
  audience: string;
86
87
  } & RequestInit): Promise<Response>;
package/dist/s2s.js CHANGED
@@ -17,6 +17,7 @@ import { OAuth2Client } from 'google-auth-library';
17
17
  import { createHash } from 'node:crypto';
18
18
  import { authMode } from './config.js';
19
19
  import { truncate } from './log-core.js';
20
+ import { fleetFetch } from './fleet-dns.js';
20
21
  /** The fleet coding agent's service-account email, read from AGENT_SA — set at
21
22
  * deploy time by each service (SECURITY.md: agents have full access, so
22
23
  * grantAllows always admits this identity). Env-driven rather than a baked-in
@@ -201,7 +202,8 @@ const idTokenMints = new Map();
201
202
  * (identity from TEST_S2S_IDENTITY, default 'test-service@test'). The token
202
203
  * is cached per audience until ID_TOKEN_REFRESH_MARGIN_MS before it
203
204
  * 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. */
205
+ * ID_TOKEN_MINT_TIMEOUT_MS, and `init.signal` also ends the wait for it.
206
+ * Fleet hosts' addresses are cached in-process (fleet-dns.ts). */
205
207
  export async function serviceFetch(url, opts) {
206
208
  const { audience, ...init } = opts;
207
209
  const headers = new Headers(init.headers);
@@ -212,7 +214,7 @@ export async function serviceFetch(url, opts) {
212
214
  const token = await abortable(idTokenFor(audience), init.signal);
213
215
  headers.set('authorization', `Bearer ${token}`);
214
216
  }
215
- return fetch(url, { ...init, headers });
217
+ return fleetFetch(url, { ...init, headers });
216
218
  }
217
219
  function idTokenFor(audience) {
218
220
  const cached = idTokens.get(audience);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@steve31415/baselib",
3
- "version": "3.54.1",
3
+ "version": "3.56.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",
@@ -133,6 +133,7 @@
133
133
  "google-auth-library": "^10.5.0",
134
134
  "hono": "^4.7.0",
135
135
  "pg": "^8.14.0",
136
+ "undici": "^6.29.1",
136
137
  "web-vitals": "^6.1.1",
137
138
  "zod": "^4.6.5"
138
139
  },