Skip to content

Inline handlers

Attach open, message, close, and error behaviors to host.ws.route with typed scope.input.

AI generated, pending review Updated 10 days ago · Flare 0.3

Inline WebSocket handlers register with host.ws.route(path, opts?), then chain lifecycle behaviors on the returned handle. Each handler receives the live connection as ws and a scope object with scope.input and any services from the named inject map.

import { FlareHost } from "@flare-ts/core";
import { node } from "@flare-ts/core/node";
const host = new FlareHost(node);
host.ws.route("/chat/:room")
.open((_ws, scope) => {
const room = scope.input.params.room;
const tag = scope.input.query.get("tag");
host.logger.info("client joined", { room, tag });
})
.message((ws, scope) => {
ws.send(scope.input.message.raw);
});
const app = host.build();
app.run();

At connect time, scope.input carries typed params and query when you declare them in the route options. On each message call, scope.input also includes the validated message when incoming is declared (the WebSocket analog of HTTP scope.input.body). An inbound message that fails incoming validation never throws into your handler: it is logged and the connection closes with code 1008 (see Socket contracts).

Use a named inject map, the same pattern as HTTP inline routes:

import { FlareHost, FlareService } from "@flare-ts/core";
import { node } from "@flare-ts/core/node";
class RoomService extends FlareService {
public static override deps = [];
welcome(room: string) {
return `welcome to ${room}`;
}
}
const host = new FlareHost(node);
host.scoped(RoomService);
host.ws.route("/chat/:room", { inject: { rooms: RoomService } })
.open((ws, scope) => {
ws.send(scope.rooms.welcome(scope.input.params.room));
});
const app = host.build();
app.run();

Reserved scope keys are input and config. Do not use those names in your inject map.

Spell descriptor fields in the route options, or pass a socketContract entry as contract. Never pass both.

import { FlareHost, socketContract } from "@flare-ts/core";
import { schema, str } from "@flare-ts/lib/schema";
import { node } from "@flare-ts/core/node";
const Msg = schema({ text: str });
const Chat = socketContract({
chat: { incoming: Msg, outgoing: Msg, params: { room: str } },
});
const host = new FlareHost(node);
host.ws.route("/chat/:room", { contract: Chat.chat })
.message((ws, scope) => {
ws.send({ text: `echo: ${scope.input.message.text}` });
});
const app = host.build();
app.run();

See Contracts for the full descriptor vocabulary.

.upgrade() attaches one hook that runs before the handshake completes, the only WebSocket moment with request context. Return nothing to proceed, a FlareResponse to deny with a real HTTP status, or a WebSocketRefusal to accept then immediately close with a code and reason the browser can read. The overview covers the full contract.

The bare form shares the route’s inject map. The options form declares the hook’s own inject plus the state tokens it provides through scope.state, which seed the accepted connection’s ws.state:

import { FlareHost, FlareResponse, flareState } from "@flare-ts/core";
import { str } from "@flare-ts/lib/schema";
import { node } from "@flare-ts/core/node";
const User = flareState<{ id: string }>("User");
const host = new FlareHost(node);
host.ws.route("/chat/:room", { query: { ticket: str } })
.upgrade({ provides: [User] }, (upgrade, scope) => {
const ticket = scope.input.query.ticket;
if (ticket !== "good") return new FlareResponse(401, { error: "invalid ticket" });
scope.state.set(User, { id: `user:${ticket}` });
})
.open((ws) => {
ws.send(`hello ${ws.state.get(User)?.id}`);
});
const app = host.build();
app.run();

The accept-then-close form covers refusals a browser must be able to act on, such as redirect-on-miss (an application close code plus the target URL as the reason):

import { WebSocketRefusal } from "@flare-ts/core";
host.ws.route("/moved")
.upgrade(() => new WebSocketRefusal(4302, "/relocated"));

On the upgrade hook’s scope, state joins input and config as a reserved key. The hook attaches once per route, like every other behavior.

host.ws.route("/chat")
.close((_ws, _scope, code, reason, wasClean) => {
host.logger.info("connection closed", { code, reason, wasClean });
})
.error((_ws, _scope, err) => {
host.logger.error("websocket error", { err });
});

Each behavior attaches once per route. A second .message() call throws at registration time.