acrylblends 1.1.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (263) hide show
  1. package/README.md +9 -0
  2. package/dist/index.d.ts +47 -2
  3. package/dist/index.js +2017 -32
  4. package/dist/index.js.map +1 -1
  5. package/docs/framework/agents/index.md +1 -1
  6. package/docs/framework/getting-started/desktop.md +23 -4
  7. package/docs/framework/guides/create-acrylblends.md +5 -3
  8. package/docs/framework/workshop/README.md +139 -153
  9. package/docs/framework/workshop/facilitator.md +69 -101
  10. package/legacy/README.md +9 -0
  11. package/legacy/agent/157ce3db1112/src/app.ts +98 -0
  12. package/legacy/agent/157ce3db1112/src/routes.ts +63 -0
  13. package/legacy/agent/24c681a00fca/src/app.ts +160 -0
  14. package/legacy/agent/24c681a00fca/src/routes.ts +83 -0
  15. package/legacy/agent/2e790da977b2/src/app.ts +98 -0
  16. package/legacy/agent/2e790da977b2/src/routes.ts +63 -0
  17. package/legacy/agent/6307045545dc/src/app.ts +168 -0
  18. package/legacy/agent/6307045545dc/src/routes.ts +83 -0
  19. package/legacy/agent/6a5fb9c15c53/src/app.ts +158 -0
  20. package/legacy/agent/6a5fb9c15c53/src/routes.ts +83 -0
  21. package/legacy/agent/71fb54a5a942/src/app.ts +111 -0
  22. package/legacy/agent/71fb54a5a942/src/routes.ts +63 -0
  23. package/legacy/agent/74ece148269c/src/app.ts +168 -0
  24. package/legacy/agent/74ece148269c/src/routes.ts +93 -0
  25. package/legacy/agent/855359304707/src/app.ts +168 -0
  26. package/legacy/agent/855359304707/src/routes.ts +126 -0
  27. package/legacy/agent/b5ef3c786ae4/src/app.ts +127 -0
  28. package/legacy/agent/b5ef3c786ae4/src/routes.ts +76 -0
  29. package/legacy/agent/c2f30958043b/src/app.ts +98 -0
  30. package/legacy/agent/c2f30958043b/src/routes.ts +53 -0
  31. package/legacy/blank/5a91c558af22/src/routes.ts +18 -0
  32. package/legacy/blank/9c650cf52bf3/src/routes.ts +14 -0
  33. package/legacy/blank/aea589283445/src/routes.ts +16 -0
  34. package/legacy/blank/b2cbb759c889/src/routes.ts +16 -0
  35. package/legacy/blank/d6d371c9a65e/src/routes.ts +18 -0
  36. package/legacy/chat/7076cc821e2c/src/app.ts +72 -0
  37. package/legacy/chat/7076cc821e2c/src/routes.ts +13 -0
  38. package/legacy/chat/7dbe6d5170df/src/app.ts +107 -0
  39. package/legacy/chat/7dbe6d5170df/src/routes.ts +19 -0
  40. package/legacy/chat/a9a9ab44c58e/src/app.ts +107 -0
  41. package/legacy/chat/a9a9ab44c58e/src/routes.ts +15 -0
  42. package/legacy/chat/f20ad9a51b06/src/app.ts +106 -0
  43. package/legacy/chat/f20ad9a51b06/src/routes.ts +15 -0
  44. package/legacy/layouts.json +524 -0
  45. package/legacy/nodejs/1d477f37aaf4/src/app.ts +92 -0
  46. package/legacy/nodejs/1d477f37aaf4/src/routes.ts +40 -0
  47. package/legacy/nodejs/b1bd9462496c/src/app.ts +61 -0
  48. package/legacy/nodejs/b1bd9462496c/src/routes.ts +37 -0
  49. package/legacy/nodejs/d5bab75076ef/src/app.ts +95 -0
  50. package/legacy/nodejs/d5bab75076ef/src/routes.ts +40 -0
  51. package/legacy/notes/4edc36949c15/src/app.ts +107 -0
  52. package/legacy/notes/4edc36949c15/src/routes.ts +14 -0
  53. package/legacy/notes/6cab2d09747c/src/app.ts +72 -0
  54. package/legacy/notes/6cab2d09747c/src/routes.ts +12 -0
  55. package/legacy/notes/7ca2dfed623a/src/app.ts +106 -0
  56. package/legacy/notes/7ca2dfed623a/src/routes.ts +14 -0
  57. package/legacy/todo/02e613b3625c/src/app.ts +71 -0
  58. package/legacy/todo/02e613b3625c/src/todo.ts +123 -0
  59. package/legacy/todo/45694a8d9e15/src/app.ts +71 -0
  60. package/legacy/todo/45694a8d9e15/src/todo.ts +96 -0
  61. package/legacy/todo/5923d3a6099f/src/app.ts +71 -0
  62. package/legacy/todo/5923d3a6099f/src/todo.ts +125 -0
  63. package/legacy/todo/849a18d66dc1/src/app.ts +91 -0
  64. package/legacy/todo/849a18d66dc1/src/todo.ts +125 -0
  65. package/legacy/todo/8e64211312f3/src/app.ts +71 -0
  66. package/legacy/todo/8e64211312f3/src/todo.ts +125 -0
  67. package/legacy/todo/b79078a87565/src/app.ts +71 -0
  68. package/legacy/todo/b79078a87565/src/todo.ts +125 -0
  69. package/lib/{audit-BFpULBCj.d.ts → audit-Bok_UNCn.d.ts} +26 -3
  70. package/lib/{client-modules-drj4mjhj.js → client-modules-Bkdr83jO.js} +1 -1
  71. package/lib/cordis/door.d.ts +87 -9
  72. package/lib/cordis/door.js +324 -60
  73. package/lib/cordis/inspect.js +1 -1
  74. package/lib/cordis/node-server.d.ts +101 -3
  75. package/lib/cordis/node-server.js +3 -3
  76. package/lib/cordis/router-http.d.ts +7 -1
  77. package/lib/cordis/router-http.js +44 -3
  78. package/lib/cordis/router.d.ts +1 -1
  79. package/lib/cordis/router.js +1 -1
  80. package/lib/cordis/state.js +1 -1
  81. package/lib/door/host.d.ts +2 -2
  82. package/lib/door/host.js +1 -1
  83. package/lib/door/page.d.ts +10 -1
  84. package/lib/door/page.js +46 -7
  85. package/lib/door/protocol.d.ts +1 -1
  86. package/lib/drivers.d.ts +10 -1
  87. package/lib/{index-DMOtA-NJ.d.ts → index-DGq7H0lb.d.ts} +11 -1
  88. package/lib/node-server.d.ts +7 -1
  89. package/lib/node-server.js +2 -2
  90. package/lib/{protocol-a2aqrp2X.d.ts → protocol-BeIYCgTo.d.ts} +4 -0
  91. package/lib/{snapshot-text-BwM6T2nG.js → snapshot-text-CXTnjxGx.js} +87 -7
  92. package/lib/src-CtkIX_Gg.js +427 -0
  93. package/lib/{src-BTBm4OPD.js → src-iBJ2ysV9.js} +77 -14
  94. package/lib/{test-websocket-BLeEvybj.js → test-websocket-CQOSvBth.js} +17 -7
  95. package/lib/{test-websocket-DyeS50Tr.d.ts → test-websocket-GUTAkFQT.d.ts} +3 -1
  96. package/package.json +2 -1
  97. package/plugins/acryl-door/plugin.json +3 -0
  98. package/plugins/acryl-door/src/cli/acryl.ts +63 -6
  99. package/plugins/acryl-door/src/host/channel.ts +86 -9
  100. package/plugins/acryl-door/src/host/chat-tools.ts +8 -6
  101. package/plugins/acryl-door/src/host/door-file.ts +7 -2
  102. package/plugins/acryl-door/src/host/door.ts +128 -24
  103. package/plugins/acryl-door/src/host/operator-routes.ts +21 -1
  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 +14 -1
  107. package/plugins/acryl-door/src/index.ts +168 -30
  108. package/plugins/acryl-door/src/page/base.ts +32 -0
  109. package/plugins/acryl-door/src/page/channel.ts +3 -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 +32 -4
  113. package/plugins/acryl-door/src/page/driver/snapshot.ts +5 -2
  114. package/plugins/acryl-door/src/page/index.ts +41 -2
  115. package/plugins/acryl-door/src/page/outline.ts +56 -0
  116. package/plugins/acryl-door/src/page/protocol.ts +4 -0
  117. package/plugins/acryl-door/src/page/snapshot-text.ts +2 -1
  118. package/plugins/acryl-node-server/src/adapter/listener.ts +4 -0
  119. package/plugins/acryl-node-server/src/adapter/raw.ts +18 -0
  120. package/plugins/acryl-node-server/src/adapter/websocket.ts +15 -9
  121. package/plugins/acryl-node-server/src/client-modules.ts +7 -1
  122. package/plugins/acryl-node-server/src/index.ts +304 -16
  123. package/plugins/acryl-node-server/src/upgrade-hub.ts +2 -2
  124. package/plugins/acryl-page/plugin.json +12 -0
  125. package/plugins/acryl-page/src/index.ts +163 -0
  126. package/plugins/acryl-router/src/service.ts +77 -14
  127. package/plugins/acryl-router-http/src/index.ts +15 -1
  128. package/plugins/acryl-router-http/src/serve.ts +31 -0
  129. package/plugins/acryl-web-server/plugin.json +14 -0
  130. package/plugins/acryl-web-server/src/index.ts +164 -0
  131. package/plugins/agent/agent.ts +38 -7
  132. package/plugins/agent/index.ts +1 -1
  133. package/plugins/agent/model.ts +10 -2
  134. package/plugins/agent/tools.ts +9 -3
  135. package/plugins/agent-panel/client/describe.ts +57 -0
  136. package/plugins/agent-panel/client/driver-pick.ts +22 -0
  137. package/plugins/agent-panel/client/frame.ts +51 -0
  138. package/plugins/agent-panel/client/main.ts +238 -26
  139. package/plugins/agent-panel/drivers.ts +7 -2
  140. package/plugins/agent-panel/headless.ts +5 -2
  141. package/plugins/agent-panel/index.ts +146 -57
  142. package/plugins/agent-panel/mount.ts +27 -0
  143. package/plugins/agent-panel/visitors.ts +231 -0
  144. package/plugins/builder/index.ts +13 -35
  145. package/plugins/builder/package.ts +186 -0
  146. package/plugins/builder/plugin.json +3 -1
  147. package/plugins/desktop/frame.ts +231 -0
  148. package/plugins/desktop/launch.ts +44 -9
  149. package/plugins/desktop/main.ts +100 -32
  150. package/plugins/desktop/window.ts +21 -4
  151. package/plugins/driver-pty/client/terminal.ts +4 -1
  152. package/plugins/driver-pty/index.ts +38 -11
  153. package/plugins/driver-pty/spawn.ts +38 -0
  154. package/plugins/driver-pty/terminal.ts +2 -0
  155. package/plugins/extensions/index.ts +47 -18
  156. package/plugins/extensions/plugin-store.ts +27 -0
  157. package/plugins/extensions/plugin.json +1 -1
  158. package/plugins/extensions/resolve.ts +13 -0
  159. package/plugins/extensions/stage.ts +15 -5
  160. package/plugins/feedback/client.d.ts +22 -0
  161. package/plugins/feedback/client.js +471 -0
  162. package/plugins/feedback/index.ts +143 -0
  163. package/plugins/feedback/plugin.json +11 -0
  164. package/plugins/feedback/store.ts +307 -0
  165. package/plugins/home/index.ts +122 -0
  166. package/plugins/home/plugin.json +11 -0
  167. package/plugins/instance/index.ts +6 -0
  168. package/plugins/manifest/app-plugins.ts +86 -0
  169. package/plugins/manifest/index.ts +12 -2
  170. package/plugins/manifest/plugin.json +1 -1
  171. package/plugins/manifest/snapshot.ts +275 -0
  172. package/plugins/openai/index.ts +64 -5
  173. package/plugins/openai/prompt.ts +23 -0
  174. package/plugins/plugin-share/README.md +51 -0
  175. package/plugins/plugin-share/index.ts +467 -0
  176. package/plugins/plugin-share/plugin.json +15 -0
  177. package/plugins/plugin-share/publish.ts +65 -0
  178. package/plugins/plugin-share/pull-check.ts +129 -0
  179. package/plugins/plugin-share/pulled-records.ts +70 -0
  180. package/shared/AGENTS.core.md +36 -16
  181. package/shared/skills/acryl-add-ui/SKILL.md +19 -0
  182. package/shared/skills/acryl-app/SKILL.md +13 -0
  183. package/shared/skills/acryl-build-plugin/SKILL.md +22 -0
  184. package/shared/skills/acryl-change-plugin/SKILL.md +14 -0
  185. package/shared/skills/acryl-diagnose-plugin/SKILL.md +13 -0
  186. package/shared/skills/acryl-fix-plugin/SKILL.md +13 -0
  187. package/shared/skills/acryl-remove-plugin/SKILL.md +12 -0
  188. package/shared/skills/acryl-share-plugin/SKILL.md +16 -0
  189. package/shared/skills/cordis-plugin/SKILL.md +26 -0
  190. package/templates/agent/.gitattributes +2 -0
  191. package/templates/agent/AGENTS.md +1 -1
  192. package/templates/agent/blend.yaml +6 -0
  193. package/templates/agent/gitignore +1 -0
  194. package/templates/agent/package.json +1 -0
  195. package/templates/agent/src/app.ts +154 -33
  196. package/templates/agent/src/frame.ts +17 -0
  197. package/templates/agent/src/main.ts +5 -3
  198. package/templates/agent/src/routes.ts +38 -19
  199. package/templates/agent/template.json +3 -0
  200. package/templates/agent/test/app.test.ts +45 -3
  201. package/templates/blank/.gitattributes +2 -0
  202. package/templates/blank/AGENTS.md +3 -2
  203. package/templates/blank/blend.yaml +11 -0
  204. package/templates/blank/gitignore +1 -0
  205. package/templates/blank/package.json +1 -0
  206. package/templates/blank/src/app.ts +100 -29
  207. package/templates/blank/src/door.ts +7 -0
  208. package/templates/blank/src/first-page.ts +5 -0
  209. package/templates/blank/src/frame.ts +17 -0
  210. package/templates/blank/src/routes.ts +65 -8
  211. package/templates/blank/template.json +14 -1
  212. package/templates/blank/test/app.test.ts +103 -16
  213. package/templates/chat/.gitattributes +2 -0
  214. package/templates/chat/blend.yaml +2 -1
  215. package/templates/chat/gitignore +1 -0
  216. package/templates/chat/package.json +1 -0
  217. package/templates/chat/src/app.ts +72 -22
  218. package/templates/chat/src/chat-app.ts +11 -2
  219. package/templates/chat/src/client/chat.tsx +4 -2
  220. package/templates/chat/src/frame.ts +17 -0
  221. package/templates/chat/src/page.tsx +6 -4
  222. package/templates/chat/src/routes.ts +14 -4
  223. package/templates/chat/src/runtime.ts +9 -3
  224. package/templates/chat/template.json +3 -1
  225. package/templates/chat/test/chat.test.ts +46 -1
  226. package/templates/chat/test/server.test.ts +17 -3
  227. package/templates/nodejs/.gitattributes +2 -0
  228. package/templates/nodejs/blend.yaml +2 -1
  229. package/templates/nodejs/gitignore +1 -0
  230. package/templates/nodejs/package.json +1 -0
  231. package/templates/nodejs/src/app.ts +76 -21
  232. package/templates/nodejs/src/frame.ts +17 -0
  233. package/templates/nodejs/src/page.tsx +3 -3
  234. package/templates/nodejs/src/routes.ts +32 -13
  235. package/templates/nodejs/template.json +3 -1
  236. package/templates/nodejs/test/app.test.ts +38 -3
  237. package/templates/nodejs/test/server.test.ts +17 -3
  238. package/templates/notes/.gitattributes +2 -0
  239. package/templates/notes/blend.yaml +2 -1
  240. package/templates/notes/gitignore +1 -0
  241. package/templates/notes/package.json +1 -0
  242. package/templates/notes/src/app.ts +72 -22
  243. package/templates/notes/src/frame.ts +17 -0
  244. package/templates/notes/src/notes-app.ts +24 -17
  245. package/templates/notes/src/routes.ts +11 -4
  246. package/templates/notes/src/runtime.ts +9 -3
  247. package/templates/notes/src/views.tsx +26 -17
  248. package/templates/notes/template.json +3 -1
  249. package/templates/notes/test/notes.test.ts +39 -1
  250. package/templates/notes/test/server.test.ts +17 -3
  251. package/templates/todo/.gitattributes +2 -0
  252. package/templates/todo/blend.yaml +3 -0
  253. package/templates/todo/gitignore +1 -0
  254. package/templates/todo/package.json +1 -0
  255. package/templates/todo/src/app.ts +81 -27
  256. package/templates/todo/src/door.ts +7 -0
  257. package/templates/todo/src/frame.ts +17 -0
  258. package/templates/todo/src/page.ts +13 -6
  259. package/templates/todo/src/todo.ts +18 -6
  260. package/templates/todo/template.json +11 -1
  261. package/templates/todo/test/todo.test.ts +67 -9
  262. package/lib/src-LX0E4ec4.js +0 -117
  263. package/shared/SKILL.md +0 -14
@@ -0,0 +1,125 @@
1
+ /**
2
+ * The reference app of the ACRYL Framework: a todo list in one plugin. It needs `router` and `state`, keeps the list in a cell, serves
3
+ * JSON CRUD plus a page, and writes the list to a file so it survives a restart. Nothing else.
4
+ */
5
+
6
+ import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
7
+ import { dirname } from 'node:path'
8
+ import type { Context } from '@deepseek-ai/cordis'
9
+ import { Acryl, HTTPException } from '../plugins/acryl-router/src/index.ts'
10
+ import { bodyLimit, loopbackOnly, trimTrailingSlash } from '../plugins/acryl-router/src/middleware/index.ts'
11
+ import { schemaValidator, streamSSE, type StandardSchema } from '../plugins/acryl-router/src/helpers/index.ts'
12
+ import type {} from '../plugins/acryl-state/src/index.ts'
13
+ import { PAGE } from './page.ts'
14
+
15
+ /** Stable Cordis plugin name, equal to the package name and the Loader row id. */
16
+ export const name = 'acryl-todo'
17
+ export const inject = ['router', 'state']
18
+
19
+ export interface Todo { id: number, title: string, done: boolean }
20
+ export interface Config { file?: string }
21
+
22
+ /** Standard Schema for the body of a new todo, written by hand so the example has no schema library. */
23
+ const NewTodo: StandardSchema<{ title: string }> = {
24
+ '~standard': {
25
+ validate: value => {
26
+ const title = (value as { title?: unknown } | null)?.title
27
+ return typeof title === 'string' && title.trim() !== '' && title.length <= 200
28
+ ? { value: { title: title.trim() } }
29
+ : { issues: [{ message: 'title must be 1 to 200 characters', path: ['title'] }] }
30
+ },
31
+ },
32
+ }
33
+
34
+ /** Standard Schema for a change to a todo. */
35
+ const TodoChange: StandardSchema<{ done: boolean }> = {
36
+ '~standard': {
37
+ validate: value => {
38
+ const done = (value as { done?: unknown } | null)?.done
39
+ return typeof done === 'boolean' ? { value: { done } } : { issues: [{ message: 'done must be true or false', path: ['done'] }] }
40
+ },
41
+ },
42
+ }
43
+
44
+ /** A todo body is small; anything bigger is refused before it is read. */
45
+ const MAX_BODY_BYTES = 16 * 1024
46
+
47
+ /** A missing file is a new list. Any other failure (unreadable, corrupt, half-written) refuses to start: starting empty would overwrite the user's real list on the first save. */
48
+ function load(file: string | undefined): Todo[] {
49
+ if (file === undefined) return []
50
+ let text: string
51
+ try {
52
+ text = readFileSync(file, 'utf8')
53
+ } catch (error) {
54
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') return []
55
+ throw new Error(`acryl-todo: cannot read ${file}: ${(error as Error).message}`, { cause: error })
56
+ }
57
+ try {
58
+ const parsed: unknown = JSON.parse(text)
59
+ if (!Array.isArray(parsed)) throw new Error('not a list')
60
+ return parsed as Todo[]
61
+ } catch (error) {
62
+ throw new Error(`acryl-todo: ${file} is not a valid todo list (${(error as Error).message}); fix or remove it, it was left untouched`, { cause: error })
63
+ }
64
+ }
65
+
66
+ function save(file: string, todos: readonly Todo[]): void {
67
+ mkdirSync(dirname(file), { recursive: true })
68
+ const temporary = `${file}.tmp`
69
+ writeFileSync(temporary, JSON.stringify(todos))
70
+ renameSync(temporary, file)
71
+ }
72
+
73
+ export function apply(ctx: Context, config: Config = {}): void {
74
+ const todos = ctx.state.cell<readonly Todo[]>('todo.list', load(config.file))
75
+ if (config.file !== undefined) {
76
+ const file = config.file
77
+ ctx.effect(() => todos.subscribe(list => { save(file, list) }), 'acryl-todo: save to file')
78
+ }
79
+ const nextId = (): number => todos.get().reduce((max, todo) => Math.max(max, todo.id), 0) + 1
80
+
81
+ const find = (id: number): Todo => {
82
+ const todo = todos.get().find(item => item.id === id)
83
+ if (todo === undefined) throw new HTTPException(404, { message: 'no such todo' })
84
+ return todo
85
+ }
86
+
87
+ // `/todo/` is what people type: trimTrailingSlash answers a GET for an unknown path ending in `/` with a 301 to the path without it,
88
+ // so the page is found either way while each route keeps one canonical path (the router itself treats `/a` and `/a/` as different).
89
+ const api = new Acryl()
90
+ .use(loopbackOnly({ inProcess: 'allow' }), bodyLimit({ maxSize: MAX_BODY_BYTES }), trimTrailingSlash())
91
+ .get('/', c => c.html(PAGE))
92
+ .get('/api', c => c.json(todos.get()))
93
+ .post('/api', schemaValidator('json', NewTodo), c => {
94
+ const todo: Todo = { id: nextId(), title: c.req.valid('json').title, done: false }
95
+ todos.update(list => [...list, todo])
96
+ return c.json(todo, 201)
97
+ })
98
+ .patch('/api/:id', schemaValidator('json', TodoChange), c => {
99
+ const { id } = find(Number(c.req.param('id')))
100
+ const { done } = c.req.valid('json')
101
+ todos.update(list => list.map(todo => todo.id === id ? { ...todo, done } : todo))
102
+ return c.json(find(id))
103
+ })
104
+ .delete('/api/:id', c => {
105
+ const { id } = find(Number(c.req.param('id')))
106
+ todos.update(list => list.filter(todo => todo.id !== id))
107
+ return c.body(null, 204)
108
+ })
109
+ .get('/events', c => streamSSE(c, async stream => {
110
+ const ended = new Promise<void>(resolve => { stream.onAbort(resolve) })
111
+ // Writes are asynchronous; chaining them keeps events in the order the list changed.
112
+ let queue: Promise<unknown> = Promise.resolve()
113
+ const send = (list: readonly Todo[]): void => { queue = queue.then(() => stream.writeSSE({ data: JSON.stringify(list) })).catch(() => {}) }
114
+ send(todos.get())
115
+ const unsubscribe = todos.subscribe(send)
116
+ try {
117
+ await ended
118
+ } finally {
119
+ unsubscribe()
120
+ }
121
+ }))
122
+ ctx.router.route('/todo', api)
123
+
124
+ ctx.inject(['routerHttp'], inner => { inner.routerHttp.mount('/todo') })
125
+ }
@@ -0,0 +1,71 @@
1
+ import { join } from 'node:path'
2
+ import { fileURLToPath } from 'node:url'
3
+ import { Context, type Fiber } from '@deepseek-ai/cordis'
4
+ import * as nodeServer from './plugins/acryl-node-server/src/index.ts'
5
+ import * as router from './plugins/acryl-router/src/index.ts'
6
+ import * as state from './plugins/acryl-state/src/index.ts'
7
+ import { type AppInstance, instance } from './plugins/instance/index.ts'
8
+ import {
9
+ blueprintsIn,
10
+ compose,
11
+ loadManifest,
12
+ type ParameterValue,
13
+ } from './plugins/manifest/index.ts'
14
+ import * as todo from './todo.ts'
15
+
16
+ /** The plugins this app's code provides. `blend.yaml` chooses, orders and configures them. */
17
+ export const registry = {
18
+ 'acryl-router': router,
19
+ 'acryl-state': state,
20
+ todo,
21
+ 'acryl-node-server': nodeServer,
22
+ }
23
+
24
+ export const APP_NAME = '__APP_NAME__'
25
+
26
+ /** The manifest next to this app's source. */
27
+ export const MANIFEST = join(fileURLToPath(new URL('..', import.meta.url)), 'blend.yaml')
28
+
29
+ /**
30
+ * The app's manifest, resolved. A Blend finds its Blueprint in `blueprints/<id>.yaml`; an app that is itself a Blueprint never looks.
31
+ * `createApp` and the tests read it the same way.
32
+ */
33
+ export function readManifest(values?: Readonly<Record<string, ParameterValue>>) {
34
+ return loadManifest(MANIFEST, {
35
+ known: Object.keys(registry),
36
+ getBlueprint: blueprintsIn(join(fileURLToPath(new URL('..', import.meta.url)), 'blueprints')),
37
+ ...(values ? { values } : {}),
38
+ })
39
+ }
40
+
41
+ export interface App {
42
+ readonly root: Context
43
+ readonly url: string
44
+ stop(): Promise<void>
45
+ }
46
+
47
+ /** The app: `instance` first (where it lives, chosen at run time), then the manifest's rows in order. */
48
+ export async function createApp(
49
+ chosen: AppInstance,
50
+ options: { values?: Readonly<Record<string, ParameterValue>> } = {},
51
+ ): Promise<App> {
52
+ const manifest = readManifest(options.values)
53
+ const root = new Context()
54
+ const first = root.plugin(instance, chosen)
55
+ await first.await()
56
+ const fibers: Fiber[] = [
57
+ first,
58
+ ...(await compose(root, manifest, registry, {
59
+ // Runtime values the manifest cannot hold: where the list is kept (in the app's home) and where the server listens.
60
+ todo: { file: join(chosen.home, 'todo.json') },
61
+ server: { port: chosen.port.start, scan: chosen.port.scan ? 20 : 0 },
62
+ })),
63
+ ]
64
+ return {
65
+ root,
66
+ url: root.nodeServer.url,
67
+ async stop() {
68
+ for (const fiber of [...fibers].reverse()) await fiber.dispose()
69
+ },
70
+ }
71
+ }
@@ -0,0 +1,125 @@
1
+ /**
2
+ * The reference app of the ACRYL Framework: a todo list in one plugin. It needs `router` and `state`, keeps the list in a cell, serves
3
+ * JSON CRUD plus a page, and writes the list to a file so it survives a restart. Nothing else.
4
+ */
5
+
6
+ import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
7
+ import { dirname } from 'node:path'
8
+ import type { Context } from '@deepseek-ai/cordis'
9
+ import { Acryl, HTTPException } from './plugins/acryl-router/src/index.ts'
10
+ import { bodyLimit, loopbackOnly, trimTrailingSlash } from './plugins/acryl-router/src/middleware/index.ts'
11
+ import { schemaValidator, streamSSE, type StandardSchema } from './plugins/acryl-router/src/helpers/index.ts'
12
+ import type {} from './plugins/acryl-state/src/index.ts'
13
+ import { PAGE } from './page.ts'
14
+
15
+ /** Stable Cordis plugin name, equal to the package name and the Loader row id. */
16
+ export const name = 'acryl-todo'
17
+ export const inject = ['router', 'state']
18
+
19
+ export interface Todo { id: number, title: string, done: boolean }
20
+ export interface Config { file?: string }
21
+
22
+ /** Standard Schema for the body of a new todo, written by hand so the example has no schema library. */
23
+ const NewTodo: StandardSchema<{ title: string }> = {
24
+ '~standard': {
25
+ validate: value => {
26
+ const title = (value as { title?: unknown } | null)?.title
27
+ return typeof title === 'string' && title.trim() !== '' && title.length <= 200
28
+ ? { value: { title: title.trim() } }
29
+ : { issues: [{ message: 'title must be 1 to 200 characters', path: ['title'] }] }
30
+ },
31
+ },
32
+ }
33
+
34
+ /** Standard Schema for a change to a todo. */
35
+ const TodoChange: StandardSchema<{ done: boolean }> = {
36
+ '~standard': {
37
+ validate: value => {
38
+ const done = (value as { done?: unknown } | null)?.done
39
+ return typeof done === 'boolean' ? { value: { done } } : { issues: [{ message: 'done must be true or false', path: ['done'] }] }
40
+ },
41
+ },
42
+ }
43
+
44
+ /** A todo body is small; anything bigger is refused before it is read. */
45
+ const MAX_BODY_BYTES = 16 * 1024
46
+
47
+ /** A missing file is a new list. Any other failure (unreadable, corrupt, half-written) refuses to start: starting empty would overwrite the user's real list on the first save. */
48
+ function load(file: string | undefined): Todo[] {
49
+ if (file === undefined) return []
50
+ let text: string
51
+ try {
52
+ text = readFileSync(file, 'utf8')
53
+ } catch (error) {
54
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') return []
55
+ throw new Error(`acryl-todo: cannot read ${file}: ${(error as Error).message}`, { cause: error })
56
+ }
57
+ try {
58
+ const parsed: unknown = JSON.parse(text)
59
+ if (!Array.isArray(parsed)) throw new Error('not a list')
60
+ return parsed as Todo[]
61
+ } catch (error) {
62
+ throw new Error(`acryl-todo: ${file} is not a valid todo list (${(error as Error).message}); fix or remove it, it was left untouched`, { cause: error })
63
+ }
64
+ }
65
+
66
+ function save(file: string, todos: readonly Todo[]): void {
67
+ mkdirSync(dirname(file), { recursive: true })
68
+ const temporary = `${file}.tmp`
69
+ writeFileSync(temporary, JSON.stringify(todos))
70
+ renameSync(temporary, file)
71
+ }
72
+
73
+ export function apply(ctx: Context, config: Config = {}): void {
74
+ const todos = ctx.state.cell<readonly Todo[]>('todo.list', load(config.file))
75
+ if (config.file !== undefined) {
76
+ const file = config.file
77
+ ctx.effect(() => todos.subscribe(list => { save(file, list) }), 'acryl-todo: save to file')
78
+ }
79
+ const nextId = (): number => todos.get().reduce((max, todo) => Math.max(max, todo.id), 0) + 1
80
+
81
+ const find = (id: number): Todo => {
82
+ const todo = todos.get().find(item => item.id === id)
83
+ if (todo === undefined) throw new HTTPException(404, { message: 'no such todo' })
84
+ return todo
85
+ }
86
+
87
+ // `/todo/` is what people type: trimTrailingSlash answers a GET for an unknown path ending in `/` with a 301 to the path without it,
88
+ // so the page is found either way while each route keeps one canonical path (the router itself treats `/a` and `/a/` as different).
89
+ const api = new Acryl()
90
+ .use(loopbackOnly({ inProcess: 'allow' }), bodyLimit({ maxSize: MAX_BODY_BYTES }), trimTrailingSlash())
91
+ .get('/', c => c.html(PAGE))
92
+ .get('/api', c => c.json(todos.get()))
93
+ .post('/api', schemaValidator('json', NewTodo), c => {
94
+ const todo: Todo = { id: nextId(), title: c.req.valid('json').title, done: false }
95
+ todos.update(list => [...list, todo])
96
+ return c.json(todo, 201)
97
+ })
98
+ .patch('/api/:id', schemaValidator('json', TodoChange), c => {
99
+ const { id } = find(Number(c.req.param('id')))
100
+ const { done } = c.req.valid('json')
101
+ todos.update(list => list.map(todo => todo.id === id ? { ...todo, done } : todo))
102
+ return c.json(find(id))
103
+ })
104
+ .delete('/api/:id', c => {
105
+ const { id } = find(Number(c.req.param('id')))
106
+ todos.update(list => list.filter(todo => todo.id !== id))
107
+ return c.body(null, 204)
108
+ })
109
+ .get('/events', c => streamSSE(c, async stream => {
110
+ const ended = new Promise<void>(resolve => { stream.onAbort(resolve) })
111
+ // Writes are asynchronous; chaining them keeps events in the order the list changed.
112
+ let queue: Promise<unknown> = Promise.resolve()
113
+ const send = (list: readonly Todo[]): void => { queue = queue.then(() => stream.writeSSE({ data: JSON.stringify(list) })).catch(() => {}) }
114
+ send(todos.get())
115
+ const unsubscribe = todos.subscribe(send)
116
+ try {
117
+ await ended
118
+ } finally {
119
+ unsubscribe()
120
+ }
121
+ }))
122
+ ctx.router.route('/todo', api)
123
+
124
+ ctx.inject(['routerHttp'], inner => { inner.routerHttp.mount('/todo') })
125
+ }
@@ -0,0 +1,71 @@
1
+ import { join } from 'node:path'
2
+ import { fileURLToPath } from 'node:url'
3
+ import { Context, type Fiber } from '@deepseek-ai/cordis'
4
+ import * as nodeServer from '../plugins/acryl-node-server/src/index.ts'
5
+ import * as router from '../plugins/acryl-router/src/index.ts'
6
+ import * as state from '../plugins/acryl-state/src/index.ts'
7
+ import { type AppInstance, instance } from '../plugins/instance/index.ts'
8
+ import {
9
+ blueprintsIn,
10
+ compose,
11
+ loadManifest,
12
+ type ParameterValue,
13
+ } from '../plugins/manifest/index.ts'
14
+ import * as todo from './todo.ts'
15
+
16
+ /** The plugins this app's code provides. `blend.yaml` chooses, orders and configures them. */
17
+ export const registry = {
18
+ 'acryl-router': router,
19
+ 'acryl-state': state,
20
+ todo,
21
+ 'acryl-node-server': nodeServer,
22
+ }
23
+
24
+ export const APP_NAME = '__APP_NAME__'
25
+
26
+ /** The manifest next to this app's source. */
27
+ export const MANIFEST = join(fileURLToPath(new URL('..', import.meta.url)), 'blend.yaml')
28
+
29
+ /**
30
+ * The app's manifest, resolved. A Blend finds its Blueprint in `blueprints/<id>.yaml`; an app that is itself a Blueprint never looks.
31
+ * `createApp` and the tests read it the same way.
32
+ */
33
+ export function readManifest(values?: Readonly<Record<string, ParameterValue>>) {
34
+ return loadManifest(MANIFEST, {
35
+ known: Object.keys(registry),
36
+ getBlueprint: blueprintsIn(join(fileURLToPath(new URL('..', import.meta.url)), 'blueprints')),
37
+ ...(values ? { values } : {}),
38
+ })
39
+ }
40
+
41
+ export interface App {
42
+ readonly root: Context
43
+ readonly url: string
44
+ stop(): Promise<void>
45
+ }
46
+
47
+ /** The app: `instance` first (where it lives, chosen at run time), then the manifest's rows in order. */
48
+ export async function createApp(
49
+ chosen: AppInstance,
50
+ options: { values?: Readonly<Record<string, ParameterValue>> } = {},
51
+ ): Promise<App> {
52
+ const manifest = readManifest(options.values)
53
+ const root = new Context()
54
+ const first = root.plugin(instance, chosen)
55
+ await first.await()
56
+ const fibers: Fiber[] = [
57
+ first,
58
+ ...(await compose(root, manifest, registry, {
59
+ // Runtime values the manifest cannot hold: where the list is kept (in the app's home) and where the server listens.
60
+ todo: { file: join(chosen.home, 'todo.json') },
61
+ server: { port: chosen.port.start, scan: chosen.port.scan ? 20 : 0 },
62
+ })),
63
+ ]
64
+ return {
65
+ root,
66
+ url: root.nodeServer.url,
67
+ async stop() {
68
+ for (const fiber of [...fibers].reverse()) await fiber.dispose()
69
+ },
70
+ }
71
+ }
@@ -0,0 +1,125 @@
1
+ /**
2
+ * The reference app of the ACRYL Framework: a todo list in one plugin. It needs `router` and `state`, keeps the list in a cell, serves
3
+ * JSON CRUD plus a page, and writes the list to a file so it survives a restart. Nothing else.
4
+ */
5
+
6
+ import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
7
+ import { dirname } from 'node:path'
8
+ import type { Context } from '@deepseek-ai/cordis'
9
+ import { Acryl, HTTPException } from '../plugins/acryl-router/src/index.ts'
10
+ import { bodyLimit, loopbackOnly, trimTrailingSlash } from '../plugins/acryl-router/src/middleware/index.ts'
11
+ import { schemaValidator, streamSSE, type StandardSchema } from '../plugins/acryl-router/src/helpers/index.ts'
12
+ import type {} from '../plugins/acryl-state/src/index.ts'
13
+ import { PAGE } from './page.ts'
14
+
15
+ /** Stable Cordis plugin name, equal to the package name and the Loader row id. */
16
+ export const name = 'acryl-todo'
17
+ export const inject = ['router', 'state']
18
+
19
+ export interface Todo { id: number, title: string, done: boolean }
20
+ export interface Config { file?: string }
21
+
22
+ /** Standard Schema for the body of a new todo, written by hand so the example has no schema library. */
23
+ const NewTodo: StandardSchema<{ title: string }> = {
24
+ '~standard': {
25
+ validate: value => {
26
+ const title = (value as { title?: unknown } | null)?.title
27
+ return typeof title === 'string' && title.trim() !== '' && title.length <= 200
28
+ ? { value: { title: title.trim() } }
29
+ : { issues: [{ message: 'title must be 1 to 200 characters', path: ['title'] }] }
30
+ },
31
+ },
32
+ }
33
+
34
+ /** Standard Schema for a change to a todo. */
35
+ const TodoChange: StandardSchema<{ done: boolean }> = {
36
+ '~standard': {
37
+ validate: value => {
38
+ const done = (value as { done?: unknown } | null)?.done
39
+ return typeof done === 'boolean' ? { value: { done } } : { issues: [{ message: 'done must be true or false', path: ['done'] }] }
40
+ },
41
+ },
42
+ }
43
+
44
+ /** A todo body is small; anything bigger is refused before it is read. */
45
+ const MAX_BODY_BYTES = 16 * 1024
46
+
47
+ /** A missing file is a new list. Any other failure (unreadable, corrupt, half-written) refuses to start: starting empty would overwrite the user's real list on the first save. */
48
+ function load(file: string | undefined): Todo[] {
49
+ if (file === undefined) return []
50
+ let text: string
51
+ try {
52
+ text = readFileSync(file, 'utf8')
53
+ } catch (error) {
54
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') return []
55
+ throw new Error(`acryl-todo: cannot read ${file}: ${(error as Error).message}`, { cause: error })
56
+ }
57
+ try {
58
+ const parsed: unknown = JSON.parse(text)
59
+ if (!Array.isArray(parsed)) throw new Error('not a list')
60
+ return parsed as Todo[]
61
+ } catch (error) {
62
+ throw new Error(`acryl-todo: ${file} is not a valid todo list (${(error as Error).message}); fix or remove it, it was left untouched`, { cause: error })
63
+ }
64
+ }
65
+
66
+ function save(file: string, todos: readonly Todo[]): void {
67
+ mkdirSync(dirname(file), { recursive: true })
68
+ const temporary = `${file}.tmp`
69
+ writeFileSync(temporary, JSON.stringify(todos))
70
+ renameSync(temporary, file)
71
+ }
72
+
73
+ export function apply(ctx: Context, config: Config = {}): void {
74
+ const todos = ctx.state.cell<readonly Todo[]>('todo.list', load(config.file))
75
+ if (config.file !== undefined) {
76
+ const file = config.file
77
+ ctx.effect(() => todos.subscribe(list => { save(file, list) }), 'acryl-todo: save to file')
78
+ }
79
+ const nextId = (): number => todos.get().reduce((max, todo) => Math.max(max, todo.id), 0) + 1
80
+
81
+ const find = (id: number): Todo => {
82
+ const todo = todos.get().find(item => item.id === id)
83
+ if (todo === undefined) throw new HTTPException(404, { message: 'no such todo' })
84
+ return todo
85
+ }
86
+
87
+ // `/todo/` is what people type: trimTrailingSlash answers a GET for an unknown path ending in `/` with a 301 to the path without it,
88
+ // so the page is found either way while each route keeps one canonical path (the router itself treats `/a` and `/a/` as different).
89
+ const api = new Acryl()
90
+ .use(loopbackOnly({ inProcess: 'allow' }), bodyLimit({ maxSize: MAX_BODY_BYTES }), trimTrailingSlash())
91
+ .get('/', c => c.html(PAGE))
92
+ .get('/api', c => c.json(todos.get()))
93
+ .post('/api', schemaValidator('json', NewTodo), c => {
94
+ const todo: Todo = { id: nextId(), title: c.req.valid('json').title, done: false }
95
+ todos.update(list => [...list, todo])
96
+ return c.json(todo, 201)
97
+ })
98
+ .patch('/api/:id', schemaValidator('json', TodoChange), c => {
99
+ const { id } = find(Number(c.req.param('id')))
100
+ const { done } = c.req.valid('json')
101
+ todos.update(list => list.map(todo => todo.id === id ? { ...todo, done } : todo))
102
+ return c.json(find(id))
103
+ })
104
+ .delete('/api/:id', c => {
105
+ const { id } = find(Number(c.req.param('id')))
106
+ todos.update(list => list.filter(todo => todo.id !== id))
107
+ return c.body(null, 204)
108
+ })
109
+ .get('/events', c => streamSSE(c, async stream => {
110
+ const ended = new Promise<void>(resolve => { stream.onAbort(resolve) })
111
+ // Writes are asynchronous; chaining them keeps events in the order the list changed.
112
+ let queue: Promise<unknown> = Promise.resolve()
113
+ const send = (list: readonly Todo[]): void => { queue = queue.then(() => stream.writeSSE({ data: JSON.stringify(list) })).catch(() => {}) }
114
+ send(todos.get())
115
+ const unsubscribe = todos.subscribe(send)
116
+ try {
117
+ await ended
118
+ } finally {
119
+ unsubscribe()
120
+ }
121
+ }))
122
+ ctx.router.route('/todo', api)
123
+
124
+ ctx.inject(['routerHttp'], inner => { inner.routerHttp.mount('/todo') })
125
+ }
@@ -1,4 +1,4 @@
1
- import { _ as UiRequest, n as AuditEntry, r as ChannelToHost, v as UiResult } from "./protocol-a2aqrp2X.js";
1
+ import { _ as UiRequest, n as AuditEntry, r as ChannelToHost, v as UiResult } from "./protocol-BeIYCgTo.js";
2
2
 
3
3
  //#region ../../plugins/acryl-door/src/host/channel.d.ts
4
4
  /** The slice of a page's stream the channel needs, so tests can supply their own. */
@@ -16,14 +16,21 @@ interface ChannelOptions {
16
16
  * address registers again a moment later). 0 (the default) fails at once.
17
17
  */
18
18
  readonly attachWaitMs?: number;
19
+ /**
20
+ * Every call must name the page it is for (`page`), and goes to that page alone: never to the page in front or the one used last, which may be another visitor's. A page-only door
21
+ * (a production assistant, one channel for every visitor of the app) sets it.
22
+ */
23
+ readonly requirePage?: boolean;
19
24
  }
20
25
  /**
21
26
  * The pages and who drives which. One page answers a call, chosen the same way every time: the page whose panel asked for the run that is making the call (`driveFor`), else the page the
22
27
  * Host just sent somewhere (it comes back as a new page, `prefer`), else the one in front (it reports `focused`), else the most recently active; a page that did not acknowledge a call in time goes to the end of the line. An explicit desktop `window` picks its page.
28
+ * A call that names its `page` (the page a run was asked from) goes to that page alone, followed into its tab when it reloads or moves, and to no other.
23
29
  */
24
30
  declare class UiChannel {
25
31
  private readonly ackMs;
26
32
  private readonly attachWaitMs;
33
+ private readonly requirePage;
27
34
  /** The page the Host sent to a new place and that came back: it keeps being driven until the person focuses another page. */
28
35
  private preferred;
29
36
  /** The pages whose panels have a run going, the latest last: a call with no `window` goes to the latest of them that is still connected. */
@@ -40,7 +47,7 @@ declare class UiChannel {
40
47
  * Register a page's stream. A stream already open for the same window is closed first, and its calls settle as `disconnected`.
41
48
  * @returns the function to call when this stream ends.
42
49
  */
43
- attach(windowId: string, focused: boolean, stream: PageStream, shellWindow?: number): () => void;
50
+ attach(windowId: string, focused: boolean, stream: PageStream, shellWindow?: number, tab?: string): () => void;
44
51
  /** A message a page sent by POST. Only the window that was asked may answer a call, so another page cannot forge a result. */
45
52
  receive(windowId: string, message: ChannelToHost): void;
46
53
  /** True when at least one page is connected. */
@@ -57,6 +64,15 @@ declare class UiChannel {
57
64
  * @returns the function to call when the run ends.
58
65
  */
59
66
  driveFor(windowId: string): () => void;
67
+ /** Where the page a run was asked from is now: the same window, or the page that replaced it in the same tab. */
68
+ private whereIs;
69
+ /**
70
+ * A promise for the run's page coming back after a reload or a move: the next page that connects in its tab (or with its window id). Ask for it BEFORE the call that makes the page
71
+ * leave. Pages of other tabs, another visitor's among them, are never taken for it.
72
+ * @param signal - stop waiting (the act did not move the page after all); the promise then answers `undefined`.
73
+ * @returns the window id of the page that came back, or `undefined` when it did not within `timeoutMs`.
74
+ */
75
+ expectPageBack(page: string, timeoutMs: number, signal?: AbortSignal): Promise<string | undefined>;
60
76
  private tellPage;
61
77
  private tell;
62
78
  /** Drive this page from now on (it is the one the Host just sent to a new place), until the person focuses another. */
@@ -70,7 +86,13 @@ declare class UiChannel {
70
86
  signal?: AbortSignal;
71
87
  timeoutMs?: number;
72
88
  window?: number;
89
+ page?: string;
73
90
  }, retried?: boolean): Promise<UiResult>;
91
+ /**
92
+ * A call for one page (the page a run was asked from, followed into its tab): it goes to that page alone and never fails over to another one. A page that is between two
93
+ * documents (it reloads, or moves to another path) is waited for, up to the attach wait.
94
+ */
95
+ private callPage;
74
96
  private callOne;
75
97
  /**
76
98
  * 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.
@@ -79,9 +101,10 @@ declare class UiChannel {
79
101
  expectAttach(timeoutMs: number, count?: number): Promise<boolean>;
80
102
  /**
81
103
  * A promise for the window id of the next page that connects and was not connected when this was asked (the page that came back after a reload or a move).
104
+ * @param signal - stop waiting; the promise then answers `undefined`.
82
105
  * @returns that id, or `undefined` when no new page connected within `timeoutMs`.
83
106
  */
84
- expectNewPage(timeoutMs: number): Promise<string | undefined>;
107
+ expectNewPage(timeoutMs: number, signal?: AbortSignal): Promise<string | undefined>;
85
108
  /**
86
109
  * 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
87
110
  * (it was closed, or it moved to a page without the door) is not an error here.
@@ -175,7 +175,7 @@ function clientModules(options) {
175
175
  const cache = new CompileCache(options.cacheBytes ?? DEFAULT_CACHE_BYTES);
176
176
  const app = new Acryl();
177
177
  app.get("/*", (c) => {
178
- const requestPath = c.req.path.slice(basePath(c).length);
178
+ const requestPath = c.req.path.slice(options.mount === void 0 ? basePath(c).length : options.mount.length);
179
179
  const runtime = /^\/@acrylblends\/([a-z-]+\.js)$/u.exec(requestPath)?.[1];
180
180
  const send = (code, etag) => {
181
181
  if (c.req.header("if-none-match") === etag) return c.body(null, 304, {