@squasher-ai/browser 0.1.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 (63) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +12 -0
  3. package/dist/__tests__/client.test.d.ts +2 -0
  4. package/dist/__tests__/client.test.d.ts.map +1 -0
  5. package/dist/__tests__/client.test.js +103 -0
  6. package/dist/__tests__/errors.test.d.ts +2 -0
  7. package/dist/__tests__/errors.test.d.ts.map +1 -0
  8. package/dist/__tests__/errors.test.js +80 -0
  9. package/dist/__tests__/replay-privacy.test.d.ts +2 -0
  10. package/dist/__tests__/replay-privacy.test.d.ts.map +1 -0
  11. package/dist/__tests__/replay-privacy.test.js +124 -0
  12. package/dist/__tests__/replay-startup.test.d.ts +2 -0
  13. package/dist/__tests__/replay-startup.test.d.ts.map +1 -0
  14. package/dist/__tests__/replay-startup.test.js +99 -0
  15. package/dist/__tests__/replay.test.d.ts +2 -0
  16. package/dist/__tests__/replay.test.d.ts.map +1 -0
  17. package/dist/__tests__/replay.test.js +217 -0
  18. package/dist/__tests__/session.test.d.ts +2 -0
  19. package/dist/__tests__/session.test.d.ts.map +1 -0
  20. package/dist/__tests__/session.test.js +46 -0
  21. package/dist/__tests__/transport.test.d.ts +2 -0
  22. package/dist/__tests__/transport.test.d.ts.map +1 -0
  23. package/dist/__tests__/transport.test.js +101 -0
  24. package/dist/__tests__/types.test.d.ts +2 -0
  25. package/dist/__tests__/types.test.d.ts.map +1 -0
  26. package/dist/__tests__/types.test.js +139 -0
  27. package/dist/autocapture.d.ts +21 -0
  28. package/dist/autocapture.d.ts.map +1 -0
  29. package/dist/autocapture.js +191 -0
  30. package/dist/client.d.ts +46 -0
  31. package/dist/client.d.ts.map +1 -0
  32. package/dist/client.js +215 -0
  33. package/dist/errors.d.ts +29 -0
  34. package/dist/errors.d.ts.map +1 -0
  35. package/dist/errors.js +111 -0
  36. package/dist/index.d.ts +62 -0
  37. package/dist/index.d.ts.map +1 -0
  38. package/dist/index.js +105 -0
  39. package/dist/react.d.ts +32 -0
  40. package/dist/react.d.ts.map +1 -0
  41. package/dist/react.js +42 -0
  42. package/dist/replay-privacy.d.ts +16 -0
  43. package/dist/replay-privacy.d.ts.map +1 -0
  44. package/dist/replay-privacy.js +64 -0
  45. package/dist/replay.d.ts +30 -0
  46. package/dist/replay.d.ts.map +1 -0
  47. package/dist/replay.js +158 -0
  48. package/dist/session.d.ts +21 -0
  49. package/dist/session.d.ts.map +1 -0
  50. package/dist/session.js +52 -0
  51. package/dist/telemetry.d.ts +20 -0
  52. package/dist/telemetry.d.ts.map +1 -0
  53. package/dist/telemetry.js +145 -0
  54. package/dist/transport.d.ts +47 -0
  55. package/dist/transport.d.ts.map +1 -0
  56. package/dist/transport.js +145 -0
  57. package/dist/types.d.ts +234 -0
  58. package/dist/types.d.ts.map +1 -0
  59. package/dist/types.js +8 -0
  60. package/dist/vitals.d.ts +30 -0
  61. package/dist/vitals.d.ts.map +1 -0
  62. package/dist/vitals.js +92 -0
  63. package/package.json +44 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Squasher
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,12 @@
1
+ # @squasher-ai/browser
2
+
3
+ Official @squasher-ai/browser package for Squasher.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ npm install @squasher-ai/browser
9
+ ```
10
+
11
+ Docs: https://docs.squasher.ai
12
+ Homepage: https://squasher.ai
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=client.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"client.test.d.ts","sourceRoot":"","sources":["../../src/__tests__/client.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,103 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+ import { BrowserClient } from "../client";
3
+ import { resetSessionId } from "../session";
4
+ describe("BrowserClient", () => {
5
+ beforeEach(() => {
6
+ resetSessionId();
7
+ const storage = {};
8
+ vi.restoreAllMocks();
9
+ vi.stubGlobal("crypto", {
10
+ randomUUID: vi.fn().mockReturnValue("aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee"),
11
+ });
12
+ vi.stubGlobal("sessionStorage", {
13
+ getItem: (key) => storage[key] ?? null,
14
+ setItem: (key, value) => {
15
+ storage[key] = value;
16
+ },
17
+ removeItem: (key) => {
18
+ delete storage[key];
19
+ },
20
+ });
21
+ vi.stubGlobal("navigator", {
22
+ sendBeacon: vi.fn().mockReturnValue(true),
23
+ });
24
+ vi.stubGlobal("document", {
25
+ addEventListener: vi.fn(),
26
+ referrer: "https://referrer.example",
27
+ title: "Settings",
28
+ });
29
+ vi.stubGlobal("window", {
30
+ addEventListener: vi.fn(),
31
+ });
32
+ vi.stubGlobal("location", {
33
+ href: "https://example.com/settings",
34
+ pathname: "/settings",
35
+ search: "?tab=profile",
36
+ });
37
+ });
38
+ it("attaches the current session ID to captured error events", async () => {
39
+ const client = new BrowserClient({
40
+ apiKey: "sq_pk_test",
41
+ projectId: "project-123",
42
+ enableVitals: false,
43
+ enableErrorCapture: false,
44
+ enableAutoBreadcrumbs: false,
45
+ });
46
+ client.captureError(new Error("boom"));
47
+ const sendBeaconMock = vi.mocked(navigator.sendBeacon);
48
+ expect(sendBeaconMock).toHaveBeenCalledOnce();
49
+ const [url, payloadBlob] = sendBeaconMock.mock.calls[0] ?? [];
50
+ expect(url).toBe("https://ingest.squasher.ai/v1/ingest/project-123?key=sq_pk_test");
51
+ expect(payloadBlob).toBeInstanceOf(Blob);
52
+ if (!(payloadBlob instanceof Blob)) {
53
+ throw new Error("expected ingest payload to be a Blob");
54
+ }
55
+ const payload = JSON.parse(await payloadBlob.text());
56
+ expect(payload.session_id).toBe("aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee");
57
+ expect(payload.request?.url).toBe("https://example.com/settings");
58
+ client.close();
59
+ });
60
+ it("track sends analytics metadata with visitor context", async () => {
61
+ const client = new BrowserClient({
62
+ apiKey: "sq_pk_test",
63
+ projectId: "project-123",
64
+ enableVitals: false,
65
+ enableErrorCapture: false,
66
+ enableAutoBreadcrumbs: false,
67
+ });
68
+ client.track("checkout_started", { step: 1 });
69
+ const [_, payloadBlob] = vi.mocked(navigator.sendBeacon).mock.calls[0] ?? [];
70
+ if (!(payloadBlob instanceof Blob)) {
71
+ throw new Error("expected ingest payload to be a Blob");
72
+ }
73
+ const payload = JSON.parse(await payloadBlob.text());
74
+ expect(payload.kind).toBe("analytics");
75
+ expect(payload.event_name).toBe("checkout_started");
76
+ expect(payload.analytics?.event).toBe("checkout_started");
77
+ expect(payload.analytics?.properties?.step).toBe(1);
78
+ expect(payload.visitor?.anonymous_id).toBe("aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee");
79
+ client.close();
80
+ });
81
+ it("page sends normalized page metadata", async () => {
82
+ const client = new BrowserClient({
83
+ apiKey: "sq_pk_test",
84
+ projectId: "project-123",
85
+ enableVitals: false,
86
+ enableErrorCapture: false,
87
+ enableAutoBreadcrumbs: false,
88
+ });
89
+ client.page("Settings");
90
+ const [_, payloadBlob] = vi.mocked(navigator.sendBeacon).mock.calls[0] ?? [];
91
+ if (!(payloadBlob instanceof Blob)) {
92
+ throw new Error("expected ingest payload to be a Blob");
93
+ }
94
+ const payload = JSON.parse(await payloadBlob.text());
95
+ expect(payload.kind).toBe("page");
96
+ expect(payload.page?.name).toBe("Settings");
97
+ expect(payload.page?.path).toBe("/settings");
98
+ expect(payload.page?.title).toBe("Settings");
99
+ expect(payload.page?.referrer).toBe("https://referrer.example");
100
+ expect(payload.page?.search).toBe("?tab=profile");
101
+ client.close();
102
+ });
103
+ });
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=errors.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"errors.test.d.ts","sourceRoot":"","sources":["../../src/__tests__/errors.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,80 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { parseStack, buildErrorEvent, buildMessageEvent } from "../errors";
3
+ describe("parseStack", () => {
4
+ it("parses V8 stack traces", () => {
5
+ const stack = `Error: Something went wrong
6
+ at functionName (https://example.com/app.js:10:20)
7
+ at anotherFunction (https://example.com/utils.js:5:3)
8
+ at Object.<anonymous> (node_modules/some-lib/index.js:100:15)`;
9
+ const frames = parseStack(stack);
10
+ expect(frames).toBeDefined();
11
+ expect(frames).toHaveLength(3);
12
+ expect(frames[0]).toEqual({
13
+ function: "functionName",
14
+ filename: "https://example.com/app.js",
15
+ lineno: 10,
16
+ colno: 20,
17
+ in_app: true,
18
+ });
19
+ expect(frames[1]).toEqual({
20
+ function: "anotherFunction",
21
+ filename: "https://example.com/utils.js",
22
+ lineno: 5,
23
+ colno: 3,
24
+ in_app: true,
25
+ });
26
+ // node_modules should be marked as not in_app
27
+ expect(frames[2].in_app).toBe(false);
28
+ });
29
+ it("handles anonymous functions", () => {
30
+ const stack = `Error: test
31
+ at https://example.com/app.js:1:1`;
32
+ const frames = parseStack(stack);
33
+ expect(frames).toBeDefined();
34
+ expect(frames).toHaveLength(1);
35
+ expect(frames[0].function).toBe("<anonymous>");
36
+ });
37
+ it("returns undefined for empty stack", () => {
38
+ expect(parseStack(undefined)).toBeUndefined();
39
+ expect(parseStack("Error: test")).toBeUndefined();
40
+ });
41
+ it("marks chrome extensions as not in_app", () => {
42
+ const stack = `Error: ext
43
+ at handler (chrome-extension://abcdef/content.js:5:10)`;
44
+ const frames = parseStack(stack);
45
+ expect(frames).toBeDefined();
46
+ expect(frames[0].in_app).toBe(false);
47
+ });
48
+ });
49
+ describe("buildErrorEvent", () => {
50
+ it("builds a complete error event", () => {
51
+ const error = new Error("Test error");
52
+ error.name = "TypeError";
53
+ const event = buildErrorEvent(error, { extra: "data" });
54
+ expect(event.message).toBe("Test error");
55
+ expect(event.type).toBe("TypeError");
56
+ expect(event.level).toBe("error");
57
+ expect(event.sdk?.name).toBe("@squasher-ai/browser");
58
+ expect(event.sdk?.version).toBe("0.1.0");
59
+ expect(event.timestamp).toBeDefined();
60
+ expect(event.extra).toEqual({ extra: "data" });
61
+ expect(event.stack).toBeDefined();
62
+ });
63
+ it("builds event without extra", () => {
64
+ const error = new Error("Simple error");
65
+ const event = buildErrorEvent(error);
66
+ expect(event.extra).toBeUndefined();
67
+ });
68
+ });
69
+ describe("buildMessageEvent", () => {
70
+ it("builds a message event with default level", () => {
71
+ const event = buildMessageEvent("Hello");
72
+ expect(event.message).toBe("Hello");
73
+ expect(event.level).toBe("info");
74
+ expect(event.sdk?.name).toBe("@squasher-ai/browser");
75
+ });
76
+ it("builds a message event with custom level", () => {
77
+ const event = buildMessageEvent("Warning!", "warning");
78
+ expect(event.level).toBe("warning");
79
+ });
80
+ });
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=replay-privacy.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"replay-privacy.test.d.ts","sourceRoot":"","sources":["../../src/__tests__/replay-privacy.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,124 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { applyReplayEventPrivacy, buildReplayRecordOptions, MASK_ALL_TEXT_SELECTOR, } from "../replay-privacy";
3
+ describe("replay privacy helpers", () => {
4
+ it("builds rrweb options for text masking and blocked selectors", () => {
5
+ const emit = vi.fn();
6
+ expect(buildReplayRecordOptions({
7
+ emit,
8
+ privacy: {
9
+ blockSelector: ".pii-container",
10
+ maskAllText: true,
11
+ },
12
+ })).toMatchObject({
13
+ blockSelector: ".pii-container",
14
+ emit,
15
+ maskAllInputs: true,
16
+ maskAllText: true,
17
+ maskTextSelector: MASK_ALL_TEXT_SELECTOR,
18
+ });
19
+ });
20
+ it("redacts replay text fields without touching non-text attributes", () => {
21
+ const event = {
22
+ data: {
23
+ node: {
24
+ attributes: {
25
+ "aria-label": "Customer email",
26
+ placeholder: "you@example.com",
27
+ style: "color:#0f172a",
28
+ title: "Checkout form",
29
+ value: "Customer email",
30
+ },
31
+ childNodes: [
32
+ {
33
+ textContent: "Jane Example",
34
+ type: 3,
35
+ },
36
+ ],
37
+ tagName: "input",
38
+ type: 2,
39
+ },
40
+ source: 5,
41
+ text: "customer@example.com",
42
+ },
43
+ timestamp: 1,
44
+ type: 3,
45
+ };
46
+ const redactedEvent = applyReplayEventPrivacy(event, { maskAllText: true });
47
+ const serializedEvent = JSON.stringify(redactedEvent);
48
+ expect(redactedEvent).not.toBe(event);
49
+ expect(serializedEvent).not.toContain("Jane Example");
50
+ expect(serializedEvent).not.toContain("you@example.com");
51
+ expect(serializedEvent).not.toContain("customer@example.com");
52
+ expect(serializedEvent).not.toContain("Checkout form");
53
+ expect(serializedEvent).toContain("color:#0f172a");
54
+ expect(serializedEvent).toContain("************");
55
+ expect(event.data.text).toBe("customer@example.com");
56
+ });
57
+ it("redacts inline style and script text alongside visible text nodes", () => {
58
+ const event = {
59
+ data: {
60
+ node: {
61
+ childNodes: [
62
+ {
63
+ childNodes: [
64
+ {
65
+ childNodes: [
66
+ {
67
+ tagName: "style",
68
+ childNodes: [
69
+ {
70
+ textContent: "body{background:#0f172a;color:#e2e8f0;}",
71
+ type: 3,
72
+ },
73
+ ],
74
+ type: 2,
75
+ },
76
+ {
77
+ tagName: "script",
78
+ childNodes: [
79
+ {
80
+ textContent: 'window.__BOOTSTRAP__={email:"jane@example.com"};',
81
+ type: 3,
82
+ },
83
+ ],
84
+ type: 2,
85
+ },
86
+ {
87
+ childNodes: [
88
+ {
89
+ textContent: "Private checkout summary",
90
+ type: 3,
91
+ },
92
+ ],
93
+ tagName: "h1",
94
+ type: 2,
95
+ },
96
+ ],
97
+ tagName: "body",
98
+ type: 2,
99
+ },
100
+ ],
101
+ tagName: "html",
102
+ type: 2,
103
+ },
104
+ ],
105
+ type: 0,
106
+ },
107
+ },
108
+ timestamp: 1,
109
+ type: 2,
110
+ };
111
+ const redactedEvent = applyReplayEventPrivacy(event, { maskAllText: true });
112
+ const serializedEvent = JSON.stringify(redactedEvent);
113
+ expect(serializedEvent).not.toContain("body{background:#0f172a;color:#e2e8f0;}");
114
+ expect(serializedEvent).not.toContain('window.__BOOTSTRAP__={email:"jane@example.com"};');
115
+ expect(serializedEvent).not.toContain("Private checkout summary");
116
+ expect(serializedEvent).toContain("******* ******** *******");
117
+ expect(serializedEvent).toMatch(/\*{10,}/);
118
+ });
119
+ it("returns the original replay event when text masking is disabled", () => {
120
+ const event = { data: { text: "customer@example.com" }, timestamp: 1, type: 3 };
121
+ expect(applyReplayEventPrivacy(event, { maskAllText: false })).toBe(event);
122
+ expect(applyReplayEventPrivacy(event)).toBe(event);
123
+ });
124
+ });
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=replay-startup.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"replay-startup.test.d.ts","sourceRoot":"","sources":["../../src/__tests__/replay-startup.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,99 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+ import { ReplayRecorder } from "../replay";
3
+ import { resetSessionId } from "../session";
4
+ const rrwebRecordMock = vi.hoisted(() => vi.fn());
5
+ const rrwebStopMock = vi.hoisted(() => vi.fn());
6
+ vi.mock("rrweb", () => ({
7
+ record: rrwebRecordMock,
8
+ }));
9
+ function createReplayEvent(type, timestamp) {
10
+ return {
11
+ type,
12
+ timestamp,
13
+ data: {
14
+ source: type,
15
+ },
16
+ };
17
+ }
18
+ describe("ReplayRecorder startup behavior", () => {
19
+ let emitReplayEvent;
20
+ beforeEach(() => {
21
+ resetSessionId();
22
+ emitReplayEvent = undefined;
23
+ vi.useFakeTimers();
24
+ vi.restoreAllMocks();
25
+ const storage = {};
26
+ vi.stubGlobal("crypto", {
27
+ randomUUID: vi.fn().mockReturnValue("11111111-2222-4333-8444-555555555555"),
28
+ });
29
+ vi.stubGlobal("sessionStorage", {
30
+ getItem: (key) => storage[key] ?? null,
31
+ setItem: (key, value) => {
32
+ storage[key] = value;
33
+ },
34
+ removeItem: (key) => {
35
+ delete storage[key];
36
+ },
37
+ });
38
+ vi.stubGlobal("location", {
39
+ href: "https://example.com/dashboard",
40
+ });
41
+ vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: true }));
42
+ vi.stubGlobal("navigator", {
43
+ sendBeacon: vi.fn().mockReturnValue(true),
44
+ });
45
+ vi.stubGlobal("document", {});
46
+ vi.stubGlobal("window", {
47
+ addEventListener: vi.fn(),
48
+ removeEventListener: vi.fn(),
49
+ });
50
+ rrwebStopMock.mockReset();
51
+ rrwebRecordMock.mockReset();
52
+ rrwebRecordMock.mockImplementation((options) => {
53
+ emitReplayEvent = options?.emit;
54
+ return rrwebStopMock;
55
+ });
56
+ });
57
+ afterEach(() => {
58
+ vi.useRealTimers();
59
+ vi.unstubAllGlobals();
60
+ });
61
+ it("preserves rrweb bootstrap events emitted before record() returns", async () => {
62
+ rrwebRecordMock.mockImplementation((options) => {
63
+ options?.emit?.(createReplayEvent(4, 1));
64
+ options?.emit?.(createReplayEvent(2, 2));
65
+ emitReplayEvent = options?.emit;
66
+ return rrwebStopMock;
67
+ });
68
+ const recorder = new ReplayRecorder({
69
+ endpoint: "https://ingest.squasher.ai",
70
+ projectId: "project-123",
71
+ apiKey: "sq_pk_test",
72
+ debug: false,
73
+ sampleRate: 1,
74
+ });
75
+ await recorder.ready;
76
+ emitReplayEvent?.(createReplayEvent(3, 3));
77
+ vi.advanceTimersByTime(5_000);
78
+ const requestBody = vi.mocked(fetch).mock.calls[0]?.[1]?.body;
79
+ expect(typeof requestBody).toBe("string");
80
+ if (typeof requestBody !== "string") {
81
+ throw new Error("expected replay request body to be a string");
82
+ }
83
+ const payload = JSON.parse(requestBody);
84
+ expect(payload.events.map((event) => event.type)).toEqual([4, 2, 3]);
85
+ recorder.dispose();
86
+ });
87
+ it("skips recording when the replay sample rate drops the session", () => {
88
+ vi.spyOn(Math, "random").mockReturnValue(0.9);
89
+ const recorder = new ReplayRecorder({
90
+ endpoint: "https://ingest.squasher.ai",
91
+ projectId: "project-123",
92
+ apiKey: "sq_pk_test",
93
+ debug: false,
94
+ sampleRate: 0.5,
95
+ });
96
+ expect(rrwebRecordMock).not.toHaveBeenCalled();
97
+ recorder.dispose();
98
+ });
99
+ });
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=replay.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"replay.test.d.ts","sourceRoot":"","sources":["../../src/__tests__/replay.test.ts"],"names":[],"mappings":""}