@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.
Files changed (86) hide show
  1. package/README.md +170 -52
  2. package/dist/src/core/iterable.d.ts +1 -0
  3. package/dist/src/core/iterable.js +17 -0
  4. package/dist/src/core/response-runtime.d.ts +2 -0
  5. package/dist/src/core/response-runtime.js +233 -0
  6. package/dist/src/core/response-state.d.ts +3 -0
  7. package/dist/src/core/response-state.js +37 -0
  8. package/dist/src/core/response-types.d.ts +73 -0
  9. package/dist/src/core/response-types.js +1 -0
  10. package/dist/src/core/response.d.ts +4 -0
  11. package/dist/src/core/response.js +26 -0
  12. package/dist/src/core/signal-context.d.ts +10 -0
  13. package/dist/src/core/signal-context.js +69 -0
  14. package/dist/src/core/signal-nodes.d.ts +45 -0
  15. package/dist/src/core/signal-nodes.js +197 -0
  16. package/dist/src/core/signal-scheduler.d.ts +2 -0
  17. package/dist/src/core/signal-scheduler.js +19 -0
  18. package/dist/src/core/signal-types.d.ts +19 -0
  19. package/dist/src/core/signal-types.js +1 -0
  20. package/dist/src/core/signal.d.ts +19 -0
  21. package/dist/src/core/signal.js +41 -0
  22. package/dist/src/core/stream-backpressure.d.ts +3 -0
  23. package/dist/src/core/stream-backpressure.js +36 -0
  24. package/dist/src/core/stream-buffer.d.ts +17 -0
  25. package/dist/src/core/stream-buffer.js +162 -0
  26. package/dist/src/core/stream-iterator.d.ts +6 -0
  27. package/dist/src/core/stream-iterator.js +14 -0
  28. package/dist/src/core/stream-lifecycle.d.ts +10 -0
  29. package/dist/src/core/stream-lifecycle.js +28 -0
  30. package/dist/src/core/stream-queue.d.ts +17 -0
  31. package/dist/src/core/stream-queue.js +62 -0
  32. package/dist/src/core/stream-runtime.d.ts +2 -0
  33. package/dist/src/core/stream-runtime.js +127 -0
  34. package/dist/src/core/stream-source.d.ts +2 -0
  35. package/dist/src/core/stream-source.js +28 -0
  36. package/dist/src/core/stream-state.d.ts +4 -0
  37. package/dist/src/core/stream-state.js +20 -0
  38. package/dist/src/core/stream-types.d.ts +61 -0
  39. package/dist/src/core/stream-types.js +1 -0
  40. package/dist/src/core/stream.d.ts +11 -0
  41. package/dist/src/core/stream.js +90 -0
  42. package/dist/src/dom/app.d.ts +38 -0
  43. package/dist/src/dom/app.js +102 -0
  44. package/dist/src/dom/dom.d.ts +13 -0
  45. package/dist/src/dom/dom.js +197 -0
  46. package/dist/src/dom/properties.d.ts +3 -0
  47. package/dist/src/dom/properties.js +147 -0
  48. package/dist/src/dom/reactive.d.ts +6 -0
  49. package/dist/src/dom/reactive.js +14 -0
  50. package/dist/src/dom/response-view.d.ts +4 -0
  51. package/dist/src/dom/response-view.js +37 -0
  52. package/dist/src/dom/scope.d.ts +10 -0
  53. package/dist/src/dom/scope.js +49 -0
  54. package/dist/src/dom/types.d.ts +34 -0
  55. package/dist/src/dom/types.js +1 -0
  56. package/dist/src/index.d.ts +16 -0
  57. package/dist/src/index.js +10 -0
  58. package/dist/src/providers/anthropic.d.ts +2 -0
  59. package/dist/src/providers/anthropic.js +102 -0
  60. package/dist/src/providers/openai.d.ts +2 -0
  61. package/dist/src/providers/openai.js +96 -0
  62. package/dist/src/providers/sse-adapter.d.ts +2 -0
  63. package/dist/src/providers/sse-adapter.js +83 -0
  64. package/dist/src/providers/sse-env.d.ts +4 -0
  65. package/dist/src/providers/sse-env.js +33 -0
  66. package/dist/src/providers/sse-parser.d.ts +5 -0
  67. package/dist/src/providers/sse-parser.js +99 -0
  68. package/dist/src/providers/sse.d.ts +4 -0
  69. package/dist/src/providers/sse.js +3 -0
  70. package/dist/src/providers/types.d.ts +94 -0
  71. package/dist/src/providers/types.js +1 -0
  72. package/dist/src/shared/utils.d.ts +2 -0
  73. package/dist/src/shared/utils.js +32 -0
  74. package/package.json +25 -11
  75. package/src/anthropic.js +0 -122
  76. package/src/app.js +0 -123
  77. package/src/dom.js +0 -527
  78. package/src/index.d.ts +0 -405
  79. package/src/index.js +0 -10
  80. package/src/iterable.js +0 -20
  81. package/src/openai.js +0 -112
  82. package/src/response.js +0 -312
  83. package/src/signal.js +0 -328
  84. package/src/sse.js +0 -264
  85. package/src/stream.js +0 -582
  86. package/src/utils.js +0 -39
package/README.md CHANGED
@@ -1,24 +1,62 @@
1
1
  # Qore
2
2
 
3
- Qore 的灵魂只有四个字:`流式响应`。
4
-
5
- 它不是把数据当快照,而是把数据当河流。token 一段一段地到来,UI 就应该一段一段地响应,不需要手动拼字符串,不需要到处补 `loading`,也不需要把 partial render 当成特例处理。
3
+ [![npm version](https://img.shields.io/npm/v/%40qorejs%2Fqore?color=0f766e&label=npm)](https://www.npmjs.com/package/@qorejs/qore)
4
+ [![ci](https://github.com/qorejs/qore/actions/workflows/ci.yml/badge.svg)](https://github.com/qorejs/qore/actions/workflows/ci.yml)
5
+ [![browser smoke](https://img.shields.io/badge/browser-smoke-playwright-45ba63)](#browser-regression)
6
+ [![release checks](https://github.com/qorejs/qore/actions/workflows/release-check.yml/badge.svg)](https://github.com/qorejs/qore/actions/workflows/release-check.yml)
7
+ [![publish github packages](https://github.com/qorejs/qore/actions/workflows/publish-github-packages.yml/badge.svg)](https://github.com/qorejs/qore/actions/workflows/publish-github-packages.yml)
8
+ [![GitHub Packages](https://img.shields.io/badge/GitHub-Packages-181717?logo=github)](https://github.com/qorejs/qore/packages)
9
+ [![node >= 18](https://img.shields.io/badge/node-%3E%3D18-1f6feb)](https://nodejs.org/)
10
+ [![license: MIT](https://img.shields.io/badge/license-MIT-0f766e)](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` 是数据流动的方式,`signal` UI 响应变化的方式。
56
+ `stream` is how data flows.
57
+ `signal` is how the UI reacts.
20
58
 
21
- Qore 里,这两者是同一个 primitive 的两面:
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
- 这里的 `answer` 同时是:
70
+ Here, `answer` is all of the following at once:
33
71
 
34
- - 一个只读 `signal`,`answer()` 拿到当前累积值
35
- - 一个 `AsyncIterable`,可以继续 `for await...of`
36
- - 一个带生命周期的流状态,支持 `status()`、`streaming()`、`error()`、`chunks()`
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 stream 当成特殊情况,需要额外心智去补
41
- - SolidJS signal 很强,但没有原生 stream primitive
42
- - Vue ref 很顺手,但 stream 依旧是外置概念
43
- - Qore 直接把 `stream = signal` 做成核心 API
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
- 上面这个例子只会更新那一个 text node,不会 whole tree 重绘。
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
- 如果你的后端本来就已经在吐 SSE,Qore 也可以直接把它收编进同一个 story:
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
- 这让 `stream(provider.chat(...))` 不再绑定某一家 SDK,而是成为一个通用入口。
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
- 默认把 chunk 累积成文本 signal
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(); // 原始 chunk 列表
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
- backpressure 现在不只是“睡一下”:
189
+ Backpressure is not just a delay wrapper:
152
190
 
153
- - `interval`:chunk 进入 signal / UI 之间的最小间隔
154
- - `buffer`:在 UI 前面最多允许排队多少个 chunk
155
- - `overflow`:缓冲区满了以后怎么办,可选 `wait` / `drop-oldest` / `drop-newest` / `error`
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(); // 当前还有多少 chunk 在排队
161
- answer.dropped(); // overflow 策略被丢掉了多少 chunk
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` 仍然保留,但它更像底层状态机 escape hatch,适合复杂 reducer 或自定义聚合。
213
+ `response` still exists, but it is closer to a lower-level state machine escape hatch for custom reducers and aggregators.
176
214
 
177
- 如果你的目标是“把流直接接进 UI”,优先使用 `stream(...)`。
215
+ If your goal is to pipe a stream directly into the UI, prefer `stream(...)`.
178
216
 
179
217
  ## Demos
180
218
 
181
- 仓库里带了完整 landing page focused demo
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.js)
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.js)
188
- - [React Compare](https://github.com/qorejs/qore/blob/main/examples/react-chat.jsx)
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
- 然后打开 [http://127.0.0.1:4173/](http://127.0.0.1:4173/)
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 核心包不内置 Button、Dialog、Tabs 这类 UI primitives
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
- - 让状态进入 UI
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 / computed / effect
221
- - stream = signal 的核心行为
222
- - response async iterable 的兼容
223
- - OpenAI / Anthropic / generic SSE adapters
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
- - 围绕服务端流式渲染收敛 hydration 模型
228
- - 做公开 benchmark,把 Qore React/Vercel AI SDK 的差异变成可重复的数据
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,2 @@
1
+ import type { CreateResponseOptions, ResponseState } from './response-types.js';
2
+ export declare function createResponse<TChunk, TValue>(options: CreateResponseOptions<TChunk, TValue>): ResponseState<TChunk, TValue>;
@@ -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
+ }