acrylblends 1.0.2 → 1.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/dist/index.js +7 -5
  2. package/dist/index.js.map +1 -1
  3. package/docs/framework/README.md +1 -0
  4. package/docs/framework/agents/index.md +1 -1
  5. package/docs/framework/guides/data.md +12 -0
  6. package/docs/framework/workshop/README.md +381 -0
  7. package/docs/framework/workshop/facilitator.md +108 -0
  8. package/lib/{audit-BXnYsLge.d.ts → audit-D5z9uwOP.d.ts} +14 -1
  9. package/lib/cordis/db-sqlite.js +10 -2
  10. package/lib/cordis/door.d.ts +8 -2
  11. package/lib/cordis/door.js +88 -12
  12. package/lib/cordis/inspect.js +1 -1
  13. package/lib/cordis/kv-json.js +10 -2
  14. package/lib/cordis/node-server.js +1 -1
  15. package/lib/cordis/router-http.d.ts +1 -1
  16. package/lib/cordis/router-http.js +1 -1
  17. package/lib/cordis/router.d.ts +1 -1
  18. package/lib/cordis/router.js +1 -1
  19. package/lib/cordis/state.js +1 -1
  20. package/lib/door/host.d.ts +2 -2
  21. package/lib/door/host.js +1 -1
  22. package/lib/door/page.d.ts +12 -1
  23. package/lib/door/page.js +21 -0
  24. package/lib/door/protocol.d.ts +2 -2
  25. package/lib/door/protocol.js +19 -3
  26. package/lib/{index-D4nVR3wu.d.ts → index-DMOtA-NJ.d.ts} +7 -1
  27. package/lib/node-server.js +1 -1
  28. package/lib/{protocol-BizVt4J6.d.ts → protocol-C37gPePD.d.ts} +9 -2
  29. package/lib/{snapshot-text-DmE0YfeZ.js → snapshot-text-BLz8nlSc.js} +49 -1
  30. package/lib/{src-2Z4mJg0x.js → src-dy3Fk9tA.js} +16 -0
  31. package/lib/{test-websocket-ZXHNHQSU.js → test-websocket-D8WWqE5G.js} +1 -2
  32. package/package.json +1 -1
  33. package/plugins/acryl-db-sqlite/plugin.json +1 -1
  34. package/plugins/acryl-db-sqlite/src/index.ts +8 -1
  35. package/plugins/acryl-door/src/cli/acryl.ts +5 -3
  36. package/plugins/acryl-door/src/host/channel.ts +36 -0
  37. package/plugins/acryl-door/src/host/door.ts +26 -4
  38. package/plugins/acryl-door/src/host/shim.ts +23 -0
  39. package/plugins/acryl-door/src/host/verbs.ts +4 -3
  40. package/plugins/acryl-door/src/index.ts +26 -3
  41. package/plugins/acryl-door/src/page/driver/driver.ts +22 -1
  42. package/plugins/acryl-door/src/page/protocol.ts +13 -2
  43. package/plugins/acryl-kv-json/plugin.json +1 -1
  44. package/plugins/acryl-kv-json/src/index.ts +8 -1
  45. package/plugins/acryl-node-server/src/adapter/request.ts +3 -2
  46. package/plugins/acryl-router/src/service.ts +19 -1
  47. package/plugins/agent-panel/client/main.ts +63 -34
  48. package/plugins/agent-panel/index.ts +115 -25
  49. package/shared/DEFAULTS.md +4 -0
@@ -1,12 +1,13 @@
1
1
  import { t as Acryl } from "../app-BM0rTCp7.js";
2
2
  import { n as clientModules } from "../client-modules-CLvWOjbd.js";
3
3
  import { MAX_SNAPSHOT_NODES, MAX_WAIT_MS, UiControlError, parseUiRequest } from "../door/protocol.js";
4
- 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-DmE0YfeZ.js";
5
- import "../src-2Z4mJg0x.js";
4
+ 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-BLz8nlSc.js";
5
+ import "../src-dy3Fk9tA.js";
6
6
  import { isAbsolute, join, relative, resolve } from "node:path";
7
7
  import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
8
8
  import z from "@deepseek-ai/schemastery";
9
9
  import { randomBytes } from "node:crypto";
10
+ import { spawnSync } from "node:child_process";
10
11
  //#region ../../plugins/acryl-door/src/host/verbs.ts
11
12
  /**
12
13
  * The one verb list (spec 049 slice 13). Every face of the door reads it: the operator client (`acryl <verb>`, its `verbs` and `brief`),
@@ -56,8 +57,8 @@ const VERB_SPECS = {
56
57
  }
57
58
  },
58
59
  "act": {
59
- usage: "act click|type|select|press|scroll|wait ...: do one thing on the page (refs from the latest look)",
60
- description: "Do one thing on the page: click, type, select, press a key, scroll, or wait for text or a control. Look again before the next act.",
60
+ usage: "act click|type|select|press|scroll|wait|reload|navigate ...: do one thing on the page (refs from the latest look)",
61
+ description: "Do one thing on the page: click, type, select, press a key, scroll, wait for text or a control, reload the page, or navigate to another path of the app. Look again before the next act.",
61
62
  arguments: {
62
63
  type: "object",
63
64
  properties: {
@@ -69,9 +70,15 @@ const VERB_SPECS = {
69
70
  "select",
70
71
  "press",
71
72
  "scroll",
72
- "wait"
73
+ "wait",
74
+ "reload",
75
+ "navigate"
73
76
  ]
74
77
  },
78
+ path: {
79
+ type: "string",
80
+ description: "navigate: a path of this app, e.g. /demo (the page must load the door script to be controlled afterwards)"
81
+ },
75
82
  ref: {
76
83
  type: "string",
77
84
  description: "a ref from the latest look, e.g. 3.12"
@@ -211,6 +218,8 @@ const SELF_KNOWLEDGE = [
211
218
  "docs",
212
219
  "plugins"
213
220
  ];
221
+ /** How long a reload or a move may take before the new page has connected to the door again. */
222
+ const PAGE_BACK_MS = 15e3;
214
223
  var Door = class {
215
224
  deps;
216
225
  constructor(deps) {
@@ -235,7 +244,7 @@ var Door = class {
235
244
  text: `done${result.target === void 0 ? "" : `: ${result.target.role} "${result.target.name}"`}${result.detail === void 0 ? "" : ` (${result.detail})`}; look again before the next act`
236
245
  };
237
246
  }
238
- case "plugin-list": return this.plugin((extensions) => extensions.list());
247
+ case "plugin-list": return this.plugin((extensions) => extensions.list(), false);
239
248
  case "plugin-add": return this.plugin((extensions) => extensions.install(this.insideApp(input.folder)));
240
249
  case "plugin-remove": {
241
250
  const name = input.name;
@@ -286,9 +295,26 @@ var Door = class {
286
295
  });
287
296
  return this.callPage("look", request, signal);
288
297
  }
289
- act(input, signal) {
298
+ async act(input, signal) {
290
299
  if (input.op === "snapshot") throw new DoorError("invalid", "use look to see the page");
291
- return this.callPage(`act ${String(input.op)}`, this.parse(input), signal);
300
+ const request = this.parse(input);
301
+ if (request.op !== "reload" && request.op !== "navigate") return await this.callPage(`act ${String(input.op)}`, request, signal);
302
+ const back = this.deps.channel.expectAttach(this.deps.pageBackMs ?? PAGE_BACK_MS);
303
+ let answer;
304
+ try {
305
+ answer = await this.callPage(`act ${request.op}`, request, signal);
306
+ } catch (cause) {
307
+ if (!(cause instanceof UiControlError && cause.code === "disconnected")) throw cause;
308
+ answer = {
309
+ ok: true,
310
+ detail: request.op === "reload" ? "reloading" : `going to ${request.path}`
311
+ };
312
+ }
313
+ if (!await back) throw new UiControlError("timeout", request.op === "reload" ? "the page did not come back after the reload" : `the page did not come back after going to ${request.path}: is there a page at that path, and does it load the door's script?`);
314
+ return {
315
+ ...answer,
316
+ detail: `${answer.detail ?? "done"}; the page is back: look again`
317
+ };
292
318
  }
293
319
  parse(input) {
294
320
  try {
@@ -335,10 +361,12 @@ var Door = class {
335
361
  throw cause;
336
362
  }
337
363
  }
338
- async plugin(run) {
364
+ async plugin(run, mutates = true) {
339
365
  const extensions = this.deps.extensions();
340
366
  if (extensions === void 0) throw new DoorError("unavailable", "this app has no extensions plugin, so plugins cannot be installed while it runs");
341
367
  const value = await run(extensions);
368
+ const unchanged = typeof value === "object" && value !== null && value.action === "unchanged";
369
+ if (mutates && !unchanged && this.deps.reloadOnChange !== false) await this.deps.channel.reloadAll();
342
370
  return {
343
371
  value,
344
372
  text: JSON.stringify(value, null, 2)
@@ -631,6 +659,34 @@ function operatorRoutes(prefix, secret, door, reportError) {
631
659
  }
632
660
  });
633
661
  }
662
+ /** The oldest Node the client runs on (the framework's own floor). */
663
+ const MIN_NODE = [22, 19];
664
+ /**
665
+ * A real Node for the command, when the app runs inside Electron: Electron can run the client as Node, but on macOS every such call prints a code-signing complaint on stderr,
666
+ * which lands in an agent's transcript. A `node` on the PATH that is new enough is used instead; without one, Electron runs the client. `find` is replaceable for tests.
667
+ */
668
+ function realNode(find = findNodeOnPath) {
669
+ const found = find();
670
+ if (found === void 0) return void 0;
671
+ const [major = 0, minor = 0] = found.version.replace(/^v/u, "").split(".").map(Number);
672
+ return major > MIN_NODE[0] || major === MIN_NODE[0] && minor >= MIN_NODE[1] ? found.path : void 0;
673
+ }
674
+ function findNodeOnPath() {
675
+ const located = spawnSync(process.platform === "win32" ? "where" : "which", ["node"], {
676
+ encoding: "utf8",
677
+ env: {
678
+ ...process.env,
679
+ ELECTRON_RUN_AS_NODE: void 0
680
+ }
681
+ });
682
+ const path = located.status === 0 ? located.stdout.split(/\r?\n/u).find((line) => line.trim() !== "")?.trim() : void 0;
683
+ if (path === void 0) return void 0;
684
+ const asked = spawnSync(path, ["--version"], { encoding: "utf8" });
685
+ return asked.status === 0 ? {
686
+ path,
687
+ version: asked.stdout.trim()
688
+ } : void 0;
689
+ }
634
690
  /** A word for `sh`: single-quoted, with each `'` closed, escaped and reopened. */
635
691
  function shWord(value) {
636
692
  return `'${value.replaceAll("'", "'\\''")}'`;
@@ -698,6 +754,7 @@ const inject = [
698
754
  ];
699
755
  const Config = z.object({
700
756
  allowInProduction: z.boolean().default(false).description("Run in production too (NODE_ENV=production); ACRYL_DOOR=off still turns it off."),
757
+ reloadOnChange: z.boolean().default(true).description("Reload the open pages after a plugin was added, removed or reloaded while the app runs."),
701
758
  tools: z.object({ expose: z.array(z.string().pattern(/^(?!door_)[A-Za-z][A-Za-z0-9_-]{0,63}$/u)).description("App tools (from the tools registry) the door offers; none by default.") })
702
759
  });
703
760
  /** The page module, served compiled from source by the app's own TypeScript. */
@@ -707,11 +764,13 @@ const CLIENT = import.meta.url.endsWith(".ts") ? join(import.meta.dirname, "cli"
707
764
  /** How the folder's `.acryl/bin/acryl` starts the client: with the Node that runs the app, and its type-stripping flag when run from source. */
708
765
  function shimTarget() {
709
766
  const fromSource = CLIENT.endsWith(".ts");
767
+ const inElectron = process.versions.electron !== void 0;
768
+ const node = inElectron ? realNode() : void 0;
710
769
  return {
711
- node: process.execPath,
770
+ node: node ?? process.execPath,
712
771
  nodeOptions: fromSource ? process.execArgv.filter((option) => /^--experimental-(strip|transform)-types$/u.test(option)) : [],
713
772
  client: CLIENT,
714
- electron: process.versions.electron !== void 0
773
+ electron: inElectron && node === void 0
715
774
  };
716
775
  }
717
776
  /** A listening address as one the operator reaches: an any-address bind is reached on loopback. */
@@ -741,12 +800,29 @@ function apply(ctx, rawConfig = {}) {
741
800
  },
742
801
  extensions: () => ctx.get("extensions"),
743
802
  tools: () => ctx.get("tools"),
744
- expose: config.tools?.expose ?? []
803
+ expose: config.tools?.expose ?? [],
804
+ reloadOnChange: config.reloadOnChange !== false
745
805
  });
746
806
  ctx.effect(() => () => {
747
807
  channel.close();
748
808
  }, "acryl-door: page channel");
749
809
  const pageModule = new Acryl().get(`${DOOR_PREFIX}/page.js`, (c) => c.body(`import '${DOOR_PREFIX}/page/main.ts'\n`, 200, { "content-type": "text/javascript; charset=utf-8" })).route(`${DOOR_PREFIX}/page`, clientModules({ root: PAGE_DIR }));
810
+ const tag = `<script type="module" src="${DOOR_PREFIX}/page.js"><\/script>`;
811
+ const addScript = async (c, next) => {
812
+ await next();
813
+ if (!(c.res.headers.get("content-type") ?? "").includes("text/html")) return;
814
+ const html = await c.res.text();
815
+ const headers = new Headers(c.res.headers);
816
+ headers.delete("content-length");
817
+ const carries = html.includes(`${DOOR_PREFIX}/page.js`);
818
+ c.res = new Response(carries ? html : html.includes("</body>") ? html.replace("</body>", `${tag}</body>`) : html + tag, {
819
+ status: c.res.status,
820
+ headers
821
+ });
822
+ };
823
+ const router = ctx.router;
824
+ if (typeof router.wrap === "function") router.wrap(addScript);
825
+ else ctx.router.route("/", new Acryl().use("*", addScript));
750
826
  ctx.router.route("/", privateApi(DOOR_PREFIX, pageModule, pageChannelRoutes(channel, { prefix: DOOR_PREFIX }), operatorRoutes(DOOR_PREFIX, secret, door, reportError), mcpRoutes(DOOR_PREFIX, secret, door, reportError)));
751
827
  ctx.inject(["tools"], (inner) => {
752
828
  inner.effect(() => registerChatTools(inner.get("tools"), door), "acryl-door: the verbs in the tools registry");
@@ -1,6 +1,6 @@
1
1
  import { t as Acryl } from "../app-BM0rTCp7.js";
2
2
  import { loopbackOnly } from "../loopback-only.js";
3
- import "../src-2Z4mJg0x.js";
3
+ import "../src-dy3Fk9tA.js";
4
4
  import "../middleware-CPQ8himN.js";
5
5
  import { Service } from "@deepseek-ai/cordis";
6
6
  //#region ../../plugins/acryl-inspect/src/report.ts
@@ -19,12 +19,20 @@ function isAppHome(value) {
19
19
  const Config = z.object({ path: z.string().description("The JSON file, absolute or relative to the app. Defaults to kv.json in the ACRYL app home; required where there is none.") });
20
20
  /** The file name used in the app home when `path` is not configured. */
21
21
  const DEFAULT_FILE = "kv.json";
22
- /** A configured path as a file path: absolute as given, relative to the Loader's base URL (the app) otherwise. */
22
+ /** The project folder of an acrylblends app (`appInstance.root`), when the instance has one: where a relative path in a row's config is read from, since such an app has no Loader base URL. */
23
+ function appRoot(value) {
24
+ return typeof value === "object" && value !== null && "root" in value && typeof value.root === "string" ? value.root : void 0;
25
+ }
26
+ /** A configured path as a file path: absolute as given, relative to the Loader's base URL (the app) otherwise, or to the app's project folder (`appInstance.root`) where there is no base URL. */
23
27
  function resolveFromApp(ctx, path) {
24
28
  if (path.startsWith("file:")) return fileURLToPath(path);
25
29
  if (isAbsolute(path)) return path;
26
30
  const base = ctx.baseUrl;
27
- if (base === void 0 || !base.startsWith("file:")) throw new Error(`acryl-kv-json: path ${JSON.stringify(path)} is relative, but this app has no file base URL to resolve it from; give an absolute path`);
31
+ if (base === void 0 || !base.startsWith("file:")) {
32
+ const root = appRoot(ctx.get("appInstance"));
33
+ if (root !== void 0) return resolve(root, path);
34
+ throw new Error(`acryl-kv-json: path ${JSON.stringify(path)} is relative, but this app has no file base URL to resolve it from; give an absolute path`);
35
+ }
28
36
  return resolve(base.endsWith("/") ? fileURLToPath(base) : dirname(fileURLToPath(base)), path);
29
37
  }
30
38
  async function apply(ctx, rawConfig = {}) {
@@ -1,4 +1,4 @@
1
- import { c as Request, i as createAdaptorServer, l as RequestError, n as UpgradeEmitter, r as UpgradeHub, t as testWebSocket } from "../test-websocket-ZXHNHQSU.js";
1
+ import { c as Request, i as createAdaptorServer, l as RequestError, n as UpgradeEmitter, r as UpgradeHub, t as testWebSocket } from "../test-websocket-D8WWqE5G.js";
2
2
  import { t as serveStatic } from "../serve-static-3uo_9rjF.js";
3
3
  import { Service } from "@deepseek-ai/cordis";
4
4
  import z from "@deepseek-ai/schemastery";
@@ -1,5 +1,5 @@
1
1
  import { n as UpgradeWebSocket } from "../index-C-SklY6u.js";
2
- import { i as RouterHttpService } from "../index-D4nVR3wu.js";
2
+ import { i as RouterHttpService } from "../index-DMOtA-NJ.js";
3
3
  import { IncomingMessage, ServerResponse } from "node:http";
4
4
  import { Duplex } from "node:stream";
5
5
  import { Context, Service } from "@deepseek-ai/cordis";
@@ -1,4 +1,4 @@
1
- import { n as UpgradeEmitter, r as UpgradeHub } from "../test-websocket-ZXHNHQSU.js";
1
+ import { n as UpgradeEmitter, r as UpgradeHub } from "../test-websocket-D8WWqE5G.js";
2
2
  import "./node-server.js";
3
3
  import { Readable } from "node:stream";
4
4
  import { Service } from "@deepseek-ai/cordis";
@@ -2,6 +2,6 @@ import { B as NotFoundHandler, L as MiddlewareHandler, O as Handler, Q as Valida
2
2
  import { c as StatusCode, n as ContentfulStatusCode } from "../http-status-DaAwGAFL.js";
3
3
  import { a as getConnInfo, i as connectionOf, n as RouterBindings, r as RouterEnv, t as ConnectionInfo } from "../connection-ZLEso7XL.js";
4
4
  import { t as Acryl } from "../app-D8riatvv.js";
5
- import { a as TrailingSlash, i as RouterHttpService, n as MountableApp, o as apply, r as Router, s as name, t as Config } from "../index-D4nVR3wu.js";
5
+ import { a as TrailingSlash, i as RouterHttpService, n as MountableApp, o as apply, r as Router, s as name, t as Config } from "../index-DMOtA-NJ.js";
6
6
  import { t as HTTPException } from "../http-exception-kpGJkmpi.js";
7
7
  export { Acryl, type AcrylRequest, Config, type ConnectionInfo, type ContentfulStatusCode, Context, type ContextRenderer, type ContextVariableMap, type Env, type ErrorHandler, type ExecutionContext, HTTPException, type Handler, type Input, type MiddlewareHandler, type MountableApp, type Next, type NotFoundHandler, type NotFoundResponse, Router, type RouterBindings, type RouterEnv, type RouterHttpService, type Schema, type StatusCode, type ToSchema, type TrailingSlash, type TypedResponse, type ValidationTargets, apply, connectionOf, getConnInfo, name };
@@ -2,5 +2,5 @@ import { HTTPException } from "../http-exception.js";
2
2
  import { t as Context } from "../context-DDO0cfuP.js";
3
3
  import { t as Acryl } from "../app-BM0rTCp7.js";
4
4
  import { n as getConnInfo, t as connectionOf } from "../connection-C7xgP1hP.js";
5
- import { i as name, n as Router, r as apply, t as Config } from "../src-2Z4mJg0x.js";
5
+ import { i as name, n as Router, r as apply, t as Config } from "../src-dy3Fk9tA.js";
6
6
  export { Acryl, Config, Context, HTTPException, Router, apply, connectionOf, getConnInfo, name };
@@ -3,7 +3,7 @@ import { methodNotAllowed } from "../method-not-allowed.js";
3
3
  import { r as streamSSE } from "../streaming-DHST3Scg.js";
4
4
  import { loopbackOnly } from "../loopback-only.js";
5
5
  import "../helpers-CvTauQT5.js";
6
- import "../src-2Z4mJg0x.js";
6
+ import "../src-dy3Fk9tA.js";
7
7
  import "../middleware-CPQ8himN.js";
8
8
  import { Service } from "@deepseek-ai/cordis";
9
9
  //#region ../../plugins/acryl-state/src/cell.ts
@@ -1,6 +1,6 @@
1
1
  import { t as Acryl } from "../app-D8riatvv.js";
2
- import { t as AuditEntry, v as UiSnapshot } from "../protocol-BizVt4J6.js";
3
- import { i as UiChannel, n as auditPath, r as PageStream, t as AuditLog } from "../audit-BXnYsLge.js";
2
+ import { n as AuditEntry, y as UiSnapshot } from "../protocol-C37gPePD.js";
3
+ import { i as UiChannel, n as auditPath, r as PageStream, t as AuditLog } from "../audit-D5z9uwOP.js";
4
4
 
5
5
  //#region ../../plugins/acryl-door/src/host/page-routes.d.ts
6
6
  /** How often the Host writes a `ping`; the page gives up on a stream that is silent for three of these. */
package/lib/door/host.js CHANGED
@@ -1,2 +1,2 @@
1
- import { d as AuditLog, f as auditPath, n as HEARTBEAT_MS, p as UiChannel, r as pageChannelRoutes, t as renderSnapshot } from "../snapshot-text-DmE0YfeZ.js";
1
+ import { d as AuditLog, f as auditPath, n as HEARTBEAT_MS, p as UiChannel, r as pageChannelRoutes, t as renderSnapshot } from "../snapshot-text-BLz8nlSc.js";
2
2
  export { AuditLog, HEARTBEAT_MS, UiChannel, auditPath, pageChannelRoutes, renderSnapshot };
@@ -1,4 +1,4 @@
1
- import { _ as UiResult, g as UiRequest } from "../protocol-BizVt4J6.js";
1
+ import { _ as UiRequest, v as UiResult } from "../protocol-C37gPePD.js";
2
2
 
3
3
  //#region ../../plugins/acryl-door/src/page/driver/driver.d.ts
4
4
  interface DriverActivity {
@@ -14,6 +14,12 @@ interface UiDriverOptions {
14
14
  /** Milliseconds since the user last used the page (mouse, keyboard); Infinity when never. */
15
15
  readonly msSinceUserInput?: () => number;
16
16
  readonly sleep?: (ms: number, signal: AbortSignal) => Promise<void>;
17
+ /** Leave the page: reload it, or go to a path of the app. Default: the document's own `location`, a moment after the answer is sent. */
18
+ readonly leave?: (to: {
19
+ readonly reload: true;
20
+ } | {
21
+ readonly path: string;
22
+ }) => void;
17
23
  }
18
24
  declare class UiDriver {
19
25
  private readonly refs;
@@ -40,6 +46,11 @@ declare class UiDriver {
40
46
  /** @param signal - the Host cancelled the call. @throws UiControlError. */
41
47
  handle(request: UiRequest, signal?: AbortSignal): Promise<UiResult>;
42
48
  private run;
49
+ /**
50
+ * The page is about to be replaced, so the answer is given first and the move happens a moment later (the answer is a POST that the page's own unload would cut off).
51
+ * The Host does not wait for this answer to be the last word: it waits for the page to come back.
52
+ */
53
+ private leave;
43
54
  private yieldToUser;
44
55
  private emit;
45
56
  }
package/lib/door/page.js CHANGED
@@ -894,8 +894,29 @@ var UiDriver = class {
894
894
  case "press": return pressKey(this.env, request);
895
895
  case "scroll": return scroll(this.env, request);
896
896
  case "wait": return wait(this.env, request, signal);
897
+ case "reload": return this.leave({ reload: true }, "reloading");
898
+ case "navigate": return this.leave({ path: request.path }, `going to ${request.path}`);
897
899
  }
898
900
  }
901
+ /**
902
+ * The page is about to be replaced, so the answer is given first and the move happens a moment later (the answer is a POST that the page's own unload would cut off).
903
+ * The Host does not wait for this answer to be the last word: it waits for the page to come back.
904
+ */
905
+ leave(to, detail) {
906
+ const go = this.options.leave ?? ((target) => {
907
+ const location = this.options.document().defaultView?.location;
908
+ if (location === void 0) return;
909
+ if ("reload" in target) location.reload();
910
+ else location.assign(target.path);
911
+ });
912
+ setTimeout(() => {
913
+ if (!this.disposed) go(to);
914
+ }, 150);
915
+ return {
916
+ ok: true,
917
+ detail
918
+ };
919
+ }
899
920
  async yieldToUser(signal) {
900
921
  const since = this.options.msSinceUserInput;
901
922
  if (since === void 0) return;
@@ -1,2 +1,2 @@
1
- import { S as parseUiRequest, _ as UiResult, a as MAX_SNAPSHOT_NODES, b as parseChannelToHost, c as MUTATING_OPS, d as UiActionResult, f as UiControlError, g as UiRequest, h as UiOp, i as DEFAULT_SNAPSHOT_NODES, l as UI_ERROR_CODES, m as UiNode, n as ChannelToHost, o as MAX_TEXT_LENGTH, p as UiErrorCode, r as ChannelToPage, s as MAX_WAIT_MS, t as AuditEntry, u as UI_OPS, v as UiSnapshot, x as parseChannelToPage, y as WINDOW_ID } from "../protocol-BizVt4J6.js";
2
- export { AuditEntry, ChannelToHost, ChannelToPage, DEFAULT_SNAPSHOT_NODES, MAX_SNAPSHOT_NODES, MAX_TEXT_LENGTH, MAX_WAIT_MS, MUTATING_OPS, UI_ERROR_CODES, UI_OPS, UiActionResult, UiControlError, UiErrorCode, UiNode, UiOp, UiRequest, UiResult, UiSnapshot, WINDOW_ID, parseChannelToHost, parseChannelToPage, parseUiRequest };
1
+ import { C as parseUiRequest, S as parseChannelToPage, _ as UiRequest, a as DEFAULT_SNAPSHOT_NODES, b as WINDOW_ID, c as MAX_WAIT_MS, d as UI_OPS, f as UiActionResult, g as UiOp, h as UiNode, i as ChannelToPage, l as MUTATING_OPS, m as UiErrorCode, n as AuditEntry, o as MAX_SNAPSHOT_NODES, p as UiControlError, r as ChannelToHost, s as MAX_TEXT_LENGTH, t as APP_PATH, u as UI_ERROR_CODES, v as UiResult, x as parseChannelToHost, y as UiSnapshot } from "../protocol-C37gPePD.js";
2
+ export { APP_PATH, AuditEntry, ChannelToHost, ChannelToPage, DEFAULT_SNAPSHOT_NODES, MAX_SNAPSHOT_NODES, MAX_TEXT_LENGTH, MAX_WAIT_MS, MUTATING_OPS, UI_ERROR_CODES, UI_OPS, UiActionResult, UiControlError, UiErrorCode, UiNode, UiOp, UiRequest, UiResult, UiSnapshot, WINDOW_ID, parseChannelToHost, parseChannelToPage, parseUiRequest };
@@ -14,15 +14,21 @@ const UI_OPS = [
14
14
  "select",
15
15
  "press",
16
16
  "scroll",
17
- "wait"
17
+ "wait",
18
+ "reload",
19
+ "navigate"
18
20
  ];
19
21
  /** Operations that change the page, so each call needs approval. `snapshot`, `scroll` and `wait` only look. */
20
22
  const MUTATING_OPS = [
21
23
  "click",
22
24
  "type",
23
25
  "select",
24
- "press"
26
+ "press",
27
+ "reload",
28
+ "navigate"
25
29
  ];
30
+ /** A place in the app to go to: a path on the page's own origin (`/demo`, `/notes?q=a#top`), never another site and never a `//host` address. */
31
+ const APP_PATH = /^\/(?![/\\])[^\0\\]{0,2000}$/u;
26
32
  const MAX_TEXT_LENGTH = 1e4;
27
33
  const MAX_WAIT_MS = 1e4;
28
34
  const MAX_SNAPSHOT_NODES = 1e3;
@@ -142,6 +148,16 @@ function parseUiRequest(value) {
142
148
  ...timeoutMs === void 0 ? {} : { timeoutMs }
143
149
  };
144
150
  }
151
+ case "reload":
152
+ only([]);
153
+ return { op };
154
+ case "navigate":
155
+ only(["path"]);
156
+ if (typeof value.path !== "string" || !APP_PATH.test(value.path)) return invalid("path must be a path of this app, such as /demo (it starts with one /, and names no other site)");
157
+ return {
158
+ op,
159
+ path: value.path
160
+ };
145
161
  default: return invalid(`unknown op ${String(op)}`);
146
162
  }
147
163
  }
@@ -194,4 +210,4 @@ function parseChannelToHost(value) {
194
210
  return null;
195
211
  }
196
212
  //#endregion
197
- export { DEFAULT_SNAPSHOT_NODES, MAX_SNAPSHOT_NODES, MAX_TEXT_LENGTH, MAX_WAIT_MS, MUTATING_OPS, UI_ERROR_CODES, UI_OPS, UiControlError, WINDOW_ID, parseChannelToHost, parseChannelToPage, parseUiRequest };
213
+ export { APP_PATH, DEFAULT_SNAPSHOT_NODES, MAX_SNAPSHOT_NODES, MAX_TEXT_LENGTH, MAX_WAIT_MS, MUTATING_OPS, UI_ERROR_CODES, UI_OPS, UiControlError, WINDOW_ID, parseChannelToHost, parseChannelToPage, parseUiRequest };
@@ -1,4 +1,4 @@
1
- import { B as NotFoundHandler, y as ErrorHandler } from "./context-DS3fYyG-.js";
1
+ import { B as NotFoundHandler, E as H, y as ErrorHandler } from "./context-DS3fYyG-.js";
2
2
  import { t as ConnectionInfo } from "./connection-ZLEso7XL.js";
3
3
  import { n as UpgradeWebSocket } from "./index-C-SklY6u.js";
4
4
  import { Context, Service } from "@deepseek-ai/cordis";
@@ -77,6 +77,12 @@ declare class Router extends Service {
77
77
  * are copied now: one the app registers after this call is not served (mount again, or finish the app first).
78
78
  */
79
79
  route(path: string, app: MountableApp): () => void;
80
+ /**
81
+ * Run `middleware` around every request the router serves, whenever the plugin that asks was mounted: a middleware in a plain mount wraps only the routes mounted after it,
82
+ * which is the wrong tool for a plugin that must see every page (the door adds its script to each HTML response, the pages of a plugin that loaded earlier included). It goes
83
+ * outside all mounts, in the order wrappers were added, and goes away when the calling plugin unloads or when the returned function is called.
84
+ */
85
+ wrap(middleware: H): () => void;
80
86
  /**
81
87
  * The mount's own copy of `app`'s routes as they are now (like the core's `app.route`), each handler wrapped so the router knows which
82
88
  * mount's code a request is in. The core's `app.route` runs first so a sub-app's own `onError` still applies to its routes.
@@ -1,4 +1,4 @@
1
- import { a as serve, i as createAdaptorServer, l as RequestError, n as UpgradeEmitter, o as upgradeWebSocket, r as UpgradeHub, s as getRequestListener, t as testWebSocket } from "./test-websocket-ZXHNHQSU.js";
1
+ import { a as serve, i as createAdaptorServer, l as RequestError, n as UpgradeEmitter, o as upgradeWebSocket, r as UpgradeHub, s as getRequestListener, t as testWebSocket } from "./test-websocket-D8WWqE5G.js";
2
2
  import { t as serveStatic } from "./serve-static-3uo_9rjF.js";
3
3
  import { getConnInfo } from "./node-server/conninfo.js";
4
4
  import { n as clientModules, t as buildClientModules } from "./client-modules-CLvWOjbd.js";
@@ -7,10 +7,12 @@
7
7
  * lives with the page code and has no Node imports.
8
8
  */
9
9
  /** The operations the driver performs on the page. */
10
- declare const UI_OPS: readonly ["snapshot", "click", "type", "select", "press", "scroll", "wait"];
10
+ declare const UI_OPS: readonly ["snapshot", "click", "type", "select", "press", "scroll", "wait", "reload", "navigate"];
11
11
  type UiOp = (typeof UI_OPS)[number];
12
12
  /** Operations that change the page, so each call needs approval. `snapshot`, `scroll` and `wait` only look. */
13
13
  declare const MUTATING_OPS: readonly UiOp[];
14
+ /** A place in the app to go to: a path on the page's own origin (`/demo`, `/notes?q=a#top`), never another site and never a `//host` address. */
15
+ declare const APP_PATH: RegExp;
14
16
  declare const MAX_TEXT_LENGTH = 10000;
15
17
  declare const MAX_WAIT_MS = 10000;
16
18
  declare const MAX_SNAPSHOT_NODES = 1000;
@@ -77,6 +79,11 @@ type UiRequest = {
77
79
  readonly name?: string;
78
80
  readonly gone?: boolean;
79
81
  readonly timeoutMs?: number;
82
+ } | {
83
+ readonly op: 'reload';
84
+ } | {
85
+ readonly op: 'navigate';
86
+ readonly path: string;
80
87
  };
81
88
  /** What the page did, and to which control (for the audit log and the approval prompt). */
82
89
  interface UiActionResult {
@@ -141,4 +148,4 @@ interface AuditEntry {
141
148
  readonly approval: 'asked' | 'none' | 'not-needed' | 'token';
142
149
  }
143
150
  //#endregion
144
- export { parseUiRequest as S, UiResult as _, MAX_SNAPSHOT_NODES as a, parseChannelToHost as b, MUTATING_OPS as c, UiActionResult as d, UiControlError as f, UiRequest as g, UiOp as h, DEFAULT_SNAPSHOT_NODES as i, UI_ERROR_CODES as l, UiNode as m, ChannelToHost as n, MAX_TEXT_LENGTH as o, UiErrorCode as p, ChannelToPage as r, MAX_WAIT_MS as s, AuditEntry as t, UI_OPS as u, UiSnapshot as v, parseChannelToPage as x, WINDOW_ID as y };
151
+ export { parseUiRequest as C, parseChannelToPage as S, UiRequest as _, DEFAULT_SNAPSHOT_NODES as a, WINDOW_ID as b, MAX_WAIT_MS as c, UI_OPS as d, UiActionResult as f, UiOp as g, UiNode as h, ChannelToPage as i, MUTATING_OPS as l, UiErrorCode as m, AuditEntry as n, MAX_SNAPSHOT_NODES as o, UiControlError as p, ChannelToHost as r, MAX_TEXT_LENGTH as s, APP_PATH as t, UI_ERROR_CODES as u, UiResult as v, parseChannelToHost as x, UiSnapshot as y };
@@ -5,7 +5,7 @@ import { r as streamSSE } from "./streaming-DHST3Scg.js";
5
5
  import { loopbackAuthority, loopbackOnly } from "./loopback-only.js";
6
6
  import "./helpers-CvTauQT5.js";
7
7
  import { UiControlError, WINDOW_ID, parseChannelToHost } from "./door/protocol.js";
8
- import "./src-2Z4mJg0x.js";
8
+ import "./src-dy3Fk9tA.js";
9
9
  import "./middleware-CPQ8himN.js";
10
10
  import { dirname, join } from "node:path";
11
11
  import { closeSync, mkdirSync, openSync, readFileSync, renameSync, statSync, writeSync } from "node:fs";
@@ -26,6 +26,8 @@ var UiChannel = class {
26
26
  nextId = 1;
27
27
  clock = 0;
28
28
  closed = false;
29
+ /** Callers waiting for a page to (re)connect: each is told once, by the next stream that attaches. */
30
+ attachWaiters = /* @__PURE__ */ new Set();
29
31
  /**
30
32
  * Register a page's stream. A stream already open for the same window is closed first, and its calls settle as `disconnected`.
31
33
  * @returns the function to call when this stream ends.
@@ -40,6 +42,7 @@ var UiChannel = class {
40
42
  lastActive: ++this.clock
41
43
  };
42
44
  this.connections.set(windowId, connection);
45
+ for (const waiter of [...this.attachWaiters]) waiter();
43
46
  return () => {
44
47
  if (this.connections.get(windowId) === connection) this.drop(connection, false);
45
48
  };
@@ -110,6 +113,51 @@ var UiChannel = class {
110
113
  }
111
114
  });
112
115
  }
116
+ /**
117
+ * A promise for the next time a page connects (a page that reloaded or moved opens a new stream). Ask for it BEFORE the call that makes the page leave, or a fast page is missed.
118
+ * @returns whether a page connected within `timeoutMs`.
119
+ */
120
+ expectAttach(timeoutMs, count = 1) {
121
+ return new Promise((resolve) => {
122
+ let left = count;
123
+ const done = (value) => {
124
+ clearTimeout(timer);
125
+ this.attachWaiters.delete(waiter);
126
+ resolve(value);
127
+ };
128
+ const waiter = () => {
129
+ left -= 1;
130
+ if (left <= 0) done(true);
131
+ };
132
+ const timer = setTimeout(() => {
133
+ done(false);
134
+ }, timeoutMs);
135
+ this.attachWaiters.add(waiter);
136
+ });
137
+ }
138
+ /**
139
+ * Ask every connected page to reload (the app just changed under them) and wait, up to `timeoutMs`, for them to come back. Best effort: a page that does not come back
140
+ * (it was closed, or it moved to a page without the door) is not an error here.
141
+ * @returns how many pages were asked.
142
+ */
143
+ async reloadAll(timeoutMs = 8e3) {
144
+ const targets = this.byRecency();
145
+ if (this.closed || targets.length === 0) return 0;
146
+ const back = this.expectAttach(timeoutMs, targets.length);
147
+ for (const target of targets) {
148
+ const id = this.nextId;
149
+ this.nextId += 1;
150
+ try {
151
+ target.stream.send(JSON.stringify({
152
+ t: "call",
153
+ id,
154
+ request: { op: "reload" }
155
+ }));
156
+ } catch {}
157
+ }
158
+ await back;
159
+ return targets.length;
160
+ }
113
161
  /** Close every page's stream and settle every pending call as `unloaded`. */
114
162
  close() {
115
163
  this.closed = true;
@@ -39,6 +39,7 @@ var Router = class extends Service {
39
39
  /** Everything that changes after construction, kept in one object so the service's live state is in one place. */
40
40
  state = {
41
41
  mounts: /* @__PURE__ */ new Set(),
42
+ wrappers: /* @__PURE__ */ new Set(),
42
43
  errorHandler: void 0,
43
44
  notFoundHandler: void 0,
44
45
  composed: void 0,
@@ -95,6 +96,20 @@ var Router = class extends Service {
95
96
  };
96
97
  }
97
98
  /**
99
+ * Run `middleware` around every request the router serves, whenever the plugin that asks was mounted: a middleware in a plain mount wraps only the routes mounted after it,
100
+ * which is the wrong tool for a plugin that must see every page (the door adds its script to each HTML response, the pages of a plugin that loaded earlier included). It goes
101
+ * outside all mounts, in the order wrappers were added, and goes away when the calling plugin unloads or when the returned function is called.
102
+ */
103
+ wrap(middleware) {
104
+ this.state.wrappers.add(middleware);
105
+ this.state.composed = void 0;
106
+ const remove = () => {
107
+ if (this.state.wrappers.delete(middleware)) this.state.composed = void 0;
108
+ };
109
+ this.ctx.effect(() => remove, "acryl-router: wrapper");
110
+ return remove;
111
+ }
112
+ /**
98
113
  * The mount's own copy of `app`'s routes as they are now (like the core's `app.route`), each handler wrapped so the router knows which
99
114
  * mount's code a request is in. The core's `app.route` runs first so a sub-app's own `onError` still applies to its routes.
100
115
  */
@@ -251,6 +266,7 @@ var Router = class extends Service {
251
266
  strict: this.strict,
252
267
  router: new TrieRouter()
253
268
  });
269
+ for (const wrapper of this.state.wrappers) root.use("*", wrapper);
254
270
  for (const mount of this.state.mounts) root.route(mount.path, mount.app);
255
271
  root.onError(this.state.errorHandler ?? this.logError);
256
272
  if (this.state.notFoundHandler !== void 0) root.notFound(this.state.notFoundHandler);
@@ -226,8 +226,7 @@ const readBodyBufferedBeforeDisconnect = (incoming, chunks) => {
226
226
  else if (incomingWithRecovery[bodyBufferedLengthBeforeDisconnectKey] !== void 0 && incoming.readableLength !== incomingWithRecovery[bodyBufferedLengthBeforeDisconnectKey]) result = newBodyUnusableError();
227
227
  else {
228
228
  const bodyChunks = chunks ?? [];
229
- const chunk = incoming.read();
230
- if (chunk !== null) bodyChunks.push(toBufferChunk(chunk, incoming.readableEncoding));
229
+ for (let chunk = incoming.read(); chunk !== null; chunk = incoming.read()) bodyChunks.push(toBufferChunk(chunk, incoming.readableEncoding));
231
230
  const buffer = bodyChunks.length === 1 ? bodyChunks[0] : Buffer.concat(bodyChunks);
232
231
  result = buffer;
233
232
  const contentLength = incoming.headers["content-length"];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "acrylblends",
3
- "version": "1.0.2",
3
+ "version": "1.0.4",
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",
@@ -8,7 +8,7 @@
8
8
  "@deepseek-ai/cordis": "4.0.4",
9
9
  "@deepseek-ai/schemastery": "3.18.4"
10
10
  },
11
- "mount": "mount it after instance: root.plugin(dbSqlite, { migrations: 'migrations' }) with the migrations/ folder (001-name.sql, ...) next to src/; then a plugin that declares inject: ['db'] calls await ctx.db.all('SELECT ...', value)",
11
+ "mount": "mount it after instance, either as a row in blend.yaml (`- id: db` / `name: acryl-db-sqlite` / `config: { migrations: migrations }`, registered in src/app.ts as 'acryl-db-sqlite') or as root.plugin(dbSqlite, { migrations: 'migrations' }); a relative path is read from the app's folder, and the migrations/ folder (001-name.sql, ...) sits next to src/. Then a plugin that declares inject: ['db'] calls await ctx.db.all('SELECT ...', value)",
12
12
  "omit": ["**/*.test.*", "**/*.spec.*", "**/*.case.ts"],
13
13
  "imports": {
14
14
  "#acryl-db-sqlite": "acryl-db-sqlite/src/index.ts"