deleting better-sqlite3 from pwnkit, and what it cost us
pwnkit 0.7.1 ships with zero native modules. the persistence layer was migrated from better-sqlite3 to a pure-wasm sqlite implementation. here's what broke, what we kept, and why every npx install on every node version now just works.
every now and then a user of pwnkit would open an issue with the same error message:
Error: The module '/.../node_modules/better-sqlite3/build/Release/better_sqlite3.node'
was compiled against a different Node.js version using
NODE_MODULE_VERSION X. This version of Node.js requires
NODE_MODULE_VERSION Y. Please try re-compiling or re-installing
the module (for instance, using `npm rebuild` or `npm install`).
this is the standard story of native modules in the npm ecosystem. better-sqlite3 ships precompiled .node binaries per node abi, and prebuild-install walks a static table to pick the right one. occasionally, on a very new node release, the table is stale and prebuild-install picks the wrong binary. the install completes “successfully,” the lockfile is happy, the cli launches — and then the first call into sqlite explodes at runtime with the message above. for a tool that’s distributed as npx pwnkit-cli@latest, this is a credibility-killing first-run experience.
we tried two ways to patch around it. first we rewrote the silent try { ... } catch { return null } around the database initializer into a thrown error with a clear remediation hint, so at least the user could see what was wrong. then we shipped a postinstall script that re-resolved the native binary against the current node abi and swapped it in if it was wrong. both worked. neither was the right fix.
the right fix is to not have a native module at all.
the migration
pwnkit 0.7.1 replaces better-sqlite3 with node-sqlite3-wasm — a pure-webassembly sqlite build that ships one .wasm file and runs identically on every node version, on bun, on deno, in electron, anywhere v8 + wasm exist. no abi. no prebuilds. no postinstall. no rebuild dance. one binary, every runtime.
the catch is that drizzle-orm — pwnkit’s query builder of choice — speaks the better-sqlite3 api shape, not node-sqlite3-wasm’s. drizzle’s BetterSQLiteSession class assumes the sync prepare(sql).all() / .run() / .get() interface, expects bound parameters in a specific form, and imports the native driver eagerly through drizzle-orm/better-sqlite3/driver.js. ripping out better-sqlite3 naively would have broken every query in the engine.
so we built a thin shim:
// packages/db/src/wasm-shim.ts (excerpt)
import { Database as WasmDatabase } from "node-sqlite3-wasm";
// import BetterSQLiteSession from the deep `/session` subpath to avoid
// pulling in `drizzle-orm/better-sqlite3/driver.js`, which would
// import `better-sqlite3` at module load and defeat the whole point.
import { BetterSQLiteSession } from "drizzle-orm/better-sqlite3/session";
class ShimmedStatement {
constructor(private impl: WasmStatement, private pluck = false) {}
run(...args: unknown[]) { /* … translates args + delegates */ }
get(...args: unknown[]) { /* … translates args + delegates */ }
all(...args: unknown[]) { /* … translates args + delegates */ }
// …
}
export function createShimmedDatabase(path: string): ShimmedDatabase { … }
export function createDrizzleFromShim<TSchema>(
client: ShimmedDatabase,
config: { schema: TSchema },
) { … }
240 lines of typescript. it presents enough of the better-sqlite3 surface that drizzle can’t tell the difference. the rest of the engine — every query, every migration, every test — kept working unchanged.
what it cost us
three things had to give:
-
the wal pragma.
better-sqlite3defaults to write-ahead logging mode for performance.node-sqlite3-wasm’s vfs implementation doesn’t support wal. pwnkit’s database is small (a few hundred kb at the high end, mostly findings + scan history), so the rollback-journal default is fine. dropped thePRAGMA journal_mode = WALline. -
postinstall complexity. the
scripts/verify-native.mjswe’d been carrying as a workaround for the abi mismatch is gone. nothing to install, nothing to verify, nothing to swap. the tarball contains the .wasm file and that’s it. -
a fresh tarball test on every supported runtime. we now run the full install-and-scan path under node 18, 20, 22, 25, and bun on every release. it’s a one-line change to the ci matrix and it’s worth the seconds.
what we did not have to give up: speed. node-sqlite3-wasm’s perf on the kind of workload pwnkit runs (a few hundred small inserts and selects per scan, never anything resembling a hot loop) is indistinguishable from the native module on a modern macbook. we benchmarked it before pulling the trigger and the round-trip difference was below the noise floor of the test harness.
what it unlocks
three things, none of them obvious until you hold them in your hand:
-
npx pwnkit-cli@0.7.1works on every node version, including the unreleased ones. when node 26 ships, pwnkit will work on it without us doing anything. this is the actual point. -
bunx pwnkit-cli@0.7.1works the same, with a roughly 10x faster cold start as a free perk. no code change. bun’s resolver is just faster than npm’s, and bun has a smarter package cache. anyone who has bun installed gets this for free; anyone who doesn’t can keep using npx exactly as before. -
the entire class of “i installed it and it crashed before i typed anything” first-run failures is gone. that class of bug was small in absolute count but enormous in damage — it killed the credibility of the tool the first time the user hit it, and it was hard to reproduce because it depended on the user’s specific node abi mismatch. now the class is empty.
the meta-lesson
most of the patches we shipped before this migration were addressing symptoms. the silent null cast → clear error. the postinstall abi fixer. the helpful retry hint. each was a real improvement and each took real engineering time. cumulatively they cost more than the migration itself did.
when you find yourself patching the same root cause for the third time, the right move is to delete the root cause. in this case the root cause was a class of dependency — native modules — that pwnkit didn’t actually need. once we accepted that, the fix was a 240-line shim and a deps swap.
pwnkit is a security tool. its job is to find vulnerabilities and write reports, not to wrestle with prebuild-install. every line of build infrastructure we don’t own is a line we don’t have to maintain.
upgrade
# the same install command everyone was already using
npx pwnkit-cli@latest scan --target https://your-app.example.com
# or, ~10x faster cold start under bun
bunx pwnkit-cli@latest scan --target https://your-app.example.com
if you were on 0.7.0 and hit the abi crash, the cleanest upgrade path is one rm -rf ~/.npm/_npx/* to evict the cached prebuilds, then re-run the npx command. that takes you to 0.7.1 with no native modules at all, and you should not see the crash again.
the github release notes for v0.7.1 cover everything else that landed in this version: the wp_fingerprint tool, the anti-honeypot flag-shape validator, the n=10 statistical evaluation harness, the methodology documentation page, the benchmark substrate parameterization, and the xben-099 root cause investigation. the wasm migration is the headline because it’s the change that makes every other change actually reachable on first install.