UNPKG

@yoyo-org/progressive-json

Version:

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

363 lines (313 loc) 9.87 kB
import { fetchJson } from "./fetch-json"; import type { ProgressiveChunkMessage, StreamProcessorOptions, PlaceholderStore, Plugin, PluginContext, } from "./resolve-placeholder"; import { filterPlaceholders } from "./utils/filter-placeholders"; import { findPlaceholders, RefPathMap, PlaceholderPath, } from "./utils/find-placeholders"; import { isPlaceholder } from "./utils/is-placeholder"; import { produce } from "immer"; export class Processor<T extends PlaceholderStore = PlaceholderStore> { private store: T | undefined; private transformedStore: T | undefined; private selectedStore: Partial<T> | undefined; private options: StreamProcessorOptions<T>; private decoder = new TextDecoder(); private listeners: Array<() => void> = []; private refStore: RefPathMap = {}; private plugins: Plugin<any, any>[] = []; private isStreaming = false; private streamError: Error | null = null; constructor(options: StreamProcessorOptions<T>) { 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(); } } private async startStreaming() { if (this.isStreaming) return; this.isStreaming = true; this.streamError = null; this.options.onStreamStart?.(); try { // Use custom fetch function if provided, otherwise use default if (this.options.customFetch) { await this.options.customFetch(this.options.url, this, this.options); } else { await fetchJson(this.options.url, this); } } catch (error) { this.doHandleStreamError(error as Error); } } private doHandleStreamError(error: Error) { this.isStreaming = false; this.streamError = error; this.options.onStreamError?.(error); this.notifyListeners(); } private updateTransformedStore() { if (!this.store) { this.transformedStore = undefined; this.selectedStore = undefined; return; } // Apply transform if provided this.transformedStore = this.options.transform ? this.options.transform(this.store) : this.store; // Apply selector if provided this.selectedStore = this.options.select ? this.options.select(this.transformedStore) : this.transformedStore; } // --- Public API --- getStore(): T | undefined { return this.selectedStore as T | undefined; } getRawStore(): T | undefined { return this.store; } getTransformedStore(): T | undefined { return this.transformedStore; } getRefStore(): RefPathMap { return this.refStore; } isCurrentlyStreaming(): boolean { return this.isStreaming; } getStreamError(): Error | null { return this.streamError; } subscribe(listener: () => void): () => void { this.listeners.push(listener); return () => { this.listeners = this.listeners.filter((l) => l !== listener); }; } startFetching(): void { if (this.options.url && this.options.enabled !== false) { this.startStreaming(); } } updateOptions(newOptions: Partial<StreamProcessorOptions<T>>): void { const oldEnabled = this.options.enabled; const oldUrl = this.options.url; this.options = { ...this.options, ...newOptions }; // Restart streaming if URL changed or enabled changed from false to true if ( (oldEnabled === false && this.options.enabled !== false) || (oldUrl !== this.options.url && this.options.enabled !== false) ) { this.startStreaming(); } // Update transforms if store exists if (this.store) { this.updateTransformedStore(); this.notifyListeners(); } } processChunk(chunk: Uint8Array): void { const text = this.decoder.decode(chunk, { stream: true }); for (const line of text.split("\n")) { if (!this.store) { this.store = {} as T; } const prevStore = this.store; this.store = this.handleStreamLine(line, this.store); // Only update transformed store and notify if data actually changed if (this.hasStoreChanged(prevStore, this.store)) { this.updateTransformedStore(); this.notifyListeners(); } } } // --- Internals --- private notifyListeners() { this.listeners.forEach((listener) => listener()); } private hasStoreChanged(prev: T, next: T): boolean { // Use custom compare function if provided if (this.options.compare) { return !this.options.compare(prev, next); } // Default: simple reference equality return prev !== next; } stop(): void { this.isStreaming = false; // Cleanup SSE connection if exists if ((this as any)._sseCleanup) { (this as any)._sseCleanup(); delete (this as any)._sseCleanup; } // Note: fetchJson implementation should handle abort signals } onStreamComplete(finalData: T): void { this.isStreaming = false; this.updateTransformedStore(); this.options.onStreamEnd?.(finalData); this.notifyListeners(); } handleStreamError(error: Error): void { this.doHandleStreamError(error); } destroy(): void { this.stop(); this.listeners = []; } normalizeRefKey(key: string): string { return key.startsWith("ref") ? key : `ref${key}`; } getRefIdFromKey(refKey: string): number | null { const match = refKey.match(/^ref\$(\d+)$/); return match ? Number(match[1]) : null; } private updateRefStore(store: T) { const found = findPlaceholders(store); Object.entries(found).forEach(([refId, path]) => { this.refStore[Number(refId)] = path; }); } updateAtPath( store: T, key: string, updater: ( obj: Record<string | number, unknown>, lastKey: string | number, ) => void, ): T { const refKey = this.normalizeRefKey(key); const refId = this.getRefIdFromKey(refKey); if (refId == null) return store; const path = this.refStore[refId]; if (!path) return store; return produce(store, (draft) => { let obj: unknown = draft; for (let i = 0; i < path.length - 1; i++) { if (typeof obj === "object" && obj !== null) { obj = (obj as Record<string | number, unknown>)[path[i]]; } } const lastKey = path[path.length - 1]; if (typeof obj === "object" && obj !== null) { updater(obj as Record<string | number, unknown>, lastKey); } }); } private applyPushUpdate(store: T, key: string, value: unknown): T { return this.updateAtPath(store, key, (obj, lastKey) => { if (!Array.isArray(obj[lastKey])) obj[lastKey] = []; (obj[lastKey] as unknown[]).push(value); }); } private applyConcatUpdate(store: T, key: string, value: unknown[]): T { return this.updateAtPath(store, key, (obj, lastKey) => { if (!Array.isArray(obj[lastKey])) obj[lastKey] = []; (obj[lastKey] as unknown[]).push(...value); }); } private applyRefUpdate(store: T, key: string, value: unknown): T { const updatedStore = this.updateAtPath(store, key, (obj, lastKey) => { obj[lastKey] = value; }); this.updateRefStore(updatedStore); return updatedStore; } private applyStreamUpdate(store: T, key: string, value: string): T { return this.updateAtPath(store, key, (obj, lastKey) => { if ( typeof obj[lastKey] === "string" && isPlaceholder(obj[lastKey] as string) ) { obj[lastKey] = value; } else { obj[lastKey] = ((obj[lastKey] ?? "") as string) + value; } }); } private handleStreamLine(line: string, currentStore: T): T { if (!line.trim()) return currentStore; const { onMessage } = this.options; let updatedStore = currentStore; try { const msg: ProgressiveChunkMessage = JSON.parse(line); // Find a plugin whose type matches the message type const plugin = this.plugins.find((p) => p.type === msg.type); if (plugin) { const context: PluginContext<any> = { updateAtPath: this.updateAtPath.bind(this), normalizeRefKey: this.normalizeRefKey.bind(this), getRefIdFromKey: this.getRefIdFromKey.bind(this), refStore: this.refStore, }; // Type-safe dispatch: cast msg to the plugin's message type updatedStore = (plugin.handleMessage as any)( msg, currentStore, context, ) as T; } else { // Handle built-in message types switch (msg.type) { case "init": updatedStore = this.handleInit(msg.data as T); break; case "value": updatedStore = this.applyRefUpdate( updatedStore, msg.key, msg.value, ); break; case "text": updatedStore = this.applyStreamUpdate( updatedStore, msg.key, String(msg.value), ); break; case "push": updatedStore = this.applyPushUpdate( updatedStore, msg.key, msg.value, ); break; case "concat": updatedStore = this.applyConcatUpdate( updatedStore, msg.key, msg.value as unknown[], ); break; } } if (onMessage) { onMessage(filterPlaceholders(updatedStore)); } return updatedStore; } catch { return currentStore; } } private handleInit(data: T): T { this.updateRefStore(data); return data; } }