acrylblends 0.7.2 → 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.
@@ -1,3 +1,3 @@
1
1
  # Generated copy
2
2
 
3
- This folder is a copy of `docs/framework` of acryldev/acryl at commit `cdc083e1bbaedaeafa7494fe646bdb202ce86d09`, made by `scripts/sync-framework.mjs`. Do not edit it here: change the text in ACRYL and sync again. `pnpm run verify:framework` fails when a page differs from the recorded digest.
3
+ This folder is a copy of `docs/framework` of acryldev/acryl at commit `e79bf3de59c700667ae617f5d367cc1fd2f8e037`, made by `scripts/sync-framework.mjs`. Do not edit it here: change the text in ACRYL and sync again. `pnpm run verify:framework` fails when a page differs from the recorded digest.
@@ -110,6 +110,40 @@ server's nodes are gone afterwards, together with anything the visitor did to th
110
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
111
  server markup of an island, or render them only in the browser.
112
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
+
113
147
  ## How the imports reach the browser
114
148
 
115
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
@@ -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.
@@ -2,8 +2,8 @@
2
2
  "source": {
3
3
  "repo": "acryldev/acryl",
4
4
  "path": "docs/framework",
5
- "commit": "cdc083e1bbaedaeafa7494fe646bdb202ce86d09"
5
+ "commit": "e79bf3de59c700667ae617f5d367cc1fd2f8e037"
6
6
  },
7
7
  "files": 91,
8
- "digest": "sha256:964e5ff58d487ce53394469d359a251265a3ef093354f55b6b4c47c55051774b"
8
+ "digest": "sha256:ea881fe758d9d9add8a8d3837f1dddc6938c3e021cb4ea5f1ce5cfb31af7ef2c"
9
9
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "acrylblends",
3
- "version": "0.7.2",
3
+ "version": "0.7.3",
4
4
  "description": "The Acryl web framework (import { Acryl } from \"acrylblends\") and the acrylblends command that scaffolds Cordis apps.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -2,7 +2,7 @@
2
2
  "source": {
3
3
  "repo": "acryldev/acryl",
4
4
  "path": "plugins/acryl-inspect",
5
- "commit": "cdc083e1bbaedaeafa7494fe646bdb202ce86d09"
5
+ "commit": "e79bf3de59c700667ae617f5d367cc1fd2f8e037"
6
6
  },
7
7
  "vendored": [
8
8
  "src",
@@ -2,7 +2,7 @@
2
2
  "source": {
3
3
  "repo": "acryldev/acryl",
4
4
  "path": "plugins/acryl-node-server",
5
- "commit": "cdc083e1bbaedaeafa7494fe646bdb202ce86d09"
5
+ "commit": "e79bf3de59c700667ae617f5d367cc1fd2f8e037"
6
6
  },
7
7
  "vendored": [
8
8
  "src",
@@ -2,7 +2,7 @@
2
2
  "source": {
3
3
  "repo": "acryldev/acryl",
4
4
  "path": "plugins/acryl-router",
5
- "commit": "cdc083e1bbaedaeafa7494fe646bdb202ce86d09"
5
+ "commit": "e79bf3de59c700667ae617f5d367cc1fd2f8e037"
6
6
  },
7
7
  "vendored": [
8
8
  "src",
@@ -2,7 +2,7 @@
2
2
  "source": {
3
3
  "repo": "acryldev/acryl",
4
4
  "path": "plugins/acryl-router-http",
5
- "commit": "cdc083e1bbaedaeafa7494fe646bdb202ce86d09"
5
+ "commit": "e79bf3de59c700667ae617f5d367cc1fd2f8e037"
6
6
  },
7
7
  "vendored": [
8
8
  "src",
@@ -2,7 +2,7 @@
2
2
  "source": {
3
3
  "repo": "acryldev/acryl",
4
4
  "path": "plugins/acryl-state",
5
- "commit": "cdc083e1bbaedaeafa7494fe646bdb202ce86d09"
5
+ "commit": "e79bf3de59c700667ae617f5d367cc1fd2f8e037"
6
6
  },
7
7
  "vendored": [
8
8
  "src",
@@ -5,7 +5,7 @@
5
5
  "apps/acryl-desktop/src/shell/desktop-user-data.ts": "user-data.ts",
6
6
  "apps/acryl-desktop/src/shell/shutdown.ts": "shutdown.ts"
7
7
  },
8
- "commit": "cdc083e1bbaedaeafa7494fe646bdb202ce86d09"
8
+ "commit": "e79bf3de59c700667ae617f5d367cc1fd2f8e037"
9
9
  },
10
10
  "rewrittenSpecifiers": 0,
11
11
  "digest": "sha256:7dfe960a8bbb476c35540d2202a7aaa62b33492c2ac3e34314e2ecfca45f32f2"
@@ -5,7 +5,7 @@
5
5
  "apps/acryl-desktop/src/shell/desktop-user-data.ts": "user-data.ts",
6
6
  "apps/acryl-desktop/src/shell/shutdown.ts": "shutdown.ts"
7
7
  },
8
- "commit": "cdc083e1bbaedaeafa7494fe646bdb202ce86d09"
8
+ "commit": "e79bf3de59c700667ae617f5d367cc1fd2f8e037"
9
9
  },
10
10
  "rewrittenSpecifiers": 0,
11
11
  "digest": "sha256:7dfe960a8bbb476c35540d2202a7aaa62b33492c2ac3e34314e2ecfca45f32f2"
@@ -5,7 +5,7 @@
5
5
  "examples/acryl-todo/src/index.ts": "src/todo.ts",
6
6
  "examples/acryl-todo/src/page.ts": "src/page.ts"
7
7
  },
8
- "commit": "cdc083e1bbaedaeafa7494fe646bdb202ce86d09"
8
+ "commit": "e79bf3de59c700667ae617f5d367cc1fd2f8e037"
9
9
  },
10
10
  "rewrittenSpecifiers": 4,
11
11
  "digest": "sha256:9093704776563001f7f472f94cc5c2fdd79206342c1cb59c11fce62aa74df3ed"