Skip to main content
Version: v5

HTTP API

The server global object is available in all Harper component code. It provides access to the HTTP server middleware chain, WebSocket server, authentication, resource registry, and cluster information.

server.http(listener, options)​

Add a handler to the HTTP request middleware chain.

server.http(listener: RequestListener, options?: HttpOptions): HttpServer[]

Returns an array of HttpServer instances based on the options.port and options.securePort values.

Example:

server.http(
(request, next) => {
if (request.url === '/graphql') return handleGraphQLRequest(request);
return next(request);
},
{ runFirst: true }
);

RequestListener​

type RequestListener = (request: Request, next: RequestListener) => Promise<Response>;

To continue the middleware chain, call next(request). To short-circuit, return a Response (or Response-like object) directly.

HttpOptions​

Added in: v5.1.0 (name, before, after, urlPath, and host)

PropertyTypeDefaultDescription
namestringRegistering component's nameName this middleware entry so other entries can position themselves relative to it
beforestring-Run this entry before the named middleware entry
afterstring-Run this entry after the named middleware entry
urlPathstring-Only handle requests whose pathname matches this prefix on a segment boundary (/api matches /api and /api/x, not /apinews). Harper removes the prefix before passing the request to the handler.
hoststring-Only handle requests whose Host header matches this virtual hostname (case-insensitive, port ignored). IPv6 hosts are given as a bare literal (::1), not bracketed.
runFirstbooleanfalseDeprecated. Insert this handler at the front of the chain. Use before or after for explicit ordering.
portnumberhttp.portTarget the HTTP server on this port
securePortnumberhttp.securePortTarget the HTTPS server on this port

host and urlPath create a routed middleware chain. When both are present, both must match. Harper selects the most specific matching chain in this order: host and path, host only, then path only. Longer path prefixes take precedence. A request that matches no routed chain uses the default chain.

server.http(handleAdminRequest, {
name: 'admin',
host: 'admin.example.com',
urlPath: '/api',
after: 'authentication',
});

For this example, a request to https://admin.example.com/api/users reaches handleAdminRequest with a pathname of /users.

HttpServer​

A Node.js http.Server or https.SecureServer instance.


Request​

A Request object is passed to HTTP middleware handlers and direct static REST handlers. It follows the WHATWG Request API with additional Harper-specific properties.

Properties​

PropertyTypeDescription
urlstringThe request target (path + query string), e.g. /path?query=string
methodstringHTTP method: GET, POST, PUT, DELETE, etc.
headersHeadersRequest headers
pathnamestringPath portion of the URL, without query string
protocolstringhttp or https. On the Node runtime, behind a TLS-terminating proxy that forwards the PROXY v2 SSL TLV, this reflects the client's original scheme even though the proxy-to-Harper hop is plaintext; a proxy forwarding only address info (no SSL TLV) still reads as http. Bun and uWebSockets derive this from X-Forwarded-Proto/the listener's secure flag instead, independent of the SSL TLV. Changed in: v5.2.0
dataanyDeserialized body, based on Content-Type header
ipstringRemote IP address of the client (or last proxy)
hoststringHost from the request headers
sessionobjectCurrent cookie-based session (a Table record instance). Update with request.session.update({ key: value }). A cookie is set automatically the first time a session is updated or a login occurs.
connectionInfoobject | undefinedTLS facts a fronting proxy forwarded over PROXY protocol v2 — negotiated ALPN, SNI, TLS version/cipher, the client's JA3/JA4 fingerprint, and the client's mTLS certificate chain (presented, not necessarily verified — see below). undefined for a direct connection or one without a v2 header. See Connection info. Added in: v5.2.0
authorizedboolean | undefinedWhether the client's certificate passed TLS verification — Node's socket.authorized. On a directly-terminated connection this is verification against Harper's configured certificate authorities (tls.certificateAuthority, or a certificate record marked as an authority — see Certificate Verification); behind Harper Fabric's proxy-protocol UDS mirror it instead reflects Symphony's own, separately configured client CA, forwarded as a single trust bit (see Trust and mTLS). false for an unverified or absent client certificate on either path, including a mirrored connection where the proxy verified the client but omitted its oversized chain. undefined on a plain (non-TLS, non-mirrored) connection, and always undefined on Bun and uWebSockets. Changed in: v5.2.0
peerCertificateobject | nullThe client's TLS peer certificate — Node's socket.getPeerCertificate(true), cached after the first read. On a direct connection this is Node's full certificate object. issuerCertificate is present only when Node can resolve the issuer, so it's absent on a resumed TLS session, on Node.js 26.8.0/26.8.1, and when the issuer is in neither the client's sent chain nor Harper's trusted CAs (for example, an unverified client when mTLS is optional) — check it before reading its fields; behind the Fabric UDS mirror it's a smaller object parsed from the forwarded chain (subject, issuer, subjectaltname, validity, serial number, fingerprints, raw, issuerCertificate — no pubkey, bits, modulus, ca, or ext_key_usage, though raw can be re-parsed for those). An empty object ({}) if no certificate was presented, if the proxy verified the certificate but omitted the chain, or if the forwarded leaf fails to parse (rare, but possible even when authorized is true); null on a connection with no TLS/mTLS support at this layer, and always null on Bun and uWebSockets. Changed in: v5.2.0

Connection info​

Added in: v5.2.0

When Harper runs behind a TLS-terminating proxy (for example, Harper Fabric's Symphony edge), the TLS handshake happens at the proxy, so facts about the client's TLS connection — its fingerprint, negotiated protocol, and any client certificate — are otherwise invisible to your application. Harper Fabric's proxy-protocol Unix domain socket mirrors decode this from a forwarded PROXY protocol v2 header and expose it on request.connectionInfo; a PROXY v2 header sent to an ordinary http.port/securePort listener isn't decoded (and isn't valid HTTP, so the connection fails).

interface ConnectionInfo {
alpn?: string; // negotiated ALPN protocol, e.g. "h2" — raw, unvalidated
authority?: string; // SNI hostname from the ClientHello — raw, unvalidated
tls?: {
version?: string; // e.g. "TLSv1.3"
cipher?: string; // negotiated cipher suite
verified?: boolean; // a client certificate was presented and the proxy verified it
};
ja3?: string; // JA3 fingerprint (32-char MD5 hex) — raw, unvalidated
ja4?: string; // JA4 fingerprint — raw, unvalidated
clientCertChain?: Buffer[]; // client certificate chain (DER, leaf first) — presented, not necessarily verified; check tls.verified
}

request.connectionInfo is undefined unless a PROXY v2 header carrying TLS facts was decoded; each field is present only when the proxy actually forwarded it. On the Bun and uWebSockets runtimes it is always undefined. alpn, authority, ja3, and ja4 are string-decoded from the TLV payload with no grammar or length validation beyond the header's own bounds, so validate them before placing them in response headers, logs, cache keys, or other security-sensitive contexts. Reading the client's JA4 fingerprint — e.g. to make a bot or abuse decision — is simply:

class Origin {
get(request) {
const ja4 = request.connectionInfo?.ja4;
if (ja4 && abuseList.has(ja4)) return new Response('blocked', { status: 403 });
return fetch(request);
}
}

Trust and mTLS. connectionInfo is populated only from the trusted proxy-protocol channel (Harper Fabric's per-worker Unix domain socket, writable only by the local proxy) — never from a request header, so the channel itself cannot be forged by a client (its contents are client-controlled, per above). clientCertChain and tls.verified are independent signals, not one another's proof: a chain is attached whenever the client presented a certificate, whether or not the proxy verified it, and tls.verified can be true while clientCertChain is absent (a proxy may verify a certificate but omit an oversized chain). Treating chain presence alone as proof of a verified client accepts unverified certificates. For gating on a verified mTLS client, use request.authorized rather than connectionInfo.tls.verified or clientCertChain presence alone — it's false (not just absent) for an unverified or absent client certificate on either path, including a mirrored chain the proxy verified but omitted. authorized reflects chain trust only. Revocation is checked separately, by Harper's mTLS authentication and only when http.mtls is configured (see Certificate Verification), so in your own middleware a revoked certificate can still read authorized: true. The proxy path verifies against Symphony's own, separately configured client CA, not Harper's own configured certificate authorities. A directly-terminated authorized: true and a mirrored authorized: true therefore aren't guaranteed to share a trust root, even though both gate the same way. To read the certificate itself, request.peerCertificate is populated the same way, but the mirror's version carries fewer fields than a direct connection's (see the table). Like connectionInfo, both are Node-runtime only: request.authorized and request.peerCertificate are always undefined/null on Bun and uWebSockets.

Methods​

request.login(username, password)​

login(username: string, password: string): Promise<void>

Authenticates the user by username and password. On success, creates a session and sets a cookie on the response. Rejects if authentication fails.

sendEarlyHints(link: string, headers?: object): void

Sends an Early Hints (HTTP 103) response before the final response. Useful in cache resolution functions to hint at preloadable resources:

class Origin {
async get(request) {
this.getContext().requestContext.sendEarlyHints('<link rel="preload" href="/my-resource" as="fetch">');
return fetch(request);
}
}
Cache.sourcedFrom(Origin);

Low-Level Node.js Access​

caution

These properties expose the raw Node.js request/response objects and should be used with caution. Using them can break other middleware handlers that depend on the layered Request/Response pattern.

PropertyDescription
_nodeRequestUnderlying http.IncomingMessage
_nodeResponseUnderlying http.ServerResponse

Response​

REST method handlers can return:

  • Data directly — Serialized using Harper's content negotiation
  • A Response object — The WHATWG Response
  • A Response-like object — A plain object with the following properties:
PropertyTypeDescription
statusnumberHTTP status code (e.g. 200, 404)
headersHeadersResponse headers
dataanyResponse data, serialized via content negotiation
bodyBuffer | string | ReadableStream | BlobRaw response body (alternative to data)

server.ws(listener, options)​

Add a handler to the WebSocket connection middleware chain.

server.ws(listener: WsListener, options?: WsOptions): HttpServer[]

Example:

server.ws((ws, request, chainCompletion) => {
chainCompletion.then(() => {
ws.on('message', (data) => console.log('received:', data));
ws.send('hello');
});
});

WsListener​

type WsListener = (ws: WebSocket, request: Request, chainCompletion: Promise<void>, next: WsListener) => Promise<void>;
ParameterDescription
wsWebSocket instance
requestHarper Request object from the upgrade event
chainCompletionPromise that resolves when the HTTP request chain finishes. Await before sending to ensure the HTTP request is handled.
nextContinue chain: next(ws, request, chainCompletion)

WsOptions​

WsOptions supports the same options as HttpOptions, plus:

PropertyTypeDefaultDescription
maxPayloadnumber100 MBMaximum WebSocket message size accepted by ws

server.upgrade(listener, options)​

Add a handler to the HTTP server upgrade event. Use this to delegate upgrade events to an external WebSocket server.

server.upgrade(listener: UpgradeListener, options?: UpgradeOptions): void

Example (from the Harper Next.js component):

server.upgrade(
(request, socket, head, next) => {
if (request.url === '/_next/webpack-hmr') {
return upgradeHandler(request, socket, head).then(() => {
request.__harperRequestUpgraded = true;
next(request, socket, head);
});
}
return next(request, socket, head);
},
{ runFirst: true }
);

When server.ws() is registered, Harper adds a default upgrade handler. The default handler sets request.__harperRequestUpgraded = true after upgrading, and checks for this flag before upgrading again (so external upgrade handlers can detect whether Harper has already handled the upgrade).

UpgradeListener​

type UpgradeListener = (request: IncomingMessage, socket: Socket, head: Buffer, next: UpgradeListener) => void;

UpgradeOptions​

UpgradeOptions supports the same options as HttpOptions.


server.socket(listener, options)​

Create a raw TCP or TLS socket server.

server.socket(listener: ConnectionListener, options: SocketOptions): SocketServer

Only one socket server is created per call. A securePort takes precedence over port.

ConnectionListener​

Node.js connection listener as in net.createServer or tls.createServer.

SocketOptions​

PropertyTypeDescription
portnumberPort for a net.Server
securePortnumberPort for a tls.Server

SocketServer​

A Node.js net.Server or tls.Server instance.


server.authenticateUser(username, password)​

Added in: v4.5.0
server.authenticateUser(username: string, password: string): Promise<User>

Returns the user object for the given username after verifying the password. Throws if the password is incorrect.

Use this when you need to explicitly verify a user's credentials (e.g., in a custom login endpoint). For lookup without password verification, use server.getUser().


server.getUser(username)​

server.getUser(username: string): Promise<User>

Returns the user object for the given username without verifying credentials. Use for authorization checks when the user is already authenticated.


server.registerBearerValidator(validator, options?)​

Added in: v5.4.0
server.registerBearerValidator(
validator: (token: string, request: Request, signal: AbortSignal) => BearerValidatorResult | Promise<BearerValidatorResult>,
options?: { timeoutMs?: number }
): () => void

Lets a component accept Authorization: Bearer tokens that Harper did not issue, such as access tokens from the component's own OAuth authorization server. A token a validator accepts sets request.user before routing, so every Harper-owned route on the application port — REST, GraphQL, WebSocket and MQTT-over-WebSocket, and MCP — authorizes it like any other Harper user.

Harper checks its own operation and refresh tokens first. A Bearer token Harper rejects as invalid is offered to each registered validator in registration order, until one claims it. Validators do not run on a dedicated operations API port. Applications load in no fixed order, so a validator should claim only tokens it can identify, for example by issuer.

A validator returns one of:

ReturnResult
null or undefinedNot this validator's token. The next validator runs; if none claims it, the token is treated as unrecognized (see Authentication and fallthrough).
{ username, authExpiresAt?, authCacheable? }Authenticate as the Harper user username, resolved through server.getUser(), with that user's own role.

The object may contain only these fields; any other field, including role, fails the request. Permissions always come from the user's role. A user that server.getUser() cannot find, or one without a role, is rejected like an unknown user.

  • authExpiresAt - epoch seconds when the authentication expires: the cached result is dropped, live subscriptions opened with the token end, and its MQTT last-will message is no longer published. It must be at most a year ahead, or the request fails; when omitted, it is 5 minutes after authentication. If the token is a JWT, Harper also caps it at the token's exp claim and refuses the token once that has passed, even when the validator accepted it. Harper does not verify the JWT, so the validator must still check expiry, and should return the verified expiry here.
  • authCacheable - false to run the validator on every request. Otherwise Harper caches the result by the Authorization header and reuses it on every route until authExpiresAt or the next authentication.cacheTTL flush (every 30 seconds by default), whichever comes first. A validator whose answer depends on the request (for example a token bound to one resource's audience) must return false.

Because results are cached, a revoked token can still be accepted on new requests for up to one authentication.cacheTTL period, plus the time an authentication already in progress takes to finish. Return authCacheable: false if a revoked token must be refused on the next request. Harper does not run the validator again for live subscriptions opened with the token: they run until authExpiresAt, so return an earlier authExpiresAt if they must end sooner.

To refuse a token the validator recognizes (wrong audience, revoked, expired), throw credentialRejectionError(message, 401) or a markCredentialRejection(error)-tagged error, both exported from harper. That is reported like a rejected Harper token. Any other thrown error, a result that breaks the rules above, or exceeding timeoutMs fails the request with a generic 401 and logs the error server-side. Any throw ends the chain, so later validators do not run: catch errors from verifying a token another issuer may own, and return null instead.

options.timeoutMs limits each call to the validator: a positive number of milliseconds up to 60000, default 5000. Registering with any other value throws a TypeError. The validators and the server.getUser() lookup of a returned username also share one deadline per request, equal to the largest timeoutMs among the registered validators: if one validator takes 3 seconds to decline, the next has what remains. When a limit runs out, the request fails with a generic 401, and a validator still running has its signal aborted; pass signal to fetch or other I/O the validator starts. A limit cannot interrupt synchronous work.

A request authenticated this way cannot call create_authentication_tokens to mint a scoped token, or ordinary tokens without a username and password; see Create Authentication Tokens.

The returned function unregisters the validator. Registrations last for the life of the worker thread. A plugin that registers through the scope.server passed to handleApplication has them removed if that load fails.

import { server, credentialRejectionError } from 'harper';

server.registerBearerValidator(async (token, request, signal) => {
const claims = await verifyMyAccessToken(token, { signal }); // returns null for tokens this issuer did not sign
if (!claims) return null;
if (claims.aud !== 'https://api.example.com') throw credentialRejectionError('wrong audience', 401);
return { username: claims.harper_user, authExpiresAt: claims.exp };
});

server.resources​

The central registry of all resources exported for REST, MQTT, and other protocols.

server.resources.set(name, resource, exportTypes?)​

Register a resource:

class NewResource extends Resource {}
server.resources.set('NewResource', NewResource);

// Limit to specific protocols:
server.resources.set('NewResource', NewResource, { rest: true, mqtt: false });

server.resources.getMatch(path, exportType?)​

Find a resource matching a path:

server.resources.getMatch('/NewResource/some-id');
server.resources.getMatch('/NewResource/some-id', 'rest');

server.operation(operation, context?, authorize?)​

Execute an Operations API operation programmatically.

server.operation(operation: object, context?: { username: string }, authorize?: boolean): Promise<any>
ParameterTypeDescription
operationobjectOperations API request body
context{ username: string }Optional: execute as this user
authorizebooleanWhether to apply authorization. Defaults to false.

server.recordAnalytics(value, metric, path?, method?, type?)​

Record a metric into Harper's analytics system.

server.recordAnalytics(value: number, metric: string, path?: string, method?: string, type?: string): void
ParameterDescription
valueNumeric value (e.g. duration in ms, bytes)
metricMetric name
pathOptional URL path for grouping (omit per-record IDs — use the resource name)
methodOptional HTTP method for grouping
typeOptional type for grouping

Metrics are aggregated and available via the analytics API.


server.config​

The parsed harper-config.yaml configuration object. Read-only access to Harper's current runtime configuration.


server.nodes​

Returns an array of node objects registered in the cluster.

server.shards​

Returns a map of shard number to an array of associated nodes.

server.hostname​

Returns the hostname of the current node.

server.contentTypes​

Returns the Map of registered content type handlers. Same as the global contentTypes object.


contentTypes​

A Map of content type handlers for HTTP request/response serialization. Harper uses content negotiation: the Content-Type header selects the deserializer for incoming requests, and the Accept header selects the serializer for responses.

Built-in Content Types​

MIME typeDescription
application/jsonJSON
application/cborCBOR
application/msgpackMessagePack
text/csvCSV
text/event-streamServer-Sent Events

Custom Content Type Handlers​

Register or replace a handler by setting it on the contentTypes map:

import { contentTypes } from 'harper';

contentTypes.set('text/xml', {
serialize(data) {
return '<root>' + serialize(data) + '</root>';
},
q: 0.8, // quality: lower = less preferred during content negotiation
});

Handler Interface​

PropertyTypeDescription
serialize(data)(any) => Buffer | Uint8Array | string | ReadableSerialize a complete response body. Used when the response body is not iterable, or when the handler defines no serializeStream. Most handlers return a string or a Buffer; the built-in text/csv handler returns a Readable, which Harper streams to the response.
serializeStream(data)(any) => Readable | Iterable | AsyncIterable | Buffer | stringSerialize a streaming response body. Called once per response with the whole iterable (not once per chunk), and only when the response body is an object that is iterable or async iterable. A Node.js Readable is the usual return, but it is not the only one — see Return values from serializeStream.
deserialize(buffer)(Buffer) => anyDeserialize an incoming request body. Harper always passes a Buffer, including for text/* types — a text handler calls buffer.toString() itself.
qnumber (0–1)Quality indicator for content negotiation. Defaults to 1.

Return values from serializeStream​

serializeStream may return a Readable, any iterable or async iterable (a generator, for example), a Buffer, or a string. Harper writes all of these to the response: a Readable is piped, an iterable is wrapped with Readable.from(), and a Buffer or string is sent as the whole body.

A Readable is the usual return, but the non-stream forms are not theoretical. Harper picks serializeStream over serialize whenever the response body is iterable, and a plain array is iterable — so a resource that returns an array reaches serializeStream even when there is nothing to stream incrementally. A handler can serialize the value in one shot in that case: the built-in application/x-msgpack handler streams only non-array iterables, and returns a packed Buffer for a plain array.

Harper's own ContentTypeHandler TypeScript interface is currently narrower than this runtime contract, so a typed handler returning a Readable or an iterable may need a cast.

Changed in: v5.3.0 Every return form above is also compressed when response compression is enabled (http.compressionThreshold set to a non-zero value) and the client sends Accept-Encoding: br: a returned Buffer or string is compressed as a whole body when its length exceeds the threshold (a string's length counts UTF-16 code units, not UTF-8 bytes, so a non-ASCII string can be over the threshold in bytes and still be sent uncompressed), and a returned Readable or iterable is compressed as a stream. Before v5.3.0, a Buffer, string, or generator return failed with TypeError: stream.pipe is not a function under compression, so a handler targeting older releases should return a Readable — Readable.from([value]) wraps an already-serialized value.