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
@@ -7,6 +7,8 @@
7
7
  * response body still streaming and waits for all of it to settle.
8
8
  */
9
9
 
10
+ import { appendFileSync } from 'node:fs'
11
+ import { tmpdir } from 'node:os'
10
12
  import { Context, Service } from '@deepseek-ai/cordis'
11
13
  import z from '@deepseek-ai/schemastery'
12
14
  import type { ConnectionInfo, RouterBindings } from './connection.ts'
@@ -21,6 +23,27 @@ import type { ErrorHandler, H, NotFoundHandler } from './core/types.ts'
21
23
 
22
24
  const DEFAULT_DISPOSE_GRACE_MS = 2000
23
25
 
26
+ /** How long disposal waits for the flights it had to abort after the drain, before it continues without them. */
27
+ const ABORT_SETTLE_MS = 300
28
+
29
+ /** Set by ACRYL_ROUTER_TRACE=1: append request-signal lifecycle lines to ACRYL_ROUTER_TRACE_FILE (the CI artifact) - diagnostics for the abort-reaches-handler defect. */
30
+ const traceFile = (): string => process.env.ACRYL_ROUTER_TRACE_FILE ?? `${tmpdir()}/acryl-router-trace.log`
31
+ const trace = (line: string): void => {
32
+ if (process.env.ACRYL_ROUTER_TRACE !== '1') return
33
+ try { appendFileSync(traceFile(), `${String(Date.now())} [${String(process.pid)}] ${line}\n`) } catch { /* tracing never affects a request */ }
34
+ }
35
+ let flightSeq = 0
36
+ const SIGNAL_TAG = Symbol.for('acryl.router.signalTag')
37
+ const tagOf = (signal: AbortSignal | undefined): string => {
38
+ if (signal === undefined) return 'none'
39
+ const tag = (signal as unknown as Record<symbol, unknown>)[SIGNAL_TAG]
40
+ return typeof tag === 'string' ? tag : 'untagged'
41
+ }
42
+ const tagSignal = (signal: AbortSignal, tag: string): void => {
43
+ try { (signal as unknown as Record<symbol, unknown>)[SIGNAL_TAG] = tag } catch { /* a frozen signal keeps no tag */ }
44
+ }
45
+
46
+
24
47
  /** How a trailing slash is read: `strict` keeps `/a` and `/a/` distinct, `ignore` treats them as one path. */
25
48
  export type TrailingSlash = 'strict' | 'ignore'
26
49
 
@@ -105,6 +128,10 @@ interface RouterState {
105
128
 
106
129
  interface Flight {
107
130
  readonly controller: AbortController
131
+ /** The request the handlers run on (the caller's request with the flight's signal): held strongly for the flight's lifetime, so its signal cannot be collected under a listener. */
132
+ readonly inner: Request
133
+ /** Names the flight in the ACRYL_ROUTER_TRACE diagnostics. */
134
+ readonly tag: string
108
135
  /** How many of each mount's handlers are running for this request right now (a middleware counts until its `next()` returns). */
109
136
  readonly running: Map<Mount, number>
110
137
  /** The mount whose handler produced the response; its body is that mount's code still running. */
@@ -150,8 +177,8 @@ export class Router extends Service {
150
177
  }
151
178
  ctx.effect(() => async () => {
152
179
  this.state.closing = true
153
- await this.end([...this.flights], 'disposal')
154
- }, 'acryl-router: abort and settle in-flight requests')
180
+ await this.drain()
181
+ }, 'acryl-router: drain in-flight requests, then abort what is left')
155
182
  }
156
183
 
157
184
  /** Registrations currently held (mounts, and the router-wide error and not-found handlers); a leak check for reload tests. */
@@ -168,12 +195,16 @@ export class Router extends Service {
168
195
  const mount = this.snapshot(path, app)
169
196
  this.state.mounts.add(mount)
170
197
  this.state.composed = undefined
171
- // Removing a mount ends the requests its code is still serving: their signals abort, a body it produced is cancelled, and the
172
- // plugin's unload waits for them (bounded by `disposeGraceMs`), so nothing a plugin started outlives it.
198
+ // Removing a mount ends the requests its code is still serving: they get the grace to finish first (a save being written when its plugin unloads,
199
+ // or when the whole app stops and every plugin unloads together, must not be cut), and only then are their signals aborted, their bodies cancelled
200
+ // and their settle awaited, so nothing a plugin started outlives it by more than that bounded grace.
173
201
  const remove = (): Promise<void> => {
174
202
  if (!this.state.mounts.delete(mount)) return Promise.resolve()
175
203
  this.state.composed = undefined
176
- return this.end([...this.flights].filter(flight => (flight.running.get(mount) ?? 0) > 0 || flight.producer === mount), `removing ${path}`)
204
+ return this.drainFlights(
205
+ [...this.flights].filter(flight => (flight.running.get(mount) ?? 0) > 0 || flight.producer === mount),
206
+ `removing ${path}`,
207
+ )
177
208
  }
178
209
  // Each route the mount serves is a child effect of it, labeled on its own, so the inspector can report routes without asking the
179
210
  // router and no label grows with the size of the app (`ALL` is middleware or `all`). The children hold nothing: the mount does.
@@ -215,6 +246,7 @@ export class Router extends Service {
215
246
  const handler: H = async (c, next) => {
216
247
  const flight = this.flightOf.get(c.req.raw)
217
248
  if (flight === undefined) return inner(c, next)
249
+ trace(`handler ${flight.tag} requestTag=${tagOf(c.req.raw.signal)} innerTag=${tagOf(flight.inner.signal)} sameSignal=${String(c.req.raw.signal === flight.inner.signal)} aborted=${String(c.req.raw.signal.aborted)}`)
218
250
  let calledNext = false
219
251
  flight.running.set(mount, (flight.running.get(mount) ?? 0) + 1)
220
252
  try {
@@ -234,18 +266,46 @@ export class Router extends Service {
234
266
  return mount
235
267
  }
236
268
 
237
- /** Abort `flights`, cut their bodies short and wait for them to settle, at most `disposeGraceMs`. */
238
- private async end(flights: readonly Flight[], why: string): Promise<void> {
269
+ /**
270
+ * The router's own disposal: let the flights already in flight finish within `disposeGraceMs` - a save being written when the app stops must not be cut
271
+ * by the router beneath it - then abort what is left and settle it for a moment, so disposal does not wait a second full grace on code that ignores
272
+ * its abort signal.
273
+ */
274
+ private async drain(): Promise<void> {
275
+ await this.drainFlights([...this.flights], 'disposal')
276
+ }
277
+
278
+ /** The one draining rule: let `flights` finish inside the grace, then abort and settle what is left. */
279
+ private async drainFlights(flights: readonly Flight[], why: string): Promise<void> {
280
+ trace(`drain ${why} chosen=[${flights.map(flight => flight.tag).join(', ')}]`)
281
+ if (flights.length === 0) return
282
+ if (await this.settledWithin(flights, this.graceMs)) { trace(`drain ${why} settled inside the grace`); return }
283
+ const left = flights.filter(flight => this.flights.has(flight))
284
+ trace(`drain ${why} grace over left=[${left.map(flight => flight.tag).join(', ')}]`)
285
+ if (left.length > 0) await this.end(left, why, ABORT_SETTLE_MS)
286
+ }
287
+
288
+ /** Resolve true when every flight settled inside `ms`, false at the deadline. */
289
+ private async settledWithin(flights: readonly Flight[], ms: number): Promise<boolean> {
290
+ let timer: ReturnType<typeof setTimeout> | undefined
291
+ const gaveUp = new Promise<false>(resolve => { timer = setTimeout(() => { resolve(false) }, ms) })
292
+ const outcome = await Promise.race([Promise.allSettled(flights.map(flight => flight.settled)).then(() => true as const), gaveUp])
293
+ clearTimeout(timer)
294
+ return outcome
295
+ }
296
+
297
+ /** Abort `flights`, cut their bodies short and wait for them to settle, at most `settleMs`. */
298
+ private async end(flights: readonly Flight[], why: string, settleMs: number = this.graceMs): Promise<void> {
239
299
  if (flights.length === 0) return
240
300
  for (const flight of flights) {
301
+ trace(`abort ${flight.tag} before=${String(flight.controller.signal.aborted)}`)
241
302
  flight.controller.abort()
303
+ trace(`abort ${flight.tag} after=${String(flight.controller.signal.aborted)} innerAborted=${String(flight.inner.signal.aborted)}`)
304
+ queueMicrotask(() => { trace(`post-abort ${flight.tag} innerAborted=${String(flight.inner.signal.aborted)}`) })
242
305
  flight.endBody()
243
306
  }
244
- let timer: ReturnType<typeof setTimeout> | undefined
245
- const gaveUp = new Promise<'gave-up'>(resolve => { timer = setTimeout(() => { resolve('gave-up') }, this.graceMs) })
246
- const outcome = await Promise.race([Promise.allSettled(flights.map(flight => flight.settled)).then(() => 'settled' as const), gaveUp])
247
- clearTimeout(timer)
248
- if (outcome === 'gave-up') this.ctx.logger.warn(`acryl-router: ${String(flights.length)} request(s) did not finish ${String(this.graceMs)} ms after abort (${why}); continued without them`)
307
+ if (await this.settledWithin(flights, settleMs)) return
308
+ this.ctx.logger.warn(`acryl-router: ${String(flights.length)} request(s) did not finish ${String(settleMs)} ms after abort (${why}); continued without them`)
249
309
  }
250
310
 
251
311
  /** Turn errors no mounted app handled into responses. One handler per router; the calling plugin owns it. */
@@ -292,7 +352,7 @@ export class Router extends Service {
292
352
  * `bindings` is an object an adapter hands to the handler as `c.env` (an upgrade request's socket hooks, for `upgradeWebSocket`); `connection` is added to it.
293
353
  */
294
354
  fetch(request: Request, connection: ConnectionInfo = {}, bindings?: object): Promise<Response> {
295
- if (this.state.closing) return Promise.resolve(new Response('Service Unavailable', { status: 503 }))
355
+ if (this.state.closing) return Promise.resolve(new Response('Service Unavailable', { status: 503, headers: { connection: 'close' } }))
296
356
  const controller = new AbortController()
297
357
  const forward = (): void => controller.abort()
298
358
  if (request.signal.aborted) controller.abort()
@@ -308,8 +368,11 @@ export class Router extends Service {
308
368
  let bodyDone: Promise<void> = Promise.resolve()
309
369
  let endBody = (): void => {}
310
370
  let resolveSettled!: (value: Promise<unknown>) => void
311
- const flight: Flight = { controller, running: new Map(), producer: undefined, endBody: () => { endBody() }, settled: new Promise(resolve => { resolveSettled = resolve }) }
371
+ const tag = `f${String(++flightSeq)}`
312
372
  const inner = new Request(request, { signal: controller.signal })
373
+ tagSignal(inner.signal, tag)
374
+ const flight: Flight = { controller, inner, tag, running: new Map(), producer: undefined, endBody: () => { endBody() }, settled: new Promise(resolve => { resolveSettled = resolve }) }
375
+ trace(`create ${tag} ${request.method} ${new URL(request.url).pathname}`)
313
376
  this.flightOf.set(inner, flight)
314
377
  // The caller gets the Response the moment the handler returns it; the flight lasts until its body ends and every waitUntil task settles.
315
378
  const response = this.dispatch(inner, env, executionCtx).then(raw => {
@@ -41,6 +41,10 @@ export class RouterHttp extends Service implements RouterHttpService {
41
41
  private readonly router: Context['router']
42
42
  private readonly web: WebServerPort
43
43
  private hub: UpgradeHub | undefined
44
+ /** One upgrade helper per calling context: a fresh wrapper per call made every service event look like an owner change. */
45
+ private readonly upgradeHelpers = new WeakMap<object, UpgradeWebSocket>()
46
+ /** A stable identity for this service instance's hub: a caller that must recognize the same owner across calls keys on this, not on a helper. */
47
+ private readonly hubIdentity: object = {}
44
48
  /** The adapter's own context: the hub lives as long as the adapter, whichever plugin asked for it first (`this.ctx` is the caller's). */
45
49
  private readonly home: Context
46
50
  private readonly upgradesIn = new UpgradeEmitter()
@@ -99,8 +103,18 @@ export class RouterHttp extends Service implements RouterHttpService {
99
103
  * `upgradeWebSocket` for the calling plugin: the sockets its routes accept are closed with 1001 when it unloads. Needs the `ws` package.
100
104
  * The route must be on a path the plugin mounted with `upgrade: [path]`.
101
105
  */
106
+ /** Stable for this service's lifetime; a re-provided adapter has a new one, which is how a caller sees a new owner. */
107
+ hubToken(): object {
108
+ return this.hubIdentity
109
+ }
110
+
102
111
  upgrades(): UpgradeWebSocket {
103
- return this.ensureHub().upgradeWebSocketFor(this.ctx) as unknown as UpgradeWebSocket
112
+ const owner = this.ctx
113
+ const cached = this.upgradeHelpers.get(owner)
114
+ if (cached !== undefined) return cached
115
+ const created = this.ensureHub().upgradeWebSocketFor(owner) as unknown as UpgradeWebSocket
116
+ this.upgradeHelpers.set(owner, created)
117
+ return created
104
118
  }
105
119
 
106
120
  /** How many WebSocket connections are open now. */
@@ -6,6 +6,35 @@ export interface FetchRouter {
6
6
  fetch(request: Request, connection?: { readonly remoteAddress?: string | undefined, readonly localPort?: number | undefined }): Promise<Response>
7
7
  }
8
8
 
9
+ /** How long a refusal waits for a request body the handler never read. */
10
+ export const REQUEST_BODY_SETTLE_MS = 1_000
11
+
12
+ /**
13
+ * Read and discard a request body the handler left unread (a guard's 403, a 415, a body over its limit), so the client can finish sending and
14
+ * read the answer. Ending a response while request data is still arriving makes Node close the socket with that data pending, and the peer sees a
15
+ * reset instead of the answer: reliably on Windows (the workspace git worktree test's read ECONNRESET) and on other systems under load. Bounded:
16
+ * a client that stops sending waits at most `ms`, then loses the socket as before.
17
+ */
18
+ export function settleRequestBody(req: IncomingMessage, ms: number = REQUEST_BODY_SETTLE_MS): Promise<void> {
19
+ if (req.readableEnded || req.destroyed) return Promise.resolve()
20
+ return new Promise<void>((resolve) => {
21
+ let timer: ReturnType<typeof setTimeout> | undefined
22
+ const done = (): void => {
23
+ if (timer !== undefined) clearTimeout(timer)
24
+ req.off('end', done)
25
+ req.off('close', done)
26
+ req.off('error', done)
27
+ resolve()
28
+ }
29
+ timer = setTimeout(done, ms)
30
+ timer.unref?.()
31
+ req.once('end', done)
32
+ req.once('close', done)
33
+ req.once('error', done)
34
+ req.resume()
35
+ })
36
+ }
37
+
9
38
  /**
10
39
  * Serve one node request through the router and write the result. The request's abort signal fires when the client
11
40
  * disconnects before the response finished; the socket's peer address and local port travel as connection info, never in the request.
@@ -20,5 +49,7 @@ export async function serve(router: FetchRouter, req: IncomingMessage, res: Serv
20
49
  } catch {
21
50
  response = new Response('Internal Server Error', { status: 500 })
22
51
  }
52
+ // The handler may have answered without reading the body; let the client finish sending before the answer ends the connection.
53
+ if (method !== 'GET' && method !== 'HEAD') await settleRequestBody(req)
23
54
  await sendResponse(res, response, method)
24
55
  }
@@ -1,13 +1,14 @@
1
1
  {
2
2
  "name": "acryl-state",
3
- "description": "Observable cells and derived cells, shared state with JSON and server-sent event routes on the router. Vendored from ACRYL.",
3
+ "description": "Observable cells and derived cells, shared state with JSON, server-sent event and WebSocket routes on the router, and a browser module (watch) that follows keys over one socket. Vendored from ACRYL.",
4
4
  "requires": ["acryl-router"],
5
5
  "inject": ["router"],
6
6
  "provides": ["state"],
7
7
  "dependencies": {
8
- "@deepseek-ai/cordis": "4.0.4"
8
+ "@deepseek-ai/cordis": "4.0.4",
9
+ "ws": "8.21.3"
9
10
  },
10
- "mount": "root.plugin(state); ctx.state.cell('todos.count', 0), then count.update((n) => n + 1)",
11
+ "mount": "root.plugin(state); ctx.state.cell('todos.count', 0), then count.update((n) => n + 1); a page follows it with import { watch } from '/api/state/client.js'; watch('todos.count', (n) => { ... })",
11
12
  "omit": ["src/react.ts", "**/*.test.*", "**/*.case.ts"],
12
13
  "entries": {
13
14
  "": "src/index.ts",
@@ -0,0 +1,244 @@
1
+ /**
2
+ * The browser module served at `/api/state/client.js`, kept as text so it travels with the plugin wherever the plugin goes (copied into an app as source, or bundled): there is
3
+ * no file to find at run time and no compiler needed to serve it. `String.raw` keeps every backslash as written, so what is below is the module exactly as the browser gets it.
4
+ * The code has no imports and uses no backtick or dollar-brace of its own.
5
+ *
6
+ * import { watch } from '/api/state/client.js'
7
+ * const stop = watch('tasks.count', (value) => { badge.textContent = String(value) })
8
+ *
9
+ * One WebSocket for the whole page carries every key; a WebSocket is not one of the browser's six connections per host, so a page that watches ten keys, in as many windows,
10
+ * leaves every one of them free for requests. Where the WebSocket cannot open (no `ws` package on the server, a proxy that does not upgrade), each key is followed by its
11
+ * event stream instead, with one console warning.
12
+ */
13
+ export const CLIENT_SOURCE = String.raw`// acryl-state: watch(key, onValue, onError?) follows a state key over one WebSocket for the page.
14
+ const HEARTBEAT_SILENCE_MS = 45000
15
+ const RETRY_MIN_MS = 250
16
+ const RETRY_MAX_MS = 10000
17
+
18
+ const here = import.meta.url
19
+ const watchers = new Map()
20
+ const latest = new Map()
21
+ const streams = new Map()
22
+ const complained = new Set()
23
+ let socket
24
+ let timer
25
+ let watchdog
26
+ let attempts = 0
27
+ let opened = false
28
+ let following = 'socket'
29
+ let suspended = false
30
+ let warnedStream = false
31
+
32
+ const socketUrl = () => {
33
+ const url = new URL('ws', here)
34
+ url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'
35
+ return url.href
36
+ }
37
+ const streamUrl = (key) => new URL(encodeURIComponent(key) + '/events?json=1', here).href
38
+
39
+ const deliver = (key, text) => {
40
+ if (latest.get(key) === text) return
41
+ latest.set(key, text)
42
+ let value
43
+ try {
44
+ value = JSON.parse(text)
45
+ } catch {
46
+ return
47
+ }
48
+ for (const watcher of [...(watchers.get(key) ?? [])]) {
49
+ try {
50
+ watcher.onValue(value)
51
+ } catch (cause) {
52
+ console.error('acryl-state: a watcher of ' + key + ' threw', cause)
53
+ }
54
+ }
55
+ }
56
+
57
+ const fail = (key, message) => {
58
+ const set = watchers.get(key)
59
+ if (set === undefined) return
60
+ let told = false
61
+ for (const watcher of [...set]) {
62
+ if (watcher.onError === undefined) continue
63
+ told = true
64
+ try {
65
+ watcher.onError(new Error(message))
66
+ } catch (cause) {
67
+ console.error('acryl-state: an error handler of ' + key + ' threw', cause)
68
+ }
69
+ }
70
+ if (!told && !complained.has(key)) {
71
+ complained.add(key)
72
+ console.warn('acryl-state: ' + key + ': ' + message)
73
+ }
74
+ }
75
+
76
+ const send = (frame) => {
77
+ if (socket !== undefined && socket.readyState === 1) socket.send(JSON.stringify(frame))
78
+ }
79
+
80
+ const arm = () => {
81
+ clearTimeout(watchdog)
82
+ watchdog = setTimeout(() => lost(socket), HEARTBEAT_SILENCE_MS)
83
+ }
84
+
85
+ const closeSocket = () => {
86
+ clearTimeout(watchdog)
87
+ const current = socket
88
+ socket = undefined
89
+ if (current === undefined) return
90
+ current.onopen = current.onmessage = current.onerror = current.onclose = null
91
+ try {
92
+ current.close(1000, 'page left')
93
+ } catch {
94
+ // Already closed.
95
+ }
96
+ }
97
+
98
+ const closeStreams = () => {
99
+ for (const source of streams.values()) source.close()
100
+ streams.clear()
101
+ }
102
+
103
+ const openStream = (key) => {
104
+ if (streams.has(key) || typeof EventSource === 'undefined') return
105
+ const source = new EventSource(streamUrl(key))
106
+ streams.set(key, source)
107
+ source.addEventListener('value', (event) => deliver(key, event.data))
108
+ source.onerror = () => {
109
+ // A network error is retried by the browser itself; a refusal (the key does not exist) ends the stream for good.
110
+ if (source.readyState === 2) {
111
+ streams.delete(key)
112
+ fail(key, 'the event stream was refused (no such state?)')
113
+ }
114
+ }
115
+ }
116
+
117
+ const fallBack = () => {
118
+ following = 'stream'
119
+ if (!warnedStream) {
120
+ warnedStream = true
121
+ console.warn('acryl-state: the WebSocket did not open, so each key is followed by its own event stream (each holds one of the browser\'s six connections to this host)')
122
+ }
123
+ for (const key of watchers.keys()) openStream(key)
124
+ }
125
+
126
+ // A socket that closes, errors or goes quiet: before it ever opened this is a server without WebSockets, so the streams take over; after, the server went away and the page tries again.
127
+ const lost = (which) => {
128
+ if (which !== socket) return
129
+ closeSocket()
130
+ if (watchers.size === 0 || suspended) return
131
+ if (!opened) {
132
+ fallBack()
133
+ return
134
+ }
135
+ const delay = Math.min(RETRY_MIN_MS * 2 ** attempts, RETRY_MAX_MS)
136
+ attempts += 1
137
+ clearTimeout(timer)
138
+ timer = setTimeout(connect, delay)
139
+ }
140
+
141
+ const connect = () => {
142
+ clearTimeout(timer)
143
+ if (socket !== undefined || watchers.size === 0 || suspended || following !== 'socket') return
144
+ let made
145
+ try {
146
+ made = new WebSocket(socketUrl())
147
+ } catch {
148
+ fallBack()
149
+ return
150
+ }
151
+ socket = made
152
+ made.onopen = () => {
153
+ opened = true
154
+ attempts = 0
155
+ arm()
156
+ for (const key of watchers.keys()) send({ t: 'sub', key })
157
+ }
158
+ made.onmessage = (event) => {
159
+ if (made !== socket) return
160
+ arm()
161
+ let frame
162
+ try {
163
+ frame = JSON.parse(event.data)
164
+ } catch {
165
+ return
166
+ }
167
+ if (frame === null || typeof frame !== 'object' || typeof frame.key !== 'string') return
168
+ if (typeof frame.error === 'string') fail(frame.key, frame.error)
169
+ else deliver(frame.key, JSON.stringify(frame.value))
170
+ }
171
+ made.onerror = () => lost(made)
172
+ made.onclose = () => lost(made)
173
+ }
174
+
175
+ const start = () => {
176
+ if (watchers.size === 0) return
177
+ if (following === 'stream') for (const key of watchers.keys()) openStream(key)
178
+ else connect()
179
+ }
180
+
181
+ if (typeof addEventListener === 'function') {
182
+ // A page in the back/forward cache keeps its connections open for as long as it is kept; let them go when the page is hidden for good, and open them again when it comes back.
183
+ addEventListener('pagehide', () => {
184
+ suspended = true
185
+ clearTimeout(timer)
186
+ closeSocket()
187
+ closeStreams()
188
+ })
189
+ addEventListener('pageshow', () => {
190
+ if (!suspended) return
191
+ suspended = false
192
+ start()
193
+ })
194
+ }
195
+
196
+ // Follow the state key: onValue gets the value now and after every change, as the JSON value the server holds (a string stays a string). onError, when given, is told of a key that
197
+ // does not exist; without it the page console gets one warning. Returns the function that stops watching.
198
+ export function watch(key, onValue, onError) {
199
+ if (typeof key !== 'string' || key === '') throw new TypeError('watch(key, onValue): the key is a state key like "tasks.count"')
200
+ if (typeof onValue !== 'function') throw new TypeError('watch(key, onValue): onValue is a function')
201
+ const watcher = { onValue, onError }
202
+ let set = watchers.get(key)
203
+ const first = set === undefined
204
+ if (first) {
205
+ set = new Set()
206
+ watchers.set(key, set)
207
+ }
208
+ set.add(watcher)
209
+ complained.delete(key)
210
+ if (!first && latest.has(key)) {
211
+ const text = latest.get(key)
212
+ queueMicrotask(() => {
213
+ if (!set.has(watcher)) return
214
+ try {
215
+ onValue(JSON.parse(text))
216
+ } catch (cause) {
217
+ console.error('acryl-state: a watcher of ' + key + ' threw', cause)
218
+ }
219
+ })
220
+ }
221
+ if (first && !suspended) {
222
+ if (following === 'stream') openStream(key)
223
+ else if (socket === undefined) connect()
224
+ else send({ t: 'sub', key })
225
+ }
226
+ let stopped = false
227
+ return () => {
228
+ if (stopped) return
229
+ stopped = true
230
+ set.delete(watcher)
231
+ if (set.size > 0) return
232
+ watchers.delete(key)
233
+ latest.delete(key)
234
+ complained.delete(key)
235
+ streams.get(key)?.close()
236
+ streams.delete(key)
237
+ send({ t: 'unsub', key })
238
+ if (watchers.size === 0) {
239
+ clearTimeout(timer)
240
+ closeSocket()
241
+ }
242
+ }
243
+ }
244
+ `
@@ -6,10 +6,12 @@
6
6
  */
7
7
 
8
8
  import { Context, Service } from '@deepseek-ai/cordis'
9
- import { Acryl, type Context as RequestContext } from 'acryl-router'
10
- import { streamSSE } from 'acryl-router/helpers'
9
+ import { Acryl, type Context as RequestContext, type MiddlewareHandler } from 'acryl-router'
10
+ import { streamSSE, type UpgradeWebSocket } from 'acryl-router/helpers'
11
11
  import { loopbackOnly, methodNotAllowed } from 'acryl-router/middleware'
12
12
  import { createCell, createDerived, type Cell, type ReadonlyCell } from './cell.ts'
13
+ import { CLIENT_SOURCE } from './client-source.ts'
14
+ import { refuseStranger, stateSocket } from './socket.ts'
13
15
 
14
16
  declare module '@deepseek-ai/cordis' {
15
17
  interface Context {
@@ -24,6 +26,9 @@ export const name = 'acryl-state'
24
26
 
25
27
  const KEY_PATTERN = /^[a-z][a-z0-9-]*(\.[a-z][a-z0-9-]*)*$/
26
28
 
29
+ /** Names under `/api/state` that are routes, not keys: a cell cannot be called by one. */
30
+ const RESERVED_KEYS = new Set(['ws', 'client.js'])
31
+
27
32
  export class State extends Service {
28
33
  private readonly cells = new Map<string, ReadonlyCell<unknown>>()
29
34
 
@@ -33,6 +38,7 @@ export class State extends Service {
33
38
 
34
39
  private claim(key: string): void {
35
40
  if (!KEY_PATTERN.test(key)) throw new TypeError(`acryl-state: key "${key}" must be dotted lowercase words, like "market.sources"`)
41
+ if (RESERVED_KEYS.has(key)) throw new TypeError(`acryl-state: "${key}" is the name of a route under /api/state, choose another key`)
36
42
  if (this.cells.has(key)) throw new Error(`acryl-state: key "${key}" is already in use`)
37
43
  }
38
44
 
@@ -73,9 +79,10 @@ export class State extends Service {
73
79
 
74
80
  /**
75
81
  * The current value of `key` as a server-sent event stream: the value now, then one event per change, until the client cancels or the router
76
- * is disposed (both abort the stream). A comment line goes out every 15 seconds so proxies and browsers keep an idle stream open.
82
+ * is disposed (both abort the stream). A comment line goes out every 15 seconds so proxies and browsers keep an idle stream open. A string value travels as it is; with `json`
83
+ * (the `?json=1` of the browser client) every value is JSON, so `"12"` and `12` can be told apart.
77
84
  */
78
- stream(key: string, c: RequestContext): Response | undefined {
85
+ stream(key: string, c: RequestContext, json = false): Response | undefined {
79
86
  const cell = this.find(key)
80
87
  if (cell === undefined) return undefined
81
88
  return streamSSE(c, async stream => {
@@ -83,7 +90,7 @@ export class State extends Service {
83
90
  // Writes are asynchronous; chaining them keeps events in the order the cell changed.
84
91
  let queue: Promise<unknown> = Promise.resolve()
85
92
  const send = (value: unknown): void => {
86
- queue = queue.then(() => stream.writeSSE({ event: 'value', data: typeof value === 'string' ? value : JSON.stringify(value) })).catch(() => {})
93
+ queue = queue.then(() => stream.writeSSE({ event: 'value', data: typeof value === 'string' && !json ? value : JSON.stringify(value) })).catch(() => {})
87
94
  }
88
95
  send(cell.get())
89
96
  const unsubscribe = cell.subscribe(send)
@@ -105,14 +112,34 @@ export function apply(ctx: Context): void {
105
112
  // app; the terminal calls the router in process and is the host's own code.
106
113
  ctx.inject(['router'], inner => {
107
114
  const api = new Acryl()
115
+ // The WebSocket form is mounted when a server that upgrades appears (the server row comes after this one in a blend); until then, and without the `ws` package, `/ws` answers 404 and the
116
+ // browser client follows the event streams. The route is registered before `/:key`, which would otherwise take `ws` for a key.
117
+ let socket: MiddlewareHandler | undefined
108
118
  api.use(loopbackOnly({ inProcess: 'allow' }), methodNotAllowed({ app: api }))
109
119
  .get('/', c => c.json({ keys: state.keys() }))
120
+ .get('/client.js', c => c.body(CLIENT_SOURCE, 200, { 'content-type': 'text/javascript; charset=utf-8', 'cache-control': 'no-cache' }))
121
+ .get('/ws', async (c, next) => {
122
+ const upgrade = socket
123
+ if (upgrade === undefined) return c.json({ error: 'this server does not upgrade to a WebSocket' }, 404)
124
+ const refusal = refuseStranger(c)
125
+ if (refusal !== undefined) return refusal
126
+ return await upgrade(c, next)
127
+ })
110
128
  .get('/:key', c => {
111
129
  const cell = state.find(c.req.param('key'))
112
130
  return cell === undefined ? c.json({ error: 'no such state' }, 404) : c.json({ key: cell.key, value: cell.get() })
113
131
  })
114
- .get('/:key/events', c => state.stream(c.req.param('key'), c) ?? c.json({ error: 'no such state' }, 404))
132
+ .get('/:key/events', c => state.stream(c.req.param('key'), c, c.req.query('json') === '1') ?? c.json({ error: 'no such state' }, 404))
115
133
  inner.router.route('/api/state', api)
134
+ inner.inject(['nodeServer'], withServer => {
135
+ try {
136
+ const upgrade = (withServer as unknown as { nodeServer: { upgrades(): UpgradeWebSocket } }).nodeServer.upgrades()
137
+ socket = stateSocket(state, { upgrade })
138
+ withServer.effect(() => () => { socket = undefined }, 'acryl-state: the WebSocket route')
139
+ } catch (cause) {
140
+ ctx.logger.warn(`acryl-state: state is followed by event streams only (${cause instanceof Error ? cause.message : String(cause)})`)
141
+ }
142
+ })
116
143
  // Over HTTP only where an adapter exists (Web and Desktop); the terminal needs none.
117
144
  inner.inject(['routerHttp'], http => { http.routerHttp.mount('/api/state') })
118
145
  })