@ops-ai/react-router-toggly 1.0.1 → 1.2.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 +30 -0
- package/README.md +50 -0
- package/dist/client.cjs +431 -82
- package/dist/client.cjs.map +1 -1
- package/dist/client.d.cts +45 -3
- package/dist/client.d.ts +45 -3
- package/dist/client.js +432 -83
- package/dist/client.js.map +1 -1
- package/dist/server.cjs +212 -64
- package/dist/server.cjs.map +1 -1
- package/dist/server.d.cts +57 -5
- package/dist/server.d.ts +57 -5
- package/dist/server.js +212 -64
- package/dist/server.js.map +1 -1
- package/dist/telemetry/grpc.cjs +1 -1
- package/dist/telemetry/grpc.cjs.map +1 -1
- package/dist/telemetry/grpc.js +1 -1
- package/dist/telemetry/grpc.js.map +1 -1
- package/package.json +3 -2
package/dist/client.cjs
CHANGED
|
@@ -2,7 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
var togglyHooksTypes = require('@ops-ai/toggly-hooks-types');
|
|
4
4
|
require('@ops-ai/toggly-eval');
|
|
5
|
+
var togglySignedDefs = require('@ops-ai/toggly-signed-defs');
|
|
5
6
|
var react = require('react');
|
|
7
|
+
var togglyClientTelemetry = require('@ops-ai/toggly-client-telemetry');
|
|
8
|
+
var browser = require('@ops-ai/toggly-client-telemetry/browser');
|
|
6
9
|
var togglyLocalGates = require('@ops-ai/toggly-local-gates');
|
|
7
10
|
var jsxRuntime = require('react/jsx-runtime');
|
|
8
11
|
var reactRouter = require('react-router');
|
|
@@ -50,12 +53,18 @@ function mergeConfig(config) {
|
|
|
50
53
|
};
|
|
51
54
|
}
|
|
52
55
|
function buildDefinitionsUrl(config, context) {
|
|
53
|
-
const { baseUrl, appKey, environment, groups, claims, evaluationMode } = mergeConfig(config);
|
|
56
|
+
const { baseUrl, appKey, environment, groups, claims, evaluationMode, enableVariants } = mergeConfig(config);
|
|
54
57
|
if (!appKey) {
|
|
55
58
|
throw new Error("appKey is required");
|
|
56
59
|
}
|
|
57
60
|
const mode = evaluationMode ?? "remote";
|
|
58
|
-
const
|
|
61
|
+
const useVariants = mode !== "local" && enableVariants === true;
|
|
62
|
+
let pathSegment = "evaluated-signed";
|
|
63
|
+
if (mode === "local") {
|
|
64
|
+
pathSegment = "definitions-signed";
|
|
65
|
+
} else if (useVariants) {
|
|
66
|
+
pathSegment = "evaluated-variants-signed";
|
|
67
|
+
}
|
|
59
68
|
const url = new URL(`${baseUrl}/${pathSegment}/${appKey}/${environment}`);
|
|
60
69
|
if (mode === "local") {
|
|
61
70
|
return url.toString();
|
|
@@ -68,10 +77,25 @@ function buildDefinitionsUrl(config, context) {
|
|
|
68
77
|
groups: fromParam?.groups ?? groups,
|
|
69
78
|
claims: fromParam?.claims ?? claims
|
|
70
79
|
},
|
|
71
|
-
"evaluated"
|
|
80
|
+
useVariants ? "variants" : "evaluated"
|
|
72
81
|
);
|
|
73
82
|
return url.toString();
|
|
74
83
|
}
|
|
84
|
+
function parseVariantDefsPayload(parsed) {
|
|
85
|
+
return togglySignedDefs.asVariantDefsRecord(togglySignedDefs.unwrapDefsPayload(parsed));
|
|
86
|
+
}
|
|
87
|
+
function variantDefsToFlags(defs) {
|
|
88
|
+
const out = {};
|
|
89
|
+
for (const key of Object.keys(defs)) {
|
|
90
|
+
out[key] = defs[key]?.enabled === true;
|
|
91
|
+
}
|
|
92
|
+
return out;
|
|
93
|
+
}
|
|
94
|
+
function decodeVariantValue(value, isT) {
|
|
95
|
+
if (value === null || value === void 0) return null;
|
|
96
|
+
if (isT) return isT(value) ? value : null;
|
|
97
|
+
return value;
|
|
98
|
+
}
|
|
75
99
|
function isFeatureEnabled(flags, featureKey, defaultValue = false, entityContext) {
|
|
76
100
|
if (!flags || Object.keys(flags).length === 0) {
|
|
77
101
|
return defaultValue;
|
|
@@ -136,16 +160,194 @@ var HEADERS = {
|
|
|
136
160
|
CACHE_CONTROL: "cache-control"
|
|
137
161
|
};
|
|
138
162
|
var TOGGLY_LOADER_KEY = "__toggly";
|
|
163
|
+
function checkVariantLabel(result) {
|
|
164
|
+
if (typeof result === "string") {
|
|
165
|
+
return result;
|
|
166
|
+
}
|
|
167
|
+
return result ? "enabled" : "disabled";
|
|
168
|
+
}
|
|
169
|
+
function createBrowserTelemetry(config) {
|
|
170
|
+
const enabled = globalThis.window !== void 0 && typeof document !== "undefined" && !!config.appKey?.trim() && config.enableTelemetry !== false;
|
|
171
|
+
const usage = enabled && config.enableUsageTracking !== false;
|
|
172
|
+
const metrics = enabled && config.enableMetrics !== false;
|
|
173
|
+
let context = { identity: config.identity, instanceId: config.instanceId };
|
|
174
|
+
let partition = 0;
|
|
175
|
+
let active = false;
|
|
176
|
+
let disposed = false;
|
|
177
|
+
let reporter;
|
|
178
|
+
let detach;
|
|
179
|
+
let staged = /* @__PURE__ */ new Map();
|
|
180
|
+
let diagnostics = 0;
|
|
181
|
+
const diagnostic = () => {
|
|
182
|
+
if (diagnostics++ >= 10) return;
|
|
183
|
+
try {
|
|
184
|
+
config.onError?.("Frontend telemetry pre-commit event rejected.");
|
|
185
|
+
} catch {
|
|
186
|
+
}
|
|
187
|
+
};
|
|
188
|
+
const getReporter = () => {
|
|
189
|
+
if (!reporter) {
|
|
190
|
+
reporter = togglyClientTelemetry.createTelemetryReporter({
|
|
191
|
+
appKey: config.appKey,
|
|
192
|
+
environment: config.environment,
|
|
193
|
+
...context,
|
|
194
|
+
metricsBaseUrl: config.metricsBaseUrl,
|
|
195
|
+
telemetryFlushIntervalMs: config.telemetryFlushIntervalMs,
|
|
196
|
+
fetch: config.telemetryFetch,
|
|
197
|
+
onDiagnostic: (code) => {
|
|
198
|
+
try {
|
|
199
|
+
config.onError?.(code);
|
|
200
|
+
} catch {
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
});
|
|
204
|
+
detach = browser.attachBrowserLifecycle(reporter);
|
|
205
|
+
}
|
|
206
|
+
return reporter;
|
|
207
|
+
};
|
|
208
|
+
function replay(entry) {
|
|
209
|
+
const target = getReporter();
|
|
210
|
+
target.setContext(entry.context);
|
|
211
|
+
if (entry.kind === "feature") {
|
|
212
|
+
const methods = ["recordCheck", "recordUsage", "recordView"];
|
|
213
|
+
entry.values.forEach((count, index) => {
|
|
214
|
+
for (let i = 0; i < count; i++) target[methods[index]](entry.key, entry.variant);
|
|
215
|
+
});
|
|
216
|
+
} else if (entry.kind === "gauge") target.setGauge(entry.key, entry.values[0]);
|
|
217
|
+
else {
|
|
218
|
+
let remaining = entry.values[0];
|
|
219
|
+
do {
|
|
220
|
+
const value = Math.min(remaining, 1e6);
|
|
221
|
+
target.incrementCounter(entry.key, value);
|
|
222
|
+
remaining -= value;
|
|
223
|
+
} while (remaining > 0);
|
|
224
|
+
}
|
|
225
|
+
target.setContext(context);
|
|
226
|
+
}
|
|
227
|
+
function isValidEntry(entry) {
|
|
228
|
+
return !(typeof entry.key !== "string" || !entry.key.trim() || entry.kind === "feature" && (typeof entry.variant !== "string" || !entry.variant.length || entry.variant.length > 64 || /[^A-Za-z0-9_-]/.test(entry.variant)) || entry.values.some((value) => !Number.isFinite(value) || value < 0 || value > 1e6 || entry.kind !== "gauge" && !Number.isInteger(value)));
|
|
229
|
+
}
|
|
230
|
+
function fitsAdmissionBudget(entries, entry) {
|
|
231
|
+
let operations = 0;
|
|
232
|
+
let bytes = 0;
|
|
233
|
+
let variants = 0;
|
|
234
|
+
for (const value of entries) {
|
|
235
|
+
if (value.kind === "feature" && value.key === entry.key && value.partition === entry.partition) variants++;
|
|
236
|
+
const count = value.kind === "feature" ? value.values.reduce((a, b) => a + b, 0) : Math.max(1, Math.ceil(value.values[0] / 1e6));
|
|
237
|
+
operations += count;
|
|
238
|
+
const serialized = JSON.stringify({ k: config.appKey, e: config.environment ?? "Production", ...value });
|
|
239
|
+
const size = new TextEncoder().encode(serialized).byteLength;
|
|
240
|
+
if (size > 49152) return false;
|
|
241
|
+
bytes += size * count;
|
|
242
|
+
}
|
|
243
|
+
return entries.length <= 2e3 && operations <= 2e3 && bytes <= 262144 && variants <= 16;
|
|
244
|
+
}
|
|
245
|
+
function event(entry) {
|
|
246
|
+
if (disposed || (entry.kind === "feature" ? !usage : !metrics)) return;
|
|
247
|
+
if (!isValidEntry(entry)) {
|
|
248
|
+
diagnostic();
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
if (active) {
|
|
252
|
+
replay(entry);
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
const id = JSON.stringify([entry.partition, entry.kind === "feature" ? "f" : "m", entry.key, entry.variant]);
|
|
256
|
+
const previous = staged.get(id);
|
|
257
|
+
if (previous && previous.kind !== entry.kind) {
|
|
258
|
+
diagnostic();
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
if (previous && entry.kind !== "gauge") entry.values = entry.values.map((value, index) => value + previous.values[index]);
|
|
262
|
+
const entries = [...staged.entries()].filter(([key]) => key !== id).map(([, value]) => value);
|
|
263
|
+
entries.push(entry);
|
|
264
|
+
if (!fitsAdmissionBudget(entries, entry)) {
|
|
265
|
+
diagnostic();
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
staged.set(id, entry);
|
|
269
|
+
}
|
|
270
|
+
function feature(key, variant, index, attribution = context, capturedPartition = partition) {
|
|
271
|
+
const values = [0, 0, 0];
|
|
272
|
+
values[index] = 1;
|
|
273
|
+
event({ partition: capturedPartition, context: attribution, key, variant, kind: "feature", values });
|
|
274
|
+
}
|
|
275
|
+
const api = {
|
|
276
|
+
recordUsage: (key, variant = "enabled") => feature(key, variant, 1),
|
|
277
|
+
recordView: (key, variant = "enabled") => feature(key, variant, 2),
|
|
278
|
+
incrementCounter: (key, value = 1) => event({ partition, context, key, kind: "counter", values: [value] }),
|
|
279
|
+
setGauge: (key, value) => event({ partition, context, key, kind: "gauge", values: [value] }),
|
|
280
|
+
flushTelemetry: async () => {
|
|
281
|
+
await reporter?.flush();
|
|
282
|
+
}
|
|
283
|
+
};
|
|
284
|
+
return {
|
|
285
|
+
api,
|
|
286
|
+
setContext(next) {
|
|
287
|
+
if (disposed) return;
|
|
288
|
+
if (context.identity !== next.identity || context.instanceId !== next.instanceId) partition++;
|
|
289
|
+
context = { identity: next.identity, instanceId: next.instanceId };
|
|
290
|
+
reporter?.setContext(context);
|
|
291
|
+
},
|
|
292
|
+
captureCheck() {
|
|
293
|
+
if (disposed || !usage) return () => {
|
|
294
|
+
};
|
|
295
|
+
if (active) {
|
|
296
|
+
const record = getReporter().captureCheck();
|
|
297
|
+
return (key, result) => record(key, checkVariantLabel(result));
|
|
298
|
+
}
|
|
299
|
+
const attribution = context;
|
|
300
|
+
const capturedPartition = partition;
|
|
301
|
+
return (key, result) => feature(key, checkVariantLabel(result), 0, attribution, capturedPartition);
|
|
302
|
+
},
|
|
303
|
+
recordCheck: (key, result) => feature(key, checkVariantLabel(result), 0),
|
|
304
|
+
activate() {
|
|
305
|
+
if (disposed || active) return;
|
|
306
|
+
active = true;
|
|
307
|
+
const pending = staged;
|
|
308
|
+
staged = /* @__PURE__ */ new Map();
|
|
309
|
+
for (const entry of pending.values()) replay(entry);
|
|
310
|
+
pending.clear();
|
|
311
|
+
},
|
|
312
|
+
dispose(options) {
|
|
313
|
+
disposed = true;
|
|
314
|
+
staged.clear();
|
|
315
|
+
detach?.();
|
|
316
|
+
reporter?.dispose(options);
|
|
317
|
+
}
|
|
318
|
+
};
|
|
319
|
+
}
|
|
139
320
|
|
|
140
321
|
// src/client/sdk-identity.ts
|
|
141
322
|
var SDK_ID = "react-router";
|
|
142
|
-
var SDK_VERSION = "1.0
|
|
323
|
+
var SDK_VERSION = "1.2.0";
|
|
143
324
|
function appendSdkQueryParams(params) {
|
|
144
325
|
params.set("sdk", SDK_ID);
|
|
145
326
|
params.set("sdkVersion", SDK_VERSION);
|
|
146
327
|
}
|
|
147
328
|
var TogglyContext = react.createContext(void 0);
|
|
148
|
-
|
|
329
|
+
var transportIds = /* @__PURE__ */ new WeakMap();
|
|
330
|
+
var nextTransportId = 0;
|
|
331
|
+
function transportId(value) {
|
|
332
|
+
if (!value) return 0;
|
|
333
|
+
let id = transportIds.get(value);
|
|
334
|
+
if (id === void 0) {
|
|
335
|
+
id = ++nextTransportId;
|
|
336
|
+
transportIds.set(value, id);
|
|
337
|
+
}
|
|
338
|
+
return id;
|
|
339
|
+
}
|
|
340
|
+
var useCommittedContextEffect = globalThis.window === void 0 ? react.useEffect : react.useLayoutEffect;
|
|
341
|
+
function TogglyProvider(props) {
|
|
342
|
+
const config = props.config ?? (props.serverContext ? { appKey: props.serverContext.appKey, environment: props.serverContext.environment } : void 0);
|
|
343
|
+
const committedOwner = react.useRef();
|
|
344
|
+
const ownerKey = JSON.stringify([transportId(config?.telemetryFetch), config?.appKey ?? "", config?.environment ?? "Production", config?.enableTelemetry, config?.enableUsageTracking, config?.enableMetrics, config?.metricsBaseUrl, config?.telemetryFlushIntervalMs]);
|
|
345
|
+
const snapshot = props.serverContext;
|
|
346
|
+
const matchesOwner = (!snapshot?.appKey || snapshot.appKey === config?.appKey) && (!snapshot?.environment || snapshot.environment === (config?.environment ?? "Production"));
|
|
347
|
+
return /* @__PURE__ */ jsxRuntime.jsx(TogglyProviderOwner, { ...props, committedOwner, config, serverContext: matchesOwner ? snapshot : void 0 }, ownerKey);
|
|
348
|
+
}
|
|
349
|
+
function TogglyProviderOwner({
|
|
350
|
+
committedOwner,
|
|
149
351
|
children,
|
|
150
352
|
serverContext,
|
|
151
353
|
config,
|
|
@@ -161,6 +363,48 @@ function TogglyProvider({
|
|
|
161
363
|
() => createLogger(mergedConfig?.debug ?? false),
|
|
162
364
|
[mergedConfig?.debug]
|
|
163
365
|
);
|
|
366
|
+
const contextRef = react.useRef({
|
|
367
|
+
identity: serverContext ? serverContext.identity : mergedConfig?.identity,
|
|
368
|
+
instanceId: mergedConfig?.instanceId?.trim() || void 0,
|
|
369
|
+
groups: [...mergedConfig?.groups ?? []],
|
|
370
|
+
claims: { ...mergedConfig?.claims }
|
|
371
|
+
});
|
|
372
|
+
const committedTargetingProps = react.useRef({
|
|
373
|
+
groups: [...mergedConfig?.groups ?? []],
|
|
374
|
+
claims: { ...mergedConfig?.claims }
|
|
375
|
+
});
|
|
376
|
+
const flagsRef = react.useRef(serverContext?.flags ?? mergedConfig?.featureDefaults ?? {});
|
|
377
|
+
const variantsRef = react.useRef(null);
|
|
378
|
+
const generationRef = react.useRef(0);
|
|
379
|
+
const updateRevisionRef = react.useRef(0);
|
|
380
|
+
const [telemetry] = react.useState(() => createBrowserTelemetry({ ...config, ...contextRef.current }));
|
|
381
|
+
const mountedRef = react.useRef(true);
|
|
382
|
+
react.useEffect(() => {
|
|
383
|
+
mountedRef.current = true;
|
|
384
|
+
const transport = JSON.stringify([
|
|
385
|
+
config?.metricsBaseUrl ?? "https://metrics.toggly.io",
|
|
386
|
+
config?.telemetryFlushIntervalMs ?? 45e3,
|
|
387
|
+
config?.enableTelemetry !== false,
|
|
388
|
+
config?.enableUsageTracking !== false,
|
|
389
|
+
config?.enableMetrics !== false
|
|
390
|
+
]);
|
|
391
|
+
const previous = committedOwner.current;
|
|
392
|
+
if (previous && previous.telemetry !== telemetry) {
|
|
393
|
+
previous.telemetry.dispose({ flush: previous.transport === transport && previous.fetch === config?.telemetryFetch });
|
|
394
|
+
}
|
|
395
|
+
committedOwner.current = { telemetry, transport, fetch: config?.telemetryFetch };
|
|
396
|
+
telemetry.activate();
|
|
397
|
+
return () => {
|
|
398
|
+
mountedRef.current = false;
|
|
399
|
+
generationRef.current++;
|
|
400
|
+
queueMicrotask(() => {
|
|
401
|
+
if (!mountedRef.current) {
|
|
402
|
+
if (committedOwner.current?.telemetry === telemetry) committedOwner.current = void 0;
|
|
403
|
+
telemetry.dispose();
|
|
404
|
+
}
|
|
405
|
+
});
|
|
406
|
+
};
|
|
407
|
+
}, [telemetry]);
|
|
164
408
|
const [flags, setFlags] = react.useState(
|
|
165
409
|
serverContext?.flags ?? mergedConfig?.featureDefaults ?? {}
|
|
166
410
|
);
|
|
@@ -175,23 +419,24 @@ function TogglyProvider({
|
|
|
175
419
|
togglyLocalGates.buildFlagGateIndex(localGatesRef.current)
|
|
176
420
|
);
|
|
177
421
|
const localGatesListenersRef = react.useRef(/* @__PURE__ */ new Set());
|
|
178
|
-
const
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
);
|
|
186
|
-
return
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
);
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
422
|
+
const captureEvaluation = react.useCallback((featureKeys) => {
|
|
423
|
+
const selectedKeys = new Set(featureKeys);
|
|
424
|
+
const selectedFlags = Object.fromEntries([...selectedKeys].map((key) => [key, flagsRef.current[key]]));
|
|
425
|
+
const capturedFlags = JSON.parse(JSON.stringify(selectedFlags));
|
|
426
|
+
const applicableGates = localGatesRef.current.filter((gate) => gate.flagKeys.some((key) => selectedKeys.has(key)));
|
|
427
|
+
const index = togglyLocalGates.buildFlagGateIndex(applicableGates);
|
|
428
|
+
const gates = [...new Set(index.values())].map((id) => {
|
|
429
|
+
const gate = localGatesRef.current.find((candidate) => candidate.id === id);
|
|
430
|
+
return { ...gate, flagKeys: [...gate.flagKeys] };
|
|
431
|
+
});
|
|
432
|
+
const record = telemetry.captureCheck();
|
|
433
|
+
return (featureKey, defaultValue = false, entityContext) => {
|
|
434
|
+
const remote = isFeatureEnabled(capturedFlags, featureKey, defaultValue, entityContext);
|
|
435
|
+
const result = togglyLocalGates.applyLocalGate(remote, featureKey, gates, index);
|
|
436
|
+
record(featureKey, result);
|
|
437
|
+
return result;
|
|
438
|
+
};
|
|
439
|
+
}, [flags, localGatesRevision, telemetry]);
|
|
195
440
|
const registerContext2 = react.useCallback(
|
|
196
441
|
(kind, mapper) => {
|
|
197
442
|
togglyHooksTypes.registerContext(kind, mapper);
|
|
@@ -227,8 +472,9 @@ function TogglyProvider({
|
|
|
227
472
|
}
|
|
228
473
|
}, [mergedConfig?.localGates, setLocalGates]);
|
|
229
474
|
const executeAfterRefresh = react.useCallback(
|
|
230
|
-
async (newFlags) => {
|
|
475
|
+
async (newFlags, generation, revision) => {
|
|
231
476
|
for (const hook of hooks) {
|
|
477
|
+
if (!mountedRef.current || generation !== generationRef.current || revision !== updateRevisionRef.current) return;
|
|
232
478
|
if (hook.afterRefresh) {
|
|
233
479
|
try {
|
|
234
480
|
await hook.afterRefresh(newFlags);
|
|
@@ -244,46 +490,66 @@ function TogglyProvider({
|
|
|
244
490
|
[hooks, logger]
|
|
245
491
|
);
|
|
246
492
|
const fetchFlags = react.useCallback(
|
|
247
|
-
async (
|
|
493
|
+
async (target = contextRef.current) => {
|
|
494
|
+
if (!mountedRef.current) return flagsRef.current;
|
|
248
495
|
if (!mergedConfig?.appKey) {
|
|
249
496
|
logger.debug("No appKey, using current flags.");
|
|
250
|
-
return
|
|
497
|
+
return flagsRef.current;
|
|
251
498
|
}
|
|
252
499
|
try {
|
|
253
|
-
const
|
|
254
|
-
|
|
500
|
+
const parsedUrl = new URL(buildDefinitionsUrl(mergedConfig, target));
|
|
501
|
+
if (target.instanceId) {
|
|
502
|
+
const capturedKeys = [...parsedUrl.searchParams.keys()];
|
|
503
|
+
for (const key of capturedKeys) {
|
|
504
|
+
if (key === "u" || key === "userId" || key === "g" || key.startsWith("claim.")) parsedUrl.searchParams.delete(key);
|
|
505
|
+
}
|
|
506
|
+
parsedUrl.searchParams.set("i", target.instanceId);
|
|
507
|
+
}
|
|
508
|
+
const url = parsedUrl.toString();
|
|
509
|
+
logger.debug("Fetching feature flags");
|
|
255
510
|
const response = await fetchWithTimeout(url, {}, mergedConfig.timeout);
|
|
256
511
|
if (!response.ok) {
|
|
257
512
|
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
|
258
513
|
}
|
|
259
514
|
const payload = await response.json();
|
|
515
|
+
if (mergedConfig.enableVariants === true) {
|
|
516
|
+
const defs = parseVariantDefsPayload(payload);
|
|
517
|
+
variantsRef.current = defs;
|
|
518
|
+
const newFlags2 = variantDefsToFlags(defs);
|
|
519
|
+
logger.debug(`Fetched ${Object.keys(newFlags2).length} flags (variants).`);
|
|
520
|
+
return newFlags2;
|
|
521
|
+
}
|
|
522
|
+
variantsRef.current = null;
|
|
260
523
|
const newFlags = payload && typeof payload === "object" ? payload : {};
|
|
261
524
|
logger.debug(`Fetched ${Object.keys(newFlags).length} flags.`);
|
|
262
525
|
return newFlags;
|
|
263
526
|
} catch (error) {
|
|
264
527
|
logger.warn("Failed to fetch flags:", error);
|
|
265
|
-
return
|
|
528
|
+
return flagsRef.current;
|
|
266
529
|
}
|
|
267
530
|
},
|
|
268
|
-
[mergedConfig,
|
|
531
|
+
[mergedConfig, logger]
|
|
269
532
|
);
|
|
270
533
|
const updateFlags = react.useCallback(
|
|
271
|
-
async (newFlags) => {
|
|
534
|
+
async (newFlags, generation, revision) => {
|
|
535
|
+
if (!mountedRef.current || generation !== generationRef.current || revision !== updateRevisionRef.current) return;
|
|
536
|
+
flagsRef.current = newFlags;
|
|
272
537
|
setFlags(newFlags);
|
|
273
|
-
await executeAfterRefresh(newFlags);
|
|
274
|
-
onFlagsChange?.(newFlags);
|
|
538
|
+
await executeAfterRefresh(newFlags, generation, revision);
|
|
539
|
+
if (mountedRef.current && generation === generationRef.current && revision === updateRevisionRef.current) onFlagsChange?.(newFlags);
|
|
275
540
|
},
|
|
276
541
|
[executeAfterRefresh, onFlagsChange]
|
|
277
542
|
);
|
|
278
543
|
const isEnabled = react.useCallback(
|
|
279
544
|
(featureKey, defaultValue = false, entity, kind) => {
|
|
280
|
-
|
|
545
|
+
const evaluate = captureEvaluation([featureKey]);
|
|
546
|
+
return evaluate(
|
|
281
547
|
featureKey,
|
|
282
548
|
defaultValue,
|
|
283
549
|
togglyHooksTypes.normalizeEntityContext(entity, kind)
|
|
284
550
|
);
|
|
285
551
|
},
|
|
286
|
-
[
|
|
552
|
+
[captureEvaluation]
|
|
287
553
|
);
|
|
288
554
|
const isDisabled = react.useCallback(
|
|
289
555
|
(featureKey, defaultValue = true, entity, kind) => {
|
|
@@ -296,67 +562,135 @@ function TogglyProvider({
|
|
|
296
562
|
if (featureKeys.length === 0) {
|
|
297
563
|
return !negate;
|
|
298
564
|
}
|
|
565
|
+
const selectedKeys = [...featureKeys];
|
|
566
|
+
const evaluate = captureEvaluation(selectedKeys);
|
|
299
567
|
const entityContext = togglyHooksTypes.normalizeEntityContext(entity, kind);
|
|
300
568
|
let result;
|
|
301
569
|
if (requirement === "any") {
|
|
302
|
-
result =
|
|
303
|
-
(key) =>
|
|
570
|
+
result = selectedKeys.some(
|
|
571
|
+
(key) => evaluate(key, false, entityContext)
|
|
304
572
|
);
|
|
305
573
|
} else {
|
|
306
|
-
result =
|
|
307
|
-
(key) =>
|
|
574
|
+
result = selectedKeys.every(
|
|
575
|
+
(key) => evaluate(key, false, entityContext)
|
|
308
576
|
);
|
|
309
577
|
}
|
|
310
578
|
return negate ? !result : result;
|
|
311
579
|
},
|
|
312
|
-
[
|
|
580
|
+
[captureEvaluation]
|
|
581
|
+
);
|
|
582
|
+
const getVariant = react.useCallback(
|
|
583
|
+
(featureKey) => {
|
|
584
|
+
if (!mergedConfig?.enableVariants) return null;
|
|
585
|
+
const entry = variantsRef.current?.[featureKey];
|
|
586
|
+
const enabled = togglyLocalGates.applyLocalGate(
|
|
587
|
+
entry?.enabled === true,
|
|
588
|
+
featureKey,
|
|
589
|
+
localGatesRef.current,
|
|
590
|
+
localGateIndexRef.current
|
|
591
|
+
);
|
|
592
|
+
const record = telemetry.captureCheck();
|
|
593
|
+
record(featureKey, enabled && entry?.variant ? entry.variant : "disabled");
|
|
594
|
+
if (!enabled || !entry?.variant) return null;
|
|
595
|
+
return { name: entry.variant, configurationValue: entry.configurationValue };
|
|
596
|
+
},
|
|
597
|
+
[mergedConfig?.enableVariants, localGatesRevision, telemetry, flags]
|
|
598
|
+
);
|
|
599
|
+
const getVariantValue = react.useCallback(
|
|
600
|
+
(featureKey, isT) => {
|
|
601
|
+
return decodeVariantValue(getVariant(featureKey)?.configurationValue, isT);
|
|
602
|
+
},
|
|
603
|
+
[getVariant]
|
|
313
604
|
);
|
|
605
|
+
const installContext = react.useCallback((target) => {
|
|
606
|
+
contextRef.current = target;
|
|
607
|
+
telemetry.setContext(target);
|
|
608
|
+
flagsRef.current = mergedConfig?.featureDefaults ?? {};
|
|
609
|
+
variantsRef.current = null;
|
|
610
|
+
setFlags(flagsRef.current);
|
|
611
|
+
setIdentity(target.identity);
|
|
612
|
+
}, [mergedConfig?.featureDefaults, telemetry]);
|
|
613
|
+
const isCurrentGeneration = react.useCallback((generation) => mountedRef.current && generation === generationRef.current, []);
|
|
614
|
+
const completeIdentification = react.useCallback((generation) => {
|
|
615
|
+
if (isCurrentGeneration(generation)) setIsReady(true);
|
|
616
|
+
}, [isCurrentGeneration]);
|
|
617
|
+
const currentIdentifyHooks = react.useCallback(function* (generation, reverse = false) {
|
|
618
|
+
let index = reverse ? hooks.length - 1 : 0;
|
|
619
|
+
while (reverse ? index >= 0 : index < hooks.length) {
|
|
620
|
+
if (!isCurrentGeneration(generation)) return;
|
|
621
|
+
yield hooks[index];
|
|
622
|
+
index += reverse ? -1 : 1;
|
|
623
|
+
}
|
|
624
|
+
}, [hooks, isCurrentGeneration]);
|
|
314
625
|
const identify = react.useCallback(
|
|
315
|
-
async (newIdentity,
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
);
|
|
326
|
-
}
|
|
626
|
+
async (newIdentity, context) => {
|
|
627
|
+
if (!mountedRef.current) return;
|
|
628
|
+
const generation = ++generationRef.current;
|
|
629
|
+
logger.debug("Identifying user");
|
|
630
|
+
for (const hook of currentIdentifyHooks(generation)) {
|
|
631
|
+
if (!hook.beforeIdentify) continue;
|
|
632
|
+
try {
|
|
633
|
+
await hook.beforeIdentify(newIdentity);
|
|
634
|
+
} catch (error) {
|
|
635
|
+
logger.error(`Error in hook "${hook.getMetadata().name}.beforeIdentify":`, error);
|
|
327
636
|
}
|
|
328
637
|
}
|
|
329
|
-
|
|
330
|
-
const
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
638
|
+
if (!isCurrentGeneration(generation)) return;
|
|
639
|
+
const target = {
|
|
640
|
+
identity: newIdentity,
|
|
641
|
+
instanceId: context?.instanceId?.trim() || void 0,
|
|
642
|
+
groups: [...context?.groups ?? contextRef.current.groups],
|
|
643
|
+
claims: { ...context?.claims ?? contextRef.current.claims }
|
|
644
|
+
};
|
|
645
|
+
installContext(target);
|
|
646
|
+
const revision = ++updateRevisionRef.current;
|
|
647
|
+
await updateFlags(await fetchFlags(target), generation, revision);
|
|
648
|
+
for (const hook of currentIdentifyHooks(generation, true)) {
|
|
649
|
+
if (!hook.afterIdentify) continue;
|
|
650
|
+
try {
|
|
651
|
+
await hook.afterIdentify(newIdentity, void 0);
|
|
652
|
+
} catch (error) {
|
|
653
|
+
logger.error(`Error in hook "${hook.getMetadata().name}.afterIdentify":`, error);
|
|
343
654
|
}
|
|
344
655
|
}
|
|
345
|
-
|
|
656
|
+
completeIdentification(generation);
|
|
346
657
|
},
|
|
347
|
-
[hooks, fetchFlags, updateFlags, logger]
|
|
658
|
+
[hooks, fetchFlags, updateFlags, installContext, logger, isCurrentGeneration, completeIdentification, currentIdentifyHooks]
|
|
348
659
|
);
|
|
660
|
+
useCommittedContextEffect(() => {
|
|
661
|
+
const previous = committedTargetingProps.current;
|
|
662
|
+
const groups = [...mergedConfig?.groups ?? []];
|
|
663
|
+
const claims = { ...mergedConfig?.claims };
|
|
664
|
+
const groupsChanged = previous.groups.length !== groups.length || groups.some((group, index) => group !== previous.groups[index]);
|
|
665
|
+
const claimKeys = Object.keys(claims);
|
|
666
|
+
const claimsChanged = Object.keys(previous.claims).length !== claimKeys.length || claimKeys.some((key) => claims[key] !== previous.claims[key]);
|
|
667
|
+
if (!groupsChanged && !claimsChanged) return;
|
|
668
|
+
committedTargetingProps.current = { groups, claims };
|
|
669
|
+
generationRef.current++;
|
|
670
|
+
updateRevisionRef.current++;
|
|
671
|
+
installContext({
|
|
672
|
+
...contextRef.current,
|
|
673
|
+
groups: groupsChanged ? groups : contextRef.current.groups,
|
|
674
|
+
claims: claimsChanged ? claims : contextRef.current.claims
|
|
675
|
+
});
|
|
676
|
+
setIsReady(false);
|
|
677
|
+
}, [mergedConfig?.groups, mergedConfig?.claims, installContext]);
|
|
349
678
|
const reset = react.useCallback(async () => {
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
const
|
|
353
|
-
|
|
354
|
-
|
|
679
|
+
if (!mountedRef.current) return;
|
|
680
|
+
const generation = ++generationRef.current;
|
|
681
|
+
const target = { ...contextRef.current, identity: void 0, instanceId: void 0 };
|
|
682
|
+
installContext(target);
|
|
683
|
+
const revision = ++updateRevisionRef.current;
|
|
684
|
+
await updateFlags(await fetchFlags(target), generation, revision);
|
|
685
|
+
if (mountedRef.current && generation === generationRef.current) setIsReady(true);
|
|
686
|
+
}, [fetchFlags, updateFlags, installContext]);
|
|
355
687
|
const refresh = react.useCallback(async () => {
|
|
356
|
-
|
|
357
|
-
const
|
|
358
|
-
|
|
359
|
-
|
|
688
|
+
if (!mountedRef.current) return;
|
|
689
|
+
const generation = generationRef.current;
|
|
690
|
+
const revision = ++updateRevisionRef.current;
|
|
691
|
+
await updateFlags(await fetchFlags(contextRef.current), generation, revision);
|
|
692
|
+
if (mountedRef.current && generation === generationRef.current && revision === updateRevisionRef.current) setIsReady(true);
|
|
693
|
+
}, [fetchFlags, updateFlags]);
|
|
360
694
|
const addHook = react.useCallback(
|
|
361
695
|
(hook) => {
|
|
362
696
|
setHooks((currentHooks) => {
|
|
@@ -434,6 +768,7 @@ function TogglyProvider({
|
|
|
434
768
|
if (typeof WebSocket === "undefined") {
|
|
435
769
|
return;
|
|
436
770
|
}
|
|
771
|
+
let live = true;
|
|
437
772
|
function buildWebSocketUrl() {
|
|
438
773
|
const baseUrl = mergedConfig.baseUrl;
|
|
439
774
|
const wsUrl = baseUrl.replace(/^https:\/\//, "wss://").replace(/^http:\/\//, "ws://");
|
|
@@ -443,7 +778,7 @@ function TogglyProvider({
|
|
|
443
778
|
return `${wsUrl.replace(/\/$/, "")}/${mergedConfig.appKey}/ws${query ? `?${query}` : ""}`;
|
|
444
779
|
}
|
|
445
780
|
function connect() {
|
|
446
|
-
if (wsRef.current) {
|
|
781
|
+
if (!live || !mountedRef.current || wsRef.current) {
|
|
447
782
|
return;
|
|
448
783
|
}
|
|
449
784
|
const wsUrl = buildWebSocketUrl();
|
|
@@ -452,11 +787,13 @@ function TogglyProvider({
|
|
|
452
787
|
const socket = new WebSocket(wsUrl);
|
|
453
788
|
wsRef.current = socket;
|
|
454
789
|
socket.onopen = () => {
|
|
790
|
+
if (!live || wsRef.current !== socket) return;
|
|
455
791
|
wsConnectedRef.current = true;
|
|
456
792
|
lastFallbackRefreshRef.current = Date.now();
|
|
457
793
|
logger.debug("WebSocket connected");
|
|
458
794
|
};
|
|
459
795
|
socket.onmessage = (event) => {
|
|
796
|
+
if (!live || wsRef.current !== socket) return;
|
|
460
797
|
const text = typeof event.data === "string" ? event.data : "";
|
|
461
798
|
try {
|
|
462
799
|
const msg = JSON.parse(text);
|
|
@@ -474,12 +811,14 @@ function TogglyProvider({
|
|
|
474
811
|
}
|
|
475
812
|
};
|
|
476
813
|
socket.onclose = () => {
|
|
814
|
+
if (!live || wsRef.current !== socket) return;
|
|
477
815
|
wsConnectedRef.current = false;
|
|
478
816
|
wsRef.current = null;
|
|
479
817
|
logger.debug("WebSocket disconnected, reconnecting in 5s");
|
|
480
818
|
scheduleReconnect();
|
|
481
819
|
};
|
|
482
820
|
socket.onerror = () => {
|
|
821
|
+
if (!live || wsRef.current !== socket) return;
|
|
483
822
|
logger.warn("WebSocket error");
|
|
484
823
|
};
|
|
485
824
|
} catch (error) {
|
|
@@ -499,6 +838,7 @@ function TogglyProvider({
|
|
|
499
838
|
}
|
|
500
839
|
connect();
|
|
501
840
|
return () => {
|
|
841
|
+
live = false;
|
|
502
842
|
if (wsReconnectTimerRef.current) {
|
|
503
843
|
clearTimeout(wsReconnectTimerRef.current);
|
|
504
844
|
wsReconnectTimerRef.current = null;
|
|
@@ -517,7 +857,11 @@ function TogglyProvider({
|
|
|
517
857
|
react.useEffect(() => {
|
|
518
858
|
if (!serverContext && mergedConfig?.appKey) {
|
|
519
859
|
logger.debug("No server context, initializing client-side");
|
|
520
|
-
|
|
860
|
+
const generation = generationRef.current;
|
|
861
|
+
const revision = ++updateRevisionRef.current;
|
|
862
|
+
fetchFlags(contextRef.current).then((newFlags) => {
|
|
863
|
+
if (!mountedRef.current || generation !== generationRef.current || revision !== updateRevisionRef.current) return;
|
|
864
|
+
flagsRef.current = newFlags;
|
|
521
865
|
setFlags(newFlags);
|
|
522
866
|
setIsReady(true);
|
|
523
867
|
});
|
|
@@ -525,6 +869,7 @@ function TogglyProvider({
|
|
|
525
869
|
}, []);
|
|
526
870
|
const contextValue = react.useMemo(
|
|
527
871
|
() => ({
|
|
872
|
+
...telemetry.api,
|
|
528
873
|
flags,
|
|
529
874
|
isReady,
|
|
530
875
|
identity,
|
|
@@ -532,6 +877,8 @@ function TogglyProvider({
|
|
|
532
877
|
isDisabled,
|
|
533
878
|
evaluateGate,
|
|
534
879
|
registerContext: registerContext2,
|
|
880
|
+
getVariant,
|
|
881
|
+
getVariantValue,
|
|
535
882
|
identify,
|
|
536
883
|
reset,
|
|
537
884
|
refresh,
|
|
@@ -542,6 +889,7 @@ function TogglyProvider({
|
|
|
542
889
|
subscribeLocalGatesChanged
|
|
543
890
|
}),
|
|
544
891
|
[
|
|
892
|
+
telemetry,
|
|
545
893
|
flags,
|
|
546
894
|
isReady,
|
|
547
895
|
identity,
|
|
@@ -549,6 +897,8 @@ function TogglyProvider({
|
|
|
549
897
|
isDisabled,
|
|
550
898
|
evaluateGate,
|
|
551
899
|
registerContext2,
|
|
900
|
+
getVariant,
|
|
901
|
+
getVariantValue,
|
|
552
902
|
identify,
|
|
553
903
|
reset,
|
|
554
904
|
refresh,
|
|
@@ -652,15 +1002,14 @@ function Feature({
|
|
|
652
1002
|
contextKind
|
|
653
1003
|
}) {
|
|
654
1004
|
const keys = featureKeys ?? (featureKey ? [featureKey] : []);
|
|
655
|
-
const
|
|
656
|
-
const gateEnabled = useFeatureGate(keys, requirement, false, context, contextKind);
|
|
1005
|
+
const { isEnabled, evaluateGate } = useTogglyContext();
|
|
657
1006
|
let enabled;
|
|
658
1007
|
if (keys.length === 0) {
|
|
659
1008
|
enabled = defaultValue;
|
|
660
1009
|
} else if (keys.length === 1) {
|
|
661
|
-
enabled =
|
|
1010
|
+
enabled = isEnabled(keys[0], defaultValue, context, contextKind);
|
|
662
1011
|
} else {
|
|
663
|
-
enabled =
|
|
1012
|
+
enabled = evaluateGate(keys, requirement, false, context, contextKind);
|
|
664
1013
|
}
|
|
665
1014
|
if (negate) {
|
|
666
1015
|
enabled = !enabled;
|