@qorejs/qore 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 +170 -52
- package/dist/src/core/iterable.d.ts +1 -0
- package/dist/src/core/iterable.js +17 -0
- package/dist/src/core/response-runtime.d.ts +2 -0
- package/dist/src/core/response-runtime.js +233 -0
- package/dist/src/core/response-state.d.ts +3 -0
- package/dist/src/core/response-state.js +37 -0
- package/dist/src/core/response-types.d.ts +73 -0
- package/dist/src/core/response-types.js +1 -0
- package/dist/src/core/response.d.ts +4 -0
- package/dist/src/core/response.js +26 -0
- package/dist/src/core/signal-context.d.ts +10 -0
- package/dist/src/core/signal-context.js +69 -0
- package/dist/src/core/signal-nodes.d.ts +45 -0
- package/dist/src/core/signal-nodes.js +197 -0
- package/dist/src/core/signal-scheduler.d.ts +2 -0
- package/dist/src/core/signal-scheduler.js +19 -0
- package/dist/src/core/signal-types.d.ts +19 -0
- package/dist/src/core/signal-types.js +1 -0
- package/dist/src/core/signal.d.ts +19 -0
- package/dist/src/core/signal.js +41 -0
- package/dist/src/core/stream-backpressure.d.ts +3 -0
- package/dist/src/core/stream-backpressure.js +36 -0
- package/dist/src/core/stream-buffer.d.ts +17 -0
- package/dist/src/core/stream-buffer.js +162 -0
- package/dist/src/core/stream-iterator.d.ts +6 -0
- package/dist/src/core/stream-iterator.js +14 -0
- package/dist/src/core/stream-lifecycle.d.ts +10 -0
- package/dist/src/core/stream-lifecycle.js +28 -0
- package/dist/src/core/stream-queue.d.ts +17 -0
- package/dist/src/core/stream-queue.js +62 -0
- package/dist/src/core/stream-runtime.d.ts +2 -0
- package/dist/src/core/stream-runtime.js +127 -0
- package/dist/src/core/stream-source.d.ts +2 -0
- package/dist/src/core/stream-source.js +28 -0
- package/dist/src/core/stream-state.d.ts +4 -0
- package/dist/src/core/stream-state.js +20 -0
- package/dist/src/core/stream-types.d.ts +61 -0
- package/dist/src/core/stream-types.js +1 -0
- package/dist/src/core/stream.d.ts +11 -0
- package/dist/src/core/stream.js +90 -0
- package/dist/src/dom/app.d.ts +38 -0
- package/dist/src/dom/app.js +102 -0
- package/dist/src/dom/dom.d.ts +13 -0
- package/dist/src/dom/dom.js +197 -0
- package/dist/src/dom/properties.d.ts +3 -0
- package/dist/src/dom/properties.js +147 -0
- package/dist/src/dom/reactive.d.ts +6 -0
- package/dist/src/dom/reactive.js +14 -0
- package/dist/src/dom/response-view.d.ts +4 -0
- package/dist/src/dom/response-view.js +37 -0
- package/dist/src/dom/scope.d.ts +10 -0
- package/dist/src/dom/scope.js +49 -0
- package/dist/src/dom/types.d.ts +34 -0
- package/dist/src/dom/types.js +1 -0
- package/dist/src/index.d.ts +16 -0
- package/dist/src/index.js +10 -0
- package/dist/src/providers/anthropic.d.ts +2 -0
- package/dist/src/providers/anthropic.js +102 -0
- package/dist/src/providers/openai.d.ts +2 -0
- package/dist/src/providers/openai.js +96 -0
- package/dist/src/providers/sse-adapter.d.ts +2 -0
- package/dist/src/providers/sse-adapter.js +83 -0
- package/dist/src/providers/sse-env.d.ts +4 -0
- package/dist/src/providers/sse-env.js +33 -0
- package/dist/src/providers/sse-parser.d.ts +5 -0
- package/dist/src/providers/sse-parser.js +99 -0
- package/dist/src/providers/sse.d.ts +4 -0
- package/dist/src/providers/sse.js +3 -0
- package/dist/src/providers/types.d.ts +94 -0
- package/dist/src/providers/types.js +1 -0
- package/dist/src/shared/utils.d.ts +2 -0
- package/dist/src/shared/utils.js +32 -0
- package/package.json +25 -11
- package/src/anthropic.js +0 -122
- package/src/app.js +0 -123
- package/src/dom.js +0 -527
- package/src/index.d.ts +0 -405
- package/src/index.js +0 -10
- package/src/iterable.js +0 -20
- package/src/openai.js +0 -112
- package/src/response.js +0 -312
- package/src/signal.js +0 -328
- package/src/sse.js +0 -264
- package/src/stream.js +0 -582
- package/src/utils.js +0 -39
package/README.md
CHANGED
|
@@ -1,24 +1,62 @@
|
|
|
1
1
|
# Qore
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
[](https://www.npmjs.com/package/@qorejs/qore)
|
|
4
|
+
[](https://github.com/qorejs/qore/actions/workflows/ci.yml)
|
|
5
|
+
[](#browser-regression)
|
|
6
|
+
[](https://github.com/qorejs/qore/actions/workflows/release-check.yml)
|
|
7
|
+
[](https://github.com/qorejs/qore/actions/workflows/publish-github-packages.yml)
|
|
8
|
+
[](https://github.com/qorejs/qore/packages)
|
|
9
|
+
[](https://nodejs.org/)
|
|
10
|
+
[](https://github.com/qorejs/qore/blob/main/LICENSE)
|
|
11
|
+
|
|
12
|
+
Qore is a streaming-response framework where `stream = signal`.
|
|
13
|
+
|
|
14
|
+
Instead of treating data as a snapshot, Qore treats it like a river. Tokens arrive piece by piece, and the UI should respond piece by piece too. No manual string accumulation. No scattered loading state. No partial rendering workaround layered on top of a snapshot-first mental model.
|
|
15
|
+
|
|
16
|
+
Quick links:
|
|
17
|
+
|
|
18
|
+
- [npm package](https://www.npmjs.com/package/@qorejs/qore)
|
|
19
|
+
- [GitHub Packages](https://github.com/qorejs/qore/packages)
|
|
20
|
+
- [landing page source](https://github.com/qorejs/qore/blob/main/index.html)
|
|
21
|
+
- [streaming demo source](https://github.com/qorejs/qore/blob/main/examples/streaming-response.html)
|
|
22
|
+
- [benchmark page](https://github.com/qorejs/qore/blob/main/examples/benchmark.html)
|
|
23
|
+
- [continuous integration](https://github.com/qorejs/qore/actions/workflows/ci.yml)
|
|
24
|
+
- [release workflow](https://github.com/qorejs/qore/actions/workflows/release-check.yml)
|
|
25
|
+
- [contributing guide](https://github.com/qorejs/qore/blob/main/CONTRIBUTING.md)
|
|
26
|
+
- [security policy](https://github.com/qorejs/qore/blob/main/SECURITY.md)
|
|
6
27
|
|
|
7
28
|
## Installation
|
|
8
29
|
|
|
30
|
+
From npm:
|
|
31
|
+
|
|
9
32
|
```bash
|
|
10
33
|
npm i @qorejs/qore
|
|
11
34
|
```
|
|
12
35
|
|
|
36
|
+
From GitHub Packages:
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
echo "@qorejs:registry=https://npm.pkg.github.com" >> .npmrc
|
|
40
|
+
npm i @qorejs/qore
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
GitHub Packages installs require an authenticated session against `https://npm.pkg.github.com`.
|
|
44
|
+
|
|
13
45
|
- Package name: `@qorejs/qore`
|
|
14
46
|
- Module format: `ESM`
|
|
15
47
|
- Supported runtime: `Node >= 18`
|
|
48
|
+
- CI coverage: `Node 18`, `20`, and `22`
|
|
49
|
+
- Browser regression: Playwright desktop + mobile smoke coverage for the homepage, focused demo, and benchmark page
|
|
50
|
+
- Registries:
|
|
51
|
+
- npm: [npmjs.com/package/@qorejs/qore](https://www.npmjs.com/package/@qorejs/qore)
|
|
52
|
+
- GitHub Packages: [github.com/qorejs/qore/packages](https://github.com/qorejs/qore/packages)
|
|
16
53
|
|
|
17
54
|
## Core Idea
|
|
18
55
|
|
|
19
|
-
`stream`
|
|
56
|
+
`stream` is how data flows.
|
|
57
|
+
`signal` is how the UI reacts.
|
|
20
58
|
|
|
21
|
-
|
|
59
|
+
In Qore, they are two sides of the same primitive:
|
|
22
60
|
|
|
23
61
|
```js
|
|
24
62
|
import { createOpenAI, h, stream, text } from '@qorejs/qore';
|
|
@@ -29,18 +67,18 @@ const answer = stream(openai.chat('hello'));
|
|
|
29
67
|
return h('div', {}, text(() => answer()));
|
|
30
68
|
```
|
|
31
69
|
|
|
32
|
-
|
|
70
|
+
Here, `answer` is all of the following at once:
|
|
33
71
|
|
|
34
|
-
-
|
|
35
|
-
-
|
|
36
|
-
-
|
|
72
|
+
- A read-only `signal`, so `answer()` returns the current accumulated value
|
|
73
|
+
- An `AsyncIterable`, so you can still use `for await...of`
|
|
74
|
+
- A lifecycle-aware streaming state, with `status()`, `streaming()`, `error()`, and `chunks()`
|
|
37
75
|
|
|
38
76
|
## Why Qore
|
|
39
77
|
|
|
40
|
-
- React
|
|
41
|
-
- SolidJS
|
|
42
|
-
- Vue
|
|
43
|
-
- Qore
|
|
78
|
+
- React treats streaming as a special case that needs extra machinery
|
|
79
|
+
- SolidJS has excellent signals, but no native stream primitive
|
|
80
|
+
- Vue has ergonomic refs, but stream handling still lives outside the core model
|
|
81
|
+
- Qore makes `stream = signal` the core API from the start
|
|
44
82
|
|
|
45
83
|
## Quick Start
|
|
46
84
|
|
|
@@ -48,15 +86,15 @@ return h('div', {}, text(() => answer()));
|
|
|
48
86
|
import { h, mount, stream, text } from '@qorejs/qore';
|
|
49
87
|
|
|
50
88
|
const answer = stream(async function* () {
|
|
51
|
-
yield '
|
|
52
|
-
yield '
|
|
53
|
-
yield '
|
|
89
|
+
yield 'stream';
|
|
90
|
+
yield ' = ';
|
|
91
|
+
yield 'signal';
|
|
54
92
|
}());
|
|
55
93
|
|
|
56
94
|
mount('#app', () => h('div', { className: 'answer' }, text(() => answer())));
|
|
57
95
|
```
|
|
58
96
|
|
|
59
|
-
|
|
97
|
+
This updates only the text node that depends on the stream. It does not re-render the whole tree.
|
|
60
98
|
|
|
61
99
|
## Providers
|
|
62
100
|
|
|
@@ -88,7 +126,7 @@ const answer = stream(anthropic.chat('Why should stream be signal?'));
|
|
|
88
126
|
|
|
89
127
|
### `createSSEAdapter(options?)`
|
|
90
128
|
|
|
91
|
-
|
|
129
|
+
If your backend already streams `text/event-stream`, Qore can adopt it directly:
|
|
92
130
|
|
|
93
131
|
```js
|
|
94
132
|
import { createSSEAdapter, stream } from '@qorejs/qore';
|
|
@@ -113,25 +151,25 @@ const provider = createSSEAdapter({
|
|
|
113
151
|
const answer = stream(provider.chat('hello'));
|
|
114
152
|
```
|
|
115
153
|
|
|
116
|
-
|
|
154
|
+
That makes `stream(provider.chat(...))` a general entry point instead of something tied to a single SDK.
|
|
117
155
|
|
|
118
156
|
## API Shape
|
|
119
157
|
|
|
120
158
|
### `stream(source, options?)`
|
|
121
159
|
|
|
122
|
-
|
|
160
|
+
By default, `stream(...)` accumulates chunks into a text signal:
|
|
123
161
|
|
|
124
162
|
```js
|
|
125
163
|
const answer = stream(openai.chat('hello'));
|
|
126
164
|
|
|
127
|
-
answer(); //
|
|
165
|
+
answer(); // current text
|
|
128
166
|
answer.status(); // idle | pending | streaming | completed | error | aborted
|
|
129
167
|
answer.streaming(); // boolean
|
|
130
|
-
answer.chunks(); //
|
|
131
|
-
await answer.ready; //
|
|
168
|
+
answer.chunks(); // raw chunks
|
|
169
|
+
await answer.ready; // wait for completion
|
|
132
170
|
```
|
|
133
171
|
|
|
134
|
-
|
|
172
|
+
If you need structured streams:
|
|
135
173
|
|
|
136
174
|
```js
|
|
137
175
|
const events = stream.list(eventSource);
|
|
@@ -148,17 +186,17 @@ const answer = stream.withBackpressure(openai.chat('hello'), {
|
|
|
148
186
|
});
|
|
149
187
|
```
|
|
150
188
|
|
|
151
|
-
|
|
189
|
+
Backpressure is not just a delay wrapper:
|
|
152
190
|
|
|
153
|
-
- `interval
|
|
154
|
-
- `buffer
|
|
155
|
-
- `overflow
|
|
191
|
+
- `interval`: the minimum spacing between chunk delivery into the signal and UI
|
|
192
|
+
- `buffer`: the maximum number of queued chunks before the UI catches up
|
|
193
|
+
- `overflow`: what to do when the buffer is full: `wait`, `drop-oldest`, `drop-newest`, or `error`
|
|
156
194
|
|
|
157
|
-
|
|
195
|
+
You can also observe stream pressure directly:
|
|
158
196
|
|
|
159
197
|
```js
|
|
160
|
-
answer.buffered(); //
|
|
161
|
-
answer.dropped(); //
|
|
198
|
+
answer.buffered(); // how many chunks are queued right now
|
|
199
|
+
answer.dropped(); // how many chunks were dropped by the overflow policy
|
|
162
200
|
```
|
|
163
201
|
|
|
164
202
|
### `signal`, `computed`, `effect`
|
|
@@ -172,42 +210,122 @@ const length = computed(() => answer().length);
|
|
|
172
210
|
|
|
173
211
|
### `response`
|
|
174
212
|
|
|
175
|
-
`response`
|
|
213
|
+
`response` still exists, but it is closer to a lower-level state machine escape hatch for custom reducers and aggregators.
|
|
176
214
|
|
|
177
|
-
|
|
215
|
+
If your goal is to pipe a stream directly into the UI, prefer `stream(...)`.
|
|
178
216
|
|
|
179
217
|
## Demos
|
|
180
218
|
|
|
181
|
-
|
|
219
|
+
The repository includes a landing page and a focused streaming demo:
|
|
182
220
|
|
|
183
221
|
- [Landing Page Source](https://github.com/qorejs/qore/blob/main/index.html)
|
|
184
|
-
- [Homepage Logic](https://github.com/qorejs/qore/blob/main/examples/showcase.
|
|
222
|
+
- [Homepage Logic](https://github.com/qorejs/qore/blob/main/examples/showcase.ts)
|
|
185
223
|
- [Homepage Styles](https://github.com/qorejs/qore/blob/main/examples/showcase.css)
|
|
224
|
+
- [Benchmark Page](https://github.com/qorejs/qore/blob/main/examples/benchmark.html)
|
|
225
|
+
- [Benchmark Logic](https://github.com/qorejs/qore/blob/main/examples/benchmark-page.ts)
|
|
226
|
+
- [Benchmark Core](https://github.com/qorejs/qore/blob/main/examples/benchmark-core.ts)
|
|
186
227
|
- [Focused Demo](https://github.com/qorejs/qore/blob/main/examples/streaming-response.html)
|
|
187
|
-
- [Focused Chat Logic](https://github.com/qorejs/qore/blob/main/examples/qore-chat.
|
|
188
|
-
- [React Compare](https://github.com/qorejs/qore/blob/main/examples/react-chat.
|
|
228
|
+
- [Focused Chat Logic](https://github.com/qorejs/qore/blob/main/examples/qore-chat.ts)
|
|
229
|
+
- [React Compare](https://github.com/qorejs/qore/blob/main/examples/react-chat.ts)
|
|
230
|
+
|
|
231
|
+
## Project Layout
|
|
232
|
+
|
|
233
|
+
```text
|
|
234
|
+
src/
|
|
235
|
+
core/ stream, signal, response, iterable
|
|
236
|
+
dom/ app mounting and DOM bindings
|
|
237
|
+
providers/ OpenAI, Anthropic, generic SSE adapters
|
|
238
|
+
shared/ runtime utilities
|
|
239
|
+
index.ts public entrypoint
|
|
240
|
+
|
|
241
|
+
dist/
|
|
242
|
+
src/ compiled package output
|
|
243
|
+
examples/ built showcase scripts for local preview
|
|
244
|
+
test/ compiled test output
|
|
245
|
+
```
|
|
189
246
|
|
|
190
|
-
|
|
247
|
+
For a local preview:
|
|
191
248
|
|
|
192
249
|
```bash
|
|
193
250
|
git clone git@github.com:qorejs/qore.git
|
|
194
251
|
cd qore
|
|
252
|
+
npm install
|
|
253
|
+
npm run build
|
|
195
254
|
python3 -m http.server 4173
|
|
196
255
|
```
|
|
197
256
|
|
|
198
|
-
|
|
257
|
+
Then open [http://127.0.0.1:4173/](http://127.0.0.1:4173/).
|
|
258
|
+
|
|
259
|
+
## Browser Regression
|
|
260
|
+
|
|
261
|
+
Install the browser binary once:
|
|
262
|
+
|
|
263
|
+
```bash
|
|
264
|
+
npm run browsers:install
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
Then run the browser smoke suite:
|
|
268
|
+
|
|
269
|
+
```bash
|
|
270
|
+
npm run test:browser
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
It validates:
|
|
274
|
+
|
|
275
|
+
- the homepage stream demo
|
|
276
|
+
- the focused streaming chat demo
|
|
277
|
+
- the dedicated benchmark page
|
|
278
|
+
|
|
279
|
+
The suite checks desktop and mobile layouts, watches for runtime console errors, exercises the primary interactions, and runs inside `release:check`.
|
|
280
|
+
|
|
281
|
+
CI also uploads the browser regression evidence as workflow artifacts. The bundle includes viewport screenshots, focused page-surface screenshots, the Playwright HTML report, and a `benchmark-suite.json` attachment from the dedicated benchmark page.
|
|
282
|
+
|
|
283
|
+
If a locked-down local shell cannot launch a supported headless browser, the script will defer to CI unless you force a hard local failure with `QORE_BROWSER_SMOKE_REQUIRED=1`.
|
|
284
|
+
|
|
285
|
+
## Benchmark Methodology
|
|
286
|
+
|
|
287
|
+
Qore now includes a reproducible browser benchmark that compares two rendering paths against the same workload:
|
|
288
|
+
|
|
289
|
+
- `Qore stream = signal`: mount the transcript shell once and advance the same live text node as chunks arrive
|
|
290
|
+
- `Snapshot rerender baseline`: rebuild the transcript shell from a snapshot string on every chunk
|
|
291
|
+
|
|
292
|
+
Both paths use the same transcript history, the same chunk list, and the same final answer text. The benchmark reports first paint time, mutation records, node churn, and regenerated markup so the difference is visible instead of rhetorical.
|
|
293
|
+
|
|
294
|
+
## GitHub Packages
|
|
295
|
+
|
|
296
|
+
The repository includes GitHub Actions workflows for both release validation and GitHub Packages publishing.
|
|
297
|
+
|
|
298
|
+
- Release validation: `.github/workflows/release-check.yml`
|
|
299
|
+
- GitHub Packages publish: `.github/workflows/publish-github-packages.yml`
|
|
300
|
+
- Both workflows can be triggered manually from the Actions tab
|
|
301
|
+
- Publishing a GitHub Release triggers both the release check and the GitHub Packages publish flow
|
|
302
|
+
- The publish workflow validates the release tag, changelog, test suite, and tarball before it pushes the package
|
|
303
|
+
- The publish workflow uses the repository `GITHUB_TOKEN`, which GitHub documents as the recommended way to publish packages from the workflow repository
|
|
304
|
+
- GitHub Packages starts new packages as private by default, so you may want to switch the package visibility to public after the first publish
|
|
305
|
+
|
|
306
|
+
Because the package already includes the correct `repository` field in `package.json`, GitHub Packages can link the package back to `qorejs/qore` when the workflow publishes it.
|
|
307
|
+
|
|
308
|
+
## Project Hygiene
|
|
309
|
+
|
|
310
|
+
The repository also includes:
|
|
311
|
+
|
|
312
|
+
- `CONTRIBUTING.md` for contributor expectations and release flow
|
|
313
|
+
- `SECURITY.md` for responsible disclosure
|
|
314
|
+
- issue templates for bugs and feature requests
|
|
315
|
+
- a pull request template
|
|
316
|
+
- `.github/release.yml` to keep GitHub release notes structured
|
|
199
317
|
|
|
200
318
|
## Package Boundary
|
|
201
319
|
|
|
202
|
-
Qore
|
|
320
|
+
Qore does not ship a built-in catalog of buttons, dialogs, tabs, or other UI primitives.
|
|
203
321
|
|
|
204
|
-
|
|
322
|
+
The core package does only three things:
|
|
205
323
|
|
|
206
|
-
-
|
|
207
|
-
-
|
|
208
|
-
-
|
|
324
|
+
- Move streams into state
|
|
325
|
+
- Move state into the UI
|
|
326
|
+
- Keep the whole process finely reactive
|
|
209
327
|
|
|
210
|
-
|
|
328
|
+
Anything that does not serve `streaming response` belongs in an experimental layer or a separate package.
|
|
211
329
|
|
|
212
330
|
## Testing
|
|
213
331
|
|
|
@@ -215,14 +333,14 @@ Qore 核心包不内置 Button、Dialog、Tabs 这类 UI primitives。
|
|
|
215
333
|
npm test
|
|
216
334
|
```
|
|
217
335
|
|
|
218
|
-
|
|
336
|
+
The current test suite covers:
|
|
219
337
|
|
|
220
|
-
- signal
|
|
221
|
-
- stream = signal
|
|
222
|
-
- response
|
|
223
|
-
- OpenAI
|
|
338
|
+
- `signal`, `computed`, and `effect`
|
|
339
|
+
- The core `stream = signal` behavior
|
|
340
|
+
- `response` interoperability with async iterables
|
|
341
|
+
- OpenAI, Anthropic, and generic SSE adapters
|
|
224
342
|
|
|
225
343
|
## Roadmap
|
|
226
344
|
|
|
227
|
-
-
|
|
228
|
-
-
|
|
345
|
+
- Tighten the hydration model around server-streamed rendering
|
|
346
|
+
- Publish repeatable benchmarks that compare Qore with React and the Vercel AI SDK
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function toAsyncIterable<T>(source: T | Iterable<T> | AsyncIterable<T> | null | undefined): AsyncIterable<T>;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// Normalize values, iterables, and async iterables into one async iterable shape.
|
|
2
|
+
export function toAsyncIterable(source) {
|
|
3
|
+
if (source == null) {
|
|
4
|
+
return (async function* empty() { })();
|
|
5
|
+
}
|
|
6
|
+
if (typeof source[Symbol.asyncIterator] === 'function') {
|
|
7
|
+
return source;
|
|
8
|
+
}
|
|
9
|
+
if (typeof source[Symbol.iterator] === 'function') {
|
|
10
|
+
return (async function* fromIterable() {
|
|
11
|
+
yield* source;
|
|
12
|
+
})();
|
|
13
|
+
}
|
|
14
|
+
return (async function* fromValue() {
|
|
15
|
+
yield source;
|
|
16
|
+
})();
|
|
17
|
+
}
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
import { batch } from './signal.js';
|
|
2
|
+
import { toAsyncIterable } from './iterable.js';
|
|
3
|
+
import { createResponseState, isTerminalStatus } from './response-state.js';
|
|
4
|
+
import { normalizeError } from '../shared/utils.js';
|
|
5
|
+
function isResponseSourceFactory(source) {
|
|
6
|
+
const callableSource = source;
|
|
7
|
+
return typeof source === 'function'
|
|
8
|
+
&& typeof callableSource[Symbol.asyncIterator] !== 'function'
|
|
9
|
+
&& typeof callableSource.peek !== 'function';
|
|
10
|
+
}
|
|
11
|
+
// Build a response state machine that accumulates chunks into a reactive value.
|
|
12
|
+
export function createResponse(options) {
|
|
13
|
+
const { seed, reduce } = options;
|
|
14
|
+
const state = createResponseState(seed);
|
|
15
|
+
const { status, value, error, chunks, startedAt, finishedAt, pending, streaming, completed, failed, aborted, chunkCount } = state;
|
|
16
|
+
let activeController = null;
|
|
17
|
+
let runId = 0;
|
|
18
|
+
let api;
|
|
19
|
+
// Abort the active executor when a new run supersedes it.
|
|
20
|
+
function supersedeActiveRun(reason = 'Response superseded by a new run') {
|
|
21
|
+
if (!activeController) {
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
activeController.abort(reason);
|
|
25
|
+
activeController = null;
|
|
26
|
+
}
|
|
27
|
+
// Reset the response to its initial seed and clear all lifecycle markers.
|
|
28
|
+
function reset(nextSeed = seed) {
|
|
29
|
+
supersedeActiveRun('Response reset');
|
|
30
|
+
batch(() => {
|
|
31
|
+
status('idle');
|
|
32
|
+
value(nextSeed);
|
|
33
|
+
error(null);
|
|
34
|
+
chunks([]);
|
|
35
|
+
startedAt(null);
|
|
36
|
+
finishedAt(null);
|
|
37
|
+
});
|
|
38
|
+
return nextSeed;
|
|
39
|
+
}
|
|
40
|
+
// Push a chunk through the reducer and advance the response into streaming state.
|
|
41
|
+
function push(chunk) {
|
|
42
|
+
const currentStatus = status.peek();
|
|
43
|
+
if (isTerminalStatus(currentStatus)) {
|
|
44
|
+
return value.peek();
|
|
45
|
+
}
|
|
46
|
+
const index = chunks.peek().length;
|
|
47
|
+
const nextValue = reduce(value.peek(), chunk, index);
|
|
48
|
+
batch(() => {
|
|
49
|
+
if (status.peek() === 'idle' || status.peek() === 'pending') {
|
|
50
|
+
status('streaming');
|
|
51
|
+
}
|
|
52
|
+
chunks([...chunks.peek(), chunk]);
|
|
53
|
+
value(nextValue);
|
|
54
|
+
});
|
|
55
|
+
return nextValue;
|
|
56
|
+
}
|
|
57
|
+
// Mark the response as completed and freeze the current accumulated value.
|
|
58
|
+
function complete() {
|
|
59
|
+
const currentStatus = status.peek();
|
|
60
|
+
if (isTerminalStatus(currentStatus)) {
|
|
61
|
+
return value.peek();
|
|
62
|
+
}
|
|
63
|
+
activeController = null;
|
|
64
|
+
batch(() => {
|
|
65
|
+
status('completed');
|
|
66
|
+
finishedAt(Date.now());
|
|
67
|
+
});
|
|
68
|
+
return value.peek();
|
|
69
|
+
}
|
|
70
|
+
// Capture an error unless the response is already closed.
|
|
71
|
+
function fail(reason) {
|
|
72
|
+
const currentStatus = status.peek();
|
|
73
|
+
const normalizedError = normalizeError(reason);
|
|
74
|
+
if (isTerminalStatus(currentStatus)) {
|
|
75
|
+
return currentStatus === 'error'
|
|
76
|
+
? error.peek() ?? normalizedError
|
|
77
|
+
: value.peek();
|
|
78
|
+
}
|
|
79
|
+
activeController = null;
|
|
80
|
+
batch(() => {
|
|
81
|
+
status('error');
|
|
82
|
+
error(normalizedError);
|
|
83
|
+
finishedAt(Date.now());
|
|
84
|
+
});
|
|
85
|
+
return normalizedError;
|
|
86
|
+
}
|
|
87
|
+
// Abort an in-flight response while keeping the value accumulated so far.
|
|
88
|
+
function abort(reason = 'Response aborted') {
|
|
89
|
+
const currentStatus = status.peek();
|
|
90
|
+
if (currentStatus !== 'pending' && currentStatus !== 'streaming') {
|
|
91
|
+
return value.peek();
|
|
92
|
+
}
|
|
93
|
+
activeController?.abort(reason);
|
|
94
|
+
activeController = null;
|
|
95
|
+
batch(() => {
|
|
96
|
+
status('aborted');
|
|
97
|
+
finishedAt(Date.now());
|
|
98
|
+
});
|
|
99
|
+
return value.peek();
|
|
100
|
+
}
|
|
101
|
+
// Run an async executor and guard every lifecycle method to the active run only.
|
|
102
|
+
async function run(executor, options = {}) {
|
|
103
|
+
const { resetValue = true, nextSeed = seed } = options;
|
|
104
|
+
supersedeActiveRun();
|
|
105
|
+
runId += 1;
|
|
106
|
+
const currentRunId = runId;
|
|
107
|
+
const controller = new AbortController();
|
|
108
|
+
activeController = controller;
|
|
109
|
+
// A response may outlive older executors, so every write must prove it still owns the run.
|
|
110
|
+
const isCurrentRun = () => (currentRunId === runId
|
|
111
|
+
&& activeController === controller
|
|
112
|
+
&& !controller.signal.aborted);
|
|
113
|
+
const settleExitedRun = () => {
|
|
114
|
+
if (currentRunId !== runId) {
|
|
115
|
+
return value.peek();
|
|
116
|
+
}
|
|
117
|
+
if (status.peek() === 'error') {
|
|
118
|
+
throw error.peek() ?? new Error('Response failed');
|
|
119
|
+
}
|
|
120
|
+
return value.peek();
|
|
121
|
+
};
|
|
122
|
+
batch(() => {
|
|
123
|
+
if (resetValue) {
|
|
124
|
+
value(nextSeed);
|
|
125
|
+
chunks([]);
|
|
126
|
+
}
|
|
127
|
+
error(null);
|
|
128
|
+
status('pending');
|
|
129
|
+
startedAt(Date.now());
|
|
130
|
+
finishedAt(null);
|
|
131
|
+
});
|
|
132
|
+
// Expose guarded lifecycle helpers so stale executors cannot leak writes into the latest run.
|
|
133
|
+
const context = {
|
|
134
|
+
get signal() {
|
|
135
|
+
return controller.signal;
|
|
136
|
+
},
|
|
137
|
+
response: api,
|
|
138
|
+
push(chunk) {
|
|
139
|
+
if (!isCurrentRun()) {
|
|
140
|
+
return value.peek();
|
|
141
|
+
}
|
|
142
|
+
return push(chunk);
|
|
143
|
+
},
|
|
144
|
+
complete() {
|
|
145
|
+
if (!isCurrentRun()) {
|
|
146
|
+
return value.peek();
|
|
147
|
+
}
|
|
148
|
+
return complete();
|
|
149
|
+
},
|
|
150
|
+
fail(reason) {
|
|
151
|
+
if (!isCurrentRun()) {
|
|
152
|
+
return status.peek() === 'error'
|
|
153
|
+
? error.peek() ?? normalizeError(reason)
|
|
154
|
+
: value.peek();
|
|
155
|
+
}
|
|
156
|
+
return fail(reason);
|
|
157
|
+
},
|
|
158
|
+
abort(reason) {
|
|
159
|
+
if (!isCurrentRun()) {
|
|
160
|
+
return value.peek();
|
|
161
|
+
}
|
|
162
|
+
return abort(reason);
|
|
163
|
+
}
|
|
164
|
+
};
|
|
165
|
+
try {
|
|
166
|
+
await executor(context);
|
|
167
|
+
if (!isCurrentRun()) {
|
|
168
|
+
return settleExitedRun();
|
|
169
|
+
}
|
|
170
|
+
return complete();
|
|
171
|
+
}
|
|
172
|
+
catch (reason) {
|
|
173
|
+
if (!isCurrentRun()) {
|
|
174
|
+
return settleExitedRun();
|
|
175
|
+
}
|
|
176
|
+
throw fail(reason);
|
|
177
|
+
}
|
|
178
|
+
finally {
|
|
179
|
+
if (currentRunId === runId && activeController === controller) {
|
|
180
|
+
activeController = null;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
// Consume any async iterable-like source and route each chunk through the guarded writer.
|
|
185
|
+
async function consume(source, options = {}) {
|
|
186
|
+
return run(async ({ signal: abortSignal, push: write }) => {
|
|
187
|
+
const resolvedSource = isResponseSourceFactory(source)
|
|
188
|
+
? await source({ signal: abortSignal, response: api })
|
|
189
|
+
: await source;
|
|
190
|
+
for await (const chunk of toAsyncIterable(resolvedSource)) {
|
|
191
|
+
if (abortSignal.aborted) {
|
|
192
|
+
break;
|
|
193
|
+
}
|
|
194
|
+
write(chunk);
|
|
195
|
+
}
|
|
196
|
+
}, options);
|
|
197
|
+
}
|
|
198
|
+
// Return a plain snapshot suitable for inspection without exposing mutable internals.
|
|
199
|
+
function snapshot() {
|
|
200
|
+
return {
|
|
201
|
+
status: status.peek(),
|
|
202
|
+
value: value.peek(),
|
|
203
|
+
error: error.peek(),
|
|
204
|
+
chunks: [...chunks.peek()],
|
|
205
|
+
startedAt: startedAt.peek(),
|
|
206
|
+
finishedAt: finishedAt.peek(),
|
|
207
|
+
chunkCount: chunks.peek().length
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
api = {
|
|
211
|
+
status,
|
|
212
|
+
value,
|
|
213
|
+
error,
|
|
214
|
+
chunks,
|
|
215
|
+
startedAt,
|
|
216
|
+
finishedAt,
|
|
217
|
+
pending,
|
|
218
|
+
streaming,
|
|
219
|
+
completed,
|
|
220
|
+
failed,
|
|
221
|
+
aborted,
|
|
222
|
+
chunkCount,
|
|
223
|
+
reset,
|
|
224
|
+
push,
|
|
225
|
+
complete,
|
|
226
|
+
fail,
|
|
227
|
+
abort,
|
|
228
|
+
run,
|
|
229
|
+
consume,
|
|
230
|
+
snapshot
|
|
231
|
+
};
|
|
232
|
+
return api;
|
|
233
|
+
}
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
import type { ResponseReactiveState, ResponseStatus } from './response-types.js';
|
|
2
|
+
export declare function isTerminalStatus(currentStatus: ResponseStatus): boolean;
|
|
3
|
+
export declare function createResponseState<TChunk, TValue>(seed: TValue): ResponseReactiveState<TChunk, TValue>;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { computed, signal } from './signal.js';
|
|
2
|
+
// Treat these states as closed so late writes cannot mutate a finished response.
|
|
3
|
+
export function isTerminalStatus(currentStatus) {
|
|
4
|
+
return currentStatus === 'completed' || currentStatus === 'error' || currentStatus === 'aborted';
|
|
5
|
+
}
|
|
6
|
+
// Create the reactive state bundle that powers a response lifecycle.
|
|
7
|
+
export function createResponseState(seed) {
|
|
8
|
+
const status = signal('idle');
|
|
9
|
+
const value = signal(seed);
|
|
10
|
+
const error = signal(null);
|
|
11
|
+
const chunks = signal([]);
|
|
12
|
+
const startedAt = signal(null);
|
|
13
|
+
const finishedAt = signal(null);
|
|
14
|
+
const pending = computed(() => {
|
|
15
|
+
const currentStatus = status();
|
|
16
|
+
return currentStatus === 'pending' || currentStatus === 'streaming';
|
|
17
|
+
});
|
|
18
|
+
const streaming = computed(() => status() === 'streaming');
|
|
19
|
+
const completed = computed(() => status() === 'completed');
|
|
20
|
+
const failed = computed(() => status() === 'error');
|
|
21
|
+
const aborted = computed(() => status() === 'aborted');
|
|
22
|
+
const chunkCount = computed(() => chunks().length);
|
|
23
|
+
return {
|
|
24
|
+
status,
|
|
25
|
+
value,
|
|
26
|
+
error,
|
|
27
|
+
chunks,
|
|
28
|
+
startedAt,
|
|
29
|
+
finishedAt,
|
|
30
|
+
pending,
|
|
31
|
+
streaming,
|
|
32
|
+
completed,
|
|
33
|
+
failed,
|
|
34
|
+
aborted,
|
|
35
|
+
chunkCount
|
|
36
|
+
};
|
|
37
|
+
}
|