@jewel998/config 0.1.8 → 0.2.1

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.
@@ -1,6 +1,9 @@
1
1
  import { withRetry } from "../retry/RetryEngine.js";
2
- import { evaluatePipeline } from "../plugins/evaluatePipeline.js";
3
2
  import { DEFAULT_CACHE_TTL } from "../types.js";
3
+ import { RefreshManager } from "./RefreshManager.js";
4
+ import { ValueResolver } from "./ValueResolver.js";
5
+ const MIN_FETCH_INTERVAL = 30_000; // Don't re-fetch within 30s
6
+ const CONTEXT_DEBOUNCE_MS = 100; // Batch rapid setContext calls
4
7
  export const buildConfigClient = (internals) => {
5
8
  const { cache, fetcher, events, retry, granularity, isDeferred } = internals;
6
9
  const plugins = internals.plugins ?? [];
@@ -10,16 +13,21 @@ export const buildConfigClient = (internals) => {
10
13
  const onContextChange = internals.onContextChange;
11
14
  let data = { ...internals.data };
12
15
  let batchFetchTriggered = false;
13
- // ── Request deduplication & debouncing ──
14
- let inflightRefresh = null;
15
- let lastFetchTime = 0;
16
+ // ── Debouncing ──
16
17
  let contextDebounceTimer = null;
17
- const MIN_FETCH_INTERVAL = 30_000; // Don't re-fetch within 30s
18
- const CONTEXT_DEBOUNCE_MS = 100; // Batch rapid setContext calls
18
+ // ── Extracted components ──
19
+ const refreshManager = new RefreshManager({
20
+ fetcher,
21
+ cache,
22
+ events,
23
+ retry,
24
+ minFetchInterval: MIN_FETCH_INTERVAL,
25
+ });
26
+ const valueResolver = new ValueResolver(plugins, data, cache, events);
27
+ // ── Deferred fetch helpers ──
19
28
  const triggerDeferredFetch = () => {
20
- if (!isDeferred || batchFetchTriggered) {
29
+ if (!isDeferred || batchFetchTriggered)
21
30
  return;
22
- }
23
31
  if (granularity === "batch") {
24
32
  batchFetchTriggered = true;
25
33
  void (async () => {
@@ -29,7 +37,7 @@ export const buildConfigClient = (internals) => {
29
37
  for (const [key, value] of Object.entries(result)) {
30
38
  cache.set(key, value, DEFAULT_CACHE_TTL);
31
39
  }
32
- data = { ...data, ...result };
40
+ Object.assign(data, result);
33
41
  events.emit("updated", {
34
42
  keys: Object.keys(result),
35
43
  source: "background",
@@ -46,9 +54,8 @@ export const buildConfigClient = (internals) => {
46
54
  }
47
55
  };
48
56
  const triggerProjectedFetch = (key) => {
49
- if (!isDeferred || granularity !== "projected") {
57
+ if (!isDeferred || granularity !== "projected")
50
58
  return;
51
- }
52
59
  void (async () => {
53
60
  try {
54
61
  const result = await fetcher.fetchKeys([key]);
@@ -72,75 +79,20 @@ export const buildConfigClient = (internals) => {
72
79
  };
73
80
  const client = {
74
81
  getValue(key, defaultValue) {
75
- // If plugins are registered, use the evaluation pipeline
82
+ // Delegate to ValueResolver for plugin-based or simple resolution
76
83
  if (plugins.length > 0) {
77
- // Consent-aware mode: if enabled and consent not granted, return default immediately
78
- if (consentAware && evalContext.consentGranted !== true) {
79
- return defaultValue;
80
- }
81
- // Get raw flag data from cache/data store
82
- const rawValue = data[key] ?? cache.get(key);
83
- // If we have no data at all for this key, trigger deferred fetch and return default
84
- if (rawValue === undefined) {
85
- if (isDeferred) {
86
- triggerDeferredFetch();
87
- triggerProjectedFetch(key);
88
- }
84
+ // Check if data is missing and trigger deferred fetch
85
+ if (valueResolver.isMissing(key) && isDeferred) {
86
+ triggerDeferredFetch();
87
+ triggerProjectedFetch(key);
89
88
  return defaultValue;
90
89
  }
91
- // Build a ConfigFlagData object from the raw data
92
- // If the data is already a full ConfigFlagData object (has 'key' and 'value' fields), use it
93
- // Otherwise, wrap the raw value as a simple flag
94
- const flag = isConfigFlagData(rawValue)
95
- ? rawValue
96
- : {
97
- key,
98
- value: rawValue,
99
- valueType: inferValueType(rawValue),
100
- version: "0",
101
- lifecycleState: "active",
102
- };
103
- // Build pipeline helpers
104
- const helpers = {
105
- evaluateFlag: (flagKey, ctx) => {
106
- // Recursive evaluation for prerequisites
107
- const flagData = data[flagKey] ?? cache.get(flagKey);
108
- if (flagData === undefined)
109
- return undefined;
110
- const innerFlag = isConfigFlagData(flagData)
111
- ? flagData
112
- : {
113
- key: flagKey,
114
- value: flagData,
115
- valueType: inferValueType(flagData),
116
- version: "0",
117
- lifecycleState: "active",
118
- };
119
- return evaluatePipeline(plugins, innerFlag, ctx, helpers);
120
- },
121
- emitError: (message) => {
122
- events.emit("fetchError", {
123
- error: new Error(message),
124
- retryCount: 0,
125
- willRetry: false,
126
- });
127
- },
128
- now: () => Date.now(),
129
- };
130
- const result = evaluatePipeline(plugins, flag, evalContext, helpers);
131
- return (result !== undefined ? result : defaultValue);
132
- }
133
- // No plugins — use existing behavior (backward compat)
134
- // Check live data first
135
- if (key in data) {
136
- return data[key];
137
- }
138
- // Check cache
139
- const cached = cache.get(key);
140
- if (cached !== undefined) {
141
- data[key] = cached;
142
- return cached;
90
+ return valueResolver.resolve(key, evalContext, defaultValue, consentAware);
143
91
  }
92
+ // No plugins — use simple resolution via ValueResolver
93
+ const result = valueResolver.resolveSimple(key, defaultValue);
94
+ if (result !== undefined)
95
+ return result;
144
96
  // Trigger deferred fetch if applicable
145
97
  if (isDeferred) {
146
98
  triggerDeferredFetch();
@@ -153,48 +105,18 @@ export const buildConfigClient = (internals) => {
153
105
  return value === true;
154
106
  },
155
107
  getAll() {
156
- // Check cache for full batch
157
108
  const cached = cache.get("__all__");
158
109
  if (cached) {
159
- data = { ...data, ...cached };
110
+ Object.assign(data, cached);
160
111
  return { ...data };
161
112
  }
162
- // Trigger deferred fetch if applicable
163
113
  if (isDeferred) {
164
114
  triggerDeferredFetch();
165
115
  }
166
116
  return { ...data };
167
117
  },
168
118
  async refresh() {
169
- // Deduplication: if a refresh is already in-flight, reuse it
170
- if (inflightRefresh)
171
- return inflightRefresh;
172
- inflightRefresh = (async () => {
173
- try {
174
- const result = await withRetry(() => fetcher.fetchAll(), retry);
175
- cache.set("__all__", result, DEFAULT_CACHE_TTL);
176
- for (const [key, value] of Object.entries(result)) {
177
- cache.set(key, value, DEFAULT_CACHE_TTL);
178
- }
179
- data = { ...data, ...result };
180
- lastFetchTime = Date.now();
181
- events.emit("updated", {
182
- keys: Object.keys(result),
183
- source: "refresh",
184
- });
185
- }
186
- catch (error) {
187
- events.emit("fetchError", {
188
- error: error,
189
- retryCount: retry.maxRetries,
190
- willRetry: false,
191
- });
192
- }
193
- finally {
194
- inflightRefresh = null;
195
- }
196
- })();
197
- return inflightRefresh;
119
+ await refreshManager.refresh(data);
198
120
  },
199
121
  on(event, callback) {
200
122
  events.on(event, callback);
@@ -204,7 +126,6 @@ export const buildConfigClient = (internals) => {
204
126
  },
205
127
  setContext(newContext) {
206
128
  evalContext = { ...newContext };
207
- // Notify the transport layer of the context change
208
129
  if (onContextChange) {
209
130
  onContextChange(evalContext);
210
131
  }
@@ -215,35 +136,20 @@ export const buildConfigClient = (internals) => {
215
136
  clearTimeout(contextDebounceTimer);
216
137
  contextDebounceTimer = setTimeout(() => {
217
138
  contextDebounceTimer = null;
218
- // Skip if we fetched very recently (stale check)
219
- if (Date.now() - lastFetchTime < MIN_FETCH_INTERVAL)
139
+ if (refreshManager.isRecent)
220
140
  return;
221
141
  void client.refresh();
222
142
  }, CONTEXT_DEBOUNCE_MS);
223
143
  }
224
144
  },
145
+ destroy() {
146
+ if (contextDebounceTimer) {
147
+ clearTimeout(contextDebounceTimer);
148
+ contextDebounceTimer = null;
149
+ }
150
+ events.removeAllListeners();
151
+ },
225
152
  };
226
153
  return client;
227
154
  };
228
- // ═══════════════════════════════════════════════════════════════
229
- // Helpers
230
- // ═══════════════════════════════════════════════════════════════
231
- /** Type guard: checks if a value looks like a full ConfigFlagData object */
232
- function isConfigFlagData(value) {
233
- return (typeof value === "object" &&
234
- value !== null &&
235
- "key" in value &&
236
- "value" in value &&
237
- "lifecycleState" in value);
238
- }
239
- /** Infer a valueType string from a raw value */
240
- function inferValueType(value) {
241
- if (typeof value === "boolean")
242
- return "boolean";
243
- if (typeof value === "number")
244
- return "number";
245
- if (typeof value === "string")
246
- return "string";
247
- return "json";
248
- }
249
155
  //# sourceMappingURL=ConfigClient.js.map
@@ -0,0 +1,45 @@
1
+ /**
2
+ * RefreshManager — handles version-gated, deduplicated config refreshes.
3
+ *
4
+ * Responsibilities:
5
+ * - Version checking before full fetch (skip if unchanged)
6
+ * - In-flight request deduplication (only one refresh at a time)
7
+ * - Tracking time since last fetch (for stale-check gating)
8
+ * - Updating cache and data store on successful refresh
9
+ */
10
+ import type { CacheStorage, ConfigFetcher, EventEmitterInterface, RetryConfig } from "../types.js";
11
+ export interface RefreshManagerConfig {
12
+ /** Fetcher for remote config data */
13
+ fetcher: ConfigFetcher;
14
+ /** Cache storage adapter */
15
+ cache: CacheStorage;
16
+ /** Event emitter for lifecycle notifications */
17
+ events: EventEmitterInterface;
18
+ /** Retry configuration */
19
+ retry: Required<RetryConfig>;
20
+ /** Minimum interval between fetches in ms */
21
+ minFetchInterval: number;
22
+ }
23
+ export declare class RefreshManager {
24
+ private config;
25
+ private inflightRefresh;
26
+ private lastFetchTime;
27
+ private cachedVersion;
28
+ constructor(config: RefreshManagerConfig);
29
+ /** Time in ms since the last successful fetch */
30
+ get timeSinceLastFetch(): number;
31
+ /** Whether a fetch has happened recently (within minFetchInterval) */
32
+ get isRecent(): boolean;
33
+ /**
34
+ * Perform a version-gated refresh with deduplication.
35
+ *
36
+ * 1. If a refresh is already in-flight, returns the existing promise.
37
+ * 2. Checks the remote version first (lightweight call).
38
+ * 3. If version is unchanged, emits an update event and skips full fetch.
39
+ * 4. Otherwise performs a full fetch with retry, updates cache & data.
40
+ *
41
+ * @param data - Mutable data record to update with fetched values
42
+ * @returns The updated data record
43
+ */
44
+ refresh(data: Record<string, unknown>): Promise<Record<string, unknown>>;
45
+ }
@@ -0,0 +1,89 @@
1
+ /**
2
+ * RefreshManager — handles version-gated, deduplicated config refreshes.
3
+ *
4
+ * Responsibilities:
5
+ * - Version checking before full fetch (skip if unchanged)
6
+ * - In-flight request deduplication (only one refresh at a time)
7
+ * - Tracking time since last fetch (for stale-check gating)
8
+ * - Updating cache and data store on successful refresh
9
+ */
10
+ import { withRetry } from "../retry/RetryEngine.js";
11
+ import { DEFAULT_CACHE_TTL } from "../types.js";
12
+ export class RefreshManager {
13
+ config;
14
+ inflightRefresh = null;
15
+ lastFetchTime = 0;
16
+ cachedVersion = null;
17
+ constructor(config) {
18
+ this.config = config;
19
+ }
20
+ /** Time in ms since the last successful fetch */
21
+ get timeSinceLastFetch() {
22
+ return Date.now() - this.lastFetchTime;
23
+ }
24
+ /** Whether a fetch has happened recently (within minFetchInterval) */
25
+ get isRecent() {
26
+ return this.timeSinceLastFetch < this.config.minFetchInterval;
27
+ }
28
+ /**
29
+ * Perform a version-gated refresh with deduplication.
30
+ *
31
+ * 1. If a refresh is already in-flight, returns the existing promise.
32
+ * 2. Checks the remote version first (lightweight call).
33
+ * 3. If version is unchanged, emits an update event and skips full fetch.
34
+ * 4. Otherwise performs a full fetch with retry, updates cache & data.
35
+ *
36
+ * @param data - Mutable data record to update with fetched values
37
+ * @returns The updated data record
38
+ */
39
+ async refresh(data) {
40
+ // Deduplication: if a refresh is already in-flight, reuse it
41
+ if (this.inflightRefresh) {
42
+ await this.inflightRefresh;
43
+ return data;
44
+ }
45
+ const { fetcher, cache, events, retry } = this.config;
46
+ this.inflightRefresh = (async () => {
47
+ try {
48
+ // Version-gated refresh: check version first (lightweight call)
49
+ try {
50
+ const { version } = await fetcher.checkVersion();
51
+ if (this.cachedVersion !== null && version === this.cachedVersion) {
52
+ // Version unchanged — skip full fetch
53
+ events.emit("updated", { keys: [], source: "version-check" });
54
+ this.lastFetchTime = Date.now();
55
+ return;
56
+ }
57
+ this.cachedVersion = version;
58
+ }
59
+ catch {
60
+ // If version check fails, proceed with full fetch as fallback
61
+ }
62
+ const result = await withRetry(() => fetcher.fetchAll(), retry);
63
+ cache.set("__all__", result, DEFAULT_CACHE_TTL);
64
+ for (const [key, value] of Object.entries(result)) {
65
+ cache.set(key, value, DEFAULT_CACHE_TTL);
66
+ }
67
+ Object.assign(data, result);
68
+ this.lastFetchTime = Date.now();
69
+ events.emit("updated", {
70
+ keys: Object.keys(result),
71
+ source: "refresh",
72
+ });
73
+ }
74
+ catch (error) {
75
+ events.emit("fetchError", {
76
+ error: error,
77
+ retryCount: retry.maxRetries,
78
+ willRetry: false,
79
+ });
80
+ }
81
+ finally {
82
+ this.inflightRefresh = null;
83
+ }
84
+ })();
85
+ await this.inflightRefresh;
86
+ return data;
87
+ }
88
+ }
89
+ //# sourceMappingURL=RefreshManager.js.map
@@ -0,0 +1,39 @@
1
+ /**
2
+ * ValueResolver — resolves config values through the plugin evaluation pipeline.
3
+ *
4
+ * Responsibilities:
5
+ * - Looking up raw flag data from in-memory data or cache
6
+ * - Running the evaluation pipeline when plugins are registered
7
+ * - Handling consent-aware mode (GDPR compliance)
8
+ * - Providing backward-compatible behavior when no plugins are present
9
+ */
10
+ import type { EvaluationContext, EvaluationPlugin } from "../plugins/types.js";
11
+ import type { CacheStorage, EventEmitterInterface } from "../types.js";
12
+ export declare class ValueResolver {
13
+ private plugins;
14
+ private data;
15
+ private cache;
16
+ private events;
17
+ constructor(plugins: EvaluationPlugin[], data: Record<string, unknown>, cache: CacheStorage, events: EventEmitterInterface);
18
+ /**
19
+ * Resolve a value through the plugin pipeline or via direct lookup.
20
+ *
21
+ * @param key - Config key to resolve
22
+ * @param context - Current evaluation context
23
+ * @param defaultValue - Fallback value if key is not found or no plugin resolves
24
+ * @param consentAware - If true, blocks evaluation unless consentGranted is true
25
+ * @returns The resolved value, or defaultValue if unresolved
26
+ */
27
+ resolve<T>(key: string, context: EvaluationContext, defaultValue?: T, consentAware?: boolean): T | undefined;
28
+ /**
29
+ * Simple resolution without plugins — checks data then cache.
30
+ * Returns undefined (or defaultValue) if the key is missing entirely.
31
+ */
32
+ resolveSimple<T>(key: string, defaultValue?: T): T | undefined;
33
+ /**
34
+ * Resolution with plugin pipeline — evaluates flag data through registered plugins.
35
+ */
36
+ private resolveWithPlugins;
37
+ /** Whether data is missing for a given key (triggers deferred fetch) */
38
+ isMissing(key: string): boolean;
39
+ }
@@ -0,0 +1,131 @@
1
+ /**
2
+ * ValueResolver — resolves config values through the plugin evaluation pipeline.
3
+ *
4
+ * Responsibilities:
5
+ * - Looking up raw flag data from in-memory data or cache
6
+ * - Running the evaluation pipeline when plugins are registered
7
+ * - Handling consent-aware mode (GDPR compliance)
8
+ * - Providing backward-compatible behavior when no plugins are present
9
+ */
10
+ import { evaluatePipeline } from "../plugins/evaluatePipeline.js";
11
+ export class ValueResolver {
12
+ plugins;
13
+ data;
14
+ cache;
15
+ events;
16
+ constructor(plugins, data, cache, events) {
17
+ this.plugins = plugins;
18
+ this.data = data;
19
+ this.cache = cache;
20
+ this.events = events;
21
+ }
22
+ /**
23
+ * Resolve a value through the plugin pipeline or via direct lookup.
24
+ *
25
+ * @param key - Config key to resolve
26
+ * @param context - Current evaluation context
27
+ * @param defaultValue - Fallback value if key is not found or no plugin resolves
28
+ * @param consentAware - If true, blocks evaluation unless consentGranted is true
29
+ * @returns The resolved value, or defaultValue if unresolved
30
+ */
31
+ resolve(key, context, defaultValue, consentAware = false) {
32
+ if (this.plugins.length > 0) {
33
+ return this.resolveWithPlugins(key, context, defaultValue, consentAware);
34
+ }
35
+ // No plugins — use existing behavior (backward compat)
36
+ return this.resolveSimple(key, defaultValue);
37
+ }
38
+ /**
39
+ * Simple resolution without plugins — checks data then cache.
40
+ * Returns undefined (or defaultValue) if the key is missing entirely.
41
+ */
42
+ resolveSimple(key, defaultValue) {
43
+ if (key in this.data) {
44
+ return this.data[key];
45
+ }
46
+ const cached = this.cache.get(key);
47
+ if (cached !== undefined) {
48
+ this.data[key] = cached;
49
+ return cached;
50
+ }
51
+ return defaultValue;
52
+ }
53
+ /**
54
+ * Resolution with plugin pipeline — evaluates flag data through registered plugins.
55
+ */
56
+ resolveWithPlugins(key, context, defaultValue, consentAware = false) {
57
+ // Consent-aware mode: if enabled and consent not granted, return default
58
+ if (consentAware && context.consentGranted !== true) {
59
+ return defaultValue;
60
+ }
61
+ // Get raw flag data from data store or cache
62
+ const rawValue = this.data[key] ?? this.cache.get(key);
63
+ if (rawValue === undefined) {
64
+ return defaultValue;
65
+ }
66
+ // Build a ConfigFlagData object from the raw data
67
+ const flag = isConfigFlagData(rawValue)
68
+ ? rawValue
69
+ : {
70
+ key,
71
+ value: rawValue,
72
+ valueType: inferValueType(rawValue),
73
+ version: "0",
74
+ lifecycleState: "active",
75
+ };
76
+ // Build pipeline helpers
77
+ const helpers = {
78
+ evaluateFlag: (flagKey, ctx) => {
79
+ const flagData = this.data[flagKey] ?? this.cache.get(flagKey);
80
+ if (flagData === undefined)
81
+ return undefined;
82
+ const innerFlag = isConfigFlagData(flagData)
83
+ ? flagData
84
+ : {
85
+ key: flagKey,
86
+ value: flagData,
87
+ valueType: inferValueType(flagData),
88
+ version: "0",
89
+ lifecycleState: "active",
90
+ };
91
+ return evaluatePipeline(this.plugins, innerFlag, ctx, helpers);
92
+ },
93
+ emitError: (message) => {
94
+ this.events.emit("fetchError", {
95
+ error: new Error(message),
96
+ retryCount: 0,
97
+ willRetry: false,
98
+ });
99
+ },
100
+ now: () => Date.now(),
101
+ };
102
+ const result = evaluatePipeline(this.plugins, flag, context, helpers);
103
+ return (result !== undefined ? result : defaultValue);
104
+ }
105
+ /** Whether data is missing for a given key (triggers deferred fetch) */
106
+ isMissing(key) {
107
+ return !(key in this.data) && this.cache.get(key) === undefined;
108
+ }
109
+ }
110
+ // ═══════════════════════════════════════════════════════════════
111
+ // Helpers
112
+ // ═══════════════════════════════════════════════════════════════
113
+ /** Type guard: checks if a value looks like a full ConfigFlagData object */
114
+ function isConfigFlagData(value) {
115
+ return (typeof value === "object" &&
116
+ value !== null &&
117
+ "key" in value &&
118
+ "value" in value &&
119
+ "lifecycleState" in value);
120
+ }
121
+ /** Infer a valueType string from a raw value */
122
+ function inferValueType(value) {
123
+ if (typeof value === "boolean")
124
+ return "boolean";
125
+ if (typeof value === "number")
126
+ return "number";
127
+ if (typeof value === "string")
128
+ return "string";
129
+ return "json";
130
+ }
131
+ //# sourceMappingURL=ValueResolver.js.map
@@ -9,7 +9,7 @@ import { executeOptimistic } from "./loading/optimistic.js";
9
9
  import { executePessimistic } from "./loading/pessimistic.js";
10
10
  import { createHttpTransport } from "./transport/HttpTransport.js";
11
11
  import { DEFAULT_RETRY, DEFAULT_TIMEOUT } from "./types.js";
12
- const DEFAULT_BASE_URL = "https://us-central1-jewel998-config.cloudfunctions.net";
12
+ const DEFAULT_BASE_URL = "https://jewel998-config.web.app/api";
13
13
  export function createConfig(options) {
14
14
  // 1. Validate clientId
15
15
  if (!options.clientId) {
@@ -1,4 +1,4 @@
1
- export type ConfigErrorCode = "MISSING_CLIENT_ID" | "AUTHENTICATION_FAILED" | "INITIALIZATION_FAILED" | "TIMEOUT" | "RATE_LIMITED" | "REVOKED" | "NETWORK_ERROR";
1
+ export type ConfigErrorCode = "MISSING_CLIENT_ID" | "AUTHENTICATION_FAILED" | "INITIALIZATION_FAILED" | "TIMEOUT" | "RATE_LIMITED" | "REVOKED" | "NETWORK_ERROR" | "BAD_REQUEST" | "FORBIDDEN" | "NOT_FOUND" | "METHOD_NOT_ALLOWED" | "PAYLOAD_TOO_LARGE" | "CONFLICT" | "SERVER_ERROR";
2
2
  export declare class ConfigError extends Error {
3
3
  readonly code: ConfigErrorCode;
4
4
  name: string;
@@ -8,5 +8,12 @@ export const createBatchFetcher = (transport) => ({
8
8
  const response = await transport.request("getConfig");
9
9
  return response.data;
10
10
  },
11
+ async checkVersion() {
12
+ const response = await transport.request("getVersion");
13
+ return {
14
+ version: String(response.version),
15
+ changedKeys: response.changedKeys ?? [],
16
+ };
17
+ },
11
18
  });
12
19
  //# sourceMappingURL=batchFetcher.js.map
@@ -38,6 +38,13 @@ export const createProjectedFetcher = (transport) => {
38
38
  }
39
39
  return filtered;
40
40
  },
41
+ async checkVersion() {
42
+ const response = await transport.request("getVersion");
43
+ return {
44
+ version: String(response.version),
45
+ changedKeys: response.changedKeys ?? [],
46
+ };
47
+ },
41
48
  };
42
49
  };
43
50
  //# sourceMappingURL=projectedFetcher.js.map
@@ -1,4 +1,4 @@
1
- import type { ConfigEventCallback, ConfigEventType } from "./types.js";
1
+ import type { CacheStorage, ConfigEventCallback, ConfigEventType } from "./types.js";
2
2
  import type { EvaluationContext } from "./plugins/types.js";
3
3
  export interface InitConfigOptions {
4
4
  /** Required. API key from the portal (cid_ for client, svr_ for server). */
@@ -17,6 +17,19 @@ export interface InitConfigOptions {
17
17
  * @example "https://your-project.web.app/api"
18
18
  */
19
19
  baseUrl?: string;
20
+ /**
21
+ * Cache storage adapter. Controls where resolved config values are persisted.
22
+ *
23
+ * - `memoryStorage()` (default) — In-memory, lost on page reload
24
+ * - `browserStorage()` — localStorage, persists across page reloads and sessions
25
+ *
26
+ * @example
27
+ * ```ts
28
+ * import { browserStorage } from "@jewel998/config";
29
+ * initConfig({ storage: browserStorage({ prefix: "myapp" }) })
30
+ * ```
31
+ */
32
+ storage?: CacheStorage;
20
33
  /**
21
34
  * Polling interval in milliseconds for version checks.
22
35
  * The SDK periodically calls /api/version (lightweight ~100 bytes)
@@ -42,6 +42,7 @@ export function initConfig(options) {
42
42
  clientId: options.clientId,
43
43
  loadingStrategy: "optimistic",
44
44
  context: options.context,
45
+ storage: options.storage,
45
46
  baseUrl,
46
47
  });
47
48
  // Version-based polling: check /api/version periodically, delta-fetch on change
@@ -5,8 +5,18 @@ export const isNonRetryableError = (error) => {
5
5
  if (error instanceof AuthenticationError) {
6
6
  return true;
7
7
  }
8
- if (error instanceof ConfigError && error.code === "REVOKED") {
9
- return true;
8
+ if (error instanceof ConfigError) {
9
+ // Client errors — retrying won't help
10
+ switch (error.code) {
11
+ case "REVOKED":
12
+ case "BAD_REQUEST":
13
+ case "FORBIDDEN":
14
+ case "NOT_FOUND":
15
+ case "METHOD_NOT_ALLOWED":
16
+ case "PAYLOAD_TOO_LARGE":
17
+ case "CONFLICT":
18
+ return true;
19
+ }
10
20
  }
11
21
  return false;
12
22
  };
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Circuit Breaker — prevents repeated requests to a failing endpoint.
3
+ *
4
+ * States:
5
+ * - CLOSED: Normal operation, all requests flow through.
6
+ * - OPEN: A fatal error occurred; requests are blocked until cooldown expires.
7
+ * - HALF_OPEN: Cooldown expired; one probe request is allowed through.
8
+ *
9
+ * On success in HALF_OPEN, the circuit transitions back to CLOSED.
10
+ * On a fatal failure (status code in the fatalCodes set), the circuit opens.
11
+ */
12
+ export type CircuitState = "CLOSED" | "OPEN" | "HALF_OPEN";
13
+ export interface CircuitBreakerConfig {
14
+ /** HTTP status codes that trigger the circuit to open */
15
+ fatalCodes: Set<number>;
16
+ /** Cooldown in ms before transitioning from OPEN to HALF_OPEN */
17
+ cooldownMs: number;
18
+ }
19
+ export declare class CircuitBreaker {
20
+ private config;
21
+ private state;
22
+ private openedAt;
23
+ private lastError;
24
+ constructor(config: CircuitBreakerConfig);
25
+ /** Current circuit state */
26
+ get currentState(): CircuitState;
27
+ /**
28
+ * Check if a request should be allowed through.
29
+ * If the circuit is OPEN and the cooldown has passed, transitions to HALF_OPEN.
30
+ *
31
+ * @returns true if the request can proceed, false if blocked
32
+ */
33
+ canExecute(): boolean;
34
+ /**
35
+ * Record a successful response.
36
+ * Closes the circuit if currently in HALF_OPEN state.
37
+ */
38
+ recordSuccess(): void;
39
+ /**
40
+ * Record a failed response.
41
+ * Opens the circuit if the status code is in the fatal set.
42
+ *
43
+ * @param statusCode - HTTP status code of the failed response
44
+ * @param error - The error to cache for re-throwing while the circuit is open
45
+ */
46
+ recordFailure(statusCode: number, error: Error): void;
47
+ /**
48
+ * Get the cached fatal error (for throwing without making a network call).
49
+ * Returns null if no fatal error has been recorded.
50
+ */
51
+ getCachedError(): Error | null;
52
+ /** Reset the circuit breaker to CLOSED state, clearing any cached error. */
53
+ reset(): void;
54
+ }
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Circuit Breaker — prevents repeated requests to a failing endpoint.
3
+ *
4
+ * States:
5
+ * - CLOSED: Normal operation, all requests flow through.
6
+ * - OPEN: A fatal error occurred; requests are blocked until cooldown expires.
7
+ * - HALF_OPEN: Cooldown expired; one probe request is allowed through.
8
+ *
9
+ * On success in HALF_OPEN, the circuit transitions back to CLOSED.
10
+ * On a fatal failure (status code in the fatalCodes set), the circuit opens.
11
+ */
12
+ export class CircuitBreaker {
13
+ config;
14
+ state = "CLOSED";
15
+ openedAt = 0;
16
+ lastError = null;
17
+ constructor(config) {
18
+ this.config = config;
19
+ }
20
+ /** Current circuit state */
21
+ get currentState() {
22
+ return this.state;
23
+ }
24
+ /**
25
+ * Check if a request should be allowed through.
26
+ * If the circuit is OPEN and the cooldown has passed, transitions to HALF_OPEN.
27
+ *
28
+ * @returns true if the request can proceed, false if blocked
29
+ */
30
+ canExecute() {
31
+ if (this.state === "CLOSED" || this.state === "HALF_OPEN") {
32
+ return true;
33
+ }
34
+ // OPEN state — check cooldown
35
+ if (Date.now() - this.openedAt >= this.config.cooldownMs) {
36
+ this.state = "HALF_OPEN";
37
+ return true;
38
+ }
39
+ return false;
40
+ }
41
+ /**
42
+ * Record a successful response.
43
+ * Closes the circuit if currently in HALF_OPEN state.
44
+ */
45
+ recordSuccess() {
46
+ if (this.state === "HALF_OPEN") {
47
+ this.state = "CLOSED";
48
+ this.lastError = null;
49
+ }
50
+ }
51
+ /**
52
+ * Record a failed response.
53
+ * Opens the circuit if the status code is in the fatal set.
54
+ *
55
+ * @param statusCode - HTTP status code of the failed response
56
+ * @param error - The error to cache for re-throwing while the circuit is open
57
+ */
58
+ recordFailure(statusCode, error) {
59
+ if (this.config.fatalCodes.has(statusCode)) {
60
+ this.state = "OPEN";
61
+ this.openedAt = Date.now();
62
+ this.lastError = error;
63
+ }
64
+ }
65
+ /**
66
+ * Get the cached fatal error (for throwing without making a network call).
67
+ * Returns null if no fatal error has been recorded.
68
+ */
69
+ getCachedError() {
70
+ return this.lastError;
71
+ }
72
+ /** Reset the circuit breaker to CLOSED state, clearing any cached error. */
73
+ reset() {
74
+ this.state = "CLOSED";
75
+ this.openedAt = 0;
76
+ this.lastError = null;
77
+ }
78
+ }
79
+ //# sourceMappingURL=CircuitBreaker.js.map
@@ -1,45 +1,93 @@
1
1
  /// <reference lib="dom" />
2
2
  import { AuthenticationError, ConfigError } from "../errors/index.js";
3
- export const createHttpTransport = (config) => ({
4
- async request(endpoint, body) {
5
- const url = `${config.baseUrl}/${endpoint}`;
6
- const requestData = {
7
- clientId: config.clientId,
8
- ...body,
9
- };
10
- // In server mode (cid_ key), include the current context for server-side evaluation
11
- if (config.evaluationMode === "server" && config.getContext) {
12
- const ctx = config.getContext();
13
- if (ctx && (ctx.userId || ctx.attributes)) {
14
- requestData.context = {
15
- ...(ctx.userId && { userId: ctx.userId }),
16
- ...(ctx.attributes && { attributes: ctx.attributes }),
17
- };
3
+ import { CircuitBreaker } from "./CircuitBreaker.js";
4
+ /** Errors that should permanently stop retrying (circuit opens) */
5
+ const FATAL_STATUS_CODES = new Set([400, 401, 403]);
6
+ /** Cooldown before trying again after a fatal error (5 minutes) */
7
+ const CIRCUIT_COOLDOWN_MS = 5 * 60 * 1000;
8
+ export const createHttpTransport = (config) => {
9
+ const circuitBreaker = new CircuitBreaker({
10
+ fatalCodes: FATAL_STATUS_CODES,
11
+ cooldownMs: CIRCUIT_COOLDOWN_MS,
12
+ });
13
+ return {
14
+ async request(endpoint, body) {
15
+ // ── Circuit breaker check ──────────────────────────────
16
+ if (!circuitBreaker.canExecute()) {
17
+ throw (circuitBreaker.getCachedError() ??
18
+ new ConfigError("API circuit breaker is open — requests are blocked due to a previous fatal error", "FORBIDDEN"));
18
19
  }
19
- }
20
- const response = await fetch(url, {
21
- method: "POST",
22
- headers: {
23
- "Content-Type": "application/json",
24
- },
25
- body: JSON.stringify({
26
- data: requestData,
27
- }),
28
- });
29
- if (!response.ok) {
30
- const errorBody = await response.json().catch(() => ({}));
31
- const errorData = errorBody;
32
- const code = errorData.error?.code ?? "NETWORK_ERROR";
33
- const message = errorData.error?.message ?? `HTTP ${response.status}`;
34
- if (response.status === 401) {
35
- throw new AuthenticationError(message);
20
+ const url = `${config.baseUrl}/${endpoint}`;
21
+ const requestData = {
22
+ clientId: config.clientId,
23
+ ...body,
24
+ };
25
+ // In server mode (cid_ key), include the current context for server-side evaluation
26
+ if (config.evaluationMode === "server" && config.getContext) {
27
+ const ctx = config.getContext();
28
+ if (ctx && (ctx.userId || ctx.attributes)) {
29
+ requestData.context = {
30
+ ...(ctx.userId && { userId: ctx.userId }),
31
+ ...(ctx.attributes && { attributes: ctx.attributes }),
32
+ };
33
+ }
36
34
  }
37
- if (response.status === 429) {
38
- throw new ConfigError(message, "RATE_LIMITED");
35
+ const response = await fetch(url, {
36
+ method: "POST",
37
+ headers: {
38
+ "Content-Type": "application/json",
39
+ },
40
+ body: JSON.stringify({
41
+ data: requestData,
42
+ }),
43
+ });
44
+ if (!response.ok) {
45
+ const errorBody = await response.json().catch(() => ({}));
46
+ const errorData = errorBody;
47
+ const message = errorData.error?.message ?? `HTTP ${response.status}`;
48
+ let error;
49
+ switch (response.status) {
50
+ case 400:
51
+ error = new ConfigError(message, "BAD_REQUEST");
52
+ break;
53
+ case 401:
54
+ error = new AuthenticationError(message);
55
+ break;
56
+ case 403:
57
+ error = new ConfigError(message, "FORBIDDEN");
58
+ break;
59
+ case 404:
60
+ error = new ConfigError(message, "NOT_FOUND");
61
+ break;
62
+ case 405:
63
+ error = new ConfigError(message, "METHOD_NOT_ALLOWED");
64
+ break;
65
+ case 409:
66
+ error = new ConfigError(message, "CONFLICT");
67
+ break;
68
+ case 413:
69
+ error = new ConfigError(message, "PAYLOAD_TOO_LARGE");
70
+ break;
71
+ case 429:
72
+ error = new ConfigError(message, "RATE_LIMITED");
73
+ break;
74
+ case 500:
75
+ case 502:
76
+ case 503:
77
+ case 504:
78
+ error = new ConfigError(message, "SERVER_ERROR");
79
+ break;
80
+ default:
81
+ error = new ConfigError(message, "NETWORK_ERROR");
82
+ }
83
+ // Record failure in circuit breaker
84
+ circuitBreaker.recordFailure(response.status, error);
85
+ throw error;
39
86
  }
40
- throw new ConfigError(message, code);
41
- }
42
- return (await response.json());
43
- },
44
- });
87
+ // Success: record in circuit breaker (closes if HALF_OPEN)
88
+ circuitBreaker.recordSuccess();
89
+ return (await response.json());
90
+ },
91
+ };
92
+ };
45
93
  //# sourceMappingURL=HttpTransport.js.map
package/dist/types.d.ts CHANGED
@@ -64,6 +64,8 @@ export interface ConfigClient {
64
64
  on<E extends ConfigEventType>(event: E, callback: ConfigEventCallback<E>): void;
65
65
  /** Unsubscribe from a lifecycle event. */
66
66
  off<E extends ConfigEventType>(event: E, callback: ConfigEventCallback<E>): void;
67
+ /** Clean up timers, listeners, and in-flight requests. */
68
+ destroy(): void;
67
69
  }
68
70
  export interface RetryConfig {
69
71
  /** Maximum number of retry attempts. Default: 3 */
@@ -104,7 +106,7 @@ export interface ConfigEventPayloads {
104
106
  };
105
107
  updated: {
106
108
  keys: string[];
107
- source: "background" | "refresh";
109
+ source: "background" | "refresh" | "version-check";
108
110
  };
109
111
  fetchError: {
110
112
  error: Error;
@@ -138,6 +140,11 @@ export interface ConfigFetcher {
138
140
  fetchAll(): Promise<Record<string, unknown>>;
139
141
  /** Fetch specific keys only */
140
142
  fetchKeys(keys: string[]): Promise<Record<string, unknown>>;
143
+ /** Check current config version (lightweight — single Firestore read) */
144
+ checkVersion(): Promise<{
145
+ version: string;
146
+ changedKeys: string[];
147
+ }>;
141
148
  }
142
149
  export interface HttpTransport {
143
150
  request<T>(endpoint: string, body?: Record<string, unknown>): Promise<T>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jewel998/config",
3
- "version": "0.1.8",
3
+ "version": "0.2.1",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "sideEffects": false,