UNPKG

@zhengxs/http

Version:

A lightweight cross-platform http request library

1 lines 78.5 kB
{"version":3,"file":"index.cjs","sources":["../src/error.ts","../src/streaming.ts","../src/uploads.ts","../src/util.ts","../src/client.ts"],"sourcesContent":["import { type ReqHeaders } from './types';\n\nexport const castToError = (err: any): Error => {\n if (err instanceof Error) return err;\n return new Error(err);\n};\n\nexport class HttpException extends Error {}\n\nexport class APIError extends HttpException {\n readonly status: number | undefined;\n readonly headers: ReqHeaders | undefined;\n readonly error: NonNullable<unknown> | undefined;\n\n readonly code: string | null | undefined;\n readonly param: string | null | undefined;\n readonly type: string | undefined;\n\n constructor(\n status: number | undefined,\n error: NonNullable<unknown> | undefined,\n message: string | undefined,\n headers: ReqHeaders | undefined,\n ) {\n super(`${APIError.makeMessage(status, error, message)}`);\n this.status = status;\n this.headers = headers;\n\n const data = error as Record<string, any>;\n this.error = data;\n this.code = data?.['code'];\n this.param = data?.['param'];\n this.type = data?.['type'];\n }\n\n private static makeMessage(\n status: number | undefined,\n error: any,\n message: string | undefined,\n ) {\n const msg = error?.message\n ? typeof error.message === 'string'\n ? error.message\n : JSON.stringify(error.message)\n : error\n ? JSON.stringify(error)\n : message;\n\n if (status && msg) {\n return `${status} ${msg}`;\n }\n if (status) {\n return `${status} status code (no body)`;\n }\n if (msg) {\n return msg;\n }\n return '(no status code or body)';\n }\n\n static generate(\n status: number | undefined,\n errorResponse: NonNullable<unknown> | undefined,\n message: string | undefined,\n headers: ReqHeaders | undefined,\n ) {\n if (!status) {\n return new APIConnectionError({ cause: castToError(errorResponse) });\n }\n\n const error = (errorResponse as Record<string, any>)?.['error'];\n\n if (status === 400) {\n return new BadRequestError(status, error, message, headers);\n }\n\n if (status === 401) {\n return new AuthenticationError(status, error, message, headers);\n }\n\n if (status === 403) {\n return new PermissionDeniedError(status, error, message, headers);\n }\n\n if (status === 404) {\n return new NotFoundError(status, error, message, headers);\n }\n\n if (status === 409) {\n return new ConflictError(status, error, message, headers);\n }\n\n if (status === 422) {\n return new UnprocessableEntityError(status, error, message, headers);\n }\n\n if (status === 429) {\n return new RateLimitError(status, error, message, headers);\n }\n\n if (status >= 500) {\n return new InternalServerError(status, error, message, headers);\n }\n\n return new APIError(status, error, message, headers);\n }\n}\n\nexport class APIUserAbortError extends APIError {\n override readonly status: undefined = undefined;\n\n constructor({ message }: { message?: string } = {}) {\n super(undefined, undefined, message || 'Request was aborted.', undefined);\n }\n}\n\nexport class APIConnectionError extends APIError {\n override readonly status: undefined = undefined;\n\n constructor({\n message,\n cause,\n }: {\n message?: string;\n cause?: Error | undefined;\n }) {\n super(undefined, undefined, message || 'Connection error.', undefined);\n // in some environments the 'cause' property is already declared\n // @ts-ignore\n if (cause) this.cause = cause;\n }\n}\n\nexport class APIConnectionTimeoutError extends APIConnectionError {\n constructor({ message }: { message?: string } = {}) {\n super({ message: message ?? 'Request timed out.' });\n }\n}\n\nexport class BadRequestError extends APIError {\n override readonly status: 400 = 400 as const;\n}\n\nexport class AuthenticationError extends APIError {\n override readonly status: 401 = 401 as const;\n}\n\nexport class PermissionDeniedError extends APIError {\n override readonly status: 403 = 403 as const;\n}\n\nexport class NotFoundError extends APIError {\n override readonly status: 404 = 404 as const;\n}\n\nexport class ConflictError extends APIError {\n override readonly status: 409 = 409 as const;\n}\n\nexport class UnprocessableEntityError extends APIError {\n override readonly status: 422 = 422 as const;\n}\n\nexport class RateLimitError extends APIError {\n override readonly status: 429 = 429 as const;\n}\n\nexport class InternalServerError extends APIError {}\n","import { APIError, HttpException } from './error';\n\nexport type Bytes =\n | string\n | ArrayBuffer\n | Uint8Array\n | Buffer\n | null\n | undefined;\n\nexport type ServerSentEvent = {\n event: string | null;\n data: string;\n raw: string[];\n};\n\nexport class Stream<Item> implements AsyncIterable<Item> {\n controller: AbortController;\n\n constructor(\n private iterator: () => AsyncIterator<Item>,\n controller: AbortController,\n ) {\n this.controller = controller;\n }\n\n static fromSSEResponse<Item>(\n response: Response,\n controller: AbortController,\n ) {\n let consumed = false;\n const decoder = new SSEDecoder();\n\n async function* iterMessages(): AsyncGenerator<\n ServerSentEvent,\n void,\n unknown\n > {\n if (!response.body) {\n controller.abort();\n throw new HttpException(\n `Attempted to iterate over a response with no body`,\n );\n }\n\n const lineDecoder = new LineDecoder();\n\n const iter = readableStreamAsyncIterable<Bytes>(response.body);\n for await (const chunk of iter) {\n for (const line of lineDecoder.decode(chunk)) {\n const sse = decoder.decode(line);\n if (sse) yield sse;\n }\n }\n\n for (const line of lineDecoder.flush()) {\n const sse = decoder.decode(line);\n if (sse) yield sse;\n }\n }\n\n async function* iterator(): AsyncIterator<Item, any, undefined> {\n if (consumed) {\n throw new Error(\n 'Cannot iterate over a consumed stream, use `.tee()` to split the stream.',\n );\n }\n consumed = true;\n let done = false;\n try {\n for await (const sse of iterMessages()) {\n if (done) continue;\n\n if (sse.data.startsWith('[DONE]')) {\n done = true;\n continue;\n }\n\n if (sse.event === null) {\n let data;\n\n try {\n data = JSON.parse(sse.data);\n } catch (e) {\n console.error(`Could not parse message into JSON:`, sse.data);\n console.error(`From chunk:`, sse.raw);\n throw e;\n }\n\n if (data && data.error) {\n throw new APIError(undefined, data.error, undefined, undefined);\n }\n\n yield data;\n }\n }\n done = true;\n } catch (e) {\n // If the user calls `stream.controller.abort()`, we should exit without throwing.\n if (e instanceof Error && e.name === 'AbortError') return;\n throw e;\n } finally {\n // If the user `break`s, abort the ongoing request.\n if (!done) controller.abort();\n }\n }\n\n return new Stream(iterator, controller);\n }\n\n /**\n * Generates a Stream from a newline-separated ReadableStream\n * where each item is a JSON value.\n */\n static fromReadableStream<Item>(\n readableStream: globalThis.ReadableStream,\n controller: AbortController,\n ) {\n let consumed = false;\n\n async function* iterLines(): AsyncGenerator<string, void, unknown> {\n const lineDecoder = new LineDecoder();\n\n const iter = readableStreamAsyncIterable<Bytes>(readableStream);\n for await (const chunk of iter) {\n for (const line of lineDecoder.decode(chunk)) {\n yield line;\n }\n }\n\n for (const line of lineDecoder.flush()) {\n yield line;\n }\n }\n\n async function* iterator(): AsyncIterator<Item, any, undefined> {\n if (consumed) {\n throw new Error(\n 'Cannot iterate over a consumed stream, use `.tee()` to split the stream.',\n );\n }\n consumed = true;\n let done = false;\n try {\n for await (const line of iterLines()) {\n if (done) continue;\n if (line) yield JSON.parse(line);\n }\n done = true;\n } catch (e) {\n // If the user calls `stream.controller.abort()`, we should exit without throwing.\n if (e instanceof Error && e.name === 'AbortError') return;\n throw e;\n } finally {\n // If the user `break`s, abort the ongoing request.\n if (!done) controller.abort();\n }\n }\n\n return new Stream(iterator, controller);\n }\n\n [Symbol.asyncIterator](): AsyncIterator<Item> {\n return this.iterator();\n }\n\n /**\n * Splits the stream into two streams which can be\n * independently read from at different speeds.\n */\n tee(): [Stream<Item>, Stream<Item>] {\n const left: Array<Promise<IteratorResult<Item>>> = [];\n const right: Array<Promise<IteratorResult<Item>>> = [];\n const iterator = this.iterator();\n\n const teeIterator = (\n queue: Array<Promise<IteratorResult<Item>>>,\n ): AsyncIterator<Item> => {\n return {\n next: () => {\n if (queue.length === 0) {\n const result = iterator.next();\n left.push(result);\n right.push(result);\n }\n return queue.shift()!;\n },\n };\n };\n\n return [\n new Stream(() => teeIterator(left), this.controller),\n new Stream(() => teeIterator(right), this.controller),\n ];\n }\n\n /**\n * Converts this stream to a newline-separated ReadableStream of\n * JSON stringified values in the stream\n * which can be turned back into a Stream with `Stream.fromReadableStream()`.\n */\n toReadableStream(): globalThis.ReadableStream {\n // eslint-disable-next-line @typescript-eslint/no-this-alias\n const self = this;\n\n let iter: AsyncIterator<Item>;\n const encoder = new TextEncoder();\n\n return new ReadableStream({\n async start() {\n iter = self[Symbol.asyncIterator]();\n },\n async pull(ctrl) {\n try {\n const { value, done } = await iter.next();\n if (done) return ctrl.close();\n\n const bytes = encoder.encode(JSON.stringify(value) + '\\n');\n\n ctrl.enqueue(bytes);\n } catch (err) {\n ctrl.error(err);\n }\n },\n async cancel() {\n await iter.return?.();\n },\n });\n }\n}\n\nexport class SSEDecoder {\n private data: string[];\n private event: string | null;\n private chunks: string[];\n\n constructor() {\n this.event = null;\n this.data = [];\n this.chunks = [];\n }\n\n decode(line: string) {\n if (line.endsWith('\\r')) {\n line = line.substring(0, line.length - 1);\n }\n\n if (!line) {\n // empty line and we didn't previously encounter any messages\n if (!this.event && !this.data.length) return null;\n\n const sse: ServerSentEvent = {\n event: this.event,\n data: this.data.join('\\n'),\n raw: this.chunks,\n };\n\n this.event = null;\n this.data = [];\n this.chunks = [];\n\n return sse;\n }\n\n this.chunks.push(line);\n\n if (line.startsWith(':')) {\n return null;\n }\n\n const [fieldname, _, raw] = partition(line, ':');\n\n let value = raw;\n if (value.startsWith(' ')) {\n value = value.substring(1);\n }\n\n if (fieldname === 'event') {\n this.event = value;\n } else if (fieldname === 'data') {\n this.data.push(value);\n }\n\n return null;\n }\n}\n\n/**\n * A re-implementation of httpx's `LineDecoder` in Python that handles incrementally\n * reading lines from text.\n *\n * https://github.com/encode/httpx/blob/920333ea98118e9cf617f246905d7b202510941c/httpx/_decoders.py#L258\n */\nexport class LineDecoder {\n // prettier-ignore\n static NEWLINE_CHARS = new Set(['\\n', '\\r', '\\x0b', '\\x0c', '\\x1c', '\\x1d', '\\x1e', '\\x85', '\\u2028', '\\u2029']);\n static NEWLINE_REGEXP = /\\r\\n|[\\n\\r\\x0b\\x0c\\x1c\\x1d\\x1e\\x85\\u2028\\u2029]/g;\n\n buffer: string[];\n trailingCR: boolean;\n textDecoder: any; // TextDecoder found in browsers; not typed to avoid pulling in either \"dom\" or \"node\" types.\n\n constructor() {\n this.buffer = [];\n this.trailingCR = false;\n }\n\n decode(chunk: Bytes): string[] {\n let text = this.decodeText(chunk);\n\n if (this.trailingCR) {\n text = '\\r' + text;\n this.trailingCR = false;\n }\n if (text.endsWith('\\r')) {\n this.trailingCR = true;\n text = text.slice(0, -1);\n }\n\n if (!text) {\n return [];\n }\n\n const trailingNewline = LineDecoder.NEWLINE_CHARS.has(\n text[text.length - 1] || '',\n );\n let lines = text.split(LineDecoder.NEWLINE_REGEXP);\n\n if (lines.length === 1 && !trailingNewline) {\n this.buffer.push(lines[0]!);\n return [];\n }\n\n if (this.buffer.length > 0) {\n lines = [this.buffer.join('') + lines[0], ...lines.slice(1)];\n this.buffer = [];\n }\n\n if (!trailingNewline) {\n this.buffer = [lines.pop() || ''];\n }\n\n return lines;\n }\n\n decodeText(bytes: Bytes): string {\n if (bytes == null) return '';\n if (typeof bytes === 'string') return bytes;\n\n // Node:\n if (typeof Buffer !== 'undefined') {\n if (bytes instanceof Buffer) {\n return bytes.toString();\n }\n if (bytes instanceof Uint8Array) {\n return Buffer.from(bytes).toString();\n }\n\n throw new HttpException(\n `Unexpected: received non-Uint8Array (${bytes.constructor.name}) stream chunk in an environment with a global \"Buffer\" defined, which this library assumes to be Node. Please report this error.`,\n );\n }\n\n // Browser\n if (typeof TextDecoder !== 'undefined') {\n if (bytes instanceof Uint8Array || bytes instanceof ArrayBuffer) {\n this.textDecoder ??= new TextDecoder('utf8');\n return this.textDecoder.decode(bytes);\n }\n\n throw new HttpException(\n `Unexpected: received non-Uint8Array/ArrayBuffer (${\n (bytes as any).constructor.name\n }) in a web platform. Please report this error.`,\n );\n }\n\n throw new HttpException(\n `Unexpected: neither Buffer nor TextDecoder are available as globals. Please report this error.`,\n );\n }\n\n flush(): string[] {\n if (!this.buffer.length && !this.trailingCR) {\n return [];\n }\n\n const lines = [this.buffer.join('')];\n this.buffer = [];\n this.trailingCR = false;\n return lines;\n }\n}\n\nfunction partition(str: string, delimiter: string): [string, string, string] {\n const index = str.indexOf(delimiter);\n if (index !== -1) {\n return [\n str.substring(0, index),\n delimiter,\n str.substring(index + delimiter.length),\n ];\n }\n\n return [str, '', ''];\n}\n\n/**\n * Most browsers don't yet have async iterable support for ReadableStream,\n * and Node has a very different way of reading bytes from its \"ReadableStream\".\n *\n * This polyfill was pulled from https://github.com/MattiasBuelens/web-streams-polyfill/pull/122#issuecomment-1627354490\n */\nexport function readableStreamAsyncIterable<T>(\n stream: any,\n): AsyncIterableIterator<T> {\n if (stream[Symbol.asyncIterator]) return stream;\n\n const reader = stream.getReader();\n return {\n async next() {\n try {\n const result = await reader.read();\n if (result?.done) reader.releaseLock(); // release lock when stream becomes closed\n return result;\n } catch (e) {\n reader.releaseLock(); // release lock when stream becomes errored\n throw e;\n }\n },\n async return() {\n const cancelPromise = reader.cancel();\n reader.releaseLock();\n await cancelPromise;\n return { done: true, value: undefined };\n },\n [Symbol.asyncIterator]() {\n return this;\n },\n };\n}\n","import {\n File,\n type FsReadStream,\n getMultipartRequestOptions,\n isFsReadStream,\n MultipartBody,\n} from './_shims/index';\nimport { type RequestOptions } from './types';\n\ntype BlobLikePart =\n | string\n | ArrayBuffer\n | ArrayBufferView\n | BlobLike\n | Uint8Array\n | DataView;\nexport type BlobPart =\n | string\n | ArrayBuffer\n | ArrayBufferView\n | Blob\n | Uint8Array\n | DataView;\n\n/**\n * Typically, this is a native \"File\" class.\n *\n * We provide the {@link toFile} utility to convert a variety of objects\n * into the File class.\n *\n * For convenience, you can also pass a fetch Response, or in Node,\n * the result of fs.createReadStream().\n */\nexport type Uploadable = FileLike | ResponseLike | FsReadStream;\n\n/**\n * Intended to match web.Blob, node.Blob, node-fetch.Blob, etc.\n */\nexport interface BlobLike {\n /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/Blob/size) */\n readonly size: number;\n /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/Blob/type) */\n readonly type: string;\n /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/Blob/text) */\n text(): Promise<string>;\n /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/Blob/slice) */\n slice(start?: number, end?: number): BlobLike;\n // unfortunately @types/node-fetch@^2.6.4 doesn't type the arrayBuffer method\n}\n\n/**\n * Intended to match web.File, node.File, node-fetch.File, etc.\n */\nexport interface FileLike extends BlobLike {\n /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/File/lastModified) */\n readonly lastModified: number;\n /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/File/name) */\n readonly name: string;\n}\n\n/**\n * Intended to match web.Response, node.Response, node-fetch.Response, etc.\n */\nexport interface ResponseLike {\n url: string;\n blob(): Promise<BlobLike>;\n}\n\nexport const isResponseLike = (value: any): value is ResponseLike =>\n value != null &&\n typeof value === 'object' &&\n typeof value.url === 'string' &&\n typeof value.blob === 'function';\n\nexport const isFileLike = (value: any): value is FileLike =>\n value != null &&\n typeof value === 'object' &&\n typeof value.name === 'string' &&\n typeof value.lastModified === 'number' &&\n isBlobLike(value);\n\n/**\n * The BlobLike type omits arrayBuffer() because `@types/node-fetch@^2.6.4` lacks it; but this check\n * adds the arrayBuffer() method type because it is available and used at runtime\n */\nexport const isBlobLike = (\n value: any,\n): value is BlobLike & { arrayBuffer(): Promise<ArrayBuffer> } =>\n value != null &&\n typeof value === 'object' &&\n typeof value.size === 'number' &&\n typeof value.type === 'string' &&\n typeof value.text === 'function' &&\n typeof value.slice === 'function' &&\n typeof value.arrayBuffer === 'function';\n\nexport const isUploadable = (value: any): value is Uploadable => {\n return isFileLike(value) || isResponseLike(value) || isFsReadStream!(value);\n};\n\nexport type ToFileInput =\n | Uploadable\n | Exclude<BlobLikePart, string>\n | AsyncIterable<BlobLikePart>;\n\n/**\n * Helper for creating a {@link File} to pass to an SDK upload method from a variety of different data formats\n * @param value - the raw content of the file. Can be an {@link Uploadable}, {@link BlobLikePart}, or {@link AsyncIterable} of {@link BlobLikePart}s\n * @param name - the name of the file. If omitted, toFile will try to determine a file name from bits if possible\n * @param options - additional properties\n * @returns a {@link File} with the given properties\n */\nexport async function toFile(\n value: ToFileInput | PromiseLike<ToFileInput>,\n name?: string | null | undefined,\n options: FilePropertyBag | undefined = {},\n): Promise<FileLike> {\n // If it's a promise, resolve it.\n value = await value;\n\n if (isResponseLike(value)) {\n const blob = await value.blob();\n name ||= new URL(value.url).pathname.split(/[\\\\/]/).pop() ?? 'unknown_file';\n\n return new File([blob as any], name, options);\n }\n\n const bits = await getBytes(value);\n\n name ||= getName(value) ?? 'unknown_file';\n\n if (!options.type) {\n const type = (bits[0] as any)?.type;\n if (typeof type === 'string') {\n options = { ...options, type };\n }\n }\n\n return new File(bits, name, options);\n}\n\nasync function getBytes(value: ToFileInput): Promise<Array<BlobPart>> {\n const parts: Array<BlobPart> = [];\n if (\n typeof value === 'string' ||\n ArrayBuffer.isView(value) || // includes Uint8Array, Buffer, etc.\n value instanceof ArrayBuffer\n ) {\n parts.push(value);\n } else if (isBlobLike(value)) {\n parts.push(await value.arrayBuffer());\n } else if (\n isAsyncIterableIterator(value) // includes Readable, ReadableStream, etc.\n ) {\n for await (const chunk of value) {\n parts.push(chunk as BlobPart); // TODO, consider validating?\n }\n } else {\n throw new Error(\n `Unexpected data type: ${typeof value}; constructor: ${value?.constructor\n ?.name}; props: ${propsForError(value)}`,\n );\n }\n\n return parts;\n}\n\nfunction propsForError(value: any): string {\n const props = Object.getOwnPropertyNames(value);\n return `[${props.map(p => `\"${p}\"`).join(', ')}]`;\n}\n\nfunction getName(value: any): string | undefined {\n return (\n getStringFromMaybeBuffer(value.name) ||\n getStringFromMaybeBuffer(value.filename) ||\n // For fs.ReadStream\n getStringFromMaybeBuffer(value.path)?.split(/[\\\\/]/).pop()\n );\n}\n\nconst getStringFromMaybeBuffer = (\n x: string | Buffer | unknown,\n): string | undefined => {\n if (typeof x === 'string') return x;\n if (typeof Buffer !== 'undefined' && x instanceof Buffer) return String(x);\n return undefined;\n};\n\nconst isAsyncIterableIterator = (\n value: any,\n): value is AsyncIterableIterator<unknown> =>\n value != null &&\n typeof value === 'object' &&\n typeof value[Symbol.asyncIterator] === 'function';\n\nexport const isMultipartBody = (body: any): body is MultipartBody =>\n body &&\n typeof body === 'object' &&\n body.body &&\n body[Symbol.toStringTag] === 'MultipartBody';\n\n/**\n * Returns a multipart/form-data request if any part of the given request body contains a File / Blob value.\n * Otherwise returns the request as is.\n */\nexport const maybeMultipartFormRequestOptions = async <\n T extends NonNullable<unknown> = Record<string, unknown>,\n>(\n opts: RequestOptions<T>,\n): Promise<RequestOptions<T | MultipartBody>> => {\n if (!hasUploadableValue(opts.body)) return opts;\n\n const form = await createForm(opts.body);\n return getMultipartRequestOptions!(form, opts);\n};\n\nexport const multipartFormRequestOptions = async <\n T extends NonNullable<unknown> = Record<string, unknown>,\n>(\n opts: RequestOptions<T>,\n): Promise<RequestOptions<T | MultipartBody>> => {\n const form = await createForm(opts.body);\n return getMultipartRequestOptions!(form, opts);\n};\n\nexport const createForm = async <T = Record<string, unknown>>(\n body: T | undefined,\n): Promise<FormData> => {\n const form = new FormData();\n await Promise.all(\n Object.entries(body || {}).map(([key, value]) =>\n addFormValue(form, key, value),\n ),\n );\n return form;\n};\n\nconst hasUploadableValue = (value: unknown): boolean => {\n if (isUploadable(value)) return true;\n if (Array.isArray(value)) return value.some(hasUploadableValue);\n if (value && typeof value === 'object') {\n for (const k in value) {\n if (hasUploadableValue((value as any)[k])) return true;\n }\n }\n return false;\n};\n\nconst addFormValue = async (\n form: FormData,\n key: string,\n value: unknown,\n): Promise<void> => {\n if (value === undefined) return;\n if (value == null) {\n throw new TypeError(\n `Received null for \"${key}\"; to pass null in FormData, you must use the string 'null'`,\n );\n }\n\n // TODO: make nested formats configurable\n if (\n typeof value === 'string' ||\n typeof value === 'number' ||\n typeof value === 'boolean'\n ) {\n form.append(key, String(value));\n } else if (isUploadable(value)) {\n const file = await toFile(value);\n form.append(key, file as File);\n } else if (Array.isArray(value)) {\n await Promise.all(\n value.map(entry => addFormValue(form, key + '[]', entry)),\n );\n } else if (typeof value === 'object') {\n await Promise.all(\n Object.entries(value).map(([name, prop]) =>\n addFormValue(form, `${key}[${name}]`, prop),\n ),\n );\n } else {\n throw new TypeError(\n `Invalid value given to form, expected a string, number, boolean, object, Array, File or Blob but got ${value} instead`,\n );\n }\n};\n","import { type Readable } from './_shims/index';\nimport { HttpException } from './error';\nimport type { Fetch, KeysEnum, RequestOptions } from './types';\n\nexport {\n maybeMultipartFormRequestOptions,\n multipartFormRequestOptions,\n createForm,\n type Uploadable,\n} from './uploads';\n\nexport const safeJSON = (text: string) => {\n try {\n return JSON.parse(text);\n } catch (err) {\n return undefined;\n }\n};\n\nexport const sleep = (ms: number) =>\n new Promise(resolve => setTimeout(resolve, ms));\n\n// https://stackoverflow.com/a/34491287\nexport function isEmptyObj(\n obj: NonNullable<unknown> | null | undefined,\n): boolean {\n if (!obj) return true;\n for (const _k in obj) return false;\n return true;\n}\n\n// https://eslint.org/docs/latest/rules/no-prototype-builtins\nexport function hasOwn(obj: NonNullable<unknown>, key: string): boolean {\n return Object.prototype.hasOwnProperty.call(obj, key);\n}\n\nexport function debug(action: string, ...args: any[]) {\n if (typeof process !== 'undefined' && process.env['DEBUG'] === 'true') {\n console.log(`DINGTALK:DEBUG:${action}`, ...args);\n }\n}\n\n/**\n * https://stackoverflow.com/a/2117523\n */\nexport const uuid4 = () => {\n return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => {\n const r = (Math.random() * 16) | 0;\n const v = c === 'x' ? r : (r & 0x3) | 0x8;\n return v.toString(16);\n });\n};\n\n// https://stackoverflow.com/a/19709846\nconst startsWithSchemeRegexp = new RegExp('^(?:[a-z]+:)?//', 'i');\n\nexport const isAbsoluteURL = (url: string): boolean => {\n return startsWithSchemeRegexp.test(url);\n};\n\n// This is required so that we can determine if a given object matches the RequestOptions\n// type at runtime. While this requires duplication, it is enforced by the TypeScript\n// compiler such that any missing / extraneous keys will cause an error.\nconst requestOptionsKeys: KeysEnum<RequestOptions> = {\n method: true,\n path: true,\n query: true,\n body: true,\n headers: true,\n duplex: true,\n\n maxRetries: true,\n stream: true,\n timeout: true,\n httpAgent: true,\n signal: true,\n idempotencyKey: true,\n\n __binaryResponse: true,\n};\n\nexport const isRequestOptions = (\n obj: unknown,\n): obj is RequestOptions<Record<string, unknown> | Readable> => {\n return (\n typeof obj === 'object' &&\n obj !== null &&\n !isEmptyObj(obj) &&\n Object.keys(obj).every(k => hasOwn(requestOptionsKeys, k))\n );\n};\n\nexport const createResponseHeaders = (\n headers: Awaited<ReturnType<Fetch>>['headers'],\n): Record<string, string> => {\n return new Proxy(\n Object.fromEntries(\n // @ts-ignore\n headers.entries(),\n ),\n {\n get(target, name) {\n const key = name.toString();\n return target[key.toLowerCase()] || target[key];\n },\n },\n );\n};\n\nexport const validatePositiveInteger = (name: string, n: unknown): number => {\n if (typeof n !== 'number' || !Number.isInteger(n)) {\n throw new HttpException(`${name} must be an integer`);\n }\n if (n < 0) {\n throw new HttpException(`${name} must be a positive integer`);\n }\n return n;\n};\n","import {\n type Agent,\n fetch,\n getDefaultAgent,\n type HeadersInit,\n type RequestInfo,\n type RequestInit,\n kind as shimsKind,\n} from './_shims/index';\nimport {\n APIConnectionError,\n APIConnectionTimeoutError,\n APIError,\n APIUserAbortError,\n castToError,\n HttpException,\n} from './error';\nimport { Stream } from './streaming';\nimport type {\n APIResponseProps,\n DefaultQuery,\n Fetch,\n FinalRequestOptions,\n HTTPMethod,\n PromiseOrValue,\n ReqHeaders,\n RequestClient,\n RequestOptions,\n} from './types';\nimport { isMultipartBody } from './uploads';\nimport {\n createResponseHeaders,\n debug,\n isAbsoluteURL,\n isEmptyObj,\n safeJSON,\n sleep,\n uuid4,\n validatePositiveInteger,\n} from './util';\n\nexport {\n maybeMultipartFormRequestOptions,\n multipartFormRequestOptions,\n createForm,\n type Uploadable,\n} from './uploads';\n\nexport async function defaultParseResponse<T>(\n props: APIResponseProps,\n): Promise<T> {\n const { response } = props;\n if (props.options.stream) {\n debug(\n 'response',\n response.status,\n response.url,\n response.headers,\n response.body,\n );\n\n // Note: there is an invariant here that isn't represented in the type system\n // that if you set `stream: true` the response type must also be `Stream<T>`\n return Stream.fromSSEResponse(response, props.controller) as any;\n }\n\n // fetch refuses to read the body when the status code is 204.\n if (response.status === 204) {\n return null as T;\n }\n\n if (props.options.__binaryResponse) {\n return response as unknown as T;\n }\n\n const contentType = response.headers.get('content-type');\n if (contentType?.includes('application/json')) {\n const json = await response.json();\n\n debug('response', response.status, response.url, response.headers, json);\n\n return json as T;\n }\n\n const text = await response.text();\n debug('response', response.status, response.url, response.headers, text);\n\n // TODO handle blob, arraybuffer, other content types, etc.\n return text as unknown as T;\n}\n\n/**\n * A subclass of `Promise` providing additional helper methods\n * for interacting with the SDK.\n */\nexport class APIPromise<T> extends Promise<T> {\n private parsedPromise: Promise<T> | undefined;\n\n constructor(\n private responsePromise: Promise<APIResponseProps>,\n private parseResponse: (\n props: APIResponseProps,\n ) => PromiseOrValue<T> = defaultParseResponse,\n ) {\n super(resolve => {\n // this is maybe a bit weird but this has to be a no-op to not implicitly\n // parse the response body; instead .then, .catch, .finally are overridden\n // to parse the response\n resolve(null as any);\n });\n }\n\n _thenUnwrap<U>(transform: (data: T) => U): APIPromise<U> {\n return new APIPromise(this.responsePromise, async props =>\n transform(await this.parseResponse(props)),\n );\n }\n\n /**\n * Gets the raw `Response` instance instead of parsing the response\n * data.\n *\n * If you want to parse the response body but still get the `Response`\n * instance, you can use {@link withResponse()}.\n */\n asResponse(): Promise<Response> {\n return this.responsePromise.then(p => p.response);\n }\n\n /**\n * Gets the parsed response data and the raw `Response` instance.\n *\n * If you just want to get the raw `Response` instance without parsing it,\n * you can use {@link asResponse()}.\n */\n async withResponse(): Promise<{ data: T; response: Response }> {\n const [data, response] = await Promise.all([\n this.parse(),\n this.asResponse(),\n ]);\n return { data, response };\n }\n\n private parse(): Promise<T> {\n if (!this.parsedPromise) {\n this.parsedPromise = this.responsePromise.then(this.parseResponse);\n }\n return this.parsedPromise;\n }\n\n override then<TResult1 = T, TResult2 = never>(\n onfulfilled?:\n | ((value: T) => TResult1 | PromiseLike<TResult1>)\n | undefined\n | null,\n onrejected?:\n | ((reason: any) => TResult2 | PromiseLike<TResult2>)\n | undefined\n | null,\n ): Promise<TResult1 | TResult2> {\n return this.parse().then(onfulfilled, onrejected);\n }\n\n override catch<TResult = never>(\n onrejected?:\n | ((reason: any) => TResult | PromiseLike<TResult>)\n | undefined\n | null,\n ): Promise<T | TResult> {\n return this.parse().catch(onrejected);\n }\n\n override finally(onfinally?: (() => void) | undefined | null): Promise<T> {\n return this.parse().finally(onfinally);\n }\n}\n\nexport interface APIClientOptions {\n baseURL: string;\n maxRetries?: number | undefined;\n timeout?: number | undefined;\n httpAgent?: Agent | undefined;\n fetch?: Fetch | undefined;\n}\n\nexport class APIClient {\n baseURL: string;\n maxRetries: number;\n timeout: number;\n httpAgent: Agent | undefined;\n\n protected fetch: Fetch;\n protected idempotencyHeader?: string;\n\n constructor({\n baseURL,\n maxRetries = 2,\n timeout = 600000, // 10 minutes\n httpAgent,\n fetch: overrideFetch,\n }: APIClientOptions) {\n this.baseURL = baseURL;\n this.maxRetries = validatePositiveInteger('maxRetries', maxRetries);\n this.timeout = validatePositiveInteger('timeout', timeout);\n this.httpAgent = httpAgent;\n\n this.fetch = overrideFetch ?? fetch;\n }\n\n /**\n * Override this to add your own auth headers.\n *\n * ```ts\n * {\n * Authorization: 'Bearer 123',\n * }\n * ```\n */\n protected authHeaders(\n _opts: FinalRequestOptions,\n ): PromiseOrValue<ReqHeaders> {\n return {};\n }\n\n /**\n * Override this to add your own default headers.\n */\n protected async defaultHeaders(\n opts: FinalRequestOptions,\n ): Promise<ReqHeaders> {\n const authHeaders = await this.authHeaders(opts);\n\n return {\n Accept: 'application/json',\n 'Content-Type': 'application/json',\n 'User-Agent': this.getUserAgent(),\n ...authHeaders,\n };\n }\n\n protected defaultQuery(): DefaultQuery | undefined {\n return undefined;\n }\n\n /**\n * Override this to add your own headers validation:\n */\n protected validateHeaders(_headers: ReqHeaders, _customHeaders: ReqHeaders) {}\n\n protected defaultIdempotencyKey(): string {\n return `stainless-node-retry-${uuid4()}`;\n }\n\n get<Req extends NonNullable<unknown>, Rsp>(\n path: string,\n opts?: PromiseOrValue<RequestOptions<Req>>,\n ): APIPromise<Rsp> {\n return this.methodRequest('get', path, opts);\n }\n\n post<Req extends NonNullable<unknown>, Rsp>(\n path: string,\n opts?: PromiseOrValue<RequestOptions<Req>>,\n ): APIPromise<Rsp> {\n return this.methodRequest('post', path, opts);\n }\n\n patch<Req extends NonNullable<unknown>, Rsp>(\n path: string,\n opts?: PromiseOrValue<RequestOptions<Req>>,\n ): APIPromise<Rsp> {\n return this.methodRequest('patch', path, opts);\n }\n\n put<Req extends NonNullable<unknown>, Rsp>(\n path: string,\n opts?: PromiseOrValue<RequestOptions<Req>>,\n ): APIPromise<Rsp> {\n return this.methodRequest('put', path, opts);\n }\n\n delete<Req extends NonNullable<unknown>, Rsp>(\n path: string,\n opts?: PromiseOrValue<RequestOptions<Req>>,\n ): APIPromise<Rsp> {\n return this.methodRequest('delete', path, opts);\n }\n\n private methodRequest<Req extends NonNullable<unknown>, Rsp>(\n method: HTTPMethod,\n path: string,\n opts?: PromiseOrValue<RequestOptions<Req>>,\n ): APIPromise<Rsp> {\n return this.request(\n Promise.resolve(opts).then(opts => ({ method, path, ...opts })),\n );\n }\n\n getAPIList<Item, PageClass extends AbstractPage<Item> = AbstractPage<Item>>(\n path: string,\n Page: new (...args: any[]) => PageClass,\n opts?: RequestOptions<any>,\n ): PagePromise<PageClass, Item> {\n return this.requestAPIList(Page, { method: 'get', path, ...opts });\n }\n\n private calculateContentLength(body: unknown): string | null {\n if (typeof body === 'string') {\n if (typeof Buffer !== 'undefined') {\n return Buffer.byteLength(body, 'utf8').toString();\n }\n\n if (typeof TextEncoder !== 'undefined') {\n const encoder = new TextEncoder();\n const encoded = encoder.encode(body);\n return encoded.length.toString();\n }\n }\n\n return null;\n }\n\n protected async buildRequest<Req extends NonNullable<unknown>>(\n options: FinalRequestOptions<Req>,\n ): Promise<{ req: RequestInit; url: string; timeout: number }> {\n const { method, path, query, headers: headers = {} } = options;\n\n const body = isMultipartBody(options.body)\n ? options.body.body\n : options.body\n ? JSON.stringify(options.body, null, 2)\n : null;\n const contentLength = this.calculateContentLength(body);\n\n const url = this.buildURL(path!, query);\n if ('timeout' in options)\n validatePositiveInteger('timeout', options.timeout);\n const timeout = options.timeout ?? this.timeout;\n const httpAgent =\n options.httpAgent ?? this.httpAgent ?? getDefaultAgent!(url);\n const minAgentTimeout = timeout + 1000;\n if (\n typeof (httpAgent as any)?.options?.timeout === 'number' &&\n minAgentTimeout > ((httpAgent as any).options.timeout ?? 0)\n ) {\n // Allow any given request to bump our agent active socket timeout.\n // This may seem strange, but leaking active sockets should be rare and not particularly problematic,\n // and without mutating agent we would need to create more of them.\n // This tradeoff optimizes for performance.\n (httpAgent as any).options.timeout = minAgentTimeout;\n }\n\n if (this.idempotencyHeader && method !== 'get') {\n if (!options.idempotencyKey)\n options.idempotencyKey = this.defaultIdempotencyKey();\n headers[this.idempotencyHeader] = options.idempotencyKey;\n }\n\n const defaultHeaders = await this.defaultHeaders(options);\n\n const reqHeaders: Record<string, string> = {\n ...(contentLength && { 'Content-Length': contentLength }),\n ...defaultHeaders,\n ...headers,\n };\n // let builtin fetch set the Content-Type for multipart bodies\n if (isMultipartBody(options.body) && shimsKind !== 'node') {\n delete reqHeaders['Content-Type'];\n }\n\n // Strip any headers being explicitly omitted with null\n Object.keys(reqHeaders).forEach(\n key => reqHeaders[key] === null && delete reqHeaders[key],\n );\n\n const req: RequestInit = {\n method,\n ...(body && { body: body as any }),\n headers: reqHeaders,\n duplex: options.duplex,\n ...(httpAgent && { agent: httpAgent }),\n // @ts-ignore node-fetch uses a custom AbortSignal type that is\n // not compatible with standard web types\n signal: options.signal ?? null,\n };\n\n this.validateHeaders(reqHeaders, headers);\n\n return { req, url, timeout };\n }\n\n /**\n * Used as a callback for mutating the given `RequestInit` object.\n *\n * This is useful for cases where you want to add certain headers based off of\n * the request properties, e.g. `method` or `url`.\n */\n protected async prepareRequest(\n _request: RequestInit,\n _config: { url: string; options: FinalRequestOptions },\n ): Promise<void> {}\n\n protected parseHeaders(\n headers: HeadersInit | null | undefined,\n ): Record<string, string> {\n return !headers\n ? {}\n : Symbol.iterator in headers\n ? Object.fromEntries(\n Array.from(headers as Iterable<string[]>).map(header => [\n ...header,\n ]),\n )\n : { ...headers };\n }\n\n protected makeStatusError(\n status: number | undefined,\n error: NonNullable<unknown> | undefined,\n message: string | undefined,\n headers: ReqHeaders | undefined,\n ) {\n return APIError.generate(status, error, message, headers);\n }\n\n request<Req extends NonNullable<unknown>, Rsp>(\n options: PromiseOrValue<FinalRequestOptions<Req>>,\n remainingRetries: number | null = null,\n ): APIPromise<Rsp> {\n return new APIPromise(this.makeRequest(options, remainingRetries));\n }\n\n protected async makeRequest(\n optionsInput: PromiseOrValue<FinalRequestOptions>,\n retriesRemaining: number | null,\n ): Promise<APIResponseProps> {\n const options = await optionsInput;\n if (retriesRemaining == null) {\n retriesRemaining = options.maxRetries ?? this.maxRetries;\n }\n\n const { req, url, timeout } = await this.buildRequest(options);\n\n await this.prepareRequest(req, { url, options });\n\n debug('request', url, options, req.headers);\n\n if (options.signal?.aborted) {\n throw new APIUserAbortError();\n }\n\n const controller = new AbortController();\n const response = await this.fetchWithTimeout(\n url,\n req,\n timeout,\n controller,\n ).catch(castToError);\n\n if (response instanceof Error) {\n if (options.signal?.aborted) {\n throw new APIUserAbortError();\n }\n if (retriesRemaining) {\n return this.retryRequest(options, retriesRemaining);\n }\n if (response.name === 'AbortError') {\n throw new APIConnectionTimeoutError();\n }\n throw new APIConnectionError({ cause: response });\n }\n\n const responseHeaders = createResponseHeaders(response.headers);\n\n if (!response.ok) {\n if (retriesRemaining && this.shouldRetry(response)) {\n return this.retryRequest(options, retriesRemaining, responseHeaders);\n }\n\n const errText = await response.text().catch(e => castToError(e).message);\n const errJSON = safeJSON(errText);\n const errMessage = errJSON ? undefined : errText;\n\n debug('response', response.status, url, responseHeaders, errMessage);\n\n const err = this.makeStatusError(\n response.status,\n errJSON,\n errMessage,\n responseHeaders,\n );\n throw err;\n }\n\n return { response, options, controller };\n }\n\n simple<Req extends NonNullable<unknown>, Rsp>(\n path: string,\n opts?: PromiseOrValue<RequestOptions<Req>>,\n ): APIPromise<Rsp> {\n const optionsInput = Promise.resolve(opts).then<FinalRequestOptions<Req>>(\n opts => ({ method: 'get', path, ...opts }),\n );\n\n return new APIPromise(this.makeSimpleRequest(optionsInput));\n }\n\n protected async makeSimpleRequest(\n optionsInput: PromiseOrValue<FinalRequestOptions>,\n retriesRemaining?: number | null,\n ): Promise<APIResponseProps> {\n const options = await optionsInput;\n if (retriesRemaining == null) {\n retriesRemaining = options.maxRetries ?? this.maxRetries;\n }\n\n const body = isMultipartBody(options.body)\n ? options.body.body\n : options.body\n ? JSON.stringify(options.body, null, 2)\n : null;\n\n // @ts-expect-error\n const url = this.buildURL(options.path!, options.query);\n\n if ('timeout' in options) {\n validatePositiveInteger('timeout', options.timeout);\n }\n\n const timeout = options.timeout ?? this.timeout;\n\n const httpAgent =\n options.httpAgent ?? this.httpAgent ?? getDefaultAgent!(url);\n const minAgentTimeout = timeout + 1000;\n if (\n typeof (httpAgent as any)?.options?.timeout === 'number' &&\n minAgentTimeout > ((httpAgent as any).options.timeout ?? 0)\n ) {\n // Allow any given request to bump our agent active socket timeout.\n // This may seem strange, but leaking active sockets should be rare and not particularly problematic,\n // and without mutating agent we would need to create more of them.\n // This tradeoff optimizes for performance.\n (httpAgent as any).options.timeout = minAgentTimeout;\n }\n\n const req: RequestInit = {\n method: options.method || 'get',\n ...(body && { body: body as any }),\n headers: options.headers,\n ...(httpAgent && { agent: httpAgent }),\n // @ts-ignore node-fetch uses a custom AbortSignal type that is\n // not compatible with standard web types\n signal: options.signal ?? null,\n };\n\n debug('request', url, options, req.headers);\n\n const controller = new AbortController();\n const response = await this.fetchWithTimeout(\n url,\n req,\n timeout,\n controller,\n ).catch(castToError);\n\n if (response instanceof Error) {\n if (req.signal?.aborted) {\n throw new APIUserAbortError();\n }\n\n if (response.name === 'AbortError') {\n throw new APIConnectionTimeoutError();\n }\n throw new APIConnectionError({ cause: response });\n }\n\n const responseHeaders = createResponseHeaders(response.headers);\n\n if (!response.ok) {\n const errText = await response.text().catch(e => castToError(e).message);\n const errJSON = safeJSON(errText);\n const errMessage = errJSON ? undefined : errText;\n\n debug('response', response.status, url, responseHeaders, errMessage);\n\n const err = this.makeStatusError(\n response.status,\n errJSON,\n errMessage,\n responseHeaders,\n );\n throw err;\n }\n\n return { response, options, controller };\n }\n\n requestAPIList<\n Item = unknown,\n PageClass extends AbstractPage<Item> = AbstractPage<Item>,\n >(\n Page: new (\n ...args: ConstructorParameters<typeof AbstractPage>\n ) => PageClass,\n options: FinalRequestOptions,\n ): PagePromise<PageClass, Item> {\n const request = this.makeRequest(options, null);\n return new PagePromise<PageClass, Item>(this, request, Page);\n }\n\n buildURL<Req extends Record<string, unknown>>(\n path: string,\n query: Req | null | undefined,\n ): string {\n const url = isAbsoluteURL(path)\n ? new URL(path)\n : new URL(\n this.baseURL +\n (this.baseURL.endsWith('/') && path.startsWith('/')\n ? path.slice(1)\n : path),\n );\n\n const defaultQuery = this.defaultQuery();\n if (!isEmptyObj(defaultQuery)) {\n query = { ...defaultQuery, ...query } as Req;\n }\n\n if (query) {\n url.search = this.stringifyQuery(query);\n }\n\n return url.toString();\n }\n\n protected stringifyQuery(query: Record<string, unknown>): string {\n return Object.entries(query)\n .filter(([_, value]) => typeof value !== 'undefined')\n .map(([key, value]) => {\n if (\n typeof value === 'string' ||\n typeof value === 'number' ||\n typeof value === 'boolean'\n ) {\n return `${encodeURIComponent(key)}=${encodeURIComponent(value)}`;\n }\n if (value === null) {\n return `${encodeURIComponent(key)}=`;\n }\n throw new HttpException(\n `Cannot stringify type ${typeof value}; Expected string, number, boolean, or null. If you need to pass nested query parameters, you can manually encode them, e.g. { query: { 'foo[key1]': value1, 'foo[key2]': value2 } }, and please open a GitHub issue requesting better support for your use case.`,\n );\n })\n .join('&');\n }\n\n async fetchWithTimeout(\n url: RequestInfo,\n init: RequestInit | undefined,\n ms: number,\n controller: AbortController,\n ): Promise<Response> {\n const { signal, ...options } = init || {};\n if (signal) signal.addEventListener('abort', () => controller.abort());\n\n const timeout = setTimeout(() => controller.abort(), ms);\n\n return (\n this.getRequestClient()\n // use undefined this binding; fetch errors if bound to something else in browser/cloudflare\n .fetch.call(undefined, url, {\n signal: controller.signal as any,\n ...options,\n })\n .finally(() => {\n clearTimeout(timeout);\n })\n );\n }\n\n protected getRequestClient(): RequestClient {\n return { fetch: this.fetch };\n }\n\n private shouldRetry(response: Response): boolean {\n // Note this is not a standard header.\n const shouldRetryHeader = response.headers.get('x-should-retry');\n\n // If the server explicitly says whether or not to retry, obey.\n if (shouldRetryHeader === 'true') return true;\n if (shouldRetryHeader === 'false') return false;\n\n // Retry on request timeouts.\n if (response.status === 408) return true;\n\n // Retry on lock timeouts.\n if (response.status === 409) return true;\n\n // Retry on rate limits.\n if (response.status === 429) return true;\n\n // Retry internal errors.\n if (response.status >= 500) return true;\n\n return false;\n }\n\n private async retryRequest(\n options: FinalRequestOptions,\n retriesRemaining: number,\n responseHeaders?: ReqHeaders | undefined,\n ): Promise<APIResponseProps> {\n // About the Retry-After header: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Retry-After\n let timeoutMillis: number | undefined;\n const retryAfterHeader = responseHeaders?.['retry-after'];\n if (retryAfterHeader) {\n const timeoutSeconds = parseInt(retryAfterHeader);\n if (!Number.isNaN(timeoutSeconds)) {\n timeoutMillis = timeoutSeconds * 1000;\n } else