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.
Files changed (60) hide show
  1. package/dist/index.js.map +1 -1
  2. package/docs/framework/cordis/node-server.md +25 -3
  3. package/lib/bun.d.ts +1 -1
  4. package/lib/cloudflare-workers.d.ts +1 -1
  5. package/lib/cordis/door.d.ts +2 -1
  6. package/lib/cordis/door.js +7 -4
  7. package/lib/cordis/inspect.js +1 -1
  8. package/lib/cordis/node-server.d.ts +14 -2
  9. package/lib/cordis/node-server.js +27 -3
  10. package/lib/cordis/router-http.d.ts +25 -2
  11. package/lib/cordis/router-http.js +54 -6
  12. package/lib/cordis/router.d.ts +1 -1
  13. package/lib/cordis/router.js +1 -1
  14. package/lib/cordis/state.js +1 -1
  15. package/lib/door/host.js +1 -1
  16. package/lib/{index-BvFh1Gfx.d.ts → index-C-SklY6u.d.ts} +3 -0
  17. package/lib/{index-BqVQkTnu.d.ts → index-D4nVR3wu.d.ts} +18 -3
  18. package/lib/node-server.d.ts +2 -38
  19. package/lib/node-server.js +2 -2
  20. package/lib/{snapshot-text-D3NspDyT.js → snapshot-text-yaWlezbd.js} +1 -1
  21. package/lib/{src-CwABFr6r.js → src-DNzw0Qsb.js} +3 -2
  22. package/lib/test-websocket-DyeS50Tr.d.ts +136 -0
  23. package/lib/{server-DvgMGtzo.js → test-websocket-ZXHNHQSU.js} +225 -3
  24. package/lib/ws.d.ts +1 -1
  25. package/lib/ws.js +3 -0
  26. package/package.json +6 -2
  27. package/plugins/acryl-door/src/host/door.ts +5 -3
  28. package/plugins/acryl-door/src/index.ts +1 -1
  29. package/plugins/acryl-node-server/plugin.json +1 -1
  30. package/plugins/acryl-node-server/src/adapter/websocket-types.ts +5 -1
  31. package/plugins/acryl-node-server/src/adapter/websocket.ts +33 -5
  32. package/plugins/acryl-node-server/src/index.ts +35 -1
  33. package/plugins/acryl-node-server/src/test-websocket.ts +110 -0
  34. package/plugins/acryl-node-server/src/upgrade-hub.ts +152 -0
  35. package/plugins/acryl-router/src/core/helper/websocket/index.ts +5 -0
  36. package/plugins/acryl-router/src/service.ts +18 -4
  37. package/plugins/acryl-router-http/plugin.json +2 -2
  38. package/plugins/acryl-router-http/src/index.ts +70 -6
  39. package/plugins/agent-panel/client/main.ts +70 -2
  40. package/plugins/agent-panel/drivers.ts +83 -0
  41. package/plugins/agent-panel/headless.ts +154 -0
  42. package/plugins/agent-panel/index.ts +42 -125
  43. package/plugins/agent-panel/plugin.json +1 -1
  44. package/plugins/driver-pty/client/terminal.ts +188 -0
  45. package/plugins/driver-pty/client/vendor/LICENSE +21 -0
  46. package/plugins/driver-pty/client/vendor/LICENSE-addon-fit +19 -0
  47. package/plugins/driver-pty/client/vendor/VERSION +4 -0
  48. package/plugins/driver-pty/client/vendor/addon-fit.d.mts +6 -0
  49. package/plugins/driver-pty/client/vendor/addon-fit.mjs +18 -0
  50. package/plugins/driver-pty/client/vendor/xterm-css.d.mts +2 -0
  51. package/plugins/driver-pty/client/vendor/xterm-css.mjs +2 -0
  52. package/plugins/driver-pty/client/vendor/xterm.d.mts +17 -0
  53. package/plugins/driver-pty/client/vendor/xterm.mjs +53 -0
  54. package/plugins/driver-pty/index.ts +249 -0
  55. package/plugins/driver-pty/plugin.json +13 -0
  56. package/plugins/driver-pty/spawn.ts +36 -0
  57. package/plugins/driver-pty/terminal.ts +84 -0
  58. package/shared/AGENTS.core.md +1 -1
  59. package/shared/DEFAULTS.md +1 -1
  60. package/lib/error-t4N2js2I.d.ts +0 -8
@@ -1,8 +1,10 @@
1
1
  import { defineWebSocketHelper } from "./ws.js";
2
2
  import "./helpers-CvTauQT5.js";
3
+ import { createRequire } from "node:module";
3
4
  import { STATUS_CODES, ServerResponse, createServer } from "node:http";
4
5
  import { Http2ServerRequest, constants } from "node:http2";
5
6
  import { Readable } from "node:stream";
7
+ import { EventEmitter } from "node:events";
6
8
  //#region ../../plugins/acryl-node-server/src/adapter/error.ts
7
9
  var RequestError = class extends Error {
8
10
  constructor(message, options) {
@@ -1110,6 +1112,8 @@ const ErrorEvent = globalThis.ErrorEvent ?? class extends Event {
1110
1112
  }
1111
1113
  };
1112
1114
  const generateConnectionSymbol = () => Symbol("connection");
1115
+ /** The codes an endpoint may send in a close frame (RFC 6455 section 7.4): 1000-1003, 1007-1014 and the private ranges 3000-4999. */
1116
+ const validCloseCode = (code) => code >= 1e3 && code <= 1014 && code !== 1004 && code !== 1005 && code !== 1006 || code >= 3e3 && code <= 4999;
1113
1117
  const CONNECTION_SYMBOL_KEY = Symbol("CONNECTION_SYMBOL_KEY");
1114
1118
  const WAIT_FOR_WEBSOCKET_SYMBOL = Symbol("WAIT_FOR_WEBSOCKET_SYMBOL");
1115
1119
  const responseHeadersToSkip = /* @__PURE__ */ new Set([
@@ -1177,7 +1181,10 @@ const setupWebSocket = (options) => {
1177
1181
  });
1178
1182
  };
1179
1183
  server.on("upgrade", async (request, socket, head) => {
1180
- if (request.headers.upgrade?.toLowerCase() !== "websocket") return;
1184
+ if (request.headers.upgrade?.toLowerCase() !== "websocket") {
1185
+ if (server.listenerCount("upgrade") === 1) rejectUpgradeRequest(socket, 400);
1186
+ return;
1187
+ }
1181
1188
  const env = {
1182
1189
  incoming: request,
1183
1190
  outgoing: void 0,
@@ -1232,6 +1239,7 @@ const upgradeWebSocket = defineWebSocketHelper(async (c, events, options) => {
1232
1239
  let ws;
1233
1240
  try {
1234
1241
  ws = await waitForWebSocket(env.incoming, connectionSymbol);
1242
+ options?.track?.(ws);
1235
1243
  } catch {
1236
1244
  return;
1237
1245
  }
@@ -1243,13 +1251,16 @@ const upgradeWebSocket = defineWebSocketHelper(async (c, events, options) => {
1243
1251
  const ctx = {
1244
1252
  binaryType: "arraybuffer",
1245
1253
  close(code, reason) {
1246
- ws.close(code, reason);
1254
+ ws.close(code === void 0 || validCloseCode(code) ? code : 1011, reason);
1247
1255
  },
1248
1256
  protocol: ws.protocol,
1249
1257
  raw: ws,
1250
1258
  get readyState() {
1251
1259
  return ws.readyState;
1252
1260
  },
1261
+ get bufferedAmount() {
1262
+ return ws.bufferedAmount;
1263
+ },
1253
1264
  send(source, opts) {
1254
1265
  ws.send(source, { compress: opts?.compress });
1255
1266
  },
@@ -1261,6 +1272,7 @@ const upgradeWebSocket = defineWebSocketHelper(async (c, events, options) => {
1261
1272
  (options?.onError ?? console.error)(e);
1262
1273
  }
1263
1274
  const handleMessage = (data, isBinary) => {
1275
+ if (isBinary && options?.textOnly) return;
1264
1276
  const datas = Array.isArray(data) ? data : [data];
1265
1277
  for (const data of datas) try {
1266
1278
  events?.onMessage?.(new MessageEvent("message", { data: isBinary ? data instanceof ArrayBuffer ? data : data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength) : typeof data === "string" ? data : Buffer.from(data).toString("utf-8") }), ctx);
@@ -1273,7 +1285,10 @@ const upgradeWebSocket = defineWebSocketHelper(async (c, events, options) => {
1273
1285
  ws.on("message", (data, isBinary) => {
1274
1286
  handleMessage(data, isBinary);
1275
1287
  });
1288
+ let closed = false;
1276
1289
  ws.on("close", (code, reason) => {
1290
+ if (closed) return;
1291
+ closed = true;
1277
1292
  try {
1278
1293
  events?.onClose?.(new CloseEvent("close", {
1279
1294
  code,
@@ -1320,4 +1335,211 @@ function serve(options, listeningListener) {
1320
1335
  return server;
1321
1336
  }
1322
1337
  //#endregion
1323
- export { Request$1 as a, getRequestListener as i, serve as n, RequestError as o, upgradeWebSocket as r, createAdaptorServer as t };
1338
+ //#region ../../plugins/acryl-node-server/src/upgrade-hub.ts
1339
+ /**
1340
+ * 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
1341
+ * `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
1342
+ * 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
1343
+ * what is left is terminated. The handler sees `close` exactly once.
1344
+ */
1345
+ /** The `ws` package, loaded when the first hub is made: an app that never upgrades does not need it. */
1346
+ function loadWs() {
1347
+ try {
1348
+ return createRequire(import.meta.url)("ws").WebSocketServer;
1349
+ } catch (error) {
1350
+ throw new Error(`WebSocket upgrades need the "ws" package (npm install ws): ${error instanceof Error ? error.message : String(error)}`);
1351
+ }
1352
+ }
1353
+ var UpgradeHub = class {
1354
+ server;
1355
+ all = /* @__PURE__ */ new Set();
1356
+ alive = /* @__PURE__ */ new WeakMap();
1357
+ grace;
1358
+ timer;
1359
+ closed = false;
1360
+ constructor(options = {}) {
1361
+ const Server = loadWs();
1362
+ this.server = new Server({
1363
+ noServer: true,
1364
+ maxPayload: options.maxPayload ?? 1024 * 1024,
1365
+ perMessageDeflate: false
1366
+ });
1367
+ this.grace = options.grace ?? 2e3;
1368
+ this.server.on("connection", (ws) => {
1369
+ this.all.add(ws);
1370
+ this.alive.set(ws, true);
1371
+ ws.on("pong", () => this.alive.set(ws, true));
1372
+ ws.on("close", () => this.all.delete(ws));
1373
+ });
1374
+ const heartbeat = options.heartbeat ?? 3e4;
1375
+ if (heartbeat > 0) {
1376
+ this.timer = setInterval(() => {
1377
+ for (const ws of this.all) {
1378
+ if (this.alive.get(ws) === false) {
1379
+ ws.terminate();
1380
+ continue;
1381
+ }
1382
+ this.alive.set(ws, false);
1383
+ ws.ping();
1384
+ }
1385
+ }, heartbeat);
1386
+ this.timer.unref();
1387
+ }
1388
+ }
1389
+ /**
1390
+ * 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
1391
+ * facts (so the same loopback, origin and host checks work), and the upgrade bindings travel in `c.env` for `upgradeWebSocket`.
1392
+ */
1393
+ attach(source, router) {
1394
+ const fetchCallback = (request, env) => {
1395
+ const { incoming } = env;
1396
+ return router.fetch(request, {
1397
+ remoteAddress: incoming.socket.remoteAddress,
1398
+ localPort: incoming.socket.localPort
1399
+ }, env);
1400
+ };
1401
+ setupWebSocket({
1402
+ server: source,
1403
+ fetchCallback,
1404
+ wss: this.server
1405
+ });
1406
+ }
1407
+ /** How many sockets are open. */
1408
+ get size() {
1409
+ return this.all.size;
1410
+ }
1411
+ /** `upgradeWebSocket` for the plugin `owner`: the sockets it accepts are closed with 1001 when that plugin unloads. */
1412
+ upgradeWebSocketFor(owner) {
1413
+ const mine = /* @__PURE__ */ new Set();
1414
+ owner.effect(() => () => this.closeAll(mine, 1001, "going away"), "upgrade sockets");
1415
+ const track = (ws) => {
1416
+ mine.add(ws);
1417
+ ws.on("close", () => mine.delete(ws));
1418
+ };
1419
+ const owned = (...args) => {
1420
+ const [first, second, third] = args;
1421
+ return typeof first === "function" ? upgradeWebSocket(first, {
1422
+ ...second,
1423
+ track
1424
+ }) : upgradeWebSocket(first, second, {
1425
+ ...third,
1426
+ track
1427
+ });
1428
+ };
1429
+ return owned;
1430
+ }
1431
+ /** Close `sockets` with `code`, wait for their close handshakes up to the grace, terminate what is left. */
1432
+ async closeAll(sockets, code, reason) {
1433
+ const open = [...sockets].filter((ws) => ws.readyState < 3);
1434
+ await Promise.all(open.map((ws) => new Promise((resolve) => {
1435
+ const timer = setTimeout(() => {
1436
+ ws.terminate();
1437
+ resolve();
1438
+ }, this.grace);
1439
+ ws.on("close", () => {
1440
+ clearTimeout(timer);
1441
+ resolve();
1442
+ });
1443
+ if (ws.readyState === 1) ws.close(code, reason);
1444
+ else if (ws.readyState === 3) {
1445
+ clearTimeout(timer);
1446
+ resolve();
1447
+ }
1448
+ })));
1449
+ }
1450
+ /** End everything: every socket closes with 1001, the heartbeat stops, the `ws` server closes. */
1451
+ async close() {
1452
+ if (this.closed) return;
1453
+ this.closed = true;
1454
+ if (this.timer !== void 0) clearInterval(this.timer);
1455
+ await this.closeAll(this.all, 1001, "going away");
1456
+ this.server.close();
1457
+ }
1458
+ };
1459
+ /** A stand-in for a server's `upgrade` events, for transports that dispatch upgrades themselves (the Host web server's exact-path routes). */
1460
+ var UpgradeEmitter = class extends EventEmitter {};
1461
+ //#endregion
1462
+ //#region ../../plugins/acryl-node-server/src/test-websocket.ts
1463
+ /** A fake `ws` socket: events go both ways without a network. */
1464
+ var FakeSocket = class {
1465
+ protocol = "";
1466
+ readyState = 1;
1467
+ bufferedAmount = 0;
1468
+ sent = [];
1469
+ closedWith;
1470
+ listeners = /* @__PURE__ */ new Map();
1471
+ on(event, listener) {
1472
+ this.listeners.set(event, [...this.listeners.get(event) ?? [], listener]);
1473
+ return this;
1474
+ }
1475
+ off(event, listener) {
1476
+ this.listeners.set(event, (this.listeners.get(event) ?? []).filter((l) => l !== listener));
1477
+ return this;
1478
+ }
1479
+ emit(event, ...args) {
1480
+ for (const listener of this.listeners.get(event) ?? []) listener(...args);
1481
+ }
1482
+ send(data) {
1483
+ this.sent.push(typeof data === "string" ? data : Buffer.from(data).toString("utf8"));
1484
+ }
1485
+ close(code, reason) {
1486
+ if (this.readyState === 3) return;
1487
+ this.closedWith = {
1488
+ code,
1489
+ reason
1490
+ };
1491
+ this.readyState = 3;
1492
+ this.emit("close", code ?? 1005, Buffer.from(reason ?? ""));
1493
+ }
1494
+ ping() {}
1495
+ terminate() {
1496
+ this.close(1006);
1497
+ }
1498
+ };
1499
+ async function testWebSocket(fetch, path, options = {}) {
1500
+ const fake = new FakeSocket();
1501
+ let waiter;
1502
+ const env = {
1503
+ incoming: {
1504
+ headers: options.headers ?? {},
1505
+ socket: {
1506
+ remoteAddress: options.connection?.remoteAddress,
1507
+ localPort: options.connection?.localPort
1508
+ }
1509
+ },
1510
+ outgoing: void 0,
1511
+ wss: {},
1512
+ [WAIT_FOR_WEBSOCKET_SYMBOL]: (_request, connectionSymbol) => new Promise((resolve) => {
1513
+ waiter = {
1514
+ connectionSymbol,
1515
+ resolve
1516
+ };
1517
+ })
1518
+ };
1519
+ const headers = new Headers({
1520
+ upgrade: "websocket",
1521
+ connection: "Upgrade",
1522
+ ...options.headers
1523
+ });
1524
+ const response = await fetch(new Request(new URL(path, options.base ?? "http://127.0.0.1"), { headers }), env);
1525
+ if (!waiter || waiter.connectionSymbol !== env[CONNECTION_SYMBOL_KEY]) return { status: response.status };
1526
+ waiter.resolve(fake);
1527
+ await new Promise((resolve) => setTimeout(resolve, 0));
1528
+ return {
1529
+ status: 101,
1530
+ socket: {
1531
+ received: fake.sent,
1532
+ get closed() {
1533
+ return fake.closedWith;
1534
+ },
1535
+ send: (text) => fake.emit("message", text, false),
1536
+ close: (code, reason) => {
1537
+ if (fake.readyState === 3) return;
1538
+ fake.readyState = 3;
1539
+ fake.emit("close", code ?? 1005, Buffer.from(reason ?? ""));
1540
+ }
1541
+ }
1542
+ };
1543
+ }
1544
+ //#endregion
1545
+ export { serve as a, Request$1 as c, createAdaptorServer as i, RequestError as l, UpgradeEmitter as n, upgradeWebSocket as o, UpgradeHub as r, getRequestListener as s, testWebSocket as t };
package/lib/ws.d.ts CHANGED
@@ -1,2 +1,2 @@
1
- import { a as WSEvents, c as WebSocketHelperDefineContext, d as defineWebSocketHelper, i as WSContextInit, l as WebSocketHelperDefineHandler, n as UpgradeWebSocket, o as WSMessageReceive, r as WSContext, s as WSReadyState, t as SendOptions, u as createWSMessageEvent } from "./index-BvFh1Gfx.js";
1
+ import { a as WSEvents, c as WebSocketHelperDefineContext, d as defineWebSocketHelper, i as WSContextInit, l as WebSocketHelperDefineHandler, n as UpgradeWebSocket, o as WSMessageReceive, r as WSContext, s as WSReadyState, t as SendOptions, u as createWSMessageEvent } from "./index-C-SklY6u.js";
2
2
  export { SendOptions, UpgradeWebSocket, WSContext, WSContextInit, WSEvents, WSMessageReceive, WSReadyState, WebSocketHelperDefineContext, WebSocketHelperDefineHandler, createWSMessageEvent, defineWebSocketHelper };
package/lib/ws.js CHANGED
@@ -18,6 +18,9 @@ var WSContext = class {
18
18
  get readyState() {
19
19
  return this.#init.readyState;
20
20
  }
21
+ get bufferedAmount() {
22
+ return this.#init.bufferedAmount ?? 0;
23
+ }
21
24
  url;
22
25
  protocol;
23
26
  close(code, reason) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "acrylblends",
3
- "version": "0.9.2",
3
+ "version": "0.11.0",
4
4
  "description": "The Acryl web framework (import { Acryl } from \"acrylblends\") and the acrylblends command that scaffolds Cordis apps.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -552,7 +552,8 @@
552
552
  "@deepseek-ai/cordis": "^4.0.2",
553
553
  "@deepseek-ai/schemastery": "~3.18.4",
554
554
  "react": ">=18",
555
- "vitest": ">=3"
555
+ "vitest": ">=3",
556
+ "ws": ">=8"
556
557
  },
557
558
  "peerDependenciesMeta": {
558
559
  "@deepseek-ai/cordis": {
@@ -566,6 +567,9 @@
566
567
  },
567
568
  "vitest": {
568
569
  "optional": true
570
+ },
571
+ "ws": {
572
+ "optional": true
569
573
  }
570
574
  },
571
575
  "scripts": {
@@ -43,8 +43,8 @@ export interface ToolsPort {
43
43
  export interface DoorDeps {
44
44
  readonly channel: UiChannel
45
45
  readonly audit: AuditLog
46
- /** The app's name and folder. */
47
- readonly app: { readonly name: string; readonly root: string }
46
+ /** The app's name, folder and, when it listens, its address (what `brief` prints so an agent can curl the running app). */
47
+ readonly app: { readonly name: string; readonly root: string; readonly url?: string | undefined }
48
48
  /** Read at each call: the services may come and go while the door runs. */
49
49
  readonly extensions: () => ExtensionsPort | undefined
50
50
  readonly tools: () => ToolsPort | undefined
@@ -99,11 +99,12 @@ export class Door {
99
99
  }
100
100
 
101
101
  brief(): VerbAnswer {
102
- const { name, root } = this.deps.app
102
+ const { name, root, url } = this.deps.app
103
103
  const read = SELF_KNOWLEDGE.filter(path => existsSync(resolve(root, path)))
104
104
  const value = {
105
105
  app: name,
106
106
  root,
107
+ ...(url === undefined ? {} : { url }),
107
108
  read,
108
109
  page: this.deps.channel.connected ? 'open' : 'not open',
109
110
  plugins: this.deps.extensions() === undefined ? 'unavailable' : 'available',
@@ -111,6 +112,7 @@ export class Door {
111
112
  }
112
113
  const text = [
113
114
  `App ${name} in ${root}.`,
115
+ ...(url === undefined ? [] : [`Address: ${url} (the running app; curl it to check a route).`]),
114
116
  read.length === 0 ? 'No AGENTS.md or skill in the folder.' : `Read first: ${read.join(', ')}.`,
115
117
  `Page: ${value.page}${value.page === 'not open' ? ' (open the app in a browser to look and act)' : ''}.`,
116
118
  `Live plugins: ${value.plugins}.`,
@@ -96,7 +96,7 @@ export function apply(ctx: Context, rawConfig: Config = {}): void {
96
96
  const door = new Door({
97
97
  channel,
98
98
  audit,
99
- app: { name: app.name, root: app.root },
99
+ app: { name: app.name, root: app.root, url: loopbackUrl(server.url) },
100
100
  extensions: () => ctx.get('extensions') as unknown as ExtensionsPort | undefined,
101
101
  tools: () => ctx.get('tools') as unknown as ToolsPort | undefined,
102
102
  expose: config.tools?.expose ?? [],
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "acryl-node-server",
3
3
  "description": "The standalone HTTP server as a Cordis plugin: one node:http listener that sends every request to the router. Port, loopback host and next-free-port scan; ends open streams and frees the port when unloaded. Vendored from ACRYL.",
4
- "requires": ["acryl-router", "acryl-router-http"],
4
+ "requires": ["acryl-router"],
5
5
  "inject": ["router"],
6
6
  "provides": ["nodeServer"],
7
7
  "dependencies": {
@@ -19,11 +19,15 @@ export type WebSocketSendOptions = {
19
19
  export interface WebSocketLike {
20
20
  protocol: string
21
21
  readyState: WSReadyState
22
+ bufferedAmount: number
22
23
  close(code?: number, reason?: string): void
23
24
  send(data: string | ArrayBuffer | ArrayBufferView, options?: WebSocketSendOptions): void
24
25
  on(event: 'message', listener: (data: WebSocketData, isBinary: boolean) => void): this
25
26
  on(event: 'close', listener: (code: number, reason: Uint8Array) => void): this
26
27
  on(event: 'error', listener: (error: unknown) => void): this
28
+ on(event: 'pong', listener: () => void): this
29
+ ping(): void
30
+ terminate(): void
27
31
  off(event: 'message', listener: (data: WebSocketData, isBinary: boolean) => void): this
28
32
  }
29
33
 
@@ -41,5 +45,5 @@ export interface WebSocketServerLike {
41
45
  head: Buffer,
42
46
  callback: (ws: WebSocketLike) => void
43
47
  ): void
44
- close(): void
48
+ close(callback?: () => void): void
45
49
  }
@@ -3,7 +3,7 @@ import { defineWebSocketHelper } from 'acryl-router/helpers'
3
3
  import type { IncomingMessage } from 'node:http'
4
4
  import { STATUS_CODES } from 'node:http'
5
5
  import type { Duplex } from 'node:stream'
6
- import type { FetchCallback, ServerType } from './types.ts'
6
+ import type { FetchCallback } from './types.ts'
7
7
  import type { WebSocketData, WebSocketLike, WebSocketServerLike } from './websocket-types.ts'
8
8
 
9
9
  interface CloseEventInit extends EventInit {
@@ -83,13 +83,17 @@ export const ErrorEvent: typeof globalThis.ErrorEvent =
83
83
 
84
84
  const generateConnectionSymbol = () => Symbol('connection')
85
85
 
86
+ /** The codes an endpoint may send in a close frame (RFC 6455 section 7.4): 1000-1003, 1007-1014 and the private ranges 3000-4999. */
87
+ export const validCloseCode = (code: number): boolean =>
88
+ (code >= 1000 && code <= 1014 && code !== 1004 && code !== 1005 && code !== 1006) || (code >= 3000 && code <= 4999)
89
+
86
90
  type WaitForWebSocket = (
87
91
  request: IncomingMessage,
88
92
  connectionSymbol: symbol
89
93
  ) => Promise<WebSocketLike>
90
94
 
91
- const CONNECTION_SYMBOL_KEY: unique symbol = Symbol('CONNECTION_SYMBOL_KEY')
92
- const WAIT_FOR_WEBSOCKET_SYMBOL: unique symbol = Symbol('WAIT_FOR_WEBSOCKET_SYMBOL')
95
+ export const CONNECTION_SYMBOL_KEY: unique symbol = Symbol('CONNECTION_SYMBOL_KEY')
96
+ export const WAIT_FOR_WEBSOCKET_SYMBOL: unique symbol = Symbol('WAIT_FOR_WEBSOCKET_SYMBOL')
93
97
 
94
98
  export type UpgradeBindings = {
95
99
  incoming: IncomingMessage
@@ -101,6 +105,10 @@ export type UpgradeBindings = {
101
105
 
102
106
  type UpgradeWebSocketOptions = {
103
107
  onError: (err: unknown) => void
108
+ /** Hand the handler text frames only; binary frames are dropped before `onMessage`. */
109
+ textOnly?: boolean
110
+ /** Called with each accepted socket: the mounting plugin's scope keeps it to close it when the plugin unloads. */
111
+ track?: (ws: WebSocketLike) => void
104
112
  }
105
113
 
106
114
  // Hop-by-hop headers per RFC 9110 Section 7.6.1
@@ -162,8 +170,15 @@ const createUpgradeRequest = (request: IncomingMessage): Request => {
162
170
  })
163
171
  }
164
172
 
173
+ /** The part of a server `setupWebSocket` listens to: the Host's web server hands upgrades to a plain emitter with the same two methods. */
174
+ export interface UpgradeSource {
175
+ on(event: 'upgrade', listener: (request: IncomingMessage, socket: Duplex, head: Buffer) => void): unknown
176
+ on(event: 'close', listener: () => void): unknown
177
+ listenerCount(event: 'upgrade'): number
178
+ }
179
+
165
180
  export const setupWebSocket = (options: {
166
- server: ServerType
181
+ server: UpgradeSource
167
182
  fetchCallback: FetchCallback
168
183
  wss: WebSocketServerLike
169
184
  }): void => {
@@ -202,6 +217,8 @@ export const setupWebSocket = (options: {
202
217
 
203
218
  server.on('upgrade', async (request, socket: Duplex, head) => {
204
219
  if (request.headers.upgrade?.toLowerCase() !== 'websocket') {
220
+ // Nobody else listens: an upgrade to another protocol is answered, not left hanging.
221
+ if (server.listenerCount('upgrade') === 1) rejectUpgradeRequest(socket, 400)
205
222
  return
206
223
  }
207
224
 
@@ -282,6 +299,7 @@ export const upgradeWebSocket: UpgradeWebSocket<WebSocketLike, UpgradeWebSocketO
282
299
  let ws: WebSocketLike
283
300
  try {
284
301
  ws = await waitForWebSocket(env.incoming, connectionSymbol)
302
+ options?.track?.(ws)
285
303
  } catch {
286
304
  return
287
305
  }
@@ -296,13 +314,17 @@ export const upgradeWebSocket: UpgradeWebSocket<WebSocketLike, UpgradeWebSocketO
296
314
  const shape: Pick<WSContext<WebSocketLike>, keyof WSContext<WebSocketLike>> = {
297
315
  binaryType: 'arraybuffer',
298
316
  close(code, reason) {
299
- ws.close(code, reason)
317
+ // An application code (1013, 4404, ...) passes; one the protocol forbids on the wire would make `ws` throw, so it becomes 1011.
318
+ ws.close(code === undefined || validCloseCode(code) ? code : 1011, reason)
300
319
  },
301
320
  protocol: ws.protocol,
302
321
  raw: ws,
303
322
  get readyState() {
304
323
  return ws.readyState
305
324
  },
325
+ get bufferedAmount() {
326
+ return ws.bufferedAmount
327
+ },
306
328
  send(source, opts) {
307
329
  ws.send(source, {
308
330
  compress: opts?.compress,
@@ -319,6 +341,8 @@ export const upgradeWebSocket: UpgradeWebSocket<WebSocketLike, UpgradeWebSocketO
319
341
  }
320
342
 
321
343
  const handleMessage = (data: WebSocketData, isBinary: boolean) => {
344
+ // With `textOnly` a binary frame never reaches the handler.
345
+ if (isBinary && options?.textOnly) return
322
346
  const datas = Array.isArray(data) ? data : [data]
323
347
  for (const data of datas) {
324
348
  try {
@@ -349,7 +373,11 @@ export const upgradeWebSocket: UpgradeWebSocket<WebSocketLike, UpgradeWebSocketO
349
373
  handleMessage(data, isBinary)
350
374
  })
351
375
 
376
+ let closed = false
352
377
  ws.on('close', (code, reason) => {
378
+ // The handler sees close exactly once, whoever closed the socket.
379
+ if (closed) return
380
+ closed = true
353
381
  try {
354
382
  events?.onClose?.(new CloseEvent('close', { code, reason: reason.toString() }), ctx)
355
383
  } catch (e) {
@@ -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
  }