UNPKG

@yoyo-org/progressive-json

Version:

Stream and render JSON data as it arrives - perfect for AI responses, large datasets, and real-time updates

253 lines (252 loc) 8.15 kB
import { fetchJson } from "./fetch-json"; import { fetchSSE } from "./fetch-sse"; import { findPlaceholders, } from "./utils/find-placeholders"; import { produce } from "immer"; export class ProcessorSSE { constructor(options) { this.decoder = new TextDecoder(); this.listeners = []; this.refStore = {}; this.plugins = []; this.isStreaming = false; this.streamError = null; this.abortController = null; this.eventSource = null; this.options = options; this.store = options.initialStore; this.plugins = options.plugins || []; if (this.store) { this.updateRefStore(this.store); this.updateTransformedStore(); } // Only start fetching if enabled is not explicitly set to false if (options.enabled !== false && options.url) { this.startStreaming(); } } async startStreaming() { var _a, _b; if (this.isStreaming) return; this.isStreaming = true; this.streamError = null; (_b = (_a = this.options).onStreamStart) === null || _b === void 0 ? void 0 : _b.call(_a); try { if (this.options.useSSE) { this.abortController = new AbortController(); await fetchSSE(this.options.url, this, { headers: this.options.headers, signal: this.abortController.signal, }); } else { await fetchJson(this.options.url, this); } } catch (error) { this.handleStreamError(error); } } handleStreamError(error) { var _a, _b; this.isStreaming = false; this.streamError = error; (_b = (_a = this.options).onStreamError) === null || _b === void 0 ? void 0 : _b.call(_a, error); this.notifyListeners(); } processChunk(chunk) { const text = this.decoder.decode(chunk); const lines = text.split("\n"); for (const line of lines) { if (line.trim()) { try { const message = JSON.parse(line); this.processMessage(message); } catch (e) { // Skip invalid JSON lines } } } } processMessage(message) { // Check if this is a plugin message const plugin = this.plugins.find((p) => p.type === message.type); if (plugin && plugin.handler) { const context = this.createPluginContext(message); plugin.handler(message, context); return; } // Handle standard messages switch (message.type) { case "init": this.store = message.data; this.updateRefStore(this.store); this.updateTransformedStore(); this.notifyListeners(); break; case "value": this.updateValue(message.ref, message.data); break; case "push": this.pushToArray(message.ref, message.data); break; case "concat": this.concatToArray(message.ref, message.data); break; } } createPluginContext(message) { return { getRefPath: (ref) => this.refStore[ref], updateValue: (ref, value) => this.updateValue(ref, value), getValue: (ref) => this.getValueByRef(ref), notifyListeners: () => this.notifyListeners(), }; } startFetching() { if (this.options.url && this.options.enabled !== false) { this.startStreaming(); } } updateOptions(newOptions) { const oldEnabled = this.options.enabled; const oldUrl = this.options.url; this.options = { ...this.options, ...newOptions }; // Restart streaming if URL or enabled state changed if (this.options.url && this.options.enabled !== false && (oldUrl !== this.options.url || oldEnabled === false)) { this.stop(); this.startStreaming(); } else if (this.options.enabled === false) { this.stop(); } } updateRefStore(data, path = []) { const placeholders = findPlaceholders(data); placeholders.forEach((placeholder) => { this.refStore[placeholder.ref] = placeholder.path; }); } updateValue(ref, value) { const path = this.refStore[ref]; if (path && this.store) { this.store = produce(this.store, (draft) => { this.setValueAtPath(draft, path, value); }); this.updateTransformedStore(); this.notifyListeners(); } } pushToArray(ref, value) { const path = this.refStore[ref]; if (path && this.store) { this.store = produce(this.store, (draft) => { const array = this.getValueAtPath(draft, path); if (Array.isArray(array)) { array.push(value); } }); this.updateTransformedStore(); this.notifyListeners(); } } concatToArray(ref, values) { const path = this.refStore[ref]; if (path && this.store) { this.store = produce(this.store, (draft) => { const array = this.getValueAtPath(draft, path); if (Array.isArray(array)) { array.push(...values); } }); this.updateTransformedStore(); this.notifyListeners(); } } stop() { this.isStreaming = false; if (this.abortController) { this.abortController.abort(); this.abortController = null; } if (this.eventSource) { this.eventSource.close(); this.eventSource = null; } } onStreamComplete(finalData) { var _a, _b; this.isStreaming = false; this.updateTransformedStore(); (_b = (_a = this.options).onStreamEnd) === null || _b === void 0 ? void 0 : _b.call(_a, finalData); this.notifyListeners(); } onStreamError(error) { this.handleStreamError(error); } destroy() { this.stop(); this.listeners = []; } // Store access methods getStore() { return this.selectedStore || this.transformedStore; } getRawStore() { return this.store; } getTransformedStore() { return this.transformedStore; } isCurrentlyStreaming() { return this.isStreaming; } getStreamError() { return this.streamError; } // Subscription methods subscribe(listener) { this.listeners.push(listener); return () => { this.listeners = this.listeners.filter((l) => l !== listener); }; } notifyListeners() { this.listeners.forEach((listener) => listener()); } // Helper methods updateTransformedStore() { if (!this.store) return; this.transformedStore = this.options.transform ? this.options.transform(this.store) : this.store; this.selectedStore = this.options.select ? this.options.select(this.transformedStore) : this.transformedStore; } getValueByRef(ref) { const path = this.refStore[ref]; if (path && this.store) { return this.getValueAtPath(this.store, path); } return undefined; } setValueAtPath(obj, path, value) { let current = obj; for (let i = 0; i < path.length - 1; i++) { current = current[path[i]]; } current[path[path.length - 1]] = value; } getValueAtPath(obj, path) { let current = obj; for (const key of path) { current = current[key]; } return current; } }