@adia-ai/a2ui 0.8.37

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 (53) hide show
  1. package/CHANGELOG.md +1073 -0
  2. package/README.md +99 -0
  3. package/a2ui.schema.d.ts +192 -0
  4. package/controllers/accordion.js +73 -0
  5. package/controllers/base.js +68 -0
  6. package/controllers/data-stream.js +281 -0
  7. package/controllers/form.js +81 -0
  8. package/controllers/index.js +6 -0
  9. package/controllers/selection.js +82 -0
  10. package/controllers/state-machine.js +135 -0
  11. package/controllers/toggle.js +40 -0
  12. package/dockables/action.d.ts +55 -0
  13. package/dockables/action.js +152 -0
  14. package/dockables/base.d.ts +26 -0
  15. package/dockables/base.js +30 -0
  16. package/dockables/controller.d.ts +35 -0
  17. package/dockables/controller.js +97 -0
  18. package/dockables/data-source.d.ts +35 -0
  19. package/dockables/data-source.js +103 -0
  20. package/dockables/index.d.ts +21 -0
  21. package/dockables/index.js +6 -0
  22. package/dockables/lifecycle.d.ts +38 -0
  23. package/dockables/lifecycle.js +84 -0
  24. package/dockables/provider.d.ts +28 -0
  25. package/dockables/provider.js +59 -0
  26. package/index.d.ts +64 -0
  27. package/index.js +54 -0
  28. package/package.json +89 -0
  29. package/prop-apply.d.ts +13 -0
  30. package/prop-apply.js +113 -0
  31. package/registry.d.ts +17 -0
  32. package/registry.js +418 -0
  33. package/renderer.d.ts +67 -0
  34. package/renderer.js +715 -0
  35. package/stream.d.ts +62 -0
  36. package/stream.js +521 -0
  37. package/surface-manifest.d.ts +73 -0
  38. package/surface-manifest.js +294 -0
  39. package/surface.d.ts +72 -0
  40. package/surface.js +222 -0
  41. package/types.d.ts +26 -0
  42. package/validate/CHANGELOG.md +1005 -0
  43. package/validate/README.md +146 -0
  44. package/validate/index.d.ts +4 -0
  45. package/validate/index.js +12 -0
  46. package/validate/validator.d.ts +4 -0
  47. package/validate/validator.js +1232 -0
  48. package/wire-factory.d.ts +15 -0
  49. package/wire-factory.js +134 -0
  50. package/wiring-engine.d.ts +61 -0
  51. package/wiring-engine.js +209 -0
  52. package/wiring-registry.d.ts +80 -0
  53. package/wiring-registry.js +342 -0
package/README.md ADDED
@@ -0,0 +1,99 @@
1
+ # @adia-ai/a2ui
2
+
3
+ > Formerly `@adia-ai/a2ui-runtime`; renamed at ADR-0048 P3, which folded the
4
+ > runtime into this package root and added protocol-side validation at
5
+ > [`./validate`](./validate/). Runtime subpath exports are unchanged.
6
+
7
+ The A2UI protocol — renderer, registry, streams, surface manifest, and wiring
8
+ primitives for A2UI (Agent-to-UI), plus protocol-side validation.
9
+ Framework-agnostic and **dependency-free**; pairs with any A2UI-conformant
10
+ component set.
11
+
12
+ ## Install
13
+
14
+ ```bash
15
+ npm install @adia-ai/a2ui
16
+ ```
17
+
18
+ Typically paired with `@adia-ai/web-components` (which provides the
19
+ custom-element catalog the renderer resolves against):
20
+
21
+ ```bash
22
+ npm install @adia-ai/a2ui @adia-ai/web-components
23
+ ```
24
+
25
+ ## What's in the box
26
+
27
+ - **`A2UIRenderer`** — processes A2UI protocol messages and writes
28
+ custom elements to a container.
29
+ - **`registry`** / **`resolveTag`** / **`registerType`** — A2UI protocol
30
+ component name → custom-element tag map. Extensible via
31
+ `registerType`.
32
+ - **Transports** — `sseStream`, `wsStream`, `mockStream`, `mcpStream`,
33
+ `jsonlStream`. Normalize inbound A2UI messages from SSE, WebSocket,
34
+ mock fixtures, MCP tool calls, or JSONL logs.
35
+ - **`SurfaceManifest`** / **`Surface`** — design-time surface shape +
36
+ runtime lifecycle for cross-surface data flow.
37
+ - **Wiring primitives** — `WiringEngine`, `wiringRegistry`,
38
+ `createDockables`, plus the dockable base classes
39
+ (`ControllerDock`, `DataSourceDock`, `ActionDock`, `ProviderDock`,
40
+ `LifecycleDock`).
41
+ - **Controllers** — lazy-loaded runtime state managers at
42
+ `controllers/`: `FormController`, `DataStreamController`,
43
+ `SelectionController`, `ToggleController`, `AccordionController`,
44
+ `StateMachineController`, plus `BaseController`. Registered in the
45
+ wiring registry for lazy resolution; surface only imports what it
46
+ declares.
47
+
48
+ ## Minimal usage
49
+
50
+ ```js
51
+ import { A2UIRenderer, jsonlStream } from '@adia-ai/a2ui';
52
+ import '@adia-ai/web-components'; // register the custom elements
53
+
54
+ const container = document.querySelector('#app');
55
+ const renderer = new A2UIRenderer(container);
56
+
57
+ for await (const msg of jsonlStream(fetch('/api/ui-trace.jsonl').then(r => r.body))) {
58
+ renderer.process(msg);
59
+ }
60
+ ```
61
+
62
+ For declarative embedding in HTML, use the `<a2ui-root>` element from
63
+ `@adia-ai/web-modules/runtime` — it wraps `A2UIRenderer` + stream
64
+ wiring in a custom element. (Was `@adia-ai/web-components/patterns/`
65
+ prior to the package split per ADR-0012.)
66
+
67
+ ## Public entry points
68
+
69
+ The default export bundles the common surface. Granular subpath
70
+ imports are available if you want to tree-shake:
71
+
72
+ ```js
73
+ import { A2UIRenderer } from '@adia-ai/a2ui/renderer';
74
+ import { resolveTag } from '@adia-ai/a2ui/registry';
75
+ import { sseStream } from '@adia-ai/a2ui/streams';
76
+ import { SurfaceManifest } from '@adia-ai/a2ui/surface';
77
+ import { WiringEngine } from '@adia-ai/a2ui/wiring';
78
+ import { ActionDock } from '@adia-ai/a2ui/dockables';
79
+ import { validateSchema } from '@adia-ai/a2ui/validate';
80
+ ```
81
+
82
+ `./validate` is the protocol-side validator — structural + wiring/registry
83
+ coherence checks against the A2UI protocol, no catalog and no LLM, so it
84
+ keeps this package dependency-free. Catalog-aware validation and the LLM
85
+ semantic judge live in `@adia-ai/gen-ui` (`./validate/catalog`,
86
+ `./validate/semantic`) per ADR-0048.
87
+
88
+ ## Relationship to other packages
89
+
90
+ - **`@adia-ai/web-components`** — the custom-element catalog the
91
+ renderer resolves tags against. Depends on this package at runtime
92
+ (as of web-components v0.0.4).
93
+ - **`@adia-ai/web-modules/runtime/a2ui-root`** — the declarative
94
+ `<a2ui-root>` custom element; wraps this package's renderer + stream.
95
+ Extracted from `@adia-ai/web-components/patterns/` per ADR-0012.
96
+
97
+ ## License
98
+
99
+ (See repository root.)
@@ -0,0 +1,192 @@
1
+ // AUTO-GENERATED FROM packages/a2ui/a2ui.schema.json. DO NOT EDIT.
2
+ // Regenerate with: node scripts/build/a2ui-schema-types.mjs
3
+ // Verify drift with: node scripts/build/a2ui-schema-types.mjs --verify
4
+
5
+ /**
6
+ * Any valid A2UI protocol message. Discriminated on `type`.
7
+ */
8
+ export type A2UIMessage =
9
+ | CreateSurfaceMessage
10
+ | UpdateComponentsMessage
11
+ | UpdateDataModelMessage
12
+ | WireComponentsMessage
13
+ | DeleteSurfaceMessage
14
+ | UpdateStylesMessage
15
+ | RemoveStylesMessage
16
+ | MetaMessage;
17
+
18
+ /**
19
+ * Initialises a new rendering surface.
20
+ */
21
+ export interface CreateSurfaceMessage {
22
+ type: 'createSurface';
23
+ /**
24
+ * Unique surface identifier.
25
+ */
26
+ surfaceId: string;
27
+ /**
28
+ * Optional catalog URL override for this surface.
29
+ */
30
+ catalogId?: string;
31
+ /**
32
+ * Component ID to use as the surface root (defaults to 'root').
33
+ */
34
+ root?: string;
35
+ }
36
+ /**
37
+ * Upserts component nodes on a surface. The renderer reconciles against existing nodes.
38
+ */
39
+ export interface UpdateComponentsMessage {
40
+ type: 'updateComponents';
41
+ /**
42
+ * Legacy alias for type; renderer accepts both.
43
+ */
44
+ messageType?: string;
45
+ surfaceId: string;
46
+ /**
47
+ * @minItems 1
48
+ */
49
+ components: [A2UIComponent, ...A2UIComponent[]];
50
+ }
51
+ /**
52
+ * A single component node in the A2UI component tree.
53
+ */
54
+ export interface A2UIComponent {
55
+ /**
56
+ * Unique identifier within the surface.
57
+ */
58
+ id: string;
59
+ /**
60
+ * A2UI type name — resolved via the registry to a custom-element tag name.
61
+ */
62
+ component: string;
63
+ /**
64
+ * Ordered list of child component IDs.
65
+ */
66
+ children?: string[];
67
+ /**
68
+ * Single child component ID (shorthand when there is exactly one child).
69
+ */
70
+ child?: string;
71
+ /**
72
+ * Per-child slot address (gh#653): names WHICH region of the PARENT this component occupies — e.g. a Button with slot "trigger" under a Popover, a Text with slot "description" under a ListItem. The legal names are the parent type's own declared anatomy slots (catalog `x-adiaui.slots`); an unknown name is inert, never fatal. Renderers map it to the light-DOM `slot=` attribute, which the component's CSS/JS already consumes — positioning stays CSS by tag + ancestor + DOM order (AGENTS.md), so this is addressing metadata, not a shadow-DOM projection instruction. Optional: children with no `slot` keep pure document order.
73
+ */
74
+ slot?: string;
75
+ [k: string]: unknown;
76
+ }
77
+ /**
78
+ * Updates the surface data model at a JSON Pointer path.
79
+ */
80
+ export interface UpdateDataModelMessage {
81
+ type: 'updateDataModel';
82
+ surfaceId: string;
83
+ /**
84
+ * JSON Pointer path within the data model (e.g. '/user/name'). '/' or '' = replace root.
85
+ */
86
+ path: string;
87
+ value: unknown;
88
+ }
89
+ /**
90
+ * Attaches interactive behaviour (actions, data sources, controllers) to a surface.
91
+ */
92
+ export interface WireComponentsMessage {
93
+ type: 'wireComponents';
94
+ surfaceId: string;
95
+ /**
96
+ * Event → handler bindings.
97
+ */
98
+ actions?: WireAction[];
99
+ /**
100
+ * Data-source dockables.
101
+ */
102
+ data?: WireDataSource[];
103
+ /**
104
+ * Controller dockables.
105
+ */
106
+ state?: WireState[];
107
+ /**
108
+ * Lifecycle hook dockables.
109
+ */
110
+ lifecycle?: {
111
+ [k: string]: unknown;
112
+ }[];
113
+ }
114
+ /**
115
+ * An event → handler binding for interactive wiring.
116
+ */
117
+ export interface WireAction {
118
+ /**
119
+ * The UIEvent that triggers this action.
120
+ */
121
+ event:
122
+ | string
123
+ | {
124
+ event: string;
125
+ /**
126
+ * Component ID that emits the event.
127
+ */
128
+ target?: string;
129
+ debounce?: number;
130
+ };
131
+ /**
132
+ * Handler name registered in the wiring registry.
133
+ */
134
+ handler: string;
135
+ /**
136
+ * Handler-specific configuration.
137
+ */
138
+ config?: {
139
+ [k: string]: unknown;
140
+ };
141
+ }
142
+ /**
143
+ * A data-source dockable: connects a model path to a component property.
144
+ */
145
+ export interface WireDataSource {
146
+ source: string;
147
+ target: string;
148
+ transform?: string;
149
+ [k: string]: unknown;
150
+ }
151
+ /**
152
+ * A controller dockable: attaches a stateful controller to a component.
153
+ */
154
+ export interface WireState {
155
+ controller: string;
156
+ target: string;
157
+ config?: {
158
+ [k: string]: unknown;
159
+ };
160
+ [k: string]: unknown;
161
+ }
162
+ /**
163
+ * Removes a surface and all its elements from the DOM.
164
+ */
165
+ export interface DeleteSurfaceMessage {
166
+ type: 'deleteSurface';
167
+ surfaceId: string;
168
+ }
169
+ /**
170
+ * CSS channel: adopts a surface-scoped stylesheet (wrapped in @scope([data-a2ui-surface=<id>]) by the renderer). See specs/genui-css-channel.md.
171
+ */
172
+ export interface UpdateStylesMessage {
173
+ type: 'updateStyles';
174
+ surfaceId: string;
175
+ styleId: string;
176
+ css: string;
177
+ }
178
+ /**
179
+ * CSS channel: removes a previously adopted surface-scoped stylesheet by styleId.
180
+ */
181
+ export interface RemoveStylesMessage {
182
+ type: 'removeStyles';
183
+ surfaceId: string;
184
+ styleId: string;
185
+ }
186
+ /**
187
+ * LLM self-critique or metadata. Not rendered; passed through for logging.
188
+ */
189
+ export interface MetaMessage {
190
+ type: 'meta';
191
+ [k: string]: unknown;
192
+ }
@@ -0,0 +1,73 @@
1
+ import { BaseController } from './base.js';
2
+
3
+ /**
4
+ * Accordion controller — manages expandable section state.
5
+ * Sets [data-accordion-expanded] on expanded sections.
6
+ */
7
+ export class AccordionController extends BaseController {
8
+ static schema = Object.freeze({
9
+ name: 'accordion',
10
+ state: { expanded: 'Set', multiple: 'boolean' },
11
+ commands: ['toggle', 'expand', 'collapse', 'collapseAll', 'expandAll'],
12
+ attributes: ['data-accordion-expanded'],
13
+ });
14
+
15
+ #expanded = new Set();
16
+ #multiple = false;
17
+
18
+ constructor({ multiple = false, initial = [] } = {}) {
19
+ super();
20
+ this.#multiple = multiple;
21
+ for (const key of initial) this.#expanded.add(String(key));
22
+ }
23
+
24
+ onDisconnect(host) {
25
+ const sections = host.querySelectorAll('[data-accordion-expanded]');
26
+ for (const s of sections) s.removeAttribute('data-accordion-expanded');
27
+ }
28
+
29
+ getState() {
30
+ return { expanded: new Set(this.#expanded), multiple: this.#multiple };
31
+ }
32
+
33
+ reflect() {
34
+ if (!this.host) return;
35
+ const sections = this.host.querySelectorAll('[data-accordion-key]');
36
+ for (const section of sections) {
37
+ const key = section.getAttribute('data-accordion-key');
38
+ if (this.#expanded.has(key)) section.setAttribute('data-accordion-expanded', '');
39
+ else section.removeAttribute('data-accordion-expanded');
40
+ }
41
+ }
42
+
43
+ commands = {
44
+ toggle: (key) => {
45
+ key = String(key);
46
+ if (this.#expanded.has(key)) this.#expanded.delete(key);
47
+ else {
48
+ if (!this.#multiple) this.#expanded.clear();
49
+ this.#expanded.add(key);
50
+ }
51
+ this.notify();
52
+ },
53
+ expand: (key) => {
54
+ key = String(key);
55
+ if (!this.#multiple) this.#expanded.clear();
56
+ this.#expanded.add(key);
57
+ this.notify();
58
+ },
59
+ collapse: (key) => {
60
+ this.#expanded.delete(String(key));
61
+ this.notify();
62
+ },
63
+ collapseAll: () => {
64
+ this.#expanded.clear();
65
+ this.notify();
66
+ },
67
+ expandAll: (keys) => {
68
+ if (!this.#multiple) return;
69
+ for (const k of keys) this.#expanded.add(String(k));
70
+ this.notify();
71
+ },
72
+ };
73
+ }
@@ -0,0 +1,68 @@
1
+ /**
2
+ * BaseController — base class for all controllers.
3
+ *
4
+ * Provides: host management, subscription, notification, reflection.
5
+ * Subclass implements: onConnect, onDisconnect, getState, reflect, commands.
6
+ *
7
+ * Usage:
8
+ * class ToggleController extends BaseController {
9
+ * static schema = { name: 'toggle', state: { on: 'boolean' }, commands: ['toggle', 'set'], attributes: ['data-toggle-on'] };
10
+ * #on = false;
11
+ * getState() { return { on: this.#on }; }
12
+ * reflect() { ... }
13
+ * commands = { toggle: () => { ... this.notify(); } };
14
+ * }
15
+ */
16
+
17
+ const validated = new WeakSet();
18
+
19
+ export class BaseController {
20
+ #host = null;
21
+ #subs = new Set();
22
+
23
+ get host() { return this.#host; }
24
+
25
+ connect(host) {
26
+ const ctor = this.constructor;
27
+ if (!validated.has(ctor) && ctor !== BaseController) {
28
+ validated.add(ctor);
29
+ if (!ctor.schema) {
30
+ console.warn(`AdiaUI: ${ctor.name} extends BaseController without static schema`);
31
+ }
32
+ if (this.getState === BaseController.prototype.getState) {
33
+ console.error(`AdiaUI: ${ctor.schema?.name ?? ctor.name} must implement getState()`);
34
+ }
35
+ }
36
+ if (this.#host && this.#host !== host) {
37
+ console.warn(`AdiaUI: ${ctor.schema?.name ?? ctor.name} already connected to a different host`);
38
+ }
39
+ this.#host = host;
40
+ this.onConnect(host);
41
+ this.reflect();
42
+ }
43
+
44
+ disconnect(host) {
45
+ const h = host ?? this.#host;
46
+ if (!h) return;
47
+ this.onDisconnect(h);
48
+ this.#host = null;
49
+ }
50
+
51
+ subscribe(fn) {
52
+ this.#subs.add(fn);
53
+ return () => this.#subs.delete(fn);
54
+ }
55
+
56
+ notify() {
57
+ this.reflect();
58
+ for (const fn of this.#subs) fn();
59
+ }
60
+
61
+ onConnect(host) {}
62
+ onDisconnect(host) {}
63
+ reflect() {}
64
+
65
+ getState() {
66
+ throw new Error(`${this.constructor.schema?.name ?? this.constructor.name}: getState() not implemented`);
67
+ }
68
+ }
@@ -0,0 +1,281 @@
1
+ import { BaseController } from './base.js';
2
+
3
+ /**
4
+ * DataStreamController — pushes live data to chart components.
5
+ *
6
+ * Accepts: SSE URL, WebSocket URL, async iterable, polling function, or manual push.
7
+ * Accumulates data points in a buffer with configurable max size.
8
+ * Each new point triggers notify() → chart re-renders.
9
+ *
10
+ * Usage:
11
+ * const ctrl = new DataStreamController({ max: 20 });
12
+ * chart.controller = ctrl;
13
+ *
14
+ * // Manual push
15
+ * ctrl.commands.push({ month: 'Jan', revenue: 4200 });
16
+ *
17
+ * // SSE
18
+ * ctrl.commands.connect('sse', '/api/metrics');
19
+ *
20
+ * // WebSocket
21
+ * ctrl.commands.connect('ws', 'wss://api.example.com/stream');
22
+ *
23
+ * // Polling
24
+ * ctrl.commands.poll('/api/latest', 2000);
25
+ *
26
+ * // Async iterable
27
+ * ctrl.commands.consume(asyncIterable);
28
+ *
29
+ * // Stop
30
+ * ctrl.commands.stop();
31
+ */
32
+ export class DataStreamController extends BaseController {
33
+ static schema = Object.freeze({
34
+ name: 'data-stream',
35
+ state: { data: 'array', status: 'string', error: 'string' },
36
+ commands: ['push', 'pushMany', 'connect', 'poll', 'consume', 'stop', 'clear'],
37
+ attributes: ['data-stream-status'],
38
+ });
39
+
40
+ #data = [];
41
+ #max;
42
+ #status = 'idle'; // idle | connecting | streaming | error | stopped
43
+ #error = null;
44
+ #abort = null;
45
+ #throttle = 0; // ms — min interval between notify() calls. 0 = every push.
46
+ #sample = 1; // keep every Nth point. 1 = keep all.
47
+ #pendingNotify = null;
48
+ #pushCount = 0; // total pushes since connect (for sampling)
49
+
50
+ /**
51
+ * @param {Object} opts
52
+ * @param {number} [opts.max=100] — Maximum data points to retain (FIFO)
53
+ * @param {number} [opts.throttle=0] — Min ms between render notifications. 0 = every push.
54
+ * @param {number} [opts.sample=1] — Keep every Nth point. 1 = keep all.
55
+ * @param {Array} [opts.initial] — Initial data
56
+ */
57
+ constructor({ max = 100, throttle = 0, sample = 1, initial = [] } = {}) {
58
+ super();
59
+ this.#max = max;
60
+ this.#throttle = throttle;
61
+ this.#sample = Math.max(1, Math.round(sample));
62
+ this.#data = initial.slice(-max);
63
+ }
64
+
65
+ getState() {
66
+ return {
67
+ data: this.#data,
68
+ status: this.#status,
69
+ error: this.#error,
70
+ };
71
+ }
72
+
73
+ reflect() {
74
+ if (!this.host) return;
75
+ this.host.setAttribute('data-stream-status', this.#status);
76
+ }
77
+
78
+ onDisconnect() {
79
+ this.commands.stop();
80
+ }
81
+
82
+ #append(point) {
83
+ // Sampling: skip points when sample > 1
84
+ this.#pushCount++;
85
+ if (this.#sample > 1 && this.#pushCount % this.#sample !== 0) return;
86
+
87
+ this.#data.push(point);
88
+ if (this.#data.length > this.#max) {
89
+ this.#data = this.#data.slice(-this.#max);
90
+ }
91
+ this.#scheduleNotify();
92
+ }
93
+
94
+ #appendMany(points) {
95
+ // Sampling: filter points when sample > 1
96
+ if (this.#sample > 1) {
97
+ const sampled = [];
98
+ for (const p of points) {
99
+ this.#pushCount++;
100
+ if (this.#pushCount % this.#sample === 0) sampled.push(p);
101
+ }
102
+ this.#data.push(...sampled);
103
+ } else {
104
+ this.#data.push(...points);
105
+ }
106
+ if (this.#data.length > this.#max) {
107
+ this.#data = this.#data.slice(-this.#max);
108
+ }
109
+ this.#scheduleNotify();
110
+ }
111
+
112
+ #scheduleNotify() {
113
+ // No throttle: immediate
114
+ if (this.#throttle <= 0) {
115
+ this.notify();
116
+ return;
117
+ }
118
+ // Throttled: batch into one notify per interval
119
+ if (this.#pendingNotify !== null) return;
120
+ this.#pendingNotify = setTimeout(() => {
121
+ this.#pendingNotify = null;
122
+ this.notify();
123
+ }, this.#throttle);
124
+ }
125
+
126
+ #setStatus(status, error = null) {
127
+ this.#status = status;
128
+ this.#error = error;
129
+ this.notify();
130
+ }
131
+
132
+ commands = {
133
+ /** Push a single data point */
134
+ push: (point) => {
135
+ this.#append(point);
136
+ },
137
+
138
+ /** Push multiple data points at once */
139
+ pushMany: (points) => {
140
+ this.#appendMany(points);
141
+ },
142
+
143
+ /** Connect to SSE or WebSocket stream */
144
+ connect: (type, url) => {
145
+ this.commands.stop();
146
+ this.#abort = new AbortController();
147
+
148
+ if (type === 'sse') {
149
+ this.#connectSSE(url);
150
+ } else if (type === 'ws') {
151
+ this.#connectWS(url);
152
+ } else {
153
+ this.#setStatus('error', `Unknown stream type: ${type}`);
154
+ }
155
+ },
156
+
157
+ /** Poll a URL at an interval (ms) */
158
+ poll: (url, interval = 2000) => {
159
+ this.commands.stop();
160
+ this.#abort = new AbortController();
161
+ this.#startPolling(url, interval);
162
+ },
163
+
164
+ /** Consume an async iterable */
165
+ consume: (iterable) => {
166
+ this.commands.stop();
167
+ this.#abort = new AbortController();
168
+ this.#consumeIterable(iterable);
169
+ },
170
+
171
+ /** Stop all active streams */
172
+ stop: () => {
173
+ if (this.#abort) {
174
+ this.#abort.abort();
175
+ this.#abort = null;
176
+ }
177
+ if (this.#pendingNotify !== null) {
178
+ clearTimeout(this.#pendingNotify);
179
+ this.#pendingNotify = null;
180
+ }
181
+ if (this.#status === 'streaming' || this.#status === 'connecting') {
182
+ this.#setStatus('stopped');
183
+ }
184
+ },
185
+
186
+ /** Clear all data */
187
+ clear: () => {
188
+ this.#data = [];
189
+ this.notify();
190
+ },
191
+ };
192
+
193
+ #connectSSE(url) {
194
+ this.#setStatus('connecting');
195
+ const es = new EventSource(url);
196
+ const signal = this.#abort.signal;
197
+
198
+ signal.addEventListener('abort', () => es.close());
199
+
200
+ es.onopen = () => this.#setStatus('streaming');
201
+
202
+ es.onmessage = (e) => {
203
+ try {
204
+ const point = JSON.parse(e.data);
205
+ this.#append(point);
206
+ } catch { /* skip malformed */ }
207
+ };
208
+
209
+ es.onerror = () => {
210
+ if (!signal.aborted) {
211
+ this.#setStatus('error', 'SSE connection lost');
212
+ es.close();
213
+ }
214
+ };
215
+ }
216
+
217
+ #connectWS(url) {
218
+ this.#setStatus('connecting');
219
+ const ws = new WebSocket(url);
220
+ const signal = this.#abort.signal;
221
+
222
+ signal.addEventListener('abort', () => ws.close());
223
+
224
+ ws.onopen = () => this.#setStatus('streaming');
225
+
226
+ ws.onmessage = (e) => {
227
+ try {
228
+ const msg = JSON.parse(e.data);
229
+ if (Array.isArray(msg)) this.#appendMany(msg);
230
+ else this.#append(msg);
231
+ } catch { /* skip malformed */ }
232
+ };
233
+
234
+ ws.onerror = () => {
235
+ if (!signal.aborted) {
236
+ this.#setStatus('error', 'WebSocket error');
237
+ }
238
+ };
239
+
240
+ ws.onclose = () => {
241
+ if (!signal.aborted) {
242
+ this.#setStatus('stopped');
243
+ }
244
+ };
245
+ }
246
+
247
+ async #startPolling(url, interval) {
248
+ this.#setStatus('streaming');
249
+ const signal = this.#abort.signal;
250
+
251
+ while (!signal.aborted) {
252
+ try {
253
+ const res = await fetch(url, { signal });
254
+ const json = await res.json();
255
+ if (Array.isArray(json)) this.#appendMany(json);
256
+ else this.#append(json);
257
+ } catch (e) {
258
+ if (signal.aborted) break;
259
+ this.#setStatus('error', e.message);
260
+ break;
261
+ }
262
+ await new Promise(r => setTimeout(r, interval));
263
+ }
264
+ }
265
+
266
+ async #consumeIterable(iterable) {
267
+ this.#setStatus('streaming');
268
+ const signal = this.#abort.signal;
269
+
270
+ try {
271
+ for await (const point of iterable) {
272
+ if (signal.aborted) break;
273
+ if (Array.isArray(point)) this.#appendMany(point);
274
+ else this.#append(point);
275
+ }
276
+ if (!signal.aborted) this.#setStatus('idle');
277
+ } catch (e) {
278
+ if (!signal.aborted) this.#setStatus('error', e.message);
279
+ }
280
+ }
281
+ }