@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,1023 @@
|
|
|
1
|
+
// @vitest-environment node
|
|
2
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
3
|
+
|
|
4
|
+
import { createWorkerHub, PING_INTERVAL_MS } from './hub';
|
|
5
|
+
import type { TabToWorkerMessage } from './protocol';
|
|
6
|
+
import { TICKET_RESPONSE_TIMEOUT_MS } from './ticketDelegate';
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
FakeEventSource,
|
|
10
|
+
FakePort as FakeTabPort,
|
|
11
|
+
kindsOf,
|
|
12
|
+
lastSource,
|
|
13
|
+
setupFakeTimers,
|
|
14
|
+
} from '../testing';
|
|
15
|
+
|
|
16
|
+
const createConnectionHarness = () => {
|
|
17
|
+
const warnings: string[] = [];
|
|
18
|
+
const hub = createWorkerHub({
|
|
19
|
+
eventSource: FakeEventSource as unknown as typeof EventSource,
|
|
20
|
+
logger: {
|
|
21
|
+
warn: (message: string) => warnings.push(message),
|
|
22
|
+
error: () => {},
|
|
23
|
+
},
|
|
24
|
+
fetch: (async () =>
|
|
25
|
+
({
|
|
26
|
+
ok: true,
|
|
27
|
+
status: 204,
|
|
28
|
+
}) as Response) as unknown as typeof globalThis.fetch,
|
|
29
|
+
});
|
|
30
|
+
return { hub, warnings };
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const respondTicket = (
|
|
34
|
+
hub: ReturnType<typeof createWorkerHub>,
|
|
35
|
+
port: FakeTabPort,
|
|
36
|
+
ticket: string
|
|
37
|
+
) => {
|
|
38
|
+
const message: TabToWorkerMessage = {
|
|
39
|
+
kind: 'ticket-response',
|
|
40
|
+
ok: true,
|
|
41
|
+
ticket,
|
|
42
|
+
};
|
|
43
|
+
hub.handleMessage(port, message);
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
setupFakeTimers();
|
|
47
|
+
|
|
48
|
+
describe('워커 연결 — 첫 init이 연결을 세웁니다', () => {
|
|
49
|
+
it('첫 init을 받으면 티켓을 탭에 요청하고, 응답이 오면 그 티켓으로 연결합니다', async () => {
|
|
50
|
+
const { hub } = createConnectionHarness();
|
|
51
|
+
const port = new FakeTabPort();
|
|
52
|
+
hub.addPort(port);
|
|
53
|
+
|
|
54
|
+
hub.handleMessage(port, {
|
|
55
|
+
kind: 'init',
|
|
56
|
+
baseUrl: 'https://sse.example.com',
|
|
57
|
+
});
|
|
58
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
59
|
+
expect(kindsOf(port, 'ticket-request')).toHaveLength(1);
|
|
60
|
+
|
|
61
|
+
respondTicket(hub, port, 'ticket-1');
|
|
62
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
63
|
+
|
|
64
|
+
expect(lastSource().url).toBe(
|
|
65
|
+
'https://sse.example.com/v1/events?ticket=ticket-1'
|
|
66
|
+
);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('두 번째 탭의 init은 새 연결을 만들지 않습니다 — 연결은 워커에 하나뿐입니다', async () => {
|
|
70
|
+
const { hub, warnings } = createConnectionHarness();
|
|
71
|
+
const first = new FakeTabPort();
|
|
72
|
+
const second = new FakeTabPort();
|
|
73
|
+
hub.addPort(first);
|
|
74
|
+
hub.addPort(second);
|
|
75
|
+
|
|
76
|
+
hub.handleMessage(first, {
|
|
77
|
+
kind: 'init',
|
|
78
|
+
baseUrl: 'https://sse.example.com',
|
|
79
|
+
});
|
|
80
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
81
|
+
respondTicket(hub, first, 'ticket-1');
|
|
82
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
83
|
+
|
|
84
|
+
const ticketRequestsBefore = kindsOf(second, 'ticket-request').length;
|
|
85
|
+
hub.handleMessage(second, {
|
|
86
|
+
kind: 'init',
|
|
87
|
+
baseUrl: 'https://other.example.com',
|
|
88
|
+
});
|
|
89
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
90
|
+
|
|
91
|
+
expect(FakeEventSource.instances).toHaveLength(1);
|
|
92
|
+
// 이미 선 연결이 있으므로 티켓을 새로 요청하지 않습니다 — 두 번째 baseUrl도 채택되지 않습니다
|
|
93
|
+
expect(kindsOf(second, 'ticket-request')).toHaveLength(
|
|
94
|
+
ticketRequestsBefore
|
|
95
|
+
);
|
|
96
|
+
// 채택되지 않았다는 사실은 탭이 알 수 없으므로 로그로 알립니다
|
|
97
|
+
expect(
|
|
98
|
+
warnings.some((message) => message.includes('other.example.com'))
|
|
99
|
+
).toBe(true);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('티켓은 한 탭에만 요청합니다 — 연결이 하나라 발급도 한 번이면 됩니다', async () => {
|
|
103
|
+
const { hub } = createConnectionHarness();
|
|
104
|
+
const tabs = [new FakeTabPort(), new FakeTabPort(), new FakeTabPort()];
|
|
105
|
+
tabs.forEach((tab) => hub.addPort(tab));
|
|
106
|
+
|
|
107
|
+
hub.handleMessage(tabs[0]!, {
|
|
108
|
+
kind: 'init',
|
|
109
|
+
baseUrl: 'https://sse.example.com',
|
|
110
|
+
});
|
|
111
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
112
|
+
|
|
113
|
+
const asked = tabs.filter(
|
|
114
|
+
(tab) => kindsOf(tab, 'ticket-request').length > 0
|
|
115
|
+
);
|
|
116
|
+
expect(asked).toHaveLength(1);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('무응답으로 수거 대상이 된 탭 대신 살아 있는 탭에 티켓을 요청합니다', async () => {
|
|
120
|
+
const { hub } = createConnectionHarness();
|
|
121
|
+
const silent = new FakeTabPort();
|
|
122
|
+
const responsive = new FakeTabPort();
|
|
123
|
+
hub.addPort(silent);
|
|
124
|
+
hub.addPort(responsive);
|
|
125
|
+
|
|
126
|
+
// 한 주기를 돌려 silent만 무응답으로 남깁니다
|
|
127
|
+
await vi.advanceTimersByTimeAsync(PING_INTERVAL_MS);
|
|
128
|
+
hub.handleMessage(responsive, { kind: 'pong' });
|
|
129
|
+
|
|
130
|
+
hub.handleMessage(responsive, {
|
|
131
|
+
kind: 'init',
|
|
132
|
+
baseUrl: 'https://sse.example.com',
|
|
133
|
+
});
|
|
134
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
135
|
+
|
|
136
|
+
expect(kindsOf(responsive, 'ticket-request')).toHaveLength(1);
|
|
137
|
+
expect(kindsOf(silent, 'ticket-request')).toHaveLength(0);
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
describe('워커 연결 — 상태와 이벤트를 모든 탭에 전합니다', () => {
|
|
142
|
+
it('연결 상태 변화를 모든 탭에 알리고, 늦게 합류한 탭에는 현재 상태를 줍니다', async () => {
|
|
143
|
+
const { hub } = createConnectionHarness();
|
|
144
|
+
const early = new FakeTabPort();
|
|
145
|
+
hub.addPort(early);
|
|
146
|
+
|
|
147
|
+
hub.handleMessage(early, {
|
|
148
|
+
kind: 'init',
|
|
149
|
+
baseUrl: 'https://sse.example.com',
|
|
150
|
+
});
|
|
151
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
152
|
+
respondTicket(hub, early, 'ticket-1');
|
|
153
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
154
|
+
lastSource().dispatch('hello');
|
|
155
|
+
|
|
156
|
+
expect(early.sent.at(-1)).toEqual({ kind: 'status', status: 'open' });
|
|
157
|
+
|
|
158
|
+
const late = new FakeTabPort();
|
|
159
|
+
hub.addPort(late);
|
|
160
|
+
expect(late.sent[0]).toEqual({ kind: 'status', status: 'open' });
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it('구독한 이벤트가 도착하면 모든 탭에 전달합니다', async () => {
|
|
164
|
+
const { hub } = createConnectionHarness();
|
|
165
|
+
const first = new FakeTabPort();
|
|
166
|
+
const second = new FakeTabPort();
|
|
167
|
+
hub.addPort(first);
|
|
168
|
+
hub.addPort(second);
|
|
169
|
+
|
|
170
|
+
hub.handleMessage(first, {
|
|
171
|
+
kind: 'init',
|
|
172
|
+
baseUrl: 'https://sse.example.com',
|
|
173
|
+
});
|
|
174
|
+
hub.handleMessage(first, { kind: 'subscribe', event: 'demo.created' });
|
|
175
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
176
|
+
respondTicket(hub, first, 'ticket-1');
|
|
177
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
178
|
+
lastSource().dispatch('hello');
|
|
179
|
+
|
|
180
|
+
lastSource().dispatch('demo.created', '{"id":"a"}');
|
|
181
|
+
|
|
182
|
+
const delivered = {
|
|
183
|
+
kind: 'event',
|
|
184
|
+
event: 'demo.created',
|
|
185
|
+
payload: { id: 'a' },
|
|
186
|
+
};
|
|
187
|
+
expect(first.sent.at(-1)).toEqual(delivered);
|
|
188
|
+
expect(second.sent.at(-1)).toEqual(delivered);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it('연결이 선 뒤에 등록된 구독도 전달됩니다', async () => {
|
|
192
|
+
const { hub } = createConnectionHarness();
|
|
193
|
+
const port = new FakeTabPort();
|
|
194
|
+
hub.addPort(port);
|
|
195
|
+
|
|
196
|
+
hub.handleMessage(port, {
|
|
197
|
+
kind: 'init',
|
|
198
|
+
baseUrl: 'https://sse.example.com',
|
|
199
|
+
});
|
|
200
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
201
|
+
respondTicket(hub, port, 'ticket-1');
|
|
202
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
203
|
+
lastSource().dispatch('hello');
|
|
204
|
+
|
|
205
|
+
hub.handleMessage(port, { kind: 'subscribe', event: 'demo.late' });
|
|
206
|
+
lastSource().dispatch('demo.late', '{"id":"b"}');
|
|
207
|
+
|
|
208
|
+
expect(port.sent.at(-1)).toEqual({
|
|
209
|
+
kind: 'event',
|
|
210
|
+
event: 'demo.late',
|
|
211
|
+
payload: { id: 'b' },
|
|
212
|
+
});
|
|
213
|
+
});
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
describe('워커 연결 — goaway는 모든 탭에 닿습니다', () => {
|
|
217
|
+
const openConnection = async (
|
|
218
|
+
hub: ReturnType<typeof createWorkerHub>,
|
|
219
|
+
port: FakeTabPort
|
|
220
|
+
) => {
|
|
221
|
+
hub.handleMessage(port, {
|
|
222
|
+
kind: 'init',
|
|
223
|
+
baseUrl: 'https://sse.example.com',
|
|
224
|
+
});
|
|
225
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
226
|
+
respondTicket(hub, port, 'ticket-1');
|
|
227
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
228
|
+
lastSource().dispatch('hello');
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
// 로그아웃 전파가 이 경로에 걸려 있습니다 — 탭이 워커에 알리는 kind 없이 서버 한 번으로 모든 탭이 압니다
|
|
232
|
+
it('goaway 페이로드를 모든 탭에 그대로 전달합니다', async () => {
|
|
233
|
+
const { hub } = createConnectionHarness();
|
|
234
|
+
const first = new FakeTabPort();
|
|
235
|
+
const second = new FakeTabPort();
|
|
236
|
+
hub.addPort(first);
|
|
237
|
+
hub.addPort(second);
|
|
238
|
+
|
|
239
|
+
await openConnection(hub, first);
|
|
240
|
+
lastSource().dispatch(
|
|
241
|
+
'goaway',
|
|
242
|
+
JSON.stringify({ reason: 'user_logout', action: 'close' })
|
|
243
|
+
);
|
|
244
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
245
|
+
|
|
246
|
+
const delivered = {
|
|
247
|
+
kind: 'event',
|
|
248
|
+
event: 'goaway',
|
|
249
|
+
payload: { reason: 'user_logout', action: 'close' },
|
|
250
|
+
};
|
|
251
|
+
expect(kindsOf(first, 'event').at(-1)).toEqual(delivered);
|
|
252
|
+
expect(kindsOf(second, 'event').at(-1)).toEqual(delivered);
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it('action이 close면 연결을 닫고 모든 탭에 알립니다', async () => {
|
|
256
|
+
const { hub } = createConnectionHarness();
|
|
257
|
+
const first = new FakeTabPort();
|
|
258
|
+
const second = new FakeTabPort();
|
|
259
|
+
hub.addPort(first);
|
|
260
|
+
hub.addPort(second);
|
|
261
|
+
|
|
262
|
+
await openConnection(hub, first);
|
|
263
|
+
const source = lastSource();
|
|
264
|
+
source.dispatch('goaway', JSON.stringify({ action: 'close' }));
|
|
265
|
+
await vi.advanceTimersByTimeAsync(30_000);
|
|
266
|
+
|
|
267
|
+
expect(source.closed).toBe(true);
|
|
268
|
+
// 티켓 요청이 첫 연결의 1건에 머뭅니다 — 재연결을 시도하지 않았다는 뜻입니다
|
|
269
|
+
expect(kindsOf(first, 'ticket-request')).toHaveLength(1);
|
|
270
|
+
expect(kindsOf(first, 'status').at(-1)?.status).toBe('closed');
|
|
271
|
+
expect(kindsOf(second, 'status').at(-1)?.status).toBe('closed');
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it('close 뒤 탭이 init을 다시 보내면 연결이 되살아납니다', async () => {
|
|
275
|
+
const { hub } = createConnectionHarness();
|
|
276
|
+
const port = new FakeTabPort();
|
|
277
|
+
hub.addPort(port);
|
|
278
|
+
|
|
279
|
+
await openConnection(hub, port);
|
|
280
|
+
lastSource().dispatch('goaway', JSON.stringify({ action: 'close' }));
|
|
281
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
282
|
+
|
|
283
|
+
hub.handleMessage(port, {
|
|
284
|
+
kind: 'init',
|
|
285
|
+
baseUrl: 'https://sse.example.com',
|
|
286
|
+
});
|
|
287
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
288
|
+
respondTicket(hub, port, 'ticket-2');
|
|
289
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
290
|
+
|
|
291
|
+
expect(lastSource().url).toBe(
|
|
292
|
+
'https://sse.example.com/v1/events?ticket=ticket-2'
|
|
293
|
+
);
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
// 서버가 세션을 끝낸 것이라 다음 연결은 첫 연결입니다 — 직결 경로(#endSession)와 같은 판정
|
|
297
|
+
it('close 뒤 되살아난 연결의 첫 hello는 resync를 내지 않습니다', async () => {
|
|
298
|
+
const { hub } = createConnectionHarness();
|
|
299
|
+
const port = new FakeTabPort();
|
|
300
|
+
hub.addPort(port);
|
|
301
|
+
|
|
302
|
+
await openConnection(hub, port);
|
|
303
|
+
lastSource().dispatch(
|
|
304
|
+
'goaway',
|
|
305
|
+
JSON.stringify({ reason: 'user_logout', action: 'close' })
|
|
306
|
+
);
|
|
307
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
308
|
+
|
|
309
|
+
hub.handleMessage(port, {
|
|
310
|
+
kind: 'init',
|
|
311
|
+
baseUrl: 'https://sse.example.com',
|
|
312
|
+
});
|
|
313
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
314
|
+
respondTicket(hub, port, 'ticket-2');
|
|
315
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
316
|
+
lastSource().dispatch('hello');
|
|
317
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
318
|
+
|
|
319
|
+
const eventNames = kindsOf(port, 'event').map((m) => m.event);
|
|
320
|
+
expect(eventNames).not.toContain('resync');
|
|
321
|
+
});
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
describe('워커 연결 — fatal 사유는 모든 탭에 닿습니다', () => {
|
|
325
|
+
// 워커 안에서 멈춘 이유를 탭이 알아야 원인별 안내(재로그인·한도)를 가를 수 있습니다
|
|
326
|
+
it('티켓 발급이 거부되어 멈추면 사유가 티켓을 발급하지 않은 탭에도 전달됩니다', async () => {
|
|
327
|
+
const { hub } = createConnectionHarness();
|
|
328
|
+
const requester = new FakeTabPort();
|
|
329
|
+
const bystander = new FakeTabPort();
|
|
330
|
+
hub.addPort(requester);
|
|
331
|
+
hub.addPort(bystander);
|
|
332
|
+
|
|
333
|
+
hub.handleMessage(requester, {
|
|
334
|
+
kind: 'init',
|
|
335
|
+
baseUrl: 'https://sse.example.com',
|
|
336
|
+
});
|
|
337
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
338
|
+
hub.handleMessage(requester, {
|
|
339
|
+
kind: 'ticket-response',
|
|
340
|
+
ok: false,
|
|
341
|
+
error: { status: 429 },
|
|
342
|
+
});
|
|
343
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
344
|
+
|
|
345
|
+
const delivered = {
|
|
346
|
+
kind: 'event',
|
|
347
|
+
event: 'fatal',
|
|
348
|
+
payload: { cause: 'rate_limit', status: 429 },
|
|
349
|
+
};
|
|
350
|
+
expect(kindsOf(requester, 'event').at(-1)).toEqual(delivered);
|
|
351
|
+
expect(kindsOf(bystander, 'event').at(-1)).toEqual(delivered);
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
// throw는 postMessage를 건너지 못하므로 탭이 값(cause)으로 실어 보냅니다 — 워커도 직결 경로와 같은 판정을 해야 합니다
|
|
355
|
+
it('탭의 getAuthToken 실패(auth_callback)가 전달되면 네트워크로 오분류하지 않고 모든 탭에 fatal을 알립니다', async () => {
|
|
356
|
+
const { hub } = createConnectionHarness();
|
|
357
|
+
const requester = new FakeTabPort();
|
|
358
|
+
const bystander = new FakeTabPort();
|
|
359
|
+
hub.addPort(requester);
|
|
360
|
+
hub.addPort(bystander);
|
|
361
|
+
|
|
362
|
+
hub.handleMessage(requester, {
|
|
363
|
+
kind: 'init',
|
|
364
|
+
baseUrl: 'https://sse.example.com',
|
|
365
|
+
});
|
|
366
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
367
|
+
hub.handleMessage(requester, {
|
|
368
|
+
kind: 'ticket-response',
|
|
369
|
+
ok: false,
|
|
370
|
+
error: { cause: 'auth_callback' },
|
|
371
|
+
});
|
|
372
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
373
|
+
|
|
374
|
+
const delivered = {
|
|
375
|
+
kind: 'event',
|
|
376
|
+
event: 'fatal',
|
|
377
|
+
payload: { cause: 'auth_callback' },
|
|
378
|
+
};
|
|
379
|
+
expect(kindsOf(requester, 'event').at(-1)).toEqual(delivered);
|
|
380
|
+
expect(kindsOf(bystander, 'event').at(-1)).toEqual(delivered);
|
|
381
|
+
|
|
382
|
+
// fatal은 재시도하지 않습니다 — 탭에 티켓을 다시 요청하지 않습니다
|
|
383
|
+
await vi.advanceTimersByTimeAsync(120_000);
|
|
384
|
+
expect(kindsOf(requester, 'ticket-request')).toHaveLength(1);
|
|
385
|
+
});
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
describe('워커 연결 — 네트워크 에러 지속(stalled)은 모든 탭에 닿습니다', () => {
|
|
389
|
+
it('티켓 발급이 네트워크성으로 연속 5회 실패하면 stalled가 모든 탭에 전달됩니다', async () => {
|
|
390
|
+
const { hub } = createConnectionHarness();
|
|
391
|
+
const requester = new FakeTabPort();
|
|
392
|
+
const bystander = new FakeTabPort();
|
|
393
|
+
hub.addPort(requester);
|
|
394
|
+
hub.addPort(bystander);
|
|
395
|
+
|
|
396
|
+
const failTicketWithNetworkError = () =>
|
|
397
|
+
hub.handleMessage(requester, {
|
|
398
|
+
kind: 'ticket-response',
|
|
399
|
+
ok: false,
|
|
400
|
+
error: {},
|
|
401
|
+
});
|
|
402
|
+
const stalledCount = (port: FakeTabPort) =>
|
|
403
|
+
kindsOf(port, 'event').filter((m) => m.event === 'stalled').length;
|
|
404
|
+
|
|
405
|
+
hub.handleMessage(requester, {
|
|
406
|
+
kind: 'init',
|
|
407
|
+
baseUrl: 'https://sse.example.com',
|
|
408
|
+
});
|
|
409
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
410
|
+
failTicketWithNetworkError();
|
|
411
|
+
await vi.advanceTimersByTimeAsync(1000);
|
|
412
|
+
failTicketWithNetworkError();
|
|
413
|
+
await vi.advanceTimersByTimeAsync(2000);
|
|
414
|
+
failTicketWithNetworkError();
|
|
415
|
+
await vi.advanceTimersByTimeAsync(4000);
|
|
416
|
+
failTicketWithNetworkError();
|
|
417
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
418
|
+
expect(stalledCount(requester)).toBe(0);
|
|
419
|
+
|
|
420
|
+
await vi.advanceTimersByTimeAsync(8000);
|
|
421
|
+
failTicketWithNetworkError(); // → 발화
|
|
422
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
423
|
+
|
|
424
|
+
expect(stalledCount(requester)).toBe(1);
|
|
425
|
+
expect(stalledCount(bystander)).toBe(1);
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
it('탭이 전달한 서버 응답 거부(5xx)도 네트워크성 연속 판정을 끊습니다', async () => {
|
|
429
|
+
// 기본 백오프면 리셋 후 5회에 30초 대기가 쌓여 탭이 침묵 판정(휴면)됩니다 — 대기를 캡해 판정만 봅니다
|
|
430
|
+
const hub = createWorkerHub({
|
|
431
|
+
eventSource: FakeEventSource as unknown as typeof EventSource,
|
|
432
|
+
backoff: { maxDelayMs: 1000 },
|
|
433
|
+
});
|
|
434
|
+
const requester = new FakeTabPort();
|
|
435
|
+
hub.addPort(requester);
|
|
436
|
+
|
|
437
|
+
const failTicketWithNetworkError = () =>
|
|
438
|
+
hub.handleMessage(requester, {
|
|
439
|
+
kind: 'ticket-response',
|
|
440
|
+
ok: false,
|
|
441
|
+
error: {},
|
|
442
|
+
});
|
|
443
|
+
const failTicketWithServerError = () =>
|
|
444
|
+
hub.handleMessage(requester, {
|
|
445
|
+
kind: 'ticket-response',
|
|
446
|
+
ok: false,
|
|
447
|
+
error: { status: 500 },
|
|
448
|
+
});
|
|
449
|
+
const stalledCount = () =>
|
|
450
|
+
kindsOf(requester, 'event').filter((m) => m.event === 'stalled').length;
|
|
451
|
+
|
|
452
|
+
hub.handleMessage(requester, {
|
|
453
|
+
kind: 'init',
|
|
454
|
+
baseUrl: 'https://sse.example.com',
|
|
455
|
+
});
|
|
456
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
457
|
+
failTicketWithNetworkError();
|
|
458
|
+
await vi.advanceTimersByTimeAsync(1000);
|
|
459
|
+
failTicketWithNetworkError();
|
|
460
|
+
await vi.advanceTimersByTimeAsync(1000);
|
|
461
|
+
failTicketWithNetworkError();
|
|
462
|
+
await vi.advanceTimersByTimeAsync(1000);
|
|
463
|
+
failTicketWithNetworkError();
|
|
464
|
+
await vi.advanceTimersByTimeAsync(1000);
|
|
465
|
+
failTicketWithServerError();
|
|
466
|
+
await vi.advanceTimersByTimeAsync(1000);
|
|
467
|
+
failTicketWithNetworkError();
|
|
468
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
469
|
+
// 끊기지 않았다면 직전 응답이 네트워크성 5회째라 이미 발화했을 것입니다
|
|
470
|
+
expect(stalledCount()).toBe(0);
|
|
471
|
+
|
|
472
|
+
await vi.advanceTimersByTimeAsync(1000);
|
|
473
|
+
failTicketWithNetworkError();
|
|
474
|
+
await vi.advanceTimersByTimeAsync(1000);
|
|
475
|
+
failTicketWithNetworkError();
|
|
476
|
+
await vi.advanceTimersByTimeAsync(1000);
|
|
477
|
+
failTicketWithNetworkError();
|
|
478
|
+
await vi.advanceTimersByTimeAsync(1000);
|
|
479
|
+
failTicketWithNetworkError();
|
|
480
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
481
|
+
expect(stalledCount()).toBe(1);
|
|
482
|
+
});
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
describe('워커 연결 — 마지막 탭이 떠나면 연결을 정리합니다', () => {
|
|
486
|
+
it('탭이 하나 남아 있으면 연결을 유지합니다', async () => {
|
|
487
|
+
const { hub } = createConnectionHarness();
|
|
488
|
+
const leaving = new FakeTabPort();
|
|
489
|
+
const staying = new FakeTabPort();
|
|
490
|
+
hub.addPort(leaving);
|
|
491
|
+
hub.addPort(staying);
|
|
492
|
+
|
|
493
|
+
hub.handleMessage(leaving, {
|
|
494
|
+
kind: 'init',
|
|
495
|
+
baseUrl: 'https://sse.example.com',
|
|
496
|
+
});
|
|
497
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
498
|
+
respondTicket(hub, leaving, 'ticket-1');
|
|
499
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
500
|
+
|
|
501
|
+
hub.handleMessage(leaving, { kind: 'close' });
|
|
502
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
503
|
+
|
|
504
|
+
expect(lastSource().closed).toBe(false);
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
it('마지막 탭이 떠나면 연결을 닫습니다', async () => {
|
|
508
|
+
const { hub } = createConnectionHarness();
|
|
509
|
+
const port = new FakeTabPort();
|
|
510
|
+
hub.addPort(port);
|
|
511
|
+
|
|
512
|
+
hub.handleMessage(port, {
|
|
513
|
+
kind: 'init',
|
|
514
|
+
baseUrl: 'https://sse.example.com',
|
|
515
|
+
});
|
|
516
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
517
|
+
respondTicket(hub, port, 'ticket-1');
|
|
518
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
519
|
+
const source = lastSource();
|
|
520
|
+
|
|
521
|
+
hub.handleMessage(port, { kind: 'close' });
|
|
522
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
523
|
+
|
|
524
|
+
expect(source.closed).toBe(true);
|
|
525
|
+
});
|
|
526
|
+
|
|
527
|
+
it('탭이 침묵해도(crash·frozen) 장부와 연결은 남습니다 — 정리는 브라우저의 워커 회수가 합니다', async () => {
|
|
528
|
+
const { hub } = createConnectionHarness();
|
|
529
|
+
const port = new FakeTabPort();
|
|
530
|
+
hub.addPort(port);
|
|
531
|
+
|
|
532
|
+
hub.handleMessage(port, {
|
|
533
|
+
kind: 'init',
|
|
534
|
+
baseUrl: 'https://sse.example.com',
|
|
535
|
+
});
|
|
536
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
537
|
+
respondTicket(hub, port, 'ticket-1');
|
|
538
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
539
|
+
lastSource().dispatch('hello');
|
|
540
|
+
const source = lastSource();
|
|
541
|
+
|
|
542
|
+
// 탭이 pong을 못 내는 상태가 이어져도 장부에서 빼지 않습니다
|
|
543
|
+
await vi.advanceTimersByTimeAsync(PING_INTERVAL_MS);
|
|
544
|
+
await vi.advanceTimersByTimeAsync(PING_INTERVAL_MS);
|
|
545
|
+
|
|
546
|
+
expect(hub.tabCount()).toBe(1);
|
|
547
|
+
expect(source.closed).toBe(false);
|
|
548
|
+
});
|
|
549
|
+
|
|
550
|
+
it('일부 탭이 침묵해도 연결이 유지되고, 침묵한 탭에게도 이벤트가 계속 나갑니다', async () => {
|
|
551
|
+
const { hub } = createConnectionHarness();
|
|
552
|
+
const frozen = new FakeTabPort();
|
|
553
|
+
const responsive = new FakeTabPort();
|
|
554
|
+
hub.addPort(frozen);
|
|
555
|
+
hub.addPort(responsive);
|
|
556
|
+
|
|
557
|
+
hub.handleMessage(frozen, {
|
|
558
|
+
kind: 'init',
|
|
559
|
+
baseUrl: 'https://sse.example.com',
|
|
560
|
+
});
|
|
561
|
+
hub.handleMessage(frozen, { kind: 'subscribe', event: 'demo.created' });
|
|
562
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
563
|
+
respondTicket(hub, frozen, 'ticket-1');
|
|
564
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
565
|
+
const source = lastSource();
|
|
566
|
+
source.dispatch('hello');
|
|
567
|
+
|
|
568
|
+
// 한 탭만 pong이 없는 상태 — 살아 있는 탭은 매 주기 응답합니다
|
|
569
|
+
await vi.advanceTimersByTimeAsync(PING_INTERVAL_MS);
|
|
570
|
+
hub.handleMessage(responsive, { kind: 'pong' });
|
|
571
|
+
await vi.advanceTimersByTimeAsync(PING_INTERVAL_MS);
|
|
572
|
+
hub.handleMessage(responsive, { kind: 'pong' });
|
|
573
|
+
|
|
574
|
+
expect(hub.tabCount()).toBe(2);
|
|
575
|
+
expect(source.closed).toBe(false);
|
|
576
|
+
|
|
577
|
+
source.dispatch('demo.created', '{"id":"a"}');
|
|
578
|
+
expect(frozen.sent.at(-1)).toEqual({
|
|
579
|
+
kind: 'event',
|
|
580
|
+
event: 'demo.created',
|
|
581
|
+
payload: { id: 'a' },
|
|
582
|
+
});
|
|
583
|
+
expect(responsive.sent.at(-1)).toEqual(frozen.sent.at(-1));
|
|
584
|
+
});
|
|
585
|
+
|
|
586
|
+
it('티켓 응답을 기다리던 중 마지막 탭이 떠나면, 새로 합류한 탭은 자기 티켓 요청을 받습니다', async () => {
|
|
587
|
+
const { hub } = createConnectionHarness();
|
|
588
|
+
const leaving = new FakeTabPort();
|
|
589
|
+
hub.addPort(leaving);
|
|
590
|
+
|
|
591
|
+
// 티켓 요청만 나간 상태에서 탭이 떠납니다 — 응답은 오지 않았습니다
|
|
592
|
+
hub.handleMessage(leaving, {
|
|
593
|
+
kind: 'init',
|
|
594
|
+
baseUrl: 'https://sse.example.com',
|
|
595
|
+
});
|
|
596
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
597
|
+
hub.handleMessage(leaving, { kind: 'close' });
|
|
598
|
+
|
|
599
|
+
const joining = new FakeTabPort();
|
|
600
|
+
hub.addPort(joining);
|
|
601
|
+
hub.handleMessage(joining, {
|
|
602
|
+
kind: 'init',
|
|
603
|
+
baseUrl: 'https://sse.example.com',
|
|
604
|
+
});
|
|
605
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
606
|
+
|
|
607
|
+
expect(kindsOf(joining, 'ticket-request')).toHaveLength(1);
|
|
608
|
+
});
|
|
609
|
+
|
|
610
|
+
it('티켓 응답을 기다리는 중 다른 탭이 합류해도, 진행 중인 발급이 끊기지 않습니다', async () => {
|
|
611
|
+
const { hub } = createConnectionHarness();
|
|
612
|
+
const first = new FakeTabPort();
|
|
613
|
+
hub.addPort(first);
|
|
614
|
+
|
|
615
|
+
// 티켓 요청만 나가고 아직 응답이 없는 상태입니다
|
|
616
|
+
hub.handleMessage(first, {
|
|
617
|
+
kind: 'init',
|
|
618
|
+
baseUrl: 'https://sse.example.com',
|
|
619
|
+
});
|
|
620
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
621
|
+
|
|
622
|
+
// 이 시점에 두 번째 탭이 합류합니다 — 첫 탭의 발급은 계속 유효해야 합니다
|
|
623
|
+
const joining = new FakeTabPort();
|
|
624
|
+
hub.addPort(joining);
|
|
625
|
+
hub.handleMessage(joining, {
|
|
626
|
+
kind: 'init',
|
|
627
|
+
baseUrl: 'https://sse.example.com',
|
|
628
|
+
});
|
|
629
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
630
|
+
|
|
631
|
+
respondTicket(hub, first, 'ticket-1');
|
|
632
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
633
|
+
|
|
634
|
+
expect(lastSource().url).toBe(
|
|
635
|
+
'https://sse.example.com/v1/events?ticket=ticket-1'
|
|
636
|
+
);
|
|
637
|
+
});
|
|
638
|
+
|
|
639
|
+
it('형식이 어긋난 ticket-response를 받아도 연결 루프가 멈추지 않고 다시 시도합니다', async () => {
|
|
640
|
+
const { hub } = createConnectionHarness();
|
|
641
|
+
const port = new FakeTabPort();
|
|
642
|
+
hub.addPort(port);
|
|
643
|
+
|
|
644
|
+
hub.handleMessage(port, {
|
|
645
|
+
kind: 'init',
|
|
646
|
+
baseUrl: 'https://sse.example.com',
|
|
647
|
+
});
|
|
648
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
649
|
+
|
|
650
|
+
// error가 null이면 코어가 status를 꺼내다 예외를 냅니다 — 경계에서 걸러야 합니다
|
|
651
|
+
hub.handleMessage(port, {
|
|
652
|
+
kind: 'ticket-response',
|
|
653
|
+
ok: false,
|
|
654
|
+
error: null,
|
|
655
|
+
} as unknown as TabToWorkerMessage);
|
|
656
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
657
|
+
|
|
658
|
+
// 걸러진 응답은 왕복을 끝내지 않으므로, 시간 제한이 끝내고 재시도로 이어집니다
|
|
659
|
+
await vi.advanceTimersByTimeAsync(TICKET_RESPONSE_TIMEOUT_MS);
|
|
660
|
+
await vi.advanceTimersByTimeAsync(10_000);
|
|
661
|
+
|
|
662
|
+
expect(kindsOf(port, 'ticket-request').length).toBeGreaterThan(1);
|
|
663
|
+
|
|
664
|
+
respondTicket(hub, port, 'ticket-2');
|
|
665
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
666
|
+
|
|
667
|
+
expect(lastSource().url).toBe(
|
|
668
|
+
'https://sse.example.com/v1/events?ticket=ticket-2'
|
|
669
|
+
);
|
|
670
|
+
});
|
|
671
|
+
|
|
672
|
+
it('탭이 남아 있는 채 연결이 fatal이 되어도, 정리 후 다시 connect한 탭이 연결을 되살립니다', async () => {
|
|
673
|
+
const { hub } = createConnectionHarness();
|
|
674
|
+
const recovering = new FakeTabPort();
|
|
675
|
+
const staying = new FakeTabPort();
|
|
676
|
+
hub.addPort(recovering);
|
|
677
|
+
hub.addPort(staying);
|
|
678
|
+
|
|
679
|
+
// 티켓 발급이 429로 거부되어 연결이 fatal로 멈춥니다
|
|
680
|
+
hub.handleMessage(recovering, {
|
|
681
|
+
kind: 'init',
|
|
682
|
+
baseUrl: 'https://sse.example.com',
|
|
683
|
+
});
|
|
684
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
685
|
+
hub.handleMessage(recovering, {
|
|
686
|
+
kind: 'ticket-response',
|
|
687
|
+
ok: false,
|
|
688
|
+
error: { status: 429 },
|
|
689
|
+
});
|
|
690
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
691
|
+
expect(staying.sent).toContainEqual({ kind: 'status', status: 'fatal' });
|
|
692
|
+
|
|
693
|
+
// 소비처가 계약대로 정리 후 재시도 — 다른 탭이 남아 있어도 되살아나야 합니다
|
|
694
|
+
hub.handleMessage(recovering, { kind: 'close' });
|
|
695
|
+
const rejoining = new FakeTabPort();
|
|
696
|
+
hub.addPort(rejoining);
|
|
697
|
+
hub.handleMessage(rejoining, {
|
|
698
|
+
kind: 'init',
|
|
699
|
+
baseUrl: 'https://sse.example.com',
|
|
700
|
+
});
|
|
701
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
702
|
+
respondTicket(hub, rejoining, 'ticket-2');
|
|
703
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
704
|
+
|
|
705
|
+
expect(lastSource().url).toBe(
|
|
706
|
+
'https://sse.example.com/v1/events?ticket=ticket-2'
|
|
707
|
+
);
|
|
708
|
+
|
|
709
|
+
// 교체 과정의 closed는 워커 내부 사정이라 탭에 새지 않습니다
|
|
710
|
+
const seen = staying.sent
|
|
711
|
+
.filter((message) => message.kind === 'status')
|
|
712
|
+
.map((message) => (message as { status: string }).status);
|
|
713
|
+
expect(seen).not.toContain('closed');
|
|
714
|
+
});
|
|
715
|
+
|
|
716
|
+
it('연결이 정리된 뒤 합류한 탭은 idle 스냅샷을 받습니다 — 낡은 open을 물려받지 않게', async () => {
|
|
717
|
+
const { hub } = createConnectionHarness();
|
|
718
|
+
const first = new FakeTabPort();
|
|
719
|
+
hub.addPort(first);
|
|
720
|
+
|
|
721
|
+
hub.handleMessage(first, {
|
|
722
|
+
kind: 'init',
|
|
723
|
+
baseUrl: 'https://sse.example.com',
|
|
724
|
+
});
|
|
725
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
726
|
+
respondTicket(hub, first, 'ticket-1');
|
|
727
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
728
|
+
lastSource().dispatch('hello');
|
|
729
|
+
expect(first.sent.at(-1)).toEqual({ kind: 'status', status: 'open' });
|
|
730
|
+
|
|
731
|
+
hub.handleMessage(first, { kind: 'close' });
|
|
732
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
733
|
+
|
|
734
|
+
// init을 보내지 않는 탭도 있습니다 — 스냅샷만 받고 기다리는 동안 open으로 보이면 안 됩니다
|
|
735
|
+
const joining = new FakeTabPort();
|
|
736
|
+
hub.addPort(joining);
|
|
737
|
+
|
|
738
|
+
expect(joining.sent[0]).toEqual({ kind: 'status', status: 'idle' });
|
|
739
|
+
});
|
|
740
|
+
|
|
741
|
+
it('close 없이 재init만으로도 죽은 연결을 교체합니다', async () => {
|
|
742
|
+
const { hub } = createConnectionHarness();
|
|
743
|
+
const port = new FakeTabPort();
|
|
744
|
+
hub.addPort(port);
|
|
745
|
+
|
|
746
|
+
hub.handleMessage(port, {
|
|
747
|
+
kind: 'init',
|
|
748
|
+
baseUrl: 'https://sse.example.com',
|
|
749
|
+
});
|
|
750
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
751
|
+
hub.handleMessage(port, {
|
|
752
|
+
kind: 'ticket-response',
|
|
753
|
+
ok: false,
|
|
754
|
+
error: { status: 429 },
|
|
755
|
+
});
|
|
756
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
757
|
+
|
|
758
|
+
// close를 보내지 않고 다시 init만 보냅니다 — fatal 연결이 교체되어야 합니다
|
|
759
|
+
hub.handleMessage(port, {
|
|
760
|
+
kind: 'init',
|
|
761
|
+
baseUrl: 'https://sse.example.com',
|
|
762
|
+
});
|
|
763
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
764
|
+
respondTicket(hub, port, 'ticket-2');
|
|
765
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
766
|
+
|
|
767
|
+
expect(lastSource().url).toBe(
|
|
768
|
+
'https://sse.example.com/v1/events?ticket=ticket-2'
|
|
769
|
+
);
|
|
770
|
+
});
|
|
771
|
+
|
|
772
|
+
it('탭이 모두 떠난 뒤 합류한 탭은 첫 연결로 취급돼 resync를 받지 않습니다', async () => {
|
|
773
|
+
const { hub } = createConnectionHarness();
|
|
774
|
+
const leaving = new FakeTabPort();
|
|
775
|
+
hub.addPort(leaving);
|
|
776
|
+
|
|
777
|
+
hub.handleMessage(leaving, {
|
|
778
|
+
kind: 'init',
|
|
779
|
+
baseUrl: 'https://sse.example.com',
|
|
780
|
+
});
|
|
781
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
782
|
+
respondTicket(hub, leaving, 'ticket-1');
|
|
783
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
784
|
+
lastSource().dispatch('hello');
|
|
785
|
+
hub.handleMessage(leaving, { kind: 'close' });
|
|
786
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
787
|
+
|
|
788
|
+
const fresh = new FakeTabPort();
|
|
789
|
+
hub.addPort(fresh);
|
|
790
|
+
hub.handleMessage(fresh, {
|
|
791
|
+
kind: 'init',
|
|
792
|
+
baseUrl: 'https://sse.example.com',
|
|
793
|
+
});
|
|
794
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
795
|
+
respondTicket(hub, fresh, 'ticket-2');
|
|
796
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
797
|
+
lastSource().dispatch('hello');
|
|
798
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
799
|
+
|
|
800
|
+
expect(kindsOf(fresh, 'event')).toHaveLength(0);
|
|
801
|
+
});
|
|
802
|
+
|
|
803
|
+
it('탭이 모두 떠났다가 다시 합류하면 연결을 새로 세웁니다', async () => {
|
|
804
|
+
const { hub } = createConnectionHarness();
|
|
805
|
+
const first = new FakeTabPort();
|
|
806
|
+
hub.addPort(first);
|
|
807
|
+
|
|
808
|
+
hub.handleMessage(first, {
|
|
809
|
+
kind: 'init',
|
|
810
|
+
baseUrl: 'https://sse.example.com',
|
|
811
|
+
});
|
|
812
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
813
|
+
respondTicket(hub, first, 'ticket-1');
|
|
814
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
815
|
+
hub.handleMessage(first, { kind: 'close' });
|
|
816
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
817
|
+
|
|
818
|
+
const second = new FakeTabPort();
|
|
819
|
+
hub.addPort(second);
|
|
820
|
+
hub.handleMessage(second, {
|
|
821
|
+
kind: 'init',
|
|
822
|
+
baseUrl: 'https://sse.example.com',
|
|
823
|
+
});
|
|
824
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
825
|
+
respondTicket(hub, second, 'ticket-2');
|
|
826
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
827
|
+
|
|
828
|
+
expect(FakeEventSource.instances).toHaveLength(2);
|
|
829
|
+
expect(lastSource().url).toBe(
|
|
830
|
+
'https://sse.example.com/v1/events?ticket=ticket-2'
|
|
831
|
+
);
|
|
832
|
+
});
|
|
833
|
+
|
|
834
|
+
// 무응답이 크래시인지 얼어붙은 것인지 알 수 없으니, 끊었다가 되살리는 대신 그대로 둡니다
|
|
835
|
+
// 티켓을 받아줄 탭이 없으면 재시도가 성공할 수 없습니다 — 멈추지 않으면 워커가 영영 헛돕니다
|
|
836
|
+
it('전원이 확정 침묵한 채 연결이 끊기면 재수립을 반복하지 않고 접습니다', async () => {
|
|
837
|
+
const { hub } = createConnectionHarness();
|
|
838
|
+
const port = new FakeTabPort();
|
|
839
|
+
hub.addPort(port);
|
|
840
|
+
|
|
841
|
+
hub.handleMessage(port, {
|
|
842
|
+
kind: 'init',
|
|
843
|
+
baseUrl: 'https://sse.example.com',
|
|
844
|
+
});
|
|
845
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
846
|
+
respondTicket(hub, port, 'ticket-1');
|
|
847
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
848
|
+
lastSource().dispatch('hello');
|
|
849
|
+
|
|
850
|
+
// 두 주기 무응답 — 판정이 확정됩니다. 장부에는 남습니다
|
|
851
|
+
await vi.advanceTimersByTimeAsync(PING_INTERVAL_MS);
|
|
852
|
+
await vi.advanceTimersByTimeAsync(PING_INTERVAL_MS);
|
|
853
|
+
expect(hub.tabCount()).toBe(1);
|
|
854
|
+
const requestsBefore = kindsOf(port, 'ticket-request').length;
|
|
855
|
+
|
|
856
|
+
lastSource().error();
|
|
857
|
+
await vi.advanceTimersByTimeAsync(300_000);
|
|
858
|
+
|
|
859
|
+
expect(FakeEventSource.instances).toHaveLength(1);
|
|
860
|
+
// frozen 탭의 큐에 요청이 쌓이지 않았어야 합니다
|
|
861
|
+
expect(kindsOf(port, 'ticket-request')).toHaveLength(requestsBefore);
|
|
862
|
+
// ping 주기 하나만 남아야 합니다 — 티켓 대기·백오프 타이머가 남아 있으면 헛돌고 있다는 뜻입니다
|
|
863
|
+
expect(vi.getTimerCount()).toBe(1);
|
|
864
|
+
});
|
|
865
|
+
|
|
866
|
+
it('pong만으로는 접힌 연결이 다시 서지 않습니다 — init이 다시 엽니다', async () => {
|
|
867
|
+
const { hub } = createConnectionHarness();
|
|
868
|
+
const port = new FakeTabPort();
|
|
869
|
+
hub.addPort(port);
|
|
870
|
+
|
|
871
|
+
hub.handleMessage(port, {
|
|
872
|
+
kind: 'init',
|
|
873
|
+
baseUrl: 'https://sse.example.com',
|
|
874
|
+
});
|
|
875
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
876
|
+
respondTicket(hub, port, 'ticket-1');
|
|
877
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
878
|
+
lastSource().dispatch('hello');
|
|
879
|
+
|
|
880
|
+
// 확정 침묵 + 끊김 → 휴면
|
|
881
|
+
await vi.advanceTimersByTimeAsync(PING_INTERVAL_MS);
|
|
882
|
+
await vi.advanceTimersByTimeAsync(PING_INTERVAL_MS);
|
|
883
|
+
lastSource().error();
|
|
884
|
+
await vi.advanceTimersByTimeAsync(60_000);
|
|
885
|
+
expect(FakeEventSource.instances).toHaveLength(1);
|
|
886
|
+
|
|
887
|
+
// 깨어난 탭은 큐에 쌓인 ping에 먼저 pong부터 답합니다 — 이것만으로 열리면 안 됩니다
|
|
888
|
+
hub.handleMessage(port, { kind: 'pong' });
|
|
889
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
890
|
+
expect(FakeEventSource.instances).toHaveLength(1);
|
|
891
|
+
|
|
892
|
+
hub.handleMessage(port, {
|
|
893
|
+
kind: 'init',
|
|
894
|
+
baseUrl: 'https://sse.example.com',
|
|
895
|
+
});
|
|
896
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
897
|
+
respondTicket(hub, port, 'ticket-2');
|
|
898
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
899
|
+
|
|
900
|
+
expect(FakeEventSource.instances).toHaveLength(2);
|
|
901
|
+
});
|
|
902
|
+
|
|
903
|
+
it('침묵으로 접힌 연결도 깨어난 탭의 init이 다시 세우고, resync가 나갑니다', async () => {
|
|
904
|
+
const { hub } = createConnectionHarness();
|
|
905
|
+
const port = new FakeTabPort();
|
|
906
|
+
hub.addPort(port);
|
|
907
|
+
|
|
908
|
+
hub.handleMessage(port, {
|
|
909
|
+
kind: 'init',
|
|
910
|
+
baseUrl: 'https://sse.example.com',
|
|
911
|
+
});
|
|
912
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
913
|
+
respondTicket(hub, port, 'ticket-1');
|
|
914
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
915
|
+
lastSource().dispatch('hello');
|
|
916
|
+
|
|
917
|
+
await vi.advanceTimersByTimeAsync(PING_INTERVAL_MS);
|
|
918
|
+
await vi.advanceTimersByTimeAsync(PING_INTERVAL_MS);
|
|
919
|
+
lastSource().error();
|
|
920
|
+
await vi.advanceTimersByTimeAsync(60_000);
|
|
921
|
+
expect(FakeEventSource.instances).toHaveLength(1);
|
|
922
|
+
|
|
923
|
+
hub.handleMessage(port, {
|
|
924
|
+
kind: 'init',
|
|
925
|
+
baseUrl: 'https://sse.example.com',
|
|
926
|
+
});
|
|
927
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
928
|
+
respondTicket(hub, port, 'ticket-2');
|
|
929
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
930
|
+
lastSource().dispatch('hello');
|
|
931
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
932
|
+
|
|
933
|
+
expect(FakeEventSource.instances).toHaveLength(2);
|
|
934
|
+
// 세션이 이어진 재수립입니다 — 자리를 비운 사이를 소비처가 메울 수 있어야 합니다
|
|
935
|
+
expect(kindsOf(port, 'event').map((m) => m.event)).toEqual(['resync']);
|
|
936
|
+
});
|
|
937
|
+
|
|
938
|
+
// 무응답 후보뿐이어도 판정 전이면 물어봅니다 — 여기서 접으면 아직 살아 있을 탭을 두고 연결이 죽습니다
|
|
939
|
+
it('한 주기 무응답(판정 전)이면 아직 티켓을 물어봅니다', async () => {
|
|
940
|
+
const { hub } = createConnectionHarness();
|
|
941
|
+
const port = new FakeTabPort();
|
|
942
|
+
hub.addPort(port);
|
|
943
|
+
|
|
944
|
+
hub.handleMessage(port, {
|
|
945
|
+
kind: 'init',
|
|
946
|
+
baseUrl: 'https://sse.example.com',
|
|
947
|
+
});
|
|
948
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
949
|
+
respondTicket(hub, port, 'ticket-1');
|
|
950
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
951
|
+
lastSource().dispatch('hello');
|
|
952
|
+
|
|
953
|
+
// pong 없이 한 주기 — 아직 확정 침묵이 아닙니다
|
|
954
|
+
await vi.advanceTimersByTimeAsync(PING_INTERVAL_MS);
|
|
955
|
+
expect(hub.tabCount()).toBe(1);
|
|
956
|
+
const before = kindsOf(port, 'ticket-request').length;
|
|
957
|
+
|
|
958
|
+
lastSource().error();
|
|
959
|
+
await vi.advanceTimersByTimeAsync(2000);
|
|
960
|
+
|
|
961
|
+
expect(kindsOf(port, 'ticket-request').length).toBeGreaterThan(before);
|
|
962
|
+
});
|
|
963
|
+
|
|
964
|
+
it('침묵했던 탭의 init은 살아 있는 연결을 건드리지 않습니다', async () => {
|
|
965
|
+
const { hub } = createConnectionHarness();
|
|
966
|
+
const freezing = new FakeTabPort();
|
|
967
|
+
hub.addPort(freezing);
|
|
968
|
+
|
|
969
|
+
hub.handleMessage(freezing, {
|
|
970
|
+
kind: 'init',
|
|
971
|
+
baseUrl: 'https://sse.example.com',
|
|
972
|
+
});
|
|
973
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
974
|
+
respondTicket(hub, freezing, 'ticket-1');
|
|
975
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
976
|
+
lastSource().dispatch('hello');
|
|
977
|
+
const source = lastSource();
|
|
978
|
+
|
|
979
|
+
await vi.advanceTimersByTimeAsync(PING_INTERVAL_MS);
|
|
980
|
+
await vi.advanceTimersByTimeAsync(PING_INTERVAL_MS);
|
|
981
|
+
expect(source.closed).toBe(false);
|
|
982
|
+
|
|
983
|
+
hub.handleMessage(freezing, {
|
|
984
|
+
kind: 'init',
|
|
985
|
+
baseUrl: 'https://sse.example.com',
|
|
986
|
+
});
|
|
987
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
988
|
+
|
|
989
|
+
// 티켓을 다시 받지 않았다는 증거 — 첫 연결이 그대로 이어집니다
|
|
990
|
+
expect(FakeEventSource.instances).toHaveLength(1);
|
|
991
|
+
});
|
|
992
|
+
});
|
|
993
|
+
|
|
994
|
+
describe('워커 연결 — 재수립 통지', () => {
|
|
995
|
+
it('재수립하면 탭에 resync를 보냅니다', async () => {
|
|
996
|
+
const { hub } = createConnectionHarness();
|
|
997
|
+
const port = new FakeTabPort();
|
|
998
|
+
hub.addPort(port);
|
|
999
|
+
|
|
1000
|
+
hub.handleMessage(port, {
|
|
1001
|
+
kind: 'init',
|
|
1002
|
+
baseUrl: 'https://sse.example.com',
|
|
1003
|
+
});
|
|
1004
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
1005
|
+
respondTicket(hub, port, 'ticket-1');
|
|
1006
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
1007
|
+
lastSource().dispatch('hello');
|
|
1008
|
+
|
|
1009
|
+
const resyncMessages = () =>
|
|
1010
|
+
kindsOf(port, 'event').filter(
|
|
1011
|
+
(message) => 'event' in message && message.event === 'resync'
|
|
1012
|
+
);
|
|
1013
|
+
expect(resyncMessages()).toHaveLength(0);
|
|
1014
|
+
|
|
1015
|
+
lastSource().error();
|
|
1016
|
+
await vi.advanceTimersByTimeAsync(1000);
|
|
1017
|
+
respondTicket(hub, port, 'ticket-2');
|
|
1018
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
1019
|
+
lastSource().dispatch('hello');
|
|
1020
|
+
|
|
1021
|
+
expect(resyncMessages()).toHaveLength(1);
|
|
1022
|
+
});
|
|
1023
|
+
});
|