UNPKG

@nsnanocat/util

Version:

Pure JS's util module for well-known iOS network tools

308 lines (304 loc) 12.5 kB
import { $app } from "../lib/app.mjs"; import { Console } from "./Console.mjs"; import { Lodash as _ } from "./Lodash.mjs"; import { StatusTexts } from "./StatusTexts.mjs"; /** * 统一请求参数。 * Unified request payload. * * @typedef {object} FetchRequest * @property {string} url 请求地址 / Request URL. * @property {string} [method] 请求方法 / HTTP method. * @property {Record<string, any>} [headers] 请求头 / Request headers. * @property {string|ArrayBuffer|ArrayBufferView|object} [body] 请求体 / Request body. * @property {ArrayBuffer} [bodyBytes] 二进制请求体 / Binary request body. * @property {number|string} [timeout] 超时(秒或毫秒)/ Timeout (seconds or milliseconds). * @property {string} [policy] 指定策略 / Preferred policy. * @property {boolean} [redirection] 是否跟随重定向 / Whether to follow redirects. * @property {boolean} ["auto-redirect"] 平台重定向字段 / Platform redirect flag. * @property {boolean|number|string} ["auto-cookie"] Worker / Node.js Cookie 开关 / Worker / Node.js Cookie toggle. * @property {Record<string, any>} [opts] 平台扩展字段 / Platform extension fields. */ /** * 统一响应结构。 * Unified response payload. * * @typedef {object} FetchResponse * @property {boolean} ok 请求是否成功 / Whether request is successful. * @property {number} status 状态码 / HTTP status code. * @property {number} [statusCode] 状态码别名 / Status code alias. * @property {string} [statusText] 状态文本 / HTTP status text. * @property {Record<string, any>} [headers] 响应头 / Response headers. * @property {string|ArrayBuffer} [body] 响应体 / Response body. * @property {ArrayBuffer} [bodyBytes] 二进制响应体 / Binary response body. */ /** * 跨平台 `fetch` 适配层。 * Cross-platform `fetch` adapter. * * 设计目标: * Design goal: * - 仿照 Web API `fetch`(`Window.fetch`)接口设计 * - Modeled after Web API `fetch` (`Window.fetch`) * - 统一 VPN App、Worker 与 Node.js 环境中的请求调用 * - Unify request calls across VPN apps, Worker, and Node.js * * 功能: * Features: * - 统一 Quantumult X / Loon / Surge / Stash / Egern / Shadowrocket / Worker / Node.js 请求接口 * - Normalize request APIs across Quantumult X / Loon / Surge / Stash / Egern / Shadowrocket / Worker / Node.js * - 统一返回体字段(`ok/status/statusText/body/bodyBytes`) * - Normalize response fields (`ok/status/statusText/body/bodyBytes`) * * 与 Web `fetch` 的已知差异: * Known differences from Web `fetch`: * - 支持 `policy`、`auto-redirect` 等平台扩展字段 * - Supports platform extension fields like `policy` and `auto-redirect` * - Worker / Node.js 共享基于 `fetch` 的请求分支 * - Worker / Node.js share the `fetch`-based request branch * - `auto-cookie` 在 Worker / Node.js 共享分支中识别 * - `auto-cookie` is recognized by the shared Worker / Node.js branch * - 非浏览器平台通过 `$httpClient/$task` 实现,不是原生 Fetch 实现 * - Non-browser platforms use `$httpClient/$task` instead of native Fetch engine * - 返回结构包含 `statusCode/bodyBytes` 等兼容字段 * - Response includes compatibility fields like `statusCode/bodyBytes` * * @link https://developer.mozilla.org/en-US/docs/Web/API/Window/fetch * @link https://developer.mozilla.org/zh-CN/docs/Web/API/Window/fetch * @async * @param {FetchRequest|string} resource 请求对象或 URL / Request object or URL string. * @param {Partial<FetchRequest>} [options={}] 追加参数 / Extra options. * @returns {Promise<FetchResponse>} */ export async function fetch(resource, options = {}) { // 初始化参数。 // Initialize request input. switch (typeof resource) { case "object": resource = { ...options, ...resource }; break; case "string": resource = { ...options, url: resource }; break; case "undefined": default: throw new TypeError(`${Function.name}: 参数类型错误, resource 必须为对象或字符串`); } // 自动判断请求方法。 // Infer the HTTP method automatically. if (!resource.method) { resource.method = "GET"; if (resource.body ?? resource.bodyBytes) resource.method = "POST"; } // 移除需要由底层实现自动生成的请求头。 // Remove headers that should be generated by the underlying runtime. delete resource.headers?.Host; delete resource.headers?.[":authority"]; delete resource.headers?.["Content-Length"]; delete resource.headers?.["content-length"]; // 统一请求方法为小写,方便后续索引平台 API。 // Normalize the method to lowercase for platform API lookups. const method = resource.method.toLocaleLowerCase(); // 默认请求超时时间为 5 秒。 // Default request timeout to 5 seconds. if (!resource.timeout) resource.timeout = 5; // 智能矫正请求超时时间,兼容用户输入的秒或毫秒。 // Normalize timeout input so both seconds and milliseconds are accepted. if (resource.timeout) { resource.timeout = Number.parseInt(resource.timeout, 10); // 统一先转换为秒,大于 500 视为毫秒输入。 // Convert to seconds first and treat values above 500 as milliseconds. if (resource.timeout > 500) resource.timeout = Math.round(resource.timeout / 1000); } // 某些平台要求毫秒级超时,进行二次换算。 // Some platforms expect timeout in milliseconds, so convert again. if (resource.timeout) { switch ($app) { case "Loon": case "Quantumult X": case "Worker": case "Node.js": // 这些平台要求毫秒,因此把秒重新换算为毫秒。 // These platforms expect milliseconds, so convert seconds back to milliseconds. resource.timeout = resource.timeout * 1000; break; case "Shadowrocket": case "Stash": case "Egern": case "Surge": default: break; } } // 根据当前平台选择请求实现。 // Select the request engine for the current platform. switch ($app) { case "Loon": case "Surge": case "Stash": case "Egern": case "Shadowrocket": default: // 转换通用请求参数到 `$httpClient` 语义。 // Map shared request fields to `$httpClient` semantics. if (resource.policy) { switch ($app) { case "Loon": resource.node = resource.policy; break; case "Stash": _.set(resource, "headers.X-Stash-Selected-Proxy", encodeURI(resource.policy)); break; case "Shadowrocket": _.set(resource, "headers.X-Surge-Proxy", resource.policy); break; } } if (typeof resource.redirection === "boolean") resource["auto-redirect"] = resource.redirection; // 优先把 `bodyBytes` 映射回 `$httpClient` 能接受的 `body`。 // Prefer mapping `bodyBytes` back to the `body` field expected by `$httpClient`. if (resource.bodyBytes && !resource.body) { resource.body = resource.bodyBytes; resource.bodyBytes = undefined; } // 根据 `Accept` 推断是否需要二进制响应体。 // Infer whether the response should be treated as binary from `Accept`. switch ((resource.headers?.Accept || resource.headers?.accept)?.split(";")?.[0]) { case "application/protobuf": case "application/x-protobuf": case "application/vnd.google.protobuf": case "application/vnd.apple.flatbuffer": case "application/grpc": case "application/grpc+proto": case "application/octet-stream": resource["binary-mode"] = true; break; } // 发送 `$httpClient` 请求并归一化返回结构。 // Send the `$httpClient` request and normalize the response payload. return new Promise((resolve, reject) => { globalThis.$httpClient[method](resource, (error, response, body) => { if (error) reject(error); else { response.ok = /^2\d\d$/.test(response.status); response.statusCode = response.status; response.statusText = StatusTexts[response.status]; if (body) { response.body = body; if (resource["binary-mode"] == true) response.bodyBytes = body; } resolve(response); } }); }); case "Quantumult X": // 转换 Quantumult X 专有请求参数。 // Map request fields to Quantumult X specific options. if (resource.policy) _.set(resource, "opts.policy", resource.policy); if (typeof resource["auto-redirect"] === "boolean") _.set(resource, "opts.redirection", resource["auto-redirect"]); // Quantumult X 使用 `bodyBytes` 传输二进制请求体。 // Quantumult X uses `bodyBytes` for binary request payloads. if (resource.body instanceof ArrayBuffer) { resource.bodyBytes = resource.body; resource.body = undefined; } else if (ArrayBuffer.isView(resource.body)) { resource.bodyBytes = resource.body.buffer.slice(resource.body.byteOffset, resource.body.byteLength + resource.body.byteOffset); resource.body = undefined; } else if (resource.body) resource.bodyBytes = undefined; // 发送请求,并用 `Promise.race` 提供统一超时保护。 // Send the request and enforce timeout with `Promise.race`. return Promise.race([ globalThis.$task.fetch(resource).then( response => { response.ok = /^2\d\d$/.test(response.statusCode); response.status = response.statusCode; response.statusText = StatusTexts[response.status]; switch ((response.headers?.["Content-Type"] ?? response.headers?.["content-type"])?.split(";")?.[0]) { case "application/protobuf": case "application/x-protobuf": case "application/vnd.google.protobuf": case "application/vnd.apple.flatbuffer": case "application/grpc": case "application/grpc+proto": case "application/octet-stream": response.body = response.bodyBytes; break; case undefined: default: break; } response.bodyBytes = undefined; return response; }, reason => Promise.reject(reason.error), ), new Promise((resolve, reject) => { setTimeout(() => { reject(new Error(`${Function.name}: 请求超时, 请检查网络后重试`)); }, resource.timeout); }), ]); case "Worker": case "Node.js": { // Worker 复用宿主 `fetch`;Node.js 优先复用原生 `fetch`,缺失时再回退到 `node-fetch`。 // Worker reuses host `fetch`; Node.js reuses native `fetch` first and falls back to `node-fetch`. if (!globalThis.fetch) globalThis.fetch = require("node-fetch"); switch (resource["auto-cookie"]) { case undefined: case "true": case true: case "1": case 1: default: // 仅在尚未包裹 CookieJar 时注入 `fetch-cookie`,避免重复包装。 // Inject `fetch-cookie` only once when a cookie jar is not already attached. if (!globalThis.fetch?.cookieJar) globalThis.fetch = require("fetch-cookie").default(globalThis.fetch); break; case "false": case false: case "0": case 0: case "-1": case -1: break; } // 将通用字段映射到 Worker / Node.js Fetch 语义。 // Map shared fields to Worker / Node.js Fetch semantics. resource.redirect = resource.redirection ? "follow" : "manual"; const { url, ...options } = resource; // 发起请求并归一化响应头、文本与二进制响应体。 // Send the request and normalize headers, text, and binary response data. return Promise.race([ globalThis .fetch(url, options) .then(async response => { const bodyBytes = await response.arrayBuffer(); let headers; try { headers = response.headers.raw(); } catch { headers = Array.from(response.headers.entries()).reduce((acc, [key, value]) => { acc[key] = acc[key] ? [...acc[key], value] : [value]; return acc; }, {}); } return { ok: response.ok ?? /^2\d\d$/.test(response.status), status: response.status, statusCode: response.status, statusText: response.statusText, body: new TextDecoder("utf-8").decode(bodyBytes), bodyBytes: bodyBytes, headers: Object.fromEntries(Object.entries(headers).map(([key, value]) => [key, key.toLowerCase() !== "set-cookie" ? value.toString() : value])), }; }) .catch(error => Promise.reject(error.message)), new Promise((resolve, reject) => { setTimeout(() => { reject(new Error(`${Function.name}: 请求超时, 请检查网络后重试`)); }, resource.timeout); }), ]); } } }