@honeybbq/teamspeak 0.0.1 → 1.0.0-beta.7

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 (393) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +726 -2
  3. package/dist/client/abort.d.ts +5 -0
  4. package/dist/client/abort.d.ts.map +1 -0
  5. package/dist/client/abort.js +23 -0
  6. package/dist/client/abort.js.map +1 -0
  7. package/dist/client/address.d.ts +17 -0
  8. package/dist/client/address.d.ts.map +1 -0
  9. package/dist/client/address.js +66 -0
  10. package/dist/client/address.js.map +1 -0
  11. package/dist/client/api/voice.d.ts +24 -0
  12. package/dist/client/api/voice.d.ts.map +1 -0
  13. package/dist/client/api/voice.js +33 -0
  14. package/dist/client/api/voice.js.map +1 -0
  15. package/dist/client/capabilities.d.ts +68 -0
  16. package/dist/client/capabilities.d.ts.map +1 -0
  17. package/dist/client/capabilities.js +59 -0
  18. package/dist/client/capabilities.js.map +1 -0
  19. package/dist/client/client.d.ts +196 -0
  20. package/dist/client/client.d.ts.map +1 -0
  21. package/dist/client/client.js +1523 -0
  22. package/dist/client/client.js.map +1 -0
  23. package/dist/client/commands.d.ts +66 -0
  24. package/dist/client/commands.d.ts.map +1 -0
  25. package/dist/client/commands.js +162 -0
  26. package/dist/client/commands.js.map +1 -0
  27. package/dist/client/crypto/crypt-ops.d.ts +7 -0
  28. package/dist/client/crypto/crypt-ops.d.ts.map +1 -0
  29. package/dist/client/crypto/crypt-ops.js +2 -0
  30. package/dist/client/crypto/crypt-ops.js.map +1 -0
  31. package/dist/client/crypto/crypt.d.ts +37 -0
  32. package/dist/client/crypto/crypt.d.ts.map +1 -0
  33. package/dist/client/crypto/crypt.js +203 -0
  34. package/dist/client/crypto/crypt.js.map +1 -0
  35. package/dist/client/crypto/eax.d.ts +12 -0
  36. package/dist/client/crypto/eax.d.ts.map +1 -0
  37. package/dist/client/crypto/eax.js +118 -0
  38. package/dist/client/crypto/eax.js.map +1 -0
  39. package/dist/client/crypto/identity.d.ts +97 -0
  40. package/dist/client/crypto/identity.d.ts.map +1 -0
  41. package/dist/client/crypto/identity.js +412 -0
  42. package/dist/client/crypto/identity.js.map +1 -0
  43. package/dist/client/crypto/index.d.ts +7 -0
  44. package/dist/client/crypto/index.d.ts.map +1 -0
  45. package/dist/client/crypto/index.js +5 -0
  46. package/dist/client/crypto/index.js.map +1 -0
  47. package/dist/client/crypto/primitives.d.ts +43 -0
  48. package/dist/client/crypto/primitives.d.ts.map +1 -0
  49. package/dist/client/crypto/primitives.js +116 -0
  50. package/dist/client/crypto/primitives.js.map +1 -0
  51. package/dist/client/diagnostics.d.ts +45 -0
  52. package/dist/client/diagnostics.d.ts.map +1 -0
  53. package/dist/client/diagnostics.js +30 -0
  54. package/dist/client/diagnostics.js.map +1 -0
  55. package/dist/client/discovery/index.d.ts +2 -0
  56. package/dist/client/discovery/index.d.ts.map +1 -0
  57. package/dist/client/discovery/index.js +2 -0
  58. package/dist/client/discovery/index.js.map +1 -0
  59. package/dist/client/discovery/resolver.d.ts +26 -0
  60. package/dist/client/discovery/resolver.d.ts.map +1 -0
  61. package/dist/client/discovery/resolver.js +275 -0
  62. package/dist/client/discovery/resolver.js.map +1 -0
  63. package/dist/client/errors.d.ts +82 -0
  64. package/dist/client/errors.d.ts.map +1 -0
  65. package/dist/client/errors.js +201 -0
  66. package/dist/client/errors.js.map +1 -0
  67. package/dist/client/events.d.ts +3 -0
  68. package/dist/client/events.d.ts.map +1 -0
  69. package/dist/client/events.js +8 -0
  70. package/dist/client/events.js.map +1 -0
  71. package/dist/client/handshake/crypt-handshake.d.ts +17 -0
  72. package/dist/client/handshake/crypt-handshake.d.ts.map +1 -0
  73. package/dist/client/handshake/crypt-handshake.js +128 -0
  74. package/dist/client/handshake/crypt-handshake.js.map +1 -0
  75. package/dist/client/handshake/crypt-init2.d.ts +7 -0
  76. package/dist/client/handshake/crypt-init2.d.ts.map +1 -0
  77. package/dist/client/handshake/crypt-init2.js +37 -0
  78. package/dist/client/handshake/crypt-init2.js.map +1 -0
  79. package/dist/client/handshake/index.d.ts +4 -0
  80. package/dist/client/handshake/index.d.ts.map +1 -0
  81. package/dist/client/handshake/index.js +4 -0
  82. package/dist/client/handshake/index.js.map +1 -0
  83. package/dist/client/handshake/license.d.ts +35 -0
  84. package/dist/client/handshake/license.d.ts.map +1 -0
  85. package/dist/client/handshake/license.js +169 -0
  86. package/dist/client/handshake/license.js.map +1 -0
  87. package/dist/client/handshake.d.ts +27 -0
  88. package/dist/client/handshake.d.ts.map +1 -0
  89. package/dist/client/handshake.js +126 -0
  90. package/dist/client/handshake.js.map +1 -0
  91. package/dist/client/helpers.d.ts +6 -0
  92. package/dist/client/helpers.d.ts.map +1 -0
  93. package/dist/client/helpers.js +13 -0
  94. package/dist/client/helpers.js.map +1 -0
  95. package/dist/client/hooks.d.ts +7 -0
  96. package/dist/client/hooks.d.ts.map +1 -0
  97. package/dist/client/hooks.js +28 -0
  98. package/dist/client/hooks.js.map +1 -0
  99. package/dist/client/index.d.ts +26 -0
  100. package/dist/client/index.d.ts.map +1 -0
  101. package/dist/client/index.js +28 -0
  102. package/dist/client/index.js.map +1 -0
  103. package/dist/client/reconnect.d.ts +12 -0
  104. package/dist/client/reconnect.d.ts.map +1 -0
  105. package/dist/client/reconnect.js +44 -0
  106. package/dist/client/reconnect.js.map +1 -0
  107. package/dist/client/throttle.d.ts +20 -0
  108. package/dist/client/throttle.d.ts.map +1 -0
  109. package/dist/client/throttle.js +83 -0
  110. package/dist/client/throttle.js.map +1 -0
  111. package/dist/client/trace.d.ts +223 -0
  112. package/dist/client/trace.d.ts.map +1 -0
  113. package/dist/client/trace.js +13 -0
  114. package/dist/client/trace.js.map +1 -0
  115. package/dist/client/transfer.d.ts +111 -0
  116. package/dist/client/transfer.d.ts.map +1 -0
  117. package/dist/client/transfer.js +243 -0
  118. package/dist/client/transfer.js.map +1 -0
  119. package/dist/client/transport/generation-window.d.ts +30 -0
  120. package/dist/client/transport/generation-window.d.ts.map +1 -0
  121. package/dist/client/transport/generation-window.js +97 -0
  122. package/dist/client/transport/generation-window.js.map +1 -0
  123. package/dist/client/transport/handler.d.ts +61 -0
  124. package/dist/client/transport/handler.d.ts.map +1 -0
  125. package/dist/client/transport/handler.js +793 -0
  126. package/dist/client/transport/handler.js.map +1 -0
  127. package/dist/client/transport/index.d.ts +6 -0
  128. package/dist/client/transport/index.d.ts.map +1 -0
  129. package/dist/client/transport/index.js +5 -0
  130. package/dist/client/transport/index.js.map +1 -0
  131. package/dist/client/transport/packet.d.ts +36 -0
  132. package/dist/client/transport/packet.d.ts.map +1 -0
  133. package/dist/client/transport/packet.js +55 -0
  134. package/dist/client/transport/packet.js.map +1 -0
  135. package/dist/client/transport/quicklz.d.ts +24 -0
  136. package/dist/client/transport/quicklz.d.ts.map +1 -0
  137. package/dist/client/transport/quicklz.js +179 -0
  138. package/dist/client/transport/quicklz.js.map +1 -0
  139. package/dist/client/types.d.ts +226 -0
  140. package/dist/client/types.d.ts.map +1 -0
  141. package/dist/client/types.js +39 -0
  142. package/dist/client/types.js.map +1 -0
  143. package/dist/core/api/channels.d.ts +182 -0
  144. package/dist/core/api/channels.d.ts.map +1 -0
  145. package/dist/core/api/channels.js +155 -0
  146. package/dist/core/api/channels.js.map +1 -0
  147. package/dist/core/api/clients.d.ts +304 -0
  148. package/dist/core/api/clients.d.ts.map +1 -0
  149. package/dist/core/api/clients.js +260 -0
  150. package/dist/core/api/clients.js.map +1 -0
  151. package/dist/core/api/command.d.ts +305 -0
  152. package/dist/core/api/command.d.ts.map +1 -0
  153. package/dist/core/api/command.js +363 -0
  154. package/dist/core/api/command.js.map +1 -0
  155. package/dist/core/api/files.d.ts +153 -0
  156. package/dist/core/api/files.d.ts.map +1 -0
  157. package/dist/core/api/files.js +181 -0
  158. package/dist/core/api/files.js.map +1 -0
  159. package/dist/core/api/groups.d.ts +248 -0
  160. package/dist/core/api/groups.d.ts.map +1 -0
  161. package/dist/core/api/groups.js +271 -0
  162. package/dist/core/api/groups.js.map +1 -0
  163. package/dist/core/api/moderation.d.ts +205 -0
  164. package/dist/core/api/moderation.d.ts.map +1 -0
  165. package/dist/core/api/moderation.js +209 -0
  166. package/dist/core/api/moderation.js.map +1 -0
  167. package/dist/core/api/parse.d.ts +16 -0
  168. package/dist/core/api/parse.d.ts.map +1 -0
  169. package/dist/core/api/parse.js +40 -0
  170. package/dist/core/api/parse.js.map +1 -0
  171. package/dist/core/api/server.d.ts +310 -0
  172. package/dist/core/api/server.d.ts.map +1 -0
  173. package/dist/core/api/server.js +290 -0
  174. package/dist/core/api/server.js.map +1 -0
  175. package/dist/core/command/command.d.ts +20 -0
  176. package/dist/core/command/command.d.ts.map +1 -0
  177. package/dist/core/command/command.js +112 -0
  178. package/dist/core/command/command.js.map +1 -0
  179. package/dist/core/command/ids.d.ts +125 -0
  180. package/dist/core/command/ids.d.ts.map +1 -0
  181. package/dist/core/command/ids.js +85 -0
  182. package/dist/core/command/ids.js.map +1 -0
  183. package/dist/core/command/parser.d.ts +17 -0
  184. package/dist/core/command/parser.d.ts.map +1 -0
  185. package/dist/core/command/parser.js +65 -0
  186. package/dist/core/command/parser.js.map +1 -0
  187. package/dist/core/command/request.d.ts +61 -0
  188. package/dist/core/command/request.d.ts.map +1 -0
  189. package/dist/core/command/request.js +64 -0
  190. package/dist/core/command/request.js.map +1 -0
  191. package/dist/core/command/responses.d.ts +59 -0
  192. package/dist/core/command/responses.d.ts.map +1 -0
  193. package/dist/core/command/responses.js +74 -0
  194. package/dist/core/command/responses.js.map +1 -0
  195. package/dist/core/command-name.d.ts +333 -0
  196. package/dist/core/command-name.d.ts.map +1 -0
  197. package/dist/core/command-name.js +331 -0
  198. package/dist/core/command-name.js.map +1 -0
  199. package/dist/core/enums.d.ts +146 -0
  200. package/dist/core/enums.d.ts.map +1 -0
  201. package/dist/core/enums.js +132 -0
  202. package/dist/core/enums.js.map +1 -0
  203. package/dist/core/errorcode.d.ts +413 -0
  204. package/dist/core/errorcode.d.ts.map +1 -0
  205. package/dist/core/errorcode.js +410 -0
  206. package/dist/core/errorcode.js.map +1 -0
  207. package/dist/core/errors.d.ts +111 -0
  208. package/dist/core/errors.d.ts.map +1 -0
  209. package/dist/core/errors.js +233 -0
  210. package/dist/core/errors.js.map +1 -0
  211. package/dist/core/events.d.ts +207 -0
  212. package/dist/core/events.d.ts.map +1 -0
  213. package/dist/core/events.js +6 -0
  214. package/dist/core/events.js.map +1 -0
  215. package/dist/core/helpers.d.ts +42 -0
  216. package/dist/core/helpers.d.ts.map +1 -0
  217. package/dist/core/helpers.js +156 -0
  218. package/dist/core/helpers.js.map +1 -0
  219. package/dist/core/index.d.ts +35 -0
  220. package/dist/core/index.d.ts.map +1 -0
  221. package/dist/core/index.js +34 -0
  222. package/dist/core/index.js.map +1 -0
  223. package/dist/core/internal.d.ts +23 -0
  224. package/dist/core/internal.d.ts.map +1 -0
  225. package/dist/core/internal.js +20 -0
  226. package/dist/core/internal.js.map +1 -0
  227. package/dist/core/middleware.d.ts +14 -0
  228. package/dist/core/middleware.d.ts.map +1 -0
  229. package/dist/core/middleware.js +18 -0
  230. package/dist/core/middleware.js.map +1 -0
  231. package/dist/core/notification-name.d.ts +154 -0
  232. package/dist/core/notification-name.d.ts.map +1 -0
  233. package/dist/core/notification-name.js +152 -0
  234. package/dist/core/notification-name.js.map +1 -0
  235. package/dist/core/notifications.d.ts +43 -0
  236. package/dist/core/notifications.d.ts.map +1 -0
  237. package/dist/core/notifications.js +116 -0
  238. package/dist/core/notifications.js.map +1 -0
  239. package/dist/core/permission-name.d.ts +533 -0
  240. package/dist/core/permission-name.d.ts.map +1 -0
  241. package/dist/core/permission-name.js +531 -0
  242. package/dist/core/permission-name.js.map +1 -0
  243. package/dist/core/permission.d.ts +31 -0
  244. package/dist/core/permission.d.ts.map +1 -0
  245. package/dist/core/permission.js +20 -0
  246. package/dist/core/permission.js.map +1 -0
  247. package/dist/core/property-name.d.ts +403 -0
  248. package/dist/core/property-name.d.ts.map +1 -0
  249. package/dist/core/property-name.js +405 -0
  250. package/dist/core/property-name.js.map +1 -0
  251. package/dist/core/reason.d.ts +39 -0
  252. package/dist/core/reason.d.ts.map +1 -0
  253. package/dist/core/reason.js +36 -0
  254. package/dist/core/reason.js.map +1 -0
  255. package/dist/core/reconnect.d.ts +59 -0
  256. package/dist/core/reconnect.d.ts.map +1 -0
  257. package/dist/core/reconnect.js +55 -0
  258. package/dist/core/reconnect.js.map +1 -0
  259. package/dist/core/types.d.ts +110 -0
  260. package/dist/core/types.d.ts.map +1 -0
  261. package/dist/core/types.js +27 -0
  262. package/dist/core/types.js.map +1 -0
  263. package/dist/query/client.d.ts +194 -0
  264. package/dist/query/client.d.ts.map +1 -0
  265. package/dist/query/client.js +967 -0
  266. package/dist/query/client.js.map +1 -0
  267. package/dist/query/errors.d.ts +61 -0
  268. package/dist/query/errors.d.ts.map +1 -0
  269. package/dist/query/errors.js +100 -0
  270. package/dist/query/errors.js.map +1 -0
  271. package/dist/query/index.d.ts +36 -0
  272. package/dist/query/index.d.ts.map +1 -0
  273. package/dist/query/index.js +34 -0
  274. package/dist/query/index.js.map +1 -0
  275. package/dist/query/knownhosts.d.ts +61 -0
  276. package/dist/query/knownhosts.d.ts.map +1 -0
  277. package/dist/query/knownhosts.js +223 -0
  278. package/dist/query/knownhosts.js.map +1 -0
  279. package/dist/query/lines.d.ts +14 -0
  280. package/dist/query/lines.d.ts.map +1 -0
  281. package/dist/query/lines.js +29 -0
  282. package/dist/query/lines.js.map +1 -0
  283. package/dist/query/options.d.ts +187 -0
  284. package/dist/query/options.d.ts.map +1 -0
  285. package/dist/query/options.js +213 -0
  286. package/dist/query/options.js.map +1 -0
  287. package/dist/query/reconnect.d.ts +29 -0
  288. package/dist/query/reconnect.d.ts.map +1 -0
  289. package/dist/query/reconnect.js +87 -0
  290. package/dist/query/reconnect.js.map +1 -0
  291. package/dist/query/ssh.d.ts +345 -0
  292. package/dist/query/ssh.d.ts.map +1 -0
  293. package/dist/query/ssh.js +241 -0
  294. package/dist/query/ssh.js.map +1 -0
  295. package/dist/query/stream.d.ts +38 -0
  296. package/dist/query/stream.d.ts.map +1 -0
  297. package/dist/query/stream.js +70 -0
  298. package/dist/query/stream.js.map +1 -0
  299. package/dist/query/tcp.d.ts +38 -0
  300. package/dist/query/tcp.d.ts.map +1 -0
  301. package/dist/query/tcp.js +53 -0
  302. package/dist/query/tcp.js.map +1 -0
  303. package/dist/query/transport.d.ts +36 -0
  304. package/dist/query/transport.d.ts.map +1 -0
  305. package/dist/query/transport.js +2 -0
  306. package/dist/query/transport.js.map +1 -0
  307. package/dist/query/web.d.ts +54 -0
  308. package/dist/query/web.d.ts.map +1 -0
  309. package/dist/query/web.js +146 -0
  310. package/dist/query/web.js.map +1 -0
  311. package/dist/versions/index.d.ts +3094 -0
  312. package/dist/versions/index.d.ts.map +1 -0
  313. package/dist/versions/index.js +9237 -0
  314. package/dist/versions/index.js.map +1 -0
  315. package/package.json +118 -4
  316. package/src/client/abort.ts +24 -0
  317. package/src/client/address.ts +74 -0
  318. package/src/client/api/voice.ts +61 -0
  319. package/src/client/capabilities.ts +106 -0
  320. package/src/client/client.ts +1809 -0
  321. package/src/client/commands.ts +193 -0
  322. package/src/client/crypto/crypt-ops.ts +6 -0
  323. package/src/client/crypto/crypt.ts +275 -0
  324. package/src/client/crypto/eax.ts +152 -0
  325. package/src/client/crypto/identity.ts +484 -0
  326. package/src/client/crypto/index.ts +21 -0
  327. package/src/client/crypto/primitives.ts +141 -0
  328. package/src/client/diagnostics.ts +58 -0
  329. package/src/client/discovery/index.ts +1 -0
  330. package/src/client/discovery/resolver.ts +325 -0
  331. package/src/client/errors.ts +217 -0
  332. package/src/client/events.ts +12 -0
  333. package/src/client/handshake/crypt-handshake.ts +157 -0
  334. package/src/client/handshake/crypt-init2.ts +48 -0
  335. package/src/client/handshake/index.ts +3 -0
  336. package/src/client/handshake/license.ts +208 -0
  337. package/src/client/handshake.ts +150 -0
  338. package/src/client/helpers.ts +10 -0
  339. package/src/client/hooks.ts +30 -0
  340. package/src/client/index.ts +93 -0
  341. package/src/client/reconnect.ts +43 -0
  342. package/src/client/throttle.ts +92 -0
  343. package/src/client/trace.ts +237 -0
  344. package/src/client/transfer.ts +436 -0
  345. package/src/client/transport/generation-window.ts +108 -0
  346. package/src/client/transport/handler.ts +926 -0
  347. package/src/client/transport/index.ts +14 -0
  348. package/src/client/transport/packet.ts +69 -0
  349. package/src/client/transport/quicklz.ts +209 -0
  350. package/src/client/types.ts +268 -0
  351. package/src/core/api/channels.ts +396 -0
  352. package/src/core/api/clients.ts +654 -0
  353. package/src/core/api/command.ts +563 -0
  354. package/src/core/api/files.ts +405 -0
  355. package/src/core/api/groups.ts +622 -0
  356. package/src/core/api/moderation.ts +525 -0
  357. package/src/core/api/parse.ts +52 -0
  358. package/src/core/api/server.ts +709 -0
  359. package/src/core/command/command.ts +125 -0
  360. package/src/core/command/ids.ts +149 -0
  361. package/src/core/command/parser.ts +68 -0
  362. package/src/core/command/request.ts +119 -0
  363. package/src/core/command/responses.ts +114 -0
  364. package/src/core/command-name.ts +334 -0
  365. package/src/core/enums.ts +167 -0
  366. package/src/core/errorcode.ts +412 -0
  367. package/src/core/errors.ts +258 -0
  368. package/src/core/events.ts +219 -0
  369. package/src/core/helpers.ts +161 -0
  370. package/src/core/index.ts +126 -0
  371. package/src/core/internal.ts +23 -0
  372. package/src/core/middleware.ts +24 -0
  373. package/src/core/notification-name.ts +155 -0
  374. package/src/core/notifications.ts +157 -0
  375. package/src/core/permission-name.ts +538 -0
  376. package/src/core/permission.ts +34 -0
  377. package/src/core/property-name.ts +411 -0
  378. package/src/core/reason.ts +38 -0
  379. package/src/core/reconnect.ts +98 -0
  380. package/src/core/types.ts +138 -0
  381. package/src/query/client.ts +1132 -0
  382. package/src/query/errors.ts +108 -0
  383. package/src/query/index.ts +65 -0
  384. package/src/query/knownhosts.ts +257 -0
  385. package/src/query/lines.ts +28 -0
  386. package/src/query/options.ts +388 -0
  387. package/src/query/reconnect.ts +84 -0
  388. package/src/query/ssh.ts +614 -0
  389. package/src/query/stream.ts +91 -0
  390. package/src/query/tcp.ts +85 -0
  391. package/src/query/transport.ts +35 -0
  392. package/src/query/web.ts +195 -0
  393. package/src/versions/index.ts +10774 -0
@@ -0,0 +1,1132 @@
1
+ import {
2
+ AlreadyConnectedError,
3
+ ClientStatus,
4
+ ClientType,
5
+ CommandTimeoutError,
6
+ NotConnectedError,
7
+ Reason,
8
+ ServerError,
9
+ TeamspeakError,
10
+ backoffDelay,
11
+ noopLogger,
12
+ type ClientEnterEvent,
13
+ type CommandExecutor,
14
+ type CommandMiddleware,
15
+ type CommandOptions,
16
+ type CommandRequest,
17
+ type KickedEvent,
18
+ type Listener,
19
+ type Logger,
20
+ type ReconnectOptions,
21
+ type ServerEventMap,
22
+ type ServerFamily,
23
+ type ServerID,
24
+ type SessionEventMap,
25
+ type Unsubscribe,
26
+ } from "@honeybbq/teamspeak";
27
+ import {
28
+ buildCommandChain,
29
+ buildCommandOrdered,
30
+ encodeRequest,
31
+ handleNotification,
32
+ parseCommand,
33
+ parseServerError,
34
+ resolveBackoffPolicy,
35
+ } from "../core/internal.js";
36
+ import type { QueryTransport } from "./transport.js";
37
+ import { connectTcp } from "./tcp.js";
38
+ import { connectSsh } from "./ssh.js";
39
+ import { WebQueryTransport } from "./web.js";
40
+ import {
41
+ assertEventsSupported,
42
+ normalizeQueryOptions,
43
+ type QueryClientOptions,
44
+ type QueryEventCategory,
45
+ } from "./options.js";
46
+ import type { ResolvedQueryOptions, ResolvedTransport } from "./options.js";
47
+ import { defaultShouldRetry } from "./reconnect.js";
48
+
49
+ export type {
50
+ QueryClientOptions,
51
+ QueryEventsOption,
52
+ QueryEventCategory,
53
+ QueryTransportOptions,
54
+ } from "./options.js";
55
+
56
+ /**
57
+ * The events `QueryClient.on()` takes, and what each delivers.
58
+ *
59
+ * The server's own events are the core's, unchanged: ServerQuery and the voice
60
+ * protocol carry the same notifications with the same fields, so a listener
61
+ * written for one client reads the other. `connected` and `disconnected` come
62
+ * from this client's own lifecycle.
63
+ *
64
+ * A notification reaches this client on two layers at once, as it does the
65
+ * voice client: the typed event for it, if it has one, and then `notification`,
66
+ * which carries every notification with its wire name and unescaped parameters.
67
+ * So a typed event added here is additive — the notification it names stays on
68
+ * the catch-all — and none of these keys is removed in a minor release.
69
+ *
70
+ * @experimental Part of the `@honeybbq/teamspeak/query` entry, which may
71
+ * change in a minor release.
72
+ */
73
+ export interface QueryEventMap extends ServerEventMap, SessionEventMap {}
74
+
75
+ /** How long a command waits for its answer unless told otherwise. */
76
+ const DEFAULT_COMMAND_TIMEOUT_MS = 10_000;
77
+
78
+ /** How long each reconnection attempt may take. */
79
+ const RECONNECT_ATTEMPT_TIMEOUT_MS = 30_000;
80
+
81
+ /** The commands `connect()` runs on the session's own path, so a middleware never sees them. */
82
+ interface Subscription {
83
+ readonly listener: (payload: unknown) => void;
84
+ removed: boolean;
85
+ }
86
+
87
+ interface Pending {
88
+ readonly command: string;
89
+ readonly rows: Array<Record<string, string>>;
90
+ resolve(rows: Array<Record<string, string>>): void;
91
+ reject(error: unknown): void;
92
+ }
93
+
94
+ /**
95
+ * A ServerQuery client: runs the typed commands from `@honeybbq/teamspeak` over
96
+ * one of the three ServerQuery transports, and delivers the notifications the
97
+ * server sends as the core's typed events.
98
+ *
99
+ * ServerQuery answers one command at a time, in order: data lines, then an
100
+ * `error` line that ends the answer. So commands are queued and sent one after
101
+ * another, which also keeps the server's flood protection from seeing a burst.
102
+ * Notifications (`notify…`) can arrive at any point and are not part of an
103
+ * answer.
104
+ *
105
+ * The transport is given as a {@link QueryClientOptions.transport} description
106
+ * rather than an open connection, so the client can build it again when it
107
+ * reconnects.
108
+ *
109
+ * @experimental New in 1.x and may change in a minor release: the options, the
110
+ * transport union and the event map are all still settling.
111
+ */
112
+ export class QueryClient implements CommandExecutor, AsyncDisposable {
113
+ #options: ResolvedQueryOptions;
114
+ #logger: Logger;
115
+ #transport: QueryTransport | null = null;
116
+ #status: ClientStatus = ClientStatus.Disconnected;
117
+ #serverFamily: ServerFamily = "unknown";
118
+ #version = "";
119
+
120
+ #pending: Pending | null = null;
121
+ /**
122
+ * Answers still to come for commands that gave up waiting. ServerQuery has
123
+ * no return_code, only order, so each is read and discarded when it arrives
124
+ * rather than taken for the next command's.
125
+ */
126
+ #abandoned = 0;
127
+ #queue: Promise<unknown> = Promise.resolve();
128
+ #closed = true;
129
+ /**
130
+ * Bumped by `disconnect()`. A connection attempt that was in flight compares
131
+ * it after every await, so one that was given up on cannot install itself as
132
+ * the live connection.
133
+ */
134
+ #generation = 0;
135
+
136
+ /**
137
+ * This connection's own client id, learned from the client list after
138
+ * registering, so that a kick of this client is told from a move of another.
139
+ * 0 until it is known — which is the case when no `nickname` option was
140
+ * given: the server names an unnamed query client itself, and matching that
141
+ * guess against the list could name a different client.
142
+ */
143
+ #clid = 0;
144
+ #clients = new Map<number, ClientEnterEvent>();
145
+
146
+ #reconnectOptions: ReconnectOptions | undefined;
147
+ #reconnecting = false;
148
+ #loopAttempt = 0;
149
+ #stopped = false;
150
+ #stopping: Promise<void> | undefined;
151
+ #stopReconnecting: AbortController | undefined;
152
+ /** The last answer of `shouldRetry`, so that one failure asks it once. */
153
+ #retryAnswer:
154
+ | {
155
+ readonly err: Error;
156
+ readonly attempt: number;
157
+ readonly retry: boolean;
158
+ readonly threw: boolean;
159
+ }
160
+ | undefined;
161
+ /**
162
+ * Why the last established session dropped: a reconnect loop whose own
163
+ * attempt connected and then dropped before the loop resumed retries for it.
164
+ */
165
+ #lastDrop: Error | undefined;
166
+ /**
167
+ * Settles once the last `disconnected` has been delivered. A `connect()`
168
+ * waits for it, so the end of one session is never heard during the next.
169
+ */
170
+ #disconnectedDelivered: Promise<void> | undefined;
171
+
172
+ /**
173
+ * Whether the transport delivers notifications, as it reports.
174
+ *
175
+ * For a `tcp`, `ssh` or `web` transport this is the kind's own truth, known
176
+ * when the client is made. A `custom` transport can only say once it has
177
+ * been opened, so until then this is `undefined` and a listener is accepted
178
+ * rather than guessed at; the first connection settles it.
179
+ */
180
+ #notifications: boolean | undefined;
181
+
182
+ #listeners = new Map<keyof QueryEventMap, Subscription[]>();
183
+ #send: CommandExecutor["send"];
184
+
185
+ constructor(options: QueryClientOptions) {
186
+ this.#options = normalizeQueryOptions(options);
187
+ this.#logger = this.#options.logger ?? noopLogger;
188
+ this.#notifications = this.#options.notificationsSupported;
189
+ this.#reconnectOptions = this.#options.reconnect;
190
+ const middlewares: readonly CommandMiddleware[] = this.#options.commandMiddleware ?? [];
191
+ // Built once: the chain is read on every command, and changing it while
192
+ // those run would make the behaviour depend on timing.
193
+ this.#send = buildCommandChain(middlewares, (request, commandOptions = {}) =>
194
+ this.exec(
195
+ encodeRequest(request, (password) => password),
196
+ commandOptions,
197
+ ),
198
+ );
199
+ }
200
+
201
+ /** Where the client is in its connection's life. */
202
+ get status(): ClientStatus {
203
+ return this.#status;
204
+ }
205
+
206
+ /**
207
+ * Which server generation the server is, read from its `version` answer.
208
+ * `"unknown"` before connecting.
209
+ *
210
+ * @experimental Part of the entry, which may change in a minor release.
211
+ */
212
+ get serverFamily(): ServerFamily {
213
+ return this.#serverFamily;
214
+ }
215
+
216
+ /** The `version` string the server answered with; "" before connecting. */
217
+ get serverVersion(): string {
218
+ return this.#version;
219
+ }
220
+
221
+ /**
222
+ * The transport carrying this connection, or `undefined` when there is none.
223
+ *
224
+ * Read-only, and the transport is the interface the client itself talks
225
+ * over — counts of what went out and came in, and which connection it is,
226
+ * can be read from it for metrics. The socket underneath is not exposed:
227
+ * a caller that needs it supplies the seam instead (the `connect` option of
228
+ * a `tcp` transport, or `connection` of an `ssh` one), where it is theirs to
229
+ * keep.
230
+ *
231
+ * @experimental Part of the entry, which may change in a minor release.
232
+ */
233
+ get transport(): QueryTransport | undefined {
234
+ return this.#transport ?? undefined;
235
+ }
236
+
237
+ /**
238
+ * The notification categories this connection registered for: what `events`
239
+ * asked for, and empty when the transport delivers no notifications or the
240
+ * client is not connected.
241
+ */
242
+ get eventCategories(): readonly QueryEventCategory[] {
243
+ return this.status === ClientStatus.Connected && this.#notifications === true
244
+ ? this.#options.eventCategories
245
+ : [];
246
+ }
247
+
248
+ // ---- Connection -----------------------------------------------------------
249
+
250
+ /**
251
+ * Connect and set the session up: open the transport, then `login` (raw TCP
252
+ * only), `use`, `version`, the notification registrations, and one client
253
+ * list.
254
+ *
255
+ * The `login` and `server` options are applied here rather than left to the
256
+ * caller so that a reconnection is the same session as the first connection
257
+ * was.
258
+ *
259
+ * A failure rejects with its cause and leaves the client disconnected; the
260
+ * transport it opened is closed. `signal` bounds only the connecting: once
261
+ * this resolves, the connection lasts until `disconnect()` or a drop.
262
+ * `connect()` does not retry itself; the `reconnect` option retries a
263
+ * connection that dropped after it was established.
264
+ *
265
+ * `connected` is delivered before this resolves, so a listener added after
266
+ * `await client.connect()` has missed it — add the listeners first.
267
+ */
268
+ async connect(options: { readonly signal?: AbortSignal | undefined } = {}): Promise<void> {
269
+ return this.#connect(options, false);
270
+ }
271
+
272
+ async #connect(
273
+ options: { readonly signal?: AbortSignal | undefined },
274
+ reconnect: boolean,
275
+ ): Promise<void> {
276
+ const { signal } = options;
277
+ signal?.throwIfAborted();
278
+ // A disconnect() still tearing down waits to be done: a session built over
279
+ // one that is still closing would have its transport closed under it.
280
+ if (this.#stopping !== undefined) await this.#stopping;
281
+ // And so does an earlier session's `disconnected`, so that no listener or
282
+ // events() iterator of this session hears the last one end.
283
+ while (this.#disconnectedDelivered !== undefined) {
284
+ await this.#disconnectedDelivered;
285
+ signal?.throwIfAborted();
286
+ }
287
+ if (this.#status !== ClientStatus.Disconnected) throw new AlreadyConnectedError();
288
+
289
+ this.#stopped = false;
290
+ this.#generation++;
291
+ const generation = this.#generation;
292
+ this.#status = ClientStatus.Connecting;
293
+ try {
294
+ await this.#establish(signal, generation);
295
+ } catch (error) {
296
+ this.#failConnection(error);
297
+ throw error;
298
+ }
299
+ if (generation !== this.#generation) throw new NotConnectedError();
300
+ this.#status = ClientStatus.Connected;
301
+ this.#emit("connected", { reconnected: reconnect });
302
+ }
303
+
304
+ /**
305
+ * One connection attempt, from opening the transport to having a usable
306
+ * session. `connect()` and the reconnect loop share it, so a reconnection is
307
+ * the same session the first connection was.
308
+ */
309
+ async #establish(signal: AbortSignal | undefined, generation: number): Promise<void> {
310
+ const transport = await this.#openTransport(signal);
311
+ // Given up on while the transport was being opened: it is closed rather
312
+ // than installed, and the caller hears that nothing is connected.
313
+ if (generation !== this.#generation || this.#status !== ClientStatus.Connecting) {
314
+ await transport.close().catch(() => {});
315
+ throw new NotConnectedError();
316
+ }
317
+
318
+ // A custom transport only says here whether it can deliver notifications,
319
+ // so a request to register for them is checked here rather than only at
320
+ // construction. The transport is closed first: the option is the caller's
321
+ // mistake, and leaving a connection open behind it helps nobody.
322
+ this.#notifications = transport.supportsNotifications;
323
+ try {
324
+ assertEventsSupported({ ...this.#options, notificationsSupported: this.#notifications });
325
+ } catch (error) {
326
+ await transport.close().catch(() => {});
327
+ throw error;
328
+ }
329
+
330
+ this.#transport = transport;
331
+ this.#closed = false;
332
+ this.#abandoned = 0;
333
+ this.#pending = null;
334
+ this.#clients.clear();
335
+ this.#clid = 0;
336
+ this.#serverFamily = "unknown";
337
+ this.#version = "";
338
+ transport.onLine = (line) => this.#handleLine(line);
339
+ transport.onClose = (error) => this.#handleClose(error);
340
+
341
+ const { login, server, nickname, eventCategories } = this.#options;
342
+ if (login !== undefined) await this.login(login.name, login.password, { signal });
343
+ if (server !== undefined) await this.use(server, { nickname, signal });
344
+ this.#version = await this.#getVersion(signal);
345
+ this.#serverFamily = serverFamilyOf(this.#version);
346
+ if (this.#notifications) {
347
+ for (const category of eventCategories) {
348
+ // The channel category takes the channel to watch, or 0 for all of them;
349
+ // the others take none, and the server refuses one they do not know.
350
+ await this.registerNotifications(category, category === "channel" ? 0 : undefined, {
351
+ signal,
352
+ });
353
+ }
354
+ // Only worth listing when there is something to fill: the groups and the
355
+ // invoker of a message this client would otherwise not know.
356
+ if (eventCategories.length > 0) await this.#learnClients(signal);
357
+ }
358
+ }
359
+
360
+ /**
361
+ * Leave the server and close the connection.
362
+ *
363
+ * A `connect()` still in flight fails with a NotConnectedError, and so do
364
+ * the commands still waiting for an answer. Resolves once `disconnected` has
365
+ * been delivered, so a listener added after `await client.disconnect()` does
366
+ * not hear it. Does nothing on a client that is not connected; called again
367
+ * while an earlier call is still running, it waits for that one. An explicit
368
+ * `disconnect()` never reconnects.
369
+ */
370
+ async disconnect(): Promise<void> {
371
+ // Recorded before anything else: a transport that closes during the
372
+ // teardown below must not be mistaken for an outage worth retrying.
373
+ this.#stopped = true;
374
+ this.#stopReconnecting?.abort();
375
+ this.#generation++;
376
+
377
+ if (this.#stopping !== undefined) return this.#stopping;
378
+ if (this.#status === ClientStatus.Disconnected) return this.#disconnectedDelivered;
379
+
380
+ this.#status = ClientStatus.Disconnected;
381
+ const stopping = this.#teardown();
382
+ this.#stopping = stopping;
383
+ try {
384
+ await stopping;
385
+ } finally {
386
+ this.#stopping = undefined;
387
+ }
388
+ }
389
+
390
+ async #teardown(): Promise<void> {
391
+ this.#logger.info("disconnecting from query server");
392
+ const transport = this.#transport;
393
+ this.#transport = null;
394
+ // Set before the transport is closed, so the close it reports is not read
395
+ // as the connection dropping.
396
+ this.#closed = true;
397
+ this.#fail(new NotConnectedError());
398
+ await transport?.close().catch(() => {});
399
+ // Announced here, with no cause: `disconnected` delivering `undefined` is
400
+ // what says the client left on purpose.
401
+ await this.#announceDisconnected(undefined, false);
402
+ }
403
+
404
+ /** `await using client = …` disconnects when the scope ends. */
405
+ [Symbol.asyncDispose](): Promise<void> {
406
+ return this.disconnect();
407
+ }
408
+
409
+ // ---- Commands ------------------------------------------------------------
410
+
411
+ /**
412
+ * Send a typed command. Passwords go as they are: ServerQuery takes the
413
+ * password itself and hashes it server-side, where the voice protocol wants
414
+ * the hash.
415
+ */
416
+ send(
417
+ request: CommandRequest,
418
+ options: CommandOptions = {},
419
+ ): Promise<Array<Record<string, string>>> {
420
+ return this.#send(request, options);
421
+ }
422
+
423
+ /** Send a raw command line and return the rows that answer it. */
424
+ exec(line: string, options: CommandOptions = {}): Promise<Array<Record<string, string>>> {
425
+ const run = this.#queue.then(() => this.#execNow(line, options));
426
+ // The queue moves on whatever this command's outcome.
427
+ this.#queue = run.catch(() => {});
428
+ return run;
429
+ }
430
+
431
+ /**
432
+ * Log in with a query account. Over WebQuery the api key does this, and the
433
+ * server refuses the command; over SSH the session is logged in already.
434
+ *
435
+ * Public because a caller driving the client by hand needs it: `connect()`
436
+ * runs it from the `login` option, and this is the same `login` command.
437
+ */
438
+ async login(name: string, password: string, options: CommandOptions = {}): Promise<void> {
439
+ await this.exec(
440
+ buildCommandOrdered("login", [
441
+ ["client_login_name", name],
442
+ ["client_login_password", password],
443
+ ]),
444
+ options,
445
+ );
446
+ }
447
+
448
+ /**
449
+ * Select the virtual server the following commands address, by id or by
450
+ * voice port. `nickname` names the query client on it.
451
+ *
452
+ * Public for the same reason as {@link QueryClient.login}: `connect()` runs
453
+ * it from the `server` option, and this is the same `use` command. Over
454
+ * WebQuery the transport answers it, by addressing that server from then on.
455
+ */
456
+ async use(
457
+ server: { readonly id: ServerID | number } | { readonly port: number },
458
+ options: CommandOptions & { readonly nickname?: string | undefined } = {},
459
+ ): Promise<void> {
460
+ await this.exec(
461
+ buildCommandOrdered("use", [
462
+ "id" in server ? ["sid", String(server.id)] : ["port", String(server.port)],
463
+ ...(options.nickname === undefined
464
+ ? []
465
+ : ([["client_nickname", options.nickname]] as const)),
466
+ ]),
467
+ options,
468
+ );
469
+ }
470
+
471
+ /**
472
+ * Ask the server for the notifications of one category: `server`, `channel`
473
+ * (for one channel, or `0` for all), `textserver`, `textchannel`,
474
+ * `textprivate`, and on TeamSpeak 6 `bans`.
475
+ *
476
+ * Public for the same reason as {@link QueryClient.login}: `connect()` runs
477
+ * it once per configured category. With the WebQuery transport there is
478
+ * nothing to register for and the server refuses it, so the client never
479
+ * calls it there, and neither should a caller.
480
+ */
481
+ async registerNotifications(
482
+ event: QueryEventCategory,
483
+ channelID?: bigint | number,
484
+ options: CommandOptions = {},
485
+ ): Promise<void> {
486
+ await this.exec(
487
+ buildCommandOrdered("servernotifyregister", [
488
+ ["event", event],
489
+ ...(channelID === undefined ? [] : ([["id", String(channelID)]] as const)),
490
+ ]),
491
+ options,
492
+ );
493
+ }
494
+
495
+ // ---- Events --------------------------------------------------------------
496
+
497
+ /**
498
+ * Listen for an event. Returns a function that removes the listener, which is
499
+ * also a `Disposable` (`using stop = client.on(...)`).
500
+ *
501
+ * ```ts
502
+ * client.on("textMessage", (message) => console.log(message.message));
503
+ * ```
504
+ *
505
+ * A listener that throws does not stop the others: the rest of the event's
506
+ * listeners, and the events after it, are still delivered, and the exception
507
+ * is rethrown on its own, from a microtask, so it surfaces as an
508
+ * `uncaughtException` just as an EventEmitter listener's would.
509
+ *
510
+ * With the WebQuery transport no server event can ever arrive, so this throws
511
+ * a `TypeError` for one rather than accepting a listener that would never
512
+ * run. `connected` and `disconnected` are still accepted: they come from the
513
+ * client, not the server.
514
+ */
515
+ on<K extends keyof QueryEventMap>(event: K, listener: Listener<QueryEventMap, K>): Unsubscribe {
516
+ if (this.#notifications === false && SERVER_EVENTS.has(event)) {
517
+ throw new TypeError(
518
+ `this transport delivers no notifications, so "${event}" cannot be listened for`,
519
+ );
520
+ }
521
+ const subscription: Subscription = {
522
+ listener: listener as (payload: unknown) => void,
523
+ removed: false,
524
+ };
525
+ // A new array rather than an edit in place: a delivery may be iterating
526
+ // over the old one.
527
+ this.#listeners.set(event, [...(this.#listeners.get(event) ?? []), subscription]);
528
+
529
+ const unsubscribe = (): void => {
530
+ if (subscription.removed) return;
531
+ subscription.removed = true;
532
+ const list = this.#listeners.get(event) ?? [];
533
+ this.#listeners.set(
534
+ event,
535
+ list.filter((s) => s !== subscription),
536
+ );
537
+ };
538
+ return Object.assign(unsubscribe, { [Symbol.dispose]: unsubscribe });
539
+ }
540
+
541
+ /** Listen for the next occurrence of an event only. */
542
+ once<K extends keyof QueryEventMap>(event: K, listener: Listener<QueryEventMap, K>): Unsubscribe {
543
+ const unsubscribe = this.on(event, ((payload: QueryEventMap[K]) => {
544
+ unsubscribe();
545
+ listener(payload);
546
+ }) as Listener<QueryEventMap, K>);
547
+ return unsubscribe;
548
+ }
549
+
550
+ /**
551
+ * An event as an async iterator, for `for await`:
552
+ *
553
+ * ```ts
554
+ * for await (const message of client.events("textMessage")) { … }
555
+ * ```
556
+ *
557
+ * Events that arrive while the loop body runs are buffered, so none is
558
+ * missed. The buffer is unbounded: an iterator that is not consumed as fast
559
+ * as events arrive holds every one of them, so stop iterating (or abort
560
+ * `signal`) when you stop reading.
561
+ *
562
+ * Iteration ends when the loop exits early (`break`, `return`, a throw), and
563
+ * — for every event but `disconnected` — when the client disconnects. When
564
+ * `signal` aborts, listening stops at once, whatever is buffered is
565
+ * discarded, and the pending and every later `next()` reject with the
566
+ * signal's reason.
567
+ */
568
+ events<K extends keyof QueryEventMap>(
569
+ event: K,
570
+ options: { readonly signal?: AbortSignal | undefined } = {},
571
+ ): AsyncIterableIterator<QueryEventMap[K]> {
572
+ const { signal } = options;
573
+ const buffered: Array<QueryEventMap[K]> = [];
574
+ // The next() calls waiting for an event, oldest first.
575
+ const waiting: Array<{
576
+ resolve: (result: IteratorResult<QueryEventMap[K]>) => void;
577
+ reject: (reason: unknown) => void;
578
+ }> = [];
579
+ let ended = false;
580
+ let aborted = false;
581
+
582
+ const finishWaiting = (): void => {
583
+ for (const waiter of waiting.splice(0)) {
584
+ waiter.resolve({ value: undefined, done: true });
585
+ }
586
+ };
587
+
588
+ const stopListening = this.on(event, ((payload: QueryEventMap[K]) => {
589
+ if (ended) return;
590
+ const waiter = waiting.shift();
591
+ if (waiter) {
592
+ waiter.resolve({ value: payload, done: false });
593
+ return;
594
+ }
595
+ buffered.push(payload);
596
+ }) as Listener<QueryEventMap, K>);
597
+ const stopOnDisconnect =
598
+ event === "disconnected"
599
+ ? undefined
600
+ : this.on("disconnected", () => {
601
+ cleanup();
602
+ finishWaiting();
603
+ });
604
+ const cleanup = (): void => {
605
+ ended = true;
606
+ stopListening();
607
+ stopOnDisconnect?.();
608
+ signal?.removeEventListener("abort", abort);
609
+ };
610
+ const abort = (): void => {
611
+ aborted = true;
612
+ buffered.length = 0;
613
+ cleanup();
614
+ const reason = signal!.reason;
615
+ for (const waiter of waiting.splice(0)) waiter.reject(reason);
616
+ };
617
+ if (signal?.aborted) abort();
618
+ else signal?.addEventListener("abort", abort, { once: true });
619
+
620
+ const iterator: AsyncIterableIterator<QueryEventMap[K]> = {
621
+ next(): Promise<IteratorResult<QueryEventMap[K]>> {
622
+ if (aborted) return Promise.reject(signal!.reason);
623
+ if (buffered.length > 0) return Promise.resolve({ value: buffered.shift()!, done: false });
624
+ if (ended) {
625
+ cleanup();
626
+ return Promise.resolve({ value: undefined, done: true });
627
+ }
628
+ return new Promise((resolve, reject) => waiting.push({ resolve, reject }));
629
+ },
630
+ return(): Promise<IteratorResult<QueryEventMap[K]>> {
631
+ buffered.length = 0;
632
+ cleanup();
633
+ finishWaiting();
634
+ return Promise.resolve({ value: undefined, done: true });
635
+ },
636
+ [Symbol.asyncIterator]() {
637
+ return iterator;
638
+ },
639
+ };
640
+ return iterator;
641
+ }
642
+
643
+ // ---- Private -------------------------------------------------------------
644
+
645
+ /** Build and open the transport the options describe. */
646
+ async #openTransport(signal: AbortSignal | undefined): Promise<QueryTransport> {
647
+ const transport: ResolvedTransport = this.#options.transport;
648
+ switch (transport.kind) {
649
+ case "tcp":
650
+ return connectTcp({
651
+ host: transport.host,
652
+ port: transport.port,
653
+ connect: transport.connect,
654
+ signal,
655
+ });
656
+ case "ssh":
657
+ return connectSsh({ ...transport.connect, signal });
658
+ case "web":
659
+ return new WebQueryTransport({
660
+ url: transport.url,
661
+ apiKey: transport.apiKey,
662
+ fetch: transport.fetch,
663
+ serverID: serverIDOf(this.#options.server),
664
+ });
665
+ case "custom":
666
+ return transport.open(signal);
667
+ default:
668
+ // Every kind is handled above; a value from outside this module's
669
+ // types would otherwise open nothing and hang the attempt.
670
+ throw new TypeError(
671
+ `unknown transport kind ${String((transport as { kind: unknown }).kind)}`,
672
+ );
673
+ }
674
+ }
675
+
676
+ /** The server's `version` string, and nothing else of the answer. */
677
+ async #getVersion(signal: AbortSignal | undefined): Promise<string> {
678
+ const rows = await this.exec("version", { signal });
679
+ return rows[0]?.["version"] ?? "";
680
+ }
681
+
682
+ /**
683
+ * List the clients once, so that `invokerGroups` and the invoker fields of a
684
+ * self-move can be filled: an enter notification is only seen for clients
685
+ * that arrive after registering, and the ones already there arrive as none.
686
+ */
687
+ async #learnClients(signal: AbortSignal | undefined): Promise<void> {
688
+ const rows = await this.exec("clientlist -uid -groups", { signal }).catch((error: unknown) => {
689
+ // A registration that worked and a list that did not leaves the client
690
+ // usable: a sender it has never seen simply has no known groups, which
691
+ // the event type already allows for.
692
+ this.#logger.warn("listing clients after registering failed", { error: String(error) });
693
+ return [] as Array<Record<string, string>>;
694
+ });
695
+ this.#clid = 0;
696
+ for (const row of rows) {
697
+ const result = handleNotification(
698
+ { name: "notifycliententerview", params: row },
699
+ 0,
700
+ this.#clients,
701
+ );
702
+ if (result.kind !== "clientEnter") continue;
703
+ // Ours is the one query client with our nickname. Without a nickname
704
+ // option there is no name to match, and the id stays unknown.
705
+ if (
706
+ this.#clid === 0 &&
707
+ result.info.type === ClientType.Query &&
708
+ this.#options.nickname !== undefined &&
709
+ result.info.nickname === this.#options.nickname
710
+ ) {
711
+ this.#clid = result.info.id;
712
+ }
713
+ }
714
+ }
715
+
716
+ async #execNow(line: string, options: CommandOptions): Promise<Array<Record<string, string>>> {
717
+ const { signal, timeoutMs = DEFAULT_COMMAND_TIMEOUT_MS } = options;
718
+ signal?.throwIfAborted();
719
+ if (this.#closed || this.#transport === null) throw new NotConnectedError();
720
+
721
+ let timer: ReturnType<typeof setTimeout> | undefined;
722
+ let onAbort: (() => void) | undefined;
723
+ const answered = new Promise<Array<Record<string, string>>>((resolve, reject) => {
724
+ this.#pending = { command: parseCommand(line)?.name ?? "", rows: [], resolve, reject };
725
+ timer = setTimeout(
726
+ () => reject(new CommandTimeoutError(parseCommand(line)?.name ?? "")),
727
+ timeoutMs,
728
+ );
729
+ onAbort = () => reject(signal!.reason);
730
+ signal?.addEventListener("abort", onAbort, { once: true });
731
+ });
732
+ // The answer may settle while the transport is still sending, as it does
733
+ // when a send carries the whole round trip (WebQuery); a failure of the
734
+ // send first would otherwise leave this rejection unhandled.
735
+ answered.catch(() => {});
736
+ const sending = { failed: false };
737
+ let abandonedByUs = false;
738
+ const send = this.#transport.send(line).then(
739
+ () => {},
740
+ (error: unknown) => {
741
+ // A send that fails after the command gave up will not be answered.
742
+ if (abandonedByUs) this.#abandoned--;
743
+ sending.failed = true;
744
+ throw error;
745
+ },
746
+ );
747
+ try {
748
+ await Promise.race([send, answered]);
749
+ return await answered;
750
+ } finally {
751
+ clearTimeout(timer);
752
+ if (onAbort) signal?.removeEventListener("abort", onAbort);
753
+ if (this.#pending !== null) {
754
+ this.#pending = null;
755
+ if (!sending.failed) {
756
+ this.#abandoned++;
757
+ abandonedByUs = true;
758
+ }
759
+ }
760
+ }
761
+ }
762
+
763
+ #handleLine(line: string): void {
764
+ const trimmed = line.replace(/\r$/, "");
765
+ if (!trimmed) return;
766
+ const cmd = parseCommand(trimmed);
767
+ if (!cmd) return;
768
+
769
+ if (cmd.name === "error") {
770
+ if (this.#abandoned > 0) {
771
+ this.#abandoned--;
772
+ return;
773
+ }
774
+ const pending = this.#pending;
775
+ this.#pending = null;
776
+ if (!pending) return;
777
+ const { err } = parseServerError(cmd.params);
778
+ const refusal = err ?? transferRefusal(pending.command, pending.rows);
779
+ if (refusal) pending.reject(refusal);
780
+ else pending.resolve(pending.rows);
781
+ return;
782
+ }
783
+ if (cmd.name.startsWith("notify")) {
784
+ // A notification is never part of an answer.
785
+ for (const row of splitRows(trimmed)) {
786
+ const parsed = parseCommand(row);
787
+ if (parsed) this.#notification(parsed.name, parsed.params);
788
+ }
789
+ return;
790
+ }
791
+ if (this.#abandoned > 0) return;
792
+ for (const row of splitRows(trimmed)) {
793
+ const parsed = parseCommand(row);
794
+ if (parsed) this.#pending?.rows.push(parsed.params);
795
+ }
796
+ }
797
+
798
+ /** Turn one notification into the core's events, then report it as it arrived. */
799
+ #notification(name: string, params: Record<string, string>): void {
800
+ const result = handleNotification({ name, params }, this.#clid, this.#clients);
801
+ switch (result.kind) {
802
+ case "clientEnter":
803
+ this.#emit("clientEnter", result.info);
804
+ break;
805
+ case "clientLeave":
806
+ this.#emit("clientLeave", result.event);
807
+ // A query client is never kicked from the server, but a ban or a
808
+ // server stopping reaches it as a leave of its own: the same two
809
+ // reasons the voice client reads.
810
+ if (
811
+ result.isSelf &&
812
+ (result.event.reason === Reason.KickServer ||
813
+ result.event.reason === Reason.KickServerBan)
814
+ ) {
815
+ this.#emit("kicked", kickedEvent(result.event));
816
+ }
817
+ break;
818
+ case "clientMoved":
819
+ this.#emit("clientMoved", result.event);
820
+ // A channel kick moves the client to the default channel, so it
821
+ // arrives as a move of its own, not as leaving view.
822
+ if (
823
+ this.#clid !== 0 &&
824
+ result.event.clientID === this.#clid &&
825
+ result.event.reason === Reason.KickChannel
826
+ ) {
827
+ this.#emit("kicked", kickedEvent(result.event));
828
+ }
829
+ break;
830
+ case "textMessage":
831
+ this.#emit("textMessage", result.message);
832
+ break;
833
+ case "poked":
834
+ case "unknown":
835
+ // A query client is never poked, and anything else is not an event the
836
+ // core models; `notification` still carries it.
837
+ break;
838
+ }
839
+ // After any event it produced, as the voice client sends it. A listener's
840
+ // failure is its own: it must not stop the answer being read.
841
+ if (this.#listeners.get("notification")?.length) {
842
+ this.#emit("notification", { name, params: { ...params } });
843
+ }
844
+ }
845
+
846
+ #emit<K extends keyof QueryEventMap>(event: K, payload: QueryEventMap[K]): void {
847
+ for (const subscription of this.#listeners.get(event) ?? []) {
848
+ // Checked at delivery: a listener removed while its event was queued
849
+ // does not get it.
850
+ if (subscription.removed) continue;
851
+ try {
852
+ subscription.listener(payload);
853
+ } catch (error) {
854
+ // Rethrown on its own, so it surfaces as an uncaught exception rather
855
+ // than breaking the listeners after it.
856
+ queueMicrotask(() => {
857
+ throw error;
858
+ });
859
+ }
860
+ }
861
+ }
862
+
863
+ #fail(error: unknown): void {
864
+ const pending = this.#pending;
865
+ this.#pending = null;
866
+ pending?.reject(error);
867
+ }
868
+
869
+ #handleClose(error: Error | undefined): void {
870
+ if (this.#status === ClientStatus.Disconnected) return;
871
+ this.#transport = null;
872
+ this.#closed = true;
873
+ this.#fail(new NotConnectedError(error ? { cause: error } : undefined));
874
+ const wasConnected = this.#status === ClientStatus.Connected;
875
+ this.#status = ClientStatus.Disconnected;
876
+ if (!wasConnected) {
877
+ // The transport closed while the session was being set up, so this is
878
+ // the attempt failing rather than a live connection dropping. The
879
+ // attempt's own command rejects, and `connect()` rejects with it.
880
+ return;
881
+ }
882
+ this.#lastDrop = error ?? new TeamspeakError("connection closed");
883
+ // Announced first, then retried: a caller watching `disconnected` should
884
+ // hear about the drop even when a reconnect is already on its way, because
885
+ // a silent recovery hides an outage.
886
+ void this.#announceDisconnected(
887
+ this.#lastDrop,
888
+ this.#willReconnect(this.#lastDrop, this.#loopAttempt + 1),
889
+ );
890
+ this.#scheduleReconnect(this.#lastDrop).catch((failure: unknown) => {
891
+ this.#logger.error("reconnecting failed", { error: String(failure) });
892
+ });
893
+ }
894
+
895
+ /**
896
+ * A connection attempt has failed for good. The transport it opened is
897
+ * closed, so a later `connect()` starts from nothing.
898
+ *
899
+ * A `connect()` the caller made is theirs to see rejected, and is not
900
+ * announced as a disconnection. An attempt of the reconnect loop is: the
901
+ * caller hears the loop's own answer, so that a loop which has given up says
902
+ * so instead of only ever having reported the drop that started it.
903
+ */
904
+ #failConnection(error: unknown): void {
905
+ if (this.#status === ClientStatus.Connected) return;
906
+ this.#status = ClientStatus.Disconnected;
907
+ this.#closed = true;
908
+ // The cause is reported as it is, not wrapped: a misuse of the options,
909
+ // such as asking for events a transport cannot deliver, must stay the
910
+ // TypeError it is.
911
+ this.#fail(error);
912
+ const transport = this.#transport;
913
+ this.#transport = null;
914
+ void transport?.close().catch(() => {});
915
+ if (!this.#reconnecting) return;
916
+ const cause =
917
+ error instanceof Error ? error : new TeamspeakError(String(error), { cause: error });
918
+ void this.#announceDisconnected(cause, this.#willReconnect(cause, this.#loopAttempt + 1));
919
+ }
920
+
921
+ #announceDisconnected(cause: Error | undefined, willReconnect: boolean): Promise<void> {
922
+ let resolve!: () => void;
923
+ const delivered = new Promise<void>((r) => (resolve = r));
924
+ try {
925
+ this.#emit("disconnected", { error: cause, willReconnect });
926
+ } finally {
927
+ resolve();
928
+ }
929
+ this.#disconnectedDelivered = delivered;
930
+ void delivered.then(() => {
931
+ if (this.#disconnectedDelivered === delivered) this.#disconnectedDelivered = undefined;
932
+ });
933
+ return delivered;
934
+ }
935
+
936
+ /**
937
+ * Whether the reconnect loop will make attempt number `attempt` after `err`.
938
+ * `disconnected` reports it, and the loop acts on the same answer: the
939
+ * caller's `shouldRetry` is asked once per failure, not once to announce it
940
+ * and again to act on it.
941
+ */
942
+ #willReconnect(err: Error, attempt: number): boolean {
943
+ const options = this.#reconnectOptions;
944
+ if (options === undefined || this.#stopped) return false;
945
+ const { maxAttempts } = resolveBackoffPolicy(options);
946
+ if (maxAttempts !== 0 && attempt > maxAttempts) return false;
947
+ const asked = this.#retryAnswer;
948
+ if (asked?.err === err && asked.attempt === attempt) return asked.retry;
949
+ let retry: boolean;
950
+ let threw = false;
951
+ try {
952
+ retry = (options.shouldRetry ?? defaultShouldRetry)(err, attempt);
953
+ } catch (failure) {
954
+ this.#logger.error("shouldRetry threw; not reconnecting", { error: String(failure) });
955
+ retry = false;
956
+ threw = true;
957
+ }
958
+ this.#retryAnswer = { err, attempt, retry, threw };
959
+ return retry;
960
+ }
961
+
962
+ /**
963
+ * Retry a dropped connection according to the reconnect policy: every
964
+ * attempt re-opens the transport and reruns the whole setup, `login` and
965
+ * `use` included.
966
+ *
967
+ * A refused `login` or `use` ends the loop, through the default
968
+ * `shouldRetry`: the credentials or the target are wrong, and the same ones
969
+ * fail the same way. Retrying them is also what turns a dropped connection
970
+ * into a ban, since those are the two commands that reach the flood limit
971
+ * fastest.
972
+ *
973
+ * Never runs after an explicit `disconnect()`, and never more than one at a
974
+ * time: two failures in quick succession would otherwise start competing
975
+ * loops, and a loop shorter than the server's antiflood window bans the IP
976
+ * rather than reconnecting it. A drop while the loop runs is the loop's to
977
+ * retry: it ends only once its attempt is still connected when it resumes.
978
+ */
979
+ async #scheduleReconnect(err: Error): Promise<void> {
980
+ const options = this.#reconnectOptions;
981
+ if (options === undefined || this.#stopped || this.#reconnecting) return;
982
+
983
+ const policy = resolveBackoffPolicy(options);
984
+ this.#reconnecting = true;
985
+ const stop = new AbortController();
986
+ this.#stopReconnecting = stop;
987
+ try {
988
+ for (let attempt = 1; policy.maxAttempts === 0 || attempt <= policy.maxAttempts; attempt++) {
989
+ if (this.#stopped) return;
990
+ if (!this.#willReconnect(err, attempt)) {
991
+ if (!this.#retryAnswer?.threw) {
992
+ this.#logger.info("not retrying", { attempt, reason: err.message });
993
+ }
994
+ return;
995
+ }
996
+
997
+ const delay = backoffDelay(policy, attempt);
998
+ this.#logger.info("reconnecting", { attempt, delayMs: delay });
999
+ // A timer that keeps the process alive: an application that does
1000
+ // nothing but stay connected would otherwise exit during the wait.
1001
+ await sleep(delay, stop.signal);
1002
+ if (this.#stopped) return;
1003
+ // Connected, or connecting, by hand during the wait: nothing is left
1004
+ // to retry, and an attempt now would only fail with
1005
+ // AlreadyConnectedError.
1006
+ if (this.#status !== ClientStatus.Disconnected) return;
1007
+
1008
+ this.#loopAttempt = attempt;
1009
+ try {
1010
+ await this.#connect({ signal: AbortSignal.timeout(RECONNECT_ATTEMPT_TIMEOUT_MS) }, true);
1011
+ // Read through the getter: the status may have changed while
1012
+ // connect() resolved, which the field's narrowing would not see.
1013
+ if (this.status === ClientStatus.Connected) {
1014
+ this.#logger.info("reconnected", { attempt });
1015
+ return;
1016
+ }
1017
+ // The new session dropped before this loop resumed. That drop found
1018
+ // this loop running and left the retrying to it.
1019
+ err = this.#lastDrop ?? new TeamspeakError("connection closed");
1020
+ } catch (retryErr) {
1021
+ if (retryErr instanceof AlreadyConnectedError) return;
1022
+ err =
1023
+ retryErr instanceof Error
1024
+ ? retryErr
1025
+ : new TeamspeakError(String(retryErr), { cause: retryErr });
1026
+ }
1027
+ }
1028
+ this.#logger.warn("reconnect attempts exhausted", { attempts: policy.maxAttempts });
1029
+ } finally {
1030
+ this.#reconnecting = false;
1031
+ this.#loopAttempt = 0;
1032
+ if (this.#stopReconnecting === stop) this.#stopReconnecting = undefined;
1033
+ }
1034
+ }
1035
+ }
1036
+
1037
+ /**
1038
+ * The core's server events. A transport with no notifications cannot deliver
1039
+ * any of them, so a listener for one on such a client could never run.
1040
+ */
1041
+ const SERVER_EVENTS: ReadonlySet<string> = new Set([
1042
+ "textMessage",
1043
+ "clientEnter",
1044
+ "clientLeave",
1045
+ "clientMoved",
1046
+ "notification",
1047
+ "kicked",
1048
+ ]);
1049
+
1050
+ function kickedEvent(event: {
1051
+ readonly reason: number;
1052
+ readonly reasonMessage: string;
1053
+ readonly invokerName: string;
1054
+ readonly invokerUID: string;
1055
+ readonly invokerID: number;
1056
+ }): KickedEvent {
1057
+ return {
1058
+ reason: event.reason,
1059
+ reasonMessage: event.reasonMessage,
1060
+ invokerName: event.invokerName,
1061
+ invokerUID: event.invokerUID as KickedEvent["invokerUID"],
1062
+ invokerID: event.invokerID as KickedEvent["invokerID"],
1063
+ };
1064
+ }
1065
+
1066
+ /** The server id a WebQuery transport addresses, from the `server` option. */
1067
+ function serverIDOf(server: ResolvedQueryOptions["server"]): ServerID | number | undefined {
1068
+ return server !== undefined && "id" in server ? server.id : undefined;
1069
+ }
1070
+
1071
+ /** Which server generation a `version` string names. */
1072
+ function serverFamilyOf(version: string): ServerFamily {
1073
+ const major = Number.parseInt(version, 10);
1074
+ if (Number.isNaN(major)) return "unknown";
1075
+ if (major === 3) return "ts3";
1076
+ if (major >= 6) return "ts6";
1077
+ return "unknown";
1078
+ }
1079
+
1080
+ /** Sleep, or wake early once `signal` aborts. Resolves either way, never rejects. */
1081
+ function sleep(ms: number, signal: AbortSignal): Promise<void> {
1082
+ if (ms <= 0 || signal.aborted) return Promise.resolve();
1083
+ return new Promise((resolve) => {
1084
+ const done = (): void => {
1085
+ clearTimeout(timer);
1086
+ signal.removeEventListener("abort", done);
1087
+ resolve();
1088
+ };
1089
+ const timer = setTimeout(done, ms);
1090
+ signal.addEventListener("abort", done, { once: true });
1091
+ });
1092
+ }
1093
+
1094
+ /**
1095
+ * The rows of a line, which ServerQuery separates with `|`.
1096
+ *
1097
+ * Every row carries all its fields, so rows are not completed from the row
1098
+ * before as the voice protocol's are; a `|` inside a value is escaped (`\p`),
1099
+ * so splitting on it is exact. A notification's name is repeated on each row.
1100
+ */
1101
+ function splitRows(line: string): string[] {
1102
+ const space = line.indexOf(" ");
1103
+ const head = space < 0 ? line : line.slice(0, space);
1104
+ const named = /^[A-Za-z0-9]+$/.test(head);
1105
+ const rest = named ? (space < 0 ? "" : line.slice(space + 1)) : line;
1106
+ const prefix = named ? `${head} ` : "";
1107
+ return rest
1108
+ .split("|")
1109
+ .filter((row) => row !== "")
1110
+ .map((row) => prefix + row);
1111
+ }
1112
+
1113
+ /**
1114
+ * The refusal a file transfer negotiation reports inside a successful answer.
1115
+ *
1116
+ * `ftinitupload` and `ftinitdownload` answer `error id=0` even when they
1117
+ * refuse, and say so in the row instead: `clientftfid=2 status=2058
1118
+ * msg=file\sis\sin\suse`, with no `ftkey`. Left as it is, the typed command
1119
+ * would find no key and report that the server sent no data, losing the
1120
+ * reason; this turns the row into the server error it is.
1121
+ */
1122
+ function transferRefusal(
1123
+ command: string,
1124
+ rows: ReadonlyArray<Record<string, string>>,
1125
+ ): ServerError | undefined {
1126
+ if (command !== "ftinitupload" && command !== "ftinitdownload") return undefined;
1127
+ const row = rows[0];
1128
+ if (row === undefined || row["ftkey"] !== undefined) return undefined;
1129
+ const status = Number(row["status"]);
1130
+ if (!Number.isInteger(status) || status === 0) return undefined;
1131
+ return new ServerError(status, row["msg"] ?? "");
1132
+ }