@nsnanocat/preference-panes 0.7.2 → 0.8.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.
@@ -1,11 +1,3 @@
1
- /**
2
- * 解析已经取得的 pathname,避免重复构造 URL。
3
- * Parse an existing pathname without constructing another URL.
4
- * @param {string} pathname 以 / 开头的 URL pathname / URL pathname beginning with /.
5
- * @returns {string[] | undefined} 解码后的路径,非 API 路径不处理 / Decoded path, or undefined outside /api/.
6
- * @throws {TypeError} 转义编码或路径片段非法 / Invalid percent encoding or path segments.
7
- */
8
-
9
1
  /**
10
2
  * 校验原始路径片段,不进行 URL 编码转换。
11
3
  * Validate raw path segments without URL encoding conversion.
@@ -116,7 +108,7 @@ function pageInputs(url, headers = {}) {
116
108
  const module = match[1];
117
109
  const values = Object.fromEntries(Object.entries(headers).map(([key, value]) => [key.toLowerCase(), value]));
118
110
  const json = values["x-preferencepanes-json"] ?? url.searchParams.get("json") ?? `/configs/${module}`;
119
- const css = values["x-preferencepanes-css"] ?? url.searchParams.get("css") ?? `/settings/assets/${module}.css`;
111
+ const css = values["x-preferencepanes-css"] ?? url.searchParams.get("css") ?? "";
120
112
  if (!json.trim()) throw new TypeError("JSON resource URL is required");
121
113
  return { url: url.href, module, json, css };
122
114
  }
@@ -322,31 +314,31 @@ function createPreferencesClient({ catalog, fetch: request = globalThis.fetch.bi
322
314
  */
323
315
  const sessions = new Map();
324
316
  /**
325
- * 发送同源请求,处理超时与取消;数据 GET 404 交给调用方处理。
326
- * Send a same-origin request with timeout and cancellation; callers handle missing-data GET responses.
327
- * @param {string} path 相对请求路径 / Relative request path.
328
- * @param {"HEAD" | "GET" | "POST" | "DELETE"} method HTTP 方法 / HTTP method.
329
- * @param {unknown} body POST 值,其它方法忽略 / POST value, ignored by other methods.
317
+ * form 发送完整存储键;读取 404 交给调用方处理。
318
+ * Send a complete storage key as form data; callers handle missing reads.
319
+ * @param {string} path 完整 @root.path / Complete @root.path.
320
+ * @param {"get" | "set" | "delete"} action 存储操作 / Storage operation.
321
+ * @param {unknown} body set 值,其它操作忽略 / Set value, ignored by other operations.
330
322
  * @param {AbortSignal | undefined} signal 会话取消信号 / Session cancellation signal.
331
323
  * @returns {Promise<Response>} 未消费正文的响应 / Response with an unread body.
332
324
  * @throws {Error} 非 200 且非数据 GET 404、超时、取消或网络错误 / Non-200 status except missing-data GETs, timeout, cancellation or network error.
333
325
  */
334
- async function send(path, method, body, signal) {
326
+ async function send(path, action, body, signal) {
335
327
  const controller = new AbortController();
336
328
  const abort = () => controller.abort();
337
329
  if (signal?.aborted) abort();
338
330
  signal?.addEventListener("abort", abort, { once: true });
339
331
  const timer = setTimeout(abort, timeout);
340
332
  try {
341
- const response = await request(path, {
342
- method,
333
+ const response = await request(`/api/${action}`, {
334
+ method: "POST",
343
335
  credentials: "omit",
344
336
  cache: "no-store",
345
337
  signal: controller.signal,
346
- headers: { "X-Settings-Client": "1", ...(method === "POST" ? { "Content-Type": "application/json" } : {}) },
347
- ...(method === "POST" ? { body: JSON.stringify(body) } : {}),
338
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
339
+ body: new URLSearchParams([[path, action === "set" ? JSON.stringify(body) : ""]]).toString(),
348
340
  });
349
- if (response.status !== 200 && !(method === "GET" && response.status === 404)) throw new Error(`HTTP ${response.status}`);
341
+ if (response.status !== 200 && !(action === "get" && response.status === 404)) throw new Error(`HTTP ${response.status}`);
350
342
  return response;
351
343
  } finally {
352
344
  clearTimeout(timer);
@@ -370,21 +362,21 @@ function createPreferencesClient({ catalog, fetch: request = globalThis.fetch.bi
370
362
  * Serialize single-key mutations and update a still-active session only after success.
371
363
  * @param {string} module 已打开模块 / Open module.
372
364
  * @param {string} key 完整点分字段路径 / Complete dotted field path.
373
- * @param {"POST" | "DELETE"} method 写入或删除 / Write or delete.
365
+ * @param {"set" | "delete"} action 写入或删除 / Write or delete.
374
366
  * @param {unknown} value 写入值,删除时忽略 / Write value, ignored for deletion.
375
367
  * @param {"write" | "delete" | "clearCaches" | "reset"} [operation] 操作类型 / Operation kind.
376
368
  * @returns {Promise<void>} 操作完成 / Operation completion.
377
369
  * @throws {Error} 会话、字段、值或请求错误 / Session, field, value or request error.
378
370
  */
379
- async function change(module, key, method, value, operation = method === "POST" ? "write" : "delete") {
371
+ async function change(module, key, action, value, operation = action === "set" ? "write" : "delete") {
380
372
  const state = sessions.get(module);
381
373
  if (!state?.definition) throw new Error("Open the module first");
382
374
  if (state.saving) throw new Error("A settings write is already in progress");
383
375
  const field = state.definition.fields.find(field => field.key === key);
384
376
  state.saving = true;
385
377
  try {
386
- if ((operation === "write" || operation === "delete") && (!field || (method === "POST" && !validValue(field, value)))) throw new TypeError("Invalid setting value");
387
- await send(`/api/${key.split(".").map(encodeURIComponent).join("/")}`, method, value);
378
+ if ((operation === "write" || operation === "delete") && (!field || (action === "set" && !validValue(field, value)))) throw new TypeError("Invalid setting value");
379
+ await send(`@${state.definition.storageKey}.${key}`, action, value);
388
380
  if (sessions.get(module) === state) {
389
381
  switch (operation) {
390
382
  case "write":
@@ -427,7 +419,7 @@ function createPreferencesClient({ catalog, fetch: request = globalThis.fetch.bi
427
419
  sessions.set(module, state);
428
420
  try {
429
421
  const definition = normalizeBoxJs(catalog.select(module), module);
430
- const response = await send(`/api/${definition.settingsPath.map(encodeURIComponent).join("/")}/`, "GET", undefined, state.controller.signal);
422
+ const response = await send(`@${definition.storageKey}.${definition.settingsPath.join(".")}`, "get", undefined, state.controller.signal);
431
423
  let subtree = response.status === 404 ? {} : await response.json();
432
424
  if (typeof subtree === "string") subtree = JSON.parse(subtree);
433
425
  if (!subtree || typeof subtree !== "object" || Array.isArray(subtree)) throw new TypeError("Expected a settings subtree object");
@@ -457,7 +449,7 @@ function createPreferencesClient({ catalog, fetch: request = globalThis.fetch.bi
457
449
  async readCaches(module) {
458
450
  const state = sessions.get(module);
459
451
  if (!state?.definition) throw new Error("Open the module first");
460
- const response = await send(`/api/${encodeURIComponent(module)}/Caches`, "GET", undefined, state.controller.signal);
452
+ const response = await send(`@${state.definition.storageKey}.${module}.Caches`, "get", undefined, state.controller.signal);
461
453
  return response.status === 404 ? undefined : response.json();
462
454
  },
463
455
  /**
@@ -466,14 +458,14 @@ function createPreferencesClient({ catalog, fetch: request = globalThis.fetch.bi
466
458
  * @param {string} module 已打开模块 / Open module.
467
459
  * @returns {Promise<void>} 清理完成 / Cleanup completion.
468
460
  */
469
- clearCaches: module => change(module, `${module}.Caches`, "DELETE", undefined, "clearCaches"),
461
+ clearCaches: module => change(module, `${module}.Caches`, "delete", undefined, "clearCaches"),
470
462
  /**
471
463
  * 删除整个模块持久化节点,以当前 BoxJS 默认值重置页面缓存。
472
464
  * Delete module persistence and reset the page cache using current BoxJS defaults.
473
465
  * @param {string} module 已打开模块 / Open module.
474
466
  * @returns {Promise<void>} 重置完成 / Reset completion.
475
467
  */
476
- reset: module => change(module, module, "DELETE", undefined, "reset"),
468
+ reset: module => change(module, module, "delete", undefined, "reset"),
477
469
  /**
478
470
  * 取消读取并清除会话,不撤销已发送的写入。
479
471
  * Abort reads and clear the session without undoing dispatched writes.
@@ -492,7 +484,7 @@ function createPreferencesClient({ catalog, fetch: request = globalThis.fetch.bi
492
484
  * @param {import("../index.js").SettingsScalar | import("../index.js").SettingsScalar[]} value 字段值 / Field value.
493
485
  * @returns {Promise<void>} 写入完成 / Write completion.
494
486
  */
495
- set: (module, key, value) => change(module, key, "POST", value),
487
+ set: (module, key, value) => change(module, key, "set", value),
496
488
  /**
497
489
  * 删除单键覆盖值并显示默认值。
498
490
  * Delete one override and display its default value.
@@ -500,7 +492,7 @@ function createPreferencesClient({ catalog, fetch: request = globalThis.fetch.bi
500
492
  * @param {string} key 点分字段路径 / Dotted field path.
501
493
  * @returns {Promise<void>} 删除完成 / Delete completion.
502
494
  */
503
- remove: (module, key) => change(module, key, "DELETE"),
495
+ remove: (module, key) => change(module, key, "delete"),
504
496
  };
505
497
  }
506
498
 
@@ -9,6 +9,6 @@
9
9
  </head>
10
10
  <body>
11
11
  <main id="preferences"></main>
12
- <script type="module" src="/settings/assets/app.mjs?v=0.7.2"></script>
12
+ <script type="module" src="/settings/assets/app.mjs?v=0.8.0"></script>
13
13
  </body>
14
14
  </html>
@@ -11,7 +11,7 @@ function pageInputs(url, headers = {}) {
11
11
  const module = match[1];
12
12
  const values = Object.fromEntries(Object.entries(headers).map(([key, value]) => [key.toLowerCase(), value]));
13
13
  const json = values["x-preferencepanes-json"] ?? url.searchParams.get("json") ?? `/configs/${module}`;
14
- const css = values["x-preferencepanes-css"] ?? url.searchParams.get("css") ?? `/settings/assets/${module}.css`;
14
+ const css = values["x-preferencepanes-css"] ?? url.searchParams.get("css") ?? "";
15
15
  if (!json.trim()) throw new TypeError("JSON resource URL is required");
16
16
  return { url: url.href, module, json, css };
17
17
  }
@@ -1,13 +1,5 @@
1
1
  var defaults = "/* 分组列表沿用 Bilibili 设置页的行结构,样式限定在面板内。\n * Grouped rows follow the Bilibili settings layout, scoped to the panel. */\n.pp-panel {\n --pp-text: #18191c;\n --pp-background: #f6f7f8;\n --pp-surface: #fff;\n --pp-border: #e3e5e7;\n --pp-muted: #9499a0;\n --pp-accent: #fb7299;\n font:\n 15px / 1.5 -apple-system,\n BlinkMacSystemFont,\n \"Segoe UI\",\n sans-serif;\n color: var(--pp-text);\n background: var(--pp-background);\n position: relative;\n min-height: 100vh;\n}\n.pp-panel * {\n box-sizing: border-box;\n letter-spacing: 0;\n}\n.pp-header {\n height: calc(52px + env(safe-area-inset-top));\n padding: env(safe-area-inset-top) 12px 0;\n display: flex;\n align-items: center;\n background: var(--pp-surface);\n border-bottom: 1px solid var(--pp-border);\n position: sticky;\n top: 0;\n z-index: 1;\n}\n.pp-title {\n font-size: 17px;\n font-weight: 500;\n margin: 0;\n min-width: 0;\n overflow-wrap: anywhere;\n}\n.pp-brand {\n flex: 1;\n min-width: 0;\n display: flex;\n align-items: center;\n justify-content: center;\n gap: 8px;\n text-align: center;\n}\n.pp-brand-icon {\n display: none;\n flex: none;\n width: 28px;\n height: 28px;\n}\n.pp-brand-icon:not(:empty) {\n display: block;\n}\n.pp-brand-icon img {\n display: block;\n width: 100%;\n height: 100%;\n object-fit: contain;\n}\n.pp-nav-spacer {\n width: 44px;\n flex: none;\n}\n.pp-panel button {\n font: inherit;\n cursor: pointer;\n border: 0;\n background: none;\n color: inherit;\n}\n.pp-panel .pp-back {\n width: 44px;\n height: 44px;\n flex: none;\n font-size: 34px;\n line-height: 32px;\n padding: 0;\n}\n.pp-panel button:disabled {\n opacity: 0.5;\n cursor: wait;\n}\n.pp-viewport {\n position: relative;\n height: calc(100vh - 52px - env(safe-area-inset-top));\n overflow: hidden;\n}\n:root[data-preference-panes-embedded] .pp-header {\n display: none;\n}\n:root[data-preference-panes-embedded] .pp-viewport {\n height: 100vh;\n}\n@supports (height: 100dvh) {\n .pp-viewport {\n height: calc(100dvh - 52px - env(safe-area-inset-top));\n }\n :root[data-preference-panes-embedded] .pp-viewport {\n height: 100dvh;\n }\n}\n.pp-fields,\n.pp-choice-page {\n position: absolute;\n inset: 0;\n overflow: auto;\n padding: 12px max(16px, calc((100% - 688px) / 2)) calc(28px + env(safe-area-inset-bottom));\n background: var(--pp-background);\n}\n.pp-panel .form-group {\n margin: 0 0 12px;\n}\n.pp-panel .form-group__title {\n font-size: 12px;\n line-height: 17px;\n font-weight: 400;\n color: var(--pp-muted);\n padding-left: 12px;\n margin: 12px 0 6px;\n}\n.pp-panel .form-group__row {\n border-radius: 8px;\n overflow: hidden;\n background: var(--pp-surface);\n}\n.pp-panel .form-row {\n position: relative;\n display: flex;\n align-items: center;\n width: 100%;\n min-height: 46px;\n padding: 12px;\n border: 0;\n border-bottom: 1px solid var(--pp-border);\n background: var(--pp-surface);\n gap: 12px;\n}\n.pp-panel .form-row:last-child {\n border-bottom: 0;\n}\n.pp-panel .form-row__text {\n flex: 1;\n min-width: 0;\n margin: 0;\n display: flex;\n flex-direction: column;\n}\n.pp-panel .form-row__title {\n font-size: 15px;\n line-height: 22px;\n color: var(--pp-text);\n text-align: left;\n}\n.pp-panel .form-row__subtitle {\n font-size: 12px;\n line-height: 18px;\n color: var(--pp-muted);\n overflow-wrap: anywhere;\n margin-top: 2px;\n}\n.pp-choice-link {\n display: flex;\n align-items: center;\n justify-content: flex-end;\n gap: 8px;\n max-width: 45%;\n min-width: 44px;\n min-height: 44px;\n padding: 0;\n text-align: right;\n flex: 1;\n}\n.pp-summary {\n color: var(--pp-muted);\n font-size: 13px;\n line-height: 18px;\n display: -webkit-box;\n -webkit-line-clamp: 2;\n -webkit-box-orient: vertical;\n overflow: hidden;\n overflow-wrap: anywhere;\n}\n.pp-chevron {\n color: var(--pp-muted);\n font-size: 22px;\n flex: none;\n}\n.pp-input {\n font: inherit;\n color: var(--pp-text);\n background: var(--pp-surface);\n border: 1px solid var(--pp-border);\n border-radius: 6px;\n padding: 8px;\n min-width: 0;\n max-width: 45%;\n width: 45%;\n}\nselect.pp-input {\n text-overflow: ellipsis;\n font-size: 13px;\n}\n.pp-panel .pp-multiline {\n display: block;\n}\n.pp-multiline .pp-input {\n max-width: 100%;\n width: 100%;\n margin-top: 10px;\n}\n.pp-switch {\n appearance: none;\n -webkit-appearance: none;\n position: relative;\n flex: none;\n width: 32px;\n height: 20px;\n max-width: none;\n border: 0;\n border-radius: 15px;\n padding: 0;\n background: #c9ccd0;\n cursor: pointer;\n transition: background 0.2s;\n}\n.pp-switch::before {\n content: \"\";\n position: absolute;\n top: 3px;\n left: 3px;\n width: 14px;\n height: 14px;\n border-radius: 50%;\n background: white;\n transition: transform 0.2s;\n}\n.pp-switch:checked {\n background: var(--pp-accent);\n}\n.pp-switch:checked::before {\n transform: translateX(12px);\n}\n.pp-choice {\n justify-content: space-between;\n cursor: pointer;\n}\n.pp-choice input {\n width: 20px;\n height: 20px;\n flex: none;\n accent-color: var(--pp-accent);\n margin: 0;\n}\n.pp-description {\n font-size: 12px;\n line-height: 1.6;\n color: var(--pp-muted);\n white-space: pre-wrap;\n overflow-wrap: anywhere;\n}\n.pp-module-info {\n display: flex;\n gap: 12px;\n margin: 12px 0;\n}\n.pp-module-icon {\n width: 48px;\n height: 48px;\n object-fit: contain;\n flex: none;\n}\n.pp-module-details {\n min-width: 0;\n overflow-wrap: anywhere;\n}\n.pp-module-source {\n color: inherit;\n text-decoration: underline;\n}\n.pp-maintenance {\n margin-top: 24px;\n}\n.pp-actions {\n display: flex;\n flex-wrap: wrap;\n gap: 8px;\n}\n.pp-actions button,\n.pp-error button {\n min-height: 44px;\n padding: 8px 12px;\n border-radius: 6px;\n background: var(--pp-surface);\n}\n.pp-panel .pp-danger {\n color: #e45656;\n}\n.pp-cache {\n max-height: 320px;\n overflow: auto;\n white-space: pre-wrap;\n overflow-wrap: anywhere;\n}\n.pp-toast {\n pointer-events: none;\n position: fixed;\n bottom: calc(30px + env(safe-area-inset-bottom));\n left: 50%;\n transform: translateX(-50%);\n max-width: 90vw;\n padding: 10px 16px;\n border-radius: 8px;\n background: #333e;\n color: white;\n font-size: 13px;\n z-index: 20;\n}\n.pp-toast[data-kind=\"error\"] {\n background: #8d2424;\n}\n.pp-panel :focus-visible {\n outline: 2px solid var(--pp-accent);\n outline-offset: -2px;\n}\n@media (prefers-color-scheme: dark) {\n .pp-panel {\n --pp-text: #e3e5e7;\n --pp-background: #17181a;\n --pp-surface: #232427;\n --pp-border: #343538;\n }\n}\n:root[data-theme=\"dark\"] .pp-panel {\n --pp-text: #e3e5e7;\n --pp-background: #17181a;\n --pp-surface: #232427;\n --pp-border: #343538;\n}\n:root[data-theme=\"light\"] .pp-panel {\n --pp-text: #18191c;\n --pp-background: #f6f7f8;\n --pp-surface: #fff;\n --pp-border: #e3e5e7;\n}\n@media (prefers-reduced-motion: reduce) {\n .pp-panel .pp-switch,\n .pp-panel .pp-switch::before {\n transition: none;\n }\n}\n";
2
2
 
3
- /**
4
- * 解析已经取得的 pathname,避免重复构造 URL。
5
- * Parse an existing pathname without constructing another URL.
6
- * @param {string} pathname 以 / 开头的 URL pathname / URL pathname beginning with /.
7
- * @returns {string[] | undefined} 解码后的路径,非 API 路径不处理 / Decoded path, or undefined outside /api/.
8
- * @throws {TypeError} 转义编码或路径片段非法 / Invalid percent encoding or path segments.
9
- */
10
-
11
3
  /**
12
4
  * 校验原始路径片段,不进行 URL 编码转换。
13
5
  * Validate raw path segments without URL encoding conversion.
@@ -304,31 +296,31 @@ function createPreferencesClient({ catalog, fetch: request = globalThis.fetch.bi
304
296
  */
305
297
  const sessions = new Map();
306
298
  /**
307
- * 发送同源请求,处理超时与取消;数据 GET 404 交给调用方处理。
308
- * Send a same-origin request with timeout and cancellation; callers handle missing-data GET responses.
309
- * @param {string} path 相对请求路径 / Relative request path.
310
- * @param {"HEAD" | "GET" | "POST" | "DELETE"} method HTTP 方法 / HTTP method.
311
- * @param {unknown} body POST 值,其它方法忽略 / POST value, ignored by other methods.
299
+ * form 发送完整存储键;读取 404 交给调用方处理。
300
+ * Send a complete storage key as form data; callers handle missing reads.
301
+ * @param {string} path 完整 @root.path / Complete @root.path.
302
+ * @param {"get" | "set" | "delete"} action 存储操作 / Storage operation.
303
+ * @param {unknown} body set 值,其它操作忽略 / Set value, ignored by other operations.
312
304
  * @param {AbortSignal | undefined} signal 会话取消信号 / Session cancellation signal.
313
305
  * @returns {Promise<Response>} 未消费正文的响应 / Response with an unread body.
314
306
  * @throws {Error} 非 200 且非数据 GET 404、超时、取消或网络错误 / Non-200 status except missing-data GETs, timeout, cancellation or network error.
315
307
  */
316
- async function send(path, method, body, signal) {
308
+ async function send(path, action, body, signal) {
317
309
  const controller = new AbortController();
318
310
  const abort = () => controller.abort();
319
311
  if (signal?.aborted) abort();
320
312
  signal?.addEventListener("abort", abort, { once: true });
321
313
  const timer = setTimeout(abort, timeout);
322
314
  try {
323
- const response = await request(path, {
324
- method,
315
+ const response = await request(`/api/${action}`, {
316
+ method: "POST",
325
317
  credentials: "omit",
326
318
  cache: "no-store",
327
319
  signal: controller.signal,
328
- headers: { "X-Settings-Client": "1", ...(method === "POST" ? { "Content-Type": "application/json" } : {}) },
329
- ...(method === "POST" ? { body: JSON.stringify(body) } : {}),
320
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
321
+ body: new URLSearchParams([[path, action === "set" ? JSON.stringify(body) : ""]]).toString(),
330
322
  });
331
- if (response.status !== 200 && !(method === "GET" && response.status === 404)) throw new Error(`HTTP ${response.status}`);
323
+ if (response.status !== 200 && !(action === "get" && response.status === 404)) throw new Error(`HTTP ${response.status}`);
332
324
  return response;
333
325
  } finally {
334
326
  clearTimeout(timer);
@@ -352,21 +344,21 @@ function createPreferencesClient({ catalog, fetch: request = globalThis.fetch.bi
352
344
  * Serialize single-key mutations and update a still-active session only after success.
353
345
  * @param {string} module 已打开模块 / Open module.
354
346
  * @param {string} key 完整点分字段路径 / Complete dotted field path.
355
- * @param {"POST" | "DELETE"} method 写入或删除 / Write or delete.
347
+ * @param {"set" | "delete"} action 写入或删除 / Write or delete.
356
348
  * @param {unknown} value 写入值,删除时忽略 / Write value, ignored for deletion.
357
349
  * @param {"write" | "delete" | "clearCaches" | "reset"} [operation] 操作类型 / Operation kind.
358
350
  * @returns {Promise<void>} 操作完成 / Operation completion.
359
351
  * @throws {Error} 会话、字段、值或请求错误 / Session, field, value or request error.
360
352
  */
361
- async function change(module, key, method, value, operation = method === "POST" ? "write" : "delete") {
353
+ async function change(module, key, action, value, operation = action === "set" ? "write" : "delete") {
362
354
  const state = sessions.get(module);
363
355
  if (!state?.definition) throw new Error("Open the module first");
364
356
  if (state.saving) throw new Error("A settings write is already in progress");
365
357
  const field = state.definition.fields.find(field => field.key === key);
366
358
  state.saving = true;
367
359
  try {
368
- if ((operation === "write" || operation === "delete") && (!field || (method === "POST" && !validValue(field, value)))) throw new TypeError("Invalid setting value");
369
- await send(`/api/${key.split(".").map(encodeURIComponent).join("/")}`, method, value);
360
+ if ((operation === "write" || operation === "delete") && (!field || (action === "set" && !validValue(field, value)))) throw new TypeError("Invalid setting value");
361
+ await send(`@${state.definition.storageKey}.${key}`, action, value);
370
362
  if (sessions.get(module) === state) {
371
363
  switch (operation) {
372
364
  case "write":
@@ -409,7 +401,7 @@ function createPreferencesClient({ catalog, fetch: request = globalThis.fetch.bi
409
401
  sessions.set(module, state);
410
402
  try {
411
403
  const definition = normalizeBoxJs(catalog.select(module), module);
412
- const response = await send(`/api/${definition.settingsPath.map(encodeURIComponent).join("/")}/`, "GET", undefined, state.controller.signal);
404
+ const response = await send(`@${definition.storageKey}.${definition.settingsPath.join(".")}`, "get", undefined, state.controller.signal);
413
405
  let subtree = response.status === 404 ? {} : await response.json();
414
406
  if (typeof subtree === "string") subtree = JSON.parse(subtree);
415
407
  if (!subtree || typeof subtree !== "object" || Array.isArray(subtree)) throw new TypeError("Expected a settings subtree object");
@@ -439,7 +431,7 @@ function createPreferencesClient({ catalog, fetch: request = globalThis.fetch.bi
439
431
  async readCaches(module) {
440
432
  const state = sessions.get(module);
441
433
  if (!state?.definition) throw new Error("Open the module first");
442
- const response = await send(`/api/${encodeURIComponent(module)}/Caches`, "GET", undefined, state.controller.signal);
434
+ const response = await send(`@${state.definition.storageKey}.${module}.Caches`, "get", undefined, state.controller.signal);
443
435
  return response.status === 404 ? undefined : response.json();
444
436
  },
445
437
  /**
@@ -448,14 +440,14 @@ function createPreferencesClient({ catalog, fetch: request = globalThis.fetch.bi
448
440
  * @param {string} module 已打开模块 / Open module.
449
441
  * @returns {Promise<void>} 清理完成 / Cleanup completion.
450
442
  */
451
- clearCaches: module => change(module, `${module}.Caches`, "DELETE", undefined, "clearCaches"),
443
+ clearCaches: module => change(module, `${module}.Caches`, "delete", undefined, "clearCaches"),
452
444
  /**
453
445
  * 删除整个模块持久化节点,以当前 BoxJS 默认值重置页面缓存。
454
446
  * Delete module persistence and reset the page cache using current BoxJS defaults.
455
447
  * @param {string} module 已打开模块 / Open module.
456
448
  * @returns {Promise<void>} 重置完成 / Reset completion.
457
449
  */
458
- reset: module => change(module, module, "DELETE", undefined, "reset"),
450
+ reset: module => change(module, module, "delete", undefined, "reset"),
459
451
  /**
460
452
  * 取消读取并清除会话,不撤销已发送的写入。
461
453
  * Abort reads and clear the session without undoing dispatched writes.
@@ -474,7 +466,7 @@ function createPreferencesClient({ catalog, fetch: request = globalThis.fetch.bi
474
466
  * @param {import("../index.js").SettingsScalar | import("../index.js").SettingsScalar[]} value 字段值 / Field value.
475
467
  * @returns {Promise<void>} 写入完成 / Write completion.
476
468
  */
477
- set: (module, key, value) => change(module, key, "POST", value),
469
+ set: (module, key, value) => change(module, key, "set", value),
478
470
  /**
479
471
  * 删除单键覆盖值并显示默认值。
480
472
  * Delete one override and display its default value.
@@ -482,7 +474,7 @@ function createPreferencesClient({ catalog, fetch: request = globalThis.fetch.bi
482
474
  * @param {string} key 点分字段路径 / Dotted field path.
483
475
  * @returns {Promise<void>} 删除完成 / Delete completion.
484
476
  */
485
- remove: (module, key) => change(module, key, "DELETE"),
477
+ remove: (module, key) => change(module, key, "delete"),
486
478
  };
487
479
  }
488
480
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nsnanocat/preference-panes",
3
- "version": "0.7.2",
3
+ "version": "0.8.0",
4
4
  "description": "Shared settings API runtime for JavaScript proxy modules",
5
5
  "author": "VirgilClyne <Virgil@nanocat.me>",
6
6
  "homepage": "https://NSNanoCat.github.io/preference-panes",
package/src/Store.mjs CHANGED
@@ -2,83 +2,70 @@ import { URL } from "@nsnanocat/url";
2
2
  import { Lodash as _ } from "@nsnanocat/util/polyfill/Lodash.mjs";
3
3
  import { Storage } from "@nsnanocat/util/polyfill/Storage";
4
4
  import { response } from "./lib/response.mjs";
5
- import { parseSettingsPathname } from "./lib/settings-path.mjs";
5
+ import { validatePathParts } from "./lib/settings-path.mjs";
6
6
 
7
7
  /**
8
- * 根据 BoxJS 目录桥接持久化存储,不下载配置或解析控件。
9
- * Bridge persistence using the BoxJS catalog without downloading configuration or interpreting controls.
8
+ * 无配置绑定的本地存储桥接;form 字段名就是完整 @root.path。
9
+ * Unbound local storage bridge; the form field name is the complete @root.path.
10
10
  */
11
11
  export class Store {
12
- #catalog;
13
-
14
- /**
15
- * 复用包内已解析的目录,构造时不访问网络或存储。
16
- * Reuse the parsed internal catalog without network or persistence access during construction.
17
- * @param {import("./BoxJS.mjs").BoxJS} catalog BoxJS 路径目录 / BoxJS path catalog.
18
- */
19
- constructor(catalog) {
20
- this.#catalog = catalog;
21
- }
22
-
23
12
  /**
24
- * GET 返回指定值,POST 替换指定值,DELETE 删除指定键或整个模块。
25
- * GET returns a value, POST replaces it, and DELETE removes a key or the entire module.
13
+ * POST /api/get、set、delete;不下载配置、不解析控件、不鉴权。
14
+ * POST /api/get, set or delete without config downloads, control parsing or authentication.
26
15
  * @param {import("./index.js").SettingsRequest} request 代理请求 / Proxy request.
27
- * @param {URL} [url] 包内复用的已解析地址 / Parsed URL reused within the package.
28
- * @returns {Promise<import("./index.js").SettingsResponse | undefined>} 响应或非接管请求 / Response, or undefined for an unhandled request.
16
+ * @param {URL} [url] 已解析地址 / Parsed URL.
17
+ * @returns {Promise<import("./index.js").SettingsResponse | undefined>} 操作结果 / Operation result.
29
18
  */
30
19
  async handle(request, url = new URL(request.url)) {
31
20
  if (!url.pathname.startsWith("/api/")) return;
32
21
  const reply = (status, data) => response(request, status, data);
33
- let parts;
22
+ const action = url.pathname.slice(5);
23
+ if (!["get", "set", "delete"].includes(action)) return reply(404, { error: "Unknown action" });
24
+ if (request.method !== "POST") return reply(405, { error: "Use POST with a form body" });
25
+ const headers = Object.fromEntries(Object.entries(request.headers ?? {}).map(([key, value]) => [key.toLowerCase(), value]));
26
+ if (headers["content-type"]?.split(";")[0].trim().toLowerCase() !== "application/x-www-form-urlencoded") return reply(415, { error: "Expected application/x-www-form-urlencoded" });
27
+ if (typeof request.body !== "string" || request.body.length > 65536) return reply(400, { error: "Expected a form body up to 65536 characters" });
28
+ let parts, value;
34
29
  try {
35
- parts = parseSettingsPathname(url.pathname);
30
+ const fields = request.body.split("&");
31
+ if (fields.length !== 1) throw new TypeError("Send exactly one storage key");
32
+ const separator = fields[0].indexOf("=");
33
+ if (separator < 0) throw new TypeError("Expected @root.path=value");
34
+ const key = decodeURIComponent(fields[0].slice(0, separator).replace(/\+/g, " "));
35
+ value = decodeURIComponent(fields[0].slice(separator + 1).replace(/\+/g, " "));
36
+ if (!key.startsWith("@")) throw new TypeError("Storage keys must start with @");
37
+ parts = validatePathParts(key.slice(1).split("."));
38
+ if (parts.length < 2) throw new TypeError("Specify a storage root and child path");
36
39
  } catch (error) {
37
40
  return reply(400, { error: error.message });
38
41
  }
39
- const binding = this.#catalog.modules.get(parts[0]);
40
- if (!binding) return reply(404, { error: "Module is not declared in BoxJS" });
41
- const requestHeaders = Object.fromEntries(Object.entries(request.headers ?? {}).map(([key, value]) => [key.toLowerCase(), value]));
42
- if (requestHeaders["x-settings-client"] !== "1" || (requestHeaders.origin && requestHeaders.origin !== url.origin)) return reply(403, { error: "Forbidden settings client" });
43
- let value;
44
- switch (request.method) {
45
- case "HEAD":
46
- return reply(200, undefined);
47
- case "GET":
48
- case "DELETE":
49
- break;
50
- case "POST":
51
- if (requestHeaders["content-type"]?.split(";")[0].trim().toLowerCase() !== "application/json") return reply(415, { error: "Expected application/json" });
52
- if (typeof request.body !== "string") return reply(400, { error: "Expected a JSON string body" });
53
- if (request.body.length > 65536) return reply(413, { error: "Body exceeds 65536 UTF-16 code units" });
54
- try {
55
- value = JSON.parse(request.body);
56
- } catch {
57
- return reply(400, { error: "Invalid JSON" });
58
- }
59
- break;
60
- default:
61
- return { ...reply(405, { error: "Method not allowed" }), headers: { ...reply(405).headers, Allow: "HEAD, GET, POST, DELETE" } };
42
+ if (action === "set") {
43
+ try {
44
+ value = JSON.parse(value);
45
+ } catch (error) {
46
+ if (!(error instanceof SyntaxError)) throw error;
47
+ }
62
48
  }
49
+ const [storageKey, ...path] = parts;
63
50
  try {
64
- const root = Storage.getItem(binding.storageKey, {});
65
- if (!isRecord(root)) throw new TypeError("stored root must be an object");
66
- const parent = storageParent(root, parts, request.method === "POST");
67
- const key = parts.at(-1);
68
- switch (request.method) {
69
- case "GET": {
51
+ const root = Storage.getItem(storageKey, {});
52
+ if (!isRecord(root)) throw new TypeError("Stored root must be an object");
53
+ const parent = storageParent(root, path, action === "set");
54
+ const key = path.at(-1);
55
+ switch (action) {
56
+ case "get": {
70
57
  const result = parent ? _.get(parent, [key]) : undefined;
71
58
  return result === undefined ? reply(404, { error: "Stored path does not exist" }) : reply(200, result);
72
59
  }
73
- case "POST":
60
+ case "set":
74
61
  _.set(parent, [key], value);
75
62
  break;
76
- case "DELETE":
63
+ case "delete":
77
64
  if (parent) _.unset(parent, [key]);
78
65
  break;
79
66
  }
80
- if (!Storage.setItem(binding.storageKey, root)) throw new Error("Storage write failed");
81
- return reply(200, request.method === "POST" ? { saved: true } : { deleted: true });
67
+ if (!Storage.setItem(storageKey, root)) throw new Error("Storage write failed");
68
+ return reply(200, action === "set" ? { saved: true } : { deleted: true });
82
69
  } catch (error) {
83
70
  return reply(500, { error: error.message });
84
71
  }
@@ -111,8 +111,8 @@ export interface PreferencesClient {
111
111
  */
112
112
  set(module: string, key: string, value: SettingsScalar | SettingsScalar[]): Promise<void>;
113
113
  /**
114
- * DELETE 单个覆盖值,HTTP 200 后显示默认值,不追加 GET。
115
- * DELETE an override and display its default after HTTP 200, without a follow-up GET.
114
+ * POST /api/delete 删除覆盖值,200 后显示默认值,不追加读取。
115
+ * POST /api/delete removes an override and displays its default after 200, without rereading.
116
116
  * @param module 已打开的模块 / Open module.
117
117
  * @param key 完整点分字段路径 / Complete dotted field path.
118
118
  * @returns 操作完成 / Completion of the operation.
@@ -24,31 +24,31 @@ export function createPreferencesClient({ catalog, fetch: request = globalThis.f
24
24
  */
25
25
  const sessions = new Map();
26
26
  /**
27
- * 发送同源请求,处理超时与取消;数据 GET 404 交给调用方处理。
28
- * Send a same-origin request with timeout and cancellation; callers handle missing-data GET responses.
29
- * @param {string} path 相对请求路径 / Relative request path.
30
- * @param {"HEAD" | "GET" | "POST" | "DELETE"} method HTTP 方法 / HTTP method.
31
- * @param {unknown} body POST 值,其它方法忽略 / POST value, ignored by other methods.
27
+ * form 发送完整存储键;读取 404 交给调用方处理。
28
+ * Send a complete storage key as form data; callers handle missing reads.
29
+ * @param {string} path 完整 @root.path / Complete @root.path.
30
+ * @param {"get" | "set" | "delete"} action 存储操作 / Storage operation.
31
+ * @param {unknown} body set 值,其它操作忽略 / Set value, ignored by other operations.
32
32
  * @param {AbortSignal | undefined} signal 会话取消信号 / Session cancellation signal.
33
33
  * @returns {Promise<Response>} 未消费正文的响应 / Response with an unread body.
34
34
  * @throws {Error} 非 200 且非数据 GET 404、超时、取消或网络错误 / Non-200 status except missing-data GETs, timeout, cancellation or network error.
35
35
  */
36
- async function send(path, method, body, signal) {
36
+ async function send(path, action, body, signal) {
37
37
  const controller = new AbortController();
38
38
  const abort = () => controller.abort();
39
39
  if (signal?.aborted) abort();
40
40
  signal?.addEventListener("abort", abort, { once: true });
41
41
  const timer = setTimeout(abort, timeout);
42
42
  try {
43
- const response = await request(path, {
44
- method,
43
+ const response = await request(`/api/${action}`, {
44
+ method: "POST",
45
45
  credentials: "omit",
46
46
  cache: "no-store",
47
47
  signal: controller.signal,
48
- headers: { "X-Settings-Client": "1", ...(method === "POST" ? { "Content-Type": "application/json" } : {}) },
49
- ...(method === "POST" ? { body: JSON.stringify(body) } : {}),
48
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
49
+ body: new URLSearchParams([[path, action === "set" ? JSON.stringify(body) : ""]]).toString(),
50
50
  });
51
- if (response.status !== 200 && !(method === "GET" && response.status === 404)) throw new Error(`HTTP ${response.status}`);
51
+ if (response.status !== 200 && !(action === "get" && response.status === 404)) throw new Error(`HTTP ${response.status}`);
52
52
  return response;
53
53
  } finally {
54
54
  clearTimeout(timer);
@@ -72,21 +72,21 @@ export function createPreferencesClient({ catalog, fetch: request = globalThis.f
72
72
  * Serialize single-key mutations and update a still-active session only after success.
73
73
  * @param {string} module 已打开模块 / Open module.
74
74
  * @param {string} key 完整点分字段路径 / Complete dotted field path.
75
- * @param {"POST" | "DELETE"} method 写入或删除 / Write or delete.
75
+ * @param {"set" | "delete"} action 写入或删除 / Write or delete.
76
76
  * @param {unknown} value 写入值,删除时忽略 / Write value, ignored for deletion.
77
77
  * @param {"write" | "delete" | "clearCaches" | "reset"} [operation] 操作类型 / Operation kind.
78
78
  * @returns {Promise<void>} 操作完成 / Operation completion.
79
79
  * @throws {Error} 会话、字段、值或请求错误 / Session, field, value or request error.
80
80
  */
81
- async function change(module, key, method, value, operation = method === "POST" ? "write" : "delete") {
81
+ async function change(module, key, action, value, operation = action === "set" ? "write" : "delete") {
82
82
  const state = sessions.get(module);
83
83
  if (!state?.definition) throw new Error("Open the module first");
84
84
  if (state.saving) throw new Error("A settings write is already in progress");
85
85
  const field = state.definition.fields.find(field => field.key === key);
86
86
  state.saving = true;
87
87
  try {
88
- if ((operation === "write" || operation === "delete") && (!field || (method === "POST" && !validValue(field, value)))) throw new TypeError("Invalid setting value");
89
- await send(`/api/${key.split(".").map(encodeURIComponent).join("/")}`, method, value);
88
+ if ((operation === "write" || operation === "delete") && (!field || (action === "set" && !validValue(field, value)))) throw new TypeError("Invalid setting value");
89
+ await send(`@${state.definition.storageKey}.${key}`, action, value);
90
90
  if (sessions.get(module) === state) {
91
91
  switch (operation) {
92
92
  case "write":
@@ -129,7 +129,7 @@ export function createPreferencesClient({ catalog, fetch: request = globalThis.f
129
129
  sessions.set(module, state);
130
130
  try {
131
131
  const definition = normalizeBoxJs(catalog.select(module), module);
132
- const response = await send(`/api/${definition.settingsPath.map(encodeURIComponent).join("/")}/`, "GET", undefined, state.controller.signal);
132
+ const response = await send(`@${definition.storageKey}.${definition.settingsPath.join(".")}`, "get", undefined, state.controller.signal);
133
133
  let subtree = response.status === 404 ? {} : await response.json();
134
134
  if (typeof subtree === "string") subtree = JSON.parse(subtree);
135
135
  if (!subtree || typeof subtree !== "object" || Array.isArray(subtree)) throw new TypeError("Expected a settings subtree object");
@@ -159,7 +159,7 @@ export function createPreferencesClient({ catalog, fetch: request = globalThis.f
159
159
  async readCaches(module) {
160
160
  const state = sessions.get(module);
161
161
  if (!state?.definition) throw new Error("Open the module first");
162
- const response = await send(`/api/${encodeURIComponent(module)}/Caches`, "GET", undefined, state.controller.signal);
162
+ const response = await send(`@${state.definition.storageKey}.${module}.Caches`, "get", undefined, state.controller.signal);
163
163
  return response.status === 404 ? undefined : response.json();
164
164
  },
165
165
  /**
@@ -168,14 +168,14 @@ export function createPreferencesClient({ catalog, fetch: request = globalThis.f
168
168
  * @param {string} module 已打开模块 / Open module.
169
169
  * @returns {Promise<void>} 清理完成 / Cleanup completion.
170
170
  */
171
- clearCaches: module => change(module, `${module}.Caches`, "DELETE", undefined, "clearCaches"),
171
+ clearCaches: module => change(module, `${module}.Caches`, "delete", undefined, "clearCaches"),
172
172
  /**
173
173
  * 删除整个模块持久化节点,以当前 BoxJS 默认值重置页面缓存。
174
174
  * Delete module persistence and reset the page cache using current BoxJS defaults.
175
175
  * @param {string} module 已打开模块 / Open module.
176
176
  * @returns {Promise<void>} 重置完成 / Reset completion.
177
177
  */
178
- reset: module => change(module, module, "DELETE", undefined, "reset"),
178
+ reset: module => change(module, module, "delete", undefined, "reset"),
179
179
  /**
180
180
  * 取消读取并清除会话,不撤销已发送的写入。
181
181
  * Abort reads and clear the session without undoing dispatched writes.
@@ -194,7 +194,7 @@ export function createPreferencesClient({ catalog, fetch: request = globalThis.f
194
194
  * @param {import("../index.js").SettingsScalar | import("../index.js").SettingsScalar[]} value 字段值 / Field value.
195
195
  * @returns {Promise<void>} 写入完成 / Write completion.
196
196
  */
197
- set: (module, key, value) => change(module, key, "POST", value),
197
+ set: (module, key, value) => change(module, key, "set", value),
198
198
  /**
199
199
  * 删除单键覆盖值并显示默认值。
200
200
  * Delete one override and display its default value.
@@ -202,6 +202,6 @@ export function createPreferencesClient({ catalog, fetch: request = globalThis.f
202
202
  * @param {string} key 点分字段路径 / Dotted field path.
203
203
  * @returns {Promise<void>} 删除完成 / Delete completion.
204
204
  */
205
- remove: (module, key) => change(module, key, "DELETE"),
205
+ remove: (module, key) => change(module, key, "delete"),
206
206
  };
207
207
  }