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.
Files changed (40) hide show
  1. package/docs/framework/cordis/node-server.md +25 -3
  2. package/lib/bun.d.ts +1 -1
  3. package/lib/cloudflare-workers.d.ts +1 -1
  4. package/lib/cordis/door.d.ts +2 -1
  5. package/lib/cordis/door.js +7 -4
  6. package/lib/cordis/inspect.js +1 -1
  7. package/lib/cordis/node-server.d.ts +14 -2
  8. package/lib/cordis/node-server.js +27 -3
  9. package/lib/cordis/router-http.d.ts +25 -2
  10. package/lib/cordis/router-http.js +54 -6
  11. package/lib/cordis/router.d.ts +1 -1
  12. package/lib/cordis/router.js +1 -1
  13. package/lib/cordis/state.js +1 -1
  14. package/lib/door/host.js +1 -1
  15. package/lib/{index-BvFh1Gfx.d.ts → index-C-SklY6u.d.ts} +3 -0
  16. package/lib/{index-BqVQkTnu.d.ts → index-D4nVR3wu.d.ts} +18 -3
  17. package/lib/node-server.d.ts +2 -38
  18. package/lib/node-server.js +2 -2
  19. package/lib/{snapshot-text-D3NspDyT.js → snapshot-text-yaWlezbd.js} +1 -1
  20. package/lib/{src-CwABFr6r.js → src-DNzw0Qsb.js} +3 -2
  21. package/lib/test-websocket-DyeS50Tr.d.ts +136 -0
  22. package/lib/{server-DvgMGtzo.js → test-websocket-ZXHNHQSU.js} +225 -3
  23. package/lib/ws.d.ts +1 -1
  24. package/lib/ws.js +3 -0
  25. package/package.json +6 -2
  26. package/plugins/acryl-door/src/host/door.ts +5 -3
  27. package/plugins/acryl-door/src/index.ts +1 -1
  28. package/plugins/acryl-node-server/plugin.json +1 -1
  29. package/plugins/acryl-node-server/src/adapter/websocket-types.ts +5 -1
  30. package/plugins/acryl-node-server/src/adapter/websocket.ts +33 -5
  31. package/plugins/acryl-node-server/src/index.ts +35 -1
  32. package/plugins/acryl-node-server/src/test-websocket.ts +110 -0
  33. package/plugins/acryl-node-server/src/upgrade-hub.ts +152 -0
  34. package/plugins/acryl-router/src/core/helper/websocket/index.ts +5 -0
  35. package/plugins/acryl-router/src/service.ts +18 -4
  36. package/plugins/acryl-router-http/plugin.json +2 -2
  37. package/plugins/acryl-router-http/src/index.ts +70 -6
  38. package/shared/AGENTS.core.md +1 -1
  39. package/shared/DEFAULTS.md +1 -1
  40. 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
- /** Serve `prefix` and everything below it through the router; returns a remover. The mount also goes away when the calling plugin unloads. */
44
- mount(prefix: string): () => void
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
- const env: RouterBindings = { connection }
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": "The serve() and request bridge that acryl-node-server uses to put the router on node:http. Mounting it as a plugin needs the DSH web server, which a generated app does not have. Vendored from ACRYL.",
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 remove = this.web.register({
49
- kind: 'prefix',
50
- path: prefix,
51
- handler: (req, res) => serve(this.router, req, res),
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 {
@@ -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 written into `plugins/` while the app runs may use the docs' own names instead: `acrylblends` and `acrylblends/<subpath>` resolve to the same files. `App` is the
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
@@ -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 plugins, where the docs are, the verbs, and whether a page is open
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]
@@ -1,8 +0,0 @@
1
- //#region ../../plugins/acryl-node-server/src/adapter/error.d.ts
2
- declare class RequestError extends Error {
3
- constructor(message: string, options?: {
4
- cause?: unknown;
5
- });
6
- }
7
- //#endregion
8
- export { RequestError as t };