Skip to content

daemon ​

Tier 1 · engines. Depends on registry (its own tier) and rpc-protocol, plus hono, @hono/node-server, and the MCP SDK. zod v4 is a peer.

sh
npm install @titan-design/daemon @titan-design/registry zod

The problem it solves ​

Once you have a command registry, hosting it is a day of plumbing you will get subtly wrong: a pid file that a restart deletes out from under its successor, a /health that lies while the process is still starting, a recursive file watcher that does not actually recurse on Linux, an SSE endpoint that one dead client wedges.

This package is that plumbing, done once. It knows nothing about any product: paths, port, context, and error formatting are all parameters.

When to reach for it ​

You have a @titan-design/registry and you want it reachable over HTTP and MCP on loopback. Or you want just one of the utilities — watchTree, the pid-file helpers, runMcpStdio — which are exported separately for exactly that reason.

Example ​

Verified against 0.1.1.

ts
import os from "node:os";
import path from "node:path";
import { mountStaticApp, startDaemon } from "@titan-design/daemon";

const stateDir = path.join(os.homedir(), ".local/state/my-product");
const watchRoot = path.join(os.homedir(), "my-product/data");

const handle = await startDaemon({
  registry,                                  // CommandRegistry<Ctx>
  createContext: (surface) => ({ warnings: [], format: "json", root, surface }),
  version: "1.0.0",
  stateDir,                                  // holds daemon.pid + daemon.meta.json
  port: 0,                                   // 0 binds an ephemeral port
  toolPrefix: "my__",                        // omit to skip the /mcp route
  watchRoot,                                 // omit to skip live reload
  health: () => ({ sessions: 42 }),
  mountRoutes: (app) => mountStaticApp(app, { root: "dist/ui", base: "/ui" }),
});

await fetch(`http://127.0.0.1:${handle.port}/health`).then((r) => r.json());
// { ok: true, version: '1.0.0', sessions: 42, pid: …, uptime_ms: …, port: … }

await fetch(`http://127.0.0.1:${handle.port}/rpc/task.done`, {
  method: "POST",
  headers: { "content-type": "application/json", "x-titan-client": "my-cli" },
  body: JSON.stringify({ slug: "TP-1" }),
}).then((r) => r.json());
// { ok: true, data: { ok: true } }

await handle.close(); // stops the watcher, closes the socket, releases the pid file

handle.port is the bound port, so port: 0 is usable in tests. runDaemonUntilSignal takes the same options, starts, waits for SIGTERM/SIGINT, then closes. Neither calls process.exit — the caller decides how the process terminates.

Surfaces ​

RouteBehaviour
GET /health503 { ok: false, starting: true } until the pid file exists, then version, pid, uptime, port, and your health() fields
GET /version{ version }
GET /eventsSSE; ready on connect, change on every watch-tree change, ping every 25s
POST /rpc/:name403 bad Host/Origin or no Origin and no X-Titan-Client, 415 non-JSON Content-Type, 413 a body over its cap (code 64), 404 unknown, 400 bad JSON or bad args (code 65), 403 a command refusing its caller (code 77), 429 a command refusing a caller over its limit (code 75), 500 on any other thrown error
POST /mcpStateless MCP; one server and transport per request

/rpc and MCP CallTool both go through the registry's invokeCommand, so the envelope and exit codes are identical across surfaces.

A /rpc body is capped before it is buffered: 1 MiB (DEFAULT_RPC_BODY_LIMIT) unless rpcBodyLimit: { maxBytes, perCommand } says otherwise, with perCommand keyed by command name. A Content-Length over the cap, or one that is not a number, gets 413 at once. A chunked body is counted as it arrives and gets 413 as soon as it passes the cap; the server then discards the rest of it for at most 500 ms before it closes the socket, and never holds it in memory.

The cap bounds one body, not how many are in flight. N slow bodies on loopback can hold N times the cap until Node's request timeout (300 s by default) drops them. This is left unbounded on purpose: a loopback caller already runs as the owner's account and can spend memory in plainer ways, while a bound would refuse honest callers, such as several agents posting at once, with a status no client retries. The remote listener checks credentials before it reads a body, so this residual is loopback's alone.

Request guards ​

An unauthenticated daemon on loopback is reachable from every browser on the machine, so every route is behind four checks: the Host header must be in an allowlist (403), a state-changing request's Origin, when it sends one, must be in an allowlist (403), a state-changing request with no Origin must send a non-empty X-Titan-Client header (403), and a state-changing request's Content-Type must be application/json (415).

The default allowlist is localhost, 127.0.0.1, and [::1], each with and without the bound port, plus a non-default host option; origins are the http:// and https:// forms of those. A state-changing request with no Origin header must send X-Titan-Client with any non-empty value, conventionally the caller's name. GET /health and GET /version need neither. A state-changing request must also carry a JSON Content-Type. A cross-origin page can send neither the JSON body nor the custom header without a preflight the daemon never answers.

A missing Origin alone is not trusted: browsers have omitted it (old releases, privacy extensions, webviews), and a loopback peer address proves nothing because the attacking page runs on this machine too. The header is not a secret and authenticates no one; any local process can send it. It only proves the sender is not a web page.

Non-browser callers opt in explicitly. liveSource from @titan-design/rpc-client sends the header already. A CLI or script adds x-titan-client: <name> to its fetch. An MCP client configures it as a transport header, for example "headers": { "x-titan-client": "claude-code" } in .mcp.json or requestInit.headers on the SDK's StreamableHTTPClientTransport. CLIENT_HEADER is exported from @titan-design/rpc-protocol and re-exported here.

ts
await startDaemon({
  guards: { allowedHosts: ["daemon.internal"], allowedOrigins: ["https://console.internal"] },
  // ...
});

Authentication beyond loopback ​

The guards keep web pages out but authenticate no one, which is fine on loopback and not on a LAN. auth.ts is the mechanism for a listener that needs a real credential. The product owns the policy: the file path, the CLI verbs that mint links and rotate the secret, and which listener the gate sits on. startDaemon wires it through the remote option (below); buildHttpApp's gate option is the same gate for an app you bind yourself.

ts
import {
  buildHttpApp, createDaemonAuth, ensureTokenFile, mintLoginCode, rotateTokenFile,
} from "@titan-design/daemon";

const tokenFile = path.join(stateDir, "lan.token");
ensureTokenFile(tokenFile);                    // creates it once: 32 random bytes, 0600, O_EXCL
const auth = createDaemonAuth({ tokenFile });  // throws TokenFileError on an untrustworthy file

const app = buildHttpApp({ ...options, gate: auth });

// In a separate CLI process, on the daemon's host:
console.log(`https://host.example.ts.net:7500/auth/login?code=${mintLoginCode(ensureTokenFile(tokenFile))}`);
rotateTokenFile(tokenFile);                    // ends every session and voids every code
  • The gate option. The gate runs right after the Host/Origin guard and before every route, so /health, /version, /events, /rpc and anything mountRoutes adds all answer 401 without a credential. A foreign Host still gets the guard's 403 first. Only /auth/login is open, and the gate answers it itself: GET and HEAD render the page, POST spends the code, and every other method gets 405 with Allow: GET, HEAD, POST. The gate is not exported as middleware, because mounted any later than this it leaves the routes ahead of it open. Without gate the app behaves exactly as before.
  • Token file. A 32-byte base64url secret. It is refused (TokenFileError, with a problem) when it is a symlink, not a regular file, owned by another user, readable by group or others, empty, shorter than 32 bytes, or not base64url. The gate re-reads it whenever its inode, size, mtime, ctime, mode or owner change, and re-checks all of the above each time; a file that fails the checks after start makes every request 503 until it is fixed. rotateTokenFile writes a 0600 temp file and renames it into place.
  • Keys. The secret is never an HMAC key itself. HKDF derives separate cookie, login-code and bearer keys, and every comparison is timingSafeEqual over equal-length HMAC digests.
  • Login code. v1.<issuedAt>.<nonce>.<mac>, minted offline by anything that can read the file. It lives ten minutes and works once per daemon process, and a code minted before the process started is refused, so a restart does not revive a spent one.
  • Login is two steps. GET /auth/login?code= returns an inert page (no-store, Referrer-Policy: no-referrer, a nonce CSP) that neither reads nor spends the code, so a chat app's link preview cannot burn it. Its button sends a same-origin JSON POST /auth/login { code }, which spends the code, sets the cookie, and then the script navigates to /.
  • Session cookie. titan_session=v1.<issuedAt>.<mac>, HttpOnly; Secure; SameSite=Strict; Path=/, Max-Age 30 days. It is stateless and survives restarts. The server refuses one from the future or 30 days old regardless of the browser. It is always Secure: the remote listener speaks only TLS, and a browser never sends a Secure cookie over plain HTTP, even to another port on the same host. A gate on an app you serve over plain HTTP yourself gets a cookie browsers drop beyond localhost.
  • Bearer. Non-browser clients send Authorization: Bearer <secret>.
  • Logout. POST /auth/logout clears this browser's cookie only. A copied cookie stays valid until it expires or the secret rotates; rotation is the revocation.
  • 401s. A browser GET asking for HTML gets a small page with a reload link; everything else gets a JSON envelope. Neither names a product's login command.
  • createContext(surface, auth). The gate records { credential: "session" | "bearer", issuedAt, peerLocal } and /rpc passes it as createContext's second argument, so a command can refuse a credential kind. It is undefined only on an ungated listener and on MCP; a gated app answers 401 rather than call createContext without it.
  • peerLocal. True when the peer address is loopback or one of this machine's own interface addresses, read from os.networkInterfaces() per request, or when no peer address is known (app.request() in tests). The remote listener terminates TLS itself, with no proxy in front, so the peer address is the client's own. Any local process can read the token file and mint a link, so a command that must come from another device refuses when it is true.

A remote listener ​

ts
const handle = await startDaemon({
  ...options,                                   // host stays loopback, the default
  port: 7500,
  remote: {
    host: "100.64.0.20",                          // the tailscale address, say
    tokenFile,
    allowedHosts: ["lan-box.example.ts.net"],
    signInHint: "Ask the operator for a fresh link: run `acme login-link` on the host.", // optional
    tls: { certFile: "lan-box.example.ts.net.crt", keyFile: "lan-box.example.ts.net.key" },
  },
});
  • The sign-in hint. remote.signInHint (also createDaemonAuth({ signInHint })) is the sentence the signed-out and invalid-link pages use to say how to get a link; it defaults to "Ask the operator for a fresh sign-in link." and is HTML-escaped on the 401 page and script-escaped on the login page. The pages add the link and session lifetimes themselves. token rotate voids every outstanding link and session.

  • TLS only. remote.tls is required, and a remote without it throws RemoteBindError before anything binds: there is no way to serve plain HTTP beyond loopback. The remote server is node:https alone, so a plain-HTTP request fails the handshake and gets no HTTP reply (curl reports an empty reply). Loopback stays plain HTTP.

  • The pair. certFile and keyFile are PEM, such as the files tailscale cert writes. Before anything binds, TlsFileError refuses a missing or unreadable file, a key that is not this user's or is readable by group or others, a key that is not the certificate's, a certificate outside its validity window, and a certificate that does not cover every name in allowedHosts. Nothing binds and no pid file is written.

  • Renewal. Both files are stat'd every remote.tlsReloadMs (60000 by default). A change that passes every check above is served to new connections with no restart. One that fails is logged as an error and the last good pair keeps serving; the check repeats on the next tick. The listener never drops to plain HTTP.

  • Two listeners, one port. Loopback is exactly as without remote: no auth, the loopback allowlist. The second listener binds remote.host on the same port with its own hono app, which shares the registry, hub, health() and mountRoutes (called once per app).

  • Order. The Host/Origin guard runs first, then the gate, then every route. A foreign Host gets 403 before the gate, /auth/login included.

  • Allowlist. remote.host plus remote.allowedHosts, nothing else: Host: localhost gets 403 there, and the loopback allowlist never gains these names. Host and the derived origins match only with the bound port (portOnly), because cookies ignore port: a page from another service on port 80 of the same host would otherwise pass the Origin check with the cookie. Origins are the https:// forms alone (httpsOnly): an http:// origin is another site. guards.allowedOrigins applies to loopback only.

  • No /mcp. It is spliced ahead of hono, where the gate would never see it, so the remote bind takes no MCP handler at all; /mcp there is 401, then 404 with a credential.

  • Refusals. RemoteBindError for a loopback or wildcard address (0.0.0.0, ::, their long and IPv4-mapped forms, all of 127/8), for anything that is not a bare IP literal (a name could resolve to either), and for a remote beside an unauthenticated non-loopback host. The token and TLS files are read before anything binds, so a bad one never half-starts the daemon.

  • Both or neither. If the remote bind fails (EADDRNOTAVAIL while the interface is down, or DaemonPortInUseError naming the remote host), loopback is closed and no pid file is written. close() shuts both.

  • Not covered. An open /events stream outlives a token rotation, and the daemon does not notice its address leaving the interface; both are known gaps.

Serving a built front end ​

mountStaticApp(app, { root, base?, immutableDir? }) serves a built app through mountRoutes:

ts
mountRoutes: (app) => mountStaticApp(app, { root: path.resolve(here, "dashboard"), base: "/ui" }),
  • root is a build directory holding index.html, or a single-file build's HTML file. Without it, every page answers 503 "not built", so a daemon can start before its front end exists.
  • GET <base>/<file> serves the file with its content type and nosniff. Files under immutableDir (default assets, where Vite writes hashed names) get public, max-age=31536000, immutable. Everything else, index.html included, gets no-cache.
  • A path with no extension is a client route and gets index.html. A missing file with an extension gets 404, because HTML in place of a script hides the real error. <base> redirects (308) to <base>/.
  • Traversal is refused. Each segment is decoded once. ., .., an encoded / or \, NUL, a drive letter, or a bad escape gets 403. The resolved real path must stay inside the root's real path, so a symlink leading out also gets 403. Dotfiles are never served.
  • Only GET is registered, after the core routes and behind the same guards. It cannot shadow /rpc, /events, or /health, and the Host check applies to every file.
  • mountStaticApp does not honour Range requests: every file comes back whole, with no Accept-Ranges. For media a browser must seek in, use fileResponse (next section).

Serving a file with byte ranges ​

fileResponse(request, file, { contentType, cacheControl? }) returns a Response for one file, for a route you write yourself:

ts
app.on(["GET", "HEAD"], "/media/:id", async (c) =>
  fileResponse(c.req.raw, await resolveCatalogEntry(c.req.param("id")), { contentType: "video/mp4" }),
);
  • bytes=a-b, bytes=a- and bytes=-n give 206 with Content-Range: bytes a-b/size (both ends inclusive; an end past the size is clamped).
  • A start at or past the size, or bytes=-0, gives 416 with Content-Range: bytes */size.
  • No Range, a malformed one, or several ranges (bytes=0-1,4-5) gives 200 with the whole file. Safari never sends several.
  • HEAD returns the headers and no body. Accept-Ranges: bytes and X-Content-Type-Options: nosniff are always set.
  • The body streams from the open file for just the requested bytes. The file is opened with O_NOFOLLOW; a symlink at the last path component or a non-regular file gets 403, and a missing file 404, with fixed text that never names the path.
  • Containment is the caller's job. Pass an already-resolved real path from a catalog you built; never a path the client named. O_NOFOLLOW only guards the final component.

The seams ​

  • createContext(surface, auth?) builds the per-request context. surface is "http" or "mcp"; auth is what the gate recorded, if one ran. The daemon never knows what your context contains.
  • health() extends /health with product state. Core fields win a collision.
  • mountRoutes(app) adds product routes to the same hono app.
  • formatError maps a thrown value to { message, code } for every surface.
  • logger accepts anything with pino's (fields, message) shape. A pino instance satisfies it structurally with no adapter. consoleLogger (stderr) is the default, silentLogger is there for tests.

The utilities, usable alone ​

  • watchTree(root, onChange, { debounceMs, onError }) — recursive watcher that debounces bursts into one callback. On macOS and Windows it holds one native recursive fs.watch handle on root; elsewhere (Linux), where recursive is version-dependent, it walks the tree and holds one handle per directory. isWatching(dir) is true for any existing path under a recursive root even though it has no handle of its own. whenWatching(dir) resolves when a path is covered, so tests never sleep.
  • EventHub — subscribe/broadcast fan-out that drops throwing subscribers rather than letting one dead connection wedge the rest.
  • daemonPaths, writePidFile, readPidFile, removePidFile — atomic (temp-file + rename) pid and metadata files.
  • isProcessAlive(pid), getProcessCommand(pid) — liveness plus the identity check that catches a recycled pid.
  • probeHealth(port, { host, timeoutMs }) — GET /health, null on any failure.
  • systemdListenFd(env, pid) — the socket systemd passed to this process (fd 3), or undefined when it passed none or passed one to another pid. Throws unless LISTEN_FDS is 1, and removes the LISTEN_* variables from env once it takes the fd.
  • buildHttpApp(options) — the hono app alone, testable through app.request().
  • createMcpServer, attachHandlers, listTools, invokeTool, runMcpStdio — the MCP projection, usable over stdio without running a daemon.

Gotchas ​

removePidFile(paths, pid) only unlinks while the file still names pid. That is what stops a supervised restart from deleting its successor's pid file. Pass the pid you wrote.

/mcp is spliced in ahead of hono on the raw Node server, because the SDK's StreamableHTTPServerTransport takes ownership of the response object.

A restart on an inherited socket refuses nothing. Pass listenFd: systemdListenFd() under a systemd .socket unit and the daemon serves on that socket instead of binding port. Close releases only this process's copy. While no process listens, connections wait in the kernel backlog, and the next process holds them until its pid file exists, so none is refused and none sees 503 starting. Without the variables it binds port as before.

Close order is yours to get right. If your product runs its own watcher or indexer, close it before the socket: a refresh may be mid-transaction. startDaemon closes what it owns, not what you own.

On macOS, XDG_DATA_HOME does not redirect state. If you point a test at a clean state directory, set HOME. This has cost real debugging time.

Where it came from ​

active-work's src/server/ (hono), which shrank from 1,302 lines to 632 when it adopted this package. brain's raw-http server was the other candidate and was worse.

MIT licensed