acrylblends 1.0.10 → 1.2.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.
- package/README.md +9 -0
- package/dist/index.d.ts +60 -2
- package/dist/index.js +1992 -33
- package/dist/index.js.map +1 -1
- package/docs/framework/agents/index.md +1 -1
- package/docs/framework/api/summary.md +1 -1
- package/docs/framework/getting-started/desktop.md +23 -4
- package/docs/framework/guides/create-acrylblends.md +1 -1
- package/docs/framework/guides/production.md +20 -0
- package/docs/framework/workshop/README.md +174 -285
- package/docs/framework/workshop/facilitator.md +81 -90
- package/legacy/README.md +9 -0
- package/legacy/agent/157ce3db1112/src/app.ts +98 -0
- package/legacy/agent/157ce3db1112/src/routes.ts +63 -0
- package/legacy/agent/24c681a00fca/src/app.ts +160 -0
- package/legacy/agent/24c681a00fca/src/routes.ts +83 -0
- package/legacy/agent/2e790da977b2/src/app.ts +98 -0
- package/legacy/agent/2e790da977b2/src/routes.ts +63 -0
- package/legacy/agent/6307045545dc/src/app.ts +168 -0
- package/legacy/agent/6307045545dc/src/routes.ts +83 -0
- package/legacy/agent/6a5fb9c15c53/src/app.ts +158 -0
- package/legacy/agent/6a5fb9c15c53/src/routes.ts +83 -0
- package/legacy/agent/71fb54a5a942/src/app.ts +111 -0
- package/legacy/agent/71fb54a5a942/src/routes.ts +63 -0
- package/legacy/agent/74ece148269c/src/app.ts +168 -0
- package/legacy/agent/74ece148269c/src/routes.ts +93 -0
- package/legacy/agent/855359304707/src/app.ts +168 -0
- package/legacy/agent/855359304707/src/routes.ts +126 -0
- package/legacy/agent/b5ef3c786ae4/src/app.ts +127 -0
- package/legacy/agent/b5ef3c786ae4/src/routes.ts +76 -0
- package/legacy/agent/c2f30958043b/src/app.ts +98 -0
- package/legacy/agent/c2f30958043b/src/routes.ts +53 -0
- package/legacy/blank/5a91c558af22/src/routes.ts +18 -0
- package/legacy/blank/9c650cf52bf3/src/routes.ts +14 -0
- package/legacy/blank/aea589283445/src/routes.ts +16 -0
- package/legacy/blank/b2cbb759c889/src/routes.ts +16 -0
- package/legacy/blank/d6d371c9a65e/src/routes.ts +18 -0
- package/legacy/chat/7076cc821e2c/src/app.ts +72 -0
- package/legacy/chat/7076cc821e2c/src/routes.ts +13 -0
- package/legacy/chat/7dbe6d5170df/src/app.ts +107 -0
- package/legacy/chat/7dbe6d5170df/src/routes.ts +19 -0
- package/legacy/chat/a9a9ab44c58e/src/app.ts +107 -0
- package/legacy/chat/a9a9ab44c58e/src/routes.ts +15 -0
- package/legacy/chat/f20ad9a51b06/src/app.ts +106 -0
- package/legacy/chat/f20ad9a51b06/src/routes.ts +15 -0
- package/legacy/layouts.json +524 -0
- package/legacy/nodejs/1d477f37aaf4/src/app.ts +92 -0
- package/legacy/nodejs/1d477f37aaf4/src/routes.ts +40 -0
- package/legacy/nodejs/b1bd9462496c/src/app.ts +61 -0
- package/legacy/nodejs/b1bd9462496c/src/routes.ts +37 -0
- package/legacy/nodejs/d5bab75076ef/src/app.ts +95 -0
- package/legacy/nodejs/d5bab75076ef/src/routes.ts +40 -0
- package/legacy/notes/4edc36949c15/src/app.ts +107 -0
- package/legacy/notes/4edc36949c15/src/routes.ts +14 -0
- package/legacy/notes/6cab2d09747c/src/app.ts +72 -0
- package/legacy/notes/6cab2d09747c/src/routes.ts +12 -0
- package/legacy/notes/7ca2dfed623a/src/app.ts +106 -0
- package/legacy/notes/7ca2dfed623a/src/routes.ts +14 -0
- package/legacy/todo/02e613b3625c/src/app.ts +71 -0
- package/legacy/todo/02e613b3625c/src/todo.ts +123 -0
- package/legacy/todo/45694a8d9e15/src/app.ts +71 -0
- package/legacy/todo/45694a8d9e15/src/todo.ts +96 -0
- package/legacy/todo/5923d3a6099f/src/app.ts +71 -0
- package/legacy/todo/5923d3a6099f/src/todo.ts +125 -0
- package/legacy/todo/849a18d66dc1/src/app.ts +91 -0
- package/legacy/todo/849a18d66dc1/src/todo.ts +125 -0
- package/legacy/todo/8e64211312f3/src/app.ts +71 -0
- package/legacy/todo/8e64211312f3/src/todo.ts +125 -0
- package/legacy/todo/b79078a87565/src/app.ts +71 -0
- package/legacy/todo/b79078a87565/src/todo.ts +125 -0
- package/lib/{audit-Dw-siYTp.d.ts → audit-Bok_UNCn.d.ts} +41 -6
- package/lib/{client-modules-drj4mjhj.js → client-modules-Bkdr83jO.js} +1 -1
- package/lib/cordis/door.d.ts +44 -7
- package/lib/cordis/door.js +214 -60
- package/lib/cordis/inspect.js +1 -1
- package/lib/cordis/node-server.d.ts +101 -3
- package/lib/cordis/node-server.js +3 -3
- package/lib/cordis/router-http.d.ts +7 -1
- package/lib/cordis/router-http.js +44 -3
- package/lib/cordis/router.d.ts +1 -1
- package/lib/cordis/router.js +1 -1
- package/lib/cordis/state.d.ts +3 -2
- package/lib/cordis/state.js +371 -7
- package/lib/door/host.d.ts +2 -2
- package/lib/door/host.js +1 -1
- package/lib/door/page.d.ts +20 -1
- package/lib/door/page.js +75 -14
- package/lib/door/protocol.d.ts +1 -1
- package/lib/drivers.d.ts +10 -1
- package/lib/{index-DMOtA-NJ.d.ts → index-DGq7H0lb.d.ts} +11 -1
- package/lib/node-server.d.ts +7 -1
- package/lib/node-server.js +2 -2
- package/lib/{protocol-a2aqrp2X.d.ts → protocol-BeIYCgTo.d.ts} +4 -0
- package/lib/{snapshot-text-BfEbEOjA.js → snapshot-text-CXTnjxGx.js} +121 -9
- package/lib/src-CtkIX_Gg.js +427 -0
- package/lib/{src-BTBm4OPD.js → src-iBJ2ysV9.js} +77 -14
- package/lib/{test-websocket-BLeEvybj.js → test-websocket-CQOSvBth.js} +17 -7
- package/lib/{test-websocket-DyeS50Tr.d.ts → test-websocket-GUTAkFQT.d.ts} +3 -1
- package/package.json +2 -1
- package/plugins/acryl-door/src/host/channel.ts +124 -9
- package/plugins/acryl-door/src/host/chat-tools.ts +8 -6
- package/plugins/acryl-door/src/host/door-file.ts +7 -2
- package/plugins/acryl-door/src/host/door.ts +58 -23
- package/plugins/acryl-door/src/host/page-routes.ts +3 -1
- package/plugins/acryl-door/src/host/page-socket.ts +18 -8
- package/plugins/acryl-door/src/host/verbs.ts +3 -0
- package/plugins/acryl-door/src/index.ts +166 -29
- package/plugins/acryl-door/src/page/base.ts +32 -0
- package/plugins/acryl-door/src/page/channel.ts +11 -1
- package/plugins/acryl-door/src/page/driver/accessibility.ts +11 -0
- package/plugins/acryl-door/src/page/driver/actions.ts +4 -0
- package/plugins/acryl-door/src/page/driver/driver.ts +49 -7
- package/plugins/acryl-door/src/page/driver/snapshot.ts +5 -2
- package/plugins/acryl-door/src/page/index.ts +46 -2
- package/plugins/acryl-door/src/page/indicator.ts +14 -6
- package/plugins/acryl-door/src/page/outline.ts +56 -0
- package/plugins/acryl-door/src/page/protocol.ts +4 -0
- package/plugins/acryl-door/src/page/snapshot-text.ts +2 -1
- package/plugins/acryl-node-server/src/adapter/listener.ts +4 -0
- package/plugins/acryl-node-server/src/adapter/raw.ts +18 -0
- package/plugins/acryl-node-server/src/adapter/websocket.ts +15 -9
- package/plugins/acryl-node-server/src/client-modules.ts +7 -1
- package/plugins/acryl-node-server/src/index.ts +304 -16
- package/plugins/acryl-node-server/src/upgrade-hub.ts +2 -2
- package/plugins/acryl-page/plugin.json +12 -0
- package/plugins/acryl-page/src/index.ts +163 -0
- package/plugins/acryl-router/src/service.ts +77 -14
- package/plugins/acryl-router-http/src/index.ts +15 -1
- package/plugins/acryl-router-http/src/serve.ts +31 -0
- package/plugins/acryl-state/plugin.json +4 -3
- package/plugins/acryl-state/src/client-source.ts +244 -0
- package/plugins/acryl-state/src/service.ts +33 -6
- package/plugins/acryl-state/src/socket.ts +96 -0
- package/plugins/acryl-web-server/plugin.json +14 -0
- package/plugins/acryl-web-server/src/index.ts +164 -0
- package/plugins/agent/agent.ts +38 -7
- package/plugins/agent/index.ts +1 -1
- package/plugins/agent/model.ts +10 -2
- package/plugins/agent/tools.ts +9 -3
- package/plugins/agent-panel/client/describe.ts +57 -0
- package/plugins/agent-panel/client/driver-pick.ts +22 -0
- package/plugins/agent-panel/client/frame.ts +51 -0
- package/plugins/agent-panel/client/main.ts +343 -81
- package/plugins/agent-panel/drivers.ts +7 -2
- package/plugins/agent-panel/headless.ts +5 -2
- package/plugins/agent-panel/index.ts +187 -61
- package/plugins/agent-panel/mount.ts +27 -0
- package/plugins/agent-panel/visitors.ts +231 -0
- package/plugins/desktop/frame.ts +231 -0
- package/plugins/desktop/launch.ts +44 -9
- package/plugins/desktop/main.ts +100 -32
- package/plugins/desktop/window.ts +21 -4
- package/plugins/driver-pty/client/terminal.ts +4 -1
- package/plugins/driver-pty/index.ts +38 -11
- package/plugins/driver-pty/spawn.ts +38 -0
- package/plugins/driver-pty/terminal.ts +2 -0
- package/plugins/extensions/index.ts +26 -1
- package/plugins/extensions/resolve.ts +13 -0
- package/plugins/feedback/client.d.ts +22 -0
- package/plugins/feedback/client.js +471 -0
- package/plugins/feedback/index.ts +143 -0
- package/plugins/feedback/plugin.json +11 -0
- package/plugins/feedback/store.ts +307 -0
- package/plugins/home/index.ts +122 -0
- package/plugins/home/plugin.json +11 -0
- package/plugins/instance/index.ts +12 -3
- package/plugins/manifest/app-plugins.ts +86 -0
- package/plugins/manifest/index.ts +12 -2
- package/plugins/manifest/snapshot.ts +275 -0
- package/plugins/openai/index.ts +64 -5
- package/plugins/openai/prompt.ts +23 -0
- package/shared/AGENTS.core.md +36 -16
- package/shared/skills/acryl-add-ui/SKILL.md +19 -0
- package/shared/skills/acryl-app/SKILL.md +13 -0
- package/shared/skills/acryl-build-plugin/SKILL.md +22 -0
- package/shared/skills/acryl-change-plugin/SKILL.md +14 -0
- package/shared/skills/acryl-diagnose-plugin/SKILL.md +13 -0
- package/shared/skills/acryl-fix-plugin/SKILL.md +13 -0
- package/shared/skills/acryl-remove-plugin/SKILL.md +12 -0
- package/shared/skills/acryl-share-plugin/SKILL.md +16 -0
- package/shared/skills/cordis-plugin/SKILL.md +26 -0
- package/templates/agent/AGENTS.md +4 -2
- package/templates/agent/blend.yaml +4 -0
- package/templates/agent/gitignore +1 -0
- package/templates/agent/package.json +1 -0
- package/templates/agent/src/app.ts +150 -33
- package/templates/agent/src/frame.ts +17 -0
- package/templates/agent/src/main.ts +5 -3
- package/templates/agent/src/routes.ts +73 -21
- package/templates/agent/template.json +2 -0
- package/templates/agent/test/app.test.ts +83 -5
- package/templates/blank/AGENTS.md +3 -2
- package/templates/blank/blend.yaml +11 -0
- package/templates/blank/gitignore +1 -0
- package/templates/blank/package.json +1 -0
- package/templates/blank/src/app.ts +100 -29
- package/templates/blank/src/door.ts +7 -0
- package/templates/blank/src/first-page.ts +5 -0
- package/templates/blank/src/frame.ts +17 -0
- package/templates/blank/src/routes.ts +65 -8
- package/templates/blank/template.json +14 -1
- package/templates/blank/test/app.test.ts +103 -16
- package/templates/chat/blend.yaml +2 -1
- package/templates/chat/gitignore +1 -0
- package/templates/chat/package.json +1 -0
- package/templates/chat/src/app.ts +72 -22
- package/templates/chat/src/chat-app.ts +11 -2
- package/templates/chat/src/client/chat.tsx +4 -2
- package/templates/chat/src/frame.ts +17 -0
- package/templates/chat/src/main.ts +1 -6
- package/templates/chat/src/page.tsx +6 -4
- package/templates/chat/src/routes.ts +14 -4
- package/templates/chat/src/runtime.ts +9 -3
- package/templates/chat/template.json +3 -1
- package/templates/chat/test/chat.test.ts +46 -1
- package/templates/chat/test/server.test.ts +17 -3
- package/templates/nodejs/blend.yaml +2 -1
- package/templates/nodejs/gitignore +1 -0
- package/templates/nodejs/package.json +1 -0
- package/templates/nodejs/src/app.ts +76 -21
- package/templates/nodejs/src/frame.ts +17 -0
- package/templates/nodejs/src/main.ts +1 -6
- package/templates/nodejs/src/page.tsx +3 -3
- package/templates/nodejs/src/routes.ts +32 -13
- package/templates/nodejs/template.json +3 -1
- package/templates/nodejs/test/app.test.ts +38 -3
- package/templates/nodejs/test/server.test.ts +17 -3
- package/templates/notes/blend.yaml +2 -1
- package/templates/notes/gitignore +1 -0
- package/templates/notes/package.json +1 -0
- package/templates/notes/src/app.ts +72 -22
- package/templates/notes/src/frame.ts +17 -0
- package/templates/notes/src/main.ts +1 -6
- package/templates/notes/src/notes-app.ts +24 -17
- package/templates/notes/src/routes.ts +11 -4
- package/templates/notes/src/runtime.ts +9 -3
- package/templates/notes/src/views.tsx +26 -17
- package/templates/notes/template.json +3 -1
- package/templates/notes/test/notes.test.ts +39 -1
- package/templates/notes/test/server.test.ts +17 -3
- package/templates/todo/blend.yaml +3 -0
- package/templates/todo/gitignore +1 -0
- package/templates/todo/package.json +1 -0
- package/templates/todo/src/app.ts +81 -27
- package/templates/todo/src/door.ts +7 -0
- package/templates/todo/src/frame.ts +17 -0
- package/templates/todo/src/page.ts +13 -6
- package/templates/todo/src/todo.ts +18 -6
- package/templates/todo/template.json +11 -1
- package/templates/todo/test/todo.test.ts +67 -9
- package/lib/src-LX0E4ec4.js +0 -117
- package/shared/SKILL.md +0 -14
|
@@ -0,0 +1,123 @@
|
|
|
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 { App, HTTPException } from './plugins/acryl-router/src/index.ts'
|
|
10
|
+
import { bodyLimit, loopbackOnly } from './plugins/acryl-router/src/middleware/index.ts'
|
|
11
|
+
import { schemaValidator, streamSSE, type StandardSchema } from './plugins/acryl-router/src/helpers/index.ts'
|
|
12
|
+
import type {} from './plugins/acryl-state/src/index.ts'
|
|
13
|
+
import { PAGE } from './page.ts'
|
|
14
|
+
|
|
15
|
+
/** Stable Cordis plugin name, equal to the package name and the Loader row id. */
|
|
16
|
+
export const name = 'acryl-todo'
|
|
17
|
+
export const inject = ['router', 'state']
|
|
18
|
+
|
|
19
|
+
export interface Todo { id: number, title: string, done: boolean }
|
|
20
|
+
export interface Config { file?: string }
|
|
21
|
+
|
|
22
|
+
/** Standard Schema for the body of a new todo, written by hand so the example has no schema library. */
|
|
23
|
+
const NewTodo: StandardSchema<{ title: string }> = {
|
|
24
|
+
'~standard': {
|
|
25
|
+
validate: value => {
|
|
26
|
+
const title = (value as { title?: unknown } | null)?.title
|
|
27
|
+
return typeof title === 'string' && title.trim() !== '' && title.length <= 200
|
|
28
|
+
? { value: { title: title.trim() } }
|
|
29
|
+
: { issues: [{ message: 'title must be 1 to 200 characters', path: ['title'] }] }
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Standard Schema for a change to a todo. */
|
|
35
|
+
const TodoChange: StandardSchema<{ done: boolean }> = {
|
|
36
|
+
'~standard': {
|
|
37
|
+
validate: value => {
|
|
38
|
+
const done = (value as { done?: unknown } | null)?.done
|
|
39
|
+
return typeof done === 'boolean' ? { value: { done } } : { issues: [{ message: 'done must be true or false', path: ['done'] }] }
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** A todo body is small; anything bigger is refused before it is read. */
|
|
45
|
+
const MAX_BODY_BYTES = 16 * 1024
|
|
46
|
+
|
|
47
|
+
/** A missing file is a new list. Any other failure (unreadable, corrupt, half-written) refuses to start: starting empty would overwrite the user's real list on the first save. */
|
|
48
|
+
function load(file: string | undefined): Todo[] {
|
|
49
|
+
if (file === undefined) return []
|
|
50
|
+
let text: string
|
|
51
|
+
try {
|
|
52
|
+
text = readFileSync(file, 'utf8')
|
|
53
|
+
} catch (error) {
|
|
54
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return []
|
|
55
|
+
throw new Error(`acryl-todo: cannot read ${file}: ${(error as Error).message}`, { cause: error })
|
|
56
|
+
}
|
|
57
|
+
try {
|
|
58
|
+
const parsed: unknown = JSON.parse(text)
|
|
59
|
+
if (!Array.isArray(parsed)) throw new Error('not a list')
|
|
60
|
+
return parsed as Todo[]
|
|
61
|
+
} catch (error) {
|
|
62
|
+
throw new Error(`acryl-todo: ${file} is not a valid todo list (${(error as Error).message}); fix or remove it, it was left untouched`, { cause: error })
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function save(file: string, todos: readonly Todo[]): void {
|
|
67
|
+
mkdirSync(dirname(file), { recursive: true })
|
|
68
|
+
const temporary = `${file}.tmp`
|
|
69
|
+
writeFileSync(temporary, JSON.stringify(todos))
|
|
70
|
+
renameSync(temporary, file)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function apply(ctx: Context, config: Config = {}): void {
|
|
74
|
+
const todos = ctx.state.cell<readonly Todo[]>('todo.list', load(config.file))
|
|
75
|
+
if (config.file !== undefined) {
|
|
76
|
+
const file = config.file
|
|
77
|
+
ctx.effect(() => todos.subscribe(list => { save(file, list) }), 'acryl-todo: save to file')
|
|
78
|
+
}
|
|
79
|
+
const nextId = (): number => todos.get().reduce((max, todo) => Math.max(max, todo.id), 0) + 1
|
|
80
|
+
|
|
81
|
+
const find = (id: number): Todo => {
|
|
82
|
+
const todo = todos.get().find(item => item.id === id)
|
|
83
|
+
if (todo === undefined) throw new HTTPException(404, { message: 'no such todo' })
|
|
84
|
+
return todo
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const api = new App()
|
|
88
|
+
.use(loopbackOnly({ inProcess: 'allow' }), bodyLimit({ maxSize: MAX_BODY_BYTES }))
|
|
89
|
+
.get('/', c => c.html(PAGE))
|
|
90
|
+
.get('/api', c => c.json(todos.get()))
|
|
91
|
+
.post('/api', schemaValidator('json', NewTodo), c => {
|
|
92
|
+
const todo: Todo = { id: nextId(), title: c.req.valid('json').title, done: false }
|
|
93
|
+
todos.update(list => [...list, todo])
|
|
94
|
+
return c.json(todo, 201)
|
|
95
|
+
})
|
|
96
|
+
.patch('/api/:id', schemaValidator('json', TodoChange), c => {
|
|
97
|
+
const { id } = find(Number(c.req.param('id')))
|
|
98
|
+
const { done } = c.req.valid('json')
|
|
99
|
+
todos.update(list => list.map(todo => todo.id === id ? { ...todo, done } : todo))
|
|
100
|
+
return c.json(find(id))
|
|
101
|
+
})
|
|
102
|
+
.delete('/api/:id', c => {
|
|
103
|
+
const { id } = find(Number(c.req.param('id')))
|
|
104
|
+
todos.update(list => list.filter(todo => todo.id !== id))
|
|
105
|
+
return c.body(null, 204)
|
|
106
|
+
})
|
|
107
|
+
.get('/events', c => streamSSE(c, async stream => {
|
|
108
|
+
const ended = new Promise<void>(resolve => { stream.onAbort(resolve) })
|
|
109
|
+
// Writes are asynchronous; chaining them keeps events in the order the list changed.
|
|
110
|
+
let queue: Promise<unknown> = Promise.resolve()
|
|
111
|
+
const send = (list: readonly Todo[]): void => { queue = queue.then(() => stream.writeSSE({ data: JSON.stringify(list) })).catch(() => {}) }
|
|
112
|
+
send(todos.get())
|
|
113
|
+
const unsubscribe = todos.subscribe(send)
|
|
114
|
+
try {
|
|
115
|
+
await ended
|
|
116
|
+
} finally {
|
|
117
|
+
unsubscribe()
|
|
118
|
+
}
|
|
119
|
+
}))
|
|
120
|
+
ctx.router.route('/todo', api)
|
|
121
|
+
|
|
122
|
+
ctx.inject(['routerHttp'], inner => { inner.routerHttp.mount('/todo') })
|
|
123
|
+
}
|
|
@@ -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,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,71 @@
|
|
|
1
|
+
import { join } from 'node:path'
|
|
2
|
+
import { fileURLToPath } from 'node:url'
|
|
3
|
+
import { Context, type Fiber } from '@deepseek-ai/cordis'
|
|
4
|
+
import * as nodeServer from './plugins/acryl-node-server/src/index.ts'
|
|
5
|
+
import * as router from './plugins/acryl-router/src/index.ts'
|
|
6
|
+
import * as state from './plugins/acryl-state/src/index.ts'
|
|
7
|
+
import { type AppInstance, instance } from './plugins/instance/index.ts'
|
|
8
|
+
import {
|
|
9
|
+
blueprintsIn,
|
|
10
|
+
compose,
|
|
11
|
+
loadManifest,
|
|
12
|
+
type ParameterValue,
|
|
13
|
+
} from './plugins/manifest/index.ts'
|
|
14
|
+
import * as todo from './todo.ts'
|
|
15
|
+
|
|
16
|
+
/** The plugins this app's code provides. `blend.yaml` chooses, orders and configures them. */
|
|
17
|
+
export const registry = {
|
|
18
|
+
'acryl-router': router,
|
|
19
|
+
'acryl-state': state,
|
|
20
|
+
todo,
|
|
21
|
+
'acryl-node-server': nodeServer,
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export const APP_NAME = '__APP_NAME__'
|
|
25
|
+
|
|
26
|
+
/** The manifest next to this app's source. */
|
|
27
|
+
export const MANIFEST = join(fileURLToPath(new URL('..', import.meta.url)), 'blend.yaml')
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The app's manifest, resolved. A Blend finds its Blueprint in `blueprints/<id>.yaml`; an app that is itself a Blueprint never looks.
|
|
31
|
+
* `createApp` and the tests read it the same way.
|
|
32
|
+
*/
|
|
33
|
+
export function readManifest(values?: Readonly<Record<string, ParameterValue>>) {
|
|
34
|
+
return loadManifest(MANIFEST, {
|
|
35
|
+
known: Object.keys(registry),
|
|
36
|
+
getBlueprint: blueprintsIn(join(fileURLToPath(new URL('..', import.meta.url)), 'blueprints')),
|
|
37
|
+
...(values ? { values } : {}),
|
|
38
|
+
})
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface App {
|
|
42
|
+
readonly root: Context
|
|
43
|
+
readonly url: string
|
|
44
|
+
stop(): Promise<void>
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The app: `instance` first (where it lives, chosen at run time), then the manifest's rows in order. */
|
|
48
|
+
export async function createApp(
|
|
49
|
+
chosen: AppInstance,
|
|
50
|
+
options: { values?: Readonly<Record<string, ParameterValue>> } = {},
|
|
51
|
+
): Promise<App> {
|
|
52
|
+
const manifest = readManifest(options.values)
|
|
53
|
+
const root = new Context()
|
|
54
|
+
const first = root.plugin(instance, chosen)
|
|
55
|
+
await first.await()
|
|
56
|
+
const fibers: Fiber[] = [
|
|
57
|
+
first,
|
|
58
|
+
...(await compose(root, manifest, registry, {
|
|
59
|
+
// Runtime values the manifest cannot hold: where the list is kept (in the app's home) and where the server listens.
|
|
60
|
+
todo: { file: join(chosen.home, 'todo.json') },
|
|
61
|
+
server: { port: chosen.port.start, scan: chosen.port.scan ? 20 : 0 },
|
|
62
|
+
})),
|
|
63
|
+
]
|
|
64
|
+
return {
|
|
65
|
+
root,
|
|
66
|
+
url: root.nodeServer.url,
|
|
67
|
+
async stop() {
|
|
68
|
+
for (const fiber of [...fibers].reverse()) await fiber.dispose()
|
|
69
|
+
},
|
|
70
|
+
}
|
|
71
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The reference app of the ACRYL Framework: a todo list in one plugin. It needs `router` and `state`, keeps the list in a cell, serves
|
|
3
|
+
* JSON CRUD plus a page, and writes the list to a file so it survives a restart. Nothing else.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
|
|
7
|
+
import { dirname } from 'node:path'
|
|
8
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
9
|
+
import { App, HTTPException } from './plugins/acryl-router/src/index.ts'
|
|
10
|
+
import { bodyLimit, loopbackOnly, trimTrailingSlash } from './plugins/acryl-router/src/middleware/index.ts'
|
|
11
|
+
import { schemaValidator, streamSSE, type StandardSchema } from './plugins/acryl-router/src/helpers/index.ts'
|
|
12
|
+
import type {} from './plugins/acryl-state/src/index.ts'
|
|
13
|
+
import { PAGE } from './page.ts'
|
|
14
|
+
|
|
15
|
+
/** Stable Cordis plugin name, equal to the package name and the Loader row id. */
|
|
16
|
+
export const name = 'acryl-todo'
|
|
17
|
+
export const inject = ['router', 'state']
|
|
18
|
+
|
|
19
|
+
export interface Todo { id: number, title: string, done: boolean }
|
|
20
|
+
export interface Config { file?: string }
|
|
21
|
+
|
|
22
|
+
/** Standard Schema for the body of a new todo, written by hand so the example has no schema library. */
|
|
23
|
+
const NewTodo: StandardSchema<{ title: string }> = {
|
|
24
|
+
'~standard': {
|
|
25
|
+
validate: value => {
|
|
26
|
+
const title = (value as { title?: unknown } | null)?.title
|
|
27
|
+
return typeof title === 'string' && title.trim() !== '' && title.length <= 200
|
|
28
|
+
? { value: { title: title.trim() } }
|
|
29
|
+
: { issues: [{ message: 'title must be 1 to 200 characters', path: ['title'] }] }
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Standard Schema for a change to a todo. */
|
|
35
|
+
const TodoChange: StandardSchema<{ done: boolean }> = {
|
|
36
|
+
'~standard': {
|
|
37
|
+
validate: value => {
|
|
38
|
+
const done = (value as { done?: unknown } | null)?.done
|
|
39
|
+
return typeof done === 'boolean' ? { value: { done } } : { issues: [{ message: 'done must be true or false', path: ['done'] }] }
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** A todo body is small; anything bigger is refused before it is read. */
|
|
45
|
+
const MAX_BODY_BYTES = 16 * 1024
|
|
46
|
+
|
|
47
|
+
/** A missing file is a new list. Any other failure (unreadable, corrupt, half-written) refuses to start: starting empty would overwrite the user's real list on the first save. */
|
|
48
|
+
function load(file: string | undefined): Todo[] {
|
|
49
|
+
if (file === undefined) return []
|
|
50
|
+
let text: string
|
|
51
|
+
try {
|
|
52
|
+
text = readFileSync(file, 'utf8')
|
|
53
|
+
} catch (error) {
|
|
54
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return []
|
|
55
|
+
throw new Error(`acryl-todo: cannot read ${file}: ${(error as Error).message}`, { cause: error })
|
|
56
|
+
}
|
|
57
|
+
try {
|
|
58
|
+
const parsed: unknown = JSON.parse(text)
|
|
59
|
+
if (!Array.isArray(parsed)) throw new Error('not a list')
|
|
60
|
+
return parsed as Todo[]
|
|
61
|
+
} catch (error) {
|
|
62
|
+
throw new Error(`acryl-todo: ${file} is not a valid todo list (${(error as Error).message}); fix or remove it, it was left untouched`, { cause: error })
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function save(file: string, todos: readonly Todo[]): void {
|
|
67
|
+
mkdirSync(dirname(file), { recursive: true })
|
|
68
|
+
const temporary = `${file}.tmp`
|
|
69
|
+
writeFileSync(temporary, JSON.stringify(todos))
|
|
70
|
+
renameSync(temporary, file)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function apply(ctx: Context, config: Config = {}): void {
|
|
74
|
+
const todos = ctx.state.cell<readonly Todo[]>('todo.list', load(config.file))
|
|
75
|
+
if (config.file !== undefined) {
|
|
76
|
+
const file = config.file
|
|
77
|
+
ctx.effect(() => todos.subscribe(list => { save(file, list) }), 'acryl-todo: save to file')
|
|
78
|
+
}
|
|
79
|
+
const nextId = (): number => todos.get().reduce((max, todo) => Math.max(max, todo.id), 0) + 1
|
|
80
|
+
|
|
81
|
+
const find = (id: number): Todo => {
|
|
82
|
+
const todo = todos.get().find(item => item.id === id)
|
|
83
|
+
if (todo === undefined) throw new HTTPException(404, { message: 'no such todo' })
|
|
84
|
+
return todo
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// `/todo/` is what people type: trimTrailingSlash answers a GET for an unknown path ending in `/` with a 301 to the path without it,
|
|
88
|
+
// so the page is found either way while each route keeps one canonical path (the router itself treats `/a` and `/a/` as different).
|
|
89
|
+
const api = new App()
|
|
90
|
+
.use(loopbackOnly({ inProcess: 'allow' }), bodyLimit({ maxSize: MAX_BODY_BYTES }), trimTrailingSlash())
|
|
91
|
+
.get('/', c => c.html(PAGE))
|
|
92
|
+
.get('/api', c => c.json(todos.get()))
|
|
93
|
+
.post('/api', schemaValidator('json', NewTodo), c => {
|
|
94
|
+
const todo: Todo = { id: nextId(), title: c.req.valid('json').title, done: false }
|
|
95
|
+
todos.update(list => [...list, todo])
|
|
96
|
+
return c.json(todo, 201)
|
|
97
|
+
})
|
|
98
|
+
.patch('/api/:id', schemaValidator('json', TodoChange), c => {
|
|
99
|
+
const { id } = find(Number(c.req.param('id')))
|
|
100
|
+
const { done } = c.req.valid('json')
|
|
101
|
+
todos.update(list => list.map(todo => todo.id === id ? { ...todo, done } : todo))
|
|
102
|
+
return c.json(find(id))
|
|
103
|
+
})
|
|
104
|
+
.delete('/api/:id', c => {
|
|
105
|
+
const { id } = find(Number(c.req.param('id')))
|
|
106
|
+
todos.update(list => list.filter(todo => todo.id !== id))
|
|
107
|
+
return c.body(null, 204)
|
|
108
|
+
})
|
|
109
|
+
.get('/events', c => streamSSE(c, async stream => {
|
|
110
|
+
const ended = new Promise<void>(resolve => { stream.onAbort(resolve) })
|
|
111
|
+
// Writes are asynchronous; chaining them keeps events in the order the list changed.
|
|
112
|
+
let queue: Promise<unknown> = Promise.resolve()
|
|
113
|
+
const send = (list: readonly Todo[]): void => { queue = queue.then(() => stream.writeSSE({ data: JSON.stringify(list) })).catch(() => {}) }
|
|
114
|
+
send(todos.get())
|
|
115
|
+
const unsubscribe = todos.subscribe(send)
|
|
116
|
+
try {
|
|
117
|
+
await ended
|
|
118
|
+
} finally {
|
|
119
|
+
unsubscribe()
|
|
120
|
+
}
|
|
121
|
+
}))
|
|
122
|
+
ctx.router.route('/todo', api)
|
|
123
|
+
|
|
124
|
+
ctx.inject(['routerHttp'], inner => { inner.routerHttp.mount('/todo') })
|
|
125
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
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
|
+
/** The manifest without its server row: the app is then reached in process, through `root.router.request`. */
|
|
42
|
+
function withoutServer<M extends { plugins: readonly { use: string }[] }>(manifest: M): M {
|
|
43
|
+
return {
|
|
44
|
+
...manifest,
|
|
45
|
+
plugins: manifest.plugins.map((row) =>
|
|
46
|
+
row.use === 'acryl-node-server' ? { ...row, disabled: true } : row,
|
|
47
|
+
),
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface App {
|
|
52
|
+
readonly root: Context
|
|
53
|
+
/** The address the server listens on; empty when the app was made with `listen: false`. */
|
|
54
|
+
readonly url: string
|
|
55
|
+
stop(): Promise<void>
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The app: `instance` first (where it lives, chosen at run time), then the manifest's rows in order. */
|
|
59
|
+
export async function createApp(
|
|
60
|
+
chosen: AppInstance,
|
|
61
|
+
options: {
|
|
62
|
+
values?: Readonly<Record<string, ParameterValue>>
|
|
63
|
+
/** 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. */
|
|
64
|
+
listen?: boolean
|
|
65
|
+
} = {},
|
|
66
|
+
): Promise<App> {
|
|
67
|
+
const manifest = readManifest(options.values)
|
|
68
|
+
const root = new Context()
|
|
69
|
+
const first = root.plugin(instance, chosen)
|
|
70
|
+
await first.await()
|
|
71
|
+
const fibers: Fiber[] = [
|
|
72
|
+
first,
|
|
73
|
+
...(await compose(
|
|
74
|
+
root,
|
|
75
|
+
options.listen === false ? withoutServer(manifest) : manifest,
|
|
76
|
+
registry,
|
|
77
|
+
{
|
|
78
|
+
// Runtime values the manifest cannot hold: where the list is kept (in the app's home) and where the server listens.
|
|
79
|
+
todo: { file: join(chosen.home, 'todo.json') },
|
|
80
|
+
server: { port: chosen.port.start, scan: chosen.port.scan ? 20 : 0 },
|
|
81
|
+
},
|
|
82
|
+
)),
|
|
83
|
+
]
|
|
84
|
+
return {
|
|
85
|
+
root,
|
|
86
|
+
url: options.listen === false ? '' : root.nodeServer.url,
|
|
87
|
+
async stop() {
|
|
88
|
+
for (const fiber of [...fibers].reverse()) await fiber.dispose()
|
|
89
|
+
},
|
|
90
|
+
}
|
|
91
|
+
}
|