Initial Commit
This commit is contained in:
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* 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<ChunkResponsePayload>(
|
||||
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<ChunkResponsePayload>(
|
||||
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),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user