acrylblends 0.10.0 → 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/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/shared/AGENTS.core.md +1 -1
- package/shared/DEFAULTS.md +1 -1
- package/lib/error-t4N2js2I.d.ts +0 -8
|
@@ -17,9 +17,14 @@ import z from '@deepseek-ai/schemastery'
|
|
|
17
17
|
import type {} from 'acryl-router'
|
|
18
18
|
import { Request as AdapterRequest } from './adapter/request.ts'
|
|
19
19
|
import { createAdaptorServer } from './adapter/server.ts'
|
|
20
|
+
import { UpgradeHub, type UpgradeHubOptions } from './upgrade-hub.ts'
|
|
21
|
+
import type { UpgradeWebSocket } from 'acryl-router/helpers'
|
|
22
|
+
import type { WebSocketLike } from './adapter/websocket-types.ts'
|
|
20
23
|
|
|
21
24
|
export { serveStatic, type ServeStaticOptions } from './adapter/serve-static.ts'
|
|
22
25
|
export { RequestError } from './adapter/request.ts'
|
|
26
|
+
export { testWebSocket, type TestSocket, type TestWebSocketOptions, type TestWebSocketResult } from './test-websocket.ts'
|
|
27
|
+
export { UpgradeEmitter, UpgradeHub, type UpgradeHubOptions, type UpgradeRouter } from './upgrade-hub.ts'
|
|
23
28
|
|
|
24
29
|
/** Stable Cordis plugin name, equal to the package name and the Loader row id. */
|
|
25
30
|
export const name = 'acryl-node-server'
|
|
@@ -29,6 +34,8 @@ export interface Config {
|
|
|
29
34
|
hostname: string
|
|
30
35
|
port: number
|
|
31
36
|
scan: number
|
|
37
|
+
/** Limits of WebSocket upgrades (`upgrades()`); the defaults suit most apps. */
|
|
38
|
+
websocket: UpgradeHubOptions
|
|
32
39
|
}
|
|
33
40
|
|
|
34
41
|
/** Every field has a default, so a caller passes only what it changes: `ctx.plugin(plugin, { ... })` takes the partial shape. */
|
|
@@ -36,6 +43,13 @@ export const Config: z<Partial<Config>, Config> = z.object({
|
|
|
36
43
|
hostname: z.string().default('127.0.0.1').description('Address to bind. Loopback by default; binding elsewhere exposes the app to the network.'),
|
|
37
44
|
port: z.number().min(0).max(65535).default(0).description('Port to listen on; 0 lets the system choose. A taken port is followed by the next free one.'),
|
|
38
45
|
scan: z.number().min(0).default(20).description('How many further ports to try when the configured one is taken.'),
|
|
46
|
+
websocket: z
|
|
47
|
+
.object({
|
|
48
|
+
maxPayload: z.number().min(1).default(1024 * 1024).description('The largest WebSocket frame accepted, in bytes; a bigger one closes the socket with 1009.'),
|
|
49
|
+
heartbeat: z.number().min(0).default(30_000).description('Milliseconds between pings; a peer that did not answer the last one is terminated. 0 turns it off.'),
|
|
50
|
+
grace: z.number().min(0).default(2000).description('Milliseconds a closing socket gets before it is terminated when its plugin unloads.'),
|
|
51
|
+
})
|
|
52
|
+
.default({}),
|
|
39
53
|
})
|
|
40
54
|
|
|
41
55
|
declare module '@deepseek-ai/cordis' {
|
|
@@ -48,6 +62,7 @@ export class NodeServer extends Service {
|
|
|
48
62
|
readonly hostname: string
|
|
49
63
|
readonly config: Config
|
|
50
64
|
private readonly server: Server
|
|
65
|
+
private hub: UpgradeHub | undefined
|
|
51
66
|
private bound = 0
|
|
52
67
|
|
|
53
68
|
constructor(ctx: Context, config: Config) {
|
|
@@ -64,11 +79,30 @@ export class NodeServer extends Service {
|
|
|
64
79
|
overrideGlobalObjects: false,
|
|
65
80
|
}) as Server
|
|
66
81
|
ctx.effect(() => async () => {
|
|
82
|
+
await this.hub?.close()
|
|
67
83
|
this.server.closeAllConnections()
|
|
68
84
|
if (this.server.listening) await new Promise<void>(resolve => { this.server.close(() => { resolve() }) })
|
|
69
85
|
}, 'acryl-node-server: close listener and open connections')
|
|
70
86
|
}
|
|
71
87
|
|
|
88
|
+
/**
|
|
89
|
+
* `upgradeWebSocket` for the plugin that asks (call it once, in `apply`): `app.get('/ws', upgrade((c) => ({ onMessage(event, ws) { ... } })))`. Checks go in ordinary middleware on the
|
|
90
|
+
* route, and a handler that returns a `Response` refuses the upgrade with that status before any handshake. Every socket it accepts belongs to the calling plugin: when the plugin
|
|
91
|
+
* unloads they are closed with 1001 within `websocket.grace` and the handler sees `close` once. Pass `{ textOnly: true }` to drop binary frames. Needs the `ws` package.
|
|
92
|
+
*/
|
|
93
|
+
upgrades(): UpgradeWebSocket<WebSocketLike> {
|
|
94
|
+
if (!this.hub) {
|
|
95
|
+
this.hub = new UpgradeHub(this.config.websocket)
|
|
96
|
+
this.hub.attach(this.server, this.ctx.router)
|
|
97
|
+
}
|
|
98
|
+
return this.hub.upgradeWebSocketFor(this.ctx)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** How many WebSocket connections are open now. */
|
|
102
|
+
get sockets(): number {
|
|
103
|
+
return this.hub?.size ?? 0
|
|
104
|
+
}
|
|
105
|
+
|
|
72
106
|
/** The port in use, 0 before the server listens. */
|
|
73
107
|
get port(): number {
|
|
74
108
|
return this.bound
|
|
@@ -113,6 +147,6 @@ export function isPortUnavailable(error: unknown, platform: NodeJS.Platform = pr
|
|
|
113
147
|
|
|
114
148
|
/** Loads once the server listens, so a plugin that injects `nodeServer` can rely on `url`. A failure to bind fails the plugin. */
|
|
115
149
|
export async function apply(ctx: Context, config: Partial<Config> = {}): Promise<void> {
|
|
116
|
-
const server = new NodeServer(ctx, { hostname: '127.0.0.1', port: 0, scan: 20, ...config })
|
|
150
|
+
const server = new NodeServer(ctx, { hostname: '127.0.0.1', port: 0, scan: 20, websocket: {}, ...config })
|
|
117
151
|
await server.listen()
|
|
118
152
|
}
|
|
@@ -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 {
|
package/shared/AGENTS.core.md
CHANGED
|
@@ -19,7 +19,7 @@ The docs write `import { Acryl } from 'acrylblends'` and `from 'acrylblends/<sub
|
|
|
19
19
|
|
|
20
20
|
**JSX: write `.tsx`.** The scripts in `package.json` run Node.js with `--import ./plugins/acryl-node-server/src/register.ts`, which compiles `.tsx` files with this app's own `typescript`; `tsconfig.json` sets `jsx: react-jsx` and `jsxImportSource: #acryl-jsx`, which `package.json`'s `imports` maps to the vendored runtime in `plugins/acryl-router`. See `src/about.tsx` (blank template) for a page and `test/app.test.ts` for how it is tested. Render a component with `.toString()` and return it with `c.html(...)`; import `jsx`, `Fragment` and the types from `../plugins/acryl-router/src/core/jsx/index.ts` when you need them. A command that starts the app without those scripts (a bare `node src/main.ts`) cannot load a `.tsx` file. Without a build step you can write the call JSX compiles to instead: `jsx('h1', { class: 'title' }, 'Hello')`, children after the props.
|
|
21
21
|
|
|
22
|
-
A plugin
|
|
22
|
+
**A plugin you install into the running app** (`.acryl/bin/acryl plugin add plugins/<name>`) **imports the framework by its package name**: `import { Acryl } from 'acrylblends'` (and `acrylblends/<subpath>`, for example `acrylblends/helpers`). `acrylblends` and `acrylblends/<subpath>` resolve to the same files as the relative paths above, and the plugin is checked in a staging copy where a relative path like `../plugins/acryl-router/src/index.ts` may not resolve (a plugin that wrote it failed its first install). Plugins that `src/app.ts` imports at start-up keep using the relative paths. `App` is the
|
|
23
23
|
deprecated name of `Acryl`.
|
|
24
24
|
|
|
25
25
|
## How a feature is built
|
package/shared/DEFAULTS.md
CHANGED
|
@@ -26,7 +26,7 @@ install a second time). Do not create `src/plugins/` or `extensions/`.
|
|
|
26
26
|
The door is a command in this folder, `.acryl/bin/acryl`, that lets you look at and change the running app. The running app writes it, and it reaches only the app running from this folder. Its state (a secret and an audit log) lives under `.acryl/`, which git ignores: never commit it. It is on in development and off with `NODE_ENV=production` or `ACRYL_DOOR=off`; one process at a time serves a folder's door, so a second `npm start` in the same folder stops with a message that says so (the tests set `ACRYL_DOOR=off`). If `.acryl/bin/acryl` is not here, the app is not running: start it with `npm run dev` (in the background) and open its address in a browser.
|
|
27
27
|
|
|
28
28
|
```
|
|
29
|
-
.acryl/bin/acryl brief what this app is: its
|
|
29
|
+
.acryl/bin/acryl brief what this app is: its address (curl a new route there), where the docs are, the verbs, and whether a page is open
|
|
30
30
|
.acryl/bin/acryl look the page as text, with refs (1.1, 1.2, ...): roles, names, values, visible text
|
|
31
31
|
.acryl/bin/acryl act click --ref <r>
|
|
32
32
|
.acryl/bin/acryl act type --ref <r> --text <t> [--submit] [--no-clear]
|
package/lib/error-t4N2js2I.d.ts
DELETED