Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions .changeset/websocket-upgrades.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
---
"@pracht/core": minor
"@pracht/adapter-cloudflare": minor
---

Serve WebSocket upgrades from API routes.

Pracht owns the worker's `fetch`, and every API response used to be rebuilt via
`new Response(response.body, { status, headers })` to stamp the default security
headers. A `101 Switching Protocols` response cannot survive that: the Response
constructor rejects any status below 200, and Cloudflare's `webSocket` handle is
not part of `ResponseInit`, so it would be dropped even where the status was
tolerated. The thrown `RangeError` was caught by the API error path, so a
WebSocket handler returned an opaque 500.

Protocol-switch responses are now passed through the response pipeline
untouched — same object, socket intact, no header or cache post-processing (a
handshake has no body for those policies to protect). The new
`isProtocolSwitchResponse()` export from `@pracht/core/server` is what adapters
use to detect them.

On Cloudflare, an upgrade request also now skips the ISG and static-asset
lookups, so a handshake no longer costs a wasted subrequest against the assets
binding on every connection. Return the handshake from an API route — typically
by forwarding the request to a Durable Object, which owns the socket for as long
as it stays open. `examples/cloudflare` ships a working `ChatRoom` object and
`src/api/ws.ts` route.

**Security change:** `api.requireSameOrigin` (on by default) now also applies to
upgrade requests, which are `GET` and were therefore previously exempt from the
method-based check. Browsers do not apply CORS to WebSocket, so without this any
page on the web could open a cookie-authenticated socket to your app
(cross-site WebSocket hijacking). This cannot break existing apps, since no
upgrade could reach a handler before this release.

The Node and Vercel adapters still cannot serve upgrades. On Node this is
structural rather than a gap in the adapter: `http.Server` routes upgrade
requests to its `upgrade` event, not to the request handler, so they never reach
pracht. `docs/ADAPTERS.md` documents attaching a `ws` server to the same HTTP
server alongside pracht's exported `handler`.
120 changes: 120 additions & 0 deletions docs/ADAPTERS.md
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,41 @@ that manifest.
- **Response headers**: preserves multiple `Set-Cookie` headers from framework
responses by writing them as an array to Node's `ServerResponse`.

### WebSockets

Pracht's Node handler cannot serve WebSocket upgrades, and this is a property of
Node rather than a gap in the adapter: `http.Server` routes upgrade requests to
its `upgrade` event, not to the request handler, so a handshake never reaches
`handler(req, res)` at all. (With no `upgrade` listener attached, Node closes
those connections.)

Attach a WebSocket server to the same HTTP server instead. The generated entry
only calls `createServer()` when it is the process entrypoint, so importing
`handler` and building the server yourself is supported:

```javascript
import { createServer } from "node:http";
import { WebSocketServer } from "ws";
import { handler } from "./dist/server/server.js";

const server = createServer(handler); // pracht serves ordinary requests
const wss = new WebSocketServer({ noServer: true });

server.on("upgrade", (req, socket, head) => {
// Check the Origin header yourself here: browsers do not apply CORS to
// WebSocket, so a cross-site page can otherwise open an authenticated
// socket (cross-site WebSocket hijacking). Pracht's own
// `api.requireSameOrigin` check cannot help — this never reaches pracht.
wss.handleUpgrade(req, socket, head, (ws) => wss.emit("connection", ws, req));
});

server.listen(3000);
```

For upgrades served *through* pracht's routing (as an API route), use the
[Cloudflare adapter](#websockets-1). The Vercel adapter cannot serve them
either.

### Generated entry options

When using `nodeAdapter()` in `vite.config.ts`, generated entries can import a context factory and tune body limits:
Expand Down Expand Up @@ -445,6 +480,91 @@ The generated entry becomes
merged in, but `fetch` always stays pracht's handler; export request handling
belongs in API routes or middleware instead.

### WebSockets

A WebSocket upgrade is request handling, so it belongs in an **API route** —
not in `workerHandlersFrom`. Pracht recognises protocol-switch responses
(`101 Switching Protocols`, or any response carrying a `webSocket` handle) and
returns them to the runtime untouched: no security headers, no cache headers,
no reconstruction. That last part is the whole trick — copying the response
would drop the `webSocket` property, since it is a Cloudflare extension to
`ResponseInit` rather than part of the fetch standard.

The socket itself has to be owned by something that outlives the request, which
on Cloudflare means a **Durable Object**. Export it via
[`workerExportsFrom`](#exporting-cloudflare-primitives-workflows-durable-objects-etc)
and have the API route forward the upgrade to it:

```typescript
// src/api/ws.ts
import type { BaseRouteArgs } from "@pracht/core";

export async function GET({ context, request, url }: BaseRouteArgs) {
if (request.headers.get("upgrade") !== "websocket") {
return new Response("Expected a WebSocket upgrade", { status: 426 });
}

const { CHAT_ROOM } = context.env as { CHAT_ROOM: DurableObjectNamespace };
const room = url.searchParams.get("room") ?? "lobby";
// The 101 comes straight back out — pracht passes it through unchanged.
return CHAT_ROOM.get(CHAT_ROOM.idFromName(room)).fetch(request);
}
```

```typescript
// src/workers/chat-room.ts
import { DurableObject } from "cloudflare:workers";

export class ChatRoom extends DurableObject {
override async fetch(request: Request): Promise<Response> {
const { 0: client, 1: server } = new WebSocketPair();
// Hibernation-aware accept: an idle room is evicted from memory without
// dropping its sockets.
this.ctx.acceptWebSocket(server);
return new Response(null, { status: 101, webSocket: client });
}

override webSocketMessage(ws: WebSocket, message: string | ArrayBuffer) {
for (const peer of this.ctx.getWebSockets()) peer.send(String(message));
}
}
```

Bind and register the class in `wrangler.jsonc` as with any Durable Object:

```jsonc
{
"durable_objects": {
"bindings": [{ "name": "CHAT_ROOM", "class_name": "ChatRoom" }],
},
"migrations": [{ "tag": "v1", "new_classes": ["ChatRoom"] }],
}
```

Upgrades work in `pracht dev` the same way they do in production — the
Cloudflare adapter owns the dev server, so requests are served by workerd
rather than by pracht's Node dev handler.

`examples/cloudflare` has the full version of both files.

> **Cross-origin upgrades are blocked by default.** Browsers do not apply CORS
> to WebSocket: without a check, any page on the web could open a socket to
> your app and the user's cookies would ride along (cross-site WebSocket
> hijacking). Pracht therefore applies its `api.requireSameOrigin` check to
> upgrade requests as well as to unsafe methods. Requests with no browser
> provenance headers at all (CLIs, server-to-server) still pass, as with
> mutations.
>
> Two things pracht cannot do for you: **authenticate** the connection (do it
> in API middleware or in the handler before forwarding — the handshake is a
> normal request and carries cookies), and **authorize each message** once the
> socket is open, which is entirely the Durable Object's business.

The 30-second `signal` an API route receives covers the handshake, not the
connection: forwarding to a Durable Object returns immediately, and the socket's
lifetime is then the object's to manage. Do not hold the socket in the API
route itself.

### Using Cloudflare bindings in dev

The adapter handles everything — just declare bindings in `wrangler.jsonc`:
Expand Down
50 changes: 50 additions & 0 deletions docs/REQUEST_FLOWS.md
Original file line number Diff line number Diff line change
Expand Up @@ -463,6 +463,56 @@ request arrives

---

## WebSocket upgrade (Cloudflare)

An upgrade request is matched and routed like any other API request, but its
response leaves the pipeline by a different door: pracht detects a
protocol-switch response and returns the **same object** the handler produced,
skipping the header and cache post-processing every other response goes through.

```
── GET /api/ws (Upgrade: websocket) ──►
├─► adapter: skip ISG lookup + assets binding
│ a handshake has no static counterpart, and the assets Fetcher
│ can never satisfy an upgrade
├─► match API route
├─► same-origin check ◄── applies here even though GET is a "safe"
│ method: browsers do not apply CORS to WebSocket
│ └─ cross-origin ─► 403 Cross-origin WebSocket upgrade blocked
├─► API middleware chain (authenticate here — the handshake carries cookies)
└─► handler forwards the request to a Durable Object
DO calls ctx.acceptWebSocket(server)
DO returns new Response(null, { status: 101, webSocket: client })
isProtocolSwitchResponse(response) === true
├─ skip withDefaultSecurityHeaders (would throw on status 101,
│ and drop `webSocket`)
└─ skip preventHeuristicCaching (nothing to cache)
◄── 101 Switching Protocols (same Response object, webSocket intact) ──
```

**Why identity matters:** `webSocket` is a Cloudflare extension to
`ResponseInit`, not part of the fetch standard, so
`new Response(body, { status, headers })` silently discards it — and the
Response constructor rejects any status below 200 outright. A copied handshake
is not a degraded handshake; it is a socket nobody holds.

The Node and Vercel adapters cannot serve upgrades at all. On Node this is
structural: `http.Server` delivers upgrades to its `upgrade` event rather than
to the request handler, so they never reach pracht. See
[ADAPTERS.md](./ADAPTERS.md#websockets) for the `ws`-alongside-pracht pattern.

---

## Module loading during navigation

```
Expand Down
7 changes: 6 additions & 1 deletion e2e/cloudflare-build.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,12 +80,17 @@ test("pracht build emits a deployable Cloudflare Worker setup", async () => {

// Cloudflare primitives configured via `workerExportsFrom` must be re-exported
expect(workerSource).toContain("Counter");
// The Durable Object that owns the example's WebSocket connections. Its
// handshake response has to survive the whole worker bundle — see
// src/api/ws.ts.
expect(workerSource).toContain("ChatRoom");
expect(workerSource).toContain("acceptWebSocket");

// The deploy entry re-exports only the default handler and entrypoint
// classes: workerd rejects non-handler named exports (buildTarget,
// manifests, ...) on the deployed entry module.
const deploySource = readFileSync(deployEntryPath, "utf-8");
expect(deploySource).toContain('export { Counter } from "./server.js";');
expect(deploySource).toContain('export { ChatRoom, Counter } from "./server.js";');
expect(deploySource).toContain('export { default } from "./server.js";');
expect(deploySource).not.toContain("buildTarget");
expect(deploySource).not.toContain("cssManifest");
Expand Down
26 changes: 26 additions & 0 deletions examples/cloudflare/src/api/ws.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import type { BaseRouteArgs } from "@pracht/core";

/**
* WebSocket upgrades are served by API routes. The handshake itself is
* produced by the `ChatRoom` Durable Object (see `src/workers/chat-room.ts`),
* which owns the socket for as long as it stays open; this handler only routes
* the request to the right room and hands the `101` back unchanged.
*
* Pracht passes protocol-switch responses through its response pipeline
* untouched, so nothing here needs to opt out of security headers or caching.
*
* Note that pracht blocks cross-origin upgrade requests by default — browsers
* do not apply CORS to WebSocket, so without that check any page on the web
* could open an authenticated socket to this room. Set
* `api: { requireSameOrigin: false }` in `defineApp` only if you intend this
* endpoint to be reachable from other origins, and authenticate it yourself.
*/
export async function GET({ context, request, url }: BaseRouteArgs): Promise<Response> {
if (request.headers.get("upgrade") !== "websocket") {
return new Response("Expected a WebSocket upgrade", { status: 426 });
}

const { CHAT_ROOM } = context.env as { CHAT_ROOM: DurableObjectNamespace };
const room = url.searchParams.get("room") ?? "lobby";
return CHAT_ROOM.get(CHAT_ROOM.idFromName(room)).fetch(request);
}
1 change: 1 addition & 0 deletions examples/cloudflare/src/cloudflare.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export { ChatRoom } from "./workers/chat-room.ts";
export { Counter } from "./workers/counter.ts";
43 changes: 43 additions & 0 deletions examples/cloudflare/src/workers/chat-room.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { DurableObject } from "cloudflare:workers";

/**
* A chat room whose members are WebSocket connections.
*
* Durable Objects are the reason WebSockets work on Cloudflare at all: a
* Worker isolate is per-request, so something has to outlive the request and
* own the socket. The room is reachable through pracht's API route in
* `src/api/ws.ts`, which forwards the upgrade request here and returns the
* handshake response untouched.
*
* Uses the hibernation API (`ctx.acceptWebSocket`) rather than
* `server.accept()`, so an idle room is evicted from memory without dropping
* its connections and is re-instantiated when the next message arrives.
*/
export class ChatRoom extends DurableObject<unknown> {
override async fetch(request: Request): Promise<Response> {
if (request.headers.get("upgrade") !== "websocket") {
return new Response("Expected a WebSocket upgrade", { status: 426 });
}

const { 0: client, 1: server } = new WebSocketPair();
this.ctx.acceptWebSocket(server);

// This response has to reach the client as *this object*. Copying it —
// `new Response(response.body, { status, headers })` — both trips the
// Response constructor's 200..599 status check and silently drops the
// `webSocket` handle, leaving a socket nobody is holding.
return new Response(null, { status: 101, webSocket: client });
}

override webSocketMessage(ws: WebSocket, message: string | ArrayBuffer): void {
const text = typeof message === "string" ? message : "<binary>";
for (const peer of this.ctx.getWebSockets()) {
peer.send(JSON.stringify({ from: peer === ws ? "you" : "peer", text }));
}
}

override webSocketClose(ws: WebSocket, code: number, reason: string): void {
// 1005 ("no status received") is not a valid code to send back.
ws.close(code === 1005 ? 1000 : code, reason);
}
}
55 changes: 54 additions & 1 deletion examples/cloudflare/src/workers/cloudflare-workers.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,58 @@ declare module "cloudflare:workers" {
export class WorkerEntrypoint {
fetch?(request: Request): Response | Promise<Response>;
}
export class DurableObject {}
export class DurableObject<TEnv = unknown> {
constructor(ctx: DurableObjectState, env: TEnv);
protected ctx: DurableObjectState;
protected env: TEnv;
fetch?(request: Request): Response | Promise<Response>;
webSocketMessage?(ws: WebSocket, message: string | ArrayBuffer): void | Promise<void>;
webSocketClose?(ws: WebSocket, code: number, reason: string, wasClean: boolean): void;
}
}

interface DurableObjectId {
toString(): string;
}

interface DurableObjectStub {
fetch(request: Request): Promise<Response>;
}

interface DurableObjectNamespace {
idFromName(name: string): DurableObjectId;
get(id: DurableObjectId): DurableObjectStub;
}

interface DurableObjectState {
/**
* Hibernation-aware accept: the runtime holds the socket open while the
* object is evicted from memory, and re-instantiates it to deliver events.
*/
acceptWebSocket(ws: WebSocket, tags?: string[]): void;
getWebSockets(tag?: string): WebSocket[];
}

/**
* workerd's WebSocket carries server-side methods the DOM lib does not
* declare (the DOM type only models the client end of a connection).
*/
interface WebSocket {
accept(): void;
}

/** `new WebSocketPair()` yields `{ 0: client, 1: server }`. */
declare class WebSocketPair {
0: WebSocket;
1: WebSocket;
}

/**
* workerd extends `ResponseInit` with `webSocket` to return the server end of
* a handshake. It is deliberately absent from the fetch standard, which is
* exactly why a 101 response cannot be copied — see `isProtocolSwitchResponse`
* in @pracht/core/server.
*/
interface ResponseInit {
webSocket?: WebSocket | null;
}
Loading