import type { WorkerCryptoProvider, WorkerStreamReceiver, WorkerStreamSender, } from './worker-client.js'; /** Default plaintext chunk size — 256 KiB. Matches `@shade/transfer`. */ export const DEFAULT_STREAM_CHUNK_SIZE = 256 * 1024; export interface CreateEncryptStreamOptions { provider: WorkerCryptoProvider; streamId: Uint8Array; streamSecret: Uint8Array; laneId?: number; /** * Plaintext bytes per AEAD chunk. Smaller = lower latency per chunk + * more postMessage overhead; larger = higher per-chunk RAM in the * worker. Default 256 KiB. */ chunkSize?: number; /** * First sequence number this sender will emit. Default 0. * Use for resume. */ startSeq?: number; /** First seq this receiver will accept; defaults to 0. */ signal?: AbortSignal; } export interface CreateDecryptStreamOptions { provider: WorkerCryptoProvider; streamId: Uint8Array; streamSecret: Uint8Array; laneId?: number; startSeq?: number; signal?: AbortSignal; } /** * Build a `TransformStream` that encrypts every * passing byte as a stream-chunk wire envelope. The actual AEAD work * happens in the worker — the main thread only buffers, slices, and * forwards. * * Output: one wire chunk per `enqueue`. Concatenation is the responsibility * of the downstream consumer (typically an HTTP-shipping `TransformStream`). */ export function createEncryptStream(opts: CreateEncryptStreamOptions): { stream: TransformStream; /** Promise that resolves to the final lane sha256 once the stream finishes. */ laneSha256: Promise; } { const chunkSize = opts.chunkSize ?? DEFAULT_STREAM_CHUNK_SIZE; if (chunkSize <= 0) throw new Error('chunkSize must be positive'); // Plaintext slices accumulate here until we have at least `chunkSize` // bytes (so we emit fixed-size chunks except for the very last one). let pending: Uint8Array = new Uint8Array(0); let sender: WorkerStreamSender | null = null; let resolveLaneSha: (b: Uint8Array) => void; let rejectLaneSha: (e: Error) => void; const laneSha256 = new Promise((res, rej) => { resolveLaneSha = res; rejectLaneSha = rej; }); // Cast to `Transformer` because some TS lib versions still ship // the pre-2023 shape without `cancel`. Runtime supports it (Bun, all // modern browsers). const transformer = { async start(): Promise { sender = await opts.provider.createStreamSender({ streamId: opts.streamId, streamSecret: opts.streamSecret, laneId: opts.laneId ?? 0, startSeq: opts.startSeq ?? 0, }); }, async transform( chunk: Uint8Array, controller: TransformStreamDefaultController, ): Promise { if (sender === null) throw new Error('encryptStream: sender not initialized'); if (chunk.byteLength === 0) return; pending = concat(pending, chunk); // Emit complete chunks. Hold back the trailing partial — we don't // know yet whether it's the last one (which gets isLast=true). while (pending.byteLength >= chunkSize) { const slice = pending.subarray(0, chunkSize); const rest = pending.subarray(chunkSize); const out = await sender.encryptChunk(slice, false); controller.enqueue(out.bytes); // Detach `rest` from the larger backing buffer so it can be GCed. pending = new Uint8Array(rest); } }, async flush(controller: TransformStreamDefaultController): Promise { if (sender === null) throw new Error('encryptStream: sender not initialized'); try { // Always emit a final chunk with isLast=true. Even if `pending` // is empty: receivers rely on a trailing isLast envelope to // mark stream completion. const out = await sender.encryptChunk(pending, true); controller.enqueue(out.bytes); pending = new Uint8Array(0); const sha = await sender.getLaneSha256(); resolveLaneSha(sha); } catch (err) { rejectLaneSha(err instanceof Error ? err : new Error(String(err))); throw err; } finally { await sender.destroy(); sender = null; } }, async cancel(reason: unknown): Promise { try { if (sender !== null) await sender.destroy(); } finally { sender = null; rejectLaneSha(reason instanceof Error ? reason : new Error(String(reason))); } }, }; const stream = new TransformStream( transformer as unknown as Transformer, ); if (opts.signal) { const abort = (): void => { stream.writable.abort(opts.signal!.reason).catch(() => {}); }; if (opts.signal.aborted) abort(); else opts.signal.addEventListener('abort', abort, { once: true }); } return { stream, laneSha256 }; } /** * Build a `TransformStream` that decrypts wire * stream-chunk envelopes back into plaintext. The input chunks must be * complete envelopes — the caller is responsible for framing on the wire * (one envelope per write). */ export function createDecryptStream(opts: CreateDecryptStreamOptions): { stream: TransformStream; /** Promise that resolves to the final lane sha256 once decryption finishes. */ laneSha256: Promise; } { let receiver: WorkerStreamReceiver | null = null; let resolveLaneSha: (b: Uint8Array) => void; let rejectLaneSha: (e: Error) => void; const laneSha256 = new Promise((res, rej) => { resolveLaneSha = res; rejectLaneSha = rej; }); const transformer = { async start(): Promise { receiver = await opts.provider.createStreamReceiver({ streamId: opts.streamId, streamSecret: opts.streamSecret, laneId: opts.laneId ?? 0, startSeq: opts.startSeq ?? 0, }); }, async transform( chunk: Uint8Array, controller: TransformStreamDefaultController, ): Promise { if (receiver === null) throw new Error('decryptStream: receiver not initialized'); const dec = await receiver.decryptChunk(chunk); if (dec.plaintext.byteLength > 0) controller.enqueue(dec.plaintext); if (dec.isLast) { const sha = await receiver.getLaneSha256(); resolveLaneSha(sha); } }, async flush(): Promise { if (receiver !== null) await receiver.destroy(); receiver = null; }, async cancel(reason: unknown): Promise { try { if (receiver !== null) await receiver.destroy(); } finally { receiver = null; rejectLaneSha(reason instanceof Error ? reason : new Error(String(reason))); } }, }; const stream = new TransformStream( transformer as unknown as Transformer, ); if (opts.signal) { const abort = (): void => { stream.writable.abort(opts.signal!.reason).catch(() => {}); }; if (opts.signal.aborted) abort(); else opts.signal.addEventListener('abort', abort, { once: true }); } return { stream, laneSha256 }; } function concat(a: Uint8Array, b: Uint8Array): Uint8Array { if (a.byteLength === 0) return b; if (b.byteLength === 0) return a; const out = new Uint8Array(a.byteLength + b.byteLength); out.set(a, 0); out.set(b, a.byteLength); return out; }