@paulmillr/jsbt
v0.7.2
Published
JS Build Tools: build, benchmark, test libs and apps
Maintainers
Readme
@paulmillr/jsbt
JS Build Tools
Zero-dependency helpers for secure JS apps, used by noble cryptography.
- test 500-line simplicity with mocha-like syntax and parallelism
- benchmark with nanosecond resolution
- random micro property-based testing with shrinking and replayable failures
- CLI to check project for common mistakes
- workflows for GitHub CI actions for test / npm+jsr publish
- tsconfig with strict, doc-friendly, with type stripping
Usage
npm install @paulmillr/jsbt
jsr add jsr:@paulmillr/jsbt
1. test
Small test runner with familiar describe / it mocha-like syntax, explicit execution, and
optional parallelism. Compatible with node --test.
Benefit over other test frameworks: NO code injection, due to runWhen().
Run a project test entrypoint with node:
node test/index.ts
JSBT_WORKERS=auto node test/index.ts # default mode
JSBT_WORKERS=3 node test/index.ts # use 3 cores
JSBT_WORKERS=50% node test/index.ts # use 50% of cores
JSBT_QUIET=1 node test/index.ts # quiet mode
JSBT_BAIL=0 node test/index.ts # don't stop after first fail
JSBT_FILTER=math/adds node test/index.ts # run specific test(s)
JSBT_DEBUG=1 node test/index.ts # print 10 slowest tests + median test timeimport { deepStrictEqual } from 'node:assert';
import { beforeEach, describe, it } from '@paulmillr/jsbt/test.js';
describe('math', () => {
let value = 0;
beforeEach(() => {
value = 2;
});
it('adds', () => {
deepStrictEqual(value + 2, 4);
});
it('works with async code', async () => {
deepStrictEqual(await Promise.resolve(value * 3), 6);
});
// modes
it.skip('documents known gaps without running them', () => {
deepStrictEqual(true, false);
});
it.only('runs this one test', () => {
deepStrictEqual(5, 5);
});
it.serial('always runs in non-parallel mode', () => {
console.log('io-friendly method');
})
});
it.runWhen(import.meta.url);2. benchmark
Lightweight benchmark helpers with nanosecond timing, terminal-friendly output, throughput units, and a matrix runner for comparing libraries, algorithms, platforms, input sizes, and other dimensions.
benchmark
Use bench for simple one-line measurements:
import bench from '@paulmillr/jsbt/benchmark.js';
const data = new Uint8Array(1024 * 1024);
const processBlock = () => data[0];
await bench('sqrt', () => Math.sqrt(2));
await bench('copy 1MiB', () => data.slice(), { bytes: data.byteLength });
await bench('blocks', () => processBlock(), { throughput: { amount: 16, unit: 'blocks' } });Options:
bytes: bytes processed by one benchmark iteration; output isb/sec,kib/sec,mib/sec, orgib/sec.throughput: custom units processed by one iteration, for example{ amount: 16, unit: 'blocks' }.maxRunTimeSec: per-benchmark runtime, from0.1to60seconds; defaults to0.4. Every measured run first performs an untimed warmup lasting one quarter of this duration.mode: 'time': print aggregate mean duration per operation.mode: 'latency': print p50, p95, and p100 (maximum) latency.mode: 'once': run one measurement and print only elapsed time.runOnceremains an alias.section('math')named export: print# mathin text output and prefix CSV names asmath; <name>.section()orsection('')disables the prefix.JSBT_CSV=1forces CSV output. CSV printsname,nanosecondsby default, orname,<unit>/secforbytesandthroughput, and is also the default when color output is disabled.
Example output:
sqrt x 6,072 ops/sec @ 164 μs/op
copy 1MiB x 1,420 mib/sec
blocks x 92,400 blocks/secbenchmark-compare
Use benchmark-compare for benchmark matrices. Static dimensions provide benchmark arguments; nested
library objects provide dynamic dimensions.
import compare from '@paulmillr/jsbt/benchmark-compare.js';
const sizes = {
'1KB': new Uint8Array(1024),
'1MiB': new Uint8Array(1024 * 1024),
};
const libraries = {
js: (buf) => buf.slice(),
native: (buf) => Buffer.from(buf),
};
await compare('copy', { size: sizes }, libraries, {
bytes: ({ args }) => args[0].byteLength,
});Common options:
libraryDimensions: names for nested library levels; defaults to['name'].defaults: fixed dimension values that should not vary in the table.dimensions: explicit dimension order and subset.filter: comma-separated match terms;a|b,cmeans(a or b) and c.filterObj: predicate for filtering generated benchmark cases.mode:normalfor aggregate throughput,timefor aggregate mean duration, orlatencyfor p50/p95/p100 latency; may be selected per case with a function. Every non-dry case is warmed independently for one quarter of its measurement time.iterations: repeats one measured operation and reports per-iteration timing.patchArgs: rewrites generated benchmark arguments before calling a library function.bytes,throughput,metrics: add throughput or custom metric columns.loadRun,skipThreshold,printUnchanged: compare against a saved previous run.format:tableorcsv; table is the default when colors are enabled, CSV otherwise.
ENV variables:
FILTERselects cases by substring-matching dimension values:FILTER=sha256,1MBrequires every comma term to match some dimension; the scoped formFILTER='algorithm=sha3_256;library=awasm,noble'pins terms to a dimension, with commas as alternatives.JSBT_BENCHMARK_DIMENSIONS=algorithm,size,namechanges dimension order or visible dimensions.JSBT_BENCHMARK_DRY_RUN=1prints the selected matrix without measuring.JSBT_CSV=1forces CSV output.
3. random
Micro property-based testing: seeded generators with edge-case bias, counterexample shrinking, and replayable failures. Also exports deterministic PRNG helpers for benchmarks.
import * as random from '@paulmillr/jsbt/random.js';
const mod = (n) => ((n % 13n) + 13n) % 13n;
random.assert(
random.property(random.bigint(1n, 12n), random.bigint(1n, 12n), (a, b) => {
return mod(a * b) === mod(b * a);
})
);Each assert runs the predicate numRuns times (default 100) with fresh random inputs.
A predicate fails by returning false or throwing. On failure, the inputs are shrunk to a
minimal counterexample and an error is thrown with a replayable { seed, path }:
Property failed after 5 runs and 12 shrinks { seed: "0x92b21f0e5830f4c7", path: 4 }
Counterexample: [0n, 3n]
Predicate returned falseReplay just the failing run with random.assert(prop, { seed: '0x92b21f0e5830f4c7', path: 4 }).
Arbitraries (value generators):
int({ min, max })integer; defaults to signed 32-bit range. Shrinks toward 0.bigint(min, max)orbigint({ min, max })bigint; defaults to ±2^256. Shrinks toward 0.array(item, { minLength, maxLength })array of values fromitem; length defaults to [0, 10].bytes({ minLength, maxLength })Uint8Array; length defaults to [0, 64].string({ unit, minLength, maxLength })string ofunits; default unit is a printable ASCII char.tuple(...arbitraries)fixed-length tuple, one arbitrary per position.arb.map(fn)transforms generated values; shrinking happens in the source domain.arb.filter(predicate)keeps matching values; throws if <1% of values match.
Every fourth run is biased toward edge cases: range bounds, zero, empty and constant-filled arrays — inputs uniform sampling rarely hits.
Runner:
property(...arbitraries, predicate)declares "for all values, the predicate holds".asyncProperty(...arbitraries, asyncPredicate)same, for async predicates; run withawait assert(...).assert(prop, { numRuns, seed, path })runs a property; options override the global config.config({ numRuns, seed })merges options into the global config and returns a snapshot.
Deterministic helpers, seeded by a number or a string label:
makeRng(seed)returns a() => numberproducing floats in [0, 1).shuffled(items, seed)deterministic Fisher–Yates shuffle; returns a new array.pseudoRandomBytes(length, seed)deterministic pseudo-random bytes. Constant or sequential data can bias benchmarks (cache access patterns, branch prediction, memcmp fast paths); same seed always produces the same bytes, keeping runs comparable across libraries.
4. CLI
jsbt-check CLI executes audit helpers.
check
Runs opinionated code quality checks. Uses typescript parsing underneath. Temporary build artifacts are created in a per-run OS temp directory and removed after the summary.
Example-running checks (readme, tsdoc, errors) execute examples in an isolated temp run
directory. Its node_modules is assembled from symlinks — nothing is fetched at check time:
- the checked package itself, installed under its own name;
- the package's runtime
dependencies, linked from the project's installednode_modules; - extra example-only imports allowed by
exampleDependenciesin a committed.jsbtrc.jsonbesidepackage.json, pinned to exact installed versions:
{
"exampleDependencies": {
"micro-packed": "0.7.3"
}
}An example importing anything else fails at run time with ERR_MODULE_NOT_FOUND, naming the
package but not the list it is missing from. When any check reports one, jsbt-check prints
a reminder about exampleDependencies once, after the last check.
jsbt-check --gen-config fills the list in automatically from what the examples import.
esbuild (importable by example code) is provided automatically and must not be listed: it
is resolved from the project's node_modules, from the copy next to jsbt itself, or from a
global install. If none is found, run npm install -g esbuild. The size selector measures
through bismar, which brings its own pinned esbuild.
The checks always parse and type-check with jsbt's own pinned typescript, never with the
one the checked project installs. The checks drive the JS compiler API directly, and a
project is free to depend on a TypeScript that does not expose it — the v7 native port is a
Go rewrite with a different surface. Pinning one compiler also keeps verdicts identical
across repos, and lets jsbt-check run in a project with no node_modules of its own.
Checks run against the package in the current directory; in a monorepo, cd into the
package first.
jsbt-check
jsbt-check bigint
jsbt-check bytes
jsbt-check comments
jsbt-check errors
jsbt-check importtime
jsbt-check jsr
jsbt-check jsrpublish
jsbt-check mutate
jsbt-check patterns
jsbt-check readme
jsbt-check size
jsbt-check tsdoc
jsbt-check typeimport
jsbt-check --ignore=readme,tsdoc
jsbt-check --gen-config--ignore=<a,b> skips the listed selectors; it accepts the same names as the selector
argument and errors if nothing would be left to run.
The one non-check mode is --gen-config, which writes .jsbtrc.json instead of auditing:
it scans runnable README fences and TSDoc @example blocks for imports that neither
dependencies nor exampleDependencies allow yet, and adds them to exampleDependencies
pinned to the exact installed versions. Existing entries are hand-set and never touched, and
the rest of the file carries over unchanged. It is a mode of its own rather than a flag on a
check: it runs no checks, takes no selector, and is the one jsbt-check invocation that
writes to the package directory.
With "check": "jsbt-check" in package.json scripts, selectors can be run through npm:
npm run check bigint
npm run check bytes
npm run check comments
npm run check errors
npm run check importtime
npm run check jsr
npm run check jsrpublish
npm run check mutate
npm run check patterns
npm run check readme
npm run check size
npm run check tsdoc
npm run check typeimportSelector summary for jsbt-check <selector>:
bigint: find BigInt compatibility hazards in public runtime files.bytes: inspect byte/typed-array API surface and TypeScript-version compatibility.comments: enforce comments and release-facing source annotations.errors: verify documented thrown errors against runtime probes.importtime: measure public entry import time and flag slow imports.jsr: validate JSR package metadata, exports, imports, and publish graph.jsrpublish: run stricter JSR publish-readiness checks.mutate: detect mutation hazards in public runtime behavior.patterns: report source patterns that are risky for published packages.readme: type-check and run runnable README examples.size: audit release bundles for retained unused code and enforcesizeLimitsbudgets.tsdoc: audit public declaration docs and examples.typeimport: verify imports that should be type-only.
size limits
jsbt-check size measures release bundles with
bismar — the same engine behind bismar --size and
bismar -bs — then audits them for unused locals that survived bundling and enforces the
gzip budgets in sizeLimits:
{
"sizeLimits": {
"index.js": "8kb",
"index.js/add": 4096,
"index.js/sign index.js/verify": "6kb"
}
}Keys are bismar --size selectors; values are bytes (4096) or a kb string ("4kb",
1kb = 1024). A space-separated key budgets the combined bundle of all its selectors — their
cost when imported together, with shared code counted once. Only local modules and exports
can be budgeted.
The check itself prints no stats. To debug an over-budget entry, ask bismar directly:
bismar -bs <selector...> for the numbers, bismar <selector> > out.js for the measured
bundle bytes.
5. Workflows
Secure GitHub CI configs for testing & publishing JS packages.
The files reside in .github/workflows:
test.yml: reusable/manual test workflow for Node 22, 24, 26, Bun, and Deno. It runsnpm run build --if-present,npm test, optionaltest:tscon Node 26, optionaltest:bun, and optionaltest:deno. Inputs:submodulesandruns-on.test-matrix.yml: reusable/manual Node matrix across Node 22, 24, 26 onubuntu-24.04-arm,macos-latest, andwindows-latest.test-custom.yml: reusable Node 26 workflow for one custom npm task, defaulting totest:slow.release.yml: release/reusable/manual publisher for NPM, and JSR whenjsr.jsonexists. It uses OIDC Trusted Publishing, disables package-manager cache, runsnpm ci, builds when present, verifies package/tag versions, dry-runs NPM publish, validates JSR version, and publishes throughnpm stage publish --access public.
You can copy them, or depend on them directly:
name: jsbt 0.5.2
on:
push:
pull_request:
jobs:
test:
uses: paulmillr/jsbt/.github/workflows/[email protected]For releases, configure NPM Trusted Publishing for the package first:
name: Publish release
on:
release:
types: [created]
jobs:
publish:
uses: paulmillr/jsbt/.github/workflows/[email protected]
permissions:
contents: read
id-token: write6. tsconfig
Strict typescript v6+ configs, friendly to type stripping. Uses isolatedDeclarations and verbatimModuleSyntax
to ensure node.js is able to natively run typescript files without compilation.
There are two files: tsconfig.json and tsconfig.test.json (looser, for tests).
Inheritable in the following way:
{
"extends": "@paulmillr/jsbt/tsconfig.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "."
},
"include": ["src"],
"exclude": ["node_modules"]
}License
MIT License
