acrylblends 0.7.8 → 0.8.1

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 (141) hide show
  1. package/dist/index.d.ts +13 -5
  2. package/dist/index.js +72 -13
  3. package/dist/index.js.map +1 -1
  4. package/docs/framework/README.md +2 -2
  5. package/docs/framework/UPSTREAM.md +1 -1
  6. package/docs/framework/getting-started/basic.md +1 -2
  7. package/docs/framework/getting-started/desktop.md +1 -1
  8. package/docs/framework/guides/bring-your-own-tool.md +216 -0
  9. package/docs/framework/guides/create-acrylblends.md +5 -4
  10. package/docs/framework/guides/data.md +2 -1
  11. package/docs/framework/upstream.json +3 -3
  12. package/lib/node-server.js +1 -0
  13. package/package.json +1 -1
  14. package/plugins/acryl-db/plugin.json +6 -1
  15. package/plugins/acryl-db/src/conformance.ts +224 -0
  16. package/plugins/acryl-db/upstream.json +1 -1
  17. package/plugins/acryl-db-sqlite/plugin.json +4 -1
  18. package/plugins/acryl-db-sqlite/upstream.json +1 -1
  19. package/plugins/acryl-door/LICENSE +47 -0
  20. package/plugins/acryl-door/plugin.json +14 -0
  21. package/plugins/acryl-door/src/cli/acryl.ts +207 -0
  22. package/plugins/acryl-door/src/host/api.ts +9 -0
  23. package/plugins/acryl-door/src/host/audit.ts +64 -0
  24. package/plugins/acryl-door/src/host/channel.ts +142 -0
  25. package/plugins/acryl-door/src/host/chat-tools.ts +39 -0
  26. package/plugins/acryl-door/src/host/door-file.ts +94 -0
  27. package/plugins/acryl-door/src/host/door.ts +195 -0
  28. package/plugins/acryl-door/src/host/http.ts +80 -0
  29. package/plugins/acryl-door/src/host/mcp-routes.ts +83 -0
  30. package/plugins/acryl-door/src/host/operator-routes.ts +38 -0
  31. package/plugins/acryl-door/src/host/page-routes.ts +65 -0
  32. package/plugins/acryl-door/src/host/shim.ts +65 -0
  33. package/plugins/acryl-door/src/host/verbs.ts +109 -0
  34. package/plugins/acryl-door/src/index.ts +133 -0
  35. package/plugins/acryl-door/src/page/api.ts +9 -0
  36. package/plugins/acryl-door/src/page/channel.ts +135 -0
  37. package/plugins/acryl-door/src/page/driver/accessibility.ts +163 -0
  38. package/plugins/acryl-door/src/page/driver/actions.ts +186 -0
  39. package/plugins/acryl-door/src/page/driver/driver.ts +153 -0
  40. package/plugins/acryl-door/src/page/driver/ref-table.ts +78 -0
  41. package/plugins/acryl-door/src/page/driver/sensitivity.ts +49 -0
  42. package/plugins/acryl-door/src/page/driver/snapshot.ts +132 -0
  43. package/plugins/acryl-door/src/page/index.ts +40 -0
  44. package/plugins/acryl-door/src/page/indicator.ts +72 -0
  45. package/plugins/acryl-door/src/page/main.ts +7 -0
  46. package/plugins/acryl-door/src/page/protocol.ts +213 -0
  47. package/plugins/acryl-door/src/page/snapshot-text.ts +18 -0
  48. package/plugins/acryl-door/src/page/user-input.ts +24 -0
  49. package/plugins/acryl-door/upstream.json +28 -0
  50. package/plugins/acryl-inspect/upstream.json +1 -1
  51. package/plugins/acryl-kv/plugin.json +6 -1
  52. package/plugins/acryl-kv/src/conformance.ts +226 -0
  53. package/plugins/acryl-kv/upstream.json +1 -1
  54. package/plugins/acryl-kv-db/plugin.json +4 -1
  55. package/plugins/acryl-kv-db/upstream.json +1 -1
  56. package/plugins/acryl-kv-json/plugin.json +4 -1
  57. package/plugins/acryl-kv-json/upstream.json +1 -1
  58. package/plugins/acryl-node-server/src/adapter/listener.ts +5 -0
  59. package/plugins/acryl-node-server/upstream.json +2 -2
  60. package/plugins/acryl-router/src/service.ts +3 -0
  61. package/plugins/acryl-router/upstream.json +2 -2
  62. package/plugins/acryl-router-http/upstream.json +1 -1
  63. package/plugins/acryl-state/upstream.json +1 -1
  64. package/plugins/agent-panel/client/main.ts +159 -0
  65. package/plugins/agent-panel/index.ts +232 -0
  66. package/plugins/agent-panel/plugin.json +21 -0
  67. package/plugins/desktop/upstream.json +1 -1
  68. package/plugins/desktop-web/upstream.json +1 -1
  69. package/plugins/extensions/resolve.ts +16 -2
  70. package/plugins/tsconfig.json +6 -0
  71. package/shared/DEFAULTS.md +11 -6
  72. package/shared/SKILL.md +1 -1
  73. package/templates/agent/blend.yaml +3 -0
  74. package/templates/agent/src/app.ts +18 -2
  75. package/templates/agent/src/routes.ts +14 -1
  76. package/templates/agent/template.json +2 -1
  77. package/templates/agent/test/agent.test.ts +10 -4
  78. package/templates/agent/test/app.test.ts +29 -8
  79. package/templates/agent/test/builder.test.ts +34 -23
  80. package/templates/agent/test/model.test.ts +24 -19
  81. package/templates/blank/src/app.ts +25 -5
  82. package/templates/blank/test/app.test.ts +28 -9
  83. package/templates/chat/AGENTS.md +16 -0
  84. package/templates/chat/blend.yaml +32 -0
  85. package/templates/chat/gitignore +6 -0
  86. package/templates/chat/migrations/001-chat.sql +12 -0
  87. package/templates/chat/package.json +33 -0
  88. package/templates/chat/src/app.ts +107 -0
  89. package/templates/chat/src/chat-app.ts +101 -0
  90. package/templates/chat/src/client/chat.tsx +115 -0
  91. package/templates/chat/src/client/tsconfig.json +26 -0
  92. package/templates/chat/src/db.ts +43 -0
  93. package/templates/chat/src/door.ts +7 -0
  94. package/templates/chat/src/llm.ts +42 -0
  95. package/templates/chat/src/main.ts +28 -0
  96. package/templates/chat/src/page.tsx +40 -0
  97. package/templates/chat/src/routes.ts +15 -0
  98. package/templates/chat/src/runtime.ts +17 -0
  99. package/templates/chat/template.json +12 -0
  100. package/templates/chat/test/chat.test.ts +198 -0
  101. package/templates/chat/test/server.test.ts +78 -0
  102. package/templates/chat/tsconfig.json +20 -0
  103. package/templates/nodejs/AGENTS.md +9 -0
  104. package/templates/nodejs/blend.yaml +29 -0
  105. package/templates/nodejs/gitignore +3 -0
  106. package/templates/nodejs/package.json +30 -0
  107. package/templates/nodejs/src/app.ts +95 -0
  108. package/templates/nodejs/src/client/counter.tsx +12 -0
  109. package/templates/nodejs/src/client/tsconfig.json +26 -0
  110. package/templates/nodejs/src/door.ts +7 -0
  111. package/templates/nodejs/src/main.ts +25 -0
  112. package/templates/nodejs/src/page.tsx +29 -0
  113. package/templates/nodejs/src/routes.ts +40 -0
  114. package/templates/nodejs/template.json +11 -0
  115. package/templates/nodejs/test/app.test.ts +78 -0
  116. package/templates/nodejs/test/server.test.ts +83 -0
  117. package/templates/nodejs/tsconfig.json +20 -0
  118. package/templates/notes/AGENTS.md +26 -0
  119. package/templates/notes/blend.yaml +32 -0
  120. package/templates/notes/gitignore +6 -0
  121. package/templates/notes/migrations/001-notes.sql +18 -0
  122. package/templates/notes/package.json +33 -0
  123. package/templates/notes/src/app.ts +107 -0
  124. package/templates/notes/src/auth.ts +57 -0
  125. package/templates/notes/src/client/counter.tsx +21 -0
  126. package/templates/notes/src/client/tsconfig.json +26 -0
  127. package/templates/notes/src/db.ts +50 -0
  128. package/templates/notes/src/door.ts +7 -0
  129. package/templates/notes/src/main.ts +28 -0
  130. package/templates/notes/src/notes-app.ts +205 -0
  131. package/templates/notes/src/routes.ts +14 -0
  132. package/templates/notes/src/runtime.ts +17 -0
  133. package/templates/notes/src/schema.ts +38 -0
  134. package/templates/notes/src/views.tsx +136 -0
  135. package/templates/notes/template.json +12 -0
  136. package/templates/notes/test/notes.test.ts +280 -0
  137. package/templates/notes/test/server.test.ts +78 -0
  138. package/templates/notes/tsconfig.json +20 -0
  139. package/templates/todo/src/app.ts +27 -7
  140. package/templates/todo/test/todo.test.ts +47 -22
  141. package/templates/todo/upstream.json +1 -1
@@ -0,0 +1,198 @@
1
+ import assert from 'node:assert/strict'
2
+ import { mkdirSync } from 'node:fs'
3
+ import { mkdtemp, rm } from 'node:fs/promises'
4
+ import { join } from 'node:path'
5
+ import { after, test } from 'node:test'
6
+ import { fileURLToPath } from 'node:url'
7
+ import type {} from '../plugins/acryl-db/src/cordis.ts'
8
+ import type { Db } from '../plugins/acryl-db/src/index.ts'
9
+ import { pinnedInstance, withPort } from '../plugins/instance/index.ts'
10
+ import { createApp as boot } from '../src/app.ts'
11
+ import { createApp } from '../src/chat-app.ts'
12
+ import type { Llm } from '../src/llm.ts'
13
+
14
+ /** Throwaway homes and databases live inside the project (`.acrylblends/` is ignored by git), because a sandboxed agent may not write to the system temp folder. */
15
+ const scratch = join(fileURLToPath(new URL('..', import.meta.url)), '.acrylblends', 'test')
16
+ mkdirSync(scratch, { recursive: true })
17
+
18
+ const projectRoot = fileURLToPath(new URL('..', import.meta.url))
19
+
20
+ /**
21
+ * A fresh database file in a throwaway home with the real migrations, opened by the app's own `db` row (the whole Cordis app boots, so the test exercises the same
22
+ * composition as production). Every app is stopped, and its home removed, when the tests end.
23
+ */
24
+ const stops: (() => Promise<void>)[] = []
25
+ after(() => Promise.all(stops.map((stop) => stop())))
26
+ async function booted() {
27
+ const home = await mkdtemp(join(scratch, 'chat-test-'))
28
+ const app = await boot(withPort(pinnedInstance(home, { root: projectRoot }), 0), {
29
+ database: join(home, 'test.sqlite'),
30
+ listen: false,
31
+ })
32
+ stops.push(async () => {
33
+ await app.stop()
34
+ await rm(home, { recursive: true, force: true })
35
+ })
36
+ return app
37
+ }
38
+ async function memoryDb(): Promise<Db> {
39
+ return (await booted()).root.db
40
+ }
41
+
42
+ /** A model that answers with fixed pieces and records what it was asked. */
43
+ function mock(pieces: string[]) {
44
+ const asked: { role: string; content: string }[][] = []
45
+ const llm: Llm = async function* (messages) {
46
+ asked.push(messages)
47
+ for (const piece of pieces) yield piece
48
+ }
49
+ return { llm, asked }
50
+ }
51
+
52
+ const post = (body: unknown): RequestInit => ({
53
+ method: 'POST',
54
+ headers: { 'content-type': 'application/json' },
55
+ body: JSON.stringify(body),
56
+ })
57
+
58
+ /** Parse a Server-Sent Events body into [event, data] pairs. */
59
+ function events(text: string): [string, string][] {
60
+ return text
61
+ .split('\n\n')
62
+ .filter(Boolean)
63
+ .map((block) => {
64
+ const event = /^event: (.*)$/m.exec(block)?.[1] ?? 'message'
65
+ const data = /^data: (.*)$/m.exec(block)?.[1] ?? ''
66
+ return [event, data]
67
+ })
68
+ }
69
+
70
+ test('the answer streams as events: meta, one token per piece, done', async () => {
71
+ const { llm } = mock(['Hel', 'lo ', 'there\nline'])
72
+ const app = createApp(await memoryDb(), llm)
73
+ const res = await app.request('/api/chat', post({ message: 'hi' }))
74
+ assert.equal(res.status, 200)
75
+ assert.match(res.headers.get('content-type') ?? '', /text\/event-stream/)
76
+ const got = events(await res.text())
77
+ assert.deepEqual(
78
+ got.map(([e]) => e),
79
+ ['meta', 'token', 'token', 'token', 'done'],
80
+ )
81
+ assert.equal(
82
+ got
83
+ .filter(([e]) => e === 'token')
84
+ .map(([, d]) => JSON.parse(d) as string)
85
+ .join(''),
86
+ 'Hello there\nline',
87
+ )
88
+ })
89
+
90
+ test('history is stored, sent back to the model, and listed', async () => {
91
+ const { llm, asked } = mock(['ok'])
92
+ const app = createApp(await memoryDb(), llm)
93
+ const first = events(
94
+ await (await app.request('/api/chat', post({ message: 'first question' }))).text(),
95
+ )
96
+ const id = (JSON.parse(first[0]![1]) as { conversation: number }).conversation
97
+ await (await app.request('/api/chat', post({ message: 'second', conversation: id }))).text()
98
+ assert.deepEqual(
99
+ asked[1]?.map((m) => m.content),
100
+ ['first question', 'ok', 'second'],
101
+ )
102
+ const one = (await (await app.request(`/api/conversations/${id}`)).json()) as {
103
+ messages: { role: string }[]
104
+ }
105
+ assert.deepEqual(
106
+ one.messages.map((m) => m.role),
107
+ ['user', 'assistant', 'user', 'assistant'],
108
+ )
109
+ const list = (await (await app.request('/api/conversations')).json()) as {
110
+ conversations: { title: string }[]
111
+ }
112
+ assert.equal(list.conversations[0]?.title, 'first question')
113
+ const page = await (await app.request(`/?c=${id}`)).text()
114
+ assert.match(page, /first question/)
115
+ assert.match(page, /src="\/client\/chat\.tsx"/)
116
+ })
117
+
118
+ test('a bad message answers 400 with a message, an unknown conversation 404, and nothing reaches the model', async () => {
119
+ const { llm, asked } = mock(['x'])
120
+ const app = createApp(await memoryDb(), llm)
121
+ const empty = await app.request('/api/chat', post({ message: ' ' }))
122
+ assert.equal(empty.status, 400)
123
+ assert.match(JSON.stringify(await empty.json()), /Write a message/)
124
+ assert.equal(
125
+ (await app.request('/api/chat', post({ message: 'hi', conversation: 99 }))).status,
126
+ 404,
127
+ )
128
+ assert.equal(asked.length, 0)
129
+ })
130
+
131
+ test('a conversation can be deleted', async () => {
132
+ const app = createApp(await memoryDb(), mock(['a']).llm)
133
+ const first = events(await (await app.request('/api/chat', post({ message: 'hi' }))).text())
134
+ const id = (JSON.parse(first[0]![1]) as { conversation: number }).conversation
135
+ assert.equal((await app.request(`/api/conversations/${id}`, { method: 'DELETE' })).status, 200)
136
+ assert.equal((await app.request(`/api/conversations/${id}`)).status, 404)
137
+ assert.equal((await app.request(`/api/conversations/${id}`, { method: 'DELETE' })).status, 404)
138
+ })
139
+
140
+ test('a failing model sends an error event with a plain message and no detail', async () => {
141
+ const llm: Llm = async function* () {
142
+ yield 'partial '
143
+ throw new Error('upstream secret detail')
144
+ }
145
+ const app = createApp(await memoryDb(), llm)
146
+ const text = await (await app.request('/api/chat', post({ message: 'hi' }))).text()
147
+ assert.doesNotMatch(text, /secret detail/)
148
+ const last = events(text).at(-1)
149
+ assert.equal(last?.[0], 'error')
150
+ assert.match(last?.[1] ?? '', /could not answer/)
151
+ })
152
+
153
+ test('when the client goes away the model call is aborted and what arrived is kept', async () => {
154
+ let sawAbort: (value: boolean) => void = () => {}
155
+ const aborted = new Promise<boolean>((resolve) => {
156
+ sawAbort = resolve
157
+ })
158
+ const llm: Llm = async function* (_messages, signal) {
159
+ yield 'first '
160
+ // The signal may already be aborted by the time the model is asked for more: an abort event that has fired does not fire again.
161
+ if (!signal.aborted)
162
+ await new Promise<void>((resolve) =>
163
+ signal.addEventListener('abort', () => resolve(), { once: true }),
164
+ )
165
+ sawAbort(signal.aborted)
166
+ if (signal.aborted) throw new DOMException('aborted', 'AbortError')
167
+ yield 'never sent'
168
+ }
169
+ const db = await memoryDb()
170
+ const app = createApp(db, llm)
171
+ const controller = new AbortController()
172
+ const res = await app.request('/api/chat', {
173
+ ...post({ message: 'hi' }),
174
+ signal: controller.signal,
175
+ })
176
+ const reader = res.body!.getReader()
177
+ const decoder = new TextDecoder()
178
+ let seen = ''
179
+ while (!seen.includes('first')) seen += decoder.decode((await reader.read()).value)
180
+ controller.abort()
181
+ assert.equal(await aborted, true)
182
+ await new Promise((resolve) => setTimeout(resolve, 50))
183
+ const rows = await db.all<{ role: string; content: string }>(
184
+ 'SELECT role, content FROM messages ORDER BY id',
185
+ )
186
+ assert.deepEqual(
187
+ rows.map((r) => `${r.role}:${r.content}`),
188
+ ['user:hi', 'assistant:first '],
189
+ )
190
+ })
191
+
192
+ test('the chat page loads its client component, served by the running app', async () => {
193
+ const running = await booted()
194
+ const module = await running.root.router.request('/client/chat.tsx')
195
+ assert.equal(module.status, 200)
196
+ assert.match(module.headers.get('content-type') ?? '', /javascript/)
197
+ assert.equal((await running.root.router.request('/client/@acrylblends/jsx-dom.js')).status, 200)
198
+ })
@@ -0,0 +1,78 @@
1
+ import assert from 'node:assert/strict'
2
+ import { spawn } from 'node:child_process'
3
+ import { once } from 'node:events'
4
+ import { mkdirSync, mkdtempSync } from 'node:fs'
5
+ import { createServer } from 'node:net'
6
+ import { join } from 'node:path'
7
+ import { test } from 'node:test'
8
+ import { fileURLToPath } from 'node:url'
9
+
10
+ /** Throwaway homes and databases live inside the project (`.acrylblends/` is ignored by git), because a sandboxed agent may not write to the system temp folder. */
11
+ const scratch = join(fileURLToPath(new URL('..', import.meta.url)), '.acrylblends', 'test')
12
+ mkdirSync(scratch, { recursive: true })
13
+
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
+ 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)))
19
+ })
20
+ const skip = canListen ? false : 'this environment does not allow listening on a port'
21
+ if (!canListen)
22
+ console.log(
23
+ `skipped: the tests that start the real server (${skip}); every other test ran in process`,
24
+ )
25
+
26
+ /** Start `src/main.ts` the way `npm start` does, on a port the system chooses, and read the address it prints. */
27
+ async function start(env: Record<string, string> = {}) {
28
+ const child = spawn(
29
+ process.execPath,
30
+ ['--import', './plugins/acryl-node-server/src/register.ts', 'src/main.ts'],
31
+ {
32
+ env: {
33
+ ...process.env,
34
+ PORT: '0',
35
+ ACRYL_DOOR: 'off',
36
+ ACRYLBLENDS_HOME: mkdtempSync(join(scratch, 'chat-home-')),
37
+ CHAT_DB: join(mkdtempSync(join(scratch, 'chat-')), 'chat.sqlite'),
38
+ ...env,
39
+ },
40
+ stdio: ['ignore', 'pipe', 'inherit'],
41
+ },
42
+ )
43
+ let out = ''
44
+ const address = await new Promise<string>((resolve, reject) => {
45
+ child.once('exit', (code) =>
46
+ reject(new Error(`the server exited with ${code} before it listened`)),
47
+ )
48
+ child.stdout.on('data', (chunk) => {
49
+ out += chunk
50
+ const match = /listening on (http:\/\/\S+)/.exec(out)
51
+ if (match?.[1]) resolve(match[1])
52
+ })
53
+ })
54
+ return { child, address }
55
+ }
56
+
57
+ test('the server listens on 127.0.0.1 unless HOST says otherwise', { skip }, async () => {
58
+ const { child, address } = await start()
59
+ try {
60
+ assert.match(address, /^http:\/\/127\.0\.0\.1:\d+$/)
61
+ } finally {
62
+ child.kill('SIGTERM')
63
+ await once(child, 'exit')
64
+ }
65
+ })
66
+
67
+ for (const signal of ['SIGTERM', 'SIGINT'] as const) {
68
+ test(`${signal} stops the server cleanly and frees the port`, { skip }, async () => {
69
+ const { child, address } = await start()
70
+ assert.equal((await fetch(`${address}/`)).status, 200)
71
+ const started = Date.now()
72
+ child.kill(signal)
73
+ const [code] = await once(child, 'exit')
74
+ assert.equal(code, 0)
75
+ assert.ok(Date.now() - started < 5000, 'the server took too long to stop')
76
+ await assert.rejects(fetch(address), 'the port should be free')
77
+ })
78
+ }
@@ -0,0 +1,20 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2024",
4
+ "lib": ["ES2024", "DOM", "DOM.Iterable"],
5
+ "module": "NodeNext",
6
+ "moduleResolution": "NodeNext",
7
+ "strict": true,
8
+ "noImplicitOverride": true,
9
+ "verbatimModuleSyntax": true,
10
+ "erasableSyntaxOnly": true,
11
+ "allowImportingTsExtensions": true,
12
+ "noEmit": true,
13
+ "skipLibCheck": true,
14
+ "types": ["node"],
15
+ "jsx": "react-jsx",
16
+ "jsxImportSource": "#acryl-jsx"
17
+ },
18
+ "include": ["src", "test", "plugins"],
19
+ "exclude": ["src/client"]
20
+ }
@@ -0,0 +1,9 @@
1
+
2
+ ## This app (nodejs)
3
+
4
+ A web app: routes, a server-rendered JSX page, and a client component, all plugins of a Cordis app. `src/routes.ts` is the app's own plugin: it builds an `Acryl` app
5
+ (`pages`) and mounts it with `ctx.router.route`, and mounts `clientModules` at `/client`. `blend.yaml` lists the plugins that are mounted. `acrylblends add <plugin>` copies
6
+ another library plugin (for example `acryl-state` or `db`) into `plugins/`. Test a route by booting the app (`createApp` in `src/app.ts`, see `test/app.test.ts`) and calling it with `fetch`.
7
+
8
+ - **Client components** (run in the browser) live in `src/client/`, which has its own `tsconfig.json` (DOM types, no Node types); `clientModules` compiles them when the browser asks, and `npm run build` compiles them once into `dist/client` for production (`NODE_ENV=production` serves only that folder). See `src/client/counter.tsx` and `docs/framework/guides/client-modules.md`.
9
+ - The server listens on `127.0.0.1`; `PORT` chooses the port and `HOST=0.0.0.0` is for a container only.
@@ -0,0 +1,29 @@
1
+ # The app as data, in ACRYL's Blend format (blends.acryl.dev/v1alpha1): which plugins it mounts, in what order, with what config.
2
+ # Edit this, not app.ts, to switch a plugin off (disabled: true) or change its config. `npm run lock` records what the app is built from.
3
+ apiVersion: blends.acryl.dev/v1alpha1
4
+ kind: Blueprint
5
+ metadata:
6
+ id: acrylblends.__APP_NAME__
7
+ name: __APP_NAME__
8
+ version: 0.1.0
9
+ visibility: private # `npm run save` never pushes a private app to a public remote
10
+ spec:
11
+ runtime: cordis
12
+ parameters:
13
+ # The address the server listens on.
14
+ hostname: { type: string, default: 127.0.0.1 }
15
+ rows:
16
+ - id: router
17
+ name: acryl-router
18
+ - id: routes
19
+ name: routes
20
+ # Plugins installed while the app runs (the door's `plugin add`): development only, src/app.ts leaves it out when NODE_ENV=production.
21
+ - id: extensions
22
+ name: extensions
23
+ - id: server
24
+ name: acryl-node-server
25
+ config:
26
+ hostname: "{{parameters.hostname}}"
27
+ # 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.
28
+ - id: acryl-door
29
+ name: acryl-door
@@ -0,0 +1,3 @@
1
+ node_modules/
2
+ .acrylblends/
3
+ .acryl/
@@ -0,0 +1,30 @@
1
+ {
2
+ "name": "__APP_NAME__",
3
+ "version": "0.1.0",
4
+ "private": true,
5
+ "type": "module",
6
+ "imports": {
7
+ "#acryl-jsx/jsx-runtime": "./plugins/acryl-router/src/core/jsx/jsx-runtime.ts",
8
+ "#acryl-jsx/jsx-dev-runtime": "./plugins/acryl-router/src/core/jsx/jsx-dev-runtime.ts"
9
+ },
10
+ "engines": {
11
+ "node": ">=22.19.0"
12
+ },
13
+ "scripts": {
14
+ "start": "node --import ./plugins/acryl-node-server/src/register.ts src/main.ts",
15
+ "dev": "node --watch --import ./plugins/acryl-node-server/src/register.ts src/main.ts",
16
+ "test": "acrylblends check-deps && node --import ./plugins/acryl-node-server/src/register.ts --test \"test/**/*.test.{ts,tsx}\"",
17
+ "check": "tsc --noEmit && tsc --noEmit -p src/client && acrylblends check-deps",
18
+ "build": "acrylblends build-client",
19
+ "lock": "node plugins/manifest/lock.ts",
20
+ "save": "node plugins/save/cli.ts",
21
+ "upgrade": "node plugins/manifest/upgrade-cli.ts"
22
+ },
23
+ "dependencies": {
24
+ "acrylblends": "__ACRYLBLENDS_RANGE__",
25
+ "typescript": "6.0.3"
26
+ },
27
+ "devDependencies": {
28
+ "@types/node": "^24.0.0"
29
+ }
30
+ }
@@ -0,0 +1,95 @@
1
+ import { join } from 'node:path'
2
+ import { fileURLToPath } from 'node:url'
3
+ import { Context, type Fiber } from '@deepseek-ai/cordis'
4
+ import * as door from '../plugins/acryl-door/src/index.ts'
5
+ import * as nodeServer from '../plugins/acryl-node-server/src/index.ts'
6
+ import * as router from '../plugins/acryl-router/src/index.ts'
7
+ import { extensions } from '../plugins/extensions/index.ts'
8
+ import { type AppInstance, instance } from '../plugins/instance/index.ts'
9
+ import {
10
+ blueprintsIn,
11
+ compose,
12
+ loadManifest,
13
+ type ParameterValue,
14
+ } from '../plugins/manifest/index.ts'
15
+ import { doorEnabled } from './door.ts'
16
+ import { routes } from './routes.ts'
17
+
18
+ /** The plugins this app's code provides. `blend.yaml` chooses, orders and configures them. */
19
+ export const registry = {
20
+ 'acryl-router': router,
21
+ 'acryl-node-server': nodeServer,
22
+ 'acryl-door': door,
23
+ extensions,
24
+ routes,
25
+ }
26
+
27
+ const projectRoot = fileURLToPath(new URL('..', import.meta.url))
28
+
29
+ /** The manifest next to this app's source. */
30
+ export const MANIFEST = join(projectRoot, 'blend.yaml')
31
+
32
+ /**
33
+ * The app's manifest, resolved. A Blend finds its Blueprint in `blueprints/<id>.yaml`; an app that is itself a Blueprint never looks.
34
+ * `createApp` and the tests read it the same way.
35
+ */
36
+ export function readManifest(values?: Readonly<Record<string, ParameterValue>>) {
37
+ return loadManifest(MANIFEST, {
38
+ known: Object.keys(registry),
39
+ getBlueprint: blueprintsIn(join(projectRoot, 'blueprints')),
40
+ ...(values ? { values } : {}),
41
+ })
42
+ }
43
+
44
+ /** 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. */
45
+ function without<M extends { plugins: readonly { use: string }[] }>(
46
+ manifest: M,
47
+ ...uses: string[]
48
+ ): M {
49
+ return {
50
+ ...manifest,
51
+ plugins: manifest.plugins.map((row) =>
52
+ uses.includes(row.use) ? { ...row, disabled: true } : row,
53
+ ),
54
+ }
55
+ }
56
+
57
+ export interface App {
58
+ readonly root: Context
59
+ /** The address the server listens on; empty when the app was made with `listen: false`. */
60
+ readonly url: string
61
+ stop(): Promise<void>
62
+ }
63
+
64
+ /** The app: `instance` first (where it lives, chosen at run time), then the manifest's rows in order. */
65
+ export async function createApp(
66
+ chosen: AppInstance,
67
+ options: { values?: Readonly<Record<string, ParameterValue>>; listen?: boolean } = {},
68
+ ): Promise<App> {
69
+ const read = readManifest(options.values)
70
+ const listen = options.listen
71
+ // Rows left out: the server (and the door, which needs it) when the app is reached in process; the door when it is switched off; live plugin installs in production.
72
+ const leaveOut = new Set<string>()
73
+ if (listen === false) leaveOut.add('acryl-node-server').add('acryl-door')
74
+ if (!doorEnabled()) leaveOut.add('acryl-door')
75
+ if (process.env.NODE_ENV === 'production') leaveOut.add('extensions')
76
+ const manifest = without(read, ...leaveOut)
77
+ const root = new Context()
78
+ const first = root.plugin(instance, chosen)
79
+ await first.await()
80
+ const fibers: Fiber[] = [
81
+ first,
82
+ ...(await compose(root, manifest, registry, {
83
+ // What is installed while the app runs lives in plugins/, next to the plugins copied from the library (plugins/library.json lists those, so they are not installed twice).
84
+ extensions: { sources: [{ dir: join(projectRoot, 'plugins'), trust: 'install' }] },
85
+ server: { port: chosen.port.start, scan: chosen.port.scan ? 20 : 0 },
86
+ })),
87
+ ]
88
+ return {
89
+ root,
90
+ url: listen === false ? '' : root.nodeServer.url,
91
+ async stop() {
92
+ for (const fiber of [...fibers].reverse()) await fiber.dispose()
93
+ },
94
+ }
95
+ }
@@ -0,0 +1,12 @@
1
+ import { render, useState } from 'acrylblends/jsx/dom'
2
+
3
+ const Counter = () => {
4
+ const [count, setCount] = useState(0)
5
+ return (
6
+ <button type="button" onClick={() => setCount(count + 1)}>
7
+ Count {count}
8
+ </button>
9
+ )
10
+ }
11
+
12
+ render(<Counter />, document.getElementById('counter')!)
@@ -0,0 +1,26 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2024",
4
+ "lib": ["ES2024", "DOM", "DOM.Iterable"],
5
+ "module": "NodeNext",
6
+ "moduleResolution": "NodeNext",
7
+ "strict": true,
8
+ "noEmit": true,
9
+ "allowImportingTsExtensions": true,
10
+ "jsx": "react-jsx",
11
+ "jsxImportSource": "acrylblends/jsx/dom",
12
+ "types": [],
13
+ "paths": {
14
+ "acrylblends/jsx/dom": ["../../plugins/acryl-router/src/browser/jsx-dom.ts"],
15
+ "acrylblends/jsx/dom/jsx-runtime": [
16
+ "../../plugins/acryl-router/src/core/jsx/dom/jsx-runtime.ts"
17
+ ],
18
+ "acrylblends/jsx/dom/jsx-dev-runtime": [
19
+ "../../plugins/acryl-router/src/core/jsx/dom/jsx-dev-runtime.ts"
20
+ ],
21
+ "acrylblends/jsx/dom/client": ["../../plugins/acryl-router/src/browser/jsx-dom.ts"],
22
+ "acrylblends/client": ["../../plugins/acryl-router/src/browser/client.ts"]
23
+ }
24
+ },
25
+ "include": ["**/*.ts", "**/*.tsx"]
26
+ }
@@ -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 pages leave 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,25 @@
1
+ import { fileURLToPath } from 'node:url'
2
+ import { selectInstance } from '../plugins/instance/index.ts'
3
+ import { createApp } from './app.ts'
4
+ import { APP_NAME } from './routes.ts'
5
+
6
+ // The composition root: the one place that reads the environment (through selectInstance) and chooses where the app lives.
7
+ const chosen = selectInstance({
8
+ projectRoot: fileURLToPath(new URL('..', import.meta.url)),
9
+ // PORT and HOST are the names hosting platforms set; ACRYLBLENDS_PORT and ACRYLBLENDS_HOST win when both are given.
10
+ env: {
11
+ ...(process.env.PORT ? { ACRYLBLENDS_PORT: process.env.PORT } : {}),
12
+ ...(process.env.HOST ? { ACRYLBLENDS_HOST: process.env.HOST } : {}),
13
+ ...process.env,
14
+ },
15
+ })
16
+ const app = await createApp(chosen)
17
+ console.log(`${APP_NAME} listening on ${app.url} (data in ${chosen.home})`)
18
+
19
+ const stop = async () => {
20
+ await app.stop()
21
+ console.log('stopped')
22
+ process.exit(0)
23
+ }
24
+ process.once('SIGINT', stop)
25
+ process.once('SIGTERM', stop)
@@ -0,0 +1,29 @@
1
+ import { doorEnabled } from './door.ts'
2
+
3
+ /** A page written in JSX: Node.js runs it through plugins/acryl-node-server/src/register.ts (see the scripts in package.json), which compiles .tsx with this app's own typescript. */
4
+ const About = () => (
5
+ <main>
6
+ <h1>About</h1>
7
+ <p>Rendered on the server with the JSX runtime in plugins/acryl-router.</p>
8
+ </main>
9
+ )
10
+
11
+ export const aboutPage = (): string => (<About />).toString()
12
+
13
+ /** Throws on purpose: the template's test checks that the stack names this file, so a failure in your own pages points at the .tsx line. */
14
+ export const failingPage = (): string => {
15
+ throw new Error('a page failed')
16
+ }
17
+
18
+ /** A page with one client component: `src/client/counter.tsx` runs in the browser, compiled on request by `clientModules` (see src/routes.ts). */
19
+ export const counterPage = (): string =>
20
+ (
21
+ <html lang="en">
22
+ <body>
23
+ <h1>Counter</h1>
24
+ <div id="counter" />
25
+ <script type="module" src="/client/counter.tsx" />
26
+ {doorEnabled() ? <script type="module" src="/api/acryl-door/page.js" /> : null}
27
+ </body>
28
+ </html>
29
+ ).toString()
@@ -0,0 +1,40 @@
1
+ import { createRequire } from 'node:module'
2
+ import { dirname, join } from 'node:path'
3
+ import type { Context } from '@deepseek-ai/cordis'
4
+ import { clientModules } from '../plugins/acryl-node-server/src/client-modules.ts'
5
+ import { Acryl } from '../plugins/acryl-router/src/index.ts'
6
+ import { aboutPage, counterPage } from './page.tsx'
7
+
8
+ /** The browser builds of the DOM renderer and the typed client, shipped by the `acrylblends` package this app depends on. */
9
+ const runtimeDir = join(
10
+ dirname(createRequire(import.meta.url).resolve('acrylblends/package.json')),
11
+ 'lib',
12
+ 'browser',
13
+ )
14
+
15
+ export const APP_NAME = '__APP_NAME__'
16
+
17
+ /** The routes, in a file that does not import the Node adapter's server, so browser code can import the type: `import type { AppType } from '../routes.ts'`. */
18
+ export const pages = new Acryl()
19
+ .get('/', (c) => c.json({ app: APP_NAME, ok: true }))
20
+ .get('/health', (c) => c.json({ ok: true }))
21
+ .get('/about', (c) => c.html(aboutPage()))
22
+ .get('/counter', (c) => c.html(counterPage()))
23
+
24
+ export type AppType = typeof pages
25
+
26
+ /**
27
+ * The app's own plugin: the routes above, and the client components without a bundler (`src/client` is compiled when the browser asks; `npm run build`
28
+ * compiles it once for production, and `NODE_ENV=production` then serves only `dist/client`). The mounts are removed when the plugin is disposed.
29
+ */
30
+ export const routes = {
31
+ name: 'routes',
32
+ inject: ['router'],
33
+ apply(ctx: Context) {
34
+ ctx.router.route('/', pages)
35
+ ctx.router.route(
36
+ '/client',
37
+ clientModules({ root: 'src/client', compiled: 'dist/client', runtimeDir }),
38
+ )
39
+ },
40
+ }
@@ -0,0 +1,11 @@
1
+ {
2
+ "plugins": [
3
+ "instance",
4
+ "acryl-router",
5
+ "acryl-node-server",
6
+ "manifest",
7
+ "save",
8
+ "extensions",
9
+ "acryl-door"
10
+ ]
11
+ }