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.
- package/README.md +9 -0
- package/dist/index.d.ts +60 -2
- package/dist/index.js +1992 -33
- package/dist/index.js.map +1 -1
- package/docs/framework/agents/index.md +1 -1
- package/docs/framework/api/summary.md +1 -1
- package/docs/framework/getting-started/desktop.md +23 -4
- package/docs/framework/guides/create-acrylblends.md +1 -1
- package/docs/framework/guides/production.md +20 -0
- package/docs/framework/workshop/README.md +174 -285
- package/docs/framework/workshop/facilitator.md +81 -90
- package/legacy/README.md +9 -0
- package/legacy/agent/157ce3db1112/src/app.ts +98 -0
- package/legacy/agent/157ce3db1112/src/routes.ts +63 -0
- package/legacy/agent/24c681a00fca/src/app.ts +160 -0
- package/legacy/agent/24c681a00fca/src/routes.ts +83 -0
- package/legacy/agent/2e790da977b2/src/app.ts +98 -0
- package/legacy/agent/2e790da977b2/src/routes.ts +63 -0
- package/legacy/agent/6307045545dc/src/app.ts +168 -0
- package/legacy/agent/6307045545dc/src/routes.ts +83 -0
- package/legacy/agent/6a5fb9c15c53/src/app.ts +158 -0
- package/legacy/agent/6a5fb9c15c53/src/routes.ts +83 -0
- package/legacy/agent/71fb54a5a942/src/app.ts +111 -0
- package/legacy/agent/71fb54a5a942/src/routes.ts +63 -0
- package/legacy/agent/74ece148269c/src/app.ts +168 -0
- package/legacy/agent/74ece148269c/src/routes.ts +93 -0
- package/legacy/agent/855359304707/src/app.ts +168 -0
- package/legacy/agent/855359304707/src/routes.ts +126 -0
- package/legacy/agent/b5ef3c786ae4/src/app.ts +127 -0
- package/legacy/agent/b5ef3c786ae4/src/routes.ts +76 -0
- package/legacy/agent/c2f30958043b/src/app.ts +98 -0
- package/legacy/agent/c2f30958043b/src/routes.ts +53 -0
- package/legacy/blank/5a91c558af22/src/routes.ts +18 -0
- package/legacy/blank/9c650cf52bf3/src/routes.ts +14 -0
- package/legacy/blank/aea589283445/src/routes.ts +16 -0
- package/legacy/blank/b2cbb759c889/src/routes.ts +16 -0
- package/legacy/blank/d6d371c9a65e/src/routes.ts +18 -0
- package/legacy/chat/7076cc821e2c/src/app.ts +72 -0
- package/legacy/chat/7076cc821e2c/src/routes.ts +13 -0
- package/legacy/chat/7dbe6d5170df/src/app.ts +107 -0
- package/legacy/chat/7dbe6d5170df/src/routes.ts +19 -0
- package/legacy/chat/a9a9ab44c58e/src/app.ts +107 -0
- package/legacy/chat/a9a9ab44c58e/src/routes.ts +15 -0
- package/legacy/chat/f20ad9a51b06/src/app.ts +106 -0
- package/legacy/chat/f20ad9a51b06/src/routes.ts +15 -0
- package/legacy/layouts.json +524 -0
- package/legacy/nodejs/1d477f37aaf4/src/app.ts +92 -0
- package/legacy/nodejs/1d477f37aaf4/src/routes.ts +40 -0
- package/legacy/nodejs/b1bd9462496c/src/app.ts +61 -0
- package/legacy/nodejs/b1bd9462496c/src/routes.ts +37 -0
- package/legacy/nodejs/d5bab75076ef/src/app.ts +95 -0
- package/legacy/nodejs/d5bab75076ef/src/routes.ts +40 -0
- package/legacy/notes/4edc36949c15/src/app.ts +107 -0
- package/legacy/notes/4edc36949c15/src/routes.ts +14 -0
- package/legacy/notes/6cab2d09747c/src/app.ts +72 -0
- package/legacy/notes/6cab2d09747c/src/routes.ts +12 -0
- package/legacy/notes/7ca2dfed623a/src/app.ts +106 -0
- package/legacy/notes/7ca2dfed623a/src/routes.ts +14 -0
- package/legacy/todo/02e613b3625c/src/app.ts +71 -0
- package/legacy/todo/02e613b3625c/src/todo.ts +123 -0
- package/legacy/todo/45694a8d9e15/src/app.ts +71 -0
- package/legacy/todo/45694a8d9e15/src/todo.ts +96 -0
- package/legacy/todo/5923d3a6099f/src/app.ts +71 -0
- package/legacy/todo/5923d3a6099f/src/todo.ts +125 -0
- package/legacy/todo/849a18d66dc1/src/app.ts +91 -0
- package/legacy/todo/849a18d66dc1/src/todo.ts +125 -0
- package/legacy/todo/8e64211312f3/src/app.ts +71 -0
- package/legacy/todo/8e64211312f3/src/todo.ts +125 -0
- package/legacy/todo/b79078a87565/src/app.ts +71 -0
- package/legacy/todo/b79078a87565/src/todo.ts +125 -0
- package/lib/{audit-Dw-siYTp.d.ts → audit-Bok_UNCn.d.ts} +41 -6
- package/lib/{client-modules-drj4mjhj.js → client-modules-Bkdr83jO.js} +1 -1
- package/lib/cordis/door.d.ts +44 -7
- package/lib/cordis/door.js +214 -60
- package/lib/cordis/inspect.js +1 -1
- package/lib/cordis/node-server.d.ts +101 -3
- package/lib/cordis/node-server.js +3 -3
- package/lib/cordis/router-http.d.ts +7 -1
- package/lib/cordis/router-http.js +44 -3
- package/lib/cordis/router.d.ts +1 -1
- package/lib/cordis/router.js +1 -1
- package/lib/cordis/state.d.ts +3 -2
- package/lib/cordis/state.js +371 -7
- package/lib/door/host.d.ts +2 -2
- package/lib/door/host.js +1 -1
- package/lib/door/page.d.ts +20 -1
- package/lib/door/page.js +75 -14
- package/lib/door/protocol.d.ts +1 -1
- package/lib/drivers.d.ts +10 -1
- package/lib/{index-DMOtA-NJ.d.ts → index-DGq7H0lb.d.ts} +11 -1
- package/lib/node-server.d.ts +7 -1
- package/lib/node-server.js +2 -2
- package/lib/{protocol-a2aqrp2X.d.ts → protocol-BeIYCgTo.d.ts} +4 -0
- package/lib/{snapshot-text-BfEbEOjA.js → snapshot-text-CXTnjxGx.js} +121 -9
- package/lib/src-CtkIX_Gg.js +427 -0
- package/lib/{src-BTBm4OPD.js → src-iBJ2ysV9.js} +77 -14
- package/lib/{test-websocket-BLeEvybj.js → test-websocket-CQOSvBth.js} +17 -7
- package/lib/{test-websocket-DyeS50Tr.d.ts → test-websocket-GUTAkFQT.d.ts} +3 -1
- package/package.json +2 -1
- package/plugins/acryl-door/src/host/channel.ts +124 -9
- package/plugins/acryl-door/src/host/chat-tools.ts +8 -6
- package/plugins/acryl-door/src/host/door-file.ts +7 -2
- package/plugins/acryl-door/src/host/door.ts +58 -23
- package/plugins/acryl-door/src/host/page-routes.ts +3 -1
- package/plugins/acryl-door/src/host/page-socket.ts +18 -8
- package/plugins/acryl-door/src/host/verbs.ts +3 -0
- package/plugins/acryl-door/src/index.ts +166 -29
- package/plugins/acryl-door/src/page/base.ts +32 -0
- package/plugins/acryl-door/src/page/channel.ts +11 -1
- package/plugins/acryl-door/src/page/driver/accessibility.ts +11 -0
- package/plugins/acryl-door/src/page/driver/actions.ts +4 -0
- package/plugins/acryl-door/src/page/driver/driver.ts +49 -7
- package/plugins/acryl-door/src/page/driver/snapshot.ts +5 -2
- package/plugins/acryl-door/src/page/index.ts +46 -2
- package/plugins/acryl-door/src/page/indicator.ts +14 -6
- package/plugins/acryl-door/src/page/outline.ts +56 -0
- package/plugins/acryl-door/src/page/protocol.ts +4 -0
- package/plugins/acryl-door/src/page/snapshot-text.ts +2 -1
- package/plugins/acryl-node-server/src/adapter/listener.ts +4 -0
- package/plugins/acryl-node-server/src/adapter/raw.ts +18 -0
- package/plugins/acryl-node-server/src/adapter/websocket.ts +15 -9
- package/plugins/acryl-node-server/src/client-modules.ts +7 -1
- package/plugins/acryl-node-server/src/index.ts +304 -16
- package/plugins/acryl-node-server/src/upgrade-hub.ts +2 -2
- package/plugins/acryl-page/plugin.json +12 -0
- package/plugins/acryl-page/src/index.ts +163 -0
- package/plugins/acryl-router/src/service.ts +77 -14
- package/plugins/acryl-router-http/src/index.ts +15 -1
- package/plugins/acryl-router-http/src/serve.ts +31 -0
- package/plugins/acryl-state/plugin.json +4 -3
- package/plugins/acryl-state/src/client-source.ts +244 -0
- package/plugins/acryl-state/src/service.ts +33 -6
- package/plugins/acryl-state/src/socket.ts +96 -0
- package/plugins/acryl-web-server/plugin.json +14 -0
- package/plugins/acryl-web-server/src/index.ts +164 -0
- package/plugins/agent/agent.ts +38 -7
- package/plugins/agent/index.ts +1 -1
- package/plugins/agent/model.ts +10 -2
- package/plugins/agent/tools.ts +9 -3
- package/plugins/agent-panel/client/describe.ts +57 -0
- package/plugins/agent-panel/client/driver-pick.ts +22 -0
- package/plugins/agent-panel/client/frame.ts +51 -0
- package/plugins/agent-panel/client/main.ts +343 -81
- package/plugins/agent-panel/drivers.ts +7 -2
- package/plugins/agent-panel/headless.ts +5 -2
- package/plugins/agent-panel/index.ts +187 -61
- package/plugins/agent-panel/mount.ts +27 -0
- package/plugins/agent-panel/visitors.ts +231 -0
- package/plugins/desktop/frame.ts +231 -0
- package/plugins/desktop/launch.ts +44 -9
- package/plugins/desktop/main.ts +100 -32
- package/plugins/desktop/window.ts +21 -4
- package/plugins/driver-pty/client/terminal.ts +4 -1
- package/plugins/driver-pty/index.ts +38 -11
- package/plugins/driver-pty/spawn.ts +38 -0
- package/plugins/driver-pty/terminal.ts +2 -0
- package/plugins/extensions/index.ts +26 -1
- package/plugins/extensions/resolve.ts +13 -0
- package/plugins/feedback/client.d.ts +22 -0
- package/plugins/feedback/client.js +471 -0
- package/plugins/feedback/index.ts +143 -0
- package/plugins/feedback/plugin.json +11 -0
- package/plugins/feedback/store.ts +307 -0
- package/plugins/home/index.ts +122 -0
- package/plugins/home/plugin.json +11 -0
- package/plugins/instance/index.ts +12 -3
- package/plugins/manifest/app-plugins.ts +86 -0
- package/plugins/manifest/index.ts +12 -2
- package/plugins/manifest/snapshot.ts +275 -0
- package/plugins/openai/index.ts +64 -5
- package/plugins/openai/prompt.ts +23 -0
- package/shared/AGENTS.core.md +36 -16
- package/shared/skills/acryl-add-ui/SKILL.md +19 -0
- package/shared/skills/acryl-app/SKILL.md +13 -0
- package/shared/skills/acryl-build-plugin/SKILL.md +22 -0
- package/shared/skills/acryl-change-plugin/SKILL.md +14 -0
- package/shared/skills/acryl-diagnose-plugin/SKILL.md +13 -0
- package/shared/skills/acryl-fix-plugin/SKILL.md +13 -0
- package/shared/skills/acryl-remove-plugin/SKILL.md +12 -0
- package/shared/skills/acryl-share-plugin/SKILL.md +16 -0
- package/shared/skills/cordis-plugin/SKILL.md +26 -0
- package/templates/agent/AGENTS.md +4 -2
- package/templates/agent/blend.yaml +4 -0
- package/templates/agent/gitignore +1 -0
- package/templates/agent/package.json +1 -0
- package/templates/agent/src/app.ts +150 -33
- package/templates/agent/src/frame.ts +17 -0
- package/templates/agent/src/main.ts +5 -3
- package/templates/agent/src/routes.ts +73 -21
- package/templates/agent/template.json +2 -0
- package/templates/agent/test/app.test.ts +83 -5
- package/templates/blank/AGENTS.md +3 -2
- package/templates/blank/blend.yaml +11 -0
- package/templates/blank/gitignore +1 -0
- package/templates/blank/package.json +1 -0
- package/templates/blank/src/app.ts +100 -29
- package/templates/blank/src/door.ts +7 -0
- package/templates/blank/src/first-page.ts +5 -0
- package/templates/blank/src/frame.ts +17 -0
- package/templates/blank/src/routes.ts +65 -8
- package/templates/blank/template.json +14 -1
- package/templates/blank/test/app.test.ts +103 -16
- package/templates/chat/blend.yaml +2 -1
- package/templates/chat/gitignore +1 -0
- package/templates/chat/package.json +1 -0
- package/templates/chat/src/app.ts +72 -22
- package/templates/chat/src/chat-app.ts +11 -2
- package/templates/chat/src/client/chat.tsx +4 -2
- package/templates/chat/src/frame.ts +17 -0
- package/templates/chat/src/main.ts +1 -6
- package/templates/chat/src/page.tsx +6 -4
- package/templates/chat/src/routes.ts +14 -4
- package/templates/chat/src/runtime.ts +9 -3
- package/templates/chat/template.json +3 -1
- package/templates/chat/test/chat.test.ts +46 -1
- package/templates/chat/test/server.test.ts +17 -3
- package/templates/nodejs/blend.yaml +2 -1
- package/templates/nodejs/gitignore +1 -0
- package/templates/nodejs/package.json +1 -0
- package/templates/nodejs/src/app.ts +76 -21
- package/templates/nodejs/src/frame.ts +17 -0
- package/templates/nodejs/src/main.ts +1 -6
- package/templates/nodejs/src/page.tsx +3 -3
- package/templates/nodejs/src/routes.ts +32 -13
- package/templates/nodejs/template.json +3 -1
- package/templates/nodejs/test/app.test.ts +38 -3
- package/templates/nodejs/test/server.test.ts +17 -3
- package/templates/notes/blend.yaml +2 -1
- package/templates/notes/gitignore +1 -0
- package/templates/notes/package.json +1 -0
- package/templates/notes/src/app.ts +72 -22
- package/templates/notes/src/frame.ts +17 -0
- package/templates/notes/src/main.ts +1 -6
- package/templates/notes/src/notes-app.ts +24 -17
- package/templates/notes/src/routes.ts +11 -4
- package/templates/notes/src/runtime.ts +9 -3
- package/templates/notes/src/views.tsx +26 -17
- package/templates/notes/template.json +3 -1
- package/templates/notes/test/notes.test.ts +39 -1
- package/templates/notes/test/server.test.ts +17 -3
- package/templates/todo/blend.yaml +3 -0
- package/templates/todo/gitignore +1 -0
- package/templates/todo/package.json +1 -0
- package/templates/todo/src/app.ts +81 -27
- package/templates/todo/src/door.ts +7 -0
- package/templates/todo/src/frame.ts +17 -0
- package/templates/todo/src/page.ts +13 -6
- package/templates/todo/src/todo.ts +18 -6
- package/templates/todo/template.json +11 -1
- package/templates/todo/test/todo.test.ts +67 -9
- package/lib/src-LX0E4ec4.js +0 -117
- 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.
|
|
154
|
-
}, 'acryl-router:
|
|
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:
|
|
172
|
-
// plugin
|
|
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.
|
|
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
|
-
/**
|
|
238
|
-
|
|
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
|
-
|
|
245
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
})
|