@remember-web/sse 0.1.0-alpha.1
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/README.md +238 -0
- package/dist/src/core/BaseSseClient.cjs.js +97 -0
- package/dist/src/core/BaseSseClient.cjs.js.map +1 -0
- package/dist/src/core/BaseSseClient.d.ts +33 -0
- package/dist/src/core/BaseSseClient.d.ts.map +1 -0
- package/dist/src/core/BaseSseClient.esm.js +89 -0
- package/dist/src/core/BaseSseClient.esm.js.map +1 -0
- package/dist/src/core/SseClient.cjs.js +382 -0
- package/dist/src/core/SseClient.cjs.js.map +1 -0
- package/dist/src/core/SseClient.d.ts +24 -0
- package/dist/src/core/SseClient.d.ts.map +1 -0
- package/dist/src/core/SseClient.esm.js +370 -0
- package/dist/src/core/SseClient.esm.js.map +1 -0
- package/dist/src/core/backoff.cjs.js +30 -0
- package/dist/src/core/backoff.cjs.js.map +1 -0
- package/dist/src/core/backoff.d.ts +12 -0
- package/dist/src/core/backoff.d.ts.map +1 -0
- package/dist/src/core/backoff.esm.js +28 -0
- package/dist/src/core/backoff.esm.js.map +1 -0
- package/dist/src/core/emitter.cjs.js +57 -0
- package/dist/src/core/emitter.cjs.js.map +1 -0
- package/dist/src/core/emitter.d.ts +14 -0
- package/dist/src/core/emitter.d.ts.map +1 -0
- package/dist/src/core/emitter.esm.js +51 -0
- package/dist/src/core/emitter.esm.js.map +1 -0
- package/dist/src/core/eventNames.cjs.js +64 -0
- package/dist/src/core/eventNames.cjs.js.map +1 -0
- package/dist/src/core/eventNames.d.ts +31 -0
- package/dist/src/core/eventNames.d.ts.map +1 -0
- package/dist/src/core/eventNames.esm.js +54 -0
- package/dist/src/core/eventNames.esm.js.map +1 -0
- package/dist/src/core/retryWait.cjs.js +20 -0
- package/dist/src/core/retryWait.cjs.js.map +1 -0
- package/dist/src/core/retryWait.d.ts +6 -0
- package/dist/src/core/retryWait.d.ts.map +1 -0
- package/dist/src/core/retryWait.esm.js +18 -0
- package/dist/src/core/retryWait.esm.js.map +1 -0
- package/dist/src/core/statusMachine.cjs.js +56 -0
- package/dist/src/core/statusMachine.cjs.js.map +1 -0
- package/dist/src/core/statusMachine.d.ts +9 -0
- package/dist/src/core/statusMachine.d.ts.map +1 -0
- package/dist/src/core/statusMachine.esm.js +54 -0
- package/dist/src/core/statusMachine.esm.js.map +1 -0
- package/dist/src/core/stream.cjs.js +136 -0
- package/dist/src/core/stream.cjs.js.map +1 -0
- package/dist/src/core/stream.d.ts +42 -0
- package/dist/src/core/stream.d.ts.map +1 -0
- package/dist/src/core/stream.esm.js +128 -0
- package/dist/src/core/stream.esm.js.map +1 -0
- package/dist/src/core/ticket.cjs.js +237 -0
- package/dist/src/core/ticket.cjs.js.map +1 -0
- package/dist/src/core/ticket.d.ts +31 -0
- package/dist/src/core/ticket.d.ts.map +1 -0
- package/dist/src/core/ticket.esm.js +220 -0
- package/dist/src/core/ticket.esm.js.map +1 -0
- package/dist/src/core/types.cjs.js +54 -0
- package/dist/src/core/types.cjs.js.map +1 -0
- package/dist/src/core/types.d.ts +131 -0
- package/dist/src/core/types.d.ts.map +1 -0
- package/dist/src/core/types.esm.js +50 -0
- package/dist/src/core/types.esm.js.map +1 -0
- package/dist/src/core/wire.cjs.js +29 -0
- package/dist/src/core/wire.cjs.js.map +1 -0
- package/dist/src/core/wire.d.ts +7 -0
- package/dist/src/core/wire.d.ts.map +1 -0
- package/dist/src/core/wire.esm.js +23 -0
- package/dist/src/core/wire.esm.js.map +1 -0
- package/dist/src/createSseClient.cjs.js +30 -0
- package/dist/src/createSseClient.cjs.js.map +1 -0
- package/dist/src/createSseClient.d.ts +8 -0
- package/dist/src/createSseClient.d.ts.map +1 -0
- package/dist/src/createSseClient.esm.js +28 -0
- package/dist/src/createSseClient.esm.js.map +1 -0
- package/dist/src/index.cjs.js +10 -0
- package/dist/src/index.cjs.js.map +1 -0
- package/dist/src/index.d.ts +6 -0
- package/dist/src/index.d.ts.map +1 -0
- package/dist/src/index.esm.js +3 -0
- package/dist/src/index.esm.js.map +1 -0
- package/dist/src/react/createSseContext.cjs.js +130 -0
- package/dist/src/react/createSseContext.cjs.js.map +1 -0
- package/dist/src/react/createSseContext.d.ts +32 -0
- package/dist/src/react/createSseContext.d.ts.map +1 -0
- package/dist/src/react/createSseContext.esm.js +122 -0
- package/dist/src/react/createSseContext.esm.js.map +1 -0
- package/dist/src/react/index.cjs.js +8 -0
- package/dist/src/react/index.cjs.js.map +1 -0
- package/dist/src/react/index.d.ts +3 -0
- package/dist/src/react/index.d.ts.map +1 -0
- package/dist/src/react/index.esm.js +2 -0
- package/dist/src/react/index.esm.js.map +1 -0
- package/dist/src/testing/doubles.d.ts +50 -0
- package/dist/src/testing/doubles.d.ts.map +1 -0
- package/dist/src/testing/harness.d.ts +43 -0
- package/dist/src/testing/harness.d.ts.map +1 -0
- package/dist/src/testing/index.d.ts +3 -0
- package/dist/src/testing/index.d.ts.map +1 -0
- package/dist/src/worker/WorkerSseClient.cjs.js +240 -0
- package/dist/src/worker/WorkerSseClient.cjs.js.map +1 -0
- package/dist/src/worker/WorkerSseClient.d.ts +23 -0
- package/dist/src/worker/WorkerSseClient.d.ts.map +1 -0
- package/dist/src/worker/WorkerSseClient.esm.js +228 -0
- package/dist/src/worker/WorkerSseClient.esm.js.map +1 -0
- package/dist/src/worker/hub.d.ts +19 -0
- package/dist/src/worker/hub.d.ts.map +1 -0
- package/dist/src/worker/portRegistry.d.ts +41 -0
- package/dist/src/worker/portRegistry.d.ts.map +1 -0
- package/dist/src/worker/protocol.d.ts +63 -0
- package/dist/src/worker/protocol.d.ts.map +1 -0
- package/dist/src/worker/sse.worker.d.ts +2 -0
- package/dist/src/worker/sse.worker.d.ts.map +1 -0
- package/dist/src/worker/ticketDelegate.d.ts +16 -0
- package/dist/src/worker/ticketDelegate.d.ts.map +1 -0
- package/dist/src/worker/workerConnection.d.ts +28 -0
- package/dist/src/worker/workerConnection.d.ts.map +1 -0
- package/dist/worker/sse.worker.js +1865 -0
- package/dist/worker/sse.worker.js.map +1 -0
- package/package.json +87 -0
- package/src/core/BaseSseClient.test.ts +142 -0
- package/src/core/BaseSseClient.ts +103 -0
- package/src/core/SseClient.connection.test.ts +863 -0
- package/src/core/SseClient.scenarios.test.ts +164 -0
- package/src/core/SseClient.test.ts +63 -0
- package/src/core/SseClient.ts +310 -0
- package/src/core/backoff.test.ts +43 -0
- package/src/core/backoff.ts +39 -0
- package/src/core/emitter.ts +56 -0
- package/src/core/eventNames.ts +53 -0
- package/src/core/retryWait.ts +18 -0
- package/src/core/statusMachine.ts +56 -0
- package/src/core/stream.ts +141 -0
- package/src/core/ticket.ts +146 -0
- package/src/core/types.ts +182 -0
- package/src/core/wire.ts +23 -0
- package/src/createSseClient.test.ts +82 -0
- package/src/createSseClient.ts +34 -0
- package/src/index.ts +21 -0
- package/src/react/SseProvider.test.tsx +129 -0
- package/src/react/createSseContext.test.ts +47 -0
- package/src/react/createSseContext.tsx +140 -0
- package/src/react/index.ts +2 -0
- package/src/react/useSseEvent.test.tsx +71 -0
- package/src/react/useSseStatus.test.tsx +40 -0
- package/src/testing/doubles.ts +128 -0
- package/src/testing/harness.ts +82 -0
- package/src/testing/index.ts +3 -0
- package/src/worker/SseClientLike.contract.test.ts +270 -0
- package/src/worker/WorkerSseClient.scenarios.test.ts +179 -0
- package/src/worker/WorkerSseClient.test.ts +377 -0
- package/src/worker/WorkerSseClient.ts +190 -0
- package/src/worker/hub.connection.test.ts +1023 -0
- package/src/worker/hub.test.ts +213 -0
- package/src/worker/hub.ts +120 -0
- package/src/worker/portRegistry.test.ts +59 -0
- package/src/worker/portRegistry.ts +86 -0
- package/src/worker/protocol.ts +80 -0
- package/src/worker/sse.worker.ts +19 -0
- package/src/worker/ticketDelegate.test.ts +99 -0
- package/src/worker/ticketDelegate.ts +80 -0
- package/src/worker/workerConnection.ts +134 -0
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// 테스트 대역 모음 — 엔트리(index.ts)에서 참조하지 않으므로 빌드 산출물에 포함되지 않습니다.
|
|
2
|
+
// 파일마다 대역을 복사하면 한쪽에만 메서드가 생겨 같은 상황이 계층마다 다르게 재현됩니다
|
|
3
|
+
import type {
|
|
4
|
+
TabToWorkerMessage,
|
|
5
|
+
WorkerToTabMessage,
|
|
6
|
+
} from '../worker/protocol';
|
|
7
|
+
|
|
8
|
+
/** @description EventSource 대역 — 생성분을 정적으로 모아 마지막 인스턴스를 집어낼 수 있게 합니다 */
|
|
9
|
+
export class FakeEventSource {
|
|
10
|
+
static readonly instances: FakeEventSource[] = [];
|
|
11
|
+
|
|
12
|
+
/** @description 테스트 간 격리 — 재할당 대신 비워, 앞서 넘긴 참조가 옛 배열을 보지 않게 합니다 */
|
|
13
|
+
static reset(): void {
|
|
14
|
+
FakeEventSource.instances.length = 0;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
readonly listeners = new Map<
|
|
18
|
+
string,
|
|
19
|
+
Set<(event: { data?: string }) => void>
|
|
20
|
+
>();
|
|
21
|
+
onerror: ((event: unknown) => void) | null = null;
|
|
22
|
+
closed = false;
|
|
23
|
+
|
|
24
|
+
constructor(readonly url: string) {
|
|
25
|
+
FakeEventSource.instances.push(this);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
addEventListener(
|
|
29
|
+
type: string,
|
|
30
|
+
listener: (event: { data?: string }) => void
|
|
31
|
+
): void {
|
|
32
|
+
const set = this.listeners.get(type) ?? new Set();
|
|
33
|
+
set.add(listener);
|
|
34
|
+
this.listeners.set(type, set);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
close(): void {
|
|
38
|
+
this.closed = true;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// 실제 EventSource는 close() 후 아무것도 배달하지 않습니다 — 하네스도 동일하게
|
|
42
|
+
dispatch(type: string, data?: string): void {
|
|
43
|
+
if (this.closed) {
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
this.listeners.get(type)?.forEach((listener) => listener({ data }));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
error(): void {
|
|
50
|
+
if (this.closed) {
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
this.onerror?.({});
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export const lastSource = (): FakeEventSource =>
|
|
58
|
+
FakeEventSource.instances.at(-1)!;
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* @description 포트 대역 — 허브 쪽(HubPort)과 탭 쪽(MessagePort) 양쪽 표면을 함께 만족합니다.
|
|
62
|
+
* 한 포트의 양 끝을 한 객체로 보므로, 허브가 보낸 메시지는 sent에, 탭이 보낸 메시지는 posted에 쌓입니다.
|
|
63
|
+
*/
|
|
64
|
+
export class FakePort {
|
|
65
|
+
/** @description 허브가 이 포트로 보낸 것 */
|
|
66
|
+
sent: WorkerToTabMessage[] = [];
|
|
67
|
+
/** @description 탭 프록시가 이 포트로 보낸 것 */
|
|
68
|
+
posted: TabToWorkerMessage[] = [];
|
|
69
|
+
closeCount = 0;
|
|
70
|
+
started = false;
|
|
71
|
+
onmessage: ((event: { data: unknown }) => void) | null = null;
|
|
72
|
+
|
|
73
|
+
get closed(): boolean {
|
|
74
|
+
return this.closeCount > 0;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// 실제 MessagePort는 close 후에도 던지지 않고 조용히 버립니다 — 던지면 그 실패 경로가 예외로 위장됩니다
|
|
78
|
+
postMessage(message: WorkerToTabMessage | TabToWorkerMessage): void {
|
|
79
|
+
if (this.closed) {
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (isWorkerToTab(message)) {
|
|
84
|
+
this.sent.push(message);
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
this.posted.push(message);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
start(): void {
|
|
91
|
+
this.started = true;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
close(): void {
|
|
95
|
+
this.closeCount += 1;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** @description 워커가 탭에 보낸 것처럼 프록시의 onmessage를 깨웁니다 */
|
|
99
|
+
deliver(message: WorkerToTabMessage): void {
|
|
100
|
+
this.onmessage?.({ data: message });
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// 두 방향 유니온에 겹치는 kind가 없어 kind만으로 방향이 갈립니다
|
|
105
|
+
const WORKER_TO_TAB_KINDS: Record<WorkerToTabMessage['kind'], true> = {
|
|
106
|
+
event: true,
|
|
107
|
+
status: true,
|
|
108
|
+
ping: true,
|
|
109
|
+
'ticket-request': true,
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
const isWorkerToTab = (
|
|
113
|
+
message: WorkerToTabMessage | TabToWorkerMessage
|
|
114
|
+
): message is WorkerToTabMessage => message.kind in WORKER_TO_TAB_KINDS;
|
|
115
|
+
|
|
116
|
+
export const kindsOf = <K extends WorkerToTabMessage['kind']>(
|
|
117
|
+
port: FakePort,
|
|
118
|
+
kind: K
|
|
119
|
+
): Extract<WorkerToTabMessage, { kind: K }>[] =>
|
|
120
|
+
port.sent.filter(
|
|
121
|
+
(message): message is Extract<WorkerToTabMessage, { kind: K }> =>
|
|
122
|
+
message.kind === kind
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
/** @description SharedWorker 대역 — 팩토리가 넘겨받는 표면은 port 하나뿐입니다 */
|
|
126
|
+
export class FakeSharedWorker {
|
|
127
|
+
readonly port = new FakePort();
|
|
128
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// 테스트 전용 하네스 — 엔트리(index.ts)에서 참조하지 않으므로 빌드 산출물에 포함되지 않습니다
|
|
2
|
+
import { beforeEach, vi } from 'vitest';
|
|
3
|
+
|
|
4
|
+
import { FakeEventSource } from './doubles';
|
|
5
|
+
|
|
6
|
+
import { SseClient } from '../core/SseClient';
|
|
7
|
+
import type { SseClientOptions } from '../core/types';
|
|
8
|
+
|
|
9
|
+
export interface TestEventMap {
|
|
10
|
+
'demo.created': { id: string };
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export const createFetchMock = () => {
|
|
14
|
+
const requests: { url: string; method?: string; authorization?: string }[] =
|
|
15
|
+
[];
|
|
16
|
+
let ticketFailureStatus: number | null = null;
|
|
17
|
+
let ticketCounter = 0;
|
|
18
|
+
|
|
19
|
+
const fetchMock = async (input: unknown, init?: RequestInit) => {
|
|
20
|
+
const headers = (init?.headers ?? {}) as Record<string, string>;
|
|
21
|
+
requests.push({
|
|
22
|
+
url: String(input),
|
|
23
|
+
method: init?.method,
|
|
24
|
+
authorization: headers.Authorization,
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
if (init?.method === 'POST') {
|
|
28
|
+
if (ticketFailureStatus !== null) {
|
|
29
|
+
return { ok: false, status: ticketFailureStatus } as Response;
|
|
30
|
+
}
|
|
31
|
+
ticketCounter += 1;
|
|
32
|
+
const ticket = `ticket-${ticketCounter}`;
|
|
33
|
+
return {
|
|
34
|
+
ok: true,
|
|
35
|
+
status: 200,
|
|
36
|
+
json: async () => ({ data: { ticket } }),
|
|
37
|
+
} as unknown as Response;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return { ok: true, status: 204 } as Response;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
return {
|
|
44
|
+
fetch: fetchMock as unknown as typeof globalThis.fetch,
|
|
45
|
+
requests,
|
|
46
|
+
ticketRequests: () =>
|
|
47
|
+
requests.filter((request) => request.method === 'POST'),
|
|
48
|
+
failTicketsWith: (status: number | null) => {
|
|
49
|
+
ticketFailureStatus = status;
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
export const createHarness = (overrides?: Partial<SseClientOptions>) => {
|
|
55
|
+
const fetchMock = createFetchMock();
|
|
56
|
+
const client = new SseClient<TestEventMap>({
|
|
57
|
+
baseUrl: 'https://sse.example.com',
|
|
58
|
+
getAuthToken: () => 'token-1',
|
|
59
|
+
fetch: fetchMock.fetch,
|
|
60
|
+
eventSource: FakeEventSource as unknown as typeof EventSource,
|
|
61
|
+
...overrides,
|
|
62
|
+
});
|
|
63
|
+
return { client, ...fetchMock };
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* @description 재연결 시점을 결정적으로 만드는 준비 — 정리는 vitest.setup.ts가 전역으로 합니다.
|
|
68
|
+
* 백오프를 기다리는 테스트 파일만 켜세요. React 테스트처럼 실제 타이머가 필요한 곳은 켜지 않습니다.
|
|
69
|
+
*/
|
|
70
|
+
export const setupFakeTimers = (): void => {
|
|
71
|
+
beforeEach(() => {
|
|
72
|
+
vi.useFakeTimers();
|
|
73
|
+
// 무작위 재시도 지연(full jitter)을 최대값으로 고정해 재시도 시점을 예측 가능하게 만듭니다
|
|
74
|
+
vi.spyOn(Math, 'random').mockReturnValue(1);
|
|
75
|
+
});
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
export const openConnection = async (client: SseClient<TestEventMap>) => {
|
|
79
|
+
client.connect();
|
|
80
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
81
|
+
FakeEventSource.instances.at(-1)!.dispatch('hello');
|
|
82
|
+
};
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
// @vitest-environment node
|
|
2
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
3
|
+
|
|
4
|
+
import { WorkerSseClient } from './WorkerSseClient';
|
|
5
|
+
|
|
6
|
+
import { SseClient } from '../core/SseClient';
|
|
7
|
+
import type { SseClientLike, SseClientOptions } from '../core/types';
|
|
8
|
+
import {
|
|
9
|
+
createFetchMock,
|
|
10
|
+
FakeEventSource,
|
|
11
|
+
FakePort,
|
|
12
|
+
lastSource,
|
|
13
|
+
setupFakeTimers,
|
|
14
|
+
type TestEventMap,
|
|
15
|
+
} from '../testing';
|
|
16
|
+
|
|
17
|
+
type ExtraOptions = Partial<Omit<SseClientOptions, 'eventSource'>>;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* @description 구현마다 다른 "연결을 여는 방법"과 "이벤트를 흘려보내는 방법".
|
|
21
|
+
* 계약 본문은 이 훅으로만 구현에 닿습니다 — 스텁 종류(EventSource·포트)를 알지 못합니다.
|
|
22
|
+
*/
|
|
23
|
+
interface ContractDriver {
|
|
24
|
+
createClient(extra?: ExtraOptions): SseClientLike<TestEventMap>;
|
|
25
|
+
/** @description hello까지 도달시켜 status가 open이 되게 합니다 */
|
|
26
|
+
open(client: SseClientLike<TestEventMap>): Promise<void>;
|
|
27
|
+
/** @description 열린 연결로 도메인 이벤트 하나를 배달합니다 */
|
|
28
|
+
deliver(event: string, payload: unknown): void;
|
|
29
|
+
/** @description 재수립 — 끊고 다시 hello까지 도달시킵니다 */
|
|
30
|
+
reopen(client: SseClientLike<TestEventMap>): Promise<void>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const testSseClientContract = (driver: ContractDriver): void => {
|
|
34
|
+
const createClient = driver.createClient;
|
|
35
|
+
|
|
36
|
+
it('브라우저 전역이 없는 환경에서 connect가 던지지 않습니다', () => {
|
|
37
|
+
const client = createClient();
|
|
38
|
+
expect(() => client.connect()).not.toThrow();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('연결 전 구독이 해제 함수를 반환합니다', () => {
|
|
42
|
+
const client = createClient();
|
|
43
|
+
const unsubscribe = client.on('demo.created', () => {});
|
|
44
|
+
expect(typeof unsubscribe).toBe('function');
|
|
45
|
+
unsubscribe();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('disconnect는 closed로 전이하고, 던지는 리스너가 있어도 나머지 리스너에 통지합니다', () => {
|
|
49
|
+
const client = createClient();
|
|
50
|
+
const received: string[] = [];
|
|
51
|
+
client.onStatus(() => {
|
|
52
|
+
throw new Error('listener boom');
|
|
53
|
+
});
|
|
54
|
+
client.onStatus((status) => {
|
|
55
|
+
received.push(status);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
expect(() => client.disconnect()).not.toThrow();
|
|
59
|
+
|
|
60
|
+
expect(received).toEqual(['closed']);
|
|
61
|
+
expect(client.status).toBe('closed');
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("SDK가 소비하는 'hello' 구독에는 배달되지 않는다고 logger.warn을 냅니다", () => {
|
|
65
|
+
const warnings: string[] = [];
|
|
66
|
+
const client = createClient({
|
|
67
|
+
logger: { warn: (message) => warnings.push(message), error: () => {} },
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
client.on('hello', () => {});
|
|
71
|
+
|
|
72
|
+
expect(warnings).toHaveLength(1);
|
|
73
|
+
expect(warnings[0]).toContain("'hello'");
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('격리한 리스너 예외를 logger.warn으로 보고합니다', () => {
|
|
77
|
+
const warnings: string[] = [];
|
|
78
|
+
const client = createClient({
|
|
79
|
+
logger: { warn: (message) => warnings.push(message), error: () => {} },
|
|
80
|
+
});
|
|
81
|
+
client.onStatus(() => {
|
|
82
|
+
throw new Error('listener boom');
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
client.disconnect();
|
|
86
|
+
|
|
87
|
+
expect(warnings).toHaveLength(1);
|
|
88
|
+
expect(warnings[0]).toContain('리스너 예외');
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('connect부터 수립까지 connecting → open을 통지합니다', async () => {
|
|
92
|
+
const client = createClient();
|
|
93
|
+
const seen: string[] = [];
|
|
94
|
+
client.onStatus((status) => seen.push(status));
|
|
95
|
+
|
|
96
|
+
await driver.open(client);
|
|
97
|
+
|
|
98
|
+
expect(seen).toEqual(['connecting', 'open']);
|
|
99
|
+
expect(client.status).toBe('open');
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('구독한 도메인 이벤트의 페이로드를 배달합니다', async () => {
|
|
103
|
+
const client = createClient();
|
|
104
|
+
const received: unknown[] = [];
|
|
105
|
+
client.on('demo.created', (payload) => received.push(payload));
|
|
106
|
+
|
|
107
|
+
await driver.open(client);
|
|
108
|
+
driver.deliver('demo.created', { id: 'a' });
|
|
109
|
+
|
|
110
|
+
expect(received).toEqual([{ id: 'a' }]);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('해제한 뒤에는 배달하지 않습니다', async () => {
|
|
114
|
+
const client = createClient();
|
|
115
|
+
const received: unknown[] = [];
|
|
116
|
+
const unsubscribe = client.on('demo.created', (payload) =>
|
|
117
|
+
received.push(payload)
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
await driver.open(client);
|
|
121
|
+
unsubscribe();
|
|
122
|
+
driver.deliver('demo.created', { id: 'a' });
|
|
123
|
+
|
|
124
|
+
expect(received).toEqual([]);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('구독하지 않은 이벤트는 배달하지 않습니다', async () => {
|
|
128
|
+
const client = createClient();
|
|
129
|
+
const received: unknown[] = [];
|
|
130
|
+
client.on('demo.created', (payload) => received.push(payload));
|
|
131
|
+
|
|
132
|
+
await driver.open(client);
|
|
133
|
+
driver.deliver('other.event', { id: 'a' });
|
|
134
|
+
|
|
135
|
+
expect(received).toEqual([]);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('재수립하면 resync를 발화합니다 — 첫 수립에는 발화하지 않습니다', async () => {
|
|
139
|
+
const client = createClient();
|
|
140
|
+
let resyncCount = 0;
|
|
141
|
+
client.on('resync', () => {
|
|
142
|
+
resyncCount += 1;
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
await driver.open(client);
|
|
146
|
+
expect(resyncCount).toBe(0);
|
|
147
|
+
|
|
148
|
+
await driver.reopen(client);
|
|
149
|
+
expect(resyncCount).toBe(1);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('disconnect를 두 번 불러도 closed 통지는 한 번뿐입니다', async () => {
|
|
153
|
+
const client = createClient();
|
|
154
|
+
await driver.open(client);
|
|
155
|
+
|
|
156
|
+
const seen: string[] = [];
|
|
157
|
+
client.onStatus((status) => seen.push(status));
|
|
158
|
+
client.disconnect();
|
|
159
|
+
client.disconnect();
|
|
160
|
+
|
|
161
|
+
expect(seen).toEqual(['closed']);
|
|
162
|
+
expect(client.status).toBe('closed');
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('열린 뒤 connect를 다시 불러도 상태가 흔들리지 않습니다', async () => {
|
|
166
|
+
const client = createClient();
|
|
167
|
+
await driver.open(client);
|
|
168
|
+
|
|
169
|
+
const seen: string[] = [];
|
|
170
|
+
client.onStatus((status) => seen.push(status));
|
|
171
|
+
client.connect();
|
|
172
|
+
|
|
173
|
+
expect(seen).toEqual([]);
|
|
174
|
+
expect(client.status).toBe('open');
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it('disconnect 뒤 다시 connect하면 열립니다', async () => {
|
|
178
|
+
const client = createClient();
|
|
179
|
+
await driver.open(client);
|
|
180
|
+
client.disconnect();
|
|
181
|
+
|
|
182
|
+
await driver.open(client);
|
|
183
|
+
|
|
184
|
+
expect(client.status).toBe('open');
|
|
185
|
+
});
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
const options = {
|
|
189
|
+
baseUrl: 'https://sse.example.com',
|
|
190
|
+
getAuthToken: () => 'token',
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
describe('SseClient — SseClientLike 계약', () => {
|
|
194
|
+
setupFakeTimers();
|
|
195
|
+
|
|
196
|
+
let fetchMock: ReturnType<typeof createFetchMock>;
|
|
197
|
+
beforeEach(() => {
|
|
198
|
+
fetchMock = createFetchMock();
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
const openDirect = async (client: SseClientLike<TestEventMap>) => {
|
|
202
|
+
client.connect();
|
|
203
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
204
|
+
lastSource().dispatch('hello');
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
testSseClientContract({
|
|
208
|
+
createClient: (extra) =>
|
|
209
|
+
new SseClient<TestEventMap>({
|
|
210
|
+
...options,
|
|
211
|
+
fetch: fetchMock.fetch,
|
|
212
|
+
eventSource: FakeEventSource as unknown as typeof EventSource,
|
|
213
|
+
...extra,
|
|
214
|
+
}),
|
|
215
|
+
open: openDirect,
|
|
216
|
+
deliver: (event, payload) =>
|
|
217
|
+
lastSource().dispatch(event, JSON.stringify(payload)),
|
|
218
|
+
reopen: async () => {
|
|
219
|
+
// onerror가 재연결 루프를 돌리고, 첫 백오프가 흐른 뒤 새 소스가 hello를 받습니다
|
|
220
|
+
lastSource().error();
|
|
221
|
+
await vi.advanceTimersByTimeAsync(1000);
|
|
222
|
+
lastSource().dispatch('hello');
|
|
223
|
+
},
|
|
224
|
+
});
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
describe('WorkerSseClient — SseClientLike 계약', () => {
|
|
228
|
+
setupFakeTimers();
|
|
229
|
+
|
|
230
|
+
let ports: FakePort[] = [];
|
|
231
|
+
beforeEach(() => {
|
|
232
|
+
ports = [];
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
const lastPort = () => ports.at(-1)!;
|
|
236
|
+
|
|
237
|
+
// 워커가 세우는 연결을 대신합니다 — 프록시 입장에서 수립은 워커의 status 통지가 전부입니다
|
|
238
|
+
const openViaWorker = async (client: SseClientLike<TestEventMap>) => {
|
|
239
|
+
client.connect();
|
|
240
|
+
lastPort().deliver({ kind: 'status', status: 'connecting' });
|
|
241
|
+
lastPort().deliver({ kind: 'status', status: 'open' });
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
testSseClientContract({
|
|
245
|
+
createClient: (extra) =>
|
|
246
|
+
new WorkerSseClient<TestEventMap>({
|
|
247
|
+
...options,
|
|
248
|
+
sharedWorker: () => {
|
|
249
|
+
const port = new FakePort();
|
|
250
|
+
ports.push(port);
|
|
251
|
+
return { port } as unknown as SharedWorker;
|
|
252
|
+
},
|
|
253
|
+
...extra,
|
|
254
|
+
}),
|
|
255
|
+
open: openViaWorker,
|
|
256
|
+
deliver: (event, payload) =>
|
|
257
|
+
lastPort().deliver({ kind: 'event', event, payload }),
|
|
258
|
+
// 워커가 재수립 때 실제로 보내는 순서입니다 — resync 전달은 hub.connection.test.ts가 검증합니다
|
|
259
|
+
reopen: async () => {
|
|
260
|
+
lastPort().deliver({ kind: 'status', status: 'reconnecting' });
|
|
261
|
+
lastPort().deliver({ kind: 'status', status: 'connecting' });
|
|
262
|
+
lastPort().deliver({ kind: 'status', status: 'open' });
|
|
263
|
+
lastPort().deliver({
|
|
264
|
+
kind: 'event',
|
|
265
|
+
event: 'resync',
|
|
266
|
+
payload: undefined,
|
|
267
|
+
});
|
|
268
|
+
},
|
|
269
|
+
});
|
|
270
|
+
});
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
// @vitest-environment node
|
|
2
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
3
|
+
|
|
4
|
+
import { createWorkerHub, PING_INTERVAL_MS } from './hub';
|
|
5
|
+
import { WorkerSseClient } from './WorkerSseClient';
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
FakeEventSource,
|
|
9
|
+
FakePort as FakeTabPort,
|
|
10
|
+
lastSource,
|
|
11
|
+
setupFakeTimers,
|
|
12
|
+
type TestEventMap,
|
|
13
|
+
} from '../testing';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* 유저 여정 시나리오(워커 경로) — 기계 계약은 hub.test.ts·hub.connection.test.ts·WorkerSseClient.test.ts가
|
|
17
|
+
* 고정하고, 이 파일은 소비처가 겪는 경험 하나를 탭에서 서버까지 걷습니다.
|
|
18
|
+
* 그 파일들은 허브를 직접 부르므로 "그 메시지를 누가 보내는가"를 검증하지 못합니다 — 여기서만 잡힙니다.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
setupFakeTimers();
|
|
22
|
+
|
|
23
|
+
/** @description 탭 프록시와 허브를 실제 배선대로 이어 붙입니다 — sse.worker.ts가 하는 일과 같습니다 */
|
|
24
|
+
const connectTab = (hub: ReturnType<typeof createWorkerHub>) => {
|
|
25
|
+
const port = new FakeTabPort();
|
|
26
|
+
const client = new WorkerSseClient<TestEventMap>({
|
|
27
|
+
baseUrl: 'https://sse.example.com',
|
|
28
|
+
getAuthToken: () => 'token-1',
|
|
29
|
+
fetch: (async (_input: unknown, init?: RequestInit) =>
|
|
30
|
+
init?.method === 'POST'
|
|
31
|
+
? ({
|
|
32
|
+
ok: true,
|
|
33
|
+
status: 200,
|
|
34
|
+
json: async () => ({ data: { ticket: 'ticket' } }),
|
|
35
|
+
} as unknown as Response)
|
|
36
|
+
: ({ ok: true, status: 204 } as Response)) as unknown as typeof fetch,
|
|
37
|
+
sharedWorker: () => ({ port }) as unknown as SharedWorker,
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
// 실제로는 MessagePort가 하는 일 — 얼어붙은 탭을 흉내내려면 배달을 멈출 수 있어야 합니다
|
|
41
|
+
let frozen = false;
|
|
42
|
+
let deliveredToTab = 0;
|
|
43
|
+
|
|
44
|
+
// 한 번 배달하면 상대가 답을 보내므로, 더 오갈 게 없을 때까지 왕복시킵니다
|
|
45
|
+
const deliverMessages = async (): Promise<void> => {
|
|
46
|
+
for (let round = 0; round < 10; round += 1) {
|
|
47
|
+
const toWorker = port.posted.splice(0);
|
|
48
|
+
toWorker.forEach((message) => hub.handleMessage(port, message));
|
|
49
|
+
|
|
50
|
+
// 얼어붙은 탭은 받지 못하고 큐에 쌓입니다 — 깨어나면 그 자리부터 이어 배달됩니다
|
|
51
|
+
const toTab = frozen ? [] : port.sent.slice(deliveredToTab);
|
|
52
|
+
deliveredToTab += toTab.length;
|
|
53
|
+
toTab.forEach((message) => port.deliver(message));
|
|
54
|
+
|
|
55
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
56
|
+
if (toWorker.length === 0 && toTab.length === 0) {
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
hub.addPort(port);
|
|
63
|
+
return {
|
|
64
|
+
client,
|
|
65
|
+
port,
|
|
66
|
+
deliverMessages,
|
|
67
|
+
freezeTab: () => {
|
|
68
|
+
frozen = true;
|
|
69
|
+
},
|
|
70
|
+
wakeTab: () => {
|
|
71
|
+
frozen = false;
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const createHub = () =>
|
|
77
|
+
createWorkerHub({
|
|
78
|
+
eventSource: FakeEventSource as unknown as typeof EventSource,
|
|
79
|
+
logger: { warn: () => {}, error: () => {} },
|
|
80
|
+
fetch: (async () =>
|
|
81
|
+
({ ok: true, status: 204 }) as Response) as unknown as typeof fetch,
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
describe('유저 시나리오 — 워커 경로', () => {
|
|
85
|
+
it('탭이 얼어붙은 사이의 이벤트도, 깨어나면 밀렸던 배달로 전부 도착한다', async () => {
|
|
86
|
+
const hub = createHub();
|
|
87
|
+
const { client, deliverMessages, freezeTab, wakeTab } = connectTab(hub);
|
|
88
|
+
|
|
89
|
+
const received: unknown[] = [];
|
|
90
|
+
client.on('demo.created', (payload) => received.push(payload));
|
|
91
|
+
|
|
92
|
+
client.connect();
|
|
93
|
+
await deliverMessages();
|
|
94
|
+
lastSource().dispatch('hello');
|
|
95
|
+
await deliverMessages();
|
|
96
|
+
expect(client.status).toBe('open');
|
|
97
|
+
|
|
98
|
+
// 얼어붙음 — 두 주기가 지나도 장부에 남고, 이벤트는 큐에 쌓입니다
|
|
99
|
+
freezeTab();
|
|
100
|
+
await vi.advanceTimersByTimeAsync(PING_INTERVAL_MS);
|
|
101
|
+
await vi.advanceTimersByTimeAsync(PING_INTERVAL_MS);
|
|
102
|
+
lastSource().dispatch('demo.created', '{"id":"missed-1"}');
|
|
103
|
+
lastSource().dispatch('demo.created', '{"id":"missed-2"}');
|
|
104
|
+
expect(received).toEqual([]);
|
|
105
|
+
|
|
106
|
+
wakeTab();
|
|
107
|
+
await deliverMessages();
|
|
108
|
+
|
|
109
|
+
expect(received).toEqual([{ id: 'missed-1' }, { id: 'missed-2' }]);
|
|
110
|
+
expect(client.status).toBe('open');
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('전원이 얼어붙어 연결이 끊겨도, 깨어난 탭의 connect가 다시 세우고 resync를 받는다', async () => {
|
|
114
|
+
const hub = createHub();
|
|
115
|
+
const { client, deliverMessages, freezeTab, wakeTab } = connectTab(hub);
|
|
116
|
+
|
|
117
|
+
const events: string[] = [];
|
|
118
|
+
client.on('resync', () => events.push('resync'));
|
|
119
|
+
|
|
120
|
+
client.connect();
|
|
121
|
+
await deliverMessages();
|
|
122
|
+
lastSource().dispatch('hello');
|
|
123
|
+
await deliverMessages();
|
|
124
|
+
expect(client.status).toBe('open');
|
|
125
|
+
|
|
126
|
+
// 유일한 탭이 얼어붙어 확정 침묵이 됩니다
|
|
127
|
+
freezeTab();
|
|
128
|
+
await vi.advanceTimersByTimeAsync(PING_INTERVAL_MS);
|
|
129
|
+
await vi.advanceTimersByTimeAsync(PING_INTERVAL_MS);
|
|
130
|
+
|
|
131
|
+
// 그 사이 연결이 끊깁니다 — 티켓을 물을 곳이 없어 워커가 휴면으로 접습니다
|
|
132
|
+
lastSource().error();
|
|
133
|
+
await vi.advanceTimersByTimeAsync(60_000);
|
|
134
|
+
expect(FakeEventSource.instances).toHaveLength(1);
|
|
135
|
+
|
|
136
|
+
// 깨어남 — 소비처가 이 시점에 connect()를 부릅니다(attachAutoResume이 하는 일)
|
|
137
|
+
wakeTab();
|
|
138
|
+
client.connect();
|
|
139
|
+
await deliverMessages();
|
|
140
|
+
|
|
141
|
+
// 전제를 먼저 단언합니다 — 조건부로 두면 재수립 실패가 아래 resync 단언으로 흘러 원인이 흐려집니다
|
|
142
|
+
expect(FakeEventSource.instances.length).toBeGreaterThan(1);
|
|
143
|
+
lastSource().dispatch('hello');
|
|
144
|
+
await deliverMessages();
|
|
145
|
+
|
|
146
|
+
expect(client.status).toBe('open');
|
|
147
|
+
// 휴면 동안의 공백을 소비처가 메울 수 있어야 합니다 — 세션이 이어진 재수립이라 resync가 옵니다
|
|
148
|
+
expect(events).toEqual(['resync']);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
// connect()는 탭이 깨어날 때마다 불립니다(attachAutoResume) — 탭 전환마다 재조회가 나가면 안 됩니다
|
|
152
|
+
it('연결이 살아 있는 동안의 connect 반복은 재수립도 resync도 만들지 않는다', async () => {
|
|
153
|
+
const hub = createHub();
|
|
154
|
+
const { client, deliverMessages, freezeTab, wakeTab } = connectTab(hub);
|
|
155
|
+
|
|
156
|
+
const events: string[] = [];
|
|
157
|
+
client.on('resync', () => events.push('resync'));
|
|
158
|
+
|
|
159
|
+
client.connect();
|
|
160
|
+
await deliverMessages();
|
|
161
|
+
lastSource().dispatch('hello');
|
|
162
|
+
await deliverMessages();
|
|
163
|
+
|
|
164
|
+
freezeTab();
|
|
165
|
+
await vi.advanceTimersByTimeAsync(PING_INTERVAL_MS);
|
|
166
|
+
await vi.advanceTimersByTimeAsync(PING_INTERVAL_MS);
|
|
167
|
+
|
|
168
|
+
// 연결은 살아 있습니다 — 깨어난 탭의 connect는 아무것도 다시 세울 필요가 없습니다
|
|
169
|
+
wakeTab();
|
|
170
|
+
client.connect();
|
|
171
|
+
client.connect();
|
|
172
|
+
client.connect();
|
|
173
|
+
await deliverMessages();
|
|
174
|
+
|
|
175
|
+
expect(FakeEventSource.instances).toHaveLength(1);
|
|
176
|
+
expect(events).toEqual([]);
|
|
177
|
+
expect(client.status).toBe('open');
|
|
178
|
+
});
|
|
179
|
+
});
|