create-acrylblends 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/create-acrylblends.js +4 -0
- package/dist/index.d.ts +56 -0
- package/dist/index.js +274 -0
- package/dist/index.js.map +1 -0
- package/docs/framework/LICENSE +23 -0
- package/docs/framework/README.md +63 -0
- package/docs/framework/UPSTREAM.md +3 -0
- package/docs/framework/agents/index.md +24 -0
- package/docs/framework/api/acryl.md +259 -0
- package/docs/framework/api/context.md +527 -0
- package/docs/framework/api/exception.md +80 -0
- package/docs/framework/api/index.md +12 -0
- package/docs/framework/api/presets.md +65 -0
- package/docs/framework/api/request.md +396 -0
- package/docs/framework/api/routing.md +303 -0
- package/docs/framework/api/summary.md +140 -0
- package/docs/framework/concepts/benchmarks.md +26 -0
- package/docs/framework/concepts/developer-experience.md +5 -0
- package/docs/framework/concepts/middleware.md +21 -0
- package/docs/framework/concepts/motivation.md +14 -0
- package/docs/framework/concepts/routers.md +63 -0
- package/docs/framework/concepts/stacks.md +235 -0
- package/docs/framework/concepts/web-standard.md +37 -0
- package/docs/framework/cordis/acryl-in-a-cordis-app.md +146 -0
- package/docs/framework/cordis/concepts.md +105 -0
- package/docs/framework/cordis/middleware.md +8 -0
- package/docs/framework/cordis/migration-from-0-3.md +111 -0
- package/docs/framework/cordis/migration-from-0-4.md +14 -0
- package/docs/framework/cordis/migration-from-dsh-webserver.md +103 -0
- package/docs/framework/cordis/mounting.md +85 -0
- package/docs/framework/cordis/node-server.md +75 -0
- package/docs/framework/cordis/plugin-lifecycle.md +111 -0
- package/docs/framework/cordis/surfaces.md +71 -0
- package/docs/framework/cordis/transport-and-cancellation.md +57 -0
- package/docs/framework/cordis/validation.md +135 -0
- package/docs/framework/getting-started/basic.md +196 -0
- package/docs/framework/getting-started/bun.md +221 -0
- package/docs/framework/getting-started/nodejs.md +344 -0
- package/docs/framework/getting-started/other-runtimes.md +23 -0
- package/docs/framework/guides/best-practices.md +231 -0
- package/docs/framework/guides/create-acrylblends.md +79 -0
- package/docs/framework/guides/examples.md +3 -0
- package/docs/framework/guides/faq.md +19 -0
- package/docs/framework/guides/helpers.md +33 -0
- package/docs/framework/guides/jsx-dom.md +299 -0
- package/docs/framework/guides/jsx.md +440 -0
- package/docs/framework/guides/middleware.md +253 -0
- package/docs/framework/guides/rpc.md +886 -0
- package/docs/framework/guides/testing.md +133 -0
- package/docs/framework/guides/validation.md +383 -0
- package/docs/framework/helpers/accepts.md +60 -0
- package/docs/framework/helpers/adapter.md +87 -0
- package/docs/framework/helpers/conninfo.md +112 -0
- package/docs/framework/helpers/cookie.md +232 -0
- package/docs/framework/helpers/css.md +285 -0
- package/docs/framework/helpers/dev.md +67 -0
- package/docs/framework/helpers/factory.md +180 -0
- package/docs/framework/helpers/html.md +123 -0
- package/docs/framework/helpers/jwt.md +184 -0
- package/docs/framework/helpers/proxy.md +118 -0
- package/docs/framework/helpers/route.md +143 -0
- package/docs/framework/helpers/ssg.md +393 -0
- package/docs/framework/helpers/streaming.md +119 -0
- package/docs/framework/helpers/testing.md +101 -0
- package/docs/framework/helpers/websocket.md +215 -0
- package/docs/framework/middleware/builtin/basic-auth.md +165 -0
- package/docs/framework/middleware/builtin/bearer-auth.md +138 -0
- package/docs/framework/middleware/builtin/body-limit.md +71 -0
- package/docs/framework/middleware/builtin/cache.md +122 -0
- package/docs/framework/middleware/builtin/combine.md +94 -0
- package/docs/framework/middleware/builtin/compress.md +59 -0
- package/docs/framework/middleware/builtin/context-storage.md +78 -0
- package/docs/framework/middleware/builtin/cors.md +135 -0
- package/docs/framework/middleware/builtin/csrf.md +105 -0
- package/docs/framework/middleware/builtin/etag.md +54 -0
- package/docs/framework/middleware/builtin/ip-restriction.md +69 -0
- package/docs/framework/middleware/builtin/jsx-renderer.md +286 -0
- package/docs/framework/middleware/builtin/jwk.md +192 -0
- package/docs/framework/middleware/builtin/jwt.md +145 -0
- package/docs/framework/middleware/builtin/language.md +232 -0
- package/docs/framework/middleware/builtin/logger.md +74 -0
- package/docs/framework/middleware/builtin/loopback-only.md +31 -0
- package/docs/framework/middleware/builtin/method-not-allowed.md +48 -0
- package/docs/framework/middleware/builtin/method-override.md +80 -0
- package/docs/framework/middleware/builtin/mount.md +106 -0
- package/docs/framework/middleware/builtin/pretty-json.md +53 -0
- package/docs/framework/middleware/builtin/request-id.md +94 -0
- package/docs/framework/middleware/builtin/secure-headers.md +247 -0
- package/docs/framework/middleware/builtin/timeout.md +98 -0
- package/docs/framework/middleware/builtin/timing.md +101 -0
- package/docs/framework/middleware/builtin/trailing-slash.md +74 -0
- package/docs/framework/middleware/third-party.md +4 -0
- package/docs/framework/upstream.json +9 -0
- package/package.json +47 -3
- package/templates/bun/gitignore +1 -0
- package/templates/bun/package.json +19 -0
- package/templates/bun/src/index.ts +7 -0
- package/templates/bun/test/app.test.ts +8 -0
- package/templates/bun/tsconfig.json +15 -0
- package/templates/nodejs/gitignore +1 -0
- package/templates/nodejs/package.json +22 -0
- package/templates/nodejs/src/app.ts +7 -0
- package/templates/nodejs/src/index.ts +9 -0
- package/templates/nodejs/test/app.test.ts +9 -0
- package/templates/nodejs/tsconfig.json +16 -0
- package/README.md +0 -3
|
@@ -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,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](../concepts/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 `Acryl` |
|
|
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,57 @@
|
|
|
1
|
+
# Transport facts and cancellation
|
|
2
|
+
|
|
3
|
+
`c.env` carries what the caller of the router supplied for this request. Under the router that is `{ connection }`: the socket's peer
|
|
4
|
+
address and the real local port, filled by the HTTP adapter and empty for an in-process call. Transport facts never travel in headers.
|
|
5
|
+
|
|
6
|
+
```ts
|
|
7
|
+
import { Acryl } from 'acrylblends'
|
|
8
|
+
import { connectionOf, type RouterEnv } from 'acrylblends/conninfo'
|
|
9
|
+
import { getConnInfo } from 'acrylblends/conninfo'
|
|
10
|
+
|
|
11
|
+
const app = new Acryl<RouterEnv>() // types c.env.connection
|
|
12
|
+
app.get('/peer', c => c.json({
|
|
13
|
+
connection: connectionOf(c), // { remoteAddress?, localPort? }
|
|
14
|
+
info: getConnInfo(c), // the same peer in the core's conninfo shape, for middleware such as ipRestriction
|
|
15
|
+
}))
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
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
|
|
19
|
+
`loopbackOnly` refuse an empty one unless told otherwise.
|
|
20
|
+
|
|
21
|
+
## Cancellation and background work
|
|
22
|
+
|
|
23
|
+
- `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
|
|
24
|
+
disposed. Long work
|
|
25
|
+
should pass it on (`fetch(url, { signal: c.req.raw.signal })`) or check it.
|
|
26
|
+
- `c.executionCtx.waitUntil(promise)` keeps the request in flight until the promise settles, for work that continues after the response
|
|
27
|
+
is returned.
|
|
28
|
+
|
|
29
|
+
Both belong to the current request, and both end with their owner: see [Plugin lifecycle](./plugin-lifecycle.md#disposal-sequence).
|
|
30
|
+
|
|
31
|
+
## Errors and control flow
|
|
32
|
+
|
|
33
|
+
Return a response for expected failures, or throw an `HTTPException`, which becomes its own response:
|
|
34
|
+
|
|
35
|
+
```ts
|
|
36
|
+
import { HTTPException } from 'acrylblends/http-exception'
|
|
37
|
+
|
|
38
|
+
if (!todo) return c.json({ error: 'todo_not_found' }, 404)
|
|
39
|
+
if (!allowed) throw new HTTPException(403, { message: 'not yours' })
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
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
|
|
43
|
+
throws: the error has already become a response by then, and the error itself is in `c.error`:
|
|
44
|
+
|
|
45
|
+
```ts
|
|
46
|
+
app.use(async (c, next) => {
|
|
47
|
+
await next()
|
|
48
|
+
if (c.error) report(c.error)
|
|
49
|
+
})
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Context rules
|
|
53
|
+
|
|
54
|
+
- Treat the context as request-local.
|
|
55
|
+
- Do not retain it after the handler returns.
|
|
56
|
+
- Do not put sockets, server objects or Cordis internals into context values for other plugins to discover accidentally.
|
|
57
|
+
- Put cross-plugin capabilities in a declared Cordis service, not in `c.set`.
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
# Validation
|
|
2
|
+
|
|
3
|
+
Validate untrusted input at the route boundary. `schemaValidator` takes any [Standard Schema](https://standardschema.dev): Schemastery
|
|
4
|
+
(the schema library Cordis already uses for plugin config), Zod and Valibot all implement it, so a route needs no extra dependency.
|
|
5
|
+
|
|
6
|
+
## Validate JSON
|
|
7
|
+
|
|
8
|
+
```ts
|
|
9
|
+
import z from '@deepseek-ai/schemastery'
|
|
10
|
+
import { Acryl } from 'acrylblends'
|
|
11
|
+
import { schemaValidator } from 'acrylblends/schema-validator'
|
|
12
|
+
|
|
13
|
+
const NewTodo = z.object({
|
|
14
|
+
title: z.string().required(),
|
|
15
|
+
done: z.boolean().default(false),
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
const app = new Acryl().post('/todos', schemaValidator('json', NewTodo), c => {
|
|
19
|
+
const todo = c.req.valid('json') // typed by the schema's output
|
|
20
|
+
return c.json({ todo }, 201)
|
|
21
|
+
})
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
A validated route has three parts: a schema for one input target, the validator middleware for that target, and a handler that reads
|
|
25
|
+
`c.req.valid(target)` instead of the raw body.
|
|
26
|
+
|
|
27
|
+
## Failures
|
|
28
|
+
|
|
29
|
+
When the schema reports issues the handler does not run and the answer is 400 with `Content-Type: application/json` and exactly this
|
|
30
|
+
shape:
|
|
31
|
+
|
|
32
|
+
```ts
|
|
33
|
+
{ error: `invalid ${target}`, issues: Array<{ message: string, path: string }> }
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
```json
|
|
37
|
+
{ "error": "invalid json", "issues": [{ "message": "title must be 1 to 200 characters", "path": "title" }] }
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
- `error` names the target: `invalid json`, `invalid query`, `invalid param`, ...
|
|
41
|
+
- There is one entry per issue the schema reported, in its order, with the schema's own `message`.
|
|
42
|
+
- `path` is the issue's path joined with dots: `["items", 0, "title"]` becomes `"items.0.title"`, and a path segment given as
|
|
43
|
+
`{ key }` (as Standard Schema allows) contributes its `key`.
|
|
44
|
+
- An issue without a path, which a hand-written Standard Schema often reports for a whole-value check, gets `"path": ""`.
|
|
45
|
+
`{ issues: [{ message: 'not allowed' }] }` answers `{ "error": "invalid json", "issues": [{ "message": "not allowed", "path": "" }] }`.
|
|
46
|
+
Give issues a path when a client should point at a field.
|
|
47
|
+
|
|
48
|
+
### A JSON body without a JSON content type
|
|
49
|
+
|
|
50
|
+
A `json` target reads the body only when the request's `Content-Type` is JSON (`application/json`, with or without parameters such as
|
|
51
|
+
`; charset=utf-8`, or `application/*+json`). Any other content type, or none, is not an error by itself: the schema sees `{}`. So a
|
|
52
|
+
client that forgets the header gets a misleading field error for a field it did send:
|
|
53
|
+
|
|
54
|
+
```sh
|
|
55
|
+
curl -X POST -d '{"title":"milk"}' http://127.0.0.1:<port>/todo/api # no content type: curl sends a form type
|
|
56
|
+
# 400 {"error":"invalid json","issues":[{"message":"title must be 1 to 200 characters","path":"title"}]}
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
If you see a field error for a field the request clearly carries, check the request's `Content-Type` first. A route that wants to say so
|
|
60
|
+
explicitly can refuse other content types before the validator:
|
|
61
|
+
|
|
62
|
+
```ts
|
|
63
|
+
import type { MiddlewareHandler } from 'acrylblends'
|
|
64
|
+
|
|
65
|
+
const jsonOnly: MiddlewareHandler = async (c, next) => {
|
|
66
|
+
// The same types the json target reads: application/json and application/*+json, parameters allowed.
|
|
67
|
+
const type = c.req.header('content-type')?.split(';', 1)[0]?.trim() ?? ''
|
|
68
|
+
if (!/^application\/([a-z.-]+\+)?json$/i.test(type)) return c.json({ error: 'content type must be JSON' }, 415)
|
|
69
|
+
await next()
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
app.post('/todos', jsonOnly, schemaValidator('json', NewTodo), createTodo)
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
### A body that is not JSON
|
|
76
|
+
|
|
77
|
+
A JSON content type with a body that does not parse fails before the schema runs: the answer is 400 with the text body
|
|
78
|
+
`Malformed JSON in request body` (a thrown `HTTPException`, so an `onError` handler can reshape it). The `form` target does the same for
|
|
79
|
+
a form body that does not parse, with the text `Malformed FormData request.`
|
|
80
|
+
|
|
81
|
+
Keep the public failure shape consistent across the application and do not leak internals in messages. An application that needs a
|
|
82
|
+
different body writes its own check with the core `validator` (below). Valid input is not proof that the caller may use it:
|
|
83
|
+
authentication and authorization stay separate checks.
|
|
84
|
+
|
|
85
|
+
## Targets
|
|
86
|
+
|
|
87
|
+
| Target | Read from |
|
|
88
|
+
| --- | --- |
|
|
89
|
+
| `json` | the JSON body |
|
|
90
|
+
| `form` | a `multipart/form-data` or `application/x-www-form-urlencoded` body |
|
|
91
|
+
| `query` | the query string |
|
|
92
|
+
| `param` | route parameters |
|
|
93
|
+
| `header` | request headers (names in lowercase) |
|
|
94
|
+
| `cookie` | cookies |
|
|
95
|
+
|
|
96
|
+
Validate only the targets the route uses, and chain one validator per target:
|
|
97
|
+
|
|
98
|
+
```ts
|
|
99
|
+
app.get('/todos/:id', schemaValidator('param', z.object({ id: z.natural() })), schemaValidator('query', Paging), handler)
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
## The core validator
|
|
103
|
+
|
|
104
|
+
`validator` (from `acrylblends/validator`) takes a function instead of a schema. Return the value the handler should see, or a
|
|
105
|
+
response to refuse:
|
|
106
|
+
|
|
107
|
+
```ts
|
|
108
|
+
import { validator } from 'acrylblends/validator'
|
|
109
|
+
|
|
110
|
+
app.post('/posts', validator('form', (value, c) => {
|
|
111
|
+
const body = value['body']
|
|
112
|
+
if (typeof body !== 'string') return c.text('Invalid!', 400)
|
|
113
|
+
return { body }
|
|
114
|
+
}), c => c.json({ body: c.req.valid('form').body }, 201))
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
## Limit the body first
|
|
118
|
+
|
|
119
|
+
Validation reads the whole body. Put `bodyLimit` in front of a validator on any route open to untrusted callers:
|
|
120
|
+
|
|
121
|
+
```ts
|
|
122
|
+
import { bodyLimit } from 'acrylblends/body-limit'
|
|
123
|
+
|
|
124
|
+
app.post('/todos', bodyLimit({ maxSize: 16 * 1024 }), schemaValidator('json', NewTodo), createTodo)
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
## Typed clients
|
|
128
|
+
|
|
129
|
+
The validated input types flow into the route types, so the typed `testClient` and `acrylblends/client` check the request a caller sends.
|
|
130
|
+
See [Helpers](../guides/helpers.md#testing).
|
|
131
|
+
|
|
132
|
+
## What not to validate twice
|
|
133
|
+
|
|
134
|
+
Validate shape at the HTTP boundary, then enforce domain rules in the domain service. The route checks that `title` is a string; the
|
|
135
|
+
todo service decides whether that title is allowed.
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
# Getting Started
|
|
2
|
+
|
|
3
|
+
Using Acryl is easy. You set up the project, write code, develop with a local server, and run it. The same application code works on every verified runtime, with a different
|
|
4
|
+
entry point for each. This page shows the basic usage.
|
|
5
|
+
|
|
6
|
+
## Starter
|
|
7
|
+
|
|
8
|
+
Starter templates are available for each runtime. Use the "create-acrylblends" command.
|
|
9
|
+
|
|
10
|
+
**npm**
|
|
11
|
+
|
|
12
|
+
```sh
|
|
13
|
+
npm create acrylblends@latest my-app
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
**yarn**
|
|
17
|
+
|
|
18
|
+
```sh
|
|
19
|
+
yarn create acrylblends my-app
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
**pnpm**
|
|
23
|
+
|
|
24
|
+
```sh
|
|
25
|
+
pnpm create acrylblends@latest my-app
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
**bun**
|
|
29
|
+
|
|
30
|
+
```sh
|
|
31
|
+
bun create acrylblends@latest my-app
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
You are asked which template you want:
|
|
35
|
+
|
|
36
|
+
```
|
|
37
|
+
? Which template do you want to use?
|
|
38
|
+
❯ nodejs
|
|
39
|
+
bun (experimental)
|
|
40
|
+
acryl-app
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
`nodejs` and `bun` (experimental) are a minimal Acryl application for that runtime. `acryl-app` is a Cordis app built from plugins, with an agent that can extend it while it
|
|
44
|
+
runs (see [Acryl in a Cordis app](../cordis/acryl-in-a-cordis-app.md)). Pass `--template` to skip the question, see [create-acrylblends](../guides/create-acrylblends.md).
|
|
45
|
+
|
|
46
|
+
The template is created in `my-app`: go to it, install the dependencies and start the local server.
|
|
47
|
+
|
|
48
|
+
```sh
|
|
49
|
+
cd my-app
|
|
50
|
+
npm install
|
|
51
|
+
npm run dev
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## Hello World
|
|
55
|
+
|
|
56
|
+
Write your first application in `src/index.ts`. The example below is a starter Acryl application.
|
|
57
|
+
|
|
58
|
+
The `import` and the final `export default` are the same on every runtime; what differs is how the runtime starts the app (see [Node.js](nodejs.md) and [Bun](bun.md)).
|
|
59
|
+
|
|
60
|
+
```ts twoslash
|
|
61
|
+
import { Acryl } from 'acrylblends'
|
|
62
|
+
|
|
63
|
+
const app = new Acryl()
|
|
64
|
+
|
|
65
|
+
app.get('/', (c) => {
|
|
66
|
+
return c.text('Hello Acryl!')
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
export default app
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Start the development server and open the address it prints.
|
|
73
|
+
|
|
74
|
+
## Return JSON
|
|
75
|
+
|
|
76
|
+
Returning JSON is also easy. The following handles a GET request to `/api/hello` and returns an `application/json` response.
|
|
77
|
+
|
|
78
|
+
```ts twoslash
|
|
79
|
+
import { Acryl } from 'acrylblends'
|
|
80
|
+
|
|
81
|
+
const app = new Acryl()
|
|
82
|
+
|
|
83
|
+
app.get('/api/hello', (c) => {
|
|
84
|
+
return c.json({
|
|
85
|
+
ok: true,
|
|
86
|
+
message: 'Hello Acryl!',
|
|
87
|
+
})
|
|
88
|
+
})
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
## Request and Response
|
|
92
|
+
|
|
93
|
+
Getting a path parameter, a URL query value, and adding a response header is written as follows.
|
|
94
|
+
|
|
95
|
+
```ts twoslash
|
|
96
|
+
import { Acryl } from 'acrylblends'
|
|
97
|
+
|
|
98
|
+
const app = new Acryl()
|
|
99
|
+
|
|
100
|
+
app.get('/posts/:id', (c) => {
|
|
101
|
+
const page = c.req.query('page')
|
|
102
|
+
const id = c.req.param('id')
|
|
103
|
+
c.header('X-Message', 'Hi!')
|
|
104
|
+
return c.text(`You want to see ${page} of ${id}`)
|
|
105
|
+
})
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
POST, PUT and DELETE are handled the same way as GET.
|
|
109
|
+
|
|
110
|
+
```ts twoslash
|
|
111
|
+
import { Acryl } from 'acrylblends'
|
|
112
|
+
|
|
113
|
+
const app = new Acryl()
|
|
114
|
+
|
|
115
|
+
app.post('/posts', (c) => c.text('Created!', 201))
|
|
116
|
+
app.delete('/posts/:id', (c) => c.text(`${c.req.param('id')} is deleted!`))
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
## Return HTML
|
|
120
|
+
|
|
121
|
+
Write HTML with [the html helper](../helpers/html.md):
|
|
122
|
+
|
|
123
|
+
```ts twoslash
|
|
124
|
+
import { Acryl } from 'acrylblends'
|
|
125
|
+
import { html } from 'acrylblends/html'
|
|
126
|
+
|
|
127
|
+
const app = new Acryl()
|
|
128
|
+
|
|
129
|
+
app.get('/', (c) => {
|
|
130
|
+
const name = 'Acryl'
|
|
131
|
+
return c.html(html`<!doctype html><h1>Hello ${name}!</h1>`)
|
|
132
|
+
})
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
[JSX](../guides/jsx.md) is not yet available.
|
|
136
|
+
|
|
137
|
+
## Return raw Response
|
|
138
|
+
|
|
139
|
+
You can also return a raw Web Standard `Response`.
|
|
140
|
+
|
|
141
|
+
```ts twoslash
|
|
142
|
+
import { Acryl } from 'acrylblends'
|
|
143
|
+
|
|
144
|
+
const app = new Acryl()
|
|
145
|
+
|
|
146
|
+
app.get('/', () => {
|
|
147
|
+
return new Response('Good morning!')
|
|
148
|
+
})
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
## Using Middleware
|
|
152
|
+
|
|
153
|
+
Middleware can do the hard work for you. For example, add Basic Authentication.
|
|
154
|
+
|
|
155
|
+
```ts twoslash
|
|
156
|
+
import { Acryl } from 'acrylblends'
|
|
157
|
+
import { basicAuth } from 'acrylblends/basic-auth'
|
|
158
|
+
|
|
159
|
+
const app = new Acryl()
|
|
160
|
+
|
|
161
|
+
app.use(
|
|
162
|
+
'/admin/*',
|
|
163
|
+
basicAuth({
|
|
164
|
+
username: 'admin',
|
|
165
|
+
password: 'secret',
|
|
166
|
+
})
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
app.get('/admin', (c) => {
|
|
170
|
+
return c.text('You are authorized!')
|
|
171
|
+
})
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
There are useful middleware included, such as Bearer and JWT authentication, CORS and ETag. See [Built-in middleware](../middleware/builtin/basic-auth.md).
|
|
175
|
+
|
|
176
|
+
## Testing
|
|
177
|
+
|
|
178
|
+
An app is a function from `Request` to `Response`, so a test needs no server:
|
|
179
|
+
|
|
180
|
+
```ts twoslash
|
|
181
|
+
import { Acryl } from 'acrylblends'
|
|
182
|
+
|
|
183
|
+
const app = new Acryl()
|
|
184
|
+
app.get('/', (c) => c.text('Hello Acryl!'))
|
|
185
|
+
|
|
186
|
+
const response = await app.request('/')
|
|
187
|
+
console.log(response.status, await response.text())
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
See [Testing](../guides/testing.md).
|
|
191
|
+
|
|
192
|
+
## Next
|
|
193
|
+
|
|
194
|
+
- [Node.js](nodejs.md) and [Bun](bun.md) (experimental)
|
|
195
|
+
- [Routing](../api/routing.md), [Context](../api/context.md) and [Request](../api/request.md)
|
|
196
|
+
- [Validation](../guides/validation.md)
|