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,129 @@
1
+ # Context
2
+
3
+ Every handler and middleware receives a context, normally named `c`. The context connects the current request, the response being built,
4
+ route data, transport information and work that must finish after the response. It lives for one request.
5
+
6
+ ## Return a response
7
+
8
+ ```ts
9
+ app.get('/text', c => c.text('Hello'))
10
+ app.get('/json', c => c.json({ ok: true }))
11
+ app.get('/html', c => c.html('<h1>Hello</h1>'))
12
+ app.get('/empty', c => c.body(null, 204))
13
+ app.get('/raw', () => new Response('Good morning!'))
14
+ app.get('/notfound', c => c.notFound())
15
+ app.get('/redirect', c => c.redirect('/')) // 302
16
+ app.get('/moved', c => c.redirect('/', 301))
17
+ ```
18
+
19
+ Set the status and headers before or with the body:
20
+
21
+ ```ts
22
+ app.post('/posts', c => {
23
+ c.status(201)
24
+ c.header('X-Message', 'created')
25
+ return c.json({ created: true })
26
+ })
27
+
28
+ app.get('/welcome', c => c.body('Thank you for coming', 201, { 'X-Message': 'Hello!', 'Content-Type': 'text/plain' }))
29
+ ```
30
+
31
+ ## The response on the way out
32
+
33
+ Middleware can read or replace the response after `next()`:
34
+
35
+ ```ts
36
+ app.use(async (c, next) => {
37
+ await next()
38
+ c.res.headers.append('X-Debug', 'Debug message')
39
+ })
40
+ ```
41
+
42
+ ## Read request data
43
+
44
+ The request object is `c.req`:
45
+
46
+ ```ts
47
+ const id = c.req.param('id')
48
+ const search = c.req.query('q')
49
+ const agent = c.req.header('User-Agent')
50
+ ```
51
+
52
+ See [Request](./request.md) for bodies, repeated values and validated input.
53
+
54
+ ## Store request-local values
55
+
56
+ `c.set` and `c.get` (or `c.var.name`) pass data from middleware to handlers. Declare the variables as the app's type parameter so both
57
+ sides agree:
58
+
59
+ ```ts
60
+ const app = new App<{ Variables: { requestId: string } }>()
61
+
62
+ app.use(async (c, next) => {
63
+ c.set('requestId', crypto.randomUUID())
64
+ await next()
65
+ })
66
+
67
+ app.get('/', c => c.text(c.get('requestId')))
68
+ ```
69
+
70
+ These values live only for the current request. Do not use them as a cache or as application state. `createMiddleware` (from
71
+ `acryl-router/helpers`) types a middleware that sets variables, and chained `.use(...)` calls accumulate their types; see
72
+ [Middleware](./middleware.md).
73
+
74
+ ## Transport facts
75
+
76
+ `c.env` carries what the caller of the router supplied for this request. Under the router that is `{ connection }`: the socket's peer
77
+ address and the real local port, filled by the HTTP adapter and empty for an in-process call. Transport facts never travel in headers.
78
+
79
+ ```ts
80
+ import { App, connectionOf, type RouterEnv } from 'acryl-router'
81
+ import { getConnInfo } from 'acryl-router/helpers'
82
+
83
+ const app = new App<RouterEnv>() // types c.env.connection
84
+ app.get('/peer', c => c.json({
85
+ connection: connectionOf(c), // { remoteAddress?, localPort? }
86
+ info: getConnInfo(c), // the same peer in the core's conninfo shape, for middleware such as ipRestriction
87
+ }))
88
+ ```
89
+
90
+ The same handler then runs over HTTP, in process or in a test, and reads an empty connection where there is no socket. Guards such as
91
+ `loopbackOnly` refuse an empty one unless told otherwise.
92
+
93
+ ## Cancellation and background work
94
+
95
+ - `c.req.raw.signal` aborts when the caller goes away, when the plugin whose code is serving the request unloads, and when the router is
96
+ disposed. Long work
97
+ should pass it on (`fetch(url, { signal: c.req.raw.signal })`) or check it.
98
+ - `c.executionCtx.waitUntil(promise)` keeps the request in flight until the promise settles, for work that continues after the response
99
+ is returned.
100
+
101
+ Both belong to the current request, and both end with their owner: see [Plugin lifecycle](./plugin-lifecycle.md#disposal-sequence).
102
+
103
+ ## Errors and control flow
104
+
105
+ Return a response for expected failures, or throw an `HTTPException`, which becomes its own response:
106
+
107
+ ```ts
108
+ import { HTTPException } from 'acryl-router'
109
+
110
+ if (!todo) return c.json({ error: 'todo_not_found' }, 404)
111
+ if (!allowed) throw new HTTPException(403, { message: 'not yours' })
112
+ ```
113
+
114
+ Anything else that is thrown goes to the app's `onError`, or to the router's when the app has none. A middleware's `await next()` never
115
+ throws: the error has already become a response by then, and the error itself is in `c.error`:
116
+
117
+ ```ts
118
+ app.use(async (c, next) => {
119
+ await next()
120
+ if (c.error) report(c.error)
121
+ })
122
+ ```
123
+
124
+ ## Context rules
125
+
126
+ - Treat the context as request-local.
127
+ - Do not retain it after the handler returns.
128
+ - Do not put sockets, server objects or Cordis internals into context values for other plugins to discover accidentally.
129
+ - Put cross-plugin capabilities in a declared Cordis service, not in `c.set`.
@@ -0,0 +1,146 @@
1
+ # Getting started
2
+
3
+ The ACRYL Framework puts its application model inside Cordis. You build an ordinary `App`, 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** - `App`, 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 `App` 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 { App } from 'acryl-router'
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 App()
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 `acryl-router/middleware` 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 'acryl-router/middleware'
57
+
58
+ const app = new App()
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 App({ 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 `App` you serve yourself (`app.fetch`, `app.request`). Details
69
+ and a measured table: [Routing and mounting](./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 App()
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](./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](./routing.md)
145
+ - [Context](./context.md)
146
+ - [Plugin lifecycle and disposal](./plugin-lifecycle.md)
@@ -0,0 +1,118 @@
1
+ # Helpers
2
+
3
+ Helpers are small utilities an application should not have to rewrite. They are plain functions over the context, not Cordis services.
4
+ Import them from `acryl-router/helpers`, except the typed client, which has its own entry, `acryl-router/client`.
5
+
6
+ | Helper | Use |
7
+ | --- | --- |
8
+ | `stream`, `streamText`, `streamSSE` | streaming responses and server-sent events |
9
+ | `getCookie`, `setCookie`, `deleteCookie`, `getSignedCookie`, `setSignedCookie`, `generateCookie`, `generateSignedCookie` | cookies |
10
+ | `testClient` | a typed client that calls an app in process, for tests |
11
+ | `validator`, `schemaValidator` | input validation, see [Validation](./validation.md) |
12
+ | `createMiddleware`, `createFactory` | typed middleware and handlers that share variables |
13
+ | `html`, `raw` | escaped HTML templates |
14
+ | `accepts` | content negotiation from `Accept` headers |
15
+ | `routePath`, `matchedRoutes`, `baseRoutePath`, `basePath` | the route a request matched |
16
+ | `getConnInfo`, `connectionOf` | the peer and local port, see [Context](./context.md#transport-facts) |
17
+ | `getMimeType`, `getExtension`, `mimes` | media types by file extension |
18
+ | `defineWebSocketHelper`, `WSContext` and their types | the runtime-neutral WebSocket pieces an adapter builds on |
19
+
20
+ JWT signing and verification (`sign`, `verify`, `decode`) live with the `jwt` middleware in `acryl-router/middleware`.
21
+
22
+ ## Streaming
23
+
24
+ ```ts
25
+ import { stream, streamSSE, streamText } from 'acryl-router/helpers'
26
+
27
+ app.get('/stream', c => stream(c, async stream => {
28
+ stream.onAbort(() => console.log('aborted'))
29
+ await stream.write(new Uint8Array([0x48, 0x69]))
30
+ await stream.pipe(anotherReadableStream)
31
+ }))
32
+
33
+ app.get('/text', c => streamText(c, async stream => {
34
+ await stream.writeln('Hello')
35
+ await stream.sleep(1000)
36
+ await stream.write('there')
37
+ }))
38
+ ```
39
+
40
+ Server-sent events write one message at a time with `writeSSE`:
41
+
42
+ ```ts
43
+ app.get('/events', c => streamSSE(c, async stream => {
44
+ let id = 0
45
+ while (!stream.aborted) {
46
+ await stream.writeSSE({ event: 'time', data: new Date().toISOString(), id: String(id++) })
47
+ await stream.sleep(1000)
48
+ }
49
+ }))
50
+ ```
51
+
52
+ - The callback runs after the response is returned. When it throws, the optional third argument `onError(error, stream)` runs and the
53
+ stream sends an `error` event before it closes.
54
+ - There is no built-in keep-alive: send a comment or an event on a timer when a proxy closes idle connections.
55
+ - When the plugin that serves the stream unloads, or the router is disposed, the router cancels the response body, so `onAbort` runs,
56
+ `aborted` turns true and a loop that checks `stream.aborted` ends with its owner.
57
+
58
+ ## Cookies
59
+
60
+ ```ts
61
+ import { deleteCookie, getCookie, getSignedCookie, setCookie, setSignedCookie } from 'acryl-router/helpers'
62
+
63
+ app.get('/cookie', c => {
64
+ setCookie(c, 'theme', 'dark', { path: '/', httpOnly: true, secure: true, sameSite: 'Strict', maxAge: 60 * 60 * 24 })
65
+ const theme = getCookie(c, 'theme')
66
+ const all = getCookie(c)
67
+ deleteCookie(c, 'old')
68
+ return c.json({ theme, all })
69
+ })
70
+
71
+ app.get('/signed', async c => {
72
+ await setSignedCookie(c, 'session', 'value', secret)
73
+ const session = await getSignedCookie(c, secret, 'session') // false when the signature does not match
74
+ return c.json({ session })
75
+ })
76
+ ```
77
+
78
+ `generateCookie` and `generateSignedCookie` return the `Set-Cookie` string without touching a context. The `__Secure-` and `__Host-`
79
+ prefixes are available through `{ prefix: 'secure' }` and `{ prefix: 'host' }`, and the options are checked against their rules.
80
+
81
+ ## Testing
82
+
83
+ `app.request(path, init?)` calls an app in process and returns a `Response`. `testClient` adds the route types:
84
+
85
+ ```ts
86
+ import { testClient } from 'acryl-router/helpers'
87
+
88
+ const app = new App()
89
+ .get('/search', c => c.json({ query: c.req.query('q') }))
90
+
91
+ const client = testClient(app)
92
+ const response = await client.search.$get({ query: { q: 'cordis' } })
93
+ expect(await response.json()).toEqual({ query: 'cordis' })
94
+ ```
95
+
96
+ The routes must be chained on one expression (as above) for their types to reach the client. Add a test through the Cordis router as well:
97
+ an app test cannot prove the mount, the prefix or the disposal.
98
+
99
+ ## The typed client
100
+
101
+ `acryl-router/client` is the same client for a real server. It runs in a browser or any runtime with `fetch`, without Cordis:
102
+
103
+ ```ts
104
+ import { hc } from 'acryl-router/client'
105
+ import type { SearchRoutes } from 'my-server' // the type of the chained app, exported by the server package
106
+
107
+ const client = hc<SearchRoutes>('http://127.0.0.1:3290/search-app')
108
+ const response = await client.search.$get({ query: { q: 'cordis' } })
109
+ ```
110
+
111
+ Export the app's type (`export type SearchRoutes = typeof app`) from the server package; the client imports only the type, never the
112
+ server code.
113
+
114
+ ## Not yet ported
115
+
116
+ Proxying, a WebSocket upgrade under the router, JSX, static-site generation, CSS-in-JS and the cache helpers are later tasks (see
117
+ `specs/047-framework-port/plan.md`). Add a missing helper to the framework as a ported, tested helper, not as private code in one
118
+ application.
@@ -0,0 +1,154 @@
1
+ # Middleware
2
+
3
+ Middleware is code that runs around a handler. Use it for behavior shared by several routes: authentication, authorization, logging, CORS,
4
+ caching, compression, request IDs and timing.
5
+
6
+ ## Write middleware
7
+
8
+ ```ts
9
+ import type { MiddlewareHandler } from 'acryl-router'
10
+
11
+ const timing: MiddlewareHandler = async (c, next) => {
12
+ const started = performance.now()
13
+ await next()
14
+ c.header('X-Response-Time', String(Math.round(performance.now() - started)))
15
+ }
16
+
17
+ app.use(timing)
18
+ ```
19
+
20
+ `await next()` runs the remaining middleware and then the handler. Not calling it ends the chain, so middleware that refuses a request
21
+ returns a response itself:
22
+
23
+ ```ts
24
+ const requireAdmin: MiddlewareHandler = async (c, next) => {
25
+ if (!isAdmin(c)) return c.json({ error: 'forbidden' }, 403)
26
+ await next()
27
+ }
28
+ ```
29
+
30
+ Middleware does not return a response *and* call `next()`: after `next()` it changes `c.res` (or assigns a new `Response` to it) instead.
31
+
32
+ Authentication establishes an identity; authorization decides whether that identity may do this. Keep them as separate middleware when
33
+ their failure answers differ.
34
+
35
+ ## Scope and order
36
+
37
+ ```ts
38
+ app.use(requestLogger) // every route of this app
39
+ app.use('/admin/*', requireAdmin) // below a prefix
40
+ app.on(['POST', 'PUT'], '/items/*', audit)
41
+ ```
42
+
43
+ Middleware runs in registration order and only wraps routes registered after it. The first middleware registered is the outermost: its code
44
+ before `next()` runs first and its code after `next()` runs last.
45
+
46
+ ## Share values with handlers
47
+
48
+ `createMiddleware` types the variables a middleware sets, and the app picks them up when it is chained:
49
+
50
+ ```ts
51
+ import { createMiddleware } from 'acryl-router/helpers'
52
+
53
+ const withUser = createMiddleware<{ Variables: { user: User } }>(async (c, next) => {
54
+ c.set('user', await loadUser(c))
55
+ await next()
56
+ })
57
+
58
+ const app = new App().use(withUser).get('/me', c => c.json(c.var.user))
59
+ ```
60
+
61
+ Only request-local values belong here. Shared services belong behind Cordis injection.
62
+
63
+ ## Errors
64
+
65
+ `await next()` does not throw. When a handler or later middleware throws, the error becomes a response first (an `HTTPException` its own,
66
+ anything else through `onError`) and is left in `c.error`:
67
+
68
+ ```ts
69
+ app.use(async (c, next) => {
70
+ await next()
71
+ if (c.error) metrics.count('request_failed')
72
+ })
73
+ ```
74
+
75
+ So a `try { await next() } catch` around `next()` never catches anything. Map domain errors to answers in `app.onError`:
76
+
77
+ ```ts
78
+ app.onError((error, c) => {
79
+ if (error instanceof DomainError) return c.json({ error: error.code }, error.status)
80
+ console.error(error)
81
+ return c.json({ error: 'internal_error' }, 500)
82
+ })
83
+ ```
84
+
85
+ ## Built-in middleware
86
+
87
+ Import from `acryl-router/middleware`:
88
+
89
+ | Middleware | What it does |
90
+ | --- | --- |
91
+ | `basicAuth`, `bearerAuth` | HTTP Basic and Bearer authentication |
92
+ | `jwt`, `jwk` (with `sign`, `verify`, `decode`, `verifyWithJwks`) | JSON Web Token verification, from a secret or a key set |
93
+ | `bodyLimit` | refuse a request body over a size, with 413, while it streams |
94
+ | `cors` | CORS headers and preflight answers |
95
+ | `csrf` | refuse a cross-site form submission by its `Origin` or `Sec-Fetch-Site` |
96
+ | `secureHeaders` (with `NONCE`) | security headers, including a content security policy with nonces |
97
+ | `ipRestriction` | allow or deny by the peer address (read through `getConnInfo`) |
98
+ | `loopbackOnly` (with `loopbackAuthority`, `sameOrigin`) | only requests from this machine to this app; see below |
99
+ | `compress` | gzip or deflate the response |
100
+ | `etag` | ETag headers and 304 answers |
101
+ | `logger` | one log line per request and response |
102
+ | `requestId` | a request ID header and `c.var.requestId` |
103
+ | `timing` (with `startTime`, `endTime`, `setMetric`, `wrapTime`) | `Server-Timing` headers |
104
+ | `timeout` | answer with an error when a handler takes too long |
105
+ | `prettyJSON` | indented JSON when the query asks for it |
106
+ | `poweredBy` | an `X-Powered-By` header |
107
+ | `methodNotAllowed({ app })` | 405 with an `Allow` header for a known path of `app` asked with another method |
108
+ | `methodOverride` | take the method from a form field, header or query |
109
+ | `trimTrailingSlash`, `appendTrailingSlash` | redirect to the canonical path |
110
+ | `languageDetector` (with `detectFromPath`, `detectFromQuery`, `detectFromCookie`, `detectFromHeader`) | the request's language as a variable |
111
+ | `contextStorage` (with `getContext`, `tryGetContext`) | the current context from code that is not given `c` |
112
+ | `every`, `some`, `except` | combine middleware: all, the first that passes, or all except on some paths |
113
+ | `serveStatic` | the runtime-neutral static file middleware; it needs a `getContent`. On Node use the one from `acryl-node-server` |
114
+
115
+ ### loopbackOnly
116
+
117
+ The guard for a local app's own routes, such as a control API a desktop window calls:
118
+
119
+ ```ts
120
+ import { loopbackOnly } from 'acryl-router/middleware'
121
+
122
+ app.use('/control/*', loopbackOnly())
123
+ ```
124
+
125
+ A request passes only when:
126
+
127
+ - the peer is a loopback address and arrived on the real local port, both read from the transport, never from a header;
128
+ - the `Host` header names a loopback hostname (`127.0.0.1`, `localhost`, `[::1]`, or `hostnames`) with that port, which stops DNS
129
+ rebinding;
130
+ - for a request that changes things (not `GET`, `HEAD`, `OPTIONS`), any `Origin` is this same authority (`mutations: 'none'` turns this
131
+ off).
132
+
133
+ An in-process call has no socket and is refused unless `inProcess: 'allow'` says the host's own code may call it. Everything refused gets a
134
+ 403.
135
+
136
+ A private route that no other program on the machine may call either, such as a workspace's file and terminal routes, takes
137
+ `callers: 'own-page'`:
138
+
139
+ ```ts
140
+ app.use('/api/workspace/*', loopbackOnly({ callers: 'own-page', hostnames: ['127.0.0.1'] }))
141
+ ```
142
+
143
+ Then every request must come from a page this app served: an `Origin` that is exactly this authority (with `Sec-Fetch-Site` absent or
144
+ `same-origin`), or, for a read, which browsers often send without `Origin`, `Sec-Fetch-Site: same-origin` and a `Referer` from this
145
+ authority. A script with no `Origin` is refused even for a read. `fromOwnPage(c, authority)` is the same check for middleware of your own.
146
+
147
+ ## Middleware in Cordis
148
+
149
+ Middleware belongs to an `App`; it is not a separate Cordis registration, and it disappears with the mount that serves it. A plugin may
150
+ build middleware from an injected service, but resources the middleware uses (timers, watchers, connections) are acquired in the plugin's
151
+ `ctx.effect()` and only the finished middleware goes into the app. Do not start timers, watchers or sockets from a handler.
152
+
153
+ Middleware an app registers with a path wraps the matching routes of every later mount too (an app mounted at `/` with
154
+ `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,108 @@
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 `App` 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
12
+ import { HttpError } from 'acryl-router'
13
+ import { readJson, streamSSE, valid, validate } from 'acryl-router/helpers'
14
+ import { bodyLimit } from 'acryl-router/middleware'
15
+
16
+ export function apply(ctx: Context): void {
17
+ const api = ctx.router.group('/todo')
18
+ api.use(bodyLimit({ maxBytes: 16 * 1024 }))
19
+ api.get('/todos/:id', c => {
20
+ const todo = find(c.param('id'))
21
+ if (!todo) throw new HttpError(404, { message: 'no such todo' })
22
+ return c.json(todo)
23
+ })
24
+ api.post('/todos', validate('json', NewTodo), c => c.json(create(valid(c, 'json')), 201))
25
+ api.get('/events', c => streamSSE(c, stream => {
26
+ const off = events.on('change', todo => stream.send(todo, { event: 'change' }))
27
+ return off
28
+ }))
29
+ ctx.inject(['routerHttp'], inner => { inner.routerHttp.mount('/todo') })
30
+ }
31
+ ```
32
+
33
+ 0.4.0:
34
+
35
+ ```ts
36
+ import { App, HTTPException } from 'acryl-router'
37
+ import { schemaValidator, streamSSE } from 'acryl-router/helpers'
38
+ import { bodyLimit } from 'acryl-router/middleware'
39
+
40
+ export function apply(ctx: Context): void {
41
+ const app = new App()
42
+ .use(bodyLimit({ maxSize: 16 * 1024 }))
43
+ .get('/todos/:id', c => {
44
+ const todo = find(c.req.param('id'))
45
+ if (!todo) throw new HTTPException(404, { message: 'no such todo' })
46
+ return c.json(todo)
47
+ })
48
+ .post('/todos', schemaValidator('json', NewTodo), c => c.json(create(c.req.valid('json')), 201))
49
+ .get('/events', c => streamSSE(c, async stream => {
50
+ const off = events.on('change', todo => { void stream.writeSSE({ event: 'change', data: JSON.stringify(todo) }) })
51
+ stream.onAbort(off)
52
+ while (!stream.aborted) await stream.sleep(15_000).then(() => stream.write(': keep-alive\n\n'))
53
+ }))
54
+
55
+ ctx.router.route('/todo', app)
56
+ ctx.inject(['routerHttp'], inner => { inner.routerHttp.mount('/todo') })
57
+ }
58
+ ```
59
+
60
+ Public paths do not change: the mount path plus the app's route path is the old group prefix plus the old route path.
61
+
62
+ ## What maps to what
63
+
64
+ | 0.3.0 | 0.4.0 |
65
+ | --- | --- |
66
+ | `ctx.router.get/post/put/patch/delete/all/on(path, ...)` | the same methods on an `App`, then `ctx.router.route(prefix, app)` |
67
+ | `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) |
68
+ | `ctx.router.group(prefix, ...middleware)` | one `App` per group mounted at `prefix`, or `app.route(prefix, sub)` / `app.basePath(prefix)` |
69
+ | the remover each registration returned | the one remover `ctx.router.route` returns; the mount is owned by the calling plugin as before |
70
+ | `ctx.router.onError`, `ctx.router.notFound` | unchanged; an app's own `app.onError` now also applies to its routes |
71
+ | `c.param(name)`, `c.params` | `c.req.param(name)`, `c.req.param()` |
72
+ | `c.query(name)`, `c.query()` | `c.req.query(name)`, `c.req.query()` (and `c.req.queries(name)` for repeated values) |
73
+ | `c.url` | `new URL(c.req.url)`, or `c.req.path` |
74
+ | `c.req` (the Web `Request`) | `c.req.raw`; `c.req` is now the request wrapper |
75
+ | `c.connection` | `connectionOf(c)` (or `getConnInfo(c)`); type the app `new App<RouterEnv>()` to read `c.env.connection` |
76
+ | `c.signal` | `c.req.raw.signal` |
77
+ | `c.waitUntil(task)` | `c.executionCtx.waitUntil(task)` |
78
+ | `c.get(key)` / `c.set(key, value)` | unchanged, typed by the app's `Variables` (`c.var.key` also works) |
79
+ | `c.json/text/html/body/redirect/notFound/header/status` | unchanged |
80
+ | `HttpError(status, { message, response, cause })` | `HTTPException(status, { message, res, cause })` |
81
+ | `readJson(c)`, `readText(c)`, `readBytes(c)` with `{ maxBytes }` | `bodyLimit({ maxSize })` in front, then `c.req.json()`, `c.req.text()`, `c.req.arrayBuffer()` |
82
+ | `bodyLimit({ maxBytes })` | `bodyLimit({ maxSize })` |
83
+ | `validate(target, schema)` and `valid(c, target)` | `schemaValidator(target, schema)` and `c.req.valid(target)`, with more targets (`form`, `header`, `cookie`) |
84
+ | `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 |
85
+ | the SSE stream's automatic keep-alive comment | none: write a comment on a timer when a proxy closes idle connections |
86
+ | the cleanup function `run` returned | `stream.onAbort(cleanup)`; the callback's end closes the stream |
87
+ | a 405 with `Allow` for a known path and another method | 404 by default; add the `methodNotAllowed` middleware for 405 |
88
+ | the `Middleware` type, `(c, next) => Response` | `MiddlewareHandler`, `async (c, next) => { await next() }`; change `c.res` after `next()` instead of returning it |
89
+ | `logger`, `cors`, `requestId`, `secureHeaders`, `loopbackOnly` | same names in `acryl-router/middleware`; the first four now take the core's options |
90
+
91
+ ## Behavior changes to check
92
+
93
+ - **A mount is a snapshot.** Routes an app registers after `ctx.router.route(...)` are not served. 0.3.0 registered each call live.
94
+ - **A JSON validator needs a JSON `Content-Type`.** Without it the schema sees `{}`. 0.3.0 read the body whatever its type.
95
+ - **`await next()` does not throw.** A thrown error has already become a response; read it from `c.error`.
96
+ - **Trailing slashes are strict by default, as before**, and `trailingSlash: 'ignore'` still treats `/a` and `/a/` as one path.
97
+ - **Disposal is unchanged:** a plugin's unload ends the requests and streams its code is serving, and router disposal ends all of them,
98
+ bounded by `disposeGraceMs`.
99
+
100
+ ## Checklist
101
+
102
+ For each plugin:
103
+
104
+ - collect its route registrations into one app per mount path and keep the public paths;
105
+ - move middleware and error handling onto the owning app;
106
+ - replace the context and helper calls from the table above;
107
+ - run its route tests, then one activation test that unloads and reloads the plugin;
108
+ - 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.