acrylblends 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. package/dist/index.d.ts +17 -16
  2. package/dist/index.js +39 -19
  3. package/dist/index.js.map +1 -1
  4. package/package.json +1 -1
  5. package/plugins/acryl-inspect/LICENSE +21 -0
  6. package/plugins/acryl-inspect/plugin.json +11 -0
  7. package/plugins/acryl-inspect/src/index.ts +3 -0
  8. package/plugins/acryl-inspect/src/report.ts +123 -0
  9. package/plugins/acryl-inspect/src/service.ts +66 -0
  10. package/plugins/acryl-inspect/tests/report.spec.ts +64 -0
  11. package/plugins/acryl-inspect/tests/service.spec.ts +120 -0
  12. package/plugins/acryl-inspect/upstream.json +18 -0
  13. package/plugins/acryl-node-server/LICENSE +21 -0
  14. package/plugins/acryl-node-server/plugin.json +12 -0
  15. package/plugins/acryl-node-server/src/index.ts +94 -0
  16. package/plugins/acryl-node-server/tests/server.spec.ts +105 -0
  17. package/plugins/acryl-node-server/upstream.json +18 -0
  18. package/plugins/acryl-router/LICENSE +21 -0
  19. package/plugins/acryl-router/plugin.json +12 -0
  20. package/plugins/acryl-router/src/compose.ts +21 -0
  21. package/plugins/acryl-router/src/context.ts +59 -0
  22. package/plugins/acryl-router/src/helpers/index.ts +3 -0
  23. package/plugins/acryl-router/src/helpers/read-body.ts +59 -0
  24. package/plugins/acryl-router/src/helpers/sse.ts +83 -0
  25. package/plugins/acryl-router/src/helpers/validate.ts +44 -0
  26. package/plugins/acryl-router/src/http-error.ts +21 -0
  27. package/plugins/acryl-router/src/index.ts +6 -0
  28. package/plugins/acryl-router/src/middleware/body-limit.ts +25 -0
  29. package/plugins/acryl-router/src/middleware/cors.ts +46 -0
  30. package/plugins/acryl-router/src/middleware/index.ts +6 -0
  31. package/plugins/acryl-router/src/middleware/logger.ts +21 -0
  32. package/plugins/acryl-router/src/middleware/loopback-only.ts +82 -0
  33. package/plugins/acryl-router/src/middleware/request-id.ts +24 -0
  34. package/plugins/acryl-router/src/middleware/secure-headers.ts +23 -0
  35. package/plugins/acryl-router/src/pattern.ts +89 -0
  36. package/plugins/acryl-router/src/registrar.ts +90 -0
  37. package/plugins/acryl-router/src/service.ts +164 -0
  38. package/plugins/acryl-router/src/table.ts +156 -0
  39. package/plugins/acryl-router/src/types.ts +73 -0
  40. package/plugins/acryl-router/src/util.ts +23 -0
  41. package/plugins/acryl-router/tests/api.spec.ts +223 -0
  42. package/plugins/acryl-router/tests/conformance.ts +170 -0
  43. package/plugins/acryl-router/tests/helpers.spec.ts +172 -0
  44. package/plugins/acryl-router/tests/live-install.spec.ts +64 -0
  45. package/plugins/acryl-router/tests/loader.spec.ts +71 -0
  46. package/plugins/acryl-router/tests/middleware.spec.ts +183 -0
  47. package/plugins/acryl-router/tests/service.spec.ts +211 -0
  48. package/plugins/acryl-router/tests/table.spec.ts +47 -0
  49. package/plugins/acryl-router/upstream.json +20 -0
  50. package/plugins/acryl-router-http/LICENSE +21 -0
  51. package/plugins/acryl-router-http/plugin.json +11 -0
  52. package/plugins/acryl-router-http/src/bridge.ts +52 -0
  53. package/plugins/acryl-router-http/src/index.ts +63 -0
  54. package/plugins/acryl-router-http/src/serve.ts +24 -0
  55. package/plugins/acryl-router-http/tests/adapter.spec.ts +170 -0
  56. package/plugins/acryl-router-http/tests/stream.spec.ts +133 -0
  57. package/plugins/acryl-router-http/tests/surfaces.spec.ts +99 -0
  58. package/plugins/acryl-router-http/upstream.json +22 -0
  59. package/plugins/acryl-state/LICENSE +21 -0
  60. package/plugins/acryl-state/plugin.json +12 -0
  61. package/plugins/acryl-state/src/cell.ts +67 -0
  62. package/plugins/acryl-state/src/index.ts +3 -0
  63. package/plugins/acryl-state/src/react.ts +11 -0
  64. package/plugins/acryl-state/src/service.ts +101 -0
  65. package/plugins/acryl-state/tests/cell.spec.ts +71 -0
  66. package/plugins/acryl-state/tests/react.spec.ts +39 -0
  67. package/plugins/acryl-state/tests/service.spec.ts +132 -0
  68. package/plugins/acryl-state/upstream.json +20 -0
  69. package/plugins/builder/script.ts +8 -9
  70. package/plugins/extensions/index.ts +18 -1
  71. package/plugins/openai/prompt.ts +9 -5
  72. package/plugins/tasks/index.ts +14 -0
  73. package/plugins/tsconfig.json +9 -1
  74. package/templates/agent/blend.yaml +5 -7
  75. package/templates/agent/src/app.ts +8 -8
  76. package/templates/agent/src/routes.ts +32 -41
  77. package/templates/agent/template.json +3 -3
  78. package/templates/agent/test/builder.test.ts +22 -14
  79. package/templates/agent/test/model.test.ts +2 -3
  80. package/templates/{app → blank}/blend.yaml +3 -3
  81. package/templates/{app → blank}/src/app.ts +5 -5
  82. package/templates/blank/src/routes.ts +14 -0
  83. package/templates/blank/template.json +1 -0
  84. package/templates/{app → blank}/test/app.test.ts +1 -1
  85. package/templates/todo/README.md +27 -0
  86. package/templates/todo/blend.yaml +25 -0
  87. package/templates/todo/gitignore +2 -0
  88. package/templates/todo/package.json +23 -0
  89. package/templates/todo/src/app.ts +71 -0
  90. package/templates/todo/src/main.ts +19 -0
  91. package/templates/todo/src/page.ts +28 -0
  92. package/templates/todo/src/todo.ts +96 -0
  93. package/templates/todo/template.json +1 -0
  94. package/templates/todo/test/todo.test.ts +145 -0
  95. package/templates/todo/tsconfig.json +19 -0
  96. package/templates/todo/upstream.json +12 -0
  97. package/plugins/events/index.ts +0 -42
  98. package/plugins/events/plugin.json +0 -9
  99. package/plugins/http/index.ts +0 -64
  100. package/plugins/http/plugin.json +0 -10
  101. package/plugins/server/index.ts +0 -138
  102. package/plugins/server/plugin.json +0 -10
  103. package/templates/app/src/routes.ts +0 -14
  104. package/templates/app/template.json +0 -1
  105. /package/templates/{app → blank}/README.md +0 -0
  106. /package/templates/{app → blank}/gitignore +0 -0
  107. /package/templates/{app → blank}/package.json +0 -0
  108. /package/templates/{app → blank}/src/main.ts +0 -0
  109. /package/templates/{app → blank}/tsconfig.json +0 -0
@@ -0,0 +1,14 @@
1
+ import type { Context } from '@deepseek-ai/cordis'
2
+ import type {} from './plugins/acryl-router/src/index.ts'
3
+
4
+ export const APP_NAME = '__APP_NAME__'
5
+
6
+ /** The app's own routes on the router. A route registered from this plugin's context is removed when the plugin is disposed. */
7
+ export const routes = {
8
+ name: 'routes',
9
+ inject: ['router'],
10
+ apply(ctx: Context) {
11
+ ctx.router.get('/', (c) => c.json({ app: APP_NAME, ok: true }))
12
+ ctx.router.get('/health', (c) => c.json({ ok: true }))
13
+ },
14
+ }
@@ -0,0 +1 @@
1
+ { "plugins": ["instance", "acryl-router", "acryl-node-server", "manifest", "save"] }
@@ -50,7 +50,7 @@ test('the manifest decides what is mounted: a parameter changes the server, an u
50
50
  const manifest = readManifest()
51
51
  assert.deepEqual(
52
52
  manifest.plugins.map((row) => row.id),
53
- ['http', 'routes', 'server'],
53
+ ['router', 'routes', 'server'],
54
54
  )
55
55
  assert.throws(() => readManifest({ nothing: 1 }), /parameter-override-unknown/)
56
56
  })
@@ -0,0 +1,27 @@
1
+ # __APP_NAME__
2
+
3
+ A todo list built on the ACRYL framework: only the router and shared state. The list is a state cell, the routes are on the router, and the list is
4
+ written to a file in the app's home so it survives a restart. `src/todo.ts` is ACRYL's reference app, copied as it is; the files you own are
5
+ `blend.yaml` (what is mounted), `src/app.ts` (the composition) and the tests.
6
+
7
+ ```sh
8
+ npm install
9
+ npm test
10
+ npm run dev # prints the URL, for example http://127.0.0.1:3100
11
+ ```
12
+
13
+ ```sh
14
+ URL=http://127.0.0.1:3100 # the address `npm run dev` printed
15
+ curl -X POST $URL/todo/api -H 'content-type: application/json' -d '{"title":"write the docs"}' # {"id":1,"title":"write the docs","done":false}
16
+ curl $URL/todo/api # the list
17
+ curl -X PATCH $URL/todo/api/1 -H 'content-type: application/json' -d '{"done":true}'
18
+ curl -X DELETE $URL/todo/api/1
19
+ curl -N $URL/todo/events # the list now, then again on every change
20
+ ```
21
+
22
+ The routes only answer a caller on this machine: a request from another site's page (a foreign `Origin`, or `Origin: null`) or addressed to another host name gets `403`.
23
+
24
+ Open `$URL/todo` for the page. Stop the app and start it again: the list is still there (`<home>/todo.json`, `.acrylblends/` unless `ACRYLBLENDS_HOME` says otherwise).
25
+
26
+ - `blend.yaml` is the app as data. `npm run lock` records what it is built from; `npm run save` commits it, refusing secrets and public remotes for a private app.
27
+ - The framework plugins are in `src/plugins/acryl-*/src`. Do not edit them: they are ACRYL's, vendored; the text that explains them is in the ACRYLBLENDS docs.
@@ -0,0 +1,25 @@
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: state
19
+ name: acryl-state
20
+ - id: todo
21
+ name: todo
22
+ - id: server
23
+ name: acryl-node-server
24
+ config:
25
+ hostname: "{{parameters.hostname}}"
@@ -0,0 +1,2 @@
1
+ node_modules/
2
+ .acrylblends/
@@ -0,0 +1,23 @@
1
+ {
2
+ "name": "__APP_NAME__",
3
+ "version": "0.1.0",
4
+ "private": true,
5
+ "type": "module",
6
+ "engines": {
7
+ "node": ">=22.19.0"
8
+ },
9
+ "scripts": {
10
+ "start": "node src/main.ts",
11
+ "dev": "node --watch src/main.ts",
12
+ "test": "node --test",
13
+ "check": "tsc --noEmit",
14
+ "lock": "node src/plugins/manifest/lock.ts",
15
+ "save": "node src/plugins/save/cli.ts",
16
+ "upgrade": "node src/plugins/manifest/upgrade-cli.ts"
17
+ },
18
+ "dependencies": {},
19
+ "devDependencies": {
20
+ "@types/node": "^24.0.0",
21
+ "typescript": "6.0.3"
22
+ }
23
+ }
@@ -0,0 +1,71 @@
1
+ import { join } from 'node:path'
2
+ import { fileURLToPath } from 'node:url'
3
+ import { Context, type Fiber } from '@deepseek-ai/cordis'
4
+ import * as nodeServer from './plugins/acryl-node-server/src/index.ts'
5
+ import * as router from './plugins/acryl-router/src/index.ts'
6
+ import * as state from './plugins/acryl-state/src/index.ts'
7
+ import { type AppInstance, instance } from './plugins/instance/index.ts'
8
+ import {
9
+ blueprintsIn,
10
+ compose,
11
+ loadManifest,
12
+ type ParameterValue,
13
+ } from './plugins/manifest/index.ts'
14
+ import * as todo from './todo.ts'
15
+
16
+ /** The plugins this app's code provides. `blend.yaml` chooses, orders and configures them. */
17
+ export const registry = {
18
+ 'acryl-router': router,
19
+ 'acryl-state': state,
20
+ todo,
21
+ 'acryl-node-server': nodeServer,
22
+ }
23
+
24
+ export const APP_NAME = '__APP_NAME__'
25
+
26
+ /** The manifest next to this app's source. */
27
+ export const MANIFEST = join(fileURLToPath(new URL('..', import.meta.url)), 'blend.yaml')
28
+
29
+ /**
30
+ * The app's manifest, resolved. A Blend finds its Blueprint in `blueprints/<id>.yaml`; an app that is itself a Blueprint never looks.
31
+ * `createApp` and the tests read it the same way.
32
+ */
33
+ export function readManifest(values?: Readonly<Record<string, ParameterValue>>) {
34
+ return loadManifest(MANIFEST, {
35
+ known: Object.keys(registry),
36
+ getBlueprint: blueprintsIn(join(fileURLToPath(new URL('..', import.meta.url)), 'blueprints')),
37
+ ...(values ? { values } : {}),
38
+ })
39
+ }
40
+
41
+ export interface App {
42
+ readonly root: Context
43
+ readonly url: string
44
+ stop(): Promise<void>
45
+ }
46
+
47
+ /** The app: `instance` first (where it lives, chosen at run time), then the manifest's rows in order. */
48
+ export async function createApp(
49
+ chosen: AppInstance,
50
+ options: { values?: Readonly<Record<string, ParameterValue>> } = {},
51
+ ): Promise<App> {
52
+ const manifest = readManifest(options.values)
53
+ const root = new Context()
54
+ const first = root.plugin(instance, chosen)
55
+ await first.await()
56
+ const fibers: Fiber[] = [
57
+ first,
58
+ ...(await compose(root, manifest, registry, {
59
+ // Runtime values the manifest cannot hold: where the list is kept (in the app's home) and where the server listens.
60
+ todo: { file: join(chosen.home, 'todo.json') },
61
+ server: { port: chosen.port.start, scan: chosen.port.scan ? 20 : 0 },
62
+ })),
63
+ ]
64
+ return {
65
+ root,
66
+ url: root.nodeServer.url,
67
+ async stop() {
68
+ for (const fiber of [...fibers].reverse()) await fiber.dispose()
69
+ },
70
+ }
71
+ }
@@ -0,0 +1,19 @@
1
+ import { fileURLToPath } from 'node:url'
2
+ import { APP_NAME, createApp } from './app.ts'
3
+ import { selectInstance } from './plugins/instance/index.ts'
4
+
5
+ // The composition root: the one place that reads the environment (through selectInstance) and chooses where the app lives.
6
+ const chosen = selectInstance({
7
+ projectRoot: fileURLToPath(new URL('..', import.meta.url)),
8
+ env: process.env,
9
+ })
10
+ const app = await createApp(chosen)
11
+ console.log(`${APP_NAME} listening on ${app.url} (data in ${chosen.home})`)
12
+
13
+ const stop = async () => {
14
+ await app.stop()
15
+ console.log('stopped')
16
+ process.exit(0)
17
+ }
18
+ process.once('SIGINT', stop)
19
+ process.once('SIGTERM', stop)
@@ -0,0 +1,28 @@
1
+ /** The page: plain HTML and a few lines of script; it follows the list through the server-sent event stream. */
2
+ export const PAGE = `<!doctype html>
3
+ <meta charset="utf-8"><title>Todo</title>
4
+ <style>body{font:16px system-ui;max-width:32rem;margin:2rem auto}li{display:flex;gap:.5rem;padding:.25rem 0}.done span{text-decoration:line-through;opacity:.6}</style>
5
+ <h1>Todo</h1>
6
+ <form id="add"><input id="title" placeholder="What needs doing?" autofocus> <button>Add</button></form>
7
+ <ul id="list"></ul>
8
+ <script>
9
+ const list = document.getElementById('list')
10
+ const send = (url, method, body) => fetch(url, { method, headers: { 'content-type': 'application/json' }, body: body && JSON.stringify(body) })
11
+ document.getElementById('add').onsubmit = async event => {
12
+ event.preventDefault()
13
+ const input = document.getElementById('title')
14
+ await send('/todo/api', 'POST', { title: input.value })
15
+ input.value = ''
16
+ }
17
+ new EventSource('/todo/events').onmessage = event => {
18
+ list.replaceChildren(...JSON.parse(event.data).map(todo => {
19
+ const item = document.createElement('li')
20
+ item.className = todo.done ? 'done' : ''
21
+ const box = Object.assign(document.createElement('input'), { type: 'checkbox', checked: todo.done, onchange: () => send('/todo/api/' + todo.id, 'PATCH', { done: box.checked }) })
22
+ const title = document.createElement('span'); title.textContent = todo.title
23
+ const remove = Object.assign(document.createElement('button'), { textContent: 'x', onclick: () => send('/todo/api/' + todo.id, 'DELETE') })
24
+ item.append(box, title, remove)
25
+ return item
26
+ }))
27
+ }
28
+ </script>`
@@ -0,0 +1,96 @@
1
+ /**
2
+ * The reference app of the ACRYL Framework: a todo list in one plugin. It needs `router` and `state`, keeps the list in a cell, serves
3
+ * JSON CRUD plus a page, and writes the list to a file so it survives a restart. Nothing else.
4
+ */
5
+
6
+ import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
7
+ import { dirname } from 'node:path'
8
+ import type { Context } from '@deepseek-ai/cordis'
9
+ import { HttpError } from './plugins/acryl-router/src/index.ts'
10
+ import { loopbackOnly } from './plugins/acryl-router/src/middleware/index.ts'
11
+ import { readJson, streamSSE, valid, validate, type StandardSchema } from './plugins/acryl-router/src/helpers/index.ts'
12
+ import type {} from './plugins/acryl-state/src/index.ts'
13
+ import { PAGE } from './page.ts'
14
+
15
+ /** Stable Cordis plugin name, equal to the package name and the Loader row id. */
16
+ export const name = 'acryl-todo'
17
+ export const inject = ['router', 'state']
18
+
19
+ export interface Todo { id: number, title: string, done: boolean }
20
+ export interface Config { file?: string }
21
+
22
+ /** Standard Schema for the body of a new todo, written by hand so the example has no schema library. */
23
+ const NewTodo: StandardSchema<{ title: string }> = {
24
+ '~standard': {
25
+ validate: value => {
26
+ const title = (value as { title?: unknown } | null)?.title
27
+ return typeof title === 'string' && title.trim() !== '' && title.length <= 200
28
+ ? { value: { title: title.trim() } }
29
+ : { issues: [{ message: 'title must be 1 to 200 characters', path: ['title'] }] }
30
+ },
31
+ },
32
+ }
33
+
34
+ /** A missing file is a new list. Any other failure (unreadable, corrupt, half-written) refuses to start: starting empty would overwrite the user's real list on the first save. */
35
+ function load(file: string | undefined): Todo[] {
36
+ if (file === undefined) return []
37
+ let text: string
38
+ try {
39
+ text = readFileSync(file, 'utf8')
40
+ } catch (error) {
41
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') return []
42
+ throw new Error(`acryl-todo: cannot read ${file}: ${(error as Error).message}`, { cause: error })
43
+ }
44
+ try {
45
+ const parsed: unknown = JSON.parse(text)
46
+ if (!Array.isArray(parsed)) throw new Error('not a list')
47
+ return parsed as Todo[]
48
+ } catch (error) {
49
+ throw new Error(`acryl-todo: ${file} is not a valid todo list (${(error as Error).message}); fix or remove it, it was left untouched`, { cause: error })
50
+ }
51
+ }
52
+
53
+ function save(file: string, todos: readonly Todo[]): void {
54
+ mkdirSync(dirname(file), { recursive: true })
55
+ const temporary = `${file}.tmp`
56
+ writeFileSync(temporary, JSON.stringify(todos))
57
+ renameSync(temporary, file)
58
+ }
59
+
60
+ export function apply(ctx: Context, config: Config = {}): void {
61
+ const todos = ctx.state.cell<readonly Todo[]>('todo.list', load(config.file))
62
+ if (config.file !== undefined) {
63
+ const file = config.file
64
+ ctx.effect(() => todos.subscribe(list => { save(file, list) }), 'acryl-todo: save to file')
65
+ }
66
+ const nextId = (): number => todos.get().reduce((max, todo) => Math.max(max, todo.id), 0) + 1
67
+
68
+ const api = ctx.router.group('/todo', loopbackOnly({ inProcess: 'allow' }))
69
+ api.get('/', c => c.html(PAGE))
70
+ api.get('/api', c => c.json(todos.get()))
71
+ api.post('/api', validate('json', NewTodo), c => {
72
+ const todo: Todo = { id: nextId(), title: valid<{ title: string }>(c, 'json').title, done: false }
73
+ todos.update(list => [...list, todo])
74
+ return c.json(todo, 201)
75
+ })
76
+ api.patch('/api/:id', async c => {
77
+ const id = Number(c.param('id'))
78
+ const change = await readJson(c) as { done?: unknown }
79
+ if (!todos.get().some(todo => todo.id === id)) throw new HttpError(404, { message: 'no such todo' })
80
+ if (typeof change.done !== 'boolean') throw new HttpError(400, { message: 'done must be true or false' })
81
+ todos.update(list => list.map(todo => todo.id === id ? { ...todo, done: change.done as boolean } : todo))
82
+ return c.json(todos.get().find(todo => todo.id === id))
83
+ })
84
+ api.delete('/api/:id', c => {
85
+ const id = Number(c.param('id'))
86
+ if (!todos.get().some(todo => todo.id === id)) throw new HttpError(404, { message: 'no such todo' })
87
+ todos.update(list => list.filter(todo => todo.id !== id))
88
+ return c.body(null, 204)
89
+ })
90
+ api.get('/events', c => streamSSE(c, stream => {
91
+ stream.send(todos.get())
92
+ return todos.subscribe(list => { stream.send(list) })
93
+ }))
94
+
95
+ ctx.inject(['routerHttp'], inner => { inner.routerHttp.mount('/todo') })
96
+ }
@@ -0,0 +1 @@
1
+ { "plugins": ["instance", "acryl-router", "acryl-state", "acryl-node-server", "manifest", "save"] }
@@ -0,0 +1,145 @@
1
+ import assert from 'node:assert/strict'
2
+ import { mkdtemp, rm } from 'node:fs/promises'
3
+ import { tmpdir } from 'node:os'
4
+ import { join } from 'node:path'
5
+ import { test } from 'node:test'
6
+ import { fileURLToPath } from 'node:url'
7
+ import { type App, createApp, readManifest } from '../src/app.ts'
8
+ import { pinnedInstance, withPort } from '../src/plugins/instance/index.ts'
9
+
10
+ const projectRoot = fileURLToPath(new URL('..', import.meta.url))
11
+
12
+ /** Boot the app on any free port with its data in `home`, run the test, always stop it. */
13
+ async function withApp(home: string, run: (app: App) => Promise<void>) {
14
+ const app = await createApp(withPort(pinnedInstance(home, { root: projectRoot }), 0))
15
+ try {
16
+ await run(app)
17
+ } finally {
18
+ await app.stop()
19
+ }
20
+ }
21
+
22
+ async function withHome(run: (home: string) => Promise<void>) {
23
+ const home = await mkdtemp(join(tmpdir(), 'todo-test-'))
24
+ try {
25
+ await run(home)
26
+ } finally {
27
+ await rm(home, { recursive: true, force: true })
28
+ }
29
+ }
30
+
31
+ const send = (app: App, method: string, path: string, body?: unknown) =>
32
+ fetch(`${app.url}${path}`, {
33
+ method,
34
+ headers: { 'content-type': 'application/json' },
35
+ ...(body === undefined ? {} : { body: JSON.stringify(body) }),
36
+ })
37
+
38
+ test('creates, completes, lists and deletes', () =>
39
+ withHome((home) =>
40
+ withApp(home, async (app) => {
41
+ const created = await send(app, 'POST', '/todo/api', { title: ' buy milk ' })
42
+ assert.equal(created.status, 201)
43
+ assert.deepEqual(await created.json(), { id: 1, title: 'buy milk', done: false })
44
+
45
+ const done = await send(app, 'PATCH', '/todo/api/1', { done: true })
46
+ assert.deepEqual(await done.json(), { id: 1, title: 'buy milk', done: true })
47
+ assert.deepEqual(await (await send(app, 'GET', '/todo/api')).json(), [
48
+ { id: 1, title: 'buy milk', done: true },
49
+ ])
50
+
51
+ assert.equal((await send(app, 'DELETE', '/todo/api/1')).status, 204)
52
+ assert.deepEqual(await (await send(app, 'GET', '/todo/api')).json(), [])
53
+ }),
54
+ ))
55
+
56
+ test('a change from a foreign origin is refused; a plain request from this machine works', () =>
57
+ withHome((home) =>
58
+ withApp(home, async (app) => {
59
+ const post = (headers: Record<string, string>) =>
60
+ fetch(`${app.url}/todo/api`, {
61
+ method: 'POST',
62
+ headers: { 'content-type': 'application/json', ...headers },
63
+ body: JSON.stringify({ title: 'x' }),
64
+ })
65
+ assert.equal((await post({ origin: 'http://evil.example' })).status, 403)
66
+ assert.equal((await post({ origin: 'null' })).status, 403)
67
+ assert.equal((await post({})).status, 201)
68
+ assert.equal((await post({ origin: new URL(app.url).origin })).status, 201)
69
+ }),
70
+ ))
71
+
72
+ test('rejects bad input and unknown ids', () =>
73
+ withHome((home) =>
74
+ withApp(home, async (app) => {
75
+ assert.equal((await send(app, 'POST', '/todo/api', { title: '' })).status, 400)
76
+ assert.equal((await send(app, 'POST', '/todo/api', { title: 42 })).status, 400)
77
+ assert.equal((await send(app, 'DELETE', '/todo/api/9')).status, 404)
78
+ assert.equal((await send(app, 'PATCH', '/todo/api/9', { done: true })).status, 404)
79
+ assert.equal((await send(app, 'GET', '/nope')).status, 404)
80
+ }),
81
+ ))
82
+
83
+ test('the list survives a restart: a second app on the same home sees it', () =>
84
+ withHome(async (home) => {
85
+ await withApp(home, async (app) => {
86
+ await send(app, 'POST', '/todo/api', { title: 'first' })
87
+ await send(app, 'POST', '/todo/api', { title: 'second' })
88
+ await send(app, 'PATCH', '/todo/api/2', { done: true })
89
+ })
90
+ await withApp(home, async (app) => {
91
+ const list = (await (await send(app, 'GET', '/todo/api')).json()) as Array<{
92
+ id: number
93
+ done: boolean
94
+ }>
95
+ assert.deepEqual(
96
+ list.map((todo) => [todo.id, todo.done]),
97
+ [
98
+ [1, false],
99
+ [2, true],
100
+ ],
101
+ )
102
+ const created = (await (await send(app, 'POST', '/todo/api', { title: 'third' })).json()) as {
103
+ id: number
104
+ }
105
+ assert.equal(created.id, 3)
106
+ })
107
+ }))
108
+
109
+ test('serves the page, and streams the list: now, and again on every change', () =>
110
+ withHome((home) =>
111
+ withApp(home, async (app) => {
112
+ const page = await send(app, 'GET', '/todo')
113
+ assert.equal(page.status, 200)
114
+ assert.match(await page.text(), /<title>Todo<\/title>/)
115
+
116
+ const controller = new AbortController()
117
+ const stream = await fetch(`${app.url}/todo/events`, { signal: controller.signal })
118
+ const reader = stream.body!.getReader()
119
+ const decoder = new TextDecoder()
120
+ let text = ''
121
+ const until = async (what: RegExp) => {
122
+ for (let waited = 0; !what.test(text) && waited < 3000; waited += 25) {
123
+ const next = await Promise.race([
124
+ reader.read(),
125
+ new Promise<{ done: true; value: undefined }>((done) =>
126
+ setTimeout(() => done({ done: true, value: undefined }), 25),
127
+ ),
128
+ ])
129
+ if (next.value) text += decoder.decode(next.value, { stream: true })
130
+ }
131
+ assert.match(text, what)
132
+ }
133
+ await until(/data: \[\]/)
134
+ await send(app, 'POST', '/todo/api', { title: 'streamed' })
135
+ await until(/"title":"streamed"/)
136
+ controller.abort()
137
+ }),
138
+ ))
139
+
140
+ test('the manifest decides what is mounted', () => {
141
+ assert.deepEqual(
142
+ readManifest().plugins.map((row) => row.id),
143
+ ['router', 'state', 'todo', 'server'],
144
+ )
145
+ })
@@ -0,0 +1,19 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2023",
4
+ "lib": ["ES2023"],
5
+ "module": "NodeNext",
6
+ "moduleResolution": "NodeNext",
7
+ "strict": true,
8
+ "noUncheckedIndexedAccess": true,
9
+ "exactOptionalPropertyTypes": true,
10
+ "noImplicitOverride": true,
11
+ "verbatimModuleSyntax": true,
12
+ "erasableSyntaxOnly": true,
13
+ "allowImportingTsExtensions": true,
14
+ "noEmit": true,
15
+ "skipLibCheck": true,
16
+ "types": ["node"]
17
+ },
18
+ "include": ["src", "test"]
19
+ }
@@ -0,0 +1,12 @@
1
+ {
2
+ "source": {
3
+ "repo": "acryldev/acryl",
4
+ "files": {
5
+ "examples/acryl-todo/src/index.ts": "src/todo.ts",
6
+ "examples/acryl-todo/src/page.ts": "src/page.ts"
7
+ },
8
+ "commit": "11fed295161c0282d144b5ac62616988b58d8189"
9
+ },
10
+ "rewrittenSpecifiers": 4,
11
+ "digest": "sha256:5ca4e0883b7ff0fdec8cf18baac5271209ee27fcc8ce8decf298c9720256f92c"
12
+ }
@@ -1,42 +0,0 @@
1
- /**
2
- * Events as a stream (`GET /events`, server-sent events). A client connects once and receives every change as it happens: the
3
- * event name is the Cordis event (`tasks/changed`, `extensions/changed`, any you list), the data is `{ "args": [...] }` with the
4
- * event's arguments as JSON. Desktop, web and terminal surfaces can all render the same stream (the Pi Durable "one place to
5
- * observe" idea); nothing here knows about tasks or extensions. Each connection's listeners are removed when the client leaves.
6
- */
7
- import type { Context } from '@deepseek-ai/cordis'
8
- import type {} from '../http/index.ts'
9
-
10
- export interface EventsConfig {
11
- /** Cordis events to stream. */
12
- topics?: readonly string[]
13
- /** Where the stream is served. Default `/events`. */
14
- path?: string
15
- }
16
-
17
- export const events = {
18
- name: 'events',
19
- inject: ['http'],
20
- apply(ctx: Context, config?: EventsConfig) {
21
- const topics = config?.topics ?? []
22
- const path = config?.path ?? '/events'
23
- const listen = ctx.on as unknown as (
24
- topic: string,
25
- listener: (...args: unknown[]) => void,
26
- ) => () => void
27
- ctx.effect(() =>
28
- ctx.http.route('GET', path, () => ({
29
- body: null,
30
- stream: (send) => {
31
- const leaves = topics.map((topic) =>
32
- listen.call(ctx, topic, (...args: unknown[]) => send(topic, { args })),
33
- )
34
- send('connected', { topics })
35
- return () => {
36
- for (const leave of leaves) leave()
37
- }
38
- },
39
- })),
40
- )
41
- },
42
- }
@@ -1,9 +0,0 @@
1
- {
2
- "name": "events",
3
- "description": "A live stream of Cordis events over server-sent events, so a surface renders state instead of polling.",
4
- "requires": ["http"],
5
- "inject": ["http"],
6
- "provides": [],
7
- "dependencies": { "@deepseek-ai/cordis": "4.0.4" },
8
- "mount": "add a row to blend.yaml: events, with config { topics: ['tasks/changed', 'extensions/changed'] }"
9
- }
@@ -1,64 +0,0 @@
1
- import type { Context } from '@deepseek-ai/cordis'
2
-
3
- /** A request as routes see it: the method, the path and the parsed JSON body, if any. */
4
- export interface RouteRequest {
5
- readonly method: string
6
- readonly path: string
7
- readonly body: unknown
8
- }
9
-
10
- /** What a route answers. The status defaults to 200; the body is sent as JSON. */
11
- /** Sends one server-sent event: `topic` is the event name, `data` is sent as JSON. */
12
- export type Send = (topic: string, data: unknown) => void
13
-
14
- /** A live stream: called when a client connects, it returns what to run when the client leaves. */
15
- export type Stream = (send: Send) => () => void
16
-
17
- export interface Reply {
18
- readonly status?: number
19
- readonly body: unknown
20
- /** Answer with a stream of server-sent events instead of a JSON body. */
21
- readonly stream?: Stream
22
- }
23
-
24
- export type Handler = (request: RouteRequest) => Reply | Promise<Reply>
25
-
26
- /** The router service. Plugins add routes; the server asks it to answer requests. */
27
- export interface Http {
28
- /** Add a route. Returns the function that removes it, so it can be the disposer of a `ctx.effect`. */
29
- route(method: string, path: string, handler: Handler): () => void
30
- /** Answer a request from the registered routes, 404 when none matches. */
31
- handle(request: RouteRequest): Promise<{ status: number; body: unknown; stream?: Stream }>
32
- /** The registered routes, as `METHOD /path`. */
33
- list(): string[]
34
- }
35
-
36
- declare module '@deepseek-ai/cordis' {
37
- interface Context {
38
- http: Http
39
- }
40
- }
41
-
42
- /** Provides the `http` router. Nothing listens here: see the `server` plugin. */
43
- export function http(ctx: Context) {
44
- const routes = new Map<string, Handler>()
45
- ctx.provide('http', {
46
- route(method, path, handler) {
47
- const key = `${method.toUpperCase()} ${path}`
48
- if (routes.has(key)) throw new Error(`route ${key} is already registered`)
49
- routes.set(key, handler)
50
- return () => void routes.delete(key)
51
- },
52
- async handle(request) {
53
- const handler = routes.get(`${request.method.toUpperCase()} ${request.path}`)
54
- if (!handler) return { status: 404, body: { error: 'not found' } }
55
- const reply = await handler(request)
56
- return {
57
- status: reply.status ?? 200,
58
- body: reply.body,
59
- ...(reply.stream ? { stream: reply.stream } : {}),
60
- }
61
- },
62
- list: () => [...routes.keys()].sort(),
63
- } satisfies Http)
64
- }
@@ -1,10 +0,0 @@
1
- {
2
- "name": "http",
3
- "description": "The router service: plugins add routes inside ctx.effect, so a route disappears with the plugin that added it.",
4
- "requires": [],
5
- "inject": [],
6
- "provides": ["http"],
7
- "dependencies": {
8
- "@deepseek-ai/cordis": "4.0.4"
9
- }
10
- }