@ops-ai/react-router-toggly 1.0.0 → 1.1.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 +26 -0
- package/README.md +50 -0
- package/dist/client.cjs +363 -79
- package/dist/client.cjs.map +1 -1
- package/dist/client.d.cts +20 -4
- package/dist/client.d.ts +20 -4
- package/dist/client.js +364 -80
- package/dist/client.js.map +1 -1
- package/dist/server.cjs +3 -3
- package/dist/server.cjs.map +1 -1
- package/dist/server.d.cts +9 -1
- package/dist/server.d.ts +9 -1
- package/dist/server.js +3 -3
- package/dist/server.js.map +1 -1
- package/dist/telemetry/grpc.cjs +2 -2
- package/dist/telemetry/grpc.cjs.map +1 -1
- package/dist/telemetry/grpc.js +2 -2
- package/dist/telemetry/grpc.js.map +1 -1
- package/package.json +3 -2
package/dist/client.cjs
CHANGED
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
var togglyHooksTypes = require('@ops-ai/toggly-hooks-types');
|
|
4
4
|
require('@ops-ai/toggly-eval');
|
|
5
5
|
var react = require('react');
|
|
6
|
+
var togglyClientTelemetry = require('@ops-ai/toggly-client-telemetry');
|
|
7
|
+
var browser = require('@ops-ai/toggly-client-telemetry/browser');
|
|
6
8
|
var togglyLocalGates = require('@ops-ai/toggly-local-gates');
|
|
7
9
|
var jsxRuntime = require('react/jsx-runtime');
|
|
8
10
|
var reactRouter = require('react-router');
|
|
@@ -136,16 +138,188 @@ var HEADERS = {
|
|
|
136
138
|
CACHE_CONTROL: "cache-control"
|
|
137
139
|
};
|
|
138
140
|
var TOGGLY_LOADER_KEY = "__toggly";
|
|
141
|
+
function createBrowserTelemetry(config) {
|
|
142
|
+
const enabled = globalThis.window !== void 0 && typeof document !== "undefined" && !!config.appKey?.trim() && config.enableTelemetry !== false;
|
|
143
|
+
const usage = enabled && config.enableUsageTracking !== false;
|
|
144
|
+
const metrics = enabled && config.enableMetrics !== false;
|
|
145
|
+
let context = { identity: config.identity, instanceId: config.instanceId };
|
|
146
|
+
let partition = 0;
|
|
147
|
+
let active = false;
|
|
148
|
+
let disposed = false;
|
|
149
|
+
let reporter;
|
|
150
|
+
let detach;
|
|
151
|
+
let staged = /* @__PURE__ */ new Map();
|
|
152
|
+
let diagnostics = 0;
|
|
153
|
+
const diagnostic = () => {
|
|
154
|
+
if (diagnostics++ >= 10) return;
|
|
155
|
+
try {
|
|
156
|
+
config.onError?.("Frontend telemetry pre-commit event rejected.");
|
|
157
|
+
} catch {
|
|
158
|
+
}
|
|
159
|
+
};
|
|
160
|
+
const getReporter = () => {
|
|
161
|
+
if (!reporter) {
|
|
162
|
+
reporter = togglyClientTelemetry.createTelemetryReporter({
|
|
163
|
+
appKey: config.appKey,
|
|
164
|
+
environment: config.environment,
|
|
165
|
+
...context,
|
|
166
|
+
metricsBaseUrl: config.metricsBaseUrl,
|
|
167
|
+
telemetryFlushIntervalMs: config.telemetryFlushIntervalMs,
|
|
168
|
+
fetch: config.telemetryFetch,
|
|
169
|
+
onDiagnostic: (code) => {
|
|
170
|
+
try {
|
|
171
|
+
config.onError?.(code);
|
|
172
|
+
} catch {
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
});
|
|
176
|
+
detach = browser.attachBrowserLifecycle(reporter);
|
|
177
|
+
}
|
|
178
|
+
return reporter;
|
|
179
|
+
};
|
|
180
|
+
function replay(entry) {
|
|
181
|
+
const target = getReporter();
|
|
182
|
+
target.setContext(entry.context);
|
|
183
|
+
if (entry.kind === "feature") {
|
|
184
|
+
const methods = ["recordCheck", "recordUsage", "recordView"];
|
|
185
|
+
entry.values.forEach((count, index) => {
|
|
186
|
+
for (let i = 0; i < count; i++) target[methods[index]](entry.key, entry.variant);
|
|
187
|
+
});
|
|
188
|
+
} else if (entry.kind === "gauge") target.setGauge(entry.key, entry.values[0]);
|
|
189
|
+
else {
|
|
190
|
+
let remaining = entry.values[0];
|
|
191
|
+
do {
|
|
192
|
+
const value = Math.min(remaining, 1e6);
|
|
193
|
+
target.incrementCounter(entry.key, value);
|
|
194
|
+
remaining -= value;
|
|
195
|
+
} while (remaining > 0);
|
|
196
|
+
}
|
|
197
|
+
target.setContext(context);
|
|
198
|
+
}
|
|
199
|
+
function isValidEntry(entry) {
|
|
200
|
+
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)));
|
|
201
|
+
}
|
|
202
|
+
function fitsAdmissionBudget(entries, entry) {
|
|
203
|
+
let operations = 0;
|
|
204
|
+
let bytes = 0;
|
|
205
|
+
let variants = 0;
|
|
206
|
+
for (const value of entries) {
|
|
207
|
+
if (value.kind === "feature" && value.key === entry.key && value.partition === entry.partition) variants++;
|
|
208
|
+
const count = value.kind === "feature" ? value.values.reduce((a, b) => a + b, 0) : Math.max(1, Math.ceil(value.values[0] / 1e6));
|
|
209
|
+
operations += count;
|
|
210
|
+
const serialized = JSON.stringify({ k: config.appKey, e: config.environment ?? "Production", ...value });
|
|
211
|
+
const size = new TextEncoder().encode(serialized).byteLength;
|
|
212
|
+
if (size > 49152) return false;
|
|
213
|
+
bytes += size * count;
|
|
214
|
+
}
|
|
215
|
+
return entries.length <= 2e3 && operations <= 2e3 && bytes <= 262144 && variants <= 16;
|
|
216
|
+
}
|
|
217
|
+
function event(entry) {
|
|
218
|
+
if (disposed || (entry.kind === "feature" ? !usage : !metrics)) return;
|
|
219
|
+
if (!isValidEntry(entry)) {
|
|
220
|
+
diagnostic();
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
if (active) {
|
|
224
|
+
replay(entry);
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
const id = JSON.stringify([entry.partition, entry.kind === "feature" ? "f" : "m", entry.key, entry.variant]);
|
|
228
|
+
const previous = staged.get(id);
|
|
229
|
+
if (previous && previous.kind !== entry.kind) {
|
|
230
|
+
diagnostic();
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
if (previous && entry.kind !== "gauge") entry.values = entry.values.map((value, index) => value + previous.values[index]);
|
|
234
|
+
const entries = [...staged.entries()].filter(([key]) => key !== id).map(([, value]) => value);
|
|
235
|
+
entries.push(entry);
|
|
236
|
+
if (!fitsAdmissionBudget(entries, entry)) {
|
|
237
|
+
diagnostic();
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
staged.set(id, entry);
|
|
241
|
+
}
|
|
242
|
+
function feature(key, variant, index, attribution = context, capturedPartition = partition) {
|
|
243
|
+
const values = [0, 0, 0];
|
|
244
|
+
values[index] = 1;
|
|
245
|
+
event({ partition: capturedPartition, context: attribution, key, variant, kind: "feature", values });
|
|
246
|
+
}
|
|
247
|
+
const api = {
|
|
248
|
+
recordUsage: (key, variant = "enabled") => feature(key, variant, 1),
|
|
249
|
+
recordView: (key, variant = "enabled") => feature(key, variant, 2),
|
|
250
|
+
incrementCounter: (key, value = 1) => event({ partition, context, key, kind: "counter", values: [value] }),
|
|
251
|
+
setGauge: (key, value) => event({ partition, context, key, kind: "gauge", values: [value] }),
|
|
252
|
+
flushTelemetry: async () => {
|
|
253
|
+
await reporter?.flush();
|
|
254
|
+
}
|
|
255
|
+
};
|
|
256
|
+
return {
|
|
257
|
+
api,
|
|
258
|
+
setContext(next) {
|
|
259
|
+
if (disposed) return;
|
|
260
|
+
if (context.identity !== next.identity || context.instanceId !== next.instanceId) partition++;
|
|
261
|
+
context = { identity: next.identity, instanceId: next.instanceId };
|
|
262
|
+
reporter?.setContext(context);
|
|
263
|
+
},
|
|
264
|
+
captureCheck() {
|
|
265
|
+
if (disposed || !usage) return () => {
|
|
266
|
+
};
|
|
267
|
+
if (active) {
|
|
268
|
+
const record = getReporter().captureCheck();
|
|
269
|
+
return (key, result) => record(key, result ? "enabled" : "disabled");
|
|
270
|
+
}
|
|
271
|
+
const attribution = context;
|
|
272
|
+
const capturedPartition = partition;
|
|
273
|
+
return (key, result) => feature(key, result ? "enabled" : "disabled", 0, attribution, capturedPartition);
|
|
274
|
+
},
|
|
275
|
+
recordCheck: (key, result) => feature(key, result ? "enabled" : "disabled", 0),
|
|
276
|
+
activate() {
|
|
277
|
+
if (disposed || active) return;
|
|
278
|
+
active = true;
|
|
279
|
+
const pending = staged;
|
|
280
|
+
staged = /* @__PURE__ */ new Map();
|
|
281
|
+
for (const entry of pending.values()) replay(entry);
|
|
282
|
+
pending.clear();
|
|
283
|
+
},
|
|
284
|
+
dispose(options) {
|
|
285
|
+
disposed = true;
|
|
286
|
+
staged.clear();
|
|
287
|
+
detach?.();
|
|
288
|
+
reporter?.dispose(options);
|
|
289
|
+
}
|
|
290
|
+
};
|
|
291
|
+
}
|
|
139
292
|
|
|
140
293
|
// src/client/sdk-identity.ts
|
|
141
294
|
var SDK_ID = "react-router";
|
|
142
|
-
var SDK_VERSION = "1.
|
|
295
|
+
var SDK_VERSION = "1.1.0";
|
|
143
296
|
function appendSdkQueryParams(params) {
|
|
144
297
|
params.set("sdk", SDK_ID);
|
|
145
298
|
params.set("sdkVersion", SDK_VERSION);
|
|
146
299
|
}
|
|
147
300
|
var TogglyContext = react.createContext(void 0);
|
|
148
|
-
|
|
301
|
+
var transportIds = /* @__PURE__ */ new WeakMap();
|
|
302
|
+
var nextTransportId = 0;
|
|
303
|
+
function transportId(value) {
|
|
304
|
+
if (!value) return 0;
|
|
305
|
+
let id = transportIds.get(value);
|
|
306
|
+
if (id === void 0) {
|
|
307
|
+
id = ++nextTransportId;
|
|
308
|
+
transportIds.set(value, id);
|
|
309
|
+
}
|
|
310
|
+
return id;
|
|
311
|
+
}
|
|
312
|
+
var useCommittedContextEffect = globalThis.window === void 0 ? react.useEffect : react.useLayoutEffect;
|
|
313
|
+
function TogglyProvider(props) {
|
|
314
|
+
const config = props.config ?? (props.serverContext ? { appKey: props.serverContext.appKey, environment: props.serverContext.environment } : void 0);
|
|
315
|
+
const committedOwner = react.useRef();
|
|
316
|
+
const ownerKey = JSON.stringify([transportId(config?.telemetryFetch), config?.appKey ?? "", config?.environment ?? "Production", config?.enableTelemetry, config?.enableUsageTracking, config?.enableMetrics, config?.metricsBaseUrl, config?.telemetryFlushIntervalMs]);
|
|
317
|
+
const snapshot = props.serverContext;
|
|
318
|
+
const matchesOwner = (!snapshot?.appKey || snapshot.appKey === config?.appKey) && (!snapshot?.environment || snapshot.environment === (config?.environment ?? "Production"));
|
|
319
|
+
return /* @__PURE__ */ jsxRuntime.jsx(TogglyProviderOwner, { ...props, committedOwner, config, serverContext: matchesOwner ? snapshot : void 0 }, ownerKey);
|
|
320
|
+
}
|
|
321
|
+
function TogglyProviderOwner({
|
|
322
|
+
committedOwner,
|
|
149
323
|
children,
|
|
150
324
|
serverContext,
|
|
151
325
|
config,
|
|
@@ -161,6 +335,47 @@ function TogglyProvider({
|
|
|
161
335
|
() => createLogger(mergedConfig?.debug ?? false),
|
|
162
336
|
[mergedConfig?.debug]
|
|
163
337
|
);
|
|
338
|
+
const contextRef = react.useRef({
|
|
339
|
+
identity: serverContext ? serverContext.identity : mergedConfig?.identity,
|
|
340
|
+
instanceId: mergedConfig?.instanceId?.trim() || void 0,
|
|
341
|
+
groups: [...mergedConfig?.groups ?? []],
|
|
342
|
+
claims: { ...mergedConfig?.claims }
|
|
343
|
+
});
|
|
344
|
+
const committedTargetingProps = react.useRef({
|
|
345
|
+
groups: [...mergedConfig?.groups ?? []],
|
|
346
|
+
claims: { ...mergedConfig?.claims }
|
|
347
|
+
});
|
|
348
|
+
const flagsRef = react.useRef(serverContext?.flags ?? mergedConfig?.featureDefaults ?? {});
|
|
349
|
+
const generationRef = react.useRef(0);
|
|
350
|
+
const updateRevisionRef = react.useRef(0);
|
|
351
|
+
const [telemetry] = react.useState(() => createBrowserTelemetry({ ...config, ...contextRef.current }));
|
|
352
|
+
const mountedRef = react.useRef(true);
|
|
353
|
+
react.useEffect(() => {
|
|
354
|
+
mountedRef.current = true;
|
|
355
|
+
const transport = JSON.stringify([
|
|
356
|
+
config?.metricsBaseUrl ?? "https://metrics.toggly.io",
|
|
357
|
+
config?.telemetryFlushIntervalMs ?? 45e3,
|
|
358
|
+
config?.enableTelemetry !== false,
|
|
359
|
+
config?.enableUsageTracking !== false,
|
|
360
|
+
config?.enableMetrics !== false
|
|
361
|
+
]);
|
|
362
|
+
const previous = committedOwner.current;
|
|
363
|
+
if (previous && previous.telemetry !== telemetry) {
|
|
364
|
+
previous.telemetry.dispose({ flush: previous.transport === transport && previous.fetch === config?.telemetryFetch });
|
|
365
|
+
}
|
|
366
|
+
committedOwner.current = { telemetry, transport, fetch: config?.telemetryFetch };
|
|
367
|
+
telemetry.activate();
|
|
368
|
+
return () => {
|
|
369
|
+
mountedRef.current = false;
|
|
370
|
+
generationRef.current++;
|
|
371
|
+
queueMicrotask(() => {
|
|
372
|
+
if (!mountedRef.current) {
|
|
373
|
+
if (committedOwner.current?.telemetry === telemetry) committedOwner.current = void 0;
|
|
374
|
+
telemetry.dispose();
|
|
375
|
+
}
|
|
376
|
+
});
|
|
377
|
+
};
|
|
378
|
+
}, [telemetry]);
|
|
164
379
|
const [flags, setFlags] = react.useState(
|
|
165
380
|
serverContext?.flags ?? mergedConfig?.featureDefaults ?? {}
|
|
166
381
|
);
|
|
@@ -175,23 +390,24 @@ function TogglyProvider({
|
|
|
175
390
|
togglyLocalGates.buildFlagGateIndex(localGatesRef.current)
|
|
176
391
|
);
|
|
177
392
|
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
|
-
|
|
393
|
+
const captureEvaluation = react.useCallback((featureKeys) => {
|
|
394
|
+
const selectedKeys = new Set(featureKeys);
|
|
395
|
+
const selectedFlags = Object.fromEntries([...selectedKeys].map((key) => [key, flagsRef.current[key]]));
|
|
396
|
+
const capturedFlags = JSON.parse(JSON.stringify(selectedFlags));
|
|
397
|
+
const applicableGates = localGatesRef.current.filter((gate) => gate.flagKeys.some((key) => selectedKeys.has(key)));
|
|
398
|
+
const index = togglyLocalGates.buildFlagGateIndex(applicableGates);
|
|
399
|
+
const gates = [...new Set(index.values())].map((id) => {
|
|
400
|
+
const gate = localGatesRef.current.find((candidate) => candidate.id === id);
|
|
401
|
+
return { ...gate, flagKeys: [...gate.flagKeys] };
|
|
402
|
+
});
|
|
403
|
+
const record = telemetry.captureCheck();
|
|
404
|
+
return (featureKey, defaultValue = false, entityContext) => {
|
|
405
|
+
const remote = isFeatureEnabled(capturedFlags, featureKey, defaultValue, entityContext);
|
|
406
|
+
const result = togglyLocalGates.applyLocalGate(remote, featureKey, gates, index);
|
|
407
|
+
record(featureKey, result);
|
|
408
|
+
return result;
|
|
409
|
+
};
|
|
410
|
+
}, [flags, localGatesRevision, telemetry]);
|
|
195
411
|
const registerContext2 = react.useCallback(
|
|
196
412
|
(kind, mapper) => {
|
|
197
413
|
togglyHooksTypes.registerContext(kind, mapper);
|
|
@@ -227,8 +443,9 @@ function TogglyProvider({
|
|
|
227
443
|
}
|
|
228
444
|
}, [mergedConfig?.localGates, setLocalGates]);
|
|
229
445
|
const executeAfterRefresh = react.useCallback(
|
|
230
|
-
async (newFlags) => {
|
|
446
|
+
async (newFlags, generation, revision) => {
|
|
231
447
|
for (const hook of hooks) {
|
|
448
|
+
if (!mountedRef.current || generation !== generationRef.current || revision !== updateRevisionRef.current) return;
|
|
232
449
|
if (hook.afterRefresh) {
|
|
233
450
|
try {
|
|
234
451
|
await hook.afterRefresh(newFlags);
|
|
@@ -244,14 +461,23 @@ function TogglyProvider({
|
|
|
244
461
|
[hooks, logger]
|
|
245
462
|
);
|
|
246
463
|
const fetchFlags = react.useCallback(
|
|
247
|
-
async (
|
|
464
|
+
async (target = contextRef.current) => {
|
|
465
|
+
if (!mountedRef.current) return flagsRef.current;
|
|
248
466
|
if (!mergedConfig?.appKey) {
|
|
249
467
|
logger.debug("No appKey, using current flags.");
|
|
250
|
-
return
|
|
468
|
+
return flagsRef.current;
|
|
251
469
|
}
|
|
252
470
|
try {
|
|
253
|
-
const
|
|
254
|
-
|
|
471
|
+
const parsedUrl = new URL(buildDefinitionsUrl(mergedConfig, target));
|
|
472
|
+
if (target.instanceId) {
|
|
473
|
+
const capturedKeys = [...parsedUrl.searchParams.keys()];
|
|
474
|
+
for (const key of capturedKeys) {
|
|
475
|
+
if (key === "u" || key === "userId" || key === "g" || key.startsWith("claim.")) parsedUrl.searchParams.delete(key);
|
|
476
|
+
}
|
|
477
|
+
parsedUrl.searchParams.set("i", target.instanceId);
|
|
478
|
+
}
|
|
479
|
+
const url = parsedUrl.toString();
|
|
480
|
+
logger.debug("Fetching feature flags");
|
|
255
481
|
const response = await fetchWithTimeout(url, {}, mergedConfig.timeout);
|
|
256
482
|
if (!response.ok) {
|
|
257
483
|
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
|
@@ -262,28 +488,31 @@ function TogglyProvider({
|
|
|
262
488
|
return newFlags;
|
|
263
489
|
} catch (error) {
|
|
264
490
|
logger.warn("Failed to fetch flags:", error);
|
|
265
|
-
return
|
|
491
|
+
return flagsRef.current;
|
|
266
492
|
}
|
|
267
493
|
},
|
|
268
|
-
[mergedConfig,
|
|
494
|
+
[mergedConfig, logger]
|
|
269
495
|
);
|
|
270
496
|
const updateFlags = react.useCallback(
|
|
271
|
-
async (newFlags) => {
|
|
497
|
+
async (newFlags, generation, revision) => {
|
|
498
|
+
if (!mountedRef.current || generation !== generationRef.current || revision !== updateRevisionRef.current) return;
|
|
499
|
+
flagsRef.current = newFlags;
|
|
272
500
|
setFlags(newFlags);
|
|
273
|
-
await executeAfterRefresh(newFlags);
|
|
274
|
-
onFlagsChange?.(newFlags);
|
|
501
|
+
await executeAfterRefresh(newFlags, generation, revision);
|
|
502
|
+
if (mountedRef.current && generation === generationRef.current && revision === updateRevisionRef.current) onFlagsChange?.(newFlags);
|
|
275
503
|
},
|
|
276
504
|
[executeAfterRefresh, onFlagsChange]
|
|
277
505
|
);
|
|
278
506
|
const isEnabled = react.useCallback(
|
|
279
507
|
(featureKey, defaultValue = false, entity, kind) => {
|
|
280
|
-
|
|
508
|
+
const evaluate = captureEvaluation([featureKey]);
|
|
509
|
+
return evaluate(
|
|
281
510
|
featureKey,
|
|
282
511
|
defaultValue,
|
|
283
512
|
togglyHooksTypes.normalizeEntityContext(entity, kind)
|
|
284
513
|
);
|
|
285
514
|
},
|
|
286
|
-
[
|
|
515
|
+
[captureEvaluation]
|
|
287
516
|
);
|
|
288
517
|
const isDisabled = react.useCallback(
|
|
289
518
|
(featureKey, defaultValue = true, entity, kind) => {
|
|
@@ -296,67 +525,111 @@ function TogglyProvider({
|
|
|
296
525
|
if (featureKeys.length === 0) {
|
|
297
526
|
return !negate;
|
|
298
527
|
}
|
|
528
|
+
const selectedKeys = [...featureKeys];
|
|
529
|
+
const evaluate = captureEvaluation(selectedKeys);
|
|
299
530
|
const entityContext = togglyHooksTypes.normalizeEntityContext(entity, kind);
|
|
300
531
|
let result;
|
|
301
532
|
if (requirement === "any") {
|
|
302
|
-
result =
|
|
303
|
-
(key) =>
|
|
533
|
+
result = selectedKeys.some(
|
|
534
|
+
(key) => evaluate(key, false, entityContext)
|
|
304
535
|
);
|
|
305
536
|
} else {
|
|
306
|
-
result =
|
|
307
|
-
(key) =>
|
|
537
|
+
result = selectedKeys.every(
|
|
538
|
+
(key) => evaluate(key, false, entityContext)
|
|
308
539
|
);
|
|
309
540
|
}
|
|
310
541
|
return negate ? !result : result;
|
|
311
542
|
},
|
|
312
|
-
[
|
|
543
|
+
[captureEvaluation]
|
|
313
544
|
);
|
|
545
|
+
const installContext = react.useCallback((target) => {
|
|
546
|
+
contextRef.current = target;
|
|
547
|
+
telemetry.setContext(target);
|
|
548
|
+
flagsRef.current = mergedConfig?.featureDefaults ?? {};
|
|
549
|
+
setFlags(flagsRef.current);
|
|
550
|
+
setIdentity(target.identity);
|
|
551
|
+
}, [mergedConfig?.featureDefaults, telemetry]);
|
|
552
|
+
const isCurrentGeneration = react.useCallback((generation) => mountedRef.current && generation === generationRef.current, []);
|
|
553
|
+
const completeIdentification = react.useCallback((generation) => {
|
|
554
|
+
if (isCurrentGeneration(generation)) setIsReady(true);
|
|
555
|
+
}, [isCurrentGeneration]);
|
|
556
|
+
const currentIdentifyHooks = react.useCallback(function* (generation, reverse = false) {
|
|
557
|
+
let index = reverse ? hooks.length - 1 : 0;
|
|
558
|
+
while (reverse ? index >= 0 : index < hooks.length) {
|
|
559
|
+
if (!isCurrentGeneration(generation)) return;
|
|
560
|
+
yield hooks[index];
|
|
561
|
+
index += reverse ? -1 : 1;
|
|
562
|
+
}
|
|
563
|
+
}, [hooks, isCurrentGeneration]);
|
|
314
564
|
const identify = react.useCallback(
|
|
315
|
-
async (newIdentity,
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
);
|
|
326
|
-
}
|
|
565
|
+
async (newIdentity, context) => {
|
|
566
|
+
if (!mountedRef.current) return;
|
|
567
|
+
const generation = ++generationRef.current;
|
|
568
|
+
logger.debug("Identifying user");
|
|
569
|
+
for (const hook of currentIdentifyHooks(generation)) {
|
|
570
|
+
if (!hook.beforeIdentify) continue;
|
|
571
|
+
try {
|
|
572
|
+
await hook.beforeIdentify(newIdentity);
|
|
573
|
+
} catch (error) {
|
|
574
|
+
logger.error(`Error in hook "${hook.getMetadata().name}.beforeIdentify":`, error);
|
|
327
575
|
}
|
|
328
576
|
}
|
|
329
|
-
|
|
330
|
-
const
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
577
|
+
if (!isCurrentGeneration(generation)) return;
|
|
578
|
+
const target = {
|
|
579
|
+
identity: newIdentity,
|
|
580
|
+
instanceId: context?.instanceId?.trim() || void 0,
|
|
581
|
+
groups: [...context?.groups ?? contextRef.current.groups],
|
|
582
|
+
claims: { ...context?.claims ?? contextRef.current.claims }
|
|
583
|
+
};
|
|
584
|
+
installContext(target);
|
|
585
|
+
const revision = ++updateRevisionRef.current;
|
|
586
|
+
await updateFlags(await fetchFlags(target), generation, revision);
|
|
587
|
+
for (const hook of currentIdentifyHooks(generation, true)) {
|
|
588
|
+
if (!hook.afterIdentify) continue;
|
|
589
|
+
try {
|
|
590
|
+
await hook.afterIdentify(newIdentity, void 0);
|
|
591
|
+
} catch (error) {
|
|
592
|
+
logger.error(`Error in hook "${hook.getMetadata().name}.afterIdentify":`, error);
|
|
343
593
|
}
|
|
344
594
|
}
|
|
345
|
-
|
|
595
|
+
completeIdentification(generation);
|
|
346
596
|
},
|
|
347
|
-
[hooks, fetchFlags, updateFlags, logger]
|
|
597
|
+
[hooks, fetchFlags, updateFlags, installContext, logger, isCurrentGeneration, completeIdentification, currentIdentifyHooks]
|
|
348
598
|
);
|
|
599
|
+
useCommittedContextEffect(() => {
|
|
600
|
+
const previous = committedTargetingProps.current;
|
|
601
|
+
const groups = [...mergedConfig?.groups ?? []];
|
|
602
|
+
const claims = { ...mergedConfig?.claims };
|
|
603
|
+
const groupsChanged = previous.groups.length !== groups.length || groups.some((group, index) => group !== previous.groups[index]);
|
|
604
|
+
const claimKeys = Object.keys(claims);
|
|
605
|
+
const claimsChanged = Object.keys(previous.claims).length !== claimKeys.length || claimKeys.some((key) => claims[key] !== previous.claims[key]);
|
|
606
|
+
if (!groupsChanged && !claimsChanged) return;
|
|
607
|
+
committedTargetingProps.current = { groups, claims };
|
|
608
|
+
generationRef.current++;
|
|
609
|
+
updateRevisionRef.current++;
|
|
610
|
+
installContext({
|
|
611
|
+
...contextRef.current,
|
|
612
|
+
groups: groupsChanged ? groups : contextRef.current.groups,
|
|
613
|
+
claims: claimsChanged ? claims : contextRef.current.claims
|
|
614
|
+
});
|
|
615
|
+
setIsReady(false);
|
|
616
|
+
}, [mergedConfig?.groups, mergedConfig?.claims, installContext]);
|
|
349
617
|
const reset = react.useCallback(async () => {
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
const
|
|
353
|
-
|
|
354
|
-
|
|
618
|
+
if (!mountedRef.current) return;
|
|
619
|
+
const generation = ++generationRef.current;
|
|
620
|
+
const target = { ...contextRef.current, identity: void 0, instanceId: void 0 };
|
|
621
|
+
installContext(target);
|
|
622
|
+
const revision = ++updateRevisionRef.current;
|
|
623
|
+
await updateFlags(await fetchFlags(target), generation, revision);
|
|
624
|
+
if (mountedRef.current && generation === generationRef.current) setIsReady(true);
|
|
625
|
+
}, [fetchFlags, updateFlags, installContext]);
|
|
355
626
|
const refresh = react.useCallback(async () => {
|
|
356
|
-
|
|
357
|
-
const
|
|
358
|
-
|
|
359
|
-
|
|
627
|
+
if (!mountedRef.current) return;
|
|
628
|
+
const generation = generationRef.current;
|
|
629
|
+
const revision = ++updateRevisionRef.current;
|
|
630
|
+
await updateFlags(await fetchFlags(contextRef.current), generation, revision);
|
|
631
|
+
if (mountedRef.current && generation === generationRef.current && revision === updateRevisionRef.current) setIsReady(true);
|
|
632
|
+
}, [fetchFlags, updateFlags]);
|
|
360
633
|
const addHook = react.useCallback(
|
|
361
634
|
(hook) => {
|
|
362
635
|
setHooks((currentHooks) => {
|
|
@@ -434,6 +707,7 @@ function TogglyProvider({
|
|
|
434
707
|
if (typeof WebSocket === "undefined") {
|
|
435
708
|
return;
|
|
436
709
|
}
|
|
710
|
+
let live = true;
|
|
437
711
|
function buildWebSocketUrl() {
|
|
438
712
|
const baseUrl = mergedConfig.baseUrl;
|
|
439
713
|
const wsUrl = baseUrl.replace(/^https:\/\//, "wss://").replace(/^http:\/\//, "ws://");
|
|
@@ -443,7 +717,7 @@ function TogglyProvider({
|
|
|
443
717
|
return `${wsUrl.replace(/\/$/, "")}/${mergedConfig.appKey}/ws${query ? `?${query}` : ""}`;
|
|
444
718
|
}
|
|
445
719
|
function connect() {
|
|
446
|
-
if (wsRef.current) {
|
|
720
|
+
if (!live || !mountedRef.current || wsRef.current) {
|
|
447
721
|
return;
|
|
448
722
|
}
|
|
449
723
|
const wsUrl = buildWebSocketUrl();
|
|
@@ -452,11 +726,13 @@ function TogglyProvider({
|
|
|
452
726
|
const socket = new WebSocket(wsUrl);
|
|
453
727
|
wsRef.current = socket;
|
|
454
728
|
socket.onopen = () => {
|
|
729
|
+
if (!live || wsRef.current !== socket) return;
|
|
455
730
|
wsConnectedRef.current = true;
|
|
456
731
|
lastFallbackRefreshRef.current = Date.now();
|
|
457
732
|
logger.debug("WebSocket connected");
|
|
458
733
|
};
|
|
459
734
|
socket.onmessage = (event) => {
|
|
735
|
+
if (!live || wsRef.current !== socket) return;
|
|
460
736
|
const text = typeof event.data === "string" ? event.data : "";
|
|
461
737
|
try {
|
|
462
738
|
const msg = JSON.parse(text);
|
|
@@ -474,12 +750,14 @@ function TogglyProvider({
|
|
|
474
750
|
}
|
|
475
751
|
};
|
|
476
752
|
socket.onclose = () => {
|
|
753
|
+
if (!live || wsRef.current !== socket) return;
|
|
477
754
|
wsConnectedRef.current = false;
|
|
478
755
|
wsRef.current = null;
|
|
479
756
|
logger.debug("WebSocket disconnected, reconnecting in 5s");
|
|
480
757
|
scheduleReconnect();
|
|
481
758
|
};
|
|
482
759
|
socket.onerror = () => {
|
|
760
|
+
if (!live || wsRef.current !== socket) return;
|
|
483
761
|
logger.warn("WebSocket error");
|
|
484
762
|
};
|
|
485
763
|
} catch (error) {
|
|
@@ -499,6 +777,7 @@ function TogglyProvider({
|
|
|
499
777
|
}
|
|
500
778
|
connect();
|
|
501
779
|
return () => {
|
|
780
|
+
live = false;
|
|
502
781
|
if (wsReconnectTimerRef.current) {
|
|
503
782
|
clearTimeout(wsReconnectTimerRef.current);
|
|
504
783
|
wsReconnectTimerRef.current = null;
|
|
@@ -517,7 +796,11 @@ function TogglyProvider({
|
|
|
517
796
|
react.useEffect(() => {
|
|
518
797
|
if (!serverContext && mergedConfig?.appKey) {
|
|
519
798
|
logger.debug("No server context, initializing client-side");
|
|
520
|
-
|
|
799
|
+
const generation = generationRef.current;
|
|
800
|
+
const revision = ++updateRevisionRef.current;
|
|
801
|
+
fetchFlags(contextRef.current).then((newFlags) => {
|
|
802
|
+
if (!mountedRef.current || generation !== generationRef.current || revision !== updateRevisionRef.current) return;
|
|
803
|
+
flagsRef.current = newFlags;
|
|
521
804
|
setFlags(newFlags);
|
|
522
805
|
setIsReady(true);
|
|
523
806
|
});
|
|
@@ -525,6 +808,7 @@ function TogglyProvider({
|
|
|
525
808
|
}, []);
|
|
526
809
|
const contextValue = react.useMemo(
|
|
527
810
|
() => ({
|
|
811
|
+
...telemetry.api,
|
|
528
812
|
flags,
|
|
529
813
|
isReady,
|
|
530
814
|
identity,
|
|
@@ -542,6 +826,7 @@ function TogglyProvider({
|
|
|
542
826
|
subscribeLocalGatesChanged
|
|
543
827
|
}),
|
|
544
828
|
[
|
|
829
|
+
telemetry,
|
|
545
830
|
flags,
|
|
546
831
|
isReady,
|
|
547
832
|
identity,
|
|
@@ -652,15 +937,14 @@ function Feature({
|
|
|
652
937
|
contextKind
|
|
653
938
|
}) {
|
|
654
939
|
const keys = featureKeys ?? (featureKey ? [featureKey] : []);
|
|
655
|
-
const
|
|
656
|
-
const gateEnabled = useFeatureGate(keys, requirement, false, context, contextKind);
|
|
940
|
+
const { isEnabled, evaluateGate } = useTogglyContext();
|
|
657
941
|
let enabled;
|
|
658
942
|
if (keys.length === 0) {
|
|
659
943
|
enabled = defaultValue;
|
|
660
944
|
} else if (keys.length === 1) {
|
|
661
|
-
enabled =
|
|
945
|
+
enabled = isEnabled(keys[0], defaultValue, context, contextKind);
|
|
662
946
|
} else {
|
|
663
|
-
enabled =
|
|
947
|
+
enabled = evaluateGate(keys, requirement, false, context, contextKind);
|
|
664
948
|
}
|
|
665
949
|
if (negate) {
|
|
666
950
|
enabled = !enabled;
|