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
@@ -1,19 +1,26 @@
1
1
  import assert from 'node:assert/strict'
2
+ import { mkdirSync } from 'node:fs'
2
3
  import { mkdtemp, rm } from 'node:fs/promises'
3
- import { tmpdir } from 'node:os'
4
+ import { createServer } from 'node:net'
4
5
  import { join } from 'node:path'
5
6
  import { test } from 'node:test'
6
7
  import { fileURLToPath } from 'node:url'
8
+ import { pinnedInstance, withPort } from '../plugins/instance/index.ts'
7
9
  import { type App, createApp } from '../src/app.ts'
8
- import { pinnedInstance, withPort } from '../src/plugins/instance/index.ts'
9
10
  import { APP_NAME } from '../src/routes.ts'
10
11
 
12
+ /** Throwaway homes live inside the project (`.acrylblends/` is ignored by git): a sandboxed agent may not write to the system temp folder. */
13
+ const scratch = join(fileURLToPath(new URL('..', import.meta.url)), '.acrylblends', 'test')
14
+ mkdirSync(scratch, { recursive: true })
15
+
11
16
  const projectRoot = fileURLToPath(new URL('..', import.meta.url))
12
17
 
13
18
  /** Boot the app in a throwaway home on any free port, run the test, always stop it and remove the home. */
14
19
  async function withApp(run: (app: App) => Promise<void>) {
15
- const home = await mkdtemp(join(tmpdir(), 'app-test-'))
16
- const app = await createApp(withPort(pinnedInstance(home, { root: projectRoot }), 0))
20
+ const home = await mkdtemp(join(scratch, 'app-test-'))
21
+ const app = await createApp(withPort(pinnedInstance(home, { root: projectRoot }), 0), {
22
+ listen: false,
23
+ })
17
24
  try {
18
25
  await run(app)
19
26
  } finally {
@@ -24,21 +31,35 @@ async function withApp(run: (app: App) => Promise<void>) {
24
31
 
25
32
  test('serves the index route', () =>
26
33
  withApp(async (app) => {
27
- const response = await fetch(`${app.url}/`)
34
+ const response = await app.root.router.request(`/`)
28
35
  assert.equal(response.status, 200)
29
36
  assert.deepEqual(await response.json(), { app: APP_NAME, ok: true })
30
37
  }))
31
38
 
32
39
  test('answers health checks and 404 for unknown paths', () =>
33
40
  withApp(async (app) => {
34
- assert.equal((await fetch(`${app.url}/health`)).status, 200)
35
- const missing = await fetch(`${app.url}/nope`)
41
+ assert.equal((await app.root.router.request(`/health`)).status, 200)
42
+ const missing = await app.root.router.request(`/nope`)
36
43
  assert.equal(missing.status, 404)
37
44
  await missing.body?.cancel()
38
45
  }))
39
46
 
40
- test('stops listening when stopped', async () => {
41
- const home = await mkdtemp(join(tmpdir(), 'app-test-'))
47
+ /** A sandbox may forbid listening on a port (Codex's workspace-write does); the one test that starts the real server is skipped there, and says so. */
48
+ const canListen = await new Promise<boolean>((done) => {
49
+ const probe = createServer()
50
+ probe.once('error', () => done(false))
51
+ probe.listen(0, '127.0.0.1', () => probe.close(() => done(true)))
52
+ })
53
+ const skip = canListen ? false : 'this environment does not allow listening on a port'
54
+ if (!canListen)
55
+ console.log(
56
+ `skipped: the test that starts the real server (${skip}); every other test ran in process`,
57
+ )
58
+
59
+ test('stops listening when stopped', { skip }, async () => {
60
+ const home = await mkdtemp(join(scratch, 'app-test-'))
61
+ // The door is left out (ACRYL_DOOR=off): one process at a time serves a folder's door.
62
+ process.env.ACRYL_DOOR = 'off'
42
63
  const app = await createApp(withPort(pinnedInstance(home, { root: projectRoot }), 0))
43
64
  await app.stop()
44
65
  await assert.rejects(fetch(`${app.url}/health`))
@@ -1,18 +1,23 @@
1
1
  import assert from 'node:assert/strict'
2
+ import { mkdirSync } from 'node:fs'
2
3
  import { mkdtemp, rm } from 'node:fs/promises'
3
- import { tmpdir } from 'node:os'
4
4
  import { join } from 'node:path'
5
5
  import { test } from 'node:test'
6
+ import { fileURLToPath } from 'node:url'
7
+ import { pinnedInstance, withPort } from '../plugins/instance/index.ts'
6
8
  import { createApp } from '../src/app.ts'
7
- import { pinnedInstance, withPort } from '../src/plugins/instance/index.ts'
9
+
10
+ /** Throwaway homes live inside the project (`.acrylblends/` is ignored by git): 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 })
8
13
 
9
14
  /** The agent extends the running app through POST /ask; a restart keeps what it built. */
10
15
  test('the agent builds, updates, protects and removes a plugin in the running app, and a restart keeps it', async () => {
11
- const project = await mkdtemp(join(tmpdir(), 'builder-project-'))
16
+ const project = await mkdtemp(join(scratch, 'builder-project-'))
12
17
  const chosen = withPort(pinnedInstance(join(project, '.acrylblends'), { root: project }), 0)
13
- let app = await createApp(chosen)
18
+ let app = await createApp(chosen, { listen: false })
14
19
  const ask = async (question: string) => {
15
- const response = await fetch(`${app.url}/ask`, {
20
+ const response = await app.root.router.request(`/ask`, {
16
21
  method: 'POST',
17
22
  headers: { 'content-type': 'application/json' },
18
23
  body: JSON.stringify({ question }),
@@ -20,7 +25,7 @@ test('the agent builds, updates, protects and removes a plugin in the running ap
20
25
  return ((await response.json()) as { output: string }).output
21
26
  }
22
27
  const hello = async () => {
23
- const response = await fetch(`${app.url}/hello`)
28
+ const response = await app.root.router.request(`/hello`)
24
29
  return response.status === 200
25
30
  ? ((await response.json()) as { text: string }).text
26
31
  : response.status
@@ -36,14 +41,14 @@ test('the agent builds, updates, protects and removes a plugin in the running ap
36
41
  // The broken version stays on disk, so put the working one back before restarting.
37
42
  await ask('update route hello /hello Bonjour')
38
43
  await app.stop()
39
- app = await createApp(chosen)
44
+ app = await createApp(chosen, { listen: false })
40
45
  assert.equal(await hello(), 'Bonjour')
41
46
 
42
47
  assert.equal(await ask('remove plugin hello'), 'hello: done.')
43
48
  assert.equal(await hello(), 404)
44
49
 
45
50
  // Every run is a durable task that owns its tool calls, and the records survived the restart.
46
- const records = (await (await fetch(`${app.url}/tasks`)).json()) as Array<{
51
+ const records = (await (await app.root.router.request(`/tasks`)).json()) as Array<{
47
52
  kind: string
48
53
  status: string
49
54
  parent?: string
@@ -63,12 +68,12 @@ test('the agent builds, updates, protects and removes a plugin in the running ap
63
68
  })
64
69
 
65
70
  test('with approval on, an install waits for the owner; approving it makes it live', async () => {
66
- const project = await mkdtemp(join(tmpdir(), 'approval-project-'))
71
+ const project = await mkdtemp(join(scratch, 'approval-project-'))
67
72
  const chosen = withPort(pinnedInstance(join(project, '.acrylblends'), { root: project }), 0)
68
- const app = await createApp(chosen, { values: { approval: true } })
73
+ const app = await createApp(chosen, { values: { approval: true }, listen: false })
69
74
  const post = async (path: string, body: unknown) =>
70
75
  (await (
71
- await fetch(`${app.url}${path}`, {
76
+ await app.root.router.request(`${path}`, {
72
77
  method: 'POST',
73
78
  headers: { 'content-type': 'application/json' },
74
79
  body: JSON.stringify(body),
@@ -79,8 +84,8 @@ test('with approval on, an install waits for the owner; approving it makes it li
79
84
  (await post('/ask', { question: 'create route hello /hello Hello' })).output,
80
85
  "hello: waiting for the owner's approval.",
81
86
  )
82
- assert.equal((await fetch(`${app.url}/hello`)).status, 404)
83
- const listed = (await (await fetch(`${app.url}/extensions`)).json()) as {
87
+ assert.equal((await app.root.router.request(`/hello`)).status, 404)
88
+ const listed = (await (await app.root.router.request(`/extensions`)).json()) as {
84
89
  waitingForApproval: Array<{ name: string }>
85
90
  }
86
91
  assert.deepEqual(
@@ -91,8 +96,10 @@ test('with approval on, an install waits for the owner; approving it makes it li
91
96
  ((await post('/extensions/approve', { name: 'hello' })) as { state?: string }).state,
92
97
  'active',
93
98
  )
94
- assert.deepEqual(await (await fetch(`${app.url}/hello`)).json(), { text: 'Hello' })
95
- const tasks = (await (await fetch(`${app.url}/tasks`)).json()) as Array<{ manifest?: string }>
99
+ assert.deepEqual(await (await app.root.router.request(`/hello`)).json(), { text: 'Hello' })
100
+ const tasks = (await (await app.root.router.request(`/tasks`)).json()) as Array<{
101
+ manifest?: string
102
+ }>
96
103
  assert.ok(
97
104
  tasks.length > 0 && tasks.every((task) => /^sha256:[0-9a-f]{64}$/.test(task.manifest ?? '')),
98
105
  )
@@ -103,13 +110,13 @@ test('with approval on, an install waits for the owner; approving it makes it li
103
110
  })
104
111
 
105
112
  test('the agent builds a sandboxed tool, calls it, and the sandbox refuses a tool that reads outside its folder', async () => {
106
- const project = await mkdtemp(join(tmpdir(), 'tool-project-'))
113
+ const project = await mkdtemp(join(scratch, 'tool-project-'))
107
114
  const chosen = withPort(pinnedInstance(join(project, '.acrylblends'), { root: project }), 0)
108
- const app = await createApp(chosen)
115
+ const app = await createApp(chosen, { listen: false })
109
116
  const ask = async (question: string) =>
110
117
  (
111
118
  (await (
112
- await fetch(`${app.url}/ask`, {
119
+ await app.root.router.request(`/ask`, {
113
120
  method: 'POST',
114
121
  headers: { 'content-type': 'application/json' },
115
122
  body: JSON.stringify({ question }),
@@ -121,7 +128,7 @@ test('the agent builds a sandboxed tool, calls it, and the sandbox refuses a too
121
128
  assert.equal(await ask('run tool triple 14'), '{"n":42}')
122
129
  const refused = await ask('spy tool snoop')
123
130
  assert.match(refused, /^Refused: its check failed: denied:/)
124
- const listed = (await (await fetch(`${app.url}/extensions`)).json()) as {
131
+ const listed = (await (await app.root.router.request(`/extensions`)).json()) as {
125
132
  installed: Array<{ name: string }>
126
133
  }
127
134
  assert.deepEqual(
@@ -135,13 +142,13 @@ test('the agent builds a sandboxed tool, calls it, and the sandbox refuses a too
135
142
  })
136
143
 
137
144
  test('what happens is streamed from the state cells: the run as a task, and the install as an extension', async () => {
138
- const project = await mkdtemp(join(tmpdir(), 'events-project-'))
145
+ const project = await mkdtemp(join(scratch, 'events-project-'))
139
146
  const chosen = withPort(pinnedInstance(join(project, '.acrylblends'), { root: project }), 0)
140
- const app = await createApp(chosen)
147
+ const app = await createApp(chosen, { listen: false })
141
148
  const controller = new AbortController()
142
149
  /** Follow one server-sent stream: its text so far. */
143
150
  const follow = async (path: string) => {
144
- const stream = await fetch(`${app.url}${path}`, { signal: controller.signal })
151
+ const stream = await app.root.router.request(`${path}`, { signal: controller.signal })
145
152
  assert.equal(stream.status, 200, path)
146
153
  const reader = stream.body!.getReader()
147
154
  const decoder = new TextDecoder()
@@ -153,12 +160,12 @@ test('what happens is streamed from the state cells: the run as a task, and the
153
160
  seen.text += decoder.decode(value, { stream: true })
154
161
  }
155
162
  })().catch(() => undefined)
156
- return { seen, pump }
163
+ return { seen, pump, reader }
157
164
  }
158
165
  try {
159
166
  const tasksStream = await follow('/api/state/tasks/events')
160
167
  const extensionsStream = await follow('/api/state/extensions/events')
161
- await fetch(`${app.url}/ask`, {
168
+ await app.root.router.request(`/ask`, {
162
169
  method: 'POST',
163
170
  headers: { 'content-type': 'application/json' },
164
171
  body: JSON.stringify({ question: 'create route hello /hello Hello' }),
@@ -185,7 +192,11 @@ test('what happens is streamed from the state cells: the run as a task, and the
185
192
  arrived(extensionsStream.seen, /"name":"hello"[^\n]*"state":"active"/),
186
193
  'the extension is reported',
187
194
  )
195
+ // In process there is no socket to close: the readers are cancelled, which ends the streams and lets the pumps finish.
188
196
  controller.abort()
197
+ await Promise.all([tasksStream.reader.cancel(), extensionsStream.reader.cancel()]).catch(
198
+ () => undefined,
199
+ )
189
200
  await Promise.all([tasksStream.pump, extensionsStream.pump])
190
201
  } finally {
191
202
  await app.stop()
@@ -1,16 +1,20 @@
1
1
  import assert from 'node:assert/strict'
2
+ import { mkdirSync } from 'node:fs'
2
3
  import { mkdtemp, rm } from 'node:fs/promises'
3
- import { createServer } from 'node:http'
4
- import type { AddressInfo } from 'node:net'
5
- import { tmpdir } from 'node:os'
6
4
  import { join } from 'node:path'
7
5
  import { test } from 'node:test'
6
+ import { fileURLToPath } from 'node:url'
7
+ import { pinnedInstance, withPort } from '../plugins/instance/index.ts'
8
8
  import { createApp } from '../src/app.ts'
9
- import { pinnedInstance, withPort } from '../src/plugins/instance/index.ts'
9
+
10
+ /** Throwaway homes live inside the project (`.acrylblends/` is ignored by git): 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 })
10
13
 
11
14
  /** A plugin no scripted command knows: the model writes it from scratch. */
12
15
  const COUNTER = `import type { Context } from '@deepseek-ai/cordis'
13
16
  import { Acryl } from 'acrylblends'
17
+
14
18
  export const name = 'counter'
15
19
  export const inject = ['router']
16
20
  export function apply(ctx: Context) {
@@ -45,42 +49,43 @@ async function modelApi() {
45
49
  },
46
50
  { content: 'I built a counter: GET /count.' },
47
51
  ]
48
- const server = createServer(async (request, response) => {
49
- for await (const _ of request) {
50
- // the body is not needed: the replies are in order
51
- }
52
- response.writeHead(200, { 'content-type': 'application/json' })
53
- response.end(
54
- JSON.stringify({ choices: [{ message: replies.shift() ?? { content: 'no more' } }] }),
55
- )
56
- })
57
- await new Promise<void>((done) => server.listen(0, '127.0.0.1', done))
52
+ // The model API is answered in process: the global fetch is wrapped for the model's address, so no port is needed.
53
+ const realFetch = globalThis.fetch
54
+ const url = 'http://model.test/v1'
55
+ globalThis.fetch = async (input, init) => {
56
+ const target = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url
57
+ if (!target.startsWith(url)) return realFetch(input, init)
58
+ return Response.json({ choices: [{ message: replies.shift() ?? { content: 'no more' } }] })
59
+ }
58
60
  return {
59
- url: `http://127.0.0.1:${(server.address() as AddressInfo).port}/v1`,
60
- close: () => new Promise<void>((done) => server.close(() => done())),
61
+ url,
62
+ close: async () => {
63
+ globalThis.fetch = realFetch
64
+ },
61
65
  }
62
66
  }
63
67
 
64
68
  test('with a real model, the agent writes a new Cordis plugin and the running app serves it', async () => {
65
69
  const api = await modelApi()
66
- const project = await mkdtemp(join(tmpdir(), 'model-project-'))
70
+ const project = await mkdtemp(join(scratch, 'model-project-'))
67
71
  const app = await createApp(
68
72
  withPort(pinnedInstance(join(project, '.acrylblends'), { root: project }), 0),
69
73
  {
70
74
  model: { baseUrl: api.url, model: 'test' },
75
+ listen: false,
71
76
  },
72
77
  )
73
78
  try {
74
79
  const answer = await (
75
- await fetch(`${app.url}/ask`, {
80
+ await app.root.router.request(`/ask`, {
76
81
  method: 'POST',
77
82
  headers: { 'content-type': 'application/json' },
78
83
  body: JSON.stringify({ question: 'add a counter route' }),
79
84
  })
80
85
  ).json()
81
86
  assert.equal((answer as { output: string }).output, 'I built a counter: GET /count.')
82
- assert.deepEqual(await (await fetch(`${app.url}/count`)).json(), { count: 1 })
83
- assert.deepEqual(await (await fetch(`${app.url}/count`)).json(), { count: 2 })
87
+ assert.deepEqual(await (await app.root.router.request(`/count`)).json(), { count: 1 })
88
+ assert.deepEqual(await (await app.root.router.request(`/count`)).json(), { count: 2 })
84
89
  } finally {
85
90
  await app.stop()
86
91
  await api.close()
@@ -13,58 +13,56 @@
13
13
  "skipLibCheck": true,
14
14
  "types": ["node"],
15
15
  "paths": {
16
- "acrylblends": ["./src/plugins/acryl-router/src/index.ts"],
17
- "acrylblends/conninfo": ["./src/plugins/acryl-router/src/index.ts"],
18
- "acrylblends/http-exception": ["./src/plugins/acryl-router/src/index.ts"],
19
- "acrylblends/types": ["./src/plugins/acryl-router/src/index.ts"],
20
- "acrylblends/client": ["./src/plugins/acryl-router/src/client.ts"],
21
- "acrylblends/basic-auth": ["./src/plugins/acryl-router/src/middleware/index.ts"],
22
- "acrylblends/bearer-auth": ["./src/plugins/acryl-router/src/middleware/index.ts"],
23
- "acrylblends/body-limit": ["./src/plugins/acryl-router/src/middleware/index.ts"],
24
- "acrylblends/combine": ["./src/plugins/acryl-router/src/middleware/index.ts"],
25
- "acrylblends/compress": ["./src/plugins/acryl-router/src/middleware/index.ts"],
26
- "acrylblends/context-storage": ["./src/plugins/acryl-router/src/middleware/index.ts"],
27
- "acrylblends/cors": ["./src/plugins/acryl-router/src/middleware/index.ts"],
28
- "acrylblends/csrf": ["./src/plugins/acryl-router/src/middleware/index.ts"],
29
- "acrylblends/etag": ["./src/plugins/acryl-router/src/middleware/index.ts"],
30
- "acrylblends/ip-restriction": ["./src/plugins/acryl-router/src/middleware/index.ts"],
31
- "acrylblends/jwk": ["./src/plugins/acryl-router/src/middleware/index.ts"],
32
- "acrylblends/jwt": ["./src/plugins/acryl-router/src/middleware/index.ts"],
33
- "acrylblends/language": ["./src/plugins/acryl-router/src/middleware/index.ts"],
34
- "acrylblends/logger": ["./src/plugins/acryl-router/src/middleware/index.ts"],
35
- "acrylblends/loopback-only": ["./src/plugins/acryl-router/src/middleware/index.ts"],
36
- "acrylblends/method-not-allowed": ["./src/plugins/acryl-router/src/middleware/index.ts"],
37
- "acrylblends/method-override": ["./src/plugins/acryl-router/src/middleware/index.ts"],
38
- "acrylblends/powered-by": ["./src/plugins/acryl-router/src/middleware/index.ts"],
39
- "acrylblends/pretty-json": ["./src/plugins/acryl-router/src/middleware/index.ts"],
40
- "acrylblends/request-id": ["./src/plugins/acryl-router/src/middleware/index.ts"],
41
- "acrylblends/secure-headers": ["./src/plugins/acryl-router/src/middleware/index.ts"],
42
- "acrylblends/serve-static": ["./src/plugins/acryl-router/src/middleware/index.ts"],
43
- "acrylblends/timeout": ["./src/plugins/acryl-router/src/middleware/index.ts"],
44
- "acrylblends/timing": ["./src/plugins/acryl-router/src/middleware/index.ts"],
45
- "acrylblends/trailing-slash": ["./src/plugins/acryl-router/src/middleware/index.ts"],
46
- "acrylblends/accepts": ["./src/plugins/acryl-router/src/helpers/index.ts"],
47
- "acrylblends/cookie": ["./src/plugins/acryl-router/src/helpers/index.ts"],
48
- "acrylblends/factory": ["./src/plugins/acryl-router/src/helpers/index.ts"],
49
- "acrylblends/html": ["./src/plugins/acryl-router/src/helpers/index.ts"],
50
- "acrylblends/route": ["./src/plugins/acryl-router/src/helpers/index.ts"],
51
- "acrylblends/schema-validator": ["./src/plugins/acryl-router/src/helpers/index.ts"],
52
- "acrylblends/streaming": ["./src/plugins/acryl-router/src/helpers/index.ts"],
53
- "acrylblends/testing": ["./src/plugins/acryl-router/src/helpers/index.ts"],
54
- "acrylblends/validator": ["./src/plugins/acryl-router/src/helpers/index.ts"],
55
- "acrylblends/ws": ["./src/plugins/acryl-router/src/helpers/index.ts"],
56
- "acryl-router": ["./src/plugins/acryl-router/src/index.ts"],
57
- "acryl-router/middleware": ["./src/plugins/acryl-router/src/middleware/index.ts"],
58
- "acryl-router/helpers": ["./src/plugins/acryl-router/src/helpers/index.ts"],
59
- "acrylblends/jsx": ["./src/plugins/acryl-router/src/core/jsx/index.ts"],
60
- "acrylblends/jsx/jsx-runtime": ["./src/plugins/acryl-router/src/core/jsx/jsx-runtime.ts"],
61
- "acrylblends/jsx/jsx-dev-runtime": [
62
- "./src/plugins/acryl-router/src/core/jsx/jsx-dev-runtime.ts"
63
- ],
64
- "acrylblends/jsx/streaming": ["./src/plugins/acryl-router/src/core/jsx/streaming.ts"]
16
+ "acrylblends": ["./plugins/acryl-router/src/index.ts"],
17
+ "acrylblends/conninfo": ["./plugins/acryl-router/src/index.ts"],
18
+ "acrylblends/http-exception": ["./plugins/acryl-router/src/index.ts"],
19
+ "acrylblends/types": ["./plugins/acryl-router/src/index.ts"],
20
+ "acrylblends/client": ["./plugins/acryl-router/src/client.ts"],
21
+ "acrylblends/basic-auth": ["./plugins/acryl-router/src/middleware/index.ts"],
22
+ "acrylblends/bearer-auth": ["./plugins/acryl-router/src/middleware/index.ts"],
23
+ "acrylblends/body-limit": ["./plugins/acryl-router/src/middleware/index.ts"],
24
+ "acrylblends/combine": ["./plugins/acryl-router/src/middleware/index.ts"],
25
+ "acrylblends/compress": ["./plugins/acryl-router/src/middleware/index.ts"],
26
+ "acrylblends/context-storage": ["./plugins/acryl-router/src/middleware/index.ts"],
27
+ "acrylblends/cors": ["./plugins/acryl-router/src/middleware/index.ts"],
28
+ "acrylblends/csrf": ["./plugins/acryl-router/src/middleware/index.ts"],
29
+ "acrylblends/etag": ["./plugins/acryl-router/src/middleware/index.ts"],
30
+ "acrylblends/ip-restriction": ["./plugins/acryl-router/src/middleware/index.ts"],
31
+ "acrylblends/jwk": ["./plugins/acryl-router/src/middleware/index.ts"],
32
+ "acrylblends/jwt": ["./plugins/acryl-router/src/middleware/index.ts"],
33
+ "acrylblends/language": ["./plugins/acryl-router/src/middleware/index.ts"],
34
+ "acrylblends/logger": ["./plugins/acryl-router/src/middleware/index.ts"],
35
+ "acrylblends/loopback-only": ["./plugins/acryl-router/src/middleware/index.ts"],
36
+ "acrylblends/method-not-allowed": ["./plugins/acryl-router/src/middleware/index.ts"],
37
+ "acrylblends/method-override": ["./plugins/acryl-router/src/middleware/index.ts"],
38
+ "acrylblends/powered-by": ["./plugins/acryl-router/src/middleware/index.ts"],
39
+ "acrylblends/pretty-json": ["./plugins/acryl-router/src/middleware/index.ts"],
40
+ "acrylblends/request-id": ["./plugins/acryl-router/src/middleware/index.ts"],
41
+ "acrylblends/secure-headers": ["./plugins/acryl-router/src/middleware/index.ts"],
42
+ "acrylblends/serve-static": ["./plugins/acryl-router/src/middleware/index.ts"],
43
+ "acrylblends/timeout": ["./plugins/acryl-router/src/middleware/index.ts"],
44
+ "acrylblends/timing": ["./plugins/acryl-router/src/middleware/index.ts"],
45
+ "acrylblends/trailing-slash": ["./plugins/acryl-router/src/middleware/index.ts"],
46
+ "acrylblends/accepts": ["./plugins/acryl-router/src/helpers/index.ts"],
47
+ "acrylblends/cookie": ["./plugins/acryl-router/src/helpers/index.ts"],
48
+ "acrylblends/factory": ["./plugins/acryl-router/src/helpers/index.ts"],
49
+ "acrylblends/html": ["./plugins/acryl-router/src/helpers/index.ts"],
50
+ "acrylblends/route": ["./plugins/acryl-router/src/helpers/index.ts"],
51
+ "acrylblends/schema-validator": ["./plugins/acryl-router/src/helpers/index.ts"],
52
+ "acrylblends/streaming": ["./plugins/acryl-router/src/helpers/index.ts"],
53
+ "acrylblends/testing": ["./plugins/acryl-router/src/helpers/index.ts"],
54
+ "acrylblends/validator": ["./plugins/acryl-router/src/helpers/index.ts"],
55
+ "acrylblends/ws": ["./plugins/acryl-router/src/helpers/index.ts"],
56
+ "acryl-router": ["./plugins/acryl-router/src/index.ts"],
57
+ "acryl-router/middleware": ["./plugins/acryl-router/src/middleware/index.ts"],
58
+ "acryl-router/helpers": ["./plugins/acryl-router/src/helpers/index.ts"],
59
+ "acrylblends/jsx": ["./plugins/acryl-router/src/core/jsx/index.ts"],
60
+ "acrylblends/jsx/jsx-runtime": ["./plugins/acryl-router/src/core/jsx/jsx-runtime.ts"],
61
+ "acrylblends/jsx/jsx-dev-runtime": ["./plugins/acryl-router/src/core/jsx/jsx-dev-runtime.ts"],
62
+ "acrylblends/jsx/streaming": ["./plugins/acryl-router/src/core/jsx/streaming.ts"]
65
63
  },
66
64
  "jsx": "react-jsx",
67
65
  "jsxImportSource": "#acryl-jsx"
68
66
  },
69
- "include": ["src", "test", "extensions"]
67
+ "include": ["src", "test", "plugins"]
70
68
  }
@@ -2,4 +2,4 @@
2
2
  ## This app (blank)
3
3
 
4
4
  `src/routes.ts` is the app's own plugin: add routes to its `App`. `blend.yaml` lists the plugins that are mounted. `acrylblends add <plugin>` copies
5
- another library plugin (for example `acryl-state`) into `src/plugins/`.
5
+ another library plugin (for example `acryl-state`) into `plugins/`.
@@ -10,13 +10,13 @@ npm test
10
10
  npm run check # type-check
11
11
  ```
12
12
 
13
- - `src/main.ts` chooses where the app lives (`src/plugins/instance`) and starts it.
13
+ - `src/main.ts` chooses where the app lives (`plugins/instance`) and starts it.
14
14
  - `blend.yaml` is the app as data: which plugins it mounts, in what order, with what config and parameters. Switch
15
15
  one off with `disabled: true`. `npm run lock` writes `blend.lock.json`: the manifest's digest and a digest of
16
16
  every plugin folder, so a diff of the lock shows what changed.
17
17
  - `src/app.ts` holds the registry of plugins the code provides, and mounts the manifest's rows.
18
18
  - `src/routes.ts` is your own plugin: it builds an `App` (`import { App } from './plugins/acryl-router/src/index.ts'`) and mounts it with `ctx.router.route(prefix, app)`. Routes added to the app after it is mounted are not served. A page mounted at `/x` answers `/x`, not `/x/` (trailing slashes are strict). The router API is documented at https://acrylblends.github.io/docs/framework/the-acryl-framework and in `docs/framework` of the acrylblends repository.
19
- - `src/plugins/` holds the plugins copied from the ACRYLBLENDS library. They are yours now; edit them freely.
19
+ - `plugins/` holds the plugins copied from the ACRYLBLENDS library. They are yours now; edit them freely.
20
20
  Add more with `acrylblends add <plugin>`.
21
21
 
22
22
  `npm run save -- "message"` commits the app with git (and pushes when it has a remote). It refuses a file that looks like
@@ -1,2 +1,3 @@
1
1
  node_modules/
2
2
  .acrylblends/
3
+ .acryl/
@@ -4,20 +4,20 @@
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "imports": {
7
- "#acryl-jsx/jsx-runtime": "./src/plugins/acryl-router/src/core/jsx/jsx-runtime.ts",
8
- "#acryl-jsx/jsx-dev-runtime": "./src/plugins/acryl-router/src/core/jsx/jsx-dev-runtime.ts"
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
9
  },
10
10
  "engines": {
11
11
  "node": ">=22.19.0"
12
12
  },
13
13
  "scripts": {
14
- "start": "node --import ./src/plugins/acryl-node-server/src/register.ts src/main.ts",
15
- "dev": "node --watch --import ./src/plugins/acryl-node-server/src/register.ts src/main.ts",
16
- "test": "node --import ./src/plugins/acryl-node-server/src/register.ts --test \"test/**/*.test.{ts,tsx}\"",
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": "node --import ./plugins/acryl-node-server/src/register.ts --test \"test/**/*.test.{ts,tsx}\"",
17
17
  "check": "tsc --noEmit",
18
- "lock": "node src/plugins/manifest/lock.ts",
19
- "save": "node src/plugins/save/cli.ts",
20
- "upgrade": "node src/plugins/manifest/upgrade-cli.ts"
18
+ "lock": "node plugins/manifest/lock.ts",
19
+ "save": "node plugins/save/cli.ts",
20
+ "upgrade": "node plugins/manifest/upgrade-cli.ts"
21
21
  },
22
22
  "dependencies": {
23
23
  "typescript": "6.0.3"
@@ -1,8 +1,8 @@
1
- /** A page written in JSX: the scripts in package.json run Node.js with the vendored register hook (src/plugins/acryl-node-server/src/register.ts), which compiles .tsx with this app's own typescript. */
1
+ /** A page written in JSX: the scripts in package.json run Node.js with the vendored register hook (plugins/acryl-node-server/src/register.ts), which compiles .tsx with this app's own typescript. */
2
2
  const About = () => (
3
3
  <main>
4
4
  <h1>About</h1>
5
- <p>Rendered on the server with the JSX runtime in src/plugins/acryl-router.</p>
5
+ <p>Rendered on the server with the JSX runtime in plugins/acryl-router.</p>
6
6
  </main>
7
7
  )
8
8
 
@@ -1,15 +1,15 @@
1
1
  import { join } from 'node:path'
2
2
  import { fileURLToPath } from 'node:url'
3
3
  import { Context, type Fiber } from '@deepseek-ai/cordis'
4
- import * as nodeServer from './plugins/acryl-node-server/src/index.ts'
5
- import * as router from './plugins/acryl-router/src/index.ts'
6
- import { type AppInstance, instance } from './plugins/instance/index.ts'
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 { type AppInstance, instance } from '../plugins/instance/index.ts'
7
7
  import {
8
8
  blueprintsIn,
9
9
  compose,
10
10
  loadManifest,
11
11
  type ParameterValue,
12
- } from './plugins/manifest/index.ts'
12
+ } from '../plugins/manifest/index.ts'
13
13
  import { routes } from './routes.ts'
14
14
 
15
15
  /** The plugins this app's code provides. `blend.yaml` chooses, orders and configures them. */
@@ -30,8 +30,19 @@ export function readManifest(values?: Readonly<Record<string, ParameterValue>>)
30
30
  })
31
31
  }
32
32
 
33
+ /** The manifest without its server row: the app is then reached in process, through `root.router.request`. */
34
+ function withoutServer<M extends { plugins: readonly { use: string }[] }>(manifest: M): M {
35
+ return {
36
+ ...manifest,
37
+ plugins: manifest.plugins.map((row) =>
38
+ row.use === 'acryl-node-server' ? { ...row, disabled: true } : row,
39
+ ),
40
+ }
41
+ }
42
+
33
43
  export interface App {
34
44
  readonly root: Context
45
+ /** The address the server listens on; empty when the app was made with `listen: false`. */
35
46
  readonly url: string
36
47
  stop(): Promise<void>
37
48
  }
@@ -39,7 +50,11 @@ export interface App {
39
50
  /** The app: `instance` first (where it lives, chosen at run time), then the manifest's rows in order. */
40
51
  export async function createApp(
41
52
  chosen: AppInstance,
42
- options: { values?: Readonly<Record<string, ParameterValue>> } = {},
53
+ options: {
54
+ values?: Readonly<Record<string, ParameterValue>>
55
+ /** Start the HTTP server (default). Tests pass `false` and call `app.root.router.request(path)`: it needs no port, so it also runs where a sandbox forbids listening. */
56
+ listen?: boolean
57
+ } = {},
43
58
  ): Promise<App> {
44
59
  const manifest = readManifest(options.values)
45
60
  const root = new Context()
@@ -47,13 +62,18 @@ export async function createApp(
47
62
  await first.await()
48
63
  const fibers: Fiber[] = [
49
64
  first,
50
- ...(await compose(root, manifest, registry, {
51
- server: { port: chosen.port.start, scan: chosen.port.scan ? 20 : 0 },
52
- })),
65
+ ...(await compose(
66
+ root,
67
+ options.listen === false ? withoutServer(manifest) : manifest,
68
+ registry,
69
+ {
70
+ server: { port: chosen.port.start, scan: chosen.port.scan ? 20 : 0 },
71
+ },
72
+ )),
53
73
  ]
54
74
  return {
55
75
  root,
56
- url: root.nodeServer.url,
76
+ url: options.listen === false ? '' : root.nodeServer.url,
57
77
  async stop() {
58
78
  for (const fiber of [...fibers].reverse()) await fiber.dispose()
59
79
  },
@@ -1,6 +1,6 @@
1
1
  import { fileURLToPath } from 'node:url'
2
+ import { selectInstance } from '../plugins/instance/index.ts'
2
3
  import { createApp } from './app.ts'
3
- import { selectInstance } from './plugins/instance/index.ts'
4
4
  import { APP_NAME } from './routes.ts'
5
5
 
6
6
  // The composition root: the one place that reads the environment (through selectInstance) and chooses where the app lives.
@@ -1,6 +1,6 @@
1
1
  import type { Context } from '@deepseek-ai/cordis'
2
+ import { Acryl } from '../plugins/acryl-router/src/index.ts'
2
3
  import { aboutPage } from './about.tsx'
3
- import { Acryl } from './plugins/acryl-router/src/index.ts'
4
4
 
5
5
  export const APP_NAME = '__APP_NAME__'
6
6