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
@@ -11,13 +11,16 @@ if (filter) filter.addEventListener('input', () => {
11
11
  })
12
12
  `
13
13
 
14
+ /** `base` is the path the app is mounted at: an empty string on its own server, `/app` when the ACRYL frame hosts it. Every URL here starts there. */
14
15
  const Layout = ({
15
16
  title,
16
17
  user,
18
+ base,
17
19
  children,
18
20
  }: {
19
21
  title: string
20
22
  user?: string
23
+ base: string
21
24
  children?: unknown
22
25
  }) => (
23
26
  <html lang="en">
@@ -37,23 +40,23 @@ const Layout = ({
37
40
  <strong>Notes</strong>
38
41
  {user ? (
39
42
  <>
40
- <a href="/notes">All notes</a>
41
- <a href="/notes/new">New note</a>
42
- <form method="post" action="/logout">
43
+ <a href={`${base}/notes`}>All notes</a>
44
+ <a href={`${base}/notes/new`}>New note</a>
45
+ <form method="post" action={`${base}/logout`}>
43
46
  <span>{user} </span>
44
47
  <button type="submit">Log out</button>
45
48
  </form>
46
49
  </>
47
50
  ) : (
48
51
  <>
49
- <a href="/login">Log in</a>
50
- <a href="/signup">Sign up</a>
52
+ <a href={`${base}/login`}>Log in</a>
53
+ <a href={`${base}/signup`}>Sign up</a>
51
54
  </>
52
55
  )}
53
56
  </nav>
54
57
  {children}
55
58
  <script dangerouslySetInnerHTML={{ __html: counterScript }} />
56
- <script type="module" src="/client/counter.tsx" />
59
+ <script type="module" src={`${base}/client/counter.tsx`} />
57
60
  {doorEnabled() ? <script type="module" src="/api/acryl-door/page.js" /> : null}
58
61
  </body>
59
62
  </html>
@@ -68,12 +71,17 @@ const Errors = ({ errors }: { errors: string[] }) =>
68
71
  </ul>
69
72
  ) : null
70
73
 
71
- export const authPage = (mode: 'login' | 'signup', errors: string[] = [], email = ''): string =>
74
+ export const authPage = (
75
+ mode: 'login' | 'signup',
76
+ errors: string[] = [],
77
+ email = '',
78
+ base = '',
79
+ ): string =>
72
80
  (
73
- <Layout title={mode === 'login' ? 'Log in' : 'Sign up'}>
81
+ <Layout title={mode === 'login' ? 'Log in' : 'Sign up'} base={base}>
74
82
  <h1>{mode === 'login' ? 'Log in' : 'Sign up'}</h1>
75
83
  <Errors errors={errors} />
76
- <form method="post" action={`/${mode}`}>
84
+ <form method="post" action={`${base}/${mode}`}>
77
85
  <label for="email">Email</label>
78
86
  <input id="email" name="email" type="email" value={email} />
79
87
  <label for="password">Password</label>
@@ -85,11 +93,11 @@ export const authPage = (mode: 'login' | 'signup', errors: string[] = [], email
85
93
  </Layout>
86
94
  ).toString()
87
95
 
88
- export const listPage = (user: string, notes: Note[], q: string): string =>
96
+ export const listPage = (user: string, notes: Note[], q: string, base = ''): string =>
89
97
  (
90
- <Layout title="Notes" user={user}>
98
+ <Layout title="Notes" user={user} base={base}>
91
99
  <h1>Your notes</h1>
92
- <form method="get" action="/notes">
100
+ <form method="get" action={`${base}/notes`}>
93
101
  <label for="filter">Search</label>
94
102
  <input id="filter" name="q" type="search" value={q} />
95
103
  <button type="submit">Search</button>
@@ -98,8 +106,8 @@ export const listPage = (user: string, notes: Note[], q: string): string =>
98
106
  <ul>
99
107
  {notes.map((n) => (
100
108
  <li data-note>
101
- <a href={`/notes/${n.id}/edit`}>{n.title}</a>
102
- <form method="post" action={`/notes/${n.id}/delete`} style="display:inline">
109
+ <a href={`${base}/notes/${n.id}/edit`}>{n.title}</a>
110
+ <form method="post" action={`${base}/notes/${n.id}/delete`} style="display:inline">
103
111
  <button type="submit">Delete</button>
104
112
  </form>
105
113
  </li>
@@ -113,12 +121,13 @@ export const formPage = (
113
121
  action: string,
114
122
  note: { title: string; body: string },
115
123
  errors: string[] = [],
124
+ base = '',
116
125
  ): string =>
117
126
  (
118
- <Layout title="Note" user={user}>
127
+ <Layout title="Note" user={user} base={base}>
119
128
  <h1>{action === '/notes' ? 'New note' : 'Edit note'}</h1>
120
129
  <Errors errors={errors} />
121
- <form method="post" action={action}>
130
+ <form method="post" action={`${base}${action}`}>
122
131
  <label for="title">Title</label>
123
132
  <input id="title" name="title" value={note.title} />
124
133
  <label for="body">Note</label>
@@ -127,7 +136,7 @@ export const formPage = (
127
136
  </textarea>
128
137
  <small id="count" aria-live="polite" data-island="counter" />
129
138
  <p>
130
- <button type="submit">Save</button> <a href="/notes">Cancel</a>
139
+ <button type="submit">Save</button> <a href={`${base}/notes`}>Cancel</a>
131
140
  </p>
132
141
  </form>
133
142
  </Layout>
@@ -7,6 +7,8 @@
7
7
  "manifest",
8
8
  "save",
9
9
  "extensions",
10
- "acryl-door"
10
+ "acryl-door",
11
+ "acryl-web-server",
12
+ "acryl-page"
11
13
  ]
12
14
  }
@@ -23,11 +23,12 @@ const projectRoot = fileURLToPath(new URL('..', import.meta.url))
23
23
  */
24
24
  const stops: (() => Promise<void>)[] = []
25
25
  after(() => Promise.all(stops.map((stop) => stop())))
26
- async function booted() {
26
+ async function booted(options: { prefix?: string } = {}) {
27
27
  const home = await mkdtemp(join(scratch, 'notes-test-'))
28
28
  const app = await boot(withPort(pinnedInstance(home, { root: projectRoot }), 0), {
29
29
  database: join(home, 'test.sqlite'),
30
30
  listen: false,
31
+ ...options,
31
32
  })
32
33
  stops.push(async () => {
33
34
  await app.stop()
@@ -278,3 +279,40 @@ test('the note form loads the character counter, a client component compiled for
278
279
  assert.equal((await running.root.router.request('/client/@acrylblends/jsx-dom.js')).status, 200)
279
280
  assert.equal((await running.root.router.request('/client/../src/db.ts')).status, 404)
280
281
  })
282
+
283
+ test('the frame mounts the app at /app: the login page, its links and redirects follow the prefix, and the client module is served there', async () => {
284
+ const running = await booted({ prefix: '/app' })
285
+ const root = running.root
286
+ const home = await root.router.request('/app')
287
+ assert.equal(home.status, 302)
288
+ assert.equal(home.headers.get('location'), '/app/notes')
289
+ const notes = await root.router.request('/app/notes')
290
+ assert.equal(notes.status, 302)
291
+ assert.equal(notes.headers.get('location'), '/app/login')
292
+ const login = await root.router.request('/app/login')
293
+ assert.equal(login.status, 200)
294
+ const loginHtml = await login.text()
295
+ assert.match(loginHtml, /action="\/app\/login"/u)
296
+ assert.match(loginHtml, /href="\/app\/signup"/u)
297
+ assert.match(loginHtml, /src="\/app\/client\/counter\.tsx"/u)
298
+ // Signing up through the framed path lands on the framed notes page, and its links stay under the prefix.
299
+ const signup = await root.router.request(
300
+ '/app/signup',
301
+ form({ email: 'framed@example.com', password: 'correct horse' }),
302
+ )
303
+ assert.equal(signup.status, 303)
304
+ assert.equal(signup.headers.get('location'), '/app/notes')
305
+ const cookie = (signup.headers.get('set-cookie') ?? '').split(';')[0] ?? ''
306
+ const list = await root.router.request('/app/notes', { headers: { cookie } })
307
+ assert.equal(list.status, 200)
308
+ const listHtml = await list.text()
309
+ assert.match(listHtml, /href="\/app\/notes\/new"/u)
310
+ assert.match(listHtml, /action="\/app\/notes"/u)
311
+ // The client component is compiled and served below the app's prefix.
312
+ const module = await root.router.request('/app/client/counter.tsx')
313
+ assert.equal(module.status, 200)
314
+ assert.match(module.headers.get('content-type') ?? '', /javascript/u)
315
+ assert.equal((await root.router.request('/app/client/@acrylblends/jsx-dom.js')).status, 200)
316
+ // The app's own URLs are not mounted in the frame; its own server keeps them (the tests above).
317
+ assert.equal((await root.router.request('/login')).status, 404)
318
+ })
@@ -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)
@@ -23,3 +23,6 @@ spec:
23
23
  name: acryl-node-server
24
24
  config:
25
25
  hostname: "{{parameters.hostname}}"
26
+ # The door: an agent working in this folder looks at the page and acts on it through .acryl/bin/acryl. Development only: src/app.ts leaves it out when NODE_ENV=production.
27
+ - id: acryl-door
28
+ name: acryl-door
@@ -1,3 +1,4 @@
1
1
  node_modules/
2
2
  .acrylblends/
3
3
  .acryl/
4
+ package-lock.json
@@ -16,6 +16,7 @@
16
16
  "test": "node --import ./plugins/acryl-node-server/src/register.ts --test \"test/**/*.test.{ts,tsx}\"",
17
17
  "check": "tsc --noEmit",
18
18
  "lock": "node plugins/manifest/lock.ts",
19
+ "snapshot": "node plugins/manifest/snapshot.ts",
19
20
  "save": "node plugins/save/cli.ts",
20
21
  "upgrade": "node plugins/manifest/upgrade-cli.ts"
21
22
  },
@@ -1,30 +1,38 @@
1
1
  import { join } from 'node:path'
2
2
  import { fileURLToPath } from 'node:url'
3
3
  import { Context, type Fiber } from '@deepseek-ai/cordis'
4
+ import * as door from '../plugins/acryl-door/src/index.ts'
4
5
  import * as nodeServer from '../plugins/acryl-node-server/src/index.ts'
5
6
  import * as router from '../plugins/acryl-router/src/index.ts'
6
7
  import * as state from '../plugins/acryl-state/src/index.ts'
7
8
  import { type AppInstance, instance } from '../plugins/instance/index.ts'
8
9
  import {
10
+ appPluginNames,
9
11
  blueprintsIn,
10
12
  compose,
13
+ loadAppPlugins,
11
14
  loadManifest,
12
15
  type ParameterValue,
13
16
  } from '../plugins/manifest/index.ts'
17
+ import { doorEnabled } from './door.ts'
14
18
  import * as todo from './todo.ts'
19
+ import { FRAME_MOUNT_PREFIX } from './todo.ts'
15
20
 
16
- /** The plugins this app's code provides. `blend.yaml` chooses, orders and configures them. */
21
+ /** The plugins this app's code provides. `blend.yaml` chooses, orders and configures them, and adds the app's own plugins from `plugins/` by name. */
17
22
  export const registry = {
18
23
  'acryl-router': router,
19
24
  'acryl-state': state,
20
25
  todo,
21
26
  'acryl-node-server': nodeServer,
27
+ 'acryl-door': door,
22
28
  }
23
29
 
24
30
  export const APP_NAME = '__APP_NAME__'
25
31
 
26
32
  /** The manifest next to this app's source. */
27
33
  export const MANIFEST = join(fileURLToPath(new URL('..', import.meta.url)), 'blend.yaml')
34
+ /** 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. */
35
+ const PLUGINS_DIR = join(fileURLToPath(new URL('..', import.meta.url)), 'plugins')
28
36
 
29
37
  /**
30
38
  * The app's manifest, resolved. A Blend finds its Blueprint in `blueprints/<id>.yaml`; an app that is itself a Blueprint never looks.
@@ -32,18 +40,21 @@ export const MANIFEST = join(fileURLToPath(new URL('..', import.meta.url)), 'ble
32
40
  */
33
41
  export function readManifest(values?: Readonly<Record<string, ParameterValue>>) {
34
42
  return loadManifest(MANIFEST, {
35
- known: Object.keys(registry),
43
+ known: [...Object.keys(registry), ...appPluginNames(PLUGINS_DIR)],
36
44
  getBlueprint: blueprintsIn(join(fileURLToPath(new URL('..', import.meta.url)), 'blueprints')),
37
45
  ...(values ? { values } : {}),
38
46
  })
39
47
  }
40
48
 
41
- /** The manifest without its server row: the app is then reached in process, through `root.router.request`. */
42
- function withoutServer<M extends { plugins: readonly { use: string }[] }>(manifest: M): M {
49
+ /** The manifest without some rows: without its server (and so its door) the app is reached in process, through `root.router.request`; the door is also left out in production. */
50
+ function without<M extends { plugins: readonly { use: string }[] }>(
51
+ manifest: M,
52
+ ...uses: string[]
53
+ ): M {
43
54
  return {
44
55
  ...manifest,
45
56
  plugins: manifest.plugins.map((row) =>
46
- row.use === 'acryl-node-server' ? { ...row, disabled: true } : row,
57
+ uses.includes(row.use) ? { ...row, disabled: true } : row,
47
58
  ),
48
59
  }
49
60
  }
@@ -55,32 +66,75 @@ export interface App {
55
66
  stop(): Promise<void>
56
67
  }
57
68
 
58
- /** The app: `instance` first (where it lives, chosen at run time), then the manifest's rows in order. */
59
- export async function createApp(
69
+ export interface AppOptions {
70
+ readonly values?: Readonly<Record<string, ParameterValue>>
71
+ /** The path the app is mounted at; absent on its own server, where it keeps `/todo`, and {@link FRAME_MOUNT_PREFIX} in the frame. */
72
+ readonly prefix?: string
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. */
74
+ readonly listen?: boolean
75
+ }
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
+
89
+ /**
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}.
93
+ */
94
+ export async function composeInto(
95
+ ctx: Context,
60
96
  chosen: AppInstance,
61
- options: {
62
- values?: Readonly<Record<string, ParameterValue>>
63
- /** 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. */
64
- listen?: boolean
65
- } = {},
66
- ): Promise<App> {
67
- const manifest = readManifest(options.values)
97
+ options: AppOptions & { readonly host?: boolean } = {},
98
+ ): Promise<Fiber[]> {
99
+ const read = readManifest(options.values)
100
+ const own = options.host !== true && options.listen !== false
101
+ // The frame host mounts the app under its own prefix; the app's own server keeps the app's old path.
102
+ const prefix = options.prefix ?? (options.host === true ? FRAME_MOUNT_PREFIX : undefined)
103
+ const leaveOut = new Set<string>(options.host === true ? HOST_ROWS : [])
104
+ // The server, and the door that needs it, are not mounted when the app is reached in process or inside the frame host.
105
+ if (!own) leaveOut.add('acryl-node-server').add('acryl-door')
106
+ if (!doorEnabled()) leaveOut.add('acryl-door')
107
+ const manifest = without(read, ...leaveOut)
108
+ // 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 is imported here.
109
+ const ownRows = [
110
+ ...new Set(read.plugins.map((row) => row.use).filter((use) => !Object.hasOwn(registry, use))),
111
+ ]
112
+ const ownPlugins = await loadAppPlugins(
113
+ PLUGINS_DIR,
114
+ ownRows.filter((use) => manifest.plugins.some((row) => row.use === use && !row.disabled)),
115
+ )
116
+ return compose(
117
+ ctx,
118
+ manifest,
119
+ { ...registry, ...ownPlugins },
120
+ {
121
+ // Runtime values the manifest cannot hold: where the list is kept (in the app's home) and, in the frame, the prefix the app answers at.
122
+ todo: {
123
+ file: join(chosen.home, 'todo.json'),
124
+ ...(prefix === undefined ? {} : { prefix }),
125
+ },
126
+ // Only the app's own server listens; the frame host owns the server and its port.
127
+ ...(own ? { server: { port: chosen.port.start, scan: chosen.port.scan ? 20 : 0 } } : {}),
128
+ },
129
+ )
130
+ }
131
+
132
+ /** The app: `instance` first (where it lives, chosen at run time), then the manifest's rows in order. */
133
+ export async function createApp(chosen: AppInstance, options: AppOptions = {}): Promise<App> {
68
134
  const root = new Context()
69
135
  const first = root.plugin(instance, chosen)
70
136
  await first.await()
71
- const fibers: Fiber[] = [
72
- first,
73
- ...(await compose(
74
- root,
75
- options.listen === false ? withoutServer(manifest) : manifest,
76
- registry,
77
- {
78
- // Runtime values the manifest cannot hold: where the list is kept (in the app's home) and where the server listens.
79
- todo: { file: join(chosen.home, 'todo.json') },
80
- server: { port: chosen.port.start, scan: chosen.port.scan ? 20 : 0 },
81
- },
82
- )),
83
- ]
137
+ const fibers: Fiber[] = [first, ...(await composeInto(root, chosen, options))]
84
138
  return {
85
139
  root,
86
140
  url: options.listen === false ? '' : root.nodeServer.url,
@@ -0,0 +1,7 @@
1
+ /**
2
+ * The door (`plugins/acryl-door`) lets an agent working in this folder look at the running page and act on it, through `.acryl/bin/acryl`. It is on in development
3
+ * and off in production (`NODE_ENV=production`) and when `ACRYL_DOOR=off`: `src/app.ts` leaves its row out then, and the page leaves its script tag out. One process
4
+ * at a time serves a folder's door, so the tests that start the real server set `ACRYL_DOOR=off` and a second `npm start` beside a running one does not fail on it.
5
+ */
6
+ export const doorEnabled = (): boolean =>
7
+ process.env.NODE_ENV !== 'production' && process.env.ACRYL_DOOR !== 'off'
@@ -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 the page and its API answer at
4
+ * `/app`. Nothing here runs in production: `npm start` composes the app on its own server (no `routerHttp` service, so this row is not mounted there),
5
+ * where the app keeps `/todo`.
6
+ */
7
+ import type { Context } from '@deepseek-ai/cordis'
8
+ import { FRAME_MOUNT_PREFIX } from './todo.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
+ }
@@ -1,26 +1,33 @@
1
- /** The page: plain HTML and a few lines of script; it follows the list through the server-sent event stream. */
2
- export const PAGE = `<!doctype html>
1
+ import { doorEnabled } from './door.ts'
2
+
3
+ /**
4
+ * The page: plain HTML and a few lines of script; it follows the list through the server-sent event stream. Every URL it writes is derived from
5
+ * `prefix`, the path the app is mounted at (`/todo` on its own server, `/app` when the ACRYL frame hosts it), so the same page works under both.
6
+ */
7
+ export const pageHtml = (prefix: string): string => `<!doctype html>
3
8
  <meta charset="utf-8"><title>Todo</title>
4
9
  <style>body{font:16px system-ui;max-width:32rem;margin:2rem auto}ul{list-style:none;padding:0}li{display:flex;gap:.5rem;padding:.25rem 0}.done span{text-decoration:line-through;opacity:.6}</style>
5
10
  <h1>Todo</h1>
6
11
  <form id="add"><input id="title" placeholder="What needs doing?" autofocus> <button>Add</button></form>
7
12
  <ul id="list"></ul>
13
+ ${doorEnabled() ? '<script type="module" src="/api/acryl-door/page.js"></script>' : ''}
8
14
  <script>
15
+ const base = ${JSON.stringify(prefix)}
9
16
  const list = document.getElementById('list')
10
17
  const send = (url, method, body) => fetch(url, { method, headers: { 'content-type': 'application/json' }, body: body && JSON.stringify(body) })
11
18
  document.getElementById('add').onsubmit = async event => {
12
19
  event.preventDefault()
13
20
  const input = document.getElementById('title')
14
- await send('/todo/api', 'POST', { title: input.value })
21
+ await send(base + '/api', 'POST', { title: input.value })
15
22
  input.value = ''
16
23
  }
17
- new EventSource('/todo/events').onmessage = event => {
24
+ new EventSource(base + '/events').onmessage = event => {
18
25
  list.replaceChildren(...JSON.parse(event.data).map(todo => {
19
26
  const item = document.createElement('li')
20
27
  item.className = todo.done ? 'done' : ''
21
- const box = Object.assign(document.createElement('input'), { type: 'checkbox', checked: todo.done, onchange: () => send('/todo/api/' + todo.id, 'PATCH', { done: box.checked }) })
28
+ const box = Object.assign(document.createElement('input'), { type: 'checkbox', checked: todo.done, onchange: () => send(base + '/api/' + todo.id, 'PATCH', { done: box.checked }) })
22
29
  const title = document.createElement('span'); title.textContent = todo.title
23
- const remove = Object.assign(document.createElement('button'), { textContent: 'x', onclick: () => send('/todo/api/' + todo.id, 'DELETE') })
30
+ const remove = Object.assign(document.createElement('button'), { textContent: 'x', onclick: () => send(base + '/api/' + todo.id, 'DELETE') })
24
31
  item.append(box, title, remove)
25
32
  return item
26
33
  }))
@@ -10,14 +10,27 @@ import { Acryl, HTTPException } from '../plugins/acryl-router/src/index.ts'
10
10
  import { bodyLimit, loopbackOnly, trimTrailingSlash } from '../plugins/acryl-router/src/middleware/index.ts'
11
11
  import { schemaValidator, streamSSE, type StandardSchema } from '../plugins/acryl-router/src/helpers/index.ts'
12
12
  import type {} from '../plugins/acryl-state/src/index.ts'
13
- import { PAGE } from './page.ts'
13
+ import { pageHtml } from './page.ts'
14
14
 
15
15
  /** Stable Cordis plugin name, equal to the package name and the Loader row id. */
16
16
  export const name = 'acryl-todo'
17
17
  export const inject = ['router', 'state']
18
18
 
19
+ /**
20
+ * The only place the app's mount prefix is named: the app answers under `/todo` on its own server (`npm start`), and the ACRYL frame mounts the same app
21
+ * at `/app` (`npm run dev`). Every URL the page writes is derived from the prefix it is mounted at, so both are served by one source.
22
+ */
23
+ export const FRAME_MOUNT_PREFIX = '/app'
24
+
19
25
  export interface Todo { id: number, title: string, done: boolean }
20
- export interface Config { file?: string }
26
+ export interface Config {
27
+ file?: string
28
+ /**
29
+ * The path the app is mounted at, and the base of every URL its page writes. `/todo` on the app's own server (and the default); the ACRYL frame passes
30
+ * `/app` so the same app answers at the frame's app prefix, with nothing else of its URLs moved.
31
+ */
32
+ prefix?: string
33
+ }
21
34
 
22
35
  /** Standard Schema for the body of a new todo, written by hand so the example has no schema library. */
23
36
  const NewTodo: StandardSchema<{ title: string }> = {
@@ -71,6 +84,7 @@ function save(file: string, todos: readonly Todo[]): void {
71
84
  }
72
85
 
73
86
  export function apply(ctx: Context, config: Config = {}): void {
87
+ const prefix = config.prefix ?? '/todo'
74
88
  const todos = ctx.state.cell<readonly Todo[]>('todo.list', load(config.file))
75
89
  if (config.file !== undefined) {
76
90
  const file = config.file
@@ -88,7 +102,7 @@ export function apply(ctx: Context, config: Config = {}): void {
88
102
  // so the page is found either way while each route keeps one canonical path (the router itself treats `/a` and `/a/` as different).
89
103
  const api = new Acryl()
90
104
  .use(loopbackOnly({ inProcess: 'allow' }), bodyLimit({ maxSize: MAX_BODY_BYTES }), trimTrailingSlash())
91
- .get('/', c => c.html(PAGE))
105
+ .get('/', c => c.html(pageHtml(prefix)))
92
106
  .get('/api', c => c.json(todos.get()))
93
107
  .post('/api', schemaValidator('json', NewTodo), c => {
94
108
  const todo: Todo = { id: nextId(), title: c.req.valid('json').title, done: false }
@@ -119,7 +133,5 @@ export function apply(ctx: Context, config: Config = {}): void {
119
133
  unsubscribe()
120
134
  }
121
135
  }))
122
- ctx.router.route('/todo', api)
123
-
124
- ctx.inject(['routerHttp'], inner => { inner.routerHttp.mount('/todo') })
136
+ ctx.router.route(prefix === '' ? '/' : prefix, api)
125
137
  }
@@ -1,4 +1,14 @@
1
1
  {
2
- "plugins": ["instance", "acryl-router", "acryl-state", "acryl-node-server", "manifest", "save"],
2
+ "plugins": [
3
+ "instance",
4
+ "acryl-router",
5
+ "acryl-state",
6
+ "acryl-node-server",
7
+ "manifest",
8
+ "save",
9
+ "acryl-door",
10
+ "acryl-web-server",
11
+ "acryl-page"
12
+ ],
3
13
  "desktopPath": "/todo"
4
14
  }
@@ -7,6 +7,7 @@ import { test } from 'node:test'
7
7
  import { fileURLToPath } from 'node:url'
8
8
  import { pinnedInstance, withPort } from '../plugins/instance/index.ts'
9
9
  import { type App, createApp, readManifest } from '../src/app.ts'
10
+ import { FRAME_MOUNT_PREFIX } from '../src/todo.ts'
10
11
 
11
12
  /** Throwaway homes live inside the project (`.acrylblends/` is ignored by git): a sandboxed agent may not write to the system temp folder. */
12
13
  const scratch = join(fileURLToPath(new URL('..', import.meta.url)), '.acrylblends', 'test')
@@ -14,9 +15,16 @@ mkdirSync(scratch, { recursive: true })
14
15
 
15
16
  const projectRoot = fileURLToPath(new URL('..', import.meta.url))
16
17
 
17
- /** Boot the app with its data in `home`, run the test, always stop it. In process by default (`app.root.router.request`); `listen` starts the real server on any free port. */
18
- async function withApp(home: string, run: (app: App) => Promise<void>, listen = false) {
19
- const app = await createApp(withPort(pinnedInstance(home, { root: projectRoot }), 0), { listen })
18
+ /** Boot the app with its data in `home`, run the test, always stop it. In process by default (`app.root.router.request`); `listen` starts the real server on any free port; `prefix` mounts it where the frame does. */
19
+ async function withApp(
20
+ home: string,
21
+ run: (app: App) => Promise<void>,
22
+ options: { listen?: boolean; prefix?: string } = {},
23
+ ) {
24
+ const app = await createApp(withPort(pinnedInstance(home, { root: projectRoot }), 0), {
25
+ listen: false,
26
+ ...options,
27
+ })
20
28
  try {
21
29
  await run(app)
22
30
  } finally {
@@ -59,10 +67,24 @@ test('creates, completes, lists and deletes', () =>
59
67
  ))
60
68
 
61
69
  /** A sandbox may forbid listening on a port (Codex's workspace-write does). The one test that needs a real connection (the origin check looks at who connected) is skipped there, and says so. */
70
+ /**
71
+ * Whether this environment forbids listening at all: only a sandbox's refusal (EPERM/EACCES) means the real-server test must skip. A loaded
72
+ * 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
73
+ * listen fails loudly instead of hiding behind a skip.
74
+ */
62
75
  const canListen = await new Promise<boolean>((done) => {
63
- const probe = createServer()
64
- probe.once('error', () => done(false))
65
- probe.listen(0, '127.0.0.1', () => probe.close(() => done(true)))
76
+ let attempts = 0
77
+ const probeOnce = (): void => {
78
+ attempts += 1
79
+ const probe = createServer()
80
+ probe.once('error', (error: NodeJS.ErrnoException) => {
81
+ if (error.code === 'EPERM' || error.code === 'EACCES') done(false)
82
+ else if (attempts < 3) setTimeout(probeOnce, 100)
83
+ else done(true)
84
+ })
85
+ probe.listen(0, '127.0.0.1', () => probe.close(() => done(true)))
86
+ }
87
+ probeOnce()
66
88
  })
67
89
  const skip = canListen ? false : 'this environment does not allow listening on a port'
68
90
  if (!canListen)
@@ -89,7 +111,7 @@ test(
89
111
  assert.equal((await post({})).status, 201)
90
112
  assert.equal((await post({ origin: new URL(app.url).origin })).status, 201)
91
113
  },
92
- true,
114
+ { listen: true },
93
115
  ),
94
116
  ),
95
117
  )
@@ -162,9 +184,45 @@ test('serves the page, and streams the list: now, and again on every change', ()
162
184
  }),
163
185
  ))
164
186
 
187
+ test('the frame mounts the same app at /app; the app on its own server keeps /todo', () =>
188
+ withHome((home) =>
189
+ withApp(
190
+ home,
191
+ async (app) => {
192
+ // The frame row claims the app's prefix on the host's web server; the app's own rows mount it there.
193
+ const frame = await import('../src/frame.ts')
194
+ assert.deepEqual([...frame.inject].sort(), ['routerHttp'])
195
+ assert.equal(FRAME_MOUNT_PREFIX, '/app')
196
+ const page = await app.root.router.request(FRAME_MOUNT_PREFIX)
197
+ assert.equal(page.status, 200)
198
+ const html = await page.text()
199
+ assert.match(html, /<title>Todo<\/title>/)
200
+ // The page writes every URL from the prefix it is mounted at.
201
+ assert.match(html, new RegExp(`const base = "${FRAME_MOUNT_PREFIX}"`, 'u'))
202
+ assert.deepEqual(await (await send(app, 'GET', `${FRAME_MOUNT_PREFIX}/api`)).json(), [])
203
+ const created = await send(app, 'POST', `${FRAME_MOUNT_PREFIX}/api`, { title: 'framed' })
204
+ assert.equal(created.status, 201)
205
+ const list = (await (await send(app, 'GET', `${FRAME_MOUNT_PREFIX}/api`)).json()) as Array<{
206
+ title: string
207
+ }>
208
+ assert.deepEqual(
209
+ list.map((todo) => todo.title),
210
+ ['framed'],
211
+ )
212
+ // The standalone URLs did not move: the app still answers under /todo on its own server.
213
+ assert.equal((await send(app, 'GET', '/todo')).status, 404)
214
+ },
215
+ { prefix: FRAME_MOUNT_PREFIX },
216
+ ),
217
+ ))
218
+
165
219
  test('the manifest decides what is mounted', () => {
220
+ // The template's rows, in their order; a feature's row (plugins/<name>/) may sit between them.
221
+ const library = ['router', 'state', 'todo', 'server', 'acryl-door']
166
222
  assert.deepEqual(
167
- readManifest().plugins.map((row) => row.id),
168
- ['router', 'state', 'todo', 'server'],
223
+ readManifest()
224
+ .plugins.map((row) => row.id)
225
+ .filter((id) => library.includes(id)),
226
+ library,
169
227
  )
170
228
  })