sonamu 0.9.5 → 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/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/dict/sonamu-dictionary.js +5 -5
- 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.js +7 -7
- 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/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.js +1 -1
- package/dist/ui/cdd-service.js +1 -1
- package/dist/ui-web/assets/{index-DzZ7vBk4.js → index-BmThfg-s.js} +37 -37
- package/dist/ui-web/index.html +1 -1
- 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 +4 -2
- 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/dict/sonamu-dictionary.ts +2 -2
- package/src/entity/entity.ts +1 -1
- package/src/index.ts +6 -0
- package/src/migration/code-generation.ts +5 -5
- package/src/shared/app.shared.ts.txt +254 -1
- package/src/shared/web.shared.ts.txt +282 -1
- 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/types/types.ts +20 -2
- 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
|
@@ -326,8 +326,23 @@ export type SSEStreamState = {
|
|
|
326
326
|
retryCount: number;
|
|
327
327
|
isEnded: boolean;
|
|
328
328
|
};
|
|
329
|
+
export type WebSocketChannelOptions = {
|
|
330
|
+
enabled?: boolean;
|
|
331
|
+
retry?: number;
|
|
332
|
+
retryInterval?: number;
|
|
333
|
+
protocols?: string | string[];
|
|
334
|
+
};
|
|
335
|
+
export type WebSocketChannelState<TSend extends Record<string, any>> = {
|
|
336
|
+
isConnected: boolean;
|
|
337
|
+
error: string | null;
|
|
338
|
+
retryCount: number;
|
|
339
|
+
readyState: number;
|
|
340
|
+
send<K extends keyof TSend>(event: K, data: TSend[K]): void;
|
|
341
|
+
close(code?: number, reason?: string): void;
|
|
342
|
+
};
|
|
343
|
+
// outbound event 전체를 강제하지 않도록 handler를 optional map으로 둠
|
|
329
344
|
export type EventHandlers<T> = {
|
|
330
|
-
[K in keyof T]
|
|
345
|
+
[K in keyof T]?: (data: T[K]) => void;
|
|
331
346
|
};
|
|
332
347
|
|
|
333
348
|
export function useSSEStream<T extends Record<string, any>>(
|
|
@@ -551,6 +566,272 @@ export function useSSEStream<T extends Record<string, any>>(
|
|
|
551
566
|
return state;
|
|
552
567
|
}
|
|
553
568
|
|
|
569
|
+
export function useWebSocketChannel<
|
|
570
|
+
TReceive extends Record<string, any>,
|
|
571
|
+
TSend extends Record<string, any>,
|
|
572
|
+
>(
|
|
573
|
+
url: string,
|
|
574
|
+
params: Record<string, any>,
|
|
575
|
+
handlers: EventHandlers<TReceive>,
|
|
576
|
+
options: WebSocketChannelOptions = {},
|
|
577
|
+
): WebSocketChannelState<TSend> {
|
|
578
|
+
const { enabled = true, retry = 3, retryInterval = 3000, protocols } = options;
|
|
579
|
+
|
|
580
|
+
const [state, setState] = useState<Omit<WebSocketChannelState<TSend>, "send" | "close">>({
|
|
581
|
+
isConnected: false,
|
|
582
|
+
error: null,
|
|
583
|
+
retryCount: 0,
|
|
584
|
+
readyState: 3,
|
|
585
|
+
});
|
|
586
|
+
|
|
587
|
+
const socketRef = useRef<WebSocket | null>(null);
|
|
588
|
+
const retryTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
589
|
+
const handlersRef = useRef(handlers);
|
|
590
|
+
const manualCloseRef = useRef(false);
|
|
591
|
+
// 최신 연결 식별자를 따로 두어 stale socket 이벤트가 상태를 덮지 못하게 함
|
|
592
|
+
const connectionIdRef = useRef(0);
|
|
593
|
+
|
|
594
|
+
useEffect(() => {
|
|
595
|
+
handlersRef.current = handlers;
|
|
596
|
+
}, [handlers]);
|
|
597
|
+
|
|
598
|
+
const close = (code?: number, reason?: string) => {
|
|
599
|
+
manualCloseRef.current = true;
|
|
600
|
+
if (retryTimeoutRef.current) {
|
|
601
|
+
clearTimeout(retryTimeoutRef.current);
|
|
602
|
+
retryTimeoutRef.current = null;
|
|
603
|
+
}
|
|
604
|
+
if (socketRef.current) {
|
|
605
|
+
socketRef.current.close(code, reason);
|
|
606
|
+
}
|
|
607
|
+
};
|
|
608
|
+
|
|
609
|
+
const send = <K extends keyof TSend>(event: K, data: TSend[K]) => {
|
|
610
|
+
const socket = socketRef.current;
|
|
611
|
+
if (!socket || socket.readyState !== WebSocket.OPEN) {
|
|
612
|
+
setState((prev) => ({
|
|
613
|
+
...prev,
|
|
614
|
+
error: "WebSocket is not connected",
|
|
615
|
+
}));
|
|
616
|
+
return;
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
socket.send(
|
|
620
|
+
JSON.stringify({
|
|
621
|
+
event,
|
|
622
|
+
data,
|
|
623
|
+
}),
|
|
624
|
+
);
|
|
625
|
+
};
|
|
626
|
+
|
|
627
|
+
const connect = () => {
|
|
628
|
+
if (!enabled) {
|
|
629
|
+
return;
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
const connectionId = ++connectionIdRef.current;
|
|
633
|
+
manualCloseRef.current = false;
|
|
634
|
+
|
|
635
|
+
if (socketRef.current) {
|
|
636
|
+
socketRef.current.close();
|
|
637
|
+
socketRef.current = null;
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
if (retryTimeoutRef.current) {
|
|
641
|
+
clearTimeout(retryTimeoutRef.current);
|
|
642
|
+
retryTimeoutRef.current = null;
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
const fullUrl = resolveWebSocketUrl(url, params);
|
|
646
|
+
const socket = new WebSocket(fullUrl, protocols);
|
|
647
|
+
socketRef.current = socket;
|
|
648
|
+
|
|
649
|
+
setState((prev) => ({
|
|
650
|
+
...prev,
|
|
651
|
+
isConnected: false,
|
|
652
|
+
error: null,
|
|
653
|
+
readyState: socket.readyState,
|
|
654
|
+
}));
|
|
655
|
+
|
|
656
|
+
// socketRef.current !== socket 가드는 이전 연결의 늦은 콜백을 무시하기 위한 장치임
|
|
657
|
+
socket.addEventListener("open", () => {
|
|
658
|
+
if (socketRef.current !== socket) {
|
|
659
|
+
return;
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
setState((prev) => ({
|
|
663
|
+
...prev,
|
|
664
|
+
isConnected: true,
|
|
665
|
+
error: null,
|
|
666
|
+
retryCount: 0,
|
|
667
|
+
readyState: socket.readyState,
|
|
668
|
+
}));
|
|
669
|
+
});
|
|
670
|
+
|
|
671
|
+
socket.addEventListener("message", (event) => {
|
|
672
|
+
if (socketRef.current !== socket) {
|
|
673
|
+
return;
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
try {
|
|
677
|
+
const payload = JSON.parse(event.data, dateReviver) as {
|
|
678
|
+
event: keyof TReceive;
|
|
679
|
+
data: TReceive[keyof TReceive];
|
|
680
|
+
};
|
|
681
|
+
const handler = handlersRef.current[payload.event];
|
|
682
|
+
if (handler) {
|
|
683
|
+
handler(payload.data);
|
|
684
|
+
}
|
|
685
|
+
} catch (error) {
|
|
686
|
+
console.error("Failed to parse WebSocket message:", error);
|
|
687
|
+
}
|
|
688
|
+
});
|
|
689
|
+
|
|
690
|
+
socket.addEventListener("error", () => {
|
|
691
|
+
if (socketRef.current !== socket) {
|
|
692
|
+
return;
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
setState((prev) => ({
|
|
696
|
+
...prev,
|
|
697
|
+
isConnected: false,
|
|
698
|
+
error: "WebSocket connection failed",
|
|
699
|
+
readyState: socket.readyState,
|
|
700
|
+
}));
|
|
701
|
+
});
|
|
702
|
+
|
|
703
|
+
socket.addEventListener("close", (event) => {
|
|
704
|
+
if (socketRef.current !== socket) {
|
|
705
|
+
return;
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
socketRef.current = null;
|
|
709
|
+
|
|
710
|
+
setState((prev) => ({
|
|
711
|
+
...prev,
|
|
712
|
+
isConnected: false,
|
|
713
|
+
readyState: socket.readyState,
|
|
714
|
+
}));
|
|
715
|
+
|
|
716
|
+
if (manualCloseRef.current || connectionIdRef.current !== connectionId) {
|
|
717
|
+
return;
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
// 정책 위반/과대 payload close는 재시도보다 명시적 에러 노출이 우선임
|
|
721
|
+
if (!isRetryableWebSocketCloseCode(event.code)) {
|
|
722
|
+
setState((prev) => ({
|
|
723
|
+
...prev,
|
|
724
|
+
error:
|
|
725
|
+
event.code === 1008 || event.code === 1009
|
|
726
|
+
? `WebSocket rejected by server (code: ${event.code})`
|
|
727
|
+
: `WebSocket closed (code: ${event.code})`,
|
|
728
|
+
}));
|
|
729
|
+
return;
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
setState((prev) => {
|
|
733
|
+
if (prev.retryCount >= retry) {
|
|
734
|
+
return {
|
|
735
|
+
...prev,
|
|
736
|
+
error: `Connection failed after ${retry} attempts`,
|
|
737
|
+
};
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
retryTimeoutRef.current = setTimeout(() => {
|
|
741
|
+
if (connectionIdRef.current !== connectionId) {
|
|
742
|
+
return;
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
setState((inner) => ({
|
|
746
|
+
...inner,
|
|
747
|
+
retryCount: inner.retryCount + 1,
|
|
748
|
+
}));
|
|
749
|
+
connect();
|
|
750
|
+
}, retryInterval);
|
|
751
|
+
|
|
752
|
+
return prev;
|
|
753
|
+
});
|
|
754
|
+
});
|
|
755
|
+
};
|
|
756
|
+
|
|
757
|
+
useEffect(() => {
|
|
758
|
+
if (enabled) {
|
|
759
|
+
setState({
|
|
760
|
+
isConnected: false,
|
|
761
|
+
error: null,
|
|
762
|
+
retryCount: 0,
|
|
763
|
+
readyState: 3,
|
|
764
|
+
});
|
|
765
|
+
connect();
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
return () => {
|
|
769
|
+
connectionIdRef.current += 1;
|
|
770
|
+
manualCloseRef.current = true;
|
|
771
|
+
if (socketRef.current) {
|
|
772
|
+
socketRef.current.close();
|
|
773
|
+
socketRef.current = null;
|
|
774
|
+
}
|
|
775
|
+
if (retryTimeoutRef.current) {
|
|
776
|
+
clearTimeout(retryTimeoutRef.current);
|
|
777
|
+
retryTimeoutRef.current = null;
|
|
778
|
+
}
|
|
779
|
+
};
|
|
780
|
+
}, [url, JSON.stringify(params), enabled, JSON.stringify(protocols)]);
|
|
781
|
+
|
|
782
|
+
return {
|
|
783
|
+
...state,
|
|
784
|
+
send,
|
|
785
|
+
close,
|
|
786
|
+
};
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
function isRetryableWebSocketCloseCode(code: number): boolean {
|
|
790
|
+
if (code === 1000) {
|
|
791
|
+
return false;
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
return ![1002, 1003, 1007, 1008, 1009].includes(code);
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
function resolveWebSocketUrl(url: string, params: Record<string, any>): string {
|
|
798
|
+
const queryString = qs.stringify(params);
|
|
799
|
+
const withQuery = queryString ? `${url}?${queryString}` : url;
|
|
800
|
+
|
|
801
|
+
if (withQuery.startsWith("ws://") || withQuery.startsWith("wss://")) {
|
|
802
|
+
return withQuery;
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
const baseUrl = resolveWebSocketBaseUrl();
|
|
806
|
+
return new URL(withQuery, baseUrl).toString();
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
function resolveWebSocketBaseUrl(): string {
|
|
810
|
+
const configuredBaseUrl = axios.defaults.baseURL;
|
|
811
|
+
// HTTP client와 WS client가 다른 origin으로 갈라지지 않게 axios baseURL을 우선 존중함
|
|
812
|
+
if (configuredBaseUrl) {
|
|
813
|
+
const absoluteBaseUrl =
|
|
814
|
+
typeof window !== "undefined"
|
|
815
|
+
? new URL(configuredBaseUrl, window.location.origin).toString()
|
|
816
|
+
: configuredBaseUrl;
|
|
817
|
+
return toWebSocketBaseUrl(absoluteBaseUrl);
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
if (typeof window !== "undefined") {
|
|
821
|
+
return toWebSocketBaseUrl(window.location.origin);
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
return toWebSocketBaseUrl("$[[baseUrl]]");
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
function toWebSocketBaseUrl(baseUrl: string): string {
|
|
828
|
+
if (baseUrl.startsWith("ws://") || baseUrl.startsWith("wss://")) {
|
|
829
|
+
return baseUrl;
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
return baseUrl.replace(/^http:/, "ws:").replace(/^https:/, "wss:");
|
|
833
|
+
}
|
|
834
|
+
|
|
554
835
|
/*
|
|
555
836
|
Dictionary Helper
|
|
556
837
|
*/
|
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { WebSocketAudience } from "../ws-audience";
|
|
4
|
+
import { WebSocketAudienceResolver } from "../ws-audience-resolver";
|
|
5
|
+
import {
|
|
6
|
+
NoopWebSocketClusterBus,
|
|
7
|
+
type WebSocketClusterBus,
|
|
8
|
+
type WebSocketClusterEnvelope,
|
|
9
|
+
type WebSocketClusterEnvelopeHandler,
|
|
10
|
+
} from "../ws-cluster-bus";
|
|
11
|
+
import { type ManagedWebSocketConnection } from "../ws-core";
|
|
12
|
+
import { WebSocketDeliveryEngine } from "../ws-delivery";
|
|
13
|
+
import { WebSocketLocalConnectionStore } from "../ws-local-connection-store";
|
|
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 ContractClusterBus implements WebSocketClusterBus {
|
|
25
|
+
readonly published: WebSocketClusterEnvelope[] = [];
|
|
26
|
+
private readonly handlers = new Set<WebSocketClusterEnvelopeHandler>();
|
|
27
|
+
|
|
28
|
+
publish(envelope: WebSocketClusterEnvelope): void {
|
|
29
|
+
this.published.push(envelope);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
subscribe(handler: WebSocketClusterEnvelopeHandler): () => void {
|
|
33
|
+
this.handlers.add(handler);
|
|
34
|
+
return () => {
|
|
35
|
+
this.handlers.delete(handler);
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
emit(envelope: WebSocketClusterEnvelope): void {
|
|
40
|
+
for (const handler of this.handlers) {
|
|
41
|
+
handler(envelope);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
shutdown(): void {
|
|
46
|
+
this.handlers.clear();
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
class ContractConnection implements ManagedWebSocketConnection {
|
|
51
|
+
readonly sent: Array<{ event: string; data: unknown }> = [];
|
|
52
|
+
closed = false;
|
|
53
|
+
|
|
54
|
+
constructor(
|
|
55
|
+
readonly id: string,
|
|
56
|
+
readonly namespace: string,
|
|
57
|
+
) {}
|
|
58
|
+
|
|
59
|
+
publishUntyped(event: string, data: unknown): void {
|
|
60
|
+
this.sent.push({ event, data });
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
close(): void {
|
|
64
|
+
this.closed = true;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
describe("WebSocket contract tests", () => {
|
|
69
|
+
describe("PresenceStore", () => {
|
|
70
|
+
it("keeps inactive sessions out of counts and audience queries until activation", () => {
|
|
71
|
+
const store = new InMemoryWebSocketPresenceStore();
|
|
72
|
+
store.register({
|
|
73
|
+
sessionId: "s1",
|
|
74
|
+
nodeId: "node-a",
|
|
75
|
+
namespace: "chat",
|
|
76
|
+
active: false,
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
expect(store.getConnectionCount("chat")).toBe(0);
|
|
80
|
+
expect(store.queryAudience(WebSocketAudience.all("chat"))).toHaveLength(0);
|
|
81
|
+
|
|
82
|
+
store.activate("s1");
|
|
83
|
+
|
|
84
|
+
expect(store.getConnectionCount("chat")).toBe(1);
|
|
85
|
+
expect(
|
|
86
|
+
store.queryAudience(WebSocketAudience.all("chat")).map((meta) => meta.sessionId),
|
|
87
|
+
).toEqual(["s1"]);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("isolates room and user bindings by namespace", () => {
|
|
91
|
+
const store = new InMemoryWebSocketPresenceStore();
|
|
92
|
+
store.register({
|
|
93
|
+
sessionId: "chat-1",
|
|
94
|
+
nodeId: "node-a",
|
|
95
|
+
namespace: "chat",
|
|
96
|
+
});
|
|
97
|
+
store.register({
|
|
98
|
+
sessionId: "admin-1",
|
|
99
|
+
nodeId: "node-a",
|
|
100
|
+
namespace: "admin",
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
store.join("chat-1", "room-1");
|
|
104
|
+
store.join("admin-1", "room-1");
|
|
105
|
+
store.setUserId("chat-1", "user-1");
|
|
106
|
+
store.setUserId("admin-1", "user-1");
|
|
107
|
+
|
|
108
|
+
expect(
|
|
109
|
+
store.queryAudience(WebSocketAudience.room("room-1", "chat")).map((meta) => meta.sessionId),
|
|
110
|
+
).toEqual(["chat-1"]);
|
|
111
|
+
expect(
|
|
112
|
+
store
|
|
113
|
+
.queryAudience(WebSocketAudience.user("user-1", "admin"))
|
|
114
|
+
.map((meta) => meta.sessionId),
|
|
115
|
+
).toEqual(["admin-1"]);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("cleans room and user bindings when a session is unregistered", () => {
|
|
119
|
+
const store = new InMemoryWebSocketPresenceStore();
|
|
120
|
+
store.register({
|
|
121
|
+
sessionId: "s1",
|
|
122
|
+
nodeId: "node-a",
|
|
123
|
+
namespace: "chat",
|
|
124
|
+
});
|
|
125
|
+
store.join("s1", "room-1");
|
|
126
|
+
store.setUserId("s1", "user-1");
|
|
127
|
+
|
|
128
|
+
expect(store.unregister("s1")?.sessionId).toBe("s1");
|
|
129
|
+
|
|
130
|
+
expect(store.getConnection("s1")).toBeUndefined();
|
|
131
|
+
expect(store.queryAudience(WebSocketAudience.room("room-1", "chat"))).toHaveLength(0);
|
|
132
|
+
expect(store.queryAudience(WebSocketAudience.user("user-1", "chat"))).toHaveLength(0);
|
|
133
|
+
expect(store.getStats()).toMatchObject({
|
|
134
|
+
totalConnections: 0,
|
|
135
|
+
totalRooms: 0,
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("dedupes union audience results", () => {
|
|
140
|
+
const store = new InMemoryWebSocketPresenceStore();
|
|
141
|
+
store.register({
|
|
142
|
+
sessionId: "s1",
|
|
143
|
+
nodeId: "node-a",
|
|
144
|
+
namespace: "chat",
|
|
145
|
+
});
|
|
146
|
+
store.join("s1", "room-1");
|
|
147
|
+
store.setUserId("s1", "user-1");
|
|
148
|
+
|
|
149
|
+
const metas = store.queryAudience(
|
|
150
|
+
WebSocketAudience.union(
|
|
151
|
+
WebSocketAudience.room("room-1", "chat"),
|
|
152
|
+
WebSocketAudience.user("user-1", "chat"),
|
|
153
|
+
),
|
|
154
|
+
);
|
|
155
|
+
|
|
156
|
+
expect(metas.map((meta) => meta.sessionId)).toEqual(["s1"]);
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
describe("AudienceResolver", () => {
|
|
161
|
+
it("splits local session ids from unique remote node ids", () => {
|
|
162
|
+
const store = new InMemoryWebSocketPresenceStore();
|
|
163
|
+
store.register({
|
|
164
|
+
sessionId: "local-1",
|
|
165
|
+
nodeId: "node-a",
|
|
166
|
+
namespace: "chat",
|
|
167
|
+
});
|
|
168
|
+
store.register({
|
|
169
|
+
sessionId: "remote-1",
|
|
170
|
+
nodeId: "node-b",
|
|
171
|
+
namespace: "chat",
|
|
172
|
+
});
|
|
173
|
+
store.register({
|
|
174
|
+
sessionId: "remote-2",
|
|
175
|
+
nodeId: "node-b",
|
|
176
|
+
namespace: "chat",
|
|
177
|
+
});
|
|
178
|
+
store.join("local-1", "room-1");
|
|
179
|
+
store.join("remote-1", "room-1");
|
|
180
|
+
store.join("remote-2", "room-1");
|
|
181
|
+
const resolver = new WebSocketAudienceResolver({
|
|
182
|
+
nodeId: "node-a",
|
|
183
|
+
presenceStore: store,
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
expect(resolver.resolve(WebSocketAudience.room("room-1", "chat"))).toEqual({
|
|
187
|
+
localSessionIds: ["local-1"],
|
|
188
|
+
remoteNodeIds: ["node-b"],
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
describe("ClusterBus", () => {
|
|
194
|
+
it("allows subscribers to unsubscribe without affecting later publishes", () => {
|
|
195
|
+
const bus = new ContractClusterBus();
|
|
196
|
+
const received: string[] = [];
|
|
197
|
+
const unsubscribe = bus.subscribe((envelope) => {
|
|
198
|
+
received.push(envelope.id);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
bus.emit(createEnvelope("e1"));
|
|
202
|
+
unsubscribe();
|
|
203
|
+
bus.emit(createEnvelope("e2"));
|
|
204
|
+
|
|
205
|
+
expect(received).toEqual(["e1"]);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it("keeps NoopClusterBus publish, subscribe, and shutdown safe", async () => {
|
|
209
|
+
const bus = new NoopWebSocketClusterBus();
|
|
210
|
+
const unsubscribe = bus.subscribe(() => {
|
|
211
|
+
throw new Error("noop bus must not retain handlers");
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
await Promise.resolve(bus.publish(createEnvelope("noop")));
|
|
215
|
+
expect(unsubscribe()).toBeUndefined();
|
|
216
|
+
await Promise.resolve(bus.shutdown());
|
|
217
|
+
});
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
describe("DeliveryEngine", () => {
|
|
221
|
+
it("queues local delivery and reports queued local target count", async () => {
|
|
222
|
+
const presenceStore = new InMemoryWebSocketPresenceStore();
|
|
223
|
+
const localConnections = new WebSocketLocalConnectionStore();
|
|
224
|
+
const clusterBus = new ContractClusterBus();
|
|
225
|
+
const connection = new ContractConnection("s1", "chat");
|
|
226
|
+
localConnections.register(connection);
|
|
227
|
+
presenceStore.register({
|
|
228
|
+
sessionId: "s1",
|
|
229
|
+
nodeId: "node-a",
|
|
230
|
+
namespace: "chat",
|
|
231
|
+
});
|
|
232
|
+
const engine = createDeliveryEngine({
|
|
233
|
+
nodeId: "node-a",
|
|
234
|
+
presenceStore,
|
|
235
|
+
localConnections,
|
|
236
|
+
clusterBus,
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
engine.publishToAudience(WebSocketAudience.all("chat"), "onReady", {
|
|
240
|
+
ok: true,
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
expect(connection.sent).toHaveLength(0);
|
|
244
|
+
|
|
245
|
+
await waitForAsyncQueue();
|
|
246
|
+
|
|
247
|
+
expect(connection.sent).toEqual([
|
|
248
|
+
{
|
|
249
|
+
event: "onReady",
|
|
250
|
+
data: {
|
|
251
|
+
ok: true,
|
|
252
|
+
},
|
|
253
|
+
},
|
|
254
|
+
]);
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it("publishes one remote envelope per remote-node routing plan", async () => {
|
|
258
|
+
const presenceStore = new InMemoryWebSocketPresenceStore();
|
|
259
|
+
const localConnections = new WebSocketLocalConnectionStore();
|
|
260
|
+
const clusterBus = new ContractClusterBus();
|
|
261
|
+
presenceStore.register({
|
|
262
|
+
sessionId: "remote-1",
|
|
263
|
+
nodeId: "node-b",
|
|
264
|
+
namespace: "chat",
|
|
265
|
+
});
|
|
266
|
+
presenceStore.join("remote-1", "room-1");
|
|
267
|
+
const engine = createDeliveryEngine({
|
|
268
|
+
nodeId: "node-a",
|
|
269
|
+
presenceStore,
|
|
270
|
+
localConnections,
|
|
271
|
+
clusterBus,
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
engine.publishToAudience(WebSocketAudience.room("room-1", "chat"), "evt", {
|
|
275
|
+
ok: true,
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
await waitForAsyncQueue();
|
|
279
|
+
|
|
280
|
+
expect(clusterBus.published).toHaveLength(1);
|
|
281
|
+
expect(clusterBus.published[0]).toMatchObject({
|
|
282
|
+
sourceNodeId: "node-a",
|
|
283
|
+
targetNodeIds: ["node-b"],
|
|
284
|
+
event: "evt",
|
|
285
|
+
});
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
it("ignores cluster envelopes from itself or targeted to another node", async () => {
|
|
289
|
+
const presenceStore = new InMemoryWebSocketPresenceStore();
|
|
290
|
+
const localConnections = new WebSocketLocalConnectionStore();
|
|
291
|
+
const clusterBus = new ContractClusterBus();
|
|
292
|
+
const connection = new ContractConnection("s1", "chat");
|
|
293
|
+
localConnections.register(connection);
|
|
294
|
+
presenceStore.register({
|
|
295
|
+
sessionId: "s1",
|
|
296
|
+
nodeId: "node-a",
|
|
297
|
+
namespace: "chat",
|
|
298
|
+
});
|
|
299
|
+
createDeliveryEngine({
|
|
300
|
+
nodeId: "node-a",
|
|
301
|
+
presenceStore,
|
|
302
|
+
localConnections,
|
|
303
|
+
clusterBus,
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
clusterBus.emit(createEnvelope("self", "node-a", ["node-a"]));
|
|
307
|
+
clusterBus.emit(createEnvelope("other-target", "node-b", ["node-c"]));
|
|
308
|
+
|
|
309
|
+
await waitForAsyncQueue();
|
|
310
|
+
|
|
311
|
+
expect(connection.sent).toHaveLength(0);
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
it("delivers accepted cluster envelopes only to local targets", async () => {
|
|
315
|
+
const presenceStore = new InMemoryWebSocketPresenceStore();
|
|
316
|
+
const localConnections = new WebSocketLocalConnectionStore();
|
|
317
|
+
const clusterBus = new ContractClusterBus();
|
|
318
|
+
const connection = new ContractConnection("s1", "chat");
|
|
319
|
+
localConnections.register(connection);
|
|
320
|
+
presenceStore.register({
|
|
321
|
+
sessionId: "s1",
|
|
322
|
+
nodeId: "node-a",
|
|
323
|
+
namespace: "chat",
|
|
324
|
+
});
|
|
325
|
+
createDeliveryEngine({
|
|
326
|
+
nodeId: "node-a",
|
|
327
|
+
presenceStore,
|
|
328
|
+
localConnections,
|
|
329
|
+
clusterBus,
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
clusterBus.emit(createEnvelope("remote", "node-b", ["node-a"]));
|
|
333
|
+
|
|
334
|
+
await waitForAsyncQueue();
|
|
335
|
+
|
|
336
|
+
expect(connection.sent).toEqual([
|
|
337
|
+
{
|
|
338
|
+
event: "evt",
|
|
339
|
+
data: {
|
|
340
|
+
ok: true,
|
|
341
|
+
},
|
|
342
|
+
},
|
|
343
|
+
]);
|
|
344
|
+
});
|
|
345
|
+
});
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
function createDeliveryEngine(input: {
|
|
349
|
+
nodeId: string;
|
|
350
|
+
presenceStore: InMemoryWebSocketPresenceStore;
|
|
351
|
+
localConnections: WebSocketLocalConnectionStore;
|
|
352
|
+
clusterBus: WebSocketClusterBus;
|
|
353
|
+
}): WebSocketDeliveryEngine {
|
|
354
|
+
return new WebSocketDeliveryEngine({
|
|
355
|
+
nodeId: input.nodeId,
|
|
356
|
+
localConnections: input.localConnections,
|
|
357
|
+
audienceResolver: new WebSocketAudienceResolver({
|
|
358
|
+
nodeId: input.nodeId,
|
|
359
|
+
presenceStore: input.presenceStore,
|
|
360
|
+
}),
|
|
361
|
+
clusterBus: input.clusterBus,
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function createEnvelope(
|
|
366
|
+
id: string,
|
|
367
|
+
sourceNodeId = "node-b",
|
|
368
|
+
targetNodeIds: string[] | undefined = ["node-a"],
|
|
369
|
+
): WebSocketClusterEnvelope {
|
|
370
|
+
return {
|
|
371
|
+
id,
|
|
372
|
+
sourceNodeId,
|
|
373
|
+
targetNodeIds,
|
|
374
|
+
audience: WebSocketAudience.all("chat"),
|
|
375
|
+
event: "evt",
|
|
376
|
+
data: {
|
|
377
|
+
ok: true,
|
|
378
|
+
},
|
|
379
|
+
emittedAt: Date.now(),
|
|
380
|
+
};
|
|
381
|
+
}
|