@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/CHANGELOG.md +17 -0
- package/LICENSE +21 -0
- package/README.md +107 -0
- package/dist/client.cjs +832 -0
- package/dist/client.cjs.map +1 -0
- package/dist/client.d.cts +603 -0
- package/dist/client.d.ts +603 -0
- package/dist/client.js +787 -0
- package/dist/client.js.map +1 -0
- package/dist/server.cjs +2324 -0
- package/dist/server.cjs.map +1 -0
- package/dist/server.d.cts +645 -0
- package/dist/server.d.ts +645 -0
- package/dist/server.js +2294 -0
- package/dist/server.js.map +1 -0
- package/dist/telemetry/grpc.cjs +159 -0
- package/dist/telemetry/grpc.cjs.map +1 -0
- package/dist/telemetry/grpc.d.cts +29 -0
- package/dist/telemetry/grpc.d.ts +29 -0
- package/dist/telemetry/grpc.js +149 -0
- package/dist/telemetry/grpc.js.map +1 -0
- package/package.json +107 -0
- package/proto/google/protobuf/timestamp.proto +17 -0
- package/proto/metrics.proto +54 -0
- package/proto/usage.proto +49 -0
package/dist/server.cjs
ADDED
|
@@ -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
|