acrylblends 1.0.9 → 1.1.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/dist/index.d.ts +15 -1
- package/dist/index.js +13 -7
- package/dist/index.js.map +1 -1
- package/docs/framework/api/summary.md +1 -1
- package/docs/framework/guides/bring-your-own-tool.md +2 -0
- package/docs/framework/guides/production.md +20 -0
- package/docs/framework/workshop/README.md +177 -274
- package/docs/framework/workshop/facilitator.md +107 -84
- package/lib/{audit-Dw-siYTp.d.ts → audit-BFpULBCj.d.ts} +15 -3
- package/lib/cordis/door.d.ts +3 -1
- package/lib/cordis/door.js +11 -1
- package/lib/cordis/state.d.ts +3 -2
- package/lib/cordis/state.js +370 -6
- package/lib/db/conformance.d.ts +6 -2
- package/lib/db/conformance.js +14 -2
- package/lib/door/host.d.ts +1 -1
- package/lib/door/host.js +1 -1
- package/lib/door/page.d.ts +10 -0
- package/lib/door/page.js +29 -7
- package/lib/kv/conformance.d.ts +6 -2
- package/lib/kv/conformance.js +14 -2
- package/lib/{snapshot-text-CBi3EMnj.js → snapshot-text-BwM6T2nG.js} +37 -5
- package/lib/vitest-binding-CctNu3jT.d.ts +15 -0
- package/lib/vitest-binding-CrH7rKg2.js +17 -0
- package/package.json +1 -5
- package/plugins/acryl-door/src/host/channel.ts +43 -5
- package/plugins/acryl-door/src/host/door.ts +5 -0
- package/plugins/acryl-door/src/host/page-routes.ts +1 -1
- package/plugins/acryl-door/src/host/page-socket.ts +1 -1
- package/plugins/acryl-door/src/page/channel.ts +8 -0
- package/plugins/acryl-door/src/page/driver/driver.ts +17 -3
- package/plugins/acryl-door/src/page/index.ts +6 -1
- package/plugins/acryl-door/src/page/indicator.ts +14 -6
- 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/agent-panel/client/main.ts +105 -55
- package/plugins/agent-panel/index.ts +42 -5
- package/plugins/desktop/main.ts +5 -1
- package/plugins/instance/index.ts +6 -3
- package/templates/agent/AGENTS.md +3 -1
- package/templates/agent/src/routes.ts +38 -5
- package/templates/agent/test/app.test.ts +38 -2
- package/templates/chat/src/main.ts +1 -6
- package/templates/nodejs/src/main.ts +1 -6
- package/templates/notes/src/main.ts +1 -6
|
@@ -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
|
})
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* State over a WebSocket. A server-sent event stream holds one of the browser's six connections per host for as long as the page lives, and an app that follows a few keys in
|
|
3
|
+
* several windows uses them all; a WebSocket is not one of those six, and one socket carries every key of a page.
|
|
4
|
+
*
|
|
5
|
+
* `GET /api/state/ws` upgrades. Up, one JSON message per frame: `{ "t": "sub", "key": "<key>" }` and `{ "t": "unsub", "key": "<key>" }`. Down: `{ "key": "<key>", "value": <json> }` once on
|
|
6
|
+
* `sub` and after every change, `{ "key": "<key>", "error": "<why>" }` for a key that cannot be followed, and `{ "t": "ping" }` every {@link SOCKET_HEARTBEAT_MS} so a page can tell a quiet
|
|
7
|
+
* socket from a dead one. `GET /api/state/client.js` is the browser module that speaks this (`watch(key, callback)`), with the event stream as its fallback.
|
|
8
|
+
*
|
|
9
|
+
* A WebSocket has no CORS, so the upgrade is checked here ({@link refuseStranger}): a loopback peer on this port with a loopback `Host`, and an `Origin` that, when the request has one,
|
|
10
|
+
* is exactly this app's own origin. A program on this machine that sends no `Origin` (a script, a test) is let in, as it is for the JSON and stream routes; a web page of another
|
|
11
|
+
* origin, and a name that only resolves to this machine (DNS rebinding), are not.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { Context, MiddlewareHandler } from 'acryl-router'
|
|
15
|
+
import type { UpgradeWebSocket } from 'acryl-router/helpers'
|
|
16
|
+
import { loopbackAuthority } from 'acryl-router/middleware'
|
|
17
|
+
import type { ReadonlyCell } from './cell.ts'
|
|
18
|
+
|
|
19
|
+
/** How often the server writes a `ping`; a page gives up on a socket that is silent for three of these. */
|
|
20
|
+
export const SOCKET_HEARTBEAT_MS = 15_000
|
|
21
|
+
|
|
22
|
+
/** How many keys one socket may follow; a page watches a handful. */
|
|
23
|
+
export const MAX_KEYS_PER_SOCKET = 256
|
|
24
|
+
|
|
25
|
+
/** What the socket needs of the state service. */
|
|
26
|
+
export interface Cells {
|
|
27
|
+
find(key: string): ReadonlyCell<unknown> | undefined
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface StateSocketOptions {
|
|
31
|
+
readonly upgrade: UpgradeWebSocket
|
|
32
|
+
readonly heartbeatMs?: number
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** A refusal (403) unless the upgrade comes from this machine to this app, and from its own page when it comes from a page at all. */
|
|
36
|
+
export function refuseStranger(c: Context): Response | undefined {
|
|
37
|
+
const authority = loopbackAuthority(c)
|
|
38
|
+
if (authority === undefined) return c.json({ error: 'request authority rejected' }, 403)
|
|
39
|
+
const origin = c.req.header('origin')
|
|
40
|
+
const site = c.req.header('sec-fetch-site')
|
|
41
|
+
if ((origin !== undefined && origin !== authority.origin) || (site !== undefined && site !== 'same-origin')) {
|
|
42
|
+
return c.json({ error: 'only this app\'s own page may follow its state' }, 403)
|
|
43
|
+
}
|
|
44
|
+
return undefined
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The route handler that upgrades and follows keys of `cells` for as long as the socket is open. */
|
|
48
|
+
export function stateSocket(cells: Cells, options: StateSocketOptions): MiddlewareHandler {
|
|
49
|
+
const heartbeatMs = options.heartbeatMs ?? SOCKET_HEARTBEAT_MS
|
|
50
|
+
return options.upgrade(() => {
|
|
51
|
+
const following = new Map<string, () => void>()
|
|
52
|
+
let beat: NodeJS.Timeout | undefined
|
|
53
|
+
return {
|
|
54
|
+
onOpen(_event, ws) {
|
|
55
|
+
beat = setInterval(() => {
|
|
56
|
+
try { ws.send(JSON.stringify({ t: 'ping' })) } catch { /* the close event follows */ }
|
|
57
|
+
}, heartbeatMs)
|
|
58
|
+
beat.unref()
|
|
59
|
+
},
|
|
60
|
+
onMessage(event, ws) {
|
|
61
|
+
if (typeof event.data !== 'string') return
|
|
62
|
+
let message: { t?: unknown, key?: unknown }
|
|
63
|
+
try { message = JSON.parse(event.data) as typeof message } catch { return }
|
|
64
|
+
if (typeof message.key !== 'string') return
|
|
65
|
+
const key = message.key
|
|
66
|
+
const tell = (frame: object): void => {
|
|
67
|
+
try { ws.send(JSON.stringify(frame)) } catch { /* the close event follows */ }
|
|
68
|
+
}
|
|
69
|
+
const value = (current: unknown): void => {
|
|
70
|
+
let text: string
|
|
71
|
+
try { text = JSON.stringify({ key, value: current === undefined ? null : current }) } catch { tell({ key, error: 'the value cannot be sent as JSON' }); return }
|
|
72
|
+
try { ws.send(text) } catch { /* the close event follows */ }
|
|
73
|
+
}
|
|
74
|
+
if (message.t === 'unsub') {
|
|
75
|
+
following.get(key)?.()
|
|
76
|
+
following.delete(key)
|
|
77
|
+
return
|
|
78
|
+
}
|
|
79
|
+
if (message.t !== 'sub') return
|
|
80
|
+
const cell = cells.find(key)
|
|
81
|
+
if (cell === undefined) { tell({ key, error: 'no such state' }); return }
|
|
82
|
+
const known = following.has(key)
|
|
83
|
+
if (!known && following.size >= MAX_KEYS_PER_SOCKET) { tell({ key, error: 'too many keys on one socket' }); return }
|
|
84
|
+
// A key asked for twice is answered again with its value, and followed once.
|
|
85
|
+
if (known) { value(cell.get()); return }
|
|
86
|
+
value(cell.get())
|
|
87
|
+
following.set(key, cell.subscribe(value))
|
|
88
|
+
},
|
|
89
|
+
onClose() {
|
|
90
|
+
if (beat !== undefined) clearInterval(beat)
|
|
91
|
+
for (const stop of following.values()) stop()
|
|
92
|
+
following.clear()
|
|
93
|
+
},
|
|
94
|
+
}
|
|
95
|
+
}, { textOnly: true })
|
|
96
|
+
}
|
|
@@ -42,70 +42,90 @@ interface RunSource {
|
|
|
42
42
|
readonly events: AsyncIterable<PanelEvent>
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
+
/** The door's id for this page, when the door is in it: sent with a question so the agent's calls (which name no window) drive this page and not another open one. */
|
|
46
|
+
function doorPage(): { page?: string } {
|
|
47
|
+
const published = (window as unknown as Record<symbol, { windowId?: unknown } | undefined>)[
|
|
48
|
+
Symbol.for('acryl.door.page')
|
|
49
|
+
]
|
|
50
|
+
return typeof published?.windowId === 'string' ? { page: published.windowId } : {}
|
|
51
|
+
}
|
|
52
|
+
|
|
45
53
|
/** A WebSocket is not one of the browser's six connections per host, so the panel's Stop and a run still work when two windows stream their own state. */
|
|
46
54
|
let socketBroken = false
|
|
47
55
|
let activeSocket: WebSocket | undefined
|
|
48
56
|
|
|
49
|
-
/**
|
|
50
|
-
|
|
57
|
+
/** The server did not accept the token: it is not the one this page was given (the app restarted since, which `node --watch` does at every change of a file). */
|
|
58
|
+
class Refused extends Error {}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Open the run socket and send the token and the first action. Resolves once the server has accepted the token (its `accepted` frame), with the socket and its events: the
|
|
62
|
+
* frames of the run as panel events, until the socket closes. One set of handlers serves the socket for its whole life, so a frame is never handed to a handler that was
|
|
63
|
+
* replaced. Rejects with `Refused` when the token is not accepted, and with another error when the page cannot upgrade (a proxy, a server without `ws`): the caller falls back
|
|
64
|
+
* to the stream.
|
|
65
|
+
*/
|
|
66
|
+
function openSocket(
|
|
67
|
+
token: string,
|
|
68
|
+
action: Record<string, unknown>,
|
|
69
|
+
): Promise<{ socket: WebSocket; events: AsyncGenerator<PanelEvent> }> {
|
|
51
70
|
return new Promise((resolve, reject) => {
|
|
52
71
|
const socket = new WebSocket(
|
|
53
72
|
`${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}${PREFIX}/run/ws`,
|
|
54
73
|
)
|
|
74
|
+
const queue: PanelEvent[] = []
|
|
75
|
+
let wake: (() => void) | undefined
|
|
76
|
+
let closed = false
|
|
77
|
+
let accepted = false
|
|
55
78
|
const timer = setTimeout(() => {
|
|
56
79
|
socket.close()
|
|
57
|
-
reject(new Error('the socket
|
|
80
|
+
reject(new Error('the socket was not accepted in time'))
|
|
58
81
|
}, 4000)
|
|
59
82
|
socket.onopen = () => {
|
|
60
|
-
clearTimeout(timer)
|
|
61
83
|
socket.send(JSON.stringify({ t: 'hello', token }))
|
|
62
84
|
socket.send(JSON.stringify(action))
|
|
63
|
-
resolve(socket)
|
|
64
85
|
}
|
|
65
|
-
socket.
|
|
66
|
-
|
|
67
|
-
|
|
86
|
+
socket.onmessage = (message) => {
|
|
87
|
+
try {
|
|
88
|
+
const frame = JSON.parse(String(message.data)) as { e?: unknown; d?: unknown }
|
|
89
|
+
if (frame.e === 'accepted' && !accepted) {
|
|
90
|
+
accepted = true
|
|
91
|
+
clearTimeout(timer)
|
|
92
|
+
resolve({ socket, events: read() })
|
|
93
|
+
} else if (typeof frame.e === 'string')
|
|
94
|
+
queue.push({ event: frame.e, data: (frame.d ?? {}) as Record<string, unknown> })
|
|
95
|
+
} catch {
|
|
96
|
+
// Not a frame of ours.
|
|
97
|
+
}
|
|
98
|
+
wake?.()
|
|
68
99
|
}
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
const frame = JSON.parse(String(message.data)) as { e?: unknown; d?: unknown }
|
|
80
|
-
if (typeof frame.e === 'string')
|
|
81
|
-
queue.push({ event: frame.e, data: (frame.d ?? {}) as Record<string, unknown> })
|
|
82
|
-
} catch {
|
|
83
|
-
// Not a frame of ours.
|
|
100
|
+
socket.onclose = (event) => {
|
|
101
|
+
clearTimeout(timer)
|
|
102
|
+
closed = true
|
|
103
|
+
if (!accepted)
|
|
104
|
+
reject(
|
|
105
|
+
event.code === 4401
|
|
106
|
+
? new Refused('the token was not accepted')
|
|
107
|
+
: new Error('the socket closed before it was accepted'),
|
|
108
|
+
)
|
|
109
|
+
wake?.()
|
|
84
110
|
}
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
closed = event
|
|
89
|
-
wake?.()
|
|
90
|
-
}
|
|
91
|
-
for (;;) {
|
|
92
|
-
const next = queue.shift()
|
|
93
|
-
if (next !== undefined) {
|
|
94
|
-
yield next
|
|
95
|
-
continue
|
|
111
|
+
socket.onerror = () => {
|
|
112
|
+
clearTimeout(timer)
|
|
113
|
+
if (!accepted) reject(new Error('the server refused the upgrade'))
|
|
96
114
|
}
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
115
|
+
async function* read(): AsyncGenerator<PanelEvent> {
|
|
116
|
+
for (;;) {
|
|
117
|
+
const next = queue.shift()
|
|
118
|
+
if (next !== undefined) {
|
|
119
|
+
yield next
|
|
120
|
+
continue
|
|
102
121
|
}
|
|
103
|
-
|
|
122
|
+
if (closed) return
|
|
123
|
+
await new Promise<void>((done) => {
|
|
124
|
+
wake = done
|
|
125
|
+
})
|
|
126
|
+
}
|
|
104
127
|
}
|
|
105
|
-
|
|
106
|
-
wake = resolve
|
|
107
|
-
})
|
|
108
|
-
}
|
|
128
|
+
})
|
|
109
129
|
}
|
|
110
130
|
|
|
111
131
|
const css = `
|
|
@@ -145,7 +165,7 @@ textarea { width: 100%; min-height: 64px; resize: vertical; padding: 8px; border
|
|
|
145
165
|
async function start(): Promise<void> {
|
|
146
166
|
const reply = await fetch(`${PREFIX}/session`)
|
|
147
167
|
if (!reply.ok) return
|
|
148
|
-
|
|
168
|
+
let session = (await reply.json()) as Session
|
|
149
169
|
|
|
150
170
|
const host = document.createElement('div')
|
|
151
171
|
host.id = 'agent-panel'
|
|
@@ -199,6 +219,7 @@ async function start(): Promise<void> {
|
|
|
199
219
|
onChange(change: { extension: string; action: string }): void
|
|
200
220
|
}): Promise<{ detach(): void; stop(): Promise<void> }>
|
|
201
221
|
}
|
|
222
|
+
await refreshSession()
|
|
202
223
|
terminal = await openTerminal({
|
|
203
224
|
container: term,
|
|
204
225
|
token: session.token,
|
|
@@ -265,26 +286,41 @@ async function start(): Promise<void> {
|
|
|
265
286
|
// A run belongs to the app, not to this page: it goes on when the page reloads (the door reloads it after a plugin change, an agent may move it to another page) and the page that
|
|
266
287
|
// loads next follows it again. Only Stop cancels it.
|
|
267
288
|
const headers = { 'content-type': 'application/json', 'x-agent-panel-token': session.token }
|
|
289
|
+
/**
|
|
290
|
+
* The token belongs to one start of the app: `node --watch` restarts it at every change of a file, and a page left open across that restart holds a token the new server
|
|
291
|
+
* does not know. Ask for the session again; the caller tries once more with what it gets.
|
|
292
|
+
*/
|
|
293
|
+
const refreshSession = async (): Promise<void> => {
|
|
294
|
+
const reply = await fetch(`${PREFIX}/session`)
|
|
295
|
+
if (!reply.ok) return
|
|
296
|
+
session = (await reply.json()) as Session
|
|
297
|
+
headers['x-agent-panel-token'] = session.token
|
|
298
|
+
}
|
|
268
299
|
let following = false
|
|
269
|
-
|
|
300
|
+
const stopRun = (): void => {
|
|
270
301
|
// On the run's socket when there is one: a request would wait behind the browser's six connections per host.
|
|
271
302
|
if (activeSocket?.readyState === WebSocket.OPEN)
|
|
272
303
|
activeSocket.send(JSON.stringify({ t: 'stop' }))
|
|
273
304
|
else void fetch(`${PREFIX}/stop`, { method: 'POST', headers })
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
305
|
+
}
|
|
306
|
+
stop.addEventListener('click', stopRun)
|
|
307
|
+
// The door's "Agent is driving" bar has a Stop too (the same page): it ends this run.
|
|
308
|
+
window.addEventListener('acryl:stop-run', stopRun)
|
|
309
|
+
const REFUSED = Symbol('refused')
|
|
310
|
+
/** A run over the socket when the server upgrades, else over the stream, with one console warning saying why. `REFUSED` when the server does not know the token. */
|
|
311
|
+
const tryOpenRun = async (
|
|
312
|
+
action: { t: 'ask'; message: string; driver: string; page?: string } | { t: 'follow' },
|
|
313
|
+
): Promise<RunSource | { error: string } | undefined | typeof REFUSED> => {
|
|
279
314
|
if (!socketBroken && typeof WebSocket !== 'undefined') {
|
|
280
315
|
try {
|
|
281
|
-
const socket = await openSocket(session.token, action)
|
|
316
|
+
const { socket, events: frames } = await openSocket(session.token, action)
|
|
282
317
|
activeSocket = socket
|
|
283
318
|
socket.addEventListener('close', () => {
|
|
284
319
|
if (activeSocket === socket) activeSocket = undefined
|
|
285
320
|
})
|
|
286
|
-
return { events:
|
|
321
|
+
return { events: frames }
|
|
287
322
|
} catch (cause) {
|
|
323
|
+
if (cause instanceof Refused) return REFUSED
|
|
288
324
|
socketBroken = true
|
|
289
325
|
console.warn(
|
|
290
326
|
`agent-panel: the run could not open a WebSocket (${cause instanceof Error ? cause.message : String(cause)}); using server-sent events, which count against the browser's six connections per host`,
|
|
@@ -295,8 +331,9 @@ async function start(): Promise<void> {
|
|
|
295
331
|
const response = await fetch(`${PREFIX}/ask`, {
|
|
296
332
|
method: 'POST',
|
|
297
333
|
headers,
|
|
298
|
-
body: JSON.stringify({ message: action.message, driver: action.driver }),
|
|
334
|
+
body: JSON.stringify({ message: action.message, driver: action.driver, page: action.page }),
|
|
299
335
|
})
|
|
336
|
+
if (response.status === 403) return REFUSED
|
|
300
337
|
if (!response.ok)
|
|
301
338
|
return {
|
|
302
339
|
error:
|
|
@@ -306,10 +343,23 @@ async function start(): Promise<void> {
|
|
|
306
343
|
return { events: events(response) }
|
|
307
344
|
}
|
|
308
345
|
const latest = await fetch(`${PREFIX}/run`, { headers })
|
|
346
|
+
if (latest.status === 403) return REFUSED
|
|
309
347
|
return latest.ok && (latest.headers.get('content-type') ?? '').includes('text/event-stream')
|
|
310
348
|
? { events: events(latest) }
|
|
311
349
|
: undefined
|
|
312
350
|
}
|
|
351
|
+
/** `tryOpenRun`, and when the token was refused, a new session and one more try without the person seeing anything; a second refusal is shown. */
|
|
352
|
+
const openRun = async (
|
|
353
|
+
action: { t: 'ask'; message: string; driver: string; page?: string } | { t: 'follow' },
|
|
354
|
+
): Promise<RunSource | { error: string } | undefined> => {
|
|
355
|
+
const first = await tryOpenRun(action)
|
|
356
|
+
if (first !== REFUSED) return first
|
|
357
|
+
await refreshSession()
|
|
358
|
+
const second = await tryOpenRun(action)
|
|
359
|
+
return second === REFUSED
|
|
360
|
+
? { error: 'the panel was not accepted (reload the page and try again)' }
|
|
361
|
+
: second
|
|
362
|
+
}
|
|
313
363
|
/**
|
|
314
364
|
* Show one run, as its events come, until it ends. `own` is true for a run this page just started (its question is on screen); a run found on load is shown from its question, and
|
|
315
365
|
* a run that had already ended before the page loaded is only shown, never reloads the page.
|
|
@@ -400,7 +450,7 @@ async function start(): Promise<void> {
|
|
|
400
450
|
input.value = ''
|
|
401
451
|
line(message, 'me')
|
|
402
452
|
try {
|
|
403
|
-
const run = await openRun({ t: 'ask', message, driver: driver.value })
|
|
453
|
+
const run = await openRun({ t: 'ask', message, driver: driver.value, ...doorPage() })
|
|
404
454
|
if (run === undefined) return
|
|
405
455
|
if ('error' in run) {
|
|
406
456
|
line(run.error, 'err')
|