@nsnanocat/preference-panes 0.1.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,69 +1,66 @@
1
1
  {
2
- "name": "@nsnanocat/preference-panes",
3
- "version": "0.1.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": "index.mjs",
20
- "exports": {
21
- ".": {
22
- "types": "./types/index.d.ts",
23
- "import": "./index.mjs"
24
- },
25
- "./browser": {
26
- "types": "./types/browser.d.ts",
27
- "import": "./browser/index.mjs"
28
- },
29
- "./browser/panel.css": "./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": "types/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 scripts/generate-apifox.mjs"
43
- },
44
- "repository": {
45
- "type": "git",
46
- "url": "git+https://github.com/NSNanoCat/PreferencePanes.git"
47
- },
48
- "files": [
49
- "index.mjs",
50
- "lib",
51
- "browser",
52
- "types",
53
- "dist",
54
- "proxy"
55
- ],
56
- "dependencies": {
57
- "@nsnanocat/url": "^1.2.6",
58
- "@nsnanocat/util": "^2.7.4"
59
- },
60
- "devDependencies": {
61
- "@biomejs/biome": "2.4.6",
62
- "@rollup/plugin-node-resolve": "^16.0.3",
63
- "rollup": "^4.63.1",
64
- "typescript": "^5.9.3"
65
- },
66
- "publishConfig": {
67
- "access": "public"
68
- }
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
+ }
69
66
  }
@@ -0,0 +1,142 @@
1
+ import { URL } from "@nsnanocat/url";
2
+ import { Lodash as _ } from "@nsnanocat/util/polyfill/Lodash.mjs";
3
+ import { Storage } from "@nsnanocat/util/polyfill/Storage";
4
+ import { parseSettingsPathname, validatePathParts } from "./lib/settings-path.mjs";
5
+
6
+ /**
7
+ * 按插件声明的根和模块桥接持久化存储,不下载或解析 BoxJS。
8
+ * Bridge persistence within the installed root and module without downloading or parsing BoxJS.
9
+ */
10
+ export class SettingsHandler {
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;
19
+
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
+ }
37
+
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
+ }
101
+ }
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
+ */
109
+ function isRecord(value) {
110
+ return value !== null && typeof value === "object" && Object.getPrototypeOf(value) === Object.prototype;
111
+ }
112
+
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;
142
+ }
@@ -0,0 +1,228 @@
1
+ import { normalizeBoxJs, normalizeStoredValue, validValue } from "../lib/boxjs.mjs";
2
+ import { validatePathParts } from "../lib/settings-path.mjs";
3
+
4
+ /**
5
+ * 单个模块的临时会话;离开页面后丢弃。
6
+ * Transient module session discarded when leaving the page.
7
+ * @typedef {object} ModuleSession
8
+ * @property {AbortController} controller 读取请求的取消控制器 / Abort controller for reads.
9
+ * @property {import("../index.js").ModuleDefinition | null} definition 加载完成的配置,加载中为 null / Loaded configuration, or null while loading.
10
+ * @property {import("./index.js").ModuleSnapshot["values"]} values 当前显示值 / Current display values.
11
+ * @property {boolean} saving 是否正在写入 / Whether a mutation is in progress.
12
+ */
13
+
14
+ /**
15
+ * 创建页面会话缓存;打开时重读,选项操作仅在 HTTP 200 后更新缓存。
16
+ * Create a page-session cache; reload on open and mutate cache only after HTTP 200.
17
+ * @param {import("./index.js").PreferencesClientOptions} options 请求与通知 / Requests and notifications.
18
+ * @returns {import("./index.js").PreferencesClient} 通用客户端 / Generic client.
19
+ */
20
+ export function createPreferencesClient({ fetch: request = globalThis.fetch.bind(globalThis), notify = () => {}, timeout = 10000 } = {}) {
21
+ /** @type {Map<string, ModuleSession>} 模块会话表 / Module session map. */
22
+ const sessions = new Map();
23
+ /**
24
+ * 发送同源请求,处理超时与取消;数据 GET 的 404 交给调用方处理。
25
+ * Send a same-origin request with timeout and cancellation; callers handle missing-data GET responses.
26
+ * @param {string} path 相对请求路径 / Relative request path.
27
+ * @param {"HEAD" | "GET" | "POST" | "DELETE"} method HTTP 方法 / HTTP method.
28
+ * @param {unknown} body POST 值,其它方法忽略 / POST value, ignored by other methods.
29
+ * @param {AbortSignal | undefined} signal 会话取消信号 / Session cancellation signal.
30
+ * @param {boolean} [resource=false] 是否为无标记头的配置资源 / Whether this is a config resource without the marker header.
31
+ * @returns {Promise<Response>} 未消费正文的响应 / Response with an unread body.
32
+ * @throws {Error} 非 200 且非数据 GET 404、超时、取消或网络错误 / Non-200 status except missing-data GETs, timeout, cancellation or network error.
33
+ */
34
+ async function send(path, method, body, signal, resource = false) {
35
+ const controller = new AbortController();
36
+ const abort = () => controller.abort();
37
+ if (signal?.aborted) abort();
38
+ signal?.addEventListener("abort", abort, { once: true });
39
+ const timer = setTimeout(abort, timeout);
40
+ try {
41
+ const response = await request(path, {
42
+ method,
43
+ credentials: "omit",
44
+ cache: "no-store",
45
+ signal: controller.signal,
46
+ headers: resource ? {} : { "X-Settings-Client": "1", ...(method === "POST" ? { "Content-Type": "application/json" } : {}) },
47
+ ...(method === "POST" ? { body: JSON.stringify(body) } : {}),
48
+ });
49
+ if (response.status !== 200 && !(!resource && method === "GET" && response.status === 404)) throw new Error(`HTTP ${response.status}`);
50
+ return response;
51
+ } finally {
52
+ clearTimeout(timer);
53
+ signal?.removeEventListener("abort", abort);
54
+ }
55
+ }
56
+ /**
57
+ * 由合法模块名生成配置 Mock 路径。
58
+ * Build the config Mock path from a valid module name.
59
+ * @param {string} module 模块标识 / Module identifier.
60
+ * @returns {string} 配置路径 / Config path.
61
+ */
62
+ const configPath = module => {
63
+ validatePathParts([module]);
64
+ return `/configs/${encodeURIComponent(module)}`;
65
+ };
66
+ /**
67
+ * 获取独立快照,避免调用方修改内部缓存。
68
+ * Return an independent snapshot so callers cannot mutate the cache.
69
+ * @param {string} module 已打开模块 / Open module.
70
+ * @returns {import("./index.js").ModuleSnapshot} 会话快照 / Session snapshot.
71
+ * @throws {Error} 模块未完成加载 / Module has not finished loading.
72
+ */
73
+ const snapshot = module => {
74
+ const state = sessions.get(module);
75
+ if (!state?.definition) throw new Error("Open the module first");
76
+ return structuredClone({ definition: state.definition, values: state.values });
77
+ };
78
+ /**
79
+ * 串行修改单键,仅成功后更新仍存活的会话。
80
+ * Serialize single-key mutations and update a still-active session only after success.
81
+ * @param {string} module 已打开模块 / Open module.
82
+ * @param {string} key 完整点分字段路径 / Complete dotted field path.
83
+ * @param {"POST" | "DELETE"} method 写入或删除 / Write or delete.
84
+ * @param {unknown} value 写入值,删除时忽略 / Write value, ignored for deletion.
85
+ * @param {"write" | "delete" | "clearCaches" | "reset"} [operation] 操作类型 / Operation kind.
86
+ * @returns {Promise<void>} 操作完成 / Operation completion.
87
+ * @throws {Error} 会话、字段、值或请求错误 / Session, field, value or request error.
88
+ */
89
+ async function change(module, key, method, value, operation = method === "POST" ? "write" : "delete") {
90
+ const state = sessions.get(module);
91
+ if (!state?.definition) throw new Error("Open the module first");
92
+ if (state.saving) throw new Error("A settings write is already in progress");
93
+ const field = state.definition.fields.find(field => field.key === key);
94
+ state.saving = true;
95
+ try {
96
+ if ((operation === "write" || operation === "delete") && (!field || (method === "POST" && !validValue(field, value)))) throw new TypeError("Invalid setting value");
97
+ await send(`/api/${key.split(".").map(encodeURIComponent).join("/")}`, method, value);
98
+ if (sessions.get(module) === state) {
99
+ switch (operation) {
100
+ case "write":
101
+ state.values[key] = structuredClone(value);
102
+ break;
103
+ case "delete":
104
+ case "clearCaches":
105
+ case "reset":
106
+ for (const candidate of state.definition.fields) {
107
+ if (candidate.key !== key && !candidate.key.startsWith(`${key}.`)) continue;
108
+ delete state.values[candidate.key];
109
+ if (Object.hasOwn(candidate, "defaultValue")) state.values[candidate.key] = structuredClone(candidate.defaultValue);
110
+ }
111
+ break;
112
+ }
113
+ }
114
+ notify({ kind: "success", operation, module, key });
115
+ } catch (error) {
116
+ notify({ kind: "error", operation, module, key, message: error.message });
117
+ throw error;
118
+ } finally {
119
+ state.saving = false;
120
+ }
121
+ }
122
+ return {
123
+ /**
124
+ * 探测配置 Mock,不读写存储。
125
+ * Probe the config Mock without accessing storage.
126
+ * @param {string} module 模块标识 / Module identifier.
127
+ * @returns {Promise<boolean>} 是否返回 HTTP 200 / Whether HTTP 200 was returned.
128
+ */
129
+ async probe(module) {
130
+ try {
131
+ await send(configPath(module), "HEAD", undefined, undefined, true);
132
+ return true;
133
+ } catch {
134
+ return false;
135
+ }
136
+ },
137
+ /**
138
+ * 替换旧会话,读取一次配置与一次设置子树。
139
+ * Replace the previous session and read config and settings subtree once each.
140
+ * @param {string} module 模块标识 / Module identifier.
141
+ * @returns {Promise<import("./index.js").ModuleSnapshot>} 新快照 / New snapshot.
142
+ * @throws {Error} 读取失败、会话被替换或写入尚未完成 / Read failure, replaced session or unfinished write.
143
+ */
144
+ async open(module) {
145
+ const previous = sessions.get(module);
146
+ if (previous?.saving) throw new Error("Cannot refresh while saving");
147
+ previous?.controller.abort();
148
+ const state = { controller: new AbortController(), definition: null, values: {}, saving: false };
149
+ sessions.set(module, state);
150
+ try {
151
+ const definition = normalizeBoxJs(await (await send(configPath(module), "GET", undefined, state.controller.signal, true)).json(), module);
152
+ if (definition.settingsPath.length < 2) throw new TypeError("BoxJS fields must share a settings subtree below the module root");
153
+ const response = await send(`/api/${definition.settingsPath.map(encodeURIComponent).join("/")}/`, "GET", undefined, state.controller.signal);
154
+ let subtree = response.status === 404 ? {} : await response.json();
155
+ if (typeof subtree === "string") subtree = JSON.parse(subtree);
156
+ if (!subtree || typeof subtree !== "object" || Array.isArray(subtree)) throw new TypeError("Expected a settings subtree object");
157
+ if (sessions.get(module) !== state) throw new Error("Module session was replaced");
158
+ state.definition = definition;
159
+ for (const field of definition.fields) {
160
+ const stored = field.key
161
+ .split(".")
162
+ .slice(definition.settingsPath.length)
163
+ .reduce((parent, part) => Object(parent)[part], subtree);
164
+ const value = stored === undefined ? field.defaultValue : stored;
165
+ if (value !== undefined) state.values[field.key] = normalizeStoredValue(field, value);
166
+ }
167
+ return snapshot(module);
168
+ } catch (error) {
169
+ if (sessions.get(module) === state) sessions.delete(module);
170
+ throw error;
171
+ }
172
+ },
173
+ snapshot,
174
+ /**
175
+ * 按需读取模块 Caches,不自动读取其它设置。
176
+ * Read module Caches on demand without refreshing other settings.
177
+ * @param {string} module 已打开的模块 / Open module.
178
+ * @returns {Promise<unknown>} 缓存值,缺失为 undefined / Cache value, or undefined when absent.
179
+ */
180
+ async readCaches(module) {
181
+ const state = sessions.get(module);
182
+ if (!state?.definition) throw new Error("Open the module first");
183
+ const response = await send(`/api/${encodeURIComponent(module)}/Caches`, "GET", undefined, state.controller.signal);
184
+ return response.status === 404 ? undefined : response.json();
185
+ },
186
+ /**
187
+ * 删除整个 Caches 节点,成功后不追加 GET。
188
+ * Delete the entire Caches node without a follow-up GET.
189
+ * @param {string} module 已打开模块 / Open module.
190
+ * @returns {Promise<void>} 清理完成 / Cleanup completion.
191
+ */
192
+ clearCaches: module => change(module, `${module}.Caches`, "DELETE", undefined, "clearCaches"),
193
+ /**
194
+ * 删除整个模块持久化节点,以当前 BoxJS 默认值重置页面缓存。
195
+ * Delete module persistence and reset the page cache using current BoxJS defaults.
196
+ * @param {string} module 已打开模块 / Open module.
197
+ * @returns {Promise<void>} 重置完成 / Reset completion.
198
+ */
199
+ reset: module => change(module, module, "DELETE", undefined, "reset"),
200
+ /**
201
+ * 取消读取并清除会话,不撤销已发送的写入。
202
+ * Abort reads and clear the session without undoing dispatched writes.
203
+ * @param {string} module 模块标识 / Module identifier.
204
+ * @returns {void} 无返回值 / No return value.
205
+ */
206
+ leave(module) {
207
+ sessions.get(module)?.controller.abort();
208
+ sessions.delete(module);
209
+ },
210
+ /**
211
+ * 写入单键并更新当前会话。
212
+ * Write one key and update the current session.
213
+ * @param {string} module 已打开模块 / Open module.
214
+ * @param {string} key 点分字段路径 / Dotted field path.
215
+ * @param {import("../index.js").SettingsScalar | import("../index.js").SettingsScalar[]} value 字段值 / Field value.
216
+ * @returns {Promise<void>} 写入完成 / Write completion.
217
+ */
218
+ set: (module, key, value) => change(module, key, "POST", value),
219
+ /**
220
+ * 删除单键覆盖值并显示默认值。
221
+ * Delete one override and display its default value.
222
+ * @param {string} module 已打开模块 / Open module.
223
+ * @param {string} key 点分字段路径 / Dotted field path.
224
+ * @returns {Promise<void>} 删除完成 / Delete completion.
225
+ */
226
+ remove: (module, key) => change(module, key, "DELETE"),
227
+ };
228
+ }
@@ -0,0 +1,153 @@
1
+ import type { ModuleDefinition, SettingsScalar } from "../index.js";
2
+ /**
3
+ * 单键写入或删除的通知事件,携带对应模块与点分键路径。
4
+ * Notification for a single-key write or delete, including module and dotted key path.
5
+ */
6
+ export interface Notification {
7
+ /** 结果类别 / Result kind. */
8
+ kind: "success" | "error";
9
+ /** 操作类别 / Operation kind. */
10
+ operation: "write" | "delete" | "clearCaches" | "reset";
11
+ /** 模块标识 / Module identifier. */
12
+ module: string;
13
+ /** 含模块名、不含存储根的点分路径 / Dotted path including the module but excluding the storage root. */
14
+ key: string;
15
+ /** 失败原因,仅错误事件提供 / Failure reason, provided for errors only. */
16
+ message?: string;
17
+ }
18
+ /**
19
+ * 浏览器会话客户端选项。
20
+ * Options for the browser session client.
21
+ */
22
+ export interface PreferencesClientOptions {
23
+ /** 默认使用浏览器 fetch,可注入同签名传输 / Defaults to browser fetch; an equivalent transport may be supplied. */
24
+ fetch?: typeof globalThis.fetch;
25
+ /** 成功写入或失败时调用,不用于读取事件 / Called for successful mutations or failures, not reads. */
26
+ notify?: (notification: Notification) => void;
27
+ /** 单次请求超时,单位毫秒,默认 10000 / Per-request timeout in milliseconds; defaults to 10000. */
28
+ timeout?: number;
29
+ }
30
+ /**
31
+ * 会话的深拷贝快照,调用方修改不会影响缓存。
32
+ * Deep-cloned session snapshot; caller changes cannot alter the cache.
33
+ */
34
+ export interface ModuleSnapshot {
35
+ /** 当前配置生成的模块定义 / Module definition generated from current config. */
36
+ definition: ModuleDefinition;
37
+ /** 点分键到显示值的映射,已包含适用的默认值 / Dotted keys mapped to display values including applicable defaults. */
38
+ values: Record<string, SettingsScalar | SettingsScalar[]>;
39
+ }
40
+ /**
41
+ * 只在页面存活期间维护模块缓存的通用客户端。
42
+ * Generic client maintaining module caches only during the page lifetime.
43
+ */
44
+ export interface PreferencesClient {
45
+ /**
46
+ * HEAD 探测配置 Mock,不读取持久化数据。
47
+ * Probe the config Mock with HEAD without reading persistence.
48
+ * @param module 模块标识 / Module identifier.
49
+ * @returns 仅 HTTP 200 为 true;无效模块或请求失败为 false / True only for HTTP 200; false for invalid modules or failed requests.
50
+ */
51
+ probe(module: string): Promise<boolean>;
52
+ /**
53
+ * 替换会话,各读取一次配置与设置子树。
54
+ * Replace the session and fetch config and settings subtree once each.
55
+ * @param module 模块标识 / Module identifier.
56
+ * @returns 新会话的独立快照 / Independent snapshot of the new session.
57
+ * @throws {Error} 写入进行中、请求或配置无效、会话被替换 / Active write, invalid request or config, or replaced session.
58
+ */
59
+ open(module: string): Promise<ModuleSnapshot>;
60
+ /**
61
+ * 获取已打开模块的快照,不发请求。
62
+ * Get a snapshot of an open module without network requests.
63
+ * @param module 模块标识 / Module identifier.
64
+ * @returns 深拷贝快照 / Deep-cloned snapshot.
65
+ * @throws {Error} 模块尚未打开 / Module has not been opened.
66
+ */
67
+ snapshot(module: string): ModuleSnapshot;
68
+ /**
69
+ * 取消未完成的读取并移除缓存,不撤销已发送的写入。
70
+ * Abort pending reads and discard the cache without undoing dispatched writes.
71
+ * @param module 模块标识 / Module identifier.
72
+ * @returns 无返回值 / No return value.
73
+ */
74
+ leave(module: string): void;
75
+ /**
76
+ * POST 单个字段,HTTP 200 后更新缓存,不追加 GET。
77
+ * POST one field and update its cache only on HTTP 200, without a follow-up GET.
78
+ * @param module 已打开的模块 / Open module.
79
+ * @param key 完整点分字段路径 / Complete dotted field path.
80
+ * @param value 符合字段类型和选项的值 / Value matching the field type and choices.
81
+ * @returns 操作完成 / Completion of the operation.
82
+ * @throws {Error} 会话、值、并发写入或网络错误 / Session, value, concurrent-write or network error.
83
+ */
84
+ set(module: string, key: string, value: SettingsScalar | SettingsScalar[]): Promise<void>;
85
+ /**
86
+ * DELETE 单个覆盖值,HTTP 200 后显示默认值,不追加 GET。
87
+ * DELETE an override and display its default after HTTP 200, without a follow-up GET.
88
+ * @param module 已打开的模块 / Open module.
89
+ * @param key 完整点分字段路径 / Complete dotted field path.
90
+ * @returns 操作完成 / Completion of the operation.
91
+ * @throws {Error} 会话、路径、并发写入或网络错误 / Session, path, concurrent-write or network error.
92
+ */
93
+ remove(module: string, key: string): Promise<void>;
94
+ /**
95
+ * 按需读取整个模块 Caches,不刷新设置。
96
+ * Read all module Caches on demand without refreshing settings.
97
+ * @param module 已打开模块 / Open module.
98
+ * @returns 缓存 JSON 值,缺失时为 undefined / Cache JSON value, or undefined when absent.
99
+ */
100
+ readCaches(module: string): Promise<unknown>;
101
+ /**
102
+ * 删除模块 Caches 并更新相关页面状态,不追加 GET。
103
+ * Delete module Caches and update related page state without a follow-up GET.
104
+ * @param module 已打开模块 / Open module.
105
+ * @returns 清理完成 / Cleanup completion.
106
+ */
107
+ clearCaches(module: string): Promise<void>;
108
+ /**
109
+ * 删除整个模块持久化数据,页面使用当前 BoxJS 默认值。
110
+ * Delete all module persistence and use current BoxJS defaults on the page.
111
+ * @param module 已打开模块 / Open module.
112
+ * @returns 重置完成 / Reset completion.
113
+ */
114
+ reset(module: string): Promise<void>;
115
+ }
116
+ /**
117
+ * WebView 面板的挂载选项,模块标识从页面路径读取。
118
+ * Mount options for the WebView panel; the module is read from the page path.
119
+ */
120
+ export interface PreferencesPanelOptions {
121
+ /** 具有浏览器 window 的挂载元素 / Mount element owned by a document with a browser window. */
122
+ element: HTMLElement;
123
+ /** 无有效模块时的标题,默认 Preferences / Title without a valid module; defaults to Preferences. */
124
+ title?: string;
125
+ /** 可选浏览器请求传输 / Optional browser request transport. */
126
+ fetch?: typeof globalThis.fetch;
127
+ }
128
+ /**
129
+ * 面板生命周期控制句柄。
130
+ * Handle controlling the panel lifecycle.
131
+ */
132
+ export interface PreferencesPanel {
133
+ /**
134
+ * 移除事件、面板与定时器,并取消当前读取。
135
+ * Remove listeners, panel and timers, and abort the current read.
136
+ * @returns 无返回值 / No return value.
137
+ */
138
+ destroy(): void;
139
+ }
140
+ /**
141
+ * 创建模块会话客户端,不立即发出请求。
142
+ * Create a module session client without immediately making requests.
143
+ * @param options 请求、通知和超时选项 / Request, notification and timeout options.
144
+ * @returns 通用客户端 / Generic client.
145
+ */
146
+ export function createPreferencesClient(options?: PreferencesClientOptions): PreferencesClient;
147
+ /**
148
+ * 按 /settings/{module} 挂载动态面板,监听导航和页面恢复事件。
149
+ * Mount a dynamic panel at /settings/{module} and observe navigation and page restoration.
150
+ * @param options 挂载选项 / Mount options.
151
+ * @returns 面板生命周期句柄 / Panel lifecycle handle.
152
+ */
153
+ export function mountPreferencePanes(options: PreferencesPanelOptions): PreferencesPanel;
@@ -0,0 +1,7 @@
1
+ /**
2
+ * WebView 面板与页面会话客户端的公开入口。
3
+ * Public entry for the WebView panel and page session client.
4
+ * @module @nsnanocat/preference-panes/browser
5
+ */
6
+ export { createPreferencesClient } from "./client.mjs";
7
+ export { mountPreferencePanes } from "./panel.mjs";