@jewel998/config 0.2.7 → 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.
@@ -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,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("v1/config");
4
+ const response = await transport.request(API_ENDPOINTS.config);
4
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("v1/config");
9
+ const response = await transport.request(API_ENDPOINTS.config);
9
10
  return { data: response.data, version: response.version ?? null };
10
11
  },
11
12
  async checkVersion() {
12
- const response = await transport.request("v1/version");
13
+ const response = await transport.request(API_ENDPOINTS.version);
13
14
  return {
14
15
  version: String(response.version),
15
16
  changedKeys: response.changedKeys ?? [],
@@ -1,9 +1,10 @@
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("v1/config");
7
+ const response = await transport.request(API_ENDPOINTS.config);
7
8
  return { data: response.data, version: response.version ?? null };
8
9
  },
9
10
  async fetchKeys(keys) {
@@ -17,7 +18,7 @@ export const createProjectedFetcher = (transport) => {
17
18
  pendingKeys = new Set();
18
19
  batchPromise = null;
19
20
  try {
20
- const response = await transport.request("v1/config", {
21
+ const response = await transport.request(API_ENDPOINTS.config, {
21
22
  keys: batchedKeys,
22
23
  });
23
24
  resolve({ data: response.data, version: response.version ?? null });
@@ -39,7 +40,7 @@ export const createProjectedFetcher = (transport) => {
39
40
  return { data: filtered, version: result.version };
40
41
  },
41
42
  async checkVersion() {
42
- const response = await transport.request("v1/version");
43
+ const response = await transport.request(API_ENDPOINTS.version);
43
44
  return {
44
45
  version: String(response.version),
45
46
  changedKeys: response.changedKeys ?? [],
@@ -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}/v1/version?clientId=${options.clientId}`, {
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
  }
@@ -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
- if (isServerKey) {
40
- // GET request — clientId as query param
41
- const params = new URLSearchParams({ clientId: config.clientId });
42
- if (body?.keys && Array.isArray(body.keys)) {
43
- params.set("keys", body.keys.join(","));
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
- else {
51
- // POST request — full body with context
52
- response = await fetch(url, {
53
- method: "POST",
54
- headers: { "Content-Type": "application/json" },
55
- body: JSON.stringify({ data: requestData }),
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
- return (await response.json());
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jewel998/config",
3
- "version": "0.2.7",
3
+ "version": "0.2.8",
4
4
  "private": false,
5
5
  "description": "Free, self-hosted feature flags and remote configuration SDK",
6
6
  "keywords": [