@nsnanocat/util 2.6.4 → 2.6.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -655,9 +655,11 @@ console.log(value); // 1
655
655
 
656
656
  当前行为:
657
657
  - 当 `query` 为 `string` 时:
658
+ - 支持前导 `?`(会先去掉)。
658
659
  - 按 `&` / `=` 切分。
660
+ - key 与 value 都会先执行 URL 解码(`decodeURIComponent`,并将 `+` 视为空格)。
659
661
  - 去掉值中的双引号。
660
- - 使用点路径或数组下标路径展开对象。
662
+ - 支持点路径、数组下标路径,以及方括号 key(如 `a[b]`)展开对象。
661
663
  - 当 `query` 为 `object` 时:
662
664
  - 将 key 当路径写入新对象(`{"a.b":"1"}` -> `{ a: { b: "1" } }`)。
663
665
  - 当 `query` 为 `null` 或 `undefined` 时:
@@ -669,6 +671,9 @@ import { qs } from "@nsnanocat/util";
669
671
  console.log(qs.parse("mode=on&a.b=1"));
670
672
  // { mode: "on", a: { b: "1" } }
671
673
 
674
+ console.log(qs.parse("a%5Bb%5D=c%20d"));
675
+ // { a: { b: "c d" } }
676
+
672
677
  console.log(qs.parse({ "list[0]": "x", "list[1]": "y" }));
673
678
  // { list: ["x", "y"] }
674
679
  ```
package/package.json CHANGED
@@ -63,5 +63,5 @@
63
63
  "registry": "https://registry.npmjs.org/",
64
64
  "access": "public"
65
65
  },
66
- "version": "2.6.4"
66
+ "version": "2.6.8"
67
67
  }
@@ -102,7 +102,9 @@ export class Storage {
102
102
  keyValue = Storage.data[keyName];
103
103
  break;
104
104
  case "Node.js":
105
- throw new Error(`${Storage.name}.getItem: ESM 版本不支持 Node.js,请改用 CJS 入口`);
105
+ Storage.data = Storage.#loaddata(Storage.dataFile);
106
+ keyValue = Storage.data?.[keyName];
107
+ break;
106
108
  default:
107
109
  keyValue = Storage.data?.[keyName] || null;
108
110
  break;
@@ -163,7 +165,11 @@ export class Storage {
163
165
  result = true;
164
166
  break;
165
167
  case "Node.js":
166
- throw new Error(`${Storage.name}.setItem: ESM 版本不支持 Node.js,请改用 CJS 入口`);
168
+ Storage.data = Storage.#loaddata(Storage.dataFile);
169
+ Storage.data[keyName] = keyValue;
170
+ Storage.#writedata(Storage.dataFile);
171
+ result = true;
172
+ break;
167
173
  default:
168
174
  result = Storage.data?.[keyName] || null;
169
175
  break;
@@ -218,7 +224,12 @@ export class Storage {
218
224
  result = true;
219
225
  break;
220
226
  case "Node.js":
221
- throw new Error(`${Storage.name}.removeItem: ESM 版本不支持 Node.js,请改用 CJS 入口`);
227
+ // result = false;
228
+ Storage.data = Storage.#loaddata(Storage.dataFile);
229
+ delete Storage.data[keyName];
230
+ Storage.#writedata(Storage.dataFile);
231
+ result = true;
232
+ break;
222
233
  default:
223
234
  result = false;
224
235
  break;
@@ -252,7 +263,12 @@ export class Storage {
252
263
  result = true;
253
264
  break;
254
265
  case "Node.js":
255
- throw new Error(`${Storage.name}.clear: ESM 版本不支持 Node.js,请改用 CJS 入口`);
266
+ // result = false;
267
+ Storage.data = Storage.#loaddata(Storage.dataFile);
268
+ Storage.data = {};
269
+ Storage.#writedata(Storage.dataFile);
270
+ result = true;
271
+ break;
256
272
  default:
257
273
  result = false;
258
274
  break;
@@ -260,4 +276,57 @@ export class Storage {
260
276
  return result;
261
277
  }
262
278
 
279
+ /**
280
+ * 从 Node.js 数据文件加载 JSON。
281
+ * Load JSON data from Node.js data file.
282
+ *
283
+ * @private
284
+ * @param {string} dataFile 数据文件名 / Data file name.
285
+ * @returns {Record<string, any>}
286
+ */
287
+ static #loaddata = dataFile => {
288
+ if ($app === "Node.js") {
289
+ this.fs = this.fs ? this.fs : require("fs");
290
+ this.path = this.path ? this.path : require("path");
291
+ const curDirDataFilePath = this.path.resolve(dataFile);
292
+ const rootDirDataFilePath = this.path.resolve(process.cwd(), dataFile);
293
+ const isCurDirDataFile = this.fs.existsSync(curDirDataFilePath);
294
+ const isRootDirDataFile = !isCurDirDataFile && this.fs.existsSync(rootDirDataFilePath);
295
+ if (isCurDirDataFile || isRootDirDataFile) {
296
+ const datPath = isCurDirDataFile ? curDirDataFilePath : rootDirDataFilePath;
297
+ try {
298
+ return JSON.parse(this.fs.readFileSync(datPath));
299
+ } catch (e) {
300
+ return {};
301
+ }
302
+ } else return {};
303
+ } else return {};
304
+ };
305
+
306
+ /**
307
+ * 将内存数据写入 Node.js 数据文件。
308
+ * Persist in-memory data to Node.js data file.
309
+ *
310
+ * @private
311
+ * @param {string} [dataFile=this.dataFile] 数据文件名 / Data file name.
312
+ * @returns {void}
313
+ */
314
+ static #writedata = (dataFile = this.dataFile) => {
315
+ if ($app === "Node.js") {
316
+ this.fs = this.fs ? this.fs : require("fs");
317
+ this.path = this.path ? this.path : require("path");
318
+ const curDirDataFilePath = this.path.resolve(dataFile);
319
+ const rootDirDataFilePath = this.path.resolve(process.cwd(), dataFile);
320
+ const isCurDirDataFile = this.fs.existsSync(curDirDataFilePath);
321
+ const isRootDirDataFile = !isCurDirDataFile && this.fs.existsSync(rootDirDataFilePath);
322
+ const jsondata = JSON.stringify(this.data);
323
+ if (isCurDirDataFile) {
324
+ this.fs.writeFileSync(curDirDataFilePath, jsondata);
325
+ } else if (isRootDirDataFile) {
326
+ this.fs.writeFileSync(rootDirDataFilePath, jsondata);
327
+ } else {
328
+ this.fs.writeFileSync(curDirDataFilePath, jsondata);
329
+ }
330
+ }
331
+ };
263
332
  }
@@ -16,6 +16,7 @@ import { StatusTexts } from "./StatusTexts.mjs";
16
16
  * @property {string} [policy] 指定策略 / Preferred policy.
17
17
  * @property {boolean} [redirection] 是否跟随重定向 / Whether to follow redirects.
18
18
  * @property {boolean} ["auto-redirect"] 平台重定向字段 / Platform redirect flag.
19
+ * @property {boolean|number|string} ["auto-cookie"] Worker / Node.js Cookie 开关 / Worker / Node.js Cookie toggle.
19
20
  * @property {Record<string, any>} [opts] 平台扩展字段 / Platform extension fields.
20
21
  */
21
22
 
@@ -34,8 +35,35 @@ import { StatusTexts } from "./StatusTexts.mjs";
34
35
  */
35
36
 
36
37
  /**
37
- * 仅面向 iOS 脚本平台的 `fetch` 适配层(ESM 版本)。
38
- * `fetch` adapter for iOS script platforms only (ESM version).
38
+ * 跨平台 `fetch` 适配层。
39
+ * Cross-platform `fetch` adapter.
40
+ *
41
+ * 设计目标:
42
+ * Design goal:
43
+ * - 仿照 Web API `fetch`(`Window.fetch`)接口设计
44
+ * - Modeled after Web API `fetch` (`Window.fetch`)
45
+ * - 统一 VPN App、Worker 与 Node.js 环境中的请求调用
46
+ * - Unify request calls across VPN apps, Worker, and Node.js
47
+ *
48
+ * 功能:
49
+ * Features:
50
+ * - 统一 Quantumult X / Loon / Surge / Stash / Egern / Shadowrocket / Worker / Node.js 请求接口
51
+ * - Normalize request APIs across Quantumult X / Loon / Surge / Stash / Egern / Shadowrocket / Worker / Node.js
52
+ * - 统一返回体字段(`ok/status/statusText/body/bodyBytes`)
53
+ * - Normalize response fields (`ok/status/statusText/body/bodyBytes`)
54
+ *
55
+ * 与 Web `fetch` 的已知差异:
56
+ * Known differences from Web `fetch`:
57
+ * - 支持 `policy`、`auto-redirect` 等平台扩展字段
58
+ * - Supports platform extension fields like `policy` and `auto-redirect`
59
+ * - Worker / Node.js 共享基于 `fetch` 的请求分支
60
+ * - Worker / Node.js share the `fetch`-based request branch
61
+ * - `auto-cookie` 在 Worker / Node.js 共享分支中识别
62
+ * - `auto-cookie` is recognized by the shared Worker / Node.js branch
63
+ * - 非浏览器平台通过 `$httpClient/$task` 实现,不是原生 Fetch 实现
64
+ * - Non-browser platforms use `$httpClient/$task` instead of native Fetch engine
65
+ * - 返回结构包含 `statusCode/bodyBytes` 等兼容字段
66
+ * - Response includes compatibility fields like `statusCode/bodyBytes`
39
67
  *
40
68
  * @link https://developer.mozilla.org/en-US/docs/Web/API/Window/fetch
41
69
  * @link https://developer.mozilla.org/zh-CN/docs/Web/API/Window/fetch
@@ -45,6 +73,8 @@ import { StatusTexts } from "./StatusTexts.mjs";
45
73
  * @returns {Promise<FetchResponse>}
46
74
  */
47
75
  export async function fetch(resource, options = {}) {
76
+ // 初始化参数。
77
+ // Initialize request input.
48
78
  switch (typeof resource) {
49
79
  case "object":
50
80
  resource = { ...options, ...resource };
@@ -56,39 +86,58 @@ export async function fetch(resource, options = {}) {
56
86
  default:
57
87
  throw new TypeError(`${Function.name}: 参数类型错误, resource 必须为对象或字符串`);
58
88
  }
59
-
89
+ // 自动判断请求方法。
90
+ // Infer the HTTP method automatically.
60
91
  if (!resource.method) {
61
92
  resource.method = "GET";
62
93
  if (resource.body ?? resource.bodyBytes) resource.method = "POST";
63
94
  }
95
+ // 移除需要由底层实现自动生成的请求头。
96
+ // Remove headers that should be generated by the underlying runtime.
64
97
  delete resource.headers?.Host;
65
98
  delete resource.headers?.[":authority"];
66
99
  delete resource.headers?.["Content-Length"];
67
100
  delete resource.headers?.["content-length"];
101
+ // 统一请求方法为小写,方便后续索引平台 API。
102
+ // Normalize the method to lowercase for platform API lookups.
68
103
  const method = resource.method.toLocaleLowerCase();
69
-
104
+ // 默认请求超时时间为 5 秒。
105
+ // Default request timeout to 5 seconds.
70
106
  if (!resource.timeout) resource.timeout = 5;
71
107
  if (resource.timeout) {
72
108
  resource.timeout = Number.parseInt(resource.timeout, 10);
109
+ // 统一先转换为秒,大于 500 视为毫秒输入。
110
+ // Convert to seconds first and treat values above 500 as milliseconds.
73
111
  if (resource.timeout > 500) resource.timeout = Math.round(resource.timeout / 1000);
74
112
  }
75
113
  if (resource.timeout) {
76
114
  switch ($app) {
77
115
  case "Loon":
78
116
  case "Quantumult X":
117
+ case "Worker":
118
+ case "Node.js":
119
+ // 这些平台要求毫秒,因此把秒重新换算为毫秒。
120
+ // These platforms expect milliseconds, so convert seconds back to milliseconds.
79
121
  resource.timeout = resource.timeout * 1000;
80
122
  break;
123
+ case "Shadowrocket":
124
+ case "Stash":
125
+ case "Egern":
126
+ case "Surge":
81
127
  default:
82
128
  break;
83
129
  }
84
130
  }
85
-
131
+ // 根据当前平台选择请求实现。
132
+ // Select the request engine for the current platform.
86
133
  switch ($app) {
87
134
  case "Loon":
88
135
  case "Surge":
89
136
  case "Stash":
90
137
  case "Egern":
91
138
  case "Shadowrocket":
139
+ // 转换通用请求参数到 `$httpClient` 语义。
140
+ // Map shared request fields to `$httpClient` semantics.
92
141
  if (resource.policy) {
93
142
  switch ($app) {
94
143
  case "Loon":
@@ -103,21 +152,28 @@ export async function fetch(resource, options = {}) {
103
152
  }
104
153
  }
105
154
  if (typeof resource.redirection === "boolean") resource["auto-redirect"] = resource.redirection;
155
+ // 优先把 `bodyBytes` 映射回 `$httpClient` 能接受的 `body`。
156
+ // Prefer mapping `bodyBytes` back to the `body` field expected by `$httpClient`.
106
157
  if (resource.bodyBytes && !resource.body) {
107
158
  resource.body = resource.bodyBytes;
108
159
  resource.bodyBytes = undefined;
109
160
  }
161
+ // 根据 `Accept` 推断是否需要二进制响应体。
162
+ // Infer whether the response should be treated as binary from `Accept`.
110
163
  switch ((resource.headers?.Accept || resource.headers?.accept)?.split(";")?.[0]) {
111
164
  case "application/protobuf":
112
165
  case "application/x-protobuf":
113
166
  case "application/vnd.google.protobuf":
114
167
  case "application/vnd.apple.flatbuffer":
115
168
  case "application/grpc":
169
+ case "application/grpc-web":
116
170
  case "application/grpc+proto":
117
171
  case "application/octet-stream":
118
172
  resource["binary-mode"] = true;
119
173
  break;
120
174
  }
175
+ // 发送 `$httpClient` 请求并归一化返回结构。
176
+ // Send the `$httpClient` request and normalize the response payload.
121
177
  return new Promise((resolve, reject) => {
122
178
  globalThis.$httpClient[method](resource, (error, response, body) => {
123
179
  if (error) reject(error);
@@ -134,8 +190,12 @@ export async function fetch(resource, options = {}) {
134
190
  });
135
191
  });
136
192
  case "Quantumult X":
193
+ // 转换 Quantumult X 专有请求参数。
194
+ // Map request fields to Quantumult X specific options.
137
195
  if (resource.policy) _.set(resource, "opts.policy", resource.policy);
138
196
  if (typeof resource["auto-redirect"] === "boolean") _.set(resource, "opts.redirection", resource["auto-redirect"]);
197
+ // Quantumult X 使用 `bodyBytes` 传输二进制请求体。
198
+ // Quantumult X uses `bodyBytes` for binary request payloads.
139
199
  if (resource.body instanceof ArrayBuffer) {
140
200
  resource.bodyBytes = resource.body;
141
201
  resource.body = undefined;
@@ -143,6 +203,8 @@ export async function fetch(resource, options = {}) {
143
203
  resource.bodyBytes = resource.body.buffer.slice(resource.body.byteOffset, resource.body.byteLength + resource.body.byteOffset);
144
204
  resource.body = undefined;
145
205
  } else if (resource.body) resource.bodyBytes = undefined;
206
+ // 发送请求,并用 `Promise.race` 提供统一超时保护。
207
+ // Send the request and enforce timeout with `Promise.race`.
146
208
  return Promise.race([
147
209
  globalThis.$task.fetch(resource).then(
148
210
  response => {
@@ -155,6 +217,7 @@ export async function fetch(resource, options = {}) {
155
217
  case "application/vnd.google.protobuf":
156
218
  case "application/vnd.apple.flatbuffer":
157
219
  case "application/grpc":
220
+ case "application/grpc-web":
158
221
  case "application/grpc+proto":
159
222
  case "application/octet-stream":
160
223
  response.body = response.bodyBytes;
@@ -175,8 +238,67 @@ export async function fetch(resource, options = {}) {
175
238
  }),
176
239
  ]);
177
240
  case "Worker":
178
- case "Node.js":
179
- throw new Error(`${Function.name}: ESM 版本不支持 Worker/Node.js,请改用 CJS 入口`);
241
+ case "Node.js": {
242
+ // Worker 复用宿主 `fetch`;Node.js 优先复用原生 `fetch`,缺失时再回退到 `node-fetch`。
243
+ // Worker reuses host `fetch`; Node.js reuses native `fetch` first and falls back to `node-fetch`.
244
+ if (!globalThis.fetch) globalThis.fetch = require("node-fetch");
245
+ switch (resource["auto-cookie"]) {
246
+ case undefined:
247
+ case "true":
248
+ case true:
249
+ case "1":
250
+ case 1:
251
+ default:
252
+ // 仅在尚未包裹 CookieJar 时注入 `fetch-cookie`,避免重复包装。
253
+ // Inject `fetch-cookie` only once when a cookie jar is not already attached.
254
+ if (!globalThis.fetch?.cookieJar) globalThis.fetch = require("fetch-cookie").default(globalThis.fetch);
255
+ break;
256
+ case "false":
257
+ case false:
258
+ case "0":
259
+ case 0:
260
+ case "-1":
261
+ case -1:
262
+ break;
263
+ }
264
+ // 将通用字段映射到 Worker / Node.js Fetch 语义。
265
+ // Map shared fields to Worker / Node.js Fetch semantics.
266
+ resource.redirect = resource.redirection ? "follow" : "manual";
267
+ const { url, ...options } = resource;
268
+ // 发起请求并归一化响应头、文本与二进制响应体。
269
+ // Send the request and normalize headers, text, and binary response data.
270
+ return Promise.race([
271
+ globalThis
272
+ .fetch(url, options)
273
+ .then(async response => {
274
+ const bodyBytes = await response.arrayBuffer();
275
+ let headers;
276
+ try {
277
+ headers = response.headers.raw();
278
+ } catch {
279
+ headers = Array.from(response.headers.entries()).reduce((acc, [key, value]) => {
280
+ acc[key] = acc[key] ? [...acc[key], value] : [value];
281
+ return acc;
282
+ }, {});
283
+ }
284
+ return {
285
+ ok: response.ok ?? /^2\d\d$/.test(response.status),
286
+ status: response.status,
287
+ statusCode: response.status,
288
+ statusText: response.statusText,
289
+ body: new TextDecoder("utf-8").decode(bodyBytes),
290
+ bodyBytes: bodyBytes,
291
+ headers: Object.fromEntries(Object.entries(headers).map(([key, value]) => [key, key.toLowerCase() !== "set-cookie" ? value.toString() : value])),
292
+ };
293
+ })
294
+ .catch(error => Promise.reject(error.message)),
295
+ new Promise((resolve, reject) => {
296
+ setTimeout(() => {
297
+ reject(new Error(`${Function.name}: 请求超时, 请检查网络后重试`));
298
+ }, resource.timeout);
299
+ }),
300
+ ]);
301
+ }
180
302
  default:
181
303
  throw new Error(`${Function.name}: 当前平台不支持`);
182
304
  }
package/polyfill/qs.mjs CHANGED
@@ -31,7 +31,18 @@ export class qs {
31
31
  let result = {};
32
32
  switch (typeof query) {
33
33
  case "string": {
34
- const obj = Object.fromEntries(query.split("&").map(item => item.split("=", 2).map(i => i.replace(/\"/g, ""))));
34
+ const source = query.replace(/^\?/, "");
35
+ if (!source) break;
36
+ const obj = Object.fromEntries(
37
+ source
38
+ .split("&")
39
+ .filter(Boolean)
40
+ .map(item => {
41
+ const [rawKey = "", rawValue = ""] = item.split("=", 2);
42
+ const key = qs.#decode(rawKey).replace(/\[([^\[\]]+)\]/g, ".$1");
43
+ return [key, qs.#decode(rawValue).replace(/\"/g, "")];
44
+ }),
45
+ );
35
46
  Object.keys(obj).forEach(key => _.set(result, key, obj[key]));
36
47
  break;
37
48
  }
@@ -139,4 +150,15 @@ export class qs {
139
150
  static #encode(value) {
140
151
  return encodeURIComponent(value);
141
152
  }
153
+
154
+ /**
155
+ * 解码查询字符串片段。
156
+ * Decode a query-string fragment.
157
+ *
158
+ * @param {string} value 编码值 / Encoded value.
159
+ * @returns {string}
160
+ */
161
+ static #decode(value) {
162
+ return decodeURIComponent(value.replace(/\+/g, " "));
163
+ }
142
164
  }