@jewel998/config 0.2.2 → 0.2.6
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/README.md +30 -14
- package/dist/cache/types.d.ts +1 -1
- package/dist/createConfig.js +9 -6
- package/dist/errors/index.d.ts +18 -0
- package/dist/errors/index.js +12 -0
- package/dist/events/EventEmitter.d.ts +21 -0
- package/dist/events/EventEmitter.js +54 -8
- package/dist/fetch/TierFetcher.d.ts +77 -0
- package/dist/fetch/TierFetcher.js +162 -0
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/initConfig.d.ts +84 -53
- package/dist/initConfig.js +360 -90
- package/dist/plugins/lifecycle/index.d.ts +1 -1
- package/dist/plugins/lifecycle/index.js +1 -1
- package/dist/plugins/rollout/murmurhash3.js +1 -5
- package/dist/retry/RetryEngine.js +1 -1
- package/dist/server/initServerConfig.d.ts +110 -0
- package/dist/server/initServerConfig.js +159 -0
- package/dist/server/serverContext.d.ts +63 -0
- package/dist/server/serverContext.js +64 -0
- package/dist/server.d.ts +10 -0
- package/dist/server.js +20 -0
- package/dist/transport/HttpTransport.js +26 -16
- package/dist/types.d.ts +3 -1
- package/dist/webhook-formats.js +1 -6
- package/package.json +36 -25
package/dist/initConfig.d.ts
CHANGED
|
@@ -1,78 +1,109 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { SdkError } from "./errors/index.js";
|
|
2
2
|
import type { EvaluationContext } from "./plugins/types.js";
|
|
3
|
+
import type { CacheStorage } from "./types.js";
|
|
3
4
|
export interface InitConfigOptions {
|
|
4
5
|
/** Required. API key from the portal (cid_ for client, svr_ for server). */
|
|
5
6
|
clientId: string;
|
|
6
7
|
/**
|
|
7
|
-
*
|
|
8
|
-
*
|
|
8
|
+
* Tier 1 keys — fetched immediately, block ready() resolution.
|
|
9
|
+
* Use for values your app cannot render without.
|
|
10
|
+
* e.g. ["app.maintenance_mode", "feature.auth_provider"]
|
|
11
|
+
*/
|
|
12
|
+
prefetch?: string[];
|
|
13
|
+
/**
|
|
14
|
+
* Default/fallback values returned instantly before any tier resolves.
|
|
15
|
+
* get() resolves immediately when a key has a default here.
|
|
9
16
|
*/
|
|
10
17
|
defaults?: Record<string, unknown>;
|
|
11
|
-
/** User context for targeting evaluation. Use autoContext() to auto-detect
|
|
18
|
+
/** User context for targeting evaluation. Use autoContext() to auto-detect. */
|
|
12
19
|
context?: EvaluationContext;
|
|
13
20
|
/**
|
|
14
|
-
* Your self-hosted API URL.
|
|
21
|
+
* Your self-hosted API URL.
|
|
15
22
|
* Default: https://jewel998-config.web.app/api (demo instance)
|
|
16
|
-
*
|
|
17
|
-
* @example "https://your-project.web.app/api"
|
|
18
23
|
*/
|
|
19
24
|
baseUrl?: string;
|
|
20
25
|
/**
|
|
21
|
-
* Cache storage adapter.
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
* -
|
|
25
|
-
*
|
|
26
|
-
* @example
|
|
27
|
-
* ```ts
|
|
28
|
-
* import { browserStorage } from "@jewel998/config";
|
|
29
|
-
* initConfig({ storage: browserStorage({ prefix: "myapp" }) })
|
|
30
|
-
* ```
|
|
26
|
+
* Cache storage adapter. Default: `browserStorage()` (localStorage), which
|
|
27
|
+
* persists values and the config version across page loads so a reload or
|
|
28
|
+
* return visit skips the `/config` fetch when nothing changed. Falls back to
|
|
29
|
+
* in-memory automatically when `localStorage` is unavailable (SSR / Node /
|
|
30
|
+
* server keys). Pass `memoryStorage()` explicitly to opt out of persistence.
|
|
31
31
|
*/
|
|
32
32
|
storage?: CacheStorage;
|
|
33
33
|
/**
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
* and only re-fetches full data when the version changes.
|
|
37
|
-
*
|
|
38
|
-
* Default: 300000 (5 minutes). Set to 0 to disable polling.
|
|
34
|
+
* Version polling interval in milliseconds.
|
|
35
|
+
* Default: 300_000 (5 minutes). Set to 0 to disable.
|
|
39
36
|
*/
|
|
40
37
|
pollInterval?: number;
|
|
38
|
+
/**
|
|
39
|
+
* Global timeout in milliseconds for get() calls with no default.
|
|
40
|
+
* If a key does not arrive within this window, get() rejects with
|
|
41
|
+
* SdkError { type: "TIMEOUT" } and onError is called.
|
|
42
|
+
* Default: 30_000 (30 seconds).
|
|
43
|
+
*/
|
|
44
|
+
timeout?: number;
|
|
45
|
+
/**
|
|
46
|
+
* Global error handler. Called on every SDK-level error:
|
|
47
|
+
* timeouts, fetch failures, auth errors, key-not-found, rate limits.
|
|
48
|
+
*
|
|
49
|
+
* get() also rejects with the same error — onError is for centralised
|
|
50
|
+
* logging/monitoring, not a replacement for catch().
|
|
51
|
+
*/
|
|
52
|
+
onError?: (error: SdkError) => void;
|
|
41
53
|
}
|
|
42
54
|
export interface Flags {
|
|
43
|
-
/**
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
55
|
+
/**
|
|
56
|
+
* Resolves when Tier 1 (init prefetch) keys have been fetched.
|
|
57
|
+
* Resolves instantly if no prefetch keys were declared.
|
|
58
|
+
*/
|
|
59
|
+
ready(): Promise<void>;
|
|
60
|
+
/**
|
|
61
|
+
* Tier 2 fetch hint — fire-and-forget.
|
|
62
|
+
* Keys already tracked in Tier 1 or already fetched are skipped.
|
|
63
|
+
* Emits "updated" and "updated:key" events per key on completion.
|
|
64
|
+
*/
|
|
65
|
+
prefetch(keys: string[]): void;
|
|
66
|
+
/**
|
|
67
|
+
* Get a typed config value asynchronously.
|
|
68
|
+
*
|
|
69
|
+
* Resolution order:
|
|
70
|
+
* 1. Memory / cache hit → resolves instantly
|
|
71
|
+
* 2. Default in defaults map → resolves instantly
|
|
72
|
+
* 3. Inline defaultValue arg → resolves instantly
|
|
73
|
+
* 4. No default → suspends until idle fetch or refresh
|
|
74
|
+
* delivers the key
|
|
75
|
+
*
|
|
76
|
+
* On timeout (global, default 30s):
|
|
77
|
+
* - onError is called with SdkError { type: "TIMEOUT", key }
|
|
78
|
+
* - Promise rejects with the same SdkError
|
|
79
|
+
*/
|
|
80
|
+
get<T = unknown>(key: string): Promise<T>;
|
|
81
|
+
get<T = unknown>(key: string, defaultValue: T): Promise<T>;
|
|
82
|
+
/**
|
|
83
|
+
* Returns a snapshot of all currently fetched values merged with defaults.
|
|
84
|
+
* Does not wait for any in-flight fetches — returns current state only.
|
|
85
|
+
*/
|
|
86
|
+
all(): Promise<Record<string, unknown>>;
|
|
87
|
+
/**
|
|
88
|
+
* Update user context. Fire-and-forget, debounced 100ms.
|
|
89
|
+
* Re-fetches all already-fetched keys in tier order with the new context.
|
|
90
|
+
* Subscribe to "updated" events to react when the re-fetch completes.
|
|
91
|
+
*/
|
|
50
92
|
setContext(context: EvaluationContext): void;
|
|
51
|
-
/**
|
|
93
|
+
/**
|
|
94
|
+
* Re-fetch all already-fetched keys in tier order.
|
|
95
|
+
* Resolves when all re-fetches complete.
|
|
96
|
+
*/
|
|
52
97
|
refresh(): Promise<void>;
|
|
53
|
-
/**
|
|
54
|
-
|
|
98
|
+
/**
|
|
99
|
+
* Subscribe to events.
|
|
100
|
+
*
|
|
101
|
+
* Key-specific: flags.on("updated:feature.dark_mode", (value) => ...)
|
|
102
|
+
* Batch updated: flags.on("updated", ({ keys, source }) => ...)
|
|
103
|
+
* Fetch errors: flags.on("fetchError", ({ error }) => ...)
|
|
104
|
+
*/
|
|
105
|
+
on(event: string, cb: (payload: unknown) => void): void;
|
|
55
106
|
/** Unsubscribe from events. */
|
|
56
|
-
off
|
|
107
|
+
off(event: string, cb: (payload: unknown) => void): void;
|
|
57
108
|
}
|
|
58
|
-
/**
|
|
59
|
-
* Initialize the feature flags SDK.
|
|
60
|
-
*
|
|
61
|
-
* Returns a Flags instance that immediately serves default values,
|
|
62
|
-
* then resolves to real values from the API in the background.
|
|
63
|
-
*
|
|
64
|
-
* @example
|
|
65
|
-
* ```ts
|
|
66
|
-
* const flags = initConfig({
|
|
67
|
-
* clientId: "cid_xxx",
|
|
68
|
-
* defaults: { "feature.dark_mode": false },
|
|
69
|
-
* context: autoContext({ userId: "user_123", plan: "pro" }),
|
|
70
|
-
* });
|
|
71
|
-
*
|
|
72
|
-
* flags.get("feature.dark_mode"); // → false (instant)
|
|
73
|
-
* flags.on("updated", () => {
|
|
74
|
-
* flags.get("feature.dark_mode"); // → true (from API)
|
|
75
|
-
* });
|
|
76
|
-
* ```
|
|
77
|
-
*/
|
|
78
109
|
export declare function initConfig(options: InitConfigOptions): Flags;
|
package/dist/initConfig.js
CHANGED
|
@@ -1,129 +1,399 @@
|
|
|
1
1
|
// ═══════════════════════════════════════════════════════════════
|
|
2
2
|
// initConfig — Primary SDK entry point
|
|
3
3
|
//
|
|
4
|
-
//
|
|
5
|
-
// const flags = initConfig({
|
|
6
|
-
// clientId: "cid_xxx",
|
|
7
|
-
// defaults: { "feature.dark_mode": false, "app.upload_limit": 50 },
|
|
8
|
-
// context: autoContext({ userId: "user_123", plan: "pro" }),
|
|
9
|
-
// });
|
|
4
|
+
// Three-tier priority fetching model:
|
|
10
5
|
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
6
|
+
// Tier 1 — PREFETCH (init)
|
|
7
|
+
// Declared via `prefetch: [...]` at initConfig time.
|
|
8
|
+
// Fetched immediately. ready() blocks until this completes.
|
|
9
|
+
//
|
|
10
|
+
// Tier 2 — PAGE (runtime)
|
|
11
|
+
// Declared via flags.prefetch(keys) per route/component.
|
|
12
|
+
// Fire-and-forget. Keys deduplicated against Tier 1.
|
|
13
|
+
//
|
|
14
|
+
// Tier 3 — IDLE
|
|
15
|
+
// Full fetchAll() during browser idle time via requestIdleCallback.
|
|
16
|
+
// Fills in all remaining keys in the background.
|
|
17
|
+
//
|
|
18
|
+
// get() behaviour:
|
|
19
|
+
// - Memory/cache hit → resolves instantly
|
|
20
|
+
// - Default provided → resolves instantly
|
|
21
|
+
// - No default → suspends until idle fetch or refresh delivers
|
|
22
|
+
// the key, or global timeout fires
|
|
23
|
+
// - Timeout → onError called, Promise rejects with SdkError
|
|
24
|
+
//
|
|
25
|
+
// Context update:
|
|
26
|
+
// setContext() is fire-and-forget (debounced 100ms).
|
|
27
|
+
// Re-fetches only already-fetched keys in tier order.
|
|
14
28
|
// ═══════════════════════════════════════════════════════════════
|
|
15
|
-
import {
|
|
29
|
+
import { browserStorage } from "./cache/browserStorage.js";
|
|
30
|
+
import { SdkError, ConfigError } from "./errors/index.js";
|
|
31
|
+
import { TypedEventEmitter } from "./events/EventEmitter.js";
|
|
32
|
+
import { createProjectedFetcher } from "./fetch/projectedFetcher.js";
|
|
33
|
+
import { TierFetcher } from "./fetch/TierFetcher.js";
|
|
34
|
+
import { withRetry } from "./retry/RetryEngine.js";
|
|
35
|
+
import { createHttpTransport } from "./transport/HttpTransport.js";
|
|
36
|
+
import { DEFAULT_CACHE_TTL, DEFAULT_RETRY } from "./types.js";
|
|
37
|
+
const DEFAULT_BASE_URL = "https://jewel998-config.web.app/api";
|
|
38
|
+
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
39
|
+
const CONTEXT_DEBOUNCE_MS = 100;
|
|
40
|
+
const MIN_REFETCH_INTERVAL_MS = 30_000;
|
|
41
|
+
/** Reserved storage keys for persisted gating metadata. */
|
|
42
|
+
const VERSION_STORAGE_KEY = "__version__";
|
|
43
|
+
const CONTEXT_FINGERPRINT_STORAGE_KEY = "__ctx__";
|
|
16
44
|
/**
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
* @example
|
|
23
|
-
* ```ts
|
|
24
|
-
* const flags = initConfig({
|
|
25
|
-
* clientId: "cid_xxx",
|
|
26
|
-
* defaults: { "feature.dark_mode": false },
|
|
27
|
-
* context: autoContext({ userId: "user_123", plan: "pro" }),
|
|
28
|
-
* });
|
|
29
|
-
*
|
|
30
|
-
* flags.get("feature.dark_mode"); // → false (instant)
|
|
31
|
-
* flags.on("updated", () => {
|
|
32
|
-
* flags.get("feature.dark_mode"); // → true (from API)
|
|
33
|
-
* });
|
|
34
|
-
* ```
|
|
45
|
+
* Stable string fingerprint of an evaluation context. Keys are sorted so the
|
|
46
|
+
* same context always produces the same fingerprint regardless of insertion
|
|
47
|
+
* order. Used to detect when context changed across loads (which invalidates
|
|
48
|
+
* persisted values even if the config version is unchanged).
|
|
35
49
|
*/
|
|
50
|
+
function contextFingerprint(ctx) {
|
|
51
|
+
const stable = (value) => {
|
|
52
|
+
if (value === null || typeof value !== "object")
|
|
53
|
+
return value;
|
|
54
|
+
if (Array.isArray(value))
|
|
55
|
+
return value.map(stable);
|
|
56
|
+
const obj = value;
|
|
57
|
+
const sorted = {};
|
|
58
|
+
for (const k of Object.keys(obj).sort())
|
|
59
|
+
sorted[k] = stable(obj[k]);
|
|
60
|
+
return sorted;
|
|
61
|
+
};
|
|
62
|
+
try {
|
|
63
|
+
return JSON.stringify(stable(ctx ?? {}));
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
return "";
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
// ─── Implementation ────────────────────────────────────────────
|
|
36
70
|
export function initConfig(options) {
|
|
71
|
+
if (!options.clientId) {
|
|
72
|
+
throw new ConfigError("clientId is required", "MISSING_CLIENT_ID");
|
|
73
|
+
}
|
|
74
|
+
if (typeof window === "undefined" && !options.clientId.startsWith("svr_")) {
|
|
75
|
+
throw new ConfigError("Client keys (cid_) require a browser environment.", "INITIALIZATION_FAILED");
|
|
76
|
+
}
|
|
37
77
|
const defaults = options.defaults ?? {};
|
|
38
|
-
const pollInterval = options.pollInterval ?? 300_000;
|
|
39
|
-
const baseUrl = options.baseUrl ??
|
|
40
|
-
|
|
41
|
-
const
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
78
|
+
const pollInterval = options.pollInterval ?? 300_000;
|
|
79
|
+
const baseUrl = options.baseUrl ?? DEFAULT_BASE_URL;
|
|
80
|
+
const storage = options.storage ?? browserStorage();
|
|
81
|
+
const retry = { ...DEFAULT_RETRY };
|
|
82
|
+
const timeoutMs = options.timeout ?? DEFAULT_TIMEOUT_MS;
|
|
83
|
+
const onError = options.onError;
|
|
84
|
+
const initPrefetch = options.prefetch ?? [];
|
|
85
|
+
const events = new TypedEventEmitter();
|
|
86
|
+
let currentContext = options.context ?? {};
|
|
87
|
+
// ── Transport + fetcher ──────────────────────────────────────
|
|
88
|
+
const transport = createHttpTransport({
|
|
46
89
|
baseUrl,
|
|
90
|
+
clientId: options.clientId,
|
|
91
|
+
evaluationMode: "server",
|
|
92
|
+
getContext: () => currentContext,
|
|
47
93
|
});
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
94
|
+
const baseFetcher = createProjectedFetcher(transport);
|
|
95
|
+
const tierFetcher = new TierFetcher(baseFetcher);
|
|
96
|
+
// ── In-memory data store ─────────────────────────────────────
|
|
97
|
+
const data = {};
|
|
98
|
+
// Version known to the SDK. Hydrated from persistent storage on init so a
|
|
99
|
+
// reload can skip the config fetch when the version is unchanged.
|
|
100
|
+
let cachedVersion = storage.get(VERSION_STORAGE_KEY) ?? null;
|
|
101
|
+
const currentFingerprint = contextFingerprint(currentContext);
|
|
102
|
+
// If the persisted context fingerprint differs from this init's context, any
|
|
103
|
+
// persisted per-user values are stale — drop the persisted version so the
|
|
104
|
+
// boot gate re-fetches rather than serving values evaluated for old context.
|
|
105
|
+
if (storage.get(CONTEXT_FINGERPRINT_STORAGE_KEY) !== currentFingerprint) {
|
|
106
|
+
cachedVersion = null;
|
|
107
|
+
}
|
|
108
|
+
/** Persist the current version + context fingerprint next to the values. */
|
|
109
|
+
function persistVersion(version) {
|
|
110
|
+
cachedVersion = version;
|
|
111
|
+
storage.set(VERSION_STORAGE_KEY, version, DEFAULT_CACHE_TTL);
|
|
112
|
+
storage.set(CONTEXT_FINGERPRINT_STORAGE_KEY, currentFingerprint, DEFAULT_CACHE_TTL);
|
|
113
|
+
}
|
|
114
|
+
function applyResult(result) {
|
|
115
|
+
const keys = Object.keys(result);
|
|
116
|
+
for (const key of keys) {
|
|
117
|
+
data[key] = result[key];
|
|
118
|
+
storage.set(key, result[key], DEFAULT_CACHE_TTL);
|
|
119
|
+
}
|
|
120
|
+
// Update the __all__ snapshot incrementally
|
|
121
|
+
const existing = storage.get("__all__") ?? {};
|
|
122
|
+
storage.set("__all__", { ...existing, ...result }, DEFAULT_CACHE_TTL);
|
|
123
|
+
return keys;
|
|
124
|
+
}
|
|
125
|
+
function emitUpdated(keys, source) {
|
|
126
|
+
if (keys.length === 0)
|
|
127
|
+
return;
|
|
128
|
+
events.emit("updated", { keys, source });
|
|
129
|
+
}
|
|
130
|
+
// ── Remote version check (shared by boot gate + polling) ─────
|
|
131
|
+
async function fetchRemoteVersion() {
|
|
52
132
|
try {
|
|
53
133
|
const res = await fetch(`${baseUrl}/v1/version?clientId=${options.clientId}`, {
|
|
54
|
-
headers: cachedVersion
|
|
55
|
-
? { "If-None-Match": `"${cachedVersion}"` }
|
|
56
|
-
: {},
|
|
134
|
+
headers: cachedVersion ? { "If-None-Match": `"${cachedVersion}"` } : {},
|
|
57
135
|
});
|
|
58
|
-
// 304
|
|
136
|
+
// 304 => unchanged from the version we sent in If-None-Match.
|
|
59
137
|
if (res.status === 304)
|
|
60
|
-
return;
|
|
138
|
+
return cachedVersion;
|
|
61
139
|
if (!res.ok)
|
|
62
|
-
return;
|
|
63
|
-
const { version
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
140
|
+
return null;
|
|
141
|
+
const { version } = (await res.json());
|
|
142
|
+
return version ?? null;
|
|
143
|
+
}
|
|
144
|
+
catch {
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
/** True when every key is already present in persistent storage. */
|
|
149
|
+
function allKeysCached(keys) {
|
|
150
|
+
return keys.every((k) => storage.get(k) !== undefined);
|
|
151
|
+
}
|
|
152
|
+
function hydrateFromStorage(keys) {
|
|
153
|
+
const hydrated = [];
|
|
154
|
+
for (const key of keys) {
|
|
155
|
+
const cached = storage.get(key);
|
|
156
|
+
if (cached !== undefined) {
|
|
157
|
+
data[key] = cached;
|
|
158
|
+
hydrated.push(key);
|
|
68
159
|
}
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
160
|
+
}
|
|
161
|
+
return hydrated;
|
|
162
|
+
}
|
|
163
|
+
// ── Tier 1: init prefetch (version-gated against persisted cache) ─
|
|
164
|
+
const readyPromise = initPrefetch.length > 0
|
|
165
|
+
? (async () => {
|
|
166
|
+
try {
|
|
167
|
+
// Boot gate: if we have a persisted version and values, confirm the
|
|
168
|
+
// remote version matches before spending a /config call. On match,
|
|
169
|
+
// serve from storage and skip the fetch entirely.
|
|
170
|
+
if (cachedVersion !== null && allKeysCached(initPrefetch)) {
|
|
171
|
+
const remote = await fetchRemoteVersion();
|
|
172
|
+
if (remote !== null && remote === cachedVersion) {
|
|
173
|
+
const hydrated = hydrateFromStorage(initPrefetch);
|
|
174
|
+
tierFetcher.markFetched(initPrefetch, "prefetch");
|
|
175
|
+
emitUpdated(hydrated, "prefetch");
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
// Version advanced (or unknown) — fall through to a real fetch.
|
|
179
|
+
if (remote !== null)
|
|
180
|
+
cachedVersion = remote;
|
|
181
|
+
}
|
|
182
|
+
const result = await withRetry(() => tierFetcher.fetchTier(initPrefetch, "prefetch"), retry);
|
|
183
|
+
const fetched = applyResult(result);
|
|
184
|
+
// Record the version these values correspond to.
|
|
185
|
+
const v = await fetchRemoteVersion();
|
|
186
|
+
if (v !== null)
|
|
187
|
+
persistVersion(v);
|
|
188
|
+
tierFetcher.notifyWaiters(result);
|
|
189
|
+
emitUpdated(fetched, "prefetch");
|
|
190
|
+
}
|
|
191
|
+
catch (err) {
|
|
192
|
+
const sdkErr = new SdkError("FETCH_FAILED", `Prefetch failed: ${err.message}`, undefined, err);
|
|
193
|
+
onError?.(sdkErr);
|
|
194
|
+
events.emit("fetchError", {
|
|
195
|
+
error: sdkErr,
|
|
196
|
+
retryCount: retry.maxRetries,
|
|
197
|
+
willRetry: false,
|
|
198
|
+
});
|
|
199
|
+
// Don't re-throw — app still runs on defaults
|
|
200
|
+
}
|
|
201
|
+
})()
|
|
202
|
+
: Promise.resolve();
|
|
203
|
+
// ── Tier 3: idle fetch (skipped when the full snapshot is fresh) ──
|
|
204
|
+
readyPromise.then(() => {
|
|
205
|
+
scheduleIdleFetch(async () => {
|
|
206
|
+
try {
|
|
207
|
+
// If we already hold the full snapshot for the current version,
|
|
208
|
+
// there is nothing to fill in — skip the idle /config call.
|
|
209
|
+
if (cachedVersion !== null && storage.get("__all__") !== undefined) {
|
|
210
|
+
const remote = await fetchRemoteVersion();
|
|
211
|
+
if (remote !== null && remote === cachedVersion)
|
|
212
|
+
return;
|
|
213
|
+
if (remote !== null)
|
|
214
|
+
cachedVersion = remote;
|
|
215
|
+
}
|
|
216
|
+
const result = await tierFetcher.fetchAll();
|
|
217
|
+
const fetched = applyResult(result);
|
|
218
|
+
const v = await fetchRemoteVersion();
|
|
219
|
+
if (v !== null)
|
|
220
|
+
persistVersion(v);
|
|
221
|
+
tierFetcher.notifyWaiters(result);
|
|
222
|
+
emitUpdated(fetched, "idle");
|
|
75
223
|
}
|
|
224
|
+
catch {
|
|
225
|
+
// Idle fetch is best-effort — silently ignore
|
|
226
|
+
// Pending waiters will eventually time out via get()
|
|
227
|
+
}
|
|
228
|
+
});
|
|
229
|
+
});
|
|
230
|
+
// ── Version polling ──────────────────────────────────────────
|
|
231
|
+
async function checkVersion() {
|
|
232
|
+
const version = await fetchRemoteVersion();
|
|
233
|
+
if (version === null)
|
|
234
|
+
return;
|
|
235
|
+
if (cachedVersion === null) {
|
|
236
|
+
persistVersion(version);
|
|
237
|
+
return;
|
|
76
238
|
}
|
|
77
|
-
|
|
78
|
-
|
|
239
|
+
if (version !== cachedVersion) {
|
|
240
|
+
persistVersion(version);
|
|
241
|
+
await doRefresh();
|
|
79
242
|
}
|
|
80
243
|
}
|
|
81
|
-
// Start polling if interval > 0
|
|
82
244
|
if (pollInterval > 0 && typeof window !== "undefined") {
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
void checkVersion();
|
|
86
|
-
});
|
|
87
|
-
pollTimer = setInterval(checkVersion, pollInterval);
|
|
88
|
-
// Also check on tab visibility change (user returns to tab)
|
|
245
|
+
readyPromise.then(() => void checkVersion());
|
|
246
|
+
setInterval(checkVersion, pollInterval);
|
|
89
247
|
document.addEventListener("visibilitychange", () => {
|
|
90
|
-
if (document.visibilityState === "visible")
|
|
248
|
+
if (document.visibilityState === "visible")
|
|
91
249
|
void checkVersion();
|
|
92
|
-
}
|
|
93
250
|
});
|
|
94
251
|
}
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
252
|
+
// ── Refresh (re-fetch fetched keys in tier order) ─────────────
|
|
253
|
+
let lastRefetchTime = 0;
|
|
254
|
+
async function doRefresh() {
|
|
255
|
+
if (Date.now() - lastRefetchTime < MIN_REFETCH_INTERVAL_MS)
|
|
256
|
+
return;
|
|
257
|
+
lastRefetchTime = Date.now();
|
|
258
|
+
const { prefetch: pf, page, idle } = tierFetcher.getFetchedKeys();
|
|
259
|
+
const allFetched = [];
|
|
260
|
+
const refetches = [];
|
|
261
|
+
if (pf.length > 0) {
|
|
262
|
+
refetches.push(withRetry(() => tierFetcher.refetchKeys(pf), retry)
|
|
263
|
+
.then((r) => {
|
|
264
|
+
applyResult(r);
|
|
265
|
+
tierFetcher.notifyWaiters(r);
|
|
266
|
+
allFetched.push(...Object.keys(r));
|
|
267
|
+
})
|
|
268
|
+
.catch(() => { }));
|
|
269
|
+
}
|
|
270
|
+
if (page.length > 0) {
|
|
271
|
+
refetches.push(withRetry(() => tierFetcher.refetchKeys(page), retry)
|
|
272
|
+
.then((r) => {
|
|
273
|
+
applyResult(r);
|
|
274
|
+
tierFetcher.notifyWaiters(r);
|
|
275
|
+
allFetched.push(...Object.keys(r));
|
|
276
|
+
})
|
|
277
|
+
.catch(() => { }));
|
|
278
|
+
}
|
|
279
|
+
if (idle.length > 0) {
|
|
280
|
+
refetches.push(tierFetcher
|
|
281
|
+
.fetchAll()
|
|
282
|
+
.then((r) => {
|
|
283
|
+
applyResult(r);
|
|
284
|
+
tierFetcher.notifyWaiters(r);
|
|
285
|
+
allFetched.push(...Object.keys(r));
|
|
286
|
+
})
|
|
287
|
+
.catch(() => { }));
|
|
288
|
+
}
|
|
289
|
+
await Promise.allSettled(refetches);
|
|
290
|
+
emitUpdated(allFetched, "refresh");
|
|
291
|
+
}
|
|
292
|
+
// ── Context re-fetch ─────────────────────────────────────────
|
|
293
|
+
let contextDebounce = null;
|
|
294
|
+
// ── Public API ───────────────────────────────────────────────
|
|
295
|
+
const flags = {
|
|
296
|
+
ready() {
|
|
297
|
+
return readyPromise;
|
|
298
|
+
},
|
|
299
|
+
prefetch(keys) {
|
|
300
|
+
void (async () => {
|
|
301
|
+
try {
|
|
302
|
+
const result = await withRetry(() => tierFetcher.fetchTier(keys, "page"), retry);
|
|
303
|
+
const fetched = applyResult(result);
|
|
304
|
+
tierFetcher.notifyWaiters(result);
|
|
305
|
+
emitUpdated(fetched, "page");
|
|
306
|
+
}
|
|
307
|
+
catch (err) {
|
|
308
|
+
const sdkErr = new SdkError("FETCH_FAILED", `Page prefetch failed: ${err.message}`, undefined, err);
|
|
309
|
+
onError?.(sdkErr);
|
|
310
|
+
events.emit("fetchError", {
|
|
311
|
+
error: sdkErr,
|
|
312
|
+
retryCount: retry.maxRetries,
|
|
313
|
+
willRetry: false,
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
})();
|
|
101
317
|
},
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
if (
|
|
105
|
-
return
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
318
|
+
get(key, defaultValue) {
|
|
319
|
+
// 1. Memory hit
|
|
320
|
+
if (key in data)
|
|
321
|
+
return Promise.resolve(data[key]);
|
|
322
|
+
// 2. Cache hit
|
|
323
|
+
const cached = storage.get(key);
|
|
324
|
+
if (cached !== undefined) {
|
|
325
|
+
data[key] = cached;
|
|
326
|
+
return Promise.resolve(cached);
|
|
327
|
+
}
|
|
328
|
+
// 3. Default from map
|
|
329
|
+
if (key in defaults)
|
|
330
|
+
return Promise.resolve(defaults[key]);
|
|
331
|
+
// 4. Inline default
|
|
332
|
+
if (defaultValue !== undefined)
|
|
333
|
+
return Promise.resolve(defaultValue);
|
|
334
|
+
// 5. Suspend — wait for idle fetch or refresh to deliver this key
|
|
335
|
+
return new Promise((resolve, reject) => {
|
|
336
|
+
const timer = setTimeout(() => {
|
|
337
|
+
const err = new SdkError("TIMEOUT", `get("${key}") timed out after ${timeoutMs}ms — key was not delivered by idle fetch or refresh`, key);
|
|
338
|
+
onError?.(err);
|
|
339
|
+
reject(err);
|
|
340
|
+
}, timeoutMs);
|
|
341
|
+
tierFetcher.waitForKey(key).then((value) => {
|
|
342
|
+
clearTimeout(timer);
|
|
343
|
+
resolve(value);
|
|
344
|
+
}, (err) => {
|
|
345
|
+
clearTimeout(timer);
|
|
346
|
+
const sdkErr = new SdkError("FETCH_FAILED", err.message, key, err);
|
|
347
|
+
onError?.(sdkErr);
|
|
348
|
+
reject(sdkErr);
|
|
349
|
+
});
|
|
350
|
+
});
|
|
110
351
|
},
|
|
111
|
-
all() {
|
|
112
|
-
const
|
|
113
|
-
return { ...defaults, ...
|
|
352
|
+
async all() {
|
|
353
|
+
const snapshot = storage.get("__all__") ?? {};
|
|
354
|
+
return { ...defaults, ...snapshot, ...data };
|
|
114
355
|
},
|
|
115
356
|
setContext(context) {
|
|
116
|
-
|
|
357
|
+
currentContext = { ...context };
|
|
358
|
+
if (contextDebounce)
|
|
359
|
+
clearTimeout(contextDebounce);
|
|
360
|
+
contextDebounce = setTimeout(() => {
|
|
361
|
+
contextDebounce = null;
|
|
362
|
+
void doRefresh();
|
|
363
|
+
}, CONTEXT_DEBOUNCE_MS);
|
|
117
364
|
},
|
|
118
|
-
refresh() {
|
|
119
|
-
|
|
365
|
+
async refresh() {
|
|
366
|
+
await doRefresh();
|
|
120
367
|
},
|
|
121
368
|
on(event, cb) {
|
|
122
|
-
|
|
369
|
+
// Key-specific: "updated:feature.dark_mode"
|
|
370
|
+
if (event.startsWith("updated:")) {
|
|
371
|
+
const key = event.slice("updated:".length);
|
|
372
|
+
events.onKey(key, cb);
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
events.on(event, cb);
|
|
123
376
|
},
|
|
124
377
|
off(event, cb) {
|
|
125
|
-
|
|
378
|
+
if (event.startsWith("updated:")) {
|
|
379
|
+
const key = event.slice("updated:".length);
|
|
380
|
+
events.offKey(key, cb);
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
events.off(event, cb);
|
|
126
384
|
},
|
|
127
385
|
};
|
|
386
|
+
return flags;
|
|
387
|
+
}
|
|
388
|
+
// ─── Idle scheduling ──────────────────────────────────────────
|
|
389
|
+
function scheduleIdleFetch(fn) {
|
|
390
|
+
if (typeof window === "undefined")
|
|
391
|
+
return;
|
|
392
|
+
if ("requestIdleCallback" in window) {
|
|
393
|
+
window.requestIdleCallback(fn, { timeout: 2000 });
|
|
394
|
+
}
|
|
395
|
+
else {
|
|
396
|
+
setTimeout(fn, 200);
|
|
397
|
+
}
|
|
128
398
|
}
|
|
129
399
|
//# sourceMappingURL=initConfig.js.map
|
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
export { archivedPlugin } from "./archivedPlugin.js";
|
|
2
|
-
export { validateStateTransition, VALID_TRANSITIONS
|
|
2
|
+
export { validateStateTransition, VALID_TRANSITIONS } from "./stateTransitions.js";
|
|
3
3
|
export type { LifecycleState } from "./stateTransitions.js";
|