@deliciousmonster/harper-binary-kit
v0.1.0-next.4
Published
Ship a native binary from a Harper component: one platform package per host, staged, verified, published, and resolved back at runtime
Maintainers
Readme
harper-binary-kit
Ship a native binary from a Harper component. One npm package per host carries that host's binaries, the component depends on them optionally so npm installs only the matching one, and at runtime the component asks the installed package where its binary landed rather than computing a path.
That last sentence is the whole design, and both halves live here: stage writes the module that answers, and
resolve is what calls it. They are one package because separately they agree with themselves.
Plain ESM, node: builtins only, no build step. Node 22.18+ or 24+.
Install
npm install @deliciousmonster/harper-binary-kit # the runtime half
npm install -D @deliciousmonster/harper-binary-kit # and the CLI, if the same repo buildsDeclare
binary-kit.config.js at the repo root is the single declaration every step reads:
export default {
scope: '@acme/agent-binary',
targets: ['linux-x86_64', 'linux-arm64', 'macos-arm64', 'windows-x86_64'],
variants: [
{ suffix: '' },
{
suffix: '-probe',
optional: true,
carries: 'It carries the probe and 42 MB of precompiled objects, which most nodes never load.',
// A plain string is carried everywhere this variant publishes. Name targets when only some carry
// it: the same binary can reach the kernel a different way per platform, and a directory two of
// three have nothing for would refuse to stage them.
extraDirs: [{ dir: 'share/probe', onlyOn: ['linux-x86_64', 'linux-arm64'] }],
},
],
binaries: [
{ shipsAs: 'agent', symbol: 'CONNECTIONS_CHECK' },
{ shipsAs: 'trace-agent' },
{ shipsAs: 'probe', variant: '-probe', onlyOn: ['linux-x86_64', 'linux-arm64'] },
],
floors: { 'linux-x86_64': { GLIBC: '2.36', GLIBCXX: '3.4.30' } },
manifest: { license: 'Apache-2.0', repository: { type: 'git', url: '…' } },
readme: (pkg) => `# ${pkg.name}\n\n…where these bytes came from…`,
};Building is yours. Leave the binaries at build/<target>/bin and anything shipped beside them under
build/<target>/, and the kit takes it from there. A build step that would rather ask than hardcode those
paths imports them:
import { buildTree } from '@deliciousmonster/harper-binary-kit/layout';
const { bin, share } = buildTree(process.cwd(), 'linux-x86_64');Four processes meet at those paths on four separate runners, so a build that recomputes them is a convention with two owners.
Resolve, at runtime
import { createBinaryResolver } from '@deliciousmonster/harper-binary-kit/resolve';
const resolver = createBinaryResolver({
packageName: '@acme/agent-binary',
packageRoot: `${import.meta.dirname}/..`,
variants: [{ suffix: '' }, { suffix: '-probe', optional: true, carries: '…' }],
// From YOUR module: a bare specifier resolves against the file the `import` is written in, so a resolver
// importing from inside this package would look for your platform packages beside this one.
load: (name) => import(name),
});
const path = await resolver.resolveBinary({ shipsAs: 'agent', title: 'the agent' });Each variant is asked in order, then a dev checkout's own build/<target>/bin. A binary that resolves to the
wrong file is refused rather than returned: a platform package published before a second binary existed
answers every request with the first one, and that path exists on disk.
Release, in CI
harper-binary-kit stage # build trees -> npm/<name>/, manifest, index.js, README
harper-binary-kit floor linux-x86_64 # symbol versions against the image the binaries ship to
harper-binary-kit verify # what npm WOULD pack, per package
harper-binary-kit publish # every package, attempting all of them, then read the registry back
harper-binary-kit latest # point latest at this release, forward only
harper-binary-kit deps # what optionalDependencies should say, for `npm version`
harper-binary-kit names # every package name, for a workflow that needs the list.github/workflows/release.yml is a reusable workflow that calls these in order. Its matrix comes from targets, so
the list a package publishes and the list CI builds cannot disagree.
What each step is defending against
Every one of these shipped before the step existed.
stage refuses a package whose binary the build did not produce, and refuses an --only that matches
nothing. Both used to report success: --only <name> filtered against the current host's platform, so on a
developer's machine of another platform it staged nothing and printed "created successfully".
floor reads the symbol versions a binary needs against what the target image provides. A build on a
newer runner produces a binary that will not load at all on the image it ships to, the failure is at exec time
on the customer's node, and every test on the runner that built it passes.
verify asks npm what it would pack rather than reading the directory. files, .npmignore and npm's own
lists all apply at pack time, so a package can look correct in a checkout and ship without the binary it
exists for. A declared symbol catches the one defect a file listing cannot see: a binary present, correctly
named, the right size, and compiled without the thing it is for.
publish attempts every package and collects the failures rather than stopping at the first. npm answers a
publish to a name with no trusted publisher with 404 rather than 403, so "not configured" and "not there" read
identically and trying is the only way to find out. Then it reads the registry back, at the version
endpoint: npm publish exiting 0 is not the package being there, and the packument lags its own writes by
long enough to send somebody chasing a partial release that never happened.
latest moves the tag forward only. npm assigns latest on a package's first publish and never again, so
a package released only under a prerelease tag freezes at whatever version created the name — and latest is
what npmjs.com shows and what a bare npm install gets.
Development
npm test is node --test, no build. npm run typecheck is tsc --noEmit.
test/unit/contract.test.js is the one to read first: every case stages a real package into a temp directory
and resolves a binary back out of it, because a resolver driven by a hand-written fake proves nothing about the
module the staging actually writes.
