@jewel998/config 0.2.6 → 0.2.8
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 +2 -2
- package/dist/client/RefreshManager.js +5 -1
- package/dist/constants.d.ts +12 -0
- package/dist/constants.js +13 -0
- package/dist/fetch/TierFetcher.d.ts +4 -4
- package/dist/fetch/TierFetcher.js +3 -3
- package/dist/fetch/batchFetcher.js +6 -5
- package/dist/fetch/projectedFetcher.js +9 -8
- package/dist/initConfig.js +35 -24
- package/dist/loading/optimistic.js +1 -1
- package/dist/loading/pessimistic.js +1 -1
- package/dist/transport/HttpTransport.js +52 -17
- package/dist/transport/browserDiagnostics.d.ts +4 -0
- package/dist/transport/browserDiagnostics.js +69 -0
- package/dist/types.d.ts +13 -2
- package/package.json +1 -1
|
@@ -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);
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/** API paths used by the SDK transport. */
|
|
2
|
+
export declare const API_ENDPOINTS: {
|
|
3
|
+
readonly config: "v1/config";
|
|
4
|
+
readonly version: "v1/version";
|
|
5
|
+
};
|
|
6
|
+
/** Shared HTTP values used by browser and server transports. */
|
|
7
|
+
export declare const HTTP_CONSTANTS: {
|
|
8
|
+
readonly jsonContentType: "application/json";
|
|
9
|
+
readonly jsonAccept: "application/json";
|
|
10
|
+
readonly etagHeader: "ETag";
|
|
11
|
+
readonly ifNoneMatchHeader: "If-None-Match";
|
|
12
|
+
};
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/** API paths used by the SDK transport. */
|
|
2
|
+
export const API_ENDPOINTS = {
|
|
3
|
+
config: "v1/config",
|
|
4
|
+
version: "v1/version",
|
|
5
|
+
};
|
|
6
|
+
/** Shared HTTP values used by browser and server transports. */
|
|
7
|
+
export const HTTP_CONSTANTS = {
|
|
8
|
+
jsonContentType: "application/json",
|
|
9
|
+
jsonAccept: "application/json",
|
|
10
|
+
etagHeader: "ETag",
|
|
11
|
+
ifNoneMatchHeader: "If-None-Match",
|
|
12
|
+
};
|
|
13
|
+
//# sourceMappingURL=constants.js.map
|
|
@@ -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<
|
|
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<
|
|
32
|
+
fetchAll(): Promise<FetchResult>;
|
|
33
33
|
/** Check the remote version (passthrough). */
|
|
34
34
|
checkVersion(): ReturnType<ConfigFetcher["checkVersion"]>;
|
|
35
35
|
/**
|
|
@@ -37,7 +37,7 @@ 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<
|
|
40
|
+
refetchKeys(keys: string[]): Promise<FetchResult>;
|
|
41
41
|
/**
|
|
42
42
|
* Record keys as fetched under a tier WITHOUT performing a network fetch.
|
|
43
43
|
* Used when values are hydrated from persistent storage (the boot gate),
|
|
@@ -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);
|
|
@@ -1,15 +1,16 @@
|
|
|
1
|
+
import { API_ENDPOINTS } from "../constants.js";
|
|
1
2
|
export const createBatchFetcher = (transport) => ({
|
|
2
3
|
async fetchAll() {
|
|
3
|
-
const response = await transport.request(
|
|
4
|
-
return response.data;
|
|
4
|
+
const response = await transport.request(API_ENDPOINTS.config);
|
|
5
|
+
return { data: response.data, version: response.version ?? null };
|
|
5
6
|
},
|
|
6
7
|
async fetchKeys(_keys) {
|
|
7
8
|
// Batch mode always fetches everything
|
|
8
|
-
const response = await transport.request(
|
|
9
|
-
return response.data;
|
|
9
|
+
const response = await transport.request(API_ENDPOINTS.config);
|
|
10
|
+
return { data: response.data, version: response.version ?? null };
|
|
10
11
|
},
|
|
11
12
|
async checkVersion() {
|
|
12
|
-
const response = await transport.request(
|
|
13
|
+
const response = await transport.request(API_ENDPOINTS.version);
|
|
13
14
|
return {
|
|
14
15
|
version: String(response.version),
|
|
15
16
|
changedKeys: response.changedKeys ?? [],
|
|
@@ -1,10 +1,11 @@
|
|
|
1
|
+
import { API_ENDPOINTS } from "../constants.js";
|
|
1
2
|
export const createProjectedFetcher = (transport) => {
|
|
2
3
|
let pendingKeys = new Set();
|
|
3
4
|
let batchPromise = null;
|
|
4
5
|
return {
|
|
5
6
|
async fetchAll() {
|
|
6
|
-
const response = await transport.request(
|
|
7
|
-
return response.data;
|
|
7
|
+
const response = await transport.request(API_ENDPOINTS.config);
|
|
8
|
+
return { data: response.data, version: response.version ?? null };
|
|
8
9
|
},
|
|
9
10
|
async fetchKeys(keys) {
|
|
10
11
|
for (const key of keys) {
|
|
@@ -17,10 +18,10 @@ export const createProjectedFetcher = (transport) => {
|
|
|
17
18
|
pendingKeys = new Set();
|
|
18
19
|
batchPromise = null;
|
|
19
20
|
try {
|
|
20
|
-
const response = await transport.request(
|
|
21
|
+
const response = await transport.request(API_ENDPOINTS.config, {
|
|
21
22
|
keys: batchedKeys,
|
|
22
23
|
});
|
|
23
|
-
resolve(response.data);
|
|
24
|
+
resolve({ data: response.data, version: response.version ?? null });
|
|
24
25
|
}
|
|
25
26
|
catch (err) {
|
|
26
27
|
reject(err);
|
|
@@ -32,14 +33,14 @@ export const createProjectedFetcher = (transport) => {
|
|
|
32
33
|
// Return only the keys this caller requested
|
|
33
34
|
const filtered = {};
|
|
34
35
|
for (const key of keys) {
|
|
35
|
-
if (key in result) {
|
|
36
|
-
filtered[key] = result[key];
|
|
36
|
+
if (key in result.data) {
|
|
37
|
+
filtered[key] = result.data[key];
|
|
37
38
|
}
|
|
38
39
|
}
|
|
39
|
-
return filtered;
|
|
40
|
+
return { data: filtered, version: result.version };
|
|
40
41
|
},
|
|
41
42
|
async checkVersion() {
|
|
42
|
-
const response = await transport.request(
|
|
43
|
+
const response = await transport.request(API_ENDPOINTS.version);
|
|
43
44
|
return {
|
|
44
45
|
version: String(response.version),
|
|
45
46
|
changedKeys: response.changedKeys ?? [],
|
package/dist/initConfig.js
CHANGED
|
@@ -27,11 +27,13 @@
|
|
|
27
27
|
// Re-fetches only already-fetched keys in tier order.
|
|
28
28
|
// ═══════════════════════════════════════════════════════════════
|
|
29
29
|
import { browserStorage } from "./cache/browserStorage.js";
|
|
30
|
+
import { API_ENDPOINTS } from "./constants.js";
|
|
30
31
|
import { SdkError, ConfigError } from "./errors/index.js";
|
|
31
32
|
import { TypedEventEmitter } from "./events/EventEmitter.js";
|
|
32
33
|
import { createProjectedFetcher } from "./fetch/projectedFetcher.js";
|
|
33
34
|
import { TierFetcher } from "./fetch/TierFetcher.js";
|
|
34
35
|
import { withRetry } from "./retry/RetryEngine.js";
|
|
36
|
+
import { warnForFetchFailure, warnIfCspBlocksUrl } from "./transport/browserDiagnostics.js";
|
|
35
37
|
import { createHttpTransport } from "./transport/HttpTransport.js";
|
|
36
38
|
import { DEFAULT_CACHE_TTL, DEFAULT_RETRY } from "./types.js";
|
|
37
39
|
const DEFAULT_BASE_URL = "https://jewel998-config.web.app/api";
|
|
@@ -77,6 +79,7 @@ export function initConfig(options) {
|
|
|
77
79
|
const defaults = options.defaults ?? {};
|
|
78
80
|
const pollInterval = options.pollInterval ?? 300_000;
|
|
79
81
|
const baseUrl = options.baseUrl ?? DEFAULT_BASE_URL;
|
|
82
|
+
warnIfCspBlocksUrl(baseUrl);
|
|
80
83
|
const storage = options.storage ?? browserStorage();
|
|
81
84
|
const retry = { ...DEFAULT_RETRY };
|
|
82
85
|
const timeoutMs = options.timeout ?? DEFAULT_TIMEOUT_MS;
|
|
@@ -130,7 +133,7 @@ export function initConfig(options) {
|
|
|
130
133
|
// ── Remote version check (shared by boot gate + polling) ─────
|
|
131
134
|
async function fetchRemoteVersion() {
|
|
132
135
|
try {
|
|
133
|
-
const res = await fetch(`${baseUrl}
|
|
136
|
+
const res = await fetch(`${baseUrl}/${API_ENDPOINTS.version}?clientId=${options.clientId}`, {
|
|
134
137
|
headers: cachedVersion ? { "If-None-Match": `"${cachedVersion}"` } : {},
|
|
135
138
|
});
|
|
136
139
|
// 304 => unchanged from the version we sent in If-None-Match.
|
|
@@ -141,7 +144,8 @@ export function initConfig(options) {
|
|
|
141
144
|
const { version } = (await res.json());
|
|
142
145
|
return version ?? null;
|
|
143
146
|
}
|
|
144
|
-
catch {
|
|
147
|
+
catch (error) {
|
|
148
|
+
warnForFetchFailure(`${baseUrl}/${API_ENDPOINTS.version}`, error);
|
|
145
149
|
return null;
|
|
146
150
|
}
|
|
147
151
|
}
|
|
@@ -180,12 +184,12 @@ export function initConfig(options) {
|
|
|
180
184
|
cachedVersion = remote;
|
|
181
185
|
}
|
|
182
186
|
const result = await withRetry(() => tierFetcher.fetchTier(initPrefetch, "prefetch"), retry);
|
|
183
|
-
const fetched = applyResult(result);
|
|
184
|
-
//
|
|
185
|
-
|
|
186
|
-
if (
|
|
187
|
-
persistVersion(
|
|
188
|
-
tierFetcher.notifyWaiters(result);
|
|
187
|
+
const fetched = applyResult(result.data);
|
|
188
|
+
// Persist the version delivered alongside these values — no extra
|
|
189
|
+
// /version round trip needed; the /config response carries it.
|
|
190
|
+
if (result.version !== null)
|
|
191
|
+
persistVersion(result.version);
|
|
192
|
+
tierFetcher.notifyWaiters(result.data);
|
|
189
193
|
emitUpdated(fetched, "prefetch");
|
|
190
194
|
}
|
|
191
195
|
catch (err) {
|
|
@@ -214,11 +218,10 @@ export function initConfig(options) {
|
|
|
214
218
|
cachedVersion = remote;
|
|
215
219
|
}
|
|
216
220
|
const result = await tierFetcher.fetchAll();
|
|
217
|
-
const fetched = applyResult(result);
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
tierFetcher.notifyWaiters(result);
|
|
221
|
+
const fetched = applyResult(result.data);
|
|
222
|
+
if (result.version !== null)
|
|
223
|
+
persistVersion(result.version);
|
|
224
|
+
tierFetcher.notifyWaiters(result.data);
|
|
222
225
|
emitUpdated(fetched, "idle");
|
|
223
226
|
}
|
|
224
227
|
catch {
|
|
@@ -261,18 +264,22 @@ export function initConfig(options) {
|
|
|
261
264
|
if (pf.length > 0) {
|
|
262
265
|
refetches.push(withRetry(() => tierFetcher.refetchKeys(pf), retry)
|
|
263
266
|
.then((r) => {
|
|
264
|
-
applyResult(r);
|
|
265
|
-
|
|
266
|
-
|
|
267
|
+
applyResult(r.data);
|
|
268
|
+
if (r.version !== null)
|
|
269
|
+
persistVersion(r.version);
|
|
270
|
+
tierFetcher.notifyWaiters(r.data);
|
|
271
|
+
allFetched.push(...Object.keys(r.data));
|
|
267
272
|
})
|
|
268
273
|
.catch(() => { }));
|
|
269
274
|
}
|
|
270
275
|
if (page.length > 0) {
|
|
271
276
|
refetches.push(withRetry(() => tierFetcher.refetchKeys(page), retry)
|
|
272
277
|
.then((r) => {
|
|
273
|
-
applyResult(r);
|
|
274
|
-
|
|
275
|
-
|
|
278
|
+
applyResult(r.data);
|
|
279
|
+
if (r.version !== null)
|
|
280
|
+
persistVersion(r.version);
|
|
281
|
+
tierFetcher.notifyWaiters(r.data);
|
|
282
|
+
allFetched.push(...Object.keys(r.data));
|
|
276
283
|
})
|
|
277
284
|
.catch(() => { }));
|
|
278
285
|
}
|
|
@@ -280,9 +287,11 @@ export function initConfig(options) {
|
|
|
280
287
|
refetches.push(tierFetcher
|
|
281
288
|
.fetchAll()
|
|
282
289
|
.then((r) => {
|
|
283
|
-
applyResult(r);
|
|
284
|
-
|
|
285
|
-
|
|
290
|
+
applyResult(r.data);
|
|
291
|
+
if (r.version !== null)
|
|
292
|
+
persistVersion(r.version);
|
|
293
|
+
tierFetcher.notifyWaiters(r.data);
|
|
294
|
+
allFetched.push(...Object.keys(r.data));
|
|
286
295
|
})
|
|
287
296
|
.catch(() => { }));
|
|
288
297
|
}
|
|
@@ -300,8 +309,10 @@ export function initConfig(options) {
|
|
|
300
309
|
void (async () => {
|
|
301
310
|
try {
|
|
302
311
|
const result = await withRetry(() => tierFetcher.fetchTier(keys, "page"), retry);
|
|
303
|
-
const fetched = applyResult(result);
|
|
304
|
-
|
|
312
|
+
const fetched = applyResult(result.data);
|
|
313
|
+
if (result.version !== null)
|
|
314
|
+
persistVersion(result.version);
|
|
315
|
+
tierFetcher.notifyWaiters(result.data);
|
|
305
316
|
emitUpdated(fetched, "page");
|
|
306
317
|
}
|
|
307
318
|
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);
|
|
@@ -1,15 +1,19 @@
|
|
|
1
1
|
/// <reference lib="dom" />
|
|
2
|
+
import { API_ENDPOINTS, HTTP_CONSTANTS } from "../constants.js";
|
|
2
3
|
import { AuthenticationError, ConfigError, RateLimitError } from "../errors/index.js";
|
|
4
|
+
import { warnForFetchFailure, warnIfCspBlocksUrl } from "./browserDiagnostics.js";
|
|
3
5
|
import { CircuitBreaker } from "./CircuitBreaker.js";
|
|
4
6
|
/** Errors that should permanently stop retrying (circuit opens) */
|
|
5
7
|
const FATAL_STATUS_CODES = new Set([400, 401, 403]);
|
|
6
8
|
/** Cooldown before trying again after a fatal error (5 minutes) */
|
|
7
9
|
const CIRCUIT_COOLDOWN_MS = 5 * 60 * 1000;
|
|
8
10
|
export const createHttpTransport = (config) => {
|
|
11
|
+
warnIfCspBlocksUrl(config.baseUrl);
|
|
9
12
|
const circuitBreaker = new CircuitBreaker({
|
|
10
13
|
fatalCodes: FATAL_STATUS_CODES,
|
|
11
14
|
cooldownMs: CIRCUIT_COOLDOWN_MS,
|
|
12
15
|
});
|
|
16
|
+
let versionCache;
|
|
13
17
|
return {
|
|
14
18
|
async request(endpoint, body) {
|
|
15
19
|
// ── Circuit breaker check ──────────────────────────────
|
|
@@ -35,25 +39,50 @@ export const createHttpTransport = (config) => {
|
|
|
35
39
|
// Client keys (cid_) use POST with body (server-side evaluation needs context).
|
|
36
40
|
// Server keys (svr_) use GET with query params (fetches full flag data).
|
|
37
41
|
const isServerKey = config.clientId.startsWith("svr_");
|
|
42
|
+
const isVersionRequest = endpoint === API_ENDPOINTS.version;
|
|
43
|
+
const cachedVersion = isVersionRequest ? versionCache : undefined;
|
|
44
|
+
const conditionalHeaders = cachedVersion
|
|
45
|
+
? { [HTTP_CONSTANTS.ifNoneMatchHeader]: cachedVersion.etag }
|
|
46
|
+
: {};
|
|
38
47
|
let response;
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
48
|
+
try {
|
|
49
|
+
if (isServerKey) {
|
|
50
|
+
// GET request — clientId as query param
|
|
51
|
+
const params = new URLSearchParams({ clientId: config.clientId });
|
|
52
|
+
if (body?.keys && Array.isArray(body.keys)) {
|
|
53
|
+
params.set("keys", body.keys.join(","));
|
|
54
|
+
}
|
|
55
|
+
response = await fetch(`${url}?${params.toString()}`, {
|
|
56
|
+
method: "GET",
|
|
57
|
+
headers: { Accept: HTTP_CONSTANTS.jsonAccept, ...conditionalHeaders },
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
else {
|
|
61
|
+
// POST request — full body with context
|
|
62
|
+
response = await fetch(url, {
|
|
63
|
+
method: "POST",
|
|
64
|
+
headers: {
|
|
65
|
+
"Content-Type": HTTP_CONSTANTS.jsonContentType,
|
|
66
|
+
...conditionalHeaders,
|
|
67
|
+
},
|
|
68
|
+
body: JSON.stringify({ data: requestData }),
|
|
69
|
+
});
|
|
44
70
|
}
|
|
45
|
-
response = await fetch(`${url}?${params.toString()}`, {
|
|
46
|
-
method: "GET",
|
|
47
|
-
headers: { Accept: "application/json" },
|
|
48
|
-
});
|
|
49
71
|
}
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
72
|
+
catch (error) {
|
|
73
|
+
warnForFetchFailure(url, error);
|
|
74
|
+
throw error;
|
|
75
|
+
}
|
|
76
|
+
// A conditional version request has no response body on 304. Reuse the
|
|
77
|
+
// last successful body so callers never attempt to parse an empty body.
|
|
78
|
+
if (response.status === 304 && cachedVersion) {
|
|
79
|
+
circuitBreaker.recordSuccess();
|
|
80
|
+
return cachedVersion.body;
|
|
81
|
+
}
|
|
82
|
+
if (response.status === 304) {
|
|
83
|
+
const error = new ConfigError("Version endpoint returned 304 but no cached response is available", "NETWORK_ERROR");
|
|
84
|
+
circuitBreaker.recordFailure(response.status, error);
|
|
85
|
+
throw error;
|
|
57
86
|
}
|
|
58
87
|
if (!response.ok) {
|
|
59
88
|
const errorBody = await response.json().catch(() => ({}));
|
|
@@ -103,7 +132,13 @@ export const createHttpTransport = (config) => {
|
|
|
103
132
|
}
|
|
104
133
|
// Success: record in circuit breaker (closes if HALF_OPEN)
|
|
105
134
|
circuitBreaker.recordSuccess();
|
|
106
|
-
|
|
135
|
+
const responseBody = (await response.json());
|
|
136
|
+
if (isVersionRequest) {
|
|
137
|
+
const etag = response.headers.get(HTTP_CONSTANTS.etagHeader);
|
|
138
|
+
if (etag)
|
|
139
|
+
versionCache = { etag, body: responseBody };
|
|
140
|
+
}
|
|
141
|
+
return responseBody;
|
|
107
142
|
},
|
|
108
143
|
};
|
|
109
144
|
};
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
/** Warn when a readable meta CSP blocks the SDK's API connection. */
|
|
2
|
+
export declare function warnIfCspBlocksUrl(baseUrl: string): void;
|
|
3
|
+
/** Fetch failures are intentionally non-fatal; cached/default values can continue. */
|
|
4
|
+
export declare function warnForFetchFailure(url: string, error: unknown): void;
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/// <reference lib="dom" />
|
|
2
|
+
const warnedMessages = new Set();
|
|
3
|
+
function warnOnce(message, cause) {
|
|
4
|
+
if (warnedMessages.has(message))
|
|
5
|
+
return;
|
|
6
|
+
warnedMessages.add(message);
|
|
7
|
+
if (cause === undefined)
|
|
8
|
+
console.warn(`[config] ${message}`);
|
|
9
|
+
else
|
|
10
|
+
console.warn(`[config] ${message}`, cause);
|
|
11
|
+
}
|
|
12
|
+
function sourceAllowsUrl(source, target) {
|
|
13
|
+
const normalized = source.replace(/^['"]|['"]$/g, "");
|
|
14
|
+
if (normalized === "*")
|
|
15
|
+
return true;
|
|
16
|
+
if (normalized === "none")
|
|
17
|
+
return false;
|
|
18
|
+
if (normalized === "self")
|
|
19
|
+
return target.origin === window.location.origin;
|
|
20
|
+
if (/^[a-z][a-z\d+.-]*:$/i.test(normalized))
|
|
21
|
+
return target.protocol === normalized;
|
|
22
|
+
try {
|
|
23
|
+
const sourceUrl = new URL(normalized);
|
|
24
|
+
if (sourceUrl.protocol !== target.protocol)
|
|
25
|
+
return false;
|
|
26
|
+
const sourceHost = sourceUrl.hostname.toLowerCase();
|
|
27
|
+
const targetHost = target.hostname.toLowerCase();
|
|
28
|
+
const hostMatches = sourceHost.startsWith("*.")
|
|
29
|
+
? targetHost.endsWith(sourceHost.slice(1))
|
|
30
|
+
: sourceHost === targetHost;
|
|
31
|
+
const portMatches = !sourceUrl.port || sourceUrl.port === target.port;
|
|
32
|
+
return hostMatches && portMatches;
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
/** Warn when a readable meta CSP blocks the SDK's API connection. */
|
|
39
|
+
export function warnIfCspBlocksUrl(baseUrl) {
|
|
40
|
+
if (typeof window === "undefined" || typeof document === "undefined")
|
|
41
|
+
return;
|
|
42
|
+
let target;
|
|
43
|
+
try {
|
|
44
|
+
target = new URL(baseUrl, window.location.href);
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
const policies = Array.from(document.querySelectorAll('meta[http-equiv="Content-Security-Policy"]'))
|
|
50
|
+
.map((meta) => meta.content)
|
|
51
|
+
.filter(Boolean);
|
|
52
|
+
for (const policy of policies) {
|
|
53
|
+
const directives = policy.split(";").map((directive) => directive.trim().split(/\s+/));
|
|
54
|
+
const connect = directives.find(([name]) => name === "connect-src");
|
|
55
|
+
const fallback = directives.find(([name]) => name === "default-src");
|
|
56
|
+
const sources = connect?.slice(1) ?? fallback?.slice(1);
|
|
57
|
+
if (sources && !sources.some((source) => sourceAllowsUrl(source, target))) {
|
|
58
|
+
warnOnce(`CSP may block requests to ${target.origin}. Add ${target.origin} to connect-src (or default-src) in your Content-Security-Policy.`);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
/** Fetch failures are intentionally non-fatal; cached/default values can continue. */
|
|
63
|
+
export function warnForFetchFailure(url, error) {
|
|
64
|
+
if (typeof window === "undefined")
|
|
65
|
+
return;
|
|
66
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
67
|
+
warnOnce(`The config request to ${url} failed. This may be a CORS or CSP connect-src restriction; the SDK will continue using cached/default values.`, message);
|
|
68
|
+
}
|
|
69
|
+
//# sourceMappingURL=browserDiagnostics.js.map
|
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<
|
|
153
|
+
fetchAll(): Promise<FetchResult>;
|
|
143
154
|
/** Fetch specific keys only */
|
|
144
|
-
fetchKeys(keys: string[]): Promise<
|
|
155
|
+
fetchKeys(keys: string[]): Promise<FetchResult>;
|
|
145
156
|
/** Check current config version (lightweight — single Firestore read) */
|
|
146
157
|
checkVersion(): Promise<{
|
|
147
158
|
version: string;
|