Engine Runtime
- Category: Engine
Engine.create({...}) returns one object that is both the config and the
runtime. You describe the app once in src/engine.ts, then call methods on the
same instance — engine.serve(), engine.dev(), engine.build(),
engine.fetch(req) — to run it. The CLI calls those exact methods for you; your
own code can call them directly.
// src/engine.ts — the one object
import { Engine } from '@point0/engine'
export const engine = Engine.create({
file: import.meta.url, // the engine must know where it lives
ssr: true,
pointsGlob: '**/*.{ts,tsx,mdx}',
server: {
scope: 'root',
port: process.env.PORT,
entry: { main: './index.server.ts' },
},
client: {
scope: 'root',
indexHtml: './index.html',
app: async () => await import('./app.client'),
},
})// src/app.server.ts — that same object, now running
import { engine } from '@/engine.js'
await engine.serve() // Server started http://localhost:3000 in 42msThe full set of Engine.create options is on engine-config.
This page is the runtime side: the methods on the instance, and the few wiring
files that boot it.
Config and runtime are one object
There is no separate "app server" abstraction. The CLI finds your exported
engine and calls a method on it; that's the whole indirection.
// what `point0 dev` does, in essence:
const { engine } = await import('./src/engine.ts')
await engine.dev() // `point0 build` → engine.build(), `point0 generate` → engine.generate()Every request the server answers also runs through the same instance —
engine.fetch(request) is the entry point. So config (Engine.create) and
runtime (the methods below) live on one value you can import anywhere.
The CLI looks for the engine file in ./, ./src, ./lib, and a few point0
subfolders, basename engine, and imports module.engine ?? module.default. So
your src/engine.ts must export the instance as a named engine or a default
export — otherwise the CLI throws
engine.ts must export "engine" or have a default export. Override the path
with --engine <path> or the POINT0_ENGINE_FILE env var.
GOTCHA — the engine file must be side-effect free. The CLI and
preload.tsimport its module graph raw, before the compiler plugins are installed. Don't validate env or throw at module scope inengine.ts(or anything it imports eagerly). Import env shapes, not eager validation — see env.
Serving requests
engine.serve() starts the Bun server. It prepares the engine first, so you
don't call prepare() yourself:
// src/app.server.ts
import { engine } from '@/engine.js'
await engine.serve()
// logs: Server started http://localhost:3000 in 42msserve() binds on the server port from your config. If the port is taken, it
does not kill the holder — it names it and throws:
Port 3000 is already in use by pid 123 (bun src/index.server.ts).
Stop that process or change the port.(Under the dev orchestrator the bind is retried for up to 10s while the old
process exits — see dev.) Calling serve() when the engine is already
serving returns early without error, which is what lets Vite re-run the entry on
HMR.
When your engine's RequiredCtx is non-undefined, serve requires a
requiredCtx argument; otherwise it's optional. You can also pass partial Bun
Serve.Options — a custom fetch here runs before point0's and wins if it
returns a response:
await engine.serve({ requiredCtx: { db } }) // only when the engine requires ctxAnswering a request without a server: engine.fetch
engine.fetch(req) runs a request through the engine and returns a Response —
no socket, no port. This is how tests and server-to-server calls hit your
points:
await engine.prepare() // required before fetch (serve() does this for you)
const response = await engine.fetch('http://localhost:3000/api/ideas/123', {
method: 'POST',
})engine.fetchDetailed(req) is the same call but returns the full result
({ response, data, error, ... }) instead of just the Response.
GOTCHA —
fetch/fetchDetailedneedprepare()first. Both throwEngine server is not prepared. Please call await engine.prepare() first.if you skip it.serve()prepares internally, so you only callprepare()by hand when you usefetchstandalone (tests, SSR, scripts). See testing.
There is no
engine.execute(...)..execute(...)is a method on a mutation, not on the engine — the engine's request entry point isfetch/fetchDetailed.
engine.withFetch(cb) runs a callback inside the server's request context (the
server port, a bound fetch, a query client) and passes the bound fetch to
the callback. Inside it, server-side point methods — a query's fetchServer, a
mutation's fetchServer/fetchServerDetailed, a loader calling another loader
— resolve their in-process fetch directly through the engine, with no socket and
no running server. Outside that context they throw, because the server-only
fetch fn has nowhere to read its port and query client from.
This is exactly what makes it the tool for integration tests: you hit your points in-process, with full types, without booting a server. This is how start0's test suite exercises its API:
import { engine } from '@/engine'
import { ideaCreateMutation, ideaListQuery } from '@/features/idea/api'
test('returns the newest ideas first', async () => {
const user = await createTestUser()
await seedIdea({ authorId: user.id, title: 'First' })
await seedIdea({ authorId: user.id, title: 'Second' })
const result = await engine.withFetch(async () => {
return await ideaListQuery.fetchServer({})
})
expect(result.items.map((i) => i.title)).toEqual(['Second', 'First'])
})
test('rejects anonymous users', async () => {
const result = await engine.withFetch(async () => {
return await ideaCreateMutation.fetchServerDetailed(
{ title: 'Nope', content: 'Nope' },
{ headers: {} },
)
})
expect(result.error?.code).toBe('UNAUTHORIZED')
})fetchServer returns the data and throws on error; fetchServerDetailed
returns { data, error, response, … } so you can assert on a failure without a
try. Pass headers to simulate an authenticated caller. The same pattern
covers any server-to-server call where you want a point's typed result inside an
existing request — call query.fetch() (or fetchServer) from within
withFetch. See testing.
Dev, build, generate
These three are what the CLI commands map to. Each is also callable directly.
await engine.dev() // start dev (server + clients), watch, regenerate, hot-reload
await engine.build() // production build of every side
await engine.generate() // codegen only (points, routes, meta, assets types)engine.dev() forces NODE_ENV=development, generates and watches by default,
and installs the dev-tree shutdown handler so the whole process tree dies as a
unit. engine.build() forces NODE_ENV=production, always generates first
("no build without generate"), and warns if it isn't actually production:
Building with NODE_ENV=development, not "production": the client gets inline
sourcemaps and unminified bundles…Each takes options — dev has side, scope, entries, watch, serverHot,
and more; build has side, scope, clean, publicdir. The full option and
flag tables live on dev and build; the command-to-method mapping
is on cli. The matching watch and codegen variants are
engine.buildWatch(...), engine.generateWatch(...), and
engine.preparePublicdirs().
Preparing and tearing down
engine.prepare() sets up the server, hot-store, source maps, and each serving
client. It's idempotent and required before fetch. serve() and the lifecycle
methods call it for you; native shells (capacitor, expo) call it explicitly:
await engine.prepare()
await engine.serve()Cleanup methods:
await engine.dispose() // dispose all clients + the server (close handles)
await engine.clean() // remove build outputs
await engine.prune() // remove the @point0 temp dir + the server hot-reload storeprune is the point0 prune command. In production you wire dispose into
your shutdown path so handles (DB pools, the server) close cleanly:
// src/app.server.ts (start0)
onShutdown('engine', ['prisma'], async () => await engine.dispose())
await Promise.all([engine.serve(), createInitialAdmin()])
if (import.meta.hot) {
// Vite only
import.meta.hot.dispose(async () => await engine.dispose())
import.meta.hot.accept()
}preload(): one call that sets up the process
engine.preload() does the process-level setup that must happen before any
app code is imported: it normalizes NODE_ENV + writes the POINT0_* and env
consts into process.env, and it installs the point0 compiler as a process-wide
Bun.plugin (which strips client code from the server bundle and vice-versa).
// src/preload.ts
import { engine } from '@/engine'
await engine.preload({ nodeEnvFallback: 'development' })Options: nodeEnvFallback (apps pass 'development'), preventLoadBunPlugins
(pass !!engine.server.viteConfig — Vite doesn't need the Bun plugin),
preventSetEnvVars, and prepare (also run prepare() in the same call).
// vite app: skip the Bun plugin
await engine.preload({
nodeEnvFallback: 'development',
preventLoadBunPlugins: !!engine.server.viteConfig,
})preload() is a no-op in a built process — once the build baked everything
in, re-running it could even flip a production server to development, so it
returns early there. The call stays in the bundle but does nothing.
The wiring files
A point0 app boots through a few small files. They are plain files you write
once; create-point0-app scaffolds them. The boot order is the point.
src/preload.ts
Imported explicitly — never ambient. One call to engine.preload() (above). It
must finish before any app code is imported, because it installs the compiler
plugin that everything downstream relies on.
src/index.server.ts
The server boot entry (your server.entry.main points at it). It imports
preload first, then the actual server code:
// src/index.server.ts
await import('./preload.js')
await import('./app.server.js')
export {} // marks the file as a moduleGOTCHA — dynamic imports only, in order. A static
importhoists above anyawait, so a static preload import would run after the app code it's meant to set up. Useawait import('./preload.js')as the first line, never a top static import. You can interleave other dynamic imports (env validation, telemetry) between preload andapp.server, as long as they stay dynamic.
src/app.server.ts
The real server code. It calls engine.serve() — and is also where any other
server-side code lives (workers, crons, env validation, seeding):
// src/app.server.ts
import { engine } from '@/engine.js'
await engine.serve()
// it is not only the API entry point — put server initializers here toosrc/app.client.tsx
The client app root, a default-exported component. It wraps the router and your
providers (query client, head). The client config references it via
app: async () => await import('./app.client'):
// src/app.client.tsx
export default function App() {
return (
<QueryClientProvider client={queryClient}>
<UnheadProvider>
<Head>{/* global head tags */}</Head>
<Router>
<RouterRoutes />
</Router>
</UnheadProvider>
</QueryClientProvider>
)
}src/index.client.tsx and mount()
The browser entry — the <script type="module"> your index.html loads. It
imports the generated client points, then calls mount():
// src/index.client.tsx
import App from '@/app.client'
import points from '@/generated/point0/points.client'
import { ErrorBoundary } from '@/ui/error-boundary'
import { mount } from '@point0/react-dom/mount'
mount(
<ErrorBoundary>
<App />
</ErrorBoundary>,
points,
)
if (import.meta.hot) {
import.meta.hot.accept() // HMR: see below
}mount from @point0/react-dom/mount takes the root element, the points, and
an optional domRootElement:
mount(element, points, { domRootElement }?)- Without
domRootElement, it mounts intodocument.getElementById('root')and throwsElement #root not found…if it's missing — pair it with<div id="root">in yourindex.html. (PassdomRootElementto target a different element; an explicit falsy value throws.) - Hydration vs CSR is automatic. On the first call, if the root already has
child nodes (SSR markup) it
hydrateRoots; otherwise itcreateRoot().renders. - HMR re-renders into the same root. Later calls (from
import.meta.hot) reuse the existing React root and just re-render, so Fast Refresh keeps hook state.
src/index.html
The shell: a mount target and the client entry script.
<div id="root"></div>
<script type="module" src="./index.client.tsx"></script>With Vite the shell is
index.client.htmlinstead, andapp.server.ts/index.client.tsxuseimport.meta.hotfor dispose + accept. See bun-vs-vite and example-vite.
Reference
Instance methods
engine. —
| Method | Does |
|---|---|
serve(options?) | Prepare, then start the Bun server. requiredCtx required when the engine's ctx is non-undefined; accepts partial Bun Serve.Options. → cli |
dev(options?) | Dev: server + clients, watch, regenerate, hot-reload. Forces NODE_ENV=development. → dev |
build(options?) | Production build of every side. Generates first; forces NODE_ENV=production. → build |
buildWatch(options?) | build in watch mode (watches the entry import graph). → build |
generate(options?) | Codegen only (points, routes, meta, assets types). → generator |
generateWatch(options?) | generate then watch. → generator |
prepare() | Idempotent runtime setup. Required before fetch; called by serve. |
fetch(req, options?) | Run a request through the engine, return a Response. Needs prepare(). |
fetchDetailed(req, options?) | As fetch, but returns { response, data, error, … }. |
withFetch(cb) | Run cb inside the server request context, so a point's fetchServer/fetch works in-process (tests, server-to-server). |
preload(options?) | Process setup: env + compiler Bun plugin. No-op in a built process. |
dispose() | Dispose all clients + the server. |
clean() | Remove build outputs. |
prune() | Remove the @point0 temp dir + server hot-reload store (point0 prune). |
getViteConfig(env?) | The full Vite UserConfig the engine would use — for vite.config.ts and vitest. → bun-vs-vite |
CLI-internal helpers also live on the instance — preparePublicdirs,
serveClientDevServers, toEntryPath, guessSideAndScope, getEmit,
readEverything, and the statics Engine.findSelfFile /
Engine.findAndImportSelf. They drive the CLI and rarely appear in app code.
Instance fields
A few are useful in app and config code:
| Field | Is |
|---|---|
engine.clients / engine.client | the configured clients; .client is the first one (throws No clients available… if none) |
engine.server | the server runtime (engine.server.viteConfig, engine.server.entry, …) |
engine.file / engine.cwd | the engine file URL and its working directory |
engine.log / engine.logger | the resolved log fn / logger |
engine.prepared / engine.wasBuilt | runtime flags |
Boot order
src/index.server.ts # server entry (server.entry.main)
→ await import preload.ts # engine.preload(): env + compiler plugin
→ await import app.server.ts# engine.serve()
index.html # browser shell
→ src/index.client.tsx # mount(<App/>, points) → hydrate or createRoot
→ src/app.client.tsx # the App component (router + providers)Enjoying Point0?
Star Point0
Start0
YouTube
Discord
Telegram
of the Lord Jesus Christ ☦️
With love for developers
of all backgrounds around the world ❤️