dsh-users-open-source 0.1.0

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/host.js ADDED
@@ -0,0 +1,880 @@
1
+ /**
2
+ * dsh-users-open-source — 宿主半边(Host half)。
3
+ *
4
+ * 干什么:在本机开一个**只绑 127.0.0.1 的回环测速口**,然后自己当客户端去压它,
5
+ * 量出这台机器的 TCP 吞吐(下载 / 上传)。所有字节都在本进程与回环网卡之间来回,
6
+ * **不连接任何外部服务器、不发起任何模型请求**,因此测速本身消耗 0 token。
7
+ *
8
+ * 为什么是回环:用户要求「只跑用户本地带宽、不能接入其他服务器」。真正测外网带宽
9
+ * 必须有一个对端,那必然要连第三方;所以这里测得的是本机协议栈 + 回环吞吐
10
+ * (通常几 GB/s,远高于物理网卡),它反映的是本机网络栈上限,而不是运营商带宽。
11
+ *
12
+ * 三个对外入口(都挂在主 webServer 上,同源,浏览器直接 fetch):
13
+ * GET /dsh-users-open-source/state 当前状态:是否在跑、瞬时速率、配置、最近结果、历史
14
+ * POST /dsh-users-open-source/run 开始一次测速(后台执行,立即返回)
15
+ * POST /dsh-users-open-source/cancel 取消正在跑的测速
16
+ * POST /dsh-users-open-source/config 保存配置(定时策略 / 单次时长 / 并发连接数)
17
+ * (「云端备份」用的 POST /dsh-users-open-source/open-workspace 在伴生包
18
+ * dsh-users-open-source-workspace 里,entry 由本包的 cordis.patch.yml 插入)
19
+ *
20
+ * 定时测速完全在宿主侧跑(浏览器关着也照跑),配置与历史落在
21
+ * `$DSH_HOME/dsh-users-open-source/{config,history}.json`。
22
+ *
23
+ * @module dsh-users-open-source
24
+ */
25
+ import { createServer, request as httpRequest } from 'node:http'
26
+ import { existsSync } from 'node:fs'
27
+ import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'
28
+ import { homedir } from 'node:os'
29
+ import { dirname, join } from 'node:path'
30
+ import { fileURLToPath } from 'node:url'
31
+
32
+ /** 插件名(cordis loader 行 id 与客户端模块 id 都用它)。 */
33
+ export const name = 'dsh-users-open-source'
34
+ /** 依赖宿主 webServer 服务来注册路由。 */
35
+ export const inject = ['webServer']
36
+
37
+ /** 本插件所有 HTTP 路由的公共前缀。 */
38
+ const BASE = '/dsh-users-open-source'
39
+ /** 本文件所在目录(`../assets` 放静态图)。 */
40
+ const HERE = dirname(fileURLToPath(import.meta.url))
41
+ /** 包内自带的默认图。 */
42
+ const DEFAULT_IMAGE = join(HERE, '..', 'assets', 'code-upload.jpg')
43
+ /** 允许用户上传的图片类型 → 落盘扩展名。 */
44
+ const IMAGE_TYPES = { 'image/png': 'png', 'image/jpeg': 'jpg', 'image/webp': 'webp', 'image/gif': 'gif' }
45
+ /** 用户上传图片的大小上限(解码后字节数)。 */
46
+ const IMAGE_MAX_BYTES = 3 * 1024 * 1024
47
+ /** 单块数据大小:1 MiB。太小会让 syscall 开销盖过吞吐,太大则浪费内存。 */
48
+ const CHUNK = Buffer.alloc(1 << 20, 0x61)
49
+ /** 历史记录保留条数(滚动覆盖)。 */
50
+ const HISTORY_LIMIT = 60
51
+ /** 采样瞬时速率的间隔(毫秒)。 */
52
+ const SAMPLE_MS = 250
53
+ /** 配置文件与历史文件所在的目录名(挂在 $DSH_HOME 下)。 */
54
+ const STORE_DIR = 'dsh-users-open-source'
55
+ /** 单次测速时长与并发数的安全边界。 */
56
+ const LIMITS = {
57
+ durationMs: { min: 500, max: 15000, fallback: 3000 },
58
+ connections: { min: 1, max: 16, fallback: 4 },
59
+ intervalMinutes: { min: 1, max: 10080, fallback: 60 },
60
+ sidebarImageSize: { min: 60, max: 400, fallback: 120 }
61
+ }
62
+
63
+ /** 出厂配置。 */
64
+ const DEFAULT_CONFIG = {
65
+ /** 单次测速时长(毫秒)。 */
66
+ durationMs: 3000,
67
+ /** 并发连接数。 */
68
+ connections: 4,
69
+ /** 测速时侧栏那张图边长(像素)。 */
70
+ sidebarImageSize: 120,
71
+ /**
72
+ * 定时策略:
73
+ * mode 'off' 不自动跑
74
+ * mode 'interval' 每 intervalMinutes 分钟跑一次
75
+ * mode 'daily' 每天 dailyTime(HH:MM)跑一次
76
+ */
77
+ schedule: { mode: 'off', intervalMinutes: 60, dailyTime: '03:00' }
78
+ }
79
+
80
+ /**
81
+ * 解析 DSH home(与内核 `dshHomePath()` 同优先级:$DSH_HOME 优先,否则 ~/.dsh)。
82
+ * @returns {string} 绝对的 DSH home 路径。
83
+ */
84
+ function dshHome() {
85
+ const configured = process.env.DSH_HOME
86
+ if (typeof configured === 'string' && configured.trim() !== '') return configured.trim()
87
+ return join(homedir(), '.dsh')
88
+ }
89
+
90
+ /**
91
+ * 把一个值夹到整数区间里。
92
+ * @param {unknown} value - 原始输入。
93
+ * @param {{min:number,max:number,fallback:number}} range - 边界与兜底值。
94
+ * @returns {number} 规整后的整数。
95
+ */
96
+ function clampInt(value, range) {
97
+ const parsed = typeof value === 'number' ? value : Number.parseInt(String(value ?? ''), 10)
98
+ if (!Number.isFinite(parsed)) return range.fallback
99
+ return Math.min(range.max, Math.max(range.min, Math.round(parsed)))
100
+ }
101
+
102
+ /**
103
+ * 校验并补齐一份配置(外部输入永远不可信)。
104
+ * @param {unknown} input - 待校验的对象。
105
+ * @param {typeof DEFAULT_CONFIG} base - 作为兜底的现有配置。
106
+ * @returns {typeof DEFAULT_CONFIG} 完整配置。
107
+ */
108
+ function normalizeConfig(input, base = DEFAULT_CONFIG) {
109
+ const raw = input !== null && typeof input === 'object' ? input : {}
110
+ const rawSchedule = raw.schedule !== null && typeof raw.schedule === 'object' ? raw.schedule : {}
111
+ const mode = ['off', 'interval', 'daily'].includes(rawSchedule.mode) ? rawSchedule.mode : base.schedule.mode
112
+ const time = typeof rawSchedule.dailyTime === 'string' && /^([01]\d|2[0-3]):[0-5]\d$/.test(rawSchedule.dailyTime)
113
+ ? rawSchedule.dailyTime
114
+ : base.schedule.dailyTime
115
+ return {
116
+ durationMs: clampInt(raw.durationMs, LIMITS.durationMs),
117
+ connections: clampInt(raw.connections, LIMITS.connections),
118
+ sidebarImageSize: clampInt(raw.sidebarImageSize, LIMITS.sidebarImageSize),
119
+ schedule: {
120
+ mode,
121
+ intervalMinutes: clampInt(rawSchedule.intervalMinutes, LIMITS.intervalMinutes),
122
+ dailyTime: time
123
+ }
124
+ }
125
+ }
126
+
127
+ /**
128
+ * 写一个 JSON 响应。
129
+ * @param {import('node:http').ServerResponse} res - 响应对象。
130
+ * @param {number} status - HTTP 状态码。
131
+ * @param {unknown} payload - 要序列化的值。
132
+ */
133
+ function sendJson(res, status, payload) {
134
+ const body = Buffer.from(JSON.stringify(payload), 'utf8')
135
+ res.writeHead(status, {
136
+ 'content-type': 'application/json; charset=utf-8',
137
+ 'cache-control': 'no-store',
138
+ 'content-length': String(body.length)
139
+ })
140
+ res.end(body)
141
+ }
142
+
143
+ /**
144
+ * 读取请求体并解析 JSON,带大小上限。
145
+ * @param {import('node:http').IncomingMessage} req - 请求对象。
146
+ * @param {number} [limit] - 最大字节数。
147
+ * @returns {Promise<Record<string, unknown>>} 解析后的对象。
148
+ */
149
+ function readJsonBody(req, limit = 16384) {
150
+ return new Promise((resolve, reject) => {
151
+ const chunks = []
152
+ let size = 0
153
+ req.on('data', (chunk) => {
154
+ size += chunk.length
155
+ if (size > limit) {
156
+ reject(new Error('body too large'))
157
+ req.destroy()
158
+ return
159
+ }
160
+ chunks.push(chunk)
161
+ })
162
+ req.on('end', () => {
163
+ const text = Buffer.concat(chunks).toString('utf8')
164
+ if (text.trim() === '') {
165
+ resolve({})
166
+ return
167
+ }
168
+ try {
169
+ const parsed = JSON.parse(text)
170
+ resolve(parsed !== null && typeof parsed === 'object' ? parsed : {})
171
+ } catch (error) {
172
+ reject(new Error(`invalid JSON body: ${String(error && error.message)}`))
173
+ }
174
+ })
175
+ req.on('error', reject)
176
+ })
177
+ }
178
+
179
+ /**
180
+ * 建一个回环测速服务器:只监听 127.0.0.1 的随机端口。
181
+ *
182
+ * 路由刻意保持"哑":不鉴权、不落盘、不读业务状态 —— 它唯一的职责是把字节搬来搬去。
183
+ * 因为只绑回环地址,局域网里的其他机器根本连不上。
184
+ *
185
+ * @param {() => number} portOf - 读取当前监听端口(`/hello` 会报出去)。
186
+ * @returns {import('node:http').Server} 尚未监听的服务器实例。
187
+ */
188
+ function createLoopbackServer(portOf) {
189
+ const server = createServer((req, res) => {
190
+ res.setHeader('cache-control', 'no-store')
191
+ // 浏览器若直连这个口(跨源),放开 CORS;测速本身不需要它,但留着无副作用。
192
+ res.setHeader('access-control-allow-origin', '*')
193
+ const url = new URL(typeof req.url === 'string' ? req.url : '/', 'http://127.0.0.1')
194
+ res.on('error', () => {})
195
+
196
+ if (req.method === 'OPTIONS') {
197
+ res.writeHead(204, {
198
+ 'access-control-allow-headers': 'content-type',
199
+ 'access-control-allow-methods': 'GET,POST,OPTIONS'
200
+ })
201
+ res.end()
202
+ return
203
+ }
204
+
205
+ if (url.pathname === '/hello') {
206
+ sendJson(res, 200, { ok: true, port: portOf() })
207
+ return
208
+ }
209
+
210
+ // 下载:按请求方要求灌数据,灌满 durationMs 就收尾。
211
+ if (url.pathname === '/down') {
212
+ const ms = clampInt(url.searchParams.get('ms'), { min: 100, max: 60000, fallback: 3000 })
213
+ res.writeHead(200, { 'content-type': 'application/octet-stream' })
214
+ const deadline = Date.now() + ms
215
+ const pump = () => {
216
+ while (Date.now() < deadline) {
217
+ if (res.destroyed || res.writableEnded) return
218
+ if (!res.write(CHUNK)) {
219
+ res.once('drain', pump)
220
+ return
221
+ }
222
+ }
223
+ if (!res.destroyed && !res.writableEnded) res.end()
224
+ }
225
+ pump()
226
+ return
227
+ }
228
+
229
+ // 上传:把请求体吃干抹净,回报真正收到的字节数。
230
+ if (url.pathname === '/up') {
231
+ let bytes = 0
232
+ req.on('data', (chunk) => {
233
+ bytes += chunk.length
234
+ })
235
+ req.on('end', () => {
236
+ sendJson(res, 200, { ok: true, bytes })
237
+ })
238
+ req.on('error', () => {})
239
+ return
240
+ }
241
+
242
+ sendJson(res, 404, { ok: false, error: 'not found' })
243
+ })
244
+ server.on('error', () => {})
245
+ return server
246
+ }
247
+
248
+ /**
249
+ * 起一个共享计数器:worker 往里加字节,采样器读它算瞬时速率。
250
+ * @returns {{bytes:number}} 计数器对象。
251
+ */
252
+ function makeCounter() {
253
+ return { bytes: 0 }
254
+ }
255
+
256
+ /**
257
+ * 按固定间隔把计数器增量换算成瞬时字节/秒,写进 progress。
258
+ * @param {{instantBps:number,bytes:number,elapsedMs:number,startedAt:number,samples:number[]}} progress - 进度对象。
259
+ * @param {{bytes:number}} counter - 共享计数器。
260
+ * @returns {() => void} 停止采样的函数。
261
+ */
262
+ function startSampler(progress, counter) {
263
+ let lastBytes = 0
264
+ let lastAt = Date.now()
265
+ const timer = setInterval(() => {
266
+ const now = Date.now()
267
+ const dt = (now - lastAt) / 1000
268
+ const bytes = counter.bytes
269
+ const instant = dt > 0 ? (bytes - lastBytes) / dt : 0
270
+ lastBytes = bytes
271
+ lastAt = now
272
+ progress.instantBps = instant
273
+ progress.bytes = bytes
274
+ progress.elapsedMs = now - progress.startedAt
275
+ progress.samples.push(Math.round(instant))
276
+ if (progress.samples.length > 40) progress.samples.shift()
277
+ }, SAMPLE_MS)
278
+ if (typeof timer.unref === 'function') timer.unref()
279
+ return () => clearInterval(timer)
280
+ }
281
+
282
+ /**
283
+ * 下载测速:并发若干条 GET /down,跑满 durationMs。
284
+ * @param {number} port - 回环测速口。
285
+ * @param {object} options - 参数。
286
+ * @param {number} options.durationMs - 目标时长。
287
+ * @param {number} options.connections - 并发连接数。
288
+ * @param {{bytes:number}} options.counter - 共享字节计数器。
289
+ * @param {() => boolean} options.cancelled - 是否已被要求取消。
290
+ * @returns {Promise<number>} 实际耗时(毫秒)。
291
+ */
292
+ function measureDownload(port, { durationMs, connections, counter, cancelled }) {
293
+ const startedAt = Date.now()
294
+ const deadline = startedAt + durationMs
295
+ const workers = []
296
+ for (let index = 0; index < connections; index += 1) {
297
+ workers.push(new Promise((resolve) => {
298
+ let settled = false
299
+ const finish = () => {
300
+ if (settled) return
301
+ settled = true
302
+ resolve()
303
+ }
304
+ const req = httpRequest({
305
+ host: '127.0.0.1',
306
+ port,
307
+ path: `/down?ms=${durationMs + 5000}`,
308
+ method: 'GET',
309
+ agent: false
310
+ }, (res) => {
311
+ res.on('data', (chunk) => {
312
+ counter.bytes += chunk.length
313
+ if (Date.now() >= deadline || cancelled()) req.destroy()
314
+ })
315
+ res.on('end', finish)
316
+ res.on('close', finish)
317
+ res.on('error', finish)
318
+ })
319
+ req.on('error', finish)
320
+ req.end()
321
+ }))
322
+ }
323
+ return Promise.all(workers).then(() => Date.now() - startedAt)
324
+ }
325
+
326
+ /**
327
+ * 上传测速:并发若干条 POST /up,往回环口灌数据直到 durationMs。
328
+ * @param {number} port - 回环测速口。
329
+ * @param {object} options - 参数。
330
+ * @param {number} options.durationMs - 目标时长。
331
+ * @param {number} options.connections - 并发连接数。
332
+ * @param {{bytes:number}} options.counter - 共享字节计数器。
333
+ * @param {() => boolean} options.cancelled - 是否已被要求取消。
334
+ * @returns {Promise<number>} 实际耗时(毫秒)。
335
+ */
336
+ function measureUpload(port, { durationMs, connections, counter, cancelled }) {
337
+ const startedAt = Date.now()
338
+ const deadline = startedAt + durationMs
339
+ const workers = []
340
+ for (let index = 0; index < connections; index += 1) {
341
+ workers.push(new Promise((resolve) => {
342
+ let settled = false
343
+ const finish = () => {
344
+ if (settled) return
345
+ settled = true
346
+ resolve()
347
+ }
348
+ const req = httpRequest({
349
+ host: '127.0.0.1',
350
+ port,
351
+ path: '/up',
352
+ method: 'POST',
353
+ agent: false,
354
+ headers: {
355
+ 'content-type': 'application/octet-stream',
356
+ 'transfer-encoding': 'chunked'
357
+ }
358
+ }, (res) => {
359
+ res.resume()
360
+ res.on('end', finish)
361
+ res.on('close', finish)
362
+ res.on('error', finish)
363
+ })
364
+ req.on('error', finish)
365
+ // 兜底:无论回环口出什么幺蛾子,也不让这一次测速挂死。
366
+ const guard = setTimeout(finish, durationMs + 8000)
367
+ if (typeof guard.unref === 'function') guard.unref()
368
+ const pump = () => {
369
+ while (Date.now() < deadline && !cancelled()) {
370
+ counter.bytes += CHUNK.length
371
+ if (!req.write(CHUNK)) {
372
+ req.once('drain', pump)
373
+ return
374
+ }
375
+ }
376
+ req.end()
377
+ }
378
+ pump()
379
+ }))
380
+ }
381
+ return Promise.all(workers).then(() => Date.now() - startedAt)
382
+ }
383
+
384
+ /**
385
+ * 读一次 JSON 文件,读不到或坏掉时给兜底值。
386
+ * @param {string} path - 文件路径。
387
+ * @param {unknown} fallback - 兜底值。
388
+ * @returns {Promise<any>} 解析结果或兜底值。
389
+ */
390
+ async function readJsonFile(path, fallback) {
391
+ try {
392
+ return JSON.parse(await readFile(path, 'utf8'))
393
+ } catch {
394
+ return fallback
395
+ }
396
+ }
397
+
398
+ /**
399
+ * 建一个插件运行时状态。
400
+ * @returns {object} 状态对象。
401
+ */
402
+ function createState() {
403
+ return {
404
+ /** 回环测速服务器。 */
405
+ server: null,
406
+ /** 回环测速端口(0 表示还没起来)。 */
407
+ port: 0,
408
+ /** 配置就绪的 Promise。 */
409
+ ready: null,
410
+ /** 生效中的配置。 */
411
+ config: normalizeConfig({}),
412
+ /** 最近一次结果。 */
413
+ last: null,
414
+ /** 历史结果(新→旧)。 */
415
+ history: [],
416
+ /** 是否正在测速。 */
417
+ running: false,
418
+ /** 当前进度(未测速时为 null)。 */
419
+ progress: null,
420
+ /** 取消标志。 */
421
+ cancel: false,
422
+ /** 定时器句柄。 */
423
+ timer: null
424
+ }
425
+ }
426
+
427
+ /**
428
+ * 把当前历史落盘(历史只是锦上添花,写不进去也不影响使用)。
429
+ * @param {object} state - 插件状态。
430
+ * @returns {Promise<void>} 落盘完成。
431
+ */
432
+ async function persistHistory(state) {
433
+ try {
434
+ const dir = join(dshHome(), STORE_DIR)
435
+ await mkdir(dir, { recursive: true })
436
+ await writeFile(join(dir, 'history.json'), JSON.stringify(state.history, null, 2), 'utf8')
437
+ } catch {
438
+ /* 写不进去就算了 */
439
+ }
440
+ }
441
+
442
+ /**
443
+ * 记一条结果:更新 last / history,并落盘。
444
+ * @param {object} state - 插件状态。
445
+ * @param {object} record - 结果记录。
446
+ * @returns {Promise<void>} 落盘完成。
447
+ */
448
+ async function recordResult(state, record) {
449
+ state.last = record
450
+ state.history = [record, ...state.history].slice(0, HISTORY_LIMIT)
451
+ await persistHistory(state)
452
+ }
453
+
454
+ /**
455
+ * 保存配置并落盘。
456
+ * @param {object} state - 插件状态。
457
+ * @param {unknown} patch - 新的配置片段。
458
+ * @returns {Promise<object>} 生效后的配置。
459
+ */
460
+ async function saveConfig(state, patch) {
461
+ state.config = normalizeConfig(patch, state.config)
462
+ try {
463
+ const dir = join(dshHome(), STORE_DIR)
464
+ await mkdir(dir, { recursive: true })
465
+ await writeFile(join(dir, 'config.json'), JSON.stringify(state.config, null, 2), 'utf8')
466
+ } catch {
467
+ /* 同上:配置写不进去不影响本次生效 */
468
+ }
469
+ return state.config
470
+ }
471
+
472
+ /**
473
+ * 跑一次完整测速:先下载后上传,全程更新 progress,结束后记账。
474
+ * @param {object} state - 插件状态。
475
+ * @param {'manual'|'schedule'} source - 触发来源。
476
+ * @returns {Promise<object>} 结果记录(失败或取消时带 error/cancelled)。
477
+ */
478
+ async function runTest(state, source) {
479
+ if (state.running) return { ok: false, error: 'busy' }
480
+ if (!state.port) return { ok: false, error: 'loopback server not ready' }
481
+ const { durationMs, connections } = state.config
482
+ state.running = true
483
+ state.cancel = false
484
+ const startedAt = Date.now()
485
+ /** @type {any} */
486
+ const progress = {
487
+ phase: 'down',
488
+ phaseIndex: 1,
489
+ phaseCount: 2,
490
+ bytes: 0,
491
+ instantBps: 0,
492
+ elapsedMs: 0,
493
+ startedAt,
494
+ durationMs,
495
+ connections,
496
+ samples: []
497
+ }
498
+ state.progress = progress
499
+ const cancelled = () => state.cancel
500
+ try {
501
+ const downCounter = makeCounter()
502
+ progress.phase = 'down'
503
+ progress.startedAt = Date.now()
504
+ progress.bytes = 0
505
+ progress.samples = []
506
+ let stop = startSampler(progress, downCounter)
507
+ const downMs = await measureDownload(state.port, { durationMs, connections, counter: downCounter, cancelled })
508
+ stop()
509
+
510
+ const upCounter = makeCounter()
511
+ progress.phase = 'up'
512
+ progress.phaseIndex = 2
513
+ progress.startedAt = Date.now()
514
+ progress.bytes = 0
515
+ progress.samples = []
516
+ stop = startSampler(progress, upCounter)
517
+ const upMs = await measureUpload(state.port, { durationMs, connections, counter: upCounter, cancelled })
518
+ stop()
519
+
520
+ const record = {
521
+ at: new Date().toISOString(),
522
+ source,
523
+ cancelled: state.cancel,
524
+ durationMs,
525
+ connections,
526
+ download: {
527
+ bytes: downCounter.bytes,
528
+ ms: downMs,
529
+ bps: downMs > 0 ? (downCounter.bytes * 1000) / downMs : 0
530
+ },
531
+ upload: {
532
+ bytes: upCounter.bytes,
533
+ ms: upMs,
534
+ bps: upMs > 0 ? (upCounter.bytes * 1000) / upMs : 0
535
+ },
536
+ totalMs: Date.now() - startedAt
537
+ }
538
+ await recordResult(state, record)
539
+ return { ok: true, result: record }
540
+ } catch (error) {
541
+ return { ok: false, error: String((error && error.message) || error) }
542
+ } finally {
543
+ state.running = false
544
+ state.progress = null
545
+ state.cancel = false
546
+ }
547
+ }
548
+
549
+ /**
550
+ * 安排下一次定时测速(每次跑完都重排,天天跟着系统时间走)。
551
+ * @param {object} state - 插件状态。
552
+ * @returns {void}
553
+ */
554
+ function scheduleNext(state) {
555
+ if (state.timer !== null) {
556
+ clearTimeout(state.timer)
557
+ state.timer = null
558
+ }
559
+ const schedule = state.config.schedule
560
+ if (schedule.mode === 'off') return
561
+ let delay
562
+ if (schedule.mode === 'interval') {
563
+ delay = schedule.intervalMinutes * 60000
564
+ } else {
565
+ const [hours, minutes] = schedule.dailyTime.split(':').map((part) => Number.parseInt(part, 10))
566
+ const now = new Date()
567
+ const next = new Date(now.getFullYear(), now.getMonth(), now.getDate(), hours, minutes, 0, 0)
568
+ if (next.getTime() <= now.getTime()) next.setDate(next.getDate() + 1)
569
+ delay = next.getTime() - now.getTime()
570
+ }
571
+ state.timer = setTimeout(() => {
572
+ state.timer = null
573
+ void runTest(state, 'schedule').finally(() => { scheduleNext(state) })
574
+ }, delay)
575
+ if (typeof state.timer.unref === 'function') state.timer.unref()
576
+ }
577
+
578
+ /**
579
+ * 把状态整理成浏览器能直接吃的快照。
580
+ * @param {object} state - 插件状态。
581
+ * @returns {object} 快照。
582
+ */
583
+ function snapshot(state) {
584
+ const progress = state.running && state.progress !== null
585
+ ? {
586
+ phase: state.progress.phase,
587
+ phaseIndex: state.progress.phaseIndex,
588
+ phaseCount: state.progress.phaseCount,
589
+ bytes: state.progress.bytes,
590
+ instantBps: state.progress.instantBps,
591
+ elapsedMs: state.progress.elapsedMs,
592
+ durationMs: state.progress.durationMs,
593
+ samples: state.progress.samples
594
+ }
595
+ : null
596
+ return {
597
+ ok: true,
598
+ plugin: name,
599
+ running: state.running,
600
+ progress,
601
+ config: state.config,
602
+ last: state.last,
603
+ history: state.history.slice(0, 20),
604
+ loopbackPort: state.port,
605
+ customImage: findCustomImage() !== null,
606
+ limits: LIMITS
607
+ }
608
+ }
609
+
610
+ /**
611
+ * 找用户自己上传的那张图(按扩展名依次找)。
612
+ * @returns {{path:string,mime:string}|null} 命中结果,没上传过时 null。
613
+ */
614
+ function findCustomImage() {
615
+ for (const [mime, ext] of Object.entries(IMAGE_TYPES)) {
616
+ const file = join(dshHome(), STORE_DIR, `sidebar-image.${ext}`)
617
+ if (existsSync(file)) return { path: file, mime }
618
+ }
619
+ return null
620
+ }
621
+
622
+ /**
623
+ * 删掉所有自定义图(恢复默认)。
624
+ * @returns {Promise<void>} 完成。
625
+ */
626
+ async function clearCustomImages() {
627
+ for (const ext of Object.values(IMAGE_TYPES)) {
628
+ try {
629
+ await rm(join(dshHome(), STORE_DIR, `sidebar-image.${ext}`), { force: true })
630
+ } catch {
631
+ /* 删不掉就算了 */
632
+ }
633
+ }
634
+ }
635
+
636
+ /**
637
+ * 插件入口:起回环测速口、读配置、排定时、挂路由。
638
+ * @param {import('@deepseek-ai/cordis').Context} ctx - 宿主上下文。
639
+ * @returns {void}
640
+ */
641
+ export function apply(ctx) {
642
+ const state = createState()
643
+
644
+ // 路由先挂上(同步),状态随后异步就绪;处理器统一 await state.ready。
645
+ state.ready = (async () => {
646
+ const dir = join(dshHome(), STORE_DIR)
647
+ const storedConfig = await readJsonFile(join(dir, 'config.json'), null)
648
+ if (storedConfig !== null) state.config = normalizeConfig(storedConfig, state.config)
649
+ const storedHistory = await readJsonFile(join(dir, 'history.json'), [])
650
+ if (Array.isArray(storedHistory)) {
651
+ state.history = storedHistory.slice(0, HISTORY_LIMIT)
652
+ state.last = state.history[0] ?? null
653
+ }
654
+
655
+ const server = createLoopbackServer(() => state.port)
656
+ state.server = server
657
+ await new Promise((resolve) => {
658
+ server.once('error', (error) => {
659
+ ctx.logger.warn(`net-speed: 回环测速口起不来: ${String((error && error.message) || error)}`)
660
+ resolve()
661
+ })
662
+ server.listen(0, '127.0.0.1', () => {
663
+ const address = server.address()
664
+ if (address !== null && typeof address === 'object') state.port = address.port
665
+ ctx.logger.info(`net-speed: 回环测速口就绪 127.0.0.1:${state.port}(只在本机内跑,不连外部服务器)`)
666
+ resolve()
667
+ })
668
+ })
669
+ scheduleNext(state)
670
+ })()
671
+
672
+ ctx.effect(() => () => {
673
+ if (state.timer !== null) clearTimeout(state.timer)
674
+ state.timer = null
675
+ state.cancel = true
676
+ const server = state.server
677
+ if (server !== null) server.close()
678
+ }, 'net-speed: 关掉回环测速口与定时器')
679
+
680
+ ctx.effect(() => ctx.webServer.register({
681
+ kind: 'exact',
682
+ path: `${BASE}/state`,
683
+ handler: async (req, res) => {
684
+ try {
685
+ await state.ready
686
+ sendJson(res, 200, snapshot(state))
687
+ } catch (error) {
688
+ sendJson(res, 500, { ok: false, error: String((error && error.message) || error) })
689
+ }
690
+ }
691
+ }), 'net-speed: /state')
692
+
693
+ ctx.effect(() => ctx.webServer.register({
694
+ kind: 'exact',
695
+ path: `${BASE}/run`,
696
+ handler: async (req, res) => {
697
+ if (req.method !== 'POST') {
698
+ sendJson(res, 405, { ok: false, error: 'POST only' })
699
+ return
700
+ }
701
+ try {
702
+ await state.ready
703
+ if (state.running) {
704
+ sendJson(res, 409, { ok: false, error: 'busy' })
705
+ return
706
+ }
707
+ // 后台跑:浏览器靠轮询 /state 看进度,这个请求立刻返回,避免长连接。
708
+ void runTest(state, 'manual')
709
+ sendJson(res, 200, { ok: true, started: true })
710
+ } catch (error) {
711
+ sendJson(res, 500, { ok: false, error: String((error && error.message) || error) })
712
+ }
713
+ }
714
+ }), 'net-speed: /run')
715
+
716
+ ctx.effect(() => ctx.webServer.register({
717
+ kind: 'exact',
718
+ path: `${BASE}/cancel`,
719
+ handler: async (req, res) => {
720
+ if (req.method !== 'POST') {
721
+ sendJson(res, 405, { ok: false, error: 'POST only' })
722
+ return
723
+ }
724
+ state.cancel = true
725
+ sendJson(res, 200, { ok: true })
726
+ }
727
+ }), 'net-speed: /cancel')
728
+
729
+ ctx.effect(() => ctx.webServer.register({
730
+ kind: 'exact',
731
+ path: `${BASE}/config`,
732
+ handler: async (req, res) => {
733
+ if (req.method !== 'POST') {
734
+ sendJson(res, 405, { ok: false, error: 'POST only' })
735
+ return
736
+ }
737
+ try {
738
+ await state.ready
739
+ const body = await readJsonBody(req)
740
+ const config = await saveConfig(state, body.config ?? body)
741
+ scheduleNext(state)
742
+ sendJson(res, 200, { ok: true, config })
743
+ } catch (error) {
744
+ sendJson(res, 500, { ok: false, error: String((error && error.message) || error) })
745
+ }
746
+ }
747
+ }), 'net-speed: /config')
748
+
749
+ // 删记录:`{ action: 'clear' }` 清空,`{ action: 'delete', at }` 删单条。
750
+ ctx.effect(() => ctx.webServer.register({
751
+ kind: 'exact',
752
+ path: `${BASE}/history`,
753
+ handler: async (req, res) => {
754
+ if (req.method !== 'POST') {
755
+ sendJson(res, 405, { ok: false, error: 'POST only' })
756
+ return
757
+ }
758
+ try {
759
+ await state.ready
760
+ const body = await readJsonBody(req)
761
+ const action = typeof body.action === 'string' ? body.action : ''
762
+ if (action === 'clear') {
763
+ state.history = []
764
+ state.last = null
765
+ } else if (action === 'delete') {
766
+ const at = typeof body.at === 'string' ? body.at : ''
767
+ if (at === '') {
768
+ sendJson(res, 400, { ok: false, error: 'missing at' })
769
+ return
770
+ }
771
+ state.history = state.history.filter((item) => item === null || item === undefined || item.at !== at)
772
+ state.last = state.history.length > 0 ? state.history[0] : null
773
+ } else {
774
+ sendJson(res, 400, { ok: false, error: `unknown action: ${JSON.stringify(action)}` })
775
+ return
776
+ }
777
+ await persistHistory(state)
778
+ sendJson(res, 200, { ok: true, last: state.last, history: state.history.slice(0, 20) })
779
+ } catch (error) {
780
+ sendJson(res, 500, { ok: false, error: String((error && error.message) || error) })
781
+ }
782
+ }
783
+ }), 'net-speed: /history')
784
+
785
+ // 静态图:测速时客户端在侧栏和面板里弹出来的那张。
786
+ ctx.effect(() => ctx.webServer.register({
787
+ kind: 'exact',
788
+ path: `${BASE}/asset/code-upload.jpg`,
789
+ handler: async (req, res) => {
790
+ try {
791
+ const bytes = await readFile(join(HERE, '..', 'assets', 'code-upload.jpg'))
792
+ res.writeHead(200, {
793
+ 'content-type': 'image/jpeg',
794
+ 'cache-control': 'public, max-age=86400',
795
+ 'content-length': String(bytes.length)
796
+ })
797
+ res.end(bytes)
798
+ } catch (error) {
799
+ res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' })
800
+ res.end(`asset unavailable: ${String((error && error.message) || error)}`)
801
+ }
802
+ }
803
+ }), 'net-speed: 图片资源')
804
+
805
+ // 当前该显示的那张图:GET 取图(用户上传的优先,否则包内默认图),POST 换图(base64)。
806
+ ctx.effect(() => ctx.webServer.register({
807
+ kind: 'exact',
808
+ path: `${BASE}/asset/image`,
809
+ handler: async (req, res) => {
810
+ if (req.method === 'POST') {
811
+ try {
812
+ const body = await readJsonBody(req, 8 * 1024 * 1024)
813
+ const type = typeof body.type === 'string' ? body.type : ''
814
+ const ext = IMAGE_TYPES[type]
815
+ if (ext === undefined) {
816
+ sendJson(res, 400, { ok: false, error: 'unsupported image type' })
817
+ return
818
+ }
819
+ const data = typeof body.data === 'string' ? body.data : ''
820
+ if (data === '') {
821
+ sendJson(res, 400, { ok: false, error: 'missing data' })
822
+ return
823
+ }
824
+ const bytes = Buffer.from(data, 'base64')
825
+ if (bytes.length === 0) {
826
+ sendJson(res, 400, { ok: false, error: 'bad base64' })
827
+ return
828
+ }
829
+ if (bytes.length > IMAGE_MAX_BYTES) {
830
+ sendJson(res, 413, { ok: false, error: 'image too large' })
831
+ return
832
+ }
833
+ const dir = join(dshHome(), STORE_DIR)
834
+ await mkdir(dir, { recursive: true })
835
+ await clearCustomImages()
836
+ await writeFile(join(dir, `sidebar-image.${ext}`), bytes)
837
+ ctx.logger.info(`net-speed: 已保存用户上传的图片(${bytes.length} 字节 ${type})`)
838
+ sendJson(res, 200, { ok: true, bytes: bytes.length, type })
839
+ } catch (error) {
840
+ sendJson(res, 500, { ok: false, error: String((error && error.message) || error) })
841
+ }
842
+ return
843
+ }
844
+ try {
845
+ const custom = findCustomImage()
846
+ const file = custom !== null ? custom.path : DEFAULT_IMAGE
847
+ const mime = custom !== null ? custom.mime : 'image/jpeg'
848
+ const bytes = await readFile(file)
849
+ res.writeHead(200, {
850
+ 'content-type': mime,
851
+ // 换过图必须立刻生效,所以这里不缓存。
852
+ 'cache-control': 'no-store',
853
+ 'content-length': String(bytes.length)
854
+ })
855
+ res.end(bytes)
856
+ } catch (error) {
857
+ res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' })
858
+ res.end(`asset unavailable: ${String((error && error.message) || error)}`)
859
+ }
860
+ }
861
+ }), 'net-speed: /asset/image')
862
+
863
+ // 恢复默认图。
864
+ ctx.effect(() => ctx.webServer.register({
865
+ kind: 'exact',
866
+ path: `${BASE}/asset/image/reset`,
867
+ handler: async (req, res) => {
868
+ if (req.method !== 'POST') {
869
+ sendJson(res, 405, { ok: false, error: 'POST only' })
870
+ return
871
+ }
872
+ try {
873
+ await clearCustomImages()
874
+ sendJson(res, 200, { ok: true })
875
+ } catch (error) {
876
+ sendJson(res, 500, { ok: false, error: String((error && error.message) || error) })
877
+ }
878
+ }
879
+ }), 'net-speed: /asset/image/reset')
880
+ }