Quickstart
Install the JavaScript SDK from npm. Send your first message between two browser tabs.
Install the Celeris packages from npm. Add a credential endpoint to a small Node application. Connect a browser with the client SDK. Open the page in two tabs. Send a message from one tab. The message arrives in the other tab.
Your example application 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.
A channel connection automatically joins the default segment of the channel. 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 application dedicated to development.
In Manage Apps, select your development application. Open API Keys. Get the application's 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 that you can focus on messaging. Keep this endpoint on your computer. Use only development credentials. Before you give other users access, replace the fixed identity with verified sessions and server-side authorization, as Authentication shows. This restriction applies to the sample endpoint and does not limit where the SDK can run.
Step 1: create the project
Create a new application. Then 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 messages. It does not need your signing secret. Vite serves and bundles the browser application during development.
private=true prevents accidental publication of your example application to npm. It does not change how you install or use the Celeris packages.
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. Replace the two placeholder values:
CELERIS_CLIENT_ID=your-client-id
CELERIS_SIGNING_SECRET=your-signing-secret
Create .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:
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. The Origin header check helps restrict browser requests, but it is not authentication. Callers outside a browser can set that header themselves. Loopback binding and a dedicated test application 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:
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. Vite forwards /api requests 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:
<!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:
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();
}
});
The example registers listeners and subscription interest before the connection starts. This file never imports the signing secret. The code inserts received text with textContent and does not interpret it as HTML.
chat.subscribe() is necessary because chat is an additional named segment. It does not replace the automatic membership in default. Messages published to chat stay in chat. The server does not also send them 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 application. 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, start the credential server from the project root:
npm run server
In a second terminal, start the development server 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.
- Click Connect in both tabs. Both tabs show
connected. - Wait until both tabs show
connected. Then sendHello from tab onefrom the first tab. - The first tab shows
Sent locally (not a delivery receipt). The second tab showsdemo-user: Hello from tab one. - Reply from the second tab. Make sure that the first tab receives the message.
- Click Close connection in one tab. Its state becomes
closed, and the page disables the Send button. Reload that tab to create a new channel.
allowEcho: false means that a connection does not receive its own messages. The example page adds the local "Sent" entry. The entry is not a server acknowledgment. Both demo tabs show the same identity but have separate connections.
Step 7: understand interruptions
The SDK does not retry a failed first connection automatically. Check your setup. Then click Connect again from the failed state.
When an established connection drops, the SDK attempts recovery. While the channel is reconnecting, a message that you send waits in the in-memory queue. The SDK sends it after the reconnection succeeds. If recovery ends in failed, the queued messages reject. The SDK does not send again a message that it already gave to the socket. This demo deliberately signs replay: false, so it does not request missed history.
Read Reconnection and Recovery before you rely on replay or restore important application state.
Solve common problems
| Symptom | Check |
|---|---|
| Vite says the port is in use | Stop the other demo process. This example requires port 5173. |
| Credential request fails | Make sure that both processes run. Inspect the request status in browser developer tools. Do not share its credentials. |
| Credential endpoint returns 403 | Use http://127.0.0.1:5173 and channel demo-room. |
Connection fails with Transport | Check network access, the client ID, and the signing secret. Make sure that both values belong to the same application. The SDK cannot identify handshake authentication failures. |
| Connected but no received messages | Connect both tabs before you send. Make sure that both tabs use chat with read permission. |
| No copy of your own message arrives | This is the expected result of allowEcho: false. |
Send rejects with NotConnected | Wait until the state is connected. A publish waits in the queue only while the channel is reconnecting. |
| A server error arrives after you send | Publish completion is local acceptance. Read the type and message of the error. |
The SDK sanitizes provider failures into safe errors. The browser network panel and your server's safe diagnostics help you identify endpoint failures. Do not log or share signed credentials.
Next steps
- Authentication: Replace the demo identity with real authorization.
- JavaScript SDK: Continue through messaging, payloads, presence, and server-side usage.
- Reconnection and Recovery: Decide how your application recovers its data.