@nebulr-group/bridge-svelte 0.3.2 → 0.4.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.d.ts +1 -1
- package/dist/client/BridgeBootstrap.js +104 -27
- package/dist/client/BridgeBootstrap.svelte +65 -4
- package/dist/client/BridgeBootstrap.svelte.d.ts +2 -0
- package/dist/client/BridgeProvider.svelte +31 -0
- package/dist/client/BridgeProvider.svelte.d.ts +8 -0
- package/dist/client/components/developer/ApiTokenManagement.svelte +1 -1
- package/dist/client/components/sdk-auth/LoginForm.svelte +10 -7
- package/dist/client/components/sdk-auth/LoginForm.svelte.d.ts +1 -1
- package/dist/client/components/sdk-auth/PasskeyLogin.svelte +1 -1
- package/dist/client/components/sdk-auth/PasskeySetup.svelte +1 -1
- package/dist/client/components/sdk-auth/SsoButton.svelte +1 -1
- package/dist/client/components/sdk-auth/SsoButton.svelte.d.ts +1 -1
- package/dist/client/components/sdk-auth/TenantSelector.svelte +1 -1
- package/dist/client/components/sdk-auth/TenantSelector.svelte.d.ts +1 -1
- package/dist/client/components/sdk-auth/WorkspaceSelector.svelte +3 -3
- package/dist/client/components/sdk-auth/WorkspaceSelector.svelte.d.ts +3 -3
- package/dist/client/components/subscription/BridgeBillingNotice.svelte +389 -0
- package/dist/client/components/subscription/BridgeBillingNotice.svelte.d.ts +19 -0
- package/dist/client/components/subscription/BridgePaywall.svelte +94 -0
- package/dist/client/components/subscription/BridgePaywall.svelte.d.ts +19 -0
- package/dist/client/components/subscription/BridgeQuotaBanner.svelte +275 -0
- package/dist/client/components/subscription/BridgeQuotaBanner.svelte.d.ts +24 -0
- package/dist/client/components/subscription/BridgeSubscriptionStatus.svelte +116 -0
- package/dist/client/components/subscription/BridgeSubscriptionStatus.svelte.d.ts +7 -0
- package/dist/client/components/subscription/PlanSelector.svelte +22 -14
- package/dist/client/components/subscription/PlanSelector.svelte.d.ts +3 -3
- package/dist/client/components/subscription/billing-notice-gate.test.d.ts +1 -0
- package/dist/client/components/subscription/billing-notice-gate.test.js +83 -0
- package/dist/client/components/team/TeamAddUserDialog.svelte +1 -1
- package/dist/client/components/team/TeamAddUserDialog.svelte.d.ts +1 -1
- package/dist/client/components/team/TeamEditUserDialog.svelte +1 -1
- package/dist/client/components/team/TeamEditUserDialog.svelte.d.ts +1 -1
- package/dist/client/components/team/TeamProfileForm.svelte +1 -1
- package/dist/client/components/team/TeamUserList.svelte +1 -1
- package/dist/client/components/team/TeamWorkspaceForm.svelte +1 -1
- package/dist/client/tracking/pii-hashing.spec.js +4 -14
- package/dist/core/bridge-instance.d.ts +1 -2
- package/dist/core/bridge-instance.js +0 -4
- package/dist/core/bridge-runtime.d.ts +101 -0
- package/dist/core/bridge-runtime.js +290 -0
- package/dist/core/bridge-runtime.test.d.ts +1 -0
- package/dist/core/bridge-runtime.test.js +203 -0
- package/dist/core/bridge.d.ts +93 -0
- package/dist/core/bridge.js +108 -0
- package/dist/core/bridge.test.d.ts +1 -0
- package/dist/core/bridge.test.js +224 -0
- package/dist/core/events.d.ts +74 -0
- package/dist/core/events.js +64 -0
- package/dist/core/events.test.d.ts +1 -0
- package/dist/core/events.test.js +116 -0
- package/dist/core/lazy-slice.d.ts +74 -0
- package/dist/core/lazy-slice.js +124 -0
- package/dist/core/lazy-slice.test.d.ts +1 -0
- package/dist/core/lazy-slice.test.js +144 -0
- package/dist/core/realtime-status.d.ts +6 -0
- package/dist/core/realtime-status.js +15 -0
- package/dist/core/snapshot-stores.d.ts +69 -0
- package/dist/core/snapshot-stores.js +64 -0
- package/dist/core/use-bridge.d.ts +13 -0
- package/dist/core/use-bridge.js +46 -0
- package/dist/core/use-bridge.test.d.ts +1 -0
- package/dist/core/use-bridge.test.js +24 -0
- package/dist/flags/FeatureFlag.svelte +57 -0
- package/dist/flags/FeatureFlag.svelte.d.ts +37 -0
- package/dist/flags/bootstrap.d.ts +72 -0
- package/dist/flags/bootstrap.js +323 -0
- package/dist/flags/bootstrap.test.d.ts +1 -0
- package/dist/flags/bootstrap.test.js +195 -0
- package/dist/flags/flag.svelte.d.ts +31 -0
- package/dist/flags/flag.svelte.js +99 -0
- package/dist/flags/index.d.ts +8 -0
- package/dist/flags/index.js +20 -0
- package/dist/flags/index.test.d.ts +1 -0
- package/dist/flags/index.test.js +81 -0
- package/dist/flags/realtime-status.d.ts +1 -0
- package/dist/flags/realtime-status.js +4 -0
- package/dist/flags/registry.d.ts +27 -0
- package/dist/flags/registry.js +63 -0
- package/dist/flags/us13-integration.test.d.ts +1 -0
- package/dist/flags/us13-integration.test.js +168 -0
- package/dist/index.d.ts +14 -1
- package/dist/index.js +31 -2
- package/dist/styles.d.ts +2 -0
- package/package.json +92 -86
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
import { describe, it, expect
|
|
2
|
-
import { webcrypto } from 'node:crypto';
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
3
2
|
import { sha256Email } from './pii-hashing.js';
|
|
4
3
|
/**
|
|
5
4
|
* Client-side PII hashing fixtures.
|
|
@@ -17,18 +16,9 @@ import { sha256Email } from './pii-hashing.js';
|
|
|
17
16
|
const USER_AT_EXAMPLE_COM_HASH = 'b4c9a289323b21a01c3e940f150eb9b8c542587f1abfd8f0e1cc1ffc5e475514';
|
|
18
17
|
// SHA-256 hex of "admin@gmail.com"
|
|
19
18
|
const ADMIN_AT_GMAIL_COM_HASH = '7932b2e116b076a54f452848eaabd5857f61bd957fe8a218faf216f24c9885bb';
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
* that `sha256Email` expects. We defensively wire it up here so the test is
|
|
24
|
-
* robust across Node versions.
|
|
25
|
-
*/
|
|
26
|
-
beforeAll(() => {
|
|
27
|
-
if (!globalThis.crypto || typeof globalThis.crypto.subtle?.digest !== 'function') {
|
|
28
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
29
|
-
globalThis.crypto = webcrypto;
|
|
30
|
-
}
|
|
31
|
-
});
|
|
19
|
+
// Vitest's `environment: 'node'` exposes the Web Crypto API at
|
|
20
|
+
// `globalThis.crypto` (Node 19+), with the same `crypto.subtle.digest()` the
|
|
21
|
+
// browser provides and `sha256Email` expects.
|
|
32
22
|
describe('sha256Email (client)', () => {
|
|
33
23
|
it('hashes a plain, already-normalized email to the known SHA-256 hex (matches server fixture)', async () => {
|
|
34
24
|
await expect(sha256Email('user@example.com')).resolves.toBe(USER_AT_EXAMPLE_COM_HASH);
|
|
@@ -45,7 +45,7 @@ export declare const flagsStore: Readable<Record<string, boolean>>;
|
|
|
45
45
|
export declare const bridgeReadyStore: Readable<boolean>;
|
|
46
46
|
/** App-level config (SSO providers, feature flags, etc.) — loaded anonymously on init */
|
|
47
47
|
export declare const appConfigStore: Readable<AppConfig | null>;
|
|
48
|
-
interface SubscriptionState {
|
|
48
|
+
export interface SubscriptionState {
|
|
49
49
|
status: SubscriptionStatus | null;
|
|
50
50
|
plans: Plan[] | null;
|
|
51
51
|
loading: boolean;
|
|
@@ -59,4 +59,3 @@ export declare const auth: BridgeAuth;
|
|
|
59
59
|
export declare const _flagsWritable: Writable<Record<string, boolean>>;
|
|
60
60
|
export declare const _profileWritable: Writable<Profile | null | undefined>;
|
|
61
61
|
export declare const _errorWritable: Writable<string | null>;
|
|
62
|
-
export {};
|
|
@@ -84,10 +84,6 @@ export function initBridge(config) {
|
|
|
84
84
|
_instance.on('auth:error', (err) => {
|
|
85
85
|
_error.set(err.message);
|
|
86
86
|
});
|
|
87
|
-
// Load app config anonymously (drives SSO buttons, signup toggle, etc.).
|
|
88
|
-
// Cached in `_appConfigPromise` so LoginForm (and others) can await the
|
|
89
|
-
// result via `ensureAppConfig()` instead of racing the store update.
|
|
90
|
-
void ensureAppConfig();
|
|
91
87
|
logger.debug('[bridge-instance] initialized');
|
|
92
88
|
return _instance;
|
|
93
89
|
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bridge core runtime — the realtime + reactive-identity wiring that every
|
|
3
|
+
* Bridge capability (auth, flags, billing, ...) rides on top of.
|
|
4
|
+
*
|
|
5
|
+
* Until TBP-Live-Channel-Unification this lived inside `/flags/bootstrap.ts`,
|
|
6
|
+
* which was misleading: the realtime client, the per-channel auth scoping,
|
|
7
|
+
* the session.snapshot fanout and the billing-family event dispatch are not
|
|
8
|
+
* flag-specific. Hoisting them here lets `<BridgeBootstrap />` mount the
|
|
9
|
+
* runtime once and any capability (flags, billing) attach onto the same
|
|
10
|
+
* RealtimeClient instance.
|
|
11
|
+
*
|
|
12
|
+
* What this module does on `startBridgeRuntime()`:
|
|
13
|
+
*
|
|
14
|
+
* 1. Constructs a single `RealtimeClient` using `appId` + `apiBaseUrl` from
|
|
15
|
+
* `bridgeConfig.initConfig({...})` (auth's `getConfig()` is the single
|
|
16
|
+
* source of truth — no separate API base URL config for flags).
|
|
17
|
+
* 2. Calls `useBridge().attachToRealtimeClient(realtime)` so the billing
|
|
18
|
+
* stores (subscription, quotas, entitlements) react to live pushes.
|
|
19
|
+
* 3. Wires `setOnOpen` / `setOnClose` to mirror connection state into the
|
|
20
|
+
* reactive `realtimeStatus` store.
|
|
21
|
+
* 4. Wires `setOnSnapshot` to call `applySessionSnapshot(...)` (drives every
|
|
22
|
+
* `useBridge()` reactive store) and dispatch the snapshot event through
|
|
23
|
+
* `bridgeEvents`.
|
|
24
|
+
* 5. Wires `setOnUserState` so a server-side claims-change signal forces a
|
|
25
|
+
* `refreshTokens()` on BridgeAuth — the fresh JWT then flows back through
|
|
26
|
+
* the tokenStore subscription below.
|
|
27
|
+
* 6. Subscribes to `tokenStore`: identity tracking for the realtime channel
|
|
28
|
+
* (setAppId/setWorkspaceId/setUserId), explicit reauthorize on
|
|
29
|
+
* token-only refresh, and re-configuration of the `useBridge().quotas`
|
|
30
|
+
* HTTP options so quota hydrate requests carry the current access token.
|
|
31
|
+
* 7. Registers the canonical billing-family event handlers via
|
|
32
|
+
* `useBridge().handle({...})` so `subscription.*` / `payment.*` /
|
|
33
|
+
* `dunning.*` / `quota.updated` / `entitlements.changed` flow into
|
|
34
|
+
* `bridgeEvents._dispatch()`.
|
|
35
|
+
* 8. Exposes chainable `onBridgeRealtimeOpen` / `onBridgeRealtimeClose` /
|
|
36
|
+
* `onBridgeRealtimeSnapshot` subscriber sets so individual capability
|
|
37
|
+
* bootstrappers (flag attach, etc.) can layer their own behavior without
|
|
38
|
+
* clobbering the core handlers (`setOn*` on RealtimeClient is single-slot).
|
|
39
|
+
*
|
|
40
|
+
* `startBridgeRuntime()` is idempotent — repeated calls return the existing
|
|
41
|
+
* instance. Call `stopBridgeRuntime()` (e.g. on `<BridgeBootstrap />` destroy)
|
|
42
|
+
* to flush the realtime client and unsubscribe from the token store.
|
|
43
|
+
*/
|
|
44
|
+
import { RealtimeClient, type RealtimeClientConfig, type SessionSnapshotMessage } from '@nebulr-group/bridge-auth-core';
|
|
45
|
+
/**
|
|
46
|
+
* Advanced runtime overrides. Product consumers never pass these; tests,
|
|
47
|
+
* Storybook harnesses, and the demo workspace use them to override the
|
|
48
|
+
* realtime transport (e.g. inject a debug-instrumented WebSocket) or watch
|
|
49
|
+
* `user.state_changed` signals for visualization.
|
|
50
|
+
*/
|
|
51
|
+
export interface StartBridgeRuntimeOptions {
|
|
52
|
+
/**
|
|
53
|
+
* Pass-through realtime overrides. `apiBaseUrl`, `apiKey`, `appId`, and
|
|
54
|
+
* `getAuthToken` are owned by the runtime and ignored here.
|
|
55
|
+
*/
|
|
56
|
+
realtime?: Partial<Omit<RealtimeClientConfig, 'apiBaseUrl' | 'apiKey' | 'appId' | 'getAuthToken'>>;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Start the Bridge runtime. Idempotent — repeated calls are a no-op. Reads
|
|
60
|
+
* `appId` + `apiBaseUrl` from the auth config store (`bridgeConfig.initConfig`).
|
|
61
|
+
* Must be called AFTER `bridgeConfig.initConfig({...})` runs — typically from
|
|
62
|
+
* `<BridgeBootstrap />`'s onMount.
|
|
63
|
+
*/
|
|
64
|
+
export declare function startBridgeRuntime(options?: StartBridgeRuntimeOptions): void;
|
|
65
|
+
/**
|
|
66
|
+
* Stop the runtime. Idempotent — safe to call without a prior start. Flushes
|
|
67
|
+
* the realtime client and unsubscribes from the token store. Subscriber sets
|
|
68
|
+
* are NOT cleared so re-start picks up existing capability extensions.
|
|
69
|
+
*/
|
|
70
|
+
export declare function stopBridgeRuntime(): Promise<void>;
|
|
71
|
+
/**
|
|
72
|
+
* Get the shared RealtimeClient. Returns `undefined` if `startBridgeRuntime()`
|
|
73
|
+
* hasn't run yet. Used by capability bootstrappers (e.g. flag attach) to
|
|
74
|
+
* register their own bridge/cache against the same channel.
|
|
75
|
+
*/
|
|
76
|
+
export declare function getBridgeRealtime(): RealtimeClient | undefined;
|
|
77
|
+
/**
|
|
78
|
+
* Get the current access token cached for the realtime client's
|
|
79
|
+
* `getAuthToken` closure. Capability bootstrappers may need it for their own
|
|
80
|
+
* auth-sensitive operations (e.g. flag hydrate). Returns `undefined` when
|
|
81
|
+
* the user is logged out.
|
|
82
|
+
*/
|
|
83
|
+
export declare function getCurrentAuthToken(): string | undefined;
|
|
84
|
+
/** Subscribe to realtime `open` events. Returns an unsubscribe fn. */
|
|
85
|
+
export declare function onBridgeRealtimeOpen(handler: () => void): () => void;
|
|
86
|
+
/** Subscribe to realtime `close` events. Returns an unsubscribe fn. */
|
|
87
|
+
export declare function onBridgeRealtimeClose(handler: () => void): () => void;
|
|
88
|
+
/** Subscribe to `session.snapshot` messages. Returns an unsubscribe fn. */
|
|
89
|
+
export declare function onBridgeRealtimeSnapshot(handler: (msg: SessionSnapshotMessage) => void): () => void;
|
|
90
|
+
/**
|
|
91
|
+
* Subscribe to server-side `user.state_changed` signals (fired before the
|
|
92
|
+
* runtime triggers a token refresh). Useful for debug overlays + tests.
|
|
93
|
+
*/
|
|
94
|
+
export declare function onBridgeRealtimeUserState(handler: (event: {
|
|
95
|
+
reason: string;
|
|
96
|
+
}) => void): () => void;
|
|
97
|
+
/**
|
|
98
|
+
* Test-only — reset module-level state. Use between unit tests to avoid
|
|
99
|
+
* cross-test bleed in the realtime client + subscriber sets.
|
|
100
|
+
*/
|
|
101
|
+
export declare function __resetBridgeRuntime(): void;
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bridge core runtime — the realtime + reactive-identity wiring that every
|
|
3
|
+
* Bridge capability (auth, flags, billing, ...) rides on top of.
|
|
4
|
+
*
|
|
5
|
+
* Until TBP-Live-Channel-Unification this lived inside `/flags/bootstrap.ts`,
|
|
6
|
+
* which was misleading: the realtime client, the per-channel auth scoping,
|
|
7
|
+
* the session.snapshot fanout and the billing-family event dispatch are not
|
|
8
|
+
* flag-specific. Hoisting them here lets `<BridgeBootstrap />` mount the
|
|
9
|
+
* runtime once and any capability (flags, billing) attach onto the same
|
|
10
|
+
* RealtimeClient instance.
|
|
11
|
+
*
|
|
12
|
+
* What this module does on `startBridgeRuntime()`:
|
|
13
|
+
*
|
|
14
|
+
* 1. Constructs a single `RealtimeClient` using `appId` + `apiBaseUrl` from
|
|
15
|
+
* `bridgeConfig.initConfig({...})` (auth's `getConfig()` is the single
|
|
16
|
+
* source of truth — no separate API base URL config for flags).
|
|
17
|
+
* 2. Calls `useBridge().attachToRealtimeClient(realtime)` so the billing
|
|
18
|
+
* stores (subscription, quotas, entitlements) react to live pushes.
|
|
19
|
+
* 3. Wires `setOnOpen` / `setOnClose` to mirror connection state into the
|
|
20
|
+
* reactive `realtimeStatus` store.
|
|
21
|
+
* 4. Wires `setOnSnapshot` to call `applySessionSnapshot(...)` (drives every
|
|
22
|
+
* `useBridge()` reactive store) and dispatch the snapshot event through
|
|
23
|
+
* `bridgeEvents`.
|
|
24
|
+
* 5. Wires `setOnUserState` so a server-side claims-change signal forces a
|
|
25
|
+
* `refreshTokens()` on BridgeAuth — the fresh JWT then flows back through
|
|
26
|
+
* the tokenStore subscription below.
|
|
27
|
+
* 6. Subscribes to `tokenStore`: identity tracking for the realtime channel
|
|
28
|
+
* (setAppId/setWorkspaceId/setUserId), explicit reauthorize on
|
|
29
|
+
* token-only refresh, and re-configuration of the `useBridge().quotas`
|
|
30
|
+
* HTTP options so quota hydrate requests carry the current access token.
|
|
31
|
+
* 7. Registers the canonical billing-family event handlers via
|
|
32
|
+
* `useBridge().handle({...})` so `subscription.*` / `payment.*` /
|
|
33
|
+
* `dunning.*` / `quota.updated` / `entitlements.changed` flow into
|
|
34
|
+
* `bridgeEvents._dispatch()`.
|
|
35
|
+
* 8. Exposes chainable `onBridgeRealtimeOpen` / `onBridgeRealtimeClose` /
|
|
36
|
+
* `onBridgeRealtimeSnapshot` subscriber sets so individual capability
|
|
37
|
+
* bootstrappers (flag attach, etc.) can layer their own behavior without
|
|
38
|
+
* clobbering the core handlers (`setOn*` on RealtimeClient is single-slot).
|
|
39
|
+
*
|
|
40
|
+
* `startBridgeRuntime()` is idempotent — repeated calls return the existing
|
|
41
|
+
* instance. Call `stopBridgeRuntime()` (e.g. on `<BridgeBootstrap />` destroy)
|
|
42
|
+
* to flush the realtime client and unsubscribe from the token store.
|
|
43
|
+
*/
|
|
44
|
+
import { RealtimeClient, useBridge, } from '@nebulr-group/bridge-auth-core';
|
|
45
|
+
import { getConfig } from '../client/stores/config.store.js';
|
|
46
|
+
import { getBridgeAuth, tokenStore } from './bridge-instance.js';
|
|
47
|
+
import { applySessionSnapshot } from './snapshot-stores.js';
|
|
48
|
+
import { bridgeEvents } from './events.js';
|
|
49
|
+
import { _setRealtimeStatus } from './realtime-status.js';
|
|
50
|
+
let _realtime;
|
|
51
|
+
let _unsubscribeAuth;
|
|
52
|
+
let _currentAuthToken;
|
|
53
|
+
const _onOpenSubs = new Set();
|
|
54
|
+
const _onCloseSubs = new Set();
|
|
55
|
+
const _onSnapshotSubs = new Set();
|
|
56
|
+
const _onUserStateSubs = new Set();
|
|
57
|
+
/**
|
|
58
|
+
* Start the Bridge runtime. Idempotent — repeated calls are a no-op. Reads
|
|
59
|
+
* `appId` + `apiBaseUrl` from the auth config store (`bridgeConfig.initConfig`).
|
|
60
|
+
* Must be called AFTER `bridgeConfig.initConfig({...})` runs — typically from
|
|
61
|
+
* `<BridgeBootstrap />`'s onMount.
|
|
62
|
+
*/
|
|
63
|
+
export function startBridgeRuntime(options = {}) {
|
|
64
|
+
if (_realtime)
|
|
65
|
+
return;
|
|
66
|
+
const config = getConfig();
|
|
67
|
+
// `appId` may come from BridgeAuth's API context if available; falls back
|
|
68
|
+
// to the value from `getConfig()`. The auth context one is what gets bound
|
|
69
|
+
// to the per-app channel scope at boot, before any user JWT arrives.
|
|
70
|
+
let _bootstrapAppId = config.appId;
|
|
71
|
+
try {
|
|
72
|
+
_bootstrapAppId = getBridgeAuth().getApiContext().appId ?? config.appId;
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
// BridgeAuth singleton not constructed yet — fall through with config.appId.
|
|
76
|
+
}
|
|
77
|
+
_realtime = new RealtimeClient({
|
|
78
|
+
...(options.realtime ?? {}),
|
|
79
|
+
apiBaseUrl: config.apiBaseUrl ?? 'https://api.thebridge.dev',
|
|
80
|
+
apiKey: config.appId,
|
|
81
|
+
appId: _bootstrapAppId,
|
|
82
|
+
getAuthToken: () => _currentAuthToken,
|
|
83
|
+
});
|
|
84
|
+
_realtime.setOnOpen(() => {
|
|
85
|
+
_setRealtimeStatus('open');
|
|
86
|
+
for (const fn of _onOpenSubs) {
|
|
87
|
+
try {
|
|
88
|
+
fn();
|
|
89
|
+
}
|
|
90
|
+
catch { /* subscriber errors swallowed */ }
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
_realtime.setOnClose(() => {
|
|
94
|
+
_setRealtimeStatus('closed');
|
|
95
|
+
for (const fn of _onCloseSubs) {
|
|
96
|
+
try {
|
|
97
|
+
fn();
|
|
98
|
+
}
|
|
99
|
+
catch { /* subscriber errors swallowed */ }
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
_realtime.setOnSnapshot((msg) => {
|
|
103
|
+
try {
|
|
104
|
+
applySessionSnapshot(msg.data);
|
|
105
|
+
}
|
|
106
|
+
catch { /* store updates shouldn't throw, defensive */ }
|
|
107
|
+
bridgeEvents._dispatch(msg);
|
|
108
|
+
for (const fn of _onSnapshotSubs) {
|
|
109
|
+
try {
|
|
110
|
+
fn(msg);
|
|
111
|
+
}
|
|
112
|
+
catch { /* subscriber errors swallowed */ }
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
// user.state_changed → JWT refresh. The fresh tokens flow back through the
|
|
116
|
+
// tokenStore subscription below and re-bind channel scopes / re-eval flags.
|
|
117
|
+
_realtime.setOnUserState(async (msg) => {
|
|
118
|
+
for (const fn of _onUserStateSubs) {
|
|
119
|
+
try {
|
|
120
|
+
fn({ reason: msg.reason });
|
|
121
|
+
}
|
|
122
|
+
catch { /* subscriber errors swallowed */ }
|
|
123
|
+
}
|
|
124
|
+
try {
|
|
125
|
+
await getBridgeAuth().refreshTokens();
|
|
126
|
+
}
|
|
127
|
+
catch { /* next scheduled refresh will pick it up */ }
|
|
128
|
+
});
|
|
129
|
+
// Billing 2.0 US-11 — bind the billing stores to this realtime client so
|
|
130
|
+
// subscription / quotas / entitlements react to live pushes.
|
|
131
|
+
useBridge().attachToRealtimeClient(_realtime);
|
|
132
|
+
// Phase 5 (TBP-331) + TBP-360 — billing-family events flow through the
|
|
133
|
+
// unified bridge events surface via `useBridge().handle({...})`.
|
|
134
|
+
useBridge().handle({
|
|
135
|
+
'subscription.plan_changed': (msg) => bridgeEvents._dispatch(msg),
|
|
136
|
+
'payment.failed': (msg) => bridgeEvents._dispatch(msg),
|
|
137
|
+
'payment.succeeded': (msg) => bridgeEvents._dispatch(msg),
|
|
138
|
+
'subscription.created': (msg) => bridgeEvents._dispatch(msg),
|
|
139
|
+
'subscription.updated': (msg) => bridgeEvents._dispatch(msg),
|
|
140
|
+
'subscription.canceled': (msg) => bridgeEvents._dispatch(msg),
|
|
141
|
+
'subscription.reactivated': (msg) => bridgeEvents._dispatch(msg),
|
|
142
|
+
'subscription.trial_started': (msg) => bridgeEvents._dispatch(msg),
|
|
143
|
+
'subscription.trial_ending_soon': (msg) => bridgeEvents._dispatch(msg),
|
|
144
|
+
'subscription.trial_converted': (msg) => bridgeEvents._dispatch(msg),
|
|
145
|
+
'subscription.trial_expired': (msg) => bridgeEvents._dispatch(msg),
|
|
146
|
+
'dunning.entered': (msg) => bridgeEvents._dispatch(msg),
|
|
147
|
+
'dunning.retry_scheduled': (msg) => bridgeEvents._dispatch(msg),
|
|
148
|
+
'dunning.recovered': (msg) => bridgeEvents._dispatch(msg),
|
|
149
|
+
'dunning.exhausted': (msg) => bridgeEvents._dispatch(msg),
|
|
150
|
+
'quota.updated': (msg) => bridgeEvents._dispatch(msg),
|
|
151
|
+
'entitlements.changed': (msg) => bridgeEvents._dispatch(msg),
|
|
152
|
+
});
|
|
153
|
+
// Token store subscription — owns realtime channel scoping + quotas HTTP
|
|
154
|
+
// options + reauthorize on token-only refresh. Capability-specific subs
|
|
155
|
+
// (e.g. flag eval context) are layered on top by their own bootstrappers.
|
|
156
|
+
const apiBaseUrl = config.apiBaseUrl ?? 'https://api.thebridge.dev';
|
|
157
|
+
_unsubscribeAuth = tokenStore.subscribe((tokens) => {
|
|
158
|
+
const prevAuthToken = _currentAuthToken;
|
|
159
|
+
_currentAuthToken = tokens?.accessToken;
|
|
160
|
+
// Quota store hydrate requests carry the current access token; configure
|
|
161
|
+
// here so post-login state is picked up. Best-effort: a missing
|
|
162
|
+
// BridgeAuth means SDK isn't fully initialized yet — skip.
|
|
163
|
+
try {
|
|
164
|
+
const auth = getBridgeAuth();
|
|
165
|
+
useBridge().quotas.configure({
|
|
166
|
+
apiBaseUrl,
|
|
167
|
+
appId: auth.getApiContext().appId,
|
|
168
|
+
accessToken: tokens?.accessToken ?? null,
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
catch {
|
|
172
|
+
// No BridgeAuth yet — quota hydration falls back to live pushes only.
|
|
173
|
+
}
|
|
174
|
+
if (!tokens?.accessToken) {
|
|
175
|
+
// Logout — drop user + workspace channel scopes. The app channel keeps
|
|
176
|
+
// its anonymous app-id auth.
|
|
177
|
+
_realtime.setUserId(undefined);
|
|
178
|
+
_realtime.setWorkspaceId(undefined);
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
const claims = decodeJwtPayload(tokens.accessToken);
|
|
182
|
+
if (!claims)
|
|
183
|
+
return;
|
|
184
|
+
// Wire all three SDK channel scopes from the JWT. `aid` → app, `tid` →
|
|
185
|
+
// workspace, `sub` → user. Each setter is idempotent; only changes trigger
|
|
186
|
+
// a reconnect.
|
|
187
|
+
_realtime.setAppId(typeof claims.aid === 'string' ? claims.aid : undefined);
|
|
188
|
+
_realtime.setWorkspaceId(typeof claims.tid === 'string' ? claims.tid : undefined);
|
|
189
|
+
_realtime.setUserId(typeof claims.sub === 'string' ? claims.sub : undefined);
|
|
190
|
+
// Token-only refresh (same user, new JWT): setUserId is a no-op when
|
|
191
|
+
// userId hasn't changed, leaving the channel riding the OLD JWT until
|
|
192
|
+
// Centrifugo's connection-token TTL drops it. Force a reauthorize so the
|
|
193
|
+
// server re-validates against the new token immediately.
|
|
194
|
+
if (prevAuthToken && _currentAuthToken && prevAuthToken !== _currentAuthToken) {
|
|
195
|
+
void _realtime.reauthorize();
|
|
196
|
+
}
|
|
197
|
+
});
|
|
198
|
+
// Best-effort start. RealtimeClient gracefully no-ops if the workspace's
|
|
199
|
+
// `/realtime/config` returns `kind: 'noop'`.
|
|
200
|
+
void _realtime.start();
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* Stop the runtime. Idempotent — safe to call without a prior start. Flushes
|
|
204
|
+
* the realtime client and unsubscribes from the token store. Subscriber sets
|
|
205
|
+
* are NOT cleared so re-start picks up existing capability extensions.
|
|
206
|
+
*/
|
|
207
|
+
export async function stopBridgeRuntime() {
|
|
208
|
+
if (_unsubscribeAuth) {
|
|
209
|
+
_unsubscribeAuth();
|
|
210
|
+
_unsubscribeAuth = undefined;
|
|
211
|
+
}
|
|
212
|
+
if (_realtime) {
|
|
213
|
+
try {
|
|
214
|
+
await _realtime.stop();
|
|
215
|
+
}
|
|
216
|
+
catch { /* already stopped, ignore */ }
|
|
217
|
+
_realtime = undefined;
|
|
218
|
+
}
|
|
219
|
+
_currentAuthToken = undefined;
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* Get the shared RealtimeClient. Returns `undefined` if `startBridgeRuntime()`
|
|
223
|
+
* hasn't run yet. Used by capability bootstrappers (e.g. flag attach) to
|
|
224
|
+
* register their own bridge/cache against the same channel.
|
|
225
|
+
*/
|
|
226
|
+
export function getBridgeRealtime() {
|
|
227
|
+
return _realtime;
|
|
228
|
+
}
|
|
229
|
+
/**
|
|
230
|
+
* Get the current access token cached for the realtime client's
|
|
231
|
+
* `getAuthToken` closure. Capability bootstrappers may need it for their own
|
|
232
|
+
* auth-sensitive operations (e.g. flag hydrate). Returns `undefined` when
|
|
233
|
+
* the user is logged out.
|
|
234
|
+
*/
|
|
235
|
+
export function getCurrentAuthToken() {
|
|
236
|
+
return _currentAuthToken;
|
|
237
|
+
}
|
|
238
|
+
/** Subscribe to realtime `open` events. Returns an unsubscribe fn. */
|
|
239
|
+
export function onBridgeRealtimeOpen(handler) {
|
|
240
|
+
_onOpenSubs.add(handler);
|
|
241
|
+
return () => _onOpenSubs.delete(handler);
|
|
242
|
+
}
|
|
243
|
+
/** Subscribe to realtime `close` events. Returns an unsubscribe fn. */
|
|
244
|
+
export function onBridgeRealtimeClose(handler) {
|
|
245
|
+
_onCloseSubs.add(handler);
|
|
246
|
+
return () => _onCloseSubs.delete(handler);
|
|
247
|
+
}
|
|
248
|
+
/** Subscribe to `session.snapshot` messages. Returns an unsubscribe fn. */
|
|
249
|
+
export function onBridgeRealtimeSnapshot(handler) {
|
|
250
|
+
_onSnapshotSubs.add(handler);
|
|
251
|
+
return () => _onSnapshotSubs.delete(handler);
|
|
252
|
+
}
|
|
253
|
+
/**
|
|
254
|
+
* Subscribe to server-side `user.state_changed` signals (fired before the
|
|
255
|
+
* runtime triggers a token refresh). Useful for debug overlays + tests.
|
|
256
|
+
*/
|
|
257
|
+
export function onBridgeRealtimeUserState(handler) {
|
|
258
|
+
_onUserStateSubs.add(handler);
|
|
259
|
+
return () => _onUserStateSubs.delete(handler);
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* Test-only — reset module-level state. Use between unit tests to avoid
|
|
263
|
+
* cross-test bleed in the realtime client + subscriber sets.
|
|
264
|
+
*/
|
|
265
|
+
export function __resetBridgeRuntime() {
|
|
266
|
+
_onOpenSubs.clear();
|
|
267
|
+
_onCloseSubs.clear();
|
|
268
|
+
_onSnapshotSubs.clear();
|
|
269
|
+
_onUserStateSubs.clear();
|
|
270
|
+
_currentAuthToken = undefined;
|
|
271
|
+
if (_unsubscribeAuth) {
|
|
272
|
+
_unsubscribeAuth();
|
|
273
|
+
_unsubscribeAuth = undefined;
|
|
274
|
+
}
|
|
275
|
+
_realtime = undefined;
|
|
276
|
+
}
|
|
277
|
+
// ── helpers ─────────────────────────────────────────────────────────────────
|
|
278
|
+
/** Decode a JWT payload without signature verification (client context only). */
|
|
279
|
+
function decodeJwtPayload(token) {
|
|
280
|
+
try {
|
|
281
|
+
const part = token.split('.')[1];
|
|
282
|
+
if (!part)
|
|
283
|
+
return null;
|
|
284
|
+
const json = atob(part.replace(/-/g, '+').replace(/_/g, '/'));
|
|
285
|
+
return JSON.parse(json);
|
|
286
|
+
}
|
|
287
|
+
catch {
|
|
288
|
+
return null;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
// Unit tests for the hoisted Bridge core runtime.
|
|
2
|
+
//
|
|
3
|
+
// Covers the contract surface around `startBridgeRuntime` / subscriber sets /
|
|
4
|
+
// `getBridgeRealtime`. Heavy stubbing — auth-core's RealtimeClient + useBridge
|
|
5
|
+
// are mocked so we can drive open/close/snapshot/user-state events and assert
|
|
6
|
+
// they fan out to subscribers + the realtimeStatus store + bridgeEvents.
|
|
7
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
8
|
+
import { writable, get } from 'svelte/store';
|
|
9
|
+
let _tokenStore;
|
|
10
|
+
// Captured event handlers so tests can fire them.
|
|
11
|
+
let _onOpen;
|
|
12
|
+
let _onClose;
|
|
13
|
+
let _onSnapshot;
|
|
14
|
+
let _onUserState;
|
|
15
|
+
const _channelScopeCalls = [];
|
|
16
|
+
const _reauthCalls = [];
|
|
17
|
+
let _startCalls = 0;
|
|
18
|
+
let _stopCalls = 0;
|
|
19
|
+
let _capturedRealtimeConfig;
|
|
20
|
+
// Reset the spy state between tests.
|
|
21
|
+
function resetSpies() {
|
|
22
|
+
_onOpen = _onClose = _onSnapshot = _onUserState = undefined;
|
|
23
|
+
_channelScopeCalls.length = 0;
|
|
24
|
+
_reauthCalls.length = 0;
|
|
25
|
+
_startCalls = 0;
|
|
26
|
+
_stopCalls = 0;
|
|
27
|
+
_capturedRealtimeConfig = undefined;
|
|
28
|
+
}
|
|
29
|
+
// ── Mocks ──────────────────────────────────────────────────────────────────
|
|
30
|
+
vi.mock('./bridge-instance.js', () => ({
|
|
31
|
+
get tokenStore() {
|
|
32
|
+
return _tokenStore;
|
|
33
|
+
},
|
|
34
|
+
getBridgeAuth: () => ({
|
|
35
|
+
getApiContext: () => ({ appId: 'app-1', accessToken: null }),
|
|
36
|
+
refreshTokens: async () => { },
|
|
37
|
+
}),
|
|
38
|
+
}));
|
|
39
|
+
vi.mock('../client/stores/config.store.js', () => ({
|
|
40
|
+
getConfig: () => ({ appId: 'app-1', apiBaseUrl: 'http://test' }),
|
|
41
|
+
}));
|
|
42
|
+
vi.mock('./snapshot-stores.js', () => ({
|
|
43
|
+
applySessionSnapshot: vi.fn(),
|
|
44
|
+
}));
|
|
45
|
+
vi.mock('./events.js', () => ({
|
|
46
|
+
bridgeEvents: { _dispatch: vi.fn() },
|
|
47
|
+
}));
|
|
48
|
+
vi.mock('@nebulr-group/bridge-auth-core', () => {
|
|
49
|
+
class FakeRealtimeClient {
|
|
50
|
+
constructor(config) {
|
|
51
|
+
_capturedRealtimeConfig = config;
|
|
52
|
+
}
|
|
53
|
+
setOnOpen(fn) { _onOpen = fn; }
|
|
54
|
+
setOnClose(fn) { _onClose = fn; }
|
|
55
|
+
setOnSnapshot(fn) { _onSnapshot = fn; }
|
|
56
|
+
setOnUserState(fn) { _onUserState = fn; }
|
|
57
|
+
setAppId(v) { _channelScopeCalls.push({ method: 'setAppId', value: v }); }
|
|
58
|
+
setWorkspaceId(v) { _channelScopeCalls.push({ method: 'setWorkspaceId', value: v }); }
|
|
59
|
+
setUserId(v) { _channelScopeCalls.push({ method: 'setUserId', value: v }); }
|
|
60
|
+
async reauthorize() { _reauthCalls.push(Date.now()); }
|
|
61
|
+
async start() { _startCalls++; }
|
|
62
|
+
async stop() { _stopCalls++; }
|
|
63
|
+
}
|
|
64
|
+
return {
|
|
65
|
+
RealtimeClient: FakeRealtimeClient,
|
|
66
|
+
useBridge: () => ({
|
|
67
|
+
quotas: { configure: vi.fn() },
|
|
68
|
+
attachToRealtimeClient: vi.fn(),
|
|
69
|
+
handle: vi.fn(() => () => { }),
|
|
70
|
+
}),
|
|
71
|
+
};
|
|
72
|
+
});
|
|
73
|
+
beforeEach(() => {
|
|
74
|
+
_tokenStore = writable(null);
|
|
75
|
+
resetSpies();
|
|
76
|
+
});
|
|
77
|
+
afterEach(async () => {
|
|
78
|
+
const { __resetBridgeRuntime, stopBridgeRuntime } = await import('./bridge-runtime.js');
|
|
79
|
+
await stopBridgeRuntime();
|
|
80
|
+
__resetBridgeRuntime();
|
|
81
|
+
});
|
|
82
|
+
// ── Imports under test ─────────────────────────────────────────────────────
|
|
83
|
+
import { startBridgeRuntime, stopBridgeRuntime, getBridgeRealtime, onBridgeRealtimeOpen, onBridgeRealtimeClose, onBridgeRealtimeSnapshot, onBridgeRealtimeUserState, } from './bridge-runtime.js';
|
|
84
|
+
import { realtimeStatus } from './realtime-status.js';
|
|
85
|
+
// ── Helpers ────────────────────────────────────────────────────────────────
|
|
86
|
+
function makeJwt(claims) {
|
|
87
|
+
const enc = (o) => btoa(JSON.stringify(o)).replace(/=+$/, '').replace(/\+/g, '-').replace(/\//g, '_');
|
|
88
|
+
return `${enc({ alg: 'HS256', typ: 'JWT' })}.${enc(claims)}.sig`;
|
|
89
|
+
}
|
|
90
|
+
// ── Tests ──────────────────────────────────────────────────────────────────
|
|
91
|
+
describe('startBridgeRuntime', () => {
|
|
92
|
+
it('is idempotent — repeated calls do not construct a second realtime client', () => {
|
|
93
|
+
startBridgeRuntime();
|
|
94
|
+
const first = getBridgeRealtime();
|
|
95
|
+
startBridgeRuntime();
|
|
96
|
+
const second = getBridgeRealtime();
|
|
97
|
+
expect(first).toBe(second);
|
|
98
|
+
expect(_startCalls).toBe(1);
|
|
99
|
+
});
|
|
100
|
+
it('reads apiBaseUrl + appId from getConfig()', () => {
|
|
101
|
+
startBridgeRuntime();
|
|
102
|
+
expect(_capturedRealtimeConfig?.apiBaseUrl).toBe('http://test');
|
|
103
|
+
expect(_capturedRealtimeConfig?.apiKey).toBe('app-1');
|
|
104
|
+
});
|
|
105
|
+
it('mirrors connection state into realtimeStatus on open/close', () => {
|
|
106
|
+
startBridgeRuntime();
|
|
107
|
+
_onOpen?.();
|
|
108
|
+
expect(get(realtimeStatus)).toBe('open');
|
|
109
|
+
_onClose?.();
|
|
110
|
+
expect(get(realtimeStatus)).toBe('closed');
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
describe('chainable subscriber sets', () => {
|
|
114
|
+
it('fans out open events to every subscriber', () => {
|
|
115
|
+
const a = vi.fn();
|
|
116
|
+
const b = vi.fn();
|
|
117
|
+
onBridgeRealtimeOpen(a);
|
|
118
|
+
onBridgeRealtimeOpen(b);
|
|
119
|
+
startBridgeRuntime();
|
|
120
|
+
_onOpen?.();
|
|
121
|
+
expect(a).toHaveBeenCalledTimes(1);
|
|
122
|
+
expect(b).toHaveBeenCalledTimes(1);
|
|
123
|
+
});
|
|
124
|
+
it('unsubscribes cleanly', () => {
|
|
125
|
+
const a = vi.fn();
|
|
126
|
+
const unsub = onBridgeRealtimeOpen(a);
|
|
127
|
+
startBridgeRuntime();
|
|
128
|
+
unsub();
|
|
129
|
+
_onOpen?.();
|
|
130
|
+
expect(a).not.toHaveBeenCalled();
|
|
131
|
+
});
|
|
132
|
+
it('isolates subscriber errors — one throwing handler does not block others', () => {
|
|
133
|
+
const a = vi.fn(() => { throw new Error('boom'); });
|
|
134
|
+
const b = vi.fn();
|
|
135
|
+
onBridgeRealtimeOpen(a);
|
|
136
|
+
onBridgeRealtimeOpen(b);
|
|
137
|
+
startBridgeRuntime();
|
|
138
|
+
_onOpen?.();
|
|
139
|
+
expect(a).toHaveBeenCalled();
|
|
140
|
+
expect(b).toHaveBeenCalled();
|
|
141
|
+
});
|
|
142
|
+
it('dispatches session.snapshot to bridgeEvents + applySessionSnapshot + subscribers', async () => {
|
|
143
|
+
const { bridgeEvents } = await import('./events.js');
|
|
144
|
+
const { applySessionSnapshot } = await import('./snapshot-stores.js');
|
|
145
|
+
const sub = vi.fn();
|
|
146
|
+
onBridgeRealtimeSnapshot(sub);
|
|
147
|
+
startBridgeRuntime();
|
|
148
|
+
_onSnapshot?.({ kind: 'session.snapshot', data: { user: { id: 'u' } } });
|
|
149
|
+
expect(applySessionSnapshot).toHaveBeenCalledWith({ user: { id: 'u' } });
|
|
150
|
+
expect(bridgeEvents._dispatch).toHaveBeenCalled();
|
|
151
|
+
expect(sub).toHaveBeenCalled();
|
|
152
|
+
});
|
|
153
|
+
it('forwards user.state_changed events to onBridgeRealtimeUserState subscribers', async () => {
|
|
154
|
+
const sub = vi.fn();
|
|
155
|
+
onBridgeRealtimeUserState(sub);
|
|
156
|
+
startBridgeRuntime();
|
|
157
|
+
await _onUserState?.({ reason: 'role.changed' });
|
|
158
|
+
expect(sub).toHaveBeenCalledWith({ reason: 'role.changed' });
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
describe('token store subscription', () => {
|
|
162
|
+
it('binds channel scopes from JWT claims on login', () => {
|
|
163
|
+
startBridgeRuntime();
|
|
164
|
+
// `tokenStore.subscribe` fires immediately with the current value (null)
|
|
165
|
+
// — that emits a `setUserId(undefined)` first. Clear so we only see the
|
|
166
|
+
// login emission.
|
|
167
|
+
_channelScopeCalls.length = 0;
|
|
168
|
+
_tokenStore.set({ accessToken: makeJwt({ sub: 'user-1', tid: 'tenant-7', aid: 'app-1' }) });
|
|
169
|
+
const userCall = _channelScopeCalls.find((c) => c.method === 'setUserId');
|
|
170
|
+
const tenantCall = _channelScopeCalls.find((c) => c.method === 'setWorkspaceId');
|
|
171
|
+
expect(userCall?.value).toBe('user-1');
|
|
172
|
+
expect(tenantCall?.value).toBe('tenant-7');
|
|
173
|
+
});
|
|
174
|
+
it('clears user + workspace scopes on logout but keeps app scope', () => {
|
|
175
|
+
startBridgeRuntime();
|
|
176
|
+
_tokenStore.set({ accessToken: makeJwt({ sub: 'user-1', tid: 'tenant-7' }) });
|
|
177
|
+
_channelScopeCalls.length = 0;
|
|
178
|
+
_tokenStore.set(null);
|
|
179
|
+
expect(_channelScopeCalls).toEqual([
|
|
180
|
+
{ method: 'setUserId', value: undefined },
|
|
181
|
+
{ method: 'setWorkspaceId', value: undefined },
|
|
182
|
+
]);
|
|
183
|
+
});
|
|
184
|
+
it('reauthorizes on token-only refresh (same user, new JWT)', () => {
|
|
185
|
+
startBridgeRuntime();
|
|
186
|
+
_tokenStore.set({ accessToken: makeJwt({ sub: 'user-1' }) });
|
|
187
|
+
_reauthCalls.length = 0;
|
|
188
|
+
_tokenStore.set({ accessToken: makeJwt({ sub: 'user-1', iat: 123 }) });
|
|
189
|
+
expect(_reauthCalls.length).toBe(1);
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
describe('stopBridgeRuntime', () => {
|
|
193
|
+
it('flushes the realtime client and clears the singleton', async () => {
|
|
194
|
+
startBridgeRuntime();
|
|
195
|
+
expect(getBridgeRealtime()).toBeDefined();
|
|
196
|
+
await stopBridgeRuntime();
|
|
197
|
+
expect(getBridgeRealtime()).toBeUndefined();
|
|
198
|
+
expect(_stopCalls).toBe(1);
|
|
199
|
+
});
|
|
200
|
+
it('is safe to call without a prior start', async () => {
|
|
201
|
+
await expect(stopBridgeRuntime()).resolves.not.toThrow();
|
|
202
|
+
});
|
|
203
|
+
});
|