@240xu/dsh-plugin-hub 0.0.0-stage → 0.3.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.
@@ -0,0 +1,54 @@
1
+ # @240xu/dsh-plugin-hub bundle layer:一条裸锚行(本包根,提供 browser client
2
+ # graph entry)+ 四条 family 行(x240-*,经本包 shell 子路径)+ 一条 web 接线行。
3
+ # 挂载(会签方案 aggregation-weball.md §7 MVA:行 name → @240xu/dsh-plugin-hub/<family>
4
+ # (永不失败的隔离壳),真实包名放 config.plugin,由 shell 动态 import——
5
+ # 任一子包 import/start 失败只降级该行,不拖垮 bundle 事务组、不 abort dsh web。
6
+ # 裸锚行的 host apply 只持有 /degraded + /rows 路由;它的 client bundle 折入
7
+ # message-ops/websearch 的 UI,并按 /rows 的 active 行 gate 挂载。
8
+ # id 统一 x240- 前缀:与各子包 standalone patch 的 id(dsh-websearch 等)不冲突,
9
+ # suite 与 standalone 可共存(浏览器半 standalone 优先;宿主工具可能有一行
10
+ # already registered 警告,见 README)。
11
+ # 若验证期需要绕过 shell 直接挂子包,把 name 换成子包名并删掉 config 即可
12
+ # (对照行保留在文件尾注释)。
13
+ - insert:
14
+ - id: x240-suite
15
+ name: '@240xu/dsh-plugin-hub'
16
+ - insert:
17
+ - id: x240-websearch
18
+ name: '@240xu/dsh-plugin-hub/websearch'
19
+ config:
20
+ plugin: '@240xu/dsh-websearch'
21
+ - insert:
22
+ - id: x240-message-ops
23
+ name: '@240xu/dsh-plugin-hub/message-ops'
24
+ config:
25
+ plugin: '@240xu/dsh-message-ops'
26
+ - insert:
27
+ - id: x240-session-lazy-view
28
+ name: '@240xu/dsh-plugin-hub/session-lazy-view'
29
+ config:
30
+ plugin: '@240xu/dsh-session-lazy-view'
31
+ - id: x240-session-search
32
+ name: '@240xu/dsh-plugin-hub/session-search'
33
+ config:
34
+ plugin: '@240xu/dsh-session-search'
35
+
36
+ # 对照(不启用):无 shell 直挂形态,即任务书 2) 的字面读法——
37
+ # - insert:
38
+ # - id: x240-websearch
39
+ # name: '@240xu/dsh-websearch'
40
+ - id: web
41
+ name: '@deepseek-ai/dsh-web'
42
+ config:
43
+ searchProvider: unified
44
+
45
+ # Optional official Inspector bundle: mounted from the aggregate package so one
46
+ # `dsh plugin add @240xu/dsh-plugin-hub` also installs log/session/DevTools tools.
47
+ - insert:
48
+ - id: experimental-inspector
49
+ name: '@deepseek-ai/dsh-experimental-inspector'
50
+ disabled: false
51
+ config:
52
+ captureFetch: true
53
+ - id: session-inspector
54
+ name: '@deepseek-ai/dsh-experimental-session-inspector'
package/package.json CHANGED
@@ -1,6 +1,65 @@
1
1
  {
2
2
  "name": "@240xu/dsh-plugin-hub",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.3.0",
4
+ "description": "One-line DSH plugin bundle: aggregates websearch, message-ops, session-lazy-view and session-search with per-row failure isolation. 一键安装的 DSH 插件聚合包。",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "240xu",
8
+ "keywords": [
9
+ "dsh-plugin"
10
+ ],
11
+ "main": "src/index.js",
12
+ "exports": {
13
+ ".": "./src/index.js",
14
+ "./client": "./client/index.js",
15
+ "./shell": "./src/shell.js",
16
+ "./websearch": "./src/shell.js",
17
+ "./message-ops": "./src/shell.js",
18
+ "./session-lazy-view": "./src/shell.js",
19
+ "./devkit": "./src/shell.js",
20
+ "./session-search": "./src/shell.js",
21
+ "./degraded": "./src/shell.js",
22
+ "./package.json": "./package.json"
23
+ },
24
+ "files": [
25
+ "src",
26
+ "client",
27
+ "scripts",
28
+ "cordis.patch.yml",
29
+ "README.md",
30
+ "LICENSE"
31
+ ],
32
+ "dsh": {
33
+ "bundle": {
34
+ "patch": "./cordis.patch.yml"
35
+ },
36
+ "client": {
37
+ "inject": [],
38
+ "platform": "web"
39
+ }
40
+ },
41
+ "dependencies": {
42
+ "@240xu/dsh-websearch": "^2.8.3",
43
+ "@240xu/dsh-message-ops": "^0.11.0",
44
+ "@240xu/dsh-session-lazy-view": "^0.3.5",
45
+ "@240xu/dsh-session-search": "^0.1.10",
46
+ "@deepseek-ai/dsh-experimental-inspector-profile": "0.2.1-rc.2"
47
+ },
48
+ "scripts": {
49
+ "build:client": "node scripts/build-client.mjs",
50
+ "check:client": "node scripts/build-client.mjs --check",
51
+ "prepack": "node scripts/build-client.mjs --check",
52
+ "test": "node --test test/*.test.js"
53
+ },
54
+ "engines": {
55
+ "node": ">=20"
56
+ },
57
+ "repository": {
58
+ "type": "git",
59
+ "url": "git+https://github.com/240xu/dsh-plugin-hub.git"
60
+ },
61
+ "homepage": "https://github.com/240xu/dsh-plugin-hub#readme",
62
+ "bugs": {
63
+ "url": "https://github.com/240xu/dsh-plugin-hub/issues"
64
+ }
65
+ }
@@ -0,0 +1,124 @@
1
+ import { createHash } from "node:crypto";
2
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
3
+ import { createRequire } from "node:module";
4
+ import { dirname, join } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+
7
+ const ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
8
+ const OUT = join(ROOT, "client", "index.js");
9
+ const TEMPLATE = join(ROOT, "scripts", "client-runtime.template.js");
10
+
11
+ // Keep this list explicit: it is the published browser surface of the suite.
12
+ // Each entry's client half is inlined into the suite's single graph entry so a
13
+ // subpath shell row can never be mistaken for a client graph row.
14
+ const CHILDREN = [
15
+ {
16
+ name: "@240xu/dsh-message-ops",
17
+ moduleId: "@240xu/dsh-plugin-hub/client/message-ops",
18
+ },
19
+ {
20
+ name: "@240xu/dsh-websearch",
21
+ moduleId: "@240xu/dsh-plugin-hub/client/websearch",
22
+ },
23
+ ];
24
+
25
+ const requireFromRoot = createRequire(join(ROOT, "package.json"));
26
+
27
+ function resolveChild(child) {
28
+ const pkgPath = requireFromRoot.resolve(`${child.name}/package.json`);
29
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
30
+ const exported = pkg.exports?.["./client"];
31
+ const clientRelative = typeof exported === "string" ? exported : exported?.default;
32
+ if (typeof clientRelative !== "string" || clientRelative === "") {
33
+ throw new Error(`${child.name} does not export a ./client bundle`);
34
+ }
35
+ const sourcePath = join(dirname(pkgPath), clientRelative);
36
+ if (!existsSync(sourcePath)) throw new Error(`${child.name} ./client points at a missing file: ${sourcePath}`);
37
+ const source = readFileSync(sourcePath, "utf8")
38
+ .replace(/^\s*\/\/[#@]\s*sourceMappingURL=.*$/gm, "")
39
+ .replace(/^\s*\/\/[#@]\s*sourceURL=.*$/gm, "")
40
+ .replace(/\r\n/g, "\n");
41
+ if (!source.includes("window.__ModuleLoader__.load")) {
42
+ throw new Error(`${child.name} ${clientRelative} is not a __ModuleLoader__ bundle`);
43
+ }
44
+ const digest = createHash("sha256").update(source).digest("hex");
45
+ return {
46
+ ...child,
47
+ version: pkg.version,
48
+ relative: clientRelative,
49
+ source,
50
+ digest,
51
+ };
52
+ }
53
+
54
+ function renderChild(child) {
55
+ const missing = `[dsh-plugin-hub] ${child.name} client bundle did not register a factory`;
56
+ return ` {
57
+ name: ${JSON.stringify(child.name)},
58
+ moduleId: ${JSON.stringify(child.moduleId)},
59
+ capture() {
60
+ let factory;
61
+ const loader = window.__ModuleLoader__;
62
+ const original = loader.load;
63
+ loader.load = (registration) => { factory = registration.factory; };
64
+ try {
65
+ ${child.source}
66
+ } finally {
67
+ loader.load = original;
68
+ }
69
+ if (typeof factory !== "function") throw new Error(${JSON.stringify(missing)});
70
+ return factory;
71
+ },
72
+ create(require) {
73
+ const factory = this.capture();
74
+ return factory(require);
75
+ },
76
+ }`;
77
+ }
78
+
79
+ function render(children) {
80
+ const template = readFileSync(TEMPLATE, "utf8");
81
+ if (!template.includes("__CHILDREN__")) throw new Error("client runtime template is missing __CHILDREN__");
82
+ const header = `/* @240xu/dsh-plugin-hub — generated browser bundle. DO NOT EDIT.
83
+ * Regenerate: node scripts/build-client.mjs
84
+ * Inputs:
85
+ ${children.map((child) => ` * ${child.name}@${child.version} ${child.relative} sha256:${child.digest}`).join("\n")}
86
+ */`;
87
+ const entries = children.map(renderChild).join(",\n");
88
+ return `${header}\n${template.replace("__CHILDREN__", `[\n${entries},\n ]`)}`;
89
+ }
90
+
91
+ const children = CHILDREN.map(resolveChild);
92
+ const output = render(children);
93
+ const check = process.argv.includes("--check");
94
+ const unknown = process.argv.slice(2).filter((arg) => arg !== "--check");
95
+ if (unknown.length > 0) {
96
+ console.error(`build-client: unknown argument(s): ${unknown.join(" ")}`);
97
+ process.exit(2);
98
+ }
99
+
100
+ if (check) {
101
+ if (!existsSync(OUT)) {
102
+ console.error("client bundle stale: client/index.js is missing; run: node scripts/build-client.mjs");
103
+ process.exit(1);
104
+ }
105
+ const current = readFileSync(OUT, "utf8");
106
+ if (current === output) {
107
+ console.log(`client bundle up to date (${Buffer.byteLength(output)} bytes, ${children.length} folded children)`);
108
+ process.exit(0);
109
+ }
110
+ const currentLines = current.split("\n");
111
+ const outputLines = output.split("\n");
112
+ let line = 0;
113
+ while (line < currentLines.length && line < outputLines.length && currentLines[line] === outputLines[line]) line += 1;
114
+ console.error(
115
+ `client bundle stale: client/index.js (${Buffer.byteLength(current)} bytes) != expected (${Buffer.byteLength(output)} bytes); first difference at line ${line + 1}\nrun: node scripts/build-client.mjs`,
116
+ );
117
+ process.exit(1);
118
+ }
119
+
120
+ mkdirSync(dirname(OUT), { recursive: true });
121
+ const temporary = `${OUT}.tmp-${process.pid}`;
122
+ writeFileSync(temporary, output, { mode: 0o644 });
123
+ renameSync(temporary, OUT);
124
+ console.log(`wrote client/index.js (${Buffer.byteLength(output)} bytes, ${children.length} folded children)`);
@@ -0,0 +1,121 @@
1
+ window.__ModuleLoader__.load({
2
+ id: "@240xu/dsh-plugin-hub",
3
+ factory: (require) => {
4
+ const CHILDREN = __CHILDREN__;
5
+ const ROWS_PATH = "api/dsh-plugin-hub/rows";
6
+ const FETCH_TIMEOUT_MS = 1500;
7
+ const MOUNTED = Symbol.for("dsh-plugin-hub.mounted-client-plugins");
8
+
9
+ function mountedRegistry() {
10
+ const root = globalThis;
11
+ return (root[MOUNTED] ??= new Set());
12
+ }
13
+
14
+ function ownClientEntryIds() {
15
+ const entries = globalThis.__DSH_BOOT__?.entries;
16
+ const ids = new Set();
17
+ if (!Array.isArray(entries)) return ids;
18
+ for (const entry of entries) {
19
+ if (typeof entry === "string") ids.add(entry);
20
+ else if (entry && typeof entry.id === "string") ids.add(entry.id);
21
+ }
22
+ return ids;
23
+ }
24
+
25
+ async function fetchActiveRows() {
26
+ if (typeof fetch !== "function") return undefined;
27
+ let timer;
28
+ try {
29
+ const controller = new AbortController();
30
+ timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
31
+ const response = await fetch(ROWS_PATH, {
32
+ credentials: "same-origin",
33
+ headers: { accept: "application/json" },
34
+ signal: controller.signal,
35
+ });
36
+ if (!response.ok) return undefined;
37
+ const payload = await response.json();
38
+ if (!payload || payload.ok !== true || !Array.isArray(payload.children)) return undefined;
39
+ return new Set(payload.children.filter((name) => typeof name === "string"));
40
+ } catch {
41
+ return undefined;
42
+ } finally {
43
+ if (timer !== undefined) clearTimeout(timer);
44
+ }
45
+ }
46
+
47
+ function pluginShape(module) {
48
+ const face = module && module.default !== undefined ? module.default : module;
49
+ if (typeof face === "function") return { apply: face, inject: undefined };
50
+ if (face && typeof face.apply === "function") {
51
+ return { apply: face.apply, inject: face.inject };
52
+ }
53
+ return undefined;
54
+ }
55
+
56
+ async function mountChildren(ctx, options = {}) {
57
+ const active = await fetchActiveRows();
58
+ if (options.isDisposed?.()) return;
59
+ const own = ownClientEntryIds();
60
+ const registry = mountedRegistry();
61
+ const claimed = options.claimed ?? new Set();
62
+ const create = options.create ?? ((child) => child.create(require));
63
+ for (const child of CHILDREN) {
64
+ if (options.isDisposed?.()) return;
65
+ if (active !== undefined && !active.has(child.name)) continue;
66
+ if (own.has(child.name)) continue;
67
+ if (registry.has(child.name)) continue;
68
+ registry.add(child.name);
69
+ claimed.add(child.name);
70
+ try {
71
+ const shape = pluginShape(create(child));
72
+ if (shape === undefined) {
73
+ registry.delete(child.name);
74
+ claimed.delete(child.name);
75
+ console.error(`[dsh-plugin-hub] folded client ${child.name} has no usable plugin shape`);
76
+ continue;
77
+ }
78
+ const definition = {
79
+ name: child.name,
80
+ inject: Array.isArray(shape.inject) ? [...shape.inject] : [],
81
+ apply: shape.apply,
82
+ };
83
+ const fiber = ctx.plugin(definition);
84
+ Promise.resolve(fiber).then(undefined, (error) => {
85
+ registry.delete(child.name);
86
+ claimed.delete(child.name);
87
+ console.error(`[dsh-plugin-hub] folded client ${child.name} failed to start:`, error);
88
+ });
89
+ } catch (error) {
90
+ registry.delete(child.name);
91
+ claimed.delete(child.name);
92
+ console.error(`[dsh-plugin-hub] folded client ${child.name} failed to mount:`, error);
93
+ }
94
+ }
95
+ }
96
+
97
+ function apply(ctx, options = {}) {
98
+ const claimed = new Set();
99
+ let disposed = false;
100
+ try {
101
+ ctx.effect(() => () => {
102
+ disposed = true;
103
+ const registry = mountedRegistry();
104
+ for (const name of claimed) registry.delete(name);
105
+ claimed.clear();
106
+ }, "dsh-plugin-hub: client mount claims");
107
+ } catch (error) {
108
+ console.warn("[dsh-plugin-hub] failed to register client mount cleanup:", error);
109
+ }
110
+ void mountChildren(ctx, { ...options, claimed, isDisposed: () => disposed }).catch((error) => {
111
+ console.error("[dsh-plugin-hub] folded client bridge failed:", error);
112
+ });
113
+ }
114
+
115
+ return {
116
+ apply,
117
+ inject: [],
118
+ __test: { CHILDREN, mountChildren, fetchActiveRows, ownClientEntryIds, mountedRegistry },
119
+ };
120
+ },
121
+ });
package/src/index.js ADDED
@@ -0,0 +1,7 @@
1
+ import shell, { apply, inject, listActiveRows, listDegraded } from "./shell.js";
2
+
3
+ export { apply, inject, listActiveRows, listDegraded };
4
+
5
+ export const name = "@240xu/dsh-plugin-hub";
6
+
7
+ export default { ...shell, name, inject, apply };
package/src/shell.js ADDED
@@ -0,0 +1,215 @@
1
+ // @240xu/dsh-plugin-hub — 故障隔离壳(web-all shell 模式的最小移植)。
2
+ //
3
+ // 模式来源:@linxin666/dsh-web-all(证据 aggregation-weball.md §5,
4
+ // lib/index.js:166-221 apply$1 / :4-12 globalThis 状态 / :52-76 degraded 路由)。
5
+ // 每个 family patch 行的 name 指向本文件(经 package.json exports 的各子路径,
6
+ // 全部重导出同一 apply/inject),真实插件包名在 config.plugin;本壳动态 import
7
+ // 它,import/start 失败只记录降级、绝不 rethrow——官方 loader 把一个 bundle 的
8
+ // 全部 patch 行当一个事务组挂载,裸挂时坏插件会 abort dsh web,壳把失败面
9
+ // 收窄到单行。
10
+ //
11
+ // 浏览器半(0.2.0):子包 client bundle 由 build-client.mjs 折进 suite 的单个
12
+ // graph entry(exports["./client"]),不再依赖“子路径行被 client graph 扫描”
13
+ // ——dsh-client-modules 只解析裸包名,子路径行永远拿不到 dsh.client。
14
+ // activeRows 账本 + /api/dsh-plugin-hub/rows 路由给折叠挂载做 gate:被 disable 的行
15
+ // 没有 shell apply,UI 不挂;import/start 降级的行仍算 active(UI 保留,降级
16
+ // 状态让用户看得见)。degraded 路由仍 loopback+Host 双围栏。
17
+ // 两条健康路由都走嵌套 inject(["webServer"]) fiber——壳比宿主 webServer 先激活,
18
+ // 直接读会 miss(web-all 同款时序处理)。
19
+
20
+ const KEY = Symbol.for("dsh-plugin-hub.shell-state");
21
+
22
+ /** 进程级共享壳状态:bundler 分裂多份模块副本时也只有一份(web-all 同款)。 */
23
+ function shellState() {
24
+ const state = (globalThis[KEY] ??= {});
25
+ state.degraded ??= new Map();
26
+ // plugin -> refcount:同一真实包可能被多行/standalone 同时挂载,计数归零才删。
27
+ state.activeRows ??= new Map();
28
+ state.healthRoutes ??= { count: 0 };
29
+ return state;
30
+ }
31
+
32
+ /** 安全 JSON:circular/BigInt/toJSON 抛出时降级为占位串(0.1.4 P2 护栏)。 */
33
+ function safeStringify(value) {
34
+ try { return JSON.stringify(value ?? null); } catch { return '"<unserializable>"'; }
35
+ }
36
+
37
+ /** 记录(或刷新)一行降级。错误在此打一次日志。
38
+ * 0.1.4(P2):整体自护栏——本函数若自己抛(stack getter/toString 抛),
39
+ * 会逃出 applyShell 让壳行事务回滚整 bundle,恰是壳要防的失败面。 */
40
+ function recordDegraded(plugin, stage, error) {
41
+ try {
42
+ const message = error instanceof Error ? (error.stack ?? error.message) : String(error);
43
+ console.error(`[dsh-plugin-hub] plugin degraded (${stage}): ${plugin}\n${message}`);
44
+ shellState().degraded.set(plugin, { plugin, stage, message, at: new Date().toISOString() });
45
+ } catch (e) {
46
+ try {
47
+ shellState().degraded.set(plugin, { plugin, stage, message: '<degrade ledger write failed>', at: new Date().toISOString() });
48
+ } catch { /* 最后防线:静默——观测功能绝不反噬主流程 */ }
49
+ }
50
+ }
51
+
52
+ export function listDegraded() {
53
+ return [...shellState().degraded.values()];
54
+ }
55
+
56
+ /** 一行 shell entry 已 apply(即使真实插件随后 import/start 降级)。 */
57
+ function recordActiveRow(plugin) {
58
+ const rows = shellState().activeRows;
59
+ rows.set(plugin, (rows.get(plugin) ?? 0) + 1);
60
+ }
61
+
62
+ /** 一行 shell entry 已 dispose;同包仍有别的行挂载时保留。 */
63
+ function removeActiveRow(plugin) {
64
+ const rows = shellState().activeRows;
65
+ const next = (rows.get(plugin) ?? 0) - 1;
66
+ if (next > 0) rows.set(plugin, next);
67
+ else rows.delete(plugin);
68
+ }
69
+
70
+ /** active 行快照(真实插件包名,插入顺序)。browser 半据此 gate 折叠挂载。 */
71
+ export function listActiveRows() {
72
+ return [...shellState().activeRows.keys()];
73
+ }
74
+
75
+ /** 测试专用:清空降级/active 账本与健康路由计数。 */
76
+ export function _resetDegraded() {
77
+ shellState().degraded.clear();
78
+ shellState().activeRows.clear();
79
+ shellState().healthRoutes.count = 0;
80
+ }
81
+
82
+ /** loopback-only 降级观测路由(每 shell 上下文注册一次)。 */
83
+ function makeDegradedRoute() {
84
+ return {
85
+ kind: "exact",
86
+ path: "/api/dsh-plugin-hub/degraded",
87
+ handler: async (req, res) => {
88
+ let remote = req.socket?.remoteAddress ?? "";
89
+ if (remote.startsWith("::ffff:")) remote = remote.slice(7);
90
+ // 0.1.4(P2):remoteAddress 之外必须校验 Host——evil.com TTL 重绑定到
91
+ // 127.0.0.1 后 remoteAddress 仍是回环,仅靠它放行会泄露降级清单
92
+ // (插件名 + error.stack 绝对路径)。Host 必须是本机名(对齐三兄弟的
93
+ // Host 围栏为 arch-review 上线硬门槛)。
94
+ const host = String(req.headers?.host ?? "").toLowerCase();
95
+ const hostOk = /^(localhost|127\.0\.0\.1|\[::1\])(:\d+)?$/.test(host);
96
+ if (remote !== "127.0.0.1" && remote !== "::1" || !hostOk) {
97
+ res.writeHead(403, { "content-type": "application/json" });
98
+ res.end(JSON.stringify({ ok: false, error: "forbidden: loopback-only" }));
99
+ return;
100
+ }
101
+ res.writeHead(200, { "content-type": "application/json", "cache-control": "no-store" });
102
+ res.end(JSON.stringify({ ok: true, degraded: listDegraded() }));
103
+ },
104
+ };
105
+ }
106
+
107
+ /** active-row 路由:不设 loopback 围栏(远端浏览器同源读取;载荷只是包名)。 */
108
+ function makeRowsRoute() {
109
+ return {
110
+ kind: "exact",
111
+ path: "/api/dsh-plugin-hub/rows",
112
+ handler: async (_req, res) => {
113
+ res.writeHead(200, { "content-type": "application/json", "cache-control": "no-store" });
114
+ res.end(JSON.stringify({ ok: true, children: listActiveRows() }));
115
+ },
116
+ };
117
+ }
118
+
119
+ /** 健康路由注册(webServer 迟到安全):嵌套 inject fiber,随本行 dispose。
120
+ * degraded + rows 作为一个原子组注册/注销:任一失败回滚另一条。 */
121
+ function holdHealthRoute(ctx) {
122
+ ctx.inject?.(["webServer"], (scoped) => {
123
+ const webServer = scoped.webServer;
124
+ if (webServer === undefined || typeof webServer.register !== "function") return;
125
+ const routes = shellState().healthRoutes;
126
+ if (routes.count === 0) {
127
+ let unregisterDegraded;
128
+ let unregisterRows;
129
+ try {
130
+ unregisterDegraded = webServer.register(makeDegradedRoute());
131
+ try {
132
+ unregisterRows = webServer.register(makeRowsRoute());
133
+ } catch (error) {
134
+ try { unregisterDegraded?.(); } catch { /* already gone */ }
135
+ throw error;
136
+ }
137
+ routes.unregister = () => {
138
+ try { unregisterRows?.(); } finally { unregisterDegraded?.(); }
139
+ };
140
+ } catch (error) {
141
+ console.warn("[dsh-plugin-hub] failed to register health routes:", error);
142
+ return;
143
+ }
144
+ }
145
+ routes.count += 1;
146
+ ctx.effect?.(() => () => {
147
+ routes.count -= 1;
148
+ if (routes.count <= 0) {
149
+ routes.count = 0;
150
+ try { routes.unregister?.(); } catch { /* already gone */ }
151
+ routes.unregister = undefined;
152
+ }
153
+ }, "dsh-plugin-hub: health routes");
154
+ });
155
+ }
156
+
157
+ /** 已知退役/占位子包:老 profile 残留行挂成静默 no-op(web-all RETIRED_PLUGINS 同款)。 */
158
+ const RETIRED_PLUGINS = new Set();
159
+
160
+ /**
161
+ * 挂载一行:config.plugin 指向的真实插件被包进本壳。
162
+ * 失败语义:import 失败 / 模块无插件形状 / start 抛错或 reject →
163
+ * recordDegraded 后 return,本行降级、其余行照常。
164
+ */
165
+ async function applyShell(ctx, config) {
166
+ holdHealthRoute(ctx);
167
+ const spec = config?.plugin;
168
+ if (typeof spec !== "string" || spec === "") {
169
+ if (config === undefined || (typeof config === "object" && config !== null && Object.keys(config).length === 0)) return;
170
+ recordDegraded("(no plugin)", "shape", new Error(`shell row config is missing the "plugin" package name (config: ${safeStringify(config)})`));
171
+ return;
172
+ }
173
+ if (RETIRED_PLUGINS.has(spec)) return;
174
+ // 先记账再 import:disabled 行没有 shell apply,故不会进账本;import/start
175
+ // 降级的行仍在账本里,browser 半保留其 UI(降级状态可见,web-all 同语义)。
176
+ recordActiveRow(spec);
177
+ try {
178
+ ctx.effect?.(() => () => {
179
+ removeActiveRow(spec);
180
+ }, "dsh-plugin-hub: active row ledger");
181
+ } catch (error) {
182
+ console.warn("[dsh-plugin-hub] failed to register active-row cleanup:", error);
183
+ }
184
+ let mod;
185
+ try {
186
+ mod = await import(/* @vite-ignore */ spec);
187
+ } catch (error) {
188
+ recordDegraded(spec, "import", error);
189
+ return;
190
+ }
191
+ const plugin = mod?.default ?? mod;
192
+ const usable =
193
+ typeof plugin === "function" ||
194
+ (typeof plugin === "object" && plugin !== null && typeof plugin.apply === "function");
195
+ if (!usable) {
196
+ recordDegraded(spec, "shape", new Error("module has no usable plugin shape (expected a function or { apply })"));
197
+ return;
198
+ }
199
+ try {
200
+ const fiber = ctx.plugin(plugin, config?.config);
201
+ Promise.resolve(fiber).then(undefined, (error) => { try { recordDegraded(spec, "start", error); } catch { /* 观测不反噬 */ } });
202
+ } catch (error) {
203
+ recordDegraded(spec, "start", error);
204
+ }
205
+ }
206
+
207
+ /** 壳必须先于一切激活:不依赖任何服务。 */
208
+ export const inject = [];
209
+
210
+ /** Host 侧插件体。 */
211
+ export function apply(ctx, config) {
212
+ return applyShell(ctx, config);
213
+ }
214
+
215
+ export default { name: "dsh-plugin-hub-shell", inject, apply };