@nebulr-group/bridge-auth-core 0.6.1 → 0.7.0-beta.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/flags/index.d.ts +2 -2
- package/dist/flags/index.d.ts.map +1 -1
- package/dist/flags/index.js +1 -1
- package/dist/flags/index.js.map +1 -1
- package/dist/flags/realtime.d.ts +181 -3
- package/dist/flags/realtime.d.ts.map +1 -1
- package/dist/flags/realtime.js +757 -41
- package/dist/flags/realtime.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/flags/realtime.js
CHANGED
|
@@ -19,8 +19,34 @@
|
|
|
19
19
|
// (`upsert` / `remove`). Per-user channel messages are handled via callbacks
|
|
20
20
|
// the framework SDK supplies (token refresh, attribute changes — see TBP-90).
|
|
21
21
|
import { createLogger } from '../logger.js';
|
|
22
|
+
// TBP-643 — single place to repoint the docs the terminal messages link to.
|
|
23
|
+
// Links render as `<base>#<reason>`, so every reason code we emit is an anchor
|
|
24
|
+
// on that page — keep codes lowercase_with_underscores.
|
|
25
|
+
export const REALTIME_DOCS_BASE_URL = 'https://thebridge.dev/docs/live-updates/troubleshooting/';
|
|
26
|
+
/**
|
|
27
|
+
* The AppSync `Authorization` value a signed-out session presents. Must be
|
|
28
|
+
* non-empty (AppSync rejects '' before the authorizer runs) and must match
|
|
29
|
+
* what the bridge-api authorizer recognises as "no token" byte for byte.
|
|
30
|
+
*/
|
|
31
|
+
export const REALTIME_ANONYMOUS_TOKEN = 'anonymous';
|
|
22
32
|
/** How long to wait for a subscribe ack before declaring the connection deaf. */
|
|
23
33
|
const SUBSCRIBE_ACK_TIMEOUT_MS = 10_000;
|
|
34
|
+
/** Cap on the diagnose round-trip — a hung call must not leave us 'connecting' forever. */
|
|
35
|
+
const DIAGNOSE_TIMEOUT_MS = 5_000;
|
|
36
|
+
/** While parked in 'unauthorized', how often to look for a new token. */
|
|
37
|
+
const PARKED_TOKEN_CHECK_MS = 5_000;
|
|
38
|
+
/** Healthy connections are reported at most this often (TBP-645). */
|
|
39
|
+
const STATUS_REPORT_OPEN_INTERVAL_MS = 10 * 60_000;
|
|
40
|
+
/** Cap on a status report — it is telemetry, it must not linger. */
|
|
41
|
+
const STATUS_REPORT_TIMEOUT_MS = 3_000;
|
|
42
|
+
/** Centrifugo `/realtime/authorize` answered 401/403 — a refusal, not a blip. */
|
|
43
|
+
class RealtimeAuthRefusedError extends Error {
|
|
44
|
+
status;
|
|
45
|
+
constructor(status) {
|
|
46
|
+
super(`realtime authorize refused: ${status}`);
|
|
47
|
+
this.status = status;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
24
50
|
export class RealtimeClient {
|
|
25
51
|
cfg;
|
|
26
52
|
ws;
|
|
@@ -55,6 +81,33 @@ export class RealtimeClient {
|
|
|
55
81
|
ackedChannels = new Set();
|
|
56
82
|
failedChannels = new Map();
|
|
57
83
|
subscribeAckTimer;
|
|
84
|
+
// ── Fault reporting (TBP-643) ─────────────────────────────────────────────
|
|
85
|
+
status;
|
|
86
|
+
onStatusChangeHook;
|
|
87
|
+
/** The current run of trouble, if any — see FaultEpisode. */
|
|
88
|
+
episode;
|
|
89
|
+
/** Set while parked in 'unauthorized'. */
|
|
90
|
+
refusal;
|
|
91
|
+
/**
|
|
92
|
+
* token|reason|side of the last refusal we logged. A resume (tab refocus,
|
|
93
|
+
* `online`) that ends in the identical refusal is the same news — it goes
|
|
94
|
+
* to debug, not another console error.
|
|
95
|
+
*/
|
|
96
|
+
lastRefusalKey;
|
|
97
|
+
/**
|
|
98
|
+
* Token handed back by `refreshAuthToken`, used while the host's
|
|
99
|
+
* `getAuthToken()` still returns the token it replaced (`staleToken`). Hosts
|
|
100
|
+
* may not have updated their store by the time the refresh resolves.
|
|
101
|
+
*/
|
|
102
|
+
freshToken;
|
|
103
|
+
staleToken;
|
|
104
|
+
/** A socket we closed on purpose (setUserId & co.) — its close is not a fault. */
|
|
105
|
+
expectedCloseWs;
|
|
106
|
+
resumeListenersInstalled = false;
|
|
107
|
+
/** Runs only while parked — see startParkedTokenCheck. */
|
|
108
|
+
parkedTokenTimer;
|
|
109
|
+
/** `Date.now()` of the last 'open' report — see STATUS_REPORT_OPEN_INTERVAL_MS. */
|
|
110
|
+
lastOpenReportAt;
|
|
58
111
|
constructor(cfg) {
|
|
59
112
|
const defaultWs = ((url, protocols) =>
|
|
60
113
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
@@ -71,9 +124,30 @@ export class RealtimeClient {
|
|
|
71
124
|
websocketFactory: cfg.websocketFactory ?? defaultWs,
|
|
72
125
|
fetchFn: cfg.fetchFn ?? (typeof fetch !== 'undefined' ? fetch : undefined),
|
|
73
126
|
getAuthToken: cfg.getAuthToken,
|
|
127
|
+
refreshAuthToken: cfg.refreshAuthToken,
|
|
128
|
+
diagnose: cfg.diagnose !== false,
|
|
129
|
+
docsBaseUrl: cfg.docsBaseUrl ?? REALTIME_DOCS_BASE_URL,
|
|
130
|
+
reportStatus: cfg.reportStatus !== false,
|
|
74
131
|
logger: cfg.logger ?? createLogger(false),
|
|
75
132
|
};
|
|
76
133
|
this.reconnectDelayMs = this.cfg.reconnectBaseMs;
|
|
134
|
+
this.status = { state: 'idle', retrying: false, since: Date.now() };
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Current connection status with the reason, whose side a fault is on, and
|
|
138
|
+
* whether the client is still retrying (TBP-643). `getState()` is the same
|
|
139
|
+
* `state` without the explanation.
|
|
140
|
+
*/
|
|
141
|
+
getStatus() {
|
|
142
|
+
return { ...this.status };
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Register a hook fired on every status change (state, reason, side or
|
|
146
|
+
* retrying). Use it to drive a "live updates off" indicator. Hook errors are
|
|
147
|
+
* swallowed, like every other hook here.
|
|
148
|
+
*/
|
|
149
|
+
setOnStatusChange(hook) {
|
|
150
|
+
this.onStatusChangeHook = hook;
|
|
77
151
|
}
|
|
78
152
|
/** Attach to a BridgeFlags instance — flag updates auto-apply to its cache. */
|
|
79
153
|
attach(bridge) {
|
|
@@ -213,10 +287,17 @@ export class RealtimeClient {
|
|
|
213
287
|
this.reconnectTimer = undefined;
|
|
214
288
|
}
|
|
215
289
|
this.reconnectDelayMs = this.cfg.reconnectBaseMs;
|
|
290
|
+
// TBP-643 — an explicit reauthorize is the host saying "try again": it
|
|
291
|
+
// lifts a parked refusal and starts a fresh episode. An auth episode that
|
|
292
|
+
// is still in flight is deliberately NOT reset — the host calls this
|
|
293
|
+
// whenever its token changes, including after the refresh WE asked for,
|
|
294
|
+
// and resetting would re-arm that refresh and loop.
|
|
295
|
+
if (this.state === 'unauthorized')
|
|
296
|
+
this.clearRefusal();
|
|
216
297
|
if (this.ws) {
|
|
217
298
|
const oldWs = this.ws;
|
|
218
299
|
this.ws = undefined;
|
|
219
|
-
this.
|
|
300
|
+
this.setState('closed');
|
|
220
301
|
try {
|
|
221
302
|
oldWs.close(1000, 'sdk.reauthorize');
|
|
222
303
|
}
|
|
@@ -224,6 +305,9 @@ export class RealtimeClient {
|
|
|
224
305
|
// ignore
|
|
225
306
|
}
|
|
226
307
|
}
|
|
308
|
+
else if (this.state === 'unauthorized') {
|
|
309
|
+
this.setState('closed');
|
|
310
|
+
}
|
|
227
311
|
await this.start();
|
|
228
312
|
}
|
|
229
313
|
/**
|
|
@@ -237,6 +321,7 @@ export class RealtimeClient {
|
|
|
237
321
|
return;
|
|
238
322
|
this.cfg.appId = appId;
|
|
239
323
|
if (this.ws) {
|
|
324
|
+
this.expectedCloseWs = this.ws;
|
|
240
325
|
this.ws.close(1000, 'sdk.setAppId');
|
|
241
326
|
// onclose → scheduleReconnect → start() picks up updated channelsToSubscribe()
|
|
242
327
|
}
|
|
@@ -251,6 +336,7 @@ export class RealtimeClient {
|
|
|
251
336
|
return;
|
|
252
337
|
this.cfg.workspaceId = workspaceId;
|
|
253
338
|
if (this.ws) {
|
|
339
|
+
this.expectedCloseWs = this.ws;
|
|
254
340
|
this.ws.close(1000, 'sdk.setWorkspaceId');
|
|
255
341
|
}
|
|
256
342
|
}
|
|
@@ -264,6 +350,7 @@ export class RealtimeClient {
|
|
|
264
350
|
return;
|
|
265
351
|
this.cfg.userId = userId;
|
|
266
352
|
if (this.ws) {
|
|
353
|
+
this.expectedCloseWs = this.ws;
|
|
267
354
|
this.ws.close(1000, 'sdk.setUserId');
|
|
268
355
|
// onclose fires → scheduleReconnect → start() picks up updated channelsToSubscribe()
|
|
269
356
|
}
|
|
@@ -272,13 +359,25 @@ export class RealtimeClient {
|
|
|
272
359
|
async start() {
|
|
273
360
|
if (!this.cfg.enabled || this.stopped)
|
|
274
361
|
return;
|
|
275
|
-
if (this.state
|
|
362
|
+
if (this.state === 'unauthorized') {
|
|
363
|
+
// Parked after a refusal (TBP-643). The same token would only be refused
|
|
364
|
+
// again, so a plain start() with it stays parked; a different token is
|
|
365
|
+
// a new session and gets a fresh episode.
|
|
366
|
+
if (this.currentToken() === this.refusal?.token)
|
|
367
|
+
return;
|
|
368
|
+
this.clearRefusal();
|
|
369
|
+
}
|
|
370
|
+
else if (this.state !== 'idle' && this.state !== 'closed') {
|
|
276
371
|
return;
|
|
277
|
-
|
|
372
|
+
}
|
|
373
|
+
this.installResumeListeners();
|
|
374
|
+
this.setState('connecting');
|
|
278
375
|
try {
|
|
279
376
|
const serverConfig = await this.fetchServerConfig();
|
|
280
377
|
if (serverConfig.kind === 'noop' || !serverConfig.endpoint) {
|
|
281
|
-
this
|
|
378
|
+
// Realtime is off for this workspace — nothing to retry or report.
|
|
379
|
+
this.episode = undefined;
|
|
380
|
+
this.setState('closed');
|
|
282
381
|
return;
|
|
283
382
|
}
|
|
284
383
|
const channels = this.channelsToSubscribe();
|
|
@@ -290,16 +389,30 @@ export class RealtimeClient {
|
|
|
290
389
|
return;
|
|
291
390
|
}
|
|
292
391
|
if (serverConfig.kind === 'centrifugo') {
|
|
293
|
-
const
|
|
392
|
+
const userToken = this.currentToken();
|
|
393
|
+
let auth;
|
|
394
|
+
try {
|
|
395
|
+
auth = await this.authorize(channels, userToken);
|
|
396
|
+
}
|
|
397
|
+
catch (err) {
|
|
398
|
+
// A 401/403 from /realtime/authorize is the same refusal AppSync
|
|
399
|
+
// reports as connection_error — retrying on a backoff can't fix it.
|
|
400
|
+
if (err instanceof RealtimeAuthRefusedError) {
|
|
401
|
+
await this.handleAuthRefusal(userToken, channels);
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
404
|
+
throw err;
|
|
405
|
+
}
|
|
294
406
|
this.openWebSocket(serverConfig.endpoint, auth);
|
|
295
407
|
return;
|
|
296
408
|
}
|
|
297
409
|
// Unknown protocol — close cleanly so consumers don't get stuck in
|
|
298
410
|
// 'connecting'. New transports must be added explicitly here.
|
|
299
|
-
this.
|
|
411
|
+
this.setState('closed');
|
|
300
412
|
}
|
|
301
|
-
catch {
|
|
302
|
-
this.
|
|
413
|
+
catch (err) {
|
|
414
|
+
this.beginTransient('setup_failed', `could not reach Bridge to set up the connection (${errorText(err)})`, 'warn');
|
|
415
|
+
this.setState('closed');
|
|
303
416
|
this.scheduleReconnect();
|
|
304
417
|
}
|
|
305
418
|
}
|
|
@@ -307,6 +420,7 @@ export class RealtimeClient {
|
|
|
307
420
|
async stop() {
|
|
308
421
|
this.stopped = true;
|
|
309
422
|
this.clearSubscribeAckTimer();
|
|
423
|
+
this.removeResumeListeners();
|
|
310
424
|
if (this.reconnectTimer) {
|
|
311
425
|
clearTimeout(this.reconnectTimer);
|
|
312
426
|
this.reconnectTimer = undefined;
|
|
@@ -320,7 +434,9 @@ export class RealtimeClient {
|
|
|
320
434
|
}
|
|
321
435
|
this.ws = undefined;
|
|
322
436
|
}
|
|
323
|
-
this.
|
|
437
|
+
this.episode = undefined;
|
|
438
|
+
this.clearRefusal();
|
|
439
|
+
this.setState('closed');
|
|
324
440
|
}
|
|
325
441
|
/** Read connection state. */
|
|
326
442
|
getState() {
|
|
@@ -356,8 +472,8 @@ export class RealtimeClient {
|
|
|
356
472
|
}
|
|
357
473
|
return (await res.json());
|
|
358
474
|
}
|
|
359
|
-
async authorize(channels) {
|
|
360
|
-
const token =
|
|
475
|
+
async authorize(channels, userToken) {
|
|
476
|
+
const token = userToken ?? this.cfg.apiKey;
|
|
361
477
|
const res = await this.cfg.fetchFn(`${this.cfg.apiBaseUrl}/realtime/authorize`, {
|
|
362
478
|
method: 'POST',
|
|
363
479
|
headers: {
|
|
@@ -366,6 +482,9 @@ export class RealtimeClient {
|
|
|
366
482
|
},
|
|
367
483
|
body: JSON.stringify({ channels }),
|
|
368
484
|
});
|
|
485
|
+
if (res.status === 401 || res.status === 403) {
|
|
486
|
+
throw new RealtimeAuthRefusedError(res.status);
|
|
487
|
+
}
|
|
369
488
|
if (!res.ok) {
|
|
370
489
|
throw new Error(`realtime authorize failed: ${res.status}`);
|
|
371
490
|
}
|
|
@@ -377,7 +496,6 @@ export class RealtimeClient {
|
|
|
377
496
|
ws.onopen = () => {
|
|
378
497
|
if (this.ws !== ws)
|
|
379
498
|
return;
|
|
380
|
-
this.state = 'open';
|
|
381
499
|
this.reconnectDelayMs = this.cfg.reconnectBaseMs;
|
|
382
500
|
// Send connect with the signed token + channels. Centrifugo expects a
|
|
383
501
|
// command frame like `{ "connect": { "token": "..." }, "id": 1 }` and
|
|
@@ -389,12 +507,7 @@ export class RealtimeClient {
|
|
|
389
507
|
catch {
|
|
390
508
|
// ignore
|
|
391
509
|
}
|
|
392
|
-
|
|
393
|
-
this.onOpenHook?.();
|
|
394
|
-
}
|
|
395
|
-
catch {
|
|
396
|
-
// hook errors must not break the connection
|
|
397
|
-
}
|
|
510
|
+
this.markOpen();
|
|
398
511
|
};
|
|
399
512
|
ws.onmessage = (ev) => {
|
|
400
513
|
if (this.ws !== ws)
|
|
@@ -427,7 +540,8 @@ export class RealtimeClient {
|
|
|
427
540
|
// is from a stale socket. Don't flap state or fire hooks.
|
|
428
541
|
if (this.ws !== ws)
|
|
429
542
|
return;
|
|
430
|
-
this.
|
|
543
|
+
this.noteClose(ws);
|
|
544
|
+
this.setState('closed');
|
|
431
545
|
try {
|
|
432
546
|
this.onCloseHook?.();
|
|
433
547
|
}
|
|
@@ -461,8 +575,10 @@ export class RealtimeClient {
|
|
|
461
575
|
* `appsync-events.adapter.ts:87` and `appsync-authorizer.handler.ts:59`).
|
|
462
576
|
*
|
|
463
577
|
* Anonymous flow: `getAuthToken()` returns undefined → Authorization sent as
|
|
464
|
-
*
|
|
465
|
-
*
|
|
578
|
+
* the marker `REALTIME_ANONYMOUS_TOKEN` (see buildAppSyncAuthHeader for why
|
|
579
|
+
* it can't be empty). The Lambda authorizer treats exactly that value as "no
|
|
580
|
+
* token": CONNECT allowed, `app:<appId>` channels origin-checked against the
|
|
581
|
+
* app's allowedOrigins, everything else denied `no_token`.
|
|
466
582
|
*/
|
|
467
583
|
openAppSyncWebSocket(endpoint, channels) {
|
|
468
584
|
const { url, httpHost } = normalizeAppSyncEndpoint(endpoint);
|
|
@@ -471,7 +587,10 @@ export class RealtimeClient {
|
|
|
471
587
|
// server-side validation uses this to verify the connection — sending
|
|
472
588
|
// the realtime host instead produces a silent close 1000 right after
|
|
473
589
|
// the WS upgrade succeeds.
|
|
474
|
-
|
|
590
|
+
// Captured once: a refusal must be diagnosed against the token that was
|
|
591
|
+
// actually presented, not whatever the host holds by the time we look.
|
|
592
|
+
const token = this.currentToken();
|
|
593
|
+
const authHeader = buildAppSyncAuthHeader(token, httpHost);
|
|
475
594
|
const headerProtocol = `header-${base64urlEncode(JSON.stringify(authHeader))}`;
|
|
476
595
|
const ws = this.cfg.websocketFactory(url, [APPSYNC_WS_PROTOCOL, headerProtocol]);
|
|
477
596
|
this.ws = ws;
|
|
@@ -580,14 +699,8 @@ export class RealtimeClient {
|
|
|
580
699
|
this.ackedChannels.add(channel);
|
|
581
700
|
}
|
|
582
701
|
if (this.state !== 'open') {
|
|
583
|
-
this.state = 'open';
|
|
584
702
|
this.clearSubscribeAckTimer();
|
|
585
|
-
|
|
586
|
-
this.onOpenHook?.();
|
|
587
|
-
}
|
|
588
|
-
catch {
|
|
589
|
-
// hook errors must not break the connection.
|
|
590
|
-
}
|
|
703
|
+
this.markOpen();
|
|
591
704
|
}
|
|
592
705
|
break;
|
|
593
706
|
}
|
|
@@ -609,8 +722,20 @@ export class RealtimeClient {
|
|
|
609
722
|
}
|
|
610
723
|
case 'connection_error':
|
|
611
724
|
case 'error':
|
|
612
|
-
//
|
|
613
|
-
|
|
725
|
+
// TBP-643 — an auth refusal is not a blip. Reconnecting with the same
|
|
726
|
+
// token can only be refused again; before this branch existed the
|
|
727
|
+
// client did exactly that on a backoff loop forever, logging a
|
|
728
|
+
// reason-less line each time. AppSync never relays the authorizer's
|
|
729
|
+
// reason, so detect by errorType/errorCode and work out the "why"
|
|
730
|
+
// ourselves (pre-checks → one refresh → one diagnose).
|
|
731
|
+
if (isAppSyncAuthRefusal(frame)) {
|
|
732
|
+
this.detachSocket(ws, `appsync:${type}`);
|
|
733
|
+
void this.handleAuthRefusal(token, channels);
|
|
734
|
+
break;
|
|
735
|
+
}
|
|
736
|
+
// Anything else is a connection-level fault worth retrying. Logged
|
|
737
|
+
// once per episode (beginTransient), not once per attempt.
|
|
738
|
+
this.beginTransient('server_error', `the realtime server reported ${type}: ${describeAppSyncError(frame)}`, 'error');
|
|
614
739
|
try {
|
|
615
740
|
ws.close(1011, `appsync:${type}`);
|
|
616
741
|
}
|
|
@@ -627,7 +752,8 @@ export class RealtimeClient {
|
|
|
627
752
|
ws.onclose = () => {
|
|
628
753
|
if (this.ws !== ws)
|
|
629
754
|
return;
|
|
630
|
-
this.
|
|
755
|
+
this.noteClose(ws);
|
|
756
|
+
this.setState('closed');
|
|
631
757
|
this.resetSubscribeTracking();
|
|
632
758
|
try {
|
|
633
759
|
this.onCloseHook?.();
|
|
@@ -661,7 +787,13 @@ export class RealtimeClient {
|
|
|
661
787
|
* polling or warn the user.
|
|
662
788
|
*/
|
|
663
789
|
markDegraded() {
|
|
664
|
-
|
|
790
|
+
// The transport did come back; degraded has its own error log above, so a
|
|
791
|
+
// pending "restored" line would be misleading — drop the episode quietly.
|
|
792
|
+
this.episode = undefined;
|
|
793
|
+
const alreadyDegraded = this.state === 'degraded';
|
|
794
|
+
this.setState('degraded');
|
|
795
|
+
if (!alreadyDegraded)
|
|
796
|
+
this.sendStatusReport({ state: 'degraded', reason: 'no_channel_accepted' });
|
|
665
797
|
try {
|
|
666
798
|
this.onDegradedHook?.();
|
|
667
799
|
}
|
|
@@ -788,12 +920,517 @@ export class RealtimeClient {
|
|
|
788
920
|
return;
|
|
789
921
|
this.reconnectTimer = setTimeout(() => {
|
|
790
922
|
this.reconnectTimer = undefined;
|
|
791
|
-
this.
|
|
792
|
-
void this.start();
|
|
923
|
+
this.fireReconnect();
|
|
793
924
|
}, this.reconnectDelayMs);
|
|
794
925
|
if (this.reconnectTimer?.unref)
|
|
795
926
|
this.reconnectTimer.unref();
|
|
796
927
|
}
|
|
928
|
+
fireReconnect() {
|
|
929
|
+
this.reconnectDelayMs = Math.min(this.reconnectDelayMs * 2, this.cfg.reconnectMaxMs);
|
|
930
|
+
if (this.episode)
|
|
931
|
+
this.episode.attempts++;
|
|
932
|
+
void this.start();
|
|
933
|
+
}
|
|
934
|
+
// ── Status + fault reporting (TBP-643) ────────────────────────────────────
|
|
935
|
+
setState(state) {
|
|
936
|
+
this.state = state;
|
|
937
|
+
this.publishStatus();
|
|
938
|
+
}
|
|
939
|
+
publishStatus() {
|
|
940
|
+
const detail = this.statusDetail();
|
|
941
|
+
const prev = this.status;
|
|
942
|
+
if (prev.state === this.state &&
|
|
943
|
+
prev.reason === detail.reason &&
|
|
944
|
+
prev.side === detail.side &&
|
|
945
|
+
prev.retrying === detail.retrying &&
|
|
946
|
+
prev.ref === detail.ref) {
|
|
947
|
+
return;
|
|
948
|
+
}
|
|
949
|
+
this.status = { state: this.state, ...detail, since: Date.now() };
|
|
950
|
+
if (!this.onStatusChangeHook)
|
|
951
|
+
return;
|
|
952
|
+
try {
|
|
953
|
+
this.onStatusChangeHook({ ...this.status });
|
|
954
|
+
}
|
|
955
|
+
catch {
|
|
956
|
+
// a consumer's indicator must never break the connection.
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
statusDetail() {
|
|
960
|
+
if (this.state === 'unauthorized' && this.refusal) {
|
|
961
|
+
const r = this.refusal;
|
|
962
|
+
return { reason: r.reason, side: r.side, retrying: false, docsUrl: r.docsUrl, ref: r.ref };
|
|
963
|
+
}
|
|
964
|
+
if (this.state === 'degraded')
|
|
965
|
+
return { reason: 'no_channel_accepted', retrying: false };
|
|
966
|
+
const ep = this.episode;
|
|
967
|
+
if (ep?.kind === 'transient') {
|
|
968
|
+
return { reason: ep.reason, side: 'network', retrying: true, ref: ep.ref };
|
|
969
|
+
}
|
|
970
|
+
// Auth episode in flight: refreshing or diagnosing — not given up yet.
|
|
971
|
+
if (ep?.kind === 'auth')
|
|
972
|
+
return { reason: ep.reason, retrying: true, ref: ep.ref };
|
|
973
|
+
return { retrying: false };
|
|
974
|
+
}
|
|
975
|
+
/** The token to present — see `freshToken`. */
|
|
976
|
+
currentToken() {
|
|
977
|
+
const host = this.cfg.getAuthToken?.();
|
|
978
|
+
if (this.freshToken !== undefined) {
|
|
979
|
+
if (host === this.staleToken)
|
|
980
|
+
return this.freshToken;
|
|
981
|
+
// The host has caught up (or moved on) — its value wins from here.
|
|
982
|
+
this.freshToken = undefined;
|
|
983
|
+
this.staleToken = undefined;
|
|
984
|
+
}
|
|
985
|
+
return host;
|
|
986
|
+
}
|
|
987
|
+
/** The transport is usable. Closes any episode, logging recovery if we logged the fault. */
|
|
988
|
+
markOpen() {
|
|
989
|
+
const ep = this.episode;
|
|
990
|
+
this.episode = undefined;
|
|
991
|
+
this.lastRefusalKey = undefined;
|
|
992
|
+
this.setState('open');
|
|
993
|
+
const now = Date.now();
|
|
994
|
+
if (this.lastOpenReportAt === undefined || now - this.lastOpenReportAt >= STATUS_REPORT_OPEN_INTERVAL_MS) {
|
|
995
|
+
if (this.sendStatusReport({ state: 'open' }))
|
|
996
|
+
this.lastOpenReportAt = now;
|
|
997
|
+
}
|
|
998
|
+
if (ep?.kind === 'transient' && ep.logLevel) {
|
|
999
|
+
const n = Math.max(ep.attempts, 1);
|
|
1000
|
+
this.cfg.logger[ep.logLevel](`[bridge] Live updates restored after ${n} attempt${n === 1 ? '' : 's'}.`);
|
|
1001
|
+
}
|
|
1002
|
+
try {
|
|
1003
|
+
this.onOpenHook?.();
|
|
1004
|
+
}
|
|
1005
|
+
catch {
|
|
1006
|
+
// hook errors must not break the connection.
|
|
1007
|
+
}
|
|
1008
|
+
}
|
|
1009
|
+
/** Called from onclose of the CURRENT socket: an unplanned close starts a transient episode. */
|
|
1010
|
+
noteClose(ws) {
|
|
1011
|
+
if (this.expectedCloseWs === ws) {
|
|
1012
|
+
this.expectedCloseWs = undefined;
|
|
1013
|
+
return;
|
|
1014
|
+
}
|
|
1015
|
+
this.beginTransient('connection_lost', 'the realtime connection dropped', 'warn');
|
|
1016
|
+
}
|
|
1017
|
+
/**
|
|
1018
|
+
* Open a transient episode if none is running, and log its start ONCE.
|
|
1019
|
+
* Server-reported errors log at `error` (they used to, and they are real
|
|
1020
|
+
* faults); plain drops and fetch failures log at `warn` — sleep/wake and
|
|
1021
|
+
* wifi changes cause them constantly and they fix themselves, so they must
|
|
1022
|
+
* not paint every end-user console red.
|
|
1023
|
+
*/
|
|
1024
|
+
beginTransient(reason, detail, level) {
|
|
1025
|
+
if (this.episode)
|
|
1026
|
+
return;
|
|
1027
|
+
const ref = newRef();
|
|
1028
|
+
this.episode = {
|
|
1029
|
+
kind: 'transient',
|
|
1030
|
+
ref,
|
|
1031
|
+
attempts: 0,
|
|
1032
|
+
reason,
|
|
1033
|
+
logLevel: level,
|
|
1034
|
+
refreshed: false,
|
|
1035
|
+
diagnosed: false,
|
|
1036
|
+
};
|
|
1037
|
+
this.cfg.logger[level](`[bridge] Live updates interrupted — ${detail}. Retrying in the background; plan, entitlement and feature-flag changes resume when it reconnects. ref ${ref}`);
|
|
1038
|
+
}
|
|
1039
|
+
/** Drop a socket without letting its onclose schedule a reconnect. */
|
|
1040
|
+
detachSocket(ws, reason) {
|
|
1041
|
+
if (this.ws === ws)
|
|
1042
|
+
this.ws = undefined;
|
|
1043
|
+
this.resetSubscribeTracking();
|
|
1044
|
+
try {
|
|
1045
|
+
ws.close(1011, reason);
|
|
1046
|
+
}
|
|
1047
|
+
catch {
|
|
1048
|
+
// ignore
|
|
1049
|
+
}
|
|
1050
|
+
try {
|
|
1051
|
+
this.onCloseHook?.();
|
|
1052
|
+
}
|
|
1053
|
+
catch {
|
|
1054
|
+
// hook errors must not break refusal handling.
|
|
1055
|
+
}
|
|
1056
|
+
}
|
|
1057
|
+
/**
|
|
1058
|
+
* Bridge refused `token` (TBP-643). Policy, in order:
|
|
1059
|
+
* a. client-side pre-checks on the token — cheap, and they name the fix;
|
|
1060
|
+
* b. one host refresh + an immediate reconnect, if the host gave us a hook;
|
|
1061
|
+
* c. if still refused and the pre-checks found nothing, ask Bridge once;
|
|
1062
|
+
* d. park in 'unauthorized', stop reconnecting, log ONE message.
|
|
1063
|
+
* Resumes on reauthorize(), a changed token on start(), `online`, or the
|
|
1064
|
+
* tab becoming visible.
|
|
1065
|
+
*/
|
|
1066
|
+
async handleAuthRefusal(token, channels) {
|
|
1067
|
+
if (this.stopped)
|
|
1068
|
+
return;
|
|
1069
|
+
if (this.reconnectTimer) {
|
|
1070
|
+
clearTimeout(this.reconnectTimer);
|
|
1071
|
+
this.reconnectTimer = undefined;
|
|
1072
|
+
}
|
|
1073
|
+
let ep = this.episode;
|
|
1074
|
+
if (!ep || ep.kind !== 'auth') {
|
|
1075
|
+
ep = {
|
|
1076
|
+
kind: 'auth',
|
|
1077
|
+
ref: newRef(),
|
|
1078
|
+
attempts: 0,
|
|
1079
|
+
reason: 'refused',
|
|
1080
|
+
refreshed: false,
|
|
1081
|
+
diagnosed: false,
|
|
1082
|
+
};
|
|
1083
|
+
this.episode = ep;
|
|
1084
|
+
}
|
|
1085
|
+
this.setState('connecting');
|
|
1086
|
+
if (this.cfg.refreshAuthToken && !ep.refreshed) {
|
|
1087
|
+
ep.refreshed = true;
|
|
1088
|
+
let fresh;
|
|
1089
|
+
try {
|
|
1090
|
+
fresh = await this.cfg.refreshAuthToken();
|
|
1091
|
+
}
|
|
1092
|
+
catch {
|
|
1093
|
+
fresh = undefined;
|
|
1094
|
+
}
|
|
1095
|
+
if (this.episode !== ep || this.stopped)
|
|
1096
|
+
return;
|
|
1097
|
+
// Same token back = nothing to retry with; fall through to diagnosis.
|
|
1098
|
+
if (fresh && fresh !== token) {
|
|
1099
|
+
this.freshToken = fresh;
|
|
1100
|
+
this.staleToken = this.cfg.getAuthToken?.();
|
|
1101
|
+
this.setState('closed');
|
|
1102
|
+
await this.start();
|
|
1103
|
+
return;
|
|
1104
|
+
}
|
|
1105
|
+
}
|
|
1106
|
+
let verdict = this.precheckToken(token, channels);
|
|
1107
|
+
if (!verdict && this.cfg.diagnose && !ep.diagnosed) {
|
|
1108
|
+
ep.diagnosed = true;
|
|
1109
|
+
verdict = await this.diagnoseRefusal(token, channels, ep.ref);
|
|
1110
|
+
if (this.episode !== ep || this.stopped)
|
|
1111
|
+
return;
|
|
1112
|
+
}
|
|
1113
|
+
// Nothing explained it. With a token: Bridge-issued, unexpired, right
|
|
1114
|
+
// environment and app, yet refused — Bridge's problem, not the app's.
|
|
1115
|
+
// Without one: the server would not take an anonymous connect (e.g. an
|
|
1116
|
+
// authorizer that predates the anonymous marker). That is not a fault in
|
|
1117
|
+
// the app either, but "a problem on Bridge's side" would send developers
|
|
1118
|
+
// chasing an outage — it gets its own reason and wording, and the parked
|
|
1119
|
+
// client resumes as soon as a user signs in.
|
|
1120
|
+
const fallback = token
|
|
1121
|
+
? { reason: 'refused', side: 'bridge' }
|
|
1122
|
+
: { reason: 'anonymous_refused', side: 'bridge' };
|
|
1123
|
+
this.enterUnauthorized(token, verdict ?? fallback, ep);
|
|
1124
|
+
}
|
|
1125
|
+
/** Explain a refusal from the token alone. Decodes without verifying — this is diagnosis, not auth. */
|
|
1126
|
+
precheckToken(token, channels) {
|
|
1127
|
+
if (!token) {
|
|
1128
|
+
// Anonymous is a legitimate state for app-only channels (`app:<id>`).
|
|
1129
|
+
// It is only a fault when a channel needs a user — workspace, user,
|
|
1130
|
+
// integration: anything that is not `app:`.
|
|
1131
|
+
return channels.some((c) => !c.startsWith('app:'))
|
|
1132
|
+
? { reason: 'no_token', side: 'app' }
|
|
1133
|
+
: undefined;
|
|
1134
|
+
}
|
|
1135
|
+
const claims = decodeJwtPayload(token);
|
|
1136
|
+
if (!claims)
|
|
1137
|
+
return { reason: 'malformed', side: 'app' };
|
|
1138
|
+
const expectedIssuer = `${this.cfg.apiBaseUrl}/auth`;
|
|
1139
|
+
if (typeof claims.iss === 'string' && !claims.iss.startsWith(expectedIssuer)) {
|
|
1140
|
+
return { reason: 'wrong_environment', side: 'config', tokenIssuer: claims.iss };
|
|
1141
|
+
}
|
|
1142
|
+
if (this.cfg.appId && typeof claims.aid === 'string' && claims.aid !== this.cfg.appId) {
|
|
1143
|
+
return { reason: 'wrong_app', side: 'config', tokenAppId: claims.aid };
|
|
1144
|
+
}
|
|
1145
|
+
if (typeof claims.exp === 'number' && claims.exp * 1000 <= Date.now()) {
|
|
1146
|
+
return { reason: 'expired', side: 'app' };
|
|
1147
|
+
}
|
|
1148
|
+
return undefined;
|
|
1149
|
+
}
|
|
1150
|
+
/**
|
|
1151
|
+
* Ask Bridge why it refused (contract: `POST /realtime/diagnose` →
|
|
1152
|
+
* `{ ok, reason, side }`, sharing the authorizer's classifier). The endpoint
|
|
1153
|
+
* may not exist yet — any failure returns undefined and the caller falls
|
|
1154
|
+
* back to 'refused'.
|
|
1155
|
+
*/
|
|
1156
|
+
async diagnoseRefusal(token, channels, ref) {
|
|
1157
|
+
const headers = {
|
|
1158
|
+
'Content-Type': 'application/json',
|
|
1159
|
+
'x-bridge-realtime-ref': ref,
|
|
1160
|
+
};
|
|
1161
|
+
if (token)
|
|
1162
|
+
headers.Authorization = `Bearer ${token}`;
|
|
1163
|
+
if (this.cfg.appId)
|
|
1164
|
+
headers['x-app-id'] = this.cfg.appId;
|
|
1165
|
+
let timer;
|
|
1166
|
+
try {
|
|
1167
|
+
const timeout = new Promise((_, reject) => {
|
|
1168
|
+
timer = setTimeout(() => reject(new Error('diagnose timed out')), DIAGNOSE_TIMEOUT_MS);
|
|
1169
|
+
});
|
|
1170
|
+
const res = await Promise.race([
|
|
1171
|
+
this.cfg.fetchFn(`${this.cfg.apiBaseUrl}/realtime/diagnose`, {
|
|
1172
|
+
method: 'POST',
|
|
1173
|
+
headers,
|
|
1174
|
+
body: JSON.stringify({ channels }),
|
|
1175
|
+
}),
|
|
1176
|
+
timeout,
|
|
1177
|
+
]);
|
|
1178
|
+
if (!res.ok)
|
|
1179
|
+
return undefined;
|
|
1180
|
+
const body = (await res.json());
|
|
1181
|
+
// Bridge sees nothing wrong — no verdict; the caller's fallback applies.
|
|
1182
|
+
if (body?.ok === true)
|
|
1183
|
+
return undefined;
|
|
1184
|
+
const reason = typeof body?.reason === 'string' && /^[a-z0-9_]+$/.test(body.reason) ? body.reason : 'refused';
|
|
1185
|
+
const side = body?.side === 'app' || body?.side === 'config' || body?.side === 'bridge' ? body.side : 'bridge';
|
|
1186
|
+
return { reason, side };
|
|
1187
|
+
}
|
|
1188
|
+
catch {
|
|
1189
|
+
return undefined;
|
|
1190
|
+
}
|
|
1191
|
+
finally {
|
|
1192
|
+
if (timer)
|
|
1193
|
+
clearTimeout(timer);
|
|
1194
|
+
}
|
|
1195
|
+
}
|
|
1196
|
+
enterUnauthorized(token, verdict, ep) {
|
|
1197
|
+
const docsUrl = `${this.cfg.docsBaseUrl}#${verdict.reason}`;
|
|
1198
|
+
this.refusal = { ...verdict, token, ref: ep.ref, docsUrl };
|
|
1199
|
+
this.episode = undefined;
|
|
1200
|
+
this.setState('unauthorized');
|
|
1201
|
+
this.startParkedTokenCheck();
|
|
1202
|
+
const message = this.formatRefusal(this.refusal);
|
|
1203
|
+
const key = `${token ?? ''}|${verdict.reason}|${verdict.side}`;
|
|
1204
|
+
if (key === this.lastRefusalKey) {
|
|
1205
|
+
this.cfg.logger.debug(message);
|
|
1206
|
+
return;
|
|
1207
|
+
}
|
|
1208
|
+
this.lastRefusalKey = key;
|
|
1209
|
+
this.cfg.logger.error(message);
|
|
1210
|
+
// Reported under the same dedupe as the log: a resume (tab refocus,
|
|
1211
|
+
// `online`) that ends in the identical refusal is not new health news.
|
|
1212
|
+
this.sendStatusReport({ state: 'unauthorized', reason: verdict.reason, side: verdict.side, ref: ep.ref });
|
|
1213
|
+
}
|
|
1214
|
+
/**
|
|
1215
|
+
* TBP-645 — fire-and-forget health report. Returns whether a report was
|
|
1216
|
+
* dispatched (false when opted out, or there is no appId to attribute it
|
|
1217
|
+
* to). Never awaited by the caller and never throws: this runs on the
|
|
1218
|
+
* connect path, and telemetry must not be able to hurt the connection. The
|
|
1219
|
+
* endpoint may not exist yet — every response, 404 included, is ignored.
|
|
1220
|
+
*/
|
|
1221
|
+
sendStatusReport(report) {
|
|
1222
|
+
const appId = this.cfg.appId;
|
|
1223
|
+
if (!this.cfg.reportStatus || !appId || typeof this.cfg.fetchFn !== 'function')
|
|
1224
|
+
return false;
|
|
1225
|
+
try {
|
|
1226
|
+
void this.postStatusReport(appId, report);
|
|
1227
|
+
}
|
|
1228
|
+
catch {
|
|
1229
|
+
// never
|
|
1230
|
+
}
|
|
1231
|
+
return true;
|
|
1232
|
+
}
|
|
1233
|
+
async postStatusReport(appId, report) {
|
|
1234
|
+
let timer;
|
|
1235
|
+
try {
|
|
1236
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1237
|
+
const AC = globalThis.AbortController;
|
|
1238
|
+
const controller = typeof AC === 'function' ? new AC() : undefined;
|
|
1239
|
+
const timeout = new Promise((resolve) => {
|
|
1240
|
+
timer = setTimeout(() => {
|
|
1241
|
+
try {
|
|
1242
|
+
controller?.abort();
|
|
1243
|
+
}
|
|
1244
|
+
catch {
|
|
1245
|
+
// ignore
|
|
1246
|
+
}
|
|
1247
|
+
resolve();
|
|
1248
|
+
}, STATUS_REPORT_TIMEOUT_MS);
|
|
1249
|
+
if (timer?.unref)
|
|
1250
|
+
timer.unref();
|
|
1251
|
+
});
|
|
1252
|
+
const body = { state: report.state };
|
|
1253
|
+
if (report.reason)
|
|
1254
|
+
body.reason = report.reason;
|
|
1255
|
+
if (report.side)
|
|
1256
|
+
body.side = report.side;
|
|
1257
|
+
if (report.ref)
|
|
1258
|
+
body.ref = report.ref;
|
|
1259
|
+
// Deferred so a synchronously-throwing fetch lands in the handler too.
|
|
1260
|
+
const request = Promise.resolve().then(() => this.cfg.fetchFn(`${this.cfg.apiBaseUrl}/account/auth/realtime-status`, {
|
|
1261
|
+
method: 'POST',
|
|
1262
|
+
headers: { 'Content-Type': 'application/json', 'x-app-id': appId },
|
|
1263
|
+
body: JSON.stringify(body),
|
|
1264
|
+
signal: controller?.signal,
|
|
1265
|
+
}));
|
|
1266
|
+
await Promise.race([request.then(noop, noop), timeout]);
|
|
1267
|
+
}
|
|
1268
|
+
catch {
|
|
1269
|
+
// swallowed — see sendStatusReport
|
|
1270
|
+
}
|
|
1271
|
+
finally {
|
|
1272
|
+
if (timer)
|
|
1273
|
+
clearTimeout(timer);
|
|
1274
|
+
}
|
|
1275
|
+
}
|
|
1276
|
+
clearRefusal() {
|
|
1277
|
+
this.refusal = undefined;
|
|
1278
|
+
if (this.parkedTokenTimer) {
|
|
1279
|
+
clearInterval(this.parkedTokenTimer);
|
|
1280
|
+
this.parkedTokenTimer = undefined;
|
|
1281
|
+
}
|
|
1282
|
+
}
|
|
1283
|
+
/**
|
|
1284
|
+
* A parked client must resume when the host's token changes — most
|
|
1285
|
+
* importantly when a signed-out session signs in (undefined → token).
|
|
1286
|
+
* Framework SDKs only call reauthorize() when one token REPLACES another
|
|
1287
|
+
* (TBP-644 fixes that), so auth-core can't rely on being told.
|
|
1288
|
+
*
|
|
1289
|
+
* Chosen over a `notifyAuthTokenChanged()` API because it needs no host
|
|
1290
|
+
* change — the missing host call is the bug. It is cheap and bounded: it
|
|
1291
|
+
* runs only while parked, calls the synchronous `getAuthToken()` getter (no
|
|
1292
|
+
* network), and resumes only on a token DIFFERENT from the refused one, so
|
|
1293
|
+
* an unchanged session can never turn it into a reconnect loop.
|
|
1294
|
+
*/
|
|
1295
|
+
startParkedTokenCheck() {
|
|
1296
|
+
if (this.parkedTokenTimer || !this.cfg.getAuthToken)
|
|
1297
|
+
return;
|
|
1298
|
+
this.parkedTokenTimer = setInterval(() => {
|
|
1299
|
+
if (this.state !== 'unauthorized' || this.stopped) {
|
|
1300
|
+
this.clearRefusal();
|
|
1301
|
+
return;
|
|
1302
|
+
}
|
|
1303
|
+
if (this.currentToken() !== this.refusal?.token)
|
|
1304
|
+
void this.start();
|
|
1305
|
+
}, PARKED_TOKEN_CHECK_MS);
|
|
1306
|
+
if (this.parkedTokenTimer?.unref)
|
|
1307
|
+
this.parkedTokenTimer.unref();
|
|
1308
|
+
}
|
|
1309
|
+
/**
|
|
1310
|
+
* The one message a developer gets per refused episode. Product terms, whose
|
|
1311
|
+
* side it is, what still works, one next step, a docs link and a ref.
|
|
1312
|
+
*/
|
|
1313
|
+
formatRefusal(r) {
|
|
1314
|
+
const stopped = ' Stopped: plan & entitlement changes, feature-flag flips, the plan-changed token refresh. They appear only after a reload.';
|
|
1315
|
+
const stillFine = ' Still fine: every API call your app makes.';
|
|
1316
|
+
const footer = ` ${r.docsUrl} · ref ${r.ref}`;
|
|
1317
|
+
if (r.reason === 'anonymous_refused') {
|
|
1318
|
+
return [
|
|
1319
|
+
`[bridge] Live updates are unavailable before sign-in — Bridge did not accept this signed-out session's realtime connection (${r.reason}).`,
|
|
1320
|
+
' They start automatically once a user signs in. Until then, feature-flag flips appear only after a reload.',
|
|
1321
|
+
stillFine,
|
|
1322
|
+
' Nothing to change in your code.',
|
|
1323
|
+
footer,
|
|
1324
|
+
].join('\n');
|
|
1325
|
+
}
|
|
1326
|
+
if (r.side === 'bridge') {
|
|
1327
|
+
return [
|
|
1328
|
+
"[bridge] Live updates are OFF — this is a problem on Bridge's side, not in your app.",
|
|
1329
|
+
` Bridge refused this session's realtime connection (${r.reason}) although the session token checks out.`,
|
|
1330
|
+
stopped,
|
|
1331
|
+
stillFine,
|
|
1332
|
+
` Nothing to change in your code — include ref ${r.ref} if you contact support.`,
|
|
1333
|
+
footer,
|
|
1334
|
+
].join('\n');
|
|
1335
|
+
}
|
|
1336
|
+
if (r.side === 'config') {
|
|
1337
|
+
const apiHost = hostOf(this.cfg.apiBaseUrl);
|
|
1338
|
+
let mismatch;
|
|
1339
|
+
let fix;
|
|
1340
|
+
if (r.reason === 'wrong_environment' && r.tokenIssuer) {
|
|
1341
|
+
const tokenHost = hostOf(r.tokenIssuer);
|
|
1342
|
+
mismatch = ` This app's Bridge API host is ${apiHost}, but the signed-in session's token was issued by ${tokenHost}.`;
|
|
1343
|
+
fix = ` Fix: point the Bridge API base URL setting (apiBaseUrl) at ${tokenHost}, or sign users in against ${apiHost} — both must be the same environment.`;
|
|
1344
|
+
}
|
|
1345
|
+
else if (r.reason === 'wrong_app' && r.tokenAppId) {
|
|
1346
|
+
mismatch = ` This app is configured with app id ${this.cfg.appId}, but the signed-in session's token belongs to app ${r.tokenAppId}.`;
|
|
1347
|
+
fix = ` Fix: set the appId setting to ${r.tokenAppId}, or sign users in through app ${this.cfg.appId} — both must name the same app.`;
|
|
1348
|
+
}
|
|
1349
|
+
else {
|
|
1350
|
+
mismatch = ` This app's Bridge settings (API host ${apiHost}${this.cfg.appId ? `, app id ${this.cfg.appId}` : ''}) don't match the signed-in session.`;
|
|
1351
|
+
fix = ` Fix: correct the Bridge setting the docs entry below names for '${r.reason}'.`;
|
|
1352
|
+
}
|
|
1353
|
+
return [
|
|
1354
|
+
`[bridge] Live updates are OFF — Bridge refused this session's realtime connection (${r.reason}): your Bridge settings don't match the session.`,
|
|
1355
|
+
mismatch,
|
|
1356
|
+
stopped,
|
|
1357
|
+
fix,
|
|
1358
|
+
footer,
|
|
1359
|
+
].join('\n');
|
|
1360
|
+
}
|
|
1361
|
+
return [
|
|
1362
|
+
`[bridge] Live updates are OFF — Bridge refused this session's realtime connection (${r.reason}).`,
|
|
1363
|
+
stopped,
|
|
1364
|
+
stillFine,
|
|
1365
|
+
` Fix: ${appFix(r.reason)}.`,
|
|
1366
|
+
footer,
|
|
1367
|
+
].join('\n');
|
|
1368
|
+
}
|
|
1369
|
+
// ── Resume triggers (browser only; guarded so Node/SSR never touches them) ──
|
|
1370
|
+
onOnline = () => this.nudge();
|
|
1371
|
+
onVisibilityChange = () => {
|
|
1372
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1373
|
+
if (globalThis.document?.visibilityState === 'visible')
|
|
1374
|
+
this.nudge();
|
|
1375
|
+
};
|
|
1376
|
+
installResumeListeners() {
|
|
1377
|
+
if (this.resumeListenersInstalled)
|
|
1378
|
+
return;
|
|
1379
|
+
this.resumeListenersInstalled = true;
|
|
1380
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1381
|
+
const g = globalThis;
|
|
1382
|
+
if (typeof g.addEventListener === 'function')
|
|
1383
|
+
g.addEventListener('online', this.onOnline);
|
|
1384
|
+
if (typeof g.document?.addEventListener === 'function') {
|
|
1385
|
+
g.document.addEventListener('visibilitychange', this.onVisibilityChange);
|
|
1386
|
+
}
|
|
1387
|
+
}
|
|
1388
|
+
removeResumeListeners() {
|
|
1389
|
+
if (!this.resumeListenersInstalled)
|
|
1390
|
+
return;
|
|
1391
|
+
this.resumeListenersInstalled = false;
|
|
1392
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1393
|
+
const g = globalThis;
|
|
1394
|
+
if (typeof g.removeEventListener === 'function')
|
|
1395
|
+
g.removeEventListener('online', this.onOnline);
|
|
1396
|
+
if (typeof g.document?.removeEventListener === 'function') {
|
|
1397
|
+
g.document.removeEventListener('visibilitychange', this.onVisibilityChange);
|
|
1398
|
+
}
|
|
1399
|
+
}
|
|
1400
|
+
/**
|
|
1401
|
+
* The network came back or the user returned to the tab: the conditions
|
|
1402
|
+
* behind a refusal may have changed (host refreshed the session, clock
|
|
1403
|
+
* caught up), so a parked client gets one new episode; a client waiting out
|
|
1404
|
+
* a backoff tries now instead.
|
|
1405
|
+
*/
|
|
1406
|
+
nudge() {
|
|
1407
|
+
if (!this.cfg.enabled || this.stopped)
|
|
1408
|
+
return;
|
|
1409
|
+
if (this.state === 'unauthorized') {
|
|
1410
|
+
this.clearRefusal();
|
|
1411
|
+
this.setState('closed');
|
|
1412
|
+
void this.start();
|
|
1413
|
+
return;
|
|
1414
|
+
}
|
|
1415
|
+
if (this.reconnectTimer) {
|
|
1416
|
+
clearTimeout(this.reconnectTimer);
|
|
1417
|
+
this.reconnectTimer = undefined;
|
|
1418
|
+
this.fireReconnect();
|
|
1419
|
+
}
|
|
1420
|
+
}
|
|
1421
|
+
}
|
|
1422
|
+
/** Reason-specific next step for app-side refusals. */
|
|
1423
|
+
function appFix(reason) {
|
|
1424
|
+
switch (reason) {
|
|
1425
|
+
case 'no_token':
|
|
1426
|
+
return "start live updates after sign-in, or pass getAuthToken so the client can read the session's access token";
|
|
1427
|
+
case 'expired':
|
|
1428
|
+
return 'the access token expired and was not refreshed — pass refreshAuthToken to the realtime client (the framework SDKs do this for you), or refresh the session and call reauthorize()';
|
|
1429
|
+
case 'malformed':
|
|
1430
|
+
return 'getAuthToken must return the Bridge access token (a JWT) — not an ID token, an API key or another string';
|
|
1431
|
+
default:
|
|
1432
|
+
return `see the docs entry below for what '${reason}' means for this session`;
|
|
1433
|
+
}
|
|
797
1434
|
}
|
|
798
1435
|
function parseMessage(raw) {
|
|
799
1436
|
if (typeof raw !== 'string')
|
|
@@ -825,14 +1462,18 @@ function parseMessage(raw) {
|
|
|
825
1462
|
* months. Anything is better than nothing here, so fall back to the raw frame.
|
|
826
1463
|
*/
|
|
827
1464
|
function describeAppSyncError(frame) {
|
|
828
|
-
const {
|
|
829
|
-
|
|
1465
|
+
const { message } = frame;
|
|
1466
|
+
const errors = appSyncErrors(frame);
|
|
1467
|
+
if (errors.length > 0) {
|
|
830
1468
|
const parts = errors
|
|
831
1469
|
.map((e) => {
|
|
832
1470
|
if (typeof e === 'string')
|
|
833
1471
|
return e;
|
|
834
1472
|
const m = e?.message;
|
|
835
|
-
|
|
1473
|
+
if (typeof m === 'string')
|
|
1474
|
+
return m;
|
|
1475
|
+
const t = e?.errorType;
|
|
1476
|
+
return typeof t === 'string' ? t : undefined;
|
|
836
1477
|
})
|
|
837
1478
|
.filter((m) => !!m);
|
|
838
1479
|
if (parts.length > 0)
|
|
@@ -847,6 +1488,73 @@ function describeAppSyncError(frame) {
|
|
|
847
1488
|
return 'no error detail supplied by the server';
|
|
848
1489
|
}
|
|
849
1490
|
}
|
|
1491
|
+
/** AppSync puts `errors` at the top level or under `payload` — accept both. */
|
|
1492
|
+
function appSyncErrors(frame) {
|
|
1493
|
+
if (Array.isArray(frame.errors))
|
|
1494
|
+
return frame.errors;
|
|
1495
|
+
const nested = frame.payload?.errors;
|
|
1496
|
+
return Array.isArray(nested) ? nested : [];
|
|
1497
|
+
}
|
|
1498
|
+
/**
|
|
1499
|
+
* TBP-643 — is this error frame an auth refusal? AppSync Events does NOT relay
|
|
1500
|
+
* the authorizer's reason and often sends no message text at all, so match on
|
|
1501
|
+
* errorType / errorCode only, never on wording.
|
|
1502
|
+
*/
|
|
1503
|
+
function isAppSyncAuthRefusal(frame) {
|
|
1504
|
+
return appSyncErrors(frame).some((e) => {
|
|
1505
|
+
if (!e || typeof e !== 'object')
|
|
1506
|
+
return false;
|
|
1507
|
+
const { errorType, errorCode } = e;
|
|
1508
|
+
if (typeof errorType === 'string' && /^unauthori[sz]ed/i.test(errorType))
|
|
1509
|
+
return true;
|
|
1510
|
+
const code = typeof errorCode === 'string' ? Number(errorCode) : errorCode;
|
|
1511
|
+
return code === 401 || code === 403;
|
|
1512
|
+
});
|
|
1513
|
+
}
|
|
1514
|
+
/** Decode a JWT payload WITHOUT verifying it. undefined = not a JWT. */
|
|
1515
|
+
function decodeJwtPayload(token) {
|
|
1516
|
+
const parts = token.split('.');
|
|
1517
|
+
if (parts.length !== 3 || !parts[1])
|
|
1518
|
+
return undefined;
|
|
1519
|
+
try {
|
|
1520
|
+
const b64 = parts[1].replace(/-/g, '+').replace(/_/g, '/');
|
|
1521
|
+
const padded = b64 + '==='.slice((b64.length + 3) % 4);
|
|
1522
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1523
|
+
const g = globalThis;
|
|
1524
|
+
const json = typeof g.atob === 'function'
|
|
1525
|
+
? decodeURIComponent(escape(g.atob(padded)))
|
|
1526
|
+
: g.Buffer.from(padded, 'base64').toString('utf-8');
|
|
1527
|
+
const claims = JSON.parse(json);
|
|
1528
|
+
return claims && typeof claims === 'object' && !Array.isArray(claims) ? claims : undefined;
|
|
1529
|
+
}
|
|
1530
|
+
catch {
|
|
1531
|
+
return undefined;
|
|
1532
|
+
}
|
|
1533
|
+
}
|
|
1534
|
+
function hostOf(url) {
|
|
1535
|
+
try {
|
|
1536
|
+
return new URL(url).host;
|
|
1537
|
+
}
|
|
1538
|
+
catch {
|
|
1539
|
+
return url;
|
|
1540
|
+
}
|
|
1541
|
+
}
|
|
1542
|
+
function noop() {
|
|
1543
|
+
// intentionally empty
|
|
1544
|
+
}
|
|
1545
|
+
function errorText(err) {
|
|
1546
|
+
return err instanceof Error ? err.message : String(err);
|
|
1547
|
+
}
|
|
1548
|
+
/** Short per-episode correlation id — quoted in logs, sent to Bridge on diagnose. */
|
|
1549
|
+
function newRef() {
|
|
1550
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1551
|
+
const g = globalThis;
|
|
1552
|
+
if (typeof g.crypto?.getRandomValues === 'function') {
|
|
1553
|
+
const bytes = g.crypto.getRandomValues(new Uint8Array(4));
|
|
1554
|
+
return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
|
|
1555
|
+
}
|
|
1556
|
+
return Math.random().toString(16).slice(2, 10).padEnd(8, '0');
|
|
1557
|
+
}
|
|
850
1558
|
/** Subprotocol identifier for AppSync Events realtime channels. */
|
|
851
1559
|
const APPSYNC_WS_PROTOCOL = 'aws-appsync-event-ws';
|
|
852
1560
|
/**
|
|
@@ -888,12 +1596,20 @@ function normalizeAppSyncEndpoint(endpoint) {
|
|
|
888
1596
|
}
|
|
889
1597
|
/**
|
|
890
1598
|
* Build the AppSync Events auth header carried in the `header-…` subprotocol
|
|
891
|
-
* token
|
|
892
|
-
*
|
|
1599
|
+
* token — and in every subscribe frame's `authorization`. This is the ONLY
|
|
1600
|
+
* place the anonymous value is decided.
|
|
1601
|
+
*
|
|
1602
|
+
* Anonymous sessions send `Authorization: REALTIME_ANONYMOUS_TOKEN`, never ''.
|
|
1603
|
+
* TBP-643 — AppSync rejects an empty Authorization itself, BEFORE the Lambda
|
|
1604
|
+
* authorizer runs (`connection_error` / UnauthorizedException 401, no
|
|
1605
|
+
* message), so with '' the authorizer's anonymous-CONNECT branch was
|
|
1606
|
+
* unreachable and no signed-out session could ever connect. The authorizer
|
|
1607
|
+
* treats exactly this marker as "no token" (app channels origin-checked,
|
|
1608
|
+
* everything else denied `no_token`).
|
|
893
1609
|
*/
|
|
894
1610
|
function buildAppSyncAuthHeader(token, host) {
|
|
895
1611
|
return {
|
|
896
|
-
Authorization: token ? `Bearer ${token}` :
|
|
1612
|
+
Authorization: token ? `Bearer ${token}` : REALTIME_ANONYMOUS_TOKEN,
|
|
897
1613
|
host,
|
|
898
1614
|
};
|
|
899
1615
|
}
|