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 +10 -0
- package/index.js +137 -1
- package/package.json +1 -1
- package/scripts/host-smoke.mjs +61 -2
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
|
-
|
|
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.
|
|
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": [
|
package/scripts/host-smoke.mjs
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
}
|