dsh-expert-group 0.1.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.
- package/LICENSE +38 -0
- package/README.md +355 -0
- package/THIRD_PARTY_NOTICES.md +35 -0
- package/assets/notification-whoosh.wav +0 -0
- package/bin/prompt-inspector.mjs +63 -0
- package/client-bundle.js +14891 -0
- package/client-expert-group-model.js +426 -0
- package/client-expert-group-sidebar.js +5401 -0
- package/client-freshness.js +364 -0
- package/client-model-config.js +2155 -0
- package/client-notifications.js +38 -0
- package/client.js +6487 -0
- package/cordis.patch.yml +10 -0
- package/fanout-host.js +1507 -0
- package/fanout.js +2981 -0
- package/host/composition.js +155 -0
- package/host/expert-group-runtime.js +822 -0
- package/host/expert-group-snapshot.js +450 -0
- package/host/fanout-runtime.js +822 -0
- package/host/handoff.js +80 -0
- package/host/lineage.js +274 -0
- package/host/notifications.js +189 -0
- package/host/persistence-health.js +389 -0
- package/host/recovery.js +242 -0
- package/host/rpc-transport.js +573 -0
- package/host/settings-runtime.js +168 -0
- package/host/settings-source.js +265 -0
- package/host/storage-binding.js +94 -0
- package/host/system-toast.js +916 -0
- package/image-routing.js +263 -0
- package/index.js +9327 -0
- package/install.ps1 +219 -0
- package/orchestrator.js +239 -0
- package/package.json +142 -0
- package/prompt-contracts.js +148 -0
- package/prompt-inspector.js +584 -0
- package/prompt-sidecar.js +610 -0
- package/resource-budget.js +540 -0
- package/role-registry.js +286 -0
- package/rpc-host.js +310 -0
- package/scripts/assemble-client.mjs +605 -0
- package/scripts/build.mjs +124 -0
- package/shared.js +4003 -0
- package/storage.js +190 -0
- package/uninstall.ps1 +47 -0
package/role-registry.js
ADDED
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 统一角色注册表:插件角色元数据的唯一来源。
|
|
3
|
+
*
|
|
4
|
+
* 设计约束(与任务书 02 一致):
|
|
5
|
+
* - 记录覆盖角色 id、显示名(label)、职责(description)、可选性(optional)、
|
|
6
|
+
* 只读性(readOnly)、默认配置(defaultSelection)与工具名(toolName)。
|
|
7
|
+
* - 只读性必须显式声明:缺失或非法一律 fail-closed 抛错,绝不把未声明角色当可写。
|
|
8
|
+
* - 不内置角色模型默认值,defaultSelection 固定为 null;非 null 视为注册数据错误。
|
|
9
|
+
* - 返回的记录与数组全部冻结;调用方修改不会污染注册表内部状态。
|
|
10
|
+
* - 本模块无任何 import,纯数据 + 纯函数,可被 Host、shared 与双面 bundle 复用。
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** 注册数据错误的稳定错误类型;调用方可用 code 精确分支。 */
|
|
14
|
+
export class RoleRegistryError extends Error {
|
|
15
|
+
constructor(code, message, details = {}) {
|
|
16
|
+
super(message);
|
|
17
|
+
this.name = "RoleRegistryError";
|
|
18
|
+
this.code = code;
|
|
19
|
+
Object.assign(this, details);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const RECORD_FIELDS = Object.freeze(["id", "label", "statusLabel", "description", "optional", "readOnly", "defaultSelection", "toolName"]);
|
|
24
|
+
|
|
25
|
+
function isPlainObject(value) {
|
|
26
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** 深冻结注册数据,保证返回的嵌套值也不能被调用方改写。 */
|
|
30
|
+
function deepFreeze(value) {
|
|
31
|
+
if (value === null || typeof value !== "object") return value;
|
|
32
|
+
Object.freeze(value);
|
|
33
|
+
for (const nested of Object.values(value)) deepFreeze(nested);
|
|
34
|
+
return value;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function normalizeText(value, field, id) {
|
|
38
|
+
if (typeof value !== "string" || value.trim().length === 0) {
|
|
39
|
+
throw new RoleRegistryError("INVALID_ROLE_RECORD", "角色记录字段 " + field + " 必须是非空字符串", { role: id, field });
|
|
40
|
+
}
|
|
41
|
+
return value;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function normalizeFlag(value, field, id) {
|
|
45
|
+
if (typeof value !== "boolean") {
|
|
46
|
+
// 只读性缺失/非法时 fail-closed:宁可拒绝注册,也不默认给出更宽权限。
|
|
47
|
+
throw new RoleRegistryError("INVALID_ROLE_RECORD", "角色记录字段 " + field + " 必须是布尔值(不得缺省)", { role: id, field });
|
|
48
|
+
}
|
|
49
|
+
return value;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** 校验并冻结单条角色记录;缺字段、类型错误或非法默认配置都显式失败。 */
|
|
53
|
+
function normalizeRecord(input) {
|
|
54
|
+
if (!isPlainObject(input)) {
|
|
55
|
+
throw new RoleRegistryError("INVALID_ROLE_RECORD", "角色记录必须是普通对象");
|
|
56
|
+
}
|
|
57
|
+
for (const field of RECORD_FIELDS) {
|
|
58
|
+
if (!Object.prototype.hasOwnProperty.call(input, field)) {
|
|
59
|
+
throw new RoleRegistryError("INVALID_ROLE_RECORD", "角色记录缺少字段 " + field, { role: input.id, field });
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
const id = normalizeText(input.id, "id", input.id);
|
|
63
|
+
const label = normalizeText(input.label, "label", id);
|
|
64
|
+
// statusLabel 是页面运行状态文案使用的英文标签("Explorer running..."),与中文
|
|
65
|
+
// 显示名分离;浏览器 bundle 无法 import 本模块,因此这里是唯一权威来源。
|
|
66
|
+
const statusLabel = normalizeText(input.statusLabel, "statusLabel", id);
|
|
67
|
+
const description = normalizeText(input.description, "description", id);
|
|
68
|
+
const toolName = normalizeText(input.toolName, "toolName", id);
|
|
69
|
+
const optional = normalizeFlag(input.optional, "optional", id);
|
|
70
|
+
const readOnly = normalizeFlag(input.readOnly, "readOnly", id);
|
|
71
|
+
if (input.defaultSelection !== null) {
|
|
72
|
+
throw new RoleRegistryError(
|
|
73
|
+
"INVALID_ROLE_RECORD",
|
|
74
|
+
"角色记录 defaultSelection 必须为 null(插件不内置角色模型默认值)",
|
|
75
|
+
{ role: id, field: "defaultSelection" },
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
return Object.freeze({ id, label, statusLabel, description, optional, readOnly, defaultSelection: null, toolName });
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** 是否按当前配置启用:默认角色恒启用;可选角色只在显式启用时进入投影。 */
|
|
82
|
+
function isEnabled(entry, options) {
|
|
83
|
+
if (entry.optional !== true) return true;
|
|
84
|
+
if (options?.enableObserver === true && entry.id === "observer") return true;
|
|
85
|
+
if (Array.isArray(options?.enableOptional) && options.enableOptional.includes(entry.id)) return true;
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* 建立只读注册表。
|
|
91
|
+
* @param {object[]} records - 按期望顺序排列的角色记录。
|
|
92
|
+
* @returns {object} 冻结的注册表;所有返回值都是冻结快照。
|
|
93
|
+
*/
|
|
94
|
+
export function createRoleRegistry(records) {
|
|
95
|
+
if (!Array.isArray(records)) {
|
|
96
|
+
throw new RoleRegistryError("INVALID_ROLE_REGISTRY", "角色注册表需要记录数组");
|
|
97
|
+
}
|
|
98
|
+
const ordered = [];
|
|
99
|
+
const byId = new Map();
|
|
100
|
+
for (const input of records) {
|
|
101
|
+
const entry = normalizeRecord(input);
|
|
102
|
+
if (byId.has(entry.id)) {
|
|
103
|
+
throw new RoleRegistryError("DUPLICATE_ROLE_ID", "角色 id 重复: " + entry.id, { role: entry.id });
|
|
104
|
+
}
|
|
105
|
+
byId.set(entry.id, entry);
|
|
106
|
+
ordered.push(entry);
|
|
107
|
+
}
|
|
108
|
+
const allRecords = Object.freeze(ordered.slice());
|
|
109
|
+
const allIds = Object.freeze(ordered.map((entry) => entry.id));
|
|
110
|
+
const optionalRecords = Object.freeze(ordered.filter((entry) => entry.optional === true).slice());
|
|
111
|
+
const optionalIds = Object.freeze(optionalRecords.map((entry) => entry.id));
|
|
112
|
+
const readOnlyAllIds = Object.freeze(ordered.filter((entry) => entry.readOnly === true).map((entry) => entry.id));
|
|
113
|
+
const enabledCache = new Map();
|
|
114
|
+
|
|
115
|
+
function enabledEntries(options) {
|
|
116
|
+
const key = options?.enableObserver === true ? "observer" : Array.isArray(options?.enableOptional) ? options.enableOptional.join(",") : "";
|
|
117
|
+
if (enabledCache.has(key)) return enabledCache.get(key);
|
|
118
|
+
const selected = Object.freeze(ordered.filter((entry) => isEnabled(entry, options)).slice());
|
|
119
|
+
enabledCache.set(key, selected);
|
|
120
|
+
return selected;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const registry = {
|
|
124
|
+
/** 注册表声明的角色总数。 */
|
|
125
|
+
get size() { return allIds.length; },
|
|
126
|
+
/** 未知、空值与非字符串一律返回 false,不做大小写猜测。 */
|
|
127
|
+
has(id) { return typeof id === "string" && byId.has(id); },
|
|
128
|
+
/** 未知角色返回 undefined(查询契约,不抛错)。 */
|
|
129
|
+
get(id) { return typeof id === "string" ? byId.get(id) : undefined; },
|
|
130
|
+
/** 未知角色抛 RoleRegistryError("UNKNOWN_ROLE")(必需读取契约,fail-closed)。 */
|
|
131
|
+
require(id) {
|
|
132
|
+
const entry = registry.get(id);
|
|
133
|
+
if (entry === undefined) {
|
|
134
|
+
throw new RoleRegistryError("UNKNOWN_ROLE", "未知角色: " + String(id), { role: id });
|
|
135
|
+
}
|
|
136
|
+
return entry;
|
|
137
|
+
},
|
|
138
|
+
/** 全部声明角色 id(含可选角色),顺序稳定。 */
|
|
139
|
+
ids() { return allIds; },
|
|
140
|
+
/** 全部声明角色记录(含可选角色),顺序稳定。 */
|
|
141
|
+
records() { return allRecords; },
|
|
142
|
+
/** 可选角色 id。 */
|
|
143
|
+
optionalIds() { return optionalIds; },
|
|
144
|
+
/** 可选角色记录。 */
|
|
145
|
+
optionalRecords() { return optionalRecords; },
|
|
146
|
+
/** 按配置启用的角色 id。 */
|
|
147
|
+
enabledIds(options) { return Object.freeze(enabledEntries(options).map((entry) => entry.id)); },
|
|
148
|
+
/** 按配置启用的角色记录。 */
|
|
149
|
+
enabledRecords(options) { return enabledEntries(options); },
|
|
150
|
+
/** 按配置启用的只读角色记录。 */
|
|
151
|
+
readOnlyRecords(options) { return Object.freeze(enabledEntries(options).filter((entry) => entry.readOnly === true).slice()); },
|
|
152
|
+
/** 按配置启用的只读角色 id。 */
|
|
153
|
+
readOnlyIds(options) { return Object.freeze(registry.readOnlyRecords(options).map((entry) => entry.id)); },
|
|
154
|
+
/** 全部声明角色中的只读角色 id(与启用配置无关,供权限判定使用)。 */
|
|
155
|
+
declaredReadOnlyIds() { return readOnlyAllIds; },
|
|
156
|
+
/** 按配置启用的角色英文状态标签映射(冻结副本)。 */
|
|
157
|
+
statusLabels(options) {
|
|
158
|
+
const map = {};
|
|
159
|
+
for (const entry of enabledEntries(options)) map[entry.id] = entry.statusLabel;
|
|
160
|
+
return Object.freeze(map);
|
|
161
|
+
},
|
|
162
|
+
/** 按配置启用的角色描述映射(冻结副本)。 */
|
|
163
|
+
descriptions(options) {
|
|
164
|
+
const map = {};
|
|
165
|
+
for (const entry of enabledEntries(options)) map[entry.id] = entry.description;
|
|
166
|
+
return Object.freeze(map);
|
|
167
|
+
},
|
|
168
|
+
/** 按配置启用的角色工具名(与角色 id 一致,顺序稳定)。 */
|
|
169
|
+
toolNames(options) { return Object.freeze(enabledEntries(options).map((entry) => entry.toolName)); },
|
|
170
|
+
};
|
|
171
|
+
return Object.freeze(registry);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* 内置角色注册表。
|
|
176
|
+
* 顺序与历史 ROLES/OPTIONAL_ROLES 完全一致:explorer、librarian、oracle、
|
|
177
|
+
* designer、fixer 为默认角色,observer 为可选角色且默认禁用。
|
|
178
|
+
* 只读角色为 explorer、librarian、oracle、observer(与历史 readOnly 判定一致)。
|
|
179
|
+
*/
|
|
180
|
+
export const ROLE_REGISTRY = createRoleRegistry([
|
|
181
|
+
{
|
|
182
|
+
id: "explorer",
|
|
183
|
+
statusLabel: "Explorer",
|
|
184
|
+
label: "侦察员 (Explorer)",
|
|
185
|
+
description: "只读代码库侦察:定位文件、符号与模式,输出压缩的事实报告。",
|
|
186
|
+
optional: false,
|
|
187
|
+
readOnly: true,
|
|
188
|
+
defaultSelection: null,
|
|
189
|
+
toolName: "explorer",
|
|
190
|
+
},
|
|
191
|
+
{
|
|
192
|
+
id: "librarian",
|
|
193
|
+
statusLabel: "Librarian",
|
|
194
|
+
label: "资料员 (Librarian)",
|
|
195
|
+
description: "只读外部文档调研:查证库 API 与上游真实用法,输出带来源的结论。",
|
|
196
|
+
optional: false,
|
|
197
|
+
readOnly: true,
|
|
198
|
+
defaultSelection: null,
|
|
199
|
+
toolName: "librarian",
|
|
200
|
+
},
|
|
201
|
+
{
|
|
202
|
+
id: "oracle",
|
|
203
|
+
statusLabel: "Oracle",
|
|
204
|
+
label: "先知 (Oracle)",
|
|
205
|
+
description: "只读架构与调试顾问:分析架构取舍、疑难缺陷与代码审查。",
|
|
206
|
+
optional: false,
|
|
207
|
+
readOnly: true,
|
|
208
|
+
defaultSelection: null,
|
|
209
|
+
toolName: "oracle",
|
|
210
|
+
},
|
|
211
|
+
{
|
|
212
|
+
id: "designer",
|
|
213
|
+
statusLabel: "Designer",
|
|
214
|
+
label: "设计师 (Designer)",
|
|
215
|
+
description: "UI/UX 与视觉实现专家:产出可访问、打磨过的界面实现。",
|
|
216
|
+
optional: false,
|
|
217
|
+
readOnly: false,
|
|
218
|
+
defaultSelection: null,
|
|
219
|
+
toolName: "designer",
|
|
220
|
+
},
|
|
221
|
+
{
|
|
222
|
+
id: "fixer",
|
|
223
|
+
statusLabel: "Fixer",
|
|
224
|
+
label: "修理工 (Fixer)",
|
|
225
|
+
description: "边界清晰的多文件实现与测试补齐:只实现,不重新调研或设计。",
|
|
226
|
+
optional: false,
|
|
227
|
+
readOnly: false,
|
|
228
|
+
defaultSelection: null,
|
|
229
|
+
toolName: "fixer",
|
|
230
|
+
},
|
|
231
|
+
{
|
|
232
|
+
id: "observer",
|
|
233
|
+
statusLabel: "Observer",
|
|
234
|
+
label: "观察员 (Observer)",
|
|
235
|
+
description: "只读视觉分析:分析图片、截图、图表、图示与 PDF,输出结构化视觉报告。",
|
|
236
|
+
optional: true,
|
|
237
|
+
readOnly: true,
|
|
238
|
+
defaultSelection: null,
|
|
239
|
+
toolName: "observer",
|
|
240
|
+
},
|
|
241
|
+
]);
|
|
242
|
+
|
|
243
|
+
/** 全部声明角色记录(含可选角色)。 */
|
|
244
|
+
export function listRoles() {
|
|
245
|
+
return ROLE_REGISTRY.records();
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** 按配置启用的角色记录;默认只含默认角色。 */
|
|
249
|
+
export function listEnabledRoles(options) {
|
|
250
|
+
return ROLE_REGISTRY.enabledRecords(options);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** 可选角色记录。 */
|
|
254
|
+
export function listOptionalRoles() {
|
|
255
|
+
return ROLE_REGISTRY.optionalRecords();
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** 按配置启用的只读角色记录。 */
|
|
259
|
+
export function listReadOnlyRoles(options) {
|
|
260
|
+
return ROLE_REGISTRY.readOnlyRecords(options);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** 按配置启用的角色英文状态标签映射(页面 "Xxx running..." 文案)。 */
|
|
264
|
+
export function roleStatusLabels(options) {
|
|
265
|
+
return ROLE_REGISTRY.statusLabels(options);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** 按配置启用的角色职责映射。 */
|
|
269
|
+
export function roleDescriptions(options) {
|
|
270
|
+
return ROLE_REGISTRY.descriptions(options);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** 按配置启用的角色工具名。 */
|
|
274
|
+
export function roleToolNames(options) {
|
|
275
|
+
return ROLE_REGISTRY.toolNames(options);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** 查询角色记录;未知角色返回 undefined。 */
|
|
279
|
+
export function getRole(id) {
|
|
280
|
+
return ROLE_REGISTRY.get(id);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** 读取角色记录;未知角色抛 RoleRegistryError("UNKNOWN_ROLE")。 */
|
|
284
|
+
export function requireRole(id) {
|
|
285
|
+
return ROLE_REGISTRY.require(id);
|
|
286
|
+
}
|
package/rpc-host.js
ADDED
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RPC 传输层契约。
|
|
3
|
+
*
|
|
4
|
+
* legacy HTTP 与 DSH Connection 必须共用同一个不感知传输细节的业务 dispatch;
|
|
5
|
+
* 本模块只负责 Connection 一侧的 envelope、signal 与 transport details:
|
|
6
|
+
*
|
|
7
|
+
* - endpoint 校验(未知 endpoint 返回结构化 not-found);
|
|
8
|
+
* - 把业务 dispatch 的 (status, body) 结果转换为 Connection 的
|
|
9
|
+
* { ok: true, value } / { ok: false, error: { code, message, details } } 形状;
|
|
10
|
+
* - 图片路由标记按 payload.action 推导(Connection 已由宿主完成浏览器认证,
|
|
11
|
+
* 不再要求 HTTP 专用头);
|
|
12
|
+
* - 宿主 rpc.handle 不可用时(例如 DSH 0.1.5-rc.1 的 HostConnectionService
|
|
13
|
+
* register() 会经 Connection 自身 ctx 解析 owner.webServer),插件自持
|
|
14
|
+
* channel 路由仍要按公开 wire 契约收发 client-request / server-response,
|
|
15
|
+
* 这两条 envelope 的纯函数也留在本模块,避免 HTTP 适配层复制契约。
|
|
16
|
+
* - 自持 channel 路由在请求体超限时只读到有界前缀,无法再 JSON.parse 整份
|
|
17
|
+
* envelope;rpcId 恢复(rpcIdFromEnvelopeText)保持在同一模块,超限响应仍能
|
|
18
|
+
* 精确回显关联 id,客户端不会把结构化错误当成无法关联的传输故障。
|
|
19
|
+
*
|
|
20
|
+
* 业务动作、认证、请求大小、ownership 与错误 code 由 index.js 的 dispatch 提供;
|
|
21
|
+
* 本模块不复制任何业务逻辑,也不伪造 HTTP request/response。
|
|
22
|
+
*
|
|
23
|
+
* @module dsh-expert-group/rpc-host
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* 创建 Connection RPC handler。
|
|
28
|
+
*
|
|
29
|
+
* @param {object} options
|
|
30
|
+
* @param {string} options.endpoint - 插件独占的 Connection endpoint 名。
|
|
31
|
+
* @param {(body:object, signal:AbortSignal|undefined, respond:(status:number, body:object)=>void, options:{imageRequest:boolean})=>Promise<void>} options.dispatch
|
|
32
|
+
* 传输无关的业务 dispatch;通过 respond 回调返回结果。
|
|
33
|
+
* @returns {(endpoint:string, payload:object, signal:AbortSignal|undefined)=>Promise<object>}
|
|
34
|
+
* DSH Connection 的 handler 契约。
|
|
35
|
+
*/
|
|
36
|
+
export function createConnectionRpcHandler({ endpoint, dispatch } = {}) {
|
|
37
|
+
if (typeof endpoint !== "string" || endpoint.length === 0) {
|
|
38
|
+
throw new TypeError("createConnectionRpcHandler 需要非空 endpoint");
|
|
39
|
+
}
|
|
40
|
+
if (typeof dispatch !== "function") {
|
|
41
|
+
throw new TypeError("createConnectionRpcHandler 需要 dispatch 函数");
|
|
42
|
+
}
|
|
43
|
+
return async function handleConnectionRpc(requestEndpoint, payload, signal) {
|
|
44
|
+
if (requestEndpoint !== endpoint) {
|
|
45
|
+
return { ok: false, error: { code: "not-found", message: "未知 RPC endpoint", details: {} } };
|
|
46
|
+
}
|
|
47
|
+
// 业务 dispatch 通过收集器返回 (status, body);这里不构造 HTTP 替身。
|
|
48
|
+
// 收集器 first-wins:Connection 没有"半写"能力,dispatch 收尾后再次 respond
|
|
49
|
+
// (迟到回调或重复 send)必须被忽略,而不是覆盖已经决定的结果。
|
|
50
|
+
let responded = false;
|
|
51
|
+
let status = 500;
|
|
52
|
+
let body;
|
|
53
|
+
const respond = (nextStatus, nextBody) => {
|
|
54
|
+
if (responded) return false;
|
|
55
|
+
responded = true;
|
|
56
|
+
status = nextStatus;
|
|
57
|
+
body = nextBody;
|
|
58
|
+
return true;
|
|
59
|
+
};
|
|
60
|
+
let failure;
|
|
61
|
+
try {
|
|
62
|
+
// 图片五个动作(上传判定、提交前复核、Observer 两阶段提交、失败清理、成功释放)都需要
|
|
63
|
+
// 专用请求标记:Connection 由 action 推导,legacy HTTP 由 x-dsh-expert-group-image 头提供。
|
|
64
|
+
// 五者共用同一 fail-closed 边界(缺少标记一律 400),因此这里必须同时识别全部动作名。
|
|
65
|
+
const imageActions = [
|
|
66
|
+
"image-route",
|
|
67
|
+
"image-route-validate",
|
|
68
|
+
"image-route-observer-commit",
|
|
69
|
+
"image-route-observer-cancel",
|
|
70
|
+
"image-route-observer-release",
|
|
71
|
+
];
|
|
72
|
+
const imageRequest = imageActions.includes(payload?.action);
|
|
73
|
+
await dispatch(payload, signal, respond, { imageRequest });
|
|
74
|
+
} catch (error) {
|
|
75
|
+
// dispatch 自身已经捕获业务错误;这里只兜底意外抛出的缺陷,避免悬挂 promise。
|
|
76
|
+
failure = error;
|
|
77
|
+
}
|
|
78
|
+
// 已经响应过的请求按已发送结果收敛:传输层真实写入无法撤回,此时把后到的异常
|
|
79
|
+
// 伪装成 internal 只会让客户端拿到与 HTTP 不同的第二个结果。未响应才兜底。
|
|
80
|
+
if (responded !== true && failure !== undefined) {
|
|
81
|
+
return {
|
|
82
|
+
ok: false,
|
|
83
|
+
error: {
|
|
84
|
+
code: "internal",
|
|
85
|
+
message: "内部错误,请查看服务端日志",
|
|
86
|
+
details: {},
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
if (status < 200 || status >= 300 || body?.ok !== true) {
|
|
91
|
+
const error = body?.error;
|
|
92
|
+
return {
|
|
93
|
+
ok: false,
|
|
94
|
+
error: {
|
|
95
|
+
code: typeof error?.code === "string" ? error.code : "internal",
|
|
96
|
+
message: typeof error?.message === "string" ? error.message : "RPC 请求失败",
|
|
97
|
+
details: error?.details !== null && typeof error?.details === "object" ? error.details : {},
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
return { ok: true, value: body };
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** 无法从请求里读到合法 rpcId 时使用的占位 id;与宿主 rpc-host 同名常量保持同形。 */
|
|
106
|
+
const INVALID_RPC_ID = "invalid-request";
|
|
107
|
+
|
|
108
|
+
/** endpoint 各段允许的字符;与宿主 ENDPOINT_SEGMENT_PATTERN 保持同一规则。 */
|
|
109
|
+
const ENDPOINT_SEGMENT_PATTERN = /^[A-Za-z0-9_$.-]+$/;
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* 从 channel 前缀路径推出 endpoint。
|
|
113
|
+
*
|
|
114
|
+
* 插件自持 channel 路由必须和宿主 rpcFetchHandler 用同一套段规则判定 endpoint:
|
|
115
|
+
* 空段、`.`、`..` 与非法字符一律不认,避免路径穿越或把 `/rpc/x` 当成别的 endpoint。
|
|
116
|
+
*
|
|
117
|
+
* @param {string} channel - channel 前缀,例如 "/dsh-expert-group"。
|
|
118
|
+
* @param {string} pathname - 请求路径。
|
|
119
|
+
* @returns {string|undefined} 去掉前缀后的 endpoint;不匹配时返回 undefined。
|
|
120
|
+
*/
|
|
121
|
+
export function channelEndpointFromPath(channel, pathname) {
|
|
122
|
+
if (typeof channel !== "string" || channel.length === 0) return void 0;
|
|
123
|
+
if (typeof pathname !== "string" || !pathname.startsWith(channel + "/")) return void 0;
|
|
124
|
+
const endpoint = pathname.slice(channel.length + 1);
|
|
125
|
+
const segments = endpoint.split("/");
|
|
126
|
+
if (segments.some((segment) => segment.length === 0 || segment === "." || segment === ".."
|
|
127
|
+
|| !ENDPOINT_SEGMENT_PATTERN.test(segment))) {
|
|
128
|
+
return void 0;
|
|
129
|
+
}
|
|
130
|
+
return endpoint;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* 校验一条已解码的 client-request envelope。
|
|
135
|
+
*
|
|
136
|
+
* 只接受 DSH Connection 公开 wire 形状 { type, rpcId, method, payload };
|
|
137
|
+
* method 必须等于按请求路径推出的 endpoint,否则同一 channel 会被用来旁路
|
|
138
|
+
* 其它 endpoint。失败时沿用宿主的 gateway/bad-request code 与 rpcId 回显语义。
|
|
139
|
+
*
|
|
140
|
+
* @param {unknown} body - 已 JSON 解码的请求体。
|
|
141
|
+
* @param {string} endpoint - 由请求路径推出的 endpoint。
|
|
142
|
+
* @returns {object} ok:true 时带 rpcId 与 payload;否则带 code/message 的结构化失败。
|
|
143
|
+
*/
|
|
144
|
+
export function readClientRequestEnvelope(body, endpoint) {
|
|
145
|
+
const record = body !== null && typeof body === "object" && !Array.isArray(body) ? body : undefined;
|
|
146
|
+
// rpcId 缺失时仍要回一个稳定 id,客户端才能把响应与请求对上,而不是在解析阶段抛错。
|
|
147
|
+
const rpcId = typeof record?.rpcId === "string" && record.rpcId.length > 0 ? record.rpcId : INVALID_RPC_ID;
|
|
148
|
+
if (record === undefined || record.type !== "client-request" || typeof record.method !== "string") {
|
|
149
|
+
return { ok: false, rpcId, code: "gateway/bad-request", message: "invalid client-request message" };
|
|
150
|
+
}
|
|
151
|
+
if (record.method !== endpoint) {
|
|
152
|
+
return {
|
|
153
|
+
ok: false,
|
|
154
|
+
rpcId,
|
|
155
|
+
code: "gateway/bad-request",
|
|
156
|
+
message: "method " + JSON.stringify(record.method) + " does not match endpoint " + JSON.stringify(endpoint),
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
return { ok: true, rpcId, payload: record.payload };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** 顶层 rpcId 键名;只认字面量键,与 readClientRequestEnvelope 读 record.rpcId 的判定一致。 */
|
|
163
|
+
const RPC_ID_KEY_TEXT = "rpcId";
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* 顶层字段扫描窗口(字符数)。
|
|
167
|
+
*
|
|
168
|
+
* 自持 channel 路由超限时只保留有界前缀:host/rpc-transport.js 的
|
|
169
|
+
* BOUNDED_PREFIX_LIMIT = RPC_JSON_BODY_LIMIT(16384) + 8192 = 24576 字符。窗口取 32KiB,
|
|
170
|
+
* 保证**真实前缀永远不会被窗口截断**(窗口外的 key 根本不存在于前缀里);它的作用
|
|
171
|
+
* 只是兜住 index.js 在前缀缺失时 JSON.stringify 整个请求体的回退路径(图片路由可达
|
|
172
|
+
* 32MiB),使扫描成本恒有上界。上游若把 BOUNDED_PREFIX_LIMIT 调大过本窗口,需同步调整。
|
|
173
|
+
*/
|
|
174
|
+
const RPC_ID_SCAN_WINDOW = 32768;
|
|
175
|
+
|
|
176
|
+
/** JSON 允许的四种空白;键/值分隔判定只跳过它们。 */
|
|
177
|
+
function isJsonSpace(char) {
|
|
178
|
+
return char === " " || char === "\t" || char === "\n" || char === "\r";
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* 从一个 JSON 字符串的起始引号跳到结束引号之后。
|
|
183
|
+
*
|
|
184
|
+
* 通用扫描规则:反斜杠后的一个字符一律跳过(`\\`、`\"`、`\n`、`\uXXXX` 都成立),
|
|
185
|
+
* 因此 payload 里的普通转义不会中断扫描,也不会被误判成引号边界。
|
|
186
|
+
*
|
|
187
|
+
* @param {string} text - 已读取的文本。
|
|
188
|
+
* @param {number} start - 起始引号下标。
|
|
189
|
+
* @param {number} limit - 允许扫描到的不含上界。
|
|
190
|
+
* @returns {number} 结束引号的下一位;窗口内读不到结束引号(前缀被截断)时返回 -1。
|
|
191
|
+
*/
|
|
192
|
+
function skipJsonString(text, start, limit) {
|
|
193
|
+
let index = start + 1;
|
|
194
|
+
while (index < limit) {
|
|
195
|
+
const char = text[index];
|
|
196
|
+
if (char === "\\") { index += 2; continue; }
|
|
197
|
+
if (char === '"') return index + 1;
|
|
198
|
+
index += 1;
|
|
199
|
+
}
|
|
200
|
+
return -1;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* 解码一个 rpcId 字符串字面量。
|
|
205
|
+
*
|
|
206
|
+
* rpcId 只可能是普通标识符,因此只认 \" 与 \\ 两种转义;其它 JSON 转义(含 \uXXXX)
|
|
207
|
+
* 按不可信处理并 fail-closed,避免回显一个与 readClientRequestEnvelope 解析结果
|
|
208
|
+
* 不一致的 id。前缀在字符串中间结束时同样返回占位 id。
|
|
209
|
+
*
|
|
210
|
+
* @param {string} text - 已读取的文本。
|
|
211
|
+
* @param {number} start - 值的起始引号下标。
|
|
212
|
+
* @returns {string} 可回显的 rpcId,或 INVALID_RPC_ID。
|
|
213
|
+
*/
|
|
214
|
+
function decodeRpcIdValue(text, start) {
|
|
215
|
+
let index = start + 1;
|
|
216
|
+
let value = "";
|
|
217
|
+
while (index < text.length) {
|
|
218
|
+
const char = text[index];
|
|
219
|
+
if (char === "\\") {
|
|
220
|
+
const next = text[index + 1];
|
|
221
|
+
if (next !== '"' && next !== "\\") return INVALID_RPC_ID;
|
|
222
|
+
value += next;
|
|
223
|
+
index += 2;
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
if (char === '"') return value.length > 0 ? value : INVALID_RPC_ID;
|
|
227
|
+
value += char;
|
|
228
|
+
index += 1;
|
|
229
|
+
}
|
|
230
|
+
// 前缀在 rpcId 字符串中间结束:没有完整的值可回显。
|
|
231
|
+
return INVALID_RPC_ID;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* 从(可能被截断的)client-request 文本里恢复 rpcId。
|
|
236
|
+
*
|
|
237
|
+
* 请求体超限时传输层只保留有界前缀,无法 JSON.parse 整份 envelope;但客户端要求
|
|
238
|
+
* server-response 精确回显 rpcId,否则会把结构化错误退化成无法关联的传输故障。
|
|
239
|
+
*
|
|
240
|
+
* 这里不再取文本里第一个 `"rpcId":"…"`:payload 先于顶层字段序列化且 payload 内嵌
|
|
241
|
+
* 同名 rpcId 时,那会把关联 id 回显错。改为在**头部固定窗口**内做一次结构扫描,只在
|
|
242
|
+
* **顶层对象的第一层**(depth === 1)匹配键名,嵌套容器一律忽略;扫描在任何时刻被
|
|
243
|
+
* 截断(窗口/前缀在字符串中间结束、顶层键名不是字面量、值不是字符串)都 fail-closed
|
|
244
|
+
* 返回占位 id——宁可让客户端拿到 invalid-request,也不能回显一个错误的关联 id。
|
|
245
|
+
*
|
|
246
|
+
* @param {unknown} text - 已读取的请求体前缀(可以为空或被截断)。
|
|
247
|
+
* @returns {string} 可回显的 rpcId;无法确定顶层字段时返回 invalid-request 占位 id。
|
|
248
|
+
*/
|
|
249
|
+
export function rpcIdFromEnvelopeText(text) {
|
|
250
|
+
if (typeof text !== "string" || text.length === 0) return INVALID_RPC_ID;
|
|
251
|
+
const limit = Math.min(text.length, RPC_ID_SCAN_WINDOW);
|
|
252
|
+
// depth:顶层 envelope 对象内部为 1;payload 等嵌套对象/数组继续加深,因此
|
|
253
|
+
// payload 内嵌的 rpcId 因 depth > 1 被忽略,只有信封顶层的 rpcId 会命中。
|
|
254
|
+
let depth = 0;
|
|
255
|
+
let index = 0;
|
|
256
|
+
while (index < limit) {
|
|
257
|
+
const char = text[index];
|
|
258
|
+
if (char === "{" || char === "[") { depth += 1; index += 1; continue; }
|
|
259
|
+
if (char === "}" || char === "]") { depth -= 1; index += 1; continue; }
|
|
260
|
+
if (char !== '"') { index += 1; continue; }
|
|
261
|
+
const end = skipJsonString(text, index, limit);
|
|
262
|
+
// 窗口内读不到结束引号:无法判定这个 token 的边界,后续结构都不可信。
|
|
263
|
+
if (end === -1) return INVALID_RPC_ID;
|
|
264
|
+
// 键位置判定:字符串结束后第一个非空白字符是冒号,才是对象的键;否则是字符串值。
|
|
265
|
+
let cursor = end;
|
|
266
|
+
while (cursor < limit && isJsonSpace(text[cursor])) cursor += 1;
|
|
267
|
+
const isKey = cursor < limit && text[cursor] === ":";
|
|
268
|
+
if (isKey && depth === 1 && text.slice(index + 1, end - 1) === RPC_ID_KEY_TEXT) {
|
|
269
|
+
// 值必须是字符串字面量:readClientRequestEnvelope 也只把字符串 rpcId 当关联 id,
|
|
270
|
+
// 两者判定必须同源,否则截断回显与解析后的语义会分叉。键已确定在窗口内,
|
|
271
|
+
// 其值紧随其后,因此这里按文本长度推进(不再受扫描窗口约束)。
|
|
272
|
+
let valueStart = cursor + 1;
|
|
273
|
+
while (valueStart < text.length && isJsonSpace(text[valueStart])) valueStart += 1;
|
|
274
|
+
if (valueStart >= text.length || text[valueStart] !== '"') return INVALID_RPC_ID;
|
|
275
|
+
return decodeRpcIdValue(text, valueStart);
|
|
276
|
+
}
|
|
277
|
+
index = end;
|
|
278
|
+
}
|
|
279
|
+
return INVALID_RPC_ID;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* 把 Connection handler 的返回值组装成 server-response envelope。
|
|
284
|
+
*
|
|
285
|
+
* 客户端(DSH createWebConnectionRpc)要求 error.details 一定是对象,因此这里
|
|
286
|
+
* 与 createConnectionRpcHandler 一样把缺失/非法 details 归一成空对象,不递归转换 value。
|
|
287
|
+
*
|
|
288
|
+
* @param {string} rpcId - 请求携带的关联 id,原样回显。
|
|
289
|
+
* @param {unknown} result - Connection handler 的 { ok:true, value } / { ok:false, error } 结果。
|
|
290
|
+
* @returns {object} 宿主公开的 server-response envelope。
|
|
291
|
+
*/
|
|
292
|
+
export function writeServerResponseEnvelope(rpcId, result) {
|
|
293
|
+
const record = result !== null && typeof result === "object" ? result : undefined;
|
|
294
|
+
if (record?.ok === true) {
|
|
295
|
+
return { type: "server-response", rpcId, result: { ok: true, value: record.value } };
|
|
296
|
+
}
|
|
297
|
+
const error = record?.error;
|
|
298
|
+
return {
|
|
299
|
+
type: "server-response",
|
|
300
|
+
rpcId,
|
|
301
|
+
result: {
|
|
302
|
+
ok: false,
|
|
303
|
+
error: {
|
|
304
|
+
code: typeof error?.code === "string" ? error.code : "internal",
|
|
305
|
+
message: typeof error?.message === "string" ? error.message : "RPC 请求失败",
|
|
306
|
+
details: error?.details !== null && typeof error?.details === "object" ? error.details : {},
|
|
307
|
+
},
|
|
308
|
+
},
|
|
309
|
+
};
|
|
310
|
+
}
|