import { CoreConfig, CoreEventMap, RequestError, HttpRequestErrorType, } from "./types.js"; import { Request as SegmentRequest, RequestControls, } from "./requests/request.js"; import { EventTarget } from "./utils/event-target.js"; import { SafeAbortController, isAbortControllerSupported, } from "./utils/abort-controller.js"; type HttpConfig = Pick< CoreConfig, "httpNotReceivingBytesTimeoutMs" | "httpRequestSetup" | "validateHTTPSegment" >; export class HttpRequestExecutor { private readonly abortController = new SafeAbortController(); private expectedBytesLength?: number; private requestByteRange?: { start: number; end?: number }; private readonly onChunkDownloaded: CoreEventMap["onChunkDownloaded"]; private isAborted(): boolean { return this.abortController.signal.aborted; } constructor( private readonly request: SegmentRequest, private readonly httpConfig: HttpConfig, eventTarget: EventTarget, ) { this.onChunkDownloaded = eventTarget.getEventDispatcher("onChunkDownloaded"); const { byteRange } = this.request.segment; if (byteRange) this.requestByteRange = { ...byteRange }; } execute() { const startControls = { onAbort: () => this.abortController.abort(), notReceivingBytesTimeoutMs: this.httpConfig.httpNotReceivingBytesTimeoutMs, }; const completed = this.request.tryCompleteByLoadedBytes( { downloadSource: "http" }, startControls, this.httpConfig.validateHTTPSegment, "http-segment-validation-failed", ); if (completed) return; if (this.request.loadedBytes !== 0) { this.requestByteRange = this.requestByteRange ?? { start: 0 }; this.requestByteRange.start = this.requestByteRange.start + this.request.loadedBytes; } if (this.request.totalBytes) { this.expectedBytesLength = this.request.totalBytes - this.request.loadedBytes; } const requestControls = this.request.start( { downloadSource: "http" }, startControls, ); void this.fetch(requestControls); } private async fetch(requestControls: RequestControls) { const { segment } = this.request; let activeReader: ReadableStreamDefaultReader | undefined; if (this.isAborted()) { return; } const onAbort = () => { try { activeReader?.cancel().catch(() => { // Swallow stream cancel errors }); } catch { // Swallow stream cancel errors } }; this.abortController.signal.addEventListener("abort", onAbort); const abortSignal = isAbortControllerSupported ? (this.abortController.signal as AbortSignal) : undefined; try { let request = await this.httpConfig.httpRequestSetup?.( segment.url, segment.byteRange, abortSignal, this.requestByteRange, ); if (this.isAborted()) { throw new DOMException("Request aborted", "AbortError"); } if (!request) { // Use default empty Headers constructor and set fields individually for compatibility // with older browsers (e.g. Chrome < 47) that do not support passing an object parameter. const headers = new Headers(); if (this.requestByteRange) { headers.set( "Range", `bytes=${this.requestByteRange.start}-${ this.requestByteRange.end ?? "" }`, ); } const requestOptions: RequestInit = { headers }; if (abortSignal) { requestOptions.signal = abortSignal; } request = new Request(segment.url, requestOptions); } if (this.isAborted()) { throw new DOMException( "Request aborted before request fetch", "AbortError", ); } const response = await window.fetch(request); if (this.isAborted()) { throw new DOMException("Request aborted", "AbortError"); } this.handleResponseHeaders(response); requestControls.firstBytesReceived(); if (!response.body || typeof response.body.getReader !== "function") { // Fallback for older browsers (e.g. Chrome < 43) that do not support ReadableStream // or response.body.getReader. Reads the entire segment into an ArrayBuffer instead. const arrayBuffer = await response.arrayBuffer(); if (this.isAborted()) { throw new DOMException("Request aborted", "AbortError"); } const value = new Uint8Array(arrayBuffer); requestControls.addLoadedChunk(value); this.onChunkDownloaded( value.byteLength, "http", undefined, segment.stream.type, this.request.infoHash, ); } else { const reader = response.body.getReader(); activeReader = reader; for (;;) { if (this.isAborted()) { throw new DOMException("Request aborted", "AbortError"); } const { done, value } = await reader.read(); if (done) break; if (this.isAborted()) { throw new DOMException("Request aborted", "AbortError"); } requestControls.addLoadedChunk(value); this.onChunkDownloaded( value.byteLength, "http", undefined, segment.stream.type, this.request.infoHash, ); } } if (this.isAborted()) { throw new DOMException("Request aborted", "AbortError"); } // If the HTTP connection drops gracefully but prematurely, fetch yields done: true // without throwing an error. We must verify that we received the full segment. // If truncated, we throw without clearing loaded bytes to allow the next // download attempt to resume from the current offset using HTTP Range requests. if ( this.request.totalBytes !== undefined && this.request.loadedBytes !== this.request.totalBytes ) { throw new RequestError( "http-bytes-mismatch", `HTTP response truncated: received ${this.request.loadedBytes} of ${this.request.totalBytes} bytes`, ); } const isValid = await this.request.validateData( this.httpConfig.validateHTTPSegment, ); if (this.isAborted()) { throw new DOMException("Request aborted", "AbortError"); } if (!isValid) { this.request.clearLoadedBytes(); throw new RequestError<"http-segment-validation-failed">( "http-segment-validation-failed", ); } requestControls.completeOnSuccess(); } catch (error) { this.handleError(error, requestControls); } finally { this.abortController.signal.removeEventListener("abort", onAbort); } } private handleResponseHeaders(response: Response) { if (!response.ok) { if (response.status === 406 || response.status === 416) { 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, requestControls: RequestControls) { // Abort-initiated errors: the Request already transitioned its own // status via cancel/abortOnTimeout, nothing to do here. if (this.isAborted()) return; if (error instanceof Error) { const httpLoaderError = error instanceof RequestError ? (error as RequestError) : new RequestError("http-error", error.message); requestControls.failWithError(httpLoaderError); } } } 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, }; }