Guides

Live updates (WebSockets)

Devora.js has no built-in WebSockets, and server-sent events from an API route do not work yet. An API route returns one whole response, and Vercel and Netlify functions cannot hold a connection open. Three things work: poll, run a small separate realtime server next to your app, or attach ws to the app's own server with a wrapper script.

This page covers WebSockets (Socket.IO and ws), polling, and why server-sent events are not supported yet.

Polling or a realtime server?

Polling

The page calls an API route every few seconds. Updates arrive up to one interval late. No extra process, and it works on every host the app does. Start here.

A realtime server

A separate Node process keeps a connection open to each browser and pushes events the moment they happen. It needs a host that stays on, and one more thing to deploy.

Reach for the realtime server when a few seconds of delay is not acceptable: chat, presence, a dashboard people watch. Keep the polling fallback either way.

How the pieces fit

  1. A logged-in browser asks a Devora API route for a token. The route requires a session, so without a login it answers 401. The token holds the user id and lives 60 seconds.
  2. The browser connects to the realtime server with that token. The server checks the signature and expiry once, at connect time, and puts the socket in a room for that user.
  3. When data changes, your Devora code calls POST /publish on the realtime server with a secret only the two servers know.
  4. The realtime server emits the event to that user's room: every open tab receives it.

The token is an HMAC-SHA256 over { sub, exp } made with node:crypto; no JWT library. The session cookie never leaves the app.

The token route

// apps/app/endpoints/realtime-token.ts
import { apiRoute } from "@devorajs/core";
import { issueRealtimeToken } from "@devorajs/backend/realtimeToken";

export const methods = ["GET"];

export const handler = apiRoute((_req, ctx) => {
  ctx.requireAuth(); // 401 without a login
  const user = (ctx.session as { username: string }).username;
  const { token, expiresIn } = issueRealtimeToken(user); // 60 seconds
  return {
    status: 200,
    headers: { "Content-Type": "application/json", "Cache-Control": "no-store" },
    body: JSON.stringify({ token, expiresIn, url: process.env.REALTIME_URL }),
  };
});

The realtime server

A plain Node file with socket.io. It verifies the token on connect and emits to one room per user.

// realtime/server.mjs (the part that matters)
io.use((socket, next) => {
  const claims = verifyToken(socket.handshake.auth?.token, tokenSecret); // HMAC + expiry
  if (!claims) return next(new Error("unauthorized"));
  socket.data.userId = claims.sub;
  next();
});
io.on("connection", (socket) => socket.join(`user:${socket.data.userId}`));
// POST /publish: check the shared secret, then
io.to(`user:${message.userId}`).emit(message.event, message.data);

Publishing from the app

// packages/backend/orders.ts — after the data changed
await fetch(`${process.env.REALTIME_URL}/publish`, {
  method: "POST",
  headers: {
    "content-type": "application/json",
    authorization: `Bearer ${process.env.REALTIME_PUBLISH_SECRET}`,
  },
  body: JSON.stringify({ userId: user, event: "order", data: order }),
});

If the publish fails the data is still saved; a client that missed the push catches up on its next poll or reconnect.

The browser, with a polling fallback

// the island: a fresh token on every (re)connect, polling if it can't connect
const socket = io(url, {
  auth: (cb) => fetch("/api/realtime-token").then((r) => r.json()).then((b) => cb({ token: b.token })),
  reconnectionAttempts: 3,
});
socket.on("connect", () => { stopPolling(); setStatus("live"); });
socket.on("order", (order) => setOrders((list) => [...list, order]));
socket.on("connect_error", startPolling);
socket.on("disconnect", startPolling);

The CSP setting

The default Content-Security-Policy is default-src 'self'; style-src 'self' 'unsafe-inline'; object-src 'none'; base-uri 'self'. default-src also covers connect-src, so the browser refuses a connection to another origin. Set security.csp in the app's app.config.ts; it replaces the whole default, so repeat the default directives and add connect-src with both the https: origin (Socket.IO's polling transport) and the wss: origin.

// apps/app/app.config.ts
export default defineApp({
  security: {
    // Replaces the default policy, so repeat its directives and add connect-src.
    csp:
      "default-src 'self'; style-src 'self' 'unsafe-inline'; object-src 'none'; base-uri 'self'; " +
      "connect-src 'self' https://realtime.example.com wss://realtime.example.com",
  },
});

Without it the console shows Connecting to 'http://…/socket.io/…' violates the following Content Security Policy directive: "default-src 'self'" and, with the fallback in place, the page keeps working by polling.

Or: ws on the app's own port

devora start does not expose the HTTP server it creates, and has no hook. But the function it calls, createNodeServer from @devorajs/adapter-node, is a public export that returns the http.Server. A small script can call it and handle the WebSocket upgrade itself, on the same port as the HTTP API. This ran on a local Node server (tested with 0.3.4): a bad token answered 401, a good one connected and echoed a message, and the API and its security headers were unchanged. Not run on Vercel or Netlify (it cannot work there: a function cannot hold a connection).

// Attach `ws` to the HTTP server `devora start` would create, by calling the same function
// the CLI calls (createNodeServer from @devorajs/adapter-node), which returns the http.Server.
// Run from the project root: node --env-file=.env lab-scripts/start-with-ws.mjs
import { loadProjectConfig, loadAppConfig, resolveAppDir } from "@devorajs/core/config-loader";
import { resolveAuthMode } from "@devorajs/core/config";
import { createNodeServer } from "@devorajs/adapter-node";
import { WebSocketServer } from "ws";

process.env.NODE_ENV = "production";
const root = process.cwd();
const project = await loadProjectConfig(root);
const app = project.apps.find((a) => a.name === "lab");
const appRoot = resolveAppDir(root, app.dir);
const cfg = await loadAppConfig(appRoot);
const server = createNodeServer({
  appRoot, appName: app.name, authMode: resolveAuthMode(project, app.name), domain: app.domain,
  security: cfg.security, sitemapEnabled: false, apiDir: cfg.apiDir, trustProxy: cfg.trustProxy,
  head: cfg.head, maxBodyBytes: cfg.maxBodyBytes, cors: cfg.cors, port: Number(process.env.PORT) || 4500,
});

// WebSocket upgrade on /ws, same port as the HTTP API. The upgrade request is a plain HTTP request:
// the session is checked here by calling the app's own GET /api/me over loopback with the same header.
const wss = new WebSocketServer({ noServer: true });
server.on("upgrade", async (req, socket, head) => {
  if (!req.url?.startsWith("/ws")) return socket.destroy();
  const token = new URL(req.url, "http://x").searchParams.get("token");
  const me = await fetch(`http://127.0.0.1:${server.address().port}/api/me`, { headers: { authorization: `Bearer ${token}` } });
  if (me.status !== 200) { socket.write("HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n"); return socket.destroy(); }
  const { session } = await me.json();
  wss.handleUpgrade(req, socket, head, (ws) => { ws.user = session.username; wss.emit("connection", ws, req); });
});
wss.on("connection", (ws) => {
  ws.send(JSON.stringify({ hello: ws.user }));
  ws.on("message", (m) => { for (const c of wss.clients) if (c.readyState === 1) c.send(JSON.stringify({ from: ws.user, text: String(m) })); });
});
  • Install ws and @devorajs/adapter-node, build the app, then run the script instead of devora start (it sets NODE_ENV=production itself, as start does).
  • The script repeats what start does: it loads the project and app config and passes the same options. If start gains options, the script needs them too.
  • The upgrade request does not go through the app's routes. This script checks the session by calling the app's own GET /api/me over loopback with the token; do the same check, or the connection is open to anyone.
  • A server hook (an onServer(server) option) would remove the wrapper. It is planned, not built.

Server-sent events do not work yet

An API route can set Content-Type: text/event-stream, but the handler returns its whole body as a string, and the server sends it in one piece when the handler finishes. Tested: three events produced half a second apart arrived together after 1.5 seconds and the connection closed, so a browser EventSource would reconnect over and over. Returning a stream, an async iterable or a Node Readable as the body answers 500. Use polling or a realtime server until a route can return a stream.

Deploying it

  • The realtime server needs an always-on host: a VPS, a container platform, or an instance that does not sleep. A free tier that spins down drops every connection.
  • Behind nginx, forward the upgrade headers (Upgrade and Connection "upgrade"); devora generate:proxy writes them for the apps it proxies, and the realtime server's location needs the same two lines.
  • One instance is fine to start. Several need a shared adapter (@socket.io/redis-adapter) so a publish that lands on one instance reaches sockets connected to another.
  • Keep the token secret and the publish secret separate, and out of the browser.

What was verified

The runnable example is in the repository under examples/realtime. In CI and locally: the token route answers 401 without a login; the realtime server rejects a missing, tampered, wrongly signed and expired token; a publish with the wrong secret is a 401, and with the right one reaches only that user; an order placed through the API reaches the connected client. In Chromium against a local production build the page went live and received a pushed order in about 35 ms, and with the default CSP it logged the violation above and fell back to polling.

Not verified: a deployment to a cloud host, a reverse proxy in front of the realtime server, several instances with a Redis adapter, or behavior under load.