@nebulr-group/bridge-svelte 0.7.1 → 0.8.0-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/client/BridgeBootstrap.svelte +14 -0
- package/dist/client/components/developer/RealtimeDevBadge.svelte +216 -0
- package/dist/client/components/developer/RealtimeDevBadge.svelte.d.ts +6 -0
- package/dist/core/bridge-runtime.d.ts +9 -1
- package/dist/core/bridge-runtime.js +107 -23
- package/dist/core/bridge-runtime.test.js +243 -4
- package/dist/core/bridge.d.ts +2 -1
- package/dist/core/bridge.test.js +77 -1
- package/dist/core/realtime-dev-badge.d.ts +29 -0
- package/dist/core/realtime-dev-badge.js +66 -0
- package/dist/core/realtime-dev-badge.test.d.ts +1 -0
- package/dist/core/realtime-dev-badge.test.js +145 -0
- package/dist/core/realtime-status.d.ts +16 -2
- package/dist/core/realtime-status.js +23 -1
- package/dist/core/snapshot-stores.d.ts +29 -0
- package/dist/core/snapshot-stores.js +37 -0
- package/dist/flags/index.d.ts +3 -2
- package/dist/flags/index.js +3 -1
- package/dist/flags/realtime-status.d.ts +1 -1
- package/dist/flags/realtime-status.js +1 -1
- package/dist/index.d.ts +4 -0
- package/dist/index.js +8 -0
- package/dist/shared/types/config.d.ts +6 -0
- package/package.json +3 -3
|
@@ -20,6 +20,18 @@
|
|
|
20
20
|
stopBridgeRuntime,
|
|
21
21
|
type StartBridgeRuntimeOptions,
|
|
22
22
|
} from '../core/bridge-runtime.js';
|
|
23
|
+
import RealtimeDevBadge from './components/developer/RealtimeDevBadge.svelte';
|
|
24
|
+
|
|
25
|
+
// TBP-644 — the "Live updates off — why?" badge is mounted here so every app
|
|
26
|
+
// gets it without code changes. It renders in development builds only;
|
|
27
|
+
// `devBadge: false` in the config turns it off there too.
|
|
28
|
+
const devBadgeEnabled = (() => {
|
|
29
|
+
try {
|
|
30
|
+
return getConfig().devBadge !== false;
|
|
31
|
+
} catch {
|
|
32
|
+
return true;
|
|
33
|
+
}
|
|
34
|
+
})();
|
|
23
35
|
|
|
24
36
|
// Props: optional `runtime` overrides for advanced/debug use (websocketFactory,
|
|
25
37
|
// reconnect overrides, etc.); `onBootstrapComplete` callback fires after the
|
|
@@ -194,3 +206,5 @@
|
|
|
194
206
|
await handleRoute(to.url.pathname, cancel, to.url.search);
|
|
195
207
|
});
|
|
196
208
|
</script>
|
|
209
|
+
|
|
210
|
+
<RealtimeDevBadge enabled={devBadgeEnabled} />
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
TBP-644 — development-only "Live updates off — why?" corner badge.
|
|
3
|
+
|
|
4
|
+
<BridgeBootstrap /> mounts this automatically. It renders only in
|
|
5
|
+
development builds (`dev` from `$app/environment`, which SvelteKit resolves
|
|
6
|
+
to a constant `false` in production builds) and only while live updates are
|
|
7
|
+
actually off: refused (`unauthorized`), connected but deaf (`degraded`), or
|
|
8
|
+
retrying for longer than 30 s. Opt out with `devBadge: false` in
|
|
9
|
+
`bridgeConfig.initConfig({...})`.
|
|
10
|
+
|
|
11
|
+
Styling is self-contained — every element resets with `all: unset` — so it
|
|
12
|
+
neither depends on nor inherits from the host app's CSS.
|
|
13
|
+
-->
|
|
14
|
+
<script lang="ts">
|
|
15
|
+
import { dev } from '$app/environment';
|
|
16
|
+
import { realtimeStatusDetail } from '../../../core/realtime-status.js';
|
|
17
|
+
import {
|
|
18
|
+
REALTIME_BADGE_RETRYING_AFTER_MS,
|
|
19
|
+
createRetryClock,
|
|
20
|
+
realtimeBadgeView,
|
|
21
|
+
} from '../../../core/realtime-dev-badge.js';
|
|
22
|
+
|
|
23
|
+
let { enabled = true }: { enabled?: boolean } = $props();
|
|
24
|
+
|
|
25
|
+
const PANEL_ID = 'bridge-realtime-dev-badge-panel';
|
|
26
|
+
const retryClock = createRetryClock();
|
|
27
|
+
|
|
28
|
+
let now = $state(Date.now());
|
|
29
|
+
let expanded = $state(false);
|
|
30
|
+
let dismissedKey = $state<string | undefined>(undefined);
|
|
31
|
+
let toggleEl = $state<HTMLButtonElement | undefined>(undefined);
|
|
32
|
+
|
|
33
|
+
const status = $derived($realtimeStatusDetail);
|
|
34
|
+
// Start of the current retry run — stable across the closed/connecting
|
|
35
|
+
// flips inside one episode (see createRetryClock).
|
|
36
|
+
const retryingSince = $derived(retryClock(status, Date.now()));
|
|
37
|
+
|
|
38
|
+
// Tick `now` once the retrying threshold passes — nothing else re-renders.
|
|
39
|
+
$effect(() => {
|
|
40
|
+
if (retryingSince === undefined) return;
|
|
41
|
+
const remaining = retryingSince + REALTIME_BADGE_RETRYING_AFTER_MS - Date.now();
|
|
42
|
+
const timer = setTimeout(() => (now = Date.now()), Math.max(0, remaining) + 50);
|
|
43
|
+
return () => clearTimeout(timer);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
const view = $derived(realtimeBadgeView(status, retryingSince, now));
|
|
47
|
+
const visible = $derived(!!view && view.key !== dismissedKey);
|
|
48
|
+
|
|
49
|
+
function dismiss() {
|
|
50
|
+
dismissedKey = view?.key;
|
|
51
|
+
expanded = false;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function onKeydown(event: KeyboardEvent) {
|
|
55
|
+
if (event.key === 'Escape' && expanded) {
|
|
56
|
+
expanded = false;
|
|
57
|
+
toggleEl?.focus();
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
</script>
|
|
61
|
+
|
|
62
|
+
{#if dev && enabled}
|
|
63
|
+
<div class="bridge-rt-root" data-testid="bridge-realtime-dev-badge-root">
|
|
64
|
+
<span class="bridge-rt-sr" role="status" aria-live="polite"
|
|
65
|
+
>{visible && view ? `Bridge live updates are off: ${view.reason}` : ''}</span
|
|
66
|
+
>
|
|
67
|
+
{#if visible && view}
|
|
68
|
+
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
|
69
|
+
<aside
|
|
70
|
+
class="bridge-rt-badge"
|
|
71
|
+
aria-label="Bridge live updates (development only)"
|
|
72
|
+
data-testid="bridge-realtime-dev-badge"
|
|
73
|
+
onkeydown={onKeydown}
|
|
74
|
+
>
|
|
75
|
+
<div class="bridge-rt-bar">
|
|
76
|
+
<button
|
|
77
|
+
bind:this={toggleEl}
|
|
78
|
+
type="button"
|
|
79
|
+
class="bridge-rt-toggle"
|
|
80
|
+
aria-expanded={expanded}
|
|
81
|
+
aria-controls={PANEL_ID}
|
|
82
|
+
onclick={() => (expanded = !expanded)}
|
|
83
|
+
>
|
|
84
|
+
<span class="bridge-rt-dot" aria-hidden="true">●</span> Live updates off — why?
|
|
85
|
+
</button>
|
|
86
|
+
<button
|
|
87
|
+
type="button"
|
|
88
|
+
class="bridge-rt-close"
|
|
89
|
+
aria-label="Dismiss the live updates notice"
|
|
90
|
+
onclick={dismiss}>×</button
|
|
91
|
+
>
|
|
92
|
+
</div>
|
|
93
|
+
{#if expanded}
|
|
94
|
+
<div id={PANEL_ID} class="bridge-rt-panel">
|
|
95
|
+
<div class="bridge-rt-row">
|
|
96
|
+
<span class="bridge-rt-label">Reason</span>
|
|
97
|
+
<code class="bridge-rt-code">{view.reason}</code>
|
|
98
|
+
</div>
|
|
99
|
+
<div class="bridge-rt-row">
|
|
100
|
+
<span class="bridge-rt-label">Whose side</span>
|
|
101
|
+
<span class="bridge-rt-value">{view.sideLabel}</span>
|
|
102
|
+
</div>
|
|
103
|
+
{#if view.ref}
|
|
104
|
+
<div class="bridge-rt-row">
|
|
105
|
+
<span class="bridge-rt-label">Ref</span>
|
|
106
|
+
<code class="bridge-rt-code">{view.ref}</code>
|
|
107
|
+
</div>
|
|
108
|
+
{/if}
|
|
109
|
+
{#if view.docsUrl}
|
|
110
|
+
<a class="bridge-rt-link" href={view.docsUrl} target="_blank" rel="noopener noreferrer"
|
|
111
|
+
>How to fix this ↗</a
|
|
112
|
+
>
|
|
113
|
+
{/if}
|
|
114
|
+
<span class="bridge-rt-note">Development builds only — never shown to your users.</span>
|
|
115
|
+
</div>
|
|
116
|
+
{/if}
|
|
117
|
+
</aside>
|
|
118
|
+
{/if}
|
|
119
|
+
</div>
|
|
120
|
+
{/if}
|
|
121
|
+
|
|
122
|
+
<style>
|
|
123
|
+
.bridge-rt-root,
|
|
124
|
+
.bridge-rt-root * {
|
|
125
|
+
all: unset;
|
|
126
|
+
box-sizing: border-box;
|
|
127
|
+
}
|
|
128
|
+
.bridge-rt-root {
|
|
129
|
+
position: fixed;
|
|
130
|
+
right: 16px;
|
|
131
|
+
bottom: 16px;
|
|
132
|
+
z-index: 2147483000;
|
|
133
|
+
display: block;
|
|
134
|
+
font-family: ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
|
|
135
|
+
font-size: 12px;
|
|
136
|
+
line-height: 1.4;
|
|
137
|
+
color: #f5f5f5;
|
|
138
|
+
}
|
|
139
|
+
.bridge-rt-sr {
|
|
140
|
+
position: absolute;
|
|
141
|
+
width: 1px;
|
|
142
|
+
height: 1px;
|
|
143
|
+
overflow: hidden;
|
|
144
|
+
clip: rect(0 0 0 0);
|
|
145
|
+
white-space: nowrap;
|
|
146
|
+
}
|
|
147
|
+
.bridge-rt-badge {
|
|
148
|
+
display: block;
|
|
149
|
+
max-width: min(360px, calc(100vw - 32px));
|
|
150
|
+
background: #1f2328;
|
|
151
|
+
border: 1px solid #3d444d;
|
|
152
|
+
border-radius: 8px;
|
|
153
|
+
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.25);
|
|
154
|
+
}
|
|
155
|
+
.bridge-rt-bar {
|
|
156
|
+
display: flex;
|
|
157
|
+
align-items: center;
|
|
158
|
+
}
|
|
159
|
+
.bridge-rt-toggle,
|
|
160
|
+
.bridge-rt-close {
|
|
161
|
+
cursor: pointer;
|
|
162
|
+
padding: 6px 10px;
|
|
163
|
+
border-radius: 8px;
|
|
164
|
+
}
|
|
165
|
+
.bridge-rt-toggle {
|
|
166
|
+
flex: 1;
|
|
167
|
+
}
|
|
168
|
+
.bridge-rt-close {
|
|
169
|
+
font-size: 14px;
|
|
170
|
+
line-height: 1;
|
|
171
|
+
color: #b0b8c1;
|
|
172
|
+
}
|
|
173
|
+
.bridge-rt-toggle:focus-visible,
|
|
174
|
+
.bridge-rt-close:focus-visible,
|
|
175
|
+
.bridge-rt-link:focus-visible {
|
|
176
|
+
outline: 2px solid #58a6ff;
|
|
177
|
+
outline-offset: 1px;
|
|
178
|
+
}
|
|
179
|
+
.bridge-rt-dot {
|
|
180
|
+
color: #f85149;
|
|
181
|
+
}
|
|
182
|
+
.bridge-rt-panel {
|
|
183
|
+
display: block;
|
|
184
|
+
padding: 4px 10px 10px;
|
|
185
|
+
border-top: 1px solid #3d444d;
|
|
186
|
+
}
|
|
187
|
+
.bridge-rt-row {
|
|
188
|
+
display: flex;
|
|
189
|
+
gap: 8px;
|
|
190
|
+
padding-top: 6px;
|
|
191
|
+
}
|
|
192
|
+
.bridge-rt-label {
|
|
193
|
+
flex: 0 0 72px;
|
|
194
|
+
color: #9198a1;
|
|
195
|
+
}
|
|
196
|
+
.bridge-rt-value {
|
|
197
|
+
flex: 1;
|
|
198
|
+
}
|
|
199
|
+
.bridge-rt-code {
|
|
200
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
201
|
+
overflow-wrap: anywhere;
|
|
202
|
+
}
|
|
203
|
+
.bridge-rt-link {
|
|
204
|
+
display: inline-block;
|
|
205
|
+
margin-top: 8px;
|
|
206
|
+
color: #58a6ff;
|
|
207
|
+
text-decoration: underline;
|
|
208
|
+
cursor: pointer;
|
|
209
|
+
}
|
|
210
|
+
.bridge-rt-note {
|
|
211
|
+
display: block;
|
|
212
|
+
margin-top: 8px;
|
|
213
|
+
color: #9198a1;
|
|
214
|
+
font-size: 11px;
|
|
215
|
+
}
|
|
216
|
+
</style>
|
|
@@ -41,7 +41,7 @@
|
|
|
41
41
|
* instance. Call `stopBridgeRuntime()` (e.g. on `<BridgeBootstrap />` destroy)
|
|
42
42
|
* to flush the realtime client and unsubscribe from the token store.
|
|
43
43
|
*/
|
|
44
|
-
import { RealtimeClient, type FlagChange, type RealtimeClientConfig, type SessionSnapshotMessage } from '@nebulr-group/bridge-auth-core';
|
|
44
|
+
import { RealtimeClient, type FlagChange, type RealtimeClientConfig, type RealtimeStatus, type SessionSnapshotMessage } from '@nebulr-group/bridge-auth-core';
|
|
45
45
|
/**
|
|
46
46
|
* Advanced runtime overrides. Product consumers never pass these; tests,
|
|
47
47
|
* Storybook harnesses, and the demo workspace use them to override the
|
|
@@ -105,6 +105,14 @@ export declare function onBridgeRealtimeClose(handler: () => void): () => void;
|
|
|
105
105
|
export declare function onBridgeFlagChange(handler: (change: FlagChange) => void): () => void;
|
|
106
106
|
/** Subscribe to `session.snapshot` messages. Returns an unsubscribe fn. */
|
|
107
107
|
export declare function onBridgeRealtimeSnapshot(handler: (msg: SessionSnapshotMessage) => void): () => void;
|
|
108
|
+
/**
|
|
109
|
+
* Subscribe to realtime status changes (TBP-644): state, the machine-readable
|
|
110
|
+
* reason, whose side a fault is on (`app` / `config` / `bridge` / `network`),
|
|
111
|
+
* whether the client is still retrying, a docs link and a support ref.
|
|
112
|
+
* Fires on every change, not with the current value — read
|
|
113
|
+
* `realtimeStatusDetail` for that. Returns an unsubscribe fn.
|
|
114
|
+
*/
|
|
115
|
+
export declare function onBridgeRealtimeStatus(handler: (status: RealtimeStatus) => void): () => void;
|
|
108
116
|
/**
|
|
109
117
|
* Subscribe to server-side `user.state_changed` signals (fired before the
|
|
110
118
|
* runtime triggers a token refresh). Useful for debug overlays + tests.
|
|
@@ -45,9 +45,9 @@ import { RealtimeClient, useBridge, } from '@nebulr-group/bridge-auth-core';
|
|
|
45
45
|
import { getConfig } from '../client/stores/config.store.js';
|
|
46
46
|
import { getBridgeAuth, tokenStore } from './bridge-instance.js';
|
|
47
47
|
import { wrapFetchWithBridgeAuth } from './bridge-fetch.js';
|
|
48
|
-
import { applySessionSnapshot } from './snapshot-stores.js';
|
|
48
|
+
import { applyEntitlementsChanged, applySessionSnapshot, applySubscriptionPlanChanged, } from './snapshot-stores.js';
|
|
49
49
|
import { bridgeEvents } from './events.js';
|
|
50
|
-
import { _setRealtimeStatus } from './realtime-status.js';
|
|
50
|
+
import { _setRealtimeStatus, _setRealtimeStatusDetail } from './realtime-status.js';
|
|
51
51
|
let _realtime;
|
|
52
52
|
let _unsubscribeAuth;
|
|
53
53
|
let _currentAuthToken;
|
|
@@ -59,6 +59,8 @@ const _onSnapshotSubs = new Set();
|
|
|
59
59
|
// invalidate its (separate) cache and re-evaluate the current route.
|
|
60
60
|
const _onFlagChangeSubs = new Set();
|
|
61
61
|
const _onUserStateSubs = new Set();
|
|
62
|
+
// TBP-644 — full realtime status (state + reason + whose side + retrying).
|
|
63
|
+
const _onStatusSubs = new Set();
|
|
62
64
|
/**
|
|
63
65
|
* Start the Bridge runtime. Idempotent — repeated calls are a no-op. Reads
|
|
64
66
|
* `appId` + `apiBaseUrl` from the auth config store (`bridgeConfig.initConfig`).
|
|
@@ -105,6 +107,25 @@ export function startBridgeRuntime(options = {}) {
|
|
|
105
107
|
apiKey: config.appId,
|
|
106
108
|
appId: _bootstrapAppId,
|
|
107
109
|
getAuthToken: () => _currentAuthToken,
|
|
110
|
+
// TBP-644 — a refused connection gets ONE session refresh per episode
|
|
111
|
+
// (auth-core enforces the once) and reconnects with the new token instead
|
|
112
|
+
// of parking. A signed-out session has nothing to refresh. Loop safety:
|
|
113
|
+
// the refreshed token lands in the tokenStore subscription below, whose
|
|
114
|
+
// reauthorize() is a no-op while that episode is still connecting, and
|
|
115
|
+
// the reconnect it produces is flagged self-induced so setOnOpen does not
|
|
116
|
+
// refresh a second time.
|
|
117
|
+
refreshAuthToken: options.realtime?.refreshAuthToken ??
|
|
118
|
+
(async () => {
|
|
119
|
+
if (!_currentAuthToken)
|
|
120
|
+
return undefined;
|
|
121
|
+
try {
|
|
122
|
+
const tokens = await getBridgeAuth().refreshTokens();
|
|
123
|
+
return tokens?.accessToken ?? undefined;
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
return undefined;
|
|
127
|
+
}
|
|
128
|
+
}),
|
|
108
129
|
});
|
|
109
130
|
let _connectedOnce = false;
|
|
110
131
|
// Set just before we call _realtime.reauthorize() so the resulting
|
|
@@ -159,6 +180,23 @@ export function startBridgeRuntime(options = {}) {
|
|
|
159
180
|
_realtime.setOnDegraded?.(() => {
|
|
160
181
|
_setRealtimeStatus('degraded');
|
|
161
182
|
});
|
|
183
|
+
// TBP-644 — the full status: why the connection is not working, whose side
|
|
184
|
+
// the fault is on, and whether it is still retrying. Guarded for the same
|
|
185
|
+
// reason as setOnDegraded (an older auth-core has no such hook; the
|
|
186
|
+
// open/close/degraded mirrors above keep `realtimeStatus` working there).
|
|
187
|
+
_realtime.setOnStatusChange?.((status) => {
|
|
188
|
+
_setRealtimeStatusDetail(status);
|
|
189
|
+
// A parked client never opens, so a reauthorize that ended in a refusal
|
|
190
|
+
// must not leave the self-induced flag set for the next genuine reconnect.
|
|
191
|
+
if (status.state === 'unauthorized')
|
|
192
|
+
_reauthInFlight = false;
|
|
193
|
+
for (const fn of _onStatusSubs) {
|
|
194
|
+
try {
|
|
195
|
+
fn(status);
|
|
196
|
+
}
|
|
197
|
+
catch { /* subscriber errors swallowed */ }
|
|
198
|
+
}
|
|
199
|
+
});
|
|
162
200
|
// TBP-575 — a flag changed on the wire. Two caches need to hear about it and
|
|
163
201
|
// only one of them was ever told:
|
|
164
202
|
// - BridgeFlags (FF 2.0) — driven by realtime already, via attach()
|
|
@@ -209,8 +247,21 @@ export function startBridgeRuntime(options = {}) {
|
|
|
209
247
|
useBridge().attachToRealtimeClient(_realtime);
|
|
210
248
|
// Phase 5 (TBP-331) + TBP-360 — billing-family events flow through the
|
|
211
249
|
// unified bridge events surface via `useBridge().handle({...})`.
|
|
250
|
+
//
|
|
251
|
+
// TBP-644 — the two pushes that carry the complete new value also move the
|
|
252
|
+
// `bridge.tenant.*` stores, which were otherwise written only by
|
|
253
|
+
// `session.snapshot`. A plan change never re-sends a snapshot, so without
|
|
254
|
+
// this an upgraded app kept rendering the old plan until a reload. The store
|
|
255
|
+
// is patched BEFORE dispatch so a `bridge.events` handler that reads
|
|
256
|
+
// `bridge.tenant.subscription` already sees the new plan.
|
|
212
257
|
useBridge().handle({
|
|
213
|
-
'subscription.plan_changed': (msg) =>
|
|
258
|
+
'subscription.plan_changed': (msg) => {
|
|
259
|
+
try {
|
|
260
|
+
applySubscriptionPlanChanged(msg);
|
|
261
|
+
}
|
|
262
|
+
catch { /* store updates shouldn't throw, defensive */ }
|
|
263
|
+
bridgeEvents._dispatch(msg);
|
|
264
|
+
},
|
|
214
265
|
'payment.failed': (msg) => bridgeEvents._dispatch(msg),
|
|
215
266
|
'payment.succeeded': (msg) => bridgeEvents._dispatch(msg),
|
|
216
267
|
'subscription.created': (msg) => bridgeEvents._dispatch(msg),
|
|
@@ -226,15 +277,37 @@ export function startBridgeRuntime(options = {}) {
|
|
|
226
277
|
'dunning.recovered': (msg) => bridgeEvents._dispatch(msg),
|
|
227
278
|
'dunning.exhausted': (msg) => bridgeEvents._dispatch(msg),
|
|
228
279
|
'quota.updated': (msg) => bridgeEvents._dispatch(msg),
|
|
229
|
-
'entitlements.changed': (msg) =>
|
|
280
|
+
'entitlements.changed': (msg) => {
|
|
281
|
+
// Only the payload-carrying variant has a map; the signal-only one is a no-op here.
|
|
282
|
+
try {
|
|
283
|
+
applyEntitlementsChanged(msg);
|
|
284
|
+
}
|
|
285
|
+
catch { /* defensive */ }
|
|
286
|
+
bridgeEvents._dispatch(msg);
|
|
287
|
+
},
|
|
230
288
|
});
|
|
231
289
|
// Token store subscription — owns realtime channel scoping + quotas HTTP
|
|
232
290
|
// options + reauthorize on token-only refresh. Capability-specific subs
|
|
233
291
|
// (e.g. flag eval context) are layered on top by their own bootstrappers.
|
|
234
292
|
const apiBaseUrl = config.apiBaseUrl ?? 'https://api.thebridge.dev';
|
|
293
|
+
// TBP-644 — the realtime connection must be re-authorized whenever the
|
|
294
|
+
// token VALUE changes: rotation (A → B), but also first sign-in
|
|
295
|
+
// (none → A) and sign-out (A → none). Keying this on rotation only meant a
|
|
296
|
+
// session that signed in after page load kept the anonymous connection —
|
|
297
|
+
// or stayed parked after a refusal — until something else reconnected it.
|
|
298
|
+
// Flagged self-induced so setOnOpen skips its catch-up refresh (see the
|
|
299
|
+
// loop note there): the token we reconnect with is already current.
|
|
300
|
+
const reauthorizeForTokenChange = () => {
|
|
301
|
+
_reauthInFlight = true;
|
|
302
|
+
void _realtime.reauthorize();
|
|
303
|
+
};
|
|
304
|
+
// `subscribe` emits the current value synchronously, before `start()` below.
|
|
305
|
+
// That first emission is not a change: start() connects with it anyway.
|
|
306
|
+
let _tokenSubscriptionLive = false;
|
|
235
307
|
_unsubscribeAuth = tokenStore.subscribe((tokens) => {
|
|
236
308
|
const prevAuthToken = _currentAuthToken;
|
|
237
|
-
_currentAuthToken = tokens?.accessToken;
|
|
309
|
+
_currentAuthToken = tokens?.accessToken ?? undefined;
|
|
310
|
+
const tokenChanged = _tokenSubscriptionLive && prevAuthToken !== _currentAuthToken;
|
|
238
311
|
// Quota store hydrate requests carry the current access token; configure
|
|
239
312
|
// here so post-login state is picked up. Best-effort: a missing
|
|
240
313
|
// BridgeAuth means SDK isn't fully initialized yet — skip.
|
|
@@ -254,29 +327,28 @@ export function startBridgeRuntime(options = {}) {
|
|
|
254
327
|
// its anonymous app-id auth.
|
|
255
328
|
_realtime.setUserId(undefined);
|
|
256
329
|
_realtime.setWorkspaceId(undefined);
|
|
330
|
+
// Reconnect as the signed-out session now, rather than riding the old
|
|
331
|
+
// user's socket until something else drops it.
|
|
332
|
+
if (tokenChanged)
|
|
333
|
+
reauthorizeForTokenChange();
|
|
257
334
|
return;
|
|
258
335
|
}
|
|
259
336
|
const claims = decodeJwtPayload(tokens.accessToken);
|
|
260
|
-
if (
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
_realtime.setUserId(typeof claims.sub === 'string' ? claims.sub : undefined);
|
|
268
|
-
// Token-only refresh (same user, new JWT): setUserId is a no-op when
|
|
269
|
-
// userId hasn't changed, leaving the channel riding the OLD JWT until
|
|
270
|
-
// Centrifugo's connection-token TTL drops it. Force a reauthorize so the
|
|
271
|
-
// server re-validates against the new token immediately.
|
|
272
|
-
if (prevAuthToken && _currentAuthToken && prevAuthToken !== _currentAuthToken) {
|
|
273
|
-
// Flag this as a self-induced reconnect so setOnOpen does NOT fire its
|
|
274
|
-
// proactive refresh (which would mint a new token → land back here →
|
|
275
|
-
// loop forever). The token we're reauthorizing with is already current.
|
|
276
|
-
_reauthInFlight = true;
|
|
277
|
-
void _realtime.reauthorize();
|
|
337
|
+
if (claims) {
|
|
338
|
+
// Wire all three SDK channel scopes from the JWT. `aid` → app, `tid` →
|
|
339
|
+
// workspace, `sub` → user. Each setter is idempotent; only changes
|
|
340
|
+
// trigger a reconnect.
|
|
341
|
+
_realtime.setAppId(typeof claims.aid === 'string' ? claims.aid : undefined);
|
|
342
|
+
_realtime.setWorkspaceId(typeof claims.tid === 'string' ? claims.tid : undefined);
|
|
343
|
+
_realtime.setUserId(typeof claims.sub === 'string' ? claims.sub : undefined);
|
|
278
344
|
}
|
|
345
|
+
// setUserId is a no-op when the user is unchanged (token-only refresh),
|
|
346
|
+
// and a setter-driven reconnect waits out a backoff and cannot lift a
|
|
347
|
+
// parked refusal — so reauthorize explicitly on every value change.
|
|
348
|
+
if (tokenChanged)
|
|
349
|
+
reauthorizeForTokenChange();
|
|
279
350
|
});
|
|
351
|
+
_tokenSubscriptionLive = true;
|
|
280
352
|
// Best-effort start. RealtimeClient gracefully no-ops if the workspace's
|
|
281
353
|
// `/realtime/config` returns `kind: 'noop'`.
|
|
282
354
|
void _realtime.start();
|
|
@@ -346,6 +418,17 @@ export function onBridgeRealtimeSnapshot(handler) {
|
|
|
346
418
|
_onSnapshotSubs.add(handler);
|
|
347
419
|
return () => _onSnapshotSubs.delete(handler);
|
|
348
420
|
}
|
|
421
|
+
/**
|
|
422
|
+
* Subscribe to realtime status changes (TBP-644): state, the machine-readable
|
|
423
|
+
* reason, whose side a fault is on (`app` / `config` / `bridge` / `network`),
|
|
424
|
+
* whether the client is still retrying, a docs link and a support ref.
|
|
425
|
+
* Fires on every change, not with the current value — read
|
|
426
|
+
* `realtimeStatusDetail` for that. Returns an unsubscribe fn.
|
|
427
|
+
*/
|
|
428
|
+
export function onBridgeRealtimeStatus(handler) {
|
|
429
|
+
_onStatusSubs.add(handler);
|
|
430
|
+
return () => _onStatusSubs.delete(handler);
|
|
431
|
+
}
|
|
349
432
|
/**
|
|
350
433
|
* Subscribe to server-side `user.state_changed` signals (fired before the
|
|
351
434
|
* runtime triggers a token refresh). Useful for debug overlays + tests.
|
|
@@ -364,6 +447,7 @@ export function __resetBridgeRuntime() {
|
|
|
364
447
|
_onSnapshotSubs.clear();
|
|
365
448
|
_onFlagChangeSubs.clear();
|
|
366
449
|
_onUserStateSubs.clear();
|
|
450
|
+
_onStatusSubs.clear();
|
|
367
451
|
_currentAuthToken = undefined;
|
|
368
452
|
if (_unsubscribeAuth) {
|
|
369
453
|
_unsubscribeAuth();
|