Skip to content

Commit bdf563a

Browse files
committed
Fix parser edge cases and release 0.1.1
1 parent a9c06b8 commit bdf563a

8 files changed

Lines changed: 181 additions & 32 deletions

File tree

package-lock.json

Lines changed: 3 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
],
88
"scripts": {
99
"build": "npm run build --workspaces",
10+
"pretest": "npm run build",
1011
"test": "node --test test/**/*.test.js",
1112
"bench": "node test/bench/streaming-benchmark.js",
1213
"demo": "npx serve ."

packages/core/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@a5omic/streamjson",
3-
"version": "0.1.0",
3+
"version": "0.1.1",
44
"description": "O(1) streaming JSON parser for the AI era. Zero dependencies. ~4KB gzipped.",
55
"type": "module",
66
"main": "dist/index.js",

packages/core/src/stream-json.ts

Lines changed: 78 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ export class StreamJSON {
4949
this.parseEscape(c, chunk[i])
5050
break
5151
case State.IN_STRING_UNICODE:
52-
this.parseUnicode(chunk[i])
52+
this.parseUnicode(c, chunk[i])
5353
break
5454
case State.IN_NUMBER:
5555
this.parseNumber(c, chunk[i])
@@ -162,10 +162,13 @@ export class StreamJSON {
162162
}
163163
}
164164

165-
private parseUnicode(ch: string): void {
165+
private parseUnicode(c: number, ch: string): void {
166166
if (!HEX.test(ch)) {
167167
this.emitError(`Invalid hex digit in unicode escape: ${ch}`)
168+
this.uniAccum = ''
169+
this.uniCount = 0
168170
this.state = State.IN_STRING
171+
this.parseString(c, ch)
169172
return
170173
}
171174
this.uniAccum += ch
@@ -193,13 +196,6 @@ export class StreamJSON {
193196

194197
private parseNumber(c: number, ch: string): void {
195198
if (c >= 0x30 && c <= 0x39) {
196-
if ((this.numBuf === '0' || this.numBuf === '-0') && c >= 0x30 && c <= 0x39) {
197-
// leading zero followed by another digit — emit error, end the 0, reprocess
198-
this.emitError(`Leading zero in number: ${this.numBuf}${ch}`)
199-
this.endNumber()
200-
this.reprocessAfterNumber(c, ch)
201-
return
202-
}
203199
this.numBuf += ch
204200
} else if (c === 0x2E || c === 0x65 || c === 0x45) {
205201
this.numBuf += ch
@@ -317,11 +313,15 @@ export class StreamJSON {
317313
if (this.stack.length > 0 && this.stack[this.stack.length - 1].type === 0) {
318314
this.endContainer()
319315
this.afterValue()
316+
} else {
317+
this.emitError('Unexpected }')
320318
}
321319
} else if (c === 0x5D) { // ]
322320
if (this.stack.length > 0 && this.stack[this.stack.length - 1].type === 1) {
323321
this.endContainer()
324322
this.afterValue()
323+
} else {
324+
this.emitError('Unexpected ]')
325325
}
326326
} else if (c === 0x20 || c === 0x09 || c === 0x0A || c === 0x0D) {
327327
// whitespace
@@ -349,6 +349,8 @@ export class StreamJSON {
349349
this.parseExpectValue(c, ch)
350350
}
351351
}
352+
} else {
353+
this.emitError(`Unexpected character: ${ch}`)
352354
}
353355
}
354356

@@ -392,27 +394,30 @@ export class StreamJSON {
392394
private endNumber(): void {
393395
const buf = this.numBuf
394396
this.numBuf = ''
395-
const val = Number(buf)
396-
if (Number.isNaN(val) || !Number.isFinite(val)) {
397-
this.emitError(`Invalid number: ${buf}`)
398-
// assign null to preserve position — prevents key drops (objects), index shifts (arrays), and undefined root
399-
if (this.stack.length > 0) {
400-
const top = this.stack[this.stack.length - 1]
401-
if ((top.type === 0 && top.key !== null) || top.type === 1) {
402-
this.assignValue(null)
403-
}
404-
} else {
405-
this.assignValue(null)
406-
}
397+
const analysis = this.analyzeNumber(buf)
398+
if (analysis.kind === 'leading_zero') {
399+
this.emitError(`Leading zero in number: ${buf}`)
400+
this.assignInvalidNumber()
407401
this.afterValue()
408402
return
409403
}
410-
if (buf.length > 1 && buf[0] === '0' && buf[1] >= '0' && buf[1] <= '9') {
411-
this.emitError(`Leading zero in number: ${buf}`)
404+
if (analysis.kind === 'invalid') {
405+
this.emitError(`Invalid number: ${buf}`)
406+
this.assignInvalidNumber()
407+
this.afterValue()
408+
return
412409
}
413-
if (buf.endsWith('.')) {
410+
const valueText = analysis.kind === 'trailing_dot' ? buf.slice(0, -1) : buf
411+
if (analysis.kind === 'trailing_dot') {
414412
this.emitError(`Trailing dot in number: ${buf}`)
415413
}
414+
const val = Number(valueText)
415+
if (Number.isNaN(val) || !Number.isFinite(val)) {
416+
this.emitError(`Invalid number: ${buf}`)
417+
this.assignInvalidNumber()
418+
this.afterValue()
419+
return
420+
}
416421
this.assignValue(val)
417422
this.emit('value', this.currentPath(), val, true)
418423
this.afterValue()
@@ -477,6 +482,55 @@ export class StreamJSON {
477482
}
478483
}
479484

485+
private analyzeNumber(buf: string): { kind: 'valid' | 'trailing_dot' | 'leading_zero' | 'invalid' } {
486+
let i = 0
487+
const len = buf.length
488+
489+
if (len === 0) return { kind: 'invalid' }
490+
491+
if (buf[i] === '-') i++
492+
if (i === len) return { kind: 'invalid' }
493+
494+
if (buf[i] === '0') {
495+
i++
496+
if (i < len && buf[i] >= '0' && buf[i] <= '9') return { kind: 'leading_zero' }
497+
} else if (buf[i] >= '1' && buf[i] <= '9') {
498+
i++
499+
while (i < len && buf[i] >= '0' && buf[i] <= '9') i++
500+
} else {
501+
return { kind: 'invalid' }
502+
}
503+
504+
if (i < len && buf[i] === '.') {
505+
i++
506+
if (i === len) return { kind: 'trailing_dot' }
507+
if (buf[i] < '0' || buf[i] > '9') return { kind: 'invalid' }
508+
while (i < len && buf[i] >= '0' && buf[i] <= '9') i++
509+
}
510+
511+
if (i < len && (buf[i] === 'e' || buf[i] === 'E')) {
512+
i++
513+
if (i < len && (buf[i] === '+' || buf[i] === '-')) i++
514+
if (i === len) return { kind: 'invalid' }
515+
if (buf[i] < '0' || buf[i] > '9') return { kind: 'invalid' }
516+
while (i < len && buf[i] >= '0' && buf[i] <= '9') i++
517+
}
518+
519+
return i === len ? { kind: 'valid' } : { kind: 'invalid' }
520+
}
521+
522+
private assignInvalidNumber(): void {
523+
// Preserve object keys and array indexes when a malformed number cannot be recovered.
524+
if (this.stack.length > 0) {
525+
const top = this.stack[this.stack.length - 1]
526+
if ((top.type === 0 && top.key !== null) || top.type === 1) {
527+
this.assignValue(null)
528+
}
529+
} else {
530+
this.assignValue(null)
531+
}
532+
}
533+
480534
private flush(): void {
481535
if (this.state === State.IN_STRING || this.state === State.IN_STRING_ESCAPE || this.state === State.IN_STRING_UNICODE) {
482536
this.flushHighSurrogate()

packages/react/package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@a5omic/streamjson-react",
3-
"version": "0.1.0",
3+
"version": "0.1.1",
44
"description": "React hooks for StreamJSON — O(1) streaming JSON parser for LLMs",
55
"type": "module",
66
"main": "dist/index.js",
@@ -43,7 +43,7 @@
4343
},
4444
"peerDependencies": {
4545
"react": ">=18.0.0",
46-
"@a5omic/streamjson": ">=0.1.0"
46+
"@a5omic/streamjson": ">=0.1.1"
4747
},
4848
"devDependencies": {
4949
"@types/react": "^19.0.0",

packages/react/src/StreamJSON.tsx

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,16 +13,20 @@ export function StreamJSON<T = unknown>({
1313
children,
1414
...options
1515
}: StreamJSONProps<T>) {
16+
const emitPartial = options.emitPartial ?? false
1617
const parserRef = useRef<StreamJSONParser | null>(null)
1718
const prevContentRef = useRef('')
1819
const contentRef = useRef(content)
20+
const completeRef = useRef(complete)
21+
const emitPartialRef = useRef(emitPartial)
1922
const [gen, setGen] = useState(0)
2023
const [isComplete, setIsComplete] = useState(false)
2124

2225
contentRef.current = content
26+
completeRef.current = complete
2327

2428
if (!parserRef.current) {
25-
parserRef.current = new StreamJSONParser(options)
29+
parserRef.current = new StreamJSONParser({ emitPartial })
2630
}
2731

2832
useEffect(() => {
@@ -31,6 +35,23 @@ export function StreamJSON<T = unknown>({
3135
}
3236
}, [])
3337

38+
useEffect(() => {
39+
if (emitPartialRef.current === emitPartial) return
40+
41+
parserRef.current?.reset()
42+
43+
const parser = new StreamJSONParser({ emitPartial })
44+
const cur = contentRef.current
45+
if (cur.length > 0) parser.push(cur)
46+
if (completeRef.current) parser.end()
47+
48+
parserRef.current = parser
49+
prevContentRef.current = cur
50+
emitPartialRef.current = emitPartial
51+
setIsComplete(completeRef.current)
52+
setGen(g => g + 1)
53+
}, [emitPartial])
54+
3455
useEffect(() => {
3556
const parser = parserRef.current
3657
if (!parser) return

packages/react/src/useStreamJSON.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,14 @@ import { StreamJSON, type StreamJSONOptions } from '@a5omic/streamjson'
44
export interface UseStreamJSONOptions extends StreamJSONOptions {}
55

66
export function useStreamJSON<T = unknown>(options: UseStreamJSONOptions = {}) {
7+
const emitPartial = options.emitPartial ?? false
78
const parserRef = useRef<StreamJSON | null>(null)
9+
const emitPartialRef = useRef(emitPartial)
810
const [gen, setGen] = useState(0)
911
const [isComplete, setIsComplete] = useState(false)
1012

1113
if (!parserRef.current) {
12-
parserRef.current = new StreamJSON(options)
14+
parserRef.current = new StreamJSON({ emitPartial })
1315
}
1416

1517
useEffect(() => {
@@ -18,6 +20,15 @@ export function useStreamJSON<T = unknown>(options: UseStreamJSONOptions = {}) {
1820
}
1921
}, [])
2022

23+
useEffect(() => {
24+
if (emitPartialRef.current === emitPartial) return
25+
parserRef.current?.reset()
26+
parserRef.current = new StreamJSON({ emitPartial })
27+
emitPartialRef.current = emitPartial
28+
setIsComplete(false)
29+
setGen(g => g + 1)
30+
}, [emitPartial])
31+
2132
const push = useCallback((chunk: string) => {
2233
parserRef.current?.push(chunk)
2334
setGen(g => g + 1)

test/edge-cases.test.js

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -308,6 +308,16 @@ describe('edge cases', () => {
308308
p.end()
309309
assert.ok(errors.length > 0)
310310
})
311+
312+
it('truncated unicode escape reprocesses the closing quote', () => {
313+
const errors = []
314+
const p = new StreamJSON()
315+
p.on('error', (err) => errors.push(err.message))
316+
p.push('{"a":"\\u00"}')
317+
p.end()
318+
assert.ok(errors.some(e => e.includes('Invalid hex digit')))
319+
assert.deepEqual(p.get(), obj({ a: '' }))
320+
})
311321
})
312322

313323
describe('number validation', () => {
@@ -372,6 +382,7 @@ describe('edge cases', () => {
372382
p.push('[01]')
373383
p.end()
374384
assert.ok(errors.some(e => e.includes('Leading zero')))
385+
assert.deepEqual(p.get(), [null])
375386
})
376387

377388
it('trailing dot emits error', () => {
@@ -398,6 +409,37 @@ describe('edge cases', () => {
398409
p.push('00')
399410
p.end()
400411
assert.ok(errors.some(e => e.includes('Leading zero')))
412+
assert.equal(p.get(), null)
413+
})
414+
415+
it('leading zero in object preserves the key with null', () => {
416+
const errors = []
417+
const p = new StreamJSON()
418+
p.on('error', (err) => errors.push(err.message))
419+
p.push('{"x":01}')
420+
p.end()
421+
assert.ok(errors.some(e => e.includes('Leading zero')))
422+
assert.deepEqual(p.get(), obj({ x: null }))
423+
})
424+
425+
it('invalid exponent after decimal assigns null', () => {
426+
const errors = []
427+
const p = new StreamJSON()
428+
p.on('error', (err) => errors.push(err.message))
429+
p.push('1.e2')
430+
p.end()
431+
assert.ok(errors.some(e => e.includes('Invalid number')))
432+
assert.equal(p.get(), null)
433+
})
434+
435+
it('missing integer before decimal assigns null', () => {
436+
const errors = []
437+
const p = new StreamJSON()
438+
p.on('error', (err) => errors.push(err.message))
439+
p.push('-.1')
440+
p.end()
441+
assert.ok(errors.some(e => e.includes('Invalid number')))
442+
assert.equal(p.get(), null)
401443
})
402444
})
403445

@@ -419,6 +461,26 @@ describe('edge cases', () => {
419461
p.end()
420462
assert.ok(errors.some(e => e.includes('Unexpected ]')))
421463
})
464+
465+
it('mismatched ] after an object value emits error', () => {
466+
const errors = []
467+
const p = new StreamJSON()
468+
p.on('error', (err) => errors.push(err.message))
469+
p.push('{"a":1]')
470+
p.end()
471+
assert.ok(errors.some(e => e.includes('Unexpected ]')))
472+
assert.deepEqual(p.get(), obj({ a: 1 }))
473+
})
474+
475+
it('mismatched } after an array value emits error', () => {
476+
const errors = []
477+
const p = new StreamJSON()
478+
p.on('error', (err) => errors.push(err.message))
479+
p.push('[1,2}')
480+
p.end()
481+
assert.ok(errors.some(e => e.includes('Unexpected }')))
482+
assert.deepEqual(p.get(), [1, 2])
483+
})
422484
})
423485

424486
describe('truncated key recovery', () => {

0 commit comments

Comments
 (0)