Skip to content

@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

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_TOKEN has write:packages scope (not just read: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