hono-preact
Overview
Quick Start
The Route Table
Layouts & Nesting
Adding Pages
Active Links
Server Loaders
Loading States
Reloading Data
Prefetching
Streaming
Live Loaders
Realtime Channels
Server Actions
Validation
Optimistic UI
View Transitions
Middleware
CSRF Protection
CLI
Vite Config
Project Structure
Composing Hono Middleware
WebSockets
Rooms & Presence
renderPage
Link Prefetch
Build & Deploy
Overview
Dialog
Popover
Tooltip
Menu
Context Menu
Select
Combobox
Toast
renderElement
useControllableState
mergeRefs
useListNavigation
useTypeahead
useListboxSelection
usePosition
usePositioner
useDismiss
useFocusReturn
useSafeArea
usePresence

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#

NeedReach for
Client sends messages to the serverdefineSocket + useSocket
Server pushes data on mutations (pub/sub)Realtime Channels
Server streams data to a layout widgetLive 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:

ValueMeaning
'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#

CodeMeaning
1000Normal closure; no reconnect by default
4000-4999Application-defined; no reconnect by default
4403Guard 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)#

ParameterTypeDescription
Incomingtype paramMessage type the client sends (JSON-serializable).
Outgoingtype paramMessage type the server sends (JSON-serializable).
Datatype paramPer-connection data bag type. Default: undefined.

handler fields:

FieldTypeDescription
useReadonlyArray<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 }) => voidCalled when the connection closes.
error(socket, err) => voidCalled on a socket error.

socket fields:

FieldTypeDescription
send(msg)(msg: Outgoing) => voidSend a message to the client. JSON-encoded by the framework.
close(code?, reason?)methodClose the connection.
dataDataPer-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).
rawunknownEscape hatch to the underlying runtime socket.

ref.useSocket(opts?) / useSocket(ref, opts?)#

OptionTypeDefaultDescription
onMessage(msg: Serialize<Outgoing>) => voidCalled for every incoming message. Does not trigger a re-render.
onOpen() => voidCalled when the connection opens.
onClose(e: CloseEvent) => voidCalled when the connection closes.
shouldReconnect(e: CloseEvent) => booleanSee belowReturns true to reconnect.
reconnect.maxRetriesnumber5Maximum reconnect attempts.
reconnect.minDelaynumber250Minimum delay in ms before first retry.
reconnect.maxDelaynumber30000Backoff cap in ms.
reconnect.growthnumber2Exponential growth factor.
enabledbooleantrueWhen false, the socket does not connect.
lastMessagebooleanfalseWhen 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:

FieldTypeDescription
send(msg: Incoming) => voidSend a message. Queued if not yet open.
statusSocketStatusReactive connection status.
close(code?, reason?) => voidClose the connection. Suppresses reconnect.
closeInfoSocketCloseInfo | undefinedCode, reason, and wasClean from the last close event.
lastMessageSerialize<Outgoing> | undefinedLast received message, when opts.lastMessage is true.

upgradeWebSocket(createEvents)#

Wraps hono/ws upgradeWebSocket, resolved at request time so the same handler works across adapters.

ParameterTypeDescription
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#