@checkstack/signal-backend 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,73 @@
1
+ # @checkstack/signal-backend
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/backend-api@0.0.2
10
+ - @checkstack/common@0.0.2
11
+ - @checkstack/signal-common@0.0.2
12
+
13
+ ## 0.1.1
14
+
15
+ ### Patch Changes
16
+
17
+ - Updated dependencies [b4eb432]
18
+ - Updated dependencies [a65e002]
19
+ - @checkstack/backend-api@1.1.0
20
+ - @checkstack/common@0.2.0
21
+ - @checkstack/signal-common@0.1.1
22
+
23
+ ## 0.1.0
24
+
25
+ ### Minor Changes
26
+
27
+ - b55fae6: Added realtime Signal Service for backend-to-frontend push notifications via WebSockets.
28
+
29
+ ## New Packages
30
+
31
+ - **@checkstack/signal-common**: Shared types including `Signal`, `SignalService`, `createSignal()`, and WebSocket protocol messages
32
+ - **@checkstack/signal-backend**: `SignalServiceImpl` with EventBus integration and Bun WebSocket handler using native pub/sub
33
+ - **@checkstack/signal-frontend**: React `SignalProvider` and `useSignal()` hook for consuming typed signals
34
+
35
+ ## Changes
36
+
37
+ - **@checkstack/backend-api**: Added `coreServices.signalService` reference for plugins to emit signals
38
+ - **@checkstack/backend**: Integrated WebSocket server at `/api/signals/ws` with session-based authentication
39
+
40
+ ## Usage
41
+
42
+ Backend plugins can emit signals:
43
+
44
+ ```typescript
45
+ import { coreServices } from "@checkstack/backend-api";
46
+ import { NOTIFICATION_RECEIVED } from "@checkstack/notification-common";
47
+
48
+ const signalService = context.signalService;
49
+ await signalService.sendToUser(NOTIFICATION_RECEIVED, userId, { ... });
50
+ ```
51
+
52
+ Frontend components subscribe to signals:
53
+
54
+ ```tsx
55
+ import { useSignal } from "@checkstack/signal-frontend";
56
+ import { NOTIFICATION_RECEIVED } from "@checkstack/notification-common";
57
+
58
+ useSignal(NOTIFICATION_RECEIVED, (payload) => {
59
+ // Handle realtime notification
60
+ });
61
+ ```
62
+
63
+ ### Patch Changes
64
+
65
+ - Updated dependencies [ffc28f6]
66
+ - Updated dependencies [71275dd]
67
+ - Updated dependencies [ae19ff6]
68
+ - Updated dependencies [b55fae6]
69
+ - Updated dependencies [b354ab3]
70
+ - Updated dependencies [81f3f85]
71
+ - @checkstack/common@0.1.0
72
+ - @checkstack/backend-api@1.0.0
73
+ - @checkstack/signal-common@0.1.0
package/package.json ADDED
@@ -0,0 +1,27 @@
1
+ {
2
+ "name": "@checkstack/signal-backend",
3
+ "version": "0.0.2",
4
+ "type": "module",
5
+ "exports": {
6
+ ".": {
7
+ "import": "./src/index.ts"
8
+ }
9
+ },
10
+ "dependencies": {
11
+ "@checkstack/common": "workspace:*",
12
+ "@checkstack/signal-common": "workspace:*",
13
+ "@checkstack/backend-api": "workspace:*"
14
+ },
15
+ "devDependencies": {
16
+ "@types/bun": "latest",
17
+ "typescript": "^5.7.2",
18
+ "zod": "^4.0.0",
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
+ }
package/src/hooks.ts ADDED
@@ -0,0 +1,19 @@
1
+ import { createHook } from "@checkstack/backend-api";
2
+ import type { SignalMessage } from "@checkstack/signal-common";
3
+
4
+ /**
5
+ * Internal hook for broadcasting signals across backend instances.
6
+ * All instances subscribe in broadcast mode to push to their local WebSocket clients.
7
+ */
8
+ export const SIGNAL_BROADCAST_HOOK = createHook<SignalMessage>(
9
+ "signal.internal.broadcast"
10
+ );
11
+
12
+ /**
13
+ * Internal hook for user-specific signals across backend instances.
14
+ * All instances subscribe in broadcast mode to push to the user's WebSocket connections.
15
+ */
16
+ export const SIGNAL_USER_HOOK = createHook<{
17
+ userId: string;
18
+ message: SignalMessage;
19
+ }>("signal.internal.user");
package/src/index.ts ADDED
@@ -0,0 +1,13 @@
1
+ // Implementation
2
+ export { SignalServiceImpl } from "./signal-service-impl";
3
+
4
+ // WebSocket handler for Bun.serve()
5
+ export {
6
+ createWebSocketHandler,
7
+ type WebSocketHandler,
8
+ type WebSocketHandlerConfig,
9
+ type WebSocketData,
10
+ } from "./websocket-handler";
11
+
12
+ // Internal hooks (for registering SignalService in the backend)
13
+ export { SIGNAL_BROADCAST_HOOK, SIGNAL_USER_HOOK } from "./hooks";
@@ -0,0 +1,163 @@
1
+ import { describe, it, expect, beforeEach, mock } from "bun:test";
2
+ import { SignalServiceImpl } from "../src/signal-service-impl";
3
+ import { SIGNAL_BROADCAST_HOOK, SIGNAL_USER_HOOK } from "../src/hooks";
4
+ import { createSignal } from "@checkstack/signal-common";
5
+ import { z } from "zod";
6
+ import type { EventBus, Logger } from "@checkstack/backend-api";
7
+
8
+ // Test signals
9
+ const TEST_BROADCAST_SIGNAL = createSignal(
10
+ "test.broadcast",
11
+ z.object({ message: z.string() })
12
+ );
13
+
14
+ const TEST_USER_SIGNAL = createSignal(
15
+ "test.user",
16
+ z.object({ notification: z.string(), count: z.number() })
17
+ );
18
+
19
+ describe("SignalServiceImpl", () => {
20
+ let signalService: SignalServiceImpl;
21
+ let mockEventBus: EventBus;
22
+ let mockLogger: Logger;
23
+ let emittedEvents: Array<{ hook: unknown; payload: unknown }>;
24
+
25
+ beforeEach(() => {
26
+ emittedEvents = [];
27
+
28
+ mockEventBus = {
29
+ emit: mock(async (hook, payload) => {
30
+ emittedEvents.push({ hook, payload });
31
+ }),
32
+ subscribe: mock(async () => {}),
33
+ shutdown: mock(async () => {}),
34
+ } as unknown as EventBus;
35
+
36
+ mockLogger = {
37
+ debug: mock(() => {}),
38
+ info: mock(() => {}),
39
+ warn: mock(() => {}),
40
+ error: mock(() => {}),
41
+ child: mock(() => mockLogger),
42
+ } as unknown as Logger;
43
+
44
+ signalService = new SignalServiceImpl(mockEventBus, mockLogger);
45
+ });
46
+
47
+ describe("broadcast", () => {
48
+ it("should emit broadcast signal to EventBus", async () => {
49
+ const payload = { message: "Hello, World!" };
50
+
51
+ await signalService.broadcast(TEST_BROADCAST_SIGNAL, payload);
52
+
53
+ expect(emittedEvents).toHaveLength(1);
54
+ expect(emittedEvents[0].hook).toBe(SIGNAL_BROADCAST_HOOK);
55
+
56
+ const message = emittedEvents[0].payload as {
57
+ signalId: string;
58
+ payload: typeof payload;
59
+ timestamp: string;
60
+ };
61
+ expect(message.signalId).toBe("test.broadcast");
62
+ expect(message.payload).toEqual(payload);
63
+ expect(typeof message.timestamp).toBe("string");
64
+ });
65
+
66
+ it("should log debug message when broadcasting", async () => {
67
+ await signalService.broadcast(TEST_BROADCAST_SIGNAL, {
68
+ message: "Test",
69
+ });
70
+
71
+ expect(mockLogger.debug).toHaveBeenCalled();
72
+ });
73
+
74
+ it("should include ISO timestamp in signal message", async () => {
75
+ const beforeTime = new Date().toISOString();
76
+ await signalService.broadcast(TEST_BROADCAST_SIGNAL, {
77
+ message: "Test",
78
+ });
79
+ const afterTime = new Date().toISOString();
80
+
81
+ const message = emittedEvents[0].payload as { timestamp: string };
82
+ expect(message.timestamp >= beforeTime).toBe(true);
83
+ expect(message.timestamp <= afterTime).toBe(true);
84
+ });
85
+ });
86
+
87
+ describe("sendToUser", () => {
88
+ it("should emit user signal to EventBus with userId", async () => {
89
+ const userId = "user-123";
90
+ const payload = { notification: "New message", count: 5 };
91
+
92
+ await signalService.sendToUser(TEST_USER_SIGNAL, userId, payload);
93
+
94
+ expect(emittedEvents).toHaveLength(1);
95
+ expect(emittedEvents[0].hook).toBe(SIGNAL_USER_HOOK);
96
+
97
+ const emitted = emittedEvents[0].payload as {
98
+ userId: string;
99
+ message: { signalId: string; payload: typeof payload };
100
+ };
101
+ expect(emitted.userId).toBe(userId);
102
+ expect(emitted.message.signalId).toBe("test.user");
103
+ expect(emitted.message.payload).toEqual(payload);
104
+ });
105
+
106
+ it("should log debug message with user and signal info", async () => {
107
+ await signalService.sendToUser(TEST_USER_SIGNAL, "user-456", {
108
+ notification: "Alert",
109
+ count: 1,
110
+ });
111
+
112
+ expect(mockLogger.debug).toHaveBeenCalled();
113
+ });
114
+ });
115
+
116
+ describe("sendToUsers", () => {
117
+ it("should emit signal to multiple users", async () => {
118
+ const userIds = ["user-1", "user-2", "user-3"];
119
+ const payload = { notification: "Broadcast to users", count: 10 };
120
+
121
+ await signalService.sendToUsers(TEST_USER_SIGNAL, userIds, payload);
122
+
123
+ // Should emit one event per user
124
+ expect(emittedEvents).toHaveLength(3);
125
+
126
+ for (const [index, event] of emittedEvents.entries()) {
127
+ expect(event.hook).toBe(SIGNAL_USER_HOOK);
128
+ const emitted = event.payload as { userId: string };
129
+ expect(emitted.userId).toBe(userIds[index]);
130
+ }
131
+ });
132
+
133
+ it("should handle empty user array", async () => {
134
+ await signalService.sendToUsers(TEST_USER_SIGNAL, [], {
135
+ notification: "Empty",
136
+ count: 0,
137
+ });
138
+
139
+ expect(emittedEvents).toHaveLength(0);
140
+ });
141
+
142
+ it("should handle single user in array", async () => {
143
+ await signalService.sendToUsers(TEST_USER_SIGNAL, ["single-user"], {
144
+ notification: "Single",
145
+ count: 1,
146
+ });
147
+
148
+ expect(emittedEvents).toHaveLength(1);
149
+ });
150
+ });
151
+ });
152
+
153
+ describe("Signal Hooks", () => {
154
+ it("should have correct hook IDs", () => {
155
+ expect(SIGNAL_BROADCAST_HOOK.id).toBe("signal.internal.broadcast");
156
+ expect(SIGNAL_USER_HOOK.id).toBe("signal.internal.user");
157
+ });
158
+
159
+ it("should have consistent hook structure", () => {
160
+ expect(SIGNAL_BROADCAST_HOOK).toHaveProperty("id");
161
+ expect(SIGNAL_USER_HOOK).toHaveProperty("id");
162
+ });
163
+ });
@@ -0,0 +1,117 @@
1
+ import type { EventBus, Logger } from "@checkstack/backend-api";
2
+ import { qualifyPermissionId } from "@checkstack/common";
3
+ import type {
4
+ Signal,
5
+ SignalMessage,
6
+ SignalService,
7
+ } from "@checkstack/signal-common";
8
+ import { SIGNAL_BROADCAST_HOOK, SIGNAL_USER_HOOK } from "./hooks";
9
+
10
+ /**
11
+ * Interface for the auth client methods needed by SignalService.
12
+ * This is a subset of the AuthApi client to avoid circular dependencies.
13
+ */
14
+ interface AuthClientForSignals {
15
+ filterUsersByPermission: (input: {
16
+ userIds: string[];
17
+ permission: string;
18
+ }) => Promise<string[]>;
19
+ }
20
+
21
+ /**
22
+ * SignalService implementation that uses EventBus for multi-instance coordination.
23
+ *
24
+ * When a signal is emitted, it goes through the EventBus (backed by the queue system),
25
+ * ensuring all backend instances receive it and can push to their local WebSocket clients.
26
+ */
27
+ export class SignalServiceImpl implements SignalService {
28
+ private authClient?: AuthClientForSignals;
29
+
30
+ constructor(private eventBus: EventBus, private logger: Logger) {}
31
+
32
+ /**
33
+ * Set the auth client for permission-based signal filtering.
34
+ * This should be called after plugins have loaded.
35
+ */
36
+ setAuthClient(client: AuthClientForSignals): void {
37
+ this.authClient = client;
38
+ }
39
+
40
+ async broadcast<T>(signal: Signal<T>, payload: T): Promise<void> {
41
+ const message: SignalMessage<T> = {
42
+ signalId: signal.id,
43
+ payload,
44
+ timestamp: new Date().toISOString(),
45
+ };
46
+
47
+ this.logger.debug(`Broadcasting signal: ${signal.id}`);
48
+
49
+ // Emit to EventBus - all backend instances receive and push to their WebSocket clients
50
+ await this.eventBus.emit(SIGNAL_BROADCAST_HOOK, message);
51
+ }
52
+
53
+ async sendToUser<T>(
54
+ signal: Signal<T>,
55
+ userId: string,
56
+ payload: T
57
+ ): Promise<void> {
58
+ const message: SignalMessage<T> = {
59
+ signalId: signal.id,
60
+ payload,
61
+ timestamp: new Date().toISOString(),
62
+ };
63
+
64
+ this.logger.debug(`Sending signal ${signal.id} to user ${userId}`);
65
+
66
+ await this.eventBus.emit(SIGNAL_USER_HOOK, { userId, message });
67
+ }
68
+
69
+ async sendToUsers<T>(
70
+ signal: Signal<T>,
71
+ userIds: string[],
72
+ payload: T
73
+ ): Promise<void> {
74
+ await Promise.all(
75
+ userIds.map((userId) => this.sendToUser(signal, userId, payload))
76
+ );
77
+ }
78
+
79
+ async sendToAuthorizedUsers<T>(
80
+ signal: Signal<T>,
81
+ userIds: string[],
82
+ payload: T,
83
+ pluginMetadata: { pluginId: string },
84
+ permission: { id: string }
85
+ ): Promise<void> {
86
+ if (userIds.length === 0) return;
87
+
88
+ if (!this.authClient) {
89
+ this.logger.warn(
90
+ `sendToAuthorizedUsers called but auth client not set. Skipping signal ${signal.id}`
91
+ );
92
+ return;
93
+ }
94
+
95
+ // Construct fully-qualified permission ID: ${pluginMetadata.pluginId}.${permission.id}
96
+ const qualifiedPermission = qualifyPermissionId(pluginMetadata, permission);
97
+
98
+ // Filter users via auth RPC
99
+ const authorizedIds = await this.authClient.filterUsersByPermission({
100
+ userIds,
101
+ permission: qualifiedPermission,
102
+ });
103
+
104
+ if (authorizedIds.length === 0) {
105
+ this.logger.debug(
106
+ `No users authorized for signal ${signal.id} with permission ${qualifiedPermission}`
107
+ );
108
+ return;
109
+ }
110
+
111
+ this.logger.debug(
112
+ `Sending signal ${signal.id} to ${authorizedIds.length}/${userIds.length} authorized users`
113
+ );
114
+
115
+ await this.sendToUsers(signal, authorizedIds, payload);
116
+ }
117
+ }
@@ -0,0 +1,352 @@
1
+ import { describe, it, expect, beforeEach, mock } from "bun:test";
2
+ import {
3
+ createWebSocketHandler,
4
+ type WebSocketData,
5
+ } from "../src/websocket-handler";
6
+ import { SIGNAL_BROADCAST_HOOK, SIGNAL_USER_HOOK } from "../src/hooks";
7
+ import type { EventBus, Logger } from "@checkstack/backend-api";
8
+ import type { Server, ServerWebSocket } from "bun";
9
+
10
+ describe("createWebSocketHandler", () => {
11
+ let mockEventBus: EventBus;
12
+ let mockLogger: Logger;
13
+ let subscriptions: Array<{
14
+ group: string;
15
+ hook: unknown;
16
+ handler: (payload: unknown) => Promise<void>;
17
+ mode: string;
18
+ }>;
19
+
20
+ beforeEach(() => {
21
+ subscriptions = [];
22
+
23
+ mockEventBus = {
24
+ emit: mock(async () => {}),
25
+ subscribe: mock(async (group, hook, handler, options) => {
26
+ subscriptions.push({ group, hook, handler, mode: options?.mode || "" });
27
+ }),
28
+ shutdown: mock(async () => {}),
29
+ } as unknown as EventBus;
30
+
31
+ mockLogger = {
32
+ debug: mock(() => {}),
33
+ info: mock(() => {}),
34
+ warn: mock(() => {}),
35
+ error: mock(() => {}),
36
+ child: mock(() => mockLogger),
37
+ } as unknown as Logger;
38
+ });
39
+
40
+ describe("initialization", () => {
41
+ it("should create handler with websocket configuration", () => {
42
+ const handler = createWebSocketHandler({
43
+ eventBus: mockEventBus,
44
+ logger: mockLogger,
45
+ });
46
+
47
+ expect(handler).toHaveProperty("setServer");
48
+ expect(handler).toHaveProperty("websocket");
49
+ expect(handler.websocket).toHaveProperty("open");
50
+ expect(handler.websocket).toHaveProperty("message");
51
+ expect(handler.websocket).toHaveProperty("close");
52
+ });
53
+
54
+ it("should subscribe to EventBus hooks when server is set", async () => {
55
+ const handler = createWebSocketHandler({
56
+ eventBus: mockEventBus,
57
+ logger: mockLogger,
58
+ });
59
+
60
+ const mockServer = {
61
+ publish: mock(() => {}),
62
+ } as unknown as Server<WebSocketData>;
63
+
64
+ handler.setServer(mockServer);
65
+
66
+ // Wait for async subscription setup
67
+ await new Promise((resolve) => setTimeout(resolve, 10));
68
+
69
+ expect(subscriptions).toHaveLength(2);
70
+ expect(subscriptions[0].hook).toBe(SIGNAL_BROADCAST_HOOK);
71
+ expect(subscriptions[1].hook).toBe(SIGNAL_USER_HOOK);
72
+ });
73
+
74
+ it("should subscribe with broadcast mode", async () => {
75
+ const handler = createWebSocketHandler({
76
+ eventBus: mockEventBus,
77
+ logger: mockLogger,
78
+ });
79
+
80
+ const mockServer = {
81
+ publish: mock(() => {}),
82
+ } as unknown as Server<WebSocketData>;
83
+
84
+ handler.setServer(mockServer);
85
+ await new Promise((resolve) => setTimeout(resolve, 10));
86
+
87
+ expect(subscriptions[0].mode).toBe("broadcast");
88
+ expect(subscriptions[1].mode).toBe("broadcast");
89
+ });
90
+ });
91
+
92
+ describe("websocket.open", () => {
93
+ it("should subscribe authenticated user to broadcast and user channels", () => {
94
+ const handler = createWebSocketHandler({
95
+ eventBus: mockEventBus,
96
+ logger: mockLogger,
97
+ });
98
+
99
+ const subscribedChannels: string[] = [];
100
+ const mockWs = {
101
+ data: { userId: "user-123", createdAt: Date.now() },
102
+ subscribe: mock((channel: string) => subscribedChannels.push(channel)),
103
+ send: mock(() => {}),
104
+ } as unknown as ServerWebSocket<WebSocketData>;
105
+
106
+ handler.websocket.open(mockWs);
107
+
108
+ expect(subscribedChannels).toContain("signals:broadcast");
109
+ expect(subscribedChannels).toContain("signals:user:user-123");
110
+ });
111
+
112
+ it("should subscribe anonymous user to broadcast channel only", () => {
113
+ const handler = createWebSocketHandler({
114
+ eventBus: mockEventBus,
115
+ logger: mockLogger,
116
+ });
117
+
118
+ const subscribedChannels: string[] = [];
119
+ const mockWs = {
120
+ data: { userId: undefined, createdAt: Date.now() },
121
+ subscribe: mock((channel: string) => subscribedChannels.push(channel)),
122
+ send: mock(() => {}),
123
+ } as unknown as ServerWebSocket<WebSocketData>;
124
+
125
+ handler.websocket.open(mockWs);
126
+
127
+ expect(subscribedChannels).toContain("signals:broadcast");
128
+ expect(subscribedChannels).not.toContain("signals:user:undefined");
129
+ expect(subscribedChannels).toHaveLength(1);
130
+ });
131
+
132
+ it("should send connected message with userId", () => {
133
+ const handler = createWebSocketHandler({
134
+ eventBus: mockEventBus,
135
+ logger: mockLogger,
136
+ });
137
+
138
+ let sentMessage: string | undefined;
139
+ const mockWs = {
140
+ data: { userId: "user-456", createdAt: Date.now() },
141
+ subscribe: mock(() => {}),
142
+ send: mock((msg: string) => {
143
+ sentMessage = msg;
144
+ }),
145
+ } as unknown as ServerWebSocket<WebSocketData>;
146
+
147
+ handler.websocket.open(mockWs);
148
+
149
+ expect(sentMessage).toBeDefined();
150
+ const parsed = JSON.parse(sentMessage!);
151
+ expect(parsed.type).toBe("connected");
152
+ expect(parsed.userId).toBe("user-456");
153
+ });
154
+
155
+ it("should send 'anonymous' userId for unauthenticated connections", () => {
156
+ const handler = createWebSocketHandler({
157
+ eventBus: mockEventBus,
158
+ logger: mockLogger,
159
+ });
160
+
161
+ let sentMessage: string | undefined;
162
+ const mockWs = {
163
+ data: { userId: undefined, createdAt: Date.now() },
164
+ subscribe: mock(() => {}),
165
+ send: mock((msg: string) => {
166
+ sentMessage = msg;
167
+ }),
168
+ } as unknown as ServerWebSocket<WebSocketData>;
169
+
170
+ handler.websocket.open(mockWs);
171
+
172
+ const parsed = JSON.parse(sentMessage!);
173
+ expect(parsed.type).toBe("connected");
174
+ expect(parsed.userId).toBe("anonymous");
175
+ });
176
+ });
177
+
178
+ describe("websocket.message", () => {
179
+ it("should respond to ping with pong", () => {
180
+ const handler = createWebSocketHandler({
181
+ eventBus: mockEventBus,
182
+ logger: mockLogger,
183
+ });
184
+
185
+ let sentMessage: string | undefined;
186
+ const mockWs = {
187
+ data: { userId: "user-123", createdAt: Date.now() },
188
+ send: mock((msg: string) => {
189
+ sentMessage = msg;
190
+ }),
191
+ } as unknown as ServerWebSocket<WebSocketData>;
192
+
193
+ handler.websocket.message(mockWs, JSON.stringify({ type: "ping" }));
194
+
195
+ expect(sentMessage).toBeDefined();
196
+ const parsed = JSON.parse(sentMessage!);
197
+ expect(parsed.type).toBe("pong");
198
+ });
199
+
200
+ it("should handle invalid JSON gracefully", () => {
201
+ const handler = createWebSocketHandler({
202
+ eventBus: mockEventBus,
203
+ logger: mockLogger,
204
+ });
205
+
206
+ const mockWs = {
207
+ data: { userId: "user-123", createdAt: Date.now() },
208
+ send: mock(() => {}),
209
+ } as unknown as ServerWebSocket<WebSocketData>;
210
+
211
+ // Should not throw
212
+ expect(() => {
213
+ handler.websocket.message(mockWs, "invalid json {{{");
214
+ }).not.toThrow();
215
+
216
+ expect(mockLogger.warn).toHaveBeenCalled();
217
+ });
218
+
219
+ it("should ignore unknown message types", () => {
220
+ const handler = createWebSocketHandler({
221
+ eventBus: mockEventBus,
222
+ logger: mockLogger,
223
+ });
224
+
225
+ let sentMessage: string | undefined;
226
+ const mockWs = {
227
+ data: { userId: "user-123", createdAt: Date.now() },
228
+ send: mock((msg: string) => {
229
+ sentMessage = msg;
230
+ }),
231
+ } as unknown as ServerWebSocket<WebSocketData>;
232
+
233
+ handler.websocket.message(mockWs, JSON.stringify({ type: "unknown" }));
234
+
235
+ // Should not send any response for unknown types
236
+ expect(sentMessage).toBeUndefined();
237
+ });
238
+ });
239
+
240
+ describe("websocket.close", () => {
241
+ it("should log close event with user info", () => {
242
+ const handler = createWebSocketHandler({
243
+ eventBus: mockEventBus,
244
+ logger: mockLogger,
245
+ });
246
+
247
+ const mockWs = {
248
+ data: { userId: "user-789", createdAt: Date.now() },
249
+ } as unknown as ServerWebSocket<WebSocketData>;
250
+
251
+ handler.websocket.close(mockWs, 1000, "Normal closure");
252
+
253
+ expect(mockLogger.debug).toHaveBeenCalled();
254
+ });
255
+
256
+ it("should handle anonymous user close", () => {
257
+ const handler = createWebSocketHandler({
258
+ eventBus: mockEventBus,
259
+ logger: mockLogger,
260
+ });
261
+
262
+ const mockWs = {
263
+ data: { userId: undefined, createdAt: Date.now() },
264
+ } as unknown as ServerWebSocket<WebSocketData>;
265
+
266
+ expect(() => {
267
+ handler.websocket.close(mockWs, 1001, "Going away");
268
+ }).not.toThrow();
269
+ });
270
+ });
271
+
272
+ describe("signal relay", () => {
273
+ it("should publish broadcast signals to broadcast channel", async () => {
274
+ const handler = createWebSocketHandler({
275
+ eventBus: mockEventBus,
276
+ logger: mockLogger,
277
+ });
278
+
279
+ let publishedChannel: string | undefined;
280
+ let publishedMessage: string | undefined;
281
+ const mockServer = {
282
+ publish: mock((channel: string, message: string) => {
283
+ publishedChannel = channel;
284
+ publishedMessage = message;
285
+ }),
286
+ } as unknown as Server<WebSocketData>;
287
+
288
+ handler.setServer(mockServer);
289
+ await new Promise((resolve) => setTimeout(resolve, 10));
290
+
291
+ // Find the broadcast handler and call it
292
+ const broadcastSubscription = subscriptions.find(
293
+ (s) => s.hook === SIGNAL_BROADCAST_HOOK
294
+ );
295
+ expect(broadcastSubscription).toBeDefined();
296
+
297
+ await broadcastSubscription!.handler({
298
+ signalId: "test.signal",
299
+ payload: { data: "test" },
300
+ timestamp: new Date().toISOString(),
301
+ });
302
+
303
+ expect(publishedChannel).toBe("signals:broadcast");
304
+ expect(publishedMessage).toBeDefined();
305
+
306
+ const parsed = JSON.parse(publishedMessage!);
307
+ expect(parsed.type).toBe("signal");
308
+ expect(parsed.signalId).toBe("test.signal");
309
+ });
310
+
311
+ it("should publish user signals to user-specific channel", async () => {
312
+ const handler = createWebSocketHandler({
313
+ eventBus: mockEventBus,
314
+ logger: mockLogger,
315
+ });
316
+
317
+ let publishedChannel: string | undefined;
318
+ let publishedMessage: string | undefined;
319
+ const mockServer = {
320
+ publish: mock((channel: string, message: string) => {
321
+ publishedChannel = channel;
322
+ publishedMessage = message;
323
+ }),
324
+ } as unknown as Server<WebSocketData>;
325
+
326
+ handler.setServer(mockServer);
327
+ await new Promise((resolve) => setTimeout(resolve, 10));
328
+
329
+ // Find the user handler and call it
330
+ const userSubscription = subscriptions.find(
331
+ (s) => s.hook === SIGNAL_USER_HOOK
332
+ );
333
+ expect(userSubscription).toBeDefined();
334
+
335
+ await userSubscription!.handler({
336
+ userId: "target-user",
337
+ message: {
338
+ signalId: "notification.received",
339
+ payload: { id: "n-1" },
340
+ timestamp: new Date().toISOString(),
341
+ },
342
+ });
343
+
344
+ expect(publishedChannel).toBe("signals:user:target-user");
345
+ expect(publishedMessage).toBeDefined();
346
+
347
+ const parsed = JSON.parse(publishedMessage!);
348
+ expect(parsed.type).toBe("signal");
349
+ expect(parsed.signalId).toBe("notification.received");
350
+ });
351
+ });
352
+ });
@@ -0,0 +1,172 @@
1
+ import type { Server, ServerWebSocket } from "bun";
2
+ import type { EventBus, Logger } from "@checkstack/backend-api";
3
+ import type { ServerToClientMessage } from "@checkstack/signal-common";
4
+ import { SIGNAL_BROADCAST_HOOK, SIGNAL_USER_HOOK } from "./hooks";
5
+
6
+ // =============================================================================
7
+ // TYPES
8
+ // =============================================================================
9
+
10
+ /**
11
+ * WebSocket connection data attached on upgrade.
12
+ * userId is optional - anonymous users can connect for broadcast signals.
13
+ */
14
+ export interface WebSocketData {
15
+ userId?: string;
16
+ createdAt: number;
17
+ }
18
+
19
+ /**
20
+ * Channel names for Bun's native pub/sub.
21
+ */
22
+ const CHANNELS = {
23
+ BROADCAST: "signals:broadcast",
24
+ user: (userId: string) => `signals:user:${userId}`,
25
+ };
26
+
27
+ // =============================================================================
28
+ // WEBSOCKET HANDLER
29
+ // =============================================================================
30
+
31
+ export interface WebSocketHandlerConfig {
32
+ eventBus: EventBus;
33
+ logger: Logger;
34
+ }
35
+
36
+ export interface WebSocketHandler {
37
+ /**
38
+ * Set the Bun server reference after `Bun.serve()` returns.
39
+ * This enables publishing to channels from EventBus subscribers.
40
+ */
41
+ setServer(server: Server<WebSocketData>): void;
42
+
43
+ /**
44
+ * WebSocket configuration object to pass to Bun.serve().
45
+ */
46
+ websocket: {
47
+ data: WebSocketData;
48
+ open(ws: ServerWebSocket<WebSocketData>): void | Promise<void>;
49
+ message(
50
+ ws: ServerWebSocket<WebSocketData>,
51
+ message: string | Buffer
52
+ ): void | Promise<void>;
53
+ close(
54
+ ws: ServerWebSocket<WebSocketData>,
55
+ code: number,
56
+ reason: string
57
+ ): void | Promise<void>;
58
+ };
59
+ }
60
+
61
+ /**
62
+ * Create a WebSocket handler for Bun's native WebSocket server.
63
+ *
64
+ * Uses Bun's built-in pub/sub for efficient channel-based routing:
65
+ * - `signals:broadcast` - all clients subscribe (including anonymous)
66
+ * - `signals:user:{userId}` - user-specific messages (authenticated only)
67
+ */
68
+ export function createWebSocketHandler(
69
+ config: WebSocketHandlerConfig
70
+ ): WebSocketHandler {
71
+ const { eventBus, logger } = config;
72
+ let server: Server<WebSocketData> | undefined;
73
+ let eventBusInitialized = false;
74
+
75
+ const setupEventBusListeners = async () => {
76
+ if (eventBusInitialized || !server) return;
77
+ eventBusInitialized = true;
78
+
79
+ logger.debug("Setting up EventBus listeners for signal relay");
80
+
81
+ // Subscribe to broadcast signals from EventBus
82
+ await eventBus.subscribe(
83
+ "signal-backend",
84
+ SIGNAL_BROADCAST_HOOK,
85
+ async (message) => {
86
+ const payload: ServerToClientMessage = {
87
+ type: "signal",
88
+ signalId: message.signalId,
89
+ payload: message.payload,
90
+ timestamp: message.timestamp,
91
+ };
92
+ server!.publish(CHANNELS.BROADCAST, JSON.stringify(payload));
93
+ logger.debug(`Relayed broadcast signal: ${message.signalId}`);
94
+ },
95
+ { mode: "broadcast" }
96
+ );
97
+
98
+ // Subscribe to user-specific signals from EventBus
99
+ await eventBus.subscribe(
100
+ "signal-backend",
101
+ SIGNAL_USER_HOOK,
102
+ async ({ userId, message }) => {
103
+ const payload: ServerToClientMessage = {
104
+ type: "signal",
105
+ signalId: message.signalId,
106
+ payload: message.payload,
107
+ timestamp: message.timestamp,
108
+ };
109
+ server!.publish(CHANNELS.user(userId), JSON.stringify(payload));
110
+ logger.debug(`Relayed signal ${message.signalId} to user ${userId}`);
111
+ },
112
+ { mode: "broadcast" }
113
+ );
114
+
115
+ logger.info("✅ Signal WebSocket relay initialized");
116
+ };
117
+
118
+ return {
119
+ setServer: (s: Server<WebSocketData>) => {
120
+ server = s;
121
+ void setupEventBusListeners();
122
+ },
123
+
124
+ websocket: {
125
+ // Type template for ws.data (used by TypeScript)
126
+ data: {} as WebSocketData,
127
+
128
+ open(ws: ServerWebSocket<WebSocketData>) {
129
+ const { userId } = ws.data;
130
+ logger.debug(
131
+ `WebSocket opened${userId ? ` for user ${userId}` : " (anonymous)"}`
132
+ );
133
+
134
+ // All clients subscribe to broadcast channel
135
+ ws.subscribe(CHANNELS.BROADCAST);
136
+
137
+ // Only authenticated users subscribe to their private channel
138
+ if (userId) {
139
+ ws.subscribe(CHANNELS.user(userId));
140
+ }
141
+
142
+ // Send connected confirmation
143
+ const msg: ServerToClientMessage = {
144
+ type: "connected",
145
+ userId: userId ?? "anonymous",
146
+ };
147
+ ws.send(JSON.stringify(msg));
148
+ },
149
+
150
+ message(ws: ServerWebSocket<WebSocketData>, message: string | Buffer) {
151
+ try {
152
+ const data = JSON.parse(message.toString());
153
+ if (data.type === "ping") {
154
+ const pong: ServerToClientMessage = { type: "pong" };
155
+ ws.send(JSON.stringify(pong));
156
+ }
157
+ } catch (error) {
158
+ logger.warn("Invalid WebSocket message received", { error });
159
+ }
160
+ },
161
+
162
+ close(ws: ServerWebSocket<WebSocketData>, code: number, reason: string) {
163
+ const { userId } = ws.data;
164
+ logger.debug(
165
+ `WebSocket closed${userId ? ` for user ${userId}` : " (anonymous)"}`,
166
+ { code, reason }
167
+ );
168
+ // Bun automatically unsubscribes from all channels on close
169
+ },
170
+ },
171
+ };
172
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "extends": "@checkstack/tsconfig/backend.json",
3
+ "include": [
4
+ "src"
5
+ ]
6
+ }