Custom server#

janux start is a thin wrapper around one function: createJanuxServer(options).fetch(request) — Web Request in, Web Response out. When you need your own server (an existing app to mount into, a platform adapter, your own static strategy or logging), you write that wrapper instead of using the command.

import { createJanuxServer } from '@janux/server';
import { prodServerOptions } from '@janux/cli';

/** Static assets first, then the Janux app — what `janux start` does. */
export async function createHandler(root = process.cwd()) {
  const server = createJanuxServer(await prodServerOptions(root));

  return async (request: Request): Promise<Response> => {
    const { pathname } = new URL(request.url);
    const file = Bun.file(`${root}/dist/client${pathname}`);

    if (pathname !== '/' && (await file.exists())) return new Response(file);

    return server.fetch(request);
  };
}
Bun.serve({ port: Number(process.env.PORT ?? 3000), fetch: await createHandler() });

prodServerOptions(root) resolves the app's conventions exactly as the CLI does — routes, *.api.ts modules, stores, agent, i18n, middleware, matchers, src/api/** handlers, the built client.js and stylesheet. Pass your own ServerOptions instead (or spread and override) when you want different wiring: createJanuxServer({ ...(await prodServerOptions(root)), onAudit }).

Run janux build first — the handler above serves what it wrote into dist/client.

What server.fetch owns#

Anything you don't intercept before it. These paths belong to the framework:

Path Response
/_janux/api/<name> api() invocation — { ok, result } / { ok, error }
/_janux/approve, /_janux/reject human-in-the-loop proposal resolution
/_janux/manifest?path=… the page's agent surface (tools, resources, routes)
/_janux/mcp hosted MCP endpoint (JSON-RPC)
/_janux/agent, /_janux/llm the copilot mount, when an agent is configured
/llms.txt when llmsTxt is configured
/sitemap.xml, /robots.txt when siteUrl is configured
<page>.md markdown projection of a page
/api/** src/api/** HTTP handlers (httpHandlers.prefix to move them)
anything else a page render, or 404

Static files are not on that list. server.fetch never reads from disk, which is why the order in the handler is yours to decide: check files first (a real asset always wins) or check routes first (a page may shadow a stale asset).

Middleware: inside or outside#

Two places, different reach:

createJanuxServer({
  ...options,
  // Inside: runs before routing, and covers /_janux/* too. A Response short-circuits.
  middleware: (req) =>
    isBlocked(req) ? new Response('Forbidden', { status: 403 }) : undefined,
});

Your own wrapper is outside — it also covers static files and anything you add before delegating. Use ServerOptions.middleware for app-level concerns (auth gates, redirects, rate limits) so they apply identically under janux start, and the wrapper for infrastructure (logging, compression, health checks).

Per-request context#

ctxFor(req) builds the ctx every route, intent and api() receives — the seam for sessions and tenancy:

createJanuxServer({
  ...options,
  ctxFor: async (req) => ({ user: await userFromCookie(req.headers.get('cookie')) }),
});

See auth & context for the full pattern.

Node, Hono, and anything else#

Hono, Elysia and other Web-standard frameworks take the handler as-is:

app.all('*', (c) => server.fetch(c.req.raw));

For node:http (and Express, whose req/res are Node's), convert both directions with the adapter the Vite plugin itself uses:

import { createServer } from 'node:http';
import { sendFetchResponse, toFetchRequest } from '@janux/vite';

createServer(async (req, res) => {
  const response = await server.fetch(await toFetchRequest(req));

  await sendFetchResponse(res, response);
}).listen(3000);

Note the one difference: toFetchRequest buffers the body, so streaming uploads want a src/api/** handler on a Web-standard runtime instead.

Development stays on janux dev#

The Vite plugin is the dev server, and it wraps server.fetch the same way this recipe does — public/ first, then the app, falling through to Vite only on a genuine page-router 404. It also owns HMR, the JSX transform and the *.api.ts client stubs, so a custom production server doesn't need a custom dev server: keep janux dev for development and reserve the wrapper for start.

Fully static instead#

If nothing in the app needs a server at request time, output: 'static' prerenders every page and you deploy the folder — no wrapper at all. See deploying.

Related: Server API · HTTP handlers · CLI · Deploying