@nebulr-group/bridge-svelte 0.9.1-beta.1 → 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,8 +200,21 @@ async function waitForConfigured() {
|
|
|
199
200
|
clearTimeout(timer);
|
|
200
201
|
}
|
|
201
202
|
}
|
|
202
|
-
/** Longest the first render waits for
|
|
203
|
-
const
|
|
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
|
-
|
|
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
|
+
}
|
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",
|