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