@nsnanocat/preference-panes 1.1.0 → 1.1.2

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,7 +1,7 @@
1
1
  {
2
2
  "name": "@nsnanocat/preference-panes",
3
- "version": "1.1.0",
4
- "description": "Shared settings API runtime for JavaScript proxy modules",
3
+ "version": "1.1.2",
4
+ "description": "Generic BoxJS settings frontend and persistence API for JavaScript proxy modules",
5
5
  "author": "VirgilClyne <Virgil@nanocat.me>",
6
6
  "homepage": "https://NSNanoCat.github.io/preference-panes",
7
7
  "keywords": [
package/src/api.mjs CHANGED
@@ -2,14 +2,13 @@ import { URL } from "@nsnanocat/url";
2
2
  import { fetch as transport } from "@nsnanocat/util";
3
3
  import { $app } from "@nsnanocat/util/lib/app.mjs";
4
4
  import { done } from "@nsnanocat/util/lib/done.mjs";
5
+ import { Lodash as _ } from "@nsnanocat/util/polyfill/Lodash.mjs";
5
6
  import { Storage } from "@nsnanocat/util/polyfill/Storage";
6
7
  import { validatePathParts } from "./lib/settings-path.mjs";
7
8
 
8
- const MISSING = Symbol("missing");
9
-
10
9
  /**
11
- * PreferencePanes 后端 API,只处理模块数据和持久化请求。
12
- * PreferencePanes backend API handling only module data and persistence requests.
10
+ * PreferencePanes 后端 API,只转发模块配置并提供通用持久化操作。
11
+ * PreferencePanes backend API only relaying module configurations and providing generic persistence operations.
13
12
  */
14
13
  class API {
15
14
  /**
@@ -31,153 +30,107 @@ class API {
31
30
  }
32
31
 
33
32
  /**
34
- * 处理 `/api/{module}` 及其动作,不接管页面或静态资源。
35
- * Handle `/api/{module}` and its actions without intercepting pages or static assets.
33
+ * 处理模块配置 API 与固定存储动作,不接管页面或静态资源。
34
+ * Handle module configuration APIs and fixed storage actions without intercepting pages or static assets.
36
35
  * @param {import("./index.js").SettingsRequest} request 代理请求 / Proxy request.
37
36
  * @returns {Promise<import("./index.js").SettingsResponse | undefined>} API 响应或未接管 / API response or pass-through.
38
37
  */
39
38
  async handle(request) {
40
39
  const url = new URL(request.url);
41
- const match = /^\/api\/([a-zA-Z0-9_-]+)(?:\/(get|set|delete))?\/?$/.exec(url.pathname);
42
- if (!match) return;
43
- const [, module, action] = match;
44
- const configURL = this.#configURL(request, url, module);
45
- switch (true) {
46
- case !action && request.method === "HEAD":
47
- return this.#probe(request, configURL);
48
- case !action && request.method === "GET":
49
- return this.#model(request, module, configURL);
50
- case Boolean(action) && request.method === "POST":
51
- return this.#action(request, module, action, configURL);
52
- default:
53
- return this.#response(request, 405, { error: "Use GET or HEAD for module reads, POST for module actions" });
54
- }
40
+ const action = /^\/api\/(get|set|delete)\/?$/.exec(url.pathname)?.[1];
41
+ if (action) return this.#store(request, action);
42
+ const module = /^\/api\/([a-zA-Z0-9_-]+)\/?$/.exec(url.pathname)?.[1];
43
+ if (!module) return;
44
+ if (!["HEAD", "GET"].includes(request.method)) return this.#response(request, 405, { error: "Use GET or HEAD for module configuration" });
45
+ return this.#configuration(request, `${url.origin}/configs/${module}`);
55
46
  }
56
47
 
57
- #configURL(request, url, module) {
58
- const headers = Object.fromEntries(Object.entries(request.headers ?? {}).map(([key, value]) => [key.toLowerCase(), value]));
59
- const source = headers["x-preferencepanes-json"] ?? `/configs/${module}`;
60
- if (/^https?:\/\//i.test(source)) return source;
61
- if (/^[a-zA-Z][a-zA-Z\d+.-]*:/.test(source)) throw Object.assign(new TypeError("BoxJS resources must use HTTP(S) URLs"), { status: 400 });
62
- return `${url.origin}/${source.replace(/^\/+/, "")}`;
63
- }
64
-
65
- async #probe(request, configURL) {
48
+ /**
49
+ * 从同源业务配置响应模块探测或原始 BoxJS JSON。
50
+ * Respond to a module probe or raw BoxJS JSON from the same-origin business configuration.
51
+ * @param {import("./index.js").SettingsRequest} request 代理请求 / Proxy request.
52
+ * @param {string} configuration 同源配置地址 / Same-origin configuration URL.
53
+ * @returns {Promise<import("./index.js").SettingsResponse>} 配置响应 / Configuration response.
54
+ */
55
+ async #configuration(request, configuration) {
66
56
  let result;
67
57
  try {
68
- result = await transport({ url: configURL, method: "HEAD", timeout: 5000, headers: { Accept: "application/json" } });
58
+ result = await transport({ url: configuration, method: request.method, timeout: 5000, headers: { Accept: "application/json" } });
69
59
  } catch (error) {
70
60
  return this.#response(request, 502, { error: error.message });
71
61
  }
72
62
  const version = this.#header(result.headers, "x-preferencepanes-version");
73
- return this.#response(request, result.statusCode ?? result.status, undefined, version ? { "X-PreferencePanes-Version": version } : {});
74
- }
75
-
76
- async #model(request, module, configURL) {
77
- const loaded = await this.#load(module, configURL);
78
- const values = {};
79
- for (const entry of loaded.entries) {
80
- const value = Storage.getItem(entry.id, MISSING);
81
- if (value !== MISSING) values[entry.id.slice(loaded.storageKey.length + 2)] = value;
82
- }
83
- return this.#response(request, 200, { module, boxjs: loaded.boxjs, values, configURL }, loaded.version ? { "X-PreferencePanes-Version": loaded.version } : {});
84
- }
85
-
86
- async #action(request, module, action, configURL) {
87
- const payload = this.#jsonBody(request);
88
- const target = await this.#load(module, configURL);
89
- switch (action) {
90
- case "get": {
91
- const value = Storage.getItem(payload?.scope ? this.#scopePath(target, payload.scope) : this.#storagePath(target, payload?.key), MISSING);
92
- return value === MISSING ? this.#response(request, 404, { error: "Stored path does not exist" }) : this.#response(request, 200, value);
93
- }
94
- case "set":
95
- if (!Object.hasOwn(payload ?? {}, "value")) throw Object.assign(new TypeError("A value is required"), { status: 400 });
96
- if (!Storage.setItem(this.#storagePath(target, payload.key), payload.value)) throw new Error("Storage write failed");
97
- return this.#response(request, 200, { saved: true });
98
- case "delete": {
99
- const path = payload?.scope ? this.#scopePath(target, payload.scope) : this.#storagePath(target, payload?.key);
100
- if (!Storage.removeItem(path)) throw new Error("Storage write failed");
101
- return this.#response(request, 200, { deleted: true });
102
- }
103
- }
63
+ const contentType = this.#header(result.headers, "content-type") ?? "application/json; charset=utf-8";
64
+ return {
65
+ status: result.statusCode ?? result.status,
66
+ headers: { "Content-Type": contentType, "Cache-Control": "no-store", "X-Content-Type-Options": "nosniff", ...(version ? { "X-PreferencePanes-Version": version } : {}) },
67
+ body: request.method === "HEAD" ? "" : typeof result.body === "string" ? result.body : new TextDecoder().decode(result.body),
68
+ };
104
69
  }
105
70
 
106
- async #load(module, configURL) {
107
- let result;
71
+ /**
72
+ * 使用唯一 form 字段中的完整 @root.path 执行存储操作。
73
+ * Execute a storage operation using the complete @root.path from the sole form field.
74
+ * @param {import("./index.js").SettingsRequest} request 代理请求 / Proxy request.
75
+ * @param {"get" | "set" | "delete"} action 存储动作 / Storage action.
76
+ * @returns {import("./index.js").SettingsResponse} 操作响应 / Operation response.
77
+ */
78
+ #store(request, action) {
79
+ const reply = (status, data) => this.#response(request, status, data);
80
+ if (request.method !== "POST") return reply(405, { error: "Use POST with a form body" });
81
+ const headers = Object.fromEntries(Object.entries(request.headers ?? {}).map(([key, value]) => [key.toLowerCase(), value]));
82
+ if (headers["content-type"]?.split(";")[0].trim().toLowerCase() !== "application/x-www-form-urlencoded") return reply(415, { error: "Expected application/x-www-form-urlencoded" });
83
+ if (typeof request.body !== "string" || request.body.length > 65536) return reply(400, { error: "Expected a form body up to 65536 characters" });
84
+ let parts, value;
108
85
  try {
109
- result = await transport({ url: configURL, method: "GET", timeout: 5000, headers: { Accept: "application/json" } });
86
+ const fields = request.body.split("&");
87
+ if (fields.length !== 1) throw new TypeError("Send exactly one storage key");
88
+ const separator = fields[0].indexOf("=");
89
+ if (separator < 0) throw new TypeError("Expected @root.path=value");
90
+ const key = decodeURIComponent(fields[0].slice(0, separator).replace(/\+/g, " "));
91
+ value = decodeURIComponent(fields[0].slice(separator + 1).replace(/\+/g, " "));
92
+ if (!key.startsWith("@")) throw new TypeError("Storage keys must start with @");
93
+ parts = validatePathParts(key.slice(1).split("."));
94
+ if (parts.length < 2) throw new TypeError("Specify a storage root and child path");
110
95
  } catch (error) {
111
- throw Object.assign(new Error(`Configuration request failed: ${error.message}`), { status: 502 });
96
+ return reply(400, { error: error.message });
112
97
  }
113
- const status = result.statusCode ?? result.status;
114
- if (status !== 200) throw Object.assign(new Error(`Configuration HTTP ${status}`), { status });
115
- try {
116
- const body = typeof result.body === "string" ? result.body : new TextDecoder().decode(result.body);
117
- const boxjs = JSON.parse(body);
118
- const apps = Array.isArray(boxjs) ? [{ settings: boxjs }] : (boxjs.apps ?? [boxjs]);
119
- if (!Array.isArray(apps)) throw new TypeError("Expected BoxJS apps array");
120
- const entries = [];
121
- let storageKey;
122
- for (const app of apps) {
123
- if (!app || !Array.isArray(app.settings)) throw new TypeError("Expected BoxJS settings array");
124
- for (const entry of app.settings) {
125
- if (typeof entry.id !== "string") throw new TypeError("BoxJS settings require string IDs");
126
- if (!entry.id.startsWith("@")) {
127
- if (Array.isArray(boxjs)) throw new TypeError("BoxJS settings require @root.path IDs");
128
- continue;
129
- }
130
- const [root, ...parts] = entry.id.slice(1).split(".");
131
- if (!root || root.startsWith("@") || parts.length < 2) throw new TypeError("A BoxJS setting must be below a literal storage root and module");
132
- validatePathParts(parts);
133
- if (parts[0] !== module) continue;
134
- if (storageKey && storageKey !== root) throw new TypeError(`A module must use one storage root: ${module}`);
135
- storageKey = root;
136
- entries.push(entry);
137
- }
98
+ if (action === "set") {
99
+ try {
100
+ value = JSON.parse(value);
101
+ } catch (error) {
102
+ if (!(error instanceof SyntaxError)) throw error;
138
103
  }
139
- if (!entries.length) throw new TypeError(`No BoxJS settings for module: ${module}`);
140
- return { boxjs, entries, module, storageKey, version: this.#header(result.headers, "x-preferencepanes-version") };
141
- } catch (error) {
142
- throw Object.assign(new Error(`Invalid BoxJS: ${error.message}`), { status: 422 });
143
104
  }
144
- }
145
-
146
- #jsonBody(request) {
147
- const headers = Object.fromEntries(Object.entries(request.headers ?? {}).map(([key, value]) => [key.toLowerCase(), value]));
148
- if (headers["content-type"]?.split(";")[0].trim().toLowerCase() !== "application/json") throw Object.assign(new TypeError("Expected application/json"), { status: 415 });
149
- if (typeof request.body !== "string" || request.body.length > 65536) throw Object.assign(new TypeError("Expected a JSON body up to 65536 characters"), { status: 400 });
105
+ const [storageKey, ...path] = parts;
150
106
  try {
151
- return JSON.parse(request.body);
107
+ const root = Storage.getItem(storageKey, {});
108
+ if (!isRecord(root)) throw new TypeError("Stored root must be an object");
109
+ const parent = storageParent(root, path, action === "set");
110
+ const key = path.at(-1);
111
+ switch (action) {
112
+ case "get": {
113
+ const result = parent ? _.get(parent, [key]) : undefined;
114
+ return result === undefined ? reply(404, { error: "Stored path does not exist" }) : reply(200, result);
115
+ }
116
+ case "set":
117
+ _.set(parent, [key], value);
118
+ break;
119
+ case "delete":
120
+ if (parent) _.unset(parent, [key]);
121
+ break;
122
+ }
123
+ if (!Storage.setItem(storageKey, root)) throw new Error("Storage write failed");
124
+ return reply(200, action === "set" ? { saved: true } : { deleted: true });
152
125
  } catch (error) {
153
- throw Object.assign(error, { status: 400 });
154
- }
155
- }
156
-
157
- #storagePath(target, key) {
158
- if (typeof key !== "string") throw Object.assign(new TypeError("A BoxJS field path is required"), { status: 400 });
159
- const path = `@${target.storageKey}.${key}`;
160
- if (!target.entries.some(entry => entry.id === path)) throw Object.assign(new TypeError(`Unknown BoxJS field: ${key}`), { status: 400 });
161
- return path;
162
- }
163
-
164
- #scopePath(target, scope) {
165
- switch (scope) {
166
- case "settings":
167
- return `@${target.storageKey}.${target.module}.Settings`;
168
- case "caches":
169
- return `@${target.storageKey}.${target.module}.Caches`;
170
- case "module":
171
- return `@${target.storageKey}.${target.module}`;
172
- default:
173
- throw Object.assign(new TypeError("Scope must be settings, caches or module"), { status: 400 });
126
+ return reply(500, { error: error.message });
174
127
  }
175
128
  }
176
129
 
177
- #response(request, status, body, extraHeaders = {}) {
130
+ #response(request, status, body) {
178
131
  return {
179
132
  status,
180
- headers: { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "no-store", "X-Content-Type-Options": "nosniff", ...extraHeaders },
133
+ headers: { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "no-store", "X-Content-Type-Options": "nosniff" },
181
134
  body: request.method === "HEAD" ? "" : JSON.stringify(body),
182
135
  };
183
136
  }
@@ -188,4 +141,44 @@ class API {
188
141
  }
189
142
  }
190
143
 
144
+ /**
145
+ * 判断存储根是否为普通对象。
146
+ * Determine whether a storage root is a plain object.
147
+ * @param {unknown} value 待检查值 / Value to inspect.
148
+ * @returns {boolean} 是否为普通对象 / Whether this is a plain object.
149
+ */
150
+ function isRecord(value) {
151
+ return value !== null && typeof value === "object" && Object.getPrototypeOf(value) === Object.prototype;
152
+ }
153
+
154
+ /**
155
+ * 遍历父路径,并解码旧存储中的 JSON 字符串中间节点。
156
+ * Traverse parent paths and decode legacy intermediate nodes stored as JSON strings.
157
+ * @param {Record<string, unknown>} root 存储根 / Storage root.
158
+ * @param {string[]} parts 完整路径 / Complete path.
159
+ * @param {boolean} create 是否创建缺失节点 / Whether to create missing parents.
160
+ * @returns {object | undefined} 父节点或 undefined / Parent node or undefined.
161
+ */
162
+ function storageParent(root, parts, create) {
163
+ let parent = root;
164
+ for (const part of parts.slice(0, -1)) {
165
+ let next = _.get(parent, [part]);
166
+ switch (typeof next) {
167
+ case "undefined":
168
+ if (!create) return;
169
+ next = {};
170
+ break;
171
+ case "string":
172
+ next = JSON.parse(next);
173
+ break;
174
+ default:
175
+ break;
176
+ }
177
+ if (!isRecord(next) && !Array.isArray(next)) throw new TypeError("Stored parent is not an object or array");
178
+ _.set(parent, [part], next);
179
+ parent = next;
180
+ }
181
+ return parent;
182
+ }
183
+
191
184
  new API().run();
@@ -1,8 +1,6 @@
1
- import { pageInputs } from "../lib/page-inputs.mjs";
2
-
3
1
  /**
4
- * 模块文档容器:原始 HTML 不改写,请求上下文随 iframe 元素传递。
5
- * Module document container: preserve HTML verbatim and carry request context on the iframe element.
2
+ * 模块文档容器:原始 HTML 不改写,只向 iframe 标记模块身份。
3
+ * Module document container: preserve HTML verbatim and mark only the module identity on the iframe.
6
4
  */
7
5
  export class ModuleFrame extends EventTarget {
8
6
  #url;
@@ -24,23 +22,25 @@ export class ModuleFrame extends EventTarget {
24
22
  };
25
23
 
26
24
  /**
27
- * 建立 iframe 与请求输入;调用方挂载 element 后调用 load。
28
- * Create the iframe and request inputs; callers mount element and then call load.
25
+ * 建立 iframe;调用方挂载 element 后调用 load。
26
+ * Create the iframe; callers mount element and then call load.
29
27
  * @param {string | URL} url 模块请求地址 / Module request URL.
30
- * @param {RequestInit} [options] 原生请求头和取消信号 / Native headers and cancellation signal.
28
+ * @param {{signal?: AbortSignal}} [options] 外部取消信号 / External cancellation signal.
31
29
  */
32
30
  constructor(url, options = {}) {
33
31
  super();
34
32
  this.#url = new URL(url, document.baseURI);
35
- this.#options = { ...options, headers: new Headers(options.headers) };
36
- const inputs = pageInputs(this.#url, Object.fromEntries(this.#options.headers));
33
+ const match = /^\/settings\/([a-zA-Z0-9_-]+)\/?$/.exec(this.#url.pathname);
34
+ if (!match) throw new TypeError("Open a concrete module URL");
35
+ this.#options = options;
37
36
  this.element = document.createElement("iframe");
38
- this.element.title = `${inputs.module} 设置`;
39
- this.element.dataset.preferencePanes = JSON.stringify(inputs);
37
+ this.element.title = `${match[1]} 设置`;
38
+ this.element.dataset.preferencePanes = "true";
39
+ this.element.dataset.preferencePanesModule = match[1];
40
40
  this.element.addEventListener("preferencepanes:change", this.#change);
41
41
  this.element.addEventListener("preferencepanes:confirm", this.#confirmation);
42
42
  this.element.addEventListener("preferencepanes:notice", this.#notice);
43
- this.#state = { title: inputs.module, module: inputs.module, busy: false, canGoBack: true, actions: [] };
43
+ this.#state = { title: match[1], module: match[1], busy: false, canGoBack: true, actions: [] };
44
44
  options.signal?.addEventListener("abort", this.#abort, { once: true });
45
45
  }
46
46
 
@@ -61,7 +61,7 @@ export class ModuleFrame extends EventTarget {
61
61
  if (this.#options.signal?.aborted) this.destroy();
62
62
  const timer = setTimeout(() => this.#controller.abort(), 10000);
63
63
  try {
64
- const response = await fetch(this.#url, { cache: "no-store", credentials: "omit", ...this.#options, signal: this.#controller.signal });
64
+ const response = await fetch(this.#url, { cache: "no-store", credentials: "omit", signal: this.#controller.signal });
65
65
  if (response.status !== 200) throw new Error(`HTTP ${response.status}`);
66
66
  const html = await response.text();
67
67
  this.#controller.signal.throwIfAborted();
@@ -4,7 +4,6 @@
4
4
  * @typedef {object} ModuleProbeOptions
5
5
  * @property {typeof globalThis.fetch} [fetch] 可注入的 fetch / Injectable fetch.
6
6
  * @property {AbortSignal} [signal] 外部取消信号 / External cancellation signal.
7
- * @property {string} [json] BoxJS JSON 来源,将随探测请求头传递 / BoxJS JSON source sent in the probe header.
8
7
  * @property {number} [timeout] 超时毫秒数,默认 3500 / Timeout in milliseconds, defaults to 3500.
9
8
  */
10
9
 
@@ -15,14 +14,14 @@
15
14
  * @param {ModuleProbeOptions} [options] 请求选项 / Request options.
16
15
  * @returns {Promise<Response>} 原始 HTTP 响应,可直接读取 status 和响应头 / Native HTTP response; read status and headers directly.
17
16
  */
18
- export async function probeModule(url, { fetch: request = globalThis.fetch, json, signal, timeout = 3500 } = {}) {
17
+ export async function probeModule(url, { fetch: request = globalThis.fetch, signal, timeout = 3500 } = {}) {
19
18
  const controller = new AbortController();
20
19
  const abort = () => controller.abort();
21
20
  if (signal?.aborted) abort();
22
21
  signal?.addEventListener("abort", abort, { once: true });
23
22
  const timer = setTimeout(() => controller.abort(), timeout);
24
23
  try {
25
- return await request(url, { method: "HEAD", cache: "no-store", credentials: "omit", signal: controller.signal, headers: json ? { "X-PreferencePanes-JSON": json } : undefined });
24
+ return await request(url, { method: "HEAD", cache: "no-store", credentials: "omit", signal: controller.signal });
26
25
  } finally {
27
26
  clearTimeout(timer);
28
27
  signal?.removeEventListener("abort", abort);
@@ -11,9 +11,9 @@ export class ModuleFrame extends EventTarget {
11
11
  * 创建容器。
12
12
  * Create a container.
13
13
  * @param url 模块地址 / Module URL.
14
- * @param options 原生请求参数 / Native request options.
14
+ * @param options 外部取消选项 / External cancellation options.
15
15
  */
16
- constructor(url: string | URL, options?: RequestInit);
16
+ constructor(url: string | URL, options?: { signal?: AbortSignal });
17
17
  /**
18
18
  * 宿主挂载节点。
19
19
  * Host-mounted element.
@@ -169,8 +169,6 @@ export interface ModuleProbeOptions {
169
169
  fetch?: typeof globalThis.fetch;
170
170
  /** 外部取消信号 / External cancellation signal. */
171
171
  signal?: AbortSignal;
172
- /** 传给模块 API 的 BoxJS JSON 来源 / BoxJS JSON source sent to the module API. */
173
- json?: string;
174
172
  /** 超时毫秒数,默认 3500 / Timeout in milliseconds, defaults to 3500. */
175
173
  timeout?: number;
176
174
  }
@@ -1,4 +1,4 @@
1
- import type { ModuleDefinition, ModuleModel, SettingsScalar } from "../index.js";
1
+ import type { ModuleDefinition, SettingsScalar } from "../index.js";
2
2
  /**
3
3
  * 单键写入或删除的通知事件。
4
4
  * Notification for a single-key write or delete.
@@ -16,12 +16,10 @@ export interface Notification {
16
16
  message?: string;
17
17
  }
18
18
  /**
19
- * 浏览器页面客户端选项;模型由 API 提供。
20
- * Browser page client options; the model is supplied by the API.
19
+ * 浏览器页面客户端选项;字段定义来自 BoxJS。
20
+ * Browser page client options; the field definition comes from BoxJS.
21
21
  */
22
22
  export interface PreferencesClientOptions {
23
- /** API 返回的模块模型 / Module model returned by the API. */
24
- model: ModuleModel;
25
23
  /** 用于渲染的归一化字段定义 / Normalized field definition for rendering. */
26
24
  definition: ModuleDefinition;
27
25
  /** 默认使用浏览器 fetch / Defaults to browser fetch. */
@@ -48,6 +46,8 @@ export interface ModuleSnapshot {
48
46
  export class PreferencesClient {
49
47
  /** 创建页面客户端 / Create the page client. */
50
48
  constructor(options: PreferencesClientOptions);
49
+ /** 读取设置并建立页面快照 / Read settings and establish the page snapshot. */
50
+ open(): Promise<ModuleSnapshot>;
51
51
  /** 获取页面快照,不发请求 / Get a page snapshot without a request. */
52
52
  snapshot(): ModuleSnapshot;
53
53
  /** 读取 Settings 子树 / Read the Settings subtree. */
@@ -1,31 +1,55 @@
1
+ import { normalizeStoredValue, validValue } from "./boxjs.mjs";
2
+
1
3
  /**
2
4
  * 管理单模块页面的 API 请求、值快照和会话终止。
3
5
  * Manage API requests, value snapshots, and session termination for one module page.
4
6
  */
5
7
  export class PreferencesClient {
6
8
  #module;
7
- #configURL;
8
9
  #definition;
9
10
  #request;
10
11
  #notify;
11
12
  #timeout;
12
13
  #session = new AbortController();
13
- #values;
14
+ #values = {};
14
15
  #saving = false;
15
16
 
16
17
  /**
17
- * 创建只调用模块 API、不读取或解析 BoxJS 的页面客户端。
18
- * Create a page client that only calls the module API and never reads or parses BoxJS.
19
- * @param {import("./client.mjs").PreferencesClientOptions} options API 模型、请求与通知 / API model, requests, and notifications.
18
+ * 创建从 BoxJS 定义读取和持久化设置的页面客户端。
19
+ * Create a page client that reads and persists settings from a BoxJS definition.
20
+ * @param {import("./client.mjs").PreferencesClientOptions} options 字段定义、请求与通知 / Field definition, requests, and notifications.
20
21
  */
21
- constructor({ model, definition, fetch: request = globalThis.fetch.bind(globalThis), notify = () => {}, timeout = 10000 }) {
22
- this.#module = model.module;
23
- this.#configURL = model.configURL;
22
+ constructor({ definition, fetch: request = globalThis.fetch.bind(globalThis), notify = () => {}, timeout = 10000 }) {
23
+ this.#module = definition.module;
24
24
  this.#definition = definition;
25
25
  this.#request = request;
26
26
  this.#notify = notify;
27
27
  this.#timeout = timeout;
28
- this.#values = structuredClone(model.values);
28
+ }
29
+
30
+ /**
31
+ * 读取一次 Settings 子树并建立页面值快照。
32
+ * Read the Settings subtree once and establish the page value snapshot.
33
+ * @returns {Promise<import("./client.mjs").ModuleSnapshot>} 页面快照 / Page snapshot.
34
+ */
35
+ async open() {
36
+ let subtree = await this.readSettings();
37
+ if (subtree === undefined) subtree = {};
38
+ if (typeof subtree === "string") subtree = JSON.parse(subtree);
39
+ if (!subtree || typeof subtree !== "object" || Array.isArray(subtree)) throw new TypeError("Expected a settings subtree object");
40
+ const values = {};
41
+ for (const field of this.#definition.fields) {
42
+ const stored = field.key
43
+ .split(".")
44
+ .slice(this.#definition.settingsPath.length)
45
+ .reduce((parent, part) => Object(parent)[part], subtree);
46
+ const value = normalizeStoredValue(field, stored === undefined ? field.defaultValue : stored);
47
+ if (value === undefined) continue;
48
+ if (!validValue(field, value)) throw new TypeError(`Invalid stored value: ${field.key}`);
49
+ values[field.key] = value;
50
+ }
51
+ this.#values = values;
52
+ return this.snapshot();
29
53
  }
30
54
 
31
55
  /**
@@ -43,7 +67,7 @@ export class PreferencesClient {
43
67
  * @returns {Promise<unknown>} Settings 内容或 undefined / Settings content or undefined.
44
68
  */
45
69
  async readSettings() {
46
- const response = await this.#send("get", { scope: "settings" });
70
+ const response = await this.#send("get", `@${this.#definition.storageKey}.${this.#definition.settingsPath.join(".")}`);
47
71
  return response.status === 404 ? undefined : response.json();
48
72
  }
49
73
 
@@ -53,7 +77,7 @@ export class PreferencesClient {
53
77
  * @returns {Promise<unknown>} Caches 内容或 undefined / Caches content or undefined.
54
78
  */
55
79
  async readCaches() {
56
- const response = await this.#send("get", { scope: "caches" });
80
+ const response = await this.#send("get", `@${this.#definition.storageKey}.${this.#module}.Caches`);
57
81
  return response.status === 404 ? undefined : response.json();
58
82
  }
59
83
 
@@ -63,7 +87,7 @@ export class PreferencesClient {
63
87
  * @returns {Promise<void>} 操作完成 / Operation completion.
64
88
  */
65
89
  clearCaches() {
66
- return this.#change("delete", { scope: "caches" }, "clearCaches");
90
+ return this.#change("delete", `${this.#module}.Caches`, undefined, "clearCaches");
67
91
  }
68
92
 
69
93
  /**
@@ -72,7 +96,7 @@ export class PreferencesClient {
72
96
  * @returns {Promise<void>} 操作完成 / Operation completion.
73
97
  */
74
98
  reset() {
75
- return this.#change("delete", { scope: "module" }, "reset");
99
+ return this.#change("delete", this.#module, undefined, "reset");
76
100
  }
77
101
 
78
102
  /**
@@ -92,7 +116,7 @@ export class PreferencesClient {
92
116
  * @returns {Promise<void>} 操作完成 / Operation completion.
93
117
  */
94
118
  set(key, value) {
95
- return this.#change("set", { key, value }, "write", key);
119
+ return this.#change("set", key, value, "write");
96
120
  }
97
121
 
98
122
  /**
@@ -102,30 +126,31 @@ export class PreferencesClient {
102
126
  * @returns {Promise<void>} 操作完成 / Operation completion.
103
127
  */
104
128
  remove(key) {
105
- return this.#change("delete", { key }, "delete", key);
129
+ return this.#change("delete", key, undefined, "delete");
106
130
  }
107
131
 
108
132
  /**
109
- * 向模块 API 发送 JSON 动作。
110
- * Send a JSON action to the module API.
111
- * @param {"get" | "set" | "delete"} action 模块动作 / Module action.
112
- * @param {unknown} payload JSON 请求体 / JSON request body.
133
+ * 向固定存储 API 发送完整路径的 form 动作。
134
+ * Send a complete-path form action to the fixed storage API.
135
+ * @param {"get" | "set" | "delete"} action 存储动作 / Storage action.
136
+ * @param {string} path 完整 @root.path / Complete @root.path.
137
+ * @param {unknown} [value] set 写入值 / Value written by set.
113
138
  * @returns {Promise<Response>} 原始响应 / Raw response.
114
139
  */
115
- async #send(action, payload) {
140
+ async #send(action, path, value) {
116
141
  const controller = new AbortController();
117
142
  const abort = () => controller.abort();
118
143
  if (this.#session.signal.aborted) abort();
119
144
  this.#session.signal.addEventListener("abort", abort, { once: true });
120
145
  const timer = setTimeout(abort, this.#timeout);
121
146
  try {
122
- const response = await this.#request(`/api/${encodeURIComponent(this.#module)}/${action}`, {
147
+ const response = await this.#request(`/api/${action}`, {
123
148
  method: "POST",
124
149
  credentials: "omit",
125
150
  cache: "no-store",
126
151
  signal: controller.signal,
127
- headers: { "Content-Type": "application/json", "X-PreferencePanes-JSON": this.#configURL },
128
- body: JSON.stringify(payload),
152
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
153
+ body: new URLSearchParams([[path, action === "set" ? JSON.stringify(value) : ""]]).toString(),
129
154
  });
130
155
  if (response.status !== 200 && !(action === "get" && response.status === 404)) throw new Error(`HTTP ${response.status}`);
131
156
  return response;
@@ -139,24 +164,28 @@ export class PreferencesClient {
139
164
  * 执行写入动作;成功后只更新当前页面值。
140
165
  * Execute a mutation and update only the current page values after success.
141
166
  * @param {"set" | "delete"} action API 动作 / API action.
142
- * @param {Record<string, unknown>} payload JSON 请求体 / JSON request body.
167
+ * @param {string} key 不含存储根的路径 / Path without the storage root.
168
+ * @param {unknown} value set 写入值 / Value written by set.
143
169
  * @param {"write" | "delete" | "clearCaches" | "reset"} operation 通知操作 / Notification operation.
144
- * @param {string} [key] 字段路径 / Field path.
145
170
  * @returns {Promise<void>} 操作完成 / Operation completion.
146
171
  */
147
- async #change(action, payload, operation, key) {
172
+ async #change(action, key, value, operation) {
148
173
  if (this.#saving) throw new Error("A settings write is already in progress");
149
174
  this.#saving = true;
150
175
  try {
151
- await this.#send(action, payload);
176
+ let field;
177
+ if (operation === "write" || operation === "delete") {
178
+ field = this.#definition.fields.find(candidate => candidate.key === key);
179
+ if (!field || (operation === "write" && !validValue(field, value))) throw new TypeError("Invalid setting value");
180
+ }
181
+ await this.#send(action, `@${this.#definition.storageKey}.${key}`, value);
152
182
  switch (operation) {
153
183
  case "write":
154
- this.#values[key] = structuredClone(payload.value);
184
+ this.#values[key] = structuredClone(value);
155
185
  break;
156
186
  case "delete": {
157
- const field = this.#definition.fields.find(candidate => candidate.key === key);
158
187
  delete this.#values[key];
159
- if (field && Object.hasOwn(field, "defaultValue")) this.#values[key] = structuredClone(field.defaultValue);
188
+ if (Object.hasOwn(field, "defaultValue")) this.#values[key] = structuredClone(field.defaultValue);
160
189
  break;
161
190
  }
162
191
  case "clearCaches":