chunk-engine
API Reference

JavaScript — js-chunks

Every export from js-chunks, with exact TypeScript declarations, the /web subpath, and an accurate ChunkError contract.

View raw
npm install js-chunks

js-chunks is the engine compiled to WebAssembly with a TypeScript wrapper. The package is ESM-only ("type": "module", "sideEffects": false); a require() of it needs Node ≥ 20.19. Declared engine floor is Node ≥ 18. It runs on Node, Bun, Deno and in browsers.

Functions

getChunks

function getChunks(
  source: ChunkSource,
  opts?: ChunkOptions & { listImages?: false },
): Promise<Chunk[]>;

function getChunks(
  source: ChunkSource,
  opts: ChunkOptions & { listImages: true },
): Promise<ChunksWithImages>;

getMarkdown

function getMarkdown(
  source: ChunkSource,
  opts?: ChunkOptions & { listImages?: false },
): Promise<string>;

function getMarkdown(
  source: ChunkSource,
  opts: ChunkOptions & { listImages: true },
): Promise<MarkdownWithImages>;

streamChunks

function streamChunks(
  source: ChunkSource,
  opts?: ChunkOptions,
): AsyncGenerator<Chunk, void, unknown>;

streamChunks is ergonomics, not incremental streaming

The WASM boundary is a synchronous full parse. streamChunks calls getChunks, waits for the complete array, and then yields its elements — the whole document and the whole chunk list are in memory before the first yield. Results and peak memory are identical to getChunks. Use it for for await ergonomics and to interleave downstream work with iteration, not to bound memory. rs-chunks and py-chunks do have truly incremental streaming for some formats; see Streaming.

fitTokens

function fitTokens(
  chunks: Iterable<Chunk>,
  counter: TokenCounter,
  budget: number,
  opts?: FitOptions,
): Chunk[];

Takes getChunks output and re-fits it so no chunk exceeds budget under your tokenizer. counter is any (text: string) => numbergpt-tokenizer, a transformers.js tokenizer, or something you wrote.

import { getChunks, fitTokens } from "js-chunks";
import { encode } from "gpt-tokenizer";

const chunks = await getChunks("report.pdf");
const fitted = fitTokens(chunks, (s) => encode(s).length, 512);

The chunks keep their contentType and metadata, including the repeated table headers that make a retrieved row interpretable — which is the difference between this and running a text splitter over the raw document.

This is the only synchronous export in the package: pure TypeScript, no wasm, no I/O, so it behaves identically in Node, Bun, Deno and the browser.

Parity-exempt by design

Every other API in this library produces byte-identical output across py-chunks, js-chunks and rs-chunks. fitTokens does not, and cannot. Its output depends on the token counter you pass, and tokenizers differ between languages — so putting a token budget in the engine would either vendor the wrong tokenizer for your embedding model or quietly break the byte-identical guarantee. Characters are parity-safe; tokens are not.

py-chunks ships the same helper as fit_tokens — a separate implementation with the same behaviour, not a shared one.

The budget holds on every input, including whitespace-free text — CJK prose, long URLs, base64, minified code. The split ladder is paragraph → sentence → whitespace → a character-level bisection sized by your own counter, so an unbreakable-looking run is split at the character level rather than silently emitted over budget (before 0.6.4 it leaked through whole). Parts reassemble losslessly.

Pass overlap to share tokens between the parts of a chunk that had to be split — an integer token count, or a fraction of the budget in (0, 1):

const fitted = fitTokens(chunks, counter, 512, { overlap: 64 });

Overlap is scoped within one engine chunk on purpose (overlapping across chunks would smear contentType and structural metadata), and overlapped parts still respect the budget — the shared tail is trimmed until the joined part fits, because tokenisation is not additive across a join.

Split chunks gain fit_part / fit_total in their metadata. The input is never mutated. Bad arguments — a budget below 1, a negative minTokens, a non-function counter, an overlap at or above the budget, or an unrecognised split / merge / mergeMetadata / oversize — throw ChunkError with kind: "invalid-arg".

Bigger chunks are not automatically better

It is tempting to raise minTokens to pack chunks fuller. Measured on this project's own corpus, raising budget utilisation from 46% to 95% halved token-level precision with no gain in answerability. Use minTokens to remove genuinely useless fragments, not to fill the budget.

chunkText

function chunkText(text: string, opts?: ChunkOptions): Promise<Chunk[]>;

Chunk a bare string — no file, no extension. The text goes through the same plain-text pipeline a .txt file would, so it returns the usual structure-aware chunks and composes with fitTokens:

import { chunkText, fitTokens } from "js-chunks";

const chunks = await chunkText(rawText);
const fitted = fitTokens(chunks, counter, 512);

Strings are content here, never paths — pass a path to getChunks instead. Anything that is not a string throws ChunkError with kind: "invalid-arg".

The host-parsed PDF family

For callers who already parsed a PDF with some other tool and want chunk-engine's chunking over that Markdown. .pdf sources are parsed by the engine itself — you do not need these to chunk a PDF.

function chunkPdfMarkdown(
  markdown: string,
  totalPages: number,
  opts?: ChunkOptions,
): Promise<Chunk[]>;

function chunkPdfMarkdownWithImages(
  markdown: string,
  images: ChunkImage[],
  totalPages: number,
  opts?: ChunkOptions,
): Promise<ChunksWithImages>;

function normalizePdfMarkdown(markdown: string): Promise<string>;

chunkPdfMarkdownWithImages expects each supplied image's name to match an ![](name) reference in the Markdown. normalizePdfMarkdown returns just the normalised Markdown string — what getMarkdown would have emitted.

Types

interface Chunk {
  content: string;
  contentType: string;
  metadata: Record<string, unknown>;
}

interface ChunkImage {
  name: string;
  data: Uint8Array;
}

interface ChunksWithImages {
  chunks: Chunk[];
  images: ChunkImage[];
}

interface MarkdownWithImages {
  markdown: string;
  images: ChunkImage[];
}

contentType is camelCase here — the WASM core emits content_type, and the wrapper renames exactly that one field. Keys inside metadata are not renamed: they stay snake_case, identical to py-chunks and rs-chunks. And images is an array here, where py-chunks returns a dict.

ChunkOptions

interface ChunkOptions {
  mode?: ChunkMode;            // default "default"
  windowSize?: number;         // default 3
  overlap?: number;            // default 1
  sentencesPerChunk?: number;  // default 3
  paragraphsPerPage?: number;  // default 15
  /** Required when `source` is raw bytes and carries no name of its own. */
  filename?: string;
  /** Return extracted embedded images alongside the result. */
  listImages?: boolean;
}

There is no JavaScript equivalent of py-chunks' delimiter / encoding / rows_per_chunk arguments — CSV and spreadsheet knobs are not exposed through this surface.

TokenCounter and FitOptions

type TokenCounter = (text: string) => number;

interface FitOptions {
  /** Merge chunks below this many tokens into the next one. `0` disables. */
  minTokens?: number;
  merge?: "forward" | "none";
  split?: "sentence" | "paragraph" | "hard";
  mergeMetadata?: "first" | "union";
  oversize?: "split" | "keep" | "error";
  /** Never merge across a change in `boundaryKeys`. Default `true`. */
  respectBoundaries?: boolean;
  /** Default `["section_heading", "page_number", "sheet_name"]`. */
  boundaryKeys?: readonly string[];
  /** Tokens shared between split parts: an int, or a fraction of budget. */
  overlap?: number;
}

respectBoundaries is on by default because merging across a section, page or sheet makes the surviving metadata a lie — and it is the fastest way to destroy the table-header repetition that makes a retrieved row interpretable. mergeMetadata: "first" keeps the leading chunk's values and never invents one; "union" collects differing values into an array, so a chunk spanning two pages reports both rather than claiming one.

ChunkMode

All ten values, exactly as the union declares them:

type ChunkMode =
  | "default"
  | "section"
  | "semantic"
  | "sentence"
  | "page_aware"
  | "sliding_window"
  | "row"
  | "table"
  | "sheet"
  | "structural";

ByteSource and ChunkSource

type ByteSource = Uint8Array | ArrayBuffer | Blob;
type ChunkSource = string | ByteSource;

A string is a filesystem path, and only on Node — passing one elsewhere throws. Node's Buffer is not a separate arm because a Buffer is a Uint8Array. A File is a Blob, so it works and supplies its own name; an unnamed Blob needs opts.filename.

Errors

type ChunkErrorKind = "unsupported" | "invalid-arg" | "parse" | "io" | "unknown";

class ChunkError extends Error {
  readonly kind: ChunkErrorKind;
}

ChunkError covers every failure the engine raises and every argument validation the wrapper performs:

Failurekind
Unsupported extension"unsupported"
Unknown mode, bad parameter"invalid-arg"
A filesystem path passed off Node"invalid-arg"
A byte source with no resolvable filename"invalid-arg"
A source of an unsupported type"invalid-arg"
opts is not an object, or a getter on it throws"invalid-arg"
filename / mode is not a string; a count is not an integer, is NaN, or is negative"invalid-arg"
The document failed to parse"parse"
An I/O failure inside the engine"io"
A path that cannot be read (missing, EACCES, EISDIR)"io"
A Blob whose arrayBuffer() rejects"io"
A thrown value carrying no recognised variant tag"unknown"

The contract is complete

Reading a path on Node goes through fs.readFileSync in the wrapper, before the engine is reached — and that read is now wrapped too. A missing file gives you a ChunkError with kind: "io" whose message is Node's own (ENOENT: no such file or directory, open './report.pdf'). There is no input for which these functions throw something that is not a ChunkError.

try {
  const chunks = await getChunks("./report.pdf");
} catch (e) {
  if (e instanceof ChunkError) {
    console.error(e.kind, e.message);   // "io" for a missing path
  } else {
    throw e;   // still worth keeping: an unexpected error should not be swallowed
  }
}

For parse failures raised by the engine, message is the engine's own message, byte-for-byte what py-chunks raises for the same input; the variant that Python expresses as an exception type is carried on kind instead.

message is not a cross-SDK contract everywhere, though. Two messages are built in each SDK's own host layer and differ in wording: the unsupported-extension message and the invalid-mode message. The numeric-range sentences (window_size, sentences_per_chunk, paragraphs_per_page) do agree now — every host layer was aligned on the engine's own wording, across both SDKs and across format families. kind agrees with Python's exception type in every case — branch on kind, log message. See Error Handling.

The wrapper's own type checks are the one place a message is deliberately not the engine's: they name the camelCase key you typed (windowSize must be an integer, got 2.5.), because a usize boundary has no counterpart for them. Out-of-range values reuse the engine's snake_case sentence instead, so the text is the same whichever side rejects.

Entry points

{
  ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" },
  "./web": { "types": "./pkg-web/chunks_wasm.d.ts", "default": "./pkg-web/chunks_wasm.js" },
  "./web/chunks_wasm_bg.wasm": "./pkg-web/chunks_wasm_bg.wasm"
}

The default entry (js-chunks) loads the WASM lazily on the first call and caches it. On Node it requires the synchronous pkg-node build — no async init step. Elsewhere it dynamically imports the pkg-web build and awaits its initialiser for you. This is the entry the API above documents.

js-chunks/web is the raw wasm-bindgen surface for bundled browser apps (Vite, webpack), where a dynamic import of a relative .js beside a .wasm does not survive bundling. You supply the .wasm URL and await the init yourself, once:

import initWasm, * as engine from "js-chunks/web";
import wasmUrl from "js-chunks/web/chunks_wasm_bg.wasm?url";

await initWasm({ module_or_path: wasmUrl });
const chunks = engine.getChunks(bytes, "report.docx", "default", 3, 1, 3, 15);

Note the shape: the /web functions are the positional wasm-bindgen exports, not the ergonomic wrapper — no options object, no overloads, and no ChunkError wrapping. See Installation.

One capability gap versus the native SDKs

PDF parsing runs in WASM — the same engine code as py-chunks and rs-chunks. What WASM cannot do is rasterise a page: the native builds fall back to PDFium rendering when a scanned PDF has no extractable text, and that fallback is compiled out of the WASM build. A text-less PDF therefore reports that it has no text here, instead of returning page images. It is the only deliberate behavioural divergence between the SDKs.

On this page