---
title: WebSockets
editUrl: https://github.com/Cratis/Arc.TypeScript/edit/main/Documentation/hosts/websockets.md
description: Mount WebSocket upgrades for observable queries in Express, Fastify, and Hono, authenticate them, and configure Origin checks and proxy trust.
---


Observable queries stream over server-sent events on the ordinary HTTP route. Fastify's plugin and Hono's Node serving helper also attach WebSocket upgrades in their setup call. Express needs a listener attach step because Express middleware does not see Node upgrades. The standalone Node host handles upgrades itself; see [Arc.Core](/arc/backend/typescript/core/#bring-your-own-node-server) for your own Node server. `arc` below is the built `ArcApplication`.

## Express

```typescript title="server.ts"
import express from 'express';
import { cratisArc } from '@cratis/arc.express';
import { arc } from './arc.js';

const app = express();
const middleware = cratisArc(arc);
app.use(middleware);
const listener = app.listen(3000, '127.0.0.1');
const closeSockets = middleware.injectWebSocket(listener);
```

Call `middleware.injectWebSocket(listener, native?)` on the listener returned by `app.listen()`, and `await closeSockets()` at shutdown. Express HTTP middleware does **not** run on Node `upgrade` requests: session, authentication, CORS, and rate-limiting middleware cannot authorize the socket. The optional `native` callback receives a raw `IncomingMessage`, not an Express request, so `trust proxy` and `req.protocol` do not apply. Authenticate upgrades with Arc authentication handlers or a trusted session lookup in that callback.

## Fastify

Call `await app.register(cratisArc, { arc })` before listening (`webSockets` defaults to `true`; `cratisArc` comes from `@cratis/arc.fastify`). A shared `@fastify/websocket` can be registered before or after Arc; real upgrade checks cover both orders. A `prefix` in the registration scopes HTTP and WebSocket paths without changing Arc's generated routes.

Fastify's `onRequest`, `preValidation`, and `preHandler` hooks run before the upgrade. `app.close()` closes the listener but does **not** dispose the Arc application or its services. Use `shutdownArcHost(arc.server, () => app.close())` from `@cratis/arc.core/hosting` to coordinate participant shutdown before socket closure; see [Fastify](/arc/backend/typescript/hosts/fastify/).

## Hono

```typescript title="server.ts"
import { Hono } from 'hono';
import { cratisArc, serveCratisArc } from '@cratis/arc.hono';
import { arc } from './arc.js';

const app = new Hono();
app.use(cratisArc(arc));
const hosted = await serveCratisArc(app, arc, { port: 3000, hostname: '127.0.0.1' });
```

Call `await hosted.dispose()` at shutdown, before disposing `arc`. If your application already has a `createNodeWebSocket({ app })` helper, call `createHonoWebSockets(app, arc.server, undefined, helper)` on the same Hono app after registering `cratisArc` middleware; call **only** `helper.injectWebSocket(listener)`, then dispose the Arc bridge before closing the listener because Arc does not own that shared listener. Ordinary GET requests pass through the WebSocket route to your handlers. Hono middleware runs for upgrades, and the Node TLS socket supplies `secure` unless trusted native context overrides it. `@hono/node-server` is needed only for this Node host; other Hono runtimes need their own verified bridge.

## Frame limits

Arc rejects oversized inbound WebSocket frames with close code 1009 on every adapter, even with shared WebSocket infrastructure. Configure a shared plugin or helper's `maxPayload` at or below `query.maxObservableInboundFrameBytes` (64 KiB by default) so it rejects them before delivery. The other observable limits are in [Configuration](/arc/backend/typescript/configuration/#observable-query-limits).

## Origin checks

Each bridge checks exact raw paths and the configured `query.allowedOrigins` before accepting an upgrade.

- By default, a browser `Origin` that is present must match the trusted transport's scheme and authority.
- `query: { allowedOrigins: ['http://localhost:5173'] }` replaces that default with an explicit list. Include your application's own origin when it must stay allowed.
- An async predicate `(origin, request, native) => boolean` can implement a host policy.
- An absent `Origin` is permitted for native clients. It is **not** proof of authentication.

## Behind a TLS-terminating proxy

Arc never trusts `X-Forwarded-*` by itself. A trusted `native` callback can set `secure` and `authority` when a verified proxy terminates TLS. Validate the proxy connection against a fixed list before reading forwarded values:

```typescript
middleware.injectWebSocket(listener, request => {
    if (request.socket.remoteAddress !== '127.0.0.1') throw new Error('Untrusted proxy');
    const protocol = request.headers['x-forwarded-proto'];
    const host = request.headers['x-forwarded-host'];
    if (protocol !== 'https' || host !== 'app.example.com') throw new Error('Invalid forwarded authority');
    return { secure: true, authority: host };
});
```

Adapt the trusted proxy address and host to your deployment; never accept these headers from direct clients. This example does not supply `remoteAddress`, so anonymous per-caller connection and subscription caps group callers by the proxy's address. For individual anonymous caps, validate `X-Forwarded-For` against your trusted proxy chain and supply the verified client address as `remoteAddress`.

## Related

- [Observable queries](/arc/backend/typescript/queries/observable-queries/)
- [Subscribe to an observable query](/arc/backend/typescript/queries/subscribing-to-observable-queries/)
- [Multiplexed observable queries](/arc/backend/typescript/queries/observable-query-demultiplexer/)
- [Native principal](/arc/backend/typescript/hosts/native-principal/)
