Skip to content

Commit f7c18a1

Browse files
khawarizmusclaude
andcommitted
feat(attachments-storage-react-native): add ReactNativeFSTransportAdapter
Native streaming transport for @dr.pogodin/react-native-fs (raw binary PUT via uploadFiles + downloadFile), mirroring ExpoFileSystemTransportAdapter, so non-Expo apps can transfer large attachments without buffering them in JS memory. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 3afcbb6 commit f7c18a1

3 files changed

Lines changed: 125 additions & 1 deletion

File tree

.changeset/attachment-streaming-transport.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,4 @@
44
'@powersync/node': minor
55
---
66

7-
Add a streaming attachment transport (`AttachmentTransportAdapter` with a default `BufferedAttachmentTransport`, plus a native Expo `ExpoFileSystemTransportAdapter`) and `AttachmentQueue.saveFileFromUri`, so large files can be uploaded and downloaded without being buffered in JS memory.
7+
Add a streaming attachment transport (`AttachmentTransportAdapter` with a default `BufferedAttachmentTransport`, plus native `ExpoFileSystemTransportAdapter` and `ReactNativeFSTransportAdapter` implementations) and `AttachmentQueue.saveFileFromUri`, so large files can be uploaded and downloaded without being buffered in JS memory.
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
import type { AttachmentTransportAdapter, LocatedAttachmentRecord } from '@powersync/common';
2+
3+
/**
4+
* Describes the HTTP request used to upload a file's bytes to remote storage.
5+
* Typically points at a presigned URL.
6+
*/
7+
export interface ReactNativeFSUploadRequest {
8+
/** Destination URL (e.g. a presigned upload URL). */
9+
url: string;
10+
/** HTTP method. Defaults to `PUT`. */
11+
httpMethod?: 'POST' | 'PUT' | 'PATCH';
12+
/** Additional request headers. */
13+
headers?: Record<string, string>;
14+
/**
15+
* Send the raw file bytes as the request body instead of a multipart form.
16+
* Defaults to `true` — required for presigned `PUT` uploads (S3, Supabase, etc.).
17+
*/
18+
binaryStreamOnly?: boolean;
19+
/** MIME type of the file. Defaults to the attachment's `mediaType`. */
20+
mimeType?: string;
21+
}
22+
23+
/**
24+
* Describes the HTTP request used to download a file's bytes from remote storage.
25+
* Typically points at a presigned URL.
26+
*/
27+
export interface ReactNativeFSDownloadRequest {
28+
/** Source URL (e.g. a presigned download URL). */
29+
url: string;
30+
/** Additional request headers. */
31+
headers?: Record<string, string>;
32+
}
33+
34+
/**
35+
* Configuration for {@link ReactNativeFSTransportAdapter}.
36+
*
37+
* The resolvers map an attachment to the request that transfers its bytes, keeping
38+
* the transport agnostic of the remote storage backend (S3, Supabase, etc.).
39+
*/
40+
export interface ReactNativeFSTransportAdapterOptions {
41+
/** Resolves the upload request (e.g. a presigned URL) for an attachment. */
42+
resolveUpload: (attachment: LocatedAttachmentRecord) => Promise<ReactNativeFSUploadRequest> | ReactNativeFSUploadRequest;
43+
/** Resolves the download request (e.g. a presigned URL) for an attachment. */
44+
resolveDownload: (
45+
attachment: LocatedAttachmentRecord
46+
) => Promise<ReactNativeFSDownloadRequest> | ReactNativeFSDownloadRequest;
47+
}
48+
49+
/**
50+
* ReactNativeFSTransportAdapter transfers attachment bytes directly between a local
51+
* file and remote storage using `@dr.pogodin/react-native-fs`'s native
52+
* `uploadFiles` / `downloadFile`.
53+
*
54+
* The bytes never enter the JS heap, so large files can be transferred without the
55+
* memory pressure of the buffer-based transport. Requires
56+
* `@dr.pogodin/react-native-fs`.
57+
*
58+
* @experimental
59+
* @alpha This is currently experimental and may change without a major version bump.
60+
*/
61+
export class ReactNativeFSTransportAdapter implements AttachmentTransportAdapter {
62+
private rnfs: typeof import('@dr.pogodin/react-native-fs');
63+
64+
constructor(private options: ReactNativeFSTransportAdapterOptions) {
65+
let rnfs: typeof import('@dr.pogodin/react-native-fs');
66+
try {
67+
rnfs = require('@dr.pogodin/react-native-fs');
68+
} catch (e) {
69+
throw new Error(`Could not resolve @dr.pogodin/react-native-fs.
70+
To use the React Native File System transport please install @dr.pogodin/react-native-fs.`);
71+
}
72+
73+
this.rnfs = rnfs;
74+
}
75+
76+
async upload(attachment: LocatedAttachmentRecord): Promise<void> {
77+
const request = await this.options.resolveUpload(attachment);
78+
79+
const { promise } = this.rnfs.uploadFiles({
80+
toUrl: request.url,
81+
method: request.httpMethod ?? 'PUT',
82+
binaryStreamOnly: request.binaryStreamOnly ?? true,
83+
headers: request.headers,
84+
files: [
85+
{
86+
name: 'file',
87+
filename: attachment.filename,
88+
filepath: this.toPath(attachment.localUri),
89+
filetype: request.mimeType ?? attachment.mediaType
90+
}
91+
]
92+
});
93+
94+
const result = await promise;
95+
if (!this.isOk(result.statusCode)) {
96+
throw new Error(`Upload for ${attachment.id} failed with status ${result.statusCode}: ${result.body ?? ''}`);
97+
}
98+
}
99+
100+
async download(attachment: LocatedAttachmentRecord): Promise<void> {
101+
const request = await this.options.resolveDownload(attachment);
102+
103+
const { promise } = this.rnfs.downloadFile({
104+
fromUrl: request.url,
105+
toFile: this.toPath(attachment.localUri),
106+
headers: request.headers
107+
});
108+
109+
const result = await promise;
110+
if (!this.isOk(result.statusCode)) {
111+
throw new Error(`Download for ${attachment.id} failed with status ${result.statusCode}`);
112+
}
113+
}
114+
115+
/** react-native-fs expects plain filesystem paths, not `file://` URIs. */
116+
private toPath(uri: string): string {
117+
return uri.startsWith('file://') ? uri.slice('file://'.length) : uri;
118+
}
119+
120+
private isOk(status: number): boolean {
121+
return status >= 200 && status < 300;
122+
}
123+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
export * from './ExpoFileSystemStorageAdapter.js';
22
export * from './ExpoFileSystemTransportAdapter.js';
33
export * from './ReactNativeFileSystemStorageAdapter.js';
4+
export * from './ReactNativeFSTransportAdapter.js';

0 commit comments

Comments
 (0)