create-acrylblends 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. package/README.md +61 -2
  2. package/bin/create-acrylblends.js +4 -0
  3. package/dist/index.d.ts +56 -0
  4. package/dist/index.js +274 -0
  5. package/dist/index.js.map +1 -0
  6. package/docs/framework/LICENSE +23 -0
  7. package/docs/framework/README.md +63 -0
  8. package/docs/framework/UPSTREAM.md +3 -0
  9. package/docs/framework/agents/index.md +24 -0
  10. package/docs/framework/api/acryl.md +259 -0
  11. package/docs/framework/api/context.md +527 -0
  12. package/docs/framework/api/exception.md +80 -0
  13. package/docs/framework/api/index.md +12 -0
  14. package/docs/framework/api/presets.md +65 -0
  15. package/docs/framework/api/request.md +396 -0
  16. package/docs/framework/api/routing.md +303 -0
  17. package/docs/framework/api/summary.md +140 -0
  18. package/docs/framework/concepts/benchmarks.md +26 -0
  19. package/docs/framework/concepts/developer-experience.md +5 -0
  20. package/docs/framework/concepts/middleware.md +21 -0
  21. package/docs/framework/concepts/motivation.md +14 -0
  22. package/docs/framework/concepts/routers.md +63 -0
  23. package/docs/framework/concepts/stacks.md +235 -0
  24. package/docs/framework/concepts/web-standard.md +37 -0
  25. package/docs/framework/cordis/acryl-in-a-cordis-app.md +146 -0
  26. package/docs/framework/cordis/concepts.md +105 -0
  27. package/docs/framework/cordis/middleware.md +8 -0
  28. package/docs/framework/cordis/migration-from-0-3.md +111 -0
  29. package/docs/framework/cordis/migration-from-0-4.md +14 -0
  30. package/docs/framework/cordis/migration-from-dsh-webserver.md +109 -0
  31. package/docs/framework/cordis/mounting.md +85 -0
  32. package/docs/framework/cordis/node-server.md +75 -0
  33. package/docs/framework/cordis/plugin-lifecycle.md +111 -0
  34. package/docs/framework/cordis/surfaces.md +71 -0
  35. package/docs/framework/cordis/transport-and-cancellation.md +57 -0
  36. package/docs/framework/cordis/validation.md +135 -0
  37. package/docs/framework/getting-started/basic.md +196 -0
  38. package/docs/framework/getting-started/bun.md +221 -0
  39. package/docs/framework/getting-started/nodejs.md +344 -0
  40. package/docs/framework/getting-started/other-runtimes.md +23 -0
  41. package/docs/framework/guides/best-practices.md +231 -0
  42. package/docs/framework/guides/create-acrylblends.md +79 -0
  43. package/docs/framework/guides/examples.md +3 -0
  44. package/docs/framework/guides/faq.md +19 -0
  45. package/docs/framework/guides/helpers.md +33 -0
  46. package/docs/framework/guides/jsx-dom.md +299 -0
  47. package/docs/framework/guides/jsx.md +440 -0
  48. package/docs/framework/guides/middleware.md +253 -0
  49. package/docs/framework/guides/rpc.md +886 -0
  50. package/docs/framework/guides/testing.md +133 -0
  51. package/docs/framework/guides/validation.md +383 -0
  52. package/docs/framework/helpers/accepts.md +60 -0
  53. package/docs/framework/helpers/adapter.md +87 -0
  54. package/docs/framework/helpers/conninfo.md +112 -0
  55. package/docs/framework/helpers/cookie.md +232 -0
  56. package/docs/framework/helpers/css.md +285 -0
  57. package/docs/framework/helpers/dev.md +67 -0
  58. package/docs/framework/helpers/factory.md +180 -0
  59. package/docs/framework/helpers/html.md +123 -0
  60. package/docs/framework/helpers/jwt.md +184 -0
  61. package/docs/framework/helpers/proxy.md +118 -0
  62. package/docs/framework/helpers/route.md +143 -0
  63. package/docs/framework/helpers/ssg.md +393 -0
  64. package/docs/framework/helpers/streaming.md +119 -0
  65. package/docs/framework/helpers/testing.md +101 -0
  66. package/docs/framework/helpers/websocket.md +215 -0
  67. package/docs/framework/middleware/builtin/basic-auth.md +165 -0
  68. package/docs/framework/middleware/builtin/bearer-auth.md +138 -0
  69. package/docs/framework/middleware/builtin/body-limit.md +71 -0
  70. package/docs/framework/middleware/builtin/cache.md +122 -0
  71. package/docs/framework/middleware/builtin/combine.md +94 -0
  72. package/docs/framework/middleware/builtin/compress.md +59 -0
  73. package/docs/framework/middleware/builtin/context-storage.md +78 -0
  74. package/docs/framework/middleware/builtin/cors.md +135 -0
  75. package/docs/framework/middleware/builtin/csrf.md +105 -0
  76. package/docs/framework/middleware/builtin/etag.md +54 -0
  77. package/docs/framework/middleware/builtin/ip-restriction.md +69 -0
  78. package/docs/framework/middleware/builtin/jsx-renderer.md +286 -0
  79. package/docs/framework/middleware/builtin/jwk.md +192 -0
  80. package/docs/framework/middleware/builtin/jwt.md +145 -0
  81. package/docs/framework/middleware/builtin/language.md +232 -0
  82. package/docs/framework/middleware/builtin/logger.md +74 -0
  83. package/docs/framework/middleware/builtin/loopback-only.md +31 -0
  84. package/docs/framework/middleware/builtin/method-not-allowed.md +48 -0
  85. package/docs/framework/middleware/builtin/method-override.md +80 -0
  86. package/docs/framework/middleware/builtin/mount.md +106 -0
  87. package/docs/framework/middleware/builtin/pretty-json.md +53 -0
  88. package/docs/framework/middleware/builtin/request-id.md +94 -0
  89. package/docs/framework/middleware/builtin/secure-headers.md +247 -0
  90. package/docs/framework/middleware/builtin/timeout.md +98 -0
  91. package/docs/framework/middleware/builtin/timing.md +101 -0
  92. package/docs/framework/middleware/builtin/trailing-slash.md +74 -0
  93. package/docs/framework/middleware/third-party.md +4 -0
  94. package/docs/framework/upstream.json +9 -0
  95. package/package.json +47 -3
  96. package/templates/bun/gitignore +1 -0
  97. package/templates/bun/package.json +19 -0
  98. package/templates/bun/src/index.ts +7 -0
  99. package/templates/bun/test/app.test.ts +8 -0
  100. package/templates/bun/tsconfig.json +15 -0
  101. package/templates/nodejs/gitignore +1 -0
  102. package/templates/nodejs/package.json +22 -0
  103. package/templates/nodejs/src/app.ts +7 -0
  104. package/templates/nodejs/src/index.ts +9 -0
  105. package/templates/nodejs/test/app.test.ts +9 -0
  106. package/templates/nodejs/tsconfig.json +16 -0
@@ -0,0 +1,146 @@
1
+ # Acryl in a Cordis app
2
+
3
+ The ACRYL Framework puts its application model inside Cordis. You build an ordinary `Acryl`, register routes and middleware on it, then
4
+ mount that app from a Cordis plugin.
5
+
6
+ ## The mental model
7
+
8
+ There are two layers:
9
+
10
+ 1. **Application layer** - `Acryl`, handlers, middleware, context, requests, responses, validation and helpers. This is the ACRYL
11
+ application API.
12
+ 2. **Cordis layer** - plugins, services, injection, effects and disposal. This decides when an application exists and which surface can
13
+ reach it.
14
+
15
+ A route therefore has two owners. The `Acryl` owns the route table. The plugin that mounted the app owns the mount for as long as its
16
+ Cordis fiber is active.
17
+
18
+ ## A minimal plugin
19
+
20
+ ```ts
21
+ import type { Context } from '@deepseek-ai/cordis'
22
+ import { Acryl } from 'acrylblends'
23
+
24
+ export const name = 'todo' // equals the package name and the Loader row id
25
+ export const inject = ['router'] // PENDING until a router exists
26
+
27
+ export function apply(ctx: Context): void {
28
+ const app = new Acryl()
29
+ .get('/', c => c.json({ ok: true }))
30
+ .get('/todos/:id', c => c.json({ id: c.req.param('id') }))
31
+
32
+ ctx.router.route('/todo', app)
33
+ // Over HTTP only where the Host's adapter exists; the terminal UI and tests have no such service and skip this.
34
+ ctx.inject(['routerHttp'], inner => { inner.routerHttp.mount('/todo') })
35
+ }
36
+ ```
37
+
38
+ `ctx.router.route('/todo', app)` serves the app below `/todo`:
39
+
40
+ | Request | Answer |
41
+ | --- | --- |
42
+ | `GET /todo` | `{ "ok": true }` |
43
+ | `GET /todo/todos/42` | `{ "id": "42" }` |
44
+ | `GET /todo/` | 404: `/todo` and `/todo/` are different paths (see below) |
45
+
46
+ ### `/todo/` is a 404 until you choose
47
+
48
+ `/todo/` is the first thing people type, and by default it is not found: the router treats `/todo` and `/todo/` as two paths. Choose
49
+ one of these, in this order of preference:
50
+
51
+ - **Redirect in your app** (what `examples/acryl-todo` does): add `trimTrailingSlash()` from `acrylblends/trailing-slash` to the app.
52
+ A GET or HEAD that would be a 404 and ends in `/` gets a 301 to the same path without it, so `/todo/` reaches the page and every
53
+ route keeps one canonical path. Other methods are not redirected: `POST /todo/api/` stays a 404.
54
+
55
+ ```ts
56
+ import { trimTrailingSlash } from 'acrylblends/trailing-slash'
57
+
58
+ const app = new Acryl()
59
+ .use(trimTrailingSlash())
60
+ .get('/', c => c.json({ ok: true }))
61
+ ```
62
+
63
+ - **One path everywhere**: the `acryl-router` row's config `trailingSlash: 'ignore'` makes `/a` and `/a/` the same path for every
64
+ mounted app, with no redirect. It is the app's composition that sets it (the Blueprint or profile that owns the router row), not one
65
+ plugin, because it changes every other plugin's routes too.
66
+
67
+ `new Acryl({ strict: false })` does not help once the app is mounted: the router composes every mount into its own root app, and only the
68
+ router's `trailingSlash` setting decides. `strict: false` applies to an `Acryl` you serve yourself (`app.fetch`, `app.request`). Details
69
+ and a measured table: [Routing and mounting](../api/routing.md#trailing-slashes).
70
+
71
+ The mount belongs to the plugin that made it: `route` registers it as a Cordis effect of the caller, so when the plugin unloads the
72
+ routes disappear, and a reload mounts the app again without duplicating routes. `route` also returns a function that removes the mount
73
+ sooner.
74
+
75
+ Finish building the app before you mount it: the router copies the app's routes at the `route` call, as the core's own `app.route` does.
76
+
77
+ ## Add middleware
78
+
79
+ Middleware is registered on the app before the routes it should wrap:
80
+
81
+ ```ts
82
+ const app = new Acryl()
83
+ .use(async (c, next) => {
84
+ const started = Date.now()
85
+ await next()
86
+ c.header('X-Response-Time', String(Date.now() - started))
87
+ })
88
+ .get('/', c => c.json({ ok: true }))
89
+ ```
90
+
91
+ Use middleware for authentication, logging, CORS, compression, request IDs and other behavior that should wrap more than one handler.
92
+ The built-in set is listed in [Middleware](../concepts/middleware.md).
93
+
94
+ ## Run the same app on a surface
95
+
96
+ The application does not open a socket. A surface supplies requests to it:
97
+
98
+ - **Web** and **Desktop**: the Host's web server, through `acryl-router-http` (`routerHttp.mount(prefix)`).
99
+ - **Terminal UI**: in process, `await ctx.router.request('/todo/todos/42')`.
100
+ - **Tests**: `await ctx.router.request(...)` through the plugin, or `await app.request(...)` on the app alone.
101
+ - **A standalone process**: `acryl-node-server`, see [Node server](./node-server.md).
102
+
103
+ ## Mount it in a Blend
104
+
105
+ Add a row to the Blend's `cordis.patch.yml`; the row id is the package name:
106
+
107
+ ```yaml
108
+ - insert:
109
+ - id: todo
110
+ name: todo
111
+ ```
112
+
113
+ A Blank app already carries the router, the HTTP adapter, state and the inspector. The reference app is `examples/acryl-todo`; the
114
+ smallest one to copy is `examples/acryl-framework-starter`.
115
+
116
+ ## Test it without a socket
117
+
118
+ ```ts
119
+ import { Context } from '@deepseek-ai/cordis'
120
+ import * as router from 'acryl-router'
121
+ import * as todo from '../src/index.ts'
122
+
123
+ const ctx = new Context()
124
+ ctx.plugin(router)
125
+ ctx.plugin(todo)
126
+ await new Promise(resolve => setTimeout(resolve, 0)) // let both plugins activate
127
+ const response = await ctx.router.request('/todo/todos/42')
128
+ expect(await response.json()).toEqual({ id: '42' })
129
+ ```
130
+
131
+ ## Package map
132
+
133
+ | Package | Responsibility |
134
+ | --- | --- |
135
+ | `acryl-router` | the application API and the Cordis `router` service |
136
+ | `acryl-router-http` | serving router prefixes on the ACRYL Host's web server |
137
+ | `acryl-node-server` | serving the whole router from a standalone Node.js process |
138
+ | `acryl-state` | shared application state across surfaces |
139
+ | `acryl-inspect` | runtime inspection of mounts, routes and plugins |
140
+
141
+ ## Next
142
+
143
+ - [Core concepts](./concepts.md)
144
+ - [Routing and mounting](../api/routing.md)
145
+ - [Context](../api/context.md)
146
+ - [Plugin lifecycle and disposal](./plugin-lifecycle.md)
@@ -0,0 +1,105 @@
1
+ # Core concepts
2
+
3
+ The framework's request-handling model is designed so every extension point belongs to a Cordis plugin. This page explains the pieces
4
+ before the API reference.
5
+
6
+ ## Acryl
7
+
8
+ An `Acryl` is a self-contained ACRYL application. It owns:
9
+
10
+ - routes;
11
+ - middleware;
12
+ - an error handler (`app.onError`);
13
+ - a request entry point (`app.fetch`, and `app.request` for tests);
14
+ - sub-applications placed below a path with `app.route(path, sub)`.
15
+
16
+ An app can be tested without a server:
17
+
18
+ ```ts
19
+ const response = await app.request('/todos/42')
20
+ ```
21
+
22
+ An app is not itself a Cordis service. That separation lets the same app be mounted by different hosts, at different paths, or not
23
+ mounted at all.
24
+
25
+ ## Router service
26
+
27
+ The Cordis `router` service (`ctx.router`, provided by `acryl-router`) composes the mounted apps into one route space. A plugin builds an
28
+ app and mounts it:
29
+
30
+ ```ts
31
+ ctx.router.route('/todo', app)
32
+ ```
33
+
34
+ The mount copies the app's routes at that call. When any mount is added or removed, the next request composes a fresh app from the mounts
35
+ that are live, in the order they were made; requests already running keep the app they started with. This matches Cordis: plugins can
36
+ load, unload, reload and be replaced while the host stays up. The core's route tables are add-only, so rebuilding is the only correct way
37
+ to remove a route; it costs about 1 ms per 1000 routes, once per change.
38
+
39
+ The router also takes one router-wide `onError` and one `notFound` (each owned by the plugin that set it), and runs requests in process:
40
+ `ctx.router.fetch(request, connection?)` and `ctx.router.request(path, init?, connection?)`.
41
+
42
+ ## Handler
43
+
44
+ A handler receives one context object, conventionally named `c`:
45
+
46
+ ```ts
47
+ app.get('/todos/:id', c => {
48
+ return c.json({ id: c.req.param('id') })
49
+ })
50
+ ```
51
+
52
+ Handlers return a Web `Response`, either directly or through context helpers such as `c.text`, `c.json` and `c.html`.
53
+
54
+ ## Middleware
55
+
56
+ Middleware wraps handlers and other middleware:
57
+
58
+ ```ts
59
+ app.use('/admin/*', async (c, next) => {
60
+ if (!isAllowed(c)) return c.text('Forbidden', 403)
61
+ await next()
62
+ })
63
+ ```
64
+
65
+ Code before `await next()` runs on the way in. Code after it can inspect or change the response (`c.res`) on the way out.
66
+
67
+ ## Surface
68
+
69
+ A surface is the place a person or another program uses the application:
70
+
71
+ | Surface | Transport |
72
+ | --- | --- |
73
+ | Web | the ACRYL Host's web server, through `acryl-router-http` |
74
+ | Desktop | the same: the Electron window talks to the app's own loopback web server |
75
+ | Terminal UI | in process: `ctx.router.request(...)`, no server |
76
+ | A standalone process | `acryl-node-server` serves the whole router |
77
+ | Tests | `ctx.router.request(...)` or `app.request(...)` |
78
+
79
+ Surfaces share capabilities and contracts. They do not need to share a transport or a user interface.
80
+
81
+ ## Plugin contract
82
+
83
+ Following the ACRYL framework rules, a plugin declares what it:
84
+
85
+ - provides as services;
86
+ - requires through injection;
87
+ - registers as routes, commands, tools or interface slots;
88
+ - owns until disposal.
89
+
90
+ The declaration is what lets tooling answer two questions without reading all the code: what does this plugin do, and what disappears
91
+ when it unloads? `acryl-inspect` reads it from the running system: each mount is labeled with its path and has one labeled child per route it serves.
92
+
93
+ ## State
94
+
95
+ Request state belongs to the context. Application state shared between surfaces belongs in `acryl-state` or an application-owned Cordis
96
+ service. Do not hide shared mutable state in module globals: Cordis can reload plugins and run several surfaces from one host, and a
97
+ module global outlives the plugin that wrote it.
98
+
99
+ ## Design rules
100
+
101
+ 1. Everything extensible is a Cordis plugin.
102
+ 2. One application can serve several surfaces.
103
+ 3. Each plugin has one declared contract.
104
+ 4. Every registration is reversible.
105
+ 5. Normal application code should not need runtime internals.
@@ -0,0 +1,8 @@
1
+ # Middleware in Cordis
2
+
3
+ Middleware belongs to an `Acryl`; it is not a separate Cordis registration, and it disappears with the mount that serves it. A plugin may
4
+ build middleware from an injected service, but resources the middleware uses (timers, watchers, connections) are acquired in the plugin's
5
+ `ctx.effect()` and only the finished middleware goes into the app. Do not start timers, watchers or sockets from a handler.
6
+
7
+ Middleware an app registers with a path wraps the matching routes of every later mount too (an app mounted at `/` with
8
+ `app.use('/todo/*', ...)` wraps a plugin mounted at `/todo`), so a host can put a guard in front of other plugins by mounting first.
@@ -0,0 +1,111 @@
1
+ # Migration from 0.3
2
+
3
+ 0.3.0 had the framework's own small router API: routes registered one at a time on `ctx.router`, and a `RouteContext` with a few
4
+ helpers. 0.4.0 replaces it with the ported application API: a plugin builds an `Acryl` and mounts it. The old calls are removed, not kept
5
+ as aliases, so there is one way to write a route.
6
+
7
+ ## Before and after
8
+
9
+ 0.3.0:
10
+
11
+ ```ts no-check
12
+ import { HttpError } from 'acrylblends'
13
+ import { readJson, valid, validate } from 'acrylblends'
14
+ import { streamSSE } from 'acrylblends/streaming'
15
+ import { bodyLimit } from 'acrylblends/body-limit'
16
+
17
+ export function apply(ctx: Context): void {
18
+ const api = ctx.router.group('/todo')
19
+ api.use(bodyLimit({ maxBytes: 16 * 1024 }))
20
+ api.get('/todos/:id', c => {
21
+ const todo = find(c.param('id'))
22
+ if (!todo) throw new HttpError(404, { message: 'no such todo' })
23
+ return c.json(todo)
24
+ })
25
+ api.post('/todos', validate('json', NewTodo), c => c.json(create(valid(c, 'json')), 201))
26
+ api.get('/events', c => streamSSE(c, stream => {
27
+ const off = events.on('change', todo => stream.send(todo, { event: 'change' }))
28
+ return off
29
+ }))
30
+ ctx.inject(['routerHttp'], inner => { inner.routerHttp.mount('/todo') })
31
+ }
32
+ ```
33
+
34
+ 0.4.0:
35
+
36
+ ```ts
37
+ import { Acryl } from 'acrylblends'
38
+ import { HTTPException } from 'acrylblends/http-exception'
39
+ import { schemaValidator } from 'acrylblends/schema-validator'
40
+ import { streamSSE } from 'acrylblends/streaming'
41
+ import { bodyLimit } from 'acrylblends/body-limit'
42
+
43
+ export function apply(ctx: Context): void {
44
+ const app = new Acryl()
45
+ .use(bodyLimit({ maxSize: 16 * 1024 }))
46
+ .get('/todos/:id', c => {
47
+ const todo = find(c.req.param('id'))
48
+ if (!todo) throw new HTTPException(404, { message: 'no such todo' })
49
+ return c.json(todo)
50
+ })
51
+ .post('/todos', schemaValidator('json', NewTodo), c => c.json(create(c.req.valid('json')), 201))
52
+ .get('/events', c => streamSSE(c, async stream => {
53
+ const off = events.on('change', todo => { void stream.writeSSE({ event: 'change', data: JSON.stringify(todo) }) })
54
+ stream.onAbort(off)
55
+ while (!stream.aborted) await stream.sleep(15_000).then(() => stream.write(': keep-alive\n\n'))
56
+ }))
57
+
58
+ ctx.router.route('/todo', app)
59
+ ctx.inject(['routerHttp'], inner => { inner.routerHttp.mount('/todo') })
60
+ }
61
+ ```
62
+
63
+ Public paths do not change: the mount path plus the app's route path is the old group prefix plus the old route path.
64
+
65
+ ## What maps to what
66
+
67
+ | 0.3.0 | 0.4.0 |
68
+ | --- | --- |
69
+ | `ctx.router.get/post/put/patch/delete/all/on(path, ...)` | the same methods on an `Acryl`, then `ctx.router.route(prefix, app)` |
70
+ | `ctx.router.use(...)` | `app.use(...)` on the app it should wrap (or on an app mounted earlier, for a guard in front of other plugins) |
71
+ | `ctx.router.group(prefix, ...middleware)` | one `Acryl` per group mounted at `prefix`, or `app.route(prefix, sub)` / `app.basePath(prefix)` |
72
+ | the remover each registration returned | the one remover `ctx.router.route` returns; the mount is owned by the calling plugin as before |
73
+ | `ctx.router.onError`, `ctx.router.notFound` | unchanged; an app's own `app.onError` now also applies to its routes |
74
+ | `c.param(name)`, `c.params` | `c.req.param(name)`, `c.req.param()` |
75
+ | `c.query(name)`, `c.query()` | `c.req.query(name)`, `c.req.query()` (and `c.req.queries(name)` for repeated values) |
76
+ | `c.url` | `new URL(c.req.url)`, or `c.req.path` |
77
+ | `c.req` (the Web `Request`) | `c.req.raw`; `c.req` is now the request wrapper |
78
+ | `c.connection` | `connectionOf(c)` (or `getConnInfo(c)`); type the app `new Acryl<RouterEnv>()` to read `c.env.connection` |
79
+ | `c.signal` | `c.req.raw.signal` |
80
+ | `c.waitUntil(task)` | `c.executionCtx.waitUntil(task)` |
81
+ | `c.get(key)` / `c.set(key, value)` | unchanged, typed by the app's `Variables` (`c.var.key` also works) |
82
+ | `c.json/text/html/body/redirect/notFound/header/status` | unchanged |
83
+ | `HttpError(status, { message, response, cause })` | `HTTPException(status, { message, res, cause })` |
84
+ | `readJson(c)`, `readText(c)`, `readBytes(c)` with `{ maxBytes }` | `bodyLimit({ maxSize })` in front, then `c.req.json()`, `c.req.text()`, `c.req.arrayBuffer()` |
85
+ | `bodyLimit({ maxBytes })` | `bodyLimit({ maxSize })` |
86
+ | `validate(target, schema)` and `valid(c, target)` | `schemaValidator(target, schema)` and `c.req.valid(target)`, with more targets (`form`, `header`, `cookie`) |
87
+ | `streamSSE(c, stream => cleanup)` with `stream.send(data, { event, id })` | `streamSSE(c, async stream => ...)` with `stream.writeSSE({ data, event, id })`; `data` is a string, so `JSON.stringify` objects |
88
+ | the SSE stream's automatic keep-alive comment | none: write a comment on a timer when a proxy closes idle connections |
89
+ | the cleanup function `run` returned | `stream.onAbort(cleanup)`; the callback's end closes the stream |
90
+ | a 405 with `Allow` for a known path and another method | 404 by default; add the `methodNotAllowed` middleware for 405 |
91
+ | the `Middleware` type, `(c, next) => Response` | `MiddlewareHandler`, `async (c, next) => { await next() }`; change `c.res` after `next()` instead of returning it |
92
+ | `logger`, `cors`, `requestId`, `secureHeaders`, `loopbackOnly` | same names, each from its own subpath (`acrylblends/logger`, `acrylblends/cors`, ...); the first four now take the core's options |
93
+
94
+ ## Behavior changes to check
95
+
96
+ - **A mount is a snapshot.** Routes an app registers after `ctx.router.route(...)` are not served. 0.3.0 registered each call live.
97
+ - **A JSON validator needs a JSON `Content-Type`.** Without it the schema sees `{}`. 0.3.0 read the body whatever its type.
98
+ - **`await next()` does not throw.** A thrown error has already become a response; read it from `c.error`.
99
+ - **Trailing slashes are strict by default, as before**, and `trailingSlash: 'ignore'` still treats `/a` and `/a/` as one path.
100
+ - **Disposal is unchanged:** a plugin's unload ends the requests and streams its code is serving, and router disposal ends all of them,
101
+ bounded by `disposeGraceMs`.
102
+
103
+ ## Checklist
104
+
105
+ For each plugin:
106
+
107
+ - collect its route registrations into one app per mount path and keep the public paths;
108
+ - move middleware and error handling onto the owning app;
109
+ - replace the context and helper calls from the table above;
110
+ - run its route tests, then one activation test that unloads and reloads the plugin;
111
+ - leave no 0.3.0-style calls behind: the old API is gone, and two styles would give new code a second API to copy.
@@ -0,0 +1,14 @@
1
+ # Migration from 0.4
2
+
3
+ 0.5 gives the framework its public names: the application class is `Acryl` and the request class is `AcrylRequest`, the upstream names
4
+ with the project renamed. Everything else (`Context`, `HTTPException`, the middleware, the helpers, `ctx.router.route(path, app)`) is
5
+ unchanged.
6
+
7
+ | 0.4 | 0.5 |
8
+ | --- | --- |
9
+ | `import { App } from 'acryl-router'`, `new App()` | `import { Acryl } from 'acryl-router'`, `new Acryl()` |
10
+ | `AppRequest` | `AcrylRequest` |
11
+
12
+ `App` and `AppRequest` still work in 0.5, marked deprecated, and are removed in the next release. Code outside Cordis imports the same
13
+ names from the public package: `import { Acryl } from 'acrylblends'`, and each upstream subpath under it (`acrylblends/cors`,
14
+ `acrylblends/jwt`, `acrylblends/node-server`).
@@ -0,0 +1,109 @@
1
+ # Migration from the DSH webServer
2
+
3
+ ACRYL plugins have registered HTTP routes directly with the DSH `webServer` (or the Host's `acrylWeb` wrapper of it): one Node-style handler
4
+ per exact path or prefix, with no middleware chain. The framework replaces that with an `Acryl`, a mount, and Web-standard requests.
5
+
6
+ ## Before and after
7
+
8
+ Before, a plugin claims a path and writes the Node response itself:
9
+
10
+ ```ts
11
+ export function apply(ctx: Context): void {
12
+ ctx.effect(() => ctx.acrylWeb.register({
13
+ kind: 'exact',
14
+ path: '/api/health',
15
+ handler: (req, res) => {
16
+ res.statusCode = 200
17
+ res.setHeader('content-type', 'application/json')
18
+ res.end(JSON.stringify({ ok: true }))
19
+ },
20
+ }))
21
+ }
22
+ ```
23
+
24
+ After, it builds an app, mounts it, and asks the Host adapter to serve the prefix:
25
+
26
+ ```ts
27
+ import { Acryl } from 'acrylblends'
28
+
29
+ export const inject = ['router']
30
+
31
+ export function apply(ctx: Context): void {
32
+ const app = new Acryl().get('/health', c => c.json({ ok: true }))
33
+ ctx.router.route('/api', app)
34
+ ctx.inject(['routerHttp'], inner => { inner.routerHttp.mount('/api/health') })
35
+ }
36
+ ```
37
+
38
+ `routerHttp.mount(prefix)` claims the prefix on the same web server, so the public path does not change. The terminal UI and tests reach
39
+ the route in process (`ctx.router.request('/api/health')`), which the old handler never allowed.
40
+
41
+ ## What maps to what
42
+
43
+ | webServer | Framework |
44
+ | --- | --- |
45
+ | `register({ kind: 'exact', path, handler })` | a route on an `Acryl`, mounted with `ctx.router.route`; served with `routerHttp.mount(path)` |
46
+ | `register({ kind: 'prefix', path, handler })` | an `Acryl` mounted at `path` with its own routes below it |
47
+ | `req.method` checks in the handler | `app.get`, `app.post`, ... (add `methodNotAllowed` for 405 answers) |
48
+ | `new URL(req.url, ...)` and manual parsing | `c.req.param`, `c.req.query`, `c.req.header` |
49
+ | reading the body stream by hand | `c.req.json()`, `c.req.parseBody()` behind `bodyLimit`, or a validator |
50
+ | `res.statusCode`, `res.setHeader`, `res.end` | `c.json`, `c.text`, `c.body`, `c.header`, or a returned `Response` |
51
+ | `req.socket.remoteAddress` | `connectionOf(c)` / `getConnInfo(c)` |
52
+ | a peer or origin check copied into each handler | `loopbackOnly`, `cors`, `csrf` or a middleware of your own |
53
+ | the disposer returned by `register` | nothing: `route` and `mount` belong to the calling plugin |
54
+
55
+ ## Migration steps
56
+
57
+ Move one capability at a time:
58
+
59
+ 1. Build an `Acryl` for the capability and move its handlers into it without changing their public answers.
60
+ 2. Mount it with `ctx.router.route` and serve the same prefix with `routerHttp.mount`.
61
+ 3. Remove the direct `webServer` registration in the same change: the two cannot claim one prefix, `mount` throws.
62
+ 4. Test the route through the router (`ctx.router.request`) and over the Host, and unload and reload the plugin to confirm the route goes
63
+ away and comes back once.
64
+
65
+ Keep domain calls unchanged until the transport change is green; do not rewrite transport, handlers and authorization in one step.
66
+
67
+ ## A worked example: the workspace
68
+
69
+ `acryl-workspace` (files, git, terminals, projects, agents) was the largest user of the webServer: 24 exact routes. It now builds one app
70
+ per domain folder (`filesRoutes`, `gitRoutes`, `ptyRoutes`, ...), composes them once (`privateApi` in `src/private-route.ts`), mounts the
71
+ result after the effect that builds its services, and serves the one prefix `/api/acryl-workspace`:
72
+
73
+ ```ts
74
+ const built: { api?: Acryl } = {}
75
+ ctx.effect(() => {
76
+ // ... the PTY table, git, files, the project registry ...
77
+ built.api = privateApi(filesRoutes(files, report), gitRoutes(git, report) /* , ... */)
78
+ return async () => { /* release them */ }
79
+ })
80
+ ctx.router.route('/', built.api)
81
+ ctx.routerHttp.mount('/api/acryl-workspace')
82
+ ```
83
+
84
+ Mounting after the effect orders the unload: the mount goes first, so the requests and event streams its code is serving end and are
85
+ waited for, then the services are released. Each route names its guard (`loopbackOnly({ callers: 'own-page' })` for the page, a bearer token
86
+ for the agent hooks) and its body limit; the event stream is `streamSSE`, and its keep-alive is one timer the plugin owns, not one per
87
+ request. The terminal's WebSocket stays a webServer upgrade until the router carries upgrades (spec 047 T009).
88
+
89
+ The answers that changed are recorded with it: a body over a route's limit is 413 on every route (some said 400); a wrong method is 405 with
90
+ an `Allow` header on every route; a missing or wrong agent-hook token is 401 with `WWW-Authenticate` (it was 403); and the hook's `Host`
91
+ must be the app's own loopback address. Its routes refuse an in-process call on purpose (the guard needs a socket to check), so its tests
92
+ run over loopback through the same adapter (`tests/serve-api.ts`), which is the "test over the Host" below.
93
+
94
+ `acryl-agent-control` (the door `acryl control`, workers and the MCP config use) moved the same way and kept its trust model exactly:
95
+ operator routes still answer a non-loopback peer or a missing or wrong secret with 403 `{ "error": "forbidden" }`, which `acryl control`
96
+ reads as "refused", never the bearer middleware's 401. What changed there: an oversized body is 413 (was 400), a wrong method's `Allow`
97
+ lists what the router serves (`GET, HEAD` for the audit log), and a page route's refusal carries `loopbackOnly`'s message instead of
98
+ `forbidden`. Only the page channel's WebSocket stays a webServer upgrade.
99
+
100
+ ## Completion check
101
+
102
+ A capability is migrated when:
103
+
104
+ - no handler in it touches Node `req` / `res` objects or the old server types;
105
+ - its public paths and answers are unchanged, or the changes are recorded;
106
+ - it unloads and reloads without duplicate or missing routes;
107
+ - the same handler passes an in-process test and a test over the Host.
108
+
109
+ Typed RPC stays a separate concern. Do not convert RPC methods because their names look like HTTP routes.
@@ -0,0 +1,85 @@
1
+ # Mounting and trailing slashes
2
+
3
+ How a Cordis plugin serves an `Acryl` app, what happens to errors and unknown paths, and how trailing slashes behave. For routing itself (methods, parameters, wildcards, grouping) see the [routing API](../api/routing.md).
4
+
5
+ ## Mounting from Cordis
6
+
7
+ A plugin mounts its app through the `router` service:
8
+
9
+ ```ts
10
+ export const name = 'todo'
11
+ export const inject = ['router']
12
+
13
+ export function apply(ctx: Context): void {
14
+ const app = createTodoApp()
15
+ ctx.router.route('/todo', app)
16
+ ctx.inject(['routerHttp'], inner => { inner.routerHttp.mount('/todo') })
17
+ }
18
+ ```
19
+
20
+ - The mount is owned by the calling plugin: `route` registers it as that plugin's effect, so unloading the plugin removes it and a reload
21
+ mounts again without duplicates. The returned function removes it sooner.
22
+ - `route` copies the app's routes at the call, like `app.route`; mount the app once it is complete.
23
+ - Mounts run in the order they were made, so middleware an earlier mount registers for a path (`app.use('/todo/*', ...)` mounted at `/`)
24
+ wraps routes a later mount serves below it. Two plugins may mount on the same path: the first one's matching route answers, and when it
25
+ unloads the second takes over.
26
+ - `ctx.router.route` is the only way to register with the router.
27
+ - To be reachable over HTTP inside the ACRYL Host, ask the adapter to serve the prefix with `routerHttp.mount(prefix)` (an optional
28
+ dependency, hence the inner `ctx.inject`). A standalone `acryl-node-server` serves the whole router, so there the call is not needed.
29
+
30
+ ## Not found and errors
31
+
32
+ An app's own error handler applies to that app's routes, also when the app is mounted:
33
+
34
+ ```ts
35
+ app.onError((error, c) => {
36
+ console.error(error)
37
+ return c.json({ error: 'internal_error' }, 500)
38
+ })
39
+ ```
40
+
41
+ A mounted app's `app.notFound` is not used: a request no mount claims is answered by the router. A plugin can replace that answer, and
42
+ the router's fallback error handler, once per router:
43
+
44
+ ```ts
45
+ ctx.router.notFound(c => c.json({ error: 'not_found' }, 404))
46
+ ctx.router.onError((error, c) => c.json({ error: 'internal_error' }, 500))
47
+ ```
48
+
49
+ Without them, an `HTTPException` becomes its own response, an aborted request is a 499 and anything else is logged and becomes a 500.
50
+ Return client errors from the handler that detected them (or throw an `HTTPException`); reserve `onError` for unexpected failures.
51
+
52
+ ## Trailing slashes
53
+
54
+ `/a` and `/a/` are different paths by default. That surprises people most on a mount's root: an app mounted at `/orders` with a route
55
+ `/` answers `/orders` and returns 404 on `/orders/`.
56
+
57
+ What each choice does for an app mounted at `/orders` with routes `/` and `/:id`, measured on the router:
58
+
59
+ | Choice | `/orders` | `/orders/` | `/orders/42` | `/orders/42/` |
60
+ | --- | --- | --- | --- | --- |
61
+ | nothing (the default) | 200 | 404 | 200 | 404 |
62
+ | `.use(trimTrailingSlash())` in the app | 200 | 301 to `/orders` | 200 | 301 to `/orders/42` |
63
+ | `acryl-router` row config `trailingSlash: 'ignore'` | 200 | 200 | 200 | 200 |
64
+ | `new Acryl({ strict: false })` for the mounted app | 200 | 404 | 200 | 404 |
65
+
66
+ - **`trimTrailingSlash()`** (from `acrylblends/trailing-slash`) is the choice a plugin makes for itself. It acts after the handlers: only
67
+ a GET or HEAD that would be a 404 and ends in `/` is redirected, so a path you did register with a trailing slash keeps working, and a
68
+ POST, PUT, PATCH or DELETE to `/orders/` stays a 404. `trimTrailingSlash({ alwaysRedirect: true })` redirects before the handlers run,
69
+ which a wildcard route (`/files/*`) needs. `appendTrailingSlash()` does the opposite, for an app whose canonical paths end in `/`.
70
+ - **`trailingSlash: 'ignore'`** is the router's own setting and applies to every mount at once, without a redirect. Set it in the
71
+ composition that owns the `acryl-router` row (the Blueprint or profile), not from one plugin.
72
+ - **`strict: false`** on an `Acryl` is the core's option and works for an app you serve yourself (`app.fetch`, `app.request`). It has no
73
+ effect on a mounted app, because the router composes all mounts into one root app whose strictness comes from `trailingSlash`.
74
+
75
+ `examples/acryl-todo` uses `trimTrailingSlash()`: `/todo/` sends the browser to `/todo`, and the API keeps one path per route.
76
+
77
+ ## Testing routes
78
+
79
+ ```ts
80
+ const response = await app.request('/todos/42')
81
+ const body = await response.json()
82
+ ```
83
+
84
+ Test both the application in isolation and at least one mounted route through the Cordis router. The second test proves the mount,
85
+ prefix and disposal contract, not just handler logic.
@@ -0,0 +1,75 @@
1
+ # Node server
2
+
3
+ `acryl-node-server` serves the whole router over HTTP from a standalone Node.js process. Use it for an app that runs without the ACRYL
4
+ Host. Inside the Host (Web and Desktop), the Host owns the web server and a plugin asks `acryl-router-http` to serve its prefix instead.
5
+
6
+ ## Serve the router
7
+
8
+ The server is a Cordis plugin that provides the `nodeServer` service:
9
+
10
+ ```ts
11
+ import { Context } from '@deepseek-ai/cordis'
12
+ import * as router from 'acryl-router'
13
+ import * as nodeServer from 'acrylblends/node-server'
14
+ import * as todo from './todo.ts'
15
+
16
+ const root = new Context()
17
+ root.plugin(router)
18
+ root.plugin(nodeServer, { port: 3000 })
19
+ root.plugin(todo)
20
+
21
+ root.inject(['nodeServer'], ctx => {
22
+ console.log(`listening on ${ctx.nodeServer.url}`)
23
+ })
24
+ ```
25
+
26
+ | Option | Default | Meaning |
27
+ | --- | --- | --- |
28
+ | `hostname` | `127.0.0.1` | address to bind; binding elsewhere exposes the app to the network |
29
+ | `port` | `0` | port to listen on; 0 lets the system choose |
30
+ | `scan` | `20` | how many further ports to try when the configured one is taken |
31
+
32
+ The plugin becomes active once the server listens, so a plugin that injects `nodeServer` can rely on `ctx.nodeServer.url` and
33
+ `ctx.nodeServer.port`. A failure to bind fails the plugin.
34
+
35
+ Every request goes to `ctx.router.fetch`, so routes appear and disappear as plugins mount and unload, with no restart. A plugin's
36
+ `ctx.inject(['routerHttp'], ...)` call never fires here: this adapter serves every prefix already.
37
+
38
+ ## Connection information
39
+
40
+ The adapter passes the socket's peer address and local port to the router as connection facts. Handlers read them through
41
+ `connectionOf(c)` or `getConnInfo(c)` and never touch Node socket objects, so the same handler runs in process and through the Host. See
42
+ [Context](../api/context.md#transport-facts).
43
+
44
+ ## Static files
45
+
46
+ The Node `serveStatic` reads files from disk:
47
+
48
+ ```ts
49
+ import { serveStatic } from 'acrylblends/node-server'
50
+
51
+ const app = new Acryl()
52
+ .use('/assets/*', serveStatic({ root: './public' }))
53
+ .get('/', serveStatic({ path: './public/index.html' }))
54
+ ```
55
+
56
+ `root` is joined with the request path, relative to the working directory unless absolute. A request path with a `.` or `..` segment, a
57
+ doubled slash, a backslash or (unless `allowPercentInPath`) an encoded `%` is treated as not found and passed on with `next()`. Only `GET`
58
+ and `HEAD` are served. Other options: `rewriteRequestPath`, `precompressed` (serve a `.br`, `.zst` or `.gz` sibling when the client
59
+ accepts it), `index` (default `index.html`), and `onFound` / `onNotFound` hooks. Serve build output, never source folders.
60
+
61
+ ## Shutdown
62
+
63
+ Disposing the plugin (or the root context) ends open streams, closes every connection and returns once the port is free. Disposing a
64
+ plugin that only mounted routes is different: its mount and the requests it is serving end, and the server keeps serving the others (see
65
+ [Plugin lifecycle](./plugin-lifecycle.md#disposal-sequence)).
66
+
67
+ ## Choosing a port
68
+
69
+ Let the process configuration choose the address. Do not hard-code a development port in a reusable plugin: a plugin mounts routes and
70
+ leaves the socket to whichever adapter the app runs with.
71
+
72
+ ## Not yet supported
73
+
74
+ WebSocket upgrades under the router are a later task. The adapter's own WebSocket support exists in `src/adapter/` with its tests but is
75
+ not wired to the router yet.