import * as Serialization from "./binary-serialization.js"; import { PeerCommandType, PeerCommand, PeerSegmentCommand, PeerRequestSegmentCommand, PeerSegmentAnnouncementCommand, PeerSendSegmentCommand, } from "./types.js"; const FRAME_PART_LENGTH = 4; const commandFrameStart = stringToUtf8CodesBuffer("cstr", FRAME_PART_LENGTH); const commandFrameEnd = stringToUtf8CodesBuffer("cend", FRAME_PART_LENGTH); const commandDivFrameStart = stringToUtf8CodesBuffer("dstr", FRAME_PART_LENGTH); const commandDivFrameEnd = stringToUtf8CodesBuffer("dend", FRAME_PART_LENGTH); const startFrames = [commandFrameStart, commandDivFrameStart]; const endFrames = [commandFrameEnd, commandDivFrameEnd]; const commandFramesLength = commandFrameStart.length + commandFrameEnd.length; export function isCommandChunk(buffer: Uint8Array) { if (buffer.length < commandFramesLength) return false; const { length } = commandFrameStart; const bufferEndingToCompare = buffer.subarray(-length); return ( startFrames.some((frame) => areBuffersEqual(buffer, frame, FRAME_PART_LENGTH), ) && endFrames.some((frame) => areBuffersEqual(bufferEndingToCompare, frame, FRAME_PART_LENGTH), ) ); } function isFirstCommandChunk(buffer: Uint8Array) { if (buffer.length < commandFramesLength) return false; return areBuffersEqual(buffer, commandFrameStart, FRAME_PART_LENGTH); } function isLastCommandChunk(buffer: Uint8Array) { if (buffer.length < commandFramesLength) return false; return areBuffersEqual( buffer.subarray(-FRAME_PART_LENGTH), commandFrameEnd, FRAME_PART_LENGTH, ); } export class BinaryCommandJoiningError extends Error { constructor(readonly type: "incomplete-joining" | "no-first-chunk") { super(); } } export class BinaryCommandChunksJoiner { readonly #chunks = new Serialization.ResizableUint8Array(); #status: "joining" | "completed" = "joining"; readonly #onComplete: (commandBuffer: Uint8Array) => void; constructor(onComplete: (commandBuffer: Uint8Array) => void) { this.#onComplete = onComplete; } addCommandChunk(chunk: Uint8Array) { if (this.#status === "completed") return; const isFirstChunk = isFirstCommandChunk(chunk); if (!this.#chunks.length && !isFirstChunk) { throw new BinaryCommandJoiningError("no-first-chunk"); } if (this.#chunks.length && isFirstChunk) { throw new BinaryCommandJoiningError("incomplete-joining"); } this.#chunks.push(this.#unframeCommandChunk(chunk)); if (!isLastCommandChunk(chunk)) return; this.#status = "completed"; this.#onComplete(this.#chunks.getBuffer()); } #unframeCommandChunk(chunk: Uint8Array) { if (chunk.length < commandFramesLength) { throw new Error("Command chunk is too short to unframe"); } return chunk.subarray(FRAME_PART_LENGTH, chunk.length - FRAME_PART_LENGTH); } } export class BinaryCommandCreator { readonly #bytes = new Serialization.ResizableUint8Array(); // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-arguments #resultBuffers: Uint8Array[] = []; #status: "creating" | "completed" = "creating"; readonly #maxChunkLength: number; constructor(commandType: PeerCommandType, maxChunkLength: number) { this.#maxChunkLength = maxChunkLength; this.#bytes.push(commandType); } addInteger(name: string, value: number) { this.#bytes.push(name.charCodeAt(0)); const bytes = Serialization.serializeInt(value); this.#bytes.push(bytes); } addUniqueSimilarIntArr(name: string, arr: number[]) { this.#bytes.push(name.charCodeAt(0)); const bytes = Serialization.serializeUniqueSimilarIntArray(arr); this.#bytes.push(bytes); } addString(name: string, string: string) { this.#bytes.push(name.charCodeAt(0)); const bytes = Serialization.serializeString(string); this.#bytes.push(bytes); } complete() { if (!this.#bytes.length) throw new Error("Buffer is empty"); if (this.#status === "completed") return; this.#status = "completed"; const unframedBuffer = this.#bytes.getBuffer(); if (unframedBuffer.length + commandFramesLength <= this.#maxChunkLength) { this.#resultBuffers.push( frameBuffer(unframedBuffer, commandFrameStart, commandFrameEnd), ); return; } let chunksCount = Math.ceil(unframedBuffer.length / this.#maxChunkLength); if ( Math.ceil(unframedBuffer.length / chunksCount) + commandFramesLength > this.#maxChunkLength ) { chunksCount++; } for (const [i, chunk] of splitBufferToEqualChunks( unframedBuffer, chunksCount, )) { if (i === 0) { this.#resultBuffers.push( frameBuffer(chunk, commandFrameStart, commandDivFrameEnd), ); } else if (i === chunksCount - 1) { this.#resultBuffers.push( frameBuffer(chunk, commandDivFrameStart, commandFrameEnd), ); } else { this.#resultBuffers.push( frameBuffer(chunk, commandDivFrameStart, commandDivFrameEnd), ); } } } // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-arguments getResultBuffers(): Uint8Array[] { if (this.#status === "creating" || !this.#resultBuffers.length) { throw new Error("Command is not complete."); } return this.#resultBuffers; } } export function deserializeCommand(bytes: Uint8Array): PeerCommand { const [commandCode] = bytes; const deserializedCommand: Record = { c: commandCode, }; let offset = 1; while (offset < bytes.length) { if (offset + 1 >= bytes.length) { throw new Error("Malformed command buffer: truncated name/type header"); } const name = String.fromCharCode(bytes[offset]); offset++; const dataType = getDataTypeFromByte(bytes[offset]); switch (dataType) { case Serialization.SerializedItem.Int: { const { number, byteLength } = Serialization.deserializeInt( bytes.subarray(offset), ); deserializedCommand[name] = number; offset += byteLength; } break; case Serialization.SerializedItem.SimilarIntArray: { const { numbers, byteLength } = Serialization.deserializeUniqueSimilarIntArray( bytes.subarray(offset), ); deserializedCommand[name] = numbers; offset += byteLength; } break; case Serialization.SerializedItem.String: { const { string, byteLength } = Serialization.deserializeString( bytes.subarray(offset), ); deserializedCommand[name] = string; offset += byteLength; } break; } } return validateCommand(deserializedCommand); } function getDataTypeFromByte(byte: number): Serialization.SerializedItem { const typeCode: Serialization.SerializedItem = byte >> 4; if ( typeCode <= Serialization.SerializedItem.Min || typeCode >= Serialization.SerializedItem.Max ) { throw new Error("Not existing type"); } return typeCode; } function stringToUtf8CodesBuffer(string: string, length?: number): Uint8Array { if (length && string.length !== length) { throw new Error("Wrong string length"); } const buffer = new Uint8Array(length ?? string.length); for (let i = 0; i < string.length; i++) buffer[i] = string.charCodeAt(i); return buffer; } function* splitBufferToEqualChunks( buffer: Uint8Array, chunksCount: number, ): Generator<[number, Uint8Array], void> { const chunkLength = Math.ceil(buffer.length / chunksCount); for (let i = 0; i < chunksCount; i++) { yield [i, buffer.subarray(i * chunkLength, (i + 1) * chunkLength)]; } } function frameBuffer( buffer: Uint8Array, frameStart: Uint8Array, frameEnd: Uint8Array, ) { const result = new Uint8Array( buffer.length + frameStart.length + frameEnd.length, ); result.set(frameStart); result.set(buffer, frameStart.length); result.set(frameEnd, frameStart.length + buffer.length); return result; } function areBuffersEqual( buffer1: Uint8Array, buffer2: Uint8Array, length: number, ) { for (let i = 0; i < length; i++) { if (buffer1[i] !== buffer2[i]) return false; } return true; } function validateCommand(command: Record): PeerCommand { switch (command.c) { case PeerCommandType.SegmentsAnnouncement: return command as unknown as PeerSegmentAnnouncementCommand; case PeerCommandType.SegmentRequest: assertNumberFields(command, "i", "r"); return command as unknown as PeerRequestSegmentCommand; case PeerCommandType.SegmentData: assertNumberFields(command, "i", "r", "s"); return command as unknown as PeerSendSegmentCommand; case PeerCommandType.SegmentAbsent: case PeerCommandType.CancelSegmentRequest: case PeerCommandType.SegmentDataSendingCompleted: assertNumberFields(command, "i", "r"); return command as unknown as PeerSegmentCommand; default: throw new Error(`Unknown peer command type: ${String(command.c)}`); } } function assertNumberFields( obj: Record, ...fields: string[] ): void { for (const field of fields) { if (typeof obj[field] !== "number") { throw new Error( `Expected number field "${field}", got ${typeof obj[field]}`, ); } } }