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 abortController = new AbortController(); private expectedBytesLength?: number; private requestByteRange?: { start: number; end?: number }; private readonly onChunkDownloaded: CoreEventMap["onChunkDownloaded"]; 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 = { abort: () => this.abortController.abort("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; 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) { throw new RequestError("http-error", "Missing response body"); } requestControls.firstBytesReceived(); const reader = response.body.getReader(); for await (const chunk of readStream(reader)) { requestControls.addLoadedChunk(chunk); this.onChunkDownloaded(chunk.byteLength, "http"); } // 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 (!isValid) { this.request.clearLoadedBytes(); throw new RequestError<"http-segment-validation-failed">( "http-segment-validation-failed", ); } requestControls.completeOnSuccess(); } catch (error) { this.handleError(error, requestControls); } } 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 abortFromProcessQueue/abortOnTimeout, nothing to do here. if (this.abortController.signal.aborted) return; if (error instanceof Error) { const httpLoaderError = error instanceof RequestError ? (error as RequestError) : new RequestError("http-error", error.message); requestControls.abortOnError(httpLoaderError); } } } async function* readStream( reader: ReadableStreamDefaultReader, ): AsyncGenerator { 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, }; }