@checkstack/signal-frontend 0.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,60 @@
1
+ # @checkstack/signal-frontend
2
+
3
+ ## 0.0.2
4
+
5
+ ### Patch Changes
6
+
7
+ - d20d274: Initial release of all @checkstack packages. Rebranded from Checkmate to Checkstack with new npm organization @checkstack and domain checkstack.dev.
8
+ - Updated dependencies [d20d274]
9
+ - @checkstack/signal-common@0.0.2
10
+
11
+ ## 0.1.1
12
+
13
+ ### Patch Changes
14
+
15
+ - @checkstack/signal-common@0.1.1
16
+
17
+ ## 0.1.0
18
+
19
+ ### Minor Changes
20
+
21
+ - b55fae6: Added realtime Signal Service for backend-to-frontend push notifications via WebSockets.
22
+
23
+ ## New Packages
24
+
25
+ - **@checkstack/signal-common**: Shared types including `Signal`, `SignalService`, `createSignal()`, and WebSocket protocol messages
26
+ - **@checkstack/signal-backend**: `SignalServiceImpl` with EventBus integration and Bun WebSocket handler using native pub/sub
27
+ - **@checkstack/signal-frontend**: React `SignalProvider` and `useSignal()` hook for consuming typed signals
28
+
29
+ ## Changes
30
+
31
+ - **@checkstack/backend-api**: Added `coreServices.signalService` reference for plugins to emit signals
32
+ - **@checkstack/backend**: Integrated WebSocket server at `/api/signals/ws` with session-based authentication
33
+
34
+ ## Usage
35
+
36
+ Backend plugins can emit signals:
37
+
38
+ ```typescript
39
+ import { coreServices } from "@checkstack/backend-api";
40
+ import { NOTIFICATION_RECEIVED } from "@checkstack/notification-common";
41
+
42
+ const signalService = context.signalService;
43
+ await signalService.sendToUser(NOTIFICATION_RECEIVED, userId, { ... });
44
+ ```
45
+
46
+ Frontend components subscribe to signals:
47
+
48
+ ```tsx
49
+ import { useSignal } from "@checkstack/signal-frontend";
50
+ import { NOTIFICATION_RECEIVED } from "@checkstack/notification-common";
51
+
52
+ useSignal(NOTIFICATION_RECEIVED, (payload) => {
53
+ // Handle realtime notification
54
+ });
55
+ ```
56
+
57
+ ### Patch Changes
58
+
59
+ - Updated dependencies [b55fae6]
60
+ - @checkstack/signal-common@0.1.0
package/package.json ADDED
@@ -0,0 +1,27 @@
1
+ {
2
+ "name": "@checkstack/signal-frontend",
3
+ "version": "0.0.2",
4
+ "type": "module",
5
+ "exports": {
6
+ ".": {
7
+ "import": "./src/index.ts"
8
+ }
9
+ },
10
+ "peerDependencies": {
11
+ "react": "^18.0.0"
12
+ },
13
+ "dependencies": {
14
+ "@checkstack/signal-common": "workspace:*"
15
+ },
16
+ "devDependencies": {
17
+ "@types/react": "^18.0.0",
18
+ "typescript": "^5.7.2",
19
+ "@checkstack/tsconfig": "workspace:*",
20
+ "@checkstack/scripts": "workspace:*"
21
+ },
22
+ "scripts": {
23
+ "typecheck": "tsc --noEmit",
24
+ "lint": "bun run lint:code",
25
+ "lint:code": "eslint . --max-warnings 0"
26
+ }
27
+ }
@@ -0,0 +1,180 @@
1
+ import {
2
+ createContext,
3
+ useContext,
4
+ useEffect,
5
+ useRef,
6
+ useState,
7
+ useCallback,
8
+ } from "react";
9
+ import type {
10
+ Signal,
11
+ ServerToClientMessage,
12
+ } from "@checkstack/signal-common";
13
+
14
+ // =============================================================================
15
+ // CONTEXT TYPES
16
+ // =============================================================================
17
+
18
+ interface SignalContextValue {
19
+ /** Whether the WebSocket connection is established */
20
+ isConnected: boolean;
21
+ /** Subscribe to a signal. Returns an unsubscribe function. */
22
+ subscribe<T>(signal: Signal<T>, callback: (payload: T) => void): () => void;
23
+ }
24
+
25
+ const SignalContext = createContext<SignalContextValue | undefined>(undefined);
26
+
27
+ // =============================================================================
28
+ // SIGNAL PROVIDER
29
+ // =============================================================================
30
+
31
+ interface SignalProviderProps {
32
+ children: React.ReactNode;
33
+ /** Backend URL (defaults to VITE_BACKEND_URL environment variable) */
34
+ backendUrl?: string;
35
+ }
36
+
37
+ /**
38
+ * Provider component that manages the WebSocket connection for signals.
39
+ *
40
+ * Should be rendered inside AuthProvider, and only when a user is authenticated.
41
+ *
42
+ * @example
43
+ * ```tsx
44
+ * // In your app layout (only render when authenticated)
45
+ * {user && (
46
+ * <SignalProvider>
47
+ * <AuthenticatedContent />
48
+ * </SignalProvider>
49
+ * )}
50
+ * ```
51
+ */
52
+ export const SignalProvider: React.FC<SignalProviderProps> = ({
53
+ children,
54
+ backendUrl,
55
+ }) => {
56
+ const [isConnected, setIsConnected] = useState(false);
57
+ const wsRef = useRef<WebSocket | undefined>(undefined);
58
+ const listenersRef = useRef<Map<string, Set<(payload: unknown) => void>>>(
59
+ new Map()
60
+ );
61
+ const reconnectTimeoutRef = useRef<ReturnType<typeof setTimeout> | undefined>(
62
+ undefined
63
+ );
64
+ const reconnectAttemptsRef = useRef(0);
65
+
66
+ useEffect(() => {
67
+ // Determine WebSocket URL - use provided backendUrl or VITE_API_BASE_URL
68
+ const baseUrl = backendUrl ?? import.meta.env.VITE_API_BASE_URL;
69
+
70
+ if (!baseUrl) {
71
+ console.warn(
72
+ "SignalProvider: No backend URL configured. WebSocket connection disabled."
73
+ );
74
+ return;
75
+ }
76
+
77
+ const wsUrl = baseUrl.replace(/^http/, "ws") + "/api/signals/ws";
78
+
79
+ const connect = () => {
80
+ if (wsRef.current?.readyState === WebSocket.OPEN) return;
81
+
82
+ const ws = new WebSocket(wsUrl);
83
+ wsRef.current = ws;
84
+
85
+ ws.addEventListener("open", () => {
86
+ setIsConnected(true);
87
+ reconnectAttemptsRef.current = 0;
88
+ });
89
+
90
+ ws.addEventListener("close", () => {
91
+ setIsConnected(false);
92
+
93
+ // Reconnect with exponential backoff
94
+ const delay = Math.min(
95
+ 1000 * Math.pow(2, reconnectAttemptsRef.current),
96
+ 30_000
97
+ );
98
+ reconnectAttemptsRef.current++;
99
+
100
+ reconnectTimeoutRef.current = setTimeout(connect, delay);
101
+ });
102
+
103
+ ws.addEventListener("error", (event) => {
104
+ console.error("SignalProvider: WebSocket error", event);
105
+ });
106
+
107
+ ws.addEventListener("message", (event: MessageEvent<string>) => {
108
+ try {
109
+ const message: ServerToClientMessage = JSON.parse(event.data);
110
+
111
+ if (message.type === "signal") {
112
+ const listeners = listenersRef.current.get(message.signalId);
113
+ if (listeners) {
114
+ for (const callback of listeners) {
115
+ callback(message.payload);
116
+ }
117
+ }
118
+ }
119
+ // Ignore "connected" and "pong" messages
120
+ } catch (error) {
121
+ console.error("SignalProvider: Failed to parse message", error);
122
+ }
123
+ });
124
+ };
125
+
126
+ connect();
127
+
128
+ return () => {
129
+ if (reconnectTimeoutRef.current) {
130
+ clearTimeout(reconnectTimeoutRef.current);
131
+ }
132
+ wsRef.current?.close();
133
+ };
134
+ }, [backendUrl]);
135
+
136
+ const subscribe = useCallback(
137
+ <T,>(signal: Signal<T>, callback: (payload: T) => void) => {
138
+ const signalId = signal.id;
139
+
140
+ if (!listenersRef.current.has(signalId)) {
141
+ listenersRef.current.set(signalId, new Set());
142
+ }
143
+ listenersRef.current
144
+ .get(signalId)!
145
+ .add(callback as (payload: unknown) => void);
146
+
147
+ // Return unsubscribe function
148
+ return () => {
149
+ listenersRef.current
150
+ .get(signalId)
151
+ ?.delete(callback as (payload: unknown) => void);
152
+ };
153
+ },
154
+ []
155
+ );
156
+
157
+ const value: SignalContextValue = {
158
+ isConnected,
159
+ subscribe,
160
+ };
161
+
162
+ return (
163
+ <SignalContext.Provider value={value}>{children}</SignalContext.Provider>
164
+ );
165
+ };
166
+
167
+ // =============================================================================
168
+ // CONTEXT HOOK
169
+ // =============================================================================
170
+
171
+ /**
172
+ * Access the SignalContext. Must be used within a SignalProvider.
173
+ */
174
+ export const useSignalContext = () => {
175
+ const context = useContext(SignalContext);
176
+ if (!context) {
177
+ throw new Error("useSignalContext must be used within a SignalProvider");
178
+ }
179
+ return context;
180
+ };
package/src/index.ts ADDED
@@ -0,0 +1,5 @@
1
+ // Provider component
2
+ export { SignalProvider, useSignalContext } from "./SignalProvider";
3
+
4
+ // Hooks
5
+ export { useSignal, useSignalConnection } from "./useSignal";
@@ -0,0 +1,57 @@
1
+ import { useEffect, useCallback } from "react";
2
+ import type { Signal } from "@checkstack/signal-common";
3
+ import { useSignalContext } from "./SignalProvider";
4
+
5
+ /**
6
+ * Subscribe to a signal and receive typed payloads.
7
+ *
8
+ * The callback will be invoked whenever the signal is received.
9
+ * Subscriptions are automatically cleaned up on unmount.
10
+ *
11
+ * @example
12
+ * ```tsx
13
+ * import { NOTIFICATION_RECEIVED } from "@checkstack/notification-common";
14
+ *
15
+ * function NotificationHandler() {
16
+ * useSignal(NOTIFICATION_RECEIVED, (payload) => {
17
+ * console.log("New notification:", payload.title);
18
+ * });
19
+ *
20
+ * return null;
21
+ * }
22
+ * ```
23
+ */
24
+ export function useSignal<T>(
25
+ signal: Signal<T>,
26
+ callback: (payload: T) => void
27
+ ): void {
28
+ const { subscribe } = useSignalContext();
29
+
30
+ // Memoize callback to prevent unnecessary resubscriptions
31
+ const stableCallback = useCallback(callback, [callback]);
32
+
33
+ useEffect(() => {
34
+ return subscribe(signal, stableCallback);
35
+ }, [signal, stableCallback, subscribe]);
36
+ }
37
+
38
+ /**
39
+ * Get the WebSocket connection status.
40
+ *
41
+ * @example
42
+ * ```tsx
43
+ * function ConnectionIndicator() {
44
+ * const { isConnected } = useSignalConnection();
45
+ *
46
+ * return (
47
+ * <div className={isConnected ? "text-green-500" : "text-red-500"}>
48
+ * {isConnected ? "Connected" : "Disconnected"}
49
+ * </div>
50
+ * );
51
+ * }
52
+ * ```
53
+ */
54
+ export function useSignalConnection(): { isConnected: boolean } {
55
+ const { isConnected } = useSignalContext();
56
+ return { isConnected };
57
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "extends": "@checkstack/tsconfig/frontend.json",
3
+ "include": [
4
+ "src"
5
+ ]
6
+ }