acrylblends 0.9.2 → 0.11.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.js.map +1 -1
- package/docs/framework/cordis/node-server.md +25 -3
- package/lib/bun.d.ts +1 -1
- package/lib/cloudflare-workers.d.ts +1 -1
- package/lib/cordis/door.d.ts +2 -1
- package/lib/cordis/door.js +7 -4
- package/lib/cordis/inspect.js +1 -1
- package/lib/cordis/node-server.d.ts +14 -2
- package/lib/cordis/node-server.js +27 -3
- package/lib/cordis/router-http.d.ts +25 -2
- package/lib/cordis/router-http.js +54 -6
- package/lib/cordis/router.d.ts +1 -1
- package/lib/cordis/router.js +1 -1
- package/lib/cordis/state.js +1 -1
- package/lib/door/host.js +1 -1
- package/lib/{index-BvFh1Gfx.d.ts → index-C-SklY6u.d.ts} +3 -0
- package/lib/{index-BqVQkTnu.d.ts → index-D4nVR3wu.d.ts} +18 -3
- package/lib/node-server.d.ts +2 -38
- package/lib/node-server.js +2 -2
- package/lib/{snapshot-text-D3NspDyT.js → snapshot-text-yaWlezbd.js} +1 -1
- package/lib/{src-CwABFr6r.js → src-DNzw0Qsb.js} +3 -2
- package/lib/test-websocket-DyeS50Tr.d.ts +136 -0
- package/lib/{server-DvgMGtzo.js → test-websocket-ZXHNHQSU.js} +225 -3
- package/lib/ws.d.ts +1 -1
- package/lib/ws.js +3 -0
- package/package.json +6 -2
- package/plugins/acryl-door/src/host/door.ts +5 -3
- package/plugins/acryl-door/src/index.ts +1 -1
- package/plugins/acryl-node-server/plugin.json +1 -1
- package/plugins/acryl-node-server/src/adapter/websocket-types.ts +5 -1
- package/plugins/acryl-node-server/src/adapter/websocket.ts +33 -5
- package/plugins/acryl-node-server/src/index.ts +35 -1
- package/plugins/acryl-node-server/src/test-websocket.ts +110 -0
- package/plugins/acryl-node-server/src/upgrade-hub.ts +152 -0
- package/plugins/acryl-router/src/core/helper/websocket/index.ts +5 -0
- package/plugins/acryl-router/src/service.ts +18 -4
- package/plugins/acryl-router-http/plugin.json +2 -2
- package/plugins/acryl-router-http/src/index.ts +70 -6
- package/plugins/agent-panel/client/main.ts +70 -2
- package/plugins/agent-panel/drivers.ts +83 -0
- package/plugins/agent-panel/headless.ts +154 -0
- package/plugins/agent-panel/index.ts +42 -125
- package/plugins/agent-panel/plugin.json +1 -1
- package/plugins/driver-pty/client/terminal.ts +188 -0
- package/plugins/driver-pty/client/vendor/LICENSE +21 -0
- package/plugins/driver-pty/client/vendor/LICENSE-addon-fit +19 -0
- package/plugins/driver-pty/client/vendor/VERSION +4 -0
- package/plugins/driver-pty/client/vendor/addon-fit.d.mts +6 -0
- package/plugins/driver-pty/client/vendor/addon-fit.mjs +18 -0
- package/plugins/driver-pty/client/vendor/xterm-css.d.mts +2 -0
- package/plugins/driver-pty/client/vendor/xterm-css.mjs +2 -0
- package/plugins/driver-pty/client/vendor/xterm.d.mts +17 -0
- package/plugins/driver-pty/client/vendor/xterm.mjs +53 -0
- package/plugins/driver-pty/index.ts +249 -0
- package/plugins/driver-pty/plugin.json +13 -0
- package/plugins/driver-pty/spawn.ts +36 -0
- package/plugins/driver-pty/terminal.ts +84 -0
- package/shared/AGENTS.core.md +1 -1
- package/shared/DEFAULTS.md +1 -1
- package/lib/error-t4N2js2I.d.ts +0 -8
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An in-process WebSocket pair for tests (spec 047 T009): runs an upgrade route the way the adapters do, with no socket and no network. `testWebSocket(fetch, '/path', { headers })`
|
|
3
|
+
* answers either a refusal (`status`, no `socket`) or an accepted connection whose client side sends text frames, receives what the handler sent, and closes.
|
|
4
|
+
*
|
|
5
|
+
* const { socket } = await testWebSocket((request, env) => app.fetch(request, env), '/stream', { headers: { origin: 'http://127.0.0.1:3000' } })
|
|
6
|
+
* socket.send('hello'); expect(socket.received).toEqual(['echo hello'])
|
|
7
|
+
*/
|
|
8
|
+
import type { IncomingMessage } from 'node:http'
|
|
9
|
+
import { CONNECTION_SYMBOL_KEY, WAIT_FOR_WEBSOCKET_SYMBOL } from './adapter/websocket.ts'
|
|
10
|
+
import type { WebSocketData, WebSocketLike } from './adapter/websocket-types.ts'
|
|
11
|
+
|
|
12
|
+
type Listener = (...args: never[]) => void
|
|
13
|
+
|
|
14
|
+
/** The client side of an accepted test connection. */
|
|
15
|
+
export interface TestSocket {
|
|
16
|
+
/** Frames the handler sent, in order (text only). */
|
|
17
|
+
readonly received: string[]
|
|
18
|
+
/** The code and reason the handler closed with, when it did. */
|
|
19
|
+
readonly closed: { readonly code: number | undefined; readonly reason: string | undefined } | undefined
|
|
20
|
+
/** Send a text frame to the handler. */
|
|
21
|
+
send(text: string): void
|
|
22
|
+
/** Close from the client side. */
|
|
23
|
+
close(code?: number, reason?: string): void
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface TestWebSocketOptions {
|
|
27
|
+
readonly headers?: Record<string, string>
|
|
28
|
+
/** The socket's peer address and local port, as an adapter would pass them. */
|
|
29
|
+
readonly connection?: { readonly remoteAddress?: string; readonly localPort?: number }
|
|
30
|
+
/** The URL origin the request is made against; default `http://127.0.0.1`. */
|
|
31
|
+
readonly base?: string
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface TestWebSocketResult {
|
|
35
|
+
/** The refusal's status, or 101 when the upgrade was accepted. */
|
|
36
|
+
readonly status: number
|
|
37
|
+
readonly socket?: TestSocket
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** A fake `ws` socket: events go both ways without a network. */
|
|
41
|
+
class FakeSocket implements WebSocketLike {
|
|
42
|
+
protocol = ''
|
|
43
|
+
readyState: 0 | 1 | 2 | 3 = 1
|
|
44
|
+
bufferedAmount = 0
|
|
45
|
+
readonly sent: string[] = []
|
|
46
|
+
closedWith: { code: number | undefined; reason: string | undefined } | undefined
|
|
47
|
+
private readonly listeners = new Map<string, Listener[]>()
|
|
48
|
+
on(event: string, listener: Listener): this {
|
|
49
|
+
this.listeners.set(event, [...(this.listeners.get(event) ?? []), listener])
|
|
50
|
+
return this
|
|
51
|
+
}
|
|
52
|
+
off(event: string, listener: Listener): this {
|
|
53
|
+
this.listeners.set(event, (this.listeners.get(event) ?? []).filter(l => l !== listener))
|
|
54
|
+
return this
|
|
55
|
+
}
|
|
56
|
+
emit(event: string, ...args: unknown[]): void {
|
|
57
|
+
for (const listener of this.listeners.get(event) ?? []) (listener as (...a: unknown[]) => void)(...args)
|
|
58
|
+
}
|
|
59
|
+
send(data: WebSocketData | ArrayBufferView): void {
|
|
60
|
+
this.sent.push(typeof data === 'string' ? data : Buffer.from(data as Uint8Array).toString('utf8'))
|
|
61
|
+
}
|
|
62
|
+
close(code?: number, reason?: string): void {
|
|
63
|
+
if (this.readyState === 3) return
|
|
64
|
+
this.closedWith = { code, reason }
|
|
65
|
+
this.readyState = 3
|
|
66
|
+
this.emit('close', code ?? 1005, Buffer.from(reason ?? ''))
|
|
67
|
+
}
|
|
68
|
+
ping(): void {}
|
|
69
|
+
terminate(): void {
|
|
70
|
+
this.close(1006)
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export async function testWebSocket(
|
|
75
|
+
fetch: (request: Request, env: object) => Promise<Response>,
|
|
76
|
+
path: string,
|
|
77
|
+
options: TestWebSocketOptions = {},
|
|
78
|
+
): Promise<TestWebSocketResult> {
|
|
79
|
+
const fake = new FakeSocket()
|
|
80
|
+
let waiter: { connectionSymbol: symbol; resolve: (ws: WebSocketLike) => void } | undefined
|
|
81
|
+
const incoming = { headers: options.headers ?? {}, socket: { remoteAddress: options.connection?.remoteAddress, localPort: options.connection?.localPort } } as unknown as IncomingMessage
|
|
82
|
+
const env: Record<symbol | string, unknown> = {
|
|
83
|
+
incoming,
|
|
84
|
+
outgoing: undefined,
|
|
85
|
+
wss: {},
|
|
86
|
+
[WAIT_FOR_WEBSOCKET_SYMBOL]: (_request: IncomingMessage, connectionSymbol: symbol) =>
|
|
87
|
+
new Promise<WebSocketLike>(resolve => {
|
|
88
|
+
waiter = { connectionSymbol, resolve }
|
|
89
|
+
}),
|
|
90
|
+
}
|
|
91
|
+
const headers = new Headers({ upgrade: 'websocket', connection: 'Upgrade', ...options.headers })
|
|
92
|
+
const response = await fetch(new Request(new URL(path, options.base ?? 'http://127.0.0.1'), { headers }), env)
|
|
93
|
+
if (!waiter || waiter.connectionSymbol !== env[CONNECTION_SYMBOL_KEY]) return { status: response.status }
|
|
94
|
+
waiter.resolve(fake)
|
|
95
|
+
// The handler's open event and buffered messages run on the next turns.
|
|
96
|
+
await new Promise(resolve => setTimeout(resolve, 0))
|
|
97
|
+
const socket: TestSocket = {
|
|
98
|
+
received: fake.sent,
|
|
99
|
+
get closed() {
|
|
100
|
+
return fake.closedWith
|
|
101
|
+
},
|
|
102
|
+
send: text => fake.emit('message', text, false),
|
|
103
|
+
close: (code, reason) => {
|
|
104
|
+
if (fake.readyState === 3) return
|
|
105
|
+
fake.readyState = 3
|
|
106
|
+
fake.emit('close', code ?? 1005, Buffer.from(reason ?? ''))
|
|
107
|
+
},
|
|
108
|
+
}
|
|
109
|
+
return { status: 101, socket }
|
|
110
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WebSocket upgrades for the Cordis adapters (spec 047 T009): one hub per HTTP transport. It owns the `ws` server (text and size limits, no compression), runs the ported
|
|
3
|
+
* `setupWebSocket` on whatever emits `upgrade` events (a `node:http` server, or the Host web server's exact-path upgrade routes), pings every socket and ends the ones that stop
|
|
4
|
+
* answering, and gives each mounting plugin an `upgradeWebSocket` whose accepted sockets belong to that plugin: when it unloads they are closed with 1001 within the grace, and
|
|
5
|
+
* what is left is terminated. The handler sees `close` exactly once.
|
|
6
|
+
*/
|
|
7
|
+
import { EventEmitter } from 'node:events'
|
|
8
|
+
import { createRequire } from 'node:module'
|
|
9
|
+
import type { UpgradeWebSocket } from 'acryl-router/helpers'
|
|
10
|
+
import type { FetchCallback } from './adapter/types.ts'
|
|
11
|
+
import { type UpgradeSource, setupWebSocket, upgradeWebSocket as portedUpgradeWebSocket } from './adapter/websocket.ts'
|
|
12
|
+
import type { WebSocketLike, WebSocketServerLike } from './adapter/websocket-types.ts'
|
|
13
|
+
|
|
14
|
+
export interface UpgradeHubOptions {
|
|
15
|
+
/** The largest frame accepted, in bytes; a bigger one closes the socket with 1009. Default 1 MiB. */
|
|
16
|
+
maxPayload?: number
|
|
17
|
+
/** Milliseconds between pings; a peer that did not answer the previous one is terminated. Default 30 000; 0 turns the heartbeat off. */
|
|
18
|
+
heartbeat?: number
|
|
19
|
+
/** Milliseconds a closing socket gets to finish its close handshake before it is terminated. Default 2000. */
|
|
20
|
+
grace?: number
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
type WebSocketServerConstructor = new (options: {
|
|
24
|
+
noServer: true
|
|
25
|
+
maxPayload: number
|
|
26
|
+
perMessageDeflate: false
|
|
27
|
+
}) => WebSocketServerLike
|
|
28
|
+
|
|
29
|
+
/** The `ws` package, loaded when the first hub is made: an app that never upgrades does not need it. */
|
|
30
|
+
function loadWs(): WebSocketServerConstructor {
|
|
31
|
+
try {
|
|
32
|
+
return (createRequire(import.meta.url)('ws') as { WebSocketServer: WebSocketServerConstructor }).WebSocketServer
|
|
33
|
+
} catch (error) {
|
|
34
|
+
throw new Error(`WebSocket upgrades need the "ws" package (npm install ws): ${error instanceof Error ? error.message : String(error)}`)
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The part of the router a hub calls: run a request with connection facts and the adapter's bindings. */
|
|
39
|
+
export interface UpgradeRouter {
|
|
40
|
+
fetch(request: Request, connection: { remoteAddress?: string | undefined; localPort?: number | undefined }, bindings?: object): Promise<Response>
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** What a hub needs of a Cordis context: tying a disposer to the plugin that asked. */
|
|
44
|
+
export interface Owner {
|
|
45
|
+
effect(execute: () => () => unknown, label?: string): unknown
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export class UpgradeHub {
|
|
49
|
+
readonly server: WebSocketServerLike
|
|
50
|
+
private readonly all = new Set<WebSocketLike>()
|
|
51
|
+
private readonly alive = new WeakMap<WebSocketLike, boolean>()
|
|
52
|
+
private readonly grace: number
|
|
53
|
+
private timer: ReturnType<typeof setInterval> | undefined
|
|
54
|
+
private closed = false
|
|
55
|
+
|
|
56
|
+
constructor(options: UpgradeHubOptions = {}) {
|
|
57
|
+
const Server = loadWs()
|
|
58
|
+
this.server = new Server({ noServer: true, maxPayload: options.maxPayload ?? 1024 * 1024, perMessageDeflate: false })
|
|
59
|
+
this.grace = options.grace ?? 2000
|
|
60
|
+
this.server.on('connection', ws => {
|
|
61
|
+
this.all.add(ws)
|
|
62
|
+
this.alive.set(ws, true)
|
|
63
|
+
ws.on('pong', () => this.alive.set(ws, true))
|
|
64
|
+
ws.on('close', () => this.all.delete(ws))
|
|
65
|
+
})
|
|
66
|
+
const heartbeat = options.heartbeat ?? 30_000
|
|
67
|
+
if (heartbeat > 0) {
|
|
68
|
+
this.timer = setInterval(() => {
|
|
69
|
+
for (const ws of this.all) {
|
|
70
|
+
if (this.alive.get(ws) === false) {
|
|
71
|
+
ws.terminate()
|
|
72
|
+
continue
|
|
73
|
+
}
|
|
74
|
+
this.alive.set(ws, false)
|
|
75
|
+
ws.ping()
|
|
76
|
+
}
|
|
77
|
+
}, heartbeat)
|
|
78
|
+
this.timer.unref()
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Accept upgrades from `source` and answer them with `router`: the request runs through the router's middleware with the socket's peer address and local port as connection
|
|
84
|
+
* facts (so the same loopback, origin and host checks work), and the upgrade bindings travel in `c.env` for `upgradeWebSocket`.
|
|
85
|
+
*/
|
|
86
|
+
attach(source: UpgradeSource, router: UpgradeRouter): void {
|
|
87
|
+
const fetchCallback: FetchCallback = (request, env) => {
|
|
88
|
+
const { incoming } = env as { incoming: { socket: { remoteAddress?: string; localPort?: number } } }
|
|
89
|
+
return router.fetch(request, { remoteAddress: incoming.socket.remoteAddress, localPort: incoming.socket.localPort }, env)
|
|
90
|
+
}
|
|
91
|
+
setupWebSocket({ server: source, fetchCallback, wss: this.server })
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** How many sockets are open. */
|
|
95
|
+
get size(): number {
|
|
96
|
+
return this.all.size
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** `upgradeWebSocket` for the plugin `owner`: the sockets it accepts are closed with 1001 when that plugin unloads. */
|
|
100
|
+
upgradeWebSocketFor(owner: Owner): UpgradeWebSocket<WebSocketLike> {
|
|
101
|
+
const mine = new Set<WebSocketLike>()
|
|
102
|
+
owner.effect(() => () => this.closeAll(mine, 1001, 'going away'), 'upgrade sockets')
|
|
103
|
+
const track = (ws: WebSocketLike): void => {
|
|
104
|
+
mine.add(ws)
|
|
105
|
+
ws.on('close', () => mine.delete(ws))
|
|
106
|
+
}
|
|
107
|
+
const owned = (...args: unknown[]): unknown => {
|
|
108
|
+
const [first, second, third] = args as [unknown, Record<string, unknown> | undefined, Record<string, unknown> | undefined]
|
|
109
|
+
return typeof first === 'function'
|
|
110
|
+
? (portedUpgradeWebSocket as (...a: unknown[]) => unknown)(first, { ...second, track })
|
|
111
|
+
: (portedUpgradeWebSocket as (...a: unknown[]) => unknown)(first, second, { ...third, track })
|
|
112
|
+
}
|
|
113
|
+
return owned as UpgradeWebSocket<WebSocketLike>
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Close `sockets` with `code`, wait for their close handshakes up to the grace, terminate what is left. */
|
|
117
|
+
async closeAll(sockets: Iterable<WebSocketLike>, code: number, reason: string): Promise<void> {
|
|
118
|
+
const open = [...sockets].filter(ws => ws.readyState < 3)
|
|
119
|
+
await Promise.all(
|
|
120
|
+
open.map(
|
|
121
|
+
ws =>
|
|
122
|
+
new Promise<void>(resolve => {
|
|
123
|
+
const timer = setTimeout(() => {
|
|
124
|
+
ws.terminate()
|
|
125
|
+
resolve()
|
|
126
|
+
}, this.grace)
|
|
127
|
+
ws.on('close', () => {
|
|
128
|
+
clearTimeout(timer)
|
|
129
|
+
resolve()
|
|
130
|
+
})
|
|
131
|
+
if (ws.readyState === 1) ws.close(code, reason)
|
|
132
|
+
else if (ws.readyState === 3) {
|
|
133
|
+
clearTimeout(timer)
|
|
134
|
+
resolve()
|
|
135
|
+
}
|
|
136
|
+
}),
|
|
137
|
+
),
|
|
138
|
+
)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** End everything: every socket closes with 1001, the heartbeat stops, the `ws` server closes. */
|
|
142
|
+
async close(): Promise<void> {
|
|
143
|
+
if (this.closed) return
|
|
144
|
+
this.closed = true
|
|
145
|
+
if (this.timer !== undefined) clearInterval(this.timer)
|
|
146
|
+
await this.closeAll(this.all, 1001, 'going away')
|
|
147
|
+
this.server.close()
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** A stand-in for a server's `upgrade` events, for transports that dispatch upgrades themselves (the Host web server's exact-path routes). */
|
|
152
|
+
export class UpgradeEmitter extends EventEmitter {}
|
|
@@ -54,6 +54,8 @@ export interface WSContextInit<T = unknown> {
|
|
|
54
54
|
|
|
55
55
|
raw?: T
|
|
56
56
|
readyState: WSReadyState
|
|
57
|
+
/** Bytes queued to send and not yet written to the socket; a sender that cares about back-pressure reads it. */
|
|
58
|
+
bufferedAmount?: number
|
|
57
59
|
url?: string | URL | null
|
|
58
60
|
protocol?: string | null
|
|
59
61
|
}
|
|
@@ -84,6 +86,9 @@ export class WSContext<T = unknown> {
|
|
|
84
86
|
get readyState(): WSReadyState {
|
|
85
87
|
return this.#init.readyState
|
|
86
88
|
}
|
|
89
|
+
get bufferedAmount(): number {
|
|
90
|
+
return this.#init.bufferedAmount ?? 0
|
|
91
|
+
}
|
|
87
92
|
url: URL | null
|
|
88
93
|
protocol: string | null
|
|
89
94
|
close(code?: number, reason?: string) {
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
import { Context, Service } from '@deepseek-ai/cordis'
|
|
11
11
|
import z from '@deepseek-ai/schemastery'
|
|
12
12
|
import type { ConnectionInfo, RouterBindings } from './connection.ts'
|
|
13
|
+
import type { UpgradeWebSocket } from './core/helper/websocket/index.ts'
|
|
13
14
|
import { Acryl } from './core/app.ts'
|
|
14
15
|
import type { ExecutionContext } from './core/context.ts'
|
|
15
16
|
import { HTTPException } from './core/http-exception.ts'
|
|
@@ -40,8 +41,19 @@ export const Config: z<Partial<Config>, Config> = z.object({
|
|
|
40
41
|
* served over HTTP without depending on the adapter package.
|
|
41
42
|
*/
|
|
42
43
|
export interface RouterHttpService {
|
|
43
|
-
/**
|
|
44
|
-
|
|
44
|
+
/**
|
|
45
|
+
* Serve `prefix` and everything below it through the router; returns a remover. The mount also goes away when the calling plugin unloads.
|
|
46
|
+
* `options.upgrade` lists the exact paths below the prefix that may be upgraded to a WebSocket (each must be inside the prefix); no other path of the mount accepts an upgrade.
|
|
47
|
+
*/
|
|
48
|
+
mount(prefix: string, options?: { readonly upgrade?: readonly string[] }): () => void
|
|
49
|
+
/**
|
|
50
|
+
* `upgradeWebSocket` for the calling plugin: `app.get('/terminal', upgrade((c) => ({ onMessage(event, ws) { ... } })))` on a route the plugin mounted with `upgrade: ['/terminal']`.
|
|
51
|
+
* Checks go in ordinary middleware on the route; a handler that returns a `Response` refuses with that status before any handshake. The sockets it accepts belong to the calling
|
|
52
|
+
* plugin: when it unloads they are closed with 1001 and the handler sees `close` once. Needs the `ws` package.
|
|
53
|
+
*/
|
|
54
|
+
upgrades(): UpgradeWebSocket
|
|
55
|
+
/** How many WebSocket connections are open now. */
|
|
56
|
+
readonly sockets: number
|
|
45
57
|
}
|
|
46
58
|
|
|
47
59
|
declare module '@deepseek-ai/cordis' {
|
|
@@ -260,8 +272,9 @@ export class Router extends Service {
|
|
|
260
272
|
* Run a request in process. Never throws: failures become a 500, a disposed router answers 503. `connection` describes the transport
|
|
261
273
|
* (an HTTP adapter passes the socket's peer address and local port); an in-process caller omits it. Handlers read it with
|
|
262
274
|
* `connectionOf(c)`, abort with `c.req.raw.signal` and keep the request in flight with `c.executionCtx.waitUntil(task)`.
|
|
275
|
+
* `bindings` is an object an adapter hands to the handler as `c.env` (an upgrade request's socket hooks, for `upgradeWebSocket`); `connection` is added to it.
|
|
263
276
|
*/
|
|
264
|
-
fetch(request: Request, connection: ConnectionInfo = {}): Promise<Response> {
|
|
277
|
+
fetch(request: Request, connection: ConnectionInfo = {}, bindings?: object): Promise<Response> {
|
|
265
278
|
if (this.state.closing) return Promise.resolve(new Response('Service Unavailable', { status: 503 }))
|
|
266
279
|
const controller = new AbortController()
|
|
267
280
|
const forward = (): void => controller.abort()
|
|
@@ -273,7 +286,8 @@ export class Router extends Service {
|
|
|
273
286
|
passThroughOnException: () => {},
|
|
274
287
|
props: {},
|
|
275
288
|
}
|
|
276
|
-
|
|
289
|
+
// `bindings` is used as `c.env` itself (not copied): the upgrade helper records its handshake state on it, and the adapter reads that state back after the handler answered.
|
|
290
|
+
const env: RouterBindings = bindings === undefined ? { connection } : Object.assign(bindings, { connection })
|
|
277
291
|
let bodyDone: Promise<void> = Promise.resolve()
|
|
278
292
|
let endBody = (): void => {}
|
|
279
293
|
let resolveSettled!: (value: Promise<unknown>) => void
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "acryl-router-http",
|
|
3
|
-
"description": "
|
|
4
|
-
"requires": ["acryl-router"],
|
|
3
|
+
"description": "Mounts router prefixes on the ACRYL Host's web server, with exact-path WebSocket upgrades owned by the mounting plugin, and the serve() and request bridge. Mounting it as a plugin needs the Host web server, which a generated app does not have. Vendored from ACRYL.",
|
|
4
|
+
"requires": ["acryl-router", "acryl-node-server"],
|
|
5
5
|
"inject": ["router"],
|
|
6
6
|
"provides": [],
|
|
7
7
|
"dependencies": {
|
|
@@ -5,8 +5,11 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
8
|
+
import type { Duplex } from 'node:stream'
|
|
8
9
|
import type { RouterHttpService } from 'acryl-router'
|
|
10
|
+
import type { UpgradeWebSocket } from 'acryl-router/helpers'
|
|
9
11
|
import { Context, Service } from '@deepseek-ai/cordis'
|
|
12
|
+
import { UpgradeEmitter, UpgradeHub } from 'acryl-node-server'
|
|
10
13
|
import { serve } from './serve.ts'
|
|
11
14
|
|
|
12
15
|
/** Stable Cordis plugin name, equal to the package name and the Loader row id. */
|
|
@@ -25,6 +28,11 @@ export interface WebServerPort {
|
|
|
25
28
|
readonly path: string
|
|
26
29
|
readonly handler: (req: IncomingMessage, res: ServerResponse) => void | Promise<void>
|
|
27
30
|
}): () => void
|
|
31
|
+
/** One exact-path upgrade claim (`WebUpgradeRoute` of the web server); optional because an older web server has none. */
|
|
32
|
+
registerUpgrade?(route: {
|
|
33
|
+
readonly path: string
|
|
34
|
+
readonly handler: (req: IncomingMessage, socket: Duplex, head: Buffer) => void | Promise<void>
|
|
35
|
+
}): () => void
|
|
28
36
|
}
|
|
29
37
|
|
|
30
38
|
const PREFIX_PATTERN = /^\/[A-Za-z0-9._~\-/]*[A-Za-z0-9._~-]$/
|
|
@@ -32,9 +40,14 @@ const PREFIX_PATTERN = /^\/[A-Za-z0-9._~\-/]*[A-Za-z0-9._~-]$/
|
|
|
32
40
|
export class RouterHttp extends Service implements RouterHttpService {
|
|
33
41
|
private readonly router: Context['router']
|
|
34
42
|
private readonly web: WebServerPort
|
|
43
|
+
private hub: UpgradeHub | undefined
|
|
44
|
+
/** The adapter's own context: the hub lives as long as the adapter, whichever plugin asked for it first (`this.ctx` is the caller's). */
|
|
45
|
+
private readonly home: Context
|
|
46
|
+
private readonly upgradesIn = new UpgradeEmitter()
|
|
35
47
|
|
|
36
48
|
constructor(ctx: Context) {
|
|
37
49
|
super(ctx, 'routerHttp')
|
|
50
|
+
this.home = ctx
|
|
38
51
|
this.router = ctx.router
|
|
39
52
|
this.web = ctx.get('webServer') as WebServerPort
|
|
40
53
|
}
|
|
@@ -42,17 +55,68 @@ export class RouterHttp extends Service implements RouterHttpService {
|
|
|
42
55
|
/**
|
|
43
56
|
* Serve `prefix` and everything below it through the router. Returns a remover; the mount also goes away when the
|
|
44
57
|
* calling plugin unloads. A prefix another route already claims throws, as the webServer treats it as misconfiguration.
|
|
58
|
+
* `options.upgrade` names the exact paths that may be upgraded to a WebSocket: each must be inside the prefix, and each is one
|
|
59
|
+
* exact-path upgrade route of the web server, removed with the mount. No other path of the mount accepts an upgrade.
|
|
45
60
|
*/
|
|
46
|
-
mount(prefix: string): () => void {
|
|
61
|
+
mount(prefix: string, options: { readonly upgrade?: readonly string[] } = {}): () => void {
|
|
47
62
|
if (!PREFIX_PATTERN.test(prefix)) throw new TypeError(`acryl-router-http: "${prefix}" is not a mountable path prefix`)
|
|
48
|
-
const
|
|
49
|
-
|
|
50
|
-
path
|
|
51
|
-
|
|
52
|
-
}
|
|
63
|
+
const upgrade = options.upgrade ?? []
|
|
64
|
+
for (const path of upgrade) {
|
|
65
|
+
if (!PREFIX_PATTERN.test(path) || !(path === prefix || path.startsWith(`${prefix}/`)))
|
|
66
|
+
throw new TypeError(`acryl-router-http: the upgrade path "${path}" is not inside the prefix "${prefix}"`)
|
|
67
|
+
}
|
|
68
|
+
if (upgrade.length > 0 && this.web.registerUpgrade === undefined)
|
|
69
|
+
throw new TypeError('acryl-router-http: this web server cannot take WebSocket upgrades (it has no registerUpgrade)')
|
|
70
|
+
const removers: Array<() => void> = []
|
|
71
|
+
// Claim the upgrade paths first: a duplicate throws before anything is mounted, so a refused mount leaves nothing behind.
|
|
72
|
+
try {
|
|
73
|
+
if (upgrade.length > 0) this.ensureHub()
|
|
74
|
+
for (const path of upgrade) {
|
|
75
|
+
removers.push(
|
|
76
|
+
(this.web.registerUpgrade as NonNullable<WebServerPort['registerUpgrade']>)({
|
|
77
|
+
path,
|
|
78
|
+
handler: (req, socket, head) => { this.upgradesIn.emit('upgrade', req, socket, head) },
|
|
79
|
+
}),
|
|
80
|
+
)
|
|
81
|
+
}
|
|
82
|
+
removers.push(
|
|
83
|
+
this.web.register({
|
|
84
|
+
kind: 'prefix',
|
|
85
|
+
path: prefix,
|
|
86
|
+
handler: (req, res) => serve(this.router, req, res),
|
|
87
|
+
}),
|
|
88
|
+
)
|
|
89
|
+
} catch (error) {
|
|
90
|
+
for (const remove of removers) remove()
|
|
91
|
+
throw error
|
|
92
|
+
}
|
|
93
|
+
const remove = (): void => { for (const undo of removers.splice(0)) undo() }
|
|
53
94
|
this.ctx.effect(() => () => { remove() }, `acryl-router-http: mount ${prefix}`)
|
|
54
95
|
return remove
|
|
55
96
|
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* `upgradeWebSocket` for the calling plugin: the sockets its routes accept are closed with 1001 when it unloads. Needs the `ws` package.
|
|
100
|
+
* The route must be on a path the plugin mounted with `upgrade: [path]`.
|
|
101
|
+
*/
|
|
102
|
+
upgrades(): UpgradeWebSocket {
|
|
103
|
+
return this.ensureHub().upgradeWebSocketFor(this.ctx) as unknown as UpgradeWebSocket
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** How many WebSocket connections are open now. */
|
|
107
|
+
get sockets(): number {
|
|
108
|
+
return this.hub?.size ?? 0
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
private ensureHub(): UpgradeHub {
|
|
112
|
+
if (!this.hub) {
|
|
113
|
+
const hub = new UpgradeHub()
|
|
114
|
+
hub.attach(this.upgradesIn, this.router)
|
|
115
|
+
this.hub = hub
|
|
116
|
+
this.home.effect(() => () => hub.close(), 'acryl-router-http: WebSocket hub')
|
|
117
|
+
}
|
|
118
|
+
return this.hub
|
|
119
|
+
}
|
|
56
120
|
}
|
|
57
121
|
|
|
58
122
|
export function apply(ctx: Context): void {
|
|
@@ -4,7 +4,7 @@ const PREFIX = '/api/agent-panel'
|
|
|
4
4
|
|
|
5
5
|
interface Session {
|
|
6
6
|
token: string
|
|
7
|
-
drivers: string
|
|
7
|
+
drivers: Array<{ name: string; kind: 'chat' | 'terminal' }>
|
|
8
8
|
}
|
|
9
9
|
interface PanelEvent {
|
|
10
10
|
event: string
|
|
@@ -42,6 +42,8 @@ section { position: fixed; top: 0; right: 0; bottom: 0; width: min(380px, 100vw)
|
|
|
42
42
|
section.shown { display: flex }
|
|
43
43
|
header { display: flex; gap: 8px; align-items: center; padding: 8px 12px; border-bottom: 1px solid #e5e7eb }
|
|
44
44
|
header strong { flex: 1 }
|
|
45
|
+
#term { flex: 1; min-height: 0; background: #000 }
|
|
46
|
+
#term[hidden], form[hidden], #log[hidden] { display: none }
|
|
45
47
|
#log { flex: 1; overflow: auto; padding: 12px; display: flex; flex-direction: column; gap: 8px }
|
|
46
48
|
.msg { white-space: pre-wrap; padding: 8px 10px; border-radius: 8px; background: #f3f4f6 }
|
|
47
49
|
.me { background: #dbeafe; align-self: flex-end }
|
|
@@ -65,8 +67,13 @@ async function start(): Promise<void> {
|
|
|
65
67
|
shadow.innerHTML = `<style>${css}</style>
|
|
66
68
|
<button class="open" type="button">Ask the agent</button>
|
|
67
69
|
<section aria-label="Agent panel">
|
|
68
|
-
<header><strong>Agent</strong><select aria-label="Agent">${session.drivers
|
|
70
|
+
<header><strong>Agent</strong><select aria-label="Agent">${session.drivers
|
|
71
|
+
.map((d) => `<option>${d.name}</option>`)
|
|
72
|
+
.join(
|
|
73
|
+
'',
|
|
74
|
+
)}</select><button class="termstop" type="button" hidden>Stop terminal</button><button class="close" type="button" aria-label="Close">×</button></header>
|
|
69
75
|
<div id="log" role="log"></div>
|
|
76
|
+
<div id="term" hidden></div>
|
|
70
77
|
<form><textarea aria-label="Message" placeholder="What should the app do?"></textarea>
|
|
71
78
|
<div class="row"><button class="send" type="submit">Send</button><button class="stop" type="button" disabled>Stop</button></div></form>
|
|
72
79
|
</section>`
|
|
@@ -77,6 +84,54 @@ async function start(): Promise<void> {
|
|
|
77
84
|
const send = $<HTMLButtonElement>('.send')
|
|
78
85
|
const stop = $<HTMLButtonElement>('.stop')
|
|
79
86
|
const driver = $<HTMLSelectElement>('select')
|
|
87
|
+
const term = $<HTMLElement>('#term')
|
|
88
|
+
const termStop = $<HTMLButtonElement>('.termstop')
|
|
89
|
+
termStop.addEventListener('click', () => void terminal?.stop())
|
|
90
|
+
const form = $<HTMLFormElement>('form')
|
|
91
|
+
const kindOf = (name: string): 'chat' | 'terminal' =>
|
|
92
|
+
session.drivers.find((d) => d.name === name)?.kind ?? 'chat'
|
|
93
|
+
let terminal: { detach(): void; stop(): Promise<void> } | undefined
|
|
94
|
+
// A terminal driver swaps the chat for the driver's own terminal (loaded from the plugin that provides it, only when asked for).
|
|
95
|
+
const choose = async (): Promise<void> => {
|
|
96
|
+
terminal?.detach()
|
|
97
|
+
terminal = undefined
|
|
98
|
+
const isTerminal = kindOf(driver.value) === 'terminal'
|
|
99
|
+
log.hidden = isTerminal
|
|
100
|
+
form.hidden = isTerminal
|
|
101
|
+
term.hidden = !isTerminal
|
|
102
|
+
termStop.hidden = !isTerminal
|
|
103
|
+
if (!isTerminal) return
|
|
104
|
+
try {
|
|
105
|
+
const url = '/api/driver-pty/client/terminal.ts'
|
|
106
|
+
const { openTerminal } = (await import(url)) as {
|
|
107
|
+
openTerminal(options: {
|
|
108
|
+
container: HTMLElement
|
|
109
|
+
token: string
|
|
110
|
+
driver: string
|
|
111
|
+
status(text: string): void
|
|
112
|
+
onChange(change: { extension: string; action: string }): void
|
|
113
|
+
}): Promise<{ detach(): void; stop(): Promise<void> }>
|
|
114
|
+
}
|
|
115
|
+
terminal = await openTerminal({
|
|
116
|
+
container: term,
|
|
117
|
+
token: session.token,
|
|
118
|
+
driver: driver.value,
|
|
119
|
+
status: (text) => line(text, 'tool'),
|
|
120
|
+
onChange: (change) => {
|
|
121
|
+
line(`plugin ${change.extension}: ${change.action}`, 'tool')
|
|
122
|
+
line('Reloading the page to show it.', 'tool')
|
|
123
|
+
setTimeout(() => location.reload(), 900)
|
|
124
|
+
},
|
|
125
|
+
})
|
|
126
|
+
} catch (error) {
|
|
127
|
+
line(
|
|
128
|
+
`The terminal could not open: ${error instanceof Error ? error.message : String(error)}`,
|
|
129
|
+
'err',
|
|
130
|
+
)
|
|
131
|
+
log.hidden = false
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
driver.addEventListener('change', () => void choose())
|
|
80
135
|
const show = (open: boolean): void => {
|
|
81
136
|
panel.classList.toggle('shown', open)
|
|
82
137
|
if (open) input.focus()
|
|
@@ -154,6 +209,19 @@ async function start(): Promise<void> {
|
|
|
154
209
|
}
|
|
155
210
|
})
|
|
156
211
|
document.body.append(host)
|
|
212
|
+
// A terminal that was open before a reload is still running on the server: show it again.
|
|
213
|
+
try {
|
|
214
|
+
const remembered = JSON.parse(sessionStorage.getItem('driver-pty-session') ?? 'null') as {
|
|
215
|
+
driver?: string
|
|
216
|
+
} | null
|
|
217
|
+
if (remembered?.driver && kindOf(remembered.driver) === 'terminal') {
|
|
218
|
+
driver.value = remembered.driver
|
|
219
|
+
show(true)
|
|
220
|
+
void choose()
|
|
221
|
+
}
|
|
222
|
+
} catch {
|
|
223
|
+
// No storage: nothing to show again.
|
|
224
|
+
}
|
|
157
225
|
}
|
|
158
226
|
|
|
159
227
|
void start()
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The drivers the agent panel can offer, in one registry (the `drivers` service the panel provides). A driver is one of two kinds of session:
|
|
3
|
+
*
|
|
4
|
+
* - `chat`: a message in, the panel's own events out (`text`, `tool`, `error`). The app's agent, the headless coding agents and a harness chat are chat drivers.
|
|
5
|
+
* - `terminal`: bytes in, bytes out, a real pseudo-terminal (the `driver-pty` plugin). Nothing is forced to speak bytes.
|
|
6
|
+
*
|
|
7
|
+
* The panel lists every registered driver and picks the view by `kind`. A plugin that provides drivers injects `drivers`: with no panel mounted it is pending,
|
|
8
|
+
* and a panel without such a plugin simply lists none of its drivers.
|
|
9
|
+
*/
|
|
10
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
11
|
+
|
|
12
|
+
/** One event of a chat run. The panel adds `change` (a plugin was installed or removed) and `done` itself. */
|
|
13
|
+
export type ChatEvent =
|
|
14
|
+
| { readonly event: 'text'; readonly data: { readonly text: string } }
|
|
15
|
+
| { readonly event: 'tool'; readonly data: Record<string, unknown> }
|
|
16
|
+
| { readonly event: 'error'; readonly data: { readonly message: string } }
|
|
17
|
+
|
|
18
|
+
export interface ChatDriver {
|
|
19
|
+
readonly kind: 'chat'
|
|
20
|
+
readonly name: string
|
|
21
|
+
/** Runs one message. Aborting `signal` stops the run. */
|
|
22
|
+
start(options: { message: string; signal: AbortSignal }): AsyncIterable<ChatEvent>
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** A live pseudo-terminal session. */
|
|
26
|
+
export interface TerminalSession {
|
|
27
|
+
readonly id: string
|
|
28
|
+
onData(listener: (chunk: string) => void): () => void
|
|
29
|
+
onExit(listener: (code: number | null) => void): () => void
|
|
30
|
+
write(data: string): void
|
|
31
|
+
resize(cols: number, rows: number): void
|
|
32
|
+
/** Resolves when the process (group) is gone. */
|
|
33
|
+
kill(): Promise<void>
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface TerminalDriver {
|
|
37
|
+
readonly kind: 'terminal'
|
|
38
|
+
readonly name: string
|
|
39
|
+
start(options: {
|
|
40
|
+
cwd: string
|
|
41
|
+
env: NodeJS.ProcessEnv
|
|
42
|
+
cols: number
|
|
43
|
+
rows: number
|
|
44
|
+
}): TerminalSession
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export type Driver = ChatDriver | TerminalDriver
|
|
48
|
+
|
|
49
|
+
export interface Drivers {
|
|
50
|
+
/** Add a driver. Returns the function that removes it; a plugin registers inside `ctx.effect` so the driver leaves with it. */
|
|
51
|
+
register(driver: Driver): () => void
|
|
52
|
+
list(): Driver[]
|
|
53
|
+
get(name: string): Driver | undefined
|
|
54
|
+
/** Whether a request may use the drivers' endpoints: it comes from the app's own page and carries the panel's token. */
|
|
55
|
+
authorized(headers: Headers): boolean
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
declare module '@deepseek-ai/cordis' {
|
|
59
|
+
interface Context {
|
|
60
|
+
drivers: Drivers
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function createDrivers(authorized: (headers: Headers) => boolean = () => true): Drivers {
|
|
65
|
+
const table = new Map<string, Driver>()
|
|
66
|
+
return {
|
|
67
|
+
register(driver) {
|
|
68
|
+
if (table.has(driver.name)) throw new Error(`driver "${driver.name}" is already registered`)
|
|
69
|
+
table.set(driver.name, driver)
|
|
70
|
+
return () => void (table.get(driver.name) === driver && table.delete(driver.name))
|
|
71
|
+
},
|
|
72
|
+
list: () => [...table.values()],
|
|
73
|
+
get: (name) => table.get(name),
|
|
74
|
+
authorized,
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Provide the registry on `ctx` (the panel does). */
|
|
79
|
+
export function provideDrivers(ctx: Context, authorized: (headers: Headers) => boolean): Drivers {
|
|
80
|
+
const registry = createDrivers(authorized)
|
|
81
|
+
;(ctx as Context).provide('drivers', registry)
|
|
82
|
+
return registry
|
|
83
|
+
}
|