@nsnanocat/preference-panes 1.1.1 → 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/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @nsnanocat/preference-panes
2
2
 
3
- PreferencePanes 提供一个由 BoxJS JSON 驱动的通用设置前端,以及一个独立的代理持久化 API。业务模块只发布自己的 `/configs/{module}` 和 `/api/{module}`;通用 `/settings/**` 前端只需要安装一次。
3
+ PreferencePanes 提供一个由 BoxJS JSON 驱动的通用设置前端,以及一个独立的代理配置与持久化 API。业务模块只发布自己的 `/configs/{module}`;通用 `/settings/**` 前端和 `/api/**` 后端分别只需要安装一次。
4
4
 
5
5
  在 Biliverse 中,Enhanced 是唯一安装 `web.js` 的模块。Global、Redirect、ADBlock 不携带 `web.js`,它们的设置页仍由同一份通用前端读取各自 BoxJS 后渲染。
6
6
 
@@ -16,7 +16,7 @@ interface MountedPreferences { destroy(): void }
16
16
  ```js
17
17
  import { mount } from "@nsnanocat/preference-panes/browser";
18
18
 
19
- const boxjs = await fetch("/configs/Module", {
19
+ const boxjs = await fetch("/api/Module", {
20
20
  cache: "no-store",
21
21
  credentials: "omit",
22
22
  }).then(response => response.json());
@@ -39,29 +39,27 @@ preferences.destroy();
39
39
  - `GET /settings/assets/index.mjs`
40
40
  - `GET /settings/assets/navigation.mjs`
41
41
 
42
- 模块页面从 URL 或 `ModuleFrame` 的模块标记取得模块名,直接读取同源 `/configs/{module}`,然后调用 `mount(boxjs)`。页面不接受 JSON/CSS 查询参数、私有请求头或兼容资源别名。
42
+ 模块页面从 URL 或 `ModuleFrame` 的模块标记取得模块名,通过同源 `/api/{module}` 读取原始 BoxJS,然后调用 `mount(boxjs)`。页面不接受 JSON/CSS 查询参数、私有请求头或兼容资源别名,也不直接访问 `/configs/**`。
43
43
 
44
44
  `api.js` 只处理:
45
45
 
46
46
  - `HEAD /api/{module}`:探测同源 `/configs/{module}`,透传状态与 `X-PreferencePanes-Version`。
47
- - `POST /api/{module}/get`:读取声明字段或 Settings、Caches、module 子树。
48
- - `POST /api/{module}/set`:写入当前 BoxJS 声明的字段。
49
- - `POST /api/{module}/delete`:删除声明字段或模块子树。
47
+ - `GET /api/{module}`:原样返回同源 `/configs/{module}` 的 BoxJS JSON 与版本头。
48
+ - `POST /api/get`:读取完整 `@root.path`。
49
+ - `POST /api/set`:写入完整 `@root.path`。
50
+ - `POST /api/delete`:删除完整 `@root.path` 或子树。
50
51
 
51
- `GET /api/{module}` 和其它未规定方法返回 `405`。API 每次动作都从 `/configs/{module}` 建立字段目录,只负责字段授权与 util `Storage` 读写,不解释控件、选项或默认值。
52
+ 模块 API 只转发业务配置;固定存储 API 不下载或解析 BoxJS。BoxJS 字段、控件、选项、默认值与写入值都由浏览器校验。
52
53
 
53
54
  ```js
54
- await fetch("/api/Enhanced/set", {
55
+ await fetch("/api/set", {
55
56
  method: "POST",
56
- headers: { "Content-Type": "application/json" },
57
- body: JSON.stringify({
58
- key: "Enhanced.Settings.Home.Top_left",
59
- value: "mine",
60
- }),
57
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
58
+ body: new URLSearchParams([["@BiliBili.Enhanced.Settings.Home.Top_left", JSON.stringify("mine")]]),
61
59
  });
62
60
  ```
63
61
 
64
- 页面初始化时只执行一次 `POST /api/{module}/get` 读取 Settings 子树。写入成功后仅更新当前页面快照;查看 Settings/Caches 时按需读取,清空和重置通过 delete 动作完成。
62
+ 页面初始化时只执行一次 `POST /api/get` 读取 Settings 子树。写入成功后仅更新当前页面快照;查看 Settings/Caches 时按需读取,清空和重置通过 `/api/delete` 完成。
65
63
 
66
64
  ## 宿主集成
67
65
 
package/dist/api.js CHANGED
@@ -1921,11 +1921,9 @@
1921
1921
  return parts;
1922
1922
  }
1923
1923
 
1924
- const MISSING = Symbol("missing");
1925
-
1926
1924
  /**
1927
- * PreferencePanes 后端 API,只处理模块数据和持久化请求。
1928
- * PreferencePanes backend API handling only module data and persistence requests.
1925
+ * PreferencePanes 后端 API,只转发模块配置并提供通用持久化操作。
1926
+ * PreferencePanes backend API only relaying module configurations and providing generic persistence operations.
1929
1927
  */
1930
1928
  class API {
1931
1929
  /**
@@ -1947,133 +1945,107 @@
1947
1945
  }
1948
1946
 
1949
1947
  /**
1950
- * 处理 `/api/{module}` 及其动作,不接管页面或静态资源。
1951
- * Handle `/api/{module}` and its actions without intercepting pages or static assets.
1948
+ * 处理模块配置 API 与固定存储动作,不接管页面或静态资源。
1949
+ * Handle module configuration APIs and fixed storage actions without intercepting pages or static assets.
1952
1950
  * @param {import("./index.js").SettingsRequest} request 代理请求 / Proxy request.
1953
1951
  * @returns {Promise<import("./index.js").SettingsResponse | undefined>} API 响应或未接管 / API response or pass-through.
1954
1952
  */
1955
1953
  async handle(request) {
1956
1954
  const url = new URL(request.url);
1957
- const match = /^\/api\/([a-zA-Z0-9_-]+)(?:\/(get|set|delete))?\/?$/.exec(url.pathname);
1958
- if (!match) return;
1959
- const [, module, action] = match;
1960
- const configuration = `${url.origin}/configs/${module}`;
1961
- switch (true) {
1962
- case !action && request.method === "HEAD":
1963
- return this.#probe(request, configuration);
1964
- case Boolean(action) && request.method === "POST":
1965
- return this.#action(request, module, action, configuration);
1966
- default:
1967
- return this.#response(request, 405, { error: "Use HEAD for module probes and POST for module actions" });
1968
- }
1955
+ const action = /^\/api\/(get|set|delete)\/?$/.exec(url.pathname)?.[1];
1956
+ if (action) return this.#store(request, action);
1957
+ const module = /^\/api\/([a-zA-Z0-9_-]+)\/?$/.exec(url.pathname)?.[1];
1958
+ if (!module) return;
1959
+ if (!["HEAD", "GET"].includes(request.method)) return this.#response(request, 405, { error: "Use GET or HEAD for module configuration" });
1960
+ return this.#configuration(request, `${url.origin}/configs/${module}`);
1969
1961
  }
1970
1962
 
1971
- async #probe(request, configuration) {
1963
+ /**
1964
+ * 从同源业务配置响应模块探测或原始 BoxJS JSON。
1965
+ * Respond to a module probe or raw BoxJS JSON from the same-origin business configuration.
1966
+ * @param {import("./index.js").SettingsRequest} request 代理请求 / Proxy request.
1967
+ * @param {string} configuration 同源配置地址 / Same-origin configuration URL.
1968
+ * @returns {Promise<import("./index.js").SettingsResponse>} 配置响应 / Configuration response.
1969
+ */
1970
+ async #configuration(request, configuration) {
1972
1971
  let result;
1973
1972
  try {
1974
- result = await fetch({ url: configuration, method: "HEAD", timeout: 5000, headers: { Accept: "application/json" } });
1973
+ result = await fetch({ url: configuration, method: request.method, timeout: 5000, headers: { Accept: "application/json" } });
1975
1974
  } catch (error) {
1976
1975
  return this.#response(request, 502, { error: error.message });
1977
1976
  }
1978
1977
  const version = this.#header(result.headers, "x-preferencepanes-version");
1979
- return this.#response(request, result.statusCode ?? result.status, undefined, version ? { "X-PreferencePanes-Version": version } : {});
1980
- }
1981
-
1982
- async #action(request, module, action, configuration) {
1983
- const payload = this.#jsonBody(request);
1984
- const target = await this.#load(module, configuration);
1985
- switch (action) {
1986
- case "get": {
1987
- const value = Storage.getItem(payload?.scope ? this.#scopePath(target, payload.scope) : this.#storagePath(target, payload?.key), MISSING);
1988
- return value === MISSING ? this.#response(request, 404, { error: "Stored path does not exist" }) : this.#response(request, 200, value);
1989
- }
1990
- case "set":
1991
- if (!Object.hasOwn(payload ?? {}, "value")) throw Object.assign(new TypeError("A value is required"), { status: 400 });
1992
- if (!Storage.setItem(this.#storagePath(target, payload.key), payload.value)) throw new Error("Storage write failed");
1993
- return this.#response(request, 200, { saved: true });
1994
- case "delete": {
1995
- const path = payload?.scope ? this.#scopePath(target, payload.scope) : this.#storagePath(target, payload?.key);
1996
- if (!Storage.removeItem(path)) throw new Error("Storage write failed");
1997
- return this.#response(request, 200, { deleted: true });
1998
- }
1999
- }
1978
+ const contentType = this.#header(result.headers, "content-type") ?? "application/json; charset=utf-8";
1979
+ return {
1980
+ status: result.statusCode ?? result.status,
1981
+ headers: { "Content-Type": contentType, "Cache-Control": "no-store", "X-Content-Type-Options": "nosniff", ...(version ? { "X-PreferencePanes-Version": version } : {}) },
1982
+ body: request.method === "HEAD" ? "" : typeof result.body === "string" ? result.body : new TextDecoder().decode(result.body),
1983
+ };
2000
1984
  }
2001
1985
 
2002
- async #load(module, configuration) {
2003
- let result;
1986
+ /**
1987
+ * 使用唯一 form 字段中的完整 @root.path 执行存储操作。
1988
+ * Execute a storage operation using the complete @root.path from the sole form field.
1989
+ * @param {import("./index.js").SettingsRequest} request 代理请求 / Proxy request.
1990
+ * @param {"get" | "set" | "delete"} action 存储动作 / Storage action.
1991
+ * @returns {import("./index.js").SettingsResponse} 操作响应 / Operation response.
1992
+ */
1993
+ #store(request, action) {
1994
+ const reply = (status, data) => this.#response(request, status, data);
1995
+ if (request.method !== "POST") return reply(405, { error: "Use POST with a form body" });
1996
+ const headers = Object.fromEntries(Object.entries(request.headers ?? {}).map(([key, value]) => [key.toLowerCase(), value]));
1997
+ if (headers["content-type"]?.split(";")[0].trim().toLowerCase() !== "application/x-www-form-urlencoded") return reply(415, { error: "Expected application/x-www-form-urlencoded" });
1998
+ if (typeof request.body !== "string" || request.body.length > 65536) return reply(400, { error: "Expected a form body up to 65536 characters" });
1999
+ let parts, value;
2004
2000
  try {
2005
- result = await fetch({ url: configuration, method: "GET", timeout: 5000, headers: { Accept: "application/json" } });
2001
+ const fields = request.body.split("&");
2002
+ if (fields.length !== 1) throw new TypeError("Send exactly one storage key");
2003
+ const separator = fields[0].indexOf("=");
2004
+ if (separator < 0) throw new TypeError("Expected @root.path=value");
2005
+ const key = decodeURIComponent(fields[0].slice(0, separator).replace(/\+/g, " "));
2006
+ value = decodeURIComponent(fields[0].slice(separator + 1).replace(/\+/g, " "));
2007
+ if (!key.startsWith("@")) throw new TypeError("Storage keys must start with @");
2008
+ parts = validatePathParts(key.slice(1).split("."));
2009
+ if (parts.length < 2) throw new TypeError("Specify a storage root and child path");
2006
2010
  } catch (error) {
2007
- throw Object.assign(new Error(`Configuration request failed: ${error.message}`), { status: 502 });
2011
+ return reply(400, { error: error.message });
2008
2012
  }
2009
- const status = result.statusCode ?? result.status;
2010
- if (status !== 200) throw Object.assign(new Error(`Configuration HTTP ${status}`), { status });
2011
- try {
2012
- const body = typeof result.body === "string" ? result.body : new TextDecoder().decode(result.body);
2013
- const boxjs = JSON.parse(body);
2014
- const apps = Array.isArray(boxjs) ? [{ settings: boxjs }] : (boxjs.apps ?? [boxjs]);
2015
- if (!Array.isArray(apps)) throw new TypeError("Expected BoxJS apps array");
2016
- const entries = [];
2017
- let storageKey;
2018
- for (const app of apps) {
2019
- if (!app || !Array.isArray(app.settings)) throw new TypeError("Expected BoxJS settings array");
2020
- for (const entry of app.settings) {
2021
- if (typeof entry.id !== "string") throw new TypeError("BoxJS settings require string IDs");
2022
- if (!entry.id.startsWith("@")) {
2023
- if (Array.isArray(boxjs)) throw new TypeError("BoxJS settings require @root.path IDs");
2024
- continue;
2025
- }
2026
- const [root, ...parts] = entry.id.slice(1).split(".");
2027
- if (!root || root.startsWith("@") || parts.length < 2) throw new TypeError("A BoxJS setting must be below a literal storage root and module");
2028
- validatePathParts(parts);
2029
- if (parts[0] !== module) continue;
2030
- if (storageKey && storageKey !== root) throw new TypeError(`A module must use one storage root: ${module}`);
2031
- storageKey = root;
2032
- entries.push(entry);
2033
- }
2013
+ if (action === "set") {
2014
+ try {
2015
+ value = JSON.parse(value);
2016
+ } catch (error) {
2017
+ if (!(error instanceof SyntaxError)) throw error;
2034
2018
  }
2035
- if (!entries.length) throw new TypeError(`No BoxJS settings for module: ${module}`);
2036
- return { entries, module, storageKey, version: this.#header(result.headers, "x-preferencepanes-version") };
2037
- } catch (error) {
2038
- throw Object.assign(new Error(`Invalid BoxJS: ${error.message}`), { status: 422 });
2039
2019
  }
2040
- }
2041
-
2042
- #jsonBody(request) {
2043
- const headers = Object.fromEntries(Object.entries(request.headers ?? {}).map(([key, value]) => [key.toLowerCase(), value]));
2044
- if (headers["content-type"]?.split(";")[0].trim().toLowerCase() !== "application/json") throw Object.assign(new TypeError("Expected application/json"), { status: 415 });
2045
- if (typeof request.body !== "string" || request.body.length > 65536) throw Object.assign(new TypeError("Expected a JSON body up to 65536 characters"), { status: 400 });
2020
+ const [storageKey, ...path] = parts;
2046
2021
  try {
2047
- return JSON.parse(request.body);
2022
+ const root = Storage.getItem(storageKey, {});
2023
+ if (!isRecord(root)) throw new TypeError("Stored root must be an object");
2024
+ const parent = storageParent(root, path, action === "set");
2025
+ const key = path.at(-1);
2026
+ switch (action) {
2027
+ case "get": {
2028
+ const result = parent ? Lodash.get(parent, [key]) : undefined;
2029
+ return result === undefined ? reply(404, { error: "Stored path does not exist" }) : reply(200, result);
2030
+ }
2031
+ case "set":
2032
+ Lodash.set(parent, [key], value);
2033
+ break;
2034
+ case "delete":
2035
+ if (parent) Lodash.unset(parent, [key]);
2036
+ break;
2037
+ }
2038
+ if (!Storage.setItem(storageKey, root)) throw new Error("Storage write failed");
2039
+ return reply(200, action === "set" ? { saved: true } : { deleted: true });
2048
2040
  } catch (error) {
2049
- throw Object.assign(error, { status: 400 });
2050
- }
2051
- }
2052
-
2053
- #storagePath(target, key) {
2054
- if (typeof key !== "string") throw Object.assign(new TypeError("A BoxJS field path is required"), { status: 400 });
2055
- const path = `@${target.storageKey}.${key}`;
2056
- if (!target.entries.some(entry => entry.id === path)) throw Object.assign(new TypeError(`Unknown BoxJS field: ${key}`), { status: 400 });
2057
- return path;
2058
- }
2059
-
2060
- #scopePath(target, scope) {
2061
- switch (scope) {
2062
- case "settings":
2063
- return `@${target.storageKey}.${target.module}.Settings`;
2064
- case "caches":
2065
- return `@${target.storageKey}.${target.module}.Caches`;
2066
- case "module":
2067
- return `@${target.storageKey}.${target.module}`;
2068
- default:
2069
- throw Object.assign(new TypeError("Scope must be settings, caches or module"), { status: 400 });
2041
+ return reply(500, { error: error.message });
2070
2042
  }
2071
2043
  }
2072
2044
 
2073
- #response(request, status, body, extraHeaders = {}) {
2045
+ #response(request, status, body) {
2074
2046
  return {
2075
2047
  status,
2076
- headers: { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "no-store", "X-Content-Type-Options": "nosniff", ...extraHeaders },
2048
+ headers: { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "no-store", "X-Content-Type-Options": "nosniff" },
2077
2049
  body: request.method === "HEAD" ? "" : JSON.stringify(body),
2078
2050
  };
2079
2051
  }
@@ -2084,6 +2056,44 @@
2084
2056
  }
2085
2057
  }
2086
2058
 
2059
+ /**
2060
+ * 判断存储根是否为普通对象。
2061
+ * Determine whether a storage root is a plain object.
2062
+ * @param {unknown} value 待检查值 / Value to inspect.
2063
+ * @returns {boolean} 是否为普通对象 / Whether this is a plain object.
2064
+ */
2065
+ function isRecord(value) {
2066
+ return value !== null && typeof value === "object" && Object.getPrototypeOf(value) === Object.prototype;
2067
+ }
2068
+
2069
+ /**
2070
+ * 遍历父路径,并解码旧存储中的 JSON 字符串中间节点。
2071
+ * Traverse parent paths and decode legacy intermediate nodes stored as JSON strings.
2072
+ * @param {Record<string, unknown>} root 存储根 / Storage root.
2073
+ * @param {string[]} parts 完整路径 / Complete path.
2074
+ * @param {boolean} create 是否创建缺失节点 / Whether to create missing parents.
2075
+ * @returns {object | undefined} 父节点或 undefined / Parent node or undefined.
2076
+ */
2077
+ function storageParent(root, parts, create) {
2078
+ let parent = root;
2079
+ for (const part of parts.slice(0, -1)) {
2080
+ let next = Lodash.get(parent, [part]);
2081
+ switch (typeof next) {
2082
+ case "undefined":
2083
+ if (!create) return;
2084
+ next = {};
2085
+ break;
2086
+ case "string":
2087
+ next = JSON.parse(next);
2088
+ break;
2089
+ }
2090
+ if (!isRecord(next) && !Array.isArray(next)) throw new TypeError("Stored parent is not an object or array");
2091
+ Lodash.set(parent, [part], next);
2092
+ parent = next;
2093
+ }
2094
+ return parent;
2095
+ }
2096
+
2087
2097
  new API().run();
2088
2098
 
2089
2099
  })();
@@ -9,6 +9,6 @@
9
9
  </head>
10
10
  <body>
11
11
  <main id="preferences"></main>
12
- <script type="module" src="/settings/assets/index.mjs?v=1.1.1"></script>
12
+ <script type="module" src="/settings/assets/index.mjs?v=1.1.2"></script>
13
13
  </body>
14
14
  </html>