@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.
- package/README.md +294 -5
- package/dist/src/core/owner.d.ts +14 -0
- package/dist/src/core/owner.js +66 -0
- package/dist/src/core/response-runtime.js +5 -5
- package/dist/src/core/response-state.d.ts +4 -0
- package/dist/src/core/response-state.js +67 -2
- package/dist/src/core/signal-context.js +19 -9
- package/dist/src/core/signal-nodes.d.ts +5 -0
- package/dist/src/core/signal-nodes.js +51 -21
- package/dist/src/core/signal-types.d.ts +2 -0
- package/dist/src/core/signal.d.ts +1 -0
- package/dist/src/core/signal.js +2 -0
- package/dist/src/core/stream-types.d.ts +12 -0
- package/dist/src/core/stream.d.ts +1 -1
- package/dist/src/core/stream.js +144 -0
- package/dist/src/dom/app.js +2 -3
- package/dist/src/dom/dom.d.ts +1 -0
- package/dist/src/dom/dom.js +96 -9
- package/dist/src/dom/scope.d.ts +3 -1
- package/dist/src/dom/scope.js +10 -3
- package/dist/src/index.d.ts +10 -4
- package/dist/src/index.js +7 -2
- package/dist/src/providers/anthropic.js +6 -4
- package/dist/src/providers/deepseek.d.ts +2 -0
- package/dist/src/providers/deepseek.js +90 -0
- package/dist/src/providers/line-adapter.d.ts +2 -0
- package/dist/src/providers/line-adapter.js +83 -0
- package/dist/src/providers/line-parser.d.ts +5 -0
- package/dist/src/providers/line-parser.js +103 -0
- package/dist/src/providers/metadata.d.ts +8 -0
- package/dist/src/providers/metadata.js +193 -0
- package/dist/src/providers/ollama.d.ts +2 -0
- package/dist/src/providers/ollama.js +82 -0
- package/dist/src/providers/openai.js +6 -4
- package/dist/src/providers/openrouter.d.ts +2 -0
- package/dist/src/providers/openrouter.js +90 -0
- package/dist/src/providers/sse-adapter.js +129 -44
- package/dist/src/providers/sse-parser.d.ts +1 -1
- package/dist/src/providers/sse-parser.js +74 -38
- package/dist/src/providers/sse.d.ts +7 -1
- package/dist/src/providers/sse.js +6 -0
- package/dist/src/providers/types.d.ts +145 -0
- package/dist/src/server/sse-response.d.ts +16 -0
- package/dist/src/server/sse-response.js +102 -0
- package/dist/src/shared/utils.d.ts +1 -0
- package/dist/src/shared/utils.js +8 -1
- package/package.json +9 -2
package/README.md
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# Qore
|
|
2
2
|
|
|
3
3
|
[](https://www.npmjs.com/package/@qorejs/qore)
|
|
4
|
+
[](https://github.com/qorejs/qore/releases/latest)
|
|
4
5
|
[](https://github.com/qorejs/qore/actions/workflows/ci.yml)
|
|
5
6
|
[](#browser-regression)
|
|
6
7
|
[](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,
|
|
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,
|
|
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
|
|
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
|
|
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
|
-
|
|
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:
|
|
204
|
+
chunks: snapshotResponseChunks(chunks),
|
|
205
205
|
startedAt: startedAt.peek(),
|
|
206
206
|
finishedAt: finishedAt.peek(),
|
|
207
|
-
chunkCount: chunks
|
|
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 =
|
|
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()
|
|
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
|
-
|
|
35
|
-
|
|
35
|
+
pendingObservers.add(observer);
|
|
36
|
+
if (batchDepth > 0 || flushingObservers) {
|
|
36
37
|
return;
|
|
37
38
|
}
|
|
38
|
-
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
pendingObservers.
|
|
48
|
-
|
|
49
|
-
|
|
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
|
}
|