@heyramzi/utils 0.0.0-stage → 1.12.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 (73) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +112 -2
  3. package/package.json +103 -4
  4. package/src/ai/image-agnes.test.ts +130 -0
  5. package/src/ai/image.ts +435 -0
  6. package/src/ai/models.test.ts +98 -0
  7. package/src/ai/models.ts +450 -0
  8. package/src/analytics/client.test.ts +149 -0
  9. package/src/analytics/client.ts +129 -0
  10. package/src/analytics/server.test.ts +165 -0
  11. package/src/analytics/server.ts +88 -0
  12. package/src/analytics/svelte.test.ts +105 -0
  13. package/src/analytics/svelte.ts +58 -0
  14. package/src/analytics/types.ts +26 -0
  15. package/src/analytics/umami/protocol.test.ts +18 -0
  16. package/src/analytics/umami/protocol.ts +104 -0
  17. package/src/analytics/umami/reporting.test.ts +391 -0
  18. package/src/analytics/umami/reporting.ts +627 -0
  19. package/src/auth/config/otp.ts +8 -0
  20. package/src/auth/config/rate-limits.ts +11 -0
  21. package/src/auth/config/trusted-origins.ts +32 -0
  22. package/src/auth/email/index.ts +7 -0
  23. package/src/auth/email/layout.test.ts +73 -0
  24. package/src/auth/email/layout.ts +122 -0
  25. package/src/auth/email/resend.test.ts +35 -0
  26. package/src/auth/email/resend.ts +26 -0
  27. package/src/auth/email/template.ts +77 -0
  28. package/src/auth/email/types.ts +74 -0
  29. package/src/auth/index.ts +5 -0
  30. package/src/cache.test.ts +67 -0
  31. package/src/cache.ts +5 -0
  32. package/src/cn.test.ts +52 -0
  33. package/src/cn.ts +16 -0
  34. package/src/collection.test.ts +66 -0
  35. package/src/collection.ts +8 -0
  36. package/src/crypto.test.ts +86 -0
  37. package/src/crypto.ts +157 -0
  38. package/src/error.test.ts +79 -0
  39. package/src/error.ts +16 -0
  40. package/src/errors/sentry/index.ts +20 -0
  41. package/src/errors/sentry/protocol.test.ts +85 -0
  42. package/src/errors/sentry/protocol.ts +144 -0
  43. package/src/errors/sentry/sveltekit.test.ts +88 -0
  44. package/src/errors/sentry/sveltekit.ts +188 -0
  45. package/src/format.test.ts +225 -0
  46. package/src/format.ts +103 -0
  47. package/src/grab.d.ts +2 -0
  48. package/src/grab.js +112 -0
  49. package/src/index.ts +38 -0
  50. package/src/logger-types.ts +14 -0
  51. package/src/logger.ts +24 -0
  52. package/src/math.test.ts +41 -0
  53. package/src/math.ts +3 -0
  54. package/src/og/fonts.ts +69 -0
  55. package/src/og/index.ts +5 -0
  56. package/src/og/markup.test.ts +73 -0
  57. package/src/og/markup.ts +90 -0
  58. package/src/og/renderer.ts +42 -0
  59. package/src/og/types.ts +26 -0
  60. package/src/og/wawoff2.d.ts +6 -0
  61. package/src/package-imports.test.mjs +22 -0
  62. package/src/result.test.ts +149 -0
  63. package/src/result.ts +29 -0
  64. package/src/string.test.ts +205 -0
  65. package/src/string.ts +71 -0
  66. package/src/testimonials/render-content.ts +21 -0
  67. package/src/testimonials/server.ts +47 -0
  68. package/src/testimonials/types.ts +35 -0
  69. package/src/timing.test.ts +166 -0
  70. package/src/timing.ts +28 -0
  71. package/src/vite.d.ts +12 -0
  72. package/src/vite.js +265 -0
  73. package/src/vite.test.js +235 -0
@@ -0,0 +1,86 @@
1
+ import { afterEach, beforeEach, describe, expect, it } from "vite-plus/test";
2
+ import {
3
+ decryptSecret,
4
+ decryptSecretWithKey,
5
+ encryptSecret,
6
+ encryptSecretWithKey,
7
+ packSecret,
8
+ unpackSecret,
9
+ } from "#src/crypto.ts";
10
+
11
+ const KEY_ENV = "HEYRAMZI_UTILS_CRYPTO_KEY";
12
+ const TEST_KEY = "MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=";
13
+
14
+ describe("secret crypto helpers", () => {
15
+ const originalKey = process.env[KEY_ENV];
16
+
17
+ beforeEach(() => {
18
+ process.env[KEY_ENV] = TEST_KEY;
19
+ });
20
+
21
+ afterEach(() => {
22
+ if (originalKey === undefined) {
23
+ delete process.env[KEY_ENV];
24
+ return;
25
+ }
26
+
27
+ process.env[KEY_ENV] = originalKey;
28
+ });
29
+
30
+ it("round-trips plaintext through encrypt and decrypt", () => {
31
+ const encrypted = encryptSecret("oauth-refresh-token");
32
+
33
+ expect(decryptSecret(encrypted.ciphertext, encrypted.iv)).toBe("oauth-refresh-token");
34
+ });
35
+
36
+ it("round-trips empty plaintext", () => {
37
+ const encrypted = encryptSecret("");
38
+
39
+ expect(decryptSecret(encrypted.ciphertext, encrypted.iv)).toBe("");
40
+ });
41
+
42
+ it("produces different ciphertext for the same plaintext because the IV is random", () => {
43
+ const first = encryptSecret("shared-secret");
44
+ const second = encryptSecret("shared-secret");
45
+
46
+ expect(first.iv).not.toBe(second.iv);
47
+ expect(first.ciphertext).not.toBe(second.ciphertext);
48
+ });
49
+
50
+ it("fails to decrypt a tampered ciphertext", () => {
51
+ const encrypted = encryptSecret("top-secret");
52
+ const tamperedCiphertext = `${encrypted.ciphertext.slice(0, -1)}A`;
53
+
54
+ expect(() => decryptSecret(tamperedCiphertext, encrypted.iv)).toThrow(
55
+ "Failed to decrypt secret",
56
+ );
57
+ });
58
+
59
+ it("preserves Unicode plaintext", () => {
60
+ const plaintext = "🔐 café OAuth 令牌";
61
+ const encrypted = encryptSecret(plaintext);
62
+
63
+ expect(decryptSecret(encrypted.ciphertext, encrypted.iv)).toBe(plaintext);
64
+ });
65
+
66
+ it("supports direct encryption and decryption with an explicit key", () => {
67
+ const encrypted = encryptSecretWithKey("api-key", TEST_KEY);
68
+
69
+ expect(decryptSecretWithKey(encrypted.ciphertext, encrypted.iv, TEST_KEY)).toBe("api-key");
70
+ });
71
+
72
+ it("packs and unpacks ciphertext for single-field storage", () => {
73
+ const encrypted = encryptSecret("legacy-token");
74
+ const packed = packSecret(encrypted);
75
+ const unpacked = unpackSecret(packed);
76
+
77
+ expect(unpacked).toEqual(encrypted);
78
+ expect(decryptSecret(unpacked.ciphertext, unpacked.iv)).toBe("legacy-token");
79
+ });
80
+
81
+ it("throws a clear error when the encryption key is missing", () => {
82
+ delete process.env[KEY_ENV];
83
+
84
+ expect(() => encryptSecret("missing-key")).toThrow(`Missing ${KEY_ENV}`);
85
+ });
86
+ });
package/src/crypto.ts ADDED
@@ -0,0 +1,157 @@
1
+ import { Buffer } from "node:buffer";
2
+ import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto";
3
+
4
+ const ALGORITHM = "aes-256-gcm";
5
+ const AUTH_TAG_BYTES = 16;
6
+ const IV_BYTES = 12;
7
+ const KEY_BYTES = 32;
8
+ const KEY_ENV = "HEYRAMZI_UTILS_CRYPTO_KEY";
9
+ const BASE64_PATTERN = /^[A-Za-z0-9+/]+={0,2}$/;
10
+ const HEX_PATTERN = /^[0-9a-fA-F]{64}$/;
11
+
12
+ export type SecretCiphertext = {
13
+ ciphertext: string;
14
+ iv: string;
15
+ };
16
+
17
+ function ensureServerEnv(): Record<string, string | undefined> {
18
+ const runtimeProcess = (globalThis as { process?: { env?: Record<string, string | undefined> } })
19
+ .process;
20
+
21
+ if (!runtimeProcess?.env) {
22
+ throw new Error("[crypto] process.env is unavailable. @heyramzi/utils/crypto is server-only.");
23
+ }
24
+
25
+ return runtimeProcess.env;
26
+ }
27
+
28
+ function decodeBase64(value: string, label: string): Buffer {
29
+ const normalized = value.trim();
30
+
31
+ if (!normalized || normalized.length % 4 !== 0 || !BASE64_PATTERN.test(normalized)) {
32
+ throw new Error(`[crypto] ${label} must be valid base64.`);
33
+ }
34
+
35
+ const bytes = Buffer.from(normalized, "base64");
36
+
37
+ if (bytes.toString("base64") !== normalized) {
38
+ throw new Error(`[crypto] ${label} must be valid base64.`);
39
+ }
40
+
41
+ return bytes;
42
+ }
43
+
44
+ function decodeSecretKey(rawValue: string, label: string): Buffer {
45
+ if (!rawValue) {
46
+ throw new Error(
47
+ `[crypto] Missing ${label}. Set a 32-byte key encoded as base64 (recommended) or 64-character hex.`,
48
+ );
49
+ }
50
+
51
+ if (HEX_PATTERN.test(rawValue)) {
52
+ return Buffer.from(rawValue, "hex");
53
+ }
54
+
55
+ const key = decodeBase64(rawValue, label);
56
+
57
+ if (key.length !== KEY_BYTES) {
58
+ throw new Error(`[crypto] ${label} must decode to exactly ${KEY_BYTES} bytes for AES-256-GCM.`);
59
+ }
60
+
61
+ return key;
62
+ }
63
+
64
+ function getSecretKey(): Buffer {
65
+ const env = ensureServerEnv();
66
+ const rawValue = env[KEY_ENV]?.trim();
67
+ return decodeSecretKey(rawValue ?? "", KEY_ENV);
68
+ }
69
+
70
+ function encryptWithKey(plaintext: string, key: Buffer): SecretCiphertext {
71
+ const iv = randomBytes(IV_BYTES);
72
+ const cipher = createCipheriv(ALGORITHM, key, iv);
73
+ const ciphertext = Buffer.concat([
74
+ cipher.update(plaintext, "utf8"),
75
+ cipher.final(),
76
+ cipher.getAuthTag(),
77
+ ]);
78
+
79
+ return {
80
+ ciphertext: ciphertext.toString("base64"),
81
+ iv: iv.toString("base64"),
82
+ };
83
+ }
84
+
85
+ function decryptWithKey(ciphertext: string, iv: string, key: Buffer): string {
86
+ const ivBytes = decodeBase64(iv, "iv");
87
+
88
+ if (ivBytes.length !== IV_BYTES) {
89
+ throw new Error(`[crypto] iv must decode to exactly ${IV_BYTES} bytes.`);
90
+ }
91
+
92
+ const payload = decodeBase64(ciphertext, "ciphertext");
93
+
94
+ if (payload.length < AUTH_TAG_BYTES) {
95
+ throw new Error("[crypto] ciphertext payload is invalid.");
96
+ }
97
+
98
+ const encrypted = payload.subarray(0, -AUTH_TAG_BYTES);
99
+ const authTag = payload.subarray(-AUTH_TAG_BYTES);
100
+
101
+ try {
102
+ const decipher = createDecipheriv(ALGORITHM, key, ivBytes);
103
+ decipher.setAuthTag(authTag);
104
+
105
+ return Buffer.concat([decipher.update(encrypted), decipher.final()]).toString("utf8");
106
+ } catch (error) {
107
+ throw new Error(
108
+ "[crypto] Failed to decrypt secret. The payload may be tampered with or invalid.",
109
+ {
110
+ cause: error,
111
+ },
112
+ );
113
+ }
114
+ }
115
+
116
+ export function encryptSecret(plaintext: string): SecretCiphertext {
117
+ return encryptWithKey(plaintext, getSecretKey());
118
+ }
119
+
120
+ export function decryptSecret(ciphertext: string, iv: string): string {
121
+ return decryptWithKey(ciphertext, iv, getSecretKey());
122
+ }
123
+
124
+ export function encryptSecretWithKey(plaintext: string, key: string): SecretCiphertext {
125
+ return encryptWithKey(plaintext, decodeSecretKey(key.trim(), "key"));
126
+ }
127
+
128
+ export function decryptSecretWithKey(ciphertext: string, iv: string, key: string): string {
129
+ return decryptWithKey(ciphertext, iv, decodeSecretKey(key.trim(), "key"));
130
+ }
131
+
132
+ export function packSecret(secret: SecretCiphertext): string {
133
+ const ivBytes = decodeBase64(secret.iv, "iv");
134
+
135
+ if (ivBytes.length !== IV_BYTES) {
136
+ throw new Error(`[crypto] iv must decode to exactly ${IV_BYTES} bytes.`);
137
+ }
138
+
139
+ const ciphertextBytes = decodeBase64(secret.ciphertext, "ciphertext");
140
+ return Buffer.concat([ivBytes, ciphertextBytes]).toString("base64");
141
+ }
142
+
143
+ export function unpackSecret(payload: string): SecretCiphertext {
144
+ const packed = decodeBase64(payload, "payload");
145
+
146
+ if (packed.length < IV_BYTES + AUTH_TAG_BYTES) {
147
+ throw new Error("[crypto] packed payload is invalid.");
148
+ }
149
+
150
+ const iv = packed.subarray(0, IV_BYTES);
151
+ const ciphertext = packed.subarray(IV_BYTES);
152
+
153
+ return {
154
+ ciphertext: ciphertext.toString("base64"),
155
+ iv: iv.toString("base64"),
156
+ };
157
+ }
@@ -0,0 +1,79 @@
1
+ import { describe, it, expect, vi } from "vite-plus/test";
2
+ import { fetchApi } from "#src/error.ts";
3
+
4
+ describe("fetchApi", () => {
5
+ it("returns parsed JSON on a successful response", async () => {
6
+ const mockData = { id: 1, name: "Test" };
7
+
8
+ vi.stubGlobal(
9
+ "fetch",
10
+ vi.fn().mockResolvedValue({
11
+ ok: true,
12
+ status: 200,
13
+ json: () => Promise.resolve(mockData),
14
+ }),
15
+ );
16
+
17
+ const result = await fetchApi<typeof mockData>("https://example.com/api");
18
+ expect(result).toEqual(mockData);
19
+ });
20
+
21
+ it("throws with the error field from the response body on failure", async () => {
22
+ vi.stubGlobal(
23
+ "fetch",
24
+ vi.fn().mockResolvedValue({
25
+ ok: false,
26
+ status: 404,
27
+ json: () => Promise.resolve({ error: "Not found" }),
28
+ }),
29
+ );
30
+
31
+ await expect(fetchApi("https://example.com/api")).rejects.toThrow("Not found");
32
+ });
33
+
34
+ it("throws with a generic message when response body has no error field", async () => {
35
+ vi.stubGlobal(
36
+ "fetch",
37
+ vi.fn().mockResolvedValue({
38
+ ok: false,
39
+ status: 500,
40
+ json: () => Promise.resolve({}),
41
+ }),
42
+ );
43
+
44
+ await expect(fetchApi("https://example.com/api")).rejects.toThrow("Request failed (500)");
45
+ });
46
+
47
+ it("sends Content-Type application/json header by default", async () => {
48
+ const mockFetch = vi.fn().mockResolvedValue({
49
+ ok: true,
50
+ status: 200,
51
+ json: () => Promise.resolve({}),
52
+ });
53
+ vi.stubGlobal("fetch", mockFetch);
54
+
55
+ await fetchApi("https://example.com/api");
56
+
57
+ const [, options] = mockFetch.mock.calls[0];
58
+ expect(options.headers["Content-Type"]).toBe("application/json");
59
+ });
60
+
61
+ it("passes extra options through to fetch", async () => {
62
+ const mockFetch = vi.fn().mockResolvedValue({
63
+ ok: true,
64
+ status: 200,
65
+ json: () => Promise.resolve({}),
66
+ });
67
+ vi.stubGlobal("fetch", mockFetch);
68
+
69
+ await fetchApi("https://example.com/api", {
70
+ method: "POST",
71
+ body: JSON.stringify({ key: "value" }),
72
+ });
73
+
74
+ const [url, options] = mockFetch.mock.calls[0];
75
+ expect(url).toBe("https://example.com/api");
76
+ expect(options.method).toBe("POST");
77
+ expect(options.body).toBe(JSON.stringify({ key: "value" }));
78
+ });
79
+ });
package/src/error.ts ADDED
@@ -0,0 +1,16 @@
1
+ /** Typed fetch wrapper with automatic JSON parsing and error handling */
2
+ export async function fetchApi<T>(url: string, options?: RequestInit): Promise<T> {
3
+ const response = await fetch(url, {
4
+ headers: { "Content-Type": "application/json", ...options?.headers },
5
+ ...options,
6
+ });
7
+
8
+ const data = (await response.json()) as { error?: string } | T;
9
+
10
+ if (!response.ok) {
11
+ const message = (data as { error?: string })?.error ?? `Request failed (${response.status})`;
12
+ throw new Error(message);
13
+ }
14
+
15
+ return data as T;
16
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Error capture over `fetch` for Convex, Workers, SvelteKit and extensions.
3
+ * Apps Script builds with `./protocol` and sends through UrlFetchApp instead.
4
+ */
5
+
6
+ import { buildErrorRequest, type CaptureInput } from "#src/errors/sentry/protocol.js";
7
+
8
+ export { buildErrorRequest, parseDsn, parseStack } from "#src/errors/sentry/protocol.js";
9
+ export type { CaptureInput, SentryRequest, SentryScalar } from "#src/errors/sentry/protocol.js";
10
+
11
+ /** Send one error. Never throws; a missing DSN is a silent no-op. */
12
+ export async function captureError(input: CaptureInput): Promise<void> {
13
+ const req = buildErrorRequest(input);
14
+ if (!req) return;
15
+ try {
16
+ await fetch(req.url, { method: "POST", headers: req.headers, body: req.body });
17
+ } catch (err) {
18
+ console.error("[errors] Sentry capture failed:", err);
19
+ }
20
+ }
@@ -0,0 +1,85 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { buildErrorRequest, parseDsn, parseStack } from "#src/errors/sentry/protocol.js";
3
+
4
+ const DSN = "https://abc123@o42.ingest.de.sentry.io/4507";
5
+
6
+ describe("parseDsn", () => {
7
+ it("splits key, origin and project", () => {
8
+ expect(parseDsn(DSN)).toEqual({
9
+ publicKey: "abc123",
10
+ origin: "https://o42.ingest.de.sentry.io",
11
+ projectId: "4507",
12
+ });
13
+ });
14
+
15
+ it("rejects junk", () => {
16
+ expect(parseDsn("")).toBeNull();
17
+ expect(parseDsn("https://o42.ingest.sentry.io/4507")).toBeNull();
18
+ });
19
+ });
20
+
21
+ describe("parseStack", () => {
22
+ it("returns V8 frames oldest first", () => {
23
+ const stack = "Error: boom\n at inner (app.js:10:5)\n at app.js:20:1";
24
+ expect(parseStack(stack)).toEqual([
25
+ { filename: "app.js", lineno: 20, colno: 1 },
26
+ { function: "inner", filename: "app.js", lineno: 10, colno: 5 },
27
+ ]);
28
+ });
29
+ });
30
+
31
+ describe("buildErrorRequest", () => {
32
+ it("is null without a DSN", () => {
33
+ expect(buildErrorRequest({ dsn: "", error: new Error("x") })).toBeNull();
34
+ });
35
+
36
+ it("builds a three-line envelope with the surface tag", () => {
37
+ const req = buildErrorRequest({
38
+ dsn: DSN,
39
+ error: new TypeError("bad"),
40
+ surface: "sync",
41
+ tags: { plan: "pro", skip: null },
42
+ user: { email: "a@b.c" },
43
+ eventId: "f".repeat(32),
44
+ now: 1_700_000_000_000,
45
+ });
46
+ expect(req?.url).toBe(
47
+ "https://o42.ingest.de.sentry.io/api/4507/envelope/?sentry_key=abc123&sentry_version=7",
48
+ );
49
+ const [head, item, event] = (req?.body ?? "").split("\n").map((l) => JSON.parse(l));
50
+ expect(head.event_id).toBe("f".repeat(32));
51
+ expect(item).toEqual({ type: "event" });
52
+ expect(event.tags).toEqual({ plan: "pro", surface: "sync" });
53
+ expect(event.exception.values[0]).toMatchObject({ type: "TypeError", value: "bad" });
54
+ expect(event.user).toEqual({ email: "a@b.c" });
55
+ expect(event.timestamp).toBe(1_700_000_000);
56
+ });
57
+
58
+ it("accepts a thrown string", () => {
59
+ const req = buildErrorRequest({ dsn: DSN, error: "plain" });
60
+ const event = JSON.parse((req?.body ?? "").split("\n")[2]);
61
+ expect(event.exception.values[0]).toEqual({ type: "Error", value: "plain" });
62
+ });
63
+
64
+ // Drizzle's "Failed query" keeps D1's real message in `cause`; Sentry showed the SQL alone.
65
+ it("sends the cause chain, root cause first and the thrown error last", () => {
66
+ const d1 = new Error("D1_ERROR: no such column: x");
67
+ const failed = new Error("Failed query: select x from t", { cause: d1 });
68
+ const req = buildErrorRequest({ dsn: DSN, error: failed });
69
+ const event = JSON.parse((req?.body ?? "").split("\n")[2]);
70
+ expect(event.exception.values.map((v: { value: string }) => v.value)).toEqual([
71
+ "D1_ERROR: no such column: x",
72
+ "Failed query: select x from t",
73
+ ]);
74
+ });
75
+
76
+ it("stops on a cause loop", () => {
77
+ const a = new Error("a");
78
+ const b = new Error("b", { cause: a });
79
+ a.cause = b;
80
+ const event = JSON.parse(
81
+ (buildErrorRequest({ dsn: DSN, error: a })?.body ?? "").split("\n")[2],
82
+ );
83
+ expect(event.exception.values).toHaveLength(2);
84
+ });
85
+ });
@@ -0,0 +1,144 @@
1
+ /**
2
+ * Pure Sentry envelope helpers, no I/O. Any runtime (Apps Script, Convex, Workers,
3
+ * extensions) builds the request here and sends it with its own transport.
4
+ */
5
+
6
+ export type SentryScalar = string | number | boolean;
7
+
8
+ export type CaptureInput = {
9
+ dsn: string;
10
+ error: unknown;
11
+ /** Where it happened, e.g. "sidebar.sync". Becomes the `surface` tag. */
12
+ surface?: string;
13
+ release?: string;
14
+ environment?: string;
15
+ user?: { id?: string; email?: string };
16
+ tags?: Record<string, SentryScalar | null | undefined>;
17
+ extra?: Record<string, unknown>;
18
+ /** 32 hex chars. Generated when omitted. */
19
+ eventId?: string;
20
+ /** Epoch milliseconds. Defaults to now. */
21
+ now?: number;
22
+ };
23
+
24
+ export type SentryRequest = {
25
+ url: string;
26
+ headers: Record<string, string>;
27
+ body: string;
28
+ };
29
+
30
+ type Dsn = { publicKey: string; origin: string; projectId: string };
31
+
32
+ type Frame = { function?: string; filename?: string; lineno?: number; colno?: number };
33
+
34
+ /** Parse `https://<key>@<host>/<projectId>`. Returns null for anything else. */
35
+ export function parseDsn(dsn: string): Dsn | null {
36
+ const m = /^(https?):\/\/([^@/]+)@([^/]+)\/(?:.*\/)?(\d+)\/?$/.exec(dsn.trim());
37
+ const [, scheme, publicKey, host, projectId] = m ?? [];
38
+ if (!scheme || !publicKey || !host || !projectId) return null;
39
+ return { publicKey, origin: `${scheme}://${host}`, projectId };
40
+ }
41
+
42
+ function randomHex32(): string {
43
+ let out = "";
44
+ for (let i = 0; i < 32; i++) out += Math.floor(Math.random() * 16).toString(16);
45
+ return out;
46
+ }
47
+
48
+ const V8_FRAME = /^\s*at (?:(.+?) \()?(.+?):(\d+):(\d+)\)?\s*$/;
49
+
50
+ /** V8 stack text to Sentry frames, oldest call first as Sentry expects. */
51
+ export function parseStack(stack: string | undefined): Frame[] {
52
+ if (!stack) return [];
53
+ const frames: Frame[] = [];
54
+ for (const line of stack.split("\n")) {
55
+ const m = V8_FRAME.exec(line);
56
+ if (!m) continue;
57
+ const frame: Frame = { filename: m[2], lineno: Number(m[3]), colno: Number(m[4]) };
58
+ if (m[1]) frame.function = m[1];
59
+ frames.push(frame);
60
+ }
61
+ return frames.reverse();
62
+ }
63
+
64
+ type Described = { type: string; value: string; stack?: string };
65
+
66
+ function describe(error: unknown): Described {
67
+ if (error instanceof Error) {
68
+ return { type: error.name || "Error", value: error.message, stack: error.stack };
69
+ }
70
+ if (typeof error === "string") return { type: "Error", value: error };
71
+ try {
72
+ return { type: "Error", value: JSON.stringify(error) };
73
+ } catch {
74
+ return { type: "Error", value: String(error) };
75
+ }
76
+ }
77
+
78
+ /** Deepest `cause` walked. A wrapper that wraps itself in a loop stops here too. */
79
+ const MAX_CHAIN = 5;
80
+
81
+ /**
82
+ * The error and its `cause` chain as Sentry exceptions, root cause first. Drizzle keeps D1's
83
+ * real message in `cause`, so without the chain the event never says why.
84
+ */
85
+ function describeChain(error: unknown): Described[] {
86
+ const chain = [describe(error)];
87
+ const seen = new Set<unknown>([error]);
88
+ let next = error instanceof Error ? error.cause : undefined;
89
+ while (next !== undefined && next !== null && !seen.has(next) && chain.length < MAX_CHAIN) {
90
+ seen.add(next);
91
+ chain.push(describe(next));
92
+ next = next instanceof Error ? next.cause : undefined;
93
+ }
94
+ return chain.reverse();
95
+ }
96
+
97
+ function cleanTags(input: CaptureInput): Record<string, string> {
98
+ const tags: Record<string, string> = {};
99
+ for (const [k, v] of Object.entries(input.tags ?? {})) {
100
+ if (v !== null && v !== undefined) tags[k] = String(v);
101
+ }
102
+ if (input.surface) tags.surface = input.surface;
103
+ return tags;
104
+ }
105
+
106
+ /** Build the envelope POST for one error. Null when the DSN is missing or malformed. */
107
+ export function buildErrorRequest(input: CaptureInput): SentryRequest | null {
108
+ const dsn = parseDsn(input.dsn ?? "");
109
+ if (!dsn) return null;
110
+ const eventId = input.eventId ?? randomHex32();
111
+ const now = input.now ?? Date.now();
112
+ const values = describeChain(input.error).map(({ type, value, stack }) => {
113
+ const exception: Record<string, unknown> = { type, value };
114
+ const frames = parseStack(stack);
115
+ if (frames.length) exception.stacktrace = { frames };
116
+ return exception;
117
+ });
118
+
119
+ const event: Record<string, unknown> = {
120
+ event_id: eventId,
121
+ timestamp: now / 1000,
122
+ platform: "javascript",
123
+ level: "error",
124
+ tags: cleanTags(input),
125
+ exception: { values },
126
+ };
127
+ if (input.release) event.release = input.release;
128
+ if (input.environment) event.environment = input.environment;
129
+ if (input.user) event.user = input.user;
130
+ if (input.extra) event.extra = input.extra;
131
+
132
+ const sentAt = new Date(now).toISOString();
133
+ const body = [
134
+ JSON.stringify({ event_id: eventId, sent_at: sentAt, dsn: input.dsn }),
135
+ JSON.stringify({ type: "event" }),
136
+ JSON.stringify(event),
137
+ ].join("\n");
138
+
139
+ return {
140
+ url: `${dsn.origin}/api/${dsn.projectId}/envelope/?sentry_key=${dsn.publicKey}&sentry_version=7`,
141
+ headers: { "Content-Type": "application/x-sentry-envelope" },
142
+ body,
143
+ };
144
+ }
@@ -0,0 +1,88 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { isStaleChunk, reloadForStaleChunk, shouldReport } from "#src/errors/sentry/sveltekit.js";
3
+
4
+ describe("shouldReport", () => {
5
+ it("never reports an error() throw", () => {
6
+ expect(shouldReport({ kind: "app", error: { message: "Not yours" } })).toBe(false);
7
+ });
8
+
9
+ it("reports an unknown error", () => {
10
+ expect(shouldReport({ kind: "unknown", error: new Error("boom") })).toBe(true);
11
+ });
12
+
13
+ it("reports a framework or validation error only from 500", () => {
14
+ expect(shouldReport({ kind: "framework", error: { status: 404, message: "Not Found" } })).toBe(
15
+ false,
16
+ );
17
+ expect(shouldReport({ kind: "validation", error: { status: 400, message: "Bad" } })).toBe(
18
+ false,
19
+ );
20
+ expect(shouldReport({ kind: "framework", error: { status: 500, message: "Internal" } })).toBe(
21
+ true,
22
+ );
23
+ });
24
+ });
25
+
26
+ describe("isStaleChunk", () => {
27
+ it("matches the Chrome, Safari and Firefox wordings", () => {
28
+ expect(isStaleChunk(new Error("Failed to fetch dynamically imported module: /a.js"))).toBe(
29
+ true,
30
+ );
31
+ expect(isStaleChunk(new Error("Importing a module script failed."))).toBe(true);
32
+ expect(isStaleChunk(new Error("error loading dynamically imported module: /a.js"))).toBe(true);
33
+ });
34
+
35
+ it("leaves other errors alone", () => {
36
+ expect(isStaleChunk(new Error("Cannot read properties of undefined"))).toBe(false);
37
+ expect(isStaleChunk("Failed to fetch dynamically imported module")).toBe(false);
38
+ });
39
+ });
40
+
41
+ function fakeEnv(startAt: number) {
42
+ const store = new Map<string, string>();
43
+ let clock = startAt;
44
+ const reload = vi.fn();
45
+ return {
46
+ env: {
47
+ storage: {
48
+ getItem: (key: string) => store.get(key) ?? null,
49
+ setItem: (key: string, value: string) => void store.set(key, value),
50
+ },
51
+ now: () => clock,
52
+ reload,
53
+ },
54
+ reload,
55
+ advance: (ms: number) => (clock += ms),
56
+ };
57
+ }
58
+
59
+ describe("reloadForStaleChunk", () => {
60
+ it("reloads once, then stops so a chunk that's really gone can't loop", () => {
61
+ const { env, reload, advance } = fakeEnv(1_000_000);
62
+ expect(reloadForStaleChunk(env)).toBe(true);
63
+ advance(5_000);
64
+ expect(reloadForStaleChunk(env)).toBe(false);
65
+ expect(reload).toHaveBeenCalledTimes(1);
66
+ });
67
+
68
+ it("reloads again for a later deploy", () => {
69
+ const { env, reload, advance } = fakeEnv(1_000_000);
70
+ reloadForStaleChunk(env);
71
+ advance(60_000);
72
+ expect(reloadForStaleChunk(env)).toBe(true);
73
+ expect(reload).toHaveBeenCalledTimes(2);
74
+ });
75
+
76
+ it("doesn't reload when storage is blocked", () => {
77
+ const reload = vi.fn();
78
+ const blocked = {
79
+ getItem: () => {
80
+ throw new Error("denied");
81
+ },
82
+ setItem: () => {},
83
+ };
84
+ expect(reloadForStaleChunk({ storage: blocked, now: Date.now, reload })).toBe(false);
85
+ expect(reloadForStaleChunk(null)).toBe(false);
86
+ expect(reload).not.toHaveBeenCalled();
87
+ });
88
+ });