@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.
@@ -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.
@@ -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,12 +127,64 @@ 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
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
- 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
- }
232
+ const version = await fetchRemoteVersion();
233
+ if (version === null)
234
+ return;
235
+ if (cachedVersion === null) {
236
+ persistVersion(version);
237
+ return;
142
238
  }
143
- catch {
144
- // Silently ignore polling errors
239
+ if (version !== cachedVersion) {
240
+ persistVersion(version);
241
+ await doRefresh();
145
242
  }
146
243
  }
147
244
  if (pollInterval > 0 && typeof window !== "undefined") {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jewel998/config",
3
- "version": "0.2.4",
3
+ "version": "0.2.6",
4
4
  "private": false,
5
5
  "description": "Free, self-hosted feature flags and remote configuration SDK",
6
6
  "keywords": [