@jewel998/config 0.2.4 → 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/dist/fetch/TierFetcher.d.ts +6 -0
- package/dist/fetch/TierFetcher.js +8 -0
- package/dist/initConfig.d.ts +7 -1
- package/dist/initConfig.js +121 -24
- package/package.json +1 -1
|
@@ -38,6 +38,12 @@ export declare class TierFetcher {
|
|
|
38
38
|
* with a new context, so the allFetched deduplication should be bypassed.
|
|
39
39
|
*/
|
|
40
40
|
refetchKeys(keys: string[]): Promise<Record<string, unknown>>;
|
|
41
|
+
/**
|
|
42
|
+
* Record keys as fetched under a tier WITHOUT performing a network fetch.
|
|
43
|
+
* Used when values are hydrated from persistent storage (the boot gate),
|
|
44
|
+
* so later context re-fetches correctly scope to these keys.
|
|
45
|
+
*/
|
|
46
|
+
markFetched(keys: string[], tier: FetchTier): void;
|
|
41
47
|
/**
|
|
42
48
|
* Register a waiter for a key that hasn't been fetched yet.
|
|
43
49
|
* Returns a Promise that resolves when the key arrives, or rejects on error.
|
|
@@ -67,6 +67,14 @@ export class TierFetcher {
|
|
|
67
67
|
async refetchKeys(keys) {
|
|
68
68
|
return this.fetcher.fetchKeys(keys);
|
|
69
69
|
}
|
|
70
|
+
/**
|
|
71
|
+
* Record keys as fetched under a tier WITHOUT performing a network fetch.
|
|
72
|
+
* Used when values are hydrated from persistent storage (the boot gate),
|
|
73
|
+
* so later context re-fetches correctly scope to these keys.
|
|
74
|
+
*/
|
|
75
|
+
markFetched(keys, tier) {
|
|
76
|
+
this.recordKeys(keys, tier);
|
|
77
|
+
}
|
|
70
78
|
// ─── Waiter management ───────────────────────────────────────
|
|
71
79
|
/**
|
|
72
80
|
* Register a waiter for a key that hasn't been fetched yet.
|
package/dist/initConfig.d.ts
CHANGED
|
@@ -22,7 +22,13 @@ export interface InitConfigOptions {
|
|
|
22
22
|
* Default: https://jewel998-config.web.app/api (demo instance)
|
|
23
23
|
*/
|
|
24
24
|
baseUrl?: string;
|
|
25
|
-
/**
|
|
25
|
+
/**
|
|
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
|
+
*/
|
|
26
32
|
storage?: CacheStorage;
|
|
27
33
|
/**
|
|
28
34
|
* Version polling interval in milliseconds.
|
package/dist/initConfig.js
CHANGED
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
// setContext() is fire-and-forget (debounced 100ms).
|
|
27
27
|
// Re-fetches only already-fetched keys in tier order.
|
|
28
28
|
// ═══════════════════════════════════════════════════════════════
|
|
29
|
-
import {
|
|
29
|
+
import { browserStorage } from "./cache/browserStorage.js";
|
|
30
30
|
import { SdkError, ConfigError } from "./errors/index.js";
|
|
31
31
|
import { TypedEventEmitter } from "./events/EventEmitter.js";
|
|
32
32
|
import { createProjectedFetcher } from "./fetch/projectedFetcher.js";
|
|
@@ -38,6 +38,34 @@ const DEFAULT_BASE_URL = "https://jewel998-config.web.app/api";
|
|
|
38
38
|
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
39
39
|
const CONTEXT_DEBOUNCE_MS = 100;
|
|
40
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__";
|
|
44
|
+
/**
|
|
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).
|
|
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
|
+
}
|
|
41
69
|
// ─── Implementation ────────────────────────────────────────────
|
|
42
70
|
export function initConfig(options) {
|
|
43
71
|
if (!options.clientId) {
|
|
@@ -49,7 +77,7 @@ export function initConfig(options) {
|
|
|
49
77
|
const defaults = options.defaults ?? {};
|
|
50
78
|
const pollInterval = options.pollInterval ?? 300_000;
|
|
51
79
|
const baseUrl = options.baseUrl ?? DEFAULT_BASE_URL;
|
|
52
|
-
const storage = options.storage ??
|
|
80
|
+
const storage = options.storage ?? browserStorage();
|
|
53
81
|
const retry = { ...DEFAULT_RETRY };
|
|
54
82
|
const timeoutMs = options.timeout ?? DEFAULT_TIMEOUT_MS;
|
|
55
83
|
const onError = options.onError;
|
|
@@ -67,6 +95,22 @@ export function initConfig(options) {
|
|
|
67
95
|
const tierFetcher = new TierFetcher(baseFetcher);
|
|
68
96
|
// ── In-memory data store ─────────────────────────────────────
|
|
69
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
|
+
}
|
|
70
114
|
function applyResult(result) {
|
|
71
115
|
const keys = Object.keys(result);
|
|
72
116
|
for (const key of keys) {
|
|
@@ -83,12 +127,64 @@ export function initConfig(options) {
|
|
|
83
127
|
return;
|
|
84
128
|
events.emit("updated", { keys, source });
|
|
85
129
|
}
|
|
86
|
-
// ──
|
|
130
|
+
// ── Remote version check (shared by boot gate + polling) ─────
|
|
131
|
+
async function fetchRemoteVersion() {
|
|
132
|
+
try {
|
|
133
|
+
const res = await fetch(`${baseUrl}/v1/version?clientId=${options.clientId}`, {
|
|
134
|
+
headers: cachedVersion ? { "If-None-Match": `"${cachedVersion}"` } : {},
|
|
135
|
+
});
|
|
136
|
+
// 304 => unchanged from the version we sent in If-None-Match.
|
|
137
|
+
if (res.status === 304)
|
|
138
|
+
return cachedVersion;
|
|
139
|
+
if (!res.ok)
|
|
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);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return hydrated;
|
|
162
|
+
}
|
|
163
|
+
// ── Tier 1: init prefetch (version-gated against persisted cache) ─
|
|
87
164
|
const readyPromise = initPrefetch.length > 0
|
|
88
165
|
? (async () => {
|
|
89
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
|
+
}
|
|
90
182
|
const result = await withRetry(() => tierFetcher.fetchTier(initPrefetch, "prefetch"), retry);
|
|
91
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);
|
|
92
188
|
tierFetcher.notifyWaiters(result);
|
|
93
189
|
emitUpdated(fetched, "prefetch");
|
|
94
190
|
}
|
|
@@ -104,12 +200,24 @@ export function initConfig(options) {
|
|
|
104
200
|
}
|
|
105
201
|
})()
|
|
106
202
|
: Promise.resolve();
|
|
107
|
-
// ── Tier 3: idle fetch
|
|
203
|
+
// ── Tier 3: idle fetch (skipped when the full snapshot is fresh) ──
|
|
108
204
|
readyPromise.then(() => {
|
|
109
205
|
scheduleIdleFetch(async () => {
|
|
110
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
|
+
}
|
|
111
216
|
const result = await tierFetcher.fetchAll();
|
|
112
217
|
const fetched = applyResult(result);
|
|
218
|
+
const v = await fetchRemoteVersion();
|
|
219
|
+
if (v !== null)
|
|
220
|
+
persistVersion(v);
|
|
113
221
|
tierFetcher.notifyWaiters(result);
|
|
114
222
|
emitUpdated(fetched, "idle");
|
|
115
223
|
}
|
|
@@ -120,28 +228,17 @@ export function initConfig(options) {
|
|
|
120
228
|
});
|
|
121
229
|
});
|
|
122
230
|
// ── Version polling ──────────────────────────────────────────
|
|
123
|
-
let cachedVersion = null;
|
|
124
231
|
async function checkVersion() {
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
if (!res.ok)
|
|
132
|
-
return;
|
|
133
|
-
const { version } = (await res.json());
|
|
134
|
-
if (cachedVersion === null) {
|
|
135
|
-
cachedVersion = version;
|
|
136
|
-
return;
|
|
137
|
-
}
|
|
138
|
-
if (version !== cachedVersion) {
|
|
139
|
-
cachedVersion = version;
|
|
140
|
-
await doRefresh();
|
|
141
|
-
}
|
|
232
|
+
const version = await fetchRemoteVersion();
|
|
233
|
+
if (version === null)
|
|
234
|
+
return;
|
|
235
|
+
if (cachedVersion === null) {
|
|
236
|
+
persistVersion(version);
|
|
237
|
+
return;
|
|
142
238
|
}
|
|
143
|
-
|
|
144
|
-
|
|
239
|
+
if (version !== cachedVersion) {
|
|
240
|
+
persistVersion(version);
|
|
241
|
+
await doRefresh();
|
|
145
242
|
}
|
|
146
243
|
}
|
|
147
244
|
if (pollInterval > 0 && typeof window !== "undefined") {
|