@squasher-ai/browser 0.1.1 → 0.3.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 (109) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +3 -42
  3. package/dist/_vendor/result-runtime/attempt.d.ts +17 -0
  4. package/dist/_vendor/result-runtime/attempt.js +62 -0
  5. package/dist/_vendor/sdk-runtime/batching/index.d.ts +42 -0
  6. package/dist/_vendor/sdk-runtime/batching/index.js +48 -0
  7. package/dist/_vendor/sdk-runtime/errors/headers.d.ts +6 -0
  8. package/dist/_vendor/sdk-runtime/errors/headers.js +44 -0
  9. package/dist/_vendor/sdk-runtime/errors/index.d.ts +95 -0
  10. package/dist/_vendor/sdk-runtime/errors/index.js +157 -0
  11. package/dist/_vendor/sdk-runtime/headers.d.ts +48 -0
  12. package/dist/_vendor/sdk-runtime/headers.js +67 -0
  13. package/dist/_vendor/sdk-runtime/platform.d.ts +33 -0
  14. package/dist/_vendor/sdk-runtime/platform.js +173 -0
  15. package/dist/_vendor/sdk-runtime/retry.d.ts +50 -0
  16. package/dist/_vendor/sdk-runtime/retry.js +104 -0
  17. package/dist/_vendor/sdk-runtime/runtime/actionable-error.d.ts +37 -0
  18. package/dist/_vendor/sdk-runtime/runtime/actionable-error.js +123 -0
  19. package/dist/_vendor/sdk-runtime/runtime/environment.d.ts +18 -0
  20. package/dist/_vendor/sdk-runtime/runtime/environment.js +81 -0
  21. package/dist/_vendor/sdk-runtime/runtime/public-sdk-runtime.d.ts +12 -0
  22. package/dist/_vendor/sdk-runtime/runtime/public-sdk-runtime.js +38 -0
  23. package/dist/_vendor/sdk-runtime/runtime/redaction.d.ts +14 -0
  24. package/dist/_vendor/sdk-runtime/runtime/redaction.js +120 -0
  25. package/dist/_vendor/sdk-runtime/runtime/release.d.ts +35 -0
  26. package/dist/_vendor/sdk-runtime/runtime/release.js +120 -0
  27. package/dist/_vendor/sdk-runtime/sampling/index.d.ts +43 -0
  28. package/dist/_vendor/sdk-runtime/sampling/index.js +70 -0
  29. package/dist/_vendor/telemetry-contract/sdk/public-contract.d.ts +175 -0
  30. package/dist/_vendor/telemetry-contract/sdk/public-contract.js +5 -0
  31. package/dist/autocapture.d.ts +14 -19
  32. package/dist/autocapture.js +77 -95
  33. package/dist/client.d.ts +12 -20
  34. package/dist/client.js +56 -72
  35. package/dist/config.d.ts +24 -0
  36. package/dist/config.js +26 -0
  37. package/dist/console-capture.d.ts +7 -0
  38. package/dist/console-capture.js +79 -0
  39. package/dist/errors.d.ts +2 -3
  40. package/dist/errors.js +8 -5
  41. package/dist/index.d.ts +5 -39
  42. package/dist/index.js +4 -82
  43. package/dist/next.d.ts +39 -0
  44. package/dist/next.js +67 -0
  45. package/dist/react.d.ts +0 -1
  46. package/dist/react.js +12 -10
  47. package/dist/replay-network.d.ts +20 -0
  48. package/dist/replay-network.js +193 -0
  49. package/dist/replay-privacy.d.ts +2 -3
  50. package/dist/replay-privacy.js +47 -17
  51. package/dist/replay-retry.d.ts +40 -0
  52. package/dist/replay-retry.js +193 -0
  53. package/dist/replay.d.ts +18 -6
  54. package/dist/replay.js +131 -71
  55. package/dist/runtime/client-singleton.d.ts +24 -0
  56. package/dist/runtime/client-singleton.js +66 -0
  57. package/dist/runtime/client.d.ts +21 -0
  58. package/dist/runtime/client.js +12 -0
  59. package/dist/runtime/replay.d.ts +12 -0
  60. package/dist/runtime/replay.js +15 -0
  61. package/dist/session.d.ts +0 -11
  62. package/dist/session.js +20 -21
  63. package/dist/telemetry.d.ts +1 -2
  64. package/dist/telemetry.js +13 -10
  65. package/dist/trace-context.d.ts +8 -0
  66. package/dist/trace-context.js +13 -0
  67. package/dist/transport.d.ts +11 -20
  68. package/dist/transport.js +86 -66
  69. package/dist/types.d.ts +38 -139
  70. package/dist/types.js +0 -7
  71. package/dist/vitals.d.ts +55 -3
  72. package/dist/vitals.js +115 -16
  73. package/package.json +11 -3
  74. package/dist/__tests__/client.test.d.ts +0 -2
  75. package/dist/__tests__/client.test.d.ts.map +0 -1
  76. package/dist/__tests__/client.test.js +0 -103
  77. package/dist/__tests__/errors.test.d.ts +0 -2
  78. package/dist/__tests__/errors.test.d.ts.map +0 -1
  79. package/dist/__tests__/errors.test.js +0 -80
  80. package/dist/__tests__/replay-privacy.test.d.ts +0 -2
  81. package/dist/__tests__/replay-privacy.test.d.ts.map +0 -1
  82. package/dist/__tests__/replay-privacy.test.js +0 -124
  83. package/dist/__tests__/replay-startup.test.d.ts +0 -2
  84. package/dist/__tests__/replay-startup.test.d.ts.map +0 -1
  85. package/dist/__tests__/replay-startup.test.js +0 -99
  86. package/dist/__tests__/replay.test.d.ts +0 -2
  87. package/dist/__tests__/replay.test.d.ts.map +0 -1
  88. package/dist/__tests__/replay.test.js +0 -217
  89. package/dist/__tests__/session.test.d.ts +0 -2
  90. package/dist/__tests__/session.test.d.ts.map +0 -1
  91. package/dist/__tests__/session.test.js +0 -46
  92. package/dist/__tests__/transport.test.d.ts +0 -2
  93. package/dist/__tests__/transport.test.d.ts.map +0 -1
  94. package/dist/__tests__/transport.test.js +0 -101
  95. package/dist/__tests__/types.test.d.ts +0 -2
  96. package/dist/__tests__/types.test.d.ts.map +0 -1
  97. package/dist/__tests__/types.test.js +0 -139
  98. package/dist/autocapture.d.ts.map +0 -1
  99. package/dist/client.d.ts.map +0 -1
  100. package/dist/errors.d.ts.map +0 -1
  101. package/dist/index.d.ts.map +0 -1
  102. package/dist/react.d.ts.map +0 -1
  103. package/dist/replay-privacy.d.ts.map +0 -1
  104. package/dist/replay.d.ts.map +0 -1
  105. package/dist/session.d.ts.map +0 -1
  106. package/dist/telemetry.d.ts.map +0 -1
  107. package/dist/transport.d.ts.map +0 -1
  108. package/dist/types.d.ts.map +0 -1
  109. package/dist/vitals.d.ts.map +0 -1
package/dist/next.js ADDED
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Next.js Web Vitals bridge for @squasher-ai/browser.
3
+ *
4
+ * Drop `<SquasherNextWebVitals />` into your root layout to capture both
5
+ * Core Web Vitals (LCP, CLS, INP, FCP, TTFB) and Next.js framework metrics
6
+ * (`Next.js-hydration`, `Next.js-route-change-to-render`, `Next.js-render`).
7
+ *
8
+ * The component hard-imports `next/web-vitals` and `next/navigation` so it
9
+ * is **only** valid in a Next.js app. Non-Next consumers should rely on the
10
+ * auto-collector that runs from `init()` and not import this module.
11
+ *
12
+ * Internally it bridges Next's `useReportWebVitals` hook into the existing
13
+ * browser client transport, suppressing the SDK's native `web-vitals`
14
+ * collector on mount so Core metrics aren't reported twice.
15
+ *
16
+ * Usage:
17
+ * import { SquasherNextWebVitals } from '@squasher-ai/browser/next';
18
+ *
19
+ * export default function RootLayout({ children }) {
20
+ * return (
21
+ * <html>
22
+ * <body>
23
+ * <SquasherNextWebVitals />
24
+ * {children}
25
+ * </body>
26
+ * </html>
27
+ * );
28
+ * }
29
+ */
30
+ "use client";
31
+ import { usePathname } from "next/navigation";
32
+ import { useReportWebVitals } from "next/web-vitals";
33
+ import { useCallback, useEffect, useRef } from "react";
34
+ import { getClient } from "./runtime/client-singleton.js";
35
+ const SUPPORTED_WEB_VITALS = new Set(["CLS", "FCP", "INP", "LCP", "TTFB"]);
36
+ function isSupportedWebVital(name) {
37
+ return SUPPORTED_WEB_VITALS.has(name);
38
+ }
39
+ export function SquasherNextWebVitals({ path } = {}) {
40
+ const pathname = usePathname();
41
+ const currentPath = path ?? pathname ?? "/";
42
+ const stabilizedPath = useRef(currentPath);
43
+ useEffect(() => {
44
+ stabilizedPath.current = currentPath;
45
+ }, [currentPath]);
46
+ useEffect(() => {
47
+ try {
48
+ getClient().suppressNativeVitalsCollection();
49
+ }
50
+ catch {
51
+ // Squasher SDK not initialized — nothing to suppress. Bridge still
52
+ // runs so the host can call init() asynchronously.
53
+ }
54
+ }, []);
55
+ const report = useCallback((metric) => {
56
+ if (!isSupportedWebVital(metric.name))
57
+ return;
58
+ try {
59
+ getClient().recordWebVital(metric, stabilizedPath.current);
60
+ }
61
+ catch {
62
+ // SDK not yet initialized; drop this metric rather than throw.
63
+ }
64
+ }, []);
65
+ useReportWebVitals(report);
66
+ return null;
67
+ }
package/dist/react.d.ts CHANGED
@@ -29,4 +29,3 @@ export declare class SquasherErrorBoundary extends Component<Props, State> {
29
29
  render(): ReactNode;
30
30
  }
31
31
  export {};
32
- //# sourceMappingURL=react.d.ts.map
package/dist/react.js CHANGED
@@ -12,7 +12,8 @@
12
12
  * </SquasherErrorBoundary>
13
13
  */
14
14
  import { Component } from "react";
15
- import { getClient } from "./index";
15
+ import { attempt } from "./_vendor/result-runtime/attempt.js";
16
+ import { getClient } from "./runtime/client-singleton.js";
16
17
  export class SquasherErrorBoundary extends Component {
17
18
  constructor(props) {
18
19
  super(props);
@@ -22,15 +23,16 @@ export class SquasherErrorBoundary extends Component {
22
23
  return { hasError: true };
23
24
  }
24
25
  componentDidCatch(error, errorInfo) {
25
- try {
26
- const client = getClient();
27
- client.captureError(error, {
28
- componentStack: errorInfo.componentStack ?? undefined,
29
- });
30
- }
31
- catch {
32
- // SDK not initialized — silently ignore
33
- }
26
+ attempt({
27
+ try: () => {
28
+ const client = getClient();
29
+ const extra = {};
30
+ if (errorInfo.componentStack)
31
+ extra.componentStack = errorInfo.componentStack;
32
+ client.captureError(error, extra);
33
+ },
34
+ catch: () => undefined,
35
+ });
34
36
  this.props.onError?.(error, errorInfo);
35
37
  }
36
38
  render() {
@@ -0,0 +1,20 @@
1
+ export interface ReplayNetworkEventPayload {
2
+ durationMs: number;
3
+ initiatorType: string;
4
+ method?: string;
5
+ pageUrl: string;
6
+ protocol?: string | null;
7
+ requestUrl: string;
8
+ source: "fetch" | "resource";
9
+ startOffsetMs: number;
10
+ statusCode?: number | null;
11
+ statusText?: string | null;
12
+ transferSize?: number | null;
13
+ }
14
+ export interface ReplayNetworkCaptureOptions {
15
+ debug: boolean;
16
+ emitCustomEvent?: (tag: string, payload: ReplayNetworkEventPayload) => void;
17
+ ignoredUrlPrefixes?: string[];
18
+ replayStartedAtMs: number;
19
+ }
20
+ export declare function installReplayNetworkCapture({ debug, emitCustomEvent, ignoredUrlPrefixes, replayStartedAtMs, }: ReplayNetworkCaptureOptions): () => void;
@@ -0,0 +1,193 @@
1
+ import { attempt, attemptAsync } from "./_vendor/result-runtime/attempt.js";
2
+ import { readRuntimeNumber, readRuntimeProperty, readRuntimeString } from "./_vendor/sdk-runtime/runtime/public-sdk-runtime.js";
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];
@@ -13,4 +13,3 @@ export declare function buildReplayRecordOptions(input: {
13
13
  }): ReplayPrivacyRecordOptions;
14
14
  export declare function applyReplayEventPrivacy<T extends ReplayEvent>(event: T, privacy?: ReplayPrivacyConfig): T;
15
15
  export {};
16
- //# sourceMappingURL=replay-privacy.d.ts.map
@@ -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,40 @@
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 {};
@@ -0,0 +1,193 @@
1
+ import { attemptAsync } from "./_vendor/result-runtime/attempt.js";
2
+ import { getDefaultHeaders, nextBackoffMs } from "./_vendor/sdk-runtime/runtime/public-sdk-runtime.js";
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.3.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
+ }