Skip to content

Commit 03e864b

Browse files
bartlomiejuclaude
andcommitted
feat: add first-class WebSocket support via ctx.upgrade() and app.ws()
Adds ctx.upgrade() to the Context class with two overloads: - Bare mode returns { socket, response } for manual event wiring - Managed mode accepts handlers object and returns Response directly Adds app.ws() shorthand for registering WebSocket endpoints. Throws HttpError(400) on non-WebSocket requests. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 36dd637 commit 03e864b

4 files changed

Lines changed: 327 additions & 2 deletions

File tree

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
*/

packages/fresh/src/context.ts

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { jsxTemplate } from "preact/jsx-runtime";
1111
import { SpanStatusCode } from "@opentelemetry/api";
1212
import type { ResolvedFreshConfig } from "./config.ts";
1313
import type { BuildCache } from "./build_cache.ts";
14+
import { HttpError } from "./error.ts";
1415
import type { LayoutConfig } from "./types.ts";
1516
import {
1617
FreshScripts,
@@ -31,6 +32,43 @@ import { renderToString } from "preact-render-to-string";
3132

3233
const ENCODER = new TextEncoder();
3334

35+
/**
36+
* Event handlers for a WebSocket connection upgraded via
37+
* {@linkcode Context.upgrade}.
38+
*/
39+
export interface WebSocketHandlers {
40+
/** Called when the WebSocket connection is established. */
41+
open?(socket: WebSocket): void;
42+
/** Called when a message is received. */
43+
message?(socket: WebSocket, event: MessageEvent): void;
44+
/** Called when the connection is closed. */
45+
close?(socket: WebSocket, code: number, reason: string): void;
46+
/** Called when an error occurs. */
47+
error?(socket: WebSocket, event: Event | ErrorEvent): void;
48+
}
49+
50+
/**
51+
* Options forwarded to `Deno.upgradeWebSocket()`.
52+
*/
53+
export interface WebSocketUpgradeOptions {
54+
/** Automatically close the connection if no ping is received
55+
* within this many seconds. Default: 120. */
56+
idleTimeout?: number;
57+
/** The WebSocket sub-protocol to negotiate. */
58+
protocol?: string;
59+
}
60+
61+
function isWebSocketHandlers(
62+
value: unknown,
63+
): value is WebSocketHandlers {
64+
if (typeof value !== "object" || value === null) return false;
65+
const v = value as Record<string, unknown>;
66+
return typeof v.open === "function" ||
67+
typeof v.message === "function" ||
68+
typeof v.close === "function" ||
69+
typeof v.error === "function";
70+
}
71+
3472
export interface Island {
3573
file: string;
3674
name: string;
@@ -489,6 +527,85 @@ export class Context<State> {
489527

490528
return new Response(body, init);
491529
}
530+
531+
/**
532+
* Upgrade the request to a WebSocket connection.
533+
*
534+
* **Bare mode** — returns the socket and the upgrade response.
535+
* Wire events yourself and return `response` from your handler:
536+
*
537+
* ```ts
538+
* app.get("/ws", (ctx) => {
539+
* const { socket, response } = ctx.upgrade();
540+
* socket.onmessage = (e) => socket.send(e.data);
541+
* return response;
542+
* });
543+
* ```
544+
*
545+
* **Managed mode** — pass handlers and receive the response directly:
546+
*
547+
* ```ts
548+
* app.get("/ws", (ctx) =>
549+
* ctx.upgrade({
550+
* message(socket, event) {
551+
* socket.send(event.data);
552+
* },
553+
* })
554+
* );
555+
* ```
556+
*/
557+
upgrade(
558+
options?: WebSocketUpgradeOptions,
559+
): { socket: WebSocket; response: Response };
560+
upgrade(
561+
handlers: WebSocketHandlers,
562+
options?: WebSocketUpgradeOptions,
563+
): Response;
564+
upgrade(
565+
handlersOrOptions?: WebSocketHandlers | WebSocketUpgradeOptions,
566+
maybeOptions?: WebSocketUpgradeOptions,
567+
): { socket: WebSocket; response: Response } | Response {
568+
let handlers: WebSocketHandlers | undefined;
569+
let options: WebSocketUpgradeOptions | undefined;
570+
571+
if (isWebSocketHandlers(handlersOrOptions)) {
572+
handlers = handlersOrOptions;
573+
options = maybeOptions;
574+
} else {
575+
options = handlersOrOptions;
576+
}
577+
578+
if (this.req.headers.get("upgrade") !== "websocket") {
579+
throw new HttpError(400, "Expected a WebSocket upgrade request");
580+
}
581+
582+
const { socket, response } = Deno.upgradeWebSocket(this.req, options);
583+
584+
if (handlers === undefined) {
585+
return { socket, response };
586+
}
587+
588+
if (handlers.open) {
589+
socket.addEventListener("open", () => handlers.open!(socket));
590+
}
591+
if (handlers.message) {
592+
socket.addEventListener(
593+
"message",
594+
(ev) => handlers.message!(socket, ev),
595+
);
596+
}
597+
if (handlers.close) {
598+
socket.addEventListener(
599+
"close",
600+
(ev) => handlers.close!(socket, ev.code, ev.reason),
601+
);
602+
}
603+
if (handlers.error) {
604+
socket.addEventListener("error", (ev) => handlers.error!(socket, ev));
605+
}
606+
607+
return response;
608+
}
492609
}
493610

494611
function getHeadersFromInit(init?: ResponseInit) {

packages/fresh/src/mod.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,13 @@ export {
2020
} from "./middlewares/ip_filter.ts";
2121
export { csp, type CSPOptions } from "./middlewares/csp.ts";
2222
export type { FreshConfig, ResolvedFreshConfig } from "./config.ts";
23-
export type { Context, FreshContext, Island } from "./context.ts";
23+
export type {
24+
Context,
25+
FreshContext,
26+
Island,
27+
WebSocketHandlers,
28+
WebSocketUpgradeOptions,
29+
} from "./context.ts";
2430
export { createDefine, type Define } from "./define.ts";
2531
export type { Method } from "./router.ts";
2632
export { HttpError } from "./error.ts";
Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
import { expect } from "@std/expect";
2+
import { App } from "./app.ts";
3+
import { FakeServer } from "./test_utils.ts";
4+
5+
Deno.test("ctx.upgrade() - throws 400 on non-WebSocket request", async () => {
6+
const app = new App()
7+
.get("/ws", (ctx) => ctx.upgrade({ message() {} }));
8+
9+
const server = new FakeServer(app.handler());
10+
const res = await server.get("/ws");
11+
expect(res.status).toEqual(400);
12+
});
13+
14+
Deno.test("app.ws() - throws 400 on non-WebSocket request", async () => {
15+
const app = new App()
16+
.ws("/ws", { message() {} });
17+
18+
const server = new FakeServer(app.handler());
19+
const res = await server.get("/ws");
20+
expect(res.status).toEqual(400);
21+
});
22+
23+
Deno.test({
24+
name: "ctx.upgrade() - managed echo",
25+
sanitizeOps: false,
26+
sanitizeResources: false,
27+
async fn() {
28+
const app = new App()
29+
.get("/ws", (ctx) =>
30+
ctx.upgrade({
31+
message(socket, event) {
32+
socket.send(`echo: ${event.data}`);
33+
},
34+
}));
35+
36+
const ac = new AbortController();
37+
const server = Deno.serve({
38+
hostname: "127.0.0.1",
39+
port: 0,
40+
signal: ac.signal,
41+
onListen: () => {},
42+
}, app.handler());
43+
44+
const port = server.addr.port;
45+
46+
const ws = new WebSocket(`ws://127.0.0.1:${port}/ws`);
47+
const received = new Promise<string>((resolve, reject) => {
48+
ws.onmessage = (e) => resolve(e.data);
49+
ws.onerror = (e) => reject(e);
50+
});
51+
ws.onopen = () => ws.send("hello");
52+
53+
const msg = await received;
54+
expect(msg).toEqual("echo: hello");
55+
56+
ws.close();
57+
ac.abort();
58+
await server.finished;
59+
},
60+
});
61+
62+
Deno.test({
63+
name: "ctx.upgrade() - bare overload",
64+
sanitizeOps: false,
65+
sanitizeResources: false,
66+
async fn() {
67+
const app = new App()
68+
.get("/ws", (ctx) => {
69+
const { socket, response } = ctx.upgrade();
70+
socket.onmessage = (e) => socket.send(`bare: ${e.data}`);
71+
return response;
72+
});
73+
74+
const ac = new AbortController();
75+
const server = Deno.serve({
76+
hostname: "127.0.0.1",
77+
port: 0,
78+
signal: ac.signal,
79+
onListen: () => {},
80+
}, app.handler());
81+
82+
const port = server.addr.port;
83+
84+
const ws = new WebSocket(`ws://127.0.0.1:${port}/ws`);
85+
const received = new Promise<string>((resolve, reject) => {
86+
ws.onmessage = (e) => resolve(e.data);
87+
ws.onerror = (e) => reject(e);
88+
});
89+
ws.onopen = () => ws.send("hello");
90+
91+
const msg = await received;
92+
expect(msg).toEqual("bare: hello");
93+
94+
ws.close();
95+
ac.abort();
96+
await server.finished;
97+
},
98+
});
99+
100+
Deno.test({
101+
name: "app.ws() - echo shorthand",
102+
sanitizeOps: false,
103+
sanitizeResources: false,
104+
async fn() {
105+
const app = new App()
106+
.ws("/ws", {
107+
message(socket, event) {
108+
socket.send(`ws: ${event.data}`);
109+
},
110+
});
111+
112+
const ac = new AbortController();
113+
const server = Deno.serve({
114+
hostname: "127.0.0.1",
115+
port: 0,
116+
signal: ac.signal,
117+
onListen: () => {},
118+
}, app.handler());
119+
120+
const port = server.addr.port;
121+
122+
const ws = new WebSocket(`ws://127.0.0.1:${port}/ws`);
123+
const received = new Promise<string>((resolve, reject) => {
124+
ws.onmessage = (e) => resolve(e.data);
125+
ws.onerror = (e) => reject(e);
126+
});
127+
ws.onopen = () => ws.send("hello");
128+
129+
const msg = await received;
130+
expect(msg).toEqual("ws: hello");
131+
132+
ws.close();
133+
ac.abort();
134+
await server.finished;
135+
},
136+
});
137+
138+
Deno.test({
139+
name: "ctx.upgrade() - open and close handlers fire",
140+
sanitizeOps: false,
141+
sanitizeResources: false,
142+
async fn() {
143+
const events: string[] = [];
144+
const closed = Promise.withResolvers<void>();
145+
146+
const app = new App()
147+
.get("/ws", (ctx) =>
148+
ctx.upgrade({
149+
open() {
150+
events.push("open");
151+
},
152+
close() {
153+
events.push("close");
154+
closed.resolve();
155+
},
156+
}));
157+
158+
const ac = new AbortController();
159+
const server = Deno.serve({
160+
hostname: "127.0.0.1",
161+
port: 0,
162+
signal: ac.signal,
163+
onListen: () => {},
164+
}, app.handler());
165+
166+
const port = server.addr.port;
167+
168+
const ws = new WebSocket(`ws://127.0.0.1:${port}/ws`);
169+
await new Promise<void>((resolve) => {
170+
ws.onopen = () => resolve();
171+
});
172+
ws.close();
173+
174+
await closed.promise;
175+
expect(events).toEqual(["open", "close"]);
176+
177+
ac.abort();
178+
await server.finished;
179+
},
180+
});

0 commit comments

Comments
 (0)