acrylblends 1.1.0 → 1.3.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 (263) hide show
  1. package/README.md +9 -0
  2. package/dist/index.d.ts +47 -2
  3. package/dist/index.js +2017 -32
  4. package/dist/index.js.map +1 -1
  5. package/docs/framework/agents/index.md +1 -1
  6. package/docs/framework/getting-started/desktop.md +23 -4
  7. package/docs/framework/guides/create-acrylblends.md +5 -3
  8. package/docs/framework/workshop/README.md +139 -153
  9. package/docs/framework/workshop/facilitator.md +69 -101
  10. package/legacy/README.md +9 -0
  11. package/legacy/agent/157ce3db1112/src/app.ts +98 -0
  12. package/legacy/agent/157ce3db1112/src/routes.ts +63 -0
  13. package/legacy/agent/24c681a00fca/src/app.ts +160 -0
  14. package/legacy/agent/24c681a00fca/src/routes.ts +83 -0
  15. package/legacy/agent/2e790da977b2/src/app.ts +98 -0
  16. package/legacy/agent/2e790da977b2/src/routes.ts +63 -0
  17. package/legacy/agent/6307045545dc/src/app.ts +168 -0
  18. package/legacy/agent/6307045545dc/src/routes.ts +83 -0
  19. package/legacy/agent/6a5fb9c15c53/src/app.ts +158 -0
  20. package/legacy/agent/6a5fb9c15c53/src/routes.ts +83 -0
  21. package/legacy/agent/71fb54a5a942/src/app.ts +111 -0
  22. package/legacy/agent/71fb54a5a942/src/routes.ts +63 -0
  23. package/legacy/agent/74ece148269c/src/app.ts +168 -0
  24. package/legacy/agent/74ece148269c/src/routes.ts +93 -0
  25. package/legacy/agent/855359304707/src/app.ts +168 -0
  26. package/legacy/agent/855359304707/src/routes.ts +126 -0
  27. package/legacy/agent/b5ef3c786ae4/src/app.ts +127 -0
  28. package/legacy/agent/b5ef3c786ae4/src/routes.ts +76 -0
  29. package/legacy/agent/c2f30958043b/src/app.ts +98 -0
  30. package/legacy/agent/c2f30958043b/src/routes.ts +53 -0
  31. package/legacy/blank/5a91c558af22/src/routes.ts +18 -0
  32. package/legacy/blank/9c650cf52bf3/src/routes.ts +14 -0
  33. package/legacy/blank/aea589283445/src/routes.ts +16 -0
  34. package/legacy/blank/b2cbb759c889/src/routes.ts +16 -0
  35. package/legacy/blank/d6d371c9a65e/src/routes.ts +18 -0
  36. package/legacy/chat/7076cc821e2c/src/app.ts +72 -0
  37. package/legacy/chat/7076cc821e2c/src/routes.ts +13 -0
  38. package/legacy/chat/7dbe6d5170df/src/app.ts +107 -0
  39. package/legacy/chat/7dbe6d5170df/src/routes.ts +19 -0
  40. package/legacy/chat/a9a9ab44c58e/src/app.ts +107 -0
  41. package/legacy/chat/a9a9ab44c58e/src/routes.ts +15 -0
  42. package/legacy/chat/f20ad9a51b06/src/app.ts +106 -0
  43. package/legacy/chat/f20ad9a51b06/src/routes.ts +15 -0
  44. package/legacy/layouts.json +524 -0
  45. package/legacy/nodejs/1d477f37aaf4/src/app.ts +92 -0
  46. package/legacy/nodejs/1d477f37aaf4/src/routes.ts +40 -0
  47. package/legacy/nodejs/b1bd9462496c/src/app.ts +61 -0
  48. package/legacy/nodejs/b1bd9462496c/src/routes.ts +37 -0
  49. package/legacy/nodejs/d5bab75076ef/src/app.ts +95 -0
  50. package/legacy/nodejs/d5bab75076ef/src/routes.ts +40 -0
  51. package/legacy/notes/4edc36949c15/src/app.ts +107 -0
  52. package/legacy/notes/4edc36949c15/src/routes.ts +14 -0
  53. package/legacy/notes/6cab2d09747c/src/app.ts +72 -0
  54. package/legacy/notes/6cab2d09747c/src/routes.ts +12 -0
  55. package/legacy/notes/7ca2dfed623a/src/app.ts +106 -0
  56. package/legacy/notes/7ca2dfed623a/src/routes.ts +14 -0
  57. package/legacy/todo/02e613b3625c/src/app.ts +71 -0
  58. package/legacy/todo/02e613b3625c/src/todo.ts +123 -0
  59. package/legacy/todo/45694a8d9e15/src/app.ts +71 -0
  60. package/legacy/todo/45694a8d9e15/src/todo.ts +96 -0
  61. package/legacy/todo/5923d3a6099f/src/app.ts +71 -0
  62. package/legacy/todo/5923d3a6099f/src/todo.ts +125 -0
  63. package/legacy/todo/849a18d66dc1/src/app.ts +91 -0
  64. package/legacy/todo/849a18d66dc1/src/todo.ts +125 -0
  65. package/legacy/todo/8e64211312f3/src/app.ts +71 -0
  66. package/legacy/todo/8e64211312f3/src/todo.ts +125 -0
  67. package/legacy/todo/b79078a87565/src/app.ts +71 -0
  68. package/legacy/todo/b79078a87565/src/todo.ts +125 -0
  69. package/lib/{audit-BFpULBCj.d.ts → audit-Bok_UNCn.d.ts} +26 -3
  70. package/lib/{client-modules-drj4mjhj.js → client-modules-Bkdr83jO.js} +1 -1
  71. package/lib/cordis/door.d.ts +87 -9
  72. package/lib/cordis/door.js +324 -60
  73. package/lib/cordis/inspect.js +1 -1
  74. package/lib/cordis/node-server.d.ts +101 -3
  75. package/lib/cordis/node-server.js +3 -3
  76. package/lib/cordis/router-http.d.ts +7 -1
  77. package/lib/cordis/router-http.js +44 -3
  78. package/lib/cordis/router.d.ts +1 -1
  79. package/lib/cordis/router.js +1 -1
  80. package/lib/cordis/state.js +1 -1
  81. package/lib/door/host.d.ts +2 -2
  82. package/lib/door/host.js +1 -1
  83. package/lib/door/page.d.ts +10 -1
  84. package/lib/door/page.js +46 -7
  85. package/lib/door/protocol.d.ts +1 -1
  86. package/lib/drivers.d.ts +10 -1
  87. package/lib/{index-DMOtA-NJ.d.ts → index-DGq7H0lb.d.ts} +11 -1
  88. package/lib/node-server.d.ts +7 -1
  89. package/lib/node-server.js +2 -2
  90. package/lib/{protocol-a2aqrp2X.d.ts → protocol-BeIYCgTo.d.ts} +4 -0
  91. package/lib/{snapshot-text-BwM6T2nG.js → snapshot-text-CXTnjxGx.js} +87 -7
  92. package/lib/src-CtkIX_Gg.js +427 -0
  93. package/lib/{src-BTBm4OPD.js → src-iBJ2ysV9.js} +77 -14
  94. package/lib/{test-websocket-BLeEvybj.js → test-websocket-CQOSvBth.js} +17 -7
  95. package/lib/{test-websocket-DyeS50Tr.d.ts → test-websocket-GUTAkFQT.d.ts} +3 -1
  96. package/package.json +2 -1
  97. package/plugins/acryl-door/plugin.json +3 -0
  98. package/plugins/acryl-door/src/cli/acryl.ts +63 -6
  99. package/plugins/acryl-door/src/host/channel.ts +86 -9
  100. package/plugins/acryl-door/src/host/chat-tools.ts +8 -6
  101. package/plugins/acryl-door/src/host/door-file.ts +7 -2
  102. package/plugins/acryl-door/src/host/door.ts +128 -24
  103. package/plugins/acryl-door/src/host/operator-routes.ts +21 -1
  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 +14 -1
  107. package/plugins/acryl-door/src/index.ts +168 -30
  108. package/plugins/acryl-door/src/page/base.ts +32 -0
  109. package/plugins/acryl-door/src/page/channel.ts +3 -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 +32 -4
  113. package/plugins/acryl-door/src/page/driver/snapshot.ts +5 -2
  114. package/plugins/acryl-door/src/page/index.ts +41 -2
  115. package/plugins/acryl-door/src/page/outline.ts +56 -0
  116. package/plugins/acryl-door/src/page/protocol.ts +4 -0
  117. package/plugins/acryl-door/src/page/snapshot-text.ts +2 -1
  118. package/plugins/acryl-node-server/src/adapter/listener.ts +4 -0
  119. package/plugins/acryl-node-server/src/adapter/raw.ts +18 -0
  120. package/plugins/acryl-node-server/src/adapter/websocket.ts +15 -9
  121. package/plugins/acryl-node-server/src/client-modules.ts +7 -1
  122. package/plugins/acryl-node-server/src/index.ts +304 -16
  123. package/plugins/acryl-node-server/src/upgrade-hub.ts +2 -2
  124. package/plugins/acryl-page/plugin.json +12 -0
  125. package/plugins/acryl-page/src/index.ts +163 -0
  126. package/plugins/acryl-router/src/service.ts +77 -14
  127. package/plugins/acryl-router-http/src/index.ts +15 -1
  128. package/plugins/acryl-router-http/src/serve.ts +31 -0
  129. package/plugins/acryl-web-server/plugin.json +14 -0
  130. package/plugins/acryl-web-server/src/index.ts +164 -0
  131. package/plugins/agent/agent.ts +38 -7
  132. package/plugins/agent/index.ts +1 -1
  133. package/plugins/agent/model.ts +10 -2
  134. package/plugins/agent/tools.ts +9 -3
  135. package/plugins/agent-panel/client/describe.ts +57 -0
  136. package/plugins/agent-panel/client/driver-pick.ts +22 -0
  137. package/plugins/agent-panel/client/frame.ts +51 -0
  138. package/plugins/agent-panel/client/main.ts +238 -26
  139. package/plugins/agent-panel/drivers.ts +7 -2
  140. package/plugins/agent-panel/headless.ts +5 -2
  141. package/plugins/agent-panel/index.ts +146 -57
  142. package/plugins/agent-panel/mount.ts +27 -0
  143. package/plugins/agent-panel/visitors.ts +231 -0
  144. package/plugins/builder/index.ts +13 -35
  145. package/plugins/builder/package.ts +186 -0
  146. package/plugins/builder/plugin.json +3 -1
  147. package/plugins/desktop/frame.ts +231 -0
  148. package/plugins/desktop/launch.ts +44 -9
  149. package/plugins/desktop/main.ts +100 -32
  150. package/plugins/desktop/window.ts +21 -4
  151. package/plugins/driver-pty/client/terminal.ts +4 -1
  152. package/plugins/driver-pty/index.ts +38 -11
  153. package/plugins/driver-pty/spawn.ts +38 -0
  154. package/plugins/driver-pty/terminal.ts +2 -0
  155. package/plugins/extensions/index.ts +47 -18
  156. package/plugins/extensions/plugin-store.ts +27 -0
  157. package/plugins/extensions/plugin.json +1 -1
  158. package/plugins/extensions/resolve.ts +13 -0
  159. package/plugins/extensions/stage.ts +15 -5
  160. package/plugins/feedback/client.d.ts +22 -0
  161. package/plugins/feedback/client.js +471 -0
  162. package/plugins/feedback/index.ts +143 -0
  163. package/plugins/feedback/plugin.json +11 -0
  164. package/plugins/feedback/store.ts +307 -0
  165. package/plugins/home/index.ts +122 -0
  166. package/plugins/home/plugin.json +11 -0
  167. package/plugins/instance/index.ts +6 -0
  168. package/plugins/manifest/app-plugins.ts +86 -0
  169. package/plugins/manifest/index.ts +12 -2
  170. package/plugins/manifest/plugin.json +1 -1
  171. package/plugins/manifest/snapshot.ts +275 -0
  172. package/plugins/openai/index.ts +64 -5
  173. package/plugins/openai/prompt.ts +23 -0
  174. package/plugins/plugin-share/README.md +51 -0
  175. package/plugins/plugin-share/index.ts +467 -0
  176. package/plugins/plugin-share/plugin.json +15 -0
  177. package/plugins/plugin-share/publish.ts +65 -0
  178. package/plugins/plugin-share/pull-check.ts +129 -0
  179. package/plugins/plugin-share/pulled-records.ts +70 -0
  180. package/shared/AGENTS.core.md +36 -16
  181. package/shared/skills/acryl-add-ui/SKILL.md +19 -0
  182. package/shared/skills/acryl-app/SKILL.md +13 -0
  183. package/shared/skills/acryl-build-plugin/SKILL.md +22 -0
  184. package/shared/skills/acryl-change-plugin/SKILL.md +14 -0
  185. package/shared/skills/acryl-diagnose-plugin/SKILL.md +13 -0
  186. package/shared/skills/acryl-fix-plugin/SKILL.md +13 -0
  187. package/shared/skills/acryl-remove-plugin/SKILL.md +12 -0
  188. package/shared/skills/acryl-share-plugin/SKILL.md +16 -0
  189. package/shared/skills/cordis-plugin/SKILL.md +26 -0
  190. package/templates/agent/.gitattributes +2 -0
  191. package/templates/agent/AGENTS.md +1 -1
  192. package/templates/agent/blend.yaml +6 -0
  193. package/templates/agent/gitignore +1 -0
  194. package/templates/agent/package.json +1 -0
  195. package/templates/agent/src/app.ts +154 -33
  196. package/templates/agent/src/frame.ts +17 -0
  197. package/templates/agent/src/main.ts +5 -3
  198. package/templates/agent/src/routes.ts +38 -19
  199. package/templates/agent/template.json +3 -0
  200. package/templates/agent/test/app.test.ts +45 -3
  201. package/templates/blank/.gitattributes +2 -0
  202. package/templates/blank/AGENTS.md +3 -2
  203. package/templates/blank/blend.yaml +11 -0
  204. package/templates/blank/gitignore +1 -0
  205. package/templates/blank/package.json +1 -0
  206. package/templates/blank/src/app.ts +100 -29
  207. package/templates/blank/src/door.ts +7 -0
  208. package/templates/blank/src/first-page.ts +5 -0
  209. package/templates/blank/src/frame.ts +17 -0
  210. package/templates/blank/src/routes.ts +65 -8
  211. package/templates/blank/template.json +14 -1
  212. package/templates/blank/test/app.test.ts +103 -16
  213. package/templates/chat/.gitattributes +2 -0
  214. package/templates/chat/blend.yaml +2 -1
  215. package/templates/chat/gitignore +1 -0
  216. package/templates/chat/package.json +1 -0
  217. package/templates/chat/src/app.ts +72 -22
  218. package/templates/chat/src/chat-app.ts +11 -2
  219. package/templates/chat/src/client/chat.tsx +4 -2
  220. package/templates/chat/src/frame.ts +17 -0
  221. package/templates/chat/src/page.tsx +6 -4
  222. package/templates/chat/src/routes.ts +14 -4
  223. package/templates/chat/src/runtime.ts +9 -3
  224. package/templates/chat/template.json +3 -1
  225. package/templates/chat/test/chat.test.ts +46 -1
  226. package/templates/chat/test/server.test.ts +17 -3
  227. package/templates/nodejs/.gitattributes +2 -0
  228. package/templates/nodejs/blend.yaml +2 -1
  229. package/templates/nodejs/gitignore +1 -0
  230. package/templates/nodejs/package.json +1 -0
  231. package/templates/nodejs/src/app.ts +76 -21
  232. package/templates/nodejs/src/frame.ts +17 -0
  233. package/templates/nodejs/src/page.tsx +3 -3
  234. package/templates/nodejs/src/routes.ts +32 -13
  235. package/templates/nodejs/template.json +3 -1
  236. package/templates/nodejs/test/app.test.ts +38 -3
  237. package/templates/nodejs/test/server.test.ts +17 -3
  238. package/templates/notes/.gitattributes +2 -0
  239. package/templates/notes/blend.yaml +2 -1
  240. package/templates/notes/gitignore +1 -0
  241. package/templates/notes/package.json +1 -0
  242. package/templates/notes/src/app.ts +72 -22
  243. package/templates/notes/src/frame.ts +17 -0
  244. package/templates/notes/src/notes-app.ts +24 -17
  245. package/templates/notes/src/routes.ts +11 -4
  246. package/templates/notes/src/runtime.ts +9 -3
  247. package/templates/notes/src/views.tsx +26 -17
  248. package/templates/notes/template.json +3 -1
  249. package/templates/notes/test/notes.test.ts +39 -1
  250. package/templates/notes/test/server.test.ts +17 -3
  251. package/templates/todo/.gitattributes +2 -0
  252. package/templates/todo/blend.yaml +3 -0
  253. package/templates/todo/gitignore +1 -0
  254. package/templates/todo/package.json +1 -0
  255. package/templates/todo/src/app.ts +81 -27
  256. package/templates/todo/src/door.ts +7 -0
  257. package/templates/todo/src/frame.ts +17 -0
  258. package/templates/todo/src/page.ts +13 -6
  259. package/templates/todo/src/todo.ts +18 -6
  260. package/templates/todo/template.json +11 -1
  261. package/templates/todo/test/todo.test.ts +67 -9
  262. package/lib/src-LX0E4ec4.js +0 -117
  263. package/shared/SKILL.md +0 -14
@@ -60,6 +60,8 @@ export interface UiNode {
60
60
  readonly value?: string
61
61
  /** Heading level, when it is a heading. */
62
62
  readonly level?: number
63
+ /** The choices of a select, as `act select` takes them, when it is not sensitive. */
64
+ readonly options?: readonly string[]
63
65
  }
64
66
 
65
67
  export interface UiSnapshot {
@@ -88,6 +90,8 @@ export interface UiActionResult {
88
90
  readonly ok: true
89
91
  readonly target?: { readonly role: string; readonly name: string }
90
92
  readonly detail?: string
93
+ /** The act started a navigation (a link, a form that submits): this page is going away, and the Host waits for the next one. */
94
+ readonly leaving?: true
91
95
  }
92
96
 
93
97
  export type UiResult = UiSnapshot | UiActionResult
@@ -11,7 +11,8 @@ export function renderSnapshot(snapshot: UiSnapshot, more: (cursor: number) => s
11
11
  const level = node.level === undefined ? '' : ` level=${String(node.level)}`
12
12
  const states = node.states.length === 0 ? '' : ` [${node.states.join(', ')}]`
13
13
  const value = node.value === undefined ? '' : ` value=${JSON.stringify(node.value)}`
14
- return `${' '.repeat(Math.min(node.depth, 8))}- ${node.role}${node.name === '' ? '' : ` ${JSON.stringify(node.name)}`}${level}${states}${value} [ref=${node.ref}]`
14
+ const options = node.options === undefined ? '' : ` options=${JSON.stringify(node.options)}`
15
+ return `${' '.repeat(Math.min(node.depth, 8))}- ${node.role}${node.name === '' ? '' : ` ${JSON.stringify(node.name)}`}${level}${states}${value}${options} [ref=${node.ref}]`
15
16
  })
16
17
  const rest = snapshot.nextCursor === undefined ? '' : `\n(${String(snapshot.total - snapshot.nextCursor)} more elements; ${more(snapshot.nextCursor)})`
17
18
  return `Page: ${snapshot.title || '(untitled)'}\nSnapshot ${String(snapshot.generation)}, ${String(snapshot.nodes.length)} of ${String(snapshot.total)} elements:\n${lines.join('\n')}${rest}`
@@ -14,6 +14,7 @@ import {
14
14
  } from './request.ts'
15
15
  import { defaultContentType, cacheKey, Response as LightweightResponse } from './response.ts'
16
16
  import type { InternalCache } from './response.ts'
17
+ import { RAW_RESPONSE_HANDLED } from './raw.ts'
17
18
  import type { CustomErrorHandler, FetchCallback, HttpBindings } from './types.ts'
18
19
  import {
19
20
  readWithoutBlocking,
@@ -500,6 +501,9 @@ export const getRequestListener = (
500
501
  res = fetchCallback(req, { incoming, outgoing } as HttpBindings) as
501
502
  | Response
502
503
  | Promise<Response>
504
+ | typeof RAW_RESPONSE_HANDLED
505
+ // A raw handler (not a `Response`) owns the node response: it has written (or is writing) it, so the adapter writes nothing.
506
+ if (res === RAW_RESPONSE_HANDLED) return
503
507
  if (!isPromise(res) && isImmediateCacheableResponse(res)) {
504
508
  // Synchronous cacheable response — no close listener needed.
505
509
  // No I/O events can fire between fetchCallback returning and responseViaCache
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Raw HTTP handlers, outside the router: a host that must serve another server's node `(req, res)` handlers (a DSH compatibility port, the
3
+ * desktop shell's own endpoints) registers by path instead of building an `Acryl`. The adapter hands a matched request to the handler with the
4
+ * real `IncomingMessage`/`ServerResponse` and writes nothing itself; `RAW_RESPONSE_HANDLED` is how the fetch callback tells it so.
5
+ *
6
+ * Upgrades work the same way at the socket level: a registered path is claimed before the router's WebSocket handling, and the adapter's
7
+ * WebSocket setup leaves a request marked with `RAW_UPGRADE_HANDLED` alone.
8
+ */
9
+ import type { IncomingMessage } from 'node:http'
10
+
11
+ /** Returned by a raw-aware fetch callback: the node response is already owned, do not write. */
12
+ export const RAW_RESPONSE_HANDLED: unique symbol = Symbol.for('acryl-node-server.raw-response')
13
+
14
+ /** Set on an upgrade request a raw handler took over; the adapter's WebSocket setup skips it. */
15
+ export const RAW_UPGRADE_HANDLED: unique symbol = Symbol.for('acryl-node-server.raw-upgrade')
16
+
17
+ /** An upgrade request a raw handler took over. */
18
+ export type RawUpgradeRequest = IncomingMessage & { [RAW_UPGRADE_HANDLED]?: true }
@@ -3,6 +3,7 @@ import { defineWebSocketHelper } from 'acryl-router/helpers'
3
3
  import type { IncomingMessage } from 'node:http'
4
4
  import { STATUS_CODES } from 'node:http'
5
5
  import type { Duplex } from 'node:stream'
6
+ import { RAW_UPGRADE_HANDLED, type RawUpgradeRequest } from './raw.ts'
6
7
  import type { FetchCallback } from './types.ts'
7
8
  import type { WebSocketData, WebSocketLike, WebSocketServerLike } from './websocket-types.ts'
8
9
 
@@ -144,6 +145,8 @@ const appendResponseHeaders = (headers: string[], responseHeaders?: Headers) =>
144
145
  }
145
146
 
146
147
  const rejectUpgradeRequest = (socket: Duplex, status: number, responseHeaders?: Headers) => {
148
+ // The socket already has its answer (a provider transition can route one upgrade to two handlers for a moment): never write a second one.
149
+ if (socket.destroyed || socket.writableEnded) return
147
150
  const responseLines = ['Connection: close', 'Content-Length: 0']
148
151
  appendResponseHeaders(responseLines, responseHeaders)
149
152
 
@@ -181,8 +184,13 @@ export const setupWebSocket = (options: {
181
184
  server: UpgradeSource
182
185
  fetchCallback: FetchCallback
183
186
  wss: WebSocketServerLike
187
+ /**
188
+ * Leave a request alone when a raw upgrade handler claimed it. Set by a transport that shares its upgrade source with raw claims (the node
189
+ * server's HTTP server); a hub fed by its own emitter (the Web adapter) must still answer its own upgrades, which arrive through a raw claim.
190
+ */
191
+ skipWhenRawUpgrade?: boolean
184
192
  }): void => {
185
- const { server, fetchCallback, wss } = options
193
+ const { server, fetchCallback, wss, skipWhenRawUpgrade = false } = options
186
194
 
187
195
  const waiterMap = new Map<
188
196
  IncomingMessage,
@@ -216,9 +224,11 @@ export const setupWebSocket = (options: {
216
224
  }
217
225
 
218
226
  server.on('upgrade', async (request, socket: Duplex, head) => {
227
+ // A raw upgrade handler claimed this path before the WebSocket setup ran: the socket is (or will be) its own.
228
+ if (skipWhenRawUpgrade && (request as RawUpgradeRequest)[RAW_UPGRADE_HANDLED] === true) return
219
229
  if (request.headers.upgrade?.toLowerCase() !== 'websocket') {
220
- // Nobody else listens: an upgrade to another protocol is answered, not left hanging.
221
- if (server.listenerCount('upgrade') === 1) rejectUpgradeRequest(socket, 400)
230
+ // An upgrade to another protocol is answered, not left hanging (the transport's raw listener flags the ones it already answered).
231
+ rejectUpgradeRequest(socket, 400)
222
232
  return
223
233
  }
224
234
 
@@ -241,9 +251,7 @@ export const setupWebSocket = (options: {
241
251
  responseHeaders = response.headers
242
252
  }
243
253
  } catch {
244
- if (server.listenerCount('upgrade') === 1) {
245
- rejectUpgradeRequest(socket, 500)
246
- }
254
+ rejectUpgradeRequest(socket, 500)
247
255
  return
248
256
  }
249
257
 
@@ -251,9 +259,7 @@ export const setupWebSocket = (options: {
251
259
 
252
260
  if (!waiter || waiter.connectionSymbol !== env[CONNECTION_SYMBOL_KEY]) {
253
261
  rejectWaiter(request)
254
- if (server.listenerCount('upgrade') === 1) {
255
- rejectUpgradeRequest(socket, status, responseHeaders)
256
- }
262
+ rejectUpgradeRequest(socket, status, responseHeaders)
257
263
  return
258
264
  }
259
265
 
@@ -38,6 +38,12 @@ const DEFAULT_CACHE_BYTES = 16 * 1024 * 1024
38
38
  export interface ClientModulesOptions {
39
39
  /** The folder of client modules, absolute or relative to the working directory. */
40
40
  readonly root: string
41
+ /**
42
+ * The URL prefix the app is mounted at, when the caller knows it. A host can compose app plugins under its own router, and under a
43
+ * Host whose router is a different copy of this package the request's match result is not visible here, so `basePath(c)` throws; the
44
+ * explicit prefix is then the only reliable way to know where the mount starts. Omitted, the router's `basePath(c)` is used.
45
+ */
46
+ readonly mount?: string | undefined
41
47
  /** The folder {@link buildClientModules} wrote; with `NODE_ENV=production` it is served instead of compiling, which needs no `typescript`. */
42
48
  readonly compiled?: string | undefined
43
49
  /** At most this many bytes of compiled modules are kept in memory (least recently served first out); 16 MiB when not given. */
@@ -204,7 +210,7 @@ export function clientModules(options: ClientModulesOptions): Acryl {
204
210
 
205
211
  const app = new Acryl()
206
212
  app.get('/*', (c) => {
207
- const requestPath = c.req.path.slice(basePath(c).length)
213
+ const requestPath = c.req.path.slice(options.mount === undefined ? basePath(c).length : options.mount.length)
208
214
  const runtime = /^\/@acrylblends\/([a-z-]+\.js)$/u.exec(requestPath)?.[1]
209
215
  const send = (code: string, etag: string): Response => {
210
216
  if (c.req.header('if-none-match') === etag) return c.body(null, 304, { etag, 'cache-control': 'no-cache' })
@@ -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
+ }