Build & CLI internals#
The plumbing the CLI and the Vite plugin use. You need this page to embed Janux in another build, to write a custom server, or to script the CLI — not to build an app.
import { resolveAppConfig, shellOptions, apiFiles, apiStubModule, exportedApiNames, apiModuleName, packageDir, toFetchRequest, sendFetchResponse } from '@janux/vite';
import { runCli, parseArgs, HELP_TEXT } from '@janux/cli';
import { createHttpHandlers } from '@janux/server';
import { renderNode } from 'janux/server';resolveAppConfig(root, pluginOptions?)#
resolveAppConfig(root: string, options?): Promise<JanuxAppConfig> resolves the conventional layout — every optional path in project structure — into absolute paths. Precedence, lowest to highest:
- the
"janux"field inpackage.json(deprecated fallback), janux.config.ts/.jsdefault export,- options passed to the plugin.
Config files are imported with an mtime cache-buster, which is why editing janux.config.ts takes effect in dev without restarting. Discovery is by existence: src/middleware.ts, src/matchers.ts, src/i18n.ts (or src/i18n/index.ts), src/api/, src/stores.ts, src/agent.ts, src/styles.css, public/favicon.svg.
publishAppRoot(root), from the same entry, sets JANUX_APP_ROOT — the app root an app's own modules read to find their data files, since a bundle's import.meta.dirname is not the app's. Every path that serves an app publishes it (the dev plugin, prodServerOptions, a deployment adapter); merely resolving a config does not, because tooling resolves the config of apps it will never run.
shellOptions(app, stylesheets)#
shellOptions(app: JanuxAppConfig, stylesheets: string[]) maps a resolved app config onto the ServerOptions fields the HTML shell reads — title, lang, siteUrl, favicon — and passes the stylesheet URLs through. Dev and production build the same shell from the same config, so they share this mapping instead of each listing the fields:
// dev: Vite serves the stylesheet with its own URL contract
{ ...shellOptions(app, devStylesheets(root, app.stylesheet)) }
// production: the bundler emitted /styles.css — unless it is being inlined
{ ...shellOptions(app, app.stylesheet && !inlineStyles ? ['/styles.css'] : []), inlineStyles }The stylesheet URL is the one field that legitimately differs between the two, which is why it's a parameter. Everything else being shared is the point: the favicon was once wired in dev and forgotten in production, so every build shipped a shell with no icon link and browsers fell back to a 404 /favicon.ico.
The app stylesheet#
src/styles.css is always a bundler input (bundleInputs), so janux build emits it through Vite as dist/client/styles.css — the same pipeline dev serves it with. That means @import of a dependency's CSS resolves in production too:
/* src/styles.css */
@import '@xyflow/react/dist/style.css';It used to be copied verbatim unless @janux/tailwind was installed, so anything only the bundler could resolve — bare specifiers, url() assets — shipped as literal text that 404'd in the browser.
The api() stub pipeline#
A *.api.ts module runs on the server; the client gets a tiny typed stub instead of the implementation. Three functions do that:
| Function | Does |
|---|---|
apiFiles(serverDir) |
Lists *.api.ts / *.api.js in the server dir (empty array when it doesn't exist) |
exportedApiNames(source) |
Parses a module's exported api() names — via SWC, without executing it |
apiStubModule(names, moduleName) |
Generates the client module: one clientApi() stub per name |
apiModuleName(file) |
The stable module id a stub is addressed by |
The parse-don't-execute step is the important one: server-only imports (a database driver, secrets) never reach the client graph, because the plugin never runs the module to learn its exports.
The binding-maps pass#
compileClientModule(id, code) (from binding-sites.ts) is the client-graph transform behind compiler.bindingMaps — on by default, off with compiler: { bindingMaps: false }. It parses each component module, finds the schema-declared state fields, and rewrites provable static reads in the view into binding thunks — {state.count} becomes {() => (state.count)} — so a write to that field updates one DOM site without re-running the view.
What counts as provable is deliberately narrow, and asymmetric by position:
| Position | Rule |
|---|---|
Text ({state.x}) |
Strict: only a non-nullable string or number leaf — text has no way to render "absent" |
Attribute (class={state.x}) |
Lax: any leaf builder, booleans included, and value/checked are bindable; event props and props with special propToAttr handling are excluded |
| List items | Sites inside map() callbacks are compiled too, keyed through the list |
optional() / nullable() modifiers disqualify a field in either position. Anything unprovable is simply left as written — those sites keep the runtime path, an island re-render followed by a DOM morph — so the compiled and uncompiled programs mean the same thing. And the pass fails open: a module it cannot parse is passed through untransformed rather than failing the build, because a missed optimization is recoverable and a broken build is not.
The intent-split pass#
splitClientModule(id, code) (from intent-split.ts) is compiler.splitIntents — opt-in. It moves an intent's run() body into its own chunk, downloaded on first invocation, and leaves a lazy stub behind. The strictness rule: only a provably self-contained run() is moved — one that touches nothing from module scope beyond its own bag — because a body that closes over module state cannot be lifted without changing what it means.
The stub keeps intents[name] a callable of the same shape, which is why nothing downstream notices: wire markers, guards, schemas and the manifest all read the intent definition, and the definition — server-side and in the client's typed surface — is unchanged; only where the body's bytes live moves. Client graph only.
Why opt-in rather than default: the split run is necessarily async, so an agent proposal's shadow-run diff degrades to input-only for that intent, and its writes land after the synchronous batch. Measured on the shop example, the chunk round-trip costs more than small run bodies save — the pass pays only when a run carries real weight (a heavy dependency, a large body).
The image optimizer#
One optimizer, used from both ends of an app's life — the build-time half of the images guide:
| Function | Does |
|---|---|
writeImageVariants(root, outDir) |
Walks <root>/public, encodes every ladder width in AVIF and WebP, and writes them under outDir/_janux/image/. Returns how many sources it processed. Called by janux build, whatever the output |
imageResponse(root, pathname) |
Encodes one variant on demand for janux dev, or undefined when the path is not one <Image> would have emitted |
Neither asks the other what exists: both derive URLs from janux's pure variantUrl / parseVariantUrl, which is what keeps janux dev, janux start and output: 'static' picking from the same candidates.
The font resolver#
The build-time half of the font pipeline. Everything is cached under node_modules/.janux/fonts, so the network is touched once per font and never again.
| Function | Does |
|---|---|
resolveFonts(root, configs) |
Fetches the Google stylesheet, keeps the declared subsets/weights, self-hosts each woff2 and measures the real file — returns the ResolvedFont[] the CSS layer formats |
writeFontAssets(root, configs, outDir) |
The build's output: the files, the finished CSS and the preload list, written under outDir/_janux/font/ |
builtFontAssets(outDir) |
Reads those back for janux start and output: 'static' — neither resolves anything |
fontResponse(root, path) |
Serves one file out of the cache under janux dev, where there is no build output yet |
The service worker build#
The build-time half of service workers. A worker cannot read its own build, so the build reads it for them.
| Function | Does |
|---|---|
serviceWorkerAssets(outDir) |
Every file of the built client worth precaching, as URL paths. Documents, sourcemaps, .md projections, islands.json and the worker itself are excluded |
serviceWorkerVersion(outDir, assets) |
A hash of those files' names and bytes — the cache name, and the reason a deploy starts from a clean cache |
builtServiceWorker(outDir, config) |
The URL janux start registers, or undefined when there is no build, no src/sw.ts, or register: false |
retireServiceWorker(outDir) |
Deletes a worker the app no longer has a source for, so removing src/sw.ts is enough to be rid of one |
SERVICE_WORKER_FILE |
'sw.js' — the name, at the root of the output so the worker's scope is the whole site |
packageDir(specifier, from)#
packageDir(specifier: string, from: string): string | undefined — where a package is installed, resolved the way Node does it: the nearest node_modules up the real path, symlinks followed. Bun.resolveSync answers from Bun's global install cache too, which reports packages the app never installed; this does not.
It is how zero-config integrations are detected — installing @janux/tailwind is the configuration — and how janux info reports the versions an app actually resolves.
packageDir('@janux/tailwind', root); // → '/app/node_modules/@janux/tailwind' | undefinedNode ⇄ Web request adapters#
const request = toFetchRequest(nodeReq); // IncomingMessage → Request
await sendFetchResponse(nodeRes, response); // Response → ServerResponseThese bridge Vite's Node middleware to Janux's Fetch-API handlers, and they're what you want when mounting Janux inside an Express/Connect app.
createHttpHandlers(options)#
Builds the router for src/api/** — the HTTP handlers feature — dispatching on exported method names (export function POST) and handling uploads. Import it when you assemble a server yourself instead of using createJanuxServer.
Upload guards#
The body-limit and content-sniffing helpers handlers validate uploads with (see HTTP handlers & uploads for the full recipe):
import { formDataWithin, matchesType, readBodyWithin, rejectOversized, sniffContentType } from '@janux/server';
rejectOversized(req, maxBytes); // null | 413 Response — content-length checked before any body byte
await readBodyWithin(req, maxBytes); // Uint8Array | 413 Response — chunked bodies cut at the limit
await formDataWithin(req, maxBytes); // FormData | 413 Response — multipart under the same protection
sniffContentType(bytes); // 'image/png' | … | undefined, from magic bytes
await matchesType(file, ['image/*']); // the file's real bytes against MIME patterns
acceptsType(type, ['image/*']); // an already-known type against those patterns — undefined never matchesspoolMultipart(req, options)#
The streaming sibling of formDataWithin: parses multipart/form-data as it arrives and spools every file part to a per-request temp directory, so memory stays flat whatever the upload weighs. SpoolOptions is { maxBytes, dir? } (dir defaults to the OS temp dir).
import { spoolMultipart, type SpooledFile, type SpooledForm } from '@janux/server';
// A `Response` instead of the form means 413 (over the limit) or 400 (malformed).
const result = await spoolMultipart(req, { maxBytes: 4 * 1024 ** 3 });
const form = result as SpooledForm;
const file: SpooledFile | undefined = form.file('video');
file?.field; // the multipart field name
file?.name; // the client-supplied filename — never a safe path
file?.type; // the declared content-type
file?.sniffed; // what the first bytes really are, read as they streamed
file?.size; // bytes on disk
file?.path; // where they landed, until moveTo() or cleanup()
await file?.moveTo('/var/uploads/clip.mp4'); // rename, or copy across filesystems
form.fields.title; // non-file parts, UTF-8, capped at 1 MB each
await form.cleanup(); // removes whatever moveTo() did not claimNothing is spooled when the request is refused: an oversized or malformed body takes the temp directory with it before the Response returns. A SpooledForm you keep is yours to cleanup().
renderNode(node, scope)#
The lower-level renderer under renderToString: renders one node against a render scope and returns HTML. Use renderToString unless you're building a renderer — it's what gives you snapshots, registry and i18nKeys alongside the HTML.
Scripting the CLI#
await runCli(['build']); // same as `janux build`
const parsed = parseArgs(['eval', 'evals/a.eval.json', '--json'], process.cwd());
console.log(HELP_TEXT);runCli(argv) dispatches to the commands and falls back to printing HELP_TEXT for anything unknown. parseArgs(argv, cwd) resolves the command, the port (--port, then PORT, then 3000, throwing on a non-number) and command flags — handy in a test or a monorepo task runner that wants the parsed shape without spawning a process.
Related: CLI reference · CLI and deployment · Project structure