Skip to content

WebSockets

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 for your own Node server. arc below is the built ArcApplication.

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.

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.

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.

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.

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.

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:

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.