acrylblends 0.7.7 → 0.8.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 (195) hide show
  1. package/dist/index.d.ts +14 -6
  2. package/dist/index.js +130 -31
  3. package/dist/index.js.map +1 -1
  4. package/docs/framework/README.md +3 -3
  5. package/docs/framework/UPSTREAM.md +1 -1
  6. package/docs/framework/agents/index.md +18 -3
  7. package/docs/framework/getting-started/basic.md +1 -2
  8. package/docs/framework/getting-started/desktop.md +3 -3
  9. package/docs/framework/guides/bring-your-own-tool.md +216 -0
  10. package/docs/framework/guides/client-modules.md +4 -3
  11. package/docs/framework/guides/create-acrylblends.md +5 -4
  12. package/docs/framework/guides/data.md +7 -6
  13. package/docs/framework/helpers/streaming.md +12 -27
  14. package/docs/framework/upstream.json +3 -3
  15. package/lib/browser/read-sse.js +84 -0
  16. package/lib/db/sqlite.d.ts +1 -1
  17. package/lib/db.d.ts +1 -84
  18. package/lib/index-BLTYsyc1.d.ts +93 -0
  19. package/lib/index-D4FAsBZu.d.ts +85 -0
  20. package/lib/kv/db.d.ts +2 -2
  21. package/lib/kv/json.d.ts +1 -1
  22. package/lib/kv/json.js +87 -24
  23. package/lib/kv.d.ts +1 -92
  24. package/lib/node-server.d.ts +3 -3
  25. package/lib/node-server.js +14 -8
  26. package/lib/read-sse.d.ts +33 -0
  27. package/lib/read-sse.js +84 -0
  28. package/package.json +5 -1
  29. package/plugins/acryl-db/plugin.json +7 -2
  30. package/plugins/acryl-db/src/conformance.ts +224 -0
  31. package/plugins/acryl-db/upstream.json +2 -2
  32. package/plugins/acryl-db-sqlite/plugin.json +4 -1
  33. package/plugins/acryl-db-sqlite/upstream.json +1 -1
  34. package/plugins/acryl-door/LICENSE +47 -0
  35. package/plugins/acryl-door/plugin.json +14 -0
  36. package/plugins/acryl-door/src/cli/acryl.ts +207 -0
  37. package/plugins/acryl-door/src/host/api.ts +9 -0
  38. package/plugins/acryl-door/src/host/audit.ts +64 -0
  39. package/plugins/acryl-door/src/host/channel.ts +142 -0
  40. package/plugins/acryl-door/src/host/chat-tools.ts +39 -0
  41. package/plugins/acryl-door/src/host/door-file.ts +94 -0
  42. package/plugins/acryl-door/src/host/door.ts +195 -0
  43. package/plugins/acryl-door/src/host/http.ts +80 -0
  44. package/plugins/acryl-door/src/host/mcp-routes.ts +83 -0
  45. package/plugins/acryl-door/src/host/operator-routes.ts +38 -0
  46. package/plugins/acryl-door/src/host/page-routes.ts +65 -0
  47. package/plugins/acryl-door/src/host/shim.ts +65 -0
  48. package/plugins/acryl-door/src/host/verbs.ts +109 -0
  49. package/plugins/acryl-door/src/index.ts +133 -0
  50. package/plugins/acryl-door/src/page/api.ts +9 -0
  51. package/plugins/acryl-door/src/page/channel.ts +135 -0
  52. package/plugins/acryl-door/src/page/driver/accessibility.ts +163 -0
  53. package/plugins/acryl-door/src/page/driver/actions.ts +186 -0
  54. package/plugins/acryl-door/src/page/driver/driver.ts +153 -0
  55. package/plugins/acryl-door/src/page/driver/ref-table.ts +78 -0
  56. package/plugins/acryl-door/src/page/driver/sensitivity.ts +49 -0
  57. package/plugins/acryl-door/src/page/driver/snapshot.ts +132 -0
  58. package/plugins/acryl-door/src/page/index.ts +40 -0
  59. package/plugins/acryl-door/src/page/indicator.ts +72 -0
  60. package/plugins/acryl-door/src/page/main.ts +7 -0
  61. package/plugins/acryl-door/src/page/protocol.ts +213 -0
  62. package/plugins/acryl-door/src/page/snapshot-text.ts +18 -0
  63. package/plugins/acryl-door/src/page/user-input.ts +24 -0
  64. package/plugins/acryl-door/upstream.json +28 -0
  65. package/plugins/acryl-inspect/upstream.json +1 -1
  66. package/plugins/acryl-kv/plugin.json +7 -2
  67. package/plugins/acryl-kv/src/conformance.ts +226 -0
  68. package/plugins/acryl-kv/upstream.json +1 -1
  69. package/plugins/acryl-kv-db/plugin.json +4 -1
  70. package/plugins/acryl-kv-db/upstream.json +1 -1
  71. package/plugins/acryl-kv-json/plugin.json +4 -1
  72. package/plugins/acryl-kv-json/src/open.ts +86 -22
  73. package/plugins/acryl-kv-json/upstream.json +2 -2
  74. package/plugins/acryl-node-server/src/adapter/listener.ts +5 -0
  75. package/plugins/acryl-node-server/src/client-modules.ts +10 -9
  76. package/plugins/acryl-node-server/upstream.json +2 -2
  77. package/plugins/acryl-router/src/browser/read-sse.ts +3 -0
  78. package/plugins/acryl-router/src/helpers/read-sse.ts +111 -0
  79. package/plugins/acryl-router/src/service.ts +3 -0
  80. package/plugins/acryl-router/upstream.json +2 -2
  81. package/plugins/acryl-router-http/upstream.json +1 -1
  82. package/plugins/acryl-state/upstream.json +1 -1
  83. package/plugins/agent-panel/client/main.ts +159 -0
  84. package/plugins/agent-panel/index.ts +232 -0
  85. package/plugins/agent-panel/plugin.json +21 -0
  86. package/plugins/builder/index.ts +2 -2
  87. package/plugins/builder/plugin.json +1 -1
  88. package/plugins/desktop/main.ts +3 -3
  89. package/plugins/desktop/plugin.json +2 -2
  90. package/plugins/desktop/upstream.json +1 -1
  91. package/plugins/desktop-web/main.ts +2 -2
  92. package/plugins/desktop-web/plugin.json +2 -2
  93. package/plugins/desktop-web/upstream.json +1 -1
  94. package/plugins/extensions/index.ts +19 -1
  95. package/plugins/extensions/plugin.json +1 -1
  96. package/plugins/extensions/resolve.ts +17 -3
  97. package/plugins/manifest/lock.ts +7 -3
  98. package/plugins/manifest/upgrade-cli.ts +1 -1
  99. package/plugins/save/cli.ts +1 -1
  100. package/plugins/save/plugin.json +1 -1
  101. package/plugins/tsconfig.json +6 -0
  102. package/shared/AGENTS.core.md +10 -10
  103. package/shared/DEFAULTS.md +45 -0
  104. package/shared/SKILL.md +14 -0
  105. package/templates/agent/AGENTS.md +1 -1
  106. package/templates/agent/README.md +6 -6
  107. package/templates/agent/blend.yaml +3 -0
  108. package/templates/agent/gitignore +1 -0
  109. package/templates/agent/package.json +8 -8
  110. package/templates/agent/src/app.ts +46 -17
  111. package/templates/agent/src/main.ts +2 -2
  112. package/templates/agent/src/routes.ts +19 -6
  113. package/templates/agent/template.json +2 -1
  114. package/templates/agent/test/agent.test.ts +11 -5
  115. package/templates/agent/test/app.test.ts +30 -9
  116. package/templates/agent/test/builder.test.ts +35 -24
  117. package/templates/agent/test/model.test.ts +25 -20
  118. package/templates/agent/tsconfig.json +48 -50
  119. package/templates/blank/AGENTS.md +1 -1
  120. package/templates/blank/README.md +2 -2
  121. package/templates/blank/gitignore +1 -0
  122. package/templates/blank/package.json +8 -8
  123. package/templates/blank/src/about.tsx +2 -2
  124. package/templates/blank/src/app.ts +29 -9
  125. package/templates/blank/src/main.ts +1 -1
  126. package/templates/blank/src/routes.ts +1 -1
  127. package/templates/blank/test/app.test.ts +29 -10
  128. package/templates/blank/tsconfig.json +1 -1
  129. package/templates/chat/AGENTS.md +16 -0
  130. package/templates/chat/blend.yaml +29 -0
  131. package/templates/chat/gitignore +6 -0
  132. package/templates/chat/migrations/001-chat.sql +12 -0
  133. package/templates/chat/package.json +33 -0
  134. package/templates/chat/src/app.ts +106 -0
  135. package/templates/chat/src/chat-app.ts +101 -0
  136. package/templates/chat/src/client/chat.tsx +115 -0
  137. package/templates/chat/src/client/tsconfig.json +26 -0
  138. package/templates/chat/src/db.ts +43 -0
  139. package/templates/chat/src/door.ts +7 -0
  140. package/templates/chat/src/llm.ts +42 -0
  141. package/templates/chat/src/main.ts +28 -0
  142. package/templates/chat/src/page.tsx +40 -0
  143. package/templates/chat/src/routes.ts +15 -0
  144. package/templates/chat/src/runtime.ts +17 -0
  145. package/templates/chat/template.json +11 -0
  146. package/templates/chat/test/chat.test.ts +198 -0
  147. package/templates/chat/test/server.test.ts +78 -0
  148. package/templates/chat/tsconfig.json +20 -0
  149. package/templates/nodejs/AGENTS.md +9 -0
  150. package/templates/nodejs/blend.yaml +26 -0
  151. package/templates/nodejs/gitignore +3 -0
  152. package/templates/nodejs/package.json +30 -0
  153. package/templates/nodejs/src/app.ts +92 -0
  154. package/templates/nodejs/src/client/counter.tsx +12 -0
  155. package/templates/nodejs/src/client/tsconfig.json +26 -0
  156. package/templates/nodejs/src/door.ts +7 -0
  157. package/templates/nodejs/src/main.ts +25 -0
  158. package/templates/nodejs/src/page.tsx +29 -0
  159. package/templates/nodejs/src/routes.ts +40 -0
  160. package/templates/nodejs/template.json +3 -0
  161. package/templates/nodejs/test/app.test.ts +78 -0
  162. package/templates/nodejs/test/server.test.ts +83 -0
  163. package/templates/nodejs/tsconfig.json +20 -0
  164. package/templates/notes/AGENTS.md +26 -0
  165. package/templates/notes/blend.yaml +29 -0
  166. package/templates/notes/gitignore +6 -0
  167. package/templates/notes/migrations/001-notes.sql +18 -0
  168. package/templates/notes/package.json +33 -0
  169. package/templates/notes/src/app.ts +106 -0
  170. package/templates/notes/src/auth.ts +57 -0
  171. package/templates/notes/src/client/counter.tsx +21 -0
  172. package/templates/notes/src/client/tsconfig.json +26 -0
  173. package/templates/notes/src/db.ts +50 -0
  174. package/templates/notes/src/door.ts +7 -0
  175. package/templates/notes/src/main.ts +28 -0
  176. package/templates/notes/src/notes-app.ts +205 -0
  177. package/templates/notes/src/routes.ts +14 -0
  178. package/templates/notes/src/runtime.ts +17 -0
  179. package/templates/notes/src/schema.ts +38 -0
  180. package/templates/notes/src/views.tsx +136 -0
  181. package/templates/notes/template.json +11 -0
  182. package/templates/notes/test/notes.test.ts +280 -0
  183. package/templates/notes/test/server.test.ts +78 -0
  184. package/templates/notes/tsconfig.json +20 -0
  185. package/templates/todo/README.md +1 -1
  186. package/templates/todo/gitignore +1 -0
  187. package/templates/todo/package.json +8 -8
  188. package/templates/todo/src/app.ts +32 -12
  189. package/templates/todo/src/main.ts +1 -1
  190. package/templates/todo/src/todo.ts +4 -4
  191. package/templates/todo/test/todo.test.ts +48 -23
  192. package/templates/todo/tsconfig.json +1 -1
  193. package/templates/todo/upstream.json +2 -2
  194. package/lib/database-D_UN6We8.d.ts +0 -44
  195. package/lib/kv-DWDm0Lja.d.ts +0 -39
@@ -0,0 +1,216 @@
1
+ # Bring your own tool: a provider of a port
2
+
3
+ Every choice in an app is a plugin behind a port, and what the app starts with is only a fallback. Any outside npm tool (a database, a store, a renderer, an auth library) joins the
4
+ app as another **provider** of the same port: a plugin that implements the port's interface on top of the tool and provides the service by its name. The code that uses the port
5
+ does not change. The port's **conformance suite** decides whether your provider is accepted, whoever wrote it. And because Cordis finds services by name, the app can swap the
6
+ provider **while it runs**: the plugins that use the port wait, and carry on when the new provider arrives. A person does it, or an agent working in the app folder, through the door.
7
+
8
+ This page does it once, from start to end, with a real tool: [lowdb](https://github.com/typicode/lowdb), a small JSON-file database, becomes a provider of the `kv` port ([Data](data.md#a-tiny-app-kv)).
9
+ Every step runs as written: the code on this page is extracted and run against a fresh app by `scripts/verify-byo-tool.mjs`.
10
+
11
+ You need an app with the `extensions` plugin, which installs plugins while the app runs: `npx acrylblends init my-app --template agent`, then `cd my-app` and `npm install`.
12
+
13
+ ## 1. Bring the port, its suite and a way to run it
14
+
15
+ ```sh
16
+ npx acrylblends add kv --suite
17
+ npm install
18
+ ```
19
+
20
+ `add kv` copies the port (`plugins/acryl-kv`) and the JSON-file provider (`plugins/acryl-kv-json`) and gives your plugins the names `#acryl-kv` and `#acryl-kv-json` (entries in `package.json` "imports").
21
+ `--suite` also copies the port's conformance suite (`#acryl-kv/conformance`) and adds `vitest`, which runs it. The same works for the `db` port: `npx acrylblends add db --suite`.
22
+
23
+ ## 2. Install the tool
24
+
25
+ ```sh
26
+ npm install lowdb
27
+ ```
28
+
29
+ ## 3. Write the provider
30
+
31
+ A provider is a folder in `plugins/` with an `index.ts` that exports `name`, and `apply(ctx)` which provides the service. Keep the tool behind a plain function that returns the port's interface (`openKv`
32
+ here), so tests and plain code use it without Cordis, and let `apply` be a thin wrapper that provides it for as long as the plugin runs.
33
+
34
+ The port gives you the checks every provider must make alike (`checkKey`, `encodeValue`, `decodeValue`, `runChange`, `compareKeys`), so a bad call fails the same way on a file, on SQL and on your tool.
35
+
36
+ ```ts no-check
37
+ // plugins/kv-lowdb/index.ts
38
+ import { join } from 'node:path'
39
+ import type { Context } from '@deepseek-ai/cordis'
40
+ import { checkKey, compareKeys, decodeValue, encodeValue, KV_SERVICE, runChange } from '#acryl-kv'
41
+ import type { Kv, KvOpened } from '#acryl-kv'
42
+ import { Low } from 'lowdb'
43
+ import { JSONFile } from 'lowdb/node'
44
+
45
+ /** The kv port on lowdb: one JSON file, written atomically by lowdb after every change. */
46
+ export async function openKv({ path }: { path: string }): Promise<KvOpened> {
47
+ const db = new Low<Record<string, unknown>>(new JSONFile(path), {})
48
+ await db.read()
49
+ // One call at a time, in order: that is what makes `update` atomic and lets `close` wait for the writes in flight.
50
+ let queue: Promise<unknown> = Promise.resolve()
51
+ let closed = false
52
+ const run = <T>(work: () => Promise<T>): Promise<T> => {
53
+ if (closed) return Promise.reject(new Error('the kv store is closed'))
54
+ const result = queue.then(work)
55
+ queue = result.catch(() => {})
56
+ return result
57
+ }
58
+ // A copy of the stored value, so changing what `get` returned changes nothing stored.
59
+ const copy = (key: string): unknown =>
60
+ key in db.data ? decodeValue(encodeValue(db.data[key])) : undefined
61
+ const kv: Kv = {
62
+ get: <T>(key: string) =>
63
+ run(async () => {
64
+ checkKey(key)
65
+ return copy(key) as T | undefined
66
+ }),
67
+ set: (key, value) =>
68
+ run(async () => {
69
+ checkKey(key)
70
+ db.data[key] = decodeValue(encodeValue(value))
71
+ await db.write()
72
+ }),
73
+ update: <T>(key: string, change: (current: T | undefined) => T) =>
74
+ run(async () => {
75
+ checkKey(key)
76
+ const next = runChange(change, copy(key) as T | undefined)
77
+ db.data[key] = decodeValue(encodeValue(next))
78
+ await db.write()
79
+ return copy(key) as T
80
+ }),
81
+ delete: (key) =>
82
+ run(async () => {
83
+ checkKey(key)
84
+ delete db.data[key]
85
+ await db.write()
86
+ }),
87
+ list: (prefix = '') =>
88
+ run(async () =>
89
+ Object.keys(db.data)
90
+ .filter((key) => key.startsWith(prefix))
91
+ .sort(compareKeys),
92
+ ),
93
+ }
94
+ return {
95
+ kv,
96
+ async close() {
97
+ closed = true
98
+ await queue
99
+ },
100
+ }
101
+ }
102
+
103
+ export const name = 'kv-lowdb'
104
+
105
+ /** As a plugin: provide `kv` for as long as the plugin runs; the store closes after `kv` is withdrawn. */
106
+ export async function apply(ctx: Context, config: { path?: string } = {}): Promise<void> {
107
+ const file = config.path ?? join(ctx.appInstance.home, 'kv-lowdb.json')
108
+ await ctx.effect(async function* () {
109
+ const opened = await openKv({ path: file })
110
+ yield () => opened.close()
111
+ yield ctx.reflect.provide(KV_SERVICE, opened.kv)
112
+ }, `kv-lowdb: ${file}`)
113
+ }
114
+ ```
115
+
116
+ (A block whose first line is a path comment is the file of that name.)
117
+
118
+ ## 4. Pass the suite
119
+
120
+ The suite is the contract: it runs the same cases against every provider. A test gives it a fresh store for each case and a way to reopen the same storage.
121
+
122
+ ```ts no-check
123
+ // test/kv-lowdb.spec.ts
124
+ import { mkdir, mkdtemp } from 'node:fs/promises'
125
+ import { join } from 'node:path'
126
+ import { describeKvConformance, type KvUnderTest } from '#acryl-kv/conformance'
127
+ import { openKv } from '../plugins/kv-lowdb/index.ts'
128
+
129
+ const underTest = async (path: string): Promise<KvUnderTest> => {
130
+ const opened = await openKv({ path })
131
+ return {
132
+ kv: opened.kv,
133
+ close: opened.close,
134
+ reopen: async () => {
135
+ await opened.close()
136
+ return underTest(path)
137
+ },
138
+ }
139
+ }
140
+
141
+ // A fresh file per case, in a folder of the project (a sandboxed agent may not write to the system temp folder).
142
+ describeKvConformance('kv-lowdb', async () => {
143
+ await mkdir('.acrylblends', { recursive: true })
144
+ return underTest(join(await mkdtemp(join('.acrylblends', 'kv-lowdb-')), 'kv.json'))
145
+ })
146
+ ```
147
+
148
+ ```sh
149
+ npx vitest run test/kv-lowdb.spec.ts
150
+ ```
151
+
152
+ All the cases must pass. A failure names the rule that broke (`counts fifty concurrent updates to exactly fifty`, `keeps what was written across close and reopen`, ...): fix the provider, not the test.
153
+
154
+ ## 5. Make the default swappable, and swap
155
+
156
+ A provider that the app's code mounts at start (a row of `blend.yaml`) is fixed until the next start. To swap while the app runs, install both the default and yours as plugins of the app. A one-line plugin
157
+ names the default:
158
+
159
+ ```ts no-check
160
+ // plugins/kv/index.ts
161
+ export { apply, Config } from '#acryl-kv-json'
162
+ export const name = 'kv'
163
+ ```
164
+
165
+ Start the app and open it in a browser (`npm run dev`). The door is the command `.acryl/bin/acryl` in the app folder:
166
+
167
+ ```sh
168
+ .acryl/bin/acryl plugin add plugins/kv
169
+ ```
170
+
171
+ Any plugin that declares `inject: ['kv']` now has a store. This one counts visits:
172
+
173
+ ```ts no-check
174
+ // plugins/visits/index.ts
175
+ import type { Context } from '@deepseek-ai/cordis'
176
+ import { Acryl } from 'acrylblends'
177
+ import type {} from '#acryl-kv'
178
+
179
+ export const name = 'visits'
180
+ export const inject = ['router', 'kv']
181
+
182
+ /** GET /visits counts the visits in the kv store, whichever provider holds it. */
183
+ export function apply(ctx: Context): void {
184
+ ctx.router.route(
185
+ '/',
186
+ new Acryl().get('/visits', async (c) =>
187
+ c.json({ visits: await ctx.kv.update<number>('visits', (n) => (n ?? 0) + 1) }),
188
+ ),
189
+ )
190
+ }
191
+ ```
192
+
193
+ ```sh
194
+ .acryl/bin/acryl plugin add plugins/visits
195
+ curl <the address npm run dev printed>/visits # {"visits":1}, then 2, then 3
196
+ ```
197
+
198
+ Now swap. Two providers of one service cannot run together (the second is refused and nothing changes), so take the default out and put yours in:
199
+
200
+ ```sh
201
+ .acryl/bin/acryl plugin remove kv
202
+ .acryl/bin/acryl plugin list # visits: pending, missing kv
203
+ .acryl/bin/acryl plugin add plugins/kv-lowdb
204
+ curl <the address>/visits # {"visits":1}: served again, by the same process
205
+ ```
206
+
207
+ `visits` was never edited. While no provider existed its route was gone and its state was `pending`; the moment `kv-lowdb` provided `kv` it started again. The process did not restart, and the swap survives a
208
+ restart: the app remembers what was installed.
209
+
210
+ ## What to know
211
+
212
+ - **Data does not move by itself.** The new store starts empty (the visits counted from 1 again). To carry it over, copy before you swap, in a script or a plugin: `copyKv(source, target)` from `#acryl-kv` copies one store into another.
213
+ - **A failed install changes nothing.** A provider that throws on start, or that cannot import something, is refused and the previous state stays. To go back: `plugin remove kv-lowdb`, then `plugin add plugins/kv`.
214
+ - **The suite checks the port, not your tool.** It cannot know that lowdb loses a write on a power cut. Read what the tool promises about durability before you trust it with more than a demo.
215
+ - **Other ports and tools work the same way.** `db`: `npx acrylblends add db --suite`, wrap a driver or an ORM behind `Db`, pass `describeDbConformance`. Keep the opener rule ([Data](data.md#why-the-database-is-opened-in-one-place)): only the file that mounts the provider imports the tool.
216
+ - **An agent can do all of it.** Give a coding agent in the app folder the page and one line: "wrap `<package>` as a provider of the `kv` port, pass its suite, and swap it in through the door". `AGENTS.md` in the app names the door.
@@ -149,8 +149,9 @@ For a request that streams its answer and a Stop button (an `AbortController` ke
149
149
  - JSX in a client module always compiles for the DOM renderer (`acrylblends/jsx/dom`), whatever the server's `tsconfig.json` says. A file's
150
150
  own `/** @jsxImportSource ... */` comment still wins.
151
151
  - `acrylblends/jsx/dom`, its `jsx-runtime`, `jsx-dev-runtime` and `client` are one browser module (`@acrylblends/jsx-dom.js`), and
152
- `acrylblends/client` is another (`@acrylblends/client.js`). Neither imports anything. The same app serves them, and each import is
153
- rewritten to a URL relative to the module, so the setup works under whatever path you route `clientModules` at.
152
+ `acrylblends/client` is another (`@acrylblends/client.js`), and `acrylblends/read-sse` (the reader of a POST event stream, see
153
+ [Streaming](../helpers/streaming.md#reading-a-post-event-stream-in-the-browser)) a third (`@acrylblends/read-sse.js`). None imports anything. The same app
154
+ serves them, and each import is rewritten to a URL relative to the module, so the setup works under whatever path you route `clientModules` at.
154
155
  - Import your own modules with their file names, as the browser fetches them: `import { label } from './parts/label.ts'`.
155
156
  - Another package (`import confetti from 'canvas-confetti'`) is left as it is: add an
156
157
  [import map](https://developer.mozilla.org/docs/Web/HTML/Element/script/type/importmap) to the page for it, or use a bundler.
@@ -185,7 +186,7 @@ Compile the client folder once when you build, as the `build` script of your `pa
185
186
  }
186
187
  ```
187
188
 
188
- It writes each module compiled to `dist/client` at the same path and file name, so the page's URLs stay the same, and copies the two
189
+ It writes each module compiled to `dist/client` at the same path and file name, so the page's URLs stay the same, and copies the three
189
190
  browser modules to `dist/client/@acrylblends/`. Run it where `typescript` is installed, before the development dependencies are removed.
190
191
 
191
192
  With `NODE_ENV=production` and the `compiled` option set, `clientModules` serves only that folder and compiles nothing. A module that is in
@@ -7,13 +7,14 @@
7
7
 
8
8
  | Template | What you get |
9
9
  | --- | --- |
10
- | `nodejs` | an Acryl application served with `serve()` from `acrylblends/node-server`; run with Node.js 22.19 or newer |
10
+ | `nodejs` | a web app: routes, a server-rendered JSX page and a client component, mounted by the app's own plugin; run with Node.js 22.19 or newer |
11
11
  | `notes` | a full-stack starter: accounts (scrypt, cookie session), notes in SQLite through the [`db` port](data.md) with numbered migrations, validated forms, search, a JSON API with a typed test client, csrf, a test for every route, a client-component counter |
12
12
  | `chat` | an AI chat starter: the answer streams token by token from an OpenAI-compatible API (DeepSeek by default, key only from `DEEPSEEK_API_KEY`), a client component with a Stop button that also stops the request to the model, history in SQLite through the [`db` port](data.md), tests with a mock model |
13
- | `bun` (experimental) | an Acryl application with `export default app`, run with `bun` |
14
13
  | `acryl-app` | a Cordis app built from plugins, with an agent that can extend it while it runs (the same app `npx acrylblends init --template agent` creates) |
15
14
 
16
- Every template includes `AGENTS.md` and `CLAUDE.md` (which imports `AGENTS.md`) and a copy of these docs in `docs/framework/`, so a coding agent working in the folder reads the
15
+ Every template is a Cordis app: `blend.yaml` lists the plugins that are mounted, every plugin is a folder in `plugins/`, and the app is started by `src/main.ts`. A bare template name after the directory is the same as `--template`: `npm create acrylblends@latest my-app notes`. `bun` is no longer a template; the framework still runs on Bun (see [Bun](../getting-started/bun.md)), but a new app is a Cordis app on Node.
16
+
17
+ Every template includes `AGENTS.md` and `CLAUDE.md` (the same text), one skill (`app-guide`) in `.claude/skills/` and `.agents/skills/`, and a copy of these docs in `docs/framework/`, so a coding agent working in the folder reads the
17
18
  API from the folder.
18
19
 
19
20
  ## Passing arguments
@@ -50,7 +51,7 @@ bun create acrylblends@latest my-app --template nodejs
50
51
  | :-- | :-- | :-- |
51
52
  | `[directory]` | The folder to create. It must not exist or must be empty. Asked for when it is missing. | `my-app` |
52
53
  | `[source]` | A template from outside the package: `github:owner/repo[#ref]`, `@scope/name[@version]` or a packed `.tgz` file. See [Templates from a repository or a package](#templates-from-a-repository-or-a-package). | `github:acme/starter` |
53
- | `--template <template>` | One of `nodejs`, `notes`, `chat`, `bun` (experimental), `acryl-app`. Skips the template question. | `--template bun` |
54
+ | `--template <template>` | One of `nodejs`, `notes`, `chat`, `acryl-app`. Skips the template question; a bare name after the directory means the same. | `--template notes` |
54
55
  | `--install` | Install the dependencies with the package manager after creating the project. For a template from a source, the install scripts it will run are listed first. | `--install` |
55
56
  | `--pm <packageManager>` | The package manager for `--install`: `npm`, `pnpm`, `yarn` or `bun`. Defaults to the one that started the initializer. | `--pm pnpm` |
56
57
  | `--yes`, `-y` | Ask nothing: use `nodejs` unless `--template` is given, and the directory `acryl-app`. | `-y` |
@@ -2,16 +2,17 @@
2
2
 
3
3
  An app that keeps records (users, notes, orders) keeps them in a SQL database, and reaches it through one small interface, `db`. Handlers and
4
4
  stores take a `Db` as an argument and never import a driver, so the file or server behind it can change without touching them. SQLite is the
5
- provider today; a Postgres provider (Postgres or Supabase through `DATABASE_URL`) is planned and serves the same interface.
5
+ provider in `acrylblends`. Postgres (or Supabase, through `DATABASE_URL`) is an optional plugin, `acryl-db-postgres`, that serves the same
6
+ interface; it is not part of `acrylblends` or the templates, and an app adds it only when it must use Postgres.
6
7
 
7
8
  ## Choosing
8
9
 
9
10
  | The app | Use | State |
10
11
  | --- | --- | --- |
11
12
  | Normal web or desktop app, users, lists, orders | `db` with SQLite (the `notes` and `chat` templates) | available |
12
- | Hosted, many users | `db` with Postgres or Supabase | planned |
13
+ | SQLite is not enough, but no server | `db` with the optional `acryl-db-pglite` plugin: Postgres in the app's process, one folder in the app home | optional, not in `acrylblends` |
14
+ | Must use Postgres or Supabase | `db` with the optional `acryl-db-postgres` plugin | optional, not in `acrylblends` |
13
15
  | Tiny, one person, a few screens | [`kv`](#a-tiny-app-kv), named JSON documents with no SQL | available |
14
- | Agents or long tasks that must survive a restart | `durable`, in the same database as `db` | planned |
15
16
 
16
17
  Start with `db` when the data has users, lists or relations: SQL is what people and coding agents already know. Start with `kv` when the whole
17
18
  data is a handful of settings or a small list that one process reads and writes.
@@ -190,7 +191,7 @@ await db.transaction(async (tx) => {
190
191
 
191
192
  ## Writing SQL that moves to Postgres
192
193
 
193
- The Postgres provider is not released yet, and it will serve the same interface. SQL that stays inside this list runs on both:
194
+ The optional Postgres plugins (`acryl-db-pglite` embedded, `acryl-db-postgres` on a server) serve the same interface. SQL that stays inside this list runs on both:
194
195
 
195
196
  - Placeholders are `?` on every provider.
196
197
  - Use `RETURNING` to get a new row, not a driver-specific "last id".
@@ -269,8 +270,8 @@ The rule is checked, not hoped for. The app names its opener once, in `package.j
269
270
  }
270
271
  ```
271
272
 
272
- `acrylblends check-deps` reads every `.ts`, `.tsx` and `.js` file in `src/`, `test/`, `tests/` and `extensions/`, and fails when a file other than
273
- the opener imports a database driver, an opener such as `acrylblends/db/sqlite` or `acryl-db-sqlite`, or (outside `src/plugins/desktop`) `electron`.
273
+ `acrylblends check-deps` reads every `.ts`, `.tsx` and `.js` file in `src/`, `test/`, `tests/` and `plugins/`, and fails when a file other than
274
+ the opener imports a database driver, an opener such as `acrylblends/db/sqlite` or `acryl-db-sqlite`, or (outside `plugins/desktop`) `electron`.
274
275
  It names the file and the line:
275
276
 
276
277
  ```
@@ -86,7 +86,7 @@ app.get('/sse', async (c) => {
86
86
  A reader that closes the tab, loses the network or presses a Stop button should not leave your server working for nobody (a request to a paid model, a database scan). There are two signals, and they are not the same:
87
87
 
88
88
  - **`c.req.raw.signal`** is the request's `AbortSignal`. It aborts when the client goes away. Pass it to anything that takes a `signal`, such as `fetch`, and the work stops with the client. This is the one to use for cancelling work.
89
- - **`stream.onAbort(callback)`** and **`stream.aborted`** belong to the response stream. They report that the *reader of the response* was cancelled. Measured: when you call `app.request()` with a `signal` and abort it, `c.req.raw.signal` aborts and `stream.aborted` stays `false`; when the reader of the response body is cancelled, `stream.onAbort` fires and `c.req.raw.signal` does not. On a real connection the request signal is what fired when a browser aborted its `fetch`. Use `c.req.raw.signal` for the work, and `stream.onAbort` for cleanup of the stream itself.
89
+ - **`stream.onAbort(callback)`** and **`stream.aborted`** belong to the response stream. They report that the *reader of the response* was cancelled, and they are set when the response body is cancelled, which is what the Node adapter does when the connection closes. Over a real connection, a browser that aborts its `fetch` (or closes the tab) fires both: the request signal and `stream.onAbort`. Under `app.request()` (in-process, no socket) they come apart, and this is how the upstream behaves too: aborting the `signal` you passed to `app.request()` fires `c.req.raw.signal` while `stream.aborted` stays `false`, and cancelling the reader of the response body fires `stream.onAbort` while `c.req.raw.signal` does not. A test that simulates the client leaving therefore aborts the request signal (for work cancelled through the signal) or cancels the body reader (for `stream.onAbort`), whichever the handler listens to. Use `c.req.raw.signal` for the work, and `stream.onAbort` for cleanup of the stream itself.
90
90
 
91
91
  An abort event that has already fired does not fire again, so check `signal.aborted` before you wait for it:
92
92
 
@@ -160,31 +160,10 @@ app.post('/api/chat', async (c) => {
160
160
 
161
161
  ## Reading a POST event stream in the browser
162
162
 
163
- `EventSource` can only send a GET request. For a POST (a chat message), read the response body yourself; this function calls `on` for each event as it arrives:
163
+ `EventSource` can only send a GET request. For a POST (a chat message), call `fetch` and read the events with `readSSE` from `acrylblends/read-sse`. It is an async iterator over the events of a `text/event-stream` response, parsed by the rules of the SSE standard (LF, CRLF and CR line ends, several `data` lines joined with a line feed, comments, UTF-8 split across chunks):
164
164
 
165
165
  ```ts
166
- async function readEvents(body: ReadableStream<Uint8Array>, on: (event: string, data: string) => void): Promise<void> {
167
- const reader = body.getReader()
168
- const decoder = new TextDecoder()
169
- let buffer = ''
170
- for (;;) {
171
- const { done, value } = await reader.read()
172
- if (done) return
173
- // Line endings may be CRLF; a \r at the end of a chunk is completed by the next chunk, so normalise the whole buffer.
174
- buffer = (buffer + decoder.decode(value, { stream: true })).replaceAll('\r\n', '\n')
175
- const blocks = buffer.split('\n\n')
176
- buffer = blocks.pop() ?? ''
177
- for (const block of blocks) {
178
- let event = 'message'
179
- const data: string[] = []
180
- for (const line of block.split('\n')) {
181
- if (line.startsWith('event:')) event = line.slice(6).trim()
182
- else if (line.startsWith('data:')) data.push(line.slice(5).replace(/^ /, '')) // the SSE spec strips one leading space only
183
- }
184
- on(event, data.join('\n'))
185
- }
186
- }
187
- }
166
+ import { readSSE } from 'acrylblends/read-sse'
188
167
 
189
168
  const controller = new AbortController() // controller.abort() is the Stop button
190
169
  const response = await fetch('/api/chat', {
@@ -193,13 +172,19 @@ const response = await fetch('/api/chat', {
193
172
  body: JSON.stringify({ message: 'Hello' }),
194
173
  signal: controller.signal,
195
174
  })
175
+
196
176
  let answer = ''
197
- await readEvents(response.body!, (event, data) => {
177
+ for await (const { event, data } of readSSE(response)) {
198
178
  if (event === 'token') answer += JSON.parse(data) as string
199
- })
179
+ }
200
180
  ```
201
181
 
202
- A complete chat component built on this is in the `chat` template of `create-acrylblends` (`npm create acrylblends@latest my-chat -- --template chat`).
182
+ - Each event is `{ event, data, id?, retry? }`; `event` is `message` when the server named none, and `data` is a string (parse it yourself when the server sends JSON).
183
+ - Leaving the loop early (a `break`, a thrown error) cancels the response body, so the request to the server ends. Abort the `fetch` with its own `signal` for a Stop button; `readSSE(response, { signal })` also stops reading when a signal aborts.
184
+ - A response that is not `ok` throws with its status (the body is not parsed as events), and a response without a body throws.
185
+ - A client module served by `clientModules` can import it as it is (`import { readSSE } from 'acrylblends/read-sse'`): the import is pointed at a browser file the same app serves, like `acrylblends/client`. With a bundler it is an ordinary import.
186
+
187
+ A complete chat component is in the `chat` template of `create-acrylblends` (`npm create acrylblends@latest my-chat -- --template chat`).
203
188
 
204
189
  ## Error Handling
205
190
 
@@ -2,8 +2,8 @@
2
2
  "source": {
3
3
  "repo": "acryldev/acryl",
4
4
  "path": "docs/framework",
5
- "commit": "f8e385e4389fe2fdc6fd2a4366a7f2a7389faacb"
5
+ "commit": "20bdc263d6f0f8febbd967007bda4cf4a802efd2"
6
6
  },
7
- "files": 99,
8
- "digest": "sha256:075d537930a53f7ba46b989ff82f9d83a720270c60d848917e6501bfabff6df1"
7
+ "files": 100,
8
+ "digest": "sha256:ab5a055bc0bfb6c47ee6bb70c72ef6ec675ad070d790b58e601cd764591df788"
9
9
  }
@@ -0,0 +1,84 @@
1
+ //#region ../../plugins/acryl-router/src/helpers/read-sse.ts
2
+ /**
3
+ * Read the events of a `text/event-stream` response as they arrive. Leaving the loop early (`break`, a thrown error) cancels the body, so the
4
+ * request to the server ends.
5
+ * @throws when the response is not `ok` (the status is in the message; the body is cancelled, not parsed), has no body, or fails while it is read.
6
+ */
7
+ async function* readSSE(response, options = {}) {
8
+ if (!response.ok) {
9
+ await response.body?.cancel().catch(() => void 0);
10
+ throw new Error(`readSSE: the request failed with ${String(response.status)}${response.statusText === "" ? "" : ` ${response.statusText}`}`);
11
+ }
12
+ if (response.body === null) throw new Error("readSSE: the response has no body to read");
13
+ const { signal } = options;
14
+ if (signal?.aborted === true) {
15
+ await response.body.cancel().catch(() => void 0);
16
+ return;
17
+ }
18
+ const reader = response.body.getReader();
19
+ const onAbort = () => {
20
+ reader.cancel().catch(() => void 0);
21
+ };
22
+ signal?.addEventListener("abort", onAbort, { once: true });
23
+ const decoder = new TextDecoder("utf-8");
24
+ let buffer = "";
25
+ let skipLineFeed = false;
26
+ let name = "";
27
+ let data = [];
28
+ let id;
29
+ let retry;
30
+ /** Apply one line; returns the event when this line completes one. */
31
+ const apply = (line) => {
32
+ if (line === "") {
33
+ const event = data.length === 0 ? void 0 : {
34
+ event: name === "" ? "message" : name,
35
+ data: data.join("\n"),
36
+ ...id === void 0 ? {} : { id },
37
+ ...retry === void 0 ? {} : { retry }
38
+ };
39
+ name = "";
40
+ data = [];
41
+ id = void 0;
42
+ retry = void 0;
43
+ return event;
44
+ }
45
+ if (line.startsWith(":")) return void 0;
46
+ const colon = line.indexOf(":");
47
+ const field = colon === -1 ? line : line.slice(0, colon);
48
+ let value = colon === -1 ? "" : line.slice(colon + 1);
49
+ if (value.startsWith(" ")) value = value.slice(1);
50
+ if (field === "event") name = value;
51
+ else if (field === "data") data.push(value);
52
+ else if (field === "id") {
53
+ if (!value.includes("\0")) id = value;
54
+ } else if (field === "retry" && /^\d+$/u.test(value)) retry = Number(value);
55
+ };
56
+ try {
57
+ for (;;) {
58
+ const { done, value } = await reader.read();
59
+ if (done) break;
60
+ let text = decoder.decode(value, { stream: true });
61
+ if (skipLineFeed && text.startsWith("\n")) text = text.slice(1);
62
+ if (text !== "") skipLineFeed = false;
63
+ buffer += text;
64
+ let start = 0;
65
+ for (let index = 0; index < buffer.length; index += 1) {
66
+ const code = buffer.charCodeAt(index);
67
+ if (code !== 10 && code !== 13) continue;
68
+ const line = buffer.slice(start, index);
69
+ if (code === 13) if (index + 1 < buffer.length) {
70
+ if (buffer.charCodeAt(index + 1) === 10) index += 1;
71
+ } else skipLineFeed = true;
72
+ start = index + 1;
73
+ const event = apply(line);
74
+ if (event !== void 0) yield event;
75
+ }
76
+ buffer = buffer.slice(start);
77
+ }
78
+ } finally {
79
+ signal?.removeEventListener("abort", onAbort);
80
+ await reader.cancel().catch(() => void 0);
81
+ }
82
+ }
83
+ //#endregion
84
+ export { readSSE };
@@ -1,4 +1,4 @@
1
- import { n as DbExecutor, r as SqlValue, t as Db } from "../database-D_UN6We8.js";
1
+ import { c as DbExecutor, l as SqlValue, s as Db } from "../index-D4FAsBZu.js";
2
2
 
3
3
  //#region ../../plugins/acryl-db-sqlite/src/open.d.ts
4
4
  interface OpenDbOptions {
package/lib/db.d.ts CHANGED
@@ -1,85 +1,2 @@
1
- //#region ../../plugins/acryl-db/src/facade/database.d.ts
2
- /** Values supported by the portable SQL drivers. */
3
- type SqlValue = null | number | bigint | string | Uint8Array;
4
- /**
5
- * Asynchronous SQL operations shared by a database and its transaction handles.
6
- *
7
- * `exec` runs SQL text without bindings and may contain several statements. `run`, `get`, and `all`
8
- * execute one statement with positional bindings. Adapters may cache prepared statements by SQL text,
9
- * so callers pass values as bindings instead of interpolating them. `run` resolves the number of rows
10
- * the statement changed.
11
- */
12
- interface DbExecutor {
13
- exec(sql: string): Promise<void>;
14
- run(sql: string, ...params: SqlValue[]): Promise<{
15
- readonly changes: number;
16
- }>;
17
- get<T extends object>(sql: string, ...params: SqlValue[]): Promise<T | undefined>;
18
- all<T extends object>(sql: string, ...params: SqlValue[]): Promise<T[]>;
19
- }
20
- /**
21
- * Minimal portable SQL database facade.
22
- *
23
- * All operations are asynchronous so adapters may execute outside the caller's runtime.
24
- *
25
- * `transaction` passes the callback a transaction handle. All work in the transaction
26
- * must use that handle; the handle is invalid after the callback settles. Adapters must
27
- * queue unrelated operations and other transactions until the transaction finishes. The
28
- * returned promise settles after commit or rollback. Calling the database itself (including
29
- * `transaction`) from inside a callback therefore waits for that transaction and
30
- * never settles.
31
- *
32
- * When the callback rejects, the adapter must roll the transaction back before rejecting
33
- * with that same error. If rollback fails, it must reject with a different error (for
34
- * example an `AggregateError`) so callers cannot mistake the callback error for a
35
- * guaranteed rollback.
36
- *
37
- * Closing is the adapter's: the plugin that opened a database closes it, so the facade an
38
- * app receives has no `close`.
39
- */
40
- interface Db extends DbExecutor {
41
- transaction<T>(callback: (transaction: DbExecutor) => Promise<T>): Promise<T>;
42
- }
43
- //#endregion
44
- //#region ../../plugins/acryl-db/src/facade/migrations.d.ts
45
- type Migration = {
46
- readonly version: number;
47
- readonly statements: readonly string[];
48
- };
49
- /**
50
- * Apply all pending migrations atomically, tracking their version in the named metadata table.
51
- *
52
- * The bookkeeping SQL runs unchanged on SQLite and Postgres: no `STRICT` table, `ON CONFLICT DO NOTHING`
53
- * instead of `INSERT OR IGNORE`, and the version written as a checked integer rather than a binding,
54
- * since the two dialects spell placeholders differently.
55
- */
56
- declare function applyMigrations(database: Db, migrations: readonly Migration[], options: {
57
- readonly table: string;
58
- }): Promise<void>;
59
- //#endregion
60
- //#region ../../plugins/acryl-db/src/facade/schema.d.ts
61
- /** Validate a prefix before using it as part of a SQL identifier. */
62
- declare function validateSchemaPrefix(prefix: string): void;
63
- //#endregion
64
- //#region ../../plugins/acryl-db/src/migration-files.d.ts
65
- /**
66
- * Read every `NNN-name.sql` file of a folder, in number order.
67
- * @throws when a `.sql` file is not named `NNN-name.sql`, or the numbers do not run 1, 2, 3, ... without gaps or repeats.
68
- */
69
- declare function readMigrationFiles(folder: string | URL): Promise<Migration[]>;
70
- //#endregion
71
- //#region ../../plugins/acryl-db/src/index.d.ts
72
- /**
73
- * The `db` port: what an app's code sees of its relational database, whichever provider (`acryl-db-sqlite`, `acryl-db-postgres`) serves
74
- * it. Plain code (an acrylblends app) opens a database with a provider's `openDb`; a Cordis plugin declares `inject: ['db']`, imports
75
- * `acryl-db/cordis` for the `ctx.db` type, and never imports a driver.
76
- *
77
- * Bindings are positional `?` placeholders on every provider; a provider whose database spells them differently translates them.
78
- * This entry imports nothing from Cordis, at runtime or in its types.
79
- */
80
- /** The service name every `db` provider registers. */
81
- declare const DB_SERVICE = "db";
82
- /** The table that records an app's migration version. */
83
- declare const MIGRATIONS_TABLE = "_migrations";
84
- //#endregion
1
+ import { a as Migration, c as DbExecutor, i as validateSchemaPrefix, l as SqlValue, n as MIGRATIONS_TABLE, o as applyMigrations, r as readMigrationFiles, s as Db, t as DB_SERVICE } from "./index-D4FAsBZu.js";
85
2
  export { DB_SERVICE, type Db, type DbExecutor, MIGRATIONS_TABLE, type Migration, type SqlValue, applyMigrations, readMigrationFiles, validateSchemaPrefix };