acrylblends 0.4.0 → 0.4.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.
- package/dist/index.js +56 -2
- package/dist/index.js.map +1 -1
- package/docs/framework/LICENSE +23 -0
- package/docs/framework/README.md +63 -0
- package/docs/framework/UPSTREAM.md +3 -0
- package/docs/framework/api-summary.md +138 -0
- package/docs/framework/concepts.md +105 -0
- package/docs/framework/context.md +129 -0
- package/docs/framework/getting-started.md +146 -0
- package/docs/framework/helpers.md +118 -0
- package/docs/framework/middleware.md +154 -0
- package/docs/framework/migration-from-0-3.md +108 -0
- package/docs/framework/migration-from-dsh-webserver.md +103 -0
- package/docs/framework/node-server.md +75 -0
- package/docs/framework/plugin-lifecycle.md +111 -0
- package/docs/framework/request.md +106 -0
- package/docs/framework/routing.md +188 -0
- package/docs/framework/surfaces.md +71 -0
- package/docs/framework/upstream.json +9 -0
- package/docs/framework/validation.md +135 -0
- package/package.json +4 -2
- package/plugins/acryl-inspect/src/report.ts +15 -10
- package/plugins/acryl-inspect/tests/report.spec.ts +9 -3
- package/plugins/acryl-inspect/upstream.json +2 -2
- package/plugins/acryl-node-server/upstream.json +1 -1
- package/plugins/acryl-router/src/middleware/index.ts +1 -1
- package/plugins/acryl-router/src/middleware/loopback-only.ts +34 -1
- package/plugins/acryl-router/src/service.ts +9 -3
- package/plugins/acryl-router/tests/loopback-only.spec.ts +80 -0
- package/plugins/acryl-router/tests/service.spec.ts +17 -0
- package/plugins/acryl-router/upstream.json +2 -2
- package/plugins/acryl-router-http/upstream.json +1 -1
- package/plugins/acryl-state/upstream.json +1 -1
- package/plugins/builder/script.ts +1 -1
- package/plugins/extensions/index.ts +4 -3
- package/plugins/openai/prompt.ts +2 -2
- package/shared/AGENTS.core.md +54 -0
- package/templates/agent/AGENTS.md +6 -0
- package/templates/agent/README.md +2 -2
- package/templates/agent/blend.yaml +1 -1
- package/templates/blank/AGENTS.md +5 -0
- package/templates/blank/README.md +1 -1
- package/templates/todo/AGENTS.md +5 -0
- package/templates/todo/src/todo.ts +4 -2
- package/templates/todo/upstream.json +2 -2
|
@@ -0,0 +1,103 @@
|
|
|
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 `App`, 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 { App } from 'acryl-router'
|
|
28
|
+
|
|
29
|
+
export const inject = ['router']
|
|
30
|
+
|
|
31
|
+
export function apply(ctx: Context): void {
|
|
32
|
+
const app = new App().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 `App`, mounted with `ctx.router.route`; served with `routerHttp.mount(path)` |
|
|
46
|
+
| `register({ kind: 'prefix', path, handler })` | an `App` 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 `App` 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?: App } = {}
|
|
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
|
+
## Completion check
|
|
95
|
+
|
|
96
|
+
A capability is migrated when:
|
|
97
|
+
|
|
98
|
+
- no handler in it touches Node `req` / `res` objects or the old server types;
|
|
99
|
+
- its public paths and answers are unchanged, or the changes are recorded;
|
|
100
|
+
- it unloads and reloads without duplicate or missing routes;
|
|
101
|
+
- the same handler passes an in-process test and a test over the Host.
|
|
102
|
+
|
|
103
|
+
Typed RPC stays a separate concern. Do not convert RPC methods because their names look like HTTP routes.
|
|
@@ -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 'acryl-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](./context.md#transport-facts).
|
|
43
|
+
|
|
44
|
+
## Static files
|
|
45
|
+
|
|
46
|
+
The Node `serveStatic` reads files from disk:
|
|
47
|
+
|
|
48
|
+
```ts
|
|
49
|
+
import { serveStatic } from 'acryl-node-server'
|
|
50
|
+
|
|
51
|
+
const app = new App()
|
|
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.
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# Plugin lifecycle and disposal
|
|
2
|
+
|
|
3
|
+
Cordis gives every framework registration an owner and a lifetime. This is the main difference between using the application API on its
|
|
4
|
+
own and using it as part of ACRYL.
|
|
5
|
+
|
|
6
|
+
## Every registration has an owner
|
|
7
|
+
|
|
8
|
+
```ts
|
|
9
|
+
export const name = 'todo'
|
|
10
|
+
export const inject = ['router']
|
|
11
|
+
|
|
12
|
+
export function apply(ctx: Context): void {
|
|
13
|
+
ctx.router.route('/todo', createTodoApp())
|
|
14
|
+
}
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
`route`, `onError` and `notFound` register themselves as effects of the plugin that called them, so there is nothing to wrap: when the
|
|
18
|
+
plugin's fiber unloads, its mount goes away, and a reload mounts it again without duplicates. `route` returns a function that removes the
|
|
19
|
+
mount sooner. Never register routes as a side effect of importing a module.
|
|
20
|
+
|
|
21
|
+
Resources an app uses (timers, watchers, connections, subprocesses) belong to one `ctx.effect()` of the plugin, whose disposer releases
|
|
22
|
+
them:
|
|
23
|
+
|
|
24
|
+
```ts
|
|
25
|
+
export function apply(ctx: Context): void {
|
|
26
|
+
const events = new EventEmitter()
|
|
27
|
+
ctx.effect(() => {
|
|
28
|
+
const timer = setInterval(() => events.emit('tick'), 1000)
|
|
29
|
+
return () => clearInterval(timer)
|
|
30
|
+
}, 'todo: ticker')
|
|
31
|
+
ctx.router.route('/todo', createTodoApp(events))
|
|
32
|
+
}
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Live composition
|
|
36
|
+
|
|
37
|
+
The router is built for a host whose plugin set changes while it runs. Mounts are added and removed, and each request after a change is
|
|
38
|
+
served by an app composed from the mounts that are live at that time; a request already running keeps the app it started with. The core's
|
|
39
|
+
route tables only grow, so the router rebuilds the composition instead of editing it. Application code does not manage this.
|
|
40
|
+
|
|
41
|
+
A mount is a snapshot of the app's routes at the `route` call. Build the app, then mount it.
|
|
42
|
+
|
|
43
|
+
## Missing providers
|
|
44
|
+
|
|
45
|
+
A plugin that injects `router` stays PENDING while no router exists. That is a normal Cordis state, not an error: when a router appears the
|
|
46
|
+
plugin activates and mounts. When the router is replaced, the plugin unloads and activates again against the new one, so it must not keep
|
|
47
|
+
the old service object anywhere else.
|
|
48
|
+
|
|
49
|
+
## Disposal sequence
|
|
50
|
+
|
|
51
|
+
When a plugin that owns a mount unloads:
|
|
52
|
+
|
|
53
|
+
1. The mount leaves the composition, so new requests no longer reach it.
|
|
54
|
+
2. The requests its code is still serving end: a request inside one of its handlers or middleware (before `next()` returned), and a
|
|
55
|
+
response body one of its handlers produced. Their signals abort (`c.req.raw.signal`) and their bodies are cancelled, which runs the
|
|
56
|
+
stream helpers' `onAbort`.
|
|
57
|
+
3. The unload waits for those requests, their bodies and their `c.executionCtx.waitUntil` work, bounded by the router's `disposeGraceMs`.
|
|
58
|
+
|
|
59
|
+
A request the plugin's middleware already passed on to another plugin's handler is not touched. When the router itself is disposed, the
|
|
60
|
+
same happens to every running request, and new requests are answered with 503.
|
|
61
|
+
|
|
62
|
+
Handlers get a chance to release their resources, nothing a plugin started outlives it, and disposal cannot hang forever.
|
|
63
|
+
|
|
64
|
+
## Services and the shadow context
|
|
65
|
+
|
|
66
|
+
A plugin reaches a service through its own view of it: `ctx.router` in one plugin and in another are two different objects over one
|
|
67
|
+
service. Two facts follow, each with a test as proof.
|
|
68
|
+
|
|
69
|
+
**Fields are shared.** Reading or assigning a service's field through any plugin's view reads or writes the one instance, so every caller
|
|
70
|
+
sees the same value (`plugins/acryl-router/tests/shadow-state.spec.ts`). Keeping changing state in one object, as the router does, is a
|
|
71
|
+
readability choice, not a requirement.
|
|
72
|
+
|
|
73
|
+
**`this.ctx` inside a method is the caller's view, not the service's own context.** It is what lets `route` tie a mount to the plugin
|
|
74
|
+
that called it. It is the wrong context for starting something that should belong to the service: a plugin started with
|
|
75
|
+
`this.ctx.plugin(...)` from a method resolves services as the service's own fiber and fails with "cannot get property router without
|
|
76
|
+
inject". Keep the context the constructor received and use that:
|
|
77
|
+
|
|
78
|
+
```ts
|
|
79
|
+
class Installer extends Service {
|
|
80
|
+
private readonly owner: Context
|
|
81
|
+
|
|
82
|
+
constructor(ctx: Context) {
|
|
83
|
+
super(ctx, 'installer')
|
|
84
|
+
this.owner = ctx // the service's own context
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async install(plugin: Plugin): Promise<void> {
|
|
88
|
+
await this.owner.plugin(plugin) // right: behaves like a plugin mounted at startup
|
|
89
|
+
// await this.ctx.plugin(plugin) // wrong: the caller's view, see above
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
The proof is `plugins/acryl-router/tests/live-install.spec.ts`, which runs both forms against a live router. This sits next to the general
|
|
95
|
+
rule: a plugin started with `this.ctx.plugin(...)` in `apply` is a child of the plugin and unloads with it.
|
|
96
|
+
|
|
97
|
+
## Module state
|
|
98
|
+
|
|
99
|
+
Do not hide mutable state in module globals. Cordis can reload a plugin and run several surfaces from one host, and a module global
|
|
100
|
+
outlives the plugin that wrote it.
|
|
101
|
+
|
|
102
|
+
## What to test
|
|
103
|
+
|
|
104
|
+
Test a route-owning plugin through real Cordis activation for:
|
|
105
|
+
|
|
106
|
+
- activation when the router is present, and PENDING until it appears;
|
|
107
|
+
- route removal after the plugin unloads, and a clean mount after reload, without duplicates over repeated cycles;
|
|
108
|
+
- router replacement without stale service references;
|
|
109
|
+
- disposal while a response is still open.
|
|
110
|
+
|
|
111
|
+
`app.request` tests prove handler behavior. Only activation tests prove ownership.
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# Request
|
|
2
|
+
|
|
3
|
+
Handlers read input through `c.req`, a thin wrapper around the Web `Request`. The original is `c.req.raw`.
|
|
4
|
+
|
|
5
|
+
## Route parameters
|
|
6
|
+
|
|
7
|
+
```ts
|
|
8
|
+
app.get('/entry/:id', c => {
|
|
9
|
+
const id = c.req.param('id')
|
|
10
|
+
return c.json({ id })
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
app.get('/entry/:id/comment/:commentId', c => {
|
|
14
|
+
const { id, commentId } = c.req.param()
|
|
15
|
+
return c.json({ id, commentId })
|
|
16
|
+
})
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Named reads keep route contracts visible; read the whole map only when a generic logger or adapter needs it.
|
|
20
|
+
|
|
21
|
+
## Query strings
|
|
22
|
+
|
|
23
|
+
```ts
|
|
24
|
+
const page = c.req.query('page') // one value
|
|
25
|
+
const all = c.req.query() // every parameter, first value of each
|
|
26
|
+
const tags = c.req.queries('tag') // repeated values: ?tag=work&tag=home
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Convert strings to numbers, booleans or dates at the application boundary and reject invalid values explicitly; a validator does both
|
|
30
|
+
(see [Validation](./validation.md)).
|
|
31
|
+
|
|
32
|
+
## Headers
|
|
33
|
+
|
|
34
|
+
```ts
|
|
35
|
+
const requestId = c.req.header('X-Request-Id')
|
|
36
|
+
const headers = c.req.header() // every header, keys in lowercase
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Header names are case-insensitive for a named read. With no argument the record's keys are lowercase, so `c.req.header()['X-Foo']` is
|
|
40
|
+
always undefined; read `c.req.header('X-Foo')` instead. Optional headers need a deliberate fallback; do not let a missing authentication
|
|
41
|
+
header silently become an anonymous request unless that is the declared behavior.
|
|
42
|
+
|
|
43
|
+
## Bodies
|
|
44
|
+
|
|
45
|
+
```ts
|
|
46
|
+
app.post('/entry', async c => {
|
|
47
|
+
const body = await c.req.json() // application/json
|
|
48
|
+
return c.json({ received: body }, 201)
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
await c.req.text()
|
|
52
|
+
await c.req.arrayBuffer()
|
|
53
|
+
await c.req.blob()
|
|
54
|
+
await c.req.formData()
|
|
55
|
+
await c.req.parseBody() // multipart/form-data or application/x-www-form-urlencoded
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
The reads are cached, so a validator and the handler can both read the body. Read untrusted bodies behind `bodyLimit`, which refuses a body
|
|
59
|
+
over the limit with 413 while it streams instead of after buffering it.
|
|
60
|
+
|
|
61
|
+
### Forms and uploads
|
|
62
|
+
|
|
63
|
+
`parseBody()` returns `string | File` per field; the last one wins when a name repeats:
|
|
64
|
+
|
|
65
|
+
```ts
|
|
66
|
+
const body = await c.req.parseBody()
|
|
67
|
+
body['file'] // string | File
|
|
68
|
+
const many = await c.req.parseBody({ all: true }) // repeated names become arrays
|
|
69
|
+
many['files'] // (string | File)[] when several were sent
|
|
70
|
+
const nested = await c.req.parseBody({ dot: true }) // obj.key1 -> { obj: { key1 } }
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
A field named with a `[]` suffix (`files[]`) is always an array. Treat every uploaded file as untrusted: check its type, size and
|
|
74
|
+
destination, and never use a caller-provided filename as a filesystem path without normalizing it and confining it to the intended
|
|
75
|
+
directory.
|
|
76
|
+
|
|
77
|
+
## Validated input
|
|
78
|
+
|
|
79
|
+
`c.req.valid(target)` returns what a validator for `json`, `form`, `query`, `header`, `cookie` or `param` produced, typed by it. See
|
|
80
|
+
[Validation](./validation.md).
|
|
81
|
+
|
|
82
|
+
## Method, path and URL
|
|
83
|
+
|
|
84
|
+
```ts
|
|
85
|
+
c.req.method // 'GET'
|
|
86
|
+
c.req.path // '/about/me'
|
|
87
|
+
c.req.url // 'http://127.0.0.1:3290/about/me'
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Do not parse the URL by hand to recover route parameters or query values the router already exposes. `routePath(c)` and
|
|
91
|
+
`matchedRoutes(c)` from `acryl-router/helpers` give the registered path and the routes that matched.
|
|
92
|
+
|
|
93
|
+
## Raw Web Request
|
|
94
|
+
|
|
95
|
+
```ts
|
|
96
|
+
const raw = c.req.raw
|
|
97
|
+
const url = new URL(raw.url)
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
Use the raw object for standards-based libraries, streaming bodies and signatures over the exact payload. Use `c.req` for normal
|
|
101
|
+
application code.
|
|
102
|
+
|
|
103
|
+
## Request cancellation
|
|
104
|
+
|
|
105
|
+
`c.req.raw.signal` aborts when the client goes away, when the plugin serving the request unloads and when the router is
|
|
106
|
+
disposed. Work that ignores it can outlive the plugin that owns it; see [Context](./context.md#cancellation-and-background-work).
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
# Routing and mounting
|
|
2
|
+
|
|
3
|
+
Routing has two levels:
|
|
4
|
+
|
|
5
|
+
1. Routes inside an `App`.
|
|
6
|
+
2. Cordis mounts that place an `App` in the host at a base path.
|
|
7
|
+
|
|
8
|
+
Keeping those levels separate lets application code stay portable while the host controls the public route space.
|
|
9
|
+
|
|
10
|
+
## HTTP methods
|
|
11
|
+
|
|
12
|
+
```ts
|
|
13
|
+
app.get('/todos', listTodos)
|
|
14
|
+
app.post('/todos', createTodo)
|
|
15
|
+
app.put('/todos/:id', replaceTodo)
|
|
16
|
+
app.patch('/todos/:id', updateTodo)
|
|
17
|
+
app.delete('/todos/:id', deleteTodo)
|
|
18
|
+
app.options('/todos', options)
|
|
19
|
+
app.all('/health', health) // any method
|
|
20
|
+
app.on('PURGE', '/cache', purge) // a custom method
|
|
21
|
+
app.on(['PUT', 'DELETE'], '/post', handler)
|
|
22
|
+
app.on('GET', ['/hello', '/en/hello'], handler)
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
`HEAD` is answered from the `GET` route with the body removed. A known path asked with another method answers 404 like an unknown path;
|
|
26
|
+
add the `methodNotAllowed` middleware to an app that should answer 405 with an `Allow` header instead.
|
|
27
|
+
|
|
28
|
+
```ts
|
|
29
|
+
import { methodNotAllowed } from 'acryl-router/middleware'
|
|
30
|
+
|
|
31
|
+
const app = new App()
|
|
32
|
+
app.use(methodNotAllowed({ app })) // POST /item -> 405, Allow: GET, HEAD
|
|
33
|
+
app.get('/item', c => c.text('item'))
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Routes can be chained, and a chained call without a path reuses the last one:
|
|
37
|
+
|
|
38
|
+
```ts
|
|
39
|
+
app
|
|
40
|
+
.get('/endpoint', c => c.text('GET /endpoint'))
|
|
41
|
+
.post(c => c.text('POST /endpoint'))
|
|
42
|
+
.delete(c => c.text('DELETE /endpoint'))
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Chaining also carries the route types, which the typed `testClient` and `acryl-router/client` read.
|
|
46
|
+
|
|
47
|
+
## Path parameters
|
|
48
|
+
|
|
49
|
+
```ts
|
|
50
|
+
app.get('/users/:userId/todos/:todoId', c => {
|
|
51
|
+
const { userId, todoId } = c.req.param()
|
|
52
|
+
return c.json({ userId, todoId })
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
app.get('/api/animal/:type?', c => c.text('Animal!')) // optional: /api/animal and /api/animal/cat
|
|
56
|
+
app.get('/post/:date{[0-9]+}/:title{[a-z]+}', handler) // constrained by a regular expression
|
|
57
|
+
app.get('/posts/:filename{.+\\.png}', handler) // a parameter that includes slashes
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Prefer explicit routes over complex patterns when both express the same rule.
|
|
61
|
+
|
|
62
|
+
## Wildcards
|
|
63
|
+
|
|
64
|
+
```ts
|
|
65
|
+
app.use('/admin/*', requireAdmin)
|
|
66
|
+
app.get('/wild/*/card', c => c.text('GET /wild/*/card'))
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
A wildcard is appropriate for a prefix boundary. It should not hide distinct resources that callers need to address independently.
|
|
70
|
+
|
|
71
|
+
## Priority
|
|
72
|
+
|
|
73
|
+
Handlers and middleware run in registration order, and the first handler that returns a response ends the request:
|
|
74
|
+
|
|
75
|
+
```ts
|
|
76
|
+
app.get('/book/a', c => c.text('a')) // GET /book/a -> a
|
|
77
|
+
app.get('/book/:slug', c => c.text('common')) // GET /book/b -> common
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Put middleware above the handlers it should wrap and a fallback such as `app.get('*', ...)` below the others.
|
|
81
|
+
|
|
82
|
+
## Grouping related routes
|
|
83
|
+
|
|
84
|
+
Build a smaller app for a feature, then place it inside another app:
|
|
85
|
+
|
|
86
|
+
```ts
|
|
87
|
+
const todos = new App()
|
|
88
|
+
todos.get('/', listTodos)
|
|
89
|
+
todos.post('/', createTodo)
|
|
90
|
+
todos.get('/:id', showTodo)
|
|
91
|
+
|
|
92
|
+
const app = new App()
|
|
93
|
+
app.route('/todos', todos)
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
`route` copies the routes the sub-app has at that moment. Register them first: a route added to `todos` after `app.route(...)` is not
|
|
97
|
+
part of `app`.
|
|
98
|
+
|
|
99
|
+
## Base paths
|
|
100
|
+
|
|
101
|
+
```ts
|
|
102
|
+
const api = new App().basePath('/api/v1')
|
|
103
|
+
api.get('/todos', listTodos) // GET /api/v1/todos
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
Do not repeat a mount prefix both in `basePath` and in the outer mount unless the doubled path is deliberate.
|
|
107
|
+
|
|
108
|
+
## Mounting from Cordis
|
|
109
|
+
|
|
110
|
+
A plugin mounts its app through the `router` service:
|
|
111
|
+
|
|
112
|
+
```ts
|
|
113
|
+
export const name = 'todo'
|
|
114
|
+
export const inject = ['router']
|
|
115
|
+
|
|
116
|
+
export function apply(ctx: Context): void {
|
|
117
|
+
const app = createTodoApp()
|
|
118
|
+
ctx.router.route('/todo', app)
|
|
119
|
+
ctx.inject(['routerHttp'], inner => { inner.routerHttp.mount('/todo') })
|
|
120
|
+
}
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
- 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
|
|
124
|
+
mounts again without duplicates. The returned function removes it sooner.
|
|
125
|
+
- `route` copies the app's routes at the call, like `app.route`; mount the app once it is complete.
|
|
126
|
+
- Mounts run in the order they were made, so middleware an earlier mount registers for a path (`app.use('/todo/*', ...)` mounted at `/`)
|
|
127
|
+
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
|
|
128
|
+
unloads the second takes over.
|
|
129
|
+
- `ctx.router.route` is the only way to register with the router.
|
|
130
|
+
- To be reachable over HTTP inside the ACRYL Host, ask the adapter to serve the prefix with `routerHttp.mount(prefix)` (an optional
|
|
131
|
+
dependency, hence the inner `ctx.inject`). A standalone `acryl-node-server` serves the whole router, so there the call is not needed.
|
|
132
|
+
|
|
133
|
+
## Not found and errors
|
|
134
|
+
|
|
135
|
+
An app's own error handler applies to that app's routes, also when the app is mounted:
|
|
136
|
+
|
|
137
|
+
```ts
|
|
138
|
+
app.onError((error, c) => {
|
|
139
|
+
console.error(error)
|
|
140
|
+
return c.json({ error: 'internal_error' }, 500)
|
|
141
|
+
})
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
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
|
|
145
|
+
the router's fallback error handler, once per router:
|
|
146
|
+
|
|
147
|
+
```ts
|
|
148
|
+
ctx.router.notFound(c => c.json({ error: 'not_found' }, 404))
|
|
149
|
+
ctx.router.onError((error, c) => c.json({ error: 'internal_error' }, 500))
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
Without them, an `HTTPException` becomes its own response, an aborted request is a 499 and anything else is logged and becomes a 500.
|
|
153
|
+
Return client errors from the handler that detected them (or throw an `HTTPException`); reserve `onError` for unexpected failures.
|
|
154
|
+
|
|
155
|
+
## Trailing slashes
|
|
156
|
+
|
|
157
|
+
`/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
|
|
158
|
+
`/` answers `/orders` and returns 404 on `/orders/`.
|
|
159
|
+
|
|
160
|
+
What each choice does for an app mounted at `/orders` with routes `/` and `/:id`, measured on the router:
|
|
161
|
+
|
|
162
|
+
| Choice | `/orders` | `/orders/` | `/orders/42` | `/orders/42/` |
|
|
163
|
+
| --- | --- | --- | --- | --- |
|
|
164
|
+
| nothing (the default) | 200 | 404 | 200 | 404 |
|
|
165
|
+
| `.use(trimTrailingSlash())` in the app | 200 | 301 to `/orders` | 200 | 301 to `/orders/42` |
|
|
166
|
+
| `acryl-router` row config `trailingSlash: 'ignore'` | 200 | 200 | 200 | 200 |
|
|
167
|
+
| `new App({ strict: false })` for the mounted app | 200 | 404 | 200 | 404 |
|
|
168
|
+
|
|
169
|
+
- **`trimTrailingSlash()`** (from `acryl-router/middleware`) is the choice a plugin makes for itself. It acts after the handlers: only
|
|
170
|
+
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
|
|
171
|
+
POST, PUT, PATCH or DELETE to `/orders/` stays a 404. `trimTrailingSlash({ alwaysRedirect: true })` redirects before the handlers run,
|
|
172
|
+
which a wildcard route (`/files/*`) needs. `appendTrailingSlash()` does the opposite, for an app whose canonical paths end in `/`.
|
|
173
|
+
- **`trailingSlash: 'ignore'`** is the router's own setting and applies to every mount at once, without a redirect. Set it in the
|
|
174
|
+
composition that owns the `acryl-router` row (the Blueprint or profile), not from one plugin.
|
|
175
|
+
- **`strict: false`** on an `App` is the core's option and works for an app you serve yourself (`app.fetch`, `app.request`). It has no
|
|
176
|
+
effect on a mounted app, because the router composes all mounts into one root app whose strictness comes from `trailingSlash`.
|
|
177
|
+
|
|
178
|
+
`examples/acryl-todo` uses `trimTrailingSlash()`: `/todo/` sends the browser to `/todo`, and the API keeps one path per route.
|
|
179
|
+
|
|
180
|
+
## Testing routes
|
|
181
|
+
|
|
182
|
+
```ts
|
|
183
|
+
const response = await app.request('/todos/42')
|
|
184
|
+
const body = await response.json()
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
Test both the application in isolation and at least one mounted route through the Cordis router. The second test proves the mount,
|
|
188
|
+
prefix and disposal contract, not just handler logic.
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# Surfaces and adapters
|
|
2
|
+
|
|
3
|
+
An ACRYL application is written once and reached through the surface that fits the user. Surfaces provide transport and presentation;
|
|
4
|
+
plugins provide capabilities.
|
|
5
|
+
|
|
6
|
+
## Surface model
|
|
7
|
+
|
|
8
|
+
| Surface | How requests arrive | Typical use |
|
|
9
|
+
| --- | --- | --- |
|
|
10
|
+
| Web | HTTP to the ACRYL Host's web server, through `acryl-router-http` | browser applications and APIs |
|
|
11
|
+
| Desktop | the same Host web server on a loopback port, opened in the Electron window | local-first applications |
|
|
12
|
+
| Terminal UI | in process: `ctx.router.request(...)` or `ctx.router.fetch(...)` | operator tools, agents, terminal workflows |
|
|
13
|
+
| Standalone process | HTTP through `acryl-node-server` | an app that runs without the Host |
|
|
14
|
+
| Tests | `ctx.router.request(...)` or `app.request(...)` | handler and contract tests |
|
|
15
|
+
|
|
16
|
+
The handler does not know which row supplied the request. When it needs a transport fact it reads the connection from the context
|
|
17
|
+
(`connectionOf(c)`), which is empty for an in-process call.
|
|
18
|
+
|
|
19
|
+
## Web
|
|
20
|
+
|
|
21
|
+
The Host owns the listening socket, the address and process shutdown. A plugin mounts its app on the router, then asks the adapter to
|
|
22
|
+
serve the prefix over HTTP:
|
|
23
|
+
|
|
24
|
+
```ts
|
|
25
|
+
ctx.router.route('/todo', app)
|
|
26
|
+
ctx.inject(['routerHttp'], inner => { inner.routerHttp.mount('/todo') })
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Only prefixes asked for this way are served over HTTP. A prefix the Host's web server already serves cannot be claimed: `mount` throws,
|
|
30
|
+
so a plugin cannot take over a Host route by mounting on it.
|
|
31
|
+
|
|
32
|
+
## Desktop
|
|
33
|
+
|
|
34
|
+
Desktop runs the same Host inside Electron, and its window talks to the Host's web server on a loopback port. A plugin's routes therefore
|
|
35
|
+
serve Desktop exactly as they serve Web, with no Desktop-specific code. Guard local control routes with `loopbackOnly` (see
|
|
36
|
+
[Middleware](./middleware.md#loopbackonly)).
|
|
37
|
+
|
|
38
|
+
A transport without a port, handing `ctx.router.fetch` to Electron's `protocol.handle`, needs no change to plugins and is a later task
|
|
39
|
+
(`specs/047-framework-port/plan.md` T014).
|
|
40
|
+
|
|
41
|
+
Desktop capabilities such as windows, files and native dialogs belong in declared Cordis services. A handler calls the service; it does not
|
|
42
|
+
import Electron.
|
|
43
|
+
|
|
44
|
+
## Terminal UI
|
|
45
|
+
|
|
46
|
+
A terminal surface needs no server. It calls the composed router in process:
|
|
47
|
+
|
|
48
|
+
```ts
|
|
49
|
+
const response = await ctx.router.request('/todo/todos')
|
|
50
|
+
const todos = await response.json()
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
The `routerHttp` service does not exist there, so the plugin's `ctx.inject(['routerHttp'], ...)` simply never runs. Check the services a
|
|
54
|
+
host declares instead of assuming a network route exists.
|
|
55
|
+
|
|
56
|
+
## What belongs where
|
|
57
|
+
|
|
58
|
+
| Concern | Owner |
|
|
59
|
+
| --- | --- |
|
|
60
|
+
| Route handlers and middleware | the feature plugin's `App` |
|
|
61
|
+
| Mount path and lifetime | the feature plugin, through `ctx.router.route` |
|
|
62
|
+
| Socket and port | the Host (`acryl-router-http`) or `acryl-node-server` |
|
|
63
|
+
| Terminal rendering and input | the terminal surface |
|
|
64
|
+
| Shared domain behavior | a domain service every surface uses |
|
|
65
|
+
| Shared reactive state | `acryl-state` or an application service |
|
|
66
|
+
|
|
67
|
+
## Loopback-only routes
|
|
68
|
+
|
|
69
|
+
Some routes exist only for the local host, such as callbacks and control endpoints. Guard them with `loopbackOnly` at the framework
|
|
70
|
+
boundary; do not rely on an obscure path or a missing link as access control. Loopback restriction is separate from authentication: a
|
|
71
|
+
local route that does privileged work still needs the host's authorization policy.
|