@ops-ai/electron-feature-flags-toggly 1.0.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.
@@ -0,0 +1,830 @@
1
+ import { toBooleanDefinitions, appendEvaluationContext, resolveEvaluatedDefinition, normalizeEntityContext, evaluateEvaluatedGate } from '@ops-ai/toggly-hooks-types';
2
+ import { InMemoryJwksCache, readResponseBody, parseEvaluatedResponseBody, unwrapDefsPayload } from '@ops-ai/toggly-signed-defs';
3
+ import { applyLocalGate, buildFlagGateIndex } from '@ops-ai/toggly-local-gates';
4
+ import WebSocket from 'ws';
5
+ import { randomUUID } from 'crypto';
6
+ import { readFile, mkdir, writeFile, unlink } from 'fs/promises';
7
+ import { join } from 'path';
8
+
9
+ // src/main/client.ts
10
+ function sanitizeSegment(value) {
11
+ return value.replace(/[^a-zA-Z0-9._-]/g, "_");
12
+ }
13
+ function buildCacheFilePath(userDataPath, appKey, environment, contextKey) {
14
+ const dir = join(userDataPath, "toggly");
15
+ const name = `flags-${sanitizeSegment(appKey)}-${sanitizeSegment(environment)}-${sanitizeSegment(contextKey)}.json`;
16
+ return join(dir, name);
17
+ }
18
+ var DiskFeatureCache = class {
19
+ constructor(userDataPath) {
20
+ this.userDataPath = userDataPath;
21
+ }
22
+ userDataPath;
23
+ filePath(appKey, environment, contextKey) {
24
+ return buildCacheFilePath(this.userDataPath, appKey, environment, contextKey);
25
+ }
26
+ async read(appKey, environment, contextKey) {
27
+ const path = this.filePath(appKey, environment, contextKey);
28
+ try {
29
+ const raw = await readFile(path, "utf-8");
30
+ const parsed = JSON.parse(raw);
31
+ if (!parsed || typeof parsed !== "object" || !parsed.flags || typeof parsed.flags !== "object") {
32
+ return null;
33
+ }
34
+ return {
35
+ flags: parsed.flags,
36
+ revision: parsed.revision ?? null,
37
+ updatedAt: typeof parsed.updatedAt === "number" ? parsed.updatedAt : Date.now()
38
+ };
39
+ } catch {
40
+ return null;
41
+ }
42
+ }
43
+ async write(appKey, environment, contextKey, entry) {
44
+ const path = this.filePath(appKey, environment, contextKey);
45
+ await mkdir(join(this.userDataPath, "toggly"), { recursive: true });
46
+ await writeFile(path, JSON.stringify(entry), "utf-8");
47
+ }
48
+ async clear(appKey, environment, contextKey) {
49
+ const path = this.filePath(appKey, environment, contextKey);
50
+ try {
51
+ await unlink(path);
52
+ } catch {
53
+ }
54
+ }
55
+ };
56
+
57
+ // src/sdk-identity.ts
58
+ var SDK_ID = "electron";
59
+ var SDK_VERSION = "1.0.0";
60
+ var SDK_HEADER_ID = "X-Toggly-Sdk";
61
+ var SDK_HEADER_VERSION = "X-Toggly-Sdk-Version";
62
+ function sdkUserAgent() {
63
+ return `toggly-${SDK_ID}/${SDK_VERSION}`;
64
+ }
65
+ function sdkCustomHeaders() {
66
+ return {
67
+ [SDK_HEADER_ID]: SDK_ID,
68
+ [SDK_HEADER_VERSION]: SDK_VERSION
69
+ };
70
+ }
71
+ function appendSdkQueryParams(params) {
72
+ params.set("sdk", SDK_ID);
73
+ params.set("sdkVersion", SDK_VERSION);
74
+ }
75
+ function buildDefinitionFetchHeaders(existing = {}) {
76
+ return {
77
+ ...existing,
78
+ "User-Agent": sdkUserAgent(),
79
+ ...sdkCustomHeaders()
80
+ };
81
+ }
82
+
83
+ // src/ws-sync.ts
84
+ var DEFINITIONS_REVISION_HEADER = "X-Definitions-Revision";
85
+ var WS_RECONNECT_BASE_MS = 5e3;
86
+ var WS_RECONNECT_MAX_MS = 6e4;
87
+ var REFRESH_DEBOUNCE_MS = 300;
88
+ function buildWebSocketUrl(baseUri, appKey, cachedEtag) {
89
+ const wsBase = baseUri.replace(/^https:\/\//, "wss://").replace(/^http:\/\//, "ws://").replace(/\/$/, "");
90
+ const params = new URLSearchParams();
91
+ if (cachedEtag) {
92
+ params.set("rev", cachedEtag);
93
+ }
94
+ appendSdkQueryParams(params);
95
+ const query = params.toString();
96
+ return `${wsBase}/${appKey}/ws${query ? `?${query}` : ""}`;
97
+ }
98
+ function getNextReconnectDelayMs(attempt) {
99
+ return Math.min(WS_RECONNECT_BASE_MS * Math.pow(2, attempt), WS_RECONNECT_MAX_MS);
100
+ }
101
+ function shouldFetchOnSync(message, cachedEtag) {
102
+ if (message.type !== "sync") {
103
+ return false;
104
+ }
105
+ if (message.unchanged === true) {
106
+ return false;
107
+ }
108
+ if (!cachedEtag) {
109
+ return true;
110
+ }
111
+ if (message.etag && message.etag !== cachedEtag) {
112
+ return true;
113
+ }
114
+ return false;
115
+ }
116
+ function shouldFetchOnFlagsUpdated(message, cachedEtag) {
117
+ if (message.type !== "flags-updated" && message.type !== "update") {
118
+ return false;
119
+ }
120
+ if (!message.etag || !cachedEtag) {
121
+ return true;
122
+ }
123
+ return message.etag !== cachedEtag;
124
+ }
125
+ function shouldFetchOnSigningKeyUpdated(message) {
126
+ return message.type === "signing-key-updated";
127
+ }
128
+ function planFlagsUpdatedRefresh(message, previousRevision) {
129
+ if (shouldFetchOnSigningKeyUpdated(message)) {
130
+ return { action: "refresh-jwks" };
131
+ }
132
+ if (shouldFetchOnFlagsUpdated(message, previousRevision)) {
133
+ return { action: "refresh-pinned", pin: message.etag ?? null };
134
+ }
135
+ return { action: "none" };
136
+ }
137
+ function applyFlagsUpdatedPlan(plan, message, hooks) {
138
+ if (plan.action === "refresh-jwks") {
139
+ hooks.refreshJwks();
140
+ return;
141
+ }
142
+ if (plan.action === "refresh-pinned") {
143
+ hooks.refreshPinned(plan.pin);
144
+ return;
145
+ }
146
+ if (message.etag) {
147
+ hooks.cacheEtagIfPresent(message.etag);
148
+ }
149
+ }
150
+ function extractDefinitionsRevision(response) {
151
+ if (!response.headers?.get) {
152
+ return null;
153
+ }
154
+ return response.headers.get(DEFINITIONS_REVISION_HEADER) ?? response.headers.get("ETag");
155
+ }
156
+ function appendDefinitionsRevisionParam(url, rev) {
157
+ if (!rev) {
158
+ return url;
159
+ }
160
+ try {
161
+ const parsed = new URL(url);
162
+ parsed.searchParams.set("rev", rev);
163
+ return parsed.toString();
164
+ } catch {
165
+ const separator = url.includes("?") ? "&" : "?";
166
+ return `${url}${separator}rev=${encodeURIComponent(rev)}`;
167
+ }
168
+ }
169
+
170
+ // src/main/client.ts
171
+ var DEFAULT_BASE_URI = "https://definitions.toggly.io";
172
+ var DEFAULT_ENVIRONMENT = "Production";
173
+ var DEFAULT_CONNECT_TIMEOUT = 5e3;
174
+ var DEFAULT_REFRESH_INTERVAL = 3 * 60 * 1e3;
175
+ var FALLBACK_REFRESH_INTERVAL = 20 * 60 * 1e3;
176
+ var HookExecutor = class {
177
+ hooks = [];
178
+ addHook(hook) {
179
+ this.hooks.push(hook);
180
+ }
181
+ async executeBeforeEvaluation(flagKey, defaultValue) {
182
+ let data = void 0;
183
+ for (const hook of this.hooks) {
184
+ if (hook.beforeEvaluation) {
185
+ data = await hook.beforeEvaluation(flagKey, defaultValue) ?? data;
186
+ }
187
+ }
188
+ return data;
189
+ }
190
+ async executeAfterEvaluation(flagKey, data, result) {
191
+ for (const hook of this.hooks) {
192
+ if (hook.afterEvaluation) {
193
+ await hook.afterEvaluation(flagKey, data, result);
194
+ }
195
+ }
196
+ }
197
+ async executeBeforeIdentify(identity) {
198
+ let data = void 0;
199
+ for (const hook of this.hooks) {
200
+ if (hook.beforeIdentify) {
201
+ data = await hook.beforeIdentify(identity) ?? data;
202
+ }
203
+ }
204
+ return data;
205
+ }
206
+ async executeAfterIdentify(identity, data) {
207
+ for (const hook of this.hooks) {
208
+ if (hook.afterIdentify) {
209
+ await hook.afterIdentify(identity, data);
210
+ }
211
+ }
212
+ }
213
+ async executeAfterRefresh(flags) {
214
+ for (const hook of this.hooks) {
215
+ if (hook.afterRefresh) {
216
+ await hook.afterRefresh(flags);
217
+ }
218
+ }
219
+ }
220
+ };
221
+ var ElectronTogglyClient = class {
222
+ config;
223
+ cache;
224
+ hookExecutor = new HookExecutor();
225
+ jwksCache = new InMemoryJwksCache();
226
+ fetchImpl;
227
+ listeners = /* @__PURE__ */ new Set();
228
+ features = {};
229
+ hasLoadedFlags = false;
230
+ identity;
231
+ groups = [];
232
+ claims = {};
233
+ cachedDefinitionsRevision = null;
234
+ pendingDefinitionsPin = null;
235
+ disposed = false;
236
+ refreshTimer = null;
237
+ refreshDebounceTimer = null;
238
+ ws = null;
239
+ wsConnected = false;
240
+ wsReconnectTimer = null;
241
+ wsReconnectAttempt = 0;
242
+ lastFallbackRefresh = 0;
243
+ localGates = [];
244
+ localGateIndex = /* @__PURE__ */ new Map();
245
+ initPromise = null;
246
+ constructor(config) {
247
+ if (!config.userDataPath) {
248
+ throw new Error('userDataPath is required (pass app.getPath("userData"))');
249
+ }
250
+ this.config = {
251
+ baseURI: config.baseURI ?? DEFAULT_BASE_URI,
252
+ environment: config.environment ?? DEFAULT_ENVIRONMENT,
253
+ connectTimeout: config.connectTimeout ?? DEFAULT_CONNECT_TIMEOUT,
254
+ featureFlagsRefreshInterval: config.featureFlagsRefreshInterval ?? DEFAULT_REFRESH_INTERVAL,
255
+ verifySignatures: config.verifySignatures ?? false,
256
+ isDebug: config.isDebug ?? false,
257
+ enableLiveUpdates: config.enableLiveUpdates ?? Boolean(config.appKey),
258
+ ...config,
259
+ userDataPath: config.userDataPath
260
+ };
261
+ this.cache = new DiskFeatureCache(this.config.userDataPath);
262
+ this.fetchImpl = config.fetch ?? (typeof globalThis.fetch === "function" ? globalThis.fetch.bind(globalThis) : (() => {
263
+ throw new Error("fetch is not available; provide config.fetch");
264
+ })());
265
+ this.identity = config.identity ?? randomUUID();
266
+ this.groups = config.groups ? [...config.groups] : [];
267
+ this.claims = config.claims ? { ...config.claims } : {};
268
+ if (config.hooks) {
269
+ for (const hook of config.hooks) {
270
+ this.hookExecutor.addHook(hook);
271
+ }
272
+ }
273
+ }
274
+ addHook(hook) {
275
+ this.hookExecutor.addHook(hook);
276
+ }
277
+ onFlagsUpdated(listener) {
278
+ this.listeners.add(listener);
279
+ return () => {
280
+ this.listeners.delete(listener);
281
+ };
282
+ }
283
+ notifyFlagsUpdated() {
284
+ const snapshot = this.getBooleanFlags();
285
+ for (const listener of this.listeners) {
286
+ try {
287
+ listener(snapshot);
288
+ } catch (error) {
289
+ this.reportError("Flags updated listener error", error);
290
+ }
291
+ }
292
+ }
293
+ reportError(message, error) {
294
+ this.config.onError?.(message, error);
295
+ if (this.config.isDebug) {
296
+ console.warn(`[Toggly] ${message}`, error);
297
+ }
298
+ }
299
+ get contextCacheKey() {
300
+ const claims = this.claims;
301
+ return `v2:${encodeURIComponent(
302
+ JSON.stringify([
303
+ this.identity ?? "",
304
+ [...this.groups].sort((a, b) => a.localeCompare(b)),
305
+ Object.entries(claims).sort(([a], [b]) => a.localeCompare(b))
306
+ ])
307
+ )}`;
308
+ }
309
+ getBooleanFlags() {
310
+ return toBooleanDefinitions(this.features);
311
+ }
312
+ getFallbackFlags() {
313
+ if (this.hasLoadedFlags) {
314
+ return this.features;
315
+ }
316
+ const defaults = this.config.flagDefaults ?? {};
317
+ return { ...defaults };
318
+ }
319
+ buildEvaluatedUrl() {
320
+ const base = this.config.baseURI.replace(/\/$/, "");
321
+ const url = new URL(
322
+ `${base}/evaluated-signed/${this.config.appKey}/${this.config.environment}`
323
+ );
324
+ appendEvaluationContext(
325
+ url,
326
+ {
327
+ identity: this.identity,
328
+ groups: this.groups,
329
+ claims: this.claims
330
+ },
331
+ "evaluated"
332
+ );
333
+ return url.toString();
334
+ }
335
+ buildFetchHeaders(skipIfNoneMatch = false) {
336
+ const revision = skipIfNoneMatch ? null : this.cachedDefinitionsRevision;
337
+ return buildDefinitionFetchHeaders(
338
+ revision ? { "If-None-Match": revision, Accept: "application/json" } : { Accept: "application/json" }
339
+ );
340
+ }
341
+ applyRevision(response) {
342
+ const revision = extractDefinitionsRevision(response);
343
+ if (revision) {
344
+ this.cachedDefinitionsRevision = revision.replace(/^"+|"+$/g, "");
345
+ }
346
+ }
347
+ async persistCache() {
348
+ if (!this.config.appKey) {
349
+ return;
350
+ }
351
+ try {
352
+ await this.cache.write(
353
+ this.config.appKey,
354
+ this.config.environment,
355
+ this.contextCacheKey,
356
+ {
357
+ flags: this.features,
358
+ revision: this.cachedDefinitionsRevision,
359
+ updatedAt: Date.now()
360
+ }
361
+ );
362
+ } catch (error) {
363
+ this.reportError("Failed to write disk cache", error);
364
+ }
365
+ }
366
+ async loadDiskCache() {
367
+ if (!this.config.appKey) {
368
+ return false;
369
+ }
370
+ try {
371
+ const entry = await this.cache.read(
372
+ this.config.appKey,
373
+ this.config.environment,
374
+ this.contextCacheKey
375
+ );
376
+ if (!entry) {
377
+ return false;
378
+ }
379
+ this.features = entry.flags;
380
+ this.cachedDefinitionsRevision = entry.revision;
381
+ this.hasLoadedFlags = true;
382
+ return true;
383
+ } catch (error) {
384
+ this.reportError("Failed to read disk cache", error);
385
+ return false;
386
+ }
387
+ }
388
+ resolveEffectiveFlag(key, entityContext) {
389
+ const resolved = resolveEvaluatedDefinition(
390
+ this.features[key],
391
+ entityContext,
392
+ this.config.flagDefaults?.[key] ?? false
393
+ );
394
+ return applyLocalGate(resolved, key, this.localGates, this.localGateIndex);
395
+ }
396
+ isFeatureOn(key, entityContext, kind) {
397
+ const ctx = normalizeEntityContext(entityContext, kind);
398
+ void this.runEvaluationHooks(key, () => this.resolveEffectiveFlag(key, ctx));
399
+ return this.resolveEffectiveFlag(key, ctx);
400
+ }
401
+ isFeatureOff(key, entityContext, kind) {
402
+ return !this.isFeatureOn(key, entityContext, kind);
403
+ }
404
+ evaluateFeatureGate(keys, requirement = "all", negate = false, entityContext, kind) {
405
+ const ctx = normalizeEntityContext(entityContext, kind);
406
+ const req = requirement === "any" ? "any" : "all";
407
+ if (keys.length === 0) {
408
+ return !negate;
409
+ }
410
+ if (Object.keys(this.features).length === 0) {
411
+ const closed = negate;
412
+ void this.runEvaluationHooks(keys[0], () => closed);
413
+ return closed;
414
+ }
415
+ const isEnabled = (key) => this.resolveEffectiveFlag(key, ctx);
416
+ let gated;
417
+ if (req === "any") {
418
+ const anyOn = keys.some(isEnabled);
419
+ gated = negate ? !anyOn : anyOn;
420
+ } else {
421
+ const allOn = keys.every(isEnabled);
422
+ gated = negate ? !allOn : allOn;
423
+ }
424
+ if (this.localGates.length === 0) {
425
+ gated = evaluateEvaluatedGate(this.features, keys, req, negate, ctx);
426
+ }
427
+ void this.runEvaluationHooks(keys[0], () => gated);
428
+ return gated;
429
+ }
430
+ async runEvaluationHooks(flagKey, evaluate) {
431
+ try {
432
+ const data = await this.hookExecutor.executeBeforeEvaluation(flagKey);
433
+ const result = evaluate();
434
+ await this.hookExecutor.executeAfterEvaluation(flagKey, data, result);
435
+ } catch (error) {
436
+ this.reportError("Hook execution error", error);
437
+ }
438
+ }
439
+ getFlags() {
440
+ return this.getBooleanFlags();
441
+ }
442
+ setLocalGates(gates) {
443
+ this.localGates = [...gates];
444
+ this.localGateIndex = buildFlagGateIndex(this.localGates);
445
+ }
446
+ async refresh() {
447
+ if (this.disposed) {
448
+ return this.getBooleanFlags();
449
+ }
450
+ if (!this.config.appKey) {
451
+ this.features = { ...this.config.flagDefaults ?? {} };
452
+ this.hasLoadedFlags = true;
453
+ this.notifyFlagsUpdated();
454
+ return this.getBooleanFlags();
455
+ }
456
+ try {
457
+ const pin = this.pendingDefinitionsPin;
458
+ this.pendingDefinitionsPin = null;
459
+ const url = appendDefinitionsRevisionParam(this.buildEvaluatedUrl(), pin);
460
+ const headers = this.buildFetchHeaders(Boolean(pin));
461
+ const controller = new AbortController();
462
+ const timeoutId = setTimeout(
463
+ () => controller.abort(),
464
+ this.config.connectTimeout
465
+ );
466
+ const response = await this.fetchImpl(url, {
467
+ method: "GET",
468
+ headers,
469
+ signal: controller.signal
470
+ });
471
+ clearTimeout(timeoutId);
472
+ if (response.status === 304) {
473
+ this.applyRevision(response);
474
+ await this.persistCache();
475
+ return this.getBooleanFlags();
476
+ }
477
+ if (!response.ok) {
478
+ throw new Error(`HTTP ${response.status} ${response.statusText}`);
479
+ }
480
+ const bodyText = await readResponseBody(response);
481
+ const parsed = await parseEvaluatedResponseBody(bodyText, {
482
+ verifySignatures: this.config.verifySignatures,
483
+ baseURI: this.config.baseURI,
484
+ allowedKeyIds: this.config.allowedKeyIds,
485
+ maxSignatureAgeSeconds: this.config.maxSignatureAgeSeconds ?? void 0,
486
+ headers,
487
+ fetchImpl: this.fetchImpl,
488
+ getJwks: this.config.verifySignatures ? () => this.jwksCache.get({
489
+ verifySignatures: true,
490
+ baseURI: this.config.baseURI,
491
+ allowedKeyIds: this.config.allowedKeyIds,
492
+ maxSignatureAgeSeconds: this.config.maxSignatureAgeSeconds ?? void 0,
493
+ headers,
494
+ fetchImpl: this.fetchImpl
495
+ }) : void 0
496
+ });
497
+ const defs = (this.config.verifySignatures ? parsed : unwrapDefsPayload(parsed)) ?? {};
498
+ this.features = defs;
499
+ this.hasLoadedFlags = true;
500
+ this.applyRevision(response);
501
+ await this.persistCache();
502
+ await this.hookExecutor.executeAfterRefresh(this.getBooleanFlags());
503
+ this.notifyFlagsUpdated();
504
+ return this.getBooleanFlags();
505
+ } catch (error) {
506
+ this.reportError("Failed to refresh feature flags", error);
507
+ if (!this.hasLoadedFlags) {
508
+ const loaded = await this.loadDiskCache();
509
+ if (!loaded) {
510
+ this.features = this.getFallbackFlags();
511
+ this.hasLoadedFlags = true;
512
+ }
513
+ }
514
+ this.notifyFlagsUpdated();
515
+ return this.getBooleanFlags();
516
+ }
517
+ }
518
+ async init() {
519
+ if (this.initPromise) {
520
+ return this.initPromise;
521
+ }
522
+ this.initPromise = this.doInit();
523
+ return this.initPromise;
524
+ }
525
+ async doInit() {
526
+ await this.loadDiskCache();
527
+ const flags = await this.refresh();
528
+ this.startRefreshInterval();
529
+ if (this.config.enableLiveUpdates && this.config.appKey) {
530
+ this.startWebSocket();
531
+ }
532
+ return flags;
533
+ }
534
+ async setContext(input) {
535
+ if (input.identity !== void 0) {
536
+ const data = await this.hookExecutor.executeBeforeIdentify(input.identity);
537
+ this.identity = input.identity;
538
+ await this.hookExecutor.executeAfterIdentify(input.identity, data);
539
+ }
540
+ if (input.groups !== void 0) {
541
+ this.groups = [...input.groups];
542
+ }
543
+ if (input.claims !== void 0) {
544
+ this.claims = { ...input.claims };
545
+ }
546
+ this.hasLoadedFlags = false;
547
+ this.cachedDefinitionsRevision = null;
548
+ return this.refresh();
549
+ }
550
+ async clearContext() {
551
+ this.identity = randomUUID();
552
+ this.groups = [];
553
+ this.claims = {};
554
+ this.hasLoadedFlags = false;
555
+ this.cachedDefinitionsRevision = null;
556
+ return this.refresh();
557
+ }
558
+ startRefreshInterval() {
559
+ this.stopRefreshInterval();
560
+ const interval = () => this.wsConnected ? FALLBACK_REFRESH_INTERVAL : this.config.featureFlagsRefreshInterval;
561
+ this.refreshTimer = setInterval(() => {
562
+ if (this.wsConnected) {
563
+ const elapsed = Date.now() - this.lastFallbackRefresh;
564
+ if (elapsed < FALLBACK_REFRESH_INTERVAL) {
565
+ return;
566
+ }
567
+ }
568
+ this.lastFallbackRefresh = Date.now();
569
+ void this.refresh();
570
+ }, Math.min(interval(), this.config.featureFlagsRefreshInterval));
571
+ }
572
+ stopRefreshInterval() {
573
+ if (this.refreshTimer) {
574
+ clearInterval(this.refreshTimer);
575
+ this.refreshTimer = null;
576
+ }
577
+ }
578
+ scheduleDebouncedRefresh(forceJwksRefresh = false) {
579
+ if (this.refreshDebounceTimer) {
580
+ clearTimeout(this.refreshDebounceTimer);
581
+ }
582
+ this.refreshDebounceTimer = setTimeout(() => {
583
+ this.refreshDebounceTimer = null;
584
+ if (forceJwksRefresh && this.config.verifySignatures) {
585
+ this.cachedDefinitionsRevision = null;
586
+ this.jwksCache.clear();
587
+ }
588
+ void this.refresh();
589
+ }, REFRESH_DEBOUNCE_MS);
590
+ }
591
+ handleWsMessage(raw) {
592
+ try {
593
+ const message = JSON.parse(String(raw));
594
+ if (message.type === "ping") {
595
+ return;
596
+ }
597
+ if (shouldFetchOnSync(message, this.cachedDefinitionsRevision)) {
598
+ this.scheduleDebouncedRefresh();
599
+ return;
600
+ }
601
+ if (message.type === "sync" && message.etag) {
602
+ this.cachedDefinitionsRevision = message.etag;
603
+ return;
604
+ }
605
+ const plan = planFlagsUpdatedRefresh(message, this.cachedDefinitionsRevision);
606
+ applyFlagsUpdatedPlan(plan, message, {
607
+ refreshJwks: () => this.scheduleDebouncedRefresh(true),
608
+ refreshPinned: (pin) => {
609
+ this.pendingDefinitionsPin = pin;
610
+ this.cachedDefinitionsRevision = null;
611
+ this.scheduleDebouncedRefresh();
612
+ },
613
+ cacheEtagIfPresent: (etag) => {
614
+ this.cachedDefinitionsRevision = etag;
615
+ }
616
+ });
617
+ } catch (error) {
618
+ this.reportError("Failed to parse WebSocket message", error);
619
+ }
620
+ }
621
+ startWebSocket() {
622
+ if (this.disposed || !this.config.appKey || !this.config.enableLiveUpdates) {
623
+ return;
624
+ }
625
+ this.stopWebSocket(false);
626
+ const url = buildWebSocketUrl(
627
+ this.config.baseURI,
628
+ this.config.appKey,
629
+ this.cachedDefinitionsRevision
630
+ );
631
+ try {
632
+ const ws = new WebSocket(url);
633
+ this.ws = ws;
634
+ ws.on("open", () => {
635
+ this.wsConnected = true;
636
+ this.wsReconnectAttempt = 0;
637
+ this.lastFallbackRefresh = Date.now();
638
+ if (this.config.isDebug) {
639
+ console.log("[Toggly] WebSocket connected");
640
+ }
641
+ });
642
+ ws.on("message", (data) => this.handleWsMessage(data));
643
+ ws.on("close", () => {
644
+ this.wsConnected = false;
645
+ this.ws = null;
646
+ this.scheduleReconnect();
647
+ });
648
+ ws.on("error", (error) => {
649
+ this.reportError("WebSocket error", error);
650
+ });
651
+ } catch (error) {
652
+ this.reportError("Failed to start WebSocket", error);
653
+ this.scheduleReconnect();
654
+ }
655
+ }
656
+ scheduleReconnect() {
657
+ if (this.disposed || !this.config.enableLiveUpdates) {
658
+ return;
659
+ }
660
+ if (this.wsReconnectTimer) {
661
+ return;
662
+ }
663
+ const delay = getNextReconnectDelayMs(this.wsReconnectAttempt);
664
+ this.wsReconnectAttempt += 1;
665
+ this.wsReconnectTimer = setTimeout(() => {
666
+ this.wsReconnectTimer = null;
667
+ this.startWebSocket();
668
+ }, delay);
669
+ }
670
+ stopWebSocket(clearReconnect = true) {
671
+ if (this.wsReconnectTimer && clearReconnect) {
672
+ clearTimeout(this.wsReconnectTimer);
673
+ this.wsReconnectTimer = null;
674
+ }
675
+ if (this.ws) {
676
+ try {
677
+ this.ws.removeAllListeners();
678
+ this.ws.close();
679
+ } catch {
680
+ }
681
+ this.ws = null;
682
+ }
683
+ this.wsConnected = false;
684
+ }
685
+ close() {
686
+ this.disposed = true;
687
+ this.stopRefreshInterval();
688
+ if (this.refreshDebounceTimer) {
689
+ clearTimeout(this.refreshDebounceTimer);
690
+ this.refreshDebounceTimer = null;
691
+ }
692
+ this.stopWebSocket(true);
693
+ this.listeners.clear();
694
+ this.initPromise = null;
695
+ }
696
+ };
697
+ var singleton = null;
698
+ async function initToggly(config) {
699
+ if (singleton) {
700
+ singleton.close();
701
+ }
702
+ singleton = new ElectronTogglyClient(config);
703
+ return singleton.init();
704
+ }
705
+ function getToggly() {
706
+ return singleton;
707
+ }
708
+ function isFeatureOn(key, entityContext, kind) {
709
+ return singleton?.isFeatureOn(key, entityContext, kind) ?? false;
710
+ }
711
+ function isFeatureOff(key, entityContext, kind) {
712
+ return singleton?.isFeatureOff(key, entityContext, kind) ?? true;
713
+ }
714
+ function evaluateFeatureGate(keys, requirement, negate, entityContext, kind) {
715
+ if (!singleton) {
716
+ return negate ?? false;
717
+ }
718
+ return singleton.evaluateFeatureGate(
719
+ keys,
720
+ requirement,
721
+ negate,
722
+ entityContext,
723
+ kind
724
+ );
725
+ }
726
+ async function setContext(input) {
727
+ if (!singleton) {
728
+ throw new Error("Toggly is not initialized. Call initToggly first.");
729
+ }
730
+ return singleton.setContext(input);
731
+ }
732
+ async function clearContext() {
733
+ if (!singleton) {
734
+ throw new Error("Toggly is not initialized. Call initToggly first.");
735
+ }
736
+ return singleton.clearContext();
737
+ }
738
+ function addHook(hook) {
739
+ singleton?.addHook(hook);
740
+ }
741
+ function closeToggly() {
742
+ singleton?.close();
743
+ singleton = null;
744
+ }
745
+ function __resetTogglyForTests() {
746
+ singleton?.close();
747
+ singleton = null;
748
+ }
749
+
750
+ // src/ipc-channels.ts
751
+ var IPC_PREFIX = "toggly:";
752
+ var IPC_CHANNELS = {
753
+ isFeatureOn: `${IPC_PREFIX}isFeatureOn`,
754
+ isFeatureOff: `${IPC_PREFIX}isFeatureOff`,
755
+ evaluateFeatureGate: `${IPC_PREFIX}evaluateFeatureGate`,
756
+ getFlags: `${IPC_PREFIX}getFlags`,
757
+ setContext: `${IPC_PREFIX}setContext`,
758
+ clearContext: `${IPC_PREFIX}clearContext`,
759
+ flagsUpdated: `${IPC_PREFIX}flags-updated`
760
+ };
761
+
762
+ // src/main/ipc.ts
763
+ function registerTogglyIpc(ipcMain, getWindows = () => []) {
764
+ const client = getToggly();
765
+ if (!client) {
766
+ throw new Error("Toggly is not initialized. Call initToggly before registerTogglyIpc.");
767
+ }
768
+ ipcMain.on(IPC_CHANNELS.isFeatureOn, (event, key, entityContext, kind) => {
769
+ event.returnValue = isFeatureOn(
770
+ String(key),
771
+ entityContext,
772
+ kind
773
+ );
774
+ });
775
+ ipcMain.on(IPC_CHANNELS.isFeatureOff, (event, key, entityContext, kind) => {
776
+ event.returnValue = isFeatureOff(
777
+ String(key),
778
+ entityContext,
779
+ kind
780
+ );
781
+ });
782
+ ipcMain.on(
783
+ IPC_CHANNELS.evaluateFeatureGate,
784
+ (event, keys, requirement, negate, entityContext, kind) => {
785
+ event.returnValue = evaluateFeatureGate(
786
+ keys ?? [],
787
+ requirement,
788
+ Boolean(negate),
789
+ entityContext,
790
+ kind
791
+ );
792
+ }
793
+ );
794
+ ipcMain.handle(IPC_CHANNELS.getFlags, () => {
795
+ return getToggly()?.getFlags() ?? {};
796
+ });
797
+ ipcMain.handle(IPC_CHANNELS.setContext, (_event, context) => {
798
+ return setContext(context ?? {});
799
+ });
800
+ ipcMain.handle(IPC_CHANNELS.clearContext, () => {
801
+ return clearContext();
802
+ });
803
+ const unsubscribe = client.onFlagsUpdated((flags) => {
804
+ for (const win of getWindows()) {
805
+ try {
806
+ if (win.isDestroyed?.()) {
807
+ continue;
808
+ }
809
+ if (win.webContents.isDestroyed?.()) {
810
+ continue;
811
+ }
812
+ win.webContents.send(IPC_CHANNELS.flagsUpdated, flags);
813
+ } catch {
814
+ }
815
+ }
816
+ });
817
+ return () => {
818
+ unsubscribe();
819
+ ipcMain.removeAllListeners?.(IPC_CHANNELS.isFeatureOn);
820
+ ipcMain.removeAllListeners?.(IPC_CHANNELS.isFeatureOff);
821
+ ipcMain.removeAllListeners?.(IPC_CHANNELS.evaluateFeatureGate);
822
+ ipcMain.removeHandler?.(IPC_CHANNELS.getFlags);
823
+ ipcMain.removeHandler?.(IPC_CHANNELS.setContext);
824
+ ipcMain.removeHandler?.(IPC_CHANNELS.clearContext);
825
+ };
826
+ }
827
+
828
+ export { DiskFeatureCache, ElectronTogglyClient, IPC_CHANNELS, IPC_PREFIX, SDK_ID, SDK_VERSION, __resetTogglyForTests, addHook, buildCacheFilePath, clearContext, closeToggly, evaluateFeatureGate, getToggly, initToggly, isFeatureOff, isFeatureOn, registerTogglyIpc, setContext };
829
+ //# sourceMappingURL=index.js.map
830
+ //# sourceMappingURL=index.js.map