@morlay/dsh-desktop-host 0.0.1-alpha.2 → 0.0.1-alpha.4
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 +34 -12
- package/config/desktop.cordis.patch.yml +23 -0
- package/lib/index.js +414 -925
- package/lib/webserver.js +264 -0
- package/lib/wire.js +245 -0
- package/package.json +25 -11
package/lib/webserver.js
ADDED
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
import { EventEmitter } from "node:events";
|
|
2
|
+
import { Service } from "@deepseek-ai/cordis";
|
|
3
|
+
import z from "@deepseek-ai/schemastery";
|
|
4
|
+
import { Readable } from "node:stream";
|
|
5
|
+
import { renderIndexInjections } from "@deepseek-ai/dsh-host-webserver";
|
|
6
|
+
//#region src/webserver.ts
|
|
7
|
+
/**
|
|
8
|
+
* 桌面形态的 `webServer` 服务:与上游同名的路由载体,但不监听任何端口。
|
|
9
|
+
*
|
|
10
|
+
* 上游 `@deepseek-ai/dsh-host-webserver` 在激活时 `listen`。桌面宿主自己拥有页面,
|
|
11
|
+
* 没有网络入口,于是这里保留同一份服务面(register / registerFallback / registerUpgrade /
|
|
12
|
+
* tapIndex / renderIndex / collectIndexInjections / port / host),把请求由 `dispatch`
|
|
13
|
+
* 从字节管道直接喂进来。
|
|
14
|
+
*/
|
|
15
|
+
function deferred() {
|
|
16
|
+
let resolve;
|
|
17
|
+
return {
|
|
18
|
+
promise: new Promise((settle) => {
|
|
19
|
+
resolve = settle;
|
|
20
|
+
}),
|
|
21
|
+
resolve
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
/** 由 handler 写出的 node 风格响应:写出头部即对外可见,`end` 收尾响应体流。 */
|
|
25
|
+
var SyntheticResponse = class extends EventEmitter {
|
|
26
|
+
statusCode = 200;
|
|
27
|
+
statusMessage = "";
|
|
28
|
+
headersSent = false;
|
|
29
|
+
writableEnded = false;
|
|
30
|
+
destroyed = false;
|
|
31
|
+
/** 上游中间件按 `res.socket === undefined` 识别「没有真实连接」。 */
|
|
32
|
+
socket = void 0;
|
|
33
|
+
body;
|
|
34
|
+
started;
|
|
35
|
+
done;
|
|
36
|
+
startSettle;
|
|
37
|
+
doneSettle;
|
|
38
|
+
headers = new Headers();
|
|
39
|
+
controller;
|
|
40
|
+
startedValue;
|
|
41
|
+
constructor() {
|
|
42
|
+
super();
|
|
43
|
+
this.startSettle = deferred();
|
|
44
|
+
this.doneSettle = deferred();
|
|
45
|
+
this.started = this.startSettle.promise;
|
|
46
|
+
this.done = this.doneSettle.promise;
|
|
47
|
+
this.body = new ReadableStream({
|
|
48
|
+
start: (controller) => {
|
|
49
|
+
this.controller = controller;
|
|
50
|
+
},
|
|
51
|
+
cancel: () => {
|
|
52
|
+
this.destroy();
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
setHeader(name, value) {
|
|
57
|
+
this.headers.set(name, Array.isArray(value) ? value.join(", ") : String(value));
|
|
58
|
+
}
|
|
59
|
+
getHeader(name) {
|
|
60
|
+
return this.headers.get(name);
|
|
61
|
+
}
|
|
62
|
+
getHeaders() {
|
|
63
|
+
return new Headers(this.headers);
|
|
64
|
+
}
|
|
65
|
+
removeHeader(name) {
|
|
66
|
+
this.headers.delete(name);
|
|
67
|
+
}
|
|
68
|
+
getHeaderNames() {
|
|
69
|
+
return [...this.headers.keys()];
|
|
70
|
+
}
|
|
71
|
+
writeHead(status, headers) {
|
|
72
|
+
this.statusCode = status;
|
|
73
|
+
if (headers !== void 0) for (const [name, value] of Object.entries(headers)) this.setHeader(name, value);
|
|
74
|
+
this.settleStart();
|
|
75
|
+
return this;
|
|
76
|
+
}
|
|
77
|
+
write(chunk) {
|
|
78
|
+
if (this.writableEnded) throw new Error("dsh desktop: response already ended");
|
|
79
|
+
this.settleStart();
|
|
80
|
+
if (!this.destroyed) this.controller.enqueue(toBytes(chunk));
|
|
81
|
+
return true;
|
|
82
|
+
}
|
|
83
|
+
end(chunk) {
|
|
84
|
+
if (this.writableEnded) return this;
|
|
85
|
+
this.settleStart();
|
|
86
|
+
if (chunk !== void 0 && !this.destroyed) this.controller.enqueue(toBytes(chunk));
|
|
87
|
+
this.writableEnded = true;
|
|
88
|
+
if (!this.destroyed) this.controller.close();
|
|
89
|
+
this.doneSettle.resolve();
|
|
90
|
+
this.emit("finish");
|
|
91
|
+
this.emit("close");
|
|
92
|
+
return this;
|
|
93
|
+
}
|
|
94
|
+
destroy(error) {
|
|
95
|
+
if (this.destroyed) return this;
|
|
96
|
+
this.destroyed = true;
|
|
97
|
+
if (!this.writableEnded) {
|
|
98
|
+
this.settleStart(500);
|
|
99
|
+
this.controller.error(error ?? /* @__PURE__ */ new Error("dsh desktop: response destroyed"));
|
|
100
|
+
this.doneSettle.resolve();
|
|
101
|
+
}
|
|
102
|
+
this.emit("close");
|
|
103
|
+
return this;
|
|
104
|
+
}
|
|
105
|
+
/** handler 抛错:未写头时以 500 收场,已写头则中断响应体。 */
|
|
106
|
+
fail(error) {
|
|
107
|
+
if (this.destroyed) return;
|
|
108
|
+
if (!this.headersSent) {
|
|
109
|
+
this.statusCode = 500;
|
|
110
|
+
this.settleStart();
|
|
111
|
+
this.writableEnded = true;
|
|
112
|
+
this.controller.enqueue(new TextEncoder().encode(errorMessage(error)));
|
|
113
|
+
this.controller.close();
|
|
114
|
+
this.doneSettle.resolve();
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
this.destroy(error instanceof Error ? error : new Error(String(error)));
|
|
118
|
+
}
|
|
119
|
+
settleStart(forcedStatus) {
|
|
120
|
+
if (this.startedValue !== void 0) return;
|
|
121
|
+
this.headersSent = true;
|
|
122
|
+
if (forcedStatus !== void 0) this.statusCode = forcedStatus;
|
|
123
|
+
this.startedValue = {
|
|
124
|
+
status: this.statusCode,
|
|
125
|
+
headers: new Headers(this.headers)
|
|
126
|
+
};
|
|
127
|
+
this.startSettle.resolve(this.startedValue);
|
|
128
|
+
}
|
|
129
|
+
};
|
|
130
|
+
function toBytes(chunk) {
|
|
131
|
+
return typeof chunk === "string" ? new TextEncoder().encode(chunk) : chunk;
|
|
132
|
+
}
|
|
133
|
+
function errorMessage(error) {
|
|
134
|
+
return error instanceof Error ? error.message : String(error);
|
|
135
|
+
}
|
|
136
|
+
/** 把一条 Fetch 请求合成 node 风格的 req/res,供注册在这些表上的 handler 使用。 */
|
|
137
|
+
function createRequest(request, url) {
|
|
138
|
+
const stream = Readable.from(request.body === null ? [] : consume(request.body));
|
|
139
|
+
const headers = {};
|
|
140
|
+
for (const [name, value] of request.headers) headers[name.toLowerCase()] = value;
|
|
141
|
+
return Object.assign(stream, {
|
|
142
|
+
url: `${url.pathname}${url.search}`,
|
|
143
|
+
method: request.method,
|
|
144
|
+
headers,
|
|
145
|
+
httpVersion: "1.1",
|
|
146
|
+
httpVersionMajor: 1,
|
|
147
|
+
httpVersionMinor: 1,
|
|
148
|
+
complete: false,
|
|
149
|
+
aborted: false
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
async function* consume(body) {
|
|
153
|
+
const reader = body.getReader();
|
|
154
|
+
try {
|
|
155
|
+
for (;;) {
|
|
156
|
+
const next = await reader.read();
|
|
157
|
+
if (next.done) return;
|
|
158
|
+
yield next.value;
|
|
159
|
+
}
|
|
160
|
+
} finally {
|
|
161
|
+
reader.releaseLock();
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
var PortlessWebServer = class extends Service {
|
|
165
|
+
config;
|
|
166
|
+
static Config = z.object({
|
|
167
|
+
host: z.union([z.const("127.0.0.1"), z.const("0.0.0.0")]).default("127.0.0.1"),
|
|
168
|
+
port: z.natural().max(65535).default(0)
|
|
169
|
+
});
|
|
170
|
+
exact = /* @__PURE__ */ new Map();
|
|
171
|
+
prefixes = /* @__PURE__ */ new Map();
|
|
172
|
+
upgrades = /* @__PURE__ */ new Map();
|
|
173
|
+
indexTaps = [];
|
|
174
|
+
fallback;
|
|
175
|
+
constructor(ctx, config) {
|
|
176
|
+
super(ctx, "webServer");
|
|
177
|
+
this.config = config;
|
|
178
|
+
}
|
|
179
|
+
/** 没有真实监听:报配置值(缺省 0),只用于拼展示用 URL。 */
|
|
180
|
+
get port() {
|
|
181
|
+
return this.config.port ?? 0;
|
|
182
|
+
}
|
|
183
|
+
get host() {
|
|
184
|
+
return this.config.host ?? "127.0.0.1";
|
|
185
|
+
}
|
|
186
|
+
register(route) {
|
|
187
|
+
const table = route.kind === "exact" ? this.exact : this.prefixes;
|
|
188
|
+
if (table.has(route.path)) throw new Error(`webserver: duplicate ${route.kind} route "${route.path}"`);
|
|
189
|
+
table.set(route.path, route);
|
|
190
|
+
return () => {
|
|
191
|
+
table.delete(route.path);
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
registerUpgrade(route) {
|
|
195
|
+
if (this.upgrades.has(route.path)) throw new Error(`webserver: duplicate upgrade route "${route.path}"`);
|
|
196
|
+
this.upgrades.set(route.path, route);
|
|
197
|
+
return () => {
|
|
198
|
+
this.upgrades.delete(route.path);
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
registerFallback(handler) {
|
|
202
|
+
if (this.fallback !== void 0) throw new Error("webserver: fallback already registered");
|
|
203
|
+
this.fallback = handler;
|
|
204
|
+
return () => {
|
|
205
|
+
this.fallback = void 0;
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
tapIndex(transform) {
|
|
209
|
+
this.indexTaps.push(transform);
|
|
210
|
+
return () => {
|
|
211
|
+
const at = this.indexTaps.indexOf(transform);
|
|
212
|
+
if (at !== -1) this.indexTaps.splice(at, 1);
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
applyIndexTaps(html) {
|
|
216
|
+
let out = html;
|
|
217
|
+
for (const transform of this.indexTaps) out = transform(out);
|
|
218
|
+
return out;
|
|
219
|
+
}
|
|
220
|
+
collectIndexInjections() {
|
|
221
|
+
const table = [];
|
|
222
|
+
this.ctx.emit("webserver/index-inject", table);
|
|
223
|
+
return table;
|
|
224
|
+
}
|
|
225
|
+
renderIndex(html) {
|
|
226
|
+
return this.applyIndexTaps(renderIndexInjections(html, this.collectIndexInjections()));
|
|
227
|
+
}
|
|
228
|
+
/** 把一条管道请求分派给注册表;没有匹配也没有 fallback 时回 404。 */
|
|
229
|
+
async dispatch(request) {
|
|
230
|
+
const url = new URL(request.url);
|
|
231
|
+
let pathname;
|
|
232
|
+
try {
|
|
233
|
+
pathname = decodeURIComponent(url.pathname);
|
|
234
|
+
} catch {
|
|
235
|
+
return new Response(null, { status: 400 });
|
|
236
|
+
}
|
|
237
|
+
const handler = this.match(pathname)?.handler ?? this.fallback;
|
|
238
|
+
if (process.env.DSH_DESKTOP_DEBUG === "1") console.error(`[dsh-desktop] dispatch ${request.method} ${pathname} → ${handler === void 0 ? "404" : "handler"}`);
|
|
239
|
+
if (handler === void 0) return new Response("not found", { status: 404 });
|
|
240
|
+
const response = new SyntheticResponse();
|
|
241
|
+
const req = createRequest(request, url);
|
|
242
|
+
Promise.resolve().then(() => handler(req, response)).catch((error) => {
|
|
243
|
+
this.ctx.logger.warn(error instanceof Error ? error : new Error(errorMessage(error)));
|
|
244
|
+
response.fail(error);
|
|
245
|
+
});
|
|
246
|
+
const started = await response.started;
|
|
247
|
+
return new Response(response.body, {
|
|
248
|
+
status: started.status,
|
|
249
|
+
headers: started.headers
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
match(pathname) {
|
|
253
|
+
const exact = this.exact.get(pathname);
|
|
254
|
+
if (exact !== void 0) return exact;
|
|
255
|
+
let best;
|
|
256
|
+
for (const [prefix, route] of this.prefixes) {
|
|
257
|
+
if (pathname !== prefix && !pathname.startsWith(`${prefix}/`)) continue;
|
|
258
|
+
if (best === void 0 || prefix.length > best.path.length) best = route;
|
|
259
|
+
}
|
|
260
|
+
return best;
|
|
261
|
+
}
|
|
262
|
+
};
|
|
263
|
+
//#endregion
|
|
264
|
+
export { PortlessWebServer, PortlessWebServer as default };
|
package/lib/wire.js
ADDED
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
//#region src/wire.ts
|
|
2
|
+
/**
|
|
3
|
+
* 桌面 host 与 Electron 壳之间的字节管道协议:请求帧(壳 → host,FD 3)与响应帧(host → 壳,FD 4)。
|
|
4
|
+
*
|
|
5
|
+
* 帧头 13 字节:magic(4) | type(1) | streamId(4) | payloadLength(4)。data 帧是原始字节,
|
|
6
|
+
* 其余帧是 JSON 或空载荷。两侧共用这一份实现,避免编解码各写一遍。
|
|
7
|
+
*/
|
|
8
|
+
const DESKTOP_HOST_PROTOCOL_VERSION = 1;
|
|
9
|
+
/** 桌面流的宿主路径:页面/主进程都用它(两侧共享一份,避免路径写两处)。 */
|
|
10
|
+
const DESKTOP_STREAM_PATH = "/.dsh/remote-stream";
|
|
11
|
+
/** 壳写请求帧的管道描述符。 */
|
|
12
|
+
const DESKTOP_REQUEST_PIPE_FD = 3;
|
|
13
|
+
/** host 写响应帧的管道描述符。 */
|
|
14
|
+
const DESKTOP_RESPONSE_PIPE_FD = 4;
|
|
15
|
+
/** Node 生命周期 IPC 通道的描述符(ready / fatal / shutdown)。 */
|
|
16
|
+
const DESKTOP_CONTROL_IPC_FD = 5;
|
|
17
|
+
/** 单个 data 帧携带的最大原始字节数。 */
|
|
18
|
+
const DESKTOP_PIPE_CHUNK_BYTES = 65536;
|
|
19
|
+
const FRAME_MAGIC = 1146308659;
|
|
20
|
+
const FRAME_HEADER_BYTES = 13;
|
|
21
|
+
const MAX_CONTROL_PAYLOAD_BYTES = 1048576;
|
|
22
|
+
const REQUEST_FRAME_START = 1;
|
|
23
|
+
const REQUEST_FRAME_DATA = 2;
|
|
24
|
+
const REQUEST_FRAME_END = 3;
|
|
25
|
+
const REQUEST_FRAME_CANCEL = 4;
|
|
26
|
+
const RESPONSE_FRAME_START = 1;
|
|
27
|
+
const RESPONSE_FRAME_DATA = 2;
|
|
28
|
+
const RESPONSE_FRAME_END = 3;
|
|
29
|
+
const RESPONSE_FRAME_ERROR = 4;
|
|
30
|
+
function isRecord(value) {
|
|
31
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
32
|
+
}
|
|
33
|
+
function isHeaders(value) {
|
|
34
|
+
return Array.isArray(value) && value.every((header) => Array.isArray(header) && header.length === 2 && typeof header[0] === "string" && typeof header[1] === "string");
|
|
35
|
+
}
|
|
36
|
+
function assertStreamId(streamId) {
|
|
37
|
+
if (!Number.isInteger(streamId) || streamId < 1 || streamId > 4294967295) throw new Error(`dsh desktop: invalid pipe stream id ${String(streamId)}`);
|
|
38
|
+
}
|
|
39
|
+
function encodeFrame(type, streamId, payload, dataType, direction) {
|
|
40
|
+
assertStreamId(streamId);
|
|
41
|
+
const limit = type === dataType ? DESKTOP_PIPE_CHUNK_BYTES : MAX_CONTROL_PAYLOAD_BYTES;
|
|
42
|
+
if (payload.byteLength > limit) throw new Error(`dsh desktop: ${direction} pipe frame exceeds the ${String(limit)}-byte limit`);
|
|
43
|
+
const frame = Buffer.allocUnsafe(FRAME_HEADER_BYTES + payload.byteLength);
|
|
44
|
+
frame.writeUInt32BE(FRAME_MAGIC, 0);
|
|
45
|
+
frame.writeUInt8(type, 4);
|
|
46
|
+
frame.writeUInt32BE(streamId, 5);
|
|
47
|
+
frame.writeUInt32BE(payload.byteLength, 9);
|
|
48
|
+
payload.copy(frame, FRAME_HEADER_BYTES);
|
|
49
|
+
return frame;
|
|
50
|
+
}
|
|
51
|
+
function jsonPayload(type, streamId, value, dataType, direction) {
|
|
52
|
+
return encodeFrame(type, streamId, Buffer.from(JSON.stringify(value), "utf8"), dataType, direction);
|
|
53
|
+
}
|
|
54
|
+
/** 编码开启一个请求流的元数据帧(壳侧)。 */
|
|
55
|
+
function encodeDesktopRequestStart(streamId, request) {
|
|
56
|
+
return jsonPayload(REQUEST_FRAME_START, streamId, request, REQUEST_FRAME_DATA, "request");
|
|
57
|
+
}
|
|
58
|
+
/** 编码一个受限的请求体原始分片(壳侧)。 */
|
|
59
|
+
function encodeDesktopRequestData(streamId, data) {
|
|
60
|
+
return encodeFrame(REQUEST_FRAME_DATA, streamId, Buffer.from(data), REQUEST_FRAME_DATA, "request");
|
|
61
|
+
}
|
|
62
|
+
/** 编码请求体正常结束(壳侧)。 */
|
|
63
|
+
function encodeDesktopRequestEnd(streamId) {
|
|
64
|
+
return encodeFrame(REQUEST_FRAME_END, streamId, Buffer.alloc(0), REQUEST_FRAME_DATA, "request");
|
|
65
|
+
}
|
|
66
|
+
/** 编码取消一个请求及其响应(壳侧)。 */
|
|
67
|
+
function encodeDesktopRequestCancel(streamId) {
|
|
68
|
+
return encodeFrame(REQUEST_FRAME_CANCEL, streamId, Buffer.alloc(0), REQUEST_FRAME_DATA, "request");
|
|
69
|
+
}
|
|
70
|
+
/** 编码响应元数据帧(host 侧)。 */
|
|
71
|
+
function encodeDesktopResponseStart(streamId, response) {
|
|
72
|
+
return jsonPayload(RESPONSE_FRAME_START, streamId, response, RESPONSE_FRAME_DATA, "response");
|
|
73
|
+
}
|
|
74
|
+
/** 编码一个受限的响应体原始分片(host 侧)。 */
|
|
75
|
+
function encodeDesktopResponseData(streamId, data) {
|
|
76
|
+
return encodeFrame(RESPONSE_FRAME_DATA, streamId, Buffer.from(data), RESPONSE_FRAME_DATA, "response");
|
|
77
|
+
}
|
|
78
|
+
/** 编码响应正常结束(host 侧)。 */
|
|
79
|
+
function encodeDesktopResponseEnd(streamId) {
|
|
80
|
+
return encodeFrame(RESPONSE_FRAME_END, streamId, Buffer.alloc(0), RESPONSE_FRAME_DATA, "response");
|
|
81
|
+
}
|
|
82
|
+
/** 编码一次响应失败,不把 Error 对象跨进程传递(host 侧)。 */
|
|
83
|
+
function encodeDesktopResponseError(streamId, message) {
|
|
84
|
+
return jsonPayload(RESPONSE_FRAME_ERROR, streamId, { message }, RESPONSE_FRAME_DATA, "response");
|
|
85
|
+
}
|
|
86
|
+
function frameLength(buffer, dataType, direction) {
|
|
87
|
+
if (buffer.byteLength < FRAME_HEADER_BYTES) return void 0;
|
|
88
|
+
if (buffer.readUInt32BE(0) !== FRAME_MAGIC) throw new Error(`dsh desktop: invalid ${direction} frame marker`);
|
|
89
|
+
const type = buffer.readUInt8(4);
|
|
90
|
+
const streamId = buffer.readUInt32BE(5);
|
|
91
|
+
const payloadLength = buffer.readUInt32BE(9);
|
|
92
|
+
assertStreamId(streamId);
|
|
93
|
+
const limit = type === dataType ? DESKTOP_PIPE_CHUNK_BYTES : MAX_CONTROL_PAYLOAD_BYTES;
|
|
94
|
+
if (payloadLength > limit) throw new Error(`dsh desktop: ${direction} frame exceeds the ${String(limit)}-byte limit`);
|
|
95
|
+
const total = FRAME_HEADER_BYTES + payloadLength;
|
|
96
|
+
return buffer.byteLength < total ? void 0 : total;
|
|
97
|
+
}
|
|
98
|
+
function parseJson(payload, subject) {
|
|
99
|
+
try {
|
|
100
|
+
return JSON.parse(payload.toString("utf8"));
|
|
101
|
+
} catch (error) {
|
|
102
|
+
throw new Error(`dsh desktop: ${subject} payload is not JSON: ${error instanceof Error ? error.message : String(error)}`);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
function assertEmpty(payload, subject) {
|
|
106
|
+
if (payload.byteLength !== 0) throw new Error(`dsh desktop: ${subject} frame carried a payload`);
|
|
107
|
+
}
|
|
108
|
+
/** 增量解码 host 侧从请求管道收到的请求帧。 */
|
|
109
|
+
var DesktopHostRequestDecoder = class {
|
|
110
|
+
buffer = Buffer.alloc(0);
|
|
111
|
+
/** 追加字节并返回所有已完整的请求帧。 */
|
|
112
|
+
push(chunk) {
|
|
113
|
+
this.buffer = this.buffer.byteLength === 0 ? chunk : Buffer.concat([this.buffer, chunk]);
|
|
114
|
+
const frames = [];
|
|
115
|
+
for (;;) {
|
|
116
|
+
const frame = this.next();
|
|
117
|
+
if (frame === void 0) return frames;
|
|
118
|
+
frames.push(frame);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
/** EOF 落在帧中间时报错。 */
|
|
122
|
+
finish() {
|
|
123
|
+
if (this.buffer.byteLength !== 0) throw new Error("dsh desktop: request pipe ended inside a frame");
|
|
124
|
+
}
|
|
125
|
+
next() {
|
|
126
|
+
const total = frameLength(this.buffer, REQUEST_FRAME_DATA, "request");
|
|
127
|
+
if (total === void 0) return void 0;
|
|
128
|
+
const type = this.buffer.readUInt8(4);
|
|
129
|
+
const streamId = this.buffer.readUInt32BE(5);
|
|
130
|
+
const payload = this.buffer.subarray(FRAME_HEADER_BYTES, total);
|
|
131
|
+
this.buffer = this.buffer.subarray(total);
|
|
132
|
+
switch (type) {
|
|
133
|
+
case REQUEST_FRAME_START: return {
|
|
134
|
+
type: "start",
|
|
135
|
+
streamId,
|
|
136
|
+
...this.parseStart(payload)
|
|
137
|
+
};
|
|
138
|
+
case REQUEST_FRAME_DATA: return {
|
|
139
|
+
type: "data",
|
|
140
|
+
streamId,
|
|
141
|
+
data: Buffer.from(payload)
|
|
142
|
+
};
|
|
143
|
+
case REQUEST_FRAME_END:
|
|
144
|
+
assertEmpty(payload, "request end");
|
|
145
|
+
return {
|
|
146
|
+
type: "end",
|
|
147
|
+
streamId
|
|
148
|
+
};
|
|
149
|
+
case REQUEST_FRAME_CANCEL:
|
|
150
|
+
assertEmpty(payload, "request cancel");
|
|
151
|
+
return {
|
|
152
|
+
type: "cancel",
|
|
153
|
+
streamId
|
|
154
|
+
};
|
|
155
|
+
default: throw new Error(`dsh desktop: unknown request frame type ${String(type)}`);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
parseStart(payload) {
|
|
159
|
+
const value = parseJson(payload, "request start");
|
|
160
|
+
if (!isRecord(value) || typeof value.url !== "string" || typeof value.method !== "string" || !isHeaders(value.headers) || typeof value.hasBody !== "boolean") throw new Error("dsh desktop: invalid request start payload");
|
|
161
|
+
return {
|
|
162
|
+
url: value.url,
|
|
163
|
+
method: value.method,
|
|
164
|
+
headers: value.headers,
|
|
165
|
+
hasBody: value.hasBody
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
};
|
|
169
|
+
/** 增量解码壳侧从响应管道收到的响应帧。 */
|
|
170
|
+
var DesktopHostResponseDecoder = class {
|
|
171
|
+
buffer = Buffer.alloc(0);
|
|
172
|
+
/** 追加字节并返回所有已完整的响应帧。 */
|
|
173
|
+
push(chunk) {
|
|
174
|
+
this.buffer = this.buffer.byteLength === 0 ? chunk : Buffer.concat([this.buffer, chunk]);
|
|
175
|
+
const frames = [];
|
|
176
|
+
for (;;) {
|
|
177
|
+
const frame = this.next();
|
|
178
|
+
if (frame === void 0) return frames;
|
|
179
|
+
frames.push(frame);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
/** EOF 落在帧中间时报错。 */
|
|
183
|
+
finish() {
|
|
184
|
+
if (this.buffer.byteLength !== 0) throw new Error("dsh desktop: response pipe ended inside a frame");
|
|
185
|
+
}
|
|
186
|
+
next() {
|
|
187
|
+
const total = frameLength(this.buffer, RESPONSE_FRAME_DATA, "response");
|
|
188
|
+
if (total === void 0) return void 0;
|
|
189
|
+
const type = this.buffer.readUInt8(4);
|
|
190
|
+
const streamId = this.buffer.readUInt32BE(5);
|
|
191
|
+
const payload = this.buffer.subarray(FRAME_HEADER_BYTES, total);
|
|
192
|
+
this.buffer = this.buffer.subarray(total);
|
|
193
|
+
switch (type) {
|
|
194
|
+
case RESPONSE_FRAME_START: return {
|
|
195
|
+
type: "start",
|
|
196
|
+
streamId,
|
|
197
|
+
...this.parseStart(payload)
|
|
198
|
+
};
|
|
199
|
+
case RESPONSE_FRAME_DATA: return {
|
|
200
|
+
type: "data",
|
|
201
|
+
streamId,
|
|
202
|
+
data: Buffer.from(payload)
|
|
203
|
+
};
|
|
204
|
+
case RESPONSE_FRAME_END:
|
|
205
|
+
assertEmpty(payload, "response end");
|
|
206
|
+
return {
|
|
207
|
+
type: "end",
|
|
208
|
+
streamId
|
|
209
|
+
};
|
|
210
|
+
case RESPONSE_FRAME_ERROR: return {
|
|
211
|
+
type: "error",
|
|
212
|
+
streamId,
|
|
213
|
+
...this.parseError(payload)
|
|
214
|
+
};
|
|
215
|
+
default: throw new Error(`dsh desktop: unknown response frame type ${String(type)}`);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
parseStart(payload) {
|
|
219
|
+
const value = parseJson(payload, "response start");
|
|
220
|
+
if (!isRecord(value) || !Number.isInteger(value.status) || value.status < 100 || value.status > 599 || !isHeaders(value.headers) || typeof value.hasBody !== "boolean") throw new Error("dsh desktop: invalid response start payload");
|
|
221
|
+
return {
|
|
222
|
+
status: value.status,
|
|
223
|
+
headers: value.headers,
|
|
224
|
+
hasBody: value.hasBody
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
parseError(payload) {
|
|
228
|
+
const value = parseJson(payload, "response error");
|
|
229
|
+
if (!isRecord(value) || typeof value.message !== "string") throw new Error("dsh desktop: invalid response error payload");
|
|
230
|
+
return { message: value.message };
|
|
231
|
+
}
|
|
232
|
+
};
|
|
233
|
+
/** IPC 事件校验(壳侧)。 */
|
|
234
|
+
function isDesktopHostEvent(value) {
|
|
235
|
+
if (!isRecord(value)) return false;
|
|
236
|
+
if (value.type === "ready") return value.protocolVersion === 1;
|
|
237
|
+
if (value.type === "fatal") return typeof value.message === "string";
|
|
238
|
+
return false;
|
|
239
|
+
}
|
|
240
|
+
/** IPC 命令校验(host 侧)。 */
|
|
241
|
+
function isDesktopHostCommand(value) {
|
|
242
|
+
return isRecord(value) && value.type === "shutdown";
|
|
243
|
+
}
|
|
244
|
+
//#endregion
|
|
245
|
+
export { DESKTOP_CONTROL_IPC_FD, DESKTOP_HOST_PROTOCOL_VERSION, DESKTOP_PIPE_CHUNK_BYTES, DESKTOP_REQUEST_PIPE_FD, DESKTOP_RESPONSE_PIPE_FD, DESKTOP_STREAM_PATH, DesktopHostRequestDecoder, DesktopHostResponseDecoder, encodeDesktopRequestCancel, encodeDesktopRequestData, encodeDesktopRequestEnd, encodeDesktopRequestStart, encodeDesktopResponseData, encodeDesktopResponseEnd, encodeDesktopResponseError, encodeDesktopResponseStart, isDesktopHostCommand, isDesktopHostEvent };
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@morlay/dsh-desktop-host",
|
|
3
|
-
"version": "0.0.1-alpha.
|
|
4
|
-
"description": "Desktop host process for dsh-desktopify: boots the
|
|
3
|
+
"version": "0.0.1-alpha.4",
|
|
4
|
+
"description": "Desktop host process for dsh-desktopify: boots the desktop profile and serves it through an in-process, portless webServer over byte pipes — upstream apps/desktop-host with the web carrier taken over and the Office skill mount removed.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"desktop",
|
|
7
7
|
"dsh",
|
|
@@ -16,27 +16,41 @@
|
|
|
16
16
|
"url": "https://github.com/morlay/dsh-plugin.git"
|
|
17
17
|
},
|
|
18
18
|
"files": [
|
|
19
|
-
"lib"
|
|
19
|
+
"lib",
|
|
20
|
+
"config/desktop.cordis.patch.yml"
|
|
20
21
|
],
|
|
21
22
|
"type": "module",
|
|
22
23
|
"main": "./lib/index.js",
|
|
23
24
|
"exports": {
|
|
24
|
-
".":
|
|
25
|
+
".": {
|
|
26
|
+
"types": "./src/index.ts",
|
|
27
|
+
"default": "./lib/index.js"
|
|
28
|
+
},
|
|
29
|
+
"./webserver": {
|
|
30
|
+
"types": "./src/webserver.ts",
|
|
31
|
+
"default": "./lib/webserver.js"
|
|
32
|
+
},
|
|
33
|
+
"./wire": {
|
|
34
|
+
"types": "./src/wire.ts",
|
|
35
|
+
"default": "./lib/wire.js"
|
|
36
|
+
},
|
|
25
37
|
"./package.json": "./package.json"
|
|
26
38
|
},
|
|
27
39
|
"dependencies": {
|
|
28
|
-
"@deepseek-ai/dsh": "
|
|
29
|
-
"@deepseek-ai/dsh-app-boot": "
|
|
30
|
-
"@deepseek-ai/dsh-home-paths": "
|
|
40
|
+
"@deepseek-ai/dsh": "0.1.6-alpha.2",
|
|
41
|
+
"@deepseek-ai/dsh-app-boot": "0.1.6-alpha.2",
|
|
42
|
+
"@deepseek-ai/dsh-home-paths": "0.1.6-alpha.2",
|
|
43
|
+
"@deepseek-ai/dsh-host-webserver": "0.1.6-alpha.2",
|
|
44
|
+
"@deepseek-ai/schemastery": "3.18.2"
|
|
31
45
|
},
|
|
32
46
|
"devDependencies": {
|
|
33
|
-
"@deepseek-ai/cordis": "
|
|
34
|
-
"@deepseek-ai/dsh-
|
|
35
|
-
"@deepseek-ai/dsh-
|
|
47
|
+
"@deepseek-ai/cordis": "4.0.2",
|
|
48
|
+
"@deepseek-ai/dsh-api-gateway": "0.1.6-alpha.2",
|
|
49
|
+
"@deepseek-ai/dsh-client-connection": "0.1.6-alpha.2",
|
|
36
50
|
"@types/node": "^26.5.0"
|
|
37
51
|
},
|
|
38
52
|
"peerDependencies": {
|
|
39
|
-
"@deepseek-ai/cordis": "
|
|
53
|
+
"@deepseek-ai/cordis": "4.0.2"
|
|
40
54
|
},
|
|
41
55
|
"engines": {
|
|
42
56
|
"node": ">=24.11.0"
|