@aiquants/platform-packaging
v0.1.1
Published
Build tooling that turns a Go command into per-platform npm packages (esbuild/Biome-style os+cpu split with optionalDependencies, bin-field executable bits, and publish-order guards)
Readme
@aiquants/platform-packaging
Build tooling that turns a Go command into per-platform npm packages, using the same distribution model as esbuild, Biome, and turbo:
- one small parent package (JavaScript only, no binary),
- one
<parent>-<platform>-<arch>package per target holding exactly one binary, - the parent references them through
optionalDependencies, so npm/pnpm/yarn downloads only the package matching the installing machine.
Why this exists
Shipping a Go binary inside a single npm package forces every consumer to
download every platform's binary, breaks the executable bit unless a
postinstall chmod runs (which pnpm 10+ blocks by default), and pins the
published binary to whatever machine ran the publish. The platform-split model
fixes all three, but doing it by hand is a minefield of publish-ordering and
lifecycle-script pitfalls. This package encodes the model once — it drives
@aiquants/storage-api and @aiquants/markdown, and works the same way in any
repository.
Usage
Add the dev dependency (workspace:* inside this monorepo, a normal version
range anywhere else):
pnpm add -D @aiquants/platform-packagingRequirements: Node >= 20 and a Go toolchain on PATH (the builder shells out to
go build with per-target GOOS/GOARCH and CGO_ENABLED=0).
Then create two thin scripts in the consuming package:
// scripts/build-platform-packages.mjs
import { runPlatformPackaging } from "@aiquants/platform-packaging"
runPlatformPackaging({
packageRoot, // the npm package root
goModuleDir, // the Go module directory
goPackage: "./cmd/foo", // go build target (use "." for a root main.go)
binaryBaseName: "foo", // binary name; ".exe" is appended on Windows
})// scripts/publish-platform-packages.mjs
import { publishPlatformPackages } from "@aiquants/platform-packaging"
publishPlatformPackages({ packageRoot })And wire the lifecycle in package.json:
{
"scripts": {
"build:platforms": "node scripts/build-platform-packages.mjs",
"publish:platforms": "node scripts/publish-platform-packages.mjs",
"prepack": "node scripts/build-platform-packages.mjs --inject",
"postpack": "node scripts/build-platform-packages.mjs --restore",
// MUST stay cheap. In a pnpm workspace with injectWorkspacePackages,
// every `pnpm install` runs prepublishOnly for injected packages
// (measured) — use a cached build command (turbo shown here; any
// fast/cached equivalent works) and NEVER put build:platforms
// (6 cross-compiles) here.
"prepublishOnly": "pnpm exec turbo run build --filter=<pkg>",
"publish:patch": "pnpm run typecheck && pnpm run --if-present test && pnpm version patch --no-git-tag-version --no-git-checks && pnpm run build:platforms && pnpm run publish:platforms && pnpm publish --no-git-checks"
}
}Lifecycle facts (measured on pnpm 11)
Which scripts fire in which context decides where work and guards may live:
| Context | Scripts that run | npm_command |
| --- | --- | --- |
| pnpm install (injected workspace dep, dedupeInjectedDeps: false) | prepare, prepublishOnly | — |
| pnpm pack | prepack, prepare, postpack | pack |
| pnpm publish (incl. --dry-run) | prepublishOnly, prepack, prepare, postpack | publish |
| pnpm --prod deploy | none of the above | — |
Consequences baked into this package:
prepublishOnlyruns on every install, so it must be turbo-cached and must never cross-compile. It exists so that install-time injection packs a built package (turbo rebuilds later do not re-sync injected copies).prepack/postpackdo not run on install, so--inject/--restoreand their guards never slow down or break installs.--injectdistinguishes publish from pack vianpm_command, enabling a publish-only registry guard whilepnpm pack(tarball E2E of unpublished versions) keeps working.
Rules that are easy to get wrong
- Platform packages must be published before the parent. The parent pins them
at its own version, so publishing the parent first leaves a window where
installs cannot resolve a binary.
publish:*must call the platform publisher first. optionalDependenciesis never committed. It points at a version that does not exist yet; committing it dirties the lockfile and breaks--frozen-lockfileCI.--injectwrites it atprepack,--restorereverts it atpostpack.- The executable bit comes from the
binfield, not frompostinstall.npm packnormalizes file modes to0644, but package managers restore0755forbintargets. Apostinstallchmodis additionally unreliable: pnpm 10+ refuses dependency lifecycle scripts unless the consumer allow-lists them. GOOS/GOARCHare pinned per target andCGO_ENABLED=0is forced. Without this the publisher's own machine decides what ships, and a dynamically linked binary breaks on any host with an older glibc.
Editing this package (monorepo-internal note)
External consumers can skip this section. The published entry point is
dist/index.mjs, a single minified bundle that pnpm run build produces from
src/ with esbuild; src/ itself is not shipped. This monorepo also resolves
workspace dependencies as injected hardlink copies
(injectWorkspacePackages: true), so a consumer keeps using the copy it was
installed with. After editing anything under src/, run pnpm run build in this
package — it rebuilds the bundle, and syncInjectedDepsAfterScripts then
refreshes every consumer's copy. Skipping the build means consumers silently run
the previous bundle.
Modes
| Command | Effect |
| --- | --- |
| node scripts/build-platform-packages.mjs | Cross-compiles all targets into platforms/<platform>-<arch>/. |
| ... --inject | Backs up package.json, adds optionalDependencies for every target. |
| ... --restore | Restores the backed-up package.json and deletes the backup. |
| node scripts/publish-platform-packages.mjs | Publishes every generated platform package, skipping versions already on the registry. |
Each mode fails loudly rather than doing something plausible-but-wrong:
- An unrecognized mode exits 1 instead of falling through to a build. Otherwise a
typo in the
prepackargument would publish a parent with nooptionalDependencies, and every consumer would get "binary not found". - A leftover backup (a previous pack/publish crashed before
postpack, so the manifest is probably still injected) is handled by version: same version → self-heal (restore the pristine backup, then re-inject, with a warning); different version → exit 1, because silently restoring would resurrect the old version. --injectrequires a non-emptydist/— packing without a build would publish a tarball whose entry points do not exist.--injectruns a publish-only registry guard: whennpm_command === "publish", every platform package must already exist on the registry at the parent's version, or it exits 1. This makes even a rawpnpm publishunable to ship a parent whose binaries are missing.pnpm packis exempt (unpublished versions are normal there). The guard is tri-state: a 404-class answer (ERR_PNPM_PACKAGE_NOT_FOUND/ERR_PNPM_FETCH_404, measured on pnpm 11) means unpublished and prints the publish-first instruction; any other query failure (network down, auth, timeout) is reported separately as could not verify so an outage is never misdiagnosed as "not published". Becausepnpm viewcan keep serving a stale cached 404 right after a successful publish (observed on the real 0.3.0 publish, 2026-07-30), every non-published verdict is confirmed by a direct, cache-free registry fetch before the guard concludes; only when that fetch cannot answer does the pnpm verdict stand.pnpm publish --dry-runalso passes through this guard (dry-run is not detectable from lifecycle env — measured); for that rehearsal, and for verified-but-unreachable emergencies, setPLATFORM_PACKAGES_SKIP_PUBLISH_CHECK=1.- The publisher waits for registry visibility after publishing: npmjs is
eventually consistent, and on the real 0.3.0 publish the six packages took
~60 s to become fetchable after the publish API returned success. Without the
wait, the parent's prepack guard fails on every first attempt. The publisher
polls the registry directly (default: 5 s interval, 180 s timeout — tunable via
PLATFORM_PACKAGES_VISIBILITY_TIMEOUT_MS/PLATFORM_PACKAGES_VISIBILITY_INTERVAL_MS) and exits 1 if visibility is not reached, so the chain stops before the parent rather than at it.PLATFORM_PACKAGES_SKIP_PUBLISH_CHECK=1skips the wait too. - Direct fetches honor
PLATFORM_PACKAGES_REGISTRY_URL(tests point it at a local stub), thennpm_config_registry, thenhttps://registry.npmjs.org. - The publisher requires the
platforms/directory names to match the target set one-to-one (<platform>-<arch>for every target, nothing extra) and exits 1 listing what is missing or unexpected. A bare count check would let "one stray leftover + one missing target" slip through; the name check closes that, so a skipped or partialbuild:platformscannot let the parent ship ahead of its binaries.
Tests
pnpm run test runs a hermetic suite (tests/guards.test.mjs, node --test)
covering every guard above: mode validation, inject/restore byte-exact round
trip, crash-leftover self-heal (same version) vs refusal (version drift vs
corrupted backup), dist presence, all registry-guard outcomes (including the
stale-pnpm-cache-but-actually-published case), and the publisher's visibility
wait (success and timeout). Registry states are simulated by a fake pnpm
placed first on PATH (tests/helpers/fake-bin/pnpm) plus a metadata stub
server (tests/helpers/registry-stub.mjs) targeted via
PLATFORM_PACKAGES_REGISTRY_URL — the stub runs as a separate process
because spawnSync blocks the test's own event loop. The suite needs no
network and cannot be fooled by pnpm's metadata cache. Guards call process.exit(1), so every case
runs through a subprocess driver (tests/helpers/*-driver.mjs) and asserts exit
codes plus on-disk manifest state. Cross-compilation (--build) is out of scope
here — that is covered by the real-tarball E2E documented in the consuming
packages' test specs.
