Documentation

Quickstart

Install the JavaScript SDK from npm and send your first message between two browser tabs.

Install the Celeris packages from npm, add a credential endpoint to a small Node app, and connect a browser using the client SDK. Open the page in two tabs, send a message from one, and see it arrive in the other.

Your example app runs on your computer; the SDK connects to Celeris. You do not need to clone an SDK repository, build the packages, or run a Celeris server locally.

A channel is a connection to a named communication space. A segment is a named stream inside it. This demo uses channel demo-room and segment chat.

Connecting to a channel automatically joins its default segment. You can access it with channel.defaultSegment(), without a separate subscribe() call. This walkthrough uses an additional segment, chat, to show explicit subscriptions. The connection still joins default, but the example's credentials grant messaging access only to chat.

Before you start

You need:

  • Node.js 24.15 or newer in the 24.x line, with npm.
  • A current browser and two terminal windows.
  • A Celeris app dedicated to development.

In Manage Apps, select your development app. Open API Keys and obtain its client ID and signing secret. The signing secret is different from the client secret.

About this example: the credential endpoint uses a fixed demo-user identity so you can focus on messaging. Keep this endpoint on your computer and use development credentials. Before exposing it to other users, replace the fixed identity with verified sessions and server-side authorization as shown in Authentication. This restriction is about the sample endpoint, not where the SDK can run.

Step 1: create the project

Create a new application and install the SDK packages from npm:

mkdir celeris-first-app
cd celeris-first-app
npm init -y
npm install @useceleris/client @useceleris/server
npm install --save-dev vite
npm pkg set type=module
npm pkg set private=true --json
npm pkg set "scripts.server=node --env-file=.env server.mjs" "scripts.dev=vite"
mkdir web

The server package signs credentials; import it only in trusted server code. The client package connects, sends, and receives without knowing your signing secret. Vite serves and bundles the browser app during development.

private=true prevents accidentally publishing your example app to npm. It does not change how the Celeris packages are installed or used.

Your project will contain:

celeris-first-app/
  .env
  .gitignore
  package.json
  package-lock.json
  server.mjs
  vite.config.mjs
  web/
    index.html
    app.js

Step 2: configure the server's credentials

Create .env in the project root, replacing the two placeholders:

.env
CELERIS_CLIENT_ID=your-client-id
CELERIS_SIGNING_SECRET=your-signing-secret

Create .gitignore:

.gitignore
.env
node_modules/
dist/

The environment file stays outside web/. Never put these values in browser JavaScript, HTML, a VITE_ environment variable, a URL you share, or logs.

Step 3: add the credential endpoint

Create server.mjs in the project root:

server.mjs
import { createServer } from "node:http";
import { createSigner } from "@useceleris/server";

const { CELERIS_CLIENT_ID, CELERIS_SIGNING_SECRET } = process.env;
if (!CELERIS_CLIENT_ID || !CELERIS_SIGNING_SECRET) {
  throw new Error("Set both Celeris values in the server .env file.");
}

const signer = createSigner({
  clientId: CELERIS_CLIENT_ID,
  signingSecret: CELERIS_SIGNING_SECRET,
});

function sendJson(response, status, value) {
  response.writeHead(status, {
    "content-type": "application/json",
    "cache-control": "no-store",
  });
  response.end(JSON.stringify(value));
}

const server = createServer(async (request, response) => {
  if (request.url !== "/api/realtime-credentials") {
    sendJson(response, 404, { error: "Not found." });
    return;
  }
  if (request.method !== "POST") {
    sendJson(response, 405, { error: "Use POST." });
    return;
  }
  if (
    request.headers.origin !== "http://127.0.0.1:5173" ||
    request.headers["content-type"]?.split(";")[0] !== "application/json"
  ) {
    sendJson(response, 403, { error: "Use the local demo page." });
    return;
  }

  try {
    const chunks = [];
    let size = 0;
    for await (const chunk of request) {
      size += chunk.length;
      if (size > 2048) {
        sendJson(response, 413, { error: "Request too large." });
        return;
      }
      chunks.push(chunk);
    }

    let body;
    try {
      body = JSON.parse(Buffer.concat(chunks).toString("utf8"));
    } catch {
      sendJson(response, 400, { error: "Send valid JSON." });
      return;
    }

    if (body?.channelReference !== "demo-room") {
      sendJson(response, 403, { error: "Only demo-room is allowed." });
      return;
    }

    // Demo identity and permissions are fixed here, never taken from the body.
    const credentials = signer.sign({
      channels: { kind: "restricted", references: ["demo-room"] },
      permissions: {
        kind: "restricted",
        segments: [{ segmentId: "chat", read: true, write: true }],
      },
      reference: "demo-user",
      replay: false,
      allowEcho: false,
    });
    sendJson(response, 200, credentials);
  } catch {
    if (!response.headersSent && !response.destroyed) {
      sendJson(response, 500, { error: "Could not issue credentials." });
    }
  }
});

server.listen(4000, "127.0.0.1", () => {
  console.log("Demo credential server: http://127.0.0.1:4000");
});

The server signs only access to demo-room / chat. Checking an Origin header helps restrict browser requests, but is not authentication: non-browser callers can set that header themselves. Loopback binding and a dedicated test app are essential for this demo.

This Node process is your application's credential endpoint, not the Celeris realtime server.

Step 4: configure the browser development server

Create vite.config.mjs in the project root:

vite.config.mjs
import { defineConfig } from "vite";

export default defineConfig({
  root: "web",
  server: {
    host: "127.0.0.1",
    port: 5173,
    strictPort: true,
    proxy: {
      "/api": {
        target: "http://127.0.0.1:4000",
        changeOrigin: true,
      },
    },
  },
});

Vite serves the browser page and bundles its SDK import. Requests to /api are forwarded to Node, so the browser can use a relative URL without a separate cross-origin setup.

Step 5: create the browser page

Create web/index.html:

web/index.html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>My first Celeris app</title>
  </head>
  <body>
    <main>
      <h1>Celeris messages</h1>
      <p role="status">Connection: <span id="status">idle</span></p>
      <button id="connect" type="button">Connect</button>
      <button id="close" type="button">Close connection</button>
      <form id="message-form">
        <label for="message">Message</label>
        <input id="message" maxlength="500" required />
        <button id="send" type="submit" disabled>Send</button>
      </form>
      <h2>Activity</h2>
      <ul id="messages" aria-live="polite"></ul>
    </main>
    <script type="module" src="/app.js"></script>
  </body>
</html>

Create web/app.js:

web/app.js
import {
  createClient,
  readText,
  textPayload,
  ServerError,
} from "@useceleris/client";

const status = document.querySelector("#status");
const connectButton = document.querySelector("#connect");
const closeButton = document.querySelector("#close");
const sendButton = document.querySelector("#send");
const form = document.querySelector("#message-form");
const messageInput = document.querySelector("#message");
const messages = document.querySelector("#messages");

function log(message) {
  const item = document.createElement("li");
  item.textContent = message;
  messages.append(item);
  if (messages.children.length > 100) messages.firstElementChild.remove();
}

function reportError(error) {
  const category =
    error instanceof ServerError ? error.type : (error.code ?? "Error");
  log(category + ": " + error.message);
}

const client = createClient({
  credentialProvider: async (request) => {
    const response = await fetch("/api/realtime-credentials", {
      method: "POST",
      headers: { "content-type": "application/json" },
      body: JSON.stringify({ channelReference: request.channelReference }),
      signal: request.signal,
    });
    if (!response.ok) {
      throw new Error("Credential endpoint returned " + response.status);
    }
    return response.json();
  },
});

const channel = client.channel("demo-room");
const chat = channel.segment("chat");
const events = channel.events();

const stopState = events.onStateChange((state) => {
  status.textContent = state;
  sendButton.disabled = state !== "connected";
  connectButton.disabled = state !== "idle" && state !== "failed";
});
const stopErrors = events.onError(reportError);
const stopRecovery = events.onRecovery(() => {
  log("Reconnected. Messages during the outage may be missing.");
});
const stopMessages = chat.onMessage((payload, metadata) => {
  log(metadata.tokenReference + ": " + readText(payload));
});
const membership = chat.subscribe();

connectButton.addEventListener("click", async () => {
  try {
    await channel.connect();
  } catch (error) {
    reportError(error);
  }
});

form.addEventListener("submit", async (event) => {
  event.preventDefault();
  const message = messageInput.value;
  if (!message.trim()) return;
  try {
    await chat.publish({ payload: textPayload(message) });
    log("Sent locally (not a delivery receipt): " + message);
    messageInput.value = "";
  } catch (error) {
    reportError(error);
  }
});

closeButton.addEventListener("click", async () => {
  closeButton.disabled = true;
  try {
    await channel.close();
  } finally {
    membership.cancel();
    stopMessages();
    stopRecovery();
    stopErrors();
    stopState();
  }
});

Listeners and subscription interest are registered before connecting. The signing secret is never imported into this file. Received text is inserted with textContent, not interpreted as HTML.

chat.subscribe() is needed because chat is an additional named segment. It does not replace the automatic membership in default. Messages published to chat stay in chat; they are not also broadcast to default. For a simpler single-stream setup, see the default-segment example.

The client uses Celeris's hosted realtime endpoint by default. /api/realtime-credentials belongs to your Node app; it only supplies signed access credentials. You do not need to set baseUrl for this walkthrough.

Step 6: run both processes

In the first terminal, from the project root:

npm run server

In a second terminal, from the same directory:

npm run dev

Open http://127.0.0.1:5173 in two tabs. Use that exact host, not localhost, because the demo endpoint checks the origin.

  1. Click Connect in both tabs. Both should show connected.
  2. Wait until both are connected, then send Hello from tab one in the first.
  3. The first shows Sent locally (not a delivery receipt); the second shows demo-user: Hello from tab one.
  4. Reply from the second tab and verify that the first receives it.
  5. Click Close connection in one tab. Its state becomes closed and sending is disabled. Reload that tab to create a new channel.

allowEcho: false means a connection does not receive its own publish. The local "Sent" entry is added by this example's UI; it is not a server acknowledgement. Both demo tabs share a display identity but have separate connections.

Step 7: understand interruptions

A failed first connection is not retried automatically. Check your setup and click Connect again from failed.

Once a working connection drops, the SDK attempts recovery. Sending is disabled while disconnected; messages are not queued or resent. This demo deliberately signs replay: false, so it does not request missed history.

Read Reconnection and Recovery before relying on replay or restoring important application state.

Troubleshooting

SymptomCheck
Vite says the port is in useStop the other demo process; this example requires port 5173
Credential request failsBoth processes must run; inspect the request status in browser developer tools, without sharing its credentials
Credential endpoint returns 403Use http://127.0.0.1:5173 and channel demo-room
Connection fails with TransportCheck network access, client ID, signing secret, and that both belong to the same app; the SDK cannot identify handshake auth failures
Connected but no received messagesConnect both tabs before sending; confirm both use chat with read permission
No copy of your own message arrivesExpected with allowEcho: false
Send rejects with NotConnectedWait until connected; there is no offline queue
A server error arrives after sendingPublish completion is local acceptance; read the error's type and message

The SDK sanitizes provider failures into safe errors. The browser network panel and your server's safe diagnostics help distinguish endpoint failures. Do not log or share signed credentials.

Next steps

  1. Authentication: replace the demo identity with real authorization.
  2. JavaScript SDK: continue through messaging, payloads, presence, and server-side usage.
  3. Reconnection and Recovery: decide how your application recovers its data.

We use Google Analytics cookies to understand how people use Celeris, only if you allow it. See our Cookie Policy.