acrylblends 1.1.0 → 1.3.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/README.md +9 -0
- package/dist/index.d.ts +47 -2
- package/dist/index.js +2017 -32
- package/dist/index.js.map +1 -1
- package/docs/framework/agents/index.md +1 -1
- package/docs/framework/getting-started/desktop.md +23 -4
- package/docs/framework/guides/create-acrylblends.md +5 -3
- package/docs/framework/workshop/README.md +139 -153
- package/docs/framework/workshop/facilitator.md +69 -101
- package/legacy/README.md +9 -0
- package/legacy/agent/157ce3db1112/src/app.ts +98 -0
- package/legacy/agent/157ce3db1112/src/routes.ts +63 -0
- package/legacy/agent/24c681a00fca/src/app.ts +160 -0
- package/legacy/agent/24c681a00fca/src/routes.ts +83 -0
- package/legacy/agent/2e790da977b2/src/app.ts +98 -0
- package/legacy/agent/2e790da977b2/src/routes.ts +63 -0
- package/legacy/agent/6307045545dc/src/app.ts +168 -0
- package/legacy/agent/6307045545dc/src/routes.ts +83 -0
- package/legacy/agent/6a5fb9c15c53/src/app.ts +158 -0
- package/legacy/agent/6a5fb9c15c53/src/routes.ts +83 -0
- package/legacy/agent/71fb54a5a942/src/app.ts +111 -0
- package/legacy/agent/71fb54a5a942/src/routes.ts +63 -0
- package/legacy/agent/74ece148269c/src/app.ts +168 -0
- package/legacy/agent/74ece148269c/src/routes.ts +93 -0
- package/legacy/agent/855359304707/src/app.ts +168 -0
- package/legacy/agent/855359304707/src/routes.ts +126 -0
- package/legacy/agent/b5ef3c786ae4/src/app.ts +127 -0
- package/legacy/agent/b5ef3c786ae4/src/routes.ts +76 -0
- package/legacy/agent/c2f30958043b/src/app.ts +98 -0
- package/legacy/agent/c2f30958043b/src/routes.ts +53 -0
- package/legacy/blank/5a91c558af22/src/routes.ts +18 -0
- package/legacy/blank/9c650cf52bf3/src/routes.ts +14 -0
- package/legacy/blank/aea589283445/src/routes.ts +16 -0
- package/legacy/blank/b2cbb759c889/src/routes.ts +16 -0
- package/legacy/blank/d6d371c9a65e/src/routes.ts +18 -0
- package/legacy/chat/7076cc821e2c/src/app.ts +72 -0
- package/legacy/chat/7076cc821e2c/src/routes.ts +13 -0
- package/legacy/chat/7dbe6d5170df/src/app.ts +107 -0
- package/legacy/chat/7dbe6d5170df/src/routes.ts +19 -0
- package/legacy/chat/a9a9ab44c58e/src/app.ts +107 -0
- package/legacy/chat/a9a9ab44c58e/src/routes.ts +15 -0
- package/legacy/chat/f20ad9a51b06/src/app.ts +106 -0
- package/legacy/chat/f20ad9a51b06/src/routes.ts +15 -0
- package/legacy/layouts.json +524 -0
- package/legacy/nodejs/1d477f37aaf4/src/app.ts +92 -0
- package/legacy/nodejs/1d477f37aaf4/src/routes.ts +40 -0
- package/legacy/nodejs/b1bd9462496c/src/app.ts +61 -0
- package/legacy/nodejs/b1bd9462496c/src/routes.ts +37 -0
- package/legacy/nodejs/d5bab75076ef/src/app.ts +95 -0
- package/legacy/nodejs/d5bab75076ef/src/routes.ts +40 -0
- package/legacy/notes/4edc36949c15/src/app.ts +107 -0
- package/legacy/notes/4edc36949c15/src/routes.ts +14 -0
- package/legacy/notes/6cab2d09747c/src/app.ts +72 -0
- package/legacy/notes/6cab2d09747c/src/routes.ts +12 -0
- package/legacy/notes/7ca2dfed623a/src/app.ts +106 -0
- package/legacy/notes/7ca2dfed623a/src/routes.ts +14 -0
- package/legacy/todo/02e613b3625c/src/app.ts +71 -0
- package/legacy/todo/02e613b3625c/src/todo.ts +123 -0
- package/legacy/todo/45694a8d9e15/src/app.ts +71 -0
- package/legacy/todo/45694a8d9e15/src/todo.ts +96 -0
- package/legacy/todo/5923d3a6099f/src/app.ts +71 -0
- package/legacy/todo/5923d3a6099f/src/todo.ts +125 -0
- package/legacy/todo/849a18d66dc1/src/app.ts +91 -0
- package/legacy/todo/849a18d66dc1/src/todo.ts +125 -0
- package/legacy/todo/8e64211312f3/src/app.ts +71 -0
- package/legacy/todo/8e64211312f3/src/todo.ts +125 -0
- package/legacy/todo/b79078a87565/src/app.ts +71 -0
- package/legacy/todo/b79078a87565/src/todo.ts +125 -0
- package/lib/{audit-BFpULBCj.d.ts → audit-Bok_UNCn.d.ts} +26 -3
- package/lib/{client-modules-drj4mjhj.js → client-modules-Bkdr83jO.js} +1 -1
- package/lib/cordis/door.d.ts +87 -9
- package/lib/cordis/door.js +324 -60
- package/lib/cordis/inspect.js +1 -1
- package/lib/cordis/node-server.d.ts +101 -3
- package/lib/cordis/node-server.js +3 -3
- package/lib/cordis/router-http.d.ts +7 -1
- package/lib/cordis/router-http.js +44 -3
- 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.d.ts +2 -2
- package/lib/door/host.js +1 -1
- package/lib/door/page.d.ts +10 -1
- package/lib/door/page.js +46 -7
- package/lib/door/protocol.d.ts +1 -1
- package/lib/drivers.d.ts +10 -1
- package/lib/{index-DMOtA-NJ.d.ts → index-DGq7H0lb.d.ts} +11 -1
- package/lib/node-server.d.ts +7 -1
- package/lib/node-server.js +2 -2
- package/lib/{protocol-a2aqrp2X.d.ts → protocol-BeIYCgTo.d.ts} +4 -0
- package/lib/{snapshot-text-BwM6T2nG.js → snapshot-text-CXTnjxGx.js} +87 -7
- package/lib/src-CtkIX_Gg.js +427 -0
- package/lib/{src-BTBm4OPD.js → src-iBJ2ysV9.js} +77 -14
- package/lib/{test-websocket-BLeEvybj.js → test-websocket-CQOSvBth.js} +17 -7
- package/lib/{test-websocket-DyeS50Tr.d.ts → test-websocket-GUTAkFQT.d.ts} +3 -1
- package/package.json +2 -1
- package/plugins/acryl-door/plugin.json +3 -0
- package/plugins/acryl-door/src/cli/acryl.ts +63 -6
- package/plugins/acryl-door/src/host/channel.ts +86 -9
- package/plugins/acryl-door/src/host/chat-tools.ts +8 -6
- package/plugins/acryl-door/src/host/door-file.ts +7 -2
- package/plugins/acryl-door/src/host/door.ts +128 -24
- package/plugins/acryl-door/src/host/operator-routes.ts +21 -1
- package/plugins/acryl-door/src/host/page-routes.ts +3 -1
- package/plugins/acryl-door/src/host/page-socket.ts +18 -8
- package/plugins/acryl-door/src/host/verbs.ts +14 -1
- package/plugins/acryl-door/src/index.ts +168 -30
- package/plugins/acryl-door/src/page/base.ts +32 -0
- package/plugins/acryl-door/src/page/channel.ts +3 -1
- package/plugins/acryl-door/src/page/driver/accessibility.ts +11 -0
- package/plugins/acryl-door/src/page/driver/actions.ts +4 -0
- package/plugins/acryl-door/src/page/driver/driver.ts +32 -4
- package/plugins/acryl-door/src/page/driver/snapshot.ts +5 -2
- package/plugins/acryl-door/src/page/index.ts +41 -2
- package/plugins/acryl-door/src/page/outline.ts +56 -0
- package/plugins/acryl-door/src/page/protocol.ts +4 -0
- package/plugins/acryl-door/src/page/snapshot-text.ts +2 -1
- package/plugins/acryl-node-server/src/adapter/listener.ts +4 -0
- package/plugins/acryl-node-server/src/adapter/raw.ts +18 -0
- package/plugins/acryl-node-server/src/adapter/websocket.ts +15 -9
- package/plugins/acryl-node-server/src/client-modules.ts +7 -1
- package/plugins/acryl-node-server/src/index.ts +304 -16
- package/plugins/acryl-node-server/src/upgrade-hub.ts +2 -2
- package/plugins/acryl-page/plugin.json +12 -0
- package/plugins/acryl-page/src/index.ts +163 -0
- package/plugins/acryl-router/src/service.ts +77 -14
- package/plugins/acryl-router-http/src/index.ts +15 -1
- package/plugins/acryl-router-http/src/serve.ts +31 -0
- package/plugins/acryl-web-server/plugin.json +14 -0
- package/plugins/acryl-web-server/src/index.ts +164 -0
- package/plugins/agent/agent.ts +38 -7
- package/plugins/agent/index.ts +1 -1
- package/plugins/agent/model.ts +10 -2
- package/plugins/agent/tools.ts +9 -3
- package/plugins/agent-panel/client/describe.ts +57 -0
- package/plugins/agent-panel/client/driver-pick.ts +22 -0
- package/plugins/agent-panel/client/frame.ts +51 -0
- package/plugins/agent-panel/client/main.ts +238 -26
- package/plugins/agent-panel/drivers.ts +7 -2
- package/plugins/agent-panel/headless.ts +5 -2
- package/plugins/agent-panel/index.ts +146 -57
- package/plugins/agent-panel/mount.ts +27 -0
- package/plugins/agent-panel/visitors.ts +231 -0
- package/plugins/builder/index.ts +13 -35
- package/plugins/builder/package.ts +186 -0
- package/plugins/builder/plugin.json +3 -1
- package/plugins/desktop/frame.ts +231 -0
- package/plugins/desktop/launch.ts +44 -9
- package/plugins/desktop/main.ts +100 -32
- package/plugins/desktop/window.ts +21 -4
- package/plugins/driver-pty/client/terminal.ts +4 -1
- package/plugins/driver-pty/index.ts +38 -11
- package/plugins/driver-pty/spawn.ts +38 -0
- package/plugins/driver-pty/terminal.ts +2 -0
- package/plugins/extensions/index.ts +47 -18
- package/plugins/extensions/plugin-store.ts +27 -0
- package/plugins/extensions/plugin.json +1 -1
- package/plugins/extensions/resolve.ts +13 -0
- package/plugins/extensions/stage.ts +15 -5
- package/plugins/feedback/client.d.ts +22 -0
- package/plugins/feedback/client.js +471 -0
- package/plugins/feedback/index.ts +143 -0
- package/plugins/feedback/plugin.json +11 -0
- package/plugins/feedback/store.ts +307 -0
- package/plugins/home/index.ts +122 -0
- package/plugins/home/plugin.json +11 -0
- package/plugins/instance/index.ts +6 -0
- package/plugins/manifest/app-plugins.ts +86 -0
- package/plugins/manifest/index.ts +12 -2
- package/plugins/manifest/plugin.json +1 -1
- package/plugins/manifest/snapshot.ts +275 -0
- package/plugins/openai/index.ts +64 -5
- package/plugins/openai/prompt.ts +23 -0
- package/plugins/plugin-share/README.md +51 -0
- package/plugins/plugin-share/index.ts +467 -0
- package/plugins/plugin-share/plugin.json +15 -0
- package/plugins/plugin-share/publish.ts +65 -0
- package/plugins/plugin-share/pull-check.ts +129 -0
- package/plugins/plugin-share/pulled-records.ts +70 -0
- package/shared/AGENTS.core.md +36 -16
- package/shared/skills/acryl-add-ui/SKILL.md +19 -0
- package/shared/skills/acryl-app/SKILL.md +13 -0
- package/shared/skills/acryl-build-plugin/SKILL.md +22 -0
- package/shared/skills/acryl-change-plugin/SKILL.md +14 -0
- package/shared/skills/acryl-diagnose-plugin/SKILL.md +13 -0
- package/shared/skills/acryl-fix-plugin/SKILL.md +13 -0
- package/shared/skills/acryl-remove-plugin/SKILL.md +12 -0
- package/shared/skills/acryl-share-plugin/SKILL.md +16 -0
- package/shared/skills/cordis-plugin/SKILL.md +26 -0
- package/templates/agent/.gitattributes +2 -0
- package/templates/agent/AGENTS.md +1 -1
- package/templates/agent/blend.yaml +6 -0
- package/templates/agent/gitignore +1 -0
- package/templates/agent/package.json +1 -0
- package/templates/agent/src/app.ts +154 -33
- package/templates/agent/src/frame.ts +17 -0
- package/templates/agent/src/main.ts +5 -3
- package/templates/agent/src/routes.ts +38 -19
- package/templates/agent/template.json +3 -0
- package/templates/agent/test/app.test.ts +45 -3
- package/templates/blank/.gitattributes +2 -0
- package/templates/blank/AGENTS.md +3 -2
- package/templates/blank/blend.yaml +11 -0
- package/templates/blank/gitignore +1 -0
- package/templates/blank/package.json +1 -0
- package/templates/blank/src/app.ts +100 -29
- package/templates/blank/src/door.ts +7 -0
- package/templates/blank/src/first-page.ts +5 -0
- package/templates/blank/src/frame.ts +17 -0
- package/templates/blank/src/routes.ts +65 -8
- package/templates/blank/template.json +14 -1
- package/templates/blank/test/app.test.ts +103 -16
- package/templates/chat/.gitattributes +2 -0
- package/templates/chat/blend.yaml +2 -1
- package/templates/chat/gitignore +1 -0
- package/templates/chat/package.json +1 -0
- package/templates/chat/src/app.ts +72 -22
- package/templates/chat/src/chat-app.ts +11 -2
- package/templates/chat/src/client/chat.tsx +4 -2
- package/templates/chat/src/frame.ts +17 -0
- package/templates/chat/src/page.tsx +6 -4
- package/templates/chat/src/routes.ts +14 -4
- package/templates/chat/src/runtime.ts +9 -3
- package/templates/chat/template.json +3 -1
- package/templates/chat/test/chat.test.ts +46 -1
- package/templates/chat/test/server.test.ts +17 -3
- package/templates/nodejs/.gitattributes +2 -0
- package/templates/nodejs/blend.yaml +2 -1
- package/templates/nodejs/gitignore +1 -0
- package/templates/nodejs/package.json +1 -0
- package/templates/nodejs/src/app.ts +76 -21
- package/templates/nodejs/src/frame.ts +17 -0
- package/templates/nodejs/src/page.tsx +3 -3
- package/templates/nodejs/src/routes.ts +32 -13
- package/templates/nodejs/template.json +3 -1
- package/templates/nodejs/test/app.test.ts +38 -3
- package/templates/nodejs/test/server.test.ts +17 -3
- package/templates/notes/.gitattributes +2 -0
- package/templates/notes/blend.yaml +2 -1
- package/templates/notes/gitignore +1 -0
- package/templates/notes/package.json +1 -0
- package/templates/notes/src/app.ts +72 -22
- package/templates/notes/src/frame.ts +17 -0
- package/templates/notes/src/notes-app.ts +24 -17
- package/templates/notes/src/routes.ts +11 -4
- package/templates/notes/src/runtime.ts +9 -3
- package/templates/notes/src/views.tsx +26 -17
- package/templates/notes/template.json +3 -1
- package/templates/notes/test/notes.test.ts +39 -1
- package/templates/notes/test/server.test.ts +17 -3
- package/templates/todo/.gitattributes +2 -0
- package/templates/todo/blend.yaml +3 -0
- package/templates/todo/gitignore +1 -0
- package/templates/todo/package.json +1 -0
- package/templates/todo/src/app.ts +81 -27
- package/templates/todo/src/door.ts +7 -0
- package/templates/todo/src/frame.ts +17 -0
- package/templates/todo/src/page.ts +13 -6
- package/templates/todo/src/todo.ts +18 -6
- package/templates/todo/template.json +11 -1
- package/templates/todo/test/todo.test.ts +67 -9
- package/lib/src-LX0E4ec4.js +0 -117
- package/shared/SKILL.md +0 -14
|
@@ -5,6 +5,8 @@ import { t as TrieRouter } from "./trie-router-JydLKUa2.js";
|
|
|
5
5
|
import { t as Acryl } from "./app-CQcGWjrk.js";
|
|
6
6
|
import "./core-BOu1jYua.js";
|
|
7
7
|
import "./connection-C7xgP1hP.js";
|
|
8
|
+
import { appendFileSync } from "node:fs";
|
|
9
|
+
import { tmpdir } from "node:os";
|
|
8
10
|
import { Service } from "@deepseek-ai/cordis";
|
|
9
11
|
import z from "@deepseek-ai/schemastery";
|
|
10
12
|
//#region ../../plugins/acryl-router/src/service.ts
|
|
@@ -17,6 +19,28 @@ import z from "@deepseek-ai/schemastery";
|
|
|
17
19
|
* response body still streaming and waits for all of it to settle.
|
|
18
20
|
*/
|
|
19
21
|
const DEFAULT_DISPOSE_GRACE_MS = 2e3;
|
|
22
|
+
/** How long disposal waits for the flights it had to abort after the drain, before it continues without them. */
|
|
23
|
+
const ABORT_SETTLE_MS = 300;
|
|
24
|
+
/** Set by ACRYL_ROUTER_TRACE=1: append request-signal lifecycle lines to ACRYL_ROUTER_TRACE_FILE (the CI artifact) - diagnostics for the abort-reaches-handler defect. */
|
|
25
|
+
const traceFile = () => process.env.ACRYL_ROUTER_TRACE_FILE ?? `${tmpdir()}/acryl-router-trace.log`;
|
|
26
|
+
const trace = (line) => {
|
|
27
|
+
if (process.env.ACRYL_ROUTER_TRACE !== "1") return;
|
|
28
|
+
try {
|
|
29
|
+
appendFileSync(traceFile(), `${String(Date.now())} [${String(process.pid)}] ${line}\n`);
|
|
30
|
+
} catch {}
|
|
31
|
+
};
|
|
32
|
+
let flightSeq = 0;
|
|
33
|
+
const SIGNAL_TAG = Symbol.for("acryl.router.signalTag");
|
|
34
|
+
const tagOf = (signal) => {
|
|
35
|
+
if (signal === void 0) return "none";
|
|
36
|
+
const tag = signal[SIGNAL_TAG];
|
|
37
|
+
return typeof tag === "string" ? tag : "untagged";
|
|
38
|
+
};
|
|
39
|
+
const tagSignal = (signal, tag) => {
|
|
40
|
+
try {
|
|
41
|
+
signal[SIGNAL_TAG] = tag;
|
|
42
|
+
} catch {}
|
|
43
|
+
};
|
|
20
44
|
/** Every field has a default, so a caller passes only what it changes: `ctx.plugin(plugin, { ... })` takes the partial shape. */
|
|
21
45
|
const Config = z.object({
|
|
22
46
|
trailingSlash: z.union(["strict", "ignore"]).default("strict").description("Whether `/a` and `/a/` are different paths."),
|
|
@@ -64,8 +88,8 @@ var Router = class extends Service {
|
|
|
64
88
|
};
|
|
65
89
|
ctx.effect(() => async () => {
|
|
66
90
|
this.state.closing = true;
|
|
67
|
-
await this.
|
|
68
|
-
}, "acryl-router:
|
|
91
|
+
await this.drain();
|
|
92
|
+
}, "acryl-router: drain in-flight requests, then abort what is left");
|
|
69
93
|
}
|
|
70
94
|
/** Registrations currently held (mounts, and the router-wide error and not-found handlers); a leak check for reload tests. */
|
|
71
95
|
get size() {
|
|
@@ -83,7 +107,7 @@ var Router = class extends Service {
|
|
|
83
107
|
const remove = () => {
|
|
84
108
|
if (!this.state.mounts.delete(mount)) return Promise.resolve();
|
|
85
109
|
this.state.composed = void 0;
|
|
86
|
-
return this.
|
|
110
|
+
return this.drainFlights([...this.flights].filter((flight) => (flight.running.get(mount) ?? 0) > 0 || flight.producer === mount), `removing ${path}`);
|
|
87
111
|
};
|
|
88
112
|
const served = new Set(app.routes.map((route) => `${route.method} ${mergePath(path, route.path)}`));
|
|
89
113
|
const owner = this.ctx;
|
|
@@ -125,6 +149,7 @@ var Router = class extends Service {
|
|
|
125
149
|
const handler = async (c, next) => {
|
|
126
150
|
const flight = this.flightOf.get(c.req.raw);
|
|
127
151
|
if (flight === void 0) return inner(c, next);
|
|
152
|
+
trace(`handler ${flight.tag} requestTag=${tagOf(c.req.raw.signal)} innerTag=${tagOf(flight.inner.signal)} sameSignal=${String(c.req.raw.signal === flight.inner.signal)} aborted=${String(c.req.raw.signal.aborted)}`);
|
|
128
153
|
let calledNext = false;
|
|
129
154
|
flight.running.set(mount, (flight.running.get(mount) ?? 0) + 1);
|
|
130
155
|
try {
|
|
@@ -145,22 +170,52 @@ var Router = class extends Service {
|
|
|
145
170
|
}
|
|
146
171
|
return mount;
|
|
147
172
|
}
|
|
148
|
-
/**
|
|
149
|
-
|
|
173
|
+
/**
|
|
174
|
+
* The router's own disposal: let the flights already in flight finish within `disposeGraceMs` - a save being written when the app stops must not be cut
|
|
175
|
+
* by the router beneath it - then abort what is left and settle it for a moment, so disposal does not wait a second full grace on code that ignores
|
|
176
|
+
* its abort signal.
|
|
177
|
+
*/
|
|
178
|
+
async drain() {
|
|
179
|
+
await this.drainFlights([...this.flights], "disposal");
|
|
180
|
+
}
|
|
181
|
+
/** The one draining rule: let `flights` finish inside the grace, then abort and settle what is left. */
|
|
182
|
+
async drainFlights(flights, why) {
|
|
183
|
+
trace(`drain ${why} chosen=[${flights.map((flight) => flight.tag).join(", ")}]`);
|
|
150
184
|
if (flights.length === 0) return;
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
185
|
+
if (await this.settledWithin(flights, this.graceMs)) {
|
|
186
|
+
trace(`drain ${why} settled inside the grace`);
|
|
187
|
+
return;
|
|
154
188
|
}
|
|
189
|
+
const left = flights.filter((flight) => this.flights.has(flight));
|
|
190
|
+
trace(`drain ${why} grace over left=[${left.map((flight) => flight.tag).join(", ")}]`);
|
|
191
|
+
if (left.length > 0) await this.end(left, why, ABORT_SETTLE_MS);
|
|
192
|
+
}
|
|
193
|
+
/** Resolve true when every flight settled inside `ms`, false at the deadline. */
|
|
194
|
+
async settledWithin(flights, ms) {
|
|
155
195
|
let timer;
|
|
156
196
|
const gaveUp = new Promise((resolve) => {
|
|
157
197
|
timer = setTimeout(() => {
|
|
158
|
-
resolve(
|
|
159
|
-
},
|
|
198
|
+
resolve(false);
|
|
199
|
+
}, ms);
|
|
160
200
|
});
|
|
161
|
-
const outcome = await Promise.race([Promise.allSettled(flights.map((flight) => flight.settled)).then(() =>
|
|
201
|
+
const outcome = await Promise.race([Promise.allSettled(flights.map((flight) => flight.settled)).then(() => true), gaveUp]);
|
|
162
202
|
clearTimeout(timer);
|
|
163
|
-
|
|
203
|
+
return outcome;
|
|
204
|
+
}
|
|
205
|
+
/** Abort `flights`, cut their bodies short and wait for them to settle, at most `settleMs`. */
|
|
206
|
+
async end(flights, why, settleMs = this.graceMs) {
|
|
207
|
+
if (flights.length === 0) return;
|
|
208
|
+
for (const flight of flights) {
|
|
209
|
+
trace(`abort ${flight.tag} before=${String(flight.controller.signal.aborted)}`);
|
|
210
|
+
flight.controller.abort();
|
|
211
|
+
trace(`abort ${flight.tag} after=${String(flight.controller.signal.aborted)} innerAborted=${String(flight.inner.signal.aborted)}`);
|
|
212
|
+
queueMicrotask(() => {
|
|
213
|
+
trace(`post-abort ${flight.tag} innerAborted=${String(flight.inner.signal.aborted)}`);
|
|
214
|
+
});
|
|
215
|
+
flight.endBody();
|
|
216
|
+
}
|
|
217
|
+
if (await this.settledWithin(flights, settleMs)) return;
|
|
218
|
+
this.ctx.logger.warn(`acryl-router: ${String(flights.length)} request(s) did not finish ${String(settleMs)} ms after abort (${why}); continued without them`);
|
|
164
219
|
}
|
|
165
220
|
/** Turn errors no mounted app handled into responses. One handler per router; the calling plugin owns it. */
|
|
166
221
|
onError(handler) {
|
|
@@ -203,7 +258,10 @@ var Router = class extends Service {
|
|
|
203
258
|
* `bindings` is an object an adapter hands to the handler as `c.env` (an upgrade request's socket hooks, for `upgradeWebSocket`); `connection` is added to it.
|
|
204
259
|
*/
|
|
205
260
|
fetch(request, connection = {}, bindings) {
|
|
206
|
-
if (this.state.closing) return Promise.resolve(new Response("Service Unavailable", {
|
|
261
|
+
if (this.state.closing) return Promise.resolve(new Response("Service Unavailable", {
|
|
262
|
+
status: 503,
|
|
263
|
+
headers: { connection: "close" }
|
|
264
|
+
}));
|
|
207
265
|
const controller = new AbortController();
|
|
208
266
|
const forward = () => controller.abort();
|
|
209
267
|
if (request.signal.aborted) controller.abort();
|
|
@@ -220,8 +278,13 @@ var Router = class extends Service {
|
|
|
220
278
|
let bodyDone = Promise.resolve();
|
|
221
279
|
let endBody = () => {};
|
|
222
280
|
let resolveSettled;
|
|
281
|
+
const tag = `f${String(++flightSeq)}`;
|
|
282
|
+
const inner = new Request(request, { signal: controller.signal });
|
|
283
|
+
tagSignal(inner.signal, tag);
|
|
223
284
|
const flight = {
|
|
224
285
|
controller,
|
|
286
|
+
inner,
|
|
287
|
+
tag,
|
|
225
288
|
running: /* @__PURE__ */ new Map(),
|
|
226
289
|
producer: void 0,
|
|
227
290
|
endBody: () => {
|
|
@@ -231,7 +294,7 @@ var Router = class extends Service {
|
|
|
231
294
|
resolveSettled = resolve;
|
|
232
295
|
})
|
|
233
296
|
};
|
|
234
|
-
|
|
297
|
+
trace(`create ${tag} ${request.method} ${new URL(request.url).pathname}`);
|
|
235
298
|
this.flightOf.set(inner, flight);
|
|
236
299
|
const response = this.dispatch(inner, env, executionCtx).then((raw) => {
|
|
237
300
|
const tracked = this.track(raw);
|
|
@@ -744,6 +744,12 @@ Object.defineProperty(Response$1, "json", {
|
|
|
744
744
|
configurable: true
|
|
745
745
|
});
|
|
746
746
|
//#endregion
|
|
747
|
+
//#region ../../plugins/acryl-node-server/src/adapter/raw.ts
|
|
748
|
+
/** Returned by a raw-aware fetch callback: the node response is already owned, do not write. */
|
|
749
|
+
const RAW_RESPONSE_HANDLED = Symbol.for("acryl-node-server.raw-response");
|
|
750
|
+
/** Set on an upgrade request a raw handler took over; the adapter's WebSocket setup skips it. */
|
|
751
|
+
const RAW_UPGRADE_HANDLED = Symbol.for("acryl-node-server.raw-upgrade");
|
|
752
|
+
//#endregion
|
|
747
753
|
//#region ../../plugins/acryl-node-server/src/adapter/utils.ts
|
|
748
754
|
async function readWithoutBlocking(readPromise) {
|
|
749
755
|
return Promise.race([readPromise, Promise.resolve().then(() => Promise.resolve(void 0))]);
|
|
@@ -1040,6 +1046,7 @@ const getRequestListener = (fetchCallback, options = {}) => {
|
|
|
1040
1046
|
incoming,
|
|
1041
1047
|
outgoing
|
|
1042
1048
|
});
|
|
1049
|
+
if (res === RAW_RESPONSE_HANDLED) return;
|
|
1043
1050
|
if (!isPromise(res) && isImmediateCacheableResponse(res)) {
|
|
1044
1051
|
if (needsBodyCleanup && !incoming.readableEnded) outgoing.once("finish", () => {
|
|
1045
1052
|
if (!incoming.readableEnded) drainIncoming(incoming);
|
|
@@ -1137,6 +1144,7 @@ const appendResponseHeaders = (headers, responseHeaders) => {
|
|
|
1137
1144
|
});
|
|
1138
1145
|
};
|
|
1139
1146
|
const rejectUpgradeRequest = (socket, status, responseHeaders) => {
|
|
1147
|
+
if (socket.destroyed || socket.writableEnded) return;
|
|
1140
1148
|
const responseLines = ["Connection: close", "Content-Length: 0"];
|
|
1141
1149
|
appendResponseHeaders(responseLines, responseHeaders);
|
|
1142
1150
|
socket.end(`HTTP/1.1 ${status.toString()} ${STATUS_CODES[status] ?? ""}\r\n${responseLines.join("\r\n")}\r\n\r
|
|
@@ -1154,7 +1162,7 @@ const createUpgradeRequest = (request) => {
|
|
|
1154
1162
|
return new Request(url, { headers });
|
|
1155
1163
|
};
|
|
1156
1164
|
const setupWebSocket = (options) => {
|
|
1157
|
-
const { server, fetchCallback, wss } = options;
|
|
1165
|
+
const { server, fetchCallback, wss, skipWhenRawUpgrade = false } = options;
|
|
1158
1166
|
const waiterMap = /* @__PURE__ */ new Map();
|
|
1159
1167
|
wss.on("connection", (ws, request) => {
|
|
1160
1168
|
const waiter = waiterMap.get(request);
|
|
@@ -1180,8 +1188,9 @@ const setupWebSocket = (options) => {
|
|
|
1180
1188
|
});
|
|
1181
1189
|
};
|
|
1182
1190
|
server.on("upgrade", async (request, socket, head) => {
|
|
1191
|
+
if (skipWhenRawUpgrade && request[RAW_UPGRADE_HANDLED] === true) return;
|
|
1183
1192
|
if (request.headers.upgrade?.toLowerCase() !== "websocket") {
|
|
1184
|
-
|
|
1193
|
+
rejectUpgradeRequest(socket, 400);
|
|
1185
1194
|
return;
|
|
1186
1195
|
}
|
|
1187
1196
|
const env = {
|
|
@@ -1199,13 +1208,13 @@ const setupWebSocket = (options) => {
|
|
|
1199
1208
|
responseHeaders = response.headers;
|
|
1200
1209
|
}
|
|
1201
1210
|
} catch {
|
|
1202
|
-
|
|
1211
|
+
rejectUpgradeRequest(socket, 500);
|
|
1203
1212
|
return;
|
|
1204
1213
|
}
|
|
1205
1214
|
const waiter = waiterMap.get(request);
|
|
1206
1215
|
if (!waiter || waiter.connectionSymbol !== env[CONNECTION_SYMBOL_KEY]) {
|
|
1207
1216
|
rejectWaiter(request);
|
|
1208
|
-
|
|
1217
|
+
rejectUpgradeRequest(socket, status, responseHeaders);
|
|
1209
1218
|
return;
|
|
1210
1219
|
}
|
|
1211
1220
|
const addResponseHeaders = (headers) => {
|
|
@@ -1389,7 +1398,7 @@ var UpgradeHub = class {
|
|
|
1389
1398
|
* 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
|
|
1390
1399
|
* facts (so the same loopback, origin and host checks work), and the upgrade bindings travel in `c.env` for `upgradeWebSocket`.
|
|
1391
1400
|
*/
|
|
1392
|
-
attach(source, router) {
|
|
1401
|
+
attach(source, router, options = {}) {
|
|
1393
1402
|
const fetchCallback = (request, env) => {
|
|
1394
1403
|
const { incoming } = env;
|
|
1395
1404
|
return router.fetch(request, {
|
|
@@ -1400,7 +1409,8 @@ var UpgradeHub = class {
|
|
|
1400
1409
|
setupWebSocket({
|
|
1401
1410
|
server: source,
|
|
1402
1411
|
fetchCallback,
|
|
1403
|
-
wss: this.server
|
|
1412
|
+
wss: this.server,
|
|
1413
|
+
...options
|
|
1404
1414
|
});
|
|
1405
1415
|
}
|
|
1406
1416
|
/** How many sockets are open. */
|
|
@@ -1541,4 +1551,4 @@ async function testWebSocket(fetch, path, options = {}) {
|
|
|
1541
1551
|
};
|
|
1542
1552
|
}
|
|
1543
1553
|
//#endregion
|
|
1544
|
-
export { serve as a,
|
|
1554
|
+
export { serve as a, RAW_RESPONSE_HANDLED as c, RequestError as d, createAdaptorServer as i, RAW_UPGRADE_HANDLED as l, UpgradeEmitter as n, upgradeWebSocket as o, UpgradeHub as r, getRequestListener as s, testWebSocket as t, Request$1 as u };
|
|
@@ -88,7 +88,9 @@ declare class UpgradeHub {
|
|
|
88
88
|
* 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
|
|
89
89
|
* facts (so the same loopback, origin and host checks work), and the upgrade bindings travel in `c.env` for `upgradeWebSocket`.
|
|
90
90
|
*/
|
|
91
|
-
attach(source: UpgradeSource, router: UpgradeRouter
|
|
91
|
+
attach(source: UpgradeSource, router: UpgradeRouter, options?: {
|
|
92
|
+
skipWhenRawUpgrade?: boolean;
|
|
93
|
+
}): void;
|
|
92
94
|
/** How many sockets are open. */
|
|
93
95
|
get size(): number;
|
|
94
96
|
/** `upgradeWebSocket` for the plugin `owner`: the sockets it accepts are closed with 1001 when that plugin unloads. */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "acrylblends",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.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",
|
|
@@ -405,6 +405,7 @@
|
|
|
405
405
|
"dist",
|
|
406
406
|
"lib",
|
|
407
407
|
"templates",
|
|
408
|
+
"legacy",
|
|
408
409
|
"shared",
|
|
409
410
|
"plugins",
|
|
410
411
|
"docs"
|
|
@@ -11,6 +11,9 @@
|
|
|
11
11
|
"devDependencies": {
|
|
12
12
|
"ws": "8.21.3"
|
|
13
13
|
},
|
|
14
|
+
"scripts": {
|
|
15
|
+
"acryl": "node plugins/acryl-door/src/cli/acryl.ts"
|
|
16
|
+
},
|
|
14
17
|
"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",
|
|
15
18
|
"gitignore": [".acryl/"],
|
|
16
19
|
"omit": ["**/*.test.*", "**/*.spec.*", "**/*.case.ts"],
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
|
-
* The door's operator client: what `<app>/.acryl/bin/acryl`
|
|
3
|
+
* The door's operator client: what `npm run acryl -- <verb>` and `<app>/.acryl/bin/acryl` run. It finds the app from the folder it is started in (the nearest
|
|
4
4
|
* `.acryl/door.json` upward, or `--dir`), never through PATH or a machine-wide registry, so it reaches only the app running from that
|
|
5
5
|
* folder. The secret is read from that file and sent as a `Bearer` header; it is never printed.
|
|
6
6
|
*
|
|
@@ -10,13 +10,14 @@
|
|
|
10
10
|
* Exit codes: 0 done, 1 the verb failed (its code and message are printed), 2 a usage error, 3 no running app in this folder.
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
+
import { createInterface } from 'node:readline/promises'
|
|
13
14
|
import { existsSync, realpathSync } from 'node:fs'
|
|
14
15
|
import { dirname, join, resolve } from 'node:path'
|
|
15
16
|
import { fileURLToPath } from 'node:url'
|
|
16
17
|
import { readDoorFile, STATE_DIR, DOOR_FILE } from '../host/door-file.ts'
|
|
17
18
|
|
|
18
19
|
const ACT_OPS = new Set(['click', 'type', 'select', 'press', 'scroll', 'wait', 'reload', 'navigate'])
|
|
19
|
-
const VALUED = new Set(['--dir', '--ref', '--text', '--option', '--key', '--direction', '--amount', '--cursor', '--max-nodes', '--role', '--name', '--timeout-ms', '--id', '--width', '--height', '--window', '--tool', '--args', '--path'])
|
|
20
|
+
const VALUED = new Set(['--dir', '--owner', '--license', '--ref', '--text', '--option', '--key', '--direction', '--amount', '--cursor', '--max-nodes', '--role', '--name', '--timeout-ms', '--id', '--width', '--height', '--window', '--tool', '--args', '--path'])
|
|
20
21
|
const FLAGS = new Set(['--json', '--submit', '--no-clear', '--gone', '--force', '--help'])
|
|
21
22
|
|
|
22
23
|
export class UsageError extends Error {}
|
|
@@ -42,7 +43,7 @@ const USAGE = `usage: acryl <verb> [options] [--json] [--dir <app folder>]
|
|
|
42
43
|
act navigate --path <path of the app, e.g. /demo>
|
|
43
44
|
act wait (--text <t> | --role <role> [--name <n>]) [--gone] [--timeout-ms <ms>]
|
|
44
45
|
(look and every act take --window <n>: the desktop window whose page it is for; window list prints the numbers)
|
|
45
|
-
plugin list | plugin add <folder> | plugin remove <name> | plugin reload
|
|
46
|
+
plugin list | plugin add <folder> | plugin pull <source> | plugin publish <name> [--owner <account>] [--license <id>] | plugin remove <name> | plugin reload
|
|
46
47
|
window list | window open [--path <p>] | window focus --id <n> | window close --id <n> [--force]
|
|
47
48
|
window resize --id <n> --width <px> --height <px>
|
|
48
49
|
menu list | menu click --path "File>New Note" [--force]
|
|
@@ -138,8 +139,15 @@ export function parseInvocation(argv: readonly string[], env: NodeJS.ProcessEnv
|
|
|
138
139
|
case 'plugin': {
|
|
139
140
|
if (second === 'list' || second === 'reload') return make(`plugin-${second}`)
|
|
140
141
|
if (second === 'add') { if (third === undefined) throw new UsageError('plugin add needs a folder, e.g. plugin add plugins/pomodoro'); return make('plugin-add', { folder: third }) }
|
|
142
|
+
if (second === 'pull') { if (third === undefined) throw new UsageError('plugin pull needs a source, e.g. plugin pull github:someone/pomodoro@0.1.0'); return make('plugin-pull', { source: third }) }
|
|
143
|
+
if (second === 'publish') {
|
|
144
|
+
if (third === undefined) throw new UsageError('plugin publish needs a plugin name, e.g. plugin publish pomodoro')
|
|
145
|
+
const owner = values.get('--owner')
|
|
146
|
+
const license = values.get('--license')
|
|
147
|
+
return make('plugin-publish', { name: third, ...(owner === undefined ? {} : { owner }), ...(license === undefined ? {} : { license }) })
|
|
148
|
+
}
|
|
141
149
|
if (second === 'remove') { if (third === undefined) throw new UsageError('plugin remove needs a plugin name'); return make('plugin-remove', { name: third }) }
|
|
142
|
-
throw new UsageError(`plugin needs list, add, remove or reload\n${USAGE}`)
|
|
150
|
+
throw new UsageError(`plugin needs list, add, pull, publish, remove or reload\n${USAGE}`)
|
|
143
151
|
}
|
|
144
152
|
case 'window': {
|
|
145
153
|
const id = integer(values, '--id')
|
|
@@ -199,6 +207,11 @@ export interface Io {
|
|
|
199
207
|
readonly out: (text: string) => void
|
|
200
208
|
readonly err: (text: string) => void
|
|
201
209
|
readonly fetch?: typeof fetch
|
|
210
|
+
/**
|
|
211
|
+
* Ask the person at this terminal; absent when nobody is there (a pipe, an agent's shell), and then nothing that needs a yes can happen.
|
|
212
|
+
* @returns whether they said yes.
|
|
213
|
+
*/
|
|
214
|
+
readonly ask?: (question: string) => Promise<boolean>
|
|
202
215
|
}
|
|
203
216
|
|
|
204
217
|
/** Run one invocation. @returns the exit code. */
|
|
@@ -214,7 +227,7 @@ export async function main(argv: readonly string[], io: Io): Promise<number> {
|
|
|
214
227
|
const app = findApp(io.cwd, invocation.dir)
|
|
215
228
|
const file = app === undefined ? undefined : readDoorFile(app)
|
|
216
229
|
if (app === undefined || file === undefined) {
|
|
217
|
-
io.err(`no running app${app === undefined ? ` in ${io.cwd} or above it` : ` in ${app}`}: start it (npm run dev) in its folder, then
|
|
230
|
+
io.err(`no running app${app === undefined ? ` in ${io.cwd} or above it` : ` in ${app}`}: these commands reach the running app, so start it (npm run dev) in its folder in another terminal, then run the same line again\n`)
|
|
218
231
|
return 3
|
|
219
232
|
}
|
|
220
233
|
const request = io.fetch ?? fetch
|
|
@@ -225,7 +238,7 @@ export async function main(argv: readonly string[], io: Io): Promise<number> {
|
|
|
225
238
|
? await request(`${file.url}/api/acryl-door/verbs`, { headers })
|
|
226
239
|
: await request(`${file.url}/api/acryl-door/call`, { method: 'POST', headers, body: JSON.stringify({ verb: invocation.verb, args: invocation.args }) })
|
|
227
240
|
} catch {
|
|
228
|
-
io.err(`no running app in ${app}: ${file.url} does not answer (it stopped without removing .acryl/door.json?); start it again\n`)
|
|
241
|
+
io.err(`no running app in ${app}: ${file.url} does not answer (it stopped without removing .acryl/door.json?); start it (npm run dev), then run the same line again\n`)
|
|
229
242
|
return 3
|
|
230
243
|
}
|
|
231
244
|
if (response.status === 403) {
|
|
@@ -243,9 +256,52 @@ export async function main(argv: readonly string[], io: Io): Promise<number> {
|
|
|
243
256
|
return 1
|
|
244
257
|
}
|
|
245
258
|
io.out(invocation.json ? `${JSON.stringify(body.value)}\n` : `${body.text ?? ''}\n`)
|
|
259
|
+
if (invocation.verb === 'plugin-publish' && io.ask !== undefined && isPlan(body.value)) return confirmPublish(invocation, body.value, io, request, `${file.url}/api/acryl-door/publish-confirmed`, headers)
|
|
246
260
|
return 0
|
|
247
261
|
}
|
|
248
262
|
|
|
263
|
+
const isPlan = (value: unknown): value is PlanAsked =>
|
|
264
|
+
typeof value === 'object' && value !== null && 'owner' in value
|
|
265
|
+
|
|
266
|
+
/** What the prompt names. */
|
|
267
|
+
interface PlanAsked {
|
|
268
|
+
readonly owner: string
|
|
269
|
+
readonly repo: string
|
|
270
|
+
readonly version: string
|
|
271
|
+
readonly license: string
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** The person's yes: only what they just read is published, and anything but a plain yes sends nothing. */
|
|
275
|
+
async function confirmPublish(
|
|
276
|
+
invocation: Invocation,
|
|
277
|
+
plan: PlanAsked,
|
|
278
|
+
io: Io,
|
|
279
|
+
request: typeof fetch,
|
|
280
|
+
url: string,
|
|
281
|
+
headers: Record<string, string>,
|
|
282
|
+
): Promise<number> {
|
|
283
|
+
const yes = await io.ask?.(`Publish ${plan.owner}/${plan.repo} v${plan.version} under ${plan.license}? Type yes to send it: `)
|
|
284
|
+
if (yes !== true) { io.out('Nothing was sent.\n'); return 0 }
|
|
285
|
+
const response = await request(url, { method: 'POST', headers, body: JSON.stringify({ name: invocation.args.name, owner: invocation.args.owner, license: invocation.args.license, confirm: plan }) })
|
|
286
|
+
const body = await response.json().catch(() => undefined) as { ok?: boolean; message?: string; value?: { ok?: boolean; releaseUrl?: string; pullLine?: string; reason?: string; next?: string } } | undefined
|
|
287
|
+
const result = body?.value
|
|
288
|
+
if (body?.ok !== true || result?.ok !== true) {
|
|
289
|
+
io.err(`Not published: ${result?.reason ?? body?.message ?? 'the app did not answer'}${result?.next === undefined ? '' : `. ${result.next}`}\n`)
|
|
290
|
+
return 1
|
|
291
|
+
}
|
|
292
|
+
io.out(`Published: ${result.releaseUrl ?? ''}\nA friend gets exactly these bytes with:\n ${result.pullLine ?? ''}\n`)
|
|
293
|
+
return 0
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
async function askAtTerminal(question: string): Promise<boolean> {
|
|
297
|
+
const prompt = createInterface({ input: process.stdin, output: process.stdout })
|
|
298
|
+
try {
|
|
299
|
+
return (await prompt.question(question)).trim().toLowerCase() === 'yes'
|
|
300
|
+
} finally {
|
|
301
|
+
prompt.close()
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
249
305
|
/** True when this file is the program Node was started with (not imported by a test). */
|
|
250
306
|
function isProgram(): boolean {
|
|
251
307
|
const entry = process.argv[1]
|
|
@@ -262,6 +318,7 @@ if (isProgram()) {
|
|
|
262
318
|
cwd: process.cwd(),
|
|
263
319
|
out: (text) => { process.stdout.write(text) },
|
|
264
320
|
err: (text) => { process.stderr.write(text) },
|
|
321
|
+
...(process.stdin.isTTY && process.stdout.isTTY ? { ask: askAtTerminal } : {}),
|
|
265
322
|
}).then((code) => { process.exitCode = code }, (cause: unknown) => {
|
|
266
323
|
process.stderr.write(`${cause instanceof Error ? cause.message : String(cause)}\n`)
|
|
267
324
|
process.exitCode = 1
|
|
@@ -44,6 +44,8 @@ interface Connection {
|
|
|
44
44
|
readonly windowId: string
|
|
45
45
|
/** The desktop window the page is in, when the Electron shell tagged it. */
|
|
46
46
|
readonly shellWindow: number | undefined
|
|
47
|
+
/** The browser tab the page is in (the page keeps it for the tab's lifetime): a page that reloads or moves to another path comes back with a new window id and the same tab. */
|
|
48
|
+
readonly tab: string | undefined
|
|
47
49
|
focused: boolean
|
|
48
50
|
/** Order of last focus (or connection), so the most recent wins. */
|
|
49
51
|
lastActive: number
|
|
@@ -64,23 +66,39 @@ export interface ChannelOptions {
|
|
|
64
66
|
* address registers again a moment later). 0 (the default) fails at once.
|
|
65
67
|
*/
|
|
66
68
|
readonly attachWaitMs?: number
|
|
69
|
+
/**
|
|
70
|
+
* Every call must name the page it is for (`page`), and goes to that page alone: never to the page in front or the one used last, which may be another visitor's. A page-only door
|
|
71
|
+
* (a production assistant, one channel for every visitor of the app) sets it.
|
|
72
|
+
*/
|
|
73
|
+
readonly requirePage?: boolean
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** A page whose panel asked for a run: the id the run knows it by, and where that page is now (the same tab after a reload or a move). */
|
|
77
|
+
interface Asker {
|
|
78
|
+
/** The window id the run was asked from: what its calls name as `page`. */
|
|
79
|
+
readonly origin: string
|
|
80
|
+
windowId: string
|
|
81
|
+
tab: string | undefined
|
|
67
82
|
}
|
|
68
83
|
|
|
69
84
|
/**
|
|
70
85
|
* The pages and who drives which. One page answers a call, chosen the same way every time: the page whose panel asked for the run that is making the call (`driveFor`), else the page the
|
|
71
86
|
* Host just sent somewhere (it comes back as a new page, `prefer`), else the one in front (it reports `focused`), else the most recently active; a page that did not acknowledge a call in time goes to the end of the line. An explicit desktop `window` picks its page.
|
|
87
|
+
* A call that names its `page` (the page a run was asked from) goes to that page alone, followed into its tab when it reloads or moves, and to no other.
|
|
72
88
|
*/
|
|
73
89
|
export class UiChannel {
|
|
74
90
|
private readonly ackMs: number
|
|
75
91
|
private readonly attachWaitMs: number
|
|
92
|
+
private readonly requirePage: boolean
|
|
76
93
|
/** The page the Host sent to a new place and that came back: it keeps being driven until the person focuses another page. */
|
|
77
94
|
private preferred: string | undefined
|
|
78
95
|
/** The pages whose panels have a run going, the latest last: a call with no `window` goes to the latest of them that is still connected. */
|
|
79
|
-
private readonly askers:
|
|
96
|
+
private readonly askers: Asker[] = []
|
|
80
97
|
|
|
81
98
|
constructor(options: ChannelOptions = {}) {
|
|
82
99
|
this.ackMs = options.ackMs ?? DEFAULT_ACK_MS
|
|
83
100
|
this.attachWaitMs = options.attachWaitMs ?? 0
|
|
101
|
+
this.requirePage = options.requirePage === true
|
|
84
102
|
}
|
|
85
103
|
|
|
86
104
|
private readonly connections = new Map<string, Connection>()
|
|
@@ -95,12 +113,17 @@ export class UiChannel {
|
|
|
95
113
|
* Register a page's stream. A stream already open for the same window is closed first, and its calls settle as `disconnected`.
|
|
96
114
|
* @returns the function to call when this stream ends.
|
|
97
115
|
*/
|
|
98
|
-
attach(windowId: string, focused: boolean, stream: PageStream, shellWindow?: number): () => void {
|
|
116
|
+
attach(windowId: string, focused: boolean, stream: PageStream, shellWindow?: number, tab?: string): () => void {
|
|
99
117
|
const previous = this.connections.get(windowId)
|
|
100
118
|
if (previous !== undefined) this.drop(previous, true)
|
|
101
119
|
// A page that did not answer stays last in line when its stream reconnects: a tab that cannot answer reconnects just as readily, and would otherwise be first again.
|
|
102
|
-
const connection: Connection = { stream, windowId, shellWindow, focused, lastActive: ++this.clock, suspect: previous?.suspect === true }
|
|
120
|
+
const connection: Connection = { stream, windowId, shellWindow, tab, focused, lastActive: ++this.clock, suspect: previous?.suspect === true }
|
|
103
121
|
this.connections.set(windowId, connection)
|
|
122
|
+
// A run's page that reloaded or moved comes back as a new page in the same tab: the run goes on with it.
|
|
123
|
+
for (const asker of this.askers) {
|
|
124
|
+
if (asker.windowId === windowId) asker.tab ??= tab
|
|
125
|
+
else if (tab !== undefined && asker.tab === tab) asker.windowId = windowId
|
|
126
|
+
}
|
|
104
127
|
// A page that reloaded or came back during its own run is told the run is still going.
|
|
105
128
|
if (this.askers.some(asker => asker.windowId === windowId)) this.tell(connection, '1')
|
|
106
129
|
for (const waiter of [...this.attachWaiters]) waiter(windowId)
|
|
@@ -149,7 +172,7 @@ export class UiChannel {
|
|
|
149
172
|
* @returns the function to call when the run ends.
|
|
150
173
|
*/
|
|
151
174
|
driveFor(windowId: string): () => void {
|
|
152
|
-
const asker = { windowId }
|
|
175
|
+
const asker: Asker = { origin: windowId, windowId, tab: this.connections.get(windowId)?.tab }
|
|
153
176
|
this.askers.push(asker)
|
|
154
177
|
this.tellPage(windowId, '1')
|
|
155
178
|
return () => {
|
|
@@ -161,6 +184,33 @@ export class UiChannel {
|
|
|
161
184
|
}
|
|
162
185
|
}
|
|
163
186
|
|
|
187
|
+
/** Where the page a run was asked from is now: the same window, or the page that replaced it in the same tab. */
|
|
188
|
+
private whereIs(page: string): { windowId: string; tab: string | undefined } {
|
|
189
|
+
const asker = [...this.askers].reverse().find(entry => entry.origin === page)
|
|
190
|
+
return asker === undefined ? { windowId: page, tab: this.connections.get(page)?.tab } : { windowId: asker.windowId, tab: asker.tab }
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* A promise for the run's page coming back after a reload or a move: the next page that connects in its tab (or with its window id). Ask for it BEFORE the call that makes the page
|
|
195
|
+
* leave. Pages of other tabs, another visitor's among them, are never taken for it.
|
|
196
|
+
* @param signal - stop waiting (the act did not move the page after all); the promise then answers `undefined`.
|
|
197
|
+
* @returns the window id of the page that came back, or `undefined` when it did not within `timeoutMs`.
|
|
198
|
+
*/
|
|
199
|
+
expectPageBack(page: string, timeoutMs: number, signal?: AbortSignal): Promise<string | undefined> {
|
|
200
|
+
const { windowId, tab } = this.whereIs(page)
|
|
201
|
+
return new Promise((resolve) => {
|
|
202
|
+
const done = (value: string | undefined): void => { clearTimeout(timer); this.attachWaiters.delete(waiter); signal?.removeEventListener('abort', stop); resolve(value) }
|
|
203
|
+
const stop = (): void => { done(undefined) }
|
|
204
|
+
signal?.addEventListener('abort', stop, { once: true })
|
|
205
|
+
const waiter = (attached: string): void => {
|
|
206
|
+
const connection = this.connections.get(attached)
|
|
207
|
+
if (attached === windowId || (tab !== undefined && connection?.tab === tab)) done(attached)
|
|
208
|
+
}
|
|
209
|
+
const timer = setTimeout(() => { done(undefined) }, timeoutMs)
|
|
210
|
+
this.attachWaiters.add(waiter)
|
|
211
|
+
})
|
|
212
|
+
}
|
|
213
|
+
|
|
164
214
|
private tellPage(windowId: string, running: '0' | '1'): void {
|
|
165
215
|
const connection = this.connections.get(windowId)
|
|
166
216
|
if (connection !== undefined) this.tell(connection, running)
|
|
@@ -176,8 +226,10 @@ export class UiChannel {
|
|
|
176
226
|
if (connection === undefined) return
|
|
177
227
|
this.preferred = windowId
|
|
178
228
|
connection.lastActive = ++this.clock
|
|
179
|
-
// A run's own page that reloaded or navigated comes back as a new page: the run goes on with that one.
|
|
180
|
-
|
|
229
|
+
// A run's own page that reloaded or navigated comes back as a new page: the run goes on with that one. A run whose page has a tab follows only that tab, so
|
|
230
|
+
// another visitor's page is never taken for it.
|
|
231
|
+
for (const asker of this.askers)
|
|
232
|
+
if (!this.connections.has(asker.windowId) && (asker.tab === undefined || asker.tab === connection.tab)) asker.windowId = windowId
|
|
181
233
|
if (this.askers.some(asker => asker.windowId === windowId)) this.tellPage(windowId, '1')
|
|
182
234
|
}
|
|
183
235
|
|
|
@@ -186,9 +238,11 @@ export class UiChannel {
|
|
|
186
238
|
* until it speaks; a call that changes the page is never sent to a second page, because the first might still run it.
|
|
187
239
|
* @throws UiControlError `no-window`, `timeout`, `aborted`, `unloaded`, `disconnected`, or whatever the page refused with.
|
|
188
240
|
*/
|
|
189
|
-
async call(request: UiRequest, options: { signal?: AbortSignal; timeoutMs?: number; window?: number } = {}, retried = false): Promise<UiResult> {
|
|
241
|
+
async call(request: UiRequest, options: { signal?: AbortSignal; timeoutMs?: number; window?: number; page?: string } = {}, retried = false): Promise<UiResult> {
|
|
190
242
|
if (this.closed) throw new UiControlError('unloaded', 'the door was unloaded')
|
|
191
243
|
const readOnlyCall = request.op === 'snapshot' || request.op === 'wait'
|
|
244
|
+
if (options.page !== undefined) return await this.callPage(request, options.page, options, readOnlyCall)
|
|
245
|
+
if (this.requirePage) throw new UiControlError('no-window', 'this door drives only the page whose visitor asked, and the call names no page')
|
|
192
246
|
// A page that is moving to another address registers again a moment later: a read-only call right after a navigate or reload waits for it, rather than failing.
|
|
193
247
|
if (readOnlyCall && this.attachWaitMs > 0 && this.connections.size === 0) await this.expectNewPage(this.attachWaitMs)
|
|
194
248
|
const ordered = this.ordered()
|
|
@@ -218,6 +272,26 @@ export class UiChannel {
|
|
|
218
272
|
throw new UiControlError('timeout', `no page of the app answered within ${String(Math.round(this.ackMs / 1000))} s: the app's pages are open but none responded. Likely causes: a tab in the background, a page that is still loading after a navigation (look again), a page busy running a long script, or a page that holds the browser's six connections with event streams of its own (a page should keep to two). Bring the app's tab to the front, or navigate it, then look again`)
|
|
219
273
|
}
|
|
220
274
|
|
|
275
|
+
/**
|
|
276
|
+
* A call for one page (the page a run was asked from, followed into its tab): it goes to that page alone and never fails over to another one. A page that is between two
|
|
277
|
+
* documents (it reloads, or moves to another path) is waited for, up to the attach wait.
|
|
278
|
+
*/
|
|
279
|
+
private async callPage(request: UiRequest, page: string, options: { signal?: AbortSignal; timeoutMs?: number }, readOnly: boolean): Promise<UiResult> {
|
|
280
|
+
let target = this.connections.get(this.whereIs(page).windowId)
|
|
281
|
+
if (target === undefined && this.attachWaitMs > 0) {
|
|
282
|
+
const back = await this.expectPageBack(page, this.attachWaitMs)
|
|
283
|
+
if (back !== undefined) target = this.connections.get(back)
|
|
284
|
+
}
|
|
285
|
+
if (target === undefined) throw new UiControlError('no-window', 'the page this run is for is not open any more')
|
|
286
|
+
if (options.signal?.aborted === true) throw new UiControlError('aborted', 'the call was cancelled')
|
|
287
|
+
try {
|
|
288
|
+
return await this.callOne(target, request, options, readOnly)
|
|
289
|
+
} catch (cause) {
|
|
290
|
+
if (cause instanceof Silent) throw new UiControlError('timeout', 'the page this run is for did not answer: is its tab in the background or still loading? Look again')
|
|
291
|
+
throw cause
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
221
295
|
private callOne(target: Connection, request: UiRequest, options: { signal?: AbortSignal; timeoutMs?: number }, failOver: boolean): Promise<UiResult> {
|
|
222
296
|
const signal = options.signal
|
|
223
297
|
const id = this.nextId
|
|
@@ -260,12 +334,15 @@ export class UiChannel {
|
|
|
260
334
|
|
|
261
335
|
/**
|
|
262
336
|
* A promise for the window id of the next page that connects and was not connected when this was asked (the page that came back after a reload or a move).
|
|
337
|
+
* @param signal - stop waiting; the promise then answers `undefined`.
|
|
263
338
|
* @returns that id, or `undefined` when no new page connected within `timeoutMs`.
|
|
264
339
|
*/
|
|
265
|
-
expectNewPage(timeoutMs: number): Promise<string | undefined> {
|
|
340
|
+
expectNewPage(timeoutMs: number, signal?: AbortSignal): Promise<string | undefined> {
|
|
266
341
|
const known = new Set(this.connections.keys())
|
|
267
342
|
return new Promise((resolve) => {
|
|
268
|
-
const done = (value: string | undefined): void => { clearTimeout(timer); this.attachWaiters.delete(waiter); resolve(value) }
|
|
343
|
+
const done = (value: string | undefined): void => { clearTimeout(timer); this.attachWaiters.delete(waiter); signal?.removeEventListener('abort', stop); resolve(value) }
|
|
344
|
+
const stop = (): void => { done(undefined) }
|
|
345
|
+
signal?.addEventListener('abort', stop, { once: true })
|
|
269
346
|
const waiter = (windowId: string): void => { if (!known.has(windowId)) done(windowId) }
|
|
270
347
|
const timer = setTimeout(() => { done(undefined) }, timeoutMs)
|
|
271
348
|
this.attachWaiters.add(waiter)
|
|
@@ -6,10 +6,10 @@
|
|
|
6
6
|
|
|
7
7
|
import { UiControlError } from '../page/protocol.ts'
|
|
8
8
|
import { DoorError, type Door, type ToolsPort } from './door.ts'
|
|
9
|
-
import { chatToolName, VERB_SPECS, VERBS } from './verbs.ts'
|
|
9
|
+
import { chatToolName, VERB_SPECS, VERBS, type Verb } from './verbs.ts'
|
|
10
10
|
|
|
11
|
-
/** Register every
|
|
12
|
-
export function registerChatTools(tools: ToolsPort, door: Door): () => void {
|
|
11
|
+
/** Register the verbs (default: every one). @returns the function that removes them all; it also aborts the calls still running. */
|
|
12
|
+
export function registerChatTools(tools: ToolsPort, door: Door, verbs: readonly Verb[] = VERBS): () => void {
|
|
13
13
|
const running = new AbortController()
|
|
14
14
|
const removers: Array<() => void> = []
|
|
15
15
|
const removeAll = (): void => {
|
|
@@ -17,14 +17,16 @@ export function registerChatTools(tools: ToolsPort, door: Door): () => void {
|
|
|
17
17
|
for (const remove of removers.splice(0).reverse()) remove()
|
|
18
18
|
}
|
|
19
19
|
try {
|
|
20
|
-
for (const verb of
|
|
20
|
+
for (const verb of verbs) {
|
|
21
21
|
removers.push(tools.register(chatToolName(verb), {
|
|
22
22
|
description: VERB_SPECS[verb].description,
|
|
23
23
|
// The model is told which fields the verb takes (an `act` needs its `op`), the same schema MCP lists.
|
|
24
24
|
parameters: VERB_SPECS[verb].arguments as unknown as Readonly<Record<string, unknown>>,
|
|
25
|
-
run
|
|
25
|
+
// The run's own signal and page come with the call: its look and act go to the page the run was asked from.
|
|
26
|
+
run: async (input: unknown, options?: { signal?: AbortSignal; page?: string }) => {
|
|
27
|
+
const signal = options?.signal === undefined ? running.signal : AbortSignal.any([running.signal, options.signal])
|
|
26
28
|
try {
|
|
27
|
-
return (await door.run(verb, input ?? {},
|
|
29
|
+
return (await door.run(verb, input ?? {}, signal, options?.page === undefined ? {} : { page: options.page })).text
|
|
28
30
|
} catch (cause) {
|
|
29
31
|
if (cause instanceof DoorError || cause instanceof UiControlError) throw new Error(`${cause.code}: ${cause.message}`, { cause })
|
|
30
32
|
throw cause
|