@juspay/shooter 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 (327) hide show
  1. package/.claude/hooks/notifier.cjs +1431 -0
  2. package/.claude/settings.json +162 -0
  3. package/README.md +515 -0
  4. package/bin/shooter.cjs +141 -0
  5. package/build/client/_app/immutable/assets/0.CM9Hl6d-.css +1 -0
  6. package/build/client/_app/immutable/assets/0.CM9Hl6d-.css.br +0 -0
  7. package/build/client/_app/immutable/assets/0.CM9Hl6d-.css.gz +0 -0
  8. package/build/client/_app/immutable/assets/2.CAShZ7lQ.css +1 -0
  9. package/build/client/_app/immutable/assets/2.CAShZ7lQ.css.br +1 -0
  10. package/build/client/_app/immutable/assets/2.CAShZ7lQ.css.gz +0 -0
  11. package/build/client/_app/immutable/assets/3.C0uFg0IS.css +1 -0
  12. package/build/client/_app/immutable/assets/3.C0uFg0IS.css.br +0 -0
  13. package/build/client/_app/immutable/assets/3.C0uFg0IS.css.gz +0 -0
  14. package/build/client/_app/immutable/assets/4.cJuCkJKZ.css +1 -0
  15. package/build/client/_app/immutable/assets/4.cJuCkJKZ.css.br +0 -0
  16. package/build/client/_app/immutable/assets/4.cJuCkJKZ.css.gz +0 -0
  17. package/build/client/_app/immutable/assets/5.DRjApZQW.css +1 -0
  18. package/build/client/_app/immutable/assets/5.DRjApZQW.css.br +0 -0
  19. package/build/client/_app/immutable/assets/5.DRjApZQW.css.gz +0 -0
  20. package/build/client/_app/immutable/assets/6.AraZrY8I.css +1 -0
  21. package/build/client/_app/immutable/assets/6.AraZrY8I.css.br +0 -0
  22. package/build/client/_app/immutable/assets/6.AraZrY8I.css.gz +0 -0
  23. package/build/client/_app/immutable/assets/7.BCJ1IuMx.css +1 -0
  24. package/build/client/_app/immutable/assets/7.BCJ1IuMx.css.br +0 -0
  25. package/build/client/_app/immutable/assets/7.BCJ1IuMx.css.gz +0 -0
  26. package/build/client/_app/immutable/assets/ChatView.CsdBAOKx.css +1 -0
  27. package/build/client/_app/immutable/assets/ChatView.CsdBAOKx.css.br +0 -0
  28. package/build/client/_app/immutable/assets/ChatView.CsdBAOKx.css.gz +0 -0
  29. package/build/client/_app/immutable/assets/markdown.B0b5w2tq.css +1 -0
  30. package/build/client/_app/immutable/assets/markdown.B0b5w2tq.css.br +0 -0
  31. package/build/client/_app/immutable/assets/markdown.B0b5w2tq.css.gz +0 -0
  32. package/build/client/_app/immutable/assets/xterm.DFuMZ0ql.css +1 -0
  33. package/build/client/_app/immutable/assets/xterm.DFuMZ0ql.css.br +0 -0
  34. package/build/client/_app/immutable/assets/xterm.DFuMZ0ql.css.gz +0 -0
  35. package/build/client/_app/immutable/chunks/BNJphC1q.js +56 -0
  36. package/build/client/_app/immutable/chunks/BNJphC1q.js.br +0 -0
  37. package/build/client/_app/immutable/chunks/BNJphC1q.js.gz +0 -0
  38. package/build/client/_app/immutable/chunks/BTGVxaYV.js +9 -0
  39. package/build/client/_app/immutable/chunks/BTGVxaYV.js.br +0 -0
  40. package/build/client/_app/immutable/chunks/BTGVxaYV.js.gz +0 -0
  41. package/build/client/_app/immutable/chunks/BlxrFPDK.js +1 -0
  42. package/build/client/_app/immutable/chunks/BlxrFPDK.js.br +0 -0
  43. package/build/client/_app/immutable/chunks/BlxrFPDK.js.gz +0 -0
  44. package/build/client/_app/immutable/chunks/Bvk7mfPM.js +1 -0
  45. package/build/client/_app/immutable/chunks/Bvk7mfPM.js.br +0 -0
  46. package/build/client/_app/immutable/chunks/Bvk7mfPM.js.gz +0 -0
  47. package/build/client/_app/immutable/chunks/CAokzuPQ.js +1 -0
  48. package/build/client/_app/immutable/chunks/CAokzuPQ.js.br +0 -0
  49. package/build/client/_app/immutable/chunks/CAokzuPQ.js.gz +0 -0
  50. package/build/client/_app/immutable/chunks/CGLrx-H5.js +1 -0
  51. package/build/client/_app/immutable/chunks/CGLrx-H5.js.br +0 -0
  52. package/build/client/_app/immutable/chunks/CGLrx-H5.js.gz +0 -0
  53. package/build/client/_app/immutable/chunks/CgCpWzEA.js +1 -0
  54. package/build/client/_app/immutable/chunks/CgCpWzEA.js.br +0 -0
  55. package/build/client/_app/immutable/chunks/CgCpWzEA.js.gz +0 -0
  56. package/build/client/_app/immutable/chunks/Cjwk_cGO.js +6 -0
  57. package/build/client/_app/immutable/chunks/Cjwk_cGO.js.br +0 -0
  58. package/build/client/_app/immutable/chunks/Cjwk_cGO.js.gz +0 -0
  59. package/build/client/_app/immutable/chunks/CtQ8EED1.js +11 -0
  60. package/build/client/_app/immutable/chunks/CtQ8EED1.js.br +0 -0
  61. package/build/client/_app/immutable/chunks/CtQ8EED1.js.gz +0 -0
  62. package/build/client/_app/immutable/chunks/DERQCisl.js +1 -0
  63. package/build/client/_app/immutable/chunks/DERQCisl.js.br +0 -0
  64. package/build/client/_app/immutable/chunks/DERQCisl.js.gz +0 -0
  65. package/build/client/_app/immutable/chunks/DKrg8TQs.js +1 -0
  66. package/build/client/_app/immutable/chunks/DKrg8TQs.js.br +0 -0
  67. package/build/client/_app/immutable/chunks/DKrg8TQs.js.gz +0 -0
  68. package/build/client/_app/immutable/chunks/DLu6yJIZ.js +1 -0
  69. package/build/client/_app/immutable/chunks/DLu6yJIZ.js.br +0 -0
  70. package/build/client/_app/immutable/chunks/DLu6yJIZ.js.gz +0 -0
  71. package/build/client/_app/immutable/chunks/Dkkpz_4D.js +126 -0
  72. package/build/client/_app/immutable/chunks/Dkkpz_4D.js.br +0 -0
  73. package/build/client/_app/immutable/chunks/Dkkpz_4D.js.gz +0 -0
  74. package/build/client/_app/immutable/chunks/DoczjQhA.js +1 -0
  75. package/build/client/_app/immutable/chunks/DoczjQhA.js.br +0 -0
  76. package/build/client/_app/immutable/chunks/DoczjQhA.js.gz +0 -0
  77. package/build/client/_app/immutable/chunks/PPVm8Dsz.js +1 -0
  78. package/build/client/_app/immutable/chunks/PPVm8Dsz.js.br +0 -0
  79. package/build/client/_app/immutable/chunks/PPVm8Dsz.js.gz +0 -0
  80. package/build/client/_app/immutable/chunks/RpcNruLP.js +2 -0
  81. package/build/client/_app/immutable/chunks/RpcNruLP.js.br +0 -0
  82. package/build/client/_app/immutable/chunks/RpcNruLP.js.gz +0 -0
  83. package/build/client/_app/immutable/chunks/a-St0Zwo.js +1 -0
  84. package/build/client/_app/immutable/chunks/a-St0Zwo.js.br +0 -0
  85. package/build/client/_app/immutable/chunks/a-St0Zwo.js.gz +0 -0
  86. package/build/client/_app/immutable/chunks/bo70OQUZ.js +1 -0
  87. package/build/client/_app/immutable/chunks/bo70OQUZ.js.br +0 -0
  88. package/build/client/_app/immutable/chunks/bo70OQUZ.js.gz +0 -0
  89. package/build/client/_app/immutable/entry/app.QvGgdvTI.js +2 -0
  90. package/build/client/_app/immutable/entry/app.QvGgdvTI.js.br +0 -0
  91. package/build/client/_app/immutable/entry/app.QvGgdvTI.js.gz +0 -0
  92. package/build/client/_app/immutable/entry/start.BntDNRMC.js +1 -0
  93. package/build/client/_app/immutable/entry/start.BntDNRMC.js.br +0 -0
  94. package/build/client/_app/immutable/entry/start.BntDNRMC.js.gz +0 -0
  95. package/build/client/_app/immutable/nodes/0.CzkdvJ7j.js +1 -0
  96. package/build/client/_app/immutable/nodes/0.CzkdvJ7j.js.br +0 -0
  97. package/build/client/_app/immutable/nodes/0.CzkdvJ7j.js.gz +0 -0
  98. package/build/client/_app/immutable/nodes/1.MG1QhfrI.js +1 -0
  99. package/build/client/_app/immutable/nodes/1.MG1QhfrI.js.br +0 -0
  100. package/build/client/_app/immutable/nodes/1.MG1QhfrI.js.gz +0 -0
  101. package/build/client/_app/immutable/nodes/2.B4MlOSh6.js +1 -0
  102. package/build/client/_app/immutable/nodes/2.B4MlOSh6.js.br +0 -0
  103. package/build/client/_app/immutable/nodes/2.B4MlOSh6.js.gz +0 -0
  104. package/build/client/_app/immutable/nodes/3.DIwYkjDn.js +3 -0
  105. package/build/client/_app/immutable/nodes/3.DIwYkjDn.js.br +0 -0
  106. package/build/client/_app/immutable/nodes/3.DIwYkjDn.js.gz +0 -0
  107. package/build/client/_app/immutable/nodes/4.D-cIe70D.js +1 -0
  108. package/build/client/_app/immutable/nodes/4.D-cIe70D.js.br +0 -0
  109. package/build/client/_app/immutable/nodes/4.D-cIe70D.js.gz +0 -0
  110. package/build/client/_app/immutable/nodes/5.D7zPRe3L.js +1 -0
  111. package/build/client/_app/immutable/nodes/5.D7zPRe3L.js.br +0 -0
  112. package/build/client/_app/immutable/nodes/5.D7zPRe3L.js.gz +0 -0
  113. package/build/client/_app/immutable/nodes/6.BB7QE48r.js +2 -0
  114. package/build/client/_app/immutable/nodes/6.BB7QE48r.js.br +0 -0
  115. package/build/client/_app/immutable/nodes/6.BB7QE48r.js.gz +0 -0
  116. package/build/client/_app/immutable/nodes/7.D8mqsrZG.js +2 -0
  117. package/build/client/_app/immutable/nodes/7.D8mqsrZG.js.br +0 -0
  118. package/build/client/_app/immutable/nodes/7.D8mqsrZG.js.gz +0 -0
  119. package/build/client/_app/version.json +1 -0
  120. package/build/client/_app/version.json.br +0 -0
  121. package/build/client/_app/version.json.gz +0 -0
  122. package/build/client/app-icon.png +0 -0
  123. package/build/client/apple-touch-icon.png +0 -0
  124. package/build/client/favicon.png +0 -0
  125. package/build/client/favicon.svg +10 -0
  126. package/build/client/favicon.svg.br +0 -0
  127. package/build/client/favicon.svg.gz +0 -0
  128. package/build/client/manifest.webmanifest +1 -0
  129. package/build/client/pwa-192x192.png +0 -0
  130. package/build/client/pwa-512x512.png +0 -0
  131. package/build/client/registerSW.js +1 -0
  132. package/build/client/registerSW.js.br +0 -0
  133. package/build/client/registerSW.js.gz +0 -0
  134. package/build/client/sw.js +222 -0
  135. package/build/client/sw.js.br +0 -0
  136. package/build/client/sw.js.gz +0 -0
  137. package/build/client/workbox-5119daf5.js +3395 -0
  138. package/build/client/workbox-5119daf5.js.br +0 -0
  139. package/build/client/workbox-5119daf5.js.gz +0 -0
  140. package/build/env.js +94 -0
  141. package/build/handler.js +1494 -0
  142. package/build/index.js +345 -0
  143. package/build/pty-holder.cjs +510 -0
  144. package/build/server/chunks/0-q2IUp76Y.js +9 -0
  145. package/build/server/chunks/0-q2IUp76Y.js.map +1 -0
  146. package/build/server/chunks/1-CU50G5wZ.js +9 -0
  147. package/build/server/chunks/1-CU50G5wZ.js.map +1 -0
  148. package/build/server/chunks/2-D01t9s8T.js +9 -0
  149. package/build/server/chunks/2-D01t9s8T.js.map +1 -0
  150. package/build/server/chunks/3-5PUQ04wC.js +9 -0
  151. package/build/server/chunks/3-5PUQ04wC.js.map +1 -0
  152. package/build/server/chunks/4-e7gywnSG.js +9 -0
  153. package/build/server/chunks/4-e7gywnSG.js.map +1 -0
  154. package/build/server/chunks/5-CA1SA6KZ.js +9 -0
  155. package/build/server/chunks/5-CA1SA6KZ.js.map +1 -0
  156. package/build/server/chunks/6-71H221sV.js +9 -0
  157. package/build/server/chunks/6-71H221sV.js.map +1 -0
  158. package/build/server/chunks/7-Bo-vmdyz.js +9 -0
  159. package/build/server/chunks/7-Bo-vmdyz.js.map +1 -0
  160. package/build/server/chunks/_layout.svelte-SFHOxs74.js +132 -0
  161. package/build/server/chunks/_layout.svelte-SFHOxs74.js.map +1 -0
  162. package/build/server/chunks/_page.svelte-B4w-2wD-.js +120 -0
  163. package/build/server/chunks/_page.svelte-B4w-2wD-.js.map +1 -0
  164. package/build/server/chunks/_page.svelte-B_qAXjkh.js +213 -0
  165. package/build/server/chunks/_page.svelte-B_qAXjkh.js.map +1 -0
  166. package/build/server/chunks/_page.svelte-CsF1_TRG.js +50 -0
  167. package/build/server/chunks/_page.svelte-CsF1_TRG.js.map +1 -0
  168. package/build/server/chunks/_page.svelte-DJC6U-P0.js +68 -0
  169. package/build/server/chunks/_page.svelte-DJC6U-P0.js.map +1 -0
  170. package/build/server/chunks/_page.svelte-DQ6HBtsz.js +407 -0
  171. package/build/server/chunks/_page.svelte-DQ6HBtsz.js.map +1 -0
  172. package/build/server/chunks/_page.svelte-LbhhjP21.js +148 -0
  173. package/build/server/chunks/_page.svelte-LbhhjP21.js.map +1 -0
  174. package/build/server/chunks/_server.ts-BL2FGb5Z.js +387 -0
  175. package/build/server/chunks/_server.ts-BL2FGb5Z.js.map +1 -0
  176. package/build/server/chunks/_server.ts-BgdjBZco.js +47 -0
  177. package/build/server/chunks/_server.ts-BgdjBZco.js.map +1 -0
  178. package/build/server/chunks/_server.ts-BihKSdj_.js +59 -0
  179. package/build/server/chunks/_server.ts-BihKSdj_.js.map +1 -0
  180. package/build/server/chunks/_server.ts-BjOJsoy4.js +63 -0
  181. package/build/server/chunks/_server.ts-BjOJsoy4.js.map +1 -0
  182. package/build/server/chunks/_server.ts-C29xzfaw.js +77 -0
  183. package/build/server/chunks/_server.ts-C29xzfaw.js.map +1 -0
  184. package/build/server/chunks/_server.ts-CPa6DgIt.js +71 -0
  185. package/build/server/chunks/_server.ts-CPa6DgIt.js.map +1 -0
  186. package/build/server/chunks/_server.ts-CbDRDIoP.js +36 -0
  187. package/build/server/chunks/_server.ts-CbDRDIoP.js.map +1 -0
  188. package/build/server/chunks/_server.ts-Cl1OEWL4.js +54 -0
  189. package/build/server/chunks/_server.ts-Cl1OEWL4.js.map +1 -0
  190. package/build/server/chunks/_server.ts-ColfDHW8.js +60 -0
  191. package/build/server/chunks/_server.ts-ColfDHW8.js.map +1 -0
  192. package/build/server/chunks/_server.ts-Cv_OrRuL.js +494 -0
  193. package/build/server/chunks/_server.ts-Cv_OrRuL.js.map +1 -0
  194. package/build/server/chunks/_server.ts-D4MNi4cD.js +25 -0
  195. package/build/server/chunks/_server.ts-D4MNi4cD.js.map +1 -0
  196. package/build/server/chunks/_server.ts-DRVbgm6k.js +125 -0
  197. package/build/server/chunks/_server.ts-DRVbgm6k.js.map +1 -0
  198. package/build/server/chunks/_server.ts-DfajWaqh.js +39 -0
  199. package/build/server/chunks/_server.ts-DfajWaqh.js.map +1 -0
  200. package/build/server/chunks/_server.ts-y9-WYDMa.js +35 -0
  201. package/build/server/chunks/_server.ts-y9-WYDMa.js.map +1 -0
  202. package/build/server/chunks/auth-CEgFis71.js +32 -0
  203. package/build/server/chunks/auth-CEgFis71.js.map +1 -0
  204. package/build/server/chunks/client-CxCatAKr.js +255 -0
  205. package/build/server/chunks/client-CxCatAKr.js.map +1 -0
  206. package/build/server/chunks/error.svelte-BqdwMWdK.js +26 -0
  207. package/build/server/chunks/error.svelte-BqdwMWdK.js.map +1 -0
  208. package/build/server/chunks/exports-CJ0Q5XmL.js +4081 -0
  209. package/build/server/chunks/exports-CJ0Q5XmL.js.map +1 -0
  210. package/build/server/chunks/index2-DAxIoAO-.js +36 -0
  211. package/build/server/chunks/index2-DAxIoAO-.js.map +1 -0
  212. package/build/server/chunks/jsonl-parser-dmZU_Hyu.js +137 -0
  213. package/build/server/chunks/jsonl-parser-dmZU_Hyu.js.map +1 -0
  214. package/build/server/chunks/library-apns-BHxLmuIx.js +104 -0
  215. package/build/server/chunks/library-apns-BHxLmuIx.js.map +1 -0
  216. package/build/server/chunks/markdown-Bxrl3cCF.js +1241 -0
  217. package/build/server/chunks/markdown-Bxrl3cCF.js.map +1 -0
  218. package/build/server/chunks/pending-requests-D8UiTw7L.js +44 -0
  219. package/build/server/chunks/pending-requests-D8UiTw7L.js.map +1 -0
  220. package/build/server/chunks/pty-manager-C0FhBiVq.js +1697 -0
  221. package/build/server/chunks/pty-manager-C0FhBiVq.js.map +1 -0
  222. package/build/server/chunks/shared-server-BDY8jh20.js +200 -0
  223. package/build/server/chunks/shared-server-BDY8jh20.js.map +1 -0
  224. package/build/server/chunks/stores-D0HorpgL.js +36 -0
  225. package/build/server/chunks/stores-D0HorpgL.js.map +1 -0
  226. package/build/server/index.js +6466 -0
  227. package/build/server/index.js.map +1 -0
  228. package/build/server/manifest.js +184 -0
  229. package/build/server/manifest.js.map +1 -0
  230. package/build/shims.js +32 -0
  231. package/package.json +94 -0
  232. package/scripts/clipboard-shims/wl-paste +48 -0
  233. package/scripts/clipboard-shims/xclip +31 -0
  234. package/scripts/install.sh +477 -0
  235. package/scripts/setup-node-pty.sh +63 -0
  236. package/scripts/setup.cjs +571 -0
  237. package/scripts/test-runner.ts +243 -0
  238. package/scripts/vercel-env-commands.sh +60 -0
  239. package/server.ts +139 -0
  240. package/src/app.css +1835 -0
  241. package/src/app.d.ts +31 -0
  242. package/src/app.html +24 -0
  243. package/src/generated/types/APN.ts +305 -0
  244. package/src/generated/types/CLI.ts +52 -0
  245. package/src/generated/types/JWT.ts +92 -0
  246. package/src/generated/types/Terminal.ts +2736 -0
  247. package/src/generated/types/index.ts +6 -0
  248. package/src/lib/assets/icons/alert-triangle.svg +5 -0
  249. package/src/lib/assets/icons/bell.svg +4 -0
  250. package/src/lib/assets/icons/check-circle.svg +4 -0
  251. package/src/lib/assets/icons/file.svg +4 -0
  252. package/src/lib/assets/icons/folder.svg +3 -0
  253. package/src/lib/assets/icons/play.svg +3 -0
  254. package/src/lib/assets/icons/refresh.svg +4 -0
  255. package/src/lib/assets/icons/settings.svg +4 -0
  256. package/src/lib/assets/icons/terminal.svg +1 -0
  257. package/src/lib/assets/icons/tool.svg +3 -0
  258. package/src/lib/assets/icons/x-circle.svg +5 -0
  259. package/src/lib/modules/client/common/Card.svelte +26 -0
  260. package/src/lib/modules/client/common/EmptyState.svelte +36 -0
  261. package/src/lib/modules/client/common/Icon.svelte +61 -0
  262. package/src/lib/modules/client/common/StatusBadge.svelte +38 -0
  263. package/src/lib/modules/client/common/cache.ts +31 -0
  264. package/src/lib/modules/client/common/config-guard.ts +18 -0
  265. package/src/lib/modules/client/common/index.ts +12 -0
  266. package/src/lib/modules/client/common/markdown.ts +23 -0
  267. package/src/lib/modules/client/common/native-bridge.ts +50 -0
  268. package/src/lib/modules/client/common/time.ts +22 -0
  269. package/src/lib/modules/client/common/tool-title.ts +28 -0
  270. package/src/lib/modules/client/terminal/ChatView.svelte +400 -0
  271. package/src/lib/modules/client/terminal/CommandPalette.svelte +60 -0
  272. package/src/lib/modules/client/terminal/ConnectionStatus.svelte +99 -0
  273. package/src/lib/modules/client/terminal/LaunchSheet.svelte +294 -0
  274. package/src/lib/modules/client/terminal/QuickKeys.svelte +71 -0
  275. package/src/lib/modules/client/terminal/ShortcutsHelp.svelte +79 -0
  276. package/src/lib/modules/client/terminal/keyboard-shortcuts.ts +70 -0
  277. package/src/lib/modules/client/terminal/xterm-wrapper.ts +243 -0
  278. package/src/lib/modules/server/apn/library-apns.ts +137 -0
  279. package/src/lib/modules/server/apn/notification-history.ts +35 -0
  280. package/src/lib/modules/server/apn/notification-sessions.ts +117 -0
  281. package/src/lib/modules/server/apn/pending-requests.ts +65 -0
  282. package/src/lib/modules/server/apn/types.ts +51 -0
  283. package/src/lib/modules/server/auth.ts +34 -0
  284. package/src/lib/modules/server/cli/index.ts +79 -0
  285. package/src/lib/modules/server/cli/runner.ts +162 -0
  286. package/src/lib/modules/server/fcm/fcm-service.ts +72 -0
  287. package/src/lib/modules/server/sessions/jsonl-parser.ts +197 -0
  288. package/src/lib/modules/server/sessions/jsonl-reader.ts +301 -0
  289. package/src/lib/modules/server/sessions/opencode-reader.ts +264 -0
  290. package/src/lib/modules/server/sessions/types.ts +53 -0
  291. package/src/lib/modules/server/terminal/holder-client.ts +273 -0
  292. package/src/lib/modules/server/terminal/opencode-watcher.ts +661 -0
  293. package/src/lib/modules/server/terminal/pty-holder.cjs +510 -0
  294. package/src/lib/modules/server/terminal/pty-manager.ts +1012 -0
  295. package/src/lib/modules/server/terminal/session-watcher.ts +320 -0
  296. package/src/lib/modules/server/terminal/terminal-store.ts +198 -0
  297. package/src/lib/modules/server/ws/events-handler.ts +73 -0
  298. package/src/lib/modules/server/ws/keepalive.ts +108 -0
  299. package/src/lib/modules/server/ws/server.ts +93 -0
  300. package/src/lib/modules/server/ws/session-handler.ts +462 -0
  301. package/src/lib/modules/server/ws/terminal-handler.ts +197 -0
  302. package/src/lib/modules/server/ws/ticket-store.ts +58 -0
  303. package/src/lib/theme.css +529 -0
  304. package/src/lib/types/config.ts +6 -0
  305. package/src/routes/+layout.svelte +218 -0
  306. package/src/routes/+page.svelte +261 -0
  307. package/src/routes/api/debug/+server.ts +33 -0
  308. package/src/routes/api/device-token/+server.ts +85 -0
  309. package/src/routes/api/health/+server.ts +100 -0
  310. package/src/routes/api/notify/+server.ts +418 -0
  311. package/src/routes/api/qr-config/+server.ts +45 -0
  312. package/src/routes/api/response/+server.ts +73 -0
  313. package/src/routes/api/sessions/+server.ts +120 -0
  314. package/src/routes/api/terminals/+server.ts +141 -0
  315. package/src/routes/api/terminals/[id]/+server.ts +75 -0
  316. package/src/routes/api/terminals/[id]/paste-image/+server.ts +61 -0
  317. package/src/routes/api/terminals/[id]/resize/+server.ts +60 -0
  318. package/src/routes/api/webhook/+server.ts +42 -0
  319. package/src/routes/api/ws-status/+server.ts +23 -0
  320. package/src/routes/api/ws-ticket/+server.ts +86 -0
  321. package/src/routes/config/+page.svelte +600 -0
  322. package/src/routes/project/+page.svelte +274 -0
  323. package/src/routes/session/[id]/+page.svelte +434 -0
  324. package/src/routes/terminals/+page.svelte +618 -0
  325. package/src/routes/terminals/[id]/+page.svelte +968 -0
  326. package/svelte.config.js +18 -0
  327. package/tsconfig.json +14 -0
@@ -0,0 +1,320 @@
1
+ import { watch as chokidarWatch, type FSWatcher } from 'chokidar';
2
+ import * as fs from 'fs';
3
+ import * as path from 'path';
4
+
5
+ import type { ConversationMessage, MessagePart } from '../sessions/types';
6
+
7
+ import { parseJsonlText } from '../sessions/jsonl-parser';
8
+
9
+ /**
10
+ * Callback invoked when new JSONL entries are parsed from a watched file.
11
+ */
12
+ type OnNewEntries = (entries: ConversationMessage[]) => void;
13
+
14
+ interface WatchedFile {
15
+ callbacks: Set<OnNewEntries>;
16
+ filePath: string;
17
+ offset: number;
18
+ watcher: FSWatcher;
19
+ }
20
+
21
+ // Path to Claude Code's project session data
22
+ const CLAUDE_PROJECTS_DIR = path.join(process.env.HOME || '', '.claude', 'projects');
23
+
24
+ /**
25
+ * SessionWatcher provides incremental, file-change-driven reading of
26
+ * Claude Code JSONL session files. It uses chokidar to detect writes
27
+ * and reads only the bytes appended since the last read, parsing them
28
+ * into structured ConversationMessage entries.
29
+ */
30
+ class SessionWatcher {
31
+ // Track assistant turns that span multiple JSONL lines, keyed by filePath
32
+ private assistantTurnsPerFile = new Map<
33
+ string,
34
+ Map<string, { parts: MessagePart[]; timestamp: string }>
35
+ >();
36
+ // Buffer for incomplete trailing lines (no terminating newline yet)
37
+ private lineBufferPerFile = new Map<string, string>();
38
+ // Track message index per file for generating fallback IDs
39
+ private messageIndexPerFile = new Map<string, number>();
40
+ private watchedFiles = new Map<string, WatchedFile>();
41
+
42
+ /**
43
+ * Read all entries from a JSONL file from the beginning.
44
+ * Used for catch-up replay when a new client connects mid-session.
45
+ */
46
+ getHistory(filePath: string): ConversationMessage[] {
47
+ if (!fs.existsSync(filePath)) {
48
+ return [];
49
+ }
50
+
51
+ try {
52
+ const raw = fs.readFileSync(filePath, 'utf-8');
53
+ const assistantTurns = new Map<string, { parts: MessagePart[]; timestamp: string }>();
54
+ const messages = parseJsonlText(raw, assistantTurns, 0);
55
+
56
+ // Flush any remaining incomplete assistant turns
57
+ for (const [msgId, turn] of assistantTurns) {
58
+ if (turn.parts.length > 0) {
59
+ messages.push({
60
+ id: msgId,
61
+ parts: turn.parts,
62
+ role: 'assistant',
63
+ timestamp: turn.timestamp,
64
+ });
65
+ }
66
+ }
67
+
68
+ return messages;
69
+ } catch (error) {
70
+ console.error(`[session-watcher] Failed to read history for ${filePath}:`, error);
71
+ return [];
72
+ }
73
+ }
74
+
75
+ /**
76
+ * Get raw JSONL entries from a session file (unparsed objects).
77
+ */
78
+ getRawEntries(filePath: string): Record<string, unknown>[] {
79
+ if (!fs.existsSync(filePath)) {
80
+ return [];
81
+ }
82
+
83
+ try {
84
+ const raw = fs.readFileSync(filePath, 'utf-8');
85
+ const entries: Record<string, unknown>[] = [];
86
+ for (const line of raw.split('\n')) {
87
+ const trimmed = line.trim();
88
+ if (!trimmed) {continue;}
89
+ try {
90
+ entries.push(JSON.parse(trimmed));
91
+ } catch {
92
+ // skip malformed lines
93
+ }
94
+ }
95
+ return entries;
96
+ } catch (error) {
97
+ console.error(`[session-watcher] Failed to read raw entries for ${filePath}:`, error);
98
+ return [];
99
+ }
100
+ }
101
+
102
+ /**
103
+ * Stop watching a specific file and clean up resources.
104
+ */
105
+ stop(filePath: string): void {
106
+ const watched = this.watchedFiles.get(filePath);
107
+ if (!watched) {
108
+ return;
109
+ }
110
+
111
+ watched.watcher.close();
112
+ this.watchedFiles.delete(filePath);
113
+ this.assistantTurnsPerFile.delete(filePath);
114
+ this.messageIndexPerFile.delete(filePath);
115
+ this.lineBufferPerFile.delete(filePath);
116
+
117
+ console.log(`[session-watcher] Stopped watching: ${filePath}`);
118
+ }
119
+
120
+ /**
121
+ * Stop watching all files and clean up all resources.
122
+ */
123
+ stopAll(): void {
124
+ for (const [filePath] of this.watchedFiles) {
125
+ this.stop(filePath);
126
+ }
127
+ }
128
+
129
+ /**
130
+ * Subscribe to new JSONL entries for a file. If the file is not yet
131
+ * being watched, starts watching it. Returns an unsubscribe function
132
+ * that removes the callback (and stops the watcher when no subscribers
133
+ * remain). Matches the multi-subscriber pattern used by OpenCodeWatcher.
134
+ */
135
+ subscribe(filePath: string, onNewEntries: OnNewEntries): () => void {
136
+ const existing = this.watchedFiles.get(filePath);
137
+ if (existing) {
138
+ // Already watching — just add the new callback.
139
+ existing.callbacks.add(onNewEntries);
140
+ console.log(
141
+ `[session-watcher] Added subscriber to: ${filePath} (total=${existing.callbacks.size})`
142
+ );
143
+ return () => {
144
+ existing.callbacks.delete(onNewEntries);
145
+ console.log(
146
+ `[session-watcher] Removed subscriber from: ${filePath} (remaining=${existing.callbacks.size})`
147
+ );
148
+ if (existing.callbacks.size === 0) {
149
+ this.stop(filePath);
150
+ }
151
+ };
152
+ }
153
+
154
+ // Initialize tracking state for this file
155
+ const initialOffset = fs.existsSync(filePath) ? fs.statSync(filePath).size : 0;
156
+ this.assistantTurnsPerFile.set(filePath, new Map());
157
+ this.messageIndexPerFile.set(filePath, 0);
158
+ this.lineBufferPerFile.set(filePath, '');
159
+
160
+ const watcher = chokidarWatch(filePath, {
161
+ // Debounce rapid successive writes
162
+ awaitWriteFinish: {
163
+ pollInterval: 100,
164
+ stabilityThreshold: 200,
165
+ },
166
+ // Don't emit 'add' event on initial scan — we handle catch-up via getHistory
167
+ ignoreInitial: true,
168
+ // Use polling as a fallback for network filesystems
169
+ usePolling: false,
170
+ });
171
+
172
+ const watched: WatchedFile = {
173
+ callbacks: new Set([onNewEntries]),
174
+ filePath,
175
+ offset: initialOffset,
176
+ watcher,
177
+ };
178
+
179
+ watcher.on('change', () => {
180
+ this.readNewEntries(watched);
181
+ });
182
+
183
+ // Handle file creation if it doesn't exist yet (PTY Manager may
184
+ // start watching before the AI process creates the file)
185
+ watcher.on('add', () => {
186
+ this.readNewEntries(watched);
187
+ });
188
+
189
+ watcher.on('error', (error) => {
190
+ console.error(`[session-watcher] Error watching ${filePath}:`, error);
191
+ });
192
+
193
+ this.watchedFiles.set(filePath, watched);
194
+ console.log(`[session-watcher] Watching: ${filePath} (offset: ${initialOffset})`);
195
+
196
+ return () => {
197
+ watched.callbacks.delete(onNewEntries);
198
+ console.log(
199
+ `[session-watcher] Removed subscriber from: ${filePath} (remaining=${watched.callbacks.size})`
200
+ );
201
+ if (watched.callbacks.size === 0) {
202
+ this.stop(filePath);
203
+ }
204
+ };
205
+ }
206
+
207
+ /**
208
+ * Start watching a JSONL file for new entries (legacy API).
209
+ * Delegates to subscribe() internally. Callers that need to
210
+ * unsubscribe should use subscribe() directly instead.
211
+ */
212
+ watch(filePath: string, onNewEntries: OnNewEntries): void {
213
+ this.subscribe(filePath, onNewEntries);
214
+ }
215
+
216
+ /**
217
+ * Read bytes appended since last offset, parse new JSONL lines,
218
+ * and invoke the callback with any new messages.
219
+ */
220
+ private readNewEntries(watched: WatchedFile): void {
221
+ const { filePath } = watched;
222
+
223
+ let stat: fs.Stats;
224
+ try {
225
+ stat = fs.statSync(filePath);
226
+ } catch {
227
+ return;
228
+ }
229
+
230
+ const currentSize = stat.size;
231
+ if (currentSize <= watched.offset) {
232
+ // File truncated or unchanged — reset offset if truncated
233
+ if (currentSize < watched.offset) {
234
+ console.warn(`[session-watcher] File truncated, resetting offset: ${filePath}`);
235
+ watched.offset = 0;
236
+ this.assistantTurnsPerFile.set(filePath, new Map());
237
+ this.messageIndexPerFile.set(filePath, 0);
238
+ this.lineBufferPerFile.set(filePath, '');
239
+ }
240
+ return;
241
+ }
242
+
243
+ // Read only the new bytes
244
+ const fd = fs.openSync(filePath, 'r');
245
+ try {
246
+ const bytesToRead = currentSize - watched.offset;
247
+ const buffer = Buffer.alloc(bytesToRead);
248
+ fs.readSync(fd, buffer, 0, bytesToRead, watched.offset);
249
+ watched.offset = currentSize;
250
+
251
+ const chunk = buffer.toString('utf-8');
252
+ // Prepend any buffered incomplete line from previous read
253
+ const previousBuffer = this.lineBufferPerFile.get(filePath) || '';
254
+ const combined = previousBuffer + chunk;
255
+
256
+ // Split on newlines. If the chunk does not end with a newline,
257
+ // the last segment is an incomplete line — buffer it for next time.
258
+ const segments = combined.split('\n');
259
+ if (!combined.endsWith('\n')) {
260
+ this.lineBufferPerFile.set(filePath, segments.pop() || '');
261
+ } else {
262
+ this.lineBufferPerFile.set(filePath, '');
263
+ // Remove trailing empty segment from the final newline
264
+ if (segments.length > 0 && segments[segments.length - 1] === '') {
265
+ segments.pop();
266
+ }
267
+ }
268
+
269
+ const completeLines = segments.filter((line) => line.trim());
270
+ if (completeLines.length === 0) {
271
+ return;
272
+ }
273
+
274
+ // Parse the new lines using the file's accumulated assistant turn state
275
+ const assistantTurns = this.assistantTurnsPerFile.get(filePath) || new Map();
276
+ const startIndex = this.messageIndexPerFile.get(filePath) || 0;
277
+ const newText = completeLines.join('\n');
278
+ const newMessages = parseJsonlText(newText, assistantTurns, startIndex);
279
+
280
+ // Update the message index counter
281
+ this.messageIndexPerFile.set(filePath, startIndex + completeLines.length);
282
+
283
+ if (newMessages.length > 0) {
284
+ for (const cb of watched.callbacks) {
285
+ try {
286
+ cb(newMessages);
287
+ } catch (cbError) {
288
+ console.error('[session-watcher] Callback error:', cbError);
289
+ }
290
+ }
291
+ }
292
+ } finally {
293
+ fs.closeSync(fd);
294
+ }
295
+ }
296
+ }
297
+
298
+ /**
299
+ * Compute the JSONL file path for a Claude Code session.
300
+ * ~/.claude/projects/{encoded-cwd}/{sessionId}.jsonl
301
+ */
302
+ export function sessionFilePath(cwd: string, sessionId: string): string {
303
+ const encoded = encodeCwd(cwd);
304
+ return path.join(CLAUDE_PROJECTS_DIR, encoded, `${sessionId}.jsonl`);
305
+ }
306
+
307
+ /**
308
+ * Encode a working directory path the way Claude Code does:
309
+ * replace `/` with `-` so `/Users/me/project` becomes `-Users-me-project`.
310
+ */
311
+ function encodeCwd(cwd: string): string {
312
+ return cwd.replace(/\//g, '-');
313
+ }
314
+
315
+ // Use globalThis to ensure a single shared instance across module loaders.
316
+ const SW_GLOBAL_KEY = '__shooter_session_watcher';
317
+ export const sessionWatcher: SessionWatcher =
318
+ ((globalThis as Record<string, unknown>)[SW_GLOBAL_KEY] as SessionWatcher) ||
319
+ new SessionWatcher();
320
+ (globalThis as Record<string, unknown>)[SW_GLOBAL_KEY] = sessionWatcher;
@@ -0,0 +1,198 @@
1
+ /**
2
+ * Terminal Store — SQLite persistence for terminal metadata.
3
+ *
4
+ * Persists TerminalRecord rows so PtyManager can recover running terminals
5
+ * after a server restart. Uses better-sqlite3 with WAL journal mode.
6
+ *
7
+ * Database location: ~/.shooter/shooter.db
8
+ */
9
+
10
+ import type { TerminalRecord } from '$generated/types';
11
+
12
+ import Database from 'better-sqlite3';
13
+ import * as fs from 'fs';
14
+ import * as path from 'path';
15
+
16
+ // ── Constants ────────────────────────────────────────────────────────
17
+
18
+ const DB_DIR = path.join(process.env.HOME || '', '.shooter');
19
+ const DB_PATH = path.join(DB_DIR, 'shooter.db');
20
+
21
+ // ── Snake/Camel Conversion ───────────────────────────────────────────
22
+
23
+ /** Column order used by INSERT. */
24
+ const COLUMNS = [
25
+ 'id',
26
+ 'command',
27
+ 'args',
28
+ 'cwd',
29
+ 'cols',
30
+ 'rows',
31
+ 'pid',
32
+ 'holder_pid',
33
+ 'socket_path',
34
+ 'session_file',
35
+ 'opencode_session_id',
36
+ 'status',
37
+ 'exit_code',
38
+ 'created_at',
39
+ 'exited_at'
40
+ ] as const;
41
+
42
+ /** Map a snake_case DB row to a camelCase TerminalRecord. */
43
+ function rowToRecord(row: Record<string, unknown>): TerminalRecord {
44
+ return {
45
+ args: row.args as string,
46
+ cols: row.cols as number,
47
+ command: row.command as string,
48
+ createdAt: row.created_at as string,
49
+ cwd: row.cwd as string,
50
+ exitCode: (row.exit_code as number) ?? null,
51
+ exitedAt: (row.exited_at as string) ?? null,
52
+ holderPid: (row.holder_pid as number) ?? null,
53
+ id: row.id as string,
54
+ opencodeSessionId: (row.opencode_session_id as string) ?? null,
55
+ pid: (row.pid as number) ?? null,
56
+ rows: row.rows as number,
57
+ sessionFile: (row.session_file as string) ?? null,
58
+ socketPath: (row.socket_path as string) ?? null,
59
+ status: row.status as TerminalRecord['status']
60
+ };
61
+ }
62
+
63
+ /** Map camelCase field name to snake_case column name. */
64
+ const CAMEL_TO_SNAKE: Record<string, string> = {
65
+ createdAt: 'created_at',
66
+ exitCode: 'exit_code',
67
+ exitedAt: 'exited_at',
68
+ holderPid: 'holder_pid',
69
+ opencodeSessionId: 'opencode_session_id',
70
+ sessionFile: 'session_file',
71
+ socketPath: 'socket_path'
72
+ };
73
+
74
+ export class TerminalStore {
75
+ private db: Database.Database;
76
+
77
+ constructor() {
78
+ fs.mkdirSync(DB_DIR, { recursive: true });
79
+
80
+ this.db = new Database(DB_PATH);
81
+ this.db.pragma('journal_mode = WAL');
82
+
83
+ this.db.exec(`
84
+ CREATE TABLE IF NOT EXISTS terminals (
85
+ id TEXT PRIMARY KEY,
86
+ command TEXT NOT NULL,
87
+ args TEXT NOT NULL DEFAULT '[]',
88
+ cwd TEXT NOT NULL,
89
+ cols INTEGER NOT NULL DEFAULT 80,
90
+ rows INTEGER NOT NULL DEFAULT 24,
91
+ pid INTEGER,
92
+ holder_pid INTEGER,
93
+ socket_path TEXT,
94
+ session_file TEXT,
95
+ opencode_session_id TEXT,
96
+ status TEXT NOT NULL DEFAULT 'running',
97
+ exit_code INTEGER,
98
+ created_at TEXT NOT NULL,
99
+ exited_at TEXT
100
+ )
101
+ `);
102
+ }
103
+
104
+ deleteOlderThan(ms: number): number {
105
+ const cutoff = new Date(Date.now() - ms).toISOString();
106
+ const result = this.db
107
+ .prepare(
108
+ "DELETE FROM terminals WHERE status IN ('exited', 'orphaned') AND COALESCE(exited_at, created_at) < ?"
109
+ )
110
+ .run(cutoff);
111
+ return result.changes;
112
+ }
113
+
114
+ get(id: string): null | TerminalRecord {
115
+ const row = this.db
116
+ .prepare('SELECT * FROM terminals WHERE id = ?')
117
+ .get(id) as Record<string, unknown> | undefined;
118
+ return row ? rowToRecord(row) : null;
119
+ }
120
+
121
+ insert(terminal: TerminalRecord): void {
122
+ const placeholders = COLUMNS.map(() => '?').join(', ');
123
+ const stmt = this.db.prepare(
124
+ `INSERT INTO terminals (${COLUMNS.join(', ')}) VALUES (${placeholders})`
125
+ );
126
+ stmt.run(
127
+ terminal.id,
128
+ terminal.command,
129
+ terminal.args,
130
+ terminal.cwd,
131
+ terminal.cols,
132
+ terminal.rows,
133
+ terminal.pid,
134
+ terminal.holderPid,
135
+ terminal.socketPath,
136
+ terminal.sessionFile,
137
+ terminal.opencodeSessionId,
138
+ terminal.status,
139
+ terminal.exitCode,
140
+ terminal.createdAt,
141
+ terminal.exitedAt
142
+ );
143
+ }
144
+
145
+ listAll(): TerminalRecord[] {
146
+ const rows = this.db
147
+ .prepare('SELECT * FROM terminals ORDER BY created_at DESC')
148
+ .all() as Record<string, unknown>[];
149
+ return rows.map(rowToRecord);
150
+ }
151
+
152
+ listRunning(): TerminalRecord[] {
153
+ const rows = this.db
154
+ .prepare("SELECT * FROM terminals WHERE status = 'running' ORDER BY created_at DESC")
155
+ .all() as Record<string, unknown>[];
156
+ return rows.map(rowToRecord);
157
+ }
158
+
159
+ markExited(id: string, exitCode: null | number): void {
160
+ this.db
161
+ .prepare(
162
+ "UPDATE terminals SET status = 'exited', exit_code = ?, exited_at = ? WHERE id = ?"
163
+ )
164
+ .run(exitCode, new Date().toISOString(), id);
165
+ }
166
+
167
+ markOrphaned(id: string): void {
168
+ this.db
169
+ .prepare("UPDATE terminals SET status = 'orphaned' WHERE id = ?")
170
+ .run(id);
171
+ }
172
+
173
+ update(id: string, fields: Partial<TerminalRecord>): void {
174
+ const entries = Object.entries(fields).filter(([key]) => key !== 'id');
175
+ if (entries.length === 0) {return;}
176
+
177
+ const sets = entries.map(([key]) => `${toSnake(key)} = ?`).join(', ');
178
+ const values = entries.map(([, val]) => val ?? null);
179
+
180
+ this.db.prepare(`UPDATE terminals SET ${sets} WHERE id = ?`).run(...values, id);
181
+ }
182
+ }
183
+
184
+ // ── TerminalStore Class ──────────────────────────────────────────────
185
+
186
+ function toSnake(key: string): string {
187
+ return CAMEL_TO_SNAKE[key] || key;
188
+ }
189
+
190
+ // ── Singleton ────────────────────────────────────────────────────────
191
+ // Use globalThis to ensure a single shared instance across module
192
+ // loaders (same pattern as pty-manager, session-watcher, opencode-watcher).
193
+
194
+ const TS_GLOBAL_KEY = '__shooter_terminal_store';
195
+ export const terminalStore: TerminalStore =
196
+ ((globalThis as Record<string, unknown>)[TS_GLOBAL_KEY] as TerminalStore) ||
197
+ new TerminalStore();
198
+ (globalThis as Record<string, unknown>)[TS_GLOBAL_KEY] = terminalStore;
@@ -0,0 +1,73 @@
1
+ // WebSocket events handler — broadcast-only global event bus.
2
+ // Manages the /ws/events channel: tracks connected clients and broadcasts
3
+ // ShooterEvents to all listeners. No client-to-server messages are expected.
4
+
5
+ import type { WebSocket } from 'ws';
6
+
7
+ // ── Event types ─────────────────────────────────────────────────────
8
+
9
+ export type ShooterEvent =
10
+ | { code: null | number; terminalId: string; type: 'terminal-exited'; }
11
+ | { command: string; terminalId: string; type: 'terminal-created'; }
12
+ | { decision: 'allow' | 'deny'; requestId: string; type: 'permission-resolved'; }
13
+ | { input: Record<string, unknown>; requestId: string; tool: string; type: 'permission-requested'; }
14
+ | { project: string; sessionId: string; source: 'claude-code' | 'opencode'; type: 'session-started'; }
15
+ | { sessionId: string; summary: string; type: 'session-ended'; };
16
+
17
+ // ── Connection tracking ─────────────────────────────────────────────
18
+
19
+ /** All clients subscribed to the global events channel. */
20
+ const EVENTS_KEY = '__shooter_ws_events_clients';
21
+ const eventsClients: Set<WebSocket> =
22
+ ((globalThis as Record<string, unknown>)[EVENTS_KEY] as Set<WebSocket>) || new Set<WebSocket>();
23
+ (globalThis as Record<string, unknown>)[EVENTS_KEY] = eventsClients;
24
+
25
+ // ── Handlers ────────────────────────────────────────────────────────
26
+
27
+ /**
28
+ * Broadcast a ShooterEvent to every connected events client.
29
+ * Silently skips clients that are not in the OPEN ready state.
30
+ */
31
+ export function broadcastEvent(event: ShooterEvent): void {
32
+ const data = JSON.stringify({
33
+ ...event,
34
+ timestamp: new Date().toISOString(),
35
+ });
36
+
37
+ for (const ws of eventsClients) {
38
+ // WebSocket.OPEN === 1
39
+ if (ws.readyState === 1) {
40
+ ws.send(data);
41
+ }
42
+ }
43
+ }
44
+
45
+ /**
46
+ * Returns the number of clients currently connected to the events channel.
47
+ * Used by the notifier to decide between WebSocket broadcast vs APNs push.
48
+ */
49
+ export function getEventsClientCount(): number {
50
+ return eventsClients.size;
51
+ }
52
+
53
+ /**
54
+ * Handle a new WebSocket connection on /ws/events.
55
+ * Adds the client to the events set, sends a welcome message,
56
+ * and registers cleanup on close.
57
+ */
58
+ export function handleEventsConnection(ws: WebSocket): void {
59
+ eventsClients.add(ws);
60
+
61
+ ws.send(
62
+ JSON.stringify({
63
+ channel: 'events',
64
+ clients: eventsClients.size,
65
+ timestamp: new Date().toISOString(),
66
+ type: 'welcome',
67
+ })
68
+ );
69
+
70
+ ws.on('close', () => {
71
+ eventsClients.delete(ws);
72
+ });
73
+ }
@@ -0,0 +1,108 @@
1
+ // WebSocket keepalive module.
2
+ // Sends protocol-level ping frames every 30 seconds to all tracked connections.
3
+ // If a client does not respond with a pong within 10 seconds, the connection is
4
+ // terminated and cleaned up.
5
+ //
6
+ // Cloudflare Tunnel closes idle WebSocket connections after ~100 seconds,
7
+ // so 30-second pings keep connections alive through the tunnel.
8
+
9
+ import type { WebSocket } from 'ws';
10
+
11
+ import { getAllConnections } from './server.js';
12
+
13
+ // ── Configuration ────────────────────────────────────────────────────
14
+
15
+ const PING_INTERVAL_MS = 30_000;
16
+ const PONG_TIMEOUT_MS = 10_000;
17
+
18
+ // ── Internal state ───────────────────────────────────────────────────
19
+
20
+ let pingInterval: null | ReturnType<typeof setInterval> = null;
21
+
22
+ /**
23
+ * Track which connections have an outstanding pong we are waiting for.
24
+ * Maps a WebSocket to its pong-timeout timer.
25
+ */
26
+ const pendingPongs = new Map<WebSocket, ReturnType<typeof setTimeout>>();
27
+
28
+ // ── Public API ───────────────────────────────────────────────────────
29
+
30
+ /**
31
+ * Start the keepalive ping loop.
32
+ * Safe to call multiple times — subsequent calls are no-ops until `stopKeepalive()`.
33
+ */
34
+ export function startKeepalive(): void {
35
+ if (pingInterval) {
36
+ return;
37
+ }
38
+
39
+ pingInterval = setInterval(() => {
40
+ const connections = getAllConnections();
41
+
42
+ for (const ws of connections) {
43
+ if (ws.readyState !== 1) {
44
+ // Not OPEN — skip (will be cleaned up by close handler)
45
+ continue;
46
+ }
47
+
48
+ // If there is already a pending pong timer for this connection,
49
+ // the previous ping was never answered. This should not happen
50
+ // under normal operation because the timeout fires in 10s and
51
+ // the interval is 30s, but guard against it anyway.
52
+ if (pendingPongs.has(ws)) {
53
+ continue;
54
+ }
55
+
56
+ // Set up a timer that fires if no pong arrives within the timeout.
57
+ const timeout = setTimeout(() => {
58
+ cleanup();
59
+ // No pong received — consider the connection dead.
60
+ ws.terminate();
61
+ }, PONG_TIMEOUT_MS);
62
+
63
+ pendingPongs.set(ws, timeout);
64
+
65
+ // Shared cleanup: clear the timer and remove both listeners
66
+ // so that close listeners do not accumulate across ping cycles.
67
+ function cleanup() {
68
+ ws.removeListener('pong', onPong);
69
+ ws.removeListener('close', onClose);
70
+ const timer = pendingPongs.get(ws);
71
+ if (timer) {
72
+ clearTimeout(timer);
73
+ }
74
+ pendingPongs.delete(ws);
75
+ }
76
+
77
+ const onPong = () => {
78
+ cleanup();
79
+ };
80
+
81
+ const onClose = () => {
82
+ cleanup();
83
+ };
84
+
85
+ ws.once('pong', onPong);
86
+ ws.once('close', onClose);
87
+
88
+ // Send the protocol-level ping frame.
89
+ ws.ping();
90
+ }
91
+ }, PING_INTERVAL_MS);
92
+ }
93
+
94
+ /**
95
+ * Stop the keepalive ping loop and clear all pending pong timers.
96
+ */
97
+ export function stopKeepalive(): void {
98
+ if (pingInterval) {
99
+ clearInterval(pingInterval);
100
+ pingInterval = null;
101
+ }
102
+
103
+ // Clear any outstanding pong timers.
104
+ for (const [, timer] of pendingPongs) {
105
+ clearTimeout(timer);
106
+ }
107
+ pendingPongs.clear();
108
+ }