@squasher-ai/browser 0.1.1 → 0.2.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.
Files changed (98) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +2 -41
  3. package/dist/__tests__/client/harness.d.ts +18 -0
  4. package/dist/__tests__/client/harness.d.ts.map +1 -0
  5. package/dist/__tests__/client/harness.js +106 -0
  6. package/dist/__tests__/replay/coverage-harness.d.ts +29 -0
  7. package/dist/__tests__/replay/coverage-harness.d.ts.map +1 -0
  8. package/dist/__tests__/replay/coverage-harness.js +123 -0
  9. package/dist/__tests__/replay/test-helpers.d.ts +13 -0
  10. package/dist/__tests__/replay/test-helpers.d.ts.map +1 -0
  11. package/dist/__tests__/replay/test-helpers.js +9 -0
  12. package/dist/autocapture.d.ts +14 -18
  13. package/dist/autocapture.d.ts.map +1 -1
  14. package/dist/autocapture.js +77 -95
  15. package/dist/client.d.ts +12 -19
  16. package/dist/client.d.ts.map +1 -1
  17. package/dist/client.js +56 -72
  18. package/dist/config.d.ts +25 -0
  19. package/dist/config.d.ts.map +1 -0
  20. package/dist/config.js +26 -0
  21. package/dist/console-capture.d.ts +8 -0
  22. package/dist/console-capture.d.ts.map +1 -0
  23. package/dist/console-capture.js +79 -0
  24. package/dist/errors.d.ts +2 -2
  25. package/dist/errors.d.ts.map +1 -1
  26. package/dist/errors.js +8 -5
  27. package/dist/index.d.ts +5 -38
  28. package/dist/index.d.ts.map +1 -1
  29. package/dist/index.js +4 -82
  30. package/dist/next.d.ts +40 -0
  31. package/dist/next.d.ts.map +1 -0
  32. package/dist/next.js +67 -0
  33. package/dist/react.d.ts.map +1 -1
  34. package/dist/react.js +12 -10
  35. package/dist/replay-network.d.ts +21 -0
  36. package/dist/replay-network.d.ts.map +1 -0
  37. package/dist/replay-network.js +193 -0
  38. package/dist/replay-privacy.d.ts +2 -2
  39. package/dist/replay-privacy.d.ts.map +1 -1
  40. package/dist/replay-privacy.js +47 -17
  41. package/dist/replay-retry.d.ts +41 -0
  42. package/dist/replay-retry.d.ts.map +1 -0
  43. package/dist/replay-retry.js +193 -0
  44. package/dist/replay.d.ts +18 -5
  45. package/dist/replay.d.ts.map +1 -1
  46. package/dist/replay.js +131 -71
  47. package/dist/runtime/client-singleton.d.ts +25 -0
  48. package/dist/runtime/client-singleton.d.ts.map +1 -0
  49. package/dist/runtime/client-singleton.js +66 -0
  50. package/dist/runtime/client.d.ts +22 -0
  51. package/dist/runtime/client.d.ts.map +1 -0
  52. package/dist/runtime/client.js +12 -0
  53. package/dist/runtime/replay.d.ts +13 -0
  54. package/dist/runtime/replay.d.ts.map +1 -0
  55. package/dist/runtime/replay.js +15 -0
  56. package/dist/session.d.ts +0 -10
  57. package/dist/session.d.ts.map +1 -1
  58. package/dist/session.js +20 -21
  59. package/dist/telemetry.d.ts +1 -1
  60. package/dist/telemetry.d.ts.map +1 -1
  61. package/dist/telemetry.js +13 -10
  62. package/dist/trace-context.d.ts +9 -0
  63. package/dist/trace-context.d.ts.map +1 -0
  64. package/dist/trace-context.js +13 -0
  65. package/dist/transport.d.ts +11 -19
  66. package/dist/transport.d.ts.map +1 -1
  67. package/dist/transport.js +86 -66
  68. package/dist/types.d.ts +37 -135
  69. package/dist/types.d.ts.map +1 -1
  70. package/dist/types.js +0 -7
  71. package/dist/vitals.d.ts +55 -2
  72. package/dist/vitals.d.ts.map +1 -1
  73. package/dist/vitals.js +115 -16
  74. package/package.json +25 -3
  75. package/dist/__tests__/client.test.d.ts +0 -2
  76. package/dist/__tests__/client.test.d.ts.map +0 -1
  77. package/dist/__tests__/client.test.js +0 -103
  78. package/dist/__tests__/errors.test.d.ts +0 -2
  79. package/dist/__tests__/errors.test.d.ts.map +0 -1
  80. package/dist/__tests__/errors.test.js +0 -80
  81. package/dist/__tests__/replay-privacy.test.d.ts +0 -2
  82. package/dist/__tests__/replay-privacy.test.d.ts.map +0 -1
  83. package/dist/__tests__/replay-privacy.test.js +0 -124
  84. package/dist/__tests__/replay-startup.test.d.ts +0 -2
  85. package/dist/__tests__/replay-startup.test.d.ts.map +0 -1
  86. package/dist/__tests__/replay-startup.test.js +0 -99
  87. package/dist/__tests__/replay.test.d.ts +0 -2
  88. package/dist/__tests__/replay.test.d.ts.map +0 -1
  89. package/dist/__tests__/replay.test.js +0 -217
  90. package/dist/__tests__/session.test.d.ts +0 -2
  91. package/dist/__tests__/session.test.d.ts.map +0 -1
  92. package/dist/__tests__/session.test.js +0 -46
  93. package/dist/__tests__/transport.test.d.ts +0 -2
  94. package/dist/__tests__/transport.test.d.ts.map +0 -1
  95. package/dist/__tests__/transport.test.js +0 -101
  96. package/dist/__tests__/types.test.d.ts +0 -2
  97. package/dist/__tests__/types.test.d.ts.map +0 -1
  98. package/dist/__tests__/types.test.js +0 -139
@@ -0,0 +1,193 @@
1
+ import { attempt, attemptAsync } from "@squasher-ai/result-utils";
2
+ import { readRuntimeNumber, readRuntimeProperty, readRuntimeString } from "@squasher-ai/sdk-core";
3
+ const NETWORK_EVENT_TAG = "squasher:network";
4
+ const MAX_NETWORK_EVENTS = 400;
5
+ function clampOffset(offsetMs) {
6
+ return Math.max(0, Math.round(offsetMs));
7
+ }
8
+ function roundDuration(durationMs) {
9
+ return Math.max(0, Math.round(durationMs));
10
+ }
11
+ function readFiniteNumber(value) {
12
+ return value !== null && value !== undefined && Number.isFinite(value) ? value : null;
13
+ }
14
+ function readFiniteString(value) {
15
+ return value && value.length > 0 ? value : null;
16
+ }
17
+ function getCurrentPageUrl() {
18
+ return globalThis.location?.href ?? "";
19
+ }
20
+ function shouldIgnoreRequestUrl(requestUrl, ignoredUrlPrefixes) {
21
+ return ignoredUrlPrefixes.some((prefix) => requestUrl.startsWith(prefix));
22
+ }
23
+ function toResourceKey(payload) {
24
+ return [
25
+ payload.source,
26
+ payload.requestUrl,
27
+ payload.initiatorType,
28
+ payload.startOffsetMs,
29
+ payload.durationMs,
30
+ ].join("|");
31
+ }
32
+ function buildResourcePayload(entry, replayStartedAtMs) {
33
+ if (entry.entryType !== "resource" && entry.entryType !== "navigation") {
34
+ return null;
35
+ }
36
+ const initiatorType = readRuntimeString(readRuntimeProperty(entry, "initiatorType"));
37
+ const nextHopProtocol = readRuntimeString(readRuntimeProperty(entry, "nextHopProtocol"));
38
+ const renderBlockingStatus = readRuntimeString(readRuntimeProperty(entry, "renderBlockingStatus"));
39
+ const responseStatus = readRuntimeNumber(readRuntimeProperty(entry, "responseStatus"));
40
+ const transferSize = readRuntimeNumber(readRuntimeProperty(entry, "transferSize"));
41
+ const absoluteStartMs = performance.timeOrigin + entry.startTime;
42
+ return {
43
+ durationMs: roundDuration(entry.duration),
44
+ initiatorType: entry.entryType === "navigation"
45
+ ? "navigation"
46
+ : readFiniteString(initiatorType) || "resource",
47
+ pageUrl: getCurrentPageUrl(),
48
+ protocol: readFiniteString(nextHopProtocol),
49
+ requestUrl: entry.name,
50
+ source: "resource",
51
+ startOffsetMs: clampOffset(absoluteStartMs - replayStartedAtMs),
52
+ statusCode: readFiniteNumber(responseStatus),
53
+ statusText: readFiniteString(renderBlockingStatus),
54
+ transferSize: readFiniteNumber(transferSize),
55
+ };
56
+ }
57
+ export function installReplayNetworkCapture({ debug, emitCustomEvent, ignoredUrlPrefixes = [], replayStartedAtMs, }) {
58
+ if (!emitCustomEvent || !globalThis.window) {
59
+ return () => undefined;
60
+ }
61
+ let emittedCount = 0;
62
+ let customEventAvailable = true;
63
+ const seenResources = new Set();
64
+ const cleanups = [];
65
+ const emit = (payload) => {
66
+ if (!customEventAvailable || emittedCount >= MAX_NETWORK_EVENTS) {
67
+ return;
68
+ }
69
+ const emitted = attempt({
70
+ try: () => {
71
+ emitCustomEvent(NETWORK_EVENT_TAG, payload);
72
+ return true;
73
+ },
74
+ catch: ({ error }) => {
75
+ customEventAvailable = false;
76
+ if (debug) {
77
+ console.warn("[squasher] Replay network event capture disabled", {
78
+ error: error.message,
79
+ });
80
+ }
81
+ return false;
82
+ },
83
+ });
84
+ if (!emitted)
85
+ return;
86
+ emittedCount += 1;
87
+ if (debug) {
88
+ console.log("[squasher] Replay network event recorded", {
89
+ requestUrl: payload.requestUrl,
90
+ source: payload.source,
91
+ statusCode: payload.statusCode ?? null,
92
+ });
93
+ }
94
+ };
95
+ if (globalThis.PerformanceObserver) {
96
+ const supportedEntryTypes = globalThis.PerformanceObserver.supportedEntryTypes ?? [];
97
+ const observer = new globalThis.PerformanceObserver((list) => {
98
+ for (const entry of list.getEntries()) {
99
+ const payload = buildResourcePayload(entry, replayStartedAtMs);
100
+ if (!payload) {
101
+ continue;
102
+ }
103
+ if (shouldIgnoreRequestUrl(payload.requestUrl, ignoredUrlPrefixes)) {
104
+ continue;
105
+ }
106
+ const absoluteStartMs = performance.timeOrigin + entry.startTime;
107
+ if (payload.initiatorType === "fetch" && absoluteStartMs >= replayStartedAtMs) {
108
+ continue;
109
+ }
110
+ const key = toResourceKey(payload);
111
+ if (seenResources.has(key)) {
112
+ continue;
113
+ }
114
+ seenResources.add(key);
115
+ emit(payload);
116
+ }
117
+ });
118
+ attempt({
119
+ try: () => {
120
+ if (supportedEntryTypes.includes("navigation")) {
121
+ observer.observe({ buffered: true, type: "navigation" });
122
+ }
123
+ if (supportedEntryTypes.includes("resource")) {
124
+ observer.observe({ buffered: true, type: "resource" });
125
+ }
126
+ cleanups.push(() => observer.disconnect());
127
+ },
128
+ catch: () => {
129
+ observer.disconnect();
130
+ },
131
+ });
132
+ }
133
+ if (window.fetch) {
134
+ const originalFetch = window.fetch.bind(window);
135
+ window.fetch = async (input, init) => {
136
+ const requestStartedAtMs = Date.now();
137
+ const inputIsRequest = globalThis.Request && input instanceof globalThis.Request;
138
+ const method = init?.method?.toUpperCase() || (inputIsRequest ? input.method.toUpperCase() : "GET");
139
+ const requestUrl = Object.prototype.toString.call(input) === "[object String]"
140
+ ? String(input)
141
+ : input instanceof URL
142
+ ? input.href
143
+ : inputIsRequest
144
+ ? input.url
145
+ : String(input);
146
+ const pageUrl = getCurrentPageUrl();
147
+ if (shouldIgnoreRequestUrl(requestUrl, ignoredUrlPrefixes)) {
148
+ return originalFetch(input, init);
149
+ }
150
+ return attemptAsync({
151
+ try: async () => {
152
+ const response = await originalFetch(input, init);
153
+ emit({
154
+ durationMs: roundDuration(Date.now() - requestStartedAtMs),
155
+ initiatorType: "fetch",
156
+ method,
157
+ pageUrl,
158
+ requestUrl,
159
+ source: "fetch",
160
+ startOffsetMs: clampOffset(requestStartedAtMs - replayStartedAtMs),
161
+ statusCode: response.status,
162
+ statusText: response.statusText || null,
163
+ transferSize: null,
164
+ });
165
+ return response;
166
+ },
167
+ catch: ({ cause, error }) => {
168
+ emit({
169
+ durationMs: roundDuration(Date.now() - requestStartedAtMs),
170
+ initiatorType: "fetch",
171
+ method,
172
+ pageUrl,
173
+ requestUrl,
174
+ source: "fetch",
175
+ startOffsetMs: clampOffset(requestStartedAtMs - replayStartedAtMs),
176
+ statusCode: null,
177
+ statusText: error.message || "network error",
178
+ transferSize: null,
179
+ });
180
+ throw cause;
181
+ },
182
+ });
183
+ };
184
+ cleanups.push(() => {
185
+ window.fetch = originalFetch;
186
+ });
187
+ }
188
+ return () => {
189
+ for (const cleanup of cleanups) {
190
+ cleanup();
191
+ }
192
+ };
193
+ }
@@ -1,5 +1,5 @@
1
- import type { ReplayPrivacyConfig } from "./types";
2
- type ReplayRecordFn = typeof import("rrweb")["record"];
1
+ import type { ReplayPrivacyConfig } from "./types.js";
2
+ type ReplayRecordFn = (typeof import("rrweb"))["record"];
3
3
  type ReplayRecordOptions = NonNullable<Parameters<ReplayRecordFn>[0]>;
4
4
  type ReplayEmit = NonNullable<ReplayRecordOptions["emit"]>;
5
5
  type ReplayEvent = Parameters<ReplayEmit>[0];
@@ -1 +1 @@
1
- {"version":3,"file":"replay-privacy.d.ts","sourceRoot":"","sources":["../src/replay-privacy.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,SAAS,CAAC;AAEnD,KAAK,cAAc,GAAG,cAAc,OAAO,CAAC,CAAC,QAAQ,CAAC,CAAC;AACvD,KAAK,mBAAmB,GAAG,WAAW,CAAC,UAAU,CAAC,cAAc,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;AACtE,KAAK,UAAU,GAAG,WAAW,CAAC,mBAAmB,CAAC,MAAM,CAAC,CAAC,CAAC;AAC3D,KAAK,WAAW,GAAG,UAAU,CAAC,UAAU,CAAC,CAAC,CAAC,CAAC,CAAC;AAc7C,MAAM,MAAM,0BAA0B,GAAG,mBAAmB,GAAG;IAC7D,WAAW,CAAC,EAAE,OAAO,CAAC;CACvB,CAAC;AAEF,eAAO,MAAM,sBAAsB,iBAAiB,CAAC;AAWrD,wBAAgB,wBAAwB,CAAC,KAAK,EAAE;IAC9C,IAAI,EAAE,UAAU,CAAC;IACjB,OAAO,CAAC,EAAE,mBAAmB,CAAC;CAC/B,GAAG,0BAA0B,CAgB7B;AAED,wBAAgB,uBAAuB,CAAC,CAAC,SAAS,WAAW,EAC3D,KAAK,EAAE,CAAC,EACR,OAAO,CAAC,EAAE,mBAAmB,GAC5B,CAAC,CASH"}
1
+ {"version":3,"file":"replay-privacy.d.ts","sourceRoot":"","sources":["../src/replay-privacy.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,YAAY,CAAC;AAEtD,KAAK,cAAc,GAAG,CAAC,cAAc,OAAO,CAAC,CAAC,CAAC,QAAQ,CAAC,CAAC;AACzD,KAAK,mBAAmB,GAAG,WAAW,CAAC,UAAU,CAAC,cAAc,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;AACtE,KAAK,UAAU,GAAG,WAAW,CAAC,mBAAmB,CAAC,MAAM,CAAC,CAAC,CAAC;AAC3D,KAAK,WAAW,GAAG,UAAU,CAAC,UAAU,CAAC,CAAC,CAAC,CAAC,CAAC;AAE7C,MAAM,MAAM,0BAA0B,GAAG,mBAAmB,GAAG;IAC7D,WAAW,CAAC,EAAE,OAAO,CAAC;CACvB,CAAC;AAEF,eAAO,MAAM,sBAAsB,iBAAiB,CAAC;AAkBrD,wBAAgB,wBAAwB,CAAC,KAAK,EAAE;IAC9C,IAAI,EAAE,UAAU,CAAC;IACjB,OAAO,CAAC,EAAE,mBAAmB,CAAC;CAC/B,GAAG,0BAA0B,CAiB7B;AAED,wBAAgB,uBAAuB,CAAC,CAAC,SAAS,WAAW,EAC3D,KAAK,EAAE,CAAC,EACR,OAAO,CAAC,EAAE,mBAAmB,GAC5B,CAAC,CAUH"}
@@ -7,9 +7,16 @@ const MASKED_TEXT_KEYS = new Set([
7
7
  "title",
8
8
  "value",
9
9
  ]);
10
+ // Force rrweb to emit a fresh Meta+FullSnapshot every minute. Without this,
11
+ // only the first segment of a session contains a snapshot, so any later
12
+ // segment loss (or seeking to a non-zero anchor) leaves the player with
13
+ // nothing to render. 60s matches Sentry's buffer-mode checkout cadence and
14
+ // keeps each segment independently seekable.
15
+ const CHECKOUT_EVERY_NMS = 60_000;
10
16
  export function buildReplayRecordOptions(input) {
11
17
  const options = {
12
18
  emit: input.emit,
19
+ checkoutEveryNms: CHECKOUT_EVERY_NMS,
13
20
  };
14
21
  if (input.privacy?.blockSelector) {
15
22
  options.blockSelector = input.privacy.blockSelector;
@@ -25,36 +32,59 @@ export function applyReplayEventPrivacy(event, privacy) {
25
32
  if (!privacy?.maskAllText) {
26
33
  return event;
27
34
  }
28
- return redactReplayValue(event, {
35
+ redactReplayValue(event, {
29
36
  currentTagName: null,
30
37
  key: "",
31
38
  });
39
+ return event;
32
40
  }
33
41
  function redactReplayValue(value, context) {
34
- if (typeof value === "string") {
35
- return shouldMaskString(context) ? maskReplayText(value) : value;
36
- }
37
42
  if (Array.isArray(value)) {
38
- return value.map((entry) => redactReplayValue(entry, context));
39
- }
40
- if (!isReplayPrivacyObject(value)) {
41
- return value;
43
+ value.forEach((entry, index) => {
44
+ if (isString(entry) && shouldMaskString(context)) {
45
+ value.splice(index, 1, maskReplayText(String(entry)));
46
+ }
47
+ else {
48
+ redactReplayValue(entry, context);
49
+ }
50
+ });
51
+ return;
42
52
  }
53
+ if (!isReplayRecord(value))
54
+ return;
43
55
  const nextTagName = readTagName(value) ?? context.currentTagName;
44
- const nextValue = {};
45
56
  for (const [entryKey, entryValue] of Object.entries(value)) {
46
- nextValue[entryKey] = redactReplayValue(entryValue, {
47
- currentTagName: nextTagName,
48
- key: entryKey,
49
- });
57
+ const nextContext = { currentTagName: nextTagName, key: entryKey };
58
+ if (isString(entryValue) && shouldMaskString(nextContext)) {
59
+ Object.defineProperty(value, entryKey, {
60
+ configurable: true,
61
+ enumerable: true,
62
+ value: maskReplayText(String(entryValue)),
63
+ writable: true,
64
+ });
65
+ }
66
+ else {
67
+ redactReplayValue(entryValue, nextContext);
68
+ }
69
+ }
70
+ }
71
+ function isReplayRecord(value) {
72
+ if (value === null)
73
+ return false;
74
+ try {
75
+ const prototype = Object.getPrototypeOf(Object(value));
76
+ return prototype === null || Object.getPrototypeOf(prototype) === null;
77
+ }
78
+ catch {
79
+ return false;
50
80
  }
51
- return nextValue;
52
81
  }
53
- function isReplayPrivacyObject(value) {
54
- return typeof value === "object" && value !== null && !Array.isArray(value);
82
+ function isString(value) {
83
+ return Object.prototype.toString.call(value) === "[object String]";
55
84
  }
56
85
  function readTagName(value) {
57
- return typeof value.tagName === "string" ? value.tagName.toLowerCase() : null;
86
+ const tagName = Object.getOwnPropertyDescriptor(Object(value), "tagName")?.value;
87
+ return isString(tagName) ? String(tagName).toLowerCase() : null;
58
88
  }
59
89
  function maskReplayText(value) {
60
90
  return value.replace(/[^\s]/g, "*");
@@ -0,0 +1,41 @@
1
+ interface ReplayUploadClient {
2
+ url: string;
3
+ headerKey: string;
4
+ debug: boolean;
5
+ }
6
+ /**
7
+ * In-memory retry queue for replay payloads.
8
+ *
9
+ * Semantics:
10
+ * - Enqueue a payload; `flush()` tries to send the oldest pending job.
11
+ * - Successful 2xx -> drop the job.
12
+ * - 4xx (except 429) -> drop the job (non-retryable client error).
13
+ * - 5xx, 429, or network error -> increment attempts; reschedule with
14
+ * exponential backoff (1s, 2s, 4s). After MAX_ATTEMPTS, the job is dropped.
15
+ * - Concurrency: a `sending` flag prevents two concurrent `flush` runs from
16
+ * double-sending the same head-of-queue job.
17
+ * - Queue length is capped at MAX_PENDING_PAYLOADS to bound memory.
18
+ */
19
+ export declare class ReplayUploader {
20
+ private readonly client;
21
+ private readonly queue;
22
+ private sending;
23
+ private activeController;
24
+ private retryTimer;
25
+ private disposed;
26
+ constructor(client: ReplayUploadClient);
27
+ enqueue(body: string): void;
28
+ /**
29
+ * Returns every payload body and clears the queue. The active fetch is
30
+ * aborted first so pagehide can transfer ownership of the head payload to
31
+ * sendBeacon instead of losing the initial replay snapshot on navigation.
32
+ */
33
+ drain(): string[];
34
+ private oldestDroppableIndex;
35
+ dispose(): void;
36
+ flush(): Promise<void>;
37
+ private scheduleRetry;
38
+ private send;
39
+ }
40
+ export {};
41
+ //# sourceMappingURL=replay-retry.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"replay-retry.d.ts","sourceRoot":"","sources":["../src/replay-retry.ts"],"names":[],"mappings":"AAmBA,UAAU,kBAAkB;IAC1B,GAAG,EAAE,MAAM,CAAC;IACZ,SAAS,EAAE,MAAM,CAAC;IAClB,KAAK,EAAE,OAAO,CAAC;CAChB;AAmBD;;;;;;;;;;;;GAYG;AACH,qBAAa,cAAc;IACzB,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAqB;IAC5C,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAyB;IAC/C,OAAO,CAAC,OAAO,CAAS;IACxB,OAAO,CAAC,gBAAgB,CAAgC;IACxD,OAAO,CAAC,UAAU,CAA8C;IAChE,OAAO,CAAC,QAAQ,CAAS;gBAEb,MAAM,EAAE,kBAAkB;IAItC,OAAO,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI;IAY3B;;;;OAIG;IACH,KAAK,IAAI,MAAM,EAAE;IAYjB,OAAO,CAAC,oBAAoB;IAI5B,OAAO,IAAI,IAAI;IAWT,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;IAkD5B,OAAO,CAAC,aAAa;YAQP,IAAI;CAwDnB"}
@@ -0,0 +1,193 @@
1
+ import { attemptAsync } from "@squasher-ai/result-utils";
2
+ import { getDefaultHeaders, nextBackoffMs } from "@squasher-ai/sdk-core";
3
+ const MAX_ATTEMPTS = 3;
4
+ const MAX_BACKOFF_MS = 4_000;
5
+ const MAX_PENDING_PAYLOADS = 10;
6
+ const SDK_NAME = "@squasher-ai/browser";
7
+ const SDK_VERSION = "0.2.0";
8
+ const DEFAULT_TELEMETRY_HEADERS = getDefaultHeaders({
9
+ packageName: SDK_NAME,
10
+ packageVersion: SDK_VERSION,
11
+ });
12
+ /**
13
+ * Compute next-attempt backoff. Delegates the math + Retry-After parsing to
14
+ * sdk-core's `nextBackoffMs` so every Squasher SDK shares one
15
+ * implementation. We keep the replay-specific 4-second cap because replay
16
+ * payloads are bursty and a longer wait throws off the page-unload window.
17
+ */
18
+ function backoffForAttempt(attempts, headers) {
19
+ // sdk-core uses 0-indexed attempts; this module uses 1-indexed (1st retry).
20
+ return Math.round(nextBackoffMs({
21
+ attempt: attempts - 1,
22
+ headers: headers ?? undefined,
23
+ maxSleepMs: MAX_BACKOFF_MS,
24
+ }));
25
+ }
26
+ /**
27
+ * In-memory retry queue for replay payloads.
28
+ *
29
+ * Semantics:
30
+ * - Enqueue a payload; `flush()` tries to send the oldest pending job.
31
+ * - Successful 2xx -> drop the job.
32
+ * - 4xx (except 429) -> drop the job (non-retryable client error).
33
+ * - 5xx, 429, or network error -> increment attempts; reschedule with
34
+ * exponential backoff (1s, 2s, 4s). After MAX_ATTEMPTS, the job is dropped.
35
+ * - Concurrency: a `sending` flag prevents two concurrent `flush` runs from
36
+ * double-sending the same head-of-queue job.
37
+ * - Queue length is capped at MAX_PENDING_PAYLOADS to bound memory.
38
+ */
39
+ export class ReplayUploader {
40
+ client;
41
+ queue = [];
42
+ sending = false;
43
+ activeController = null;
44
+ retryTimer = null;
45
+ disposed = false;
46
+ constructor(client) {
47
+ this.client = client;
48
+ }
49
+ enqueue(body) {
50
+ if (this.disposed)
51
+ return;
52
+ if (this.queue.length >= MAX_PENDING_PAYLOADS) {
53
+ if (this.client.debug) {
54
+ console.warn("[squasher] Replay retry queue full; dropping oldest payload");
55
+ }
56
+ this.queue.splice(this.oldestDroppableIndex(), 1);
57
+ }
58
+ this.queue.push({ body, attempts: 0 });
59
+ void this.flush();
60
+ }
61
+ /**
62
+ * Returns every payload body and clears the queue. The active fetch is
63
+ * aborted first so pagehide can transfer ownership of the head payload to
64
+ * sendBeacon instead of losing the initial replay snapshot on navigation.
65
+ */
66
+ drain() {
67
+ this.activeController?.abort();
68
+ this.activeController = null;
69
+ const bodies = this.queue.map((job) => job.body);
70
+ this.queue.length = 0;
71
+ if (this.retryTimer) {
72
+ clearTimeout(this.retryTimer);
73
+ this.retryTimer = null;
74
+ }
75
+ return bodies;
76
+ }
77
+ oldestDroppableIndex() {
78
+ return this.sending && this.queue.length > 1 ? 1 : 0;
79
+ }
80
+ dispose() {
81
+ this.disposed = true;
82
+ this.activeController?.abort();
83
+ this.activeController = null;
84
+ this.queue.length = 0;
85
+ if (this.retryTimer) {
86
+ clearTimeout(this.retryTimer);
87
+ this.retryTimer = null;
88
+ }
89
+ }
90
+ async flush() {
91
+ if (this.sending || this.disposed)
92
+ return;
93
+ if (this.queue.length === 0)
94
+ return;
95
+ if (this.retryTimer)
96
+ return; // waiting for scheduled retry
97
+ this.sending = true;
98
+ try {
99
+ const job = this.queue[0];
100
+ if (!job)
101
+ return;
102
+ const controller = new AbortController();
103
+ this.activeController = controller;
104
+ const outcome = await this.send(job.body, controller.signal);
105
+ if (this.activeController === controller) {
106
+ this.activeController = null;
107
+ }
108
+ if (this.queue[0] === job) {
109
+ if (outcome.kind === "success") {
110
+ this.queue.shift();
111
+ }
112
+ else if (outcome.kind === "drop") {
113
+ this.queue.shift();
114
+ if (this.client.debug) {
115
+ console.warn(`[squasher] Replay upload failed with non-retryable status; dropping payload (${outcome.reason})`);
116
+ }
117
+ }
118
+ else {
119
+ job.attempts += 1;
120
+ if (job.attempts >= MAX_ATTEMPTS) {
121
+ this.queue.shift();
122
+ if (this.client.debug) {
123
+ console.warn(`[squasher] Replay upload exhausted ${MAX_ATTEMPTS} attempts; dropping payload`);
124
+ }
125
+ }
126
+ else {
127
+ const delay = backoffForAttempt(job.attempts, outcome.retryHeaders);
128
+ this.scheduleRetry(delay);
129
+ return;
130
+ }
131
+ }
132
+ }
133
+ }
134
+ finally {
135
+ this.sending = false;
136
+ }
137
+ if (this.queue.length > 0 && !this.retryTimer) {
138
+ void this.flush();
139
+ }
140
+ }
141
+ scheduleRetry(delayMs) {
142
+ if (this.retryTimer)
143
+ return;
144
+ this.retryTimer = setTimeout(() => {
145
+ this.retryTimer = null;
146
+ void this.flush();
147
+ }, delayMs);
148
+ }
149
+ async send(body, signal) {
150
+ const result = await attemptAsync({
151
+ try: async () => {
152
+ const response = await fetch(this.client.url, {
153
+ method: "POST",
154
+ headers: {
155
+ ...DEFAULT_TELEMETRY_HEADERS,
156
+ "Content-Type": "application/json",
157
+ "x-squasher-key": this.client.headerKey,
158
+ },
159
+ body,
160
+ signal,
161
+ });
162
+ return response;
163
+ },
164
+ catch: ({ cause }) => {
165
+ if (this.client.debug) {
166
+ console.warn("[squasher] Replay upload network error:", cause);
167
+ }
168
+ return null;
169
+ },
170
+ });
171
+ if (!result) {
172
+ return { kind: "retry", retryHeaders: null };
173
+ }
174
+ if (result.status >= 200 && result.status < 300) {
175
+ if (this.client.debug) {
176
+ console.log("[squasher] Replay events sent via fetch");
177
+ }
178
+ return { kind: "success" };
179
+ }
180
+ if (result.status === 429) {
181
+ // Hand the response Headers straight to sdk-core's nextBackoffMs so it
182
+ // can read Retry-After-Ms or Retry-After (seconds or HTTP-date).
183
+ return {
184
+ kind: "retry",
185
+ retryHeaders: result.headers,
186
+ };
187
+ }
188
+ if (result.status >= 500) {
189
+ return { kind: "retry", retryHeaders: null };
190
+ }
191
+ return { kind: "drop", reason: `HTTP ${result.status}` };
192
+ }
193
+ }
package/dist/replay.d.ts CHANGED
@@ -1,10 +1,16 @@
1
- import type { ReplayPrivacyConfig } from "./types";
2
- interface ReplayRecorderConfig {
1
+ import { type ReplayRecorderRuntime } from "./runtime/replay.js";
2
+ import type { ReplayPrivacyConfig } from "./types.js";
3
+ export interface ReplayRecorderConfig {
3
4
  endpoint: string;
4
5
  projectId: string;
5
6
  apiKey: string;
6
7
  debug: boolean;
7
8
  sampleRate: number;
9
+ environment?: string;
10
+ release?: string;
11
+ getDistinctId?: () => string | undefined;
12
+ getUserId?: () => string | undefined;
13
+ getVisitorId?: () => string | undefined;
8
14
  privacy?: ReplayPrivacyConfig;
9
15
  }
10
16
  export declare class ReplayRecorder {
@@ -13,18 +19,25 @@ export declare class ReplayRecorder {
13
19
  private readonly startedAt;
14
20
  private readonly cleanups;
15
21
  private readonly config;
22
+ private readonly runtime;
16
23
  private buffer;
24
+ private bufferBytes;
17
25
  private flushTimer;
18
26
  private stopRecording;
19
27
  private disposed;
20
- constructor(config: ReplayRecorderConfig);
28
+ private readonly uploader;
29
+ constructor(config: ReplayRecorderConfig, runtime?: ReplayRecorderRuntime);
21
30
  dispose(): void;
22
31
  private start;
23
32
  private enqueue;
24
33
  private flushBufferedEvents;
34
+ /**
35
+ * Terminal flush during pagehide/beforeunload. Tries sendBeacon first for the
36
+ * current buffer and any pending retries, then falls back to keepalive fetch.
37
+ */
38
+ private finalFlush;
39
+ private buildPayloadBody;
25
40
  private buildReplayUrl;
26
41
  private trySendBeacon;
27
- private sendFetch;
28
42
  }
29
- export {};
30
43
  //# sourceMappingURL=replay.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"replay.d.ts","sourceRoot":"","sources":["../src/replay.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,SAAS,CAAC;AAWnD,UAAU,oBAAoB;IAC5B,QAAQ,EAAE,MAAM,CAAC;IACjB,SAAS,EAAE,MAAM,CAAC;IAClB,MAAM,EAAE,MAAM,CAAC;IACf,KAAK,EAAE,OAAO,CAAC;IACf,UAAU,EAAE,MAAM,CAAC;IACnB,OAAO,CAAC,EAAE,mBAAmB,CAAC;CAC/B;AAwBD,qBAAa,cAAc;IACzB,QAAQ,CAAC,KAAK,EAAE,OAAO,CAAC,IAAI,CAAC,CAAC;IAC9B,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAkB;IAC5C,OAAO,CAAC,QAAQ,CAAC,SAAS,CAA4B;IACtD,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAyB;IAClD,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAuB;IAC9C,OAAO,CAAC,MAAM,CAAqB;IACnC,OAAO,CAAC,UAAU,CAA+C;IACjE,OAAO,CAAC,aAAa,CAA8B;IACnD,OAAO,CAAC,QAAQ,CAAS;gBAEb,MAAM,EAAE,oBAAoB;IAcxC,OAAO,IAAI,IAAI;YAuBD,KAAK;IA8CnB,OAAO,CAAC,OAAO;IAUf,OAAO,CAAC,mBAAmB;IAwB3B,OAAO,CAAC,cAAc;IAQtB,OAAO,CAAC,aAAa;YAaP,SAAS;CAqBxB"}
1
+ {"version":3,"file":"replay.d.ts","sourceRoot":"","sources":["../src/replay.ts"],"names":[],"mappings":"AAGA,OAAO,EAEL,KAAK,qBAAqB,EAE3B,MAAM,qBAAqB,CAAC;AAE7B,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,YAAY,CAAC;AAgBtD,MAAM,WAAW,oBAAoB;IACnC,QAAQ,EAAE,MAAM,CAAC;IACjB,SAAS,EAAE,MAAM,CAAC;IAClB,MAAM,EAAE,MAAM,CAAC;IACf,KAAK,EAAE,OAAO,CAAC;IACf,UAAU,EAAE,MAAM,CAAC;IACnB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,aAAa,CAAC,EAAE,MAAM,MAAM,GAAG,SAAS,CAAC;IACzC,SAAS,CAAC,EAAE,MAAM,MAAM,GAAG,SAAS,CAAC;IACrC,YAAY,CAAC,EAAE,MAAM,MAAM,GAAG,SAAS,CAAC;IACxC,OAAO,CAAC,EAAE,mBAAmB,CAAC;CAC/B;AAyCD,qBAAa,cAAc;IACzB,QAAQ,CAAC,KAAK,EAAE,OAAO,CAAC,IAAI,CAAC,CAAC;IAC9B,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAkB;IAC5C,OAAO,CAAC,QAAQ,CAAC,SAAS,CAA4B;IACtD,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAyB;IAClD,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAuB;IAC9C,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAwB;IAChD,OAAO,CAAC,MAAM,CAAqB;IACnC,OAAO,CAAC,WAAW,CAAK;IACxB,OAAO,CAAC,UAAU,CAA+C;IACjE,OAAO,CAAC,aAAa,CAA8B;IACnD,OAAO,CAAC,QAAQ,CAAS;IACzB,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAiB;gBAE9B,MAAM,EAAE,oBAAoB,EAAE,OAAO,wBAA+B;IAoBhF,OAAO,IAAI,IAAI;YAwBD,KAAK;IA6DnB,OAAO,CAAC,OAAO;IAWf,OAAO,CAAC,mBAAmB;IAgB3B;;;OAGG;IACH,OAAO,CAAC,UAAU;IAoBlB,OAAO,CAAC,gBAAgB;IAexB,OAAO,CAAC,cAAc;IAQtB,OAAO,CAAC,aAAa;CAatB"}