Dev
- Category: Engine
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.The rest of this page shows what happens on a file change: when the server
restarts, when it hot-swaps (--hot), why the client never reloads underneath
you, and how the process tree cleans up after itself.
What a dev run is
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.
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.
Two ports in dev, one in prod — and no CORS
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 forever 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:
- The client dev server is the only origin the browser talks to. It serves
the page and its assets, and it forwards every other request through to the
server — including the query/mutation POSTs your code fires. Each forwarded
request is tagged
X-Point0-Forwarded-From-Dev-Clientso the server answers it instead of bouncing it back. The browser only ever sees the client dev server's origin, so there are no CORS preflights and no@point0/corsneeded in dev — just as in prod, where it's all one port. - Hit the server port directly and dev redirects you to the client. Opening
a page URL on the server's port during dev
302s you to the matching client dev server port (same path), so the browser lands on the origin that owns HMR and your Fast Refresh connection never breaks. (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 would hit the server cross-origin,
so in that setup you'd add the @point0/cors middleware yourself,
exactly as you would for any external client.
Server restarts vs client restarts
There are two reload axes, and they behave differently.
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. point0's server dev does
not truly hot-reload without --hot: on any watched change it 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.
Server hot mode (--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. It's marked experimental.
Why not bun --hot
There are two reasons, and the second is the decisive one.
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 — and this is why bun --hot is a non-starter here, not just awkward —
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 by never reloading the framework and by managing its own module store — it only re-imports your edited points, with a fresh identity, against the unchanged framework singletons.
How it works: the content-addressed 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 get isolated stores and 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, gated by a manifest hash: unchanged → cached
module (singletons live); changed → fresh module identity.
(point0 prune deletes this cache.)
Hot vs cold
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:
- Every server file is hot by default — editing it hot-swaps.
- Cold roots restart on edit. Three sources:
- The in-file marker — this file and its static-import subtree are cold:
import '@point0/core/cold' // editing this file (or its static deps) restarts the server - A config glob on the server importer:
// engine.ts (server config) importer: { cold: ['**/prisma.*'] } // matched against the file's own path - Auto — a file the store provably can't flatten (a location-relative
import.meta, or an un-rewritable relative import like a generated client's./enumsdir or wasm). These are logged at startup:N file(s) can't be flattened, running cold (edit => restart): ….
- The in-file marker — this file and its static-import subtree are cold:
- The server entry is cold by default — it is boot.
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 is simply: changed file is cold → full restart; otherwise → hot-swap.
What hot-swaps and what doesn't
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:
- Adding a brand-new file restarts (it doesn't hot-swap). The new point is picked up automatically — the generator regenerates the aggregators — but it comes in via a child restart, not a hot-swap.
- The client app shell (
app.client) is not in the store — editing it restarts.
Crash resilience
--hot is built so a typo never forces you to re-run point0 dev:
- A hot-file compile error is caught and the previous store is kept:
Hot reload failed — keeping the previous store (fix the error and save again). Fix and save → it recovers. - A cold/boot syntax error prints the error and leaves the tree alive; the child exits, and the next save respawns it.
- Starting
--hoton 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. - An initial store build failure defers the server child entirely; the watcher retries the build on each save and starts the server the moment it succeeds.
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.
The HMR fix: export anything from a point file
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:
// what the compiler appends to the last point in a file
._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. Put points anywhere, mix freely, several per file.
// 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).
The dev lifecycle: no orphans
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 shebang
carries --no-orphans (Bun >= 1.3.14):
#!/usr/bin/env -S bun --no-orphans --no-env-file --config=/dev/nullA 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 the server
child and the client dev servers 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.
Graceful teardown
--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.
Ports are named, never killed
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.
Stability under rapid edits
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 that hold:
- One watcher pipeline, strictly serial. Events are deduped by path (latest wins) and drained one at a time; the watcher never runs concurrently with itself.
- Junk never enters. Atomic-write and editor artifacts (
*.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. - At most one respawn in flight, one queued. A burst settles
(
POINT0_DEV_RESTART_SETTLE_MS, default 120 ms) into a single respawn; respawns chain, never run in parallel. - A hot edit never kills a live child — an alive-but-booting child plus a hot-node change rebuilds the store only; the child serves the latest code once it's up.
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.
Reference
CLI flags
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> | all server entries | Server entry points, by name or path (comma-separated or repeated). |
--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() options
await 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
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 vars (dev tuning)
| 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.
Related
- CLI —
point0 devis a wrapper overengine.dev(); see alsogenerate,build,compile,prune. - Build — the production counterpart,
point0 build. - Engine runtime —
engine.dev()alongside the other engine methods. - Bun vs Vite — the two dev pipelines (hot mode is bun-native only).
- Compiler — where the HMR fix and the
-h/--hmroverride live.
Enjoying Point0?
Star Point0
Start0
YouTube
Discord
Telegram
of the Lord Jesus Christ ☦️
With love for developers
of all backgrounds around the world ❤️