@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
JavaScript
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;
}
}