Skip to content

WebSockets

Juice's router handles HTTP: (Request) => Response. WebSocket connections upgrade from HTTP but live outside the request-response cycle. This page shows how to compose WebSockets with Juice on each runtime.

The Key Insight

createRouter returns a function. Your server runtime calls that function for HTTP requests. WebSocket upgrades happen alongside the router, not inside it. Each runtime has its own WebSocket API, so the integration pattern differs per platform.

Bun

Bun's Bun.serve() accepts both fetch and websocket options. The fetch handler checks for upgrade requests and the websocket handler manages connections:

import { createRouter } from '@cmj/juice/runtime';
import manifest from './flight-manifest.json';

const router = createRouter(manifest, { root: import.meta.url });

Bun.serve({
  fetch(req, server) {
    // Check for WebSocket upgrade
    if (req.headers.get('upgrade') === 'websocket') {
      const url = new URL(req.url);
      // Attach data to the socket (e.g., room ID from the URL)
      const success = server.upgrade(req, {
        data: { room: url.searchParams.get('room') ?? 'default' },
      });
      if (success) return; // Bun returns undefined on upgrade
      return new Response('WebSocket upgrade failed', { status: 400 });
    }

    // All other requests go through the Juice router
    return router(req);
  },
  websocket: {
    open(ws) {
      // Subscribe to a pub/sub topic based on the room
      ws.subscribe(ws.data.room);
      ws.send(JSON.stringify({ type: 'connected', room: ws.data.room }));
    },
    message(ws, message) {
      // Broadcast to everyone in the room
      ws.publish(ws.data.room, message);
    },
    close(ws) {
      ws.unsubscribe(ws.data.room);
    },
  },
});

Bun's built-in pub/sub (ws.subscribe, ws.publish) is efficient for multi-room scenarios without external dependencies.

Deno

Deno uses Deno.upgradeWebSocket() inside the fetch handler. The upgrade returns a Response to send back and a WebSocket object to attach event handlers to:

import { createRouter } from '@cmj/juice/runtime';
import manifest from './flight-manifest.json';

const router = createRouter(manifest, { root: import.meta.url });

Deno.serve((req) => {
  if (req.headers.get('upgrade') === 'websocket') {
    const { socket, response } = Deno.upgradeWebSocket(req);

    socket.onopen = () => {
      socket.send(JSON.stringify({ type: 'connected' }));
    };
    socket.onmessage = (event) => {
      socket.send(event.data); // Echo
    };
    socket.onclose = () => {
      // Cleanup
    };

    return response;
  }

  return router(req);
});

Cloudflare Workers

Standard Workers do not support long-lived WebSocket connections. Use Durable Objects for WebSocket coordination on Cloudflare. The Worker routes the upgrade request to a Durable Object, which manages the connection:

// server.ts — Worker entry
import { createRouter } from '@cmj/juice/runtime';
import manifest from './flight-manifest.json';

const router = createRouter(manifest, { root: import.meta.url });

export default {
  fetch(req: Request, env: Env) {
    if (req.headers.get('upgrade') === 'websocket') {
      const url = new URL(req.url);
      const room = url.searchParams.get('room') ?? 'default';
      // Route to a Durable Object by room ID
      const id = env.CHAT_ROOM.idFromName(room);
      const stub = env.CHAT_ROOM.get(id);
      return stub.fetch(req);
    }

    return router(req);
  },
};
// chat-room.ts — Durable Object
export class ChatRoom {
  connections = new Set<WebSocket>();

  async fetch(req: Request) {
    const pair = new WebSocketPair();
    const [client, server] = Object.values(pair);

    server.accept();
    this.connections.add(server);

    server.addEventListener('message', (event) => {
      for (const conn of this.connections) {
        if (conn !== server) conn.send(event.data);
      }
    });

    server.addEventListener('close', () => {
      this.connections.delete(server);
    });

    return new Response(null, { status: 101, webSocket: client });
  }
}

Node.js

Node.js does not have a built-in WebSocket server. Use a library like wsalongside the HTTP server that hosts your Juice router:

import { createServer } from 'node:http';
import { WebSocketServer } from 'ws';
import { createRouter } from '@cmj/juice/runtime';
import manifest from './flight-manifest.json';

const router = createRouter(manifest, { root: import.meta.url });

const server = createServer(async (nodeReq, nodeRes) => {
  // Convert Node.js request to standard Request
  const url = new URL(nodeReq.url, `http://${nodeReq.headers.host}`);
  const req = new Request(url, {
    method: nodeReq.method,
    headers: nodeReq.headers,
  });

  const res = await router(req);

  nodeRes.writeHead(res.status, Object.fromEntries(res.headers));
  const body = await res.text();
  nodeRes.end(body);
});

// Attach WebSocket server to the same HTTP server
const wss = new WebSocketServer({ server });

wss.on('connection', (ws) => {
  ws.on('message', (data) => {
    ws.send(data); // Echo
  });
});

server.listen(3000);

Sharing Auth Between HTTP and WebSocket

WebSocket connections start as an HTTP upgrade request. Validate authentication during the upgrade, not after the connection is established:

// Bun example — validate during upgrade
fetch(req, server) {
  if (req.headers.get('upgrade') === 'websocket') {
    const token = req.headers.get('Authorization')?.replace('Bearer ', '');
    const user = await validateToken(token);

    if (!user) {
      return new Response('Unauthorized', { status: 401 });
    }

    server.upgrade(req, { data: { userId: user.id } });
    return;
  }

  return router(req);
}

The data attached during upgrade is available on ws.datain all WebSocket event handlers. This avoids re-authenticating on every message.

When NOT to Use WebSockets

  • Infrequent updates. If data changes every few seconds or slower, use polling or Server-Sent Events (SSE). WebSockets add complexity for little benefit when updates are sparse.
  • One-way server-to-client. Server-Sent Events (SSE) are simpler for push-only patterns (notifications, live feeds). WebSockets are for bidirectional communication.
  • Cloudflare without Durable Objects. Standard Workers cannot hold long-lived connections. If you need WebSockets on Cloudflare, you need Durable Objects (which have their own pricing and complexity).