The Signal Service provides realtime backend-to-frontend communication via WebSockets. It enables plugins to push typed events to connected clients, replacing polling mechanisms with instant updates.
graph LR
subgraph Backend
P[Plugin] --> SS[SignalService]
SS --> EB[EventBus]
EB --> WSH[WebSocket Handler]
end
subgraph Frontend
WSH --- WS((WebSocket))
WS --- SP[SignalProvider]
SP --> US[useSignal Hook]
US --> C[Component]
end
| Package | Purpose |
|---|---|
@checkstack/signal-common |
Shared types, Signal interface, createSignal() factory |
@checkstack/signal-backend |
SignalServiceImpl, WebSocket handler for Bun |
@checkstack/signal-frontend |
React SignalProvider and useSignal() hook |
Signals are defined in -common packages using createSignal():
import { createSignal } from "@checkstack/signal-common";
import { z } from "zod";
export const NOTIFICATION_RECEIVED = createSignal(
"notification.received",
z.object({
id: z.string(),
title: z.string(),
description: z.string(),
importance: z.enum(["info", "warning", "critical"]),
})
);
Use dot-notation: {domain}.{action}
Examples:
notification.receivednotification.countChangedhealthcheck.statusChangedsystem.maintenanceScheduledThe SignalService is available via coreServices:
import { coreServices } from "@checkstack/backend-api";
// In a plugin init
const signalService = await services.get(coreServices.signalService);
import { SYSTEM_MAINTENANCE } from "@checkstack/system-common";
// All connected clients receive this
await signalService.broadcast(SYSTEM_MAINTENANCE, {
message: "System maintenance in 5 minutes",
scheduledAt: new Date().toISOString(),
});
import { NOTIFICATION_RECEIVED } from "@checkstack/notification-common";
// Only this user's connections receive it
await signalService.sendToUser(NOTIFICATION_RECEIVED, userId, {
id: notificationId,
title: "New message",
description: "You have a new notification",
importance: "info",
});
await signalService.sendToUsers(NOTIFICATION_RECEIVED, [user1, user2], {
id: notificationId,
title: "Team alert",
description: "Important update for the team",
importance: "warning",
});
For sensitive signals that should only reach users with specific access rules:
sequenceDiagram
participant Plugin as Backend Plugin
participant Signal as SignalService
participant Auth as AuthApi (RPC)
participant WS as WebSocket
Plugin->>Signal: sendToAuthorizedUsers(signal, userIds, payload, accessRule)
Signal->>Auth: filterUsersByAccessRule(userIds, accessRule)
Auth-->>Signal: authorizedUserIds[]
Signal->>WS: sendToUsers(signal, authorizedUserIds, payload)
import { pluginMetadata, access, HEALTH_STATE_CHANGED } from "@checkstack/healthcheck-common";
// Only users with the access rule receive the signal
await signalService.sendToAuthorizedUsers(
HEALTH_STATE_CHANGED,
subscriberUserIds,
{ systemId, newState: "degraded" },
pluginMetadata, // Typed PluginMetadata from common package
access.healthcheckStatusRead
);
Note: This method uses S2S RPC to filter users via the
authplugin’sfilterUsersByAccessRuleendpoint. Users with theadminrole receive the signal because all access rules are synced to the admin role.
The SignalProvider wraps the application and manages the WebSocket connection:
// App.tsx
import { SignalProvider } from "@checkstack/signal-frontend";
function App() {
return (
<SignalProvider backendUrl={import.meta.env.VITE_API_BASE_URL}>
<YourApp />
</SignalProvider>
);
}
Subscribe to signals in any component:
import { useSignal } from "@checkstack/signal-frontend";
import { NOTIFICATION_RECEIVED } from "@checkstack/notification-common";
import { useCallback } from "react";
function NotificationBell() {
const [count, setCount] = useState(0);
useSignal(
NOTIFICATION_RECEIVED,
useCallback((payload) => {
// payload is fully typed!
console.log("New notification:", payload.title);
setCount((prev) => prev + 1);
}, [])
);
return <Badge count={count} />;
}
Important: Wrap the callback in
useCallbackto avoid unnecessary re-subscriptions.
Check connection status:
import { useSignalConnection } from "@checkstack/signal-frontend";
function ConnectionIndicator() {
const { isConnected } = useSignalConnection();
return (
<div className={isConnected ? "text-green-500" : "text-red-500"}>
{isConnected ? "Connected" : "Disconnected"}
</div>
);
}
ws://{backend-url}/api/signals/ws
| Type | Description |
|---|---|
connected |
Connection confirmed, includes userId if authenticated |
signal |
Signal payload with signalId, payload, timestamp |
pong |
Response to client ping |
error |
Error message |
| Type | Description |
|---|---|
ping |
Keepalive ping |
// Server: Connection confirmed
{ "type": "connected", "userId": "user-123" }
// Server: Signal received
{
"type": "signal",
"signalId": "notification.received",
"payload": { "id": "n-1", "title": "Hello" },
"timestamp": "2024-01-15T12:00:00Z"
}
The Signal Service uses the EventBus for coordination across backend instances:
graph TB
subgraph "Backend Instance 1"
P1[Plugin] --> SS1[SignalService]
SS1 --> EB1[EventBus]
WSH1[WebSocket Handler] --> C1[Clients]
end
subgraph "Backend Instance 2"
WSH2[WebSocket Handler] --> C2[Clients]
EB2[EventBus]
end
EB1 <--> Q[(Queue/Redis)]
Q <--> EB2
EB2 --> WSH2
When a signal is emitted:
SignalService.sendToUser() emits to EventBusBun’s native pub/sub is used for efficient routing:
| Channel | Pattern | Purpose |
|---|---|---|
signals:broadcast |
All clients subscribe | System-wide announcements |
signals:user:{userId} |
Per-user subscription | Private notifications |
Keep signal definitions in the -common package so both backend and frontend can import them:
plugins/notification-common/src/signals.ts # Define here
plugins/notification-backend/src/service.ts # Emit here
plugins/notification-frontend/src/... # Consume here
Prefer multiple specific signals over one generic signal:
// ✅ Good
const NOTIFICATION_RECEIVED = createSignal("notification.received", ...);
const NOTIFICATION_READ = createSignal("notification.read", ...);
// ❌ Avoid
const NOTIFICATION_EVENT = createSignal("notification.event", z.object({
type: z.enum(["received", "read", ...]),
...
}));
The SignalProvider auto-reconnects, but components should handle the gap:
function NotificationBell() {
const { isConnected } = useSignalConnection();
// Refetch on reconnection to catch missed signals
useEffect(() => {
if (isConnected) {
refetchNotifications();
}
}, [isConnected]);
}
Use void to emit signals without blocking:
// Don't await if the caller doesn't need to wait
if (signalService) {
void signalService.sendToUser(NOTIFICATION_RECEIVED, userId, payload);
}
No additional configuration required. The WebSocket server runs on the same port as HTTP.
Set VITE_API_BASE_URL environment variable:
VITE_API_BASE_URL=http://localhost:3000
Set log level to debug to see signal emissions:
DEBUG Sending signal notification.received to user user-123
DEBUG Relayed signal notification.received to user user-123
Check WebSocket connection in browser DevTools:
/api/signals/wsTo migrate an existing polling-based component:
useSignal in the frontenduseEffect(() => {
const interval = setInterval(fetchCount, 60000);
return () => clearInterval(interval);
}, []);
useSignal(NOTIFICATION_COUNT_CHANGED, useCallback((payload) => {
setCount(payload.unreadCount);
}, []));
// Create a typed signal
function createSignal<T>(id: string, payloadSchema: z.ZodType<T>): Signal<T>
// Signal interface
interface Signal<T> {
id: string;
payloadSchema: z.ZodType<T>;
}
// SignalService interface
interface SignalService {
broadcast<T>(signal: Signal<T>, payload: T): Promise<void>;
sendToUser<T>(signal: Signal<T>, userId: string, payload: T): Promise<void>;
sendToUsers<T>(signal: Signal<T>, userIds: string[], payload: T): Promise<void>;
sendToAuthorizedUsers<T>(
signal: Signal<T>,
userIds: string[],
payload: T,
pluginMetadata: PluginMetadata,
accessRule: AccessRule
): Promise<void>;
}
// Provider component
function SignalProvider(props: {
children: React.ReactNode;
backendUrl?: string;
}): JSX.Element;
// Subscribe to a signal
function useSignal<T>(
signal: Signal<T>,
callback: (payload: T) => void
): void;
// Get connection status
function useSignalConnection(): { isConnected: boolean };