@jewel998/config 0.2.4 → 0.2.7

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.
@@ -32,7 +32,7 @@ export const buildConfigClient = (internals) => {
32
32
  batchFetchTriggered = true;
33
33
  void (async () => {
34
34
  try {
35
- const result = await withRetry(() => fetcher.fetchAll(), retry);
35
+ const { data: result } = await withRetry(() => fetcher.fetchAll(), retry);
36
36
  cache.set("__all__", result, DEFAULT_CACHE_TTL);
37
37
  for (const [key, value] of Object.entries(result)) {
38
38
  cache.set(key, value, DEFAULT_CACHE_TTL);
@@ -58,7 +58,7 @@ export const buildConfigClient = (internals) => {
58
58
  return;
59
59
  void (async () => {
60
60
  try {
61
- const result = await fetcher.fetchKeys([key]);
61
+ const { data: result } = await fetcher.fetchKeys([key]);
62
62
  for (const [k, v] of Object.entries(result)) {
63
63
  cache.set(k, v, DEFAULT_CACHE_TTL);
64
64
  data[k] = v;
@@ -59,7 +59,11 @@ export class RefreshManager {
59
59
  catch {
60
60
  // If version check fails, proceed with full fetch as fallback
61
61
  }
62
- const result = await withRetry(() => fetcher.fetchAll(), retry);
62
+ const { data: result, version: responseVersion } = await withRetry(() => fetcher.fetchAll(), retry);
63
+ // Sync the version from the config response itself — avoids drifting
64
+ // from the value learned by the earlier checkVersion() probe.
65
+ if (responseVersion !== null)
66
+ this.cachedVersion = responseVersion;
63
67
  cache.set("__all__", result, DEFAULT_CACHE_TTL);
64
68
  for (const [key, value] of Object.entries(result)) {
65
69
  cache.set(key, value, DEFAULT_CACHE_TTL);
@@ -1,4 +1,4 @@
1
- import type { ConfigFetcher } from "../types.js";
1
+ import type { ConfigFetcher, FetchResult } from "../types.js";
2
2
  export type FetchTier = "prefetch" | "page" | "idle";
3
3
  /**
4
4
  * TierFetcher — wraps a ConfigFetcher with three responsibilities:
@@ -24,12 +24,12 @@ export declare class TierFetcher {
24
24
  * Fetch a specific set of keys under a given tier.
25
25
  * Keys already tracked under any tier are skipped (deduplication).
26
26
  */
27
- fetchTier(keys: string[], tier: FetchTier): Promise<Record<string, unknown>>;
27
+ fetchTier(keys: string[], tier: FetchTier): Promise<FetchResult>;
28
28
  /**
29
29
  * Fetch all remaining keys (idle tier).
30
30
  * Only keys not already tracked are added to the idle set.
31
31
  */
32
- fetchAll(): Promise<Record<string, unknown>>;
32
+ fetchAll(): Promise<FetchResult>;
33
33
  /** Check the remote version (passthrough). */
34
34
  checkVersion(): ReturnType<ConfigFetcher["checkVersion"]>;
35
35
  /**
@@ -37,7 +37,13 @@ export declare class TierFetcher {
37
37
  * Used by doRefresh() — we're intentionally re-fetching already-fetched keys
38
38
  * with a new context, so the allFetched deduplication should be bypassed.
39
39
  */
40
- refetchKeys(keys: string[]): Promise<Record<string, unknown>>;
40
+ refetchKeys(keys: string[]): Promise<FetchResult>;
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.
@@ -36,9 +36,9 @@ export class TierFetcher {
36
36
  async fetchTier(keys, tier) {
37
37
  const toFetch = keys.filter((k) => !this.allFetched.has(k));
38
38
  if (toFetch.length === 0)
39
- return {};
39
+ return { data: {}, version: null };
40
40
  const result = await this.fetcher.fetchKeys(toFetch);
41
- this.recordKeys(Object.keys(result), tier);
41
+ this.recordKeys(Object.keys(result.data), tier);
42
42
  return result;
43
43
  }
44
44
  /**
@@ -47,7 +47,7 @@ export class TierFetcher {
47
47
  */
48
48
  async fetchAll() {
49
49
  const result = await this.fetcher.fetchAll();
50
- for (const key of Object.keys(result)) {
50
+ for (const key of Object.keys(result.data)) {
51
51
  if (!this.allFetched.has(key)) {
52
52
  this.fetchedKeys.get("idle").add(key);
53
53
  this.allFetched.add(key);
@@ -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.
@@ -1,12 +1,12 @@
1
1
  export const createBatchFetcher = (transport) => ({
2
2
  async fetchAll() {
3
3
  const response = await transport.request("v1/config");
4
- return response.data;
4
+ return { data: response.data, version: response.version ?? null };
5
5
  },
6
6
  async fetchKeys(_keys) {
7
7
  // Batch mode always fetches everything
8
8
  const response = await transport.request("v1/config");
9
- return response.data;
9
+ return { data: response.data, version: response.version ?? null };
10
10
  },
11
11
  async checkVersion() {
12
12
  const response = await transport.request("v1/version");
@@ -4,7 +4,7 @@ export const createProjectedFetcher = (transport) => {
4
4
  return {
5
5
  async fetchAll() {
6
6
  const response = await transport.request("v1/config");
7
- return response.data;
7
+ return { data: response.data, version: response.version ?? null };
8
8
  },
9
9
  async fetchKeys(keys) {
10
10
  for (const key of keys) {
@@ -20,7 +20,7 @@ export const createProjectedFetcher = (transport) => {
20
20
  const response = await transport.request("v1/config", {
21
21
  keys: batchedKeys,
22
22
  });
23
- resolve(response.data);
23
+ resolve({ data: response.data, version: response.version ?? null });
24
24
  }
25
25
  catch (err) {
26
26
  reject(err);
@@ -32,11 +32,11 @@ export const createProjectedFetcher = (transport) => {
32
32
  // Return only the keys this caller requested
33
33
  const filtered = {};
34
34
  for (const key of keys) {
35
- if (key in result) {
36
- filtered[key] = result[key];
35
+ if (key in result.data) {
36
+ filtered[key] = result.data[key];
37
37
  }
38
38
  }
39
- return filtered;
39
+ return { data: filtered, version: result.version };
40
40
  },
41
41
  async checkVersion() {
42
42
  const response = await transport.request("v1/version");
@@ -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
- /** Cache storage adapter. Default: memoryStorage() */
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.
@@ -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 { memoryStorage } from "./cache/memoryStorage.js";
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 ?? memoryStorage();
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,13 +127,65 @@ export function initConfig(options) {
83
127
  return;
84
128
  events.emit("updated", { keys, source });
85
129
  }
86
- // ── Tier 1: init prefetch ────────────────────────────────────
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
- const fetched = applyResult(result);
92
- tierFetcher.notifyWaiters(result);
183
+ const fetched = applyResult(result.data);
184
+ // Persist the version delivered alongside these values — no extra
185
+ // /version round trip needed; the /config response carries it.
186
+ if (result.version !== null)
187
+ persistVersion(result.version);
188
+ tierFetcher.notifyWaiters(result.data);
93
189
  emitUpdated(fetched, "prefetch");
94
190
  }
95
191
  catch (err) {
@@ -104,13 +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
- const fetched = applyResult(result);
113
- tierFetcher.notifyWaiters(result);
217
+ const fetched = applyResult(result.data);
218
+ if (result.version !== null)
219
+ persistVersion(result.version);
220
+ tierFetcher.notifyWaiters(result.data);
114
221
  emitUpdated(fetched, "idle");
115
222
  }
116
223
  catch {
@@ -120,28 +227,17 @@ export function initConfig(options) {
120
227
  });
121
228
  });
122
229
  // ── Version polling ──────────────────────────────────────────
123
- let cachedVersion = null;
124
230
  async function checkVersion() {
125
- try {
126
- const res = await fetch(`${baseUrl}/v1/version?clientId=${options.clientId}`, {
127
- headers: cachedVersion ? { "If-None-Match": `"${cachedVersion}"` } : {},
128
- });
129
- if (res.status === 304)
130
- return;
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
- }
231
+ const version = await fetchRemoteVersion();
232
+ if (version === null)
233
+ return;
234
+ if (cachedVersion === null) {
235
+ persistVersion(version);
236
+ return;
142
237
  }
143
- catch {
144
- // Silently ignore polling errors
238
+ if (version !== cachedVersion) {
239
+ persistVersion(version);
240
+ await doRefresh();
145
241
  }
146
242
  }
147
243
  if (pollInterval > 0 && typeof window !== "undefined") {
@@ -164,18 +260,22 @@ export function initConfig(options) {
164
260
  if (pf.length > 0) {
165
261
  refetches.push(withRetry(() => tierFetcher.refetchKeys(pf), retry)
166
262
  .then((r) => {
167
- applyResult(r);
168
- tierFetcher.notifyWaiters(r);
169
- allFetched.push(...Object.keys(r));
263
+ applyResult(r.data);
264
+ if (r.version !== null)
265
+ persistVersion(r.version);
266
+ tierFetcher.notifyWaiters(r.data);
267
+ allFetched.push(...Object.keys(r.data));
170
268
  })
171
269
  .catch(() => { }));
172
270
  }
173
271
  if (page.length > 0) {
174
272
  refetches.push(withRetry(() => tierFetcher.refetchKeys(page), retry)
175
273
  .then((r) => {
176
- applyResult(r);
177
- tierFetcher.notifyWaiters(r);
178
- allFetched.push(...Object.keys(r));
274
+ applyResult(r.data);
275
+ if (r.version !== null)
276
+ persistVersion(r.version);
277
+ tierFetcher.notifyWaiters(r.data);
278
+ allFetched.push(...Object.keys(r.data));
179
279
  })
180
280
  .catch(() => { }));
181
281
  }
@@ -183,9 +283,11 @@ export function initConfig(options) {
183
283
  refetches.push(tierFetcher
184
284
  .fetchAll()
185
285
  .then((r) => {
186
- applyResult(r);
187
- tierFetcher.notifyWaiters(r);
188
- allFetched.push(...Object.keys(r));
286
+ applyResult(r.data);
287
+ if (r.version !== null)
288
+ persistVersion(r.version);
289
+ tierFetcher.notifyWaiters(r.data);
290
+ allFetched.push(...Object.keys(r.data));
189
291
  })
190
292
  .catch(() => { }));
191
293
  }
@@ -203,8 +305,10 @@ export function initConfig(options) {
203
305
  void (async () => {
204
306
  try {
205
307
  const result = await withRetry(() => tierFetcher.fetchTier(keys, "page"), retry);
206
- const fetched = applyResult(result);
207
- tierFetcher.notifyWaiters(result);
308
+ const fetched = applyResult(result.data);
309
+ if (result.version !== null)
310
+ persistVersion(result.version);
311
+ tierFetcher.notifyWaiters(result.data);
208
312
  emitUpdated(fetched, "page");
209
313
  }
210
314
  catch (err) {
@@ -3,7 +3,7 @@ import { DEFAULT_CACHE_TTL } from "../types.js";
3
3
  const scheduleBackgroundFetch = (ctx) => {
4
4
  const doFetch = async () => {
5
5
  try {
6
- const data = await withRetry(() => ctx.fetcher.fetchAll(), ctx.retry);
6
+ const { data } = await withRetry(() => ctx.fetcher.fetchAll(), ctx.retry);
7
7
  ctx.cache.set("__all__", data, DEFAULT_CACHE_TTL);
8
8
  for (const [key, value] of Object.entries(data)) {
9
9
  ctx.cache.set(key, value, DEFAULT_CACHE_TTL);
@@ -7,7 +7,7 @@ export const executePessimistic = async (ctx) => {
7
7
  setTimeout(() => reject(new TimeoutError(ctx.timeout)), ctx.timeout);
8
8
  });
9
9
  try {
10
- const data = await Promise.race([fetchPromise, timeoutPromise]);
10
+ const { data } = await Promise.race([fetchPromise, timeoutPromise]);
11
11
  ctx.cache.set("__all__", data, DEFAULT_CACHE_TTL);
12
12
  for (const [key, value] of Object.entries(data)) {
13
13
  ctx.cache.set(key, value, DEFAULT_CACHE_TTL);
package/dist/types.d.ts CHANGED
@@ -137,11 +137,22 @@ export interface LoadingResult {
137
137
  export interface LoadingStrategyHandler {
138
138
  execute(context: LoadingContext): LoadingResult | Promise<LoadingResult>;
139
139
  }
140
+ /**
141
+ * Result of a config fetch — the resolved values plus the config version
142
+ * they correspond to. The version comes from the same `/config` response
143
+ * that delivered the data, so the SDK can persist it without a second
144
+ * `/version` round trip.
145
+ */
146
+ export interface FetchResult {
147
+ data: Record<string, unknown>;
148
+ /** Config version from the response, or null if the response omitted it. */
149
+ version: string | null;
150
+ }
140
151
  export interface ConfigFetcher {
141
152
  /** Fetch all configs for the bound project+environment */
142
- fetchAll(): Promise<Record<string, unknown>>;
153
+ fetchAll(): Promise<FetchResult>;
143
154
  /** Fetch specific keys only */
144
- fetchKeys(keys: string[]): Promise<Record<string, unknown>>;
155
+ fetchKeys(keys: string[]): Promise<FetchResult>;
145
156
  /** Check current config version (lightweight — single Firestore read) */
146
157
  checkVersion(): Promise<{
147
158
  version: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jewel998/config",
3
- "version": "0.2.4",
3
+ "version": "0.2.7",
4
4
  "private": false,
5
5
  "description": "Free, self-hosted feature flags and remote configuration SDK",
6
6
  "keywords": [