@yunzai-ng/core 0.1.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 (357) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +51 -0
  3. package/dist/adapter/accounts.d.ts +174 -0
  4. package/dist/adapter/accounts.d.ts.map +1 -0
  5. package/dist/adapter/accounts.js +653 -0
  6. package/dist/adapter/accounts.js.map +1 -0
  7. package/dist/adapter/bots.d.ts +142 -0
  8. package/dist/adapter/bots.d.ts.map +1 -0
  9. package/dist/adapter/bots.js +454 -0
  10. package/dist/adapter/bots.js.map +1 -0
  11. package/dist/adapter/host.d.ts +114 -0
  12. package/dist/adapter/host.d.ts.map +1 -0
  13. package/dist/adapter/host.js +90 -0
  14. package/dist/adapter/host.js.map +1 -0
  15. package/dist/adapter/login.d.ts +174 -0
  16. package/dist/adapter/login.d.ts.map +1 -0
  17. package/dist/adapter/login.js +345 -0
  18. package/dist/adapter/login.js.map +1 -0
  19. package/dist/adapter/registry.d.ts +102 -0
  20. package/dist/adapter/registry.d.ts.map +1 -0
  21. package/dist/adapter/registry.js +150 -0
  22. package/dist/adapter/registry.js.map +1 -0
  23. package/dist/config/core-config.d.ts +151 -0
  24. package/dist/config/core-config.d.ts.map +1 -0
  25. package/dist/config/core-config.js +338 -0
  26. package/dist/config/core-config.js.map +1 -0
  27. package/dist/config/schema.d.ts +449 -0
  28. package/dist/config/schema.d.ts.map +1 -0
  29. package/dist/config/schema.js +871 -0
  30. package/dist/config/schema.js.map +1 -0
  31. package/dist/config/store.d.ts +184 -0
  32. package/dist/config/store.d.ts.map +1 -0
  33. package/dist/config/store.js +428 -0
  34. package/dist/config/store.js.map +1 -0
  35. package/dist/config/yaml.d.ts +23 -0
  36. package/dist/config/yaml.d.ts.map +1 -0
  37. package/dist/config/yaml.js +108 -0
  38. package/dist/config/yaml.js.map +1 -0
  39. package/dist/http/client.d.ts +82 -0
  40. package/dist/http/client.d.ts.map +1 -0
  41. package/dist/http/client.js +658 -0
  42. package/dist/http/client.js.map +1 -0
  43. package/dist/index.d.ts +77 -0
  44. package/dist/index.d.ts.map +1 -0
  45. package/dist/index.js +94 -0
  46. package/dist/index.js.map +1 -0
  47. package/dist/kernel/app.d.ts +244 -0
  48. package/dist/kernel/app.d.ts.map +1 -0
  49. package/dist/kernel/app.js +654 -0
  50. package/dist/kernel/app.js.map +1 -0
  51. package/dist/kernel/kv-sink.d.ts +34 -0
  52. package/dist/kernel/kv-sink.d.ts.map +1 -0
  53. package/dist/kernel/kv-sink.js +25 -0
  54. package/dist/kernel/kv-sink.js.map +1 -0
  55. package/dist/kernel/policy.d.ts +77 -0
  56. package/dist/kernel/policy.d.ts.map +1 -0
  57. package/dist/kernel/policy.js +99 -0
  58. package/dist/kernel/policy.js.map +1 -0
  59. package/dist/kernel/runtime.d.ts +117 -0
  60. package/dist/kernel/runtime.d.ts.map +1 -0
  61. package/dist/kernel/runtime.js +197 -0
  62. package/dist/kernel/runtime.js.map +1 -0
  63. package/dist/kernel/sql-sink.d.ts +36 -0
  64. package/dist/kernel/sql-sink.d.ts.map +1 -0
  65. package/dist/kernel/sql-sink.js +114 -0
  66. package/dist/kernel/sql-sink.js.map +1 -0
  67. package/dist/logger/format.d.ts +67 -0
  68. package/dist/logger/format.d.ts.map +1 -0
  69. package/dist/logger/format.js +218 -0
  70. package/dist/logger/format.js.map +1 -0
  71. package/dist/logger/index.d.ts +97 -0
  72. package/dist/logger/index.d.ts.map +1 -0
  73. package/dist/logger/index.js +363 -0
  74. package/dist/logger/index.js.map +1 -0
  75. package/dist/logger/rotate.d.ts +47 -0
  76. package/dist/logger/rotate.d.ts.map +1 -0
  77. package/dist/logger/rotate.js +242 -0
  78. package/dist/logger/rotate.js.map +1 -0
  79. package/dist/message/segment.d.ts +255 -0
  80. package/dist/message/segment.d.ts.map +1 -0
  81. package/dist/message/segment.js +510 -0
  82. package/dist/message/segment.js.map +1 -0
  83. package/dist/message/split.d.ts +34 -0
  84. package/dist/message/split.d.ts.map +1 -0
  85. package/dist/message/split.js +79 -0
  86. package/dist/message/split.js.map +1 -0
  87. package/dist/message/target.d.ts +29 -0
  88. package/dist/message/target.d.ts.map +1 -0
  89. package/dist/message/target.js +34 -0
  90. package/dist/message/target.js.map +1 -0
  91. package/dist/pipeline/cooldown.d.ts +84 -0
  92. package/dist/pipeline/cooldown.d.ts.map +1 -0
  93. package/dist/pipeline/cooldown.js +94 -0
  94. package/dist/pipeline/cooldown.js.map +1 -0
  95. package/dist/pipeline/dispatch.d.ts +98 -0
  96. package/dist/pipeline/dispatch.d.ts.map +1 -0
  97. package/dist/pipeline/dispatch.js +323 -0
  98. package/dist/pipeline/dispatch.js.map +1 -0
  99. package/dist/pipeline/event.d.ts +130 -0
  100. package/dist/pipeline/event.d.ts.map +1 -0
  101. package/dist/pipeline/event.js +538 -0
  102. package/dist/pipeline/event.js.map +1 -0
  103. package/dist/pipeline/middleware.d.ts +63 -0
  104. package/dist/pipeline/middleware.d.ts.map +1 -0
  105. package/dist/pipeline/middleware.js +174 -0
  106. package/dist/pipeline/middleware.js.map +1 -0
  107. package/dist/pipeline/prompt.d.ts +80 -0
  108. package/dist/pipeline/prompt.d.ts.map +1 -0
  109. package/dist/pipeline/prompt.js +155 -0
  110. package/dist/pipeline/prompt.js.map +1 -0
  111. package/dist/pipeline/router.d.ts +98 -0
  112. package/dist/pipeline/router.d.ts.map +1 -0
  113. package/dist/pipeline/router.js +489 -0
  114. package/dist/pipeline/router.js.map +1 -0
  115. package/dist/platform/detect.d.ts +24 -0
  116. package/dist/platform/detect.d.ts.map +1 -0
  117. package/dist/platform/detect.js +178 -0
  118. package/dist/platform/detect.js.map +1 -0
  119. package/dist/platform/paths.d.ts +38 -0
  120. package/dist/platform/paths.d.ts.map +1 -0
  121. package/dist/platform/paths.js +129 -0
  122. package/dist/platform/paths.js.map +1 -0
  123. package/dist/platform/system.d.ts +71 -0
  124. package/dist/platform/system.d.ts.map +1 -0
  125. package/dist/platform/system.js +242 -0
  126. package/dist/platform/system.js.map +1 -0
  127. package/dist/plugin/context.d.ts +92 -0
  128. package/dist/plugin/context.d.ts.map +1 -0
  129. package/dist/plugin/context.js +573 -0
  130. package/dist/plugin/context.js.map +1 -0
  131. package/dist/plugin/define.d.ts +92 -0
  132. package/dist/plugin/define.d.ts.map +1 -0
  133. package/dist/plugin/define.js +82 -0
  134. package/dist/plugin/define.js.map +1 -0
  135. package/dist/plugin/discover.d.ts +106 -0
  136. package/dist/plugin/discover.d.ts.map +1 -0
  137. package/dist/plugin/discover.js +254 -0
  138. package/dist/plugin/discover.js.map +1 -0
  139. package/dist/plugin/events.d.ts +109 -0
  140. package/dist/plugin/events.d.ts.map +1 -0
  141. package/dist/plugin/events.js +209 -0
  142. package/dist/plugin/events.js.map +1 -0
  143. package/dist/plugin/hooks.d.ts +266 -0
  144. package/dist/plugin/hooks.d.ts.map +1 -0
  145. package/dist/plugin/hooks.js +84 -0
  146. package/dist/plugin/hooks.js.map +1 -0
  147. package/dist/plugin/host.d.ts +160 -0
  148. package/dist/plugin/host.d.ts.map +1 -0
  149. package/dist/plugin/host.js +546 -0
  150. package/dist/plugin/host.js.map +1 -0
  151. package/dist/plugin/market.d.ts +264 -0
  152. package/dist/plugin/market.d.ts.map +1 -0
  153. package/dist/plugin/market.js +700 -0
  154. package/dist/plugin/market.js.map +1 -0
  155. package/dist/plugin/services.d.ts +116 -0
  156. package/dist/plugin/services.d.ts.map +1 -0
  157. package/dist/plugin/services.js +213 -0
  158. package/dist/plugin/services.js.map +1 -0
  159. package/dist/plugin/tar.d.ts +65 -0
  160. package/dist/plugin/tar.d.ts.map +1 -0
  161. package/dist/plugin/tar.js +277 -0
  162. package/dist/plugin/tar.js.map +1 -0
  163. package/dist/render/registry.d.ts +127 -0
  164. package/dist/render/registry.d.ts.map +1 -0
  165. package/dist/render/registry.js +306 -0
  166. package/dist/render/registry.js.map +1 -0
  167. package/dist/scheduler/index.d.ts +67 -0
  168. package/dist/scheduler/index.d.ts.map +1 -0
  169. package/dist/scheduler/index.js +279 -0
  170. package/dist/scheduler/index.js.map +1 -0
  171. package/dist/server/api.d.ts +179 -0
  172. package/dist/server/api.d.ts.map +1 -0
  173. package/dist/server/api.js +602 -0
  174. package/dist/server/api.js.map +1 -0
  175. package/dist/server/auth.d.ts +83 -0
  176. package/dist/server/auth.d.ts.map +1 -0
  177. package/dist/server/auth.js +178 -0
  178. package/dist/server/auth.js.map +1 -0
  179. package/dist/server/browse.d.ts +60 -0
  180. package/dist/server/browse.d.ts.map +1 -0
  181. package/dist/server/browse.js +187 -0
  182. package/dist/server/browse.js.map +1 -0
  183. package/dist/server/files.d.ts +27 -0
  184. package/dist/server/files.d.ts.map +1 -0
  185. package/dist/server/files.js +76 -0
  186. package/dist/server/files.js.map +1 -0
  187. package/dist/server/index.d.ts +189 -0
  188. package/dist/server/index.d.ts.map +1 -0
  189. package/dist/server/index.js +1123 -0
  190. package/dist/server/index.js.map +1 -0
  191. package/dist/server/table.d.ts +105 -0
  192. package/dist/server/table.d.ts.map +1 -0
  193. package/dist/server/table.js +289 -0
  194. package/dist/server/table.js.map +1 -0
  195. package/dist/store/index.d.ts +50 -0
  196. package/dist/store/index.d.ts.map +1 -0
  197. package/dist/store/index.js +120 -0
  198. package/dist/store/index.js.map +1 -0
  199. package/dist/store/json.d.ts +73 -0
  200. package/dist/store/json.d.ts.map +1 -0
  201. package/dist/store/json.js +174 -0
  202. package/dist/store/json.js.map +1 -0
  203. package/dist/store/kv.d.ts +126 -0
  204. package/dist/store/kv.d.ts.map +1 -0
  205. package/dist/store/kv.js +232 -0
  206. package/dist/store/kv.js.map +1 -0
  207. package/dist/store/level.d.ts +103 -0
  208. package/dist/store/level.d.ts.map +1 -0
  209. package/dist/store/level.js +119 -0
  210. package/dist/store/level.js.map +1 -0
  211. package/dist/store/memory.d.ts +61 -0
  212. package/dist/store/memory.d.ts.map +1 -0
  213. package/dist/store/memory.js +94 -0
  214. package/dist/store/memory.js.map +1 -0
  215. package/dist/store/sql.d.ts +142 -0
  216. package/dist/store/sql.d.ts.map +1 -0
  217. package/dist/store/sql.js +246 -0
  218. package/dist/store/sql.js.map +1 -0
  219. package/dist/testing/fake.d.ts +109 -0
  220. package/dist/testing/fake.d.ts.map +1 -0
  221. package/dist/testing/fake.js +196 -0
  222. package/dist/testing/fake.js.map +1 -0
  223. package/dist/testing/index.d.ts +16 -0
  224. package/dist/testing/index.d.ts.map +1 -0
  225. package/dist/testing/index.js +16 -0
  226. package/dist/testing/index.js.map +1 -0
  227. package/dist/testing/mock-adapter.d.ts +205 -0
  228. package/dist/testing/mock-adapter.d.ts.map +1 -0
  229. package/dist/testing/mock-adapter.js +309 -0
  230. package/dist/testing/mock-adapter.js.map +1 -0
  231. package/dist/util/deep.d.ts +108 -0
  232. package/dist/util/deep.d.ts.map +1 -0
  233. package/dist/util/deep.js +275 -0
  234. package/dist/util/deep.js.map +1 -0
  235. package/dist/util/defer.d.ts +107 -0
  236. package/dist/util/defer.d.ts.map +1 -0
  237. package/dist/util/defer.js +163 -0
  238. package/dist/util/defer.js.map +1 -0
  239. package/dist/util/dispose.d.ts +88 -0
  240. package/dist/util/dispose.d.ts.map +1 -0
  241. package/dist/util/dispose.js +143 -0
  242. package/dist/util/dispose.js.map +1 -0
  243. package/dist/util/duration.d.ts +28 -0
  244. package/dist/util/duration.d.ts.map +1 -0
  245. package/dist/util/duration.js +77 -0
  246. package/dist/util/duration.js.map +1 -0
  247. package/dist/util/fs.d.ts +110 -0
  248. package/dist/util/fs.d.ts.map +1 -0
  249. package/dist/util/fs.js +269 -0
  250. package/dist/util/fs.js.map +1 -0
  251. package/dist/util/id.d.ts +49 -0
  252. package/dist/util/id.d.ts.map +1 -0
  253. package/dist/util/id.js +87 -0
  254. package/dist/util/id.js.map +1 -0
  255. package/dist/util/lru.d.ts +74 -0
  256. package/dist/util/lru.d.ts.map +1 -0
  257. package/dist/util/lru.js +156 -0
  258. package/dist/util/lru.js.map +1 -0
  259. package/dist/util/queue.d.ts +64 -0
  260. package/dist/util/queue.d.ts.map +1 -0
  261. package/dist/util/queue.js +147 -0
  262. package/dist/util/queue.js.map +1 -0
  263. package/dist/util/text.d.ts +86 -0
  264. package/dist/util/text.d.ts.map +1 -0
  265. package/dist/util/text.js +182 -0
  266. package/dist/util/text.js.map +1 -0
  267. package/package.json +44 -0
  268. package/src/adapter/accounts.ts +758 -0
  269. package/src/adapter/bots.ts +582 -0
  270. package/src/adapter/host.ts +224 -0
  271. package/src/adapter/login.ts +481 -0
  272. package/src/adapter/registry.ts +202 -0
  273. package/src/config/core-config.test.ts +60 -0
  274. package/src/config/core-config.ts +401 -0
  275. package/src/config/schema.test.ts +179 -0
  276. package/src/config/schema.ts +1036 -0
  277. package/src/config/store.test.ts +245 -0
  278. package/src/config/store.ts +506 -0
  279. package/src/config/yaml.ts +119 -0
  280. package/src/http/client.test.ts +568 -0
  281. package/src/http/client.ts +777 -0
  282. package/src/index.ts +106 -0
  283. package/src/kernel/app.test.ts +267 -0
  284. package/src/kernel/app.ts +843 -0
  285. package/src/kernel/kv-sink.ts +58 -0
  286. package/src/kernel/policy.ts +120 -0
  287. package/src/kernel/runtime.test.ts +542 -0
  288. package/src/kernel/runtime.ts +307 -0
  289. package/src/kernel/sql-sink.ts +154 -0
  290. package/src/logger/format.ts +259 -0
  291. package/src/logger/index.ts +427 -0
  292. package/src/logger/rotate.ts +265 -0
  293. package/src/message/segment.test.ts +335 -0
  294. package/src/message/segment.ts +569 -0
  295. package/src/message/split.ts +97 -0
  296. package/src/message/target.ts +48 -0
  297. package/src/pipeline/cooldown.ts +136 -0
  298. package/src/pipeline/dispatch.ts +419 -0
  299. package/src/pipeline/event.ts +735 -0
  300. package/src/pipeline/middleware.test.ts +426 -0
  301. package/src/pipeline/middleware.ts +199 -0
  302. package/src/pipeline/prompt.ts +212 -0
  303. package/src/pipeline/router.test.ts +432 -0
  304. package/src/pipeline/router.ts +575 -0
  305. package/src/platform/detect.ts +178 -0
  306. package/src/platform/paths.ts +147 -0
  307. package/src/platform/system.test.ts +102 -0
  308. package/src/platform/system.ts +289 -0
  309. package/src/plugin/context.test.ts +459 -0
  310. package/src/plugin/context.ts +757 -0
  311. package/src/plugin/define.test.ts +120 -0
  312. package/src/plugin/define.ts +142 -0
  313. package/src/plugin/discover.test.ts +253 -0
  314. package/src/plugin/discover.ts +353 -0
  315. package/src/plugin/events.test.ts +162 -0
  316. package/src/plugin/events.ts +266 -0
  317. package/src/plugin/hooks.ts +373 -0
  318. package/src/plugin/host.test.ts +696 -0
  319. package/src/plugin/host.ts +738 -0
  320. package/src/plugin/market.test.ts +781 -0
  321. package/src/plugin/market.ts +870 -0
  322. package/src/plugin/services.test.ts +135 -0
  323. package/src/plugin/services.ts +261 -0
  324. package/src/plugin/tar.test.ts +158 -0
  325. package/src/plugin/tar.ts +309 -0
  326. package/src/render/registry.test.ts +127 -0
  327. package/src/render/registry.ts +439 -0
  328. package/src/scheduler/index.ts +357 -0
  329. package/src/server/api.test.ts +802 -0
  330. package/src/server/api.ts +869 -0
  331. package/src/server/auth.ts +201 -0
  332. package/src/server/browse.test.ts +193 -0
  333. package/src/server/browse.ts +240 -0
  334. package/src/server/files.ts +97 -0
  335. package/src/server/index.test.ts +566 -0
  336. package/src/server/index.ts +1386 -0
  337. package/src/server/table.ts +351 -0
  338. package/src/store/index.ts +156 -0
  339. package/src/store/json.ts +198 -0
  340. package/src/store/kv.test.ts +307 -0
  341. package/src/store/kv.ts +256 -0
  342. package/src/store/level.ts +167 -0
  343. package/src/store/memory.ts +109 -0
  344. package/src/store/sql.test.ts +204 -0
  345. package/src/store/sql.ts +324 -0
  346. package/src/testing/fake.ts +297 -0
  347. package/src/testing/index.ts +15 -0
  348. package/src/testing/mock-adapter.ts +588 -0
  349. package/src/util/deep.ts +272 -0
  350. package/src/util/defer.ts +211 -0
  351. package/src/util/dispose.ts +169 -0
  352. package/src/util/duration.ts +82 -0
  353. package/src/util/fs.ts +279 -0
  354. package/src/util/id.ts +94 -0
  355. package/src/util/lru.ts +185 -0
  356. package/src/util/queue.ts +167 -0
  357. package/src/util/text.ts +189 -0
@@ -0,0 +1,1386 @@
1
+ /**
2
+ * 模块职责:共享 HTTP / WebSocket 服务器 —— 面板、插件路由、适配器 webhook 共用一个端口
3
+ * 依赖方向:依赖 fastify 与本目录的 auth/files/table;不认识任何插件,也不认识面板前端
4
+ * 生命周期:`createManagedServer()` 建好但不监听;`listen()` 由 `App.start()` 在插件
5
+ * 全部装完之后调用;`close()` 幂等,由 `App.own()` 登记
6
+ * 注意事项:四个不显而易见的决定 ——
7
+ *
8
+ * **Fastify 上只注册 4 条 catch-all 路由**(`/` 与 `/*` 各两条),真正的分发走 table.ts 的
9
+ * `PathTable` —— find-my-way 没有删除单条路由的 API,而「插件能装能卸能热重载」是核心不变量。
10
+ *
11
+ * **鉴权放在 `onRequest` 钩子里**,不放在处理函数里。fastify 在 `handle-request` 开头就判
12
+ * `reply.sent`,故在 `onRequest` 里回了响应,body 解析与路由处理都不会发生 —— 未鉴权的请求
13
+ * 不该有机会往本进程内存写 64MB。
14
+ *
15
+ * **WebSocket 的拒绝也必须在 `onRequest` 里做。** `@fastify/websocket` 的 `onUpgrade` 会先建
16
+ * 响应对象再调 `fastify.routing()`,此时回响应得到的是一个真正的 401/404,对方看得到原因;
17
+ * 等进了 `wsHandler` 再关连接,对方只看到一次没有理由的断线。
18
+ *
19
+ * **静态资源刻意不鉴权。** 浏览器请求文档时设不了请求头,要鉴权就只剩 Cookie 一条路,
20
+ * 那会把 auth.ts 刻意避开的 CSRF 面重新引进来。HTML/JS 本身不是机密,要保护的是 API。
21
+ */
22
+ import { isAbsolute } from "node:path"
23
+ import type { IncomingMessage } from "node:http"
24
+ import Fastify, { type FastifyInstance, type FastifyReply, type FastifyRequest } from "fastify"
25
+ import fastifyStatic from "@fastify/static"
26
+ import fastifyWebsocket from "@fastify/websocket"
27
+ import type {
28
+ Awaitable,
29
+ Disposer,
30
+ HttpMethod,
31
+ Logger,
32
+ RouteHandler,
33
+ RouteOptions,
34
+ RouteRequest,
35
+ RouteResponse,
36
+ ServerInfo,
37
+ WebSocketConnection,
38
+ WebSocketHandler,
39
+ WebSocketOptions
40
+ } from "@yunzai-ng/types"
41
+ import type { CoreConfigHandle } from "../config/core-config.js"
42
+ import type { ServerSink } from "../plugin/hooks.js"
43
+ import { createSeqFactory } from "../util/id.js"
44
+ import { formatBytes } from "../util/duration.js"
45
+ import {
46
+ WS_PROTOCOL,
47
+ checkAuth,
48
+ checkForgeableBody,
49
+ generateToken,
50
+ isLocalHost,
51
+ type AuthRequest
52
+ } from "./auth.js"
53
+ import { resolveStaticFile, type StaticMount } from "./files.js"
54
+ import { HTTP_METHODS, PathTable, isHttpMethod, joinPattern, splitSegments, type PathHit } from "./table.js"
55
+
56
+ /** 单条路由未声明 `bodyLimit` 时的请求体上限 */
57
+ const DEFAULT_BODY_LIMIT = 1024 * 1024
58
+
59
+ /**
60
+ * 任何路由都不得超过的请求体上限
61
+ *
62
+ * 同时作为 Fastify 实例级的 `bodyLimit`:实际的限流在本模块自有的解析器中按路由施加,
63
+ * 此处仅为最后一道兜底,防止某个路由将 `bodyLimit` 设为 `Infinity`。
64
+ */
65
+ const MAX_BODY_LIMIT = 64 * 1024 * 1024
66
+
67
+ /** 静态挂载的通配参数名,`/webui/*rest` 里的 `rest` */
68
+ const STATIC_REST = "rest"
69
+
70
+ /** `ws` 的 `readyState` 里代表"已连通"的值 */
71
+ const WS_OPEN = 1
72
+
73
+ /** `RouteResponse` 允许出现的键;出现其他键即表明该对象实为业务数据 */
74
+ const RESPONSE_KEYS = new Set(["status", "headers", "body"])
75
+
76
+ /** 一条 HTTP 路由的注册信息 */
77
+ interface RouteEntry {
78
+ /** 注册方的 URL 前缀,通常带插件名;面板据此显示归属 */
79
+ readonly scope: string
80
+ /** 处理函数 */
81
+ readonly handler: RouteHandler
82
+ /** 是否需要鉴权 */
83
+ readonly auth: boolean
84
+ /** 请求体上限(字节) */
85
+ readonly limit: number
86
+ /** 是否把原始字节一并交给处理函数 */
87
+ readonly rawBody: boolean
88
+ }
89
+
90
+ /** 一个 WebSocket 端点的注册信息 */
91
+ interface WsEntry {
92
+ /** 注册方的 URL 前缀 */
93
+ readonly scope: string
94
+ /** 处理函数 */
95
+ readonly handler: WebSocketHandler
96
+ /** 自定义校验;`undefined` 表示沿用面板令牌校验 */
97
+ readonly verify: ((req: RouteRequest) => Awaitable<boolean>) | undefined
98
+ }
99
+
100
+ /**
101
+ * 一次请求的查表结果
102
+ *
103
+ * 之所以要缓存:鉴权(`onRequest`)、请求体限流(内容解析器)、真正分发(路由处理
104
+ * 函数)是三个不同阶段,它们必须看到**同一次**查表结果。否则一次插件热重载正好卡在
105
+ * 中间,就会出现"按 A 路由鉴权、按 B 路由分发"这种既难复现又致命的错位。
106
+ */
107
+ interface RequestContext {
108
+ /** 归一化后的请求方法 */
109
+ readonly method: HttpMethod
110
+ /** 去掉查询串的路径 */
111
+ readonly path: string
112
+ /** 已解码的路径段 */
113
+ readonly parts: readonly string[]
114
+ /** 命中的 HTTP 路由 */
115
+ readonly route: PathHit<RouteEntry> | undefined
116
+ /** 命中的静态挂载 */
117
+ readonly mount: PathHit<StaticMount> | undefined
118
+ /** 命中的 WebSocket 端点 */
119
+ readonly ws: PathHit<WsEntry> | undefined
120
+ /** 本次请求适用的请求体上限 */
121
+ readonly limit: number
122
+ /** 本次请求是否要保留原始字节 */
123
+ readonly rawBody: boolean
124
+ }
125
+
126
+ /** 带状态码的错误,`setErrorHandler` 靠它决定回什么码 */
127
+ interface HttpError extends Error {
128
+ /** 建议的 HTTP 状态码 */
129
+ statusCode: number
130
+ }
131
+
132
+ /** Fastify 内置 JSON 解析器的回调式签名 */
133
+ type JsonParser = (
134
+ request: FastifyRequest,
135
+ body: string,
136
+ done: (err: Error | null, body?: unknown) => void
137
+ ) => void
138
+
139
+ /**
140
+ * `ws` 原生连接里本文件真正用到的部分
141
+ *
142
+ * 为什么自己声明而不是 `import type { WebSocket } from "ws"`:`ws` 不自带类型声明,
143
+ * 仓库里也没装 `@types/ws`。`@fastify/websocket` 内部 `import * as WebSocket from "ws"`,
144
+ * 在 `skipLibCheck` 下退化成 `any`,因此这个最小接口可以直接赋给它的 `wsHandler` ——
145
+ * 既不用装一个只为编译服务的类型包,也不用在代码里写 `any`。
146
+ */
147
+ interface RawSocket {
148
+ /** 连接状态,`1` 为已连通 */
149
+ readonly readyState: number
150
+
151
+ /**
152
+ * 发一帧
153
+ * @param data 文本或二进制数据
154
+ */
155
+ send(data: string | Uint8Array): void
156
+
157
+ /**
158
+ * 正常关闭
159
+ * @param code 关闭码
160
+ * @param reason 关闭原因
161
+ */
162
+ close(code?: number, reason?: string): void
163
+
164
+ /** 直接掐断,不走关闭握手 */
165
+ terminate(): void
166
+
167
+ /**
168
+ * 监听收到的帧
169
+ * @param event 固定为 `"message"`
170
+ * @param cb 回调,第二个参数标明是否为二进制帧
171
+ */
172
+ on(event: "message", cb: (data: unknown, isBinary: boolean) => void): void
173
+
174
+ /**
175
+ * 监听关闭
176
+ * @param event 固定为 `"close"`
177
+ * @param cb 回调
178
+ */
179
+ on(event: "close", cb: (code: number, reason: unknown) => void): void
180
+
181
+ /**
182
+ * 监听错误
183
+ * @param event 固定为 `"error"`
184
+ * @param cb 回调
185
+ */
186
+ on(event: "error", cb: (err: unknown) => void): void
187
+ }
188
+
189
+ /** 面板"路由一览"里的一条 HTTP 路由 */
190
+ export interface RouteSummary {
191
+ /** 请求方法 */
192
+ readonly method: HttpMethod
193
+ /** URL 模式 */
194
+ readonly pattern: string
195
+ /** 注册方前缀 */
196
+ readonly scope: string
197
+ /** 是否需要鉴权 */
198
+ readonly auth: boolean
199
+ }
200
+
201
+ /** 面板"路由一览"里的一个 WebSocket 端点 */
202
+ export interface WebSocketSummary {
203
+ /** URL 模式 */
204
+ readonly pattern: string
205
+ /** 注册方前缀 */
206
+ readonly scope: string
207
+ }
208
+
209
+ /** 面板"路由一览"里的一个静态挂载 */
210
+ export interface StaticSummary {
211
+ /** URL 模式 */
212
+ readonly pattern: string
213
+ /** 本地目录 */
214
+ readonly dir: string
215
+ /** 是否单页回退 */
216
+ readonly spa: boolean
217
+ }
218
+
219
+ /** 创建共享服务器的参数 */
220
+ export interface ManagedServerOptions {
221
+ /** 内核配置句柄,监听地址、端口与访问令牌都从这里读 */
222
+ readonly config: CoreConfigHandle
223
+ /** 日志器 */
224
+ readonly logger: Logger
225
+ }
226
+
227
+ /**
228
+ * 取错误的可读文本
229
+ *
230
+ * 内核没有统一的错误文案工具(pipeline/router.ts 也是就地内联的),这里同样自备一个,
231
+ * 免得为一行三元表达式在 util 里加一个模块。
232
+ * @param err 任意抛出物
233
+ * @returns 可读文本
234
+ */
235
+ function messageOf(err: unknown): string {
236
+ return err instanceof Error ? err.message : String(err)
237
+ }
238
+
239
+ /**
240
+ * 去掉查询串与锚点,只留路径
241
+ * @param url `request.url`,形如 `/api/config?x=1`
242
+ * @returns 路径部分
243
+ */
244
+ function pathOf(url: string): string {
245
+ const cut = url.search(/[?#]/)
246
+ return cut < 0 ? url : url.slice(0, cut)
247
+ }
248
+
249
+ /**
250
+ * 取 TCP 对端地址
251
+ *
252
+ * 刻意使用 `request.socket.remoteAddress` 而非 `request.ip`:后者在 `trustProxy` 开启
253
+ * 时会变为 `X-Forwarded-For` 的值。鉴权的"是否本机"判断绝不能被一个请求头左右,
254
+ * 因此此处绕开 Fastify 的加工,直接向内核获取对端地址。
255
+ *
256
+ * 使用 `?.` 是必要的:WebSocket 升级经由 `fastify.routing()` 而非正常的请求流程,
257
+ * 注入式测试(`injectWS`)伪造的 raw request 上并不存在 `socket`。无法取得时返回空串,
258
+ * 于是 `isLoopbackAddress("")` 为 false —— 无法确定时按"非本机"处理,是安全的一侧。
259
+ * @param request Fastify 请求
260
+ * @returns 对端地址;取不到时空串
261
+ */
262
+ function ipOf(request: FastifyRequest): string {
263
+ return request.socket?.remoteAddress ?? ""
264
+ }
265
+
266
+ /**
267
+ * 取请求头的第一个值
268
+ * @param value 头值
269
+ * @returns 首个值;不存在时 undefined
270
+ */
271
+ function firstHeader(value: string | string[] | undefined): string | undefined {
272
+ if (value === undefined) return undefined
273
+ return Array.isArray(value) ? value[0] : value
274
+ }
275
+
276
+ /**
277
+ * 从 `Content-Type` 里取出媒体类型本身
278
+ * @param header 头值
279
+ * @returns 已小写化的媒体类型;没有时空串
280
+ */
281
+ function mediaTypeOf(header: string | undefined): string {
282
+ return (header ?? "").split(";")[0]?.trim().toLowerCase() ?? ""
283
+ }
284
+
285
+ /**
286
+ * 判断是否是一个 WebSocket 升级请求
287
+ *
288
+ * 不能只看 `request.ws`:`fastify.addHook()` 立即生效,而 `fastify.register()` 要等到
289
+ * `ready()` 才启动插件,因此本服务器的 `onRequest` 钩子**排在 `@fastify/websocket`
290
+ * 自己那个钩子之前**,那时 `request.ws` 还没被赋值。所以以请求头为准,
291
+ * `request.ws` 只当快捷路径。
292
+ * @param request Fastify 请求
293
+ * @returns 是否为升级请求
294
+ */
295
+ function isUpgradeRequest(request: FastifyRequest): boolean {
296
+ if (request.ws === true) return true
297
+ const upgrade = firstHeader(request.headers.upgrade)
298
+ if (upgrade === undefined || upgrade.toLowerCase() !== "websocket") return false
299
+ return (firstHeader(request.headers.connection) ?? "").toLowerCase().includes("upgrade")
300
+ }
301
+
302
+ /**
303
+ * 把监听地址转成"能点开"的形式
304
+ *
305
+ * `0.0.0.0` 与 `::` 是通配监听地址,不是可访问地址。日志里印一条
306
+ * `http://0.0.0.0:2536` 等于给用户一个点不开的链接。
307
+ * @param host 配置里的监听地址
308
+ * @returns 可放进 URL 的主机名
309
+ */
310
+ function displayHost(host: string): string {
311
+ if (host === "" || host === "0.0.0.0") return "127.0.0.1"
312
+ if (host === "::" || host === "::0") return "[::1]"
313
+ return host.includes(":") ? `[${host}]` : host
314
+ }
315
+
316
+ /**
317
+ * 造一个带状态码的错误
318
+ * @param status HTTP 状态码
319
+ * @param message 错误文案,会直接回给对方
320
+ * @returns 错误对象
321
+ */
322
+ function httpError(status: number, message: string): HttpError {
323
+ const err = new Error(message) as HttpError
324
+ err.statusCode = status
325
+ return err
326
+ }
327
+
328
+ /**
329
+ * 从错误里取状态码
330
+ *
331
+ * 只认 400–599:插件把 `statusCode` 写成 `0` 或 `200` 时,回一个"成功"的错误响应
332
+ * 远比回 500 更难排查。
333
+ * @param err 任意抛出物
334
+ * @returns 状态码;不合法时 500
335
+ */
336
+ function statusOf(err: unknown): number {
337
+ if (typeof err !== "object" || err === null) return 500
338
+ const code = (err as { statusCode?: unknown }).statusCode
339
+ if (typeof code !== "number" || !Number.isInteger(code)) return 500
340
+ return code >= 400 && code < 600 ? code : 500
341
+ }
342
+
343
+ /**
344
+ * 判断处理函数的返回值是否为一个 `RouteResponse` 信封
345
+ *
346
+ * `RouteHandler` 允许直接以 `return { ok: true }` 作为响应体,亦允许
347
+ * `return { status: 201, body: x }` 指定状态码,二者必须可以区分。判据是**键集合完全
348
+ * 落在 `status`/`headers`/`body` 之内**:业务对象只要多出一个字段便不会被误判。
349
+ * 确需返回一个恰好只含这三个键的业务对象时,外层包裹一层 `{ body: 该对象 }` 即可。
350
+ * @param value 处理函数的返回值
351
+ * @returns 是否为信封
352
+ */
353
+ function isRouteResponse(value: unknown): value is RouteResponse<unknown> {
354
+ if (typeof value !== "object" || value === null) return false
355
+ if (Array.isArray(value) || value instanceof Uint8Array) return false
356
+ const keys = Object.keys(value)
357
+ if (keys.length === 0) return false
358
+ if (!keys.every(key => RESPONSE_KEYS.has(key))) return false
359
+ const rec = value as Record<string, unknown>
360
+ if (rec.status !== undefined && typeof rec.status !== "number") return false
361
+ if (rec.headers !== undefined && (typeof rec.headers !== "object" || rec.headers === null)) return false
362
+ return true
363
+ }
364
+
365
+ /**
366
+ * 把响应体转成 Fastify 认得的形态
367
+ *
368
+ * Fastify 只对 `Buffer.isBuffer()` 为真的值走二进制直发。一个普通 `Uint8Array`
369
+ * 会被当成对象 JSON 序列化成 `{"0":137,"1":80,…}`,而且状态码还是 200 ——
370
+ * 这种故障从现象追回类型问题极其费时,所以在这里就地转掉。
371
+ * @param body 处理函数给的响应体
372
+ * @returns 可交给 `reply.send()` 的值
373
+ */
374
+ function toPayload(body: unknown): unknown {
375
+ if (body instanceof Uint8Array && !Buffer.isBuffer(body)) {
376
+ return Buffer.from(body.buffer, body.byteOffset, body.byteLength)
377
+ }
378
+ return body
379
+ }
380
+
381
+ /**
382
+ * 按处理函数的返回值回响应
383
+ * @param reply Fastify 响应
384
+ * @param result 处理函数的返回值
385
+ * @returns 同一个 `reply`,供处理函数 `return`
386
+ */
387
+ function sendResult(reply: FastifyReply, result: unknown): FastifyReply {
388
+ if (result === undefined) return reply.code(204).send()
389
+ if (isRouteResponse(result)) {
390
+ const { status, headers, body } = result
391
+ reply.code(status ?? (body === undefined ? 204 : 200))
392
+ if (headers !== undefined) reply.headers(headers)
393
+ return body === undefined ? reply.send() : reply.send(toPayload(body))
394
+ }
395
+ return reply.send(toPayload(result))
396
+ }
397
+
398
+ /**
399
+ * 解析表单编码的请求体
400
+ *
401
+ * 用 `Object.create(null)` 而不是 `{}`:对字面量对象写 `out["__proto__"] = x` 会真的
402
+ * 改掉它的原型,一个表单字段就能污染下游所有属性查找。
403
+ * @param text 表单文本
404
+ * @returns 无原型的键值对
405
+ */
406
+ function formToObject(text: string): Record<string, string> {
407
+ const out = Object.create(null) as Record<string, string>
408
+ for (const [key, value] of new URLSearchParams(text)) out[key] = value
409
+ return out
410
+ }
411
+
412
+ /**
413
+ * 把 `ws` 给的各种数据形态统一成 Buffer
414
+ * @param data `ws` 的 message 回调第一个参数
415
+ * @returns 字节
416
+ */
417
+ function toBytes(data: unknown): Buffer {
418
+ if (Buffer.isBuffer(data)) return data
419
+ if (Array.isArray(data)) return Buffer.concat(data.filter((part): part is Buffer => Buffer.isBuffer(part)))
420
+ if (data instanceof ArrayBuffer) return Buffer.from(data)
421
+ if (data instanceof Uint8Array) return Buffer.from(data.buffer, data.byteOffset, data.byteLength)
422
+ return Buffer.from(String(data), "utf8")
423
+ }
424
+
425
+ /**
426
+ * 把关闭原因统一成字符串
427
+ * @param reason `ws` 的 close 回调第二个参数
428
+ * @returns 原因文本;没有时空串
429
+ */
430
+ function reasonOf(reason: unknown): string {
431
+ if (typeof reason === "string") return reason
432
+ if (Buffer.isBuffer(reason)) return reason.toString("utf8")
433
+ return ""
434
+ }
435
+
436
+ /**
437
+ * 将一条 `ws` 原生连接封装为 `WebSocketConnection`
438
+ * @param socket `ws` 原生连接
439
+ * @param id 连接编号
440
+ * @param ip 对端地址
441
+ * @param headers 握手请求头
442
+ * @param log 日志器
443
+ * @returns 交给插件的连接对象
444
+ */
445
+ function wrapConnection(
446
+ socket: RawSocket,
447
+ id: string,
448
+ ip: string,
449
+ headers: Readonly<Record<string, string | string[] | undefined>>,
450
+ log: Logger
451
+ ): WebSocketConnection {
452
+ /** 是否仍认为连接可用 */
453
+ let open = true
454
+ const messageCbs = new Set<(data: string | Uint8Array, isBinary: boolean) => void>()
455
+ const closeCbs = new Set<(code: number, reason: string) => void>()
456
+ const errorCbs = new Set<(err: Error) => void>()
457
+
458
+ /*
459
+ * 回调收进 Set 由单一监听分发,而不是每个插件各注册一次
460
+ *
461
+ * 于是 `onMessage()` 的 Disposer 只是从 Set 里删一项 —— 不需要 `off`(`RawSocket` 因而
462
+ * 不必声明它),也不会因一条连接上注册了十几个插件而触发 Node 的 MaxListeners 警告。
463
+ */
464
+
465
+ /**
466
+ * 把一个事件分发给一组回调
467
+ * @param set 回调集合
468
+ * @param args 回调实参
469
+ */
470
+ const fire = <A extends unknown[]>(set: ReadonlySet<(...args: A) => void>, ...args: A): void => {
471
+ // 遍历副本:在回调里调用自身的 Disposer 是常见写法,直接遍历原 Set 会让迭代器行为取决于
472
+ // 删除时机。抛错只记日志不外传 —— 单个插件的缺陷不该中断连接,更不该沿 ws 的事件循环
473
+ // 变成 uncaught exception
474
+ for (const cb of [...set]) {
475
+ try {
476
+ cb(...args)
477
+ } catch (err) {
478
+ log.error(`WebSocket ${id} 的回调抛错:${messageOf(err)}`)
479
+ }
480
+ }
481
+ }
482
+
483
+ socket.on("message", (data: unknown, isBinary: boolean) => {
484
+ const bytes = toBytes(data)
485
+ fire(messageCbs, isBinary ? bytes : bytes.toString("utf8"), isBinary)
486
+ })
487
+
488
+ socket.on("close", (code: number, reason: unknown) => {
489
+ open = false
490
+ fire(closeCbs, typeof code === "number" ? code : 1006, reasonOf(reason))
491
+ // 连接已经结束,留着回调只会把插件闭包连带的对象一直吊在内存里
492
+ messageCbs.clear()
493
+ closeCbs.clear()
494
+ errorCbs.clear()
495
+ })
496
+
497
+ socket.on("error", (err: unknown) => {
498
+ const error = err instanceof Error ? err : new Error(String(err))
499
+ // 没人关心时也要留一条痕迹:服务器是 `logger: false` 建的,fastify 自己不会记
500
+ if (errorCbs.size === 0) log.debug(`WebSocket ${id} 出错:${error.message}`)
501
+ fire(errorCbs, error)
502
+ })
503
+
504
+ return {
505
+ id,
506
+ ip,
507
+ headers,
508
+
509
+ get open(): boolean {
510
+ return open && socket.readyState === WS_OPEN
511
+ },
512
+
513
+ send(data: string | Uint8Array): void {
514
+ if (!open || socket.readyState !== WS_OPEN) {
515
+ // 静默丢弃会让"消息发不出去"变成一个查不到的现象,至少留条 debug
516
+ log.debug(`WebSocket ${id} 已不可用,丢弃一条待发消息`)
517
+ return
518
+ }
519
+ socket.send(data)
520
+ },
521
+
522
+ close(code?: number, reason?: string): void {
523
+ open = false
524
+ socket.close(code, reason)
525
+ },
526
+
527
+ onMessage(cb: (data: string | Uint8Array, isBinary: boolean) => void): Disposer {
528
+ messageCbs.add(cb)
529
+ return () => {
530
+ messageCbs.delete(cb)
531
+ }
532
+ },
533
+
534
+ onClose(cb: (code: number, reason: string) => void): Disposer {
535
+ closeCbs.add(cb)
536
+ return () => {
537
+ closeCbs.delete(cb)
538
+ }
539
+ },
540
+
541
+ onError(cb: (err: Error) => void): Disposer {
542
+ errorCbs.add(cb)
543
+ return () => {
544
+ errorCbs.delete(cb)
545
+ }
546
+ }
547
+ }
548
+ }
549
+
550
+ /**
551
+ * 共享 HTTP / WebSocket 服务器
552
+ *
553
+ * 实现 `ServerSink`,因此 `ctx.route()` / `ctx.websocket()` / `ctx.static()` 最终都落到
554
+ * 这里。面板自己的 API 也只是"一个 scope 为 `/api` 的普通注册方",没有任何特权 ——
555
+ * 这是"一切皆可为插件"在服务器层的体现。
556
+ */
557
+ export class ManagedServer implements ServerSink {
558
+ /** Fastify 实例 */
559
+ readonly #fastify: FastifyInstance
560
+ /** 内核配置句柄 */
561
+ readonly #config: CoreConfigHandle
562
+ /** 日志器 */
563
+ readonly #logger: Logger
564
+ /** 方法 → 该方法下的路径表 */
565
+ readonly #routes = new Map<HttpMethod, PathTable<RouteEntry>>()
566
+ /** WebSocket 端点表 */
567
+ readonly #ws = new PathTable<WsEntry>()
568
+ /** 静态挂载表 */
569
+ readonly #static = new PathTable<StaticMount>()
570
+ /** 请求 → 查表结果;使用 WeakMap 以免请求对象被本表长期持有 */
571
+ readonly #contexts = new WeakMap<FastifyRequest, RequestContext>()
572
+ /** 请求 → 原始请求体,只在路由声明了 `rawBody` 时写入 */
573
+ readonly #rawBodies = new WeakMap<FastifyRequest, Buffer>()
574
+ /** 当前保持的连接,面板需展示数量,`close()` 需清空 */
575
+ readonly #connections = new Set<WebSocketConnection>()
576
+ /** 连接编号发号器 */
577
+ readonly #nextConnId = createSeqFactory("ws-")
578
+ /** Fastify 内置的 JSON 解析器,带原型污染防护 */
579
+ readonly #jsonParser: JsonParser
580
+ /** 是否已在监听 */
581
+ #listening = false
582
+ /** 是否已关闭 */
583
+ #closed = false
584
+ /** 实际监听到的端口,`listen()` 之后才有值 */
585
+ #port = 0
586
+
587
+ /**
588
+ * 建好实例但不监听
589
+ *
590
+ * **不要直接 `new`**,用 `createManagedServer()`:路由必须等到两个 Fastify 插件启动
591
+ * 完成之后才能注册(原因见 `#boot()`),而那是一个异步过程,构造函数里做不到。
592
+ * @param opts 构造参数
593
+ */
594
+ constructor(opts: ManagedServerOptions) {
595
+ this.#config = opts.config
596
+ this.#logger = opts.logger
597
+ this.#fastify = Fastify({
598
+ // 日志走内核自己的 logger;开着 Fastify 的会出现两套格式两份文件
599
+ logger: false,
600
+ // 必须为 false:auth.ts 第 4 条的"是否本机"判断依赖真实 TCP 对端地址
601
+ trustProxy: false,
602
+ // 本服务器已显式注册 HEAD,若再由 Fastify 自动生成一条将冲突为 duplicate route
603
+ exposeHeadRoutes: false,
604
+ bodyLimit: MAX_BODY_LIMIT
605
+ })
606
+ this.#jsonParser = this.#fastify.getDefaultJsonParser("remove", "remove") as JsonParser
607
+ this.#registerPlugins()
608
+ }
609
+
610
+ /**
611
+ * 建一个已就绪的服务器
612
+ * @param opts 构造参数
613
+ * @returns 已 `ready()` 的服务器
614
+ */
615
+ static async create(opts: ManagedServerOptions): Promise<ManagedServer> {
616
+ const server = new ManagedServer(opts)
617
+ await server.#boot()
618
+ return server
619
+ }
620
+
621
+ /**
622
+ * 启动两个 Fastify 插件,然后装上钩子与那 4 条 catch-all 路由
623
+ *
624
+ * **`await fastify.after()` 这一步不能省。** `fastify.route()` 会**同步**触发
625
+ * `onRoute` 钩子(见 fastify 的 `lib/route.js` 里 `addNewRoute` 直接
626
+ * `for (const hook of this[kHooks].onRoute)`),而 `@fastify/websocket` 的 `onRoute`
627
+ * 钩子是在其自身启动时方才装上的。若先注册路由,该钩子将完全观察不到本服务器的路由,
628
+ * 于是路由的 handler 不会被包上"升级分支" —— 表现为所有 WebSocket 握手均得到一个
629
+ * 普通 HTTP 响应,且完全不报错。
630
+ * @returns 完成时 resolve
631
+ */
632
+ async #boot(): Promise<void> {
633
+ await this.#fastify.after()
634
+ this.#installRoutes()
635
+ await this.#fastify.ready()
636
+ }
637
+
638
+ /**
639
+ * 注册 `@fastify/static` 与 `@fastify/websocket`
640
+ *
641
+ * 两个 `register()` 都是延迟的,真正启动要等 `after()` / `ready()`。
642
+ */
643
+ #registerPlugins(): void {
644
+ const fastify = this.#fastify
645
+
646
+ // `serve: false` 让它一条路由都不注册,只装饰出 `reply.sendFile()`。
647
+ // 它默认会占用 `/*`,而 `/*` 正是本服务器自己的总入口,见 files.ts 文件头
648
+ fastify.register(fastifyStatic, { serve: false, decorateReply: true })
649
+
650
+ fastify.register(fastifyWebsocket, {
651
+ options: {
652
+ // 浏览器将令牌置于子协议中(见 auth.ts 第 2 条)。返回 `WS_PROTOCOL` 即向
653
+ // 浏览器表明"该子协议被接受",否则 `new WebSocket(url, [...])` 会立即失败
654
+ handleProtocols: (protocols: Set<string>): string | false =>
655
+ protocols.has(WS_PROTOCOL) ? WS_PROTOCOL : false
656
+ },
657
+ // 服务器以 `logger: false` 创建,插件内部的 `fastify.log.error` 会被丢弃,
658
+ // 此处将其接回内核日志
659
+ errorHandler: (error: Error, socket: RawSocket): void => {
660
+ this.#logger.error(`WebSocket 连接出错:${error.message}`)
661
+ socket.terminate()
662
+ }
663
+ })
664
+ }
665
+
666
+ /**
667
+ * 装上请求体解析器、钩子、错误处理与那 4 条 catch-all 路由
668
+ *
669
+ * 必须在两个插件启动之后调用,见 `#boot()`。
670
+ */
671
+ #installRoutes(): void {
672
+ const fastify = this.#fastify
673
+
674
+ fastify.removeAllContentTypeParsers()
675
+ fastify.addContentTypeParser(
676
+ "*",
677
+ (request: FastifyRequest, payload: IncomingMessage, done: (err: Error | null, body?: unknown) => void): void => {
678
+ this.#parseBody(request, payload, done)
679
+ }
680
+ )
681
+
682
+ fastify.addHook("onRequest", async (request: FastifyRequest, reply: FastifyReply) =>
683
+ this.#onRequest(request, reply)
684
+ )
685
+
686
+ // `reply.sendFile()` 遇到 ENOENT 会走 `callNotFound()`,加上这个处理器是为了让
687
+ // 404 也是 `{ error: string }` —— 面板前端只需要认一种错误形状
688
+ fastify.setNotFoundHandler((request: FastifyRequest, reply: FastifyReply) =>
689
+ this.#reject(reply, 404, `路由 ${pathOf(request.url)} 不存在`)
690
+ )
691
+
692
+ fastify.setErrorHandler((err: unknown, request: FastifyRequest, reply: FastifyReply) => {
693
+ const status = statusOf(err)
694
+ // 4xx 源于请求方,记录日志只会被扫描器填满;5xx 源于本服务器,必须记录
695
+ if (status >= 500) {
696
+ this.#logger.error(`处理 ${request.method} ${pathOf(request.url)} 出错:${messageOf(err)}`)
697
+ }
698
+ return this.#reject(reply, status, messageOf(err))
699
+ })
700
+
701
+ const handler = async (request: FastifyRequest, reply: FastifyReply): Promise<FastifyReply> =>
702
+ this.#dispatch(request, reply)
703
+ const wsHandler = (socket: RawSocket, request: FastifyRequest): void => {
704
+ this.#openConnection(socket, request)
705
+ }
706
+ // GET 之外的方法不能带 wsHandler:`@fastify/websocket` 的 onRoute 钩子会抛
707
+ // `websocket handler can only be declared in GET method`
708
+ const others: HttpMethod[] = ["POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"]
709
+
710
+ for (const url of ["/", "/*"]) {
711
+ fastify.route({ method: "GET", url, handler, wsHandler })
712
+ fastify.route({ method: others, url, handler })
713
+ }
714
+ }
715
+
716
+ /**
717
+ * 当前服务器状态
718
+ *
719
+ * 刻意做成 getter 而不是构造时算好的常量:`port: 0` 时内核会拿到一个随机端口,
720
+ * 只有 `listen()` 之后才知道是多少,而测试与面板都要显示真实端口。
721
+ * @returns 服务器信息
722
+ */
723
+ get info(): ServerInfo {
724
+ const cfg = this.#config.get().server
725
+ const port = this.#port === 0 ? cfg.port : this.#port
726
+ return {
727
+ enabled: this.#listening,
728
+ host: cfg.host,
729
+ port,
730
+ publicUrl: cfg.publicUrl ?? `http://${displayHost(cfg.host)}:${port}`
731
+ }
732
+ }
733
+
734
+ /** 当前保持连接的 WebSocket 数量 */
735
+ get connections(): number {
736
+ return this.#connections.size
737
+ }
738
+
739
+ /**
740
+ * 原生 Fastify 实例
741
+ *
742
+ * 保留给两种场合:测试中的 `inject()` / `injectWS()`,以及插件确实需要 Fastify 原生
743
+ * 能力(multipart、SSE、自定义序列化)之时。**直接在其上注册的路由无法摘除**,
744
+ * 插件热重载后会残留为失效路由,因此除已确认可接受该后果外,应使用 `route()`。
745
+ * @returns Fastify 实例
746
+ */
747
+ get raw(): FastifyInstance {
748
+ return this.#fastify
749
+ }
750
+
751
+ /**
752
+ * 非本机监听时确保有访问令牌
753
+ *
754
+ * 只监听本机就不生成 —— 单机用户不该为了打开面板先去配置文件里抄一串随机字符。
755
+ * 一旦监听地址对外,没有令牌等于把配置写权限公开,所以这里**自动生成并落盘**,
756
+ * 而不是只打一句警告了事。
757
+ * @returns 完成时 resolve
758
+ */
759
+ async ensureToken(): Promise<void> {
760
+ const cfg = this.#config.get().server
761
+ if (cfg.token !== undefined && cfg.token !== "") return
762
+ if (isLocalHost(cfg.host)) return
763
+
764
+ const token = generateToken()
765
+ await this.#config.patch({ server: { token } }, "api")
766
+ // 明文输出一次是必要的:使用者此时即需以其登录面板,且它本已写入配置文件
767
+ this.#logger.warn(`面板监听于 ${cfg.host},已自动生成访问令牌:${token}`)
768
+ this.#logger.warn("该令牌已写入配置项 server.token,可随时自行修改")
769
+ }
770
+
771
+ /**
772
+ * 开始监听
773
+ *
774
+ * 由 `App.start()` 在插件全部装完之后调用:那时路由才齐,也不会出现"面板半截可用"
775
+ * 的窗口期。
776
+ * @returns 完成时 resolve
777
+ * @throws 服务器已关闭、或端口被占用时
778
+ */
779
+ async listen(): Promise<void> {
780
+ if (this.#closed) throw new Error("服务器已关闭,不能再监听")
781
+ if (this.#listening) return
782
+
783
+ const cfg = this.#config.get().server
784
+ await this.#fastify.listen({ host: cfg.host, port: cfg.port })
785
+ const addr = this.#fastify.server.address()
786
+ this.#port = typeof addr === "object" && addr !== null ? addr.port : cfg.port
787
+ this.#listening = true
788
+ this.#logger.info(`面板与插件路由已就绪:${this.info.publicUrl}`)
789
+ }
790
+
791
+ /**
792
+ * 关闭服务器
793
+ *
794
+ * 幂等。清表置于 `finally` 中:`fastify.close()` 抛错(例如某个 `preClose` 钩子出现问题)
795
+ * 亦不应残留大量指向已卸载插件闭包的注册项。
796
+ * @returns 完成时 resolve
797
+ */
798
+ async close(): Promise<void> {
799
+ if (this.#closed) return
800
+ this.#closed = true
801
+ try {
802
+ await this.#fastify.close()
803
+ } finally {
804
+ for (const table of this.#routes.values()) table.clear()
805
+ this.#ws.clear()
806
+ this.#static.clear()
807
+ this.#connections.clear()
808
+ this.#listening = false
809
+ }
810
+ }
811
+
812
+ /**
813
+ * 注册一条 HTTP 路由
814
+ * @param scope URL 前缀,通常是 `/plugin/<插件名>`
815
+ * @param method 请求方法,大小写不敏感
816
+ * @param path 相对 scope 的路径,可含 `:id` 与末尾的 `*rest`
817
+ * @param handler 处理函数
818
+ * @param opts 路由选项;`auth` 缺省为 true
819
+ * @returns 注销句柄;幂等
820
+ * @throws 服务器已关闭、方法不受支持、或同一模式重复注册时
821
+ */
822
+ route(
823
+ scope: string,
824
+ method: HttpMethod,
825
+ path: string,
826
+ handler: RouteHandler,
827
+ opts: RouteOptions = {}
828
+ ): Disposer {
829
+ this.#assertOpen()
830
+ const key = method.toUpperCase()
831
+ if (!isHttpMethod(key)) throw new Error(`不支持的请求方法 ${method}`)
832
+
833
+ const entry: RouteEntry = {
834
+ scope,
835
+ handler,
836
+ // 默认要鉴权:插件作者忘了写选项时,安全的那一侧才该是默认值
837
+ auth: opts.auth !== false,
838
+ limit: this.#clampLimit(opts.bodyLimit),
839
+ rawBody: opts.rawBody === true
840
+ }
841
+ return this.#tableOf(key).add(joinPattern(scope, path), entry)
842
+ }
843
+
844
+ /**
845
+ * 注册一个 WebSocket 端点
846
+ *
847
+ * 不给 `verify` 时沿用面板令牌校验(与 `route()` 的 `auth` 默认为 true 对齐)。
848
+ * 适配器的反向连接端点要让平台连进来,就自己给一个 `verify` 做签名校验 ——
849
+ * 给了就完全接管,不再叠加令牌校验。
850
+ * @param scope URL 前缀
851
+ * @param path 相对 scope 的路径
852
+ * @param handler 连接建立后的处理函数
853
+ * @param opts 端点选项
854
+ * @returns 注销句柄;幂等
855
+ * @throws 服务器已关闭、或同一模式重复注册时
856
+ */
857
+ websocket(scope: string, path: string, handler: WebSocketHandler, opts: WebSocketOptions = {}): Disposer {
858
+ this.#assertOpen()
859
+ return this.#ws.add(joinPattern(scope, path), { scope, handler, verify: opts.verify })
860
+ }
861
+
862
+ /**
863
+ * 挂一个静态目录
864
+ * @param scope URL 前缀
865
+ * @param urlPath 相对 scope 的 URL 路径,`""` 或 `"/"` 表示挂在 scope 根上
866
+ * @param dir 本地目录绝对路径
867
+ * @returns 注销句柄;幂等
868
+ * @throws 服务器已关闭、`dir` 不是绝对路径、或同一模式重复注册时
869
+ */
870
+ static(scope: string, urlPath: string, dir: string): Disposer {
871
+ return this.#mount(scope, urlPath, dir, false)
872
+ }
873
+
874
+ /**
875
+ * 挂载面板:把一个单页应用目录挂到站点根路径
876
+ *
877
+ * 内置面板与接管它的插件都走这里,因此"谁在提供面板"只有一个判定依据
878
+ * (`claimant("/")`)。第二个调用方会在路径表里撞上重复模式而抛错,
879
+ * 这正是期望行为:静默覆盖会让面板显示的内容无法判定。
880
+ * @param owner 归属标识,如 `core` 或 `plugin:webui-next`
881
+ * @param dir 单页应用产物目录(绝对路径)
882
+ * @returns 注销句柄;幂等
883
+ * @throws 服务器已关闭、`dir` 不是绝对路径、或根路径已被占用时
884
+ */
885
+ panel(owner: string, dir: string): Disposer {
886
+ return this.#mount("/", "", dir, true, owner)
887
+ }
888
+
889
+ /**
890
+ * 导出全部 HTTP 路由,面板"路由一览"用
891
+ * @returns 路由摘要数组
892
+ */
893
+ listRoutes(): RouteSummary[] {
894
+ const out: RouteSummary[] = []
895
+ for (const [method, table] of this.#routes) {
896
+ for (const item of table.entries()) {
897
+ out.push({ method, pattern: item.pattern, scope: item.value.scope, auth: item.value.auth })
898
+ }
899
+ }
900
+ return out
901
+ }
902
+
903
+ /**
904
+ * 导出全部 WebSocket 端点
905
+ * @returns 端点摘要数组
906
+ */
907
+ listWebsockets(): WebSocketSummary[] {
908
+ return this.#ws.entries().map(item => ({ pattern: item.pattern, scope: item.value.scope }))
909
+ }
910
+
911
+ /**
912
+ * 导出全部静态挂载
913
+ * @returns 挂载摘要数组
914
+ */
915
+ listStatic(): StaticSummary[] {
916
+ return this.#static.entries().map(item => ({
917
+ pattern: item.pattern,
918
+ dir: item.value.dir,
919
+ spa: item.value.spa
920
+ }))
921
+ }
922
+
923
+ /**
924
+ * 查询某路径当前由谁提供
925
+ *
926
+ * 先查静态挂载再查 GET 路由:内核用它判断根路径是否已被插件接管,而接管
927
+ * 面板既可以挂一个单页目录,也可以注册一条返回 HTML 的路由,两种都要认。
928
+ * @param path URL 路径
929
+ * @returns 提供方的注册前缀;无人提供时 undefined
930
+ */
931
+ claimant(path: string): string | undefined {
932
+ const parts = splitSegments(path)
933
+ const mount = this.#static.find(parts)
934
+ if (mount !== undefined) return mount.value.scope
935
+ return this.#routes.get("GET")?.find(parts)?.value.scope
936
+ }
937
+
938
+ /**
939
+ * 静态挂载的公共实现
940
+ *
941
+ * 模式写成 `<urlPath>/*rest`:通配段"连什么都不剩也匹配"(见 table.ts 的
942
+ * `matchSegments`),所以 `/webui/*rest` 同时命中 `/webui` 与 `/webui/a/b.js`,
943
+ * 不必再额外注册一条裸路径。
944
+ * @param scope URL 前缀
945
+ * @param urlPath 相对 scope 的 URL 路径
946
+ * @param dir 本地目录绝对路径
947
+ * @param spa 是否单页回退
948
+ * @param owner 归属标识,缺省与 scope 相同;仅面板挂载需要与 scope 不同的值
949
+ * @returns 注销句柄
950
+ * @throws 服务器已关闭或 `dir` 不是绝对路径时
951
+ */
952
+ #mount(scope: string, urlPath: string, dir: string, spa: boolean, owner = scope): Disposer {
953
+ this.#assertOpen()
954
+ if (!isAbsolute(dir)) {
955
+ // 相对路径会随进程工作目录变化,而工作目录取决于用户是怎么启动的
956
+ throw new Error(`静态目录必须是绝对路径,收到 ${dir}`)
957
+ }
958
+ return this.#static.add(joinPattern(scope, `${urlPath}/*${STATIC_REST}`), { dir, spa, scope: owner })
959
+ }
960
+
961
+ /**
962
+ * 取某方法的路径表,没有就建
963
+ * @param method 请求方法
964
+ * @returns 路径表
965
+ */
966
+ #tableOf(method: HttpMethod): PathTable<RouteEntry> {
967
+ let table = this.#routes.get(method)
968
+ if (table === undefined) {
969
+ table = new PathTable<RouteEntry>()
970
+ this.#routes.set(method, table)
971
+ }
972
+ return table
973
+ }
974
+
975
+ /**
976
+ * 把路由声明的请求体上限收进合法范围
977
+ * @param limit 路由声明的上限
978
+ * @returns 实际生效的上限
979
+ */
980
+ #clampLimit(limit: number | undefined): number {
981
+ if (limit === undefined || !Number.isFinite(limit) || limit <= 0) return DEFAULT_BODY_LIMIT
982
+ if (limit > MAX_BODY_LIMIT) {
983
+ this.#logger.warn(
984
+ `路由声明的 bodyLimit ${formatBytes(limit)} 超过服务器上限,按 ${formatBytes(MAX_BODY_LIMIT)} 处理`
985
+ )
986
+ return MAX_BODY_LIMIT
987
+ }
988
+ return limit
989
+ }
990
+
991
+ /**
992
+ * 拒绝在已关闭的服务器上注册
993
+ * @throws 已关闭时
994
+ */
995
+ #assertOpen(): void {
996
+ if (this.#closed) throw new Error("服务器已关闭,不能再注册路由")
997
+ }
998
+
999
+ /**
1000
+ * 取当前配置里的访问令牌
1001
+ * @returns 令牌;未设置时 undefined
1002
+ */
1003
+ #token(): string | undefined {
1004
+ const token = this.#config.get().server.token
1005
+ return token === "" ? undefined : token
1006
+ }
1007
+
1008
+ /**
1009
+ * 回一个统一形状的错误响应
1010
+ * @param reply Fastify 响应
1011
+ * @param status 状态码
1012
+ * @param message 错误文案
1013
+ * @param extra 额外响应头
1014
+ * @returns 同一个 `reply`
1015
+ */
1016
+ #reject(reply: FastifyReply, status: number, message: string, extra?: Record<string, string>): FastifyReply {
1017
+ if (extra !== undefined) reply.headers(extra)
1018
+ return reply.code(status).send({ error: message })
1019
+ }
1020
+
1021
+ /**
1022
+ * 拒绝一次升级请求
1023
+ *
1024
+ * 额外附带 `Connection: close`:升级被拒之后该条 TCP 连接已无其他用途,而
1025
+ * `@fastify/websocket` 的 `onResponse` 钩子仅在 `request.ws` 为真时才去 destroy
1026
+ * socket —— 本服务器的钩子执行于其之前,该标记此时尚未被设置。
1027
+ * @param reply Fastify 响应
1028
+ * @param status 状态码
1029
+ * @param message 错误文案
1030
+ * @returns 同一个 `reply`
1031
+ */
1032
+ #rejectUpgrade(reply: FastifyReply, status: number, message: string): FastifyReply {
1033
+ return this.#reject(reply, status, message, { connection: "close" })
1034
+ }
1035
+
1036
+ /**
1037
+ * 唯一的请求入口钩子:查表 → 鉴权 → 缓存上下文
1038
+ *
1039
+ * 返回 `undefined` 表示放行,返回 `reply` 表示"已经回过响应了,别再往下走"。后者
1040
+ * 之所以真的能拦住后续阶段,是因为 fastify 的 `lib/handle-request.js` 第一行就是
1041
+ * `if (reply.sent === true) return`,body 解析与路由处理都在它之后。
1042
+ * @param request Fastify 请求
1043
+ * @param reply Fastify 响应
1044
+ * @returns 已回响应时是 `reply`,放行时是 `undefined`
1045
+ */
1046
+ async #onRequest(request: FastifyRequest, reply: FastifyReply): Promise<FastifyReply | undefined> {
1047
+ const raw = request.method.toUpperCase()
1048
+ const path = pathOf(request.url)
1049
+ const parts = splitSegments(path)
1050
+
1051
+ if (isUpgradeRequest(request)) return this.#handleUpgrade(request, reply, path, parts)
1052
+ if (!isHttpMethod(raw)) return this.#reject(reply, 405, `不支持的请求方法 ${raw}`)
1053
+ const method: HttpMethod = raw
1054
+
1055
+ // HEAD 未单独注册时回落到 GET:Node 的 ServerResponse 对 HEAD 会自动丢弃响应体,
1056
+ // 因此直接执行 GET 的处理函数是安全的,同时免去每个插件重复注册两次
1057
+ const route =
1058
+ method === "HEAD"
1059
+ ? (this.#routes.get("HEAD")?.find(parts) ?? this.#routes.get("GET")?.find(parts))
1060
+ : this.#routes.get(method)?.find(parts)
1061
+
1062
+ if (route !== undefined) {
1063
+ const entry = route.value
1064
+ if (entry.auth) {
1065
+ const authReq: AuthRequest = { ip: ipOf(request), method, headers: request.headers }
1066
+ const failure = checkAuth(this.#token(), authReq) ?? checkForgeableBody(authReq)
1067
+ if (failure !== undefined) return this.#reject(reply, failure.status, failure.message)
1068
+ }
1069
+
1070
+ // 声明了长度就先按声明拒掉,别等把 64MB 收完再说 413
1071
+ const declared = Number(firstHeader(request.headers["content-length"]) ?? "0")
1072
+ if (Number.isFinite(declared) && declared > entry.limit) {
1073
+ return this.#reject(
1074
+ reply,
1075
+ 413,
1076
+ `请求体 ${formatBytes(declared)} 超过该路由的上限 ${formatBytes(entry.limit)}`
1077
+ )
1078
+ }
1079
+
1080
+ this.#contexts.set(request, {
1081
+ method,
1082
+ path,
1083
+ parts,
1084
+ route,
1085
+ mount: undefined,
1086
+ ws: undefined,
1087
+ limit: entry.limit,
1088
+ rawBody: entry.rawBody
1089
+ })
1090
+ return undefined
1091
+ }
1092
+
1093
+ if (method === "GET" || method === "HEAD") {
1094
+ const mount = this.#static.find(parts)
1095
+ if (mount !== undefined) {
1096
+ // 静态资源刻意不鉴权,理由见文件头第 4 条
1097
+ this.#contexts.set(request, {
1098
+ method,
1099
+ path,
1100
+ parts,
1101
+ route: undefined,
1102
+ mount,
1103
+ ws: undefined,
1104
+ limit: DEFAULT_BODY_LIMIT,
1105
+ rawBody: false
1106
+ })
1107
+ return undefined
1108
+ }
1109
+ }
1110
+
1111
+ const allow = this.#allowFor(parts)
1112
+ // 不存在任何方法 → 该路径本身不存在,应为 404 而非 405
1113
+ if (allow.length === 0) return this.#reject(reply, 404, `路由 ${path} 不存在`)
1114
+ const header = allow.join(", ")
1115
+ if (method === "OPTIONS") {
1116
+ reply.header("allow", header)
1117
+ return reply.code(204).send()
1118
+ }
1119
+ return this.#reject(reply, 405, `${path} 不支持 ${method}`, { allow: header })
1120
+ }
1121
+
1122
+ /**
1123
+ * 算出某路径支持哪些方法,用于 `Allow` 响应头
1124
+ * @param parts 已解码的路径段
1125
+ * @returns 方法数组;路径不存在时为空数组
1126
+ */
1127
+ #allowFor(parts: readonly string[]): HttpMethod[] {
1128
+ const out: HttpMethod[] = []
1129
+ for (const method of HTTP_METHODS) {
1130
+ if (this.#routes.get(method)?.find(parts) !== undefined) out.push(method)
1131
+ }
1132
+ // 这两条是服务器代劳的,注册表里查不到但确实支持
1133
+ if (out.includes("GET") && !out.includes("HEAD")) out.push("HEAD")
1134
+ if (out.length > 0 && !out.includes("OPTIONS")) out.push("OPTIONS")
1135
+ return out
1136
+ }
1137
+
1138
+ /**
1139
+ * 处理一次 WebSocket 升级请求
1140
+ *
1141
+ * 全部拒绝都在这里做完,理由见文件头第 3 条:此刻回的是真正的 HTTP 响应,对方能
1142
+ * 从状态码和响应体里看到原因;进了 `wsHandler` 之后就只剩"关连接"这一种表达方式。
1143
+ * @param request Fastify 请求
1144
+ * @param reply Fastify 响应
1145
+ * @param path 请求路径
1146
+ * @param parts 已解码的路径段
1147
+ * @returns 已拒绝时是 `reply`,放行时是 `undefined`
1148
+ */
1149
+ async #handleUpgrade(
1150
+ request: FastifyRequest,
1151
+ reply: FastifyReply,
1152
+ path: string,
1153
+ parts: readonly string[]
1154
+ ): Promise<FastifyReply | undefined> {
1155
+ const hit = this.#ws.find(parts)
1156
+ if (hit === undefined) return this.#rejectUpgrade(reply, 404, `WebSocket 端点 ${path} 不存在`)
1157
+
1158
+ const ctx: RequestContext = {
1159
+ method: "GET",
1160
+ path,
1161
+ parts,
1162
+ route: undefined,
1163
+ mount: undefined,
1164
+ ws: hit,
1165
+ limit: 0,
1166
+ rawBody: false
1167
+ }
1168
+
1169
+ const verify = hit.value.verify
1170
+ if (verify === undefined) {
1171
+ const failure = checkAuth(this.#token(), { ip: ipOf(request), method: "GET", headers: request.headers })
1172
+ if (failure !== undefined) return this.#rejectUpgrade(reply, failure.status, failure.message)
1173
+ } else {
1174
+ let ok = false
1175
+ try {
1176
+ ok = await verify(this.#toRequest(request, ctx, hit.params))
1177
+ } catch (err) {
1178
+ // verify 抛出异常等同于校验未通过,但须记录日志:否则 verify 中的一处拼写错误将
1179
+ // 表现为"对端无法连接,而服务端未给出任何信息"
1180
+ this.#logger.error(`WebSocket ${hit.pattern} 的 verify 抛错:${messageOf(err)}`)
1181
+ }
1182
+ if (!ok) return this.#rejectUpgrade(reply, 401, `WebSocket ${path} 校验未通过`)
1183
+ }
1184
+
1185
+ this.#contexts.set(request, ctx)
1186
+ return undefined
1187
+ }
1188
+
1189
+ /**
1190
+ * 把 Fastify 请求转成插件看到的 `RouteRequest`
1191
+ *
1192
+ * 插件拿到的是一个平的、只读的普通对象,不是 Fastify 请求:一方面插件不该依赖
1193
+ * Fastify 的 API(将来换 HTTP 实现时才不必改插件),另一方面这样也堵住了插件从
1194
+ * `request` 上摸到 `raw.socket` 去做越权操作的路。
1195
+ * @param request Fastify 请求
1196
+ * @param ctx 本次请求的查表结果
1197
+ * @param params 路径参数
1198
+ * @returns 只读请求对象
1199
+ */
1200
+ #toRequest(
1201
+ request: FastifyRequest,
1202
+ ctx: RequestContext,
1203
+ params: Readonly<Record<string, string>>
1204
+ ): RouteRequest {
1205
+ return {
1206
+ method: ctx.method,
1207
+ path: ctx.path,
1208
+ params,
1209
+ query: request.query as Readonly<Record<string, string | string[] | undefined>>,
1210
+ headers: request.headers,
1211
+ body: request.body,
1212
+ rawBody: this.#rawBodies.get(request),
1213
+ ip: ipOf(request)
1214
+ }
1215
+ }
1216
+
1217
+ /**
1218
+ * 唯一的请求体解析器
1219
+ *
1220
+ * 只注册一个 `"*"` catch-all 而不是按类型注册若干个,原因和路由一样:限流上限是
1221
+ * **按路由**来的,只有拿到本次请求的上下文才知道该按多少截断,而 Fastify 的按类型
1222
+ * 解析器拿不到"这条请求命中了哪个路由"。
1223
+ * @param request Fastify 请求
1224
+ * @param payload 原始请求流
1225
+ * @param done 解析完成回调
1226
+ */
1227
+ #parseBody(
1228
+ request: FastifyRequest,
1229
+ payload: IncomingMessage,
1230
+ done: (err: Error | null, body?: unknown) => void
1231
+ ): void {
1232
+ const ctx = this.#contexts.get(request)
1233
+ const limit = ctx?.limit ?? DEFAULT_BODY_LIMIT
1234
+ const chunks: Buffer[] = []
1235
+ let size = 0
1236
+ let settled = false
1237
+
1238
+ /**
1239
+ * 只允许回调一次
1240
+ * @param err 错误
1241
+ * @param body 解析结果
1242
+ */
1243
+ const finish = (err: Error | null, body?: unknown): void => {
1244
+ if (settled) return
1245
+ settled = true
1246
+ done(err, body)
1247
+ }
1248
+
1249
+ payload.on("data", (chunk: Buffer) => {
1250
+ if (settled) return
1251
+ size += chunk.length
1252
+ if (size > limit) {
1253
+ finish(httpError(413, `请求体超过该路由的上限 ${formatBytes(limit)}`))
1254
+ // 若不 resume,该连接会持续等待本端读取,直至对端超时
1255
+ payload.resume()
1256
+ return
1257
+ }
1258
+ chunks.push(chunk)
1259
+ })
1260
+
1261
+ payload.on("aborted", () => {
1262
+ finish(httpError(400, "请求体尚未传完连接就断了"))
1263
+ })
1264
+
1265
+ payload.on("error", (err: Error) => {
1266
+ finish(err)
1267
+ })
1268
+
1269
+ payload.on("end", () => {
1270
+ if (settled) return
1271
+ const raw = Buffer.concat(chunks)
1272
+ // 仅声明了 rawBody 的路由才保留原始字节:缺省保留会使每个请求额外驻留一份副本
1273
+ if (ctx?.rawBody === true) this.#rawBodies.set(request, raw)
1274
+ if (raw.length === 0) {
1275
+ finish(null, undefined)
1276
+ return
1277
+ }
1278
+
1279
+ const type = mediaTypeOf(firstHeader(request.headers["content-type"]))
1280
+ if (type === "application/json" || type.endsWith("+json")) {
1281
+ this.#jsonParser(request, raw.toString("utf8"), finish)
1282
+ return
1283
+ }
1284
+ if (type === "") {
1285
+ // 部分设备端 SDK 完全不发送 content-type。先按 JSON 尝试解析,失败则将原始字节
1286
+ // 交由路由自行处理 —— 若仅返回一个不含说明的 400,对端无从判断问题所在
1287
+ this.#jsonParser(request, raw.toString("utf8"), (err, body) => {
1288
+ finish(null, err === null ? body : raw)
1289
+ })
1290
+ return
1291
+ }
1292
+ if (type === "application/x-www-form-urlencoded") {
1293
+ finish(null, formToObject(raw.toString("utf8")))
1294
+ return
1295
+ }
1296
+ if (type.startsWith("text/") || type === "application/xml" || type.endsWith("+xml")) {
1297
+ finish(null, raw.toString("utf8"))
1298
+ return
1299
+ }
1300
+ // 其余(含 multipart)原样给出去,需要的插件自己解
1301
+ finish(null, raw)
1302
+ })
1303
+ }
1304
+
1305
+ /**
1306
+ * 真正分发一次请求
1307
+ *
1308
+ * 走到这里说明 `onRequest` 已经放行,所以上下文一定在;查不到只可能是有人绕开了钩子
1309
+ * (比如直接往 `raw` 上加了路由),那是 500 而不是 404。
1310
+ * @param request Fastify 请求
1311
+ * @param reply Fastify 响应
1312
+ * @returns 同一个 `reply`
1313
+ */
1314
+ async #dispatch(request: FastifyRequest, reply: FastifyReply): Promise<FastifyReply> {
1315
+ const ctx = this.#contexts.get(request)
1316
+ if (ctx === undefined) return this.#reject(reply, 500, "请求上下文丢失:这条路由不是通过 route() 注册的")
1317
+
1318
+ if (ctx.route !== undefined) {
1319
+ const result = await ctx.route.value.handler(this.#toRequest(request, ctx, ctx.route.params))
1320
+ return sendResult(reply, result)
1321
+ }
1322
+
1323
+ if (ctx.mount !== undefined) {
1324
+ const rest = ctx.mount.params[STATIC_REST] ?? ""
1325
+ const rel = await resolveStaticFile(ctx.mount.value, rest)
1326
+ if (rel === undefined) {
1327
+ reply.callNotFound()
1328
+ return reply
1329
+ }
1330
+ // 此处给出的是**未编码**的相对路径:`@fastify/static` 内部会自行 `encodeURI()`
1331
+ // 再交给 `@fastify/send`,若在此提前编码将造成双重编码
1332
+ return reply.sendFile(rel, ctx.mount.value.dir)
1333
+ }
1334
+
1335
+ // 携带升级头进入但最终未被 `@fastify/websocket` 接管 —— 最常见的原因是对端实际
1336
+ // 发送的是一个普通 GET 请求,因此明确说明该端点仅接受 WebSocket,避免对端只见 404 而无从判断
1337
+ if (ctx.ws !== undefined) {
1338
+ return this.#reject(reply, 400, `${ctx.path} 是一个 WebSocket 端点,请使用 WebSocket 连接`)
1339
+ }
1340
+ return this.#reject(reply, 404, `路由 ${ctx.path} 不存在`)
1341
+ }
1342
+
1343
+ /**
1344
+ * 一条 WebSocket 握手成功后接管连接
1345
+ * @param socket `ws` 原生连接
1346
+ * @param request 握手时的 Fastify 请求
1347
+ */
1348
+ #openConnection(socket: RawSocket, request: FastifyRequest): void {
1349
+ const ctx = this.#contexts.get(request)
1350
+ const hit = ctx?.ws
1351
+ if (ctx === undefined || hit === undefined) {
1352
+ socket.close(1011, "服务器内部状态丢失")
1353
+ return
1354
+ }
1355
+
1356
+ const conn = wrapConnection(socket, this.#nextConnId(), ipOf(request), request.headers, this.#logger)
1357
+ this.#connections.add(conn)
1358
+ conn.onClose(() => {
1359
+ this.#connections.delete(conn)
1360
+ })
1361
+
1362
+ try {
1363
+ hit.value.handler(conn, this.#toRequest(request, ctx, hit.params))
1364
+ } catch (err) {
1365
+ // 处理函数是同步抛的,此时插件很可能还没挂上任何回调,直接关掉比留个哑连接好
1366
+ this.#logger.error(`WebSocket ${hit.pattern} 的处理函数抛错:${messageOf(err)}`)
1367
+ conn.close(1011, "处理函数抛错")
1368
+ }
1369
+ }
1370
+
1371
+ }
1372
+
1373
+ /**
1374
+ * 创建一个共享服务器,但**不**开始监听
1375
+ *
1376
+ * `await ready()` 在此处即完成,而非留至 `listen()`:`ready()` 之后 Fastify 不再
1377
+ * 接受新路由,而本服务器共计只有那 4 条 catch-all,插件后续注册的路由均进入 `PathTable`,
1378
+ * 不经由 Fastify —— 因此提前 ready 是安全的,同时可使 `raw.inject()` / `raw.injectWS()`
1379
+ * 在不占用端口的前提下直接用于测试。
1380
+ * @param opts 构造参数
1381
+ * @returns 已就绪的服务器
1382
+ */
1383
+ export async function createManagedServer(opts: ManagedServerOptions): Promise<ManagedServer> {
1384
+ return ManagedServer.create(opts)
1385
+ }
1386
+