sonamu 0.11.16 → 0.12.0

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.
Files changed (65) hide show
  1. package/dist/api/sonamu.d.ts.map +1 -1
  2. package/dist/api/sonamu.js +12 -7
  3. package/dist/index.d.ts +1 -0
  4. package/dist/index.d.ts.map +1 -1
  5. package/dist/index.js +4 -2
  6. package/dist/stream/__tests__/ws-test-helpers.d.ts +108 -0
  7. package/dist/stream/__tests__/ws-test-helpers.d.ts.map +1 -0
  8. package/dist/stream/__tests__/ws-test-helpers.js +260 -0
  9. package/dist/stream/index.d.ts +1 -0
  10. package/dist/stream/index.d.ts.map +1 -1
  11. package/dist/stream/index.js +4 -2
  12. package/dist/stream/ws-audience-resolver.d.ts +8 -5
  13. package/dist/stream/ws-audience-resolver.d.ts.map +1 -1
  14. package/dist/stream/ws-audience-resolver.js +16 -13
  15. package/dist/stream/ws-cluster-bus.d.ts +9 -4
  16. package/dist/stream/ws-cluster-bus.d.ts.map +1 -1
  17. package/dist/stream/ws-cluster-bus.js +1 -1
  18. package/dist/stream/ws-core.d.ts +10 -0
  19. package/dist/stream/ws-core.d.ts.map +1 -1
  20. package/dist/stream/ws-delivery.d.ts +30 -4
  21. package/dist/stream/ws-delivery.d.ts.map +1 -1
  22. package/dist/stream/ws-delivery.js +199 -52
  23. package/dist/stream/ws-frame.d.ts +15 -0
  24. package/dist/stream/ws-frame.d.ts.map +1 -0
  25. package/dist/stream/ws-frame.js +29 -0
  26. package/dist/stream/ws-local-connection-store.d.ts +4 -4
  27. package/dist/stream/ws-local-connection-store.d.ts.map +1 -1
  28. package/dist/stream/ws-local-connection-store.js +2 -2
  29. package/dist/stream/ws-presence-index.d.ts +36 -0
  30. package/dist/stream/ws-presence-index.d.ts.map +1 -0
  31. package/dist/stream/ws-presence-index.js +177 -0
  32. package/dist/stream/ws-presence-store.d.ts +39 -39
  33. package/dist/stream/ws-presence-store.d.ts.map +1 -1
  34. package/dist/stream/ws-presence-store.js +22 -152
  35. package/dist/stream/ws-registry.d.ts +45 -17
  36. package/dist/stream/ws-registry.d.ts.map +1 -1
  37. package/dist/stream/ws-registry.js +169 -60
  38. package/dist/stream/ws-session-id.d.ts +14 -0
  39. package/dist/stream/ws-session-id.d.ts.map +1 -0
  40. package/dist/stream/ws-session-id.js +37 -0
  41. package/dist/stream/ws.d.ts +20 -10
  42. package/dist/stream/ws.d.ts.map +1 -1
  43. package/dist/stream/ws.js +59 -36
  44. package/package.json +1 -1
  45. package/src/api/__tests__/sonamu.websocket-routes.test.ts +234 -0
  46. package/src/api/__tests__/sonamu.websocket.test.ts +241 -20
  47. package/src/api/sonamu.ts +20 -7
  48. package/src/index.ts +1 -0
  49. package/src/stream/__tests__/ws-contracts.test.ts +483 -265
  50. package/src/stream/__tests__/ws-multinode.test.ts +326 -0
  51. package/src/stream/__tests__/ws-registry-async.test.ts +855 -0
  52. package/src/stream/__tests__/ws-test-helpers.ts +360 -0
  53. package/src/stream/__tests__/ws.test.ts +342 -145
  54. package/src/stream/index.ts +1 -0
  55. package/src/stream/ws-audience-resolver.ts +21 -19
  56. package/src/stream/ws-cluster-bus.ts +12 -4
  57. package/src/stream/ws-core.ts +14 -0
  58. package/src/stream/ws-delivery.ts +321 -97
  59. package/src/stream/ws-frame.ts +39 -0
  60. package/src/stream/ws-local-connection-store.ts +10 -9
  61. package/src/stream/ws-presence-index.ts +273 -0
  62. package/src/stream/ws-presence-store.ts +64 -284
  63. package/src/stream/ws-registry.ts +272 -85
  64. package/src/stream/ws-session-id.ts +39 -0
  65. package/src/stream/ws.ts +91 -50
@@ -1,6 +1,10 @@
1
1
  import { type WebSocketAudience } from "./ws-audience";
2
- import { type WebSocketRegistryStats, type WebSocketRoomId, type WebSocketUserId } from "./ws-core";
2
+ import { type MaybePromise, type WebSocketRoomId, type WebSocketUserId } from "./ws-core";
3
+ import { WebSocketPresenceIndex } from "./ws-presence-index";
3
4
 
5
+ /**
6
+ * 세션 presence snapshot. live 객체가 아니므로 값을 바꿔도 registry나 store 상태에 영향을 주지 않는다.
7
+ */
4
8
  export type WebSocketSessionPresence = {
5
9
  sessionId: string;
6
10
  nodeId: string;
@@ -12,320 +16,96 @@ export type WebSocketSessionPresence = {
12
16
  userId?: WebSocketUserId;
13
17
  };
14
18
 
19
+ export type WebSocketPresenceRegistration = {
20
+ sessionId: string;
21
+ nodeId: string;
22
+ namespace: string;
23
+ active: boolean;
24
+ connectedAt: Date;
25
+ };
26
+
27
+ export type WebSocketRoutingPlan = {
28
+ localSessionIds: string[];
29
+ remoteNodeIds: string[];
30
+ };
31
+
32
+ /**
33
+ * 클러스터 presence replica 계약. 각 노드의 registry가 자기 세션의 mutation을 세션별 호출 순서대로 전달한다.
34
+ * 모든 메서드는 값이나 promise를 반환할 수 있고, registry는 동기 throw도 rejection으로 바꿔 처리한다.
35
+ * userId는 타입까지 일치해야 매칭된다(1과 "1"은 다르다).
36
+ */
15
37
  export interface WebSocketPresenceStore {
16
- register(input: {
17
- sessionId: string;
18
- nodeId: string;
19
- namespace: string;
20
- active?: boolean;
21
- }): WebSocketSessionPresence;
22
- activate(sessionId: string): void;
23
- unregister(sessionId: string): WebSocketSessionPresence | undefined;
24
- touch(sessionId: string): void;
25
- setUserId(sessionId: string, userId: WebSocketUserId): void;
26
- clearUserId(sessionId: string): void;
27
- join(sessionId: string, roomId: WebSocketRoomId): void;
28
- leave(sessionId: string, roomId: WebSocketRoomId): void;
29
- getConnection(sessionId: string): WebSocketSessionPresence | undefined;
30
- getConnectionCount(namespace?: string): number;
31
- getRoomMembers(roomId: WebSocketRoomId, namespace?: string): WebSocketSessionPresence[];
32
- getStats(): WebSocketRegistryStats;
33
- queryAudience(audience: WebSocketAudience): WebSocketSessionPresence[];
38
+ // 설정하면 runtime nodeId와 같아야 한다. 다르면 runtime 생성이 실패한다
39
+ readonly nodeId?: string;
40
+ register(input: WebSocketPresenceRegistration): MaybePromise<void>;
41
+ activate(sessionId: string): MaybePromise<void>;
42
+ unregister(sessionId: string): MaybePromise<void>;
43
+ setUserId(sessionId: string, userId: WebSocketUserId): MaybePromise<void>;
44
+ clearUserId(sessionId: string): MaybePromise<void>;
45
+ join(sessionId: string, roomId: WebSocketRoomId): MaybePromise<void>;
46
+ leave(sessionId: string, roomId: WebSocketRoomId): MaybePromise<void>;
47
+ // nodeId 관점에서 audience를 로컬 세션 ID와 중복 없는 원격 노드 ID로 나눈다. 비활성 세션은 제외한다
48
+ resolveRoute(audience: WebSocketAudience, nodeId: string): MaybePromise<WebSocketRoutingPlan>;
49
+ // 클러스터 전체의 활성 방 인원수
50
+ getRoomMemberCount(roomId: WebSocketRoomId, namespace?: string): MaybePromise<number>;
51
+ shutdown?(): MaybePromise<void>;
34
52
  }
35
53
 
54
+ /**
55
+ * 프로세스 메모리에 presence를 두는 store. 단일 노드의 기본값이며, 같은 프로세스의 여러 runtime이 공유하면 클러스터 replica로도 동작한다.
56
+ */
36
57
  export class InMemoryWebSocketPresenceStore implements WebSocketPresenceStore {
37
- private readonly connections = new Map<string, WebSocketSessionPresence>();
38
- private readonly rooms = new Map<string, Set<string>>();
39
- private readonly users = new Map<string, Set<string>>();
58
+ private readonly index = new WebSocketPresenceIndex();
40
59
 
41
- register(input: {
42
- sessionId: string;
43
- nodeId: string;
44
- namespace: string;
45
- active?: boolean;
46
- }): WebSocketSessionPresence {
47
- const meta: WebSocketSessionPresence = {
48
- sessionId: input.sessionId,
49
- nodeId: input.nodeId,
50
- namespace: input.namespace,
51
- active: input.active ?? true,
52
- rooms: new Set(),
53
- connectedAt: new Date(),
54
- lastSeenAt: new Date(),
55
- };
56
- this.connections.set(input.sessionId, meta);
57
- return meta;
60
+ register(input: WebSocketPresenceRegistration): void {
61
+ this.index.register(input);
58
62
  }
59
63
 
60
64
  activate(sessionId: string): void {
61
- const meta = this.connections.get(sessionId);
62
- if (!meta) {
63
- return;
64
- }
65
-
66
- meta.active = true;
65
+ this.index.activate(sessionId);
67
66
  }
68
67
 
69
- unregister(sessionId: string): WebSocketSessionPresence | undefined {
70
- const meta = this.connections.get(sessionId);
71
- if (!meta) {
72
- return undefined;
73
- }
74
-
75
- if (meta.userId !== undefined) {
76
- this.removeUserBinding(meta.namespace, meta.userId, sessionId);
77
- }
78
-
79
- for (const roomId of meta.rooms) {
80
- this.removeRoomBinding(meta.namespace, roomId, sessionId);
81
- }
82
-
83
- this.connections.delete(sessionId);
84
- return meta;
85
- }
86
-
87
- touch(sessionId: string): void {
88
- const meta = this.connections.get(sessionId);
89
- if (!meta) {
90
- return;
91
- }
92
-
93
- meta.lastSeenAt = new Date();
68
+ unregister(sessionId: string): void {
69
+ this.index.unregister(sessionId);
94
70
  }
95
71
 
96
72
  setUserId(sessionId: string, userId: WebSocketUserId): void {
97
- const meta = this.connections.get(sessionId);
98
- if (!meta) {
99
- return;
100
- }
101
-
102
- if (meta.userId !== undefined) {
103
- this.removeUserBinding(meta.namespace, meta.userId, sessionId);
104
- }
105
-
106
- meta.userId = userId;
107
- const key = getBindingKey(meta.namespace, String(userId));
108
- const bound = this.users.get(key) ?? new Set<string>();
109
- bound.add(sessionId);
110
- this.users.set(key, bound);
73
+ this.index.setUserId(sessionId, userId);
111
74
  }
112
75
 
113
76
  clearUserId(sessionId: string): void {
114
- const meta = this.connections.get(sessionId);
115
- if (!meta || meta.userId === undefined) {
116
- return;
117
- }
118
-
119
- this.removeUserBinding(meta.namespace, meta.userId, sessionId);
120
- delete meta.userId;
77
+ this.index.clearUserId(sessionId);
121
78
  }
122
79
 
123
80
  join(sessionId: string, roomId: WebSocketRoomId): void {
124
- const meta = this.connections.get(sessionId);
125
- if (!meta) {
126
- return;
127
- }
128
-
129
- meta.rooms.add(roomId);
130
- const key = getBindingKey(meta.namespace, roomId);
131
- const members = this.rooms.get(key) ?? new Set<string>();
132
- members.add(sessionId);
133
- this.rooms.set(key, members);
81
+ this.index.join(sessionId, roomId);
134
82
  }
135
83
 
136
84
  leave(sessionId: string, roomId: WebSocketRoomId): void {
137
- const meta = this.connections.get(sessionId);
138
- if (!meta) {
139
- return;
140
- }
141
-
142
- meta.rooms.delete(roomId);
143
- this.removeRoomBinding(meta.namespace, roomId, sessionId);
144
- }
145
-
146
- getConnection(sessionId: string): WebSocketSessionPresence | undefined {
147
- return this.connections.get(sessionId);
148
- }
149
-
150
- getConnectionCount(namespace?: string): number {
151
- let count = 0;
152
-
153
- for (const meta of this.connections.values()) {
154
- if (!meta.active || (namespace && meta.namespace !== namespace)) {
155
- continue;
156
- }
157
-
158
- count += 1;
159
- }
160
-
161
- return count;
162
- }
163
-
164
- getRoomMembers(roomId: WebSocketRoomId, namespace?: string): WebSocketSessionPresence[] {
165
- return this.getBoundConnections(this.rooms, roomId, namespace);
166
- }
167
-
168
- getStats(): WebSocketRegistryStats {
169
- const byNamespace: Record<string, number> = {};
170
-
171
- for (const meta of this.connections.values()) {
172
- if (!meta.active) {
173
- continue;
174
- }
175
- byNamespace[meta.namespace] = (byNamespace[meta.namespace] ?? 0) + 1;
176
- }
177
-
178
- return {
179
- totalConnections: Object.values(byNamespace).reduce((sum, count) => sum + count, 0),
180
- totalRooms: this.rooms.size,
181
- byNamespace,
182
- };
183
- }
184
-
185
- queryAudience(audience: WebSocketAudience): WebSocketSessionPresence[] {
186
- switch (audience.type) {
187
- case "all":
188
- return this.getActiveConnections(audience.namespace);
189
- case "room":
190
- return this.getBoundConnections(this.rooms, audience.roomId, audience.namespace);
191
- case "user":
192
- return this.getBoundConnections(this.users, String(audience.userId), audience.namespace);
193
- case "connections": {
194
- const metas: WebSocketSessionPresence[] = [];
195
-
196
- for (const connectionId of audience.connectionIds) {
197
- const meta = this.connections.get(connectionId);
198
- if (!meta?.active) {
199
- continue;
200
- }
201
- if (audience.namespace && meta.namespace !== audience.namespace) {
202
- continue;
203
- }
204
- metas.push(meta);
205
- }
206
-
207
- return metas;
208
- }
209
- case "union": {
210
- const seen = new Set<string>();
211
- const metas: WebSocketSessionPresence[] = [];
212
-
213
- for (const item of audience.audiences) {
214
- for (const meta of this.queryAudience(item)) {
215
- if (seen.has(meta.sessionId)) {
216
- continue;
217
- }
218
-
219
- seen.add(meta.sessionId);
220
- metas.push(meta);
221
- }
222
- }
223
-
224
- return metas;
225
- }
226
- }
227
- }
228
-
229
- private getActiveConnections(namespace?: string): WebSocketSessionPresence[] {
230
- const metas: WebSocketSessionPresence[] = [];
231
-
232
- for (const meta of this.connections.values()) {
233
- if (!meta.active || (namespace && meta.namespace !== namespace)) {
234
- continue;
235
- }
236
-
237
- metas.push(meta);
238
- }
239
-
240
- return metas;
85
+ this.index.leave(sessionId, roomId);
241
86
  }
242
87
 
243
- private getBoundConnections(
244
- bindings: Map<string, Set<string>>,
245
- id: string,
246
- namespace?: string,
247
- ): WebSocketSessionPresence[] {
248
- const metas: WebSocketSessionPresence[] = [];
249
- const seen = new Set<string>();
88
+ resolveRoute(audience: WebSocketAudience, nodeId: string): WebSocketRoutingPlan {
89
+ const localSessionIds: string[] = [];
90
+ const remoteNodeIds = new Set<string>();
250
91
 
251
- for (const members of this.getScopedBindings(bindings, id, namespace)) {
252
- for (const connectionId of members) {
253
- if (seen.has(connectionId)) {
254
- continue;
255
- }
256
- seen.add(connectionId);
257
-
258
- const meta = this.connections.get(connectionId);
259
- if (meta?.active) {
260
- metas.push(meta);
261
- }
92
+ for (const session of this.index.queryAudience(audience)) {
93
+ if (session.nodeId === nodeId) {
94
+ localSessionIds.push(session.sessionId);
95
+ } else {
96
+ remoteNodeIds.add(session.nodeId);
262
97
  }
263
98
  }
264
99
 
265
- return metas;
100
+ return { localSessionIds, remoteNodeIds: [...remoteNodeIds] };
266
101
  }
267
102
 
268
- private getScopedBindings(
269
- bindings: Map<string, Set<string>>,
270
- id: string,
271
- namespace?: string,
272
- ): Set<string>[] {
273
- if (namespace) {
274
- const exact = bindings.get(getBindingKey(namespace, id));
275
- return exact ? [exact] : [];
276
- }
277
-
278
- const matches: Set<string>[] = [];
279
- for (const [key, members] of bindings) {
280
- const parsed = parseBindingKey(key);
281
- if (parsed.id === id) {
282
- matches.push(members);
283
- }
284
- }
285
-
286
- return matches;
287
- }
288
-
289
- private removeRoomBinding(namespace: string, roomId: WebSocketRoomId, sessionId: string): void {
290
- const key = getBindingKey(namespace, roomId);
291
- const members = this.rooms.get(key);
292
- if (!members) {
293
- return;
294
- }
295
-
296
- members.delete(sessionId);
297
- if (members.size === 0) {
298
- this.rooms.delete(key);
299
- }
103
+ getRoomMemberCount(roomId: WebSocketRoomId, namespace?: string): number {
104
+ return this.index.getRoomMemberCount(roomId, namespace);
300
105
  }
301
106
 
302
- private removeUserBinding(namespace: string, userId: WebSocketUserId, sessionId: string): void {
303
- const key = getBindingKey(namespace, String(userId));
304
- const members = this.users.get(key);
305
- if (!members) {
306
- return;
307
- }
308
-
309
- members.delete(sessionId);
310
- if (members.size === 0) {
311
- this.users.delete(key);
312
- }
107
+ // 복사본을 반환하므로 호출자가 값을 바꿔도 store 상태는 그대로다
108
+ getConnection(sessionId: string): WebSocketSessionPresence | undefined {
109
+ return this.index.getConnection(sessionId);
313
110
  }
314
111
  }
315
-
316
- function getBindingKey(namespace: string, id: string): string {
317
- return `${namespace}::${id}`;
318
- }
319
-
320
- interface PresenceBinding {
321
- namespace: string;
322
- id: string;
323
- }
324
-
325
- function parseBindingKey(key: string): PresenceBinding {
326
- const [namespace, ...rest] = key.split("::");
327
- return {
328
- namespace,
329
- id: rest.join("::"),
330
- };
331
- }