@nsnanocat/preference-panes 0.2.0 → 0.3.0

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/package.json CHANGED
@@ -1,66 +1,66 @@
1
1
  {
2
- "name": "@nsnanocat/preference-panes",
3
- "version": "0.2.0",
4
- "description": "Shared settings API runtime for JavaScript proxy modules",
5
- "author": "VirgilClyne <Virgil@nanocat.me>",
6
- "homepage": "https://NSNanoCat.github.io/preference-panes",
7
- "keywords": [
8
- "loon",
9
- "quantumult",
10
- "surge",
11
- "shadowrocket",
12
- "stash",
13
- "egern",
14
- "settings"
15
- ],
16
- "license": "Apache-2.0",
17
- "bugs": "https://github.com/NSNanoCat/PreferencePanes/issues",
18
- "type": "module",
19
- "main": "src/index.mjs",
20
- "exports": {
21
- ".": {
22
- "types": "./src/index.d.ts",
23
- "import": "./src/index.mjs"
24
- },
25
- "./browser": {
26
- "types": "./src/browser/index.d.ts",
27
- "import": "./src/browser/index.mjs"
28
- },
29
- "./browser/panel.css": "./src/browser/panel.css",
30
- "./dist/preference-panes.mjs": "./dist/preference-panes.mjs",
31
- "./dist/preference-panes.request.js": "./dist/preference-panes.request.js"
32
- },
33
- "types": "src/index.d.ts",
34
- "scripts": {
35
- "test": "node --test test/*.test.mjs",
36
- "check": "npm run lint && npm run typecheck && npm test",
37
- "lint": "biome check .",
38
- "format": "biome check --write .",
39
- "typecheck": "tsc --noEmit",
40
- "build": "rollup -c",
41
- "prepack": "npm run build",
42
- "apifox:generate": "node apifox/generate.mjs",
43
- "apifox:check": "node apifox/generate.mjs --check"
44
- },
45
- "repository": {
46
- "type": "git",
47
- "url": "git+https://github.com/NSNanoCat/PreferencePanes.git"
48
- },
49
- "files": [
50
- "src",
51
- "dist"
52
- ],
53
- "dependencies": {
54
- "@nsnanocat/url": "^1.2.6",
55
- "@nsnanocat/util": "^2.7.4"
56
- },
57
- "devDependencies": {
58
- "@biomejs/biome": "2.4.6",
59
- "@rollup/plugin-node-resolve": "^16.0.3",
60
- "rollup": "^4.63.1",
61
- "typescript": "^5.9.3"
62
- },
63
- "publishConfig": {
64
- "access": "public"
65
- }
2
+ "name": "@nsnanocat/preference-panes",
3
+ "version": "0.3.0",
4
+ "description": "Shared settings API runtime for JavaScript proxy modules",
5
+ "author": "VirgilClyne <Virgil@nanocat.me>",
6
+ "homepage": "https://NSNanoCat.github.io/preference-panes",
7
+ "keywords": [
8
+ "loon",
9
+ "quantumult",
10
+ "surge",
11
+ "shadowrocket",
12
+ "stash",
13
+ "egern",
14
+ "settings"
15
+ ],
16
+ "license": "Apache-2.0",
17
+ "bugs": "https://github.com/NSNanoCat/PreferencePanes/issues",
18
+ "type": "module",
19
+ "main": "src/index.mjs",
20
+ "exports": {
21
+ ".": {
22
+ "types": "./src/index.d.ts",
23
+ "import": "./src/index.mjs"
24
+ },
25
+ "./browser": {
26
+ "types": "./src/browser/index.d.ts",
27
+ "import": "./src/browser/index.mjs"
28
+ },
29
+ "./browser/panel.css": "./src/browser/panel.css",
30
+ "./dist/preference-panes.mjs": "./dist/preference-panes.mjs",
31
+ "./dist/preference-panes.request.js": "./dist/preference-panes.request.js"
32
+ },
33
+ "types": "src/index.d.ts",
34
+ "scripts": {
35
+ "test": "node --test test/*.test.mjs",
36
+ "check": "npm run lint && npm run typecheck && npm test",
37
+ "lint": "biome check .",
38
+ "format": "biome check --write .",
39
+ "typecheck": "tsc --noEmit",
40
+ "build": "rollup -c",
41
+ "prepack": "npm run build",
42
+ "apifox:generate": "node apifox/generate.mjs",
43
+ "apifox:check": "node apifox/generate.mjs --check"
44
+ },
45
+ "repository": {
46
+ "type": "git",
47
+ "url": "git+https://github.com/NSNanoCat/PreferencePanes.git"
48
+ },
49
+ "files": [
50
+ "src",
51
+ "dist"
52
+ ],
53
+ "dependencies": {
54
+ "@nsnanocat/url": "^1.2.6",
55
+ "@nsnanocat/util": "^2.7.4"
56
+ },
57
+ "devDependencies": {
58
+ "@biomejs/biome": "2.4.6",
59
+ "@rollup/plugin-node-resolve": "^16.0.3",
60
+ "rollup": "^4.63.1",
61
+ "typescript": "^5.9.3"
62
+ },
63
+ "publishConfig": {
64
+ "access": "public"
65
+ }
66
66
  }
@@ -1,138 +1,142 @@
1
1
  import { URL } from "@nsnanocat/url";
2
- import { fetch } from "@nsnanocat/util/polyfill/fetch";
3
2
  import { Lodash as _ } from "@nsnanocat/util/polyfill/Lodash.mjs";
4
3
  import { Storage } from "@nsnanocat/util/polyfill/Storage";
5
- import { normalizeBoxJs, validValue } from "./lib/boxjs.mjs";
6
- import { parseSettingsPath } from "./lib/settings-path.mjs";
4
+ import { parseSettingsPathname, validatePathParts } from "./lib/settings-path.mjs";
7
5
 
8
6
  /**
9
- * 使用 util 下载 BoxJS、校验字段并读写持久化存储。
10
- * Download BoxJS through util, validate fields and handle persistent storage.
7
+ * 按插件声明的根和模块桥接持久化存储,不下载或解析 BoxJS
8
+ * Bridge persistence within the installed root and module without downloading or parsing BoxJS.
11
9
  */
12
10
  export class SettingsHandler {
13
- #origin;
14
- #configURL;
15
- #requestHeader;
16
- #resolveSettings;
11
+ /** @type {string} 接管来源 / Handled origin. */
12
+ #origin;
13
+ /** @type {string} 安装配置中的存储根 / Storage root from installation config. */
14
+ #storageKey;
15
+ /** @type {string} 安装配置中的模块 / Module from installation config. */
16
+ #module;
17
+ /** @type {string} 页面标记头 / Page marker header. */
18
+ #requestHeader;
17
19
 
18
- /** @param {import("./index.js").SettingsHandlerOptions} options 来源、配置地址与 GET 解析器 / Origin, config source and GET resolver. */
19
- constructor({ origin, configURL, requestHeader = "X-Settings-Client", resolveSettings }) {
20
- const target = new URL(origin);
21
- if (target.protocol !== "https:" || target.pathname !== "/" || target.search || target.hash || target.username || target.password)
22
- throw new TypeError("origin must be an HTTPS origin");
23
- const source = new URL(configURL);
24
- if (source.protocol !== "https:" || source.username || source.password || source.hash)
25
- throw new TypeError("configURL must be an HTTPS URL without credentials or fragment");
26
- if (!/^[a-z][a-z0-9-]*$/i.test(requestHeader)) throw new TypeError("Invalid requestHeader");
27
- this.#origin = target.origin;
28
- this.#configURL = source.href;
29
- this.#requestHeader = requestHeader;
30
- this.#resolveSettings = resolveSettings;
31
- }
20
+ /**
21
+ * 固定来源、存储根和模块,构造时不访问网络或存储。
22
+ * Fix the origin, storage root and module without network or storage access at construction.
23
+ * @param {import("./index.js").SettingsHandlerOptions} options 插件安装配置 / Plugin installation config.
24
+ * @throws {TypeError} 安装配置无效 / Invalid installation config.
25
+ */
26
+ constructor({ origin, storageKey, module, requestHeader = "X-Settings-Client" }) {
27
+ const target = new URL(origin);
28
+ if (target.protocol !== "https:" || target.pathname !== "/" || target.search || target.hash || target.username || target.password) throw new TypeError("origin must be an HTTPS origin");
29
+ if (typeof storageKey !== "string" || !storageKey || storageKey.startsWith("@")) throw new TypeError("storageKey must be a literal root key");
30
+ validatePathParts([module]);
31
+ if (!/^[a-z][a-z0-9-]*$/i.test(requestHeader)) throw new TypeError("Invalid requestHeader");
32
+ this.#origin = target.origin;
33
+ this.#storageKey = storageKey;
34
+ this.#module = module;
35
+ this.#requestHeader = requestHeader;
36
+ }
32
37
 
33
- async #loadConfig(module) {
34
- const response = await fetch({ url: this.#configURL, method: "GET", headers: { "Cache-Control": "no-cache" }, timeout: 5000 });
35
- if (response.status !== 200) throw new Error(`BoxJS source HTTP ${response.status}`);
36
- return normalizeBoxJs(JSON.parse(response.body), module);
37
- }
38
-
39
- /**
40
- * @param {import("./index.js").SettingsRequest} request 代理请求 / Proxy request.
41
- * @returns {Promise<import("./index.js").SettingsResponse | undefined>} API 响应,非本来源 API 则不处理 / API response, or undefined outside the configured API origin.
42
- */
43
- async handle(request) {
44
- const url = new URL(request.url);
45
- if (url.origin !== this.#origin || !url.pathname.startsWith("/api/")) return;
46
- const headers = { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "no-store", "X-Content-Type-Options": "nosniff" };
47
- const reply = (status, data) => ({ status, headers, body: request.method === "HEAD" ? "" : JSON.stringify(data) });
48
- let parts;
49
- try {
50
- parts = parseSettingsPath(request.url);
51
- } catch (error) {
52
- return reply(400, { error: error.message });
53
- }
54
- const requestHeaders = Object.fromEntries(Object.entries(request.headers ?? {}).map(([key, value]) => [key.toLowerCase(), value]));
55
- if (requestHeaders[this.#requestHeader.toLowerCase()] !== "1" || (requestHeaders.origin && requestHeaders.origin !== this.#origin))
56
- return reply(403, { error: "Forbidden settings client" });
57
- if (!["HEAD", "GET", "POST", "DELETE"].includes(request.method))
58
- return { ...reply(405, { error: "Method not allowed" }), headers: { ...headers, Allow: "HEAD, GET, POST, DELETE" } };
59
- let definition;
60
- try {
61
- definition = await this.#loadConfig(parts[0]);
62
- } catch (error) {
63
- return reply(502, { error: `Module configuration unavailable: ${error.message}` });
64
- }
65
- const key = parts.join(".");
66
- const field = definition.fields.find((field) => field.key === key);
67
- const descendants = definition.fields.filter((field) => field.key.startsWith(`${key}.`));
68
- if (!field && !descendants.length) return reply(404, { error: `Unknown setting path: ${key}` });
69
- if (request.method === "HEAD") return reply(200, undefined);
70
- if (request.method === "GET") {
71
- const stored = Storage.getItem(definition.storageKey, {});
72
- const effective = this.#resolveSettings ? this.#resolveSettings(stored, definition) : stored;
73
- if (!isRecord(effective)) throw new TypeError("resolved settings must be a synchronous object");
74
- if (field) {
75
- const value = pathValue(effective, parts);
76
- return value === undefined ? reply(404, { error: `Setting has no stored value: ${key}` }) : reply(200, value);
77
- }
78
- const subtree = {};
79
- // 只返回配置文件公开的字段;默认值由浏览器用 BoxJS val 生成。
80
- // Expose only declared fields; the browser renders defaults from BoxJS val.
81
- for (const descendant of descendants) {
82
- const fullPath = descendant.key.split(".");
83
- const value = pathValue(effective, fullPath);
84
- if (value !== undefined) _.set(subtree, fullPath.slice(parts.length), value);
85
- }
86
- return reply(200, subtree);
87
- }
88
- if (!field) return reply(405, { error: "Only individual declared keys can be modified" });
89
- let value;
90
- if (request.method === "POST") {
91
- if (requestHeaders["content-type"]?.split(";")[0].trim().toLowerCase() !== "application/json")
92
- return reply(415, { error: "Expected application/json" });
93
- if (typeof request.body !== "string") return reply(400, { error: "Expected a JSON string body" });
94
- if (request.body.length > 65536) return reply(413, { error: "Body exceeds 65536 UTF-16 code units" });
95
- try {
96
- value = JSON.parse(request.body);
97
- } catch {
98
- return reply(400, { error: "Invalid JSON" });
99
- }
100
- if (!validValue(field, value)) return reply(400, { error: `Invalid setting value: ${key}` });
101
- }
102
- const saved = Storage.getItem(definition.storageKey, {});
103
- if (!isRecord(saved)) throw new TypeError("stored settings must be an object");
104
- const parent = settingsParent(saved, parts, request.method === "POST");
105
- if (parent) {
106
- if (request.method === "DELETE") _.unset(parent, [parts.at(-1)]);
107
- else _.set(parent, [parts.at(-1)], value);
108
- }
109
- if (!Storage.setItem(definition.storageKey, saved)) return reply(500, { error: "Settings storage write failed" });
110
- return reply(200, request.method === "DELETE" ? { deleted: true } : { saved: true });
111
- }
38
+ /**
39
+ * GET 返回指定值,POST 替换指定值,DELETE 删除指定键或整个模块。
40
+ * GET returns a value, POST replaces it, and DELETE removes a key or the entire module.
41
+ * @param {import("./index.js").SettingsRequest} request 代理请求 / Proxy request.
42
+ * @returns {Promise<import("./index.js").SettingsResponse | undefined>} 响应或非接管请求 / Response, or undefined for an unhandled request.
43
+ */
44
+ async handle(request) {
45
+ const url = new URL(request.url);
46
+ if (url.origin !== this.#origin || !url.pathname.startsWith("/api/")) return;
47
+ const headers = { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "no-store", "X-Content-Type-Options": "nosniff" };
48
+ const reply = (status, data) => ({ status, headers, body: request.method === "HEAD" ? "" : JSON.stringify(data) });
49
+ let parts;
50
+ try {
51
+ parts = parseSettingsPathname(url.pathname);
52
+ } catch (error) {
53
+ return reply(400, { error: error.message });
54
+ }
55
+ if (parts[0] !== this.#module) return reply(404, { error: "Module is not handled" });
56
+ const requestHeaders = Object.fromEntries(Object.entries(request.headers ?? {}).map(([key, value]) => [key.toLowerCase(), value]));
57
+ if (requestHeaders[this.#requestHeader.toLowerCase()] !== "1" || (requestHeaders.origin && requestHeaders.origin !== this.#origin)) return reply(403, { error: "Forbidden settings client" });
58
+ let value;
59
+ switch (request.method) {
60
+ case "HEAD":
61
+ return reply(200, undefined);
62
+ case "GET":
63
+ case "DELETE":
64
+ break;
65
+ case "POST":
66
+ if (requestHeaders["content-type"]?.split(";")[0].trim().toLowerCase() !== "application/json") return reply(415, { error: "Expected application/json" });
67
+ if (typeof request.body !== "string") return reply(400, { error: "Expected a JSON string body" });
68
+ if (request.body.length > 65536) return reply(413, { error: "Body exceeds 65536 UTF-16 code units" });
69
+ try {
70
+ value = JSON.parse(request.body);
71
+ } catch {
72
+ return reply(400, { error: "Invalid JSON" });
73
+ }
74
+ break;
75
+ default:
76
+ return { ...reply(405, { error: "Method not allowed" }), headers: { ...headers, Allow: "HEAD, GET, POST, DELETE" } };
77
+ }
78
+ try {
79
+ const root = Storage.getItem(this.#storageKey, {});
80
+ if (!isRecord(root)) throw new TypeError("stored root must be an object");
81
+ const parent = storageParent(root, parts, request.method === "POST");
82
+ const key = parts.at(-1);
83
+ switch (request.method) {
84
+ case "GET": {
85
+ const result = parent ? _.get(parent, [key]) : undefined;
86
+ return result === undefined ? reply(404, { error: "Stored path does not exist" }) : reply(200, result);
87
+ }
88
+ case "POST":
89
+ _.set(parent, [key], value);
90
+ break;
91
+ case "DELETE":
92
+ if (parent) _.unset(parent, [key]);
93
+ break;
94
+ }
95
+ if (!Storage.setItem(this.#storageKey, root)) throw new Error("Storage write failed");
96
+ return reply(200, request.method === "POST" ? { saved: true } : { deleted: true });
97
+ } catch (error) {
98
+ return reply(500, { error: error.message });
99
+ }
100
+ }
112
101
  }
113
102
 
103
+ /**
104
+ * 判断根节点是否为普通对象。
105
+ * Determine whether a root node is a plain object.
106
+ * @param {unknown} value 待检查值 / Value to inspect.
107
+ * @returns {boolean} 是否为普通对象 / Whether this is a plain object.
108
+ */
114
109
  function isRecord(value) {
115
- return value !== null && typeof value === "object" && Object.getPrototypeOf(value) === Object.prototype;
110
+ return value !== null && typeof value === "object" && Object.getPrototypeOf(value) === Object.prototype;
116
111
  }
117
112
 
118
- function pathValue(root, parts) {
119
- const parent = settingsParent(root, parts, false);
120
- return parent ? _.get(parent, [parts.at(-1)]) : undefined;
121
- }
122
-
123
- // util @root.path 允许序列化中间对象;统一解码并保留相邻键。
124
- // Decode util-serialized intermediate objects while retaining sibling keys.
125
- function settingsParent(root, parts, create) {
126
- let parent = root;
127
- for (const part of parts.slice(0, -1)) {
128
- let next = _.get(parent, [part]);
129
- if (next === undefined) {
130
- if (!create) return;
131
- next = {};
132
- } else if (typeof next === "string") next = JSON.parse(next);
133
- if (!isRecord(next)) throw new TypeError(`Stored path is not an object: ${part}`);
134
- _.set(parent, [part], next);
135
- parent = next;
136
- }
137
- return parent;
113
+ /**
114
+ * 遍历父路径,兼容旧存储中 JSON 字符串形式的中间节点。
115
+ * Traverse parents, supporting legacy intermediate nodes serialized as JSON strings.
116
+ * @param {Record<string, unknown>} root 存储根 / Storage root.
117
+ * @param {string[]} parts 完整路径 / Complete path.
118
+ * @param {boolean} create 是否创建缺失节点 / Whether to create missing parents.
119
+ * @returns {object | undefined} 父节点,缺失且不创建时为 undefined / Parent, or undefined when absent and not creating.
120
+ * @throws {TypeError} 无法继续遍历标量节点 / A scalar node cannot be traversed.
121
+ */
122
+ function storageParent(root, parts, create) {
123
+ let parent = root;
124
+ for (const part of parts.slice(0, -1)) {
125
+ let next = _.get(parent, [part]);
126
+ switch (typeof next) {
127
+ case "undefined":
128
+ if (!create) return;
129
+ next = {};
130
+ break;
131
+ case "string":
132
+ next = JSON.parse(next);
133
+ break;
134
+ default:
135
+ break;
136
+ }
137
+ if (!isRecord(next) && !Array.isArray(next)) throw new TypeError("Stored parent is not an object or array");
138
+ _.set(parent, [part], next);
139
+ parent = next;
140
+ }
141
+ return parent;
138
142
  }