@ops-ai/react-router-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,2324 @@
1
+ 'use strict';
2
+
3
+ var togglyHooksTypes = require('@ops-ai/toggly-hooks-types');
4
+ var togglyEval = require('@ops-ai/toggly-eval');
5
+ var async_hooks = require('async_hooks');
6
+ var togglyLocalGates = require('@ops-ai/toggly-local-gates');
7
+ var WebSocket = require('ws');
8
+ var togglySignedDefs = require('@ops-ai/toggly-signed-defs');
9
+ var module$1 = require('module');
10
+ var fs = require('fs');
11
+ var path = require('path');
12
+ var url = require('url');
13
+ var reactRouter = require('react-router');
14
+
15
+ var _documentCurrentScript = typeof document !== 'undefined' ? document.currentScript : null;
16
+ function _interopDefault (e) { return e && e.__esModule ? e : { default: e }; }
17
+
18
+ var WebSocket__default = /*#__PURE__*/_interopDefault(WebSocket);
19
+ var fs__default = /*#__PURE__*/_interopDefault(fs);
20
+ var path__default = /*#__PURE__*/_interopDefault(path);
21
+
22
+ // src/core/index.ts
23
+
24
+ // src/core/types.ts
25
+ var TogglyError = class extends Error {
26
+ constructor(message, code, cause) {
27
+ super(message);
28
+ this.code = code;
29
+ this.cause = cause;
30
+ this.name = "TogglyError";
31
+ }
32
+ };
33
+ var TogglyNetworkError = class extends TogglyError {
34
+ constructor(message, cause) {
35
+ super(message, "NETWORK_ERROR", cause);
36
+ this.name = "TogglyNetworkError";
37
+ }
38
+ };
39
+ var TogglyConfigError = class extends TogglyError {
40
+ constructor(message) {
41
+ super(message, "CONFIG_ERROR");
42
+ this.name = "TogglyConfigError";
43
+ }
44
+ };
45
+ var TogglyTimeoutError = class extends TogglyError {
46
+ constructor(message) {
47
+ super(message, "TIMEOUT_ERROR");
48
+ this.name = "TogglyTimeoutError";
49
+ }
50
+ };
51
+ var DEFAULT_CONFIG = {
52
+ baseUrl: "https://definitions.toggly.io",
53
+ environment: "Production",
54
+ timeout: 1e4,
55
+ debug: false,
56
+ evaluationMode: "remote"
57
+ };
58
+ function mergeConfig(config) {
59
+ return {
60
+ ...DEFAULT_CONFIG,
61
+ ...config
62
+ };
63
+ }
64
+ function buildDefinitionsUrl(config, context) {
65
+ const { baseUrl, appKey, environment, groups, claims, evaluationMode } = mergeConfig(config);
66
+ if (!appKey) {
67
+ throw new Error("appKey is required");
68
+ }
69
+ const mode = evaluationMode ?? "remote";
70
+ const pathSegment = mode === "local" ? "definitions-signed" : "evaluated-signed";
71
+ const url = new URL(`${baseUrl}/${pathSegment}/${appKey}/${environment}`);
72
+ if (mode === "local") {
73
+ return url.toString();
74
+ }
75
+ const fromParam = context;
76
+ togglyHooksTypes.appendEvaluationContext(
77
+ url,
78
+ {
79
+ identity: fromParam?.identity,
80
+ groups: groups,
81
+ claims: claims
82
+ },
83
+ "evaluated"
84
+ );
85
+ return url.toString();
86
+ }
87
+ function isFeatureEnabledLocal(defsByKey, featureKey, evalCtx = {}, defaultValue = false) {
88
+ if (!defsByKey || defsByKey.size === 0) {
89
+ return defaultValue;
90
+ }
91
+ if (!defsByKey.has(featureKey)) {
92
+ return defaultValue;
93
+ }
94
+ return togglyEval.evaluateDefinitions(defsByKey, featureKey, evalCtx);
95
+ }
96
+ function createLogger(debug) {
97
+ return {
98
+ debug: (...args) => {
99
+ if (debug) {
100
+ console.debug("[Toggly]", ...args);
101
+ }
102
+ },
103
+ info: (...args) => {
104
+ if (debug) {
105
+ console.info("[Toggly]", ...args);
106
+ }
107
+ },
108
+ warn: (...args) => {
109
+ console.warn("[Toggly]", ...args);
110
+ },
111
+ error: (...args) => {
112
+ console.error("[Toggly]", ...args);
113
+ }
114
+ };
115
+ }
116
+ function parseIdentity(value) {
117
+ if (!value) {
118
+ return void 0;
119
+ }
120
+ try {
121
+ const parsed = JSON.parse(value);
122
+ if (typeof parsed === "string") {
123
+ return parsed;
124
+ }
125
+ if (typeof parsed === "object" && parsed !== null) {
126
+ return parsed.identity || parsed.id || parsed.userId || parsed.sub;
127
+ }
128
+ } catch {
129
+ }
130
+ return value;
131
+ }
132
+ async function fetchWithTimeout(url, options = {}, timeout = 1e4) {
133
+ const controller = new AbortController();
134
+ const timeoutId = setTimeout(() => controller.abort(), timeout);
135
+ try {
136
+ const response = await fetch(url, {
137
+ ...options,
138
+ signal: controller.signal
139
+ });
140
+ return response;
141
+ } finally {
142
+ clearTimeout(timeoutId);
143
+ }
144
+ }
145
+
146
+ // src/core/constants.ts
147
+ var STORAGE_KEYS = {
148
+ /** Identity cookie/storage key */
149
+ IDENTITY: "toggly_identity",
150
+ /** Feature flags cache key */
151
+ FLAGS: "toggly_flags",
152
+ /** Config cache key */
153
+ CONFIG: "toggly_config",
154
+ /** Last fetch timestamp key */
155
+ LAST_FETCH: "toggly_last_fetch"
156
+ };
157
+ var HEADERS = {
158
+ /** Identity header */
159
+ IDENTITY: "x-toggly-identity",
160
+ /** Feature flags header */
161
+ FLAGS: "x-toggly-flags",
162
+ /** Cache control */
163
+ CACHE_CONTROL: "cache-control"
164
+ };
165
+ var TOGGLY_LOADER_KEY = "__toggly";
166
+
167
+ // src/core/telemetry/hash.ts
168
+ var utf8Encoder = new TextEncoder();
169
+ function hashIdentity(identity) {
170
+ let hash = 2166136261;
171
+ const bytes = utf8Encoder.encode(identity);
172
+ for (let i = 0; i < bytes.length; i++) {
173
+ hash ^= bytes[i];
174
+ hash = Math.imul(hash, 16777619);
175
+ }
176
+ const unsigned = hash >>> 0;
177
+ return unsigned > 2147483647 ? unsigned - 4294967296 : unsigned;
178
+ }
179
+ function toProtobufTimestamp(date = /* @__PURE__ */ new Date()) {
180
+ const ms = date.getTime();
181
+ const seconds = Math.floor(ms / 1e3);
182
+ const nanos = ms % 1e3 * 1e6;
183
+ return { seconds, nanos };
184
+ }
185
+ function protobufTimestampToIso(ts) {
186
+ return new Date(ts.seconds * 1e3 + Math.floor(ts.nanos / 1e6)).toISOString();
187
+ }
188
+
189
+ // src/core/telemetry/usage-batcher.ts
190
+ var MAX_UNIQUE_USER_HASHES_PER_FEATURE = 1e4;
191
+ var MAX_APPLICATION_UNIQUE_USER_HASHES = 1e4;
192
+ var MAX_FEATURES_PER_BATCH = 500;
193
+ function emptyVariant() {
194
+ return { checkCount: 0, requestCount: 0, usedCount: 0, viewedCount: 0 };
195
+ }
196
+ function emptyFeature() {
197
+ return {
198
+ variantStats: /* @__PURE__ */ new Map(),
199
+ uniqueUsersEnabled: /* @__PURE__ */ new Set(),
200
+ uniqueUsersDisabled: /* @__PURE__ */ new Set(),
201
+ uniqueUsersUsed: /* @__PURE__ */ new Set(),
202
+ uniqueUsersViewed: /* @__PURE__ */ new Set(),
203
+ uniqueUserHashes: /* @__PURE__ */ new Set(),
204
+ uniqueViewedUserHashes: /* @__PURE__ */ new Set()
205
+ };
206
+ }
207
+ var UsageBatcher = class {
208
+ constructor(options) {
209
+ this.perFeature = /* @__PURE__ */ new Map();
210
+ this.appUnique = /* @__PURE__ */ new Set();
211
+ this.droppedFeatures = false;
212
+ this.definitionCacheHits = 0;
213
+ this.definitionCacheMisses = 0;
214
+ this.appKey = options.appKey;
215
+ this.environment = options.environment;
216
+ this.instanceName = options.instanceName;
217
+ this.appVersion = options.appVersion;
218
+ this.processStartTime = options.processStartTime ?? /* @__PURE__ */ new Date();
219
+ this.maxUniqueHashesPerFeature = options.maxUniqueHashesPerFeature ?? MAX_UNIQUE_USER_HASHES_PER_FEATURE;
220
+ this.maxApplicationUniqueHashes = options.maxApplicationUniqueHashes ?? MAX_APPLICATION_UNIQUE_USER_HASHES;
221
+ this.maxFeatures = options.maxFeatures ?? MAX_FEATURES_PER_BATCH;
222
+ }
223
+ /** Count a definition-refresh outcome served from local/cache (not a new revision). */
224
+ recordDefinitionCacheHit() {
225
+ this.definitionCacheHits += 1;
226
+ }
227
+ /** Count a definition-refresh that applied a new revision from the network. */
228
+ recordDefinitionCacheMiss() {
229
+ this.definitionCacheMisses += 1;
230
+ }
231
+ get(feature) {
232
+ let agg = this.perFeature.get(feature);
233
+ if (!agg) {
234
+ if (this.perFeature.size >= this.maxFeatures) {
235
+ this.droppedFeatures = true;
236
+ return null;
237
+ }
238
+ agg = emptyFeature();
239
+ this.perFeature.set(feature, agg);
240
+ }
241
+ return agg;
242
+ }
243
+ getVariant(agg, variant) {
244
+ let stats = agg.variantStats.get(variant);
245
+ if (!stats) {
246
+ stats = emptyVariant();
247
+ agg.variantStats.set(variant, stats);
248
+ }
249
+ return stats;
250
+ }
251
+ addHashCapped(set, hash, max) {
252
+ if (set.has(hash) || set.size < max) {
253
+ set.add(hash);
254
+ }
255
+ }
256
+ trackAppUnique(hash) {
257
+ this.addHashCapped(this.appUnique, hash, this.maxApplicationUniqueHashes);
258
+ }
259
+ /**
260
+ * Record a feature evaluation.
261
+ *
262
+ * `checkCount` increments on every call. `requestCount` maps to .NET
263
+ * UniqueRequestEnabled/Disabled when `uniqueRequest` is true.
264
+ */
265
+ recordCheck(feature, enabled, identity, variant, uniqueRequest = false) {
266
+ const agg = this.get(feature);
267
+ if (!agg) return;
268
+ const name = variant ?? (enabled ? "enabled" : "disabled");
269
+ const stats = this.getVariant(agg, name);
270
+ stats.checkCount += 1;
271
+ if (uniqueRequest) {
272
+ stats.requestCount += 1;
273
+ }
274
+ if (identity) {
275
+ const hash = hashIdentity(identity);
276
+ this.trackAppUnique(hash);
277
+ if (enabled) {
278
+ this.addHashCapped(agg.uniqueUsersEnabled, hash, this.maxUniqueHashesPerFeature);
279
+ } else {
280
+ this.addHashCapped(agg.uniqueUsersDisabled, hash, this.maxUniqueHashesPerFeature);
281
+ }
282
+ }
283
+ }
284
+ recordUsage(feature, identity, variant = "enabled") {
285
+ const agg = this.get(feature);
286
+ if (!agg) return;
287
+ this.getVariant(agg, variant).usedCount += 1;
288
+ if (identity) {
289
+ const hash = hashIdentity(identity);
290
+ this.trackAppUnique(hash);
291
+ this.addHashCapped(agg.uniqueUsersUsed, hash, this.maxUniqueHashesPerFeature);
292
+ this.addHashCapped(agg.uniqueUserHashes, hash, this.maxUniqueHashesPerFeature);
293
+ }
294
+ }
295
+ recordView(feature, identity, variant = "enabled") {
296
+ const agg = this.get(feature);
297
+ if (!agg) return;
298
+ this.getVariant(agg, variant).viewedCount += 1;
299
+ if (identity) {
300
+ const hash = hashIdentity(identity);
301
+ this.trackAppUnique(hash);
302
+ this.addHashCapped(agg.uniqueUsersViewed, hash, this.maxUniqueHashesPerFeature);
303
+ this.addHashCapped(agg.uniqueViewedUserHashes, hash, this.maxUniqueHashesPerFeature);
304
+ }
305
+ }
306
+ isEmpty() {
307
+ return this.perFeature.size === 0 && this.appUnique.size === 0 && this.definitionCacheHits === 0 && this.definitionCacheMisses === 0;
308
+ }
309
+ hitFeatureCap() {
310
+ return this.droppedFeatures;
311
+ }
312
+ featureCount() {
313
+ return this.perFeature.size;
314
+ }
315
+ buildAndReset() {
316
+ if (this.isEmpty()) {
317
+ return null;
318
+ }
319
+ const payload = {
320
+ appKey: this.appKey,
321
+ environment: this.environment,
322
+ time: toProtobufTimestamp(),
323
+ stats: [],
324
+ totalUniqueUsers: this.appUnique.size,
325
+ uniqueUserHashes: [...this.appUnique],
326
+ processStartTime: toProtobufTimestamp(this.processStartTime)
327
+ };
328
+ if (this.instanceName) {
329
+ payload.instanceName = this.instanceName;
330
+ }
331
+ if (this.appVersion) {
332
+ payload.appVersion = this.appVersion;
333
+ }
334
+ if (this.definitionCacheHits > 0) {
335
+ payload.definitionCacheHits = this.definitionCacheHits;
336
+ }
337
+ if (this.definitionCacheMisses > 0) {
338
+ payload.definitionCacheMisses = this.definitionCacheMisses;
339
+ }
340
+ const uniqueUsersEnabled = {};
341
+ const uniqueUsersDisabled = {};
342
+ const uniqueUsersUsed = {};
343
+ for (const [feature, agg] of this.perFeature) {
344
+ const variantStats = {};
345
+ for (const [name, stats] of agg.variantStats) {
346
+ if (stats.checkCount > 0 || stats.requestCount > 0 || stats.usedCount > 0 || stats.viewedCount > 0) {
347
+ variantStats[name] = { ...stats };
348
+ }
349
+ }
350
+ if (agg.uniqueUsersEnabled.size > 0) {
351
+ uniqueUsersEnabled[feature] = [...agg.uniqueUsersEnabled];
352
+ }
353
+ if (agg.uniqueUsersDisabled.size > 0) {
354
+ uniqueUsersDisabled[feature] = [...agg.uniqueUsersDisabled];
355
+ }
356
+ if (agg.uniqueUsersUsed.size > 0) {
357
+ uniqueUsersUsed[feature] = [...agg.uniqueUsersUsed];
358
+ }
359
+ payload.stats.push({
360
+ feature,
361
+ uniqueContextIdentifierEnabledCount: agg.uniqueUsersEnabled.size,
362
+ uniqueContextIdentifierDisabledCount: agg.uniqueUsersDisabled.size,
363
+ uniqueUsersUsedCount: agg.uniqueUsersUsed.size,
364
+ uniqueUserHashes: [...agg.uniqueUserHashes],
365
+ uniqueViewedUserHashes: [...agg.uniqueViewedUserHashes],
366
+ variantStats
367
+ });
368
+ }
369
+ this.perFeature = /* @__PURE__ */ new Map();
370
+ this.appUnique = /* @__PURE__ */ new Set();
371
+ this.droppedFeatures = false;
372
+ this.definitionCacheHits = 0;
373
+ this.definitionCacheMisses = 0;
374
+ return {
375
+ payload,
376
+ uniqueUsersEnabled,
377
+ uniqueUsersDisabled,
378
+ uniqueUsersUsed
379
+ };
380
+ }
381
+ restoreFromBundle(bundle) {
382
+ const { payload } = bundle;
383
+ this.definitionCacheHits += payload.definitionCacheHits ?? 0;
384
+ this.definitionCacheMisses += payload.definitionCacheMisses ?? 0;
385
+ for (const hash of payload.uniqueUserHashes ?? []) {
386
+ this.trackAppUnique(hash);
387
+ }
388
+ for (const stat of payload.stats ?? []) {
389
+ const feature = stat.feature;
390
+ if (!feature) continue;
391
+ const agg = this.get(feature);
392
+ if (!agg) continue;
393
+ for (const [name, vs] of Object.entries(stat.variantStats ?? {})) {
394
+ const target = this.getVariant(agg, name);
395
+ target.checkCount += vs.checkCount ?? 0;
396
+ target.requestCount += vs.requestCount ?? 0;
397
+ target.usedCount += vs.usedCount ?? 0;
398
+ target.viewedCount += vs.viewedCount ?? 0;
399
+ }
400
+ for (const hash of stat.uniqueUserHashes ?? []) {
401
+ this.addHashCapped(agg.uniqueUserHashes, hash, this.maxUniqueHashesPerFeature);
402
+ }
403
+ for (const hash of stat.uniqueViewedUserHashes ?? []) {
404
+ this.addHashCapped(agg.uniqueViewedUserHashes, hash, this.maxUniqueHashesPerFeature);
405
+ this.addHashCapped(agg.uniqueUsersViewed, hash, this.maxUniqueHashesPerFeature);
406
+ }
407
+ }
408
+ this.mergeUniqueHashMap(bundle.uniqueUsersEnabled, "uniqueUsersEnabled");
409
+ this.mergeUniqueHashMap(bundle.uniqueUsersDisabled, "uniqueUsersDisabled");
410
+ this.mergeUniqueHashMap(bundle.uniqueUsersUsed, "uniqueUsersUsed");
411
+ }
412
+ mergeUniqueHashMap(snapshot2, field) {
413
+ for (const [feature, hashes] of Object.entries(snapshot2 ?? {})) {
414
+ const agg = this.get(feature);
415
+ if (!agg) continue;
416
+ for (const hash of hashes) {
417
+ this.addHashCapped(agg[field], hash, this.maxUniqueHashesPerFeature);
418
+ this.trackAppUnique(hash);
419
+ }
420
+ }
421
+ }
422
+ };
423
+
424
+ // src/core/telemetry/metrics-batcher.ts
425
+ var MAX_METRIC_KEYS_PER_BATCH = 500;
426
+ var MAX_OBSERVATIONS_PER_BATCH = 1e3;
427
+ var MetricsBatcher = class {
428
+ constructor(options) {
429
+ this.measures = /* @__PURE__ */ new Map();
430
+ this.counters = /* @__PURE__ */ new Map();
431
+ this.observations = [];
432
+ this.droppedKeys = false;
433
+ this.appKey = options.appKey;
434
+ this.environment = options.environment;
435
+ this.instanceName = options.instanceName;
436
+ this.maxMetricKeys = options.maxMetricKeys ?? MAX_METRIC_KEYS_PER_BATCH;
437
+ this.maxObservations = options.maxObservations ?? MAX_OBSERVATIONS_PER_BATCH;
438
+ }
439
+ key(metric, feature) {
440
+ return `${metric}\0${feature ?? ""}`;
441
+ }
442
+ parseKey(key) {
443
+ const sep = key.indexOf("\0");
444
+ if (sep < 0) {
445
+ return { metric: key };
446
+ }
447
+ const metric = key.slice(0, sep);
448
+ const feature = key.slice(sep + 1);
449
+ return feature ? { metric, feature } : { metric };
450
+ }
451
+ addToMap(store, metric, value, options) {
452
+ const variant = options?.variant ?? "enabled";
453
+ const mapKey = this.key(metric, options?.feature);
454
+ let variants = store.get(mapKey);
455
+ if (!variants) {
456
+ if (store.size >= this.maxMetricKeys) {
457
+ this.droppedKeys = true;
458
+ return;
459
+ }
460
+ variants = /* @__PURE__ */ new Map();
461
+ store.set(mapKey, variants);
462
+ }
463
+ variants.set(variant, (variants.get(variant) ?? 0) + value);
464
+ }
465
+ measure(metric, value, options) {
466
+ this.addToMap(this.measures, metric, value, options);
467
+ }
468
+ incrementCounter(metric, value = 1, options) {
469
+ this.addToMap(this.counters, metric, value, options);
470
+ }
471
+ observe(metric, value, options) {
472
+ if (this.observations.length >= this.maxObservations) {
473
+ this.droppedKeys = true;
474
+ return;
475
+ }
476
+ this.observations.push({
477
+ time: /* @__PURE__ */ new Date(),
478
+ metric,
479
+ feature: options?.feature,
480
+ variant: options?.variant ?? "enabled",
481
+ value
482
+ });
483
+ }
484
+ isEmpty() {
485
+ return this.measures.size === 0 && this.counters.size === 0 && this.observations.length === 0;
486
+ }
487
+ hitCap() {
488
+ return this.droppedKeys;
489
+ }
490
+ drainMap(store) {
491
+ const out = [];
492
+ for (const [key, variants] of store) {
493
+ const parsed = this.parseKey(key);
494
+ const variantValues = {};
495
+ for (const [variant, value] of variants) {
496
+ if (value !== 0) {
497
+ variantValues[variant] = value;
498
+ }
499
+ }
500
+ if (Object.keys(variantValues).length > 0) {
501
+ out.push({
502
+ metric: parsed.metric,
503
+ ...parsed.feature ? { feature: parsed.feature } : {},
504
+ variantValues
505
+ });
506
+ }
507
+ }
508
+ store.clear();
509
+ return out;
510
+ }
511
+ buildAndReset() {
512
+ if (this.isEmpty()) {
513
+ return null;
514
+ }
515
+ const observationGroups = /* @__PURE__ */ new Map();
516
+ for (const obs of this.observations) {
517
+ const groupKey = `${obs.time.toISOString()}\0${obs.metric}\0${obs.feature ?? ""}`;
518
+ let group = observationGroups.get(groupKey);
519
+ if (!group) {
520
+ group = {
521
+ time: toProtobufTimestamp(obs.time),
522
+ metric: obs.metric,
523
+ ...obs.feature ? { feature: obs.feature } : {},
524
+ variantValues: {}
525
+ };
526
+ observationGroups.set(groupKey, group);
527
+ }
528
+ group.variantValues[obs.variant] = obs.value;
529
+ }
530
+ this.observations = [];
531
+ this.droppedKeys = false;
532
+ const payload = {
533
+ appKey: this.appKey,
534
+ environment: this.environment,
535
+ time: toProtobufTimestamp(),
536
+ stats: this.drainMap(this.measures),
537
+ counters: this.drainMap(this.counters),
538
+ observations: [...observationGroups.values()]
539
+ };
540
+ if (this.instanceName) {
541
+ payload.instanceName = this.instanceName;
542
+ }
543
+ return payload;
544
+ }
545
+ restoreFromPayload(payload) {
546
+ for (const stat of payload.stats ?? []) {
547
+ for (const [variant, value] of Object.entries(stat.variantValues ?? {})) {
548
+ this.measure(stat.metric, value, { feature: stat.feature, variant });
549
+ }
550
+ }
551
+ for (const counter of payload.counters ?? []) {
552
+ for (const [variant, value] of Object.entries(counter.variantValues ?? {})) {
553
+ this.incrementCounter(counter.metric, value, {
554
+ feature: counter.feature,
555
+ variant
556
+ });
557
+ }
558
+ }
559
+ for (const obs of payload.observations ?? []) {
560
+ for (const [variant, value] of Object.entries(obs.variantValues ?? {})) {
561
+ this.observe(obs.metric, value, { feature: obs.feature, variant });
562
+ }
563
+ }
564
+ }
565
+ };
566
+
567
+ // src/core/sdk-identity.ts
568
+ var SDK_ID = "react-router";
569
+ var SDK_VERSION = "1.0.0";
570
+ function sdkUserAgent() {
571
+ return `toggly-${SDK_ID}/${SDK_VERSION}`;
572
+ }
573
+
574
+ // src/core/telemetry/https-client.ts
575
+ var DEFAULT_METRICS_BASE_URL = "https://app.toggly.io/";
576
+ var DEFAULT_TELEMETRY_FLUSH_MS = 6e4;
577
+ function ensureTrailingSlash(baseUrl) {
578
+ return baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`;
579
+ }
580
+ function usagePayloadToHttpJson(payload) {
581
+ return {
582
+ ...payload,
583
+ time: protobufTimestampToIso(payload.time),
584
+ ...payload.processStartTime ? { processStartTime: protobufTimestampToIso(payload.processStartTime) } : {}
585
+ };
586
+ }
587
+ function metricsPayloadToHttpJson(payload) {
588
+ return {
589
+ ...payload,
590
+ time: protobufTimestampToIso(payload.time),
591
+ observations: (payload.observations ?? []).map((obs) => ({
592
+ ...obs,
593
+ time: protobufTimestampToIso(obs.time)
594
+ }))
595
+ };
596
+ }
597
+ var HttpsTelemetryClient = class {
598
+ constructor(options = {}) {
599
+ this.baseUrl = ensureTrailingSlash(options.metricsBaseUrl ?? DEFAULT_METRICS_BASE_URL);
600
+ this.userAgent = options.userAgent ?? sdkUserAgent();
601
+ this.fetchImpl = options.fetchImpl ?? fetch.bind(globalThis);
602
+ }
603
+ getUserAgent() {
604
+ return this.userAgent;
605
+ }
606
+ getBaseUrl() {
607
+ return this.baseUrl;
608
+ }
609
+ /**
610
+ * POST JSON. Returns true on 2xx, false on soft-fail (network / non-2xx).
611
+ */
612
+ async post(path2, body) {
613
+ try {
614
+ const url = new URL(path2.replace(/^\//, ""), this.baseUrl).toString();
615
+ const response = await this.fetchImpl(url, {
616
+ method: "POST",
617
+ headers: {
618
+ "Content-Type": "application/json",
619
+ Accept: "application/json",
620
+ "User-Agent": this.userAgent
621
+ },
622
+ body: JSON.stringify(body)
623
+ });
624
+ return response.ok;
625
+ } catch {
626
+ return false;
627
+ }
628
+ }
629
+ sendUsageStats(payload) {
630
+ return this.post("api/usage/stats", usagePayloadToHttpJson(payload));
631
+ }
632
+ sendMetrics(payload) {
633
+ return this.post("api/metrics", metricsPayloadToHttpJson(payload));
634
+ }
635
+ };
636
+ function resolveMetricsBaseUrl(raw) {
637
+ const trimmed = (raw ?? "").trim();
638
+ if (!trimmed) {
639
+ return DEFAULT_METRICS_BASE_URL;
640
+ }
641
+ return ensureTrailingSlash(trimmed);
642
+ }
643
+ function isTelemetryEnvDisabled() {
644
+ try {
645
+ return typeof process !== "undefined" && process.env?.TOGGLY_DISABLE_TELEMETRY === "1";
646
+ } catch {
647
+ return false;
648
+ }
649
+ }
650
+ function resolveTelemetryEnableFlag(explicit, defaultWhenUnset) {
651
+ if (isTelemetryEnvDisabled()) {
652
+ return false;
653
+ }
654
+ return explicit ?? defaultWhenUnset;
655
+ }
656
+
657
+ // src/core/telemetry/index.ts
658
+ var _TelemetryRuntime = class _TelemetryRuntime {
659
+ constructor(config, logger) {
660
+ this.usageBatcher = null;
661
+ this.metricsBatcher = null;
662
+ this.usageClient = null;
663
+ this.metricsClient = null;
664
+ this.httpsClient = null;
665
+ this.usageTimer = null;
666
+ this.metricsTimer = null;
667
+ /** Single-flight drain promise (CF Worker TelemetryRuntime pattern). */
668
+ this.flushInFlight = null;
669
+ /** Set when flush/close is requested during an in-flight drain. */
670
+ this.pendingDrain = false;
671
+ this.closed = false;
672
+ this.processStartTime = /* @__PURE__ */ new Date();
673
+ this.signalHandlers = [];
674
+ const hasAppKey = Boolean(config.appKey);
675
+ this.transport = config.transport ?? "grpc";
676
+ this.attachProcessHandlers = config.attachProcessHandlers ?? this.transport === "grpc";
677
+ this.restoreOnSendFailure = config.restoreOnSendFailure ?? this.transport === "https";
678
+ this.config = {
679
+ appKey: config.appKey,
680
+ environment: config.environment,
681
+ metricsBaseUrl: resolveMetricsBaseUrl(config.metricsBaseUrl ?? DEFAULT_METRICS_BASE_URL),
682
+ // TOGGLY_DISABLE_TELEMETRY=1 wins over explicit true.
683
+ enableUsageTracking: resolveTelemetryEnableFlag(
684
+ config.enableUsageTracking,
685
+ hasAppKey
686
+ ),
687
+ enableMetrics: resolveTelemetryEnableFlag(config.enableMetrics, hasAppKey),
688
+ usageFlushInterval: config.usageFlushInterval ?? DEFAULT_TELEMETRY_FLUSH_MS,
689
+ metricsFlushInterval: config.metricsFlushInterval ?? DEFAULT_TELEMETRY_FLUSH_MS,
690
+ instanceName: config.instanceName,
691
+ appVersion: config.appVersion,
692
+ usageClient: config.usageClient,
693
+ metricsClient: config.metricsClient,
694
+ fetchImpl: config.fetchImpl
695
+ };
696
+ this.logger = logger ?? {
697
+ debug: () => {
698
+ },
699
+ warn: (...args) => console.warn("[Toggly]", ...args),
700
+ error: (...args) => console.error("[Toggly]", ...args)
701
+ };
702
+ }
703
+ get usageEnabled() {
704
+ return this.config.enableUsageTracking && !this.closed;
705
+ }
706
+ get metricsEnabled() {
707
+ return this.config.enableMetrics && !this.closed;
708
+ }
709
+ start() {
710
+ if (this.closed) return;
711
+ if (!this.config.enableUsageTracking && !this.config.enableMetrics) {
712
+ return;
713
+ }
714
+ if (this.config.usageClient !== void 0 || this.config.metricsClient !== void 0) {
715
+ this.usageClient = this.config.usageClient ?? null;
716
+ this.metricsClient = this.config.metricsClient ?? null;
717
+ } else if (this.transport === "https") {
718
+ this.httpsClient = new HttpsTelemetryClient({
719
+ metricsBaseUrl: this.config.metricsBaseUrl,
720
+ fetchImpl: this.config.fetchImpl
721
+ });
722
+ this.usageClient = {
723
+ sendStats: async (request) => {
724
+ const ok = await this.httpsClient.sendUsageStats(
725
+ request
726
+ );
727
+ return { ok };
728
+ }
729
+ };
730
+ this.metricsClient = {
731
+ sendMetrics: async (request) => {
732
+ const ok = await this.httpsClient.sendMetrics(
733
+ request
734
+ );
735
+ return { ok };
736
+ }
737
+ };
738
+ } else {
739
+ this.logger.warn(
740
+ "Usage/metrics enabled with gRPC transport but no clients were injected. Pass usageClient/metricsClient from createGrpcClients (optional @grpc/grpc-js)."
741
+ );
742
+ }
743
+ if (this.config.enableUsageTracking) {
744
+ this.usageBatcher = new UsageBatcher({
745
+ appKey: this.config.appKey,
746
+ environment: this.config.environment,
747
+ instanceName: this.config.instanceName,
748
+ appVersion: this.config.appVersion,
749
+ processStartTime: this.processStartTime
750
+ });
751
+ if (this.config.usageFlushInterval > 0) {
752
+ this.usageTimer = setInterval(() => {
753
+ void this.flush();
754
+ }, this.config.usageFlushInterval);
755
+ this.usageTimer.unref?.();
756
+ }
757
+ }
758
+ if (this.config.enableMetrics) {
759
+ this.metricsBatcher = new MetricsBatcher({
760
+ appKey: this.config.appKey,
761
+ environment: this.config.environment,
762
+ instanceName: this.config.instanceName
763
+ });
764
+ if (this.config.metricsFlushInterval > 0) {
765
+ this.metricsTimer = setInterval(() => {
766
+ void this.flush();
767
+ }, this.config.metricsFlushInterval);
768
+ this.metricsTimer.unref?.();
769
+ }
770
+ }
771
+ if (this.attachProcessHandlers) {
772
+ this.attachHandlers();
773
+ }
774
+ }
775
+ /* istanbul ignore next -- process signal exit paths; covered by attach/detach tests */
776
+ attachHandlers() {
777
+ if (typeof process === "undefined" || typeof process.on !== "function") {
778
+ return;
779
+ }
780
+ const flush = () => {
781
+ void this.flushAll();
782
+ };
783
+ process.on("beforeExit", flush);
784
+ this.signalHandlers.push({ event: "beforeExit", handler: flush });
785
+ for (const signal of ["SIGTERM", "SIGINT"]) {
786
+ try {
787
+ const onSignal = () => {
788
+ void this.handleProcessSignal(signal);
789
+ };
790
+ process.on(signal, onSignal);
791
+ this.signalHandlers.push({ event: signal, handler: onSignal });
792
+ } catch {
793
+ }
794
+ }
795
+ }
796
+ /* istanbul ignore next -- process.kill / process.exit on SIGTERM/SIGINT */
797
+ async handleProcessSignal(signal) {
798
+ try {
799
+ await Promise.race([
800
+ this.close(),
801
+ new Promise((resolve) => {
802
+ const timer = setTimeout(resolve, _TelemetryRuntime.SIGNAL_FLUSH_TIMEOUT_MS);
803
+ timer.unref?.();
804
+ })
805
+ ]);
806
+ } catch {
807
+ } finally {
808
+ this.detachProcessHandlers();
809
+ this.reemitSignalAndExit(signal);
810
+ }
811
+ }
812
+ /* istanbul ignore next -- process.kill / process.exit on SIGTERM/SIGINT */
813
+ reemitSignalAndExit(signal) {
814
+ try {
815
+ process.kill(process.pid, signal);
816
+ } catch {
817
+ const code = signal === "SIGINT" ? 130 : signal === "SIGTERM" ? 143 : 0;
818
+ process.exit(code);
819
+ }
820
+ }
821
+ detachProcessHandlers() {
822
+ if (typeof process === "undefined" || typeof process.off !== "function") {
823
+ return;
824
+ }
825
+ for (const { event, handler } of this.signalHandlers) {
826
+ process.off(event, handler);
827
+ }
828
+ this.signalHandlers.length = 0;
829
+ }
830
+ recordCheck(feature, enabled, identity, variant, uniqueRequest = false) {
831
+ this.usageBatcher?.recordCheck(feature, enabled, identity, variant, uniqueRequest);
832
+ }
833
+ recordUsage(feature, identity, variant) {
834
+ this.usageBatcher?.recordUsage(feature, identity, variant);
835
+ }
836
+ recordView(feature, identity, variant) {
837
+ this.usageBatcher?.recordView(feature, identity, variant);
838
+ }
839
+ recordDefinitionCacheHit() {
840
+ this.usageBatcher?.recordDefinitionCacheHit();
841
+ }
842
+ recordDefinitionCacheMiss() {
843
+ this.usageBatcher?.recordDefinitionCacheMiss();
844
+ }
845
+ measure(metric, value, options) {
846
+ this.metricsBatcher?.measure(metric, value, options);
847
+ }
848
+ incrementCounter(metric, value = 1, options) {
849
+ this.metricsBatcher?.incrementCounter(metric, value, options);
850
+ }
851
+ observe(metric, value, options) {
852
+ this.metricsBatcher?.observe(metric, value, options);
853
+ }
854
+ shouldFlushForCaps() {
855
+ return Boolean(this.usageBatcher?.hitFeatureCap() || this.metricsBatcher?.hitCap());
856
+ }
857
+ /**
858
+ * Single-flight flush with pending-drain follow-up (CF Worker parity).
859
+ * Concurrent flush/close during an active send coalesces onto one drain and
860
+ * runs another pass so batches recorded mid-send are not stranded.
861
+ */
862
+ async flush() {
863
+ this.pendingDrain = true;
864
+ if (this.flushInFlight) {
865
+ return this.flushInFlight;
866
+ }
867
+ this.flushInFlight = this.drainUntilIdle();
868
+ return this.flushInFlight;
869
+ }
870
+ async flushAll() {
871
+ await this.flush();
872
+ }
873
+ /** @deprecated Prefer {@link flush}; kept for callers that split usage/metrics. */
874
+ async flushUsage() {
875
+ await this.flush();
876
+ }
877
+ /** @deprecated Prefer {@link flush}; kept for callers that split usage/metrics. */
878
+ async flushMetrics() {
879
+ await this.flush();
880
+ }
881
+ async drainUntilIdle() {
882
+ try {
883
+ while (this.pendingDrain) {
884
+ this.pendingDrain = false;
885
+ await this.flushInternal();
886
+ }
887
+ } finally {
888
+ this.flushInFlight = null;
889
+ if (this.pendingDrain) {
890
+ await this.flush();
891
+ }
892
+ }
893
+ }
894
+ async flushInternal() {
895
+ await Promise.all([this.sendUsageOnce(), this.sendMetricsOnce()]);
896
+ }
897
+ async sendUsageOnce() {
898
+ if (!this.usageBatcher) return;
899
+ const client = this.usageClient;
900
+ if (!client?.sendStats) {
901
+ this.logger.debug("Usage flush skipped: no usage client");
902
+ return;
903
+ }
904
+ const bundle = this.usageBatcher.buildAndReset();
905
+ if (!bundle) return;
906
+ try {
907
+ const result = await client.sendStats(bundle.payload);
908
+ if (this.restoreOnSendFailure && result && typeof result === "object" && "ok" in result && result.ok === false) {
909
+ this.usageBatcher.restoreFromBundle(bundle);
910
+ this.logger.debug("Usage HTTPS soft-fail; batch restored");
911
+ }
912
+ } catch (error) {
913
+ if (this.restoreOnSendFailure) {
914
+ this.usageBatcher.restoreFromBundle(bundle);
915
+ }
916
+ this.logger.error("Failed to send usage stats:", error);
917
+ }
918
+ }
919
+ async sendMetricsOnce() {
920
+ if (!this.metricsBatcher) return;
921
+ const client = this.metricsClient;
922
+ if (!client?.sendMetrics) {
923
+ this.logger.debug("Metrics flush skipped: no metrics client");
924
+ return;
925
+ }
926
+ const payload = this.metricsBatcher.buildAndReset();
927
+ if (!payload) return;
928
+ try {
929
+ const result = await client.sendMetrics(payload);
930
+ if (this.restoreOnSendFailure && result && typeof result === "object" && "ok" in result && result.ok === false) {
931
+ this.metricsBatcher.restoreFromPayload(payload);
932
+ this.logger.debug("Metrics HTTPS soft-fail; batch restored");
933
+ }
934
+ } catch (error) {
935
+ if (this.restoreOnSendFailure) {
936
+ this.metricsBatcher.restoreFromPayload(payload);
937
+ }
938
+ this.logger.error("Failed to send metrics:", error);
939
+ }
940
+ }
941
+ async close() {
942
+ if (this.closed) return;
943
+ this.closed = true;
944
+ if (this.usageTimer) {
945
+ clearInterval(this.usageTimer);
946
+ this.usageTimer = null;
947
+ }
948
+ if (this.metricsTimer) {
949
+ clearInterval(this.metricsTimer);
950
+ this.metricsTimer = null;
951
+ }
952
+ this.detachProcessHandlers();
953
+ try {
954
+ await this.flush();
955
+ } finally {
956
+ try {
957
+ this.usageClient?.close?.();
958
+ } catch {
959
+ }
960
+ try {
961
+ this.metricsClient?.close?.();
962
+ } catch {
963
+ }
964
+ this.usageClient = null;
965
+ this.metricsClient = null;
966
+ this.httpsClient = null;
967
+ this.usageBatcher = null;
968
+ this.metricsBatcher = null;
969
+ }
970
+ }
971
+ };
972
+ /** Best-effort flush budget before re-emitting the signal so Node can exit. */
973
+ _TelemetryRuntime.SIGNAL_FLUSH_TIMEOUT_MS = 2e3;
974
+ var TelemetryRuntime = _TelemetryRuntime;
975
+ var ambientStore = new async_hooks.AsyncLocalStorage();
976
+ function snapshot(ctx) {
977
+ return {
978
+ ...ctx.identity !== void 0 ? { identity: ctx.identity } : {},
979
+ ...ctx.groups !== void 0 ? { groups: ctx.groups } : {},
980
+ ...ctx.claims !== void 0 ? { claims: ctx.claims } : {},
981
+ ...ctx.traits !== void 0 ? { traits: ctx.traits } : {},
982
+ ...ctx.request !== void 0 ? { request: ctx.request } : {}
983
+ };
984
+ }
985
+ function getAmbientEvalOverrides() {
986
+ const store = ambientStore.getStore();
987
+ return store ? snapshot(store) : void 0;
988
+ }
989
+ function runWithEvalContext(ctx, fn) {
990
+ return ambientStore.run(snapshot(ctx), fn);
991
+ }
992
+ function mergeIdentityContext(ambient, perCall) {
993
+ if (ambient == null && perCall == null) {
994
+ return void 0;
995
+ }
996
+ if (ambient == null) {
997
+ return perCall ? snapshot(perCall) : void 0;
998
+ }
999
+ if (perCall == null) {
1000
+ return snapshot(ambient);
1001
+ }
1002
+ return {
1003
+ identity: perCall.identity ?? ambient.identity,
1004
+ groups: perCall.groups ?? ambient.groups,
1005
+ claims: perCall.claims ?? ambient.claims,
1006
+ traits: perCall.traits ?? ambient.traits,
1007
+ request: perCall.request ?? ambient.request
1008
+ };
1009
+ }
1010
+
1011
+ // src/server/extract-context.ts
1012
+ function headersToBag(headers) {
1013
+ const bag = {};
1014
+ headers.forEach((value, key) => {
1015
+ bag[key] = value;
1016
+ });
1017
+ return bag;
1018
+ }
1019
+ function getIdentityFromRequest(request, customCookieParser) {
1020
+ const headerIdentity = request.headers.get(HEADERS.IDENTITY);
1021
+ if (headerIdentity) {
1022
+ return parseIdentity(headerIdentity);
1023
+ }
1024
+ const cookies = request.headers.get("cookie");
1025
+ if (customCookieParser) {
1026
+ return customCookieParser(cookies);
1027
+ }
1028
+ if (cookies) {
1029
+ const identity = parseCookie(cookies, STORAGE_KEYS.IDENTITY);
1030
+ if (identity) {
1031
+ return parseIdentity(identity);
1032
+ }
1033
+ }
1034
+ return void 0;
1035
+ }
1036
+ function parseCookie(cookies, name) {
1037
+ const pairs = cookies.split(";");
1038
+ for (const pair of pairs) {
1039
+ const [key, value] = pair.trim().split("=");
1040
+ if (key === name && value) {
1041
+ try {
1042
+ return decodeURIComponent(value);
1043
+ } catch {
1044
+ return value;
1045
+ }
1046
+ }
1047
+ }
1048
+ return void 0;
1049
+ }
1050
+ async function extractEvalContext(request, providers = {}) {
1051
+ const headers = headersToBag(request.headers);
1052
+ const headerRequest = togglyEval.fromHttpRequest(headers).request;
1053
+ if (providers.getContext) {
1054
+ const custom = await providers.getContext(request);
1055
+ return {
1056
+ identity: custom.identity,
1057
+ groups: custom.groups,
1058
+ // Prefer claims for UserClaims; ignore traits-as-claims from custom
1059
+ claims: custom.claims,
1060
+ traits: custom.traits,
1061
+ request: {
1062
+ ...headerRequest,
1063
+ ...custom.request
1064
+ }
1065
+ };
1066
+ }
1067
+ let identity;
1068
+ if (providers.getIdentity) {
1069
+ identity = await providers.getIdentity(request);
1070
+ } else {
1071
+ identity = getIdentityFromRequest(
1072
+ request,
1073
+ providers.getIdentityFromCookies
1074
+ );
1075
+ }
1076
+ const groups = providers.getGroups ? await providers.getGroups(request) : void 0;
1077
+ const claims = providers.getClaims ? await providers.getClaims(request) : void 0;
1078
+ const fromReq = togglyEval.fromHttpRequest(headers, { identity, groups, claims });
1079
+ return {
1080
+ identity: fromReq.identity,
1081
+ groups: fromReq.groups,
1082
+ claims: fromReq.claims,
1083
+ request: fromReq.request
1084
+ };
1085
+ }
1086
+
1087
+ // src/server/sdk-identity.ts
1088
+ var SDK_ID2 = "react-router";
1089
+ var SDK_VERSION2 = "1.0.0";
1090
+ var SDK_HEADER_ID = "X-Toggly-Sdk";
1091
+ var SDK_HEADER_VERSION = "X-Toggly-Sdk-Version";
1092
+ function sdkUserAgent2() {
1093
+ return `toggly-${SDK_ID2}/${SDK_VERSION2}`;
1094
+ }
1095
+ function sdkCustomHeaders() {
1096
+ return {
1097
+ [SDK_HEADER_ID]: SDK_ID2,
1098
+ [SDK_HEADER_VERSION]: SDK_VERSION2
1099
+ };
1100
+ }
1101
+ function appendSdkQueryParams(params) {
1102
+ params.set("sdk", SDK_ID2);
1103
+ params.set("sdkVersion", SDK_VERSION2);
1104
+ }
1105
+ function usesSdkCustomHeaders() {
1106
+ const g = globalThis;
1107
+ if (g.window !== void 0 && g.document !== void 0) {
1108
+ return true;
1109
+ }
1110
+ if (g.navigator?.product === "ReactNative") {
1111
+ return true;
1112
+ }
1113
+ return false;
1114
+ }
1115
+ function buildDefinitionFetchHeaders(existing = {}) {
1116
+ const headers = { ...existing };
1117
+ if (usesSdkCustomHeaders()) {
1118
+ Object.assign(headers, sdkCustomHeaders());
1119
+ } else {
1120
+ headers["User-Agent"] = sdkUserAgent2();
1121
+ }
1122
+ return headers;
1123
+ }
1124
+
1125
+ // src/server/ws-sync.ts
1126
+ var DEFINITIONS_REVISION_HEADER = "X-Definitions-Revision";
1127
+ var WS_RECONNECT_BASE_MS = 5e3;
1128
+ var WS_RECONNECT_MAX_MS = 6e4;
1129
+ var REFRESH_DEBOUNCE_MS = 300;
1130
+ function buildWebSocketUrl(baseUri, appKey, cachedEtag) {
1131
+ const wsBase = baseUri.replace(/^https:\/\//, "wss://").replace(/^http:\/\//, "ws://").replace(/\/$/, "");
1132
+ const params = new URLSearchParams();
1133
+ if (cachedEtag) {
1134
+ params.set("rev", cachedEtag);
1135
+ }
1136
+ appendSdkQueryParams(params);
1137
+ const query = params.toString();
1138
+ return `${wsBase}/${appKey}/ws${query ? `?${query}` : ""}`;
1139
+ }
1140
+ function getNextReconnectDelayMs(attempt) {
1141
+ return Math.min(WS_RECONNECT_BASE_MS * Math.pow(2, attempt), WS_RECONNECT_MAX_MS);
1142
+ }
1143
+ function shouldFetchOnSync(message, cachedEtag) {
1144
+ if (message.type !== "sync") {
1145
+ return false;
1146
+ }
1147
+ if (message.unchanged === true) {
1148
+ return false;
1149
+ }
1150
+ if (!cachedEtag) {
1151
+ return true;
1152
+ }
1153
+ if (message.etag && message.etag !== cachedEtag) {
1154
+ return true;
1155
+ }
1156
+ return false;
1157
+ }
1158
+ function shouldFetchOnFlagsUpdated(message, cachedEtag) {
1159
+ if (message.type !== "flags-updated" && message.type !== "update") {
1160
+ return false;
1161
+ }
1162
+ if (!message.etag || !cachedEtag) {
1163
+ return true;
1164
+ }
1165
+ return message.etag !== cachedEtag;
1166
+ }
1167
+ function shouldFetchOnSigningKeyUpdated(message) {
1168
+ return message.type === "signing-key-updated";
1169
+ }
1170
+ function planFlagsUpdatedRefresh(message, previousRevision) {
1171
+ if (shouldFetchOnSigningKeyUpdated(message)) {
1172
+ return { action: "refresh-jwks" };
1173
+ }
1174
+ if (shouldFetchOnFlagsUpdated(message, previousRevision)) {
1175
+ return { action: "refresh-pinned", pin: message.etag ?? null };
1176
+ }
1177
+ return { action: "none" };
1178
+ }
1179
+ function applyFlagsUpdatedPlan(plan, message, hooks) {
1180
+ if (plan.action === "refresh-jwks") {
1181
+ hooks.refreshJwks();
1182
+ return;
1183
+ }
1184
+ if (plan.action === "refresh-pinned") {
1185
+ hooks.refreshPinned(plan.pin);
1186
+ return;
1187
+ }
1188
+ if (message.etag) {
1189
+ hooks.cacheEtagIfPresent(message.etag);
1190
+ }
1191
+ }
1192
+ function extractDefinitionsRevision(response) {
1193
+ if (!response.headers?.get) {
1194
+ return null;
1195
+ }
1196
+ return response.headers.get(DEFINITIONS_REVISION_HEADER) ?? response.headers.get("ETag");
1197
+ }
1198
+ function appendDefinitionsRevisionParam(url, rev) {
1199
+ if (!rev) {
1200
+ return url;
1201
+ }
1202
+ try {
1203
+ const parsed = new URL(url);
1204
+ parsed.searchParams.set("rev", rev);
1205
+ return parsed.toString();
1206
+ } catch {
1207
+ const separator = url.includes("?") ? "&" : "?";
1208
+ return `${url}${separator}rev=${encodeURIComponent(rev)}`;
1209
+ }
1210
+ }
1211
+ var thisModuleUrl = (typeof document === 'undefined' ? require('u' + 'rl').pathToFileURL(__filename).href : (_documentCurrentScript && _documentCurrentScript.tagName.toUpperCase() === 'SCRIPT' && _documentCurrentScript.src || new URL('server.cjs', document.baseURI).href));
1212
+ var nodeRequire = module$1.createRequire(thisModuleUrl);
1213
+ function resolveProtoRoot(moduleUrl = thisModuleUrl) {
1214
+ const moduleDir = path__default.default.dirname(url.fileURLToPath(moduleUrl));
1215
+ const candidates = [
1216
+ // Built dist/cjs|esm/telemetry/grpc.js → packageRoot/proto
1217
+ path__default.default.resolve(moduleDir, "..", "..", "..", "proto"),
1218
+ // Built dist/telemetry/grpc.js → packageRoot/proto
1219
+ path__default.default.resolve(moduleDir, "..", "..", "proto"),
1220
+ // Built dist/index.js sibling layouts
1221
+ path__default.default.resolve(moduleDir, "..", "proto"),
1222
+ // Source src/telemetry/*.ts → packageRoot/proto
1223
+ path__default.default.resolve(moduleDir, "..", "..", "proto"),
1224
+ path__default.default.resolve(moduleDir, "proto")
1225
+ ];
1226
+ for (const candidate of candidates) {
1227
+ if (fs__default.default.existsSync(path__default.default.join(candidate, "usage.proto"))) {
1228
+ return candidate;
1229
+ }
1230
+ }
1231
+ return candidates[0] ?? path__default.default.resolve(moduleDir, "..", "..", "proto");
1232
+ }
1233
+ var protoRoot = resolveProtoRoot();
1234
+ function grpcTarget(baseUrl) {
1235
+ try {
1236
+ const u = new URL(baseUrl.includes("://") ? baseUrl : `https://${baseUrl}`);
1237
+ let host = u.host;
1238
+ if (!host.includes(":")) {
1239
+ host = `${host}:443`;
1240
+ }
1241
+ return host;
1242
+ } catch {
1243
+ const trimmed = baseUrl.replace(/^https?:\/\//, "").replace(/\/$/, "");
1244
+ return trimmed.includes(":") ? trimmed : `${trimmed}:443`;
1245
+ }
1246
+ }
1247
+ function resolveUserAgent(override) {
1248
+ return sdkUserAgent();
1249
+ }
1250
+ function tryLoadGrpcModules() {
1251
+ try {
1252
+ const grpc = nodeRequire("@grpc/grpc-js");
1253
+ const protoLoader = nodeRequire("@grpc/proto-loader");
1254
+ return { grpc, protoLoader };
1255
+ } catch {
1256
+ return null;
1257
+ }
1258
+ }
1259
+ function loadPackageDefinition(protoLoader, grpc, protoFile) {
1260
+ const packageDefinition = protoLoader.loadSync(path__default.default.join(protoRoot, protoFile), {
1261
+ keepCase: true,
1262
+ longs: Number,
1263
+ enums: String,
1264
+ defaults: true,
1265
+ oneofs: true,
1266
+ includeDirs: [protoRoot]
1267
+ });
1268
+ return grpc.loadPackageDefinition(packageDefinition);
1269
+ }
1270
+ function createMetadata(grpc, map) {
1271
+ const metadata = new grpc.Metadata();
1272
+ if (map) {
1273
+ for (const [key, value] of Object.entries(map)) {
1274
+ metadata.set(key, value);
1275
+ }
1276
+ }
1277
+ return metadata;
1278
+ }
1279
+ function createGrpcClients(metricsBaseUrl = DEFAULT_METRICS_BASE_URL, userAgent) {
1280
+ const modules = tryLoadGrpcModules();
1281
+ if (!modules) {
1282
+ return null;
1283
+ }
1284
+ const { grpc, protoLoader } = modules;
1285
+ const target = grpcTarget(metricsBaseUrl);
1286
+ const credentials = grpc.credentials.createSsl();
1287
+ const ua = resolveUserAgent();
1288
+ const defaultMeta = { UA: ua };
1289
+ const usageDef = loadPackageDefinition(protoLoader, grpc, "usage.proto");
1290
+ const metricsDef = loadPackageDefinition(protoLoader, grpc, "metrics.proto");
1291
+ const UsageService = usageDef.Usage.Usage;
1292
+ const MetricsService = metricsDef.Metrics.Metrics;
1293
+ const usageStub = new UsageService(target, credentials);
1294
+ const metricsStub = new MetricsService(target, credentials);
1295
+ return {
1296
+ usage: {
1297
+ sendStats(request, metadata) {
1298
+ return new Promise((resolve, reject) => {
1299
+ usageStub.SendStats(
1300
+ request,
1301
+ createMetadata(grpc, { ...defaultMeta, ...metadata }),
1302
+ (err, res) => {
1303
+ if (err) reject(err);
1304
+ else resolve(res ?? {});
1305
+ }
1306
+ );
1307
+ });
1308
+ },
1309
+ close() {
1310
+ usageStub.close();
1311
+ }
1312
+ },
1313
+ metrics: {
1314
+ sendMetrics(request, metadata) {
1315
+ return new Promise((resolve, reject) => {
1316
+ metricsStub.SendMetrics(
1317
+ request,
1318
+ createMetadata(grpc, { ...defaultMeta, ...metadata }),
1319
+ (err, res) => {
1320
+ if (err) reject(err);
1321
+ else resolve(res ?? {});
1322
+ }
1323
+ );
1324
+ });
1325
+ },
1326
+ close() {
1327
+ metricsStub.close();
1328
+ }
1329
+ }
1330
+ };
1331
+ }
1332
+ function isGrpcAvailable() {
1333
+ return tryLoadGrpcModules() !== null;
1334
+ }
1335
+
1336
+ // src/server/client.ts
1337
+ function resolveServerTelemetryFlags(config) {
1338
+ const hasAppKey = Boolean(config.appKey);
1339
+ return {
1340
+ enableUsageTracking: resolveTelemetryEnableFlag(
1341
+ config.enableUsageTracking,
1342
+ hasAppKey
1343
+ ),
1344
+ enableMetrics: resolveTelemetryEnableFlag(config.enableMetrics, hasAppKey)
1345
+ };
1346
+ }
1347
+ function resolveGrpcClients(config) {
1348
+ if (config.usageClient !== void 0 || config.metricsClient !== void 0) {
1349
+ return {
1350
+ usageClient: config.usageClient,
1351
+ metricsClient: config.metricsClient
1352
+ };
1353
+ }
1354
+ const flags = resolveServerTelemetryFlags(config);
1355
+ if (!flags.enableUsageTracking && !flags.enableMetrics) {
1356
+ return {};
1357
+ }
1358
+ const transport = config.telemetryTransport ?? "grpc";
1359
+ if (transport !== "grpc") {
1360
+ return {};
1361
+ }
1362
+ if (!isGrpcAvailable()) {
1363
+ console.warn(
1364
+ "[Toggly] Usage/metrics enabled but @grpc/grpc-js and @grpc/proto-loader are not installed. Install them to send telemetry: npm install @grpc/grpc-js @grpc/proto-loader"
1365
+ );
1366
+ return { usageClient: null, metricsClient: null };
1367
+ }
1368
+ const clients = createGrpcClients(config.metricsBaseUrl);
1369
+ if (!clients) {
1370
+ console.warn("[Toggly] Failed to create gRPC clients; telemetry transport disabled");
1371
+ return { usageClient: null, metricsClient: null };
1372
+ }
1373
+ return { usageClient: clients.usage, metricsClient: clients.metrics };
1374
+ }
1375
+ var TogglyServerClient = class {
1376
+ constructor(config) {
1377
+ /** Compatibility snapshot of locally evaluated booleans (hydration / afterRefresh). */
1378
+ this.flags = {};
1379
+ /** Raw definitions-signed models keyed by featureKey. */
1380
+ this.definitions = /* @__PURE__ */ new Map();
1381
+ this.hooks = [];
1382
+ this.initialized = false;
1383
+ /** Dedupes concurrent cold-start definition fetches. */
1384
+ this.definitionsLoadPromise = null;
1385
+ /** Single-flight for definition GET so concurrent joiners are not double-counted. */
1386
+ this.definitionsFetchInFlight = null;
1387
+ // WebSocket live updates
1388
+ this.ws = null;
1389
+ this.wsConnected = false;
1390
+ this.wsReconnectTimer = null;
1391
+ this.wsReconnectAttempt = 0;
1392
+ this.refreshDebounceTimer = null;
1393
+ this.cachedDefinitionsRevision = null;
1394
+ this.pendingDefinitionsPin = null;
1395
+ this.refreshTimer = null;
1396
+ this.localGates = [];
1397
+ this.localGateIndex = /* @__PURE__ */ new Map();
1398
+ this.localGatesListeners = /* @__PURE__ */ new Set();
1399
+ this.telemetry = null;
1400
+ const telemetryFlags = resolveServerTelemetryFlags(config);
1401
+ const grpcClients = resolveGrpcClients({ ...config, ...telemetryFlags });
1402
+ this.config = mergeConfig({
1403
+ ...config,
1404
+ ...telemetryFlags,
1405
+ ...grpcClients,
1406
+ evaluationMode: "local",
1407
+ telemetryTransport: config.telemetryTransport ?? "grpc",
1408
+ telemetryAttachProcessHandlers: config.telemetryAttachProcessHandlers ?? (config.telemetryTransport ?? "grpc") === "grpc"
1409
+ });
1410
+ this.logger = createLogger(this.config.debug ?? false);
1411
+ if (this.config.localGates) {
1412
+ this.setLocalGates(this.config.localGates);
1413
+ }
1414
+ if (!this.config.appKey && !this.config.featureDefaults) {
1415
+ this.logger.warn(
1416
+ "No appKey provided and no featureDefaults set. All features will be disabled."
1417
+ );
1418
+ }
1419
+ this.startTelemetry();
1420
+ }
1421
+ startTelemetry() {
1422
+ if (!this.config.appKey || isTelemetryEnvDisabled()) {
1423
+ return;
1424
+ }
1425
+ if (!this.config.enableUsageTracking && !this.config.enableMetrics) {
1426
+ return;
1427
+ }
1428
+ if (this.telemetry) {
1429
+ void this.telemetry.close();
1430
+ this.telemetry = null;
1431
+ }
1432
+ this.telemetry = new TelemetryRuntime({
1433
+ appKey: this.config.appKey,
1434
+ environment: this.config.environment ?? "Production",
1435
+ metricsBaseUrl: this.config.metricsBaseUrl,
1436
+ enableUsageTracking: this.config.enableUsageTracking,
1437
+ enableMetrics: this.config.enableMetrics,
1438
+ usageFlushInterval: this.config.usageFlushInterval,
1439
+ metricsFlushInterval: this.config.metricsFlushInterval,
1440
+ instanceName: this.config.instanceName,
1441
+ appVersion: this.config.appVersion,
1442
+ transport: this.config.telemetryTransport ?? "grpc",
1443
+ attachProcessHandlers: this.config.telemetryAttachProcessHandlers,
1444
+ usageClient: this.config.usageClient,
1445
+ metricsClient: this.config.metricsClient,
1446
+ fetchImpl: this.config.telemetryFetch
1447
+ });
1448
+ this.telemetry.start();
1449
+ }
1450
+ recordCheckForKey(featureKey, enabled, identityOverride) {
1451
+ if (!this.telemetry?.usageEnabled) {
1452
+ return;
1453
+ }
1454
+ const identity = identityOverride?.identity ?? this.identity;
1455
+ this.telemetry.recordCheck(featureKey, enabled, identity);
1456
+ }
1457
+ /** Soft-fail wrapper — definition refresh must not throw from telemetry. */
1458
+ noteDefinitionCacheHit() {
1459
+ try {
1460
+ this.telemetry?.recordDefinitionCacheHit();
1461
+ } catch {
1462
+ }
1463
+ }
1464
+ noteDefinitionCacheMiss() {
1465
+ try {
1466
+ this.telemetry?.recordDefinitionCacheMiss();
1467
+ } catch {
1468
+ }
1469
+ }
1470
+ normalizeDefinitionsRevision(revision) {
1471
+ if (!revision) {
1472
+ return null;
1473
+ }
1474
+ return revision.replace(/^"+|"+$/g, "");
1475
+ }
1476
+ definitionsRevisionsMatch(previous, incoming) {
1477
+ const a = this.normalizeDefinitionsRevision(previous);
1478
+ const b = this.normalizeDefinitionsRevision(incoming);
1479
+ if (!a || !b) {
1480
+ return false;
1481
+ }
1482
+ return a === b;
1483
+ }
1484
+ /**
1485
+ * Add a hook to the client
1486
+ */
1487
+ addHook(hook) {
1488
+ const metadata = hook.getMetadata();
1489
+ const exists = this.hooks.find((h) => h.getMetadata().name === metadata.name);
1490
+ if (exists) {
1491
+ this.logger.warn(`Hook "${metadata.name}" already registered. Skipping.`);
1492
+ return;
1493
+ }
1494
+ this.hooks.push(hook);
1495
+ this.logger.debug(`Hook "${metadata.name}" registered.`);
1496
+ }
1497
+ /**
1498
+ * Remove a hook by name
1499
+ */
1500
+ removeHook(name) {
1501
+ const index = this.hooks.findIndex((h) => h.getMetadata().name === name);
1502
+ if (index > -1) {
1503
+ this.hooks.splice(index, 1);
1504
+ this.logger.debug(`Hook "${name}" removed.`);
1505
+ return true;
1506
+ }
1507
+ return false;
1508
+ }
1509
+ /**
1510
+ * Register device-local post-filter gates
1511
+ */
1512
+ setLocalGates(gates) {
1513
+ this.localGates = [...gates];
1514
+ this.localGateIndex = togglyLocalGates.buildFlagGateIndex(this.localGates);
1515
+ }
1516
+ /**
1517
+ * Notify subscribers that local gate state changed (no network)
1518
+ */
1519
+ notifyLocalGatesChanged() {
1520
+ this.localGatesListeners.forEach((listener) => {
1521
+ try {
1522
+ listener();
1523
+ } catch (error) {
1524
+ this.logger.error("Local gate listener error:", error);
1525
+ }
1526
+ });
1527
+ }
1528
+ /**
1529
+ * Subscribe to local gate changes
1530
+ */
1531
+ subscribeLocalGatesChanged(listener) {
1532
+ this.localGatesListeners.add(listener);
1533
+ return () => {
1534
+ this.localGatesListeners.delete(listener);
1535
+ };
1536
+ }
1537
+ buildEvalContext(identityOverride, entityContext) {
1538
+ const merged = mergeIdentityContext(
1539
+ getAmbientEvalOverrides(),
1540
+ identityOverride
1541
+ );
1542
+ if (merged) {
1543
+ const claims2 = merged.claims ?? this.config.claims;
1544
+ return {
1545
+ identity: merged.identity,
1546
+ groups: merged.groups ?? this.config.groups,
1547
+ traits: merged.traits ?? this.config.claims,
1548
+ claims: claims2,
1549
+ request: merged.request,
1550
+ entity: entityContext ?? null
1551
+ };
1552
+ }
1553
+ const claims = this.config.claims;
1554
+ return {
1555
+ identity: this.identity,
1556
+ groups: this.config.groups,
1557
+ traits: this.config.claims,
1558
+ claims,
1559
+ entity: entityContext ?? null
1560
+ };
1561
+ }
1562
+ /**
1563
+ * Evaluate a boolean snapshot for a specific identity without relying on
1564
+ * shared mutable `this.flags` / `this.identity` (safe under concurrency).
1565
+ */
1566
+ snapshotFlags(identityOverride) {
1567
+ return {
1568
+ ...this.config.featureDefaults ?? {},
1569
+ ...togglyEval.snapshotEvaluatedBooleans(
1570
+ this.definitions,
1571
+ this.buildEvalContext(identityOverride)
1572
+ )
1573
+ };
1574
+ }
1575
+ getEffectiveFlag(featureKey, defaultValue = false, entityContext, identityOverride) {
1576
+ const evaluated = isFeatureEnabledLocal(
1577
+ this.definitions,
1578
+ featureKey,
1579
+ this.buildEvalContext(identityOverride, entityContext),
1580
+ this.config.featureDefaults?.[featureKey] ?? defaultValue
1581
+ );
1582
+ return togglyLocalGates.applyLocalGate(evaluated, featureKey, this.localGates, this.localGateIndex);
1583
+ }
1584
+ getDefinitionsRevision() {
1585
+ return this.cachedDefinitionsRevision;
1586
+ }
1587
+ cacheDefinitionsRevision(revision) {
1588
+ const normalized = this.normalizeDefinitionsRevision(revision);
1589
+ if (!normalized) {
1590
+ return;
1591
+ }
1592
+ this.cachedDefinitionsRevision = normalized;
1593
+ }
1594
+ scheduleDebouncedRefresh(forceRevisionReset = false) {
1595
+ if (this.refreshDebounceTimer) {
1596
+ clearTimeout(this.refreshDebounceTimer);
1597
+ }
1598
+ this.refreshDebounceTimer = setTimeout(() => {
1599
+ this.refreshDebounceTimer = null;
1600
+ if (forceRevisionReset) {
1601
+ this.cachedDefinitionsRevision = null;
1602
+ }
1603
+ void this.fetchFlags(this.identity);
1604
+ }, REFRESH_DEBOUNCE_MS);
1605
+ }
1606
+ handleWsSyncMessage(message) {
1607
+ const previousRevision = this.getDefinitionsRevision();
1608
+ if (shouldFetchOnSync(message, previousRevision)) {
1609
+ this.scheduleDebouncedRefresh();
1610
+ return;
1611
+ }
1612
+ this.cacheDefinitionsRevision(message.etag);
1613
+ }
1614
+ handleWsUpdateMessage(message) {
1615
+ applyFlagsUpdatedPlan(
1616
+ planFlagsUpdatedRefresh(message, this.getDefinitionsRevision()),
1617
+ message,
1618
+ {
1619
+ refreshJwks: () => this.scheduleDebouncedRefresh(true),
1620
+ refreshPinned: (pin) => {
1621
+ this.pendingDefinitionsPin = pin;
1622
+ this.cachedDefinitionsRevision = null;
1623
+ this.scheduleDebouncedRefresh();
1624
+ },
1625
+ cacheEtagIfPresent: (etag) => this.cacheDefinitionsRevision(etag)
1626
+ }
1627
+ );
1628
+ }
1629
+ evaluateGateEffective(featureKeys, requirement = "all", negate = false, defaultValue = false, entityContext, identityOverride) {
1630
+ if (featureKeys.length === 0) {
1631
+ return !negate;
1632
+ }
1633
+ const checks = featureKeys.map((key) => {
1634
+ const enabled = this.getEffectiveFlag(
1635
+ key,
1636
+ defaultValue,
1637
+ entityContext,
1638
+ identityOverride
1639
+ );
1640
+ this.recordCheckForKey(key, enabled, identityOverride);
1641
+ return enabled;
1642
+ });
1643
+ let result;
1644
+ if (requirement === "any") {
1645
+ result = checks.some(Boolean);
1646
+ } else {
1647
+ result = checks.every(Boolean);
1648
+ }
1649
+ return negate ? !result : result;
1650
+ }
1651
+ /**
1652
+ * Whether the WebSocket connection is active
1653
+ */
1654
+ get isWsConnected() {
1655
+ return this.wsConnected;
1656
+ }
1657
+ /**
1658
+ * Initialize the client by fetching feature definitions.
1659
+ * When already initialized, rebinds identity (including clearing it) and
1660
+ * re-snapshots flags without re-fetching (definitions are identity-agnostic).
1661
+ * Concurrent cold starts share one fetch; each caller still receives a
1662
+ * request-local snapshot for its identity.
1663
+ */
1664
+ async init(identity) {
1665
+ const wasWarm = this.initialized && this.definitions.size > 0;
1666
+ if (!wasWarm) {
1667
+ if (!this.definitionsLoadPromise) {
1668
+ this.definitionsLoadPromise = (async () => {
1669
+ await this.fetchFlags();
1670
+ this.initialized = true;
1671
+ this.startWebSocket();
1672
+ })().finally(() => {
1673
+ this.definitionsLoadPromise = null;
1674
+ });
1675
+ }
1676
+ await this.definitionsLoadPromise;
1677
+ if (identity) {
1678
+ await this.executeBeforeIdentify(identity);
1679
+ await this.executeAfterIdentify(identity);
1680
+ }
1681
+ }
1682
+ this.identity = identity;
1683
+ this.flags = this.snapshotFlags({ identity });
1684
+ if (wasWarm) {
1685
+ this.logger.debug("Client already initialized; re-snapshotted for identity.");
1686
+ }
1687
+ return this.flags;
1688
+ }
1689
+ /**
1690
+ * Fetch feature definitions from the API (definitions-signed; no identity query).
1691
+ * Counts one definition-refresh hit/miss per performed attempt; concurrent
1692
+ * in-flight joiners await the same fetch and are not counted.
1693
+ */
1694
+ async fetchFlags(identity) {
1695
+ if (identity !== void 0) {
1696
+ this.identity = identity;
1697
+ }
1698
+ if (this.definitionsFetchInFlight) {
1699
+ return this.definitionsFetchInFlight;
1700
+ }
1701
+ this.definitionsFetchInFlight = this.performDefinitionsFetch();
1702
+ try {
1703
+ return await this.definitionsFetchInFlight;
1704
+ } finally {
1705
+ this.definitionsFetchInFlight = null;
1706
+ }
1707
+ }
1708
+ async performDefinitionsFetch() {
1709
+ if (!this.config.appKey) {
1710
+ this.logger.debug("No appKey, using featureDefaults.");
1711
+ this.definitions = /* @__PURE__ */ new Map();
1712
+ this.flags = this.config.featureDefaults ?? {};
1713
+ return this.flags;
1714
+ }
1715
+ let outcomeRecorded = false;
1716
+ try {
1717
+ const pin = this.pendingDefinitionsPin;
1718
+ this.pendingDefinitionsPin = null;
1719
+ const url = appendDefinitionsRevisionParam(
1720
+ buildDefinitionsUrl(this.config),
1721
+ pin
1722
+ );
1723
+ this.logger.debug(`Fetching definitions from: ${url}`);
1724
+ const previousRevision = pin ? null : this.getDefinitionsRevision();
1725
+ const headers = buildDefinitionFetchHeaders(
1726
+ previousRevision ? { "If-None-Match": previousRevision } : {}
1727
+ );
1728
+ const response = await fetchWithTimeout(url, { headers }, this.config.timeout);
1729
+ const responseRevision = this.normalizeDefinitionsRevision(
1730
+ extractDefinitionsRevision(response)
1731
+ );
1732
+ if (response.status === 304) {
1733
+ if (responseRevision) {
1734
+ this.cacheDefinitionsRevision(responseRevision);
1735
+ }
1736
+ this.noteDefinitionCacheHit();
1737
+ outcomeRecorded = true;
1738
+ this.logger.debug("Definitions unchanged (304)");
1739
+ return this.flags;
1740
+ }
1741
+ if (!response.ok) {
1742
+ throw new TogglyNetworkError(
1743
+ `HTTP ${response.status}: ${response.statusText}`
1744
+ );
1745
+ }
1746
+ const bodyText = await togglySignedDefs.readResponseBody(response);
1747
+ const parsed = await togglySignedDefs.parseEvaluatedResponseBody(bodyText, {
1748
+ verifySignatures: this.config.verifySignatures,
1749
+ baseUrl: this.config.baseUrl ?? "https://definitions.toggly.io",
1750
+ allowedKeyIds: this.config.allowedKeyIds,
1751
+ maxSignatureAgeSeconds: this.config.maxSignatureAgeSeconds,
1752
+ headers: buildDefinitionFetchHeaders({})
1753
+ });
1754
+ this.definitions = togglyEval.parseDefinitionsPayload(parsed);
1755
+ this.flags = {
1756
+ ...this.config.featureDefaults ?? {},
1757
+ ...togglyEval.snapshotEvaluatedBooleans(this.definitions, this.buildEvalContext())
1758
+ };
1759
+ if (responseRevision) {
1760
+ this.cacheDefinitionsRevision(responseRevision);
1761
+ }
1762
+ this.logger.debug(`Fetched ${this.definitions.size} definitions.`);
1763
+ if (this.definitionsRevisionsMatch(previousRevision, responseRevision)) {
1764
+ this.noteDefinitionCacheHit();
1765
+ } else {
1766
+ this.noteDefinitionCacheMiss();
1767
+ }
1768
+ outcomeRecorded = true;
1769
+ await this.executeAfterRefresh(this.flags);
1770
+ return this.flags;
1771
+ } catch (error) {
1772
+ this.logger.warn("Failed to fetch flags, preserving last-known-good flags when available.", error);
1773
+ this.config.onError?.("Error fetching feature flags", error);
1774
+ if (!outcomeRecorded && this.definitions.size > 0) {
1775
+ this.noteDefinitionCacheHit();
1776
+ }
1777
+ if (Object.keys(this.flags).length === 0) {
1778
+ this.flags = this.config.featureDefaults ?? {};
1779
+ }
1780
+ return this.flags;
1781
+ }
1782
+ }
1783
+ /**
1784
+ * Get all flags
1785
+ */
1786
+ getFlags() {
1787
+ return { ...this.flags };
1788
+ }
1789
+ /**
1790
+ * Check if a feature is enabled
1791
+ */
1792
+ async isEnabled(featureKey, context, defaultValue = false, entity, kind) {
1793
+ const entityContext = togglyHooksTypes.normalizeEntityContext(entity, kind);
1794
+ const hookData = await this.executeBeforeEvaluation(featureKey, defaultValue);
1795
+ const result = this.getEffectiveFlag(featureKey, defaultValue, entityContext, context);
1796
+ this.recordCheckForKey(featureKey, result, context);
1797
+ await this.executeAfterEvaluation(featureKey, hookData, result);
1798
+ return result;
1799
+ }
1800
+ registerContext(kind, mapper) {
1801
+ togglyHooksTypes.registerContext(kind, mapper);
1802
+ }
1803
+ /**
1804
+ * Check if a feature is disabled
1805
+ */
1806
+ async isDisabled(featureKey, context, defaultValue = true, entity, kind) {
1807
+ return !await this.isEnabled(featureKey, context, !defaultValue, entity, kind);
1808
+ }
1809
+ /**
1810
+ * Evaluate a feature gate (multiple features)
1811
+ */
1812
+ async evaluateGate(featureKeys, requirement = "all", negate = false, defaultValue = false, entity, kind, identityOverride) {
1813
+ const entityContext = togglyHooksTypes.normalizeEntityContext(entity, kind);
1814
+ const firstKey = featureKeys[0] ?? "gate";
1815
+ const hookData = await this.executeBeforeEvaluation(firstKey, defaultValue);
1816
+ const result = this.evaluateGateEffective(
1817
+ featureKeys,
1818
+ requirement,
1819
+ negate,
1820
+ defaultValue,
1821
+ entityContext,
1822
+ identityOverride
1823
+ );
1824
+ await this.executeAfterEvaluation(firstKey, hookData, result);
1825
+ return result;
1826
+ }
1827
+ /**
1828
+ * Get the server context for client hydration
1829
+ */
1830
+ getServerContext() {
1831
+ return {
1832
+ flags: this.flags,
1833
+ appKey: this.config.appKey,
1834
+ environment: this.config.environment,
1835
+ fetchedAt: Date.now()
1836
+ };
1837
+ }
1838
+ // WebSocket live updates
1839
+ /**
1840
+ * Start WebSocket connection for live updates
1841
+ */
1842
+ startWebSocket() {
1843
+ if (!this.config.appKey) {
1844
+ return;
1845
+ }
1846
+ if (this.ws) {
1847
+ return;
1848
+ }
1849
+ const baseUrl = this.config.baseUrl ?? "https://definitions.toggly.io";
1850
+ const wsUrl = buildWebSocketUrl(baseUrl, this.config.appKey, this.getDefinitionsRevision());
1851
+ this.logger.debug(`WebSocket connecting to: ${wsUrl}`);
1852
+ try {
1853
+ this.ws = new WebSocket__default.default(wsUrl);
1854
+ this.ws.on("open", () => {
1855
+ this.wsConnected = true;
1856
+ this.wsReconnectAttempt = 0;
1857
+ this.logger.debug("WebSocket connected");
1858
+ });
1859
+ this.ws.on("message", (data) => {
1860
+ const text = data.toString();
1861
+ if (text === "update" || text === "flags-updated") {
1862
+ this.scheduleDebouncedRefresh();
1863
+ return;
1864
+ }
1865
+ try {
1866
+ const message = JSON.parse(text);
1867
+ if (message.type === "ping") {
1868
+ return;
1869
+ }
1870
+ if (message.type === "sync") {
1871
+ this.handleWsSyncMessage(message);
1872
+ return;
1873
+ }
1874
+ if (message.type === "flags-updated" || message.type === "update" || message.type === "signing-key-updated") {
1875
+ this.handleWsUpdateMessage(message);
1876
+ }
1877
+ } catch {
1878
+ }
1879
+ });
1880
+ this.ws.on("close", () => {
1881
+ this.wsConnected = false;
1882
+ this.ws = null;
1883
+ this.logger.debug("WebSocket disconnected, scheduling reconnect");
1884
+ this.scheduleReconnect();
1885
+ });
1886
+ this.ws.on("error", (error) => {
1887
+ this.logger.error("WebSocket error:", error.message);
1888
+ });
1889
+ } catch (error) {
1890
+ this.logger.error("Failed to create WebSocket:", error);
1891
+ this.ws = null;
1892
+ this.scheduleReconnect();
1893
+ }
1894
+ }
1895
+ /**
1896
+ * Schedule WebSocket reconnection
1897
+ */
1898
+ scheduleReconnect() {
1899
+ if (this.wsReconnectTimer) {
1900
+ return;
1901
+ }
1902
+ const delay = getNextReconnectDelayMs(this.wsReconnectAttempt);
1903
+ this.wsReconnectAttempt += 1;
1904
+ this.wsReconnectTimer = setTimeout(() => {
1905
+ this.wsReconnectTimer = null;
1906
+ this.startWebSocket();
1907
+ }, delay);
1908
+ }
1909
+ /**
1910
+ * Stop WebSocket connection and cleanup
1911
+ */
1912
+ stopWebSocket() {
1913
+ if (this.wsReconnectTimer) {
1914
+ clearTimeout(this.wsReconnectTimer);
1915
+ this.wsReconnectTimer = null;
1916
+ }
1917
+ if (this.refreshDebounceTimer) {
1918
+ clearTimeout(this.refreshDebounceTimer);
1919
+ this.refreshDebounceTimer = null;
1920
+ }
1921
+ if (this.ws) {
1922
+ this.ws.removeAllListeners();
1923
+ this.ws.close();
1924
+ this.ws = null;
1925
+ this.wsConnected = false;
1926
+ }
1927
+ }
1928
+ /**
1929
+ * Close the client and cleanup all resources
1930
+ */
1931
+ close() {
1932
+ this.stopWebSocket();
1933
+ if (this.refreshTimer) {
1934
+ clearInterval(this.refreshTimer);
1935
+ this.refreshTimer = null;
1936
+ }
1937
+ if (this.telemetry) {
1938
+ void this.telemetry.close();
1939
+ this.telemetry = null;
1940
+ }
1941
+ this.logger.debug("TogglyServerClient closed");
1942
+ }
1943
+ /** Record a feature "used" interaction (usage telemetry). */
1944
+ recordUsage(featureKey, identity, variant) {
1945
+ this.telemetry?.recordUsage(featureKey, identity ?? this.identity, variant);
1946
+ }
1947
+ /** Record a feature "viewed" event (usage telemetry). */
1948
+ recordView(featureKey, identity, variant) {
1949
+ this.telemetry?.recordView(featureKey, identity ?? this.identity, variant);
1950
+ }
1951
+ measure(metricKey, value, options) {
1952
+ this.telemetry?.measure(metricKey, value, options);
1953
+ }
1954
+ incrementCounter(metricKey, value = 1, options) {
1955
+ this.telemetry?.incrementCounter(metricKey, value, options);
1956
+ }
1957
+ observe(metricKey, value, options) {
1958
+ this.telemetry?.observe(metricKey, value, options);
1959
+ }
1960
+ async flushTelemetry() {
1961
+ await this.telemetry?.flushAll();
1962
+ }
1963
+ // Hook execution methods
1964
+ async executeBeforeEvaluation(flagKey, defaultValue) {
1965
+ const dataMap = /* @__PURE__ */ new Map();
1966
+ for (const hook of this.hooks) {
1967
+ if (hook.beforeEvaluation) {
1968
+ try {
1969
+ const data = await hook.beforeEvaluation(flagKey, defaultValue);
1970
+ dataMap.set(hook.getMetadata().name, data);
1971
+ } catch (error) {
1972
+ this.logger.error(
1973
+ `Error in hook "${hook.getMetadata().name}.beforeEvaluation":`,
1974
+ error
1975
+ );
1976
+ }
1977
+ }
1978
+ }
1979
+ return dataMap;
1980
+ }
1981
+ async executeAfterEvaluation(flagKey, dataMap, result) {
1982
+ for (let i = this.hooks.length - 1; i >= 0; i--) {
1983
+ const hook = this.hooks[i];
1984
+ if (hook.afterEvaluation) {
1985
+ try {
1986
+ const data = dataMap.get(hook.getMetadata().name);
1987
+ await hook.afterEvaluation(flagKey, data, result);
1988
+ } catch (error) {
1989
+ this.logger.error(
1990
+ `Error in hook "${hook.getMetadata().name}.afterEvaluation":`,
1991
+ error
1992
+ );
1993
+ }
1994
+ }
1995
+ }
1996
+ }
1997
+ async executeBeforeIdentify(identity) {
1998
+ const dataMap = /* @__PURE__ */ new Map();
1999
+ for (const hook of this.hooks) {
2000
+ if (hook.beforeIdentify) {
2001
+ try {
2002
+ const data = await hook.beforeIdentify(identity);
2003
+ dataMap.set(hook.getMetadata().name, data);
2004
+ } catch (error) {
2005
+ this.logger.error(
2006
+ `Error in hook "${hook.getMetadata().name}.beforeIdentify":`,
2007
+ error
2008
+ );
2009
+ }
2010
+ }
2011
+ }
2012
+ return dataMap;
2013
+ }
2014
+ async executeAfterIdentify(identity) {
2015
+ for (let i = this.hooks.length - 1; i >= 0; i--) {
2016
+ const hook = this.hooks[i];
2017
+ if (hook.afterIdentify) {
2018
+ try {
2019
+ await hook.afterIdentify(identity, void 0);
2020
+ } catch (error) {
2021
+ this.logger.error(
2022
+ `Error in hook "${hook.getMetadata().name}.afterIdentify":`,
2023
+ error
2024
+ );
2025
+ }
2026
+ }
2027
+ }
2028
+ }
2029
+ async executeAfterRefresh(flags) {
2030
+ for (const hook of this.hooks) {
2031
+ if (hook.afterRefresh) {
2032
+ try {
2033
+ await hook.afterRefresh(flags);
2034
+ } catch (error) {
2035
+ this.logger.error(
2036
+ `Error in hook "${hook.getMetadata().name}.afterRefresh":`,
2037
+ error
2038
+ );
2039
+ }
2040
+ }
2041
+ }
2042
+ }
2043
+ };
2044
+ function createServerClient(config) {
2045
+ return new TogglyServerClient(config);
2046
+ }
2047
+
2048
+ // src/server/loader.ts
2049
+ function resolveIdentityOverride(override) {
2050
+ if (override == null) {
2051
+ return getAmbientEvalOverrides();
2052
+ }
2053
+ if (typeof override === "string") {
2054
+ return mergeIdentityContext(getAmbientEvalOverrides(), {
2055
+ identity: override
2056
+ });
2057
+ }
2058
+ return mergeIdentityContext(getAmbientEvalOverrides(), override);
2059
+ }
2060
+ function createTogglyLoader(options) {
2061
+ const client = createServerClient(options);
2062
+ return {
2063
+ /**
2064
+ * Get the Toggly client
2065
+ */
2066
+ getClient() {
2067
+ return client;
2068
+ },
2069
+ /**
2070
+ * Load feature flags for a loader function.
2071
+ * Builds ambient EvalContext (identity / groups / claims / request) for
2072
+ * the snapshot. Prefer `run` when calling `client.isEnabled('X')` without
2073
+ * an IdentityContext for the full loader duration.
2074
+ */
2075
+ async load(args) {
2076
+ const ambient = await extractEvalContext(args.request, options);
2077
+ await client.init(ambient.identity);
2078
+ return {
2079
+ flags: client.snapshotFlags(ambient),
2080
+ identity: ambient.identity,
2081
+ appKey: options.appKey,
2082
+ environment: options.environment,
2083
+ fetchedAt: Date.now()
2084
+ };
2085
+ },
2086
+ /**
2087
+ * Bind ambient EvalContext for the full loader duration, then run `fn`.
2088
+ * Prefer this when calling `client.isEnabled('X')` without an IdentityContext.
2089
+ */
2090
+ async run(args, fn) {
2091
+ const ambient = await extractEvalContext(args.request, options);
2092
+ return runWithEvalContext(ambient, async () => {
2093
+ await client.init(ambient.identity);
2094
+ const ctx = {
2095
+ flags: client.snapshotFlags(ambient),
2096
+ identity: ambient.identity,
2097
+ appKey: options.appKey,
2098
+ environment: options.environment,
2099
+ fetchedAt: Date.now(),
2100
+ client
2101
+ };
2102
+ return fn(ctx);
2103
+ });
2104
+ },
2105
+ /**
2106
+ * Create loader data with feature context
2107
+ */
2108
+ async getLoaderData(args, additionalData) {
2109
+ const context = await this.load(args);
2110
+ return {
2111
+ ...additionalData,
2112
+ [TOGGLY_LOADER_KEY]: context
2113
+ };
2114
+ },
2115
+ /**
2116
+ * Check if a feature is enabled.
2117
+ * Ambient EvalContext (from `run` / `load` ALS scope) is merged;
2118
+ * per-call string identity or IdentityContext overrides field-by-field.
2119
+ */
2120
+ async isEnabled(featureKey, defaultValue = false, identityOrContext) {
2121
+ return client.isEnabled(
2122
+ featureKey,
2123
+ resolveIdentityOverride(identityOrContext),
2124
+ defaultValue
2125
+ );
2126
+ },
2127
+ /**
2128
+ * Check if a feature is disabled
2129
+ */
2130
+ async isDisabled(featureKey, defaultValue = true, identityOrContext) {
2131
+ return client.isDisabled(
2132
+ featureKey,
2133
+ resolveIdentityOverride(identityOrContext),
2134
+ defaultValue
2135
+ );
2136
+ },
2137
+ /**
2138
+ * Evaluate a feature gate
2139
+ */
2140
+ async evaluateGate(featureKeys, requirement = "all", negate = false, identityOrContext) {
2141
+ return client.evaluateGate(
2142
+ featureKeys,
2143
+ requirement,
2144
+ negate,
2145
+ false,
2146
+ void 0,
2147
+ void 0,
2148
+ resolveIdentityOverride(identityOrContext)
2149
+ );
2150
+ },
2151
+ /**
2152
+ * Get all flags
2153
+ */
2154
+ getFlags() {
2155
+ return client.getFlags();
2156
+ }
2157
+ };
2158
+ }
2159
+ async function getFeatureFlags(request, options) {
2160
+ const loader = createTogglyLoader(options);
2161
+ return loader.load({ request, params: {}, context: {} });
2162
+ }
2163
+ async function isFeatureEnabled2(request, featureKey, options, defaultValue = false) {
2164
+ const loader = createTogglyLoader(options);
2165
+ return loader.run(
2166
+ { request, params: {}, context: {} },
2167
+ async ({ client }) => client.isEnabled(featureKey, void 0, defaultValue)
2168
+ );
2169
+ }
2170
+ function buildActionContext(client, ambient) {
2171
+ return {
2172
+ client,
2173
+ flags: client.snapshotFlags(ambient),
2174
+ context: ambient,
2175
+ isEnabled: (key, def, override) => client.isEnabled(key, mergeIdentityContext(ambient, override), def),
2176
+ isDisabled: (key, def, override) => client.isDisabled(key, mergeIdentityContext(ambient, override), def),
2177
+ evaluateGate: (keys, req, neg, override) => client.evaluateGate(
2178
+ keys,
2179
+ req,
2180
+ neg,
2181
+ false,
2182
+ void 0,
2183
+ void 0,
2184
+ mergeIdentityContext(ambient, override)
2185
+ )
2186
+ };
2187
+ }
2188
+ function createFeatureGatedAction(options, handler) {
2189
+ return async (args) => {
2190
+ const { request } = args;
2191
+ const client = createServerClient(options);
2192
+ const ambient = await extractEvalContext(request, options);
2193
+ return runWithEvalContext(ambient, async () => {
2194
+ await client.init(ambient.identity);
2195
+ const togglyContext = buildActionContext(client, ambient);
2196
+ if (options.requiredFeatures) {
2197
+ const featureKeys = Array.isArray(options.requiredFeatures) ? options.requiredFeatures : [options.requiredFeatures];
2198
+ const requirement = options.requirement ?? "all";
2199
+ const isAllowed = await client.evaluateGate(
2200
+ featureKeys,
2201
+ requirement,
2202
+ false,
2203
+ false,
2204
+ void 0,
2205
+ void 0,
2206
+ ambient
2207
+ );
2208
+ if (!isAllowed) {
2209
+ if (options.onFeatureDisabled) {
2210
+ return options.onFeatureDisabled(request, featureKeys);
2211
+ }
2212
+ if (options.redirectTo) {
2213
+ return reactRouter.redirect(options.redirectTo);
2214
+ }
2215
+ return Response.json(
2216
+ {
2217
+ error: options.errorMessage ?? "Feature is not available",
2218
+ featureKeys
2219
+ },
2220
+ { status: options.errorStatus ?? 403 }
2221
+ );
2222
+ }
2223
+ }
2224
+ return handler(args, togglyContext);
2225
+ });
2226
+ };
2227
+ }
2228
+ function createTogglyAction(options) {
2229
+ const client = createServerClient(options);
2230
+ return {
2231
+ /**
2232
+ * Get the Toggly client
2233
+ */
2234
+ getClient() {
2235
+ return client;
2236
+ },
2237
+ /**
2238
+ * Initialize for an action request.
2239
+ * Returned helpers close over ambient EvalContext so `toggly.isEnabled('X')`
2240
+ * needs no IdentityContext. Prefer `run` when using `client.isEnabled('X')`
2241
+ * directly for the full action duration.
2242
+ */
2243
+ async init(request) {
2244
+ const ambient = await extractEvalContext(request, options);
2245
+ await client.init(ambient.identity);
2246
+ return buildActionContext(client, ambient);
2247
+ },
2248
+ /**
2249
+ * Bind ambient EvalContext and run an action handler.
2250
+ */
2251
+ async run(args, fn) {
2252
+ const ambient = await extractEvalContext(args.request, options);
2253
+ return runWithEvalContext(ambient, async () => {
2254
+ await client.init(ambient.identity);
2255
+ return fn(args, buildActionContext(client, ambient));
2256
+ });
2257
+ },
2258
+ /**
2259
+ * Wrap an action with feature checks
2260
+ */
2261
+ requireFeature(featureKey, handler, onDisabled) {
2262
+ return createFeatureGatedAction(
2263
+ {
2264
+ ...options,
2265
+ requiredFeatures: featureKey,
2266
+ onFeatureDisabled: onDisabled
2267
+ },
2268
+ handler
2269
+ );
2270
+ },
2271
+ /**
2272
+ * Wrap an action with feature gate checks
2273
+ */
2274
+ requireFeatures(featureKeys, requirement, handler, onDisabled) {
2275
+ return createFeatureGatedAction(
2276
+ {
2277
+ ...options,
2278
+ requiredFeatures: featureKeys,
2279
+ requirement,
2280
+ onFeatureDisabled: onDisabled
2281
+ },
2282
+ handler
2283
+ );
2284
+ }
2285
+ };
2286
+ }
2287
+ function requireFeature(featureKey, options, onDisabled) {
2288
+ return function(handler) {
2289
+ return createFeatureGatedAction(
2290
+ {
2291
+ ...options,
2292
+ requiredFeatures: featureKey,
2293
+ onFeatureDisabled: onDisabled ? () => onDisabled() : void 0
2294
+ },
2295
+ handler
2296
+ );
2297
+ };
2298
+ }
2299
+
2300
+ Object.defineProperty(exports, "fromHttpRequest", {
2301
+ enumerable: true,
2302
+ get: function () { return togglyEval.fromHttpRequest; }
2303
+ });
2304
+ exports.HEADERS = HEADERS;
2305
+ exports.STORAGE_KEYS = STORAGE_KEYS;
2306
+ exports.TOGGLY_LOADER_KEY = TOGGLY_LOADER_KEY;
2307
+ exports.TogglyConfigError = TogglyConfigError;
2308
+ exports.TogglyError = TogglyError;
2309
+ exports.TogglyNetworkError = TogglyNetworkError;
2310
+ exports.TogglyServerClient = TogglyServerClient;
2311
+ exports.TogglyTimeoutError = TogglyTimeoutError;
2312
+ exports.createFeatureGatedAction = createFeatureGatedAction;
2313
+ exports.createServerClient = createServerClient;
2314
+ exports.createTogglyAction = createTogglyAction;
2315
+ exports.createTogglyLoader = createTogglyLoader;
2316
+ exports.extractEvalContext = extractEvalContext;
2317
+ exports.getAmbientEvalOverrides = getAmbientEvalOverrides;
2318
+ exports.getFeatureFlags = getFeatureFlags;
2319
+ exports.isFeatureEnabled = isFeatureEnabled2;
2320
+ exports.mergeIdentityContext = mergeIdentityContext;
2321
+ exports.requireFeature = requireFeature;
2322
+ exports.runWithEvalContext = runWithEvalContext;
2323
+ //# sourceMappingURL=server.cjs.map
2324
+ //# sourceMappingURL=server.cjs.map