# Hono WebSocket helper on Neon Functions Neon Functions accept WebSockets via a `{ fetch, upgrade }` default export, where `upgrade(req, socket, head)` is the raw Node handshake (see the [WebSocket servers](../SKILL.md#websocket-servers) section). That works directly with the `ws` library. If you'd rather declare WebSocket routes _inside_ a Hono app — `app.get("/ws", upgradeWebSocket(...))` with the standard `onOpen`/`onMessage`/`onClose` lifecycle — you need an adapter. ## Why an adapter (and why not `@hono/node-ws`) Hono's `upgradeWebSocket()` is runtime-agnostic at the route layer, but the actual handshake is done by a per-runtime adapter (`hono/cloudflare-workers`, `hono/deno`, `hono/bun`, `@hono/node-server`). **There is no adapter for Neon.** The Node one, `@hono/node-ws`, is **deprecated** and its replacement assumes it owns the HTTP server (`serve({ websocket })`) — which Neon's runtime does instead. So vendor the small adapter below. It depends only on `hono` and `ws` (no deprecated package), and is adapted from `@hono/node-ws` (MIT). Instead of attaching to an `http.Server`'s `'upgrade'` event, it returns a ready-to-export `{ fetch, upgrade }` handler that matches Neon's contract. ## The adapter ```typescript // src/hono-ws.ts — bridges Hono's upgradeWebSocket() to Neon's { fetch, upgrade }. import { STATUS_CODES, type IncomingMessage } from "node:http"; import type { Duplex } from "node:stream"; import type { Hono } from "hono"; import { WSContext, defineWebSocketHelper } from "hono/ws"; import { WebSocketServer, type WebSocket } from "ws"; type Wire = (ws: WebSocket) => void; export function createNeonWebSocket(app: Hono, baseUrl = "http://localhost") { const wss = new WebSocketServer({ noServer: true }); // Correlate a handshake to its route handler via the per-request env object identity. const pending = new Map(); const upgradeWebSocket = defineWebSocketHelper(async (c, events, options) => { if (c.req.header("upgrade")?.toLowerCase() !== "websocket") return; const url = c.req.url; pending.set(c.env, (ws) => { const onError = options?.onError ?? ((e: unknown) => console.error(e)); const ctx = new WSContext({ send: (data, opts) => ws.send(data, { compress: opts?.compress }), close: (code, reason) => ws.close(code, reason), raw: ws, url, protocol: ws.protocol, get readyState() { return ws.readyState; }, }); try { events.onOpen?.(new Event("open"), ctx); } catch (e) { onError(e); } ws.on("message", (data, isBinary) => { for (const chunk of Array.isArray(data) ? data : [data]) { try { const payload = isBinary ? chunk instanceof ArrayBuffer ? chunk : chunk.buffer.slice(chunk.byteOffset, chunk.byteOffset + chunk.byteLength) : chunk.toString("utf-8"); events.onMessage?.(new MessageEvent("message", { data: payload }), ctx); } catch (e) { onError(e); } } }); ws.on("close", (code, reason) => { try { events.onClose?.(new CloseEvent("close", { code, reason: reason.toString() }), ctx); } catch (e) { onError(e); } }); // Node 24 has no global ErrorEvent; a browser's ws.onerror gets a plain Event anyway. ws.on("error", (error) => { onError(error); try { events.onError?.(new Event("error"), ctx); } catch (e) { onError(e); } }); }); return new Response(); }); const handler = { fetch: (request: Request) => app.fetch(request), async upgrade(req: IncomingMessage, socket: Duplex, head: Buffer) { const url = new URL(req.url ?? "/", baseUrl); const headers = new Headers(); for (const [key, value] of Object.entries(req.headers)) { if (value) headers.append(key, Array.isArray(value) ? value[0] : value); } // The env object identity links this request back to the route handler above. const env = { incoming: req, outgoing: undefined }; const response = await app.request(url, { headers }, env); const wire = pending.get(env); pending.delete(env); if (!wire) { socket.end(`HTTP/1.1 ${response.status} ${STATUS_CODES[response.status] ?? ""}\r\nConnection: close\r\nContent-Length: 0\r\n\r\n`); return; } wss.handleUpgrade(req, socket, head, (ws) => wire(ws)); }, }; return { upgradeWebSocket, handler }; } ``` How it works: the `upgrade` handler runs the handshake request through `app.request(...)` so Hono's router matches the `upgradeWebSocket` route; the helper registers a `wire` callback keyed by the per-request `env` object; if a route matched, `wss.handleUpgrade` accepts the socket and `wire` attaches the lifecycle handlers, otherwise the socket is closed with the route's status (e.g. 401/404). ## Usage Because the handshake is routed through `app.request`, **auth and other gating are just normal Hono middleware on the route** — verify the `?token=` and return 401 before the upgrade: ```typescript // src/index.ts import { Hono } from "hono"; import { createNeonWebSocket } from "./hono-ws"; const app = new Hono(); const { upgradeWebSocket, handler } = createNeonWebSocket(app); app.get("/", (c) => c.text("ok")); app.get( "/ws", async (c, next) => { const identity = await verifyToken(c.req.query("token")); // your JWT check if (!identity) return c.text("Unauthorized", 401); await next(); }, upgradeWebSocket(() => ({ onOpen: (_evt, ws) => ws.send("welcome"), onMessage: (evt, ws) => ws.send(`echo: ${evt.data}`), onClose: () => console.log("disconnected"), })), ); export default handler; // Neon's { fetch, upgrade } contract ``` ## Caveats - **No header-modifying middleware on the WS route.** Per [Hono's docs](https://hono.dev/docs/helpers/websocket), middleware that changes headers (e.g. CORS) on an `upgradeWebSocket` route throws ("can't modify immutable headers"), because the helper rewrites headers internally. Auth middleware that only reads the query/headers and returns 401 is fine. - **Send a heartbeat.** A socket is dropped once it goes silent — Neon's window is 15 minutes ([Timeouts](../SKILL.md#timeouts-and-runtime-limits)), but intermediary proxies are usually far stricter (tens of seconds). Send a keepalive every ~25–30s so the connection never goes quiet. With the raw `ws` socket use `ws.ping()` (the browser auto-replies with a pong); through this helper you don't hold the raw socket, so send an app-level `ws.send("ping")` the client ignores instead. See [Heartbeat](../SKILL.md#heartbeat-keep-the-socket-alive). - **Fan-out and reconnect still apply.** This adapter only covers the per-isolate handshake. For a genuinely shared chat across isolates, broadcast with Postgres `LISTEN`/`NOTIFY`, and have clients reconnect with backoff — see [Fan-out across isolates](../SKILL.md#fan-out-across-isolates-do-not-skip-this) and [Client must reconnect](../SKILL.md#client-must-reconnect). - **Node-only globals.** Relies on `Event`, `MessageEvent`, and `CloseEvent` (all present on Neon's Node 24 runtime). `ErrorEvent` is intentionally avoided since it isn't a Node global.