@k8ordo/server

Run & deploy

vite build writes a request handler and the files the browser is served. serve() runs them on Node.js, and any other host can call the handler directly. This page covers the shape of the output, how serve() answers, and running the handler elsewhere.

The build output

dist/rsc/index.js is the request handler, dist/ssr/ is what it turns payloads into HTML with, and dist/client/ holds the files the browser is served. Nothing is rendered ahead of time in this mode, so there is no page HTML in dist/client/.

dist/
  rsc/
    index.js
  ssr/
  client/
    assets/

The handler imports the application's dependencies from node_modules at run time, so install them where the server runs. vite is only for the build, so an install without dev dependencies (pnpm install --prod) is enough.

serve()

serve() runs the build on a Node.js HTTP server. Once it is listening it hands back where it listens and a way to stop it, and logs k8ordo: serving <dist> on <url>. Its options are typed ServeOptions, and what it returns Server.

// serve.js
import { serve } from '@k8ordo/server/runtime';

await serve({ port: 3000, host: '0.0.0.0' });
OptionDefaultMeaning
dist'dist'The build output directory — the one holding rsc/ and client/ — resolved from the current working directory.
port3000The port to listen on. 0 asks the system for a free one, and the returned port says which.
host'localhost'The host to listen on. The default is reachable from the same machine only; pass 0.0.0.0 to be reachable from outside, as inside a container.
Returned (Server)Meaning
portThe port it actually listens on
urlhttp://<host>:<port>
close()Stops the server, returning a promise that settles once it has stopped

With port: 0 and close(), a test can start its own server against a build and stop what it started.

// scripts/smoke.js
import { serve } from '@k8ordo/server/runtime';

const server = await serve({ dist: 'dist', port: 0 });
const response = await fetch(`${server.url}/products/1`);
console.log(response.status);
await server.close();

How serve() answers

A GET or HEAD naming a file inside dist/client/ is answered with that file as it is. Everything under /assets/ carries a content hash in its name, so it gets cache-control: public, max-age=31536000, immutable; anything else gets no-cache.

Everything else goes to the handler, and a POST always does, even to a path that names a file. The handler's status and headers pass through as they are, several Set-Cookie headers included. When the handler throws, the answer is a 500 with the body internal error; the details go to the server's log, not to the visitor.

A request pathname can only ever name a file inside dist/client/, whatever it is spelled like: spellings with .., %2e%2e or %2f stay inside, and an escape that cannot be decoded or a NUL byte is not a file name at all. Traversal is not a case weighed per request but an outcome the path resolution cannot produce.

The statuses the handler answers with are listed here. Errors & redirects

Running on another host

The built handler is a plain function, (request: Request) => Promise<Response>, default-exported from dist/rsc/index.js. Any runtime that can hand it a Request and take back a Response can run it. @k8ordo/static builds and calls the same handler at build time, compiled for that mode.

// render.js
import handler from './dist/rsc/index.js';

const response = await handler(
  new Request('https://example.com/products/1'),
);
console.log(response.status, response.headers.get('content-type'));

The handler does not serve files: on another host, serve dist/client/ from the host and hand every other request to the handler.

Build the Request with the URL the visitor asked for: the handler accepts a POST only when its Origin header is present and names that URL's host, and answers any other POST with a 403. Behind a proxy that means passing the public host through — serve() builds the URL from the request's own Host header and reads no forwarded header, so a proxy in front of it has to pass the original Host on unchanged.

routesDir

routesDir is the one option framework() takes (typed ServerOptions): the route directory, relative to the project root, src/routes by default. The generated files still go to .k8ordo/, and problem lines still begin with routes/.

// vite.config.ts
import { framework } from '@k8ordo/server';
import { defineConfig } from 'vite';

export default defineConfig({
  plugins: [framework({ routesDir: 'app/routes' })],
});