dsh-panel-control 0.3.14 → 0.3.16

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
@@ -109,6 +109,16 @@ python -m http.server 8787 --bind 127.0.0.1 # 在仓库根目录
109
109
  - 文案改成「刷新后仍然缺:重启 DSH 才会应用新的插件清单」——这才是真正有效的动作。
110
110
  - 缺口修复后自动清除记忆,下次真出现新缺口还会提示。
111
111
 
112
+ ## 为什么重启后偶尔看不到入口(0.3.16)
113
+
114
+ DSH 给每个插件的脚本算一个**版本号**,来源是文件的 `mtime / ctime / 大小`,注册表只认它自己在组装那一刻读到的那个版本。页面拿到的是**启动时那份清单**,只要 `client.js` 在清单发出去之后被重写过(编辑器保存、`git` 换行归一化、或者早先那个"自愈"改写),页面手里的版本号就成了作废的号,请求回 404,而页面**不会报错**——入口就这么没了。
115
+
116
+ 0.3.16 的处理:
117
+
118
+ - Host 半只对本插件包装注册表的资源查询:页面若拿旧版本号来要,先用**本进程发过的同一版本字节**回答(缓存最近 5 个版本),再退到"用当前版本回答",其余请求原样放行,卸载时撤掉包装。别家插件的行为一点不改。
119
+ - 早先的"自愈"(改写 `client.js` 强制热加载)**默认关闭**:它会重跑所有插件的注册,邻居不幂等就会丢入口——`dsh-workspace-control` 就是这么被弄掉的。需要时用 `DPC_NUDGE=on` 显式打开。
120
+ - `host.log` 每次 apply 打两行结论:`graph: lists|does not list <包名>` 和 `graph: own bundle at rev … answers 200|404`,一次就能分清是"没进清单"还是"清单有但取不到"。
121
+
112
122
  ## 代码审查后的加固(0.3.11)
113
123
 
114
124
  一轮完整审查(正确性 / 安全 / 可维护性 / 性能)之后改掉的东西,按主题记在这里,免得以后又踩回去。
package/index.js CHANGED
@@ -33,7 +33,12 @@ const ROUTE_PATH = `/api/${ENDPOINT}`;
33
33
  * back to registration order. A file under the profile's data directory
34
34
  * survives restarts, upgrades and origin changes alike.
35
35
  */
36
- const STATE_DIR = path.join(os.homedir(), '.dsh', 'panel-control');
36
+ // Tests (and anyone sandboxing this plugin) point DPC_STATE_DIR somewhere else;
37
+ // the smoke harness writes here, and it must not touch the real preferences or
38
+ // the real log that support questions get asked about.
39
+ const STATE_DIR = process.env.DPC_STATE_DIR
40
+ ? path.resolve(process.env.DPC_STATE_DIR)
41
+ : path.join(os.homedir(), '.dsh', 'panel-control');
37
42
  const PREFS_FILE = path.join(STATE_DIR, 'prefs.json');
38
43
  const HOST_LOG = path.join(STATE_DIR, 'host.log');
39
44
  const CLIENT_LOG = path.join(STATE_DIR, 'client.log');
@@ -537,11 +542,140 @@ function logInventoryOnApply(scope) {
537
542
  `graph: ${listed ? 'lists' : 'does not list'} ${PACKAGE} · rev ${value.rev} · ` +
538
543
  `${value.ids.length} client entr${value.ids.length === 1 ? 'y' : 'ies'}`,
539
544
  );
545
+ if (listed) void probeOwnBundle(scope);
540
546
  } catch (error) {
541
547
  logHost(`graph: probe failed (${describeError(error)})`);
542
548
  }
543
549
  }
544
550
 
551
+ /**
552
+ * Serve the current revision when a page asks for one this process has retired.
553
+ *
554
+ * The registry addresses every bundle by a revision derived from the file's
555
+ * mtime, ctime and size, and it answers only the revisions it read at
556
+ * composition time ("versioned code is immutable"). A page keeps the list it
557
+ * booted with, so as soon as this package's file is rewritten - by an editor, by
558
+ * git normalising line endings, or by the retry nudge that used to live here -
559
+ * the page's revision stops existing and its request becomes a 404 that nothing
560
+ * reports: the plugin is simply absent from the sidebar.
561
+ *
562
+ * This wraps the registry's own resource lookup for this one package. A 404 for
563
+ * a stale revision is retried at the revision the Host holds now, and the page
564
+ * gets the bytes it needs; everything else passes straight through, and the
565
+ * patch is undone on teardown.
566
+ */
567
+ const SERVE_CURRENT = String(process.env.DPC_SERVE_CURRENT || '').toLowerCase() !== 'off';
568
+ let servePatched = null;
569
+ let serveLoggedAt = 0;
570
+ /**
571
+ * Bytes this Host has already served, kept by revision.
572
+ *
573
+ * The registry serves a revision only while it is the current one. A page
574
+ * (or a page rebuilt by another tool) keeps asking for the revision it booted
575
+ * with, so the copy we handed out is the only thing that can answer it later.
576
+ * Five revisions is a few hundred kilobytes and covers a run of edits.
577
+ */
578
+ const servedBytes = new Map();
579
+ const SERVED_BYTES_KEPT = 5;
580
+
581
+ function revisionOf(url) {
582
+ const match = /[?&]rev=([A-Za-z0-9._-]+)/.exec(String(url || ''));
583
+ return match ? match[1] : null;
584
+ }
585
+
586
+ function serveCurrentRevision(scope) {
587
+ if (!SERVE_CURRENT || servePatched) return;
588
+ const modules = clientModules || serviceOf(scope, 'clientModules');
589
+ if (!modules || typeof modules.bundleResource !== 'function') return;
590
+ const original = modules.bundleResource.bind(modules);
591
+ servePatched = modules;
592
+ modules.bundleResource = async function bundleResourceWithFallback(method, url) {
593
+ const answer = await original(method, url);
594
+ if (answer && answer.status === 200 && method === 'GET' && String(url || '').includes(PACKAGE)) {
595
+ const rev = revisionOf(url);
596
+ if (rev && typeof answer.body === 'string') {
597
+ servedBytes.delete(rev);
598
+ servedBytes.set(rev, answer);
599
+ while (servedBytes.size > SERVED_BYTES_KEPT) {
600
+ servedBytes.delete(servedBytes.keys().next().value);
601
+ }
602
+ }
603
+ }
604
+ if (answer && answer.status !== 404) return answer;
605
+ try {
606
+ const requested = String(url || '');
607
+ if (!requested.includes(PACKAGE)) return answer;
608
+ // A revision this Host already handed out outranks the current one: the
609
+ // page booted with it, and its list will keep naming it.
610
+ const asked = revisionOf(requested);
611
+ const remembered = asked ? servedBytes.get(asked) : undefined;
612
+ if (remembered !== undefined) {
613
+ if (Date.now() - serveLoggedAt > 30_000) {
614
+ serveLoggedAt = Date.now();
615
+ logHost(
616
+ `served ${PACKAGE} again at revision ${asked}: the page still runs the list it booted with`,
617
+ );
618
+ }
619
+ return method === 'HEAD' ? { ...remembered, body: undefined } : remembered;
620
+ }
621
+ const graph = typeof modules.graph === 'function' ? modules.graph() : null;
622
+ const rows = graph && Array.isArray(graph.entries) ? graph.entries : [];
623
+ const entry = rows.find((row) => row && row.id === PACKAGE);
624
+ if (!entry || typeof entry.url !== 'string') return answer;
625
+ const retryUrl = entry.url.startsWith('/') ? entry.url : `/${entry.url}`;
626
+ if (retryUrl === requested) return answer;
627
+ const retried = await original(method, retryUrl);
628
+ if (!retried || retried.status !== 200) return answer;
629
+ if (Date.now() - serveLoggedAt > 30_000) {
630
+ serveLoggedAt = Date.now();
631
+ logHost(
632
+ `served ${PACKAGE} at the current revision: the page asked for a retired one ` +
633
+ '(its plugin list predates the last write to client.js)',
634
+ );
635
+ }
636
+ return retried;
637
+ } catch (error) {
638
+ logHost(`stale revision fallback failed: ${describeError(error)}`);
639
+ return answer;
640
+ }
641
+ };
642
+ }
643
+
644
+ function releaseServeCurrent() {
645
+ const modules = servePatched;
646
+ servePatched = null;
647
+ if (!modules) return;
648
+ try {
649
+ delete modules.bundleResource;
650
+ } catch {
651
+ /* a frozen service keeps the wrapper; it only ever adds a retry */
652
+ }
653
+ }
654
+
655
+ /**
656
+ * Ask the Host to serve this package's own bundle, the way the page would.
657
+ *
658
+ * A row can be advertised and still be unservable: the registry answers only
659
+ * the revisions it read at composition time, and everything else is a 404 that
660
+ * the page drops without a word. Runs the same request the browser would make.
661
+ */
662
+ async function probeOwnBundle(scope) {
663
+ try {
664
+ const modules = clientModules || serviceOf(scope, 'clientModules');
665
+ const graph = modules && typeof modules.graph === 'function' ? modules.graph() : null;
666
+ const entries = graph && Array.isArray(graph.entries) ? graph.entries : [];
667
+ const entry = entries.find((row) => row && row.id === PACKAGE);
668
+ if (!entry || typeof entry.url !== 'string' || typeof modules.fetchBundle !== 'function') return;
669
+ const response = await modules.fetchBundle({ method: 'GET', url: entry.url });
670
+ logHost(
671
+ `graph: own bundle at rev ${String(entry.rev)} answers ${response.status}` +
672
+ (response.status === 200 ? '' : ' — the page cannot load a bundle the Host will not serve'),
673
+ );
674
+ } catch (error) {
675
+ logHost(`graph: own bundle probe failed (${describeError(error)})`);
676
+ }
677
+ }
678
+
545
679
  /**
546
680
  * Say it out loud when the page never came back.
547
681
  *
@@ -663,6 +797,7 @@ export function apply(ctx) {
663
797
  if (dispose) {
664
798
  logHost(`route mounted at ${ROUTE_PATH} (attempt ${attempts})`);
665
799
  logInventoryOnApply(scope);
800
+ serveCurrentRevision(scope);
666
801
  const startedAt = Date.now();
667
802
  // A timer callback that throws is fatal for the Host: contain it here and
668
803
  // again inside the async body.
@@ -699,6 +834,7 @@ export function apply(ctx) {
699
834
  }
700
835
  }
701
836
  dispose = null;
837
+ releaseServeCurrent();
702
838
  },
703
839
  `${PACKAGE}: classification route`,
704
840
  );
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-panel-control",
3
- "version": "0.3.14",
3
+ "version": "0.3.16",
4
4
  "type": "module",
5
5
  "description": "Manage the global panel navigation at the top of the DSH sidebar: reorder, hide and group every entry, with the layout persisted per installation.",
6
6
  "keywords": [
@@ -10,9 +10,17 @@
10
10
  * node .scratch/host-smoke.mjs
11
11
  */
12
12
  import { fileURLToPath, pathToFileURL } from 'node:url';
13
+ import fs from 'node:fs/promises';
13
14
  import path from 'node:path';
14
15
 
15
16
  const here = path.dirname(fileURLToPath(import.meta.url));
17
+
18
+ // Keep the harness out of the real preferences and the real host.log: the
19
+ // module resolves its state directory at import time, so this must be set first.
20
+ const STATE = path.join(here, '..', '.scratch', 'smoke-state');
21
+ process.env.DPC_STATE_DIR = STATE;
22
+ await fs.mkdir(STATE, { recursive: true });
23
+
16
24
  // `npm run smoke -- <file>` points the harness at another copy, which is how
17
25
  // a regression (or the broken revision that shipped once) gets reproduced.
18
26
  const override = process.argv[2];
@@ -43,6 +51,29 @@ function check(name, ok, detail) {
43
51
  }
44
52
  }
45
53
 
54
+ // The registry currently holds revision "rev-now" for this package; the page
55
+ // below asks for "rev-old", which is the 404 that silently drops a plugin.
56
+ const clientModulesStub = {
57
+ served: [],
58
+ graph: () => ({
59
+ rev: 'rev-test',
60
+ release: 'rel',
61
+ entries: [
62
+ { id: 'a' },
63
+ { id: 'b' },
64
+ { id: 'dsh-panel-control', rev: 'rev-now', url: 'plugins/??dsh-panel-control/client.js&rev=rev-now' },
65
+ ],
66
+ batches: [],
67
+ }),
68
+ async bundleResource(method, url) {
69
+ clientModulesStub.served.push(String(url));
70
+ if (String(url).includes('rev-now') && !clientModulesStub.dropRev) {
71
+ return { status: 200, headers: { 'content-type': 'text/javascript' }, body: 'current' };
72
+ }
73
+ return { status: 404 };
74
+ },
75
+ };
76
+
46
77
  const registrations = [];
47
78
  const scope = {
48
79
  get(key) {
@@ -58,7 +89,7 @@ const scope = {
58
89
  };
59
90
  }
60
91
  if (key === 'clientModules') {
61
- return { graph: () => ({ rev: 'rev-test', release: 'rel', entries: [{ id: 'a' }, { id: 'b' }], batches: [] }) };
92
+ return clientModulesStub;
62
93
  }
63
94
  return undefined;
64
95
  },
@@ -158,7 +189,35 @@ if (route) {
158
189
  const body = await response.json();
159
190
  check('inventory answers', body && body.result && body.result.ok === true, JSON.stringify(body).slice(0, 160));
160
191
  check('inventory rev matches graph', body.result.value.rev === 'rev-test', body.result.value && body.result.value.rev);
161
- check('inventory ids', Array.isArray(body.result.value.ids) && body.result.value.ids.join() === 'a,b');
192
+
193
+ const stale = await clientModulesStub.bundleResource(
194
+ 'GET',
195
+ '/plugins/??dsh-panel-control/client.js&rev=rev-old',
196
+ );
197
+ check('stale revision is served at the current one', stale.status === 200, `status ${stale.status}`);
198
+ // A revision this Host already handed out must survive the registry
199
+ // forgetting it: the page keeps asking for the list it booted with.
200
+ await clientModulesStub.bundleResource('GET', '/plugins/??dsh-panel-control/client.js&rev=rev-now');
201
+ clientModulesStub.dropRev = true;
202
+ const remembered = await clientModulesStub.bundleResource(
203
+ 'GET',
204
+ '/plugins/??dsh-panel-control/client.js&rev=rev-now',
205
+ );
206
+ check('a remembered revision is served after the registry drops it', remembered.status === 200, `status ${remembered.status}`);
207
+ clientModulesStub.dropRev = false;
208
+ const fresh = await clientModulesStub.bundleResource(
209
+ 'GET',
210
+ '/plugins/??dsh-panel-control/client.js&rev=rev-now',
211
+ );
212
+ check('current revision passes straight through', fresh.status === 200, `status ${fresh.status}`);
213
+ const other = await clientModulesStub.bundleResource('GET', '/plugins/??someone-else/client.js&rev=old');
214
+ check('other packages keep their 404', other.status === 404, `status ${other.status}`);
215
+ check(
216
+ 'inventory ids',
217
+ Array.isArray(body.result.value.ids) &&
218
+ body.result.value.ids.join() === 'a,b,dsh-panel-control',
219
+ body.result.value && body.result.value.ids.join(),
220
+ );
162
221
  } catch (error) {
163
222
  check('inventory answers', false, error.message);
164
223
  }