Skip to content

feat(with-realtime-chat): serve the chat socket with upgradeWebSocket - #246

Draft
andrelandgraf wants to merge 1 commit into
mainfrom
feat/realtime-chat-upgrade-websocket
Draft

feat(with-realtime-chat): serve the chat socket with upgradeWebSocket#246
andrelandgraf wants to merge 1 commit into
mainfrom
feat/realtime-chat-upgrade-websocket

Conversation

@andrelandgraf

Copy link
Copy Markdown
Contributor

What changed

with-realtime-chat exported { fetch, upgrade } and drove the WebSocket handshake with the ws library. It now upgrades from inside fetch with upgradeWebSocket from @neon/functions@0.7.0.

export default {
  async fetch(request: Request): Promise<Response> {
    if (request.headers.get('upgrade')?.toLowerCase() !== 'websocket') {
      return app.fetch(request);
    }

    const identity = await verifyToken(new URL(request.url).searchParams.get('token'));
    if (!identity) return new Response('unauthorized', { status: 401 });

    const { socket, response } = upgradeWebSocket(request);
    clients.add(socket);
    socket.addEventListener('close', () => clients.delete(socket));
    socket.addEventListener('message', (event) => { /* persist */ });
    return response;
  },
};

socket is a standard WebSocket, so the Postgres poll/fan-out loop and the browser client are untouched.

Why it reads better

Auth is the part that changes shape. Refusing an unauthenticated handshake used to mean writing a status line onto a raw socket and destroying it:

-    if (!identity) {
-      socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
-      socket.destroy();
-      return;
-    }
+    if (!identity) return new Response('unauthorized', { status: 401 });

Net for src/index.ts: −27/+23, and these imports are gone entirely:

-import type { IncomingMessage } from 'node:http';
-import type { Duplex } from 'node:stream';
-import { WebSocketServer, type WebSocket } from 'ws';
+import { upgradeWebSocket } from '@neon/functions';

ws and @types/ws are dropped from package.json; @neon/functions moves ^0.5.0^0.7.0, the first version that exports upgradeWebSocket.

Skill refresh

The vendored neon-functions skill is re-installed in all nine examples that ship it (with-ai-sdk, with-hono, with-mastra, with-mcp, with-realtime-chat, with-realtime-sse, and the three bots/*), picking up neondatabase/agent-skills#78. That drops references/hono-websockets.md from each — the vendored Hono adapter existed only to bridge Hono's helper onto the raw Node handshake, and there is no raw handshake to bridge to now.

Installed with npx skills add neondatabase/agent-skills -s neon-functions -a universal, so only the .agents/skills tree each example already tracked is touched.

Verification

tsc --noEmit is clean on the rewritten function against the real @neon/functions types, under the example's own strict tsconfig, with no assertions added.

The interface itself was exercised against a real neon dev over loopback on an equivalent handler: the 101 with a correct accept key, text fan-out to a second client, binary as ArrayBuffer, a 70 KB message across both extended length forms, subprotocol negotiation, server-initiated close with code and reason, ping/pong, and the 401 rejection path.

Not verified — read before merging

This example has not been deployed and run end to end on the new interface. The deployed Neon Functions runtime does not yet publish the WebSocket bridge that upgradeWebSocket reads — that work is LKB-15856, and neon-solutions/function-spec records the gap as still open. On today's runtime this function will throw TypeError: upgradeWebSocket() is only available inside a Neon Functions invocation at the first handshake.

It works under neon dev with neon@2.45.0. Do not merge until the runtime side ships, or the example's deploy instructions will be broken for anyone following the README.

The chat function exported `{ fetch, upgrade }` and drove the handshake with the
`ws` library. It now upgrades from inside `fetch` with `upgradeWebSocket` from
`@neon/functions`, which is the interface Neon Functions expose.

    const { socket, response } = upgradeWebSocket(request);
    clients.add(socket);
    socket.addEventListener('message', (event) => { ... });
    return response;

`socket` is a standard WebSocket, so the fan-out loop and the client are
unchanged. Auth is the part that reads better: rejecting an unauthenticated
handshake is `return new Response('unauthorized', { status: 401 })` rather than
writing `HTTP/1.1 401 Unauthorized` onto a raw socket and destroying it.

`ws` and `@types/ws` are gone from the dependencies, along with the `node:http`
and `node:stream` type imports. `@neon/functions` moves to ^0.7.0, the first
version that exports `upgradeWebSocket`.

The vendored `neon-functions` skill is refreshed in all nine examples that ship
it, which drops its `hono-websockets.md` reference -- the Hono adapter it
described only existed to bridge Hono onto the raw Node handshake.
@vercel

vercel Bot commented Aug 6, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
fuzzy-semantic-search-nextjs Ready Ready Preview Aug 6, 2026 9:56am

Request Review

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant