From fa04ade1cdc0dfbb33888c4060741bd9f1ea8b4b Mon Sep 17 00:00:00 2001 From: eden <198768181+lcweden@users.noreply.github.com> Date: Mon, 25 May 2026 07:51:53 +0000 Subject: [PATCH 1/5] chore: rename publish workflow file to .yml --- .github/workflows/{publish.yaml => publish.yml} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename .github/workflows/{publish.yaml => publish.yml} (100%) diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yml similarity index 100% rename from .github/workflows/publish.yaml rename to .github/workflows/publish.yml From e0fa48b57f55873ed7879268652ad0deb78da10b Mon Sep 17 00:00:00 2001 From: eden <198768181+lcweden@users.noreply.github.com> Date: Mon, 25 May 2026 07:52:42 +0000 Subject: [PATCH 2/5] docs: improve documentation for constants --- src/common/constants.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/common/constants.ts b/src/common/constants.ts index 7d8ffa1..a090798 100644 --- a/src/common/constants.ts +++ b/src/common/constants.ts @@ -1,3 +1,4 @@ +/** ASCII byte values used for JSON parsing and encoding. */ const ASCII = { TAB: 0x09, LINE_FEED: 0x0A, @@ -51,6 +52,7 @@ const ASCII = { DELETE: 0x7F, } as const; +/** Unicode escape sequences for safe HTML and JavaScript embedding. */ const UNICODE = { OPEN_ANGLED_BRACKET: "\\u003c", CLOSE_ANGLED_BRACKET: "\\u003e", @@ -59,6 +61,7 @@ const UNICODE = { PARAGRAPH_SEPARATOR: "\\u2029", } as const; +/** String discriminants identifying the structural role of a JSON token. */ const KIND = { NULL: "null", FALSE: "false", @@ -71,13 +74,16 @@ const KIND = { ARRAY_END: "]", } as const; +/** Maximum JSON nesting depth supported by the decoder and encoder. */ const MAX_NESTING_DEPTH = 10_000; +/** Default option values for decoding. */ const DEFAULT_DECODER_OPTIONS = { allowDuplicateNames: false, allowInvalidUTF8: false, } as const; +/** Default option values for encoding. */ const DEFAULT_ENCODER_OPTIONS = { escapeForHTML: false, escapeForJS: false, @@ -89,16 +95,19 @@ const DEFAULT_ENCODER_OPTIONS = { indentPrefix: "", } as const; +/** JSON Path identifier types. */ const IDENTIFIER = { ROOT: 0, CURRENT: 1, } as const; +/** JSON Path segment kinds. */ const SEGMENT = { CHILD: 0, DESCENDANT: 1, } as const; +/** JSON Path selector kinds. */ const SELECTOR = { NAME: 0, WILDCARD: 1, From 6cafdf7bfe2d320615c7fec6211371bc8549f762 Mon Sep 17 00:00:00 2001 From: eden <198768181+lcweden@users.noreply.github.com> Date: Mon, 25 May 2026 07:53:49 +0000 Subject: [PATCH 3/5] docs: enhance documentation and internal structure for JSON processing modules --- src/api/decoder.ts | 21 +++++--- src/api/encoder.ts | 10 ++-- src/modules/automaton.ts | 70 +++++++++++++++++++++++++- src/modules/cursor.ts | 75 ++++++++++++++++++++++++++++ src/modules/decoder.ts | 104 +++++++++++++++++++++++++++++++++++++++ src/modules/encoder.ts | 65 ++++++++++++++++++++++++ src/modules/entry.ts | 65 +++++++++++++++++++++++- src/modules/path.ts | 8 +-- src/modules/pointer.ts | 49 ++++++++++++++++++ src/modules/stack.ts | 51 +++++++++++++++++++ src/modules/state.ts | 94 +++++++++++++++++++++++++++++++++++ src/modules/tape.ts | 52 ++++++++++++++++++++ src/modules/token.ts | 20 ++++++-- src/modules/value.ts | 42 ++++++++++++++-- 14 files changed, 699 insertions(+), 27 deletions(-) diff --git a/src/api/decoder.ts b/src/api/decoder.ts index 89f2fda..06c8a99 100644 --- a/src/api/decoder.ts +++ b/src/api/decoder.ts @@ -13,6 +13,8 @@ type JSONTextDecoderOptions = DecoderOptions; * Feed byte chunks via {@link push} then consume tokens with * {@link readToken} / {@link readValue} / {@link skipValue}. * Call {@link end} when the stream is exhausted to flush any buffered state. + * + * @public */ class JSONTextDecoder { #decoder: Decoder; @@ -28,15 +30,17 @@ class JSONTextDecoder { /** * Asserts that the input has been fully consumed. * - * @throws {SyntacticError} If there are unread bytes remaining. + * @throws {SyntaxError} If the decoder is still inside a nested structure, + * or if non-whitespace characters remain after the value. */ checkEOF(): void { this.#decoder.checkEOF(); } /** - * The current nesting depth — `0` at top level, incremented inside each - * object or array. + * Returns the current nesting depth of the structural state. + * + * @returns The current nesting depth — `1` at the top level, incremented by each open object or array. */ depth(): number { return this.#decoder.depth(); @@ -45,18 +49,18 @@ class JSONTextDecoder { /** * Signals that no more input will be pushed. * - * Validates that any incomplete value is properly terminated. - * - * @throws {SyntacticError} If the input ends in the middle of a value. + * After calling this, number tokens no longer require a trailing byte to + * confirm their end. */ end(): void { this.#decoder.end(); } /** - * The byte offset of the next unread byte within the total input seen so far. + * The byte offset of the end of the last consumed token within the total + * input seen so far. * - * @returns The byte offset of the next unread byte, or the total length of all + * @returns The global byte offset from the start of the stream. */ inputOffset(): number { return this.#decoder.inputOffset(); @@ -76,6 +80,7 @@ class JSONTextDecoder { * or `undefined` if no complete token is available yet. * * @returns The {@link Kind} of the next token, or `undefined` if no complete token is available yet. + * @throws {SyntacticError} If an invalid character or unexpected delimiter is encountered. */ peekKind(): Kind | undefined { return this.#decoder.peekKind(); diff --git a/src/api/encoder.ts b/src/api/encoder.ts index 05e6fde..415782a 100644 --- a/src/api/encoder.ts +++ b/src/api/encoder.ts @@ -12,6 +12,8 @@ type JSONTextEncoderOptions = EncoderOptions; * Write tokens or values via {@link writeToken} / {@link writeValue}, then * retrieve the accumulated output with {@link bytes}. Call {@link reset} to * start a new document without creating a new instance. + * + * @public */ class JSONTextEncoder { #encoder: Encoder; @@ -24,10 +26,10 @@ class JSONTextEncoder { } /** - * The current nesting depth — `0` at top level, incremented inside each - * object or array. + * The current nesting depth — `1` at the top level, incremented by each + * open object or array. * - * @returns The current nesting depth. + * @returns The current nesting depth — `1` at the top level, incremented by each open object or array. */ depth(): number { return this.#encoder.depth(); @@ -72,6 +74,8 @@ class JSONTextEncoder { * Drains and returns the bytes accumulated in the output buffer since the * last call. The internal buffer is cleared; structural state (nesting, * delimiters) is preserved so subsequent writes continue the same document. + * + * @returns A copy of the bytes written since the last `takeBytes` call. */ takeBytes(): Uint8Array { return this.#encoder.takeBytes(); diff --git a/src/modules/automaton.ts b/src/modules/automaton.ts index cb2f291..1831015 100644 --- a/src/modules/automaton.ts +++ b/src/modules/automaton.ts @@ -2,23 +2,39 @@ import { MAX_NESTING_DEPTH } from "#src/common/constants"; import Entry from "#src/modules/entry"; import type { Kind } from "#src/types/kind"; +/** + * A state machine that enforces JSON syntax rules and tracks the structural nesting depth. + * It ensures that the sequence of incoming tokens adheres strictly to `RFC 8259`. + * + * @internal + */ class Automaton { #last: Entry; #stack: Entry[]; + /** + * Creates a new Automaton instance. + */ constructor() { this.#last = new Entry("array"); this.#stack = []; } + /** The current entry at the deepest active parsing context. */ get last(): Entry { return this.#last; } + /** The stack of parent entries representing the nesting history. */ get stack(): Entry[] { return this.#stack; } + /** + * Asserts and appends a generic literal value to the current context. + * + * @throws {SyntaxError} If the current entry requires an object name. + */ appendLiteral(): void { if (this.#last.needObjectName()) { throw new SyntaxError("object name must be a string"); @@ -27,18 +43,40 @@ class Automaton { this.#last.increment(); } + /** + * Appends a string value to the current context. + * + * Unlike {@link appendLiteral}, strings are valid in both object-name and + * value positions, so no structural guard is applied. + */ appendString(): void { this.#last.increment(); } + /** + * Asserts and appends a number value to the current context. + * + * @throws {SyntaxError} If the current entry requires an object name. + */ appendNumber(): void { this.appendLiteral(); } + /** + * Returns the current nesting depth of the structural state. + * + * @returns The current nesting depth — `1` at the top level, incremented by each open object or array. + */ depth(): number { return this.#stack.length + 1; } + /** + * Retrieves the structural entry at the specified depth index. + * + * @param index The index of the entry to retrieve. + * @returns The entry at the specified index. + */ getEntry(index: number): Entry { if (index === this.#stack.length) { return this.#last; @@ -47,6 +85,12 @@ class Automaton { return this.#stack[index]; } + /** + * Pushes a new object structure `{` onto the state machine stack. + * + * @throws {SyntaxError} If the parent context requires an object name. + * @throws {RangeError} If the maximum nesting depth is exceeded. + */ pushObject(): void { if (this.#last.needObjectName()) { throw new SyntaxError("object name must be a string"); @@ -62,6 +106,11 @@ class Automaton { this.#last = new Entry("object"); } + /** + * Resolves and pops the current object structure `}` from the stack. + * + * @throws {SyntaxError} If the current context is not an object, or if it is prematurely closed while expecting a value. + */ popObject(): void { if (!this.#last.isObject()) { throw new SyntaxError("mismatching } for object"); @@ -78,12 +127,18 @@ class Automaton { } } + /** + * Pushes a new array structure `[` onto the state machine stack. + * + * @throws {SyntaxError} If the parent context requires an object name. + * @throws {RangeError} If the maximum nesting depth is exceeded. + */ pushArray(): void { if (this.#last.needObjectName()) { - throw new SyntaxError("object member name must be a string"); + throw new SyntaxError("object name must be a string"); } - if (this.#stack.length === MAX_NESTING_DEPTH) { + if (this.#stack.length >= MAX_NESTING_DEPTH) { throw new RangeError("exceeded max depth"); } @@ -93,6 +148,11 @@ class Automaton { this.#last = new Entry("array"); } + /** + * Resolves and pops the current array structure `]` from the stack. + * + * @throws {SyntaxError} If the current context is not an array, or if it's the implicit top-level array being closed. + */ popArray(): void { if (!this.#last.isArray() || this.#stack.length === 0) { throw new SyntaxError("mismatching structural token for object or array"); @@ -105,6 +165,12 @@ class Automaton { } } + /** + * Determines whether an implicit delimiter is required before the next token. + * + * @param kind The kind of the next incoming token. + * @returns `":"` if a colon is needed, `","` if a comma is needed, or `null` if no delimiter is expected. + */ needDelimiter(kind: Kind): ":" | "," | null { if (this.#last.needImplicitColon()) { return ":"; diff --git a/src/modules/cursor.ts b/src/modules/cursor.ts index 131a227..0ead8f5 100644 --- a/src/modules/cursor.ts +++ b/src/modules/cursor.ts @@ -1,3 +1,8 @@ +/** + * A sliding window cursor used by the Decoder to navigate through incoming stream chunks. + * + * @internal + */ class Cursor { #baseOffset: number; #ended: boolean; @@ -7,6 +12,11 @@ class Cursor { #peekError: Error | null; #bytes: Uint8Array; + /** + * Creates a new Cursor instance starting with an initial chunk of bytes. + * + * @param bytes The initial Uint8Array chunk to read from. + */ constructor(bytes: Uint8Array) { this.#baseOffset = 0; this.#ended = false; @@ -17,14 +27,23 @@ class Cursor { this.#bytes = bytes; } + /** + * Indicates whether the underlying stream has completely ended (EOF). + */ get ended(): boolean { return this.#ended; } + /** + * The current active byte buffer. + */ get bytes(): Uint8Array { return this.#bytes; } + /** + * The start position of the previous chunk. + */ get previousStart(): number { return this.#previousStart; } @@ -33,6 +52,9 @@ class Cursor { this.#previousStart = value; } + /** + * The end position of the previous chunk. + */ get previousEnd(): number { return this.#previousEnd; } @@ -41,6 +63,9 @@ class Cursor { this.#previousEnd = value; } + /** + * The current peek position within the byte buffer. + */ get peekPosition(): number { return this.#peekPosition; } @@ -49,6 +74,9 @@ class Cursor { this.#peekPosition = value; } + /** + * The current peek error, if any. + */ get peekError(): Error | null { return this.#peekError; } @@ -57,6 +85,13 @@ class Cursor { this.#peekError = value; } + /** + * 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(); @@ -80,36 +115,76 @@ class Cursor { this.#previousEnd = 0; } + /** + * Discards the previously processed token bytes by advancing the start pointer. + * This signals that the current memory region is no longer needed. + */ discardPrevious(): void { if (this.#previousStart < this.#previousEnd && this.#previousStart < this.#bytes.length) { this.#previousStart = this.#previousEnd; } } + /** + * Marks the cursor as ended, indicating that no more bytes will be received. + */ end(): void { this.#ended = true; } + /** + * Checks if it has reached the end of the current internal buffer + * and requires more data from the stream to continue. + * + * @param position The current index being processed. + * @returns `true` if more chunks need to be pulled from the stream, `false` otherwise. + */ needMore(position: number): boolean { return position === this.#bytes.length; } + /** + * Calculates the absolute offset in the entire stream for a given local buffer position. + * + * @param position The local index within the current chunk. + * @returns The global byte offset from the very beginning of the stream. + */ offsetAt(position: number): number { return this.#baseOffset + position; } + /** + * Extracts the bytes of the most recently processed token. + * + * @returns A subarray representing the previous token. + */ previousBytes(): Uint8Array { return this.#bytes.subarray(this.#previousStart, this.#previousEnd); } + /** + * Calculates the absolute start offset of the most recently processed token. + * + * @returns The global byte offset from the very beginning of the stream. + */ previousOffsetStart(): number { return this.#baseOffset + this.#previousStart; } + /** + * Calculates the absolute end offset of the most recently processed token. + * + * @returns The global byte offset from the very beginning of the stream. + */ previousOffsetEnd(): number { return this.#baseOffset + this.#previousEnd; } + /** + * Extracts the bytes that have been read but not yet discarded, representing the unread portion of the buffer. + * + * @returns A subarray of the current buffer containing all unread bytes. + */ unreadBytes(): Uint8Array { return this.#bytes.subarray(this.#previousEnd, this.#bytes.length); } diff --git a/src/modules/decoder.ts b/src/modules/decoder.ts index 037cd07..d69009e 100644 --- a/src/modules/decoder.ts +++ b/src/modules/decoder.ts @@ -20,17 +20,37 @@ import { consumeWhitespace, } from "#src/utils/wire"; +/** + * Low-level streaming JSON decoder that reads UTF-8 encoded bytes, + * navigates through them via a {@link Cursor}, and enforces RFC 8259 + * structural rules through a {@link State} machine. + * + * @internal + */ class Decoder { #cursor: Cursor; #state: State; #options: DecoderOptions; + /** + * Creates a new Decoder with an initial byte buffer and options. + * + * @param bytes Initial UTF-8 bytes to decode. + * @param options Decoder configuration options. + */ constructor(bytes: Uint8Array, options: DecoderOptions) { this.#cursor = new Cursor(bytes); this.#state = new State(options); this.#options = options; } + /** + * Asserts that exactly one complete JSON value has been consumed with no + * trailing content. + * + * @throws {SyntaxError} If the decoder is still inside a nested structure, + * or if non-whitespace characters remain after the value. + */ checkEOF(): void { if (this.#state.depth() > 1) { throw new SyntaxError(`Unexpected end of input`); @@ -43,30 +63,72 @@ class Decoder { } } + /** + * Returns the current nesting depth of the structural state. + * + * @returns The current nesting depth — `1` at the top level, incremented by each open object or array. + */ depth(): number { return this.#state.depth(); } + /** + * Signals that no more bytes will arrive (end of stream). + * + * After calling this, number tokens no longer require a trailing byte to + * confirm their end. + */ end(): void { this.#cursor.end(); } + /** + * Returns the absolute byte offset at the end of the last consumed token. + * + * @returns The global byte offset from the start of the stream. + */ inputOffset(): number { return this.#cursor.previousOffsetEnd(); } + /** + * Checks whether the current context expects an object key. + * + * @returns `true` if the next token must be a string serving as an object name, `false` otherwise. + */ needObjectName(): boolean { return this.#state.needObjectName(); } + /** + * Returns the most recently consumed object key string. + * + * @returns The current object property name, or an empty string if not inside an object. + */ lastObjectName(): string { return this.#state.lastObjectName(); } + /** + * Appends the next byte chunk from the stream to the input buffer. + * + * @param bytes The incoming UTF-8 bytes. + */ push(bytes: Uint8Array): void { this.#cursor.appendBytes(bytes); } + /** + * Peeks at the kind of the next token without consuming it. + * + * Skips leading whitespace and validates any required structural delimiter + * (`","` or `":"`). + * The result is cached until the next call to {@link readToken}, + * {@link readValue}, or {@link skipValue}. + * + * @returns The {@link Kind} of the next token, or `undefined` if more bytes are needed to determine it. + * @throws {SyntacticError} If an invalid character or unexpected delimiter is encountered. + */ peekKind(): Kind | undefined { if (this.#cursor.peekPosition > 0) { if (this.#cursor.peekError) { @@ -130,11 +192,25 @@ class Decoder { return kind; } + /** + * Resets the decoder to its initial state, discarding all buffered bytes + * and structural state. + */ reset(): void { this.#cursor = new Cursor(new Uint8Array()); this.#state = new State(this.#options); } + /** + * Consumes and returns the next token from the input buffer. + * + * For structural tokens (`{`, `}`, `[`, `]`), the state machine is updated + * accordingly. For string tokens that serve as object names, the name is + * recorded in the state. + * + * @returns The next {@link Token}, or `undefined` if more bytes are needed. + * @throws {SyntacticError} If the token is malformed or structurally invalid. + */ readToken(): Token | undefined { const kind = this.peekKind(); @@ -201,6 +277,16 @@ class Decoder { return new Token(this.#cursor.previousBytes()); } + /** + * Consumes and returns the next complete JSON value from the input buffer. + * + * For composite values (objects and arrays), the raw bytes are captured as + * a single unit without entering the nested structure. For string values that + * serve as object names, the name is recorded in the state. + * + * @returns The next {@link Value}, or `undefined` if more bytes are needed. + * @throws {SyntacticError} If the value is malformed or structurally invalid. + */ readValue(): Value | undefined { const kind = this.peekKind(); @@ -251,6 +337,12 @@ class Decoder { return new Value(bytes); } + /** + * Consumes the next complete JSON value without returning its bytes. + * + * @returns `true` if a value was consumed, `false` if more bytes are needed. + * @throws {SyntacticError} If the value is malformed or structurally invalid. + */ skipValue(): boolean { const kind = this.peekKind(); @@ -277,10 +369,22 @@ class Decoder { return true; } + /** + * Generates a JSON Pointer representing a location relative to the current + * decoding position. + * + * @param where `-1` for the previously processed value, `0` for the current scope, `1` for the next value. + * @returns A {@link Pointer} representing the absolute path. + */ stackPointer(where: 0 | 1 | -1 = 1): Pointer { return this.#state.stackPointer(where); } + /** + * Returns the unconsumed portion of the input buffer. + * + * @returns A subarray of bytes that have not yet been processed. + */ unreadBytes(): Uint8Array { return this.#cursor.unreadBytes(); } diff --git a/src/modules/encoder.ts b/src/modules/encoder.ts index 2431386..fc5b704 100644 --- a/src/modules/encoder.ts +++ b/src/modules/encoder.ts @@ -9,12 +9,24 @@ import type { Kind } from "#src/types/kind"; import type { EncoderOptions } from "#src/types/options"; import { decodeText, encodeText } from "#src/utils/text"; +/** + * Low-level JSON encoder that serializes tokens and values onto an internal + * {@link Tape}, enforcing RFC 8259 structural rules and applying optional + * formatting (indentation, HTML escaping, etc.). + * + * @internal + */ class Encoder { #tape: Tape; #state: State; #options: EncoderOptions; #cache: Record; + /** + * Creates a new Encoder with the given options. + * + * @param options Encoder configuration options. + */ constructor(options: EncoderOptions) { this.#tape = new Tape(); this.#state = new State(options); @@ -28,31 +40,74 @@ class Encoder { }; } + /** + * Returns a view of the bytes written so far without advancing the output offset. + * + * @returns A subarray of the internal tape buffer. + */ bytes(): Uint8Array { return this.#tape.bytes(); } + /** + * Returns the current structural nesting depth. + * + * @returns The current nesting depth — `1` at the top level, incremented by each open object or array. + */ depth(): number { return this.#state.depth(); } + /** + * Returns the absolute output byte offset, accumulating across all + * {@link takeBytes} calls since the last {@link reset}. + * + * @returns The absolute output byte offset. + */ outputOffset(): number { return this.#tape.outputOffset(); } + /** + * Clears the tape and reinitializes the structural state. + */ reset(): void { this.#tape.reset(); this.#state = new State(this.#options); } + /** + * Generates a JSON Pointer representing a location relative to the current + * encoding position. + * + * @param where `-1` for the previously processed value, `0` for the current scope, `1` for the next value. + * @returns A {@link Pointer} representing the absolute path. + */ stackPointer(where: 0 | 1 | -1): Pointer { return this.#state.stackPointer(where); } + /** + * Extracts the written bytes as a slice and advances the base output offset, + * preparing the tape for the next chunk (streaming use). + * + * @returns A copy of the bytes written since the last `takeBytes` call. + */ takeBytes(): Uint8Array { return this.#tape.takeBytes(); } + /** + * Serializes a single token onto the tape. + * + * Automatically inserts structural delimiters (`:` or `,`) and any + * configured whitespace or indentation before the token. On error, the + * tape is rolled back to its pre-call length. + * + * @param token The {@link Token} to write. + * @throws {SyntacticError} If the token is structurally invalid at the + * current position. + */ writeToken(token: Token): void { const length = this.#tape.length; const delimiter = this.#state.needDelimiter(token.kind); @@ -144,6 +199,16 @@ class Encoder { } } + /** + * Serializes a complete pre-parsed value onto the tape. + * + * Automatically inserts structural delimiters and any configured whitespace + * before the value. On error, the tape is rolled back to its pre-call length. + * + * @param value The {@link Value} to write. + * @throws {SyntacticError} If the value is structurally invalid at the + * current position. + */ writeValue(value: Value): void { const length = this.#tape.length; const delimiter = this.#state.needDelimiter(value.kind); diff --git a/src/modules/entry.ts b/src/modules/entry.ts index 8d12bf2..b4d39a8 100644 --- a/src/modules/entry.ts +++ b/src/modules/entry.ts @@ -1,39 +1,96 @@ import { KIND } from "#src/common/constants"; import type { Kind } from "#src/types/kind"; +/** + * Represents a single depth level in the automaton stack, which can be either an object or an array. + * + * @internal + */ class Entry { #type: "object" | "array"; #count: number; - constructor(type: "object" | "array" = "array") { + /** + * Creates a new Entry instance with the specified structural type. + * + * @param type The type of the entry, either "object" or "array". + */ + constructor(type: "object" | "array") { this.#type = type; this.#count = 0; } + /** + * Returns the current count of elements in the entry. + * - **Array**: the index of the next element. + * - **Object**: the number of key-value half-steps processed. + * + * @returns The current element count. + */ count(): number { return this.#count; } + /** + * Checks if the entry represents a JSON object. + * + * @returns `true` if the entry is an object, `false` otherwise. + */ isObject(): boolean { return this.#type === "object"; } + /** + * Checks if the entry represents a JSON array. + * + * @returns `true` if the entry is an array, `false` otherwise. + */ isArray(): boolean { return this.#type === "array"; } + /** + * Checks if the entry expects an object name for the next token. + * This is true when the structure is an object and the current count is even. + * + * @returns `true` if the next token must be a string serving as an object name, `false` otherwise. + */ needObjectName(): boolean { return this.isObject() && this.#count % 2 === 0; } + /** + * Checks if the entry expects an object value for the next token. + * This is true when the structure is an object and a key has just been processed (count is odd). + * + * @returns `true` if an object value is needed, `false` otherwise. + */ needObjectValue(): boolean { return this.isObject() && this.#count % 2 === 1; } + /** + * Checks if the entry needs an implicit colon before the next element, + * if it's an object expecting a value, the next token must be a colon (`:`). + * + * @returns `true` if the entry needs an implicit colon, `false` otherwise. + */ needImplicitColon(): boolean { return this.needObjectValue(); } + /** + * Checks if the entry needs an implicit comma before the next element: + * + * | Condition | Description | + * | :------------------------- | :--------------------------------------------------------------------------------------------------------------- | + * | Count > 0 | There is at least one element already in the entry. | + * | Don't need object value | The entry is not expecting an object value, which means it's either an array or an object expecting a key. | + * | Not ending object or array | The next token is not an object end (`}`) or array end (`]`), which means we are still within the current entry. | + * + * @param next The kind of the next token to be processed. + * @returns `true` if the entry needs an implicit comma, `false` otherwise. + */ needImplicitComma(next: Kind): boolean { const isObjectEnd = this.isObject() && next === KIND.OBJECT_END; const isArrayEnd = this.isArray() && next === KIND.ARRAY_END; @@ -41,10 +98,16 @@ class Entry { return (!!this.count() && !this.needObjectValue() && !isObjectEnd && !isArrayEnd); } + /** + * Increases the count of elements in the entry. + */ increment(): void { this.#count++; } + /** + * Decreases the count of elements in the entry. + */ decrement(): void { this.#count--; } diff --git a/src/modules/path.ts b/src/modules/path.ts index d7a3ad3..63861cd 100644 --- a/src/modules/path.ts +++ b/src/modules/path.ts @@ -22,10 +22,12 @@ import { consumeNumber, consumeWhitespace } from "#src/utils/wire"; * - **Index Selector (positive)**: `[1]` * - **Array Slice Selector (positive)**: `[0:5]` or `[::2]` * - * @see https://www.rfc-editor.org/rfc/rfc9535 * @internal + * @see https://www.rfc-editor.org/rfc/rfc9535 * @example + * ```javascript * const path = new Path(new TextEncoder().encode("$.store.book[0].title")); + * ``` */ class Path { #bytes: Uint8Array; @@ -43,9 +45,9 @@ class Path { } /** - * Creates a `Matcher` instance based on this path. + * Creates a `Matcher` for incrementally matching a JSON traversal against this path. * - * @returns A `Matcher` instance that can be used to test JSON values against this path. + * @returns A new `Matcher` instance initialised to the start of this path. */ createMatcher(): Matcher { return new Matcher(this.#segments); diff --git a/src/modules/pointer.ts b/src/modules/pointer.ts index 646c3aa..2d7cd1c 100644 --- a/src/modules/pointer.ts +++ b/src/modules/pointer.ts @@ -1,14 +1,38 @@ +/** + * Represents a JSON Pointer as defined in RFC 6901. + * + * @internal + */ class Pointer { #tokens: string[]; + /** + * Creates a new Pointer instance with the specified tokens. + * + * @param tokens An array of unescaped tokens representing the path segments of the pointer. + */ constructor(tokens: string[]) { this.#tokens = tokens; } + /** The array of unescaped reference tokens representing the path. */ get tokens(): string[] { return this.#tokens; } + /** + * Parses a JSON Pointer string into a `Pointer` instance. + * + * @param value The RFC 6901 compliant pointer string (e.g., `""` or `"/foo/bar"`). + * @returns A parsed `Pointer` object. + * @throws {TypeError} If the string is not empty and does not start with a slash (`/`). + * @example + * ```javascript + * Pointer.parse("").tokens // [] + * Pointer.parse("/foo/bar").tokens // ["foo", "bar"] + * Pointer.parse("/a~1b").tokens // ["a/b"] + * ``` + */ static parse(value: string): Pointer { if (value === "") { return new Pointer([]); @@ -21,6 +45,13 @@ class Pointer { return new Pointer(value.slice(1).split("/").map(Pointer.unescapeToken)); } + /** + * Unescapes a single reference token according to RFC 6901. + * Converts `~1` back to `/`, and `~0` back to `~`. + * + * @param token The escaped token segment. + * @returns The raw, unescaped string. + */ static unescapeToken(token: string): string { if (!token.includes("~")) { return token; @@ -29,10 +60,28 @@ class Pointer { return token.replaceAll("~1", "/").replaceAll("~0", "~"); } + /** + * Escapes a single reference token according to RFC 6901. + * Converts `~` to `~0`, and `/` to `~1`. + * + * @param token The raw string segment. + * @returns The escaped token segment safe for pointer construction. + */ static escapeToken(token: string): string { return token.replaceAll("~", "~0").replaceAll("/", "~1"); } + /** + * Serializes the Pointer instance back into an RFC 6901 compliant string. + * + * @returns The serialized pointer string, or `""` for the root pointer. + * @example + * ```ts + * Pointer.parse("/foo/bar").toString() // "/foo/bar" + * Pointer.parse("").toString() // "" + * new Pointer(["a/b", "c"]).toString() // "/a~1b/c" + * ``` + */ toString(): string { if (this.#tokens.length === 0) { return ""; diff --git a/src/modules/stack.ts b/src/modules/stack.ts index cc25630..262195a 100644 --- a/src/modules/stack.ts +++ b/src/modules/stack.ts @@ -1,50 +1,101 @@ +/** + * A specialized stack for tracking the current property names of nested JSON objects. + * + * @internal + */ class ObjectNameStack { #names: Array; + /** + * Creates a new ObjectNameStack instance. + */ constructor() { this.#names = []; } + /** The current depth of tracked object names. */ get length(): number { return this.#names.length; } + /** + * Retrieves the active property name at a specific nesting depth. + * + * @param depth The 0-based depth index. + * @returns The property name, or an empty string if not found. + */ getObjectName(depth: number): string { return this.#names[depth] ?? ""; } + /** + * Retrieves the property name of the deepest (currently active) object context. + * + * @returns The current property name, or an empty string if the stack is empty. + */ getLast(): string { return this.#names[this.#names.length - 1] ?? ""; } + /** + * Pushes a new, empty name context onto the stack when entering a new JSON object. + */ pushObject(): void { this.#names.push(""); } + /** + * Pops the deepest name context from the stack when exiting a JSON object. + */ popObject(): void { this.#names.pop(); } + /** + * Sets the active property name for the current object context. + * + * @param name The parsed object name. + */ setLast(name: string): void { this.#names[this.#names.length - 1] = name; } } +/** + * A specialized stack for tracking the uniqueness of property names within nested JSON objects. + * + * @internal + */ class ObjectNamespaceStack { #namespaces: Array>; + /** + * Creates a new ObjectNamespaceStack instance. + */ constructor() { this.#namespaces = []; } + /** + * Pushes a new, empty namespace Set when entering a new JSON object. + */ pushObject(): void { this.#namespaces.push(new Set()); } + /** + * Pops and discards the namespace Set when exiting a JSON object. + */ popObject(): void { this.#namespaces.pop(); } + /** + * Attempts to insert a property name into the current namespace Set. + * + * @param name The object name to track. + * @returns `true` if the name was successfully inserted, or `false` if it already exists (a duplicate). + */ insert(name: string): boolean { const namespaces = this.#namespaces[this.#namespaces.length - 1]; diff --git a/src/modules/state.ts b/src/modules/state.ts index cac49e0..1de2b43 100644 --- a/src/modules/state.ts +++ b/src/modules/state.ts @@ -4,12 +4,22 @@ import { ObjectNamespaceStack, ObjectNameStack } from "#src/modules/stack"; import type { Kind } from "#src/types/kind"; import type { BaseOptions } from "#src/types/options"; +/** + * The central coordinator for the decoding/encoding state. + * + * @internal + */ class State { #automaton: Automaton; #names: ObjectNameStack; #namespaces: ObjectNamespaceStack; #options: BaseOptions; + /** + * Creates a new State coordinator. + * + * @param options Decoder/encoder configuration options. + */ constructor(options: BaseOptions) { this.#automaton = new Automaton(); this.#names = new ObjectNameStack(); @@ -17,58 +27,126 @@ class State { this.#options = options; } + /** + * Asserts and appends a generic literal value to the current context. + * + * @throws {SyntaxError} If the current entry requires an object name. + */ appendLiteral(): void { this.#automaton.appendLiteral(); } + /** + * Appends a string value to the current context. + */ appendString(): void { this.#automaton.appendString(); } + /** + * Asserts and appends a number value to the current context. + * + * @throws {SyntaxError} If the current entry requires an object name. + */ appendNumber(): void { this.#automaton.appendNumber(); } + /** + * Returns the current nesting depth of the structural state. + * + * @returns The current nesting depth — `1` at the top level, incremented by each open object or array. + */ depth(): number { return this.#automaton.depth(); } + /** + * Determines if a delimiter (`:` or `,`) is required before the next token. + * + * @param kind The kind of the next incoming token. + * @returns `":"` if a colon is needed, `","` if a comma is needed, or `null` if no delimiter is expected. + */ needDelimiter(kind: Kind): ":" | "," | null { return this.#automaton.needDelimiter(kind); } + /** + * Checks if the current context expects an object key. + * + * @returns `true` if the next token must be a string serving as an object name, `false` otherwise. + */ needObjectName(): boolean { return this.#automaton.last.needObjectName(); } + /** + * Checks if the current context expects an object value. + * + * @returns `true` if an object value is needed, `false` otherwise. + */ needObjectValue(): boolean { return this.#automaton.last.needObjectValue(); } + /** + * Retrieves the name of the currently active object property. + * + * @returns The current object property name, or an empty string if not inside an object. + */ lastObjectName(): string { return this.#names.getLast(); } + /** + * Pushes a new array structure, synchronizing the underlying automaton. + * + * @throws {SyntaxError} If the parent context requires an object name. + * @throws {RangeError} If the maximum nesting depth is exceeded. + */ pushArray(): void { this.#automaton.pushArray(); } + /** + * Pops the current array structure. + * + * @throws {SyntaxError} If the current context is not an array, or if it is prematurely closed. + */ popArray(): void { this.#automaton.popArray(); } + /** + * Pushes a new object structure. + * Synchronizes the syntax automaton, name stack, and namespace stack. + * + * @throws {SyntaxError} If the parent context requires an object name. + * @throws {RangeError} If the maximum nesting depth is exceeded. + */ pushObject(): void { this.#automaton.pushObject(); this.#names.pushObject(); this.#namespaces.pushObject(); } + /** + * Pops the current object structure, cleaning up the associated name and namespace tracking. + * + * @throws {SyntaxError} If the current context is not an object, or if it is prematurely closed while expecting a value. + */ popObject(): void { this.#automaton.popObject(); this.#names.popObject(); this.#namespaces.popObject(); } + /** + * Sets the name for the current object property and validates it against duplicates. + * + * @param name The object key being processed. + * @throws {SyntaxError} If the name already exists in the current object and `allowDuplicateNames` is false. + */ setLast(name: string): void { this.#names.setLast(name); @@ -81,6 +159,22 @@ class State { } } + /** + * Dynamically generates a JSON Pointer (RFC 6901) representing a specific location + * in the JSON structure relative to the current state. + * + * @param where `-1` for the previously processed value, `0` for the current scope, `1` for the next value. + * @returns A `Pointer` instance representing the absolute path. + * @example + * ```javascript + * // Object { "a": [...] } — one element at "/a/0" has just been written. + * // state.pushObject(); state.setLast("a"); state.appendString(); + * // state.pushArray(); state.appendString() + * state.stackPointer(-1) // "/a/0" — the previously written element + * state.stackPointer(0) // "/a" — the current array at "a" + * state.stackPointer(1) // "/a/1" — the next element position + * ``` + */ stackPointer(where: -1 | 0 | 1): Pointer { const tokens: string[] = []; let depth = 0; diff --git a/src/modules/tape.ts b/src/modules/tape.ts index a40fb97..8ad2304 100644 --- a/src/modules/tape.ts +++ b/src/modules/tape.ts @@ -1,18 +1,33 @@ +/** + * A dynamic, pre-allocated buffer used as the primary write destination for the Encoder. + * + * @internal + */ class Tape { #baseOffset: number; #length: number; #bytes: Uint8Array; + /** + * Creates a new Tape instance with an initial buffer size of 256 bytes. + */ constructor() { this.#baseOffset = 0; this.#length = 0; this.#bytes = new Uint8Array(256); } + /** The number of bytes currently written to the buffer. */ get length(): number { return this.#length; } + /** + * Appends a single byte (0-255) to the buffer. + * Doubles the underlying capacity if the buffer is full. + * + * @param byte The numeric ASCII/UTF-8 byte to append. + */ appendByte(byte: number): void { if (this.#length >= this.#bytes.length) { this.#grow(1); @@ -21,6 +36,12 @@ class Tape { this.#bytes[this.#length++] = byte; } + /** + * Appends a sequence of bytes to the buffer. + * Dynamically grows the underlying capacity if the incoming chunk exceeds available space. + * + * @param bytes The Uint8Array chunk to append. + */ appendBytes(bytes: Uint8Array): void { const length = this.#length + bytes.length; @@ -32,19 +53,40 @@ class Tape { this.#length += bytes.length; } + /** + * Returns a view of the currently written bytes without advancing the output offset. + * + * @returns A subarray of the written bytes. + */ bytes(): Uint8Array { return this.#bytes.subarray(0, this.#length); } + /** + * Returns the absolute output byte offset, accumulating across all + * {@link takeBytes} calls since the last {@link reset}. + * + * @returns The absolute output byte offset. + */ outputOffset(): number { return this.#baseOffset + this.#length; } + /** + * Resets the buffer, clearing all written bytes and resetting the base offset. + */ reset(): void { this.#length = 0; this.#baseOffset = 0; } + /** + * Extracts the currently written bytes and prepares the Tape for the next chunk of data. + * This method advances the base offset and resets the length pointer to 0, + * allowing the internal buffer to be safely overwritten (Buffer Reuse). + * + * @returns A copy (slice) of the bytes written so far. + */ takeBytes(): Uint8Array { const bytes = this.#bytes.slice(0, this.#length); @@ -54,12 +96,22 @@ class Tape { return bytes; } + /** + * Truncates the buffer to a specific length, effectively discarding recent writes. + * + * @param length The target length to truncate to. + */ truncate(length: number): void { if (length < this.#length) { this.#length = length; } } + /** + * Grows the internal buffer to accommodate additional bytes. + * + * @param needed The number of additional bytes needed. + */ #grow(needed: number): void { const size = Math.max(this.#bytes.length * 2, this.#length + needed); const bytes = new Uint8Array(size); diff --git a/src/modules/token.ts b/src/modules/token.ts index 3c8d3ed..8001ff5 100644 --- a/src/modules/token.ts +++ b/src/modules/token.ts @@ -6,10 +6,11 @@ import { decodeText, encodeText } from "#src/utils/text"; /** * Represents a single JSON token. * - * A token is the smallest unit in a JSON document — either a scalar value - * (`null`, `true`, `false`, a number, or a string) or a structural symbol - * (`{`, `}`, `[`, `]`). Tokens hold their raw UTF-8 bytes and expose typed - * accessor methods for converting to JavaScript primitives. + * A token is either a scalar value (`null`, `true`, `false`, a number, or a + * string) or a structural symbol (`{`, `}`, `[`, `]`). Unlike {@link Value}, + * leading whitespace is not permitted. + * + * @public */ class Token { #bytes: Uint8Array; @@ -78,9 +79,12 @@ class Token { * @returns A new `Token` parsed from the given text. * @throws {SyntaxError} If `value` is not a valid JSON token. * @example + * ```ts * const token = Token.fromText("true"); // or Token.fromText(JSON.stringify(true)); + * * console.log(token.kind); // "true" * console.log(token.asBoolean()); // true + * ``` */ static fromText(value: string): Token { const encoded = encodeText(value); @@ -107,6 +111,12 @@ class Token { * * @param value - The number to encode. * @returns A number token, or a string token for non-finite values. + * @example + * ```javascript + * Token.fromNumber(42).kind // "number" + * Token.fromNumber(NaN).kind // "string" — encoded as '"NaN"' + * Token.fromNumber(Infinity).kind // "string" — encoded as '"Infinity"' + * ``` */ static fromNumber(value: number): Token { if (Number.isNaN(value)) { @@ -231,7 +241,7 @@ class Token { } /** - * Asserts that this token is `null` and returns `null`. + * Decodes this token as `null`. * * @returns `null`. * @throws {TypeError} If this token is not of kind {@link KIND.NULL}. diff --git a/src/modules/value.ts b/src/modules/value.ts index 8b5ceaf..8eebb4a 100644 --- a/src/modules/value.ts +++ b/src/modules/value.ts @@ -9,12 +9,12 @@ import { compareUTF16, consumeWhitespace } from "#src/utils/wire"; /** * Represents a complete JSON value. * - * A value may be a scalar (`null`, `true`, `false`, a number, or a string) + * A value is either a scalar (`null`, `true`, `false`, a number, or a string) * or a composite structure (an object or array, including all nested content). - * It holds the raw UTF-8 bytes and provides methods for converting, - * validating, canonicalizing, and iterating over the value. + * Unlike {@link Token}, leading whitespace is accepted and preserved in + * {@link bytes}. * - * Unlike {@link Token}, a `Value` may be preceded by whitespace. + * @public */ class Value { #bytes: Uint8Array; @@ -66,8 +66,10 @@ class Value { * @param input - Any JSON-serialisable value. * @returns A new `Value` whose bytes are the JSON representation of `input`. * @example + * ```javascript * const value = Value.from({ a: 1, b: [true, null] }); * console.log(value.text()); // '{"a":1,"b":[true,null]}' + * ``` */ static from(input: unknown): Value { const json = JSON.stringify(input); @@ -83,6 +85,12 @@ class Value { * and normalizes numbers. The result is deterministic and idempotent. * * @returns A new `Value` in canonical form. + * @throws {SyntacticError} If the bytes do not represent valid JSON. + * @example + * ```javascript + * Value.from({ b: 2, a: 1 }).canonicalize().text() // '{"a":1,"b":2}' + * Value.from([3, 1, 2]).canonicalize().text() // '[3,1,2]' (arrays are not sorted) + * ``` */ canonicalize(): Value { const decoder = new Decoder(this.#bytes, { allowDuplicateNames: true }); @@ -130,6 +138,7 @@ class Value { * Deserializes this value to a JavaScript value via `JSON.parse`. * * @returns The parsed JavaScript value. + * @throws {SyntaxError} If the bytes do not represent valid JSON. */ json(): unknown { return JSON.parse(this.text()); @@ -139,7 +148,7 @@ class Value { * Returns the UTF-8 string representation of this value. * * @returns The JSON text of this value. - * @throws If the bytes contain invalid UTF-8 sequences. + * @throws {TypeError} If the bytes contain invalid UTF-8 sequences. */ text(): string { return decodeText(this.bytes, true); @@ -153,6 +162,7 @@ class Value { * tokens including structural delimiters, keys, and nested values. * * @yields {Token} Tokens in document order. + * @throws {SyntacticError} If the bytes do not represent valid JSON. */ *tokens(): Generator { const decoder = new Decoder(this.bytes, DEFAULT_DECODER_OPTIONS); @@ -168,6 +178,14 @@ class Value { } } + /** + * Recursively processes a single JSON value from the decoder, normalizing + * numbers and dispatching composite values to `#processObject` or + * `#processArray`. + * + * @param decoder The decoder positioned at the start of the value. + * @returns The canonicalized UTF-8 bytes of the value. + */ #processValue(decoder: Decoder): Uint8Array { const kind = decoder.peekKind(); @@ -193,6 +211,13 @@ class Value { return token.bytes; } + /** + * Consumes a JSON object from the decoder, sorts its members by key in + * UTF-16 code unit order, and re-serializes them to canonical UTF-8 bytes. + * + * @param decoder The decoder positioned at the opening `{`. + * @returns The canonicalized UTF-8 bytes of the object. + */ #processObject(decoder: Decoder): Uint8Array { decoder.readToken(); @@ -246,6 +271,13 @@ class Value { return result; } + /** + * Consumes a JSON array from the decoder and re-serializes its elements + * in their original order to UTF-8 bytes. + * + * @param decoder The decoder positioned at the opening `[`. + * @returns The UTF-8 bytes of the re-serialized array. + */ #processArray(decoder: Decoder): Uint8Array { decoder.readToken(); From 19a76abae4eb4924829cc537469b5985b4afcbc3 Mon Sep 17 00:00:00 2001 From: eden <198768181+lcweden@users.noreply.github.com> Date: Mon, 25 May 2026 09:27:26 +0000 Subject: [PATCH 4/5] docs: enhance documentation for stream classes --- src/api/decoder.ts | 8 +++--- src/libs/stream-decoder.ts | 50 ++++++++++++++++------------------ src/libs/stream-encoder.ts | 54 ++++++++++++++++++++----------------- src/libs/stream-line.ts | 52 +++++++++++++++++------------------ src/libs/stream-selector.ts | 38 ++++++++++++++++++++++++-- src/modules/value.ts | 17 ++++++++---- 6 files changed, 129 insertions(+), 90 deletions(-) diff --git a/src/api/decoder.ts b/src/api/decoder.ts index 06c8a99..185a20b 100644 --- a/src/api/decoder.ts +++ b/src/api/decoder.ts @@ -130,11 +130,11 @@ class JSONTextDecoder { * Returns a JSON Pointer string describing a position in the current * nesting context. * - * | `where` | Meaning | - * |---------|-------------------------------------------------------------------| + * | `where` | Meaning | + * |---------|----------------------------------------------------------| * | `1` | The position of the **next** value to be read (default). | - * | `0` | The position of the **current** container. | - * | `-1` | The position of the **previously** read value. | + * | `0` | The position of the **current** container. | + * | `-1` | The position of the **previously** read value. | * * @param where - Which position to return. Defaults to `1`. * @returns A JSON Pointer string, e.g. `"/foo/0"`. diff --git a/src/libs/stream-decoder.ts b/src/libs/stream-decoder.ts index da47942..0ad14c2 100644 --- a/src/libs/stream-decoder.ts +++ b/src/libs/stream-decoder.ts @@ -15,54 +15,50 @@ type JSONTextDecoderStreamOptions = DecoderOptions & { * Writable side accepts raw JSON bytes (possibly split across multiple chunks). * Readable side emits one {@link Token} per JSON token in document order. * + * @public * @example + * ```javascript * const response = await fetch(url); * const tokens = response.body * .pipeThrough(new JSONTextDecoderStream()); + * ``` */ class JSONTextDecoderStream extends TransformStream { + #decoder: Decoder; + /** * @param options - Decoder and queuing strategy options. */ constructor(options: JSONTextDecoderStreamOptions = {}) { const { writableStrategy, readableStrategy, ...rest } = options; const decoderOptions = { ...DEFAULT_DECODER_OPTIONS, ...rest }; - const decoder = new Decoder(new Uint8Array(), decoderOptions); super( { - transform(chunk, controller) { - try { - decoder.push(chunk); - - let token; - - while ((token = decoder.readToken()) !== undefined) { - controller.enqueue(token); - } - } catch (error) { - controller.error(error); - } + transform: (chunk, controller) => { + this.#decoder.push(chunk); + this.#drain(controller); }, - flush(controller) { - try { - decoder.end(); - - let token; - - while ((token = decoder.readToken()) !== undefined) { - controller.enqueue(token); - } - - decoder.checkEOF(); - } catch (error) { - controller.error(error); - } + flush: (controller) => { + this.#decoder.end(); + this.#drain(controller); + this.#decoder.checkEOF(); }, }, writableStrategy, readableStrategy, ); + + this.#decoder = new Decoder(new Uint8Array(), decoderOptions); + } + + /** + * Drains all available tokens from the decoder into the readable side. + */ + #drain(controller: TransformStreamDefaultController): void { + for (let token; (token = this.#decoder.readToken()) !== undefined;) { + controller.enqueue(token); + } } } diff --git a/src/libs/stream-encoder.ts b/src/libs/stream-encoder.ts index 7950854..e753964 100644 --- a/src/libs/stream-encoder.ts +++ b/src/libs/stream-encoder.ts @@ -15,52 +15,56 @@ type JSONTextEncoderStreamOptions = EncoderOptions & { * Writable side accepts {@link Token} objects. Readable side emits the * corresponding JSON bytes, flushing output as tokens are written. * + * @public * @example + * ```javascript * const { readable, writable } = new JSONTextEncoderStream(); * const writer = writable.getWriter(); + * * writer.write(Token.ARRAY_BEGIN); * writer.write(Token.fromNumber(1)); * writer.write(Token.ARRAY_END); * writer.close(); + * + * // or just pipe a token stream + * ``` */ class JSONTextEncoderStream extends TransformStream { + #encoder: Encoder; + /** * @param options - Encoder and queuing strategy options. */ - constructor(options?: JSONTextEncoderStreamOptions) { - const { writableStrategy, readableStrategy, ...rest } = options ?? {}; - const encoder = new Encoder({ ...DEFAULT_ENCODER_OPTIONS, ...rest }); + constructor(options: JSONTextEncoderStreamOptions = {}) { + const { writableStrategy, readableStrategy, ...rest } = options; + const encoderOptions = { ...DEFAULT_ENCODER_OPTIONS, ...rest }; super( { - transform(token, controller) { - try { - encoder.writeToken(token); - - const bytes = encoder.takeBytes(); - - if (bytes.length > 0) { - controller.enqueue(bytes); - } - } catch (error) { - controller.error(error); - } + transform: (token, controller) => { + this.#encoder.writeToken(token); + this.#drain(controller); }, - flush(controller) { - try { - const bytes = encoder.takeBytes(); - - if (bytes.length > 0) { - controller.enqueue(bytes); - } - } catch (error) { - controller.error(error); - } + flush: (controller) => { + this.#drain(controller); }, }, writableStrategy, readableStrategy, ); + + this.#encoder = new Encoder(encoderOptions); + } + + /** + * Flushes accumulated bytes from the encoder into the readable side. + */ + #drain(controller: TransformStreamDefaultController): void { + const bytes = this.#encoder.takeBytes(); + + if (bytes.length > 0) { + controller.enqueue(bytes); + } } } diff --git a/src/libs/stream-line.ts b/src/libs/stream-line.ts index e0c834a..8cc3c94 100644 --- a/src/libs/stream-line.ts +++ b/src/libs/stream-line.ts @@ -16,51 +16,49 @@ type JSONTextLineStreamOptions = DecoderOptions & { * This makes `JSONTextLineStream` well-suited for processing newline-delimited * JSON (JSONL / JSON Lines) as well as any concatenated-JSON stream. * + * @public * @example + * ```javascript * const response = await fetch(url); * const values = response.body * .pipeThrough(new JSONTextLineStream()); + * ``` */ class JSONTextLineStream extends TransformStream { + #decoder: Decoder; + /** * @param options - Decoder and queuing strategy options. */ - constructor(options?: JSONTextLineStreamOptions) { - const { writableStrategy, readableStrategy, ...rest } = options ?? {}; - const decoder = new Decoder(new Uint8Array(), { ...DEFAULT_DECODER_OPTIONS, ...rest }); + constructor(options: JSONTextLineStreamOptions = {}) { + const { writableStrategy, readableStrategy, ...rest } = options; + const decoderOptions = { ...DEFAULT_DECODER_OPTIONS, ...rest }; super( { - transform(chunk, controller) { - try { - decoder.push(chunk); - - let value; - - while ((value = decoder.readValue()) !== undefined) { - controller.enqueue(value); - } - } catch (error) { - controller.error(error); - } + transform: (chunk, controller) => { + this.#decoder.push(chunk); + this.#drain(controller); }, - flush(controller) { - try { - decoder.end(); - - let value; - - while ((value = decoder.readValue()) !== undefined) { - controller.enqueue(value); - } - } catch (error) { - controller.error(error); - } + flush: (controller) => { + this.#decoder.end(); + this.#drain(controller); }, }, writableStrategy, readableStrategy, ); + + this.#decoder = new Decoder(new Uint8Array(), decoderOptions); + } + + /** + * Drains all available values from the decoder into the readable side. + */ + #drain(controller: TransformStreamDefaultController): void { + for (let value; (value = this.#decoder.readValue()) !== undefined;) { + controller.enqueue(value); + } } } diff --git a/src/libs/stream-selector.ts b/src/libs/stream-selector.ts index c6967f4..5a066d3 100644 --- a/src/libs/stream-selector.ts +++ b/src/libs/stream-selector.ts @@ -2,7 +2,7 @@ import { DEFAULT_DECODER_OPTIONS, KIND, MAX_NESTING_DEPTH } from "#src/common/co import Decoder from "#src/modules/decoder"; import type { Matcher } from "#src/modules/path"; import Path from "#src/modules/path"; -import type Value from "#src/modules/value"; +import Value from "#src/modules/value"; import type { DecoderOptions } from "#src/types/options"; import { encodeText } from "#src/utils/text"; @@ -11,6 +11,31 @@ type JSONTextSelectorStreamOptions = DecoderOptions & { readableStrategy?: QueuingStrategy; }; +/** + * A `TransformStream` that decodes a stream of `Uint8Array` byte chunks and + * emits only the {@link Value} objects matched by a JSON Path expression. + * + * Writable side accepts raw JSON bytes. Readable side emits each {@link Value} + * whose location in the document satisfies the path. Supports a subset of + * RFC 9535 JSON Path syntax: + * + * - **Root Identifier**: `$` + * - **Child Segment**: `.` or `[...]` + * - **Descendant Segment**: `..` + * - **Name Selector**: `.name` or `['name']` + * - **Wildcard Selector**: `.*` or `[*]` + * - **Index Selector (positive)**: `[1]` + * - **Array Slice Selector (positive)**: `[0:5]` or `[::2]` + * + * @see https://www.rfc-editor.org/rfc/rfc9535 + * @public + * @example + * ```javascript + * const response = await fetch(url); + * const items = response.body + * .pipeThrough(new JSONTextSelectorStream("$.items[*]")); + * ``` + */ class JSONTextSelectorStream extends TransformStream { #decoder: Decoder; #matcher: Matcher; @@ -19,6 +44,11 @@ class JSONTextSelectorStream extends TransformStream { #pushs: Uint8Array; #depth: number; + /** + * @param input - A JSON Path expression string (subset of RFC 9535) selecting which values to emit. + * @param options - Decoder and queuing strategy options. + * @throws {SyntaxError} If the path expression is invalid. + */ constructor(input: string, options: JSONTextSelectorStreamOptions = {}) { const { writableStrategy, readableStrategy, ...rest } = options; const decoderOptions = { ...DEFAULT_DECODER_OPTIONS, ...rest }; @@ -47,6 +77,10 @@ class JSONTextSelectorStream extends TransformStream { this.#depth = 0; } + /** + * Reads decoder output step by step, advancing the path matcher, and + * enqueues values at positions that satisfy the path. + */ #drain(controller: TransformStreamDefaultController): void { while (true) { const kind = this.#decoder.peekKind(); @@ -108,7 +142,7 @@ class JSONTextSelectorStream extends TransformStream { return; } - controller.enqueue(value); + controller.enqueue(new Value(value.bytes, this.#decoder.stackPointer(-1).toString())); if (pushed) { this.#matcher.pop(); diff --git a/src/modules/value.ts b/src/modules/value.ts index 8eebb4a..4ffb770 100644 --- a/src/modules/value.ts +++ b/src/modules/value.ts @@ -19,17 +19,18 @@ import { compareUTF16, consumeWhitespace } from "#src/utils/wire"; class Value { #bytes: Uint8Array; #kind: Kind; + #pointer?: string; /** * Creates a `Value` from raw UTF-8 bytes. - * * Leading whitespace is accepted and preserved in {@link bytes}. * * @param bytes - Raw UTF-8 bytes of a complete JSON value. + * @param pointer - Optional JSON Pointer (RFC 6901) indicating where this valuewas located in the source document. Typically set by {@link JSONTextSelectorStream}. * @throws {RangeError} If `bytes` is empty. * @throws {SyntaxError} If no valid JSON token is found after skipping leading whitespace. */ - constructor(bytes: Uint8Array) { + constructor(bytes: Uint8Array, pointer: string | undefined = undefined) { if (!bytes.length) { throw new RangeError("Value must have at least one byte"); } @@ -48,6 +49,12 @@ class Value { this.#bytes = bytes; this.#kind = kind; + this.#pointer = pointer; + } + + /** The raw UTF-8 bytes of this value, including any leading whitespace. */ + get bytes(): Uint8Array { + return this.#bytes; } /** The {@link Kind} of the top-level token of this value. */ @@ -55,9 +62,9 @@ class Value { return this.#kind; } - /** The raw UTF-8 bytes of this value, including any leading whitespace. */ - get bytes(): Uint8Array { - return this.#bytes; + /** The JSON Pointer (RFC 6901) describing where this value was located in the source document, or `undefined` if the value was not produced by a stream API. */ + get pointer(): string | undefined { + return this.#pointer; } /** From e6c2941e872fa4a5eb654f715d4a469d687768ce Mon Sep 17 00:00:00 2001 From: eden <198768181+lcweden@users.noreply.github.com> Date: Mon, 25 May 2026 14:10:49 +0000 Subject: [PATCH 5/5] docs: update README with improved examples and clarifications --- README.md | 83 ++++++++++++++++++++++--------------------------------- 1 file changed, 33 insertions(+), 50 deletions(-) diff --git a/README.md b/README.md index 81b1ecc..7a519b1 100644 --- a/README.md +++ b/README.md @@ -32,9 +32,6 @@ Install via [npm](https://www.npmjs.com/package/jsontext): npm install jsontext ``` -> [!NOTE] -> It may require Node.js 18 or later. - ### Deno Install via [JSR](https://jsr.io/@lcweden/jsontext): @@ -92,8 +89,8 @@ for await (const value of titles) { Represents JSON at two granularities: -- **Tokens**: The smallest lexical unit (a scalar like `"Alice"`, `true`, `123`, or a delimiter like - `{`, `}`, `[`, `]`). +- **Tokens**: The smallest lexical unit (a scalar like `"Alice"`, `true`, `123`, or a structural + symbol like `{`, `}`, `[`, `]`). - **Values**: A complete unit — a scalar, or an entire `object` or `array` including everything nested inside. @@ -172,7 +169,7 @@ decoder.checkEOF(); fetch more bytes, not an error. `end()` tells the decoder no more input is coming; `checkEOF()` then asserts that what arrived was a complete, well-formed document. -### Composing with streams +### Composing The core `JSONTextDecoder` and `JSONTextEncoder` are manual state machines. For common use cases, use `TransformStream` wrappers that natively compose with fetch, files, and Web Streams. @@ -181,7 +178,7 @@ use `TransformStream` wrappers that natively compose with fetch, files, and Web import { JSONTextLineStream, JSONTextSelectorStream } from "jsontext"; // Filter a JSON Lines feed: keep only active users, write them back out as JSONL. -// JSONTextLineStream emits one Value per line, preserving the original bytes. +// JSONTextLineStream emits one Value per top-level JSON value — ideal for JSONL and concatenated-JSON. const encoder = new TextEncoder(); await response.body @@ -222,60 +219,46 @@ try { } ``` -## Example Pipelines +## Examples + +Below are some simple examples demonstrating how to use `jsontext` for common JSON processing tasks. +For more examples, see the [documentation](/docs/). ### Replace `null` with an empty string -Swap every `null` token for an empty string as the JSON flows through — no parsing the whole -document, no intermediate object. +In this example, we read a JSON stream from an API endpoint, replace all `null` values with empty +strings, and write the modified JSON back out as a stream without ever materializing the whole +document in memory. ```javascript import { JSONTextDecoderStream, JSONTextEncoderStream, KIND, Token } from "jsontext"; -stream - .pipeThrough(new JSONTextDecoderStream()) // decode bytes into tokens - .pipeThrough( - new TransformStream({ - transform(token, controller) { - if (token.kind === KIND.NULL) { // Detect a `null` token - controller.enqueue(Token.fromString("")); // Emit an empty string token instead - } else { - controller.enqueue(token); - } - }, - }), - ) - .pipeThrough(new JSONTextEncoderStream()); // encode tokens back into bytes -``` - -### Extract and Restructure Data +const response = await fetch("your.api/endpoint"); -Extract specific nested elements using JSONPath, and wrap them into a brand new JSON array structure -directly in the stream pipeline. - -```javascript -import { JSONTextEncoderStream, JSONTextSelectorStream, Token } from "jsontext"; +if (!response.ok || !response.body) { + throw new Error("Failed to fetch data"); +} -stream - .pipeThrough(new JSONTextSelectorStream("$.todos[*].todo")) // extract all `todo` values from the `todos` array - .pipeThrough( - new TransformStream({ - start(controller) { - controller.enqueue(Token.ARRAY_BEGIN); // emit a `[` to start the output array - }, - transform(value, controller) { - for (const token of value.tokens()) { - controller.enqueue(token); - } - }, - flush(controller) { - controller.enqueue(Token.ARRAY_END); // emit a `]` to end the output array - }, - }), - ) - .pipeThrough(new JSONTextEncoderStream()); // encode back to bytes for output +const decoder = new JSONTextDecoderStream(); +const encoder = new JSONTextEncoderStream(); +const replacer = new TransformStream({ + transform(token, controller) { + if (token.kind === KIND.NULL) { // Detect a `null` token + controller.enqueue(Token.fromString("")); // Emit an empty string token instead + } else { + controller.enqueue(token); + } + }, +}); + +const stream = response.body.pipeThrough(decoder).pipeThrough(replacer).pipeThrough(encoder); +const blob = await new Response(stream).blob(); ``` +> [!TIP] +> `JSONTextDecoderStream` supports Token-level processing only. If you need to replace values that +> may be nested inside objects or arrays, you will need to use `JSONTextDecoder` directly. + ## License This project is licensed under the [MIT](LICENSE) License.