Docs / Realtime WebSocket v2

Realtime WebSocket v2

Use server-authenticated publishing, user access grants, SDK recovery and a transactional order outbox.

Prepare the key and a pinned SDK

Terminal
joripspace realtime-v2 status --project PROJECT
joripspace realtime-v2 activate --project PROJECT
joripspace realtime-v2 docs

Check status and activate only projects that do not already have a key. The private key is installed as the JORIPSPACE_REALTIME_V2_SIGNING_KEY Secret and must never be copied or printed.

Verify the SHA-256 and size in the 2.0.1 manifest. Vendor the server files under src/vendor/realtime/ and serve browser.js through your own static asset path. Never include the server SDK in a browser bundle.

Issue access only after app authentication

Use the session middleware. The sample grants access only to the signed-in user’s notification room. For a store room, verify membership in the app database before issuing access. Use an opaque internal user ID instead of an email address.

src/routes/realtime.ts
import { Hono } from 'hono';
import { createRealtimeServer } from '../vendor/realtime/server';
import { requireSession } from '../middleware/session';
import type { AppEnv } from '../types';
type RealtimeEnv = AppEnv & { Bindings: AppEnv['Bindings'] & {
  JORIPSPACE_REALTIME_V2_SIGNING_KEY: string;
} };
export const realtimeRoutes = new Hono<RealtimeEnv>();
realtimeRoutes.use('*', requireSession);
realtimeRoutes.post('/token', async (c) => {
  const identity = c.get('identity');
  const realtime = createRealtimeServer({
    origin: new URL(c.req.url).origin,
    signingKey: c.env.JORIPSPACE_REALTIME_V2_SIGNING_KEY,
  });
  // Private notification room: do not let the browser choose a user ID or room.
  const room = await realtime.createRoom('user_' + identity.user_id);
  return c.json({ room: room.name, ...await realtime.accessToken(room, {
    subject: identity.user_id, session: identity.session_hash,
    permissions: ['subscribe', 'history'],
  }) });
});
Add to src/index.ts
import { realtimeRoutes } from './routes/realtime';
app.route('/api/realtime', realtimeRoutes);

Publish from an authenticated server

Publish from a business operation
import { createRealtimeServer, messageId } from './vendor/realtime/server';

// Call this from business logic after server-side authorization succeeds.
const realtime = createRealtimeServer({
  origin: new URL(request.url).origin,
  signingKey: env.JORIPSPACE_REALTIME_V2_SIGNING_KEY,
});
const room = await realtime.getRoom('user_' + userId);
const event = { id: messageId(), type: 'notification.created', data: { notification_id: notificationId } };
await realtime.publish(room, event);
// When changing business data too, store the event ID, body and room.generation in an outbox
// and retry the same values until publishing succeeds. A successful database write alone is not a successful publish.

Create a message ID once and reuse the same ID, content and room generation for retries. Reusing an ID with different content is a conflict. A publish success proves durable realtime storage, not that a customer screen has processed the event.

Connect and recover with the browser SDK

Add elements with id="last-notification" and id="connection-state", then run this module.

Browser realtime-v2.js
import { RealtimeClient } from '/sdk/realtime/browser.js';
// Download the pinned SDK import and serve it from your own site.
async function getToken() {
  const response = await fetch('/api/realtime/token', { method: 'POST', credentials: 'same-origin' });
  if (!response.ok) throw new Error('Check the login session and permissions.');
  return response.json();
}
const first = await getToken();
const client = new RealtimeClient({
  room: first.room,
  getToken, // the server rechecks the current session on every reconnect and refresh
  onEvent(event) {
    if (event.type !== 'notification.created') return;
    const element = document.querySelector('#last-notification');
    if (!element) throw new Error('A notification display element is required.');
    element.textContent = String(event.data.notification_id);
    // The SDK advances the sequence only after the event is applied successfully.
  },
  onState(state) { document.querySelector('#connection-state').textContent = state; },
  onError() { console.warn('Check the realtime connection status.'); },
});
client.start();
window.addEventListener('pagehide', () => client.stop());

The SDK reconnects, refreshes short-lived grants, sends heartbeats and recovers after the last applied sequence. Do not put auth tokens in URLs or localStorage. Sequence values are decimal strings; compare them with BigInt when needed.

Persist screen state together with generation and applied sequence when exact recovery across reloads matters. Call stop() when leaving the page.

History, deletion and access revocation

Authenticated server SDK
// Server SDK example. Create realtime and room in authenticated server code.
async function readPage(cursor) {
  return realtime.history(room, {
    limit: '50', search: 'notification', ...(cursor ? { cursor } : {}),
  });
}
const page = await readPage(); // return this single page from the application API
// Pass page.cursor to a load-more request. A null value means the scan is complete.
// Reset the cursor when the search term changes. Never log raw messages or tokens.

await realtime.deleteMessage(room, messageIdToDelete);
await realtime.revoke(room, { target: 'session', session: sessionHashToRevoke });

The default grant covers subscription and history. Add browser search or deletion scopes only after the app has verified permission. A page can be empty while its cursor still points to another range, so keep history APIs bounded and paginated.

Apply deletion events in screen state. When membership changes, update the app database and call revoke, with retry handling because the two systems are not one transaction.

Combine history and live events in one list

This example deduplicates by ID, compares sequences with BigInt and handles deletion arriving during a history request. It never inserts message content as HTML.

Browser event-list.js
// Separate ES module. This lists raw realtime events, not business-database records.
// HTML: <ul id="event-list"></ul>
import { RealtimeClient } from '/sdk/realtime/browser.js';
const items = new Map();
const deleted = new Set(); // prevent a deletion received during loading from being undone by a late response
const list = document.querySelector('#event-list');
if (!list) throw new Error('An event-list element is required.');
function draw() {
  const rows = [...items.values()].sort((a, b) =>
    BigInt(a.seq) < BigInt(b.seq) ? -1 : BigInt(a.seq) > BigInt(b.seq) ? 1 : 0);
  list.replaceChildren(...rows.map(event => {
    const row = document.createElement('li');
    row.dataset.messageId = event.id;
    row.textContent = event.type + ' ' + JSON.stringify(event.data);
    return row;
  }));
}
function merge(event) {
  if (event.deleted) return remove(event.id);
  if (!deleted.has(event.id)) items.set(event.id, event);
  draw();
}
function remove(id) { deleted.add(id); items.delete(id); draw(); }
async function getToken() {
  const response = await fetch('/api/realtime/token', { method: 'POST', credentials: 'same-origin' });
  if (!response.ok) throw new Error('Failed to issue an access token');
  return response.json();
}
const first = await getToken();
const client = new RealtimeClient({ room: first.room, getToken,
  onEvent: merge, onDelete: remove,
  onError() { console.warn('Check the realtime recovery status.'); },
});
client.start(); // this is a new list, so the SDK recovers history from sequence 0
window.addEventListener('pagehide', () => client.stop());
// Merge a separate history page into the same state with page.events.forEach(merge).
// For large histories, use paginated lists and reload canonical records from the database as described below.

For large histories, render a paginated business-data list first and use realtime changes to refetch affected rows. Reset cursor and list state when the query, room or generation changes, and cap in-memory maps in production.

Edit source data, not immutable events

Realtime messages are immutable. Update the source row and version in the project database, then publish a new change event with a new ID. Store the source update and outbox entry in one database transaction.

Publish an edit event
// Example server business logic after authentication, author authorization and input validation.
// Changing the body under an existing message ID is a conflict, not an edit.
const updateEvent = {
  id: messageId(),
  type: 'message.updated', // application-defined event name, not a platform edit command
  data: { entity_id: entityId, version: nextVersion },
};
// Store the business-data update and an outbox containing updateEvent ID, body and room generation
// in the same transaction; the outbox processor retries the publish below.
await realtime.publish(room, updateEvent);
// The receiving screen reloads the latest canonical record from an authorized application API using entity_id.
// Do not overwrite the current screen when a response older than version arrives.

message.updated is an application-defined event. Receivers must refetch the latest source by entity_id. Enforce login, membership, author or administrator permission, expected version and conflict handling on the source API.

Synchronize deletion across screens

On the authenticated server, call realtime.deleteMessage(room, messageId) only after checking that the item belongs to the user’s room and permission scope. Room access does not prove authorship. Apps that allow author-only deletion should not grant browser delete scope.

Remove a successful deletion immediately on the requesting screen and process onDelete everywhere else. Keep the row or restore it when deletion fails. Deleting a realtime event does not delete the project database record; update source data and its outbox separately.

Handle retries without duplicating work

RealtimeError exposes status, code and retryable. Fix authentication and permission errors instead of retrying them blindly. Use exponential backoff with jitter for retryable failures, and retry a lost response with the original event ID, content and room generation.

Test duplicate clicks, disconnects during changes, reloads, deletion during pagination and unauthorized deletion in two simultaneous windows.

Use a transactional order outbox

The complete examples store an order change and outbox record together, then retry with the same message ID.

Adapt the sample to one existing session scheme instead of mixing its app_session cookie with another guide. Set APP_ORIGIN to the live HTTPS origin, create SESSION_HASH_SALT as a Secret and register /jobs/realtime-outbox as a POST cron job.