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.
npm install @titan-design/daemon @titan-design/registry zodThe 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.
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 filehandle.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
| Route | Behaviour |
|---|---|
GET /health | 503 { ok: false, starting: true } until the pid file exists, then version, pid, uptime, port, and your health() fields |
GET /version | { version } |
GET /events | SSE; ready on connect, change on every watch-tree change, ping every 25s |
POST /rpc/:name | 403 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 /mcp | Stateless 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.
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.
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
gateoption. The gate runs right after the Host/Origin guard and before every route, so/health,/version,/events,/rpcand anythingmountRoutesadds all answer 401 without a credential. A foreign Host still gets the guard's 403 first. Only/auth/loginis open, and the gate answers it itself:GETandHEADrender the page,POSTspends the code, and every other method gets 405 withAllow: GET, HEAD, POST. The gate is not exported as middleware, because mounted any later than this it leaves the routes ahead of it open. Withoutgatethe app behaves exactly as before. - Token file. A 32-byte base64url secret. It is refused (
TokenFileError, with aproblem) 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.rotateTokenFilewrites 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
timingSafeEqualover 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 JSONPOST /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-Age30 days. It is stateless and survives restarts. The server refuses one from the future or 30 days old regardless of the browser. It is alwaysSecure: the remote listener speaks only TLS, and a browser never sends aSecurecookie over plain HTTP, even to another port on the same host. Agateon an app you serve over plain HTTP yourself gets a cookie browsers drop beyondlocalhost. - Bearer. Non-browser clients send
Authorization: Bearer <secret>. - Logout.
POST /auth/logoutclears this browser's cookie only. A copied cookie stays valid until it expires or the secret rotates; rotation is the revocation. - 401s. A browser
GETasking 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/rpcpasses it ascreateContext's second argument, so a command can refuse a credential kind. It isundefinedonly on an ungated listener and on MCP; a gated app answers 401 rather than callcreateContextwithout it.peerLocal. True when the peer address is loopback or one of this machine's own interface addresses, read fromos.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
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(alsocreateDaemonAuth({ 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 rotatevoids every outstanding link and session.TLS only.
remote.tlsis required, and aremotewithout it throwsRemoteBindErrorbefore anything binds: there is no way to serve plain HTTP beyond loopback. The remote server isnode:httpsalone, so a plain-HTTP request fails the handshake and gets no HTTP reply (curl reports an empty reply). Loopback stays plain HTTP.The pair.
certFileandkeyFileare PEM, such as the filestailscale certwrites. Before anything binds,TlsFileErrorrefuses 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 inallowedHosts. 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 bindsremote.hoston the same port with its own hono app, which shares the registry, hub,health()andmountRoutes(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/loginincluded.Allowlist.
remote.hostplusremote.allowedHosts, nothing else:Host: localhostgets 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 thehttps://forms alone (httpsOnly): anhttp://origin is another site.guards.allowedOriginsapplies 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;/mcpthere is 401, then 404 with a credential.Refusals.
RemoteBindErrorfor 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 aremotebeside an unauthenticated non-loopbackhost. 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 (
EADDRNOTAVAILwhile the interface is down, orDaemonPortInUseErrornaming the remote host), loopback is closed and no pid file is written.close()shuts both.Not covered. An open
/eventsstream 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:
mountRoutes: (app) => mountStaticApp(app, { root: path.resolve(here, "dashboard"), base: "/ui" }),rootis a build directory holdingindex.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 andnosniff. Files underimmutableDir(defaultassets, where Vite writes hashed names) getpublic, max-age=31536000, immutable. Everything else,index.htmlincluded, getsno-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
GETis 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. mountStaticAppdoes not honour Range requests: every file comes back whole, with noAccept-Ranges. For media a browser must seek in, usefileResponse(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:
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-andbytes=-ngive206withContent-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, gives416withContent-Range: bytes */size. - No
Range, a malformed one, or several ranges (bytes=0-1,4-5) gives200with the whole file. Safari never sends several. HEADreturns the headers and no body.Accept-Ranges: bytesandX-Content-Type-Options: nosniffare 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 gets403, and a missing file404, 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_NOFOLLOWonly guards the final component.
The seams
createContext(surface, auth?)builds the per-request context.surfaceis"http"or"mcp";authis what thegaterecorded, if one ran. The daemon never knows what your context contains.health()extends/healthwith product state. Core fields win a collision.mountRoutes(app)adds product routes to the same hono app.formatErrormaps a thrown value to{ message, code }for every surface.loggeraccepts anything with pino's(fields, message)shape. A pino instance satisfies it structurally with no adapter.consoleLogger(stderr) is the default,silentLoggeris 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 recursivefs.watchhandle onroot; elsewhere (Linux), whererecursiveis 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), orundefinedwhen it passed none or passed one to another pid. Throws unlessLISTEN_FDSis 1, and removes theLISTEN_*variables fromenvonce it takes the fd.buildHttpApp(options)— the hono app alone, testable throughapp.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.