@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 +60 -0
- package/package.json +27 -0
- package/src/SignalProvider.tsx +180 -0
- package/src/index.ts +5 -0
- package/src/useSignal.ts +57 -0
- package/tsconfig.json +6 -0
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
package/src/useSignal.ts
ADDED
|
@@ -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
|
+
}
|