@lyhue1991/dsh-soup 0.4.7 → 0.4.9
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/lib/client.js +2976 -2873
- package/lib/index.js +651 -520
- package/package.json +1 -8
- package/lib/client.js.map +0 -7
package/lib/index.js
CHANGED
|
@@ -1,678 +1,809 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
1
|
+
/**
|
|
2
|
+
* dsh-soup — 宿主半区:资源管理器的文件能力供给器。
|
|
3
|
+
*
|
|
4
|
+
* 通过 `webServer` 注册一个同源 HTTP 路由 `/api/dsh-soup`(POST JSON),
|
|
5
|
+
* 为浏览器半区提供对文件系统的只具名操作:列目录、系统打开、移到废纸篓、
|
|
6
|
+
* 移动/重命名、新建、上传。永久插件(profile bundle)不经过动态 runner,
|
|
7
|
+
* 因此不依赖 dynamic 半区的 `harness.handle`/`host.call`,而是走
|
|
8
|
+
* 宿主 HTTP 路由 + 浏览器 `fetch` 的规范桥梁。
|
|
9
|
+
*
|
|
10
|
+
* 跨平台:move/create/upload 直接用 `node:fs/promises`(mac/linux/win 通用),
|
|
11
|
+
* 仅 open/trash 这类"唤起系统"的动作按 `process.platform` 分支选命令。
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { rename, mkdir, writeFile, appendFile, open as openFile, stat, realpath } from 'node:fs/promises'
|
|
15
|
+
import { createReadStream } from 'node:fs'
|
|
16
|
+
import { randomBytes } from 'node:crypto'
|
|
17
|
+
import path from 'node:path'
|
|
18
|
+
|
|
19
|
+
/** 稳定插件名(与 cordis.patch.yml 的 insert id 一致)。 */
|
|
20
|
+
export const name = 'ui-dsh-soup'
|
|
21
|
+
|
|
22
|
+
/** 注入的宿主服务。 */
|
|
23
|
+
export const inject = ['webServer', 'fs', 'subprocess', 'sandboxPolicy', 'sessions', 'timer']
|
|
24
|
+
|
|
25
|
+
/** 当前运行平台:'mac' | 'linux' | 'win'(其余回退 mac 逻辑)。 */
|
|
26
|
+
const PLATFORM = process.platform === 'win32' ? 'win'
|
|
27
|
+
: process.platform === 'darwin' ? 'mac'
|
|
28
|
+
: process.platform === 'linux' ? 'linux' : 'mac'
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* 读取一次请求体为 JSON 对象(损坏或不存在的 body 返回空对象)。
|
|
32
|
+
* @param req - node:http IncomingMessage。
|
|
33
|
+
*/
|
|
34
|
+
function readBody(req) {
|
|
35
|
+
return new Promise((resolve, reject) => {
|
|
36
|
+
const chunks = []
|
|
37
|
+
req.on('data', (chunk) => chunks.push(chunk))
|
|
38
|
+
req.on('end', () => {
|
|
39
|
+
const text = Buffer.concat(chunks).toString('utf8')
|
|
40
|
+
if (!text) { resolve({}); return }
|
|
41
|
+
try { resolve(JSON.parse(text)) } catch { resolve({}) }
|
|
42
|
+
})
|
|
43
|
+
req.on('error', reject)
|
|
44
|
+
})
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** 写一个 JSON 响应。 */
|
|
48
|
+
function writeJson(res, code, data) {
|
|
49
|
+
const body = JSON.stringify(data)
|
|
50
|
+
res.writeHead(code, { 'content-type': 'application/json; charset=utf-8' })
|
|
51
|
+
res.end(body)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* 应用宿主半区:注册文件操作路由。
|
|
56
|
+
* @param ctx - cordis 宿主上下文。
|
|
57
|
+
*/
|
|
58
|
+
export function apply(ctx) {
|
|
59
|
+
const fs = ctx.fs
|
|
60
|
+
const subprocess = ctx.subprocess
|
|
61
|
+
const sessions = ctx.sessions
|
|
62
|
+
const root = (ctx.sandboxPolicy && ctx.sandboxPolicy.workspaceRoot) || '/'
|
|
63
|
+
|
|
64
|
+
// ------------------------------------------------------------------
|
|
65
|
+
// 路径围栏:除 list 走沙箱 fs 外,其余具名文件操作(read/move/
|
|
66
|
+
// trash/open/create/upload)一律先过 confine(),只允许落在
|
|
67
|
+
// 「workspace 根 ∪ 所有已知会话 cwd」的子树内。解析走 realpath,
|
|
68
|
+
// 防符号链接逃逸;容忍目标末级尚不存在(新建/上传/移动目标)。
|
|
69
|
+
// 已知残留:confine 与实际操作之间存在 TOCTOU 窗口(符号链接竞态),
|
|
70
|
+
// Node 可移植 API 下无法根除,见 README 安全模型。
|
|
71
|
+
// ------------------------------------------------------------------
|
|
72
|
+
/** win 文件系统大小写不敏感,比较时折叠大小写。 */
|
|
73
|
+
const FOLD_CASE = PLATFORM === 'win'
|
|
74
|
+
|
|
75
|
+
/** 带 HTTP 状态码的错误对象(handler 据此写响应码)。 */
|
|
76
|
+
function httpError(statusCode, message) {
|
|
77
|
+
return Object.assign(new Error(message), { statusCode })
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** 容忍末级不存在的 realpath:向上找最近一个已存在的祖先再拼回剩余段。 */
|
|
81
|
+
async function realPathLenient(p) {
|
|
82
|
+
let cur = path.resolve(String(p))
|
|
83
|
+
const tail = []
|
|
84
|
+
for (;;) {
|
|
85
|
+
try {
|
|
86
|
+
const rp = await realpath(cur)
|
|
87
|
+
return tail.length ? path.join(rp, ...tail) : rp
|
|
88
|
+
} catch (err) {
|
|
89
|
+
const parent = path.dirname(cur)
|
|
90
|
+
if (!err || err.code !== 'ENOENT' || parent === cur) throw err
|
|
91
|
+
tail.unshift(path.basename(cur))
|
|
92
|
+
cur = parent
|
|
93
|
+
}
|
|
11
94
|
}
|
|
12
|
-
return cjk + rest / 4;
|
|
13
95
|
}
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
96
|
+
|
|
97
|
+
let rootRealCache = null
|
|
98
|
+
async function rootReal() {
|
|
99
|
+
if (rootRealCache === null) {
|
|
100
|
+
try { rootRealCache = await realpath(root) } catch { rootRealCache = path.resolve(root) }
|
|
101
|
+
}
|
|
102
|
+
return rootRealCache
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** candidate 是否位于 base 子树内(含 base 本身)。 */
|
|
106
|
+
function within(base, candidate) {
|
|
107
|
+
const b = FOLD_CASE ? base.toLowerCase() : base
|
|
108
|
+
const c = FOLD_CASE ? candidate.toLowerCase() : candidate
|
|
109
|
+
if (c === b) return true
|
|
110
|
+
const rel = path.relative(b, c)
|
|
111
|
+
return rel !== '' && rel !== '..' && !rel.startsWith('..' + path.sep) && !path.isAbsolute(rel)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** 允许的基目录集合:workspace 根 + 所有已知会话的 cwd(尽力而为)。 */
|
|
115
|
+
async function allowedBases(preferredSessionId) {
|
|
116
|
+
const bases = [await rootReal()]
|
|
117
|
+
const pushCwd = async (cwd) => {
|
|
118
|
+
if (typeof cwd === 'string' && cwd) {
|
|
119
|
+
try { bases.push(await realPathLenient(cwd)) } catch { /* 会话目录不可达则跳过 */ }
|
|
25
120
|
}
|
|
26
|
-
|
|
121
|
+
}
|
|
122
|
+
// 指定会话优先直查:新会话刚创建时 list 枚举可能尚未纳入其 cwd。
|
|
123
|
+
if (preferredSessionId && typeof sessions.get === 'function') {
|
|
124
|
+
try {
|
|
125
|
+
const pref = sessions.get(preferredSessionId)
|
|
126
|
+
await pushCwd(pref && pref.header && pref.header.cwd)
|
|
127
|
+
} catch { /* ignore */ }
|
|
128
|
+
}
|
|
129
|
+
try {
|
|
130
|
+
const list = typeof sessions.list === 'function' ? sessions.list() : []
|
|
131
|
+
for (const s of list) await pushCwd(s && s.header && s.header.cwd)
|
|
132
|
+
} catch { /* 宿主无 list API 时仅用 workspace 根 */ }
|
|
133
|
+
return bases
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* 围栏校验:解析真实路径并确认落在允许基目录集合内。
|
|
138
|
+
* 通过则返回规范化真实路径;越界/不可达抛带 statusCode 的错误。
|
|
139
|
+
*/
|
|
140
|
+
async function confine(rawPath, label, preferredSessionId) {
|
|
141
|
+
const name = label || '路径'
|
|
142
|
+
const p = String(rawPath || '')
|
|
143
|
+
if (!p) throw httpError(400, `缺少${name}`)
|
|
144
|
+
let rp
|
|
145
|
+
try {
|
|
146
|
+
rp = await realPathLenient(p)
|
|
27
147
|
} catch (err) {
|
|
148
|
+
throw httpError(400, `${name}不可达: ${p}`)
|
|
28
149
|
}
|
|
29
|
-
|
|
150
|
+
for (const base of await allowedBases(preferredSessionId)) {
|
|
151
|
+
if (within(base, rp)) return rp
|
|
152
|
+
}
|
|
153
|
+
throw httpError(403, `${name}超出允许范围(仅限工作区与会话目录): ${p}`)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// ------------------------------------------------------------------
|
|
157
|
+
// 速度徽标:包 llm/stream 统计 token 吞吐(等待/流式/done 三态)
|
|
158
|
+
// ------------------------------------------------------------------
|
|
159
|
+
const CJK = /[\u3000-\u30ff\u3400-\u9fff\uf900-\ufaff\uac00-\ud7af]/
|
|
160
|
+
function estimateChars(text) {
|
|
161
|
+
let cjk = 0
|
|
162
|
+
let rest = 0
|
|
163
|
+
for (const ch of text) { if (CJK.test(ch)) cjk++; else rest++ }
|
|
164
|
+
return cjk + rest / 4
|
|
30
165
|
}
|
|
166
|
+
|
|
167
|
+
/** 会话级流状态:phase / token / tps / ttft。 */
|
|
168
|
+
const streams = new Map()
|
|
169
|
+
|
|
170
|
+
function extractKey(options) {
|
|
171
|
+
try {
|
|
172
|
+
if (!options) return null
|
|
173
|
+
if (typeof options.sessionId === 'string') return options.sessionId
|
|
174
|
+
if (options.session && typeof options.session.id === 'string') return options.session.id
|
|
175
|
+
if (options.agent) {
|
|
176
|
+
const a = options.agent
|
|
177
|
+
if (typeof a.sessionId === 'string') return a.sessionId
|
|
178
|
+
if (typeof a.id === 'string') return a.id
|
|
179
|
+
if (a.session && typeof a.session.id === 'string') return a.session.id
|
|
180
|
+
}
|
|
181
|
+
if (options.meta && typeof options.meta.sessionId === 'string') return options.meta.sessionId
|
|
182
|
+
} catch (err) { /* ignore */ }
|
|
183
|
+
return null
|
|
184
|
+
}
|
|
185
|
+
|
|
31
186
|
function extractChunk(chunk) {
|
|
32
|
-
let text =
|
|
33
|
-
let realTokens = null
|
|
187
|
+
let text = ''
|
|
188
|
+
let realTokens = null
|
|
34
189
|
try {
|
|
35
|
-
if (!chunk) return { text, realTokens }
|
|
36
|
-
if (typeof chunk.text ===
|
|
37
|
-
if (typeof chunk.content ===
|
|
190
|
+
if (!chunk) return { text, realTokens }
|
|
191
|
+
if (typeof chunk.text === 'string') text += chunk.text
|
|
192
|
+
if (typeof chunk.content === 'string') text += chunk.content
|
|
38
193
|
if (Array.isArray(chunk.content)) {
|
|
39
|
-
for (const part of chunk.content) if (part && typeof part.text ===
|
|
194
|
+
for (const part of chunk.content) if (part && typeof part.text === 'string') text += part.text
|
|
40
195
|
}
|
|
41
196
|
if (chunk.delta) {
|
|
42
|
-
const d = chunk.delta
|
|
43
|
-
if (typeof d.text ===
|
|
44
|
-
if (typeof d.content ===
|
|
197
|
+
const d = chunk.delta
|
|
198
|
+
if (typeof d.text === 'string') text += d.text
|
|
199
|
+
if (typeof d.content === 'string') text += d.content
|
|
45
200
|
if (d.usage) {
|
|
46
|
-
const ot = d.usage.outputTokens != null ? d.usage.outputTokens
|
|
47
|
-
|
|
201
|
+
const ot = d.usage.outputTokens != null ? d.usage.outputTokens
|
|
202
|
+
: (d.usage.output_tokens != null ? d.usage.output_tokens
|
|
203
|
+
: (d.usage.completion_tokens != null ? d.usage.completion_tokens : d.usage.completionTokens))
|
|
204
|
+
if (typeof ot === 'number') realTokens = ot
|
|
48
205
|
}
|
|
49
206
|
}
|
|
50
207
|
if (chunk.usage) {
|
|
51
|
-
const u = chunk.usage
|
|
52
|
-
const ot = u.outputTokens != null ? u.outputTokens
|
|
53
|
-
|
|
208
|
+
const u = chunk.usage
|
|
209
|
+
const ot = u.outputTokens != null ? u.outputTokens
|
|
210
|
+
: (u.output_tokens != null ? u.output_tokens
|
|
211
|
+
: (u.completion_tokens != null ? u.completion_tokens : u.completionTokens))
|
|
212
|
+
if (typeof ot === 'number') realTokens = ot
|
|
54
213
|
}
|
|
55
214
|
if (Array.isArray(chunk.choices)) {
|
|
56
215
|
for (const c of chunk.choices) {
|
|
57
|
-
const d = c && c.delta
|
|
58
|
-
if (d && typeof d.content ===
|
|
216
|
+
const d = c && c.delta
|
|
217
|
+
if (d && typeof d.content === 'string') text += d.content
|
|
59
218
|
if (d && d.usage) {
|
|
60
|
-
const ot = d.usage.outputTokens != null ? d.usage.outputTokens : d.usage.output_tokens
|
|
61
|
-
if (typeof ot ===
|
|
219
|
+
const ot = d.usage.outputTokens != null ? d.usage.outputTokens : d.usage.output_tokens
|
|
220
|
+
if (typeof ot === 'number') realTokens = ot
|
|
62
221
|
}
|
|
63
222
|
}
|
|
64
223
|
}
|
|
65
|
-
} catch (err) {
|
|
66
|
-
}
|
|
67
|
-
return { text, realTokens };
|
|
224
|
+
} catch (err) { /* ignore */ }
|
|
225
|
+
return { text, realTokens }
|
|
68
226
|
}
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
const
|
|
72
|
-
|
|
73
|
-
|
|
227
|
+
|
|
228
|
+
ctx.on('llm/stream', (options, next) => {
|
|
229
|
+
const key = extractKey(options) || '_'
|
|
230
|
+
const now = Date.now()
|
|
231
|
+
streams.set(key, { phase: 'waiting', startedAt: now, firstChunkAt: null, charTokens: 0, realTokens: 0, hasReal: false, lastChunkAt: null, lastContentAt: null, samples: [], lastSeen: now })
|
|
232
|
+
const innerP = Promise.resolve(next())
|
|
74
233
|
return (async function* () {
|
|
75
234
|
try {
|
|
76
|
-
const inner = await innerP
|
|
235
|
+
const inner = await innerP
|
|
77
236
|
for await (const chunk of inner) {
|
|
78
|
-
const st = streams.get(key)
|
|
237
|
+
const st = streams.get(key)
|
|
79
238
|
if (st) {
|
|
80
|
-
const parsed = extractChunk(chunk)
|
|
81
|
-
const t = Date.now()
|
|
82
|
-
if (st.firstChunkAt === null) {
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
st.
|
|
92
|
-
|
|
93
|
-
st.samples.push([t, st.hasReal ? st.realTokens : st.charTokens]);
|
|
94
|
-
if (st.samples.length > 128) st.samples.splice(0, st.samples.length - 128);
|
|
95
|
-
st.lastSeen = t;
|
|
239
|
+
const parsed = extractChunk(chunk)
|
|
240
|
+
const t = Date.now()
|
|
241
|
+
if (st.firstChunkAt === null) { st.firstChunkAt = t; st.phase = 'streaming' }
|
|
242
|
+
if (parsed.text) st.charTokens += estimateChars(parsed.text)
|
|
243
|
+
if (parsed.realTokens != null) { st.realTokens = parsed.realTokens; st.hasReal = true }
|
|
244
|
+
st.lastChunkAt = t
|
|
245
|
+
// 只有携带真实内容的 chunk 才算「有进展」:keepalive / 空 delta
|
|
246
|
+
// 不刷新内容时钟,避免等待期被空包滴漏伪装成慢速流式。
|
|
247
|
+
if ((parsed.text && parsed.text.length > 0) || parsed.realTokens != null) st.lastContentAt = t
|
|
248
|
+
// 滑动窗口采样:[时刻, 累计 token],用于算瞬时速率而非全程平均
|
|
249
|
+
st.samples.push([t, st.hasReal ? st.realTokens : st.charTokens])
|
|
250
|
+
if (st.samples.length > 128) st.samples.splice(0, st.samples.length - 128)
|
|
251
|
+
st.lastSeen = t
|
|
96
252
|
}
|
|
97
|
-
yield chunk
|
|
253
|
+
yield chunk
|
|
98
254
|
}
|
|
99
255
|
} finally {
|
|
100
|
-
const st = streams.get(key)
|
|
256
|
+
const st = streams.get(key)
|
|
101
257
|
if (st) {
|
|
102
|
-
st.phase =
|
|
103
|
-
st.lastSeen = Date.now()
|
|
258
|
+
st.phase = 'done'
|
|
259
|
+
st.lastSeen = Date.now()
|
|
104
260
|
}
|
|
105
261
|
}
|
|
106
|
-
})()
|
|
107
|
-
})
|
|
262
|
+
})()
|
|
263
|
+
})
|
|
264
|
+
|
|
108
265
|
const stopReap = ctx.timer.interval(() => {
|
|
109
|
-
const now = Date.now()
|
|
266
|
+
const now = Date.now()
|
|
110
267
|
for (const [k, st] of streams) {
|
|
111
|
-
if (st.phase ===
|
|
268
|
+
if (st.phase === 'done' && now - st.lastSeen > 4000) streams.delete(k)
|
|
112
269
|
}
|
|
113
|
-
},
|
|
114
|
-
ctx.effect(stopReap,
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
const
|
|
118
|
-
const
|
|
119
|
-
const
|
|
120
|
-
|
|
121
|
-
|
|
270
|
+
}, 1000)
|
|
271
|
+
ctx.effect(stopReap, 'dsh-soup: speed stream reap')
|
|
272
|
+
|
|
273
|
+
/** 瞬时速率窗口 / 最小统计跨度 / 停顿判定阈值。 */
|
|
274
|
+
const RATE_WINDOW_MS = 2000
|
|
275
|
+
const RATE_MIN_SPAN_MS = 500
|
|
276
|
+
const STALL_MS = 2000
|
|
277
|
+
/** 真实生成下限:低于此速率必然是等待被误标(DeepSeek 正常输出远高于此)。 */
|
|
278
|
+
const MIN_REAL_TPS = 3
|
|
279
|
+
/** 速率分母宽限:内容停止后分母最多延伸这么久,静默期不稀释速率。 */
|
|
280
|
+
const SPAN_GRACE_MS = 500
|
|
281
|
+
|
|
282
|
+
/** 返回当前会话的速度状态(无记录时回退到最近活跃流)。 */
|
|
283
|
+
function speedStatus(sid) {
|
|
284
|
+
let st = streams.get(sid)
|
|
122
285
|
if (!st) {
|
|
123
|
-
let latest = null
|
|
124
|
-
for (const s of streams.values()) if (!latest || s.lastSeen > latest.lastSeen) latest = s
|
|
125
|
-
st = latest
|
|
286
|
+
let latest = null
|
|
287
|
+
for (const s of streams.values()) if (!latest || s.lastSeen > latest.lastSeen) latest = s
|
|
288
|
+
st = latest
|
|
126
289
|
}
|
|
127
|
-
if (!st) return { phase:
|
|
128
|
-
const now = Date.now()
|
|
129
|
-
const tokens = st.hasReal ? st.realTokens : Math.round(st.charTokens)
|
|
130
|
-
const ttft = st.firstChunkAt ? st.firstChunkAt - st.startedAt : null
|
|
131
|
-
if (st.phase !==
|
|
132
|
-
return { phase: st.phase, tokens, tps: 0, ttft }
|
|
290
|
+
if (!st) return { phase: 'idle' }
|
|
291
|
+
const now = Date.now()
|
|
292
|
+
const tokens = st.hasReal ? st.realTokens : Math.round(st.charTokens)
|
|
293
|
+
const ttft = st.firstChunkAt ? st.firstChunkAt - st.startedAt : null
|
|
294
|
+
if (st.phase !== 'streaming' || !st.firstChunkAt) {
|
|
295
|
+
return { phase: st.phase, tokens, tps: 0, ttft }
|
|
133
296
|
}
|
|
134
|
-
|
|
135
|
-
const
|
|
297
|
+
// 停顿按「内容时钟」判定:keepalive / 空 delta 滴漏不算活动。
|
|
298
|
+
const contentAt = st.lastContentAt || st.firstChunkAt
|
|
299
|
+
const sinceContent = now - contentAt
|
|
136
300
|
if (sinceContent > STALL_MS) {
|
|
137
|
-
return { phase:
|
|
301
|
+
return { phase: 'waiting', tokens, tps: 0, ttft }
|
|
138
302
|
}
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
303
|
+
// 滑动窗口瞬时速率:基线优先取窗口前最后一个样本(burst 场景也能算出增量),
|
|
304
|
+
// 否则取窗口内第一个样本。
|
|
305
|
+
const cutoff = now - RATE_WINDOW_MS
|
|
306
|
+
let base = null
|
|
307
|
+
let first = null
|
|
142
308
|
for (const s of st.samples) {
|
|
143
|
-
if (first === null) first = s
|
|
144
|
-
if (s[0] < cutoff) base = s
|
|
309
|
+
if (first === null) first = s
|
|
310
|
+
if (s[0] < cutoff) base = s
|
|
145
311
|
}
|
|
146
|
-
if (base === null) base = first
|
|
147
|
-
let tps = 0
|
|
312
|
+
if (base === null) base = first
|
|
313
|
+
let tps = 0
|
|
148
314
|
if (base && now - base[0] >= RATE_MIN_SPAN_MS) {
|
|
149
|
-
|
|
315
|
+
// 分母封顶到最后内容时刻 + 宽限:静默期不再把速率摊薄成 0.x t/s。
|
|
316
|
+
const spanEnd = Math.min(now, contentAt + SPAN_GRACE_MS)
|
|
150
317
|
if (spanEnd - base[0] >= RATE_MIN_SPAN_MS) {
|
|
151
|
-
tps = Math.max(0, (tokens - base[1]) / ((spanEnd - base[0]) /
|
|
318
|
+
tps = Math.max(0, (tokens - base[1]) / ((spanEnd - base[0]) / 1000))
|
|
152
319
|
}
|
|
153
320
|
}
|
|
321
|
+
// 近零进展护栏:内容已停顿超过最小跨度时,任何低于真实生成下限的读数
|
|
322
|
+
// (含 0 与负增量截断)都判回等待——0.3 t/s 必然是等待,不是极慢生成。
|
|
154
323
|
if (sinceContent >= RATE_MIN_SPAN_MS && tps < MIN_REAL_TPS) {
|
|
155
|
-
return { phase:
|
|
324
|
+
return { phase: 'waiting', tokens, tps: 0, ttft }
|
|
156
325
|
}
|
|
157
|
-
return { phase:
|
|
326
|
+
return { phase: 'streaming', tokens, tps, ttft }
|
|
158
327
|
}
|
|
159
|
-
return { speedStatus };
|
|
160
|
-
}
|
|
161
328
|
|
|
162
|
-
// src/host/files.ts
|
|
163
|
-
import { rename, mkdir, writeFile, appendFile, open as openFile, stat } from "node:fs/promises";
|
|
164
|
-
import { createReadStream } from "node:fs";
|
|
165
|
-
import { randomBytes } from "node:crypto";
|
|
166
|
-
import path2 from "node:path";
|
|
167
|
-
|
|
168
|
-
// src/host/confine.ts
|
|
169
|
-
import { realpath } from "node:fs/promises";
|
|
170
|
-
import path from "node:path";
|
|
171
|
-
function createConfine(deps) {
|
|
172
|
-
const { root, sessions } = deps;
|
|
173
|
-
const FOLD_CASE = deps.PLATFORM === "win";
|
|
174
|
-
function httpError(statusCode, message) {
|
|
175
|
-
return Object.assign(new Error(message), { statusCode });
|
|
176
|
-
}
|
|
177
|
-
async function realPathLenient(p) {
|
|
178
|
-
let cur = path.resolve(String(p));
|
|
179
|
-
const tail = [];
|
|
180
|
-
for (; ; ) {
|
|
181
|
-
try {
|
|
182
|
-
const rp = await realpath(cur);
|
|
183
|
-
return tail.length ? path.join(rp, ...tail) : rp;
|
|
184
|
-
} catch (err) {
|
|
185
|
-
const parent = path.dirname(cur);
|
|
186
|
-
if (!err || err.code !== "ENOENT" || parent === cur) throw err;
|
|
187
|
-
tail.unshift(path.basename(cur));
|
|
188
|
-
cur = parent;
|
|
189
|
-
}
|
|
190
|
-
}
|
|
191
|
-
}
|
|
192
|
-
let rootRealCache = null;
|
|
193
|
-
async function rootReal() {
|
|
194
|
-
if (rootRealCache === null) {
|
|
195
|
-
try {
|
|
196
|
-
rootRealCache = await realpath(root);
|
|
197
|
-
} catch {
|
|
198
|
-
rootRealCache = path.resolve(root);
|
|
199
|
-
}
|
|
200
|
-
}
|
|
201
|
-
return rootRealCache;
|
|
202
|
-
}
|
|
203
|
-
function within(base, candidate) {
|
|
204
|
-
const b = FOLD_CASE ? base.toLowerCase() : base;
|
|
205
|
-
const c = FOLD_CASE ? candidate.toLowerCase() : candidate;
|
|
206
|
-
if (c === b) return true;
|
|
207
|
-
const rel = path.relative(b, c);
|
|
208
|
-
return rel !== "" && rel !== ".." && !rel.startsWith(".." + path.sep) && !path.isAbsolute(rel);
|
|
209
|
-
}
|
|
210
|
-
async function allowedBases(preferredSessionId) {
|
|
211
|
-
const bases = [await rootReal()];
|
|
212
|
-
const pushCwd = async (cwd) => {
|
|
213
|
-
if (typeof cwd === "string" && cwd) {
|
|
214
|
-
try {
|
|
215
|
-
bases.push(await realPathLenient(cwd));
|
|
216
|
-
} catch {
|
|
217
|
-
}
|
|
218
|
-
}
|
|
219
|
-
};
|
|
220
|
-
if (preferredSessionId && typeof sessions.get === "function") {
|
|
221
|
-
try {
|
|
222
|
-
const pref = sessions.get(preferredSessionId);
|
|
223
|
-
await pushCwd(pref && pref.header && pref.header.cwd);
|
|
224
|
-
} catch {
|
|
225
|
-
}
|
|
226
|
-
}
|
|
227
|
-
try {
|
|
228
|
-
const list = typeof sessions.list === "function" ? sessions.list() : [];
|
|
229
|
-
for (const s of list) await pushCwd(s && s.header && s.header.cwd);
|
|
230
|
-
} catch {
|
|
231
|
-
}
|
|
232
|
-
return bases;
|
|
233
|
-
}
|
|
234
|
-
async function confine(rawPath, label, preferredSessionId) {
|
|
235
|
-
const name2 = label || "\u8DEF\u5F84";
|
|
236
|
-
const p = String(rawPath || "");
|
|
237
|
-
if (!p) throw httpError(400, `\u7F3A\u5C11${name2}`);
|
|
238
|
-
let rp;
|
|
239
|
-
try {
|
|
240
|
-
rp = await realPathLenient(p);
|
|
241
|
-
} catch (err) {
|
|
242
|
-
throw httpError(400, `${name2}\u4E0D\u53EF\u8FBE: ${p}`);
|
|
243
|
-
}
|
|
244
|
-
for (const base of await allowedBases(preferredSessionId)) {
|
|
245
|
-
if (within(base, rp)) return rp;
|
|
246
|
-
}
|
|
247
|
-
throw httpError(403, `${name2}\u8D85\u51FA\u5141\u8BB8\u8303\u56F4\uFF08\u4EC5\u9650\u5DE5\u4F5C\u533A\u4E0E\u4F1A\u8BDD\u76EE\u5F55\uFF09: ${p}`);
|
|
248
|
-
}
|
|
249
|
-
return { confine, httpError, realPathLenient };
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
// src/host/files.ts
|
|
253
|
-
function readBody(req) {
|
|
254
|
-
return new Promise((resolve, reject) => {
|
|
255
|
-
const chunks = [];
|
|
256
|
-
req.on("data", (chunk) => chunks.push(chunk));
|
|
257
|
-
req.on("end", () => {
|
|
258
|
-
const text = Buffer.concat(chunks).toString("utf8");
|
|
259
|
-
if (!text) {
|
|
260
|
-
resolve({});
|
|
261
|
-
return;
|
|
262
|
-
}
|
|
263
|
-
try {
|
|
264
|
-
resolve(JSON.parse(text));
|
|
265
|
-
} catch {
|
|
266
|
-
resolve({});
|
|
267
|
-
}
|
|
268
|
-
});
|
|
269
|
-
req.on("error", reject);
|
|
270
|
-
});
|
|
271
|
-
}
|
|
272
|
-
function writeJson(res, code, data) {
|
|
273
|
-
const body = JSON.stringify(data);
|
|
274
|
-
res.writeHead(code, { "content-type": "application/json; charset=utf-8" });
|
|
275
|
-
res.end(body);
|
|
276
|
-
}
|
|
277
|
-
function setupFiles(deps) {
|
|
278
|
-
const { ctx, root, PLATFORM: PLATFORM2 } = deps;
|
|
279
|
-
const fs = ctx.fs;
|
|
280
|
-
const subprocess = ctx.subprocess;
|
|
281
|
-
const sessions = ctx.sessions;
|
|
282
|
-
const { confine, httpError } = createConfine({ root, PLATFORM: PLATFORM2, sessions });
|
|
283
329
|
async function runCmd(argv) {
|
|
284
330
|
try {
|
|
285
331
|
const proc = subprocess.spawn({
|
|
286
332
|
argv,
|
|
287
|
-
cwd:
|
|
288
|
-
stdio: { stdin:
|
|
289
|
-
graceMs:
|
|
290
|
-
})
|
|
291
|
-
const outcome = await proc.done
|
|
292
|
-
return { code: outcome.exitCode }
|
|
333
|
+
cwd: '/',
|
|
334
|
+
stdio: { stdin: 'ignore', stdout: 'ignore', stderr: { maxBytes: 4096 } },
|
|
335
|
+
graceMs: 6000,
|
|
336
|
+
})
|
|
337
|
+
const outcome = await proc.done
|
|
338
|
+
return { code: outcome.exitCode }
|
|
293
339
|
} catch (err) {
|
|
294
|
-
return { error: String(err && err.message || err) }
|
|
340
|
+
return { error: String((err && err.message) || err) }
|
|
295
341
|
}
|
|
296
342
|
}
|
|
297
|
-
|
|
298
|
-
|
|
343
|
+
|
|
344
|
+
/** 拼接目录与子项(统一正斜杠,兼容各平台)。 */
|
|
345
|
+
function joinPath(dir, name) {
|
|
346
|
+
return String(dir || '').replace(/[\\/]+$/, '') + '/' + name
|
|
299
347
|
}
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
if (
|
|
304
|
-
if (
|
|
305
|
-
|
|
348
|
+
|
|
349
|
+
/** 系统打开一个路径(按平台选命令)。 */
|
|
350
|
+
async function openPath(path) { const p = String(path || '')
|
|
351
|
+
if (!p) return { code: -1, error: '缺少路径' }
|
|
352
|
+
if (PLATFORM === 'win') {
|
|
353
|
+
// explorer /select 打开所在位置;单文件用 start 调默认程序
|
|
354
|
+
return runCmd(['cmd', '/c', 'start', '', p])
|
|
355
|
+
}
|
|
356
|
+
if (PLATFORM === 'linux') {
|
|
357
|
+
return runCmd(['xdg-open', p])
|
|
358
|
+
}
|
|
359
|
+
return runCmd(['/usr/bin/open', p])
|
|
306
360
|
}
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
361
|
+
|
|
362
|
+
/** 移到废纸篓(按平台选实现)。 */
|
|
363
|
+
async function trashPath(path) {
|
|
364
|
+
const p = String(path || '')
|
|
365
|
+
if (!p) return { ok: false, error: '缺少路径' }
|
|
366
|
+
if (PLATFORM === 'win') {
|
|
367
|
+
// PowerShell Shell.Application 的 NameSpace(10) = 回收站
|
|
368
|
+
const esc = String(p).replace(/'/g, "''")
|
|
369
|
+
const script = `$sh = New-Object -ComObject Shell.Application; $sh.NameSpace(10).MoveHere('${esc}'); Start-Sleep -Milliseconds 300`
|
|
370
|
+
const res = await runCmd(['powershell', '-NoProfile', '-Command', script])
|
|
371
|
+
return res.code === 0
|
|
372
|
+
? { ok: true }
|
|
373
|
+
: { ok: false, error: res.error || '移动失败', hint: '请确认 PowerShell 可用(Windows 10+ 自带)' }
|
|
315
374
|
}
|
|
316
|
-
if (
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
375
|
+
if (PLATFORM === 'linux') {
|
|
376
|
+
// 优先 gio trash(GNOME 自带),回退 trash-cli 的 trash 命令
|
|
377
|
+
const res = await runCmd(['gio', 'trash', p])
|
|
378
|
+
if (res.code === 0) return { ok: true }
|
|
379
|
+
const res2 = await runCmd(['trash', p])
|
|
380
|
+
if (res2.code === 0) return { ok: true }
|
|
381
|
+
return { ok: false, error: (res.error || res2.error) || '移动失败', hint: '请安装 gio(glib2)或 trash-cli(如: sudo apt install trash-cli)' }
|
|
322
382
|
}
|
|
323
|
-
|
|
324
|
-
const
|
|
325
|
-
const
|
|
326
|
-
|
|
383
|
+
// mac: Finder osascript
|
|
384
|
+
const esc = String(p).replace(/"/g, '\\"')
|
|
385
|
+
const script = `tell application "Finder" to delete POSIX file "${esc}"`
|
|
386
|
+
const res = await runCmd(['/usr/bin/osascript', '-e', script])
|
|
387
|
+
return res.code === 0
|
|
388
|
+
? { ok: true }
|
|
389
|
+
: { ok: false, error: res.error || '移动失败', hint: '如需移到废纸篓,请在 系统设置→隐私与安全性→自动化 中允许 DSH 控制 Finder' }
|
|
327
390
|
}
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
391
|
+
|
|
392
|
+
// ------------------------------------------------------------------
|
|
393
|
+
// 文件只读预览:read(文本/图片/二进制三态);无 write——预览-only 不提供保存
|
|
394
|
+
// ------------------------------------------------------------------
|
|
395
|
+
/** 文本读取上限(超过则截断标记 truncated)。8 MB——HTML 常含内联 base64 图片,2 MB 太紧。 */
|
|
396
|
+
const TEXT_MAX_BYTES = 8 * 1024 * 1024
|
|
397
|
+
/** 图片内联上限。 */
|
|
398
|
+
const IMAGE_MAX_BYTES = 8 * 1024 * 1024
|
|
399
|
+
/** PDF 内联上限。 */
|
|
400
|
+
const PDF_MAX_BYTES = 20 * 1024 * 1024
|
|
401
|
+
/** Notebook(.ipynb)文本上限——含图片输出的 base64,体积膨胀快,宽于普通文本。 */
|
|
402
|
+
const NB_MAX_BYTES = 20 * 1024 * 1024
|
|
403
|
+
/** 二进制嗅探窗口:头部出现 NUL 即判二进制。 */
|
|
404
|
+
const BINARY_SNIFF = 8192
|
|
405
|
+
/** 图片扩展名 → MIME。 */
|
|
333
406
|
const IMAGE_MIME = {
|
|
334
|
-
png:
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
gif: "image/gif",
|
|
338
|
-
webp: "image/webp",
|
|
339
|
-
svg: "image/svg+xml",
|
|
340
|
-
bmp: "image/bmp",
|
|
341
|
-
ico: "image/x-icon"
|
|
342
|
-
};
|
|
407
|
+
png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif',
|
|
408
|
+
webp: 'image/webp', svg: 'image/svg+xml', bmp: 'image/bmp', ico: 'image/x-icon',
|
|
409
|
+
}
|
|
343
410
|
function extOf(p) {
|
|
344
|
-
const m = /\.([A-Za-z0-9]+)$/.exec(String(p ||
|
|
345
|
-
return m ? m[1].toLowerCase() :
|
|
411
|
+
const m = /\.([A-Za-z0-9]+)$/.exec(String(p || ''))
|
|
412
|
+
return m ? m[1].toLowerCase() : ''
|
|
346
413
|
}
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* 读取文件供 tab 展示:图片返回 base64,文本返回内容(可截断),
|
|
417
|
+
* 二进制只返回元信息。统一走 node:fs/promises(跨平台、无 shell 注入面)。
|
|
418
|
+
*/
|
|
419
|
+
async function readFileForView(path) {
|
|
420
|
+
const p = String(path || '')
|
|
421
|
+
if (!p) return { ok: false, error: '缺少路径' }
|
|
422
|
+
let st
|
|
351
423
|
try {
|
|
352
|
-
st = await stat(
|
|
424
|
+
st = await stat(p)
|
|
353
425
|
} catch (err) {
|
|
354
|
-
return { ok: false, error:
|
|
426
|
+
return { ok: false, error: `无法读取文件: ${String((err && err.message) || err)}` }
|
|
355
427
|
}
|
|
356
|
-
if (st.isDirectory()) return { ok: false, error:
|
|
357
|
-
const ext = extOf(
|
|
428
|
+
if (st.isDirectory()) return { ok: false, error: '目标是目录' }
|
|
429
|
+
const ext = extOf(p)
|
|
358
430
|
if (IMAGE_MIME[ext]) {
|
|
359
|
-
if (st.size > IMAGE_MAX_BYTES) return { ok: true, kind:
|
|
431
|
+
if (st.size > IMAGE_MAX_BYTES) return { ok: true, kind: 'image-too-large', size: st.size, limit: IMAGE_MAX_BYTES }
|
|
360
432
|
try {
|
|
361
|
-
const fh = await openFile(
|
|
433
|
+
const fh = await openFile(p, 'r')
|
|
362
434
|
try {
|
|
363
|
-
const buf = Buffer.alloc(st.size)
|
|
364
|
-
await fh.read(buf, 0, st.size, 0)
|
|
365
|
-
return { ok: true, kind:
|
|
435
|
+
const buf = Buffer.alloc(st.size)
|
|
436
|
+
await fh.read(buf, 0, st.size, 0)
|
|
437
|
+
return { ok: true, kind: 'image', mime: IMAGE_MIME[ext], data: buf.toString('base64'), size: st.size }
|
|
366
438
|
} finally {
|
|
367
|
-
await fh.close()
|
|
439
|
+
await fh.close()
|
|
368
440
|
}
|
|
369
441
|
} catch (err) {
|
|
370
|
-
return { ok: false, error:
|
|
442
|
+
return { ok: false, error: `无法读取文件: ${String((err && err.message) || err)}` }
|
|
371
443
|
}
|
|
372
444
|
}
|
|
373
|
-
|
|
374
|
-
|
|
445
|
+
// PDF:整文件 base64 返回,交给浏览器原生查看器渲染(JupyterLab
|
|
446
|
+
// pdf-extension 同思路:b64 → Blob → object 嵌入)。超限返回元信息。
|
|
447
|
+
if (ext === 'pdf') {
|
|
448
|
+
if (st.size > PDF_MAX_BYTES) return { ok: true, kind: 'pdf-too-large', size: st.size, limit: PDF_MAX_BYTES }
|
|
375
449
|
try {
|
|
376
|
-
const fh = await openFile(
|
|
450
|
+
const fh = await openFile(p, 'r')
|
|
377
451
|
try {
|
|
378
|
-
const buf = Buffer.alloc(st.size)
|
|
379
|
-
await fh.read(buf, 0, st.size, 0)
|
|
380
|
-
return { ok: true, kind:
|
|
452
|
+
const buf = Buffer.alloc(st.size)
|
|
453
|
+
await fh.read(buf, 0, st.size, 0)
|
|
454
|
+
return { ok: true, kind: 'pdf', data: buf.toString('base64'), size: st.size }
|
|
381
455
|
} finally {
|
|
382
|
-
await fh.close()
|
|
456
|
+
await fh.close()
|
|
383
457
|
}
|
|
384
458
|
} catch (err) {
|
|
385
|
-
return { ok: false, error:
|
|
459
|
+
return { ok: false, error: `无法读取文件: ${String((err && err.message) || err)}` }
|
|
386
460
|
}
|
|
387
461
|
}
|
|
388
|
-
|
|
389
|
-
const
|
|
462
|
+
// .ipynb 含图片输出的 base64,体积膨胀快——单独放宽文本上限
|
|
463
|
+
const textCap = ext === 'ipynb' ? NB_MAX_BYTES : TEXT_MAX_BYTES
|
|
464
|
+
const want = Math.min(st.size, textCap + 1)
|
|
390
465
|
try {
|
|
391
|
-
const fh = await openFile(
|
|
392
|
-
let bytesRead
|
|
393
|
-
let buf
|
|
466
|
+
const fh = await openFile(p, 'r')
|
|
467
|
+
let bytesRead
|
|
468
|
+
let buf
|
|
394
469
|
try {
|
|
395
|
-
buf = Buffer.alloc(want)
|
|
396
|
-
const r = await fh.read(buf, 0, want, 0)
|
|
397
|
-
bytesRead = r.bytesRead
|
|
470
|
+
buf = Buffer.alloc(want)
|
|
471
|
+
const r = await fh.read(buf, 0, want, 0)
|
|
472
|
+
bytesRead = r.bytesRead
|
|
398
473
|
} finally {
|
|
399
|
-
await fh.close()
|
|
474
|
+
await fh.close()
|
|
400
475
|
}
|
|
401
|
-
const
|
|
402
|
-
let
|
|
403
|
-
|
|
404
|
-
if (head[i] === 0) {
|
|
405
|
-
isBinary = true;
|
|
406
|
-
break;
|
|
407
|
-
}
|
|
476
|
+
const sniffEnd = Math.min(bytesRead, BINARY_SNIFF)
|
|
477
|
+
for (let i = 0; i < sniffEnd; i++) {
|
|
478
|
+
if (buf[i] === 0) return { ok: true, kind: 'binary', size: st.size }
|
|
408
479
|
}
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
return { ok: true, kind: "text", content: buf.subarray(0, Math.min(bytesRead, textCap)).toString("utf-8"), truncated, size: st.size };
|
|
480
|
+
const truncated = st.size > textCap
|
|
481
|
+
return { ok: true, kind: 'text', content: buf.toString('utf8', 0, Math.min(bytesRead, textCap)), size: st.size, truncated }
|
|
412
482
|
} catch (err) {
|
|
413
|
-
return { ok: false, error:
|
|
483
|
+
return { ok: false, error: `无法读取文件: ${String((err && err.message) || err)}` }
|
|
414
484
|
}
|
|
415
485
|
}
|
|
416
|
-
|
|
486
|
+
|
|
487
|
+
// ------------------------------------------------------------------
|
|
488
|
+
// 下载票据:POST 换取一次性短时效 URL,/dl 流式路由凭票发送。
|
|
489
|
+
// 票据不可伪造 → GET 通道无需鉴权头也能保持与 POST 同级的门禁强度。
|
|
490
|
+
// ------------------------------------------------------------------
|
|
491
|
+
const downloadTickets = new Map()
|
|
417
492
|
ctx.effect(
|
|
418
493
|
() => ctx.timer.interval(() => {
|
|
419
|
-
const now = Date.now()
|
|
420
|
-
for (const [k, v] of downloadTickets) if (v.exp < now) downloadTickets.delete(k)
|
|
421
|
-
},
|
|
422
|
-
|
|
423
|
-
)
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
494
|
+
const now = Date.now()
|
|
495
|
+
for (const [k, v] of downloadTickets) if (v.exp < now) downloadTickets.delete(k)
|
|
496
|
+
}, 30000),
|
|
497
|
+
'dsh-soup: download ticket sweep',
|
|
498
|
+
)
|
|
499
|
+
|
|
500
|
+
/** RFC 5987/6266 下载头:ASCII 兜底 + UTF-8 真名。 */
|
|
501
|
+
function contentDisposition(name) {
|
|
502
|
+
const fallback = name.replace(/[^\x20-\x7E]|["\\;\r\n]/g, '_') || 'download'
|
|
503
|
+
return `attachment; filename="${fallback}"; filename*=UTF-8''${encodeURIComponent(name)}`
|
|
427
504
|
}
|
|
505
|
+
|
|
506
|
+
/** 流式下载路由:凭票单次发送,支持客户端中断即停读。 */
|
|
428
507
|
async function dlHandler(req, res) {
|
|
429
508
|
try {
|
|
430
|
-
const token = new URL(req.url ||
|
|
431
|
-
const entry = downloadTickets.get(token)
|
|
509
|
+
const token = new URL(req.url || '/?t=', 'http://local').searchParams.get('t') || ''
|
|
510
|
+
const entry = downloadTickets.get(token)
|
|
432
511
|
if (!entry) {
|
|
433
|
-
writeJson(res, 404, { ok: false, error:
|
|
434
|
-
return
|
|
512
|
+
writeJson(res, 404, { ok: false, error: '下载票据无效或已使用' })
|
|
513
|
+
return
|
|
435
514
|
}
|
|
436
|
-
downloadTickets.delete(token)
|
|
437
|
-
const st = await stat(entry.path)
|
|
515
|
+
downloadTickets.delete(token)
|
|
516
|
+
const st = await stat(entry.path)
|
|
438
517
|
res.writeHead(200, {
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
stream
|
|
445
|
-
stream.
|
|
446
|
-
|
|
518
|
+
'content-type': 'application/octet-stream',
|
|
519
|
+
'content-length': String(st.size),
|
|
520
|
+
'content-disposition': contentDisposition(entry.name),
|
|
521
|
+
'cache-control': 'no-store',
|
|
522
|
+
})
|
|
523
|
+
const stream = createReadStream(entry.path)
|
|
524
|
+
req.on('close', () => stream.destroy())
|
|
525
|
+
stream.pipe(res)
|
|
447
526
|
} catch (err) {
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
} catch {
|
|
451
|
-
}
|
|
527
|
+
if (!res.headersSent) writeJson(res, 500, { ok: false, error: String((err && err.message) || err) })
|
|
528
|
+
else res.end()
|
|
452
529
|
}
|
|
453
530
|
}
|
|
454
|
-
|
|
531
|
+
ctx.effect(
|
|
532
|
+
() => ctx.webServer.register({ kind: 'exact', path: '/api/dsh-soup/dl', handler: dlHandler }),
|
|
533
|
+
'dsh-soup: streaming download route',
|
|
534
|
+
)
|
|
535
|
+
|
|
536
|
+
/**
|
|
537
|
+
* 图片内联端点:GET /api/dsh-soup/img?p=<路径>。
|
|
538
|
+
* 浏览器端 Markdown 预览会把「相对路径图片」改写为这里的绝对 URL,
|
|
539
|
+
* 由 <img> 内联加载真实图片字节(JupyterLab md 图片同思路)。只读、无
|
|
540
|
+
* Content-Disposition(内联显示而非下载)。
|
|
541
|
+
* 安全模型:confine 围栏(只读工作区/会话目录)+ 仅图片类型 + 大小上限 +
|
|
542
|
+
* 不返回任何 CORS 头 + 拒绝跨站(Sec-Fetch-Site: cross-site 与不同源 Origin)。
|
|
543
|
+
* 注意 <img> 只能确认「是否 200/404」,无法读取像素,跨站利用面很小。
|
|
544
|
+
*/
|
|
545
|
+
async function imageHandler(req, res) {
|
|
455
546
|
try {
|
|
456
|
-
const
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
if (!mime) {
|
|
471
|
-
writeJson(res, 415, { ok: false, error: "\u4E0D\u652F\u6301\u7684\u56FE\u7247\u7C7B\u578B" });
|
|
472
|
-
return;
|
|
473
|
-
}
|
|
474
|
-
if (st.size > IMAGE_MAX_BYTES) {
|
|
475
|
-
writeJson(res, 413, { ok: false, error: "\u56FE\u7247\u8FC7\u5927" });
|
|
476
|
-
return;
|
|
547
|
+
const site = String(req.headers['sec-fetch-site'] || '')
|
|
548
|
+
if (site === 'cross-site') {
|
|
549
|
+
writeJson(res, 403, { ok: false, error: '跨站图片请求已拒绝' })
|
|
550
|
+
return
|
|
551
|
+
}
|
|
552
|
+
const origin = req.headers.origin
|
|
553
|
+
if (typeof origin === 'string' && origin !== '') {
|
|
554
|
+
let originHost = ''
|
|
555
|
+
try { originHost = new URL(origin).host } catch { originHost = '' }
|
|
556
|
+
const host = typeof req.headers.host === 'string' ? req.headers.host : ''
|
|
557
|
+
if (!originHost || originHost !== host) {
|
|
558
|
+
writeJson(res, 403, { ok: false, error: '跨源图片请求已拒绝' })
|
|
559
|
+
return
|
|
560
|
+
}
|
|
477
561
|
}
|
|
478
|
-
|
|
562
|
+
let p = ''
|
|
563
|
+
try { p = new URL(req.url || '/img?', 'http://local').searchParams.get('p') || '' } catch { p = '' }
|
|
564
|
+
if (!p) { writeJson(res, 400, { ok: false, error: '缺少图片路径' }); return }
|
|
565
|
+
const rp = await confine(p, '图片路径')
|
|
566
|
+
const mime = IMAGE_MIME[extOf(rp)]
|
|
567
|
+
if (!mime) { writeJson(res, 415, { ok: false, error: '不是可预览的图片类型' }); return }
|
|
568
|
+
let st
|
|
569
|
+
try { st = await stat(rp) } catch { writeJson(res, 404, { ok: false, error: '图片不可读' }); return }
|
|
570
|
+
if (st.isDirectory() || st.size > IMAGE_MAX_BYTES) {
|
|
571
|
+
writeJson(res, 413, { ok: false, error: '图片过大或目标为目录' }); return
|
|
572
|
+
}
|
|
573
|
+
const fh = await openFile(rp, 'r')
|
|
574
|
+
let buf
|
|
479
575
|
try {
|
|
480
|
-
|
|
481
|
-
await fh.read(buf, 0, st.size, 0)
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
576
|
+
buf = Buffer.alloc(st.size)
|
|
577
|
+
await fh.read(buf, 0, st.size, 0)
|
|
578
|
+
} finally { await fh.close() }
|
|
579
|
+
res.writeHead(200, {
|
|
580
|
+
'content-type': mime,
|
|
581
|
+
'content-length': String(st.size),
|
|
582
|
+
'cache-control': 'no-store',
|
|
583
|
+
})
|
|
584
|
+
res.end(buf)
|
|
487
585
|
} catch (err) {
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
} catch {
|
|
491
|
-
}
|
|
586
|
+
if (!res.headersSent) writeJson(res, (err && err.statusCode) || 500, { ok: false, error: String((err && err.message) || err) })
|
|
587
|
+
else res.end()
|
|
492
588
|
}
|
|
493
589
|
}
|
|
590
|
+
ctx.effect(
|
|
591
|
+
() => ctx.webServer.register({ kind: 'exact', path: '/api/dsh-soup/img', handler: imageHandler }),
|
|
592
|
+
'dsh-soup: inline image route',
|
|
593
|
+
)
|
|
594
|
+
|
|
595
|
+
/** 统一路由 dispatch:每个 action 返回可 JSON 序列化的结果。 */
|
|
494
596
|
async function handleAction(body) {
|
|
495
|
-
const action =
|
|
496
|
-
const args = body.args || {}
|
|
497
|
-
const sessionIdArg = args.sessionId
|
|
597
|
+
const action = body && body.action
|
|
598
|
+
const args = (body && body.args) || {}
|
|
599
|
+
const sessionIdArg = args && args.sessionId ? String(args.sessionId) : undefined
|
|
498
600
|
switch (action) {
|
|
499
|
-
case
|
|
500
|
-
return { ok: true, root }
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
const
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
601
|
+
case 'root': {
|
|
602
|
+
return { ok: true, root }
|
|
603
|
+
}
|
|
604
|
+
case 'sessionCwd': {
|
|
605
|
+
const id = args.sessionId
|
|
606
|
+
const session = id ? sessions.get(id) : undefined
|
|
607
|
+
const cwd = session && session.header && session.header.cwd
|
|
608
|
+
return cwd ? { ok: true, cwd } : { ok: false }
|
|
609
|
+
}
|
|
610
|
+
case 'list': {
|
|
611
|
+
const rp = await confine(String(args.path || '') || root, '目录', sessionIdArg)
|
|
612
|
+
let target
|
|
613
|
+
let entries
|
|
510
614
|
try {
|
|
511
|
-
target = await fs.resolve(rp, { cwd: root })
|
|
512
|
-
entries = await fs.listDir(target)
|
|
615
|
+
target = await fs.resolve(rp, { cwd: root })
|
|
616
|
+
entries = await fs.listDir(target)
|
|
513
617
|
} catch (err) {
|
|
514
|
-
return { ok: false, error:
|
|
618
|
+
return { ok: false, error: `无法读取目录: ${String((err && err.message) || err)}` }
|
|
515
619
|
}
|
|
516
|
-
const basePath = target && target.displayPath || rp
|
|
620
|
+
const basePath = (target && target.displayPath) || rp
|
|
517
621
|
const out = entries.map((entry) => {
|
|
518
622
|
const item = {
|
|
519
623
|
name: entry.name,
|
|
520
624
|
type: entry.type,
|
|
521
|
-
path: entry.target && entry.target.displayPath ? entry.target.displayPath : `${basePath}/${entry.name}
|
|
522
|
-
}
|
|
523
|
-
if (typeof entry.size ===
|
|
524
|
-
return item
|
|
525
|
-
})
|
|
625
|
+
path: entry.target && entry.target.displayPath ? entry.target.displayPath : `${basePath}/${entry.name}`,
|
|
626
|
+
}
|
|
627
|
+
if (typeof entry.size === 'number' && entry.size >= 0) item.size = entry.size
|
|
628
|
+
return item
|
|
629
|
+
})
|
|
630
|
+
// 目录在前、文件在后,各按名称排(与 VS Code / Finder 一致)。
|
|
526
631
|
out.sort((a, b) => {
|
|
527
|
-
const dirDiff = (a.type ===
|
|
528
|
-
if (dirDiff !== 0) return dirDiff
|
|
529
|
-
return a.name.localeCompare(b.name,
|
|
530
|
-
})
|
|
531
|
-
return { ok: true, root, path: basePath, entries: out }
|
|
532
|
-
}
|
|
533
|
-
case
|
|
534
|
-
const rp = await confine(args.path,
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
const
|
|
632
|
+
const dirDiff = (a.type === 'directory' ? 0 : 1) - (b.type === 'directory' ? 0 : 1)
|
|
633
|
+
if (dirDiff !== 0) return dirDiff
|
|
634
|
+
return a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: 'base' })
|
|
635
|
+
})
|
|
636
|
+
return { ok: true, root, path: basePath, entries: out }
|
|
637
|
+
}
|
|
638
|
+
case 'open': {
|
|
639
|
+
const rp = await confine(args.path, '打开路径', sessionIdArg)
|
|
640
|
+
const res = await openPath(rp)
|
|
641
|
+
return res.code === 0 ? { ok: true } : { ok: false, error: res.error || '打开失败' }
|
|
642
|
+
}
|
|
643
|
+
case 'trash': {
|
|
644
|
+
const rp = await confine(args.path, '废纸篓路径', sessionIdArg)
|
|
645
|
+
return trashPath(rp)
|
|
646
|
+
}
|
|
647
|
+
case 'move': {
|
|
648
|
+
const from = String((args && args.from) || '')
|
|
649
|
+
const to = String((args && args.to) || '')
|
|
650
|
+
if (!from || !to) return { ok: false, error: '缺少路径' }
|
|
651
|
+
const fromRp = await confine(from, '源路径', sessionIdArg)
|
|
652
|
+
const toRp = await confine(to, '目标路径', sessionIdArg)
|
|
653
|
+
if (toRp !== fromRp && within(fromRp, toRp)) return { ok: false, error: '不能把文件夹移入其自身子目录' }
|
|
544
654
|
try {
|
|
545
|
-
await
|
|
546
|
-
|
|
547
|
-
return { ok: true, path: toRp };
|
|
655
|
+
await rename(fromRp, toRp)
|
|
656
|
+
return { ok: true }
|
|
548
657
|
} catch (err) {
|
|
549
|
-
return { ok: false, error:
|
|
658
|
+
return { ok: false, error: `移动/重命名失败: ${String((err && err.message) || err)}` }
|
|
550
659
|
}
|
|
551
660
|
}
|
|
552
|
-
case
|
|
553
|
-
const
|
|
661
|
+
case 'create': {
|
|
662
|
+
const dir = String((args && args.dir) || '')
|
|
663
|
+
const newName = String((args && args.name) || '')
|
|
664
|
+
const isDir = Boolean(args && args.isDir)
|
|
665
|
+
if (!dir || !newName || newName === '.' || newName === '..' || newName.includes('/') || newName.includes('\\') || newName.includes('\0')) {
|
|
666
|
+
return { ok: false, error: '无效文件名' }
|
|
667
|
+
}
|
|
668
|
+
const dirRp = await confine(dir, '目录', sessionIdArg)
|
|
669
|
+
// 二次围栏:name 若与既有符号链接同名,lenient realpath 会解析出
|
|
670
|
+
// 链接真实指向,越界即拒绝。
|
|
671
|
+
const target = await confine(joinPath(dirRp, newName), '创建路径', sessionIdArg)
|
|
554
672
|
try {
|
|
555
|
-
if (
|
|
556
|
-
await mkdir(
|
|
557
|
-
|
|
673
|
+
if (isDir) {
|
|
674
|
+
await mkdir(target, { recursive: true })
|
|
675
|
+
} else {
|
|
676
|
+
await writeFile(target, '')
|
|
558
677
|
}
|
|
559
|
-
|
|
560
|
-
await writeFile(rp, "", { flag: "wx" });
|
|
561
|
-
return { ok: true, path: rp };
|
|
678
|
+
return { ok: true, path: target }
|
|
562
679
|
} catch (err) {
|
|
563
|
-
|
|
564
|
-
return { ok: false, error: `\u521B\u5EFA\u5931\u8D25: ${String(err && err.message || err)}` };
|
|
680
|
+
return { ok: false, error: `${isDir ? '新建文件夹' : '新建文件'}失败: ${String((err && err.message) || err)}` }
|
|
565
681
|
}
|
|
566
682
|
}
|
|
567
|
-
case
|
|
568
|
-
const
|
|
569
|
-
const
|
|
570
|
-
|
|
683
|
+
case 'upload': {
|
|
684
|
+
const dir = String((args && args.dir) || '')
|
|
685
|
+
const fileName = String((args && args.name) || '')
|
|
686
|
+
const data = String((args && args.data) || '')
|
|
687
|
+
// 文件名与 create 同规:禁路径分隔符与相对段,杜绝 ../ 穿越。
|
|
688
|
+
if (!dir || !fileName || fileName === '.' || fileName === '..' || fileName.includes('/') || fileName.includes('\\') || fileName.includes('\0')) {
|
|
689
|
+
return { ok: false, error: '无效文件名' }
|
|
690
|
+
}
|
|
691
|
+
const dirRp = await confine(dir, '目录', sessionIdArg)
|
|
692
|
+
const target = await confine(joinPath(dirRp, fileName), '上传路径', sessionIdArg)
|
|
693
|
+
// 分块上传(JupyterLab 同款语义):chunk=1/缺省覆盖写,chunk>=2 追加。
|
|
694
|
+
// 无需服务端状态——按块序到达即顺序落盘。
|
|
695
|
+
const chunk = Number((args && args.chunk) || 0)
|
|
696
|
+
if (chunk < 0 || !Number.isInteger(chunk)) return { ok: false, error: '无效的 chunk 序号' }
|
|
571
697
|
try {
|
|
572
|
-
const bytes = Buffer.from(
|
|
573
|
-
if (chunk >= 2) await appendFile(
|
|
574
|
-
else await writeFile(
|
|
575
|
-
return { ok: true, path:
|
|
698
|
+
const bytes = Buffer.from(data.replace(/\s+/g, ''), 'base64')
|
|
699
|
+
if (chunk >= 2) await appendFile(target, bytes)
|
|
700
|
+
else await writeFile(target, bytes)
|
|
701
|
+
return { ok: true, path: target }
|
|
576
702
|
} catch (err) {
|
|
577
|
-
return { ok: false, error:
|
|
703
|
+
return { ok: false, error: `写入失败: ${String((err && err.message) || err)}` }
|
|
578
704
|
}
|
|
579
705
|
}
|
|
580
|
-
case
|
|
581
|
-
const rp = await confine(args.path,
|
|
582
|
-
return readFileForView(rp)
|
|
706
|
+
case 'read': {
|
|
707
|
+
const rp = await confine(args.path, '文件路径', sessionIdArg)
|
|
708
|
+
return readFileForView(rp)
|
|
583
709
|
}
|
|
584
|
-
case
|
|
585
|
-
|
|
586
|
-
|
|
710
|
+
case 'download': {
|
|
711
|
+
// 流式下载签发:围栏校验通过后发一张一次性、2 分钟时效的票据 URL,
|
|
712
|
+
// 真正的字节由 /api/dsh-soup/dl 流式发送——任意大小文件零内存放大。
|
|
713
|
+
const rp = await confine(args.path, '下载路径', sessionIdArg)
|
|
714
|
+
let dst
|
|
587
715
|
try {
|
|
588
|
-
dst = await stat(rp)
|
|
716
|
+
dst = await stat(rp)
|
|
589
717
|
} catch (err) {
|
|
590
|
-
return { ok: false, error:
|
|
718
|
+
return { ok: false, error: '无法读取文件' }
|
|
591
719
|
}
|
|
592
|
-
if (dst.isDirectory()) return { ok: false, error:
|
|
593
|
-
const token = randomBytes(24).toString(
|
|
594
|
-
downloadTickets.set(token, { path: rp, name:
|
|
595
|
-
return { ok: true, name:
|
|
596
|
-
}
|
|
597
|
-
case
|
|
598
|
-
|
|
599
|
-
|
|
720
|
+
if (dst.isDirectory()) return { ok: false, error: '目标是目录,不支持下载' }
|
|
721
|
+
const token = randomBytes(24).toString('hex')
|
|
722
|
+
downloadTickets.set(token, { path: rp, name: path.basename(rp), exp: Date.now() + 120000 })
|
|
723
|
+
return { ok: true, name: path.basename(rp), size: dst.size, url: `/api/dsh-soup/dl?t=${token}` }
|
|
724
|
+
}
|
|
725
|
+
case 'mtime': {
|
|
726
|
+
// 轻量变更探测:对一批路径返回 mtime(+size/目录标志),供浏览器端
|
|
727
|
+
// 低频轮询比较、变了才真正重拉——避免引入跨平台文件 watcher。
|
|
728
|
+
// 上限 64 个路径;越界/不可达的路径返回 null(客户端视为消失)。
|
|
729
|
+
const paths = Array.isArray(args && args.paths) ? args.paths.slice(0, 64) : []
|
|
730
|
+
const mtimes = {}
|
|
600
731
|
for (const raw of paths) {
|
|
601
|
-
const p = String(raw ||
|
|
602
|
-
if (!p) continue
|
|
732
|
+
const p = String(raw || '')
|
|
733
|
+
if (!p) continue
|
|
603
734
|
try {
|
|
604
|
-
const rp = await confine(p,
|
|
605
|
-
const st = await stat(rp)
|
|
606
|
-
mtimes[p] = { m: st.mtimeMs,
|
|
735
|
+
const rp = await confine(p, '探测路径', sessionIdArg)
|
|
736
|
+
const st = await stat(rp)
|
|
737
|
+
mtimes[p] = st.isDirectory() ? { m: st.mtimeMs, d: 1 } : { m: st.mtimeMs, s: st.size }
|
|
607
738
|
} catch {
|
|
608
|
-
mtimes[p] = null
|
|
739
|
+
mtimes[p] = null
|
|
609
740
|
}
|
|
610
741
|
}
|
|
611
|
-
return { ok: true, mtimes }
|
|
742
|
+
return { ok: true, mtimes }
|
|
612
743
|
}
|
|
613
|
-
case
|
|
614
|
-
|
|
744
|
+
case 'speed-status': {
|
|
745
|
+
const sid = args && args.sessionId ? String(args.sessionId) : ''
|
|
746
|
+
return { ok: true, ...speedStatus(sid) }
|
|
615
747
|
}
|
|
616
748
|
default:
|
|
617
|
-
return { ok: false, error:
|
|
749
|
+
return { ok: false, error: `unknown action: ${String(action)}` }
|
|
618
750
|
}
|
|
619
751
|
}
|
|
752
|
+
|
|
753
|
+
/**
|
|
754
|
+
* 请求门:同源校验 + 强制自定义头。
|
|
755
|
+
* - 仅接受 POST;OPTIONS 直接 403(让浏览器 preflight 失败),其余 405;
|
|
756
|
+
* - 带 Origin 时必须与 Host 同源——挡跨站 CSRF 与 DNS rebinding
|
|
757
|
+
* (rebinding 页面的 Origin 是攻击者域名,与 Host 必不相同);
|
|
758
|
+
* - 必须携带 x-dsh-soup: 1 且 content-type 为 application/json:
|
|
759
|
+
* 自定义头会强制浏览器先走 preflight,而本路由永不回 CORS 头,
|
|
760
|
+
* 因此恶意网页无法用 text/plain「简单请求」盲打写操作。
|
|
761
|
+
* 非浏览器本地工具(无 Origin 头)不受影响。
|
|
762
|
+
*/
|
|
620
763
|
function gate(req, res) {
|
|
621
|
-
const
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
764
|
+
const method = String(req.method || 'POST').toUpperCase()
|
|
765
|
+
if (method === 'OPTIONS') {
|
|
766
|
+
writeJson(res, 403, { ok: false, error: 'forbidden' })
|
|
767
|
+
return true
|
|
768
|
+
}
|
|
769
|
+
if (method !== 'POST') {
|
|
770
|
+
res.setHeader('Allow', 'POST')
|
|
771
|
+
writeJson(res, 405, { ok: false, error: '仅支持 POST' })
|
|
772
|
+
return true
|
|
773
|
+
}
|
|
774
|
+
const headers = req.headers || {}
|
|
775
|
+
const origin = headers.origin
|
|
776
|
+
if (typeof origin === 'string' && origin !== '') {
|
|
777
|
+
let originHost = ''
|
|
778
|
+
try { originHost = new URL(origin).host } catch { originHost = '' }
|
|
779
|
+
const host = typeof headers.host === 'string' ? headers.host : ''
|
|
780
|
+
if (!originHost || originHost !== host) {
|
|
781
|
+
writeJson(res, 403, { ok: false, error: '跨源请求已拒绝' })
|
|
782
|
+
return true
|
|
632
783
|
}
|
|
633
784
|
}
|
|
634
|
-
const contentType = String(headers[
|
|
635
|
-
if (headers[
|
|
636
|
-
writeJson(res, 403, { ok: false, error:
|
|
637
|
-
return true
|
|
785
|
+
const contentType = String(headers['content-type'] || '').toLowerCase()
|
|
786
|
+
if (headers['x-dsh-soup'] !== '1' || !contentType.startsWith('application/json')) {
|
|
787
|
+
writeJson(res, 403, { ok: false, error: '缺少必要的请求头' })
|
|
788
|
+
return true
|
|
638
789
|
}
|
|
639
|
-
return false
|
|
790
|
+
return false
|
|
640
791
|
}
|
|
792
|
+
|
|
793
|
+
/** HTTP 处理器:请求门 → 解析 body → dispatch → JSON 响应。 */
|
|
641
794
|
async function handler(req, res) {
|
|
642
795
|
try {
|
|
643
|
-
if (gate(req, res)) return
|
|
644
|
-
const body = await readBody(req)
|
|
645
|
-
const result = await handleAction(body)
|
|
646
|
-
writeJson(res, 200, result)
|
|
796
|
+
if (gate(req, res)) return
|
|
797
|
+
const body = await readBody(req)
|
|
798
|
+
const result = await handleAction(body)
|
|
799
|
+
writeJson(res, 200, result)
|
|
647
800
|
} catch (err) {
|
|
648
|
-
writeJson(res, err && err.statusCode || 500, { ok: false, error: String(err && err.message || err) })
|
|
801
|
+
writeJson(res, (err && err.statusCode) || 500, { ok: false, error: String((err && err.message) || err) })
|
|
649
802
|
}
|
|
650
803
|
}
|
|
651
|
-
ctx.effect(
|
|
652
|
-
() => ctx.webServer.register({ kind: "exact", path: "/api/dsh-soup", handler }),
|
|
653
|
-
"dsh-soup: file http route"
|
|
654
|
-
);
|
|
655
|
-
ctx.effect(
|
|
656
|
-
() => ctx.webServer.register({ kind: "prefix", path: "/api/dsh-soup/img", handler: handleImgRoute }),
|
|
657
|
-
"dsh-soup: image inline route"
|
|
658
|
-
);
|
|
659
|
-
ctx.effect(
|
|
660
|
-
() => ctx.webServer.register({ kind: "prefix", path: "/api/dsh-soup/dl", handler: dlHandler }),
|
|
661
|
-
"dsh-soup: download route"
|
|
662
|
-
);
|
|
663
|
-
}
|
|
664
804
|
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
function apply(ctx) {
|
|
670
|
-
const root = ctx.sandboxPolicy && ctx.sandboxPolicy.workspaceRoot || "/";
|
|
671
|
-
const { speedStatus } = setupSpeedTracking({ ctx });
|
|
672
|
-
setupFiles({ ctx, root, PLATFORM, speedStatus });
|
|
805
|
+
ctx.effect(
|
|
806
|
+
() => ctx.webServer.register({ kind: 'exact', path: '/api/dsh-soup', handler }),
|
|
807
|
+
'dsh-soup: file http route',
|
|
808
|
+
)
|
|
673
809
|
}
|
|
674
|
-
export {
|
|
675
|
-
apply,
|
|
676
|
-
inject,
|
|
677
|
-
name
|
|
678
|
-
};
|