@maple-dev/browser 0.11.1 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -11,6 +11,15 @@ function mayUseStorage() {
11
11
  //#region ../browser-session/src/platform/url-privacy.ts
12
12
  const REDACTED = "REDACTED";
13
13
  const SENSITIVE_PARAM = /^(access_token|id_token|refresh_token|token|token_hash|auth|authorization|code|oobcode|state|password|passwd|pwd|pass|secret|client_secret|api_key|apikey|key|signature|sig|otp|jwt|session|sessionid|session_id|ticket|__clerk_ticket|reset_token|magic|nonce|x-amz-signature|x-amz-credential|x-amz-security-token|x-goog-signature|x-goog-credential)$/i;
14
+ /**
15
+ * Any name that, lowercased with `_`, `-` and `.` removed, ends in a credential
16
+ * word: `accessToken`, `auth_token`, `user-token`, `__clerk_db_jwt`, `x-api-key`.
17
+ * Suffix rather than substring, so `tokenizer_mode` or `secretary` stay readable.
18
+ */
19
+ const CREDENTIAL_SUFFIX = /(token|secret|password|passwd|apikey|jwt|signature|credential|credentials|privatekey)$/;
20
+ function isSensitiveName(name) {
21
+ return SENSITIVE_PARAM.test(name) || CREDENTIAL_SUFFIX.test(name.toLowerCase().replace(/[-_.]/g, ""));
22
+ }
14
23
  /** Sanitizers live on `globalThis`, like consent: one page, one policy, however many SDK copies. */
15
24
  const SANITIZERS_KEY = "__MAPLE_URL_SANITIZERS__";
16
25
  function sanitizers() {
@@ -27,45 +36,68 @@ function sanitizers() {
27
36
  */
28
37
  function addUrlSanitizer(sanitizer) {
29
38
  sanitizers().add(sanitizer);
39
+ return () => void sanitizers().delete(sanitizer);
30
40
  }
31
41
  function redactParams(params) {
32
42
  let changed = false;
33
43
  for (const name of new Set(params.keys())) {
34
- if (!SENSITIVE_PARAM.test(name)) continue;
44
+ if (!isSensitiveName(name)) continue;
35
45
  params.set(name, REDACTED);
36
46
  changed = true;
37
47
  }
38
48
  return changed;
39
49
  }
50
+ function redactQuery(query) {
51
+ const params = new URLSearchParams(query);
52
+ return redactParams(params) ? params.toString() : query;
53
+ }
40
54
  /**
41
55
  * Implicit-flow tokens ride the fragment as `#access_token=…&…`; a hash-routed
42
- * app carries them in the route's own query, `#/reset?token=…`. The route path
43
- * is kept either way.
56
+ * app carries them in the route's own query, `#/reset?token=…` or `#reset?token=…`.
57
+ * The route path is kept either way.
44
58
  */
45
59
  function redactFragment(fragment) {
46
- if (fragment.startsWith("/") || fragment.startsWith("!/")) {
47
- const queryAt = fragment.indexOf("?");
48
- if (queryAt === -1) return fragment;
49
- const params = new URLSearchParams(fragment.slice(queryAt + 1));
50
- return redactParams(params) ? `${fragment.slice(0, queryAt)}?${params.toString()}` : fragment;
51
- }
52
- if (!fragment.includes("=")) return fragment;
53
- const params = new URLSearchParams(fragment);
54
- return redactParams(params) ? params.toString() : fragment;
60
+ const queryAt = fragment.indexOf("?");
61
+ const head = queryAt === -1 ? fragment : fragment.slice(0, queryAt);
62
+ const redactedHead = head.startsWith("/") || head.startsWith("!/") || !head.includes("=") ? head : redactQuery(head);
63
+ return queryAt === -1 ? redactedHead : `${redactedHead}?${redactQuery(fragment.slice(queryAt + 1))}`;
55
64
  }
56
65
  const ABSOLUTE = /^[a-z][a-z0-9+.-]*:/i;
57
66
  const RELATIVE_BASE = "http://relative.invalid";
58
- /** Redact credential-shaped query and fragment parameters, keeping the URL's form. */
67
+ /** `user:password@` after a scheme's `//`, for URLs the parser rejects. */
68
+ const USERINFO = /^([a-z][a-z0-9+.-]*:)?\/\/[^/?#@]*@/i;
69
+ /** One `name=value` pair after a query, fragment or matrix separator. */
70
+ const PAIR = /([?&#;])([^=&#;?]*)=([^&#;?]*)/g;
71
+ function decodeName(raw) {
72
+ try {
73
+ return decodeURIComponent(raw.replace(/\+/g, " "));
74
+ } catch {
75
+ return raw;
76
+ }
77
+ }
78
+ /**
79
+ * Best-effort redaction for a URL `new URL` rejects (`https://exa mple.com/?token=1`):
80
+ * returning it raw would send exactly the credentials redaction exists to stop.
81
+ */
82
+ function redactUnparsable(url) {
83
+ return url.replace(USERINFO, (_, scheme) => `${scheme ?? ""}//`).replace(PAIR, (pair, separator, name) => isSensitiveName(decodeName(name)) ? `${separator}${name}=${REDACTED}` : pair);
84
+ }
85
+ /** Redact credentials in userinfo and in query and fragment parameters, keeping the URL's form. */
59
86
  function redactUrl(url) {
60
- if (!url || !url.includes("?") && !url.includes("#")) return url;
87
+ if (!url || !/[?#@;]/.test(url)) return url;
61
88
  const absolute = ABSOLUTE.test(url);
62
89
  let parsed;
63
90
  try {
64
91
  parsed = new URL(url, absolute ? void 0 : RELATIVE_BASE);
65
92
  } catch {
66
- return url;
93
+ return redactUnparsable(url);
67
94
  }
68
95
  let changed = redactParams(parsed.searchParams);
96
+ if (parsed.username || parsed.password) {
97
+ parsed.username = "";
98
+ parsed.password = "";
99
+ changed = true;
100
+ }
69
101
  const fragment = parsed.hash.slice(1);
70
102
  const redactedFragment = redactFragment(fragment);
71
103
  if (redactedFragment !== fragment) {
@@ -92,13 +124,42 @@ function scrubUrl(url) {
92
124
  return out;
93
125
  }
94
126
  //#endregion
127
+ //#region ../browser-session/src/platform/uuid.ts
128
+ /**
129
+ * A random v4 UUID. `crypto.randomUUID` exists only in secure contexts (HTTPS
130
+ * and localhost) and from Safari 15.4, so a plain-http intranet or staging site
131
+ * would otherwise throw from `init()`. `getRandomValues` has no such limit.
132
+ */
133
+ function randomUuid() {
134
+ if (typeof crypto.randomUUID === "function") return crypto.randomUUID();
135
+ const bytes = crypto.getRandomValues(/* @__PURE__ */ new Uint8Array(16));
136
+ bytes[6] = (bytes[6] ?? 0) & 15 | 64;
137
+ bytes[8] = (bytes[8] ?? 0) & 63 | 128;
138
+ const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
139
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
140
+ }
141
+ //#endregion
95
142
  //#region ../browser-session/src/platform/cookie.ts
96
- /** Scope cookies to the registered domain so subdomains share them. */
97
- let crossSubdomainCookie = true;
98
- /** Explicit `Domain=` override; `""` forces a host-only cookie. */
99
- let cookieDomainOverride;
100
- /** Memoized probe result. `undefined` = not resolved yet. */
101
- let probedCookieDomain;
143
+ /**
144
+ * On `globalThis`, like consent: two bundled SDK copies writing the same cookies
145
+ * under different `Domain=` scopes would each keep a copy the other cannot delete.
146
+ */
147
+ const SCOPE_KEY = "__MAPLE_BROWSER_COOKIE_SCOPE__";
148
+ function scope() {
149
+ const owner = globalThis;
150
+ const existing = owner[SCOPE_KEY];
151
+ if (isCookieScope(existing)) return existing;
152
+ const fresh = {
153
+ cookieDomainOverride: void 0,
154
+ crossSubdomainCookie: true,
155
+ probedCookieDomain: void 0
156
+ };
157
+ owner[SCOPE_KEY] = fresh;
158
+ return fresh;
159
+ }
160
+ function isCookieScope(value) {
161
+ return typeof value === "object" && value !== null && "crossSubdomainCookie" in value;
162
+ }
102
163
  /**
103
164
  * Apply the host app's cookie configuration. Called from `configurePrivacy`, so
104
165
  * both SDKs get it from the single call they already make.
@@ -114,9 +175,10 @@ let probedCookieDomain;
114
175
  * `app.example.com`, not the reverse), so the longer string wins.
115
176
  */
116
177
  function configureCookieScope(options) {
117
- if (options.crossSubdomainCookie === false) crossSubdomainCookie = false;
118
- if (options.cookieDomain !== void 0) cookieDomainOverride = tighterCookieDomain(cookieDomainOverride, options.cookieDomain);
119
- probedCookieDomain = void 0;
178
+ const state = scope();
179
+ if (options.crossSubdomainCookie === false) state.crossSubdomainCookie = false;
180
+ if (options.cookieDomain !== void 0) state.cookieDomainOverride = tighterCookieDomain(state.cookieDomainOverride, options.cookieDomain);
181
+ state.probedCookieDomain = void 0;
120
182
  }
121
183
  /** The narrower of two `Domain=` values, treating `undefined` as "unset". */
122
184
  function tighterCookieDomain(current, next) {
@@ -124,16 +186,29 @@ function tighterCookieDomain(current, next) {
124
186
  if (current === "" || next === "") return "";
125
187
  return next.length > current.length ? next : current;
126
188
  }
127
- function readRawCookie(name) {
128
- if (typeof document === "undefined") return void 0;
189
+ /** Every value stored under `name`: a host-only and a domain cookie can coexist. */
190
+ function readRawCookies(name) {
191
+ if (typeof document === "undefined") return [];
192
+ const values = [];
129
193
  try {
130
194
  for (const part of document.cookie.split(";")) {
131
195
  const raw = part.trim();
132
196
  if (!raw.startsWith(`${name}=`)) continue;
133
- return decodeURIComponent(raw.slice(name.length + 1));
197
+ try {
198
+ values.push(decodeURIComponent(raw.slice(name.length + 1)));
199
+ } catch {}
134
200
  }
135
201
  } catch {}
202
+ return values;
203
+ }
204
+ function readRawCookie(name) {
205
+ return readRawCookies(name)[0];
136
206
  }
207
+ /**
208
+ * Write a cookie, returning whether the browser actually kept it. Assigning to
209
+ * `document.cookie` succeeds silently when the cookie is refused (blocked,
210
+ * oversized, bad `Domain=`), so a stored cookie is confirmed by reading it back.
211
+ */
137
212
  function setRawCookie(name, value, domain, maxAgeSeconds) {
138
213
  if (typeof document === "undefined") return false;
139
214
  const attributes = [
@@ -146,10 +221,10 @@ function setRawCookie(name, value, domain, maxAgeSeconds) {
146
221
  if (typeof location !== "undefined" && location.protocol === "https:") attributes.push("Secure");
147
222
  try {
148
223
  document.cookie = attributes.join("; ");
149
- return true;
150
224
  } catch {
151
225
  return false;
152
226
  }
227
+ return maxAgeSeconds <= 0 || readRawCookies(name).includes(value);
153
228
  }
154
229
  /**
155
230
  * Expire `name` host-only and on every parent domain it could have been scoped
@@ -158,15 +233,19 @@ function setRawCookie(name, value, domain, maxAgeSeconds) {
158
233
  */
159
234
  function deleteRawCookie(name) {
160
235
  setRawCookie(name, "", "", 0);
161
- for (const domain of candidateDomains()) setRawCookie(name, "", domain, 0);
162
- if (cookieDomainOverride) setRawCookie(name, "", cookieDomainOverride, 0);
236
+ const hostname = typeof location === "undefined" ? "" : location.hostname;
237
+ for (const domain of candidateDomains(hostname)) setRawCookie(name, "", domain, 0);
238
+ const override = scope().cookieDomainOverride;
239
+ if (override) setRawCookie(name, "", override, 0);
163
240
  }
164
- /** Parent domains of this host, broadest first. Empty for single-label hosts and IPs. */
165
- function candidateDomains() {
166
- if (typeof location === "undefined") return [];
167
- const hostname = location.hostname;
241
+ /**
242
+ * Parent domains of `hostname`, broadest first. Empty for single-label hosts,
243
+ * IPs, and hosts with an empty label (`example.com.`), whose suffixes are not domains.
244
+ */
245
+ function candidateDomains(hostname) {
168
246
  if (!hostname || /^[\d.]+$/.test(hostname) || hostname.includes(":")) return [];
169
247
  const parts = hostname.split(".");
248
+ if (parts.includes("")) return [];
170
249
  const candidates = [];
171
250
  for (let i = parts.length - 2; i >= 0; i--) candidates.push(parts.slice(i).join("."));
172
251
  return candidates;
@@ -182,21 +261,21 @@ function candidateDomains() {
182
261
  * for bare IPs, neither of which can carry a `Domain=` attribute.
183
262
  */
184
263
  function probeCookieDomain() {
185
- if (typeof document === "undefined") return "";
186
- for (const candidate of candidateDomains()) {
187
- const probe = "__maple_probe";
188
- if (setRawCookie(probe, "1", candidate, 60) && readRawCookie(probe) === "1") {
189
- setRawCookie(probe, "", candidate, 0);
190
- return candidate;
191
- }
264
+ if (typeof document === "undefined" || typeof location === "undefined") return "";
265
+ const probe = "__maple_probe";
266
+ const value = randomUuid().slice(0, 8);
267
+ for (const candidate of candidateDomains(location.hostname)) if (setRawCookie(probe, value, candidate, 60)) {
268
+ setRawCookie(probe, "", candidate, 0);
269
+ return candidate;
192
270
  }
193
271
  return "";
194
272
  }
195
273
  function cookieDomain() {
196
- if (cookieDomainOverride !== void 0) return cookieDomainOverride;
197
- if (!crossSubdomainCookie) return "";
198
- if (probedCookieDomain === void 0) probedCookieDomain = probeCookieDomain();
199
- return probedCookieDomain;
274
+ const state = scope();
275
+ if (state.cookieDomainOverride !== void 0) return state.cookieDomainOverride;
276
+ if (!state.crossSubdomainCookie) return "";
277
+ if (state.probedCookieDomain === void 0) state.probedCookieDomain = probeCookieDomain();
278
+ return state.probedCookieDomain;
200
279
  }
201
280
  //#endregion
202
281
  //#region ../browser-session/src/platform/json.ts
@@ -242,6 +321,18 @@ function parseJsonObject(raw) {
242
321
  function isStringRecord(value) {
243
322
  return isJsonObject(value) && Object.values(value).every((entry) => typeof entry === "string");
244
323
  }
324
+ /** A `\\` pair, or the escape `JSON.stringify` writes for a lone surrogate. */
325
+ const LONE_SURROGATE_ESCAPE = /\\(?:\\|u[dD][89a-fA-F][0-9a-fA-F]{2})/g;
326
+ /**
327
+ * `JSON.stringify`, with every lone surrogate written as U+FFFD. JavaScript
328
+ * strings may hold half an emoji; strict JSON parsers (ingest's among them)
329
+ * reject the escape for one, and with it the whole NDJSON batch it rides in.
330
+ * Paired surrogates are written as raw characters, so any `\uD8xx` escape left
331
+ * in the output is a lone one.
332
+ */
333
+ function stringifyWellFormed(value) {
334
+ return (JSON.stringify(value) ?? "null").replace(LONE_SURROGATE_ESCAPE, (escape) => escape === "\\\\" ? escape : "\\ufffd");
335
+ }
245
336
  //#endregion
246
337
  //#region ../browser-session/src/session/idle.ts
247
338
  /**
@@ -268,53 +359,71 @@ const STORAGE_KEY$2 = "maple.visit";
268
359
  const COOKIE_NAME$1 = "maple_visit";
269
360
  /** In-memory copy: all a page load has when persistence is off or blocked. */
270
361
  let ephemeral$1;
271
- function parseMarker(raw) {
362
+ function parseVisitMarker(raw) {
272
363
  if (!raw) return void 0;
273
- const parsed = Number.parseInt(raw, 10);
274
- return Number.isFinite(parsed) ? parsed : void 0;
364
+ const match = /^(\d{1,16})(?:\.([0-9a-z]{1,32}))?$/.exec(raw);
365
+ if (!match?.[1]) return void 0;
366
+ return {
367
+ at: Number(match[1]),
368
+ token: match[2] ?? ""
369
+ };
275
370
  }
276
371
  function readMarker(persist) {
277
372
  if (!persist) return ephemeral$1;
278
- const fromCookie = parseMarker(readRawCookie(COOKIE_NAME$1));
373
+ const fromCookie = parseVisitMarker(readRawCookie(COOKIE_NAME$1));
279
374
  if (fromCookie !== void 0) return fromCookie;
280
375
  try {
281
- const fromStorage = parseMarker(window.localStorage.getItem(STORAGE_KEY$2));
376
+ const fromStorage = parseVisitMarker(window.localStorage.getItem(STORAGE_KEY$2));
282
377
  if (fromStorage !== void 0) return fromStorage;
283
378
  } catch {}
284
379
  return ephemeral$1;
285
380
  }
286
- function writeMarker(at, persist) {
287
- ephemeral$1 = at;
381
+ function writeMarker(marker, persist) {
382
+ ephemeral$1 = marker;
288
383
  if (!persist) return;
289
- const value = String(at);
384
+ const value = marker.token ? `${marker.at}.${marker.token}` : String(marker.at);
290
385
  try {
291
386
  window.localStorage.setItem(STORAGE_KEY$2, value);
292
387
  } catch {}
293
- setRawCookie(COOKIE_NAME$1, value, cookieDomain(), (IDLE_TIMEOUT_MS - (Date.now() - at)) / 1e3);
388
+ setRawCookie(COOKIE_NAME$1, value, cookieDomain(), (IDLE_TIMEOUT_MS - (Date.now() - marker.at)) / 1e3);
294
389
  }
295
390
  function withinVisit(marker, at) {
296
- return marker !== void 0 && Math.abs(at - marker) < 18e5;
391
+ return marker !== void 0 && Math.abs(at - marker.at) < 18e5;
297
392
  }
298
393
  /**
299
394
  * Claim the visit `at` belongs to, returning whether this caller is the one
300
395
  * charged for it. `persist` is false when visitor tracking is off, which keeps
301
396
  * the claim in memory: no identifier-adjacent cookie the host app opted out of.
397
+ *
398
+ * Restored tabs all start at once and can each read "no visit" before any of
399
+ * them writes one. Each writes its own token and reads the marker back, and
400
+ * only the tab whose token survived is charged: the last writer wins.
302
401
  */
303
402
  function claimVisit(at, persist) {
304
403
  if (typeof window === "undefined") return false;
305
404
  const marker = readMarker(persist);
306
405
  if (withinVisit(marker, at)) {
307
- if (at > marker) writeMarker(at, persist);
406
+ if (at > marker.at) writeMarker({
407
+ at,
408
+ token: marker.token
409
+ }, persist);
308
410
  return false;
309
411
  }
310
- writeMarker(at, persist);
311
- return true;
412
+ const token = randomUuid().slice(0, 8);
413
+ writeMarker({
414
+ at,
415
+ token
416
+ }, persist);
417
+ return readMarker(persist)?.token === token;
312
418
  }
313
419
  /** Extend the current visit to `at`. Never opens one: only `claimVisit` does. */
314
420
  function touchVisit(at, persist) {
315
421
  if (typeof window === "undefined") return;
316
422
  const marker = readMarker(persist);
317
- if (withinVisit(marker, at) && at > marker) writeMarker(at, persist);
423
+ if (withinVisit(marker, at) && at > marker.at) writeMarker({
424
+ at,
425
+ token: marker.token
426
+ }, persist);
318
427
  }
319
428
  /** Drop the claim from every store, for a visitor-tracking opt-out. */
320
429
  function clearVisitClaim() {
@@ -377,22 +486,42 @@ const COOKIE_NAME = "maple_visitor";
377
486
  * expiry onto ids already in the wild is not.
378
487
  */
379
488
  const MAX_AGE_MS = 3456e7;
380
- let enabled = true;
381
- /** Memoized so the hot path never re-reads storage. */
382
- let cached;
383
- let persisted = false;
384
- let mintedThisLoad = false;
489
+ /** Only ids this SDK could have minted; anything else in the cookie is someone else's. */
490
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
491
+ /**
492
+ * On `globalThis`, like consent: with two bundled SDK copies, per-copy state let
493
+ * one copy's opt-out purge the id while the other minted it straight back.
494
+ */
495
+ const VISITOR_KEY = "__MAPLE_BROWSER_VISITOR__";
496
+ function visitorState() {
497
+ const owner = globalThis;
498
+ const existing = owner[VISITOR_KEY];
499
+ if (isVisitorState(existing)) return existing;
500
+ const fresh = {
501
+ cached: void 0,
502
+ enabled: true,
503
+ mintedThisLoad: false,
504
+ optedOut: false,
505
+ persisted: false
506
+ };
507
+ owner[VISITOR_KEY] = fresh;
508
+ return fresh;
509
+ }
510
+ function isVisitorState(value) {
511
+ return typeof value === "object" && value !== null && "optedOut" in value;
512
+ }
385
513
  /**
386
514
  * Kept under this name because `configurePrivacy` and the SDK tests call it;
387
515
  * the domain decision lives in `platform/cookie.ts`, shared with the visit claim.
388
516
  */
389
517
  const configureVisitorCookie = configureCookieScope;
390
- function parseRecord(raw) {
518
+ function parseVisitorRecord(raw) {
391
519
  if (!raw) return void 0;
392
520
  const value = parseJsonObject(raw);
393
521
  if (!value) return void 0;
394
522
  const { id, mintedAt } = value;
395
- if (typeof id !== "string" || typeof mintedAt !== "number") return void 0;
523
+ if (typeof id !== "string" || !UUID.test(id)) return void 0;
524
+ if (typeof mintedAt !== "number" || !Number.isFinite(mintedAt)) return void 0;
396
525
  if (Date.now() - mintedAt > MAX_AGE_MS) return void 0;
397
526
  return {
398
527
  id,
@@ -401,13 +530,13 @@ function parseRecord(raw) {
401
530
  }
402
531
  function readFromStorage() {
403
532
  try {
404
- return parseRecord(window.localStorage.getItem(STORAGE_KEY$1) ?? void 0);
533
+ return parseVisitorRecord(window.localStorage.getItem(STORAGE_KEY$1));
405
534
  } catch {
406
535
  return;
407
536
  }
408
537
  }
409
538
  function readFromCookie() {
410
- return parseRecord(readRawCookie(COOKIE_NAME));
539
+ return parseVisitorRecord(readRawCookie(COOKIE_NAME));
411
540
  }
412
541
  function writeToStorage(record) {
413
542
  try {
@@ -430,31 +559,32 @@ function writeToCookie(record) {
430
559
  function write(record) {
431
560
  const inStorage = writeToStorage(record);
432
561
  const inCookie = writeToCookie(record);
433
- persisted = inStorage || inCookie;
562
+ visitorState().persisted = inStorage || inCookie;
434
563
  }
435
564
  /**
436
565
  * The current visitor id, minting one on first use. `undefined` when visitor
437
566
  * tracking is off or outside a browser.
438
567
  */
439
568
  function getVisitorId() {
440
- if (!enabled || typeof window === "undefined") return void 0;
441
- if (cached) return cached.id;
569
+ const state = visitorState();
570
+ if (!state.enabled || state.optedOut || typeof window === "undefined") return void 0;
571
+ if (state.cached) return state.cached.id;
442
572
  const fromCookie = readFromCookie();
443
573
  const fromStorage = readFromStorage();
444
574
  const existing = fromCookie ?? fromStorage;
445
575
  if (existing) {
446
- cached = existing;
447
- persisted = true;
576
+ state.cached = existing;
577
+ state.persisted = true;
448
578
  if (!fromCookie) writeToCookie(existing);
449
579
  if (!fromStorage || fromStorage.id !== existing.id) writeToStorage(existing);
450
580
  return existing.id;
451
581
  }
452
582
  const record = {
453
- id: crypto.randomUUID(),
583
+ id: randomUuid(),
454
584
  mintedAt: Date.now()
455
585
  };
456
- cached = record;
457
- mintedThisLoad = true;
586
+ state.cached = record;
587
+ state.mintedThisLoad = true;
458
588
  write(record);
459
589
  return record.id;
460
590
  }
@@ -471,8 +601,9 @@ function getVisitorId() {
471
601
  */
472
602
  function claimNewVisitor() {
473
603
  getVisitorId();
474
- if (!mintedThisLoad) return false;
475
- mintedThisLoad = false;
604
+ const state = visitorState();
605
+ if (!state.mintedThisLoad) return false;
606
+ state.mintedThisLoad = false;
476
607
  return true;
477
608
  }
478
609
  /**
@@ -482,18 +613,31 @@ function claimNewVisitor() {
482
613
  * layer can flag it rather than quietly inflate.
483
614
  */
484
615
  function isVisitorIdPersisted() {
485
- return persisted;
616
+ return visitorState().persisted;
486
617
  }
487
618
  /**
488
619
  * Turn persistent visitor tracking on or off. Turning it off also purges any
489
620
  * id already stored — an opt-out that leaves the identifier behind is not one.
490
621
  */
491
622
  function setVisitorTracking(nextEnabled) {
492
- enabled = nextEnabled;
493
- if (nextEnabled) return;
494
- cached = void 0;
495
- persisted = false;
496
- mintedThisLoad = false;
623
+ visitorState().enabled = nextEnabled;
624
+ if (!nextEnabled) purgeVisitorId();
625
+ }
626
+ /**
627
+ * `persistVisitorId: false` from any SDK on the page. Unlike `setVisitorTracking`,
628
+ * which follows consent both ways, this never turns back on for the page load.
629
+ */
630
+ function optOutOfVisitorId() {
631
+ visitorState().optedOut = true;
632
+ purgeVisitorId();
633
+ }
634
+ /** Drop the id and the visit claim from memory and every store. */
635
+ function purgeVisitorId() {
636
+ const state = visitorState();
637
+ state.cached = void 0;
638
+ state.persisted = false;
639
+ state.mintedThisLoad = false;
640
+ if (typeof window === "undefined") return;
497
641
  try {
498
642
  window.localStorage.removeItem(STORAGE_KEY$1);
499
643
  } catch {}
@@ -533,10 +677,14 @@ function consentState() {
533
677
  const existing = owner[CONSENT_KEY];
534
678
  if (existing) {
535
679
  existing.revokedAt ||= 0;
680
+ existing.configured ||= false;
681
+ existing.deniedBeforeGate ||= false;
536
682
  return existing;
537
683
  }
538
684
  const fresh = {
539
685
  allowedSince: 0,
686
+ configured: false,
687
+ deniedBeforeGate: false,
540
688
  granted: false,
541
689
  listeners: /* @__PURE__ */ new Set(),
542
690
  requireConsent: false,
@@ -565,6 +713,34 @@ function consentRevokedAt() {
565
713
  return inMemory;
566
714
  }
567
715
  }
716
+ /**
717
+ * Withdraw consent for good: data kept from before now is never sent, and the
718
+ * persistent visitor id and visit claim are deleted from every store.
719
+ */
720
+ function revoke() {
721
+ writeRevokedAt(Date.now());
722
+ purgeVisitorId();
723
+ }
724
+ /**
725
+ * How long a denial on a page that never granted waits before it purges.
726
+ * Consent tools commonly set a default "denied" and then replay the stored
727
+ * "granted" once loaded; purging at once would mint a new visitor every page.
728
+ */
729
+ const DENIAL_SETTLE_MS = 1e4;
730
+ let pendingRevoke;
731
+ function cancelPendingRevoke() {
732
+ if (pendingRevoke !== void 0) clearTimeout(pendingRevoke);
733
+ pendingRevoke = void 0;
734
+ }
735
+ /** A denial with no grant before it on this page: a withdrawal unless a grant follows soon. */
736
+ function revokeUnlessGranted() {
737
+ if (pendingRevoke !== void 0) return;
738
+ pendingRevoke = setTimeout(() => {
739
+ pendingRevoke = void 0;
740
+ if (!consentState().granted) revoke();
741
+ }, DENIAL_SETTLE_MS);
742
+ }
743
+ let warnedAboutMissingGate = false;
568
744
  function updateEffectiveConsent(previous) {
569
745
  const state = consentState();
570
746
  const allowed = hasConsent();
@@ -584,9 +760,11 @@ function updateEffectiveConsent(previous) {
584
760
  * denied→allowed transition would start capture on a user who never consented.
585
761
  * To genuinely relax a gate, reload without it.
586
762
  */
763
+ /** Returns the remover for its `sanitizeUrl`, so a shutdown leaves no closure behind. */
587
764
  function configurePrivacy(options) {
588
765
  const state = consentState();
589
766
  const previous = hasConsent();
767
+ state.configured = true;
590
768
  state.requireConsent = state.requireConsent || (options?.requireConsent ?? false);
591
769
  state.respectDoNotTrack = state.respectDoNotTrack || (options?.respectDoNotTrack ?? false);
592
770
  configurePersistence(options?.persistence);
@@ -594,15 +772,39 @@ function configurePrivacy(options) {
594
772
  crossSubdomainCookie: options?.crossSubdomainCookie,
595
773
  cookieDomain: options?.cookieDomain
596
774
  });
597
- if (options?.sanitizeUrl) addUrlSanitizer(options.sanitizeUrl);
775
+ if (options?.persistVisitorId === false) optOutOfVisitorId();
776
+ const removeSanitizer = options?.sanitizeUrl ? addUrlSanitizer(options.sanitizeUrl) : () => {};
777
+ if (state.requireConsent && state.deniedBeforeGate && !state.granted) revokeUnlessGranted();
778
+ state.deniedBeforeGate = false;
598
779
  updateEffectiveConsent(previous);
780
+ return removeSanitizer;
599
781
  }
600
- /** Record the user's consent decision. No-op unless `requireConsent` is set. */
782
+ /**
783
+ * Record the user's consent decision. No-op unless `requireConsent` is set.
784
+ *
785
+ * Under the gate, every `false` is a withdrawal, including one replayed on a
786
+ * fresh page load: the visitor id and data queued under an earlier grant must
787
+ * not survive it. That one purges after a short settle, so a default "denied"
788
+ * the consent tool replaces with "granted" does not reset the visitor.
789
+ */
601
790
  function setConsent(nextGranted) {
602
791
  const previous = hasConsent();
603
792
  const state = consentState();
604
- if (state.granted && !nextGranted) writeRevokedAt(Date.now());
793
+ const wasGranted = state.granted;
605
794
  state.granted = nextGranted;
795
+ if (nextGranted) {
796
+ state.deniedBeforeGate = false;
797
+ cancelPendingRevoke();
798
+ } else if (state.requireConsent) {
799
+ if (wasGranted) {
800
+ cancelPendingRevoke();
801
+ revoke();
802
+ } else revokeUnlessGranted();
803
+ } else if (!state.configured) state.deniedBeforeGate = true;
804
+ else if (!warnedAboutMissingGate) {
805
+ warnedAboutMissingGate = true;
806
+ console.warn("[maple] setConsent(false) has no effect without privacy.requireConsent; capture continues.");
807
+ }
606
808
  updateEffectiveConsent(previous);
607
809
  }
608
810
  /** Whether capture may proceed at all. */
@@ -653,27 +855,41 @@ function activeTraceId() {
653
855
  const id = traceIdProvider();
654
856
  return id && id !== ZERO_TRACE_ID ? id : void 0;
655
857
  }
656
- const slots = [];
858
+ const slots$1 = [];
657
859
  /** Report a span that just started. Fills the innermost open slot, first span wins. */
658
860
  function noteStartedTraceId(traceId) {
659
- const slot = slots.at(-1);
861
+ const slot = slots$1.at(-1);
660
862
  if (slot && slot.traceId === void 0 && traceId !== ZERO_TRACE_ID) slot.traceId = traceId;
661
863
  }
662
864
  /** Run `fn`, returning its result and the trace id of the first span started during it. */
663
865
  function withStartedTraceId(fn) {
664
866
  const slot = { traceId: void 0 };
665
- slots.push(slot);
867
+ slots$1.push(slot);
666
868
  try {
667
869
  return {
668
870
  result: fn(),
669
871
  traceId: slot.traceId
670
872
  };
671
873
  } finally {
672
- const index = slots.lastIndexOf(slot);
673
- if (index !== -1) slots.splice(index, 1);
874
+ const index = slots$1.lastIndexOf(slot);
875
+ if (index !== -1) slots$1.splice(index, 1);
674
876
  }
675
877
  }
676
878
  //#endregion
879
+ //#region ../browser-session/src/platform/text.ts
880
+ /**
881
+ * Cut `text` to at most `max` UTF-16 units without splitting a surrogate pair,
882
+ * appending `suffix` when anything was cut. Half an emoji is a lone surrogate,
883
+ * which strict JSON parsers reject along with the batch carrying it.
884
+ */
885
+ function truncateText(text, max, suffix = "") {
886
+ if (text.length <= max) return text;
887
+ let end = Math.max(0, max);
888
+ const last = text.charCodeAt(end - 1);
889
+ if (end > 0 && last >= 55296 && last <= 56319) end--;
890
+ return `${text.slice(0, end)}${suffix}`;
891
+ }
892
+ //#endregion
677
893
  //#region ../browser-session/src/capture/shared.ts
678
894
  /** Emit best-effort: capture must never throw into the host app's call site. */
679
895
  function safeEmit(emit, ev) {
@@ -684,24 +900,25 @@ function safeEmit(emit, ev) {
684
900
  //#endregion
685
901
  //#region ../browser-session/src/capture/errors.ts
686
902
  const MAX_STACK = 4e3;
903
+ const MAX_MESSAGE = 1e3;
687
904
  /** Capture uncaught errors + unhandled promise rejections as session events. */
688
905
  function installErrorCapture(emit) {
689
906
  const onError = (event) => {
690
907
  safeEmit(emit, {
691
908
  type: "error",
692
909
  level: "error",
693
- message: event.message || String(event.error ?? "Error"),
694
- errorStack: truncate(event.error?.stack),
910
+ message: truncate(event.message || String(event.error ?? "Error"), MAX_MESSAGE),
911
+ errorStack: truncate(describeRejection(event.error).stack, MAX_STACK),
695
912
  traceId: activeTraceId()
696
913
  });
697
914
  };
698
915
  const onRejection = (event) => {
699
- const reason = event.reason;
916
+ const { message, stack } = describeRejection(event.reason);
700
917
  safeEmit(emit, {
701
918
  type: "error",
702
919
  level: "error",
703
- message: typeof reason === "string" ? reason : reason?.message ?? "Unhandled promise rejection",
704
- errorStack: truncate(typeof reason === "object" ? reason?.stack : void 0),
920
+ message: truncate(message, MAX_MESSAGE),
921
+ errorStack: truncate(stack, MAX_STACK),
705
922
  traceId: activeTraceId()
706
923
  });
707
924
  };
@@ -712,9 +929,25 @@ function installErrorCapture(emit) {
712
929
  window.removeEventListener("unhandledrejection", onRejection);
713
930
  };
714
931
  }
715
- function truncate(stack) {
716
- if (!stack) return void 0;
717
- return stack.length > MAX_STACK ? `${stack.slice(0, MAX_STACK)}…` : stack;
932
+ /** An error or rejection reason is anything a `throw` or `reject()` was given. */
933
+ function describeRejection(reason) {
934
+ if (typeof reason === "string") return {
935
+ message: reason,
936
+ stack: void 0
937
+ };
938
+ if (typeof reason !== "object" || reason === null) return {
939
+ message: "Unhandled promise rejection",
940
+ stack: void 0
941
+ };
942
+ return {
943
+ message: ("message" in reason && typeof reason.message === "string" ? reason.message : void 0) ?? "Unhandled promise rejection",
944
+ stack: "stack" in reason && typeof reason.stack === "string" ? reason.stack : void 0
945
+ };
946
+ }
947
+ /** Cut on a code-point boundary: half an emoji is a lone surrogate, which ingest rejects with its batch. */
948
+ function truncate(text, max) {
949
+ if (!text) return void 0;
950
+ return truncateText(text, max, "…");
718
951
  }
719
952
  const BLOCK_SELECTOR = `[data-rr-block]`;
720
953
  /**
@@ -734,12 +967,28 @@ const isBlocked = (el) => {
734
967
  //#endregion
735
968
  //#region ../browser-session/src/capture/interactions.ts
736
969
  const MAX_TEXT = 120;
970
+ /** Bounds the walk on a click that lands on `<body>` or a huge container. */
971
+ const MAX_NODES = 500;
972
+ /** rrweb's default mask class: replay shows its text as `*`, so neither may this. */
973
+ const MASK_CLASS = "rr-mask";
974
+ /** Text that is code, markup or user input rather than a visible label. */
975
+ const SKIPPED_TAGS = /* @__PURE__ */ new Set([
976
+ "SCRIPT",
977
+ "STYLE",
978
+ "NOSCRIPT",
979
+ "TEMPLATE",
980
+ "INPUT",
981
+ "TEXTAREA",
982
+ "SELECT",
983
+ "OPTION"
984
+ ]);
737
985
  /**
738
986
  * Capture clicks and input events as session events. Listens in the capture
739
987
  * phase so it sees interactions even when the host app calls
740
988
  * `stopPropagation()`. Input *values* are never recorded; only the target
741
989
  * element. Click target text is omitted when `maskAllText` is set, and when the
742
- * target sits inside a blocked (`.rr-block` / `data-rr-block`) subtree.
990
+ * target sits inside a blocked (`.rr-block` / `data-rr-block`) or masked
991
+ * (`.rr-mask`) subtree; blocked descendants of the target are skipped.
743
992
  */
744
993
  function installInteractionCapture(emit, maskAllText) {
745
994
  const onClick = (event) => {
@@ -748,7 +997,7 @@ function installInteractionCapture(emit, maskAllText) {
748
997
  safeEmit(emit, {
749
998
  type: "click",
750
999
  targetSelector: selectorOf(target),
751
- targetText: maskAllText || isBlocked(target) ? void 0 : textOf(target)
1000
+ targetText: maskAllText || isBlocked(target) || isMasked(target) ? void 0 : textOf(target)
752
1001
  });
753
1002
  };
754
1003
  const onInput = (event) => {
@@ -770,10 +1019,41 @@ function installInteractionCapture(emit, maskAllText) {
770
1019
  function selectorOf(el) {
771
1020
  return `${el.tagName.toLowerCase()}${el.id ? `#${el.id}` : ""}${typeof el.className === "string" && el.className.trim() ? `.${el.className.trim().split(/\s+/)[0]}` : ""}`;
772
1021
  }
1022
+ /** An element whose text must not be read: blocked, masked, code, or editable. */
1023
+ function isHiddenText(el) {
1024
+ return SKIPPED_TAGS.has(el.tagName.toUpperCase()) || el.classList.contains("rr-block") || el.classList.contains(MASK_CLASS) || el.hasAttribute("data-rr-block") || el instanceof HTMLElement && el.isContentEditable;
1025
+ }
1026
+ function isMasked(el) {
1027
+ for (let node = el; node !== null; node = node.parentElement) if (node.classList.contains(MASK_CLASS)) return true;
1028
+ return false;
1029
+ }
1030
+ /**
1031
+ * The target's visible label: its text nodes in document order, skipping hidden
1032
+ * subtrees and stopping once the cap is reached. `textContent` would read the
1033
+ * whole subtree, blocked parts and all, on every click.
1034
+ */
773
1035
  function textOf(el) {
774
- const text = (el.textContent ?? "").trim().replace(/\s+/g, " ");
1036
+ if (isHiddenText(el)) return void 0;
1037
+ let text = "";
1038
+ let visited = 0;
1039
+ const stack = [el];
1040
+ while (stack.length > 0 && visited < MAX_NODES && text.length <= MAX_TEXT) {
1041
+ const node = stack.pop();
1042
+ visited++;
1043
+ if (node instanceof Text) {
1044
+ text = `${text}${node.data}`.replace(/\s+/g, " ").trimStart();
1045
+ continue;
1046
+ }
1047
+ if (node !== el && (!(node instanceof Element) || isHiddenText(node))) continue;
1048
+ const children = node?.childNodes ?? [];
1049
+ for (let i = children.length - 1; i >= 0; i--) {
1050
+ const child = children[i];
1051
+ if (child) stack.push(child);
1052
+ }
1053
+ }
1054
+ text = text.trim();
775
1055
  if (!text) return void 0;
776
- return text.length > MAX_TEXT ? `${text.slice(0, MAX_TEXT)}…` : text;
1056
+ return truncateText(text, MAX_TEXT, "…");
777
1057
  }
778
1058
  //#endregion
779
1059
  //#region ../browser-session/src/capture/baseline.ts
@@ -899,6 +1179,10 @@ function reserveKeepalive(requested, bytes, otlp = false) {
899
1179
  if (otlp) inflight.otlpBytes = Math.max(0, (inflight.otlpBytes ?? 0) - bytes);
900
1180
  };
901
1181
  }
1182
+ /** Keepalive bytes a session write could reserve right now. */
1183
+ function keepaliveRoom() {
1184
+ return Math.max(0, KEEPALIVE_BUDGET_BYTES - keepaliveInflight().bytes);
1185
+ }
902
1186
  /** Body size in bytes: the keepalive limit counts bytes, and `string.length` counts UTF-16 units. */
903
1187
  function byteLength(body) {
904
1188
  return typeof body === "string" ? new TextEncoder().encode(body).byteLength : body.byteLength;
@@ -946,6 +1230,7 @@ const GZIP_MIN_BYTES = 18;
946
1230
  * crawlers, which tear the page down on nearly every session.
947
1231
  */
948
1232
  async function gzip(bytes) {
1233
+ if (typeof CompressionStream !== "function") return gzipStored(bytes);
949
1234
  const stream = new CompressionStream("gzip");
950
1235
  const writer = stream.writable.getWriter();
951
1236
  const written = writer.write(bytes).then(() => writer.close());
@@ -954,39 +1239,158 @@ async function gzip(bytes) {
954
1239
  if (out.length < GZIP_MIN_BYTES || out[0] !== 31 || out[1] !== 139 || out[2] !== 8) throw new Error(`gzip produced ${out.length} bytes, not a complete gzip stream`);
955
1240
  return out;
956
1241
  }
1242
+ let crcTable;
1243
+ function crc32(bytes) {
1244
+ if (!crcTable) {
1245
+ crcTable = /* @__PURE__ */ new Uint32Array(256);
1246
+ for (let n = 0; n < 256; n++) {
1247
+ let c = n;
1248
+ for (let k = 0; k < 8; k++) c = c & 1 ? 3988292384 ^ c >>> 1 : c >>> 1;
1249
+ crcTable[n] = c >>> 0;
1250
+ }
1251
+ }
1252
+ let crc = 4294967295;
1253
+ for (const byte of bytes) crc = (crcTable[(crc ^ byte) & 255] ?? 0) ^ crc >>> 8;
1254
+ return (crc ^ 4294967295) >>> 0;
1255
+ }
1256
+ /** Largest payload of one stored (uncompressed) deflate block. */
1257
+ const STORED_BLOCK_BYTES = 65535;
1258
+ /**
1259
+ * gzip with stored deflate blocks: valid gzip any inflater reads, built
1260
+ * synchronously and not compressed. For a page going away, where
1261
+ * `CompressionStream` never resolves: a request has to be issued before the
1262
+ * document is torn down, and nothing after the first await runs by then.
1263
+ */
1264
+ function gzipStored(bytes) {
1265
+ const blocks = Math.max(1, Math.ceil(bytes.length / STORED_BLOCK_BYTES));
1266
+ const out = new Uint8Array(10 + blocks * 5 + bytes.length + 8);
1267
+ const view = new DataView(out.buffer);
1268
+ out.set([
1269
+ 31,
1270
+ 139,
1271
+ 8,
1272
+ 0,
1273
+ 0,
1274
+ 0,
1275
+ 0,
1276
+ 0,
1277
+ 0,
1278
+ 255
1279
+ ]);
1280
+ let at = 10;
1281
+ for (let block = 0; block < blocks; block++) {
1282
+ const start = block * STORED_BLOCK_BYTES;
1283
+ const length = Math.min(STORED_BLOCK_BYTES, bytes.length - start);
1284
+ out[at] = block === blocks - 1 ? 1 : 0;
1285
+ view.setUint16(at + 1, length, true);
1286
+ view.setUint16(at + 3, ~length & 65535, true);
1287
+ out.set(bytes.subarray(start, start + length), at + 5);
1288
+ at += 5 + length;
1289
+ }
1290
+ view.setUint32(at, crc32(bytes), true);
1291
+ view.setUint32(at + 4, bytes.length >>> 0, true);
1292
+ return out;
1293
+ }
1294
+ function outcomeOf(status) {
1295
+ if (status >= 200 && status < 300) return "accepted";
1296
+ if (status === 429 || status >= 500) return "retry";
1297
+ if (status === 401 || status === 402 || status === 403) return "refused";
1298
+ return "rejected";
1299
+ }
1300
+ /**
1301
+ * Ingest URL and key pairs that were refused. Every later request with the
1302
+ * same key would be refused the same way, so none is sent for the rest of the
1303
+ * page; a re-init with another key starts clean.
1304
+ */
1305
+ const refusedUrls = /* @__PURE__ */ new Set();
1306
+ /** The URL plus an FNV-1a hash of the key, so the key itself is not kept. */
1307
+ function channelOf(config, url) {
1308
+ let hash = 2166136261;
1309
+ for (const char of config.ingestKey ?? "") hash = Math.imul(hash ^ char.charCodeAt(0), 16777619);
1310
+ return `${url}|${hash >>> 0}`;
1311
+ }
1312
+ const isRefused = (config, url) => refusedUrls.has(channelOf(config, url));
1313
+ function refuse(config, url, status) {
1314
+ const channel = channelOf(config, url);
1315
+ if (refusedUrls.has(channel)) return;
1316
+ refusedUrls.add(channel);
1317
+ console.warn(`[maple] ingest refused ${url} (HTTP ${status}); nothing more is sent there from this page`);
1318
+ }
1319
+ /** POST one body, mapping the answer to a {@link PostOutcome}. Never throws. */
1320
+ async function postNdjson(config, url, body, keepalive, what) {
1321
+ try {
1322
+ const response = await postToIngest(url, {
1323
+ ...ingestHeaders(config),
1324
+ "content-type": "application/x-ndjson"
1325
+ }, body, keepalive);
1326
+ const outcome = outcomeOf(response.status);
1327
+ if (outcome === "refused") refuse(config, url, response.status);
1328
+ return outcome;
1329
+ } catch (error) {
1330
+ warnDropped(what, error);
1331
+ return "failed";
1332
+ }
1333
+ }
957
1334
  /** POST session metadata (NDJSON, single row). `keepalive` for the final unload write. */
958
1335
  async function postSessionMeta(config, row, keepalive = false) {
959
- const body = `${JSON.stringify(row)}\n`;
960
- await postToIngest(`${config.endpoint}/v1/sessionReplays/meta`, {
961
- ...ingestHeaders(config),
962
- "content-type": "application/x-ndjson"
963
- }, body, keepalive).catch((error) => {
964
- warnDropped("metadata POST", error);
965
- });
1336
+ const url = `${config.endpoint}/v1/sessionReplays/meta`;
1337
+ if (isRefused(config, url)) return;
1338
+ await postNdjson(config, url, `${stringifyWellFormed(row)}\n`, keepalive, "metadata POST");
1339
+ }
1340
+ /** Worst first: what a caller acts on when a batch went out in parts. */
1341
+ const OUTCOME_RANK = {
1342
+ refused: 0,
1343
+ failed: 1,
1344
+ retry: 2,
1345
+ rejected: 3,
1346
+ accepted: 4
1347
+ };
1348
+ /**
1349
+ * Split NDJSON lines for a page going away: the oldest that fit the keepalive
1350
+ * room left, measured on the serialized bytes, and the rest as a plain request
1351
+ * that only survives if the page does. One body over the room lost all of it.
1352
+ */
1353
+ function keepaliveBodies(lines) {
1354
+ const encoder = new TextEncoder();
1355
+ let room = keepaliveRoom();
1356
+ let fits = 0;
1357
+ for (const line of lines) {
1358
+ const size = encoder.encode(line).byteLength;
1359
+ if (size > room) break;
1360
+ room -= size;
1361
+ fits++;
1362
+ }
1363
+ return [{
1364
+ body: lines.slice(0, fits).join(""),
1365
+ keepalive: true
1366
+ }, {
1367
+ body: lines.slice(fits).join(""),
1368
+ keepalive: false
1369
+ }].filter(({ body }) => body.length > 0);
966
1370
  }
967
1371
  /**
968
1372
  * POST distilled session events (NDJSON, one row per event). Never throws.
969
- * `"failed"` means no response came back, so the rows may or may not have
970
- * reached ingest.
1373
+ * A keepalive batch larger than the room left goes out in two parts, and the
1374
+ * worst of their outcomes is returned.
971
1375
  */
972
1376
  async function postSessionEvents(config, rows, keepalive = false) {
973
1377
  if (rows.length === 0) return "accepted";
974
- const body = `${rows.map((row) => JSON.stringify(row)).join("\n")}\n`;
975
- try {
976
- return (await postToIngest(`${config.endpoint}/v1/sessionEvents`, {
977
- ...ingestHeaders(config),
978
- "content-type": "application/x-ndjson"
979
- }, body, keepalive)).ok ? "accepted" : "rejected";
980
- } catch (error) {
981
- warnDropped("events POST", error);
982
- return "failed";
983
- }
1378
+ const url = `${config.endpoint}/v1/sessionEvents`;
1379
+ if (isRefused(config, url)) return "refused";
1380
+ const lines = rows.map((row) => `${stringifyWellFormed(row)}\n`);
1381
+ const bodies = keepalive ? keepaliveBodies(lines) : [{
1382
+ body: lines.join(""),
1383
+ keepalive: false
1384
+ }];
1385
+ return (await Promise.all(bodies.map((part) => postNdjson(config, url, part.body, part.keepalive, "events POST")))).reduce((worst, outcome) => OUTCOME_RANK[outcome] < OUTCOME_RANK[worst] ? outcome : worst);
984
1386
  }
985
1387
  const SESSION_EXHAUSTED_STATUS = 413;
986
1388
  /** POST a gzipped rrweb event chunk. */
987
1389
  async function postSessionBlob(config, meta, gzipped, keepalive = false) {
1390
+ const url = `${config.endpoint}/v1/sessionReplays/blob`;
1391
+ if (isRefused(config, url)) return "refused";
988
1392
  try {
989
- const response = await postToIngest(`${config.endpoint}/v1/sessionReplays/blob`, {
1393
+ const response = await postToIngest(url, {
990
1394
  ...ingestHeaders(config),
991
1395
  "content-type": "application/octet-stream",
992
1396
  "x-maple-session-id": meta.sessionId,
@@ -995,8 +1399,10 @@ async function postSessionBlob(config, meta, gzipped, keepalive = false) {
995
1399
  "x-maple-event-count": String(meta.eventCount),
996
1400
  "x-maple-duration-ms": String(meta.durationMs)
997
1401
  }, gzipped, keepalive);
998
- if (response.ok) return "accepted";
999
- return response.status === SESSION_EXHAUSTED_STATUS ? "exhausted" : "rejected";
1402
+ if (response.status === SESSION_EXHAUSTED_STATUS) return "exhausted";
1403
+ const outcome = outcomeOf(response.status);
1404
+ if (outcome === "refused") refuse(config, url, response.status);
1405
+ return outcome;
1000
1406
  } catch (error) {
1001
1407
  warnDropped("blob PUT", error);
1002
1408
  return "failed";
@@ -1016,10 +1422,12 @@ function parseUserAgent(ua) {
1016
1422
  return parsed;
1017
1423
  }
1018
1424
  function parse(ua) {
1425
+ const browserName = /edg/i.test(ua) ? "Edge" : /opr|opera/i.test(ua) ? "Opera" : /samsungbrowser/i.test(ua) ? "Samsung Internet" : /chrome|crios/i.test(ua) ? "Chrome" : /firefox|fxios/i.test(ua) ? "Firefox" : /safari/i.test(ua) ? "Safari" : "Unknown";
1426
+ const osName = /windows/i.test(ua) ? "Windows" : /iphone|ipad|ipod|\bios\b/i.test(ua) ? "iOS" : /mac os|macintosh/i.test(ua) ? "macOS" : /android/i.test(ua) ? "Android" : /\bcros\b/i.test(ua) ? "ChromeOS" : /linux/i.test(ua) ? "Linux" : "Unknown";
1019
1427
  return {
1020
- browserName: /edg/i.test(ua) ? "Edge" : /opr|opera/i.test(ua) ? "Opera" : /chrome|crios/i.test(ua) ? "Chrome" : /firefox|fxios/i.test(ua) ? "Firefox" : /safari/i.test(ua) ? "Safari" : "Unknown",
1021
- osName: /windows/i.test(ua) ? "Windows" : /iphone|ipad|ios/i.test(ua) ? "iOS" : /mac os|macintosh/i.test(ua) ? "macOS" : /android/i.test(ua) ? "Android" : /linux/i.test(ua) ? "Linux" : "Unknown",
1022
- deviceType: /mobile|iphone/i.test(ua) ? "mobile" : /ipad|tablet/i.test(ua) ? "tablet" : "desktop"
1428
+ browserName,
1429
+ osName,
1430
+ deviceType: /ipad|tablet/i.test(ua) ? "tablet" : /mobile|iphone|ipod/i.test(ua) ? "mobile" : osName === "Android" ? "tablet" : "desktop"
1023
1431
  };
1024
1432
  }
1025
1433
  /**
@@ -1069,7 +1477,7 @@ function isLikelyBot(ua) {
1069
1477
  /** ClickHouse-style `YYYY-MM-DD HH:MM:SS.mmm` in UTC (matches the ingest gateway). */
1070
1478
  function formatCHDateTime(date) {
1071
1479
  const pad = (n, width = 2) => String(n).padStart(width, "0");
1072
- return `${date.getUTCFullYear()}-${pad(date.getUTCMonth() + 1)}-${pad(date.getUTCDate())} ${pad(date.getUTCHours())}:${pad(date.getUTCMinutes())}:${pad(date.getUTCSeconds())}.` + pad(date.getUTCMilliseconds(), 3);
1480
+ return `${pad(date.getUTCFullYear(), 4)}-${pad(date.getUTCMonth() + 1)}-${pad(date.getUTCDate())} ${pad(date.getUTCHours())}:${pad(date.getUTCMinutes())}:${pad(date.getUTCSeconds())}.` + pad(date.getUTCMilliseconds(), 3);
1073
1481
  }
1074
1482
  /**
1075
1483
  * Build one `/v1/sessionReplays/meta` NDJSON row. Shared by `@maple-dev/browser`
@@ -1156,7 +1564,7 @@ function pathOf(url) {
1156
1564
  }
1157
1565
  /** POST one session metadata row (NDJSON). Best-effort — never throws. */
1158
1566
  async function postSessionMetaRow(target, row, keepalive = false) {
1159
- const body = `${JSON.stringify(row)}\n`;
1567
+ const body = `${stringifyWellFormed(row)}\n`;
1160
1568
  await postToIngest(`${target.endpoint.replace(/\/$/, "")}/v1/sessionReplays/meta`, {
1161
1569
  ...ingestHeaders(target),
1162
1570
  "content-type": "application/x-ndjson"
@@ -1174,13 +1582,53 @@ function approximateSize(value) {
1174
1582
  }
1175
1583
  }
1176
1584
  //#endregion
1585
+ //#region ../browser-session/src/capture/patch.ts
1586
+ const slots = /* @__PURE__ */ new WeakMap();
1587
+ /**
1588
+ * Install `wrap(original, hooks)` as `target[key]`, or reuse the wrapper an
1589
+ * earlier call left in place. The wrapper must read `hooks()` on every call
1590
+ * and only forward to `original` when it is undefined. Returns the teardown.
1591
+ */
1592
+ function patchMethod(target, key, hooks, wrap) {
1593
+ let byKey = slots.get(target);
1594
+ if (!byKey) {
1595
+ byKey = /* @__PURE__ */ new Map();
1596
+ slots.set(target, byKey);
1597
+ }
1598
+ let slot = byKey.get(key);
1599
+ if (slot && target[key] !== slot.wrapper && target[key] === slot.original) slot = void 0;
1600
+ if (slot) slot.hooks = hooks;
1601
+ else {
1602
+ const original = target[key];
1603
+ const fresh = {
1604
+ original,
1605
+ wrapper: void 0,
1606
+ hooks
1607
+ };
1608
+ const wrapper = wrap(original, () => fresh.hooks);
1609
+ fresh.wrapper = wrapper;
1610
+ target[key] = wrapper;
1611
+ byKey.set(key, fresh);
1612
+ slot = fresh;
1613
+ }
1614
+ const owned = slot;
1615
+ const map = byKey;
1616
+ return () => {
1617
+ if (owned.hooks !== hooks) return;
1618
+ owned.hooks = void 0;
1619
+ if (target[key] !== owned.wrapper) return;
1620
+ Reflect.set(target, key, owned.original);
1621
+ map.delete(key);
1622
+ };
1623
+ }
1624
+ //#endregion
1177
1625
  //#region ../browser-session/src/session/session.ts
1178
1626
  const STORAGE_KEY = "maple.session";
1179
1627
  /** Hard cap on a single session's lifetime regardless of activity. */
1180
1628
  const MAX_SESSION_MS = 864e5;
1181
1629
  /**
1182
1630
  * The activity path runs per span creation (`getSessionId`) and per captured
1183
- * event (`markActivity` — every console call, keystroke, click, fetch);
1631
+ * user input (`markActivity`: clicks, typing, scrolling, mouse movement);
1184
1632
  * persisting the bump on every call would hammer sessionStorage for no benefit
1185
1633
  * — rotation correctness only needs sub-idle-timeout granularity.
1186
1634
  */
@@ -1245,6 +1693,10 @@ function parseSessionRecord(raw) {
1245
1693
  if (typeof value.replayBuffered !== "boolean") return void 0;
1246
1694
  record.replayBuffered = value.replayBuffered;
1247
1695
  }
1696
+ if (value.replayHalted !== void 0) {
1697
+ if (typeof value.replayHalted !== "boolean") return void 0;
1698
+ record.replayHalted = value.replayHalted;
1699
+ }
1248
1700
  if (value.replayTrigger === "error") record.replayTrigger = value.replayTrigger;
1249
1701
  if (value.utm !== void 0) {
1250
1702
  if (!isStringRecord(value.utm)) return void 0;
@@ -1262,6 +1714,12 @@ const UTM_KEYS = [
1262
1714
  /** In-memory fallback when sessionStorage is unavailable (private mode). */
1263
1715
  let ephemeral;
1264
1716
  /**
1717
+ * The Storage `ephemeral` was last written to. When the key vanishes from that
1718
+ * same Storage (a host app's `sessionStorage.clear()` on logout), the in-memory
1719
+ * copy is still this tab's session; a different Storage object is not ours.
1720
+ */
1721
+ let ephemeralStorage;
1722
+ /**
1265
1723
  * Set while sessionStorage writes throw. On quota exhaustion reads still work,
1266
1724
  * so the stored copy is a frozen snapshot: `metaVersion` pinned at 1 billed
1267
1725
  * every heartbeat as a new session. Keep this separate from the read try/catch.
@@ -1271,9 +1729,8 @@ const rotationListeners = /* @__PURE__ */ new Set();
1271
1729
  /**
1272
1730
  * Read the entry URL/referrer/UTM of the current page.
1273
1731
  *
1274
- * Guarded for non-DOM runtimes: `freshRecord` is reachable from `getSessionId`
1275
- * on the span path and from the `nextChunkSeq`/`nextMetaVersion` fallbacks, so
1276
- * this must not assume `location`/`document` exist.
1732
+ * Guarded for non-DOM runtimes: `freshRecord` is reachable from `markActivity`,
1733
+ * which runs outside a DOM, so this must not assume `location`/`document` exist.
1277
1734
  */
1278
1735
  function readEntryContext() {
1279
1736
  if (typeof window === "undefined" || typeof location === "undefined") return {};
@@ -1298,7 +1755,7 @@ function readEntryContext() {
1298
1755
  }
1299
1756
  function freshRecord(now) {
1300
1757
  return {
1301
- id: crypto.randomUUID(),
1758
+ id: randomUuid(),
1302
1759
  startedAt: now,
1303
1760
  lastActivityAt: now,
1304
1761
  chunkSeq: 0,
@@ -1310,8 +1767,9 @@ function freshRecord(now) {
1310
1767
  function readRecord() {
1311
1768
  if (!mayUseStorage() || writesFailed && ephemeral) return ephemeral;
1312
1769
  try {
1313
- const raw = window.sessionStorage.getItem(STORAGE_KEY);
1314
- if (!raw) return void 0;
1770
+ const storage = window.sessionStorage;
1771
+ const raw = storage.getItem(STORAGE_KEY);
1772
+ if (!raw) return storage === ephemeralStorage ? ephemeral : void 0;
1315
1773
  return parseSessionRecord(raw) ?? ephemeral;
1316
1774
  } catch {
1317
1775
  return ephemeral;
@@ -1319,9 +1777,12 @@ function readRecord() {
1319
1777
  }
1320
1778
  function writeRecord(record) {
1321
1779
  ephemeral = record;
1780
+ rememberCounters(record);
1322
1781
  if (!mayUseStorage()) return;
1323
1782
  try {
1324
- window.sessionStorage.setItem(STORAGE_KEY, JSON.stringify(record));
1783
+ const storage = window.sessionStorage;
1784
+ ephemeralStorage = storage;
1785
+ storage.setItem(STORAGE_KEY, JSON.stringify(record));
1325
1786
  writesFailed = false;
1326
1787
  } catch {
1327
1788
  writesFailed = true;
@@ -1331,11 +1792,13 @@ function writeRecord(record) {
1331
1792
  const TAB_LEASE_PREFIX = "maple.session.tab.";
1332
1793
  const TAB_LEASE_TTL_MS = 12e4;
1333
1794
  const TAB_LEASE_REFRESH_MS = 5e3;
1795
+ const TAB_LEASE_KEEPALIVE_MS = 3e4;
1334
1796
  const TAB_NONCE_KEY = "__MAPLE_TAB_NONCE__";
1335
1797
  /** The session id this tab has checked (or minted) and may keep using. */
1336
1798
  let verifiedSessionId;
1337
1799
  let leaseWrittenAt = 0;
1338
1800
  let releaseInstalled = false;
1801
+ let leaseTimer;
1339
1802
  /**
1340
1803
  * Set from `pagehide` until the page is shown again. The lifecycle's own
1341
1804
  * `pagehide` handler posts an `ended` row after ours runs, which writes the
@@ -1347,7 +1810,7 @@ function tabNonce() {
1347
1810
  const owner = globalThis;
1348
1811
  const existing = owner[TAB_NONCE_KEY];
1349
1812
  if (typeof existing === "string") return existing;
1350
- const fresh = crypto.randomUUID();
1813
+ const fresh = randomUuid();
1351
1814
  owner[TAB_NONCE_KEY] = fresh;
1352
1815
  return fresh;
1353
1816
  }
@@ -1378,10 +1841,27 @@ function claimTab(sessionId) {
1378
1841
  leaseWrittenAt = now;
1379
1842
  const storage = leaseStorage();
1380
1843
  if (!storage) return;
1844
+ writeLease(storage, sessionId, now);
1845
+ installLeaseRelease();
1846
+ if (leaseTimer === void 0 && typeof setInterval === "function") leaseTimer = setInterval(refreshLease, TAB_LEASE_KEEPALIVE_MS);
1847
+ }
1848
+ function writeLease(storage, sessionId, now) {
1381
1849
  try {
1382
1850
  storage.setItem(`${TAB_LEASE_PREFIX}${sessionId}`, `${tabNonce()}:${now}`);
1383
1851
  } catch {}
1384
- installLeaseRelease();
1852
+ }
1853
+ function refreshLease() {
1854
+ if (verifiedSessionId === void 0 || leaseReleased) return;
1855
+ const record = readRecord();
1856
+ if (!hasConsent() || record === void 0 || record.id !== verifiedSessionId || isSessionExpired(record)) {
1857
+ clearInterval(leaseTimer);
1858
+ leaseTimer = void 0;
1859
+ return;
1860
+ }
1861
+ const storage = leaseStorage();
1862
+ if (!storage) return;
1863
+ leaseWrittenAt = Date.now();
1864
+ writeLease(storage, verifiedSessionId, leaseWrittenAt);
1385
1865
  }
1386
1866
  function releaseTab(sessionId) {
1387
1867
  const storage = leaseStorage();
@@ -1504,13 +1984,20 @@ function touchSession(now) {
1504
1984
  return record;
1505
1985
  }
1506
1986
  /**
1507
- * Mark activity, rotating first when the stored session has expired. Called per
1508
- * captured event, so it shares `getSessionId`'s throttled touch — callers read
1509
- * the returned record's `id` to detect that rotation.
1987
+ * Resolve the session a captured event belongs to. For `"input"` (what a person
1988
+ * does: clicks, typing, scrolling, navigation) this marks activity, rotating
1989
+ * first when the stored session has expired, and shares `getSessionId`'s
1990
+ * throttled touch; callers read the returned record's `id` to detect rotation.
1991
+ *
1992
+ * `"passive"` events (DOM mutations, console, network, errors) must neither keep
1993
+ * an abandoned page's session alive nor start a new one: they get the stored
1994
+ * record untouched, or `null` when it has expired. Undefined outside a browser.
1510
1995
  */
1511
- function markActivity() {
1996
+ function markActivity(kind = "input") {
1512
1997
  if (typeof window === "undefined") return void 0;
1513
- return touchSession(Date.now());
1998
+ if (kind === "input") return touchSession(Date.now());
1999
+ const record = readRecord();
2000
+ return record !== void 0 && !isSessionExpired(record) ? record : null;
1514
2001
  }
1515
2002
  /**
1516
2003
  * Record a page view. Persisted on the session record rather than held in the
@@ -1574,19 +2061,58 @@ function entryContextOf(record) {
1574
2061
  utm: record.utm ?? {}
1575
2062
  };
1576
2063
  }
2064
+ const recentCounters = /* @__PURE__ */ new Map();
2065
+ const MAX_RECENT_COUNTERS = 4;
2066
+ function rememberCounters(record) {
2067
+ recentCounters.delete(record.id);
2068
+ recentCounters.set(record.id, {
2069
+ chunkSeq: record.chunkSeq,
2070
+ metaVersion: record.metaVersion
2071
+ });
2072
+ for (const id of recentCounters.keys()) {
2073
+ if (recentCounters.size <= MAX_RECENT_COUNTERS) break;
2074
+ recentCounters.delete(id);
2075
+ }
2076
+ }
1577
2077
  /**
1578
- * Take the next replay chunk sequence number for the current session. Monotonic
1579
- * across reloads (persisted on the session record), so a refresh continues the
1580
- * sequence instead of restarting at 0 and overwriting the previous load's blobs.
2078
+ * Advance one counter of `sessionId`: on its persisted record when that is the
2079
+ * stored one, else on this document's memory of it. Undefined for a session
2080
+ * this document never wrote, whose counters it cannot know.
1581
2081
  */
1582
- function nextChunkSeq() {
1583
- const record = readRecord() ?? freshRecord(Date.now());
1584
- const seq = record.chunkSeq;
1585
- writeRecord({
1586
- ...record,
1587
- chunkSeq: seq + 1
1588
- });
1589
- return seq;
2082
+ function takeCounter(sessionId, take) {
2083
+ const record = readRecord();
2084
+ if (record?.id === sessionId) {
2085
+ const { value, next } = take({
2086
+ chunkSeq: record.chunkSeq,
2087
+ metaVersion: record.metaVersion
2088
+ });
2089
+ writeRecord({
2090
+ ...record,
2091
+ chunkSeq: next.chunkSeq,
2092
+ metaVersion: next.metaVersion
2093
+ });
2094
+ return value;
2095
+ }
2096
+ const remembered = recentCounters.get(sessionId);
2097
+ if (!remembered) return void 0;
2098
+ const { value, next } = take(remembered);
2099
+ recentCounters.set(sessionId, next);
2100
+ return value;
2101
+ }
2102
+ /**
2103
+ * Take the next replay chunk sequence number of `sessionId`. Monotonic across
2104
+ * reloads (persisted on the session record), so a refresh continues the
2105
+ * sequence instead of restarting at 0 and overwriting the previous load's blobs.
2106
+ * Undefined when the session's counter is unknown: the chunk must be dropped.
2107
+ */
2108
+ function nextChunkSeq(sessionId) {
2109
+ return takeCounter(sessionId, (counters) => ({
2110
+ value: counters.chunkSeq,
2111
+ next: {
2112
+ ...counters,
2113
+ chunkSeq: counters.chunkSeq + 1
2114
+ }
2115
+ }));
1590
2116
  }
1591
2117
  /**
1592
2118
  * Take the next session-metadata row version for the current session.
@@ -1596,14 +2122,17 @@ function nextChunkSeq() {
1596
2122
  * older SDKs (no `metaVersion`) already posted versions 1 and 2, so the
1597
2123
  * counter resumes at 3 for them; a fresh session starts at 1.
1598
2124
  */
1599
- function nextMetaVersion() {
1600
- const record = readRecord() ?? freshRecord(Date.now());
1601
- const version = (record.metaVersion ?? 2) + 1;
1602
- writeRecord({
1603
- ...record,
1604
- metaVersion: version
2125
+ function nextMetaVersion(sessionId) {
2126
+ return takeCounter(sessionId, (counters) => {
2127
+ const version = (counters.metaVersion ?? 2) + 1;
2128
+ return {
2129
+ value: version,
2130
+ next: {
2131
+ ...counters,
2132
+ metaVersion: version
2133
+ }
2134
+ };
1605
2135
  });
1606
- return version;
1607
2136
  }
1608
2137
  /** A uniform draw in [0, 1) from Web Crypto, the same source session ids use. */
1609
2138
  function uniformRandom() {
@@ -1644,6 +2173,19 @@ function claimReplayMode(sampleRate, onErrorSampleRate) {
1644
2173
  }
1645
2174
  return record.replayBuffered ? "buffer" : "off";
1646
2175
  }
2176
+ /** Recording of `sessionId` stopped for good: later recorder starts of it do nothing. */
2177
+ function markReplayHalted(sessionId) {
2178
+ const record = readRecord();
2179
+ if (!record || record.id !== sessionId || record.replayHalted) return;
2180
+ writeRecord({
2181
+ ...record,
2182
+ replayHalted: true
2183
+ });
2184
+ }
2185
+ function isReplayHalted(sessionId) {
2186
+ const record = readRecord();
2187
+ return record?.id === sessionId && record.replayHalted === true;
2188
+ }
1647
2189
  /** An error made this buffered session a recorded one: later loads record it from the start. */
1648
2190
  function markReplayTriggered(sessionId) {
1649
2191
  const record = readRecord();
@@ -1687,7 +2229,23 @@ function onSessionEvent(listener) {
1687
2229
  return () => listeners().delete(listener);
1688
2230
  }
1689
2231
  const FLUSH_INTERVAL_MS = 5e3;
1690
- const FLUSH_BYTES = 65536;
2232
+ /** Small enough that the batch a page leaves with usually fits the shared keepalive budget. */
2233
+ const FLUSH_BYTES = 32768;
2234
+ /** Ceiling on buffered events while ingest asks to back off; the oldest go first. */
2235
+ const MAX_BUFFER_BYTES = 262144;
2236
+ /** Pause after ingest asks for a retry (429, 5xx), doubling per answer in a row up to the cap. */
2237
+ const RETRY_BACKOFF_MS = 5e3;
2238
+ const MAX_RETRY_BACKOFF_MS = 6e4;
2239
+ /** Identical error rows kept per window: an error thrown every frame is one row, not sixty a second. */
2240
+ const MAX_REPEATED_ERRORS = 5;
2241
+ const REPEATED_ERROR_WINDOW_MS = 6e4;
2242
+ /** Event types a person causes. Only these keep a session from going idle. */
2243
+ const USER_EVENTS = /* @__PURE__ */ new Set([
2244
+ "navigation",
2245
+ "click",
2246
+ "input",
2247
+ "custom"
2248
+ ]);
1691
2249
  /** Events across which an in-flight POST can fail although ingest stored it. */
1692
2250
  const PAGE_TRANSITIONS = [
1693
2251
  "beforeunload",
@@ -1730,6 +2288,15 @@ function startEventSink(config, sessionId) {
1730
2288
  let buffer = [];
1731
2289
  let bufferBytes = 0;
1732
2290
  let retry = [];
2291
+ let resend = [];
2292
+ /** The one non-keepalive POST in flight, and whether a flush was asked for meanwhile. */
2293
+ let inFlight;
2294
+ let flushAgain = false;
2295
+ let retryAt = 0;
2296
+ let retriesInARow = 0;
2297
+ /** Ingest refused this page's key or plan: nothing more is buffered. */
2298
+ let refused = false;
2299
+ const repeatedErrors = /* @__PURE__ */ new Map();
1733
2300
  let seq = 0;
1734
2301
  let pageViews = 0;
1735
2302
  let clickCount = 0;
@@ -1741,19 +2308,74 @@ function startEventSink(config, sessionId) {
1741
2308
  };
1742
2309
  const pageTarget = typeof globalThis.addEventListener === "function" ? globalThis : void 0;
1743
2310
  for (const type of PAGE_TRANSITIONS) pageTarget?.addEventListener(type, onPageTransition);
1744
- const flush = async (keepalive = false) => {
2311
+ const send = async (keepalive) => {
2312
+ if (!keepalive && Date.now() < retryAt) return;
1745
2313
  const fresh = buffer.map(({ ev, seq }) => toRow(config, sessionId, ev, seq));
1746
- const rows = keepalive ? fresh : [...retry, ...fresh];
2314
+ const rows = keepalive ? [...resend, ...fresh] : [
2315
+ ...retry,
2316
+ ...resend,
2317
+ ...fresh
2318
+ ];
1747
2319
  if (rows.length === 0) return;
1748
2320
  if (!keepalive) retry = [];
2321
+ resend = [];
1749
2322
  buffer = [];
1750
2323
  bufferBytes = 0;
1751
2324
  const transitionsAtSend = pageTransitions;
1752
- if (await postSessionEvents(config, rows, keepalive) !== "failed" || keepalive || pageTransitions !== transitionsAtSend) return;
1753
- retry = [...fresh, ...retry];
2325
+ const outcome = await postSessionEvents(config, rows, keepalive);
2326
+ if (outcome === "refused") {
2327
+ refused = true;
2328
+ buffer = [];
2329
+ bufferBytes = 0;
2330
+ retry = [];
2331
+ return;
2332
+ }
2333
+ if (outcome === "retry") {
2334
+ retriesInARow++;
2335
+ retryAt = Date.now() + Math.min(MAX_RETRY_BACKOFF_MS, RETRY_BACKOFF_MS * 2 ** (retriesInARow - 1));
2336
+ } else retriesInARow = 0;
2337
+ if (outcome === "retry") resend = [...fresh, ...resend];
2338
+ else if (!keepalive && outcome === "failed" && pageTransitions === transitionsAtSend) retry = [...fresh, ...retry];
2339
+ };
2340
+ const flush = (keepalive = false) => {
2341
+ if (keepalive) return send(true);
2342
+ if (inFlight) {
2343
+ flushAgain = true;
2344
+ return inFlight;
2345
+ }
2346
+ const run = async () => {
2347
+ try {
2348
+ do {
2349
+ flushAgain = false;
2350
+ await send(false);
2351
+ } while (flushAgain);
2352
+ } finally {
2353
+ inFlight = void 0;
2354
+ }
2355
+ };
2356
+ inFlight = run();
2357
+ return inFlight;
2358
+ };
2359
+ /** Whether `ev` repeats an error already sent `MAX_REPEATED_ERRORS` times this window. */
2360
+ const isRepeatedError = (ev) => {
2361
+ if (ev.type !== "error" && !(ev.type === "console" && ev.level === "error")) return false;
2362
+ const key = `${ev.type}|${ev.message ?? ""}|${(ev.errorStack ?? "").slice(0, 500)}`;
2363
+ const now = Date.now();
2364
+ const seen = repeatedErrors.get(key);
2365
+ if (!seen || now - seen.since > REPEATED_ERROR_WINDOW_MS) {
2366
+ if (repeatedErrors.size >= 100) repeatedErrors.clear();
2367
+ repeatedErrors.set(key, {
2368
+ count: 1,
2369
+ since: now
2370
+ });
2371
+ return false;
2372
+ }
2373
+ seen.count++;
2374
+ return seen.count > MAX_REPEATED_ERRORS;
1754
2375
  };
1755
2376
  const emit = (ev) => {
1756
- const session = markActivity();
2377
+ const session = markActivity(USER_EVENTS.has(ev.type) ? "input" : "passive");
2378
+ if (session === null) return;
1757
2379
  if (session && session.id !== sessionId) {
1758
2380
  const nextSink = startEventSink(config, session.id);
1759
2381
  if (ev.type !== "navigation") nextSink.emit(ev);
@@ -1764,11 +2386,16 @@ function startEventSink(config, sessionId) {
1764
2386
  noteNavigation(ev.url ?? (typeof location !== "undefined" ? location.href : ""));
1765
2387
  } else if (ev.type === "click") clickCount++;
1766
2388
  else if (ev.type === "error" || ev.type === "console" && ev.level === "error") errorCount++;
2389
+ if (refused || isRepeatedError(ev)) return;
1767
2390
  buffer.push({
1768
2391
  ev,
1769
2392
  seq: seq++
1770
2393
  });
1771
2394
  bufferBytes += approximateSize(ev);
2395
+ while (bufferBytes > MAX_BUFFER_BYTES && buffer.length > 1) {
2396
+ const dropped = buffer.shift();
2397
+ if (dropped) bufferBytes -= approximateSize(dropped.ev);
2398
+ }
1772
2399
  if (bufferBytes >= FLUSH_BYTES) flush();
1773
2400
  for (const listener of listeners()) try {
1774
2401
  listener(ev);
@@ -1819,24 +2446,18 @@ function installNavigationObserver(onNavigate) {
1819
2446
  onNavigate(url);
1820
2447
  };
1821
2448
  emitNav();
1822
- const origPush = history.pushState;
1823
- const origReplace = history.replaceState;
2449
+ const hooks = { emitNav };
1824
2450
  const receiver = (self) => self instanceof History ? self : history;
1825
- history.pushState = function(...args) {
1826
- const result = origPush.apply(receiver(this), args);
1827
- emitNav();
1828
- return result;
1829
- };
1830
- history.replaceState = function(...args) {
1831
- const result = origReplace.apply(receiver(this), args);
1832
- emitNav();
2451
+ const wrapHistory = (original, current) => function(...args) {
2452
+ const result = original.apply(receiver(this), args);
2453
+ current()?.emitNav();
1833
2454
  return result;
1834
2455
  };
2456
+ const unpatch = [patchMethod(history, "pushState", hooks, wrapHistory), patchMethod(history, "replaceState", hooks, wrapHistory)];
1835
2457
  window.addEventListener("popstate", emitNav);
1836
2458
  window.addEventListener("hashchange", emitNav);
1837
2459
  return () => {
1838
- history.pushState = origPush;
1839
- history.replaceState = origReplace;
2460
+ for (const off of unpatch) off();
1840
2461
  window.removeEventListener("popstate", emitNav);
1841
2462
  window.removeEventListener("hashchange", emitNav);
1842
2463
  };
@@ -1947,6 +2568,10 @@ function startSessionLifecycle(options, hooks) {
1947
2568
  let stopped = false;
1948
2569
  /** Whether a run is live: capture started, `active` posted, heartbeat armed. */
1949
2570
  let running = false;
2571
+ /** A live run whose tab is hidden: `ended` posted, heartbeat off, capture kept. */
2572
+ let paused = false;
2573
+ /** Ends a paused run once its session would have gone idle, so a hidden tab does not record for long. */
2574
+ let pauseTimer;
1950
2575
  let heartbeat;
1951
2576
  let clickCountBase = 0;
1952
2577
  let errorCountBase = 0;
@@ -2000,6 +2625,8 @@ function startSessionLifecycle(options, hooks) {
2000
2625
  };
2001
2626
  const post = (status, keepalive) => {
2002
2627
  const record = liveRecord();
2628
+ const version = nextMetaVersion(record.id);
2629
+ if (version === void 0) return;
2003
2630
  const counts = countsFor(record);
2004
2631
  const visitorId = getVisitorId();
2005
2632
  const persistVisit = visitorId !== void 0;
@@ -2008,7 +2635,7 @@ function startSessionLifecycle(options, hooks) {
2008
2635
  hooks.post(buildSessionMetaRow({
2009
2636
  sessionId: record.id,
2010
2637
  startedAt: new Date(record.startedAt),
2011
- version: nextMetaVersion(),
2638
+ version,
2012
2639
  status,
2013
2640
  serviceName: options.serviceName,
2014
2641
  identity: options.getIdentity?.(),
@@ -2034,6 +2661,10 @@ function startSessionLifecycle(options, hooks) {
2034
2661
  clearInterval(heartbeat);
2035
2662
  heartbeat = void 0;
2036
2663
  };
2664
+ const clearPauseTimer = () => {
2665
+ if (pauseTimer !== void 0) clearTimeout(pauseTimer);
2666
+ pauseTimer = void 0;
2667
+ };
2037
2668
  const startRun = () => {
2038
2669
  if (stopped || running) return;
2039
2670
  running = true;
@@ -2042,6 +2673,10 @@ function startSessionLifecycle(options, hooks) {
2042
2673
  rebaseCounts(record);
2043
2674
  hooks.onStart?.(record);
2044
2675
  post("active", false);
2676
+ startHeartbeat();
2677
+ };
2678
+ const startHeartbeat = () => {
2679
+ stopHeartbeat();
2045
2680
  heartbeat = setInterval(() => {
2046
2681
  if (typeof document !== "undefined" && document.visibilityState !== "visible") return;
2047
2682
  if (isSessionExpired(liveRecord())) {
@@ -2058,9 +2693,32 @@ function startSessionLifecycle(options, hooks) {
2058
2693
  if (!running) return;
2059
2694
  running = false;
2060
2695
  stopHeartbeat();
2061
- if (suspend.flush) post("ended", suspend.keepalive);
2696
+ clearPauseTimer();
2697
+ if (suspend.flush && !paused) post("ended", suspend.keepalive);
2698
+ paused = false;
2062
2699
  return hooks.onSuspend?.(suspend);
2063
2700
  };
2701
+ const pauseRun = (onPause) => {
2702
+ if (!running || paused) return;
2703
+ paused = true;
2704
+ stopHeartbeat();
2705
+ post("ended", true);
2706
+ onPause({ keepalive: true });
2707
+ const idleIn = liveRecord().lastActivityAt + IDLE_TIMEOUT_MS - Date.now();
2708
+ pauseTimer = setTimeout(() => {
2709
+ pauseTimer = void 0;
2710
+ endRun({
2711
+ flush: true,
2712
+ keepalive: false
2713
+ });
2714
+ }, Math.max(0, idleIn) + 1e3);
2715
+ };
2716
+ const resumeRun = () => {
2717
+ clearPauseTimer();
2718
+ paused = false;
2719
+ post("active", false);
2720
+ startHeartbeat();
2721
+ };
2064
2722
  const stopRotationListener = onSessionRotate((previous, next) => {
2065
2723
  if (stopped || previous.id !== current.id) return;
2066
2724
  endRun({
@@ -2079,7 +2737,8 @@ function startSessionLifecycle(options, hooks) {
2079
2737
  const doc = browserDocument();
2080
2738
  if (!doc) return;
2081
2739
  if (doc.visibilityState === "hidden") {
2082
- endRun({
2740
+ if (hooks.onPause) pauseRun(hooks.onPause);
2741
+ else endRun({
2083
2742
  flush: true,
2084
2743
  keepalive: true
2085
2744
  });
@@ -2089,7 +2748,8 @@ function startSessionLifecycle(options, hooks) {
2089
2748
  getSession();
2090
2749
  return;
2091
2750
  }
2092
- startRun();
2751
+ if (paused) resumeRun();
2752
+ else startRun();
2093
2753
  };
2094
2754
  const visibilityTarget = typeof document !== "undefined" && typeof document.addEventListener === "function" ? document : typeof globalThis.addEventListener === "function" ? globalThis : void 0;
2095
2755
  const pageHideTarget = typeof globalThis.addEventListener === "function" ? globalThis : void 0;
@@ -2101,7 +2761,7 @@ function startSessionLifecycle(options, hooks) {
2101
2761
  return current.id;
2102
2762
  },
2103
2763
  announce: () => {
2104
- if (running) post("active", false);
2764
+ if (running) post(paused ? "ended" : "active", false);
2105
2765
  },
2106
2766
  shutdown: async (shutdownOptions) => {
2107
2767
  if (stopped) return;
@@ -2187,4 +2847,4 @@ function readSessionSink() {
2187
2847
  return globalThis[SESSION_SINK_KEY];
2188
2848
  }
2189
2849
  //#endregion
2190
- export { selectorOf as A, mayPersistIdentifier as B, otlpKeepaliveRoom as C, reserveKeepalive as D, postToIngest as E, withStartedTraceId as F, scrubUrl as G, setConsent as H, configurePrivacy as I, mayUseStorage as K, consentAllowedSince as L, safeEmit as M, activeTraceId as N, sdkHint as O, setActiveTraceIdProvider as P, consentRevokedAt as R, ingestHeaders as S, postSessionMeta as T, setVisitorTracking as U, onConsentChange as V, isJsonObject as W, rotateSession as _, recordTraceId as a, OTLP_UNLOAD_TAIL_BYTES as b, getActiveSink as c, startEventSink as d, claimReplayMode as f, nextChunkSeq as g, markReplayTriggered as h, readSessionSink as i, BLOCK_SELECTOR as j, warnDropped as k, onSessionEvent as l, markActivity as m, getObservedTraceIds as n, startSessionLifecycle as o, getSession as p, publishSessionSink as r, clearPendingEvents as s, clearSessionSink as t, queuePending as u, postSessionMetaRow as v, postSessionBlob as w, gzip as x, isLikelyBot as y, hasConsent as z };
2850
+ export { postToIngest as A, withStartedTraceId as B, OTLP_UNLOAD_TAIL_BYTES as C, otlpKeepaliveRoom as D, ingestHeaders as E, BLOCK_SELECTOR as F, mayPersistIdentifier as G, consentAllowedSince as H, safeEmit as I, setVisitorTracking as J, onConsentChange as K, truncateText as L, sdkHint as M, warnDropped as N, postSessionBlob as O, selectorOf as P, activeTraceId as R, isLikelyBot as S, gzipStored as T, consentRevokedAt as U, configurePrivacy as V, hasConsent as W, scrubUrl as X, isJsonObject as Y, mayUseStorage as Z, markReplayTriggered as _, recordTraceId as a, patchMethod as b, getActiveSink as c, startEventSink as d, claimReplayMode as f, markReplayHalted as g, markActivity as h, readSessionSink as i, reserveKeepalive as j, postSessionMeta as k, onSessionEvent as l, isReplayHalted as m, getObservedTraceIds as n, startSessionLifecycle as o, getSession as p, setConsent as q, publishSessionSink as r, clearPendingEvents as s, clearSessionSink as t, queuePending as u, nextChunkSeq as v, gzip as w, postSessionMetaRow as x, rotateSession as y, setActiveTraceIdProvider as z };