acrylblends 0.9.2 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js.map +1 -1
- package/docs/framework/cordis/node-server.md +25 -3
- package/lib/bun.d.ts +1 -1
- package/lib/cloudflare-workers.d.ts +1 -1
- package/lib/cordis/door.d.ts +2 -1
- package/lib/cordis/door.js +7 -4
- package/lib/cordis/inspect.js +1 -1
- package/lib/cordis/node-server.d.ts +14 -2
- package/lib/cordis/node-server.js +27 -3
- package/lib/cordis/router-http.d.ts +25 -2
- package/lib/cordis/router-http.js +54 -6
- package/lib/cordis/router.d.ts +1 -1
- package/lib/cordis/router.js +1 -1
- package/lib/cordis/state.js +1 -1
- package/lib/door/host.js +1 -1
- package/lib/{index-BvFh1Gfx.d.ts → index-C-SklY6u.d.ts} +3 -0
- package/lib/{index-BqVQkTnu.d.ts → index-D4nVR3wu.d.ts} +18 -3
- package/lib/node-server.d.ts +2 -38
- package/lib/node-server.js +2 -2
- package/lib/{snapshot-text-D3NspDyT.js → snapshot-text-yaWlezbd.js} +1 -1
- package/lib/{src-CwABFr6r.js → src-DNzw0Qsb.js} +3 -2
- package/lib/test-websocket-DyeS50Tr.d.ts +136 -0
- package/lib/{server-DvgMGtzo.js → test-websocket-ZXHNHQSU.js} +225 -3
- package/lib/ws.d.ts +1 -1
- package/lib/ws.js +3 -0
- package/package.json +6 -2
- package/plugins/acryl-door/src/host/door.ts +5 -3
- package/plugins/acryl-door/src/index.ts +1 -1
- package/plugins/acryl-node-server/plugin.json +1 -1
- package/plugins/acryl-node-server/src/adapter/websocket-types.ts +5 -1
- package/plugins/acryl-node-server/src/adapter/websocket.ts +33 -5
- package/plugins/acryl-node-server/src/index.ts +35 -1
- package/plugins/acryl-node-server/src/test-websocket.ts +110 -0
- package/plugins/acryl-node-server/src/upgrade-hub.ts +152 -0
- package/plugins/acryl-router/src/core/helper/websocket/index.ts +5 -0
- package/plugins/acryl-router/src/service.ts +18 -4
- package/plugins/acryl-router-http/plugin.json +2 -2
- package/plugins/acryl-router-http/src/index.ts +70 -6
- package/plugins/agent-panel/client/main.ts +70 -2
- package/plugins/agent-panel/drivers.ts +83 -0
- package/plugins/agent-panel/headless.ts +154 -0
- package/plugins/agent-panel/index.ts +42 -125
- package/plugins/agent-panel/plugin.json +1 -1
- package/plugins/driver-pty/client/terminal.ts +188 -0
- package/plugins/driver-pty/client/vendor/LICENSE +21 -0
- package/plugins/driver-pty/client/vendor/LICENSE-addon-fit +19 -0
- package/plugins/driver-pty/client/vendor/VERSION +4 -0
- package/plugins/driver-pty/client/vendor/addon-fit.d.mts +6 -0
- package/plugins/driver-pty/client/vendor/addon-fit.mjs +18 -0
- package/plugins/driver-pty/client/vendor/xterm-css.d.mts +2 -0
- package/plugins/driver-pty/client/vendor/xterm-css.mjs +2 -0
- package/plugins/driver-pty/client/vendor/xterm.d.mts +17 -0
- package/plugins/driver-pty/client/vendor/xterm.mjs +53 -0
- package/plugins/driver-pty/index.ts +249 -0
- package/plugins/driver-pty/plugin.json +13 -0
- package/plugins/driver-pty/spawn.ts +36 -0
- package/plugins/driver-pty/terminal.ts +84 -0
- package/shared/AGENTS.core.md +1 -1
- package/shared/DEFAULTS.md +1 -1
- package/lib/error-t4N2js2I.d.ts +0 -8
|
@@ -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")
|
|
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
|
-
|
|
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-
|
|
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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "acrylblends",
|
|
3
|
-
"version": "0.
|
|
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
|
|
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"
|
|
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
|
|
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:
|
|
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
|
-
|
|
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
|
}
|