Install
ESM and CommonJS builds are both included, with bundled TypeScript declarations. No runtime dependencies. Works anywhere JSON.parse does: Node 18+, Deno, Bun, browsers, and edge runtimes.
npm install sofar-json
# or
pnpm add sofar-json
yarn add sofar-json
import { parsePartialJSON, createJSONStream } from "sofar-json";
parsePartialJSON
function parsePartialJSON(input: string): unknown
Stateless, best-effort parse of a possibly-incomplete JSON string. It scans the input exactly once and returns the parsed value of the longest prefix that can be repaired into valid JSON.
- Returns the value when any usable prefix can be repaired.
- Returns
undefined when nothing is parseable yet — empty input, whitespace, a bare tru.
- Never throws. Garbage in,
undefined out.
- A complete document parses identically to
JSON.parse. A complete null returns null, not undefined — it's a real value.
parsePartialJSON('{"a": [1, 2, {"b": "c'); // { a: [1, 2, { b: "c" }] }
parsePartialJSON('{"a": 1,'); // { a: 1 } trailing comma dropped
parsePartialJSON('{"ok": tru'); // {} partial literal → last good value
parsePartialJSON('{"n": 1e+'); // {} partial number → last good value
parsePartialJSON('{"s": "caf\\u00e'); // { s: "caf" } partial \u escape trimmed
parsePartialJSON(''); // undefined
parsePartialJSON('null'); // null
createJSONStream
function createJSONStream(): { feed(chunk: string): unknown; readonly raw: string }
A stateful wrapper for the common case: chunks arrive, and you want the current value after each one. feed appends the chunk and returns parsePartialJSON of everything received so far. raw is the untouched concatenation — useful for logging, retries, or a final strict parse once the stream ends.
const stream = createJSONStream();
stream.feed('{"title": "Pad'); // { title: "Pad" }
stream.feed(' Thai", "servings"'); // { title: "Pad Thai" }
stream.feed(': 4}'); // { title: "Pad Thai", servings: 4 }
stream.raw; // '{"title": "Pad Thai", "servings": 4}'
Each call returns a fresh object, so assigning the result straight into React state, a Vue ref, or a Svelte store triggers a re-render with no manual cloning.
fetch + ReadableStream
The most portable shape: read bytes off response.body, decode them with { stream: true } so multi-byte characters split across chunks are handled, and feed each piece.
import { createJSONStream } from "sofar-json";
async function streamRecipe(prompt: string, onUpdate: (value: unknown) => void) {
const res = await fetch("/api/recipe", {
method: "POST",
body: JSON.stringify({ prompt }),
});
const reader = res.body!.getReader();
const decoder = new TextDecoder();
const stream = createJSONStream();
while (true) {
const { done, value } = await reader.read();
if (done) break;
const parsed = stream.feed(decoder.decode(value, { stream: true }));
if (parsed !== undefined) onUpdate(parsed);
}
// The stream is complete. A strict parse of `raw` now either succeeds
// or tells you the model produced invalid JSON.
return JSON.parse(stream.raw);
}
If your SDK already hands you text deltas — the OpenAI and Anthropic clients, the Vercel AI SDK — skip the reader and call stream.feed(delta) directly.
Framework-agnostic
The package has no opinion about your UI. The pattern is always the same: feed each chunk, and when the result is not undefined, put it wherever your state lives. Intermediate values are always valid JSON, so the components that render the finished result render the partial one too — no separate loading shape.
React
const [data, setData] = useState<unknown>(), then setData(stream.feed(chunk)) inside your handler. Fresh object per call means the re-render is automatic.
Vue
A ref() or shallowRef(); assign value.value = stream.feed(chunk). Reach for shallowRef on large payloads to skip deep reactivity.
Svelte
A writable store or a $state rune; data = stream.feed(chunk). Nothing else to wire up.
Anything else
Solid signals, Angular signals, plain DOM. One feed per chunk, one assignment per non-undefined result.
How it works
The buffer is scanned once, left to right, tracking the stack of open containers and whether the cursor is inside a string. As it goes, the scanner records safe cut points — positions where the JSON so far is structurally coherent — each paired with a snapshot of the container stack at that moment. The snapshot is stored inline as the string of closers to append, so rewinding later is a slice and a concat, never a rescan.
01Trim the tail
A dangling \ or a partial \uXXXX at the end of an open string is dropped, using the scanner's own escape state — so a string that legitimately ends in \\ is left alone.
02Attempt 1 — close everything
Close the open string if there is one, append the closers for every open { and [, and hand the result to JSON.parse. This resolves the overwhelming majority of prefixes: strings cut mid-word, arrays mid-element, objects mid-value.
03Attempt 2 — rewind to the last cut
If that fails — a dangling key, a trailing comma, tru, 4. — walk the cut points newest-first: right after a string closes, right after a container opens or closes, right before a structural comma. Slice the buffer there, append that cut's stored closers, parse. First success wins.
04Otherwise, undefined
If no cut parses, there is genuinely no value yet. Return undefined — never throw.
Everything is O(n) in the buffer length. The scan is linear, the snapshots are O(1) to store and O(1) to apply, and for a typical LLM stream the first or second parse attempt succeeds. On a 1.6 MB buffer the whole thing runs in about 1.3× the cost of a bare JSON.parse.
Guarantees computed live
Every row in this table is produced by running the library against the input when the page loads, so it can't drift from the shipped behaviour. These are the same cases covered one-per-test in the suite.
What it won't do
- Fix JSON that was never going to be valid. Unquoted keys, single quotes, comments,
NaN — use a lenient parser for that. This library repairs truncation, not syntax.
- Guess at a partial number.
{"count": 4. returns {}, not { count: 4 }, because 4. could become 4.5. Partial strings are returned as-is because a prefix of a string is still a string.
- Stream out. You get a full value on every call, not a diff. At LLM token rates and typical payload sizes this is not a concern; for multi-megabyte documents, throttle your
feed calls to animation frames.