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:

  1. the "janux" field in package.json (deprecated fallback),
  2. janux.config.ts / .js default export,
  3. 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.splitIntentsopt-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' | undefined

Node ⇄ Web request adapters#

const request = toFetchRequest(nodeReq);          // IncomingMessage → Request
await sendFetchResponse(nodeRes, response);       // Response → ServerResponse

These 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 matches

spoolMultipart(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 claim

Nothing 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