This layer accepts inbound traffic on every supported protocol (HTTP/1.1, HTTP/2, HTTPS, WebSockets, MQTT, NATS) and routes it through to the Resource layer.
Read this when: you're touching request/response, protocol handling, middleware ordering, or WebSocket upgrade behavior.
Navigation convention. This guide references code by symbol name (function/const). Use your editor's go-to-symbol or
grep -n '<name>' server/<file>to jump. Line numbers drift; symbols don't.
| Stack | File | Used for |
|---|---|---|
| Native | http.ts |
Direct socket handling for application-level HTTP/1.1, HTTPS, HTTP/2, and WebSockets. Highest performance. This is the path most user requests take (REST, GraphQL, custom resource endpoints). |
| Operations API | operationsServer.ts |
Fastify-based JSON operations API ({operation: 'create_table', ...}). Internal/admin surface — not on the hot path for application data. |
| Custom Functions (legacy) | fastifyRoutes.ts |
Legacy custom functions only. Wraps Fastify with autoload. Don't add new code here. |
A request entering http.ts does not go through Fastify. The two handleApplication(scope) functions (one in each Fastify file) load independently from component config.
| File | Purpose |
|---|---|
Server.ts |
Defines the Server interface — the contract that protocol plugins use to register listeners. Has socket(), http(), ws(), upgrade(), contentTypes, getUser(), operation(), replication, etc. |
http.ts |
Native HTTP/WS server. Registration entry points (onRequest, onUpgrade, onWebSocket), per-port middleware chains, UDS support, PROXY protocol. See section map below. |
middlewareChain.ts |
Topological sort respecting before/after constraints on listener registrations (topoSort). Falls back to registration order on cycle. Also urlPath/host sub-route dispatch: a mount is a prefix with a segment boundary, the mount prefix is stripped from request.pathname before the sub-chain runs, and the root mount '/' normalizes to no path constraint (joins the default chain, nothing stripped — #1766). |
REST.ts |
Resource-routed REST handler: URL → Resource.getResource() → method dispatch + content negotiation. |
graphqlQuerying.ts |
GraphQL query/mutation/subscription execution against Resources. |
mqtt.ts |
MQTT broker (connect/sub/pub mapped onto Resource interface). |
DurableSubscriptionsSession.ts |
Persistent subscription state (resume across reconnects). |
| File | Purpose |
|---|---|
operationsServer.ts |
Boots Fastify for operations API. buildServer() constructs the server; handler() parses {operation: ...} and dispatches. |
fastifyRoutes.ts |
Legacy custom functions. Discovers routes from each component's routes/ folder. |
| File | Purpose |
|---|---|
serverHelpers/Request.ts |
Wraps IncomingMessage with Harper-specific fields (user, response, headers). |
serverHelpers/Headers.ts |
Header mutation/merge utilities. |
serverHelpers/contentTypes.ts |
(de)serialization registry; serialize, serializeMessage, getDeserializer. |
serverHelpers/serverUtilities.ts |
OperationDefinition and shared helpers. |
serverHelpers/OperationFunctionObject.ts |
Wraps an operation handler with metadata. |
serverHelpers/JSONStream.ts |
Streaming JSON output for large responses. |
nodeName.ts |
Resolves this node's name (config → hostname). |
static.ts |
Static file serving for component-bundled assets. |
throttle.ts |
Per-IP / per-user request throttling. |
storageReclamation.ts |
Disk-pressure signals to downstream consumers; getStorageSpaceStats() is the shared quota-aware (falls back to statfs) source of available/free/size storage numbers — used by Table.getStorageStats() (#1976). NOT used for blob storage path weighting (resources/blob.ts): quota-status.json is a single instance-wide figure, so it can't distinguish between multiple STORAGE_BLOBPATHS disks — that still needs raw per-path statfs. |
serverRegistry.ts |
Trivial registry export. |
status/ |
Server status reporting (cluster status, per-port info). |
| File | Purpose |
|---|---|
threads/socketRouter.ts |
Routes accepted sockets to worker threads based on port. |
threads/manageThreads.js |
Thread pool lifecycle. |
threads/threadServer.js |
Worker entry point — receives sockets via IPC. |
threads/itc.js |
Inter-thread comms primitives. |
transactionLogCooling.ts |
Main-thread timer that cools transaction-log mmaps. |
Workers receive
workerData.noServerStart = true— never start the server inside a worker.
threadServer.listenOnDomainSocket()skips a listener only when its path exceeds the platform'ssockaddr_un.sun_pathbyte limit (some Node versions reject it; others silently truncate it). Every actuallisten()error rejects startup, and the temporary bind-error listener is removed once the socket is listening.
Single-instance background tasks pick their thread by what state they touch:
- Last worker (
getWorkerIndex() === getWorkerCount() - 1) — for tasks that operate on worker-resident JS state: audit cleanup (resources/auditStore.ts) and disk reclamation (storageReclamation.ts) walk per-store objects that only exist in a worker. - Main thread (
isMainThread) — for tasks that drive a process-global native singleton and need no JS state.transactionLogCooling.tsis the example: rocksdb-js's transaction-log registry is one C++ static shared across all worker threads, so any thread cools every log. The main thread is chosen because it is the only thread that lives for the whole process — a worker-driven timer would stall whenever that worker is recycled.
Every entry is a top-level function or named const. Jump via go-to-symbol or grep -n 'function <name>' server/http.ts.
| Symbol | What it does |
|---|---|
registerUdsCleanupPaths, recordUdsBindSuccess, cleanupUdsFiles, markUdsBindFailed, writeUdsMetadata, cleanupSocketsDirectory |
UDS socket / metadata file lifecycle. Ownership-aware: recordUdsBindSuccess captures the inode a worker's own bind confirmed; cleanupUdsFiles/markUdsBindFailed only unlink a path when the inode on disk still matches, so an overlapping restart's outgoing worker can never delete the replacement that already rebound the same path (see restartWorkers() in manageThreads.js). cleanupSocketsDirectory is the separate crash-path sweep, run once from socketRouter.ts's startHTTPThreads on main-thread startup, before any worker can bind. |
handleApplication(scope) |
Component entry point — captures httpOptions for the scope. |
getHttpOptions() |
Returns the current scope's HttpOptions. |
deliverSocket() |
IPC-delivered socket handoff from socketRouter. |
proxyRequest() |
Cross-port request routing. |
registerServer() |
Records a server for a port in the SERVERS map. |
getPorts() |
Resolves listener options → list of {port, secure}. |
httpServer() |
Main listener registration entry point. |
getHTTPServer(port, secure, options) |
The largest function in the file. Creates/retrieves the underlying Node HTTP/HTTPS server. Wires request, upgrade, error handlers, TLS context, and the per-port middleware chain. |
makeCallbackChain() |
Builds the per-port handler chain via middlewareChain.topoSort. |
unhandled() |
Terminal 404 handler. |
onRequest() |
Thin alias of httpServer({requestOnly: true}). |
onUpgrade() / upgradeListeners (const) |
Register HTTP upgrade listener; underlying list. |
onWebSocket() / websocketListeners (const) |
Register WebSocket listener; auto-adds default upgrade handler the first time it runs for a port. Underlying list of registrations. |
enableProxyProtocol() |
PROXY v1/v2 stripping on UDS mirrors (Node 24+-compatible workaround). Decoding lives in serverHelpers/proxyProtocol.ts; v2 TLVs forward the client source address plus the connection's TLS facts a fronting proxy (symphony) observed — ALPN, SNI authority, TLS version/cipher, JA3/JA4 fingerprints, and the mTLS client cert chain. These are surfaced on request.connectionInfo (see below); the verified cert chain is additionally exposed with TLSSocket semantics (authorized, getPeerCertificate()) so HTTP/MQTT mTLS auth works unchanged, and the SSL TLV lets request.protocol report https on the plaintext UDS mirror. A peer that stalls mid-header is destroyed after prehandoffTimeout (default 10s), matching withProxyProtocol's guard on the raw-socket path. |
defaultNotFound() |
Default 404 response. |
logRequest() |
Per-request access log line. |
getRequestId() |
Generates the per-request correlation ID. |
Components register listeners with optional before: 'name' / after: 'name' options. middlewareChain.topoSort resolves order; cycles fall back to registration order with a warning. Three lists hold the registrations:
httpResponders— request handlersupgradeListeners(inhttp.ts)websocketListeners(inhttp.ts)
The default WebSocket upgrade handler is registered automatically inside onWebSocket() the first time it runs for a given port.
An operator mounts an application by putting host/urlPath on its entry in the root config; components/scopeMount.ts models it and the loader threads it into every Scope for that application (both load paths — the root-config package recursion and the components-root directory scan).
The mount is applied at exactly one place: Scope.routeFor(), used by the scope.server proxy. Do not push it anywhere else. In particular, do not compose it into the plugin config the entry pipeline reads: entry.urlPath is what graphqlSchema and jsResource derive resource paths from, and the router strips the mount before REST resolves them. Composing it there registers a table at /v1/Thing while REST looks up Thing, and every mounted REST route 404s. A single-app static test will not catch it — static de-prefixes its own map keys, so it stays self-consistent either way.
Consequences worth knowing:
- Everything inside an application addresses itself mount-relative. Only two things need the absolute path: code that emits a URL back to the client (use
Scope.externalBasePath()— static's redirectLocation), and code that bypasses the routed chain (legacy fastify registers on the bare server, so its route prefix must be the full external path).static.ts's mount-root redirect gates on the external base path, not the plugin-local one — a root-level static plugin (baseURLPath === '/') still needs the redirect when the application itself carries a mount, since the client-visible mount root is thenexternalBaseURLPath, not/. - A plugin registering per-mount state must key it on
Scope.routeFor()'s resolved route, not on the parts it composes from — distinct(mount, pluginUrlPath)pairs can flatten to the same string (/a+bcand/ab+c).REST.ts'sstartedMountsdoes this; it replaced a process-globalstartedflag that silently 404'd the second mounted application's REST API.handleApplicationalso closes overresources/httpOptionsper call rather than a module-level var, and skips deploy pre-flight validation scopes (scope.isTransientValidation) entirely — registering handlers from a throwaway validation scope would splice a validation run into the live request path and permanently mark that mount started, silently skipping the real scope's later registration. - A mount is routing, not isolation: exported resources stay instance-wide, and a
hostmount cannot constrain legacy fastify routes —fastifyRoutes.tsrefuses to load (throws) rather than warn when ahostmount is configured, since the fallback really is reachable on every host. - An invalid mount (unparseable
host/urlPath) fails the application closed:componentLoader.tryRootConfigMountskips loading it entirely rather than falling back to unmounted access — loading unconstrained would silently drop the isolation the operator asked for, which is worse than not loading at all. - Two applications mounted at different routes can register same-named middleware (e.g. both enable
rest) without colliding:middlewareChain.resolveRoutedChainsresolvesbefore/aftername references against a registry scoped to that route's own group, falling back to a global registry that only holds genuinely unmounted entries (e.g.authentication) — never another mounted route's entries. hostmatching readsrequest.host(Harper'sRequest.hostgetter), not the rawHostheader — HTTP/2 clients send:authority, neverHost, so reading the header directly silently 404s every host-mounted app under h2 while h1 keeps working.hostnameFromHeaderalso strips a trailing dot (api.example.com., the absolute-FQDN form some resolvers emit) since it names the same origin.scopeMount.normalizeMountHostvalidates against the same grammar as thedeploy_componentoperation'shostfield (bare DNS hostname or IPv6 literal) and throws otherwise, so a hand-typed root-confighostwith a port/scheme/path fails the application closed too, instead of loading it unreachably.nestScopeMountlogs a warning when a child'shostis discarded by the parent-authority rule, so that isn't silent.
Operations request bodies are untrusted data. serverHandlers.js → handlePostRequest() rejects
prototype-mutating property names and strips the legacy bypass_auth property before dispatch.
serverUtilities.ts → chooseOperation() never reads authorization control from the body: trusted
internal callers pass bypass state as a separate argument and expose it to operation handlers only
through operationAuthorizationState.ts's async context. When an operation registered by a component
must run on a worker, registeredOperations.ts carries that state in the same-process ITC envelope,
separately from the structured-cloned body. Never attach trusted dispatch state to an operation
payload.
REST.ts → http(request, nextHandler) is the chief integration point: it takes a Request, asks the Resources registry for a match, builds a RequestTarget, and dispatches into the Resource class's static method. Cache headers are translated to request.expiresAt / onlyIfCached / noCache flags within the same function.
Three tiers, applied in two places:
- App/resource explicit — a
Cache-Controlset by the resource (or@table(cacheControl: "...")for anonymous reads, emitted inREST.ts → http()) always wins. The declaration is required: anonymous readability alone never emits shared-cache headers, because a request-attribute-gatedallowRead(IP, headers) would make inferredpublicunsound. - Identity floor —
security/auth.ts → applyResponseHeadersstampsCache-Control: private, no-cache+Vary: Authorization(+Cookiewhen sessions are on) on any response where a principal was resolved or credentials were rejected (401), unless the app opted into shared caching withpublic/s-maxage(the RFC 9111 opt-in). - CORS partitioning — when CORS is enabled, every response gets
Vary: Origin(the ACAO header is reflected per-origin, and its absence on no-Origin requests is origin-dependent too).
The @table(cacheControl:) value is persisted on the primary-key attribute (like expiration), so all threads and future boots see it; resources/databases.ts → table() treats null as "schema explicitly has none" (clears on reload) and undefined as "caller is not schema-defining" (no clobber from add_attribute/cluster schema events).
| Question | Where |
|---|---|
| Where do I register a new HTTP handler? | http.ts → httpServer() (or onRequest() for the request-only form) |
| Where do I register a WebSocket handler? | http.ts → onWebSocket() |
How does before/after middleware ordering work? |
middlewareChain.ts → topoSort |
| Where does PROXY protocol get parsed? | serverHelpers/proxyProtocol.ts (applied by http.ts → enableProxyProtocol / createH2CProxyFront) |
| How does an app read forwarded TLS facts (JA3/JA4, ALPN, SNI, mTLS cert)? | request.connectionInfo (ConnectionInfo from serverHelpers/proxyProtocol.ts); only set from a trusted PROXY v2 header on the UDS mirror, never from a request header |
| Where is the REST request → Resource dispatch? | REST.ts → http() |
| Where is the operations API request handled? | operationsServer.ts → handler |
| How are content types (de)serialized? | serverHelpers/contentTypes.ts |
| Where do durable subscriptions live? | DurableSubscriptionsSession.ts |
| How are sockets dispatched to worker threads? | threads/socketRouter.ts |
| Where is the Operations API wired into Fastify? | operationsServer.ts → buildServer |
- Don't add new code to
fastifyRoutes.ts— it's the legacy custom-functions path. - New protocol plugins implement the
Serverinterface (inServer.ts) and register viaonRequest/onUpgrade/onWebSocket. - Always pass
namewhen registering a listener withbefore/after— anonymous entries can't be ordered against. - Tests live in
../unitTests/server/.