I built Recon, a local API client with semantic search. The idea was simple: I had 200+ saved API requests across projects and could never find the right one by name. Now I type "the one that updates the user profile" and it finds it.
The semantic search runs a local embedding model (all-MiniLM-L6-v2, ~23MB) through @xenova/transformers. No API calls, no data leaves the machine. SQLite stores everything. One file, one database, no cloud.
I shipped five releases. The semantic search was broken in all of them.
The bug
all-MiniLM-L6-v2 produces a 384-dimensional embedding as a Float32Array. To store it in SQLite, I converted it to a Buffer:
const embedding = await generateEmbedding(text); // Float32Array(384)
db.prepare('INSERT INTO requests (embedding) VALUES (?)').run(Buffer.from(embedding));
Buffer.from(Float32Array) creates a buffer viewing the TypedArray's memory. But here's the thing: it creates a buffer with length equal to the TypedArray's length (384), not its byteLength (1536). Each 4-byte float gets truncated to a single byte.
The stored embedding is 384 bytes. The real embedding is 1536 bytes. Every embedding in the database is corrupted.
When searching, the query embedding (correct, 1536 bytes) gets compared against stored embeddings (corrupted, 384 bytes). The cosine similarity returns NaN. Every search returns nothing.
The fix:
// Before (broken):
Buffer.from(embedding)
// After (correct):
Buffer.from(embedding.buffer, embedding.byteOffset, embedding.byteLength)
Or more simply:
Buffer.from(new Uint8Array(embedding.buffer))
This creates a buffer from the raw bytes of the underlying ArrayBuffer, preserving all 1536 bytes.
Why I didn't catch it
In dev mode, I tested semantic search by typing a query and seeing results. It worked. The model loaded, embeddings generated, results appeared. I never checked the actual cosine similarity scores. The results were there because the FTS5 keyword search was returning them, not the semantic search. The semantic results were silently NaN and filtered out. I was testing the feature and seeing the fallback.
I wrote integration tests two weeks after shipping. The tests checked actual cosine similarity scores. The first test failed: expected score > 0.1, received NaN. That's when I found it.
The ESM-only trap (the second bug)
The semantic search wasn't the only thing broken. The first binary I shipped crashed on launch.
@xenova/transformers is ESM-only. In dev mode, everything works fine. electron-vite handles the import, the model loads, semantic search works. You ship the binary and it crashes before the window opens.
The problem: electron-vite's externalizeDepsPlugin() externalizes node_modules as require() calls in the compiled output. But @xenova/transformers can't be require()'d. It's ESM. The compiled main process does require("@xenova/transformers") and the process dies.
The fix is a dynamic import():
let pipeline;
async function getPipeline() {
if (!pipeline) {
const { pipeline: p } = await import('@xenova/transformers');
pipeline = p;
}
return pipeline;
}
Dynamic import() works in CommonJS output because it returns a promise. The module loads asynchronously at runtime instead of synchronously at require time. The binary ships, the model loads on first search, everything works.
This took me four hours to figure out. The error message was Error [ERR_REQUIRE_ESM]: require() of ES Module. Not obvious that the fix is "don't require it, import it dynamically" when your entire build pipeline is CommonJS.
FTS5 injection via search input
Recon also has full-text search (SQLite FTS5) for keyword matching. Results from FTS and semantic search are merged. The FTS5 query takes user input directly:
SELECT * FROM requests_fts WHERE requests_fts MATCH ?
This is parameterized, so SQL injection isn't the issue. The issue is FTS5 query syntax. A user searching for user-or gets a syntax error because OR is a FTS5 operator. A hyphen is NOT. The search crashes and returns zero results.
The fix: wrap the query in double quotes to force a phrase search, and catch any FTS5 errors to fall back to semantic-only:
try {
const ftsResults = db.prepare(
'SELECT * FROM requests_fts WHERE requests_fts MATCH ?'
).all(`"${query.replace(/"/g, '""')}"`);
} catch {
// FTS5 syntax error, fall back to semantic search only
}
The double-quote escaping ("" inside a quoted string) handles queries that contain quotes. The try/catch is the safety net for anything FTS5 decides is invalid syntax.
What's in the box
- Save and organize requests by project
- Environment variables (dev/staging/prod)
- Full request/response history
- Semantic search (local embeddings) + keyword search (FTS5)
- Import Postman collections (JSON export)
- Export collections as .http files (Git-friendly, works with JetBrains HTTP Client and VS Code REST Client)
- Import .http files back into Recon
- No account, no login, no cloud sync
- MIT source
Windows only for now. The source is MIT. If you can build it yourself, do it. If you want a prebuilt binary, grab it from the releases page.
Source: https://github.com/d4r4ki4n/recon
Landing: https://d4r4ki4n.github.io/recon/
What's missing
No WebSocket, no gRPC, no Mac/Linux builds. REST only. No team collaboration features. No scripting. No mock servers.
This is a tool for one developer who has too many saved requests and can't find them. If that's you, try it.
What I learned
Buffer.from(TypedArray)does not preserve byte data. It creates a byte-level view with length equal to the TypedArray's element count, not its byte length. UseBuffer.from(typedArray.buffer)instead.Dynamic
import()is the fix for ESM-only deps in Electron. Notesbuildconfig, nottype: "module", not a custom resolver. Justawait import().FTS5 query syntax is user input. Treat it like SQL injection: sanitize, escape, and have a fallback.
Local embeddings are practical. 23MB model, 50ms per embedding, runs in Node.js. You don't need an API for semantic search. The model is small enough to ship in the binary.
Shipping a binary is not the same as running it in dev. The ESM bug only appeared in the production build. The embedding bug was invisible because FTS5 was masking it. Run the binary before you ship it. Write tests that check actual values, not just "does it return something."
If your product's core differentiator is a feature, test that feature specifically. Not "does search return results." "does semantic search return results with non-NaN scores." The difference is the difference between shipping a working product and shipping a broken one for two weeks.
I'm one developer. The product is free. The source is MIT. If you have feedback, I want to hear it.
Top comments (0)