@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.
@@ -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>
@@ -0,0 +1,6 @@
1
+ type $$ComponentProps = {
2
+ enabled?: boolean;
3
+ };
4
+ declare const RealtimeDevBadge: import("svelte").Component<$$ComponentProps, {}, "">;
5
+ type RealtimeDevBadge = ReturnType<typeof RealtimeDevBadge>;
6
+ export default RealtimeDevBadge;
@@ -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 (!claims)
261
- return;
262
- // Wire all three SDK channel scopes from the JWT. `aid` → app, `tid` →
263
- // workspace, `sub` → user. Each setter is idempotent; only changes trigger
264
- // a reconnect.
265
- _realtime.setAppId(typeof claims.aid === 'string' ? claims.aid : undefined);
266
- _realtime.setWorkspaceId(typeof claims.tid === 'string' ? claims.tid : undefined);
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
- /** Internal — set the current status. Only called by `startBridgeRuntime`. */
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
- /** Internal — set the current status. Only called by `startBridgeRuntime`. */
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
  }
@@ -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 type { ConnectionState } from '@nebulr-group/bridge-auth-core';
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';
@@ -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.7.1",
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.6.0 <1.0.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": "^0.6.0"
80
+ "@nebulr-group/bridge-auth-core": "0.7.0-beta.0"
81
81
  },
82
82
  "keywords": [
83
83
  "svelte",