acrylblends 0.7.0 → 0.7.2
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/README.md +2 -2
- package/dist/index.js +75 -4
- package/dist/index.js.map +1 -1
- package/docs/framework/README.md +2 -1
- package/docs/framework/UPSTREAM.md +1 -1
- package/docs/framework/api/summary.md +7 -4
- package/docs/framework/cordis/node-server.md +3 -0
- package/docs/framework/getting-started/deploy.md +133 -0
- package/docs/framework/getting-started/desktop.md +1 -1
- package/docs/framework/getting-started/nodejs.md +37 -16
- package/docs/framework/guides/client-modules.md +175 -0
- package/docs/framework/guides/forms-and-sessions.md +231 -0
- package/docs/framework/guides/jsx-dom.md +12 -7
- package/docs/framework/guides/validation.md +88 -1
- package/docs/framework/helpers/cookie.md +1 -1
- package/docs/framework/helpers/testing.md +17 -0
- package/docs/framework/upstream.json +3 -3
- package/lib/browser/client.js +307 -0
- package/lib/browser/jsx-dom.js +1432 -0
- package/lib/index.js +1 -25
- package/lib/node-server/register.js +7 -49
- package/lib/node-server.d.ts +74 -7
- package/lib/node-server.js +264 -7
- package/lib/schema-validator.d.ts +40 -14
- package/lib/schema-validator.js +2 -2
- package/lib/src-0hI7M7cN.js +27 -0
- package/lib/transpile-CTnWof4z.js +90 -0
- package/package.json +1 -1
- package/plugins/acryl-inspect/upstream.json +1 -1
- package/plugins/acryl-node-server/src/adapter/server.ts +28 -5
- package/plugins/acryl-node-server/src/adapter/types.ts +14 -2
- package/plugins/acryl-node-server/src/client-modules.ts +293 -0
- package/plugins/acryl-node-server/src/register.ts +5 -62
- package/plugins/acryl-node-server/src/transpile.ts +120 -0
- package/plugins/acryl-node-server/upstream.json +4 -3
- package/plugins/acryl-router/src/browser/client.ts +3 -0
- package/plugins/acryl-router/src/browser/jsx-dom.ts +9 -0
- package/plugins/acryl-router/src/helpers/index.ts +1 -1
- package/plugins/acryl-router/src/helpers/schema-validator.ts +39 -12
- package/plugins/acryl-router/upstream.json +2 -2
- package/plugins/acryl-router-http/upstream.json +1 -1
- package/plugins/acryl-state/upstream.json +1 -1
- package/plugins/desktop/plugin.json +1 -1
- package/plugins/desktop/upstream.json +1 -1
- package/plugins/desktop-web/plugin.json +1 -1
- package/plugins/desktop-web/upstream.json +1 -1
- package/templates/agent/package.json +4 -3
- package/templates/blank/package.json +4 -3
- package/templates/todo/package.json +4 -3
- package/templates/todo/upstream.json +1 -1
|
@@ -80,27 +80,29 @@ app.get('/', (c) => c.text('Hello Node.js!'))
|
|
|
80
80
|
serve(app)
|
|
81
81
|
```
|
|
82
82
|
|
|
83
|
-
|
|
83
|
+
`serve()` listens on `127.0.0.1` unless you pass `hostname`, so a development server is reachable from this machine only, not from the rest
|
|
84
|
+
of the network. See [Listen on another interface](#listen-on-another-interface).
|
|
85
|
+
|
|
86
|
+
To shut the server down gracefully, stop taking requests, let the ones in flight finish, and exit after a bounded time, so a connection
|
|
87
|
+
that never ends cannot keep the process alive:
|
|
84
88
|
|
|
85
89
|
```ts
|
|
86
90
|
const server = serve(app)
|
|
87
91
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
console.error(err)
|
|
97
|
-
process.exit(1)
|
|
98
|
-
}
|
|
99
|
-
process.exit(0)
|
|
100
|
-
})
|
|
101
|
-
})
|
|
92
|
+
function shutdown(): void {
|
|
93
|
+
server.close(() => process.exit(0))
|
|
94
|
+
// Keep-alive connections with no request in flight would otherwise hold close() open until they time out.
|
|
95
|
+
server.closeIdleConnections()
|
|
96
|
+
setTimeout(() => process.exit(1), 10_000).unref()
|
|
97
|
+
}
|
|
98
|
+
process.once('SIGINT', shutdown)
|
|
99
|
+
process.once('SIGTERM', shutdown)
|
|
102
100
|
```
|
|
103
101
|
|
|
102
|
+
`serve(app)` and `serve({ fetch, ... })` return an `http.Server`, which has `closeIdleConnections()`. With `createServer` from
|
|
103
|
+
`node:https` the result is an `https.Server`, and with `node:http2` an `Http2Server` or `Http2SecureServer`, which has no such method;
|
|
104
|
+
there, `server.close()` waits for the open sessions.
|
|
105
|
+
|
|
104
106
|
> **Note.** On Node.js, `serve()` wraps the `node:http` module and returns the underlying server instance, so closing it is up to you. Bun serves Fetch handlers natively and manages the server itself, which is why its guide doesn't include this step.
|
|
105
107
|
|
|
106
108
|
## 3. Run
|
|
@@ -136,6 +138,22 @@ serve({
|
|
|
136
138
|
})
|
|
137
139
|
```
|
|
138
140
|
|
|
141
|
+
## Listen on another interface
|
|
142
|
+
|
|
143
|
+
`serve()` binds `127.0.0.1` when `hostname` is not given. That is right on a developer machine and behind a reverse proxy on the same
|
|
144
|
+
host. Inside a container, or when other machines should reach the app directly, bind every interface on purpose:
|
|
145
|
+
|
|
146
|
+
```ts
|
|
147
|
+
serve({
|
|
148
|
+
fetch: app.fetch,
|
|
149
|
+
port: 3000,
|
|
150
|
+
hostname: '0.0.0.0', // or '::' for IPv6 and IPv4
|
|
151
|
+
})
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
> **Note.** Many Node servers listen on every interface by default. `serve()` does not, so starting a development server never exposes
|
|
155
|
+
> the app to the local network by accident.
|
|
156
|
+
|
|
139
157
|
## WebSocket
|
|
140
158
|
|
|
141
159
|
WebSocket support is built into `acrylblends/node-server`. Install `ws` and, if you use TypeScript, `@types/ws`. Then create a `WebSocketServer` with `{ noServer: true }` and pass it to `serve()` with the `websocket` option.
|
|
@@ -311,7 +329,9 @@ bun run build
|
|
|
311
329
|
|
|
312
330
|
### Dockerfile
|
|
313
331
|
|
|
314
|
-
Here is an example of a Node.js Dockerfile.
|
|
332
|
+
Here is an example of a Node.js Dockerfile. A container's published port reaches the app only when it listens on every interface, so
|
|
333
|
+
the app reads its hostname from the environment, as the templates do (`hostname: process.env.HOST || '127.0.0.1'`), and the image
|
|
334
|
+
sets `HOST=0.0.0.0`.
|
|
315
335
|
|
|
316
336
|
```Dockerfile
|
|
317
337
|
FROM node:22-alpine AS base
|
|
@@ -338,6 +358,7 @@ COPY --from=builder --chown=acrylblends:nodejs /app/dist /app/dist
|
|
|
338
358
|
COPY --from=builder --chown=acrylblends:nodejs /app/package.json /app/package.json
|
|
339
359
|
|
|
340
360
|
USER acrylblends
|
|
361
|
+
ENV HOST=0.0.0.0
|
|
341
362
|
EXPOSE 3000
|
|
342
363
|
|
|
343
364
|
CMD ["node", "/app/dist/index.js"]
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
# Client components without a bundler
|
|
2
|
+
|
|
3
|
+
A page can run [client components](jsx-dom.md) written in TypeScript and JSX with no bundler and no build configuration. `clientModules` from
|
|
4
|
+
`acrylblends/node-server` serves a folder of client modules to the browser: each `.tsx`, `.ts` or `.jsx` file is compiled when the browser asks
|
|
5
|
+
for it, with your app's own `typescript`, and the framework's browser imports are pointed at the browser builds the package ships. The page
|
|
6
|
+
needs one script tag.
|
|
7
|
+
|
|
8
|
+
It runs on Node, the runtime the framework targets. For a large client app with many npm packages, a bundler (Vite, esbuild) remains the
|
|
9
|
+
better tool; see [The `acrylblends/jsx/dom` runtime](jsx-dom.md#the-acrylblendsjsxdom-runtime).
|
|
10
|
+
|
|
11
|
+
## Set up
|
|
12
|
+
|
|
13
|
+
The routes, with the type the browser's typed client uses, in a file of their own (`app.ts`):
|
|
14
|
+
|
|
15
|
+
```ts
|
|
16
|
+
import { Acryl } from 'acrylblends'
|
|
17
|
+
|
|
18
|
+
export const app = new Acryl()
|
|
19
|
+
.get('/', (c) =>
|
|
20
|
+
c.html(
|
|
21
|
+
'<!doctype html><div id="counter"></div><p id="greeting"></p>' +
|
|
22
|
+
'<script type="module" src="/client/counter.tsx"></script>'
|
|
23
|
+
)
|
|
24
|
+
)
|
|
25
|
+
.get('/api/hello', (c) => c.json({ hello: 'browser' }))
|
|
26
|
+
|
|
27
|
+
export type AppType = typeof app
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
The Node entry routes the client modules next to them (`site.ts`):
|
|
31
|
+
|
|
32
|
+
```ts
|
|
33
|
+
import { Acryl } from 'acrylblends'
|
|
34
|
+
import { clientModules, serve } from 'acrylblends/node-server'
|
|
35
|
+
import { app } from './app.ts'
|
|
36
|
+
|
|
37
|
+
const site = new Acryl()
|
|
38
|
+
.route('/', app)
|
|
39
|
+
.route('/client', clientModules({ root: 'src/client', compiled: 'dist/client' }))
|
|
40
|
+
|
|
41
|
+
serve({ fetch: site.fetch, port: 3000 })
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
A client component (`src/client/counter.tsx`), with a helper module beside it (`src/client/parts/label.ts`):
|
|
45
|
+
|
|
46
|
+
```tsx
|
|
47
|
+
import { render, useState } from 'acrylblends/jsx/dom'
|
|
48
|
+
import { hc } from 'acrylblends/client'
|
|
49
|
+
import type { AppType } from '../../app.ts'
|
|
50
|
+
import { label } from './parts/label.ts'
|
|
51
|
+
|
|
52
|
+
const Counter = () => {
|
|
53
|
+
const [count, setCount] = useState(0)
|
|
54
|
+
return <button onClick={() => setCount(count + 1)}>{label(count)}</button>
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
render(<Counter />, document.getElementById('counter')!)
|
|
58
|
+
|
|
59
|
+
const client = hc<AppType>(location.origin)
|
|
60
|
+
const { hello } = await (await client.api.hello.$get()).json()
|
|
61
|
+
document.getElementById('greeting')!.textContent = `hello ${hello}`
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
```ts
|
|
65
|
+
export const label = (count: number): string => `Count ${count}`
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Start the site with `node site.ts` and open `http://localhost:3000`. Edit `counter.tsx` and reload: the module is compiled again.
|
|
69
|
+
|
|
70
|
+
Your editor checks the client folder as browser code with its own `src/client/tsconfig.json`: the DOM library, the DOM renderer, no Node
|
|
71
|
+
types. This is why `AppType` comes from `app.ts`, which does not import the Node adapter.
|
|
72
|
+
|
|
73
|
+
```json
|
|
74
|
+
{
|
|
75
|
+
"compilerOptions": {
|
|
76
|
+
"target": "ES2024",
|
|
77
|
+
"lib": ["ES2024", "DOM", "DOM.Iterable"],
|
|
78
|
+
"module": "NodeNext",
|
|
79
|
+
"moduleResolution": "NodeNext",
|
|
80
|
+
"strict": true,
|
|
81
|
+
"noEmit": true,
|
|
82
|
+
"allowImportingTsExtensions": true,
|
|
83
|
+
"jsx": "react-jsx",
|
|
84
|
+
"jsxImportSource": "acrylblends/jsx/dom",
|
|
85
|
+
"types": []
|
|
86
|
+
},
|
|
87
|
+
"include": ["**/*.ts", "**/*.tsx"]
|
|
88
|
+
}
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
## Server markup and `hydrateRoot`
|
|
92
|
+
|
|
93
|
+
A page can carry server-rendered markup for an island and take it over in the browser with `hydrateRoot` from `acrylblends/jsx/dom/client`:
|
|
94
|
+
|
|
95
|
+
```tsx
|
|
96
|
+
import { useState } from 'acrylblends/jsx/dom'
|
|
97
|
+
import { hydrateRoot } from 'acrylblends/jsx/dom/client'
|
|
98
|
+
|
|
99
|
+
const Counter = () => {
|
|
100
|
+
const [count, setCount] = useState(0)
|
|
101
|
+
return <button onClick={() => setCount(count + 1)}>Count {count}</button>
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
hydrateRoot(document.getElementById('island')!, <Counter />)
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
The DOM renderer does not reuse server markup: `hydrateRoot` renders the component and replaces the element's children with its output
|
|
108
|
+
in one step, so the page shows the server markup until the module runs and the component from then on, with no empty frame between. The
|
|
109
|
+
server's nodes are gone afterwards, together with anything the visitor did to them before the module ran: focus, a selection, text typed
|
|
110
|
+
into an input. Keep the server markup the same as the component's first render, so the swap does not show, and keep inputs out of the
|
|
111
|
+
server markup of an island, or render them only in the browser.
|
|
112
|
+
|
|
113
|
+
## How the imports reach the browser
|
|
114
|
+
|
|
115
|
+
- JSX in a client module always compiles for the DOM renderer (`acrylblends/jsx/dom`), whatever the server's `tsconfig.json` says. A file's
|
|
116
|
+
own `/** @jsxImportSource ... */` comment still wins.
|
|
117
|
+
- `acrylblends/jsx/dom`, its `jsx-runtime`, `jsx-dev-runtime` and `client` are one browser module (`@acrylblends/jsx-dom.js`), and
|
|
118
|
+
`acrylblends/client` is another (`@acrylblends/client.js`). Neither imports anything. The same app serves them, and each import is
|
|
119
|
+
rewritten to a URL relative to the module, so the setup works under whatever path you route `clientModules` at.
|
|
120
|
+
- Import your own modules with their file names, as the browser fetches them: `import { label } from './parts/label.ts'`.
|
|
121
|
+
- Another package (`import confetti from 'canvas-confetti'`) is left as it is: add an
|
|
122
|
+
[import map](https://developer.mozilla.org/docs/Web/HTML/Element/script/type/importmap) to the page for it, or use a bundler.
|
|
123
|
+
- `.js` and `.mjs` files are served unchanged.
|
|
124
|
+
|
|
125
|
+
A module is compiled once and kept in memory until its file changes, up to 16 MiB of compiled code (the least recently served goes first;
|
|
126
|
+
set `cacheBytes` to change the bound). Responses carry an `ETag` and `Cache-Control: no-cache`, so the browser revalidates and an edit shows
|
|
127
|
+
on the next reload. Only files inside the folder are served: a path with `..` or a dot segment, a `.d.ts` file, a symbolic link out of the
|
|
128
|
+
folder and other file types answer 404.
|
|
129
|
+
|
|
130
|
+
## In production
|
|
131
|
+
|
|
132
|
+
Templates install `typescript` as a development dependency, and a deploy that leaves those out (`npm ci --omit=dev`) has no compiler.
|
|
133
|
+
Compile the client folder once when you build, as the `build` script of your `package.json`:
|
|
134
|
+
|
|
135
|
+
```json
|
|
136
|
+
{
|
|
137
|
+
"scripts": {
|
|
138
|
+
"build": "acrylblends build-client"
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
`acrylblends build-client [root] [outDir]` (from acrylblends 0.7.2; `src/client` and `dist/client` when not given) calls
|
|
144
|
+
`buildClientModules`. With an earlier version, or without the command line tool, call the function itself:
|
|
145
|
+
|
|
146
|
+
```json
|
|
147
|
+
{
|
|
148
|
+
"scripts": {
|
|
149
|
+
"build": "node --input-type=module -e \"import { buildClientModules } from 'acrylblends/node-server'; buildClientModules({ root: 'src/client', outDir: 'dist/client' })\""
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
It writes each module compiled to `dist/client` at the same path and file name, so the page's URLs stay the same, and copies the two
|
|
155
|
+
browser modules to `dist/client/@acrylblends/`. Run it where `typescript` is installed, before the development dependencies are removed.
|
|
156
|
+
|
|
157
|
+
With `NODE_ENV=production` and the `compiled` option set, `clientModules` serves only that folder and compiles nothing. A module that is in
|
|
158
|
+
`src/client` but missing from `dist/client` answers 500 with a message to build again, and a `compiled` folder that does not exist stops the
|
|
159
|
+
server at startup. Outside production, modules are always compiled on request, so a stale `dist/client` never hides an edit.
|
|
160
|
+
|
|
161
|
+
When `typescript` is missing where a module has to be compiled, the module answers 500 with the message
|
|
162
|
+
`... is a client module, and compiling it needs the typescript package in this app: npm install --save-dev typescript`.
|
|
163
|
+
|
|
164
|
+
## Reference
|
|
165
|
+
|
|
166
|
+
`clientModules(options)` returns an app; route it where the page's script tags point.
|
|
167
|
+
|
|
168
|
+
| Option | Meaning |
|
|
169
|
+
| --- | --- |
|
|
170
|
+
| `root` | The folder of client modules, absolute or relative to the working directory. |
|
|
171
|
+
| `compiled` | The folder `buildClientModules` wrote. Served instead of compiling when `NODE_ENV` is `production`. |
|
|
172
|
+
| `cacheBytes` | The bound of the in-memory cache of compiled modules, in bytes. 16 MiB when not given. |
|
|
173
|
+
|
|
174
|
+
`buildClientModules({ root, outDir })` compiles every module below `root` (dot files, `node_modules` and `.d.ts` files skipped) into `outDir`,
|
|
175
|
+
which must not lie inside `root`, and returns the paths it wrote. Files already in `outDir` that no source produces are left there.
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
# Forms, sessions and a database
|
|
2
|
+
|
|
3
|
+
This page shows the parts a small multi-user app needs, in the order an app uses them: a database, passwords, a cookie session, HTML forms with server-side validation, and a JSON API that shares the same rules.
|
|
4
|
+
It is the shape of a "Notes" app built from these docs alone (sign up, log in, create, edit, delete and search notes, a typed test client for the API).
|
|
5
|
+
|
|
6
|
+
## A database with `node:sqlite`
|
|
7
|
+
|
|
8
|
+
Node.js 22.19 and later include SQLite, so there is nothing to install. `DatabaseSync` is synchronous, which suits a small app.
|
|
9
|
+
|
|
10
|
+
```ts
|
|
11
|
+
import { DatabaseSync } from 'node:sqlite'
|
|
12
|
+
|
|
13
|
+
export function openDb(path = process.env.NOTES_DB ?? 'notes.db'): DatabaseSync {
|
|
14
|
+
const db = new DatabaseSync(path)
|
|
15
|
+
db.exec(`
|
|
16
|
+
CREATE TABLE IF NOT EXISTS users (
|
|
17
|
+
id INTEGER PRIMARY KEY,
|
|
18
|
+
email TEXT NOT NULL UNIQUE,
|
|
19
|
+
password_hash TEXT NOT NULL
|
|
20
|
+
);
|
|
21
|
+
CREATE TABLE IF NOT EXISTS sessions (
|
|
22
|
+
token TEXT PRIMARY KEY,
|
|
23
|
+
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE
|
|
24
|
+
);
|
|
25
|
+
`)
|
|
26
|
+
return db
|
|
27
|
+
}
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Take the database as an argument (`createApp(db)`) instead of opening it at import time. A test then passes `openDb(':memory:')`, and importing the app module does not create a file.
|
|
31
|
+
|
|
32
|
+
## Passwords with `node:crypto`
|
|
33
|
+
|
|
34
|
+
Never store a password. Store a salted `scrypt` hash and compare in constant time.
|
|
35
|
+
|
|
36
|
+
```ts
|
|
37
|
+
import { randomBytes, scrypt, timingSafeEqual } from 'node:crypto'
|
|
38
|
+
import { promisify } from 'node:util'
|
|
39
|
+
|
|
40
|
+
const scryptAsync = promisify(scrypt) as (password: string, salt: Buffer, length: number) => Promise<Buffer>
|
|
41
|
+
|
|
42
|
+
export async function hashPassword(password: string): Promise<string> {
|
|
43
|
+
const salt = randomBytes(16)
|
|
44
|
+
const key = await scryptAsync(password, salt, 64)
|
|
45
|
+
return `${salt.toString('hex')}:${key.toString('hex')}`
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export async function verifyPassword(password: string, stored: string): Promise<boolean> {
|
|
49
|
+
const [salt, key] = stored.split(':')
|
|
50
|
+
if (!salt || !key) return false
|
|
51
|
+
const expected = Buffer.from(key, 'hex')
|
|
52
|
+
return timingSafeEqual(await scryptAsync(password, Buffer.from(salt, 'hex'), expected.length), expected)
|
|
53
|
+
}
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## A cookie session
|
|
57
|
+
|
|
58
|
+
The simplest session is a random token in an `httpOnly` cookie and a `sessions` row that maps it to a user. Log out deletes the row, so a stolen cookie stops working.
|
|
59
|
+
`getCookie`, `setCookie` and `deleteCookie` are in [the Cookie helper](../helpers/cookie.md).
|
|
60
|
+
|
|
61
|
+
```ts
|
|
62
|
+
import { Acryl } from 'acrylblends'
|
|
63
|
+
import { createMiddleware } from 'acrylblends/factory'
|
|
64
|
+
import { deleteCookie, getCookie, setCookie } from 'acrylblends/cookie'
|
|
65
|
+
import { randomBytes } from 'node:crypto'
|
|
66
|
+
import type { DatabaseSync } from 'node:sqlite'
|
|
67
|
+
|
|
68
|
+
type Env = { Variables: { user: { id: number; email: string } } }
|
|
69
|
+
|
|
70
|
+
export function createApp(db: DatabaseSync) {
|
|
71
|
+
const userFor = (token: string | undefined) =>
|
|
72
|
+
token
|
|
73
|
+
? (db
|
|
74
|
+
.prepare('SELECT users.id AS id, users.email AS email FROM sessions JOIN users ON users.id = sessions.user_id WHERE token = ?')
|
|
75
|
+
.get(token) as { id: number; email: string } | undefined)
|
|
76
|
+
: undefined
|
|
77
|
+
|
|
78
|
+
const requireUser = createMiddleware<Env>(async (c, next) => {
|
|
79
|
+
const user = userFor(getCookie(c, 'sid'))
|
|
80
|
+
if (!user) return c.redirect('/login')
|
|
81
|
+
c.set('user', user)
|
|
82
|
+
await next()
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
const app = new Acryl<Env>()
|
|
86
|
+
app.use('/notes', requireUser)
|
|
87
|
+
app.use('/notes/*', requireUser)
|
|
88
|
+
app.get('/notes', (c) => c.text(`Hello ${c.get('user').email}`))
|
|
89
|
+
|
|
90
|
+
app.post('/login-example', (c) => {
|
|
91
|
+
const token = randomBytes(32).toString('hex')
|
|
92
|
+
db.prepare('INSERT INTO sessions (token, user_id) VALUES (?, ?)').run(token, 1)
|
|
93
|
+
setCookie(c, 'sid', token, { path: '/', httpOnly: true, sameSite: 'Lax', maxAge: 60 * 60 * 24 * 30 })
|
|
94
|
+
return c.redirect('/notes', 303)
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
app.post('/logout', (c) => {
|
|
98
|
+
const token = getCookie(c, 'sid')
|
|
99
|
+
if (token) db.prepare('DELETE FROM sessions WHERE token = ?').run(token)
|
|
100
|
+
deleteCookie(c, 'sid', { path: '/' })
|
|
101
|
+
return c.redirect('/login', 303)
|
|
102
|
+
})
|
|
103
|
+
return app
|
|
104
|
+
}
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
A middleware registered with `use('/notes', ...)` does not cover `/notes/…`: register both `'/notes'` and `'/notes/*'`.
|
|
108
|
+
After a form POST redirect with status **303** so the browser follows with a GET.
|
|
109
|
+
|
|
110
|
+
## Forms and server-side validation
|
|
111
|
+
|
|
112
|
+
`await c.req.parseBody()` reads an HTML form (`application/x-www-form-urlencoded` or multipart). Each value is a `string` or a `File`, so convert with `String(...)` or check `typeof`.
|
|
113
|
+
Keep the rules in one function that both the form routes and the JSON API call, and answer an invalid form with **status 400 and the same page**, with the messages and the typed values filled in:
|
|
114
|
+
|
|
115
|
+
```ts
|
|
116
|
+
export interface NoteInput {
|
|
117
|
+
title: string
|
|
118
|
+
body: string
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function checkNote(value: unknown): { value: NoteInput } | { issues: { message: string; path: string[] }[] } {
|
|
122
|
+
const input = (value ?? {}) as Record<string, unknown>
|
|
123
|
+
const title = typeof input.title === 'string' ? input.title.trim() : ''
|
|
124
|
+
const body = typeof input.body === 'string' ? input.body : ''
|
|
125
|
+
const issues: { message: string; path: string[] }[] = []
|
|
126
|
+
if (title === '') issues.push({ message: 'Give the note a title.', path: ['title'] })
|
|
127
|
+
if (body.length > 2000) issues.push({ message: 'The note is at most 2000 characters.', path: ['body'] })
|
|
128
|
+
return issues.length > 0 ? { issues } : { value: { title, body } }
|
|
129
|
+
}
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
```tsx
|
|
133
|
+
import { Acryl } from 'acrylblends'
|
|
134
|
+
|
|
135
|
+
const app = new Acryl()
|
|
136
|
+
|
|
137
|
+
app.post('/notes', async (c) => {
|
|
138
|
+
const form = await c.req.parseBody()
|
|
139
|
+
const checked = checkNote(form)
|
|
140
|
+
if ('issues' in checked) {
|
|
141
|
+
const messages = checked.issues.map((issue) => issue.message)
|
|
142
|
+
return c.html(
|
|
143
|
+
<form method="post" action="/notes">
|
|
144
|
+
<ul role="alert">
|
|
145
|
+
{messages.map((message) => (
|
|
146
|
+
<li>{message}</li>
|
|
147
|
+
))}
|
|
148
|
+
</ul>
|
|
149
|
+
<input name="title" value={String(form.title ?? '')} />
|
|
150
|
+
<textarea name="body">{String(form.body ?? '')}</textarea>
|
|
151
|
+
<button type="submit">Save</button>
|
|
152
|
+
</form>,
|
|
153
|
+
400,
|
|
154
|
+
)
|
|
155
|
+
}
|
|
156
|
+
return c.redirect('/notes', 303)
|
|
157
|
+
})
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
Escaping is automatic in JSX: `{String(form.title)}` is safe to put in a page. Use `dangerouslySetInnerHTML` only for text you wrote yourself, such as a `<style>` or `<script>` block.
|
|
161
|
+
|
|
162
|
+
## A JSON API that shares the rules
|
|
163
|
+
|
|
164
|
+
Wrap `checkNote` as a Standard Schema (an object with a `~standard.validate` function) and give it to `schemaValidator`. A failure answers 400 with `{ error, issues: [{ message, path }] }`.
|
|
165
|
+
Build the API app by **chaining** the routes on `new Acryl()`, because the typed client and `testClient` only know chained routes:
|
|
166
|
+
|
|
167
|
+
```ts
|
|
168
|
+
import { Acryl } from 'acrylblends'
|
|
169
|
+
import { schemaValidator, type StandardSchema } from 'acrylblends/schema-validator'
|
|
170
|
+
|
|
171
|
+
const NoteSchema: StandardSchema<NoteInput> = {
|
|
172
|
+
'~standard': { validate: (value) => checkNote(value) },
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export const api = new Acryl()
|
|
176
|
+
.get('/', (c) => c.json({ notes: [] as NoteInput[] }, 200))
|
|
177
|
+
.post('/', schemaValidator('json', NoteSchema), (c) => c.json({ note: c.req.valid('json') }, 201))
|
|
178
|
+
.get('/:id', (c) => (c.req.param('id') === '1' ? c.json({ note: { title: 't', body: '' } }, 200) : c.json({ error: 'No such note.' }, 404)))
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
Mount it below the pages with `app.route('/api/notes', api)`.
|
|
182
|
+
|
|
183
|
+
## Testing every route
|
|
184
|
+
|
|
185
|
+
Call the app with `app.request`. A form POST needs a body and the cookie of a signed-in user, which a test reads from the sign-up response's `set-cookie` header:
|
|
186
|
+
|
|
187
|
+
```ts
|
|
188
|
+
const response = await app.request('/signup', {
|
|
189
|
+
method: 'POST',
|
|
190
|
+
body: new URLSearchParams({ email: 'a@example.com', password: 'correct horse' }),
|
|
191
|
+
})
|
|
192
|
+
const cookie = (response.headers.get('set-cookie') ?? '').split(';')[0] ?? ''
|
|
193
|
+
await app.request('/notes', { headers: { cookie } })
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
For the typed API use `testClient` ([Testing helper](../helpers/testing.md)); the cookie goes in the second argument of each call:
|
|
197
|
+
|
|
198
|
+
```ts
|
|
199
|
+
import { testClient } from 'acrylblends/testing'
|
|
200
|
+
|
|
201
|
+
const client = testClient(api)
|
|
202
|
+
const created = await client.index.$post({ json: { title: 'First', body: 'hello' } }, { headers: { cookie } })
|
|
203
|
+
const { note } = await created.json()
|
|
204
|
+
const one = await client[':id'].$get({ param: { id: '1' } }, { headers: { cookie } })
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
A route mounted at `'/'` is `client.index` and `'/:id'` is `client[':id']`.
|
|
208
|
+
|
|
209
|
+
## A part of the page that reacts in the browser
|
|
210
|
+
|
|
211
|
+
A live character counter or an instant filter needs a little JavaScript in the page. The simplest way, with no build step, is a `<script>` in the server-rendered page:
|
|
212
|
+
|
|
213
|
+
```tsx
|
|
214
|
+
const counter = `
|
|
215
|
+
const area = document.getElementById('body')
|
|
216
|
+
const out = document.getElementById('count')
|
|
217
|
+
const show = () => { out.textContent = area.value.length + ' / ' + area.maxLength }
|
|
218
|
+
area.addEventListener('input', show)
|
|
219
|
+
show()
|
|
220
|
+
`
|
|
221
|
+
|
|
222
|
+
export const Field = () => (
|
|
223
|
+
<>
|
|
224
|
+
<textarea id="body" name="body" maxlength={2000} />
|
|
225
|
+
<small id="count" aria-live="polite" />
|
|
226
|
+
<script dangerouslySetInnerHTML={{ __html: counter }} />
|
|
227
|
+
</>
|
|
228
|
+
)
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
[Client Components](jsx-dom.md) (`acrylblends/jsx/dom`) are for pages that need components and state in the browser; on Node, [Client components without a bundler](client-modules.md) serves them to the browser with one script tag, or bundle them with Vite or esbuild.
|
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
`acrylblends/jsx` supports not only server side but also client side. This means that it is possible to create an interactive UI that runs in the browser. We call it Client Components or `acrylblends/jsx/dom`.
|
|
4
4
|
|
|
5
|
-
It is
|
|
5
|
+
It is small and imports nothing. Measured with Node's `zlib.brotliCompressSync` at the highest quality: the whole runtime as
|
|
6
|
+
[`clientModules`](client-modules.md) serves it (`jsx-dom.js`, not minified) is 11,209 bytes with Brotli compression, and the counter
|
|
7
|
+
below, bundled and minified with tsdown so only the parts it uses are kept, is 5,804 bytes.
|
|
6
8
|
|
|
7
9
|
This section introduces Client Components-specific features.
|
|
8
10
|
|
|
@@ -105,11 +107,11 @@ export default function App() {
|
|
|
105
107
|
</button>
|
|
106
108
|
<div>
|
|
107
109
|
{!showLargeImage ? (
|
|
108
|
-
<img src=
|
|
110
|
+
<img src="/photo.jpg" alt="" />
|
|
109
111
|
) : (
|
|
110
112
|
<div
|
|
111
113
|
class={css`
|
|
112
|
-
background: url(');
|
|
114
|
+
background: url('/photo.jpg');
|
|
113
115
|
background-size: contain;
|
|
114
116
|
background-repeat: no-repeat;
|
|
115
117
|
background-position: center;
|
|
@@ -170,12 +172,12 @@ export default function App() {
|
|
|
170
172
|
</button>
|
|
171
173
|
<div>
|
|
172
174
|
{!showLargeImage ? (
|
|
173
|
-
<img src=
|
|
175
|
+
<img src="/photo.jpg" alt="" />
|
|
174
176
|
) : (
|
|
175
177
|
<div
|
|
176
178
|
class={css`
|
|
177
179
|
${transitionNameClass}
|
|
178
|
-
background: url(');
|
|
180
|
+
background: url('/photo.jpg');
|
|
179
181
|
background-size: contain;
|
|
180
182
|
background-repeat: no-repeat;
|
|
181
183
|
background-position: center;
|
|
@@ -240,12 +242,12 @@ export default function App() {
|
|
|
240
242
|
</button>
|
|
241
243
|
<div>
|
|
242
244
|
{!showLargeImage ? (
|
|
243
|
-
<img src=
|
|
245
|
+
<img src="/photo.jpg" alt="" />
|
|
244
246
|
) : (
|
|
245
247
|
<div
|
|
246
248
|
class={css`
|
|
247
249
|
${transitionNameClass}
|
|
248
|
-
background: url(');
|
|
250
|
+
background: url('/photo.jpg');
|
|
249
251
|
background-size: contain;
|
|
250
252
|
background-repeat: no-repeat;
|
|
251
253
|
background-position: center;
|
|
@@ -272,6 +274,9 @@ export default function App() {
|
|
|
272
274
|
|
|
273
275
|
## The `acrylblends/jsx/dom` runtime
|
|
274
276
|
|
|
277
|
+
On Node you can skip the bundler: [Client components without a bundler](client-modules.md) serves `.tsx` client modules to the browser with
|
|
278
|
+
this runtime and one script tag, in development and in production.
|
|
279
|
+
|
|
275
280
|
There is a small JSX Runtime for Client Components. Using this will result in smaller bundled results than using `acrylblends/jsx`. Specify `acrylblends/jsx/dom` in `tsconfig.json`. For Deno, modify the deno.json.
|
|
276
281
|
|
|
277
282
|
```json
|
|
@@ -144,6 +144,93 @@ app.post(
|
|
|
144
144
|
)
|
|
145
145
|
```
|
|
146
146
|
|
|
147
|
+
## Validating an HTML form
|
|
148
|
+
|
|
149
|
+
A browser posts a `<form>` as `application/x-www-form-urlencoded`, or as `multipart/form-data` when it has `enctype="multipart/form-data"` (needed
|
|
150
|
+
for file inputs). `validator('form', ...)` parses both. Each field arrives as a `string`, a file input as a `File`, and a name that appears more
|
|
151
|
+
than once (several checkboxes, or a name ending in `[]`) as an array, so the value has the type `Record<string, string | File | (string | File)[]>`.
|
|
152
|
+
|
|
153
|
+
This page shows a form and re-renders it with a message when a field is wrong:
|
|
154
|
+
|
|
155
|
+
```ts
|
|
156
|
+
import { Acryl } from 'acrylblends'
|
|
157
|
+
import { html } from 'acrylblends/html'
|
|
158
|
+
import { validator } from 'acrylblends/validator'
|
|
159
|
+
|
|
160
|
+
const page = (error?: string) => html`<!doctype html>
|
|
161
|
+
<form method="post" action="/notes" enctype="multipart/form-data">
|
|
162
|
+
<input name="title" required>
|
|
163
|
+
<textarea name="body"></textarea>
|
|
164
|
+
<input type="file" name="attachment">
|
|
165
|
+
<button>Save</button>
|
|
166
|
+
${error === undefined ? '' : html`<p role="alert">${error}</p>`}
|
|
167
|
+
</form>`
|
|
168
|
+
|
|
169
|
+
const app = new Acryl()
|
|
170
|
+
.get('/notes/new', (c) => c.html(page()))
|
|
171
|
+
.post(
|
|
172
|
+
'/notes',
|
|
173
|
+
validator('form', (form, c) => {
|
|
174
|
+
const { title, body, attachment } = form
|
|
175
|
+
if (typeof title !== 'string' || title.trim() === '') return c.html(page('Title is required'), 400)
|
|
176
|
+
if (body !== undefined && typeof body !== 'string') return c.html(page('Body must be text'), 400)
|
|
177
|
+
if (attachment !== undefined && !(attachment instanceof File)) return c.html(page('Attachment must be one file'), 400)
|
|
178
|
+
return {
|
|
179
|
+
title: title.trim(),
|
|
180
|
+
body: body ?? '',
|
|
181
|
+
// A file input left empty still sends a part: a File with no name and no bytes.
|
|
182
|
+
attachment: attachment !== undefined && attachment.size > 0 ? attachment : undefined,
|
|
183
|
+
}
|
|
184
|
+
}),
|
|
185
|
+
(c) => {
|
|
186
|
+
const { title, attachment } = c.req.valid('form') // { title: string, body: string, attachment: File | undefined }
|
|
187
|
+
return c.text(`Saved ${title}${attachment === undefined ? '' : ` with ${attachment.name}`}`, 201)
|
|
188
|
+
}
|
|
189
|
+
)
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
The messages are escaped by `html`, so a field value shown back in the page cannot inject markup.
|
|
193
|
+
|
|
194
|
+
### With a schema
|
|
195
|
+
|
|
196
|
+
`schemaValidator('form', schema)` takes the same value. It answers a failure with 400 and JSON issues, which suits a form your own script submits
|
|
197
|
+
with `fetch()`:
|
|
198
|
+
|
|
199
|
+
```ts
|
|
200
|
+
import * as z from 'zod'
|
|
201
|
+
import { schemaValidator } from 'acrylblends/schema-validator'
|
|
202
|
+
|
|
203
|
+
const NoteForm = z.object({
|
|
204
|
+
title: z.string().trim().min(1, 'Title is required'),
|
|
205
|
+
body: z.string().default(''),
|
|
206
|
+
attachment: z.instanceof(File).optional(),
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
app.post('/api/notes', schemaValidator('form', NoteForm), (c) => {
|
|
210
|
+
const { title } = c.req.valid('form') // typed by the schema's output
|
|
211
|
+
return c.json({ title }, 201)
|
|
212
|
+
})
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
For a plain HTML form, call the schema inside `validator('form', ...)` to re-render the page with the first message instead:
|
|
216
|
+
|
|
217
|
+
```ts
|
|
218
|
+
validator('form', (form, c) => {
|
|
219
|
+
const result = NoteForm.safeParse(form)
|
|
220
|
+
return result.success ? result.data : c.html(page(result.error.issues[0]?.message), 400)
|
|
221
|
+
})
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
To test a form route, send a `FormData`: `app.request` sets the `multipart/form-data` content type with its boundary itself.
|
|
225
|
+
|
|
226
|
+
```ts
|
|
227
|
+
const form = new FormData()
|
|
228
|
+
form.set('title', 'Groceries')
|
|
229
|
+
form.set('attachment', new File(['milk'], 'list.txt', { type: 'text/plain' }))
|
|
230
|
+
const res = await app.request('/notes', { method: 'POST', body: form })
|
|
231
|
+
// res.status === 201, await res.text() === 'Saved Groceries with list.txt'
|
|
232
|
+
```
|
|
233
|
+
|
|
147
234
|
## Multiple validators
|
|
148
235
|
|
|
149
236
|
You can also include multiple validators to validate different parts of request:
|
|
@@ -213,7 +300,7 @@ const route = app.post(
|
|
|
213
300
|
validator('form', (value, c) => {
|
|
214
301
|
const parsed = schema.safeParse(value)
|
|
215
302
|
if (!parsed.success) {
|
|
216
|
-
return c.text('Invalid!',
|
|
303
|
+
return c.text('Invalid!', 400)
|
|
217
304
|
}
|
|
218
305
|
return parsed.data
|
|
219
306
|
}),
|
|
@@ -36,7 +36,7 @@ app.get('/cookie', (c) => {
|
|
|
36
36
|
**NOTE**: Setting and retrieving signed cookies returns a Promise due to the async nature of the WebCrypto API, which is used to create HMAC SHA-256 signatures.
|
|
37
37
|
|
|
38
38
|
```ts
|
|
39
|
-
app.get('/signed-cookie', (c) => {
|
|
39
|
+
app.get('/signed-cookie', async (c) => {
|
|
40
40
|
const secret = process.env.COOKIE_SECRET! // a long random string, kept out of the source
|
|
41
41
|
|
|
42
42
|
await setSignedCookie(c, 'cookie_name0', 'cookie_value', secret)
|