Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 34 additions & 9 deletions src/modules/cursor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,13 @@
*/
class Cursor {
#baseOffset: number;
#bytes: Uint8Array;
#ended: boolean;
#owned: boolean;
#previousStart: number;
#previousEnd: number;
#peekPosition: number;
#peekError: Error | null;
#bytes: Uint8Array;

/**
* Creates a new Cursor instance starting with an initial chunk of bytes.
Expand All @@ -19,12 +20,13 @@ class Cursor {
*/
constructor(bytes: Uint8Array) {
this.#baseOffset = 0;
this.#bytes = bytes;
this.#ended = false;
this.#owned = false;
this.#previousStart = 0;
this.#previousEnd = 0;
this.#peekPosition = 0;
this.#peekError = null;
this.#bytes = bytes;
}

/**
Expand Down Expand Up @@ -87,25 +89,48 @@ class Cursor {

/**
* Appends a newly received chunk of bytes to the cursor.
* - **Fast-Path**: If all previous bytes were consumed, it simply reassigns the internal pointer.
* - **Slow-Path**: If there are unread bytes, it allocates a new buffer and merges them.
*
* @param bytes The new Uint8Array chunk arriving from the stream.
*/
appendBytes(bytes: Uint8Array): void {
const unread = this.unreadBytes();
const start = this.#previousEnd;

if (unread.length > 0) {
const merged = new Uint8Array(unread.length + bytes.length);
const capacity = start + unread.length + bytes.length;
const length = unread.length + bytes.length;

if (this.#owned && capacity <= this.#bytes.buffer.byteLength) {
const buffer = this.#bytes.buffer;

this.#bytes = new Uint8Array(buffer, 0, capacity);
this.#bytes.set(bytes, start + unread.length);

merged.set(unread, 0);
merged.set(bytes, unread.length);
return;
}

this.#bytes = merged;
if (this.#owned && length <= this.#bytes.buffer.byteLength) {
const buffer = this.#bytes.buffer;
const view = new Uint8Array(buffer);

view.copyWithin(0, start, start + unread.length);

this.#bytes = new Uint8Array(buffer, 0, length);
this.#bytes.set(bytes, unread.length);
} else {
const capacity = this.#owned ? this.#bytes.buffer.byteLength : 0;
const array = new Uint8Array(Math.max(length, capacity * 2));

array.set(unread, 0);
array.set(bytes, unread.length);

this.#bytes = new Uint8Array(array.buffer, 0, length);
this.#owned = true;
}
} else {
this.#bytes = bytes;
this.#owned = false;
}

if (this.#peekPosition > 0) {
this.#peekPosition -= this.#previousEnd;
}
Expand Down
47 changes: 40 additions & 7 deletions src/modules/decoder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import {
consumeNumber,
consumeSimpleNumber,
consumeSimpleString,
consumeString,
consumeStringResumable,
consumeTrue,
consumeWhitespace,
} from "#src/utils/wire";
Expand All @@ -28,6 +28,7 @@ import {
* @internal
*/
class Decoder {
#offset: number;
#cursor: Cursor;
#state: State;
#options: DecoderOptions;
Expand All @@ -39,6 +40,7 @@ class Decoder {
* @param options Decoder configuration options.
*/
constructor(bytes: Uint8Array, options: DecoderOptions) {
this.#offset = 0;
this.#cursor = new Cursor(bytes);
this.#state = new State(options);
this.#options = options;
Expand Down Expand Up @@ -197,6 +199,7 @@ class Decoder {
* and structural state.
*/
reset(): void {
this.#offset = 0;
this.#cursor = new Cursor(new Uint8Array());
this.#state = new State(this.#options);
}
Expand Down Expand Up @@ -407,8 +410,25 @@ class Decoder {
}

if (kind === KIND.STRING) {
return consumeSimpleString(bytes, position) ||
consumeString(bytes, position, !this.#options.allowInvalidUTF8);
if (this.#offset === 0) {
const size = consumeSimpleString(bytes, position);
if (size > 0) return size;
}

const result = consumeStringResumable(
bytes,
position,
this.#offset,
!this.#options.allowInvalidUTF8,
);

if (!result.completed) {
this.#offset = result.consumed;
return 0;
}

this.#offset = 0;
return result.consumed;
}

if (kind === KIND.NUMBER) {
Expand Down Expand Up @@ -529,14 +549,27 @@ class Decoder {
}

#consumeString(start: number): number {
let size = consumeSimpleString(this.#cursor.bytes, start);
let size = 0;

if (size === 0) {
size = consumeString(this.#cursor.bytes, start, !this.#options.allowInvalidUTF8);
if (this.#offset === 0) {
size = consumeSimpleString(this.#cursor.bytes, start);
}

if (size === 0) {
if (size === 0) {
const result = consumeStringResumable(
this.#cursor.bytes,
start,
this.#offset,
!this.#options.allowInvalidUTF8,
);

if (!result.completed) {
this.#offset = result.consumed;
return 0;
}

size = result.consumed;
this.#offset = 0;
}

if (this.#state.needObjectName()) {
Expand Down
38 changes: 31 additions & 7 deletions src/utils/wire.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,13 +112,33 @@ function consumeFalse(bytes: Uint8Array, position: number): number {
* @returns The number of bytes consumed if a string literal is found, otherwise 0.
*/
function consumeString(bytes: Uint8Array, position: number, validateUTF8 = true): number {
const result = consumeStringResumable(bytes, position, 0, validateUTF8);

return result.completed ? result.consumed : 0;
}

/**
* Consumes a string literal from the given position, supporting resumption from a previous offset.
* @param bytes The Uint8Array bytes to consume from.
* @param position The position in the bytes to start consuming.
* @param offset The relative number of bytes already scanned in previous chunks.
* @param validateUTF8 Whether to validate the string as UTF-8.
* @returns An object containing the number of bytes consumed (or the scan offset if incomplete) and whether the string is complete.
*/
function consumeStringResumable(
bytes: Uint8Array,
position: number,
offset: number,
validateUTF8 = true,
): { consumed: number; completed: boolean } {
if (position >= bytes.length || bytes[position] !== ASCII.QUOTE) {
return 0;
return { consumed: 0, completed: false };
}

let inEscape = false;
let index = offset > 0 ? position + offset : position + 1;

for (let index = position + 1; index < bytes.length; index++) {
for (; index < bytes.length; index++) {
const byte = bytes[index];

if (inEscape) {
Expand All @@ -127,12 +147,15 @@ function consumeString(bytes: Uint8Array, position: number, validateUTF8 = true)
}

if (byte === ASCII.BACKSLASH) {
if (index === bytes.length - 1) {
return { consumed: index - position, completed: false };
}
inEscape = true;
continue;
}

if (byte < ASCII.SPACE) {
return 0;
return { consumed: 0, completed: false };
}

if (byte === ASCII.QUOTE) {
Expand All @@ -141,16 +164,16 @@ function consumeString(bytes: Uint8Array, position: number, validateUTF8 = true)
if (validateUTF8) {
try {
decodeText(chunk, true);
} catch (_error) {
return 0;
} catch {
return { consumed: 0, completed: false };
}
}

return index - position + 1;
return { consumed: index - position + 1, completed: true };
}
}

return 0;
return { consumed: index - position, completed: false };
}

/**
Expand Down Expand Up @@ -332,6 +355,7 @@ export {
consumeSimpleNumber,
consumeSimpleString,
consumeString,
consumeStringResumable,
consumeTrue,
consumeWhitespace,
};
71 changes: 71 additions & 0 deletions tests/integration/decoder.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,77 @@ Deno.test("[integration] JSONTextDecoder", async (test) => {
assertEquals(decoder.readValue()?.text(), "1");
assertEquals(decoder.readValue()?.text(), "2");
});

await test.step("should read an object string value split across many chunks", () => {
const value = "a".repeat(64 * 1024);
const decoder = new JSONTextDecoder();

decoder.push(encodeText(`{"key":"${value.slice(0, 1024)}`));

assertEquals(decoder.readToken()?.kind, KIND.OBJECT_BEGIN);
assertEquals(decoder.readToken()?.asString(), "key");
assertEquals(decoder.readToken(), undefined);

for (let i = 1024; i < value.length; i += 1024) {
decoder.push(encodeText(value.slice(i, i + 1024)));
assertEquals(decoder.readToken(), undefined);
}

decoder.push(encodeText('"}'));
decoder.end();

const token = decoder.readToken();

assertEquals(token?.kind, KIND.STRING);
assertEquals(token?.asString(), value);
assertEquals(decoder.readToken()?.kind, KIND.OBJECT_END);
assertEquals(decoder.readToken(), undefined);
});

await test.step("should preserve an escaped quote split across chunks", () => {
const decoder = new JSONTextDecoder();
const first = '{"key":"say ' + "\\";
const second = '"hi\\""}';

decoder.push(encodeText(first));

assertEquals(decoder.readToken()?.kind, KIND.OBJECT_BEGIN);
assertEquals(decoder.readToken()?.asString(), "key");
assertEquals(decoder.readToken(), undefined);

decoder.push(encodeText(second));
decoder.end();

const token = decoder.readToken();

assertEquals(token?.kind, KIND.STRING);
assertEquals(token?.asString(), 'say "hi"');
assertEquals(decoder.readToken()?.kind, KIND.OBJECT_END);
assertEquals(decoder.readToken(), undefined);
});

await test.step("should read a complete object value after a long string spans many chunks", () => {
const value = "b".repeat(64 * 1024);
const decoder = new JSONTextDecoder();

decoder.push(encodeText(`{"key":"${value.slice(0, 1024)}`));

assertEquals(decoder.readValue(), undefined);

for (let i = 1024; i < value.length; i += 1024) {
decoder.push(encodeText(value.slice(i, i + 1024)));
assertEquals(decoder.readValue(), undefined);
}

decoder.push(encodeText('"}'));
decoder.end();

const parsed = decoder.readValue();

assertEquals(parsed?.kind, KIND.OBJECT_BEGIN);
assertEquals(parsed?.text(), `{"key":"${value}"}`);
assertEquals(decoder.readValue(), undefined);
});
});

await test.step("[scenario] options", async (test) => {
Expand Down
Loading
Loading