dsh-panel-control 0.4.0 → 0.7.1

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.
@@ -0,0 +1,100 @@
1
+ # 「同一个 client bundle 被求值两次」:一个插件让另一个插件整条消失的排查记录
2
+
3
+ > 本文记录一次真实故障:插件 A 一旦启用,插件 B 的侧栏入口和全部功能就**整体消失**,看起来像 B 被平台「移除」了。最终定位到的根因不是两个插件的功能冲突,而是 **A 的客户端 bundle 在同一个 document 里被当作经典脚本求值了两次**:第二次在解析阶段抛 `SyntaxError`,而这条错误发生在「组合批」执行过程中,**打断了整轮插件启动**,排在 A 后面出场的 B 永远轮不到 `apply()`。
4
+
5
+ ---
6
+
7
+ ## 1. 现象
8
+
9
+ - 环境:DSH 桌面端(Web 前端壳 `dsh-app://app`),Cordis 插件体系,插件用本地 `link:` 安装。
10
+ - 症状:启用**面板管理**(dsh-panel-control)后,**工作区管理**(dsh-workspace-control)的侧栏入口立即消失,会话菜单项与相关工具一并不可用;插件管理里它仍显示为「运行」。
11
+ - 关键对照:**关掉面板管理并刷新页面,工作区管理立刻回来。** 因果成立,但不是功能冲突。
12
+ - 更迷惑的是:面板管理自己有时也「不运行」——同一进程里冷启动成功、刷新后失效,行为不稳定。
13
+
14
+ ## 2. 环境
15
+
16
+ | 项 | 值 |
17
+ | --- | --- |
18
+ | 桌面端 | DSH Desktop(Web 前端壳 `dsh-app://app`) |
19
+ | 插件机制 | Cordis:宿主半边(Node)+ 客户端半边(`client.js` bundle) |
20
+ | 安装方式 | `dsh.profile.bundles` 列表 + `dependencies: link:` |
21
+ | 干扰项 | `dsh-unlag`(刷新/热重载插件,会代理 `/plugins/` 路由并按图重拼组合批) |
22
+ | 系统 | Windows 11 |
23
+
24
+ ## 3. 走过的弯路(这部分比结论更有用)
25
+
26
+ | # | 假设 | 为什么被否掉 |
27
+ | --- | --- | --- |
28
+ | 1 | 「模块图每次启动都在重组,页面冻结的清单对不上」 | 同一形状的 26 项批重复出现,结果时好时坏 ⇒ 是竞态 + 另一条独立路径,不是单纯的图抖动 |
29
+ | 2 | 宿主侧把「过期组合批」用「单模块字节」顶替 | 服务器日志 13 条 bundle 请求**全是 single**,没有一次 combo 走到该分支;修好后症状依旧 |
30
+ | 3 | 单个模块 import 失败会连坐其它插件 | 读源码:`reconcile` 每包独立 try/catch,失败只进 `failures`,不中止别人 |
31
+ | 4 | 我们的插件把对方隐藏了(hidden 列表 / 侧栏 `display:none`) | `prefs.json` 里 `hidden=[]`;而且即使 DOM 全隐藏,槽表也应该还有对方条目——实际没有 |
32
+ | 5 | 页面自动刷新打断了批次加载 | `reloadPage` 只挂在用户手动点击的按钮上,没有自动调用点 |
33
+
34
+ > 教训:**「槽表里没有它」比「DOM 里没有它」是强得多的判据。** `entriesOfSlot` 返回已注册条目且不按可见性过滤,所以「槽表无条目」直接排除所有 CSS/DOM 解释,把问题逼进「客户端模块到底有没有执行」这一层。
35
+
36
+ ## 4. 真正的证据来自页面内部
37
+
38
+ 宿主日志只能看 HTTP 状态码,而这里所有 bundle 都是 200,宿主侧完全看不出问题。突破口是**让插件自己在页面里留痕**:
39
+
40
+ 1. **模块顶层就写 localStorage**:`client.js` 一执行就记一条(阶段序列 `top → build → factory → apply → ready`,加上 `typeof window.__ModuleLoader__` 与 boot 清单摘要)。阶段停在 `factory` 就说明「脚本跑了,但 apply 没被调用」。
41
+ 2. **`window.addEventListener('error', ..., true)` + unhandledrejection + 包装 console.error/warn**,把原文写进同一个值。
42
+ 3. 离线读 Chromium 的 LevelDB(`Local Storage/leveldb` 下的 `*.log`/`*.ldb`,值按 UTF-16LE 解码)——不需要 DevTools,也不需要人工盯界面。
43
+
44
+ 拿到的决定性一行:
45
+
46
+ ```text
47
+ Uncaught SyntaxError: Identifier 'PAGE_LOG' has already been declared
48
+ @ dsh-app://app/plugins/??dsh-panel-control/client.js&rev=4bbae3358a87:1
49
+ ```
50
+
51
+ 以及更早一批重复注册错误(说明这是同一个实例的第二次执行):
52
+
53
+ ```text
54
+ Error: keyed slot "main" already has an entry for key "panel-control" at priority 0
55
+ list slot "sidebar.panellist" already has an entry with id "panel-control"
56
+ list slot "settings.section" already has an entry with id "panel-control-settings"
57
+ ```
58
+
59
+ ## 5. 根因链条
60
+
61
+ 机制(`dsh-client-modules/lib/client.js`):
62
+
63
+ 1. `start()` 先 `await Promise.all(desired.plugins.map(...))` 再 `await loader.await()`,**没有 per-entry 失败隔离**:一轮里抛出即整轮中止。
64
+ 2. 同一 bundle 执行两次时,`register` 会抛 `client-modules: duplicate factory registration for "<id>" (bundle executed twice without invalidate?)`;但如果脚本带顶层 `const`,它在**解析阶段**就死了,连这句友好错误都拿不到,只剩 `SyntaxError`。
65
+ 3. 对立即加载的插件,平台会发一条独立单包 URL;同一份代码又出现在组合批里(批用于补齐增量模块)。两条路径各执行一次 ⇒ 双求值。`invalidate(id, rev)` 本应在重载时清工厂,但「同 rev 被两个入口各执行一次」不经过它。
66
+
67
+ **症状对应**:批内顺序上 dsh-workspace-control 恰好紧跟在本插件后面(多次日志都是 `[dsh-panel-control], dsh-workspace-control` 相邻),所以整轮一断它第一个陪葬 → 入口、菜单、工具全没 → 看起来「像被移除了」。插件管理读宿主状态,宿主半边一直活着,所以仍显示「运行」。
68
+
69
+ ## 6. 修复(插件侧,已上线 v0.6.4)
70
+
71
+ 1. **整份 `client.js` 包进 IIFE**,杜绝顶层 `const/let` 落到全局词法作用域——双求值从此最多是「重复注册」,不再是 `SyntaxError`。
72
+ 2. **注册调用全部 try/catch**:撞 duplicate 时 `console.warn` 并保留第一个实例,绝不外抛。
73
+ 3. **不做跨版本「早退」优化**:停用插件时平台会 delete 工厂,重开必须重新注册;早退会导致 `import failed` 启动崩溃(我们踩过)。
74
+ 4. 声明 `immediately: true`,拥有独立单包 URL,不依赖组合批是否包含自己。
75
+ 5. 删掉一切「自己造 `/plugins/` 请求」的探针——那会额外制造 duplicate factory registration,等于自己给自己下毒。
76
+
77
+ 验证:连续 4 次冷启动 + 1 次停用/启用,`slots` 同时含两个插件、侧栏两条入口均 shown、page-log 0 条、崩溃目录无新增 `web-boot` 日志。
78
+
79
+ ## 7. 给平台的建议(按收益排序)
80
+
81
+ 1. **`loader.start()` 做 per-entry 隔离**:单条 create/apply 失败只把该条目记进 `failures`,不要中止整轮。今天「一个插件写错一行」= 「后面所有插件静默消失」。
82
+ 2. **bundle 执行幂等化**:同一 `(id, rev)` 在同一个 document 内第二次执行直接跳过;或注入前比对 rev,已注册就不再插 `<script>`。
83
+ 3. **重复注册可降级为警告**(opt-in):撞 id 时若两份 label/locale 相同,保留先注册者。
84
+ 4. **启动失败要能看见**:`web boot: 1 entry did not activate` 应带上是哪一条,以及被中止后继条目的完整列表。
85
+ 5. 文档补一句:`client.js` 是经典脚本,**必须**自包 IIFE;顶层 `const` 在双求值场景下是致命错误。
86
+
87
+ ## 8. 给插件作者的防御清单
88
+
89
+ - [ ] 整份 `client.js` 包 IIFE,不往 window 写裸 `const/let`。
90
+ - [ ] 所有 `ctx.slots.register` / `ctx.provide` / 工具注册包 try/catch,撞重复只警告。
91
+ - [ ] 不要主动请求 `/plugins/...`:那不是探针,是自伤。
92
+ - [ ] 别在 `dsh-app://` 页面用 `performance.getEntriesByType('resource')` 观测网络:非 http(s) 协议不计数,永远是 0。
93
+ - [ ] 页面内取证优先写 `localStorage`(模块顶层就写),离线读 LevelDB,可留档且不依赖人工。
94
+ - [ ] 「插件没出现」先查 `ctx.slots.entriesOfSlot(...)`:**槽表有 = 渲染问题;槽表无 = 执行问题**。
95
+ - [ ] 宿主侧日志记录**所有** bundle 请求与状态码(不止自己),否则 404 / 双求值完全不可见。
96
+ - [ ] 把平台报的 duplicate registration 原文贴进 issue,它通常直接指向双求值。
97
+
98
+ ---
99
+
100
+ **一句话总结**:这不是「两个插件冲突」,而是「一个插件被平台执行了两次,第二次在解析期爆炸,把整批插件的启动循环炸断了」。诊断的关键不在宿主 HTTP 日志(全是 200),而在让页面自己把执行阶段写到硬盘上。
@@ -0,0 +1,40 @@
1
+ # 侧栏布局:为什么是兜底而不是改包
2
+
3
+ ## 问题
4
+
5
+ `@deepseek-ai/dsh-client-ui-sidebar` 的 `.root` 下,`panelList` 与 `regionArea` 是兄弟节点:
6
+
7
+ ```css
8
+ .hHd-Xa_panelList { flex: none; }
9
+ .hHd-Xa_regionArea { flex: 1; overflow: hidden; }
10
+ ```
11
+
12
+ 导航项超过窗口能容纳的高度后,列表按内容高度独占,`regionArea` 分到的高度趋近 0,`overflow:hidden`
13
+ 把内容裁掉,而全页没有任何可滚动容器——滚轮"失效"只是现象。官方包在 0.2.1-rc.4 上如此,桌面浏览器
14
+ 压到约 700px 也能复现。
15
+
16
+ ## 做法
17
+
18
+ 1. **不改官方包**。类名是构建时哈希(`hHd-Xa_`、`wtg9ga_`),每次发版都会变,任何按类名写的补丁都会碎。
19
+ 插件只加自己的属性:`data-dpc-list`、`data-dpc-unbounded`、`data-dpc-region`。
20
+ 2. **CSS 只依赖属性**,写在 `SIDEBAR_CSS`:
21
+ - 列表 `flex:0 1 auto;min-height:0;overflow-y:auto;max-height:max(120px,var(--dpc-panellist-max,45vh))`
22
+ - 上限「不限」时加 `data-dpc-unbounded` → `max-height:none`(仍然可滚)
23
+ - 区域 `flex:1 1 auto;min-height:min(120px,18vh)`,保证会话区永远有位置
24
+ 3. **每次 apply 重新打标**(列表可能被 React 重建);`destroy()` 逐个移除属性与 `--dpc-panellist-max`。
25
+ 4. **分组标题即折叠按钮**。标题本来由本插件插入,所以点击处理不碰 React 的节点;折叠只是给组员按钮
26
+ 写 `display:none` + `data-dpc-collapsed`,与「隐藏」是两条独立的状态。
27
+
28
+ ## 边界
29
+
30
+ - 窄侧栏(宽度 < `WIDE_MIN_WIDTH`=64px)不渲染标题、不折叠:那里没有空间放 caret 和计数。
31
+ - 设置里关掉「高度兜底」后两个属性都会被移除,行为完全回到官方默认;用户随时能退回。
32
+ - 上限夹在 0–70vh;`sanitize()` 会把历史文件里缺失的字段补成默认值(guard/collapse 默认开,panelMax 45)。
33
+ - 折叠状态按组 id 保存;组被删除后残留的 id 无害,`sanitize()` 会去重并限长。
34
+
35
+ ## 验证
36
+
37
+ - `npm run check` 跑 `scripts/layout-smoke.mjs`:断言属性钩子、CSS 三条规则、`BUILD_STAMP` 与
38
+ `package.json` 版本一致。
39
+ - 运行时:`[data-dpc-list]` 的 `getComputedStyle(...).overflowY` 应为 `auto`,上限 45vh 时
40
+ `maxHeight` 为 `max(120px, 45vh)`;点标题后该组按钮 `display:none`、标题仍在。
package/index.js CHANGED
@@ -17,6 +17,7 @@
17
17
 
18
18
  import { promises as fs } from 'node:fs';
19
19
  import os from 'node:os';
20
+ import { createRequire } from 'node:module';
20
21
  import path from 'node:path';
21
22
  import { fileURLToPath } from 'node:url';
22
23
 
@@ -75,6 +76,23 @@ function isLoopbackAuthority(authority) {
75
76
  }
76
77
  }
77
78
 
79
+ /**
80
+ * The desktop's dev-proxy serves the page from a reserved origin such as
81
+ * `http://unlag.invalid`. That TLD never resolves, so only a proxy running
82
+ * inside this Host can put it in a Host header -- but the page is still ours.
83
+ * Treating it as foreign refused every call the plugin made for itself.
84
+ */
85
+ function isProxyAuthority(authority) {
86
+ if (typeof authority !== 'string' || authority === '') return false;
87
+ try {
88
+ const url = new URL(`http://${authority}`);
89
+ if (url.username || url.password || url.pathname !== '/' || url.search || url.hash) return false;
90
+ return /\.invalid$/.test(url.hostname.replace(/\.$/, ''));
91
+ } catch {
92
+ return false;
93
+ }
94
+ }
95
+
78
96
  /**
79
97
  * Only the page the Host itself served may call this route.
80
98
  *
@@ -86,7 +104,10 @@ function isLoopbackAuthority(authority) {
86
104
  */
87
105
  function isOwnRequest(request) {
88
106
  const host = request.headers.get('host');
89
- if (!isLoopbackAuthority(host)) return false;
107
+ const proxied = isProxyAuthority(host);
108
+ if (!isLoopbackAuthority(host) && !proxied) return false;
109
+ // A proxied page cannot be told apart by port: the proxy owns the authority.
110
+ if (proxied) return true;
90
111
  // A browser labels where the request came from; anything but same-origin (or
91
112
  // a non-browser caller) is a different page and has no business here.
92
113
  const site = request.headers.get('sec-fetch-site');
@@ -401,7 +422,30 @@ async function classify(scope, payload, signal) {
401
422
  /* -------------------------------------------------------------------- route */
402
423
 
403
424
  async function handle(scope, request) {
404
- if (!isOwnRequest(request)) return new Response('forbidden', { status: 403 });
425
+ // A page beacon is a plain GET: it has to work from the bundle's module scope,
426
+ // where no service exists yet, and it is the only channel that still speaks
427
+ // when the runner never applies the Client half.
428
+ {
429
+ const beaconUrl = new URL(String(request.url || ''), 'http://beacon.invalid');
430
+ if (beaconUrl.pathname === ROUTE_PATH && beaconUrl.searchParams.has('d')) {
431
+ if (!isOwnRequest(request)) {
432
+ logHost(`page-beacon refused: host=${request.headers.get('host')} origin=${request.headers.get('origin')}`);
433
+ return new Response('forbidden', { status: 403 });
434
+ }
435
+ const text = String(beaconUrl.searchParams.get('d') || '').slice(0, 700);
436
+ logHost(`page-beacon: ${text}`);
437
+ return new Response('ok', { status: 200, headers: { 'content-type': 'text/plain' } });
438
+ }
439
+ }
440
+ if (!isOwnRequest(request)) {
441
+ // Stay quiet here and "the page ran but was refused" looks exactly like
442
+ // "the page never ran": every hostCall returns null on a 403, so the
443
+ // Client half logs nothing either way.
444
+ logHost(
445
+ `refused: ${request.method} host=${request.headers.get('host')} origin=${request.headers.get('origin')} site=${request.headers.get('sec-fetch-site')} · ${String(request.url || '').split('?')[0].slice(0, 80)}`,
446
+ );
447
+ return new Response('forbidden', { status: 403 });
448
+ }
405
449
  if (request.method !== 'POST') return new Response('method not allowed', { status: 405 });
406
450
  const contentType = request.headers.get('content-type');
407
451
  if (!contentType || contentType.split(';', 1)[0].trim().toLowerCase() !== 'application/json') {
@@ -513,12 +557,82 @@ function logInventoryOnApply(scope) {
513
557
  `graph: ${listed ? 'lists' : 'does not list'} ${PACKAGE} · rev ${value.rev} · ` +
514
558
  `${value.ids.length} client entr${value.ids.length === 1 ? 'y' : 'ies'}`,
515
559
  );
516
- if (listed) void probeOwnBundle(scope);
560
+ if (listed) {
561
+ void probeOwnBundle(scope);
562
+ logBatchPlacement(scope);
563
+ }
517
564
  } catch (error) {
518
565
  logHost(`graph: probe failed (${describeError(error)})`);
519
566
  }
520
567
  }
521
568
 
569
+ /**
570
+ * Where in the composed graph this package sits. The page's frozen list names
571
+ * our package by two URLs: the single-resource URL on `entry.url` (rev == our
572
+ * artifact rev) and the batch URL it actually boots with (a combo rev shared
573
+ * with the neighbours). When a cold start serves the batch but never executes
574
+ * it, these lines show whether the page's batch rev matches the one the Host
575
+ * composed now, or is a retired batch.
576
+ */
577
+ function logBatchPlacement(scope) {
578
+ try {
579
+ const modules = clientModules || serviceOf(scope, 'clientModules');
580
+ const graph = modules && typeof modules.graph === 'function' ? modules.graph() : null;
581
+ const entries = graph && Array.isArray(graph.entries) ? graph.entries : [];
582
+ const batches = graph && Array.isArray(graph.batches) ? graph.batches : [];
583
+ const entry = entries.find((row) => row && row.id === PACKAGE);
584
+ if (entry) {
585
+ logHost(
586
+ `graph: own row ${JSON.stringify({
587
+ id: entry.id,
588
+ url: entry.url,
589
+ rev: entry.rev,
590
+ immediately: entry.immediately,
591
+ inject: entry.inject,
592
+ external: entry.external,
593
+ })}`,
594
+ );
595
+ }
596
+ // Every row, once per start: a repeated id, or two ids over one bundle, is what makes a
597
+ // page apply a plugin twice and collide on the slot ids it registers.
598
+ const rows = entries.map((row) => ({
599
+ id: row && row.id,
600
+ rev: row && row.rev,
601
+ immediately: row && row.immediately,
602
+ url: String((row && row.url) || '').slice(0, 120),
603
+ }));
604
+ const byId = new Map();
605
+ for (const row of rows) byId.set(row.id, (byId.get(row.id) || 0) + 1);
606
+ const repeated = [...byId].filter((pair) => pair[1] > 1).map((pair) => `${pair[0]}x${pair[1]}`);
607
+ const byUrl = new Map();
608
+ for (const row of rows) {
609
+ const key = String(row.url || '').split('&rev=')[0];
610
+ byUrl.set(key, [...(byUrl.get(key) || []), row.id]);
611
+ }
612
+ const shared = [...byUrl].filter((pair) => pair[1].length > 1).map((pair) => `${pair[0]} <- ${pair[1].join(',')}`);
613
+ logHost(`graph: batches=${batches.length} rows=${rows.length} repeated=${JSON.stringify(repeated)} shared-bundle=${JSON.stringify(shared.slice(0, 4))}`);
614
+ for (const batch of batches) {
615
+ const ids = batch && Array.isArray(batch.entries) ? batch.entries : [];
616
+ logHost(`graph: batch dump one phase=${String(batch && batch.phase)} rev=${String(batch && batch.rev)} n=${ids.length} ws=${ids.includes('dsh-workspace-control')} pc=${ids.includes(PACKAGE)}`);
617
+ }
618
+ logHost(`graph: ids ${rows.map((row) => row.id).join(',')}`);
619
+ for (const batch of batches) {
620
+ if (!batch || !Array.isArray(batch.entries) || !batch.entries.includes(PACKAGE)) continue;
621
+ const index = batch.entries.indexOf(PACKAGE);
622
+ const neighbours = batch.entries
623
+ .slice(Math.max(0, index - 1), index + 2)
624
+ .map((id, i) => (id === PACKAGE ? `[${id}]` : id))
625
+ .join(',');
626
+ logHost(
627
+ `graph: batch ${batch.phase} · rev ${String(batch.rev)} · ${batch.entries.length} entries · ` +
628
+ `#${index} near {${neighbours}} · url ${String(batch.url)}`,
629
+ );
630
+ }
631
+ } catch (error) {
632
+ logHost(`graph: batch placement failed (${describeError(error)})`);
633
+ }
634
+ }
635
+
522
636
  /**
523
637
  * Serve the current revision when a page asks for one this process has retired.
524
638
  *
@@ -535,7 +649,9 @@ function logInventoryOnApply(scope) {
535
649
  * gets the bytes it needs; everything else passes straight through, and the
536
650
  * patch is undone on teardown.
537
651
  */
538
- const SERVE_CURRENT = String(process.env.DPC_SERVE_CURRENT || '').toLowerCase() !== 'off';
652
+ // Off by default: rewriting the shared bundle service is a whole-page change that
653
+ // other plugins pay for. It stays available as an explicit escape hatch only.
654
+ const SERVE_CURRENT = String(process.env.DPC_SERVE_CURRENT || '').toLowerCase() === 'on';
539
655
  let servePatched = null;
540
656
  let serveLoggedAt = 0;
541
657
  /**
@@ -548,9 +664,30 @@ let serveLoggedAt = 0;
548
664
  */
549
665
  const servedBytes = new Map();
550
666
  const SERVED_BYTES_KEPT = 5;
667
+
668
+ /**
669
+ * A combo request asks for several bundles at once (`/plugins/??a,b,c`).
670
+ *
671
+ * A combo response can only be answered whole: handing back one module's bytes
672
+ * for a combo URL is not a stale revision, it is a broken batch. The page that
673
+ * receives it registers this plugin and nothing else in the batch, so every
674
+ * plugin after ours in the composition disappears without an error. Keep the
675
+ * two shapes strictly apart.
676
+ */
677
+ function isComboUrl(url) {
678
+ const value = String(url || '');
679
+ const query = value.indexOf('??');
680
+ return query !== -1 && value.slice(query + 2).split('&')[0].includes(',');
681
+ }
682
+
683
+ /** Bytes are remembered per (revision, shape): one revision can exist as a
684
+ * single bundle and as a combo, and the two bodies are not interchangeable. */
685
+ function servedKey(url) {
686
+ return `${revisionOf(url) || ''}|${isComboUrl(url) ? 'combo' : 'single'}`;
687
+ }
551
688
  /** Requests to log per Host process: enough to see a boot, not a chatty stream. */
552
689
  let bundleAsked = 0;
553
- const BUNDLE_ASK_LIMIT = 8;
690
+ const BUNDLE_ASK_LIMIT = 120;
554
691
 
555
692
  function revisionOf(url) {
556
693
  const match = /[?&]rev=([A-Za-z0-9._-]+)/.exec(String(url || ''));
@@ -565,15 +702,23 @@ function serveCurrentRevision(scope) {
565
702
  servePatched = modules;
566
703
  modules.bundleResource = async function bundleResourceWithFallback(method, url) {
567
704
  const answer = await original(method, url);
568
- if (String(url || '').includes(PACKAGE) && bundleAsked < BUNDLE_ASK_LIMIT) {
705
+ // Log every bundle the page asks for, whichever plugin it belongs to: when a
706
+ // neighbour disappears, the question is whether its script *failed to arrive*
707
+ // (a status code here) or arrived and failed (a client-side message instead).
708
+ const askedUrl = String(url || '');
709
+ if (askedUrl.includes('??') && bundleAsked < BUNDLE_ASK_LIMIT) {
569
710
  bundleAsked += 1;
570
- logHost(`bundle: asked rev ${String(revisionOf(url))} -> ${answer && answer.status}`);
711
+ logHost(
712
+ `bundle: asked ${isComboUrl(askedUrl) ? 'combo' : 'single'} ` +
713
+ `rev ${String(revisionOf(askedUrl))} -> ${answer && answer.status} · ${askedUrl.slice(0, 160)}`,
714
+ );
571
715
  }
572
716
  if (answer && answer.status === 200 && method === 'GET' && String(url || '').includes(PACKAGE)) {
573
717
  const rev = revisionOf(url);
574
718
  if (rev && typeof answer.body === 'string') {
575
- servedBytes.delete(rev);
576
- servedBytes.set(rev, answer);
719
+ const key = servedKey(url);
720
+ servedBytes.delete(key);
721
+ servedBytes.set(key, answer);
577
722
  while (servedBytes.size > SERVED_BYTES_KEPT) {
578
723
  servedBytes.delete(servedBytes.keys().next().value);
579
724
  }
@@ -582,11 +727,12 @@ function serveCurrentRevision(scope) {
582
727
  if (answer && answer.status !== 404) return answer;
583
728
  try {
584
729
  const requested = String(url || '');
585
- if (!requested.includes(PACKAGE)) return answer;
586
730
  // A revision this Host already handed out outranks the current one: the
587
731
  // page booted with it, and its list will keep naming it.
588
732
  const asked = revisionOf(requested);
589
- const remembered = asked ? servedBytes.get(asked) : undefined;
733
+ const remembered = asked && requested.includes(PACKAGE)
734
+ ? servedBytes.get(servedKey(requested))
735
+ : undefined;
590
736
  if (remembered !== undefined) {
591
737
  if (Date.now() - serveLoggedAt > 30_000) {
592
738
  serveLoggedAt = Date.now();
@@ -596,10 +742,37 @@ function serveCurrentRevision(scope) {
596
742
  }
597
743
  return method === 'HEAD' ? { ...remembered, body: undefined } : remembered;
598
744
  }
745
+ // Only a single-bundle request may be re-pointed at the current revision.
746
+ // A combo has to be answered as the batch it asked for: replying with this
747
+ // package alone drops every other module the page listed, and the ones
748
+ // ordered after us in the composition never run. Let the registry's 404
749
+ // through instead, so the page reloads its list instead of half-booting.
750
+ if (isComboUrl(requested)) {
751
+ if (Date.now() - serveLoggedAt > 30_000) {
752
+ serveLoggedAt = Date.now();
753
+ logHost(
754
+ `bundle: stale combo rev ${String(asked)} left as a 404 (a batch cannot be ` +
755
+ 'answered with one module; the page must reload its plugin list)',
756
+ );
757
+ }
758
+ return answer;
759
+ }
599
760
  const graph = typeof modules.graph === 'function' ? modules.graph() : null;
600
761
  const rows = graph && Array.isArray(graph.entries) ? graph.entries : [];
601
- const entry = rows.find((row) => row && row.id === PACKAGE);
602
- if (!entry || typeof entry.url !== 'string') return answer;
762
+ // Every plugin that declares `immediately` is fetched as a single bundle
763
+ // of its own, so a retired revision drops exactly one plugin: its script
764
+ // never runs, it never registers a slot, and the manager still reports it
765
+ // running. Rescuing only this package made this plugin outlive its
766
+ // neighbours: the one carrying the fallback survived while the one next
767
+ // to it vanished. Rescue every row the page named instead.
768
+ const entry = rows.find(
769
+ (row) =>
770
+ row &&
771
+ typeof row.id === 'string' &&
772
+ typeof row.url === 'string' &&
773
+ requested.includes(`${row.id}/client.js`),
774
+ );
775
+ if (!entry) return answer;
603
776
  const retryUrl = entry.url.startsWith('/') ? entry.url : `/${entry.url}`;
604
777
  if (retryUrl === requested) return answer;
605
778
  const retried = await original(method, retryUrl);
@@ -607,8 +780,11 @@ function serveCurrentRevision(scope) {
607
780
  if (Date.now() - serveLoggedAt > 30_000) {
608
781
  serveLoggedAt = Date.now();
609
782
  logHost(
610
- `served ${PACKAGE} at the current revision: the page asked for a retired one ` +
611
- '(its plugin list predates the last write to client.js)',
783
+ entry.id === PACKAGE
784
+ ? `served ${PACKAGE} at the current revision: the page asked for a retired ` +
785
+ 'one (its plugin list predates the last write to client.js)'
786
+ : `served ${entry.id} at the current revision: the page asked for a retired ` +
787
+ 'one, and a missing bundle silently drops that plugin',
612
788
  );
613
789
  }
614
790
  return retried;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-panel-control",
3
- "version": "0.4.0",
3
+ "version": "0.7.1",
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": [
@@ -50,7 +50,7 @@
50
50
  "url": "https://github.com/QIUZAIYOU/dsh-panel-control/issues"
51
51
  },
52
52
  "scripts": {
53
- "check": "node --check index.js && node --check client.js && node scripts/host-smoke.mjs",
53
+ "check": "node --check index.js && node --check client.js && node scripts/host-smoke.mjs && node scripts/layout-smoke.mjs",
54
54
  "smoke": "node scripts/host-smoke.mjs",
55
55
  "doctor": "node scripts/profile-doctor.mjs",
56
56
  "enable": "node scripts/enable-local.mjs"
@@ -29,6 +29,9 @@ const MODULE = pathToFileURL(override ? path.resolve(override) : path.join(here,
29
29
  // The recovery nudge rewrites the real client.js when a page never reports; the
30
30
  // harness must stay read-only about the checkout.
31
31
  process.env.DPC_NUDGE = 'off';
32
+ // Rescue-by-current-revision is an explicit opt-in now: the pages below exercise
33
+ // it deliberately, while the default path is asserted later.
34
+ process.env.DPC_SERVE_CURRENT = 'on';
32
35
 
33
36
  const timers = [];
34
37
  const realSetTimeout = globalThis.setTimeout;
@@ -62,6 +65,10 @@ const clientModulesStub = {
62
65
  { id: 'a' },
63
66
  { id: 'b' },
64
67
  { id: 'dsh-panel-control', rev: 'rev-now', url: 'plugins/??dsh-panel-control/client.js&rev=rev-now' },
68
+ // The plugin that sits next to this one in the real profile: it carries no
69
+ // fallback of its own, so a retired revision used to drop it silently
70
+ // while the manager still reported it running.
71
+ { id: 'dsh-workspace-control', rev: 'rev-now', url: 'plugins/??dsh-workspace-control/client.js&rev=rev-now' },
65
72
  ],
66
73
  batches: [],
67
74
  }),
@@ -195,6 +202,19 @@ if (route) {
195
202
  '/plugins/??dsh-panel-control/client.js&rev=rev-old',
196
203
  );
197
204
  check('stale revision is served at the current one', stale.status === 200, `status ${stale.status}`);
205
+ // A combo is the batch the page booted with. Answering it with this one
206
+ // module's bytes registers this plugin and silently drops every other module
207
+ // in the batch -- the page then runs one plugin instead of the list it
208
+ // booted with. The stale combo has to stay a 404.
209
+ const staleCombo = await clientModulesStub.bundleResource(
210
+ 'GET',
211
+ '/plugins/??a/client.js,b/client.js,dsh-panel-control/client.js&rev=rev-old-combo',
212
+ );
213
+ check(
214
+ 'a stale combo is not answered with the single bundle',
215
+ staleCombo.status === 404,
216
+ `status ${staleCombo.status}`,
217
+ );
198
218
  // A revision this Host already handed out must survive the registry
199
219
  // forgetting it: the page keeps asking for the list it booted with.
200
220
  await clientModulesStub.bundleResource('GET', '/plugins/??dsh-panel-control/client.js&rev=rev-now');
@@ -211,11 +231,23 @@ if (route) {
211
231
  );
212
232
  check('current revision passes straight through', fresh.status === 200, `status ${fresh.status}`);
213
233
  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}`);
234
+ check('packages outside the graph keep their 404', other.status === 404, `status ${other.status}`);
235
+ // The reported bug: a *neighbouring* plugin on a retired revision. Our
236
+ // bundle loaded, theirs 404'd, and it vanished from the page while the
237
+ // manager kept saying "running". One fallback for the whole list fixes it.
238
+ const neighbour = await clientModulesStub.bundleResource(
239
+ 'GET',
240
+ '/plugins/??dsh-workspace-control/client.js&rev=rev-old',
241
+ );
242
+ check(
243
+ 'a neighbouring plugin is served at the current revision too',
244
+ neighbour.status === 200,
245
+ `status ${neighbour.status}`
246
+ );
215
247
  check(
216
248
  'inventory ids',
217
249
  Array.isArray(body.result.value.ids) &&
218
- body.result.value.ids.join() === 'a,b,dsh-panel-control',
250
+ body.result.value.ids.join() === 'a,b,dsh-panel-control,dsh-workspace-control',
219
251
  body.result.value && body.result.value.ids.join(),
220
252
  );
221
253
  } catch (error) {
@@ -259,6 +291,17 @@ if (route) {
259
291
  } catch (error) {
260
292
  check('custom-protocol page allowed', false, error.message);
261
293
  }
294
+ // The desktop dev-proxy serves the page from a reserved .invalid origin; the
295
+ // page is still ours, so its calls must not be refused.
296
+ try {
297
+ const response = await route.fetch(
298
+ request(envelope('inventory'), { host: 'unlag.invalid', origin: 'http://unlag.invalid' }),
299
+ );
300
+ const body = await response.json();
301
+ check('dev-proxy page allowed', response.ok === true && body.result && body.result.ok === true, String(response.status));
302
+ } catch (error) {
303
+ check('dev-proxy page allowed', false, error.message);
304
+ }
262
305
  // An oversized body is refused before it is parsed.
263
306
  try {
264
307
  const huge = request(envelope('inventory'));
@@ -0,0 +1,48 @@
1
+ // Static gate for the sidebar layout guard (discussion #12083).
2
+ //
3
+ // The guard is CSS plus a handful of attributes the engine writes; neither is
4
+ // reachable from a node unit test because client.js only loads inside the page
5
+ // module loader. These assertions keep the pieces the runtime check proved are
6
+ // necessary from silently disappearing in a refactor.
7
+ import { readFileSync } from 'node:fs';
8
+
9
+ const read = (name) => readFileSync(new URL('../' + name, import.meta.url), 'utf8');
10
+ const client = read('client.js');
11
+ const pkg = JSON.parse(read('package.json'));
12
+ const problems = [];
13
+
14
+ const expect = (ok, message) => {
15
+ if (!ok) problems.push(message);
16
+ };
17
+
18
+ for (const marker of [
19
+ 'data-dpc-list',
20
+ 'data-dpc-unbounded',
21
+ 'data-dpc-region',
22
+ '--dpc-panellist-max',
23
+ 'data-dpc-group-id',
24
+ 'dpc-caret',
25
+ 'dpc-sepCount',
26
+ 'layoutTitle',
27
+ 'layoutGuard',
28
+ 'layoutMaxNone',
29
+ 'toggleCollapse',
30
+ ]) {
31
+ expect(client.includes(marker), 'client.js lost ' + marker);
32
+ }
33
+
34
+ expect(client.includes("const BUILD_STAMP = 'dsh-panel-control/" + pkg.version + "'"), 'BUILD_STAMP and package.json version disagree');
35
+ expect(pkg.scripts && String(pkg.scripts.check).includes('layout-smoke'), 'npm run check no longer runs this gate');
36
+ expect(/\[data-dpc-list\]\{[^}]*overflow-y:auto/.test(client), 'the panel list is not scrollable');
37
+ expect(/\[data-dpc-list\]\{[^}]*max-height:max\(120px,var\(--dpc-panellist-max,45vh\)\)/.test(client), 'the panel list lost its height cap');
38
+ expect(/\[data-dpc-region\]\{[^}]*min-height/.test(client), 'the region below the list lost its floor');
39
+
40
+ expect(/\.dpc-layoutRow:first-of-type\{[^}]*border-top:0/.test(client), 'the first layout row has a hairline touching the card hint');
41
+ expect(/\.dpc-layout > \.dpc-note\{[^}]*margin:6px 0 10px/.test(client), 'the card hint lost its gap above the rows');
42
+ expect(/\.dpc-layoutActions\{[^}]*margin-top:12px/.test(client), 'the collapse/expand actions lost their separation');
43
+ if (problems.length) {
44
+ console.error('layout-smoke: ' + problems.length + ' problem(s)');
45
+ for (const problem of problems) console.error(' - ' + problem);
46
+ process.exit(1);
47
+ }
48
+ console.log('layout-smoke: ok (' + pkg.version + ')');