@nsnanocat/util
Version:
Pure JS's util module for well-known iOS network tools
308 lines (304 loc) • 12.5 kB
JavaScript
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);
}),
]);
}
}
}