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