@morlay/dsh-desktop-host 0.2.4 → 0.2.6
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 +20 -6
- package/config/desktop.cordis.patch.yml +37 -35
- package/dist/index.d.mts +1 -0
- package/dist/index.js +430 -0
- package/dist/paths.d.mts +4 -0
- package/dist/paths.js +5 -0
- package/dist/preload-source.d.mts +29 -0
- package/dist/preload-source.js +80 -0
- package/dist/settings-namespace.d.mts +9 -0
- package/dist/settings-namespace.js +13 -0
- package/dist/wire.d.mts +109 -0
- package/{lib → dist}/wire.js +4 -1
- package/package.json +39 -16
- package/src/__tests__/desktop-patch.spec.ts +171 -0
- package/src/__tests__/inspector.spec.ts +50 -0
- package/src/__tests__/invocation.spec.ts +60 -0
- package/src/__tests__/manifest.spec.ts +70 -0
- package/src/__tests__/web-entry.spec.ts +13 -0
- package/src/__tests__/wire.spec.ts +238 -0
- package/src/desktop-ipc.ts +71 -0
- package/src/fatal.ts +30 -0
- package/src/index.ts +126 -0
- package/src/inspector.ts +44 -0
- package/src/invocation.ts +68 -0
- package/src/paths.ts +4 -0
- package/src/preload-source.ts +86 -0
- package/src/settings-namespace.ts +31 -0
- package/src/transport.ts +286 -0
- package/src/web.ts +57 -0
- package/src/wire.ts +480 -0
- package/lib/index.js +0 -390
- package/lib/webserver.js +0 -250
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* preload 的**源码**由服务现场打包成 CJS 交给壳,装配目录里因此不再有 `dist/`。
|
|
3
|
+
*
|
|
4
|
+
* 为什么需要它:sandboxed preload 有三个硬约束——只能 CJS、不能 ESM、不能 `require` 第三方(因此也挂不了
|
|
5
|
+
* 本仓库那套 TS hooks)。而它的源码是 TS(还跨几个文件:`preload-app.ts` 拉 `./stream-uplink.ts` 那些),
|
|
6
|
+
* 所以只能**打成单文件 CJS**——打包这件事放在这里做,壳就不必在装配时预先产出一份 `dist/`。
|
|
7
|
+
*
|
|
8
|
+
* 谁来取:壳在窗口创建之前(那时宿主已经就绪)取回来落盘,再把路径交给 `BrowserWindow` 的 `preload`。
|
|
9
|
+
*/
|
|
10
|
+
import { readFile } from "node:fs/promises";
|
|
11
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
import { rolldown } from "rolldown";
|
|
14
|
+
import type { Context } from "@deepseek-ai/cordis";
|
|
15
|
+
|
|
16
|
+
/** 壳取 preload 的路径(`GET` 它得到可执行的 CJS 字节)。 */
|
|
17
|
+
export const PRELOAD_SOURCE_PATH = "/@dsh/preload.cjs";
|
|
18
|
+
|
|
19
|
+
export interface PreloadSourceOptions {
|
|
20
|
+
/** 壳包所在的目录(app 目录):preload 的源码在它的 `src/` 下。 */
|
|
21
|
+
readonly shellRoot: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* 把 preload 打包成单文件 CJS。
|
|
26
|
+
*
|
|
27
|
+
* @param options - 见 {@link PreloadSourceOptions}。
|
|
28
|
+
* @returns CJS 源码。
|
|
29
|
+
*/
|
|
30
|
+
export async function bundlePreload(options: PreloadSourceOptions): Promise<string> {
|
|
31
|
+
const bundle = await rolldown({
|
|
32
|
+
input: join(options.shellRoot, "src", "preload-app.ts"),
|
|
33
|
+
platform: "node",
|
|
34
|
+
// preload 里只能 require Electron 自己给的模块。
|
|
35
|
+
external: ["electron"],
|
|
36
|
+
});
|
|
37
|
+
try {
|
|
38
|
+
const { output } = await bundle.generate({ format: "cjs", inlineDynamicImports: true });
|
|
39
|
+
const chunk = output.find((item) => item.type === "chunk");
|
|
40
|
+
if (chunk === undefined) throw new Error("dsh desktop: preload bundling produced no chunk");
|
|
41
|
+
return chunk.code;
|
|
42
|
+
} finally {
|
|
43
|
+
await bundle.close();
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** 构建期打好的那份 preload(`prepareShell` 里调 {@link bundlePreload} 产出)。 */
|
|
48
|
+
export const PRELOAD_BUNDLE_FILE = join("dist", "preload.cjs");
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* 在宿主的 webServer 上注册 `/@dsh/preload.cjs`:壳按这条路径取走 preload 的 CJS 字节。
|
|
52
|
+
*
|
|
53
|
+
* **读的是构建期打好的文件**,不在运行时打包:现场打包要读 `src/preload-app.ts`,而打包器(rolldown)
|
|
54
|
+
* 是 native 模块、用自己的文件 IO——**读不了 asar 内的路径**(宿主跑在 Electron 上,`fs` 才被补丁照看着)。
|
|
55
|
+
* 构建期那份读进来是字节,`readFile` 对归档是透明的。
|
|
56
|
+
*
|
|
57
|
+
* @param ctx - 宿主上下文(拿 `webServer`)。
|
|
58
|
+
* @param options - 见 {@link PreloadSourceOptions}。
|
|
59
|
+
*/
|
|
60
|
+
export function installPreloadSource(ctx: Context, options: PreloadSourceOptions): void {
|
|
61
|
+
const file = join(options.shellRoot, PRELOAD_BUNDLE_FILE);
|
|
62
|
+
const serve = (req: IncomingMessage, res: ServerResponse): void => {
|
|
63
|
+
void (async () => {
|
|
64
|
+
let code: Buffer;
|
|
65
|
+
try {
|
|
66
|
+
code = await readFile(file);
|
|
67
|
+
} catch (error) {
|
|
68
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
69
|
+
ctx.logger.error(`dsh desktop: preload bundle is unreadable at ${file}: ${message}`);
|
|
70
|
+
res.writeHead(500, { "content-type": "text/plain; charset=utf-8" });
|
|
71
|
+
res.end(`dsh desktop: preload bundle is unreadable at ${file}: ${message}`);
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
res.writeHead(200, {
|
|
75
|
+
"content-type": "text/javascript; charset=utf-8",
|
|
76
|
+
// 构建期定的产物(dev 形态由工具在装配时重打):别缓存。
|
|
77
|
+
"cache-control": "no-store",
|
|
78
|
+
});
|
|
79
|
+
res.end(req.method === "HEAD" ? undefined : code);
|
|
80
|
+
})();
|
|
81
|
+
};
|
|
82
|
+
ctx.effect(
|
|
83
|
+
() => ctx.webServer.register({ kind: "exact", path: PRELOAD_SOURCE_PATH, handler: serve }),
|
|
84
|
+
`dsh-desktop-host: ${PRELOAD_SOURCE_PATH}`,
|
|
85
|
+
);
|
|
86
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// 桌面形态的**账号面空壳**:本部署不要官方账号的用户界面,但那一行(`ui-settings-account`)同时是
|
|
2
|
+
// **settings namespace 的 owner**——它的 `apply` 只做两件不可省的事,我们只需第一件:
|
|
3
|
+
//
|
|
4
|
+
// ```ts
|
|
5
|
+
// ctx.inject(['settings'], (scope) => {
|
|
6
|
+
// scope.effect(() => scope.settings.configure({ auto: false }, ctx.fiber))
|
|
7
|
+
// })
|
|
8
|
+
// ```
|
|
9
|
+
//
|
|
10
|
+
// 少了这个注册,前端的 settings 镜面拿不到名册(`describeFace.view === undefined`)→ 直接 failLoad,
|
|
11
|
+
// 整个设置页判 "settings are unavailable in this browser",连 `/api/settings` 都不请求。
|
|
12
|
+
//
|
|
13
|
+
// 上游那一行还往页面注入 contact 配置(给它自己的账号页用),以及渲染设置页的账号页、侧栏账号菜单、
|
|
14
|
+
// 首次引导层——本部署都不需要,所以停掉上游那行、由这里顶上这个注册。
|
|
15
|
+
//
|
|
16
|
+
// 装配方式见 `../config/desktop.cordis.patch.yml` 的 `insert`(必须写在那份 overlay 里:写在 bundle
|
|
17
|
+
// 层的停用会被后来应用的层盖回去)。
|
|
18
|
+
|
|
19
|
+
import type { Context } from "@deepseek-ai/cordis";
|
|
20
|
+
|
|
21
|
+
export const name = "@morlay/dsh-desktop-host-settings-namespace";
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* 注册 settings namespace(`auto: false` 表示这个 namespace 的界面由宿主自己安排,不走自动渲染)。
|
|
25
|
+
* @param ctx - 宿主的上下文。
|
|
26
|
+
*/
|
|
27
|
+
export function apply(ctx: Context): void {
|
|
28
|
+
ctx.inject(["settings"], (scope) => {
|
|
29
|
+
scope.effect(() => scope.settings.configure({ auto: false }, ctx.fiber));
|
|
30
|
+
});
|
|
31
|
+
}
|
package/src/transport.ts
ADDED
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
// 桌面形态的传输接管:把 Web 应用的浏览器装配换成「宿主内直连」——放行 `connection` 的浏览器认证,
|
|
2
|
+
// 并把客户端的 transport 行注入 index、注册它访问的 `/.dsh/remote-stream`(请求体首行定
|
|
3
|
+
// endpoint/payload、后续行是逻辑流的上行项,响应体是下行 NDJSON)。
|
|
4
|
+
|
|
5
|
+
import { once } from "node:events";
|
|
6
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
7
|
+
import type { TypertGatewayWireStream } from "@deepseek-ai/dsh-api-gateway";
|
|
8
|
+
import type { Context, LoggerService } from "@deepseek-ai/cordis";
|
|
9
|
+
import { DESKTOP_STREAM_PATH, DesktopStreamBodyDecoder } from "./wire.ts";
|
|
10
|
+
|
|
11
|
+
export { DESKTOP_STREAM_PATH } from "./wire.ts";
|
|
12
|
+
|
|
13
|
+
// 桌面流的计数:给「宿主内存一直在涨」这类问题用的——把增长落到「哪条流、多少字节」上。
|
|
14
|
+
// 只在 `DSH_MEMORY_REPORT=1` 时被读出来打印(见 `index.ts` 的 reportMemoryWhenAsked),平时就是几个自增。
|
|
15
|
+
export const desktopStreamStats = {
|
|
16
|
+
active: 0,
|
|
17
|
+
opened: 0,
|
|
18
|
+
failed: 0,
|
|
19
|
+
items: 0,
|
|
20
|
+
bytes: 0,
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
// 注入 index 的 transport 行:声明页面拥有 Host,并给出 Gateway 流载体(下行流 + 上行项)。
|
|
24
|
+
// 这份脚本里的 `console.*` 是**页面里**跑的代码(浏览器没有 cordis 树),不是宿主日志。
|
|
25
|
+
export const DESKTOP_TRANSPORT_SCRIPT = `globalThis.__DSH_TRANSPORT__={
|
|
26
|
+
_desktop:true,
|
|
27
|
+
ownsHost:true,
|
|
28
|
+
async *openStream(endpoint,payload,signal,uplink){
|
|
29
|
+
console.info('[dsh-desktop] openStream',endpoint)
|
|
30
|
+
const carrier=globalThis.__DSH_DESKTOP_STREAM__
|
|
31
|
+
if(carrier===undefined){
|
|
32
|
+
console.error('[dsh-desktop] stream carrier missing for',endpoint)
|
|
33
|
+
throw new Error('desktop stream carrier is unavailable')
|
|
34
|
+
}
|
|
35
|
+
const queue=[]
|
|
36
|
+
let buffer=''
|
|
37
|
+
let ended=false
|
|
38
|
+
let failure
|
|
39
|
+
let wake
|
|
40
|
+
const notify=()=>{const pending=wake;wake=undefined;if(pending)pending()}
|
|
41
|
+
const append=(text)=>{
|
|
42
|
+
buffer+=text
|
|
43
|
+
let at
|
|
44
|
+
while((at=buffer.indexOf('\\n'))!==-1){
|
|
45
|
+
const line=buffer.slice(0,at)
|
|
46
|
+
buffer=buffer.slice(at+1)
|
|
47
|
+
if(line!=='')queue.push(JSON.parse(line))
|
|
48
|
+
}
|
|
49
|
+
notify()
|
|
50
|
+
}
|
|
51
|
+
const stream=carrier.open(endpoint,payload,{
|
|
52
|
+
chunk(text){append(text)},
|
|
53
|
+
end(){
|
|
54
|
+
if(buffer!==''){queue.push(JSON.parse(buffer));buffer=''}
|
|
55
|
+
ended=true
|
|
56
|
+
notify()
|
|
57
|
+
},
|
|
58
|
+
fail(message){failure=new Error(message);ended=true;notify()},
|
|
59
|
+
})
|
|
60
|
+
// 收尾只有一处:取消必须结束迭代(不叫醒它,等在这一句上的消费方收不到 end 帧,
|
|
61
|
+
// dispose(await iterator.return)也永不落定——窗口重建会卡在这里),同时取消载体流本身。
|
|
62
|
+
// abort(窗口重建)与消费方提前退出(切会话时上游只 return 迭代器,不发 abort)都走它;
|
|
63
|
+
// 幂等,两条路径都发生也只 cancel 一次。
|
|
64
|
+
let closed=false
|
|
65
|
+
const close=()=>{
|
|
66
|
+
if(closed)return
|
|
67
|
+
closed=true
|
|
68
|
+
stream.cancel()
|
|
69
|
+
ended=true
|
|
70
|
+
notify()
|
|
71
|
+
}
|
|
72
|
+
const onAbort=close
|
|
73
|
+
signal.addEventListener('abort',onAbort,{once:true})
|
|
74
|
+
if(signal.aborted)onAbort()
|
|
75
|
+
// 上行(客户端 → 宿主)是逻辑流的输入项:逐条交给主进程,收尾时结束请求体;
|
|
76
|
+
// 没有 uplink 的流(如 $events)立刻结束请求体,宿主侧不用等一个永不来的 end。
|
|
77
|
+
if(uplink===undefined)stream.end()
|
|
78
|
+
else void (async()=>{
|
|
79
|
+
try{
|
|
80
|
+
for await(const item of uplink){
|
|
81
|
+
// 取消后不再上行:宿主那边这条逻辑流已经作废。
|
|
82
|
+
if(signal.aborted)break
|
|
83
|
+
stream.send(item)
|
|
84
|
+
}
|
|
85
|
+
}catch(error){
|
|
86
|
+
console.error('[dsh-desktop] stream uplink failed for',endpoint,error)
|
|
87
|
+
}finally{
|
|
88
|
+
stream.end()
|
|
89
|
+
}
|
|
90
|
+
})()
|
|
91
|
+
try{
|
|
92
|
+
for(;;){
|
|
93
|
+
while(queue.length>0)yield queue.shift()
|
|
94
|
+
if(failure!==undefined)throw failure
|
|
95
|
+
if(ended)return
|
|
96
|
+
await new Promise(resolve=>{wake=resolve})
|
|
97
|
+
}
|
|
98
|
+
}finally{
|
|
99
|
+
signal.removeEventListener('abort',onAbort)
|
|
100
|
+
close()
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
console.info('[dsh-desktop] transport installed',Object.keys(globalThis.__DSH_TRANSPORT__))`;
|
|
105
|
+
interface BrowserAuthSurface {
|
|
106
|
+
/** 桌面形态下这棵树的流代表的 peer:已认证浏览器连接共享的 owner Peer(上游 `HostConnectionService`)。 */
|
|
107
|
+
readonly browserPeer: PeerScope;
|
|
108
|
+
requestRejection(request: unknown): number | undefined;
|
|
109
|
+
authorizeIndex(request: unknown, response: unknown): boolean;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** 流开口的 peer 形参类型:上游签名一改这里跟着改(见 `GatewaySurface` 的同一考虑)。 */
|
|
113
|
+
type PeerScope = Parameters<TypertGatewayWireStream["open"]>[3];
|
|
114
|
+
|
|
115
|
+
interface GatewaySurface {
|
|
116
|
+
// 直接用上游导出的签名:它变参数时我们编译即报错,抄一份会静默失效成运行时 TypeError。
|
|
117
|
+
readonly wireStream: Pick<TypertGatewayWireStream, "open">;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// 桌面形态下放行 `connection` 的两处浏览器认证:`requestRejection`(`/api` 路由与各插件 handler
|
|
121
|
+
// 的自查)与 `authorizeIndex`(`frontend-static` 的 index 渲染)——页面由壳独占,认证没有对象。
|
|
122
|
+
export function takeOverDesktopAuthentication(ctx: Context): void {
|
|
123
|
+
const connection = ctx.get("connection") as unknown as BrowserAuthSurface | undefined;
|
|
124
|
+
if (connection === undefined)
|
|
125
|
+
throw new Error("dsh desktop: the composition did not provide the connection service");
|
|
126
|
+
if (
|
|
127
|
+
typeof connection.requestRejection !== "function" ||
|
|
128
|
+
typeof connection.authorizeIndex !== "function"
|
|
129
|
+
)
|
|
130
|
+
throw new Error(
|
|
131
|
+
"dsh desktop: the connection service no longer exposes requestRejection / authorizeIndex; " +
|
|
132
|
+
"desktop authentication takeover needs an update",
|
|
133
|
+
);
|
|
134
|
+
connection.requestRejection = () => undefined;
|
|
135
|
+
connection.authorizeIndex = () => true;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
139
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// 桌面流请求体:首行是 open(endpoint / payload),后续每行是一道上行项。
|
|
143
|
+
async function* readStreamFrames(request: IncomingMessage): AsyncGenerator<unknown> {
|
|
144
|
+
const decoder = new DesktopStreamBodyDecoder();
|
|
145
|
+
for await (const chunk of request) {
|
|
146
|
+
for (const frame of decoder.push(chunk as Uint8Array)) yield frame;
|
|
147
|
+
}
|
|
148
|
+
for (const frame of decoder.finish()) yield frame;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
interface StreamOpening {
|
|
152
|
+
readonly endpoint: string;
|
|
153
|
+
readonly payload: unknown;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// 校验首行:它决定这条流开在哪个 endpoint 上。
|
|
157
|
+
function parseStreamOpening(value: unknown, subject: string): StreamOpening {
|
|
158
|
+
if (!isRecord(value) || typeof value.endpoint !== "string")
|
|
159
|
+
throw new Error(`dsh desktop: invalid stream request ${subject}`);
|
|
160
|
+
return { endpoint: value.endpoint, payload: value.payload ?? { args: {} } };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// 诊断行:桌面流是连接就绪的唯一来源,出问题时先看这几行——走这条树的 logger(由调用方给)。
|
|
164
|
+
function reportStreamFailure(subject: string, detail: unknown, log: LoggerService): void {
|
|
165
|
+
log.error(`[dsh-desktop] ${subject}: ${errorText(detail)}`);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function errorText(value: unknown): string {
|
|
169
|
+
return value instanceof Error ? (value.stack ?? value.message) : String(value);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function streamHandler(ctx: Context): (req: IncomingMessage, res: ServerResponse) => Promise<void> {
|
|
173
|
+
return async (request, response) => {
|
|
174
|
+
if (request.method !== "POST") {
|
|
175
|
+
response.writeHead(405);
|
|
176
|
+
response.end();
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
const gateway = ctx.get("typertGateway") as unknown as GatewaySurface | undefined;
|
|
180
|
+
if (gateway === undefined) {
|
|
181
|
+
reportStreamFailure("stream failed", "typertGateway service is missing", ctx.logger);
|
|
182
|
+
response.writeHead(503);
|
|
183
|
+
response.end("gateway unavailable");
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
// 上游 0.2.1-alpha.4 起 `wireStream.open` 的 peer 必填:桌面这条流代表的就是浏览器那个 owner Peer
|
|
187
|
+
// (认证已被 `takeOverDesktopAuthentication` 放行,见上)。
|
|
188
|
+
const peer = (ctx.get("connection") as unknown as BrowserAuthSurface | undefined)?.browserPeer;
|
|
189
|
+
if (peer === undefined) {
|
|
190
|
+
reportStreamFailure("stream failed", "connection service is missing", ctx.logger);
|
|
191
|
+
response.writeHead(503);
|
|
192
|
+
response.end("connection unavailable");
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
const endpointFromQuery = new URL(request.url ?? "/", "http://127.0.0.1").searchParams.get(
|
|
196
|
+
"endpoint",
|
|
197
|
+
);
|
|
198
|
+
// 首行定流,其余行是上行项:`frames` 剩下的部分就是交给 Gateway 的 uplink。
|
|
199
|
+
const frames = readStreamFrames(request);
|
|
200
|
+
let opening: StreamOpening;
|
|
201
|
+
try {
|
|
202
|
+
const first = await frames.next();
|
|
203
|
+
opening = first.done
|
|
204
|
+
? parseStreamOpening(
|
|
205
|
+
endpointFromQuery === null ? undefined : { endpoint: endpointFromQuery },
|
|
206
|
+
String(request.url),
|
|
207
|
+
)
|
|
208
|
+
: parseStreamOpening(first.value, String(request.url));
|
|
209
|
+
} catch (error) {
|
|
210
|
+
reportStreamFailure("stream body is not a valid opener", error, ctx.logger);
|
|
211
|
+
response.writeHead(400);
|
|
212
|
+
response.end("invalid stream request");
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
const endpoint = opening.endpoint;
|
|
216
|
+
const abort = new AbortController();
|
|
217
|
+
const cancel = (): void => {
|
|
218
|
+
abort.abort();
|
|
219
|
+
};
|
|
220
|
+
request.once("aborted", cancel);
|
|
221
|
+
response.once("close", cancel);
|
|
222
|
+
try {
|
|
223
|
+
ctx.logger.debug(`[dsh-desktop] stream opening ${endpoint}`);
|
|
224
|
+
// 上游契约是五参:`uplink` 是「客户端 → 宿主」的逻辑流输入,`$events` 那类 Gateway 自己的流
|
|
225
|
+
// 会被上游立刻释放(`releaseUplink`)。
|
|
226
|
+
const values = await gateway.wireStream.open(
|
|
227
|
+
endpoint,
|
|
228
|
+
opening.payload,
|
|
229
|
+
frames,
|
|
230
|
+
peer,
|
|
231
|
+
abort.signal,
|
|
232
|
+
);
|
|
233
|
+
ctx.logger.debug(`[dsh-desktop] stream opened ${endpoint}`);
|
|
234
|
+
response.writeHead(200, {
|
|
235
|
+
"content-type": "application/x-ndjson",
|
|
236
|
+
"cache-control": "no-store",
|
|
237
|
+
});
|
|
238
|
+
desktopStreamStats.opened += 1;
|
|
239
|
+
desktopStreamStats.active += 1;
|
|
240
|
+
try {
|
|
241
|
+
for await (const value of values) {
|
|
242
|
+
// 尊重下游背压:`write()` 返回 false 就是这一侧的缓冲满了,必须等 `drain` 再继续拉上游。
|
|
243
|
+
// 不等就是「客户端丢下这条流、上游还在产」时的无界缓冲——侧栏反复重试那条循环实测
|
|
244
|
+
// 4MB/s 一路涨到 GB(进程崩在这里,而不是崩在业务错误上)。
|
|
245
|
+
const line = `${JSON.stringify(value)}\n`;
|
|
246
|
+
desktopStreamStats.items += 1;
|
|
247
|
+
desktopStreamStats.bytes += Buffer.byteLength(line);
|
|
248
|
+
if (!response.write(line)) {
|
|
249
|
+
await Promise.race([once(response, "drain"), once(response, "close")]).catch(
|
|
250
|
+
() => undefined,
|
|
251
|
+
);
|
|
252
|
+
if (response.destroyed) return;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
response.end();
|
|
256
|
+
} finally {
|
|
257
|
+
desktopStreamStats.active -= 1;
|
|
258
|
+
}
|
|
259
|
+
} catch (error) {
|
|
260
|
+
desktopStreamStats.failed += 1;
|
|
261
|
+
reportStreamFailure(`stream ${endpoint} failed`, error, ctx.logger);
|
|
262
|
+
if (response.headersSent) {
|
|
263
|
+
response.destroy(error instanceof Error ? error : new Error(String(error)));
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
response.writeHead(502);
|
|
267
|
+
response.end(error instanceof Error ? error.message : String(error));
|
|
268
|
+
}
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// 注入 transport 行并注册 Gateway 流路由;随插件 fiber 一起撤销。
|
|
273
|
+
export function installDesktopTransport(ctx: Context): void {
|
|
274
|
+
ctx.on("webserver/index-inject", (table) => {
|
|
275
|
+
table.push({ kind: "script", placement: "head", text: DESKTOP_TRANSPORT_SCRIPT });
|
|
276
|
+
});
|
|
277
|
+
ctx.effect(
|
|
278
|
+
() =>
|
|
279
|
+
ctx.webServer.register({
|
|
280
|
+
kind: "exact",
|
|
281
|
+
path: DESKTOP_STREAM_PATH,
|
|
282
|
+
handler: streamHandler(ctx),
|
|
283
|
+
}),
|
|
284
|
+
"dsh-desktop: remote stream route",
|
|
285
|
+
);
|
|
286
|
+
}
|
package/src/web.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// dev 形态的 web 启动入口(不进包的 `files`:只有 `dev --web` 用它)。
|
|
2
|
+
//
|
|
3
|
+
// 它做的事只有一件:把 **profile root 当 profile 启一次 web 形态**——装配走 `@morlay/dsh-app-boot`,与桌面
|
|
4
|
+
// 宿主同一份;`@deepseek-ai/dsh-web-app` 那套行(端口、认证、打印 URL、开浏览器)由 profile 的
|
|
5
|
+
// bundle 层提供,和上游 `dsh --profile web` 完全同一套。
|
|
6
|
+
//
|
|
7
|
+
// argv 与桌面宿主**同一套**(见 `./invocation.ts`):`--profile-dir <dir> --runtime-dir <dir>
|
|
8
|
+
// [--patch <file>]... [-- <web app 的内层参数>...]`;端口契约与上游一致:`PORT` 环境变量定缺省
|
|
9
|
+
// (3080),内层里显式 `--port` 以它为准,`--no-open` 等 flag 原样交给 web app 的 flag family。
|
|
10
|
+
|
|
11
|
+
import { bootAppProfile } from "@morlay/dsh-app-boot";
|
|
12
|
+
import { parseHostInvocation } from "./invocation.ts";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* 缺省端口:`0` = 向系统要一个。
|
|
16
|
+
*
|
|
17
|
+
* 不跟着上游用 3080 写死——本机上跑第二个实例、或别的工具占了它,web 形态就只能靠"改 `PORT`"才起得来。
|
|
18
|
+
* 实际端口看 web app 自己打的那行 `➜ Local: http://127.0.0.1:<port>/`(浏览器也由它打开)。
|
|
19
|
+
*/
|
|
20
|
+
export const DEFAULT_WEB_PORT = "0";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* 把缺省端口补进内层参数:web app 的 flag family 自己解析 `--port`,所以这里只在内层没给
|
|
24
|
+
* `--port` 时补一个。
|
|
25
|
+
* @param inner - `--` 之后的内层参数。
|
|
26
|
+
* @param port - `PORT` 环境变量给出的端口。
|
|
27
|
+
* @returns 交给树的内层参数。
|
|
28
|
+
*/
|
|
29
|
+
export function webArgs(inner: readonly string[], port: string): readonly string[] {
|
|
30
|
+
return inner.includes("--port") ? [...inner] : ["--port", port, ...inner];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function main(): Promise<void> {
|
|
34
|
+
const invocation = parseHostInvocation("dsh web", process.argv.slice(2));
|
|
35
|
+
const port = process.env.PORT ?? DEFAULT_WEB_PORT;
|
|
36
|
+
const { ctx } = await bootAppProfile({
|
|
37
|
+
profileDir: invocation.profileDir,
|
|
38
|
+
profile: "web",
|
|
39
|
+
patchFiles: invocation.patches,
|
|
40
|
+
args: webArgs(invocation.inner, port),
|
|
41
|
+
});
|
|
42
|
+
// 这行在**树起来之后**打:名字交给这条树的 logger(不自己拼 `[dsh-web]` 前缀),exporter 拿到的是字段。
|
|
43
|
+
ctx.logger.info(
|
|
44
|
+
"profile root=%s runtime=%s port=%s",
|
|
45
|
+
invocation.profileDir,
|
|
46
|
+
invocation.runtimeDir,
|
|
47
|
+
port,
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (import.meta.main) {
|
|
52
|
+
main().catch((error: unknown) => {
|
|
53
|
+
// 树没起来(或已经在拆)时只能用 console:这里没有 `ctx.logger` 可用。
|
|
54
|
+
console.error(error);
|
|
55
|
+
process.exitCode = 1;
|
|
56
|
+
});
|
|
57
|
+
}
|