@nebulr-group/bridge-svelte 0.9.1-beta.1 → 0.9.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.
@@ -7,6 +7,7 @@ import { dropFlagCache, guardCacheGeneration } from '../auth/guard-cache.js';
7
7
  import { getBridgeAuth, bridgeReadyStore, markReady, waitForBridge as _waitForBridge, } from '../core/bridge-instance.js';
8
8
  import { installBridgeAuthFetch } from '../core/bridge-runtime.js';
9
9
  import { refreshBilling, tokenStaleHandlerOf } from '../core/billing-store.js';
10
+ import { prefetchFlagDefinitions } from '../core/flag-definitions.js';
10
11
  import { useBridge, sanitizeReturnTo, stashReturnTo, takeReturnTo, withReturnTo, } from '@nebulr-group/bridge-auth-core';
11
12
  import { logger } from '../shared/logger.js';
12
13
  import { bridgeConfig, getConfig, getRouteGuardConfig } from './stores/config.store.js';
@@ -199,8 +200,21 @@ async function waitForConfigured() {
199
200
  clearTimeout(timer);
200
201
  }
201
202
  }
202
- /** Longest the first render waits for the plan's features before showing anyway. */
203
- const ENTITLEMENTS_FIRST_RENDER_WAIT_MS = 1500;
203
+ /** Longest the first render waits for a startup read before showing anyway. */
204
+ const FIRST_RENDER_WAIT_MS = 1500;
205
+ /** Wait for `work`, but never longer than the first-render cap. Never rejects. */
206
+ async function withinFirstRenderWait(work) {
207
+ let timer;
208
+ const timeout = new Promise((resolve) => {
209
+ timer = setTimeout(resolve, FIRST_RENDER_WAIT_MS);
210
+ });
211
+ try {
212
+ await Promise.race([work.catch(() => undefined), timeout]);
213
+ }
214
+ finally {
215
+ clearTimeout(timer);
216
+ }
217
+ }
204
218
  // TBP-810 — flag rules on `bridge:billing.entitlement.*` read auth-core's
205
219
  // entitlements store. Nothing ever configured it, so it could not load itself
206
220
  // and only filled from `/session/init` on the first realtime connect, about a
@@ -217,29 +231,41 @@ async function loadEntitlementsBeforeFirstRender(bridge) {
217
231
  }, logger);
218
232
  if (store.isHydrated())
219
233
  return;
220
- let timer;
221
- const timeout = new Promise((resolve) => {
222
- timer = setTimeout(resolve, ENTITLEMENTS_FIRST_RENDER_WAIT_MS);
223
- });
224
- try {
225
- await Promise.race([store.hydrate(), timeout]);
226
- }
227
- finally {
228
- clearTimeout(timer);
229
- }
234
+ await withinFirstRenderWait(store.hydrate());
230
235
  }
231
236
  catch (err) {
232
237
  logger.debug('[bridgeBootstrap] entitlements not loaded before first render:', err);
233
238
  }
234
239
  }
240
+ // TBP-810 — `<FeatureFlag>` / `useFlag()` evaluate the flag definitions, which
241
+ // the flags bundle used to fetch only once the page was showing: every gated
242
+ // tile rendered its fallback first. Start the read now (it needs no sign-in);
243
+ // `createBridgeFlags()` takes the result instead of fetching again.
244
+ function startFlagDefinitions() {
245
+ try {
246
+ const cfg = getConfig();
247
+ if (!cfg.appId)
248
+ return Promise.resolve();
249
+ return prefetchFlagDefinitions(cfg.apiBaseUrl ?? 'https://api.thebridge.dev', cfg.appId);
250
+ }
251
+ catch {
252
+ return Promise.resolve();
253
+ }
254
+ }
235
255
  // Once per page load. Concurrent callers share the same promise, so a first
236
256
  // navigation that redirects mid-bootstrap does not start a second refresh or
237
257
  // flag load. Never rejects: every step is best-effort by design — none of them
238
258
  // decides authorization, which is enforceRouteGuard's job alone.
259
+ //
260
+ // TBP-810 — every startup read runs at the same time, and the first render
261
+ // waits for the ones the page's gates read (billing state, the plan's
262
+ // features, the flag definitions), each capped. One slow read no longer holds
263
+ // up the others.
239
264
  function ensureInitialised() {
240
265
  if (_initialisation)
241
266
  return _initialisation;
242
267
  _initialisation = (async () => {
268
+ const definitions = startFlagDefinitions();
243
269
  // Ensure tokens are fresh if needed
244
270
  try {
245
271
  const bridge = getBridgeAuth();
@@ -250,29 +276,6 @@ function ensureInitialised() {
250
276
  catch {
251
277
  // Non-fatal — stale tokens will be caught by route guard
252
278
  }
253
- // Auto-manage billing state so the notice/gate render without the
254
- // integrator wiring a mount, and `hard` billing route rules can decide.
255
- // The plan's features load alongside it (same round-trip time, so the
256
- // page waits no longer).
257
- try {
258
- const bridge = getBridgeAuth();
259
- const ctx = bridge.getApiContext();
260
- if (ctx.accessToken) {
261
- await Promise.all([
262
- useBridge().subscription.mount({
263
- apiBaseUrl: ctx.apiBaseUrl,
264
- accessToken: ctx.accessToken,
265
- appId: ctx.appId,
266
- // TBP-762 — renew an out-of-date sign-in and retry instead of failing.
267
- onTokenStale: tokenStaleHandlerOf(bridge),
268
- }),
269
- loadEntitlementsBeforeFirstRender(bridge),
270
- ]);
271
- }
272
- }
273
- catch {
274
- // Non-fatal — the notice hydrates lazily on first render.
275
- }
276
279
  // Warm the route-guard's flag cache without awaiting so GQL queries can
277
280
  // start in parallel. `loadFeatureFlags()` populates the same auth-core
278
281
  // FeatureFlagService the route guard evaluates `featureFlag` rules
@@ -291,6 +294,31 @@ function ensureInitialised() {
291
294
  .catch((err) => {
292
295
  logger.warn('[bridgeBootstrap] Feature flags failed to load:', err);
293
296
  });
297
+ // Auto-manage billing state so the notice/gate render without the
298
+ // integrator wiring a mount, and `hard` billing route rules can decide.
299
+ // The plan's features and the flag definitions load alongside it.
300
+ const reads = [withinFirstRenderWait(definitions)];
301
+ try {
302
+ const bridge = getBridgeAuth();
303
+ const ctx = bridge.getApiContext();
304
+ if (ctx.accessToken) {
305
+ reads.push(useBridge()
306
+ .subscription.mount({
307
+ apiBaseUrl: ctx.apiBaseUrl,
308
+ accessToken: ctx.accessToken,
309
+ appId: ctx.appId,
310
+ // TBP-762 — renew an out-of-date sign-in and retry instead of failing.
311
+ onTokenStale: tokenStaleHandlerOf(bridge),
312
+ })
313
+ .catch(() => {
314
+ // Non-fatal — the notice hydrates lazily on first render.
315
+ }), loadEntitlementsBeforeFirstRender(bridge));
316
+ }
317
+ }
318
+ catch {
319
+ // Non-fatal — the notice hydrates lazily on first render.
320
+ }
321
+ await Promise.all(reads);
294
322
  return { flagsReady };
295
323
  })();
296
324
  return _initialisation;
@@ -0,0 +1,15 @@
1
+ /** Where the definitions for an app live. */
2
+ export declare function flagDefinitionsUrl(apiBaseUrl: string, appKey: string): string;
3
+ /**
4
+ * Start reading the definitions. Concurrent callers share one request.
5
+ * Resolves to the list, or `null` when the read failed. Never rejects.
6
+ */
7
+ export declare function prefetchFlagDefinitions(apiBaseUrl: string, appKey: string): Promise<unknown[] | null>;
8
+ /**
9
+ * The prefetched definitions for this app, if they have arrived. One-shot: a
10
+ * later caller (a remount, a reconnect) fetches fresh ones instead of reusing
11
+ * what may be stale by then.
12
+ */
13
+ export declare function takePrefetchedFlagDefinitions(apiBaseUrl: string, appKey: string): unknown[] | null;
14
+ /** Test-only. */
15
+ export declare function __resetFlagDefinitionsForTests(): void;
@@ -0,0 +1,66 @@
1
+ // TBP-810 — the flag definitions `<FeatureFlag>` / `useFlag()` evaluate.
2
+ //
3
+ // The flags bundle used to fetch them only once the page was already showing,
4
+ // so every `<FeatureFlag>` rendered its fallback (a padlock) for a moment on
5
+ // each full page load. The root `load` now starts this read at the same time
6
+ // as the sign-in and billing reads and waits for it before the first render;
7
+ // `createBridgeFlags()` then takes the result instead of fetching again.
8
+ //
9
+ // Plain TS with no flags imports, so the core bootstrap can use it without
10
+ // pulling the flags bundle into auth-only apps.
11
+ let _url = null;
12
+ let _promise = null;
13
+ let _result = null;
14
+ /** Where the definitions for an app live. */
15
+ export function flagDefinitionsUrl(apiBaseUrl, appKey) {
16
+ return `${apiBaseUrl.replace(/\/+$/, '')}/admin/flags-internal/flags-cache/${encodeURIComponent(appKey)}`;
17
+ }
18
+ /**
19
+ * Start reading the definitions. Concurrent callers share one request.
20
+ * Resolves to the list, or `null` when the read failed. Never rejects.
21
+ */
22
+ export function prefetchFlagDefinitions(apiBaseUrl, appKey) {
23
+ const url = flagDefinitionsUrl(apiBaseUrl, appKey);
24
+ if (_promise && _url === url)
25
+ return _promise;
26
+ _url = url;
27
+ _result = null;
28
+ const promise = (async () => {
29
+ try {
30
+ const res = await fetch(url);
31
+ if (!res.ok)
32
+ return null;
33
+ const body = (await res.json());
34
+ return Array.isArray(body) ? body : null;
35
+ }
36
+ catch {
37
+ return null;
38
+ }
39
+ })().then((list) => {
40
+ if (_promise === promise)
41
+ _result = list;
42
+ return list;
43
+ });
44
+ _promise = promise;
45
+ return promise;
46
+ }
47
+ /**
48
+ * The prefetched definitions for this app, if they have arrived. One-shot: a
49
+ * later caller (a remount, a reconnect) fetches fresh ones instead of reusing
50
+ * what may be stale by then.
51
+ */
52
+ export function takePrefetchedFlagDefinitions(apiBaseUrl, appKey) {
53
+ if (_url !== flagDefinitionsUrl(apiBaseUrl, appKey) || !_result)
54
+ return null;
55
+ const list = _result;
56
+ _url = null;
57
+ _promise = null;
58
+ _result = null;
59
+ return list;
60
+ }
61
+ /** Test-only. */
62
+ export function __resetFlagDefinitionsForTests() {
63
+ _url = null;
64
+ _promise = null;
65
+ _result = null;
66
+ }
@@ -20,6 +20,7 @@ import { _getDevAttributeProvider } from '../core/bridge.js';
20
20
  import { tokenStaleHandlerOf } from '../core/billing-store.js';
21
21
  import { getBridgeRealtime, onBridgeRealtimeOpen, } from '../core/bridge-runtime.js';
22
22
  import { getConfig } from '../client/stores/config.store.js';
23
+ import { takePrefetchedFlagDefinitions } from '../core/flag-definitions.js';
23
24
  /** A storage implementation that uses `localStorage` (persistent) or `sessionStorage` (per-tab). */
24
25
  export class BrowserIdentityStorage {
25
26
  mode;
@@ -220,8 +221,17 @@ export function createBridgeFlags(config = {}) {
220
221
  void hydrateFlagsCache();
221
222
  });
222
223
  // Initial hydrate fires here too — covers the case where realtime is
223
- // disabled and onOpen never fires.
224
- void hydrateFlagsCache();
224
+ // disabled and onOpen never fires. TBP-810 — the root `load` already read
225
+ // the definitions before the first render; use them so the first
226
+ // `<FeatureFlag>` evaluates against real rules instead of rendering its
227
+ // fallback until this fetch lands.
228
+ const prefetched = takePrefetchedFlagDefinitions(resolvedApiBaseUrl, resolvedApiKey);
229
+ if (prefetched && prefetched.length > 0) {
230
+ bridge.hydrate(prefetched);
231
+ }
232
+ else {
233
+ void hydrateFlagsCache();
234
+ }
225
235
  if (config.registerGlobal !== false) {
226
236
  setBridgeFlagsInstance(bridge);
227
237
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nebulr-group/bridge-svelte",
3
- "version": "0.9.1-beta.1",
3
+ "version": "0.9.1",
4
4
  "description": "The Bridge for SvelteKit: sign-in, workspaces and roles, feature flags, Stripe subscriptions and plan limits in one .env line and three files.",
5
5
  "author": "Iman Pouya",
6
6
  "license": "MIT",