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,
  });
  // 본인 알림방: 브라우저가 원하는 User ID/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';

// 서버의 권한 검사가 끝난 업무 처리 코드에서 호출합니다.
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);
// 업무 DB 변경을 함께 처리한다면 event의 ID·Body·room.generation을 outbox에 Save하고
// Success할 때까지 같은 값으로 재시도합니다. DB Save Success만으로 발행 Success이라고 하지 않습니다.

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';
// 위 import 파일은 고정 버전 SDK를 내려받아 자신의 사이트에서 제공하세요.
async function getToken() {
  const response = await fetch('/api/realtime/token', { method: 'POST', credentials: 'same-origin' });
  if (!response.ok) throw new Error('로그인과 권한을 확인하세요.');
  return response.json();
}
const first = await getToken();
const client = new RealtimeClient({
  room: first.room,
  getToken, // 재연결·갱신 때마다 서버가 현재 세션을 다시 검사합니다.
  onEvent(event) {
    if (event.type !== 'notification.created') return;
    const element = document.querySelector('#last-notification');
    if (!element) throw new Error('알림 표시 요소가 필요합니다.');
    element.textContent = String(event.data.notification_id);
    // Success적으로 적용한 뒤에만 SDK가 순번을 전진시킵니다.
  },
  onState(state) { document.querySelector('#connection-state').textContent = state; },
  onError() { console.warn('실시간 연결 상태를 확인하세요.'); },
});
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
// 서버 SDK 예제. realtime과 room은 인증된 서버 코드에서 준비합니다.
async function readPage(cursor) {
  return realtime.history(room, {
    limit: '50', search: '알림', ...(cursor ? { cursor } : {}),
  });
}
const page = await readPage(); // 앱 API에서는 이 한 페이지를 응답합니다.
// 더 보기 요청에는 page.cursor를 전달합니다. null이면 탐색 완료입니다.
// 검색어를 바꾸면 cursor도 초기화하세요. 메시지 원문·토큰은 로그에 남기지 않습니다.

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
// 별도 ES 모듈. 이 목록은 업무 DB 목록이 아닌 실시간 이벤트 원문 목록입니다.
// HTML: <ul id="event-list"></ul>
import { RealtimeClient } from '/sdk/realtime/browser.js';
const items = new Map();
const deleted = new Set(); // 조회 중 도착한 Delete가 늦은 응답으로 되살아나지 않게 합니다.
const list = document.querySelector('#event-list');
if (!list) throw new Error('event-list 요소가 필요합니다.');
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');
  return response.json();
}
const first = await getToken();
const client = new RealtimeClient({ room: first.room, getToken,
  onEvent: merge, onDelete: remove,
  onError() { console.warn('실시간 복구 상태를 확인하세요.'); },
});
client.start(); // 새 목록이므로 기본 순번 0부터 SDK가 이력을 복구합니다.
window.addEventListener('pagehide', () => client.stop());
// 별도 이력 페이지를 표시할 때도 page.events.forEach(merge)로 같은 상태에 병합합니다.
// 대량 이력 서비스는 아래 Description대로 페이지 단위 목록과 DB 원본 재조회를 사용하세요.

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
// 인증·작성자 권한·입력 검증이 끝난 서버 업무 처리 코드의 예입니다.
// 기존 메시지 ID로 본문을 바꾸면 Edit이 아니라 충돌입니다.
const updateEvent = {
  id: messageId(),
  type: 'message.updated', // 앱이 정한 이벤트 이름이며 플랫폼의 Edit 명령이 아닙니다.
  data: { entity_id: entityId, version: nextVersion },
};
// 업무 DB Edit과 updateEvent의 ID·Body·room 세대가 담긴 outbox를
// 같은 트랜잭션에 Save하고, outbox 처리기가 아래 발행을 재시도합니다.
await realtime.publish(room, updateEvent);
// 수신 화면은 entity_id로 권한이 적용된 앱 조회 API에서 최신 원본을 다시 읽습니다.
// version보다 오래된 조회 결과가 도착하면 현재 화면을 덮어쓰지 않습니다.

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.