Browser
What runs on the user's machine, and the one thing that cannot.
Reading the PDF, building the tree, and running the retrieval tools all happen on the user’s machine. The file is never uploaded. The only thing that leaves is the text the model asked to read, and it goes to your LLM endpoint rather than to you.
The library is shaped the way it is to keep that true.
What runs where
| Step | Where |
|---|---|
| Parse the PDF | the browser, in WASM |
| Build the tree | the browser, no network |
get_document_structure, get_page_content |
the browser, against the tree in memory |
| The LLM call | your endpoint |
The tools are the interesting row. When the agent asks for lines 266 to 313, that lookup happens locally against the tree. Only the resulting text goes out, wrapped in the next request.
The one thing you cannot do from a browser
Call a provider directly. Two reasons, and neither has a clever workaround:
CORS blocks it. Providers do not send the headers a browser needs to accept a cross-origin response, so the request fails before you see it.
Your key would be in the page. Anything the browser can read, a visitor can read, including a key in a bundle or an env var your bundler inlined.
So put a proxy in front. It holds the key and adds the CORS header:
// A Cloudflare Worker, but any tiny server does this job.
export default {
async fetch(req: Request, env: Env) {
const upstream = await fetch("https://api.openai.com/v1/chat/completions", {
method: "POST",
headers: {
"content-type": "application/json",
authorization: `Bearer ${env.OPENAI_API_KEY}`,
},
body: await req.text(),
});
return new Response(upstream.body, {
headers: { "access-control-allow-origin": "*", "content-type": "application/json" },
});
},
};
Then point at it and leave apiKey unset:
const llm = "/api/llm"; // shorthand for { baseURL: "/api/llm" }
The proxy sees prompts and answers. It never sees the document, because the document never went anywhere.
A working page
bun add vectorless-js @llamaindex/liteparse-wasm
The parser needs its wasm, and the library will not guess where your bundler put it. Pass it in:
import { initParser, pdfToMarkdown } from "vectorless-js/parse";
import { buildIndexAuto, ask, complete } from "vectorless-js";
import wasmUrl from "@llamaindex/liteparse-wasm/liteparse_wasm_bg.wasm?url";
await initParser(wasmUrl);
const llm = "/api/llm";
const think = (p: string) => complete(p, llm);
async function onFile(file: File) {
const bytes = new Uint8Array(await file.arrayBuffer());
const markdown = await pdfToMarkdown(bytes); // wasm, on this machine
const doc = await buildIndexAuto(markdown, file.name, think);
const { answer, citations } = await ask(doc, "What is the notice period?", llm, (e) => {
if (e.type === "tool") console.log(e.name, e.input); // watch it navigate
});
return { answer, citations };
}
An <input type="file"> on one end, an answer with citations on the other, and nothing to wire up in between.
Bundler notes
Vite resolves ?url and copies the wasm into your assets. Other bundlers have their own spelling for the same idea, and initParser also takes bytes if you would rather fetch it yourself.
One setting matters. The wasm glue breaks if esbuild pre-bundles it, so exclude it:
// vite.config.ts
export default defineConfig({
optimizeDeps: { exclude: ["@llamaindex/liteparse-wasm"] },
});
Budget for the download. The wasm is about 4.8 MB, which gzips to roughly 2.3 MB, and it loads once. If some of your users only ever feed it Markdown, skip vectorless-js/parse for them and they never fetch it at all.
What crosses the network
For a 28 page filing, in order:
The table of contents goes out first, about 1.6 KB of titles and line numbers. Then whichever section the model picked, usually a couple of KB. The other 60-odd KB stays on the machine, because nothing ever asked for it.
Two limits worth knowing
OCR is off, so born-digital PDFs read fine and scanned pages come back empty. Wiring a JS OCR engine through pdfToMarkdown’s config is the fix, and liteparse can tell you which pages would need it.
Large documents cost memory, since the tree holds the text. A 53 KB contract becomes a 59 KB tree, which is nothing, but a 500 page manual is a different conversation.