@traffical/js-client 0.14.0 → 0.16.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/dist/client.js CHANGED
@@ -9,7 +9,7 @@
9
9
  * - Plugin system (P2)
10
10
  * - Auto stable ID for anonymous users
11
11
  */
12
- import { resolveParameters, decide as coreDecide, getUnitKeyValue, generateExposureId, generateTrackEventId, generateDecisionId, generateAssignmentId, } from "@traffical/core";
12
+ import { resolveParameters, decide as coreDecide, getUnitKeyValue, getUnitKeyField as coreGetUnitKeyField, getParameterLayerId as coreGetParameterLayerId, generateExposureId, generateTrackEventId, generateDecisionId, generateAssignmentId, } from "@traffical/core";
13
13
  import { DecisionClient, createEdgeDecideRequest, } from "@traffical/core-io";
14
14
  import { ErrorBoundary } from "./error-boundary.js";
15
15
  import { EventLogger } from "./event-logger.js";
@@ -23,10 +23,59 @@ import { SDK_VERSION } from "./version.js";
23
23
  // Constants
24
24
  // =============================================================================
25
25
  const SDK_NAME = "js-client";
26
+ /**
27
+ * Normalizes evaluation-method arguments so both the canonical positional form
28
+ * `decide(context, defaults)` and the legacy object-bag form
29
+ * `decide({ context, defaults })` work. Positional is detected by the presence
30
+ * of the second (`defaults`) argument; the bag form is soft-deprecated.
31
+ */
32
+ function normalizeEvalArgs(contextOrOptions, maybeDefaults) {
33
+ if (maybeDefaults !== undefined) {
34
+ return { context: contextOrOptions, defaults: maybeDefaults };
35
+ }
36
+ const bag = contextOrOptions;
37
+ return { context: bag.context, defaults: bag.defaults };
38
+ }
26
39
  const DEFAULT_BASE_URL = "https://sdk.traffical.io";
27
40
  const DEFAULT_REFRESH_INTERVAL_MS = 60000; // 1 minute
41
+ const DEFAULT_REQUEST_TIMEOUT_MS = 10000; // 10 seconds
28
42
  const OFFLINE_WARNING_INTERVAL_MS = 300000; // 5 minutes
43
+ const MALFORMED_BUNDLE_WARNING_INTERVAL_MS = 300000; // 5 minutes
29
44
  const DECISION_CACHE_MAX_SIZE = 100; // Max decisions to cache for attribution lookup
45
+ /** +/-10% jitter on the background refresh interval to avoid thundering-herd sync. */
46
+ const REFRESH_JITTER_RATIO = 0.1;
47
+ /** Returns `intervalMs` perturbed by uniform +/-REFRESH_JITTER_RATIO jitter. */
48
+ function jitteredInterval(intervalMs) {
49
+ const delta = intervalMs * REFRESH_JITTER_RATIO;
50
+ return intervalMs + (Math.random() * 2 - 1) * delta;
51
+ }
52
+ /**
53
+ * Structural guard for a fetched config bundle. A 200 response can still carry a
54
+ * malformed body (truncated CDN write, partial deploy); serving it would corrupt
55
+ * every bucket assignment. Requires the hashing config the resolver depends on
56
+ * (`unitKey` non-empty, `bucketCount` an integer >= 1) plus the top-level
57
+ * parameters/layers arrays.
58
+ */
59
+ function isValidConfigBundle(bundle) {
60
+ if (!bundle || typeof bundle !== "object")
61
+ return false;
62
+ const b = bundle;
63
+ if (!Array.isArray(b.parameters))
64
+ return false;
65
+ if (!Array.isArray(b.layers))
66
+ return false;
67
+ const hashing = b.hashing;
68
+ if (!hashing || typeof hashing !== "object")
69
+ return false;
70
+ if (typeof hashing.unitKey !== "string" || hashing.unitKey.length === 0)
71
+ return false;
72
+ if (typeof hashing.bucketCount !== "number" ||
73
+ !Number.isInteger(hashing.bucketCount) ||
74
+ hashing.bucketCount < 1) {
75
+ return false;
76
+ }
77
+ return true;
78
+ }
30
79
  // =============================================================================
31
80
  // TrafficalClient Class
32
81
  // =============================================================================
@@ -37,6 +86,7 @@ export class TrafficalClient {
37
86
  etag: null,
38
87
  lastFetchTime: 0,
39
88
  lastOfflineWarning: 0,
89
+ lastMalformedWarning: 0,
40
90
  refreshTimer: null,
41
91
  isInitialized: false,
42
92
  serverResponse: null,
@@ -55,6 +105,11 @@ export class TrafficalClient {
55
105
  this._identityListeners = [];
56
106
  this._overrideListeners = [];
57
107
  this._overrides = {};
108
+ /** Serialized context of the last server-mode resolve (per-call throttle). */
109
+ this._lastResolveContextKey = null;
110
+ this._readyPromise = new Promise((resolve) => {
111
+ this._readyResolve = resolve;
112
+ });
58
113
  const evaluationMode = options.evaluationMode ?? "bundle";
59
114
  this._options = {
60
115
  orgId: options.orgId,
@@ -67,6 +122,9 @@ export class TrafficalClient {
67
122
  attributionMode: options.attributionMode ?? "cumulative",
68
123
  evaluationMode,
69
124
  };
125
+ // Config-fetch timeout: canonical configTimeoutMs wins, else legacy requestTimeoutMs.
126
+ this._requestTimeoutMs =
127
+ options.configTimeoutMs ?? options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
70
128
  // Create DecisionClient when needed (server mode, or bundle mode may use for edge policies)
71
129
  const decisionClientConfig = {
72
130
  baseUrl: this._options.baseUrl,
@@ -74,6 +132,9 @@ export class TrafficalClient {
74
132
  projectId: this._options.projectId,
75
133
  env: this._options.env,
76
134
  apiKey: this._options.apiKey,
135
+ // Server-resolve timeout: canonical resolveTimeoutMs wins, else legacy
136
+ // requestTimeoutMs; undefined lets DecisionClient apply its own 5s default.
137
+ defaultTimeoutMs: options.resolveTimeoutMs ?? options.requestTimeoutMs,
77
138
  };
78
139
  this._decisionClient = new DecisionClient(decisionClientConfig);
79
140
  // Initialize components
@@ -102,8 +163,12 @@ export class TrafficalClient {
102
163
  apiKey: options.apiKey,
103
164
  storage: this._storage,
104
165
  lifecycleProvider: this._lifecycleProvider,
105
- batchSize: options.eventBatchSize,
106
- flushIntervalMs: options.eventFlushIntervalMs,
166
+ // Canonical batchSize/flushIntervalMs win over legacy event* names.
167
+ batchSize: options.batchSize ?? options.eventBatchSize,
168
+ flushIntervalMs: options.flushIntervalMs ?? options.eventFlushIntervalMs,
169
+ maxQueueSize: options.eventMaxQueueSize,
170
+ // Event-delivery timeout: canonical eventsTimeoutMs wins, else legacy requestTimeoutMs.
171
+ requestTimeoutMs: options.eventsTimeoutMs ?? options.requestTimeoutMs,
107
172
  onError: (error) => {
108
173
  console.warn("[Traffical] Event logging error:", error.message);
109
174
  },
@@ -133,6 +198,7 @@ export class TrafficalClient {
133
198
  projectId: this._options.projectId,
134
199
  env: this._options.env,
135
200
  log: (event) => this._dispatchEvent(event),
201
+ getConfigVersion: () => this.getConfigVersion(),
136
202
  }),
137
203
  priority: 100, // High priority so it runs before user plugins
138
204
  });
@@ -175,6 +241,8 @@ export class TrafficalClient {
175
241
  // Run plugin onInitialize hooks (pass client reference for autonomous plugins)
176
242
  await this._plugins.runInitialize(this);
177
243
  }, undefined);
244
+ // Fail-open: resolve readiness even if the config load errored/degraded.
245
+ this._readyResolve();
178
246
  }
179
247
  /**
180
248
  * Check if the client is initialized.
@@ -182,8 +250,51 @@ export class TrafficalClient {
182
250
  get isInitialized() {
183
251
  return this._state.isInitialized;
184
252
  }
253
+ /**
254
+ * Resolves once the first usable config has loaded (or the SDK has failed
255
+ * open on an unavailable/malformed bundle). Never rejects.
256
+ */
257
+ async waitForReady() {
258
+ return this._readyPromise;
259
+ }
260
+ /**
261
+ * Single teardown verb (spec 0.7.0 design contract). Awaits a final event
262
+ * flush before returning; on page unload it falls back to sendBeacon so the
263
+ * final batch still ships. Prefer this over destroy()/destroySync().
264
+ */
265
+ async close() {
266
+ if (this._state.refreshTimer) {
267
+ clearInterval(this._state.refreshTimer);
268
+ this._state.refreshTimer = null;
269
+ }
270
+ // Await the final flush in the normal path; fall back to a beacon on unload
271
+ // (a normal async flush can't complete as the page tears down).
272
+ if (this._lifecycleProvider.isUnloading()) {
273
+ this._eventLogger.flushBeacon();
274
+ }
275
+ else {
276
+ await this._eventLogger.flush().catch(() => { });
277
+ }
278
+ this._eventLogger.destroy();
279
+ this._plugins.runDestroy();
280
+ this._identityListeners = [];
281
+ this._overrideListeners = [];
282
+ this._overrides = {};
283
+ if (typeof window !== "undefined") {
284
+ const w = window;
285
+ const instances = w.__TRAFFICAL_INSTANCES__;
286
+ if (instances) {
287
+ const idx = instances.indexOf(this);
288
+ if (idx !== -1)
289
+ instances.splice(idx, 1);
290
+ }
291
+ }
292
+ }
185
293
  /**
186
294
  * Stops background refresh and cleans up resources.
295
+ *
296
+ * @deprecated Use {@link close} instead — the canonical single teardown verb
297
+ * that awaits the final flush.
187
298
  */
188
299
  destroy() {
189
300
  if (this._state.refreshTimer) {
@@ -236,17 +347,28 @@ export class TrafficalClient {
236
347
  getConfigVersion() {
237
348
  return this._state.serverResponse?.stateVersion ?? this._state.bundle?.version ?? null;
238
349
  }
239
- // ===========================================================================
240
- // Parameter Resolution
241
- // ===========================================================================
242
350
  /**
243
- * Resolves parameters with defaults as fallback.
351
+ * Returns the context field the bundle buckets on (the project's unit key),
352
+ * or null before the bundle has loaded. Adapters (e.g. an OpenFeature
353
+ * provider) map their targeting key onto this field.
354
+ */
355
+ getUnitKeyField() {
356
+ return coreGetUnitKeyField(this._getEffectiveBundle());
357
+ }
358
+ /**
359
+ * Returns the id of the layer a parameter belongs to, or null if the
360
+ * parameter is unknown / the bundle is not yet loaded.
244
361
  */
245
- getParams(options) {
362
+ getParameterLayerId(key) {
363
+ return coreGetParameterLayerId(this._getEffectiveBundle(), key);
364
+ }
365
+ getParams(contextOrOptions, maybeDefaults) {
366
+ const { context: rawContext, defaults } = normalizeEvalArgs(contextOrOptions, maybeDefaults);
246
367
  return this._errorBoundary.capture("getParams", () => {
247
368
  // Server mode: return from cached server response
248
369
  if (this._options.evaluationMode === "server" && this._state.serverResponse) {
249
- const result = { ...options.defaults };
370
+ this._maybeResolveForContext(rawContext);
371
+ const result = { ...defaults };
250
372
  for (const [key, value] of Object.entries(this._state.serverResponse.assignments)) {
251
373
  if (key in result) {
252
374
  result[key] = value;
@@ -257,33 +379,37 @@ export class TrafficalClient {
257
379
  return result;
258
380
  }
259
381
  const bundle = this._getEffectiveBundle();
260
- const context = this._enrichContext(options.context);
261
- const params = resolveParameters(bundle, context, options.defaults);
382
+ const context = this._enrichContext(rawContext);
383
+ const params = resolveParameters(bundle, context, defaults);
262
384
  // Run plugin onResolve hooks (e.g., DOM binding plugin)
263
385
  this._plugins.runResolve(params);
264
386
  // Apply parameter overrides (post-resolution, post-plugin)
265
387
  this._applyOverridesToResult(params);
266
388
  return params;
267
- }, options.defaults);
389
+ }, defaults);
268
390
  }
269
- /**
270
- * Makes a decision with full metadata for tracking.
271
- */
272
- decide(options) {
391
+ decide(contextOrOptions, maybeDefaults) {
392
+ const { context: rawContext, defaults } = normalizeEvalArgs(contextOrOptions, maybeDefaults);
273
393
  return this._errorBoundary.capture("decide", () => {
274
394
  // Server mode: return from cached server response
275
395
  if (this._options.evaluationMode === "server" && this._state.serverResponse) {
396
+ this._maybeResolveForContext(rawContext);
276
397
  const resp = this._state.serverResponse;
277
- const assignments = { ...options.defaults };
398
+ const assignments = { ...defaults };
278
399
  for (const [key, value] of Object.entries(resp.assignments)) {
279
400
  if (key in assignments) {
280
401
  assignments[key] = value;
281
402
  }
282
403
  }
283
404
  const decision = {
284
- decisionId: resp.decisionId,
405
+ // Fresh decisionId per call — never reuse the resolve response's
406
+ // decisionId across decisions (spec 0.7.0 S8).
407
+ decisionId: generateDecisionId(),
285
408
  assignments,
286
- metadata: resp.metadata,
409
+ // Snapshot the resolve stateVersion at decision time so events
410
+ // built later stamp the version this decision was evaluated
411
+ // against (not whatever response is cached at event-build time).
412
+ metadata: { ...resp.metadata, configVersion: resp.stateVersion },
287
413
  };
288
414
  this._cacheDecision(decision);
289
415
  this._updateCumulativeAttribution(decision);
@@ -294,11 +420,11 @@ export class TrafficalClient {
294
420
  }
295
421
  const bundle = this._getEffectiveBundle();
296
422
  // Run plugin onBeforeDecision hooks
297
- let context = this._enrichContext(options.context);
423
+ let context = this._enrichContext(rawContext);
298
424
  context = this._plugins.runBeforeDecision(context);
299
425
  // Pass cached edge results (from bundle mode pre-fetch) if available
300
426
  const edgeOpts = this._state.cachedEdgeResults ?? undefined;
301
- const decision = coreDecide(bundle, context, options.defaults, edgeOpts);
427
+ const decision = coreDecide(bundle, context, defaults, edgeOpts);
302
428
  // Cache decision for attribution lookup when track() is called
303
429
  this._cacheDecision(decision);
304
430
  // Accumulate attribution entries (survives decision cache eviction)
@@ -311,7 +437,7 @@ export class TrafficalClient {
311
437
  return decision;
312
438
  }, {
313
439
  decisionId: generateDecisionId(),
314
- assignments: options.defaults,
440
+ assignments: defaults,
315
441
  metadata: {
316
442
  timestamp: new Date().toISOString(),
317
443
  unitKeyValue: "",
@@ -337,39 +463,51 @@ export class TrafficalClient {
337
463
  return;
338
464
  // Emit to assignment logger (separate from cloud events)
339
465
  this._emitAssignmentLogEntries(decision, "exposure");
340
- // Check each layer for deduplication
466
+ // Config bundle version the SDK evaluated against — from the
467
+ // decision-time snapshot. The current version is only a fallback for
468
+ // decisions that predate the snapshot field.
469
+ const configVersion = decision.metadata.configVersion ?? this.getConfigVersion() ?? undefined;
470
+ // S4 canonical shape: ONE exposure event per trackExposure() carrying
471
+ // only newly-exposed, non-attributionOnly layers. Skip layers without
472
+ // a policy/allocation, skip attribution-only layers (parameters not
473
+ // requested by this decision), and dedup per (unit, policy, allocation)
474
+ // for the session (dedup on by default). Mirrors the Node SDK — no
475
+ // longer one event per layer with the full unfiltered layers array.
476
+ const exposedLayers = [];
341
477
  for (const layer of decision.metadata.layers) {
342
478
  if (!layer.policyId || !layer.allocationName)
343
479
  continue;
344
- // Skip attribution-only layers — the user wasn't exposed to
345
- // parameters from this layer, so no exposure event should fire.
346
480
  if (layer.attributionOnly)
347
481
  continue;
348
- // Deduplicate
349
482
  const isNew = this._exposureDedup.checkAndMark(unitKey, layer.policyId, layer.allocationName);
350
483
  if (!isNew)
351
484
  continue;
352
- const event = {
353
- type: "exposure",
354
- id: generateExposureId(), // Unique exposure ID (not same as decision)
355
- decisionId: decision.decisionId,
356
- orgId: this._options.orgId,
357
- projectId: this._options.projectId,
358
- env: this._options.env,
359
- unitKey,
360
- timestamp: new Date().toISOString(),
361
- assignments: decision.assignments,
362
- layers: decision.metadata.layers,
363
- context: decision.metadata.filteredContext,
364
- sdkName: SDK_NAME,
365
- sdkVersion: SDK_VERSION,
366
- };
367
- // Run plugin onExposure hooks
368
- if (!this._plugins.runExposure(event)) {
369
- continue;
370
- }
371
- this._dispatchEvent(event);
485
+ exposedLayers.push(layer);
372
486
  }
487
+ // Nothing new to expose (all attribution-only or already seen this
488
+ // session) — emit NO event (never an empty-layers event).
489
+ if (exposedLayers.length === 0)
490
+ return;
491
+ const event = {
492
+ type: "exposure",
493
+ id: generateExposureId(), // Unique exposure ID (not same as decision)
494
+ decisionId: decision.decisionId,
495
+ orgId: this._options.orgId,
496
+ projectId: this._options.projectId,
497
+ env: this._options.env,
498
+ unitKey,
499
+ timestamp: new Date().toISOString(),
500
+ assignments: decision.assignments,
501
+ layers: exposedLayers,
502
+ context: decision.metadata.filteredContext,
503
+ configVersion,
504
+ sdkName: SDK_NAME,
505
+ sdkVersion: SDK_VERSION,
506
+ };
507
+ // Run plugin onExposure hooks
508
+ if (!this._plugins.runExposure(event))
509
+ return;
510
+ this._dispatchEvent(event);
373
511
  }, undefined);
374
512
  }
375
513
  /**
@@ -392,7 +530,12 @@ export class TrafficalClient {
392
530
  track(eventName, properties, options) {
393
531
  this._errorBoundary.capture("track", () => {
394
532
  const unitKey = options?.unitKey ?? this._stableId.getId();
395
- const value = typeof properties?.value === 'number' ? properties.value : undefined;
533
+ // Single numeric value: explicit options.value wins, else properties.value.
534
+ const value = typeof options?.value === "number"
535
+ ? options.value
536
+ : typeof properties?.value === "number"
537
+ ? properties.value
538
+ : undefined;
396
539
  // Auto-populate attribution from cached decisions
397
540
  const attribution = this._buildAttribution(unitKey, options?.decisionId);
398
541
  const decisionId = options?.decisionId;
@@ -406,9 +549,11 @@ export class TrafficalClient {
406
549
  timestamp: new Date().toISOString(),
407
550
  event: eventName,
408
551
  value,
552
+ values: options?.values,
409
553
  properties,
410
554
  decisionId,
411
555
  attribution,
556
+ eventTimestamp: options?.eventTimestamp,
412
557
  sdkName: SDK_NAME,
413
558
  sdkVersion: SDK_VERSION,
414
559
  };
@@ -584,6 +729,9 @@ export class TrafficalClient {
584
729
  const unitKey = decision.metadata.unitKeyValue;
585
730
  if (!unitKey)
586
731
  return;
732
+ // Config bundle version the SDK evaluated against — from the
733
+ // decision-time snapshot, falling back to the current version.
734
+ const configVersion = decision.metadata.configVersion ?? this.getConfigVersion() ?? undefined;
587
735
  for (const layer of decision.metadata.layers) {
588
736
  if (!layer.policyId || !layer.allocationName)
589
737
  continue;
@@ -612,6 +760,10 @@ export class TrafficalClient {
612
760
  decisionId: decision.decisionId,
613
761
  anonymousId: this._stableId.getId(),
614
762
  id: generateAssignmentId(),
763
+ bucket: layer.bucket >= 0 ? layer.bucket : undefined,
764
+ probability: layer.probability,
765
+ modelVersion: layer.modelVersion,
766
+ configVersion,
615
767
  });
616
768
  }
617
769
  }
@@ -641,7 +793,9 @@ export class TrafficalClient {
641
793
  return context;
642
794
  }
643
795
  async _fetchConfig() {
644
- const url = `${this._options.baseUrl}/v1/config/${this._options.projectId}?env=${this._options.env}`;
796
+ // URL-encode path/query components so an env or projectId containing
797
+ // reserved characters (spaces, &, ?, /) can't corrupt the request URL.
798
+ const url = `${this._options.baseUrl}/v1/config/${encodeURIComponent(this._options.projectId)}?env=${encodeURIComponent(this._options.env)}`;
645
799
  const headers = {
646
800
  "Content-Type": "application/json",
647
801
  Authorization: `Bearer ${this._options.apiKey}`,
@@ -649,8 +803,16 @@ export class TrafficalClient {
649
803
  if (this._state.etag) {
650
804
  headers["If-None-Match"] = this._state.etag;
651
805
  }
806
+ // Abort the request if the edge hangs (slow TCP, not a 5xx) so the
807
+ // promise settles and we fall back to cached/local config.
808
+ const controller = new AbortController();
809
+ const timeoutId = setTimeout(() => controller.abort(), this._requestTimeoutMs);
652
810
  try {
653
- const response = await fetch(url, { method: "GET", headers });
811
+ const response = await fetch(url, {
812
+ method: "GET",
813
+ headers,
814
+ signal: controller.signal,
815
+ });
654
816
  if (response.status === 304) {
655
817
  this._state.lastFetchTime = Date.now();
656
818
  return;
@@ -660,6 +822,13 @@ export class TrafficalClient {
660
822
  }
661
823
  const bundle = (await response.json());
662
824
  const etag = response.headers.get("ETag");
825
+ // A 200 can still carry a malformed body. Discard it and keep the
826
+ // previous last-good bundle rather than corrupting bucket assignments or
827
+ // falling through to defaults when we already have a valid config.
828
+ if (!isValidConfigBundle(bundle)) {
829
+ this._logMalformedBundleWarning();
830
+ return;
831
+ }
663
832
  this._state.bundle = bundle;
664
833
  this._state.etag = etag;
665
834
  this._state.lastFetchTime = Date.now();
@@ -677,6 +846,9 @@ export class TrafficalClient {
677
846
  catch (error) {
678
847
  this._logOfflineWarning(error);
679
848
  }
849
+ finally {
850
+ clearTimeout(timeoutId);
851
+ }
680
852
  }
681
853
  _startBackgroundRefresh() {
682
854
  const interval = this._options.evaluationMode === "server"
@@ -684,20 +856,50 @@ export class TrafficalClient {
684
856
  : this._options.refreshIntervalMs;
685
857
  if (interval <= 0)
686
858
  return;
687
- this._state.refreshTimer = setInterval(() => {
688
- if (this._options.evaluationMode === "server") {
689
- this._fetchServerResolve().catch(() => { });
690
- }
691
- else {
692
- this._fetchConfig().catch(() => { });
693
- }
694
- }, interval);
859
+ // Reschedule via setTimeout with fresh +/-10% jitter each tick (instead of a
860
+ // fixed setInterval) so fleets of clients don't converge on the same refresh
861
+ // instant and stampede the edge.
862
+ const scheduleNext = () => {
863
+ this._state.refreshTimer = setTimeout(() => {
864
+ // Schedule the next tick before firing so cadence stays independent of
865
+ // fetch latency (matching the previous fire-and-forget setInterval).
866
+ scheduleNext();
867
+ if (this._options.evaluationMode === "server") {
868
+ this._fetchServerResolve().catch(() => { });
869
+ }
870
+ else {
871
+ this._fetchConfig().catch(() => { });
872
+ }
873
+ }, jitteredInterval(interval));
874
+ };
875
+ scheduleNext();
695
876
  }
696
- async _fetchServerResolve() {
877
+ /**
878
+ * Server mode: threads the per-call context into a background /v1/resolve so
879
+ * the cached snapshot converges to the contexts actually being evaluated.
880
+ * Throttled by serialized context so repeated identical contexts don't hammer
881
+ * the edge. decide()/getParams() are synchronous and cannot await this; the
882
+ * current call degrades to the last-good snapshot.
883
+ */
884
+ _maybeResolveForContext(context) {
885
+ const enriched = this._enrichContext(context);
886
+ let key;
887
+ try {
888
+ key = JSON.stringify(enriched);
889
+ }
890
+ catch {
891
+ key = "";
892
+ }
893
+ if (key === this._lastResolveContextKey)
894
+ return;
895
+ this._lastResolveContextKey = key;
896
+ void this._fetchServerResolve(enriched);
897
+ }
898
+ async _fetchServerResolve(contextOverride) {
697
899
  if (!this._decisionClient)
698
900
  return;
699
901
  try {
700
- const context = this._enrichContext({});
902
+ const context = contextOverride ?? this._enrichContext({});
701
903
  const response = await this._decisionClient.resolve({ context });
702
904
  if (response) {
703
905
  this._state.serverResponse = response;
@@ -775,6 +977,13 @@ export class TrafficalClient {
775
977
  this._state.lastOfflineWarning = now;
776
978
  }
777
979
  }
980
+ _logMalformedBundleWarning() {
981
+ const now = Date.now();
982
+ if (now - this._state.lastMalformedWarning > MALFORMED_BUNDLE_WARNING_INTERVAL_MS) {
983
+ console.warn(`[Traffical] Discarded malformed config bundle (invalid hashing/shape). Using ${this._state.bundle ? "cached" : "local"} config.`);
984
+ this._state.lastMalformedWarning = now;
985
+ }
986
+ }
778
987
  /**
779
988
  * Caches a decision for attribution lookup when track() is called.
780
989
  * Maintains a bounded cache to prevent memory leaks.