JSON6 Streaming Parser Complete Guide

Parse a stream of JSON6 values as the text arrives. JSON6.begin gives you write, value and reset, and every example here was run on json-6 1.1.7.

A river of text chunks turning a water-wheel parser labeled begin, write and reset, releasing complete objects

TL;DR: const parser = JSON6.begin(callback) returns an object. Call parser.write(text) for each chunk; the callback fires once for every complete top-level value, including several values in one chunk and one value split across many. Numbers need a following space or an empty write('') to be known as finished. After an error the parser stops until you call reset().

What the Streaming Parser Does

JSON6.parse wants exactly one value in one string. That is wrong for log files, event feeds, sockets and anything where text arrives in pieces or where one document contains many values. JSON6.begin() creates a parser that keeps its state between calls. The README describes it as a way to get "objects as they are encountered in a stream". Its signature is JSON6.begin(callback[, reviver][, options]), and the parse options and the reviver behave exactly as they do for parse.

The returned object has three public methods. write(string) feeds text in and calls your callback once for each value that becomes complete. value() returns the most recently completed value, which matters only if you drive the lower-level interface. reset([options]) clears an error state so you can keep using the same parser. There are two further members in the declarations, _write and finalError, which are internal; the low-level section shows what _write does.

stream-basic.mjs

import JSON6 from 'json-6'; const values = []; const parser = JSON6.begin((value) => values.push(value)); // Several values in one write. parser.write("{a: 1} {b: 2} 'three' [4] true null "); console.log(values);

Run it:

node stream-basic.mjs

Output:

[ { a: 1 }, { b: 2 }, 'three', [ 4 ], true, null ]

Values in a stream are separated by whitespace (including newlines), by comments, or by nothing at all when the previous value ends in a closing bracket or quote. They are not separated by commas: {a:1},{b:2} throws "bad context; excessive commas". That matches how JSON text sequences and line-delimited JSON work, and it is different from an array, which is one value that contains many.

Values Split Across Chunks

The reason to use a stream parser is that you do not control where chunks end. A network packet can end in the middle of a key, in the middle of a string or in the middle of an escape sequence. The parser holds the partial value and continues on the next call. The next example cuts one document at awkward places.

stream-chunks.mjs

import JSON6 from 'json-6'; const out = []; const parser = JSON6.begin((value) => out.push(value)); const pieces = [ '{ first: 1,', // an object split after a comma ' second : 2 } {b:', // the end of one object, the start of the next '[1,2,3]} "Hello ', // the end of the second, the start of a string 'World!" ', // the end of the string "'a\\", // a string cut right after a backslash "nb' ", // the escape finishes in the next chunk '/* a comment ', // a comment cut in half '*/ [1,2', // the comment ends and an array begins '3]', // the 2 and the 3 join into 23 ' ', ]; for (const piece of pieces) parser.write(piece); console.log(JSON.stringify(out));

Run it:

node stream-chunks.mjs

Output:

[{"first":1,"second":2},{"b":[1,2,3]},"Hello World!","a\nb",[1,23]]

Look at the last value: the array was cut between 2 and 3 without a comma, so the parser correctly reads one number, 23. The parser has no way to know where a number ends other than the next character, and that is the next topic.

Numbers, Keywords and Flushing

A number, or a bare word like true, has no closing delimiter. When a chunk ends with 12 the parser cannot tell whether the next chunk will start with 3. So it waits. An object, array or quoted string closes itself, but a trailing number does not. There are two ways to finish it: send any whitespace, or call write('') with an empty string, which tells the parser that the input has ended for now.

stream-numbers.mjs

import JSON6 from 'json-6'; function run(label, chunks) { const out = []; const parser = JSON6.begin((value) => out.push(value)); try { for (const chunk of chunks) parser.write(chunk); console.log(label.padEnd(30), JSON.stringify(out)); } catch (err) { console.log(label.padEnd(30), 'throws:', err.message.split(' at ')[0]); } } run('number, nothing after it', ['12']); run('number, then empty write', ['12', '']); run('number, then a space', ['12 ']); run('number split across chunks', ['12', '34 ']); run('hex split across chunks', ['0x1', 'F ']); run('keyword split across chunks', ['tr', 'ue ']); run('object needs no flush', ['{a:1}']); run('string needs no flush', ["'text'"]); run('unterminated string, flush', ["'abc", '']); run('unterminated object, flush', ['{a:1', '']);

Run it:

node stream-numbers.mjs

Output:

number, nothing after it [] number, then empty write [12] number, then a space [12] number split across chunks [1234] hex split across chunks [31] keyword split across chunks [true] object needs no flush [{"a":1}] string needs no flush ["text"] unterminated string, flush [] unterminated object, flush throws: context stack is not empty

Two outcomes in that table are worth stating plainly. An unterminated object followed by an empty write throws "context stack is not empty at flush", which is the right behavior: the input ended in the middle of a structure. An unterminated string followed by an empty write does not throw and delivers nothing in my test, even though the README says a split string throws on flush. Do not rely on the empty write to detect truncation of a string.

The practical rule: when your source ends (the file reaches end of stream, the socket closes), call parser.write('') once so a final bare number or keyword is delivered. When it has not ended, never send an empty string just to be safe, because it can finish a number early only when the next chunk was about to continue it.

Newline-Delimited Input

Line-delimited records are the most common use. Because each record is a complete value that ends in a bracket or quote, no flush is needed, and the full JSON6 syntax is available per line: comments, unquoted keys, single and back-tick quotes, trailing commas and hex numbers. A reader that is strict about JSON Lines would reject every one of those lines, so only use this where both ends agree on JSON6. The example reads standard input, which is how you would use it in a pipe.

stream-stdin.mjs

import JSON6 from 'json-6'; let count = 0; const parser = JSON6.begin((event) => { count += 1; console.log(count, event.level, '-', event.msg); }); process.stdin.setEncoding('utf8'); process.stdin.on('data', (chunk) => parser.write(chunk)); process.stdin.on('end', () => { parser.write(''); console.log('total events:', count); });

Run it:

node stream-stdin.mjs < events.json6

Output:

1 info - started 2 warn - disk nearly full 3 error - failed total events: 3

The second event contains a back-tick string that spans two lines. In a line-based format that would break the record, but the stream parser does not look for line ends at all, so a record can be formatted over several lines. The parser only needs each value to be complete by the end of the stream.

Working with Node Streams

The parser accepts strings. Anything else you pass to write is converted with string conversion first, which means a Buffer is decoded as UTF-8 on every call. That is a trap with multi-byte characters: when a network or file chunk boundary falls inside a character, each half decodes to the replacement character U+FFFD and the data is silently corrupted. In my test a two-byte character cut in half produced two replacement characters followed by the second character, while the same bytes through a streaming decoder came out right. So decode first. Either call stream.setEncoding('utf8'), as the previous example does, or use a TextDecoder with the stream option.

stream-decoding.mjs

import JSON6 from 'json-6'; // "e acute" twice, as UTF-8 bytes: C3 A9 C3 A9 const bytes = Buffer.from("'\u00e9\u00e9' ", 'utf8'); const first = bytes.subarray(0, 2); // quote + first byte of the first character const second = bytes.subarray(2); function codePoints(text) { return [...text].map((ch) => ch.codePointAt(0).toString(16)); } // Wrong: passing Buffers. The split character becomes U+FFFD. const wrong = []; const p1 = JSON6.begin((v) => wrong.push(v)); p1.write(first); p1.write(second); console.log('buffers: ', codePoints(wrong[0])); // Right: decode with a streaming decoder first. const right = []; const p2 = JSON6.begin((v) => right.push(v)); const decoder = new TextDecoder(); p2.write(decoder.decode(first, { stream: true })); p2.write(decoder.decode(second, { stream: true })); console.log('TextDecoder: ', codePoints(right[0]));

Run it:

node stream-decoding.mjs

Output:

buffers: [ 'fffd', 'fffd', 'e9' ] TextDecoder: [ 'e9', 'e9' ]

Now the full pattern: a file read in tiny chunks. The file below is three records, read four bytes at a time, which splits every record across many chunks.

stream-file.mjs

import { createReadStream, writeFileSync } from 'node:fs'; import JSON6 from 'json-6'; writeFileSync('events.json6', "{t: 1}\n{t: 2}\n{t: 3}\n"); const times = []; const parser = JSON6.begin((event) => times.push(event.t)); const stream = createReadStream('events.json6', { encoding: 'utf8', highWaterMark: 4 }); stream.on('data', (chunk) => parser.write(chunk)); stream.on('end', () => { parser.write(''); // deliver a trailing bare value, if any console.log('times:', times); });

Run it:

node stream-file.mjs

Output:

times: [ 1, 2, 3 ]

For real pipelines a Transform stream in object mode is a better fit than loose callbacks, because it plugs into pipeline() and gives you backpressure and error propagation. The class below wraps the parser. It decodes bytes with a StringDecoder, pushes every completed value downstream and flushes the parser when the input ends. Parse errors become stream errors.

stream-transform.mjs

import { Readable, Transform, Writable } from 'node:stream'; import { pipeline } from 'node:stream/promises'; import { StringDecoder } from 'node:string_decoder'; import JSON6 from 'json-6'; class JSON6Values extends Transform { constructor(options) { super({ readableObjectMode: true }); this.decoder = new StringDecoder('utf8'); this.parser = JSON6.begin((value) => this.push(value), undefined, options); } _transform(chunk, encoding, callback) { try { this.parser.write(typeof chunk === 'string' ? chunk : this.decoder.write(chunk)); callback(); } catch (err) { callback(err); } } _flush(callback) { try { this.parser.write(this.decoder.end()); this.parser.write(''); callback(); } catch (err) { callback(err); } } } const source = Readable.from([ "{ id: 1, tags: ['a',], }\n{ id: 2, ", "tags: [], }\n// done\n", '3', ]); const seen = []; await pipeline( source, new JSON6Values(), new Writable({ objectMode: true, write(value, encoding, callback) { seen.push(value); callback(); }, }), ); console.log(seen); // A parse error surfaces as a stream error. try { await pipeline(Readable.from(['{ ok: 1 } { bad: @ }']), new JSON6Values(), new Writable({ objectMode: true, write(v, e, cb) { cb(); } })); } catch (err) { console.log('pipeline rejected:', err.message.replace(/ '.' unexpected.*$/s, '')); }

Run it:

node stream-transform.mjs

Output:

[ { id: 1, tags: [ 'a' ] }, { id: 2, tags: [] }, 3 ] pipeline rejected: fault parsing

One consequence of the design: the whole value has to be in memory before the callback fires. A giant single array of a million records is one value and will be held in full. The stream parser is for many moderately sized values, not for incrementally walking one enormous document.

Errors, reset() and Revivers

When a write hits a syntax error it throws, and the parser is then in a failed state. Values that were completed before the error in the same call were already delivered to your callback. Any further write keeps throwing until you call reset(). After a reset the same parser object works again, but the text that was in the failed region is gone, so the next write must start at a clean value boundary. The README warns that resetting "may require some work to actually work for complex cases"; for line-delimited input it is straightforward: reset and resume at the next line.

The options you passed to begin() stay in effect across a bare reset(). Pass an options object to reset({...}) to replace them. The reviver given to begin() is applied to every value, with the same (key, value) signature as in parse, including the root value.

stream-errors.mjs

import JSON6 from 'json-6'; // 1. Errors stop the parser until reset(). const good = []; const parser = JSON6.begin((v) => good.push(v)); try { parser.write('{a: 1} {b: ,,} {c: 3}'); } catch (err) { console.log('first error: ', err.message.replace(/ '.' unexpected.*$/s, '')); } console.log('delivered so far:', JSON.stringify(good)); try { parser.write('{d: 4}'); } catch (err) { console.log('still failing: ', err.message.replace(/ unexpected +'.' unexpected.*$/s, '')); } parser.reset(); parser.write('{d: 4} '); console.log('after reset: ', JSON.stringify(good)); // 2. Options survive a bare reset(), and reset(options) replaces them. const strict = []; const sp = JSON6.begin((v) => strict.push(v), undefined, { esStrictCompatible: true }); try { sp.write('{n: 0123} '); } catch (err) { console.log('strict rejects 0123'); } sp.reset(); try { sp.write('{n: 0123} '); } catch (err) { console.log('still strict after reset()'); } sp.reset({}); sp.write('{n: 0123} '); console.log('after reset({}):', JSON.stringify(strict)); // 3. A reviver sees every value, including the root of each one. const revived = []; const rp = JSON6.begin((v) => revived.push(v), (key, value) => (typeof value === 'number' ? value + 1 : value)); rp.write('[1, 2] {a: 5} 7 '); console.log('revived: ', JSON.stringify(revived)); // 4. begin() without a callback cannot deliver anything. try { JSON6.begin().write('1 '); } catch (err) { console.log('no callback: ', err.message); }

Run it:

node stream-errors.mjs

Output:

first error: Unexpected comma after object field name delivered so far: [{"a":1}] still failing: fault while parsing; getting field name after reset: [{"a":1},{"d":4}] strict rejects 0123 still strict after reset() after reset({}): [{"n":123}] revived: [[2,3],{"a":6},8] no callback: Callback function must be passed to begin, or you can't get values from the stream.

Wrap write in try/catch in any real pipeline. A throw in the middle of a Node data event handler becomes an uncaught exception, which can end the process.

value() and the Low-Level _write()

Under write() sits _write(string, completeAtEnd), which the README documents as a low-level routine. It returns a number: 0 when no value completed, 1 when a value completed and there is no more input, 2 when a value completed and more input remains. You then call value() to fetch the completed value, and call _write() with no arguments to continue through the buffered input. The leading underscore means it is not part of the stable API, so prefer write with a callback. It is useful when you want a pull-style loop and not a callback. The README prints this loop as the internal implementation of write. In the run below every completed value returned 2.

stream-lowlevel.mjs

import JSON6 from 'json-6'; const parser = JSON6.begin(); // no callback needed when you drive it yourself const rows = []; let status = parser._write('{a: 1} {b: 2} 3 ', false); while (status > 0) { rows.push([status, parser.value()]); status = parser._write(); } console.log(JSON.stringify(rows));

Run it:

node stream-lowlevel.mjs

Output:

[[2,{"a":1}],[2,{"b":2}],[2,3]]

Note that JSON6.begin() with no callback is allowed when you use _write, but write() then throws, since write can only hand values to the callback. That behavior was added in 1.1.6; earlier versions required the callback.

Choosing Between parse and begin

SituationUse
One document in one string (a config file)JSON6.parse
An array of many records you can hold in memoryJSON6.parse on the array
Many top-level values in one string or fileJSON6.begin
A socket, pipe or file read in chunksJSON6.begin with a decoded string
A single value larger than memoryNeither. The value is built whole.
Interchange with tools that expect JSON LinesJSON.parse per line; JSON6 would accept input that those tools reject

Remember what the stream parser does not do. It does not split on newlines, it does not recover from errors by itself, it does not validate that the stream ended cleanly except for the unfinished object case above, and it cannot report progress inside a value. If you need those, read lines yourself and call JSON6.parse on each one, which gives you per-line error reporting for free at the cost of requiring one value per line.

FAQ

How do I parse many JSON6 values from one string?

Create a parser with JSON6.begin(callback), call write() with the string, then write('') to flush a trailing number. The callback fires once per value. Separate values with whitespace, not commas.

Why was my last number not delivered?

A number has no end marker, so the parser waits for the next character. Send a space or call write('') when your input ends.

Does the parser handle chunks that split a string or an escape sequence?

Yes, in my tests objects, arrays, strings, escape sequences, block comments, numbers and keywords were all rejoined correctly across chunk boundaries. The caveat is bytes: decode to strings before you call write, or split multi-byte characters will be corrupted.

What happens after a syntax error?

write throws and the parser stays failed. Values completed earlier in the same call were already delivered. Call reset() to continue, and start the next write at a value boundary.

Can I use async iteration or pipeline()?

Wrap the parser in a Transform stream as shown above, then use pipeline() or for await on the readable side. The package does not ship such a wrapper.

Is there a way to see the byte offset or line of a value?

Not in the public API. Errors include a [line:column] suffix in the message, and nothing else exposes positions.

Stream JSON6 in Your App

Wrap the parser in a Transform stream, or start with the basic write loop.