acrylblends 0.0.0-stage → 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 (144) hide show
  1. package/bin/acrylblends.js +4 -0
  2. package/dist/index.d.ts +118 -0
  3. package/dist/index.js +454 -0
  4. package/dist/index.js.map +1 -0
  5. package/package.json +50 -4
  6. package/plugins/acryl-inspect/LICENSE +21 -0
  7. package/plugins/acryl-inspect/plugin.json +11 -0
  8. package/plugins/acryl-inspect/src/index.ts +3 -0
  9. package/plugins/acryl-inspect/src/report.ts +123 -0
  10. package/plugins/acryl-inspect/src/service.ts +66 -0
  11. package/plugins/acryl-inspect/tests/report.spec.ts +64 -0
  12. package/plugins/acryl-inspect/tests/service.spec.ts +120 -0
  13. package/plugins/acryl-inspect/upstream.json +18 -0
  14. package/plugins/acryl-node-server/LICENSE +21 -0
  15. package/plugins/acryl-node-server/plugin.json +12 -0
  16. package/plugins/acryl-node-server/src/index.ts +94 -0
  17. package/plugins/acryl-node-server/tests/server.spec.ts +105 -0
  18. package/plugins/acryl-node-server/upstream.json +18 -0
  19. package/plugins/acryl-router/LICENSE +21 -0
  20. package/plugins/acryl-router/plugin.json +12 -0
  21. package/plugins/acryl-router/src/compose.ts +21 -0
  22. package/plugins/acryl-router/src/context.ts +59 -0
  23. package/plugins/acryl-router/src/helpers/index.ts +3 -0
  24. package/plugins/acryl-router/src/helpers/read-body.ts +59 -0
  25. package/plugins/acryl-router/src/helpers/sse.ts +83 -0
  26. package/plugins/acryl-router/src/helpers/validate.ts +44 -0
  27. package/plugins/acryl-router/src/http-error.ts +21 -0
  28. package/plugins/acryl-router/src/index.ts +6 -0
  29. package/plugins/acryl-router/src/middleware/body-limit.ts +25 -0
  30. package/plugins/acryl-router/src/middleware/cors.ts +46 -0
  31. package/plugins/acryl-router/src/middleware/index.ts +6 -0
  32. package/plugins/acryl-router/src/middleware/logger.ts +21 -0
  33. package/plugins/acryl-router/src/middleware/loopback-only.ts +82 -0
  34. package/plugins/acryl-router/src/middleware/request-id.ts +24 -0
  35. package/plugins/acryl-router/src/middleware/secure-headers.ts +23 -0
  36. package/plugins/acryl-router/src/pattern.ts +89 -0
  37. package/plugins/acryl-router/src/registrar.ts +90 -0
  38. package/plugins/acryl-router/src/service.ts +164 -0
  39. package/plugins/acryl-router/src/table.ts +156 -0
  40. package/plugins/acryl-router/src/types.ts +73 -0
  41. package/plugins/acryl-router/src/util.ts +23 -0
  42. package/plugins/acryl-router/tests/api.spec.ts +223 -0
  43. package/plugins/acryl-router/tests/conformance.ts +170 -0
  44. package/plugins/acryl-router/tests/helpers.spec.ts +172 -0
  45. package/plugins/acryl-router/tests/live-install.spec.ts +64 -0
  46. package/plugins/acryl-router/tests/loader.spec.ts +71 -0
  47. package/plugins/acryl-router/tests/middleware.spec.ts +183 -0
  48. package/plugins/acryl-router/tests/service.spec.ts +211 -0
  49. package/plugins/acryl-router/tests/table.spec.ts +47 -0
  50. package/plugins/acryl-router/upstream.json +20 -0
  51. package/plugins/acryl-router-http/LICENSE +21 -0
  52. package/plugins/acryl-router-http/plugin.json +11 -0
  53. package/plugins/acryl-router-http/src/bridge.ts +52 -0
  54. package/plugins/acryl-router-http/src/index.ts +63 -0
  55. package/plugins/acryl-router-http/src/serve.ts +24 -0
  56. package/plugins/acryl-router-http/tests/adapter.spec.ts +170 -0
  57. package/plugins/acryl-router-http/tests/stream.spec.ts +133 -0
  58. package/plugins/acryl-router-http/tests/surfaces.spec.ts +99 -0
  59. package/plugins/acryl-router-http/upstream.json +22 -0
  60. package/plugins/acryl-state/LICENSE +21 -0
  61. package/plugins/acryl-state/plugin.json +12 -0
  62. package/plugins/acryl-state/src/cell.ts +67 -0
  63. package/plugins/acryl-state/src/index.ts +3 -0
  64. package/plugins/acryl-state/src/react.ts +11 -0
  65. package/plugins/acryl-state/src/service.ts +101 -0
  66. package/plugins/acryl-state/tests/cell.spec.ts +71 -0
  67. package/plugins/acryl-state/tests/react.spec.ts +39 -0
  68. package/plugins/acryl-state/tests/service.spec.ts +132 -0
  69. package/plugins/acryl-state/upstream.json +20 -0
  70. package/plugins/agent/agent.ts +152 -0
  71. package/plugins/agent/index.ts +20 -0
  72. package/plugins/agent/memory.ts +24 -0
  73. package/plugins/agent/model.ts +79 -0
  74. package/plugins/agent/plugin.json +10 -0
  75. package/plugins/agent/tools.ts +50 -0
  76. package/plugins/builder/index.ts +193 -0
  77. package/plugins/builder/plugin.json +11 -0
  78. package/plugins/builder/script.ts +161 -0
  79. package/plugins/extensions/index.ts +755 -0
  80. package/plugins/extensions/ledger.ts +39 -0
  81. package/plugins/extensions/plugin.json +12 -0
  82. package/plugins/extensions/resolve.ts +30 -0
  83. package/plugins/extensions/stage.ts +44 -0
  84. package/plugins/instance/index.ts +167 -0
  85. package/plugins/instance/plugin.json +10 -0
  86. package/plugins/manifest/index.ts +635 -0
  87. package/plugins/manifest/lock.ts +94 -0
  88. package/plugins/manifest/plugin.json +13 -0
  89. package/plugins/manifest/upgrade-cli.ts +70 -0
  90. package/plugins/manifest/upgrade.ts +97 -0
  91. package/plugins/openai/index.ts +159 -0
  92. package/plugins/openai/plugin.json +11 -0
  93. package/plugins/openai/prompt.ts +35 -0
  94. package/plugins/sandbox/index.ts +150 -0
  95. package/plugins/sandbox/plugin.json +8 -0
  96. package/plugins/sandbox/runner.mjs +18 -0
  97. package/plugins/save/cli.ts +19 -0
  98. package/plugins/save/index.ts +55 -0
  99. package/plugins/save/plugin.json +12 -0
  100. package/plugins/settings/index.ts +270 -0
  101. package/plugins/settings/merge.ts +79 -0
  102. package/plugins/settings/plugin.json +13 -0
  103. package/plugins/tasks/index.ts +246 -0
  104. package/plugins/tasks/plugin.json +11 -0
  105. package/plugins/tasks/store.ts +86 -0
  106. package/plugins/tsconfig.json +27 -0
  107. package/templates/agent/Dockerfile +15 -0
  108. package/templates/agent/README.md +52 -0
  109. package/templates/agent/blend.yaml +56 -0
  110. package/templates/agent/dockerignore +3 -0
  111. package/templates/agent/gitignore +2 -0
  112. package/templates/agent/package.json +23 -0
  113. package/templates/agent/src/app.ts +98 -0
  114. package/templates/agent/src/main.ts +25 -0
  115. package/templates/agent/src/routes.ts +53 -0
  116. package/templates/agent/template.json +16 -0
  117. package/templates/agent/test/agent.test.ts +45 -0
  118. package/templates/agent/test/app.test.ts +46 -0
  119. package/templates/agent/test/builder.test.ts +194 -0
  120. package/templates/agent/test/model.test.ts +88 -0
  121. package/templates/agent/tsconfig.json +19 -0
  122. package/templates/blank/README.md +26 -0
  123. package/templates/blank/blend.yaml +23 -0
  124. package/templates/blank/gitignore +2 -0
  125. package/templates/blank/package.json +23 -0
  126. package/templates/blank/src/app.ts +61 -0
  127. package/templates/blank/src/main.ts +20 -0
  128. package/templates/blank/src/routes.ts +14 -0
  129. package/templates/blank/template.json +1 -0
  130. package/templates/blank/test/app.test.ts +56 -0
  131. package/templates/blank/tsconfig.json +19 -0
  132. package/templates/todo/README.md +27 -0
  133. package/templates/todo/blend.yaml +25 -0
  134. package/templates/todo/gitignore +2 -0
  135. package/templates/todo/package.json +23 -0
  136. package/templates/todo/src/app.ts +71 -0
  137. package/templates/todo/src/main.ts +19 -0
  138. package/templates/todo/src/page.ts +28 -0
  139. package/templates/todo/src/todo.ts +96 -0
  140. package/templates/todo/template.json +1 -0
  141. package/templates/todo/test/todo.test.ts +145 -0
  142. package/templates/todo/tsconfig.json +19 -0
  143. package/templates/todo/upstream.json +12 -0
  144. package/README.md +0 -3
@@ -0,0 +1,56 @@
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 } from '../src/app.ts'
8
+ import { pinnedInstance, withPort } from '../src/plugins/instance/index.ts'
9
+ import { APP_NAME } from '../src/routes.ts'
10
+
11
+ const projectRoot = fileURLToPath(new URL('..', import.meta.url))
12
+
13
+ /** Boot the app in a throwaway home on any free port, run the test, always stop it and remove the home. */
14
+ 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))
17
+ try {
18
+ await run(app)
19
+ } finally {
20
+ await app.stop()
21
+ await rm(home, { recursive: true, force: true })
22
+ }
23
+ }
24
+
25
+ test('serves the index route', () =>
26
+ withApp(async (app) => {
27
+ const response = await fetch(`${app.url}/`)
28
+ assert.equal(response.status, 200)
29
+ assert.deepEqual(await response.json(), { app: APP_NAME, ok: true })
30
+ }))
31
+
32
+ test('answers health checks and 404 for unknown paths', () =>
33
+ withApp(async (app) => {
34
+ assert.equal((await fetch(`${app.url}/health`)).status, 200)
35
+ const missing = await fetch(`${app.url}/nope`)
36
+ assert.equal(missing.status, 404)
37
+ await missing.body?.cancel()
38
+ }))
39
+
40
+ test('stops listening when stopped', async () => {
41
+ const home = await mkdtemp(join(tmpdir(), 'app-test-'))
42
+ const app = await createApp(withPort(pinnedInstance(home, { root: projectRoot }), 0))
43
+ await app.stop()
44
+ await assert.rejects(fetch(`${app.url}/health`))
45
+ await rm(home, { recursive: true, force: true })
46
+ })
47
+
48
+ test('the manifest decides what is mounted: a parameter changes the server, an unknown value is refused', async () => {
49
+ const { readManifest } = await import('../src/app.ts')
50
+ const manifest = readManifest()
51
+ assert.deepEqual(
52
+ manifest.plugins.map((row) => row.id),
53
+ ['router', 'routes', 'server'],
54
+ )
55
+ assert.throws(() => readManifest({ nothing: 1 }), /parameter-override-unknown/)
56
+ })
@@ -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,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
+ }
package/README.md DELETED
@@ -1,3 +0,0 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.