Build & start
voltro build and voltro start — production builds, SSG pre-render, the SSR bundle, ISR cache.
voltro build produces a production artefact; voltro start serves it. Two commands, clean separation.
voltro build <appDir>
voltro build apps/acme/web
voltro build . # current dirWhat it does for a web app:
- vite build against
.framework/— producesdist/with chunked client bundle. - Pre-renders static pages — every page with
renderMode: 'static'is rendered to HTML once + lands atdist/<path>/index.html. - Pre-builds the SSR bundle — every page module compiled to
dist/server/ssrEntry.jssovoltro startdoesn't need a Vite middleware loader at runtime. - Copies
public/intodist/.
For api apps, voltro build precompiles the whole handler closure — every procedure, workflow, subscriber, reaction, aggregate, agent, webhook, cron, startup, app.config, and their shared database/lib deps — into a single esbuild bundle at .framework/dist-api/apiEntry.js (the framework + npm deps stay external). voltro serve loads that bundle automatically at boot and resolves every handler from it, so production never transpiles TypeScript at runtime. Without a build, voltro serve still loads each source module on demand (via the tsx loader) exactly as voltro dev does — the build is an optimisation, not a requirement.
voltro build takes a single optional app directory and parses no flags — the SSR bundle is always attempted, and the SSG pre-render always runs for static-mode pages.
Output layout
apps/acme/web/.framework/dist/
├── index.html # SPA shell fallback
├── about/index.html # pre-rendered static page
├── blog/first-post/index.html # SSG via getStaticPaths
├── assets/
│ ├── index-abc.js # main client bundle
│ ├── index-abc.css
│ └── island-LikeButton-def.js # per-island chunks (interactive: 'islands' pages)
└── server/
└── ssrEntry.js # SSR bundle for voltro startvoltro start <appDir>
voltro start apps/acme/web # serves the build output
PORT=8080 voltro start apps/acme/webWhat it does:
- Reads
app.config.ts.port(orPORTenv var) for the listen port. - Walks
dist/to discover pre-rendered HTML files. - Loads the SSR bundle from
dist/server/ssrEntry.js. Falls back to Vite middleware mode if absent. - Starts an
http.Serverthat:- Serves pre-rendered HTML for matched URLs.
- Serves static assets from
dist/assets/,dist/_voltro/. - Renders SSR pages per request via the SSR bundle.
- Reads / writes ISR cache for
renderMode: 'isr'pages.
Flags + env vars
| Flag / env | Notes |
|---|---|
PORT=8080 |
Override the listen port. |
SSR_CACHE=postgres |
Use the Postgres-backed ISR cache. Requires the PG_* connection env (PG_HOST etc.) to also be set — without it, voltro start stays on the in-memory cache. Default is in-memory. |
VOLTRO_INSPECT=off |
Disable the inspect HTTP endpoints in production. |
VOLTRO_INSPECT_TOKEN=… |
Bearer token guard on the inspect endpoints. |
Per-request routing logic
For a request to /foo:
1. dist/foo/index.html exists? → serve it.
2. URL matches a static asset? → serve from disk.
3. URL matches a registered query?
- renderMode 'ssr' → render fresh via SSR bundle.
- renderMode 'isr' →
- cache HIT (fresh) → serve cached.
- cache HIT (stale) + staleWhileRevalidate → serve cached + bg refresh.
- cache MISS → render, store, serve.
- renderMode 'static' (no pre-render found) → serve SPA shell.
4. None of the above → 404 via not-found.tsx.The response includes a x-voltro-rendered-by header (prerender / ssr / isr) + cache state.
ISR cache backends
SSR_CACHE=memory voltro start # default — per-process, doesn't survive restart
# Postgres-backed cache — needs the PG_* connection env too:
SSR_CACHE=postgres PG_HOST=… PG_PORT=… PG_USER=… PG_PASSWORD=… PG_DATABASE=… voltro startFor multi-instance + horizontal scale → Postgres. The cache table is auto-created on first boot.
Tenant-aware ISR
Pages with tenantAware: true get separate cache entries per tenant. The cache key becomes <pathname>|tenant=<tenantId>. See Render modes.
CDC invalidation
For pages with cacheInvalidatesOn: ['table', …], voltro start reads Postgres logical replication. Writes to listed tables invalidate every matching cache entry. Requires SSR_CACHE=postgres + wal_level=logical.
Graceful shutdown
voltro start handles SIGTERM:
- Stop accepting new connections.
- Wait up to
SHUTDOWN_GRACE_MS(default 30s) for in-flight requests to finish. - Close active WebSocket connections (the client auto-reconnects).
- Exit.
For container orchestrators, set terminationGracePeriodSeconds: 60 to match.
Health checks
voltro start exposes two unauthenticated probe routes:
GET /internal/liveness→200 ok— the process is up at all (restart the pod if it stops answering).GET /internal/readiness→200 readyonce boot completes,503 not-readybefore (pulls the pod from Service endpoints until ready).
Point the Kubernetes liveness probe at /internal/liveness and the readiness probe at /internal/readiness.
Multi-instance + sticky sessions
For WebSocket connections to land on the same backend (required for in-process subscription state):
- Reverse proxy:
lb_policy ip_hash(Caddy) /ip_hash(nginx). - Or use
@voltro/plugin-clusterto share subscription state across instances → any-load-balancer-works.
voltro doctor — preflight a production serve
Production voltro serve for an API app boots ONLY from the precompiled serve
bundle and is fatal if it's missing — a hand-rolled Dockerfile that runs
voltro serve without a prior voltro build breaks at deploy. voltro doctor
(or voltro serve --preflight) catches that at BUILD time instead of cold-start:
voltro doctor . # check the serve bundle exists; print the fix if not
voltro serve --preflight . # same check, then exit — never bootsIt exits 1 when the serve bundle is missing on an API app (so it fails a CI /
Docker step) and prints the exact remedy: add a voltro build . step before
voltro serve .. Drop it into your image build right after voltro build to
guarantee the artefact is present before the image ships.
The predicate-column check
eq / isNull / inSet are free functions, so the column name arrives as a bare
string and the builder cannot relate it to the table the predicate is attached
to:
database.teamAppointments.where(isNull('deletedAt'))
// ^ teamAppointments has no softDelete() mixin,
// so no `deletedAt` column exists. tsc: OK.That type-checks — review and CI pass — and then fails at runtime as a bare SQL
error. voltro doctor checks every literal predicate column against the table's
declared columns and names both:
✗ predicate columns: 1 filter on a column that does not exist (214 checked)
api/appointments/rollforward.query.server.ts:31 'teamAppointments' has no column 'deletedAt'
columns: id, teamId, startsAt, createdAt, updatedAt
This type-checks today and fails at runtime as a bare SQL error.Matching is on the AST, never on text, so a column name in a comment or an unrelated string cannot trip it. A call site whose table cannot be resolved is skipped silently — an unresolvable receiver is usually not a table at all.
The type-level fix (binding the predicate to the row, where(c => isNull(c.x)))
is the right end state and is planned separately. This check is the half that
works retroactively: it finds the bug in code that already exists, which a
type change never will.
The detector also flags raw fetch() in server files. The SSRF guard the
framework ships lives in the HttpClient handlers yield* — so it protects
exactly the apps that already adopted it, and misses the ones that never did.
Those are usually the same apps that secured least elsewhere, which is why the
absence is worth naming out loud rather than assuming the default did its job.
Server files only (*.server.ts, *.cron.ts, *.subscribe.ts, …): fetch is
unremarkable in a browser component, and flagging it there would make the rule
noise that gets scrolled past — taking the real findings with it.
The workflows.start audit
voltro doctor also diffs every workflows.start(name, payload) call site
against the workflows the app actually registers:
✗ workflow starts: 13/14 call sites checked
api/crons/weekly.cron.ts:22 'sprint.report' payload is missing: teamId
api/crons/weekly.cron.ts:22 'sprint.report' payload has unknown field(s): scheduledAt
accepted: sinceIso, teamId
1 UNCHECKED (not verified — not a pass):
api/crons/digest.cron.ts:8 — payload spreads a valueWhy this exists even though workflows.start validates at runtime. Runtime
validation fires on the next firing — which for a daily cron is hours, for a
weekly one is a week, and for a quarterly one is a quarter. And
voltro inspect schedules --failing cannot see a job that has never fired,
because its roll-up is built from recorded runs. A weekly workflow broken by a
refactor is invisible to both until it next runs.
UNCHECKED is never folded into a pass. A payload built with a spread or a
computed key can contribute any name, so its key set is unknowable here. Those
call sites are counted and listed rather than passed silently — 0 issues must
not be readable as "all verified". The workflow name is checked regardless,
since a rename or a deletion is decidable whatever the payload looks like.
The required keys come from the live payloadSchema, the same source the runtime
validation reads, so the two cannot disagree about what a payload needs.
The hand-roll detector
voltro doctor also scans your source for shapes the framework already has a
primitive for, and names the primitive at the spot the hand-roll lives. This is
advisory and never blocking — it prints, it does not fail your build.
voltro doctor .• Shipped primitives you may be hand-rolling:
[server]
hand-written not-found branch on rows[0] — 12 file(s): queries/team.get.ts, …
→ .one() — fails with the typed NoRowFound on zero rows AND on more than one
[client]
per-field useState + a submit flag (hand-rolled form) — 4 file(s): src/create-dialog.tsx, …
→ useFormBinding — fields + validation from the mutation input SchemaIt covers both halves of the stack:
| Scope | It notices | Reach for |
|---|---|---|
| server | if (!rows[0]) throw … |
.one() / .first() |
| server | 3+ sequential store.query in one handler |
relations() + .with() — or Effect.all |
| server | Effect.promise(() => ctx.store.…) |
yield* EffectStore |
| server | requireScope(...) at the top of an executor |
guards: on the descriptor |
| server | a token / secret / password column with no encryption |
.encrypted() |
| server | a notify / webhook helper called at a mutation's tail | defineSubscriber / defineReaction |
| server | hasMore + limit + 1 |
paginateById |
| server | .getTime() / .toISOString() mapping a row on the way out |
timestampMs / timestampMsOrNull from @voltro/database/wire in the descriptor's output struct |
| client | per-field useState + a submit flag |
useFormBinding |
| client | a table with local sort/filter state | useDataTable |
| client | FileReader / readAsDataURL |
useUpload |
| client | setTimeout debounce in a useEffect |
useDebounced |
| client | useMemo fanning in several subscriptions |
useDerived |
| client | a local Next.js compat shim | the native @voltro/web exports |
| client | a hand-rolled presence heartbeat | @voltro/plugin-presence |
| client | data === undefined / !data on a subscription result |
branch on loading (and idle, if you pass skip) |
The subscription rule resolves the binding rather than matching text, and
that distinction is the reason this scanner parses at all. A consumer migrating
these call sites wrote a regex codemod for the same job, and it rewrote a
summary === undefined check inside a child component where summary was a
PROP. Their compiler happened to catch it, because that name was out of scope
there; had the names matched, a silent behaviour change would have shipped. Text
cannot tell you which declaration an identifier refers to — so a rule about
identifiers has no business being written in text.
The rules are deliberately conservative — a detector that cries wolf trains you
to ignore it. A column that already carries .encrypted(), or a handler that
already uses .one(), stays silent.
Two of them are worth spelling out, because their advice is not one-line:
The credential-column rule skips names that aren't credentials. A name ending
in Id / _id, a name ending in Hash / _hash, and a name beginning with
vault are all left alone:
| Column | Why it's skipped |
|---|---|
jiraSecretId, token_id |
an IDENTIFIER of a secret held elsewhere, not the secret |
apiKeyHash, password_hash |
the hash IS the protection — encrypting it is nonsense, and it breaks the column as a unique lookup key |
vaultToken |
a HANDLE into a secret store, naming a secret held elsewhere |
The suffix tests use a camelCase / underscore boundary on purpose: a blind
/id$/i would also swallow apiKeyValid, while tokenIdentifier — which ends
in neither — must still fire.
The sequential-reads rule names TWO levers, and the criterion for choosing. Both shapes chain later reads off earlier results, so no text-level heuristic can split them — you make the call:
- The reads are a parent → child walk on ONE key → declare
relations()in a*.relations.tsand collapse them into.with({ … }): one JSON-aggregate query, on every dialect. - The reads collect ids from SEVERAL sources (JSON-array references, a
junction carrying extra columns, JS-side sorting) → keep the assembly and run
the independent LEADING reads under
Effect.all. Same queries, same results, only concurrent — zero parity risk.
The second case is the common one. Measured on a real 74-hit codebase, about two
handlers were clean full-parity relations() conversions and the other ~72 were
multi-source assemblies where .with() covers only part of the work or subtly
changes behaviour. Prescribing relations() for all of them would be wrong ~97%
of the time — and advice that is usually wrong trains you to ignore the finding.
The human view shows the first three file paths per finding and says how many it
withheld. Those paths are the actionable part — a count you cannot turn back
into a work list tells you the size of the problem, not how to fix it — and the
matching rule lives inside the CLI, so you cannot re-derive the list with your
own grep. --json prints the complete scan, nothing elided, with no preflight
output mixed in:
voltro doctor . --json # the complete scan: every file path, machine-readable{
"root": "/app/api",
"scannedFiles": 214,
"scannedDirs": ["queries", "mutations", "database"],
"findings": [
{
"id": "row-not-found",
"scope": "server",
"smell": "hand-written not-found branch on rows[0]",
"use": ".one() — fails with the typed NoRowFound on zero rows AND on more than one",
"files": ["queries/team.get.ts", "queries/user.get.ts", "…"]
}
],
"spaCandidates": []
}That is the form to hand an agent, or to pipe into a script that works the list file by file.
renderMode:'spa' candidates
voltro doctor also flags web pages that could adopt renderMode: 'spa' without
losing their server-rendered shell. A page under a layout renders that LAYOUT
chain on the server — nav, sidebar, auth gate, via the layout's own loader — even
when the page itself is 'spa'. So a page whose BODY needs no SSR can skip its
per-page SSR compile while the shell still server-renders. Like the hand-roll
detector, this is advisory and never blocking.
A page is listed when ALL of these hold:
- it is a page file — not
layout.tsx/loading.tsx/error.tsx/not-found.tsx; - it exports no
loader(so'spa'loses nothing the page contributed server-side); - its
renderModeis'ssr'or unset/default — not a page that already opted into a non-SSR render ('spa'/'static'/'isr', or any other explicit mode); - a
layout.tsxsits somewhere in its directory chain — root, an ancestor, or the page's own dir. This is the load-bearing condition: only then does a layout still SSR the shell. A page with no layout would, as'spa', ship no server HTML at all — so it is never flagged.
• renderMode:'spa' candidates (2 pages — loader-free, under a layout, currently ssr/default):
src/pages/dashboard/index.tsx (/dashboard) — default renderMode
src/pages/admin/settings.tsx (/admin/settings) — renderMode:'ssr'
→ renderMode:'spa' skips this page's SSR compile while its layout shell still renders server-side — adopt it if the page BODY does not need SSR (internal/authenticated pages); keep 'ssr' if the page content needs SEO or server first-paint.Adopt 'spa' for internal or authenticated pages whose content needs no SEO or
server first-paint; keep 'ssr' (or the 'static' default) when it does. There
is deliberately no codemod to flip pages automatically — dropping a page
body's server render is a per-page product decision, not a mechanically-safe
transform. Every candidate (with its file, pattern, and currentMode) is
also in voltro doctor --json under a spaCandidates array; the human view
above caps at ten pages and points to --json for the rest.
voltro capabilities — what the framework actually exports
Asked "what does this framework export", a language model will produce a
confident answer whether or not it knows. This command replaces that guess with
a reading of the .d.ts files in your own node_modules:
voltro capabilities # human summary, grouped by package
voltro capabilities --json # the full machine-readable surfacevoltro capabilities — 2517 exported symbols across 36 packages
@voltro/runtime@0.4.0 — 9 primitives, 41 values, 118 types
defineAggregate, defineReaction, defineSubscriber, defineResourcePolicy, …
@voltro/client@0.4.0 — 47 hooks, 12 components, 60 types
useSubscription, useMutation, useFormBinding, useDataTable, useUpload, …
* 8 primitive(s)/hook(s) appear nowhere in this project's agent guide:
@voltro/plugin-mail: defineEmail
…Every symbol reported was read out of an installed package a moment ago, so an
agent can verify the surface instead of recalling it. The --json form is
stable and locale-independent — the same tree produces byte-identical output on
every machine, so you can diff it across upgrades.
Symbols marked * ship but appear nowhere in this project's seeded AGENTS.md
/ CLAUDE.md. Refresh the guide with voltro agents-md --force, or read that
package's README.
Anti-patterns
- Running
voltro startagainst a directory withoutdist/. It exits 1 with a clearno built dist found — run voltro build first(checked against.framework/dist/index.htmlbefore any heavy work). Runvoltro buildfirst. voltro startin dev to "test prod". Usevoltro build && voltro start. The dev server has different behaviour; serving dev artefacts via start is undefined.- Skipping the SSR bundle build. Middleware mode is slower (cold-start cost on every render). For production deploys with
renderMode: 'ssr'pages, build the bundle.
See also
- Render modes — which mode produces which output
- Voltro Cloud — managed
voltro start+ autoscale (coming soon) - Self-hosting — Docker + reverse proxy patterns