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