@atproto/lex-json 0.0.14 → 0.0.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,672 @@
1
+ import { LexValue, fromBase64, parseCid } from '@atproto/lex-data'
2
+ import { parseTypedBlobRef } from './blob.js'
3
+
4
+ const CHAR_TAB = 0x09
5
+ const CHAR_NEWLINE = 0x0a
6
+ const CHAR_CARRIAGE_RETURN = 0x0d
7
+ const CHAR_SPACE = 0x20
8
+ const CHAR_DOUBLE_QUOTE = 0x22
9
+ const CHAR_PLUS = 0x2b
10
+ const CHAR_COMMA = 0x2c
11
+ const CHAR_MINUS = 0x2d
12
+ const CHAR_PERIOD = 0x2e
13
+ const CHAR_SLASH = 0x2f
14
+ const CHAR_DIGIT_0 = 0x30
15
+ const CHAR_DIGIT_1 = 0x31
16
+ const CHAR_DIGIT_9 = 0x39
17
+ const CHAR_COLON = 0x3a
18
+ const CHAR_EQUAL = 0x3d
19
+ const CHAR_UPPER_A = 0x41
20
+ const CHAR_UPPER_E = 0x45
21
+ const CHAR_UPPER_F = 0x46
22
+ const CHAR_UPPER_Z = 0x5a
23
+ const CHAR_LEFT_BRACKET = 0x5b
24
+ const CHAR_BACKSLASH = 0x5c
25
+ const CHAR_RIGHT_BRACKET = 0x5d
26
+ const CHAR_UNDERSCORE = 0x5f
27
+ const CHAR_DOLLAR = 0x24
28
+ const CHAR_LOWER_A = 0x61
29
+ const CHAR_LOWER_B = 0x62
30
+ const CHAR_LOWER_E = 0x65
31
+ const CHAR_LOWER_F = 0x66
32
+ const CHAR_LOWER_L = 0x6c
33
+ const CHAR_LOWER_N = 0x6e
34
+ const CHAR_LOWER_R = 0x72
35
+ const CHAR_LOWER_S = 0x73
36
+ const CHAR_LOWER_T = 0x74
37
+ const CHAR_LOWER_U = 0x75
38
+ const CHAR_LOWER_Z = 0x7a
39
+ const CHAR_LEFT_BRACE = 0x7b
40
+ const CHAR_RIGHT_BRACE = 0x7d
41
+
42
+ const DECODER = new TextDecoder('utf-8', { fatal: true })
43
+
44
+ const BASE64_LOOKUP = new Int8Array(256)
45
+ BASE64_LOOKUP.fill(-1)
46
+ for (let i = CHAR_UPPER_A; i <= CHAR_UPPER_Z; i++)
47
+ BASE64_LOOKUP[i] = i - CHAR_UPPER_A
48
+ for (let i = CHAR_LOWER_A; i <= CHAR_LOWER_Z; i++)
49
+ BASE64_LOOKUP[i] = i - CHAR_LOWER_A + 26
50
+ for (let i = CHAR_DIGIT_0; i <= CHAR_DIGIT_9; i++)
51
+ BASE64_LOOKUP[i] = i - CHAR_DIGIT_0 + 52
52
+ BASE64_LOOKUP[CHAR_PLUS] = 62
53
+ BASE64_LOOKUP[CHAR_MINUS] = 62
54
+ BASE64_LOOKUP[CHAR_SLASH] = 63
55
+ BASE64_LOOKUP[CHAR_UNDERSCORE] = 63
56
+
57
+ const HEX_LOOKUP = new Int8Array(256)
58
+ HEX_LOOKUP.fill(-1)
59
+ for (let i = CHAR_DIGIT_0; i <= CHAR_DIGIT_9; i++)
60
+ HEX_LOOKUP[i] = i - CHAR_DIGIT_0
61
+ for (let i = CHAR_UPPER_A; i <= CHAR_UPPER_F; i++)
62
+ HEX_LOOKUP[i] = i - CHAR_UPPER_A + 10
63
+ for (let i = CHAR_LOWER_A; i <= CHAR_LOWER_F; i++)
64
+ HEX_LOOKUP[i] = i - CHAR_LOWER_A + 10
65
+
66
+ // Thresholds for optimization heuristics
67
+ export const BASE64_NATIVE_THRESHOLD = 256 // Use native decoding for base64 strings > this length
68
+
69
+ export class JsonBytesDecoder {
70
+ private pos = 0
71
+
72
+ constructor(
73
+ private readonly data: Uint8Array,
74
+ private readonly strict = true,
75
+ ) {}
76
+
77
+ decode(): LexValue {
78
+ this.skipWhitespace()
79
+ const value = this.parseValue()
80
+ this.skipWhitespace()
81
+
82
+ if (this.pos < this.data.length) {
83
+ throw new TypeError(`Unexpected data after JSON at position ${this.pos}`)
84
+ }
85
+
86
+ return value
87
+ }
88
+
89
+ private parseValue(): LexValue {
90
+ const ch = this.data[this.pos]
91
+
92
+ // Optimize by checking most common value types first
93
+ // Strings and objects are very common in real JSON
94
+ if (ch === CHAR_DOUBLE_QUOTE) {
95
+ return this.parseString()
96
+ } else if (ch === CHAR_LEFT_BRACE) {
97
+ return this.parseObject()
98
+ } else if (ch === CHAR_LEFT_BRACKET) {
99
+ return this.parseArray()
100
+ } else if (ch === CHAR_LOWER_T) {
101
+ return this.parseTrue()
102
+ } else if (ch === CHAR_LOWER_F) {
103
+ return this.parseFalse()
104
+ } else if (ch === CHAR_LOWER_N) {
105
+ return this.parseNull()
106
+ } else {
107
+ // Fallback for unexpected input
108
+ return this.parseNumber()
109
+ }
110
+ }
111
+
112
+ private parseObject(): LexValue {
113
+ this.pos++ // skip '{'
114
+ this.skipWhitespace()
115
+
116
+ // Check for empty object
117
+ if (this.data[this.pos] === CHAR_RIGHT_BRACE) {
118
+ this.pos++
119
+ return {}
120
+ }
121
+
122
+ let obj: Record<string, LexValue>
123
+ let hasDollarKey = false // Track if we've seen any $ key for validation
124
+
125
+ for (let i = 0; ; i++) {
126
+ this.skipWhitespace()
127
+
128
+ // Parse key
129
+ if (this.data[this.pos] !== CHAR_DOUBLE_QUOTE) {
130
+ throw new TypeError(`Expected string key at position ${this.pos}`)
131
+ }
132
+
133
+ // Track special keys for later validation
134
+ if (this.data[this.pos + 1] === CHAR_DOLLAR) {
135
+ hasDollarKey = true
136
+ }
137
+
138
+ const key = this.parseString()
139
+
140
+ // Prevent prototype pollution
141
+ if (key === '__proto__') {
142
+ throw new TypeError('JSON object keys cannot be "__proto__"')
143
+ }
144
+
145
+ this.skipWhitespace()
146
+
147
+ // Parse colon
148
+ if (this.data[this.pos] !== CHAR_COLON) {
149
+ throw new TypeError(`Expected ':' at position ${this.pos}`)
150
+ }
151
+ this.pos++
152
+ this.skipWhitespace()
153
+
154
+ // Parse $bytes or $link if it's the first and only key
155
+ if (i === 0) {
156
+ if (key === '$bytes' && this.data[this.pos] === CHAR_DOUBLE_QUOTE) {
157
+ const initialPos = this.pos
158
+ const b64Start = initialPos + 1
159
+ const b64End = this.data.indexOf(CHAR_DOUBLE_QUOTE, b64Start)
160
+ if (b64End !== -1) {
161
+ this.pos = b64End + 1
162
+ this.skipWhitespace()
163
+ if (this.data[this.pos] === CHAR_RIGHT_BRACE) {
164
+ this.pos++
165
+
166
+ const base64Len = b64End - b64Start
167
+
168
+ try {
169
+ // Use native decoding for large base64 strings (much faster
170
+ // based on benchmarks)
171
+ if (base64Len > BASE64_NATIVE_THRESHOLD) {
172
+ const b64Str = this.decodeUnescapedString(b64Start, b64End)
173
+ return fromBase64(b64Str) // Validate and convert to LexValue bytes
174
+ }
175
+
176
+ // Manual decoding for smaller strings (optimized path)
177
+ // Skip padding characters
178
+ let b64EndNoPadding = b64End
179
+ while (
180
+ b64EndNoPadding > b64Start &&
181
+ this.data[b64EndNoPadding - 1] === CHAR_EQUAL
182
+ ) {
183
+ b64EndNoPadding--
184
+ }
185
+
186
+ const base64LenNoPadding = b64EndNoPadding - b64Start
187
+ const bytesLen = Math.floor((base64LenNoPadding * 3) / 4)
188
+ const result = new Uint8Array(bytesLen)
189
+
190
+ for (
191
+ let i = b64Start, j = 0;
192
+ i <= b64EndNoPadding - 4;
193
+ i += 4
194
+ ) {
195
+ const chunk =
196
+ (this.base64Value(this.data[i]) << 18) |
197
+ (this.base64Value(this.data[i + 1]) << 12) |
198
+ (this.base64Value(this.data[i + 2]) << 6) |
199
+ this.base64Value(this.data[i + 3])
200
+
201
+ result[j++] = (chunk >> 16) & 0xff
202
+ result[j++] = (chunk >> 8) & 0xff
203
+ result[j++] = chunk & 0xff
204
+ }
205
+
206
+ // Handle remaining characters (if any)
207
+ if (base64LenNoPadding % 4 === 2) {
208
+ const chunk =
209
+ (this.base64Value(this.data[b64EndNoPadding - 2]) << 18) |
210
+ (this.base64Value(this.data[b64EndNoPadding - 1]) << 12)
211
+ result[bytesLen - 1] = (chunk >> 16) & 0xff
212
+ } else if (base64LenNoPadding % 4 === 3) {
213
+ const chunk =
214
+ (this.base64Value(this.data[b64EndNoPadding - 3]) << 18) |
215
+ (this.base64Value(this.data[b64EndNoPadding - 2]) << 12) |
216
+ (this.base64Value(this.data[b64EndNoPadding - 1]) << 6)
217
+ result[bytesLen - 2] = (chunk >> 16) & 0xff
218
+ result[bytesLen - 1] = (chunk >> 8) & 0xff
219
+ }
220
+
221
+ return result
222
+ } catch (cause) {
223
+ if (this.strict) {
224
+ throw new TypeError('Invalid $bytes object', { cause })
225
+ }
226
+ // ignore and parse as regular object
227
+ }
228
+ }
229
+ }
230
+
231
+ this.pos = initialPos // reset position to parse string properly
232
+ } else if (
233
+ key === '$link' &&
234
+ this.data[this.pos] === CHAR_DOUBLE_QUOTE
235
+ ) {
236
+ const initialPos = this.pos
237
+ const cidStart = initialPos + 1
238
+ const cidEnd = this.data.indexOf(CHAR_DOUBLE_QUOTE, cidStart)
239
+ if (cidEnd !== -1) {
240
+ this.pos = cidEnd + 1
241
+ this.skipWhitespace()
242
+ if (this.data[this.pos] === CHAR_RIGHT_BRACE) {
243
+ this.pos++
244
+ const cidStr = this.decodeUnescapedString(cidStart, cidEnd)
245
+ try {
246
+ return parseCid(cidStr)
247
+ } catch (cause) {
248
+ if (this.strict) {
249
+ throw new TypeError('Invalid $link object', { cause })
250
+ }
251
+ // ignore
252
+ }
253
+ }
254
+ }
255
+
256
+ this.pos = initialPos // reset position to parse string properly
257
+ }
258
+ }
259
+
260
+ // Parse value
261
+ obj ??= {}
262
+ obj[key] = this.parseValue()
263
+
264
+ this.skipWhitespace()
265
+
266
+ const next = this.data[this.pos]
267
+ if (next === CHAR_RIGHT_BRACE) {
268
+ this.pos++
269
+ break
270
+ } else if (next === CHAR_COMMA) {
271
+ this.pos++
272
+ } else {
273
+ throw new TypeError(`Expected ',' or '}' at position ${this.pos}`)
274
+ }
275
+ }
276
+
277
+ // In strict mode, validate special objects with extra keys
278
+ // Only check if we've seen a $ key (optimization)
279
+ if (hasDollarKey && this.strict) {
280
+ if (obj.$bytes !== undefined) {
281
+ throw new TypeError('Invalid $bytes object')
282
+ } else if (obj.$link !== undefined) {
283
+ throw new TypeError('Invalid $link object')
284
+ } else if (obj.$type === 'blob') {
285
+ const blob = parseTypedBlobRef(obj, { strict: this.strict })
286
+ if (blob) return blob
287
+ throw new TypeError(`Invalid blob object`)
288
+ } else if (obj.$type !== undefined) {
289
+ if (typeof obj.$type !== 'string') {
290
+ throw new TypeError(`Invalid $type property (${typeof obj.$type})`)
291
+ } else if (obj.$type.length === 0) {
292
+ throw new TypeError(`Empty $type property`)
293
+ }
294
+ }
295
+ }
296
+
297
+ return obj
298
+ }
299
+
300
+ private parseArray(): LexValue[] {
301
+ this.pos++ // skip '['
302
+ this.skipWhitespace()
303
+
304
+ const arr: LexValue[] = []
305
+
306
+ // Check for empty array
307
+ if (this.data[this.pos] === CHAR_RIGHT_BRACKET) {
308
+ this.pos++
309
+ return arr
310
+ }
311
+
312
+ for (;;) {
313
+ this.skipWhitespace()
314
+ arr.push(this.parseValue())
315
+ this.skipWhitespace()
316
+
317
+ const next = this.data[this.pos]
318
+ if (next === CHAR_RIGHT_BRACKET) {
319
+ this.pos++
320
+ break
321
+ } else if (next === CHAR_COMMA) {
322
+ this.pos++
323
+ } else {
324
+ throw new TypeError(`Expected ',' or ']' at position ${this.pos}`)
325
+ }
326
+ }
327
+
328
+ return arr
329
+ }
330
+
331
+ private parseString(): string {
332
+ this.pos++ // skip opening quote
333
+ const start = this.pos
334
+
335
+ // Fast path: scan for quote, checking for escapes and control chars inline
336
+ // Optimized for the common case of strings without escapes
337
+ let i = this.pos
338
+ while (i < this.data.length) {
339
+ const ch = this.data[i]
340
+
341
+ if (ch === CHAR_DOUBLE_QUOTE) {
342
+ // Found end quote - fast path success
343
+ this.pos = i + 1
344
+ return this.decodeUnescapedString(start, i)
345
+ } else if (ch === CHAR_BACKSLASH) {
346
+ // Found escape or control character - need slow path
347
+ break
348
+ } else if (ch < 0x20) {
349
+ throw new TypeError(`Unescaped control character at position ${i}`)
350
+ }
351
+ i++
352
+ }
353
+
354
+ // Slow path: handle escapes or control characters
355
+ if (i >= this.data.length) {
356
+ throw new TypeError('Unterminated string')
357
+ }
358
+
359
+ // We hit a backslash - need to process escape sequences
360
+ let result = ''
361
+ let segmentStart = start
362
+
363
+ this.pos = i
364
+ while (this.pos < this.data.length) {
365
+ const ch = this.data[this.pos]
366
+
367
+ if (ch === CHAR_DOUBLE_QUOTE) {
368
+ // Found end of string
369
+ if (segmentStart < this.pos) {
370
+ result += this.decodeUnescapedString(segmentStart, this.pos)
371
+ }
372
+ this.pos++
373
+ return result
374
+ } else if (ch === CHAR_BACKSLASH) {
375
+ // Process escape sequence
376
+ if (segmentStart < this.pos) {
377
+ result += this.decodeUnescapedString(segmentStart, this.pos)
378
+ }
379
+ this.pos++ // skip backslash
380
+ result += this.parseEscapeSequence()
381
+ segmentStart = this.pos
382
+ } else if (ch < 0x20) {
383
+ throw new TypeError(
384
+ `Unescaped control character at position ${this.pos}`,
385
+ )
386
+ } else {
387
+ this.pos++
388
+ }
389
+ }
390
+
391
+ throw new TypeError('Unterminated string')
392
+ }
393
+
394
+ private parseEscapeSequence(): string {
395
+ const ch = this.data[this.pos++]
396
+
397
+ switch (ch) {
398
+ case CHAR_DOUBLE_QUOTE:
399
+ return '"'
400
+ case CHAR_BACKSLASH:
401
+ return '\\'
402
+ case CHAR_SLASH:
403
+ return '/'
404
+ case CHAR_LOWER_B:
405
+ return '\b'
406
+ case CHAR_LOWER_F:
407
+ return '\f'
408
+ case CHAR_LOWER_N:
409
+ return '\n'
410
+ case CHAR_LOWER_R:
411
+ return '\r'
412
+ case CHAR_LOWER_T:
413
+ return '\t'
414
+ case CHAR_LOWER_U:
415
+ return this.parseUnicodeEscape()
416
+ default:
417
+ throw new TypeError(`Invalid escape sequence at position ${this.pos}`)
418
+ }
419
+ }
420
+
421
+ private parseUnicodeEscape(): string {
422
+ // Parse \uXXXX
423
+ let codePoint = 0
424
+ for (let i = 0; i < 4; i++) {
425
+ const ch = this.data[this.pos++]
426
+ const hex = this.hexValue(ch)
427
+ codePoint = (codePoint << 4) | hex
428
+ }
429
+
430
+ // Handle surrogate pairs
431
+ if (codePoint >= 0xd800 && codePoint <= 0xdbff) {
432
+ // High surrogate, check if followed by low surrogate
433
+ if (
434
+ this.pos + 5 < this.data.length &&
435
+ this.data[this.pos] === CHAR_BACKSLASH &&
436
+ this.data[this.pos + 1] === CHAR_LOWER_U
437
+ ) {
438
+ // Save position in case we need to backtrack
439
+ const savedPos = this.pos
440
+ this.pos += 2
441
+ let low = 0
442
+ for (let i = 0; i < 4; i++) {
443
+ const ch = this.data[this.pos++]
444
+ const hex = this.hexValue(ch)
445
+ low = (low << 4) | hex
446
+ }
447
+ // Check if it's a valid low surrogate
448
+ if (low >= 0xdc00 && low <= 0xdfff) {
449
+ // Valid pair - combine into single codepoint
450
+ codePoint = 0x10000 + ((codePoint - 0xd800) << 10) + (low - 0xdc00)
451
+ } else {
452
+ // Not a low surrogate - backtrack so it gets processed separately
453
+ this.pos = savedPos
454
+ }
455
+ }
456
+ }
457
+
458
+ return String.fromCodePoint(codePoint)
459
+ }
460
+
461
+ private hexValue(ch: number): number {
462
+ const value = HEX_LOOKUP[ch]
463
+ if (value !== -1) return value
464
+ throw new TypeError(`Invalid unicode escape at position ${this.pos}`)
465
+ }
466
+
467
+ private base64Value(ch: number): number {
468
+ const value = BASE64_LOOKUP[ch]
469
+ if (value !== -1) return value
470
+ throw new TypeError(
471
+ `Invalid base64 character: ${String.fromCharCode(ch)} at position ${this.pos}`,
472
+ )
473
+ }
474
+
475
+ private decodeUnescapedString(start: number, end: number): string {
476
+ const len = end - start
477
+ if (len === 0) return ''
478
+
479
+ // Fast path for very short ASCII strings (common for object keys like "id", "name", etc.)
480
+ // Heuristic: only worth it for strings <= 20 chars where String.fromCharCode is faster
481
+ // This is a hot path for object keys
482
+ if (len <= 20) {
483
+ let result = ''
484
+ for (let i = start; i < end; i++) {
485
+ const byte = this.data[i]
486
+ if (byte > 0x7f) {
487
+ // Hit non-ASCII, fall back to TextDecoder for full UTF-8 decoding
488
+ const subView = new Uint8Array(
489
+ this.data.buffer,
490
+ this.data.byteOffset + start,
491
+ len,
492
+ )
493
+ return DECODER.decode(subView)
494
+ }
495
+ result += String.fromCharCode(byte)
496
+ }
497
+ return result
498
+ }
499
+
500
+ // For longer strings, use utf8FromBytes directly (it's highly optimized)
501
+ const subView = new Uint8Array(
502
+ this.data.buffer,
503
+ this.data.byteOffset + start,
504
+ len,
505
+ )
506
+ return DECODER.decode(subView)
507
+ }
508
+
509
+ private parseNumber(): number {
510
+ const start = this.pos
511
+
512
+ let sign = 1
513
+ let int = 0
514
+ let decimal = 0
515
+ let expSign = 1
516
+ let exp = 0
517
+
518
+ // Parse sign
519
+ if (this.data[this.pos] === CHAR_MINUS) {
520
+ sign = -1
521
+ this.pos++
522
+ }
523
+
524
+ // Parse integer part
525
+ if (this.data[this.pos] === CHAR_DIGIT_0) {
526
+ this.pos++
527
+ // Leading zero must be followed by decimal, exponent, or end
528
+ } else if (
529
+ // Note: cannot start with "0"
530
+ this.data[this.pos] >= CHAR_DIGIT_1 &&
531
+ this.data[this.pos] <= CHAR_DIGIT_9
532
+ ) {
533
+ do {
534
+ int = int * 10 + (this.data[this.pos] - CHAR_DIGIT_0)
535
+ this.pos++
536
+ } while (
537
+ this.pos < this.data.length &&
538
+ this.data[this.pos] >= CHAR_DIGIT_0 &&
539
+ this.data[this.pos] <= CHAR_DIGIT_9
540
+ )
541
+ } else {
542
+ throw new TypeError(`Unexpected character at position ${this.pos}`)
543
+ }
544
+
545
+ // Strict mode validation is deferred until after decimal/exponent parsing
546
+ // so that we can include the complete number value in the error message.
547
+
548
+ // Parse decimal part
549
+ if (this.pos < this.data.length && this.data[this.pos] === CHAR_PERIOD) {
550
+ this.pos++
551
+ if (
552
+ this.pos >= this.data.length ||
553
+ this.data[this.pos] < CHAR_DIGIT_0 ||
554
+ this.data[this.pos] > CHAR_DIGIT_9
555
+ ) {
556
+ throw new TypeError(`Invalid number at position ${start}`)
557
+ }
558
+ let decimalPlace = 0.1
559
+ do {
560
+ decimal += (this.data[this.pos] - CHAR_DIGIT_0) * decimalPlace
561
+ decimalPlace *= 0.1
562
+ this.pos++
563
+ } while (
564
+ this.pos < this.data.length &&
565
+ this.data[this.pos] >= CHAR_DIGIT_0 &&
566
+ this.data[this.pos] <= CHAR_DIGIT_9
567
+ )
568
+ }
569
+
570
+ // Parse exponent part
571
+ if (
572
+ this.pos < this.data.length &&
573
+ (this.data[this.pos] === CHAR_LOWER_E ||
574
+ this.data[this.pos] === CHAR_UPPER_E)
575
+ ) {
576
+ this.pos++
577
+ if (
578
+ this.pos < this.data.length &&
579
+ (this.data[this.pos] === CHAR_PLUS ||
580
+ this.data[this.pos] === CHAR_MINUS)
581
+ ) {
582
+ expSign = this.data[this.pos] === CHAR_MINUS ? -1 : 1
583
+ this.pos++ // skip + or -
584
+ }
585
+ if (
586
+ this.pos >= this.data.length ||
587
+ this.data[this.pos] < CHAR_DIGIT_0 ||
588
+ this.data[this.pos] > CHAR_DIGIT_9
589
+ ) {
590
+ throw new TypeError(`Invalid number at position ${start}`)
591
+ }
592
+ do {
593
+ exp = exp * 10 + (this.data[this.pos] - CHAR_DIGIT_0)
594
+ this.pos++
595
+ } while (
596
+ this.pos < this.data.length &&
597
+ this.data[this.pos] >= CHAR_DIGIT_0 &&
598
+ this.data[this.pos] <= CHAR_DIGIT_9
599
+ )
600
+ }
601
+
602
+ const num = sign * (int + decimal) * Math.pow(10, expSign * exp)
603
+
604
+ if (this.strict && !Number.isSafeInteger(num)) {
605
+ throw new TypeError(`Invalid non-integer number: ${num}`)
606
+ }
607
+
608
+ return num
609
+ }
610
+
611
+ private parseTrue(): boolean {
612
+ if (
613
+ this.pos + 4 <= this.data.length &&
614
+ this.data[this.pos] === CHAR_LOWER_T &&
615
+ this.data[this.pos + 1] === CHAR_LOWER_R &&
616
+ this.data[this.pos + 2] === CHAR_LOWER_U &&
617
+ this.data[this.pos + 3] === CHAR_LOWER_E
618
+ ) {
619
+ this.pos += 4
620
+ return true
621
+ }
622
+ throw new TypeError(`Unexpected token at position ${this.pos}`)
623
+ }
624
+
625
+ private parseFalse(): boolean {
626
+ if (
627
+ this.pos + 5 <= this.data.length &&
628
+ this.data[this.pos] === CHAR_LOWER_F &&
629
+ this.data[this.pos + 1] === CHAR_LOWER_A &&
630
+ this.data[this.pos + 2] === CHAR_LOWER_L &&
631
+ this.data[this.pos + 3] === CHAR_LOWER_S &&
632
+ this.data[this.pos + 4] === CHAR_LOWER_E
633
+ ) {
634
+ this.pos += 5
635
+ return false
636
+ }
637
+ throw new TypeError(`Unexpected token at position ${this.pos}`)
638
+ }
639
+
640
+ private parseNull(): null {
641
+ if (
642
+ this.pos + 4 <= this.data.length &&
643
+ this.data[this.pos] === CHAR_LOWER_N &&
644
+ this.data[this.pos + 1] === CHAR_LOWER_U &&
645
+ this.data[this.pos + 2] === CHAR_LOWER_L &&
646
+ this.data[this.pos + 3] === CHAR_LOWER_L
647
+ ) {
648
+ this.pos += 4
649
+ return null
650
+ }
651
+ throw new TypeError(`Unexpected token at position ${this.pos}`)
652
+ }
653
+
654
+ private skipWhitespace(): void {
655
+ // Optimized: check most common case (space) first, and use <= for compact check
656
+ while (this.pos < this.data.length) {
657
+ const ch = this.data[this.pos]
658
+ // Optimize for the most common case: space (0x20)
659
+ if (ch === CHAR_SPACE) {
660
+ this.pos++
661
+ } else if (
662
+ ch === CHAR_TAB ||
663
+ ch === CHAR_NEWLINE ||
664
+ ch === CHAR_CARRIAGE_RETURN
665
+ ) {
666
+ this.pos++
667
+ } else {
668
+ break
669
+ }
670
+ }
671
+ }
672
+ }