With love for developers of all backgrounds around the world ❤️
point0 dev starts your whole app for development: the server, the client dev
servers, file watching, and codegen on the fly. It's a thin wrapper over
engine.dev() — the CLI parses flags and calls it. The
canonical basic example wires it as:
// package.json
{
"scripts": {
"dev": "point0 dev --hot"
}
}bun run dev
# regenerates points/routes, starts the client dev server, boots the server,
# and watches src — edit a page and it hot-swaps without restarting the server.A dev run is a small process tree:
point0 dev # the orchestrator (the engine.dev() process)
├─ server child # a `bun run` of your server entry (or in-process for Vite)
└─ client dev server(s) # one per client scope, in the orchestrator processThe orchestrator owns the file watcher. The client dev servers always run inside the orchestrator process as a single instance — they own HMR / Fast Refresh and are never restarted by an edit. The server runs as a child process (for the bun-native pipeline) and is the thing that restarts.
One server child, not one per declared entry. point0 build builds every
entry in the server entry record; dev starts a single one — main, or the
first declared key when there is no main. A second entry (a worker, a one-shot
sync script) joins the tree only when you ask: --entry, --entry '*', or the
devEntries option.
engine.dev() does this in order:
await engine.dev()
// 1. apply the app logger
// 2. install dev shutdown handlers (SIGINT/SIGTERM/SIGHUP)
// 3. generate points/routes once, then watch and regenerate on change
// 4. start the client dev server(s) in this process
// 5. start the server child (bun-native) or in-process Vite serverIt defaults NODE_ENV to development when unset; an explicit --mode /
NODE_ENV is preserved.
In production the server and the client are served from one origin on a single port: the same server that renders pages also answers queries and mutations. In development point0 splits them across two ports on purpose — a dedicated client dev server (one per client scope) and the server — so the client dev server can stay alive and never lose its HMR / Fast Refresh state when the server restarts.
That split would normally mean cross-origin requests, but point0 keeps everything same-origin for the browser:
x-point0-forwarded-from-dev-client so the server answers
it instead of bouncing it back. So there are no CORS preflights and no
@point0/cors needed in dev. The proxy is byte-transparent, too — a
Content-Encoding your server sets (a gzip/brotli compression middleware)
rides through untouched, so origin compression behaves in dev exactly as in
prod.302s to the matching client dev server port
(same path), so the browser lands on the origin that owns HMR. (Production
does no such redirect.)If you turn the client side off (point0 dev --side server), there's no client
dev server to forward through — the browser hits the server cross-origin, so add
the @point0/cors middleware yourself, as for any external client.
The client never restarts on an edit. The client dev server owns HMR (Bun's HTMLBundle bundler, or Vite when configured). It stays alive for the whole session and patches modules in place. Because it's also the front door the browser hits and proxies everything through to the server (see above), even a full server restart is invisible in the browser — point0 retries connection-refused quietly across the brief moment the server re-binds its port, so the client proxy doesn't 502.
The server, by default, restarts on every change. Without --hot there is
no true hot reload: any watched change kills the server child and respawns it.
That's slow and drops all in-memory state — DB pools, caches, the open socket:
point0 dev
# edit any server file →
# Server restarting... (changed: src/pages/home.tsx)
# Server started http://localhost:3000 in 412msThe child runs as a plain bun run (never bun --watch) — the orchestrator's
own import-graph watcher is the sole watcher and owns every restart. Bursts of
edits coalesce into one respawn (a 120 ms settle window), and a respawn is
polite: SIGTERM first, a grace window for your shutdown hooks, then SIGKILL for
stragglers.
To keep server state across edits, turn on hot mode.
The server entry record can declare more than one entry point — the app, a
background worker, a one-shot sync script:
Engine.create({
file: import.meta.url,
server: {
entry: {
main: './index.server.ts',
worker: './worker.server.ts',
sync: './sync.server.ts',
},
},
})point0 build always builds all of them. point0 dev starts
one: main, or the first declared key when there is no main. Everything
else is opt-in — plain point0 dev boots your app, not your whole fleet.
point0 dev # main only (the default)
point0 dev --entry worker # worker only, by name
point0 dev --entry main,worker # both (comma-separated, or repeat --entry)
point0 dev --entry ./scripts/probe.ts # a path that isn't even declared
point0 dev --entry '*' # every declared entry (quote it — an unquoted * globs in the shell)'*' can never be a real entry name, so the sentinel is unambiguous.
To fix a selection in the config instead of typing it every time, use
devEntries on the server side:
Engine.create({
file: import.meta.url,
server: {
entry: { main: './index.server.ts', worker: './worker.server.ts' },
devEntries: ['main', 'worker'], // or '*', or a single string
},
})The ladder is: --entry (or engine.dev({ entries })) → devEntries → the
single main entry.
A worker or a script has no browser side, and the client dev servers cost a
bundler each. --side server leaves them out:
point0 dev --entry worker --side serverNot every entry is a server. A one-shot program — a sync script, a seeder, a migration you keep as its own entry — runs to completion and exits. Dev treats a zero exit code as success:
point0 dev --entry main,sync
# Server started http://localhost:3000 in 412ms
# Entry "sync" finishedThe tree keeps running, and the finished entry re-runs on the next change in
its own import graph — editing a page that only main imports does not re-run
sync:
# edit src/sync/upload.ts (imported by sync.server.ts) →
# Entry "sync" re-running... (changed: src/sync/upload.ts)
# Entry "sync" finishedAny non-zero exit of a booted child is still a crash, and the dev tree lives and dies as one unit — the rest of it is torn down.
--hot)--hot keeps the server process stable — React, react-dom, the engine,
Prisma all load once and never reload — and picks up changes by re-importing
just the edited points with a fresh module identity. Edit a page and the SSR
output updates with no restart; the process pid stays the same:
point0 dev --hot
# edit src/pages/home.tsx →
# Server hot reloading... (changed: src/pages/home.tsx)
# Server hot reloaded
# (no "Server started" — same process, DB pool and caches intact)Enable it three ways — the flag, the dev() option, or an env var:
point0 dev --hot # CLI flagawait engine.dev({ serverHot: true }) // programmaticPOINT0_DEV_SERVER_HOT=true point0 dev # env-var fallbackThe explicit option wins; otherwise it falls back to the env var. Off by default. Bun-native only — under Vite the dev path is unchanged. Experimental.
bun --hotThere are two reasons; the second is decisive.
First, bun --hot does a partial module-graph reload, which tears React's
cross-module dispatcher singleton (Invalid hook call /
more than one copy of React) because react-dom/server lives in the unwatched
framework dist and doesn't reload with your edited component. Reproduced on
the real app, and not fixable with accept-boundaries.
Second, the files point0 rewrites on the fly (the content-hashed modules it
feeds the server, see below) never land in Bun's own hot store, so native Bun
hot reload simply doesn't fire for them. It's an open Bun limitation
(oven-sh/bun#5844 — filed against
--watch, but --hot is affected the same way).
point0's hot mode sidesteps both: it never reloads the framework and manages its own module store.
In hot mode the orchestrator compiles each of your source files, writes it under
a content-hash filename, and rewrites relative import specifiers to its deps'
hashed names. Bare specifiers (react, @prisma/*, @point0/*) are left
alone, so they resolve to the same cached singletons the renderer already uses.
node_modules/.cache/server-hot/<scope>-<port>/The store lives there, keyed by <scope>-<port>, so two --hot processes on
the same folder but different ports can't clobber each other. On a change, only
the changed file and its importer chain get new hashes; everything else
keeps its hash. The server child re-imports the current points aggregator per
request — incoming WebSocket messages included, so channel and handler code
swaps even when the only traffic is the socket — gated by a manifest hash:
unchanged → cached module (singletons live); changed → fresh module identity.
(point0 prune deletes this cache.)
Not every file is safe to hot-swap — a file with boot side effects (crons, queues, the DB client) needs a full restart. point0 classifies default-hot:
import '@point0/core/cold' // editing this file (or its static deps) restarts the server// engine.ts
Engine.create({
file: import.meta.url,
server: {
importer: { cold: ['**/prisma.*'] }, // matched against the file's own path
},
})import.meta, or an un-rewritable relative import like a generated
client's ./enums dir or wasm). These are logged at startup:
N file(s) can't be flattened, running cold (edit => restart): ….Cold flows downward through static imports and stops at a lazy
import() (the hot boundary). Importers of a cold file stay hot: a page
importing prisma (cold) still hot-swaps; prisma just isn't re-evaluated,
because its content hash is unchanged. Prisma forces a minimal cold boundary
even on day one — its generated client (a directory + wasm + import.meta)
can't be flattened, so it's externalized to its real path and runs cold.
The decision per change: changed file is cold → full restart; otherwise → hot-swap.
Editing an existing file behaves as you'd expect:
| Change | Result |
|---|---|
| Edit a page / layout / component | hot-swap (pid stable) |
| Edit a mutation / query / loader | hot-swap (next call returns the edited value) |
Edit a server loader, shared lib, or .mdx page body | hot-swap |
| Delete a file | route drops, tree stays alive |
| Rename a file | route follows the new file |
Edit a @point0/core/cold file, the boot entry, or app.client | full restart |
Two MVP cuts to know:
app.client) is not in the store — editing it
restarts.--hot is built so a typo never forces you to re-run point0 dev:
Hot reload failed — keeping the previous store (fix the error and save again).
Fix and save → it recovers.--hot on an already-broken hot file: the store builds, the
child crashes importing it before binding the port, and that never-booted
child is dropped without tearing down the tree. The next save respawns it:
Server failed to boot (entry "...", code N) — fix the error and save; it will start automatically.Entry "<name>" finished, keeps the rest of the tree running, and re-runs
that entry on the next change in its own import graph.To bound memory across a long session, the orchestrator periodically restarts instead of hot-swapping (every 200th hot reload by default) to release Bun's module cache; the store dir is mark-and-swept on disk.
A point file exports points — objects and method-decorated functions, not React components. Bun's bundler and Vite only enable React Fast Refresh for a module that looks like it exports a component; a plain points file wouldn't qualify, and every edit would trigger a full page reload instead of HMR.
point0 fixes this in the compiler. It appends a decoy component-shaped function to the final point in the chain:
// you write
export const ideaQuery = root.lets.query().loader(/* ... */).query()
// the compiler appends a decoy to the last point in the file
export const ideaQuery = root.lets
.query()
.loader(/* ... */)
.query()
._tail(function X() {
return null
})That statically-declared, capitalized function X is all the bundler's static
pass needs to wire up Fast Refresh. At runtime _tail returns the real thing —
a mountable point's actual mount component, or the decoy decorated with the
point's methods for everything else. The inline render function of a
page/layout/component is also hoisted to a top-level declaration so Fast Refresh
tracks edits to the render body.
The payoff: a single file can export a page, a query, a mutation, a provider, and plain values all at once, and HMR keeps working.
// one file, mixed exports — HMR survives all of them
export const ideaQuery = root.lets
.query() /* ... */
.query()
export const likeIdea = root.lets
.mutation() /* ... */
.mutation()
export const ideaPage = root.lets
.page('/ideas/:id') /* ... */
.page(/* ... */)The fix is on by default for the client side and off for the server side. It's
not an engine config option — you can only override it per
compile through the point0 compile CLI (-h/--hmr,
-H/--no-hmr).
A dev tree cleans up after itself. The invariant:
No point0 process can outlive whatever launched it.
This is enforced at the kernel level, not by bookkeeping. The CLI passes
--no-orphans (Bun >= 1.3.14) explicitly on every dev child it spawns — the
server child and the client dev servers (the point0 shebang itself stays
flag-free so the bin launches through Bun's Windows shim).
A flagged process exits when its parent dies — even on SIGKILL — and on its own
exit SIGKILLs every descendant (Bun re-verifies parentage first, so recycled
PIDs are safe). The flag is inherited by nested bun processes, so each child's
own spawns are covered automatically. Each app's bunfig.toml extends the
invariant one level up, to the bun run dev wrapper itself:
# bunfig.toml
[run]
noOrphans = trueVerified empirically (bun 1.3.14, macOS): SIGKILL the orchestrator and both
children are gone, ports free, in ~2s. Because a tree can't leak, there's
nothing to find, list, or stop from outside — so point0 has no dev lockfile
and no point0 stop command. Stopping a dev run is Ctrl-C, or killing the
terminal.
--no-orphans SIGKILLs. For a polite shutdown — so your DB pool closes and
your job queue drains — engine.dev() installs SIGINT/SIGTERM/SIGHUP handlers
that SIGTERM every child first, wait a grace window
(POINT0_DEV_SHUTDOWN_GRACE_MS, default 5000 ms), then SIGKILL stragglers. The
same path runs if a core child dies unexpectedly — the tree lives and dies as
one unit. Ctrl-C stays graceful end-to-end because bun run waits for its child
after forwarding SIGINT.
point0 never kills whoever holds a port. Since trees can't leak, a busy port always means a live process someone owns. A real conflict fails with an error that names the holder, and you decide:
Port 3000 is already in use by pid 123 (bun src/index.server.ts).
Stop that process or change the port.Under the orchestrator the server child binds with patient retries
(POINT0_DEV_BIND_TIMEOUT_MS, default 10000 ms) — because a transient conflict
during a respawn is just the predecessor draining its shutdown hooks. Any other
dev run binds once.
Windows caveat:
--no-orphansis a no-op on Windows, so a SIGKILLed tree can leave children behind. The port-conflict error then names them for a manual kill.
point0 dev and --hot survive fast edit bursts — the way an AI agent saves,
30–150 ms apart, written atomically (<file>.tmp.<pid>.<hex> + rename). The
invariants:
*.tmp.<pid>.<hex>,
*.tmp, *.vsctmp, *.crswap, vim/JetBrains/emacs swap files, ~,
.DS_Store, …) are dropped at the watcher source, so every consumer — dev
restarts, the generator, build --watch — is immune.POINT0_DEV_RESTART_SETTLE_MS, default 120 ms) into a single respawn;
respawns chain, never run in parallel.Server started your saves are already being seen. The first save after
startup counts like any other.While the server is still booting, the watch set is broad (the whole app-src tree), because the precise import walk can't yet be trusted to include the file that fixes a broken build. Once the server is confirmed up, it narrows to the precise import graph.
Every flag on point0 dev (each maps to an engine.dev() option). Full CLI
reference on CLI.
| Flag | Default | What it does |
|---|---|---|
--hot | off | Server-side hot reload (bun-native). Hot-swap edited points without restarting; cold files still restart. Experimental. |
-G, --no-generate | generate on | Skip files generation. |
--side <server|client> | both | Serve only one side. |
--scope <scope> | all | Serve only one scope. |
-w, --watch [glob] | engine devWatchGlob | Watch files and rebuild on change (comma-separated or repeated; no value = the engine's devWatchGlob). |
-W, --no-watch | watch on | Disable file watching (no restart / regenerate on change). |
--entry <name|path> | devEntries, else the main entry | Server entries to start, by name or path (comma-separated or repeated); --entry '*' starts every declared entry. See multiple server entries. |
--engine <path> | auto-find | Path to the engine file. |
--env <name=value> | none | Define env vars (override .env); repeatable. |
--mode <mode> | development | production | development | test — which .env files apply. |
-- <args> | none | Everything after -- is forwarded to the spawned bun run. |
engine.dev() optionsawait engine.dev({
generateFiles, // boolean — run codegen first + watch (default true)
side, // 'server' | 'client' — one side only
scope, // a single points scope
entries, // string[] | '*' — server entry names or paths (default: devEntries, else main)
bunRunArgs, // string[] — extra args for the server child's `bun run`
watch, // string | string[] | boolean — watch globs, or true/false
cwd, // string — defaults to process.cwd()
serverHot, // boolean — hot mode; defaults to POINT0_DEV_SERVER_HOT
})| Env var | Default | Effect |
|---|---|---|
POINT0_DEV_SERVER_HOT | false | Enable server hot mode (fallback when --hot / serverHot is omitted). |
POINT0_DEV_BIND_TIMEOUT_MS | 10000 | Server child's port-bind retry window under the orchestrator. |
POINT0_DEV_SHUTDOWN_GRACE_MS | 5000 | Graceful teardown grace window before SIGKILL. |
POINT0_DEV_RESTART_SETTLE_MS | 120 | Burst-coalescing settle delay before a respawn. |
POINT0_DEV_RESTART_GRACE_MS | 1500 | Per-respawn SIGTERM → SIGKILL grace. |
POINT0_DEV_SERVER_HOT_RESTART_EVERY | 200 | Restart instead of hot-swap every Nth reload, to release Bun's module cache (0 = off). |
POINT0_DEV_SERVER_HOT_GC_GRACE_MS | 30000 | Disk-GC grace before sweeping stale store files. |
POINT0_DEV_CHILD and POINT0_DEV_STORE_DIR are set internally by the
orchestrator on the server child (they mark it as orchestrator-owned and hand it
the hot store) — you don't set them.
point0 dev is a wrapper over engine.dev(); see also
generate, build, compile, prune.point0 build.engine.dev() alongside the other engine
methods.-h/--hmr override live.