С любовью ко всем разработчикам на свете ❤️
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.
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.
--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, 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 (server config)
importer: {
cold: ['**/prisma.*']
} // matched against the file's own pathimport.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.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:
// 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.
// 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.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> | 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() 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
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.