acrylblends 1.0.10 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (252) hide show
  1. package/README.md +9 -0
  2. package/dist/index.d.ts +60 -2
  3. package/dist/index.js +1992 -33
  4. package/dist/index.js.map +1 -1
  5. package/docs/framework/agents/index.md +1 -1
  6. package/docs/framework/api/summary.md +1 -1
  7. package/docs/framework/getting-started/desktop.md +23 -4
  8. package/docs/framework/guides/create-acrylblends.md +1 -1
  9. package/docs/framework/guides/production.md +20 -0
  10. package/docs/framework/workshop/README.md +174 -285
  11. package/docs/framework/workshop/facilitator.md +81 -90
  12. package/legacy/README.md +9 -0
  13. package/legacy/agent/157ce3db1112/src/app.ts +98 -0
  14. package/legacy/agent/157ce3db1112/src/routes.ts +63 -0
  15. package/legacy/agent/24c681a00fca/src/app.ts +160 -0
  16. package/legacy/agent/24c681a00fca/src/routes.ts +83 -0
  17. package/legacy/agent/2e790da977b2/src/app.ts +98 -0
  18. package/legacy/agent/2e790da977b2/src/routes.ts +63 -0
  19. package/legacy/agent/6307045545dc/src/app.ts +168 -0
  20. package/legacy/agent/6307045545dc/src/routes.ts +83 -0
  21. package/legacy/agent/6a5fb9c15c53/src/app.ts +158 -0
  22. package/legacy/agent/6a5fb9c15c53/src/routes.ts +83 -0
  23. package/legacy/agent/71fb54a5a942/src/app.ts +111 -0
  24. package/legacy/agent/71fb54a5a942/src/routes.ts +63 -0
  25. package/legacy/agent/74ece148269c/src/app.ts +168 -0
  26. package/legacy/agent/74ece148269c/src/routes.ts +93 -0
  27. package/legacy/agent/855359304707/src/app.ts +168 -0
  28. package/legacy/agent/855359304707/src/routes.ts +126 -0
  29. package/legacy/agent/b5ef3c786ae4/src/app.ts +127 -0
  30. package/legacy/agent/b5ef3c786ae4/src/routes.ts +76 -0
  31. package/legacy/agent/c2f30958043b/src/app.ts +98 -0
  32. package/legacy/agent/c2f30958043b/src/routes.ts +53 -0
  33. package/legacy/blank/5a91c558af22/src/routes.ts +18 -0
  34. package/legacy/blank/9c650cf52bf3/src/routes.ts +14 -0
  35. package/legacy/blank/aea589283445/src/routes.ts +16 -0
  36. package/legacy/blank/b2cbb759c889/src/routes.ts +16 -0
  37. package/legacy/blank/d6d371c9a65e/src/routes.ts +18 -0
  38. package/legacy/chat/7076cc821e2c/src/app.ts +72 -0
  39. package/legacy/chat/7076cc821e2c/src/routes.ts +13 -0
  40. package/legacy/chat/7dbe6d5170df/src/app.ts +107 -0
  41. package/legacy/chat/7dbe6d5170df/src/routes.ts +19 -0
  42. package/legacy/chat/a9a9ab44c58e/src/app.ts +107 -0
  43. package/legacy/chat/a9a9ab44c58e/src/routes.ts +15 -0
  44. package/legacy/chat/f20ad9a51b06/src/app.ts +106 -0
  45. package/legacy/chat/f20ad9a51b06/src/routes.ts +15 -0
  46. package/legacy/layouts.json +524 -0
  47. package/legacy/nodejs/1d477f37aaf4/src/app.ts +92 -0
  48. package/legacy/nodejs/1d477f37aaf4/src/routes.ts +40 -0
  49. package/legacy/nodejs/b1bd9462496c/src/app.ts +61 -0
  50. package/legacy/nodejs/b1bd9462496c/src/routes.ts +37 -0
  51. package/legacy/nodejs/d5bab75076ef/src/app.ts +95 -0
  52. package/legacy/nodejs/d5bab75076ef/src/routes.ts +40 -0
  53. package/legacy/notes/4edc36949c15/src/app.ts +107 -0
  54. package/legacy/notes/4edc36949c15/src/routes.ts +14 -0
  55. package/legacy/notes/6cab2d09747c/src/app.ts +72 -0
  56. package/legacy/notes/6cab2d09747c/src/routes.ts +12 -0
  57. package/legacy/notes/7ca2dfed623a/src/app.ts +106 -0
  58. package/legacy/notes/7ca2dfed623a/src/routes.ts +14 -0
  59. package/legacy/todo/02e613b3625c/src/app.ts +71 -0
  60. package/legacy/todo/02e613b3625c/src/todo.ts +123 -0
  61. package/legacy/todo/45694a8d9e15/src/app.ts +71 -0
  62. package/legacy/todo/45694a8d9e15/src/todo.ts +96 -0
  63. package/legacy/todo/5923d3a6099f/src/app.ts +71 -0
  64. package/legacy/todo/5923d3a6099f/src/todo.ts +125 -0
  65. package/legacy/todo/849a18d66dc1/src/app.ts +91 -0
  66. package/legacy/todo/849a18d66dc1/src/todo.ts +125 -0
  67. package/legacy/todo/8e64211312f3/src/app.ts +71 -0
  68. package/legacy/todo/8e64211312f3/src/todo.ts +125 -0
  69. package/legacy/todo/b79078a87565/src/app.ts +71 -0
  70. package/legacy/todo/b79078a87565/src/todo.ts +125 -0
  71. package/lib/{audit-Dw-siYTp.d.ts → audit-Bok_UNCn.d.ts} +41 -6
  72. package/lib/{client-modules-drj4mjhj.js → client-modules-Bkdr83jO.js} +1 -1
  73. package/lib/cordis/door.d.ts +44 -7
  74. package/lib/cordis/door.js +214 -60
  75. package/lib/cordis/inspect.js +1 -1
  76. package/lib/cordis/node-server.d.ts +101 -3
  77. package/lib/cordis/node-server.js +3 -3
  78. package/lib/cordis/router-http.d.ts +7 -1
  79. package/lib/cordis/router-http.js +44 -3
  80. package/lib/cordis/router.d.ts +1 -1
  81. package/lib/cordis/router.js +1 -1
  82. package/lib/cordis/state.d.ts +3 -2
  83. package/lib/cordis/state.js +371 -7
  84. package/lib/door/host.d.ts +2 -2
  85. package/lib/door/host.js +1 -1
  86. package/lib/door/page.d.ts +20 -1
  87. package/lib/door/page.js +75 -14
  88. package/lib/door/protocol.d.ts +1 -1
  89. package/lib/drivers.d.ts +10 -1
  90. package/lib/{index-DMOtA-NJ.d.ts → index-DGq7H0lb.d.ts} +11 -1
  91. package/lib/node-server.d.ts +7 -1
  92. package/lib/node-server.js +2 -2
  93. package/lib/{protocol-a2aqrp2X.d.ts → protocol-BeIYCgTo.d.ts} +4 -0
  94. package/lib/{snapshot-text-BfEbEOjA.js → snapshot-text-CXTnjxGx.js} +121 -9
  95. package/lib/src-CtkIX_Gg.js +427 -0
  96. package/lib/{src-BTBm4OPD.js → src-iBJ2ysV9.js} +77 -14
  97. package/lib/{test-websocket-BLeEvybj.js → test-websocket-CQOSvBth.js} +17 -7
  98. package/lib/{test-websocket-DyeS50Tr.d.ts → test-websocket-GUTAkFQT.d.ts} +3 -1
  99. package/package.json +2 -1
  100. package/plugins/acryl-door/src/host/channel.ts +124 -9
  101. package/plugins/acryl-door/src/host/chat-tools.ts +8 -6
  102. package/plugins/acryl-door/src/host/door-file.ts +7 -2
  103. package/plugins/acryl-door/src/host/door.ts +58 -23
  104. package/plugins/acryl-door/src/host/page-routes.ts +3 -1
  105. package/plugins/acryl-door/src/host/page-socket.ts +18 -8
  106. package/plugins/acryl-door/src/host/verbs.ts +3 -0
  107. package/plugins/acryl-door/src/index.ts +166 -29
  108. package/plugins/acryl-door/src/page/base.ts +32 -0
  109. package/plugins/acryl-door/src/page/channel.ts +11 -1
  110. package/plugins/acryl-door/src/page/driver/accessibility.ts +11 -0
  111. package/plugins/acryl-door/src/page/driver/actions.ts +4 -0
  112. package/plugins/acryl-door/src/page/driver/driver.ts +49 -7
  113. package/plugins/acryl-door/src/page/driver/snapshot.ts +5 -2
  114. package/plugins/acryl-door/src/page/index.ts +46 -2
  115. package/plugins/acryl-door/src/page/indicator.ts +14 -6
  116. package/plugins/acryl-door/src/page/outline.ts +56 -0
  117. package/plugins/acryl-door/src/page/protocol.ts +4 -0
  118. package/plugins/acryl-door/src/page/snapshot-text.ts +2 -1
  119. package/plugins/acryl-node-server/src/adapter/listener.ts +4 -0
  120. package/plugins/acryl-node-server/src/adapter/raw.ts +18 -0
  121. package/plugins/acryl-node-server/src/adapter/websocket.ts +15 -9
  122. package/plugins/acryl-node-server/src/client-modules.ts +7 -1
  123. package/plugins/acryl-node-server/src/index.ts +304 -16
  124. package/plugins/acryl-node-server/src/upgrade-hub.ts +2 -2
  125. package/plugins/acryl-page/plugin.json +12 -0
  126. package/plugins/acryl-page/src/index.ts +163 -0
  127. package/plugins/acryl-router/src/service.ts +77 -14
  128. package/plugins/acryl-router-http/src/index.ts +15 -1
  129. package/plugins/acryl-router-http/src/serve.ts +31 -0
  130. package/plugins/acryl-state/plugin.json +4 -3
  131. package/plugins/acryl-state/src/client-source.ts +244 -0
  132. package/plugins/acryl-state/src/service.ts +33 -6
  133. package/plugins/acryl-state/src/socket.ts +96 -0
  134. package/plugins/acryl-web-server/plugin.json +14 -0
  135. package/plugins/acryl-web-server/src/index.ts +164 -0
  136. package/plugins/agent/agent.ts +38 -7
  137. package/plugins/agent/index.ts +1 -1
  138. package/plugins/agent/model.ts +10 -2
  139. package/plugins/agent/tools.ts +9 -3
  140. package/plugins/agent-panel/client/describe.ts +57 -0
  141. package/plugins/agent-panel/client/driver-pick.ts +22 -0
  142. package/plugins/agent-panel/client/frame.ts +51 -0
  143. package/plugins/agent-panel/client/main.ts +343 -81
  144. package/plugins/agent-panel/drivers.ts +7 -2
  145. package/plugins/agent-panel/headless.ts +5 -2
  146. package/plugins/agent-panel/index.ts +187 -61
  147. package/plugins/agent-panel/mount.ts +27 -0
  148. package/plugins/agent-panel/visitors.ts +231 -0
  149. package/plugins/desktop/frame.ts +231 -0
  150. package/plugins/desktop/launch.ts +44 -9
  151. package/plugins/desktop/main.ts +100 -32
  152. package/plugins/desktop/window.ts +21 -4
  153. package/plugins/driver-pty/client/terminal.ts +4 -1
  154. package/plugins/driver-pty/index.ts +38 -11
  155. package/plugins/driver-pty/spawn.ts +38 -0
  156. package/plugins/driver-pty/terminal.ts +2 -0
  157. package/plugins/extensions/index.ts +26 -1
  158. package/plugins/extensions/resolve.ts +13 -0
  159. package/plugins/feedback/client.d.ts +22 -0
  160. package/plugins/feedback/client.js +471 -0
  161. package/plugins/feedback/index.ts +143 -0
  162. package/plugins/feedback/plugin.json +11 -0
  163. package/plugins/feedback/store.ts +307 -0
  164. package/plugins/home/index.ts +122 -0
  165. package/plugins/home/plugin.json +11 -0
  166. package/plugins/instance/index.ts +12 -3
  167. package/plugins/manifest/app-plugins.ts +86 -0
  168. package/plugins/manifest/index.ts +12 -2
  169. package/plugins/manifest/snapshot.ts +275 -0
  170. package/plugins/openai/index.ts +64 -5
  171. package/plugins/openai/prompt.ts +23 -0
  172. package/shared/AGENTS.core.md +36 -16
  173. package/shared/skills/acryl-add-ui/SKILL.md +19 -0
  174. package/shared/skills/acryl-app/SKILL.md +13 -0
  175. package/shared/skills/acryl-build-plugin/SKILL.md +22 -0
  176. package/shared/skills/acryl-change-plugin/SKILL.md +14 -0
  177. package/shared/skills/acryl-diagnose-plugin/SKILL.md +13 -0
  178. package/shared/skills/acryl-fix-plugin/SKILL.md +13 -0
  179. package/shared/skills/acryl-remove-plugin/SKILL.md +12 -0
  180. package/shared/skills/acryl-share-plugin/SKILL.md +16 -0
  181. package/shared/skills/cordis-plugin/SKILL.md +26 -0
  182. package/templates/agent/AGENTS.md +4 -2
  183. package/templates/agent/blend.yaml +4 -0
  184. package/templates/agent/gitignore +1 -0
  185. package/templates/agent/package.json +1 -0
  186. package/templates/agent/src/app.ts +150 -33
  187. package/templates/agent/src/frame.ts +17 -0
  188. package/templates/agent/src/main.ts +5 -3
  189. package/templates/agent/src/routes.ts +73 -21
  190. package/templates/agent/template.json +2 -0
  191. package/templates/agent/test/app.test.ts +83 -5
  192. package/templates/blank/AGENTS.md +3 -2
  193. package/templates/blank/blend.yaml +11 -0
  194. package/templates/blank/gitignore +1 -0
  195. package/templates/blank/package.json +1 -0
  196. package/templates/blank/src/app.ts +100 -29
  197. package/templates/blank/src/door.ts +7 -0
  198. package/templates/blank/src/first-page.ts +5 -0
  199. package/templates/blank/src/frame.ts +17 -0
  200. package/templates/blank/src/routes.ts +65 -8
  201. package/templates/blank/template.json +14 -1
  202. package/templates/blank/test/app.test.ts +103 -16
  203. package/templates/chat/blend.yaml +2 -1
  204. package/templates/chat/gitignore +1 -0
  205. package/templates/chat/package.json +1 -0
  206. package/templates/chat/src/app.ts +72 -22
  207. package/templates/chat/src/chat-app.ts +11 -2
  208. package/templates/chat/src/client/chat.tsx +4 -2
  209. package/templates/chat/src/frame.ts +17 -0
  210. package/templates/chat/src/main.ts +1 -6
  211. package/templates/chat/src/page.tsx +6 -4
  212. package/templates/chat/src/routes.ts +14 -4
  213. package/templates/chat/src/runtime.ts +9 -3
  214. package/templates/chat/template.json +3 -1
  215. package/templates/chat/test/chat.test.ts +46 -1
  216. package/templates/chat/test/server.test.ts +17 -3
  217. package/templates/nodejs/blend.yaml +2 -1
  218. package/templates/nodejs/gitignore +1 -0
  219. package/templates/nodejs/package.json +1 -0
  220. package/templates/nodejs/src/app.ts +76 -21
  221. package/templates/nodejs/src/frame.ts +17 -0
  222. package/templates/nodejs/src/main.ts +1 -6
  223. package/templates/nodejs/src/page.tsx +3 -3
  224. package/templates/nodejs/src/routes.ts +32 -13
  225. package/templates/nodejs/template.json +3 -1
  226. package/templates/nodejs/test/app.test.ts +38 -3
  227. package/templates/nodejs/test/server.test.ts +17 -3
  228. package/templates/notes/blend.yaml +2 -1
  229. package/templates/notes/gitignore +1 -0
  230. package/templates/notes/package.json +1 -0
  231. package/templates/notes/src/app.ts +72 -22
  232. package/templates/notes/src/frame.ts +17 -0
  233. package/templates/notes/src/main.ts +1 -6
  234. package/templates/notes/src/notes-app.ts +24 -17
  235. package/templates/notes/src/routes.ts +11 -4
  236. package/templates/notes/src/runtime.ts +9 -3
  237. package/templates/notes/src/views.tsx +26 -17
  238. package/templates/notes/template.json +3 -1
  239. package/templates/notes/test/notes.test.ts +39 -1
  240. package/templates/notes/test/server.test.ts +17 -3
  241. package/templates/todo/blend.yaml +3 -0
  242. package/templates/todo/gitignore +1 -0
  243. package/templates/todo/package.json +1 -0
  244. package/templates/todo/src/app.ts +81 -27
  245. package/templates/todo/src/door.ts +7 -0
  246. package/templates/todo/src/frame.ts +17 -0
  247. package/templates/todo/src/page.ts +13 -6
  248. package/templates/todo/src/todo.ts +18 -6
  249. package/templates/todo/template.json +11 -1
  250. package/templates/todo/test/todo.test.ts +67 -9
  251. package/lib/src-LX0E4ec4.js +0 -117
  252. package/shared/SKILL.md +0 -14
@@ -15,14 +15,14 @@ export const failingPage = (): string => {
15
15
  throw new Error('a page failed')
16
16
  }
17
17
 
18
- /** A page with one client component: `src/client/counter.tsx` runs in the browser, compiled on request by `clientModules` (see src/routes.ts). */
19
- export const counterPage = (): string =>
18
+ /** A page with one client component: `src/client/counter.tsx` runs in the browser, compiled on request by `clientModules` (see src/routes.ts). `prefix` is the path the app is mounted at, so the script URL follows it. */
19
+ export const counterPage = (prefix = ''): string =>
20
20
  (
21
21
  <html lang="en">
22
22
  <body>
23
23
  <h1>Counter</h1>
24
24
  <div id="counter" />
25
- <script type="module" src="/client/counter.tsx" />
25
+ <script type="module" src={`${prefix}/client/counter.tsx`} />
26
26
  {doorEnabled() ? <script type="module" src="/api/acryl-door/page.js" /> : null}
27
27
  </body>
28
28
  </html>
@@ -14,27 +14,46 @@ const runtimeDir = join(
14
14
 
15
15
  export const APP_NAME = '__APP_NAME__'
16
16
 
17
- /** The routes, in a file that does not import the Node adapter's server, so browser code can import the type: `import type { AppType } from '../routes.ts'`. */
18
- export const pages = new Acryl()
19
- .get('/', (c) => c.json({ app: APP_NAME, ok: true }))
20
- .get('/health', (c) => c.json({ ok: true }))
21
- .get('/about', (c) => c.html(aboutPage()))
22
- .get('/counter', (c) => c.html(counterPage()))
17
+ /**
18
+ * The only place the app's mount prefix is named: the root on its own server (`npm start`), `/app` when the ACRYL frame hosts it (`npm run dev`). Every
19
+ * URL the pages write is derived from the prefix the app is mounted at, so the standalone URLs do not move.
20
+ */
21
+ export const FRAME_MOUNT_PREFIX = '/app'
22
+
23
+ /**
24
+ * The app's own pages, mountable at any prefix: the standalone server mounts it at the root, the frame row at {@link FRAME_MOUNT_PREFIX}.
25
+ * A page that carries a client component writes its URL from the prefix it is mounted at.
26
+ */
27
+ export function makeApp(prefix = ''): Acryl {
28
+ return new Acryl()
29
+ .get('/', (c) => c.json({ app: APP_NAME, ok: true }))
30
+ .get('/health', (c) => c.json({ ok: true }))
31
+ .get('/about', (c) => c.html(aboutPage()))
32
+ .get('/counter', (c) => c.html(counterPage(prefix)))
33
+ }
23
34
 
24
- export type AppType = typeof pages
35
+ /** The routes, in a file that does not import the Node adapter's server, so browser code can import the type: `import type { AppType } from '../routes.ts'`. */
36
+ export type AppType = ReturnType<typeof makeApp>
25
37
 
26
38
  /**
27
- * The app's own plugin: the routes above, and the client components without a bundler (`src/client` is compiled when the browser asks; `npm run build`
28
- * compiles it once for production, and `NODE_ENV=production` then serves only `dist/client`). The mounts are removed when the plugin is disposed.
39
+ * The app's own plugin: the pages above at `options.prefix` (the root when absent), and the client components without a bundler (`src/client` is compiled
40
+ * when the browser asks; `npm run build` compiles it once for production, and `NODE_ENV=production` then serves only `dist/client`). The mounts are
41
+ * removed when the plugin is disposed.
29
42
  */
30
43
  export const routes = {
31
44
  name: 'routes',
32
45
  inject: ['router'],
33
- apply(ctx: Context) {
34
- ctx.router.route('/', pages)
46
+ apply(ctx: Context, options: { prefix?: string } = {}) {
47
+ const prefix = options.prefix ?? ''
48
+ ctx.router.route(prefix === '' ? '/' : prefix, makeApp(prefix))
35
49
  ctx.router.route(
36
- '/client',
37
- clientModules({ root: 'src/client', compiled: 'dist/client', runtimeDir }),
50
+ `${prefix}/client`,
51
+ clientModules({
52
+ root: 'src/client',
53
+ compiled: 'dist/client',
54
+ runtimeDir,
55
+ mount: `${prefix}/client`,
56
+ }),
38
57
  )
39
58
  },
40
59
  }
@@ -6,6 +6,8 @@
6
6
  "manifest",
7
7
  "save",
8
8
  "extensions",
9
- "acryl-door"
9
+ "acryl-door",
10
+ "acryl-web-server",
11
+ "acryl-page"
10
12
  ]
11
13
  }
@@ -6,7 +6,7 @@ import { test } from 'node:test'
6
6
  import { fileURLToPath } from 'node:url'
7
7
  import { pinnedInstance, withPort } from '../plugins/instance/index.ts'
8
8
  import { type App, createApp } from '../src/app.ts'
9
- import { APP_NAME } from '../src/routes.ts'
9
+ import { APP_NAME, FRAME_MOUNT_PREFIX } from '../src/routes.ts'
10
10
 
11
11
  /** Throwaway homes and databases live inside the project (`.acrylblends/` is ignored by git), because a sandboxed agent may not write to the system temp folder. */
12
12
  const scratch = join(fileURLToPath(new URL('..', import.meta.url)), '.acrylblends', 'test')
@@ -14,11 +14,12 @@ mkdirSync(scratch, { recursive: true })
14
14
 
15
15
  const projectRoot = fileURLToPath(new URL('..', import.meta.url))
16
16
 
17
- /** Boot the app in a throwaway home on any free port, run the test, always stop it and remove the home. */
18
- async function withApp(run: (app: App) => Promise<void>) {
17
+ /** Boot the app in a throwaway home on any free port, run the test, always stop it and remove the home. `prefix` mounts it where the frame does. */
18
+ async function withApp(run: (app: App) => Promise<void>, options: { prefix?: string } = {}) {
19
19
  const home = await mkdtemp(join(scratch, 'app-test-'))
20
20
  const app = await createApp(withPort(pinnedInstance(home, { root: projectRoot }), 0), {
21
21
  listen: false,
22
+ ...options,
22
23
  })
23
24
  try {
24
25
  await run(app)
@@ -76,3 +77,37 @@ test('GET /counter serves a page with a client component, and the component is c
76
77
  const runtime = await app.root.router.request('/client/@acrylblends/jsx-dom.js')
77
78
  assert.equal(runtime.status, 200)
78
79
  }))
80
+
81
+ test('the frame mounts the app at /app: its pages, health route and client component follow the prefix', () =>
82
+ withApp(
83
+ async (app) => {
84
+ const root = await app.root.router.request(FRAME_MOUNT_PREFIX)
85
+ assert.equal(root.status, 200)
86
+ assert.deepEqual(await root.json(), { app: APP_NAME, ok: true })
87
+ const about = await app.root.router.request(`${FRAME_MOUNT_PREFIX}/about`)
88
+ assert.equal(about.status, 200)
89
+ assert.match(await about.text(), /<main><h1>About<\/h1>/u)
90
+ const health = await app.root.router.request(`${FRAME_MOUNT_PREFIX}/health`)
91
+ assert.equal(health.status, 200)
92
+ assert.deepEqual(await health.json(), { ok: true })
93
+ const page = await app.root.router.request(`${FRAME_MOUNT_PREFIX}/counter`)
94
+ assert.equal(page.status, 200)
95
+ assert.match(
96
+ await page.text(),
97
+ new RegExp(`<script type="module" src="${FRAME_MOUNT_PREFIX}/client/counter\\.tsx">`, 'u'),
98
+ )
99
+ const module = await app.root.router.request(`${FRAME_MOUNT_PREFIX}/client/counter.tsx`)
100
+ assert.equal(module.status, 200)
101
+ assert.match(module.headers.get('content-type') ?? '', /javascript/u)
102
+ assert.doesNotMatch(await module.text(), /<Counter|<button/u)
103
+ // The component's import of the DOM renderer is served below the app's prefix too.
104
+ assert.equal(
105
+ (await app.root.router.request(`${FRAME_MOUNT_PREFIX}/client/@acrylblends/jsx-dom.js`))
106
+ .status,
107
+ 200,
108
+ )
109
+ // The app's own URLs are not mounted in the frame; its own server keeps them (the tests above).
110
+ assert.equal((await app.root.router.request('/about')).status, 404)
111
+ },
112
+ { prefix: FRAME_MOUNT_PREFIX },
113
+ ))
@@ -12,10 +12,24 @@ const scratch = join(fileURLToPath(new URL('..', import.meta.url)), '.acrylblend
12
12
  mkdirSync(scratch, { recursive: true })
13
13
 
14
14
  /** A sandbox may forbid listening on a port (Codex's workspace-write does); the tests that start the real server are skipped there, and every other test runs in process. */
15
+ /**
16
+ * Whether this environment forbids listening at all: only a sandbox's refusal (EPERM/EACCES) means the real-server test must skip. A loaded
17
+ * machine can fail a bind transiently, so that is retried briefly; any other outcome lets the real test run, where a host that truly cannot
18
+ * listen fails loudly instead of hiding behind a skip.
19
+ */
15
20
  const canListen = await new Promise<boolean>((done) => {
16
- const probe = createServer()
17
- probe.once('error', () => done(false))
18
- probe.listen(0, '127.0.0.1', () => probe.close(() => done(true)))
21
+ let attempts = 0
22
+ const probeOnce = (): void => {
23
+ attempts += 1
24
+ const probe = createServer()
25
+ probe.once('error', (error: NodeJS.ErrnoException) => {
26
+ if (error.code === 'EPERM' || error.code === 'EACCES') done(false)
27
+ else if (attempts < 3) setTimeout(probeOnce, 100)
28
+ else done(true)
29
+ })
30
+ probe.listen(0, '127.0.0.1', () => probe.close(() => done(true)))
31
+ }
32
+ probeOnce()
19
33
  })
20
34
  const skip = canListen ? false : 'this environment does not allow listening on a port'
21
35
  if (!canListen)
@@ -20,7 +20,8 @@ spec:
20
20
  name: acryl-db-sqlite
21
21
  - id: routes
22
22
  name: routes
23
- # Plugins installed while the app runs (the door's `plugin add`): development only, src/app.ts leaves it out when NODE_ENV=production.
23
+ # Plugin folders without a row of their own (a live install with the door's `plugin add`); a folder with a row is the app's and starts by it.
24
+ # Read-only when NODE_ENV=production.
24
25
  - id: extensions
25
26
  name: extensions
26
27
  - id: server
@@ -4,3 +4,4 @@ node_modules/
4
4
  data/
5
5
  *.sqlite
6
6
  *.sqlite-*
7
+ package-lock.json
@@ -17,6 +17,7 @@
17
17
  "check": "tsc --noEmit && tsc --noEmit -p src/client && acrylblends check-deps",
18
18
  "build": "acrylblends build-client",
19
19
  "lock": "node plugins/manifest/lock.ts",
20
+ "snapshot": "node plugins/manifest/snapshot.ts",
20
21
  "save": "node plugins/save/cli.ts",
21
22
  "upgrade": "node plugins/manifest/upgrade-cli.ts"
22
23
  },
@@ -8,13 +8,15 @@ import * as router from '../plugins/acryl-router/src/index.ts'
8
8
  import { extensions } from '../plugins/extensions/index.ts'
9
9
  import { type AppInstance, instance } from '../plugins/instance/index.ts'
10
10
  import {
11
+ appPluginNames,
11
12
  blueprintsIn,
12
13
  compose,
14
+ loadAppPlugins,
13
15
  loadManifest,
14
16
  type ParameterValue,
15
17
  } from '../plugins/manifest/index.ts'
16
18
  import { doorEnabled } from './door.ts'
17
- import { routes } from './routes.ts'
19
+ import { FRAME_MOUNT_PREFIX, routes } from './routes.ts'
18
20
 
19
21
  /** The plugins this app's code provides. `blend.yaml` chooses, orders and configures them. */
20
22
  export const registry = {
@@ -30,11 +32,13 @@ const projectRoot = fileURLToPath(new URL('..', import.meta.url))
30
32
 
31
33
  /** The manifest next to this app's source. */
32
34
  export const MANIFEST = join(projectRoot, 'blend.yaml')
35
+ /** The app's plugins: the library copies `registry` imports, and the app's own plugins (a feature each), which `blend.yaml` mounts by their folder name. */
36
+ const PLUGINS_DIR = join(projectRoot, 'plugins')
33
37
 
34
38
  /** The manifest, resolved. `createApp` and the tests read it the same way. */
35
39
  export function readManifest(values?: Readonly<Record<string, ParameterValue>>) {
36
40
  return loadManifest(MANIFEST, {
37
- known: Object.keys(registry),
41
+ known: [...Object.keys(registry), ...appPluginNames(PLUGINS_DIR)],
38
42
  getBlueprint: blueprintsIn(join(projectRoot, 'blueprints')),
39
43
  ...(values ? { values } : {}),
40
44
  })
@@ -64,42 +68,88 @@ export interface AppOptions {
64
68
  readonly values?: Readonly<Record<string, ParameterValue>>
65
69
  /** The SQLite file, relative to the app folder; an absolute path is used as it is (tests give a file in a throwaway home). Default: `data/notes.sqlite`. */
66
70
  readonly database?: string
71
+ /** The path the app is mounted at; absent on its own server, where it keeps the root, and {@link FRAME_MOUNT_PREFIX} in the frame. */
72
+ readonly prefix?: string
67
73
  /** Start the HTTP server (default). Tests pass `false` and call `app.root.router.request(path)`: it needs no port, so it also runs where a sandbox forbids listening. */
68
74
  readonly listen?: boolean
69
75
  }
70
76
 
77
+ /** The rows the frame host provides: the app contributes only the plugins that are its own. */
78
+ const HOST_ROWS = [
79
+ 'instance',
80
+ 'acryl-router',
81
+ 'acryl-node-server',
82
+ 'acryl-web-server',
83
+ 'acryl-page',
84
+ 'manifest',
85
+ 'save',
86
+ 'acryl-door',
87
+ ]
88
+
71
89
  /**
72
- * The app: `instance` first (where it lives, chosen at run time), then the manifest's rows in order. The `db` row opens the database and applies
73
- * `migrations/`; the one place that names the SQLite provider is this file (`acrylblends check-deps`), so a different provider is a change here.
90
+ * The app's rows, composed into `ctx`: the app's own server calls this from `createApp`, and the generated frame layer mounts it as one row, so the frame
91
+ * runs the app exactly as the app's own server does (same rows, same order, same config). `host: true` leaves the infrastructure rows to the frame host,
92
+ * which is where the door lives too, and mounts the app at {@link FRAME_MOUNT_PREFIX}. The `db` row opens the database and applies `migrations/`; the one
93
+ * place that names the SQLite provider is this file (`acrylblends check-deps`), so a different provider is a change here.
74
94
  */
75
- export async function createApp(chosen: AppInstance, options: AppOptions = {}): Promise<App> {
95
+ export async function composeInto(
96
+ ctx: Context,
97
+ chosen: AppInstance,
98
+ options: AppOptions & { readonly host?: boolean } = {},
99
+ ): Promise<Fiber[]> {
76
100
  const read = readManifest(options.values)
77
- const listen = options.listen
78
- // Rows left out: the server (and the door, which needs it) when the app is reached in process; the door when it is switched off; live plugin installs in production.
79
- const leaveOut = new Set<string>()
80
- if (listen === false) leaveOut.add('acryl-node-server').add('acryl-door')
101
+ const own = options.host !== true && options.listen !== false
102
+ // The frame host mounts the app under its own prefix; the app's own server keeps the root.
103
+ const prefix = options.prefix ?? (options.host === true ? FRAME_MOUNT_PREFIX : undefined)
104
+ // Rows left out: the server (and the door, which needs it) when the app is reached in process or inside the frame host; the door when it is switched
105
+ // off.
106
+ const leaveOut = new Set<string>(options.host === true ? HOST_ROWS : [])
107
+ if (!own) leaveOut.add('acryl-node-server').add('acryl-door')
81
108
  if (!doorEnabled()) leaveOut.add('acryl-door')
82
- if (process.env.NODE_ENV === 'production') leaveOut.add('extensions')
83
109
  const manifest = without(read, ...leaveOut)
110
+ // The rows that name one of the app's own plugin folders (a feature: plugins/<name>/ and its row) rather than a registry import. Each one that runs
111
+ // is imported here; all of them, off or on, belong to their row, so the extensions row never starts them a second time.
112
+ const ownRows = [
113
+ ...new Set(read.plugins.map((row) => row.use).filter((use) => !Object.hasOwn(registry, use))),
114
+ ]
115
+ const ownPlugins = await loadAppPlugins(
116
+ PLUGINS_DIR,
117
+ ownRows.filter((use) => manifest.plugins.some((row) => row.use === use && !row.disabled)),
118
+ )
84
119
  const file = options.database ?? 'data/notes.sqlite'
85
- const root = new Context()
86
- const first = root.plugin(instance, chosen)
87
- await first.await()
88
- const fibers: Fiber[] = [
89
- first,
90
- ...(await compose(root, manifest, registry, {
120
+ return compose(
121
+ ctx,
122
+ manifest,
123
+ { ...registry, ...ownPlugins },
124
+ {
91
125
  db: {
92
126
  path: isAbsolute(file) ? file : resolve(projectRoot, file),
93
127
  migrations: join(projectRoot, 'migrations'),
94
128
  },
95
- // What is installed while the app runs lives in plugins/, next to the plugins copied from the library (plugins/library.json lists those, so they are not installed twice).
96
- extensions: { sources: [{ dir: join(projectRoot, 'plugins'), trust: 'install' }] },
97
- server: { port: chosen.port.start, scan: chosen.port.scan ? 20 : 0 },
98
- })),
99
- ]
129
+ // The routes row mounts the app (and its client modules) at the prefix the frame host chose.
130
+ routes: { ...(prefix === undefined ? {} : { prefix }) },
131
+ // The app's own plugins live in plugins/, next to the plugins copied from the library (plugins/library.json lists those, so they are not installed twice).
132
+ // In production they start read-only from where they are deployed: the app keeps its features, and nothing can be installed while it runs.
133
+ extensions: {
134
+ ...(process.env.NODE_ENV === 'production' ? { readOnly: true } : {}),
135
+ sources: [{ dir: PLUGINS_DIR, trust: 'install' }],
136
+ rows: ownRows,
137
+ },
138
+ // Only the app's own server listens; the frame host owns the server and its port.
139
+ ...(own ? { server: { port: chosen.port.start, scan: chosen.port.scan ? 20 : 0 } } : {}),
140
+ },
141
+ )
142
+ }
143
+
144
+ /** The app: `instance` first (where it lives, chosen at run time), then the manifest's rows in order. */
145
+ export async function createApp(chosen: AppInstance, options: AppOptions = {}): Promise<App> {
146
+ const root = new Context()
147
+ const first = root.plugin(instance, chosen)
148
+ await first.await()
149
+ const fibers: Fiber[] = [first, ...(await composeInto(root, chosen, options))]
100
150
  return {
101
151
  root,
102
- url: listen === false ? '' : root.nodeServer.url,
152
+ url: options.listen === false ? '' : root.nodeServer.url,
103
153
  async stop() {
104
154
  for (const fiber of [...fibers].reverse()) await fiber.dispose()
105
155
  },
@@ -0,0 +1,17 @@
1
+ /**
2
+ * The app as the ACRYL frame hosts it (`npm run dev`): the generated layer mounts this row, which claims {@link FRAME_MOUNT_PREFIX} on the frame's web
3
+ * server. The app's own rows (the generated `app-plugins` row, which calls `composeInto`) mount the app at that prefix, so its pages and its client
4
+ * modules answer at `/app`. Nothing here runs in production: `npm start` composes the app on its own server (no `routerHttp` service, so this row is not
5
+ * mounted there), where the app keeps the root as it always did.
6
+ */
7
+ import type { Context } from '@deepseek-ai/cordis'
8
+ import { FRAME_MOUNT_PREFIX } from './routes.ts'
9
+
10
+ export const name = 'app-routes'
11
+
12
+ /** The frame always has it: its layer carries the router and the web adapter. */
13
+ export const inject = ['routerHttp']
14
+
15
+ export function apply(ctx: Context): void {
16
+ ctx.routerHttp.mount(FRAME_MOUNT_PREFIX)
17
+ }
@@ -5,12 +5,7 @@ import { createApp } from './app.ts'
5
5
  // The composition root: the one place that reads the environment and chooses where the app lives.
6
6
  const chosen = selectInstance({
7
7
  projectRoot: fileURLToPath(new URL('..', import.meta.url)),
8
- // PORT and HOST are the names hosting platforms set; ACRYLBLENDS_PORT and ACRYLBLENDS_HOST win when both are given.
9
- env: {
10
- ...(process.env.PORT ? { ACRYLBLENDS_PORT: process.env.PORT } : {}),
11
- ...(process.env.HOST ? { ACRYLBLENDS_HOST: process.env.HOST } : {}),
12
- ...process.env,
13
- },
8
+ env: process.env,
14
9
  })
15
10
  const app = await createApp(chosen, {
16
11
  ...(process.env.NOTES_DB ? { database: process.env.NOTES_DB } : {}),
@@ -24,6 +24,8 @@ export interface AppOptions {
24
24
  trustProxy?: boolean
25
25
  /** The address people use in the browser, for example `https://notes.example.com`. Behind a proxy the app sees `http://127.0.0.1:3000`, so csrf needs it. Default: PUBLIC_ORIGIN. */
26
26
  publicOrigin?: string
27
+ /** The path the app is mounted at: '' on its own server, `/app` in the ACRYL frame. Every link, form action and redirect is written from it. */
28
+ prefix?: string
27
29
  }
28
30
 
29
31
  export function createApi(db: Db) {
@@ -74,11 +76,14 @@ export function createApp(
74
76
  {
75
77
  trustProxy = process.env.TRUST_PROXY === '1',
76
78
  publicOrigin = process.env.PUBLIC_ORIGIN,
79
+ prefix = '',
77
80
  }: AppOptions = {},
78
81
  ) {
79
82
  const auth = authStore(db)
80
83
  const notes = notesStore(db)
81
84
  const app = new Acryl<Env>()
85
+ // Every URL the pages and redirects write starts at the path the app is mounted at.
86
+ const at = (path: string): string => `${prefix}${path}`
82
87
  // Refuses a form POST that does not come from this site (checks Origin and Sec-Fetch-Site); JSON requests are not form-shaped and pass.
83
88
  app.use(csrf(publicOrigin ? { origin: [publicOrigin] } : undefined))
84
89
  // A secure cookie is only sent over HTTPS: set when the request is HTTPS, or a proxy we trust says it was.
@@ -100,14 +105,14 @@ export function createApp(
100
105
 
101
106
  const requireUser = createMiddleware<Env>(async (c, next) => {
102
107
  const user = await auth.userFor(getCookie(c, COOKIE))
103
- if (!user) return c.redirect('/login')
108
+ if (!user) return c.redirect(at('/login'))
104
109
  c.set('user', user)
105
110
  await next()
106
111
  })
107
112
 
108
- app.get('/', (c) => c.redirect('/notes'))
109
- app.get('/signup', (c) => c.html(authPage('signup')))
110
- app.get('/login', (c) => c.html(authPage('login')))
113
+ app.get('/', (c) => c.redirect(at('/notes')))
114
+ app.get('/signup', (c) => c.html(authPage('signup', [], '', prefix)))
115
+ app.get('/login', (c) => c.html(authPage('login', [], '', prefix)))
111
116
 
112
117
  app.post('/signup', async (c) => {
113
118
  const form = await c.req.parseBody()
@@ -117,10 +122,10 @@ export function createApp(
117
122
  const errors = checkCredentials(email, form.password, true)
118
123
  if (errors.length === 0 && (await auth.findUser(email)))
119
124
  errors.push('That email already has an account. Log in instead.')
120
- if (errors.length > 0) return c.html(authPage('signup', errors, email), 400)
125
+ if (errors.length > 0) return c.html(authPage('signup', errors, email, prefix), 400)
121
126
  const id = await auth.createUser(email, String(form.password))
122
127
  setCookie(c, COOKIE, await auth.startSession(id), session(c))
123
- return c.redirect('/notes', 303)
128
+ return c.redirect(at('/notes'), 303)
124
129
  })
125
130
 
126
131
  app.post('/login', async (c) => {
@@ -129,19 +134,19 @@ export function createApp(
129
134
  .trim()
130
135
  .toLowerCase()
131
136
  const errors = checkCredentials(email, form.password, false)
132
- if (errors.length > 0) return c.html(authPage('login', errors, email), 400)
137
+ if (errors.length > 0) return c.html(authPage('login', errors, email, prefix), 400)
133
138
  const user = await auth.findUser(email)
134
139
  if (!user || !(await verifyPassword(String(form.password), user.password_hash)))
135
- return c.html(authPage('login', ['Wrong email or password.'], email), 401)
140
+ return c.html(authPage('login', ['Wrong email or password.'], email, prefix), 401)
136
141
  setCookie(c, COOKIE, await auth.startSession(user.id), session(c))
137
- return c.redirect('/notes', 303)
142
+ return c.redirect(at('/notes'), 303)
138
143
  })
139
144
 
140
145
  app.post('/logout', async (c) => {
141
146
  const token = getCookie(c, COOKIE)
142
147
  if (token) await auth.endSession(token)
143
148
  deleteCookie(c, COOKIE, { path: '/' })
144
- return c.redirect('/login', 303)
149
+ return c.redirect(at('/login'), 303)
145
150
  })
146
151
 
147
152
  app.use('/notes/*', requireUser)
@@ -149,10 +154,10 @@ export function createApp(
149
154
 
150
155
  app.get('/notes', async (c) => {
151
156
  const q = (c.req.query('q') ?? '').trim()
152
- return c.html(listPage(c.get('user').email, await notes.list(c.get('user').id, q), q))
157
+ return c.html(listPage(c.get('user').email, await notes.list(c.get('user').id, q), q, prefix))
153
158
  })
154
159
  app.get('/notes/new', (c) =>
155
- c.html(formPage(c.get('user').email, '/notes', { title: '', body: '' })),
160
+ c.html(formPage(c.get('user').email, '/notes', { title: '', body: '' }, [], prefix)),
156
161
  )
157
162
  app.post('/notes', async (c) => {
158
163
  const form = await c.req.parseBody()
@@ -164,17 +169,18 @@ export function createApp(
164
169
  '/notes',
165
170
  { title: String(form.title ?? ''), body: String(form.body ?? '') },
166
171
  checked.issues.map((i) => i.message),
172
+ prefix,
167
173
  ),
168
174
  400,
169
175
  )
170
176
  await notes.create(c.get('user').id, checked.value.title, checked.value.body)
171
- return c.redirect('/notes', 303)
177
+ return c.redirect(at('/notes'), 303)
172
178
  })
173
179
  app.get('/notes/:id/edit', async (c) => {
174
180
  const note = await notes.get(c.get('user').id, Number(c.req.param('id')))
175
181
  return note
176
- ? c.html(formPage(c.get('user').email, `/notes/${note.id}`, note))
177
- : c.html(listPage(c.get('user').email, [], ''), 404)
182
+ ? c.html(formPage(c.get('user').email, `/notes/${note.id}`, note, [], prefix))
183
+ : c.html(listPage(c.get('user').email, [], '', prefix), 404)
178
184
  })
179
185
  app.post('/notes/:id', async (c) => {
180
186
  const id = Number(c.req.param('id'))
@@ -187,17 +193,18 @@ export function createApp(
187
193
  `/notes/${id}`,
188
194
  { title: String(form.title ?? ''), body: String(form.body ?? '') },
189
195
  checked.issues.map((i) => i.message),
196
+ prefix,
190
197
  ),
191
198
  400,
192
199
  )
193
200
  if (!(await notes.update(c.get('user').id, id, checked.value.title, checked.value.body)))
194
201
  return c.text('No such note.', 404)
195
- return c.redirect('/notes', 303)
202
+ return c.redirect(at('/notes'), 303)
196
203
  })
197
204
  app.post('/notes/:id/delete', async (c) => {
198
205
  if (!(await notes.remove(c.get('user').id, Number(c.req.param('id')))))
199
206
  return c.text('No such note.', 404)
200
- return c.redirect('/notes', 303)
207
+ return c.redirect(at('/notes'), 303)
201
208
  })
202
209
 
203
210
  app.route('/api/notes', createApi(db))
@@ -3,12 +3,19 @@ import type {} from '../plugins/acryl-db/src/cordis.ts'
3
3
  import { createApp } from './notes-app.ts'
4
4
  import { createClient } from './runtime.ts'
5
5
 
6
- /** The app's own plugin: it builds the app on the `db` port and mounts it on the router. The mount is removed when the plugin is disposed. */
6
+ /**
7
+ * The only place the app's mount prefix is named: the root on its own server (`npm start`), `/app` when the ACRYL frame hosts it (`npm run dev`). Every
8
+ * URL the pages write is derived from the prefix the app is mounted at, so the standalone URLs do not move.
9
+ */
10
+ export const FRAME_MOUNT_PREFIX = '/app'
11
+
12
+ /** The app's own plugin: it builds the app on the `db` port and mounts it on the router at `options.prefix` (the root when absent). The mounts are removed when the plugin is disposed. */
7
13
  export const routes = {
8
14
  name: 'routes',
9
15
  inject: ['router', 'db'],
10
- apply(ctx: Context) {
11
- ctx.router.route('/', createApp(ctx.db))
12
- ctx.router.route('/client', createClient())
16
+ apply(ctx: Context, options: { prefix?: string } = {}) {
17
+ const prefix = options.prefix ?? ''
18
+ ctx.router.route(prefix === '' ? '/' : prefix, createApp(ctx.db, { prefix }))
19
+ ctx.router.route(`${prefix}/client`, createClient(prefix))
13
20
  },
14
21
  }
@@ -11,7 +11,13 @@ const runtimeDir = join(
11
11
 
12
12
  /**
13
13
  * Client components without a bundler: `src/client` is compiled when the browser asks; `npm run build` compiles it once for production
14
- * (`NODE_ENV=production` then serves only `dist/client`). `src/routes.ts` mounts it at `/client` on the router, next to the app.
14
+ * (`NODE_ENV=production` then serves only `dist/client`). `src/routes.ts` mounts it at `<prefix>/client` on the router, next to the app; the explicit
15
+ * mount is the path below which its modules are found, so the same app works at the root and under the frame's prefix.
15
16
  */
16
- export const createClient = () =>
17
- clientModules({ root: 'src/client', compiled: 'dist/client', runtimeDir })
17
+ export const createClient = (prefix = '') =>
18
+ clientModules({
19
+ root: 'src/client',
20
+ compiled: 'dist/client',
21
+ runtimeDir,
22
+ mount: `${prefix}/client`,
23
+ })