acrylblends 1.0.10 → 1.2.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 (252) hide show
  1. package/README.md +9 -0
  2. package/dist/index.d.ts +60 -2
  3. package/dist/index.js +1992 -33
  4. package/dist/index.js.map +1 -1
  5. package/docs/framework/agents/index.md +1 -1
  6. package/docs/framework/api/summary.md +1 -1
  7. package/docs/framework/getting-started/desktop.md +23 -4
  8. package/docs/framework/guides/create-acrylblends.md +1 -1
  9. package/docs/framework/guides/production.md +20 -0
  10. package/docs/framework/workshop/README.md +174 -285
  11. package/docs/framework/workshop/facilitator.md +81 -90
  12. package/legacy/README.md +9 -0
  13. package/legacy/agent/157ce3db1112/src/app.ts +98 -0
  14. package/legacy/agent/157ce3db1112/src/routes.ts +63 -0
  15. package/legacy/agent/24c681a00fca/src/app.ts +160 -0
  16. package/legacy/agent/24c681a00fca/src/routes.ts +83 -0
  17. package/legacy/agent/2e790da977b2/src/app.ts +98 -0
  18. package/legacy/agent/2e790da977b2/src/routes.ts +63 -0
  19. package/legacy/agent/6307045545dc/src/app.ts +168 -0
  20. package/legacy/agent/6307045545dc/src/routes.ts +83 -0
  21. package/legacy/agent/6a5fb9c15c53/src/app.ts +158 -0
  22. package/legacy/agent/6a5fb9c15c53/src/routes.ts +83 -0
  23. package/legacy/agent/71fb54a5a942/src/app.ts +111 -0
  24. package/legacy/agent/71fb54a5a942/src/routes.ts +63 -0
  25. package/legacy/agent/74ece148269c/src/app.ts +168 -0
  26. package/legacy/agent/74ece148269c/src/routes.ts +93 -0
  27. package/legacy/agent/855359304707/src/app.ts +168 -0
  28. package/legacy/agent/855359304707/src/routes.ts +126 -0
  29. package/legacy/agent/b5ef3c786ae4/src/app.ts +127 -0
  30. package/legacy/agent/b5ef3c786ae4/src/routes.ts +76 -0
  31. package/legacy/agent/c2f30958043b/src/app.ts +98 -0
  32. package/legacy/agent/c2f30958043b/src/routes.ts +53 -0
  33. package/legacy/blank/5a91c558af22/src/routes.ts +18 -0
  34. package/legacy/blank/9c650cf52bf3/src/routes.ts +14 -0
  35. package/legacy/blank/aea589283445/src/routes.ts +16 -0
  36. package/legacy/blank/b2cbb759c889/src/routes.ts +16 -0
  37. package/legacy/blank/d6d371c9a65e/src/routes.ts +18 -0
  38. package/legacy/chat/7076cc821e2c/src/app.ts +72 -0
  39. package/legacy/chat/7076cc821e2c/src/routes.ts +13 -0
  40. package/legacy/chat/7dbe6d5170df/src/app.ts +107 -0
  41. package/legacy/chat/7dbe6d5170df/src/routes.ts +19 -0
  42. package/legacy/chat/a9a9ab44c58e/src/app.ts +107 -0
  43. package/legacy/chat/a9a9ab44c58e/src/routes.ts +15 -0
  44. package/legacy/chat/f20ad9a51b06/src/app.ts +106 -0
  45. package/legacy/chat/f20ad9a51b06/src/routes.ts +15 -0
  46. package/legacy/layouts.json +524 -0
  47. package/legacy/nodejs/1d477f37aaf4/src/app.ts +92 -0
  48. package/legacy/nodejs/1d477f37aaf4/src/routes.ts +40 -0
  49. package/legacy/nodejs/b1bd9462496c/src/app.ts +61 -0
  50. package/legacy/nodejs/b1bd9462496c/src/routes.ts +37 -0
  51. package/legacy/nodejs/d5bab75076ef/src/app.ts +95 -0
  52. package/legacy/nodejs/d5bab75076ef/src/routes.ts +40 -0
  53. package/legacy/notes/4edc36949c15/src/app.ts +107 -0
  54. package/legacy/notes/4edc36949c15/src/routes.ts +14 -0
  55. package/legacy/notes/6cab2d09747c/src/app.ts +72 -0
  56. package/legacy/notes/6cab2d09747c/src/routes.ts +12 -0
  57. package/legacy/notes/7ca2dfed623a/src/app.ts +106 -0
  58. package/legacy/notes/7ca2dfed623a/src/routes.ts +14 -0
  59. package/legacy/todo/02e613b3625c/src/app.ts +71 -0
  60. package/legacy/todo/02e613b3625c/src/todo.ts +123 -0
  61. package/legacy/todo/45694a8d9e15/src/app.ts +71 -0
  62. package/legacy/todo/45694a8d9e15/src/todo.ts +96 -0
  63. package/legacy/todo/5923d3a6099f/src/app.ts +71 -0
  64. package/legacy/todo/5923d3a6099f/src/todo.ts +125 -0
  65. package/legacy/todo/849a18d66dc1/src/app.ts +91 -0
  66. package/legacy/todo/849a18d66dc1/src/todo.ts +125 -0
  67. package/legacy/todo/8e64211312f3/src/app.ts +71 -0
  68. package/legacy/todo/8e64211312f3/src/todo.ts +125 -0
  69. package/legacy/todo/b79078a87565/src/app.ts +71 -0
  70. package/legacy/todo/b79078a87565/src/todo.ts +125 -0
  71. package/lib/{audit-Dw-siYTp.d.ts → audit-Bok_UNCn.d.ts} +41 -6
  72. package/lib/{client-modules-drj4mjhj.js → client-modules-Bkdr83jO.js} +1 -1
  73. package/lib/cordis/door.d.ts +44 -7
  74. package/lib/cordis/door.js +214 -60
  75. package/lib/cordis/inspect.js +1 -1
  76. package/lib/cordis/node-server.d.ts +101 -3
  77. package/lib/cordis/node-server.js +3 -3
  78. package/lib/cordis/router-http.d.ts +7 -1
  79. package/lib/cordis/router-http.js +44 -3
  80. package/lib/cordis/router.d.ts +1 -1
  81. package/lib/cordis/router.js +1 -1
  82. package/lib/cordis/state.d.ts +3 -2
  83. package/lib/cordis/state.js +371 -7
  84. package/lib/door/host.d.ts +2 -2
  85. package/lib/door/host.js +1 -1
  86. package/lib/door/page.d.ts +20 -1
  87. package/lib/door/page.js +75 -14
  88. package/lib/door/protocol.d.ts +1 -1
  89. package/lib/drivers.d.ts +10 -1
  90. package/lib/{index-DMOtA-NJ.d.ts → index-DGq7H0lb.d.ts} +11 -1
  91. package/lib/node-server.d.ts +7 -1
  92. package/lib/node-server.js +2 -2
  93. package/lib/{protocol-a2aqrp2X.d.ts → protocol-BeIYCgTo.d.ts} +4 -0
  94. package/lib/{snapshot-text-BfEbEOjA.js → snapshot-text-CXTnjxGx.js} +121 -9
  95. package/lib/src-CtkIX_Gg.js +427 -0
  96. package/lib/{src-BTBm4OPD.js → src-iBJ2ysV9.js} +77 -14
  97. package/lib/{test-websocket-BLeEvybj.js → test-websocket-CQOSvBth.js} +17 -7
  98. package/lib/{test-websocket-DyeS50Tr.d.ts → test-websocket-GUTAkFQT.d.ts} +3 -1
  99. package/package.json +2 -1
  100. package/plugins/acryl-door/src/host/channel.ts +124 -9
  101. package/plugins/acryl-door/src/host/chat-tools.ts +8 -6
  102. package/plugins/acryl-door/src/host/door-file.ts +7 -2
  103. package/plugins/acryl-door/src/host/door.ts +58 -23
  104. package/plugins/acryl-door/src/host/page-routes.ts +3 -1
  105. package/plugins/acryl-door/src/host/page-socket.ts +18 -8
  106. package/plugins/acryl-door/src/host/verbs.ts +3 -0
  107. package/plugins/acryl-door/src/index.ts +166 -29
  108. package/plugins/acryl-door/src/page/base.ts +32 -0
  109. package/plugins/acryl-door/src/page/channel.ts +11 -1
  110. package/plugins/acryl-door/src/page/driver/accessibility.ts +11 -0
  111. package/plugins/acryl-door/src/page/driver/actions.ts +4 -0
  112. package/plugins/acryl-door/src/page/driver/driver.ts +49 -7
  113. package/plugins/acryl-door/src/page/driver/snapshot.ts +5 -2
  114. package/plugins/acryl-door/src/page/index.ts +46 -2
  115. package/plugins/acryl-door/src/page/indicator.ts +14 -6
  116. package/plugins/acryl-door/src/page/outline.ts +56 -0
  117. package/plugins/acryl-door/src/page/protocol.ts +4 -0
  118. package/plugins/acryl-door/src/page/snapshot-text.ts +2 -1
  119. package/plugins/acryl-node-server/src/adapter/listener.ts +4 -0
  120. package/plugins/acryl-node-server/src/adapter/raw.ts +18 -0
  121. package/plugins/acryl-node-server/src/adapter/websocket.ts +15 -9
  122. package/plugins/acryl-node-server/src/client-modules.ts +7 -1
  123. package/plugins/acryl-node-server/src/index.ts +304 -16
  124. package/plugins/acryl-node-server/src/upgrade-hub.ts +2 -2
  125. package/plugins/acryl-page/plugin.json +12 -0
  126. package/plugins/acryl-page/src/index.ts +163 -0
  127. package/plugins/acryl-router/src/service.ts +77 -14
  128. package/plugins/acryl-router-http/src/index.ts +15 -1
  129. package/plugins/acryl-router-http/src/serve.ts +31 -0
  130. package/plugins/acryl-state/plugin.json +4 -3
  131. package/plugins/acryl-state/src/client-source.ts +244 -0
  132. package/plugins/acryl-state/src/service.ts +33 -6
  133. package/plugins/acryl-state/src/socket.ts +96 -0
  134. package/plugins/acryl-web-server/plugin.json +14 -0
  135. package/plugins/acryl-web-server/src/index.ts +164 -0
  136. package/plugins/agent/agent.ts +38 -7
  137. package/plugins/agent/index.ts +1 -1
  138. package/plugins/agent/model.ts +10 -2
  139. package/plugins/agent/tools.ts +9 -3
  140. package/plugins/agent-panel/client/describe.ts +57 -0
  141. package/plugins/agent-panel/client/driver-pick.ts +22 -0
  142. package/plugins/agent-panel/client/frame.ts +51 -0
  143. package/plugins/agent-panel/client/main.ts +343 -81
  144. package/plugins/agent-panel/drivers.ts +7 -2
  145. package/plugins/agent-panel/headless.ts +5 -2
  146. package/plugins/agent-panel/index.ts +187 -61
  147. package/plugins/agent-panel/mount.ts +27 -0
  148. package/plugins/agent-panel/visitors.ts +231 -0
  149. package/plugins/desktop/frame.ts +231 -0
  150. package/plugins/desktop/launch.ts +44 -9
  151. package/plugins/desktop/main.ts +100 -32
  152. package/plugins/desktop/window.ts +21 -4
  153. package/plugins/driver-pty/client/terminal.ts +4 -1
  154. package/plugins/driver-pty/index.ts +38 -11
  155. package/plugins/driver-pty/spawn.ts +38 -0
  156. package/plugins/driver-pty/terminal.ts +2 -0
  157. package/plugins/extensions/index.ts +26 -1
  158. package/plugins/extensions/resolve.ts +13 -0
  159. package/plugins/feedback/client.d.ts +22 -0
  160. package/plugins/feedback/client.js +471 -0
  161. package/plugins/feedback/index.ts +143 -0
  162. package/plugins/feedback/plugin.json +11 -0
  163. package/plugins/feedback/store.ts +307 -0
  164. package/plugins/home/index.ts +122 -0
  165. package/plugins/home/plugin.json +11 -0
  166. package/plugins/instance/index.ts +12 -3
  167. package/plugins/manifest/app-plugins.ts +86 -0
  168. package/plugins/manifest/index.ts +12 -2
  169. package/plugins/manifest/snapshot.ts +275 -0
  170. package/plugins/openai/index.ts +64 -5
  171. package/plugins/openai/prompt.ts +23 -0
  172. package/shared/AGENTS.core.md +36 -16
  173. package/shared/skills/acryl-add-ui/SKILL.md +19 -0
  174. package/shared/skills/acryl-app/SKILL.md +13 -0
  175. package/shared/skills/acryl-build-plugin/SKILL.md +22 -0
  176. package/shared/skills/acryl-change-plugin/SKILL.md +14 -0
  177. package/shared/skills/acryl-diagnose-plugin/SKILL.md +13 -0
  178. package/shared/skills/acryl-fix-plugin/SKILL.md +13 -0
  179. package/shared/skills/acryl-remove-plugin/SKILL.md +12 -0
  180. package/shared/skills/acryl-share-plugin/SKILL.md +16 -0
  181. package/shared/skills/cordis-plugin/SKILL.md +26 -0
  182. package/templates/agent/AGENTS.md +4 -2
  183. package/templates/agent/blend.yaml +4 -0
  184. package/templates/agent/gitignore +1 -0
  185. package/templates/agent/package.json +1 -0
  186. package/templates/agent/src/app.ts +150 -33
  187. package/templates/agent/src/frame.ts +17 -0
  188. package/templates/agent/src/main.ts +5 -3
  189. package/templates/agent/src/routes.ts +73 -21
  190. package/templates/agent/template.json +2 -0
  191. package/templates/agent/test/app.test.ts +83 -5
  192. package/templates/blank/AGENTS.md +3 -2
  193. package/templates/blank/blend.yaml +11 -0
  194. package/templates/blank/gitignore +1 -0
  195. package/templates/blank/package.json +1 -0
  196. package/templates/blank/src/app.ts +100 -29
  197. package/templates/blank/src/door.ts +7 -0
  198. package/templates/blank/src/first-page.ts +5 -0
  199. package/templates/blank/src/frame.ts +17 -0
  200. package/templates/blank/src/routes.ts +65 -8
  201. package/templates/blank/template.json +14 -1
  202. package/templates/blank/test/app.test.ts +103 -16
  203. package/templates/chat/blend.yaml +2 -1
  204. package/templates/chat/gitignore +1 -0
  205. package/templates/chat/package.json +1 -0
  206. package/templates/chat/src/app.ts +72 -22
  207. package/templates/chat/src/chat-app.ts +11 -2
  208. package/templates/chat/src/client/chat.tsx +4 -2
  209. package/templates/chat/src/frame.ts +17 -0
  210. package/templates/chat/src/main.ts +1 -6
  211. package/templates/chat/src/page.tsx +6 -4
  212. package/templates/chat/src/routes.ts +14 -4
  213. package/templates/chat/src/runtime.ts +9 -3
  214. package/templates/chat/template.json +3 -1
  215. package/templates/chat/test/chat.test.ts +46 -1
  216. package/templates/chat/test/server.test.ts +17 -3
  217. package/templates/nodejs/blend.yaml +2 -1
  218. package/templates/nodejs/gitignore +1 -0
  219. package/templates/nodejs/package.json +1 -0
  220. package/templates/nodejs/src/app.ts +76 -21
  221. package/templates/nodejs/src/frame.ts +17 -0
  222. package/templates/nodejs/src/main.ts +1 -6
  223. package/templates/nodejs/src/page.tsx +3 -3
  224. package/templates/nodejs/src/routes.ts +32 -13
  225. package/templates/nodejs/template.json +3 -1
  226. package/templates/nodejs/test/app.test.ts +38 -3
  227. package/templates/nodejs/test/server.test.ts +17 -3
  228. package/templates/notes/blend.yaml +2 -1
  229. package/templates/notes/gitignore +1 -0
  230. package/templates/notes/package.json +1 -0
  231. package/templates/notes/src/app.ts +72 -22
  232. package/templates/notes/src/frame.ts +17 -0
  233. package/templates/notes/src/main.ts +1 -6
  234. package/templates/notes/src/notes-app.ts +24 -17
  235. package/templates/notes/src/routes.ts +11 -4
  236. package/templates/notes/src/runtime.ts +9 -3
  237. package/templates/notes/src/views.tsx +26 -17
  238. package/templates/notes/template.json +3 -1
  239. package/templates/notes/test/notes.test.ts +39 -1
  240. package/templates/notes/test/server.test.ts +17 -3
  241. package/templates/todo/blend.yaml +3 -0
  242. package/templates/todo/gitignore +1 -0
  243. package/templates/todo/package.json +1 -0
  244. package/templates/todo/src/app.ts +81 -27
  245. package/templates/todo/src/door.ts +7 -0
  246. package/templates/todo/src/frame.ts +17 -0
  247. package/templates/todo/src/page.ts +13 -6
  248. package/templates/todo/src/todo.ts +18 -6
  249. package/templates/todo/template.json +11 -1
  250. package/templates/todo/test/todo.test.ts +67 -9
  251. package/lib/src-LX0E4ec4.js +0 -117
  252. package/shared/SKILL.md +0 -14
@@ -6,16 +6,19 @@
6
6
  * One adapter per runtime: a plugin keeps asking `ctx.inject(['routerHttp'], ...)` for a mount, which simply never fires here, because this
7
7
  * adapter serves the whole router.
8
8
  *
9
- * A configured port that is taken is followed by the next free one, at most `scan` further ports; port 0 lets the system choose. Disposal ends
10
- * open streams, closes every connection and returns once the port is free.
9
+ * A configured port that is taken is followed by the next free one, at most `scan` further ports; port 0 lets the system choose. A port a
10
+ * browser refuses to open (`RESTRICTED_PORTS`) is skipped like a taken one. Disposal ends open streams, closes every connection and returns
11
+ * once the port is free.
11
12
  */
12
13
 
13
- import type { Server } from 'node:http'
14
+ import type { IncomingMessage, Server, ServerResponse } from 'node:http'
14
15
  import type { AddressInfo } from 'node:net'
16
+ import type { Duplex } from 'node:stream'
15
17
  import { Context, Service } from '@deepseek-ai/cordis'
16
18
  import z from '@deepseek-ai/schemastery'
17
- import type {} from 'acryl-router'
19
+ import type { ConnectionInfo, Router } from 'acryl-router'
18
20
  import { Request as AdapterRequest } from './adapter/request.ts'
21
+ import { RAW_RESPONSE_HANDLED, RAW_UPGRADE_HANDLED, type RawUpgradeRequest } from './adapter/raw.ts'
19
22
  import { createAdaptorServer } from './adapter/server.ts'
20
23
  import { UpgradeHub, type UpgradeHubOptions } from './upgrade-hub.ts'
21
24
  import type { UpgradeWebSocket } from 'acryl-router/helpers'
@@ -30,6 +33,37 @@ export { UpgradeEmitter, UpgradeHub, type UpgradeHubOptions, type UpgradeRouter
30
33
  export const name = 'acryl-node-server'
31
34
  export const inject = ['router']
32
35
 
36
+ /** A raw node handler: the request and response are the platform's, and the adapter writes nothing for them. */
37
+ export interface RawRoute {
38
+ readonly kind: 'prefix' | 'exact'
39
+ readonly path: string
40
+ readonly handler: (req: IncomingMessage, res: ServerResponse) => void | Promise<void>
41
+ }
42
+
43
+ /** A raw upgrade handler: the socket is the handler's once the path matches; the router never sees the upgrade. */
44
+ export interface RawUpgradeRoute {
45
+ readonly path: string
46
+ readonly handler: (req: IncomingMessage, socket: Duplex, head: Buffer) => void | Promise<void>
47
+ }
48
+
49
+ /**
50
+ * Answer a request before raw routes and the router, or pass it on with `undefined`. The callback runs synchronously, so a request no gate answers still
51
+ * reaches a raw route's sentinel synchronously; a gate that needs the router (a host keeping an app's URLs in scope under a prefix, the ACRYL frame's canvas)
52
+ * returns its promise, and the adapter handles that like the router's own answer. `connection` carries the socket facts, which a router mount needs to check
53
+ * the peer and the real port.
54
+ */
55
+ export type RequestGate = (request: Request, connection: ConnectionInfo) => Response | Promise<Response> | undefined
56
+
57
+ /** The pathname of a request target, for raw matching: a malformed one matches nothing (`'/'` matches only itself). */
58
+ function pathnameOf(target: string | undefined): string {
59
+ if (target === undefined) return '/'
60
+ try {
61
+ return new URL(target, 'http://node-server.invalid').pathname
62
+ } catch {
63
+ return '/'
64
+ }
65
+ }
66
+
33
67
  export interface Config {
34
68
  hostname: string
35
69
  port: number
@@ -41,8 +75,8 @@ export interface Config {
41
75
  /** Every field has a default, so a caller passes only what it changes: `ctx.plugin(plugin, { ... })` takes the partial shape. */
42
76
  export const Config: z<Partial<Config>, Config> = z.object({
43
77
  hostname: z.string().default('127.0.0.1').description('Address to bind. Loopback by default; binding elsewhere exposes the app to the network.'),
44
- port: z.number().min(0).max(65535).default(0).description('Port to listen on; 0 lets the system choose. A taken port is followed by the next free one.'),
45
- scan: z.number().min(0).default(20).description('How many further ports to try when the configured one is taken.'),
78
+ port: z.number().min(0).max(65535).default(0).description('Port to listen on; 0 lets the system choose. A taken port, or one a browser refuses, is followed by the next usable one.'),
79
+ scan: z.number().min(0).default(20).description('How many further ports to try when the configured one is taken or a browser refuses it.'),
46
80
  websocket: z
47
81
  .object({
48
82
  maxPayload: z.number().min(1).default(1024 * 1024).description('The largest WebSocket frame accepted, in bytes; a bigger one closes the socket with 1009.'),
@@ -58,31 +92,249 @@ declare module '@deepseek-ai/cordis' {
58
92
  }
59
93
  }
60
94
 
95
+ /**
96
+ * Milliseconds a request already in flight, or an upgraded socket's own owner, may finish once the server stops taking new work. A save during a
97
+ * restart must not be cut; a stream that never ends must not hold the stop open.
98
+ */
99
+ export const STOP_GRACE_MS = 2_000
100
+
61
101
  export class NodeServer extends Service {
62
102
  readonly hostname: string
63
103
  readonly config: Config
64
104
  private readonly server: Server
105
+ private readonly router: Router
65
106
  private hub: UpgradeHub | undefined
66
107
  private bound = 0
108
+ /** The one stop this server runs: a second call (the disposer after an explicit stop) waits on the same promise. */
109
+ private stopping: Promise<void> | undefined
110
+ /** Requests whose response has not finished yet: the stop waits for them before their sockets are cut. */
111
+ private active = 0
112
+ /** Upgraded sockets this server handed out (WebSockets): neither close() nor closeAllConnections() reaches them, so the stop tracks them itself. */
113
+ private readonly upgraded = new Set<Duplex>()
114
+ /** Raw routes registered by the host's compatibility layer (see {@link registerRaw}). */
115
+ private readonly rawExact = new Map<string, RawRoute['handler']>()
116
+ private readonly rawPrefix = new Map<string, RawRoute['handler']>()
117
+ private readonly rawUpgrades = new Map<string, RawUpgradeRoute['handler']>()
118
+ /** Gates registered by a host that maps an app's URLs (see {@link NodeServer.gate}); they run in the order they were added. */
119
+ private readonly gates: RequestGate[] = []
120
+ /** One raw dispatch per upgrade request: a second listener (or a re-added one) must never emit the same request again. */
121
+ private readonly rawUpgradeSeen = new WeakSet<IncomingMessage>()
67
122
 
68
123
  constructor(ctx: Context, config: Config) {
69
124
  super(ctx, 'nodeServer')
70
125
  this.config = config
71
126
  this.hostname = config.hostname
72
127
  const router = ctx.router
128
+ this.router = router
73
129
  // The handler sees the router's bindings (`c.env.connection`), not the raw `incoming`/`outgoing` pair. The global `Request` and
74
130
  // `Response` stay the platform's, because this adapter shares its process with the rest of the app; so the adapter's lightweight
75
131
  // request is made a real one (its signal still aborts when the client goes away) before the router re-creates it with its own signal.
76
132
  this.server = createAdaptorServer({
77
- fetch: (request, { incoming }) => router.fetch(new AdapterRequest(request), { remoteAddress: incoming.socket.remoteAddress, localPort: incoming.socket.localPort }),
133
+ fetch: (request, bindings) => {
134
+ const { incoming, outgoing } = bindings as { incoming: IncomingMessage; outgoing: ServerResponse }
135
+ // One count of a request whose response has not finished: the stop (see the disposer below) lets these finish before their sockets are cut,
136
+ // which is what keeps a save made while the app restarts from being lost.
137
+ this.active += 1
138
+ let finished = false
139
+ const settle = (): void => {
140
+ if (finished) return
141
+ finished = true
142
+ this.active -= 1
143
+ }
144
+ outgoing.once('finish', settle)
145
+ outgoing.once('close', settle)
146
+ // A raw route owns the node response: start it and return the sentinel synchronously, so the adapter (which checks the
147
+ // sentinel before treating the result as a promise) writes nothing. The handler's own failure becomes a 500 here.
148
+ // An upgrade that reaches the HTTP fetch path has no WebSocket listener to claim it (a provider transition, or no hub at all): refuse it
149
+ // here, before any HTTP route (a raw prefix would otherwise answer it as a normal request), so it is never answered twice.
150
+ if (incoming.headers.upgrade !== undefined) {
151
+ const websocket = incoming.headers.upgrade.toLowerCase() === 'websocket'
152
+ return new Response('', {
153
+ status: websocket ? 503 : 400,
154
+ headers: { connection: 'close' },
155
+ })
156
+ }
157
+ // A gate decides before raw routes and the router (a host that keeps an app's URLs under a prefix answers a navigation the frame's root would
158
+ // otherwise claim, and sends the app's own sub-resources to the router so a raw prefix cannot answer for the app): its answer is the request's.
159
+ if (this.gates.length > 0) {
160
+ const requestLike = request as unknown as Request
161
+ const connection: ConnectionInfo = { remoteAddress: incoming.socket.remoteAddress, localPort: incoming.socket.localPort }
162
+ for (const gate of this.gates) {
163
+ const answer = gate(requestLike, connection)
164
+ if (answer !== undefined) return answer
165
+ }
166
+ }
167
+ const raw = this.rawFor(incoming)
168
+ if (raw !== undefined) {
169
+ void (async () => {
170
+ try {
171
+ await raw(incoming, outgoing)
172
+ } catch {
173
+ if (!outgoing.headersSent) {
174
+ outgoing.writeHead(500, { 'content-type': 'text/plain; charset=utf-8' })
175
+ outgoing.end('internal error')
176
+ } else outgoing.destroy()
177
+ }
178
+ })()
179
+ return RAW_RESPONSE_HANDLED
180
+ }
181
+ return router.fetch(new AdapterRequest(request), { remoteAddress: incoming.socket.remoteAddress, localPort: incoming.socket.localPort })
182
+ },
78
183
  hostname: config.hostname,
79
184
  overrideGlobalObjects: false,
80
185
  }) as Server
81
- ctx.effect(() => async () => {
82
- await this.hub?.close()
186
+ // ONE raw upgrade listener for the server's lifetime, ahead of the router's WebSocket setups. It consults the registry, so a claim added or
187
+ // removed never adds or removes a listener; a request it dispatched once is never dispatched again (a guard beside the single ownership).
188
+ this.server.on('upgrade', (request: IncomingMessage, socket: Duplex, head: Buffer) => {
189
+ if (this.rawUpgradeSeen.has(request)) return
190
+ // Upgraded sockets leave the HTTP connection lifecycle: close() and closeAllConnections() never reach them (measured, not assumed), so the stop
191
+ // tracks them and waits for their owner's close frame before cutting what is left.
192
+ this.upgraded.add(socket)
193
+ socket.once('close', () => { this.upgraded.delete(socket) })
194
+ // Node drops the HTTP server's own error listener when it hands a socket to 'upgrade', and the route may still be deciding (a check that
195
+ // reads a store) when the client resets: without a listener of its own, that ECONNRESET would throw and stop the whole server. The socket
196
+ // is simply gone; its owner, if it has one yet, sees the close.
197
+ socket.on('error', () => { socket.destroy() })
198
+ const websocket = request.headers.upgrade?.toLowerCase() === 'websocket'
199
+ const handler = this.rawUpgrades.get(pathnameOf(request.url))
200
+ // A raw claim owns its path. A WebSocket upgrade nobody claimed is left to the router's hub when one is attached; with no hub, or for a
201
+ // protocol this server does not speak, the refusal is written here, cleanly, and never by a second handler.
202
+ if (handler === undefined) {
203
+ if (!websocket || this.hub !== undefined) return
204
+ if (socket.destroyed || socket.writableEnded) return
205
+ ;(request as RawUpgradeRequest)[RAW_UPGRADE_HANDLED] = true
206
+ socket.end('HTTP/1.1 503 Service Unavailable\r\nConnection: close\r\nContent-Length: 0\r\n\r\n')
207
+ return
208
+ }
209
+ if (!websocket) {
210
+ if (socket.destroyed || socket.writableEnded) return
211
+ ;(request as RawUpgradeRequest)[RAW_UPGRADE_HANDLED] = true
212
+ socket.end('HTTP/1.1 400 Bad Request\r\nConnection: close\r\nContent-Length: 0\r\n\r\n')
213
+ return
214
+ }
215
+ this.rawUpgradeSeen.add(request)
216
+ ;(request as RawUpgradeRequest)[RAW_UPGRADE_HANDLED] = true
217
+ Promise.resolve(handler(request, socket, head)).catch(() => socket.destroy())
218
+ })
219
+ ctx.effect(() => () => this.stop(), 'acryl-node-server: close listener and open connections')
220
+ }
221
+
222
+ /**
223
+ * Stop serving, gracefully, and resolve once the port is free: no new connections; requests already in flight may finish (a stream that never ends is
224
+ * cut) within {@link STOP_GRACE_MS}; each upgraded socket's owner closes it with a close frame first, and what is left is destroyed. Idempotent - the
225
+ * disposer calls it.
226
+ *
227
+ * Calling this BEFORE the rest of the app is disposed is what makes the grace count: the router has its own disposal, and it aborts in-flight requests
228
+ * and ends their bodies by design, concurrently with every other disposer (Cordis runs them together). A launcher that disposes the context directly
229
+ * therefore cuts the request this drains; a launcher that stops the server first lets it answer, then disposes.
230
+ */
231
+ stop(): Promise<void> {
232
+ this.stopping ??= this.closeGracefully()
233
+ return this.stopping
234
+ }
235
+
236
+ private async closeGracefully(): Promise<void> {
237
+ // The hub's close runs beside the request grace rather than before it: the stop takes one grace, not two.
238
+ const upgrades = this.hub?.close()
239
+ if (!this.server.listening) {
240
+ await upgrades
241
+ return
242
+ }
243
+ const stopped = new Promise<void>(resolve => { this.server.close(() => { resolve() }) })
244
+ this.server.closeIdleConnections()
245
+ const settled = await this.settleStop(STOP_GRACE_MS)
246
+ // A response that finished exactly at the grace's end is written but not yet delivered: end its socket gracefully (a FIN behind the buffered body)
247
+ // instead of destroying it, which would answer the client with a reset and an empty body. A little flush window, then force what is left.
248
+ this.server.closeIdleConnections()
249
+ const drained = settled
250
+ ? await Promise.race([
251
+ stopped.then(() => true),
252
+ new Promise<boolean>(resolve => { setTimeout(() => { resolve(false) }, 300) }),
253
+ ])
254
+ : false
255
+ if (!drained) {
83
256
  this.server.closeAllConnections()
84
- if (this.server.listening) await new Promise<void>(resolve => { this.server.close(() => { resolve() }) })
85
- }, 'acryl-node-server: close listener and open connections')
257
+ // Upgraded sockets that outlived their owner's close: destroy them, or the server's own close never finishes.
258
+ for (const socket of this.upgraded) socket.destroy()
259
+ this.upgraded.clear()
260
+ }
261
+ await Promise.all([upgrades, stopped])
262
+ }
263
+
264
+ /**
265
+ * Wait, bounded, until no request is in flight and no upgraded socket is left; true when both reached zero inside the bound. Every pass sweeps idle
266
+ * keep-alive sockets again: the single sweep `close()` does misses one that becomes idle after its request finished, and such a socket would otherwise
267
+ * hold the stop to its deadline.
268
+ */
269
+ private async settleStop(ms: number): Promise<boolean> {
270
+ const deadline = Date.now() + ms
271
+ while (Date.now() < deadline) {
272
+ if (this.active === 0 && this.upgraded.size === 0) return true
273
+ this.server.closeIdleConnections()
274
+ await new Promise(resolve => setTimeout(resolve, 25))
275
+ }
276
+ return this.active === 0 && this.upgraded.size === 0
277
+ }
278
+
279
+ /**
280
+ * Serve a raw node handler at `path`, beside the router: `exact` matches the pathname only, `prefix` the path and everything below it. The
281
+ * handler gets the platform's `IncomingMessage`/`ServerResponse` and owns the response; the router is not run for it. A path already served
282
+ * throws. The returned remover restores the path (the server itself goes on serving the rest).
283
+ */
284
+ registerRaw(route: RawRoute): () => void {
285
+ const table = route.kind === 'exact' ? this.rawExact : this.rawPrefix
286
+ if (table.has(route.path)) throw new Error(`acryl-node-server: ${route.path} is already served`)
287
+ table.set(route.path, route.handler)
288
+ return () => {
289
+ if (table.get(route.path) === route.handler) table.delete(route.path)
290
+ }
291
+ }
292
+
293
+ /**
294
+ * Answer matching requests before raw routes and the router, so a host can keep an app's URLs in scope under a prefix (the frame's canvas). A gate runs
295
+ * synchronously and can answer with a promise (see {@link RequestGate}); gates run in the order they were added, and the returned remover takes this one back.
296
+ */
297
+ gate(handler: RequestGate): () => void {
298
+ this.gates.push(handler)
299
+ return () => {
300
+ const at = this.gates.indexOf(handler)
301
+ if (at !== -1) this.gates.splice(at, 1)
302
+ }
303
+ }
304
+
305
+ /**
306
+ * A gate's asynchronous answer: run the request it was given through the router, in the adapter's request form (a gate never makes one itself), with the
307
+ * socket facts. The raw routes do not run for that request.
308
+ */
309
+ throughRouter(request: Request, connection: ConnectionInfo = {}): Promise<Response> {
310
+ return this.router.fetch(new AdapterRequest(request), connection)
311
+ }
312
+
313
+ /**
314
+ * Claim an upgrade path for a raw node handler: the handler receives `(req, socket, head)` and from then on owns the socket. The router's
315
+ * WebSocket handling never sees a claimed upgrade. A path already claimed throws; the remover releases the claim. The transport's one raw
316
+ * listener exists for the server's whole lifetime and consults this registry, so claims coming and going never add or remove a listener.
317
+ */
318
+ registerRawUpgrade(route: RawUpgradeRoute): () => void {
319
+ if (this.rawUpgrades.has(route.path))
320
+ throw new Error(`acryl-node-server: the upgrade path ${route.path} is already claimed`)
321
+ this.rawUpgrades.set(route.path, route.handler)
322
+ return () => {
323
+ if (this.rawUpgrades.get(route.path) !== route.handler) return
324
+ this.rawUpgrades.delete(route.path)
325
+ }
326
+ }
327
+
328
+ /** The raw handler for a request: an exact path first, then the longest matching prefix. */
329
+ private rawFor(incoming: IncomingMessage): RawRoute['handler'] | undefined {
330
+ const path = pathnameOf(incoming.url)
331
+ const exact = this.rawExact.get(path)
332
+ if (exact !== undefined) return exact
333
+ let best: { length: number; handler: RawRoute['handler'] } | undefined
334
+ for (const [prefix, handler] of this.rawPrefix)
335
+ if ((path === prefix || path.startsWith(`${prefix}/`)) && (best === undefined || prefix.length > best.length))
336
+ best = { length: prefix.length, handler }
337
+ return best?.handler
86
338
  }
87
339
 
88
340
  /**
@@ -93,11 +345,16 @@ export class NodeServer extends Service {
93
345
  upgrades(): UpgradeWebSocket<WebSocketLike> {
94
346
  if (!this.hub) {
95
347
  this.hub = new UpgradeHub(this.config.websocket)
96
- this.hub.attach(this.server, this.ctx.router)
348
+ this.hub.attach(this.server, this.ctx.router, { skipWhenRawUpgrade: true })
97
349
  }
98
350
  return this.hub.upgradeWebSocketFor(this.ctx)
99
351
  }
100
352
 
353
+ /** How many `upgrade` listeners the transport holds (raw plus router setups); a test asserts it does not grow across claim changes. */
354
+ get upgradeListenerCount(): number {
355
+ return this.server.listenerCount('upgrade')
356
+ }
357
+
101
358
  /** How many WebSocket connections are open now. */
102
359
  get sockets(): number {
103
360
  return this.hub?.size ?? 0
@@ -113,11 +370,11 @@ export class NodeServer extends Service {
113
370
  return `http://${this.hostname.includes(':') ? `[${this.hostname}]` : this.hostname}:${String(this.bound)}`
114
371
  }
115
372
 
116
- /** Bind, scanning past taken ports. */
373
+ /** Bind, scanning past taken ports and past the ports a browser refuses. */
117
374
  async listen(): Promise<void> {
118
375
  const { port, scan, hostname } = this.config
119
- const last = port === 0 ? 0 : Math.min(65535, port + scan)
120
- for (let candidate = port; candidate <= last; candidate += 1) {
376
+ const last = port === 0 ? 0 : Math.min(65_535, port + scan)
377
+ for (const candidate of portCandidates(port, scan)) {
121
378
  try {
122
379
  await new Promise<void>((resolve, reject) => {
123
380
  const onError = (error: Error): void => { reject(error) }
@@ -130,8 +387,39 @@ export class NodeServer extends Service {
130
387
  if (!isPortUnavailable(error)) throw error
131
388
  }
132
389
  }
133
- throw new Error(`acryl-node-server: no free port from ${String(port)} to ${String(last)} on ${hostname}`)
390
+ throw new Error(`acryl-node-server: no usable port from ${String(port)} to ${String(last)} on ${hostname}: each is taken, or one a browser refuses`)
391
+ }
392
+ }
393
+
394
+ /**
395
+ * The ports every Fetch client refuses to connect to: the Fetch standard's bad ports (https://fetch.spec.whatwg.org/#port-blocking). Chromium
396
+ * refuses a page on one (ERR_UNSAFE_PORT) and Node's fetch answers "bad port"; the list below is the one Node 24 reports, probed port by port
397
+ * on 2026-11-03 (82 ports, 1 to 10080). An app's stable port is hashed into 3100..3999, and 3659 is on the list: without this rule one app in
398
+ * 900 would come up on an address no browser may open, which the frame gate caught that day (verify-frame-token was refused at 3659).
399
+ */
400
+ export const RESTRICTED_PORTS: ReadonlySet<number> = new Set([
401
+ 1, 7, 9, 11, 13, 15, 17, 19, 20, 21, 22, 23, 25, 37, 42, 43, 53, 69, 77, 79, 87, 95, 101, 102, 103, 104,
402
+ 109, 110, 111, 113, 115, 117, 119, 123, 135, 137, 139, 143, 161, 179, 389, 427, 465, 512, 513, 514, 515,
403
+ 526, 530, 531, 532, 540, 548, 554, 556, 563, 587, 601, 636, 989, 990, 993, 995, 1719, 1720, 1723, 2049,
404
+ 3659, 4045, 4190, 5060, 5061, 6000, 6566, 6665, 6666, 6667, 6668, 6669, 6679, 6697, 10080,
405
+ ])
406
+
407
+ /** Whether a browser refuses to open this port. */
408
+ export function isRestrictedPort(port: number): boolean {
409
+ return RESTRICTED_PORTS.has(port)
410
+ }
411
+
412
+ /**
413
+ * The ports `listen` may try, in order: `port` with at most `scan` further ones, without the ports a browser refuses. Port 0 stays alone: the
414
+ * system chooses it, and the ports it hands out are above the restricted range.
415
+ */
416
+ export function portCandidates(port: number, scan: number): number[] {
417
+ const last = port === 0 ? 0 : Math.min(65_535, port + scan)
418
+ const candidates: number[] = []
419
+ for (let candidate = port; candidate <= last; candidate += 1) {
420
+ if (!isRestrictedPort(candidate)) candidates.push(candidate)
134
421
  }
422
+ return candidates
135
423
  }
136
424
 
137
425
  /**
@@ -83,12 +83,12 @@ export class UpgradeHub {
83
83
  * Accept upgrades from `source` and answer them with `router`: the request runs through the router's middleware with the socket's peer address and local port as connection
84
84
  * facts (so the same loopback, origin and host checks work), and the upgrade bindings travel in `c.env` for `upgradeWebSocket`.
85
85
  */
86
- attach(source: UpgradeSource, router: UpgradeRouter): void {
86
+ attach(source: UpgradeSource, router: UpgradeRouter, options: { skipWhenRawUpgrade?: boolean } = {}): void {
87
87
  const fetchCallback: FetchCallback = (request, env) => {
88
88
  const { incoming } = env as { incoming: { socket: { remoteAddress?: string; localPort?: number } } }
89
89
  return router.fetch(request, { remoteAddress: incoming.socket.remoteAddress, localPort: incoming.socket.localPort }, env)
90
90
  }
91
- setupWebSocket({ server: source, fetchCallback, wss: this.server })
91
+ setupWebSocket({ server: source, fetchCallback, wss: this.server, ...options })
92
92
  }
93
93
 
94
94
  /** How many sockets are open. */
@@ -0,0 +1,12 @@
1
+ {
2
+ "name": "acryl-page",
3
+ "description": "The page of a framework-hosted app: our own minimal index (the ACRYL frame's document, not DSH's), its index taps applied per request, and the built client assets the client runtime boots from. Ours, served by acryl-web-server on the framework's node server.",
4
+ "requires": ["acryl-web-server"],
5
+ "inject": ["webServer"],
6
+ "provides": [],
7
+ "dependencies": {},
8
+ "mount": "mount it after acryl-web-server and the client rows: it serves '/' and '/assets/*' and applies the webServer index taps (the client modules' __DSH_BOOT__ and registration scripts) to its own document",
9
+ "entries": {
10
+ "": "src/index.ts"
11
+ }
12
+ }
@@ -0,0 +1,163 @@
1
+ /**
2
+ * The page of a framework-hosted app. The document is ours: a minimal index (doctype, title, the root div) with the index taps applied per request,
3
+ * which is where the client-module row inserts `window.__DSH_BOOT__` and its registration scripts. DSH's built frontend is used only as the client
4
+ * runtime library the frame's client plugins load through: its own index is read for the entry script and stylesheet tags it genuinely needs, and its
5
+ * `dist/assets/` files are served beside our document. No DSH web server, web runtime or web-startup row is involved.
6
+ */
7
+ import { readFile } from 'node:fs/promises'
8
+ import { createRequire } from 'node:module'
9
+ import { dirname, extname, join, normalize, sep } from 'node:path'
10
+ import type { IncomingMessage, ServerResponse } from 'node:http'
11
+ import type { Context } from '@deepseek-ai/cordis'
12
+ import type {} from 'acryl-web-server'
13
+
14
+ /** Stable Cordis plugin name, equal to the package name and the Loader row id. */
15
+ export const name = 'acryl-page'
16
+
17
+ /** The page is served through the host's webServer port. */
18
+ export const inject = ['webServer']
19
+
20
+ export interface Config {
21
+ /** The document's title. */
22
+ title: string
23
+ }
24
+
25
+ const CONTENT_TYPES: Readonly<Record<string, string>> = {
26
+ '.js': 'text/javascript; charset=utf-8',
27
+ '.mjs': 'text/javascript; charset=utf-8',
28
+ '.css': 'text/css; charset=utf-8',
29
+ '.map': 'application/json; charset=utf-8',
30
+ '.json': 'application/json; charset=utf-8',
31
+ '.webmanifest': 'application/manifest+json; charset=utf-8',
32
+ '.svg': 'image/svg+xml',
33
+ '.png': 'image/png',
34
+ '.jpg': 'image/jpeg',
35
+ '.jpeg': 'image/jpeg',
36
+ '.webp': 'image/webp',
37
+ '.ico': 'image/x-icon',
38
+ '.woff': 'font/woff',
39
+ '.woff2': 'font/woff2',
40
+ '.ttf': 'font/ttf',
41
+ '.wasm': 'application/wasm',
42
+ }
43
+
44
+ /** The frontend package's `dist` directory: anchored on its manifest, as DSH itself does. */
45
+ export function frontendDist(): string {
46
+ const require = createRequire(import.meta.url)
47
+ return join(dirname(require.resolve('@deepseek-ai/dsh-web-frontend/package.json')), 'dist')
48
+ }
49
+
50
+ /** The tags the built client runtime genuinely needs from the frontend's own index: the entry script, its preloads and its stylesheets. */
51
+ export function clientAssetTags(indexHtml: string): string {
52
+ const tags = [...indexHtml.matchAll(/<(?:script|link)\b[^>]*>/gu)]
53
+ .map((match) => match[0])
54
+ .filter(
55
+ (tag) =>
56
+ /^<script\b[^>]*\bsrc=/u.test(tag) ||
57
+ /^<link\b[^>]*\brel="(?:stylesheet|modulepreload)"/u.test(tag),
58
+ )
59
+ // A copied `<script src>` without its closing tag turns every tag after it (the stylesheet links) into its text: DSH's component stylesheet then never
60
+ // loads, and the frame draws its dialogs, buttons and inputs with browser defaults instead of the product's. The frontend's own script tags are empty,
61
+ // so the closing tag is this side's to add.
62
+ return tags.map((tag) => (tag.startsWith('<script') ? `${tag}</script>` : tag)).join('\n ')
63
+ }
64
+
65
+ /** Our document: minimal on purpose; the frame renders into `#root`, and the taps add the boot wire before `</head>`. */
66
+ export function pageDocument(tags: string, title: string, taps: (html: string) => string): string {
67
+ const document = `<!doctype html>
68
+ <html lang="en">
69
+ <head>
70
+ <meta charset="utf-8" />
71
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
72
+ <title>${title}</title>
73
+ ${tags}
74
+ </head>
75
+ <body>
76
+ <div id="root"></div>
77
+ </body>
78
+ </html>
79
+ `
80
+ return taps(document)
81
+ }
82
+
83
+ /** The slice of the chat driver's connection the page needs: the process-token exchange that sets the browser's session cookie. */
84
+ interface ConnectionPort {
85
+ /** True when the caller may serve the index; false when it already answered (a token redirect or 401). */
86
+ authorizeIndex(req: IncomingMessage, res: ServerResponse): boolean
87
+ }
88
+
89
+ /** Answer with a file under `root`, or 404: a path that escapes `root` and a missing file both 404. */
90
+ async function serveFile(root: string, target: string, response: ServerResponse, method: string): Promise<void> {
91
+ const path = normalize(join(root, target))
92
+ if (path !== root && !path.startsWith(root + sep)) {
93
+ response.writeHead(404).end('not found')
94
+ return
95
+ }
96
+ try {
97
+ const bytes = await readFile(path)
98
+ response.writeHead(200, {
99
+ 'content-type': CONTENT_TYPES[extname(path).toLowerCase()] ?? 'application/octet-stream',
100
+ 'content-length': String(bytes.byteLength),
101
+ 'cache-control': 'no-cache',
102
+ })
103
+ response.end(method === 'HEAD' ? undefined : bytes)
104
+ } catch {
105
+ response.writeHead(404).end('not found')
106
+ }
107
+ }
108
+
109
+ /** Serve `/` (our document), `/index.html`, the built assets and the root files the index names. */
110
+ export function apply(ctx: Context, config: Partial<Config> = {}): void {
111
+ const title = config.title ?? 'ACRYL'
112
+ const dist = frontendDist()
113
+ const assets = join(dist, 'assets')
114
+ ctx.webServer.register({
115
+ kind: 'exact',
116
+ path: '/',
117
+ handler: async (req, res) => {
118
+ // The chat's remote RPC is authenticated by the connection's process token: the page's root request is where the token is exchanged
119
+ // for the session cookie. A composition without the driver has no connection and serves the page directly.
120
+ const connection = (ctx as unknown as { get(name: string): unknown }).get('connection') as ConnectionPort | undefined
121
+ if (connection !== undefined && !connection.authorizeIndex(req, res)) return
122
+ const index = await readFile(join(dist, 'index.html'), 'utf8')
123
+ const document = pageDocument(clientAssetTags(index), title, (html) => ctx.webServer.index(html))
124
+ res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' })
125
+ res.end(req.method === 'HEAD' ? undefined : document)
126
+ },
127
+ })
128
+ ctx.webServer.register({
129
+ kind: 'exact',
130
+ path: '/index.html',
131
+ handler: async (req, res) => {
132
+ const index = await readFile(join(dist, 'index.html'), 'utf8')
133
+ const document = pageDocument(clientAssetTags(index), title, (html) => ctx.webServer.index(html))
134
+ res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' })
135
+ res.end(req.method === 'HEAD' ? undefined : document)
136
+ },
137
+ })
138
+ ctx.webServer.register({
139
+ kind: 'prefix',
140
+ path: '/assets',
141
+ handler: async (req: IncomingMessage, res: ServerResponse) => {
142
+ const pathname = new URL(req.url ?? '/', 'http://page.invalid').pathname
143
+ await serveFile(assets, pathname.slice('/assets'.length), res, req.method ?? 'GET')
144
+ },
145
+ })
146
+ // Browsers ask for /favicon.ico unconditionally; the built light icon answers it.
147
+ ctx.webServer.register({
148
+ kind: 'exact',
149
+ path: '/favicon.ico',
150
+ handler: async (req: IncomingMessage, res: ServerResponse) => {
151
+ await serveFile(dist, 'favicon.svg', res, req.method ?? 'GET')
152
+ },
153
+ })
154
+ for (const file of ['favicon.svg', 'favicon-dark.svg', 'manifest.webmanifest']) {
155
+ ctx.webServer.register({
156
+ kind: 'exact',
157
+ path: `/${file}`,
158
+ handler: async (req: IncomingMessage, res: ServerResponse) => {
159
+ await serveFile(dist, file, res, req.method ?? 'GET')
160
+ },
161
+ })
162
+ }
163
+ }