@steve31415/baselib 3.44.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/auth.d.ts +1 -1
- package/dist/auth.js +82 -9
- package/dist/s2s.d.ts +12 -1
- package/dist/s2s.js +86 -12
- package/dist/sync/broadcaster.d.ts +4 -0
- package/dist/sync/broadcaster.js +6 -0
- package/dist/sync/db-bridge.d.ts +11 -5
- package/dist/sync/db-bridge.js +35 -11
- package/package.json +1 -1
package/dist/auth.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { Context } from 'hono';
|
|
2
|
-
import type
|
|
2
|
+
import { type Logger } from './log-core.js';
|
|
3
3
|
import { ServiceCaller, S2sOptions } from './s2s.js';
|
|
4
4
|
export declare const DEFAULT_AUTH_URL = "https://auth.apps.snewman.net";
|
|
5
5
|
export declare const SESSION_COOKIE = "pw_session";
|
package/dist/auth.js
CHANGED
|
@@ -9,7 +9,11 @@
|
|
|
9
9
|
// policy, app-level checks) still runs.
|
|
10
10
|
// 2. Authorization: Bearer → service-to-service OIDC (s2s.ts).
|
|
11
11
|
// 3. pw_session cookie → whoami call to the auth service, cached
|
|
12
|
-
// in-instance (10 min hits / 30 s misses — the old-world numbers)
|
|
12
|
+
// in-instance (10 min hits / 30 s misses — the old-world numbers),
|
|
13
|
+
// one call per cookie at a time (concurrent misses share it).
|
|
14
|
+
// Every call (never a cache hit) logs one INFO 'session check' event
|
|
15
|
+
// saying why the cache could not answer and what the call cost the
|
|
16
|
+
// waiting request (~/migration/research/whoami-latency-2026-10-30.md).
|
|
13
17
|
//
|
|
14
18
|
// Then the user-access policy: by default an app admits only users whose
|
|
15
19
|
// auth role is 'owner'; `userAccess: 'invited'` admits any active fleet
|
|
@@ -25,6 +29,7 @@
|
|
|
25
29
|
import { createHash } from 'node:crypto';
|
|
26
30
|
import { createMiddleware } from 'hono/factory';
|
|
27
31
|
import { authMode } from './config.js';
|
|
32
|
+
import { truncate } from './log-core.js';
|
|
28
33
|
import { RateLimiter, resolveS2sCaller } from './s2s.js';
|
|
29
34
|
export const DEFAULT_AUTH_URL = 'https://auth.apps.snewman.net';
|
|
30
35
|
export const SESSION_COOKIE = 'pw_session';
|
|
@@ -46,6 +51,13 @@ export function requireIdentity(opts) {
|
|
|
46
51
|
const policy = opts.userAccess ?? 'owner';
|
|
47
52
|
const limiter = new RateLimiter(opts.rateLimitPerMin ?? 600);
|
|
48
53
|
const cache = new Map();
|
|
54
|
+
// The whoami call in flight per cache key (single-flight).
|
|
55
|
+
const inFlight = new Map();
|
|
56
|
+
// Session-check log state (per middleware, like the cache): cache hits
|
|
57
|
+
// since the last whoami call, and when the last call ended.
|
|
58
|
+
let hitsSinceLastCall = 0;
|
|
59
|
+
let callCount = 0;
|
|
60
|
+
let lastCallEndedAt;
|
|
49
61
|
const isApi = opts.isApiRequest ??
|
|
50
62
|
((c) => c.req.path.startsWith('/api/') || (c.req.header('accept') ?? '').includes('application/json'));
|
|
51
63
|
const unauthenticated = (c) => {
|
|
@@ -108,18 +120,77 @@ export function requireIdentity(opts) {
|
|
|
108
120
|
const key = createHash('sha256').update(cookieHeader).digest('hex');
|
|
109
121
|
const cached = cache.get(key);
|
|
110
122
|
if (cached && cached.expires > Date.now()) {
|
|
123
|
+
hitsSinceLastCall++;
|
|
111
124
|
if (!cached.user)
|
|
112
125
|
return unauthenticated(c);
|
|
113
126
|
return admit(cached.user);
|
|
114
127
|
}
|
|
128
|
+
// Single-flight: a request that misses while a call for the same cookie
|
|
129
|
+
// is already in flight shares that call's answer instead of making its
|
|
130
|
+
// own (30% of calls were such duplicates). Every request still gets an
|
|
131
|
+
// answer fetched during its own lifetime, so what is admitted does not
|
|
132
|
+
// change; the answer is applied per request below.
|
|
133
|
+
const flight = inFlight.get(key);
|
|
134
|
+
if (flight) {
|
|
135
|
+
flight.joiners.count++;
|
|
136
|
+
return settle(await flight.outcome);
|
|
137
|
+
}
|
|
138
|
+
const joiners = { count: 0 };
|
|
139
|
+
const outcome = callWhoami(c, key, cached, cookieHeader, joiners).finally(() => inFlight.delete(key));
|
|
140
|
+
inFlight.set(key, { outcome, joiners });
|
|
141
|
+
return settle(await outcome);
|
|
142
|
+
function settle(outcome) {
|
|
143
|
+
if (outcome.kind === 'user')
|
|
144
|
+
return admit(outcome.user);
|
|
145
|
+
if (outcome.kind === 'unauthenticated')
|
|
146
|
+
return unauthenticated(c);
|
|
147
|
+
return c.json({ error: 'auth service unavailable' }, 503);
|
|
148
|
+
}
|
|
149
|
+
});
|
|
150
|
+
// One whoami call, whose answer every request that joined it shares. The
|
|
151
|
+
// waiting request pays its full latency. One INFO event per call (a few
|
|
152
|
+
// hundred a day fleet-wide; never per hit or per joined request), with a
|
|
153
|
+
// fixed vocabulary of keys and nothing from the cookie but how many
|
|
154
|
+
// cookies the header carries (a changing header defeats the cache).
|
|
155
|
+
async function callWhoami(c, key, cached, cookieHeader, joiners) {
|
|
156
|
+
const started = Date.now();
|
|
157
|
+
const cacheState = !cached ? 'absent' : cached.user ? 'expired-user' : 'expired-unauthenticated';
|
|
158
|
+
const callFields = {
|
|
159
|
+
cacheState,
|
|
160
|
+
...(cached ? { entryAgeMs: started - cached.filled } : {}),
|
|
161
|
+
hitsSinceLastCall,
|
|
162
|
+
...(lastCallEndedAt !== undefined ? { sinceLastCallMs: started - lastCallEndedAt } : {}),
|
|
163
|
+
...(callCount === 0 ? { firstCall: true } : {}),
|
|
164
|
+
uptimeMs: Math.round(process.uptime() * 1000),
|
|
165
|
+
method: c.req.method,
|
|
166
|
+
path: truncate(c.req.path, 200),
|
|
167
|
+
upgrade: (c.req.header('upgrade') ?? '').toLowerCase() === 'websocket',
|
|
168
|
+
cookieCount: cookieHeader.split(';').filter((part) => part.includes('=')).length,
|
|
169
|
+
};
|
|
170
|
+
hitsSinceLastCall = 0;
|
|
171
|
+
callCount++;
|
|
172
|
+
// `joinedRequests`: requests that shared this call (single-flight),
|
|
173
|
+
// counted when the call ends, so it includes every joiner.
|
|
174
|
+
const callEnded = (status) => {
|
|
175
|
+
const ended = Date.now();
|
|
176
|
+
lastCallEndedAt = ended;
|
|
177
|
+
opts.logger?.info('session check', {
|
|
178
|
+
...callFields,
|
|
179
|
+
joinedRequests: joiners.count,
|
|
180
|
+
status,
|
|
181
|
+
elapsedMs: ended - started,
|
|
182
|
+
});
|
|
183
|
+
};
|
|
115
184
|
let res;
|
|
116
185
|
try {
|
|
117
186
|
res = await whoamiFetch(`${authUrl}/api/whoami`, { headers: { cookie: cookieHeader } });
|
|
118
187
|
}
|
|
119
188
|
catch (err) {
|
|
189
|
+
callEnded(0);
|
|
120
190
|
opts.logger?.error('auth service unreachable', { error: err });
|
|
121
|
-
return
|
|
191
|
+
return { kind: 'unavailable' };
|
|
122
192
|
}
|
|
193
|
+
callEnded(res.status);
|
|
123
194
|
if (res.ok) {
|
|
124
195
|
const data = (await res.json());
|
|
125
196
|
const role = parseRole(data.role);
|
|
@@ -128,19 +199,21 @@ export function requireIdentity(opts) {
|
|
|
128
199
|
// protocol change: a fault, not an "invited" answer. Fail closed,
|
|
129
200
|
// loudly, and without caching so recovery is instant.
|
|
130
201
|
opts.logger?.error('whoami answer has no role', { email: data.email, role: data.role });
|
|
131
|
-
return
|
|
202
|
+
return { kind: 'unavailable' };
|
|
132
203
|
}
|
|
133
204
|
const user = { type: 'user', email: data.email, name: data.name, userId: data.user_id, role };
|
|
134
|
-
|
|
135
|
-
|
|
205
|
+
const now = Date.now();
|
|
206
|
+
cache.set(key, { expires: now + hitMs, filled: now, user });
|
|
207
|
+
return { kind: 'user', user };
|
|
136
208
|
}
|
|
137
209
|
if (res.status === 401) {
|
|
138
|
-
|
|
139
|
-
|
|
210
|
+
const now = Date.now();
|
|
211
|
+
cache.set(key, { expires: now + missMs, filled: now });
|
|
212
|
+
return { kind: 'unauthenticated' };
|
|
140
213
|
}
|
|
141
214
|
opts.logger?.error('auth service error', { meta_status: res.status });
|
|
142
|
-
return
|
|
143
|
-
}
|
|
215
|
+
return { kind: 'unavailable' };
|
|
216
|
+
}
|
|
144
217
|
}
|
|
145
218
|
// The fleet-wide "not enabled" page, in the auth service's plain style so
|
|
146
219
|
// every app shows the same thing. Carries nothing but the request's host,
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/dist/sync/broadcaster.js
CHANGED
|
@@ -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
|
}
|
package/dist/sync/db-bridge.d.ts
CHANGED
|
@@ -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
|
|
26
|
-
*
|
|
27
|
-
*
|
|
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
|
-
|
|
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
|
-
/**
|
|
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. */
|
package/dist/sync/db-bridge.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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(
|
|
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 (
|
|
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(
|
|
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