sonamu 0.9.4 → 0.9.6
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/dist/ai/providers/rtzr/utils.js +2 -2
- package/dist/api/config.d.ts +13 -2
- package/dist/api/config.d.ts.map +1 -1
- package/dist/api/config.js +1 -1
- package/dist/api/context.d.ts +17 -7
- package/dist/api/context.d.ts.map +1 -1
- package/dist/api/context.js +1 -1
- package/dist/api/decorators.d.ts +18 -0
- package/dist/api/decorators.d.ts.map +1 -1
- package/dist/api/decorators.js +54 -3
- package/dist/api/index.js +8 -3
- package/dist/api/sonamu.d.ts +24 -9
- package/dist/api/sonamu.d.ts.map +1 -1
- package/dist/api/sonamu.js +365 -79
- package/dist/api/websocket-helpers.d.ts +24 -0
- package/dist/api/websocket-helpers.d.ts.map +1 -0
- package/dist/api/websocket-helpers.js +77 -0
- package/dist/bin/cli.js +12 -4
- package/dist/database/upsert-builder.js +4 -4
- package/dist/dict/sonamu-dictionary.js +6 -6
- package/dist/entity/entity-manager.js +1 -1
- package/dist/entity/entity.js +3 -3
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +16 -4
- package/dist/migration/code-generation.d.ts.map +1 -1
- package/dist/migration/code-generation.js +8 -9
- package/dist/stream/index.d.ts +6 -0
- package/dist/stream/index.d.ts.map +1 -1
- package/dist/stream/index.js +13 -2
- package/dist/stream/ws-audience-resolver.d.ts +15 -0
- package/dist/stream/ws-audience-resolver.d.ts.map +1 -0
- package/dist/stream/ws-audience-resolver.js +31 -0
- package/dist/stream/ws-audience.d.ts +28 -0
- package/dist/stream/ws-audience.d.ts.map +1 -0
- package/dist/stream/ws-audience.js +46 -0
- package/dist/stream/ws-cluster-bus.d.ts +23 -0
- package/dist/stream/ws-cluster-bus.d.ts.map +1 -0
- package/dist/stream/ws-cluster-bus.js +18 -0
- package/dist/stream/ws-core.d.ts +15 -0
- package/dist/stream/ws-core.d.ts.map +1 -0
- package/dist/stream/ws-core.js +1 -0
- package/dist/stream/ws-delivery.d.ts +24 -0
- package/dist/stream/ws-delivery.d.ts.map +1 -0
- package/dist/stream/ws-delivery.js +103 -0
- package/dist/stream/ws-local-connection-store.d.ts +10 -0
- package/dist/stream/ws-local-connection-store.d.ts.map +1 -0
- package/dist/stream/ws-local-connection-store.js +44 -0
- package/dist/stream/ws-presence-store.d.ts +61 -0
- package/dist/stream/ws-presence-store.d.ts.map +1 -0
- package/dist/stream/ws-presence-store.js +236 -0
- package/dist/stream/ws-registry.d.ts +42 -0
- package/dist/stream/ws-registry.d.ts.map +1 -0
- package/dist/stream/ws-registry.js +108 -0
- package/dist/stream/ws.d.ts +52 -0
- package/dist/stream/ws.d.ts.map +1 -0
- package/dist/stream/ws.js +397 -0
- package/dist/syncer/api-parser.d.ts.map +1 -1
- package/dist/syncer/api-parser.js +72 -2
- package/dist/syncer/checksum.d.ts.map +1 -1
- package/dist/syncer/checksum.js +13 -12
- package/dist/syncer/code-generator.d.ts.map +1 -1
- package/dist/syncer/code-generator.js +7 -4
- package/dist/syncer/event-batcher.d.ts +27 -0
- package/dist/syncer/event-batcher.d.ts.map +1 -0
- package/dist/syncer/event-batcher.js +69 -0
- package/dist/syncer/file-patterns.d.ts +48 -26
- package/dist/syncer/file-patterns.d.ts.map +1 -1
- package/dist/syncer/file-patterns.js +71 -23
- package/dist/syncer/file-tracking.d.ts +13 -0
- package/dist/syncer/file-tracking.d.ts.map +1 -0
- package/dist/syncer/file-tracking.js +33 -0
- package/dist/syncer/index.js +2 -2
- package/dist/syncer/module-loader.d.ts +2 -11
- package/dist/syncer/module-loader.d.ts.map +1 -1
- package/dist/syncer/module-loader.js +3 -3
- package/dist/syncer/syncer-actions.d.ts +39 -6
- package/dist/syncer/syncer-actions.d.ts.map +1 -1
- package/dist/syncer/syncer-actions.js +125 -10
- package/dist/syncer/syncer.d.ts +33 -19
- package/dist/syncer/syncer.d.ts.map +1 -1
- package/dist/syncer/syncer.js +168 -168
- package/dist/syncer/watcher.d.ts +8 -0
- package/dist/syncer/watcher.d.ts.map +1 -0
- package/dist/syncer/watcher.js +105 -0
- package/dist/tasks/workflow-manager.d.ts.map +1 -1
- package/dist/tasks/workflow-manager.js +2 -1
- package/dist/template/implementations/services.template.d.ts.map +1 -1
- package/dist/template/implementations/services.template.js +36 -1
- package/dist/testing/bootstrap.d.ts.map +1 -1
- package/dist/testing/bootstrap.js +8 -1
- package/dist/testing/data-explorer.d.ts.map +1 -1
- package/dist/testing/data-explorer.js +5 -3
- package/dist/testing/fixture-manager.js +1 -1
- package/dist/types/types.d.ts +2 -1
- package/dist/types/types.d.ts.map +1 -1
- package/dist/types/types.js +2 -2
- package/dist/ui/api.d.ts.map +1 -1
- package/dist/ui/api.js +4 -3
- package/dist/ui/cdd-service.js +1 -1
- package/dist/ui-web/assets/{index-C5KUjXm0.js → index-BmThfg-s.js} +39 -39
- package/dist/ui-web/assets/index-D4rYm-Xz.css +1 -0
- package/dist/ui-web/index.html +2 -2
- package/dist/utils/async-utils.d.ts +27 -3
- package/dist/utils/async-utils.d.ts.map +1 -1
- package/dist/utils/async-utils.js +56 -6
- package/dist/utils/formatter.d.ts +7 -1
- package/dist/utils/formatter.d.ts.map +1 -1
- package/dist/utils/formatter.js +95 -60
- package/dist/utils/fs-utils.d.ts +2 -0
- package/dist/utils/fs-utils.d.ts.map +1 -1
- package/dist/utils/fs-utils.js +10 -2
- package/dist/utils/process-utils.d.ts +6 -0
- package/dist/utils/process-utils.d.ts.map +1 -1
- package/dist/utils/process-utils.js +16 -3
- package/dist/utils/utils.d.ts +1 -0
- package/dist/utils/utils.d.ts.map +1 -1
- package/dist/utils/utils.js +2 -2
- package/package.json +7 -5
- package/src/ai/providers/rtzr/utils.ts +1 -1
- package/src/api/__tests__/sonamu.websocket.test.ts +64 -0
- package/src/api/__tests__/websocket-context.types.test.ts +58 -0
- package/src/api/config.ts +28 -2
- package/src/api/context.ts +21 -7
- package/src/api/decorators.ts +101 -1
- package/src/api/sonamu.ts +529 -127
- package/src/api/websocket-helpers.ts +122 -0
- package/src/bin/cli.ts +10 -2
- package/src/database/upsert-builder.ts +3 -3
- package/src/dict/sonamu-dictionary.ts +3 -3
- package/src/entity/entity.ts +1 -1
- package/src/index.ts +6 -0
- package/src/migration/code-generation.ts +6 -11
- package/src/shared/app.shared.ts.txt +312 -4
- package/src/shared/web.shared.ts.txt +340 -4
- package/src/stream/__tests__/ws-contracts.test.ts +381 -0
- package/src/stream/__tests__/ws.test.ts +449 -0
- package/src/stream/index.ts +6 -0
- package/src/stream/ws-audience-resolver.ts +35 -0
- package/src/stream/ws-audience.ts +62 -0
- package/src/stream/ws-cluster-bus.ts +32 -0
- package/src/stream/ws-core.ts +16 -0
- package/src/stream/ws-delivery.ts +138 -0
- package/src/stream/ws-local-connection-store.ts +44 -0
- package/src/stream/ws-presence-store.ts +326 -0
- package/src/stream/ws-registry.ts +138 -0
- package/src/stream/ws.ts +591 -0
- package/src/syncer/__tests__/api-parser.websocket-type-ref.test.ts +78 -0
- package/src/syncer/api-parser.ts +112 -1
- package/src/syncer/checksum.ts +23 -29
- package/src/syncer/code-generator.ts +4 -1
- package/src/syncer/event-batcher.ts +72 -0
- package/src/syncer/file-patterns.ts +98 -30
- package/src/syncer/file-tracking.ts +27 -0
- package/src/syncer/module-loader.ts +5 -12
- package/src/syncer/syncer-actions.ts +179 -17
- package/src/syncer/syncer.ts +250 -287
- package/src/syncer/watcher.ts +128 -0
- package/src/tasks/workflow-manager.ts +1 -0
- package/src/template/__tests__/services.template.websocket.test.ts +79 -0
- package/src/template/implementations/services.template.ts +69 -0
- package/src/testing/bootstrap.ts +8 -1
- package/src/testing/data-explorer.ts +3 -2
- package/src/types/types.ts +20 -2
- package/src/ui/api.ts +10 -1
- package/src/utils/async-utils.ts +71 -4
- package/src/utils/formatter.ts +114 -75
- package/src/utils/fs-utils.ts +9 -0
- package/src/utils/process-utils.ts +17 -0
- package/src/utils/utils.ts +1 -1
- package/dist/ui-web/assets/index-Dr8pRJC_.css +0 -1
|
@@ -0,0 +1,449 @@
|
|
|
1
|
+
import { EventEmitter } from "node:events";
|
|
2
|
+
|
|
3
|
+
import { describe, expect, it, vi } from "vitest";
|
|
4
|
+
import { type WebSocket } from "ws";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
|
|
7
|
+
import { createWebSocketRuntime } from "../ws";
|
|
8
|
+
import { WebSocketAudience } from "../ws-audience";
|
|
9
|
+
import {
|
|
10
|
+
type WebSocketClusterBus,
|
|
11
|
+
type WebSocketClusterEnvelope,
|
|
12
|
+
type WebSocketClusterEnvelopeHandler,
|
|
13
|
+
} from "../ws-cluster-bus";
|
|
14
|
+
import { InMemoryWebSocketPresenceStore } from "../ws-presence-store";
|
|
15
|
+
|
|
16
|
+
async function waitForAsyncQueue(rounds: number = 3): Promise<void> {
|
|
17
|
+
for (let index = 0; index < rounds; index += 1) {
|
|
18
|
+
await new Promise<void>((resolve) => {
|
|
19
|
+
setImmediate(resolve);
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
class FakeWebSocket extends EventEmitter {
|
|
25
|
+
readyState: 0 | 1 | 2 | 3 = 1;
|
|
26
|
+
bufferedAmount = 0;
|
|
27
|
+
sentMessages: string[] = [];
|
|
28
|
+
pingCount = 0;
|
|
29
|
+
closedWith: { code?: number; reason?: string } | null = null;
|
|
30
|
+
|
|
31
|
+
send(data: string): void {
|
|
32
|
+
this.sentMessages.push(data);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
close(code?: number, reason?: string): void {
|
|
36
|
+
this.closedWith = { code, reason };
|
|
37
|
+
this.readyState = 3;
|
|
38
|
+
this.emit("close", code ?? 1000, Buffer.from(reason ?? "", "utf-8"));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
ping(): void {
|
|
42
|
+
this.pingCount += 1;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
terminate(): void {
|
|
46
|
+
this.readyState = 3;
|
|
47
|
+
this.emit("close", 1006, Buffer.alloc(0));
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function asWebSocket(socket: FakeWebSocket): WebSocket {
|
|
52
|
+
return socket as unknown as WebSocket;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
class FakeClusterBus implements WebSocketClusterBus {
|
|
56
|
+
readonly published: WebSocketClusterEnvelope[] = [];
|
|
57
|
+
private readonly handlers = new Set<WebSocketClusterEnvelopeHandler>();
|
|
58
|
+
|
|
59
|
+
publish(envelope: WebSocketClusterEnvelope): void {
|
|
60
|
+
this.published.push(envelope);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
subscribe(handler: WebSocketClusterEnvelopeHandler): () => void {
|
|
64
|
+
this.handlers.add(handler);
|
|
65
|
+
return () => {
|
|
66
|
+
this.handlers.delete(handler);
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
shutdown(): void {}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
describe("WebSocketRuntime", () => {
|
|
74
|
+
const OutEvents = z.object({
|
|
75
|
+
onReady: z.object({
|
|
76
|
+
ok: z.boolean(),
|
|
77
|
+
}),
|
|
78
|
+
onRoomMessage: z.object({
|
|
79
|
+
roomId: z.string(),
|
|
80
|
+
text: z.string(),
|
|
81
|
+
}),
|
|
82
|
+
});
|
|
83
|
+
const InEvents = z.object({
|
|
84
|
+
joinRoom: z.object({
|
|
85
|
+
roomId: z.string(),
|
|
86
|
+
}),
|
|
87
|
+
sendMessage: z.object({
|
|
88
|
+
roomId: z.string(),
|
|
89
|
+
text: z.string(),
|
|
90
|
+
}),
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("buffers early messages until handlers are attached", async () => {
|
|
94
|
+
const runtime = createWebSocketRuntime();
|
|
95
|
+
const socket = new FakeWebSocket();
|
|
96
|
+
const connection = runtime.registerConnection(asWebSocket(socket), {
|
|
97
|
+
outEvents: OutEvents,
|
|
98
|
+
inEvents: InEvents,
|
|
99
|
+
namespace: "chat",
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
socket.emit(
|
|
103
|
+
"message",
|
|
104
|
+
JSON.stringify({
|
|
105
|
+
event: "joinRoom",
|
|
106
|
+
data: { roomId: "alpha" },
|
|
107
|
+
}),
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
const handler = vi.fn();
|
|
111
|
+
connection.onMessage("joinRoom", handler);
|
|
112
|
+
|
|
113
|
+
await Promise.resolve();
|
|
114
|
+
await Promise.resolve();
|
|
115
|
+
|
|
116
|
+
expect(handler).toHaveBeenCalledWith({ roomId: "alpha" });
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("keeps inactive connections out of registry fan-out until activated", async () => {
|
|
120
|
+
const runtime = createWebSocketRuntime();
|
|
121
|
+
const socket = new FakeWebSocket();
|
|
122
|
+
const connection = runtime.registerConnection(asWebSocket(socket), {
|
|
123
|
+
outEvents: OutEvents,
|
|
124
|
+
inEvents: InEvents,
|
|
125
|
+
namespace: "chat",
|
|
126
|
+
active: false,
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
runtime.broadcast("onReady", {
|
|
130
|
+
ok: true,
|
|
131
|
+
});
|
|
132
|
+
await waitForAsyncQueue();
|
|
133
|
+
|
|
134
|
+
expect(socket.sentMessages).toHaveLength(0);
|
|
135
|
+
expect(runtime.registry.getConnectionCount("chat")).toBe(0);
|
|
136
|
+
|
|
137
|
+
runtime.activateConnection(connection.id);
|
|
138
|
+
runtime.broadcast("onReady", {
|
|
139
|
+
ok: true,
|
|
140
|
+
});
|
|
141
|
+
await waitForAsyncQueue();
|
|
142
|
+
|
|
143
|
+
expect(socket.sentMessages).toHaveLength(1);
|
|
144
|
+
expect(runtime.registry.getConnectionCount("chat")).toBe(1);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("manages rooms and user fan-out through the registry", async () => {
|
|
148
|
+
const runtime = createWebSocketRuntime();
|
|
149
|
+
const socketA = new FakeWebSocket();
|
|
150
|
+
const socketB = new FakeWebSocket();
|
|
151
|
+
const connectionA = runtime.registerConnection(asWebSocket(socketA), {
|
|
152
|
+
outEvents: OutEvents,
|
|
153
|
+
inEvents: InEvents,
|
|
154
|
+
namespace: "chat",
|
|
155
|
+
});
|
|
156
|
+
const connectionB = runtime.registerConnection(asWebSocket(socketB), {
|
|
157
|
+
outEvents: OutEvents,
|
|
158
|
+
inEvents: InEvents,
|
|
159
|
+
namespace: "chat",
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
connectionA.join("room-1");
|
|
163
|
+
connectionB.join("room-1");
|
|
164
|
+
connectionA.setUserId("user-1");
|
|
165
|
+
|
|
166
|
+
runtime.registry.publishToRoom("room-1", "onRoomMessage", {
|
|
167
|
+
roomId: "room-1",
|
|
168
|
+
text: "hello",
|
|
169
|
+
});
|
|
170
|
+
runtime.registry.publishToUser("user-1", "onReady", {
|
|
171
|
+
ok: true,
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
await waitForAsyncQueue();
|
|
175
|
+
|
|
176
|
+
expect(socketA.sentMessages).toHaveLength(2);
|
|
177
|
+
expect(socketB.sentMessages).toHaveLength(1);
|
|
178
|
+
expect(runtime.registry.getRoomMembers("room-1")).toHaveLength(2);
|
|
179
|
+
expect(runtime.registry.getConnectionCount("chat")).toBe(2);
|
|
180
|
+
|
|
181
|
+
connectionA.close(1000, "done");
|
|
182
|
+
|
|
183
|
+
expect(runtime.registry.getRoomMembers("room-1")).toHaveLength(1);
|
|
184
|
+
expect(runtime.registry.getConnectionCount("chat")).toBe(1);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it("keeps room fan-out isolated by namespace", async () => {
|
|
188
|
+
const runtime = createWebSocketRuntime();
|
|
189
|
+
const chatSocket = new FakeWebSocket();
|
|
190
|
+
const otherSocket = new FakeWebSocket();
|
|
191
|
+
const chatConnection = runtime.registerConnection(asWebSocket(chatSocket), {
|
|
192
|
+
outEvents: OutEvents,
|
|
193
|
+
inEvents: InEvents,
|
|
194
|
+
namespace: "chat",
|
|
195
|
+
});
|
|
196
|
+
const otherConnection = runtime.registerConnection(asWebSocket(otherSocket), {
|
|
197
|
+
outEvents: OutEvents,
|
|
198
|
+
inEvents: InEvents,
|
|
199
|
+
namespace: "other",
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
chatConnection.join("room-1");
|
|
203
|
+
otherConnection.join("room-1");
|
|
204
|
+
|
|
205
|
+
runtime.registry.publishToRoom(
|
|
206
|
+
"room-1",
|
|
207
|
+
"onRoomMessage",
|
|
208
|
+
{
|
|
209
|
+
roomId: "room-1",
|
|
210
|
+
text: "hello",
|
|
211
|
+
},
|
|
212
|
+
"chat",
|
|
213
|
+
);
|
|
214
|
+
|
|
215
|
+
await waitForAsyncQueue();
|
|
216
|
+
|
|
217
|
+
expect(chatSocket.sentMessages).toHaveLength(1);
|
|
218
|
+
expect(otherSocket.sentMessages).toHaveLength(0);
|
|
219
|
+
expect(runtime.registry.getRoomMembers("room-1", "chat")).toHaveLength(1);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it("dedupes union audiences before delivery", async () => {
|
|
223
|
+
const runtime = createWebSocketRuntime();
|
|
224
|
+
const socket = new FakeWebSocket();
|
|
225
|
+
const connection = runtime.registerConnection(asWebSocket(socket), {
|
|
226
|
+
outEvents: OutEvents,
|
|
227
|
+
inEvents: InEvents,
|
|
228
|
+
namespace: "chat",
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
connection.join("room-1");
|
|
232
|
+
connection.setUserId("user-1");
|
|
233
|
+
|
|
234
|
+
runtime.publishToAudience(
|
|
235
|
+
WebSocketAudience.union(
|
|
236
|
+
WebSocketAudience.room("room-1", "chat"),
|
|
237
|
+
WebSocketAudience.user("user-1", "chat"),
|
|
238
|
+
),
|
|
239
|
+
"onReady",
|
|
240
|
+
{
|
|
241
|
+
ok: true,
|
|
242
|
+
},
|
|
243
|
+
);
|
|
244
|
+
|
|
245
|
+
await waitForAsyncQueue();
|
|
246
|
+
|
|
247
|
+
expect(socket.sentMessages).toHaveLength(1);
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it("publishes a cluster envelope for remote-node audiences", () => {
|
|
251
|
+
const presenceStore = new InMemoryWebSocketPresenceStore();
|
|
252
|
+
const clusterBus = new FakeClusterBus();
|
|
253
|
+
const runtime = createWebSocketRuntime({
|
|
254
|
+
nodeId: "local-node",
|
|
255
|
+
presenceStore,
|
|
256
|
+
clusterBus,
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
presenceStore.register({
|
|
260
|
+
sessionId: "remote-1",
|
|
261
|
+
nodeId: "remote-node",
|
|
262
|
+
namespace: "chat",
|
|
263
|
+
active: true,
|
|
264
|
+
});
|
|
265
|
+
presenceStore.join("remote-1", "room-remote");
|
|
266
|
+
|
|
267
|
+
runtime.publishToRoom(
|
|
268
|
+
"room-remote",
|
|
269
|
+
"onRoomMessage",
|
|
270
|
+
{
|
|
271
|
+
roomId: "room-remote",
|
|
272
|
+
text: "hello remote",
|
|
273
|
+
},
|
|
274
|
+
"chat",
|
|
275
|
+
);
|
|
276
|
+
|
|
277
|
+
expect(clusterBus.published).toHaveLength(1);
|
|
278
|
+
expect(clusterBus.published[0]).toMatchObject({
|
|
279
|
+
sourceNodeId: "local-node",
|
|
280
|
+
targetNodeIds: ["remote-node"],
|
|
281
|
+
event: "onRoomMessage",
|
|
282
|
+
});
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
it("closes invalid inbound payloads", async () => {
|
|
286
|
+
const runtime = createWebSocketRuntime();
|
|
287
|
+
const socket = new FakeWebSocket();
|
|
288
|
+
runtime.registerConnection(asWebSocket(socket), {
|
|
289
|
+
outEvents: OutEvents,
|
|
290
|
+
inEvents: InEvents,
|
|
291
|
+
namespace: "chat",
|
|
292
|
+
maxPayload: 128,
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
socket.emit(
|
|
296
|
+
"message",
|
|
297
|
+
JSON.stringify({
|
|
298
|
+
event: "sendMessage",
|
|
299
|
+
data: { roomId: "alpha", text: 123 },
|
|
300
|
+
}),
|
|
301
|
+
);
|
|
302
|
+
|
|
303
|
+
await Promise.resolve();
|
|
304
|
+
await Promise.resolve();
|
|
305
|
+
|
|
306
|
+
expect(socket.closedWith?.code).toBe(1007);
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
it("still closes and unregisters when onClose callbacks throw", () => {
|
|
310
|
+
const runtime = createWebSocketRuntime();
|
|
311
|
+
const socket = new FakeWebSocket();
|
|
312
|
+
const connection = runtime.registerConnection(asWebSocket(socket), {
|
|
313
|
+
outEvents: OutEvents,
|
|
314
|
+
inEvents: InEvents,
|
|
315
|
+
namespace: "chat",
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
connection.onClose(() => {
|
|
319
|
+
throw new Error("close callback failed");
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
connection.close(1000, "done");
|
|
323
|
+
|
|
324
|
+
expect(socket.closedWith).toEqual({
|
|
325
|
+
code: 1000,
|
|
326
|
+
reason: "done",
|
|
327
|
+
});
|
|
328
|
+
expect(runtime.registry.getConnectionCount("chat")).toBe(0);
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
it("swallows rejected async onClose callbacks without leaking unhandled rejections", async () => {
|
|
332
|
+
const runtime = createWebSocketRuntime();
|
|
333
|
+
const socket = new FakeWebSocket();
|
|
334
|
+
const connection = runtime.registerConnection(asWebSocket(socket), {
|
|
335
|
+
outEvents: OutEvents,
|
|
336
|
+
inEvents: InEvents,
|
|
337
|
+
namespace: "chat",
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
let unhandled: unknown = null;
|
|
341
|
+
const handleUnhandledRejection = (reason: unknown) => {
|
|
342
|
+
unhandled = reason;
|
|
343
|
+
};
|
|
344
|
+
process.once("unhandledRejection", handleUnhandledRejection);
|
|
345
|
+
|
|
346
|
+
connection.onClose(async () => {
|
|
347
|
+
throw new Error("async close callback failed");
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
connection.close(1000, "done");
|
|
351
|
+
await waitForAsyncQueue(2);
|
|
352
|
+
process.off("unhandledRejection", handleUnhandledRejection);
|
|
353
|
+
|
|
354
|
+
expect(unhandled).toBeNull();
|
|
355
|
+
expect(runtime.registry.getConnectionCount("chat")).toBe(0);
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
it("closes the transport when the socket emits an error", () => {
|
|
359
|
+
const runtime = createWebSocketRuntime();
|
|
360
|
+
const socket = new FakeWebSocket();
|
|
361
|
+
runtime.registerConnection(asWebSocket(socket), {
|
|
362
|
+
outEvents: OutEvents,
|
|
363
|
+
inEvents: InEvents,
|
|
364
|
+
namespace: "chat",
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
socket.emit("error", new Error("broken transport"));
|
|
368
|
+
|
|
369
|
+
expect(socket.closedWith).toEqual({
|
|
370
|
+
code: 1011,
|
|
371
|
+
reason: "WebSocket transport error",
|
|
372
|
+
});
|
|
373
|
+
expect(runtime.registry.getConnectionCount("chat")).toBe(0);
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
it("closes the connection when a buffered handler rejects after registration", async () => {
|
|
377
|
+
const runtime = createWebSocketRuntime();
|
|
378
|
+
const socket = new FakeWebSocket();
|
|
379
|
+
const connection = runtime.registerConnection(asWebSocket(socket), {
|
|
380
|
+
outEvents: OutEvents,
|
|
381
|
+
inEvents: InEvents,
|
|
382
|
+
namespace: "chat",
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
socket.emit(
|
|
386
|
+
"message",
|
|
387
|
+
JSON.stringify({
|
|
388
|
+
event: "joinRoom",
|
|
389
|
+
data: { roomId: "alpha" },
|
|
390
|
+
}),
|
|
391
|
+
);
|
|
392
|
+
|
|
393
|
+
connection.onMessage("joinRoom", async () => {
|
|
394
|
+
throw new Error("handler failed");
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
await waitForAsyncQueue();
|
|
398
|
+
|
|
399
|
+
expect(socket.closedWith?.code).toBe(1011);
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
it("closes slow consumers when outbound backpressure keeps growing", () => {
|
|
403
|
+
const runtime = createWebSocketRuntime();
|
|
404
|
+
const socket = new FakeWebSocket();
|
|
405
|
+
socket.bufferedAmount = 2_000_000;
|
|
406
|
+
const connection = runtime.registerConnection(asWebSocket(socket), {
|
|
407
|
+
outEvents: OutEvents,
|
|
408
|
+
inEvents: InEvents,
|
|
409
|
+
namespace: "chat",
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
for (let index = 0; index <= 1_000; index += 1) {
|
|
413
|
+
connection.publish("onReady", {
|
|
414
|
+
ok: true,
|
|
415
|
+
});
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
expect(socket.closedWith?.code).toBe(1013);
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
it("preserves broadcast order across chunked fan-out batches", async () => {
|
|
422
|
+
const runtime = createWebSocketRuntime();
|
|
423
|
+
const sockets = Array.from({ length: 300 }, () => new FakeWebSocket());
|
|
424
|
+
|
|
425
|
+
for (const socket of sockets) {
|
|
426
|
+
runtime.registerConnection(asWebSocket(socket), {
|
|
427
|
+
outEvents: OutEvents,
|
|
428
|
+
inEvents: InEvents,
|
|
429
|
+
namespace: "chat",
|
|
430
|
+
});
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
runtime.broadcast("onReady", {
|
|
434
|
+
ok: true,
|
|
435
|
+
});
|
|
436
|
+
runtime.broadcast("onReady", {
|
|
437
|
+
ok: false,
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
await waitForAsyncQueue(6);
|
|
441
|
+
|
|
442
|
+
expect(sockets[0].sentMessages).toHaveLength(2);
|
|
443
|
+
expect(sockets.at(-1)?.sentMessages).toHaveLength(2);
|
|
444
|
+
expect(JSON.parse(sockets[0].sentMessages[0]).data).toEqual({ ok: true });
|
|
445
|
+
expect(JSON.parse(sockets[0].sentMessages[1]).data).toEqual({ ok: false });
|
|
446
|
+
expect(JSON.parse(sockets.at(-1)!.sentMessages[0]).data).toEqual({ ok: true });
|
|
447
|
+
expect(JSON.parse(sockets.at(-1)!.sentMessages[1]).data).toEqual({ ok: false });
|
|
448
|
+
});
|
|
449
|
+
});
|
package/src/stream/index.ts
CHANGED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { type WebSocketAudience } from "./ws-audience";
|
|
2
|
+
import { type WebSocketPresenceStore } from "./ws-presence-store";
|
|
3
|
+
|
|
4
|
+
export type WebSocketRoutingPlan = {
|
|
5
|
+
localSessionIds: string[];
|
|
6
|
+
remoteNodeIds: string[];
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export class WebSocketAudienceResolver {
|
|
10
|
+
constructor(
|
|
11
|
+
private readonly options: {
|
|
12
|
+
nodeId: string;
|
|
13
|
+
presenceStore: WebSocketPresenceStore;
|
|
14
|
+
},
|
|
15
|
+
) {}
|
|
16
|
+
|
|
17
|
+
resolve(audience: WebSocketAudience): WebSocketRoutingPlan {
|
|
18
|
+
const localSessionIds: string[] = [];
|
|
19
|
+
const remoteNodeIds = new Set<string>();
|
|
20
|
+
|
|
21
|
+
for (const meta of this.options.presenceStore.queryAudience(audience)) {
|
|
22
|
+
if (meta.nodeId === this.options.nodeId) {
|
|
23
|
+
localSessionIds.push(meta.sessionId);
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
remoteNodeIds.add(meta.nodeId);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return {
|
|
31
|
+
localSessionIds,
|
|
32
|
+
remoteNodeIds: [...remoteNodeIds],
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { type WebSocketRoomId, type WebSocketUserId } from "./ws-core";
|
|
2
|
+
|
|
3
|
+
export type WebSocketAudience =
|
|
4
|
+
| {
|
|
5
|
+
type: "all";
|
|
6
|
+
namespace?: string;
|
|
7
|
+
}
|
|
8
|
+
| {
|
|
9
|
+
type: "room";
|
|
10
|
+
roomId: WebSocketRoomId;
|
|
11
|
+
namespace?: string;
|
|
12
|
+
}
|
|
13
|
+
| {
|
|
14
|
+
type: "user";
|
|
15
|
+
userId: WebSocketUserId;
|
|
16
|
+
namespace?: string;
|
|
17
|
+
}
|
|
18
|
+
| {
|
|
19
|
+
type: "connections";
|
|
20
|
+
connectionIds: string[];
|
|
21
|
+
namespace?: string;
|
|
22
|
+
}
|
|
23
|
+
| {
|
|
24
|
+
type: "union";
|
|
25
|
+
audiences: WebSocketAudience[];
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export const WebSocketAudience = {
|
|
29
|
+
all(namespace?: string): WebSocketAudience {
|
|
30
|
+
return {
|
|
31
|
+
type: "all",
|
|
32
|
+
namespace,
|
|
33
|
+
};
|
|
34
|
+
},
|
|
35
|
+
room(roomId: WebSocketRoomId, namespace?: string): WebSocketAudience {
|
|
36
|
+
return {
|
|
37
|
+
type: "room",
|
|
38
|
+
roomId,
|
|
39
|
+
namespace,
|
|
40
|
+
};
|
|
41
|
+
},
|
|
42
|
+
user(userId: WebSocketUserId, namespace?: string): WebSocketAudience {
|
|
43
|
+
return {
|
|
44
|
+
type: "user",
|
|
45
|
+
userId,
|
|
46
|
+
namespace,
|
|
47
|
+
};
|
|
48
|
+
},
|
|
49
|
+
connections(connectionIds: string[], namespace?: string): WebSocketAudience {
|
|
50
|
+
return {
|
|
51
|
+
type: "connections",
|
|
52
|
+
connectionIds,
|
|
53
|
+
namespace,
|
|
54
|
+
};
|
|
55
|
+
},
|
|
56
|
+
union(...audiences: WebSocketAudience[]): WebSocketAudience {
|
|
57
|
+
return {
|
|
58
|
+
type: "union",
|
|
59
|
+
audiences,
|
|
60
|
+
};
|
|
61
|
+
},
|
|
62
|
+
};
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { type WebSocketAudience } from "./ws-audience";
|
|
2
|
+
|
|
3
|
+
export type WebSocketClusterEnvelope = {
|
|
4
|
+
id: string;
|
|
5
|
+
sourceNodeId: string;
|
|
6
|
+
targetNodeIds?: string[];
|
|
7
|
+
namespace?: string;
|
|
8
|
+
audience: WebSocketAudience;
|
|
9
|
+
event: string;
|
|
10
|
+
data: unknown;
|
|
11
|
+
emittedAt: number;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export type WebSocketClusterEnvelopeHandler = (
|
|
15
|
+
envelope: WebSocketClusterEnvelope,
|
|
16
|
+
) => void | Promise<void>;
|
|
17
|
+
|
|
18
|
+
export interface WebSocketClusterBus {
|
|
19
|
+
publish(envelope: WebSocketClusterEnvelope): void | Promise<void>;
|
|
20
|
+
subscribe(handler: WebSocketClusterEnvelopeHandler): () => void;
|
|
21
|
+
shutdown(): void | Promise<void>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export class NoopWebSocketClusterBus implements WebSocketClusterBus {
|
|
25
|
+
publish(_envelope: WebSocketClusterEnvelope): void {}
|
|
26
|
+
|
|
27
|
+
subscribe(_handler: WebSocketClusterEnvelopeHandler): () => void {
|
|
28
|
+
return () => {};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
shutdown(): void {}
|
|
32
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export type WebSocketUserId = number | string;
|
|
2
|
+
export type WebSocketRoomId = string;
|
|
3
|
+
|
|
4
|
+
export interface ManagedWebSocketConnection {
|
|
5
|
+
id: string;
|
|
6
|
+
namespace: string;
|
|
7
|
+
closed: boolean;
|
|
8
|
+
publishUntyped(event: string, data: unknown): void;
|
|
9
|
+
close(code?: number, reason?: string): void;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export type WebSocketRegistryStats = {
|
|
13
|
+
totalConnections: number;
|
|
14
|
+
totalRooms: number;
|
|
15
|
+
byNamespace: Record<string, number>;
|
|
16
|
+
};
|