@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,150 @@
1
+ import type { Clock, FileSource, HttpTransport, StateStore, NetworkMonitor, Hasher } from './types.js';
2
+ import { type SessionRecord, type SessionStatus } from './session.js';
3
+ import { type Progress } from './events.js';
4
+ import { type HostrApi } from '../api/client.js';
5
+ export type CompleteInfo = {
6
+ assetId: string;
7
+ etag: string;
8
+ sha256?: string;
9
+ bytes: number;
10
+ };
11
+ export type EngineDeps = {
12
+ api: HostrApi;
13
+ file: FileSource;
14
+ transport: HttpTransport;
15
+ store: StateStore;
16
+ hasher: Hasher;
17
+ clock: Clock;
18
+ network?: NetworkMonitor;
19
+ rng?: () => number;
20
+ };
21
+ export type EngineOptions = {
22
+ concurrency: number;
23
+ checksum: 'sha256' | 'none';
24
+ presignBatch: number;
25
+ presignTtlSec: number;
26
+ onTokenExpired?: (assetId: string) => Promise<string>;
27
+ };
28
+ /** Advisory signal that every part PUT is dying at the network layer while
29
+ * zero bytes have EVER been confirmed for the session. To XHR a CORS-blocked
30
+ * PUT is indistinguishable from network loss, so without this the two cases
31
+ * present identically (running↔stalled forever). Zero-ever-confirmed is the
32
+ * discriminator from mid-upload connectivity loss; a genuinely-offline client
33
+ * usually never gets here at all (API calls stall first, or a NetworkMonitor
34
+ * parks dispatch before any PUT is attempted). Never terminal — a long
35
+ * offline stretch from byte zero looks the same and must still recover — the
36
+ * listener decides what to do with it. */
37
+ export type TransportDiagnostic = {
38
+ code: 'systemic-transport-failure';
39
+ consecutiveNetworkFailures: number;
40
+ bytesEverConfirmed: number;
41
+ hint: string;
42
+ };
43
+ /** Consecutive network-class part-PUT failures (with zero bytes ever
44
+ * confirmed) before a 'diagnostic' event fires. */
45
+ export declare const SYSTEMIC_TRANSPORT_FAILURE_THRESHOLD = 3;
46
+ export type EngineEvents = {
47
+ progress: Progress;
48
+ statechange: SessionStatus;
49
+ error: Error;
50
+ complete: CompleteInfo;
51
+ diagnostic: TransportDiagnostic;
52
+ };
53
+ export declare class IntegrityError extends Error {
54
+ constructor(message?: string);
55
+ }
56
+ export declare class UploadEngine {
57
+ readonly done: Promise<CompleteInfo>;
58
+ private resolveDone;
59
+ private rejectDone;
60
+ private emitter;
61
+ private presigned;
62
+ /** Presign fetches since the last confirmed part. Healthy uploads confirm
63
+ * constantly, so this stays at zero and costs nothing; in a sustained
64
+ * failure loop (every PUT dying without landing a part) it grows, and
65
+ * presign() spaces further /parts fetches on the same backoff curve the
66
+ * 'expired' storm uses — the API call rate is what trips rate limits, so
67
+ * it gets its own governor, independent of per-part attempt cadence and
68
+ * worker count. */
69
+ private presignsSinceConfirm;
70
+ /** The in-flight /parts fetch, shared so concurrent cache-missing workers
71
+ * ride one round-trip and re-check the cache instead of each firing an
72
+ * overlapping batch of their own (same memo shape as hashLanePromise). */
73
+ private presignInflight;
74
+ private claimed;
75
+ private inflightSent;
76
+ private stopped;
77
+ private running;
78
+ private restartPending;
79
+ private startedAt;
80
+ private confirmedAtStart;
81
+ private lastEmit;
82
+ private deps;
83
+ private opts;
84
+ private session;
85
+ /** The in-flight sha256 lane, tracked so a pause→resume never starts a
86
+ * second concurrent lane over the same file. Cleared once settled so a
87
+ * later resume can start a fresh one from the persisted midstate. */
88
+ private hashLanePromise;
89
+ /** Engine-wide (not per-part): a CORS-blocked bucket fails every worker's
90
+ * PUT the same way, so the streak accumulates across parts. Deliberately
91
+ * not bumped by apiCall's network stalls — the API answering while every
92
+ * R2 PUT dies is exactly the bucket-CORS signature. */
93
+ private consecutiveNetworkFailures;
94
+ private diagnosticFired;
95
+ /** Resolvers for anyone currently parked in waitOnline(), woken the
96
+ * instant pause()/abort()/fail() halts the engine — without this a
97
+ * stalled engine could only be interrupted by the network itself
98
+ * coming back online. */
99
+ private stopWaiters;
100
+ constructor(deps: EngineDeps, opts: EngineOptions, session: SessionRecord);
101
+ on<K extends keyof EngineEvents>(ev: K, cb: (v: EngineEvents[K]) => void): () => void;
102
+ get state(): SessionStatus;
103
+ start(): void;
104
+ pause(): void;
105
+ resume(): void;
106
+ abort(): Promise<void>;
107
+ /** Stop dispatch everywhere at once: new part claims, waitOnline's
108
+ * parked callers, and apiCall's network-retry loop all observe this on
109
+ * their next check. */
110
+ private halt;
111
+ private setStatus;
112
+ private fail;
113
+ private run;
114
+ private finish;
115
+ private expectedComposite;
116
+ /** Reconcile local bookkeeping against what R2 actually holds before resuming.
117
+ * Local claims R2 can't confirm are stale and forgotten; parts whose stored
118
+ * md5 matches R2's etag are kept as-is; a mismatch means the landed bytes
119
+ * aren't verified as ours, so it's dropped for re-upload. For parts R2 holds
120
+ * but local `done` has no record of (state lost — reinstall, cleared
121
+ * storage), the local byte range is re-read and re-hashed; it's only
122
+ * adopted as done when that hash matches R2's etag, never trusted blind. */
123
+ private reconcile;
124
+ private nextPart;
125
+ private worker;
126
+ private uploadPart;
127
+ private presign;
128
+ /** Fresh = safely usable for a new PUT: at least 60s of validity left, so
129
+ * a part starting now won't have its URL expire mid-flight. */
130
+ private presignFresh;
131
+ /** API calls: infinite patience for network loss, one token refresh on 401, else throw.
132
+ * Throws Stopped (never surfaced) if pause()/abort() interrupts a stuck retry. */
133
+ private apiCall;
134
+ private waitOnline;
135
+ /** Lazily starts the sha256 lane and remembers the in-flight promise so a
136
+ * fast pause→resume can never spawn a second concurrent lane over the
137
+ * same file. The promise carries its own no-op .catch so it can never
138
+ * become an unhandledRejection while nothing is actively awaiting it
139
+ * (e.g. during a paused window); a real failure still surfaces normally
140
+ * to whoever does await it, and from there through run() to fail(). */
141
+ private getHashLane;
142
+ private hashLane;
143
+ /** Feed every part-PUT outcome to the systemic-transport-failure detector.
144
+ * Any outcome that isn't network-class — success, 403, 5xx, even corrupt
145
+ * bytes — proves a request reached the server, so the streak resets. */
146
+ private notePutOutcome;
147
+ private bytesConfirmed;
148
+ private onPartProgress;
149
+ private emitProgress;
150
+ }
@@ -0,0 +1,33 @@
1
+ /** Minimal typed pub/sub. A throwing listener is isolated: it never
2
+ * prevents emit from reaching later listeners, and never propagates to
3
+ * the caller of emit. */
4
+ export declare class Emitter<E extends Record<string, unknown>> {
5
+ private listeners;
6
+ on<K extends keyof E>(ev: K, cb: (v: E[K]) => void): () => void;
7
+ emit<K extends keyof E>(ev: K, v: E[K]): void;
8
+ }
9
+ export type Progress = {
10
+ bytesSent: number;
11
+ bytesConfirmed: number;
12
+ totalBytes: number;
13
+ throughputBps: number | null;
14
+ etaSec: number | null;
15
+ partsDone: number;
16
+ partsTotal: number;
17
+ /** Optional passthrough for a per-part status grid (Task 23's playground).
18
+ * computeProgress does not populate this itself; the engine will. */
19
+ partStates?: Record<number, 'inflight' | 'done'>;
20
+ };
21
+ export declare function computeProgress(i: {
22
+ confirmed: number;
23
+ inflight: number;
24
+ total: number;
25
+ partsDone: number;
26
+ partsTotal: number;
27
+ startedAtMs: number;
28
+ nowMs: number;
29
+ /** Bytes confirmed when this run began (not a timestamp, despite the
30
+ * similar-looking field names elsewhere — this is a byte count). */
31
+ confirmedAtStart: number;
32
+ partStates?: Record<number, 'inflight' | 'done'>;
33
+ }): Progress;
@@ -0,0 +1,20 @@
1
+ import type { PutResult } from './types.js';
2
+ /**
3
+ * Design rationale: network-class errors never exhaust a budget —
4
+ * connectivity loss stalls patiently, since it says nothing about whether
5
+ * the server-side operation was valid. Finite retry budgets exist only for
6
+ * unexpected server responses (5xx, 429) where retrying blindly could be
7
+ * futile or harmful. 'expired' and 'fatal' are not retried at all, just
8
+ * surfaced for the caller to recover (re-presign) or abort.
9
+ */
10
+ export type ErrorClass = 'network' | 'transient' | 'expired' | 'corrupt' | 'fatal';
11
+ export declare const RETRY_BUDGET = 6;
12
+ /** Classify the outcome of a PUT (e.g. an R2/S3 presigned upload). */
13
+ export declare function classifyPut(res: PutResult | null, err: unknown): ErrorClass;
14
+ /** Classify the outcome of an API call (create/complete/token endpoints). */
15
+ export declare function classifyApi(err: unknown): ErrorClass;
16
+ /**
17
+ * Exponential backoff with jitter: min(cap, 500 * 2^(attempt-1) * (1 + 0.5*rng())).
18
+ * `rng` defaults to Math.random; `capMs` defaults to 60000.
19
+ */
20
+ export declare function backoffMs(attempt: number, rng?: () => number, capMs?: number): number;
@@ -0,0 +1,28 @@
1
+ import type { StateStore } from './types.js';
2
+ export type SessionStatus = 'idle' | 'running' | 'stalled' | 'paused' | 'completing' | 'complete' | 'failed' | 'aborted';
3
+ export type SessionRecord = {
4
+ v: 1;
5
+ assetId: string;
6
+ endpoint: string;
7
+ uploadToken: string;
8
+ key: string;
9
+ partSize: number;
10
+ expectedParts: number;
11
+ declaredSize: number;
12
+ fileId: string;
13
+ done: Record<number, string>;
14
+ hash?: {
15
+ offset: number;
16
+ state: string;
17
+ };
18
+ sha256?: string;
19
+ status: SessionStatus;
20
+ createdAt: string;
21
+ };
22
+ export declare const storageKey: (fileId: string) => string;
23
+ export declare function loadSession(store: StateStore, fileId: string): Promise<SessionRecord | null>;
24
+ export declare function saveSession(store: StateStore, s: SessionRecord): Promise<void>;
25
+ export declare function clearSession(store: StateStore, fileId: string): Promise<void>;
26
+ /** partSize except a short final part. */
27
+ export declare function partSizeOf(s: SessionRecord, n: number): number;
28
+ export declare function memoryStore(): StateStore;
@@ -0,0 +1,44 @@
1
+ /** Adapter seams. Everything platform-specific enters the engine through these. */
2
+ export interface FileSource {
3
+ size: number;
4
+ /** Stable identity across relaunches: name:size:mtime (browser) or path:size:mtime. */
5
+ id: string;
6
+ read(offset: number, length: number): Promise<Uint8Array>;
7
+ close?(): Promise<void>;
8
+ }
9
+ export type PutResult = {
10
+ status: number;
11
+ etag: string | null;
12
+ };
13
+ export interface HttpTransport {
14
+ put(url: string, body: Uint8Array, opts?: {
15
+ onProgress?: (sentBytes: number) => void;
16
+ signal?: AbortSignal;
17
+ }): Promise<PutResult>;
18
+ }
19
+ /** Persisted across kill/relaunch. Values are opaque strings (JSON). */
20
+ export interface StateStore {
21
+ get(key: string): Promise<string | null>;
22
+ set(key: string, value: string): Promise<void>;
23
+ delete(key: string): Promise<void>;
24
+ }
25
+ export interface NetworkMonitor {
26
+ isOnline(): boolean;
27
+ onChange(cb: (online: boolean) => void): () => void;
28
+ }
29
+ export interface IncrementalHash {
30
+ update(data: Uint8Array): void;
31
+ digestHex(): string;
32
+ digestBytes(): Uint8Array;
33
+ exportState?(): string;
34
+ importState?(state: string): void;
35
+ }
36
+ /** Per-part hashing seam. Node swaps in node:crypto MD5; the SHA-256 lane
37
+ * always uses the pure implementation for portable midstate. */
38
+ export interface Hasher {
39
+ md5(): IncrementalHash;
40
+ }
41
+ export interface Clock {
42
+ now(): number;
43
+ sleep(ms: number): Promise<void>;
44
+ }
@@ -0,0 +1,32 @@
1
+ import type { Clock, FileSource, HttpTransport, StateStore, Hasher, NetworkMonitor } from './types.js';
2
+ import { UploadEngine } from './engine.js';
3
+ export type UploadOptions = {
4
+ endpoint: string;
5
+ uploadToken: string;
6
+ assetId: string;
7
+ file: FileSource;
8
+ transport: HttpTransport;
9
+ store: StateStore;
10
+ hasher: Hasher;
11
+ network?: NetworkMonitor;
12
+ clock?: Clock;
13
+ fetchFn?: typeof fetch;
14
+ concurrency?: number;
15
+ checksum?: 'sha256' | 'none';
16
+ presignTtlSec?: number;
17
+ onTokenExpired?: (assetId: string) => Promise<string>;
18
+ };
19
+ export type Upload = Pick<UploadEngine, 'done' | 'state' | 'on' | 'start' | 'pause' | 'resume' | 'abort'>;
20
+ /** Resolves (or creates) the persisted session for `file.id`, wires an
21
+ * `HostrApi` and `UploadEngine` around it, and returns the engine as the
22
+ * public `Upload` surface. A session already saved for this file wins over
23
+ * `opts.assetId` — the caller's backend may have minted a fresh upload
24
+ * without knowing one was already in flight — but `opts.uploadToken` is
25
+ * always adopted, since the saved token may be stale. */
26
+ export declare function buildUpload(opts: UploadOptions): Promise<Upload>;
27
+ /** Lets an app skip minting a fresh upload token when one is already in
28
+ * flight for this file — check first, then only call the backend if this
29
+ * returns null. */
30
+ export declare function findResumable(store: StateStore, fileId: string): Promise<{
31
+ assetId: string;
32
+ } | null>;
@@ -0,0 +1,2 @@
1
+ /** R2's multipart object ETag: MD5 of the concatenated raw part digests, dash, part count. */
2
+ export declare function compositeEtag(md5Hexes: string[]): string;
@@ -0,0 +1,2 @@
1
+ import type { Hasher } from '../core/types.js';
2
+ export declare const pureHasher: Hasher;
@@ -0,0 +1,2 @@
1
+ export declare function bytesToHex(b: Uint8Array): string;
2
+ export declare function hexToBytes(s: string): Uint8Array;
@@ -0,0 +1,24 @@
1
+ import type { IncrementalHash } from '../core/types.js';
2
+ export declare class Md5 implements IncrementalHash {
3
+ private h;
4
+ private buf;
5
+ private bufLen;
6
+ private len;
7
+ update(data: Uint8Array): void;
8
+ private compress;
9
+ /**
10
+ * Finalize on a clone so the hash can keep accepting updates.
11
+ *
12
+ * Padding: capture the message length in bits BEFORE touching the clone,
13
+ * then build the entire 0x80 + zero-padding + 64-bit length tail as one
14
+ * buffer sized so the clone's running byte count lands on a block
15
+ * boundary, and hand it to update() in a single call. update()'s own
16
+ * `len` bookkeeping on the clone is irrelevant after this point (the
17
+ * clone is discarded), so there is no need to keep it meaningful.
18
+ */
19
+ digestBytes(): Uint8Array;
20
+ digestHex(): string;
21
+ exportState(): string;
22
+ importState(state: string): void;
23
+ private clone;
24
+ }
@@ -0,0 +1,25 @@
1
+ import type { IncrementalHash } from '../core/types.js';
2
+ export declare class Sha256 implements IncrementalHash {
3
+ private h;
4
+ private buf;
5
+ private bufLen;
6
+ private len;
7
+ private w;
8
+ update(data: Uint8Array): void;
9
+ private compress;
10
+ /**
11
+ * Finalize on a clone so the hash can keep accepting updates.
12
+ *
13
+ * Padding: capture the message length in bits BEFORE touching the clone,
14
+ * then build the entire 0x80 + zero-padding + 64-bit length tail as one
15
+ * buffer sized so the clone's running byte count lands on a block
16
+ * boundary, and hand it to update() in a single call. update()'s own
17
+ * `len` bookkeeping on the clone is irrelevant after this point (the
18
+ * clone is discarded), so there is no need to keep it meaningful.
19
+ */
20
+ digestBytes(): Uint8Array;
21
+ digestHex(): string;
22
+ exportState(): string;
23
+ importState(state: string): void;
24
+ private clone;
25
+ }
@@ -0,0 +1,13 @@
1
+ export type * from './core/types.js';
2
+ export { ApiError, NetworkError, HostrApi, createUploadSession, mintUploadToken, } from './api/client.js';
3
+ export type { RemotePart, RemoteUpload, CreateResult, CompleteResult } from './api/client.js';
4
+ export { storageKey, loadSession, saveSession, clearSession, partSizeOf, memoryStore, } from './core/session.js';
5
+ export type { SessionStatus, SessionRecord } from './core/session.js';
6
+ export { classifyPut, classifyApi, backoffMs, RETRY_BUDGET } from './core/retry.js';
7
+ export type { ErrorClass } from './core/retry.js';
8
+ export { Emitter, computeProgress } from './core/events.js';
9
+ export type { Progress } from './core/events.js';
10
+ export { UploadEngine, IntegrityError, SYSTEMIC_TRANSPORT_FAILURE_THRESHOLD } from './core/engine.js';
11
+ export type { CompleteInfo, EngineDeps, EngineOptions, EngineEvents, TransportDiagnostic } from './core/engine.js';
12
+ export { buildUpload, findResumable } from './core/upload.js';
13
+ export type { UploadOptions, Upload } from './core/upload.js';