@boltmcp/jq
v0.1.11
Published
jq filter engine for TypeScript, powered by jaq and NAPI-RS
Readme
@boltmcp/jq
Native jq filter engine for TypeScript, powered by jaq and NAPI-RS.
Compile and run jq filters on JSON values at native speed, directly from TypeScript. No child processes, no WASM overhead.
Installation
npm install @boltmcp/jqnpm automatically installs the correct native binary for your platform.
Supported Platforms
| Platform | Architecture | libc | | -------- | --------------------- | ---- | | Linux | x64 | musl | | Linux | arm64 | musl | | macOS | arm64 (Apple Silicon) | - |
Usage
import { compile } from "@boltmcp/jq"
// Compile a jq filter (reusable across many inputs)
const filter = compile(".items | map(select(.active)) | length")
// Run against a JSON value
const result = filter.run({
items: [
{ name: "a", active: true },
{ name: "b", active: false },
{ name: "c", active: true },
],
})
console.log(result.outputs) // [2]
console.log(result.errors) // []Handling multiple outputs
jq filters can produce zero, one, or many outputs per input:
const explode = compile(".[]")
explode.run([10, 20, 30])
// { outputs: [10, 20, 30], errors: [] }
const nothing = compile("empty")
nothing.run(null)
// { outputs: [], errors: [] }Error handling
Compilation errors throw immediately:
try {
compile("invalid syntax [[[")
} catch (e) {
// Error: parse/compile error details
}Runtime errors are collected in the errors array (the filter does not throw):
const filter = compile(".foo")
filter.run(42)
// { outputs: [], errors: ['cannot index 42 with "foo"'] }Filter reuse
The compile() step parses and compiles the jq filter. Reuse the CompiledFilter object to amortize this cost across many inputs:
const filter = compile(".timestamp | fromdateiso8601")
for (const event of events) {
const { outputs } = filter.run(event)
// ...
}API Reference
compile(filter: string): CompiledFilter
Compiles a jq filter string. Throws on syntax or compilation errors.
CompiledFilter
.filter: string (getter)
Returns the original filter string.
.run(input: unknown): RunResult
Runs the compiled filter against a JSON-compatible value. Never throws. Returns a RunResult.
RunResult
interface RunResult {
outputs: unknown[] // Values produced by the filter
errors: string[] // Runtime error messages (empty on success)
}Design Decisions
Why jaq, not jq
jaq is a Rust reimplementation of jq that provides:
- Embeddable library (
jaq-core): jq does not expose a stable C library API suitable for embedding. jaq-core is designed for library use and is memory-safe. - Performance: jaq is fastest on 20 out of 30 benchmarks vs jq and gojq (see jaq README).
- Thread safety: jaq-core can be used in multi-threaded environments. jq's C implementation has global state.
Why NAPI-RS, not WASM
NAPI-RS compiles Rust to a native Node.js addon (.node file). Alternatives considered:
- WASM (e.g., jq-wasm): Portable but slower. WASM has overhead for JS/WASM boundary crossing and limited access to native instructions. NAPI-RS produces native machine code with zero interop overhead.
- Child process (e.g., node-jq): Spawns
jqas a subprocess per invocation. High per-call overhead from process creation, JSON serialization to/from stdin/stdout, and requiresjqto be installed on the system. - FFI/C binding: Would require wrapping jq's C library, which has no stable embedding API and is not memory-safe.
Why separate compile() + run(), not a single function
The two-step API amortizes compilation cost. In hot paths the filter is compiled once and reused. A convenience run(filter, input) function was considered but omitted to keep the API surface minimal and encourage correct usage. Wrapping the two-step API in a one-shot helper is trivial:
function run(filter: string, input: unknown) {
return compile(filter).run(input)
}Why JS values, not JSON strings
Input and output are JS values (unknown), not JSON strings. Alternatives considered:
- JSON string I/O: Would require the caller to
JSON.stringifyinput andJSON.parseoutput, adding boilerplate and serialization overhead. NAPI-RS efficiently bridges JS values to Rust'sserde_json::Valuewithout an intermediate string representation. - Both: Offering both
run(input: unknown)andrunRaw(json: string)was considered but deferred. The JS value API covers all use cases, and a raw string API can be added later if profiling shows benefit for large payloads.
Why runtime errors are collected, not thrown
Compilation errors throw (they indicate a broken filter). Runtime errors are collected in RunResult.errors because:
- jq filters can produce a mix of values and errors in a single run (e.g.,
.[] | .fooon[{"foo":1}, 42]produces one value and one error). - Throwing on the first error would discard partial results.
- The caller can check
result.errors.lengthand decide how to handle errors based on their use case.
Why always return an array
RunResult.outputs is always an array, even for filters that produce exactly one output. Alternative considered:
- Return single value when exactly one output: Would require the caller to check
Array.isArray()or handle two return shapes. Always returning an array is consistent and predictable. The caller can doresult.outputs[0]when they know the filter produces a single value.
Why musl + darwin only
The target platforms match the deployment environment:
- Linux musl (x64 + arm64): For
node:24-alpineDocker containers. Alpine uses musl libc, not glibc. - macOS (arm64 + x64): For local development on macOS.
- Linux glibc: Not included because the consumer runs on Alpine. Adding glibc targets is straightforward if needed (add targets to
napiconfig inpackage.json). - Windows: Not included because the consumer runs in Linux containers. Can be added if needed.
Why jaq-core/std/json, not jaq-all
The package depends on jaq-core, jaq-std, and jaq-json individually, not on the jaq-all convenience crate. jaq-all bundles jaq-fmts which adds YAML, TOML, CBOR, and XML format support that we don't need. Using the lower-level crates produces a smaller binary and fewer dependencies.
Development
Prerequisites
- Rust toolchain
- Node.js >= 18
Building locally
npm install
npm run build # release build
npm run build:debug # debug build (faster compilation)This produces:
index.js— ESM loader that selects the correct native binaryindex.d.ts— TypeScript type declarationsjaq-ts.<platform>.node— native binary for your platform
Testing locally
node --input-type=module -e '
import { compile } from "./index.js"
const f = compile(".foo | map(. + 1)")
console.log(f.run({ foo: [1, 2, 3] }))
'Publishing
Publishing is automated via GitHub Actions. Push a semver tag to trigger a release:
git tag v0.1.0
git push origin v0.1.0CI builds native binaries for all 3 platforms, then publishes to npm using trusted publishing (OIDC — no npm token needed). Provenance is disabled because the source repository is private.
npm packages published
| Package | Contents |
| ---------------------------------- | ------------------------------ |
| @boltmcp/jq | JS loader + TypeScript types |
| @boltmcp/jaq-ts-linux-x64-musl | Linux x64 musl native binary |
| @boltmcp/jaq-ts-linux-arm64-musl | Linux arm64 musl native binary |
| @boltmcp/jaq-ts-darwin-arm64 | macOS arm64 native binary |
The "packageName": "@boltmcp/jaq-ts" in the napi section of package.json controls the naming of these platform-specific packages in the generated index.js. It is independent of the parent package name (@boltmcp/jq) — without it, napi build would derive platform package names from the parent, producing incorrect names like @boltmcp/jq-darwin-arm64.
License
MIT
