Native IO
Shortest working example: publish a file atomically, then run one bounded command.
import { writeFileAtomic } from 'stitchkit/files'
import { runNativeCommand } from 'stitchkit/process'
await writeFileAtomic('/srv/app/state.json', '{"version":2}')
const { exitCode, stdout } = await runNativeCommand({
executable: '/usr/bin/git', args: ['rev-parse', 'HEAD'], cwd: '/srv/app',
capture: true, maxOutputBytes: 4096, timeoutMs: 5000,
})
console.log(exitCode, new TextDecoder().decode(stdout))
The examples use public imports and run the same in Bun and Node >= 22.
Writing a file
import { writeFileAtomic, AtomicFilePublicationError } from 'stitchkit/files'
try {
await writeFileAtomic('/srv/app/receipt.json', JSON.stringify({ id: 'receipt-1' }), {
replace: false,
mode: 0o600,
durability: 'directory',
})
} catch (error) {
if (error instanceof AtomicFilePublicationError) {
// The target is already visible. Check the receipt before deciding whether to retry.
console.error(error.phase, error.cause)
} else throw error
}
await writeFileAtomic('/srv/app/state.json', '{"version":2}', {
replace: true, durability: 'directory',
})
The defaults are replace: true, durability: 'file' and mode: 0o600: file fsync and atomic
replacement. durability: 'none' skips every sync. 'directory' runs the full sequence: write all
bytes, exact descriptor chmod, file fsync, rename (replace) or link (create), remove the staging
link, parent directory fsync. writeFileAtomicSync takes the same options and runs the same order.
Create fails with the native EEXIST when the target exists, is a symlink, or a concurrent writer
won. Never turn that refusal into a replace automatically.
AtomicFilePublicationError.published === true tells a visible target from an unconfirmed
acknowledgement. phase is cleanup, directory-sync or directory-close; the primary cause is
kept in cause. An error before publication leaves the previous target untouched. Cleanup errors are
secondary to the primary error. While a create is in flight a second hardlink briefly exists, so a
strict single-link reader may refuse in that window.
Abandoned staging files
The bytes are staged beside the target under .stitchkit-<24 lowercase hex>.tmp, the same name for
writeFileAtomic, writeFileAtomicSync, the managed writer and the chunk spool. A process killed
between the staging and the publication (SIGKILL, OOM, power loss) leaves that file behind, and no
later write removes it: a random name alone cannot tell an abandoned write from one in flight.
Removing it is the caller’s responsibility. A directory that does not exist yet has nothing to
sweep and yields []; a file standing where the directory should be, or a denied listing, throws:
import { isAtomicStagingName, sweepAtomicStaging } from 'stitchkit/files'
// At startup, or before trusting that a directory holds only your own records:
const removed = await sweepAtomicStaging({ directory: '/srv/app/registry', olderThanMs: 60 * 60_000 })
// Skip staging files when listing a directory of your records:
const records = (await readdir('/srv/app/registry')).filter((name) => !isAtomicStagingName(name))
olderThanMs is required and must exceed the longest write in flight; a younger staging file is
left alone. The sweep removes only regular files, never follows a symlink and never enters a
subdirectory. The name form is a stable public contract: changing it is a breaking change of
stitchkit/files. An exclusive lock’s .lock-*.tmp file is not an atomic-write staging file and
does not match.
Directory fsync is a native POSIX filesystem capability. Windows refuses it explicitly, and another
filesystem may refuse at open or sync. A check on Linux does not confirm macOS behavior, network
filesystems or survival of a power loss; the disk state is whatever the filesystem and OS guarantee.
createManagedFileBoundary.write(..., { durable: true }) uses the same publish owner and parent sync;
its create stays a create, and replace: true allows replacement explicitly.
Strict reads
import { createManagedFileBoundary } from 'stitchkit/files'
const files = await createManagedFileBoundary({ root: '/srv/app', maxReadBytes: 64 * 1024 })
const source = await files.read('receipt.json', {
rejectSymlinks: true, singleLink: true, stable: true, observe: true,
})
console.log(source.observation) // dev, ino, size, nlink, mtimeMs, ctimeMs
const receipt = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(source.bytes))
// Schema parsing and digest checks belong to the application.
Without options the result is { ref, bytes } and a trusted in-root symlink or hardlink is
allowed. rejectSymlinks refuses a symlink at the requested leaf, singleLink checks nlink of
the opened descriptor, stable compares dev/ino/size/mtime/ctime/nlink around the byte read, and
observe adds the metadata. These refusals are distinct: FILE_NOT_FOUND, FILE_NOT_REGULAR,
FILE_UNSAFE_LINK, FILE_TOO_LARGE, FILE_CHANGED, FILE_UNSUPPORTED and FILE_IO_ERROR. Abort
keeps the caller’s reason. A nonblocking open lets a FIFO be refused without waiting for a writer.
Bytes are bounded while reading, including a file that grows, not only by the initial stat. Chunk
allocation is at most maxBytes + 1 + 64 KiB and the flattened result at most maxBytes. The
descriptor closes on success, refusal, abort and IO error. A close error keeps the primary read error;
after a successful read a close error is returned to the caller.
The root and its ancestors belong to a trusted actor. These options do not give descriptor-relative containment against a hostile concurrent ancestor replacement, and there is no switch to treat ancestors as safe: the portable owner has no such capability. Metadata stability does not prove an immutable snapshot against an arbitrary hostile writer; use an application lock or immutable publication.
Commands
import { runNativeCommand } from 'stitchkit/process'
const captured = await runNativeCommand({
executable: '/usr/bin/git', args: ['rev-parse', 'HEAD'], cwd: '/srv/app',
envPolicy: 'declared-only', env: { PATH: '/usr/bin:/bin' },
capture: true, maxOutputBytes: 4096, timeoutMs: 5000,
})
console.log(captured.exitCode, new TextDecoder().decode(captured.stdout))
const lifetime = new AbortController()
await runNativeCommand({
executable: '/usr/bin/git', args: ['status'], signal: lifetime.signal,
ownerLoss: 'terminate', // also stop its group if this JavaScript process is SIGKILLed
onOutput: async (bytes, channel, outputSignal) => {
outputSignal.throwIfAborted()
await output.write(bytes, channel, outputSignal) // application-owned bounded sink
},
})
To read output as it appears, use streamNativeCommand: the same owner, group ownership, stop
policy and bounds, with the output as ReadableStream<Uint8Array>.
import { streamNativeCommand } from 'stitchkit/process'
const lifetime = new AbortController()
const command = streamNativeCommand({
executable: '/usr/bin/tail', args: ['-f', '/var/log/app.log'], signal: lifetime.signal,
})
void command.stderr.cancel() // not interested: dropped, the command keeps running
for await (const bytes of command.stdout) handle(bytes) // a slow reader slows the child
await command.result // settles like `runNativeCommand`; `stop()` ends the group and waits for it
The streams own capture, onOutput and stdio; passing one is refused. Each stream queues up to 1 MiB, so
a small output can be read in any order, even after result; past that an unread stream fills its pipe
and holds the command, and its result, until the signal or timeoutMs, so read large outputs
concurrently or cancel the stream you ignore. Cancelling a stream drops its later bytes and leaves the command running. When the
command fails or is stopped, both streams error and the bytes still queued are dropped.
A command needs a caller signal or a finite timeoutMs (1 to 2147483647 ms, the native timer
range; a larger value is refused, never turned into 1 ms). Streaming runs for the caller’s lifetime,
with no hidden 60-second timeout or 1 MiB output limit. capture defaults to false; true requires
maxOutputBytes for the combined stdout and stderr. The limit counts source bytes; the API returns
Uint8Array and does not decode invalid UTF-8. The result holds the observed exitCode: number | null
and signal: string | null; a signal termination is never turned into an invented exit code.
maxOutputBytes may bound streaming too. stdin is bytes bounded by maxStdinBytes (default 1 MiB).
envPolicy defaults to declared-only: no env means an empty environment, and ambient inherits
explicitly. The application owns cwd and executable policy; the primitive launches a native
executable and never creates a shell.
NativeCommandError.reason classifies a limit error by machine: deadline when the command’s own
timeoutMs expires, output-budget when the combined stdout and stderr bytes exceed the limit, in
capture or streaming. Both keep code: 'COMMAND_LIMIT'; message is for diagnosis, not for
protocols. COMMAND_UNAVAILABLE and COMMAND_CLEANUP have no reason. A caller abort and a sink
failure keep the original error. When cleanup fails, the original limit and its reason stay inside the
AggregateError cause.
import { NativeCommandError } from 'stitchkit/process'
try {
await runNativeCommand({ executable: '/usr/bin/git', args: ['status'], timeoutMs: 5000 })
} catch (error) {
if (error instanceof NativeCommandError && error.code === 'COMMAND_LIMIT') {
if (error.reason === 'deadline') console.error('Command timed out')
if (error.reason === 'output-budget') console.error('Command produced too many bytes')
}
throw error
}
The constructor is new NativeCommandError(code, message, { cause }); it accepts
{ cause, reason: 'deadline' | 'output-budget' } only for COMMAND_LIMIT. The owner always sets the
reason for its own limits. An error a caller constructs without a reason gets none inferred from the
text; its reason is undefined.
Output is read with backpressure, stdout and stderr separately: one running sink per channel plus a bounded native Readable buffer, with no accumulation of the whole history while streaming. The callback receives the lifetime signal. Abort releases the wait even for a callback that ignores the signal, but it cannot cancel third-party work the callback continues after the abort. A sink must cancel its own IO, must not keep an unbounded history and must not change shared descriptor flags. After an abort the owner starts no new callbacks. Normal completion waits for all bytes and sinks.
Cleanup uses the POSIX process group under the stop policy, then KILL; cleanupTimeoutMs
(default 2000, maximum 30 000) bounds the wait for close after the signals. On
Darwin a transient EPERM when signalling a group can mean zombies the OS reaper has not collected
yet. The retry is bounded by cleanupTimeoutMs; success needs a delivered signal or ESRCH, and a
permanent refusal is kept in the cause. The close of every owned pipe and the leader’s exit are
observed separately, including the absence of a shared child.close during teardown. The caller or
sink cause is kept; a cleanup failure is a NativeCommandError with COMMAND_CLEANUP and an
AggregateError cause. Even an exited parent does not exclude a helper that holds a pipe. Windows
refuses with COMMAND_UNAVAILABLE; a descendant that has left the group on its own with setsid is
outside this capability. This is one-shot execution, not a PTY, a supervisor or a daemon host.
ownerLoss: 'terminate' binds the command group to this JavaScript process even when it cannot run
cleanup. A package-owned guard becomes the process-group leader, starts the requested executable in
that group and holds one end of a private inherited kernel channel. The caller holds the other end.
SIGKILL, a crash or any abrupt caller exit closes that end; EOF makes the guard KILL its own group.
There is no liveness polling and no later PID lookup. Because the guard is still the group leader
when it signals, its PGID cannot have been reused by a neighbour. The option is available on Linux
and Darwin under Bun and Node, requires group: 'own', and other platforms refuse it with
COMMAND_UNAVAILABLE. It adds one short-lived process and one descriptor per guarded command, so it
is opt-in. onLeaderStarted({ pid }) reports the guard/group leader with this option; output, stdin,
exit status, deadlines, cancellation and onLeaderSettled retain the requested command’s behaviour.
An unbundled application already launches the package’s own guard module. A single-file bundle whose application entry also owns argv routing must load the narrow bootstrap before its dispatcher and skip dispatch when it claims the private guard invocation. This is especially relevant when the full command runner stays behind a dynamic import:
import { bootstrapNativeCommandOwnerLoss } from 'stitchkit/process/owner-loss'
if (!bootstrapNativeCommandOwnerLoss()) {
await dispatch(process.argv.slice(2)) // may lazy-import `stitchkit/process`
}
The bootstrap carries the package-owned guard into the bundle. It returns false for application
argv and true only for the private child started by runNativeCommand, so the application does
not read a guard flag or filename. The built file is self-contained; no installed node_modules
is needed beside it.
Stopping a command: the stop policy
A caller abort, the timeoutMs deadline, an exceeded output budget and a failing sink all stop the
command the same way, by its stop policy:
| field | meaning |
|---|---|
target |
'group': signal goes to every member of the group. 'leader': signal goes to the leader alone; the leader’s exit ends the grace at once. Required. |
signal |
SIGTERM (default), SIGINT, SIGHUP, SIGQUIT, SIGUSR1 or SIGUSR2. KILL is not a stop signal: it is what the grace ends in. |
graceMs |
How long the leader (or the group) has to leave before the whole group gets KILL. 0 to 3 600 000 (one hour). Required. |
killOn |
Optional AbortSignal. Its abort stops the command with KILL to the whole group at once, also in the middle of a grace. |
Omitting stop means { target: 'group', signal: 'SIGTERM', graceMs: 100 }; a command in the
caller’s group (group: 'caller') stops its leader alone, so its default is target: 'leader'
and a group target is refused (see Terminal commands).
// Cooperative cancellation: the leader rolls its work back, its helpers are not asked.
await runNativeCommand({
executable: '/usr/local/bin/migrate', args: ['apply'], signal: request.signal,
stop: { target: 'leader', signal: 'SIGINT', graceMs: 15 * 60_000, killOn: shutdown.signal },
})
With target: 'leader' the rest of the group never receives the cooperative signal: they are
processes the leader started, and the leader decides what happens to them. Whatever is left in the
group when the leader exits, or when the grace ends, gets KILL, including a helper with its stdio
closed that no pipe would have revealed. A leader that has already exited is never signalled again.
The output pipes stay open until the stop sequence ends. Output a stopping command writes is read and
dropped, never passed to onOutput and never captured, so a leader that writes while it shuts down
does not meet a broken pipe. The result rejects with the abort reason once the group is gone and the
pipes are closed, so it can take graceMs plus cleanupTimeoutMs after the abort.
The grace is a timer inside the process that runs the command. Without ownerLoss, if that process
exits first, the timer is gone and the group, which is detached into its own session, keeps running.
Abort killOn in your shutdown path so a deliberate shutdown kills the group before exit. For an
abrupt-exit guarantee, choose ownerLoss: 'terminate'; for all groups in a service, let the service
supervisor own the wider boundary. Under systemd, keep KillMode=control-group (the default) so the
stop of the unit reaches every process in its cgroup, whatever its process group; launchd only kills
the job’s own process group, which an unguarded detached command’s group is not.
Stopping a group and its descendants
A group is signalled only while the kernel still reports members. ESRCH from the first signal,
or from the existence probe that follows it during the grace, means no member is left: no KILL
is sent afterwards, because a numeric group id that has vanished can already belong to an
unrelated group. A group that is still visible after the grace period receives KILL.
descendants declares what happens to processes the leader left running once it has exited
successfully:
descendants |
After the leader exits |
|---|---|
'terminate-after-leader' (the default) |
The group is stopped right after onLeaderSettled returns and before the pipes drain: with target: 'group' by the same signal, grace, KILL sequence as cancellation; with target: 'leader' by KILL at once, since the leader has already exited. A descendant that had already left the group with setsid when the leader exited stays out of reach. |
'leave' |
Nothing. A helper started with its stdio detached keeps running. One that inherited stdout or stderr keeps the pipes open, and the command then waits for it until its deadline or abort. |
Omitting the option means 'terminate-after-leader': a command does not leak the helpers it
started. The result’s descendantsStopped is true when the group still had members after the
leader exited and they were stopped, so a helper that was ended this way is visible to the caller
rather than only missing afterwards; under 'leave' it is always false.
A real daemon detaches into its own session (setsid, as daemon(3) does), is no member of the
group and is unaffected, provided it has left the group before the leader exits. Leaving is
the daemon’s own system call, and the group is stopped as soon as the leader’s exit is observed,
so a helper started in the background races the leader:
| launched by the leader as | after the leader exits |
|---|---|
setsid -f helper |
usually survives: setsid -f forks and returns at once, and the child calls setsid() a moment later, so a leader that exits immediately can still catch it in the group (13 of 300 launches under CPU load) |
setsid helper &, (setsid helper &), nohup helper & |
stopped: the leader usually exits before the background job has left the group |
| a launch the leader waits on until the helper reports it has left the group | survives |
any of these under descendants: 'leave' |
survives |
Start a daemon so that the leader waits for the session to exist before it returns: the helper
writes its pid, or prints a line, only after its own setsid(), and the leader waits for that.
Or declare 'leave'. Declare 'leave' only for a helper that has to outlive the command while
staying in its group:
await runNativeCommand({
executable: '/usr/local/bin/start-helper', timeoutMs: 60_000,
// the helper started here outlives this command on purpose
descendants: 'leave',
})
A command in the caller’s group (group: 'caller') has no group of its own: descendants is
refused there and nothing is stopped after its leader exits. A command whose group this package did not create (a structural
launcher supplied by a host) has no group to stop, and descendants has nothing to act on there.
Replacing hand-written wrappers
An atomic-file wrapper becomes one writeFileAtomic call with the chosen options. A bounded
regular-file read becomes files.read; JSON and schema parsing, receipt conflicts, journal rotation
and application locks stay with the application. A spawn, drain, capture and cancellation wrapper
becomes runNativeCommand; shell policy, command admission, logging, redaction, the bytes-to-text
decoder and the operator sink stay with the application. Long signal-only commands stay streaming;
bounded tar or capture commands get their own explicit limits.
A child the caller holds until it exits is the same call. A host that forwards its own SIGTERM
passes an abort signal and stop: { target: 'leader', signal: 'SIGTERM', graceMs }. It records the
pid in onLeaderStarted({ pid }), which runs once right after the leader exists (a throw stops the
command with that error), and the leader’s exit code and signal in onLeaderSettled: 'exit'
when the leader ended on its own, 'stopped' with the same fields when the guardian stopped it.
It reads stderr through onOutput or capture with maxOutputBytes. drainTimeoutMs bounds how
long the output pipes may stay open after the leader exited: a holder that left the group with
setsid keeps them open, and without the option the command waits for it until timeoutMs or
the signal; with it the command ends with COMMAND_CLEANUP (a holder inside the group is already
stopped by descendants). A worker whose whole group must stop on abort is the default
target: 'group'. Add ownerLoss: 'terminate' when the worker must also die after an abrupt host
loss; in that mode onLeaderStarted reports the package guard that leads the same group. A command
that uses the caller’s terminal is the next section. Importing one leaf does not make
installing the whole npm package smaller; choose a dependency after measuring packed size and its
dependency closure.
Terminal commands: inherited stdio and the caller’s group
An operator CLI that re-runs itself, or any command that prompts or prints in colour, needs the caller’s terminal itself rather than pipes. Two options give it that:
const { exitCode } = await runNativeCommand({
executable: process.execPath, args: [cliPath, ...argv], envPolicy: 'ambient',
signal: shutdown.signal,
stdio: 'inherit', // the command's stdin, stdout and stderr are the caller's: a TTY stays a TTY
group: 'caller', // it joins the caller's process group, so a Ctrl-C at the terminal reaches it
})
process.exitCode = exitCode ?? 1
stdio: 'inherit' hands the caller’s three descriptors to the command; its bytes never pass
through this package, so capture, onOutput, stdin, maxOutputBytes and drainTimeoutMs are
refused by name with it, and the result’s buffers stay empty.
group: 'caller' keeps the command in the caller’s process group instead of a group of its own.
That is what an interactive command needs: the terminal sends Ctrl-C to its foreground group,
and a command in a group of its own is a background group there, which the kernel stops
(SIGTTIN) as soon as it reads the terminal. In the caller’s group the command has no group of
its own, so:
- a stop signals the leader alone:
stop.targetis'leader'(the default here is{ target: 'leader', signal: 'SIGTERM', graceMs: 100 }), andtarget: 'group'is refused, because a group signal would reach the caller; descendantsdoes not apply and is refused: what the leader leaves behind belongs to the caller’s group, anddescendantsStoppedis alwaysfalse;ownerLossis refused: an owner-loss guard must lead a group that belongs only to the command;- a Ctrl-C reaches the caller too; handle
SIGINTin the caller when it must outlive the command.
The two options are independent: stdio: 'inherit' alone keeps colour and isatty for a command
that never reads the terminal, and group: 'caller' alone forwards Ctrl-C to a piped command.
Resource-scoped launchers and shared owners
onLeaderSettled(event, signal) observes leader exit before inherited stdout/stderr pipes
close. A resource owner can stop its external scope there, allowing pipe drain to complete.
The callback runs once, with one of three events:
event.kind |
when | carries |
|---|---|---|
'exit' |
the leader ended on its own | exitCode, signal as the kernel reported them |
'stopped' |
the command was stopped (caller abort, timeoutMs, output budget, failing sink) and the leader’s exit after the stop signals was observed |
the stop’s cause, and the leader’s exitCode and signal (for example SIGTERM, or SIGKILL once the grace ended) |
'error' |
no exit of the leader was observed: it could not start, or its exit did not arrive within cleanupTimeoutMs |
the cause |
A leader that ended on its own settles before the pipes drain; a stopped one settles after the
stop sequence, once its exit is known. A stop that comes after the leader already exited (a
deadline while a helper holds the pipe) does not change its 'exit' event. Unavailable
executables, output sink failure and caller cancellation settle through the same owner. Synchronous native launch
failures such as E2BIG also return a rejected result after this bounded settlement;
schema-invalid and already-aborted inputs refuse before native ownership and do not invoke
the hook. A failing hook preserves both the initial launch error and cleanup cause.
cleanupTimeoutMs bounds
the callback after settlement starts; it never becomes a deadline for signal-only execution.
Honor the settlement signal; arbitrary user promises cannot be forcibly cancelled.
await runNativeCommand({
executable: launcher,
args,
signal: scope.signal,
onOutput: (bytes, channel, signal) => scope.write(bytes, channel, signal),
onLeaderSettled: (event, signal) => scope.settle(event, signal),
});
observeProcessInstance(pid) returns observed with a ProcessInstance, or unavailable
with its cause. probeProcessOwner(pid, recorded) compares that evidence on the machine the
caller established: matched, different-boot, reused-pid, pid-gone, legacy or
unavailable. Missing/partial/denied evidence is not death. Supervisor tree fencing remains
the caller’s job; a PID lifetime is not proof that its whole workload stopped.
Exclusive acquisition checks cancellation again before invoking the protected callback; an aborted acquisition releases its newly owned lock without starting the callback. Existing lock and reclaim-guard records are opened with nonblocking/no-follow flags, must be regular files with one link, and are capped at 16 KiB. Descriptor metadata must remain stable during the read. Unsafe records are not evidence of absence or permission to reclaim. This bounds local record reads; it does not make a hung remote-filesystem syscall interruptible or provide a snapshot against a hostile writer.
An exclusive lock applies exact requested permissions through its creating descriptor,
independent of umask. Default 0600 stays private. Shared 0640 requires a common group and
traversable directories; another UID also needs directory write permission to reclaim a
proven-dead owner’s file.
The lock is published with its owner already recorded: the owner record is written to a
private temporary file in the lock’s directory, then hard-linked to the lock name. link
fails with EEXIST for every caller but one, and the name never exists without a complete
record, so a holder that stalls or dies before publishing leaves no lock and cannot be
displaced. The directory must therefore be on a filesystem that supports hard links. A crash
between the temporary write and the link can leave a .lock-*.tmp file behind; it is not a
lock and nothing waits on it.
ownerlessGraceMs defaults to null: a lock file with no readable owner is never taken
because of its age. This library does not produce such a file; only a writer that creates the name
before its owner record can leave one (for example an empty file from a crashed process).
ownerlessGraceMs: <ms> opts in to reclaiming an empty lock file older than that, and also governs
abandoned empty reclaim guards.
When unset, an empty reclaim guard left by an older writer is taken after 5 000 ms so recovery
is never disabled for good; null refuses that too.
Stale reclaim guards use the same lock owner and a child guard for the inode check and
unlink. Recovery depth is capped at 16; a deeper stale chain refuses recovery with a depth diagnosis retained as the cause of
the bounded LOCK_TIMEOUT. Live owners, unknown process-lifetime evidence and unsafe records are never
reclaimed. Concurrent writers cannot bypass a guard while a prior unlink is paused.
A record that names this very process is held only while a descriptor of this process is open
on the lock: the holder keeps the one it created the lock with until it releases. A process image
replaced in place — execve, which is how bun --watch restarts — keeps its PID and kernel start
time, so its record still matches, but not that descriptor, and the new image takes the lock over
at once instead of waiting for the PID to change. Worker threads and a second copy of the package
in the same process share the descriptor table, so a hold any of them takes is never displaced.
The check counts only when /dev/fd lists the descriptor it opens itself; a listing it cannot
prove complete means “unknown”, which never reclaims. A replaced image of another process is
still judged alive.
Libraries with a Zod-only runtime
Public leaves keep optional peers out of their import closure. Installing stitchkit still
installs the complete package and ky; selective imports alone do not avoid that installation.
A library can use Stitchkit as a build dependency, bundle its selected public native imports,
keep Zod external, and publish the resulting implementation with its library artifact.
// The library's build entry; all mechanisms remain owned by Stitchkit.
export { canonicalJson } from 'stitchkit/primitives';
export { writeFileAtomic, withExclusiveLock } from 'stitchkit/files';
export { observeProcessInstance, probeProcessOwner, runNativeCommand } from 'stitchkit/process';
Bundle for Node-compatible execution with Zod external. Publish declarations from the same
version: carry their reachable relative .d.ts closure, rather than emitting references to
stitchkit that force clients to install it for types. A library exposing its own DTOs may
instead emit declarations for only that DTO contract. Keep the library’s manifest at runtime
dependencies: {} and peerDependencies: { zod: ... } when that is its declared promise.
The packaged Darwin loader computes its addon path at run time from its own file
(module.filename), so a bundler never follows it: bundling stitchkit/server,
stitchkit/files or stitchkit/process yields the same single JS file on every operating
system, with no .node output, bun build --outfile works, and no path of the build machine
becomes a literal of the artifact. A bundle has no installed package beside it, so without the
packaging plugin it never loads the addon: the first call that needs it reports the Darwin backend
unavailable with stage packaging, and the message names createNativePackaging. An artifact
that must carry the Darwin addon uses the packaging plugin of the next section; it is the only way
a static loader enters a bundle.
Only these calls load the addon, and only on macOS: process identity (observeProcessInstance
and probeProcessOwner), every exclusive lock (withExclusiveLock and what is built on it, such
as the file state store and the diagnostic journal) and the contained file operations of the Agent
coding tools. Without the addon a lock still works, but it cannot record or check its owner’s
identity, so a lock held by a crashed owner is not reclaimed. The managed file boundary
(createManagedFileBoundary and the transfer tools built on it), atomic writes and the chunk
spool never load it, on any operating system. The package is free of import side effects
(sideEffects: false), so a bundle that imports only those parts carries no Darwin loader at all.
Check an artifact by its loader, never by whether it holds .node bytes: Bun can copy the addon
into a compiled executable even without the plugin, and the unpackaged loader does not use it.
inspectNativeArtifact(bytes) from stitchkit/files/packaging reads the marker every generated
loader carries and answers packaged (the plugin’s static loader is inside), unpackaged (the
default loader is inside, and every call above refuses on macOS) or no-loader (nothing in the
artifact loads the addon). It reads both latin1 and UTF-16LE, because Bun stores source text with
non-ASCII characters as UTF-16, and it does not mistake the STITCHKIT_NATIVE_NOT_PACKAGED error
code, which every bundle with a lock carries, for a loader. Artifacts built by 0.107.0 or earlier
carry no marker and read no-loader.
import { inspectNativeArtifact } from 'stitchkit/files/packaging'
const loader = inspectNativeArtifact(await Bun.file('dist/cli').bytes())
if (loader === 'unpackaged') throw new Error('dist/cli was built without native packaging')
bun build src/native.ts --target=bun --minify --outfile=dist/native.js
That plain build serves Linux and the parts that never load the addon. A library whose consumers
take locks or read process identity on macOS builds each native entry with the companion plugin of
the next section (one entry per build, naming.entry equal to entryPath) and ships the addon it
writes beside the entries.
Qualify the complete output outside its build tree and installed dependency graph. Test process
identity, live-owner refusal, dead-owner recovery and contained file operations in artifacts built
with the packaging plugin, and check that an unpackaged build reports the backend unavailable
with stage packaging rather than certifying a dead owner. Bun and Node package imports retain the same
lazy loader; importing a portable leaf does not load a Darwin addon on another OS. Test
declarations too, and check that importing an unbundled Stitchkit leaf fails in a deliberately
isolated library distribution. A separate schema-only entry must not import the native entry.
If the Darwin backend cannot load, observeProcessInstance still returns unavailable.
Its backend Error preserves the original cause; JSON serialization carries only the stable
DARWIN_BACKEND_UNAVAILABLE code, architecture, safe message and failure stage
(architecture, packaging, resolve, load or surface), plus a recognized native error code
when present: packaging is a bundle built without the packaging plugin, with native code
STITCHKIT_NATIVE_NOT_PACKAGED.
It excludes stack, paths and nested cause. This backend diagnosis does not reclassify kernel
failures or turn unavailable identity into proof that an owner is dead. If a runtime provides
no recognized native error code, the stage stays load; the loader never infers errno from text.
This delivery removes a runtime kernel installation; it does not remove the build-time Stitchkit installation or create an independent lightweight npm package.
Public native packaging
Custom archives and installers use stitchkit/files/packaging at build time.
createNativePackaging resolves the installed version and target addon, reads the addon once and
checks it against the size and SHA256 that Stitchkit published in its native-assets.json when the
package was built. A ready asset carries those verified bytes with the published size and
sha256; its Bun-compatible plugin integrates the same bytes with the same lazy native loader.
Each call reads and hashes the selected addons, so call it once per build and reuse the result.
The leaf is evolving. It requires no optional peer or Bun ambient declarations for Node imports.
Calling it must happen in a process that runs it from its installed package: it locates the
package’s native-assets.json beside its own file and throws Native packaging must run from its installed Stitchkit package anywhere else, such as inside an application’s runtime artifact.
Importing the leaf is side-effect free (it loads no addon and reads no file), so a module that
both builds and is bundled may import it; only the call has to stay in the build step.
import { createNativePackaging } from 'stitchkit/files/packaging'
import { mkdirSync, writeFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
const native = createNativePackaging({
platform: 'darwin', architecture: 'arm64', delivery: 'companion',
entryPath: 'app/native.js', assetPath: 'addons/owner.node',
})
if (native.state !== 'ready') throw new Error(native.code)
const result = await Bun.build({
entrypoints: ['src/native.ts'], target: 'node', format: 'esm',
outdir: 'dist', naming: { entry: 'app/native.js' }, splitting: false,
plugins: [native.plugin],
})
if (!result.success) throw new AggregateError(result.logs, 'Build failed')
// Preserve every result.outputs file under dist, then write the verified companion bytes.
for (const asset of native.assets) {
const destination = join('dist', asset.outputPath)
mkdirSync(dirname(destination), { recursive: true })
writeFileSync(destination, asset.bytes)
}
In companion delivery, entryPath and assetPath are relative paths inside the application’s output root, without
absolute paths, traversal or overlapping file/directory paths. assetPath may contain [hash],
which becomes the first 16 hex digits of that addon’s published SHA256: the digest is known before
the build, so addons/darwin-arm64-[hash].node gives two installed versions of the package
different companion names, and the loader requires exactly that name. It is the only template an
addon path accepts. entryPath is fixed, without
Bun naming templates such as [dir] or [name]. Companion JS builds require one entry, no splitting,
and naming.entry exactly matching entryPath; the plugin rejects a mismatching layout.
Choose any application layout; the framework does not prescribe an app directory or installer.
The plugin packages the loader of the installation it was created from. A build whose entry
imports another stitchkit installation — another version, or the same version under another
path, as when a build tool carries its own copy — fails with an error naming both versions and
roots: that installation’s loader would refuse the addon at run time, so the artifact is never
written. Call createNativePackaging from the stitchkit the entry imports.
Keep every bundler output and write asset.bytes; never read the addon from node_modules a
second time. An installed addon whose size or SHA256 differs from the published manifest refuses
with mismatch / NATIVE_ASSET_DIGEST_MISMATCH, so a file substituted after installation never
reaches an artifact. Archive the complete output directory with an integrity manifest that records
asset.sha256, unpack to a clean directory, verify the same hashes there, and run offline without
the build tree or node_modules. The published digest ties the bytes to the package Stitchkit
built; the package itself is only as trusted as its tarball (the lockfile integrity of the install).
Signature and trust policy remain application-owned.
One JS artifact can run on both Darwin architectures; change only the packaging inputs:
const native = createNativePackaging({
platform: 'darwin', architecture: ['arm64', 'x64'], delivery: 'companion',
entryPath: 'app/native.js',
assetPath: { arm64: 'addons/arm.node', x64: 'addons/intel.node' },
})
Use the same single plugin and write loop above. A single-target loader deliberately refuses the
other architecture; two competing plugins cannot produce a universal loader. The array form
requires exactly one distinct output path per declared target. A ready result carries the target
array and both verified assets with their published digests; NativePackagingOptions<true> /
NativePackagingResult<true> describe this form when explicitly annotating variables.
The loader selects only process.arch: a missing or wrong-architecture selected addon refuses,
even when the other valid addon is present. Linux runtime imports remain lazy and use Linux
process primitives without loading Darwin assets. Qualify the exact same JS bytes on real
Darwin arm64 and x64 machines, with both companions preserved through archive delivery.
Build-machine architecture and cross-build success cannot establish universal native support.
For a standalone executable use delivery: 'embedded' and its plugin in Bun.build with
compile. An executable carries the addon inside itself, so this form names no entryPath or
assetPath; passing one is a type error and a schema refusal, never silently ignored:
const native = createNativePackaging({
platform: 'darwin', architecture: 'arm64', delivery: 'embedded',
})
if (native.state !== 'ready') throw new Error(native.code)
const result = await Bun.build({
entrypoints: ['src/cli.ts'], minify: true,
compile: { target: 'bun-darwin-arm64', outfile: 'dist/cli' },
plugins: [native.plugin],
})
if (!result.success) throw new AggregateError(result.logs, 'Build failed')
Bun embeds the selected addon from the verified bytes the plugin holds, not from a second read of
the file, and the executable loads it from /$bunfs/root/darwin-arm64-<hash>.node. Its assets
(NativePackagingEmbeddedAsset: bytes, size, sha256) identify those bytes and their
published digest for qualification; there is nothing to write beside the executable.
Cross-builds must select the requested architecture explicitly; the artifact must run on that target.
The installed package must contain that target’s addon. No automatic fallback/downgrade occurs.
platform is the closed set of platforms with native addons, today 'darwin': another name is a
type error and a schema refusal that throws, so pass the literal (or narrow process.platform
with === 'darwin') rather than a free string. The API returns unsupported /
NATIVE_TARGET_UNSUPPORTED for an unsupported architecture, missing / NATIVE_ASSET_MISSING for
an addon the package does not publish or whose file is gone, and mismatch /
NATIVE_ASSET_DIGEST_MISMATCH for an addon whose bytes differ from the published size or SHA256.
A mismatch names the first differing architecture and carries expected (the published
{ size, sha256 }) and actual (the installed file’s size and the SHA256 of the bytes read), so
a build log can tell a truncated copy from a substituted one:
if (native.state === 'mismatch')
throw new Error(
`${native.architecture} addon: expected ${native.expected.size} bytes ` +
`${native.expected.sha256}, found ${native.actual.size} bytes ${native.actual.sha256}`,
)
Portable libraries
and Linux builds use their normal build, without a Darwin plugin; merely importing the build leaf
loads no addon.
Malformed inputs, a manifest whose formatVersion is not 2 and other IO errors throw before packaging.
A missing or corrupt companion at runtime yields the native unavailable result with the safe
stage and code from the observer; it never certifies a dead owner. Preserve the raw cause internally.
The plugin does not parse consumer JS or establish a second runtime native implementation.
Qualification boundaries
Native command cleanup owns the child before launch subscriptions are installed. Setup
refusal still settles leader closure and teardown within the cleanup budget; failure to
prove closure reports COMMAND_CLEANUP with the original failure retained. Process-group
signaling requires evidence from the actual detached launcher, not a structural PID field.
The built-in sandbox transport carries that ownership across the package’s split entrypoints.
Internal finite deadlines and Darwin retry admission use monotonic elapsed time.
Linux UID-refusal qualification is a separate privileged installed-package proof in Bun and real Node; an unsupported platform or insufficient UID reports an explicit skip. A generic native-owner marker does not certify the privileged case. Real Darwin CI remains the qualification for native Darwin behavior.
Local filesystem tests do not attest delayed NFS visibility, shared-volume microVM or gVisor ownership, actual PGID reuse, power-loss durability or macOS full-sync behavior. Those require their actual topology or failure environment. No age-only observation proves a process dead; the lock’s owner record exists before its name does.
A sandbox admission slot is released once a direct command’s pipes have closed and its owner has settled.
A COMMAND_CLEANUP outcome means the death of its group was not proven, so the slot stays
occupied, stop keeps rejecting with the cleanup error, and the sandbox session is recreated to
recover.