/** * Main-thread direct-to-S3 multipart uploader for recording streams (the * `s3` storage driver). The drop-in counterpart to {@link ChunkUploader}: * it exposes the SAME `upload(seq, blob)` / `drain()` / `stop()` surface, so * the observer wiring doesn't change — only which uploader the client * constructs, chosen by the server's advertised direct-upload support. * * Why this is NOT just "POST each chunk to S3": * * S3 multipart has hard rules the naive mapping violates. Every part * except the last must be >= 5 MB, and CompleteMultipartUpload only * concatenates the parts it's given. A MediaRecorder timeslice is way * under 5 MB, so we CANNOT make one part per timeslice. Instead we BUFFER * incoming blobs into stable parts (≥ the S3 5 MB floor) and PUT those. The server owns * the manifest + reconciles with S3 `ListParts` on finalize, so: * - parts upload strictly IN ORDER (contiguous part numbers — the server * completes only the longest gap-free prefix from part 1), * - our `ack` is best-effort (a lost ack is recovered by the server's * ListParts reconcile), * - we do NOT complete on stop()/abort — the server's finalize-on- * abandon path recovers whatever reached S3. We only `complete` on a * graceful `drain()`. * * Lifecycle: * upload(seq, blob) → append to a byte buffer. When buffered >= the part * size, cut a part + enqueue it. One part PUT in flight at a time. * first part → lazy `start` (gets mediaUploadId + uploadId). * each part → `sign` → PUT to S3 (capture ETag) → `ack`. Retry 5xx / * network with backoff; a 4xx fails the stream (no point retrying). * drain() → flush the final (possibly < 5 MB) part, await the queue, * then `complete` (server assembles the contiguous prefix → derived). * stop() → abort in-flight + clear; never completes. * * Durability (optional {@link MultipartUploaderConfig.durability}): * The in-memory buffer is volatile — a page refresh / crash discards the * un-flushed sub-part-size tail (the reported data-loss bug). When a * {@link DurableBlobStore} is injected, every incoming blob is also * persisted; on a fresh instance for the same (session, kind) — i.e. after * a refresh — we `restore()` the un-committed tail (sliced at the server's * authoritative resume point, `(nextPartNumber - 1) * partSize`) into the * front of the buffer BEFORE any live blob, and PUT it into the SAME * resumed multipart. Acked parts `commit()` (GC); a graceful `complete()` * `clear()`s the store. */ import type { DurableBlobStore } from "./durable-blob-store.js"; export interface MultipartUploaderConfig { sessionId: string; /** Recording stream this uploads — picks the asset + object key server-side. */ kind: "screen" | "webcam"; /** * The events ingest URL (eg. `http://localhost:3001/ingest`). The uploader * derives the origin from this and mounts the `/uploads/multipart/*` * endpoints on it — same origin as the events + legacy recording paths. */ ingestUrl: string; /** Public app key (`pk_live_…`) for org routing (x-app-id). */ appId?: string; /** Per-device fingerprint; threaded to the server so it can attribute the stream. */ fingerprintId?: string; /** MIME type of the recording (`video/webm` / `video/mp4`); used at start. */ mimeType?: string; /** * Target part size in bytes. Buffered blobs are cut into parts at this * threshold. Must be >= the S3 5 MB minimum, which is also the DEFAULT: * we deliberately sit at the floor so the most a hard crash can lose is the * un-flushed sub-5 MB buffer (~1–2 min at typical recording bitrates), * rather than a larger window. The final part (flushed on drain) may be * smaller — that's allowed for the last part. */ partSizeBytes?: number; /** * Max bytes held in memory (buffer + the enqueued, not-yet-PUT parts). * When exceeded the OLDEST enqueued part is dropped — which breaks * contiguity, so the server will complete only up to the gap. This is the * overflow backstop for a stalled network, mirroring ChunkUploader's cap. * Default 64 MB. */ maxBufferBytes?: number; /** Per-part retry budget for 5xx / network errors. Default 5. */ maxRetries?: number; /** * Optional durable buffer. When present, incoming blobs are persisted and * the un-committed tail is restored on a resumed (post-refresh) instance so * the pre-refresh recording isn't lost. Injected already-opened by the * caller; the uploader `commit`s on ack and `clear`s on a graceful complete * but never opens/closes it (the client owns its lifecycle + orphan sweep). */ durability?: DurableBlobStore; /** Called when a part is accepted by S3 (after a successful PUT + ack). */ onPartUploaded?: (partNumber: number, byteSize: number) => void; /** Called when the whole stream finalizes (`complete` returned). */ onCompleted?: (status: string) => void; /** Called when a part (and thus the stream's contiguity) is dropped. */ onDropped?: (partNumber: number, reason: MultipartDropReason, detail?: MultipartDropDetail) => void; } export type MultipartDropReason = "max-retries" | "rejected" | "overflow" | "aborted"; export interface MultipartDropDetail { httpStatus?: number; serverError?: string; statusText?: string; retryCount?: number; message?: string; timedOut?: boolean; timeoutMs?: number; } export declare class MultipartUploader { private readonly config; private buffer; private bufferBytes; private readonly queue; private queuedBytes; private nextPartNumber; private mediaUploadId; private startPromise; private startResolved; private inFlight; private currentAbort; private readonly lifecycleAbort; private stopped; private failed; private recordingStartedAt; private restorePromise; private restored; private highestAckedPart; constructor(config: MultipartUploaderConfig); /** * Resume path: settle the multipart start (to learn the server's part * number), then pull the un-committed tail from the durable store and * prepend it to the buffer so it uploads BEFORE any live blob. Best-effort: * on any failure we proceed with whatever's live. Draining stays gated on * this promise so live blobs never jump ahead of the restored tail. */ private restoreFromDurable; /** * Append a recorder blob. Returns immediately. Cuts and enqueues a part * whenever the buffer reaches the target part size; the actual S3 PUT * happens on the internal worker loop. */ upload(_seq: number, blob: Blob, timingHeaders?: Record): void; /** Cut as many full parts as the buffer allows, then pump the queue. */ private drainBufferIntoParts; /** * Graceful end: flush whatever's buffered as a final part (allowed to be * < 5 MB), wait for every enqueued part to reach S3, then `complete` so * the server assembles the contiguous prefix into the derived recording. * * Resolves once complete returns (or there was nothing to upload). * Rejects on timeout — the caller can then `stop()` and accept that the * server's abandon-finalize path recovers whatever reached S3. */ drain(timeoutMs?: number): Promise; private drainWithin; /** * Abort the in-flight PUT and clear everything. Does NOT complete the * multipart upload — the server's finalize-on-abandon recovers whatever * parts reached S3. Idempotent. */ stop(): void; /** Slice `bytes` off the front of the buffer into a new enqueued part. */ private cutPart; /** * Memory backstop: while the buffer + enqueued parts exceed the cap, drop * the OLDEST enqueued part. Dropping a part creates a gap, so the server * will complete only up to the part before it — we surface that as a drop. */ private enforceBufferCap; /** Pull the next part and PUT it; re-runs until the queue drains. */ private processNext; /** * Lazily start the multipart upload on the server (once), returning the * manifest handle. Concurrency-safe via a memoised promise. */ private ensureStarted; private sendPart; private complete; /** A 4xx anywhere means retrying is pointless; stop the whole stream. */ private failStream; private backoff; /** POST JSON to a server endpoint; throws HttpError on non-2xx. */ private postJson; } //# sourceMappingURL=multipart-uploader.d.ts.map