p2p-media-loader-core
Version:
P2P Media Loader core functionality
230 lines (200 loc) • 6.88 kB
text/typescript
import {
CoreConfig,
CoreEventMap,
RequestError,
HttpRequestErrorType,
} from "./types.js";
import {
Request as SegmentRequest,
RequestControls,
} from "./requests/request.js";
import { EventTarget } from "./utils/event-target.js";
type HttpConfig = Pick<
CoreConfig,
"httpNotReceivingBytesTimeoutMs" | "httpRequestSetup" | "validateHTTPSegment"
>;
export class HttpRequestExecutor {
private readonly requestControls: RequestControls;
private readonly abortController = new AbortController();
private readonly expectedBytesLength?: number;
private readonly requestByteRange?: { start: number; end?: number };
private readonly onChunkDownloaded: CoreEventMap["onChunkDownloaded"];
constructor(
private readonly request: SegmentRequest,
private readonly httpConfig: HttpConfig,
eventTarget: EventTarget<CoreEventMap>,
) {
this.onChunkDownloaded =
eventTarget.getEventDispatcher("onChunkDownloaded");
const { byteRange } = this.request.segment;
if (byteRange) this.requestByteRange = { ...byteRange };
if (request.loadedBytes !== 0) {
this.requestByteRange = this.requestByteRange ?? { start: 0 };
this.requestByteRange.start =
this.requestByteRange.start + request.loadedBytes;
}
if (this.request.totalBytes) {
this.expectedBytesLength =
this.request.totalBytes - this.request.loadedBytes;
}
this.requestControls = this.request.start(
{ downloadSource: "http" },
{
abort: () => this.abortController.abort("abort"),
notReceivingBytesTimeoutMs:
this.httpConfig.httpNotReceivingBytesTimeoutMs,
},
);
void this.fetch();
}
private async fetch() {
const { segment } = this.request;
try {
let request = await this.httpConfig.httpRequestSetup?.(
segment.url,
segment.byteRange,
this.abortController.signal,
this.requestByteRange,
);
if (!request) {
const headers = new Headers(
this.requestByteRange
? {
Range: `bytes=${this.requestByteRange.start}-${
this.requestByteRange.end ?? ""
}`,
}
: undefined,
);
request = new Request(segment.url, {
headers,
signal: this.abortController.signal,
});
}
if (this.abortController.signal.aborted) {
throw new DOMException(
"Request aborted before request fetch",
"AbortError",
);
}
const response = await window.fetch(request);
this.handleResponseHeaders(response);
if (!response.body) return;
const { requestControls } = this;
requestControls.firstBytesReceived();
const reader = response.body.getReader();
for await (const chunk of readStream(reader)) {
this.requestControls.addLoadedChunk(chunk);
this.onChunkDownloaded(chunk.byteLength, "http");
}
const isValid =
(await this.httpConfig.validateHTTPSegment?.(
segment.url,
segment.byteRange,
this.request.data,
)) ?? true;
if (!isValid) {
this.request.clearLoadedBytes();
throw new RequestError<"http-segment-validation-failed">(
"http-segment-validation-failed",
);
}
requestControls.completeOnSuccess();
} catch (error) {
this.handleError(error);
}
}
private handleResponseHeaders(response: Response) {
if (!response.ok) {
if (response.status === 406) {
this.request.clearLoadedBytes();
throw new RequestError<"http-bytes-mismatch">(
"http-bytes-mismatch",
response.statusText,
);
} else {
throw new RequestError<"http-error">("http-error", response.statusText);
}
}
const { requestByteRange } = this;
if (requestByteRange) {
if (response.status === 200) {
if (this.request.segment.byteRange) {
throw new RequestError("http-unexpected-status-code");
} else {
this.request.clearLoadedBytes();
}
} else {
if (response.status !== 206) {
throw new RequestError(
"http-unexpected-status-code",
response.statusText,
);
}
const contentLengthHeader = response.headers.get("Content-Length");
if (
contentLengthHeader &&
this.expectedBytesLength !== undefined &&
this.expectedBytesLength !== +contentLengthHeader
) {
this.request.clearLoadedBytes();
throw new RequestError("http-bytes-mismatch", response.statusText);
}
const contentRangeHeader = response.headers.get("Content-Range");
const contentRange = contentRangeHeader
? parseContentRangeHeader(contentRangeHeader)
: undefined;
if (contentRange) {
const { from, to } = contentRange;
const responseExpectedBytesLength =
to !== undefined && from !== undefined ? to - from + 1 : undefined;
if (
(responseExpectedBytesLength !== undefined &&
this.expectedBytesLength !== responseExpectedBytesLength) ||
(from !== undefined && requestByteRange.start !== from) ||
(to !== undefined &&
requestByteRange.end !== undefined &&
requestByteRange.end !== to)
) {
this.request.clearLoadedBytes();
throw new RequestError("http-bytes-mismatch", response.statusText);
}
}
}
}
if (response.status === 200 && this.request.totalBytes === undefined) {
const contentLengthHeader = response.headers.get("Content-Length");
if (contentLengthHeader) this.request.setTotalBytes(+contentLengthHeader);
}
}
private handleError(error: unknown) {
if (error instanceof Error) {
if (error.name !== "abort") return;
const httpLoaderError =
error instanceof RequestError
? (error as RequestError<HttpRequestErrorType>)
: new RequestError("http-error", error.message);
this.requestControls.abortOnError(httpLoaderError);
}
}
}
async function* readStream(
reader: ReadableStreamDefaultReader<Uint8Array>,
): AsyncGenerator<Uint8Array> {
while (true) {
const { done, value } = await reader.read();
if (done) break;
yield value;
}
}
const rangeHeaderRegex = /^bytes (?:(?:(\d+)|)-(?:(\d+)|)|\*)\/(?:(\d+)|\*)$/;
function parseContentRangeHeader(headerValue: string) {
const match = rangeHeaderRegex.exec(headerValue.trim());
if (!match) return;
const [, from, to, total] = match;
return {
from: from ? parseInt(from) : undefined,
to: to ? parseInt(to) : undefined,
total: total ? parseInt(total) : undefined,
};
}