dsh-remote-plugin 0.6.14 → 0.6.15

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.
Binary file
package/gateway.cjs CHANGED
@@ -114,6 +114,8 @@ const CAPABILITIES = Object.freeze({
114
114
  centralAnnouncements: 2,
115
115
  feedback: 1,
116
116
  deviceKeys: 1,
117
+ healthProbes: 1,
118
+ resumableUploads: 2,
117
119
  })
118
120
 
119
121
  const MIME = {
@@ -144,6 +146,7 @@ const FS_ROOTS = (process.env.DSH_REMOTE_FS_ROOT || FS_DEFAULT_ROOT)
144
146
  .map(r => path.resolve(r.trim() === '~' ? FS_DEFAULT_ROOT : r.trim()))
145
147
  const FS_WORKSPACE_CACHE_MS = durationEnv('DSH_REMOTE_FS_WORKSPACE_CACHE_MS', 15_000, 1000, 10 * 60_000)
146
148
  const FS_MAX_UPLOAD = Number(process.env.DSH_REMOTE_FS_MAX_UPLOAD) || 2 * 1024 * 1024 * 1024
149
+ const FS_UPLOAD_TTL_MS = durationEnv('DSH_REMOTE_FS_UPLOAD_TTL_MS', 24 * 60 * 60 * 1000, 60_000, 7 * 24 * 60 * 60 * 1000)
147
150
  let FS_ROOT_REALS = null
148
151
  let fsWorkspaceRootsCache = { roots: [], reals: [], fetchedAt: 0 }
149
152
  let fsWorkspaceRootsFetch = null
@@ -924,6 +927,8 @@ function failDshOperation(operation, code, message, detail = '', status = null)
924
927
  detail: String(detail || '').slice(0, 1000),
925
928
  ...(status ? { status } : {}),
926
929
  })
930
+ if (status) operation.observed = status
931
+ operation.evidence = { observed: status || operation.observed || null, upstream: operation.upstream || null, events: operation.events || null }
927
932
  }
928
933
 
929
934
  function dshEventChannelStatus() {
@@ -947,6 +952,7 @@ async function runDshControlOperation(operation) {
947
952
  dshOperationStep(operation, 'checking', `正在检查 systemd 用户服务 ${DSH_SERVICE}`)
948
953
  const initial = await dshServiceStatus()
949
954
  operation.initialStatus = initial
955
+ operation.observed = initial
950
956
  if (!initial.supported) {
951
957
  failDshOperation(operation, initial.code || 'UNSUPPORTED', initial.message || '当前 DSH 服务不可控', initial.detail, initial)
952
958
  return
@@ -980,6 +986,7 @@ async function runDshControlOperation(operation) {
980
986
  const status = await dshServiceStatus()
981
987
  lastStatus = status
982
988
  operation.status = status
989
+ operation.observed = status
983
990
  if (!status.supported) {
984
991
  failDshOperation(operation, status.code || 'STATUS_FAILED', status.message || '无法读取 DSH 服务状态', status.detail, status)
985
992
  return
@@ -1011,6 +1018,7 @@ async function runDshControlOperation(operation) {
1011
1018
  dshOperationStep(operation, 'complete', `DSH ${operation.action === 'start' ? '启动' : '重启'}成功:服务已运行,HTTP ${lastProbe.status},实时通道已连接,PID ${status.mainPid || '未知'}`, {
1012
1019
  ok: true, done: true, code: 'SUCCESS', status, upstream: lastProbe, events,
1013
1020
  })
1021
+ operation.evidence = { observed: status, upstream: lastProbe, events }
1014
1022
  return
1015
1023
  }
1016
1024
  }
@@ -1110,6 +1118,9 @@ async function serveDshControl(req, res, url) {
1110
1118
  const now = Date.now()
1111
1119
  dshControlOperation = {
1112
1120
  operationId: crypto.randomUUID(), action, service: DSH_SERVICE,
1121
+ desired: { service: DSH_SERVICE, running: true, action },
1122
+ observed: null,
1123
+ evidence: null,
1113
1124
  ok: false, accepted: true, done: false, stage: 'queued', code: 'ACCEPTED',
1114
1125
  message: `已接收 DSH ${action === 'start' ? '启动' : '重启'}请求,等待检查服务`,
1115
1126
  startedAt: now, updatedAt: now, steps: [],
@@ -1125,12 +1136,14 @@ async function serveDshControl(req, res, url) {
1125
1136
  // 内存环形缓冲并广播给已认证客户端;前端在 WebSocket 被隧道/受限网络
1126
1137
  // 阻断时改走 GET /api/events.poll 增量拉取。
1127
1138
  const EVENT_BUFFER_MAX = durationEnv('GATEWAY_EVENT_BUFFER_MAX', 1000, 100, 10000)
1139
+ const EVENT_POLL_WAIT_MAX = durationEnv('GATEWAY_EVENT_POLL_WAIT_MS', 25000, 0, 60000)
1128
1140
  const EVENT_MAX_STRING = 16 * 1024
1129
1141
  const WS_GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11'
1130
1142
  const eventBuffers = { mux: [], host: [] }
1131
1143
  const eventNextSeq = { mux: 1, host: 1 }
1132
1144
  const collectorClients = { mux: new Set(), host: new Set() }
1133
1145
  const collectorReplay = { mux: new Map(), host: new Map() }
1146
+ const eventPollWaiters = { mux: new Set(), host: new Set() }
1134
1147
  const eventCollectorState = {
1135
1148
  mux: { connected: false, lastEventAt: 0, lastConnectAt: 0, reconnects: 0, lastError: '', lastCloseCode: 0, lastCloseReason: '', attempt: 0, clients: 0, framesBroadcast: 0, lastBroadcastAt: 0 },
1136
1149
  host: { connected: false, lastEventAt: 0, lastConnectAt: 0, reconnects: 0, lastError: '', lastCloseCode: 0, lastCloseReason: '', attempt: 0, clients: 0, framesBroadcast: 0, lastBroadcastAt: 0 },
@@ -1213,6 +1226,38 @@ function pushEvent(kind, full, raw = JSON.stringify(full)) {
1213
1226
  if (buf.length > EVENT_BUFFER_MAX) buf.shift()
1214
1227
  rememberCollectorReplay(kind, full, raw)
1215
1228
  broadcastCollectorFrame(kind, raw)
1229
+ flushEventPollWaiters(kind)
1230
+ }
1231
+
1232
+ function eventPollPayload(kind, since, waitSupported = false) {
1233
+ const buf = eventBuffers[kind]
1234
+ const events = buf.filter(r => r.seq > since)
1235
+ const latestSeq = buf.length ? buf[buf.length - 1].seq : 0
1236
+ const truncated = buf.length > 0 && since < buf[0].seq - 1
1237
+ return { ok: true, kind, since, latestSeq, truncated, waitSupported, events }
1238
+ }
1239
+
1240
+ function sendEventPollResponse(waiter, payload) {
1241
+ if (waiter.req.destroyed || waiter.res.destroyed) return
1242
+ cors(waiter.res)
1243
+ waiter.res.writeHead(200, {
1244
+ 'content-type': 'application/json; charset=utf-8',
1245
+ 'cache-control': 'no-store'
1246
+ })
1247
+ waiter.res.end(JSON.stringify(payload))
1248
+ }
1249
+
1250
+ function finishEventPollWaiter(waiter, send = true) {
1251
+ if (!eventPollWaiters[waiter.kind]?.delete(waiter)) return
1252
+ clearTimeout(waiter.timer)
1253
+ if (send) sendEventPollResponse(waiter, eventPollPayload(waiter.kind, waiter.since, true))
1254
+ }
1255
+
1256
+ function flushEventPollWaiters(kind) {
1257
+ for (const waiter of [...eventPollWaiters[kind]]) {
1258
+ const payload = eventPollPayload(kind, waiter.since, true)
1259
+ if (payload.events.length) finishEventPollWaiter(waiter, true)
1260
+ }
1216
1261
  }
1217
1262
 
1218
1263
  function serveWsTicket(req, res, url) {
@@ -1270,16 +1315,33 @@ function serveEventPoll(req, res, url) {
1270
1315
  res.end(JSON.stringify({ error: 'bad-since', detail: 'since 必须是非负整数' }))
1271
1316
  return
1272
1317
  }
1273
- const buf = eventBuffers[kind]
1274
- const events = buf.filter(r => r.seq > since)
1275
- const latestSeq = buf.length ? buf[buf.length - 1].seq : 0
1276
- const truncated = buf.length > 0 && since < buf[0].seq - 1
1277
- cors(res)
1278
- res.writeHead(200, {
1279
- 'content-type': 'application/json; charset=utf-8',
1280
- 'cache-control': 'no-store'
1281
- })
1282
- res.end(JSON.stringify({ ok: true, kind, since, latestSeq, truncated, events }))
1318
+ const waitRaw = url.searchParams.get('wait')
1319
+ const requestedWait = waitRaw === null ? 0 : Number(waitRaw)
1320
+ if (!Number.isFinite(requestedWait) || requestedWait < 0) {
1321
+ cors(res)
1322
+ res.writeHead(400, { 'content-type': 'application/json; charset=utf-8' })
1323
+ res.end(JSON.stringify({ error: 'bad-wait', detail: 'wait 必须是非负数字' }))
1324
+ return
1325
+ }
1326
+ const wait = Math.min(Math.floor(requestedWait), EVENT_POLL_WAIT_MAX)
1327
+ const waitSupported = wait > 0 && EVENT_POLL_WAIT_MAX > 0
1328
+ const payload = eventPollPayload(kind, since, waitSupported)
1329
+ if (payload.events.length || wait <= 0 || EVENT_POLL_WAIT_MAX <= 0) {
1330
+ cors(res)
1331
+ res.writeHead(200, {
1332
+ 'content-type': 'application/json; charset=utf-8',
1333
+ 'cache-control': 'no-store'
1334
+ })
1335
+ res.end(JSON.stringify(payload))
1336
+ return
1337
+ }
1338
+ const waiter = { req, res, kind, since, timer: null }
1339
+ eventPollWaiters[kind].add(waiter)
1340
+ waiter.timer = setTimeout(() => finishEventPollWaiter(waiter, true), wait)
1341
+ waiter.timer.unref?.()
1342
+ req.once('close', () => finishEventPollWaiter(waiter, false))
1343
+ // 事件可能刚好在首次检查和加入等待集合之间到达,加入后再检查一次避免漏唤醒。
1344
+ if (eventPollPayload(kind, since, true).events.length) finishEventPollWaiter(waiter, true)
1283
1345
  }
1284
1346
 
1285
1347
  /** 网关自带上游事件采集:mux/host 各一条 WS,断线自动重连。 */
@@ -2059,9 +2121,9 @@ function serveAdminApi(req, res, url) {
2059
2121
  }
2060
2122
 
2061
2123
  // ---------- /fs 文件传输: 实现 ----------
2062
- function fsJson(res, status, body) {
2124
+ function fsJson(res, status, body, extraHeaders = {}) {
2063
2125
  cors(res)
2064
- res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' })
2126
+ res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', ...extraHeaders })
2065
2127
  res.end(JSON.stringify(body))
2066
2128
  }
2067
2129
 
@@ -2173,6 +2235,17 @@ function fsParseRange(header, size) {
2173
2235
  return { start, end: Math.min(end, size - 1) }
2174
2236
  }
2175
2237
 
2238
+ function fsEntityTag(st) {
2239
+ return `"${Number(st.size).toString(16)}-${Math.floor(Number(st.mtimeMs)).toString(16)}"`
2240
+ }
2241
+
2242
+ function fsIfRangeMatches(value, etag, mtimeMs) {
2243
+ if (!value) return true
2244
+ if (String(value).trim() === etag) return true
2245
+ const time = Date.parse(String(value))
2246
+ return Number.isFinite(time) && time >= Math.floor(Number(mtimeMs) / 1000) * 1000
2247
+ }
2248
+
2176
2249
  async function fsList(req, res, url) {
2177
2250
  if (req.method !== 'GET') {
2178
2251
  res.writeHead(405, { allow: 'GET' })
@@ -2241,7 +2314,17 @@ async function fsFile(req, res, url) {
2241
2314
  }
2242
2315
  if (!st.isFile()) return fsJson(res, 400, { error: 'not-a-file' })
2243
2316
 
2244
- const range = fsParseRange(req.headers.range, st.size)
2317
+ const etag = fsEntityTag(st)
2318
+ const lastModified = st.mtime.toUTCString()
2319
+ if (!req.headers.range && req.headers['if-none-match'] === etag) {
2320
+ cors(res)
2321
+ res.writeHead(304, { etag, 'last-modified': lastModified, 'cache-control': 'no-cache' })
2322
+ res.end()
2323
+ return
2324
+ }
2325
+ const range = fsIfRangeMatches(req.headers['if-range'], etag, st.mtimeMs)
2326
+ ? fsParseRange(req.headers.range, st.size)
2327
+ : null
2245
2328
  if (range && range.start >= st.size) {
2246
2329
  cors(res)
2247
2330
  res.writeHead(416, {
@@ -2260,6 +2343,8 @@ async function fsFile(req, res, url) {
2260
2343
  'content-length': range ? range.end - range.start + 1 : st.size,
2261
2344
  'content-disposition': fsContentDisposition(path.basename(checked.abs)),
2262
2345
  'accept-ranges': 'bytes',
2346
+ etag,
2347
+ 'last-modified': lastModified,
2263
2348
  'cache-control': 'no-cache',
2264
2349
  ...(range ? { 'content-range': `bytes ${range.start}-${range.end}/${st.size}` } : {})
2265
2350
  })
@@ -2335,10 +2420,49 @@ function sha256FileHex(file, cb) {
2335
2420
 
2336
2421
  /* 进行中的续传写流: 取消时先 destroy 再删分片, 避免“先删后写”竞态 */
2337
2422
  const activeUploads = new Map()
2423
+ const uploadPartDirs = new Set()
2338
2424
  function fsActiveKey(dirReal, name, session) {
2339
2425
  return dirReal + '\n' + name + '\n' + (session || '')
2340
2426
  }
2341
2427
 
2428
+ function rememberUploadDir(dirReal) {
2429
+ uploadPartDirs.add(dirReal)
2430
+ }
2431
+
2432
+ function uploadDirHasActive(dirReal) {
2433
+ const prefix = dirReal + '\n'
2434
+ for (const key of activeUploads.keys()) if (key.startsWith(prefix)) return true
2435
+ return false
2436
+ }
2437
+
2438
+ function cleanupExpiredUploadParts(dirReal = '') {
2439
+ const dirs = dirReal ? [dirReal] : [...uploadPartDirs]
2440
+ const now = Date.now()
2441
+ for (const dir of dirs) {
2442
+ if (uploadDirHasActive(dir)) continue
2443
+ let entries
2444
+ try { entries = fs.readdirSync(dir) } catch { continue }
2445
+ for (const name of entries) {
2446
+ if (!name.startsWith('.') || !name.includes('.dsh-remote-part-')) continue
2447
+ const file = path.join(dir, name)
2448
+ try {
2449
+ const st = fs.statSync(file)
2450
+ if (st.isFile() && now - st.mtimeMs > FS_UPLOAD_TTL_MS) fs.unlinkSync(file)
2451
+ } catch {}
2452
+ }
2453
+ }
2454
+ }
2455
+
2456
+ const uploadCleanupTimer = setInterval(() => cleanupExpiredUploadParts(), 15 * 60 * 1000)
2457
+ uploadCleanupTimer.unref?.()
2458
+
2459
+ function fsUploadHeaders(offset, length = null, expiresAt = null) {
2460
+ const headers = { 'upload-offset': String(Math.max(0, Number(offset) || 0)) }
2461
+ if (Number.isSafeInteger(length) && length >= 0) headers['upload-length'] = String(length)
2462
+ if (Number.isFinite(expiresAt)) headers['upload-expires'] = new Date(expiresAt).toUTCString()
2463
+ return headers
2464
+ }
2465
+
2342
2466
  /** 打开上传目标: 同名冲突/符号链接/临时文件都在这层判定。 */
2343
2467
  function fsOpenUploadTarget(res, url, dirLex, dirReal, name) {
2344
2468
  if (!fsValidName(name)) {
@@ -2539,14 +2663,22 @@ async function fsUploadProbe(req, res, url) {
2539
2663
  if (resolved.error) return fsJson(res, resolved.error === 'forbidden' ? 403 : 404, { error: resolved.error })
2540
2664
  const checked = fsRealChecked(resolved.abs)
2541
2665
  if (checked.error) return fsJson(res, checked.error === 'forbidden' ? 403 : 404, { error: checked.error })
2666
+ rememberUploadDir(checked.abs)
2667
+ cleanupExpiredUploadParts(checked.abs)
2542
2668
  const name = url.searchParams.get('name') || ''
2543
2669
  if (!fsValidName(name)) return fsJson(res, 400, { error: 'bad-name' })
2544
2670
  const part = fsPartPath(checked.abs, name, url.searchParams.get('session') || 'default')
2545
2671
  let partialSize = 0, partExists = false
2672
+ let expiresAt = null
2546
2673
  try {
2547
2674
  const st = fs.statSync(part)
2548
- if (st.isFile()) { partialSize = st.size; partExists = true }
2675
+ if (st.isFile()) { partialSize = st.size; partExists = true; expiresAt = st.mtimeMs + FS_UPLOAD_TTL_MS }
2549
2676
  } catch {}
2677
+ const lengthRaw = url.searchParams.get('size')
2678
+ const uploadLength = lengthRaw === null || lengthRaw === '' ? null : Number(lengthRaw)
2679
+ if (uploadLength !== null && (!Number.isSafeInteger(uploadLength) || uploadLength < 0)) {
2680
+ return fsJson(res, 400, { error: 'bad-length', detail: 'size 必须是非负整数' })
2681
+ }
2550
2682
  const target = fsTargetState(path.join(checked.abs, name))
2551
2683
  let targetSize = 0
2552
2684
  if (target.exists) {
@@ -2558,8 +2690,10 @@ async function fsUploadProbe(req, res, url) {
2558
2690
  partialSize,
2559
2691
  partExists,
2560
2692
  targetExists: !!target.exists,
2561
- targetSize
2562
- })
2693
+ targetSize,
2694
+ uploadLength,
2695
+ expiresAt
2696
+ }, fsUploadHeaders(partialSize, uploadLength, expiresAt))
2563
2697
  }
2564
2698
 
2565
2699
  /** POST /fs/mkdir?path=<parent>&name=<directory> 创建一个工作区目录。 */
@@ -2594,15 +2728,27 @@ async function fsMkdir(req, res, url) {
2594
2728
  }
2595
2729
 
2596
2730
  function fsUploadResumable(req, res, url, dirLex, dirReal) {
2731
+ rememberUploadDir(dirReal)
2732
+ cleanupExpiredUploadParts(dirReal)
2597
2733
  const name = url.searchParams.get('name') || ''
2598
2734
  if (!fsValidName(name)) return fsJson(res, 400, { error: 'bad-name', detail: '文件名不能为空且不能包含路径分隔符' })
2599
2735
  const session = url.searchParams.get('session') || ''
2600
2736
  if (!session) return fsJson(res, 400, { error: 'missing-session', detail: '断点续传需要 session 参数' })
2601
- const offsetRaw = url.searchParams.get('offset')
2737
+ const queryOffsetRaw = url.searchParams.get('offset')
2738
+ const headerOffsetRaw = req.headers['upload-offset']
2739
+ const offsetRaw = queryOffsetRaw ?? headerOffsetRaw
2602
2740
  const offset = Number(offsetRaw)
2603
2741
  if (!Number.isSafeInteger(offset) || offset < 0) {
2604
2742
  return fsJson(res, 400, { error: 'bad-offset', detail: 'offset 必须是非负整数' })
2605
2743
  }
2744
+ if (queryOffsetRaw !== null && headerOffsetRaw !== undefined && Number(headerOffsetRaw) !== offset) {
2745
+ return fsJson(res, 400, { error: 'offset-mismatch', detail: 'URL offset 与 Upload-Offset 不一致' })
2746
+ }
2747
+ const lengthRaw = url.searchParams.get('size') ?? req.headers['upload-length']
2748
+ const uploadLength = lengthRaw === null || lengthRaw === undefined || lengthRaw === '' ? null : Number(lengthRaw)
2749
+ if (uploadLength !== null && (!Number.isSafeInteger(uploadLength) || uploadLength < 0)) {
2750
+ return fsJson(res, 400, { error: 'bad-length', detail: 'size/Upload-Length 必须是非负整数' })
2751
+ }
2606
2752
  const finish = url.searchParams.get('finish') === '1' || url.searchParams.get('complete') === '1'
2607
2753
  const overwrite = url.searchParams.get('overwrite') === '1' || url.searchParams.get('overwrite') === 'true'
2608
2754
  const sha256Expected = (url.searchParams.get('sha256') || '').trim().toLowerCase()
@@ -2687,8 +2833,16 @@ function fsUploadResumable(req, res, url, dirLex, dirReal) {
2687
2833
  try {
2688
2834
  const st = fs.statSync(part)
2689
2835
  if (!st.isFile() || st.size !== total) throw new Error('part-size-mismatch')
2836
+ if (uploadLength !== null && total > uploadLength) {
2837
+ fsJson(res, 409, { error: 'length-exceeded', size: total, uploadLength, session }, fsUploadHeaders(total, uploadLength, Date.now() + FS_UPLOAD_TTL_MS))
2838
+ return
2839
+ }
2690
2840
  if (!finish) {
2691
- fsJson(res, 200, { ok: true, partial: true, name, size: total, offset: total, session })
2841
+ fsJson(res, 200, { ok: true, partial: true, name, size: total, offset: total, session, uploadLength }, fsUploadHeaders(total, uploadLength, Date.now() + FS_UPLOAD_TTL_MS))
2842
+ return
2843
+ }
2844
+ if (uploadLength !== null && total !== uploadLength) {
2845
+ fsJson(res, 409, { error: 'length-incomplete', size: total, uploadLength, session }, fsUploadHeaders(total, uploadLength, Date.now() + FS_UPLOAD_TTL_MS))
2692
2846
  return
2693
2847
  }
2694
2848
  const commit = (actualSha256) => {
@@ -2698,7 +2852,7 @@ function fsUploadResumable(req, res, url, dirLex, dirReal) {
2698
2852
  if (ts.exists && !overwrite) return fsJson(res, 409, { error: 'conflict', detail: '文件已存在, overwrite=1 可覆盖' })
2699
2853
  if (ts.exists) fs.rmSync(target, { force: true })
2700
2854
  fs.renameSync(part, target)
2701
- fsJson(res, 201, { ok: true, name, path: path.join(dirLex, name), size: total, resumed: offset > 0, session, ...(actualSha256 ? { sha256: actualSha256 } : {}) })
2855
+ fsJson(res, 201, { ok: true, name, path: path.join(dirLex, name), size: total, resumed: offset > 0, session, uploadLength, ...(actualSha256 ? { sha256: actualSha256 } : {}) }, fsUploadHeaders(total, uploadLength))
2702
2856
  } catch (err) {
2703
2857
  if (!res.headersSent) fsJson(res, 403, { error: 'write-failed', detail: err.message })
2704
2858
  else try { res.destroy() } catch {}
@@ -2709,7 +2863,7 @@ function fsUploadResumable(req, res, url, dirLex, dirReal) {
2709
2863
  sha256FileHex(part, (err, actual) => {
2710
2864
  if (err) return fsJson(res, 403, { error: 'checksum-failed', detail: err.message })
2711
2865
  if (actual !== sha256Expected) {
2712
- return fsJson(res, 422, { error: 'checksum-mismatch', expected: sha256Expected, actual, partialSize: total, session })
2866
+ return fsJson(res, 422, { error: 'checksum-mismatch', expected: sha256Expected, actual, partialSize: total, session }, fsUploadHeaders(total, uploadLength, Date.now() + FS_UPLOAD_TTL_MS))
2713
2867
  }
2714
2868
  commit(actual)
2715
2869
  })
@@ -2738,6 +2892,8 @@ async function fsUploadControl(req, res, url) {
2738
2892
  if (resolved.error) return fsJson(res, resolved.error === 'forbidden' ? 403 : 404, { error: resolved.error })
2739
2893
  const checked = fsRealChecked(resolved.abs)
2740
2894
  if (checked.error) return fsJson(res, checked.error === 'forbidden' ? 403 : 404, { error: checked.error })
2895
+ rememberUploadDir(checked.abs)
2896
+ cleanupExpiredUploadParts(checked.abs)
2741
2897
  const name = url.searchParams.get('name') || ''
2742
2898
  if (!fsValidName(name)) return fsJson(res, 400, { error: 'bad-name' })
2743
2899
  const session = url.searchParams.get('session') || 'default'
@@ -2998,7 +3154,24 @@ function proxyApi(req, res, url) {
2998
3154
  }
2999
3155
 
3000
3156
  // ---------- 其它 ----------
3001
- async function serveHealth(res) {
3157
+ async function serveHealth(req, res, url) {
3158
+ const eventHealth = Object.fromEntries(Object.entries(eventCollectorState).map(([kind, state]) => [kind, {
3159
+ connected: state.connected,
3160
+ lastEventAt: state.lastEventAt,
3161
+ eventLagMs: state.lastEventAt ? Math.max(0, Date.now() - state.lastEventAt) : null,
3162
+ lastConnectAt: state.lastConnectAt,
3163
+ reconnects: state.reconnects,
3164
+ attempt: state.attempt,
3165
+ lastError: state.lastError,
3166
+ clients: state.clients,
3167
+ }]))
3168
+ const liveness = { ok: true, pid: process.pid, uptimeMs: Math.max(0, Date.now() - STARTED_AT), runtime: runtimeState }
3169
+ if (url?.searchParams.get('probe') === 'live') {
3170
+ cors(res)
3171
+ res.writeHead(200, { 'content-type': 'application/json' })
3172
+ res.end(JSON.stringify({ ok: true, service: 'dsh-remote', version: VERSION, probe: 'live', liveness }))
3173
+ return
3174
+ }
3002
3175
  let upstreamOk = false
3003
3176
  let upstreamReachable = false
3004
3177
  let upstreamStatus = 0
@@ -3017,11 +3190,17 @@ async function serveHealth(res) {
3017
3190
  } finally {
3018
3191
  if (timer) clearTimeout(timer)
3019
3192
  }
3193
+ const eventsOk = eventHealth.mux.connected && eventHealth.host.connected
3194
+ const readiness = { ok: upstreamOk && eventsOk, upstreamOk, eventsOk }
3195
+ const status = readiness.ok ? 'ready' : upstreamReachable ? 'degraded' : 'offline'
3020
3196
  cors(res)
3021
3197
  res.writeHead(200, { 'content-type': 'application/json; charset=utf-8' })
3022
3198
  res.end(JSON.stringify({
3023
3199
  ok: true,
3024
3200
  service: 'dsh-remote',
3201
+ status,
3202
+ liveness,
3203
+ readiness,
3025
3204
  version: VERSION,
3026
3205
  protocol: { version: PROTOCOL_VERSION },
3027
3206
  capabilities: CAPABILITIES,
@@ -3032,7 +3211,7 @@ async function serveHealth(res) {
3032
3211
  upstreamReachable,
3033
3212
  upstreamStatus,
3034
3213
  ...(upstreamError ? { upstreamError } : {}),
3035
- events: eventCollectorState,
3214
+ events: eventHealth,
3036
3215
  runtime: runtimeState,
3037
3216
  }))
3038
3217
  }
@@ -3061,7 +3240,7 @@ const server = http.createServer(async (req, res) => {
3061
3240
  if (url.pathname === '/api/events.poll') return serveEventPoll(req, res, url)
3062
3241
  if (url.pathname.startsWith('/remote/')) return proxyApi(req, res, url)
3063
3242
  if (url.pathname.startsWith('/api/')) return proxyApi(req, res, url)
3064
- if (url.pathname === '/health') return serveHealth(res)
3243
+ if (url.pathname === '/health') return serveHealth(req, res, url)
3065
3244
  touchDevice(req)
3066
3245
  return serveStatic(req, res, url)
3067
3246
  } catch (err) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-remote-plugin",
3
- "version": "0.6.14",
3
+ "version": "0.6.15",
4
4
  "description": "DSH Remote 官方 bundle 插件:DSH 左侧原生边栏入口 + 右侧抽屉内嵌管理控制台;内置网关随 DSH 自动启停(systemd 独立单元),抽屉直显令牌与设备监控;网关提供 /fs/* 文件传输端点(列表/断点下载/上传),配合 Android App 远程操控会话/审批/提问/goal 与文件互传(多服务器测速切换、聊天记录离线缓存)。",
5
5
  "type": "module",
6
6
  "main": "./index.mjs",