jsonriver

repository·main·Indexed 22 days ago

https://github.com/rictic/jsonriver

A lightweight, dependency-free JSON parser for incrementally parsing JSON streams, such as network requests or LLM outputs. It provides a sequence of increasingly complete values via the parse() function and supports a completeCallback to track finished values. The library also includes a Tokenizer and TokenHandler interface for low-level stream processing of JSON tokens.

Tokens
2.3K
Snippets
4
Records
14
Agent score
79%

What's inside jsonriver

  1. Understand incremental value updates and invariants

    main

    As JSON streams in, jsonriver yields a sequence of values that grow in completeness. To ensure predictable behavior, the parser follows these invariants:

    1. Type Stability: Subsequent versions of a value will have the same type (except when an object has repeated keys).
    2. Atomicity: true, false, null, and numbers are yielded only when the entire value is available.
    3. String Growth: Strings are updated by appending more characters.
    4. Array Updates: Arrays are modified by appending new elements or mutating the element currently at the end.
    5. Object Updates: Objects are modified by adding new properties or mutating the most recently added property.
    6. Property Addition: A property is only added to an object once the full key and the value's type are known.
    7. Repeated Keys: If an object contains the same key multiple times, later values take precedence, matching JSON.parse behavior. This is the only case where a value's type might change or earlier keys might be removed.
  2. Understand JSON parsing invariants

    main

    The parse() function maintains several invariants to ensure predictable incremental behavior:

    1. Type Stability: Subsequent versions of a value will have the same type (e.g., a string won't suddenly become an array), except when handling repeated keys.
    2. Atomicity: true, false, null, and number are yielded only when complete.
    3. String Growth: Strings are updated by appending more characters.
    4. Array Mutation: Arrays are modified by appending new elements or mutating the element currently at the end.
    5. Object Mutation: Objects are modified by adding new properties or mutating the most recently added property.
    6. Property Addition: A property is only added to an object once the key is fully parsed and the value's type is known.
    7. Repeated Keys: If an object contains the same key multiple times, later values take precedence, matching JSON.parse behavior. This may change the type of a value and overwrite earlier keys.
  3. Track completed objects using the Completions Recipe

    main

    To efficiently check if an object or array has finished updating during a render loop, use a WeakMap to track completed values via the completeCallback.

    const completed = new WeakMap();
    
    function markCompleted(value) {
      if (value && typeof value === 'object') {
        completed.set(value, true);
      }
    }
    
    function isComplete(value) {
      if (value && typeof value === 'object') {
        return completed.has(value);
      }
    }
    
    const values = parse(stream, {completeCallback: markCompleted});
    for await (const value of values) {
      // Use isComplete to decide how to render
      render(value, isComplete);
    }
  4. Handle complete values with completeCallback

    main

    You can provide an options object as the second argument to parse() containing a completeCallback. This function is called whenever a value is considered 'complete' (meaning it will not be mutated or replaced again, barring the repeated key exception).

    completeCallback receives two arguments:

    • value: The newly completed value.
    • path: A lazy path object describing the location of the value relative to the top-level object.

    Important: Because the path is constructed lazily, you must call path.segments() synchronously within the callback if you need to access the path data.

  5. Parse JSON incrementally with parse()

    main

    Use the parse function to consume a stream of JSON data (e.g., from a fetch response or a language model) and receive an AsyncIterable that yields increasingly complete values. This allows you to process data as it arrives rather than waiting for the entire payload.

    import {parse} from 'jsonriver';
    
    const response = await fetch(`https://jsonplaceholder.typicode.com/posts`);
    const postsStream = parse(response.body.pipeThrough(new TextDecoderStream()));
    for await (const posts of postsStream) {
      console.log(posts);
    }
  6. Tokenize an async stream of JSON strings

    main

    Use the tokenize function to create a Tokenizer instance. It takes an AsyncIterable<string> (the stream) and a TokenHandler (your implementation).

    To actually process the stream, you must call pump() on the returned Tokenizer instance. The pump() method is asynchronous and drives the tokenization process by pulling chunks from the stream and invoking your handler.

    Note: The tokenize function and the pump method will throw an error if the input is not valid JSON or if it contains trailing content after the main JSON structure.

  7. Use completeCallback to track finished values

    main

    The completeCallback option in parse() is called whenever a JSON value (or sub-value) becomes "complete". A value is considered complete when the parser will no longer mutate it or replace it (except in the case of repeated keys in an object).

    Callback Signature: (value: JsonValue, path: Path) => void

    Example: For the input {"name": "Alex", "keys": [1, 20, 300]}, the callback is triggered for:

    1. "Alex" at path ['name']
    2. 1 at path ['keys', 0]
    3. 20 at path ['keys', 1]
    4. 300 at path ['keys', 2]
    5. [1, 20, 300] at path ['keys']
    6. {"name": "Alex", "keys": [1, 20, 300]} at path []
  8. Implement a TokenHandler to process JSON tokens

    main

    To use jsonriver for stream processing, you must implement the TokenHandler interface. This interface provides synchronous callback methods that are invoked as the Tokenizer recognizes different JSON components. This allows you to process JSON data incrementally without loading the entire structure into memory.

    Required methods include:

    • handleNull()
    • handleBoolean(value: boolean)
    • handleNumber(value: number)
    • handleStringStart()
    • handleStringMiddle(value: string)
    • handleStringEnd()
    • handleArrayStart()
    • handleArrayEnd()
    • handleObjectStart()
    • handleObjectEnd()
    export interface TokenHandler {
      handleNull(): void;
      handleBoolean(value: boolean): void;
      handleNumber(value: number): void;
      handleStringStart(): void;
      handleStringMiddle(value: string): void;
      handleStringEnd(): void;
      handleArrayStart(): void;
      handleArrayEnd(): void;
      handleObjectStart(): void;
      handleObjectEnd(): void;
    }
  9. Incrementally parse JSON with parse()

    main

    The parse() function incrementally parses a single JSON value from an AsyncIterable<string> of chunks. It yields a sequence of increasingly complete JSON values as more input is processed.

    Key Behaviors:

    • Value Reuse: For objects and arrays, yielded values are reused. If you hold a reference to a yielded object or array, it will be mutated in place as more data arrives.
    • Atomicity: true, false, null, and number types are atomic; they are only yielded once the entire value is available.
    • Strings: Strings are yielded as they grow, with characters appended.
    • Error Handling: Throws errors similarly to JSON.parse() if the input is invalid or contains non-whitespace trailing content.
    • Performance: The parser attempts to process as much synchronous data as possible before yielding.
  10. Use the Tokenizer class directly

    main

    While tokenize() is the preferred way to initialize a tokenizer, you can instantiate the Tokenizer class directly. This is useful if you need to manage the input property or if you are building custom integration logic.

    Key methods on Tokenizer:

    • isDone(): boolean: Returns true if the stack is empty and the input stream is exhausted.
    • pump(): Promise<void>: The primary method to drive the tokenization process.