WebSockets#
hono-preact ships two WebSocket layers. The typed socket primitive (defineSocket + useSocket) gives you a declarative, type-safe duplex channel wired automatically to the framework's shared connection. The raw upgradeWebSocket export lets you register any hand-authored WS route in api.ts on the same connection, following the same Hono pattern as before, but now imported from hono-preact directly.
For the rules around the api.ts mount point and framework-reserved paths, see Composing Hono Middleware.
Choosing between sockets and live loaders#
| Need | Reach for |
|---|---|
| Client sends messages to the server | defineSocket + useSocket |
| Server pushes data on mutations (pub/sub) | Realtime Channels |
| Server streams data to a layout widget | Live Loaders |
A socket is the right tool when the browser needs to send upstream, or when you want a persistent full-duplex channel. Server-to-client-only scenarios (a notification feed, a dashboard widget) are better served by SSE-backed live loaders, which require no upgrade plumbing.
On Node, a single HTTP connection is shared across the framework and all sockets. On Cloudflare a typed socket runs end to end: the worker guards the upgrade at the edge and forwards it to a per-connection Durable Object that runs the handler under the Hibernation API, so the same defineSocket works on both runtimes. Cross-connection fan-out (broadcasting to every connected client) is a separate concern: use Rooms, which coordinate many connections through one Durable Object per topic.
Typed sockets with defineSocket#
The typed socket primitive integrates with the framework's route table and build pipeline. The build strips the server implementation on the client side, replacing it with a lightweight descriptor, so the types flow without shipping handler code to the browser.
Server module#
Place the socket definition in a .server.ts module, inside a serverSockets export:
// src/chat.server.ts
import { defineSocket, serverRoute } from 'hono-preact';
const route = serverRoute('/chat');
export const serverSockets = {
chat: defineSocket<
{ text: string }, // messages the client sends
{ text: string; from: string } // messages the server sends
>({
// Optional guard middleware. A failing guard closes the socket with 4403.
use: [route.use],
// Edge factory: runs at the upgrade with the live Context; its result seeds
// socket.data. Read cookies, headers, and middleware values here.
data: (c) => ({ name: c.get('user').name }),
open(socket) {
// Return a teardown fn to run on close (Node only; see the note below).
return () => {
console.log(`${socket.data.name} disconnected`);
};
},
message(socket, msg) {
// Echo the message back to the same connection.
socket.send({ text: msg.text, from: socket.data.name });
},
close(socket, ev) {
console.log('closed', ev.code, ev.reason);
},
}),
};
open runs once per connection after the upgrade. It receives only the socket; read request-derived values (cookies, headers, middleware state) in the data factory, which runs at the upgrade with the full Hono Context and seeds socket.data. Returning a function from open registers a teardown that runs when the connection closes on Node; on Cloudflare the connection is hibernatable, so use close for cleanup that must run on both runtimes.
socket.data is seeded by the data factory at connect time. Declare its shape with the third type parameter: defineSocket<Incoming, Outgoing, Data>(). It is undefined by default (no data factory). On Node the handler may mutate it across events (open/message/close share the same object). On Cloudflare each event sees the connect-time value (the Durable Object hibernates between events), so per-connection state that must survive across messages on Cloudflare belongs in external storage.
Client component#
Import the serverSockets map from the .server module and call .useSocket on the entry:
// src/chat.tsx
import { serverSockets } from './chat.server.js';
export default function Chat() {
const { send, status, lastMessage } = serverSockets.chat.useSocket({
lastMessage: true,
onMessage(msg) {
console.log('received', msg.text);
},
});
return (
<div>
<p>Status: {status}</p>
{lastMessage && (
<p>
{lastMessage.from}: {lastMessage.text}
</p>
)}
<button onClick={() => send({ text: 'hello' })}>Send</button>
</div>
);
}
The free useSocket(ref, opts) export also works and is equivalent; the method form is the idiomatic choice when the ref is a serverSockets entry.
useSocket manages the WebSocket lifecycle: it connects on mount, reconnects with exponential backoff on unexpected drops, and closes cleanly on unmount. send queues messages if the socket is not yet open, flushing them once the connection is established (up to 128 queued messages); beyond that cap, further sends while not open are dropped, with a dev-mode console warning rather than a silent loss. Toggling enabled to false on an open socket tears the connection down and status becomes 'closed'.
status is reactive and drives UI affordances:
| Value | Meaning |
|---|---|
'connecting' | First connect attempt in progress |
'open' | Connection is established |
'reconnecting' | Connection dropped; backoff timer running |
'closing' | close() called; handshake in progress |
'closed' | Connection closed and will not reconnect |
Guard model#
A socket's guard chain is composed in order: app-level use from defineApp({ use }), then any use on the enclosing route node (via serverRoute), then the socket's own use array. Route-node and layout use guards are inherited, so a guard that protects a route also covers its sockets and rooms without having to be repeated. Add socket-specific authorization in the socket's own use array for concerns that only apply to the socket.
Use deny(), not redirect(), to reject a realtime upgrade. A WebSocket handshake cannot follow an HTTP redirect, so a guard that throws redirect() on the socket path is treated as a deny (close 4403) and logs a warning. If you share a guard between an HTTP route and its socket, branch on the request so the HTTP path redirects and the socket path denies.
A plain socket does not receive route path params in an inherited route-node guard. The upgrade is served from the framework's flat /__sockets endpoint, which is query-string only, so ctx.location.pathParams is empty for a socket connection. Param-dependent authorization for a socket must read the connection's query or headers in the socket's own use (or in open), not rely on a :param from the route the socket lives next to. (Rooms differ: a room's guard chain can read the room-key params via ctx.location.pathParams, because the room key rides the wire and is resolved server-side before the guard runs. See Rooms.)
Cloudflare setup#
On Cloudflare Workers, defineSocket runs inside a per-connection Durable Object, so it needs the HONO_PREACT_REALTIME Durable Object binding and migration in wrangler.jsonc (the same binding rooms and live-loader pub/sub use). See Rooms → Cloudflare setup for the binding and migration. (This applies to defineSocket; the raw upgradeWebSocket path below is separate and pairs its own Durable Object.)
Reconnect behavior#
useSocket reconnects automatically after unexpected drops. The default policy skips reconnect for close code 1000 (normal closure) and all 4000-4999 codes (application-defined). Code 4403 is issued by the framework when a socket's use guard rejects the upgrade, signaling that reconnecting would hit the same rejection.
Override the policy with shouldReconnect:
serverSockets.chat.useSocket({
shouldReconnect: (ev) => ev.code !== 1000 && ev.code !== 4403,
reconnect: {
maxRetries: 10,
minDelay: 500,
maxDelay: 60_000,
growth: 2,
},
});
Disable the socket entirely with enabled: false; the hook will not connect until enabled becomes true.
Close codes#
| Code | Meaning |
|---|---|
1000 | Normal closure; no reconnect by default |
4000-4999 | Application-defined; no reconnect by default |
4403 | Guard rejected the upgrade; no reconnect by default |
Raw WebSocket routes with upgradeWebSocket#
For routes that do not fit the typed socket pattern, upgradeWebSocket from hono-preact lets you register any WS route in api.ts using the same Hono handler API:
// src/api.ts
import { Hono } from 'hono';
import { upgradeWebSocket } from 'hono-preact';
const app = new Hono();
app.get(
'/ws',
upgradeWebSocket(() => ({
onMessage(event, ws) {
ws.send(`echo: ${event.data}`);
},
onClose() {
// cleanup
},
}))
);
export default app;
upgradeWebSocket resolves the correct adapter at request time (Cloudflare Workers or Node), so the same api.ts code works across adapters without conditional imports.
Node.js#
On Node the framework's adapter wires the WebSocket upgrade internally (the same connection that powers serverSockets), so you do not install @hono/node-ws, call createNodeWebSocket, or export injectWebSocket. Your api.ts only needs upgradeWebSocket from hono-preact:
// src/api.ts
import { Hono } from 'hono';
import { upgradeWebSocket } from 'hono-preact';
const app = new Hono();
app.get(
'/ws',
upgradeWebSocket(() => ({
onMessage(event, ws) {
ws.send(`echo: ${event.data}`);
},
}))
);
export default app;
A working end-to-end example lives at apps/example-node/ in the repo.
Cloudflare Workers#
On Cloudflare the Worker runtime handles the upgrade natively. upgradeWebSocket from hono-preact routes through hono/cloudflare-workers under the hood, so no extra peer install is needed. The same handler runs in vite dev (because the Cloudflare adapter boots workerd via @cloudflare/vite-plugin) and in a deployed Worker.
For long-lived stateful connections, Cloudflare's recommended pattern is to pair with a Durable Object that owns the connection state. From hono-preact's perspective the handler in api.ts is unchanged.
Avoiding reserved paths#
Register WebSocket routes on any non-colliding path. The build rejects catch-all and reserved-path route registrations in api.ts. See Composing Hono Middleware for the full rules.
API reference#
defineSocket<Incoming, Outgoing, Data>(handler)#
| Parameter | Type | Description |
|---|---|---|
Incoming | type param | Message type the client sends (JSON-serializable). |
Outgoing | type param | Message type the server sends (JSON-serializable). |
Data | type param | Per-connection data bag type. Default: undefined. |
handler fields:
| Field | Type | Description |
|---|---|---|
use | ReadonlyArray<Middleware> | Guard chain run before the upgrade. A failing guard closes with code 4403. |
data | (c: Context) => Data | Promise<Data> | Edge factory run once at the upgrade with the live Context; its result seeds socket.data. May be async. The only place a socket handler sees a Context (on Cloudflare the handler runs inside a Durable Object). On Cloudflare the data() result rides a request header to the Durable Object, so keep it small. |
open | (socket) => void | (() => void) | Promise<...> | Runs once per connection. Returning a function registers a teardown (Node only; on Cloudflare use close). Receives only the socket; its data is the data factory result. |
message | (socket, msg: Incoming) => void | Promise<void> | Called for every incoming message. |
close | (socket, { code, reason }) => void | Called when the connection closes. |
error | (socket, err) => void | Called on a socket error. |
socket fields:
| Field | Type | Description |
|---|---|---|
send(msg) | (msg: Outgoing) => void | Send a message to the client. JSON-encoded by the framework. |
close(code?, reason?) | method | Close the connection. |
data | Data | Per-connection data seeded by the data factory at connect time. On Node, mutable across events. On Cloudflare, the connect-time value only (cross-event mutable state goes to external storage). |
raw | unknown | Escape hatch to the underlying runtime socket. |
ref.useSocket(opts?) / useSocket(ref, opts?)#
| Option | Type | Default | Description |
|---|---|---|---|
onMessage | (msg: Serialize<Outgoing>) => void | Called for every incoming message. Does not trigger a re-render. | |
onOpen | () => void | Called when the connection opens. | |
onClose | (e: CloseEvent) => void | Called when the connection closes. | |
shouldReconnect | (e: CloseEvent) => boolean | See below | Returns true to reconnect. |
reconnect.maxRetries | number | 5 | Maximum reconnect attempts. |
reconnect.minDelay | number | 250 | Minimum delay in ms before first retry. |
reconnect.maxDelay | number | 30000 | Backoff cap in ms. |
reconnect.growth | number | 2 | Exponential growth factor. |
enabled | boolean | true | When false, the socket does not connect. |
lastMessage | boolean | false | When true, the latest message is stored in reactive state and returned as lastMessage. |
Default shouldReconnect: false for codes 1000 and 4000-4999, true otherwise.
Returns:
| Field | Type | Description |
|---|---|---|
send | (msg: Incoming) => void | Send a message. Queued if not yet open. |
status | SocketStatus | Reactive connection status. |
close | (code?, reason?) => void | Close the connection. Suppresses reconnect. |
closeInfo | SocketCloseInfo | undefined | Code, reason, and wasClean from the last close event. |
lastMessage | Serialize<Outgoing> | undefined | Last received message, when opts.lastMessage is true. |
upgradeWebSocket(createEvents)#
Wraps hono/ws upgradeWebSocket, resolved at request time so the same handler works across adapters.
| Parameter | Type | Description |
|---|---|---|
createEvents | (c: Context) => WSEvents | Promise<WSEvents> | Factory that returns the Hono WSEvents handler. |
Returns a MiddlewareHandler for use in app.get('/path', upgradeWebSocket(...)).
Type exports#
import type {
SocketRef,
SocketHandler,
ServerSocket,
SocketStatus,
SocketCloseInfo,
ReconnectOptions,
UseSocketOptions,
UseSocketResult,
} from 'hono-preact';
See also#
- Realtime Channels: server-to-client pub/sub over SSE with
defineChannel+publish. - Live Loaders: persistent streaming loaders for layout-scoped widgets.
- Composing Hono Middleware:
api.tsmount rules and reserved paths.