@jsbot-tellus/core 2.5.0 → 2.6.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 (175) hide show
  1. package/README.md +8 -5
  2. package/dist/app/cli.d.ts +10 -0
  3. package/dist/app/cli.d.ts.map +1 -0
  4. package/dist/app/cli.js +37 -0
  5. package/dist/app/cli.js.map +1 -0
  6. package/dist/app/runtime.d.ts +121 -0
  7. package/dist/app/runtime.d.ts.map +1 -0
  8. package/dist/app/runtime.js +296 -0
  9. package/dist/app/runtime.js.map +1 -0
  10. package/dist/app.js +63 -273
  11. package/dist/app.js.map +1 -1
  12. package/dist/core/plugin/command-params.d.ts +3 -37
  13. package/dist/core/plugin/command-params.d.ts.map +1 -1
  14. package/dist/core/plugin/command-params.js +2 -580
  15. package/dist/core/plugin/command-params.js.map +1 -1
  16. package/dist/core/plugin/install/paths.d.ts +7 -0
  17. package/dist/core/plugin/install/paths.d.ts.map +1 -0
  18. package/dist/core/plugin/install/paths.js +49 -0
  19. package/dist/core/plugin/install/paths.js.map +1 -0
  20. package/dist/core/plugin/install/state.d.ts +23 -0
  21. package/dist/core/plugin/install/state.d.ts.map +1 -0
  22. package/dist/core/plugin/install/state.js +129 -0
  23. package/dist/core/plugin/install/state.js.map +1 -0
  24. package/dist/core/plugin/install/validate-script.d.ts +3 -0
  25. package/dist/core/plugin/install/validate-script.d.ts.map +1 -0
  26. package/dist/core/plugin/install/validate-script.js +154 -0
  27. package/dist/core/plugin/install/validate-script.js.map +1 -0
  28. package/dist/core/plugin/install-dir.d.ts +3 -28
  29. package/dist/core/plugin/install-dir.d.ts.map +1 -1
  30. package/dist/core/plugin/install-dir.js +3 -326
  31. package/dist/core/plugin/install-dir.js.map +1 -1
  32. package/dist/core/plugin/installer/checks.d.ts +6 -0
  33. package/dist/core/plugin/installer/checks.d.ts.map +1 -0
  34. package/dist/core/plugin/installer/checks.js +99 -0
  35. package/dist/core/plugin/installer/checks.js.map +1 -0
  36. package/dist/core/plugin/installer/support.d.ts +37 -0
  37. package/dist/core/plugin/installer/support.d.ts.map +1 -0
  38. package/dist/core/plugin/installer/support.js +67 -0
  39. package/dist/core/plugin/installer/support.js.map +1 -0
  40. package/dist/core/plugin/installer/warnings.d.ts +5 -0
  41. package/dist/core/plugin/installer/warnings.d.ts.map +1 -0
  42. package/dist/core/plugin/installer/warnings.js +28 -0
  43. package/dist/core/plugin/installer/warnings.js.map +1 -0
  44. package/dist/core/plugin/installer.d.ts +4 -30
  45. package/dist/core/plugin/installer.d.ts.map +1 -1
  46. package/dist/core/plugin/installer.js +21 -202
  47. package/dist/core/plugin/installer.js.map +1 -1
  48. package/dist/core/plugin/params/compile.d.ts +3 -0
  49. package/dist/core/plugin/params/compile.d.ts.map +1 -0
  50. package/dist/core/plugin/params/compile.js +23 -0
  51. package/dist/core/plugin/params/compile.js.map +1 -0
  52. package/dist/core/plugin/params/declaration.d.ts +9 -0
  53. package/dist/core/plugin/params/declaration.d.ts.map +1 -0
  54. package/dist/core/plugin/params/declaration.js +286 -0
  55. package/dist/core/plugin/params/declaration.js.map +1 -0
  56. package/dist/core/plugin/params/input.d.ts +5 -0
  57. package/dist/core/plugin/params/input.d.ts.map +1 -0
  58. package/dist/core/plugin/params/input.js +196 -0
  59. package/dist/core/plugin/params/input.js.map +1 -0
  60. package/dist/core/plugin/params/reply.d.ts +3 -0
  61. package/dist/core/plugin/params/reply.d.ts.map +1 -0
  62. package/dist/core/plugin/params/reply.js +49 -0
  63. package/dist/core/plugin/params/reply.js.map +1 -0
  64. package/dist/core/plugin/params/type-level.d.ts +24 -0
  65. package/dist/core/plugin/params/type-level.d.ts.map +1 -0
  66. package/dist/core/plugin/params/type-level.js +2 -0
  67. package/dist/core/plugin/params/type-level.js.map +1 -0
  68. package/dist/core/plugin/params/types.d.ts +33 -0
  69. package/dist/core/plugin/params/types.d.ts.map +1 -0
  70. package/dist/core/plugin/params/types.js +12 -0
  71. package/dist/core/plugin/params/types.js.map +1 -0
  72. package/dist/core/plugin/params/values.d.ts +7 -0
  73. package/dist/core/plugin/params/values.d.ts.map +1 -0
  74. package/dist/core/plugin/params/values.js +111 -0
  75. package/dist/core/plugin/params/values.js.map +1 -0
  76. package/dist/core/web/api.d.ts +2 -14
  77. package/dist/core/web/api.d.ts.map +1 -1
  78. package/dist/core/web/api.js +8 -500
  79. package/dist/core/web/api.js.map +1 -1
  80. package/dist/core/web/routes/actions.d.ts +4 -0
  81. package/dist/core/web/routes/actions.d.ts.map +1 -0
  82. package/dist/core/web/routes/actions.js +33 -0
  83. package/dist/core/web/routes/actions.js.map +1 -0
  84. package/dist/core/web/routes/config.d.ts +5 -0
  85. package/dist/core/web/routes/config.d.ts.map +1 -0
  86. package/dist/core/web/routes/config.js +30 -0
  87. package/dist/core/web/routes/config.js.map +1 -0
  88. package/dist/core/web/routes/kv.d.ts +6 -0
  89. package/dist/core/web/routes/kv.d.ts.map +1 -0
  90. package/dist/core/web/routes/kv.js +80 -0
  91. package/dist/core/web/routes/kv.js.map +1 -0
  92. package/dist/core/web/routes/plugins.d.ts +5 -0
  93. package/dist/core/web/routes/plugins.d.ts.map +1 -0
  94. package/dist/core/web/routes/plugins.js +98 -0
  95. package/dist/core/web/routes/plugins.js.map +1 -0
  96. package/dist/core/web/routes/sessions.d.ts +8 -0
  97. package/dist/core/web/routes/sessions.d.ts.map +1 -0
  98. package/dist/core/web/routes/sessions.js +130 -0
  99. package/dist/core/web/routes/sessions.js.map +1 -0
  100. package/dist/core/web/routes/shared.d.ts +18 -0
  101. package/dist/core/web/routes/shared.d.ts.map +1 -0
  102. package/dist/core/web/routes/shared.js +19 -0
  103. package/dist/core/web/routes/shared.js.map +1 -0
  104. package/dist/core/web/routes/status.d.ts +6 -0
  105. package/dist/core/web/routes/status.d.ts.map +1 -0
  106. package/dist/core/web/routes/status.js +132 -0
  107. package/dist/core/web/routes/status.js.map +1 -0
  108. package/dist/core/web/server/auth.d.ts +17 -0
  109. package/dist/core/web/server/auth.d.ts.map +1 -0
  110. package/dist/core/web/server/auth.js +68 -0
  111. package/dist/core/web/server/auth.js.map +1 -0
  112. package/dist/core/web/server/csp.d.ts +11 -0
  113. package/dist/core/web/server/csp.d.ts.map +1 -0
  114. package/dist/core/web/server/csp.js +31 -0
  115. package/dist/core/web/server/csp.js.map +1 -0
  116. package/dist/core/web/server/request.d.ts +6 -0
  117. package/dist/core/web/server/request.d.ts.map +1 -0
  118. package/dist/core/web/server/request.js +33 -0
  119. package/dist/core/web/server/request.js.map +1 -0
  120. package/dist/core/web/server/security.d.ts +4 -0
  121. package/dist/core/web/server/security.d.ts.map +1 -0
  122. package/dist/core/web/server/security.js +12 -0
  123. package/dist/core/web/server/security.js.map +1 -0
  124. package/dist/core/web/server/sse.d.ts +18 -0
  125. package/dist/core/web/server/sse.d.ts.map +1 -0
  126. package/dist/core/web/server/sse.js +49 -0
  127. package/dist/core/web/server/sse.js.map +1 -0
  128. package/dist/core/web/server/static.d.ts +16 -0
  129. package/dist/core/web/server/static.d.ts.map +1 -0
  130. package/dist/core/web/server/static.js +80 -0
  131. package/dist/core/web/server/static.js.map +1 -0
  132. package/dist/core/web/server.d.ts +0 -7
  133. package/dist/core/web/server.d.ts.map +1 -1
  134. package/dist/core/web/server.js +41 -263
  135. package/dist/core/web/server.js.map +1 -1
  136. package/docs/architecture.md +28 -11
  137. package/docs/configuration.md +1 -1
  138. package/docs/migration.md +2 -2
  139. package/docs/plugin-api.md +24 -11
  140. package/docs/plugin-development.md +1 -1
  141. package/package.json +19 -17
  142. package/src/app/cli.ts +47 -0
  143. package/src/app/runtime.ts +522 -0
  144. package/src/app.ts +87 -395
  145. package/src/core/plugin/command-params.ts +8 -871
  146. package/src/core/plugin/install/paths.ts +72 -0
  147. package/src/core/plugin/install/state.ts +196 -0
  148. package/src/core/plugin/install/validate-script.ts +154 -0
  149. package/src/core/plugin/install-dir.ts +19 -427
  150. package/src/core/plugin/installer/checks.ts +137 -0
  151. package/src/core/plugin/installer/support.ts +120 -0
  152. package/src/core/plugin/installer/warnings.ts +52 -0
  153. package/src/core/plugin/installer.ts +52 -301
  154. package/src/core/plugin/params/compile.ts +41 -0
  155. package/src/core/plugin/params/declaration.ts +394 -0
  156. package/src/core/plugin/params/input.ts +281 -0
  157. package/src/core/plugin/params/reply.ts +59 -0
  158. package/src/core/plugin/params/type-level.ts +75 -0
  159. package/src/core/plugin/params/types.ts +55 -0
  160. package/src/core/plugin/params/values.ts +150 -0
  161. package/src/core/web/api.ts +33 -759
  162. package/src/core/web/routes/actions.ts +43 -0
  163. package/src/core/web/routes/config.ts +42 -0
  164. package/src/core/web/routes/kv.ts +122 -0
  165. package/src/core/web/routes/plugins.ts +151 -0
  166. package/src/core/web/routes/sessions.ts +188 -0
  167. package/src/core/web/routes/shared.ts +42 -0
  168. package/src/core/web/routes/status.ts +191 -0
  169. package/src/core/web/server/auth.ts +126 -0
  170. package/src/core/web/server/csp.ts +33 -0
  171. package/src/core/web/server/request.ts +38 -0
  172. package/src/core/web/server/security.ts +14 -0
  173. package/src/core/web/server/sse.ts +74 -0
  174. package/src/core/web/server/static.ts +112 -0
  175. package/src/core/web/server.ts +41 -383
@@ -1,103 +1,32 @@
1
1
  import {
2
2
  createServer,
3
3
  type IncomingMessage,
4
- type RequestListener,
5
4
  type Server,
6
5
  type ServerResponse,
7
6
  } 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
7
  import { handleApiRoute } from './api.js'
8
+ import { AuthManager } from './auth.js'
14
9
  import {
15
10
  assertCsrf,
16
11
  assertSameOrigin,
17
- clearCookie,
18
- clientIp,
19
- isImmutableAsset,
20
- mimeType,
21
- parseCookies,
22
- readJsonBody,
23
12
  respondWithError,
24
- safeStringify,
25
- sendError,
26
13
  sendData,
27
- serializeCookie,
28
- withTimeout,
29
- type URLSearchParamsLike,
30
14
  } from './http.js'
31
15
  import { HttpError } from './http.js'
16
+ import { authenticate, handleAuth } from './server/auth.js'
17
+ import { buildCsp, cspForStaticDir } from './server/csp.js'
18
+ import { parseSearch, pathnameOf, segmentsOf } from './server/request.js'
19
+ import { applySecurityHeaders } from './server/security.js'
20
+ import {
21
+ DEFAULT_MAX_SSE_CLIENTS,
22
+ openSse,
23
+ type SseConnection,
24
+ } from './server/sse.js'
25
+ import { FALLBACK_BODY, serveStatic } from './server/static.js'
32
26
  import type { WebUiAddress, WebUiDeps, WebUiServerOptions } from './types.js'
33
27
 
34
- const SESSION_COOKIE = 'jsbot_webui'
35
-
36
28
  const WRITE_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE'])
37
29
 
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
30
  function errorPayload(error: unknown): {
102
31
  status: number
103
32
  message: string
@@ -118,42 +47,6 @@ function errorPayload(error: unknown): {
118
47
  }
119
48
  }
120
49
 
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
50
  export class WebUiServer {
158
51
  private readonly deps: WebUiDeps
159
52
  private readonly maxSseClients: number
@@ -267,9 +160,7 @@ export class WebUiServer {
267
160
  }
268
161
 
269
162
  private applySecurityHeaders(res: ServerResponse): void {
270
- for (const [key, value] of Object.entries(SECURITY_HEADERS)) {
271
- res.setHeader(key, value)
272
- }
163
+ applySecurityHeaders(res)
273
164
 
274
165
  if (this.cspCache === null) {
275
166
  // Derived once per server instance from the built index.html.
@@ -301,7 +192,13 @@ export class WebUiServer {
301
192
  }
302
193
 
303
194
  if (!pathname.startsWith('/api/') && pathname !== '/api') {
304
- await this.serveStatic(req, res, pathname)
195
+ await serveStatic({
196
+ req,
197
+ res,
198
+ pathname,
199
+ staticDir: this.deps.paths.staticDir,
200
+ fallbackBody: this.fallbackBody,
201
+ })
305
202
 
306
203
  return
307
204
  }
@@ -316,35 +213,47 @@ export class WebUiServer {
316
213
  const segments = segmentsOf(pathname)
317
214
 
318
215
  if (segments[1] === 'auth') {
319
- await this.handleAuth(req, res, segments[2])
216
+ await handleAuth({
217
+ req,
218
+ res,
219
+ action: segments[2],
220
+ auth: this.auth,
221
+ deps: this.deps,
222
+ bodyLimit: this.bodyLimit,
223
+ })
320
224
 
321
225
  return
322
226
  }
323
227
 
324
- const session = this.authenticate(req)
228
+ const session = authenticate(this.auth, req)
325
229
 
326
230
  if (session === null) {
327
231
  throw new HttpError(401, '未登录或会话已过期')
328
232
  }
329
233
 
330
234
  if (segments[1] === 'logs' && segments[2] === 'stream') {
331
- this.openSse(
235
+ openSse({
332
236
  req,
333
237
  res,
334
- (listener) => this.deps.logs.subscribe(listener),
335
- Date.now()
336
- )
238
+ subscribe: (listener) => this.deps.logs.subscribe(listener),
239
+ openedAt: Date.now(),
240
+ maxClients: this.maxSseClients,
241
+ connections: this.sse,
242
+ })
337
243
 
338
244
  return
339
245
  }
340
246
 
341
247
  if (segments[1] === 'events' && segments[2] === 'stream') {
342
- this.openSse(
248
+ openSse({
343
249
  req,
344
250
  res,
345
- (listener) => this.deps.events.subscribe(listener),
346
- Date.now()
347
- )
251
+ subscribe: (listener) =>
252
+ this.deps.events.subscribe(listener),
253
+ openedAt: Date.now(),
254
+ maxClients: this.maxSseClients,
255
+ connections: this.sse,
256
+ })
348
257
 
349
258
  return
350
259
  }
@@ -367,255 +276,4 @@ export class WebUiServer {
367
276
  respondWithError(res, error)
368
277
  }
369
278
  }
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
279
  }