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.
Files changed (45) hide show
  1. package/dist/index.js +56 -2
  2. package/dist/index.js.map +1 -1
  3. package/docs/framework/LICENSE +23 -0
  4. package/docs/framework/README.md +63 -0
  5. package/docs/framework/UPSTREAM.md +3 -0
  6. package/docs/framework/api-summary.md +138 -0
  7. package/docs/framework/concepts.md +105 -0
  8. package/docs/framework/context.md +129 -0
  9. package/docs/framework/getting-started.md +146 -0
  10. package/docs/framework/helpers.md +118 -0
  11. package/docs/framework/middleware.md +154 -0
  12. package/docs/framework/migration-from-0-3.md +108 -0
  13. package/docs/framework/migration-from-dsh-webserver.md +103 -0
  14. package/docs/framework/node-server.md +75 -0
  15. package/docs/framework/plugin-lifecycle.md +111 -0
  16. package/docs/framework/request.md +106 -0
  17. package/docs/framework/routing.md +188 -0
  18. package/docs/framework/surfaces.md +71 -0
  19. package/docs/framework/upstream.json +9 -0
  20. package/docs/framework/validation.md +135 -0
  21. package/package.json +4 -2
  22. package/plugins/acryl-inspect/src/report.ts +15 -10
  23. package/plugins/acryl-inspect/tests/report.spec.ts +9 -3
  24. package/plugins/acryl-inspect/upstream.json +2 -2
  25. package/plugins/acryl-node-server/upstream.json +1 -1
  26. package/plugins/acryl-router/src/middleware/index.ts +1 -1
  27. package/plugins/acryl-router/src/middleware/loopback-only.ts +34 -1
  28. package/plugins/acryl-router/src/service.ts +9 -3
  29. package/plugins/acryl-router/tests/loopback-only.spec.ts +80 -0
  30. package/plugins/acryl-router/tests/service.spec.ts +17 -0
  31. package/plugins/acryl-router/upstream.json +2 -2
  32. package/plugins/acryl-router-http/upstream.json +1 -1
  33. package/plugins/acryl-state/upstream.json +1 -1
  34. package/plugins/builder/script.ts +1 -1
  35. package/plugins/extensions/index.ts +4 -3
  36. package/plugins/openai/prompt.ts +2 -2
  37. package/shared/AGENTS.core.md +54 -0
  38. package/templates/agent/AGENTS.md +6 -0
  39. package/templates/agent/README.md +2 -2
  40. package/templates/agent/blend.yaml +1 -1
  41. package/templates/blank/AGENTS.md +5 -0
  42. package/templates/blank/README.md +1 -1
  43. package/templates/todo/AGENTS.md +5 -0
  44. package/templates/todo/src/todo.ts +4 -2
  45. 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.
@@ -0,0 +1,9 @@
1
+ {
2
+ "source": {
3
+ "repo": "acryldev/acryl",
4
+ "path": "docs/framework",
5
+ "commit": "64e7c9d2e787f74b2da82492644fbbb46a601074"
6
+ },
7
+ "files": 16,
8
+ "digest": "sha256:f41321a2c2da4a9348634c9a6a6d7a62632d7c5906fbcba30479d569ef4bc6ef"
9
+ }