@scenarix/jaduspine-sdk¶
Internal realtime messaging SDK wrapping Centrifugo for WebSocket and HTTP communication.
Installation¶
Note: This is a private package hosted on GitHub Packages.
1. Configure npm registry¶
Create or update .npmrc in your project root:
@scenarix:registry=https://npm.pkg.github.com
//npm.pkg.github.com/:_authToken=${GITHUB_PKG_TOKEN}
2. Set up authentication¶
Create a GitHub Personal Access Token with read:packages scope:
export GITHUB_PKG_TOKEN=ghp_xxxxxxxxxxxx
3. Install¶
npm install @scenarix/jaduspine-sdk
# or
yarn add @scenarix/jaduspine-sdk
# or
pnpm add @scenarix/jaduspine-sdk
Frontend Integration¶
React Provider & Hook¶
The SDK provides a React context for sharing a single WebSocket connection across your app.
1. Wrap your app with the provider:
// app/providers.tsx
import {
JaduSpineProvider,
FrontendChannels,
} from "@scenarix/jaduspine-sdk/react";
export function Providers({
children,
token,
}: {
children: React.ReactNode;
token: string;
}) {
return (
<JaduSpineProvider
wsUrl={process.env.NEXT_PUBLIC_CENTRIFUGO_WS_URL!}
token={token}
frontend={FrontendChannels.MINIMATICS}
debug={process.env.NODE_ENV === "development"}
>
{children}
</JaduSpineProvider>
);
}
2. Use the hook in any component:
import { useEffect } from "react";
import { useJaduSpine, ConnectionState } from "@scenarix/jaduspine-sdk/react";
function NotificationListener() {
const { connectionState, onMessage, userId } = useJaduSpine();
useEffect(() => {
const unsubscribe = onMessage((msg) => {
console.log("Received:", msg.payload);
// Handle message based on msg.type
});
return unsubscribe;
}, [onMessage]);
return (
<div>
{connectionState === ConnectionState.CONNECTED
? `Connected as ${userId}`
: "Connecting..."}
</div>
);
}
Provider Props¶
| Prop | Type | Required | Default | Description |
|---|---|---|---|---|
wsUrl |
string |
Yes | - | Centrifugo WebSocket URL |
token |
string |
Yes | - | JWT token (must contain sub claim) |
frontend |
FrontendChannels |
Yes | - | Frontend app identifier |
debug |
boolean |
No | false |
Enable debug logging |
autoConnect |
boolean |
No | true |
Auto-connect on mount |
onTokenExpiry |
() => Promise<string> |
No | - | Called when the token is expired or within 30s of expiry. Return the new token. |
onError |
(event) => void |
No | - | Called for any ERROR or AUTH_ERROR event, and on provider init failures. |
Hook Return Value¶
const {
connectionState, // ConnectionState: DISCONNECTED | CONNECTING | CONNECTED
userId, // string | null - extracted from JWT
clientId, // string | null - unique client ID
connect, // () => void - manual connect
disconnect, // () => void - disconnect
reconnect, // () => void - reconnect after RECONNECTED_STALE (clears offset tracking)
destroy, // () => void - cleanup
onMessage, // (handler) => unsubscribe - listen for messages
onConnectionStateChange, // (handler) => unsubscribe - listen for state changes
onEvent, // (handler) => unsubscribe - listen for all events
} = useJaduSpine();
Backend Integration¶
Singleton Pattern (Recommended)¶
Initialize once at server startup, then use JaduSpineBackend.publish() from anywhere in your codebase.
1. Initialize at server bootstrap:
// server/index.ts or app initialization
import { JaduSpineBackend } from "@scenarix/jaduspine-sdk";
await JaduSpineBackend.init({
apiUrl: process.env.CENTRIFUGO_API_URL!,
apiKey: process.env.CENTRIFUGO_API_KEY!,
debug: process.env.NODE_ENV === "development",
});
// Throws if health check fails (server unreachable or invalid API key)
2. Use anywhere in your codebase:
// services/notifications.ts
import {
JaduSpineBackend,
FrontendChannel,
FrontendChannels,
} from "@scenarix/jaduspine-sdk";
export async function notifyUser(userId: string, message: string) {
await JaduSpineBackend.publish(
new FrontendChannel(FrontendChannels.MINIMATICS, userId),
{ type: "notification", message }
);
}
// api/jobs/complete.ts
import {
JaduSpineBackend,
FrontendChannel,
FrontendChannels,
} from "@scenarix/jaduspine-sdk";
export async function handleJobComplete(userId: string, jobId: string) {
await JaduSpineBackend.publish(
new FrontendChannel(FrontendChannels.STUDIO, userId),
{ type: "job:complete", jobId, timestamp: Date.now() }
);
}
Init Configuration¶
| Option | Type | Required | Default | Description |
|---|---|---|---|---|
apiUrl |
string |
Yes | - | Centrifugo HTTP API URL |
apiKey |
string |
No | "" |
API key for authentication |
clientId |
string |
No | Auto-generated | Custom client identifier |
debug |
boolean |
No | false |
Enable debug logging |
skipHealthCheck |
boolean |
No | false |
Skip server health check on init |
retryAttempts |
number |
No | 0 |
Number of retry attempts on publish fail |
retryDelay |
number |
No | 1000 |
Delay between retries (ms) |
Static Methods¶
// Initialize (call once)
await JaduSpineBackend.init(config);
// Check if initialized
JaduSpineBackend.isInitialized(); // boolean
// Publish a message to a channel
await JaduSpineBackend.publish(channel, message);
// Get underlying JaduSpineHttp instance
JaduSpineBackend.getInstance();
// Destroy singleton (for reinit or shutdown)
JaduSpineBackend.destroy();
Channel Classes¶
import {
FrontendChannel,
FrontendChannels,
BackendChannel,
BackendChannels,
CustomChannel,
} from "@scenarix/jaduspine-sdk";
// User-scoped frontend channel: "frontend:minimatics#user123"
const userChannel = new FrontendChannel(FrontendChannels.MINIMATICS, "user123");
console.log(userChannel.value); // "frontend:minimatics#user123"
// Backend channel: "backend:studio"
const studioChannel = new BackendChannel(BackendChannels.STUDIO);
console.log(studioChannel.value); // "backend:studio"
// Custom named channel: "custom:proj_abc123"
const projectChannel = new CustomChannel("proj_abc123");
console.log(projectChannel.value); // "custom:proj_abc123"
Available Channels¶
// Frontend (user-scoped)
FrontendChannels.MINIMATICS; // "minimatics"
FrontendChannels.STORYDESK; // "storydesk"
FrontendChannels.STUDIO; // "studio"
FrontendChannels.PLAYGROUND; // "playground"
// Backend (service-to-service)
BackendChannels.STUDIO; // "studio"
BackendChannels.STORYDESK; // "storydesk"
BackendChannels.PLAYGROUND; // "playground"
// Custom (broadcast / group / project-scoped)
new CustomChannel("any-name-you-choose"); // "custom:any-name-you-choose"
Custom Channels¶
CustomChannel enables broadcasting to any named group — project members, shared rooms, global feeds — without user-scoping.
Backend: publish to a custom channel
import { JaduSpineBackend, CustomChannel } from "@scenarix/jaduspine-sdk";
await JaduSpineBackend.publish(
new CustomChannel("proj_abc123"),
{ topic: "project:update", data: { action: "scene_saved", userId } }
);
Frontend: subscribe via useChannel
import { useChannel, CustomChannel } from "@scenarix/jaduspine-sdk/react";
function ProjectPanel({ projectId }: { projectId: string }) {
const [events, setEvents] = useState<ProjectEvent[]>([]);
useChannel<ProjectEvent>(
new CustomChannel(projectId),
(msg) => setEvents((prev) => [...prev, msg.data])
);
return <EventFeed events={events} />;
}
Multiple components subscribing to the same channel share one underlying WebSocket subscription. The subscription is cleaned up when the last component unmounts.
Centrifugo configuration
The custom namespace must be enabled in your Centrifugo config:
{
"namespaces": [
{
"name": "custom",
"allow_subscribe_for_client": true
}
]
}
Error Handling¶
All events — full reference¶
Every event fired by onEvent() is typed. The table below lists each event, when it fires, and what fields it carries.
| Event | When it fires | Key fields |
|---|---|---|
DISCONNECTED |
Connection dropped (any reason) | code: number, reason: string |
UNSUBSCRIBED |
Channel subscription dropped | channel, code: number, reason: string |
ERROR |
Transport, connect, or subscription error | error, kind, centrifugoType?, transport?, channel?, code? |
AUTH_ERROR |
Auth failed and all retries exhausted | error, code?, reason? |
AUTH_RETRYING |
Each auth retry attempt before giving up | attemptNumber, retryInMs, elapsedMs |
RECONNECTED_STALE |
Gap in history after reconnect — missed messages unrecoverable | channels, missedPerChannel, totalMissed, offlineDurationMs |
DISCONNECTED¶
Fires every time the WebSocket connection closes, whether it's a network drop, server restart, or auth rejection. Check code to distinguish the cause:
code |
Meaning |
|---|---|
0 |
Intentional disconnect (disconnect() called client-side) |
1 |
Unauthorized — server rejected the connection |
3500 |
Invalid token — Centrifugo rejected the JWT |
3501 |
Expired token — JWT exp has passed |
| other | Network drop or transport close |
onEvent((event) => {
if (event.type === EventType.DISCONNECTED) {
console.log(`Disconnected: code=${event.code} reason="${event.reason}"`);
// code=3501 → token expired → show re-login prompt
// code=0 → intentional → no action needed
}
});
ERROR¶
Fires for transport failures, connect errors, and subscription errors. All raw Centrifugo context is passed through:
onEvent((event) => {
if (event.type === EventType.ERROR) {
console.error(`[${event.kind}] ${event.error.message}`, {
centrifugoType: event.centrifugoType, // e.g. "transport", "refresh"
transport: event.transport, // e.g. "websocket"
channel: event.channel, // set for subscription errors
code: event.code, // raw numeric Centrifugo error code
});
}
});
kind values:
- "transport" — WebSocket-level failure (connection closed, write error)
- "connect" — error during the connect handshake
- "subscription" — error on a specific channel subscription
- "unknown" — error that didn't match a more specific category
AUTH_ERROR¶
Fires when authentication has definitively failed — either the retry window (5 minutes) was exhausted, or no getToken callback was provided and the token was rejected. At this point the SDK stops retrying and the consumer must intervene (e.g. redirect to login).
onEvent((event) => {
if (event.type === EventType.AUTH_ERROR) {
console.error("Auth failed:", event.error.message, {
code: event.code, // raw Centrifugo disconnect code (e.g. 3501 = expired)
reason: event.reason, // raw reason string from server
});
// redirect to login, clear session, etc.
}
});
AUTH_RETRYING¶
Fires on each retry attempt while waiting for a valid token. Useful for showing a "reconnecting…" UI state or logging retry telemetry:
onEvent((event) => {
if (event.type === EventType.AUTH_RETRYING) {
console.warn(
`Auth retry #${event.attemptNumber} — ` +
`next attempt in ${event.retryInMs / 1000}s, ` +
`${Math.round(event.elapsedMs / 1000)}s elapsed`
);
}
});
The SDK retries every 10 seconds for up to 5 minutes. If getToken is set on the provider, it is called before each retry so a fresh token can be supplied automatically.
RECONNECTED_STALE¶
Fires when the client reconnects after an extended offline period and the Centrifugo history window no longer covers the gap. The SDK disconnects automatically after emitting this event — you must re-fetch your application state from the API and then call reconnect() to resume.
onEvent((event) => {
if (event.type === EventType.RECONNECTED_STALE) {
console.warn("Missed messages — re-fetching state", {
channels: event.channels, // which channels had a gap
missedPerChannel: event.missedPerChannel, // { "frontend:app#user": 42 }
totalMissed: event.totalMissed, // total across all channels
offlineDurationMs: event.offlineDurationMs,
});
fetchLatestStateFromAPI().then(() => {
reconnect(); // clears offset tracking, fresh connect
});
}
});
If no gap is detected, missed messages are replayed in order before CONNECTED fires — no action needed.
Gap detection does not depend on the server's history_size. Centrifugo returns its current latest offset alongside the recovered publications, so the SDK knows exactly how many events were missed and compares that against how many it actually received. If any are unaccounted for — evicted from the server's buffer — the channel is reported stale. You can change history_size server-side at any time without releasing a new SDK version.
The SDK requests the entire available history on reconnect and replays all of it. The server's history_size is the only bound on how much is recoverable, so there is no SDK-side limit to keep in sync with it.
Using onError (provider shortcut)¶
onError on JaduSpineProvider is a convenience that receives both ERROR and AUTH_ERROR events plus provider-level SpineError exceptions (e.g. invalid JWT on startup):
import { SpineError, SpineErrorCode, EventType } from "@scenarix/jaduspine-sdk/react";
<JaduSpineProvider
wsUrl={WS_URL}
token={token}
frontend={FrontendChannels.MINIMATICS}
onError={(e) => {
if (e instanceof SpineError) {
// Provider init failure — e.g. JWT missing `sub` claim
console.error("Init error:", e.code, e.message);
} else if (e.type === EventType.AUTH_ERROR) {
console.error("Auth permanently failed:", e.error.message, {
code: e.code,
reason: e.reason,
});
} else {
// EventType.ERROR
console.error(`[${e.kind}] ${e.error.message}`, {
centrifugoType: e.centrifugoType,
transport: e.transport,
channel: e.channel,
code: e.code,
});
}
}}
>
For access to DISCONNECTED, AUTH_RETRYING, and RECONNECTED_STALE, use onEvent directly in a component:
const { onEvent } = useJaduSpine();
useEffect(() => {
return onEvent((event) => {
switch (event.type) {
case EventType.DISCONNECTED:
// event.code, event.reason
break;
case EventType.AUTH_RETRYING:
// event.attemptNumber, event.retryInMs, event.elapsedMs
break;
case EventType.RECONNECTED_STALE:
// event.totalMissed, event.missedPerChannel, event.offlineDurationMs
break;
}
});
}, [onEvent]);
Backend errors¶
import { SpineError, SpineErrorCode } from "@scenarix/jaduspine-sdk";
try {
await JaduSpineBackend.publish(channel, data);
} catch (error) {
if (error instanceof SpineError) {
switch (error.code) {
case SpineErrorCode.HTTP_UNAUTHORIZED:
console.error("Invalid API key");
break;
case SpineErrorCode.HTTP_REQUEST_FAILED:
console.error("Request failed:", error.message);
break;
case SpineErrorCode.INVALID_STATE:
console.error("Not initialized — call JaduSpineBackend.init() first");
break;
}
}
}
Publishing¶
Ensure your
GITHUB_PKG_TOKENhaswrite:packagesscope (not justread:packages).
# 1. Run pre-publish checks (build, typecheck, test)
npm run prepublish:check
# 2. Bump version in package.json
# 3. Publish to GitHub Packages
npm publish
# Or dry-run first
npm run publish:dry
Available Scripts¶
| Script | Description |
|---|---|
npm run build |
Build the SDK (CJS + ESM + types) |
npm run typecheck |
Run TypeScript type checking |
npm run test |
Run tests |
npm run prepublish:check |
Clean, build, typecheck, and test |
npm run publish:dry |
Dry-run publish to verify package |
Documentation¶
- AUTH_README.md - JWT authentication setup and token generation
- CONTRIBUTING.md - Development setup and playground instructions
- infra/DEPLOYMENT.md - AWS deployment playbook (ECS/Fargate + ALB + Cloudflare DNS + Terraform)
- CHANGELOG.md - Version history and release notes
- Specification - Architecture and design decisions