import type { FetchInput, FetchOutput, BaseFetchOptions, FetchOutputJsonData, FetchOutputTextData, FetchOutputBlobData, FetchOutputArrayBufferData, FetchOutputStreamData, } from "./base.ts" import { BaseFetch } from "./base.ts" const isRecord = (value: unknown): value is Record => { return typeof value === "object" && value !== null } const serializeQueryValue = (value: unknown): string => { if (value instanceof Date) { return value.toISOString() } if (typeof value === "bigint") { return value.toString() } if (isRecord(value) || Array.isArray(value)) { return JSON.stringify(value) } return String(value) } const appendQuery = (url: URL, query: unknown): void => { if (query === undefined || query === null) { return } if (query instanceof URLSearchParams) { for (const [key, value] of query.entries()) { url.searchParams.append(key, value) } return } if (isRecord(query) === false) { throw new TypeError("BrowserFetch query must be a record or URLSearchParams.") } for (const [key, value] of Object.entries(query)) { if (value === undefined) { continue } if (Array.isArray(value)) { for (const item of value) { url.searchParams.append(key, serializeQueryValue(item)) } continue } url.searchParams.append(key, serializeQueryValue(value)) } } const isBodyInit = (value: unknown): value is BodyInit => { if (typeof value === "string") { return true } if ( (typeof Blob !== "undefined" && value instanceof Blob) || value instanceof ArrayBuffer || value instanceof URLSearchParams || (typeof FormData !== "undefined" && value instanceof FormData) ) { return true } if (typeof ReadableStream !== "undefined" && value instanceof ReadableStream) { return true } const isArrayBufferView = ArrayBuffer.isView(value) return isArrayBufferView === true } const buildHeaders = (headers: Record | undefined, body: unknown): Headers => { const output = new Headers(headers) if ( body !== undefined && body !== null && isBodyInit(body) === false && output.has("Content-Type") === false ) { output.set("Content-Type", "application/json") } return output } const buildBody = (body: unknown): BodyInit | undefined => { if (body === undefined || body === null) { return undefined } if (isBodyInit(body) === true) { return body } return JSON.stringify(body) } const buildRequestAbortSignal = ( timeoutSignal: AbortSignal, abortSignal: AbortSignal | undefined, ): { signal: AbortSignal; cleanup: () => void } => { if (abortSignal === undefined) { return { signal: timeoutSignal, cleanup: () => { // no cleanup needed when abortSignal is not provided }, } } const requestAbortController = new AbortController() const cleanupList: Array<() => void> = [] const bindAbortSignal = (signal: AbortSignal): void => { if (signal.aborted === true) { requestAbortController.abort(signal.reason) return } const onAbort = (): void => { requestAbortController.abort(signal.reason) } signal.addEventListener("abort", onAbort, { once: true }) cleanupList.push(() => { signal.removeEventListener("abort", onAbort) }) } bindAbortSignal(timeoutSignal) bindAbortSignal(abortSignal) return { signal: requestAbortController.signal, cleanup: () => { for (const cleanup of cleanupList) { cleanup() } }, } } /** * @description 描述创建浏览器 fetch 执行器时可提供的选项。 */ export interface BrowserFetchOptions< Input extends FetchInput = FetchInput, Output extends FetchOutput = FetchOutput, > extends BaseFetchOptions {} /** * @description 基于浏览器原生 fetch 的请求执行器。 */ export class BrowserFetch extends BaseFetch< Input, Output > { private responsePromise: Promise | undefined constructor(options: BrowserFetchOptions) { super(options) } private async execute(): Promise { const { baseUrl, path, method, headers, query, body, timeout, abortSignal } = this.options const url = new URL(path, baseUrl) appendQuery(url, query) const requestHeaders = buildHeaders(headers, body) const requestBody = buildBody(body) const timeoutAbortController = new AbortController() const { signal: requestAbortSignal, cleanup } = buildRequestAbortSignal( timeoutAbortController.signal, abortSignal, ) const requestInit: RequestInit = { method: method.toUpperCase(), headers: requestHeaders, signal: requestAbortSignal, } if (requestBody !== undefined) { requestInit.body = requestBody } let timeoutId: ReturnType | undefined if (timeout !== undefined && timeout > 0) { timeoutId = setTimeout(() => { timeoutAbortController.abort(new Error(`Request timeout after ${timeout}ms.`)) }, timeout) } try { return await fetch(url.toString(), requestInit) } catch (exception) { if (requestAbortSignal.aborted === true) { throw requestAbortSignal.reason } throw exception } finally { cleanup() if (timeoutId !== undefined) { clearTimeout(timeoutId) } } } private async getResponse(): Promise { if (this.responsePromise === undefined) { this.responsePromise = this.execute() } return await this.responsePromise } /** * @description 以 JSON 形式读取浏览器响应。 */ async getJson(): Promise> { const response = await this.getResponse() return (await response.json()) as FetchOutputJsonData } /** * @description 以文本形式读取浏览器响应。 */ async getText(): Promise> { const response = await this.getResponse() return (await response.text()) as FetchOutputTextData } /** * @description 以 Blob 形式读取浏览器响应。 */ async getBlob(): Promise> { const response = await this.getResponse() return (await response.blob()) as FetchOutputBlobData } /** * @description 以 ArrayBuffer 形式读取浏览器响应。 */ async getArrayBuffer(): Promise> { const response = await this.getResponse() return (await response.arrayBuffer()) as FetchOutputArrayBufferData } /** * @description 以流形式读取浏览器响应。 */ async getStream(): Promise> { const response = await this.getResponse() if (response.body === null) { throw new Error("Response body stream is unavailable.") } return response.body as FetchOutputStreamData } } /** * @description 创建浏览器环境下的 fetch 执行器。 */ export const browserFetch = ( options: BrowserFetchOptions, ): BrowserFetch => { return new BrowserFetch(options) }