Building
Primate runs on Node, Bun, and Deno via rcompat. It avoids
runtime-specific APIs and abstracts differences via rcompat. You build
and run with your chosen runtime, and target Web or Native.
Why rcompat
Using rcompat keeps apps portable and future-proof:
- No vendor lock-in to a single runtime
- One codebase, three runtimes
- Unified stable interfaces insulate you from runtime churn
- Matches Primate's stance elsewhere (frontend, backend, database agnostic)
Build and run
Dev
# Node
npx primate
# Bun
bunx --bun primate
# Deno
deno run -A npm:primate
Build
# Node
npx primate build
# Bun
bunx --bun primate build
# Deno
deno run -A npm:primate buildPass --strict to fail the build when application configuration or a build
hook actually calls a deprecated API. Ordinary builds print the same diagnostic
and continue. Route-handler references are not scanned, and runtime calls remain
warnings even when the artifact was produced by a strict build.
The command exits nonzero when compilation, migration artifact generation, or
another build step fails, so CI and deployment scripts can stop immediately.
It also evaluates route declarations once in an isolated, time-limited process
to catch errors such as a body schema without contentType. This does not
start the application server or invoke route handlers, serve hooks, or
migrations. Route modules and their imports are trusted application code:
top-level initialization can perform I/O and must work on the build host,
including when building for a different runtime. The validator uses the build
host's runtime conditions; target-specific declarations still need testing on
their target runtime.
Route validation retains the server build's native add-on resolution. Imports
such as @resvg/resvg-js do not need a validator-only externalization override.
Ship the generated build/native/ bindings alongside build/server.js when
your build produces them; the bindings must match the deployment platform.
Serve a Build
# Node
npx primate serve
# Bun
bunx --bun primate serve
# Deno
deno run -A npm:primate serve
Update guidance
Run primate update from an application to send its installed release line to
the update service and print the todos for the single immediate next stable
release, when one exists:
primate updateTodos are grouped by the packages in your application's package.json. Use
primate update --all to see every package's guidance. In a terminal, API
names are highlighted; redirected output and NO_COLOR retain backticks. Use
primate update --json for a machine-readable response, or combine it with
--all for the complete unfiltered response.
The command is read-only. It does not install packages, edit application files, run migrations or execute returned todos. It never skips release lines; after manually completing one update, run it again to request the next transition. Unknown release lines and service failures are reported as errors rather than being interpreted as proof that the application is current.
Unified APIs with rcompat
Prefer rcompat packages over runtime-specific APIs.
Filesystem
import fs from "@rcompat/fs";
const base = fs.ref("static");
const file = base.join("logo.svg");
HTTP Status
import http from "@rcompat/http";
return new Response("ok", { status: http.Status.OK });
MIME Types
import http from "@rcompat/http";
const known = http.MIME.TEXT_PLAIN; // "text/plain"
const byExt = http.toMIME(".svg"); // "image/svg+xml"
Runtime Support
Primate supports multiple runtimes and build targets. A blank cell below is not a claim of support; check the adapter's documentation and test on your chosen runtime and target before deploying.
Primate requires Node.js 26 or later when running on Node. Bun and Deno remain supported where listed below.
Build Targets
| Target | Node | Deno | Bun |
|---|---|---|---|
| Web | |||
| Desktop (linux-x64) | |||
| Desktop (darwin-x64) | |||
| Desktop (darwin-arm64) | |||
| Desktop (windows-x64) |
Backends
| Backend | Node | Deno | Bun (web) | Bun (native) |
|---|---|---|---|---|
| Go | ||||
| Kotlin | ||||
| Python | ||||
| Ruby | ||||
| TypeScript |
Databases
| Database | Node | Deno | Bun (web) | Bun (native) |
|---|---|---|---|---|
| MemoryDB | ||||
| JSONDB | ||||
| MongoDB | ||||
| MySQL | ||||
| Oracle | ||||
| PostgreSQL | ||||
| SQLite |
Web target
The Web target serves your app over HTTP. It's the default target and
runs on all runtimes (Node, Bun, Deno). primate build creates the server
bundle and assets; primate serve runs the built app.
Build output
build/
└── server.js # Production server entry
Serving
npx primate serve
# or
node build/server.js
Native target
The Native target compiles your Primate application into standalone desktop executables for Windows, macOS, and Linux, and into iOS apps for the simulator or a signed iPhone. It embeds your app and uses a system WebView to render the UI.
Under the hood, a desktop build runs an HTTP server inside the executable and points the WebView at it, so you ship a single desktop app without needing a separate web server. An iOS build has no server to run: it ships your routes rendered at build time and serves them from the app bundle (see iOS apps).
Setup
Install the native module:
npm install @primate/native
Platform prerequisites
Linux x64 uses the WebKitGTK 6.0 sidecar from @rcompat/webview. Running a
native app locally, in CI, or in a container requires GTK 4, WebKitGTK 6.0,
and libsoup 3 from the host distribution:
# Arch Linux
sudo pacman -S webkitgtk-6.0
# Debian and Ubuntu
sudo apt install libgtk-4-1 libwebkitgtk-6.0-4
# Fedora
sudo dnf install gtk4 webkitgtk6.0For a Debian or Ubuntu container, install libgtk-4-1 and
libwebkitgtk-6.0-4 in the image before launching the executable. Building or
cross-compiling alone does not load these libraries, but a launch smoke test
does.
GTK 3, WebKitGTK 4.0, and libsoup 2 are not ABI-compatible substitutes. Do not work around a missing library with a compatibility symlink.
macOS uses the system WebKit framework. Windows uses the system WebView2 runtime; the Linux packages above are not required on either platform.
Configure your application:
// config/app.ts
import app from "primate/app";
import native from "@primate/native";
export default app({
modules: [native()],
});
How it works
Primate compiles a launcher into the executable: the parent process serves your built app and a child process owns the WebView window, whose event loop must run on the process main thread (a requirement of macOS AppKit). Navigation, cookies, and fetch requests work as they do in the browser.
The build embeds your bundled server into the executable -- routes,
middleware, modules, client bundles, and static files alike -- so the
compiled app runs on its own, without the build directory it was produced
in. The launcher bakes the start URL into the executable at build time from
your http config, with 0.0.0.0 and empty hosts rewritten to 127.0.0.1
(WebKit refuses those spellings). At launch, an explicitly set PORT
environment variable wins and the window navigates the resulting URL;
otherwise the server is pinned to the baked-in port, so the compiled app
always serves and navigates the same URL. The child probes the server on both
localhost and 127.0.0.1 spellings before navigating. If the server never
becomes reachable, or the webview fails to load, the executable exits with a
nonzero code instead of starting a broken window.
- Server: serves your built app; you can still use routes, middleware, and modules.
- WebView: renders your UI using the system WebView for each platform.
bunx --bun primate.Building
Development
Run the dev loop with a Webview window attached to the watch server:
bunx --bun primate dev --target=desktopNo binary is compiled: the window is an uncompiled script navigating the
watch server, and on every file change, primate rebuilds the server bundle
and reloads the page in the window. Close the window to leave the dev loop,
and Ctrl-C it to stop the server. Window requests appear in the terminal
prefixed with [native].
Production
Build a native executable for the host platform:
bunx --bun primate build --target desktopThis will create an executable app (or app.exe on Windows) inside build.
The embedded server makes the executable self-contained: copying it out of
build and running it is enough to launch the app.
Cross-compilation
You can target specific platforms from your development machine:
{
"scripts": {
"build:linux-x64": "bunx --bun primate build --target linux-x64",
"build:darwin-x64": "bunx --bun primate build --target darwin-x64",
"build:darwin-arm64": "bunx --bun primate build --target darwin-arm64",
"build:windows-x64": "bunx --bun primate build --target windows-x64"
}
}Run, for example:
bunx --bun primate build --target windows-x64
Build Output
build/
└── app # Linux/MacOS/Windows executable (app.exe on Windows)The executable embeds your bundled server and all client and static assets: it is the only thing you need to copy when distributing the app, plus any sidecar directories modules have declared (see Sidecars below).
Configuration
| Option | Type | Default | Description |
|---|---|---|---|
start |
string |
"/" |
Initial path when app launches |
debug |
boolean |
false |
Enable debug logging |
width |
number |
1280 |
Initial window width |
height |
number |
720 |
Initial window height |
Example
import native from "@primate/native";
export default app({
modules: [
native({
// start URL when app launches
start: "/",
// enable debug mode
debug: false,
// window size
width: 1280,
height: 720,
}),
],
});
Sidecars
Modules of a Primate app can contribute runtime files that a compiled app
needs next to its executable. A module declares these files as
sidecars through Primate's build contract; the Native module copies each
declared file into the build directory, with its declared relative path
preserved -- so the compiled app resolves it from its own directory at
launch.
Sidecars are declared by the module that owns the runtime in question;
Native itself has no knowledge of which modules or runtimes an app was built
with. Apps without declarations build executables without sidecars. The
most common use is the Pyodide runtime of @primate/python apps; see the
Python documentation for details.
Sidecar files are validated at declaration: destinations have to be portable relative paths inside the build output, and a declared file that is missing or uncopyable fails the build instead of shipping a broken distribution.
Distribution
The built executable is the distributable: it embeds the bundled server and
all client and static assets, so it runs from any location you copy it to.
Sidecar directories modules declared (see Sidecars above) ship alongside it;
the most common is the Pyodide runtime directory of @primate/python apps.
Shipping native apps usually involves platform packaging and signing:
- Windows: package/sign your
app.exeand its sidecar files as needed for distribution. - macOS: wrap the executable and its sidecar files into an
.appbundle, then notarize/sign. - Linux: distribute the binary and its sidecar files directly or via a package format of choice, and declare GTK 4, WebKitGTK 6.0, and libsoup 3 as host dependencies.
These steps depend on your release tooling; integrate them after
primate build completes.
iOS apps
Two targets build for iOS. ios runs your app in the iOS Simulator, and
ios-device builds the same app signed for a physical iPhone. Both need Xcode
with an iOS SDK (xcodebuild -downloadPlatform iOS installs one).
Development in the simulator
bunx --bun primate dev --target=iosPrimate boots a simulator (the booted one, else the newest iPhone Pro), builds a
small WKWebView host, and points it at the development server on your machine —
the simulator shares your network, so live reload keeps working. Requests show
up in the terminal prefixed with [native]. Ctrl-C stops the server and
terminates the app. Pick a specific device with the simulator option, by name
or UDID.
Building an app
bunx --bun primate build --target=ios # an offline simulator app
bunx --bun primate build --target=ios-device # signed for a paired iPhoneEither way the build renders every static route into an app bundle:
build/
└── ios/
├── My App.app # app bundle: host, Info.plist, static export
└── my.bundle.id.entitlements # device builds onlyAn iOS app carries no server. Bun has no iOS runtime, so a build prerenders each
parameter-free GET page into HTML, records the payload the client router
loads for that route, copies the client build output and your static/ files,
and serves all of it from the bundle through a primate:// scheme handler.
Links keep navigating client-side, offline, without a page reload. Routes with
parameters, and anything that needs a server at runtime — forms, sessions, API
routes, stores — are not part of an iOS build: a GET route that answers
anything but HTML is skipped, never written to the bundle.
Install and launch the simulator app with xcrun simctl install and
xcrun simctl launch; the build prints both commands.
Device builds
ios-device builds for arm64 against the iphoneos SDK and signs the result
the way Xcode does: the entitlements of a matching provisioning profile, with
application-identifier made concrete, the profile embedded in the bundle, and
codesign --generate-entitlement-der. It then installs and launches the app over
devicectl.
Three things have to exist, and the module looks for all of them:
- a paired iPhone with Developer Mode enabled,
- an Apple Development certificate in your keychain (Xcode > Settings > Accounts),
- a provisioning profile that covers the bundle id and lists the device.
Xcode creates that profile the first time it builds the bundle id; until then
the build stops and names the profiles it found. Set
ios.plist.info.bundleIdentifier to a bundle id you can sign, and
ios.device.name if several devices are paired. Signing for distribution
(App Store, TestFlight, ad hoc) is not covered: this signs for development.
Locking the phone while the build runs is not an error — the app is installed, and only the launch is skipped.
primate dev --target=ios-device is
rejected at build time. Develop in the simulator and build for the device.Configuration
import native from "@primate/native";
export default app({
modules: [
native({
ios: {
// simulator to use, by name or UDID
simulator: "iPhone 17 Pro",
plist: {
info: {
// shortcuts
name: "Primate Mobile",
bundleIdentifier: "run.primate.mobile",
version: "1.0.0",
build: "1",
// any other Info.plist key
NSAppTransportSecurity: { NSAllowsArbitraryLoads: true },
},
},
device: {
name: "My iPhone", // paired device; the default is the only one
team: "ABCDE12345", // optional, DEVELOPMENT_TEAM
identity: "Apple Development", // optional, keychain identity
profile: "iOS Team Provisioning Profile: run.primate.mobile",
install: true, // install and launch after signing
},
},
}),
],
});ios.plist.info merges over the defaults a bundle needs to launch, dictionaries
included, so adding NSAppTransportSecurity keys keeps
NSAllowsLocalNetworking. Defaults: bundle id run.primate.<package name>,
display name from package.json, iOS 16.0, iPhone and iPad, portrait and
landscape, and no network exception beyond local networking.