# App Server
The App Server is Deft's production deployment layer for web apps. A
single app file serves in single-process mode during development; in
production the same file runs in N worker runtimes, each binding the
same port with `SO_REUSEPORT`. The kernel load-balances incoming
connections across the worker sockets — there is no application-level
proxy hop.
```
┌──────────────────────────────────┐
│ Parent runtime │
│ (no listener, no traffic) │
│ • file watcher (reload) │
│ • cluster state │
│ • http/cluster-* stdlib functions │
└──────────────┬───────────────────┘
│ createChild + spawnFile
┌──────────────┴───────────┐
│ │
┌─────┴─────┐ ┌─────┴─────┐
│ Worker 0 │ │ Worker N │
│ SO_REUSEPORT │ SO_REUSEPORT
│ own VM │ │ own VM │
│ own heap │ │ own heap │
│ own thread│ │ own thread│
└───────────┘ └───────────┘
```
Each worker is a fully isolated runtime (own VM, heap, event loop, OS
thread) that loads the app file independently. The same app file works
in both single-process and cluster mode — in cluster-worker mode,
`[vwait]` is a no-op (the loop thread services requests via its tick
loop) and `[http/serve]` automatically sets `SO_REUSEPORT`.
The underlying HTTP machinery (routers, middleware, templates) is
documented separately in [HTTP Server](http-server).
## Usage
The app file is unchanged from single-process mode:
# app.dft def site %{ routes @{ %{ path "" method :GET handler { "hello" } } } } |> [http/router] [http/serve $site :3000] [vwait]
A separate driver starts the cluster:
# driver.dft [http/serve-cluster 3000 "app.dft"] # n-workers defaults to [os/cpu-count] [vwait] # parent stays alive for reloads + watchers
`http/serve-cluster` takes:
| Arg | Type | Notes |
|---|---|---|
| `port` | number | TCP port to bind |
| `app-path` | string | App file each worker loads |
| `n-workers` | number | Optional, defaults to `os/cpu-count` |
| `opts` | map | Optional, e.g. `%{ :app-name "api" :watch-dirs @{...} }` |
Explicit worker count:
[http/serve-cluster 3000 "app.dft" 8]
Watching content directories (the parent watches these for reloads;
workers skip their own `--watch` in cluster mode):
[http/serve-cluster 3000 "app.dft" %{ :watch-dirs @{"packages/help/manual" "www/pages"} }] [vwait]
The parent stays alive (via `vwait`) to service the file watcher for
reloads. Watch dirs default to the app file's directory; pass
`:watch-dirs` to add content trees (e.g. a `docs/*.md` directory the
app renders).
## Control stdlib functions
| Function | Purpose |
|---|---|
| `[http/cluster-metrics]` | Full info map: port, app-name, app-path, workers, generation |
| `[http/cluster-status]` | Lightweight health: `:status`, `:healthy`, port, workers, generation |
| `[http/cluster-reload]` | Trigger a new-generation reload manually; returns info map |
| `[http/cluster-stop]` | Destroy all workers and clear cluster state |
## Reload (new-generation swap)
The parent watches the app file. On change (or on
`[http/cluster-reload]`):
1. N fresh worker runtimes are spawned (each binds `SO_REUSEPORT` —
the kernel immediately starts sending them accepts).
2. Old workers' listener fds are closed (kernel stops routing to them).
3. Old worker runtimes are joined and destroyed.
4. `generation` is incremented.
There is a brief window during the swap where both old and new workers
are bound. In-flight requests on old workers are interrupted when their
runtimes are joined; clients retry and hit fresh workers.
## Worker isolation
Workers do not share mutable state. Each has its own heap, loaded
modules, and caches. State that must be consistent across workers must
go through:
- **SQLite file (WAL mode)** — render caches, structured state. Each
worker opens its own connection; WAL allows concurrent readers that
don't block the writer.
- **`config/*` registry** — persistent user preferences.
- **`pub/sub`** — ephemeral messaging between runtimes.
Session state in a worker-local `def` is visible to only a subset of
requests and is lost on reload.
## Cross-worker cache convention
For a render cache or any structured cross-worker state, open a SQLite
file in WAL mode. Cache file convention: `.deft/http_.cache` in
the workspace root, where `` derives from the app-path basename
(or the `:app-name` opt).
def db [sqlite/open ".deft/http_app.cache"] [sqlite/exec $db "PRAGMA journal_mode=WAL"] [sqlite/exec $db "PRAGMA synchronous=NORMAL"] [sqlite/exec $db "CREATE TABLE IF NOT EXISTS render_cache ( path TEXT PRIMARY KEY, hash TEXT, body TEXT, ts INTEGER )"]
If cache keys are **content-addressed** (include a source hash), stale
entries are never hit and no explicit invalidation is needed on reload.
If keys are **path-based**, the parent publishes a `"cache-invalidate"`
pubsub message on file change; workers subscribe and delete the
matching rows.
## Performance notes
- Each worker independently loads the app, parses templates, etc.
Memory cost is N× a single-process deployment.
- The accept path scales linearly with worker count (kernel-level
distribution, no user-space bottleneck).
- `os/cpu-count` is the recommended default for `n-workers`.
## Example: deft-lang.org
The deft-lang.org site ships in `www/` and is served by the App Server.
The app file builds the routers and serves single-process for
development:
# www/app.dft def site %{ prefix "" statics @{ %{ path "/assets" dir "./public" } } routes @{ %{ path "" method :GET handler home/home-page } %{ path "/docs/:slug" method :GET handler docs/docs-page } # ... } } |> [http/router] [http/serve 3002 $site $partials --watch --watch-dirs "packages/help/manual"] [vwait]
The cluster driver is a separate 2-line file:
# www/cluster.dft [http/serve-cluster 3000 "www/app.dft" %{ :watch-dirs @{"packages/help/manual" "www/pages"} }] [vwait]
## Quick Reference
| Form | Purpose |
|---|---|
| `[http/serve-cluster $port $app-path ?n-workers ?opts]` | Start cluster (multi-worker, SO_REUSEPORT) |
| `[http/cluster-metrics]` | Cluster info map |
| `[http/cluster-status]` | Cluster health map |
| `[http/cluster-reload]` | Trigger cluster reload |
| `[http/cluster-stop]` | Stop cluster |