termdock 1.0.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.
Files changed (227) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +385 -0
  3. package/dist/client/apple-splash-default-1170x2532.png +0 -0
  4. package/dist/client/apple-splash-landscape-1136x640.png +0 -0
  5. package/dist/client/apple-splash-landscape-1334x750.png +0 -0
  6. package/dist/client/apple-splash-landscape-1792x828.png +0 -0
  7. package/dist/client/apple-splash-landscape-2048x1536.png +0 -0
  8. package/dist/client/apple-splash-landscape-2160x1620.png +0 -0
  9. package/dist/client/apple-splash-landscape-2208x1242.png +0 -0
  10. package/dist/client/apple-splash-landscape-2224x1668.png +0 -0
  11. package/dist/client/apple-splash-landscape-2266x1488.png +0 -0
  12. package/dist/client/apple-splash-landscape-2360x1640.png +0 -0
  13. package/dist/client/apple-splash-landscape-2388x1668.png +0 -0
  14. package/dist/client/apple-splash-landscape-2436x1125.png +0 -0
  15. package/dist/client/apple-splash-landscape-2532x1170.png +0 -0
  16. package/dist/client/apple-splash-landscape-2556x1179.png +0 -0
  17. package/dist/client/apple-splash-landscape-2622x1206.png +0 -0
  18. package/dist/client/apple-splash-landscape-2688x1242.png +0 -0
  19. package/dist/client/apple-splash-landscape-2732x2048.png +0 -0
  20. package/dist/client/apple-splash-landscape-2778x1284.png +0 -0
  21. package/dist/client/apple-splash-landscape-2796x1290.png +0 -0
  22. package/dist/client/apple-splash-landscape-2868x1320.png +0 -0
  23. package/dist/client/apple-splash-portrait-1125x2436.png +0 -0
  24. package/dist/client/apple-splash-portrait-1170x2532.png +0 -0
  25. package/dist/client/apple-splash-portrait-1179x2556.png +0 -0
  26. package/dist/client/apple-splash-portrait-1206x2622.png +0 -0
  27. package/dist/client/apple-splash-portrait-1242x2208.png +0 -0
  28. package/dist/client/apple-splash-portrait-1242x2688.png +0 -0
  29. package/dist/client/apple-splash-portrait-1284x2778.png +0 -0
  30. package/dist/client/apple-splash-portrait-1290x2796.png +0 -0
  31. package/dist/client/apple-splash-portrait-1320x2868.png +0 -0
  32. package/dist/client/apple-splash-portrait-1488x2266.png +0 -0
  33. package/dist/client/apple-splash-portrait-1536x2048.png +0 -0
  34. package/dist/client/apple-splash-portrait-1620x2160.png +0 -0
  35. package/dist/client/apple-splash-portrait-1640x2360.png +0 -0
  36. package/dist/client/apple-splash-portrait-1668x2224.png +0 -0
  37. package/dist/client/apple-splash-portrait-1668x2388.png +0 -0
  38. package/dist/client/apple-splash-portrait-2048x2732.png +0 -0
  39. package/dist/client/apple-splash-portrait-640x1136.png +0 -0
  40. package/dist/client/apple-splash-portrait-750x1334.png +0 -0
  41. package/dist/client/apple-splash-portrait-828x1792.png +0 -0
  42. package/dist/client/apple-touch-icon-180x180.png +0 -0
  43. package/dist/client/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
  44. package/dist/client/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
  45. package/dist/client/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
  46. package/dist/client/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
  47. package/dist/client/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
  48. package/dist/client/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
  49. package/dist/client/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
  50. package/dist/client/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
  51. package/dist/client/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
  52. package/dist/client/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
  53. package/dist/client/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
  54. package/dist/client/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
  55. package/dist/client/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
  56. package/dist/client/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
  57. package/dist/client/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
  58. package/dist/client/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
  59. package/dist/client/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
  60. package/dist/client/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
  61. package/dist/client/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
  62. package/dist/client/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
  63. package/dist/client/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
  64. package/dist/client/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
  65. package/dist/client/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
  66. package/dist/client/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
  67. package/dist/client/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
  68. package/dist/client/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
  69. package/dist/client/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
  70. package/dist/client/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
  71. package/dist/client/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
  72. package/dist/client/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
  73. package/dist/client/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
  74. package/dist/client/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
  75. package/dist/client/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
  76. package/dist/client/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
  77. package/dist/client/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
  78. package/dist/client/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
  79. package/dist/client/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
  80. package/dist/client/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
  81. package/dist/client/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
  82. package/dist/client/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
  83. package/dist/client/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
  84. package/dist/client/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
  85. package/dist/client/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
  86. package/dist/client/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
  87. package/dist/client/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
  88. package/dist/client/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
  89. package/dist/client/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
  90. package/dist/client/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
  91. package/dist/client/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
  92. package/dist/client/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
  93. package/dist/client/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
  94. package/dist/client/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
  95. package/dist/client/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
  96. package/dist/client/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
  97. package/dist/client/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
  98. package/dist/client/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
  99. package/dist/client/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
  100. package/dist/client/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
  101. package/dist/client/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
  102. package/dist/client/assets/abnfDiagram-VRR7QNED-D0NxeSHA.js +1 -0
  103. package/dist/client/assets/arc-B7GoKc6u.js +1 -0
  104. package/dist/client/assets/architectureDiagram-ZJ3FMSHR-BQPW-wUx.js +36 -0
  105. package/dist/client/assets/blockDiagram-677ZJIJ3-CX_hbcgG.js +132 -0
  106. package/dist/client/assets/c4Diagram-LMCZKHZV-BwOMQA4x.js +10 -0
  107. package/dist/client/assets/channel-C2gUFcyU.js +1 -0
  108. package/dist/client/assets/chunk-2Q5K7J3B-BLJzEQXz.js +1 -0
  109. package/dist/client/assets/chunk-32BRIVSS-CjBDr_gz.js +1 -0
  110. package/dist/client/assets/chunk-5VM5RSS4-lddvv0x2.js +15 -0
  111. package/dist/client/assets/chunk-EX3LRPZG-Dtsq1Stg.js +231 -0
  112. package/dist/client/assets/chunk-JWPE2WC7-BG3PZx4j.js +1 -0
  113. package/dist/client/assets/chunk-MOJQB5TN-CMtlCuQg.js +88 -0
  114. package/dist/client/assets/chunk-RYQCIY6F-uplKqw64.js +1 -0
  115. package/dist/client/assets/chunk-V7JOEXUC-CpteLsY3.js +206 -0
  116. package/dist/client/assets/chunk-VR4S4FIN-1zYOJQXL.js +1 -0
  117. package/dist/client/assets/chunk-XXDRQBXY-C0wXH91I.js +1 -0
  118. package/dist/client/assets/classDiagram-OUVF2IWQ-BTOnA9HS.js +1 -0
  119. package/dist/client/assets/classDiagram-v2-EOCWNBFH-BTOnA9HS.js +1 -0
  120. package/dist/client/assets/cose-bilkent-JH36ORCC-BceT5ki2.js +1 -0
  121. package/dist/client/assets/cynefin-VYW2F7L2-BqCIkNeC.js +166 -0
  122. package/dist/client/assets/cynefinDiagram-TSTJHNR4-Dm7y-tsN.js +62 -0
  123. package/dist/client/assets/cytoscape.esm-D3_iZ_3b.js +321 -0
  124. package/dist/client/assets/dagre-VKFMJZFB-Bn8eh0lE.js +4 -0
  125. package/dist/client/assets/defaultLocale-CrowFXzY.js +1 -0
  126. package/dist/client/assets/diagram-FQU43EPY-DmZGMAsX.js +3 -0
  127. package/dist/client/assets/diagram-G47NLZAW-BLWSm_Rn.js +24 -0
  128. package/dist/client/assets/diagram-NH7WQ7WH-C2-HK8pv.js +24 -0
  129. package/dist/client/assets/diagram-OA4YK3LP-8Ex4XIU5.js +30 -0
  130. package/dist/client/assets/diagram-WEI45ONY-MRNIpBXG.js +41 -0
  131. package/dist/client/assets/diffWorker-B6msVyjo.js +18 -0
  132. package/dist/client/assets/dnd-BCFAPC0L.js +1 -0
  133. package/dist/client/assets/ebnfDiagram-CCIWWBDH-D7Z6wZi_.js +1 -0
  134. package/dist/client/assets/erDiagram-Q63AITRT-C96Q5x9y.js +85 -0
  135. package/dist/client/assets/flowDiagram-23GEKE2U-DzQp40QH.js +156 -0
  136. package/dist/client/assets/ganttDiagram-NO4QXBWP-ZzkchZJ8.js +292 -0
  137. package/dist/client/assets/gitGraphDiagram-IHSO6WYX-CFacEf54.js +106 -0
  138. package/dist/client/assets/graph-D2o_JWn5.js +1 -0
  139. package/dist/client/assets/index-Ca-rq7yX.js +6 -0
  140. package/dist/client/assets/index-Cv7q4nZR.css +1 -0
  141. package/dist/client/assets/index-XktPMm51.js +394 -0
  142. package/dist/client/assets/infoDiagram-FWYZ7A6U-CVRXs5rF.js +2 -0
  143. package/dist/client/assets/init-Gi6I4Gst.js +1 -0
  144. package/dist/client/assets/ishikawaDiagram-FXEZZL3T-B8FP_wzM.js +70 -0
  145. package/dist/client/assets/journeyDiagram-5HDEW3XC-BR__4Hbd.js +139 -0
  146. package/dist/client/assets/kanban-definition-HUTT4EX6-v39oZe6X.js +89 -0
  147. package/dist/client/assets/katex-55ZPZeOF.css +1 -0
  148. package/dist/client/assets/katex-DnlPpQZa.js +257 -0
  149. package/dist/client/assets/katex-HP8lGamR.js +257 -0
  150. package/dist/client/assets/layout-1eBIAoSl.js +1 -0
  151. package/dist/client/assets/linear-jlTiHqO6.js +1 -0
  152. package/dist/client/assets/map-BdqsVdb3.js +1 -0
  153. package/dist/client/assets/mermaid.core-DiiIN0EC.js +306 -0
  154. package/dist/client/assets/mindmap-definition-LN4V7U3C-AeUxAZeq.js +96 -0
  155. package/dist/client/assets/ordinal-Cboi1Yqb.js +1 -0
  156. package/dist/client/assets/pegDiagram-2B236MQR-Dh-bgAbv.js +1 -0
  157. package/dist/client/assets/pieDiagram-ENE6RG2P-De7Si7Tm.js +39 -0
  158. package/dist/client/assets/purify.es-CC4Brkr_.js +3 -0
  159. package/dist/client/assets/quadrantDiagram-ABIIQ3AL-CgWfHqy0.js +7 -0
  160. package/dist/client/assets/railroadDiagram-RFXS5EU6-BXnQW-pI.js +1 -0
  161. package/dist/client/assets/react-XDCjal2T.js +8 -0
  162. package/dist/client/assets/requirementDiagram-TGXJPOKE-CU1nnpUM.js +84 -0
  163. package/dist/client/assets/sankeyDiagram-HTMAVEWB-DFK-AXY6.js +40 -0
  164. package/dist/client/assets/sequenceDiagram-DBY2YBRQ-D_bVDWgq.js +162 -0
  165. package/dist/client/assets/sizeCapture-X5ZJPWSS-nh89ExHT.js +1 -0
  166. package/dist/client/assets/stateDiagram-2N3HPSRC-DUvT6Ruk.js +1 -0
  167. package/dist/client/assets/stateDiagram-v2-6OUMAXLB-Dz-3r-Yq.js +1 -0
  168. package/dist/client/assets/swimlanes-5IMT3BWC-ZYa52gjG.js +2 -0
  169. package/dist/client/assets/swimlanesDiagram-G3AALYLV-DXR_9lf9.js +8 -0
  170. package/dist/client/assets/terminal-C7X98Amf.js +10 -0
  171. package/dist/client/assets/timeline-definition-FHXFAJF6-Bp6A49KQ.js +120 -0
  172. package/dist/client/assets/vennDiagram-L72KCM5P-CDDUmLOI.js +34 -0
  173. package/dist/client/assets/wardleyDiagram-EHGQE667-CRc2ptu7.js +78 -0
  174. package/dist/client/assets/xychartDiagram-FW5EYKEG-ht_S0LUA.js +7 -0
  175. package/dist/client/favicon.ico +0 -0
  176. package/dist/client/fonts/JetBrainsMonoNL-Bold.woff2 +0 -0
  177. package/dist/client/fonts/JetBrainsMonoNL-BoldItalic.woff2 +0 -0
  178. package/dist/client/fonts/JetBrainsMonoNL-Italic.woff2 +0 -0
  179. package/dist/client/fonts/JetBrainsMonoNL-Regular.woff2 +0 -0
  180. package/dist/client/fonts/JetBrainsMonoNLNerdFontMono-Bold.ttf +0 -0
  181. package/dist/client/fonts/JetBrainsMonoNLNerdFontMono-BoldItalic.ttf +0 -0
  182. package/dist/client/fonts/JetBrainsMonoNLNerdFontMono-Italic.ttf +0 -0
  183. package/dist/client/fonts/JetBrainsMonoNLNerdFontMono-Regular.ttf +0 -0
  184. package/dist/client/fonts/NotoSansMonoCJKsc-Bold-Subset.woff2 +0 -0
  185. package/dist/client/fonts/NotoSansMonoCJKsc-Subset.woff2 +0 -0
  186. package/dist/client/fonts/NotoSansSymbols2-Subset.woff2 +0 -0
  187. package/dist/client/fonts/SymbolsNerdFontMono-Regular.woff2 +0 -0
  188. package/dist/client/fonts/TermdockStarSymbol-Subset.woff2 +0 -0
  189. package/dist/client/index.html +255 -0
  190. package/dist/client/manifest.webmanifest +1 -0
  191. package/dist/client/maskable-icon-512x512.png +0 -0
  192. package/dist/client/pwa-192x192.png +0 -0
  193. package/dist/client/pwa-192x192.svg +5 -0
  194. package/dist/client/pwa-512x512.png +0 -0
  195. package/dist/client/pwa-512x512.svg +5 -0
  196. package/dist/client/pwa-64x64.png +0 -0
  197. package/dist/client/registerSW.js +1 -0
  198. package/dist/client/robots.txt +4 -0
  199. package/dist/client/shell-integration/termdock.bash +107 -0
  200. package/dist/client/shell-integration/termdock.fish +60 -0
  201. package/dist/client/shell-integration/termdock.zsh +164 -0
  202. package/dist/client/sw-notifications.js +42 -0
  203. package/dist/client/sw.js +1 -0
  204. package/dist/client/workbox-e4022e15.js +1 -0
  205. package/dist/server/certificateWatcher.js +111 -0
  206. package/dist/server/cli.js +2534 -0
  207. package/dist/server/config.js +54 -0
  208. package/dist/server/entry.js +571 -0
  209. package/dist/server/onboardingServer.js +54 -0
  210. package/dist/server/routes/auth.js +48 -0
  211. package/dist/server/routes/filesystem.js +3749 -0
  212. package/dist/server/routes/local.js +32 -0
  213. package/dist/server/routes/onboarding.js +134 -0
  214. package/dist/server/routes/terminal.js +4879 -0
  215. package/dist/server/utils/authProtection.js +338 -0
  216. package/dist/server/utils/caffeinate.js +124 -0
  217. package/dist/server/utils/changeAuditStore.js +521 -0
  218. package/dist/server/utils/cookieSecurity.js +13 -0
  219. package/dist/server/utils/csrfProtection.js +112 -0
  220. package/dist/server/utils/localAccess.js +211 -0
  221. package/dist/server/utils/pathValidator.js +272 -0
  222. package/dist/server/utils/requestSecurity.js +103 -0
  223. package/dist/server/utils/serverLogger.js +57 -0
  224. package/dist/server/utils/settings.js +157 -0
  225. package/dist/server/utils/tmuxFocus.js +45 -0
  226. package/dist/server/utils/tmuxProgramDetection.js +148 -0
  227. package/package.json +125 -0
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Termdock 配置中心
3
+ *
4
+ * —— 如何新增可配置值(三步法) ——
5
+ * 1. 选辅助函数:envInt(envKey, fallback) / envStr(envKey, fallback)
6
+ * 2. 在对应对象上加 getter(不要直接赋值——ESM import hoisting 会导致
7
+ * dotenv 加载前就读值,getter 懒求值可规避此问题)
8
+ * 3. 在 .env.example 补充文档
9
+ *
10
+ * —— 命名规则 ——
11
+ * 环境变量:TERMDOCK_<CATEGORY>_<PROPERTY>,全大写蛇形
12
+ * 详见 docs/configuration.md
13
+ *
14
+ * —— 为什么用 getter ——
15
+ * ESM import 先于模块体执行。若在模块顶层直接读 process.env,
16
+ * 此时 dotenv 尚未加载,读到的是空值。getter 在每次属性访问时才
17
+ * 求值,保证读到 dotenv 加载后的正确值。
18
+ */
19
+ function envInt(key, fallback) {
20
+ const v = process.env[key];
21
+ if (v === undefined)
22
+ return fallback;
23
+ const n = Number.parseInt(v, 10);
24
+ return Number.isFinite(n) && n > 0 ? n : fallback;
25
+ }
26
+ function envStr(key, fallback) {
27
+ return process.env[key] || fallback;
28
+ }
29
+ function envBool(key, fallback) {
30
+ const v = process.env[key];
31
+ if (v === undefined)
32
+ return fallback;
33
+ return v === '1' || v === 'true' || v === 'yes';
34
+ }
35
+ /**
36
+ * 端口配置(可通过环境变量覆盖)
37
+ * - TERMDOCK_PORT_FRONTEND: 前端开发服务器 (Vite) 端口
38
+ * - PORT: 正式服务端口
39
+ * - TERMDOCK_PORT_DEV_BACKEND: 后端开发 API 端口
40
+ */
41
+ export const PORT = {
42
+ get frontend() { return envInt('TERMDOCK_PORT_FRONTEND', 9833); },
43
+ get backend() { return envInt('PORT', 9834); },
44
+ get devBackend() { return envInt('TERMDOCK_PORT_DEV_BACKEND', 9835); },
45
+ };
46
+ /** 默认主机(HOST 环境变量可覆盖) */
47
+ export const DEFAULT_HOST = '0.0.0.0';
48
+ /** Local Access 域名配置 */
49
+ export const LOCAL_ACCESS = {
50
+ get domainSuffix() { return envStr('TERMDOCK_LOCAL_ACCESS_DOMAIN_SUFFIX', 'termdock.local'); },
51
+ get generatedNameLength() { return envInt('TERMDOCK_LOCAL_ACCESS_NAME_LENGTH', 4); },
52
+ get generatedNameAlphabet() { return envStr('TERMDOCK_LOCAL_ACCESS_NAME_ALPHABET', 'abcdefghijklmnopqrstuvwxyz0123456789'); },
53
+ get mdnsTtlSeconds() { return envInt('TERMDOCK_LOCAL_ACCESS_MDNS_TTL', 120); },
54
+ };
@@ -0,0 +1,571 @@
1
+ import 'dotenv/config';
2
+ import express from 'express';
3
+ import { createServer as createHttpServer } from 'http';
4
+ import { createServer as createHttpsServer } from 'https';
5
+ import crypto from 'crypto';
6
+ import fs from 'fs';
7
+ import path from 'path';
8
+ import zlib from 'zlib';
9
+ import { homedir } from 'os';
10
+ import cookieParser from 'cookie-parser';
11
+ import { fileURLToPath } from 'url';
12
+ import { WebSocketServer } from 'ws';
13
+ import terminalRoutes, { handleTerminalWebSocket, handleControlWebSocket } from './routes/terminal.js';
14
+ import filesystemRoutes from './routes/filesystem.js';
15
+ import authRoutes from './routes/auth.js';
16
+ import { createOnboardingRouter } from './routes/onboarding.js';
17
+ import { createLocalRouter } from './routes/local.js';
18
+ import { csrfProtection } from './utils/csrfProtection.js';
19
+ import { pathValidator } from './utils/pathValidator.js';
20
+ import { isUpgradeRequestAuthenticated, requireAuth } from './utils/authProtection.js';
21
+ import { localAccessManager } from './utils/localAccess.js';
22
+ import { isAllowedHost, isUpgradeOriginAllowed, validateHostMiddleware, } from './utils/requestSecurity.js';
23
+ import { getCookieSecurityOptions, setSecureCookieMode } from './utils/cookieSecurity.js';
24
+ import { startOnboardingServer, stopOnboardingServer, getOnboardingServerUrl } from './onboardingServer.js';
25
+ import { CertificateWatcher } from './certificateWatcher.js';
26
+ import { writeDiffTraceLog, writeErrorLog, writeJsonLog, writeTextLog } from './utils/serverLogger.js';
27
+ import { PORT, DEFAULT_HOST } from './config.js';
28
+ const CLIENT_STATE_COOKIE = 'termdock-client';
29
+ export const DEFAULT_PORT = PORT.backend;
30
+ const CLIENT_LOG_DEDUP_WINDOW_MS = 5_000;
31
+ const CLIENT_LOG_RATE_WINDOW_MS = 10_000;
32
+ const CLIENT_LOG_RATE_LIMIT = 120;
33
+ const currentFilePath = fileURLToPath(import.meta.url);
34
+ const currentDirPath = path.dirname(currentFilePath);
35
+ const clientDistPath = path.resolve(currentDirPath, '../client');
36
+ const clientIndexPath = path.join(clientDistPath, 'index.html');
37
+ const clientLogRecent = new Map();
38
+ let clientLogWindowStartedAt = 0;
39
+ let clientLogWindowCount = 0;
40
+ let clientLogSuppressedCount = 0;
41
+ function getRouteFamily(pathname) {
42
+ if (pathname.startsWith('/api/terminal/fs/'))
43
+ return 'fs';
44
+ if (pathname.startsWith('/api/terminal/'))
45
+ return 'terminal';
46
+ if (pathname.startsWith('/api/auth'))
47
+ return 'auth';
48
+ if (pathname.startsWith('/api/client-log'))
49
+ return 'client-log';
50
+ if (pathname === '/health')
51
+ return 'health';
52
+ if (pathname.startsWith('/assets/'))
53
+ return 'asset';
54
+ return 'page';
55
+ }
56
+ function shouldWriteClientLog(level, message) {
57
+ const now = Date.now();
58
+ const importantClientLog = typeof message === 'string' && (message.startsWith('DIFF_VIEWER ')
59
+ || message.startsWith('DIFF_LOADING ')
60
+ || message.startsWith('FILE_PREVIEW_LOADING ')
61
+ || message.startsWith('GIT_BUNDLE ')
62
+ || message.startsWith('DIFF_INTERACTION ')
63
+ || message.startsWith('DIFF_API '));
64
+ if (!clientLogWindowStartedAt || now - clientLogWindowStartedAt > CLIENT_LOG_RATE_WINDOW_MS) {
65
+ if (clientLogSuppressedCount > 0) {
66
+ writeTextLog('client.log', `[client-log ${new Date().toISOString()}] [warn] suppressed ${clientLogSuppressedCount} noisy client log(s)`);
67
+ }
68
+ clientLogWindowStartedAt = now;
69
+ clientLogWindowCount = 0;
70
+ clientLogSuppressedCount = 0;
71
+ clientLogRecent.clear();
72
+ }
73
+ if (importantClientLog) {
74
+ return true;
75
+ }
76
+ clientLogWindowCount += 1;
77
+ if (clientLogWindowCount > CLIENT_LOG_RATE_LIMIT) {
78
+ clientLogSuppressedCount += 1;
79
+ return false;
80
+ }
81
+ const key = `${String(level ?? 'info')}\u0000${String(message ?? '')}`;
82
+ const last = clientLogRecent.get(key);
83
+ if (last && now - last < CLIENT_LOG_DEDUP_WINDOW_MS) {
84
+ clientLogSuppressedCount += 1;
85
+ return false;
86
+ }
87
+ clientLogRecent.set(key, now);
88
+ return true;
89
+ }
90
+ function getDiffTraceSource(message) {
91
+ if (typeof message !== 'string')
92
+ return null;
93
+ if (message.startsWith('DIFF_INTERACTION '))
94
+ return 'client.interaction';
95
+ if (message.startsWith('DIFF_LOADING '))
96
+ return 'client.loading';
97
+ if (message.startsWith('DIFF_VIEWER '))
98
+ return 'client.viewer';
99
+ if (message.startsWith('DIFF_API '))
100
+ return 'client.api';
101
+ if (message.startsWith('FILE_PREVIEW_LOADING '))
102
+ return 'client.file-preview';
103
+ if (message.startsWith('GIT_BUNDLE '))
104
+ return 'client.git-bundle';
105
+ return null;
106
+ }
107
+ function getDiffTraceEvent(message) {
108
+ if (typeof message !== 'string')
109
+ return null;
110
+ const spaceIndex = message.indexOf(' ');
111
+ if (spaceIndex < 0)
112
+ return message;
113
+ return message.slice(spaceIndex + 1);
114
+ }
115
+ // 静态资源压缩中间件(零依赖,用 Node 内置 zlib)。
116
+ // 动机:跨城/弱网首刷(或 PWA SW 更新后)要下载未压缩的 JS/CSS bundle,
117
+ // express.static 默认不压缩。这里对文本类资源做 br/gzip 压缩,跨城下能把
118
+ // bundle 下载体积砍到 ~1/4,明显缩短首屏等待。
119
+ // 设计要点:
120
+ // - 只压文本类扩展名;图片/字体/woff2 等已是压缩格式,跳过避免做无用功。
121
+ // - 编译产物在运行期不变,按 (绝对路径 + mtimeMs + 编码) 缓存压缩结果到内存,
122
+ // 只在第一次请求时压一次,后续直接命中,不占 CPU。
123
+ // - 路径必须落在 dist 目录内且文件真实存在,否则交回后续中间件(含 SPA
124
+ // fallback),不影响 index.html 路由与 /api 等。
125
+ const COMPRESSIBLE_EXT = new Set(['.js', '.mjs', '.css', '.html', '.json', '.svg', '.webmanifest', '.map', '.txt']);
126
+ // 扩展名 → Content-Type。自己维护一张小表,不依赖 express.static.mime
127
+ // (express 5 运行期不暴露该字段,访问会抛 TypeError)。
128
+ const CONTENT_TYPE_BY_EXT = {
129
+ '.js': 'text/javascript; charset=utf-8',
130
+ '.mjs': 'text/javascript; charset=utf-8',
131
+ '.css': 'text/css; charset=utf-8',
132
+ '.html': 'text/html; charset=utf-8',
133
+ '.json': 'application/json; charset=utf-8',
134
+ '.svg': 'image/svg+xml',
135
+ '.webmanifest': 'application/manifest+json; charset=utf-8',
136
+ '.map': 'application/json; charset=utf-8',
137
+ '.txt': 'text/plain; charset=utf-8',
138
+ };
139
+ function setStaticCacheHeaders(req, res) {
140
+ let pathname;
141
+ try {
142
+ pathname = decodeURIComponent(new URL(req.url, 'http://localhost').pathname);
143
+ }
144
+ catch {
145
+ pathname = req.path || req.url || '';
146
+ }
147
+ if (pathname === '/'
148
+ || pathname.endsWith('/index.html')
149
+ || pathname === '/sw.js'
150
+ || pathname === '/registerSW.js'
151
+ || pathname === '/manifest.webmanifest') {
152
+ res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
153
+ res.setHeader('Pragma', 'no-cache');
154
+ res.setHeader('Expires', '0');
155
+ return;
156
+ }
157
+ if (pathname.startsWith('/assets/')) {
158
+ res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
159
+ }
160
+ }
161
+ function createStaticCompressionMiddleware(rootDir) {
162
+ const resolvedRoot = path.resolve(rootDir);
163
+ const cache = new Map();
164
+ return (req, res, next) => {
165
+ if (req.method !== 'GET' && req.method !== 'HEAD')
166
+ return next();
167
+ const acceptEncoding = String(req.headers['accept-encoding'] || '');
168
+ const useBr = /\bbr\b/.test(acceptEncoding);
169
+ const useGzip = /\bgzip\b/.test(acceptEncoding);
170
+ if (!useBr && !useGzip)
171
+ return next();
172
+ // 解析并防目录穿越:只服务 dist 内的文件。
173
+ let pathname;
174
+ try {
175
+ pathname = decodeURIComponent(new URL(req.url, 'http://localhost').pathname);
176
+ }
177
+ catch {
178
+ return next();
179
+ }
180
+ const ext = path.extname(pathname).toLowerCase();
181
+ if (!COMPRESSIBLE_EXT.has(ext))
182
+ return next();
183
+ const filePath = path.resolve(resolvedRoot, '.' + pathname);
184
+ if (filePath !== resolvedRoot && !filePath.startsWith(resolvedRoot + path.sep))
185
+ return next();
186
+ let stat;
187
+ try {
188
+ stat = fs.statSync(filePath);
189
+ }
190
+ catch {
191
+ return next();
192
+ }
193
+ if (!stat.isFile())
194
+ return next();
195
+ const encoding = useBr ? 'br' : 'gzip';
196
+ const cacheKey = `${filePath}|${encoding}`;
197
+ let entry = cache.get(cacheKey);
198
+ if (!entry || entry.mtimeMs !== stat.mtimeMs) {
199
+ let raw;
200
+ try {
201
+ raw = fs.readFileSync(filePath);
202
+ }
203
+ catch {
204
+ return next();
205
+ }
206
+ const body = encoding === 'br'
207
+ ? zlib.brotliCompressSync(raw, {
208
+ params: {
209
+ [zlib.constants.BROTLI_PARAM_QUALITY]: 9,
210
+ [zlib.constants.BROTLI_PARAM_SIZE_HINT]: raw.length,
211
+ },
212
+ })
213
+ : zlib.gzipSync(raw, { level: 7 });
214
+ entry = { encoding, body, mtimeMs: stat.mtimeMs };
215
+ cache.set(cacheKey, entry);
216
+ }
217
+ res.setHeader('Content-Encoding', encoding);
218
+ res.setHeader('Vary', 'Accept-Encoding');
219
+ const type = CONTENT_TYPE_BY_EXT[ext];
220
+ if (type)
221
+ res.setHeader('Content-Type', type);
222
+ setStaticCacheHeaders(req, res);
223
+ res.setHeader('Content-Length', entry.body.length);
224
+ if (req.method === 'HEAD') {
225
+ res.end();
226
+ return;
227
+ }
228
+ res.end(entry.body);
229
+ };
230
+ }
231
+ export function createApp(options = {}) {
232
+ const app = express();
233
+ app.use(validateHostMiddleware);
234
+ // 基础中间件
235
+ app.use(express.json({ limit: '5mb' }));
236
+ app.use(cookieParser());
237
+ // Note: clientId cookie is no longer used for session persistence (sessions are global).
238
+ // Kept for potential future use and backward compatibility.
239
+ app.use((req, res, next) => {
240
+ const existingClientId = req.cookies?.[CLIENT_STATE_COOKIE];
241
+ const clientId = typeof existingClientId === 'string' && existingClientId.trim().length > 0
242
+ ? existingClientId
243
+ : crypto.randomUUID();
244
+ if (existingClientId !== clientId) {
245
+ res.cookie(CLIENT_STATE_COOKIE, clientId, {
246
+ httpOnly: true,
247
+ ...getCookieSecurityOptions(),
248
+ maxAge: 30 * 24 * 60 * 60 * 1000,
249
+ });
250
+ }
251
+ req.clientId = clientId;
252
+ next();
253
+ });
254
+ app.use((req, res, next) => {
255
+ const startedAt = process.hrtime.bigint();
256
+ const pathname = (() => {
257
+ try {
258
+ return new URL(req.originalUrl || req.url, 'http://localhost').pathname;
259
+ }
260
+ catch {
261
+ return req.path || req.url || '';
262
+ }
263
+ })();
264
+ const routeFamily = getRouteFamily(pathname);
265
+ let logged = false;
266
+ const log = (event) => {
267
+ if (logged)
268
+ return;
269
+ logged = true;
270
+ const durationMs = Number(process.hrtime.bigint() - startedAt) / 1_000_000;
271
+ writeJsonLog('access.log', {
272
+ event,
273
+ method: req.method,
274
+ path: pathname,
275
+ routeFamily,
276
+ statusCode: res.statusCode,
277
+ durationMs: Math.round(durationMs * 100) / 100,
278
+ contentLength: res.getHeader('Content-Length') ?? null,
279
+ clientId: req.clientId,
280
+ closedBeforeFinish: event === 'close' && !res.writableEnded,
281
+ });
282
+ if (res.statusCode >= 400 || (event === 'close' && !res.writableEnded)) {
283
+ writeErrorLog({
284
+ source: 'access',
285
+ event,
286
+ method: req.method,
287
+ path: pathname,
288
+ routeFamily,
289
+ statusCode: res.statusCode,
290
+ durationMs: Math.round(durationMs * 100) / 100,
291
+ clientId: req.clientId,
292
+ closedBeforeFinish: event === 'close' && !res.writableEnded,
293
+ });
294
+ }
295
+ };
296
+ res.on('finish', () => log('finish'));
297
+ res.on('close', () => {
298
+ if (!res.writableEnded)
299
+ log('close');
300
+ });
301
+ next();
302
+ });
303
+ app.use('/api', (req, res, next) => {
304
+ // JSON/API responses must never hit browser conditional caching. A 304 with
305
+ // an empty body breaks fetch().json() callers and looks like random IO
306
+ // failures in the sidebar.
307
+ delete req.headers['if-none-match'];
308
+ delete req.headers['if-modified-since'];
309
+ res.setHeader('Cache-Control', 'no-store');
310
+ next();
311
+ });
312
+ // 安全中间件:CSRF令牌生成(在所有路由之前)
313
+ app.use(csrfProtection.tokenMiddleware());
314
+ // 健康检查端点(不需要CSRF保护,也不需要登录)
315
+ app.get('/health', (_req, res) => {
316
+ res.json({
317
+ status: 'ok',
318
+ timestamp: new Date().toISOString(),
319
+ security: {
320
+ csrfEnabled: true,
321
+ pathValidationEnabled: true
322
+ }
323
+ });
324
+ });
325
+ // Client-side log relay — enables collecting browser/device logs
326
+ // on the server, critical for debugging mobile Safari / PWA issues.
327
+ app.post('/api/client-log', (req, res) => {
328
+ const { level, message, data } = req.body ?? {};
329
+ if (!shouldWriteClientLog(level, message)) {
330
+ res.json({ ok: true, suppressed: true });
331
+ return;
332
+ }
333
+ const ts = new Date().toISOString();
334
+ const line = `[client-log ${ts}] [${level ?? 'info'}] ${message ?? ''} ${data ? JSON.stringify(data) : ''}`;
335
+ writeTextLog('client.log', line);
336
+ const diffTraceSource = getDiffTraceSource(message);
337
+ if (diffTraceSource) {
338
+ writeDiffTraceLog({
339
+ source: diffTraceSource,
340
+ event: getDiffTraceEvent(message),
341
+ level: level ?? 'info',
342
+ traceId: data?.traceId,
343
+ interactionId: data?.interactionId,
344
+ filePath: data?.filePath ?? data?.selectedFilePath ?? data?.requestedPath,
345
+ requestPath: data?.requestPath,
346
+ cwd: data?.cwd,
347
+ gitRoot: data?.gitRoot,
348
+ data,
349
+ });
350
+ }
351
+ res.json({ ok: true });
352
+ });
353
+ app.use('/api/local', createLocalRouter({ token: options.localApiToken }));
354
+ // 手机首次接入引导(未信任 CA 前需要从 HTTP 内网页面下载证书)
355
+ app.use('/onboarding', createOnboardingRouter({ port: options.port, caCertPath: options.httpsCaPath }));
356
+ app.get('/ca', (_req, res) => {
357
+ if (!options.httpsCaPath || !fs.existsSync(options.httpsCaPath)) {
358
+ res.status(404).json({ error: 'CA certificate is not configured', code: 'CA_NOT_CONFIGURED' });
359
+ return;
360
+ }
361
+ res.download(options.httpsCaPath, 'rootCA.pem');
362
+ });
363
+ // 鉴权路由(公开:登录 / 登出 / 状态查询)
364
+ app.use('/api/auth/logout', csrfProtection.verifyMiddleware());
365
+ app.use('/api/auth', authRoutes);
366
+ // CSRF令牌获取端点(必须先登录后才能拿,避免未授权探测)
367
+ app.get('/api/csrf-token', requireAuth(), csrfProtection.getTokenHandler());
368
+ // 安全中间件:将路径验证器注入到请求对象中
369
+ app.use((req, _res, next) => {
370
+ req.pathValidator = pathValidator;
371
+ next();
372
+ });
373
+ app.get('/api/home', requireAuth(), (_req, res) => {
374
+ res.json({ home: homedir() });
375
+ });
376
+ // 应用鉴权 + CSRF保护(在终端路由之前)
377
+ app.use('/api/terminal', requireAuth());
378
+ app.use('/api/terminal', csrfProtection.verifyMiddleware());
379
+ // 终端路由
380
+ app.use('/api/terminal', terminalRoutes);
381
+ // 文件系统路由(继承 /api/terminal 上的 auth + CSRF 保护)
382
+ app.use('/api/terminal/fs', filesystemRoutes);
383
+ if (fs.existsSync(clientIndexPath)) {
384
+ app.use(createStaticCompressionMiddleware(clientDistPath));
385
+ app.use(express.static(clientDistPath, {
386
+ setHeaders: (res, filePath, stat) => {
387
+ void stat;
388
+ const relativePath = `/${path.relative(clientDistPath, filePath).split(path.sep).join('/')}`;
389
+ setStaticCacheHeaders({ url: relativePath, path: relativePath }, res);
390
+ },
391
+ }));
392
+ app.get(/^(?!\/api(?:\/|$)|\/health$|\/onboarding(?:\/|$)|\/ca(?:\/|$)).*/, (req, res) => {
393
+ setStaticCacheHeaders(req, res);
394
+ res.sendFile(clientIndexPath);
395
+ });
396
+ }
397
+ return app;
398
+ }
399
+ function createServerForApp(app, options) {
400
+ if (options.httpsCertPath && options.httpsKeyPath) {
401
+ const cert = fs.readFileSync(options.httpsCertPath);
402
+ const key = fs.readFileSync(options.httpsKeyPath);
403
+ return { server: createHttpsServer({ cert, key }, app), scheme: 'https' };
404
+ }
405
+ return { server: createHttpServer(app), scheme: 'http' };
406
+ }
407
+ function reloadHttpsCertificate(server, options) {
408
+ if (!options.httpsCertPath || !options.httpsKeyPath || typeof server.setSecureContext !== 'function') {
409
+ return false;
410
+ }
411
+ const cert = fs.readFileSync(options.httpsCertPath);
412
+ const key = fs.readFileSync(options.httpsKeyPath);
413
+ server.setSecureContext({ cert, key });
414
+ return true;
415
+ }
416
+ export function startServer(options = {}) {
417
+ const port = options.port ?? Number(process.env.PORT || DEFAULT_PORT);
418
+ const host = options.host ?? (process.env.HOST || DEFAULT_HOST);
419
+ const app = createApp({ port: options.onboardingPort ?? port, httpsCaPath: options.httpsCaPath, localApiToken: options.localApiToken });
420
+ const { server, scheme } = createServerForApp(app, options);
421
+ setSecureCookieMode(scheme === 'https');
422
+ const certWatcher = new CertificateWatcher({
423
+ enabled: scheme === 'https' && Boolean(options.httpsCertPath && options.httpsKeyPath),
424
+ certPath: options.httpsCertPath,
425
+ keyPath: options.httpsKeyPath,
426
+ caPath: options.httpsCaPath,
427
+ });
428
+ certWatcher.on('refresh-needed', (missingNames) => {
429
+ void (async () => {
430
+ const result = await options.onCertificateRefreshNeeded?.(missingNames);
431
+ if (!result?.reloaded && !reloadHttpsCertificate(server, options)) {
432
+ console.warn('[cert-watch] certificate refresh requested but HTTPS context could not be reloaded');
433
+ certWatcher.markRefreshComplete(missingNames, false);
434
+ return;
435
+ }
436
+ if (result?.localAccessState) {
437
+ latestLocalAccessState = result.localAccessState;
438
+ latestOnboardingUrl = getOnboardingServerUrl();
439
+ }
440
+ certWatcher.markRefreshComplete(missingNames, true);
441
+ console.log('[cert-watch] HTTPS certificate context reloaded');
442
+ })().catch((error) => {
443
+ certWatcher.markRefreshComplete(missingNames, false);
444
+ console.error('[cert-watch] failed to handle certificate refresh:', error);
445
+ });
446
+ });
447
+ certWatcher.start();
448
+ let latestLocalAccessState = localAccessManager.getState();
449
+ let latestOnboardingUrl = null;
450
+ // WebSocket for bidirectional terminal communication.
451
+ // Replaces SSE (server→client) + HTTP POST (client→server) with a single
452
+ // persistent connection per terminal session.
453
+ //
454
+ // perMessageDeflate: 终端输出是纯文本,压缩比通常 5-10x。弱网/跨城(高 RTT、
455
+ // 低带宽)下,刷新页面时 N 个终端各自一次性回放全量 scrollback(每个可达
456
+ // 100KB),未压缩会同时挤满链路、肉眼可见地卡几秒。开启压缩后带宽需求直接
457
+ // 降到 1/5~1/10。参数:
458
+ // - threshold 1024:小于 1KB 的小包(按键回显等)不压缩,避免 CPU 浪费。
459
+ // - concurrencyLimit:限制并发压缩任务,防止突发回放打满 CPU。
460
+ // - zlib memLevel 7 + level 6:在压缩比和内存/CPU 间取均衡,避免每连接
461
+ // 分配过大 zlib 上下文(默认 memLevel 8 内存更高)。
462
+ // - serverNoContextTakeover:每条消息独立压缩上下文,降低长连接常驻内存。
463
+ const wss = new WebSocketServer({
464
+ noServer: true,
465
+ perMessageDeflate: {
466
+ threshold: 1024,
467
+ concurrencyLimit: 10,
468
+ serverNoContextTakeover: true,
469
+ clientNoContextTakeover: true,
470
+ zlibDeflateOptions: { level: 6, memLevel: 7 },
471
+ },
472
+ });
473
+ const WS_PATH_RE = /^\/api\/terminal\/([^/]+)\/ws$/;
474
+ const CONTROL_WS_PATH = '/api/control/ws';
475
+ server.on('upgrade', (request, socket, head) => {
476
+ if (!isAllowedHost(request.headers.host) || !isUpgradeOriginAllowed(request.headers.origin, request.headers.host)) {
477
+ socket.write('HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n');
478
+ socket.destroy();
479
+ return;
480
+ }
481
+ const url = new URL(request.url ?? '/', `${scheme}://${request.headers.host ?? 'localhost'}`);
482
+ const pathname = url.pathname;
483
+ // Reject the upgrade before the WebSocket handshake completes if auth
484
+ // is enabled and the client cookie is missing/expired. We respond with
485
+ // a real HTTP 401 so the browser surfaces a useful error. Applied to
486
+ // both per-terminal and control paths.
487
+ const cookieHeader = request.headers.cookie;
488
+ if (!isUpgradeRequestAuthenticated(typeof cookieHeader === 'string' ? cookieHeader : undefined)) {
489
+ socket.write('HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n');
490
+ socket.destroy();
491
+ return;
492
+ }
493
+ // Control WS: server-pushed global client-state events. One per browser.
494
+ if (pathname === CONTROL_WS_PATH) {
495
+ const clientId = crypto.randomUUID();
496
+ wss.handleUpgrade(request, socket, head, (ws) => {
497
+ handleControlWebSocket(ws, clientId);
498
+ });
499
+ return;
500
+ }
501
+ // Per-terminal WS: bidirectional I/O for a single terminal session.
502
+ const match = pathname.match(WS_PATH_RE);
503
+ if (!match) {
504
+ socket.destroy();
505
+ return;
506
+ }
507
+ const sessionId = match[1];
508
+ const clientId = crypto.randomUUID();
509
+ // 短线重连时客户端会带上 ?since=<lastSeq>,让服务端只补发增量。
510
+ const sinceParam = url.searchParams.get('since');
511
+ const sinceSeq = sinceParam ? Math.max(0, Number.parseInt(sinceParam, 10) || 0) : 0;
512
+ wss.handleUpgrade(request, socket, head, (ws) => {
513
+ handleTerminalWebSocket(ws, sessionId, clientId, { sinceSeq });
514
+ });
515
+ });
516
+ server.listen(port, host, () => {
517
+ const displayHost = host === '0.0.0.0' ? 'localhost' : host;
518
+ console.log(`Termdock server running at ${scheme}://${displayHost}:${port}`);
519
+ console.log(`Health check: ${scheme}://${displayHost}:${port}/health`);
520
+ const onboardingServerState = scheme === 'https'
521
+ ? startOnboardingServer({ httpsPort: port, caCertPath: options.httpsCaPath })
522
+ : { server: null, url: null };
523
+ void localAccessManager.start({ host, port, scheme, caCertPath: options.httpsCaPath, onboardingPort: options.onboardingPort ?? port }).then((state) => {
524
+ const publishState = () => {
525
+ const onboardingUrl = onboardingServerState.url ?? state.onboardingUrl;
526
+ latestLocalAccessState = state;
527
+ latestOnboardingUrl = onboardingUrl;
528
+ if (state.status === 'active') {
529
+ console.log(`LAN access: ${state.url}`);
530
+ if (onboardingUrl)
531
+ console.log(`Mobile setup: ${onboardingUrl} (open this on your phone to download the CA certificate)`);
532
+ }
533
+ else {
534
+ console.log(`LAN access: ${state.status}${state.reason ? ` (${state.reason})` : ''}`);
535
+ }
536
+ };
537
+ if (onboardingServerState.server && !onboardingServerState.url) {
538
+ onboardingServerState.server.once('listening', publishState);
539
+ }
540
+ else {
541
+ publishState();
542
+ }
543
+ }).catch((error) => {
544
+ console.warn('[local-access] failed to start:', error);
545
+ });
546
+ });
547
+ server.on('error', (error) => {
548
+ if (error.code === 'EADDRINUSE') {
549
+ console.error(`Port ${port} is already in use.`);
550
+ console.error(`To free it, find and stop the process: lsof -tiTCP:${port} -sTCP:LISTEN | xargs kill`);
551
+ process.exit(1);
552
+ }
553
+ console.error('Server error:', error);
554
+ process.exit(1);
555
+ });
556
+ server.on('close', () => {
557
+ certWatcher.stop();
558
+ stopOnboardingServer();
559
+ void localAccessManager.stop();
560
+ });
561
+ return {
562
+ server,
563
+ scheme,
564
+ getLocalAccessState: () => latestLocalAccessState,
565
+ getOnboardingUrl: () => latestOnboardingUrl,
566
+ };
567
+ }
568
+ const isDirectExecution = process.argv[1] && path.resolve(process.argv[1]) === currentFilePath;
569
+ if (isDirectExecution) {
570
+ startServer();
571
+ }
@@ -0,0 +1,54 @@
1
+ import express from 'express';
2
+ import { createServer } from 'http';
3
+ import fs from 'fs';
4
+ import { createOnboardingRouter } from './routes/onboarding.js';
5
+ import { getLanIPv4Addresses } from './utils/localAccess.js';
6
+ let activeServer = null;
7
+ let activeUrl = null;
8
+ function canStart(options) {
9
+ return Boolean(options.caCertPath && fs.existsSync(options.caCertPath));
10
+ }
11
+ export function startOnboardingServer(options) {
12
+ stopOnboardingServer();
13
+ if (!canStart(options)) {
14
+ return { server: null, url: null };
15
+ }
16
+ const app = express();
17
+ app.use('/onboarding', createOnboardingRouter({ port: options.httpsPort, caCertPath: options.caCertPath }));
18
+ app.get('/ca', (_req, res) => {
19
+ if (!options.caCertPath || !fs.existsSync(options.caCertPath)) {
20
+ res.status(404).json({ error: 'CA certificate is not configured', code: 'CA_NOT_CONFIGURED' });
21
+ return;
22
+ }
23
+ res.setHeader('Content-Type', 'application/x-x509-ca-cert');
24
+ res.setHeader('Content-Disposition', 'attachment; filename="rootCA.pem"');
25
+ res.send(fs.readFileSync(options.caCertPath));
26
+ });
27
+ app.get('/', (_req, res) => res.redirect('/onboarding'));
28
+ const server = createServer(app);
29
+ server.on('error', (error) => {
30
+ console.warn('[onboarding] HTTP setup server failed:', error);
31
+ });
32
+ server.once('listening', () => {
33
+ const address = server.address();
34
+ const port = address?.port ?? null;
35
+ const host = getLanIPv4Addresses()[0] ?? 'localhost';
36
+ activeUrl = port ? `http://${host}:${port}/onboarding` : null;
37
+ });
38
+ server.listen(0, '0.0.0.0');
39
+ activeServer = server;
40
+ return { server, get url() { return activeUrl; } };
41
+ }
42
+ export function stopOnboardingServer() {
43
+ if (activeServer) {
44
+ try {
45
+ activeServer.close();
46
+ }
47
+ catch { /* ignore */ }
48
+ }
49
+ activeServer = null;
50
+ activeUrl = null;
51
+ }
52
+ export function getOnboardingServerUrl() {
53
+ return activeUrl;
54
+ }