@nebulr-group/bridge-svelte 0.9.1-beta.0 → 0.9.1-beta.2
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,14 +200,72 @@ async function waitForConfigured() {
|
|
|
199
200
|
clearTimeout(timer);
|
|
200
201
|
}
|
|
201
202
|
}
|
|
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
|
+
}
|
|
218
|
+
// TBP-810 — flag rules on `bridge:billing.entitlement.*` read auth-core's
|
|
219
|
+
// entitlements store. Nothing ever configured it, so it could not load itself
|
|
220
|
+
// and only filled from `/session/init` on the first realtime connect, about a
|
|
221
|
+
// second after the page rendered. Until then an entitled user saw every
|
|
222
|
+
// plan-gated feature locked (and a click offered an upgrade). Configure it,
|
|
223
|
+
// then load it before the first render — bounded, so a slow or failing read
|
|
224
|
+
// shows the page anyway and the store fills in when it can, as before.
|
|
225
|
+
async function loadEntitlementsBeforeFirstRender(bridge) {
|
|
226
|
+
try {
|
|
227
|
+
const store = useBridge().entitlementsStore;
|
|
228
|
+
store.configure({
|
|
229
|
+
apiBaseUrl: bridge.getApiContext().apiBaseUrl,
|
|
230
|
+
getAccessToken: () => bridge.getApiContext().accessToken ?? null,
|
|
231
|
+
}, logger);
|
|
232
|
+
if (store.isHydrated())
|
|
233
|
+
return;
|
|
234
|
+
await withinFirstRenderWait(store.hydrate());
|
|
235
|
+
}
|
|
236
|
+
catch (err) {
|
|
237
|
+
logger.debug('[bridgeBootstrap] entitlements not loaded before first render:', err);
|
|
238
|
+
}
|
|
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
|
+
}
|
|
202
255
|
// Once per page load. Concurrent callers share the same promise, so a first
|
|
203
256
|
// navigation that redirects mid-bootstrap does not start a second refresh or
|
|
204
257
|
// flag load. Never rejects: every step is best-effort by design — none of them
|
|
205
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.
|
|
206
264
|
function ensureInitialised() {
|
|
207
265
|
if (_initialisation)
|
|
208
266
|
return _initialisation;
|
|
209
267
|
_initialisation = (async () => {
|
|
268
|
+
const definitions = startFlagDefinitions();
|
|
210
269
|
// Ensure tokens are fresh if needed
|
|
211
270
|
try {
|
|
212
271
|
const bridge = getBridgeAuth();
|
|
@@ -217,24 +276,6 @@ function ensureInitialised() {
|
|
|
217
276
|
catch {
|
|
218
277
|
// Non-fatal — stale tokens will be caught by route guard
|
|
219
278
|
}
|
|
220
|
-
// Auto-manage billing state so the notice/gate render without the
|
|
221
|
-
// integrator wiring a mount, and `hard` billing route rules can decide.
|
|
222
|
-
try {
|
|
223
|
-
const bridge = getBridgeAuth();
|
|
224
|
-
const ctx = bridge.getApiContext();
|
|
225
|
-
if (ctx.accessToken) {
|
|
226
|
-
await useBridge().subscription.mount({
|
|
227
|
-
apiBaseUrl: ctx.apiBaseUrl,
|
|
228
|
-
accessToken: ctx.accessToken,
|
|
229
|
-
appId: ctx.appId,
|
|
230
|
-
// TBP-762 — renew an out-of-date sign-in and retry instead of failing.
|
|
231
|
-
onTokenStale: tokenStaleHandlerOf(bridge),
|
|
232
|
-
});
|
|
233
|
-
}
|
|
234
|
-
}
|
|
235
|
-
catch {
|
|
236
|
-
// Non-fatal — the notice hydrates lazily on first render.
|
|
237
|
-
}
|
|
238
279
|
// Warm the route-guard's flag cache without awaiting so GQL queries can
|
|
239
280
|
// start in parallel. `loadFeatureFlags()` populates the same auth-core
|
|
240
281
|
// FeatureFlagService the route guard evaluates `featureFlag` rules
|
|
@@ -253,6 +294,31 @@ function ensureInitialised() {
|
|
|
253
294
|
.catch((err) => {
|
|
254
295
|
logger.warn('[bridgeBootstrap] Feature flags failed to load:', err);
|
|
255
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);
|
|
256
322
|
return { flagsReady };
|
|
257
323
|
})();
|
|
258
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
|
+
}
|
package/dist/flags/bootstrap.js
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
3
|
+
"version": "0.9.1-beta.2",
|
|
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",
|