@qorejs/qore 0.7.3 → 1.0.0-rc.1
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 +275 -5
- package/dist/src/core/owner.d.ts +14 -0
- package/dist/src/core/owner.js +66 -0
- 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 +11 -2
package/README.md
CHANGED
|
@@ -19,6 +19,7 @@ Quick links:
|
|
|
19
19
|
- [npm package](https://www.npmjs.com/package/@qorejs/qore)
|
|
20
20
|
- [latest release](https://github.com/qorejs/qore/releases/latest)
|
|
21
21
|
- [GitHub Packages](https://github.com/qorejs/qore/packages)
|
|
22
|
+
- [migration notes](./MIGRATION.md)
|
|
22
23
|
- [landing page source](https://github.com/qorejs/qore/blob/main/index.html)
|
|
23
24
|
- [streaming demo source](https://github.com/qorejs/qore/blob/main/examples/streaming-response.html)
|
|
24
25
|
- [benchmark page](https://github.com/qorejs/qore/blob/main/examples/benchmark.html)
|
|
@@ -44,6 +45,15 @@ npm i @qorejs/qore
|
|
|
44
45
|
|
|
45
46
|
GitHub Packages installs require an authenticated session against `https://npm.pkg.github.com`.
|
|
46
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
|
+
|
|
47
57
|
- Package name: `@qorejs/qore`
|
|
48
58
|
- Module format: `ESM`
|
|
49
59
|
- Supported runtime: `Node >= 18`
|
|
@@ -53,6 +63,19 @@ GitHub Packages installs require an authenticated session against `https://npm.p
|
|
|
53
63
|
- npm: [npmjs.com/package/@qorejs/qore](https://www.npmjs.com/package/@qorejs/qore)
|
|
54
64
|
- GitHub Packages: [github.com/qorejs/qore/packages](https://github.com/qorejs/qore/packages)
|
|
55
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
|
+
|
|
56
79
|
## Core Idea
|
|
57
80
|
|
|
58
81
|
`stream` is how data flows.
|
|
@@ -137,6 +160,57 @@ const anthropic = createAnthropic({
|
|
|
137
160
|
const answer = stream(anthropic.chat('Why should stream be signal?'));
|
|
138
161
|
```
|
|
139
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
|
+
|
|
140
214
|
### `createSSEAdapter(options?)`
|
|
141
215
|
|
|
142
216
|
If your backend already streams `text/event-stream`, Qore can adopt it directly:
|
|
@@ -166,6 +240,63 @@ const answer = stream(provider.chat('hello'));
|
|
|
166
240
|
|
|
167
241
|
That makes `stream(provider.chat(...))` a general entry point instead of something tied to a single SDK.
|
|
168
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
|
+
|
|
169
300
|
## API Shape
|
|
170
301
|
|
|
171
302
|
### `stream(source, options?)`
|
|
@@ -189,6 +320,80 @@ const events = stream.list(eventSource);
|
|
|
189
320
|
const latest = stream.latest(modelEvents);
|
|
190
321
|
```
|
|
191
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
|
+
|
|
192
397
|
### Backpressure
|
|
193
398
|
|
|
194
399
|
```js
|
|
@@ -212,13 +417,24 @@ answer.buffered(); // how many chunks are queued right now
|
|
|
212
417
|
answer.dropped(); // how many chunks were dropped by the overflow policy
|
|
213
418
|
```
|
|
214
419
|
|
|
215
|
-
### `signal`, `computed`, `effect`
|
|
420
|
+
### `signal`, `computed`, `effect`, `createRoot`, `onCleanup`
|
|
216
421
|
|
|
217
422
|
```js
|
|
218
|
-
import { computed, signal, stream } from '@qorejs/qore';
|
|
423
|
+
import { computed, createRoot, effect, onCleanup, signal, stream } from '@qorejs/qore';
|
|
219
424
|
|
|
220
425
|
const answer = stream(openai.chat('hello'));
|
|
221
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();
|
|
222
438
|
```
|
|
223
439
|
|
|
224
440
|
### `response`
|
|
@@ -247,7 +463,7 @@ The repository includes a landing page and a focused streaming demo:
|
|
|
247
463
|
src/
|
|
248
464
|
core/ stream, signal, response, iterable
|
|
249
465
|
dom/ app mounting and DOM bindings
|
|
250
|
-
providers/ OpenAI, Anthropic,
|
|
466
|
+
providers/ OpenAI, Anthropic, OpenRouter, DeepSeek, Ollama, SSE, and line-stream adapters
|
|
251
467
|
shared/ runtime utilities
|
|
252
468
|
index.ts public entrypoint
|
|
253
469
|
|
|
@@ -269,6 +485,43 @@ python3 -m http.server 4173
|
|
|
269
485
|
|
|
270
486
|
Then open [http://127.0.0.1:4173/](http://127.0.0.1:4173/).
|
|
271
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
|
+
|
|
272
525
|
## Browser Regression
|
|
273
526
|
|
|
274
527
|
Install the browser binary once:
|
|
@@ -291,7 +544,7 @@ It validates:
|
|
|
291
544
|
|
|
292
545
|
The suite checks desktop and mobile layouts, watches for runtime console errors, exercises the primary interactions, and runs inside `release:check`.
|
|
293
546
|
|
|
294
|
-
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.
|
|
295
548
|
|
|
296
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`.
|
|
297
550
|
|
|
@@ -324,6 +577,23 @@ The repository includes GitHub Actions workflows for both release validation and
|
|
|
324
577
|
|
|
325
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.
|
|
326
579
|
|
|
580
|
+
## Release Checklist
|
|
581
|
+
|
|
582
|
+
The canonical release checklist lives in [`RELEASE.md`](./RELEASE.md). You can generate the current version's GitHub release body with `npm run release:notes`.
|
|
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
|
+
|
|
327
597
|
## Project Hygiene
|
|
328
598
|
|
|
329
599
|
The repository also includes:
|
|
@@ -357,7 +627,7 @@ The current test suite covers:
|
|
|
357
627
|
- `signal`, `computed`, and `effect`
|
|
358
628
|
- The core `stream = signal` behavior
|
|
359
629
|
- `response` interoperability with async iterables
|
|
360
|
-
- OpenAI, Anthropic, and generic
|
|
630
|
+
- OpenAI, Anthropic, OpenRouter, DeepSeek, Ollama, and generic streaming adapters
|
|
361
631
|
|
|
362
632
|
## Roadmap
|
|
363
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
|
+
}
|
|
@@ -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
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { Cleanup, EffectCallback, EffectOptions, ObserverDependency, ReactiveObserver, SignalListener, SubscribeOptions } from './signal-types.js';
|
|
2
2
|
export declare class SignalNode<T> implements ObserverDependency {
|
|
3
3
|
value: T;
|
|
4
|
+
level: number;
|
|
4
5
|
subscribers: Set<ReactiveObserver>;
|
|
5
6
|
listeners: Set<SignalListener<T>>;
|
|
6
7
|
constructor(initialValue: T);
|
|
@@ -13,6 +14,8 @@ export declare class SignalNode<T> implements ObserverDependency {
|
|
|
13
14
|
}
|
|
14
15
|
export declare class ComputedNode<T> implements ObserverDependency, ReactiveObserver {
|
|
15
16
|
getter: () => T;
|
|
17
|
+
private owner;
|
|
18
|
+
level: number;
|
|
16
19
|
subscribers: Set<ReactiveObserver>;
|
|
17
20
|
listeners: Set<SignalListener<T>>;
|
|
18
21
|
deps: Set<ObserverDependency>;
|
|
@@ -31,6 +34,8 @@ export declare class ComputedNode<T> implements ObserverDependency, ReactiveObse
|
|
|
31
34
|
export declare class EffectNode implements ReactiveObserver {
|
|
32
35
|
fn: EffectCallback;
|
|
33
36
|
scheduler: EffectOptions['scheduler'];
|
|
37
|
+
private owner;
|
|
38
|
+
level: number;
|
|
34
39
|
deps: Set<ObserverDependency>;
|
|
35
40
|
active: boolean;
|
|
36
41
|
scheduled: boolean;
|
|
@@ -1,8 +1,10 @@
|
|
|
1
|
-
import { cleanupObserver, getActiveObserver, removePendingObserver, scheduleObserver, withActiveObserver } from './signal-context.js';
|
|
1
|
+
import { batch, cleanupObserver, getActiveObserver, removePendingObserver, scheduleObserver, withActiveObserver } from './signal-context.js';
|
|
2
|
+
import { createOwnedScope, disposeOwner, onCleanup, resetOwner, withOwner } from './owner.js';
|
|
2
3
|
import { scheduleEffectRun } from './signal-scheduler.js';
|
|
3
4
|
// A mutable signal node stores a value and fan-outs updates to listeners and observers.
|
|
4
5
|
export class SignalNode {
|
|
5
6
|
value;
|
|
7
|
+
level = 0;
|
|
6
8
|
subscribers = new Set();
|
|
7
9
|
listeners = new Set();
|
|
8
10
|
constructor(initialValue) {
|
|
@@ -45,14 +47,18 @@ export class SignalNode {
|
|
|
45
47
|
for (const listener of Array.from(this.listeners)) {
|
|
46
48
|
listener(this.value);
|
|
47
49
|
}
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
50
|
+
batch(() => {
|
|
51
|
+
for (const observer of Array.from(this.subscribers)) {
|
|
52
|
+
scheduleObserver(observer);
|
|
53
|
+
}
|
|
54
|
+
});
|
|
51
55
|
}
|
|
52
56
|
}
|
|
53
57
|
// A computed node re-runs its getter whenever one of its dependencies changes.
|
|
54
58
|
export class ComputedNode {
|
|
55
59
|
getter;
|
|
60
|
+
owner = createOwnedScope();
|
|
61
|
+
level = 1;
|
|
56
62
|
subscribers = new Set();
|
|
57
63
|
listeners = new Set();
|
|
58
64
|
deps = new Set();
|
|
@@ -61,6 +67,7 @@ export class ComputedNode {
|
|
|
61
67
|
value;
|
|
62
68
|
constructor(getter) {
|
|
63
69
|
this.getter = getter;
|
|
70
|
+
onCleanup(() => this.stop());
|
|
64
71
|
this.recompute();
|
|
65
72
|
}
|
|
66
73
|
get() {
|
|
@@ -96,11 +103,16 @@ export class ComputedNode {
|
|
|
96
103
|
return;
|
|
97
104
|
}
|
|
98
105
|
const previousValue = this.value;
|
|
106
|
+
resetOwner(this.owner);
|
|
99
107
|
cleanupObserver(this);
|
|
100
|
-
withActiveObserver(this, () => {
|
|
108
|
+
withOwner(this.owner, () => withActiveObserver(this, () => {
|
|
101
109
|
const nextValue = this.getter();
|
|
102
110
|
const changed = !this.initialized || !Object.is(previousValue, nextValue);
|
|
111
|
+
const nextLevel = this.deps.size > 0
|
|
112
|
+
? Math.max(...Array.from(this.deps, (dependency) => dependency.level)) + 1
|
|
113
|
+
: 1;
|
|
103
114
|
this.value = nextValue;
|
|
115
|
+
this.level = nextLevel;
|
|
104
116
|
this.initialized = true;
|
|
105
117
|
if (!changed) {
|
|
106
118
|
return;
|
|
@@ -108,10 +120,12 @@ export class ComputedNode {
|
|
|
108
120
|
for (const listener of Array.from(this.listeners)) {
|
|
109
121
|
listener(this.value);
|
|
110
122
|
}
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
123
|
+
batch(() => {
|
|
124
|
+
for (const observer of Array.from(this.subscribers)) {
|
|
125
|
+
scheduleObserver(observer);
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
}));
|
|
115
129
|
}
|
|
116
130
|
stop() {
|
|
117
131
|
if (!this.active) {
|
|
@@ -120,6 +134,7 @@ export class ComputedNode {
|
|
|
120
134
|
this.active = false;
|
|
121
135
|
cleanupObserver(this);
|
|
122
136
|
removePendingObserver(this);
|
|
137
|
+
disposeOwner(this.owner);
|
|
123
138
|
this.subscribers.clear();
|
|
124
139
|
this.listeners.clear();
|
|
125
140
|
}
|
|
@@ -128,6 +143,8 @@ export class ComputedNode {
|
|
|
128
143
|
export class EffectNode {
|
|
129
144
|
fn;
|
|
130
145
|
scheduler;
|
|
146
|
+
owner = createOwnedScope();
|
|
147
|
+
level = 1;
|
|
131
148
|
deps = new Set();
|
|
132
149
|
active = true;
|
|
133
150
|
scheduled = false;
|
|
@@ -137,6 +154,7 @@ export class EffectNode {
|
|
|
137
154
|
constructor(fn, options = {}) {
|
|
138
155
|
this.fn = fn;
|
|
139
156
|
this.scheduler = options.scheduler ?? 'sync';
|
|
157
|
+
onCleanup(() => this.stop());
|
|
140
158
|
this.run();
|
|
141
159
|
}
|
|
142
160
|
schedule() {
|
|
@@ -166,18 +184,29 @@ export class EffectNode {
|
|
|
166
184
|
}
|
|
167
185
|
this.running = true;
|
|
168
186
|
this.needsRun = false;
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
187
|
+
const previousCleanup = this.cleanup;
|
|
188
|
+
this.cleanup = null;
|
|
189
|
+
try {
|
|
190
|
+
// Run the previous cleanup before dropping old dependencies so a thrown cleanup
|
|
191
|
+
// does not silently unsubscribe the effect from future source updates.
|
|
192
|
+
if (typeof previousCleanup === 'function') {
|
|
193
|
+
previousCleanup();
|
|
194
|
+
}
|
|
195
|
+
resetOwner(this.owner);
|
|
196
|
+
cleanupObserver(this);
|
|
197
|
+
withOwner(this.owner, () => withActiveObserver(this, () => {
|
|
198
|
+
const maybeCleanup = this.fn();
|
|
199
|
+
this.cleanup = typeof maybeCleanup === 'function' ? maybeCleanup : null;
|
|
200
|
+
}));
|
|
201
|
+
this.level = this.deps.size > 0
|
|
202
|
+
? Math.max(...Array.from(this.deps, (dependency) => dependency.level)) + 1
|
|
203
|
+
: 1;
|
|
204
|
+
}
|
|
205
|
+
finally {
|
|
206
|
+
this.running = false;
|
|
207
|
+
if (this.needsRun && this.active) {
|
|
208
|
+
this.schedule();
|
|
209
|
+
}
|
|
181
210
|
}
|
|
182
211
|
}
|
|
183
212
|
stop() {
|
|
@@ -189,6 +218,7 @@ export class EffectNode {
|
|
|
189
218
|
this.needsRun = false;
|
|
190
219
|
cleanupObserver(this);
|
|
191
220
|
removePendingObserver(this);
|
|
221
|
+
disposeOwner(this.owner);
|
|
192
222
|
if (typeof this.cleanup === 'function') {
|
|
193
223
|
this.cleanup();
|
|
194
224
|
this.cleanup = null;
|
|
@@ -9,11 +9,13 @@ export interface EffectOptions {
|
|
|
9
9
|
scheduler?: EffectScheduler;
|
|
10
10
|
}
|
|
11
11
|
export interface ObserverDependency {
|
|
12
|
+
level: number;
|
|
12
13
|
subscribers: Set<ReactiveObserver>;
|
|
13
14
|
}
|
|
14
15
|
export interface ReactiveObserver {
|
|
15
16
|
deps: Set<ObserverDependency>;
|
|
16
17
|
active: boolean;
|
|
18
|
+
level: number;
|
|
17
19
|
schedule(): void;
|
|
18
20
|
notify(): void;
|
|
19
21
|
}
|