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