@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.
@@ -0,0 +1,1061 @@
1
+ // src/core/session.ts
2
+ var storageKey = (fileId) => `hostr.upload.v1:${fileId}`;
3
+ async function loadSession(store, fileId) {
4
+ try {
5
+ const raw = await store.get(storageKey(fileId));
6
+ if (raw == null) return null;
7
+ const parsed = JSON.parse(raw);
8
+ if (parsed?.v !== 1) return null;
9
+ return parsed;
10
+ } catch {
11
+ return null;
12
+ }
13
+ }
14
+ async function saveSession(store, s) {
15
+ await store.set(storageKey(s.fileId), JSON.stringify(s));
16
+ }
17
+ async function clearSession(store, fileId) {
18
+ await store.delete(storageKey(fileId));
19
+ }
20
+ function partSizeOf(s, n) {
21
+ if (n === s.expectedParts) {
22
+ return s.declaredSize - (s.expectedParts - 1) * s.partSize;
23
+ }
24
+ return s.partSize;
25
+ }
26
+
27
+ // src/api/client.ts
28
+ var ApiError = class extends Error {
29
+ status;
30
+ code;
31
+ constructor(status, code) {
32
+ super(code ? `hostr api error ${status}: ${code}` : `hostr api error ${status}`);
33
+ this.name = "ApiError";
34
+ this.status = status;
35
+ this.code = code;
36
+ }
37
+ };
38
+ var NetworkError = class extends Error {
39
+ constructor(message = "network error") {
40
+ super(message);
41
+ this.name = "NetworkError";
42
+ }
43
+ };
44
+ var trimEndpoint = (endpoint) => endpoint.replace(/\/$/, "");
45
+ async function req(fetchFn, endpoint, bearer, path, init = {}) {
46
+ const url = `${trimEndpoint(endpoint)}${path}`;
47
+ const headers = { authorization: `Bearer ${bearer}` };
48
+ let body;
49
+ if (init.body !== void 0) {
50
+ headers["content-type"] = "application/json";
51
+ body = JSON.stringify(init.body);
52
+ }
53
+ let res;
54
+ try {
55
+ res = await fetchFn(url, { method: init.method ?? "GET", headers, body });
56
+ } catch {
57
+ throw new NetworkError();
58
+ }
59
+ const text = await res.text();
60
+ const data = text ? JSON.parse(text) : {};
61
+ if (!res.ok) {
62
+ const code = data && typeof data === "object" && typeof data.error === "string" ? data.error : void 0;
63
+ throw new ApiError(res.status, code);
64
+ }
65
+ return data;
66
+ }
67
+ var HostrApi = class {
68
+ #endpoint;
69
+ #bearer;
70
+ #fetch;
71
+ constructor(endpoint, bearer, fetchFn = fetch) {
72
+ this.#endpoint = endpoint;
73
+ this.#bearer = bearer;
74
+ this.#fetch = fetchFn;
75
+ }
76
+ setBearer(bearer) {
77
+ this.#bearer = bearer;
78
+ }
79
+ getUpload(assetId) {
80
+ return req(this.#fetch, this.#endpoint, this.#bearer, `/v1/uploads/${assetId}`);
81
+ }
82
+ presignParts(assetId, partNumbers, expiresIn) {
83
+ return req(this.#fetch, this.#endpoint, this.#bearer, `/v1/uploads/${assetId}/parts`, {
84
+ method: "POST",
85
+ body: expiresIn === void 0 ? { partNumbers } : { partNumbers, expiresIn }
86
+ });
87
+ }
88
+ complete(assetId, body) {
89
+ return req(this.#fetch, this.#endpoint, this.#bearer, `/v1/uploads/${assetId}/complete`, {
90
+ method: "POST",
91
+ body
92
+ });
93
+ }
94
+ async abort(assetId) {
95
+ await req(this.#fetch, this.#endpoint, this.#bearer, `/v1/uploads/${assetId}`, { method: "DELETE" });
96
+ }
97
+ };
98
+ async function createUploadSession(opts, fetchFn = fetch) {
99
+ const { endpoint, apiKey, ...body } = opts;
100
+ return req(fetchFn, endpoint, apiKey, "/v1/uploads", { method: "POST", body });
101
+ }
102
+ function mintUploadToken(opts, fetchFn = fetch) {
103
+ const { endpoint, apiKey, assetId, tokenTtlSec } = opts;
104
+ return req(fetchFn, endpoint, apiKey, `/v1/uploads/${assetId}/token`, {
105
+ method: "POST",
106
+ body: tokenTtlSec === void 0 ? {} : { tokenTtlSec }
107
+ });
108
+ }
109
+
110
+ // src/core/retry.ts
111
+ var RETRY_BUDGET = 6;
112
+ function classifyPut(res, err) {
113
+ if (err instanceof NetworkError) return "network";
114
+ if (err != null) return "fatal";
115
+ if (res === null) return "network";
116
+ const { status } = res;
117
+ if (status === 403) return "expired";
118
+ if (status === 429 || status >= 500 && status < 600) return "transient";
119
+ return "fatal";
120
+ }
121
+ function classifyApi(err) {
122
+ if (err instanceof NetworkError) return "network";
123
+ if (err instanceof ApiError) {
124
+ if (err.status === 429 || err.status >= 500 && err.status < 600) return "transient";
125
+ return "fatal";
126
+ }
127
+ return "transient";
128
+ }
129
+ function backoffMs(attempt, rng = Math.random, capMs = 6e4) {
130
+ const base = 500 * 2 ** (attempt - 1);
131
+ const jittered = base * (1 + 0.5 * rng());
132
+ return Math.min(capMs, jittered);
133
+ }
134
+
135
+ // src/core/events.ts
136
+ var Emitter = class {
137
+ listeners = /* @__PURE__ */ new Map();
138
+ on(ev, cb) {
139
+ let set = this.listeners.get(ev);
140
+ if (!set) {
141
+ set = /* @__PURE__ */ new Set();
142
+ this.listeners.set(ev, set);
143
+ }
144
+ const wrapped = cb;
145
+ set.add(wrapped);
146
+ return () => {
147
+ set.delete(wrapped);
148
+ };
149
+ }
150
+ emit(ev, v) {
151
+ const set = this.listeners.get(ev);
152
+ if (!set) return;
153
+ for (const cb of set) {
154
+ try {
155
+ cb(v);
156
+ } catch {
157
+ }
158
+ }
159
+ }
160
+ };
161
+ function computeProgress(i) {
162
+ const elapsedSec = (i.nowMs - i.startedAtMs) / 1e3;
163
+ const confirmedThisRun = i.confirmed - i.confirmedAtStart;
164
+ const throughputBps = elapsedSec >= 1 && confirmedThisRun > 0 ? confirmedThisRun / elapsedSec : null;
165
+ const bytesSent = Math.min(i.confirmed + i.inflight, i.total);
166
+ const etaSec = throughputBps === null ? null : (i.total - bytesSent) / throughputBps;
167
+ const progress = {
168
+ bytesSent,
169
+ bytesConfirmed: i.confirmed,
170
+ totalBytes: i.total,
171
+ throughputBps,
172
+ etaSec,
173
+ partsDone: i.partsDone,
174
+ partsTotal: i.partsTotal
175
+ };
176
+ if (i.partStates !== void 0) progress.partStates = i.partStates;
177
+ return progress;
178
+ }
179
+
180
+ // src/hash/hex.ts
181
+ var HEX = "0123456789abcdef";
182
+ function bytesToHex(b) {
183
+ let s = "";
184
+ for (let i = 0; i < b.length; i++) s += HEX[b[i] >> 4] + HEX[b[i] & 15];
185
+ return s;
186
+ }
187
+ function hexToBytes(s) {
188
+ const out = new Uint8Array(s.length >> 1);
189
+ for (let i = 0; i < out.length; i++) out[i] = parseInt(s.slice(i * 2, i * 2 + 2), 16);
190
+ return out;
191
+ }
192
+
193
+ // src/hash/sha256.ts
194
+ var K = new Int32Array(64);
195
+ var H0 = new Int32Array(8);
196
+ {
197
+ const primes = [];
198
+ for (let n = 2; primes.length < 64; n++) if (primes.every((p) => n % p)) primes.push(n);
199
+ for (let i = 0; i < 64; i++) K[i] = Math.floor(primes[i] ** (1 / 3) % 1 * 2 ** 32) | 0;
200
+ for (let i = 0; i < 8; i++) H0[i] = Math.floor(Math.sqrt(primes[i]) % 1 * 2 ** 32) | 0;
201
+ }
202
+ var rotr = (x, n) => x >>> n | x << 32 - n;
203
+ var Sha256 = class _Sha256 {
204
+ h = new Int32Array(H0);
205
+ buf = new Uint8Array(64);
206
+ bufLen = 0;
207
+ len = 0;
208
+ // total bytes; Number is exact to 2^53
209
+ w = new Int32Array(64);
210
+ update(data) {
211
+ this.len += data.length;
212
+ let off = 0;
213
+ if (this.bufLen > 0) {
214
+ const take = Math.min(64 - this.bufLen, data.length);
215
+ this.buf.set(data.subarray(0, take), this.bufLen);
216
+ this.bufLen += take;
217
+ off = take;
218
+ if (this.bufLen === 64) {
219
+ this.compress(this.buf, 0);
220
+ this.bufLen = 0;
221
+ }
222
+ }
223
+ for (; off + 64 <= data.length; off += 64) this.compress(data, off);
224
+ if (off < data.length) {
225
+ this.buf.set(data.subarray(off));
226
+ this.bufLen = data.length - off;
227
+ }
228
+ }
229
+ compress(p, off) {
230
+ const w = this.w;
231
+ for (let i = 0; i < 16; i++)
232
+ w[i] = p[off + 4 * i] << 24 | p[off + 4 * i + 1] << 16 | p[off + 4 * i + 2] << 8 | p[off + 4 * i + 3];
233
+ for (let i = 16; i < 64; i++) {
234
+ const s0 = rotr(w[i - 15], 7) ^ rotr(w[i - 15], 18) ^ w[i - 15] >>> 3;
235
+ const s1 = rotr(w[i - 2], 17) ^ rotr(w[i - 2], 19) ^ w[i - 2] >>> 10;
236
+ w[i] = w[i - 16] + s0 + w[i - 7] + s1 | 0;
237
+ }
238
+ let [a, b, c, d, e, f, g, hh] = [this.h[0], this.h[1], this.h[2], this.h[3], this.h[4], this.h[5], this.h[6], this.h[7]];
239
+ for (let i = 0; i < 64; i++) {
240
+ const S1 = rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25);
241
+ const ch = e & f ^ ~e & g;
242
+ const t1 = hh + S1 + ch + K[i] + w[i] | 0;
243
+ const S0 = rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22);
244
+ const maj = a & b ^ a & c ^ b & c;
245
+ const t2 = S0 + maj | 0;
246
+ hh = g;
247
+ g = f;
248
+ f = e;
249
+ e = d + t1 | 0;
250
+ d = c;
251
+ c = b;
252
+ b = a;
253
+ a = t1 + t2 | 0;
254
+ }
255
+ this.h[0] = this.h[0] + a | 0;
256
+ this.h[1] = this.h[1] + b | 0;
257
+ this.h[2] = this.h[2] + c | 0;
258
+ this.h[3] = this.h[3] + d | 0;
259
+ this.h[4] = this.h[4] + e | 0;
260
+ this.h[5] = this.h[5] + f | 0;
261
+ this.h[6] = this.h[6] + g | 0;
262
+ this.h[7] = this.h[7] + hh | 0;
263
+ }
264
+ /**
265
+ * Finalize on a clone so the hash can keep accepting updates.
266
+ *
267
+ * Padding: capture the message length in bits BEFORE touching the clone,
268
+ * then build the entire 0x80 + zero-padding + 64-bit length tail as one
269
+ * buffer sized so the clone's running byte count lands on a block
270
+ * boundary, and hand it to update() in a single call. update()'s own
271
+ * `len` bookkeeping on the clone is irrelevant after this point (the
272
+ * clone is discarded), so there is no need to keep it meaningful.
273
+ */
274
+ digestBytes() {
275
+ const c = this.clone();
276
+ const bitLen = c.len * 8;
277
+ const zeroCount = (56 - (c.len + 1) % 64 + 64) % 64;
278
+ const pad = new Uint8Array(1 + zeroCount + 8);
279
+ pad[0] = 128;
280
+ const lo = bitLen >>> 0, hi = Math.floor(bitLen / 2 ** 32);
281
+ const tailOff = 1 + zeroCount;
282
+ for (let i = 0; i < 4; i++) {
283
+ pad[tailOff + i] = hi >>> 8 * (3 - i) & 255;
284
+ pad[tailOff + 4 + i] = lo >>> 8 * (3 - i) & 255;
285
+ }
286
+ c.update(pad);
287
+ const out = new Uint8Array(32);
288
+ for (let i = 0; i < 8; i++)
289
+ for (let j = 0; j < 4; j++) out[4 * i + j] = c.h[i] >>> 8 * (3 - j) & 255;
290
+ return out;
291
+ }
292
+ digestHex() {
293
+ return bytesToHex(this.digestBytes());
294
+ }
295
+ exportState() {
296
+ return JSON.stringify({ h: [...this.h], buf: bytesToHex(this.buf.subarray(0, this.bufLen)), len: this.len });
297
+ }
298
+ importState(state) {
299
+ const s = JSON.parse(state);
300
+ this.h = new Int32Array(s.h);
301
+ this.buf.fill(0);
302
+ this.bufLen = s.buf.length / 2;
303
+ this.len = s.len;
304
+ for (let i = 0; i < this.bufLen; i++) this.buf[i] = parseInt(s.buf.slice(i * 2, i * 2 + 2), 16);
305
+ }
306
+ clone() {
307
+ const c = new _Sha256();
308
+ c.h = new Int32Array(this.h);
309
+ c.buf = new Uint8Array(this.buf);
310
+ c.bufLen = this.bufLen;
311
+ c.len = this.len;
312
+ return c;
313
+ }
314
+ };
315
+
316
+ // src/hash/md5.ts
317
+ var K2 = new Int32Array(64);
318
+ for (let i = 0; i < 64; i++) K2[i] = Math.floor(Math.abs(Math.sin(i + 1)) * 2 ** 32) | 0;
319
+ var S = [7, 12, 17, 22, 5, 9, 14, 20, 4, 11, 16, 23, 6, 10, 15, 21];
320
+ var rotl = (x, n) => x << n | x >>> 32 - n;
321
+ var Md5 = class _Md5 {
322
+ h = new Int32Array([1732584193, 4023233417 | 0, 2562383102 | 0, 271733878]);
323
+ buf = new Uint8Array(64);
324
+ bufLen = 0;
325
+ len = 0;
326
+ // total bytes; Number is exact to 2^53
327
+ update(data) {
328
+ this.len += data.length;
329
+ let off = 0;
330
+ if (this.bufLen > 0) {
331
+ const take = Math.min(64 - this.bufLen, data.length);
332
+ this.buf.set(data.subarray(0, take), this.bufLen);
333
+ this.bufLen += take;
334
+ off = take;
335
+ if (this.bufLen === 64) {
336
+ this.compress(this.buf, 0);
337
+ this.bufLen = 0;
338
+ }
339
+ }
340
+ for (; off + 64 <= data.length; off += 64) this.compress(data, off);
341
+ if (off < data.length) {
342
+ this.buf.set(data.subarray(off));
343
+ this.bufLen = data.length - off;
344
+ }
345
+ }
346
+ compress(p, off) {
347
+ const m = new Int32Array(16);
348
+ for (let i = 0; i < 16; i++)
349
+ m[i] = p[off + 4 * i] | p[off + 4 * i + 1] << 8 | p[off + 4 * i + 2] << 16 | p[off + 4 * i + 3] << 24;
350
+ let a = this.h[0], b = this.h[1], c = this.h[2], d = this.h[3];
351
+ for (let i = 0; i < 64; i++) {
352
+ let f, g;
353
+ if (i < 16) {
354
+ f = b & c | ~b & d;
355
+ g = i;
356
+ } else if (i < 32) {
357
+ f = d & b | ~d & c;
358
+ g = (5 * i + 1) % 16;
359
+ } else if (i < 48) {
360
+ f = b ^ c ^ d;
361
+ g = (3 * i + 5) % 16;
362
+ } else {
363
+ f = c ^ (b | ~d);
364
+ g = 7 * i % 16;
365
+ }
366
+ const t = d;
367
+ d = c;
368
+ c = b;
369
+ b = b + rotl(a + f + K2[i] + m[g] | 0, S[(i >> 4) * 4 + (i & 3)]) | 0;
370
+ a = t;
371
+ }
372
+ this.h[0] = this.h[0] + a | 0;
373
+ this.h[1] = this.h[1] + b | 0;
374
+ this.h[2] = this.h[2] + c | 0;
375
+ this.h[3] = this.h[3] + d | 0;
376
+ }
377
+ /**
378
+ * Finalize on a clone so the hash can keep accepting updates.
379
+ *
380
+ * Padding: capture the message length in bits BEFORE touching the clone,
381
+ * then build the entire 0x80 + zero-padding + 64-bit length tail as one
382
+ * buffer sized so the clone's running byte count lands on a block
383
+ * boundary, and hand it to update() in a single call. update()'s own
384
+ * `len` bookkeeping on the clone is irrelevant after this point (the
385
+ * clone is discarded), so there is no need to keep it meaningful.
386
+ */
387
+ digestBytes() {
388
+ const c = this.clone();
389
+ const bitLen = c.len * 8;
390
+ const zeroCount = (56 - (c.len + 1) % 64 + 64) % 64;
391
+ const pad = new Uint8Array(1 + zeroCount + 8);
392
+ pad[0] = 128;
393
+ const lo = bitLen >>> 0, hi = Math.floor(bitLen / 2 ** 32);
394
+ const tailOff = 1 + zeroCount;
395
+ for (let i = 0; i < 4; i++) {
396
+ pad[tailOff + i] = lo >>> 8 * i & 255;
397
+ pad[tailOff + 4 + i] = hi >>> 8 * i & 255;
398
+ }
399
+ c.update(pad);
400
+ const out = new Uint8Array(16);
401
+ for (let i = 0; i < 4; i++)
402
+ for (let j = 0; j < 4; j++) out[4 * i + j] = c.h[i] >>> 8 * j & 255;
403
+ return out;
404
+ }
405
+ digestHex() {
406
+ return bytesToHex(this.digestBytes());
407
+ }
408
+ exportState() {
409
+ return JSON.stringify({ h: [...this.h], buf: bytesToHex(this.buf.subarray(0, this.bufLen)), len: this.len });
410
+ }
411
+ importState(state) {
412
+ const s = JSON.parse(state);
413
+ this.h = new Int32Array(s.h);
414
+ this.buf.fill(0);
415
+ this.bufLen = s.buf.length / 2;
416
+ this.len = s.len;
417
+ for (let i = 0; i < this.bufLen; i++) this.buf[i] = parseInt(s.buf.slice(i * 2, i * 2 + 2), 16);
418
+ }
419
+ clone() {
420
+ const c = new _Md5();
421
+ c.h = new Int32Array(this.h);
422
+ c.buf = new Uint8Array(this.buf);
423
+ c.bufLen = this.bufLen;
424
+ c.len = this.len;
425
+ return c;
426
+ }
427
+ };
428
+
429
+ // src/hash/composite.ts
430
+ function compositeEtag(md5Hexes) {
431
+ const h = new Md5();
432
+ for (const hex of md5Hexes) h.update(hexToBytes(hex));
433
+ return `${h.digestHex()}-${md5Hexes.length}`;
434
+ }
435
+
436
+ // src/core/engine.ts
437
+ var SYSTEMIC_TRANSPORT_FAILURE_THRESHOLD = 3;
438
+ var IntegrityError = class extends Error {
439
+ constructor(message) {
440
+ super(message);
441
+ this.name = "IntegrityError";
442
+ }
443
+ };
444
+ var Stopped = class extends Error {
445
+ constructor(message) {
446
+ super(message);
447
+ this.name = "Stopped";
448
+ }
449
+ };
450
+ var stripQuotes = (s) => s.replace(/^"|"$/g, "");
451
+ var TERMINAL = ["complete", "failed", "aborted"];
452
+ var UploadEngine = class {
453
+ done;
454
+ resolveDone;
455
+ rejectDone;
456
+ emitter = new Emitter();
457
+ presigned = /* @__PURE__ */ new Map();
458
+ /** Presign fetches since the last confirmed part. Healthy uploads confirm
459
+ * constantly, so this stays at zero and costs nothing; in a sustained
460
+ * failure loop (every PUT dying without landing a part) it grows, and
461
+ * presign() spaces further /parts fetches on the same backoff curve the
462
+ * 'expired' storm uses — the API call rate is what trips rate limits, so
463
+ * it gets its own governor, independent of per-part attempt cadence and
464
+ * worker count. */
465
+ presignsSinceConfirm = 0;
466
+ /** The in-flight /parts fetch, shared so concurrent cache-missing workers
467
+ * ride one round-trip and re-check the cache instead of each firing an
468
+ * overlapping batch of their own (same memo shape as hashLanePromise). */
469
+ presignInflight = null;
470
+ claimed = /* @__PURE__ */ new Set();
471
+ inflightSent = /* @__PURE__ */ new Map();
472
+ stopped = false;
473
+ running = false;
474
+ restartPending = false;
475
+ startedAt = 0;
476
+ confirmedAtStart = 0;
477
+ lastEmit = 0;
478
+ deps;
479
+ opts;
480
+ session;
481
+ /** The in-flight sha256 lane, tracked so a pause→resume never starts a
482
+ * second concurrent lane over the same file. Cleared once settled so a
483
+ * later resume can start a fresh one from the persisted midstate. */
484
+ hashLanePromise = null;
485
+ /** Engine-wide (not per-part): a CORS-blocked bucket fails every worker's
486
+ * PUT the same way, so the streak accumulates across parts. Deliberately
487
+ * not bumped by apiCall's network stalls — the API answering while every
488
+ * R2 PUT dies is exactly the bucket-CORS signature. */
489
+ consecutiveNetworkFailures = 0;
490
+ diagnosticFired = false;
491
+ /** Resolvers for anyone currently parked in waitOnline(), woken the
492
+ * instant pause()/abort()/fail() halts the engine — without this a
493
+ * stalled engine could only be interrupted by the network itself
494
+ * coming back online. */
495
+ stopWaiters = /* @__PURE__ */ new Set();
496
+ constructor(deps, opts, session) {
497
+ this.deps = deps;
498
+ this.opts = opts;
499
+ this.session = session;
500
+ this.done = new Promise((res, rej) => {
501
+ this.resolveDone = res;
502
+ this.rejectDone = rej;
503
+ });
504
+ this.done.catch(() => {
505
+ });
506
+ }
507
+ on(ev, cb) {
508
+ return this.emitter.on(ev, cb);
509
+ }
510
+ get state() {
511
+ return this.session.status;
512
+ }
513
+ start() {
514
+ if (TERMINAL.includes(this.session.status)) return;
515
+ if (this.running) {
516
+ this.restartPending = true;
517
+ return;
518
+ }
519
+ this.stopped = false;
520
+ this.running = true;
521
+ this.run().catch((e) => {
522
+ if (!(e instanceof Stopped)) this.fail(e instanceof Error ? e : new Error(String(e)));
523
+ }).finally(() => {
524
+ this.running = false;
525
+ if (this.restartPending) {
526
+ this.restartPending = false;
527
+ this.start();
528
+ }
529
+ });
530
+ }
531
+ pause() {
532
+ this.halt();
533
+ this.restartPending = false;
534
+ this.setStatus("paused");
535
+ }
536
+ resume() {
537
+ this.start();
538
+ }
539
+ async abort() {
540
+ this.halt();
541
+ this.setStatus("aborted");
542
+ await this.deps.api.abort(this.session.assetId).catch(() => {
543
+ });
544
+ await clearSession(this.deps.store, this.session.fileId);
545
+ }
546
+ /** Stop dispatch everywhere at once: new part claims, waitOnline's
547
+ * parked callers, and apiCall's network-retry loop all observe this on
548
+ * their next check. */
549
+ halt() {
550
+ this.stopped = true;
551
+ for (const w of this.stopWaiters) w();
552
+ this.stopWaiters.clear();
553
+ }
554
+ setStatus(s) {
555
+ if (this.session.status === s) return;
556
+ this.session.status = s;
557
+ this.emitter.emit("statechange", s);
558
+ }
559
+ fail(e) {
560
+ this.halt();
561
+ this.setStatus("failed");
562
+ this.emitter.emit("error", e);
563
+ this.rejectDone(e);
564
+ }
565
+ async run() {
566
+ const remote = await this.apiCall(() => this.deps.api.getUpload(this.session.assetId));
567
+ if (remote.status !== "uploading") {
568
+ if (["uploaded", "queued", "processing", "ready"].includes(remote.status)) return this.finish();
569
+ throw new Error(`upload is ${remote.status}, cannot continue`);
570
+ }
571
+ await this.reconcile(remote.parts);
572
+ if (this.stopped) return;
573
+ this.setStatus("running");
574
+ this.startedAt = this.deps.clock.now();
575
+ this.confirmedAtStart = this.bytesConfirmed();
576
+ const hashLane = this.getHashLane();
577
+ await Promise.all(Array.from({ length: this.opts.concurrency }, () => this.worker()));
578
+ if (this.stopped) return;
579
+ this.session.sha256 = await hashLane;
580
+ if (this.stopped) return;
581
+ await saveSession(this.deps.store, this.session);
582
+ await this.finish();
583
+ }
584
+ async finish() {
585
+ this.setStatus("completing");
586
+ await saveSession(this.deps.store, this.session);
587
+ const res = await this.apiCall(() => this.deps.api.complete(
588
+ this.session.assetId,
589
+ this.session.sha256 ? { sha256: this.session.sha256 } : {}
590
+ ));
591
+ const expected = this.expectedComposite();
592
+ if (expected && res.etag && res.etag !== expected)
593
+ throw new IntegrityError(`R2 assembled ${res.etag}; client computed ${expected}`);
594
+ await clearSession(this.deps.store, this.session.fileId);
595
+ await this.deps.file.close?.();
596
+ this.setStatus("complete");
597
+ const info = {
598
+ assetId: this.session.assetId,
599
+ etag: res.etag ?? expected ?? "",
600
+ sha256: this.session.sha256,
601
+ bytes: res.bytes ?? this.session.declaredSize
602
+ };
603
+ this.emitter.emit("complete", info);
604
+ this.resolveDone(info);
605
+ }
606
+ expectedComposite() {
607
+ const md5s = [];
608
+ for (let n = 1; n <= this.session.expectedParts; n++) {
609
+ const m = this.session.done[n];
610
+ if (!m) return null;
611
+ md5s.push(m);
612
+ }
613
+ return compositeEtag(md5s);
614
+ }
615
+ /** Reconcile local bookkeeping against what R2 actually holds before resuming.
616
+ * Local claims R2 can't confirm are stale and forgotten; parts whose stored
617
+ * md5 matches R2's etag are kept as-is; a mismatch means the landed bytes
618
+ * aren't verified as ours, so it's dropped for re-upload. For parts R2 holds
619
+ * but local `done` has no record of (state lost — reinstall, cleared
620
+ * storage), the local byte range is re-read and re-hashed; it's only
621
+ * adopted as done when that hash matches R2's etag, never trusted blind. */
622
+ async reconcile(remote) {
623
+ const remoteByN = new Map(remote.map((p) => [p.partNumber, p]));
624
+ for (const n of Object.keys(this.session.done).map(Number))
625
+ if (!remoteByN.has(n)) delete this.session.done[n];
626
+ for (const p of remote) {
627
+ const have = this.session.done[p.partNumber];
628
+ if (have === p.etag) continue;
629
+ if (have) {
630
+ delete this.session.done[p.partNumber];
631
+ continue;
632
+ }
633
+ const offset = (p.partNumber - 1) * this.session.partSize;
634
+ const body = await this.deps.file.read(offset, partSizeOf(this.session, p.partNumber));
635
+ const h = this.deps.hasher.md5();
636
+ h.update(body);
637
+ const md5 = h.digestHex();
638
+ if (md5 === p.etag) this.session.done[p.partNumber] = md5;
639
+ }
640
+ if (this.session.status === "aborted") return;
641
+ await saveSession(this.deps.store, this.session);
642
+ }
643
+ nextPart() {
644
+ for (let n = 1; n <= this.session.expectedParts; n++)
645
+ if (!this.session.done[n] && !this.claimed.has(n)) return n;
646
+ return null;
647
+ }
648
+ async worker() {
649
+ while (!this.stopped) {
650
+ const n = this.nextPart();
651
+ if (n == null) return;
652
+ this.claimed.add(n);
653
+ try {
654
+ await this.uploadPart(n);
655
+ } finally {
656
+ this.claimed.delete(n);
657
+ this.inflightSent.delete(n);
658
+ }
659
+ }
660
+ }
661
+ async uploadPart(n) {
662
+ let budget = RETRY_BUDGET, attempt = 0, consecutiveExpired = 0;
663
+ let cls;
664
+ for (; ; ) {
665
+ if (this.stopped) return;
666
+ await this.waitOnline();
667
+ if (this.stopped) return;
668
+ let caught;
669
+ try {
670
+ const url = await this.presign(n, cls === "expired");
671
+ const offset = (n - 1) * this.session.partSize;
672
+ const expectedLen = partSizeOf(this.session, n);
673
+ const body = await this.deps.file.read(offset, expectedLen);
674
+ if (body.length !== expectedLen)
675
+ throw new Error(
676
+ `part ${n}: source file changed size mid-upload (expected ${expectedLen} bytes, read ${body.length}) \u2014 the file was modified or truncated after the upload started`
677
+ );
678
+ const h = this.deps.hasher.md5();
679
+ h.update(body);
680
+ const md5 = h.digestHex();
681
+ const res = await this.deps.transport.put(
682
+ url,
683
+ body,
684
+ { onProgress: (s) => this.onPartProgress(n, s) }
685
+ );
686
+ if (res.status >= 200 && res.status < 300) {
687
+ if (res.etag && stripQuotes(res.etag) === md5) {
688
+ if (this.session.status === "aborted") return;
689
+ this.notePutOutcome("ok");
690
+ this.session.done[n] = md5;
691
+ this.presignsSinceConfirm = 0;
692
+ await saveSession(this.deps.store, this.session);
693
+ this.inflightSent.delete(n);
694
+ this.emitProgress(true);
695
+ return;
696
+ }
697
+ cls = "corrupt";
698
+ } else cls = classifyPut(res, void 0);
699
+ } catch (err) {
700
+ if (err instanceof Stopped) return;
701
+ caught = err instanceof Error ? err : void 0;
702
+ cls = err instanceof ApiError ? classifyApi(err) : classifyPut(null, err);
703
+ }
704
+ this.inflightSent.delete(n);
705
+ this.notePutOutcome(cls);
706
+ if (cls === "fatal") {
707
+ this.halt();
708
+ throw caught ?? new Error(`part ${n}: unrecoverable upload error`);
709
+ }
710
+ if (cls === "expired") {
711
+ this.presigned.delete(n);
712
+ consecutiveExpired++;
713
+ if (consecutiveExpired > 1) await this.deps.clock.sleep(backoffMs(consecutiveExpired, this.deps.rng));
714
+ continue;
715
+ }
716
+ consecutiveExpired = 0;
717
+ attempt++;
718
+ if (cls !== "network" && --budget <= 0) {
719
+ this.halt();
720
+ throw new Error(`part ${n}: retries exhausted (${cls})`);
721
+ }
722
+ if (cls === "network") this.setStatus("stalled");
723
+ await this.deps.clock.sleep(backoffMs(attempt, this.deps.rng));
724
+ if (cls === "network" && !this.stopped) this.setStatus("running");
725
+ }
726
+ }
727
+ async presign(n, afterExpiry = false) {
728
+ const c = this.presigned.get(n);
729
+ if (c && this.presignFresh(c)) return c.url;
730
+ if (this.presignInflight) {
731
+ await this.presignInflight;
732
+ if (this.stopped) throw new Stopped();
733
+ const landed = this.presigned.get(n);
734
+ if (landed && this.presignFresh(landed)) return landed.url;
735
+ }
736
+ if (!afterExpiry && this.presignsSinceConfirm > 0) {
737
+ await this.deps.clock.sleep(backoffMs(this.presignsSinceConfirm, this.deps.rng));
738
+ if (this.stopped) throw new Stopped();
739
+ const landed = this.presigned.get(n);
740
+ if (landed && this.presignFresh(landed)) return landed.url;
741
+ }
742
+ this.presignsSinceConfirm++;
743
+ const want = [n];
744
+ for (let m = 1; m <= this.session.expectedParts && want.length < this.opts.presignBatch; m++) {
745
+ const e = this.presigned.get(m);
746
+ if (m !== n && !this.session.done[m] && !(e && this.presignFresh(e))) want.push(m);
747
+ }
748
+ const pending = this.apiCall(() => this.deps.api.presignParts(this.session.assetId, want, this.opts.presignTtlSec));
749
+ const inflight = pending.then(() => {
750
+ }, () => {
751
+ });
752
+ this.presignInflight = inflight;
753
+ try {
754
+ const res = await pending;
755
+ for (const u of res.urls)
756
+ this.presigned.set(u.partNumber, { url: u.url, expiresAt: this.deps.clock.now() + res.expiresIn * 1e3 });
757
+ return this.presigned.get(n).url;
758
+ } finally {
759
+ if (this.presignInflight === inflight) this.presignInflight = null;
760
+ }
761
+ }
762
+ /** Fresh = safely usable for a new PUT: at least 60s of validity left, so
763
+ * a part starting now won't have its URL expire mid-flight. */
764
+ presignFresh(c) {
765
+ return c.expiresAt - 6e4 > this.deps.clock.now();
766
+ }
767
+ /** API calls: infinite patience for network loss, one token refresh on 401, else throw.
768
+ * Throws Stopped (never surfaced) if pause()/abort() interrupts a stuck retry. */
769
+ async apiCall(fn) {
770
+ const attempts = /* @__PURE__ */ new Map();
771
+ let refreshed = false;
772
+ const bump = (cls) => {
773
+ const n = (attempts.get(cls) ?? 0) + 1;
774
+ attempts.set(cls, n);
775
+ return n;
776
+ };
777
+ for (; ; ) {
778
+ try {
779
+ return await fn();
780
+ } catch (e) {
781
+ const cls = classifyApi(e);
782
+ if (cls === "network") {
783
+ this.setStatus("stalled");
784
+ const attempt = bump("network");
785
+ await this.waitOnline();
786
+ if (this.stopped) throw new Stopped();
787
+ await this.deps.clock.sleep(backoffMs(attempt, this.deps.rng));
788
+ if (this.stopped) throw new Stopped();
789
+ continue;
790
+ }
791
+ if (e instanceof ApiError && e.status === 401 && this.opts.onTokenExpired && !refreshed) {
792
+ refreshed = true;
793
+ const t = await this.opts.onTokenExpired(this.session.assetId);
794
+ this.session.uploadToken = t;
795
+ this.deps.api.setBearer(t);
796
+ await saveSession(this.deps.store, this.session);
797
+ continue;
798
+ }
799
+ if (cls === "transient") {
800
+ const attempt = bump("transient");
801
+ if (attempt <= RETRY_BUDGET) {
802
+ await this.deps.clock.sleep(backoffMs(attempt, this.deps.rng));
803
+ continue;
804
+ }
805
+ }
806
+ throw e;
807
+ }
808
+ }
809
+ }
810
+ async waitOnline() {
811
+ const net = this.deps.network;
812
+ if (!net || net.isOnline()) return;
813
+ if (this.stopped) return;
814
+ this.setStatus("stalled");
815
+ await new Promise((res) => {
816
+ let off = () => {
817
+ };
818
+ const finish = () => {
819
+ off();
820
+ this.stopWaiters.delete(finish);
821
+ res();
822
+ };
823
+ off = net.onChange((on) => {
824
+ if (on) finish();
825
+ });
826
+ this.stopWaiters.add(finish);
827
+ });
828
+ if (!this.stopped) this.setStatus("running");
829
+ }
830
+ /** Lazily starts the sha256 lane and remembers the in-flight promise so a
831
+ * fast pause→resume can never spawn a second concurrent lane over the
832
+ * same file. The promise carries its own no-op .catch so it can never
833
+ * become an unhandledRejection while nothing is actively awaiting it
834
+ * (e.g. during a paused window); a real failure still surfaces normally
835
+ * to whoever does await it, and from there through run() to fail(). */
836
+ getHashLane() {
837
+ if (this.opts.checksum !== "sha256" || this.session.sha256) return Promise.resolve(this.session.sha256);
838
+ if (!this.hashLanePromise) {
839
+ const p = this.hashLane().finally(() => {
840
+ if (this.hashLanePromise === p) this.hashLanePromise = null;
841
+ });
842
+ this.hashLanePromise = p;
843
+ this.hashLanePromise.catch(() => {
844
+ });
845
+ }
846
+ return this.hashLanePromise;
847
+ }
848
+ async hashLane() {
849
+ const CHUNK = 1 << 20;
850
+ const h = new Sha256();
851
+ let off = 0;
852
+ if (this.session.hash) {
853
+ h.importState(this.session.hash.state);
854
+ off = this.session.hash.offset;
855
+ }
856
+ while (off < this.deps.file.size) {
857
+ if (this.stopped) {
858
+ this.hashLanePromise = null;
859
+ if (this.session.status === "aborted") return void 0;
860
+ this.session.hash = { offset: off, state: h.exportState() };
861
+ await saveSession(this.deps.store, this.session);
862
+ return void 0;
863
+ }
864
+ const b = await this.deps.file.read(off, Math.min(CHUNK, this.deps.file.size - off));
865
+ h.update(b);
866
+ off += b.length;
867
+ if (off % (8 * CHUNK) === 0) this.session.hash = { offset: off, state: h.exportState() };
868
+ }
869
+ this.session.hash = void 0;
870
+ return h.digestHex();
871
+ }
872
+ /** Feed every part-PUT outcome to the systemic-transport-failure detector.
873
+ * Any outcome that isn't network-class — success, 403, 5xx, even corrupt
874
+ * bytes — proves a request reached the server, so the streak resets. */
875
+ notePutOutcome(cls) {
876
+ if (cls !== "network") {
877
+ this.consecutiveNetworkFailures = 0;
878
+ this.diagnosticFired = false;
879
+ return;
880
+ }
881
+ this.consecutiveNetworkFailures++;
882
+ if (this.diagnosticFired) return;
883
+ if (this.consecutiveNetworkFailures < SYSTEMIC_TRANSPORT_FAILURE_THRESHOLD) return;
884
+ if (this.bytesConfirmed() > 0) return;
885
+ this.diagnosticFired = true;
886
+ this.emitter.emit("diagnostic", {
887
+ code: "systemic-transport-failure",
888
+ consecutiveNetworkFailures: this.consecutiveNetworkFailures,
889
+ bytesEverConfirmed: this.bytesConfirmed(),
890
+ hint: 'every part PUT is failing at the network layer and no bytes have ever been confirmed for this session \u2014 if this is a browser, check the source bucket CORS rules (preflight PUT from this origin, ExposeHeaders ETag); see README "Bucket CORS is a hard prerequisite for browser uploads"'
891
+ });
892
+ }
893
+ bytesConfirmed() {
894
+ let sum = 0;
895
+ for (const n of Object.keys(this.session.done)) sum += partSizeOf(this.session, Number(n));
896
+ return sum;
897
+ }
898
+ onPartProgress(n, sent) {
899
+ this.inflightSent.set(n, sent);
900
+ this.emitProgress(false);
901
+ }
902
+ emitProgress(force) {
903
+ const now = this.deps.clock.now();
904
+ if (!force && now - this.lastEmit < 200) return;
905
+ this.lastEmit = now;
906
+ let inflight = 0;
907
+ for (const v of this.inflightSent.values()) inflight += v;
908
+ const partStates = {};
909
+ for (const n of this.claimed) partStates[n] = "inflight";
910
+ for (const n of Object.keys(this.session.done)) partStates[Number(n)] = "done";
911
+ this.emitter.emit("progress", computeProgress({
912
+ confirmed: this.bytesConfirmed(),
913
+ inflight,
914
+ total: this.session.declaredSize,
915
+ partsDone: Object.keys(this.session.done).length,
916
+ partsTotal: this.session.expectedParts,
917
+ startedAtMs: this.startedAt,
918
+ nowMs: now,
919
+ confirmedAtStart: this.confirmedAtStart,
920
+ partStates
921
+ }));
922
+ }
923
+ };
924
+
925
+ // src/core/upload.ts
926
+ var realClock = { now: () => Date.now(), sleep: (ms) => new Promise((res) => setTimeout(res, ms)) };
927
+ async function buildUpload(opts) {
928
+ const clock = opts.clock ?? realClock;
929
+ const fetchFn = opts.fetchFn ?? fetch;
930
+ const api = new HostrApi(opts.endpoint, opts.uploadToken, fetchFn);
931
+ let session = await loadSession(opts.store, opts.file.id);
932
+ if (session) {
933
+ session.uploadToken = opts.uploadToken;
934
+ await saveSession(opts.store, session);
935
+ } else {
936
+ const remote = await api.getUpload(opts.assetId);
937
+ session = {
938
+ v: 1,
939
+ assetId: opts.assetId,
940
+ endpoint: opts.endpoint,
941
+ uploadToken: opts.uploadToken,
942
+ key: remote.key,
943
+ partSize: remote.partSize,
944
+ expectedParts: remote.expectedParts,
945
+ declaredSize: opts.file.size,
946
+ fileId: opts.file.id,
947
+ done: {},
948
+ status: "idle",
949
+ createdAt: (/* @__PURE__ */ new Date()).toISOString()
950
+ };
951
+ await saveSession(opts.store, session);
952
+ }
953
+ const deps = {
954
+ api,
955
+ file: opts.file,
956
+ transport: opts.transport,
957
+ store: opts.store,
958
+ hasher: opts.hasher,
959
+ clock,
960
+ network: opts.network
961
+ };
962
+ const engineOpts = {
963
+ concurrency: opts.concurrency ?? 4,
964
+ checksum: opts.checksum ?? "sha256",
965
+ presignBatch: 16,
966
+ presignTtlSec: opts.presignTtlSec ?? 3600,
967
+ onTokenExpired: opts.onTokenExpired
968
+ };
969
+ return new UploadEngine(deps, engineOpts, session);
970
+ }
971
+
972
+ // src/hash/hasher.ts
973
+ var pureHasher = { md5: () => new Md5() };
974
+
975
+ // src/browser/index.ts
976
+ function browserFileSource(f) {
977
+ return {
978
+ size: f.size,
979
+ id: `${f.name}:${f.size}:${f.lastModified}`,
980
+ async read(offset, length) {
981
+ const buf = await f.slice(offset, offset + length).arrayBuffer();
982
+ return new Uint8Array(buf);
983
+ }
984
+ };
985
+ }
986
+ var xhrTransport = {
987
+ put: (url, body, opts = {}) => new Promise((resolve, reject) => {
988
+ const x = new XMLHttpRequest();
989
+ x.open("PUT", url);
990
+ x.upload.onprogress = (e) => opts.onProgress?.(e.loaded);
991
+ x.onload = () => resolve({ status: x.status, etag: x.getResponseHeader("etag") });
992
+ x.onerror = () => reject(new NetworkError("xhr error"));
993
+ x.onabort = () => reject(new NetworkError("xhr aborted"));
994
+ opts.signal?.addEventListener("abort", () => x.abort());
995
+ x.send(body);
996
+ })
997
+ };
998
+ function localStorageStore(ls) {
999
+ const backing = ls ?? globalThis.localStorage;
1000
+ return {
1001
+ async get(key) {
1002
+ return backing.getItem(key);
1003
+ },
1004
+ async set(key, value) {
1005
+ backing.setItem(key, value);
1006
+ },
1007
+ async delete(key) {
1008
+ backing.removeItem(key);
1009
+ }
1010
+ };
1011
+ }
1012
+ function browserNetworkMonitor() {
1013
+ const hasWindow = typeof window !== "undefined" && typeof window.addEventListener === "function";
1014
+ return {
1015
+ isOnline() {
1016
+ return typeof navigator !== "undefined" && "onLine" in navigator ? navigator.onLine : true;
1017
+ },
1018
+ onChange(cb) {
1019
+ if (!hasWindow) return () => {
1020
+ };
1021
+ const onOnline = () => cb(true);
1022
+ const onOffline = () => cb(false);
1023
+ window.addEventListener("online", onOnline);
1024
+ window.addEventListener("offline", onOffline);
1025
+ return () => {
1026
+ window.removeEventListener("online", onOnline);
1027
+ window.removeEventListener("offline", onOffline);
1028
+ };
1029
+ }
1030
+ };
1031
+ }
1032
+ async function createUpload(opts) {
1033
+ const file = browserFileSource(opts.file);
1034
+ const store = opts.store ?? localStorageStore();
1035
+ const network = typeof window !== "undefined" ? browserNetworkMonitor() : void 0;
1036
+ return buildUpload({
1037
+ endpoint: opts.endpoint,
1038
+ uploadToken: opts.uploadToken,
1039
+ assetId: opts.assetId,
1040
+ file,
1041
+ transport: xhrTransport,
1042
+ store,
1043
+ hasher: pureHasher,
1044
+ network,
1045
+ concurrency: opts.concurrency ?? 4,
1046
+ checksum: opts.checksum,
1047
+ onTokenExpired: opts.onTokenExpired
1048
+ });
1049
+ }
1050
+ export {
1051
+ ApiError,
1052
+ IntegrityError,
1053
+ NetworkError,
1054
+ browserFileSource,
1055
+ browserNetworkMonitor,
1056
+ createUpload,
1057
+ createUploadSession,
1058
+ localStorageStore,
1059
+ mintUploadToken,
1060
+ xhrTransport
1061
+ };