dsh-remote-plugin 0.6.14 → 0.6.16

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
@@ -83,6 +83,7 @@ const WORKBENCH_FILE = process.env.DSH_REMOTE_WORKBENCH || path.join(os.homedir(
83
83
  const STARTED_AT = Date.now()
84
84
  const DSH_SERVICE = String(process.env.DSH_REMOTE_DSH_SERVICE || 'dsh-web').trim()
85
85
  const SYSTEMCTL = String(process.env.DSH_REMOTE_SYSTEMCTL || 'systemctl').trim() || 'systemctl'
86
+ const WINDOWS_SC = String(process.env.DSH_REMOTE_WINDOWS_SC || 'sc.exe').trim() || 'sc.exe'
86
87
  const DSH_CONTROL_TIMEOUT_MS = durationEnv('DSH_REMOTE_DSH_CONTROL_TIMEOUT_MS', 45000, 2000, 5 * 60 * 1000)
87
88
  const DSH_CONTROL_POLL_MS = durationEnv('DSH_REMOTE_DSH_CONTROL_POLL_MS', 500, 50, 5000)
88
89
  const HTTP_REQUEST_TIMEOUT_MS = durationEnv('GATEWAY_HTTP_REQUEST_TIMEOUT_MS', 15 * 60 * 1000, 0, 24 * 60 * 60 * 1000)
@@ -114,6 +115,8 @@ const CAPABILITIES = Object.freeze({
114
115
  centralAnnouncements: 2,
115
116
  feedback: 1,
116
117
  deviceKeys: 1,
118
+ healthProbes: 1,
119
+ resumableUploads: 2,
117
120
  })
118
121
 
119
122
  const MIME = {
@@ -144,6 +147,7 @@ const FS_ROOTS = (process.env.DSH_REMOTE_FS_ROOT || FS_DEFAULT_ROOT)
144
147
  .map(r => path.resolve(r.trim() === '~' ? FS_DEFAULT_ROOT : r.trim()))
145
148
  const FS_WORKSPACE_CACHE_MS = durationEnv('DSH_REMOTE_FS_WORKSPACE_CACHE_MS', 15_000, 1000, 10 * 60_000)
146
149
  const FS_MAX_UPLOAD = Number(process.env.DSH_REMOTE_FS_MAX_UPLOAD) || 2 * 1024 * 1024 * 1024
150
+ const FS_UPLOAD_TTL_MS = durationEnv('DSH_REMOTE_FS_UPLOAD_TTL_MS', 24 * 60 * 60 * 1000, 60_000, 7 * 24 * 60 * 60 * 1000)
147
151
  let FS_ROOT_REALS = null
148
152
  let fsWorkspaceRootsCache = { roots: [], reals: [], fetchedAt: 0 }
149
153
  let fsWorkspaceRootsFetch = null
@@ -837,13 +841,91 @@ function classifySystemctlFailure(result) {
837
841
  return { code: 'COMMAND_FAILED', message: 'systemctl 未能接受 DSH 控制命令', detail }
838
842
  }
839
843
 
840
- async function dshServiceStatus() {
841
- if (process.platform === 'win32') {
842
- return { ok: true, supported: false, running: false, service: DSH_SERVICE, code: 'PLATFORM_UNSUPPORTED', message: 'Windows 暂不支持通过 systemd 远程控制 DSH' }
844
+ const WINDOWS_SERVICE_STATE_NAMES = Object.freeze({
845
+ 1: 'STOPPED',
846
+ 2: 'START_PENDING',
847
+ 3: 'STOP_PENDING',
848
+ 4: 'RUNNING',
849
+ 5: 'CONTINUE_PENDING',
850
+ 6: 'PAUSE_PENDING',
851
+ 7: 'PAUSED',
852
+ })
853
+
854
+ function parseWindowsServiceQuery(output) {
855
+ const text = String(output || '')
856
+ const state = /\bSTATE\s*:\s*(\d+)(?:\s+([^\r\n(]+))?/i.exec(text)
857
+ if (!state) return null
858
+ const stateCode = Number(state[1])
859
+ const stateName = WINDOWS_SERVICE_STATE_NAMES[stateCode] || String(state[2] || 'UNKNOWN').trim().split(/\s+/)[0].toUpperCase()
860
+ const pid = /\bPID\s*:\s*(\d+)/i.exec(text)
861
+ const pending = [2, 3, 5, 6].includes(stateCode)
862
+ return {
863
+ stateCode,
864
+ stateName,
865
+ pending,
866
+ running: stateCode === 4,
867
+ mainPid: Number(pid?.[1]) || 0,
868
+ }
869
+ }
870
+
871
+ function classifyWindowsServiceFailure(result) {
872
+ const detail = [result?.stderr, result?.stdout, result?.error].filter(Boolean).join(' · ').slice(0, 1000)
873
+ const code = String(result?.code ?? '')
874
+ if (result?.timedOut) return { code: 'COMMAND_TIMEOUT', message: 'Windows 服务控制命令超时', detail }
875
+ if (code === 'ENOENT') return { code: 'SERVICE_CONTROL_NOT_FOUND', message: '系统中找不到 sc.exe', detail }
876
+ if (code === '1060' || /1060|does not exist|cannot find the file specified|找不到指定的服务/i.test(detail)) {
877
+ return { code: 'SERVICE_NOT_FOUND', message: `未找到 Windows 服务 ${DSH_SERVICE}`, detail }
843
878
  }
879
+ if (code === '1058' || /1058|disabled|禁用/i.test(detail)) return { code: 'SERVICE_DISABLED', message: `Windows 服务 ${DSH_SERVICE} 已被禁用`, detail }
880
+ if (/access is denied|permission denied|not authorized|需要提升|拒绝访问/i.test(detail)) return { code: 'PERMISSION_DENIED', message: '当前用户无权控制 Windows DSH 服务', detail }
881
+ if (code === 'SERVICE_STOP_TIMEOUT') return { code, message: `Windows 服务 ${DSH_SERVICE} 停止超时`, detail }
882
+ return { code: 'COMMAND_FAILED', message: 'Windows 服务控制命令失败', detail }
883
+ }
884
+
885
+ function classifyDshServiceFailure(result) {
886
+ return process.platform === 'win32' ? classifyWindowsServiceFailure(result) : classifySystemctlFailure(result)
887
+ }
888
+
889
+ async function windowsServiceStatus() {
890
+ const r = await execFileResult(WINDOWS_SC, ['queryex', DSH_SERVICE], 5000)
891
+ if (!r.ok) {
892
+ const failure = classifyWindowsServiceFailure(r)
893
+ if (failure.code === 'SERVICE_NOT_FOUND') {
894
+ return { ok: true, supported: false, running: false, service: DSH_SERVICE, ...failure }
895
+ }
896
+ return { ok: false, supported: false, running: false, service: DSH_SERVICE, ...failure }
897
+ }
898
+ const parsed = parseWindowsServiceQuery([r.stdout, r.stderr].filter(Boolean).join('\n'))
899
+ if (!parsed) {
900
+ return {
901
+ ok: false, supported: false, running: false, service: DSH_SERVICE,
902
+ code: 'STATUS_PARSE_FAILED', message: '无法解析 Windows DSH 服务状态', detail: r.stdout || r.stderr || 'sc.exe 没有返回 STATE',
903
+ }
904
+ }
905
+ const activeState = parsed.running ? 'active' : parsed.stateCode === 7 ? 'paused' : parsed.pending ? 'activating' : 'inactive'
906
+ return {
907
+ ok: true,
908
+ supported: true,
909
+ running: parsed.running,
910
+ service: DSH_SERVICE,
911
+ state: activeState,
912
+ loadState: 'loaded',
913
+ activeState,
914
+ subState: parsed.stateName.toLowerCase(),
915
+ unitFileState: 'windows-service',
916
+ mainPid: parsed.mainPid,
917
+ result: '',
918
+ execMainStatus: 0,
919
+ serviceStateCode: parsed.stateCode,
920
+ serviceState: parsed.stateName,
921
+ }
922
+ }
923
+
924
+ async function dshServiceStatus() {
844
925
  if (!/^[A-Za-z0-9_.@-]+$/.test(DSH_SERVICE)) {
845
926
  return { ok: false, supported: false, running: false, service: DSH_SERVICE, code: 'INVALID_SERVICE', message: 'DSH_REMOTE_DSH_SERVICE 服务名配置不合法' }
846
927
  }
928
+ if (process.platform === 'win32') return windowsServiceStatus()
847
929
  const r = await execFileResult(SYSTEMCTL, [
848
930
  '--user', 'show', DSH_SERVICE,
849
931
  '--property=Id,LoadState,ActiveState,SubState,UnitFileState,MainPID,Result,ExecMainStatus',
@@ -881,6 +963,31 @@ async function dshServiceStatus() {
881
963
  }
882
964
  }
883
965
 
966
+ async function executeDshServiceAction(action, initial) {
967
+ if (process.platform !== 'win32') {
968
+ return execFileResult(SYSTEMCTL, ['--user', '--no-block', action, DSH_SERVICE], 5000)
969
+ }
970
+ if (action === 'restart' && initial?.running) {
971
+ const stop = await execFileResult(WINDOWS_SC, ['stop', DSH_SERVICE], 5000)
972
+ if (!stop.ok) {
973
+ const current = await windowsServiceStatus()
974
+ if (!current.supported || current.running || current.serviceStateCode !== 1) return stop
975
+ }
976
+ let stopped = false
977
+ const checks = Math.max(1, Math.ceil(DSH_CONTROL_TIMEOUT_MS / Math.max(50, DSH_CONTROL_POLL_MS)))
978
+ for (let i = 0; i < checks; i++) {
979
+ const current = await windowsServiceStatus()
980
+ if (!current.supported) {
981
+ return { ok: false, code: current.code || 'SERVICE_STATUS_FAILED', error: current.message, stderr: current.detail }
982
+ }
983
+ if (current.serviceStateCode === 1) { stopped = true; break }
984
+ await delay(DSH_CONTROL_POLL_MS)
985
+ }
986
+ if (!stopped) return { ok: false, code: 'SERVICE_STOP_TIMEOUT', error: `Windows 服务 ${DSH_SERVICE} 停止超时` }
987
+ }
988
+ return execFileResult(WINDOWS_SC, ['start', DSH_SERVICE], 5000)
989
+ }
990
+
884
991
  function delay(ms) {
885
992
  return new Promise(resolvePromise => setTimeout(resolvePromise, ms))
886
993
  }
@@ -924,6 +1031,8 @@ function failDshOperation(operation, code, message, detail = '', status = null)
924
1031
  detail: String(detail || '').slice(0, 1000),
925
1032
  ...(status ? { status } : {}),
926
1033
  })
1034
+ if (status) operation.observed = status
1035
+ operation.evidence = { observed: status || operation.observed || null, upstream: operation.upstream || null, events: operation.events || null }
927
1036
  }
928
1037
 
929
1038
  function dshEventChannelStatus() {
@@ -944,9 +1053,11 @@ function reconnectDshEventCollectors() {
944
1053
 
945
1054
  async function runDshControlOperation(operation) {
946
1055
  try {
947
- dshOperationStep(operation, 'checking', `正在检查 systemd 用户服务 ${DSH_SERVICE}`)
1056
+ const manager = process.platform === 'win32' ? 'Windows 服务' : 'systemd 用户服务'
1057
+ dshOperationStep(operation, 'checking', `正在检查 ${manager} ${DSH_SERVICE}`)
948
1058
  const initial = await dshServiceStatus()
949
1059
  operation.initialStatus = initial
1060
+ operation.observed = initial
950
1061
  if (!initial.supported) {
951
1062
  failDshOperation(operation, initial.code || 'UNSUPPORTED', initial.message || '当前 DSH 服务不可控', initial.detail, initial)
952
1063
  return
@@ -958,11 +1069,11 @@ async function runDshControlOperation(operation) {
958
1069
  return
959
1070
  }
960
1071
 
961
- dshOperationStep(operation, 'command', `正在向 systemd 提交 DSH ${operation.action === 'start' ? '启动' : '重启'}命令`)
962
- const command = await execFileResult(SYSTEMCTL, ['--user', '--no-block', operation.action, DSH_SERVICE], 5000)
1072
+ dshOperationStep(operation, 'command', `正在向 ${manager} 提交 DSH ${operation.action === 'start' ? '启动' : '重启'}命令`)
1073
+ const command = await executeDshServiceAction(operation.action, initial)
963
1074
  operation.command = { ok: command.ok, code: command.code, signal: command.signal }
964
1075
  if (!command.ok) {
965
- const failure = classifySystemctlFailure(command)
1076
+ const failure = classifyDshServiceFailure(command)
966
1077
  failDshOperation(operation, failure.code, failure.message, failure.detail, await dshServiceStatus())
967
1078
  return
968
1079
  }
@@ -980,6 +1091,7 @@ async function runDshControlOperation(operation) {
980
1091
  const status = await dshServiceStatus()
981
1092
  lastStatus = status
982
1093
  operation.status = status
1094
+ operation.observed = status
983
1095
  if (!status.supported) {
984
1096
  failDshOperation(operation, status.code || 'STATUS_FAILED', status.message || '无法读取 DSH 服务状态', status.detail, status)
985
1097
  return
@@ -1011,6 +1123,7 @@ async function runDshControlOperation(operation) {
1011
1123
  dshOperationStep(operation, 'complete', `DSH ${operation.action === 'start' ? '启动' : '重启'}成功:服务已运行,HTTP ${lastProbe.status},实时通道已连接,PID ${status.mainPid || '未知'}`, {
1012
1124
  ok: true, done: true, code: 'SUCCESS', status, upstream: lastProbe, events,
1013
1125
  })
1126
+ operation.evidence = { observed: status, upstream: lastProbe, events }
1014
1127
  return
1015
1128
  }
1016
1129
  }
@@ -1110,6 +1223,9 @@ async function serveDshControl(req, res, url) {
1110
1223
  const now = Date.now()
1111
1224
  dshControlOperation = {
1112
1225
  operationId: crypto.randomUUID(), action, service: DSH_SERVICE,
1226
+ desired: { service: DSH_SERVICE, running: true, action },
1227
+ observed: null,
1228
+ evidence: null,
1113
1229
  ok: false, accepted: true, done: false, stage: 'queued', code: 'ACCEPTED',
1114
1230
  message: `已接收 DSH ${action === 'start' ? '启动' : '重启'}请求,等待检查服务`,
1115
1231
  startedAt: now, updatedAt: now, steps: [],
@@ -1125,12 +1241,14 @@ async function serveDshControl(req, res, url) {
1125
1241
  // 内存环形缓冲并广播给已认证客户端;前端在 WebSocket 被隧道/受限网络
1126
1242
  // 阻断时改走 GET /api/events.poll 增量拉取。
1127
1243
  const EVENT_BUFFER_MAX = durationEnv('GATEWAY_EVENT_BUFFER_MAX', 1000, 100, 10000)
1244
+ const EVENT_POLL_WAIT_MAX = durationEnv('GATEWAY_EVENT_POLL_WAIT_MS', 25000, 0, 60000)
1128
1245
  const EVENT_MAX_STRING = 16 * 1024
1129
1246
  const WS_GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11'
1130
1247
  const eventBuffers = { mux: [], host: [] }
1131
1248
  const eventNextSeq = { mux: 1, host: 1 }
1132
1249
  const collectorClients = { mux: new Set(), host: new Set() }
1133
1250
  const collectorReplay = { mux: new Map(), host: new Map() }
1251
+ const eventPollWaiters = { mux: new Set(), host: new Set() }
1134
1252
  const eventCollectorState = {
1135
1253
  mux: { connected: false, lastEventAt: 0, lastConnectAt: 0, reconnects: 0, lastError: '', lastCloseCode: 0, lastCloseReason: '', attempt: 0, clients: 0, framesBroadcast: 0, lastBroadcastAt: 0 },
1136
1254
  host: { connected: false, lastEventAt: 0, lastConnectAt: 0, reconnects: 0, lastError: '', lastCloseCode: 0, lastCloseReason: '', attempt: 0, clients: 0, framesBroadcast: 0, lastBroadcastAt: 0 },
@@ -1213,6 +1331,38 @@ function pushEvent(kind, full, raw = JSON.stringify(full)) {
1213
1331
  if (buf.length > EVENT_BUFFER_MAX) buf.shift()
1214
1332
  rememberCollectorReplay(kind, full, raw)
1215
1333
  broadcastCollectorFrame(kind, raw)
1334
+ flushEventPollWaiters(kind)
1335
+ }
1336
+
1337
+ function eventPollPayload(kind, since, waitSupported = false) {
1338
+ const buf = eventBuffers[kind]
1339
+ const events = buf.filter(r => r.seq > since)
1340
+ const latestSeq = buf.length ? buf[buf.length - 1].seq : 0
1341
+ const truncated = buf.length > 0 && since < buf[0].seq - 1
1342
+ return { ok: true, kind, since, latestSeq, truncated, waitSupported, events }
1343
+ }
1344
+
1345
+ function sendEventPollResponse(waiter, payload) {
1346
+ if (waiter.req.destroyed || waiter.res.destroyed) return
1347
+ cors(waiter.res)
1348
+ waiter.res.writeHead(200, {
1349
+ 'content-type': 'application/json; charset=utf-8',
1350
+ 'cache-control': 'no-store'
1351
+ })
1352
+ waiter.res.end(JSON.stringify(payload))
1353
+ }
1354
+
1355
+ function finishEventPollWaiter(waiter, send = true) {
1356
+ if (!eventPollWaiters[waiter.kind]?.delete(waiter)) return
1357
+ clearTimeout(waiter.timer)
1358
+ if (send) sendEventPollResponse(waiter, eventPollPayload(waiter.kind, waiter.since, true))
1359
+ }
1360
+
1361
+ function flushEventPollWaiters(kind) {
1362
+ for (const waiter of [...eventPollWaiters[kind]]) {
1363
+ const payload = eventPollPayload(kind, waiter.since, true)
1364
+ if (payload.events.length) finishEventPollWaiter(waiter, true)
1365
+ }
1216
1366
  }
1217
1367
 
1218
1368
  function serveWsTicket(req, res, url) {
@@ -1270,16 +1420,33 @@ function serveEventPoll(req, res, url) {
1270
1420
  res.end(JSON.stringify({ error: 'bad-since', detail: 'since 必须是非负整数' }))
1271
1421
  return
1272
1422
  }
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 }))
1423
+ const waitRaw = url.searchParams.get('wait')
1424
+ const requestedWait = waitRaw === null ? 0 : Number(waitRaw)
1425
+ if (!Number.isFinite(requestedWait) || requestedWait < 0) {
1426
+ cors(res)
1427
+ res.writeHead(400, { 'content-type': 'application/json; charset=utf-8' })
1428
+ res.end(JSON.stringify({ error: 'bad-wait', detail: 'wait 必须是非负数字' }))
1429
+ return
1430
+ }
1431
+ const wait = Math.min(Math.floor(requestedWait), EVENT_POLL_WAIT_MAX)
1432
+ const waitSupported = wait > 0 && EVENT_POLL_WAIT_MAX > 0
1433
+ const payload = eventPollPayload(kind, since, waitSupported)
1434
+ if (payload.events.length || wait <= 0 || EVENT_POLL_WAIT_MAX <= 0) {
1435
+ cors(res)
1436
+ res.writeHead(200, {
1437
+ 'content-type': 'application/json; charset=utf-8',
1438
+ 'cache-control': 'no-store'
1439
+ })
1440
+ res.end(JSON.stringify(payload))
1441
+ return
1442
+ }
1443
+ const waiter = { req, res, kind, since, timer: null }
1444
+ eventPollWaiters[kind].add(waiter)
1445
+ waiter.timer = setTimeout(() => finishEventPollWaiter(waiter, true), wait)
1446
+ waiter.timer.unref?.()
1447
+ req.once('close', () => finishEventPollWaiter(waiter, false))
1448
+ // 事件可能刚好在首次检查和加入等待集合之间到达,加入后再检查一次避免漏唤醒。
1449
+ if (eventPollPayload(kind, since, true).events.length) finishEventPollWaiter(waiter, true)
1283
1450
  }
1284
1451
 
1285
1452
  /** 网关自带上游事件采集:mux/host 各一条 WS,断线自动重连。 */
@@ -2059,9 +2226,9 @@ function serveAdminApi(req, res, url) {
2059
2226
  }
2060
2227
 
2061
2228
  // ---------- /fs 文件传输: 实现 ----------
2062
- function fsJson(res, status, body) {
2229
+ function fsJson(res, status, body, extraHeaders = {}) {
2063
2230
  cors(res)
2064
- res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' })
2231
+ res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', ...extraHeaders })
2065
2232
  res.end(JSON.stringify(body))
2066
2233
  }
2067
2234
 
@@ -2173,6 +2340,17 @@ function fsParseRange(header, size) {
2173
2340
  return { start, end: Math.min(end, size - 1) }
2174
2341
  }
2175
2342
 
2343
+ function fsEntityTag(st) {
2344
+ return `"${Number(st.size).toString(16)}-${Math.floor(Number(st.mtimeMs)).toString(16)}"`
2345
+ }
2346
+
2347
+ function fsIfRangeMatches(value, etag, mtimeMs) {
2348
+ if (!value) return true
2349
+ if (String(value).trim() === etag) return true
2350
+ const time = Date.parse(String(value))
2351
+ return Number.isFinite(time) && time >= Math.floor(Number(mtimeMs) / 1000) * 1000
2352
+ }
2353
+
2176
2354
  async function fsList(req, res, url) {
2177
2355
  if (req.method !== 'GET') {
2178
2356
  res.writeHead(405, { allow: 'GET' })
@@ -2241,7 +2419,17 @@ async function fsFile(req, res, url) {
2241
2419
  }
2242
2420
  if (!st.isFile()) return fsJson(res, 400, { error: 'not-a-file' })
2243
2421
 
2244
- const range = fsParseRange(req.headers.range, st.size)
2422
+ const etag = fsEntityTag(st)
2423
+ const lastModified = st.mtime.toUTCString()
2424
+ if (!req.headers.range && req.headers['if-none-match'] === etag) {
2425
+ cors(res)
2426
+ res.writeHead(304, { etag, 'last-modified': lastModified, 'cache-control': 'no-cache' })
2427
+ res.end()
2428
+ return
2429
+ }
2430
+ const range = fsIfRangeMatches(req.headers['if-range'], etag, st.mtimeMs)
2431
+ ? fsParseRange(req.headers.range, st.size)
2432
+ : null
2245
2433
  if (range && range.start >= st.size) {
2246
2434
  cors(res)
2247
2435
  res.writeHead(416, {
@@ -2260,6 +2448,8 @@ async function fsFile(req, res, url) {
2260
2448
  'content-length': range ? range.end - range.start + 1 : st.size,
2261
2449
  'content-disposition': fsContentDisposition(path.basename(checked.abs)),
2262
2450
  'accept-ranges': 'bytes',
2451
+ etag,
2452
+ 'last-modified': lastModified,
2263
2453
  'cache-control': 'no-cache',
2264
2454
  ...(range ? { 'content-range': `bytes ${range.start}-${range.end}/${st.size}` } : {})
2265
2455
  })
@@ -2335,10 +2525,49 @@ function sha256FileHex(file, cb) {
2335
2525
 
2336
2526
  /* 进行中的续传写流: 取消时先 destroy 再删分片, 避免“先删后写”竞态 */
2337
2527
  const activeUploads = new Map()
2528
+ const uploadPartDirs = new Set()
2338
2529
  function fsActiveKey(dirReal, name, session) {
2339
2530
  return dirReal + '\n' + name + '\n' + (session || '')
2340
2531
  }
2341
2532
 
2533
+ function rememberUploadDir(dirReal) {
2534
+ uploadPartDirs.add(dirReal)
2535
+ }
2536
+
2537
+ function uploadDirHasActive(dirReal) {
2538
+ const prefix = dirReal + '\n'
2539
+ for (const key of activeUploads.keys()) if (key.startsWith(prefix)) return true
2540
+ return false
2541
+ }
2542
+
2543
+ function cleanupExpiredUploadParts(dirReal = '') {
2544
+ const dirs = dirReal ? [dirReal] : [...uploadPartDirs]
2545
+ const now = Date.now()
2546
+ for (const dir of dirs) {
2547
+ if (uploadDirHasActive(dir)) continue
2548
+ let entries
2549
+ try { entries = fs.readdirSync(dir) } catch { continue }
2550
+ for (const name of entries) {
2551
+ if (!name.startsWith('.') || !name.includes('.dsh-remote-part-')) continue
2552
+ const file = path.join(dir, name)
2553
+ try {
2554
+ const st = fs.statSync(file)
2555
+ if (st.isFile() && now - st.mtimeMs > FS_UPLOAD_TTL_MS) fs.unlinkSync(file)
2556
+ } catch {}
2557
+ }
2558
+ }
2559
+ }
2560
+
2561
+ const uploadCleanupTimer = setInterval(() => cleanupExpiredUploadParts(), 15 * 60 * 1000)
2562
+ uploadCleanupTimer.unref?.()
2563
+
2564
+ function fsUploadHeaders(offset, length = null, expiresAt = null) {
2565
+ const headers = { 'upload-offset': String(Math.max(0, Number(offset) || 0)) }
2566
+ if (Number.isSafeInteger(length) && length >= 0) headers['upload-length'] = String(length)
2567
+ if (Number.isFinite(expiresAt)) headers['upload-expires'] = new Date(expiresAt).toUTCString()
2568
+ return headers
2569
+ }
2570
+
2342
2571
  /** 打开上传目标: 同名冲突/符号链接/临时文件都在这层判定。 */
2343
2572
  function fsOpenUploadTarget(res, url, dirLex, dirReal, name) {
2344
2573
  if (!fsValidName(name)) {
@@ -2539,14 +2768,22 @@ async function fsUploadProbe(req, res, url) {
2539
2768
  if (resolved.error) return fsJson(res, resolved.error === 'forbidden' ? 403 : 404, { error: resolved.error })
2540
2769
  const checked = fsRealChecked(resolved.abs)
2541
2770
  if (checked.error) return fsJson(res, checked.error === 'forbidden' ? 403 : 404, { error: checked.error })
2771
+ rememberUploadDir(checked.abs)
2772
+ cleanupExpiredUploadParts(checked.abs)
2542
2773
  const name = url.searchParams.get('name') || ''
2543
2774
  if (!fsValidName(name)) return fsJson(res, 400, { error: 'bad-name' })
2544
2775
  const part = fsPartPath(checked.abs, name, url.searchParams.get('session') || 'default')
2545
2776
  let partialSize = 0, partExists = false
2777
+ let expiresAt = null
2546
2778
  try {
2547
2779
  const st = fs.statSync(part)
2548
- if (st.isFile()) { partialSize = st.size; partExists = true }
2780
+ if (st.isFile()) { partialSize = st.size; partExists = true; expiresAt = st.mtimeMs + FS_UPLOAD_TTL_MS }
2549
2781
  } catch {}
2782
+ const lengthRaw = url.searchParams.get('size')
2783
+ const uploadLength = lengthRaw === null || lengthRaw === '' ? null : Number(lengthRaw)
2784
+ if (uploadLength !== null && (!Number.isSafeInteger(uploadLength) || uploadLength < 0)) {
2785
+ return fsJson(res, 400, { error: 'bad-length', detail: 'size 必须是非负整数' })
2786
+ }
2550
2787
  const target = fsTargetState(path.join(checked.abs, name))
2551
2788
  let targetSize = 0
2552
2789
  if (target.exists) {
@@ -2558,8 +2795,10 @@ async function fsUploadProbe(req, res, url) {
2558
2795
  partialSize,
2559
2796
  partExists,
2560
2797
  targetExists: !!target.exists,
2561
- targetSize
2562
- })
2798
+ targetSize,
2799
+ uploadLength,
2800
+ expiresAt
2801
+ }, fsUploadHeaders(partialSize, uploadLength, expiresAt))
2563
2802
  }
2564
2803
 
2565
2804
  /** POST /fs/mkdir?path=<parent>&name=<directory> 创建一个工作区目录。 */
@@ -2594,15 +2833,27 @@ async function fsMkdir(req, res, url) {
2594
2833
  }
2595
2834
 
2596
2835
  function fsUploadResumable(req, res, url, dirLex, dirReal) {
2836
+ rememberUploadDir(dirReal)
2837
+ cleanupExpiredUploadParts(dirReal)
2597
2838
  const name = url.searchParams.get('name') || ''
2598
2839
  if (!fsValidName(name)) return fsJson(res, 400, { error: 'bad-name', detail: '文件名不能为空且不能包含路径分隔符' })
2599
2840
  const session = url.searchParams.get('session') || ''
2600
2841
  if (!session) return fsJson(res, 400, { error: 'missing-session', detail: '断点续传需要 session 参数' })
2601
- const offsetRaw = url.searchParams.get('offset')
2842
+ const queryOffsetRaw = url.searchParams.get('offset')
2843
+ const headerOffsetRaw = req.headers['upload-offset']
2844
+ const offsetRaw = queryOffsetRaw ?? headerOffsetRaw
2602
2845
  const offset = Number(offsetRaw)
2603
2846
  if (!Number.isSafeInteger(offset) || offset < 0) {
2604
2847
  return fsJson(res, 400, { error: 'bad-offset', detail: 'offset 必须是非负整数' })
2605
2848
  }
2849
+ if (queryOffsetRaw !== null && headerOffsetRaw !== undefined && Number(headerOffsetRaw) !== offset) {
2850
+ return fsJson(res, 400, { error: 'offset-mismatch', detail: 'URL offset 与 Upload-Offset 不一致' })
2851
+ }
2852
+ const lengthRaw = url.searchParams.get('size') ?? req.headers['upload-length']
2853
+ const uploadLength = lengthRaw === null || lengthRaw === undefined || lengthRaw === '' ? null : Number(lengthRaw)
2854
+ if (uploadLength !== null && (!Number.isSafeInteger(uploadLength) || uploadLength < 0)) {
2855
+ return fsJson(res, 400, { error: 'bad-length', detail: 'size/Upload-Length 必须是非负整数' })
2856
+ }
2606
2857
  const finish = url.searchParams.get('finish') === '1' || url.searchParams.get('complete') === '1'
2607
2858
  const overwrite = url.searchParams.get('overwrite') === '1' || url.searchParams.get('overwrite') === 'true'
2608
2859
  const sha256Expected = (url.searchParams.get('sha256') || '').trim().toLowerCase()
@@ -2687,8 +2938,16 @@ function fsUploadResumable(req, res, url, dirLex, dirReal) {
2687
2938
  try {
2688
2939
  const st = fs.statSync(part)
2689
2940
  if (!st.isFile() || st.size !== total) throw new Error('part-size-mismatch')
2941
+ if (uploadLength !== null && total > uploadLength) {
2942
+ fsJson(res, 409, { error: 'length-exceeded', size: total, uploadLength, session }, fsUploadHeaders(total, uploadLength, Date.now() + FS_UPLOAD_TTL_MS))
2943
+ return
2944
+ }
2690
2945
  if (!finish) {
2691
- fsJson(res, 200, { ok: true, partial: true, name, size: total, offset: total, session })
2946
+ fsJson(res, 200, { ok: true, partial: true, name, size: total, offset: total, session, uploadLength }, fsUploadHeaders(total, uploadLength, Date.now() + FS_UPLOAD_TTL_MS))
2947
+ return
2948
+ }
2949
+ if (uploadLength !== null && total !== uploadLength) {
2950
+ fsJson(res, 409, { error: 'length-incomplete', size: total, uploadLength, session }, fsUploadHeaders(total, uploadLength, Date.now() + FS_UPLOAD_TTL_MS))
2692
2951
  return
2693
2952
  }
2694
2953
  const commit = (actualSha256) => {
@@ -2698,7 +2957,7 @@ function fsUploadResumable(req, res, url, dirLex, dirReal) {
2698
2957
  if (ts.exists && !overwrite) return fsJson(res, 409, { error: 'conflict', detail: '文件已存在, overwrite=1 可覆盖' })
2699
2958
  if (ts.exists) fs.rmSync(target, { force: true })
2700
2959
  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 } : {}) })
2960
+ 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
2961
  } catch (err) {
2703
2962
  if (!res.headersSent) fsJson(res, 403, { error: 'write-failed', detail: err.message })
2704
2963
  else try { res.destroy() } catch {}
@@ -2709,7 +2968,7 @@ function fsUploadResumable(req, res, url, dirLex, dirReal) {
2709
2968
  sha256FileHex(part, (err, actual) => {
2710
2969
  if (err) return fsJson(res, 403, { error: 'checksum-failed', detail: err.message })
2711
2970
  if (actual !== sha256Expected) {
2712
- return fsJson(res, 422, { error: 'checksum-mismatch', expected: sha256Expected, actual, partialSize: total, session })
2971
+ return fsJson(res, 422, { error: 'checksum-mismatch', expected: sha256Expected, actual, partialSize: total, session }, fsUploadHeaders(total, uploadLength, Date.now() + FS_UPLOAD_TTL_MS))
2713
2972
  }
2714
2973
  commit(actual)
2715
2974
  })
@@ -2738,6 +2997,8 @@ async function fsUploadControl(req, res, url) {
2738
2997
  if (resolved.error) return fsJson(res, resolved.error === 'forbidden' ? 403 : 404, { error: resolved.error })
2739
2998
  const checked = fsRealChecked(resolved.abs)
2740
2999
  if (checked.error) return fsJson(res, checked.error === 'forbidden' ? 403 : 404, { error: checked.error })
3000
+ rememberUploadDir(checked.abs)
3001
+ cleanupExpiredUploadParts(checked.abs)
2741
3002
  const name = url.searchParams.get('name') || ''
2742
3003
  if (!fsValidName(name)) return fsJson(res, 400, { error: 'bad-name' })
2743
3004
  const session = url.searchParams.get('session') || 'default'
@@ -2998,7 +3259,24 @@ function proxyApi(req, res, url) {
2998
3259
  }
2999
3260
 
3000
3261
  // ---------- 其它 ----------
3001
- async function serveHealth(res) {
3262
+ async function serveHealth(req, res, url) {
3263
+ const eventHealth = Object.fromEntries(Object.entries(eventCollectorState).map(([kind, state]) => [kind, {
3264
+ connected: state.connected,
3265
+ lastEventAt: state.lastEventAt,
3266
+ eventLagMs: state.lastEventAt ? Math.max(0, Date.now() - state.lastEventAt) : null,
3267
+ lastConnectAt: state.lastConnectAt,
3268
+ reconnects: state.reconnects,
3269
+ attempt: state.attempt,
3270
+ lastError: state.lastError,
3271
+ clients: state.clients,
3272
+ }]))
3273
+ const liveness = { ok: true, pid: process.pid, uptimeMs: Math.max(0, Date.now() - STARTED_AT), runtime: runtimeState }
3274
+ if (url?.searchParams.get('probe') === 'live') {
3275
+ cors(res)
3276
+ res.writeHead(200, { 'content-type': 'application/json' })
3277
+ res.end(JSON.stringify({ ok: true, service: 'dsh-remote', version: VERSION, probe: 'live', liveness }))
3278
+ return
3279
+ }
3002
3280
  let upstreamOk = false
3003
3281
  let upstreamReachable = false
3004
3282
  let upstreamStatus = 0
@@ -3017,11 +3295,17 @@ async function serveHealth(res) {
3017
3295
  } finally {
3018
3296
  if (timer) clearTimeout(timer)
3019
3297
  }
3298
+ const eventsOk = eventHealth.mux.connected && eventHealth.host.connected
3299
+ const readiness = { ok: upstreamOk && eventsOk, upstreamOk, eventsOk }
3300
+ const status = readiness.ok ? 'ready' : upstreamReachable ? 'degraded' : 'offline'
3020
3301
  cors(res)
3021
3302
  res.writeHead(200, { 'content-type': 'application/json; charset=utf-8' })
3022
3303
  res.end(JSON.stringify({
3023
3304
  ok: true,
3024
3305
  service: 'dsh-remote',
3306
+ status,
3307
+ liveness,
3308
+ readiness,
3025
3309
  version: VERSION,
3026
3310
  protocol: { version: PROTOCOL_VERSION },
3027
3311
  capabilities: CAPABILITIES,
@@ -3032,7 +3316,7 @@ async function serveHealth(res) {
3032
3316
  upstreamReachable,
3033
3317
  upstreamStatus,
3034
3318
  ...(upstreamError ? { upstreamError } : {}),
3035
- events: eventCollectorState,
3319
+ events: eventHealth,
3036
3320
  runtime: runtimeState,
3037
3321
  }))
3038
3322
  }
@@ -3061,7 +3345,7 @@ const server = http.createServer(async (req, res) => {
3061
3345
  if (url.pathname === '/api/events.poll') return serveEventPoll(req, res, url)
3062
3346
  if (url.pathname.startsWith('/remote/')) return proxyApi(req, res, url)
3063
3347
  if (url.pathname.startsWith('/api/')) return proxyApi(req, res, url)
3064
- if (url.pathname === '/health') return serveHealth(res)
3348
+ if (url.pathname === '/health') return serveHealth(req, res, url)
3065
3349
  touchDevice(req)
3066
3350
  return serveStatic(req, res, url)
3067
3351
  } 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.16",
4
4
  "description": "DSH Remote 官方 bundle 插件:DSH 左侧原生边栏入口 + 右侧抽屉内嵌管理控制台;内置网关随 DSH 自动启停(systemd 独立单元),抽屉直显令牌与设备监控;网关提供 /fs/* 文件传输端点(列表/断点下载/上传),配合 Android App 远程操控会话/审批/提问/goal 与文件互传(多服务器测速切换、聊天记录离线缓存)。",
5
5
  "type": "module",
6
6
  "main": "./index.mjs",