@qorejs/qore 0.7.2 → 0.9.0

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 +294 -5
  2. package/dist/src/core/owner.d.ts +14 -0
  3. package/dist/src/core/owner.js +66 -0
  4. package/dist/src/core/response-runtime.js +5 -5
  5. package/dist/src/core/response-state.d.ts +4 -0
  6. package/dist/src/core/response-state.js +67 -2
  7. package/dist/src/core/signal-context.js +19 -9
  8. package/dist/src/core/signal-nodes.d.ts +5 -0
  9. package/dist/src/core/signal-nodes.js +51 -21
  10. package/dist/src/core/signal-types.d.ts +2 -0
  11. package/dist/src/core/signal.d.ts +1 -0
  12. package/dist/src/core/signal.js +2 -0
  13. package/dist/src/core/stream-types.d.ts +12 -0
  14. package/dist/src/core/stream.d.ts +1 -1
  15. package/dist/src/core/stream.js +144 -0
  16. package/dist/src/dom/app.js +2 -3
  17. package/dist/src/dom/dom.d.ts +1 -0
  18. package/dist/src/dom/dom.js +96 -9
  19. package/dist/src/dom/scope.d.ts +3 -1
  20. package/dist/src/dom/scope.js +10 -3
  21. package/dist/src/index.d.ts +10 -4
  22. package/dist/src/index.js +7 -2
  23. package/dist/src/providers/anthropic.js +6 -4
  24. package/dist/src/providers/deepseek.d.ts +2 -0
  25. package/dist/src/providers/deepseek.js +90 -0
  26. package/dist/src/providers/line-adapter.d.ts +2 -0
  27. package/dist/src/providers/line-adapter.js +83 -0
  28. package/dist/src/providers/line-parser.d.ts +5 -0
  29. package/dist/src/providers/line-parser.js +103 -0
  30. package/dist/src/providers/metadata.d.ts +8 -0
  31. package/dist/src/providers/metadata.js +193 -0
  32. package/dist/src/providers/ollama.d.ts +2 -0
  33. package/dist/src/providers/ollama.js +82 -0
  34. package/dist/src/providers/openai.js +6 -4
  35. package/dist/src/providers/openrouter.d.ts +2 -0
  36. package/dist/src/providers/openrouter.js +90 -0
  37. package/dist/src/providers/sse-adapter.js +129 -44
  38. package/dist/src/providers/sse-parser.d.ts +1 -1
  39. package/dist/src/providers/sse-parser.js +74 -38
  40. package/dist/src/providers/sse.d.ts +7 -1
  41. package/dist/src/providers/sse.js +6 -0
  42. package/dist/src/providers/types.d.ts +145 -0
  43. package/dist/src/server/sse-response.d.ts +16 -0
  44. package/dist/src/server/sse-response.js +102 -0
  45. package/dist/src/shared/utils.d.ts +1 -0
  46. package/dist/src/shared/utils.js +8 -1
  47. package/package.json +9 -2
package/README.md CHANGED
@@ -1,6 +1,7 @@
1
1
  # Qore
2
2
 
3
3
  [![npm version](https://img.shields.io/npm/v/%40qorejs%2Fqore?color=0f766e&label=npm)](https://www.npmjs.com/package/@qorejs/qore)
4
+ [![latest release](https://img.shields.io/github/v/release/qorejs/qore?color=0f766e&label=release)](https://github.com/qorejs/qore/releases/latest)
4
5
  [![ci](https://github.com/qorejs/qore/actions/workflows/ci.yml/badge.svg)](https://github.com/qorejs/qore/actions/workflows/ci.yml)
5
6
  [![browser smoke](https://img.shields.io/badge/browser-smoke-playwright-45ba63)](#browser-regression)
6
7
  [![release checks](https://github.com/qorejs/qore/actions/workflows/release-check.yml/badge.svg)](https://github.com/qorejs/qore/actions/workflows/release-check.yml)
@@ -16,7 +17,9 @@ Instead of treating data as a snapshot, Qore treats it like a river. Tokens arri
16
17
  Quick links:
17
18
 
18
19
  - [npm package](https://www.npmjs.com/package/@qorejs/qore)
20
+ - [latest release](https://github.com/qorejs/qore/releases/latest)
19
21
  - [GitHub Packages](https://github.com/qorejs/qore/packages)
22
+ - [migration notes](./MIGRATION.md)
20
23
  - [landing page source](https://github.com/qorejs/qore/blob/main/index.html)
21
24
  - [streaming demo source](https://github.com/qorejs/qore/blob/main/examples/streaming-response.html)
22
25
  - [benchmark page](https://github.com/qorejs/qore/blob/main/examples/benchmark.html)
@@ -42,6 +45,15 @@ npm i @qorejs/qore
42
45
 
43
46
  GitHub Packages installs require an authenticated session against `https://npm.pkg.github.com`.
44
47
 
48
+ For maintainers, the local npm release path now includes a fast preflight:
49
+
50
+ ```bash
51
+ npm run publish:preflight
52
+ npm run publish:npm
53
+ ```
54
+
55
+ The preflight checks npm auth, confirms the changelog matches `package.json`, and fails early if that exact version is already published.
56
+
45
57
  - Package name: `@qorejs/qore`
46
58
  - Module format: `ESM`
47
59
  - Supported runtime: `Node >= 18`
@@ -51,6 +63,19 @@ GitHub Packages installs require an authenticated session against `https://npm.p
51
63
  - npm: [npmjs.com/package/@qorejs/qore](https://www.npmjs.com/package/@qorejs/qore)
52
64
  - GitHub Packages: [github.com/qorejs/qore/packages](https://github.com/qorejs/qore/packages)
53
65
 
66
+ ## Compatibility Matrix
67
+
68
+ | Surface | Status | Notes |
69
+ | --- | --- | --- |
70
+ | Node runtime | Supported | `Node >= 18` |
71
+ | Browser runtime | Supported | browser DOM entrypoints require `document` |
72
+ | Reactive core | Supported | `signal`, `computed`, `effect`, `batch`, `createRoot`, `onCleanup` |
73
+ | Stream runtime | Supported | includes backpressure, retry, orchestration, async iteration, and abort |
74
+ | Provider adapters | Supported | OpenAI, Anthropic, OpenRouter, DeepSeek, Ollama, generic SSE, generic line-stream |
75
+ | Browser DOM layer | Supported | `h`, `text`, `dynamic`, `list`, `mount`, `createApp(...).mount(...)` |
76
+ | SSR | Not supported | explicit browser-only DOM boundary in `1.0.x` |
77
+ | Hydration | Not supported | deferred until a fully proven implementation exists |
78
+
54
79
  ## Core Idea
55
80
 
56
81
  `stream` is how data flows.
@@ -73,6 +98,17 @@ Here, `answer` is all of the following at once:
73
98
  - An `AsyncIterable`, so you can still use `for await...of`
74
99
  - A lifecycle-aware streaming state, with `status()`, `streaming()`, `error()`, and `chunks()`
75
100
 
101
+ ## Performance Model
102
+
103
+ Qore keeps the streaming hot path narrow:
104
+
105
+ - chunk commits append into an internal log instead of cloning the full history on every token
106
+ - public `chunks()` reads still return defensive copies, so consumers cannot corrupt runtime state
107
+ - `chunkCount()` tracks the internal log version directly, so status UIs can stay cheap during long generations
108
+ - DOM bindings update only the nodes that read the stream signal
109
+
110
+ That means a long AI answer can keep flowing through one signal and one text node without turning every token into a full transcript rewrite.
111
+
76
112
  ## Why Qore
77
113
 
78
114
  - React treats streaming as a special case that needs extra machinery
@@ -124,6 +160,57 @@ const anthropic = createAnthropic({
124
160
  const answer = stream(anthropic.chat('Why should stream be signal?'));
125
161
  ```
126
162
 
163
+ ### `createOpenRouter(options?)`
164
+
165
+ ```js
166
+ import { createOpenRouter, stream } from '@qorejs/qore';
167
+
168
+ const openrouter = createOpenRouter({
169
+ apiKey: process.env.OPENROUTER_API_KEY,
170
+ model: 'openai/gpt-4.1-mini'
171
+ });
172
+
173
+ const answer = stream(openrouter.chat('Why should stream be signal?'));
174
+ ```
175
+
176
+ ### `createDeepSeek(options?)`
177
+
178
+ ```js
179
+ import { createDeepSeek, stream } from '@qorejs/qore';
180
+
181
+ const deepseek = createDeepSeek({
182
+ apiKey: process.env.DEEPSEEK_API_KEY,
183
+ model: 'deepseek-chat'
184
+ });
185
+
186
+ const answer = stream(deepseek.chat('Why should stream be signal?'));
187
+ ```
188
+
189
+ Provider adapters also accept a request `signal` so you can cancel in-flight streams explicitly:
190
+
191
+ ```js
192
+ const controller = new AbortController();
193
+ const answer = stream(openrouter.chat('Keep streaming', {
194
+ signal: controller.signal
195
+ }));
196
+
197
+ controller.abort('user navigated away');
198
+ ```
199
+
200
+ ### `createOllama(options?)`
201
+
202
+ If you want a local-first provider path, Qore can stream directly from Ollama:
203
+
204
+ ```js
205
+ import { createOllama, stream } from '@qorejs/qore';
206
+
207
+ const ollama = createOllama({
208
+ model: 'llama3.2'
209
+ });
210
+
211
+ const answer = stream(ollama.chat('Why should stream be signal?'));
212
+ ```
213
+
127
214
  ### `createSSEAdapter(options?)`
128
215
 
129
216
  If your backend already streams `text/event-stream`, Qore can adopt it directly:
@@ -153,6 +240,63 @@ const answer = stream(provider.chat('hello'));
153
240
 
154
241
  That makes `stream(provider.chat(...))` a general entry point instead of something tied to a single SDK.
155
242
 
243
+ ### `createLineAdapter(options?)`
244
+
245
+ If your backend streams newline-delimited JSON instead of `text/event-stream`, Qore can adopt that too:
246
+
247
+ ```js
248
+ import { createLineAdapter, stream } from '@qorejs/qore';
249
+
250
+ const provider = createLineAdapter({
251
+ name: 'Local NDJSON Chat',
252
+ url: 'http://localhost:11434/api/chat',
253
+ buildRequest(request) {
254
+ return {
255
+ method: 'POST',
256
+ body: JSON.stringify(request)
257
+ };
258
+ },
259
+ buildChatRequest(input) {
260
+ return {
261
+ model: 'llama3.2',
262
+ messages: [{ role: 'user', content: input }]
263
+ };
264
+ },
265
+ lineToText(event) {
266
+ return typeof event.data?.message?.content === 'string'
267
+ ? event.data.message.content
268
+ : undefined;
269
+ }
270
+ });
271
+
272
+ const answer = stream(provider.chat('hello'));
273
+ ```
274
+
275
+ ## Provider Support Matrix
276
+
277
+ | Adapter | Transport | Status | Notes |
278
+ | --- | --- | --- | --- |
279
+ | `createOpenAI(...)` | SSE | Supported | OpenAI Responses streaming |
280
+ | `createAnthropic(...)` | SSE | Supported | Anthropic Messages streaming |
281
+ | `createOpenRouter(...)` | SSE | Supported | chat-completions style SSE |
282
+ | `createDeepSeek(...)` | SSE | Supported | chat-completions style SSE |
283
+ | `createOllama(...)` | line-stream / NDJSON | Supported | local-first model path |
284
+ | `createSSEAdapter(...)` | SSE | Supported | generic hosted or self-managed SSE |
285
+ | `createLineAdapter(...)` | line-stream / NDJSON | Supported | generic line-delimited transport |
286
+
287
+ Common guarantees across supported adapters:
288
+
289
+ - async iterable text streaming
290
+ - request `signal` support for cancellation
291
+ - typed event streaming on provider-specific surfaces
292
+ - package smoke coverage
293
+
294
+ Additional hosted SSE guarantees:
295
+
296
+ - retry contract support
297
+ - `Last-Event-ID` resume support
298
+ - normalized metadata helpers for usage, finish reason, response id, and model identity
299
+
156
300
  ## API Shape
157
301
 
158
302
  ### `stream(source, options?)`
@@ -176,6 +320,80 @@ const events = stream.list(eventSource);
176
320
  const latest = stream.latest(modelEvents);
177
321
  ```
178
322
 
323
+ For append-heavy DOM lists such as chat transcripts, pass a stable key:
324
+
325
+ ```js
326
+ list(messages, (message) => h('article', {}, message.body), {
327
+ key: (message) => message.id
328
+ });
329
+ ```
330
+
331
+ ## Server-Side SSE
332
+
333
+ If you want Qore to produce the server stream as well:
334
+
335
+ ```js
336
+ import { createSSEResponse } from '@qorejs/qore';
337
+
338
+ export function handler() {
339
+ return createSSEResponse(['hello', ' world']);
340
+ }
341
+ ```
342
+
343
+ If you need orchestration:
344
+
345
+ ```js
346
+ const merged = stream.merge([openai.chat('a'), anthropic.chat('b')]);
347
+ const scripted = stream.concat([retrieve.chat('a'), summarize.chat('a')]);
348
+ const pipeline = stream.pipe(openai.chat('hello'), [
349
+ (draft) => review.chat(draft),
350
+ (reviewed) => format.chat(reviewed)
351
+ ]);
352
+ const fastest = stream.race([openai.chat('hello'), openrouter.chat('hello')]);
353
+ const resilient = stream.retryable(() => openai.chat('retry me'), {
354
+ maxRetries: 2,
355
+ backoff: 'exponential'
356
+ });
357
+ const liveAnswer = stream.switchMap(promptChanges, (prompt) => openai.chat(prompt));
358
+ ```
359
+
360
+ Provider adapters can also retry dropped SSE connections and resume from the last event id:
361
+
362
+ ```js
363
+ const openai = createOpenAI({
364
+ apiKey: process.env.OPENAI_API_KEY,
365
+ retry: {
366
+ maxAttempts: 3,
367
+ backoff: 'exponential'
368
+ }
369
+ });
370
+ ```
371
+
372
+ Provider metadata can be normalized into one shared shape:
373
+
374
+ ```js
375
+ import {
376
+ collectProviderMetadata,
377
+ extractAnthropicMetadata,
378
+ extractOpenAIMetadata
379
+ } from '@qorejs/qore';
380
+
381
+ const openaiMetadata = await collectProviderMetadata(
382
+ 'OpenAI',
383
+ openai.responses.stream({ input: 'hello' }),
384
+ extractOpenAIMetadata
385
+ );
386
+
387
+ const anthropicMetadata = await collectProviderMetadata(
388
+ 'Anthropic',
389
+ anthropic.messages.stream({ messages: [{ role: 'user', content: 'hello' }] }),
390
+ extractAnthropicMetadata
391
+ );
392
+
393
+ openaiMetadata.usage?.totalTokens;
394
+ anthropicMetadata.finishReason;
395
+ ```
396
+
179
397
  ### Backpressure
180
398
 
181
399
  ```js
@@ -199,13 +417,24 @@ answer.buffered(); // how many chunks are queued right now
199
417
  answer.dropped(); // how many chunks were dropped by the overflow policy
200
418
  ```
201
419
 
202
- ### `signal`, `computed`, `effect`
420
+ ### `signal`, `computed`, `effect`, `createRoot`, `onCleanup`
203
421
 
204
422
  ```js
205
- import { computed, signal, stream } from '@qorejs/qore';
423
+ import { computed, createRoot, effect, onCleanup, signal, stream } from '@qorejs/qore';
206
424
 
207
425
  const answer = stream(openai.chat('hello'));
208
426
  const length = computed(() => answer().length);
427
+
428
+ const dispose = createRoot((dispose) => {
429
+ effect(() => {
430
+ console.log(length());
431
+ onCleanup(() => console.log('effect disposed'));
432
+ });
433
+
434
+ return dispose;
435
+ });
436
+
437
+ dispose();
209
438
  ```
210
439
 
211
440
  ### `response`
@@ -234,7 +463,7 @@ The repository includes a landing page and a focused streaming demo:
234
463
  src/
235
464
  core/ stream, signal, response, iterable
236
465
  dom/ app mounting and DOM bindings
237
- providers/ OpenAI, Anthropic, generic SSE adapters
466
+ providers/ OpenAI, Anthropic, OpenRouter, DeepSeek, Ollama, SSE, and line-stream adapters
238
467
  shared/ runtime utilities
239
468
  index.ts public entrypoint
240
469
 
@@ -256,6 +485,43 @@ python3 -m http.server 4173
256
485
 
257
486
  Then open [http://127.0.0.1:4173/](http://127.0.0.1:4173/).
258
487
 
488
+ ## Server And SSR
489
+
490
+ Qore's reactive core and stream runtime work in Node and browser environments today.
491
+
492
+ The DOM layer is intentionally browser-only right now:
493
+
494
+ - `signal`, `computed`, `effect`, `stream`, and provider adapters work in Node and the browser
495
+ - `h`, `text`, `mount`, and `createApp(...).mount(...)` require a browser-like `document`
496
+ - `canUseDOM()` is exported so integrations can branch cleanly before touching DOM APIs
497
+ - `assertCanUseDOM(name?)` is exported if you want to fail fast with the same browser-boundary error shape Qore uses internally
498
+ - the published entrypoint is checked against a frozen public API snapshot before release so accidental export drift fails CI early
499
+
500
+ If you call DOM helpers without a browser-like runtime, Qore throws an entrypoint-specific error instead of failing later with a generic reference error. For example:
501
+
502
+ - `h() requires a browser-like environment`
503
+ - `mount() requires a browser-like environment`
504
+ - `createApp(...).mount(...) requires a browser-like environment`
505
+
506
+ Example:
507
+
508
+ ```js
509
+ import { assertCanUseDOM, canUseDOM } from '@qorejs/qore';
510
+
511
+ if (canUseDOM()) {
512
+ // Safe to call mount(), h(), text(), and other DOM entrypoints.
513
+ }
514
+
515
+ assertCanUseDOM('chat shell hydration');
516
+ ```
517
+
518
+ That means the current `1.0.0` path is:
519
+
520
+ - stable reactive runtime
521
+ - stable streaming runtime
522
+ - explicit browser DOM boundary
523
+ - streaming SSR and hydration as a post-`1.0.0` expansion area unless the implementation is fully proven first
524
+
259
525
  ## Browser Regression
260
526
 
261
527
  Install the browser binary once:
@@ -278,10 +544,16 @@ It validates:
278
544
 
279
545
  The suite checks desktop and mobile layouts, watches for runtime console errors, exercises the primary interactions, and runs inside `release:check`.
280
546
 
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.
547
+ CI also uploads the browser regression evidence as workflow artifacts. The bundle includes viewport screenshots, focused page-surface screenshots, the Playwright HTML report, a `benchmark-suite.json` attachment from the dedicated benchmark page, and a human-readable benchmark summary markdown file from the benchmark gate.
282
548
 
283
549
  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
550
 
551
+ Local preview ports can also be pinned when another process is already using the default range:
552
+
553
+ ```bash
554
+ QORE_STATIC_PORT=4300 QORE_STATIC_PORT_END=4400 npm run test:browser
555
+ ```
556
+
285
557
  ## Benchmark Methodology
286
558
 
287
559
  Qore now includes a reproducible browser benchmark that compares two rendering paths against the same workload:
@@ -305,6 +577,23 @@ The repository includes GitHub Actions workflows for both release validation and
305
577
 
306
578
  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
579
 
580
+ ## Release Checklist
581
+
582
+ The canonical release checklist lives in [`RELEASE.md`](./RELEASE.md).
583
+
584
+ For every release candidate or stable release, the minimum local gate is:
585
+
586
+ ```bash
587
+ npm ci
588
+ npm run release:check
589
+ ```
590
+
591
+ For npm publishing from a local shell, run:
592
+
593
+ ```bash
594
+ npm run publish:preflight
595
+ ```
596
+
308
597
  ## Project Hygiene
309
598
 
310
599
  The repository also includes:
@@ -338,7 +627,7 @@ The current test suite covers:
338
627
  - `signal`, `computed`, and `effect`
339
628
  - The core `stream = signal` behavior
340
629
  - `response` interoperability with async iterables
341
- - OpenAI, Anthropic, and generic SSE adapters
630
+ - OpenAI, Anthropic, OpenRouter, DeepSeek, Ollama, and generic streaming adapters
342
631
 
343
632
  ## Roadmap
344
633
 
@@ -0,0 +1,14 @@
1
+ import type { Cleanup } from './signal-types.js';
2
+ interface OwnerNode {
3
+ parent: OwnerNode | null;
4
+ children: Set<OwnerNode>;
5
+ cleanups: Cleanup[];
6
+ active: boolean;
7
+ }
8
+ export declare function withOwner<T>(owner: OwnerNode | null, fn: () => T): T;
9
+ export declare function createOwnedScope(): OwnerNode;
10
+ export declare function resetOwner(owner: OwnerNode): void;
11
+ export declare function disposeOwner(owner: OwnerNode | null | undefined): void;
12
+ export declare function createRoot<T>(fn: (dispose: Cleanup) => T): T;
13
+ export declare function onCleanup(cleanup: Cleanup): void;
14
+ export {};
@@ -0,0 +1,66 @@
1
+ let activeOwner = null;
2
+ function createOwner(parent) {
3
+ const owner = {
4
+ parent,
5
+ children: new Set(),
6
+ cleanups: [],
7
+ active: true
8
+ };
9
+ parent?.children.add(owner);
10
+ return owner;
11
+ }
12
+ function disposeOwnerContents(owner) {
13
+ const children = Array.from(owner.children);
14
+ owner.children.clear();
15
+ for (let index = children.length - 1; index >= 0; index -= 1) {
16
+ disposeOwner(children[index]);
17
+ }
18
+ for (let index = owner.cleanups.length - 1; index >= 0; index -= 1) {
19
+ try {
20
+ const cleanup = owner.cleanups[index];
21
+ if (cleanup) {
22
+ cleanup();
23
+ }
24
+ }
25
+ catch {
26
+ // Keep unwinding the owner tree even if one cleanup throws.
27
+ }
28
+ }
29
+ owner.cleanups.length = 0;
30
+ }
31
+ export function withOwner(owner, fn) {
32
+ const previousOwner = activeOwner;
33
+ activeOwner = owner;
34
+ try {
35
+ return fn();
36
+ }
37
+ finally {
38
+ activeOwner = previousOwner;
39
+ }
40
+ }
41
+ export function createOwnedScope() {
42
+ return createOwner(activeOwner);
43
+ }
44
+ export function resetOwner(owner) {
45
+ if (!owner.active) {
46
+ return;
47
+ }
48
+ disposeOwnerContents(owner);
49
+ }
50
+ export function disposeOwner(owner) {
51
+ if (!owner || !owner.active) {
52
+ return;
53
+ }
54
+ owner.active = false;
55
+ disposeOwnerContents(owner);
56
+ owner.parent?.children.delete(owner);
57
+ owner.parent = null;
58
+ }
59
+ export function createRoot(fn) {
60
+ const owner = createOwner(activeOwner);
61
+ const dispose = () => disposeOwner(owner);
62
+ return withOwner(owner, () => fn(dispose));
63
+ }
64
+ export function onCleanup(cleanup) {
65
+ activeOwner?.cleanups.push(cleanup);
66
+ }
@@ -1,6 +1,6 @@
1
1
  import { batch } from './signal.js';
2
2
  import { toAsyncIterable } from './iterable.js';
3
- import { createResponseState, isTerminalStatus } from './response-state.js';
3
+ import { appendResponseChunk, createResponseState, getResponseChunkCount, isTerminalStatus, snapshotResponseChunks } from './response-state.js';
4
4
  import { normalizeError } from '../shared/utils.js';
5
5
  function isResponseSourceFactory(source) {
6
6
  const callableSource = source;
@@ -43,13 +43,13 @@ export function createResponse(options) {
43
43
  if (isTerminalStatus(currentStatus)) {
44
44
  return value.peek();
45
45
  }
46
- const index = chunks.peek().length;
46
+ const index = getResponseChunkCount(chunks);
47
47
  const nextValue = reduce(value.peek(), chunk, index);
48
48
  batch(() => {
49
49
  if (status.peek() === 'idle' || status.peek() === 'pending') {
50
50
  status('streaming');
51
51
  }
52
- chunks([...chunks.peek(), chunk]);
52
+ appendResponseChunk(chunks, chunk);
53
53
  value(nextValue);
54
54
  });
55
55
  return nextValue;
@@ -201,10 +201,10 @@ export function createResponse(options) {
201
201
  status: status.peek(),
202
202
  value: value.peek(),
203
203
  error: error.peek(),
204
- chunks: [...chunks.peek()],
204
+ chunks: snapshotResponseChunks(chunks),
205
205
  startedAt: startedAt.peek(),
206
206
  finishedAt: finishedAt.peek(),
207
- chunkCount: chunks.peek().length
207
+ chunkCount: getResponseChunkCount(chunks)
208
208
  };
209
209
  }
210
210
  api = {
@@ -1,3 +1,7 @@
1
+ import { type Signal } from './signal.js';
1
2
  import type { ResponseReactiveState, ResponseStatus } from './response-types.js';
2
3
  export declare function isTerminalStatus(currentStatus: ResponseStatus): boolean;
4
+ export declare function appendResponseChunk<T>(chunks: Signal<T[]>, chunk: T): number;
5
+ export declare function getResponseChunkCount<T>(chunks: Signal<T[]>): number;
6
+ export declare function snapshotResponseChunks<T>(chunks: Signal<T[]>): T[];
3
7
  export declare function createResponseState<TChunk, TValue>(seed: TValue): ResponseReactiveState<TChunk, TValue>;
@@ -1,14 +1,79 @@
1
+ import { READ } from './signal-context.js';
1
2
  import { computed, signal } from './signal.js';
2
3
  // Treat these states as closed so late writes cannot mutate a finished response.
3
4
  export function isTerminalStatus(currentStatus) {
4
5
  return currentStatus === 'completed' || currentStatus === 'error' || currentStatus === 'aborted';
5
6
  }
7
+ function isChunkSignal(value) {
8
+ return typeof value.append === 'function'
9
+ && typeof value.count === 'function'
10
+ && typeof value.peekCount === 'function'
11
+ && typeof value.snapshot === 'function';
12
+ }
13
+ // Store the live chunk log behind a version signal so token commits do not copy
14
+ // the full history on every push. Public reads still receive defensive copies.
15
+ function createChunkSignal() {
16
+ let items = [];
17
+ const version = signal(0);
18
+ const notify = () => version(version.peek() + 1);
19
+ const chunks = ((nextValue = READ) => {
20
+ if (nextValue === READ) {
21
+ version();
22
+ return [...items];
23
+ }
24
+ items = [...nextValue];
25
+ notify();
26
+ return [...items];
27
+ });
28
+ chunks.set = (nextValue) => {
29
+ items = [...nextValue];
30
+ notify();
31
+ return [...items];
32
+ };
33
+ chunks.update = (updater) => chunks.set(updater([...items]));
34
+ chunks.peek = () => [...items];
35
+ chunks.subscribe = (listener, options = {}) => {
36
+ const { immediate = true } = options;
37
+ if (immediate) {
38
+ listener([...items]);
39
+ }
40
+ return version.subscribe(() => listener([...items]), { immediate: false });
41
+ };
42
+ chunks.append = (chunk) => {
43
+ const index = items.length;
44
+ items.push(chunk);
45
+ notify();
46
+ return index;
47
+ };
48
+ chunks.count = () => {
49
+ version();
50
+ return items.length;
51
+ };
52
+ chunks.peekCount = () => items.length;
53
+ chunks.snapshot = () => [...items];
54
+ return chunks;
55
+ }
56
+ export function appendResponseChunk(chunks, chunk) {
57
+ if (isChunkSignal(chunks)) {
58
+ return chunks.append(chunk);
59
+ }
60
+ const currentChunks = chunks.peek();
61
+ const index = currentChunks.length;
62
+ chunks([...currentChunks, chunk]);
63
+ return index;
64
+ }
65
+ export function getResponseChunkCount(chunks) {
66
+ return isChunkSignal(chunks) ? chunks.peekCount() : chunks.peek().length;
67
+ }
68
+ export function snapshotResponseChunks(chunks) {
69
+ return isChunkSignal(chunks) ? chunks.snapshot() : [...chunks.peek()];
70
+ }
6
71
  // Create the reactive state bundle that powers a response lifecycle.
7
72
  export function createResponseState(seed) {
8
73
  const status = signal('idle');
9
74
  const value = signal(seed);
10
75
  const error = signal(null);
11
- const chunks = signal([]);
76
+ const chunks = createChunkSignal();
12
77
  const startedAt = signal(null);
13
78
  const finishedAt = signal(null);
14
79
  const pending = computed(() => {
@@ -19,7 +84,7 @@ export function createResponseState(seed) {
19
84
  const completed = computed(() => status() === 'completed');
20
85
  const failed = computed(() => status() === 'error');
21
86
  const aborted = computed(() => status() === 'aborted');
22
- const chunkCount = computed(() => chunks().length);
87
+ const chunkCount = computed(() => chunks.count());
23
88
  return {
24
89
  status,
25
90
  value,
@@ -3,6 +3,7 @@ export const READ = Symbol('qore.signal.read');
3
3
  let activeObserver = null;
4
4
  let batchDepth = 0;
5
5
  const pendingObservers = new Set();
6
+ let flushingObservers = false;
6
7
  export function getActiveObserver() {
7
8
  return activeObserver;
8
9
  }
@@ -31,24 +32,33 @@ export function scheduleObserver(observer) {
31
32
  if (!observer.active) {
32
33
  return;
33
34
  }
34
- if (batchDepth > 0) {
35
- pendingObservers.add(observer);
35
+ pendingObservers.add(observer);
36
+ if (batchDepth > 0 || flushingObservers) {
36
37
  return;
37
38
  }
38
- observer.schedule();
39
+ flushObservers();
39
40
  }
40
41
  export function removePendingObserver(observer) {
41
42
  pendingObservers.delete(observer);
42
43
  }
43
44
  // Flush batched observer work in FIFO-like waves until the queue is empty.
44
45
  function flushObservers() {
45
- while (pendingObservers.size > 0) {
46
- const queue = Array.from(pendingObservers);
47
- pendingObservers.clear();
48
- for (const observer of queue) {
49
- observer.schedule();
46
+ flushingObservers = true;
47
+ try {
48
+ while (pendingObservers.size > 0) {
49
+ const queue = Array.from(pendingObservers);
50
+ pendingObservers.clear();
51
+ queue.sort((left, right) => left.level - right.level);
52
+ for (const observer of queue) {
53
+ if (observer.active) {
54
+ observer.schedule();
55
+ }
56
+ }
50
57
  }
51
58
  }
59
+ finally {
60
+ flushingObservers = false;
61
+ }
52
62
  }
53
63
  // Batch synchronous updates so dependent observers only re-run once afterward.
54
64
  export function batch(fn) {
@@ -58,7 +68,7 @@ export function batch(fn) {
58
68
  }
59
69
  finally {
60
70
  batchDepth -= 1;
61
- if (batchDepth === 0) {
71
+ if (batchDepth === 0 && !flushingObservers) {
62
72
  flushObservers();
63
73
  }
64
74
  }