Files
playbook/antigravity-awesome-skills/skills/neon-functions/references/hono-websockets.md
T
2026-07-01 16:02:41 +00:00

146 lines
7.2 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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<unknown, Wire>();
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<WebSocket>({
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 ~2530s 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.