Skip to content

Custom HTTP Routes

The LoopbackServer surface is mount / mountDir / unmount for file mounts, plus registerRoute for arbitrary HTTP endpoints — a dev-only upload route, a status page, or any custom API.

registerRoute(method, path, handler) registers an exact METHOD path route. The handler receives the raw (req, res); read a POST body with collectRequestBody(req, maxBytes), always passing a byte cap:

import { collectRequestBody, type WorkletRuntime } from '@ekrooh/bare/runtime';
declare const runtime: WorkletRuntime;
runtime.server.registerRoute('POST', '/upload', async (req, res) => {
const body = await collectRequestBody(req, 1024 * 1024); // rejects past 1 MiB
res.writeHead(201, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ received: body.length }));
});
  • Routes run before mount resolution, after the auth gate: in device mode the request must carry the session cookie/token (401 otherwise) and pass the Sec-Fetch-Site check (403 for same-site/cross-site); dev mode is open, but the origin rule still applies.
  • The path is normalized like a mount path (percent-decoded, trailing slash dropped), so /api/ and /a%20b are reachable; a path no request could match throws. The method is upper-cased at registration.
  • Async handlers are supported. A handler that throws or rejects is answered with a bare 500 Internal Server Error; the cause goes to the log.
  • The response starts with Referrer-Policy: no-referrer and X-Content-Type-Options: nosniff already set, plus Cross-Origin-Resource-Policy: same-origin whenever auth is on (off in dev). Set your own Content-Type.
  • A handler that fails after its response has ended is only logged. The 500 for a failed route never carries headers the handler had set (such as Set-Cookie).

For serving static bytes, the mount API still applies:

import type { WorkletRuntime } from '@ekrooh/bare/runtime';
declare const runtime: WorkletRuntime;
runtime.server.mount('/photos/1.jpg', '/absolute/path/to/photo.jpg');
runtime.server.mountDir('/photos', '/absolute/path/to/photos');

mountDir serves every file below the prefix (longest-prefix match). By default a directory mount is gated like a file mount. Pass { public: true } to mark it as world-readable bootstrap content (the web app at / is mounted that way); only a public mount applies the SPA fallback to index.html for unknown GET navigations — a non-public mount 404s them.

Files from a gated mount are served as content, not app code: they carry the same sandbox Content-Security-Policy as file mounts, so an HTML or SVG file in a mounted folder (user files, a peer-synced folder) never runs script in the app origin. Ship app code only in the public mount.

A gated directory mount must not be inside (or the same folder as) the public one: mountDir throws on that layout, in either mount order (real paths; case- and normalization-insensitive on macOS, iOS and Windows). A gated folder strictly around the web app folder is allowed. Keep gated file mounts out of the public web app directory too; that layout is not detected, and such a file would be reachable before login through its public URL.

For chunked uploads of large files (photos), the wire protocol’s DISPATCH/INVOKE envelopes remain the supported channel. A registerRoute endpoint gives you a second path (e.g. dev-only) when you need browser → worklet bytes without the protocol — pair it with mount/mountDir to serve what was uploaded.