p2p-media-loader-core
Version:
P2P Media Loader core functionality
310 lines (272 loc) • 9.48 kB
text/typescript
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<ArrayBuffer>[] = [];
#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<ArrayBuffer>[] {
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<string, unknown> = {
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<string, unknown>): 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<string, unknown>,
...fields: string[]
): void {
for (const field of fields) {
if (typeof obj[field] !== "number") {
throw new Error(
`Expected number field "${field}", got ${typeof obj[field]}`,
);
}
}
}