@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.
- package/dist/client/ConfigClient.js +38 -132
- package/dist/client/RefreshManager.d.ts +45 -0
- package/dist/client/RefreshManager.js +89 -0
- package/dist/client/ValueResolver.d.ts +39 -0
- package/dist/client/ValueResolver.js +131 -0
- package/dist/createConfig.js +1 -1
- package/dist/errors/index.d.ts +1 -1
- package/dist/fetch/batchFetcher.js +7 -0
- package/dist/fetch/projectedFetcher.js +7 -0
- package/dist/initConfig.d.ts +14 -1
- package/dist/initConfig.js +1 -0
- package/dist/retry/RetryEngine.js +12 -2
- package/dist/transport/CircuitBreaker.d.ts +54 -0
- package/dist/transport/CircuitBreaker.js +79 -0
- package/dist/transport/HttpTransport.js +87 -39
- package/dist/types.d.ts +8 -1
- package/package.json +1 -1
|
@@ -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
|
-
// ──
|
|
14
|
-
let inflightRefresh = null;
|
|
15
|
-
let lastFetchTime = 0;
|
|
16
|
+
// ── Debouncing ──
|
|
16
17
|
let contextDebounceTimer = null;
|
|
17
|
-
|
|
18
|
-
const
|
|
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
|
|
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
|
-
//
|
|
82
|
+
// Delegate to ValueResolver for plugin-based or simple resolution
|
|
76
83
|
if (plugins.length > 0) {
|
|
77
|
-
//
|
|
78
|
-
if (
|
|
79
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
package/dist/createConfig.js
CHANGED
|
@@ -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://
|
|
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) {
|
package/dist/errors/index.d.ts
CHANGED
|
@@ -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
|
package/dist/initConfig.d.ts
CHANGED
|
@@ -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)
|
package/dist/initConfig.js
CHANGED
|
@@ -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
|
|
9
|
-
|
|
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
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
|
|
38
|
-
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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>;
|