@twinsearth/dsh-master 3.3.3 → 3.3.5
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 +17 -0
- package/client.js +126 -1
- package/cordis.patch.yml +51 -0
- package/dist-host/agents/pjm-agent.js +62 -0
- package/dist-host/agents/pm-agent.js +57 -0
- package/dist-host/agents/prompts.js +71 -0
- package/dist-host/agents/toolkit.js +64 -0
- package/dist-host/channel/abort-registry.js +93 -0
- package/dist-host/channel/protocol.js +114 -0
- package/dist-host/channel/server.js +349 -0
- package/dist-host/channel/stream-state.js +343 -0
- package/dist-host/channel/ws.js +199 -0
- package/dist-host/dual/documents.js +143 -0
- package/dist-host/dual/orchestrator.js +214 -0
- package/dist-host/dual/schema.js +367 -0
- package/dist-host/engine/atomic-switch.js +159 -0
- package/dist-host/engine/health-probe.js +86 -0
- package/dist-host/engine/index.js +18 -0
- package/dist-host/engine/llm-stream.js +215 -0
- package/dist-host/engine/plugin-updater.js +392 -0
- package/dist-host/engine/safe-update.js +150 -0
- package/dist-host/index.js +154 -3
- package/dist-host/kernel.js +27 -1
- package/dist-host/plugins/cost-optimizer.js +3 -3
- package/dist-host/plugins/dual-manager.js +374 -77
- package/docs/AGENTS-PM-PJM.md +207 -0
- package/docs/ARCHITECTURE.md +2 -0
- package/docs/CHANGELOG.md +74 -0
- package/docs/CHANNEL-WEBSOCKET.md +169 -0
- package/docs/DESIGN-command-center.md +147 -0
- package/docs/ENGINE-PRIMITIVES.md +215 -0
- package/docs/GAP-ANALYSIS-fang-session.md +75 -0
- package/docs/REVIEW-PM-PJM-3.3.4.md +179 -0
- package/docs/examples/command-center/README.md +167 -0
- package/package.json +5 -1
- package/src/agents/pjm-agent.ts +62 -0
- package/src/agents/pm-agent.ts +58 -0
- package/src/agents/prompts.ts +74 -0
- package/src/agents/toolkit.ts +64 -0
- package/src/channel/abort-registry.ts +85 -0
- package/src/channel/protocol.ts +110 -0
- package/src/channel/server.ts +289 -0
- package/src/channel/stream-state.ts +349 -0
- package/src/channel/ws.ts +182 -0
- package/src/dual/documents.ts +142 -0
- package/src/dual/orchestrator.ts +223 -0
- package/src/dual/schema.ts +355 -0
- package/src/engine/atomic-switch.ts +146 -0
- package/src/engine/health-probe.ts +84 -0
- package/src/engine/index.ts +20 -0
- package/src/engine/llm-stream.ts +189 -0
- package/src/engine/plugin-updater.ts +359 -0
- package/src/engine/safe-update.ts +148 -0
- package/src/index.ts +143 -4
- package/src/kernel.ts +28 -1
- package/src/plugins/cost-optimizer.ts +3 -3
- package/src/plugins/dual-manager.ts +337 -76
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
// Copyright (c) 2026 dsh-Master contributors — see LICENSE-CORE.
|
|
3
|
+
// @ts-nocheck
|
|
4
|
+
/**
|
|
5
|
+
* 智囊团通道协议(3.3.5)。
|
|
6
|
+
*
|
|
7
|
+
* 客户端 → 服务端:
|
|
8
|
+
* { type: 'ping' }
|
|
9
|
+
* { type: 'resync' } → 重新推一次快照
|
|
10
|
+
* { type: 'chat', content: string, agent?: 'pm'|'pjm' }
|
|
11
|
+
* { type: 'answer', callId: string, value: string } ← 目前会返回 session-prompt-unavailable
|
|
12
|
+
*
|
|
13
|
+
* 服务端 → 客户端:
|
|
14
|
+
* { type: 'hello', ... } 连接建立(含协议版本与可用性)
|
|
15
|
+
* { type: 'dual', snapshot } 交接/计划/反馈/复核快照
|
|
16
|
+
* { type: 'pong', at }
|
|
17
|
+
* { type: 'error', code, message, detail? }
|
|
18
|
+
*
|
|
19
|
+
* 为什么 chat/answer 现在只返回错误:见 docs/CHANNEL-WEBSOCKET.md。
|
|
20
|
+
* 插件侧没有「向指定 preset 会话提交用户消息」的 Host API;假装能聊就是假聊天。
|
|
21
|
+
*/
|
|
22
|
+
export const CHANNEL_PROTOCOL_VERSION = 1;
|
|
23
|
+
export const CHANNEL_MAX_MESSAGE_BYTES = 64 * 1024;
|
|
24
|
+
export const CLIENT_MESSAGE_TYPES = Object.freeze(['ping', 'resync', 'chat', 'answer', 'interrupt']);
|
|
25
|
+
function text(value) { return String(value ?? '').trim(); }
|
|
26
|
+
/**
|
|
27
|
+
* 解析并校验一条客户端消息;非法输入给出具名错误而不是抛。
|
|
28
|
+
*/
|
|
29
|
+
export function parseClientMessage(raw) {
|
|
30
|
+
if (typeof raw === 'string' && raw.length > CHANNEL_MAX_MESSAGE_BYTES) {
|
|
31
|
+
return { ok: false, code: 'message-too-large', error: `message exceeds ${CHANNEL_MAX_MESSAGE_BYTES} bytes` };
|
|
32
|
+
}
|
|
33
|
+
let body = null;
|
|
34
|
+
if (typeof raw === 'string') {
|
|
35
|
+
try {
|
|
36
|
+
body = JSON.parse(raw);
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return { ok: false, code: 'invalid-json', error: 'message must be JSON' };
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
else if (raw && typeof raw === 'object') {
|
|
43
|
+
body = raw;
|
|
44
|
+
}
|
|
45
|
+
else {
|
|
46
|
+
return { ok: false, code: 'invalid-message', error: 'message must be a JSON object' };
|
|
47
|
+
}
|
|
48
|
+
const type = text(body.type);
|
|
49
|
+
if (!CLIENT_MESSAGE_TYPES.includes(type)) {
|
|
50
|
+
return { ok: false, code: 'unknown-type', error: `unknown message type: ${type || '(empty)'}`, types: CLIENT_MESSAGE_TYPES };
|
|
51
|
+
}
|
|
52
|
+
if (type === 'chat') {
|
|
53
|
+
const content = text(body.content);
|
|
54
|
+
if (!content)
|
|
55
|
+
return { ok: false, code: 'content-required', error: 'chat requires content' };
|
|
56
|
+
if (content.length > 8_000)
|
|
57
|
+
return { ok: false, code: 'content-too-long', error: 'chat content exceeds 8000 characters' };
|
|
58
|
+
const agent = text(body.agent) || 'pm';
|
|
59
|
+
if (!['pm', 'pjm'].includes(agent))
|
|
60
|
+
return { ok: false, code: 'agent-invalid', error: 'agent must be pm or pjm' };
|
|
61
|
+
return { ok: true, message: { type, content, agent } };
|
|
62
|
+
}
|
|
63
|
+
if (type === 'answer') {
|
|
64
|
+
const callId = text(body.callId);
|
|
65
|
+
const value = text(body.value);
|
|
66
|
+
if (!callId || !value)
|
|
67
|
+
return { ok: false, code: 'answer-incomplete', error: 'answer requires callId and value' };
|
|
68
|
+
return { ok: true, message: { type, callId, value } };
|
|
69
|
+
}
|
|
70
|
+
if (type === 'interrupt') {
|
|
71
|
+
// messageId 可选:省略表示“这一连接上所有正在生成的流都停”
|
|
72
|
+
const messageId = text(body.messageId);
|
|
73
|
+
return { ok: true, message: { type, messageId: messageId || null } };
|
|
74
|
+
}
|
|
75
|
+
return { ok: true, message: { type } };
|
|
76
|
+
}
|
|
77
|
+
/** 中断确认:`aborted` 表示是否真的掐断了一条在跑的流。 */
|
|
78
|
+
export function interruptedMessage({ messageId, savedContent = '', aborted = true } = {}) {
|
|
79
|
+
return {
|
|
80
|
+
type: 'interrupted',
|
|
81
|
+
messageId: text(messageId) || null,
|
|
82
|
+
savedContent: String(savedContent ?? ''),
|
|
83
|
+
aborted: aborted === true,
|
|
84
|
+
at: Date.now(),
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
export function serverHello({ channelId, tokenRequired = true } = {}) {
|
|
88
|
+
return {
|
|
89
|
+
type: 'hello',
|
|
90
|
+
protocol: CHANNEL_PROTOCOL_VERSION,
|
|
91
|
+
channelId: text(channelId) || 'dsh-master-command-center',
|
|
92
|
+
capabilities: {
|
|
93
|
+
dualSnapshot: true,
|
|
94
|
+
chat: false,
|
|
95
|
+
answer: false,
|
|
96
|
+
},
|
|
97
|
+
limitations: {
|
|
98
|
+
chat: 'plugin cannot submit a user message into a preset agent session',
|
|
99
|
+
answer: 'user questions are answered inside their owning session',
|
|
100
|
+
},
|
|
101
|
+
tokenRequired: tokenRequired === true,
|
|
102
|
+
serverTime: Date.now(),
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
export function serverError(code, message, detail) {
|
|
106
|
+
const out = { type: 'error', code: text(code) || 'unknown', message: String(message ?? '') };
|
|
107
|
+
if (detail !== undefined)
|
|
108
|
+
out.detail = detail;
|
|
109
|
+
return out;
|
|
110
|
+
}
|
|
111
|
+
/** 把 dual 快照包成通道消息(保持字段原样,不在这里加工业务语义)。 */
|
|
112
|
+
export function dualMessage(snapshot) {
|
|
113
|
+
return { type: 'dual', snapshot: snapshot ?? null };
|
|
114
|
+
}
|
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
// Copyright (c) 2026 dsh-Master contributors — see LICENSE-CORE.
|
|
3
|
+
// @ts-nocheck
|
|
4
|
+
/**
|
|
5
|
+
* 智囊团 WebSocket 通道(宿主侧)。
|
|
6
|
+
*
|
|
7
|
+
* 能力边界(重要):
|
|
8
|
+
* * 可以:握手、鉴权、心跳、推 dual 快照、收到 chat/answer 时返回具名错误;
|
|
9
|
+
* * 不可以:把用户消息提交给 PM/PJM 会话 —— 插件侧没有该 Host API;
|
|
10
|
+
* * 不假装:`chat` 返回 `session-prompt-unavailable`,并把缺失能力写进 hello 的 capabilities。
|
|
11
|
+
*
|
|
12
|
+
* 为什么不用 `ws` 依赖:房主坚持零运行时依赖;`registerUpgrade` 已由宿主提供,
|
|
13
|
+
* 帧编解码在 `ws.ts` 里自己实现(有 RFC 测试向量)。
|
|
14
|
+
*/
|
|
15
|
+
import { createFrameParser, encodeClose, encodePing, encodePong, encodeText, rejectionResponse, upgradeResponse, validateUpgradeRequest } from "./ws.js";
|
|
16
|
+
import { dualMessage, interruptedMessage, parseClientMessage, serverError, serverHello } from "./protocol.js";
|
|
17
|
+
import { createAbortRegistry } from "./abort-registry.js";
|
|
18
|
+
export const CHANNEL_PATH = '/dsh-master/ws/command-center';
|
|
19
|
+
const DEFAULTS = Object.freeze({ heartbeatMs: 30_000, maxConnections: 8 });
|
|
20
|
+
function text(value) { return String(value ?? '').trim(); }
|
|
21
|
+
/** 从升级请求的 URL 里取 token(`?token=`)或 Authorization 头。 */
|
|
22
|
+
export function tokenOf(req) {
|
|
23
|
+
const raw = String(req?.url ?? '');
|
|
24
|
+
const queryIndex = raw.indexOf('?');
|
|
25
|
+
const query = queryIndex >= 0 ? raw.slice(queryIndex + 1) : '';
|
|
26
|
+
for (const pair of query.split('&')) {
|
|
27
|
+
const [key, value = ''] = pair.split('=');
|
|
28
|
+
if (decodeURIComponent(key) === 'token')
|
|
29
|
+
return decodeURIComponent(value);
|
|
30
|
+
}
|
|
31
|
+
const header = String(req?.headers?.authorization ?? '');
|
|
32
|
+
const match = /^Bearer\s+(.+)$/i.exec(header.trim());
|
|
33
|
+
return match ? match[1].trim() : '';
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* @param {{
|
|
37
|
+
* webServer: object,
|
|
38
|
+
* token: string,
|
|
39
|
+
* dualSnapshot: () => object,
|
|
40
|
+
* logger?: { info?: Function, warn?: Function, error?: Function },
|
|
41
|
+
* heartbeatMs?: number,
|
|
42
|
+
* maxConnections?: number,
|
|
43
|
+
* now?: () => number,
|
|
44
|
+
* }} deps
|
|
45
|
+
*/
|
|
46
|
+
export function attachCommandCenterChannel(deps = {}) {
|
|
47
|
+
const webServer = deps.webServer;
|
|
48
|
+
const token = text(deps.token);
|
|
49
|
+
const logger = deps.logger ?? {};
|
|
50
|
+
const now = typeof deps.now === 'function' ? deps.now : () => Date.now();
|
|
51
|
+
const heartbeatMs = Number(deps.heartbeatMs) > 0 ? Number(deps.heartbeatMs) : DEFAULTS.heartbeatMs;
|
|
52
|
+
const maxConnections = Number(deps.maxConnections) > 0 ? Number(deps.maxConnections) : DEFAULTS.maxConnections;
|
|
53
|
+
const log = (level, message) => { try {
|
|
54
|
+
logger[level]?.(message);
|
|
55
|
+
}
|
|
56
|
+
catch { /* ignore */ } };
|
|
57
|
+
if (!webServer || typeof webServer.registerUpgrade !== 'function') {
|
|
58
|
+
return { attached: false, reason: 'web-server-unavailable' };
|
|
59
|
+
}
|
|
60
|
+
if (!token) {
|
|
61
|
+
// fail-closed:没有 token 就不开通道,避免任何本地进程都能订阅看板数据
|
|
62
|
+
return { attached: false, reason: 'channel-token-missing' };
|
|
63
|
+
}
|
|
64
|
+
const connections = new Set();
|
|
65
|
+
let closed = false;
|
|
66
|
+
/** 最近一次成功握手的连接:生产者登记流时默认绑定到它(单面板本地场景)。 */
|
|
67
|
+
let lastConnection = null;
|
|
68
|
+
/** 全局取消登记表(messageId → AbortController);所有权按连接记录,断连时只中止自己的流。 */
|
|
69
|
+
const streams = createAbortRegistry();
|
|
70
|
+
const ownedStreams = new Map();
|
|
71
|
+
const snapshot = () => {
|
|
72
|
+
try {
|
|
73
|
+
return typeof deps.dualSnapshot === 'function' ? deps.dualSnapshot() : null;
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
const send = (connection, message) => {
|
|
80
|
+
if (connection.closed || !connection.socket.writable)
|
|
81
|
+
return;
|
|
82
|
+
try {
|
|
83
|
+
connection.socket.write(encodeText(JSON.stringify(message)));
|
|
84
|
+
}
|
|
85
|
+
catch (error) {
|
|
86
|
+
log('warn', `[channel] write failed: ${String(error?.message ?? error)}`);
|
|
87
|
+
closeConnection(connection, 1011, 'write-failed');
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
/** 中止某连接拥有的全部流(断连、卸载都走这里,避免僵尸流继续烧 token)。 */
|
|
91
|
+
const abortOwnedStreams = (connection, reason) => {
|
|
92
|
+
const owned = ownedStreams.get(connection);
|
|
93
|
+
if (!owned || owned.size === 0) {
|
|
94
|
+
ownedStreams.delete(connection);
|
|
95
|
+
return [];
|
|
96
|
+
}
|
|
97
|
+
const aborted = [];
|
|
98
|
+
for (const messageId of owned) {
|
|
99
|
+
if (streams.abort(messageId, reason))
|
|
100
|
+
aborted.push(messageId);
|
|
101
|
+
}
|
|
102
|
+
ownedStreams.delete(connection);
|
|
103
|
+
if (aborted.length > 0)
|
|
104
|
+
log('info', `[channel] aborted ${aborted.length} stream(s) (${reason})`);
|
|
105
|
+
return aborted;
|
|
106
|
+
};
|
|
107
|
+
const closeConnection = (connection, code = 1000, reason = '') => {
|
|
108
|
+
if (connection.closed)
|
|
109
|
+
return;
|
|
110
|
+
connection.closed = true;
|
|
111
|
+
connections.delete(connection);
|
|
112
|
+
abortOwnedStreams(connection, reason || 'connection-closed');
|
|
113
|
+
try {
|
|
114
|
+
connection.socket.write(encodeClose(code, reason));
|
|
115
|
+
}
|
|
116
|
+
catch { /* ignore */ }
|
|
117
|
+
try {
|
|
118
|
+
connection.socket.end();
|
|
119
|
+
}
|
|
120
|
+
catch { /* ignore */ }
|
|
121
|
+
try {
|
|
122
|
+
connection.socket.destroy();
|
|
123
|
+
}
|
|
124
|
+
catch { /* ignore */ }
|
|
125
|
+
};
|
|
126
|
+
const broadcast = (message) => { for (const connection of connections)
|
|
127
|
+
send(connection, message); };
|
|
128
|
+
const handleClientMessage = (connection, raw) => {
|
|
129
|
+
const parsed = parseClientMessage(raw);
|
|
130
|
+
if (!parsed.ok) {
|
|
131
|
+
send(connection, serverError(parsed.code, parsed.error, parsed.types ? { types: parsed.types } : undefined));
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
const message = parsed.message;
|
|
135
|
+
if (message.type === 'ping') {
|
|
136
|
+
send(connection, { type: 'pong', at: now() });
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
if (message.type === 'resync') {
|
|
140
|
+
send(connection, dualMessage(snapshot()));
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
if (message.type === 'interrupt') {
|
|
144
|
+
const target = message.messageId;
|
|
145
|
+
const owned = ownedStreams.get(connection) ?? new Set();
|
|
146
|
+
let aborted = [];
|
|
147
|
+
if (target) {
|
|
148
|
+
if (streams.abort(target, 'user-interrupt'))
|
|
149
|
+
aborted = [target];
|
|
150
|
+
}
|
|
151
|
+
else {
|
|
152
|
+
for (const messageId of owned) {
|
|
153
|
+
if (streams.abort(messageId, 'user-interrupt'))
|
|
154
|
+
aborted.push(messageId);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
if (aborted.length === 0) {
|
|
158
|
+
// 没有在跑的流:如实说,不假装已中止
|
|
159
|
+
send(connection, serverError('no-active-stream', 'no stream is running on this connection', { messageId: target }));
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
send(connection, interruptedMessage({ messageId: target, aborted: true, savedContent: '' }));
|
|
163
|
+
log('info', `[channel] interrupt: aborted ${aborted.length} stream(s)`);
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
if (message.type === 'chat') {
|
|
167
|
+
// 防御性兜底(“打断施法”):同一连接上已有在跑的流时,先中止旧的再处理新请求。
|
|
168
|
+
// 即便前端被绕过(多端/直调),也不会出现两路生成并发烧 token。
|
|
169
|
+
const owned = ownedStreams.get(connection);
|
|
170
|
+
if (owned && owned.size > 0) {
|
|
171
|
+
const aborted = [];
|
|
172
|
+
for (const messageId of owned) {
|
|
173
|
+
if (streams.abort(messageId, 'superseded-by-new-chat'))
|
|
174
|
+
aborted.push(messageId);
|
|
175
|
+
}
|
|
176
|
+
if (aborted.length > 0)
|
|
177
|
+
log('warn', `[channel] new chat superseded ${aborted.length} running stream(s)`);
|
|
178
|
+
}
|
|
179
|
+
// 真实缺口,不编造成功。
|
|
180
|
+
send(connection, serverError('session-prompt-unavailable', 'this host has no API to submit a user message into a preset agent session; use a PM/PJM session to chat', { agent: message.agent, protocol: 1 }));
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
if (message.type === 'answer') {
|
|
184
|
+
send(connection, serverError('session-answer-unavailable', 'user questions are answered inside their owning session, not through this channel', { callId: message.callId }));
|
|
185
|
+
}
|
|
186
|
+
};
|
|
187
|
+
/**
|
|
188
|
+
* 心跳扫描(单一定时器,不是每连接一个):
|
|
189
|
+
* * 超过 2×heartbeat 没收到任何数据(含浏览器自动回的 pong)→ 判定僵尸连接;
|
|
190
|
+
* * 僵尸连接先中止它拥有的所有流,再 destroy socket(不等对端回 close 帧)。
|
|
191
|
+
* 拔网线/休眠/强杀进程都不会发 close 帧,只有这里能把资源收回来。
|
|
192
|
+
*/
|
|
193
|
+
function heartbeatSweep() {
|
|
194
|
+
for (const connection of [...connections]) {
|
|
195
|
+
if (connection.closed)
|
|
196
|
+
continue;
|
|
197
|
+
if (now() - connection.lastSeenAt > heartbeatMs * 2) {
|
|
198
|
+
log('warn', '[channel] zombie connection detected, terminating');
|
|
199
|
+
closeConnection(connection, 1001, 'heartbeat-timeout');
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
try {
|
|
203
|
+
connection.socket.write(encodePing());
|
|
204
|
+
}
|
|
205
|
+
catch {
|
|
206
|
+
closeConnection(connection, 1011, 'ping-failed');
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
const heartbeatTimer = setInterval(heartbeatSweep, Math.max(1_000, Math.floor(heartbeatMs / 2)));
|
|
211
|
+
if (typeof heartbeatTimer.unref === 'function')
|
|
212
|
+
heartbeatTimer.unref();
|
|
213
|
+
const accept = (req, socket) => {
|
|
214
|
+
const validation = validateUpgradeRequest(req);
|
|
215
|
+
if (!validation.ok) {
|
|
216
|
+
socket.write(rejectionResponse(400, validation.code, 'invalid websocket handshake'));
|
|
217
|
+
socket.destroy();
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
// Origin 必须与宿主一致(浏览器 WS 一定带 Origin):挡掉跨站页面直连看板。
|
|
221
|
+
const origin = text(req?.headers?.origin);
|
|
222
|
+
const allowedOrigin = text(deps.allowedOrigin);
|
|
223
|
+
if (origin && allowedOrigin && origin !== allowedOrigin) {
|
|
224
|
+
log('warn', `[channel] rejected connection: bad origin ${origin}`);
|
|
225
|
+
socket.write(rejectionResponse(403, 'channel-origin-rejected', 'origin does not match the host'));
|
|
226
|
+
socket.destroy();
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
if (tokenOf(req) !== token) {
|
|
230
|
+
log('warn', '[channel] rejected connection: bad token');
|
|
231
|
+
socket.write(rejectionResponse(401, 'channel-unauthorized', 'missing or invalid channel token'));
|
|
232
|
+
socket.destroy();
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
if (connections.size >= maxConnections) {
|
|
236
|
+
socket.write(rejectionResponse(403, 'channel-full', `at most ${maxConnections} connections`));
|
|
237
|
+
socket.destroy();
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
socket.write(upgradeResponse(validation.accept));
|
|
241
|
+
const connection = { socket, closed: false, parser: createFrameParser(), lastSeenAt: now(), pingedAt: null };
|
|
242
|
+
connections.add(connection);
|
|
243
|
+
lastConnection = connection;
|
|
244
|
+
log('info', `[channel] connection accepted (${connections.size}/${maxConnections})`);
|
|
245
|
+
send(connection, serverHello({ tokenRequired: true }));
|
|
246
|
+
send(connection, dualMessage(snapshot()));
|
|
247
|
+
socket.on('data', (chunk) => {
|
|
248
|
+
if (connection.closed)
|
|
249
|
+
return;
|
|
250
|
+
connection.lastSeenAt = now();
|
|
251
|
+
let parsed = null;
|
|
252
|
+
try {
|
|
253
|
+
parsed = connection.parser.push(chunk);
|
|
254
|
+
}
|
|
255
|
+
catch (error) {
|
|
256
|
+
log('warn', `[channel] frame parse failed: ${String(error?.message ?? error)}`);
|
|
257
|
+
closeConnection(connection, 1002, 'protocol-error');
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
for (const error of parsed.errors ?? []) {
|
|
261
|
+
closeConnection(connection, 1002, error);
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
for (const control of parsed.controls ?? []) {
|
|
265
|
+
if (control.type === 'ping') {
|
|
266
|
+
try {
|
|
267
|
+
connection.socket.write(encodePong(control.payload));
|
|
268
|
+
}
|
|
269
|
+
catch { /* ignore */ }
|
|
270
|
+
}
|
|
271
|
+
if (control.type === 'close') {
|
|
272
|
+
closeConnection(connection, 1000, 'client-close');
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
for (const message of parsed.messages ?? [])
|
|
277
|
+
handleClientMessage(connection, message);
|
|
278
|
+
});
|
|
279
|
+
socket.on('error', (error) => {
|
|
280
|
+
log('warn', `[channel] socket error: ${String(error?.message ?? error)}`);
|
|
281
|
+
closeConnection(connection, 1011, 'socket-error');
|
|
282
|
+
});
|
|
283
|
+
socket.on('close', () => {
|
|
284
|
+
if (connection.heartbeat)
|
|
285
|
+
clearInterval(connection.heartbeat);
|
|
286
|
+
abortOwnedStreams(connection, 'connection-closed');
|
|
287
|
+
connection.closed = true;
|
|
288
|
+
connections.delete(connection);
|
|
289
|
+
});
|
|
290
|
+
};
|
|
291
|
+
let dispose = null;
|
|
292
|
+
try {
|
|
293
|
+
dispose = webServer.registerUpgrade({
|
|
294
|
+
path: CHANNEL_PATH,
|
|
295
|
+
handler: (req, socket) => { try {
|
|
296
|
+
accept(req, socket);
|
|
297
|
+
}
|
|
298
|
+
catch (error) {
|
|
299
|
+
log('error', `[channel] upgrade failed: ${String(error?.message ?? error)}`);
|
|
300
|
+
try {
|
|
301
|
+
socket.destroy();
|
|
302
|
+
}
|
|
303
|
+
catch { /* ignore */ }
|
|
304
|
+
} },
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
catch (error) {
|
|
308
|
+
return { attached: false, reason: 'register-upgrade-failed', error: String(error?.message ?? error) };
|
|
309
|
+
}
|
|
310
|
+
return {
|
|
311
|
+
attached: true,
|
|
312
|
+
path: CHANNEL_PATH,
|
|
313
|
+
connections: () => connections.size,
|
|
314
|
+
/** 主动推一次快照(供 dual 状态变化时调用)。 */
|
|
315
|
+
publish: () => broadcast(dualMessage(snapshot())),
|
|
316
|
+
/**
|
|
317
|
+
* 生产者登记一条可中断的流(会话桥接入后由它调用)。
|
|
318
|
+
* 返回值里的 `signal` 被中止时必须停止推送;`end()` 收尾清理。
|
|
319
|
+
*/
|
|
320
|
+
openStream: (messageId, { connection = lastConnection, onAbort = null } = {}) => {
|
|
321
|
+
const id = text(messageId);
|
|
322
|
+
if (!id)
|
|
323
|
+
throw new Error('openStream: messageId is required');
|
|
324
|
+
const entry = streams.begin(id, { onAbort });
|
|
325
|
+
if (connection) {
|
|
326
|
+
const owned = ownedStreams.get(connection) ?? new Set();
|
|
327
|
+
owned.add(id);
|
|
328
|
+
ownedStreams.set(connection, owned);
|
|
329
|
+
}
|
|
330
|
+
return entry;
|
|
331
|
+
},
|
|
332
|
+
activeStreams: () => streams.keys(),
|
|
333
|
+
/** 手动触发一次心跳扫描(测试与运维用)。 */
|
|
334
|
+
heartbeatTick: heartbeatSweep,
|
|
335
|
+
dispose: () => {
|
|
336
|
+
if (closed)
|
|
337
|
+
return;
|
|
338
|
+
closed = true;
|
|
339
|
+
clearInterval(heartbeatTimer);
|
|
340
|
+
streams.abortAll('server-shutdown');
|
|
341
|
+
for (const connection of [...connections])
|
|
342
|
+
closeConnection(connection, 1001, 'server-shutdown');
|
|
343
|
+
try {
|
|
344
|
+
dispose?.();
|
|
345
|
+
}
|
|
346
|
+
catch { /* ignore */ }
|
|
347
|
+
},
|
|
348
|
+
};
|
|
349
|
+
}
|