uploados 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/android/build.gradle +25 -0
- package/android/src/main/AndroidManifest.xml +15 -0
- package/android/src/main/java/expo/modules/uploados/UploadosModule.kt +121 -0
- package/android/src/main/java/expo/modules/uploados/upload/CompressionPipeline.kt +192 -0
- package/android/src/main/java/expo/modules/uploados/upload/FileStager.kt +125 -0
- package/android/src/main/java/expo/modules/uploados/upload/ProgressRequestBody.kt +36 -0
- package/android/src/main/java/expo/modules/uploados/upload/UploadManager.kt +857 -0
- package/android/src/main/java/expo/modules/uploados/upload/UploadModels.kt +209 -0
- package/android/src/main/java/expo/modules/uploados/upload/UploadNotificationHelper.kt +93 -0
- package/android/src/main/java/expo/modules/uploados/upload/UploadTaskStore.kt +224 -0
- package/android/src/main/java/expo/modules/uploados/upload/UploadWorker.kt +31 -0
- package/build/Uploados.types.d.ts +226 -0
- package/build/Uploados.types.d.ts.map +1 -0
- package/build/Uploados.types.js +2 -0
- package/build/Uploados.types.js.map +1 -0
- package/build/UploadosModule.d.ts +13 -0
- package/build/UploadosModule.d.ts.map +1 -0
- package/build/UploadosModule.js +3 -0
- package/build/UploadosModule.js.map +1 -0
- package/build/UploadosModule.web.d.ts +13 -0
- package/build/UploadosModule.web.d.ts.map +1 -0
- package/build/UploadosModule.web.js +33 -0
- package/build/UploadosModule.web.js.map +1 -0
- package/build/createUploader.d.ts +3 -0
- package/build/createUploader.d.ts.map +1 -0
- package/build/createUploader.js +108 -0
- package/build/createUploader.js.map +1 -0
- package/build/index.d.ts +5 -0
- package/build/index.d.ts.map +1 -0
- package/build/index.js +5 -0
- package/build/index.js.map +1 -0
- package/build/normalizeUploadOptions.d.ts +9 -0
- package/build/normalizeUploadOptions.d.ts.map +1 -0
- package/build/normalizeUploadOptions.js +81 -0
- package/build/normalizeUploadOptions.js.map +1 -0
- package/build/providers/defineUploadProvider.d.ts +26 -0
- package/build/providers/defineUploadProvider.d.ts.map +1 -0
- package/build/providers/defineUploadProvider.js +39 -0
- package/build/providers/defineUploadProvider.js.map +1 -0
- package/build/providers/multipartPlan.d.ts +10 -0
- package/build/providers/multipartPlan.d.ts.map +1 -0
- package/build/providers/multipartPlan.js +28 -0
- package/build/providers/multipartPlan.js.map +1 -0
- package/eslint.config.cjs +5 -0
- package/expo-module.config.json +10 -0
- package/ios/Upload/CompressionPipeline.swift +183 -0
- package/ios/Upload/FileStager.swift +67 -0
- package/ios/Upload/UploadManager.swift +813 -0
- package/ios/Upload/UploadModels.swift +305 -0
- package/ios/Upload/UploadSessionDelegate.swift +82 -0
- package/ios/Upload/UploadTaskStore.swift +92 -0
- package/ios/Upload/UploadosAppDelegate.swift +14 -0
- package/ios/Uploados.podspec +23 -0
- package/ios/UploadosModule.swift +87 -0
- package/jest.config.js +15 -0
- package/package.json +54 -0
- package/readme.md +169 -0
- package/src/Uploados.types.ts +260 -0
- package/src/UploadosModule.ts +18 -0
- package/src/UploadosModule.web.ts +49 -0
- package/src/createUploader.ts +146 -0
- package/src/index.ts +4 -0
- package/src/normalizeUploadOptions.ts +132 -0
- package/src/providers/defineUploadProvider.ts +75 -0
- package/src/providers/multipartPlan.ts +43 -0
- package/tsconfig.json +42 -0
package/readme.md
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
# uploados
|
|
2
|
+
|
|
3
|
+
Native signed-URL uploads for Expo and React Native apps.
|
|
4
|
+
|
|
5
|
+
uploados is an Expo native module that runs direct `PUT`/`POST` uploads from iOS and Android native code. The current release focuses on direct signed-URL uploads, provider-based presign, file staging, native JPEG compression presets, retry, progress events, and a persistent upload queue.
|
|
6
|
+
|
|
7
|
+
Native multipart uploads are not wired yet. Passing `multipart: true` currently throws a typed `MULTIPART_FAILED` error.
|
|
8
|
+
|
|
9
|
+
## Current status
|
|
10
|
+
|
|
11
|
+
| Capability | Status |
|
|
12
|
+
|------------|--------|
|
|
13
|
+
| Direct signed-URL uploads | Shipped on iOS and Android |
|
|
14
|
+
| Provider-based direct presign (`getUploadUrl`) | Shipped |
|
|
15
|
+
| File staging for picker and content URIs | Shipped |
|
|
16
|
+
| Persistent queue and restore | Shipped |
|
|
17
|
+
| Native JPEG compression presets | Shipped |
|
|
18
|
+
| Android foreground upload notification | Shipped for background WorkManager uploads; customization is not exposed yet |
|
|
19
|
+
| iOS Live Activity / Dynamic Island | Not implemented; build app-owned progress UI from upload events |
|
|
20
|
+
| Native multipart engine | Deferred; use direct uploads for now |
|
|
21
|
+
| Web | Throws `UNSUPPORTED_PLATFORM` by design |
|
|
22
|
+
|
|
23
|
+
## Installation
|
|
24
|
+
|
|
25
|
+
Use Expo to install the package so peer versions stay aligned with your SDK:
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
npx expo install uploados
|
|
29
|
+
npx expo doctor
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Then rebuild native projects:
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
npx expo prebuild
|
|
36
|
+
npx pod-install
|
|
37
|
+
npx expo run:ios
|
|
38
|
+
npx expo run:android
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Expo Go does not load custom native modules, so use a development build.
|
|
42
|
+
|
|
43
|
+
Current peer metadata targets Expo SDK 52 through 56:
|
|
44
|
+
|
|
45
|
+
- `expo >=52.0.0 <57.0.0`
|
|
46
|
+
- `react-native >=0.76.0 <0.86.0`
|
|
47
|
+
- `react >=18.3.1 <20.0.0`
|
|
48
|
+
|
|
49
|
+
## Direct signed-URL upload
|
|
50
|
+
|
|
51
|
+
```ts
|
|
52
|
+
import { createUploader } from 'uploados';
|
|
53
|
+
|
|
54
|
+
const uploader = createUploader({
|
|
55
|
+
background: true,
|
|
56
|
+
retry: { maxAttempts: 5 },
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
const task = await uploader.upload({
|
|
60
|
+
file: imageUri,
|
|
61
|
+
uploadUrl: signedPutUrl,
|
|
62
|
+
headers: { 'Content-Type': 'image/jpeg' },
|
|
63
|
+
compression: { enabled: true, preset: 'balanced' },
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
task.onProgress((progress) => {
|
|
67
|
+
console.log(progress.percentage);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
task.onComplete((snapshot) => {
|
|
71
|
+
console.log(snapshot.id, snapshot.state);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
task.onFailed((error) => {
|
|
75
|
+
console.log(error.code, error.message);
|
|
76
|
+
});
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
## Provider-based direct upload
|
|
80
|
+
|
|
81
|
+
Use `defineUploadProvider` when your backend accepts an object key and returns a presigned URL. Multipart callbacks are optional and unused until the native multipart path ships.
|
|
82
|
+
|
|
83
|
+
```ts
|
|
84
|
+
import { createUploader, defineUploadProvider } from 'uploados';
|
|
85
|
+
|
|
86
|
+
const uploader = createUploader({
|
|
87
|
+
provider: defineUploadProvider({
|
|
88
|
+
getUploadUrl: async ({ key, contentType }) => {
|
|
89
|
+
const res = await fetch('https://your-api/presign', {
|
|
90
|
+
method: 'POST',
|
|
91
|
+
headers: { 'Content-Type': 'application/json' },
|
|
92
|
+
body: JSON.stringify({ key, contentType }),
|
|
93
|
+
});
|
|
94
|
+
return res.json();
|
|
95
|
+
},
|
|
96
|
+
}),
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
await uploader.upload({
|
|
100
|
+
file: imageUri,
|
|
101
|
+
key: 'uploads/photo.jpg',
|
|
102
|
+
contentType: 'image/jpeg',
|
|
103
|
+
compression: { enabled: true, preset: 'inspection' },
|
|
104
|
+
});
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
## Compression
|
|
108
|
+
|
|
109
|
+
Compression runs natively before upload and leaves the original file unchanged. Output is JPEG, and `compressed` events include `CompressionStats` with `originalSize`, `optimizedSize`, `preset`, and `format: 'jpeg'`.
|
|
110
|
+
|
|
111
|
+
```ts
|
|
112
|
+
compression: {
|
|
113
|
+
enabled: true,
|
|
114
|
+
preset: 'balanced',
|
|
115
|
+
}
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
Presets:
|
|
119
|
+
|
|
120
|
+
- `balanced`: default size and quality tradeoff
|
|
121
|
+
- `inspection`: keeps more detail for inspection photos
|
|
122
|
+
- `avatar`: smaller profile image and thumbnail output
|
|
123
|
+
|
|
124
|
+
## Queue and lifecycle
|
|
125
|
+
|
|
126
|
+
Each upload is stored in a native task queue. States use the exported `UploadState` union:
|
|
127
|
+
|
|
128
|
+
```txt
|
|
129
|
+
created -> validating -> compressing -> queued -> connecting -> uploading
|
|
130
|
+
-> verifying -> completed
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
Failures may enter `retrying` before `failed`. Cancelled tasks enter `cancelled`.
|
|
134
|
+
|
|
135
|
+
Native retries reuse the same `uploadUrl` and headers. For background uploads, issue presigned URLs with enough TTL for the retry window, or create a fresh upload after an `AUTH_ERROR` / `PROVIDER_ERROR`.
|
|
136
|
+
|
|
137
|
+
Use queue APIs to inspect or resume work:
|
|
138
|
+
|
|
139
|
+
```ts
|
|
140
|
+
const snapshots = await uploader.restoreQueue();
|
|
141
|
+
const all = await uploader.getAllTasks();
|
|
142
|
+
const one = await uploader.getTask(taskId);
|
|
143
|
+
await uploader.cancel(taskId);
|
|
144
|
+
await uploader.retry(taskId);
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
## Platform notes
|
|
148
|
+
|
|
149
|
+
- iOS uses `URLSession`, including background sessions when `background: true`.
|
|
150
|
+
- Android uses WorkManager and OkHttp for deferred upload work.
|
|
151
|
+
- Android background uploads run as foreground WorkManager work with an ongoing upload notification. Apps targeting Android 13+ must request notification permission for the notification to be visible.
|
|
152
|
+
- Android notification customization is not exposed in the JS API yet.
|
|
153
|
+
- iOS Live Activity and Dynamic Island surfaces are app-owned. Subscribe to upload events and render your own progress UI if you need them.
|
|
154
|
+
- Web upload APIs intentionally throw `UNSUPPORTED_PLATFORM`.
|
|
155
|
+
|
|
156
|
+
## Repository development
|
|
157
|
+
|
|
158
|
+
This repository uses Bun.
|
|
159
|
+
|
|
160
|
+
```bash
|
|
161
|
+
bun install
|
|
162
|
+
EXPO_NONINTERACTIVE=1 bun run build
|
|
163
|
+
bun run lint
|
|
164
|
+
EXPO_NONINTERACTIVE=1 bun run test
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
## License
|
|
168
|
+
|
|
169
|
+
MIT
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
export type UploadState =
|
|
2
|
+
| 'created'
|
|
3
|
+
| 'validating'
|
|
4
|
+
| 'compressing'
|
|
5
|
+
| 'queued'
|
|
6
|
+
| 'connecting'
|
|
7
|
+
| 'uploading'
|
|
8
|
+
| 'verifying'
|
|
9
|
+
| 'retrying'
|
|
10
|
+
| 'completed'
|
|
11
|
+
| 'paused'
|
|
12
|
+
| 'failed'
|
|
13
|
+
| 'cancelled';
|
|
14
|
+
|
|
15
|
+
export type UploadHttpMethod = 'PUT' | 'POST';
|
|
16
|
+
|
|
17
|
+
export type UploadNetworkPolicy = 'wait' | 'failFast';
|
|
18
|
+
|
|
19
|
+
export type UploadErrorPhase =
|
|
20
|
+
| 'validating'
|
|
21
|
+
| 'compressing'
|
|
22
|
+
| 'connecting'
|
|
23
|
+
| 'uploading'
|
|
24
|
+
| 'verifying'
|
|
25
|
+
| 'retrying';
|
|
26
|
+
|
|
27
|
+
export type UploadProgress = {
|
|
28
|
+
bytesUploaded: number;
|
|
29
|
+
totalBytes: number;
|
|
30
|
+
percentage: number;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export type UploadErrorCode =
|
|
34
|
+
| 'FILE_NOT_FOUND'
|
|
35
|
+
| 'FILE_TOO_LARGE'
|
|
36
|
+
| 'NETWORK_ERROR'
|
|
37
|
+
| 'AUTH_ERROR'
|
|
38
|
+
| 'PROVIDER_ERROR'
|
|
39
|
+
| 'MULTIPART_FAILED'
|
|
40
|
+
| 'COMPRESSION_FAILED'
|
|
41
|
+
| 'BACKGROUND_RESTRICTED'
|
|
42
|
+
| 'CANCELLED'
|
|
43
|
+
| 'UNSUPPORTED_PLATFORM'
|
|
44
|
+
| 'UNKNOWN';
|
|
45
|
+
|
|
46
|
+
export type UploadErrorPayload = {
|
|
47
|
+
code: UploadErrorCode;
|
|
48
|
+
message: string;
|
|
49
|
+
retryable: boolean;
|
|
50
|
+
taskId: string;
|
|
51
|
+
phase?: UploadErrorPhase;
|
|
52
|
+
httpStatus?: number;
|
|
53
|
+
providerCode?: string;
|
|
54
|
+
providerMessage?: string;
|
|
55
|
+
nativeCause?: string;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
export type UploadFileMetadata = {
|
|
59
|
+
originalUri: string;
|
|
60
|
+
uploadUri: string;
|
|
61
|
+
isStaged: boolean;
|
|
62
|
+
sizeBytes?: number;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
export type CompressionStats = {
|
|
66
|
+
preset: CompressionPreset;
|
|
67
|
+
originalSize: number;
|
|
68
|
+
optimizedSize: number;
|
|
69
|
+
format: 'jpeg';
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
export type UploadTaskSnapshot = {
|
|
73
|
+
id: string;
|
|
74
|
+
localUri: string;
|
|
75
|
+
file?: UploadFileMetadata;
|
|
76
|
+
uploadUrl: string;
|
|
77
|
+
method: UploadHttpMethod;
|
|
78
|
+
state: UploadState;
|
|
79
|
+
progress: UploadProgress;
|
|
80
|
+
background: boolean;
|
|
81
|
+
networkPolicy: UploadNetworkPolicy;
|
|
82
|
+
attempt: number;
|
|
83
|
+
maxAttempts: number;
|
|
84
|
+
nextRetryAt?: number;
|
|
85
|
+
createdAt: number;
|
|
86
|
+
updatedAt: number;
|
|
87
|
+
originalSize?: number;
|
|
88
|
+
optimizedSize?: number;
|
|
89
|
+
compression?: CompressionStats;
|
|
90
|
+
error?: UploadErrorPayload;
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
export type UploadRetryOptions = {
|
|
94
|
+
maxAttempts?: number;
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
export type CompressionPreset = 'balanced' | 'inspection' | 'avatar';
|
|
98
|
+
|
|
99
|
+
export type CompressionOptions = {
|
|
100
|
+
enabled?: boolean;
|
|
101
|
+
preset?: CompressionPreset;
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
export type UploaderConfig = {
|
|
105
|
+
background?: boolean;
|
|
106
|
+
networkPolicy?: UploadNetworkPolicy;
|
|
107
|
+
retry?: UploadRetryOptions;
|
|
108
|
+
provider?: UploadProvider;
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
export type CreateMultipartInput = {
|
|
112
|
+
key: string;
|
|
113
|
+
contentType?: string;
|
|
114
|
+
metadata?: Record<string, string>;
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
export type CreateMultipartResult = {
|
|
118
|
+
uploadId: string;
|
|
119
|
+
key: string;
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
export type SignPartInput = {
|
|
123
|
+
key: string;
|
|
124
|
+
uploadId: string;
|
|
125
|
+
partNumber: number;
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
export type SignedPartResult = {
|
|
129
|
+
url: string;
|
|
130
|
+
method: UploadHttpMethod;
|
|
131
|
+
headers?: Record<string, string>;
|
|
132
|
+
partNumber: number;
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
export type CompletedPart = {
|
|
136
|
+
partNumber: number;
|
|
137
|
+
etag: string;
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
export type CompleteMultipartInput = {
|
|
141
|
+
key: string;
|
|
142
|
+
uploadId: string;
|
|
143
|
+
parts: CompletedPart[];
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
export type CompleteMultipartResult = {
|
|
147
|
+
key: string;
|
|
148
|
+
location?: string;
|
|
149
|
+
etag?: string;
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
export type AbortMultipartInput = {
|
|
153
|
+
key: string;
|
|
154
|
+
uploadId: string;
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
export type ProviderUploadUrlInput = {
|
|
158
|
+
key: string;
|
|
159
|
+
contentType?: string;
|
|
160
|
+
metadata?: Record<string, string>;
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
export type ProviderUploadUrlResult = {
|
|
164
|
+
url: string;
|
|
165
|
+
method?: UploadHttpMethod;
|
|
166
|
+
headers?: Record<string, string>;
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
export type UploadProvider = {
|
|
170
|
+
getUploadUrl?: (input: ProviderUploadUrlInput) => Promise<ProviderUploadUrlResult>;
|
|
171
|
+
createMultipartUpload?: (input: CreateMultipartInput) => Promise<CreateMultipartResult>;
|
|
172
|
+
signPart?: (input: SignPartInput) => Promise<SignedPartResult>;
|
|
173
|
+
completeMultipartUpload?: (input: CompleteMultipartInput) => Promise<CompleteMultipartResult>;
|
|
174
|
+
abortMultipartUpload?: (input: AbortMultipartInput) => Promise<void>;
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
/** Direct signed-URL upload supported by the current native baseline. */
|
|
178
|
+
export type DirectUploadOptions = {
|
|
179
|
+
file: string;
|
|
180
|
+
uploadUrl: string;
|
|
181
|
+
method?: UploadHttpMethod;
|
|
182
|
+
headers?: Record<string, string>;
|
|
183
|
+
background?: boolean;
|
|
184
|
+
networkPolicy?: UploadNetworkPolicy;
|
|
185
|
+
retry?: UploadRetryOptions;
|
|
186
|
+
/** Reserved for P1-C. Rejected when enabled until native compression ships. */
|
|
187
|
+
compression?: CompressionOptions;
|
|
188
|
+
/** Reserved for P1-E. Rejected when true until multipart ships. */
|
|
189
|
+
multipart?: boolean;
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
/** Provider-key upload reserved for the P1-D provider contract. */
|
|
193
|
+
export type ProviderUploadOptions = {
|
|
194
|
+
file: string;
|
|
195
|
+
key: string;
|
|
196
|
+
contentType?: string;
|
|
197
|
+
background?: boolean;
|
|
198
|
+
networkPolicy?: UploadNetworkPolicy;
|
|
199
|
+
retry?: UploadRetryOptions;
|
|
200
|
+
compression?: CompressionOptions;
|
|
201
|
+
multipart?: boolean;
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
export type UploadOptions = DirectUploadOptions | ProviderUploadOptions;
|
|
205
|
+
|
|
206
|
+
export type UploadTaskHandle = {
|
|
207
|
+
readonly id: string;
|
|
208
|
+
readonly snapshot: UploadTaskSnapshot;
|
|
209
|
+
cancel(): Promise<UploadTaskSnapshot>;
|
|
210
|
+
retry(): Promise<UploadTaskSnapshot>;
|
|
211
|
+
getSnapshot(): Promise<UploadTaskSnapshot | null>;
|
|
212
|
+
onProgress(listener: (progress: UploadProgress) => void): () => void;
|
|
213
|
+
onComplete(listener: (task: UploadTaskSnapshot) => void): () => void;
|
|
214
|
+
onFailed(listener: (error: UploadErrorPayload) => void): () => void;
|
|
215
|
+
onCancelled(listener: () => void): () => void;
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
export type Uploader = {
|
|
219
|
+
upload(options: UploadOptions): Promise<UploadTaskHandle>;
|
|
220
|
+
cancel(taskId: string): Promise<UploadTaskSnapshot>;
|
|
221
|
+
retry(taskId: string): Promise<UploadTaskSnapshot>;
|
|
222
|
+
getTask(taskId: string): Promise<UploadTaskSnapshot | null>;
|
|
223
|
+
getAllTasks(): Promise<UploadTaskSnapshot[]>;
|
|
224
|
+
restoreQueue(): Promise<UploadTaskSnapshot[]>;
|
|
225
|
+
subscribe(listener: (event: UploadEvent) => void): () => void;
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
export type CreateUploadOptions = {
|
|
229
|
+
localUri: string;
|
|
230
|
+
uploadUrl: string;
|
|
231
|
+
method?: UploadHttpMethod;
|
|
232
|
+
headers?: Record<string, string>;
|
|
233
|
+
background?: boolean;
|
|
234
|
+
networkPolicy?: UploadNetworkPolicy;
|
|
235
|
+
retry?: UploadRetryOptions;
|
|
236
|
+
compression?: CompressionOptions;
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
export type UploadEvent =
|
|
240
|
+
| { type: 'created'; task: UploadTaskSnapshot }
|
|
241
|
+
| { type: 'compressing'; taskId: string; task: UploadTaskSnapshot }
|
|
242
|
+
| { type: 'compressed'; taskId: string; task: UploadTaskSnapshot; stats: CompressionStats }
|
|
243
|
+
| { type: 'queued'; taskId: string }
|
|
244
|
+
| { type: 'connecting'; taskId: string; task: UploadTaskSnapshot }
|
|
245
|
+
| { type: 'progress'; taskId: string; progress: UploadProgress }
|
|
246
|
+
| { type: 'verifying'; taskId: string }
|
|
247
|
+
| {
|
|
248
|
+
type: 'retrying';
|
|
249
|
+
taskId: string;
|
|
250
|
+
attempt: number;
|
|
251
|
+
delayMs: number;
|
|
252
|
+
error: UploadErrorPayload;
|
|
253
|
+
}
|
|
254
|
+
| { type: 'completed'; taskId: string; task: UploadTaskSnapshot }
|
|
255
|
+
| { type: 'failed'; taskId: string; error: UploadErrorPayload }
|
|
256
|
+
| { type: 'cancelled'; taskId: string };
|
|
257
|
+
|
|
258
|
+
export type UploadosModuleEvents = {
|
|
259
|
+
onUploadEvent: (event: UploadEvent) => void;
|
|
260
|
+
};
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { NativeModule, requireNativeModule } from 'expo';
|
|
2
|
+
|
|
3
|
+
import type {
|
|
4
|
+
CreateUploadOptions,
|
|
5
|
+
UploadosModuleEvents,
|
|
6
|
+
UploadTaskSnapshot,
|
|
7
|
+
} from './Uploados.types';
|
|
8
|
+
|
|
9
|
+
declare class UploadosModule extends NativeModule<UploadosModuleEvents> {
|
|
10
|
+
createUploadAsync(options: CreateUploadOptions): Promise<UploadTaskSnapshot>;
|
|
11
|
+
cancelUploadAsync(taskId: string): Promise<UploadTaskSnapshot>;
|
|
12
|
+
retryUploadAsync(taskId: string): Promise<UploadTaskSnapshot>;
|
|
13
|
+
getTaskAsync(taskId: string): Promise<UploadTaskSnapshot | null>;
|
|
14
|
+
getAllTasksAsync(): Promise<UploadTaskSnapshot[]>;
|
|
15
|
+
restoreQueueAsync(): Promise<UploadTaskSnapshot[]>;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export default requireNativeModule<UploadosModule>('Uploados');
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { NativeModule, registerWebModule } from 'expo';
|
|
2
|
+
|
|
3
|
+
import type {
|
|
4
|
+
CreateUploadOptions,
|
|
5
|
+
UploadErrorPayload,
|
|
6
|
+
UploadosModuleEvents,
|
|
7
|
+
UploadTaskSnapshot,
|
|
8
|
+
} from './Uploados.types';
|
|
9
|
+
|
|
10
|
+
function unsupported(): never {
|
|
11
|
+
const error: UploadErrorPayload = {
|
|
12
|
+
code: 'UNSUPPORTED_PLATFORM',
|
|
13
|
+
message: 'Uploados native uploads are not available on web.',
|
|
14
|
+
retryable: false,
|
|
15
|
+
taskId: '',
|
|
16
|
+
};
|
|
17
|
+
throw error;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
class Uploados extends NativeModule<UploadosModuleEvents> {
|
|
21
|
+
async createUploadAsync(_options: CreateUploadOptions): Promise<UploadTaskSnapshot> {
|
|
22
|
+
unsupported();
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async cancelUploadAsync(_taskId: string): Promise<UploadTaskSnapshot> {
|
|
26
|
+
unsupported();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async retryUploadAsync(_taskId: string): Promise<UploadTaskSnapshot> {
|
|
30
|
+
unsupported();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async getTaskAsync(_taskId: string): Promise<UploadTaskSnapshot | null> {
|
|
34
|
+
unsupported();
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async getAllTasksAsync(): Promise<UploadTaskSnapshot[]> {
|
|
38
|
+
unsupported();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async restoreQueueAsync(): Promise<UploadTaskSnapshot[]> {
|
|
42
|
+
unsupported();
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
type RegisterWebModuleCompat = (moduleImplementation: typeof Uploados) => Uploados;
|
|
47
|
+
|
|
48
|
+
// SDK 52 types only the one-argument form; newer SDKs support it at runtime.
|
|
49
|
+
export default (registerWebModule as unknown as RegisterWebModuleCompat)(Uploados);
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
DirectUploadOptions,
|
|
3
|
+
ProviderUploadOptions,
|
|
4
|
+
UploadErrorPayload,
|
|
5
|
+
UploadEvent,
|
|
6
|
+
UploadOptions,
|
|
7
|
+
UploadTaskHandle,
|
|
8
|
+
UploadTaskSnapshot,
|
|
9
|
+
Uploader,
|
|
10
|
+
UploaderConfig,
|
|
11
|
+
} from './Uploados.types';
|
|
12
|
+
import UploadosModule from './UploadosModule';
|
|
13
|
+
import {
|
|
14
|
+
normalizeDirectUploadOptions,
|
|
15
|
+
normalizeProviderUploadOptions,
|
|
16
|
+
} from './normalizeUploadOptions';
|
|
17
|
+
import {
|
|
18
|
+
assertProviderDirectUploadSupported,
|
|
19
|
+
assertUploadProvider,
|
|
20
|
+
} from './providers/defineUploadProvider';
|
|
21
|
+
|
|
22
|
+
function throwMultipartNotReady(): never {
|
|
23
|
+
const error: UploadErrorPayload = {
|
|
24
|
+
code: 'MULTIPART_FAILED',
|
|
25
|
+
message:
|
|
26
|
+
'Multipart provider uploads require the P1-E native multipart engine. Use direct signed-URL uploads for now.',
|
|
27
|
+
retryable: false,
|
|
28
|
+
taskId: '',
|
|
29
|
+
};
|
|
30
|
+
throw error;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function isDirectUploadOptions(options: UploadOptions): options is DirectUploadOptions {
|
|
34
|
+
return 'uploadUrl' in options;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function isProviderUploadOptions(options: UploadOptions): options is ProviderUploadOptions {
|
|
38
|
+
return 'key' in options;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function getEventTaskId(event: UploadEvent): string | undefined {
|
|
42
|
+
if (event.type === 'created') {
|
|
43
|
+
return event.task.id;
|
|
44
|
+
}
|
|
45
|
+
return event.taskId;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function createTaskHandle(
|
|
49
|
+
snapshot: UploadTaskSnapshot,
|
|
50
|
+
subscribe: Uploader['subscribe'],
|
|
51
|
+
module: typeof UploadosModule
|
|
52
|
+
): UploadTaskHandle {
|
|
53
|
+
const taskId = snapshot.id;
|
|
54
|
+
|
|
55
|
+
const onTaskEvent = <T extends UploadEvent['type']>(
|
|
56
|
+
type: T,
|
|
57
|
+
listener: (event: Extract<UploadEvent, { type: T }>) => void
|
|
58
|
+
): (() => void) =>
|
|
59
|
+
subscribe((event) => {
|
|
60
|
+
if (getEventTaskId(event) === taskId && event.type === type) {
|
|
61
|
+
listener(event as Extract<UploadEvent, { type: T }>);
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
return {
|
|
66
|
+
id: taskId,
|
|
67
|
+
snapshot,
|
|
68
|
+
cancel: () => module.cancelUploadAsync(taskId),
|
|
69
|
+
retry: () => module.retryUploadAsync(taskId),
|
|
70
|
+
getSnapshot: () => module.getTaskAsync(taskId),
|
|
71
|
+
onProgress: (listener) => onTaskEvent('progress', (event) => listener(event.progress)),
|
|
72
|
+
onComplete: (listener) => onTaskEvent('completed', (event) => listener(event.task)),
|
|
73
|
+
onFailed: (listener) => onTaskEvent('failed', (event) => listener(event.error)),
|
|
74
|
+
onCancelled: (listener) => onTaskEvent('cancelled', () => listener()),
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function createUploader(config: UploaderConfig = {}): Uploader {
|
|
79
|
+
const listeners = new Set<(event: UploadEvent) => void>();
|
|
80
|
+
let moduleSubscription: { remove: () => void } | null = null;
|
|
81
|
+
const provider = config.provider;
|
|
82
|
+
|
|
83
|
+
const ensureModuleSubscription = () => {
|
|
84
|
+
if (moduleSubscription) {
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
moduleSubscription = UploadosModule.addListener('onUploadEvent', (event: UploadEvent) => {
|
|
88
|
+
listeners.forEach((listener) => {
|
|
89
|
+
listener(event);
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const subscribe = (listener: (event: UploadEvent) => void): (() => void) => {
|
|
95
|
+
ensureModuleSubscription();
|
|
96
|
+
listeners.add(listener);
|
|
97
|
+
return () => {
|
|
98
|
+
listeners.delete(listener);
|
|
99
|
+
if (listeners.size === 0 && moduleSubscription) {
|
|
100
|
+
moduleSubscription.remove();
|
|
101
|
+
moduleSubscription = null;
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
return {
|
|
107
|
+
async upload(options: UploadOptions): Promise<UploadTaskHandle> {
|
|
108
|
+
if (isProviderUploadOptions(options)) {
|
|
109
|
+
const resolvedProvider = assertUploadProvider(provider);
|
|
110
|
+
if (options.multipart === true) {
|
|
111
|
+
throwMultipartNotReady();
|
|
112
|
+
}
|
|
113
|
+
assertProviderDirectUploadSupported(resolvedProvider);
|
|
114
|
+
const signed = await resolvedProvider.getUploadUrl!({
|
|
115
|
+
key: options.key.trim(),
|
|
116
|
+
contentType: options.contentType,
|
|
117
|
+
});
|
|
118
|
+
const normalized = normalizeProviderUploadOptions(config, options, {
|
|
119
|
+
uploadUrl: signed.url,
|
|
120
|
+
method: signed.method,
|
|
121
|
+
headers: signed.headers,
|
|
122
|
+
});
|
|
123
|
+
const snapshot = await UploadosModule.createUploadAsync(normalized);
|
|
124
|
+
return createTaskHandle(snapshot, subscribe, UploadosModule);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (!isDirectUploadOptions(options)) {
|
|
128
|
+
throw Object.assign(new Error('Invalid upload options.'), {
|
|
129
|
+
code: 'UNKNOWN',
|
|
130
|
+
retryable: false,
|
|
131
|
+
taskId: '',
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const normalized = normalizeDirectUploadOptions(config, options);
|
|
136
|
+
const snapshot = await UploadosModule.createUploadAsync(normalized);
|
|
137
|
+
return createTaskHandle(snapshot, subscribe, UploadosModule);
|
|
138
|
+
},
|
|
139
|
+
cancel: (taskId) => UploadosModule.cancelUploadAsync(taskId),
|
|
140
|
+
retry: (taskId) => UploadosModule.retryUploadAsync(taskId),
|
|
141
|
+
getTask: (taskId) => UploadosModule.getTaskAsync(taskId),
|
|
142
|
+
getAllTasks: () => UploadosModule.getAllTasksAsync(),
|
|
143
|
+
restoreQueue: () => UploadosModule.restoreQueueAsync(),
|
|
144
|
+
subscribe,
|
|
145
|
+
};
|
|
146
|
+
}
|
package/src/index.ts
ADDED