Build output
vura build writes everything into dist/. This page describes what ends up
where, and the one property of it that changes how you write code.
dist/
server/
entry.js the server, self-contained
package.json { "type": "module" }
pages/** server and hybrid page modules, and their layouts
api/** API route modules
actions/** server action modules
middleware.js src/middleware.ts, if the project has one
static/
index.html prerendered static and hybrid pages
<route>/index.html
_then/pages/*.js browser bundles for client and hybrid pages, content-hashed
functions/** per-route bundles for serverless targets
package.json { "type": "module" } + pinned runtime dependencies
Run the server with node dist/server/entry.js; that is what a Dockerfile's
CMD points at too. dist/static/ is what a CDN serves.
Each server module is bundled separately
This is worth knowing before you write anything that expects shared state.
dist/server/entry.js inlines its dependencies. The modules beside it (a page,
an API route, an action, middleware) are each bundled on their own, and each
resolves @celsian/vura-core through an internal shim that inlines its own
copy of the runtime. Every page and every layout is its own bundle.
The upside is that a serverless target can ship one route without the rest of the application. The consequence is that a module-level value is not shared between them.
// src/lib/cache.ts
export const cache = new Map(); // NOT one Map in a built app
A page and an API route that both import this get a Map each. In vura dev
they share one, because dev loads modules through Node's cache, so this is a
class of bug that only appears in production.
For genuinely process-wide state, key it on a symbol:
const KEY = Symbol.for('myapp.cache');
const store = globalThis as unknown as { [KEY]?: Map<string, unknown> };
export const cache = (store[KEY] ??= new Map());
Symbol.for returns the same symbol for the same string in every copy, so
every bundle reaches the same object. Vura's own loader context and action
registry work exactly this way.
For state that must survive a restart or be shared across instances, this is still the wrong tool. Use a database, or Redis, or a hot route if you want it in memory and addressable.
instanceof across the boundary
The same split means a class from @celsian/vura-core is a different class
object in each bundle:
// in an API route
throw notFound('No such user');
// somewhere in the entry bundle
if (err instanceof HttpError) { … } // false, for the error above
Vura handles this internally for the errors it raises: every HttpError carries
a Symbol.for('vura.http-error') brand, and Vura recognises its own errors by
that brand rather than by instanceof, so a thrown HttpError reaches the
client with the right status from any bundle. A registry symbol is the same
symbol in every copy, which is exactly what a class is not.
The reverse also holds: an error Vura did not construct does not carry the
brand, so a library error that happens to have a statusCode field cannot pick
its own HTTP status. It gets a sanitised 500.
If you write your own error class and compare it with instanceof across a page
and an API route, it will not match. Compare a discriminant field instead, or
use a registry symbol the way Vura does:
export class AppError extends Error {
readonly kind = 'AppError' as const;
}
function isAppError(e: unknown): e is AppError {
return e instanceof Error && (e as AppError).kind === 'AppError';
}
What reaches the browser
Only two things: the content-hashed bundles under dist/static/_then/pages/,
and whatever is inlined in a prerendered HTML file.
A static page ships no framework JavaScript at all. A client or
hybrid page ships one bundle, built for the browser, which is where
what-framework is inlined (a browser has no module resolver).
Files under src/actions/ are never opened for a browser bundle. An import
that lands there is replaced with a generated fetch stub before the bundler
reads the file, so a credential in an action module cannot reach the client.
See server actions.
Everything else on the server side keeps what-framework external, so a
running app holds one copy of the framework per process rather than one per
page.
The generated package.json files
Three are written, and the difference between them matters for containers.
| File | Contents |
|---|---|
dist/package.json |
{ "type": "module" } plus the runtime dependencies the server bundles kept external, with the version pinned to what the project has installed |
dist/server/package.json |
{ "type": "module" } only |
dist/functions/package.json |
{ "type": "module" } only |
The two inner ones exist so Node treats those subtrees as ESM even when the
project itself has no package.json or defaults to CommonJS.
The one that carries dependencies is dist/package.json:
{
"type": "module",
"dependencies": { "what-framework": "0.13.3" }
}
A Dockerfile that copies dist/ and runs npm install --omit=dev from
dist/ therefore installs exactly what entry.js needs. Running it from
dist/server/ installs nothing, which is a container that starts and then dies
on its first request with ERR_MODULE_NOT_FOUND. The
Docker guide uses the correct path.
Reproducing a problem
If something works in vura dev and not in a built app, the difference is
almost always one of the above: dev shares module instances and a build does
not. Build it and boot it:
vura build
node dist/server/entry.js
To see whether a value is inlined rather than imported, look for the import:
grep -c "from \"what-framework\"" dist/server/pages/*.js
A server page bundle should import it. If it has no imports at all, it inlined a copy.