@hostr/upload 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/README.md ADDED
@@ -0,0 +1,298 @@
1
+ # @hostr/upload
2
+
3
+ Resumable, presigned multipart uploads straight to R2 — bytes never transit
4
+ the API Worker. Four entry points, each bundled independently:
5
+
6
+ | Entry | Platform | Adapter-specific job |
7
+ |---|---|---|
8
+ | `@hostr/upload` | trusted backend only | `createUploadSession`, `mintUploadToken` — needs `HOSTR_API_KEY` |
9
+ | `@hostr/upload/browser` | web | XHR transport (for upload-progress events), `localStorage` session store |
10
+ | `@hostr/upload/node` | Node.js, scripts | `fetch` transport, filesystem session store |
11
+ | `@hostr/upload/react-native` | Expo / RN | native staged-parts transport; every upload survives backgrounding |
12
+
13
+ The engine, retry policy, and integrity checks are one shared core
14
+ (`packages/upload/src/core`); adapters only supply platform I/O (read bytes,
15
+ PUT bytes, persist a few KB of JSON). (The RN adapter is the exception: it
16
+ bypasses the engine entirely for OS-carried native transfers — see the
17
+ React Native section.)
18
+
19
+ **One rule for clients of a proxying backend:** take the hostr `endpoint`
20
+ from your backend's create-upload response (have your backend include its
21
+ own `HOSTR_API_BASE`), never from separate client config. An upload token is
22
+ only valid on the hostr instance that minted it, and endpoint drift fails as
23
+ a bare mid-flow `401` with no hint of the cause — returning the endpoint
24
+ with the session makes the drift structurally impossible. (Learned by the
25
+ first consumer integration, 2026-08-22.)
26
+
27
+ ## Quickstart
28
+
29
+ ### Browser
30
+
31
+ ```js
32
+ import { createUploadSession, createUpload } from '@hostr/upload/browser';
33
+
34
+ // Trusted-backend call — holds HOSTR_API_KEY. Runs on your server, not in
35
+ // the bundle you ship to users; shown inline here only for brevity.
36
+ const session = await createUploadSession({
37
+ endpoint, apiKey, filename: file.name, contentType: file.type || 'video/mp4', size: file.size,
38
+ });
39
+
40
+ // From here on the browser holds only a scoped token — never the master key.
41
+ const upload = await createUpload({ endpoint, uploadToken: session.uploadToken, assetId: session.assetId, file });
42
+
43
+ upload.on('progress', p => {
44
+ console.log(`${p.partsDone}/${p.partsTotal} parts, ${(p.bytesConfirmed / p.totalBytes * 100).toFixed(1)}%`);
45
+ for (const [n, st] of Object.entries(p.partStates ?? {})) mark(Number(n), st); // 'inflight' | 'done', for a per-part grid
46
+ });
47
+ upload.on('statechange', s => console.log('state:', s)); // idle → running → (stalled ↔ running)* → completing → complete
48
+ upload.start();
49
+
50
+ const { etag, sha256 } = await upload.done;
51
+ ```
52
+
53
+ Session state (assetId, part progress, hash midstate) persists under
54
+ `localStorage` keyed by `name:size:lastModified`. Reload the page, pick the
55
+ same file again, and `createUpload` resumes it automatically — no explicit
56
+ "resume" call. See `apps/playground/upload.html` for the full flow, including
57
+ re-minting a token for a saved session with `mintUploadToken`.
58
+
59
+ ### Node
60
+
61
+ ```js
62
+ import { createUploadSession, createUpload } from '@hostr/upload/node';
63
+
64
+ const session = await createUploadSession({
65
+ endpoint, apiKey, filename: 'video.mp4', contentType: 'video/mp4', size, partSize: PART_SIZE,
66
+ });
67
+
68
+ const upload = await createUpload({
69
+ endpoint, uploadToken: session.uploadToken, assetId: session.assetId,
70
+ file: '/path/to/video.mp4', // filesystem path, not an open handle
71
+ stateDir: './upload-state', // survives a process restart; defaults to os.tmpdir()/hostr-upload
72
+ });
73
+
74
+ upload.start();
75
+ const { etag, sha256 } = await upload.done;
76
+ ```
77
+
78
+ `infra/scripts/test-upload.mjs` is the canonical example — it drives the SDK
79
+ through a kill/resume cycle and a corrupted-part self-heal against a real API
80
+ and real R2.
81
+
82
+ ### React Native
83
+
84
+ Requires `expo-file-system` >= 57 (Expo SDK 57): the adapter rides the modern
85
+ File/Directory API — `FileHandle` byte-window reads, native per-part `md5`,
86
+ `File.createUploadTask` background sessions — with no fallback to the removed
87
+ `expo-file-system/legacy` surface. Older SDKs should pin an older version of
88
+ this package.
89
+
90
+ There is **one upload path on RN**. Parts are staged natively and carried by
91
+ OS background-session tasks, so every upload keeps transferring if the app is
92
+ backgrounded — that is not a mode, it's a property. The engine-style surface
93
+ (`progress`/`statechange` events, `pause`/`resume`/`abort`, a `done` promise)
94
+ wraps it. RN uploads record no whole-file sha256; the composite etag —
95
+ verified per part and again at complete — is the integrity check.
96
+
97
+ ```tsx
98
+ import { createUploadSession, mintUploadToken } from '@hostr/upload';
99
+ import { createUpload, asyncStorageStore, reconcileOnLaunch } from '@hostr/upload/react-native';
100
+ import AsyncStorage from '@react-native-async-storage/async-storage';
101
+
102
+ const store = asyncStorageStore(AsyncStorage); // REQUIRED — RN has no default persisted store
103
+
104
+ // Once, at app launch, before the user has picked anything — recovers any
105
+ // upload a previous launch left mid-flight (including after a force-kill).
106
+ useEffect(() => {
107
+ reconcileOnLaunch(store, { endpoint }).catch(() => {}); // best-effort; a real failure just waits for the next launch
108
+ }, []);
109
+
110
+ const session = await createUploadSession({ endpoint, apiKey, filename, contentType, size }); // trusted backend
111
+ const refreshToken = assetId => mintUploadToken({ endpoint, apiKey, assetId }).then(r => r.uploadToken);
112
+
113
+ const upload = await createUpload({
114
+ endpoint, uploadToken: session.uploadToken, assetId: session.assetId,
115
+ file, // { uri, size, modificationTime } from expo-document-picker / expo-image-picker
116
+ store,
117
+ onTokenExpired: refreshToken,
118
+ });
119
+ upload.on('progress', setProgress);
120
+ upload.start();
121
+ const { etag } = await upload.done; // resolved by the auto-reconcile that runs as the last part lands
122
+ ```
123
+
124
+ For headless control (no events, no `done` promise — e.g. an upload queue
125
+ worker), `startBackgroundUpload` returns the underlying handle directly:
126
+ `reconcile()`, `pause()`, `abort()`, and session geometry. `reconcileOnLaunch`
127
+ is the mandatory recovery hook either way.
128
+
129
+ `apps/expo-playground/App.tsx` exercises the path end to end, with a manual
130
+ "Reconcile now" button for the force-kill recovery leg.
131
+
132
+ ## Auth flow
133
+
134
+ ```
135
+ trusted backend (holds HOSTR_API_KEY)
136
+ │ POST /v1/uploads ──createUploadSession()──▶ { assetId, uploadToken, tokenExpiresAt, partSize, expectedParts }
137
+ ▼
138
+ client (browser / Node / RN) — holds only { endpoint, uploadToken, assetId }, never the master key
139
+ │ createUpload({ endpoint, uploadToken, assetId, file, ... })
140
+ ▼
141
+ GET /v1/uploads/:id · POST /v1/uploads/:id/parts · POST /v1/uploads/:id/complete
142
+ — every call authenticates with uploadToken; the API accepts either the
143
+ master key or an HMAC token scoped to exactly that assetId (401 on any
144
+ other asset — verified by infra/scripts/test-upload.mjs's leg 4)
145
+ ```
146
+
147
+ The split matters because `createUploadSession`/`mintUploadToken` are the
148
+ only two calls in this package that take `apiKey` — everything else takes
149
+ `uploadToken`. Never ship `apiKey` to an untrusted client; it is the same
150
+ credential that can create and delete any asset.
151
+
152
+ **Token TTL** defaults to 72 h, clamped 5 min–7 d server-side
153
+ (`clampTokenTtl` in `apps/api/src/limits.ts`) — pass `tokenTtlSec` to
154
+ `createUploadSession` or `mintUploadToken` to override, within that range.
155
+
156
+ **Token expiry mid-upload.** A large file can outlive a 72 h token. Pass
157
+ `onTokenExpired: async (assetId) => newToken` to `createUpload` /
158
+ `startBackgroundUpload`; the engine calls it on the first 401 from any API
159
+ call, swaps in the returned token, and retries — no restart, no lost
160
+ progress. The hook's server-side counterpart is `POST /v1/uploads/:id/token`
161
+ (`mintUploadToken`), which is **master-key-only**: the untrusted client can
162
+ never mint its own replacement, only ask the trusted backend to.
163
+
164
+ ## Integrity: three layers
165
+
166
+ Each layer proves a different thing; none subsumes the others.
167
+
168
+ 1. **Per-part MD5 vs R2's ETag.** Checked immediately after every `PUT` — a
169
+ 2xx response whose returned ETag doesn't match the client's MD5 of the
170
+ bytes it just sent is treated as `corrupt`, not success, and the part is
171
+ re-sent (see `infra/scripts/test-upload.mjs` leg 3, which flips a byte on
172
+ the wire and watches the engine self-heal). Proves: *these specific bytes,
173
+ for this one part, landed uncorrupted.*
174
+ 2. **Composite ETag at complete.** R2's multipart ETag is
175
+ `md5(concat(part MD5s)) + "-" + partCount` — computed client-side
176
+ (`compositeEtag`) from the same per-part MD5s and compared against what
177
+ `POST /complete` actually returns from R2's `CompleteMultipartUpload`.
178
+ Proves: *the parts assembled, in order, into one coherent object* — not
179
+ just that each part landed individually. This is also why multipart
180
+ ETags are **not** a portable content identity (CLAUDE.md's "Environment
181
+ gotchas"): the same file uploaded with a different part size produces a
182
+ different composite ETag.
183
+ 3. **Whole-file SHA-256.** Hashed incrementally as bytes are read — off the
184
+ part-size grid entirely — and included in `POST /complete`, stored as
185
+ asset metadata. Proves: *the identity of the file itself*, independent of
186
+ how it happened to be chunked or stored. This is the layer
187
+ `infra/scripts/verify-upload.mjs` uses to independently re-derive from
188
+ the bytes actually sitting in R2, with no input from the SDK at all.
189
+ Opt out with `checksum: 'none'` (skips the hash lane; use when content
190
+ identity isn't needed and the file is large enough that the extra read
191
+ pass matters).
192
+
193
+ ## Resume semantics
194
+
195
+ **What survives a kill:** the session record (assetId, token, `partSize`,
196
+ which parts are confirmed done, SHA-256 hash midstate) is persisted to the
197
+ adapter's `store` on every part landing and token refresh. The hash lane's
198
+ own checkpoints (every 8 MiB) only update that in-memory record — they don't
199
+ trigger a save of their own; they ride along on the next part-completion
200
+ save, or on a pause/stop (which flushes midstate explicitly so a kill mid-hash
201
+ doesn't restart it from byte 0). Relaunching `createUpload` against
202
+ the *same file* (same `name:size:lastModified` / `path:size:mtime` /
203
+ `uri:size:modificationTime` — a modified file gets a fresh session instead
204
+ of a corrupted resume) picks the session back up automatically.
205
+
206
+ **What doesn't survive:** a part that was mid-flight at the moment of the
207
+ kill is not resumed partway — it re-uploads from the start of that part.
208
+ SHA-256 progress since the last checkpoint (every 8 MiB) is lost and
209
+ re-hashed from the checkpoint forward, not from byte 0.
210
+
211
+ **`GET /v1/uploads/:id`'s `parts[]` (R2's own `ListParts`) is the truth on
212
+ every resume — never the persisted `done` map by itself.** The engine's
213
+ `reconcile()` step, run before dispatching any work:
214
+
215
+ - drops a locally-claimed-done part that R2 doesn't actually have (stale
216
+ claim — never sent, or R2 expired the multipart upload);
217
+ - keeps a part whose stored MD5 matches R2's ETag for it (still verified);
218
+ - re-uploads a part whose stored MD5 does **not** match R2's ETag (the
219
+ landed bytes aren't provably ours);
220
+ - for a part R2 holds but local state has no record of at all — lost
221
+ storage, reinstall, a different device picking up the same file — **re-reads
222
+ and re-hashes that byte range locally**, and adopts it as done only if the
223
+ hash matches R2's ETag. Lost local state never gets a free pass; it has to
224
+ re-earn trust by re-hashing, exactly like a normal upload would.
225
+
226
+ ## Mobile
227
+
228
+ ### Android
229
+
230
+ No second transport, and this package ships no native Android code. A JS
231
+ engine keeps running for as long as something keeps the host process
232
+ alive — on Android that means running the upload inside a **foreground
233
+ service** with a visible notification (Android's own requirement for
234
+ long-running background work; this is what YouTube/Drive do). Wire one up
235
+ with `react-native-background-actions`-style patterns, or Expo's own
236
+ foreground-service APIs, and call the normal foreground `createUpload` from
237
+ inside it — the engine itself is unmodified; only what keeps the process
238
+ alive changes.
239
+
240
+ ### iOS background caveats
241
+
242
+ `startBackgroundUpload` hands each remaining part to
243
+ `expo-file-system`'s iOS **background session** upload task, which the OS
244
+ keeps carrying even if the app is backgrounded or its JS runtime is
245
+ suspended — Expo ships the `AppDelegate` glue for this, so no native wiring
246
+ is needed in an Expo app.
247
+
248
+ - **A user force-killing the app cancels the OS's in-flight background
249
+ transfers outright.** This is documented `URLSessionUploadTask` behavior,
250
+ true for every app on iOS (YouTube included) — not a hostr limitation, and
251
+ not preventable from JS. `reconcileOnLaunch` on the next app start is the
252
+ recovery path, not a workaround: it re-derives from `ListParts` what
253
+ actually landed and re-stages the rest with a fresh presign.
254
+ - **Scheduling is discretionary.** The OS decides when a background transfer
255
+ actually runs — battery state, network conditions, and Low Power Mode can
256
+ all delay it. There is no guaranteed latency.
257
+ - **Completion callbacks are an optimization, never load-bearing.** A task's
258
+ completion handler, while the app happens to be alive to receive it, marks
259
+ a part done a little sooner and skips a round trip — but `reconcile()`
260
+ (via `ListParts`) is the only path anything is actually allowed to depend
261
+ on, since a callback after a kill is simply never delivered.
262
+ - **No whole-file SHA-256.** `startBackgroundUpload` never runs the hash lane
263
+ (there's no in-process read pass over the whole file to drive it), so a
264
+ background-completed upload's asset record has no `sha256`; integrity
265
+ rests on the per-part MD5 and composite ETag checks alone (layers 1 and 2
266
+ above — see "Integrity: three layers").
267
+
268
+ ## Bundle identity: import error classes from the entry you use
269
+
270
+ `NetworkError` and `ApiError` (and the engine's `IntegrityError`) are
271
+ exported from every entry that re-exports `api/client.ts` — but **each
272
+ subpath entry is bundled independently** (`@hostr/upload`, `/browser`,
273
+ `/node`, `/react-native` are four separate build outputs). A class
274
+ constructed inside one entry's bundle is not `instanceof`-equal to the "same"
275
+ class imported from a different entry.
276
+
277
+ This matters concretely if you supply a custom `transport` — e.g. to inject
278
+ failures in a test, as `infra/scripts/test-upload.mjs` does — and want the
279
+ engine's retry logic to recognize a thrown error as network-class:
280
+
281
+ ```js
282
+ import { NetworkError } from '@hostr/upload/node'; // matches the entry createUpload was imported from
283
+ // import { NetworkError } from '@hostr/upload'; // WRONG here — different bundle, fails the instanceof check
284
+ ```
285
+
286
+ Always import `NetworkError`/`ApiError` from the same subpath you imported
287
+ `createUpload` from.
288
+
289
+ ## See also
290
+
291
+ - Root `README.md`, "Upload API" section — the HTTP surface this SDK talks
292
+ to (`POST /v1/uploads`, `GET /v1/uploads/:id`, `.../parts`,
293
+ `.../complete`, `.../token`, `DELETE`).
294
+ - `pnpm build:sdk` — builds this package and vendors the browser bundle into
295
+ `apps/playground/vendor/` for the playground to import.
296
+ - `pnpm verify:upload <assetId>` — independently re-derives the composite
297
+ ETag and SHA-256 from the bytes actually sitting in R2, with no input from
298
+ this SDK.
@@ -0,0 +1,71 @@
1
+ /** Typed client for the hostr upload API (apps/api). No transport concerns
2
+ * beyond fetch — retries, backoff, and offline handling live in the engine. */
3
+ export declare class ApiError extends Error {
4
+ readonly status: number;
5
+ readonly code?: string;
6
+ constructor(status: number, code?: string);
7
+ }
8
+ export declare class NetworkError extends Error {
9
+ constructor(message?: string);
10
+ }
11
+ export type RemotePart = {
12
+ partNumber: number;
13
+ size: number;
14
+ etag: string;
15
+ };
16
+ export type RemoteUpload = {
17
+ assetId: string;
18
+ status: string;
19
+ key: string;
20
+ partSize: number;
21
+ expectedParts: number;
22
+ parts: RemotePart[];
23
+ };
24
+ export type CreateResult = {
25
+ assetId: string;
26
+ uploadToken: string;
27
+ tokenExpiresAt: number;
28
+ partSize: number;
29
+ expectedParts: number;
30
+ key: string;
31
+ };
32
+ export type CompleteResult = {
33
+ status: string;
34
+ etag?: string;
35
+ bytes?: number;
36
+ };
37
+ export declare class HostrApi {
38
+ #private;
39
+ constructor(endpoint: string, bearer: string, fetchFn?: typeof fetch);
40
+ setBearer(bearer: string): void;
41
+ getUpload(assetId: string): Promise<RemoteUpload>;
42
+ presignParts(assetId: string, partNumbers: number[], expiresIn?: number): Promise<{
43
+ urls: {
44
+ partNumber: number;
45
+ url: string;
46
+ }[];
47
+ expiresIn: number;
48
+ }>;
49
+ complete(assetId: string, body: {
50
+ sha256?: string;
51
+ }): Promise<CompleteResult>;
52
+ abort(assetId: string): Promise<void>;
53
+ }
54
+ export declare function createUploadSession(opts: {
55
+ endpoint: string;
56
+ apiKey: string;
57
+ filename: string;
58
+ contentType?: string;
59
+ size: number;
60
+ partSize?: number;
61
+ tokenTtlSec?: number;
62
+ }, fetchFn?: typeof fetch): Promise<CreateResult>;
63
+ export declare function mintUploadToken(opts: {
64
+ endpoint: string;
65
+ apiKey: string;
66
+ assetId: string;
67
+ tokenTtlSec?: number;
68
+ }, fetchFn?: typeof fetch): Promise<{
69
+ uploadToken: string;
70
+ tokenExpiresAt: number;
71
+ }>;
@@ -0,0 +1,50 @@
1
+ /** Browser adapter. This is a subpath entry (`@hostr/upload/browser`), never
2
+ * re-exported from the package root — pulling DOM-only types (XHR, window,
3
+ * localStorage) into the root entry would break non-browser bundlers. */
4
+ import type { FileSource, HttpTransport, StateStore, NetworkMonitor } from '../core/types.js';
5
+ import { type Upload } from '../core/upload.js';
6
+ export { createUploadSession, mintUploadToken } from '../api/client.js';
7
+ export { NetworkError, ApiError } from '../api/client.js';
8
+ export { IntegrityError } from '../core/engine.js';
9
+ export type { Upload } from '../core/upload.js';
10
+ /** Structural File-ish source: anything with name/size/lastModified and a
11
+ * slice(a, b).arrayBuffer() (a real DOM `File` satisfies this, and so does
12
+ * a plain stub) is Node-testable without touching the DOM. Identity is
13
+ * `name:size:lastModified` so a relaunch against the same file resolves to
14
+ * the same session, and a changed file (different size or mtime) starts a
15
+ * fresh one — mirrors nodeFileSource's `path:size:mtime`. */
16
+ export declare function browserFileSource(f: {
17
+ name: string;
18
+ size: number;
19
+ lastModified: number;
20
+ slice(a: number, b: number): {
21
+ arrayBuffer(): Promise<ArrayBuffer>;
22
+ };
23
+ }): FileSource;
24
+ /** Default transport: XHR, not `fetch` — `fetch` exposes no upload-progress
25
+ * events, and progress is the whole reason to reach for XHR here. */
26
+ export declare const xhrTransport: HttpTransport;
27
+ /** Persists session state under `localStorage` (or any Storage-ish backend,
28
+ * for tests). Defaults to `globalThis.localStorage`, resolved lazily inside
29
+ * the function body — not at module load — so importing this module in a
30
+ * non-browser environment (e.g. Node tests) never throws; it only throws if
31
+ * `localStorageStore()` is actually called with no `localStorage` global. */
32
+ export declare function localStorageStore(ls?: Storage): StateStore;
33
+ /** Wraps `navigator.onLine` + `window` `online`/`offline` events. Listeners
34
+ * are attached lazily inside `onChange`, not at module top level or even at
35
+ * factory-call time, so constructing (and even calling) this in Node —
36
+ * where `window` doesn't exist — never throws; `onChange` just becomes a
37
+ * no-op subscription in that case. */
38
+ export declare function browserNetworkMonitor(): NetworkMonitor;
39
+ export type CreateUploadOptions = {
40
+ endpoint: string;
41
+ uploadToken: string;
42
+ assetId: string;
43
+ file: File;
44
+ /** Defaults to a `localStorage`-backed store. */
45
+ store?: StateStore;
46
+ concurrency?: number;
47
+ checksum?: 'sha256' | 'none';
48
+ onTokenExpired?: (assetId: string) => Promise<string>;
49
+ };
50
+ export declare function createUpload(opts: CreateUploadOptions): Promise<Upload>;