@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/chunks/index-BYO0AI0T.js +420 -0
- package/dist/chunks/index-BYO0AI0T.js.map +1 -0
- package/dist/chunks/index-BxmW7qox.cjs +432 -0
- package/dist/chunks/index-BxmW7qox.cjs.map +1 -0
- package/dist/chunks/index-CKUPedC4.cjs +432 -0
- package/dist/chunks/index-CKUPedC4.cjs.map +1 -0
- package/dist/chunks/index-NNGWKwfy.js +420 -0
- package/dist/chunks/index-NNGWKwfy.js.map +1 -0
- package/dist/chunks/start-B4n8DLY_.cjs +5554 -0
- package/dist/chunks/start-B4n8DLY_.cjs.map +1 -0
- package/dist/chunks/start-CJOOPw78.cjs +5554 -0
- package/dist/chunks/start-CJOOPw78.cjs.map +1 -0
- package/dist/chunks/start-CrKx_Alk.js +5552 -0
- package/dist/chunks/start-CrKx_Alk.js.map +1 -0
- package/dist/chunks/start-y4ZhvkRa.js +5552 -0
- package/dist/chunks/start-y4ZhvkRa.js.map +1 -0
- package/dist/index.d.ts +166 -37
- package/dist/tri.cjs.js +2 -5467
- package/dist/tri.cjs.js.map +1 -1
- package/dist/tri.esm.js +1 -5470
- package/dist/tri.esm.js.map +1 -1
- package/dist/tri.umd.js +2160 -1658
- package/dist/tri.umd.js.map +1 -1
- package/package.json +5 -7
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
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
|
|
102
|
-
|
|
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
|
-
|
|
295
|
-
sdkVersion:
|
|
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
|
-
|
|
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.
|
|
453
|
-
this.
|
|
454
|
-
this.
|
|
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.
|
|
471
|
-
* flush
|
|
472
|
-
*
|
|
473
|
-
* the event
|
|
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 (
|
|
477
|
-
|
|
478
|
-
|
|
884
|
+
if (!this.started) {
|
|
885
|
+
logger.warn('Transport', 'push() called before start() — event dropped');
|
|
886
|
+
return;
|
|
479
887
|
}
|
|
480
|
-
if (this.queue.length < this.
|
|
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.
|
|
902
|
+
this.scheduleFlush();
|
|
485
903
|
}
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
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
|
-
|
|
495
|
-
|
|
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 (
|
|
507
|
-
|
|
508
|
-
|
|
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
|
-
|
|
512
|
-
|
|
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.
|
|
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.
|
|
533
|
-
|
|
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
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
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
|
-
|
|
547
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
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.
|
|
594
|
-
|
|
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
|
-
*
|
|
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
|
|
606
|
-
* - `server` / `rate_limited`: backend asked us to back off
|
|
607
|
-
* - `abort`:
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
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
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
680
|
-
*
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
*
|
|
692
|
-
*
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
*
|
|
699
|
-
* the
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
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
|
-
*
|
|
712
|
-
*
|
|
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
|
-
|
|
715
|
-
return hasTimingAccess ? value : null;
|
|
716
|
-
}
|
|
1317
|
+
const STORAGE_KEY_PREFIX = 'tri:bcast:';
|
|
717
1318
|
/**
|
|
718
|
-
*
|
|
719
|
-
*
|
|
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
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
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
|
-
*
|
|
788
|
-
*
|
|
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
|
-
|
|
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
|
-
*
|
|
800
|
-
*
|
|
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
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
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
|
|
811
|
-
*
|
|
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
|
|
1452
|
+
function isLocalStorageWritable() {
|
|
1453
|
+
if (typeof Storage === 'undefined' || typeof window === 'undefined') {
|
|
1454
|
+
return false;
|
|
1455
|
+
}
|
|
814
1456
|
try {
|
|
815
|
-
|
|
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
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1068
|
-
|
|
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
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
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
|
-
|
|
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
|
|
1110
|
-
*
|
|
1111
|
-
*
|
|
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
|
|
1830
|
+
function createOriginIgnorePredicate(ignoredOrigins) {
|
|
1114
1831
|
return function shouldIgnoreUrl(url) {
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
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
|
|
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 =
|
|
1133
|
-
const
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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', {
|
|
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, {
|
|
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' &&
|
|
3103
|
+
if (state === 'hashchange' && sanitisedCurrentUrl === previousUrl)
|
|
2383
3104
|
return;
|
|
2384
|
-
const historyEvent = createNavigationEvent('change', state, { from: previousUrl, to:
|
|
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
|
-
|
|
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
|
|
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
|
|
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,
|
|
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
|
-
|
|
3327
|
+
context.flush();
|
|
2712
3328
|
}
|
|
2713
|
-
|
|
3329
|
+
function onPageHide(_) {
|
|
3330
|
+
context.flush();
|
|
2714
3331
|
context.stopTab();
|
|
2715
|
-
|
|
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 (
|
|
3334
|
+
if (shouldSkipStopActivity) {
|
|
2719
3335
|
return;
|
|
2720
3336
|
}
|
|
2721
|
-
context.releaseAliveLock();
|
|
2722
3337
|
void context.stopActivity();
|
|
2723
3338
|
}
|
|
2724
|
-
|
|
2725
|
-
|
|
2726
|
-
|
|
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
|
|
4125
|
+
let reqiestTimeoutTimer = null;
|
|
3501
4126
|
let timedOut = false;
|
|
3502
4127
|
{
|
|
3503
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
4243
|
+
async function attemptRefresh() {
|
|
4058
4244
|
try {
|
|
4059
|
-
|
|
4060
|
-
|
|
4061
|
-
this.broadcastChannel.addEventListener('message', this.bound_OnBroadcastMessage);
|
|
4245
|
+
await refresh();
|
|
4246
|
+
return getToken();
|
|
4062
4247
|
}
|
|
4063
4248
|
catch (error) {
|
|
4064
|
-
|
|
4065
|
-
|
|
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
|
-
|
|
4071
|
-
|
|
4072
|
-
|
|
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
|
-
|
|
4088
|
-
|
|
4089
|
-
|
|
4090
|
-
|
|
4091
|
-
|
|
4092
|
-
|
|
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
|
-
|
|
4104
|
-
|
|
4105
|
-
|
|
4106
|
-
|
|
4107
|
-
|
|
4108
|
-
|
|
4109
|
-
|
|
4110
|
-
|
|
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
|
-
|
|
4116
|
-
|
|
4117
|
-
|
|
4118
|
-
|
|
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
|
-
|
|
4122
|
-
|
|
4123
|
-
|
|
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
|
-
|
|
4135
|
-
|
|
4136
|
-
|
|
4137
|
-
|
|
4138
|
-
|
|
4139
|
-
|
|
4140
|
-
|
|
4141
|
-
|
|
4142
|
-
|
|
4143
|
-
|
|
4144
|
-
|
|
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
|
-
|
|
4168
|
-
|
|
4169
|
-
|
|
4170
|
-
|
|
4171
|
-
|
|
4172
|
-
|
|
4173
|
-
|
|
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
|
-
|
|
4177
|
-
|
|
4178
|
-
|
|
4179
|
-
|
|
4180
|
-
|
|
4181
|
-
|
|
4182
|
-
|
|
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
|
-
|
|
4185
|
-
|
|
4186
|
-
|
|
4187
|
-
|
|
4188
|
-
|
|
4189
|
-
|
|
4190
|
-
|
|
4191
|
-
|
|
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
|
-
|
|
4209
|
-
|
|
4210
|
-
|
|
4211
|
-
|
|
4212
|
-
|
|
4213
|
-
|
|
4214
|
-
|
|
4215
|
-
|
|
4216
|
-
|
|
4217
|
-
|
|
4218
|
-
|
|
4219
|
-
|
|
4220
|
-
|
|
4221
|
-
|
|
4222
|
-
|
|
4223
|
-
|
|
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
|
-
|
|
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
|
-
|
|
4247
|
-
|
|
4248
|
-
|
|
4249
|
-
|
|
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.
|
|
4252
|
-
|
|
4447
|
+
if (attributes.path !== undefined) {
|
|
4448
|
+
result += '; Path=' + attributes.path;
|
|
4253
4449
|
}
|
|
4254
|
-
|
|
4255
|
-
|
|
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
|
-
|
|
4282
|
-
|
|
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
|
-
|
|
4291
|
-
|
|
4292
|
-
|
|
4293
|
-
|
|
4294
|
-
|
|
4295
|
-
|
|
4296
|
-
|
|
4297
|
-
|
|
4298
|
-
|
|
4299
|
-
|
|
4300
|
-
|
|
4301
|
-
|
|
4302
|
-
|
|
4303
|
-
|
|
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
|
-
*
|
|
4348
|
-
*
|
|
4349
|
-
*
|
|
4350
|
-
*
|
|
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(() =>
|
|
4370
|
-
const ok = safeSync(() =>
|
|
4371
|
-
safeSync(() =>
|
|
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(() =>
|
|
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(() =>
|
|
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(() =>
|
|
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 ?? '
|
|
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
|
-
|
|
5301
|
-
|
|
5302
|
-
|
|
5303
|
-
|
|
5304
|
-
|
|
5305
|
-
|
|
5306
|
-
|
|
5307
|
-
|
|
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
|
-
|
|
5310
|
-
|
|
5311
|
-
|
|
5312
|
-
|
|
5313
|
-
|
|
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
|
-
|
|
5317
|
-
|
|
5318
|
-
|
|
5319
|
-
|
|
5320
|
-
|
|
5321
|
-
|
|
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
|
-
|
|
5324
|
-
|
|
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
|
-
|
|
5367
|
-
|
|
5368
|
-
|
|
5369
|
-
|
|
5370
|
-
|
|
5371
|
-
|
|
5372
|
-
|
|
5373
|
-
|
|
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
|
-
|
|
5376
|
-
|
|
5377
|
-
|
|
5378
|
-
|
|
5379
|
-
|
|
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
|
-
|
|
5400
|
-
|
|
5401
|
-
|
|
5402
|
-
|
|
5403
|
-
|
|
5404
|
-
|
|
5405
|
-
|
|
5406
|
-
|
|
5407
|
-
|
|
5408
|
-
|
|
5409
|
-
|
|
5410
|
-
|
|
5411
|
-
|
|
5412
|
-
|
|
5413
|
-
|
|
5414
|
-
|
|
5415
|
-
|
|
5416
|
-
|
|
5417
|
-
|
|
5418
|
-
|
|
5419
|
-
|
|
5420
|
-
|
|
5421
|
-
return
|
|
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
|
-
|
|
5424
|
-
|
|
5425
|
-
|
|
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: () =>
|
|
5433
|
-
refresh: async () =>
|
|
5434
|
-
getActivityId: () =>
|
|
5435
|
-
};
|
|
5436
|
-
|
|
5437
|
-
|
|
5438
|
-
|
|
5439
|
-
|
|
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
|
-
|
|
5445
|
-
|
|
5446
|
-
ingest:
|
|
5447
|
-
|
|
5448
|
-
|
|
5449
|
-
|
|
5450
|
-
|
|
5451
|
-
|
|
5452
|
-
|
|
5453
|
-
|
|
5454
|
-
|
|
5455
|
-
|
|
5456
|
-
|
|
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
|
-
|
|
5459
|
-
|
|
5460
|
-
|
|
5461
|
-
|
|
5462
|
-
|
|
5933
|
+
falconServiceManager: falconServiceManagerRef,
|
|
5934
|
+
activityManager: activityManagerRef,
|
|
5935
|
+
transportManager: transportManagerRef,
|
|
5936
|
+
tabManager: tabManagerRef,
|
|
5937
|
+
lifecycleManager,
|
|
5938
|
+
collectorManager,
|
|
5939
|
+
eventReportManager
|
|
5463
5940
|
};
|
|
5464
5941
|
}
|
|
5465
|
-
|
|
5466
|
-
|
|
5467
|
-
|
|
5468
|
-
|
|
5469
|
-
|
|
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
|
-
|
|
5973
|
+
var start$1 = /*#__PURE__*/Object.freeze({
|
|
5974
|
+
__proto__: null,
|
|
5975
|
+
start: start
|
|
5976
|
+
});
|
|
5475
5977
|
|
|
5476
|
-
return
|
|
5978
|
+
return TRI;
|
|
5477
5979
|
|
|
5478
5980
|
}));
|
|
5479
5981
|
//# sourceMappingURL=tri.umd.js.map
|