p2p-media-loader-core
Version:
P2P Media Loader core functionality
380 lines (331 loc) • 11.6 kB
text/typescript
import { PeerConnection } from "bittorrent-tracker";
import debug from "debug";
import { Request, RequestControls } from "../requests/request.js";
import {
CoreEventMap,
PeerRequestErrorType,
RequestError,
RequestAbortErrorType,
SegmentWithStream,
StreamType,
} from "../types.js";
import * as Utils from "../utils/utils.js";
import * as Command from "./commands/index.js";
import { PeerProtocol, PeerConfig } from "./peer-protocol.js";
import { EventTarget } from "../utils/event-target.js";
const { PeerCommandType } = Command;
type PeerEventHandlers = {
onPeerClosed: (peer: Peer) => void;
onSegmentRequested: (
peer: Peer,
segmentId: number,
requestId: number,
byteFrom?: number,
) => void;
onSegmentsAnnouncement: () => void;
};
export class Peer {
readonly id: string;
private readonly peerProtocol;
private downloadingContext?: {
request: Request;
controls: RequestControls;
isSegmentDataCommandReceived: boolean;
requestId: number;
};
private loadedSegments = new Set<number>();
private httpLoadingSegments = new Set<number>();
private downloadingErrors: RequestError<
PeerRequestErrorType | RequestAbortErrorType
>[] = [];
private logger = debug("p2pml-core:peer");
private readonly onPeerClosed: CoreEventMap["onPeerClose"];
constructor(
private readonly connection: PeerConnection,
private readonly eventHandlers: PeerEventHandlers,
private readonly peerConfig: PeerConfig,
private readonly streamType: StreamType,
private readonly eventTarget: EventTarget<CoreEventMap>,
) {
this.onPeerClosed = eventTarget.getEventDispatcher("onPeerClose");
this.id = Peer.getPeerIdFromConnection(connection);
this.peerProtocol = new PeerProtocol(
connection,
peerConfig,
{
onSegmentChunkReceived: this.onSegmentChunkReceived,
// eslint-disable-next-line @typescript-eslint/no-misused-promises
onCommandReceived: this.onCommandReceived,
},
eventTarget,
);
eventTarget.getEventDispatcher("onPeerConnect")({
peerId: this.id,
streamType,
});
connection.on("error", this.onConnectionError);
connection.on("close", this.onPeerConnectionClosed);
connection.on("end", this.onPeerConnectionClosed);
connection.on("finish", this.onPeerConnectionClosed);
}
get downloadingSegment(): SegmentWithStream | undefined {
return this.downloadingContext?.request.segment;
}
getSegmentStatus(
segment: SegmentWithStream,
): "loaded" | "http-loading" | undefined {
const { externalId } = segment;
if (this.loadedSegments.has(externalId)) return "loaded";
if (this.httpLoadingSegments.has(externalId)) return "http-loading";
}
private onCommandReceived = async (command: Command.PeerCommand) => {
switch (command.c) {
case PeerCommandType.SegmentsAnnouncement:
this.loadedSegments = new Set(command.l);
this.httpLoadingSegments = new Set(command.p);
this.eventHandlers.onSegmentsAnnouncement();
break;
case PeerCommandType.SegmentRequest:
this.peerProtocol.stopUploadingSegmentData();
this.eventHandlers.onSegmentRequested(
this,
command.i,
command.r,
command.b,
);
break;
case PeerCommandType.SegmentData:
{
if (!this.downloadingContext) break;
if (this.downloadingContext.isSegmentDataCommandReceived) break;
const { request, controls, requestId } = this.downloadingContext;
if (
request.segment.externalId !== command.i ||
requestId !== command.r
) {
break;
}
this.downloadingContext.isSegmentDataCommandReceived = true;
controls.firstBytesReceived();
if (request.totalBytes === undefined) {
request.setTotalBytes(command.s);
} else if (request.totalBytes - request.loadedBytes !== command.s) {
request.clearLoadedBytes();
this.sendCancelSegmentRequestCommand(request.segment, requestId);
this.cancelSegmentDownloading(
"peer-response-bytes-length-mismatch",
);
this.destroy();
}
}
break;
case PeerCommandType.SegmentDataSendingCompleted: {
const { downloadingContext } = this;
if (!downloadingContext?.isSegmentDataCommandReceived) return;
const { request, controls } = downloadingContext;
const isWrongSegment =
downloadingContext.request.segment.externalId !== command.i ||
downloadingContext.requestId !== command.r;
if (isWrongSegment) {
request.clearLoadedBytes();
this.cancelSegmentDownloading("peer-protocol-violation");
this.destroy();
return;
}
const isWrongBytes = request.loadedBytes !== request.totalBytes;
if (isWrongBytes) {
request.clearLoadedBytes();
this.cancelSegmentDownloading("peer-response-bytes-length-mismatch");
this.destroy();
return;
}
const isValid =
(await this.peerConfig.validateP2PSegment?.(
request.segment.url,
request.segment.byteRange,
request.data,
)) ?? true;
if (this.downloadingContext !== downloadingContext) return;
if (!isValid) {
request.clearLoadedBytes();
this.cancelSegmentDownloading("p2p-segment-validation-failed");
this.destroy();
return;
}
this.downloadingErrors = [];
controls.completeOnSuccess();
this.downloadingContext = undefined;
break;
}
case PeerCommandType.SegmentAbsent:
if (
this.downloadingContext?.request.segment.externalId === command.i &&
this.downloadingContext.requestId === command.r
) {
this.cancelSegmentDownloading("peer-segment-absent");
this.loadedSegments.delete(command.i);
}
break;
case PeerCommandType.CancelSegmentRequest: {
const uploadingRequestId = this.peerProtocol.getUploadingRequestId();
if (uploadingRequestId !== command.r) break;
this.peerProtocol.stopUploadingSegmentData();
break;
}
}
};
protected onSegmentChunkReceived = (chunk: Uint8Array) => {
if (!this.downloadingContext?.isSegmentDataCommandReceived) return;
const { request, controls } = this.downloadingContext;
const isOverflow =
request.totalBytes !== undefined &&
request.loadedBytes + chunk.byteLength > request.totalBytes;
if (isOverflow) {
request.clearLoadedBytes();
this.cancelSegmentDownloading("peer-response-bytes-length-mismatch");
this.destroy();
return;
}
controls.addLoadedChunk(chunk);
};
downloadSegment(segmentRequest: Request) {
if (this.downloadingContext) {
throw new Error("Some segment already is downloading");
}
this.downloadingContext = {
request: segmentRequest,
requestId: Math.floor(Math.random() * 1000),
isSegmentDataCommandReceived: false,
controls: segmentRequest.start(
{ downloadSource: "p2p", peerId: this.id },
{
notReceivingBytesTimeoutMs:
this.peerConfig.p2pNotReceivingBytesTimeoutMs,
abort: (error) => {
if (!this.downloadingContext) return;
const { request, requestId } = this.downloadingContext;
this.sendCancelSegmentRequestCommand(request.segment, requestId);
this.downloadingErrors.push(error);
this.downloadingContext = undefined;
const timeoutErrors = this.downloadingErrors.filter(
(error) => error.type === "bytes-receiving-timeout",
);
if (timeoutErrors.length >= this.peerConfig.p2pErrorRetries) {
this.destroy();
}
},
},
),
};
const command: Command.PeerRequestSegmentCommand = {
c: PeerCommandType.SegmentRequest,
r: this.downloadingContext.requestId,
i: segmentRequest.segment.externalId,
};
if (segmentRequest.loadedBytes) command.b = segmentRequest.loadedBytes;
this.peerProtocol.sendCommand(command);
}
async uploadSegmentData(
segment: SegmentWithStream,
requestId: number,
data: ArrayBuffer,
) {
const { externalId } = segment;
this.logger(`send segment ${segment.externalId} to ${this.id}`);
const command: Command.PeerSendSegmentCommand = {
c: PeerCommandType.SegmentData,
i: externalId,
r: requestId,
s: data.byteLength,
};
this.peerProtocol.sendCommand(command);
try {
await this.peerProtocol.splitSegmentDataToChunksAndUploadAsync(
data as Uint8Array,
requestId,
);
this.sendSegmentDataSendingCompletedCommand(segment, requestId);
this.logger(`segment ${externalId} has been sent to ${this.id}`);
} catch {
this.logger(`cancel segment uploading ${externalId}`);
}
}
private cancelSegmentDownloading(type: PeerRequestErrorType) {
if (!this.downloadingContext) return;
const { request, controls } = this.downloadingContext;
const { segment } = request;
this.logger(`cancel segment request ${segment.externalId} (${type})`);
const error = new RequestError(type);
controls.abortOnError(error);
this.downloadingContext = undefined;
this.downloadingErrors.push(error);
}
sendSegmentsAnnouncementCommand(
loadedSegmentsIds: number[],
httpLoadingSegmentsIds: number[],
) {
const command: Command.PeerSegmentAnnouncementCommand = {
c: PeerCommandType.SegmentsAnnouncement,
p: httpLoadingSegmentsIds,
l: loadedSegmentsIds,
};
this.peerProtocol.sendCommand(command);
}
sendSegmentAbsentCommand(segmentExternalId: number, requestId: number) {
this.peerProtocol.sendCommand({
c: PeerCommandType.SegmentAbsent,
i: segmentExternalId,
r: requestId,
});
}
private sendCancelSegmentRequestCommand(
segment: SegmentWithStream,
requestId: number,
) {
this.peerProtocol.sendCommand({
c: PeerCommandType.CancelSegmentRequest,
i: segment.externalId,
r: requestId,
});
}
private sendSegmentDataSendingCompletedCommand(
segment: SegmentWithStream,
requestId: number,
) {
this.peerProtocol.sendCommand({
c: PeerCommandType.SegmentDataSendingCompleted,
r: requestId,
i: segment.externalId,
});
}
private onPeerConnectionClosed = () => {
this.destroy();
};
private onConnectionError = (error: Error) => {
this.logger(`peer connection error ${this.id} %O`, error);
this.eventTarget.getEventDispatcher("onPeerError")({
peerId: this.id,
streamType: this.streamType,
error,
});
const { code } = error as { code?: string };
if (code === "ERR_DATA_CHANNEL") {
this.destroy();
} else if (code === "ERR_CONNECTION_FAILURE") {
this.destroy();
}
};
destroy = () => {
this.cancelSegmentDownloading("peer-closed");
this.connection.destroy();
this.eventHandlers.onPeerClosed(this);
this.onPeerClosed({
peerId: this.id,
streamType: this.streamType,
});
this.logger(`peer closed ${this.id}`);
};
static getPeerIdFromConnection(connection: PeerConnection) {
return Utils.hexToUtf8(connection.id);
}
}