From 08d38f02c57b4e4e247adb77711d420456c7c13d Mon Sep 17 00:00:00 2001 From: Bobby Galli Date: Tue, 10 Mar 2026 17:26:58 -0400 Subject: [PATCH 01/13] Add postFeedback API for user feedback submission Adds postFeedback method using the 3-step presigned URL upload flow (getCrashUploadUrl -> PUT to S3 -> commitS3CrashUpload) with crashTypeId=36. Creates feedback.json with title and description, zips it using a minimal inline ZIP implementation with CRC-32, and uploads via the presigned URL flow. Co-Authored-By: Claude Opus 4.6 --- src/bugsplat-options.ts | 26 +++++ src/bugsplat.ts | 205 +++++++++++++++++++++++++++++++++++++++- src/index.ts | 2 +- 3 files changed, 231 insertions(+), 2 deletions(-) diff --git a/src/bugsplat-options.ts b/src/bugsplat-options.ts index e5a95fd..0bbff64 100644 --- a/src/bugsplat-options.ts +++ b/src/bugsplat-options.ts @@ -30,3 +30,29 @@ export interface BugSplatOptions { */ user?: string; } + +/** + * Additional parameters that can be passed to `postFeedback()` + */ +export interface BugSplatFeedbackOptions { + /** + * Additional feedback context + */ + description?: string; + /** + * The email of the user submitting feedback + */ + email?: string; + /** + * The name or id of the user submitting feedback + */ + user?: string; + /** + * Additional metadata that can be queried via BugSplat's web application + */ + appKey?: string; + /** + * Define arbitrary fields to be appended to the commit form data + */ + additionalFormDataParams?: Array; +} diff --git a/src/bugsplat.ts b/src/bugsplat.ts index ddffde0..73f9df6 100644 --- a/src/bugsplat.ts +++ b/src/bugsplat.ts @@ -1,4 +1,4 @@ -import type { BugSplatOptions } from './bugsplat-options'; +import type { BugSplatFeedbackOptions, BugSplatOptions } from './bugsplat-options'; import { type BugSplatResponse, type BugSplatResponseBody, @@ -33,6 +33,95 @@ export async function tryParseResponseJson(response: { const isError = (val: unknown): val is Error => Boolean((val as Error)?.stack); +/** + * Creates a minimal ZIP file containing a single file. + * Implements the bare minimum of the ZIP format specification. + */ +function createZip(filename: string, data: Uint8Array): Uint8Array { + const encoder = new TextEncoder(); + const nameBytes = encoder.encode(filename); + const crc = crc32(data); + const now = new Date(); + const dosTime = ((now.getHours() << 11) | (now.getMinutes() << 5) | (now.getSeconds() >> 1)) & 0xffff; + const dosDate = (((now.getFullYear() - 1980) << 9) | ((now.getMonth() + 1) << 5) | now.getDate()) & 0xffff; + + // Local file header + const localHeader = new Uint8Array(30 + nameBytes.length); + const lhView = new DataView(localHeader.buffer); + lhView.setUint32(0, 0x04034b50, true); // Local file header signature + lhView.setUint16(4, 20, true); // Version needed + lhView.setUint16(6, 0, true); // General purpose flag + lhView.setUint16(8, 0, true); // Compression: stored + lhView.setUint16(10, dosTime, true); // Mod time + lhView.setUint16(12, dosDate, true); // Mod date + lhView.setUint32(14, crc, true); // CRC-32 + lhView.setUint32(18, data.length, true); // Compressed size + lhView.setUint32(22, data.length, true); // Uncompressed size + lhView.setUint16(26, nameBytes.length, true); // File name length + lhView.setUint16(28, 0, true); // Extra field length + localHeader.set(nameBytes, 30); + + // Central directory header + const centralDir = new Uint8Array(46 + nameBytes.length); + const cdView = new DataView(centralDir.buffer); + cdView.setUint32(0, 0x02014b50, true); // Central dir signature + cdView.setUint16(4, 20, true); // Version made by + cdView.setUint16(6, 20, true); // Version needed + cdView.setUint16(8, 0, true); // General purpose flag + cdView.setUint16(10, 0, true); // Compression: stored + cdView.setUint16(12, dosTime, true); // Mod time + cdView.setUint16(14, dosDate, true); // Mod date + cdView.setUint32(16, crc, true); // CRC-32 + cdView.setUint32(20, data.length, true); // Compressed size + cdView.setUint32(24, data.length, true); // Uncompressed size + cdView.setUint16(28, nameBytes.length, true); // File name length + cdView.setUint16(30, 0, true); // Extra field length + cdView.setUint16(32, 0, true); // Comment length + cdView.setUint16(34, 0, true); // Disk number start + cdView.setUint16(36, 0, true); // Internal attributes + cdView.setUint32(38, 0, true); // External attributes + cdView.setUint32(42, 0, true); // Local header offset + centralDir.set(nameBytes, 46); + + const centralDirOffset = localHeader.length + data.length; + + // End of central directory record + const eocd = new Uint8Array(22); + const eocdView = new DataView(eocd.buffer); + eocdView.setUint32(0, 0x06054b50, true); // EOCD signature + eocdView.setUint16(4, 0, true); // Disk number + eocdView.setUint16(6, 0, true); // Central dir disk + eocdView.setUint16(8, 1, true); // Entries on this disk + eocdView.setUint16(10, 1, true); // Total entries + eocdView.setUint32(12, centralDir.length, true); // Central dir size + eocdView.setUint32(16, centralDirOffset, true); // Central dir offset + eocdView.setUint16(20, 0, true); // Comment length + + // Combine all parts + const result = new Uint8Array(localHeader.length + data.length + centralDir.length + eocd.length); + let offset = 0; + result.set(localHeader, offset); offset += localHeader.length; + result.set(data, offset); offset += data.length; + result.set(centralDir, offset); offset += centralDir.length; + result.set(eocd, offset); + + return result; +} + +/** + * Compute CRC-32 for a Uint8Array + */ +function crc32(data: Uint8Array): number { + let crc = 0xffffffff; + for (let i = 0; i < data.length; i++) { + crc ^= data[i]; + for (let j = 0; j < 8; j++) { + crc = (crc >>> 1) ^ (crc & 1 ? 0xedb88320 : 0); + } + } + return (crc ^ 0xffffffff) >>> 0; +} + /** * BugSplat crash posting client. Facilitates sending * crash reports through the `post()` method. @@ -136,6 +225,120 @@ export class BugSplat { return this._createReturnValue(null, json, errorToPost); } + /** + * Posts user feedback to BugSplat via the presigned URL upload flow + * @param title - Feedback title, used as the stack key for grouping + * @param options - Additional parameters for the feedback submission + */ + async postFeedback( + title: string, + options?: BugSplatFeedbackOptions + ): Promise { + options = options || {}; + + const appKey = options.appKey || this._appKey; + const user = options.user || this._user; + const email = options.email || this._email; + const description = options.description || this._description; + + // Create feedback.json + const feedbackJson = JSON.stringify({ title, description }); + const feedbackBlob = new Blob([feedbackJson], { type: 'application/json' }); + + // Create zip containing feedback.json + const zipData = createZip('feedback.json', new Uint8Array(await feedbackBlob.arrayBuffer())); + + const baseUrl = process.env.BUGSPLAT_CRASH_POST_URL?.replace(/\/post\/js\/?$/, '') || `https://${this.database}.bugsplat.com`; + + // Step 1: Get presigned upload URL + const getCrashUrlParams = new URLSearchParams({ + database: this.database, + appName: this.application, + appVersion: this.version, + crashPostSize: String(zipData.byteLength) + }); + + console.log('BugSplat Feedback:', title); + console.log('BugSplat: Getting presigned URL...'); + + const getCrashUrlResponse = await globalThis.fetch( + `${baseUrl}/api/getCrashUploadUrl?${getCrashUrlParams}` + ); + + if (!getCrashUrlResponse.ok) { + return this._createReturnValue( + new Error('BugSplat Error: Failed to get upload URL'), + {}, + title + ); + } + + const { url: presignedUrl } = await getCrashUrlResponse.json() as { url: string }; + + // Step 2: Upload zip to S3 + console.log('BugSplat: Uploading feedback...'); + + const putResponse = await globalThis.fetch(presignedUrl, { + method: 'PUT', + body: zipData, + headers: { 'Content-Type': 'application/zip' } + }); + + if (!putResponse.ok) { + return this._createReturnValue( + new Error('BugSplat Error: Failed to upload to S3'), + {}, + title + ); + } + + const etag = putResponse.headers.get('ETag')?.replace(/"/g, '') || ''; + + // Step 3: Commit the upload + console.log('BugSplat: Committing feedback...'); + + const commitBody = this._formData(); + commitBody.append('database', this.database); + commitBody.append('appName', this.application); + commitBody.append('appVersion', this.version); + commitBody.append('crashTypeId', '36'); + commitBody.append('s3Key', presignedUrl); + commitBody.append('md5', etag); + commitBody.append('appKey', appKey); + commitBody.append('user', user); + commitBody.append('email', email); + commitBody.append('description', description); + + const additionalFormDataParams = options.additionalFormDataParams || []; + additionalFormDataParams.forEach((param) => { + if (isFormDataParamString(param)) { + commitBody.append(param.key, param.value); + } else { + commitBody.append(param.key, param.value, param.filename); + } + }); + + const commitResponse = await globalThis.fetch( + `${baseUrl}/api/commitS3CrashUpload`, + { method: 'POST', body: commitBody } + ); + + const json = await tryParseResponseJson(commitResponse); + + console.log('BugSplat commit status code:', commitResponse.status); + console.log('BugSplat commit response body:', json); + + if (!commitResponse.ok) { + return this._createReturnValue( + new Error('BugSplat Error: Failed to commit feedback'), + json, + title + ); + } + + return this._createReturnValue(null, json as any, title); + } + /** * Additional metadata that can be queried via BugSplat's web application */ diff --git a/src/index.ts b/src/index.ts index 0f1af31..68b0097 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,4 @@ export { BugSplat } from './bugsplat'; -export type { BugSplatOptions } from './bugsplat-options'; +export type { BugSplatFeedbackOptions, BugSplatOptions } from './bugsplat-options'; export type { BugSplatResponse, BugSplatResponseBody, BugSplatResponseType, validateResponseBody } from './bugsplat-response'; export type { FormDataParam } from './form-data-param'; From 8ab0a32a56e87e8912434a4fcff83b5f0551da73 Mon Sep 17 00:00:00 2001 From: Bobby Galli Date: Wed, 11 Mar 2026 08:53:06 -0400 Subject: [PATCH 02/13] Replace inline ZIP implementation with jszip library Use jszip instead of hand-rolled ZIP/CRC-32 code for creating the feedback.json zip archive in postFeedback. Co-Authored-By: Claude Opus 4.6 --- package-lock.json | 102 ++++++++++++++++++++++++++++++++++++++++++++++ package.json | 3 ++ src/bugsplat.ts | 99 +++----------------------------------------- 3 files changed, 110 insertions(+), 94 deletions(-) diff --git a/package-lock.json b/package-lock.json index 4333c36..ff8c70e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,9 @@ "name": "bugsplat", "version": "8.0.2", "license": "MIT", + "dependencies": { + "jszip": "^3.10.1" + }, "devDependencies": { "@bugsplat/js-api-client": "^14.1.0", "@eslint/js": "^9.28.0", @@ -1701,6 +1704,12 @@ "dev": true, "license": "MIT" }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "license": "MIT" + }, "node_modules/create-require": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", @@ -2174,6 +2183,12 @@ "node": ">= 4" } }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", + "license": "MIT" + }, "node_modules/import-fresh": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", @@ -2201,6 +2216,12 @@ "node": ">=0.8.19" } }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -2224,6 +2245,12 @@ "node": ">=0.10.0" } }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -2265,6 +2292,18 @@ "dev": true, "license": "MIT" }, + "node_modules/jszip": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", + "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==", + "license": "(MIT OR GPL-3.0-or-later)", + "dependencies": { + "lie": "~3.3.0", + "pako": "~1.0.2", + "readable-stream": "~2.3.6", + "setimmediate": "^1.0.5" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -2289,6 +2328,15 @@ "node": ">= 0.8.0" } }, + "node_modules/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "license": "MIT", + "dependencies": { + "immediate": "~3.0.5" + } + }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -2436,6 +2484,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "license": "(MIT AND Zlib)" + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -2551,6 +2605,12 @@ "url": "https://github.com/prettier/prettier?sponsor=1" } }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "license": "MIT" + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -2561,6 +2621,21 @@ "node": ">=6" } }, + "node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", @@ -2616,6 +2691,12 @@ "fsevents": "~2.3.2" } }, + "node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, "node_modules/semver": { "version": "7.7.3", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", @@ -2629,6 +2710,12 @@ "node": ">=10" } }, + "node_modules/setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", + "license": "MIT" + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -2683,6 +2770,15 @@ "dev": true, "license": "MIT" }, + "node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -2878,6 +2974,12 @@ "punycode": "^2.1.0" } }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, "node_modules/v8-compile-cache-lib": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", diff --git a/package.json b/package.json index e866fa4..d2deb88 100644 --- a/package.json +++ b/package.json @@ -55,5 +55,8 @@ "typescript": "^5.9.3", "typescript-eslint": "^8.33.1", "vitest": "^4.0.16" + }, + "dependencies": { + "jszip": "^3.10.1" } } diff --git a/src/bugsplat.ts b/src/bugsplat.ts index 73f9df6..ea91284 100644 --- a/src/bugsplat.ts +++ b/src/bugsplat.ts @@ -1,3 +1,4 @@ +import JSZip from 'jszip'; import type { BugSplatFeedbackOptions, BugSplatOptions } from './bugsplat-options'; import { type BugSplatResponse, @@ -33,95 +34,6 @@ export async function tryParseResponseJson(response: { const isError = (val: unknown): val is Error => Boolean((val as Error)?.stack); -/** - * Creates a minimal ZIP file containing a single file. - * Implements the bare minimum of the ZIP format specification. - */ -function createZip(filename: string, data: Uint8Array): Uint8Array { - const encoder = new TextEncoder(); - const nameBytes = encoder.encode(filename); - const crc = crc32(data); - const now = new Date(); - const dosTime = ((now.getHours() << 11) | (now.getMinutes() << 5) | (now.getSeconds() >> 1)) & 0xffff; - const dosDate = (((now.getFullYear() - 1980) << 9) | ((now.getMonth() + 1) << 5) | now.getDate()) & 0xffff; - - // Local file header - const localHeader = new Uint8Array(30 + nameBytes.length); - const lhView = new DataView(localHeader.buffer); - lhView.setUint32(0, 0x04034b50, true); // Local file header signature - lhView.setUint16(4, 20, true); // Version needed - lhView.setUint16(6, 0, true); // General purpose flag - lhView.setUint16(8, 0, true); // Compression: stored - lhView.setUint16(10, dosTime, true); // Mod time - lhView.setUint16(12, dosDate, true); // Mod date - lhView.setUint32(14, crc, true); // CRC-32 - lhView.setUint32(18, data.length, true); // Compressed size - lhView.setUint32(22, data.length, true); // Uncompressed size - lhView.setUint16(26, nameBytes.length, true); // File name length - lhView.setUint16(28, 0, true); // Extra field length - localHeader.set(nameBytes, 30); - - // Central directory header - const centralDir = new Uint8Array(46 + nameBytes.length); - const cdView = new DataView(centralDir.buffer); - cdView.setUint32(0, 0x02014b50, true); // Central dir signature - cdView.setUint16(4, 20, true); // Version made by - cdView.setUint16(6, 20, true); // Version needed - cdView.setUint16(8, 0, true); // General purpose flag - cdView.setUint16(10, 0, true); // Compression: stored - cdView.setUint16(12, dosTime, true); // Mod time - cdView.setUint16(14, dosDate, true); // Mod date - cdView.setUint32(16, crc, true); // CRC-32 - cdView.setUint32(20, data.length, true); // Compressed size - cdView.setUint32(24, data.length, true); // Uncompressed size - cdView.setUint16(28, nameBytes.length, true); // File name length - cdView.setUint16(30, 0, true); // Extra field length - cdView.setUint16(32, 0, true); // Comment length - cdView.setUint16(34, 0, true); // Disk number start - cdView.setUint16(36, 0, true); // Internal attributes - cdView.setUint32(38, 0, true); // External attributes - cdView.setUint32(42, 0, true); // Local header offset - centralDir.set(nameBytes, 46); - - const centralDirOffset = localHeader.length + data.length; - - // End of central directory record - const eocd = new Uint8Array(22); - const eocdView = new DataView(eocd.buffer); - eocdView.setUint32(0, 0x06054b50, true); // EOCD signature - eocdView.setUint16(4, 0, true); // Disk number - eocdView.setUint16(6, 0, true); // Central dir disk - eocdView.setUint16(8, 1, true); // Entries on this disk - eocdView.setUint16(10, 1, true); // Total entries - eocdView.setUint32(12, centralDir.length, true); // Central dir size - eocdView.setUint32(16, centralDirOffset, true); // Central dir offset - eocdView.setUint16(20, 0, true); // Comment length - - // Combine all parts - const result = new Uint8Array(localHeader.length + data.length + centralDir.length + eocd.length); - let offset = 0; - result.set(localHeader, offset); offset += localHeader.length; - result.set(data, offset); offset += data.length; - result.set(centralDir, offset); offset += centralDir.length; - result.set(eocd, offset); - - return result; -} - -/** - * Compute CRC-32 for a Uint8Array - */ -function crc32(data: Uint8Array): number { - let crc = 0xffffffff; - for (let i = 0; i < data.length; i++) { - crc ^= data[i]; - for (let j = 0; j < 8; j++) { - crc = (crc >>> 1) ^ (crc & 1 ? 0xedb88320 : 0); - } - } - return (crc ^ 0xffffffff) >>> 0; -} - /** * BugSplat crash posting client. Facilitates sending * crash reports through the `post()` method. @@ -241,12 +153,11 @@ export class BugSplat { const email = options.email || this._email; const description = options.description || this._description; - // Create feedback.json + // Create feedback.json and zip it const feedbackJson = JSON.stringify({ title, description }); - const feedbackBlob = new Blob([feedbackJson], { type: 'application/json' }); - - // Create zip containing feedback.json - const zipData = createZip('feedback.json', new Uint8Array(await feedbackBlob.arrayBuffer())); + const zip = new JSZip(); + zip.file('feedback.json', feedbackJson); + const zipData = await zip.generateAsync({ type: 'uint8array' }); const baseUrl = process.env.BUGSPLAT_CRASH_POST_URL?.replace(/\/post\/js\/?$/, '') || `https://${this.database}.bugsplat.com`; From 0f0866541a79f4ae9a0561cdc8271c0a1e98737b Mon Sep 17 00:00:00 2001 From: Bobby Galli Date: Wed, 11 Mar 2026 08:55:50 -0400 Subject: [PATCH 03/13] Replace jszip with fflate for lighter zip creation fflate is tree-shakeable (~3KB gzipped) vs jszip (~45KB). Only zipSync and strToU8 are imported. Co-Authored-By: Claude Opus 4.6 --- package-lock.json | 107 +++------------------------------------------- package.json | 2 +- src/bugsplat.ts | 6 +-- 3 files changed, 10 insertions(+), 105 deletions(-) diff --git a/package-lock.json b/package-lock.json index ff8c70e..7537cbf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "8.0.2", "license": "MIT", "dependencies": { - "jszip": "^3.10.1" + "fflate": "^0.8.2" }, "devDependencies": { "@bugsplat/js-api-client": "^14.1.0", @@ -1704,12 +1704,6 @@ "dev": true, "license": "MIT" }, - "node_modules/core-util-is": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", - "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", - "license": "MIT" - }, "node_modules/create-require": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", @@ -2071,6 +2065,12 @@ } } }, + "node_modules/fflate": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", + "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==", + "license": "MIT" + }, "node_modules/file-entry-cache": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", @@ -2183,12 +2183,6 @@ "node": ">= 4" } }, - "node_modules/immediate": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", - "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", - "license": "MIT" - }, "node_modules/import-fresh": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", @@ -2216,12 +2210,6 @@ "node": ">=0.8.19" } }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "license": "ISC" - }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -2245,12 +2233,6 @@ "node": ">=0.10.0" } }, - "node_modules/isarray": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", - "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", - "license": "MIT" - }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -2292,18 +2274,6 @@ "dev": true, "license": "MIT" }, - "node_modules/jszip": { - "version": "3.10.1", - "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", - "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==", - "license": "(MIT OR GPL-3.0-or-later)", - "dependencies": { - "lie": "~3.3.0", - "pako": "~1.0.2", - "readable-stream": "~2.3.6", - "setimmediate": "^1.0.5" - } - }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -2328,15 +2298,6 @@ "node": ">= 0.8.0" } }, - "node_modules/lie": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", - "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", - "license": "MIT", - "dependencies": { - "immediate": "~3.0.5" - } - }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -2484,12 +2445,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/pako": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", - "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", - "license": "(MIT AND Zlib)" - }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -2605,12 +2560,6 @@ "url": "https://github.com/prettier/prettier?sponsor=1" } }, - "node_modules/process-nextick-args": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", - "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", - "license": "MIT" - }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -2621,21 +2570,6 @@ "node": ">=6" } }, - "node_modules/readable-stream": { - "version": "2.3.8", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", - "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", - "license": "MIT", - "dependencies": { - "core-util-is": "~1.0.0", - "inherits": "~2.0.3", - "isarray": "~1.0.0", - "process-nextick-args": "~2.0.0", - "safe-buffer": "~5.1.1", - "string_decoder": "~1.1.1", - "util-deprecate": "~1.0.1" - } - }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", @@ -2691,12 +2625,6 @@ "fsevents": "~2.3.2" } }, - "node_modules/safe-buffer": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", - "license": "MIT" - }, "node_modules/semver": { "version": "7.7.3", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", @@ -2710,12 +2638,6 @@ "node": ">=10" } }, - "node_modules/setimmediate": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", - "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", - "license": "MIT" - }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -2770,15 +2692,6 @@ "dev": true, "license": "MIT" }, - "node_modules/string_decoder": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", - "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", - "license": "MIT", - "dependencies": { - "safe-buffer": "~5.1.0" - } - }, "node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -2974,12 +2887,6 @@ "punycode": "^2.1.0" } }, - "node_modules/util-deprecate": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "license": "MIT" - }, "node_modules/v8-compile-cache-lib": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", diff --git a/package.json b/package.json index d2deb88..ac18c89 100644 --- a/package.json +++ b/package.json @@ -57,6 +57,6 @@ "vitest": "^4.0.16" }, "dependencies": { - "jszip": "^3.10.1" + "fflate": "^0.8.2" } } diff --git a/src/bugsplat.ts b/src/bugsplat.ts index ea91284..caf8828 100644 --- a/src/bugsplat.ts +++ b/src/bugsplat.ts @@ -1,4 +1,4 @@ -import JSZip from 'jszip'; +import { zipSync, strToU8 } from 'fflate'; import type { BugSplatFeedbackOptions, BugSplatOptions } from './bugsplat-options'; import { type BugSplatResponse, @@ -155,9 +155,7 @@ export class BugSplat { // Create feedback.json and zip it const feedbackJson = JSON.stringify({ title, description }); - const zip = new JSZip(); - zip.file('feedback.json', feedbackJson); - const zipData = await zip.generateAsync({ type: 'uint8array' }); + const zipData = zipSync({ 'feedback.json': strToU8(feedbackJson) }); const baseUrl = process.env.BUGSPLAT_CRASH_POST_URL?.replace(/\/post\/js\/?$/, '') || `https://${this.database}.bugsplat.com`; From 81f8528cf0c5673979e1d01aaa468a861672fb6c Mon Sep 17 00:00:00 2001 From: Bobby Galli Date: Wed, 11 Mar 2026 08:57:49 -0400 Subject: [PATCH 04/13] Add attachments support to postFeedback (e.g. screenshots) Adds BugSplatAttachment type and attachments option to BugSplatFeedbackOptions. Attachments are included in the zip alongside feedback.json. Named BugSplatAttachment (not BugSplatFeedbackAttachment) so it can be reused for other report types in the future. Co-Authored-By: Claude Opus 4.6 --- src/bugsplat-options.ts | 18 ++++++++++++++++++ src/bugsplat.ts | 15 ++++++++++++--- src/index.ts | 2 +- 3 files changed, 31 insertions(+), 4 deletions(-) diff --git a/src/bugsplat-options.ts b/src/bugsplat-options.ts index 0bbff64..4ed47fe 100644 --- a/src/bugsplat-options.ts +++ b/src/bugsplat-options.ts @@ -31,6 +31,20 @@ export interface BugSplatOptions { user?: string; } +/** + * A file attachment to include in the feedback zip (e.g. a screenshot). + */ +export interface BugSplatAttachment { + /** + * The filename as it will appear inside the zip + */ + filename: string; + /** + * The file contents + */ + data: Blob | Uint8Array; +} + /** * Additional parameters that can be passed to `postFeedback()` */ @@ -51,6 +65,10 @@ export interface BugSplatFeedbackOptions { * Additional metadata that can be queried via BugSplat's web application */ appKey?: string; + /** + * File attachments to include in the feedback zip (e.g. screenshots) + */ + attachments?: Array; /** * Define arbitrary fields to be appended to the commit form data */ diff --git a/src/bugsplat.ts b/src/bugsplat.ts index caf8828..3dddcb5 100644 --- a/src/bugsplat.ts +++ b/src/bugsplat.ts @@ -153,9 +153,18 @@ export class BugSplat { const email = options.email || this._email; const description = options.description || this._description; - // Create feedback.json and zip it + // Create zip with feedback.json and any attachments const feedbackJson = JSON.stringify({ title, description }); - const zipData = zipSync({ 'feedback.json': strToU8(feedbackJson) }); + const zipFiles: Record = { + 'feedback.json': strToU8(feedbackJson), + }; + for (const attachment of options.attachments || []) { + const bytes = attachment.data instanceof Uint8Array + ? attachment.data + : new Uint8Array(await attachment.data.arrayBuffer()); + zipFiles[attachment.filename] = bytes; + } + const zipData = zipSync(zipFiles); const baseUrl = process.env.BUGSPLAT_CRASH_POST_URL?.replace(/\/post\/js\/?$/, '') || `https://${this.database}.bugsplat.com`; @@ -189,7 +198,7 @@ export class BugSplat { const putResponse = await globalThis.fetch(presignedUrl, { method: 'PUT', - body: zipData, + body: new Blob([zipData.buffer as ArrayBuffer], { type: 'application/zip' }), headers: { 'Content-Type': 'application/zip' } }); diff --git a/src/index.ts b/src/index.ts index 68b0097..8bea438 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,4 @@ export { BugSplat } from './bugsplat'; -export type { BugSplatFeedbackOptions, BugSplatOptions } from './bugsplat-options'; +export type { BugSplatAttachment, BugSplatFeedbackOptions, BugSplatOptions } from './bugsplat-options'; export type { BugSplatResponse, BugSplatResponseBody, BugSplatResponseType, validateResponseBody } from './bugsplat-response'; export type { FormDataParam } from './form-data-param'; From e91269b7b4752697f1ff12530ded315a57366a2b Mon Sep 17 00:00:00 2001 From: Bobby Galli Date: Wed, 11 Mar 2026 08:59:22 -0400 Subject: [PATCH 05/13] Add postFeedback and attachments documentation to README Co-Authored-By: Claude Opus 4.6 --- README.md | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/README.md b/README.md index cac2fee..c356ef3 100644 --- a/README.md +++ b/README.md @@ -106,6 +106,31 @@ async bugsplat.post(error, options); // Posts an arbitrary Error object to BugSp // Returns a promise that resolves with properties: error (if there was an error posting to BugSplat), response (the response from the BugSplat crash post API), and original (the error passed by bugsplat.post) ``` +### User Feedback + +You can also submit non-crashing user feedback (e.g. bug reports, feature requests) using `postFeedback`. Feedback reports appear in BugSplat with the "User Feedback" crash type, grouped by `title`. + +```ts +const response = await bugsplat.postFeedback('Login button does not respond', { + description: 'Tapping login on iPhone does nothing.', + user: 'jane@example.com', + email: 'jane@example.com', +}); +``` + +You can attach files such as screenshots: + +```ts +const screenshot = document.querySelector('input[type="file"]').files[0]; + +await bugsplat.postFeedback('UI rendering issue', { + description: 'The sidebar overlaps the main content.', + attachments: [ + { filename: 'screenshot.png', data: screenshot }, + ], +}); +``` + ## 📢 Upgrading If you are developing a Node.js application and were using bugsplat-js <= 5.0.0 please upgrade to [bugsplat-node](https://www.npmjs.com/package/bugsplat-node). BugSplat-node has the same consumer APIs as bugsplat-js <= 5.0.0. Additionally, support for file attachments and exiting the Node process in the error handler have been moved to [bugsplat-node](https://www.npmjs.com/package/bugsplat-node) so that bugsplat-js can be run in browsers as well as Node.js environments. From 2d845b8a5f3ce4e45326a47c42214a8b34c70fe8 Mon Sep 17 00:00:00 2001 From: Bobby Galli Date: Wed, 11 Mar 2026 09:35:37 -0400 Subject: [PATCH 06/13] Fix no-explicit-any lint error in postFeedback by using validateResponseBody type guard Co-Authored-By: Claude Opus 4.6 --- src/bugsplat.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/bugsplat.ts b/src/bugsplat.ts index 3dddcb5..c877875 100644 --- a/src/bugsplat.ts +++ b/src/bugsplat.ts @@ -254,7 +254,15 @@ export class BugSplat { ); } - return this._createReturnValue(null, json as any, title); + if (!validateResponseBody(json)) { + return this._createReturnValue( + new Error('BugSplat Error: Invalid response received'), + json, + title + ); + } + + return this._createReturnValue(null, json, title); } /** From a88479330bf063ded496308bdffa1a3231c4a779 Mon Sep 17 00:00:00 2001 From: Bobby Galli Date: Wed, 11 Mar 2026 12:41:16 -0400 Subject: [PATCH 07/13] BREAKING: Unify crash and feedback uploads via presigned URL flow Migrate post() from direct /post/js/ to the presigned URL upload flow, add attributes and attachments as first-class options, remove additionalFormDataParams and FormDataParam, and unify response types to match the commitS3CrashUpload endpoint shape. Co-Authored-By: Claude Opus 4.6 --- spec/bugsplat.e2e.spec.ts | 63 ++++-- spec/bugsplat.spec.ts | 423 ++++++++++++++++---------------------- src/bugsplat-options.ts | 67 ++---- src/bugsplat-response.ts | 37 ++-- src/bugsplat.ts | 215 ++++++++----------- src/form-data-param.ts | 30 --- src/index.ts | 3 +- 7 files changed, 358 insertions(+), 480 deletions(-) delete mode 100644 src/form-data-param.ts diff --git a/spec/bugsplat.e2e.spec.ts b/spec/bugsplat.e2e.spec.ts index bd234fa..9699e75 100644 --- a/spec/bugsplat.e2e.spec.ts +++ b/spec/bugsplat.e2e.spec.ts @@ -17,7 +17,10 @@ describe('BugSplat', () => { throw new Error('Please set FRED_PASSWORD environment variable'); } - const api = await BugSplatApiClient.createAuthenticatedClientForNode(email, password); + const api = await BugSplatApiClient.createAuthenticatedClientForNode( + email, + password, + ); client = new CrashApiClient(api); // Posting too frequently results in 400s from the web server @@ -36,26 +39,62 @@ describe('BugSplat', () => { const additionalFile = './spec/files/additionalFile.txt'; const fileName = path.basename(additionalFile); const fileContents = await readFile(additionalFile); - const additionalFormDataParams = [ - { - key: fileName, - value: new Blob([new Uint8Array(fileContents)]), - filename: fileName, - }, - { key: 'attributes', value: '{"foo": "bar"}' }, - ]; const bugsplat = new BugSplat(database, appName, appVersion); bugsplat.setDefaultAppKey(appKey); bugsplat.setDefaultUser(user); bugsplat.setDefaultEmail(email); bugsplat.setDefaultDescription(description); - const result = await bugsplat.post(error, { additionalFormDataParams }); + const result = await bugsplat.post(error, { + attachments: [ + { data: new Uint8Array(fileContents), filename: fileName }, + ], + attributes: { foo: 'bar' }, + }); if (result.error) { throw new Error(result.error.message); } - const expectedCrashId = result.response.crash_id; + const expectedCrashId = result.response.crashId; + const crashData = await client.getCrashById(database, expectedCrashId); + expect(crashData.appName).toEqual(appName); + expect(crashData.appVersion).toEqual(appVersion); + expect(crashData.appKey).toEqual(appKey); + expect(crashData.description).toEqual(description); + expect(crashData.user).toBeTruthy(); // Fred has PII obfuscated so the best we can do here is to check if truthy + expect(crashData.email).toBeTruthy(); // Fred has PII obfuscated so the best we can do here is to check if truthy + }, 30000); + + it('should post a feedback report with all provided information', async () => { + const database = 'fred'; + const appName = 'my-node-crasher'; + const appVersion = '1.2.3.4'; + const appKey = 'Key!'; + const user = 'User!'; + const email = 'fred@bedrock.com'; + const title = 'Symbol Store Size Limit'; + const description = + 'I hit the 8 GB symbol size limit and can no longer upload symbols'; + const additionalFile = './spec/files/additionalFile.txt'; + const fileName = path.basename(additionalFile); + const fileContents = await readFile(additionalFile); + const bugsplat = new BugSplat(database, appName, appVersion); + bugsplat.setDefaultAppKey(appKey); + bugsplat.setDefaultUser(user); + bugsplat.setDefaultEmail(email); + bugsplat.setDefaultDescription(description); + + const result = await bugsplat.postFeedback(title, { + attachments: [ + { data: new Uint8Array(fileContents), filename: fileName }, + ], + attributes: { foo: 'bar' }, + }); + if (result.error) { + throw new Error(result.error.message); + } + + const expectedCrashId = result.response.crashId; const crashData = await client.getCrashById(database, expectedCrashId); expect(crashData.appName).toEqual(appName); expect(crashData.appVersion).toEqual(appVersion); @@ -76,7 +115,7 @@ describe('BugSplat', () => { if (result.error) { throw new Error(result.error.message); } - const expectedCrashId = result.response.crash_id; + const expectedCrashId = result.response.crashId; const crashData = await client.getCrashById(database, expectedCrashId); expect(crashData.appName).toEqual(appName); diff --git a/spec/bugsplat.spec.ts b/spec/bugsplat.spec.ts index 0aefbeb..ec20982 100644 --- a/spec/bugsplat.spec.ts +++ b/spec/bugsplat.spec.ts @@ -4,7 +4,6 @@ import { createStandardizedCallStack, tryParseResponseJson, } from '../src/bugsplat'; -import { Blob } from 'buffer'; describe('createStandardizedCallStack', () => { it('should always return a call stack containing "Error:"', () => { @@ -72,315 +71,255 @@ describe('BugSplat', function () { const database = 'fred'; const appName = 'my-node-crasher'; const appVersion = '1.0.0.0'; - const expectedStatus = 'success'; const expectedCrashId = 73180; - let bugsplat; + let bugsplat: InstanceType; let appendSpy: Mock; let fakeFormData; - let fakeCrashResponse; - let fakeSuccessResponseBody; let fetchSpy: Mock; + const fakePresignedUrlResponse = { + ok: true, + json: async () => ({ url: 'https://s3.example.com/presigned' }), + }; + + const fakePutResponse = { + ok: true, + headers: { get: (key: string) => key === 'ETag' ? '"abc123"' : null }, + }; + + const fakeCommitResponse = { + ok: true, + status: 200, + json: async () => ({ + status: 'success', + crashId: expectedCrashId, + stackKeyId: 1, + messageId: 1, + infoUrl: 'https://app.bugsplat.com/browse/crashInfo.php', + }), + }; + beforeEach(() => { appendSpy = vi.fn(); fakeFormData = { append: appendSpy, toString: () => 'BugSplat rocks!' }; - fakeCrashResponse = { - status: 'success', - current_server_time: 1, - message: 'BugSplat rocks!', - url: 'bugsplat.rocks/yes-its-true', - crash_id: expectedCrashId, - }; - fakeSuccessResponseBody = { - status: expectedStatus, - json: async () => fakeCrashResponse, - ok: true, - }; bugsplat = new BugSplat(database, appName, appVersion); - bugsplat._formData = () => fakeFormData; + (bugsplat as any)._formData = () => fakeFormData; fetchSpy = vi.spyOn(globalThis, 'fetch') as unknown as Mock; + fetchSpy + .mockResolvedValueOnce(fakePresignedUrlResponse) + .mockResolvedValueOnce(fakePutResponse) + .mockResolvedValueOnce(fakeCommitResponse); }); - describe('when options.additionalFormDataParams is set', () => { - it('should call append with key and value if passed string value', async () => { - const key = 'attachment.txt'; - const value = '🐶'; - const additionalFormDataParams = [{ key, value }]; - fetchSpy.mockResolvedValue(fakeSuccessResponseBody); + describe('presigned URL upload flow', () => { + it('should request a presigned URL with correct query params', async () => { + await bugsplat.post(new Error('BugSplat!')); - await bugsplat.post(new Error('BugSplat!'), { - additionalFormDataParams, - }); + const url = new URL(fetchSpy.mock.calls[0][0]); + expect(url.pathname).toEqual('/api/getCrashUploadUrl'); + expect(url.searchParams.get('database')).toEqual(database); + expect(url.searchParams.get('appName')).toEqual(appName); + expect(url.searchParams.get('appVersion')).toEqual(appVersion); + }); - expect(appendSpy).toHaveBeenCalledWith(key, value); + it('should PUT to the presigned URL', async () => { + await bugsplat.post(new Error('BugSplat!')); + + expect(fetchSpy.mock.calls[1][0]).toEqual('https://s3.example.com/presigned'); + expect(fetchSpy.mock.calls[1][1].method).toEqual('PUT'); }); - it('should call append with key, value and filename if passed Blob value', async () => { - const key = 'attachment.txt'; - const value = new Blob([]); - const filename = key; - const additionalFormDataParams = [{ key, value, filename }]; - fetchSpy.mockResolvedValue(fakeSuccessResponseBody); + it('should commit with correct form data fields', async () => { + const appKey = 'myKey'; + const user = 'myUser'; + const email = 'my@email.com'; + const description = 'myDescription'; await bugsplat.post(new Error('BugSplat!'), { - additionalFormDataParams, + appKey, + user, + email, + description, }); - expect(appendSpy).toHaveBeenCalledWith(key, value, filename); + expect(appendSpy).toHaveBeenCalledWith('database', database); + expect(appendSpy).toHaveBeenCalledWith('appName', appName); + expect(appendSpy).toHaveBeenCalledWith('appVersion', appVersion); + expect(appendSpy).toHaveBeenCalledWith('crashTypeId', '14'); + expect(appendSpy).toHaveBeenCalledWith('s3Key', 'https://s3.example.com/presigned'); + expect(appendSpy).toHaveBeenCalledWith('md5', 'abc123'); + expect(appendSpy).toHaveBeenCalledWith('appKey', appKey); + expect(appendSpy).toHaveBeenCalledWith('user', user); + expect(appendSpy).toHaveBeenCalledWith('email', email); + expect(appendSpy).toHaveBeenCalledWith('description', description); }); - }); - it('should use default appKey if options.appKey is not set', async () => { - await createDefaultPropertyTest( - bugsplat, - 'appKey', - 'defaultAppKey', - bugsplat.setDefaultAppKey.bind(bugsplat) - ); - }); - - it('should use options.appKey if set', async () => { - const appKey = 'overridenAppKey'; - await createOptionsOverrideTest(bugsplat, { appKey }, 'appKey', appKey); - }); + it('should commit with crashTypeId 36 for feedback', async () => { + await bugsplat.postFeedback('My feedback'); - it('should use default user if options.user is not set', async () => { - await createDefaultPropertyTest( - bugsplat, - 'user', - 'defaultUser', - bugsplat.setDefaultUser.bind(bugsplat) - ); + expect(appendSpy).toHaveBeenCalledWith('crashTypeId', '36'); + }); }); - it('should use options.user if set', async () => { - const user = 'overridenUser'; - await createOptionsOverrideTest(bugsplat, { user }, 'user', user); - }); + describe('when options.attributes is set', () => { + it('should append JSON-serialized attributes to commit body', async () => { + const attributes = { foo: 'bar', baz: 'qux' }; - it('should use default email if options.email is not set', async () => { - await createDefaultPropertyTest( - bugsplat, - 'email', - 'defaultEmail', - bugsplat.setDefaultEmail.bind(bugsplat) - ); - }); + await bugsplat.post(new Error('BugSplat!'), { attributes }); - it('should use options.email if set', async () => { - const email = 'overridenEmail'; - await createOptionsOverrideTest(bugsplat, { email }, 'email', email); - }); + expect(appendSpy).toHaveBeenCalledWith( + 'attributes', + JSON.stringify(attributes) + ); + }); - it('should use default description if options.description is not set', async () => { - await createDefaultPropertyTest( - bugsplat, - 'description', - 'defaultDescription', - bugsplat.setDefaultDescription.bind(bugsplat) - ); - }); + it('should not append attributes if empty object', async () => { + await bugsplat.post(new Error('BugSplat!'), { attributes: {} }); - it('should use options.description if set', async () => { - const description = 'overridenDescription'; - await createOptionsOverrideTest( - bugsplat, - { description }, - 'description', - description - ); - }); + expect(appendSpy).not.toHaveBeenCalledWith( + 'attributes', + expect.anything() + ); + }); - it('should append database to post body', async () => { - await createDefaultPropertyTest(bugsplat, 'database', database); - }); + it('should use default attributes if options.attributes is not set', async () => { + const attributes = { env: 'production' }; + bugsplat.setDefaultAttributes(attributes); - it('should append appName to post body', async () => { - await createDefaultPropertyTest(bugsplat, 'appName', appName); - }); + await bugsplat.post(new Error('BugSplat!'), {}); - it('should append appVersion to post body', async () => { - await createDefaultPropertyTest(bugsplat, 'appVersion', appVersion); - }); + expect(appendSpy).toHaveBeenCalledWith( + 'attributes', + JSON.stringify(attributes) + ); + }); - it('should append callstack to post body', async () => { - const expectedError = new Error('BugSplat!'); - fetchSpy.mockResolvedValue(fakeSuccessResponseBody); + it('should use options.attributes over default attributes', async () => { + const defaultAttributes = { env: 'production' }; + const overrideAttributes = { env: 'staging' }; + bugsplat.setDefaultAttributes(defaultAttributes); - await bugsplat.post(expectedError, {}); + await bugsplat.post(new Error('BugSplat!'), { + attributes: overrideAttributes, + }); - expect(appendSpy).toHaveBeenCalledWith( - 'callstack', - expectedError.stack - ); + expect(appendSpy).toHaveBeenCalledWith( + 'attributes', + JSON.stringify(overrideAttributes) + ); + }); }); - it('should create a stack if none was provided', async () => { - const expectedError = 'Error without a stack!'; - fetchSpy.mockResolvedValue(fakeSuccessResponseBody); - - await bugsplat.post(expectedError, {}); - - expect(appendSpy).toHaveBeenCalledWith( - 'callstack', - expect.stringMatching( - new RegExp( - `Error: ${expectedError}.*\n.*at BugSplat\\.` - ) - ) - ); + it('should use default appKey if options.appKey is not set', async () => { + bugsplat.setDefaultAppKey('defaultAppKey'); + await bugsplat.post(new Error('BugSplat!'), {}); + expect(appendSpy).toHaveBeenCalledWith('appKey', 'defaultAppKey'); }); - it('should create a stack if stack is only spaces', async () => { - const expectedError = ' '; - fetchSpy.mockResolvedValue(fakeSuccessResponseBody); - - await bugsplat.post(expectedError, {}); - - expect(appendSpy).toHaveBeenCalledWith( - 'callstack', - expect.stringMatching( - new RegExp( - `Error: ${expectedError}.*\n.*at BugSplat\\.` - ) - ) - ); + it('should use options.appKey if set', async () => { + await bugsplat.post(new Error('BugSplat!'), { appKey: 'overridenAppKey' }); + expect(appendSpy).toHaveBeenCalledWith('appKey', 'overridenAppKey'); }); - it('should reconstruct error line of callstack if not provided by browser (Safari)', async () => { - const error = { - message: 'Stack without a message', - stack: 'handlError/<@https://app.bugsplat.com/v2/main-es2015.32bd4307e375ff22d168.js:1:1413880>', - }; - fetchSpy.mockResolvedValue(fakeSuccessResponseBody); - - await bugsplat.post(error, {}); - - expect(appendSpy).toHaveBeenCalledWith( - 'callstack', - expect.stringMatching( - new RegExp(`Error: ${error.message}.*\n${error.stack}`) - ) - ); + it('should use default user if options.user is not set', async () => { + bugsplat.setDefaultUser('defaultUser'); + await bugsplat.post(new Error('BugSplat!'), {}); + expect(appendSpy).toHaveBeenCalledWith('user', 'defaultUser'); }); - it('should call fetch url containing database', async () => { - fetchSpy.mockResolvedValue(fakeSuccessResponseBody); - - await bugsplat.post(new Error('BugSplat!')); + it('should use options.user if set', async () => { + await bugsplat.post(new Error('BugSplat!'), { user: 'overridenUser' }); + expect(appendSpy).toHaveBeenCalledWith('user', 'overridenUser'); + }); - expect(fetchSpy).toHaveBeenCalledWith( - `https://${database}.bugsplat.com/post/js/`, - expect.anything() - ); + it('should use default email if options.email is not set', async () => { + bugsplat.setDefaultEmail('defaultEmail'); + await bugsplat.post(new Error('BugSplat!'), {}); + expect(appendSpy).toHaveBeenCalledWith('email', 'defaultEmail'); }); - it('should call fetch with method and body', async () => { - fetchSpy.mockResolvedValue(fakeSuccessResponseBody); + it('should use options.email if set', async () => { + await bugsplat.post(new Error('BugSplat!'), { email: 'overridenEmail' }); + expect(appendSpy).toHaveBeenCalledWith('email', 'overridenEmail'); + }); - await bugsplat.post(new Error('BugSplat!')); + it('should use default description if options.description is not set', async () => { + bugsplat.setDefaultDescription('defaultDescription'); + await bugsplat.post(new Error('BugSplat!'), {}); + expect(appendSpy).toHaveBeenCalledWith('description', 'defaultDescription'); + }); - expect(fetchSpy).toHaveBeenCalledWith( - expect.anything(), - expect.objectContaining({ - method: 'POST', - body: fakeFormData, - }) - ); + it('should use options.description if set', async () => { + await bugsplat.post(new Error('BugSplat!'), { description: 'overridenDescription' }); + expect(appendSpy).toHaveBeenCalledWith('description', 'overridenDescription'); }); - it('should return response body and original error if BugSplat POST returns 200', async () => { + it('should return response body and original error on success', async () => { const errorToPost = new Error('BugSplat!'); - fetchSpy.mockResolvedValue(fakeSuccessResponseBody); const result = await bugsplat.post(errorToPost, {}); - expect(result.error).toBeFalsy(); - expect(result.response.crash_id).toEqual(expectedCrashId); - expect(result.original.message).toEqual(errorToPost.message); + expect(result.error).toBeNull(); + if (!result.error) { + expect(result.response.crashId).toEqual(expectedCrashId); + } + expect(result.original).toBe(errorToPost); }); - it('should return BugSplat error, response body and original error if BugSplat POST returns an invalid response', async () => { - const errorToPost = new Error('BugSplat!'); - fetchSpy.mockResolvedValue({ - status: 200, - json: async () => ({}), - ok: true, - }); + it('should return error if presigned URL request fails', async () => { + fetchSpy.mockReset(); + fetchSpy.mockResolvedValueOnce({ ok: false }); - const result = await bugsplat.post(errorToPost, {}); - expect(result.error.message).toEqual( - 'BugSplat Error: Invalid response received' - ); - expect(result.original.message).toEqual(errorToPost.message); - }); + const result = await bugsplat.post(new Error('BugSplat!'), {}); - it('should return BugSplat error, response body and original error if BugSplat POST returns 400', async () => { - const errorToPost = new Error('BugSplat!'); - fetchSpy.mockResolvedValue({ - status: 400, - json: async () => ({}), - }); - - const result = await bugsplat.post(errorToPost, {}); - expect(result.error.message).toEqual('BugSplat Error: Bad request'); - expect(result.original.message).toEqual(errorToPost.message); + expect(result.error).not.toBeNull(); + expect(result.error!.message).toEqual('BugSplat Error: Failed to get upload URL'); }); - it('should return BugSplat error, response body and original error if BugSplat POST returns 429', async () => { - const errorToPost = new Error('BugSplat!'); - fetchSpy.mockResolvedValue({ - status: 429, - json: async () => ({}), - }); + it('should return error if S3 upload fails', async () => { + fetchSpy.mockReset(); + fetchSpy + .mockResolvedValueOnce(fakePresignedUrlResponse) + .mockResolvedValueOnce({ ok: false }); - const result = await bugsplat.post(errorToPost, {}); - expect(result.error.message).toEqual( - 'BugSplat Error: Rate limit of one crash per second exceeded' - ); - expect(result.original.message).toEqual(errorToPost.message); - }); + const result = await bugsplat.post(new Error('BugSplat!'), {}); - it('should return BugSplat error, response body and original error for unknown BugSplat POST error', async () => { - const errorToPost = new Error('BugSplat!'); - fetchSpy.mockResolvedValue({ - status: 500, - json: async () => ({}), - ok: false, - }); - - const result = await bugsplat.post(errorToPost, {}); - expect(result.error.message).toEqual('BugSplat Error: Unknown error'); - expect(result.original.message).toEqual(errorToPost.message); + expect(result.error).not.toBeNull(); + expect(result.error!.message).toEqual('BugSplat Error: Failed to upload to S3'); }); - async function createDefaultPropertyTest( - bugsplat, - propertyName, - propertyValue, - propertySetter = (_value) => { - // no-op - } - ) { - fetchSpy.mockResolvedValue(fakeSuccessResponseBody); + it('should return error if commit fails', async () => { + fetchSpy.mockReset(); + fetchSpy + .mockResolvedValueOnce(fakePresignedUrlResponse) + .mockResolvedValueOnce(fakePutResponse) + .mockResolvedValueOnce({ + ok: false, + status: 500, + json: async () => ({}), + }); - propertySetter(propertyValue); - await bugsplat.post(new Error('BugSplat!'), {}); + const result = await bugsplat.post(new Error('BugSplat!'), {}); - expect(appendSpy).toHaveBeenCalledWith(propertyName, propertyValue); - } + expect(result.error).not.toBeNull(); + expect(result.error!.message).toEqual('BugSplat Error: Failed to commit upload'); + }); - async function createOptionsOverrideTest( - bugsplat, - postOptions, - propertyName, - propertyValue - ) { - fetchSpy.mockResolvedValue(fakeSuccessResponseBody); + it('should return error if commit response is invalid', async () => { + fetchSpy.mockReset(); + fetchSpy + .mockResolvedValueOnce(fakePresignedUrlResponse) + .mockResolvedValueOnce(fakePutResponse) + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({}), + }); - await bugsplat.post(new Error('BugSplat!'), postOptions); + const result = await bugsplat.post(new Error('BugSplat!'), {}); - expect(appendSpy).toHaveBeenCalledWith(propertyName, propertyValue); - } + expect(result.error).not.toBeNull(); + expect(result.error!.message).toEqual('BugSplat Error: Invalid response received'); + }); }); diff --git a/src/bugsplat-options.ts b/src/bugsplat-options.ts index 4ed47fe..e3d7cb1 100644 --- a/src/bugsplat-options.ts +++ b/src/bugsplat-options.ts @@ -1,38 +1,5 @@ -import { FormDataParam } from './form-data-param'; - -/** - * Additional parameters that can be passed to `post()` - * - * If any of `appKey`, `user`, `email`, `description` are set, - * the corresponding default values will be overwritten - */ -export interface BugSplatOptions { - /** - * Define arbitrary fields to be appended to the form data - * object to be sent. This is useful to pass any additional - * data as string or `blob`. - */ - additionalFormDataParams?: Array; - /** - * Additional metadata that can be queried via BugSplat's web application - */ - appKey?: string; - /** - * Additional info about your crash that gets reset after every post - */ - description?: string; - /** - * The email of your user - */ - email?: string; - /** - * The name or id of your user - */ - user?: string; -} - /** - * A file attachment to include in the feedback zip (e.g. a screenshot). + * A file attachment to include in the upload zip. */ export interface BugSplatAttachment { /** @@ -46,31 +13,35 @@ export interface BugSplatAttachment { } /** - * Additional parameters that can be passed to `postFeedback()` + * Additional parameters that can be passed to `post()` or `postFeedback()` + * + * If any of `appKey`, `user`, `email`, `description` are set, + * the corresponding default values will be overwritten */ -export interface BugSplatFeedbackOptions { +export interface BugSplatOptions { /** - * Additional feedback context + * Additional metadata that can be queried via BugSplat's web application */ - description?: string; + appKey?: string; /** - * The email of the user submitting feedback + * Key/value attributes to attach to the report. + * These are searchable via BugSplat's web application. */ - email?: string; + attributes?: Record; /** - * The name or id of the user submitting feedback + * File attachments to include in the upload zip */ - user?: string; + attachments?: Array; /** - * Additional metadata that can be queried via BugSplat's web application + * Additional info about the crash or feedback */ - appKey?: string; + description?: string; /** - * File attachments to include in the feedback zip (e.g. screenshots) + * The email of your user */ - attachments?: Array; + email?: string; /** - * Define arbitrary fields to be appended to the commit form data + * The name or id of your user */ - additionalFormDataParams?: Array; + user?: string; } diff --git a/src/bugsplat-response.ts b/src/bugsplat-response.ts index 6413e4a..2c9bba2 100644 --- a/src/bugsplat-response.ts +++ b/src/bugsplat-response.ts @@ -1,5 +1,5 @@ /** - * Parsed response from BugSplat crash post API. + * Parsed response from BugSplat's commit S3 upload API. */ export interface BugSplatResponseBody { /** @@ -7,23 +7,21 @@ export interface BugSplatResponseBody { */ status: 'success' | 'fail'; /** - * Server time in seconds since epoch. - * - * Get a Date object with: - * ``` - * new Date(response.current_server_time * 1000) - * ``` + * Id of the newly created crash report */ - current_server_time: number; - message: string; + crashId: number; /** - * Support response url + * Id of the stack key group */ - url?: string; + stackKeyId: number; /** - * Id of the newly created crash report + * Id of the message + */ + messageId: number; + /** + * URL to view the crash info */ - crash_id: number; + infoUrl: string; } export interface BugSplatResponseType { @@ -32,11 +30,11 @@ export interface BugSplatResponseType { */ error: ErrorType; /** - * Crash response object. Validated if `error` is null. + * Response object. Validated if `error` is null. */ response: ErrorType extends null ? BugSplatResponseBody : unknown; /** - * The original error posted to BugSplat. + * The original error or title posted to BugSplat. */ original: Error | string; } @@ -49,7 +47,6 @@ const isObject = (val: unknown): val is object => typeof val === 'object' && val !== null; const isString = (val: unknown): val is string => typeof val === 'string'; const isNumber = (val: unknown): val is number => typeof val === 'number'; -const isUndefined = (val: unknown): val is undefined => val === undefined; /** * Ensure the response body has the expected properties. @@ -63,10 +60,10 @@ export function validateResponseBody( const conditions = [ ['success', 'fail'].includes(response['status']), - isNumber(response['current_server_time']), - isString(response['message']), - isString(response['url']) || isUndefined(response['url']), - isNumber(response['crash_id']), + isNumber(response['crashId']), + isNumber(response['stackKeyId']), + isNumber(response['messageId']), + isString(response['infoUrl']), ]; return conditions.every(Boolean); diff --git a/src/bugsplat.ts b/src/bugsplat.ts index c877875..f8aad36 100644 --- a/src/bugsplat.ts +++ b/src/bugsplat.ts @@ -1,12 +1,11 @@ import { zipSync, strToU8 } from 'fflate'; -import type { BugSplatFeedbackOptions, BugSplatOptions } from './bugsplat-options'; +import type { BugSplatOptions } from './bugsplat-options'; import { type BugSplatResponse, type BugSplatResponseBody, type BugSplatResponseType, validateResponseBody, } from './bugsplat-response'; -import { isFormDataParamString } from './form-data-param'; export function createStandardizedCallStack(error: Error): string { if (!error.stack?.includes('Error:')) { @@ -35,13 +34,13 @@ export async function tryParseResponseJson(response: { const isError = (val: unknown): val is Error => Boolean((val as Error)?.stack); /** - * BugSplat crash posting client. Facilitates sending - * crash reports through the `post()` method. + * BugSplat crash and feedback posting client. */ export class BugSplat { private _formData = () => new FormData(); private _appKey = ''; + private _attributes: Record = {}; private _description = ''; private _email = ''; private _user = ''; @@ -53,7 +52,7 @@ export class BugSplat { ) {} /** - * Posts an arbitrary Error object to BugSplat + * Posts an arbitrary Error object to BugSplat via presigned URL upload * @param errorToPost - Error object or a message to be sent to BugSplat * @param options - Additional parameters that can be sent to BugSplat */ @@ -63,97 +62,40 @@ export class BugSplat { ): Promise { options = options || {}; - const appKey = options.appKey || this._appKey; - const user = options.user || this._user; - const email = options.email || this._email; const description = options.description || this._description; - const additionalFormDataParams = options.additionalFormDataParams || []; const callstack = createStandardizedCallStack( isError(errorToPost) ? errorToPost : new Error(errorToPost) ); - const url = process.env.BUGSPLAT_CRASH_POST_URL || 'https://' + this.database + '.bugsplat.com/post/js/'; - const method = 'POST'; - const body = this._formData(); - body.append('database', this.database); - body.append('appName', this.application); - body.append('appVersion', this.version); - body.append('appKey', appKey); - body.append('user', user); - body.append('email', email); - body.append('description', description); - body.append('callstack', callstack); - additionalFormDataParams.forEach((param) => { - if (isFormDataParamString(param)) { - body.append(param.key, param.value); - } else { - body.append(param.key, param.value, param.filename); - } - }); - - console.log('BugSplat Error:', errorToPost); - console.log('BugSplat Url:', url); - - const response = await globalThis.fetch(url, { method, body }); - const json = await tryParseResponseJson(response); - - console.log('BugSplat POST status code:', response.status); - console.log('BugSplat POST response body:', json); - - if (response.status === 400) { - return this._createReturnValue( - new Error('BugSplat Error: Bad request'), - json, - errorToPost - ); - } - - if (response.status === 429) { - return this._createReturnValue( - new Error( - 'BugSplat Error: Rate limit of one crash per second exceeded' - ), - json, - errorToPost - ); - } - - if (!response.ok) { - return this._createReturnValue( - new Error('BugSplat Error: Unknown error'), - json, - errorToPost - ); + const crashJson = JSON.stringify({ callstack, description }); + const zipFiles: Record = { + 'crash.json': strToU8(crashJson), + }; + for (const attachment of options.attachments || []) { + const bytes = attachment.data instanceof Uint8Array + ? attachment.data + : new Uint8Array(await attachment.data.arrayBuffer()); + zipFiles[attachment.filename] = bytes; } - if (!validateResponseBody(json)) { - return this._createReturnValue( - new Error('BugSplat Error: Invalid response received'), - json, - errorToPost - ); - } + console.log('BugSplat Error:', errorToPost); - return this._createReturnValue(null, json, errorToPost); + return this._upload(zipFiles, '14', options, errorToPost); } /** - * Posts user feedback to BugSplat via the presigned URL upload flow + * Posts user feedback to BugSplat via presigned URL upload * @param title - Feedback title, used as the stack key for grouping * @param options - Additional parameters for the feedback submission */ async postFeedback( title: string, - options?: BugSplatFeedbackOptions + options?: BugSplatOptions ): Promise { options = options || {}; - const appKey = options.appKey || this._appKey; - const user = options.user || this._user; - const email = options.email || this._email; const description = options.description || this._description; - // Create zip with feedback.json and any attachments const feedbackJson = JSON.stringify({ title, description }); const zipFiles: Record = { 'feedback.json': strToU8(feedbackJson), @@ -164,19 +106,74 @@ export class BugSplat { : new Uint8Array(await attachment.data.arrayBuffer()); zipFiles[attachment.filename] = bytes; } - const zipData = zipSync(zipFiles); - const baseUrl = process.env.BUGSPLAT_CRASH_POST_URL?.replace(/\/post\/js\/?$/, '') || `https://${this.database}.bugsplat.com`; + console.log('BugSplat Feedback:', title); + + return this._upload(zipFiles, '36', options, title); + } + + /** + * Additional metadata that can be queried via BugSplat's web application + */ + setDefaultAppKey(appKey: string): void { + this._appKey = appKey; + } + + /** + * Key/value attributes to attach to crash and feedback reports. + * These are searchable via BugSplat's web application. + */ + setDefaultAttributes(attributes: Record): void { + this._attributes = attributes; + } + + /** + * Additional info about your crash that gets reset after every post + */ + setDefaultDescription(description: string): void { + this._description = description; + } + + /** + * The email of your user + */ + setDefaultEmail(email: string): void { + this._email = email; + } + + /** + * The name or id of your user + */ + setDefaultUser(user: string): void { + this._user = user; + } + + /** + * Shared presigned URL upload flow used by both post() and postFeedback(). + */ + private async _upload( + zipFiles: Record, + crashTypeId: string, + options: BugSplatOptions, + original: Error | string + ): Promise { + const appKey = options.appKey || this._appKey; + const attributes = options.attributes || this._attributes; + const user = options.user || this._user; + const email = options.email || this._email; + const description = options.description || this._description; + + const zipData = zipSync(zipFiles); + const baseUrl = process.env.BUGSPLAT_BASE_URL || `https://${this.database}.bugsplat.com`; // Step 1: Get presigned upload URL const getCrashUrlParams = new URLSearchParams({ database: this.database, appName: this.application, appVersion: this.version, - crashPostSize: String(zipData.byteLength) + crashPostSize: String(zipData.byteLength), }); - console.log('BugSplat Feedback:', title); console.log('BugSplat: Getting presigned URL...'); const getCrashUrlResponse = await globalThis.fetch( @@ -187,54 +184,48 @@ export class BugSplat { return this._createReturnValue( new Error('BugSplat Error: Failed to get upload URL'), {}, - title + original ); } const { url: presignedUrl } = await getCrashUrlResponse.json() as { url: string }; // Step 2: Upload zip to S3 - console.log('BugSplat: Uploading feedback...'); + console.log('BugSplat: Uploading...'); const putResponse = await globalThis.fetch(presignedUrl, { method: 'PUT', body: new Blob([zipData.buffer as ArrayBuffer], { type: 'application/zip' }), - headers: { 'Content-Type': 'application/zip' } + headers: { 'Content-Type': 'application/zip' }, }); if (!putResponse.ok) { return this._createReturnValue( new Error('BugSplat Error: Failed to upload to S3'), {}, - title + original ); } const etag = putResponse.headers.get('ETag')?.replace(/"/g, '') || ''; // Step 3: Commit the upload - console.log('BugSplat: Committing feedback...'); + console.log('BugSplat: Committing...'); const commitBody = this._formData(); commitBody.append('database', this.database); commitBody.append('appName', this.application); commitBody.append('appVersion', this.version); - commitBody.append('crashTypeId', '36'); + commitBody.append('crashTypeId', crashTypeId); commitBody.append('s3Key', presignedUrl); commitBody.append('md5', etag); commitBody.append('appKey', appKey); commitBody.append('user', user); commitBody.append('email', email); commitBody.append('description', description); - - const additionalFormDataParams = options.additionalFormDataParams || []; - additionalFormDataParams.forEach((param) => { - if (isFormDataParamString(param)) { - commitBody.append(param.key, param.value); - } else { - commitBody.append(param.key, param.value, param.filename); - } - }); + if (Object.keys(attributes).length > 0) { + commitBody.append('attributes', JSON.stringify(attributes)); + } const commitResponse = await globalThis.fetch( `${baseUrl}/api/commitS3CrashUpload`, @@ -248,9 +239,9 @@ export class BugSplat { if (!commitResponse.ok) { return this._createReturnValue( - new Error('BugSplat Error: Failed to commit feedback'), + new Error('BugSplat Error: Failed to commit upload'), json, - title + original ); } @@ -258,39 +249,11 @@ export class BugSplat { return this._createReturnValue( new Error('BugSplat Error: Invalid response received'), json, - title + original ); } - return this._createReturnValue(null, json, title); - } - - /** - * Additional metadata that can be queried via BugSplat's web application - */ - setDefaultAppKey(appKey: string): void { - this._appKey = appKey; - } - - /** - * Additional info about your crash that gets reset after every post - */ - setDefaultDescription(description: string): void { - this._description = description; - } - - /** - * The email of your user - */ - setDefaultEmail(email: string): void { - this._email = email; - } - - /** - * The name or id of your user - */ - setDefaultUser(user: string): void { - this._user = user; + return this._createReturnValue(null, json, original); } private _createReturnValue( diff --git a/src/form-data-param.ts b/src/form-data-param.ts deleted file mode 100644 index 6ad9afa..0000000 --- a/src/form-data-param.ts +++ /dev/null @@ -1,30 +0,0 @@ -interface FormDataParamType { - /** - * The name of the field whose data is contained in `value`. - */ - key: string; - /** - * The field's value. - */ - value: T; - /** - * The filename reported to the server - * when `value` is a `Blob` or `File` - */ - filename?: T extends string ? never : string; -} - -/** - * Simple object that is used to construct `FormData` objects. - * @see https://developer.mozilla.org/en-US/docs/Web/API/FormData/append - */ -export type FormDataParam = FormDataParamType | FormDataParamType; - -/** - * Check if FormField has a string value - */ -export function isFormDataParamString( - param: FormDataParam -): param is FormDataParamType { - return typeof param.value === 'string'; -} diff --git a/src/index.ts b/src/index.ts index 8bea438..7b7d22d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,3 @@ export { BugSplat } from './bugsplat'; -export type { BugSplatAttachment, BugSplatFeedbackOptions, BugSplatOptions } from './bugsplat-options'; +export type { BugSplatAttachment, BugSplatOptions } from './bugsplat-options'; export type { BugSplatResponse, BugSplatResponseBody, BugSplatResponseType, validateResponseBody } from './bugsplat-response'; -export type { FormDataParam } from './form-data-param'; From 576458130f2fd7c5c1a5ecb55ef6004ddd143250 Mon Sep 17 00:00:00 2001 From: Bobby Galli Date: Wed, 11 Mar 2026 12:50:20 -0400 Subject: [PATCH 08/13] Fix no-explicit-any lint error in test file Co-Authored-By: Claude Opus 4.6 --- spec/bugsplat.spec.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/spec/bugsplat.spec.ts b/spec/bugsplat.spec.ts index ec20982..8db910b 100644 --- a/spec/bugsplat.spec.ts +++ b/spec/bugsplat.spec.ts @@ -73,7 +73,8 @@ describe('BugSplat', function () { const appVersion = '1.0.0.0'; const expectedCrashId = 73180; - let bugsplat: InstanceType; + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- untyped to allow mocking private fields + let bugsplat: any; let appendSpy: Mock; let fakeFormData; let fetchSpy: Mock; @@ -104,7 +105,7 @@ describe('BugSplat', function () { appendSpy = vi.fn(); fakeFormData = { append: appendSpy, toString: () => 'BugSplat rocks!' }; bugsplat = new BugSplat(database, appName, appVersion); - (bugsplat as any)._formData = () => fakeFormData; + bugsplat._formData = () => fakeFormData; fetchSpy = vi.spyOn(globalThis, 'fetch') as unknown as Mock; fetchSpy .mockResolvedValueOnce(fakePresignedUrlResponse) From 5070934804db34192c0332c9e2fb3f563aa5a09c Mon Sep 17 00:00:00 2001 From: Bobby Galli Date: Wed, 11 Mar 2026 12:51:14 -0400 Subject: [PATCH 09/13] Scope type suppression to private field mock only Use @ts-expect-error on the single _formData assignment instead of typing the whole bugsplat variable as any. Co-Authored-By: Claude Opus 4.6 --- spec/bugsplat.spec.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/spec/bugsplat.spec.ts b/spec/bugsplat.spec.ts index 8db910b..7f0f9af 100644 --- a/spec/bugsplat.spec.ts +++ b/spec/bugsplat.spec.ts @@ -73,8 +73,7 @@ describe('BugSplat', function () { const appVersion = '1.0.0.0'; const expectedCrashId = 73180; - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- untyped to allow mocking private fields - let bugsplat: any; + let bugsplat: BugSplat; let appendSpy: Mock; let fakeFormData; let fetchSpy: Mock; @@ -105,6 +104,7 @@ describe('BugSplat', function () { appendSpy = vi.fn(); fakeFormData = { append: appendSpy, toString: () => 'BugSplat rocks!' }; bugsplat = new BugSplat(database, appName, appVersion); + // @ts-expect-error -- accessing private field for test mocking bugsplat._formData = () => fakeFormData; fetchSpy = vi.spyOn(globalThis, 'fetch') as unknown as Mock; fetchSpy From 91cbdc741519a79075faa45c6df0ceb1c99dbe73 Mon Sep 17 00:00:00 2001 From: Bobby Galli Date: Wed, 11 Mar 2026 13:50:59 -0400 Subject: [PATCH 10/13] Update validateResponseBody tests for new response shape Co-Authored-By: Claude Opus 4.6 --- spec/bugsplat-response.spec.ts | 68 +++++++++++++++++----------------- 1 file changed, 34 insertions(+), 34 deletions(-) diff --git a/spec/bugsplat-response.spec.ts b/spec/bugsplat-response.spec.ts index aba7d0f..b17411f 100644 --- a/spec/bugsplat-response.spec.ts +++ b/spec/bugsplat-response.spec.ts @@ -6,24 +6,24 @@ describe('validateResponseBody', () => { const responseBodies = [ { status: 'success', - current_server_time: 12, - message: 'message 1', - url: 'osaiujdhfihfju', - crash_id: 9, + crashId: 9, + stackKeyId: 1, + messageId: 1, + infoUrl: 'https://app.bugsplat.com/browse/crashInfo.php', }, { status: 'fail', - current_server_time: 24, - message: 'message 2', - url: 'http://example.com', - crash_id: 100, + crashId: 100, + stackKeyId: 55, + messageId: 200, + infoUrl: 'https://app.bugsplat.com/browse/crashInfo.php?id=100', }, { status: 'success', - current_server_time: -100, - message: 'message 3', - url: 'http://bugsplat.com/rocks', - crash_id: 333, + crashId: 333, + stackKeyId: 0, + messageId: 0, + infoUrl: 'https://app.bugsplat.com/browse/crashInfo.php?id=333', }, ]; @@ -41,41 +41,41 @@ describe('validateResponseBody', () => { {}, { status: 'succes', - current_server_time: 12, - message: 'message 1', - url: 'osaiujdhfihfju', - crash_id: 9, + crashId: 9, + stackKeyId: 1, + messageId: 1, + infoUrl: 'https://app.bugsplat.com', }, { - status: 'fail', - current_server_time: 24, - message: 'message 2', - url: new Date(), - crash_id: 100, + status: 'success', + stackKeyId: 1, + messageId: 1, + infoUrl: 'https://app.bugsplat.com', }, { - current_server_time: -100, - message: 'message 3', - url: 'http://bugsplat.com/rocks', - crash_id: 333, + status: 'success', + crashId: 9, + messageId: 1, + infoUrl: 'https://app.bugsplat.com', }, { status: 'success', - message: 'message 3', - url: 'http://bugsplat.com/rocks', - crash_id: 333, + crashId: 9, + stackKeyId: 1, + infoUrl: 'https://app.bugsplat.com', }, { status: 'success', - current_server_time: -100, - url: 'http://bugsplat.com/rocks', - crash_id: 333, + crashId: 9, + stackKeyId: 1, + messageId: 1, }, { status: 'success', - current_server_time: -100, - message: 'message 3', - url: 'http://bugsplat.com/rocks', + crashId: 9, + stackKeyId: 1, + messageId: 1, + infoUrl: 42, }, ]; From 31ec09f6f75f3d5d3c458bf2309128d2307f0687 Mon Sep 17 00:00:00 2001 From: Bobby Galli Date: Wed, 11 Mar 2026 13:58:13 -0400 Subject: [PATCH 11/13] Use javascriptCallStack.txt instead of crash.json in zip Co-Authored-By: Claude Opus 4.6 --- src/bugsplat.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/bugsplat.ts b/src/bugsplat.ts index f8aad36..2ba7ecd 100644 --- a/src/bugsplat.ts +++ b/src/bugsplat.ts @@ -67,9 +67,8 @@ export class BugSplat { isError(errorToPost) ? errorToPost : new Error(errorToPost) ); - const crashJson = JSON.stringify({ callstack, description }); const zipFiles: Record = { - 'crash.json': strToU8(crashJson), + 'javascriptCallStack.txt': strToU8(callstack), }; for (const attachment of options.attachments || []) { const bytes = attachment.data instanceof Uint8Array From f9aa825ca414bcd024e555d82c13e7abd9458dd2 Mon Sep 17 00:00:00 2001 From: Bobby Galli Date: Wed, 11 Mar 2026 14:14:11 -0400 Subject: [PATCH 12/13] Make infoUrl optional in response validation The server doesn't always return infoUrl in the commit response, causing validateResponseBody to incorrectly reject valid responses. Co-Authored-By: Claude Opus 4.6 --- spec/bugsplat-response.spec.ts | 12 ++++++------ src/bugsplat-response.ts | 7 ++++--- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/spec/bugsplat-response.spec.ts b/spec/bugsplat-response.spec.ts index b17411f..6853edd 100644 --- a/spec/bugsplat-response.spec.ts +++ b/spec/bugsplat-response.spec.ts @@ -25,6 +25,12 @@ describe('validateResponseBody', () => { messageId: 0, infoUrl: 'https://app.bugsplat.com/browse/crashInfo.php?id=333', }, + { + status: 'success', + crashId: 21417, + stackKeyId: -1, + messageId: -1, + }, ]; responseBodies.forEach((body) => { @@ -64,12 +70,6 @@ describe('validateResponseBody', () => { stackKeyId: 1, infoUrl: 'https://app.bugsplat.com', }, - { - status: 'success', - crashId: 9, - stackKeyId: 1, - messageId: 1, - }, { status: 'success', crashId: 9, diff --git a/src/bugsplat-response.ts b/src/bugsplat-response.ts index 2c9bba2..932abaf 100644 --- a/src/bugsplat-response.ts +++ b/src/bugsplat-response.ts @@ -19,9 +19,9 @@ export interface BugSplatResponseBody { */ messageId: number; /** - * URL to view the crash info + * URL to view the crash info (not always present in server response) */ - infoUrl: string; + infoUrl?: string; } export interface BugSplatResponseType { @@ -47,6 +47,7 @@ const isObject = (val: unknown): val is object => typeof val === 'object' && val !== null; const isString = (val: unknown): val is string => typeof val === 'string'; const isNumber = (val: unknown): val is number => typeof val === 'number'; +const isUndefined = (val: unknown): val is undefined => typeof val === 'undefined'; /** * Ensure the response body has the expected properties. @@ -63,7 +64,7 @@ export function validateResponseBody( isNumber(response['crashId']), isNumber(response['stackKeyId']), isNumber(response['messageId']), - isString(response['infoUrl']), + isString(response['infoUrl']) || isUndefined(response['infoUrl']), ]; return conditions.every(Boolean); From 727d1804d72c5d4fb9c77dd49c28f5d56b8ecbba Mon Sep 17 00:00:00 2001 From: Bobby Galli Date: Wed, 11 Mar 2026 14:43:28 -0400 Subject: [PATCH 13/13] Fix unused variable lint error in post method The description variable was already resolved inside _upload, making the one in post redundant. Co-Authored-By: Claude Opus 4.6 --- src/bugsplat.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/bugsplat.ts b/src/bugsplat.ts index 2ba7ecd..8d73291 100644 --- a/src/bugsplat.ts +++ b/src/bugsplat.ts @@ -62,7 +62,6 @@ export class BugSplat { ): Promise { options = options || {}; - const description = options.description || this._description; const callstack = createStandardizedCallStack( isError(errorToPost) ? errorToPost : new Error(errorToPost) );