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,138 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
import { type WebSocketAudience } from "./ws-audience";
|
|
4
|
+
import { type WebSocketAudienceResolver } from "./ws-audience-resolver";
|
|
5
|
+
import { type WebSocketClusterBus, type WebSocketClusterEnvelope } from "./ws-cluster-bus";
|
|
6
|
+
import { type ManagedWebSocketConnection } from "./ws-core";
|
|
7
|
+
import { type WebSocketLocalConnectionStore } from "./ws-local-connection-store";
|
|
8
|
+
|
|
9
|
+
// fan-out을 event loop tick 단위로 나눠 한 번에 긴 동기 루프를 만들지 않게 함
|
|
10
|
+
const FAN_OUT_BATCH_SIZE = 250;
|
|
11
|
+
|
|
12
|
+
export class WebSocketDeliveryEngine {
|
|
13
|
+
private readonly pendingFanOutJobs: Array<{
|
|
14
|
+
targets: ManagedWebSocketConnection[];
|
|
15
|
+
event: string;
|
|
16
|
+
data: unknown;
|
|
17
|
+
cursor: number;
|
|
18
|
+
}> = [];
|
|
19
|
+
private fanOutFlushScheduled = false;
|
|
20
|
+
private readonly unsubscribe: () => void;
|
|
21
|
+
|
|
22
|
+
constructor(
|
|
23
|
+
private readonly options: {
|
|
24
|
+
nodeId: string;
|
|
25
|
+
localConnections: WebSocketLocalConnectionStore;
|
|
26
|
+
audienceResolver: WebSocketAudienceResolver;
|
|
27
|
+
clusterBus: WebSocketClusterBus;
|
|
28
|
+
},
|
|
29
|
+
) {
|
|
30
|
+
this.unsubscribe = this.options.clusterBus.subscribe((envelope) => {
|
|
31
|
+
this.handleClusterEnvelope(envelope);
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
publishToAudience(audience: WebSocketAudience, event: string, data: unknown): void {
|
|
36
|
+
const routingPlan = this.options.audienceResolver.resolve(audience);
|
|
37
|
+
const localTargets = this.options.localConnections.getConnections(routingPlan.localSessionIds);
|
|
38
|
+
this.enqueueFanOut(localTargets, event, data);
|
|
39
|
+
|
|
40
|
+
if (routingPlan.remoteNodeIds.length === 0) {
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
void this.options.clusterBus.publish({
|
|
45
|
+
id: randomUUID(),
|
|
46
|
+
sourceNodeId: this.options.nodeId,
|
|
47
|
+
targetNodeIds: routingPlan.remoteNodeIds,
|
|
48
|
+
audience,
|
|
49
|
+
event,
|
|
50
|
+
data,
|
|
51
|
+
emittedAt: Date.now(),
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async shutdown(): Promise<void> {
|
|
56
|
+
this.unsubscribe();
|
|
57
|
+
await this.options.clusterBus.shutdown();
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
private handleClusterEnvelope(envelope: WebSocketClusterEnvelope): void {
|
|
61
|
+
if (envelope.sourceNodeId === this.options.nodeId) {
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (
|
|
66
|
+
envelope.targetNodeIds &&
|
|
67
|
+
envelope.targetNodeIds.length > 0 &&
|
|
68
|
+
!envelope.targetNodeIds.includes(this.options.nodeId)
|
|
69
|
+
) {
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const routingPlan = this.options.audienceResolver.resolve(envelope.audience);
|
|
74
|
+
this.enqueueFanOut(
|
|
75
|
+
this.options.localConnections.getConnections(routingPlan.localSessionIds),
|
|
76
|
+
envelope.event,
|
|
77
|
+
envelope.data,
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
private enqueueFanOut(targets: ManagedWebSocketConnection[], event: string, data: unknown): void {
|
|
82
|
+
if (targets.length === 0) {
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
this.pendingFanOutJobs.push({
|
|
87
|
+
targets,
|
|
88
|
+
event,
|
|
89
|
+
data,
|
|
90
|
+
cursor: 0,
|
|
91
|
+
});
|
|
92
|
+
this.scheduleFanOutFlush();
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
private scheduleFanOutFlush(): void {
|
|
96
|
+
if (this.fanOutFlushScheduled) {
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
this.fanOutFlushScheduled = true;
|
|
101
|
+
setImmediate(() => {
|
|
102
|
+
this.fanOutFlushScheduled = false;
|
|
103
|
+
this.flushFanOutJobs();
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
private flushFanOutJobs(): void {
|
|
108
|
+
let processed = 0;
|
|
109
|
+
|
|
110
|
+
while (this.pendingFanOutJobs.length > 0 && processed < FAN_OUT_BATCH_SIZE) {
|
|
111
|
+
const job = this.pendingFanOutJobs[0];
|
|
112
|
+
|
|
113
|
+
while (job.cursor < job.targets.length && processed < FAN_OUT_BATCH_SIZE) {
|
|
114
|
+
this.safePublish(job.targets[job.cursor], job.event, job.data);
|
|
115
|
+
job.cursor += 1;
|
|
116
|
+
processed += 1;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (job.cursor >= job.targets.length) {
|
|
120
|
+
this.pendingFanOutJobs.shift();
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (this.pendingFanOutJobs.length > 0) {
|
|
125
|
+
this.scheduleFanOutFlush();
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
private safePublish(connection: ManagedWebSocketConnection, event: string, data: unknown): void {
|
|
130
|
+
try {
|
|
131
|
+
if (!connection.closed) {
|
|
132
|
+
connection.publishUntyped(event, data);
|
|
133
|
+
}
|
|
134
|
+
} catch {
|
|
135
|
+
connection.close(1011, "WebSocket publish failed");
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { type ManagedWebSocketConnection } from "./ws-core";
|
|
2
|
+
|
|
3
|
+
export class WebSocketLocalConnectionStore {
|
|
4
|
+
private readonly connections = new Map<string, ManagedWebSocketConnection>();
|
|
5
|
+
|
|
6
|
+
register(connection: ManagedWebSocketConnection): void {
|
|
7
|
+
this.connections.set(connection.id, connection);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
unregister(connectionId: string): void {
|
|
11
|
+
this.connections.delete(connectionId);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
getConnection(connectionId: string): ManagedWebSocketConnection | undefined {
|
|
15
|
+
return this.connections.get(connectionId);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
getConnections(connectionIds: string[]): ManagedWebSocketConnection[] {
|
|
19
|
+
const targets: ManagedWebSocketConnection[] = [];
|
|
20
|
+
const seen = new Set<string>();
|
|
21
|
+
|
|
22
|
+
for (const connectionId of connectionIds) {
|
|
23
|
+
if (seen.has(connectionId)) {
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
seen.add(connectionId);
|
|
27
|
+
|
|
28
|
+
const connection = this.connections.get(connectionId);
|
|
29
|
+
if (!connection || connection.closed) {
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
targets.push(connection);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return targets;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
closeAll(code?: number, reason?: string): void {
|
|
40
|
+
for (const connection of this.connections.values()) {
|
|
41
|
+
connection.close(code, reason);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
import { type WebSocketAudience } from "./ws-audience";
|
|
2
|
+
import { type WebSocketRegistryStats, type WebSocketRoomId, type WebSocketUserId } from "./ws-core";
|
|
3
|
+
|
|
4
|
+
export type WebSocketSessionPresence = {
|
|
5
|
+
sessionId: string;
|
|
6
|
+
nodeId: string;
|
|
7
|
+
namespace: string;
|
|
8
|
+
active: boolean;
|
|
9
|
+
rooms: Set<WebSocketRoomId>;
|
|
10
|
+
connectedAt: Date;
|
|
11
|
+
lastSeenAt: Date;
|
|
12
|
+
userId?: WebSocketUserId;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
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[];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
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>>();
|
|
40
|
+
|
|
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;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
activate(sessionId: string): void {
|
|
61
|
+
const meta = this.connections.get(sessionId);
|
|
62
|
+
if (!meta) {
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
meta.active = true;
|
|
67
|
+
}
|
|
68
|
+
|
|
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();
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
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);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
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;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
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);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
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;
|
|
241
|
+
}
|
|
242
|
+
|
|
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>();
|
|
250
|
+
|
|
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
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
return metas;
|
|
266
|
+
}
|
|
267
|
+
|
|
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
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
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
|
+
}
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function getBindingKey(namespace: string, id: string): string {
|
|
317
|
+
return `${namespace}::${id}`;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function parseBindingKey(key: string): { namespace: string; id: string } {
|
|
321
|
+
const [namespace, ...rest] = key.split("::");
|
|
322
|
+
return {
|
|
323
|
+
namespace,
|
|
324
|
+
id: rest.join("::"),
|
|
325
|
+
};
|
|
326
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { WebSocketAudience, type WebSocketAudience as WebSocketAudienceSpec } from "./ws-audience";
|
|
2
|
+
import { WebSocketAudienceResolver } from "./ws-audience-resolver";
|
|
3
|
+
import { NoopWebSocketClusterBus, type WebSocketClusterBus } from "./ws-cluster-bus";
|
|
4
|
+
import {
|
|
5
|
+
type ManagedWebSocketConnection,
|
|
6
|
+
type WebSocketRegistryStats,
|
|
7
|
+
type WebSocketRoomId,
|
|
8
|
+
type WebSocketUserId,
|
|
9
|
+
} from "./ws-core";
|
|
10
|
+
import { WebSocketDeliveryEngine } from "./ws-delivery";
|
|
11
|
+
import { WebSocketLocalConnectionStore } from "./ws-local-connection-store";
|
|
12
|
+
import {
|
|
13
|
+
InMemoryWebSocketPresenceStore,
|
|
14
|
+
type WebSocketPresenceStore,
|
|
15
|
+
type WebSocketSessionPresence,
|
|
16
|
+
} from "./ws-presence-store";
|
|
17
|
+
|
|
18
|
+
export type {
|
|
19
|
+
ManagedWebSocketConnection,
|
|
20
|
+
WebSocketRegistryStats,
|
|
21
|
+
WebSocketRoomId,
|
|
22
|
+
WebSocketUserId,
|
|
23
|
+
};
|
|
24
|
+
export type WebSocketConnectionMeta = WebSocketSessionPresence;
|
|
25
|
+
|
|
26
|
+
export type WebSocketRegistryOptions = {
|
|
27
|
+
nodeId?: string;
|
|
28
|
+
presenceStore?: WebSocketPresenceStore;
|
|
29
|
+
clusterBus?: WebSocketClusterBus;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export class WebSocketRegistry {
|
|
33
|
+
readonly nodeId: string;
|
|
34
|
+
readonly localConnections = new WebSocketLocalConnectionStore();
|
|
35
|
+
readonly presenceStore: WebSocketPresenceStore;
|
|
36
|
+
readonly clusterBus: WebSocketClusterBus;
|
|
37
|
+
readonly audienceResolver: WebSocketAudienceResolver;
|
|
38
|
+
readonly deliveryEngine: WebSocketDeliveryEngine;
|
|
39
|
+
|
|
40
|
+
constructor(options: WebSocketRegistryOptions = {}) {
|
|
41
|
+
this.nodeId = options.nodeId ?? "local";
|
|
42
|
+
this.presenceStore = options.presenceStore ?? new InMemoryWebSocketPresenceStore();
|
|
43
|
+
this.clusterBus = options.clusterBus ?? new NoopWebSocketClusterBus();
|
|
44
|
+
this.audienceResolver = new WebSocketAudienceResolver({
|
|
45
|
+
nodeId: this.nodeId,
|
|
46
|
+
presenceStore: this.presenceStore,
|
|
47
|
+
});
|
|
48
|
+
this.deliveryEngine = new WebSocketDeliveryEngine({
|
|
49
|
+
nodeId: this.nodeId,
|
|
50
|
+
localConnections: this.localConnections,
|
|
51
|
+
audienceResolver: this.audienceResolver,
|
|
52
|
+
clusterBus: this.clusterBus,
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
register(
|
|
57
|
+
connection: ManagedWebSocketConnection,
|
|
58
|
+
active: boolean = true,
|
|
59
|
+
): WebSocketConnectionMeta {
|
|
60
|
+
this.localConnections.register(connection);
|
|
61
|
+
return this.presenceStore.register({
|
|
62
|
+
sessionId: connection.id,
|
|
63
|
+
nodeId: this.nodeId,
|
|
64
|
+
namespace: connection.namespace,
|
|
65
|
+
active,
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
activate(connectionId: string): void {
|
|
70
|
+
this.presenceStore.activate(connectionId);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
unregister(connectionId: string): void {
|
|
74
|
+
this.presenceStore.unregister(connectionId);
|
|
75
|
+
this.localConnections.unregister(connectionId);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
touch(connectionId: string): void {
|
|
79
|
+
this.presenceStore.touch(connectionId);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
setUserId(connectionId: string, userId: WebSocketUserId): void {
|
|
83
|
+
this.presenceStore.setUserId(connectionId, userId);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
clearUserId(connectionId: string): void {
|
|
87
|
+
this.presenceStore.clearUserId(connectionId);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
join(connectionId: string, roomId: WebSocketRoomId): void {
|
|
91
|
+
this.presenceStore.join(connectionId, roomId);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
leave(connectionId: string, roomId: WebSocketRoomId): void {
|
|
95
|
+
this.presenceStore.leave(connectionId, roomId);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
broadcast(event: string, data: unknown, namespace?: string): void {
|
|
99
|
+
this.publishToAudience(WebSocketAudience.all(namespace), event, data);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
publishToRoom(roomId: WebSocketRoomId, event: string, data: unknown, namespace?: string): void {
|
|
103
|
+
this.publishToAudience(WebSocketAudience.room(roomId, namespace), event, data);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
publishToUser(userId: WebSocketUserId, event: string, data: unknown, namespace?: string): void {
|
|
107
|
+
this.publishToAudience(WebSocketAudience.user(userId, namespace), event, data);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
publishToAudience(audience: WebSocketAudienceSpec, event: string, data: unknown): void {
|
|
111
|
+
this.deliveryEngine.publishToAudience(audience, event, data);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
getConnection(connectionId: string): WebSocketConnectionMeta | undefined {
|
|
115
|
+
return this.presenceStore.getConnection(connectionId);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
getConnectionCount(namespace?: string): number {
|
|
119
|
+
return this.presenceStore.getConnectionCount(namespace);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
getRoomMembers(roomId: WebSocketRoomId, namespace?: string): WebSocketConnectionMeta[] {
|
|
123
|
+
return this.presenceStore.getRoomMembers(roomId, namespace);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
getStats(): WebSocketRegistryStats {
|
|
127
|
+
return this.presenceStore.getStats();
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
closeAll(code?: number, reason?: string): void {
|
|
131
|
+
this.localConnections.closeAll(code, reason);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async shutdown(code?: number, reason?: string): Promise<void> {
|
|
135
|
+
this.closeAll(code, reason);
|
|
136
|
+
await this.deliveryEngine.shutdown();
|
|
137
|
+
}
|
|
138
|
+
}
|