@nebulr-group/bridge-svelte 0.7.1 → 0.8.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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.
@@ -45,9 +45,9 @@ import { RealtimeClient, useBridge, } from '@nebulr-group/bridge-auth-core';
45
45
  import { getConfig } from '../client/stores/config.store.js';
46
46
  import { getBridgeAuth, tokenStore } from './bridge-instance.js';
47
47
  import { wrapFetchWithBridgeAuth } from './bridge-fetch.js';
48
- import { applySessionSnapshot } from './snapshot-stores.js';
48
+ import { applyEntitlementsChanged, applySessionSnapshot, applySubscriptionPlanChanged, } from './snapshot-stores.js';
49
49
  import { bridgeEvents } from './events.js';
50
- import { _setRealtimeStatus } from './realtime-status.js';
50
+ import { _setRealtimeStatus, _setRealtimeStatusDetail } from './realtime-status.js';
51
51
  let _realtime;
52
52
  let _unsubscribeAuth;
53
53
  let _currentAuthToken;
@@ -59,6 +59,8 @@ const _onSnapshotSubs = new Set();
59
59
  // invalidate its (separate) cache and re-evaluate the current route.
60
60
  const _onFlagChangeSubs = new Set();
61
61
  const _onUserStateSubs = new Set();
62
+ // TBP-644 — full realtime status (state + reason + whose side + retrying).
63
+ const _onStatusSubs = new Set();
62
64
  /**
63
65
  * Start the Bridge runtime. Idempotent — repeated calls are a no-op. Reads
64
66
  * `appId` + `apiBaseUrl` from the auth config store (`bridgeConfig.initConfig`).
@@ -105,6 +107,25 @@ export function startBridgeRuntime(options = {}) {
105
107
  apiKey: config.appId,
106
108
  appId: _bootstrapAppId,
107
109
  getAuthToken: () => _currentAuthToken,
110
+ // TBP-644 — a refused connection gets ONE session refresh per episode
111
+ // (auth-core enforces the once) and reconnects with the new token instead
112
+ // of parking. A signed-out session has nothing to refresh. Loop safety:
113
+ // the refreshed token lands in the tokenStore subscription below, whose
114
+ // reauthorize() is a no-op while that episode is still connecting, and
115
+ // the reconnect it produces is flagged self-induced so setOnOpen does not
116
+ // refresh a second time.
117
+ refreshAuthToken: options.realtime?.refreshAuthToken ??
118
+ (async () => {
119
+ if (!_currentAuthToken)
120
+ return undefined;
121
+ try {
122
+ const tokens = await getBridgeAuth().refreshTokens();
123
+ return tokens?.accessToken ?? undefined;
124
+ }
125
+ catch {
126
+ return undefined;
127
+ }
128
+ }),
108
129
  });
109
130
  let _connectedOnce = false;
110
131
  // Set just before we call _realtime.reauthorize() so the resulting
@@ -159,6 +180,23 @@ export function startBridgeRuntime(options = {}) {
159
180
  _realtime.setOnDegraded?.(() => {
160
181
  _setRealtimeStatus('degraded');
161
182
  });
183
+ // TBP-644 — the full status: why the connection is not working, whose side
184
+ // the fault is on, and whether it is still retrying. Guarded for the same
185
+ // reason as setOnDegraded (an older auth-core has no such hook; the
186
+ // open/close/degraded mirrors above keep `realtimeStatus` working there).
187
+ _realtime.setOnStatusChange?.((status) => {
188
+ _setRealtimeStatusDetail(status);
189
+ // A parked client never opens, so a reauthorize that ended in a refusal
190
+ // must not leave the self-induced flag set for the next genuine reconnect.
191
+ if (status.state === 'unauthorized')
192
+ _reauthInFlight = false;
193
+ for (const fn of _onStatusSubs) {
194
+ try {
195
+ fn(status);
196
+ }
197
+ catch { /* subscriber errors swallowed */ }
198
+ }
199
+ });
162
200
  // TBP-575 — a flag changed on the wire. Two caches need to hear about it and
163
201
  // only one of them was ever told:
164
202
  // - BridgeFlags (FF 2.0) — driven by realtime already, via attach()
@@ -209,8 +247,21 @@ export function startBridgeRuntime(options = {}) {
209
247
  useBridge().attachToRealtimeClient(_realtime);
210
248
  // Phase 5 (TBP-331) + TBP-360 — billing-family events flow through the
211
249
  // unified bridge events surface via `useBridge().handle({...})`.
250
+ //
251
+ // TBP-644 — the two pushes that carry the complete new value also move the
252
+ // `bridge.tenant.*` stores, which were otherwise written only by
253
+ // `session.snapshot`. A plan change never re-sends a snapshot, so without
254
+ // this an upgraded app kept rendering the old plan until a reload. The store
255
+ // is patched BEFORE dispatch so a `bridge.events` handler that reads
256
+ // `bridge.tenant.subscription` already sees the new plan.
212
257
  useBridge().handle({
213
- 'subscription.plan_changed': (msg) => bridgeEvents._dispatch(msg),
258
+ 'subscription.plan_changed': (msg) => {
259
+ try {
260
+ applySubscriptionPlanChanged(msg);
261
+ }
262
+ catch { /* store updates shouldn't throw, defensive */ }
263
+ bridgeEvents._dispatch(msg);
264
+ },
214
265
  'payment.failed': (msg) => bridgeEvents._dispatch(msg),
215
266
  'payment.succeeded': (msg) => bridgeEvents._dispatch(msg),
216
267
  'subscription.created': (msg) => bridgeEvents._dispatch(msg),
@@ -226,15 +277,37 @@ export function startBridgeRuntime(options = {}) {
226
277
  'dunning.recovered': (msg) => bridgeEvents._dispatch(msg),
227
278
  'dunning.exhausted': (msg) => bridgeEvents._dispatch(msg),
228
279
  'quota.updated': (msg) => bridgeEvents._dispatch(msg),
229
- 'entitlements.changed': (msg) => bridgeEvents._dispatch(msg),
280
+ 'entitlements.changed': (msg) => {
281
+ // Only the payload-carrying variant has a map; the signal-only one is a no-op here.
282
+ try {
283
+ applyEntitlementsChanged(msg);
284
+ }
285
+ catch { /* defensive */ }
286
+ bridgeEvents._dispatch(msg);
287
+ },
230
288
  });
231
289
  // Token store subscription — owns realtime channel scoping + quotas HTTP
232
290
  // options + reauthorize on token-only refresh. Capability-specific subs
233
291
  // (e.g. flag eval context) are layered on top by their own bootstrappers.
234
292
  const apiBaseUrl = config.apiBaseUrl ?? 'https://api.thebridge.dev';
293
+ // TBP-644 — the realtime connection must be re-authorized whenever the
294
+ // token VALUE changes: rotation (A → B), but also first sign-in
295
+ // (none → A) and sign-out (A → none). Keying this on rotation only meant a
296
+ // session that signed in after page load kept the anonymous connection —
297
+ // or stayed parked after a refusal — until something else reconnected it.
298
+ // Flagged self-induced so setOnOpen skips its catch-up refresh (see the
299
+ // loop note there): the token we reconnect with is already current.
300
+ const reauthorizeForTokenChange = () => {
301
+ _reauthInFlight = true;
302
+ void _realtime.reauthorize();
303
+ };
304
+ // `subscribe` emits the current value synchronously, before `start()` below.
305
+ // That first emission is not a change: start() connects with it anyway.
306
+ let _tokenSubscriptionLive = false;
235
307
  _unsubscribeAuth = tokenStore.subscribe((tokens) => {
236
308
  const prevAuthToken = _currentAuthToken;
237
- _currentAuthToken = tokens?.accessToken;
309
+ _currentAuthToken = tokens?.accessToken ?? undefined;
310
+ const tokenChanged = _tokenSubscriptionLive && prevAuthToken !== _currentAuthToken;
238
311
  // Quota store hydrate requests carry the current access token; configure
239
312
  // here so post-login state is picked up. Best-effort: a missing
240
313
  // BridgeAuth means SDK isn't fully initialized yet — skip.
@@ -254,29 +327,28 @@ export function startBridgeRuntime(options = {}) {
254
327
  // its anonymous app-id auth.
255
328
  _realtime.setUserId(undefined);
256
329
  _realtime.setWorkspaceId(undefined);
330
+ // Reconnect as the signed-out session now, rather than riding the old
331
+ // user's socket until something else drops it.
332
+ if (tokenChanged)
333
+ reauthorizeForTokenChange();
257
334
  return;
258
335
  }
259
336
  const claims = decodeJwtPayload(tokens.accessToken);
260
- if (!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();
337
+ if (claims) {
338
+ // Wire all three SDK channel scopes from the JWT. `aid` → app, `tid` →
339
+ // workspace, `sub` → user. Each setter is idempotent; only changes
340
+ // trigger a reconnect.
341
+ _realtime.setAppId(typeof claims.aid === 'string' ? claims.aid : undefined);
342
+ _realtime.setWorkspaceId(typeof claims.tid === 'string' ? claims.tid : undefined);
343
+ _realtime.setUserId(typeof claims.sub === 'string' ? claims.sub : undefined);
278
344
  }
345
+ // setUserId is a no-op when the user is unchanged (token-only refresh),
346
+ // and a setter-driven reconnect waits out a backoff and cannot lift a
347
+ // parked refusal — so reauthorize explicitly on every value change.
348
+ if (tokenChanged)
349
+ reauthorizeForTokenChange();
279
350
  });
351
+ _tokenSubscriptionLive = true;
280
352
  // Best-effort start. RealtimeClient gracefully no-ops if the workspace's
281
353
  // `/realtime/config` returns `kind: 'noop'`.
282
354
  void _realtime.start();
@@ -346,6 +418,17 @@ export function onBridgeRealtimeSnapshot(handler) {
346
418
  _onSnapshotSubs.add(handler);
347
419
  return () => _onSnapshotSubs.delete(handler);
348
420
  }
421
+ /**
422
+ * Subscribe to realtime status changes (TBP-644): state, the machine-readable
423
+ * reason, whose side a fault is on (`app` / `config` / `bridge` / `network`),
424
+ * whether the client is still retrying, a docs link and a support ref.
425
+ * Fires on every change, not with the current value — read
426
+ * `realtimeStatusDetail` for that. Returns an unsubscribe fn.
427
+ */
428
+ export function onBridgeRealtimeStatus(handler) {
429
+ _onStatusSubs.add(handler);
430
+ return () => _onStatusSubs.delete(handler);
431
+ }
349
432
  /**
350
433
  * Subscribe to server-side `user.state_changed` signals (fired before the
351
434
  * runtime triggers a token refresh). Useful for debug overlays + tests.
@@ -364,6 +447,7 @@ export function __resetBridgeRuntime() {
364
447
  _onSnapshotSubs.clear();
365
448
  _onFlagChangeSubs.clear();
366
449
  _onUserStateSubs.clear();
450
+ _onStatusSubs.clear();
367
451
  _currentAuthToken = undefined;
368
452
  if (_unsubscribeAuth) {
369
453
  _unsubscribeAuth();