From bf1102907959b4bb1a749da61fcdfed421c073bc Mon Sep 17 00:00:00 2001 From: Bobby Galli Date: Fri, 17 Apr 2026 16:57:24 -0400 Subject: [PATCH 1/4] feat: support string and file-URI attachments for React Native MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Widens BugSplatAttachment.data to also accept: - string — appended as-is, avoids needlessly wrapping text in a Blob (which React Native's FormData polyfill can't serialize). - BugSplatFileRef ({ uri, type? }) — React Native's file-upload shape, used by expo-image-picker, react-native-view-shot, etc. Lets RN's fetch stream the file from disk instead of loading it into JS memory. Refactors the duplicated attachment loop in post()/postFeedback() into a single appendAttachment() helper that branches on the data shape. Co-Authored-By: Claude Opus 4.7 (1M context) --- spec/bugsplat.spec.ts | 56 +++++++++++++++++++++++++++++++++++++++++ src/bugsplat-options.ts | 28 +++++++++++++++++++-- src/bugsplat.ts | 51 ++++++++++++++++++++++++++++++------- src/index.ts | 2 +- 4 files changed, 125 insertions(+), 12 deletions(-) diff --git a/spec/bugsplat.spec.ts b/spec/bugsplat.spec.ts index 3c140db..1e372aa 100644 --- a/spec/bugsplat.spec.ts +++ b/spec/bugsplat.spec.ts @@ -1,5 +1,6 @@ import { describe, it, expect, beforeEach, vi, Mock } from 'vitest'; import { + appendAttachment, BugSplat, createStandardizedCallStack, tryParseResponseJson, @@ -369,3 +370,58 @@ describe('BugSplat', function () { expect(result.error!.message).toEqual('BugSplat Error: Invalid response received'); }); }); + +describe('appendAttachment', () => { + let append: Mock; + let body: FormData; + + beforeEach(() => { + append = vi.fn(); + body = { append } as unknown as FormData; + }); + + it('appends a string as-is (no Blob wrapping)', () => { + appendAttachment(body, { filename: 'stack.txt', data: 'component stack' }); + expect(append).toHaveBeenCalledWith('stack.txt', 'component stack'); + }); + + it('wraps a Uint8Array in a Blob before appending', () => { + const bytes = new Uint8Array([0x42, 0x53]); + appendAttachment(body, { filename: 'data.bin', data: bytes }); + expect(append).toHaveBeenCalledTimes(1); + const [filename, value, postedName] = append.mock.calls[0]; + expect(filename).toBe('data.bin'); + expect(value).toBeInstanceOf(Blob); + expect(postedName).toBe('data.bin'); + }); + + it('passes a Blob through with filename', () => { + const blob = new Blob(['hello']); + appendAttachment(body, { filename: 'hello.txt', data: blob }); + expect(append).toHaveBeenCalledWith('hello.txt', blob, 'hello.txt'); + }); + + it('appends a React Native file ref in {uri, name, type} shape', () => { + appendAttachment(body, { + filename: 'screenshot.png', + data: { uri: 'file:///tmp/shot.png', type: 'image/png' }, + }); + expect(append).toHaveBeenCalledWith( + 'screenshot.png', + { uri: 'file:///tmp/shot.png', type: 'image/png', name: 'screenshot.png' }, + 'screenshot.png' + ); + }); + + it('omits type when not provided on a file ref', () => { + appendAttachment(body, { + filename: 'log.txt', + data: { uri: 'file:///tmp/log.txt' }, + }); + expect(append).toHaveBeenCalledWith( + 'log.txt', + { uri: 'file:///tmp/log.txt', type: undefined, name: 'log.txt' }, + 'log.txt' + ); + }); +}); diff --git a/src/bugsplat-options.ts b/src/bugsplat-options.ts index e3d7cb1..6d8c8a8 100644 --- a/src/bugsplat-options.ts +++ b/src/bugsplat-options.ts @@ -1,3 +1,22 @@ +/** + * A reference to a file on the local filesystem. + * + * Used on React Native, where FormData streams files from disk via their URI + * (e.g. `file://`, `content://`, `ph://`) instead of reading them into JS memory. + * Most RN file libraries (`expo-image-picker`, `react-native-view-shot`, + * `expo-screen-capture`, etc.) already return values in this shape. + */ +export interface BugSplatFileRef { + /** + * The URI the runtime can read the file from. + */ + uri: string; + /** + * The MIME type, e.g. `image/png`. Optional but recommended. + */ + type?: string; +} + /** * A file attachment to include in the upload zip. */ @@ -7,9 +26,14 @@ export interface BugSplatAttachment { */ filename: string; /** - * The file contents + * The file contents. + * + * - `Blob` — browser `File`/`Blob` from an `` or `canvas.toBlob()`. + * - `Uint8Array` — raw binary buffer (wrapped in a `Blob` before upload). + * - `string` — appended as-is; use for text like a stack trace. + * - `BugSplatFileRef` — a `{ uri, type? }` reference for React Native file uploads. */ - data: Blob | Uint8Array; + data: Blob | Uint8Array | string | BugSplatFileRef; } /** diff --git a/src/bugsplat.ts b/src/bugsplat.ts index 4a621a7..8775402 100644 --- a/src/bugsplat.ts +++ b/src/bugsplat.ts @@ -1,4 +1,4 @@ -import type { BugSplatOptions } from './bugsplat-options'; +import type { BugSplatAttachment, BugSplatFileRef, BugSplatOptions } from './bugsplat-options'; import { type BugSplatResponse, type BugSplatResponseBody, @@ -32,6 +32,45 @@ export async function tryParseResponseJson(response: { const isError = (val: unknown): val is Error => Boolean((val as Error)?.stack); +function isFileRef(data: unknown): data is BugSplatFileRef { + return typeof data === 'object' && data !== null && typeof (data as BugSplatFileRef).uri === 'string'; +} + +/** + * Append an attachment to a multipart body, branching on the runtime shape of + * `data` so that each environment's FormData can serialize it. + * + * - `string` — appended as a plain field (works in browsers and React Native). + * - `Uint8Array` — wrapped in a `Blob` for browser FormData. + * - `Blob` — appended with filename so the server sees it as an upload. + * - `BugSplatFileRef` (`{ uri, type? }`) — appended in React Native's file + * shape so RN's fetch streams the file from disk. + */ +export function appendAttachment(body: FormData, attachment: BugSplatAttachment): void { + const { filename, data } = attachment; + + if (typeof data === 'string') { + body.append(filename, data); + return; + } + + if (data instanceof Uint8Array) { + body.append(filename, new Blob([data.buffer as ArrayBuffer]), filename); + return; + } + + if (isFileRef(data)) { + body.append( + filename, + { uri: data.uri, type: data.type, name: filename } as unknown as Blob, + filename + ); + return; + } + + body.append(filename, data, filename); +} + /** * BugSplat crash and feedback posting client. */ @@ -84,10 +123,7 @@ export class BugSplat { body.append('attributes', JSON.stringify(attributes)); } for (const attachment of options.attachments || []) { - const blob = attachment.data instanceof Uint8Array - ? new Blob([attachment.data.buffer as ArrayBuffer]) - : attachment.data; - body.append(attachment.filename, blob, attachment.filename); + appendAttachment(body, attachment); } console.log('BugSplat Error:', errorToPost); @@ -167,10 +203,7 @@ export class BugSplat { body.append('attributes', JSON.stringify(attributes)); } for (const attachment of options.attachments || []) { - const blob = attachment.data instanceof Uint8Array - ? new Blob([attachment.data.buffer as ArrayBuffer]) - : attachment.data; - body.append(attachment.filename, blob, attachment.filename); + appendAttachment(body, attachment); } console.log('BugSplat Feedback:', title); diff --git a/src/index.ts b/src/index.ts index 7b7d22d..8aca286 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,3 @@ export { BugSplat } from './bugsplat'; -export type { BugSplatAttachment, BugSplatOptions } from './bugsplat-options'; +export type { BugSplatAttachment, BugSplatFileRef, BugSplatOptions } from './bugsplat-options'; export type { BugSplatResponse, BugSplatResponseBody, BugSplatResponseType, validateResponseBody } from './bugsplat-response'; From 681ea559cb680e428988c98ffe7b2a00b6b50691 Mon Sep 17 00:00:00 2001 From: Bobby Galli Date: Fri, 17 Apr 2026 17:13:40 -0400 Subject: [PATCH 2/4] fix: wrap string attachments as real file parts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Appending a plain string to FormData with a filename arg still produces a regular form field per spec — the filename is ignored and the server never sees it as a file upload. That broke componentStack.txt: POSTs succeeded but BugSplat didn't surface the attachment. Wrap strings per platform so the multipart part gets a filename header: - Browser: new Blob([data], { type: 'text/plain' }) + 3-arg append. - React Native: encode as a base64 data URI inside the { uri, type, name } shape that RN's fetch uploads as a file. Avoids real Blob objects which RN's FormData polyfill can't serialize. Co-Authored-By: Claude Opus 4.7 (1M context) --- bugsplat-9.0.1.tgz | Bin 0 -> 8893 bytes spec/bugsplat.spec.ts | 34 +++++++++++++++++++++++++++++++-- src/bugsplat.ts | 43 +++++++++++++++++++++++++++++++++++++----- 3 files changed, 70 insertions(+), 7 deletions(-) create mode 100644 bugsplat-9.0.1.tgz diff --git a/bugsplat-9.0.1.tgz b/bugsplat-9.0.1.tgz new file mode 100644 index 0000000000000000000000000000000000000000..fc0070f644a1cc7c7c803bc77c093d56d9a0a026 GIT binary patch literal 8893 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 { body = { append } as unknown as FormData; }); - it('appends a string as-is (no Blob wrapping)', () => { + it('wraps a string in a text/plain Blob so it uploads as a file part (browser)', () => { appendAttachment(body, { filename: 'stack.txt', data: 'component stack' }); - expect(append).toHaveBeenCalledWith('stack.txt', 'component stack'); + expect(append).toHaveBeenCalledTimes(1); + const [fieldName, value, postedName] = append.mock.calls[0]; + expect(fieldName).toBe('stack.txt'); + expect(value).toBeInstanceOf(Blob); + expect((value as Blob).type).toBe('text/plain'); + expect(postedName).toBe('stack.txt'); + }); + + it('encodes a string as a data URI file ref on React Native', () => { + const originalNav = globalThis.navigator; + Object.defineProperty(globalThis, 'navigator', { + value: { product: 'ReactNative' }, + configurable: true, + }); + try { + appendAttachment(body, { filename: 'stack.txt', data: 'hello' }); + expect(append).toHaveBeenCalledTimes(1); + const [fieldName, value, postedName] = append.mock.calls[0]; + expect(fieldName).toBe('stack.txt'); + expect(value).toEqual({ + uri: 'data:text/plain;base64,aGVsbG8=', + type: 'text/plain', + name: 'stack.txt', + }); + expect(postedName).toBe('stack.txt'); + } finally { + Object.defineProperty(globalThis, 'navigator', { + value: originalNav, + configurable: true, + }); + } }); it('wraps a Uint8Array in a Blob before appending', () => { diff --git a/src/bugsplat.ts b/src/bugsplat.ts index 8775402..044cd1e 100644 --- a/src/bugsplat.ts +++ b/src/bugsplat.ts @@ -36,21 +36,54 @@ function isFileRef(data: unknown): data is BugSplatFileRef { return typeof data === 'object' && data !== null && typeof (data as BugSplatFileRef).uri === 'string'; } +function isReactNative(): boolean { + return typeof navigator !== 'undefined' && + (navigator as { product?: string }).product === 'ReactNative'; +} + +function utf8ToBase64(text: string): string { + if (typeof Buffer !== 'undefined') { + return Buffer.from(text, 'utf-8').toString('base64'); + } + // Browser-safe UTF-8 → base64 + return btoa(unescape(encodeURIComponent(text))); +} + /** * Append an attachment to a multipart body, branching on the runtime shape of - * `data` so that each environment's FormData can serialize it. + * `data` so that each environment's FormData serializes it as a real file part + * (with a `filename` in the `Content-Disposition` header) rather than a plain + * form field. * - * - `string` — appended as a plain field (works in browsers and React Native). + * - `string` — wrapped as a `text/plain` file part. On web this is a `Blob`; + * on React Native (where FormData can't serialize browser `Blob` objects) it + * becomes a base64 `data:` URI in RN's `{ uri, type, name }` shape. * - `Uint8Array` — wrapped in a `Blob` for browser FormData. * - `Blob` — appended with filename so the server sees it as an upload. - * - `BugSplatFileRef` (`{ uri, type? }`) — appended in React Native's file - * shape so RN's fetch streams the file from disk. + * - `BugSplatFileRef` (`{ uri, type? }`) — RN's file-upload shape, streamed + * from disk by RN's fetch. */ export function appendAttachment(body: FormData, attachment: BugSplatAttachment): void { const { filename, data } = attachment; if (typeof data === 'string') { - body.append(filename, data); + if (isReactNative()) { + body.append( + filename, + { + uri: `data:text/plain;base64,${utf8ToBase64(data)}`, + type: 'text/plain', + name: filename, + } as unknown as Blob, + filename + ); + } else { + body.append( + filename, + new Blob([data], { type: 'text/plain' }), + filename + ); + } return; } From a1877f87de9e01c5a4720026fb4ec7345d81cd1d Mon Sep 17 00:00:00 2001 From: Bobby Galli Date: Fri, 17 Apr 2026 17:19:27 -0400 Subject: [PATCH 3/4] refactor: drop string shape from attachment union MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Callers that have text to upload (like bugsplat-react's componentStack) should decide how to wrap it for their environment — Blob on web, BugSplatFileRef with a data URI on React Native. Handling that inside the SDK pulled in runtime-detection code for something only one consumer actually cares about. Keeping the union to `Blob | Uint8Array | BugSplatFileRef` leaves the SDK with one straight-line branch per shape and no platform checks. Co-Authored-By: Claude Opus 4.7 (1M context) --- bugsplat-9.0.1.tgz | Bin 8893 -> 0 bytes spec/bugsplat.spec.ts | 35 -------------------------- src/bugsplat-options.ts | 5 ++-- src/bugsplat.ts | 53 ++++++---------------------------------- 4 files changed, 10 insertions(+), 83 deletions(-) delete mode 100644 bugsplat-9.0.1.tgz diff --git a/bugsplat-9.0.1.tgz b/bugsplat-9.0.1.tgz deleted file mode 100644 index fc0070f644a1cc7c7c803bc77c093d56d9a0a026..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 8893 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 { body = { append } as unknown as FormData; }); - it('wraps a string in a text/plain Blob so it uploads as a file part (browser)', () => { - appendAttachment(body, { filename: 'stack.txt', data: 'component stack' }); - expect(append).toHaveBeenCalledTimes(1); - const [fieldName, value, postedName] = append.mock.calls[0]; - expect(fieldName).toBe('stack.txt'); - expect(value).toBeInstanceOf(Blob); - expect((value as Blob).type).toBe('text/plain'); - expect(postedName).toBe('stack.txt'); - }); - - it('encodes a string as a data URI file ref on React Native', () => { - const originalNav = globalThis.navigator; - Object.defineProperty(globalThis, 'navigator', { - value: { product: 'ReactNative' }, - configurable: true, - }); - try { - appendAttachment(body, { filename: 'stack.txt', data: 'hello' }); - expect(append).toHaveBeenCalledTimes(1); - const [fieldName, value, postedName] = append.mock.calls[0]; - expect(fieldName).toBe('stack.txt'); - expect(value).toEqual({ - uri: 'data:text/plain;base64,aGVsbG8=', - type: 'text/plain', - name: 'stack.txt', - }); - expect(postedName).toBe('stack.txt'); - } finally { - Object.defineProperty(globalThis, 'navigator', { - value: originalNav, - configurable: true, - }); - } - }); - it('wraps a Uint8Array in a Blob before appending', () => { const bytes = new Uint8Array([0x42, 0x53]); appendAttachment(body, { filename: 'data.bin', data: bytes }); diff --git a/src/bugsplat-options.ts b/src/bugsplat-options.ts index 6d8c8a8..664364b 100644 --- a/src/bugsplat-options.ts +++ b/src/bugsplat-options.ts @@ -30,10 +30,9 @@ export interface BugSplatAttachment { * * - `Blob` — browser `File`/`Blob` from an `` or `canvas.toBlob()`. * - `Uint8Array` — raw binary buffer (wrapped in a `Blob` before upload). - * - `string` — appended as-is; use for text like a stack trace. - * - `BugSplatFileRef` — a `{ uri, type? }` reference for React Native file uploads. + * - `BugSplatFileRef` — `{ uri, type? }` reference for React Native file uploads. */ - data: Blob | Uint8Array | string | BugSplatFileRef; + data: Blob | Uint8Array | BugSplatFileRef; } /** diff --git a/src/bugsplat.ts b/src/bugsplat.ts index 044cd1e..3da8eb8 100644 --- a/src/bugsplat.ts +++ b/src/bugsplat.ts @@ -36,57 +36,20 @@ function isFileRef(data: unknown): data is BugSplatFileRef { return typeof data === 'object' && data !== null && typeof (data as BugSplatFileRef).uri === 'string'; } -function isReactNative(): boolean { - return typeof navigator !== 'undefined' && - (navigator as { product?: string }).product === 'ReactNative'; -} - -function utf8ToBase64(text: string): string { - if (typeof Buffer !== 'undefined') { - return Buffer.from(text, 'utf-8').toString('base64'); - } - // Browser-safe UTF-8 → base64 - return btoa(unescape(encodeURIComponent(text))); -} - /** - * Append an attachment to a multipart body, branching on the runtime shape of - * `data` so that each environment's FormData serializes it as a real file part - * (with a `filename` in the `Content-Disposition` header) rather than a plain - * form field. + * Append an attachment to a multipart body in whatever shape the runtime's + * FormData expects: * - * - `string` — wrapped as a `text/plain` file part. On web this is a `Blob`; - * on React Native (where FormData can't serialize browser `Blob` objects) it - * becomes a base64 `data:` URI in RN's `{ uri, type, name }` shape. - * - `Uint8Array` — wrapped in a `Blob` for browser FormData. - * - `Blob` — appended with filename so the server sees it as an upload. - * - `BugSplatFileRef` (`{ uri, type? }`) — RN's file-upload shape, streamed - * from disk by RN's fetch. + * - `Uint8Array` → wrapped in a `Blob` so browsers send it as a file part. + * - `BugSplatFileRef` → React Native's `{ uri, type, name }` file-upload + * shape; RN's fetch streams the file from the URI. Not supported in + * browsers — pass a `Blob` there instead. + * - `Blob` → appended with filename so the part has a `Content-Disposition` + * `filename=` header and reaches the server as an upload. */ export function appendAttachment(body: FormData, attachment: BugSplatAttachment): void { const { filename, data } = attachment; - if (typeof data === 'string') { - if (isReactNative()) { - body.append( - filename, - { - uri: `data:text/plain;base64,${utf8ToBase64(data)}`, - type: 'text/plain', - name: filename, - } as unknown as Blob, - filename - ); - } else { - body.append( - filename, - new Blob([data], { type: 'text/plain' }), - filename - ); - } - return; - } - if (data instanceof Uint8Array) { body.append(filename, new Blob([data.buffer as ArrayBuffer]), filename); return; From af8085f4a9af8aa16684c550a749121808018408 Mon Sep 17 00:00:00 2001 From: Bobby Galli Date: Fri, 17 Apr 2026 18:13:40 -0400 Subject: [PATCH 4/4] fix: upload only the bytes covered by a Uint8Array view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wrapping `data.buffer` uploaded the entire underlying ArrayBuffer even when the Uint8Array was a subarray view (non-zero byteOffset or shorter byteLength), which could corrupt the attachment or leak unrelated bytes from the caller's buffer. Pass the view itself to Blob so it uses the view's byteOffset and byteLength. Adds a regression test that constructs a subarray view into a larger buffer and asserts only the viewed bytes make it into the uploaded Blob. Also renames the file-ref test that said "omits type" but asserted `type: undefined` — it handles a missing type, it doesn't omit the field. Co-Authored-By: Claude Opus 4.7 (1M context) --- spec/bugsplat.spec.ts | 14 +++++++++++++- src/bugsplat.ts | 6 +++++- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/spec/bugsplat.spec.ts b/spec/bugsplat.spec.ts index 3e88253..138abc8 100644 --- a/spec/bugsplat.spec.ts +++ b/spec/bugsplat.spec.ts @@ -390,6 +390,18 @@ describe('appendAttachment', () => { expect(postedName).toBe('data.bin'); }); + it('uploads only the bytes covered by a Uint8Array view, not the whole backing buffer', async () => { + // Build a Uint8Array that is a subarray view into a larger buffer. + const underlying = new Uint8Array([0x00, 0x00, 0x42, 0x53, 0x00, 0x00]); + const view = underlying.subarray(2, 4); // just [0x42, 0x53] + appendAttachment(body, { filename: 'data.bin', data: view }); + const [, value] = append.mock.calls[0]; + expect(value).toBeInstanceOf(Blob); + expect((value as Blob).size).toBe(2); + const bytes = new Uint8Array(await (value as Blob).arrayBuffer()); + expect(Array.from(bytes)).toEqual([0x42, 0x53]); + }); + it('passes a Blob through with filename', () => { const blob = new Blob(['hello']); appendAttachment(body, { filename: 'hello.txt', data: blob }); @@ -408,7 +420,7 @@ describe('appendAttachment', () => { ); }); - it('omits type when not provided on a file ref', () => { + it('handles a file ref with no type', () => { appendAttachment(body, { filename: 'log.txt', data: { uri: 'file:///tmp/log.txt' }, diff --git a/src/bugsplat.ts b/src/bugsplat.ts index 3da8eb8..511a371 100644 --- a/src/bugsplat.ts +++ b/src/bugsplat.ts @@ -51,7 +51,11 @@ export function appendAttachment(body: FormData, attachment: BugSplatAttachment) const { filename, data } = attachment; if (data instanceof Uint8Array) { - body.append(filename, new Blob([data.buffer as ArrayBuffer]), filename); + // Pass the view itself, not `data.buffer`, so subarray views upload only + // their own bytes (byteOffset..byteOffset+byteLength) rather than the + // entire underlying ArrayBuffer. Cast narrows the buffer type to + // ArrayBuffer — SharedArrayBuffer-backed views are not expected here. + body.append(filename, new Blob([data as Uint8Array]), filename); return; }