Skip to content

Commit 0ab5a2a

Browse files
authored
feat: add first-class WebSocket support via ctx.upgrade() and app.ws() (#3774)
## Summary - **`ctx.upgrade()`** on the `Context` class with two overloads: - **Bare mode**: `ctx.upgrade()` or `ctx.upgrade(options)` returns `{ socket, response }` for manual event wiring - **Managed mode**: `ctx.upgrade(handlers)` accepts `{ open, message, close, error }` and returns the upgrade `Response` directly - **`app.ws(path, handlers, options?)`** shorthand that registers a GET route with automatic WebSocket upgrade (managed mode only) - Both support `idleTimeout` and `protocol` options forwarded to `Deno.upgradeWebSocket()` - Throws `HttpError(400)` on non-WebSocket requests - Case-insensitive `Upgrade` header check per RFC 6455 §4.2.1 - Exports `WebSocketHandlers` and `WebSocketUpgradeOptions` types from `fresh` - Full documentation page with examples for managed mode, bare mode, broadcast chat, and client-side usage
1 parent 49c4be1 commit 0ab5a2a

8 files changed

Lines changed: 605 additions & 25 deletions

File tree

docs/latest/examples/common-patterns.md

Lines changed: 2 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -175,29 +175,8 @@ export const handler = define.handlers({
175175

176176
## WebSockets
177177

178-
Fresh runs on Deno, so you can upgrade HTTP connections to WebSockets directly:
179-
180-
```ts routes/api/ws.ts
181-
import { define } from "@/utils.ts";
182-
183-
export const handler = define.handlers({
184-
GET(ctx) {
185-
const { socket, response } = Deno.upgradeWebSocket(ctx.req);
186-
187-
socket.onopen = () => {
188-
console.log("Client connected");
189-
};
190-
socket.onmessage = (event) => {
191-
socket.send(`Echo: ${event.data}`);
192-
};
193-
socket.onclose = () => {
194-
console.log("Client disconnected");
195-
};
196-
197-
return response;
198-
},
199-
});
200-
```
178+
Fresh provides first-class WebSocket support via `ctx.upgrade()`. See the full
179+
[WebSocket guide](/docs/examples/websockets) for all options.
201180

202181
## Subdomain routing
203182

docs/latest/examples/index.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,4 +13,5 @@ that you may like in your Fresh project.
1313
- [Sharing state between islands](./examples/sharing-state-between-islands)
1414
- [Active links](./examples/active-links)
1515
- [Session management](./examples/session-management)
16+
- [WebSockets](./examples/websockets)
1617
- [Common Patterns](./examples/common-patterns)

docs/latest/examples/websockets.md

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
---
2+
description: |
3+
Add real-time WebSocket endpoints to your Fresh app with ctx.upgrade() or app.ws().
4+
---
5+
6+
Fresh provides built-in helpers for upgrading HTTP connections to WebSockets.
7+
There are two main approaches depending on your use case.
8+
9+
## Quick start with `app.ws()`
10+
11+
The simplest way to add a WebSocket endpoint:
12+
13+
```ts main.ts
14+
import { App } from "fresh";
15+
16+
const app = new App()
17+
.ws("/ws", {
18+
open(socket) {
19+
console.log("Client connected");
20+
},
21+
message(socket, event) {
22+
socket.send(`Echo: ${event.data}`);
23+
},
24+
close(socket, code, reason) {
25+
console.log("Client disconnected", code, reason);
26+
},
27+
});
28+
```
29+
30+
`app.ws(path, handlers)` registers a GET route that automatically upgrades the
31+
request to a WebSocket connection and wires up your event handlers.
32+
33+
## Using `ctx.upgrade()` in route handlers
34+
35+
For file-based routes or when you need more control, use `ctx.upgrade()` inside
36+
a GET handler.
37+
38+
### Managed mode
39+
40+
Pass an event handlers object and receive the upgrade `Response` directly:
41+
42+
```ts routes/api/ws.ts
43+
import { define } from "@/utils.ts";
44+
45+
export const handlers = define.handlers({
46+
GET(ctx) {
47+
return ctx.upgrade({
48+
open(socket) {
49+
console.log("Client connected");
50+
},
51+
message(socket, event) {
52+
socket.send(`Echo: ${event.data}`);
53+
},
54+
close(socket, code, reason) {
55+
console.log("Disconnected", code, reason);
56+
},
57+
error(socket, event) {
58+
console.error("WebSocket error", event);
59+
},
60+
});
61+
},
62+
});
63+
```
64+
65+
### Bare mode
66+
67+
Call `ctx.upgrade()` without arguments to get the raw `WebSocket` object. This
68+
is useful when you need to store the socket in a shared structure like a chat
69+
room or pub/sub registry:
70+
71+
```ts routes/api/chat.ts
72+
import { define } from "@/utils.ts";
73+
74+
const clients = new Set<WebSocket>();
75+
76+
export const handlers = define.handlers({
77+
GET(ctx) {
78+
const { socket, response } = ctx.upgrade();
79+
80+
socket.onopen = () => {
81+
clients.add(socket);
82+
};
83+
socket.onmessage = (event) => {
84+
// Broadcast to all connected clients
85+
for (const client of clients) {
86+
if (client.readyState === WebSocket.OPEN) {
87+
client.send(event.data);
88+
}
89+
}
90+
};
91+
socket.onclose = () => {
92+
clients.delete(socket);
93+
};
94+
95+
return response;
96+
},
97+
});
98+
```
99+
100+
## Upgrade options
101+
102+
Both modes accept an options object to configure the underlying WebSocket:
103+
104+
```ts
105+
// Managed mode — pass handlers first, then options
106+
ctx.upgrade(handlers, {
107+
idleTimeout: 60, // close if no ping received within 60s (default: 120)
108+
protocol: "graphql-ws", // sub-protocol to negotiate
109+
});
110+
111+
// Bare mode — pass options without handlers to get the raw socket back
112+
const { socket, response } = ctx.upgrade({ idleTimeout: 60 });
113+
```
114+
115+
> **How does Fresh tell the two calls apart?** The first argument is treated as
116+
> managed-mode handlers when it contains at least one function-valued handler
117+
> key (`open`, `message`, `close`, or `error`). A plain options object only has
118+
> non-function fields (`idleTimeout`, `protocol`), so it always enters bare
119+
> mode.
120+
121+
The same options can be passed to `app.ws()`:
122+
123+
```ts
124+
app.ws("/ws", handlers, { idleTimeout: 60 });
125+
```
126+
127+
> `app.ws()` always uses managed mode. For bare-mode access to the raw socket,
128+
> use `app.get()` with `ctx.upgrade()` instead.
129+
130+
## Error handling
131+
132+
If a non-WebSocket request hits a WebSocket route, `ctx.upgrade()` throws an
133+
`HttpError(400)` with the message "Expected a WebSocket upgrade request". This
134+
is handled automatically by Fresh's error pipeline and returns a 400 response.
135+
136+
## Handler reference
137+
138+
All handler callbacks are optional:
139+
140+
| Callback | Arguments | Description |
141+
| --------- | ------------------------ | ---------------------------------------------------- |
142+
| `open` | `(socket)` | Connection established |
143+
| `message` | `(socket, event)` | Message received (`event.data` contains the payload) |
144+
| `close` | `(socket, code, reason)` | Connection closed |
145+
| `error` | `(socket, event)` | Error occurred on the connection |
146+
147+
## Client-side example
148+
149+
Connect from the browser:
150+
151+
```ts
152+
const protocol = location.protocol === "https:" ? "wss:" : "ws:";
153+
const ws = new WebSocket(`${protocol}//${location.host}/ws`);
154+
155+
ws.onopen = () => {
156+
ws.send("Hello from the client!");
157+
};
158+
159+
ws.onmessage = (event) => {
160+
console.log("Received:", event.data);
161+
};
162+
```

docs/toc.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,7 @@ const toc: RawTableOfContents = {
107107
],
108108
["active-links", "Active links", "link:latest"],
109109
["session-management", "Session management", "link:latest"],
110+
["websockets", "WebSockets", "link:latest"],
110111
["common-patterns", "Common Patterns", "link:latest"],
111112
],
112113
},

packages/fresh/src/app.ts

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,11 @@ import { trace } from "@opentelemetry/api";
33
import { DENO_DEPLOYMENT_ID } from "@fresh/build-id";
44
import * as colors from "@std/fmt/colors";
55
import type { MaybeLazyMiddleware, Middleware } from "./middlewares/mod.ts";
6-
import { Context } from "./context.ts";
6+
import {
7+
Context,
8+
type WebSocketHandlers,
9+
type WebSocketUpgradeOptions,
10+
} from "./context.ts";
711
import { mergePath, type Method, UrlPatternRouter } from "./router.ts";
812
import type { FreshConfig, ResolvedFreshConfig } from "./config.ts";
913
import type { BuildCache } from "./build_cache.ts";
@@ -301,6 +305,24 @@ export class App<State> {
301305
return this;
302306
}
303307

308+
/**
309+
* Register a WebSocket endpoint at the specified path.
310+
*
311+
* ```ts
312+
* app.ws("/chat", {
313+
* open(socket) { console.log("connected"); },
314+
* message(socket, event) { socket.send(event.data); },
315+
* });
316+
* ```
317+
*/
318+
ws(
319+
path: string,
320+
handlers: WebSocketHandlers,
321+
options?: WebSocketUpgradeOptions,
322+
): this {
323+
return this.get(path, (ctx) => ctx.upgrade(handlers, options));
324+
}
325+
304326
/**
305327
* Add middlewares for all HTTP verbs at the specified path.
306328
*/

0 commit comments

Comments
 (0)