WebSockets
A WebSocket route is a file whose name ends in .ws.ts. It runs on the server, keeps a connection open with each client, and can push messages to them at any time. The same file works under px dev and in the production build.
File names and URLs
A .ws.ts file is served at its path, with the .ws kept in the URL so it never collides with a page:
| File | URL |
|---|---|
src/routes/chat.ws.ts |
/chat.ws |
src/routes/rooms/[id].ws.ts |
/rooms/:id.ws, with id in ctx.params |
src/api/feed.ws.ts |
/api/feed.ws |
Brackets work as they do for pages: [id] becomes a parameter. A request that asks to open a socket on any other path gets a 404.
A chat room
This room relays every message to everyone in it, the sender included. The name comes from the URL, /chat/lobby.ws?name=ann:
import type { SocketContext } from "@palinx/core";
type Member = { name: string };
// Runs before the connection opens. Refuse it with a Response, or return the
// data every handler then gets as ctx.data.
export function accept(_req: Request, ctx: SocketContext) {
const name = ctx.query.get("name")?.trim();
if (!name) return new Response("A name is required", { status: 400 });
return { name } satisfies Member;
}
export function open(ws: any, ctx: SocketContext<Member>) {
ws.subscribe(ctx.params.room);
say(ctx, "server", `${ctx.data.name} joined`);
}
export function message(_ws: any, text: string | Buffer, ctx: SocketContext<Member>) {
say(ctx, ctx.data.name, String(text));
}
export function close(_ws: any, _code: number, _reason: string, ctx: SocketContext<Member>) {
say(ctx, "server", `${ctx.data.name} left`);
}
// Everyone in the room, the sender included.
function say(ctx: SocketContext<Member>, from: string, text: string) {
ctx.server.publish(ctx.params.room, JSON.stringify({ from, text }));
}A route exports any of these handlers, as named exports or as methods of a default export:
| Handler | Runs |
|---|---|
accept(req, ctx) |
before the connection opens |
open(ws, ctx) |
once the connection is open |
message(ws, message, ctx) |
for each message a client sends (a string, or a Buffer for binary data) |
close(ws, code, reason, ctx) |
when the connection closes |
ws is Bun's server WebSocket: ws.send() replies to this client, ws.subscribe(topic) joins a topic, and ws.close(code, reason) ends the connection. ctx carries:
params: the route parameters (ctx.params.room).query: the URL's query string, asURLSearchParams.url: the full request URL.data: whateveraccept()returned.server: the Bun server.ctx.server.publish(topic, message)sends to every socket subscribed to the topic, the sender included;ws.publish()would skip the sender.
The module is loaded before the connection opens, so a message a client sends the moment it connects is never lost.
Deciding who connects
accept(req, ctx) runs before the connection opens. Return a Response to refuse the connection with that status, such as a 401, or return any value to accept it; that value becomes ctx.data for every handler. This is where authentication goes. Browsers send the site's cookies with a WebSocket connection, so a session cookie works here as it does for a page:
import type { SocketContext } from "@palinx/core";
export async function accept(req: Request, ctx: SocketContext) {
// `sessions` stands for your own session lookup (a service, a database query).
const user = await sessions.userFromCookie(req.headers.get("cookie"));
if (!user) return new Response("Sign in first", { status: 401 });
return { userId: user.id };
}If accept() throws, the connection is refused with a 500 and the error is logged.
Guards
A _guard.ts applies to the WebSocket routes in its folder and below, exactly as it does to pages and API endpoints: src/routes/admin/_guard.ts decides who can open src/routes/admin/live.ws.ts, and src/api/secure/_guard.ts covers src/api/secure/feed.ws.ts. It runs before accept(). A guard that denies answers the connection request with a 403, and one that redirects answers with a 302, which a WebSocket client treats as a failed connection. See Guards.
The client
useWebSocket from @palinx/core opens a connection and exposes it as signals. Create it in onMount(), which runs only in the browser; a path like /chat/lobby.ws connects to the page's own host, over wss: when the page is on HTTPS:
import { Route, html, signal, effect, useWebSocket } from "@palinx/core";
type Line = { from: string; text: string };
export default class Chat extends Route {
private lines = signal<Line[]>([]);
private socket?: ReturnType<typeof useWebSocket<Line>>;
onMount() {
const name = `guest-${Math.random().toString(36).slice(2, 6)}`;
this.socket = useWebSocket<Line>(`/chat/lobby.ws?name=${name}`, { json: true });
// data() holds the latest message; keep them all.
const keep = effect(() => {
const line = this.socket!.data();
if (line) this.lines.set([...this.lines.peek(), line]);
});
return () => {
keep.destroy();
this.socket?.close();
};
}
private send(form: HTMLFormElement) {
const text = String(new FormData(form).get("text") ?? "").trim();
if (text) this.socket?.send(text);
form.reset();
}
render() {
return html`
<h1>Lobby</h1>
<ul id="lines">
${this.lines().map((l) => html`<li><b>${l.from}</b> ${l.text}</li>`)}
</ul>
<form @submit.prevent=${(e: Event) => this.send(e.target as HTMLFormElement)}>
<input name="text" autocomplete="off" />
<button>Send</button>
</form>
`;
}
}useWebSocket(url, options) returns:
data(): the latest message received.status():"connecting","open","closed"or"error".error(): the last error event, if any.send(data)andsendJSON(value);close(code?, reason?);open()to connect again.
Options:
json: trueparses each message as JSON.reconnect(defaulttrue) reconnects after the connection drops, waitingreconnectDelay(default 1000 ms) times the attempt number, up to ten times that delay, for at mostmaxRetriesattempts (default unlimited).protocolspasses WebSocket subprotocols.
Which pages can connect
A browser lets any website open a WebSocket to any server, and it sends that server's cookies along. To stop another site from using a visitor's session this way, a WebSocket route only accepts:
- pages from the site itself: the request's
Originmust match itsHost, name and port (the scheme is ignored, and default ports count, sohttps://example.commatchesexample.com:443); - origins you list by name in
security.cors.origins, such as"https://app.example.com"; - clients that send no
Originat all, which browsers always do, so these are server-side clients.
Anything else gets a 403, and so does an Origin of null (sandboxed frames and file: pages). A "*" in security.cors.origins never applies to WebSockets: it would let every site in. px dev and px build print a warning when the list has "*" and the app has WebSocket routes. See Configuration for security.cors.
Behind a reverse proxy
The origin check compares Origin with the Host header the server receives. A proxy that rewrites Host to the server's internal name, which some proxies do by default, makes every connection from your own pages look foreign. Either:
- have the proxy forward the original
Hostheader, or - set
security.trustProxy: true, so the server compares against theX-Forwarded-Hostheader the proxy sets instead (only do this when the server is reachable through the proxy alone).
When a refused connection's Origin matches X-Forwarded-Host while trustProxy is off, the server logs a one-line hint saying so. A proxy that terminates TLS needs nothing extra, since the scheme isn't compared.
Message size
server.websocket.maxPayloadLength in palinx.config.ts sets the largest message the server accepts, in bytes. The default is 16 MB. Bun closes a connection that sends a larger message before any handler sees it, so the client sees close code 1006 rather than an error message; the server logs which route it happened on.
Errors
A handler that throws doesn't stop the server. The error is logged with the route's path, and then:
- in
open(), the connection is closed with code1011; - in
message()orclose(), the connection stays as it is; - in
accept(), the connection is refused with a500.
An error a handler leaves behind, such as a rejected promise nobody awaited or a throw inside a timer it started, is logged too and doesn't stop the server either. See Deployment for that policy and how to change it.
In development
Editing a .ws.ts file under px dev closes that route's open connections with code 1012 (service restart). useWebSocket reconnects by default, so clients pick up the new code straight away. Rate limits from security.rateLimit apply to connection requests as they do to other requests, in development and production.