Skip to content

Commit 554ba41

Browse files
authored
feat: support string and file-URI attachments for React Native (#78)
1 parent 9cc8847 commit 554ba41

4 files changed

Lines changed: 131 additions & 12 deletions

File tree

spec/bugsplat.spec.ts

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { describe, it, expect, beforeEach, vi, Mock } from 'vitest';
22
import {
3+
appendAttachment,
34
BugSplat,
45
createStandardizedCallStack,
56
tryParseResponseJson,
@@ -369,3 +370,65 @@ describe('BugSplat', function () {
369370
expect(result.error!.message).toEqual('BugSplat Error: Invalid response received');
370371
});
371372
});
373+
374+
describe('appendAttachment', () => {
375+
let append: Mock;
376+
let body: FormData;
377+
378+
beforeEach(() => {
379+
append = vi.fn();
380+
body = { append } as unknown as FormData;
381+
});
382+
383+
it('wraps a Uint8Array in a Blob before appending', () => {
384+
const bytes = new Uint8Array([0x42, 0x53]);
385+
appendAttachment(body, { filename: 'data.bin', data: bytes });
386+
expect(append).toHaveBeenCalledTimes(1);
387+
const [filename, value, postedName] = append.mock.calls[0];
388+
expect(filename).toBe('data.bin');
389+
expect(value).toBeInstanceOf(Blob);
390+
expect(postedName).toBe('data.bin');
391+
});
392+
393+
it('uploads only the bytes covered by a Uint8Array view, not the whole backing buffer', async () => {
394+
// Build a Uint8Array that is a subarray view into a larger buffer.
395+
const underlying = new Uint8Array([0x00, 0x00, 0x42, 0x53, 0x00, 0x00]);
396+
const view = underlying.subarray(2, 4); // just [0x42, 0x53]
397+
appendAttachment(body, { filename: 'data.bin', data: view });
398+
const [, value] = append.mock.calls[0];
399+
expect(value).toBeInstanceOf(Blob);
400+
expect((value as Blob).size).toBe(2);
401+
const bytes = new Uint8Array(await (value as Blob).arrayBuffer());
402+
expect(Array.from(bytes)).toEqual([0x42, 0x53]);
403+
});
404+
405+
it('passes a Blob through with filename', () => {
406+
const blob = new Blob(['hello']);
407+
appendAttachment(body, { filename: 'hello.txt', data: blob });
408+
expect(append).toHaveBeenCalledWith('hello.txt', blob, 'hello.txt');
409+
});
410+
411+
it('appends a React Native file ref in {uri, name, type} shape', () => {
412+
appendAttachment(body, {
413+
filename: 'screenshot.png',
414+
data: { uri: 'file:///tmp/shot.png', type: 'image/png' },
415+
});
416+
expect(append).toHaveBeenCalledWith(
417+
'screenshot.png',
418+
{ uri: 'file:///tmp/shot.png', type: 'image/png', name: 'screenshot.png' },
419+
'screenshot.png'
420+
);
421+
});
422+
423+
it('handles a file ref with no type', () => {
424+
appendAttachment(body, {
425+
filename: 'log.txt',
426+
data: { uri: 'file:///tmp/log.txt' },
427+
});
428+
expect(append).toHaveBeenCalledWith(
429+
'log.txt',
430+
{ uri: 'file:///tmp/log.txt', type: undefined, name: 'log.txt' },
431+
'log.txt'
432+
);
433+
});
434+
});

src/bugsplat-options.ts

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,22 @@
1+
/**
2+
* A reference to a file on the local filesystem.
3+
*
4+
* Used on React Native, where FormData streams files from disk via their URI
5+
* (e.g. `file://`, `content://`, `ph://`) instead of reading them into JS memory.
6+
* Most RN file libraries (`expo-image-picker`, `react-native-view-shot`,
7+
* `expo-screen-capture`, etc.) already return values in this shape.
8+
*/
9+
export interface BugSplatFileRef {
10+
/**
11+
* The URI the runtime can read the file from.
12+
*/
13+
uri: string;
14+
/**
15+
* The MIME type, e.g. `image/png`. Optional but recommended.
16+
*/
17+
type?: string;
18+
}
19+
120
/**
221
* A file attachment to include in the upload zip.
322
*/
@@ -7,9 +26,13 @@ export interface BugSplatAttachment {
726
*/
827
filename: string;
928
/**
10-
* The file contents
29+
* The file contents.
30+
*
31+
* - `Blob` — browser `File`/`Blob` from an `<input type="file">` or `canvas.toBlob()`.
32+
* - `Uint8Array` — raw binary buffer (wrapped in a `Blob` before upload).
33+
* - `BugSplatFileRef` — `{ uri, type? }` reference for React Native file uploads.
1134
*/
12-
data: Blob | Uint8Array;
35+
data: Blob | Uint8Array | BugSplatFileRef;
1336
}
1437

1538
/**

src/bugsplat.ts

Lines changed: 42 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { BugSplatOptions } from './bugsplat-options';
1+
import type { BugSplatAttachment, BugSplatFileRef, BugSplatOptions } from './bugsplat-options';
22
import {
33
type BugSplatResponse,
44
type BugSplatResponseBody,
@@ -32,6 +32,45 @@ export async function tryParseResponseJson(response: {
3232

3333
const isError = (val: unknown): val is Error => Boolean((val as Error)?.stack);
3434

35+
function isFileRef(data: unknown): data is BugSplatFileRef {
36+
return typeof data === 'object' && data !== null && typeof (data as BugSplatFileRef).uri === 'string';
37+
}
38+
39+
/**
40+
* Append an attachment to a multipart body in whatever shape the runtime's
41+
* FormData expects:
42+
*
43+
* - `Uint8Array` → wrapped in a `Blob` so browsers send it as a file part.
44+
* - `BugSplatFileRef` → React Native's `{ uri, type, name }` file-upload
45+
* shape; RN's fetch streams the file from the URI. Not supported in
46+
* browsers — pass a `Blob` there instead.
47+
* - `Blob` → appended with filename so the part has a `Content-Disposition`
48+
* `filename=` header and reaches the server as an upload.
49+
*/
50+
export function appendAttachment(body: FormData, attachment: BugSplatAttachment): void {
51+
const { filename, data } = attachment;
52+
53+
if (data instanceof Uint8Array) {
54+
// Pass the view itself, not `data.buffer`, so subarray views upload only
55+
// their own bytes (byteOffset..byteOffset+byteLength) rather than the
56+
// entire underlying ArrayBuffer. Cast narrows the buffer type to
57+
// ArrayBuffer — SharedArrayBuffer-backed views are not expected here.
58+
body.append(filename, new Blob([data as Uint8Array<ArrayBuffer>]), filename);
59+
return;
60+
}
61+
62+
if (isFileRef(data)) {
63+
body.append(
64+
filename,
65+
{ uri: data.uri, type: data.type, name: filename } as unknown as Blob,
66+
filename
67+
);
68+
return;
69+
}
70+
71+
body.append(filename, data, filename);
72+
}
73+
3574
/**
3675
* BugSplat crash and feedback posting client.
3776
*/
@@ -84,10 +123,7 @@ export class BugSplat {
84123
body.append('attributes', JSON.stringify(attributes));
85124
}
86125
for (const attachment of options.attachments || []) {
87-
const blob = attachment.data instanceof Uint8Array
88-
? new Blob([attachment.data.buffer as ArrayBuffer])
89-
: attachment.data;
90-
body.append(attachment.filename, blob, attachment.filename);
126+
appendAttachment(body, attachment);
91127
}
92128

93129
console.log('BugSplat Error:', errorToPost);
@@ -167,10 +203,7 @@ export class BugSplat {
167203
body.append('attributes', JSON.stringify(attributes));
168204
}
169205
for (const attachment of options.attachments || []) {
170-
const blob = attachment.data instanceof Uint8Array
171-
? new Blob([attachment.data.buffer as ArrayBuffer])
172-
: attachment.data;
173-
body.append(attachment.filename, blob, attachment.filename);
206+
appendAttachment(body, attachment);
174207
}
175208

176209
console.log('BugSplat Feedback:', title);

src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
export { BugSplat } from './bugsplat';
2-
export type { BugSplatAttachment, BugSplatOptions } from './bugsplat-options';
2+
export type { BugSplatAttachment, BugSplatFileRef, BugSplatOptions } from './bugsplat-options';
33
export type { BugSplatResponse, BugSplatResponseBody, BugSplatResponseType, validateResponseBody } from './bugsplat-response';

0 commit comments

Comments
 (0)