@nebulr-group/bridge-svelte 0.7.1 → 0.8.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/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 +84 -20
- package/dist/core/bridge-runtime.test.js +180 -3
- 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/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.
|
|
@@ -47,7 +47,7 @@ import { getBridgeAuth, tokenStore } from './bridge-instance.js';
|
|
|
47
47
|
import { wrapFetchWithBridgeAuth } from './bridge-fetch.js';
|
|
48
48
|
import { applySessionSnapshot } 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()
|
|
@@ -232,9 +270,24 @@ export function startBridgeRuntime(options = {}) {
|
|
|
232
270
|
// options + reauthorize on token-only refresh. Capability-specific subs
|
|
233
271
|
// (e.g. flag eval context) are layered on top by their own bootstrappers.
|
|
234
272
|
const apiBaseUrl = config.apiBaseUrl ?? 'https://api.thebridge.dev';
|
|
273
|
+
// TBP-644 — the realtime connection must be re-authorized whenever the
|
|
274
|
+
// token VALUE changes: rotation (A → B), but also first sign-in
|
|
275
|
+
// (none → A) and sign-out (A → none). Keying this on rotation only meant a
|
|
276
|
+
// session that signed in after page load kept the anonymous connection —
|
|
277
|
+
// or stayed parked after a refusal — until something else reconnected it.
|
|
278
|
+
// Flagged self-induced so setOnOpen skips its catch-up refresh (see the
|
|
279
|
+
// loop note there): the token we reconnect with is already current.
|
|
280
|
+
const reauthorizeForTokenChange = () => {
|
|
281
|
+
_reauthInFlight = true;
|
|
282
|
+
void _realtime.reauthorize();
|
|
283
|
+
};
|
|
284
|
+
// `subscribe` emits the current value synchronously, before `start()` below.
|
|
285
|
+
// That first emission is not a change: start() connects with it anyway.
|
|
286
|
+
let _tokenSubscriptionLive = false;
|
|
235
287
|
_unsubscribeAuth = tokenStore.subscribe((tokens) => {
|
|
236
288
|
const prevAuthToken = _currentAuthToken;
|
|
237
|
-
_currentAuthToken = tokens?.accessToken;
|
|
289
|
+
_currentAuthToken = tokens?.accessToken ?? undefined;
|
|
290
|
+
const tokenChanged = _tokenSubscriptionLive && prevAuthToken !== _currentAuthToken;
|
|
238
291
|
// Quota store hydrate requests carry the current access token; configure
|
|
239
292
|
// here so post-login state is picked up. Best-effort: a missing
|
|
240
293
|
// BridgeAuth means SDK isn't fully initialized yet — skip.
|
|
@@ -254,29 +307,28 @@ export function startBridgeRuntime(options = {}) {
|
|
|
254
307
|
// its anonymous app-id auth.
|
|
255
308
|
_realtime.setUserId(undefined);
|
|
256
309
|
_realtime.setWorkspaceId(undefined);
|
|
310
|
+
// Reconnect as the signed-out session now, rather than riding the old
|
|
311
|
+
// user's socket until something else drops it.
|
|
312
|
+
if (tokenChanged)
|
|
313
|
+
reauthorizeForTokenChange();
|
|
257
314
|
return;
|
|
258
315
|
}
|
|
259
316
|
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();
|
|
317
|
+
if (claims) {
|
|
318
|
+
// Wire all three SDK channel scopes from the JWT. `aid` → app, `tid` →
|
|
319
|
+
// workspace, `sub` → user. Each setter is idempotent; only changes
|
|
320
|
+
// trigger a reconnect.
|
|
321
|
+
_realtime.setAppId(typeof claims.aid === 'string' ? claims.aid : undefined);
|
|
322
|
+
_realtime.setWorkspaceId(typeof claims.tid === 'string' ? claims.tid : undefined);
|
|
323
|
+
_realtime.setUserId(typeof claims.sub === 'string' ? claims.sub : undefined);
|
|
278
324
|
}
|
|
325
|
+
// setUserId is a no-op when the user is unchanged (token-only refresh),
|
|
326
|
+
// and a setter-driven reconnect waits out a backoff and cannot lift a
|
|
327
|
+
// parked refusal — so reauthorize explicitly on every value change.
|
|
328
|
+
if (tokenChanged)
|
|
329
|
+
reauthorizeForTokenChange();
|
|
279
330
|
});
|
|
331
|
+
_tokenSubscriptionLive = true;
|
|
280
332
|
// Best-effort start. RealtimeClient gracefully no-ops if the workspace's
|
|
281
333
|
// `/realtime/config` returns `kind: 'noop'`.
|
|
282
334
|
void _realtime.start();
|
|
@@ -346,6 +398,17 @@ export function onBridgeRealtimeSnapshot(handler) {
|
|
|
346
398
|
_onSnapshotSubs.add(handler);
|
|
347
399
|
return () => _onSnapshotSubs.delete(handler);
|
|
348
400
|
}
|
|
401
|
+
/**
|
|
402
|
+
* Subscribe to realtime status changes (TBP-644): state, the machine-readable
|
|
403
|
+
* reason, whose side a fault is on (`app` / `config` / `bridge` / `network`),
|
|
404
|
+
* whether the client is still retrying, a docs link and a support ref.
|
|
405
|
+
* Fires on every change, not with the current value — read
|
|
406
|
+
* `realtimeStatusDetail` for that. Returns an unsubscribe fn.
|
|
407
|
+
*/
|
|
408
|
+
export function onBridgeRealtimeStatus(handler) {
|
|
409
|
+
_onStatusSubs.add(handler);
|
|
410
|
+
return () => _onStatusSubs.delete(handler);
|
|
411
|
+
}
|
|
349
412
|
/**
|
|
350
413
|
* Subscribe to server-side `user.state_changed` signals (fired before the
|
|
351
414
|
* runtime triggers a token refresh). Useful for debug overlays + tests.
|
|
@@ -364,6 +427,7 @@ export function __resetBridgeRuntime() {
|
|
|
364
427
|
_onSnapshotSubs.clear();
|
|
365
428
|
_onFlagChangeSubs.clear();
|
|
366
429
|
_onUserStateSubs.clear();
|
|
430
|
+
_onStatusSubs.clear();
|
|
367
431
|
_currentAuthToken = undefined;
|
|
368
432
|
if (_unsubscribeAuth) {
|
|
369
433
|
_unsubscribeAuth();
|
|
@@ -15,6 +15,10 @@ let _onDegraded;
|
|
|
15
15
|
let _invalidateCalls = 0;
|
|
16
16
|
let _onFlagChange;
|
|
17
17
|
let _onUserState;
|
|
18
|
+
let _onStatusChange;
|
|
19
|
+
let _refreshCalls = 0;
|
|
20
|
+
let _refreshImpl;
|
|
21
|
+
let _refreshThrows = false;
|
|
18
22
|
const _channelScopeCalls = [];
|
|
19
23
|
const _reauthCalls = [];
|
|
20
24
|
let _startCalls = 0;
|
|
@@ -31,6 +35,10 @@ function resetSpies() {
|
|
|
31
35
|
_startCalls = 0;
|
|
32
36
|
_stopCalls = 0;
|
|
33
37
|
_capturedRealtimeConfig = undefined;
|
|
38
|
+
_onStatusChange = undefined;
|
|
39
|
+
_refreshCalls = 0;
|
|
40
|
+
_refreshImpl = undefined;
|
|
41
|
+
_refreshThrows = false;
|
|
34
42
|
}
|
|
35
43
|
// ── Mocks ──────────────────────────────────────────────────────────────────
|
|
36
44
|
vi.mock('./bridge-instance.js', () => ({
|
|
@@ -39,7 +47,12 @@ vi.mock('./bridge-instance.js', () => ({
|
|
|
39
47
|
},
|
|
40
48
|
getBridgeAuth: () => ({
|
|
41
49
|
getApiContext: () => ({ appId: 'app-1', accessToken: null }),
|
|
42
|
-
refreshTokens: async () => {
|
|
50
|
+
refreshTokens: async () => {
|
|
51
|
+
_refreshCalls += 1;
|
|
52
|
+
if (_refreshThrows)
|
|
53
|
+
throw new Error('refresh failed');
|
|
54
|
+
return _refreshImpl ? _refreshImpl() : null;
|
|
55
|
+
},
|
|
43
56
|
invalidateFeatureFlagCache: () => { _invalidateCalls += 1; },
|
|
44
57
|
}),
|
|
45
58
|
}));
|
|
@@ -63,6 +76,7 @@ vi.mock('@nebulr-group/bridge-auth-core', () => {
|
|
|
63
76
|
setOnDegraded(fn) { _onDegraded = fn; }
|
|
64
77
|
setOnFlagChange(fn) { _onFlagChange = fn; }
|
|
65
78
|
setOnUserState(fn) { _onUserState = fn; }
|
|
79
|
+
setOnStatusChange(fn) { _onStatusChange = fn; }
|
|
66
80
|
setAppId(v) { _channelScopeCalls.push({ method: 'setAppId', value: v }); }
|
|
67
81
|
setWorkspaceId(v) { _channelScopeCalls.push({ method: 'setWorkspaceId', value: v }); }
|
|
68
82
|
setUserId(v) { _channelScopeCalls.push({ method: 'setUserId', value: v }); }
|
|
@@ -89,8 +103,8 @@ afterEach(async () => {
|
|
|
89
103
|
__resetBridgeRuntime();
|
|
90
104
|
});
|
|
91
105
|
// ── Imports under test ─────────────────────────────────────────────────────
|
|
92
|
-
import { startBridgeRuntime, stopBridgeRuntime, getBridgeRealtime, onBridgeRealtimeOpen, onBridgeRealtimeClose, onBridgeRealtimeSnapshot, onBridgeRealtimeUserState, onBridgeFlagChange, } from './bridge-runtime.js';
|
|
93
|
-
import { realtimeStatus } from './realtime-status.js';
|
|
106
|
+
import { startBridgeRuntime, stopBridgeRuntime, getBridgeRealtime, onBridgeRealtimeOpen, onBridgeRealtimeClose, onBridgeRealtimeSnapshot, onBridgeRealtimeUserState, onBridgeFlagChange, onBridgeRealtimeStatus, } from './bridge-runtime.js';
|
|
107
|
+
import { realtimeStatus, realtimeStatusDetail } from './realtime-status.js';
|
|
94
108
|
// ── Helpers ────────────────────────────────────────────────────────────────
|
|
95
109
|
function makeJwt(claims) {
|
|
96
110
|
const enc = (o) => btoa(JSON.stringify(o)).replace(/=+$/, '').replace(/\+/g, '-').replace(/\//g, '_');
|
|
@@ -266,3 +280,166 @@ describe('degraded realtime is reported as degraded, not open (TBP-575)', () =>
|
|
|
266
280
|
expect(get(realtimeStatus)).toBe('degraded');
|
|
267
281
|
});
|
|
268
282
|
});
|
|
283
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
284
|
+
// TBP-644 — the runtime only reauthorized on token ROTATION (A → B). A session
|
|
285
|
+
// that signed in after page load (none → A) kept the anonymous connection, and
|
|
286
|
+
// a client parked after a refusal stayed parked, until something else happened
|
|
287
|
+
// to reconnect it.
|
|
288
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
289
|
+
const flush = async () => {
|
|
290
|
+
for (let i = 0; i < 10; i++)
|
|
291
|
+
await Promise.resolve();
|
|
292
|
+
};
|
|
293
|
+
describe('reauthorizes on every token value change (TBP-644)', () => {
|
|
294
|
+
it('reauthorizes on first sign-in (no token → token)', () => {
|
|
295
|
+
startBridgeRuntime();
|
|
296
|
+
expect(_reauthCalls.length).toBe(0);
|
|
297
|
+
_tokenStore.set({ accessToken: makeJwt({ sub: 'user-1' }) });
|
|
298
|
+
expect(_reauthCalls.length).toBe(1);
|
|
299
|
+
});
|
|
300
|
+
it('reauthorizes on sign-out (token → no token)', () => {
|
|
301
|
+
startBridgeRuntime();
|
|
302
|
+
_tokenStore.set({ accessToken: makeJwt({ sub: 'user-1' }) });
|
|
303
|
+
_reauthCalls.length = 0;
|
|
304
|
+
_tokenStore.set(null);
|
|
305
|
+
expect(_reauthCalls.length).toBe(1);
|
|
306
|
+
});
|
|
307
|
+
it('does not reauthorize for the value already present at start — start() connects with it', () => {
|
|
308
|
+
_tokenStore.set({ accessToken: makeJwt({ sub: 'user-1' }) });
|
|
309
|
+
startBridgeRuntime();
|
|
310
|
+
expect(_reauthCalls.length).toBe(0);
|
|
311
|
+
expect(_startCalls).toBe(1);
|
|
312
|
+
});
|
|
313
|
+
it('does not reauthorize when the same token is emitted again', () => {
|
|
314
|
+
startBridgeRuntime();
|
|
315
|
+
const token = makeJwt({ sub: 'user-1' });
|
|
316
|
+
_tokenStore.set({ accessToken: token });
|
|
317
|
+
_reauthCalls.length = 0;
|
|
318
|
+
_tokenStore.set({ accessToken: token });
|
|
319
|
+
expect(_reauthCalls.length).toBe(0);
|
|
320
|
+
});
|
|
321
|
+
it('does not treat null → null (signed out, re-emitted) as a change', () => {
|
|
322
|
+
startBridgeRuntime();
|
|
323
|
+
_tokenStore.set({ accessToken: null });
|
|
324
|
+
_tokenStore.set(null);
|
|
325
|
+
expect(_reauthCalls.length).toBe(0);
|
|
326
|
+
});
|
|
327
|
+
});
|
|
328
|
+
describe('the self-induced refresh loop guard still holds (TBP-644)', () => {
|
|
329
|
+
it('the reconnect caused by a sign-in reauthorize does not fire the on-open refresh', () => {
|
|
330
|
+
startBridgeRuntime();
|
|
331
|
+
_onOpen?.(); // initial, anonymous connect
|
|
332
|
+
_tokenStore.set({ accessToken: makeJwt({ sub: 'user-1' }) }); // → reauthorize
|
|
333
|
+
_onOpen?.(); // the reconnect that reauthorize caused
|
|
334
|
+
expect(_refreshCalls).toBe(0);
|
|
335
|
+
});
|
|
336
|
+
it('a genuine reconnect still refreshes (catch-up for a missed user.state_changed)', () => {
|
|
337
|
+
startBridgeRuntime();
|
|
338
|
+
_onOpen?.();
|
|
339
|
+
_onOpen?.();
|
|
340
|
+
expect(_refreshCalls).toBe(1);
|
|
341
|
+
});
|
|
342
|
+
it('refresh → new token → reauthorize → open stops there instead of refreshing again', async () => {
|
|
343
|
+
startBridgeRuntime();
|
|
344
|
+
_tokenStore.set({ accessToken: makeJwt({ sub: 'user-1', iat: 1 }) });
|
|
345
|
+
_onOpen?.();
|
|
346
|
+
_refreshImpl = () => {
|
|
347
|
+
const t = { accessToken: makeJwt({ sub: 'user-1', iat: 2 }) };
|
|
348
|
+
_tokenStore.set(t);
|
|
349
|
+
return t;
|
|
350
|
+
};
|
|
351
|
+
_onOpen?.(); // genuine reconnect → catch-up refresh → token change → reauthorize
|
|
352
|
+
await flush();
|
|
353
|
+
expect(_refreshCalls).toBe(1);
|
|
354
|
+
_onOpen?.(); // the reauthorize's reconnect
|
|
355
|
+
await flush();
|
|
356
|
+
expect(_refreshCalls).toBe(1);
|
|
357
|
+
});
|
|
358
|
+
});
|
|
359
|
+
describe('refreshAuthToken is wired into the realtime client (TBP-644)', () => {
|
|
360
|
+
const refreshHook = () => _capturedRealtimeConfig.refreshAuthToken;
|
|
361
|
+
it('resolves to the NEW access token from BridgeAuth.refreshTokens()', async () => {
|
|
362
|
+
startBridgeRuntime();
|
|
363
|
+
_tokenStore.set({ accessToken: makeJwt({ sub: 'user-1', iat: 1 }) });
|
|
364
|
+
const fresh = makeJwt({ sub: 'user-1', iat: 2 });
|
|
365
|
+
_refreshImpl = () => {
|
|
366
|
+
_tokenStore.set({ accessToken: fresh });
|
|
367
|
+
return { accessToken: fresh };
|
|
368
|
+
};
|
|
369
|
+
await expect(refreshHook()()).resolves.toBe(fresh);
|
|
370
|
+
expect(_refreshCalls).toBe(1);
|
|
371
|
+
});
|
|
372
|
+
it('a signed-out session has nothing to refresh — resolves undefined without calling refresh', async () => {
|
|
373
|
+
startBridgeRuntime();
|
|
374
|
+
await expect(refreshHook()()).resolves.toBeUndefined();
|
|
375
|
+
expect(_refreshCalls).toBe(0);
|
|
376
|
+
});
|
|
377
|
+
it('a failed refresh resolves undefined instead of throwing', async () => {
|
|
378
|
+
startBridgeRuntime();
|
|
379
|
+
_tokenStore.set({ accessToken: makeJwt({ sub: 'user-1' }) });
|
|
380
|
+
_refreshThrows = true;
|
|
381
|
+
await expect(refreshHook()()).resolves.toBeUndefined();
|
|
382
|
+
});
|
|
383
|
+
it('the reconnect after a refreshAuthToken-driven token change does not refresh a second time', async () => {
|
|
384
|
+
startBridgeRuntime();
|
|
385
|
+
_tokenStore.set({ accessToken: makeJwt({ sub: 'user-1', iat: 1 }) });
|
|
386
|
+
_onOpen?.();
|
|
387
|
+
_refreshImpl = () => {
|
|
388
|
+
const t = { accessToken: makeJwt({ sub: 'user-1', iat: 2 }) };
|
|
389
|
+
_tokenStore.set(t);
|
|
390
|
+
return t;
|
|
391
|
+
};
|
|
392
|
+
await refreshHook()(); // realtime asked for it after a refusal
|
|
393
|
+
_onOpen?.(); // reconnect with the refreshed token
|
|
394
|
+
await flush();
|
|
395
|
+
expect(_refreshCalls).toBe(1);
|
|
396
|
+
});
|
|
397
|
+
it('an app-supplied refreshAuthToken override wins', () => {
|
|
398
|
+
const own = async () => 'own-token';
|
|
399
|
+
startBridgeRuntime({ realtime: { refreshAuthToken: own } });
|
|
400
|
+
expect(_capturedRealtimeConfig.refreshAuthToken).toBe(own);
|
|
401
|
+
});
|
|
402
|
+
});
|
|
403
|
+
describe('full realtime status reaches the public API (TBP-644)', () => {
|
|
404
|
+
const unauthorized = {
|
|
405
|
+
state: 'unauthorized',
|
|
406
|
+
reason: 'expired',
|
|
407
|
+
side: 'app',
|
|
408
|
+
retrying: false,
|
|
409
|
+
docsUrl: 'https://thebridge.dev/docs/live-updates/troubleshooting/#expired',
|
|
410
|
+
ref: 'abcd1234',
|
|
411
|
+
since: 1,
|
|
412
|
+
};
|
|
413
|
+
it('propagates to realtimeStatusDetail, realtimeStatus and onBridgeRealtimeStatus', () => {
|
|
414
|
+
const seen = [];
|
|
415
|
+
onBridgeRealtimeStatus((s) => seen.push(s));
|
|
416
|
+
startBridgeRuntime();
|
|
417
|
+
_onStatusChange?.(unauthorized);
|
|
418
|
+
expect(get(realtimeStatusDetail)).toEqual(unauthorized);
|
|
419
|
+
expect(get(realtimeStatus)).toBe('unauthorized');
|
|
420
|
+
expect(seen).toEqual([unauthorized]);
|
|
421
|
+
});
|
|
422
|
+
it('a later open/close mirror does not clobber the detail of the same state', () => {
|
|
423
|
+
startBridgeRuntime();
|
|
424
|
+
const closing = { state: 'closed', reason: 'connection_lost', side: 'network', retrying: true, ref: 'r1', since: 2 };
|
|
425
|
+
_onStatusChange?.(closing);
|
|
426
|
+
_onClose?.();
|
|
427
|
+
expect(get(realtimeStatusDetail)).toEqual(closing);
|
|
428
|
+
});
|
|
429
|
+
it('a parked (unauthorized) client clears the self-induced flag so the next genuine reconnect refreshes', () => {
|
|
430
|
+
startBridgeRuntime();
|
|
431
|
+
_onOpen?.();
|
|
432
|
+
_tokenStore.set({ accessToken: makeJwt({ sub: 'user-1' }) }); // reauthorize → flag set
|
|
433
|
+
_onStatusChange?.(unauthorized); // …but it was refused and parked
|
|
434
|
+
_onOpen?.(); // a later, genuine reconnect
|
|
435
|
+
expect(_refreshCalls).toBe(1);
|
|
436
|
+
});
|
|
437
|
+
it('unsubscribes cleanly', () => {
|
|
438
|
+
const handler = vi.fn();
|
|
439
|
+
const off = onBridgeRealtimeStatus(handler);
|
|
440
|
+
startBridgeRuntime();
|
|
441
|
+
off();
|
|
442
|
+
_onStatusChange?.(unauthorized);
|
|
443
|
+
expect(handler).not.toHaveBeenCalled();
|
|
444
|
+
});
|
|
445
|
+
});
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { RealtimeStatus } from '@nebulr-group/bridge-auth-core';
|
|
2
|
+
/** A connection still retrying after this long is worth telling the developer about. */
|
|
3
|
+
export declare const REALTIME_BADGE_RETRYING_AFTER_MS = 30000;
|
|
4
|
+
export interface RealtimeBadgeView {
|
|
5
|
+
/** Machine-readable reason — also the docs anchor. */
|
|
6
|
+
reason: string;
|
|
7
|
+
/** Plain-language owner of the fault. */
|
|
8
|
+
sideLabel: string;
|
|
9
|
+
docsUrl?: string;
|
|
10
|
+
ref?: string;
|
|
11
|
+
/**
|
|
12
|
+
* Identity of this run of trouble. Dismissing hides the badge until the key
|
|
13
|
+
* changes, so a NEW problem shows again but the same one does not nag.
|
|
14
|
+
*/
|
|
15
|
+
key: string;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Tracks when the current retrying run started. `RealtimeStatus.since` resets
|
|
19
|
+
* on every state flip (closed ↔ connecting), so it cannot measure how long a
|
|
20
|
+
* retry loop has lasted; the episode `ref` stays constant for the whole run.
|
|
21
|
+
*/
|
|
22
|
+
export declare function createRetryClock(): (status: RealtimeStatus, now: number) => number | undefined;
|
|
23
|
+
/**
|
|
24
|
+
* What the badge should say, or null when live updates are fine. Shown while
|
|
25
|
+
* the client has given up (`unauthorized`), is connected but deaf
|
|
26
|
+
* (`degraded`), or has been retrying for longer than
|
|
27
|
+
* {@link REALTIME_BADGE_RETRYING_AFTER_MS}.
|
|
28
|
+
*/
|
|
29
|
+
export declare function realtimeBadgeView(status: RealtimeStatus, retryingSince: number | undefined, now: number): RealtimeBadgeView | null;
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// TBP-644 — decision logic for the development-only "Live updates off — why?"
|
|
2
|
+
// badge. Kept framework-free so it is unit-testable without rendering and so
|
|
3
|
+
// the component stays a thin view over it.
|
|
4
|
+
/** A connection still retrying after this long is worth telling the developer about. */
|
|
5
|
+
export const REALTIME_BADGE_RETRYING_AFTER_MS = 30_000;
|
|
6
|
+
/**
|
|
7
|
+
* Used when a status carries a reason but no docs link (e.g. `degraded`).
|
|
8
|
+
* Mirrors auth-core's REALTIME_DOCS_BASE_URL — not imported, so a consumer on
|
|
9
|
+
* an auth-core that predates it does not fail to build.
|
|
10
|
+
*/
|
|
11
|
+
const DOCS_BASE_URL = 'https://thebridge.dev/docs/live-updates/troubleshooting/';
|
|
12
|
+
const SIDE_LABELS = {
|
|
13
|
+
app: "Your app — the session / token it hands to Bridge",
|
|
14
|
+
config: "Your Bridge settings — apiBaseUrl or appId don't match the session",
|
|
15
|
+
bridge: 'Bridge — nothing to change in your app',
|
|
16
|
+
network: 'The network — retrying automatically',
|
|
17
|
+
};
|
|
18
|
+
function sideLabel(status) {
|
|
19
|
+
if (status.side)
|
|
20
|
+
return SIDE_LABELS[status.side];
|
|
21
|
+
if (status.state === 'degraded')
|
|
22
|
+
return 'Channel access — connected, but Bridge accepted no channel';
|
|
23
|
+
return 'Unknown';
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Tracks when the current retrying run started. `RealtimeStatus.since` resets
|
|
27
|
+
* on every state flip (closed ↔ connecting), so it cannot measure how long a
|
|
28
|
+
* retry loop has lasted; the episode `ref` stays constant for the whole run.
|
|
29
|
+
*/
|
|
30
|
+
export function createRetryClock() {
|
|
31
|
+
let ref;
|
|
32
|
+
let since;
|
|
33
|
+
return (status, now) => {
|
|
34
|
+
if (!status.retrying) {
|
|
35
|
+
ref = undefined;
|
|
36
|
+
since = undefined;
|
|
37
|
+
return undefined;
|
|
38
|
+
}
|
|
39
|
+
const key = status.ref ?? '';
|
|
40
|
+
if (since === undefined || key !== ref) {
|
|
41
|
+
ref = key;
|
|
42
|
+
since = now;
|
|
43
|
+
}
|
|
44
|
+
return since;
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* What the badge should say, or null when live updates are fine. Shown while
|
|
49
|
+
* the client has given up (`unauthorized`), is connected but deaf
|
|
50
|
+
* (`degraded`), or has been retrying for longer than
|
|
51
|
+
* {@link REALTIME_BADGE_RETRYING_AFTER_MS}.
|
|
52
|
+
*/
|
|
53
|
+
export function realtimeBadgeView(status, retryingSince, now) {
|
|
54
|
+
const stuck = status.state === 'unauthorized' || status.state === 'degraded';
|
|
55
|
+
const retryingTooLong = status.retrying && retryingSince !== undefined && now - retryingSince >= REALTIME_BADGE_RETRYING_AFTER_MS;
|
|
56
|
+
if (!stuck && !retryingTooLong)
|
|
57
|
+
return null;
|
|
58
|
+
const reason = status.reason ?? status.state;
|
|
59
|
+
return {
|
|
60
|
+
reason,
|
|
61
|
+
sideLabel: sideLabel(status),
|
|
62
|
+
docsUrl: status.docsUrl ?? (status.reason ? `${DOCS_BASE_URL}#${status.reason}` : undefined),
|
|
63
|
+
ref: status.ref,
|
|
64
|
+
key: `${stuck ? status.state : 'retrying'}|${reason}|${status.ref ?? ''}`,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
// TBP-644 — the development-only "Live updates off — why?" badge.
|
|
2
|
+
//
|
|
3
|
+
// Rendered with `svelte/server`: the vitest environment has no DOM, and what
|
|
4
|
+
// matters most here — does it render at all, in which build, with which
|
|
5
|
+
// config — is fully visible in the server-rendered HTML.
|
|
6
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
7
|
+
import { readable } from 'svelte/store';
|
|
8
|
+
import { REALTIME_BADGE_RETRYING_AFTER_MS, createRetryClock, realtimeBadgeView, } from './realtime-dev-badge.js';
|
|
9
|
+
const unauthorized = {
|
|
10
|
+
state: 'unauthorized',
|
|
11
|
+
reason: 'wrong_environment',
|
|
12
|
+
side: 'config',
|
|
13
|
+
retrying: false,
|
|
14
|
+
docsUrl: 'https://thebridge.dev/docs/live-updates/troubleshooting/#wrong_environment',
|
|
15
|
+
ref: '0badf00d',
|
|
16
|
+
since: 1,
|
|
17
|
+
};
|
|
18
|
+
const degraded = { state: 'degraded', reason: 'no_channel_accepted', retrying: false, since: 1 };
|
|
19
|
+
const open = { state: 'open', retrying: false, since: 1 };
|
|
20
|
+
const retrying = {
|
|
21
|
+
state: 'connecting',
|
|
22
|
+
reason: 'connection_lost',
|
|
23
|
+
side: 'network',
|
|
24
|
+
retrying: true,
|
|
25
|
+
ref: 'r1',
|
|
26
|
+
since: 1,
|
|
27
|
+
};
|
|
28
|
+
describe('realtimeBadgeView', () => {
|
|
29
|
+
it('shows for unauthorized, with reason, whose side, docs link and ref', () => {
|
|
30
|
+
const view = realtimeBadgeView(unauthorized, undefined, 0);
|
|
31
|
+
expect(view).toMatchObject({
|
|
32
|
+
reason: 'wrong_environment',
|
|
33
|
+
docsUrl: unauthorized.docsUrl,
|
|
34
|
+
ref: '0badf00d',
|
|
35
|
+
});
|
|
36
|
+
expect(view?.sideLabel).toMatch(/Bridge settings/);
|
|
37
|
+
});
|
|
38
|
+
it('shows for degraded and builds the docs link from the reason', () => {
|
|
39
|
+
expect(realtimeBadgeView(degraded, undefined, 0)).toMatchObject({
|
|
40
|
+
reason: 'no_channel_accepted',
|
|
41
|
+
docsUrl: 'https://thebridge.dev/docs/live-updates/troubleshooting/#no_channel_accepted',
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
it('stays hidden while open', () => {
|
|
45
|
+
expect(realtimeBadgeView(open, undefined, 0)).toBeNull();
|
|
46
|
+
});
|
|
47
|
+
it('stays hidden while retrying for less than 30 s, and shows after', () => {
|
|
48
|
+
expect(realtimeBadgeView(retrying, 0, REALTIME_BADGE_RETRYING_AFTER_MS - 1)).toBeNull();
|
|
49
|
+
expect(realtimeBadgeView(retrying, 0, REALTIME_BADGE_RETRYING_AFTER_MS)).toMatchObject({
|
|
50
|
+
reason: 'connection_lost',
|
|
51
|
+
sideLabel: expect.stringMatching(/network/i),
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
it('keys a retry run by its ref, not by the closed/connecting flips inside it', () => {
|
|
55
|
+
const a = realtimeBadgeView({ ...retrying, state: 'closed' }, 0, 60_000);
|
|
56
|
+
const b = realtimeBadgeView({ ...retrying, state: 'connecting' }, 0, 60_000);
|
|
57
|
+
expect(a?.key).toBe(b?.key);
|
|
58
|
+
expect(realtimeBadgeView({ ...retrying, ref: 'r2' }, 0, 60_000)?.key).not.toBe(a?.key);
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
describe('createRetryClock', () => {
|
|
62
|
+
it('measures a retry run from its first status, across state flips, and resets on recovery', () => {
|
|
63
|
+
const clock = createRetryClock();
|
|
64
|
+
expect(clock(retrying, 100)).toBe(100);
|
|
65
|
+
expect(clock({ ...retrying, state: 'closed' }, 5_000)).toBe(100);
|
|
66
|
+
expect(clock({ ...retrying, ref: 'r2' }, 9_000)).toBe(9_000); // a new episode
|
|
67
|
+
expect(clock(open, 10_000)).toBeUndefined();
|
|
68
|
+
expect(clock(retrying, 11_000)).toBe(11_000);
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
// ── Rendering ───────────────────────────────────────────────────────────────
|
|
72
|
+
async function renderBadge(opts) {
|
|
73
|
+
vi.resetModules();
|
|
74
|
+
vi.doMock('$app/environment', () => ({ dev: opts.dev, browser: false, building: false, version: 'test' }));
|
|
75
|
+
const { _setRealtimeStatusDetail } = await import('./realtime-status.js');
|
|
76
|
+
if (opts.status)
|
|
77
|
+
_setRealtimeStatusDetail(opts.status);
|
|
78
|
+
const { default: Badge } = await import('../client/components/developer/RealtimeDevBadge.svelte');
|
|
79
|
+
const { render } = await import('svelte/server');
|
|
80
|
+
return render(Badge, { props: opts.enabled === undefined ? {} : { enabled: opts.enabled } }).body;
|
|
81
|
+
}
|
|
82
|
+
afterEach(() => {
|
|
83
|
+
vi.doUnmock('$app/environment');
|
|
84
|
+
vi.doUnmock('$app/navigation');
|
|
85
|
+
vi.doUnmock('$app/stores');
|
|
86
|
+
vi.doUnmock('../auth/route-guard.js');
|
|
87
|
+
vi.doUnmock('../client/stores/config.store.js');
|
|
88
|
+
});
|
|
89
|
+
describe('<RealtimeDevBadge>', () => {
|
|
90
|
+
it('renders in a development build while live updates are off', async () => {
|
|
91
|
+
const html = await renderBadge({ dev: true, status: unauthorized });
|
|
92
|
+
expect(html).toContain('Live updates off — why?');
|
|
93
|
+
expect(html).toContain('data-testid="bridge-realtime-dev-badge"');
|
|
94
|
+
// Accessible: a real button that says it controls a collapsed panel, and
|
|
95
|
+
// a polite live region that announces the reason.
|
|
96
|
+
expect(html).toMatch(/<button[^>]*type="button"[^>]*aria-expanded="false"/);
|
|
97
|
+
expect(html).toContain('aria-live="polite"');
|
|
98
|
+
expect(html).toContain('Bridge live updates are off: wrong_environment');
|
|
99
|
+
});
|
|
100
|
+
it('renders nothing in a production build', async () => {
|
|
101
|
+
const html = await renderBadge({ dev: false, status: unauthorized });
|
|
102
|
+
expect(html).not.toContain('bridge-realtime-dev-badge');
|
|
103
|
+
expect(html).not.toContain('Live updates off');
|
|
104
|
+
});
|
|
105
|
+
it('renders nothing when opted out', async () => {
|
|
106
|
+
const html = await renderBadge({ dev: true, status: unauthorized, enabled: false });
|
|
107
|
+
expect(html).not.toContain('bridge-realtime-dev-badge');
|
|
108
|
+
});
|
|
109
|
+
it('renders no badge while live updates are working', async () => {
|
|
110
|
+
const html = await renderBadge({ dev: true, status: open });
|
|
111
|
+
expect(html).not.toContain('Live updates off');
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
// ── Mounted automatically by <BridgeBootstrap /> ────────────────────────────
|
|
115
|
+
async function renderBootstrap(opts) {
|
|
116
|
+
vi.resetModules();
|
|
117
|
+
vi.doMock('$app/environment', () => ({ dev: opts.dev, browser: false, building: false, version: 'test' }));
|
|
118
|
+
vi.doMock('$app/navigation', () => ({ beforeNavigate: () => { }, goto: async () => { } }));
|
|
119
|
+
vi.doMock('$app/stores', () => ({ page: readable({ url: new URL('http://localhost/') }) }));
|
|
120
|
+
// The route guard needs an initialised BridgeAuth; this test is about markup.
|
|
121
|
+
vi.doMock('../auth/route-guard.js', () => ({
|
|
122
|
+
createRouteGuard: () => ({ getNavigationDecision: async () => ({ type: 'allow' }) }),
|
|
123
|
+
routeRulesReferenceFlag: () => false,
|
|
124
|
+
}));
|
|
125
|
+
vi.doMock('../client/stores/config.store.js', () => ({
|
|
126
|
+
getConfig: () => ({ appId: 'app-1', apiBaseUrl: 'http://test', devBadge: opts.devBadge }),
|
|
127
|
+
getRouteGuardConfig: () => null,
|
|
128
|
+
}));
|
|
129
|
+
const { _setRealtimeStatusDetail } = await import('./realtime-status.js');
|
|
130
|
+
_setRealtimeStatusDetail(unauthorized);
|
|
131
|
+
const { default: BridgeBootstrap } = await import('../client/BridgeBootstrap.svelte');
|
|
132
|
+
const { render } = await import('svelte/server');
|
|
133
|
+
return render(BridgeBootstrap, { props: {} }).body;
|
|
134
|
+
}
|
|
135
|
+
describe('<BridgeBootstrap> mounts the badge', () => {
|
|
136
|
+
it('without any app code in a development build', async () => {
|
|
137
|
+
expect(await renderBootstrap({ dev: true })).toContain('Live updates off — why?');
|
|
138
|
+
});
|
|
139
|
+
it('and respects devBadge: false', async () => {
|
|
140
|
+
expect(await renderBootstrap({ dev: true, devBadge: false })).not.toContain('bridge-realtime-dev-badge');
|
|
141
|
+
});
|
|
142
|
+
it('and never in a production build', async () => {
|
|
143
|
+
expect(await renderBootstrap({ dev: false })).not.toContain('bridge-realtime-dev-badge');
|
|
144
|
+
});
|
|
145
|
+
});
|
|
@@ -1,6 +1,20 @@
|
|
|
1
1
|
import { type Readable } from 'svelte/store';
|
|
2
|
-
import type { ConnectionState } from '@nebulr-group/bridge-auth-core';
|
|
2
|
+
import type { ConnectionState, RealtimeStatus } from '@nebulr-group/bridge-auth-core';
|
|
3
3
|
/** Reactive readable store of the current realtime connection state. */
|
|
4
4
|
export declare const realtimeStatus: Readable<ConnectionState>;
|
|
5
|
-
/**
|
|
5
|
+
/**
|
|
6
|
+
* Reactive readable store of the full realtime status (TBP-644): `state`, and
|
|
7
|
+
* when live updates are not working, the machine-readable `reason`, whose
|
|
8
|
+
* `side` the fault is on (`app` / `config` / `bridge` / `network`), whether the
|
|
9
|
+
* client is still `retrying`, a `docsUrl` and a support `ref`.
|
|
10
|
+
*
|
|
11
|
+
* A sibling of `realtimeStatus` rather than a change to it: that store is a
|
|
12
|
+
* plain `ConnectionState` string that apps compare and render directly, so
|
|
13
|
+
* widening its type would break them. `realtimeStatus` always equals
|
|
14
|
+
* `realtimeStatusDetail.state`.
|
|
15
|
+
*/
|
|
16
|
+
export declare const realtimeStatusDetail: Readable<RealtimeStatus>;
|
|
17
|
+
/** Internal — set the current state. Only called by `startBridgeRuntime`. */
|
|
6
18
|
export declare function _setRealtimeStatus(state: ConnectionState): void;
|
|
19
|
+
/** Internal — set the full status. Only called by `startBridgeRuntime`. */
|
|
20
|
+
export declare function _setRealtimeStatusDetail(status: RealtimeStatus): void;
|
|
@@ -7,9 +7,31 @@
|
|
|
7
7
|
// offline indicators, retry banners, etc.
|
|
8
8
|
import { writable } from 'svelte/store';
|
|
9
9
|
const _store = writable('idle');
|
|
10
|
+
const _detail = writable({ state: 'idle', retrying: false, since: Date.now() });
|
|
10
11
|
/** Reactive readable store of the current realtime connection state. */
|
|
11
12
|
export const realtimeStatus = _store;
|
|
12
|
-
/**
|
|
13
|
+
/**
|
|
14
|
+
* Reactive readable store of the full realtime status (TBP-644): `state`, and
|
|
15
|
+
* when live updates are not working, the machine-readable `reason`, whose
|
|
16
|
+
* `side` the fault is on (`app` / `config` / `bridge` / `network`), whether the
|
|
17
|
+
* client is still `retrying`, a `docsUrl` and a support `ref`.
|
|
18
|
+
*
|
|
19
|
+
* A sibling of `realtimeStatus` rather than a change to it: that store is a
|
|
20
|
+
* plain `ConnectionState` string that apps compare and render directly, so
|
|
21
|
+
* widening its type would break them. `realtimeStatus` always equals
|
|
22
|
+
* `realtimeStatusDetail.state`.
|
|
23
|
+
*/
|
|
24
|
+
export const realtimeStatusDetail = _detail;
|
|
25
|
+
/** Internal — set the current state. Only called by `startBridgeRuntime`. */
|
|
13
26
|
export function _setRealtimeStatus(state) {
|
|
14
27
|
_store.set(state);
|
|
28
|
+
// Keep the detail store in step when running on an auth-core that has no
|
|
29
|
+
// status hook (only open/close/degraded). With the hook, the detail for
|
|
30
|
+
// this state has already landed and is left alone.
|
|
31
|
+
_detail.update((d) => (d.state === state ? d : { state, retrying: false, since: Date.now() }));
|
|
32
|
+
}
|
|
33
|
+
/** Internal — set the full status. Only called by `startBridgeRuntime`. */
|
|
34
|
+
export function _setRealtimeStatusDetail(status) {
|
|
35
|
+
_detail.set(status);
|
|
36
|
+
_store.set(status.state);
|
|
15
37
|
}
|
package/dist/flags/index.d.ts
CHANGED
|
@@ -2,7 +2,8 @@ export { createBridgeFlags, BrowserIdentityStorage, type CreateBridgeFlagsConfig
|
|
|
2
2
|
export { evaluateFlag, setBridgeFlagsInstance, getBridgeFlagsInstance, notifyFlagChanged, notifyAllFlagsChanged, subscribeToFlagChanges, } from './registry.js';
|
|
3
3
|
export { useFlag, flagStore, _flagVersionsRune, type FlagStore } from './flag.svelte.js';
|
|
4
4
|
export { default as FeatureFlag } from './FeatureFlag.svelte';
|
|
5
|
-
export { realtimeStatus } from './realtime-status.js';
|
|
6
|
-
export
|
|
5
|
+
export { realtimeStatus, realtimeStatusDetail } from './realtime-status.js';
|
|
6
|
+
export { onBridgeRealtimeStatus } from '../core/bridge-runtime.js';
|
|
7
|
+
export type { ConnectionState, RealtimeStatus } from '@nebulr-group/bridge-auth-core';
|
|
7
8
|
export { BridgeFlags, MemoryIdentityStorage, attachIdentity, generateAnonymousId, BRIDGE_CONTEXT_HEADER, serializeContext, deserializeContext, serverInstanceId, } from '@nebulr-group/bridge-auth-core';
|
|
8
9
|
export type { CachedFlag, FlagValueType, EvalTelemetry, DiscoveryTelemetry, BridgeFlagsHooks, DeclaredAttributeType, AttributeDeclaration, BridgeFlagsMode, EvalContext, IdentityStorage, AnonymousTrackingMode, BridgeIdentity, RealtimeMessage, } from '@nebulr-group/bridge-auth-core';
|
package/dist/flags/index.js
CHANGED
|
@@ -14,7 +14,9 @@ export { useFlag, flagStore, _flagVersionsRune } from './flag.svelte.js';
|
|
|
14
14
|
export { default as FeatureFlag } from './FeatureFlag.svelte';
|
|
15
15
|
// Reactive realtime connection status (subscribe in components to show
|
|
16
16
|
// offline indicators, retry banners, etc.).
|
|
17
|
-
export { realtimeStatus } from './realtime-status.js';
|
|
17
|
+
export { realtimeStatus, realtimeStatusDetail } from './realtime-status.js';
|
|
18
|
+
// TBP-644 — why live updates are off, whose side it is, and whether it retries.
|
|
19
|
+
export { onBridgeRealtimeStatus } from '../core/bridge-runtime.js';
|
|
18
20
|
// Auth-core re-exports — consumers can stay on the `/flags` path without
|
|
19
21
|
// adding a direct dependency on `@nebulr-group/bridge-auth-core`.
|
|
20
22
|
export { BridgeFlags, MemoryIdentityStorage, attachIdentity, generateAnonymousId, BRIDGE_CONTEXT_HEADER, serializeContext, deserializeContext, serverInstanceId, } from '@nebulr-group/bridge-auth-core';
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export { realtimeStatus, _setRealtimeStatus } from '../core/realtime-status.js';
|
|
1
|
+
export { realtimeStatus, realtimeStatusDetail, _setRealtimeStatus } from '../core/realtime-status.js';
|
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
// Backward-compat re-export. The implementation moved to `core/realtime-status.ts`
|
|
2
2
|
// because the realtime channel is a Bridge-level construct (flags + billing both
|
|
3
3
|
// ride it). Imports from `@nebulr-group/bridge-svelte/flags` keep working.
|
|
4
|
-
export { realtimeStatus, _setRealtimeStatus } from '../core/realtime-status.js';
|
|
4
|
+
export { realtimeStatus, realtimeStatusDetail, _setRealtimeStatus } from '../core/realtime-status.js';
|
package/dist/index.d.ts
CHANGED
|
@@ -8,6 +8,10 @@ export type { BrandingSnapshot, SubscriptionSnapshot, UserSnapshot, SessionSnaps
|
|
|
8
8
|
export type { BridgeEventHandlers, BridgeEventsDispatcher } from './core/events.js';
|
|
9
9
|
export { default as BridgeBootstrap, default as BridgeProvider } from './client/BridgeBootstrap.svelte';
|
|
10
10
|
export { default as ApiTokenManagement } from './client/components/developer/ApiTokenManagement.svelte';
|
|
11
|
+
export { default as RealtimeDevBadge } from './client/components/developer/RealtimeDevBadge.svelte';
|
|
12
|
+
export { realtimeStatus, realtimeStatusDetail } from './core/realtime-status.js';
|
|
13
|
+
export { onBridgeRealtimeStatus } from './core/bridge-runtime.js';
|
|
14
|
+
export type { RealtimeStatus } from '@nebulr-group/bridge-auth-core';
|
|
11
15
|
export { default as FeatureFlag } from './flags/FeatureFlag.svelte';
|
|
12
16
|
export { default as ProfileName } from './client/components/ProfileName.svelte';
|
|
13
17
|
export { default as TeamManagementPanel } from './client/components/team/TeamManagementPanel.svelte';
|
package/dist/index.js
CHANGED
|
@@ -16,6 +16,14 @@ export { bridge } from './core/bridge.js';
|
|
|
16
16
|
// Components (Svelte components must have `export default`)
|
|
17
17
|
export { default as BridgeBootstrap, default as BridgeProvider } from './client/BridgeBootstrap.svelte';
|
|
18
18
|
export { default as ApiTokenManagement } from './client/components/developer/ApiTokenManagement.svelte';
|
|
19
|
+
// TBP-644 — dev-only live-updates badge. <BridgeBootstrap /> already mounts it;
|
|
20
|
+
// exported for apps that render their own bootstrap.
|
|
21
|
+
export { default as RealtimeDevBadge } from './client/components/developer/RealtimeDevBadge.svelte';
|
|
22
|
+
// TBP-644 — realtime status. `realtimeStatus` is the plain state string;
|
|
23
|
+
// `realtimeStatusDetail` adds the reason, whose side it is and whether it is
|
|
24
|
+
// still retrying. `onBridgeRealtimeStatus` is the event-style subscription.
|
|
25
|
+
export { realtimeStatus, realtimeStatusDetail } from './core/realtime-status.js';
|
|
26
|
+
export { onBridgeRealtimeStatus } from './core/bridge-runtime.js';
|
|
19
27
|
export { default as FeatureFlag } from './flags/FeatureFlag.svelte';
|
|
20
28
|
export { default as ProfileName } from './client/components/ProfileName.svelte';
|
|
21
29
|
export { default as TeamManagementPanel } from './client/components/team/TeamManagementPanel.svelte';
|
|
@@ -12,6 +12,12 @@ export interface BridgeConfig extends BridgeAuthConfig {
|
|
|
12
12
|
/** Per-key copy overrides applied on top of the resolved locale, for wording
|
|
13
13
|
* an app genuinely needs to differ. Highest precedence in the chain. */
|
|
14
14
|
messages?: MessageOverrides;
|
|
15
|
+
/** Show the "Live updates off — why?" corner badge that <BridgeBootstrap />
|
|
16
|
+
* mounts while realtime is refused, degraded or stuck retrying (TBP-644).
|
|
17
|
+
* It only ever renders in development builds; set `false` to hide it there
|
|
18
|
+
* too. Production builds never show it, whatever this says.
|
|
19
|
+
* @default true */
|
|
20
|
+
devBadge?: boolean;
|
|
15
21
|
/** Billing paywall configuration. When set, Bridge redirects unauthenticated
|
|
16
22
|
* or plan-less users to `paywallRoute` before the page renders. */
|
|
17
23
|
billing?: {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nebulr-group/bridge-svelte",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0-beta.0",
|
|
4
4
|
"description": "Bridge Svelte library, This library helps you to add bridge authentication and feature flags, and payments to your svelte application.",
|
|
5
5
|
"author": "Iman Pouya",
|
|
6
6
|
"license": "MIT",
|
|
@@ -56,7 +56,7 @@
|
|
|
56
56
|
"svelte": "^5.0.0",
|
|
57
57
|
"@sveltejs/kit": "^2.0.0",
|
|
58
58
|
"@stripe/stripe-js": ">=4.0.0",
|
|
59
|
-
"@nebulr-group/bridge-auth-core": ">=0.
|
|
59
|
+
"@nebulr-group/bridge-auth-core": ">=0.7.0-beta.0 <1.0.0"
|
|
60
60
|
},
|
|
61
61
|
"peerDependenciesMeta": {
|
|
62
62
|
"@stripe/stripe-js": {
|
|
@@ -77,7 +77,7 @@
|
|
|
77
77
|
"typescript": "^6.0.0",
|
|
78
78
|
"vite": "^6.2.6",
|
|
79
79
|
"vitest": "^4.1.4",
|
|
80
|
-
"@nebulr-group/bridge-auth-core": "
|
|
80
|
+
"@nebulr-group/bridge-auth-core": "0.7.0-beta.0"
|
|
81
81
|
},
|
|
82
82
|
"keywords": [
|
|
83
83
|
"svelte",
|