acrylblends 0.7.1 → 0.7.3

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 (47) hide show
  1. package/README.md +2 -2
  2. package/dist/index.js +41 -1
  3. package/dist/index.js.map +1 -1
  4. package/docs/framework/README.md +2 -1
  5. package/docs/framework/UPSTREAM.md +1 -1
  6. package/docs/framework/api/summary.md +6 -3
  7. package/docs/framework/cordis/node-server.md +3 -0
  8. package/docs/framework/getting-started/deploy.md +133 -0
  9. package/docs/framework/getting-started/nodejs.md +37 -16
  10. package/docs/framework/guides/client-modules.md +209 -0
  11. package/docs/framework/guides/forms-and-sessions.md +1 -1
  12. package/docs/framework/guides/jsx-dom.md +6 -1
  13. package/docs/framework/guides/validation.md +87 -0
  14. package/docs/framework/helpers/streaming.md +119 -0
  15. package/docs/framework/helpers/testing.md +17 -0
  16. package/docs/framework/upstream.json +3 -3
  17. package/lib/browser/client.js +307 -0
  18. package/lib/browser/jsx-dom.js +1432 -0
  19. package/lib/index.js +1 -25
  20. package/lib/node-server/register.js +7 -49
  21. package/lib/node-server.d.ts +74 -7
  22. package/lib/node-server.js +264 -7
  23. package/lib/schema-validator.d.ts +40 -14
  24. package/lib/schema-validator.js +2 -2
  25. package/lib/src-0hI7M7cN.js +27 -0
  26. package/lib/transpile-CTnWof4z.js +90 -0
  27. package/package.json +1 -1
  28. package/plugins/acryl-inspect/upstream.json +1 -1
  29. package/plugins/acryl-node-server/src/adapter/server.ts +28 -5
  30. package/plugins/acryl-node-server/src/adapter/types.ts +14 -2
  31. package/plugins/acryl-node-server/src/client-modules.ts +293 -0
  32. package/plugins/acryl-node-server/src/register.ts +5 -62
  33. package/plugins/acryl-node-server/src/transpile.ts +120 -0
  34. package/plugins/acryl-node-server/upstream.json +4 -3
  35. package/plugins/acryl-router/src/browser/client.ts +3 -0
  36. package/plugins/acryl-router/src/browser/jsx-dom.ts +9 -0
  37. package/plugins/acryl-router/src/helpers/index.ts +1 -1
  38. package/plugins/acryl-router/src/helpers/schema-validator.ts +39 -12
  39. package/plugins/acryl-router/upstream.json +2 -2
  40. package/plugins/acryl-router-http/upstream.json +1 -1
  41. package/plugins/acryl-state/upstream.json +1 -1
  42. package/plugins/desktop/upstream.json +1 -1
  43. package/plugins/desktop-web/upstream.json +1 -1
  44. package/templates/agent/package.json +4 -3
  45. package/templates/blank/package.json +4 -3
  46. package/templates/todo/package.json +4 -3
  47. package/templates/todo/upstream.json +1 -1
@@ -0,0 +1,209 @@
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
+ ## A form and its state in an island
114
+
115
+ An island that owns an input keeps the text in state and writes it back to the element, so typing and clearing the box both work:
116
+
117
+ ```tsx
118
+ import { render, useState } from 'acrylblends/jsx/dom'
119
+
120
+ const Ask = () => {
121
+ const [text, setText] = useState('')
122
+ const [sent, setSent] = useState<string[]>([])
123
+ const submit = (event: Event) => {
124
+ event.preventDefault()
125
+ if (text.trim() === '') return
126
+ setSent([...sent, text.trim()])
127
+ setText('')
128
+ }
129
+ return (
130
+ <form onSubmit={submit}>
131
+ <textarea value={text} onInput={(e) => setText((e.target as HTMLTextAreaElement).value)} />
132
+ <button type="submit">Send</button>
133
+ <ul>
134
+ {sent.map((message) => (
135
+ <li>{message}</li>
136
+ ))}
137
+ </ul>
138
+ </form>
139
+ )
140
+ }
141
+
142
+ render(<Ask />, document.getElementById('ask')!)
143
+ ```
144
+
145
+ For a request that streams its answer and a Stop button (an `AbortController` kept in a `useRef`), see [Streaming an answer from a model](../helpers/streaming.md#streaming-an-answer-from-a-model).
146
+
147
+ ## How the imports reach the browser
148
+
149
+ - JSX in a client module always compiles for the DOM renderer (`acrylblends/jsx/dom`), whatever the server's `tsconfig.json` says. A file's
150
+ own `/** @jsxImportSource ... */` comment still wins.
151
+ - `acrylblends/jsx/dom`, its `jsx-runtime`, `jsx-dev-runtime` and `client` are one browser module (`@acrylblends/jsx-dom.js`), and
152
+ `acrylblends/client` is another (`@acrylblends/client.js`). Neither imports anything. The same app serves them, and each import is
153
+ rewritten to a URL relative to the module, so the setup works under whatever path you route `clientModules` at.
154
+ - Import your own modules with their file names, as the browser fetches them: `import { label } from './parts/label.ts'`.
155
+ - Another package (`import confetti from 'canvas-confetti'`) is left as it is: add an
156
+ [import map](https://developer.mozilla.org/docs/Web/HTML/Element/script/type/importmap) to the page for it, or use a bundler.
157
+ - `.js` and `.mjs` files are served unchanged.
158
+
159
+ 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;
160
+ set `cacheBytes` to change the bound). Responses carry an `ETag` and `Cache-Control: no-cache`, so the browser revalidates and an edit shows
161
+ 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
162
+ folder and other file types answer 404.
163
+
164
+ ## In production
165
+
166
+ Templates install `typescript` as a development dependency, and a deploy that leaves those out (`npm ci --omit=dev`) has no compiler.
167
+ Compile the client folder once when you build, as the `build` script of your `package.json`:
168
+
169
+ ```json
170
+ {
171
+ "scripts": {
172
+ "build": "acrylblends build-client"
173
+ }
174
+ }
175
+ ```
176
+
177
+ `acrylblends build-client [root] [outDir]` (from acrylblends 0.7.2; `src/client` and `dist/client` when not given) calls
178
+ `buildClientModules`. With an earlier version, or without the command line tool, call the function itself:
179
+
180
+ ```json
181
+ {
182
+ "scripts": {
183
+ "build": "node --input-type=module -e \"import { buildClientModules } from 'acrylblends/node-server'; buildClientModules({ root: 'src/client', outDir: 'dist/client' })\""
184
+ }
185
+ }
186
+ ```
187
+
188
+ 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
189
+ browser modules to `dist/client/@acrylblends/`. Run it where `typescript` is installed, before the development dependencies are removed.
190
+
191
+ With `NODE_ENV=production` and the `compiled` option set, `clientModules` serves only that folder and compiles nothing. A module that is in
192
+ `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
193
+ server at startup. Outside production, modules are always compiled on request, so a stale `dist/client` never hides an edit.
194
+
195
+ When `typescript` is missing where a module has to be compiled, the module answers 500 with the message
196
+ `... is a client module, and compiling it needs the typescript package in this app: npm install --save-dev typescript`.
197
+
198
+ ## Reference
199
+
200
+ `clientModules(options)` returns an app; route it where the page's script tags point.
201
+
202
+ | Option | Meaning |
203
+ | --- | --- |
204
+ | `root` | The folder of client modules, absolute or relative to the working directory. |
205
+ | `compiled` | The folder `buildClientModules` wrote. Served instead of compiling when `NODE_ENV` is `production`. |
206
+ | `cacheBytes` | The bound of the in-memory cache of compiled modules, in bytes. 16 MiB when not given. |
207
+
208
+ `buildClientModules({ root, outDir })` compiles every module below `root` (dot files, `node_modules` and `.d.ts` files skipped) into `outDir`,
209
+ which must not lie inside `root`, and returns the paths it wrote. Files already in `outDir` that no source produces are left there.
@@ -228,4 +228,4 @@ export const Field = () => (
228
228
  )
229
229
  ```
230
230
 
231
- [Client Components](jsx-dom.md) (`acrylblends/jsx/dom`) are for pages that need components and state in the browser; they have to be bundled for the browser first (with Vite or esbuild).
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 fast and very small. The counter program in `acrylblends/jsx/dom` is only 2.8KB with Brotli compression, but 47.8KB for React.
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
 
@@ -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:
@@ -81,6 +81,125 @@ app.get('/sse', async (c) => {
81
81
  })
82
82
  ```
83
83
 
84
+ ## Cancelling work when the client leaves
85
+
86
+ A reader that closes the tab, loses the network or presses a Stop button should not leave your server working for nobody (a request to a paid model, a database scan). There are two signals, and they are not the same:
87
+
88
+ - **`c.req.raw.signal`** is the request's `AbortSignal`. It aborts when the client goes away. Pass it to anything that takes a `signal`, such as `fetch`, and the work stops with the client. This is the one to use for cancelling work.
89
+ - **`stream.onAbort(callback)`** and **`stream.aborted`** belong to the response stream. They report that the *reader of the response* was cancelled. Measured: when you call `app.request()` with a `signal` and abort it, `c.req.raw.signal` aborts and `stream.aborted` stays `false`; when the reader of the response body is cancelled, `stream.onAbort` fires and `c.req.raw.signal` does not. On a real connection the request signal is what fired when a browser aborted its `fetch`. Use `c.req.raw.signal` for the work, and `stream.onAbort` for cleanup of the stream itself.
90
+
91
+ An abort event that has already fired does not fire again, so check `signal.aborted` before you wait for it:
92
+
93
+ ```ts
94
+ const waitForAbort = (signal: AbortSignal) =>
95
+ signal.aborted
96
+ ? Promise.resolve()
97
+ : new Promise<void>((resolve) => signal.addEventListener('abort', () => resolve(), { once: true }))
98
+ ```
99
+
100
+ Keep what you have when the client leaves by saving it in a `finally` block: the stream callback runs to the end whether or not the client is still there.
101
+
102
+ ## Streaming an answer from a model
103
+
104
+ A chat answer that arrives token by token is a `streamSSE` response that reads a streaming upstream. This server route calls an OpenAI-compatible chat completions API (`stream: true`), forwards each piece as an event, and cancels the upstream request when the client leaves. The key comes from the environment and is never written anywhere.
105
+
106
+ ```ts
107
+ import { Acryl } from 'acrylblends'
108
+ import { streamSSE } from 'acrylblends/streaming'
109
+
110
+ /** The pieces of an OpenAI-compatible streaming answer: each `data:` line holds a JSON chunk, and `[DONE]` ends it. */
111
+ async function* modelPieces(messages: { role: string; content: string }[], signal: AbortSignal): AsyncGenerator<string> {
112
+ const key = process.env.DEEPSEEK_API_KEY
113
+ if (!key) throw new Error('DEEPSEEK_API_KEY is not set')
114
+ const base = process.env.DEEPSEEK_BASE_URL ?? 'https://api.deepseek.com'
115
+ const response = await fetch(`${base}/chat/completions`, {
116
+ method: 'POST',
117
+ headers: { 'content-type': 'application/json', authorization: `Bearer ${key}` },
118
+ body: JSON.stringify({ model: process.env.DEEPSEEK_MODEL ?? 'deepseek-chat', messages, stream: true }),
119
+ signal,
120
+ })
121
+ if (!response.ok || !response.body) throw new Error(`the model service answered ${response.status}`)
122
+ const decoder = new TextDecoder()
123
+ let buffer = ''
124
+ for await (const chunk of response.body) {
125
+ buffer += decoder.decode(chunk, { stream: true })
126
+ const lines = buffer.split('\n')
127
+ buffer = lines.pop() ?? ''
128
+ for (const line of lines) {
129
+ if (!line.startsWith('data:')) continue
130
+ const data = line.slice(5).trim()
131
+ if (data === '[DONE]') return
132
+ const piece = (JSON.parse(data) as { choices?: { delta?: { content?: string } }[] }).choices?.[0]?.delta?.content
133
+ if (piece) yield piece
134
+ }
135
+ }
136
+ }
137
+
138
+ const app = new Acryl()
139
+
140
+ app.post('/api/chat', async (c) => {
141
+ const { message } = await c.req.json<{ message: string }>()
142
+ const signal = c.req.raw.signal // aborts when the client goes away
143
+ return streamSSE(c, async (stream) => {
144
+ let answer = ''
145
+ try {
146
+ for await (const piece of modelPieces([{ role: 'user', content: message }], signal)) {
147
+ answer += piece
148
+ // JSON-encode the piece: a piece may contain a newline, which would end an SSE `data:` line.
149
+ await stream.writeSSE({ event: 'token', data: JSON.stringify(piece) })
150
+ }
151
+ await stream.writeSSE({ event: 'done', data: '{}' })
152
+ } catch (error) {
153
+ if (!signal.aborted) await stream.writeSSE({ event: 'error', data: JSON.stringify('The model could not answer.') })
154
+ } finally {
155
+ console.log(`kept ${answer.length} characters`) // save what arrived, also when the client pressed Stop
156
+ }
157
+ })
158
+ })
159
+ ```
160
+
161
+ ## Reading a POST event stream in the browser
162
+
163
+ `EventSource` can only send a GET request. For a POST (a chat message), read the response body yourself; this function calls `on` for each event as it arrives:
164
+
165
+ ```ts
166
+ async function readEvents(body: ReadableStream<Uint8Array>, on: (event: string, data: string) => void): Promise<void> {
167
+ const reader = body.getReader()
168
+ const decoder = new TextDecoder()
169
+ let buffer = ''
170
+ for (;;) {
171
+ const { done, value } = await reader.read()
172
+ if (done) return
173
+ buffer += decoder.decode(value, { stream: true })
174
+ const blocks = buffer.split('\n\n')
175
+ buffer = blocks.pop() ?? ''
176
+ for (const block of blocks) {
177
+ let event = 'message'
178
+ const data: string[] = []
179
+ for (const line of block.split('\n')) {
180
+ if (line.startsWith('event:')) event = line.slice(6).trim()
181
+ else if (line.startsWith('data:')) data.push(line.slice(5).trimStart())
182
+ }
183
+ on(event, data.join('\n'))
184
+ }
185
+ }
186
+ }
187
+
188
+ const controller = new AbortController() // controller.abort() is the Stop button
189
+ const response = await fetch('/api/chat', {
190
+ method: 'POST',
191
+ headers: { 'content-type': 'application/json' },
192
+ body: JSON.stringify({ message: 'Hello' }),
193
+ signal: controller.signal,
194
+ })
195
+ let answer = ''
196
+ await readEvents(response.body!, (event, data) => {
197
+ if (event === 'token') answer += JSON.parse(data) as string
198
+ })
199
+ ```
200
+
201
+ A complete chat component built on this is in the `chat` template of `create-acrylblends` (`npm create acrylblends@latest my-chat -- --template chat`).
202
+
84
203
  ## Error Handling
85
204
 
86
205
  The third argument of the streaming helper is an error handler.
@@ -99,3 +99,20 @@ describe('Search Endpoint', () => {
99
99
  })
100
100
  })
101
101
  ```
102
+
103
+ ### Default request options
104
+
105
+ `testClient(app, env?, executionCtx?, options?)` takes the client's request options as its fourth argument, so headers every request of a test
106
+ needs, such as a session cookie, are set once. Headers given to a single call are merged over them.
107
+
108
+ ```ts
109
+ import { Acryl } from 'acrylblends'
110
+ import { getCookie } from 'acrylblends/cookie'
111
+ import { testClient } from 'acrylblends/testing'
112
+
113
+ const app = new Acryl().get('/me', (c) => c.json({ session: getCookie(c, 'session') ?? null }))
114
+
115
+ const client = testClient(app, undefined, undefined, { headers: { cookie: 'session=abc' } })
116
+ await (await client.me.$get()).json() // { session: 'abc' }
117
+ await (await client.me.$get(undefined, { headers: { cookie: 'session=xyz' } })).json() // { session: 'xyz' }
118
+ ```
@@ -2,8 +2,8 @@
2
2
  "source": {
3
3
  "repo": "acryldev/acryl",
4
4
  "path": "docs/framework",
5
- "commit": "524406d2033cdc5ca5e01207f9ce29543bbf221b"
5
+ "commit": "e79bf3de59c700667ae617f5d367cc1fd2f8e037"
6
6
  },
7
- "files": 89,
8
- "digest": "sha256:fe2da945c3286e316a0412b2d3cfccc4dbf8d1c082e283b77606b45e27b778aa"
7
+ "files": 91,
8
+ "digest": "sha256:ea881fe758d9d9add8a8d3837f1dddc6938c3e021cb4ea5f1ce5cfb31af7ef2c"
9
9
  }