@jsbot-tellus/core 2.2.0 → 2.4.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 (116) hide show
  1. package/README.md +19 -0
  2. package/bot.config.example.json +3 -0
  3. package/dist/app.js +215 -98
  4. package/dist/app.js.map +1 -1
  5. package/dist/core/client.d.ts +1 -0
  6. package/dist/core/client.d.ts.map +1 -1
  7. package/dist/core/client.js +3 -0
  8. package/dist/core/client.js.map +1 -1
  9. package/dist/core/config/schema.d.ts +19 -0
  10. package/dist/core/config/schema.d.ts.map +1 -1
  11. package/dist/core/config/schema.js +37 -0
  12. package/dist/core/config/schema.js.map +1 -1
  13. package/dist/core/dispatch/router.d.ts +3 -0
  14. package/dist/core/dispatch/router.d.ts.map +1 -1
  15. package/dist/core/dispatch/router.js +13 -0
  16. package/dist/core/dispatch/router.js.map +1 -1
  17. package/dist/core/index.d.ts +2 -2
  18. package/dist/core/index.d.ts.map +1 -1
  19. package/dist/core/index.js.map +1 -1
  20. package/dist/core/kernel.d.ts +8 -0
  21. package/dist/core/kernel.d.ts.map +1 -1
  22. package/dist/core/kernel.js +17 -0
  23. package/dist/core/kernel.js.map +1 -1
  24. package/dist/core/logging/log-buffer.d.ts +27 -0
  25. package/dist/core/logging/log-buffer.d.ts.map +1 -0
  26. package/dist/core/logging/log-buffer.js +71 -0
  27. package/dist/core/logging/log-buffer.js.map +1 -0
  28. package/dist/core/logging/logger.d.ts +3 -0
  29. package/dist/core/logging/logger.d.ts.map +1 -1
  30. package/dist/core/logging/logger.js +6 -3
  31. package/dist/core/logging/logger.js.map +1 -1
  32. package/dist/core/plugin/command-params.d.ts +2 -2
  33. package/dist/core/plugin/command-params.d.ts.map +1 -1
  34. package/dist/core/plugin/command-params.js +35 -0
  35. package/dist/core/plugin/command-params.js.map +1 -1
  36. package/dist/core/storage/session.d.ts +15 -0
  37. package/dist/core/storage/session.d.ts.map +1 -1
  38. package/dist/core/storage/session.js +55 -0
  39. package/dist/core/storage/session.js.map +1 -1
  40. package/dist/core/web/api.d.ts +16 -0
  41. package/dist/core/web/api.d.ts.map +1 -0
  42. package/dist/core/web/api.js +617 -0
  43. package/dist/core/web/api.js.map +1 -0
  44. package/dist/core/web/auth.d.ts +43 -0
  45. package/dist/core/web/auth.d.ts.map +1 -0
  46. package/dist/core/web/auth.js +161 -0
  47. package/dist/core/web/auth.js.map +1 -0
  48. package/dist/core/web/config-store.d.ts +30 -0
  49. package/dist/core/web/config-store.d.ts.map +1 -0
  50. package/dist/core/web/config-store.js +216 -0
  51. package/dist/core/web/config-store.js.map +1 -0
  52. package/dist/core/web/event-feed.d.ts +26 -0
  53. package/dist/core/web/event-feed.d.ts.map +1 -0
  54. package/dist/core/web/event-feed.js +107 -0
  55. package/dist/core/web/event-feed.js.map +1 -0
  56. package/dist/core/web/http.d.ts +39 -0
  57. package/dist/core/web/http.d.ts.map +1 -0
  58. package/dist/core/web/http.js +233 -0
  59. package/dist/core/web/http.js.map +1 -0
  60. package/dist/core/web/index.d.ts +11 -0
  61. package/dist/core/web/index.d.ts.map +1 -0
  62. package/dist/core/web/index.js +6 -0
  63. package/dist/core/web/index.js.map +1 -0
  64. package/dist/core/web/server.d.ts +30 -0
  65. package/dist/core/web/server.d.ts.map +1 -0
  66. package/dist/core/web/server.js +437 -0
  67. package/dist/core/web/server.js.map +1 -0
  68. package/dist/core/web/types.d.ts +84 -0
  69. package/dist/core/web/types.d.ts.map +1 -0
  70. package/dist/core/web/types.js +2 -0
  71. package/dist/core/web/types.js.map +1 -0
  72. package/dist/webui/assets/ActionsView-BDuXBWG1.js +1 -0
  73. package/dist/webui/assets/ActionsView-Ciib5lC6.css +1 -0
  74. package/dist/webui/assets/ConfigView-B3Gv2uRG.js +1 -0
  75. package/dist/webui/assets/ConfigView-CrEpss2y.css +1 -0
  76. package/dist/webui/assets/DashboardView-DR6yCaqs.css +1 -0
  77. package/dist/webui/assets/DashboardView-n9LGPmQL.js +1 -0
  78. package/dist/webui/assets/EventsView-DhO8twWb.js +1 -0
  79. package/dist/webui/assets/EventsView-PMkYKxZ-.css +1 -0
  80. package/dist/webui/assets/KvView-BCz_dOVC.js +1 -0
  81. package/dist/webui/assets/KvView-CaMvbxUz.css +1 -0
  82. package/dist/webui/assets/LoginView-BVnFkwSW.css +1 -0
  83. package/dist/webui/assets/LoginView-Br3_AC5z.js +1 -0
  84. package/dist/webui/assets/LogsView-Ba_12frI.js +1 -0
  85. package/dist/webui/assets/LogsView-NR4L1-XY.css +1 -0
  86. package/dist/webui/assets/MainLayout-D2LTc57u.js +1 -0
  87. package/dist/webui/assets/MainLayout-glfVNbMz.css +1 -0
  88. package/dist/webui/assets/PluginsView-DL9OxGph.css +1 -0
  89. package/dist/webui/assets/PluginsView-vQKBSCU8.js +1 -0
  90. package/dist/webui/assets/SessionsView-DSl37qpo.js +1 -0
  91. package/dist/webui/assets/SessionsView-p-u3JGTV.css +1 -0
  92. package/dist/webui/assets/api-CNzxhTWe.js +3 -0
  93. package/dist/webui/assets/index-CUvpnClx.js +50 -0
  94. package/dist/webui/assets/index-CePlRZX6.css +1 -0
  95. package/dist/webui/index.html +110 -0
  96. package/docs/plugin-api.md +1 -0
  97. package/docs/webui.md +142 -0
  98. package/package.json +14 -3
  99. package/src/app.ts +286 -118
  100. package/src/core/client.ts +4 -0
  101. package/src/core/config/schema.ts +40 -0
  102. package/src/core/dispatch/router.ts +15 -0
  103. package/src/core/index.ts +3 -0
  104. package/src/core/kernel.ts +25 -1
  105. package/src/core/logging/log-buffer.ts +109 -0
  106. package/src/core/logging/logger.ts +7 -3
  107. package/src/core/plugin/command-params.ts +48 -2
  108. package/src/core/storage/session.ts +95 -0
  109. package/src/core/web/api.ts +932 -0
  110. package/src/core/web/auth.ts +245 -0
  111. package/src/core/web/config-store.ts +333 -0
  112. package/src/core/web/event-feed.ts +163 -0
  113. package/src/core/web/http.ts +340 -0
  114. package/src/core/web/index.ts +25 -0
  115. package/src/core/web/server.ts +621 -0
  116. package/src/core/web/types.ts +100 -0
@@ -0,0 +1,621 @@
1
+ import {
2
+ createServer,
3
+ type IncomingMessage,
4
+ type RequestListener,
5
+ type Server,
6
+ type ServerResponse,
7
+ } from 'node:http'
8
+ import { createReadStream, readFileSync } from 'node:fs'
9
+ import { stat } from 'node:fs/promises'
10
+ import { extname, join, resolve, sep } from 'node:path'
11
+ import { createHash } from 'node:crypto'
12
+ import { AuthManager, type AuthSession } from './auth.js'
13
+ import { handleApiRoute } from './api.js'
14
+ import {
15
+ assertCsrf,
16
+ assertSameOrigin,
17
+ clearCookie,
18
+ clientIp,
19
+ isImmutableAsset,
20
+ mimeType,
21
+ parseCookies,
22
+ readJsonBody,
23
+ respondWithError,
24
+ safeStringify,
25
+ sendError,
26
+ sendData,
27
+ serializeCookie,
28
+ withTimeout,
29
+ type URLSearchParamsLike,
30
+ } from './http.js'
31
+ import { HttpError } from './http.js'
32
+ import type { WebUiAddress, WebUiDeps, WebUiServerOptions } from './types.js'
33
+
34
+ const SESSION_COOKIE = 'jsbot_webui'
35
+
36
+ const WRITE_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE'])
37
+
38
+ const SECURITY_HEADERS: Record<string, string> = {
39
+ 'x-content-type-options': 'nosniff',
40
+ 'x-frame-options': 'DENY',
41
+ 'referrer-policy': 'no-referrer',
42
+ 'cross-origin-opener-policy': 'same-origin',
43
+ }
44
+
45
+ /**
46
+ * CSP for the WebUI. webui/index.html ships a single authorized inline
47
+ * script (the no-flash appearance bootstrap for the first paint); its exact
48
+ * hash is derived from the built index.html on disk so editing the bootstrap
49
+ * only needs a rebuild, never a source edit. Every other inline script
50
+ * stays blocked.
51
+ */
52
+ function buildCsp(scriptSrc: string): string {
53
+ return `default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src ${scriptSrc}; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'`
54
+ }
55
+
56
+ /** Extracts the bootstrap script's sha256 from a built index.html. */
57
+ function cspForStaticDir(staticDir: string): string | null {
58
+ try {
59
+ const html = readFileSync(join(staticDir, 'index.html'), 'utf8')
60
+ const match = /<script>([\s\S]*?)<\/script>/.exec(html)
61
+ const bootstrap = match?.[1]
62
+
63
+ if (bootstrap === undefined) return null
64
+
65
+ const hash = createHash('sha256')
66
+ .update(bootstrap, 'utf8')
67
+ .digest('base64')
68
+
69
+ return buildCsp(`'self' 'sha256-${hash}'`)
70
+ } catch {
71
+ return null
72
+ }
73
+ }
74
+
75
+ const SSE_HEARTBEAT_MS = 15_000
76
+ const DEFAULT_MAX_SSE_CLIENTS = 20
77
+ const SSE_TIMEOUT_MS = 60_000
78
+
79
+ interface SseConnection {
80
+ res: ServerResponse
81
+ close(): void
82
+ }
83
+
84
+ interface FallbackPageOptions {
85
+ listening: string
86
+ }
87
+
88
+ const FALLBACK_BODY = (options: FallbackPageOptions): string => `<!doctype html>
89
+ <html lang="zh-CN"><head><meta charset="utf-8">
90
+ <title>JsBot WebUI</title>
91
+ <style>body{font-family:system-ui,sans-serif;max-width:36em;margin:4rem auto;padding:0 1rem;color:#333}</style>
92
+ </head><body>
93
+ <h1>WebUI 前端未构建</h1>
94
+ <p>管理 API 正常运行,但静态资源缺失。请在项目根目录执行:</p>
95
+ <pre><code>npm run build:webui</code></pre>
96
+ <p>然后重启进程,访问 <code>${options.listening}</code>。</p>
97
+ <p>API 仍可通过 <code>/api/health</code>、登录和各接口访问。</p>
98
+ </body></html>
99
+ `
100
+
101
+ function errorPayload(error: unknown): {
102
+ status: number
103
+ message: string
104
+ details: unknown
105
+ } {
106
+ if (error instanceof HttpError) {
107
+ return {
108
+ status: error.status,
109
+ message: error.message,
110
+ details: error.details,
111
+ }
112
+ }
113
+
114
+ return {
115
+ status: 500,
116
+ message: error instanceof Error ? error.message : 'internal error',
117
+ details: undefined,
118
+ }
119
+ }
120
+
121
+ function parseSearch(req: IncomingMessage): URLSearchParamsLike {
122
+ const index = (req.url ?? '').indexOf('?')
123
+ const query = index >= 0 ? (req.url ?? '').slice(index + 1) : ''
124
+
125
+ return new URLSearchParams(query)
126
+ }
127
+
128
+ function segmentsOf(pathname: string): string[] {
129
+ return pathname
130
+ .split('/')
131
+ .filter((segment) => segment.length > 0)
132
+ .map((segment) => {
133
+ try {
134
+ return decodeURIComponent(segment)
135
+ } catch {
136
+ return segment
137
+ }
138
+ })
139
+ }
140
+
141
+ function pathnameOf(req: IncomingMessage): string {
142
+ const url = req.url ?? '/'
143
+ const index = url.indexOf('?')
144
+ const raw = index >= 0 ? url.slice(0, index) : url
145
+
146
+ if (raw.includes('%')) {
147
+ try {
148
+ return decodeURIComponent(raw)
149
+ } catch {
150
+ return raw
151
+ }
152
+ }
153
+
154
+ return raw
155
+ }
156
+
157
+ export class WebUiServer {
158
+ private readonly deps: WebUiDeps
159
+ private readonly maxSseClients: number
160
+ private readonly bodyLimit: number
161
+ private readonly loginMaxFailures: number | undefined
162
+ private readonly loginWindowMs: number | undefined
163
+ private readonly auth: AuthManager
164
+ private readonly sse = new Set<SseConnection>()
165
+ private readonly fallbackBody: string
166
+
167
+ private httpServer: Server | null = null
168
+ private listening: WebUiAddress | null = null
169
+ private cspCache: string | null = null
170
+
171
+ constructor(options: WebUiServerOptions) {
172
+ const { config, deps } = options
173
+
174
+ this.deps = deps
175
+ this.maxSseClients = options.maxSseClients ?? DEFAULT_MAX_SSE_CLIENTS
176
+ this.bodyLimit = options.bodyLimitBytes ?? 1_048_576
177
+ this.loginMaxFailures = options.loginMaxFailures
178
+ this.loginWindowMs = options.loginWindowMs
179
+ this.fallbackBody = FALLBACK_BODY({
180
+ listening: `http://${config.host}:${config.port}`,
181
+ })
182
+ this.auth = new AuthManager({
183
+ getConfig: () => deps.getWebUiConfig(),
184
+ ...(options.loginMaxFailures !== undefined
185
+ ? { maxFailures: options.loginMaxFailures }
186
+ : {}),
187
+ ...(options.loginWindowMs !== undefined
188
+ ? { windowMs: options.loginWindowMs }
189
+ : {}),
190
+ })
191
+ }
192
+
193
+ get address(): WebUiAddress | null {
194
+ return this.listening
195
+ }
196
+
197
+ async start(
198
+ config: WebUiServerOptions['config']
199
+ ): Promise<WebUiAddress | null> {
200
+ if (this.httpServer !== null) return this.listening
201
+
202
+ const server = createServer(this.handleRequest.bind(this))
203
+
204
+ try {
205
+ await new Promise<void>((resolvePromise, reject) => {
206
+ server.once('error', reject)
207
+ server.listen(config.port, config.host, () => {
208
+ server.off('error', reject)
209
+ resolvePromise()
210
+ })
211
+ })
212
+
213
+ const address = server.address()
214
+
215
+ if (address === null) {
216
+ this.listening = {
217
+ host: config.host,
218
+ port: config.port,
219
+ }
220
+ } else if (typeof address === 'string') {
221
+ this.listening = { host: address, port: config.port }
222
+ } else {
223
+ this.listening = {
224
+ host: config.host,
225
+ port: address.port,
226
+ }
227
+ }
228
+
229
+ this.httpServer = server
230
+ this.deps.logger.info(
231
+ `webui listening on http://${config.host}:${this.listening.port}`
232
+ )
233
+
234
+ return this.listening
235
+ } catch (error) {
236
+ this.deps.logger.error('webui listen failed', error)
237
+ server.close()
238
+
239
+ return null
240
+ }
241
+ }
242
+
243
+ get requiresRestart(): boolean {
244
+ return this.httpServer !== null
245
+ }
246
+
247
+ async stop(): Promise<void> {
248
+ for (const connection of [...this.sse]) connection.close()
249
+
250
+ this.sse.clear()
251
+ this.auth.invalidateAll()
252
+
253
+ const server = this.httpServer
254
+
255
+ if (server === null) return
256
+
257
+ this.httpServer = null
258
+ this.listening = null
259
+
260
+ await new Promise<void>((resolvePromise) => {
261
+ server.close(() => resolvePromise())
262
+ })
263
+ }
264
+
265
+ invalidateSessions(): void {
266
+ this.auth.invalidateAll()
267
+ }
268
+
269
+ private applySecurityHeaders(res: ServerResponse): void {
270
+ for (const [key, value] of Object.entries(SECURITY_HEADERS)) {
271
+ res.setHeader(key, value)
272
+ }
273
+
274
+ if (this.cspCache === null) {
275
+ // Derived once per server instance from the built index.html.
276
+ const staticDir = this.deps.paths.staticDir
277
+
278
+ this.cspCache =
279
+ staticDir !== null
280
+ ? (cspForStaticDir(staticDir) ?? buildCsp("'self'"))
281
+ : buildCsp("'self'")
282
+ }
283
+
284
+ res.setHeader('content-security-policy', this.cspCache)
285
+ }
286
+
287
+ private async handleRequest(
288
+ req: IncomingMessage,
289
+ res: ServerResponse
290
+ ): Promise<void> {
291
+ this.applySecurityHeaders(res)
292
+
293
+ try {
294
+ const pathname = pathnameOf(req)
295
+
296
+ if (pathname === '/api/health') {
297
+ res.setHeader('cache-control', 'no-store')
298
+ sendData(res, 200, { ok: true })
299
+
300
+ return
301
+ }
302
+
303
+ if (!pathname.startsWith('/api/') && pathname !== '/api') {
304
+ await this.serveStatic(req, res, pathname)
305
+
306
+ return
307
+ }
308
+
309
+ if (!WRITE_METHODS.has(req.method ?? 'GET')) {
310
+ assertSameOrigin(req, this.deps.getWebUiConfig().trustProxy)
311
+ } else {
312
+ assertSameOrigin(req, this.deps.getWebUiConfig().trustProxy)
313
+ assertCsrf(req)
314
+ }
315
+
316
+ const segments = segmentsOf(pathname)
317
+
318
+ if (segments[1] === 'auth') {
319
+ await this.handleAuth(req, res, segments[2])
320
+
321
+ return
322
+ }
323
+
324
+ const session = this.authenticate(req)
325
+
326
+ if (session === null) {
327
+ throw new HttpError(401, '未登录或会话已过期')
328
+ }
329
+
330
+ if (segments[1] === 'logs' && segments[2] === 'stream') {
331
+ this.openSse(
332
+ req,
333
+ res,
334
+ (listener) => this.deps.logs.subscribe(listener),
335
+ Date.now()
336
+ )
337
+
338
+ return
339
+ }
340
+
341
+ if (segments[1] === 'events' && segments[2] === 'stream') {
342
+ this.openSse(
343
+ req,
344
+ res,
345
+ (listener) => this.deps.events.subscribe(listener),
346
+ Date.now()
347
+ )
348
+
349
+ return
350
+ }
351
+
352
+ const handled = await handleApiRoute({
353
+ deps: this.deps,
354
+ req,
355
+ res,
356
+ method: req.method ?? 'GET',
357
+ pathname,
358
+ segments,
359
+ search: parseSearch(req),
360
+ session,
361
+ })
362
+
363
+ if (handled) return
364
+
365
+ throw new HttpError(404, `not found: ${req.method} ${pathname}`)
366
+ } catch (error) {
367
+ respondWithError(res, error)
368
+ }
369
+ }
370
+
371
+ private authenticate(req: IncomingMessage): AuthSession | null {
372
+ const cookies = parseCookies(req.headers.cookie)
373
+
374
+ return this.auth.authenticate(cookies[SESSION_COOKIE])
375
+ }
376
+
377
+ private async handleAuth(
378
+ req: IncomingMessage,
379
+ res: ServerResponse,
380
+ action: string | undefined
381
+ ): Promise<void> {
382
+ const method = req.method ?? 'GET'
383
+
384
+ if (action === 'login' && method === 'POST') {
385
+ const body = await readJsonBody(req, this.bodyLimit)
386
+ const value =
387
+ typeof body === 'object' && body !== null
388
+ ? (body as Record<string, unknown>)
389
+ : {}
390
+ const ip = clientIp(req, this.deps.getWebUiConfig().trustProxy)
391
+ const result = await this.auth.login(
392
+ value['username'],
393
+ value['password'],
394
+ ip
395
+ )
396
+
397
+ if (!result.ok || !result.session) {
398
+ const headers: Record<string, string | undefined> = {}
399
+
400
+ if (result.retryAfterSeconds !== undefined) {
401
+ headers['retry-after'] = String(result.retryAfterSeconds)
402
+ }
403
+
404
+ sendError(
405
+ res,
406
+ result.status,
407
+ result.message ?? 'login failed',
408
+ undefined,
409
+ headers
410
+ )
411
+
412
+ return
413
+ }
414
+
415
+ res.setHeader(
416
+ 'set-cookie',
417
+ serializeCookie(SESSION_COOKIE, result.session.token, {
418
+ maxAgeSeconds:
419
+ this.deps.getWebUiConfig().sessionTtlHours * 3600,
420
+ secure: this.isSecure(req),
421
+ })
422
+ )
423
+ sendData(res, result.status, {
424
+ username: result.session.username,
425
+ expiresAt: result.session.expiresAt,
426
+ })
427
+
428
+ return
429
+ }
430
+
431
+ if (action === 'logout' && method === 'POST') {
432
+ const session = this.authenticate(req)
433
+
434
+ this.auth.logout(this.tokenOf(req))
435
+ this.deps.logger.info(
436
+ `webui logout: ${session?.username ?? 'unknown'}`
437
+ )
438
+ res.setHeader('set-cookie', clearCookie(SESSION_COOKIE))
439
+ sendData(res, 200, { ok: true })
440
+
441
+ return
442
+ }
443
+
444
+ if (action === 'me' && method === 'GET') {
445
+ const session = this.authenticate(req)
446
+
447
+ if (session === null) {
448
+ throw new HttpError(401, '未登录或会话已过期')
449
+ }
450
+
451
+ sendData(res, 200, {
452
+ username: session.username,
453
+ expiresAt: session.expiresAt,
454
+ })
455
+
456
+ return
457
+ }
458
+
459
+ throw new HttpError(
460
+ 404,
461
+ `not found: ${method} /api/auth/${action ?? ''}`
462
+ )
463
+ }
464
+
465
+ private tokenOf(req: IncomingMessage): string | undefined {
466
+ const cookies = parseCookies(req.headers.cookie)
467
+
468
+ return cookies[SESSION_COOKIE]
469
+ }
470
+
471
+ private isSecure(req: IncomingMessage): boolean {
472
+ if (!this.deps.getWebUiConfig().trustProxy) return false
473
+
474
+ const proto = req.headers['x-forwarded-proto']
475
+
476
+ return (
477
+ typeof proto === 'string' && proto.split(',')[0]?.trim() === 'https'
478
+ )
479
+ }
480
+
481
+ private openSse(
482
+ req: IncomingMessage,
483
+ res: ServerResponse,
484
+ subscribe: (listener: (entry: unknown) => void) => () => void,
485
+ openedAt: number
486
+ ): void {
487
+ if (this.sse.size >= this.maxSseClients) {
488
+ throw new HttpError(503, '实时连接数量已达上限')
489
+ }
490
+
491
+ res.writeHead(200, {
492
+ 'content-type': 'text/event-stream; charset=utf-8',
493
+ 'cache-control': 'no-store, no-transform',
494
+ connection: 'keep-alive',
495
+ })
496
+ res.write(':ok\n\n')
497
+
498
+ const connection: SseConnection = {
499
+ res,
500
+ close: () => {
501
+ unsubscribe()
502
+ clearInterval(heartbeat)
503
+ this.sse.delete(connection)
504
+
505
+ try {
506
+ res.end()
507
+ } catch {
508
+ // Already closed.
509
+ }
510
+ },
511
+ }
512
+
513
+ let closing = false
514
+
515
+ const unsubscribe = subscribe((entry) => {
516
+ if (closing) return
517
+
518
+ res.write(`data: ${safeStringify(entry)}\n\n`)
519
+ })
520
+
521
+ const heartbeat = setInterval(() => {
522
+ res.write(`:keepalive ${Date.now() - openedAt}\n\n`)
523
+ }, SSE_HEARTBEAT_MS)
524
+
525
+ req.on('close', () => {
526
+ if (closing) return
527
+
528
+ closing = true
529
+
530
+ unsubscribe()
531
+ clearInterval(heartbeat)
532
+ this.sse.delete(connection)
533
+ })
534
+
535
+ this.sse.add(connection)
536
+ }
537
+
538
+ private async serveStatic(
539
+ req: IncomingMessage,
540
+ res: ServerResponse,
541
+ pathname: string
542
+ ): Promise<void> {
543
+ if (req.method !== 'GET' && req.method !== 'HEAD') {
544
+ throw new HttpError(405, `method not allowed: ${req.method}`)
545
+ }
546
+
547
+ const staticDir = this.deps.paths.staticDir
548
+
549
+ if (staticDir === null) {
550
+ res.setHeader('content-type', 'text/html; charset=utf-8')
551
+ res.setHeader('cache-control', 'no-store')
552
+ res.writeHead(503)
553
+ res.end(this.fallbackBody)
554
+
555
+ return
556
+ }
557
+
558
+ const cleaned = pathname.replace(/\/+$/, '') || '/'
559
+ let filePath =
560
+ cleaned === '/'
561
+ ? join(staticDir, 'index.html')
562
+ : resolve(staticDir, `.${cleaned}`)
563
+
564
+ if (!filePath.startsWith(staticDir + sep) && filePath !== staticDir) {
565
+ throw new HttpError(403, 'forbidden')
566
+ }
567
+
568
+ let fileStat = await this.statOrNull(filePath)
569
+
570
+ if (fileStat?.isDirectory()) {
571
+ filePath = join(filePath, 'index.html')
572
+ fileStat = await this.statOrNull(filePath)
573
+ }
574
+
575
+ if (fileStat === null) {
576
+ // SPA fallback: only routes without a file extension.
577
+ if (extname(cleaned) === '') {
578
+ filePath = join(staticDir, 'index.html')
579
+ fileStat = await this.statOrNull(filePath)
580
+ }
581
+
582
+ if (fileStat === null) throw new HttpError(404, 'not found')
583
+ }
584
+
585
+ const cacheControl = isImmutableAsset(cleaned)
586
+ ? 'public, max-age=31536000, immutable'
587
+ : 'no-cache'
588
+
589
+ res.setHeader('content-type', mimeType(filePath))
590
+ res.setHeader('cache-control', cacheControl)
591
+ res.setHeader('content-length', String(fileStat.size))
592
+ res.writeHead(200)
593
+
594
+ if (req.method === 'HEAD') {
595
+ res.end()
596
+
597
+ return
598
+ }
599
+
600
+ const stream = createReadStream(filePath)
601
+
602
+ stream.on('error', () => {
603
+ try {
604
+ res.end()
605
+ } catch {
606
+ // Already closed.
607
+ }
608
+ })
609
+ stream.pipe(res)
610
+ }
611
+
612
+ private async statOrNull(
613
+ filePath: string
614
+ ): Promise<Awaited<ReturnType<typeof stat>> | null> {
615
+ try {
616
+ return await withTimeout(stat(filePath), 1_000)
617
+ } catch {
618
+ return null
619
+ }
620
+ }
621
+ }
@@ -0,0 +1,100 @@
1
+ import type { BotApi } from '../api.js'
2
+ import type { AppConfig, WebUiConfig } from '../config/schema.js'
3
+ import type { Kernel } from '../kernel.js'
4
+ import type { LogBuffer } from '../logging/log-buffer.js'
5
+ import type { Logger } from '../logging/logger.js'
6
+ import type { AnyPluginDefinition, PluginInstaller } from '../plugin/types.js'
7
+ import type { Database } from '../storage/database.js'
8
+ import type { EventFeed } from './event-feed.js'
9
+
10
+ export interface WebUiRuntime {
11
+ generation: number
12
+ config: AppConfig
13
+ kernel: Kernel
14
+ api: BotApi
15
+ database: Database
16
+ installer: PluginInstaller
17
+ plugins: AnyPluginDefinition[]
18
+ }
19
+
20
+ export interface WebUiPaths {
21
+ configPath: string
22
+ dataDir: string
23
+ pluginDir: string
24
+ staticDir: string | null
25
+ }
26
+
27
+ export interface WebUiAddress {
28
+ host: string
29
+ port: number
30
+ }
31
+
32
+ export interface ConfigDocument {
33
+ path: string
34
+ raw: string
35
+ hash: string
36
+ config: AppConfig
37
+ }
38
+
39
+ export interface ConfigValidationIssue {
40
+ path: string
41
+ message: string
42
+ }
43
+
44
+ export interface ConfigValidationResult {
45
+ ok: boolean
46
+ issues: ConfigValidationIssue[]
47
+ config?: AppConfig
48
+ }
49
+
50
+ export interface ConfigApplyResult {
51
+ ok: boolean
52
+ hash?: string
53
+ issues?: ConfigValidationIssue[]
54
+ error?: string
55
+ }
56
+
57
+ export interface WebUiDeps {
58
+ logger: Logger
59
+ paths: WebUiPaths
60
+ version: string
61
+ logs: LogBuffer
62
+ events: EventFeed
63
+
64
+ /** Current webui settings; picked up after runtime restarts. */
65
+ getWebUiConfig(): WebUiConfig
66
+
67
+ readConfig(): Promise<ConfigDocument>
68
+ validateConfig(value: unknown): Promise<ConfigValidationResult>
69
+ applyConfig(
70
+ value: unknown,
71
+ baseHash?: string | undefined
72
+ ): Promise<ConfigApplyResult>
73
+
74
+ restart(): Promise<boolean>
75
+ requestRestart(): void
76
+ pause(): void
77
+ resume(): void
78
+
79
+ getListening(): WebUiAddress | null
80
+ invalidateSessions(): void
81
+ runExclusive<T>(task: () => Promise<T>): Promise<T>
82
+
83
+ getRuntime(): WebUiRuntime | null
84
+ isRestarting(): boolean
85
+ getStartupError(): string | null
86
+ getRequiresProcessRestart(): boolean
87
+ }
88
+
89
+ export interface WebUiServerOptions {
90
+ config: WebUiConfig
91
+ deps: WebUiDeps
92
+ /** Maximum number of concurrent SSE clients. Defaults to 20. */
93
+ maxSseClients?: number | undefined
94
+ /** Maximum JSON request body size. Defaults to 1 MiB. */
95
+ bodyLimitBytes?: number | undefined
96
+ /** Login failures allowed per IP inside the window. Defaults to 5. */
97
+ loginMaxFailures?: number | undefined
98
+ /** Login failure window. Defaults to 15 minutes. */
99
+ loginWindowMs?: number | undefined
100
+ }