@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/README.md +63 -47
- package/dist/api.js +114 -124
- package/dist/module/index.html +1 -1
- package/dist/module/index.mjs +188 -218
- package/dist/module/navigation.mjs +15 -32
- package/dist/preference-panes.mjs +83 -72
- package/dist/web.js +2 -23
- package/package.json +2 -2
- package/src/api.mjs +117 -124
- package/src/browser/ModuleFrame.mjs +13 -13
- package/src/browser/ModuleStatus.mjs +2 -3
- package/src/browser/Navigation.d.mts +2 -4
- package/src/browser/client.d.mts +5 -5
- package/src/browser/client.mjs +59 -30
- package/src/browser/index.d.ts +5 -21
- package/src/browser/index.mjs +16 -48
- package/src/browser/mount.mjs +16 -32
- package/src/browser/panel.mjs +9 -9
- package/src/index.d.ts +2 -24
- package/src/index.mjs +3 -3
- package/src/web.mjs +1 -5
- package/src/build.mjs +0 -20
- package/src/lib/page-inputs.mjs +0 -17
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nsnanocat/preference-panes",
|
|
3
|
-
"version": "1.1.
|
|
4
|
-
"description": "
|
|
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
|
|
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
|
-
*
|
|
35
|
-
* Handle
|
|
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
|
|
42
|
-
if (
|
|
43
|
-
const
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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:
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
|
|
107
|
-
|
|
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
|
-
|
|
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
|
-
|
|
96
|
+
return reply(400, { error: error.message });
|
|
112
97
|
}
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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"
|
|
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
|
|
5
|
-
* Module document container: preserve HTML verbatim and
|
|
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
|
|
28
|
-
* Create the iframe
|
|
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 {
|
|
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
|
-
|
|
36
|
-
|
|
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 = `${
|
|
39
|
-
this.element.dataset.preferencePanes =
|
|
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:
|
|
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",
|
|
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,
|
|
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
|
|
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
|
|
14
|
+
* @param options 外部取消选项 / External cancellation options.
|
|
15
15
|
*/
|
|
16
|
-
constructor(url: string | URL, options?:
|
|
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
|
}
|
package/src/browser/client.d.mts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { ModuleDefinition,
|
|
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
|
-
*
|
|
20
|
-
* Browser page client options; the
|
|
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. */
|
package/src/browser/client.mjs
CHANGED
|
@@ -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
|
-
*
|
|
18
|
-
* Create a page client that
|
|
19
|
-
* @param {import("./client.mjs").PreferencesClientOptions} options
|
|
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({
|
|
22
|
-
this.#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
|
-
|
|
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", {
|
|
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", {
|
|
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", {
|
|
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",
|
|
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",
|
|
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",
|
|
129
|
+
return this.#change("delete", key, undefined, "delete");
|
|
106
130
|
}
|
|
107
131
|
|
|
108
132
|
/**
|
|
109
|
-
*
|
|
110
|
-
* Send a
|
|
111
|
-
* @param {"get" | "set" | "delete"} action
|
|
112
|
-
* @param {
|
|
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,
|
|
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/${
|
|
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/
|
|
128
|
-
body: JSON.stringify(
|
|
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 {
|
|
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,
|
|
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
|
-
|
|
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(
|
|
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 (
|
|
188
|
+
if (Object.hasOwn(field, "defaultValue")) this.#values[key] = structuredClone(field.defaultValue);
|
|
160
189
|
break;
|
|
161
190
|
}
|
|
162
191
|
case "clearCaches":
|