@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 +73 -0
- package/package.json +27 -0
- package/src/hooks.ts +19 -0
- package/src/index.ts +13 -0
- package/src/signal-service-impl.test.ts +163 -0
- package/src/signal-service-impl.ts +117 -0
- package/src/websocket-handler.test.ts +352 -0
- package/src/websocket-handler.ts +172 -0
- package/tsconfig.json +6 -0
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
|
+
}
|