/** * Codec for `cd:response` envelopes (DMP-11, DMP-12, DMP-21). * * Response payloads carry an array of `ChunkBytes` ordered by `chunkId` * ascending plus a piggyback presence bitset for the responder's full * inventory. Encoder and decoder both reject out-of-order chunks with * `DMP.UNORDERED_RESPONSE`. Both fields cross JSON as base64 strings; * the presence bitset additionally goes through the * `bitpacker-compressed` framing magic check (DMP-21). */ import { DMP_CODES, DmpError } from "../error-codes.js"; import { ChunkResponsePayloadSchema } from "../schemas/envelope.schema.js"; import type { ChunkBytes, ChunkResponsePayload, } from "../types/protocol.types.js"; import { decodeBitset } from "./bitset.js"; import { fromBase64, parseStrict, toBase64 } from "./common.js"; interface WireChunk { readonly chunkId: number; readonly bytes: { readonly _b: string }; } interface WirePayload { readonly fileId: string; readonly chunks: readonly WireChunk[]; readonly presence: { readonly _b: string }; readonly generation: number; } export function encodeResponsePayload( payload: ChunkResponsePayload ): Uint8Array { const validated = parseStrict( ChunkResponsePayloadSchema, payload, DMP_CODES.ENVELOPE_MALFORMED, "response payload" ); assertOrdered(validated.chunks); // DMP-21: presence MUST already be bitpacker-compressed-framed. decodeBitset(validated.presence); const wire: WirePayload = { fileId: validated.fileId, chunks: validated.chunks.map((c) => ({ chunkId: c.chunkId, bytes: { _b: toBase64(c.bytes) }, })), presence: { _b: toBase64(validated.presence) }, generation: validated.generation, }; return new TextEncoder().encode(JSON.stringify(wire)); } export function decodeResponsePayload(bytes: Uint8Array): ChunkResponsePayload { let parsed: unknown; try { parsed = JSON.parse(new TextDecoder().decode(bytes)); } catch (err) { throw new DmpError( DMP_CODES.ENVELOPE_MALFORMED, `response payload is not valid JSON: ${String(err)}` ); } const validated = parseStrict( ChunkResponsePayloadSchema, unwrapWire(parsed), DMP_CODES.ENVELOPE_MALFORMED, "response payload" ); assertOrdered(validated.chunks); decodeBitset(validated.presence); return validated; } function assertOrdered(chunks: readonly ChunkBytes[]): void { for (let i = 1; i < chunks.length; i++) { const prev = chunks[i - 1] as ChunkBytes; const cur = chunks[i] as ChunkBytes; if (cur.chunkId <= prev.chunkId) { throw new DmpError( DMP_CODES.UNORDERED_RESPONSE, `chunk ${cur.chunkId} appears at index ${i} after ${prev.chunkId} — chunks must be strictly ascending` ); } } } function unwrapWire(parsed: unknown): unknown { if ( parsed === null || typeof parsed !== "object" || !("chunks" in parsed) || !("presence" in parsed) ) { return parsed; } const wire = parsed as WirePayload; return { ...wire, chunks: wire.chunks.map((c) => ({ chunkId: c.chunkId, bytes: fromBase64(c.bytes._b), })), presence: fromBase64(wire.presence._b), }; }