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
@@ -0,0 +1,39 @@
1
+ import { type z } from "zod";
2
+
3
+ export type WebSocketFrameEncoding =
4
+ | { status: "encoded"; frame: string }
5
+ | { status: "unknownEvent" }
6
+ | { status: "invalidPayload" };
7
+
8
+ /**
9
+ * outEvents 스키마로 payload를 검증하고 `{event, data}` frame 문자열로 인코딩한다.
10
+ * 같은 스키마로 인코딩한 frame은 로컬·원격 수신자 모두에게 같은 바이트로 전달된다.
11
+ */
12
+ export function encodeWebSocketFrame<Data>(
13
+ outEvents: z.ZodObject,
14
+ event: string,
15
+ data: Data,
16
+ ): WebSocketFrameEncoding {
17
+ const schema = getEventSchema(outEvents, event);
18
+ if (!schema) {
19
+ return { status: "unknownEvent" };
20
+ }
21
+
22
+ const parsed = schema.safeParse(data);
23
+ if (!parsed.success) {
24
+ return { status: "invalidPayload" };
25
+ }
26
+
27
+ return {
28
+ status: "encoded",
29
+ frame: JSON.stringify({
30
+ event,
31
+ data: parsed.data,
32
+ }),
33
+ };
34
+ }
35
+
36
+ function getEventSchema(outEvents: z.ZodObject, event: string): z.ZodType | undefined {
37
+ const eventSchemas: Record<string, z.ZodType> = outEvents["shape"];
38
+ return Object.hasOwn(eventSchemas, event) ? eventSchemas[event] : undefined;
39
+ }
@@ -1,14 +1,14 @@
1
1
  import { isFunctionValue } from "../utils/runtime-value";
2
- import { type ManagedWebSocketConnection } from "./ws-core";
2
+ import { type WebSocketFrameConnection } from "./ws-core";
3
3
  import {
4
4
  type WebSocketTelemetryConnectionSnapshot,
5
5
  type TelemetryInspectableConnection,
6
6
  } from "./ws-telemetry";
7
7
 
8
8
  export class WebSocketLocalConnectionStore {
9
- private readonly connections = new Map<string, ManagedWebSocketConnection>();
9
+ private readonly connections = new Map<string, WebSocketFrameConnection>();
10
10
 
11
- register(connection: ManagedWebSocketConnection): void {
11
+ register(connection: WebSocketFrameConnection): void {
12
12
  this.connections.set(connection.id, connection);
13
13
  }
14
14
 
@@ -16,12 +16,12 @@ export class WebSocketLocalConnectionStore {
16
16
  this.connections.delete(connectionId);
17
17
  }
18
18
 
19
- getConnection(connectionId: string): ManagedWebSocketConnection | undefined {
19
+ getConnection(connectionId: string): WebSocketFrameConnection | undefined {
20
20
  return this.connections.get(connectionId);
21
21
  }
22
22
 
23
- getConnections(connectionIds: string[]): ManagedWebSocketConnection[] {
24
- const targets: ManagedWebSocketConnection[] = [];
23
+ getConnections(connectionIds: string[]): WebSocketFrameConnection[] {
24
+ const targets: WebSocketFrameConnection[] = [];
25
25
  const seen = new Set<string>();
26
26
 
27
27
  for (const connectionId of connectionIds) {
@@ -63,14 +63,15 @@ export class WebSocketLocalConnectionStore {
63
63
  }
64
64
 
65
65
  closeAll(code?: number, reason?: string): void {
66
- for (const connection of this.connections.values()) {
66
+ // close 도중 unregister가 Map을 바꾸므로 복사본을 순회한다
67
+ for (const connection of Array.from(this.connections.values())) {
67
68
  connection.close(code, reason);
68
69
  }
69
70
  }
70
71
  }
71
72
 
72
73
  function isTelemetryInspectableConnection(
73
- connection: ManagedWebSocketConnection,
74
- ): connection is ManagedWebSocketConnection & TelemetryInspectableConnection {
74
+ connection: WebSocketFrameConnection,
75
+ ): connection is WebSocketFrameConnection & TelemetryInspectableConnection {
75
76
  return "getTelemetrySnapshot" in connection && isFunctionValue(connection.getTelemetrySnapshot);
76
77
  }
@@ -0,0 +1,273 @@
1
+ import { type WebSocketAudience } from "./ws-audience";
2
+ import { type WebSocketRegistryStats, type WebSocketRoomId, type WebSocketUserId } from "./ws-core";
3
+ import {
4
+ type WebSocketPresenceRegistration,
5
+ type WebSocketSessionPresence,
6
+ } from "./ws-presence-store";
7
+
8
+ type PresenceRecord = WebSocketSessionPresence;
9
+ // namespace → 바인딩 id → 세션 ID 집합. userId는 Map 키로 그대로 써서 1과 "1"을 구분한다
10
+ type BindingIndex<Id> = Map<string, Map<Id, Set<string>>>;
11
+
12
+ /**
13
+ * 동기 presence 인덱스. registry의 노드 로컬 메모리와 in-memory store가 함께 쓴다.
14
+ * 외부로 내보내는 presence는 항상 복사본이다.
15
+ */
16
+ export class WebSocketPresenceIndex {
17
+ private readonly sessions = new Map<string, PresenceRecord>();
18
+ private readonly rooms: BindingIndex<WebSocketRoomId> = new Map();
19
+ private readonly users: BindingIndex<WebSocketUserId> = new Map();
20
+
21
+ has(sessionId: string): boolean {
22
+ return this.sessions.has(sessionId);
23
+ }
24
+
25
+ register(input: WebSocketPresenceRegistration): void {
26
+ // 같은 세션을 다시 등록하면 이전 바인딩을 먼저 정리해 고아 바인딩이 남지 않게 한다
27
+ this.unregister(input.sessionId);
28
+ this.sessions.set(input.sessionId, {
29
+ sessionId: input.sessionId,
30
+ nodeId: input.nodeId,
31
+ namespace: input.namespace,
32
+ active: input.active,
33
+ rooms: new Set(),
34
+ connectedAt: new Date(input.connectedAt.getTime()),
35
+ lastSeenAt: new Date(input.connectedAt.getTime()),
36
+ });
37
+ }
38
+
39
+ activate(sessionId: string): void {
40
+ const record = this.sessions.get(sessionId);
41
+ if (record) {
42
+ record.active = true;
43
+ }
44
+ }
45
+
46
+ unregister(sessionId: string): WebSocketSessionPresence | undefined {
47
+ const record = this.sessions.get(sessionId);
48
+ if (!record) {
49
+ return undefined;
50
+ }
51
+
52
+ if (record.userId !== undefined) {
53
+ removeBinding(this.users, record.namespace, record.userId, sessionId);
54
+ }
55
+ for (const roomId of record.rooms) {
56
+ removeBinding(this.rooms, record.namespace, roomId, sessionId);
57
+ }
58
+ this.sessions.delete(sessionId);
59
+ return copyPresence(record);
60
+ }
61
+
62
+ touch(sessionId: string): void {
63
+ const record = this.sessions.get(sessionId);
64
+ if (record) {
65
+ record.lastSeenAt = new Date();
66
+ }
67
+ }
68
+
69
+ setUserId(sessionId: string, userId: WebSocketUserId): void {
70
+ const record = this.sessions.get(sessionId);
71
+ if (!record) {
72
+ return;
73
+ }
74
+
75
+ if (record.userId !== undefined) {
76
+ removeBinding(this.users, record.namespace, record.userId, sessionId);
77
+ }
78
+ record.userId = userId;
79
+ addBinding(this.users, record.namespace, userId, sessionId);
80
+ }
81
+
82
+ clearUserId(sessionId: string): void {
83
+ const record = this.sessions.get(sessionId);
84
+ if (!record || record.userId === undefined) {
85
+ return;
86
+ }
87
+
88
+ removeBinding(this.users, record.namespace, record.userId, sessionId);
89
+ delete record.userId;
90
+ }
91
+
92
+ join(sessionId: string, roomId: WebSocketRoomId): void {
93
+ const record = this.sessions.get(sessionId);
94
+ if (!record) {
95
+ return;
96
+ }
97
+
98
+ record.rooms.add(roomId);
99
+ addBinding(this.rooms, record.namespace, roomId, sessionId);
100
+ }
101
+
102
+ leave(sessionId: string, roomId: WebSocketRoomId): void {
103
+ const record = this.sessions.get(sessionId);
104
+ if (!record) {
105
+ return;
106
+ }
107
+
108
+ record.rooms.delete(roomId);
109
+ removeBinding(this.rooms, record.namespace, roomId, sessionId);
110
+ }
111
+
112
+ getConnection(sessionId: string): WebSocketSessionPresence | undefined {
113
+ const record = this.sessions.get(sessionId);
114
+ return record ? copyPresence(record) : undefined;
115
+ }
116
+
117
+ getConnectionCount(namespace?: string): number {
118
+ let count = 0;
119
+ for (const record of this.sessions.values()) {
120
+ if (record.active && matchesNamespace(record, namespace)) {
121
+ count += 1;
122
+ }
123
+ }
124
+ return count;
125
+ }
126
+
127
+ getRoomMembers(roomId: WebSocketRoomId, namespace?: string): WebSocketSessionPresence[] {
128
+ return this.getBoundSessions(this.rooms, roomId, namespace).map(copyPresence);
129
+ }
130
+
131
+ getRoomMemberCount(roomId: WebSocketRoomId, namespace?: string): number {
132
+ return this.getBoundSessions(this.rooms, roomId, namespace).length;
133
+ }
134
+
135
+ getStats(): WebSocketRegistryStats {
136
+ const byNamespace: Record<string, number> = {};
137
+ let totalConnections = 0;
138
+
139
+ for (const record of this.sessions.values()) {
140
+ if (!record.active) {
141
+ continue;
142
+ }
143
+ byNamespace[record.namespace] = (byNamespace[record.namespace] ?? 0) + 1;
144
+ totalConnections += 1;
145
+ }
146
+
147
+ let totalRooms = 0;
148
+ for (const bindings of this.rooms.values()) {
149
+ totalRooms += bindings.size;
150
+ }
151
+
152
+ return { totalConnections, totalRooms, byNamespace };
153
+ }
154
+
155
+ /**
156
+ * audience에 해당하는 활성 세션을 중복 없이 반환한다. 반환값은 내부 상태를 노출하지 않도록 ID와 노드 ID만 담는다.
157
+ */
158
+ queryAudience(audience: WebSocketAudience): Array<{ sessionId: string; nodeId: string }> {
159
+ const seen = new Set<string>();
160
+ const results: Array<{ sessionId: string; nodeId: string }> = [];
161
+
162
+ for (const record of this.collectAudience(audience)) {
163
+ if (seen.has(record.sessionId)) {
164
+ continue;
165
+ }
166
+ seen.add(record.sessionId);
167
+ results.push({ sessionId: record.sessionId, nodeId: record.nodeId });
168
+ }
169
+
170
+ return results;
171
+ }
172
+
173
+ private collectAudience(audience: WebSocketAudience): PresenceRecord[] {
174
+ switch (audience.type) {
175
+ case "all":
176
+ return [...this.sessions.values()].filter(
177
+ (record) => record.active && matchesNamespace(record, audience.namespace),
178
+ );
179
+ case "room":
180
+ return this.getBoundSessions(this.rooms, audience.roomId, audience.namespace);
181
+ case "user":
182
+ return this.getBoundSessions(this.users, audience.userId, audience.namespace);
183
+ case "connections": {
184
+ const records: PresenceRecord[] = [];
185
+ for (const sessionId of audience.connectionIds) {
186
+ const record = this.sessions.get(sessionId);
187
+ if (record?.active && matchesNamespace(record, audience.namespace)) {
188
+ records.push(record);
189
+ }
190
+ }
191
+ return records;
192
+ }
193
+ case "union":
194
+ return audience.audiences.flatMap((item) => this.collectAudience(item));
195
+ }
196
+ }
197
+
198
+ private getBoundSessions<Id>(
199
+ index: BindingIndex<Id>,
200
+ id: Id,
201
+ namespace?: string,
202
+ ): PresenceRecord[] {
203
+ // namespace가 없으면(빈 문자열 포함) 모든 namespace의 바인딩을 본다
204
+ const namespaces = namespace ? [index.get(namespace)] : [...index.values()];
205
+ const seen = new Set<string>();
206
+ const records: PresenceRecord[] = [];
207
+
208
+ for (const bindings of namespaces) {
209
+ for (const sessionId of bindings?.get(id) ?? []) {
210
+ if (seen.has(sessionId)) {
211
+ continue;
212
+ }
213
+ seen.add(sessionId);
214
+
215
+ const record = this.sessions.get(sessionId);
216
+ if (record?.active) {
217
+ records.push(record);
218
+ }
219
+ }
220
+ }
221
+
222
+ return records;
223
+ }
224
+ }
225
+
226
+ function matchesNamespace(record: PresenceRecord, namespace: string | undefined): boolean {
227
+ return namespace === undefined || namespace === "" || record.namespace === namespace;
228
+ }
229
+
230
+ function copyPresence(record: PresenceRecord): WebSocketSessionPresence {
231
+ const copy: WebSocketSessionPresence = {
232
+ sessionId: record.sessionId,
233
+ nodeId: record.nodeId,
234
+ namespace: record.namespace,
235
+ active: record.active,
236
+ rooms: new Set(record.rooms),
237
+ connectedAt: new Date(record.connectedAt.getTime()),
238
+ lastSeenAt: new Date(record.lastSeenAt.getTime()),
239
+ };
240
+ if (record.userId !== undefined) {
241
+ copy.userId = record.userId;
242
+ }
243
+ return copy;
244
+ }
245
+
246
+ function addBinding<Id>(index: BindingIndex<Id>, namespace: string, id: Id, sessionId: string) {
247
+ const bindings = index.get(namespace) ?? new Map<Id, Set<string>>();
248
+ const members = bindings.get(id) ?? new Set<string>();
249
+ members.add(sessionId);
250
+ bindings.set(id, members);
251
+ index.set(namespace, bindings);
252
+ }
253
+
254
+ function removeBinding<Id>(
255
+ index: BindingIndex<Id>,
256
+ namespace: string,
257
+ id: Id,
258
+ sessionId: string,
259
+ ): void {
260
+ const bindings = index.get(namespace);
261
+ const members = bindings?.get(id);
262
+ if (!bindings || !members) {
263
+ return;
264
+ }
265
+
266
+ members.delete(sessionId);
267
+ if (members.size === 0) {
268
+ bindings.delete(id);
269
+ }
270
+ if (bindings.size === 0) {
271
+ index.delete(namespace);
272
+ }
273
+ }