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.
Files changed (39) hide show
  1. package/dist/index.d.ts +15 -1
  2. package/dist/index.js +13 -7
  3. package/dist/index.js.map +1 -1
  4. package/docs/framework/api/summary.md +1 -1
  5. package/docs/framework/guides/production.md +20 -0
  6. package/docs/framework/workshop/README.md +177 -274
  7. package/docs/framework/workshop/facilitator.md +107 -84
  8. package/lib/{audit-Dw-siYTp.d.ts → audit-BFpULBCj.d.ts} +15 -3
  9. package/lib/cordis/door.d.ts +3 -1
  10. package/lib/cordis/door.js +11 -1
  11. package/lib/cordis/state.d.ts +3 -2
  12. package/lib/cordis/state.js +370 -6
  13. package/lib/door/host.d.ts +1 -1
  14. package/lib/door/host.js +1 -1
  15. package/lib/door/page.d.ts +10 -0
  16. package/lib/door/page.js +29 -7
  17. package/lib/{snapshot-text-BfEbEOjA.js → snapshot-text-BwM6T2nG.js} +36 -4
  18. package/package.json +1 -1
  19. package/plugins/acryl-door/src/host/channel.ts +42 -4
  20. package/plugins/acryl-door/src/host/door.ts +5 -0
  21. package/plugins/acryl-door/src/host/page-routes.ts +1 -1
  22. package/plugins/acryl-door/src/host/page-socket.ts +1 -1
  23. package/plugins/acryl-door/src/page/channel.ts +8 -0
  24. package/plugins/acryl-door/src/page/driver/driver.ts +17 -3
  25. package/plugins/acryl-door/src/page/index.ts +6 -1
  26. package/plugins/acryl-door/src/page/indicator.ts +14 -6
  27. package/plugins/acryl-state/plugin.json +4 -3
  28. package/plugins/acryl-state/src/client-source.ts +244 -0
  29. package/plugins/acryl-state/src/service.ts +33 -6
  30. package/plugins/acryl-state/src/socket.ts +96 -0
  31. package/plugins/agent-panel/client/main.ts +105 -55
  32. package/plugins/agent-panel/index.ts +42 -5
  33. package/plugins/instance/index.ts +6 -3
  34. package/templates/agent/AGENTS.md +3 -1
  35. package/templates/agent/src/routes.ts +38 -5
  36. package/templates/agent/test/app.test.ts +38 -2
  37. package/templates/chat/src/main.ts +1 -6
  38. package/templates/nodejs/src/main.ts +1 -6
  39. 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 the Host just sent somewhere (it comes back as a new page, `prefer`), else the
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
- const rank = (connection: Connection): number => (connection.suspect ? 0 : 4) + (connection.windowId === this.preferred ? 2 : 0) + (connection.focused ? 1 : 0)
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 while
3
- * a call runs and after the person stopped the agent. It is marked as driver UI (so snapshots skip it) and as off limits to the agent (so
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) driver.kill()
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', () => { driver.kill() })
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 and server-sent event routes on the router. Vendored from ACRYL.",
3
+ "description": "Observable cells and derived cells, shared state with JSON, server-sent event and WebSocket routes on the router, and a browser module (watch) that follows keys over one socket. Vendored from ACRYL.",
4
4
  "requires": ["acryl-router"],
5
5
  "inject": ["router"],
6
6
  "provides": ["state"],
7
7
  "dependencies": {
8
- "@deepseek-ai/cordis": "4.0.4"
8
+ "@deepseek-ai/cordis": "4.0.4",
9
+ "ws": "8.21.3"
9
10
  },
10
- "mount": "root.plugin(state); ctx.state.cell('todos.count', 0), then count.update((n) => n + 1)",
11
+ "mount": "root.plugin(state); ctx.state.cell('todos.count', 0), then count.update((n) => n + 1); a page follows it with import { watch } from '/api/state/client.js'; watch('todos.count', (n) => { ... })",
11
12
  "omit": ["src/react.ts", "**/*.test.*", "**/*.case.ts"],
12
13
  "entries": {
13
14
  "": "src/index.ts",
@@ -0,0 +1,244 @@
1
+ /**
2
+ * The browser module served at `/api/state/client.js`, kept as text so it travels with the plugin wherever the plugin goes (copied into an app as source, or bundled): there is
3
+ * no file to find at run time and no compiler needed to serve it. `String.raw` keeps every backslash as written, so what is below is the module exactly as the browser gets it.
4
+ * The code has no imports and uses no backtick or dollar-brace of its own.
5
+ *
6
+ * import { watch } from '/api/state/client.js'
7
+ * const stop = watch('tasks.count', (value) => { badge.textContent = String(value) })
8
+ *
9
+ * One WebSocket for the whole page carries every key; a WebSocket is not one of the browser's six connections per host, so a page that watches ten keys, in as many windows,
10
+ * leaves every one of them free for requests. Where the WebSocket cannot open (no `ws` package on the server, a proxy that does not upgrade), each key is followed by its
11
+ * event stream instead, with one console warning.
12
+ */
13
+ export const CLIENT_SOURCE = String.raw`// acryl-state: watch(key, onValue, onError?) follows a state key over one WebSocket for the page.
14
+ const HEARTBEAT_SILENCE_MS = 45000
15
+ const RETRY_MIN_MS = 250
16
+ const RETRY_MAX_MS = 10000
17
+
18
+ const here = import.meta.url
19
+ const watchers = new Map()
20
+ const latest = new Map()
21
+ const streams = new Map()
22
+ const complained = new Set()
23
+ let socket
24
+ let timer
25
+ let watchdog
26
+ let attempts = 0
27
+ let opened = false
28
+ let following = 'socket'
29
+ let suspended = false
30
+ let warnedStream = false
31
+
32
+ const socketUrl = () => {
33
+ const url = new URL('ws', here)
34
+ url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'
35
+ return url.href
36
+ }
37
+ const streamUrl = (key) => new URL(encodeURIComponent(key) + '/events?json=1', here).href
38
+
39
+ const deliver = (key, text) => {
40
+ if (latest.get(key) === text) return
41
+ latest.set(key, text)
42
+ let value
43
+ try {
44
+ value = JSON.parse(text)
45
+ } catch {
46
+ return
47
+ }
48
+ for (const watcher of [...(watchers.get(key) ?? [])]) {
49
+ try {
50
+ watcher.onValue(value)
51
+ } catch (cause) {
52
+ console.error('acryl-state: a watcher of ' + key + ' threw', cause)
53
+ }
54
+ }
55
+ }
56
+
57
+ const fail = (key, message) => {
58
+ const set = watchers.get(key)
59
+ if (set === undefined) return
60
+ let told = false
61
+ for (const watcher of [...set]) {
62
+ if (watcher.onError === undefined) continue
63
+ told = true
64
+ try {
65
+ watcher.onError(new Error(message))
66
+ } catch (cause) {
67
+ console.error('acryl-state: an error handler of ' + key + ' threw', cause)
68
+ }
69
+ }
70
+ if (!told && !complained.has(key)) {
71
+ complained.add(key)
72
+ console.warn('acryl-state: ' + key + ': ' + message)
73
+ }
74
+ }
75
+
76
+ const send = (frame) => {
77
+ if (socket !== undefined && socket.readyState === 1) socket.send(JSON.stringify(frame))
78
+ }
79
+
80
+ const arm = () => {
81
+ clearTimeout(watchdog)
82
+ watchdog = setTimeout(() => lost(socket), HEARTBEAT_SILENCE_MS)
83
+ }
84
+
85
+ const closeSocket = () => {
86
+ clearTimeout(watchdog)
87
+ const current = socket
88
+ socket = undefined
89
+ if (current === undefined) return
90
+ current.onopen = current.onmessage = current.onerror = current.onclose = null
91
+ try {
92
+ current.close(1000, 'page left')
93
+ } catch {
94
+ // Already closed.
95
+ }
96
+ }
97
+
98
+ const closeStreams = () => {
99
+ for (const source of streams.values()) source.close()
100
+ streams.clear()
101
+ }
102
+
103
+ const openStream = (key) => {
104
+ if (streams.has(key) || typeof EventSource === 'undefined') return
105
+ const source = new EventSource(streamUrl(key))
106
+ streams.set(key, source)
107
+ source.addEventListener('value', (event) => deliver(key, event.data))
108
+ source.onerror = () => {
109
+ // A network error is retried by the browser itself; a refusal (the key does not exist) ends the stream for good.
110
+ if (source.readyState === 2) {
111
+ streams.delete(key)
112
+ fail(key, 'the event stream was refused (no such state?)')
113
+ }
114
+ }
115
+ }
116
+
117
+ const fallBack = () => {
118
+ following = 'stream'
119
+ if (!warnedStream) {
120
+ warnedStream = true
121
+ console.warn('acryl-state: the WebSocket did not open, so each key is followed by its own event stream (each holds one of the browser\'s six connections to this host)')
122
+ }
123
+ for (const key of watchers.keys()) openStream(key)
124
+ }
125
+
126
+ // A socket that closes, errors or goes quiet: before it ever opened this is a server without WebSockets, so the streams take over; after, the server went away and the page tries again.
127
+ const lost = (which) => {
128
+ if (which !== socket) return
129
+ closeSocket()
130
+ if (watchers.size === 0 || suspended) return
131
+ if (!opened) {
132
+ fallBack()
133
+ return
134
+ }
135
+ const delay = Math.min(RETRY_MIN_MS * 2 ** attempts, RETRY_MAX_MS)
136
+ attempts += 1
137
+ clearTimeout(timer)
138
+ timer = setTimeout(connect, delay)
139
+ }
140
+
141
+ const connect = () => {
142
+ clearTimeout(timer)
143
+ if (socket !== undefined || watchers.size === 0 || suspended || following !== 'socket') return
144
+ let made
145
+ try {
146
+ made = new WebSocket(socketUrl())
147
+ } catch {
148
+ fallBack()
149
+ return
150
+ }
151
+ socket = made
152
+ made.onopen = () => {
153
+ opened = true
154
+ attempts = 0
155
+ arm()
156
+ for (const key of watchers.keys()) send({ t: 'sub', key })
157
+ }
158
+ made.onmessage = (event) => {
159
+ if (made !== socket) return
160
+ arm()
161
+ let frame
162
+ try {
163
+ frame = JSON.parse(event.data)
164
+ } catch {
165
+ return
166
+ }
167
+ if (frame === null || typeof frame !== 'object' || typeof frame.key !== 'string') return
168
+ if (typeof frame.error === 'string') fail(frame.key, frame.error)
169
+ else deliver(frame.key, JSON.stringify(frame.value))
170
+ }
171
+ made.onerror = () => lost(made)
172
+ made.onclose = () => lost(made)
173
+ }
174
+
175
+ const start = () => {
176
+ if (watchers.size === 0) return
177
+ if (following === 'stream') for (const key of watchers.keys()) openStream(key)
178
+ else connect()
179
+ }
180
+
181
+ if (typeof addEventListener === 'function') {
182
+ // A page in the back/forward cache keeps its connections open for as long as it is kept; let them go when the page is hidden for good, and open them again when it comes back.
183
+ addEventListener('pagehide', () => {
184
+ suspended = true
185
+ clearTimeout(timer)
186
+ closeSocket()
187
+ closeStreams()
188
+ })
189
+ addEventListener('pageshow', () => {
190
+ if (!suspended) return
191
+ suspended = false
192
+ start()
193
+ })
194
+ }
195
+
196
+ // Follow the state key: onValue gets the value now and after every change, as the JSON value the server holds (a string stays a string). onError, when given, is told of a key that
197
+ // does not exist; without it the page console gets one warning. Returns the function that stops watching.
198
+ export function watch(key, onValue, onError) {
199
+ if (typeof key !== 'string' || key === '') throw new TypeError('watch(key, onValue): the key is a state key like "tasks.count"')
200
+ if (typeof onValue !== 'function') throw new TypeError('watch(key, onValue): onValue is a function')
201
+ const watcher = { onValue, onError }
202
+ let set = watchers.get(key)
203
+ const first = set === undefined
204
+ if (first) {
205
+ set = new Set()
206
+ watchers.set(key, set)
207
+ }
208
+ set.add(watcher)
209
+ complained.delete(key)
210
+ if (!first && latest.has(key)) {
211
+ const text = latest.get(key)
212
+ queueMicrotask(() => {
213
+ if (!set.has(watcher)) return
214
+ try {
215
+ onValue(JSON.parse(text))
216
+ } catch (cause) {
217
+ console.error('acryl-state: a watcher of ' + key + ' threw', cause)
218
+ }
219
+ })
220
+ }
221
+ if (first && !suspended) {
222
+ if (following === 'stream') openStream(key)
223
+ else if (socket === undefined) connect()
224
+ else send({ t: 'sub', key })
225
+ }
226
+ let stopped = false
227
+ return () => {
228
+ if (stopped) return
229
+ stopped = true
230
+ set.delete(watcher)
231
+ if (set.size > 0) return
232
+ watchers.delete(key)
233
+ latest.delete(key)
234
+ complained.delete(key)
235
+ streams.get(key)?.close()
236
+ streams.delete(key)
237
+ send({ t: 'unsub', key })
238
+ if (watchers.size === 0) {
239
+ clearTimeout(timer)
240
+ closeSocket()
241
+ }
242
+ }
243
+ }
244
+ `
@@ -6,10 +6,12 @@
6
6
  */
7
7
 
8
8
  import { Context, Service } from '@deepseek-ai/cordis'
9
- import { Acryl, type Context as RequestContext } from 'acryl-router'
10
- import { streamSSE } from 'acryl-router/helpers'
9
+ import { Acryl, type Context as RequestContext, type MiddlewareHandler } from 'acryl-router'
10
+ import { streamSSE, type UpgradeWebSocket } from 'acryl-router/helpers'
11
11
  import { loopbackOnly, methodNotAllowed } from 'acryl-router/middleware'
12
12
  import { createCell, createDerived, type Cell, type ReadonlyCell } from './cell.ts'
13
+ import { CLIENT_SOURCE } from './client-source.ts'
14
+ import { refuseStranger, stateSocket } from './socket.ts'
13
15
 
14
16
  declare module '@deepseek-ai/cordis' {
15
17
  interface Context {
@@ -24,6 +26,9 @@ export const name = 'acryl-state'
24
26
 
25
27
  const KEY_PATTERN = /^[a-z][a-z0-9-]*(\.[a-z][a-z0-9-]*)*$/
26
28
 
29
+ /** Names under `/api/state` that are routes, not keys: a cell cannot be called by one. */
30
+ const RESERVED_KEYS = new Set(['ws', 'client.js'])
31
+
27
32
  export class State extends Service {
28
33
  private readonly cells = new Map<string, ReadonlyCell<unknown>>()
29
34
 
@@ -33,6 +38,7 @@ export class State extends Service {
33
38
 
34
39
  private claim(key: string): void {
35
40
  if (!KEY_PATTERN.test(key)) throw new TypeError(`acryl-state: key "${key}" must be dotted lowercase words, like "market.sources"`)
41
+ if (RESERVED_KEYS.has(key)) throw new TypeError(`acryl-state: "${key}" is the name of a route under /api/state, choose another key`)
36
42
  if (this.cells.has(key)) throw new Error(`acryl-state: key "${key}" is already in use`)
37
43
  }
38
44
 
@@ -73,9 +79,10 @@ export class State extends Service {
73
79
 
74
80
  /**
75
81
  * The current value of `key` as a server-sent event stream: the value now, then one event per change, until the client cancels or the router
76
- * is disposed (both abort the stream). A comment line goes out every 15 seconds so proxies and browsers keep an idle stream open.
82
+ * is disposed (both abort the stream). A comment line goes out every 15 seconds so proxies and browsers keep an idle stream open. A string value travels as it is; with `json`
83
+ * (the `?json=1` of the browser client) every value is JSON, so `"12"` and `12` can be told apart.
77
84
  */
78
- stream(key: string, c: RequestContext): Response | undefined {
85
+ stream(key: string, c: RequestContext, json = false): Response | undefined {
79
86
  const cell = this.find(key)
80
87
  if (cell === undefined) return undefined
81
88
  return streamSSE(c, async stream => {
@@ -83,7 +90,7 @@ export class State extends Service {
83
90
  // Writes are asynchronous; chaining them keeps events in the order the cell changed.
84
91
  let queue: Promise<unknown> = Promise.resolve()
85
92
  const send = (value: unknown): void => {
86
- queue = queue.then(() => stream.writeSSE({ event: 'value', data: typeof value === 'string' ? value : JSON.stringify(value) })).catch(() => {})
93
+ queue = queue.then(() => stream.writeSSE({ event: 'value', data: typeof value === 'string' && !json ? value : JSON.stringify(value) })).catch(() => {})
87
94
  }
88
95
  send(cell.get())
89
96
  const unsubscribe = cell.subscribe(send)
@@ -105,14 +112,34 @@ export function apply(ctx: Context): void {
105
112
  // app; the terminal calls the router in process and is the host's own code.
106
113
  ctx.inject(['router'], inner => {
107
114
  const api = new Acryl()
115
+ // The WebSocket form is mounted when a server that upgrades appears (the server row comes after this one in a blend); until then, and without the `ws` package, `/ws` answers 404 and the
116
+ // browser client follows the event streams. The route is registered before `/:key`, which would otherwise take `ws` for a key.
117
+ let socket: MiddlewareHandler | undefined
108
118
  api.use(loopbackOnly({ inProcess: 'allow' }), methodNotAllowed({ app: api }))
109
119
  .get('/', c => c.json({ keys: state.keys() }))
120
+ .get('/client.js', c => c.body(CLIENT_SOURCE, 200, { 'content-type': 'text/javascript; charset=utf-8', 'cache-control': 'no-cache' }))
121
+ .get('/ws', async (c, next) => {
122
+ const upgrade = socket
123
+ if (upgrade === undefined) return c.json({ error: 'this server does not upgrade to a WebSocket' }, 404)
124
+ const refusal = refuseStranger(c)
125
+ if (refusal !== undefined) return refusal
126
+ return await upgrade(c, next)
127
+ })
110
128
  .get('/:key', c => {
111
129
  const cell = state.find(c.req.param('key'))
112
130
  return cell === undefined ? c.json({ error: 'no such state' }, 404) : c.json({ key: cell.key, value: cell.get() })
113
131
  })
114
- .get('/:key/events', c => state.stream(c.req.param('key'), c) ?? c.json({ error: 'no such state' }, 404))
132
+ .get('/:key/events', c => state.stream(c.req.param('key'), c, c.req.query('json') === '1') ?? c.json({ error: 'no such state' }, 404))
115
133
  inner.router.route('/api/state', api)
134
+ inner.inject(['nodeServer'], withServer => {
135
+ try {
136
+ const upgrade = (withServer as unknown as { nodeServer: { upgrades(): UpgradeWebSocket } }).nodeServer.upgrades()
137
+ socket = stateSocket(state, { upgrade })
138
+ withServer.effect(() => () => { socket = undefined }, 'acryl-state: the WebSocket route')
139
+ } catch (cause) {
140
+ ctx.logger.warn(`acryl-state: state is followed by event streams only (${cause instanceof Error ? cause.message : String(cause)})`)
141
+ }
142
+ })
116
143
  // Over HTTP only where an adapter exists (Web and Desktop); the terminal needs none.
117
144
  inner.inject(['routerHttp'], http => { http.routerHttp.mount('/api/state') })
118
145
  })