@mergedapp/feature-flags 0.1.0

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/index.js ADDED
@@ -0,0 +1,856 @@
1
+ var __defProp = Object.defineProperty;
2
+ var __name = (target, value) => __defProp(target, "name", { value, configurable: true });
3
+
4
+ // src/errors.ts
5
+ var FeatureFlagError = class extends Error {
6
+ static {
7
+ __name(this, "FeatureFlagError");
8
+ }
9
+ constructor(message, options) {
10
+ super(message, options);
11
+ this.name = "FeatureFlagError";
12
+ }
13
+ };
14
+ var FeatureFlagNetworkError = class extends FeatureFlagError {
15
+ static {
16
+ __name(this, "FeatureFlagNetworkError");
17
+ }
18
+ constructor(message, options) {
19
+ super(message, options);
20
+ this.name = "FeatureFlagNetworkError";
21
+ }
22
+ };
23
+ var FeatureFlagVerificationError = class extends FeatureFlagError {
24
+ static {
25
+ __name(this, "FeatureFlagVerificationError");
26
+ }
27
+ constructor(message, options) {
28
+ super(message, options);
29
+ this.name = "FeatureFlagVerificationError";
30
+ }
31
+ };
32
+
33
+ // src/persistence.ts
34
+ var DEFAULT_SNAPSHOT_KEY_PREFIX = "merged-feature-flags";
35
+ var SNAPSHOT_SCHEMA_VERSION = 1;
36
+ var textEncoder = new TextEncoder();
37
+ var textDecoder = new TextDecoder();
38
+ var nodeModulePromise = null;
39
+ async function getNodeModules() {
40
+ if (!nodeModulePromise) {
41
+ nodeModulePromise = Promise.all([
42
+ import("crypto"),
43
+ import("fs/promises"),
44
+ import("os"),
45
+ import("path")
46
+ ]).then(([crypto, fs, os, path]) => ({
47
+ crypto,
48
+ fs,
49
+ os,
50
+ path
51
+ }));
52
+ }
53
+ return nodeModulePromise;
54
+ }
55
+ __name(getNodeModules, "getNodeModules");
56
+ function normalizeValue(value) {
57
+ if (Array.isArray(value)) {
58
+ return value.map((item) => normalizeValue(item));
59
+ }
60
+ if (value && typeof value === "object") {
61
+ const entries = Object.entries(value).sort(([left], [right]) => left.localeCompare(right)).map(([key, entryValue]) => [
62
+ key,
63
+ normalizeValue(entryValue)
64
+ ]);
65
+ return Object.fromEntries(entries);
66
+ }
67
+ return value;
68
+ }
69
+ __name(normalizeValue, "normalizeValue");
70
+ function serializeCanonicalValue(value) {
71
+ return JSON.stringify(normalizeValue(value));
72
+ }
73
+ __name(serializeCanonicalValue, "serializeCanonicalValue");
74
+ function createDefaultFeatureFlagRuntimeStatus() {
75
+ return {
76
+ source: "defaults",
77
+ isStale: false,
78
+ lastSuccessfulRefreshAt: null,
79
+ tokenExpiresAt: null,
80
+ lastError: null
81
+ };
82
+ }
83
+ __name(createDefaultFeatureFlagRuntimeStatus, "createDefaultFeatureFlagRuntimeStatus");
84
+ async function sha256Hex(value) {
85
+ if (globalThis.crypto?.subtle) {
86
+ const digest = await globalThis.crypto.subtle.digest("SHA-256", textEncoder.encode(value));
87
+ return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("");
88
+ }
89
+ const { crypto } = await getNodeModules();
90
+ return crypto.createHash("sha256").update(value).digest("hex");
91
+ }
92
+ __name(sha256Hex, "sha256Hex");
93
+ async function buildSnapshotScopeParts(params) {
94
+ const contextHash = await sha256Hex(serializeCanonicalValue(params.config.evaluationContext ?? null));
95
+ const clientKeyFingerprint = await sha256Hex(params.config.clientKey);
96
+ const prefix = params.keyPrefix?.trim() || DEFAULT_SNAPSHOT_KEY_PREFIX;
97
+ const scopeMaterial = serializeCanonicalValue({
98
+ apiUrl: params.config.apiUrl.replace(/\/$/, ""),
99
+ organizationId: params.config.organizationId.trim(),
100
+ environmentId: params.config.environmentId.trim(),
101
+ teamId: params.config.teamId?.trim() || null,
102
+ clientKeyFingerprint,
103
+ contextHash
104
+ });
105
+ return {
106
+ scopeKey: `${prefix}:${await sha256Hex(scopeMaterial)}`,
107
+ organizationId: params.config.organizationId.trim(),
108
+ environmentId: params.config.environmentId.trim(),
109
+ teamId: params.config.teamId?.trim() || null,
110
+ clientKeyFingerprint,
111
+ contextHash
112
+ };
113
+ }
114
+ __name(buildSnapshotScopeParts, "buildSnapshotScopeParts");
115
+ function buildSnapshotRecord(params) {
116
+ return {
117
+ schemaVersion: SNAPSHOT_SCHEMA_VERSION,
118
+ scopeKey: params.scope.scopeKey,
119
+ organizationId: params.scope.organizationId,
120
+ environmentId: params.scope.environmentId,
121
+ teamId: params.scope.teamId,
122
+ clientKeyFingerprint: params.scope.clientKeyFingerprint,
123
+ contextHash: params.scope.contextHash,
124
+ token: params.token,
125
+ publicKeyPem: params.publicKeyPem,
126
+ fetchedAt: params.fetchedAt,
127
+ tokenExpiresAt: params.tokenExpiresAt
128
+ };
129
+ }
130
+ __name(buildSnapshotRecord, "buildSnapshotRecord");
131
+ function createPersistedFeatureFlagSnapshot(params) {
132
+ return buildSnapshotRecord(params);
133
+ }
134
+ __name(createPersistedFeatureFlagSnapshot, "createPersistedFeatureFlagSnapshot");
135
+ function doesSnapshotMatchScope(params) {
136
+ return params.snapshot.schemaVersion === SNAPSHOT_SCHEMA_VERSION && params.snapshot.scopeKey === params.scope.scopeKey && params.snapshot.organizationId === params.scope.organizationId && params.snapshot.environmentId === params.scope.environmentId && params.snapshot.teamId === params.scope.teamId && params.snapshot.clientKeyFingerprint === params.scope.clientKeyFingerprint && params.snapshot.contextHash === params.scope.contextHash;
137
+ }
138
+ __name(doesSnapshotMatchScope, "doesSnapshotMatchScope");
139
+ function isBrowserPersistenceAvailable() {
140
+ return typeof window !== "undefined" && typeof window.localStorage !== "undefined";
141
+ }
142
+ __name(isBrowserPersistenceAvailable, "isBrowserPersistenceAvailable");
143
+ function createLocalStorageFeatureFlagSnapshotStore() {
144
+ return {
145
+ async load(key) {
146
+ if (!isBrowserPersistenceAvailable()) {
147
+ return null;
148
+ }
149
+ try {
150
+ const raw = window.localStorage.getItem(key);
151
+ return raw ? JSON.parse(raw) : null;
152
+ } catch {
153
+ return null;
154
+ }
155
+ },
156
+ async save(key, snapshot) {
157
+ if (!isBrowserPersistenceAvailable()) {
158
+ return;
159
+ }
160
+ try {
161
+ window.localStorage.setItem(key, JSON.stringify(snapshot));
162
+ } catch {
163
+ }
164
+ },
165
+ async remove(key) {
166
+ if (!isBrowserPersistenceAvailable()) {
167
+ return;
168
+ }
169
+ try {
170
+ window.localStorage.removeItem(key);
171
+ } catch {
172
+ }
173
+ }
174
+ };
175
+ }
176
+ __name(createLocalStorageFeatureFlagSnapshotStore, "createLocalStorageFeatureFlagSnapshotStore");
177
+ function sanitizeKeyForFilename(key) {
178
+ return encodeURIComponent(key);
179
+ }
180
+ __name(sanitizeKeyForFilename, "sanitizeKeyForFilename");
181
+ function createFileFeatureFlagSnapshotStore(params) {
182
+ return {
183
+ async load(key) {
184
+ const { fs, os, path } = await getNodeModules();
185
+ const rootDirectory = params?.rootDirectory ?? path.join(os.tmpdir(), DEFAULT_SNAPSHOT_KEY_PREFIX);
186
+ const filePath = path.join(rootDirectory, `${sanitizeKeyForFilename(key)}.json`);
187
+ try {
188
+ const raw = await fs.readFile(filePath, "utf-8");
189
+ return JSON.parse(raw);
190
+ } catch {
191
+ return null;
192
+ }
193
+ },
194
+ async save(key, snapshot) {
195
+ const { fs, os, path } = await getNodeModules();
196
+ const rootDirectory = params?.rootDirectory ?? path.join(os.tmpdir(), DEFAULT_SNAPSHOT_KEY_PREFIX);
197
+ const filePath = path.join(rootDirectory, `${sanitizeKeyForFilename(key)}.json`);
198
+ const tempFilePath = path.join(rootDirectory, `${sanitizeKeyForFilename(key)}.${Date.now()}.${Math.random().toString(16).slice(2)}.tmp`);
199
+ await fs.mkdir(rootDirectory, {
200
+ recursive: true
201
+ });
202
+ await fs.writeFile(tempFilePath, JSON.stringify(snapshot), "utf-8");
203
+ await fs.rename(tempFilePath, filePath);
204
+ },
205
+ async remove(key) {
206
+ const { fs, os, path } = await getNodeModules();
207
+ const rootDirectory = params?.rootDirectory ?? path.join(os.tmpdir(), DEFAULT_SNAPSHOT_KEY_PREFIX);
208
+ const filePath = path.join(rootDirectory, `${sanitizeKeyForFilename(key)}.json`);
209
+ try {
210
+ await fs.rm(filePath, {
211
+ force: true
212
+ });
213
+ } catch {
214
+ }
215
+ }
216
+ };
217
+ }
218
+ __name(createFileFeatureFlagSnapshotStore, "createFileFeatureFlagSnapshotStore");
219
+ async function resolveSnapshotStore(params) {
220
+ const persistence = params.config.snapshotPersistence;
221
+ if (persistence === false) {
222
+ return null;
223
+ }
224
+ if (persistence?.store) {
225
+ return persistence.store;
226
+ }
227
+ if (isBrowserPersistenceAvailable()) {
228
+ return createLocalStorageFeatureFlagSnapshotStore();
229
+ }
230
+ return createFileFeatureFlagSnapshotStore();
231
+ }
232
+ __name(resolveSnapshotStore, "resolveSnapshotStore");
233
+ function getSnapshotKeyPrefix(params) {
234
+ const persistence = params.config.snapshotPersistence;
235
+ return persistence?.keyPrefix?.trim() || DEFAULT_SNAPSHOT_KEY_PREFIX;
236
+ }
237
+ __name(getSnapshotKeyPrefix, "getSnapshotKeyPrefix");
238
+
239
+ // src/jwt.ts
240
+ import { compactVerify, importSPKI, jwtVerify } from "jose";
241
+ var JWT_ALGORITHM = "ES256";
242
+ var JWT_ISSUER = "merged";
243
+ var JWT_TYPE = "feature_flag_values";
244
+ var cachedKey = null;
245
+ var cachedPem = null;
246
+ async function importPublicKey(pem) {
247
+ if (cachedPem === pem && cachedKey) {
248
+ return cachedKey;
249
+ }
250
+ try {
251
+ cachedKey = await importSPKI(pem, JWT_ALGORITHM);
252
+ cachedPem = pem;
253
+ return cachedKey;
254
+ } catch (error) {
255
+ cachedKey = null;
256
+ cachedPem = null;
257
+ throw new FeatureFlagVerificationError("Failed to import public key.", {
258
+ cause: error
259
+ });
260
+ }
261
+ }
262
+ __name(importPublicKey, "importPublicKey");
263
+ function resetPublicKeyCache() {
264
+ cachedKey = null;
265
+ cachedPem = null;
266
+ }
267
+ __name(resetPublicKeyCache, "resetPublicKeyCache");
268
+ async function verifyFeatureFlagTokenDetailed(params) {
269
+ try {
270
+ if (!params.allowExpired) {
271
+ const { payload: payload2 } = await jwtVerify(params.token, params.publicKey, {
272
+ algorithms: [
273
+ JWT_ALGORITHM
274
+ ],
275
+ issuer: JWT_ISSUER
276
+ });
277
+ return extractVerifiedTokenPayload(payload2);
278
+ }
279
+ const { payload } = await compactVerify(params.token, params.publicKey, {
280
+ algorithms: [
281
+ JWT_ALGORITHM
282
+ ]
283
+ });
284
+ const rawPayload = JSON.parse(new TextDecoder().decode(payload));
285
+ const notBefore = rawPayload.nbf;
286
+ if (typeof notBefore === "number" && Number.isFinite(notBefore) && Math.floor(Date.now() / 1e3) < notBefore) {
287
+ throw new FeatureFlagVerificationError("JWT payload is not yet valid.");
288
+ }
289
+ return extractVerifiedTokenPayload(rawPayload);
290
+ } catch (error) {
291
+ if (error instanceof FeatureFlagVerificationError) {
292
+ throw error;
293
+ }
294
+ throw new FeatureFlagVerificationError("JWT verification failed.", {
295
+ cause: error
296
+ });
297
+ }
298
+ }
299
+ __name(verifyFeatureFlagTokenDetailed, "verifyFeatureFlagTokenDetailed");
300
+ function extractVerifiedTokenPayload(payload) {
301
+ if (payload.iss !== JWT_ISSUER) {
302
+ throw new FeatureFlagVerificationError("JWT payload has an unexpected issuer.");
303
+ }
304
+ if (payload.type !== JWT_TYPE) {
305
+ throw new FeatureFlagVerificationError("JWT payload has an unexpected token type.");
306
+ }
307
+ const flags = payload.flags;
308
+ if (!Array.isArray(flags)) {
309
+ throw new FeatureFlagVerificationError("JWT payload does not contain a flags array.");
310
+ }
311
+ const expiresAt = typeof payload.exp === "number" && Number.isFinite(payload.exp) ? new Date(payload.exp * 1e3).toISOString() : null;
312
+ return {
313
+ flags,
314
+ expiresAt
315
+ };
316
+ }
317
+ __name(extractVerifiedTokenPayload, "extractVerifiedTokenPayload");
318
+
319
+ // src/client.ts
320
+ var DEFAULT_REFRESH_INTERVAL_MS = 6e4;
321
+ var BACKOFF_BASE_MS = 5e3;
322
+ var BACKOFF_CAP_MS = 3e5;
323
+ var MergedFeatureFlags = class {
324
+ static {
325
+ __name(this, "MergedFeatureFlags");
326
+ }
327
+ flagsById = /* @__PURE__ */ new Map();
328
+ flagsByName = /* @__PURE__ */ new Map();
329
+ publicKeyPem;
330
+ publicKeyCrypto = null;
331
+ refreshTimer = null;
332
+ backoffMs = 0;
333
+ consecutiveFailures = 0;
334
+ listeners = /* @__PURE__ */ new Set();
335
+ storeListeners = /* @__PURE__ */ new Set();
336
+ initialized = false;
337
+ visibilityHandler = null;
338
+ pendingScopeTransition = false;
339
+ runtimeStatus = createDefaultFeatureFlagRuntimeStatus();
340
+ snapshotStorePromise = null;
341
+ keyPrefix;
342
+ contextSignature;
343
+ flagIds;
344
+ clientKey;
345
+ apiUrl;
346
+ organizationId;
347
+ environmentId;
348
+ teamId;
349
+ refreshInterval;
350
+ onError;
351
+ onFlagsChanged;
352
+ snapshotPersistence;
353
+ evaluationContext;
354
+ constructor(config) {
355
+ this.flagIds = config.flagIds ?? null;
356
+ this.clientKey = config.clientKey;
357
+ this.apiUrl = config.apiUrl.replace(/\/$/, "");
358
+ this.organizationId = config.organizationId.trim();
359
+ this.environmentId = config.environmentId.trim();
360
+ this.teamId = config.teamId?.trim() || null;
361
+ this.publicKeyPem = config.publicKey ?? null;
362
+ this.refreshInterval = config.refreshInterval ?? DEFAULT_REFRESH_INTERVAL_MS;
363
+ this.onError = config.onError ?? null;
364
+ this.onFlagsChanged = config.onFlagsChanged ?? null;
365
+ this.snapshotPersistence = config.snapshotPersistence;
366
+ this.evaluationContext = config.evaluationContext ?? null;
367
+ this.contextSignature = serializeCanonicalValue(this.evaluationContext ?? null);
368
+ this.keyPrefix = getSnapshotKeyPrefix({
369
+ config
370
+ });
371
+ if (!this.organizationId) {
372
+ throw new TypeError("organizationId is required.");
373
+ }
374
+ if (!this.environmentId) {
375
+ throw new TypeError("environmentId is required.");
376
+ }
377
+ }
378
+ async initialize() {
379
+ if (this.initialized) {
380
+ return;
381
+ }
382
+ this.initialized = true;
383
+ try {
384
+ const restored = await this.restorePersistedSnapshot().catch(() => false);
385
+ try {
386
+ await this.refresh();
387
+ } catch {
388
+ if (!restored) {
389
+ this.clearFlags();
390
+ this.setRuntimeStatus({
391
+ source: "defaults",
392
+ tokenExpiresAt: null
393
+ });
394
+ this.emitChange({
395
+ flagsChanged: true,
396
+ storeChanged: true
397
+ });
398
+ }
399
+ }
400
+ } finally {
401
+ if (this.refreshInterval > 0) {
402
+ this.startPolling();
403
+ }
404
+ }
405
+ }
406
+ /** Returns true if the flag is enabled. Returns false for unknown flags. */
407
+ isEnabled(name) {
408
+ return this.resolveFlag(name)?.enabled ?? false;
409
+ }
410
+ /** Returns the flag's value with the type inferred from the flag registry. */
411
+ getValue(name) {
412
+ const flag = this.resolveFlag(name);
413
+ return flag?.value;
414
+ }
415
+ /** Returns the full evaluated flag entry, or undefined if not found. */
416
+ getFlag(name) {
417
+ return this.resolveFlag(name);
418
+ }
419
+ /** Returns all evaluated flags. */
420
+ getAllFlags() {
421
+ return Array.from(this.flagsById.values());
422
+ }
423
+ getStatus() {
424
+ const tokenExpiresAt = this.runtimeStatus.tokenExpiresAt;
425
+ const isStale = tokenExpiresAt != null && Number.isFinite(new Date(tokenExpiresAt).getTime()) && new Date(tokenExpiresAt).getTime() <= Date.now();
426
+ return {
427
+ ...this.runtimeStatus,
428
+ isStale
429
+ };
430
+ }
431
+ getStatusSnapshot() {
432
+ return this.getStatus();
433
+ }
434
+ /** Manually trigger a refresh from the server. */
435
+ async refresh() {
436
+ const restoredForCurrentScope = this.pendingScopeTransition ? await this.restorePersistedSnapshot().catch(() => false) : false;
437
+ try {
438
+ await this.ensureVerificationKey();
439
+ const response = await this.fetchSignedFlags();
440
+ const verifiedFlags = await this.verifyWithRetry(response.token);
441
+ const fetchedAt = (/* @__PURE__ */ new Date()).toISOString();
442
+ const flagsChanged = this.updateFlags(verifiedFlags.flags);
443
+ this.resetBackoff();
444
+ this.pendingScopeTransition = false;
445
+ this.setRuntimeStatus({
446
+ source: "network",
447
+ tokenExpiresAt: verifiedFlags.expiresAt,
448
+ lastSuccessfulRefreshAt: fetchedAt,
449
+ lastError: null
450
+ });
451
+ await this.persistSnapshot({
452
+ fetchedAt,
453
+ token: response.token,
454
+ tokenExpiresAt: verifiedFlags.expiresAt
455
+ });
456
+ this.emitChange({
457
+ flagsChanged,
458
+ storeChanged: true
459
+ });
460
+ } catch (error) {
461
+ this.incrementBackoff();
462
+ const wrappedError = error instanceof Error ? error : new FeatureFlagNetworkError("Unknown error during refresh.");
463
+ if (this.pendingScopeTransition && !restoredForCurrentScope) {
464
+ const hadFlags = this.flagsById.size > 0;
465
+ this.clearFlags();
466
+ this.pendingScopeTransition = false;
467
+ this.setRuntimeStatus({
468
+ source: "defaults",
469
+ tokenExpiresAt: null,
470
+ lastSuccessfulRefreshAt: null,
471
+ lastError: wrappedError
472
+ });
473
+ this.emitChange({
474
+ flagsChanged: hadFlags,
475
+ storeChanged: true
476
+ });
477
+ } else {
478
+ this.setRuntimeStatus({
479
+ lastError: wrappedError
480
+ });
481
+ this.emitChange({
482
+ flagsChanged: false,
483
+ storeChanged: true
484
+ });
485
+ }
486
+ this.onError?.(wrappedError);
487
+ throw wrappedError;
488
+ }
489
+ }
490
+ /** Subscribe to flag changes. Returns an unsubscribe function. */
491
+ onChange(listener) {
492
+ this.listeners.add(listener);
493
+ return () => {
494
+ this.listeners.delete(listener);
495
+ };
496
+ }
497
+ /** Get a snapshot function for useSyncExternalStore. */
498
+ getSnapshot() {
499
+ return this.getAllFlags();
500
+ }
501
+ /** Replace the caller-provided evaluation context used on subsequent refreshes. */
502
+ setEvaluationContext(context) {
503
+ const nextContext = context ?? null;
504
+ const nextSignature = serializeCanonicalValue(nextContext ?? null);
505
+ if (nextSignature === this.contextSignature) {
506
+ this.evaluationContext = nextContext;
507
+ return;
508
+ }
509
+ this.evaluationContext = nextContext;
510
+ this.contextSignature = nextSignature;
511
+ this.pendingScopeTransition = true;
512
+ }
513
+ /** Subscribe function for useSyncExternalStore. */
514
+ subscribe(onStoreChange) {
515
+ this.storeListeners.add(onStoreChange);
516
+ return () => {
517
+ this.storeListeners.delete(onStoreChange);
518
+ };
519
+ }
520
+ /** Clean up timers, listeners, and cached data. */
521
+ destroy() {
522
+ if (this.refreshTimer) {
523
+ clearTimeout(this.refreshTimer);
524
+ this.refreshTimer = null;
525
+ }
526
+ if (this.visibilityHandler && typeof document !== "undefined") {
527
+ document.removeEventListener("visibilitychange", this.visibilityHandler);
528
+ this.visibilityHandler = null;
529
+ }
530
+ this.listeners.clear();
531
+ this.storeListeners.clear();
532
+ this.clearFlags();
533
+ this.pendingScopeTransition = false;
534
+ this.runtimeStatus = createDefaultFeatureFlagRuntimeStatus();
535
+ this.initialized = false;
536
+ }
537
+ resolveFlag(name) {
538
+ if (this.flagIds && name in this.flagIds) {
539
+ const id = this.flagIds[name];
540
+ return this.flagsById.get(id);
541
+ }
542
+ return this.flagsById.get(name) ?? this.flagsByName.get(name);
543
+ }
544
+ clearFlags() {
545
+ this.flagsById.clear();
546
+ this.flagsByName.clear();
547
+ }
548
+ emitChange(params) {
549
+ const allFlags = this.getAllFlags();
550
+ if (params.flagsChanged) {
551
+ this.onFlagsChanged?.(allFlags);
552
+ for (const listener of this.listeners) {
553
+ listener(allFlags);
554
+ }
555
+ }
556
+ if (params.storeChanged) {
557
+ for (const listener of this.storeListeners) {
558
+ listener();
559
+ }
560
+ }
561
+ }
562
+ updateFlags(flags) {
563
+ const oldFlags = new Map(this.flagsById);
564
+ this.clearFlags();
565
+ for (const flag of flags) {
566
+ this.flagsById.set(flag.id, flag);
567
+ this.flagsByName.set(flag.name, flag);
568
+ }
569
+ return this.hasFlagsChanged(oldFlags, this.flagsById);
570
+ }
571
+ hasFlagsChanged(oldFlags, newFlags) {
572
+ if (oldFlags.size !== newFlags.size) {
573
+ return true;
574
+ }
575
+ for (const [id, newFlag] of newFlags) {
576
+ const oldFlag = oldFlags.get(id);
577
+ if (!oldFlag) {
578
+ return true;
579
+ }
580
+ if (oldFlag.enabled !== newFlag.enabled || oldFlag.name !== newFlag.name || JSON.stringify(oldFlag.value) !== JSON.stringify(newFlag.value)) {
581
+ return true;
582
+ }
583
+ }
584
+ return false;
585
+ }
586
+ setRuntimeStatus(patch) {
587
+ this.runtimeStatus = {
588
+ ...this.runtimeStatus,
589
+ ...patch
590
+ };
591
+ }
592
+ async fetchSignedFlags() {
593
+ const url = new URL("/api/feature-flags/evaluate/signed", this.apiUrl);
594
+ const payload = {
595
+ organizationId: this.organizationId,
596
+ environmentId: this.environmentId,
597
+ ...this.teamId ? {
598
+ teamId: this.teamId
599
+ } : {},
600
+ ...this.evaluationContext ? {
601
+ context: this.evaluationContext
602
+ } : {}
603
+ };
604
+ let response;
605
+ try {
606
+ response = await fetch(url, {
607
+ method: "POST",
608
+ headers: {
609
+ Authorization: `Bearer ${this.clientKey}`,
610
+ "Content-Type": "application/json",
611
+ Accept: "application/json"
612
+ },
613
+ body: JSON.stringify(payload)
614
+ });
615
+ } catch (error) {
616
+ throw new FeatureFlagNetworkError("Failed to fetch feature flags.", {
617
+ cause: error
618
+ });
619
+ }
620
+ if (!response.ok) {
621
+ throw new FeatureFlagNetworkError(`Feature flag API returned ${response.status}: ${response.statusText}`);
622
+ }
623
+ return await response.json();
624
+ }
625
+ async fetchPublicKey() {
626
+ const url = `${this.apiUrl}/api/feature-flags/public-key`;
627
+ let response;
628
+ try {
629
+ response = await fetch(url);
630
+ } catch (error) {
631
+ throw new FeatureFlagNetworkError("Failed to fetch public key.", {
632
+ cause: error
633
+ });
634
+ }
635
+ if (!response.ok) {
636
+ throw new FeatureFlagNetworkError(`Public key endpoint returned ${response.status}: ${response.statusText}`);
637
+ }
638
+ const body = await response.json();
639
+ return body.publicKey;
640
+ }
641
+ async verifyWithRetry(token) {
642
+ try {
643
+ return await verifyFeatureFlagTokenDetailed({
644
+ token,
645
+ publicKey: this.publicKeyCrypto
646
+ });
647
+ } catch (error) {
648
+ if (!(error instanceof FeatureFlagVerificationError)) {
649
+ throw error;
650
+ }
651
+ resetPublicKeyCache();
652
+ this.publicKeyPem = await this.fetchPublicKey();
653
+ this.publicKeyCrypto = await importPublicKey(this.publicKeyPem);
654
+ return verifyFeatureFlagTokenDetailed({
655
+ token,
656
+ publicKey: this.publicKeyCrypto
657
+ });
658
+ }
659
+ }
660
+ async ensureVerificationKey() {
661
+ if (!this.publicKeyPem) {
662
+ this.publicKeyPem = await this.fetchPublicKey();
663
+ }
664
+ if (!this.publicKeyCrypto) {
665
+ this.publicKeyCrypto = await importPublicKey(this.publicKeyPem);
666
+ }
667
+ }
668
+ async getTrustedSnapshotVerificationKey() {
669
+ if (this.publicKeyPem) {
670
+ if (!this.publicKeyCrypto) {
671
+ this.publicKeyCrypto = await importPublicKey(this.publicKeyPem);
672
+ }
673
+ return {
674
+ publicKeyCrypto: this.publicKeyCrypto,
675
+ publicKeyPem: this.publicKeyPem
676
+ };
677
+ }
678
+ try {
679
+ await this.ensureVerificationKey();
680
+ } catch (error) {
681
+ if (error instanceof FeatureFlagNetworkError) {
682
+ return null;
683
+ }
684
+ throw error;
685
+ }
686
+ if (!this.publicKeyPem || !this.publicKeyCrypto) {
687
+ return null;
688
+ }
689
+ return {
690
+ publicKeyCrypto: this.publicKeyCrypto,
691
+ publicKeyPem: this.publicKeyPem
692
+ };
693
+ }
694
+ async getSnapshotStore() {
695
+ if (!this.snapshotStorePromise) {
696
+ let snapshotPersistence;
697
+ if (this.snapshotPersistence === void 0) {
698
+ snapshotPersistence = {
699
+ keyPrefix: this.keyPrefix
700
+ };
701
+ } else if (this.snapshotPersistence === false) {
702
+ snapshotPersistence = false;
703
+ } else {
704
+ snapshotPersistence = {
705
+ ...this.snapshotPersistence,
706
+ keyPrefix: this.snapshotPersistence.keyPrefix ?? this.keyPrefix
707
+ };
708
+ }
709
+ this.snapshotStorePromise = resolveSnapshotStore({
710
+ config: {
711
+ apiUrl: this.apiUrl,
712
+ clientKey: this.clientKey,
713
+ environmentId: this.environmentId,
714
+ organizationId: this.organizationId,
715
+ teamId: this.teamId ?? void 0,
716
+ evaluationContext: this.evaluationContext ?? void 0,
717
+ snapshotPersistence
718
+ }
719
+ });
720
+ }
721
+ return this.snapshotStorePromise;
722
+ }
723
+ async getScopeParts() {
724
+ return buildSnapshotScopeParts({
725
+ config: {
726
+ apiUrl: this.apiUrl,
727
+ clientKey: this.clientKey,
728
+ environmentId: this.environmentId,
729
+ organizationId: this.organizationId,
730
+ teamId: this.teamId ?? void 0,
731
+ evaluationContext: this.evaluationContext ?? void 0
732
+ },
733
+ keyPrefix: this.keyPrefix
734
+ });
735
+ }
736
+ async restorePersistedSnapshot() {
737
+ const store = await this.getSnapshotStore();
738
+ if (!store) {
739
+ return false;
740
+ }
741
+ const scope = await this.getScopeParts();
742
+ const snapshot = await store.load(scope.scopeKey);
743
+ if (!snapshot) {
744
+ return false;
745
+ }
746
+ if (!doesSnapshotMatchScope({
747
+ snapshot,
748
+ scope
749
+ })) {
750
+ await store.remove(scope.scopeKey);
751
+ return false;
752
+ }
753
+ const trustedKey = await this.getTrustedSnapshotVerificationKey();
754
+ if (!trustedKey) {
755
+ return false;
756
+ }
757
+ try {
758
+ const verified = await verifyFeatureFlagTokenDetailed({
759
+ token: snapshot.token,
760
+ publicKey: trustedKey.publicKeyCrypto,
761
+ allowExpired: true
762
+ });
763
+ this.publicKeyPem = trustedKey.publicKeyPem;
764
+ this.publicKeyCrypto = trustedKey.publicKeyCrypto;
765
+ this.pendingScopeTransition = false;
766
+ const flagsChanged = this.updateFlags(verified.flags);
767
+ this.setRuntimeStatus({
768
+ source: "persisted",
769
+ tokenExpiresAt: verified.expiresAt,
770
+ lastSuccessfulRefreshAt: snapshot.fetchedAt,
771
+ lastError: null
772
+ });
773
+ this.emitChange({
774
+ flagsChanged,
775
+ storeChanged: true
776
+ });
777
+ return true;
778
+ } catch {
779
+ await store.remove(scope.scopeKey);
780
+ return false;
781
+ }
782
+ }
783
+ async persistSnapshot(params) {
784
+ const store = await this.getSnapshotStore();
785
+ if (!store || !this.publicKeyPem) {
786
+ return;
787
+ }
788
+ const scope = await this.getScopeParts();
789
+ const snapshot = createPersistedFeatureFlagSnapshot({
790
+ scope,
791
+ token: params.token,
792
+ publicKeyPem: this.publicKeyPem,
793
+ fetchedAt: params.fetchedAt,
794
+ tokenExpiresAt: params.tokenExpiresAt
795
+ });
796
+ try {
797
+ await store.save(scope.scopeKey, snapshot);
798
+ } catch (error) {
799
+ const wrappedError = error instanceof Error ? error : new Error("Failed to persist feature flag snapshot.");
800
+ this.onError?.(wrappedError);
801
+ }
802
+ }
803
+ startPolling() {
804
+ this.scheduleNextPoll();
805
+ if (typeof document !== "undefined") {
806
+ this.visibilityHandler = () => {
807
+ if (document.visibilityState === "hidden") {
808
+ if (this.refreshTimer) {
809
+ clearTimeout(this.refreshTimer);
810
+ this.refreshTimer = null;
811
+ }
812
+ } else if (document.visibilityState === "visible") {
813
+ void this.safeRefresh();
814
+ }
815
+ };
816
+ document.addEventListener("visibilitychange", this.visibilityHandler);
817
+ }
818
+ }
819
+ scheduleNextPoll() {
820
+ if (this.refreshTimer) {
821
+ clearTimeout(this.refreshTimer);
822
+ }
823
+ const delay = this.backoffMs > 0 ? this.backoffMs : this.refreshInterval;
824
+ this.refreshTimer = setTimeout(() => {
825
+ void this.safeRefresh();
826
+ }, delay);
827
+ }
828
+ async safeRefresh() {
829
+ try {
830
+ await this.refresh();
831
+ } catch {
832
+ } finally {
833
+ const isPageVisible = typeof document === "undefined" || document.visibilityState !== "hidden";
834
+ if (this.initialized && this.refreshInterval > 0 && isPageVisible) {
835
+ this.scheduleNextPoll();
836
+ }
837
+ }
838
+ }
839
+ resetBackoff() {
840
+ this.backoffMs = 0;
841
+ this.consecutiveFailures = 0;
842
+ }
843
+ incrementBackoff() {
844
+ this.consecutiveFailures++;
845
+ this.backoffMs = Math.min(BACKOFF_BASE_MS * 2 ** (this.consecutiveFailures - 1), BACKOFF_CAP_MS);
846
+ }
847
+ };
848
+ export {
849
+ FeatureFlagError,
850
+ FeatureFlagNetworkError,
851
+ FeatureFlagVerificationError,
852
+ MergedFeatureFlags,
853
+ createDefaultFeatureFlagRuntimeStatus,
854
+ createFileFeatureFlagSnapshotStore,
855
+ createLocalStorageFeatureFlagSnapshotStore
856
+ };