v0.1.0MITTypeScriptESM + CJS

Every token, a valid value.

An LLM streams JSON one token at a time, and JSON.parse throws on every prefix until the last brace. SoFar repairs what has arrived so far into the best value it honestly supports — on every chunk, in one pass, without ever throwing.

npm install sofar-json
459 Bgzipped, full ESM build
0runtime dependencies
2exports, nothing else
O(n)single forward pass
streaming from model
idle
raw buffer0 chars
parsePartialJSON(raw)

The problem

Streaming structured output means parsing text that isn't finished yet.

Every intermediate buffer below is exactly what your handler sees between tokens. The left column is the built-in parser. The right column is the same input through parsePartialJSON — computed live on this page.

JSON.parse(buffer)throws
parsePartialJSON(buffer)returns

Playground

Scrub through a document one character at a time.

Paste any JSON, then drag the cursor. Synthesized characters the repair appends are shown in amber; characters it had to rewind past are struck out. The ruler underneath shows every safe cut point the scanner recorded — and which one won.

0 chars
0 / 0
cut pointcut usedcursor
← → to step
repaired string handed to JSON.parse

            
result
"}] appended by the repair"ingr rewound past

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.

01
Trim 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.

02
Attempt 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.

03
Attempt 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.

04
Otherwise, 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.

InputOutputWhy

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.
Copied