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/a2a hosted A2A endpoint (JSON-RPC); a GET answers with the agent card
/.well-known/agent-card.json the A2A agent card, derived from the app
/_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)
websocket.path 426 Upgrade Required — the pure fetch cannot upgrade; see First-class WebSockets
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.

First-class WebSockets#

Most apps never need this recipe for realtime: declare the endpoint in src/ws.ts (or point websocket: in janux.config.ts at another module) and janux dev and janux start upgrade it themselves, on the same port as the pages:

import type { WebSocketConfig } from '@janux/server';

export default {
  path: '/ws',
  // Per-socket data, attached at upgrade time from the request.
  data: (req) => ({ user: new URL(req.url).searchParams.get('u') ?? 'anon' }),
  open(socket) {
    socket.send(`hello ${socket.data.user}`);
  },
  message(socket, frame) {
    socket.send(`echo:${frame}`);
  },
} satisfies WebSocketConfig<{ user: string }>;

The handlers are Bun.serve-style (open/message/close/drain) and the socket surface is production's ServerWebSocket — under janux dev an adapter provides the same data/send/close contract, so the module runs identically in both (fan out by iterating your own socket set rather than Bun-only pub/sub topics if dev parity matters to you). A plain HTTP request on path gets 426 from fetch itself.

When you DO own the Bun.serve, the framework hands you the two pieces janux start uses: serve(request, bunServer) decides the upgrade when the request matches path (returning undefined, per Bun's contract) and delegates everything else — failed upgrades included — to the pure fetch; websocket is the handler object Bun.serve takes:

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

const server = createJanuxServer(await prodServerOptions(process.cwd()));

Bun.serve({
  port: Number(process.env.PORT ?? 3000),
  fetch: (request, bun) => server.serve(request, bun),
  websocket: server.websocket,
});

examples/realtime-chat is the full story: optimistic delivery, presence and cursor replay over exactly this seam.

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 — and upgrades the src/ws.ts endpoint on its own port too. 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