acrylblends 1.0.10 → 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/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/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/{snapshot-text-BfEbEOjA.js → snapshot-text-BwM6T2nG.js} +36 -4
- package/package.json +1 -1
- package/plugins/acryl-door/src/host/channel.ts +42 -4
- 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/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
|
@@ -17,6 +17,8 @@ import {
|
|
|
17
17
|
/** The slice of a page's stream the channel needs, so tests can supply their own. */
|
|
18
18
|
export interface PageStream {
|
|
19
19
|
send(data: string): void
|
|
20
|
+
/** Tell the page something that is not a call (`run`: `1` while a run asked for from its panel goes, `0` when it ends). A stream that cannot (a test's) leaves it out. */
|
|
21
|
+
notify?(event: string, data: string): void
|
|
20
22
|
close(): void
|
|
21
23
|
}
|
|
22
24
|
|
|
@@ -65,14 +67,16 @@ export interface ChannelOptions {
|
|
|
65
67
|
}
|
|
66
68
|
|
|
67
69
|
/**
|
|
68
|
-
* The pages and who drives which. One page answers a call, chosen the same way every time: the page
|
|
69
|
-
* one in front (it reports `focused`), else the most recently active; a page that did not acknowledge a call in time goes to the end of the line. An explicit desktop `window` picks its page.
|
|
70
|
+
* The pages and who drives which. One page answers a call, chosen the same way every time: the page whose panel asked for the run that is making the call (`driveFor`), else the page the
|
|
71
|
+
* Host just sent somewhere (it comes back as a new page, `prefer`), else the one in front (it reports `focused`), else the most recently active; a page that did not acknowledge a call in time goes to the end of the line. An explicit desktop `window` picks its page.
|
|
70
72
|
*/
|
|
71
73
|
export class UiChannel {
|
|
72
74
|
private readonly ackMs: number
|
|
73
75
|
private readonly attachWaitMs: number
|
|
74
76
|
/** The page the Host sent to a new place and that came back: it keeps being driven until the person focuses another page. */
|
|
75
77
|
private preferred: string | undefined
|
|
78
|
+
/** The pages whose panels have a run going, the latest last: a call with no `window` goes to the latest of them that is still connected. */
|
|
79
|
+
private readonly askers: Array<{ windowId: string }> = []
|
|
76
80
|
|
|
77
81
|
constructor(options: ChannelOptions = {}) {
|
|
78
82
|
this.ackMs = options.ackMs ?? DEFAULT_ACK_MS
|
|
@@ -97,6 +101,8 @@ export class UiChannel {
|
|
|
97
101
|
// A page that did not answer stays last in line when its stream reconnects: a tab that cannot answer reconnects just as readily, and would otherwise be first again.
|
|
98
102
|
const connection: Connection = { stream, windowId, shellWindow, focused, lastActive: ++this.clock, suspect: previous?.suspect === true }
|
|
99
103
|
this.connections.set(windowId, connection)
|
|
104
|
+
// A page that reloaded or came back during its own run is told the run is still going.
|
|
105
|
+
if (this.askers.some(asker => asker.windowId === windowId)) this.tell(connection, '1')
|
|
100
106
|
for (const waiter of [...this.attachWaiters]) waiter(windowId)
|
|
101
107
|
return () => { if (this.connections.get(windowId) === connection) this.drop(connection, false) }
|
|
102
108
|
}
|
|
@@ -137,12 +143,42 @@ export class UiChannel {
|
|
|
137
143
|
return this.ordered().map(({ windowId, focused, shellWindow }) => ({ windowId, focused, ...(shellWindow === undefined ? {} : { shellWindow }) }))
|
|
138
144
|
}
|
|
139
145
|
|
|
146
|
+
/**
|
|
147
|
+
* A run asked for from this page's panel is going: its calls (which name no window) are for this page, whatever else is open and whichever was driven last, until the run ends.
|
|
148
|
+
* An explicit desktop `window` still picks its own page.
|
|
149
|
+
* @returns the function to call when the run ends.
|
|
150
|
+
*/
|
|
151
|
+
driveFor(windowId: string): () => void {
|
|
152
|
+
const asker = { windowId }
|
|
153
|
+
this.askers.push(asker)
|
|
154
|
+
this.tellPage(windowId, '1')
|
|
155
|
+
return () => {
|
|
156
|
+
const at = this.askers.indexOf(asker)
|
|
157
|
+
if (at === -1) return
|
|
158
|
+
this.askers.splice(at, 1)
|
|
159
|
+
// The page keeps the bar if another run of its own is still going.
|
|
160
|
+
if (!this.askers.some(other => other.windowId === asker.windowId)) this.tellPage(asker.windowId, '0')
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
private tellPage(windowId: string, running: '0' | '1'): void {
|
|
165
|
+
const connection = this.connections.get(windowId)
|
|
166
|
+
if (connection !== undefined) this.tell(connection, running)
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
private tell(connection: Connection, running: '0' | '1'): void {
|
|
170
|
+
try { connection.stream.notify?.('run', running) } catch { /* a stream that is going away says so by closing */ }
|
|
171
|
+
}
|
|
172
|
+
|
|
140
173
|
/** Drive this page from now on (it is the one the Host just sent to a new place), until the person focuses another. */
|
|
141
174
|
prefer(windowId: string): void {
|
|
142
175
|
const connection = this.connections.get(windowId)
|
|
143
176
|
if (connection === undefined) return
|
|
144
177
|
this.preferred = windowId
|
|
145
178
|
connection.lastActive = ++this.clock
|
|
179
|
+
// A run's own page that reloaded or navigated comes back as a new page: the run goes on with that one.
|
|
180
|
+
for (const asker of this.askers) if (!this.connections.has(asker.windowId)) asker.windowId = windowId
|
|
181
|
+
if (this.askers.some(asker => asker.windowId === windowId)) this.tellPage(windowId, '1')
|
|
146
182
|
}
|
|
147
183
|
|
|
148
184
|
/**
|
|
@@ -263,9 +299,11 @@ export class UiChannel {
|
|
|
263
299
|
this.connections.clear()
|
|
264
300
|
}
|
|
265
301
|
|
|
266
|
-
/** The order pages are driven in: the one the Host sent somewhere, suspect ones last, the one in front, then the most recently active. */
|
|
302
|
+
/** The order pages are driven in: the one whose panel asked, the one the Host sent somewhere, suspect ones last, the one in front, then the most recently active. */
|
|
267
303
|
private ordered(): Connection[] {
|
|
268
|
-
|
|
304
|
+
// The latest asker that is still connected; a page that went away is skipped, and the run goes on with whatever is open.
|
|
305
|
+
const asker = [...this.askers].reverse().find(entry => this.connections.has(entry.windowId))?.windowId
|
|
306
|
+
const rank = (connection: Connection): number => (connection.suspect ? 0 : 4) + (connection.windowId === this.preferred ? 2 : 0) + (connection.focused ? 1 : 0) + (connection.windowId === asker && !connection.suspect ? 8 : 0)
|
|
269
307
|
return [...this.connections.values()].sort((a, b) => rank(b) - rank(a) || b.lastActive - a.lastActive)
|
|
270
308
|
}
|
|
271
309
|
|
|
@@ -90,6 +90,11 @@ export class Door {
|
|
|
90
90
|
this.deps = deps
|
|
91
91
|
}
|
|
92
92
|
|
|
93
|
+
/** A run asked for from the panel of this page is going: its verbs that name no window are for that page. @returns the function to call when the run ends. */
|
|
94
|
+
driveFor(windowId: string): () => void {
|
|
95
|
+
return this.deps.channel.driveFor(windowId)
|
|
96
|
+
}
|
|
97
|
+
|
|
93
98
|
/** Run one verb. @throws DoorError or UiControlError. */
|
|
94
99
|
async run(verb: string, args: unknown, signal: AbortSignal): Promise<VerbAnswer> {
|
|
95
100
|
const input = (typeof args === 'object' && args !== null && !Array.isArray(args) ? args : {}) as Record<string, unknown>
|
|
@@ -43,7 +43,7 @@ export function pageChannelRoutes(channel: UiChannel, options: PageRoutesOptions
|
|
|
43
43
|
const write = (event: string, data: string): void => {
|
|
44
44
|
stream.writeSSE({ event, data }).catch(end)
|
|
45
45
|
}
|
|
46
|
-
const detach = channel.attach(windowId, focused, { send: (data) => { write('call', data) }, close: end }, shellWindow)
|
|
46
|
+
const detach = channel.attach(windowId, focused, { send: (data) => { write('call', data) }, notify: write, close: end }, shellWindow)
|
|
47
47
|
const beat = setInterval(() => { write('ping', '') }, heartbeatMs)
|
|
48
48
|
try {
|
|
49
49
|
write('ready', '')
|
|
@@ -55,7 +55,7 @@ export function pageSocketRoutes(channel: UiChannel, options: PageSocketOptions)
|
|
|
55
55
|
ws.send(JSON.stringify(frame))
|
|
56
56
|
}
|
|
57
57
|
// A call that cannot be written fails at once (`disconnected`), and a socket that is gone is dropped by its close: the door never holds a call for it.
|
|
58
|
-
detach = channel.attach(windowId, focused, { send: data => { write({ e: 'call', d: data }) }, close: () => { ws.close(1000, 'replaced') } }, shellWindow)
|
|
58
|
+
detach = channel.attach(windowId, focused, { send: data => { write({ e: 'call', d: data }) }, notify: (event, data) => { write({ e: event, d: data }) }, close: () => { ws.close(1000, 'replaced') } }, shellWindow)
|
|
59
59
|
write({ e: 'ready' })
|
|
60
60
|
beat = setInterval(() => {
|
|
61
61
|
try { write({ e: 'ping' }) } catch { /* the close event follows */ }
|
|
@@ -41,6 +41,8 @@ export interface PageChannelOptions {
|
|
|
41
41
|
readonly post?: (url: string, body: string) => Promise<unknown>
|
|
42
42
|
readonly silenceMs?: number
|
|
43
43
|
readonly now?: () => number
|
|
44
|
+
/** The Host says a run asked for from this page's panel started (`true`) or ended (`false`); false too when the stream is lost, until the Host says again. */
|
|
45
|
+
readonly onRun?: (running: boolean) => void
|
|
44
46
|
}
|
|
45
47
|
|
|
46
48
|
export class PageChannel {
|
|
@@ -85,6 +87,10 @@ export class PageChannel {
|
|
|
85
87
|
heard()
|
|
86
88
|
})
|
|
87
89
|
source.addEventListener('ping', heard)
|
|
90
|
+
source.addEventListener('run', (event) => {
|
|
91
|
+
heard()
|
|
92
|
+
this.options.onRun?.(event.data === '1')
|
|
93
|
+
})
|
|
88
94
|
source.addEventListener('call', (event) => {
|
|
89
95
|
heard()
|
|
90
96
|
if (typeof event.data === 'string') this.answer(event.data)
|
|
@@ -158,6 +164,8 @@ export class PageChannel {
|
|
|
158
164
|
if (source !== undefined) {
|
|
159
165
|
source.onerror = null
|
|
160
166
|
source.close()
|
|
167
|
+
// Without the stream the page cannot hear the run end: the Host says again if it is still going, once the stream is back.
|
|
168
|
+
this.options.onRun?.(false)
|
|
161
169
|
}
|
|
162
170
|
}
|
|
163
171
|
|
|
@@ -19,6 +19,8 @@ export const LEAVE_DELAY_MS = 150
|
|
|
19
19
|
export interface DriverActivity {
|
|
20
20
|
/** True from the moment a call starts until it ends. */
|
|
21
21
|
readonly busy: boolean
|
|
22
|
+
/** True from the moment a run asked for from this page's panel starts until it ends, between the calls too. */
|
|
23
|
+
readonly running: boolean
|
|
22
24
|
/** What the agent is doing, for the indicator ("click", "type", ...). */
|
|
23
25
|
readonly doing: string
|
|
24
26
|
/** True after the user pressed the kill switch. */
|
|
@@ -53,7 +55,8 @@ export class UiDriver {
|
|
|
53
55
|
private disposed = false
|
|
54
56
|
private busyCount = 0
|
|
55
57
|
private doing = ''
|
|
56
|
-
private current: DriverActivity = { busy: false, doing: '', killed: false }
|
|
58
|
+
private current: DriverActivity = { busy: false, running: false, doing: '', killed: false }
|
|
59
|
+
private runningFlag = false
|
|
57
60
|
|
|
58
61
|
private readonly options: UiDriverOptions
|
|
59
62
|
|
|
@@ -82,6 +85,17 @@ export class UiDriver {
|
|
|
82
85
|
this.emit()
|
|
83
86
|
}
|
|
84
87
|
|
|
88
|
+
/**
|
|
89
|
+
* A run asked for from this page's panel starts (`true`) or ends (`false`). The indicator stays up for the whole run, not only while a call is in flight. A new run is a new
|
|
90
|
+
* question from the person, so it also allows the agent to drive again after an earlier Stop.
|
|
91
|
+
*/
|
|
92
|
+
setRunning(running: boolean): void {
|
|
93
|
+
if (running === this.runningFlag) return
|
|
94
|
+
this.runningFlag = running
|
|
95
|
+
if (running) this.killedFlag = false
|
|
96
|
+
this.emit()
|
|
97
|
+
}
|
|
98
|
+
|
|
85
99
|
/** The user allowed the agent to drive again. */
|
|
86
100
|
resume(): void {
|
|
87
101
|
this.killedFlag = false
|
|
@@ -165,8 +179,8 @@ export class UiDriver {
|
|
|
165
179
|
}
|
|
166
180
|
|
|
167
181
|
private emit(): void {
|
|
168
|
-
const next: DriverActivity = { busy: this.busyCount > 0, doing: this.doing, killed: this.killedFlag }
|
|
169
|
-
if (next.busy === this.current.busy && next.doing === this.current.doing && next.killed === this.current.killed) return
|
|
182
|
+
const next: DriverActivity = { busy: this.busyCount > 0, running: this.runningFlag, doing: this.doing, killed: this.killedFlag }
|
|
183
|
+
if (next.busy === this.current.busy && next.running === this.current.running && next.doing === this.current.doing && next.killed === this.current.killed) return
|
|
170
184
|
this.current = next
|
|
171
185
|
const activity = next
|
|
172
186
|
for (const listener of [...this.listeners]) listener(activity)
|
|
@@ -11,6 +11,9 @@ import { UserInputClock } from './user-input.ts'
|
|
|
11
11
|
|
|
12
12
|
export const DOOR_BASE = '/api/acryl-door'
|
|
13
13
|
|
|
14
|
+
/** The key on the page's `window` under which the door publishes `{ windowId }`: the agent panel in the same page sends it with a question, and the door drives that page for the run. */
|
|
15
|
+
export const PAGE_KEY = Symbol.for('acryl.door.page')
|
|
16
|
+
|
|
14
17
|
/** Start the door on `document`. @returns the disposer. */
|
|
15
18
|
export function startDoor(document: Document, base: string = DOOR_BASE): () => void {
|
|
16
19
|
const view = document.defaultView
|
|
@@ -18,6 +21,7 @@ export function startDoor(document: Document, base: string = DOOR_BASE): () => v
|
|
|
18
21
|
const clock = new UserInputClock()
|
|
19
22
|
const driver = new UiDriver({ document: () => document, msSinceUserInput: clock.msSince })
|
|
20
23
|
const windowId = typeof view.crypto?.randomUUID === 'function' ? view.crypto.randomUUID() : `w-${String(Date.now())}-${String(Math.random()).slice(2, 8)}`
|
|
24
|
+
Object.defineProperty(view, PAGE_KEY, { value: Object.freeze({ windowId }), configurable: true })
|
|
21
25
|
const focused = (): boolean => document.visibilityState === 'visible' && document.hasFocus()
|
|
22
26
|
const detachInput = clock.attach(document)
|
|
23
27
|
const removeIndicator = mountIndicator(driver, document)
|
|
@@ -30,7 +34,7 @@ export function startDoor(document: Document, base: string = DOOR_BASE): () => v
|
|
|
30
34
|
return 'auto'
|
|
31
35
|
}
|
|
32
36
|
})()
|
|
33
|
-
const channel = new PageChannel(driver, { windowId, focused, base, transport, ...(Number.isSafeInteger(shellWindow) && shellWindow > 0 ? { shellWindow } : {}) })
|
|
37
|
+
const channel = new PageChannel(driver, { windowId, focused, base, transport, onRun: running => { driver.setRunning(running) }, ...(Number.isSafeInteger(shellWindow) && shellWindow > 0 ? { shellWindow } : {}) })
|
|
34
38
|
const reportFocus = (): void => { channel.reportFocus(focused()) }
|
|
35
39
|
view.addEventListener('focus', reportFocus)
|
|
36
40
|
view.addEventListener('blur', reportFocus)
|
|
@@ -47,6 +51,7 @@ export function startDoor(document: Document, base: string = DOOR_BASE): () => v
|
|
|
47
51
|
view.removeEventListener('pagehide', leave)
|
|
48
52
|
view.removeEventListener('pageshow', back)
|
|
49
53
|
document.removeEventListener('visibilitychange', reportFocus)
|
|
54
|
+
Reflect.deleteProperty(view, PAGE_KEY)
|
|
50
55
|
channel.dispose()
|
|
51
56
|
removeIndicator()
|
|
52
57
|
detachInput()
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* "The agent is driving" bar with its kill switch, in plain DOM (the React one in `acryl-agent-control` shows the same states). Shown
|
|
3
|
-
* a call runs and after the person stopped the agent.
|
|
2
|
+
* "The agent is driving" bar with its kill switch, in plain DOM (the React one in `acryl-agent-control` shows the same states). Shown for the whole of a run asked for from
|
|
3
|
+
* this page's panel (between the calls too), while a call runs, and after the person stopped the agent. Stop kills the page's control and asks the panel to stop the run (the
|
|
4
|
+
* `acryl:stop-run` event on the window, which the panel listens for). It is marked as driver UI (so snapshots skip it) and as off limits to the agent (so
|
|
4
5
|
* it can never press its own Stop or Allow buttons). Escape stops the agent too, while it is working.
|
|
5
6
|
*/
|
|
6
7
|
|
|
@@ -8,6 +9,9 @@ import type { DriverActivity, UiDriver } from './driver/driver.ts'
|
|
|
8
9
|
|
|
9
10
|
const LABELS: Record<string, string> = { snapshot: 'looking at the page', click: 'clicking', type: 'typing', select: 'choosing an option', press: 'pressing a key', scroll: 'scrolling', wait: 'waiting' }
|
|
10
11
|
|
|
12
|
+
/** Dispatched on the page's window by the Stop button: the agent panel in the same page cancels its run. */
|
|
13
|
+
export const STOP_RUN_EVENT = 'acryl:stop-run'
|
|
14
|
+
|
|
11
15
|
const STYLE_ID = 'acryl-door-indicator-style'
|
|
12
16
|
const STYLES = `
|
|
13
17
|
.acrylDoorBar { position: fixed; z-index: 2147483647; left: 50%; bottom: 16px; transform: translateX(-50%); display: flex; align-items: center; gap: 10px;
|
|
@@ -32,13 +36,17 @@ export function mountIndicator(driver: UiDriver, document: Document): () => void
|
|
|
32
36
|
bar.setAttribute('role', 'status')
|
|
33
37
|
bar.setAttribute('aria-live', 'polite')
|
|
34
38
|
|
|
39
|
+
const stopAgent = (): void => {
|
|
40
|
+
driver.kill()
|
|
41
|
+
document.defaultView?.dispatchEvent(new CustomEvent(STOP_RUN_EVENT))
|
|
42
|
+
}
|
|
35
43
|
const onKey = (event: KeyboardEvent): void => {
|
|
36
|
-
if (event.key === 'Escape' && event.isTrusted && driver.activity.busy
|
|
44
|
+
if (event.key === 'Escape' && event.isTrusted && (driver.activity.busy || driver.activity.running)) stopAgent()
|
|
37
45
|
}
|
|
38
46
|
document.addEventListener('keydown', onKey, true)
|
|
39
47
|
|
|
40
48
|
const render = (activity: DriverActivity): void => {
|
|
41
|
-
if (!activity.busy && !activity.killed) { bar.remove(); return }
|
|
49
|
+
if (!activity.busy && !activity.running && !activity.killed) { bar.remove(); return }
|
|
42
50
|
const button = document.createElement('button')
|
|
43
51
|
button.type = 'button'
|
|
44
52
|
const text = document.createElement('span')
|
|
@@ -53,9 +61,9 @@ export function mountIndicator(driver: UiDriver, document: Document): () => void
|
|
|
53
61
|
const dot = document.createElement('span')
|
|
54
62
|
dot.className = 'acrylDoorDot'
|
|
55
63
|
dot.setAttribute('aria-hidden', 'true')
|
|
56
|
-
text.textContent = `Agent is driving: ${LABELS[activity.doing] ?? activity.doing}`
|
|
64
|
+
text.textContent = `Agent is driving: ${activity.busy ? (LABELS[activity.doing] ?? activity.doing) : 'working on your request'}`
|
|
57
65
|
button.textContent = 'Stop'
|
|
58
|
-
button.addEventListener('click',
|
|
66
|
+
button.addEventListener('click', stopAgent)
|
|
59
67
|
bar.replaceChildren(dot, text, button)
|
|
60
68
|
}
|
|
61
69
|
if (!bar.isConnected) document.body.append(bar)
|
|
@@ -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
|
})
|