@incodetech/tri-sdk 0.1.0 → 0.1.1

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/tri.umd.js CHANGED
@@ -4,26 +4,191 @@
4
4
  (global = typeof globalThis !== 'undefined' ? globalThis : global || self, global.TRI = factory());
5
5
  })(this, (function () { 'use strict';
6
6
 
7
- function decode(token) {
8
- if (!token)
9
- return null;
10
- const payload = token.split('.')[1];
11
- const json = atob(payload.replace(/-/g, '+').replace(/_/g, '/'));
12
- return JSON.parse(json);
13
- }
14
- function isExpired(token, skewSeconds = 30) {
15
- if (!token)
16
- return true;
17
- const payload = decode(token);
18
- if (!payload || typeof payload.exp !== 'number') {
19
- return true;
7
+ /**
8
+ * @module lib/logger
9
+ *
10
+ * @description
11
+ * Singleton logger for the TRI SDK. This is the **only** sanctioned site for
12
+ * `console.*` calls inside `packages/core`. All other modules must import and
13
+ * use the exported `logger` instance rather than calling `console` directly.
14
+ *
15
+ * ### Debug gating
16
+ * Non-fatal methods (`warn`, `error`, `info`) are no-ops when `DEBUG` is
17
+ * `false`. Set `logger.DEBUG = true` — typically driven by the `debug` field
18
+ * in `TRIConfig` — to activate them. No string concatenation happens in the
19
+ * fast path (early return before any allocation).
20
+ *
21
+ * `fatal` always emits regardless of `DEBUG`; use it only for messages that
22
+ * must surface in production (hard misconfigurations, blocked environments).
23
+ *
24
+ * ### Message format
25
+ * `[prefix] message` — prefix is wrapped in square brackets and prepended to
26
+ * the message with a single space. Extra `args` are forwarded to the underlying
27
+ * `console.*` method unchanged so error objects and context values render with
28
+ * full browser-console expansion.
29
+ */
30
+ const SDK_PREFIX = 'TRI';
31
+ class Logger {
32
+ constructor() {
33
+ this.debugValue = false;
34
+ }
35
+ /** Returns the singleton Logger instance. */
36
+ static get instance() {
37
+ if (!Logger.singleton) {
38
+ Logger.singleton = new Logger();
39
+ }
40
+ return Logger.singleton;
41
+ }
42
+ /** Exposed read-only view of the current debug state. */
43
+ get _DEBUG() {
44
+ return this.debugValue;
45
+ }
46
+ get DEBUG() {
47
+ return this.debugValue;
48
+ }
49
+ set DEBUG(value) {
50
+ this.debugValue = value;
51
+ }
52
+ /** @inheritdoc LoggerInterface.warn */
53
+ warn(prefix, message, ...args) {
54
+ if (!this.debugValue)
55
+ return;
56
+ this._log('warn', prefix, message, ...args);
57
+ }
58
+ /** @inheritdoc LoggerInterface.error */
59
+ error(prefix, message, ...args) {
60
+ if (!this.debugValue)
61
+ return;
62
+ this._log('error', prefix, message, ...args);
63
+ }
64
+ /** @inheritdoc LoggerInterface.info */
65
+ info(prefix, message, ...args) {
66
+ if (!this.debugValue)
67
+ return;
68
+ this._log('info', prefix, message, ...args);
69
+ }
70
+ /** @inheritdoc LoggerInterface.fatal */
71
+ fatal(level, prefix, message, ...args) {
72
+ this._log(level, prefix, message, ...args);
73
+ }
74
+ _log(level, prefix, message, ...args) {
75
+ const modulePrefix = prefix ? `[${prefix}]` : '';
76
+ console[level](`[${SDK_PREFIX}]${modulePrefix} ${message}`, ...args);
20
77
  }
21
- return payload.exp * 1000 <= Date.now() + skewSeconds * 1000;
22
78
  }
23
- const jwt = {
24
- decode,
25
- isExpired
79
+ /** Singleton logger instance. Import and use this throughout `packages/core`. */
80
+ const logger = Logger.instance;
81
+
82
+ /**
83
+ * @module sdk/internal/env
84
+ *
85
+ * @description
86
+ * Environment constants and URL resolver for the SDK preflight. This module
87
+ * is part of the **static bundle** — it imports only primitive types and the
88
+ * logger singleton, so it carries no collector-factory cost.
89
+ *
90
+ * Anything that must run before the lazy chunk loads (e.g. `sdk.ts` deciding
91
+ * whether to even fetch the bundle) should import from here rather than from
92
+ * the full `sdk/constants` module.
93
+ *
94
+ * @internal
95
+ */
96
+ /** How long (ms) to wait for a dynamic `import()` before giving up. */
97
+ const DYNAMIC_IMPORT_TIMEOUT_MS = 10000;
98
+ /** Default value for the `debug` flag when the host omits it. */
99
+ const DEFAULT_DEBUG = false;
100
+ /** Default `apiVersion` string when the host omits it. */
101
+ const DEFAULT_API_VERSION = '1.0';
102
+ /**
103
+ * Lookup from a `TriEnv` value to its corresponding base API URL.
104
+ * Entries with an empty string are **not yet provisioned**; hosts attempting
105
+ * to use them will receive a fatal error at SDK start.
106
+ */
107
+ const ENV_TO_BASE_API_URL_LOOKUP = {
108
+ STAGE_K8S: 'https://falcon-ingest-k8s.stage.incodetest.com',
109
+ SAAS: 'http://localhost:8080',
110
+ STAGE: '',
111
+ DEMO: '',
112
+ DEMO_K8S: ''
26
113
  };
114
+ /**
115
+ * All known `TriEnv` keys (including those not yet provisioned with a URL).
116
+ * Used to distinguish a typo/unsupported env from a known-but-unprovisioned one.
117
+ */
118
+ const SUPPORTED_ENVS = Object.keys(ENV_TO_BASE_API_URL_LOOKUP);
119
+ /**
120
+ * Base API URLs that are fully provisioned and accepted at SDK start.
121
+ * Derived from `ENV_TO_BASE_API_URL_LOOKUP` by filtering empty strings.
122
+ */
123
+ const SUPPORTED_API_URLS = Object.values(ENV_TO_BASE_API_URL_LOOKUP).filter(Boolean);
124
+ /**
125
+ * Resolves a `TriEnv` value to its base API URL.
126
+ *
127
+ * Returns `undefined` (and logs a fatal) when:
128
+ * - the env is not a known `TriEnv` key, or
129
+ * - the env is known but not yet provisioned (empty URL).
130
+ */
131
+ function resolveBaseApiURLFromEnv(env) {
132
+ if (!SUPPORTED_ENVS.includes(env)) {
133
+ logger.fatal('error', 'SDK', 'Provided environment is not supported:', env, 'Supported environments are:', SUPPORTED_ENVS.join(', '));
134
+ return undefined;
135
+ }
136
+ const url = ENV_TO_BASE_API_URL_LOOKUP[env];
137
+ if (!url) {
138
+ logger.fatal('error', 'SDK', `env "${env}" is not yet provisioned with a base URL.`);
139
+ return undefined;
140
+ }
141
+ return url;
142
+ }
143
+
144
+ /**
145
+ * @module sdk/internal/preflight-config
146
+ *
147
+ * @description
148
+ * Lightweight config validator that runs in the **static bundle** before the
149
+ * lazy chunk is ever fetched. Its only job is to extract the three values
150
+ * needed for the collection-status preflight check (`apiKey`, `baseApiURL`,
151
+ * `debug`) and return them — or log a fatal and return `null` when the host
152
+ * config is unusable.
153
+ *
154
+ * The full `resolveTRIConfig` function (in `sdk/helpers.ts`) performs a
155
+ * superset of this validation and lives in the lazy chunk. This module exists
156
+ * solely to keep the static bundle free of `COLLECTORS_CONFIG` and every
157
+ * transitively-imported collector factory.
158
+ *
159
+ * @internal
160
+ */
161
+ /**
162
+ * Extracts and validates the preflight-critical fields from the host-supplied
163
+ * config. Returns `null` (with a fatal log) when any required field is missing
164
+ * or the resolved URL is not provisioned.
165
+ */
166
+ function resolvePreflightConfig(config) {
167
+ if (!config?.apiKey) {
168
+ logger.fatal('error', 'SDK', 'apiKey is required.');
169
+ return null;
170
+ }
171
+ let baseApiURL;
172
+ if (config.apiURL) {
173
+ if (!SUPPORTED_API_URLS.includes(config.apiURL)) {
174
+ logger.fatal('error', 'SDK', 'Provided apiURL is not supported:', config.apiURL, 'Supported API URLs are:', SUPPORTED_API_URLS.join(', '));
175
+ return null;
176
+ }
177
+ baseApiURL = config.apiURL;
178
+ }
179
+ else if (config.env) {
180
+ baseApiURL = resolveBaseApiURLFromEnv(config.env);
181
+ }
182
+ if (!baseApiURL) {
183
+ logger.fatal('error', 'SDK', "Couldn't initialise SDK — neither apiURL nor env were provided, or the provided value has no provisioned URL.");
184
+ return null;
185
+ }
186
+ return {
187
+ apiKey: config.apiKey,
188
+ baseApiURL,
189
+ debug: config.debug ?? DEFAULT_DEBUG,
190
+ };
191
+ }
27
192
 
28
193
  function addEventListener(target, type, listener) {
29
194
  let resolvedTarget = target;
@@ -62,10 +227,7 @@
62
227
  return fn();
63
228
  }
64
229
  catch (error) {
65
- {
66
- // eslint-disable-next-line no-console
67
- console.warn('[TriSDK] safeSync swallowed error:', error);
68
- }
230
+ logger.warn('utils', 'safeSync swallowed error:', error);
69
231
  }
70
232
  finally {
71
233
  }
@@ -75,10 +237,7 @@
75
237
  return await fn();
76
238
  }
77
239
  catch (error) {
78
- {
79
- // eslint-disable-next-line no-console
80
- console.warn('[TriSDK] safeAsync swallowed error:', error);
81
- }
240
+ logger.warn('utils', 'safeAsync swallowed error:', error);
82
241
  }
83
242
  }
84
243
  function safeListener(fn) {
@@ -90,17 +249,225 @@
90
249
  fn(...args);
91
250
  }
92
251
  catch (error) {
93
- {
94
- // eslint-disable-next-line no-console
95
- console.warn('[TriSDK] safeTimer swallowed error:', error);
96
- }
252
+ logger.warn('utils', 'safeTimer swallowed error:', error);
97
253
  }
98
254
  };
99
255
  }
100
256
  function safeSetTimeout(fn, delay) {
101
- const id = _setTimeout(safeTimer(fn), delay);
102
- return () => _clearTimeout(id);
257
+ return _setTimeout(safeTimer(fn), delay);
258
+ }
259
+ function safeClearTimeout(id) {
260
+ if (id === null)
261
+ return;
262
+ try {
263
+ _clearTimeout(id);
264
+ }
265
+ catch (error) {
266
+ logger.warn('utils', 'safeClearTimeout swallowed error:', error);
267
+ }
268
+ }
269
+
270
+ /**
271
+ * Singleton entry-point for the TRI SDK.
272
+ *
273
+ * Obtain the instance via `TRI.instance`; the constructor is private.
274
+ *
275
+ * ### Life-cycle states
276
+ * The instance moves through three states:
277
+ * - `'idle'` — not started, or fully stopped.
278
+ * - `'starting'` — `start()` has been called; preflight and lazy bundle load in progress.
279
+ * - `'started'` — all managers are running and `reportEvent()` is active.
280
+ *
281
+ * ### Never throws
282
+ * All public methods are fire-and-forget. Errors are logged via `logger` and
283
+ * swallowed so the host application is never disrupted.
284
+ *
285
+ * ### Bundle split
286
+ * Only the preflight config validator and collection-status check live in the
287
+ * static bundle. The full manager wiring (`internal/start.ts`) is dynamically
288
+ * imported only when the backend confirms collection is enabled, keeping the
289
+ * initial page-load cost minimal.
290
+ */
291
+ let TRI$1 = class TRI {
292
+ constructor() {
293
+ /**
294
+ * Monotonically-increasing counter incremented by every `start()` and
295
+ * `stop()` call. Each in-flight `runStart` captures its own generation value
296
+ * and bails out after every `await` if the counter has moved on, preventing
297
+ * orphaned manager instances from being installed after a concurrent stop.
298
+ */
299
+ this.startGeneration = 0;
300
+ this.startState = 'idle';
301
+ // All manager fields are set after the lazy chunk loads successfully.
302
+ this.falconServiceManager = null;
303
+ this.activityManager = null;
304
+ this.transportManager = null;
305
+ this.tabManager = null;
306
+ this.lifecycleManager = null;
307
+ this.collectorManager = null;
308
+ this.eventReportManager = null;
309
+ }
310
+ static get instance() {
311
+ if (!TRI._instance) {
312
+ TRI._instance = new TRI();
313
+ }
314
+ return TRI._instance;
315
+ }
316
+ /**
317
+ * @description Starts the TRI SDK. Fire-and-forget and idempotent — a second
318
+ * call while the SDK is starting or already started is a no-op. Checks the
319
+ * backend `/collection-status` endpoint and only loads the full manager bundle
320
+ * when collection is enabled. Safe to call in SSR environments; if `document`
321
+ * is unavailable the function logs a fatal and returns without throwing.
322
+ */
323
+ start(config) {
324
+ if (this.startState !== 'idle') {
325
+ logger.warn('Start', 'start() called more than once — ignoring.');
326
+ return;
327
+ }
328
+ this.startState = 'starting';
329
+ const generation = ++this.startGeneration;
330
+ void this.runStart(config, generation);
331
+ }
332
+ /**
333
+ * @description Stops all collectors, flushes pending events, and tears down
334
+ * every manager. Safe to call more than once. After `stop()`, `start()` may be
335
+ * called again with a fresh config. Any in-flight `start()` is cancelled at
336
+ * the next async checkpoint.
337
+ */
338
+ stop() {
339
+ ++this.startGeneration;
340
+ this.startState = 'idle';
341
+ this.lifecycleManager?.stop();
342
+ this.lifecycleManager = null;
343
+ this.activityManager?.stop();
344
+ this.activityManager = null;
345
+ this.transportManager?.stop();
346
+ this.transportManager = null;
347
+ this.tabManager?.stop();
348
+ this.tabManager = null;
349
+ this.collectorManager?.stop();
350
+ this.collectorManager = null;
351
+ this.eventReportManager?.stop();
352
+ this.eventReportManager = null;
353
+ this.falconServiceManager = null;
354
+ logger.DEBUG = DEFAULT_DEBUG;
355
+ }
356
+ /**
357
+ * @description Reports a host-defined event to the TRI backend. Fire-and-forget —
358
+ * never throws and never returns a promise the host must handle. Drops the
359
+ * event silently (with a dev warning) if called before `start` completes.
360
+ */
361
+ reportEvent(name, metadata) {
362
+ if (this.shouldEarlyEscapeReport()) {
363
+ return;
364
+ }
365
+ void safeAsync(() => this.eventReportManager.report(name, metadata));
366
+ }
367
+ async runStart(config, generation) {
368
+ if (typeof document === 'undefined') {
369
+ logger.fatal('error', 'Start', 'start() called in a non-browser environment. The SDK will not start.');
370
+ this.startState = 'idle';
371
+ return;
372
+ }
373
+ // resolvePreflightConfig runs entirely in the static bundle — no collector
374
+ // factories are loaded at this point.
375
+ const preflight = resolvePreflightConfig(config);
376
+ if (!preflight) {
377
+ this.startState = 'idle';
378
+ return;
379
+ }
380
+ // Bail if stop() or a concurrent start() invalidated this run.
381
+ if (this.startGeneration !== generation) {
382
+ return;
383
+ }
384
+ // Flip DEBUG only after the SDK is confirmed to start.
385
+ logger.DEBUG = preflight.debug;
386
+ // Dynamically import the lazy chunk with a hard timeout so a slow/dead
387
+ // CDN does not hang the host application indefinitely.
388
+ let startModule;
389
+ try {
390
+ startModule = await Promise.race([
391
+ Promise.resolve().then(function () { return start$1; }),
392
+ new Promise((_, reject) => setTimeout(() => reject(new Error('dynamic import timed out')), DYNAMIC_IMPORT_TIMEOUT_MS))
393
+ ]);
394
+ }
395
+ catch (error) {
396
+ logger.error('Start', 'Failed to load manager bundle:', error);
397
+ if (this.startGeneration === generation) {
398
+ this.startState = 'idle';
399
+ }
400
+ return;
401
+ }
402
+ if (this.startGeneration !== generation) {
403
+ return;
404
+ }
405
+ // The lazy chunk receives the raw config and runs the full resolver internally.
406
+ const managers = await startModule.start(config);
407
+ if (this.startGeneration !== generation) {
408
+ // A concurrent stop() (or new start()) raced us — discard what we built.
409
+ if (managers) {
410
+ managers.lifecycleManager?.stop();
411
+ managers.tabManager?.stop();
412
+ managers.transportManager?.stop();
413
+ void managers.activityManager?.stop();
414
+ }
415
+ return;
416
+ }
417
+ if (!managers) {
418
+ // internal/start.ts already logged the reason.
419
+ this.startState = 'idle';
420
+ return;
421
+ }
422
+ this.falconServiceManager = managers.falconServiceManager;
423
+ this.activityManager = managers.activityManager;
424
+ this.transportManager = managers.transportManager;
425
+ this.tabManager = managers.tabManager;
426
+ this.lifecycleManager = managers.lifecycleManager;
427
+ this.collectorManager = managers.collectorManager;
428
+ this.eventReportManager = managers.eventReportManager;
429
+ this.startState = 'started';
430
+ }
431
+ /**
432
+ * Returns `true` (and logs a dev warning) when `reportEvent()` should be a
433
+ * no-op. Guards against calling before the SDK is fully started, or when
434
+ * the `eventReportManager` is unexpectedly absent.
435
+ */
436
+ shouldEarlyEscapeReport() {
437
+ if (this.startState !== 'started') {
438
+ logger.warn('ReportEvent', 'reportEvent() called before start() completed.');
439
+ return true;
440
+ }
441
+ if (!this.eventReportManager) {
442
+ logger.warn('ReportEvent', 'eventReportManager not available.');
443
+ return true;
444
+ }
445
+ return false;
446
+ }
447
+ };
448
+
449
+ const TRI = TRI$1.instance;
450
+
451
+ function decode(token) {
452
+ if (!token)
453
+ return null;
454
+ const payload = token.split('.')[1];
455
+ const json = atob(payload.replace(/-/g, '+').replace(/_/g, '/'));
456
+ return JSON.parse(json);
457
+ }
458
+ function isExpired(token, skewSeconds = 30) {
459
+ if (!token)
460
+ return true;
461
+ const payload = decode(token);
462
+ if (!payload || typeof payload.exp !== 'number') {
463
+ return true;
464
+ }
465
+ return payload.exp * 1000 <= Date.now() + skewSeconds * 1000;
103
466
  }
467
+ const jwt = {
468
+ decode,
469
+ isExpired
470
+ };
104
471
 
105
472
  function isNativeWebLocksAvailable() {
106
473
  return (globalThis.navigator &&
@@ -114,19 +481,6 @@
114
481
  }
115
482
  return navigator.locks.request(lockName, { mode: 'exclusive' }, async () => safeAsync(fn));
116
483
  }
117
- async function withSharedWebLock(lockName, fn) {
118
- if (!isNativeWebLocksAvailable()) {
119
- return safeAsync(fn);
120
- }
121
- return navigator.locks.request(lockName, { mode: 'shared' }, async () => safeAsync(fn));
122
- }
123
- async function queryWebLockHolders(name) {
124
- if (!isNativeWebLocksAvailable()) {
125
- return null;
126
- }
127
- const snapshot = (await safeAsync(() => navigator.locks.query()));
128
- return (snapshot.held ?? []).filter((lock) => lock.name === name);
129
- }
130
484
 
131
485
  const KEY_TOKEN = 'activity_token';
132
486
  const KEY_ACTIVITY_ID = 'activity_id';
@@ -198,7 +552,6 @@
198
552
  this.startPromise = null;
199
553
  }
200
554
  }
201
- // HAVE TO BE UNDER LOCK
202
555
  /**
203
556
  * @description Resolves the activity start by either loading from persistent storage or starting a new activity. **Have to be called under lock.**
204
557
  * @important **If start-activity api fails, system will try to recover by refresh path.**
@@ -291,18 +644,38 @@
291
644
  * @internal
292
645
  */
293
646
  const WIRE = {
294
- schemaVersion: '0.1.0',
295
- sdkVersion: '0.1.0',
647
+ /** Injected at build time from package.json `version` via @rollup/plugin-replace. */
648
+ sdkVersion: "0.1.1",
649
+ /** Injected at build time via @rollup/plugin-replace. Bump when the wire schema changes. */
650
+ schemaVersion: "0.1.0",
296
651
  sdkPlatform: 'web',
297
652
  ndjsonContentType: 'application/x-ndjson',
298
653
  jsonContentType: 'application/json',
299
654
  gzipEncoding: 'gzip'};
300
655
 
656
+ /**
657
+ * @module managers/transport/constants
658
+ *
659
+ * @description
660
+ * Hard limits for the retry queue. Not exposed through `TransportConfig` to
661
+ * preserve the zero-config contract; promote to config fields under a
662
+ * non-breaking minor if a host use case requires tuning.
663
+ *
664
+ * @internal
665
+ */
666
+ /** Maximum number of send attempts for a single batch before it is dropped. */
667
+ const MAX_RETRY_ATTEMPTS = 3;
668
+ /**
669
+ * Maximum number of failed batches the retry queue will hold simultaneously.
670
+ * When the queue is full and a new failure arrives the oldest entry is evicted
671
+ * (FIFO) to make room.
672
+ */
673
+ const MAX_RETRY_BATCHES = 5;
674
+
301
675
  /**
302
676
  * Frozen array of all defined priority levels, used to initialise and iterate
303
677
  * per-priority drop counters.
304
678
  */
305
- const PRIORITY_VALUES = Object.freeze([20, 40, 60, 80, 100]);
306
679
  /**
307
680
  * Base priority lookup keyed by `TRIEvent['name']`. TypeScript enforces
308
681
  * exhaustiveness — adding a new name to the `TRIEvent` union without a
@@ -441,136 +814,337 @@
441
814
  return impl();
442
815
  }
443
816
 
444
- // TODO - use environment variables for sdkVersion and schemaVersion
817
+ /**
818
+ * @module managers/transport/transport
819
+ *
820
+ * @description
821
+ * TransportManager — the single component responsible for buffering TRIEvents
822
+ * and shipping them to the ingest endpoint as NDJSON batches.
823
+ *
824
+ * ### Threading model
825
+ * JavaScript is single-threaded. All methods are synchronous except for the
826
+ * promise chains that stem from `context.ingest`. Those chains run as
827
+ * microtasks and can therefore interleave between setTimeout callbacks, but
828
+ * they cannot interleave with synchronous code. The absence of `flushInFlight`
829
+ * is intentional: fire-and-forget batches are desirable — a slow backend
830
+ * should not block new events from being sent. The 10 s HTTP timeout bounds
831
+ * the maximum number of concurrent in-flight requests to 2 per
832
+ * `effectiveIntervalMs` window (one main flush + one retry drain) before
833
+ * adaptive backoff collapses cadence further.
834
+ *
835
+ * ### Concurrency guarantees
836
+ * 1. The retry queue holds at most `MAX_RETRY_BATCHES` entries. Combined with
837
+ * the single shared `retryDrainTimer`, at most one retry is in-flight at
838
+ * any time alongside the scheduled main flush.
839
+ * 2. No event is double-shipped: the retry queue and the main queue operate on
840
+ * disjoint event sets; the drain timer is idempotent.
841
+ * 3. `stop()` is fire-and-forget — the keepalive drain may not complete if the
842
+ * browser tears the page down within milliseconds after the call.
843
+ *
844
+ * @internal
845
+ */
846
+ /**
847
+ * Buffers `TRIEvent` instances and ships them in batches to the ingest
848
+ * endpoint on a scheduled interval, at capacity, and on lifecycle events.
849
+ *
850
+ * The scheduler is edge-triggered: a timer is started on the first `push()`
851
+ * when none is running, and re-armed only when the queue still holds events
852
+ * after a flush. This means an idle SDK holds no live timer.
853
+ *
854
+ * Retryable batch failures are enqueued in a bounded FIFO retry queue
855
+ * (`MAX_RETRY_BATCHES = 5`). A single shared drain timer fires after
856
+ * `effectiveIntervalMs` and sends the head entry. Each batch is retried up to
857
+ * `MAX_RETRY_ATTEMPTS = 10` times; exhausted batches are dropped. Consecutive
858
+ * drain failures widen the interval (adaptive backoff); the first successful
859
+ * flush or drain snaps it back to the base.
860
+ */
445
861
  class TransportManager {
446
862
  constructor(config, context) {
447
863
  this.config = config;
448
864
  this.context = context;
449
865
  this.queue = [];
450
- this.retryQueue = [];
451
866
  this.flushTimer = null;
452
- this.retryFlushTimer = null;
453
- this.flushInFlight = false;
454
- this.destroyed = false;
867
+ this.retryQueue = [];
868
+ this.retryDrainTimer = null;
869
+ this.started = false;
870
+ this.online = true;
871
+ this.consecutiveFailures = 0;
455
872
  this.batchSequence = 0;
456
- /**
457
- * Per-priority-class drop counters reset on every `createBatch()` call
458
- * (architecture §10.3). Counts both evicted-existing and dropped-incoming
459
- * events so the backend can distinguish "user idle" from "we threw it away".
460
- */
461
- this.droppedEvents = {
462
- 20: 0,
463
- 40: 0,
464
- 60: 0,
465
- 80: 0,
466
- 100: 0
467
- };
873
+ this.effectiveIntervalMs = config.flush.intervalMs;
468
874
  }
469
875
  /**
470
- * Enqueues an event. If the queue has reached the flush batch size and no
471
- * flush is in flight, a capacity flush is triggered immediately. If the
472
- * queue is at hard cap, the priority-aware eviction algorithm runs before
473
- * the event can be accepted (architecture §10.2).
876
+ * Enqueues an event. When the queue is at the normal flush batch size, a
877
+ * capacity flush fires immediately as a belt-and-braces guard for callers
878
+ * that configure a high `intervalMs`. If the hard queue cap is reached,
879
+ * priority-aware eviction runs before the event is accepted.
880
+ *
881
+ * No-ops silently when offline.
474
882
  */
475
883
  push(event) {
476
- if (this.queue.length >= this.config.flush.size && !this.flushInFlight) {
477
- void this.flush('capacity');
478
- this.restartFlushTimer();
884
+ if (!this.started) {
885
+ logger.warn('Transport', 'push() called before start() — event dropped');
886
+ return;
479
887
  }
480
- if (this.queue.length < this.config.queue.size) {
888
+ if (this.queue.length < this.effectiveQueueCap()) {
481
889
  this.queue.push(event);
890
+ }
891
+ else {
892
+ this.evictAndPush(event);
893
+ }
894
+ if (!this.online)
895
+ return;
896
+ if (this.queue.length >= this.config.flush.size) {
897
+ safeClearTimeout(this.flushTimer);
898
+ this.flushTimer = null;
899
+ this.flush('capacity');
482
900
  return;
483
901
  }
484
- this.evictAndEnqueue(event);
902
+ this.scheduleFlush();
485
903
  }
486
- async flush(reason) {
487
- if (this.flushInFlight)
488
- return;
489
- if (this.queue.length === 0)
904
+ /**
905
+ * Synchronous, fire-and-forget flush. Splices a batch from the queue and
906
+ * starts an `ingest` promise chain without awaiting it. Any thrown or
907
+ * rejected error is contained — it never surfaces as an unhandled rejection.
908
+ *
909
+ * On the lifecycle path (`reason === 'lifecycle'`): drains both the retry
910
+ * queue (if non-empty) and the newest `flush.size` events from the main
911
+ * queue using keepalive mode.
912
+ */
913
+ flush(reason) {
914
+ if (!this.started)
490
915
  return;
491
- const batch = this.createBatch();
492
- this.flushInFlight = true;
493
916
  try {
494
- const keepalive = reason === 'lifecycle';
495
- let result;
496
- try {
497
- result = await this.context.ingest(batch, keepalive);
498
- }
499
- catch (error) {
500
- if (true) {
501
- // eslint-disable-next-line no-console
502
- console.warn('[TriSDK] ingest threw unexpectedly — batch dropped:', error);
503
- }
917
+ if (reason === 'lifecycle') {
918
+ this.flushLifecycle();
504
919
  return;
505
920
  }
506
- if (!result.ok && reason !== 'lifecycle' && this.isRetryable(result)) {
507
- this.retryQueue.push({ batch, attempt: 0 });
508
- this.scheduleRetryFlush();
921
+ if (this.queue.length === 0)
922
+ return;
923
+ const batch = this.createBatch(reason);
924
+ void this.context
925
+ .ingest(batch, false)
926
+ .then((result) => this.handleIngestResult(result, batch))
927
+ .catch(() => {
928
+ logger.warn('Transport', 'ingest threw unexpectedly — batch dropped');
929
+ });
930
+ if (this.queue.length > 0) {
931
+ this.scheduleFlush();
509
932
  }
510
933
  }
511
- finally {
512
- this.flushInFlight = false;
934
+ catch (error) {
935
+ logger.warn('Transport', 'flush() threw unexpectedly:', error);
513
936
  }
514
937
  }
938
+ /**
939
+ * Starts the transport manager. Idempotent — a second call is a no-op.
940
+ * Seeds the online state from `navigator.onLine` at call time.
941
+ */
515
942
  start() {
516
- this.scheduleFlush();
517
- }
518
- destroy() {
519
- this.destroyed = true;
520
- if (this.flushTimer !== null) {
521
- clearTimeout(this.flushTimer);
522
- this.flushTimer = null;
523
- }
524
- if (this.retryFlushTimer !== null) {
525
- clearTimeout(this.retryFlushTimer);
526
- this.retryFlushTimer = null;
527
- }
528
- }
529
- scheduleFlush() {
530
- if (this.destroyed)
943
+ if (this.started)
531
944
  return;
532
- this.flushTimer = setTimeout(async () => {
533
- try {
534
- await this.flush('interval');
535
- }
536
- finally {
537
- this.scheduleFlush();
538
- }
539
- }, this.config.flush.intervalMs);
945
+ this.started = true;
946
+ this.online = typeof navigator !== 'undefined' ? navigator.onLine : true;
540
947
  }
541
- restartFlushTimer() {
542
- if (this.flushTimer !== null)
543
- clearTimeout(this.flushTimer);
544
- this.scheduleFlush();
948
+ /**
949
+ * Stops the transport manager. Cancels all timers and fires a best-effort
950
+ * keepalive drain of the retry queue and the newest `flush.size` events.
951
+ * Synchronous and void — the keepalive requests may not complete before the
952
+ * browser tears the page down.
953
+ */
954
+ stop() {
955
+ if (!this.started)
956
+ return;
957
+ safeClearTimeout(this.flushTimer);
958
+ this.flushTimer = null;
959
+ safeClearTimeout(this.retryDrainTimer);
960
+ this.retryDrainTimer = null;
961
+ this.flush('lifecycle');
962
+ this.started = false;
963
+ }
964
+ /**
965
+ * Notifies the manager that the browser's connection state has changed.
966
+ * Reads `navigator.onLine` inline at call time.
967
+ *
968
+ * On transition to offline: cancels both timers but preserves the retry
969
+ * queue so no pending retry batches are lost.
970
+ * On transition to online: schedules an immediate flush if the main queue is
971
+ * non-empty; re-arms the retry drain if the retry queue is non-empty.
972
+ */
973
+ onConnectionChange() {
974
+ const nowOnline = typeof navigator !== 'undefined' ? navigator.onLine : true;
975
+ if (nowOnline === this.online)
976
+ return;
977
+ this.online = nowOnline;
978
+ if (!this.online) {
979
+ safeClearTimeout(this.flushTimer);
980
+ this.flushTimer = null;
981
+ safeClearTimeout(this.retryDrainTimer);
982
+ this.retryDrainTimer = null;
983
+ return;
984
+ }
985
+ if (this.queue.length > 0) {
986
+ this.scheduleFlush();
987
+ }
988
+ if (this.retryQueue.length > 0) {
989
+ this.scheduleRetryDrain();
990
+ }
545
991
  }
546
- scheduleRetryFlush() {
547
- if (this.destroyed)
992
+ /**
993
+ * Schedules a flush after `effectiveIntervalMs`. Idempotent — if a timer is
994
+ * already running the call is a no-op. No-ops if the manager is stopped or
995
+ * offline.
996
+ */
997
+ scheduleFlush() {
998
+ if (!this.started || !this.online || this.flushTimer !== null)
548
999
  return;
549
- if (this.retryFlushTimer !== null)
1000
+ this.flushTimer = safeSetTimeout(() => {
1001
+ this.flushTimer = null;
1002
+ this.flush('interval');
1003
+ }, this.effectiveIntervalMs);
1004
+ }
1005
+ /**
1006
+ * Arms the shared retry drain timer for `effectiveIntervalMs`. Idempotent
1007
+ * — if the drain timer is already running the call is a no-op. No-ops if
1008
+ * the manager is stopped or offline.
1009
+ */
1010
+ scheduleRetryDrain() {
1011
+ if (!this.started || !this.online || this.retryDrainTimer !== null)
550
1012
  return;
551
- if (this.retryQueue.length === 0)
1013
+ this.retryDrainTimer = safeSetTimeout(() => {
1014
+ this.retryDrainTimer = null;
1015
+ this.drainRetryQueue();
1016
+ }, this.effectiveIntervalMs);
1017
+ }
1018
+ /**
1019
+ * Dequeues the head entry from the retry queue and fires a single `ingest`
1020
+ * call. If the queue still contains entries after the result is handled,
1021
+ * `scheduleRetryDrain()` re-arms the timer, keeping the drain rate at one
1022
+ * batch per `effectiveIntervalMs`.
1023
+ */
1024
+ drainRetryQueue() {
1025
+ if (!this.started || !this.online || this.retryQueue.length === 0)
552
1026
  return;
553
- this.retryFlushTimer = setTimeout(() => {
554
- this.retryFlushTimer = null;
555
- void this.flushRetryQueue();
556
- }, this.config.flush.intervalMs * 2);
557
- }
558
- async flushRetryQueue() {
559
- const due = this.retryQueue.splice(0, this.retryQueue.length);
560
- for (const item of due) {
561
- const result = await this.context.ingest(item.batch, false);
562
- if (result.ok)
563
- continue;
564
- if (!this.isRetryable(result) || item.attempt >= 1) {
565
- {
566
- // eslint-disable-next-line no-console
567
- console.warn('[TriSDK] batch dropped after retries:', item.batch.batchId);
568
- }
569
- continue;
570
- }
571
- this.retryQueue.push({ batch: item.batch, attempt: item.attempt + 1 });
1027
+ const entry = this.retryQueue.shift();
1028
+ void this.context
1029
+ .ingest(entry.batch, false)
1030
+ .then((result) => this.handleRetryResult(result, entry))
1031
+ .catch(() => {
1032
+ logger.warn('Transport', 'retry drain ingest threw unexpectedly — batch dropped');
1033
+ if (this.retryQueue.length > 0)
1034
+ this.scheduleRetryDrain();
1035
+ });
1036
+ }
1037
+ /**
1038
+ * Enqueues a failed batch for retry. If the queue is at `MAX_RETRY_BATCHES`
1039
+ * the oldest entry is evicted (FIFO) to make room, accepting the loss of
1040
+ * that batch's events. Arms the drain timer if not already running.
1041
+ */
1042
+ enqueueRetry(batch, attempts) {
1043
+ if (this.retryQueue.length >= MAX_RETRY_BATCHES) {
1044
+ this.retryQueue.shift();
1045
+ logger.warn('Transport', 'retry queue full — oldest batch evicted');
1046
+ }
1047
+ this.retryQueue.push({ batch, attempts });
1048
+ this.scheduleRetryDrain();
1049
+ }
1050
+ /**
1051
+ * Handles the resolved result of a main-flush `ingest` call.
1052
+ *
1053
+ * - Success: snap the backoff interval back to the configured base.
1054
+ * - Retryable failure: hand the batch to the retry queue.
1055
+ * - Non-retryable failure (`abort`, `client`, `parse`, `serialize`): accept
1056
+ * the loss — retrying would produce the same outcome.
1057
+ */
1058
+ handleIngestResult(result, batch) {
1059
+ if (result.ok) {
1060
+ this.resetBackoffOnSuccess();
1061
+ return;
1062
+ }
1063
+ if (!this.isRetryable(result))
1064
+ return;
1065
+ this.enqueueRetry(batch, 1);
1066
+ }
1067
+ /**
1068
+ * Handles the resolved result of a retry-drain `ingest` call.
1069
+ *
1070
+ * - Success: snap the backoff interval back to the configured base; re-arm
1071
+ * the drain if more entries remain.
1072
+ * - Retryable + attempts under cap: increment `attempts`, push to the tail,
1073
+ * widen backoff if the failure threshold is crossed, re-arm drain.
1074
+ * - Retryable + attempts exhausted: accept the loss, widen backoff, re-arm
1075
+ * drain if more entries remain.
1076
+ * - Non-retryable: drop silently, re-arm drain if more entries remain.
1077
+ */
1078
+ handleRetryResult(result, entry) {
1079
+ if (result.ok) {
1080
+ this.resetBackoffOnSuccess();
1081
+ if (this.retryQueue.length > 0)
1082
+ this.scheduleRetryDrain();
1083
+ return;
1084
+ }
1085
+ if (!this.isRetryable(result)) {
1086
+ if (this.retryQueue.length > 0)
1087
+ this.scheduleRetryDrain();
1088
+ return;
1089
+ }
1090
+ if (entry.attempts >= MAX_RETRY_ATTEMPTS) {
1091
+ this.adjustBackoffInterval();
1092
+ logger.warn('Transport', 'batch dropped after MAX_RETRY_ATTEMPTS', entry.batch.batchId);
1093
+ if (this.retryQueue.length > 0)
1094
+ this.scheduleRetryDrain();
1095
+ return;
1096
+ }
1097
+ this.adjustBackoffInterval();
1098
+ this.enqueueRetry(entry.batch, entry.attempts + 1);
1099
+ }
1100
+ /**
1101
+ * Increments the consecutive-failure counter and doubles `effectiveIntervalMs`
1102
+ * once the `backoffFailureThreshold` is crossed, up to `maxIntervalMs`.
1103
+ */
1104
+ adjustBackoffInterval() {
1105
+ this.consecutiveFailures++;
1106
+ if (this.consecutiveFailures % this.config.flush.backoffFailureThreshold ===
1107
+ 0) {
1108
+ this.effectiveIntervalMs = Math.min(this.effectiveIntervalMs * 2, this.config.flush.maxIntervalMs);
1109
+ }
1110
+ }
1111
+ /**
1112
+ * Snaps both the consecutive-failure counter and `effectiveIntervalMs` back
1113
+ * to their initial values on the first successful flush or drain.
1114
+ */
1115
+ resetBackoffOnSuccess() {
1116
+ this.consecutiveFailures = 0;
1117
+ this.effectiveIntervalMs = this.config.flush.intervalMs;
1118
+ }
1119
+ /**
1120
+ * Returns the current effective queue hard cap. The cap doubles when the
1121
+ * browser is offline so that events are preserved longer during outages.
1122
+ */
1123
+ effectiveQueueCap() {
1124
+ return this.config.queue.size * (this.online ? 1 : 2);
1125
+ }
1126
+ /**
1127
+ * Drains all retry queue entries as best-effort keepalive requests (in
1128
+ * FIFO order) then sends the newest `flush.size` events from the main queue
1129
+ * as a keepalive.
1130
+ *
1131
+ * NOTE: keepalive bodies are capped at ~64 KiB by browsers. Sending up to
1132
+ * `MAX_RETRY_BATCHES + 1` keepalive requests in rapid succession may exceed
1133
+ * per-request or aggregate browser limits. See proposal open question §3.
1134
+ */
1135
+ flushLifecycle() {
1136
+ for (const entry of this.retryQueue) {
1137
+ void this.context.ingest(entry.batch, true).catch(() => {
1138
+ logger.warn('Transport', 'lifecycle retry ingest threw — batch dropped');
1139
+ });
572
1140
  }
573
- this.scheduleRetryFlush();
1141
+ this.retryQueue = [];
1142
+ if (this.queue.length === 0)
1143
+ return;
1144
+ const batch = this.createBatch('lifecycle');
1145
+ void this.context.ingest(batch, true).catch(() => {
1146
+ logger.warn('Transport', 'lifecycle ingest threw — batch dropped');
1147
+ });
574
1148
  }
575
1149
  /**
576
1150
  * Priority-aware eviction (architecture §10.2).
@@ -578,526 +1152,666 @@
578
1152
  * Walks the queue oldest → newest to find the first event whose priority is
579
1153
  * strictly less than the incoming event's priority. If found, evicts it and
580
1154
  * enqueues the incoming event. If no lower-priority candidate exists, the
581
- * incoming event is dropped instead.
582
- *
583
- * Both the evicted-existing case and the dropped-incoming case increment the
584
- * `droppedEvents` counter for the affected priority class (§10.3): any loss
585
- * must be reflected in the batch envelope so the backend can distinguish
586
- * pressure-induced drops from genuine user inactivity.
1155
+ * incoming event is dropped (it has the lowest priority in the queue).
587
1156
  */
588
- evictAndEnqueue(event) {
1157
+ evictAndPush(event) {
589
1158
  const incomingPriority = getEventPriority(event);
590
1159
  for (let index = 0; index < this.queue.length; index++) {
591
1160
  const candidatePriority = getEventPriority(this.queue[index]);
592
1161
  if (candidatePriority < incomingPriority) {
593
- this.queue.splice(index, 1);
594
- this.droppedEvents[candidatePriority]++;
1162
+ for (let i = index; i < this.queue.length - 1; i++) {
1163
+ this.queue[i] = this.queue[i + 1];
1164
+ }
1165
+ this.queue.length -= 1;
595
1166
  this.queue.push(event);
596
1167
  return;
597
1168
  }
598
1169
  }
599
- // No evictable candidate found — the incoming event itself is dropped.
600
- this.droppedEvents[incomingPriority]++;
601
1170
  }
602
1171
  /**
603
- * Retry policy for the http layer's discriminated failure kinds.
1172
+ * Assembles a batch from the queue.
1173
+ *
1174
+ * Normal path: splices `flush.size` events from the head (FIFO).
1175
+ * Lifecycle path: splices `flush.size` events from the tail (newest-first).
1176
+ *
1177
+ * `activityId` is intentionally set to an empty string here. The authoritative
1178
+ * value is stamped by the `api.ts` `withAuth` callback immediately before the
1179
+ * network request, ensuring that a 401-triggered token refresh (which mints a
1180
+ * new activityId on the backend) is reflected in the retried request.
1181
+ */
1182
+ createBatch(reason) {
1183
+ const events = reason === 'lifecycle'
1184
+ ? this.queue.splice(-this.config.flush.size)
1185
+ : this.queue.splice(0, this.config.flush.size);
1186
+ return {
1187
+ activityId: '',
1188
+ batchId: generateUUID(),
1189
+ batchSequence: this.batchSequence++,
1190
+ clientTimestamp: Date.now(),
1191
+ schemaVersion: WIRE.schemaVersion,
1192
+ sdkVersion: WIRE.sdkVersion,
1193
+ sdkPlatform: WIRE.sdkPlatform,
1194
+ tabId: this.context.getTabId(),
1195
+ events
1196
+ };
1197
+ }
1198
+ /**
1199
+ * Returns whether a failed `HttpResult` is worth retrying.
604
1200
  *
605
- * - `network` / `timeout`: transient transport issue, retry.
606
- * - `server` / `rate_limited`: backend asked us to back off, retry.
607
- * - `abort`: the request was cancelled deliberately (today only via
608
- * `options.signal` from teardown paths). Retrying defeats the cancel.
1201
+ * - `network` / `timeout`: transient transport issue.
1202
+ * - `server` / `rate_limited`: backend asked us to back off.
1203
+ * - `abort`: deliberate cancellation; retrying defeats the cancel.
609
1204
  * - `client` / `parse` / `serialize`: our bug; the next attempt will
610
- * fail the same way. Drop the batch.
1205
+ * produce the same result. Drop the batch.
1206
+ *
1207
+ * NOTE: If a new kind is added to `HttpResult['kind']` in `@/lib/http/types`,
1208
+ * update this switch. See the JSDoc on that type for the full extension guide.
611
1209
  */
612
1210
  isRetryable(result) {
613
1211
  if (result.ok)
614
1212
  return false;
615
1213
  switch (result.kind) {
616
- case 'network':
617
1214
  case 'timeout':
618
1215
  case 'server':
619
1216
  case 'rate_limited':
620
1217
  return true;
621
- default:
1218
+ case 'abort':
1219
+ case 'client':
1220
+ case 'parse':
1221
+ case 'serialize':
1222
+ case 'network':
622
1223
  return false;
1224
+ default: {
1225
+ return false;
1226
+ }
623
1227
  }
624
1228
  }
625
- createBatch() {
626
- const droppedEvents = this.snapshotAndResetDroppedEvents();
627
- return {
628
- activityId: this.context.getActivityId(),
629
- batchId: generateUUID(),
630
- batchSequence: this.batchSequence++,
631
- clientTimestamp: Date.now(),
632
- schemaVersion: WIRE.schemaVersion,
633
- sdkVersion: WIRE.sdkVersion,
634
- sdkPlatform: WIRE.sdkPlatform,
635
- tabId: this.context.getTabId(),
636
- droppedEvents,
637
- events: this.queue.splice(0, this.config.flush.size)
638
- };
1229
+ }
1230
+
1231
+ function createStorage(storageId) {
1232
+ function hasStorage() {
1233
+ return (globalThis.window &&
1234
+ globalThis.window[storageId] &&
1235
+ typeof globalThis.window[storageId] !== 'undefined');
639
1236
  }
640
- /**
641
- * Snapshots the current per-priority drop counters as a `Record<string,
642
- * number>` (string keys are stable JSON) and resets the in-memory counters
643
- * to zero in the same step. Called exactly once per `createBatch()`.
644
- */
645
- snapshotAndResetDroppedEvents() {
646
- const snapshot = {};
647
- for (const priority of PRIORITY_VALUES) {
648
- snapshot[String(priority)] = this.droppedEvents[priority];
649
- this.droppedEvents[priority] = 0;
1237
+ function get(key) {
1238
+ if (!hasStorage())
1239
+ return null;
1240
+ try {
1241
+ const raw = window[storageId].getItem(key) ?? 'null';
1242
+ if (raw === 'null')
1243
+ return null;
1244
+ return JSON.parse(raw);
650
1245
  }
651
- return snapshot;
1246
+ catch {
1247
+ return null;
1248
+ }
1249
+ }
1250
+ function set(key, value) {
1251
+ if (!hasStorage())
1252
+ return;
1253
+ safeSync(() => window[storageId].setItem(key, JSON.stringify(value)));
652
1254
  }
1255
+ function remove(key) {
1256
+ if (!hasStorage())
1257
+ return;
1258
+ safeSync(() => window[storageId].removeItem(key));
1259
+ }
1260
+ function key(index) {
1261
+ if (!hasStorage())
1262
+ return null;
1263
+ return window[storageId].key(index);
1264
+ }
1265
+ function length() {
1266
+ if (!hasStorage())
1267
+ return 0;
1268
+ return window[storageId].length;
1269
+ }
1270
+ return {
1271
+ get,
1272
+ set,
1273
+ remove,
1274
+ key,
1275
+ get length() {
1276
+ return length();
1277
+ }
1278
+ };
653
1279
  }
1280
+ const LocalStorage = createStorage('localStorage');
1281
+ const SessionStorage = createStorage('sessionStorage');
654
1282
 
655
- /** Maximum byte length of a URL stored in any NetworkRequest event field. */
656
- const MAX_URL_LENGTH = 2048;
657
- /**
658
- * Initiator types the PerformanceObserver entry mapper normalises to 'other'
659
- * when the browser returns a string not in the known enumeration.
660
- */
661
- const KNOWN_INITIATOR_TYPES = new Set([
662
- 'xmlhttprequest',
663
- 'fetch',
664
- 'beacon',
665
- 'navigation',
666
- 'script',
667
- 'link',
668
- 'css',
669
- 'img',
670
- 'iframe',
671
- 'video',
672
- 'audio'
673
- ]);
674
1283
  /**
675
- * Default set of PerformanceResourceTiming initiator types that the observer
676
- * emits events for. Restricted to API-call sources by default to avoid
677
- * generating high-volume events for every image, script, and stylesheet load.
1284
+ * @module storage-event-channel
678
1285
  *
679
- * Callers can override this by passing a custom set to `createResourceObserver`.
680
- * Pass `KNOWN_INITIATOR_TYPES` (or a superset) to capture all resource types.
681
- */
682
- const DEFAULT_RESOURCE_INITIATOR_TYPES = new Set([
683
- 'xmlhttprequest',
684
- 'fetch',
685
- 'beacon'
686
- ]);
687
- /** Name used in error logging for this collector. */
688
- const COLLECTOR_NAME = 'network-request';
689
-
690
- /**
691
- * Clamps a URL string to MAX_URL_LENGTH characters so enormous query strings
692
- * do not bloat the wire payload.
693
- */
694
- function truncateUrl(url) {
695
- return url.length > MAX_URL_LENGTH ? url.slice(0, MAX_URL_LENGTH) : url;
696
- }
697
- /**
698
- * Normalises the raw string from PerformanceResourceTiming.initiatorType into
699
- * the known enumeration, falling back to 'other' for unrecognised values.
700
- */
701
- function normaliseInitiatorType(raw) {
702
- return KNOWN_INITIATOR_TYPES.has(raw)
703
- ? raw
704
- : 'other';
705
- }
706
- /**
707
- * Returns null when the entry's value is 0 and the field is a phase timing
708
- * that is expected to be null cross-origin (the browser sets it to 0 in that
709
- * case, not to a meaningful zero-duration).
1286
+ * BroadcastChannel fallback backed by localStorage + the window `storage` event.
1287
+ *
1288
+ * ## How it works
1289
+ *
1290
+ * `postMessage` serialises the payload to JSON and writes it under a unique
1291
+ * key (`tri:bcast:<channel>:<sender-uuid>:<seq>`). The `storage` event fires
1292
+ * synchronously in every other tab that has the same origin. The writer then
1293
+ * immediately removes the key so no durable state is left in localStorage.
1294
+ *
1295
+ * ## Same-tab echo prevention
1296
+ *
1297
+ * The browser's `storage` event does NOT fire in the tab that performed the
1298
+ * write — this matches native BroadcastChannel's contract exactly. The
1299
+ * listener in this file therefore never echoes a message back to the sender.
1300
+ * Do NOT change the mechanism (e.g. dispatch a synthetic StorageEvent) without
1301
+ * verifying this invariant is preserved.
1302
+ *
1303
+ * ## Key lifecycle
1304
+ *
1305
+ * 1. Writer: setItem → removeItem (synchronous, back-to-back).
1306
+ * 2. Receiver: sees the storage event for setItem (newValue = payload), parses
1307
+ * it, then defensively calls removeItem on the key to clean up any entry
1308
+ * that survived a writer crash. Events for removeItem (newValue = null) are
1309
+ * ignored.
1310
+ *
1311
+ * ## Failure isolation
710
1312
  *
711
- * The heuristic: when responseStart === 0 the browser withheld the timing
712
- * information due to the Timing-Allow-Origin policy.
1313
+ * Throws inside JSON.parse, listener callbacks, or localStorage writes are
1314
+ * caught and silently dropped. The channel degrades to a no-op per operation
1315
+ * rather than crashing the host page (rule #2 of CLAUDE.md).
713
1316
  */
714
- function phaseMs(value, hasTimingAccess) {
715
- return hasTimingAccess ? value : null;
716
- }
1317
+ const STORAGE_KEY_PREFIX = 'tri:bcast:';
717
1318
  /**
718
- * Maps a PerformanceResourceTiming entry to a NetworkRequestResourceEvent
719
- * ready to push into the collector context.
1319
+ * BroadcastChannel-like backed by localStorage and the window `storage` event.
1320
+ * Used as a fallback on browsers that do not support native BroadcastChannel.
720
1321
  */
721
- function toResourceEvent(entry) {
722
- const now = performance.now();
723
- // responseStart === 0 (and duration > 0) is the browser's signal that
724
- // cross-origin Timing-Allow-Origin headers were absent.
725
- const hasTimingAccess = entry.responseStart > 0;
726
- const dnsLookupMs = phaseMs(entry.domainLookupEnd - entry.domainLookupStart, hasTimingAccess);
727
- const tcpConnectMs = phaseMs(entry.connectEnd - entry.connectStart, hasTimingAccess);
728
- const tlsHandshakeMs = phaseMs(entry.connectEnd > 0 && entry.secureConnectionStart > 0
729
- ? entry.connectEnd - entry.secureConnectionStart
730
- : 0, hasTimingAccess);
731
- const ttfbMs = phaseMs(entry.responseStart - entry.requestStart, hasTimingAccess);
732
- const responseDownloadMs = phaseMs(entry.responseEnd - entry.responseStart, hasTimingAccess);
733
- const transferSize = hasTimingAccess ? entry.transferSize : null;
734
- const encodedBodySize = hasTimingAccess ? entry.encodedBodySize : null;
735
- const decodedBodySize = hasTimingAccess ? entry.decodedBodySize : null;
736
- const nextHopProtocol = hasTimingAccess ? entry.nextHopProtocol || null : null;
737
- // deliveryType is a newer addition; safely access via optional chaining.
738
- const deliveryTypeRaw = entry.deliveryType;
739
- const deliveryType = hasTimingAccess && deliveryTypeRaw !== undefined
740
- ? deliveryTypeRaw
741
- : null;
742
- // responseStatus is Chrome 112+. Safely access via optional chaining.
743
- const responseStatusRaw = entry.responseStatus;
744
- const responseStatus = responseStatusRaw !== undefined ? responseStatusRaw : null;
745
- return {
746
- name: 'network_request',
747
- type: 'resource',
748
- timestamp: Date.now(),
749
- performanceTime: now,
750
- url: truncateUrl(entry.name),
751
- initiatorType: normaliseInitiatorType(entry.initiatorType),
752
- startTime: entry.startTime,
753
- durationMs: entry.duration,
754
- dnsLookupMs,
755
- tcpConnectMs,
756
- tlsHandshakeMs,
757
- ttfbMs,
758
- responseDownloadMs,
759
- transferSize,
760
- encodedBodySize,
761
- decodedBodySize,
762
- nextHopProtocol,
763
- deliveryType,
764
- responseStatus
765
- };
766
- }
767
- /**
768
- * Derives the byte count of a fetch / XHR request body without consuming
769
- * it. Returns null for body types where consumption would be required
770
- * (FormData, URLSearchParams, ReadableStream) or when the body is absent.
771
- */
772
- function deriveRequestBodyBytes(body) {
773
- if (body === null || body === undefined)
774
- return null;
775
- if (typeof body === 'string')
776
- return new TextEncoder().encode(body).byteLength;
777
- if (body instanceof ArrayBuffer)
778
- return body.byteLength;
779
- if (ArrayBuffer.isView(body))
780
- return body.byteLength;
781
- if (body instanceof Blob)
782
- return body.size;
783
- // FormData, URLSearchParams, ReadableStream: cannot measure without consuming.
784
- return null;
1322
+ class StorageEventChannel {
1323
+ constructor(name) {
1324
+ this.seq = 0;
1325
+ this.closed = false;
1326
+ this.listeners = new Set();
1327
+ /**
1328
+ * Arrow property so `this` is bound when passed directly to
1329
+ * addEventListener / removeEventListener.
1330
+ */
1331
+ this.onStorageEvent = (event) => {
1332
+ if (this.closed)
1333
+ return;
1334
+ const keyPrefix = `${STORAGE_KEY_PREFIX}${this.channelName}:`;
1335
+ if (!event.key || !event.key.startsWith(keyPrefix))
1336
+ return;
1337
+ // Ignore the removal event produced by the writer's own removeItem call.
1338
+ if (event.newValue === null)
1339
+ return;
1340
+ // Defensive cleanup: the writer should have removed this already, but
1341
+ // guard against crashes mid-write that leave a stale entry.
1342
+ safeSync(() => LocalStorage.remove(event.key));
1343
+ let data;
1344
+ try {
1345
+ data = JSON.parse(event.newValue);
1346
+ }
1347
+ catch {
1348
+ return;
1349
+ }
1350
+ const dispatched = { data };
1351
+ for (const listener of this.listeners) {
1352
+ safeSync(() => listener(dispatched));
1353
+ }
1354
+ };
1355
+ this.channelName = name;
1356
+ this.senderId = generateUUID();
1357
+ window.addEventListener('storage', this.onStorageEvent);
1358
+ }
1359
+ /**
1360
+ * Serialises `message` to JSON, writes it under a unique key, then removes
1361
+ * it immediately. The write triggers the `storage` event in all other tabs
1362
+ * on the same origin; the removal keeps localStorage clean.
1363
+ *
1364
+ * Payload size is not explicitly bounded here because tab-discovery messages
1365
+ * are tiny plain-object JSON. If the channel is ever used for larger payloads
1366
+ * this assumption must be revisited against the ~5 MB localStorage quota.
1367
+ */
1368
+ postMessage(message) {
1369
+ if (this.closed)
1370
+ return;
1371
+ const key = `${STORAGE_KEY_PREFIX}${this.channelName}:${this.senderId}:${this.seq++}`;
1372
+ safeSync(() => {
1373
+ LocalStorage.set(key, message);
1374
+ // The storage event fires in other tabs upon setItem; remove the key
1375
+ // immediately so no stale entry persists if a subsequent read races.
1376
+ LocalStorage.remove(key);
1377
+ });
1378
+ }
1379
+ addEventListener(_type, listener) {
1380
+ this.listeners.add(listener);
1381
+ }
1382
+ removeEventListener(_type, listener) {
1383
+ this.listeners.delete(listener);
1384
+ }
1385
+ /** Detaches the storage listener and prevents further dispatch. */
1386
+ close() {
1387
+ if (this.closed)
1388
+ return;
1389
+ this.closed = true;
1390
+ window.removeEventListener('storage', this.onStorageEvent);
1391
+ this.listeners.clear();
1392
+ }
785
1393
  }
1394
+
786
1395
  /**
787
- * Resolves the URL string from a fetch RequestInfo argument, which may be
788
- * a plain string, a URL object, or a Request object.
1396
+ * @module broadcast-channel
1397
+ *
1398
+ * Ponyfill selector for BroadcastChannel.
1399
+ *
1400
+ * ## Ponyfill, not polyfill
1401
+ *
1402
+ * This module exports `TriBroadcastChannel` — a local binding that resolves
1403
+ * to native BroadcastChannel when available, a localStorage fallback when not,
1404
+ * or a no-op when neither storage mechanism is usable (e.g. private mode with
1405
+ * storage blocked). The host page's `globalThis` is never modified.
1406
+ *
1407
+ * ## Detection order
1408
+ *
1409
+ * 1. Native `BroadcastChannel` — detected via `typeof BroadcastChannel === 'function'`
1410
+ * so any polyfill the host app installed is transparently honoured.
1411
+ * 2. `StorageEventChannel` — requires `window` and a writable `localStorage`.
1412
+ * Availability is probed once at module load via a setItem/getItem/removeItem
1413
+ * round-trip, matching the approach used in `lib/immortal-db/storages/web.ts`.
1414
+ * 3. `NoopChannel` — silent fallback when neither option is available. Emits
1415
+ * a `logger.warn` at construction time so developers can see that tab
1416
+ * discovery is disabled (when `DEBUG` is enabled).
1417
+ *
1418
+ * ## Module-load detection
1419
+ *
1420
+ * Selection happens once when this module is first imported and the result is
1421
+ * cached in `TriBroadcastChannel`. This is intentional: re-probing on every
1422
+ * constructor call would be wasteful and storage availability does not change
1423
+ * within a session.
789
1424
  */
790
- function resolveRequestUrl(input) {
791
- if (typeof input === 'string')
792
- return input;
793
- if (input instanceof URL)
794
- return input.href;
795
- // Request object
796
- return input.url;
797
- }
1425
+ // ─── No-op channel ────────────────────────────────────────────────────────────
798
1426
  /**
799
- * Resolves the HTTP method from a fetch RequestInfo argument, with an
800
- * optional override from RequestInit.method.
1427
+ * Silent fallback used when neither native BroadcastChannel nor localStorage
1428
+ * is available. All operations are no-ops. Tab count will stay at 1.
1429
+ * Emits a `logger.warn` at construction so developers can see that
1430
+ * cross-tab discovery is disabled (when `DEBUG` is enabled).
801
1431
  */
802
- function resolveRequestMethod(input, init) {
803
- if (init?.method)
804
- return init.method.toUpperCase();
805
- if (input instanceof Request)
806
- return input.method.toUpperCase();
807
- return 'GET';
1432
+ class NoopChannel {
1433
+ constructor(_name) {
1434
+ logger.warn('BroadcastChannel', 'BroadcastChannel is unavailable and localStorage is not ' +
1435
+ 'writable in this context. Cross-tab discovery is disabled; tab ' +
1436
+ 'count will always be reported as 1.');
1437
+ }
1438
+ postMessage(_message) { }
1439
+ addEventListener(_type, _listener) { }
1440
+ removeEventListener(_type, _listener) { }
1441
+ close() { }
808
1442
  }
1443
+ // ─── Availability probes ──────────────────────────────────────────────────────
809
1444
  /**
810
- * Returns true when the given URL shares the same origin as the current page.
811
- * Silently returns false for URLs that cannot be parsed.
1445
+ * Returns true if `localStorage` is present and can be written to.
1446
+ * Catches: Safari private mode (throws on setItem), storage disabled by
1447
+ * policy, quota-exhausted state.
1448
+ *
1449
+ * Uses `typeof Storage !== 'undefined'` as a fast pre-check; the round-trip
1450
+ * write is needed because that check alone is unreliable in embedded contexts.
812
1451
  */
813
- function isSameOrigin(url) {
1452
+ function isLocalStorageWritable() {
1453
+ if (typeof Storage === 'undefined' || typeof window === 'undefined') {
1454
+ return false;
1455
+ }
814
1456
  try {
815
- return new URL(url, location.href).origin === location.origin;
1457
+ const probeKey = '__tri_bcast_probe__';
1458
+ localStorage.setItem(probeKey, '1');
1459
+ const ok = localStorage.getItem(probeKey) === '1';
1460
+ localStorage.removeItem(probeKey);
1461
+ return ok;
816
1462
  }
817
1463
  catch {
818
1464
  return false;
819
1465
  }
820
1466
  }
1467
+ // ─── Selector ─────────────────────────────────────────────────────────────────
1468
+ function pickChannel() {
1469
+ if (typeof BroadcastChannel === 'function') {
1470
+ return BroadcastChannel;
1471
+ }
1472
+ if (isLocalStorageWritable()) {
1473
+ return StorageEventChannel;
1474
+ }
1475
+ return NoopChannel;
1476
+ }
1477
+ /**
1478
+ * Drop-in BroadcastChannel constructor that works across environments.
1479
+ *
1480
+ * Resolves at module load to:
1481
+ * - Native `BroadcastChannel` when available (including host-app polyfills).
1482
+ * - `StorageEventChannel` (localStorage + storage event) on older browsers.
1483
+ * - `NoopChannel` when neither is usable.
1484
+ *
1485
+ * Usage is identical to native BroadcastChannel:
1486
+ * ```ts
1487
+ * const channel = new TriBroadcastChannel('my-channel')
1488
+ * channel.addEventListener('message', handler)
1489
+ * channel.postMessage({ type: 'hello' })
1490
+ * channel.close()
1491
+ * ```
1492
+ */
1493
+ const TriBroadcastChannel = pickChannel();
821
1494
 
822
- function installFetchPatch(emit, shouldIgnore) {
823
- // Capture whatever fetch is installed right now. If a third-party
824
- // instrumentation (Sentry, Datadog) wrapped fetch before us, we preserve
825
- // their wrapper as our "previous" reference and call it from our patch.
826
- const previousFetch = typeof globalThis.fetch === 'function' ? globalThis.fetch : undefined;
827
- if (previousFetch === undefined) {
828
- return () => void 0;
829
- }
830
- const patchedFetch = function patchedFetchImpl(input, init) {
831
- const url = resolveRequestUrl(input);
832
- const truncatedUrl = truncateUrl(url);
833
- const requestStart = performance.now();
834
- if (shouldIgnore(url)) {
835
- return previousFetch(input, init);
836
- }
837
- const method = resolveRequestMethod(input, init);
838
- const credentials = init?.credentials ??
839
- (input instanceof Request ? input.credentials : null);
840
- const cache = init?.cache ?? (input instanceof Request ? input.cache : null);
841
- const mode = init?.mode ?? (input instanceof Request ? input.mode : null);
842
- const keepalive = init?.keepalive ?? false;
843
- const requestBodyBytes = deriveRequestBodyBytes(init?.body ?? null);
844
- let originalPromise;
1495
+ const STORAGE_KEY$1 = 'tri:tabId';
1496
+ const CHANNEL_NAME = 'tri:tab_channel';
1497
+ const HEARTBEAT_INTERVAL_MS = 1000;
1498
+ const IDLE_HEARTBEAT_INTERVAL_MS = 3 * HEARTBEAT_INTERVAL_MS;
1499
+ /* setting to 1s due to browser tab throttling to 1Hz so 500ms could be missed */
1500
+ const COUNT_EMIT_DEBOUNCE_MS = 1000;
1501
+ class TabManager {
1502
+ constructor(context) {
1503
+ this.context = context;
1504
+ this.tabId = '';
1505
+ this.started = false;
1506
+ this.broadcastChannel = null;
1507
+ this.heartbeatTimer = null;
1508
+ this.countEmitTimer = null;
1509
+ this.peers = new Map();
1510
+ this.lastEmittedCount = -1;
1511
+ this.bound_OnBroadcastMessage = null;
1512
+ }
1513
+ async start() {
1514
+ if (this.started)
1515
+ return;
1516
+ this.started = true;
1517
+ this.tabId = this._generateTabId();
1518
+ this._openChannel();
1519
+ this._broadcast({ type: 'hello', tabId: this.tabId, ts: Date.now() });
1520
+ this._startHeartbeat();
1521
+ this._emitTabState('active');
1522
+ this._scheduleCountEmit();
1523
+ }
1524
+ stop() {
1525
+ if (!this.started)
1526
+ return;
1527
+ this.started = false;
1528
+ this._emitTabState('inactive');
1529
+ this._broadcast({ type: 'bye', tabId: this.tabId, ts: Date.now() });
1530
+ /* there is no increment because we are emitting count of the remaining tabs */
1531
+ this._emitTabCount(this._sweepPeers());
1532
+ this._stopHeartbeat();
1533
+ this._cancelCountEmit();
1534
+ this._closeChannel();
1535
+ }
1536
+ getTabId() {
1537
+ return this.tabId;
1538
+ }
1539
+ queryTabsNumber() {
1540
+ return this.peers.size + 1;
1541
+ }
1542
+ _openChannel() {
845
1543
  try {
846
- originalPromise = previousFetch(input, init);
1544
+ this.broadcastChannel = new TriBroadcastChannel(CHANNEL_NAME);
1545
+ this.bound_OnBroadcastMessage = this._onBroadcastMessage.bind(this);
1546
+ this.broadcastChannel.addEventListener('message', this.bound_OnBroadcastMessage);
847
1547
  }
848
1548
  catch (error) {
849
- safeSync(() => {
850
- const requestEnd = performance.now();
851
- const event = {
852
- name: 'network_request',
853
- type: 'invocation',
854
- source: 'fetch',
855
- timestamp: Date.now(),
856
- performanceTime: requestEnd,
857
- url: truncatedUrl,
858
- method,
859
- isSameOrigin: isSameOrigin(url),
860
- credentials: credentials,
861
- cache,
862
- mode,
863
- keepalive,
864
- requestBodyBytes,
865
- requestStart,
866
- requestEnd,
867
- durationMs: requestEnd - requestStart,
868
- responseStatus: null,
869
- ok: null,
870
- outcome: 'error',
871
- errorName: error instanceof Error ? error.name.slice(0, 100) : 'UnknownError'
872
- };
873
- emit(event);
874
- });
875
- throw error;
876
- }
877
- // Side-effect observation branch. The returned promise from this .then()
878
- // is intentionally discarded — the host receives `originalPromise`. Both
879
- // handlers are provided so the floating branch can never become an
880
- // unhandledrejection, and safeSync guarantees neither handler throws.
881
- originalPromise.then((response) => {
882
- safeSync(() => {
883
- const requestEnd = performance.now();
884
- const event = {
885
- name: 'network_request',
886
- type: 'invocation',
887
- source: 'fetch',
888
- timestamp: Date.now(),
889
- performanceTime: requestEnd,
890
- url: truncatedUrl,
891
- method,
892
- isSameOrigin: isSameOrigin(url),
893
- credentials: credentials,
894
- cache,
895
- mode,
896
- keepalive,
897
- requestBodyBytes,
898
- requestStart,
899
- requestEnd,
900
- durationMs: requestEnd - requestStart,
901
- responseStatus: response.status,
902
- ok: response.ok,
903
- outcome: response.ok ? 'success' : 'error',
904
- errorName: null
905
- };
906
- emit(event);
907
- });
908
- }, (error) => {
909
- safeSync(() => {
910
- const requestEnd = performance.now();
911
- const isDomException = error instanceof DOMException;
912
- const outcome = isDomException && error.name === 'AbortError' ? 'abort' : 'error';
913
- const event = {
914
- name: 'network_request',
915
- type: 'invocation',
916
- source: 'fetch',
917
- timestamp: Date.now(),
918
- performanceTime: requestEnd,
919
- url: truncatedUrl,
920
- method,
921
- isSameOrigin: isSameOrigin(url),
922
- credentials: credentials,
923
- cache,
924
- mode,
925
- keepalive,
926
- requestBodyBytes,
927
- requestStart,
928
- requestEnd,
929
- durationMs: requestEnd - requestStart,
930
- responseStatus: null,
931
- ok: null,
932
- outcome,
933
- errorName: error instanceof Error ? error.name.slice(0, 100) : null
934
- };
935
- emit(event);
936
- });
937
- });
938
- return originalPromise;
939
- };
940
- globalThis.fetch = patchedFetch;
941
- return function disposeFetchPatch() {
942
- if (globalThis.fetch === patchedFetch) {
943
- globalThis.fetch = previousFetch;
1549
+ logger.warn('TabManager', 'failed to open broadcast channel:', error);
944
1550
  }
945
- };
946
- }
947
-
948
- const requestStateMap = new WeakMap();
949
- function installXhrPatch(emit, shouldIgnore) {
950
- const OriginalXHR = typeof globalThis.XMLHttpRequest === 'function'
951
- ? globalThis.XMLHttpRequest
952
- : undefined;
953
- const originalOpen = OriginalXHR?.prototype.open;
954
- const originalSend = OriginalXHR?.prototype.send;
955
- if (OriginalXHR === undefined ||
956
- originalOpen === undefined ||
957
- originalSend === undefined) {
958
- return () => void 0;
959
- }
960
- const patchedOpen = function patchedXhrOpen(...args) {
961
- const method = typeof args[0] === 'string' ? args[0] : String(args[0]);
962
- const urlArg = args[1];
963
- const resolvedUrl = urlArg instanceof URL ? urlArg.href : String(urlArg);
964
- // A second open() on the same XHR resets the request — drop any stale
965
- // state so we don't mis-attribute a later send() to the previous URL.
966
- requestStateMap.set(this, {
967
- method: method.toUpperCase(),
968
- url: resolvedUrl,
969
- requestStart: 0,
970
- requestBodyBytes: null
971
- });
972
- // Forward exactly what the host passed, preserving argument count.
973
- return originalOpen.apply(this, args);
974
- };
975
- const patchedSend = function patchedXhrSend(body) {
976
- const state = requestStateMap.get(this);
977
- if (state && !shouldIgnore(state.url)) {
978
- state.requestStart = performance.now();
979
- // XHR.send() accepts Document | XMLHttpRequestBodyInit | null.
980
- // Document is not a BodyInit; guard with a feature check so this module
981
- // remains safe in environments where Document is not a bare global.
982
- const isDocument = typeof globalThis.Document !== 'undefined' && body instanceof globalThis.Document;
983
- state.requestBodyBytes = deriveRequestBodyBytes(isDocument ? null : body);
984
- const truncatedUrl = truncateUrl(state.url);
985
- const cleanup = () => {
986
- this.removeEventListener('loadend', onLoadEnd);
987
- this.removeEventListener('timeout', onTimeout);
988
- this.removeEventListener('abort', onAbort);
989
- };
990
- const emitOutcome = (outcome) => {
991
- safeSync(() => {
992
- const requestEnd = performance.now();
993
- const status = this.status;
994
- const responseStatus = outcome === 'success' || outcome === 'error'
995
- ? (status > 0 ? status : null)
996
- : null;
997
- const ok = outcome === 'success'
998
- ? true
999
- : outcome === 'error' && responseStatus !== null
1000
- ? false
1001
- : null;
1002
- const event = {
1003
- name: 'network_request',
1004
- type: 'invocation',
1005
- source: 'xhr',
1006
- timestamp: Date.now(),
1007
- performanceTime: requestEnd,
1008
- url: truncatedUrl,
1009
- method: state.method,
1010
- isSameOrigin: isSameOrigin(state.url),
1011
- credentials: this.withCredentials ? 'include' : 'same-origin',
1012
- cache: null,
1013
- mode: null,
1014
- keepalive: false,
1015
- requestBodyBytes: state.requestBodyBytes,
1016
- requestStart: state.requestStart,
1017
- requestEnd,
1018
- durationMs: requestEnd - state.requestStart,
1019
- responseStatus,
1020
- ok,
1021
- outcome,
1022
- errorName: null
1023
- };
1024
- emit(event);
1025
- requestStateMap.delete(this);
1026
- });
1027
- };
1028
- // Order of XHR terminal events per spec: abort/timeout fire BEFORE
1029
- // loadend. We attach all three and let whichever fires first own the
1030
- // outcome; cleanup removes the others so loadend doesn't double-emit.
1031
- let resolved = false;
1032
- const onAbort = () => {
1033
- if (resolved)
1034
- return;
1035
- resolved = true;
1036
- emitOutcome('abort');
1037
- cleanup();
1038
- };
1039
- const onTimeout = () => {
1040
- if (resolved)
1041
- return;
1042
- resolved = true;
1043
- emitOutcome('timeout');
1044
- cleanup();
1045
- };
1046
- const onLoadEnd = () => {
1047
- if (resolved)
1048
- return;
1049
- resolved = true;
1050
- const status = this.status;
1051
- const isSuccess = status >= 200 && status < 400;
1052
- emitOutcome(status > 0 ? (isSuccess ? 'success' : 'error') : 'error');
1053
- cleanup();
1054
- };
1055
- this.addEventListener('abort', onAbort);
1056
- this.addEventListener('timeout', onTimeout);
1057
- this.addEventListener('loadend', onLoadEnd);
1551
+ }
1552
+ _closeChannel() {
1553
+ try {
1554
+ if (!this.broadcastChannel)
1555
+ return;
1556
+ if (this.bound_OnBroadcastMessage) {
1557
+ this.broadcastChannel.removeEventListener('message', this.bound_OnBroadcastMessage);
1558
+ }
1559
+ this.broadcastChannel.close();
1058
1560
  }
1059
- return originalSend.call(this, body);
1060
- };
1061
- XMLHttpRequest.prototype.open = patchedOpen;
1062
- XMLHttpRequest.prototype.send = patchedSend;
1063
- return function disposeXhrPatch() {
1064
- if (XMLHttpRequest.prototype.open === patchedOpen) {
1065
- XMLHttpRequest.prototype.open = originalOpen;
1561
+ catch {
1562
+ // do nothing
1066
1563
  }
1067
- if (XMLHttpRequest.prototype.send === patchedSend) {
1068
- XMLHttpRequest.prototype.send = originalSend;
1564
+ finally {
1565
+ this.broadcastChannel = null;
1566
+ this.bound_OnBroadcastMessage = null;
1069
1567
  }
1070
- };
1071
- }
1072
-
1073
- function createResourceObserver(emit, shouldIgnore, allowedInitiatorTypes = DEFAULT_RESOURCE_INITIATOR_TYPES) {
1074
- if (typeof PerformanceObserver === 'undefined') {
1075
- return () => void 0;
1076
1568
  }
1077
- let observer = null;
1078
- try {
1079
- observer = new PerformanceObserver((list) => {
1080
- for (const entry of list.getEntries()) {
1081
- safeSync(() => {
1082
- if (entry.entryType !== 'resource')
1083
- return;
1084
- const resourceEntry = entry;
1085
- if (!allowedInitiatorTypes.has(resourceEntry.initiatorType))
1086
- return;
1087
- if (shouldIgnore(resourceEntry.name))
1088
- return;
1089
- emit(toResourceEvent(resourceEntry));
1090
- });
1091
- }
1569
+ _startHeartbeat() {
1570
+ this.heartbeatTimer = setInterval(() => {
1571
+ const now = Date.now();
1572
+ this._broadcast({
1573
+ type: 'heartbeat',
1574
+ tabId: this.tabId,
1575
+ ts: now
1576
+ });
1577
+ }, HEARTBEAT_INTERVAL_MS);
1578
+ }
1579
+ _stopHeartbeat() {
1580
+ if (this.heartbeatTimer != null) {
1581
+ clearInterval(this.heartbeatTimer);
1582
+ this.heartbeatTimer = null;
1583
+ }
1584
+ }
1585
+ _scheduleCountEmit() {
1586
+ if (this.countEmitTimer != null)
1587
+ clearTimeout(this.countEmitTimer);
1588
+ this.countEmitTimer = setTimeout(() => {
1589
+ this.countEmitTimer = null;
1590
+ const count = this._sweepPeers() + 1;
1591
+ if (count === this.lastEmittedCount)
1592
+ return;
1593
+ this.lastEmittedCount = count;
1594
+ this._emitTabCount(count);
1595
+ }, COUNT_EMIT_DEBOUNCE_MS);
1596
+ }
1597
+ _cancelCountEmit() {
1598
+ if (this.countEmitTimer != null) {
1599
+ clearTimeout(this.countEmitTimer);
1600
+ this.countEmitTimer = null;
1601
+ }
1602
+ }
1603
+ _broadcast(message) {
1604
+ try {
1605
+ if (!this.broadcastChannel)
1606
+ return;
1607
+ this.broadcastChannel.postMessage(message);
1608
+ }
1609
+ catch {
1610
+ logger.warn('TabManager', 'failed to broadcast message:', message);
1611
+ }
1612
+ }
1613
+ _onBroadcastMessage(event) {
1614
+ const { type, tabId } = event.data;
1615
+ if (tabId === this.tabId)
1616
+ return;
1617
+ const receivedAt = Date.now();
1618
+ if (type === 'hello') {
1619
+ this._broadcast({
1620
+ type: 'ack',
1621
+ tabId: this.tabId,
1622
+ ts: receivedAt,
1623
+ to: tabId
1624
+ });
1625
+ this.peers.set(tabId, receivedAt);
1626
+ }
1627
+ else if (type === 'heartbeat') {
1628
+ this.peers.set(tabId, receivedAt);
1629
+ }
1630
+ else if (type === 'ack') {
1631
+ this.peers.set(tabId, receivedAt);
1632
+ }
1633
+ else if (type === 'bye') {
1634
+ this.peers.delete(tabId);
1635
+ }
1636
+ }
1637
+ _emitTabCount(value) {
1638
+ this.context.push({
1639
+ name: 'tab',
1640
+ type: 'count',
1641
+ tabId: this.tabId,
1642
+ timestamp: Date.now(),
1643
+ value
1644
+ });
1645
+ }
1646
+ _emitTabState(value) {
1647
+ this.context.push({
1648
+ name: 'tab',
1649
+ type: 'state',
1650
+ tabId: this.tabId,
1651
+ timestamp: Date.now(),
1652
+ value
1653
+ });
1654
+ }
1655
+ _generateTabId() {
1656
+ const existing = SessionStorage.get(STORAGE_KEY$1);
1657
+ if (existing)
1658
+ return existing;
1659
+ const fresh = generateUUID();
1660
+ SessionStorage.set(STORAGE_KEY$1, fresh);
1661
+ return fresh;
1662
+ }
1663
+ _sweepPeers(now = Date.now()) {
1664
+ let active = 0;
1665
+ for (const [tabId, timestamp] of this.peers.entries()) {
1666
+ if (now - timestamp >= IDLE_HEARTBEAT_INTERVAL_MS) {
1667
+ this.peers.delete(tabId);
1668
+ }
1669
+ else {
1670
+ active++;
1671
+ }
1672
+ }
1673
+ return active;
1674
+ }
1675
+ }
1676
+
1677
+ /** Maximum UTF-16 code-unit length of a URL stored in any NetworkRequest event field. */
1678
+ const MAX_URL_LENGTH = 2048;
1679
+ /**
1680
+ * Initiator types the PerformanceObserver entry mapper normalises to 'other'
1681
+ * when the browser returns a string not in the known enumeration.
1682
+ */
1683
+ const KNOWN_INITIATOR_TYPES = new Set([
1684
+ 'xmlhttprequest',
1685
+ 'fetch',
1686
+ 'beacon',
1687
+ 'navigation',
1688
+ 'script',
1689
+ 'link',
1690
+ 'css',
1691
+ 'img',
1692
+ 'iframe',
1693
+ 'video',
1694
+ 'audio'
1695
+ ]);
1696
+ /**
1697
+ * Default set of PerformanceResourceTiming initiator types that the observer
1698
+ * emits events for. Restricted to API-call sources by default to avoid
1699
+ * generating high-volume events for every image, script, and stylesheet load.
1700
+ *
1701
+ * Callers can override this by passing a custom set to `createResourceObserver`.
1702
+ * Pass `KNOWN_INITIATOR_TYPES` (or a superset) to capture all resource types.
1703
+ */
1704
+ const DEFAULT_RESOURCE_INITIATOR_TYPES = new Set([
1705
+ 'xmlhttprequest',
1706
+ 'fetch',
1707
+ 'beacon'
1708
+ ]);
1709
+ /** Name used in error logging for this collector. */
1710
+ const COLLECTOR_NAME = 'network-request';
1711
+
1712
+ /**
1713
+ * Normalises the raw string from PerformanceResourceTiming.initiatorType into
1714
+ * the known enumeration, falling back to 'other' for unrecognised values.
1715
+ * Note: in the default config, allowedInitiatorTypes restricts entries to
1716
+ * fetch, XHR, and beacon, so 'other' is unreachable unless the caller passes
1717
+ * a wider set to createResourceObserver.
1718
+ */
1719
+ function normaliseInitiatorType(raw) {
1720
+ return KNOWN_INITIATOR_TYPES.has(raw)
1721
+ ? raw
1722
+ : 'other';
1723
+ }
1724
+ /**
1725
+ * Returns null when the entry's value is 0 and the field is a phase timing
1726
+ * expected to be null for cross-origin resources. The browser sets withheld
1727
+ * timing fields to 0 when the server does not send Timing-Allow-Origin.
1728
+ *
1729
+ * Heuristic: responseStart === 0 when duration > 0 indicates the browser
1730
+ * withheld the timing information. Service-worker-intercepted requests and
1731
+ * HTTP/2 reused connections can also produce responseStart === 0; the backend
1732
+ * should treat null as "unavailable", not as "zero duration".
1733
+ */
1734
+ function phaseMs(value, hasTimingAccess) {
1735
+ return hasTimingAccess ? value : null;
1736
+ }
1737
+ /**
1738
+ * Maps a PerformanceResourceTiming entry to a NetworkRequestEvent ready to
1739
+ * push into the collector context.
1740
+ *
1741
+ * @param entry - The PerformanceResourceTiming entry from the observer.
1742
+ * @param sanitizeUrl - Pre-bound URL sanitiser from CollectorContext.
1743
+ */
1744
+ function toResourceEvent(entry, sanitizeUrl) {
1745
+ const extended = entry;
1746
+ // responseStart === 0 (and duration > 0) is the browser's signal that
1747
+ // cross-origin Timing-Allow-Origin headers were absent.
1748
+ const hasTimingAccess = entry.responseStart > 0;
1749
+ const dnsLookupMs = phaseMs(entry.domainLookupEnd - entry.domainLookupStart, hasTimingAccess);
1750
+ const tcpConnectMs = phaseMs(entry.connectEnd - entry.connectStart, hasTimingAccess);
1751
+ const tlsHandshakeMs = phaseMs(entry.connectEnd > 0 && entry.secureConnectionStart > 0
1752
+ ? entry.connectEnd - entry.secureConnectionStart
1753
+ : 0, hasTimingAccess);
1754
+ const ttfbMs = phaseMs(entry.responseStart - entry.requestStart, hasTimingAccess);
1755
+ const responseDownloadMs = phaseMs(entry.responseEnd - entry.responseStart, hasTimingAccess);
1756
+ const transferSize = hasTimingAccess ? entry.transferSize : null;
1757
+ const encodedBodySize = hasTimingAccess ? entry.encodedBodySize : null;
1758
+ const decodedBodySize = hasTimingAccess ? entry.decodedBodySize : null;
1759
+ const nextHopProtocol = hasTimingAccess ? entry.nextHopProtocol || null : null;
1760
+ // deliveryType is a newer addition; safely access via ExtendedResourceEntry.
1761
+ const deliveryType = hasTimingAccess && extended.deliveryType !== undefined
1762
+ ? extended.deliveryType
1763
+ : null;
1764
+ // responseStatus is Chrome 112+. The browser returns 0 for cross-origin /
1765
+ // opaque responses where the real status is withheld — map 0 to null so
1766
+ // downstream consumers don't have to special-case the sentinel.
1767
+ const responseStatusRaw = extended.responseStatus;
1768
+ const responseStatus = responseStatusRaw !== undefined && responseStatusRaw !== 0
1769
+ ? responseStatusRaw
1770
+ : null;
1771
+ return {
1772
+ name: 'network_request',
1773
+ timestamp: Date.now(),
1774
+ performanceTime: entry.startTime + entry.duration,
1775
+ url: sanitizeUrl(entry.name),
1776
+ initiatorType: normaliseInitiatorType(entry.initiatorType),
1777
+ startTime: entry.startTime,
1778
+ durationMs: entry.duration,
1779
+ dnsLookupMs,
1780
+ tcpConnectMs,
1781
+ tlsHandshakeMs,
1782
+ ttfbMs,
1783
+ responseDownloadMs,
1784
+ transferSize,
1785
+ encodedBodySize,
1786
+ decodedBodySize,
1787
+ nextHopProtocol,
1788
+ deliveryType,
1789
+ responseStatus
1790
+ };
1791
+ }
1792
+
1793
+ function noop() { }
1794
+ function createResourceObserver(emit, shouldIgnore, sanitizeUrl, allowedInitiatorTypes = DEFAULT_RESOURCE_INITIATOR_TYPES) {
1795
+ if (typeof PerformanceObserver === 'undefined') {
1796
+ return noop;
1797
+ }
1798
+ let observer = null;
1799
+ try {
1800
+ observer = new PerformanceObserver((list) => {
1801
+ for (const entry of list.getEntries()) {
1802
+ const resourceEntry = entry;
1803
+ if (!allowedInitiatorTypes.has(resourceEntry.initiatorType))
1804
+ continue;
1805
+ if (shouldIgnore(resourceEntry.name))
1806
+ continue;
1807
+ emit(toResourceEvent(resourceEntry, sanitizeUrl));
1808
+ }
1092
1809
  });
1093
1810
  observer.observe({ type: 'resource', buffered: true });
1094
1811
  }
1095
1812
  catch {
1096
- {
1097
- // eslint-disable-next-line no-console
1098
- console.warn(`[TriSDK] ${COLLECTOR_NAME}: PerformanceObserver failed to start`);
1099
- }
1100
- return () => void 0;
1813
+ logger.warn(COLLECTOR_NAME, 'PerformanceObserver failed to start');
1814
+ return noop;
1101
1815
  }
1102
1816
  return function disposeResourceObserver() {
1103
1817
  observer?.disconnect();
@@ -1106,43 +1820,38 @@
1106
1820
  }
1107
1821
 
1108
1822
  /**
1109
- * Returns a predicate that returns true for any URL that should be excluded
1110
- * from NetworkRequest collection. Used to filter the SDK's own ingest
1111
- * endpoint to prevent feedback loops.
1823
+ * Returns a predicate that returns true for any URL whose origin matches an
1824
+ * entry in the ignoredOrigins set. Runs against the raw (pre-sanitisation)
1825
+ * URL so SDK ingest URLs are caught before sanitisation could transform them.
1826
+ *
1827
+ * Matching is structural (origin equality) to avoid the false-positive risk of
1828
+ * plain string prefix matching against unrelated hosts that share a prefix.
1112
1829
  */
1113
- function createUrlIgnorePredicate(ignorePrefixes) {
1830
+ function createOriginIgnorePredicate(ignoredOrigins) {
1114
1831
  return function shouldIgnoreUrl(url) {
1115
- const normalised = truncateUrl(url);
1116
- for (const prefix of ignorePrefixes) {
1117
- if (normalised.startsWith(prefix))
1118
- return true;
1832
+ try {
1833
+ return ignoredOrigins.has(new URL(url).origin);
1834
+ }
1835
+ catch {
1836
+ return false;
1119
1837
  }
1120
- return false;
1121
1838
  };
1122
1839
  }
1123
1840
  /**
1124
1841
  * Creates and starts the NetworkRequest collector. Returns a dispose function
1125
- * that removes the fetch patch, restores the XHR prototype methods, and
1126
- * disconnects the PerformanceObserver.
1842
+ * that disconnects the PerformanceObserver.
1127
1843
  */
1128
1844
  function createNetworkRequestInteractionCollector(context) {
1129
1845
  function emit(event) {
1130
1846
  safeSync(() => context.push(event));
1131
1847
  }
1132
- const shouldIgnore = createUrlIgnorePredicate([context.baseApiUrl]);
1133
- const stopFetch = installFetchPatch(emit, shouldIgnore);
1134
- const stopXhr = installXhrPatch(emit, shouldIgnore);
1135
- const stopObserver = createResourceObserver(emit, shouldIgnore);
1848
+ const shouldIgnore = createOriginIgnorePredicate(context.ignoredOrigins);
1849
+ const stopObserver = createResourceObserver(emit, shouldIgnore, context.sanitizeUrl);
1136
1850
  return function disposeNetworkRequestCollector() {
1137
1851
  stopObserver();
1138
- stopXhr();
1139
- stopFetch();
1140
1852
  };
1141
1853
  }
1142
1854
 
1143
- // TODO
1144
- // 1. should we support only mouse dynamics?
1145
- // 2. should we support only pointer move with coalesced events?
1146
1855
  const IDLE_PUSH_INTERVAL$1 = 1500;
1147
1856
  function createPointerMoveCollector(
1148
1857
  /* config - TBD */ context) {
@@ -1150,9 +1859,6 @@
1150
1859
  let pushTimeoutId = null;
1151
1860
  const onPointerMove = safeListener((event) => {
1152
1861
  const pointerEvent = event;
1153
- // TODO: - decide should we event support this?
1154
- // Pointer ID switch: a different pointer started moving mid-window.
1155
- // Flush the in-flight window so the new pointer's data starts cleanly.
1156
1862
  if (pointerMoveWindow && pointerMoveWindow.id !== pointerEvent.pointerId) {
1157
1863
  context.push(pointerMoveWindow);
1158
1864
  pointerMoveWindow = null;
@@ -1179,26 +1885,10 @@
1179
1885
  pointerMoveWindow.samples.force?.push(pointerEvent.pressure);
1180
1886
  pointerMoveWindow.samples.radius?.push(Math.max(pointerEvent.width, pointerEvent.height));
1181
1887
  }
1182
- pointerMoveWindow.dispatchedEventCount += 1;
1183
1888
  scheduleIdlePush(context);
1184
1889
  });
1185
1890
  function createPointerMoveWindow(pointerEvent) {
1186
- let additionalSamples = {
1187
- tiltX: [],
1188
- tiltY: [],
1189
- force: [],
1190
- radius: []
1191
- };
1192
- if (pointerEvent.pointerType === 'pen') {
1193
- additionalSamples.tiltX = [pointerEvent.tiltX];
1194
- additionalSamples.tiltY = [pointerEvent.tiltY];
1195
- }
1196
- if (pointerEvent.pointerType !== 'mouse') {
1197
- additionalSamples.force = [pointerEvent.pressure];
1198
- additionalSamples.radius = [
1199
- Math.max(pointerEvent.width, pointerEvent.height)
1200
- ];
1201
- }
1891
+ const additionalSamples = getAdditionalSamples(pointerEvent);
1202
1892
  return {
1203
1893
  name: 'pointer',
1204
1894
  timestamp: Date.now(),
@@ -1212,10 +1902,8 @@
1212
1902
  x: [pointerEvent.clientX],
1213
1903
  y: [pointerEvent.clientY],
1214
1904
  ts: [pointerEvent.timeStamp],
1215
- ...additionalSamples
1216
- },
1217
- coalescedRatio: 1,
1218
- dispatchedEventCount: 1
1905
+ ...(additionalSamples ?? {})
1906
+ }
1219
1907
  };
1220
1908
  }
1221
1909
  function scheduleIdlePush(context) {
@@ -1229,6 +1917,20 @@
1229
1917
  pushTimeoutId = null;
1230
1918
  }, IDLE_PUSH_INTERVAL$1);
1231
1919
  }
1920
+ function getAdditionalSamples(pointerEvent) {
1921
+ if (pointerEvent.pointerType === 'mouse') {
1922
+ return null;
1923
+ }
1924
+ const additionalSamples = {
1925
+ force: [pointerEvent.pressure],
1926
+ radius: [Math.max(pointerEvent.width, pointerEvent.height)]
1927
+ };
1928
+ if (pointerEvent.pointerType === 'pen') {
1929
+ additionalSamples.tiltX = [pointerEvent.tiltX];
1930
+ additionalSamples.tiltY = [pointerEvent.tiltY];
1931
+ }
1932
+ return additionalSamples;
1933
+ }
1232
1934
  addEventListener(document, 'pointermove', onPointerMove);
1233
1935
  return () => {
1234
1936
  removeEventListener(document, 'pointermove', onPointerMove);
@@ -1403,20 +2105,20 @@
1403
2105
  * the cache is silently disabled and the fingerprint is fetched per
1404
2106
  * page session.
1405
2107
  */
1406
- const STORAGE_KEY$1 = 'tri:fingerprint';
2108
+ const STORAGE_KEY = 'tri:fingerprint';
1407
2109
  const DEFAULT_CACHE_TTL_MS = 30 * 24 * 60 * 60 * 1000;
1408
2110
  let agentPromise = null;
1409
2111
  let visitorIdPromise = null;
1410
2112
  function readCache() {
1411
2113
  try {
1412
- const raw = localStorage.getItem(STORAGE_KEY$1);
2114
+ const raw = localStorage.getItem(STORAGE_KEY);
1413
2115
  if (!raw)
1414
2116
  return null;
1415
2117
  const entry = JSON.parse(raw);
1416
2118
  if (typeof entry.visitorId !== 'string' ||
1417
2119
  typeof entry.expiresAt !== 'number' ||
1418
2120
  entry.expiresAt <= Date.now()) {
1419
- localStorage.removeItem(STORAGE_KEY$1);
2121
+ localStorage.removeItem(STORAGE_KEY);
1420
2122
  return null;
1421
2123
  }
1422
2124
  return {
@@ -1435,7 +2137,7 @@
1435
2137
  requestId,
1436
2138
  expiresAt: Date.now() + ttlMs
1437
2139
  };
1438
- localStorage.setItem(STORAGE_KEY$1, JSON.stringify(entry));
2140
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(entry));
1439
2141
  }
1440
2142
  catch {
1441
2143
  // Best-effort. localStorage may be disabled, full, or partitioned.
@@ -1721,8 +2423,7 @@
1721
2423
  timestamp: Date.now(),
1722
2424
  isTrusted: focusEvent.isTrusted,
1723
2425
  element: context.resolveElement(focusEvent.target),
1724
- relatedTarget: context.resolveElement(focusEvent.relatedTarget),
1725
- firesTouchEvents: false // TODO - derive from recent pointer history
2426
+ relatedTarget: context.resolveElement(focusEvent.relatedTarget)
1726
2427
  };
1727
2428
  }
1728
2429
  addEventListener(document, 'focusin', onFocusInteraction);
@@ -2080,6 +2781,7 @@
2080
2781
  });
2081
2782
  function createPointerInteractionEvent(nativeEvent) {
2082
2783
  const pointerEvent = nativeEvent;
2784
+ const additionalProperties = getAdditionalProperties(pointerEvent);
2083
2785
  return {
2084
2786
  id: pointerEvent.pointerId,
2085
2787
  name: 'pointer',
@@ -2098,24 +2800,36 @@
2098
2800
  x: pointerEvent.clientX,
2099
2801
  y: pointerEvent.clientY
2100
2802
  },
2803
+ ...additionalProperties
2804
+ };
2805
+ }
2806
+ function getAdditionalProperties(pointerEvent) {
2807
+ if (pointerEvent.pointerType === 'mouse') {
2808
+ return {
2809
+ button: pointerEvent.button,
2810
+ buttons: pointerEvent.buttons
2811
+ };
2812
+ }
2813
+ const additionalProperties = {
2101
2814
  force: pointerEvent.pressure,
2102
- contact: Math.min(pointerEvent.width, pointerEvent.height),
2103
- tiltX: pointerEvent.tiltX,
2104
- tiltY: pointerEvent.tiltY,
2105
- twist: pointerEvent.twist,
2106
- button: pointerEvent.button,
2107
- buttons: pointerEvent.buttons
2815
+ contact: Math.min(pointerEvent.width, pointerEvent.height)
2108
2816
  };
2817
+ if (pointerEvent.pointerType === 'pen') {
2818
+ additionalProperties.tiltX = pointerEvent.tiltX;
2819
+ additionalProperties.tiltY = pointerEvent.tiltY;
2820
+ additionalProperties.twist = pointerEvent.twist;
2821
+ }
2822
+ return additionalProperties;
2109
2823
  }
2824
+ addEventListener(document, 'click', onPointerInteraction);
2110
2825
  addEventListener(document, 'pointerdown', onPointerInteraction);
2111
2826
  addEventListener(document, 'pointerup', onPointerInteraction);
2112
- addEventListener(document, 'click', onPointerInteraction);
2113
2827
  addEventListener(document, 'dblclick', onPointerInteraction);
2114
2828
  addEventListener(document, 'contextmenu', onPointerInteraction);
2115
2829
  return () => {
2830
+ removeEventListener(document, 'click', onPointerInteraction);
2116
2831
  removeEventListener(document, 'pointerdown', onPointerInteraction);
2117
2832
  removeEventListener(document, 'pointerup', onPointerInteraction);
2118
- removeEventListener(document, 'click', onPointerInteraction);
2119
2833
  removeEventListener(document, 'dblclick', onPointerInteraction);
2120
2834
  removeEventListener(document, 'contextmenu', onPointerInteraction);
2121
2835
  };
@@ -2311,7 +3025,7 @@
2311
3025
  width: window.innerWidth,
2312
3026
  scrollX: window.scrollX,
2313
3027
  scrollY: window.scrollY,
2314
- devicePixelRatio: window.devicePixelRatio
3028
+ dpr: window.devicePixelRatio
2315
3029
  };
2316
3030
  }
2317
3031
  addEventListener(window, 'orientationchange', onOrientationViewportInteraction);
@@ -2359,29 +3073,36 @@
2359
3073
 
2360
3074
  function createNavigationLifecycleCollector(
2361
3075
  /* config - TBD */ context) {
2362
- let previousUrl = document.referrer || '';
3076
+ let previousUrl = context.sanitizeUrl(document.referrer || '');
2363
3077
  function transport(navigationEvent) {
2364
3078
  context.push(navigationEvent);
2365
3079
  previousUrl = navigationEvent.to ?? '';
2366
3080
  }
2367
3081
  const onPageLoad = safeListener(() => {
2368
3082
  const [navEntry] = performance.getEntriesByType('navigation');
2369
- const pageLoadEvent = createNavigationEvent('load', navEntry?.type ?? 'navigate', { from: document.referrer || null, to: location.href }, performance.timeOrigin, navEntry ? createNavigationTiming(navEntry) : undefined);
3083
+ const pageLoadEvent = createNavigationEvent('load', navEntry?.type ?? 'navigate', {
3084
+ from: document.referrer ? context.sanitizeUrl(document.referrer) : null,
3085
+ to: context.sanitizeUrl(location.href)
3086
+ }, performance.timeOrigin, navEntry ? createNavigationTiming(navEntry) : undefined);
2370
3087
  transport(pageLoadEvent);
2371
3088
  });
2372
3089
  const onNavigate = safeListener((event) => {
2373
3090
  const navigateEvent = event;
2374
- const navigationEvent = createNavigationEvent('change', navigateEvent.navigationType, { from: previousUrl, to: navigateEvent.destination.url }, performance.timeOrigin);
3091
+ const navigationEvent = createNavigationEvent('change', navigateEvent.navigationType, {
3092
+ from: previousUrl,
3093
+ to: context.sanitizeUrl(navigateEvent.destination.url)
3094
+ }, performance.timeOrigin);
2375
3095
  transport(navigationEvent);
2376
3096
  });
2377
3097
  const onPopstate = safeListener(() => onHistoryStateChange('traverse'));
2378
3098
  const onHashchange = safeListener(() => onHistoryStateChange('hashchange'));
2379
3099
  function onHistoryStateChange(state) {
3100
+ const sanitisedCurrentUrl = context.sanitizeUrl(location.href);
2380
3101
  // pushState/replaceState don't fire for plain anchor clicks (<a href="#x">)
2381
3102
  // so hashchange covers that gap on the fallback path
2382
- if (state === 'hashchange' && location.href === previousUrl)
3103
+ if (state === 'hashchange' && sanitisedCurrentUrl === previousUrl)
2383
3104
  return;
2384
- const historyEvent = createNavigationEvent('change', state, { from: previousUrl, to: location.href }, performance.timeOrigin);
3105
+ const historyEvent = createNavigationEvent('change', state, { from: previousUrl, to: sanitisedCurrentUrl }, performance.timeOrigin);
2385
3106
  transport(historyEvent);
2386
3107
  }
2387
3108
  // --- SPA navigation ---
@@ -2545,7 +3266,7 @@
2545
3266
  create: noopCollectorFactory,
2546
3267
  layer: 'behavioral'
2547
3268
  },
2548
- networkrequest: {
3269
+ 'network-request': {
2549
3270
  create: createNetworkRequestInteractionCollector,
2550
3271
  layer: 'session'
2551
3272
  },
@@ -2561,104 +3282,6 @@
2561
3282
  const COLLECTOR_KEYS = Object.keys(COLLECTORS_CONFIG);
2562
3283
  const COLLECTOR_DEFAULT_ENABLED_STATE = false;
2563
3284
 
2564
- const DEFAULT_CAPTURE_ELEMENT = 'all';
2565
- const DEFAULT_CAPTURE_CONTENT = 'on';
2566
- /**
2567
- * Resolves the user-supplied TriSDKConfig into a complete, fully-defaulted internal
2568
- * config. Branches:
2569
- * - no config / neither layers nor collectors → all collectors and events enabled,
2570
- * no excludes (still honours `capture` and `profileId` when provided)
2571
- * - layers map → collectors enabled iff their layer is true; omitted layers = false
2572
- * - collectors → per-collector/per-event overrides; omitted entries default to enabled
2573
- *
2574
- * `capture.level` defaults to `'redact'` across all branches — the safe option.
2575
- * Hosts that want full PII capture must explicitly set `capture: { level: 'all' }`.
2576
- */
2577
- function resolveTriSDKConfig(config) {
2578
- if (!config?.api?.key || !config?.api?.version || !config?.api?.url) {
2579
- {
2580
- // eslint-disable-next-line no-console
2581
- console.warn('API key, version, and URL are required');
2582
- }
2583
- }
2584
- const { element, content } = config?.capture ?? {};
2585
- const sdk = {
2586
- capture: {
2587
- element: element ?? DEFAULT_CAPTURE_ELEMENT,
2588
- content: content ?? DEFAULT_CAPTURE_CONTENT
2589
- },
2590
- profileId: config?.profileId,
2591
- api: {
2592
- key: config?.api.key ?? '',
2593
- version: config?.api.version ?? '',
2594
- url: config?.api.url ?? ''
2595
- }
2596
- };
2597
- if (!config ||
2598
- (config.collectors === undefined && config.layers === undefined)) {
2599
- return { sdk, collectors: resolveDefaults() };
2600
- }
2601
- if (config.collectors !== undefined) {
2602
- return { sdk, collectors: resolveFromCollectors(config.collectors) };
2603
- }
2604
- return { sdk, collectors: resolveFromLayers(config.layers) };
2605
- }
2606
- /** Builds a ResolvedCollectorsConfig with every collector and event defaulting to enabled. */
2607
- function resolveDefaults() {
2608
- const result = {};
2609
- for (const key of COLLECTOR_KEYS) {
2610
- result[key] = resolveCollectorEntry(key, undefined, true);
2611
- }
2612
- return result;
2613
- }
2614
- /** Builds a ResolvedCollectorsConfig driven by a layer toggle map. Omitted layers default to false. */
2615
- function resolveFromLayers(layers) {
2616
- const result = {};
2617
- for (const key of COLLECTOR_KEYS) {
2618
- const collectorEnabled = layers[COLLECTORS_CONFIG[key].layer] ?? false;
2619
- result[key] = resolveCollectorEntry(key, undefined, collectorEnabled);
2620
- }
2621
- return result;
2622
- }
2623
- /** Builds a ResolvedCollectorsConfig from a per-collector config object. Omitted collectors default to enabled. */
2624
- function resolveFromCollectors(collectorsInput) {
2625
- const result = {};
2626
- for (const key of COLLECTOR_KEYS) {
2627
- const input = collectorsInput[key];
2628
- const collectorEnabled = input?.enabled ?? false;
2629
- result[key] = resolveCollectorEntry(key, input, collectorEnabled);
2630
- }
2631
- return result;
2632
- }
2633
- /**
2634
- * Resolves a single collector entry into its fully typed ResolvedCollectorConfig.
2635
- * One `as` cast at the return boundary is intentional and sound: TypeScript cannot
2636
- * narrow a conditional type through a runtime length guard.
2637
- */
2638
- function resolveCollectorEntry(key, input, collectorEnabled) {
2639
- const eventNames = COLLECTORS_CONFIG[key]?.events ?? [];
2640
- if (eventNames.length === 0) {
2641
- return { enabled: collectorEnabled };
2642
- }
2643
- const eventsInput = input?.events;
2644
- const globalExcludes = eventsInput?.exclude ?? [];
2645
- const resolvedEvents = {};
2646
- for (const eventName of eventNames) {
2647
- const eventEntry = eventsInput?.[eventName];
2648
- resolvedEvents[eventName] = {
2649
- enabled: eventEntry?.enabled ?? true,
2650
- exclude: dedupe([...globalExcludes, ...(eventEntry?.exclude ?? [])])
2651
- };
2652
- }
2653
- return {
2654
- enabled: collectorEnabled,
2655
- events: resolvedEvents
2656
- };
2657
- }
2658
- function dedupe(array) {
2659
- return [...new Set(array)];
2660
- }
2661
-
2662
3285
  function isCollectorEnabled(collectorKey, config) {
2663
3286
  return config[collectorKey]?.enabled ?? COLLECTOR_DEFAULT_ENABLED_STATE;
2664
3287
  }
@@ -2673,14 +3296,14 @@
2673
3296
  const dispose = collectorConfig.create(context, captureContentLevel);
2674
3297
  disposers.set(collectorKey, dispose);
2675
3298
  }
2676
- function stop(collectorKey) {
3299
+ function stopCollector(collectorKey) {
2677
3300
  const dispose = disposers.get(collectorKey);
2678
3301
  if (!dispose)
2679
3302
  return;
2680
3303
  dispose();
2681
3304
  disposers.delete(collectorKey);
2682
3305
  }
2683
- function stopAll() {
3306
+ function stop() {
2684
3307
  for (const [, dispose] of disposers)
2685
3308
  dispose();
2686
3309
  disposers.clear();
@@ -2693,39 +3316,42 @@
2693
3316
  start(collectorKey);
2694
3317
  }
2695
3318
  }
2696
- return { start, stop, stopAll, isRunning, autocapture };
3319
+ return { start, stopCollector, stop, isRunning, autocapture };
2697
3320
  }
2698
3321
 
2699
3322
  function createLifecycleManager(
2700
3323
  /** config - TBD */ context) {
2701
- // TODO: think about other lifecycle events that might be useful
2702
- // - beforeunload
2703
- // - unload
2704
- // - freeze
2705
- // - resume
2706
- // - readystatechange
2707
- // - pageshow
2708
3324
  function onVisibilityChange(_) {
2709
3325
  if (document.visibilityState !== 'hidden')
2710
3326
  return;
2711
- void context.flush();
3327
+ context.flush();
2712
3328
  }
2713
- async function onPageHide(_) {
3329
+ function onPageHide(_) {
3330
+ context.flush();
2714
3331
  context.stopTab();
2715
- void context.flush();
2716
- const shouldSkipStopActivity = await context.getShouldSkipStopActivity();
3332
+ const shouldSkipStopActivity = context.getShouldSkipStopActivity();
2717
3333
  /* if there are some other tabs open, we don't need to stop the activity, instead we flush events that are left in the queue and early return */
2718
- if (!shouldSkipStopActivity) {
3334
+ if (shouldSkipStopActivity) {
2719
3335
  return;
2720
3336
  }
2721
- context.releaseAliveLock();
2722
3337
  void context.stopActivity();
2723
3338
  }
2724
- addEventListener(document, 'visibilitychange', onVisibilityChange);
2725
- addEventListener(window, 'pagehide', onPageHide);
2726
- return () => {
3339
+ const onConnectionChangeSafe = safeListener(() => context.onConnectionChange());
3340
+ function start() {
3341
+ addEventListener(document, 'visibilitychange', onVisibilityChange);
3342
+ addEventListener(window, 'pagehide', onPageHide);
3343
+ addEventListener(window, 'online', onConnectionChangeSafe);
3344
+ addEventListener(window, 'offline', onConnectionChangeSafe);
3345
+ }
3346
+ function stop() {
2727
3347
  removeEventListener(document, 'visibilitychange', onVisibilityChange);
2728
3348
  removeEventListener(window, 'pagehide', onPageHide);
3349
+ removeEventListener(window, 'online', onConnectionChangeSafe);
3350
+ removeEventListener(window, 'offline', onConnectionChangeSafe);
3351
+ }
3352
+ return {
3353
+ start,
3354
+ stop
2729
3355
  };
2730
3356
  }
2731
3357
 
@@ -3343,7 +3969,6 @@
3343
3969
  }
3344
3970
  catch {
3345
3971
  // Skip the single corrupted event and preserve the rest of the batch.
3346
- // Callers track dropped counts via the droppedEvents envelope field.
3347
3972
  }
3348
3973
  }
3349
3974
  return lines.join('\n');
@@ -3497,10 +4122,10 @@
3497
4122
  }
3498
4123
  const requestUrl = baseUrl ? `${baseUrl}${url}` : url;
3499
4124
  const controller = new AbortController();
3500
- let cancelTimeout = () => void 0;
4125
+ let reqiestTimeoutTimer = null;
3501
4126
  let timedOut = false;
3502
4127
  {
3503
- cancelTimeout = safeSetTimeout(() => {
4128
+ reqiestTimeoutTimer = safeSetTimeout(() => {
3504
4129
  timedOut = true;
3505
4130
  controller.abort();
3506
4131
  }, timeout);
@@ -3522,7 +4147,7 @@
3522
4147
  /* resolve body from options - string, arraybuffer, object */
3523
4148
  const bodyResult = buildBody(options?.body);
3524
4149
  if (!bodyResult.ok) {
3525
- cancelTimeout();
4150
+ safeClearTimeout(reqiestTimeoutTimer);
3526
4151
  removeExternalListener();
3527
4152
  return { ok: false, kind: 'serialize' };
3528
4153
  }
@@ -3575,7 +4200,7 @@
3575
4200
  return { ok: false, kind: 'network' };
3576
4201
  }
3577
4202
  finally {
3578
- cancelTimeout();
4203
+ safeClearTimeout(reqiestTimeoutTimer);
3579
4204
  removeExternalListener();
3580
4205
  }
3581
4206
  }
@@ -3605,737 +4230,297 @@
3605
4230
  return fn(earlyToken);
3606
4231
  }
3607
4232
  const tokenAtStart = getToken() ?? '';
3608
- const initial = await fn(tokenAtStart);
3609
- if (!shouldRefresh(initial) || options?.refreshable === false)
3610
- return initial;
3611
- const newToken = await scheduleSingleFlightRefresh();
3612
- // Bail if refresh failed or returned the same stale token (e.g. another tab
3613
- // raced the current tab and both read the pre-refresh value).
3614
- if (!newToken || newToken === tokenAtStart)
3615
- return initial;
3616
- return fn(newToken);
3617
- }
3618
- async function attemptRefresh() {
3619
- try {
3620
- await refresh();
3621
- return getToken();
3622
- }
3623
- catch (error) {
3624
- {
3625
- // eslint-disable-next-line no-console
3626
- console.warn('[TriSDK] auth refresh failed', error);
3627
- }
3628
- return null;
3629
- }
3630
- }
3631
- function scheduleSingleFlightRefresh() {
3632
- if (inflightRefresh) {
3633
- return inflightRefresh;
3634
- }
3635
- inflightRefresh = attemptRefresh().finally(() => {
3636
- inflightRefresh = null;
3637
- });
3638
- return inflightRefresh;
3639
- }
3640
- function shouldRefresh(result) {
3641
- if (result.ok)
3642
- return false;
3643
- if (result.kind !== 'client')
3644
- return false;
3645
- return REFRESHABLE_STATUS_CODES.includes(result.status);
3646
- }
3647
- return { withAuth };
3648
- }
3649
-
3650
- function createFalconServiceManager(context) {
3651
- {
3652
- if (typeof document === 'undefined' ||
3653
- typeof globalThis.fetch !== 'function') {
3654
- // eslint-disable-next-line no-console
3655
- console.warn('[TriSDK] createFalconServiceManager called in a non-browser environment' +
3656
- " — all requests will resolve with { ok: false, kind: 'network' }.");
3657
- }
3658
- }
3659
- const http = createHttpClient({
3660
- baseUrl: context.api.url,
3661
- timeout: 10000,
3662
- profileId: context.profileId,
3663
- api: {
3664
- key: context.api.key,
3665
- version: context.api.version
3666
- }
3667
- });
3668
- const authMiddleware = createAuthMiddleware(context);
3669
- /** @description Starts a new activity and returns a token — unauthenticated endpoint */
3670
- async function startActivity() {
3671
- return http.post('/start-activity');
3672
- }
3673
- /** @description Stops an activity and returns a result — authenticated endpoint */
3674
- async function stopActivity() {
3675
- return authMiddleware.withAuth((token) => http.post('/stop-activity', { token, keepalive: true }), { refreshable: false });
3676
- }
3677
- /** @description Ingests a batch of events — authenticated endpoint */
3678
- async function ingest(payload, keepalive) {
3679
- return authMiddleware.withAuth(async (token) => {
3680
- // Re-stamp activityId from the ActivityManager at withAuth-callback time so
3681
- // that if a 401 triggers a token refresh (which mints a new activityId on the
3682
- // backend), the retried request carries the fresh activityId rather than the
3683
- // stale one baked into the batch at createBatch() time. Sending a stale
3684
- // activityId after a refresh would cause the backend to tag the batch as
3685
- // less reliable. The withAuth callback re-runs on refresh, which is exactly
3686
- // the point where we need to pull the updated value.
3687
- const { events, ...rest } = payload;
3688
- const batchMeta = { ...rest, activityId: context.getActivityId() };
3689
- let compressed;
3690
- try {
3691
- compressed = await compressIngestPayload(batchMeta, events);
3692
- }
3693
- catch {
3694
- return { ok: false, kind: 'serialize' };
3695
- }
3696
- return http.post('/capture', {
3697
- body: compressed.body,
3698
- token,
3699
- keepalive,
3700
- contentType: 'ndjson',
3701
- contentEncoding: compressed.type === 'gzip' ? 'gzip' : undefined
3702
- });
3703
- });
3704
- }
3705
- /** @description Sends a single named event with free-form metadata — authenticated endpoint */
3706
- async function reportEvent(payload) {
3707
- return authMiddleware.withAuth((token) => http.post('/report-event', {
3708
- body: payload,
3709
- token,
3710
- contentType: 'json'
3711
- }));
3712
- }
3713
- return {
3714
- api: {
3715
- ingest,
3716
- reportEvent,
3717
- startActivity,
3718
- stopActivity
3719
- }
3720
- };
3721
- }
3722
-
3723
- const TRANSPORT_CONFIG = {
3724
- flush: {
3725
- intervalMs: 2000,
3726
- size: 50
3727
- },
3728
- queue: {
3729
- size: 100
3730
- }
3731
- };
3732
-
3733
- function createStorage(storageId) {
3734
- function hasStorage() {
3735
- return (globalThis.window &&
3736
- globalThis.window[storageId] &&
3737
- typeof globalThis.window[storageId] !== 'undefined');
3738
- }
3739
- function get(key) {
3740
- if (!hasStorage())
3741
- return null;
3742
- try {
3743
- const raw = window[storageId].getItem(key) ?? 'null';
3744
- if (raw === 'null')
3745
- return null;
3746
- return JSON.parse(raw);
3747
- }
3748
- catch {
3749
- return null;
3750
- }
3751
- }
3752
- function set(key, value) {
3753
- if (!hasStorage())
3754
- return;
3755
- safeSync(() => window[storageId].setItem(key, JSON.stringify(value)));
3756
- }
3757
- function remove(key) {
3758
- if (!hasStorage())
3759
- return;
3760
- safeSync(() => window[storageId].removeItem(key));
3761
- }
3762
- function key(index) {
3763
- if (!hasStorage())
3764
- return null;
3765
- return window[storageId].key(index);
3766
- }
3767
- function length() {
3768
- if (!hasStorage())
3769
- return 0;
3770
- return window[storageId].length;
3771
- }
3772
- return {
3773
- get,
3774
- set,
3775
- remove,
3776
- key,
3777
- get length() {
3778
- return length();
3779
- }
3780
- };
3781
- }
3782
- const LocalStorage = createStorage('localStorage');
3783
- const SessionStorage = createStorage('sessionStorage');
3784
-
3785
- /**
3786
- * @module storage-event-channel
3787
- *
3788
- * BroadcastChannel fallback backed by localStorage + the window `storage` event.
3789
- *
3790
- * ## How it works
3791
- *
3792
- * `postMessage` serialises the payload to JSON and writes it under a unique
3793
- * key (`tri:bcast:<channel>:<sender-uuid>:<seq>`). The `storage` event fires
3794
- * synchronously in every other tab that has the same origin. The writer then
3795
- * immediately removes the key so no durable state is left in localStorage.
3796
- *
3797
- * ## Same-tab echo prevention
3798
- *
3799
- * The browser's `storage` event does NOT fire in the tab that performed the
3800
- * write — this matches native BroadcastChannel's contract exactly. The
3801
- * listener in this file therefore never echoes a message back to the sender.
3802
- * Do NOT change the mechanism (e.g. dispatch a synthetic StorageEvent) without
3803
- * verifying this invariant is preserved.
3804
- *
3805
- * ## Key lifecycle
3806
- *
3807
- * 1. Writer: setItem → removeItem (synchronous, back-to-back).
3808
- * 2. Receiver: sees the storage event for setItem (newValue = payload), parses
3809
- * it, then defensively calls removeItem on the key to clean up any entry
3810
- * that survived a writer crash. Events for removeItem (newValue = null) are
3811
- * ignored.
3812
- *
3813
- * ## Failure isolation
3814
- *
3815
- * Throws inside JSON.parse, listener callbacks, or localStorage writes are
3816
- * caught and silently dropped. The channel degrades to a no-op per operation
3817
- * rather than crashing the host page (rule #2 of CLAUDE.md).
3818
- */
3819
- const STORAGE_KEY_PREFIX = 'tri:bcast:';
3820
- /**
3821
- * BroadcastChannel-like backed by localStorage and the window `storage` event.
3822
- * Used as a fallback on browsers that do not support native BroadcastChannel.
3823
- */
3824
- class StorageEventChannel {
3825
- constructor(name) {
3826
- this.seq = 0;
3827
- this.closed = false;
3828
- this.listeners = new Set();
3829
- /**
3830
- * Arrow property so `this` is bound when passed directly to
3831
- * addEventListener / removeEventListener.
3832
- */
3833
- this.onStorageEvent = (event) => {
3834
- if (this.closed)
3835
- return;
3836
- const keyPrefix = `${STORAGE_KEY_PREFIX}${this.channelName}:`;
3837
- if (!event.key || !event.key.startsWith(keyPrefix))
3838
- return;
3839
- // Ignore the removal event produced by the writer's own removeItem call.
3840
- if (event.newValue === null)
3841
- return;
3842
- // Defensive cleanup: the writer should have removed this already, but
3843
- // guard against crashes mid-write that leave a stale entry.
3844
- safeSync(() => LocalStorage.remove(event.key));
3845
- let data;
3846
- try {
3847
- data = JSON.parse(event.newValue);
3848
- }
3849
- catch {
3850
- return;
3851
- }
3852
- const dispatched = { data };
3853
- for (const listener of this.listeners) {
3854
- safeSync(() => listener(dispatched));
3855
- }
3856
- };
3857
- this.channelName = name;
3858
- this.senderId = generateUUID();
3859
- window.addEventListener('storage', this.onStorageEvent);
3860
- }
3861
- /**
3862
- * Serialises `message` to JSON, writes it under a unique key, then removes
3863
- * it immediately. The write triggers the `storage` event in all other tabs
3864
- * on the same origin; the removal keeps localStorage clean.
3865
- *
3866
- * Payload size is not explicitly bounded here because tab-discovery messages
3867
- * are tiny plain-object JSON. If the channel is ever used for larger payloads
3868
- * this assumption must be revisited against the ~5 MB localStorage quota.
3869
- */
3870
- postMessage(message) {
3871
- if (this.closed)
3872
- return;
3873
- const key = `${STORAGE_KEY_PREFIX}${this.channelName}:${this.senderId}:${this.seq++}`;
3874
- safeSync(() => {
3875
- LocalStorage.set(key, message);
3876
- // The storage event fires in other tabs upon setItem; remove the key
3877
- // immediately so no stale entry persists if a subsequent read races.
3878
- LocalStorage.remove(key);
3879
- });
3880
- }
3881
- addEventListener(_type, listener) {
3882
- this.listeners.add(listener);
3883
- }
3884
- removeEventListener(_type, listener) {
3885
- this.listeners.delete(listener);
3886
- }
3887
- /** Detaches the storage listener and prevents further dispatch. */
3888
- close() {
3889
- if (this.closed)
3890
- return;
3891
- this.closed = true;
3892
- window.removeEventListener('storage', this.onStorageEvent);
3893
- this.listeners.clear();
3894
- }
3895
- }
3896
-
3897
- /**
3898
- * @module broadcast-channel
3899
- *
3900
- * Ponyfill selector for BroadcastChannel.
3901
- *
3902
- * ## Ponyfill, not polyfill
3903
- *
3904
- * This module exports `TriBroadcastChannel` — a local binding that resolves
3905
- * to native BroadcastChannel when available, a localStorage fallback when not,
3906
- * or a no-op when neither storage mechanism is usable (e.g. private mode with
3907
- * storage blocked). The host page's `globalThis` is never modified.
3908
- *
3909
- * ## Detection order
3910
- *
3911
- * 1. Native `BroadcastChannel` — detected via `typeof BroadcastChannel === 'function'`
3912
- * so any polyfill the host app installed is transparently honoured.
3913
- * 2. `StorageEventChannel` — requires `window` and a writable `localStorage`.
3914
- * Availability is probed once at module load via a setItem/getItem/removeItem
3915
- * round-trip, matching the approach used in `lib/immortal-db/storages/web.ts`.
3916
- * 3. `NoopChannel` — silent fallback when neither option is available. Emits
3917
- * a single `true`-gated console.warn at construction time so developers
3918
- * can see that tab discovery is disabled.
3919
- *
3920
- * ## Module-load detection
3921
- *
3922
- * Selection happens once when this module is first imported and the result is
3923
- * cached in `TriBroadcastChannel`. This is intentional: re-probing on every
3924
- * constructor call would be wasteful and storage availability does not change
3925
- * within a session.
3926
- */
3927
- // ─── No-op channel ────────────────────────────────────────────────────────────
3928
- /**
3929
- * Silent fallback used when neither native BroadcastChannel nor localStorage
3930
- * is available. All operations are no-ops. Tab count will stay at 1.
3931
- * Emits a single `true` console.warn at construction so developers can
3932
- * see that cross-tab discovery is disabled.
3933
- */
3934
- class NoopChannel {
3935
- constructor(_name) {
3936
- {
3937
- // eslint-disable-next-line no-console
3938
- console.warn('[TriSDK] BroadcastChannel is unavailable and localStorage is not ' +
3939
- 'writable in this context. Cross-tab discovery is disabled; tab ' +
3940
- 'count will always be reported as 1.');
3941
- }
3942
- }
3943
- postMessage(_message) { }
3944
- addEventListener(_type, _listener) { }
3945
- removeEventListener(_type, _listener) { }
3946
- close() { }
3947
- }
3948
- // ─── Availability probes ──────────────────────────────────────────────────────
3949
- /**
3950
- * Returns true if `localStorage` is present and can be written to.
3951
- * Catches: Safari private mode (throws on setItem), storage disabled by
3952
- * policy, quota-exhausted state.
3953
- *
3954
- * Uses `typeof Storage !== 'undefined'` as a fast pre-check; the round-trip
3955
- * write is needed because that check alone is unreliable in embedded contexts.
3956
- */
3957
- function isLocalStorageWritable() {
3958
- if (typeof Storage === 'undefined' || typeof window === 'undefined') {
3959
- return false;
3960
- }
3961
- try {
3962
- const probeKey = '__tri_bcast_probe__';
3963
- localStorage.setItem(probeKey, '1');
3964
- const ok = localStorage.getItem(probeKey) === '1';
3965
- localStorage.removeItem(probeKey);
3966
- return ok;
3967
- }
3968
- catch {
3969
- return false;
3970
- }
3971
- }
3972
- // ─── Selector ─────────────────────────────────────────────────────────────────
3973
- function pickChannel() {
3974
- if (typeof BroadcastChannel === 'function') {
3975
- return BroadcastChannel;
3976
- }
3977
- if (isLocalStorageWritable()) {
3978
- return StorageEventChannel;
3979
- }
3980
- return NoopChannel;
3981
- }
3982
- /**
3983
- * Drop-in BroadcastChannel constructor that works across environments.
3984
- *
3985
- * Resolves at module load to:
3986
- * - Native `BroadcastChannel` when available (including host-app polyfills).
3987
- * - `StorageEventChannel` (localStorage + storage event) on older browsers.
3988
- * - `NoopChannel` when neither is usable.
3989
- *
3990
- * Usage is identical to native BroadcastChannel:
3991
- * ```ts
3992
- * const channel = new TriBroadcastChannel('my-channel')
3993
- * channel.addEventListener('message', handler)
3994
- * channel.postMessage({ type: 'hello' })
3995
- * channel.close()
3996
- * ```
3997
- */
3998
- const TriBroadcastChannel = pickChannel();
3999
-
4000
- const STORAGE_KEY = 'tri:tabId';
4001
- const CHANNEL_NAME = 'tri:tab_channel';
4002
- const HEARTBEAT_INTERVAL_MS = 2000;
4003
- const IDLE_HEARTBEAT_INTERVAL_MS = 3 * HEARTBEAT_INTERVAL_MS;
4004
- /* setting to 1s due to browser tab throttling to 1Hz so 500ms could be missed */
4005
- const COUNT_EMIT_DEBOUNCE_MS = 1000;
4006
- const ALIVE_LOCK_NAME = 'tri:tab:alive';
4007
- class TabManager {
4008
- constructor(context) {
4009
- this.context = context;
4010
- this.tabId = '';
4011
- this.started = false;
4012
- this.broadcastChannel = null;
4013
- this.heartbeatTimer = null;
4014
- this.countEmitTimer = null;
4015
- this.peers = new Map();
4016
- this.lastEmittedCount = -1;
4017
- this.releaseAlive = null;
4018
- this.bound_OnBroadcastMessage = null;
4019
- }
4020
- async start() {
4021
- if (this.started)
4022
- return;
4023
- this.started = true;
4024
- this.tabId = this._generateTabId();
4025
- await this._setupAliveLock();
4026
- this._openChannel();
4027
- this._broadcast({ type: 'hello', tabId: this.tabId, ts: Date.now() });
4028
- this._startHeartbeat();
4029
- this._emitTabState('active');
4030
- this._scheduleCountEmit();
4031
- }
4032
- stop() {
4033
- if (!this.started)
4034
- return;
4035
- this.started = false;
4036
- this._emitTabState('inactive');
4037
- this._broadcast({ type: 'bye', tabId: this.tabId, ts: Date.now() });
4038
- /* there is no increment because we are emitting count of the remaining tabs */
4039
- this._emitTabCount(this._sweepPeers());
4040
- this._stopHeartbeat();
4041
- this._cancelCountEmit();
4042
- this._closeChannel();
4043
- this.releaseAliveLock();
4044
- }
4045
- getTabId() {
4046
- return this.tabId;
4047
- }
4048
- async queryTabsNumber() {
4049
- const totalTabsNumber = this.peers.size + 1;
4050
- const holders = await queryWebLockHolders(ALIVE_LOCK_NAME);
4051
- return holders?.length ?? totalTabsNumber;
4052
- }
4053
- releaseAliveLock() {
4054
- this.releaseAlive?.();
4055
- this.releaseAlive = null;
4233
+ const initial = await fn(tokenAtStart);
4234
+ if (!shouldRefresh(initial) || options?.refreshable === false)
4235
+ return initial;
4236
+ const newToken = await scheduleSingleFlightRefresh();
4237
+ // Bail if refresh failed or returned the same stale token (e.g. another tab
4238
+ // raced the current tab and both read the pre-refresh value).
4239
+ if (!newToken || newToken === tokenAtStart)
4240
+ return initial;
4241
+ return fn(newToken);
4056
4242
  }
4057
- _openChannel() {
4243
+ async function attemptRefresh() {
4058
4244
  try {
4059
- this.broadcastChannel = new TriBroadcastChannel(CHANNEL_NAME);
4060
- this.bound_OnBroadcastMessage = this._onBroadcastMessage.bind(this);
4061
- this.broadcastChannel.addEventListener('message', this.bound_OnBroadcastMessage);
4245
+ await refresh();
4246
+ return getToken();
4062
4247
  }
4063
4248
  catch (error) {
4064
- {
4065
- // eslint-disable-next-line no-console
4066
- console.warn('[TriSDK] failed to open broadcast channel:', error);
4067
- }
4249
+ logger.warn('AuthMiddleware', 'auth refresh failed', error);
4250
+ return null;
4068
4251
  }
4069
4252
  }
4070
- _closeChannel() {
4071
- try {
4072
- if (!this.broadcastChannel)
4073
- return;
4074
- if (this.bound_OnBroadcastMessage) {
4075
- this.broadcastChannel.removeEventListener('message', this.bound_OnBroadcastMessage);
4076
- }
4077
- this.broadcastChannel.close();
4078
- }
4079
- catch {
4080
- // do nothing
4081
- }
4082
- finally {
4083
- this.broadcastChannel = null;
4084
- this.bound_OnBroadcastMessage = null;
4253
+ function scheduleSingleFlightRefresh() {
4254
+ if (inflightRefresh) {
4255
+ return inflightRefresh;
4085
4256
  }
4257
+ inflightRefresh = attemptRefresh().finally(() => {
4258
+ inflightRefresh = null;
4259
+ });
4260
+ return inflightRefresh;
4086
4261
  }
4087
- _startHeartbeat() {
4088
- this.heartbeatTimer = setInterval(() => {
4089
- const now = Date.now();
4090
- this._broadcast({
4091
- type: 'heartbeat',
4092
- tabId: this.tabId,
4093
- ts: now
4094
- });
4095
- }, HEARTBEAT_INTERVAL_MS);
4096
- }
4097
- _stopHeartbeat() {
4098
- if (this.heartbeatTimer != null) {
4099
- clearInterval(this.heartbeatTimer);
4100
- this.heartbeatTimer = null;
4101
- }
4262
+ function shouldRefresh(result) {
4263
+ if (result.ok)
4264
+ return false;
4265
+ if (result.kind !== 'client')
4266
+ return false;
4267
+ return REFRESHABLE_STATUS_CODES.includes(result.status);
4102
4268
  }
4103
- _scheduleCountEmit() {
4104
- if (this.countEmitTimer != null)
4105
- clearTimeout(this.countEmitTimer);
4106
- this.countEmitTimer = setTimeout(() => {
4107
- this.countEmitTimer = null;
4108
- const count = this._sweepPeers() + 1;
4109
- if (count === this.lastEmittedCount)
4110
- return;
4111
- this.lastEmittedCount = count;
4112
- this._emitTabCount(count);
4113
- }, COUNT_EMIT_DEBOUNCE_MS);
4269
+ return { withAuth };
4270
+ }
4271
+
4272
+ function createFalconServiceManager(context) {
4273
+ if (typeof document === 'undefined' ||
4274
+ typeof globalThis.fetch !== 'function') {
4275
+ logger.warn('FalconService', 'createFalconServiceManager called in a non-browser environment' +
4276
+ " — all requests will resolve with { ok: false, kind: 'network' }.");
4114
4277
  }
4115
- _cancelCountEmit() {
4116
- if (this.countEmitTimer != null) {
4117
- clearTimeout(this.countEmitTimer);
4118
- this.countEmitTimer = null;
4278
+ const http = createHttpClient({
4279
+ baseUrl: `${context.api.url}/api/v1/events`,
4280
+ timeout: 10000,
4281
+ profileId: context.profileId,
4282
+ api: {
4283
+ key: context.api.key,
4284
+ version: context.api.version
4119
4285
  }
4286
+ });
4287
+ const authMiddleware = createAuthMiddleware(context);
4288
+ /** @description Starts a new activity and returns a token — unauthenticated endpoint */
4289
+ async function startActivity() {
4290
+ return http.post('/start-activity');
4120
4291
  }
4121
- _broadcast(message) {
4122
- try {
4123
- if (!this.broadcastChannel)
4124
- return;
4125
- this.broadcastChannel.postMessage(message);
4126
- }
4127
- catch {
4128
- {
4129
- // eslint-disable-next-line no-console
4130
- console.warn('[TriSDK] failed to broadcast message:', message);
4131
- }
4132
- }
4292
+ /** @description Stops an activity and returns a result — authenticated endpoint */
4293
+ async function stopActivity() {
4294
+ return authMiddleware.withAuth((token) => http.post('/stop-activity', { token, keepalive: true }), { refreshable: false });
4133
4295
  }
4134
- _onBroadcastMessage(event) {
4135
- const { type, tabId } = event.data;
4136
- if (tabId === this.tabId)
4137
- return;
4138
- const receivedAt = Date.now();
4139
- if (type === 'hello') {
4140
- this._broadcast({
4141
- type: 'ack',
4142
- tabId: this.tabId,
4143
- ts: receivedAt,
4144
- to: tabId
4296
+ /** @description Ingests a batch of events — authenticated endpoint */
4297
+ async function ingest(payload, keepalive) {
4298
+ return authMiddleware.withAuth(async (token) => {
4299
+ // Re-stamp activityId from the ActivityManager at withAuth-callback time so
4300
+ // that if a 401 triggers a token refresh (which mints a new activityId on the
4301
+ // backend), the retried request carries the fresh activityId rather than the
4302
+ // stale one baked into the batch at createBatch() time. Sending a stale
4303
+ // activityId after a refresh would cause the backend to tag the batch as
4304
+ // less reliable. The withAuth callback re-runs on refresh, which is exactly
4305
+ // the point where we need to pull the updated value.
4306
+ const { events, ...batchMeta } = payload;
4307
+ const adjustedBatchMeta = {
4308
+ ...batchMeta,
4309
+ activityId: context.getActivityId()
4310
+ };
4311
+ let compressed;
4312
+ try {
4313
+ compressed = await compressIngestPayload(adjustedBatchMeta, events);
4314
+ }
4315
+ catch {
4316
+ return { ok: false, kind: 'serialize' };
4317
+ }
4318
+ return http.post('/capture', {
4319
+ body: compressed.body,
4320
+ token,
4321
+ keepalive,
4322
+ contentType: 'ndjson',
4323
+ contentEncoding: compressed.type === 'gzip' ? 'gzip' : undefined
4145
4324
  });
4146
- this.peers.set(tabId, receivedAt);
4147
- }
4148
- else if (type === 'heartbeat') {
4149
- this.peers.set(tabId, receivedAt);
4150
- }
4151
- else if (type === 'ack') {
4152
- this.peers.set(tabId, receivedAt);
4153
- }
4154
- else if (type === 'bye') {
4155
- this.peers.delete(tabId);
4156
- }
4157
- }
4158
- _emitTabCount(value) {
4159
- this.context.push({
4160
- name: 'tab',
4161
- type: 'count',
4162
- tabId: this.tabId,
4163
- timestamp: Date.now(),
4164
- value
4165
4325
  });
4166
4326
  }
4167
- _emitTabState(value, tabId) {
4168
- this.context.push({
4169
- name: 'tab',
4170
- type: 'state',
4171
- tabId: this.tabId,
4172
- timestamp: Date.now(),
4173
- value
4174
- });
4327
+ /** @description Sends a single named event with free-form metadata — authenticated endpoint */
4328
+ async function reportEvent(payload) {
4329
+ return authMiddleware.withAuth((token) => http.post('/report-event', {
4330
+ body: payload,
4331
+ token,
4332
+ contentType: 'json'
4333
+ }));
4175
4334
  }
4176
- _generateTabId() {
4177
- const existing = SessionStorage.get(STORAGE_KEY);
4178
- if (existing)
4179
- return existing;
4180
- const fresh = generateUUID();
4181
- SessionStorage.set(STORAGE_KEY, fresh);
4182
- return fresh;
4335
+ return {
4336
+ api: {
4337
+ ingest,
4338
+ reportEvent,
4339
+ startActivity,
4340
+ stopActivity
4341
+ }
4342
+ };
4343
+ }
4344
+
4345
+ class EventReportManager {
4346
+ constructor(context) {
4347
+ this.context = context;
4183
4348
  }
4184
- _sweepPeers(now = Date.now()) {
4185
- let active = 0;
4186
- for (const [tabId, timestamp] of this.peers.entries()) {
4187
- if (now - timestamp >= IDLE_HEARTBEAT_INTERVAL_MS) {
4188
- this.peers.delete(tabId);
4189
- }
4190
- else {
4191
- active++;
4349
+ /** No-op. Present for lifecycle symmetry with other managers. */
4350
+ stop() { }
4351
+ async report(name, metadata) {
4352
+ const reportEventPayload = {
4353
+ name,
4354
+ metadata,
4355
+ timestamp: Date.now(),
4356
+ activityId: this.context.getActivityId() ?? ''
4357
+ };
4358
+ try {
4359
+ const result = await this.context.reportEvent(reportEventPayload);
4360
+ if (result.ok) {
4361
+ return result;
4192
4362
  }
4363
+ logger.warn('EventReport', 'Failed to report event:', name, metadata, 'with result:', result);
4364
+ }
4365
+ catch (error) {
4366
+ logger.warn('EventReport', 'Failed to report event:', name, metadata, 'with error:', error);
4193
4367
  }
4194
- return active;
4195
- }
4196
- async _setupAliveLock() {
4197
- return new Promise((acquired) => {
4198
- void withSharedWebLock(ALIVE_LOCK_NAME, () => {
4199
- acquired();
4200
- return new Promise((resolve) => {
4201
- this.releaseAlive = resolve;
4202
- });
4203
- });
4204
- });
4205
4368
  }
4206
4369
  }
4207
4370
 
4208
- /*! js-cookie v3.0.5 | MIT */
4209
- /* eslint-disable no-var */
4210
- function assign (target) {
4211
- for (var i = 1; i < arguments.length; i++) {
4212
- var source = arguments[i];
4213
- for (var key in source) {
4214
- target[key] = source[key];
4215
- }
4216
- }
4217
- return target
4218
- }
4219
- /* eslint-enable no-var */
4220
-
4221
- /* eslint-disable no-var */
4222
- var defaultConverter = {
4223
- read: function (value) {
4371
+ /**
4372
+ * Internal RFC6265-compliant cookie utility.
4373
+ *
4374
+ * Encoding is intentionally bit-for-bit compatible with js-cookie v3.0.7 so
4375
+ * that cookies written by earlier SDK versions (which used js-cookie directly)
4376
+ * continue to decode correctly after upgrading to this implementation.
4377
+ *
4378
+ * The module is internal-only and must not be re-exported from src/index.ts.
4379
+ *
4380
+ * @module
4381
+ */
4382
+ /**
4383
+ * Encodes a cookie value using the js-cookie v3 RFC6265 scheme:
4384
+ * 1. `encodeURIComponent` percent-encodes everything except `A–Z a–z 0–9 - _ . ! ~ * ' ( )`.
4385
+ * 2. A second pass un-escapes the RFC6265 cookie-octet whitelist characters
4386
+ * that are safe to transmit without encoding:
4387
+ * `# $ & + / : < = > ? @ [ ] ^ \` { | }`.
4388
+ * 3. Semicolons (`;`) remain percent-encoded because they act as the cookie
4389
+ * attribute separator and must not appear unescaped in a value.
4390
+ */
4391
+ function encodeValue(value) {
4392
+ return encodeURIComponent(value).replace(/%(2[346BF]|3[AC-F]|40|5[BDE]|60|7[BCD])/gi, decodeURIComponent);
4393
+ }
4394
+ /**
4395
+ * Encodes a cookie name using the js-cookie v3 RFC6265 scheme:
4396
+ * 1. `encodeURIComponent` percent-encodes the name.
4397
+ * 2. A second pass un-escapes the RFC6265 cookie-name token whitelist:
4398
+ * `# $ & + ^ \` |`.
4399
+ * 3. `(` and `)` are left as `%28` / `%29`; raw parentheses are disallowed in
4400
+ * cookie names per the spec, and js-cookie explicitly re-encodes them via
4401
+ * the deprecated global `escape()` which produces the same result.
4402
+ */
4403
+ function encodeName(name) {
4404
+ return encodeURIComponent(name)
4405
+ .replace(/%(2[346B]|5E|60|7C)/gi, decodeURIComponent)
4406
+ .replace(/[()]/g, (char) => (char === '(' ? '%28' : '%29'));
4407
+ }
4408
+ /**
4409
+ * Decodes a cookie value using the js-cookie v3 scheme.
4410
+ *
4411
+ * Strips a surrounding pair of double quotes (some Set-Cookie implementations
4412
+ * wrap values in quotes), then replaces runs of percent-encoded bytes with
4413
+ * their decoded form. Mirrors js-cookie's per-sequence try/catch fallback so
4414
+ * that malformed percent-sequences in cookies written by other parties degrade
4415
+ * to the raw string rather than throwing.
4416
+ */
4417
+ function decodeValue(raw) {
4418
+ let value = raw;
4224
4419
  if (value[0] === '"') {
4225
- value = value.slice(1, -1);
4226
- }
4227
- return value.replace(/(%[\dA-F]{2})+/gi, decodeURIComponent)
4228
- },
4229
- write: function (value) {
4230
- return encodeURIComponent(value).replace(
4231
- /%(2[346BF]|3[AC-F]|40|5[BDE]|60|7[BCD])/g,
4232
- decodeURIComponent
4233
- )
4234
- }
4235
- };
4236
- /* eslint-enable no-var */
4237
-
4238
- /* eslint-disable no-var */
4239
-
4240
- function init (converter, defaultAttributes) {
4241
- function set (name, value, attributes) {
4242
- if (typeof document === 'undefined') {
4243
- return
4420
+ value = value.slice(1, -1);
4244
4421
  }
4245
-
4246
- attributes = assign({}, defaultAttributes, attributes);
4247
-
4248
- if (typeof attributes.expires === 'number') {
4249
- attributes.expires = new Date(Date.now() + attributes.expires * 864e5);
4422
+ return value.replace(/(%[\dA-F]{2})+/gi, (sequence) => {
4423
+ try {
4424
+ return decodeURIComponent(sequence);
4425
+ }
4426
+ catch {
4427
+ return sequence;
4428
+ }
4429
+ });
4430
+ }
4431
+ /**
4432
+ * Serialises `CookieAttributes` into the `; Key=Value` fragment appended to
4433
+ * the `name=value` pair written to `document.cookie`.
4434
+ */
4435
+ function serializeAttributes(attributes) {
4436
+ let result = '';
4437
+ if (attributes.expires !== undefined) {
4438
+ let expiresDate;
4439
+ if (attributes.expires instanceof Date) {
4440
+ expiresDate = attributes.expires;
4441
+ }
4442
+ else {
4443
+ expiresDate = new Date(Date.now() + attributes.expires * 864e5);
4444
+ }
4445
+ result += '; Expires=' + expiresDate.toUTCString();
4250
4446
  }
4251
- if (attributes.expires) {
4252
- attributes.expires = attributes.expires.toUTCString();
4447
+ if (attributes.path !== undefined) {
4448
+ result += '; Path=' + attributes.path;
4253
4449
  }
4254
-
4255
- name = encodeURIComponent(name)
4256
- .replace(/%(2[346B]|5E|60|7C)/g, decodeURIComponent)
4257
- .replace(/[()]/g, escape);
4258
-
4259
- var stringifiedAttributes = '';
4260
- for (var attributeName in attributes) {
4261
- if (!attributes[attributeName]) {
4262
- continue
4263
- }
4264
-
4265
- stringifiedAttributes += '; ' + attributeName;
4266
-
4267
- if (attributes[attributeName] === true) {
4268
- continue
4269
- }
4270
-
4271
- // Considers RFC 6265 section 5.2:
4272
- // ...
4273
- // 3. If the remaining unparsed-attributes contains a %x3B (";")
4274
- // character:
4275
- // Consume the characters of the unparsed-attributes up to,
4276
- // not including, the first %x3B (";") character.
4277
- // ...
4278
- stringifiedAttributes += '=' + attributes[attributeName].split(';')[0];
4450
+ if (attributes.domain !== undefined) {
4451
+ result += '; Domain=' + attributes.domain;
4279
4452
  }
4280
-
4281
- return (document.cookie =
4282
- name + '=' + converter.write(value, name) + stringifiedAttributes)
4283
- }
4284
-
4285
- function get (name) {
4286
- if (typeof document === 'undefined' || (arguments.length && !name)) {
4287
- return
4453
+ if (attributes.sameSite !== undefined) {
4454
+ const capitalised = attributes.sameSite.charAt(0).toUpperCase() +
4455
+ attributes.sameSite.slice(1);
4456
+ result += '; SameSite=' + capitalised;
4288
4457
  }
4289
-
4290
- // To prevent the for loop in the first place assign an empty array
4291
- // in case there are no cookies at all.
4292
- var cookies = document.cookie ? document.cookie.split('; ') : [];
4293
- var jar = {};
4294
- for (var i = 0; i < cookies.length; i++) {
4295
- var parts = cookies[i].split('=');
4296
- var value = parts.slice(1).join('=');
4297
-
4298
- try {
4299
- var found = decodeURIComponent(parts[0]);
4300
- jar[found] = converter.read(value, found);
4301
-
4302
- if (name === found) {
4303
- break
4458
+ if (attributes.secure === true) {
4459
+ result += '; Secure';
4460
+ }
4461
+ return result;
4462
+ }
4463
+ /**
4464
+ * Reads a cookie by name from `document.cookie`.
4465
+ *
4466
+ * Decoding mirrors js-cookie v3 exactly:
4467
+ * - Segments are split on `'; '`.
4468
+ * - Each segment is split on the **first** `=`.
4469
+ * - The name is decoded with plain `decodeURIComponent`.
4470
+ * - The value is decoded with the js-cookie percent-sequence approach (see
4471
+ * `decodeValue`).
4472
+ *
4473
+ * Returns `undefined` when the cookie is absent or when `document` is not
4474
+ * available in the current environment.
4475
+ */
4476
+ function getCookie(name) {
4477
+ if (typeof document === 'undefined')
4478
+ return undefined;
4479
+ const cookies = document.cookie ? document.cookie.split('; ') : [];
4480
+ for (const cookie of cookies) {
4481
+ const separatorIndex = cookie.indexOf('=');
4482
+ if (separatorIndex === -1)
4483
+ continue;
4484
+ const rawName = cookie.slice(0, separatorIndex);
4485
+ let decodedName;
4486
+ try {
4487
+ decodedName = decodeURIComponent(rawName);
4488
+ }
4489
+ catch {
4490
+ decodedName = rawName;
4491
+ }
4492
+ if (decodedName === name) {
4493
+ const rawValue = cookie.slice(separatorIndex + 1);
4494
+ return decodeValue(rawValue);
4304
4495
  }
4305
- } catch (e) {}
4306
- }
4307
-
4308
- return name ? jar[name] : jar
4309
- }
4310
-
4311
- return Object.create(
4312
- {
4313
- set,
4314
- get,
4315
- remove: function (name, attributes) {
4316
- set(
4317
- name,
4318
- '',
4319
- assign({}, attributes, {
4320
- expires: -1
4321
- })
4322
- );
4323
- },
4324
- withAttributes: function (attributes) {
4325
- return init(this.converter, assign({}, this.attributes, attributes))
4326
- },
4327
- withConverter: function (converter) {
4328
- return init(assign({}, this.converter, converter), this.attributes)
4329
- }
4330
- },
4331
- {
4332
- attributes: { value: Object.freeze(defaultAttributes) },
4333
- converter: { value: Object.freeze(converter) }
4334
4496
  }
4335
- )
4497
+ return undefined;
4498
+ }
4499
+ /**
4500
+ * Writes a cookie to `document.cookie`.
4501
+ *
4502
+ * Name and value encoding mirrors js-cookie v3 exactly (see `encodeName` and
4503
+ * `encodeValue`). No-ops silently when `document` is not available.
4504
+ */
4505
+ function setCookie(name, value, attributes = {}) {
4506
+ if (typeof document === 'undefined')
4507
+ return;
4508
+ document.cookie =
4509
+ encodeName(name) +
4510
+ '=' +
4511
+ encodeValue(value) +
4512
+ serializeAttributes(attributes);
4513
+ }
4514
+ /**
4515
+ * Removes a cookie by expiring it one day in the past.
4516
+ *
4517
+ * Mirrors js-cookie v3's removal approach (`expires: -1`). The `path` and
4518
+ * `domain` attributes must match those used when the cookie was written;
4519
+ * mismatches silently leave the cookie in place.
4520
+ */
4521
+ function removeCookie(name, attributes = {}) {
4522
+ setCookie(name, '', { ...attributes, expires: -1 });
4336
4523
  }
4337
-
4338
- var api = init(defaultConverter, { path: '/' });
4339
4524
 
4340
4525
  /**
4341
4526
  * Hard cap on cookie size. Browsers vary slightly (the spec is 4096 bytes
@@ -4344,10 +4529,15 @@
4344
4529
  */
4345
4530
  const COOKIE_BYTE_BUDGET = 4000;
4346
4531
  /**
4347
- * Adapter over js-cookie. Cookies are durable across tabs and survive
4348
- * localStorage clears, but they're capped at ~4KB per cookie and ~50
4349
- * cookies per domain — large values are rejected rather than silently
4350
- * truncated so the orchestrator can degrade gracefully.
4532
+ * Cookie storage adapter backed by `document.cookie`.
4533
+ *
4534
+ * Cookies are durable across tabs and survive localStorage clears, but they're
4535
+ * capped at ~4KB per cookie and ~50 cookies per domain — large values are
4536
+ * rejected rather than silently truncated so the orchestrator can degrade
4537
+ * gracefully.
4538
+ *
4539
+ * Encoding is bit-for-bit compatible with js-cookie v3 (RFC6265) so that
4540
+ * cookies written by earlier SDK versions continue to read correctly.
4351
4541
  */
4352
4542
  class CookieStorage {
4353
4543
  constructor(options = {}) {
@@ -4366,9 +4556,9 @@
4366
4556
  return false;
4367
4557
  try {
4368
4558
  const probeKey = `__immortal_probe_${generateUUID()}__`;
4369
- safeSync(() => api.set(probeKey, '1', { path: '/' }));
4370
- const ok = safeSync(() => api.get(probeKey)) === '1';
4371
- safeSync(() => api.remove(probeKey, { path: '/' }));
4559
+ safeSync(() => setCookie(probeKey, '1', { path: '/' }));
4560
+ const ok = safeSync(() => getCookie(probeKey)) === '1';
4561
+ safeSync(() => removeCookie(probeKey, { path: '/' }));
4372
4562
  return ok;
4373
4563
  }
4374
4564
  catch {
@@ -4376,7 +4566,7 @@
4376
4566
  }
4377
4567
  }
4378
4568
  async get(key) {
4379
- const value = safeSync(() => api.get(key));
4569
+ const value = safeSync(() => getCookie(key));
4380
4570
  return value === undefined ? null : value;
4381
4571
  }
4382
4572
  async set(key, value) {
@@ -4384,13 +4574,13 @@
4384
4574
  throw new Error(`CookieStorage: combined key+value size (${key.length + value.length}) ` +
4385
4575
  `exceeds budget (${COOKIE_BYTE_BUDGET}) for key "${key}"`);
4386
4576
  }
4387
- safeSync(() => api.set(key, value, this.attrs));
4577
+ safeSync(() => setCookie(key, value, this.attrs));
4388
4578
  }
4389
4579
  async remove(key) {
4390
4580
  // Path must match the original write to actually clear the cookie.
4391
- safeSync(() => api.remove(key, {
4581
+ safeSync(() => removeCookie(key, {
4392
4582
  path: this.attrs.path,
4393
- domain: this.attrs.domain
4583
+ domain: this.attrs.domain,
4394
4584
  }));
4395
4585
  }
4396
4586
  static getCookieAttributes(options) {
@@ -4398,7 +4588,7 @@
4398
4588
  expires: options.expires ?? 365,
4399
4589
  path: options.path ?? '/',
4400
4590
  sameSite: options.sameSite ?? 'lax',
4401
- secure: options.secure ?? false
4591
+ secure: options.secure ?? false,
4402
4592
  };
4403
4593
  }
4404
4594
  }
@@ -4545,7 +4735,7 @@
4545
4735
  this._initialized = false;
4546
4736
  this._initPromise = null;
4547
4737
  this._configured = options.stores ?? ImmortalDB._defaultStores();
4548
- this._keyPrefix = options.keyPrefix ?? 'tri:_immortal_';
4738
+ this._keyPrefix = options.keyPrefix ?? '_tri_immortal_';
4549
4739
  this._onError = options.onError ?? (() => { });
4550
4740
  }
4551
4741
  /** Names of backends that passed availability probing. Useful for diagnostics. */
@@ -5297,183 +5487,495 @@
5297
5487
  };
5298
5488
  }
5299
5489
 
5300
- let TriSDK$1 = class TriSDK {
5301
- constructor() {
5302
- this.initialized = false;
5303
- this.activityManager = null;
5304
- this.transportManager = null;
5305
- this.tabManager = null;
5306
- this.collectorManager = null;
5307
- this.falconServiceManager = null;
5490
+ const TRANSPORT_CONFIG = {
5491
+ flush: {
5492
+ intervalMs: 2000,
5493
+ maxIntervalMs: 30000,
5494
+ size: 50,
5495
+ backoffFailureThreshold: 2
5496
+ },
5497
+ queue: {
5498
+ size: 100
5308
5499
  }
5309
- static get instance() {
5310
- if (!TriSDK._instance) {
5311
- TriSDK._instance = new TriSDK();
5312
- }
5313
- return TriSDK._instance;
5500
+ };
5501
+ /** Default `capture.element` level — `'redact'` is the PII-safe option. */
5502
+ const DEFAULT_CAPTURE_ELEMENT = 'redact';
5503
+ /** Default `capture.content` level. */
5504
+ const DEFAULT_CAPTURE_CONTENT = 'on';
5505
+
5506
+ /**
5507
+ * UUID v4 (and v1/v5 — same shape). Case-insensitive to cover uppercase variants.
5508
+ * Example: `550e8400-e29b-41d4-a716-446655440000`
5509
+ */
5510
+ const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
5511
+ /** Binary number literal embedded in a path. Example: `0b1010` */
5512
+ const NUMBER_LITERAL_BINARY_PATTERN = /^0b[01]+$/i;
5513
+ /** Octal number literal embedded in a path. Example: `0o777` */
5514
+ const NUMBER_LITERAL_OCTAL_PATTERN = /^0o[0-7]+$/i;
5515
+ /** Hex number literal embedded in a path. Example: `0x1A2B` */
5516
+ const NUMBER_LITERAL_HEX_PATTERN = /^0x[0-9a-f]+$/i;
5517
+ /**
5518
+ * Algorithm-prefixed digest. Prefix is 1–13 lowercase alphanumeric chars,
5519
+ * followed by a colon, then ≥6 chars of the digest.
5520
+ * Example: `sha256:e3b0c44298fc1c149afbf4c8996fb924…`
5521
+ */
5522
+ const PREFIXED_HASH_PATTERN = /^[a-z][a-z0-9]{1,12}:[A-Za-z0-9+/=_-]{6,}$/i;
5523
+ /**
5524
+ * Stripe-style prefixed ID: 2–8 lowercase letters, underscore, then ≥3
5525
+ * alphanumeric chars that include at least one digit. The digit lookahead
5526
+ * prevents matching plain slug-style segments like `my_blog` or `app_dir`.
5527
+ * Example: `sub_456`, `cus_NDdj1aPdHbHj1mwa`
5528
+ */
5529
+ const PREFIXED_ID_PATTERN = /^[a-z]{2,8}_(?=.*\d)[A-Za-z0-9]{3,}$/;
5530
+ /**
5531
+ * All-digit numeric IDs four or more characters long (shorter ones are
5532
+ * pagination/version numbers and should be preserved).
5533
+ * Example: `49827341`
5534
+ */
5535
+ const NUMERIC_ID_PATTERN = /^\d{4,}$/;
5536
+ /** Segment containing `@` — almost always an email address in a path. */
5537
+ const EMAIL_PATTERN = /@/;
5538
+ /**
5539
+ * Full JSON Web Token: three base64url groups separated by dots.
5540
+ * Header and signature must each be ≥10 chars to avoid false-positives on
5541
+ * versioned filenames like `style.min.css` or `bundle.abc123.js`.
5542
+ * Example: `eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0In0.SflKxw…`
5543
+ */
5544
+ const JWT_PATTERN = /^[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{5,}\.[A-Za-z0-9_-]{10,}$/;
5545
+ /**
5546
+ * Long base64url blob (≥20 chars, no dots). Catches opaque session tokens,
5547
+ * PKCE code verifiers, and other single-segment encoded values.
5548
+ * Example: `dGhpcyBpcyBhIHRlc3Q`
5549
+ */
5550
+ const BASE64URL_PATTERN = /^[A-Za-z0-9_-]{20,}={0,2}$/;
5551
+ /**
5552
+ * Short Git SHA (7+ hex chars) and any other pure-hex identifier. The
5553
+ * threshold of 7 matches the canonical Git short-SHA length and subsumes
5554
+ * longer hex digests. Known acceptable false-positive: 7-char English words
5555
+ * that happen to be all-hex (`defaced`, `feedface`) — we accept this because
5556
+ * the sanitiser's bias is toward redaction.
5557
+ * Example: `a1b2c3d`, `deadbeefcafe1234`, `abc123def456`
5558
+ */
5559
+ const HEX_PATTERN = /^[0-9a-f]{7,}$/i;
5560
+ /**
5561
+ * Ordered list of patterns used to decide whether a path segment should be
5562
+ * replaced with `:param`. Evaluation stops at the first match. Order is
5563
+ * deliberate: UUID before generic hex (UUID contains hyphens and is more
5564
+ * specific), number literals before the catch-all hex pattern, JWT before
5565
+ * the long base64url pattern.
5566
+ *
5567
+ * Patterns in order:
5568
+ * 1. UUID v4/v1/v5
5569
+ * 2. Binary number literal (`0b…`)
5570
+ * 3. Octal number literal (`0o…`)
5571
+ * 4. Hex number literal (`0x…`)
5572
+ * 5. Algorithm-prefixed digest (`sha256:…`, `md5:…`)
5573
+ * 6. Stripe-style prefixed ID (`sub_456`, `cus_…`)
5574
+ * 7. Bare numeric ID (≥4 digits)
5575
+ * 8. Email in path (`@`)
5576
+ * 9. Full JWT (three base64url parts separated by dots)
5577
+ * 10. Long base64url blob (≥20 chars, no dots)
5578
+ * 11. Pure-hex identifier (≥7 chars, covers Git SHAs and longer digests)
5579
+ */
5580
+ const IDENTIFIER_PATTERNS = [
5581
+ UUID_PATTERN,
5582
+ NUMBER_LITERAL_BINARY_PATTERN,
5583
+ NUMBER_LITERAL_OCTAL_PATTERN,
5584
+ NUMBER_LITERAL_HEX_PATTERN,
5585
+ PREFIXED_HASH_PATTERN,
5586
+ PREFIXED_ID_PATTERN,
5587
+ NUMERIC_ID_PATTERN,
5588
+ EMAIL_PATTERN,
5589
+ JWT_PATTERN,
5590
+ BASE64URL_PATTERN,
5591
+ HEX_PATTERN
5592
+ ];
5593
+ function isIdentifierSegment(segment) {
5594
+ for (const pattern of IDENTIFIER_PATTERNS) {
5595
+ if (pattern.test(segment))
5596
+ return true;
5314
5597
  }
5315
- /**
5316
- * Initialises the TRI SDK. Synchronous and idempotent — a second call is a
5317
- * no-op. Safe to call in SSR environments; if `document` is unavailable the
5318
- * function logs an error and returns without throwing.
5319
- */
5320
- start(config) {
5321
- void this._start(config);
5598
+ return false;
5599
+ }
5600
+ function redactPathSegments(pathname) {
5601
+ return pathname
5602
+ .split('/')
5603
+ .map((segment) => segment !== '' && isIdentifierSegment(segment) ? ':param' : segment)
5604
+ .join('/');
5605
+ }
5606
+ /**
5607
+ * Returns a sanitised URL string with PII-carrying components stripped or
5608
+ * replaced. Designed for consumer-protection: prevents raw secrets
5609
+ * (OAuth tokens, API keys, session tokens, emails, account IDs) from
5610
+ * leaving the browser via the TRI SDK wire payload.
5611
+ *
5612
+ * Sanitisation steps, in order:
5613
+ * 1. Parse via `new URL`. Relative URLs are resolved against `location.href`.
5614
+ * Returns `''` for strings that cannot be parsed.
5615
+ * 2. Strip `username` and `password` (embedded credentials).
5616
+ * 3. Strip `hash` (OAuth implicit-flow tokens, hash-router state).
5617
+ * 4. Redact all query-string values to `<redacted>` (keys are preserved for
5618
+ * endpoint cardinality analysis; values are almost never needed for fraud
5619
+ * signal and routinely carry bearer tokens, magic-link codes, etc.).
5620
+ * 5. Tokenise path segments that look like identifiers → `:param`. Recognised
5621
+ * shapes: UUIDs, binary/octal/hex number literals, algorithm-prefixed
5622
+ * digests (`sha256:…`), Stripe-style prefixed IDs (`sub_456`), bare
5623
+ * numeric IDs (≥4 digits), email-in-path, full JWTs (three base64url
5624
+ * parts), long base64url blobs (≥20 chars), and pure-hex strings (≥7
5625
+ * chars, covering Git short SHAs and longer digests). The hostname is
5626
+ * preserved verbatim (third-party domain is a valuable signal).
5627
+ * 6. Cap the result at `MAX_URL_LENGTH` UTF-16 code units **after** redaction,
5628
+ * so cuts can never strand half an unredacted token.
5629
+ *
5630
+ * This is policy, not a security boundary: a script with same-realm execution
5631
+ * can read URLs directly. The goal is preventing accidental leakage to the
5632
+ * TRI backend by SDK consumers who have not reviewed every URL their app
5633
+ * issues.
5634
+ */
5635
+ function defaultSanitizeUrl(rawUrl) {
5636
+ try {
5637
+ const url = new URL(rawUrl, location.href);
5638
+ url.username = '';
5639
+ url.password = '';
5640
+ url.hash = '';
5641
+ // Build the sanitised query string with literal `<redacted>` values.
5642
+ // Using URLSearchParams.set() would percent-encode `<` and `>`, producing
5643
+ // `%3Credacted%3E` on the wire. We construct the search manually to keep
5644
+ // the human-readable placeholder intact.
5645
+ const paramKeys = [...url.searchParams.keys()];
5646
+ const sanitisedSearch = paramKeys.length > 0
5647
+ ? '?' +
5648
+ paramKeys
5649
+ .map((key) => `${encodeURIComponent(key)}=<redacted>`)
5650
+ .join('&')
5651
+ : '';
5652
+ url.search = '';
5653
+ url.pathname = redactPathSegments(url.pathname);
5654
+ const result = url.origin + url.pathname + sanitisedSearch;
5655
+ return result.length > MAX_URL_LENGTH
5656
+ ? result.slice(0, MAX_URL_LENGTH)
5657
+ : result;
5322
5658
  }
5323
- async _start(config) {
5324
- if (this._shouldEarlyEscapeInit()) {
5325
- return false;
5326
- }
5327
- try {
5328
- const { collectors: collectorsConfig, sdk: sdkConfig } = resolveTriSDKConfig(config);
5329
- /** Falcon Service Manager */
5330
- const falconServiceManagerContext = this._createFalconServiceManagerContext(sdkConfig);
5331
- this.falconServiceManager = createFalconServiceManager(falconServiceManagerContext);
5332
- /** Activity Manager */
5333
- const activityManagerContext = this._createActivityManagerContext();
5334
- /* initialize the activity manager and start activity to get token */
5335
- this.activityManager = new ActivityManager(activityManagerContext);
5336
- await this.activityManager.start();
5337
- /** Transport Manager */
5338
- const transportManagerContext = this._createTransportManagerContext();
5339
- this.transportManager = new TransportManager(TRANSPORT_CONFIG, transportManagerContext);
5340
- this.transportManager.start();
5341
- /** Tab Manager */
5342
- const tabManagerContext = this._createTabManagerContext();
5343
- this.tabManager = new TabManager(tabManagerContext);
5344
- await this.tabManager.start();
5345
- /** Lifecycle Manager */
5346
- const lifecycleManagerContext = this._createLifecycleManagerContext();
5347
- createLifecycleManager(lifecycleManagerContext);
5348
- /** Element Context Processor */
5349
- const elementContextProcessor = createElementContextProcessor(sdkConfig.capture?.element);
5350
- /** Collectors Manager */
5351
- const collectorsManagerContext = this._createCollectorsManagerContext(elementContextProcessor, sdkConfig);
5352
- this.collectorManager = createCollectorsManager(collectorsManagerContext, collectorsConfig, sdkConfig.capture?.content);
5353
- this.collectorManager.autocapture();
5354
- this.initialized = true;
5355
- return true;
5356
- }
5357
- catch (error) {
5358
- {
5359
- // eslint-disable-next-line no-console
5360
- console.error('[TriSDK] init() failed:', error);
5361
- }
5362
- return false;
5363
- }
5659
+ catch {
5660
+ return '';
5364
5661
  }
5365
- /**
5366
- * Reports a host-defined event to the TRI backend. Fire-and-forget — never
5367
- * throws and never returns a promise the host must handle. Drops the event
5368
- * silently (with a dev warning) if called before `init`.
5369
- */
5370
- reportEvent(name, metadata) {
5371
- // TODO - think about creating EventReporterManager that will handle reportEvent and this public facing method will only be a facade for it
5372
- if (this._shouldEarlyEscapeReportEvent()) {
5373
- return;
5662
+ }
5663
+
5664
+ /**
5665
+ * @module sdk/helpers
5666
+ *
5667
+ * @description
5668
+ * Full config resolver for the TRI SDK. Lives in the **lazy chunk** — imported
5669
+ * only after the `/collection-status` preflight succeeds. Transforms the
5670
+ * host-supplied `TRIConfig` into a fully-defaulted `ResolvedTRIConfig`
5671
+ * that all manager and collector instances consume.
5672
+ */
5673
+ /**
5674
+ * Resolves the user-supplied `TRIConfig` into a complete, fully-defaulted internal
5675
+ * config. Branches:
5676
+ * - no config / neither layers nor collectors → all collectors and events enabled,
5677
+ * no excludes (still honours `capture` and `profileId` when provided)
5678
+ * - layers map → collectors enabled iff their layer is true; omitted layers = false
5679
+ * - collectors → per-collector/per-event overrides; omitted collectors default to
5680
+ * **disabled** (host must explicitly opt-in each collector when supplying a
5681
+ * `collectors` map)
5682
+ *
5683
+ * `capture.element` defaults to `'redact'` — the PII-safe option. Hosts that want
5684
+ * full element capture must explicitly set `capture: { element: 'all' }`.
5685
+ */
5686
+ function resolveTRIConfig(config) {
5687
+ if (!config?.apiKey) {
5688
+ logger.fatal('error', 'SDK', 'apiKey is required.');
5689
+ return null;
5690
+ }
5691
+ let baseApiURL;
5692
+ if (config.apiURL) {
5693
+ if (!isApiURLSupported(config.apiURL)) {
5694
+ logger.fatal('error', 'SDK', 'Provided apiURL is not supported:', config.apiURL, 'Supported API URLs are:', SUPPORTED_API_URLS.join(', '));
5695
+ return null;
5374
5696
  }
5375
- const reportEventPayload = {
5376
- name,
5377
- metadata,
5378
- timestamp: Date.now(),
5379
- activityId: this.activityManager?.getActivityId() ?? ''
5697
+ baseApiURL = config.apiURL;
5698
+ }
5699
+ else if (config.env) {
5700
+ baseApiURL = resolveBaseApiURLFromEnv(config.env);
5701
+ }
5702
+ if (!baseApiURL) {
5703
+ logger.fatal('error', 'SDK', "Couldn't initialise SDK — neither apiURL nor env were provided, or the provided value has no provisioned URL.");
5704
+ return null;
5705
+ }
5706
+ const { element, content } = config.capture ?? {};
5707
+ const sdk = {
5708
+ capture: {
5709
+ element: element ?? DEFAULT_CAPTURE_ELEMENT,
5710
+ content: content ?? DEFAULT_CAPTURE_CONTENT
5711
+ },
5712
+ profileId: config.profileId,
5713
+ api: {
5714
+ key: config.apiKey,
5715
+ version: config.apiVersion ?? DEFAULT_API_VERSION,
5716
+ url: baseApiURL
5717
+ },
5718
+ sanitizeUrl: resolveSanitizeUrl(config.sanitizeUrl),
5719
+ debug: config.debug ?? DEFAULT_DEBUG
5720
+ };
5721
+ if (config.collectors === undefined && config.layers === undefined) {
5722
+ return { sdk, collectors: resolveDefaults() };
5723
+ }
5724
+ if (config.collectors !== undefined) {
5725
+ return { sdk, collectors: resolveFromCollectors(config.collectors) };
5726
+ }
5727
+ return { sdk, collectors: resolveFromLayers(config.layers) };
5728
+ }
5729
+ /**
5730
+ * Builds a ResolvedCollectorsConfig with every collector and event defaulting
5731
+ * to enabled. Used when the host omits both `layers` and `collectors`.
5732
+ */
5733
+ function resolveDefaults() {
5734
+ const result = {};
5735
+ for (const key of COLLECTOR_KEYS) {
5736
+ result[key] = resolveCollectorEntry(key, undefined, true);
5737
+ }
5738
+ return result;
5739
+ }
5740
+ /**
5741
+ * Builds a ResolvedCollectorsConfig driven by a layer toggle map.
5742
+ * Omitted layers default to `false` (all collectors in that layer disabled).
5743
+ */
5744
+ function resolveFromLayers(layers) {
5745
+ const result = {};
5746
+ for (const key of COLLECTOR_KEYS) {
5747
+ const collectorEnabled = layers[COLLECTORS_CONFIG[key].layer] ?? false;
5748
+ result[key] = resolveCollectorEntry(key, undefined, collectorEnabled);
5749
+ }
5750
+ return result;
5751
+ }
5752
+ /**
5753
+ * Builds a ResolvedCollectorsConfig from a per-collector config object.
5754
+ * Omitted collectors default to **disabled** — the host must explicitly set
5755
+ * `enabled: true` for each collector it wants active when supplying a
5756
+ * `collectors` map.
5757
+ */
5758
+ function resolveFromCollectors(collectorsInput) {
5759
+ const result = {};
5760
+ for (const key of COLLECTOR_KEYS) {
5761
+ const input = collectorsInput[key];
5762
+ const collectorEnabled = input?.enabled ?? false;
5763
+ result[key] = resolveCollectorEntry(key, input, collectorEnabled);
5764
+ }
5765
+ return result;
5766
+ }
5767
+ /**
5768
+ * Resolves a single collector entry into its fully typed ResolvedCollectorConfig.
5769
+ * One `as` cast at the return boundary is intentional and sound: TypeScript cannot
5770
+ * narrow a conditional type through a runtime length guard.
5771
+ */
5772
+ function resolveCollectorEntry(key, input, collectorEnabled) {
5773
+ const eventNames = COLLECTORS_CONFIG[key]?.events ?? [];
5774
+ if (eventNames.length === 0) {
5775
+ return { enabled: collectorEnabled };
5776
+ }
5777
+ const eventsInput = input?.events;
5778
+ const globalExcludes = eventsInput?.exclude ?? [];
5779
+ const resolvedEvents = {};
5780
+ for (const eventName of eventNames) {
5781
+ const eventEntry = eventsInput?.[eventName];
5782
+ resolvedEvents[eventName] = {
5783
+ enabled: eventEntry?.enabled ?? true,
5784
+ exclude: dedupe([...globalExcludes, ...(eventEntry?.exclude ?? [])])
5380
5785
  };
5381
- void this.falconServiceManager?.api.reportEvent(reportEventPayload);
5382
- }
5383
- _shouldEarlyEscapeInit() {
5384
- // TODO - more robust checks for document availability
5385
- if (this.initialized) {
5386
- {
5387
- // eslint-disable-next-line no-console
5388
- console.warn('[TriSDK] init() called more than once — ignoring.');
5389
- }
5390
- return true;
5391
- }
5392
- if (typeof document === 'undefined') {
5393
- // eslint-disable-next-line no-console
5394
- console.error('[TriSDK] init() called in a non-browser environment. The SDK will not start.');
5395
- return true;
5396
- }
5397
- return false;
5398
5786
  }
5399
- _shouldEarlyEscapeReportEvent() {
5400
- if (!this.initialized) {
5401
- {
5402
- // eslint-disable-next-line no-console
5403
- console.warn('[TriSDK] reportEvent() called before init() — event dropped.');
5404
- }
5405
- return true;
5406
- }
5407
- if (!this.activityManager) {
5408
- {
5409
- // eslint-disable-next-line no-console
5410
- console.warn('[TriSDK] activityManager not initialized.');
5411
- }
5412
- return true;
5413
- }
5414
- if (!this.transportManager) {
5415
- {
5416
- // eslint-disable-next-line no-console
5417
- console.warn('[TriSDK] transportManager not initialized.');
5418
- }
5419
- return true;
5420
- }
5421
- return false;
5787
+ return {
5788
+ enabled: collectorEnabled,
5789
+ events: resolvedEvents
5790
+ };
5791
+ }
5792
+ /**
5793
+ * Returns a new array with duplicate values removed (preserves first occurrence).
5794
+ */
5795
+ function dedupe(array) {
5796
+ return [...new Set(array)];
5797
+ }
5798
+ /**
5799
+ * Produces the final `sanitizeUrl` function stored in the resolved SDK config.
5800
+ * When the host supplies a hook, the hook receives both the raw URL and the
5801
+ * default implementation so it can compose or replace as needed. Any exception
5802
+ * thrown by the host hook is swallowed and the default is used as fallback,
5803
+ * preserving the "zero impact on the host app" contract.
5804
+ */
5805
+ function resolveSanitizeUrl(hook) {
5806
+ if (!hook)
5807
+ return defaultSanitizeUrl;
5808
+ return function sanitizeUrl(url) {
5809
+ return (safeSync(() => hook(url, defaultSanitizeUrl)) ?? defaultSanitizeUrl(url));
5810
+ };
5811
+ }
5812
+ /**
5813
+ * Returns `true` when `apiURL` matches one of the provisioned base URLs from
5814
+ * `ENV_TO_BASE_API_URL_LOOKUP`. Custom URLs not in the list are rejected.
5815
+ */
5816
+ function isApiURLSupported(apiURL) {
5817
+ return SUPPORTED_API_URLS.includes(apiURL);
5818
+ }
5819
+
5820
+ /**
5821
+ * @module sdk/internal/start
5822
+ *
5823
+ * @description
5824
+ * Heavy wiring module that is **dynamically imported** by `sdk.ts` after the
5825
+ * `/collection-status` preflight succeeds. Everything in this file — and every
5826
+ * module it statically imports — lives in the lazy chunk. Nothing in the static
5827
+ * bundle references this file at runtime.
5828
+ *
5829
+ * Managers cross-reference each other through closures over local `let` variables.
5830
+ * Each call to `start` creates a fresh scope, so repeated start/stop cycles
5831
+ * each get independent manager instances.
5832
+ *
5833
+ * The function never throws: any failure during manager construction is caught,
5834
+ * logged, and cleaned up internally; the caller receives `null` to signal failure.
5835
+ *
5836
+ * @internal
5837
+ */
5838
+ /**
5839
+ * Resolves the raw host config and wires all manager instances, returning them
5840
+ * as a struct.
5841
+ *
5842
+ * Called only when the `/collection-status` preflight returns `true`. The
5843
+ * function never throws — if any step fails the partial state is cleaned up
5844
+ * in reverse construction order and `null` is returned so the caller can
5845
+ * reset cleanly.
5846
+ *
5847
+ * ### Construction order matters
5848
+ * `FalconServiceManager` is constructed first because it holds the API client
5849
+ * used by every other manager. Its `getToken`, `refresh`, and `getActivityId`
5850
+ * callbacks capture the `activityManagerRef` variable by closure — the variable
5851
+ * is initially `null` and is assigned synchronously on the very next line, so
5852
+ * by the time any callback is invoked (asynchronously) the ref is populated.
5853
+ */
5854
+ async function start(rawConfig) {
5855
+ const resolvedConfig = resolveTRIConfig(rawConfig);
5856
+ if (!resolvedConfig) {
5857
+ logger.warn('SDK', 'start() failed: could not resolve config.');
5858
+ return null;
5422
5859
  }
5423
- /* Manager Context Factories */
5424
- _createFalconServiceManagerContext(sdkConfig) {
5425
- return {
5860
+ const { collectors: collectorsConfig, sdk: sdkConfig } = resolvedConfig;
5861
+ // Local mutable manager references. Context closures capture these variables,
5862
+ // not the sdk fields, so they always see the current instance for this cycle.
5863
+ let falconServiceManagerRef = null;
5864
+ let activityManagerRef = null;
5865
+ let transportManagerRef = null;
5866
+ let tabManagerRef = null;
5867
+ // Hoisted so the catch block can reach them for cleanup.
5868
+ let lifecycleManager = null;
5869
+ let collectorManager = null;
5870
+ try {
5871
+ /** Falcon Service Manager */
5872
+ falconServiceManagerRef = createFalconServiceManager({
5426
5873
  api: {
5427
5874
  key: sdkConfig.api.key,
5428
5875
  version: sdkConfig.api.version,
5429
5876
  url: sdkConfig.api.url
5430
5877
  },
5431
5878
  profileId: sdkConfig.profileId,
5432
- getToken: () => this.activityManager?.getToken() ?? null,
5433
- refresh: async () => this.activityManager?.refresh() ?? Promise.resolve(),
5434
- getActivityId: () => this.activityManager?.getActivityId() ?? ''
5435
- };
5436
- }
5437
- _createActivityManagerContext() {
5438
- return {
5439
- startActivity: async () => this.falconServiceManager.api.startActivity(),
5440
- stopActivity: async () => this.falconServiceManager.api.stopActivity(),
5879
+ getToken: () => activityManagerRef?.getToken() ?? null,
5880
+ refresh: async () => activityManagerRef?.refresh() ?? Promise.resolve(),
5881
+ getActivityId: () => activityManagerRef?.getActivityId() ?? ''
5882
+ });
5883
+ /** Activity Manager */
5884
+ activityManagerRef = new ActivityManager({
5885
+ startActivity: () => falconServiceManagerRef.api.startActivity(),
5886
+ stopActivity: () => falconServiceManagerRef.api.stopActivity(),
5441
5887
  db: ImmortalDB
5442
- };
5443
- }
5444
- _createTransportManagerContext() {
5445
- return {
5446
- ingest: async (payload, keepalive) => this.falconServiceManager.api.ingest(payload, keepalive),
5447
- getActivityId: () => this.activityManager.getActivityId(),
5448
- getTabId: () => this.tabManager?.getTabId() ?? 'unknown'
5449
- };
5450
- }
5451
- _createTabManagerContext() {
5452
- return {
5453
- push: (event) => this.transportManager?.push(event)
5454
- };
5455
- }
5456
- _createLifecycleManagerContext() {
5888
+ });
5889
+ await activityManagerRef.start();
5890
+ /** Transport Manager */
5891
+ transportManagerRef = new TransportManager(TRANSPORT_CONFIG, {
5892
+ ingest: (payload, keepalive) => falconServiceManagerRef.api.ingest(payload, keepalive),
5893
+ getTabId: () => tabManagerRef?.getTabId() ?? 'unknown'
5894
+ });
5895
+ transportManagerRef.start();
5896
+ /** Tab Manager */
5897
+ tabManagerRef = new TabManager({
5898
+ push: (event) => transportManagerRef?.push(event)
5899
+ });
5900
+ await tabManagerRef.start();
5901
+ /** Lifecycle Manager */
5902
+ lifecycleManager = createLifecycleManager({
5903
+ flush: () => transportManagerRef?.flush('lifecycle'),
5904
+ stopActivity: async () => activityManagerRef?.stop(),
5905
+ stopTab: () => tabManagerRef?.stop(),
5906
+ getShouldSkipStopActivity: () => (tabManagerRef?.queryTabsNumber() ?? 0) > 1,
5907
+ onConnectionChange: () => transportManagerRef?.onConnectionChange()
5908
+ });
5909
+ lifecycleManager.start();
5910
+ /** Element Context Processor */
5911
+ const elementContextProcessor = createElementContextProcessor(sdkConfig.capture?.element);
5912
+ /** Collectors Manager */
5913
+ const ignoredOrigins = new Set();
5914
+ try {
5915
+ ignoredOrigins.add(new URL(sdkConfig.api.url).origin);
5916
+ }
5917
+ catch {
5918
+ // Malformed api.url — leave the set empty; no feedback-loop filtering.
5919
+ }
5920
+ collectorManager = createCollectorsManager({
5921
+ push: (event) => transportManagerRef?.push(event),
5922
+ resolveElement: (element) => elementContextProcessor.resolve(element),
5923
+ sanitizeUrl: sdkConfig.sanitizeUrl,
5924
+ ignoredOrigins: ignoredOrigins
5925
+ }, collectorsConfig, sdkConfig.capture?.content);
5926
+ collectorManager.autocapture();
5927
+ /** Event Reporter */
5928
+ const eventReportManager = new EventReportManager({
5929
+ getActivityId: () => activityManagerRef?.getActivityId() ?? '',
5930
+ reportEvent: (payload) => falconServiceManagerRef.api.reportEvent(payload)
5931
+ });
5457
5932
  return {
5458
- flush: async () => this.transportManager?.flush('lifecycle'),
5459
- stopActivity: async () => this.activityManager?.stop(),
5460
- stopTab: () => this.tabManager?.stop(),
5461
- getShouldSkipStopActivity: async () => ((await this.tabManager?.queryTabsNumber()) ?? 0) <= 1,
5462
- releaseAliveLock: () => this.tabManager?.releaseAliveLock()
5933
+ falconServiceManager: falconServiceManagerRef,
5934
+ activityManager: activityManagerRef,
5935
+ transportManager: transportManagerRef,
5936
+ tabManager: tabManagerRef,
5937
+ lifecycleManager,
5938
+ collectorManager,
5939
+ eventReportManager
5463
5940
  };
5464
5941
  }
5465
- _createCollectorsManagerContext(ecp, sdkConfig) {
5466
- return {
5467
- push: (event) => this.transportManager?.push(event),
5468
- resolveElement: (element) => ecp.resolve(element),
5469
- baseApiUrl: sdkConfig.api.url
5470
- };
5942
+ catch (error) {
5943
+ logger.warn('SDK', 'start() failed during manager wiring; tearing down partial state.', error);
5944
+ // Best-effort cleanup in reverse construction order.
5945
+ try {
5946
+ lifecycleManager?.stop();
5947
+ }
5948
+ catch (e) {
5949
+ logger.warn('SDK', 'lifecycleManager.stop() failed during teardown:', e);
5950
+ }
5951
+ try {
5952
+ tabManagerRef?.stop();
5953
+ }
5954
+ catch (e) {
5955
+ logger.warn('SDK', 'tabManager.stop() failed during teardown:', e);
5956
+ }
5957
+ try {
5958
+ transportManagerRef?.stop();
5959
+ }
5960
+ catch (e) {
5961
+ logger.warn('SDK', 'transportManager.stop() failed during teardown:', e);
5962
+ }
5963
+ try {
5964
+ await activityManagerRef?.stop();
5965
+ }
5966
+ catch (e) {
5967
+ logger.warn('SDK', 'activityManager.stop() failed during teardown:', e);
5968
+ }
5969
+ return null;
5471
5970
  }
5472
- };
5971
+ }
5473
5972
 
5474
- const TriSDK = TriSDK$1.instance;
5973
+ var start$1 = /*#__PURE__*/Object.freeze({
5974
+ __proto__: null,
5975
+ start: start
5976
+ });
5475
5977
 
5476
- return TriSDK;
5978
+ return TRI;
5477
5979
 
5478
5980
  }));
5479
5981
  //# sourceMappingURL=tri.umd.js.map