acrylblends 1.0.7 → 1.0.8
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 +6 -1
- package/dist/index.js.map +1 -1
- package/lib/cordis/door.js +73 -2
- package/lib/door/page.d.ts +16 -0
- package/lib/door/page.js +81 -4
- package/package.json +1 -1
- package/plugins/acryl-door/plugin.json +3 -0
- package/plugins/acryl-door/src/host/page-socket.ts +78 -0
- package/plugins/acryl-door/src/index.ts +10 -0
- package/plugins/acryl-door/src/page/channel.ts +47 -5
- package/plugins/acryl-door/src/page/index.ts +9 -1
- package/plugins/acryl-door/src/page/socket-source.ts +75 -0
- package/plugins/agent-panel/client/main.ts +125 -18
- package/plugins/agent-panel/headless.ts +20 -0
- package/plugins/agent-panel/index.ts +190 -50
- package/plugins/desktop/main.ts +50 -3
- package/plugins/desktop/menu.ts +60 -0
- package/plugins/desktop/window.ts +48 -2
- package/templates/agent/AGENTS.md +3 -1
- package/templates/agent/src/routes.ts +11 -1
- package/templates/agent/test/app.test.ts +20 -0
package/lib/cordis/door.js
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { t as Acryl } from "../app-CQcGWjrk.js";
|
|
2
2
|
import { r as streamSSE } from "../streaming-BlDaph8U.js";
|
|
3
|
+
import { r as loopbackAuthority } from "../loopback-only-BbasoF7R.js";
|
|
3
4
|
import "../helpers-BEjcdbgw.js";
|
|
4
5
|
import { n as clientModules } from "../client-modules-drj4mjhj.js";
|
|
5
|
-
import { a as MAX_WAIT_MS, l as UiControlError, p as parseUiRequest, r as MAX_SNAPSHOT_NODES } from "../protocol-CQ_if7Dg.js";
|
|
6
|
+
import { a as MAX_WAIT_MS, d as parseChannelToHost, l as UiControlError, p as parseUiRequest, r as MAX_SNAPSHOT_NODES, u as WINDOW_ID } from "../protocol-CQ_if7Dg.js";
|
|
6
7
|
import { a as INVALID_JSON, c as operatorOnly, d as AuditLog, f as auditPath, i as DOOR_PREFIX, l as privateApi, o as error, p as UiChannel, r as pageChannelRoutes, s as jsonBody, t as renderSnapshot, u as readJson } from "../snapshot-text-m13Pk-AI.js";
|
|
7
8
|
import "../src-BTBm4OPD.js";
|
|
9
|
+
import "../middleware-BfG_fp8P.js";
|
|
8
10
|
import { isAbsolute, join, relative, resolve } from "node:path";
|
|
9
11
|
import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
10
12
|
import z from "@deepseek-ai/schemastery";
|
|
@@ -1153,6 +1155,66 @@ function operatorRoutes(prefix, secret, door, reportError) {
|
|
|
1153
1155
|
}
|
|
1154
1156
|
});
|
|
1155
1157
|
}
|
|
1158
|
+
/** The upgrade request comes from this app's own page: a loopback peer on this port, and an `Origin` that is exactly this app's origin. Nothing else is read as proof. */
|
|
1159
|
+
const ownOrigin = async (c, next) => {
|
|
1160
|
+
const authority = loopbackAuthority(c);
|
|
1161
|
+
const origin = c.req.header("origin");
|
|
1162
|
+
const site = c.req.header("sec-fetch-site");
|
|
1163
|
+
if (authority === void 0 || origin === void 0 || origin !== authority.origin || site !== void 0 && site !== "same-origin") return c.json(error("only this app's own page may open the channel"), 403);
|
|
1164
|
+
const windowId = c.req.query("window");
|
|
1165
|
+
if (windowId === void 0 || !WINDOW_ID.test(windowId)) return c.json(error("a window id is required"), 400);
|
|
1166
|
+
await next();
|
|
1167
|
+
};
|
|
1168
|
+
function pageSocketRoutes(channel, options) {
|
|
1169
|
+
const heartbeatMs = options.heartbeatMs ?? 15e3;
|
|
1170
|
+
return new Acryl().get(`${options.prefix}/channel/ws`, ownOrigin, options.upgrade((c) => {
|
|
1171
|
+
const windowId = c.req.query("window");
|
|
1172
|
+
const focused = c.req.query("focused") === "1";
|
|
1173
|
+
const shellQuery = Number(c.req.query("shell"));
|
|
1174
|
+
const shellWindow = Number.isSafeInteger(shellQuery) && shellQuery > 0 ? shellQuery : void 0;
|
|
1175
|
+
let detach;
|
|
1176
|
+
let beat;
|
|
1177
|
+
return {
|
|
1178
|
+
onOpen(_event, ws) {
|
|
1179
|
+
const write = (frame) => {
|
|
1180
|
+
ws.send(JSON.stringify(frame));
|
|
1181
|
+
};
|
|
1182
|
+
detach = channel.attach(windowId, focused, {
|
|
1183
|
+
send: (data) => {
|
|
1184
|
+
write({
|
|
1185
|
+
e: "call",
|
|
1186
|
+
d: data
|
|
1187
|
+
});
|
|
1188
|
+
},
|
|
1189
|
+
close: () => {
|
|
1190
|
+
ws.close(1e3, "replaced");
|
|
1191
|
+
}
|
|
1192
|
+
}, shellWindow);
|
|
1193
|
+
write({ e: "ready" });
|
|
1194
|
+
beat = setInterval(() => {
|
|
1195
|
+
try {
|
|
1196
|
+
write({ e: "ping" });
|
|
1197
|
+
} catch {}
|
|
1198
|
+
}, heartbeatMs);
|
|
1199
|
+
},
|
|
1200
|
+
onMessage(event) {
|
|
1201
|
+
if (typeof event.data !== "string") return;
|
|
1202
|
+
let message;
|
|
1203
|
+
try {
|
|
1204
|
+
message = JSON.parse(event.data);
|
|
1205
|
+
} catch {
|
|
1206
|
+
return;
|
|
1207
|
+
}
|
|
1208
|
+
const parsed = parseChannelToHost(message);
|
|
1209
|
+
if (parsed !== null) channel.receive(windowId, parsed);
|
|
1210
|
+
},
|
|
1211
|
+
onClose() {
|
|
1212
|
+
if (beat !== void 0) clearInterval(beat);
|
|
1213
|
+
detach?.();
|
|
1214
|
+
}
|
|
1215
|
+
};
|
|
1216
|
+
}, { textOnly: true }));
|
|
1217
|
+
}
|
|
1156
1218
|
/** The oldest Node the client runs on (the framework's own floor). */
|
|
1157
1219
|
const MIN_NODE = [22, 19];
|
|
1158
1220
|
/**
|
|
@@ -1363,7 +1425,16 @@ function apply(ctx, rawConfig = {}) {
|
|
|
1363
1425
|
const router = ctx.router;
|
|
1364
1426
|
if (typeof router.wrap === "function") router.wrap(addScript);
|
|
1365
1427
|
else ctx.router.route("/", new Acryl().use("*", addScript));
|
|
1366
|
-
|
|
1428
|
+
let upgrade;
|
|
1429
|
+
try {
|
|
1430
|
+
upgrade = ctx.nodeServer?.upgrades?.();
|
|
1431
|
+
} catch (cause) {
|
|
1432
|
+
reportError("open the page channel over a WebSocket (the pages use the event stream)", cause);
|
|
1433
|
+
}
|
|
1434
|
+
ctx.router.route("/", privateApi(DOOR_PREFIX, pageModule, pageChannelRoutes(channel, { prefix: DOOR_PREFIX }), ...upgrade === void 0 ? [] : [pageSocketRoutes(channel, {
|
|
1435
|
+
prefix: DOOR_PREFIX,
|
|
1436
|
+
upgrade
|
|
1437
|
+
})], shellRoutes(DOOR_PREFIX, secret, shell), operatorRoutes(DOOR_PREFIX, secret, door, reportError), mcpRoutes(DOOR_PREFIX, secret, door, reportError)));
|
|
1367
1438
|
ctx.inject(["tools"], (inner) => {
|
|
1368
1439
|
inner.effect(() => registerChatTools(inner.get("tools"), door), "acryl-door: the verbs in the tools registry");
|
|
1369
1440
|
});
|
package/lib/door/page.d.ts
CHANGED
|
@@ -55,6 +55,12 @@ declare class UiDriver {
|
|
|
55
55
|
private emit;
|
|
56
56
|
}
|
|
57
57
|
//#endregion
|
|
58
|
+
//#region ../../plugins/acryl-door/src/page/socket-source.d.ts
|
|
59
|
+
interface SocketSource extends ChannelSource {
|
|
60
|
+
/** Send one message up; false when the socket is not open (the caller posts it instead). */
|
|
61
|
+
send(text: string): boolean;
|
|
62
|
+
}
|
|
63
|
+
//#endregion
|
|
58
64
|
//#region ../../plugins/acryl-door/src/page/channel.d.ts
|
|
59
65
|
/** The slice of an `EventSource` the channel uses, so tests can supply their own. */
|
|
60
66
|
interface ChannelSource {
|
|
@@ -73,6 +79,11 @@ interface PageChannelOptions {
|
|
|
73
79
|
/** Where the door's routes are, e.g. `/api/acryl-door`. */
|
|
74
80
|
readonly base: string;
|
|
75
81
|
readonly createSource?: (url: string) => ChannelSource;
|
|
82
|
+
/** Opens the WebSocket form of the channel (`<base>/channel/ws`); the browser's own by default. Pass `transport: 'stream'` to keep to the event stream. */
|
|
83
|
+
readonly createSocket?: (url: string) => SocketSource;
|
|
84
|
+
readonly transport?: 'auto' | 'stream';
|
|
85
|
+
/** Where the page says it fell back to the event stream (the console by default). */
|
|
86
|
+
readonly warn?: (message: string) => void;
|
|
76
87
|
readonly post?: (url: string, body: string) => Promise<unknown>;
|
|
77
88
|
readonly silenceMs?: number;
|
|
78
89
|
readonly now?: () => number;
|
|
@@ -82,6 +93,9 @@ declare class PageChannel {
|
|
|
82
93
|
private reconnectTimer;
|
|
83
94
|
private watchdog;
|
|
84
95
|
private backoff;
|
|
96
|
+
/** The WebSocket form is tried first; once it fails to open (a proxy that cannot upgrade, no `ws` on the server) the page keeps to the event stream. */
|
|
97
|
+
private socketAllowed;
|
|
98
|
+
private socketReady;
|
|
85
99
|
private lastHeard;
|
|
86
100
|
private disposed;
|
|
87
101
|
private readonly now;
|
|
@@ -89,6 +103,8 @@ declare class PageChannel {
|
|
|
89
103
|
private readonly options;
|
|
90
104
|
constructor(driver: UiDriver, options: PageChannelOptions);
|
|
91
105
|
connect(): void;
|
|
106
|
+
/** The WebSocket did not open: use the event stream from now on, and say why once. */
|
|
107
|
+
private fallBack;
|
|
92
108
|
/** Tell the Host whether this window is the one in front. */
|
|
93
109
|
reportFocus(focused: boolean): void;
|
|
94
110
|
/**
|
package/lib/door/page.js
CHANGED
|
@@ -1,4 +1,54 @@
|
|
|
1
1
|
import { a as MAX_WAIT_MS, f as parseChannelToPage, l as UiControlError, o as MUTATING_OPS, r as MAX_SNAPSHOT_NODES } from "../protocol-CQ_if7Dg.js";
|
|
2
|
+
//#region ../../plugins/acryl-door/src/page/socket-source.ts
|
|
3
|
+
function socketSource(socket) {
|
|
4
|
+
const listeners = /* @__PURE__ */ new Map();
|
|
5
|
+
let closed = false;
|
|
6
|
+
const source = {
|
|
7
|
+
onerror: null,
|
|
8
|
+
get readyState() {
|
|
9
|
+
return closed || socket.readyState >= 2 ? 2 : socket.readyState;
|
|
10
|
+
},
|
|
11
|
+
addEventListener(type, listener) {
|
|
12
|
+
listeners.set(type, [...listeners.get(type) ?? [], listener]);
|
|
13
|
+
},
|
|
14
|
+
send(text) {
|
|
15
|
+
if (socket.readyState !== 1) return false;
|
|
16
|
+
try {
|
|
17
|
+
socket.send(text);
|
|
18
|
+
return true;
|
|
19
|
+
} catch {
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
},
|
|
23
|
+
close() {
|
|
24
|
+
closed = true;
|
|
25
|
+
socket.onmessage = null;
|
|
26
|
+
socket.onerror = null;
|
|
27
|
+
socket.onclose = null;
|
|
28
|
+
try {
|
|
29
|
+
socket.close(1e3, "page left");
|
|
30
|
+
} catch {}
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
socket.onmessage = (event) => {
|
|
34
|
+
if (typeof event.data !== "string") return;
|
|
35
|
+
let frame;
|
|
36
|
+
try {
|
|
37
|
+
frame = JSON.parse(event.data);
|
|
38
|
+
} catch {
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
if (typeof frame.e !== "string") return;
|
|
42
|
+
for (const listener of listeners.get(frame.e) ?? []) listener({ data: typeof frame.d === "string" ? frame.d : "" });
|
|
43
|
+
};
|
|
44
|
+
const lost = (event) => {
|
|
45
|
+
if (!closed) source.onerror?.(event);
|
|
46
|
+
};
|
|
47
|
+
socket.onerror = lost;
|
|
48
|
+
socket.onclose = lost;
|
|
49
|
+
return source;
|
|
50
|
+
}
|
|
51
|
+
//#endregion
|
|
2
52
|
//#region ../../plugins/acryl-door/src/page/channel.ts
|
|
3
53
|
/**
|
|
4
54
|
* The page's end of the door's channel.
|
|
@@ -18,6 +68,9 @@ var PageChannel = class {
|
|
|
18
68
|
reconnectTimer;
|
|
19
69
|
watchdog;
|
|
20
70
|
backoff = BACKOFF_START_MS;
|
|
71
|
+
/** The WebSocket form is tried first; once it fails to open (a proxy that cannot upgrade, no `ws` on the server) the page keeps to the event stream. */
|
|
72
|
+
socketAllowed;
|
|
73
|
+
socketReady = false;
|
|
21
74
|
lastHeard = 0;
|
|
22
75
|
disposed = false;
|
|
23
76
|
now;
|
|
@@ -27,32 +80,49 @@ var PageChannel = class {
|
|
|
27
80
|
this.driver = driver;
|
|
28
81
|
this.options = options;
|
|
29
82
|
this.now = options.now ?? (() => Date.now());
|
|
83
|
+
this.socketAllowed = options.transport !== "stream" && (options.createSocket !== void 0 || options.createSource === void 0 && typeof WebSocket !== "undefined");
|
|
30
84
|
}
|
|
31
85
|
connect() {
|
|
32
86
|
if (this.disposed || this.source !== void 0) return;
|
|
33
|
-
const create = this.options.createSource ?? ((url) => new EventSource(url));
|
|
34
87
|
const query = `window=${encodeURIComponent(this.options.windowId)}&focused=${this.options.focused() ? "1" : "0"}${this.options.shellWindow === void 0 ? "" : `&shell=${String(this.options.shellWindow)}`}`;
|
|
35
|
-
const
|
|
88
|
+
const viaSocket = this.socketAllowed;
|
|
89
|
+
this.socketReady = false;
|
|
90
|
+
const source = viaSocket ? (this.options.createSocket ?? ((url) => socketSource(new WebSocket(url))))(`${socketBase(this.options.base)}/channel/ws?${query}`) : (this.options.createSource ?? ((url) => new EventSource(url)))(`${this.options.base}/channel?${query}`);
|
|
36
91
|
this.source = source;
|
|
37
92
|
this.lastHeard = this.now();
|
|
38
93
|
const heard = () => {
|
|
39
94
|
this.lastHeard = this.now();
|
|
40
95
|
this.backoff = BACKOFF_START_MS;
|
|
41
96
|
};
|
|
42
|
-
source.addEventListener("ready",
|
|
97
|
+
source.addEventListener("ready", () => {
|
|
98
|
+
this.socketReady = true;
|
|
99
|
+
heard();
|
|
100
|
+
});
|
|
43
101
|
source.addEventListener("ping", heard);
|
|
44
102
|
source.addEventListener("call", (event) => {
|
|
45
103
|
heard();
|
|
46
104
|
if (typeof event.data === "string") this.answer(event.data);
|
|
47
105
|
});
|
|
48
106
|
source.onerror = () => {
|
|
49
|
-
if (source.readyState
|
|
107
|
+
if (source.readyState !== CLOSED) return;
|
|
108
|
+
if (viaSocket && !this.socketReady) this.fallBack(source);
|
|
109
|
+
else this.restart(source);
|
|
50
110
|
};
|
|
51
111
|
const silence = this.options.silenceMs ?? 45e3;
|
|
52
112
|
this.watchdog = setInterval(() => {
|
|
53
113
|
if (this.now() - this.lastHeard > silence) this.restart(source);
|
|
54
114
|
}, Math.max(50, Math.floor(silence / 3)));
|
|
55
115
|
}
|
|
116
|
+
/** The WebSocket did not open: use the event stream from now on, and say why once. */
|
|
117
|
+
fallBack(source) {
|
|
118
|
+
if (this.source !== source) return;
|
|
119
|
+
this.socketAllowed = false;
|
|
120
|
+
(this.options.warn ?? ((message) => {
|
|
121
|
+
console.warn(message);
|
|
122
|
+
}))("acryl-door: the page channel could not open a WebSocket (the server has no upgrade route, or a proxy refused it); using server-sent events, which count against the browser's six connections per host");
|
|
123
|
+
this.stop();
|
|
124
|
+
this.connect();
|
|
125
|
+
}
|
|
56
126
|
/** Tell the Host whether this window is the one in front. */
|
|
57
127
|
reportFocus(focused) {
|
|
58
128
|
this.send({
|
|
@@ -130,6 +200,7 @@ var PageChannel = class {
|
|
|
130
200
|
});
|
|
131
201
|
}
|
|
132
202
|
send(message) {
|
|
203
|
+
if (this.source?.send?.(JSON.stringify(message)) === true) return;
|
|
133
204
|
(this.options.post ?? ((url, body) => fetch(url, {
|
|
134
205
|
method: "POST",
|
|
135
206
|
headers: { "content-type": "application/json" },
|
|
@@ -140,6 +211,12 @@ var PageChannel = class {
|
|
|
140
211
|
})).catch(() => {});
|
|
141
212
|
}
|
|
142
213
|
};
|
|
214
|
+
/** The WebSocket address of the door's routes: this page's own origin, `ws:` or `wss:`, then the base path. */
|
|
215
|
+
function socketBase(base) {
|
|
216
|
+
const here = globalThis.location;
|
|
217
|
+
if (here === void 0) return base;
|
|
218
|
+
return `${here.protocol === "https:" ? "wss" : "ws"}://${here.host}${base}`;
|
|
219
|
+
}
|
|
143
220
|
//#endregion
|
|
144
221
|
//#region ../../plugins/acryl-door/src/page/driver/sensitivity.ts
|
|
145
222
|
/**
|
package/package.json
CHANGED
|
@@ -8,6 +8,9 @@
|
|
|
8
8
|
"@deepseek-ai/cordis": "4.0.4",
|
|
9
9
|
"@deepseek-ai/schemastery": "3.18.4"
|
|
10
10
|
},
|
|
11
|
+
"devDependencies": {
|
|
12
|
+
"ws": "8.21.3"
|
|
13
|
+
},
|
|
11
14
|
"mount": "mount it after acryl-node-server and extensions in development builds: root.plugin(door); add <script type=\"module\" src=\"/api/acryl-door/page.js\"></script> to the pages; the command is .acryl/bin/acryl",
|
|
12
15
|
"gitignore": [".acryl/"],
|
|
13
16
|
"omit": ["**/*.test.*", "**/*.spec.*", "**/*.case.ts"],
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The page channel over a WebSocket. An event stream (`page-routes.ts`) holds one of the browser's six connections per host for as long as the page lives; two windows of an app that
|
|
3
|
+
* also stream their own state, with the panel following a run, use them all, and then no request of either window is sent (the door's answers, Stop). A WebSocket is not one of
|
|
4
|
+
* those six: it opens at once however many streams are open, and the page's answers travel on it too, so nothing is queued behind a stream.
|
|
5
|
+
*
|
|
6
|
+
* `GET <prefix>/channel/ws?window=<id>&focused=0|1[&shell=<n>]` upgrades. The messages are the same JSON as the stream's: down, `{ "e": "call", "d": "<the call>" }` and
|
|
7
|
+
* `{ "e": "ready" }` / `{ "e": "ping" }`; up, one {@link ChannelToHost} message per frame. A WebSocket has no CORS, so the `Origin` header is the boundary: it must be present and be
|
|
8
|
+
* exactly this app's own origin, or the upgrade is refused (403) before any handshake. A page on another port, a web site, and a program that sends no `Origin` are all refused.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { Acryl, type MiddlewareHandler } from 'acryl-router'
|
|
12
|
+
import type { UpgradeWebSocket } from 'acryl-router/helpers'
|
|
13
|
+
import { loopbackAuthority } from 'acryl-router/middleware'
|
|
14
|
+
import { parseChannelToHost, WINDOW_ID } from '../page/protocol.ts'
|
|
15
|
+
import type { UiChannel } from './channel.ts'
|
|
16
|
+
import { error } from './http.ts'
|
|
17
|
+
|
|
18
|
+
/** How often the Host writes a `ping`; the page gives up on a socket that is silent for three of these. */
|
|
19
|
+
export const SOCKET_HEARTBEAT_MS = 15_000
|
|
20
|
+
|
|
21
|
+
export interface PageSocketOptions {
|
|
22
|
+
readonly prefix: string
|
|
23
|
+
readonly upgrade: UpgradeWebSocket
|
|
24
|
+
readonly heartbeatMs?: number
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** The upgrade request comes from this app's own page: a loopback peer on this port, and an `Origin` that is exactly this app's origin. Nothing else is read as proof. */
|
|
28
|
+
export const ownOrigin: MiddlewareHandler = async (c, next) => {
|
|
29
|
+
const authority = loopbackAuthority(c)
|
|
30
|
+
const origin = c.req.header('origin')
|
|
31
|
+
const site = c.req.header('sec-fetch-site')
|
|
32
|
+
if (authority === undefined || origin === undefined || origin !== authority.origin || (site !== undefined && site !== 'same-origin')) {
|
|
33
|
+
return c.json(error('only this app\'s own page may open the channel'), 403)
|
|
34
|
+
}
|
|
35
|
+
const windowId = c.req.query('window')
|
|
36
|
+
if (windowId === undefined || !WINDOW_ID.test(windowId)) return c.json(error('a window id is required'), 400)
|
|
37
|
+
await next()
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function pageSocketRoutes(channel: UiChannel, options: PageSocketOptions): Acryl {
|
|
41
|
+
const heartbeatMs = options.heartbeatMs ?? SOCKET_HEARTBEAT_MS
|
|
42
|
+
return new Acryl().get(
|
|
43
|
+
`${options.prefix}/channel/ws`,
|
|
44
|
+
ownOrigin,
|
|
45
|
+
options.upgrade((c) => {
|
|
46
|
+
const windowId = c.req.query('window') as string
|
|
47
|
+
const focused = c.req.query('focused') === '1'
|
|
48
|
+
const shellQuery = Number(c.req.query('shell'))
|
|
49
|
+
const shellWindow = Number.isSafeInteger(shellQuery) && shellQuery > 0 ? shellQuery : undefined
|
|
50
|
+
let detach: (() => void) | undefined
|
|
51
|
+
let beat: NodeJS.Timeout | undefined
|
|
52
|
+
return {
|
|
53
|
+
onOpen(_event, ws) {
|
|
54
|
+
const write = (frame: { e: string; d?: string }): void => {
|
|
55
|
+
ws.send(JSON.stringify(frame))
|
|
56
|
+
}
|
|
57
|
+
// A call that cannot be written fails at once (`disconnected`), and a socket that is gone is dropped by its close: the door never holds a call for it.
|
|
58
|
+
detach = channel.attach(windowId, focused, { send: data => { write({ e: 'call', d: data }) }, close: () => { ws.close(1000, 'replaced') } }, shellWindow)
|
|
59
|
+
write({ e: 'ready' })
|
|
60
|
+
beat = setInterval(() => {
|
|
61
|
+
try { write({ e: 'ping' }) } catch { /* the close event follows */ }
|
|
62
|
+
}, heartbeatMs)
|
|
63
|
+
},
|
|
64
|
+
onMessage(event) {
|
|
65
|
+
if (typeof event.data !== 'string') return
|
|
66
|
+
let message: unknown
|
|
67
|
+
try { message = JSON.parse(event.data) } catch { return }
|
|
68
|
+
const parsed = parseChannelToHost(message)
|
|
69
|
+
if (parsed !== null) channel.receive(windowId, parsed)
|
|
70
|
+
},
|
|
71
|
+
onClose() {
|
|
72
|
+
if (beat !== undefined) clearInterval(beat)
|
|
73
|
+
detach?.()
|
|
74
|
+
},
|
|
75
|
+
}
|
|
76
|
+
}, { textOnly: true }),
|
|
77
|
+
)
|
|
78
|
+
}
|
|
@@ -13,6 +13,7 @@ import { join } from 'node:path'
|
|
|
13
13
|
import type { Context } from '@deepseek-ai/cordis'
|
|
14
14
|
import z from '@deepseek-ai/schemastery'
|
|
15
15
|
import { Acryl } from 'acryl-router'
|
|
16
|
+
import type { UpgradeWebSocket } from 'acryl-router/helpers'
|
|
16
17
|
import { clientModules } from 'acryl-node-server/client-modules'
|
|
17
18
|
import { auditPath, AuditLog } from './host/audit.ts'
|
|
18
19
|
import { UiChannel } from './host/channel.ts'
|
|
@@ -23,6 +24,7 @@ import { DOOR_PREFIX, privateApi } from './host/http.ts'
|
|
|
23
24
|
import { mcpRoutes } from './host/mcp-routes.ts'
|
|
24
25
|
import { operatorRoutes } from './host/operator-routes.ts'
|
|
25
26
|
import { pageChannelRoutes } from './host/page-routes.ts'
|
|
27
|
+
import { pageSocketRoutes } from './host/page-socket.ts'
|
|
26
28
|
import { ShellChannel, shellRoutes } from './host/shell.ts'
|
|
27
29
|
import { realNode, writeShims, type ShimTarget } from './host/shim.ts'
|
|
28
30
|
|
|
@@ -154,10 +156,18 @@ export function apply(ctx: Context, rawConfig: Config = {}): void {
|
|
|
154
156
|
// An app whose router copy is older than this plugin has no `wrap`: the middleware then covers the routes mounted after the door, as the agent panel's does.
|
|
155
157
|
if (typeof router.wrap === 'function') router.wrap(addScript)
|
|
156
158
|
else ctx.router.route('/', new Acryl().use('*', addScript as never))
|
|
159
|
+
// The page channel also runs over a WebSocket, which is not one of the browser's six connections per host. Without the `ws` package or an adapter that upgrades, the pages use the stream.
|
|
160
|
+
let upgrade: UpgradeWebSocket | undefined
|
|
161
|
+
try {
|
|
162
|
+
upgrade = (ctx as unknown as { nodeServer?: { upgrades?: () => UpgradeWebSocket } }).nodeServer?.upgrades?.()
|
|
163
|
+
} catch (cause) {
|
|
164
|
+
reportError('open the page channel over a WebSocket (the pages use the event stream)', cause)
|
|
165
|
+
}
|
|
157
166
|
ctx.router.route('/', privateApi(
|
|
158
167
|
DOOR_PREFIX,
|
|
159
168
|
pageModule,
|
|
160
169
|
pageChannelRoutes(channel, { prefix: DOOR_PREFIX }),
|
|
170
|
+
...(upgrade === undefined ? [] : [pageSocketRoutes(channel, { prefix: DOOR_PREFIX, upgrade })]),
|
|
161
171
|
shellRoutes(DOOR_PREFIX, secret, shell),
|
|
162
172
|
operatorRoutes(DOOR_PREFIX, secret, door, reportError),
|
|
163
173
|
mcpRoutes(DOOR_PREFIX, secret, door, reportError),
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
|
|
10
10
|
import { UiControlError, parseChannelToPage, type ChannelToHost } from './protocol.ts'
|
|
11
11
|
import type { UiDriver } from './driver/driver.ts'
|
|
12
|
+
import { type SocketLike, type SocketSource, socketSource } from './socket-source.ts'
|
|
12
13
|
|
|
13
14
|
/** The slice of an `EventSource` the channel uses, so tests can supply their own. */
|
|
14
15
|
export interface ChannelSource {
|
|
@@ -32,6 +33,11 @@ export interface PageChannelOptions {
|
|
|
32
33
|
/** Where the door's routes are, e.g. `/api/acryl-door`. */
|
|
33
34
|
readonly base: string
|
|
34
35
|
readonly createSource?: (url: string) => ChannelSource
|
|
36
|
+
/** Opens the WebSocket form of the channel (`<base>/channel/ws`); the browser's own by default. Pass `transport: 'stream'` to keep to the event stream. */
|
|
37
|
+
readonly createSocket?: (url: string) => SocketSource
|
|
38
|
+
readonly transport?: 'auto' | 'stream'
|
|
39
|
+
/** Where the page says it fell back to the event stream (the console by default). */
|
|
40
|
+
readonly warn?: (message: string) => void
|
|
35
41
|
readonly post?: (url: string, body: string) => Promise<unknown>
|
|
36
42
|
readonly silenceMs?: number
|
|
37
43
|
readonly now?: () => number
|
|
@@ -42,6 +48,9 @@ export class PageChannel {
|
|
|
42
48
|
private reconnectTimer: ReturnType<typeof setTimeout> | undefined
|
|
43
49
|
private watchdog: ReturnType<typeof setInterval> | undefined
|
|
44
50
|
private backoff = BACKOFF_START_MS
|
|
51
|
+
/** The WebSocket form is tried first; once it fails to open (a proxy that cannot upgrade, no `ws` on the server) the page keeps to the event stream. */
|
|
52
|
+
private socketAllowed: boolean
|
|
53
|
+
private socketReady = false
|
|
45
54
|
private lastHeard = 0
|
|
46
55
|
private disposed = false
|
|
47
56
|
private readonly now: () => number
|
|
@@ -53,33 +62,56 @@ export class PageChannel {
|
|
|
53
62
|
this.driver = driver
|
|
54
63
|
this.options = options
|
|
55
64
|
this.now = options.now ?? (() => Date.now())
|
|
65
|
+
// A test that supplies only a stream keeps to it; the browser's own WebSocket is tried when the page supplies neither.
|
|
66
|
+
this.socketAllowed = options.transport !== 'stream' && (options.createSocket !== undefined || (options.createSource === undefined && typeof WebSocket !== 'undefined'))
|
|
56
67
|
}
|
|
57
68
|
|
|
58
69
|
connect(): void {
|
|
59
70
|
if (this.disposed || this.source !== undefined) return
|
|
60
|
-
const create = this.options.createSource ?? ((url: string): ChannelSource => new EventSource(url))
|
|
61
71
|
const query = `window=${encodeURIComponent(this.options.windowId)}&focused=${this.options.focused() ? '1' : '0'}${this.options.shellWindow === undefined ? '' : `&shell=${String(this.options.shellWindow)}`}`
|
|
62
|
-
const
|
|
72
|
+
const viaSocket = this.socketAllowed
|
|
73
|
+
this.socketReady = false
|
|
74
|
+
const source: ChannelSource = viaSocket
|
|
75
|
+
? (this.options.createSocket ?? ((url: string): SocketSource => socketSource(new WebSocket(url) as unknown as SocketLike)))(`${socketBase(this.options.base)}/channel/ws?${query}`)
|
|
76
|
+
: (this.options.createSource ?? ((url: string): ChannelSource => new EventSource(url) as unknown as ChannelSource))(`${this.options.base}/channel?${query}`)
|
|
63
77
|
this.source = source
|
|
64
78
|
this.lastHeard = this.now()
|
|
65
79
|
const heard = (): void => {
|
|
66
80
|
this.lastHeard = this.now()
|
|
67
81
|
this.backoff = BACKOFF_START_MS
|
|
68
82
|
}
|
|
69
|
-
source.addEventListener('ready',
|
|
83
|
+
source.addEventListener('ready', () => {
|
|
84
|
+
this.socketReady = true
|
|
85
|
+
heard()
|
|
86
|
+
})
|
|
70
87
|
source.addEventListener('ping', heard)
|
|
71
88
|
source.addEventListener('call', (event) => {
|
|
72
89
|
heard()
|
|
73
90
|
if (typeof event.data === 'string') this.answer(event.data)
|
|
74
91
|
})
|
|
75
|
-
// An EventSource retries by itself after a network error; once it gives up (CLOSED), the channel starts over.
|
|
76
|
-
|
|
92
|
+
// An EventSource retries by itself after a network error; once it gives up (CLOSED), the channel starts over. A WebSocket that never became ready is not retried: the page
|
|
93
|
+
// falls back to the event stream, and says so once.
|
|
94
|
+
source.onerror = () => {
|
|
95
|
+
if (source.readyState !== CLOSED) return
|
|
96
|
+
if (viaSocket && !this.socketReady) this.fallBack(source)
|
|
97
|
+
else this.restart(source)
|
|
98
|
+
}
|
|
77
99
|
const silence = this.options.silenceMs ?? SILENCE_MS
|
|
78
100
|
this.watchdog = setInterval(() => {
|
|
79
101
|
if (this.now() - this.lastHeard > silence) this.restart(source)
|
|
80
102
|
}, Math.max(50, Math.floor(silence / 3)))
|
|
81
103
|
}
|
|
82
104
|
|
|
105
|
+
/** The WebSocket did not open: use the event stream from now on, and say why once. */
|
|
106
|
+
private fallBack(source: ChannelSource): void {
|
|
107
|
+
if (this.source !== source) return
|
|
108
|
+
this.socketAllowed = false
|
|
109
|
+
const warn = this.options.warn ?? ((message: string) => { console.warn(message) })
|
|
110
|
+
warn('acryl-door: the page channel could not open a WebSocket (the server has no upgrade route, or a proxy refused it); using server-sent events, which count against the browser\'s six connections per host')
|
|
111
|
+
this.stop()
|
|
112
|
+
this.connect()
|
|
113
|
+
}
|
|
114
|
+
|
|
83
115
|
/** Tell the Host whether this window is the one in front. */
|
|
84
116
|
reportFocus(focused: boolean): void {
|
|
85
117
|
this.send({ t: 'focus', focused })
|
|
@@ -149,9 +181,19 @@ export class PageChannel {
|
|
|
149
181
|
}
|
|
150
182
|
|
|
151
183
|
private send(message: ChannelToHost): void {
|
|
184
|
+
// On a WebSocket the answer goes up the socket: a request would queue behind the browser's six connections per host.
|
|
185
|
+
const source = this.source as Partial<SocketSource> | undefined
|
|
186
|
+
if (source?.send?.(JSON.stringify(message)) === true) return
|
|
152
187
|
const post = this.options.post ?? ((url: string, body: string) => fetch(url, { method: 'POST', headers: { 'content-type': 'application/json' }, body }))
|
|
153
188
|
post(`${this.options.base}/channel/answer`, JSON.stringify({ window: this.options.windowId, message })).catch(() => {
|
|
154
189
|
// The Host is gone or restarting; the call it made settles on its side.
|
|
155
190
|
})
|
|
156
191
|
}
|
|
157
192
|
}
|
|
193
|
+
|
|
194
|
+
/** The WebSocket address of the door's routes: this page's own origin, `ws:` or `wss:`, then the base path. */
|
|
195
|
+
function socketBase(base: string): string {
|
|
196
|
+
const here = (globalThis as { location?: { protocol: string; host: string } }).location
|
|
197
|
+
if (here === undefined) return base
|
|
198
|
+
return `${here.protocol === 'https:' ? 'wss' : 'ws'}://${here.host}${base}`
|
|
199
|
+
}
|
|
@@ -22,7 +22,15 @@ export function startDoor(document: Document, base: string = DOOR_BASE): () => v
|
|
|
22
22
|
const detachInput = clock.attach(document)
|
|
23
23
|
const removeIndicator = mountIndicator(driver, document)
|
|
24
24
|
const shellWindow = Number(/\bAcrylWindow\/(\d{1,9})\b/u.exec(view.navigator?.userAgent ?? '')?.[1])
|
|
25
|
-
|
|
25
|
+
// `localStorage.setItem('acryl-door-transport', 'stream')` keeps this browser to the event stream (to compare, or behind a proxy that cannot upgrade).
|
|
26
|
+
const transport = ((): 'auto' | 'stream' => {
|
|
27
|
+
try {
|
|
28
|
+
return view.localStorage.getItem('acryl-door-transport') === 'stream' ? 'stream' : 'auto'
|
|
29
|
+
} catch {
|
|
30
|
+
return 'auto'
|
|
31
|
+
}
|
|
32
|
+
})()
|
|
33
|
+
const channel = new PageChannel(driver, { windowId, focused, base, transport, ...(Number.isSafeInteger(shellWindow) && shellWindow > 0 ? { shellWindow } : {}) })
|
|
26
34
|
const reportFocus = (): void => { channel.reportFocus(focused()) }
|
|
27
35
|
view.addEventListener('focus', reportFocus)
|
|
28
36
|
view.addEventListener('blur', reportFocus)
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A WebSocket that looks like the event stream the channel already runs on: `addEventListener('call' | 'ready' | 'ping')`, `onerror`, `close()` and `readyState` (0 connecting, 1 open,
|
|
3
|
+
* 2 closed). Each frame from the Host is `{ "e": <event>, "d": <data> }`. It can also send, so the page's answers go up the same socket and need no request of their own: a request
|
|
4
|
+
* would wait behind the browser's six connections per host, which is what the socket is for.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { ChannelSource } from './channel.ts'
|
|
8
|
+
|
|
9
|
+
/** The slice of the browser's `WebSocket` used here, so a test can supply its own. */
|
|
10
|
+
export interface SocketLike {
|
|
11
|
+
readyState: number
|
|
12
|
+
onopen: ((event: Event) => void) | null
|
|
13
|
+
onmessage: ((event: MessageEvent) => void) | null
|
|
14
|
+
onerror: ((event: Event) => void) | null
|
|
15
|
+
onclose: ((event: CloseEvent) => void) | null
|
|
16
|
+
send(data: string): void
|
|
17
|
+
close(code?: number, reason?: string): void
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface SocketSource extends ChannelSource {
|
|
21
|
+
/** Send one message up; false when the socket is not open (the caller posts it instead). */
|
|
22
|
+
send(text: string): boolean
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function socketSource(socket: SocketLike): SocketSource {
|
|
26
|
+
const listeners = new Map<string, Array<(event: MessageEvent) => void>>()
|
|
27
|
+
let closed = false
|
|
28
|
+
const source: SocketSource = {
|
|
29
|
+
onerror: null,
|
|
30
|
+
get readyState(): number {
|
|
31
|
+
return closed || socket.readyState >= 2 ? 2 : socket.readyState
|
|
32
|
+
},
|
|
33
|
+
addEventListener(type, listener) {
|
|
34
|
+
listeners.set(type, [...(listeners.get(type) ?? []), listener])
|
|
35
|
+
},
|
|
36
|
+
send(text) {
|
|
37
|
+
if (socket.readyState !== 1) return false
|
|
38
|
+
try {
|
|
39
|
+
socket.send(text)
|
|
40
|
+
return true
|
|
41
|
+
} catch {
|
|
42
|
+
return false
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
close() {
|
|
46
|
+
closed = true
|
|
47
|
+
socket.onmessage = null
|
|
48
|
+
socket.onerror = null
|
|
49
|
+
socket.onclose = null
|
|
50
|
+
try {
|
|
51
|
+
socket.close(1000, 'page left')
|
|
52
|
+
} catch {
|
|
53
|
+
// Already closed.
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
}
|
|
57
|
+
socket.onmessage = (event) => {
|
|
58
|
+
if (typeof event.data !== 'string') return
|
|
59
|
+
let frame: { e?: unknown; d?: unknown }
|
|
60
|
+
try {
|
|
61
|
+
frame = JSON.parse(event.data) as { e?: unknown; d?: unknown }
|
|
62
|
+
} catch {
|
|
63
|
+
return
|
|
64
|
+
}
|
|
65
|
+
if (typeof frame.e !== 'string') return
|
|
66
|
+
for (const listener of listeners.get(frame.e) ?? []) listener({ data: typeof frame.d === 'string' ? frame.d : '' } as MessageEvent)
|
|
67
|
+
}
|
|
68
|
+
// An error or a close that this side did not ask for is the channel's cue to start over, or to fall back to the event stream.
|
|
69
|
+
const lost = (event: Event): void => {
|
|
70
|
+
if (!closed) source.onerror?.(event)
|
|
71
|
+
}
|
|
72
|
+
socket.onerror = lost
|
|
73
|
+
socket.onclose = lost
|
|
74
|
+
return source
|
|
75
|
+
}
|