@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.
Files changed (160) hide show
  1. package/README.md +238 -0
  2. package/dist/src/core/BaseSseClient.cjs.js +97 -0
  3. package/dist/src/core/BaseSseClient.cjs.js.map +1 -0
  4. package/dist/src/core/BaseSseClient.d.ts +33 -0
  5. package/dist/src/core/BaseSseClient.d.ts.map +1 -0
  6. package/dist/src/core/BaseSseClient.esm.js +89 -0
  7. package/dist/src/core/BaseSseClient.esm.js.map +1 -0
  8. package/dist/src/core/SseClient.cjs.js +382 -0
  9. package/dist/src/core/SseClient.cjs.js.map +1 -0
  10. package/dist/src/core/SseClient.d.ts +24 -0
  11. package/dist/src/core/SseClient.d.ts.map +1 -0
  12. package/dist/src/core/SseClient.esm.js +370 -0
  13. package/dist/src/core/SseClient.esm.js.map +1 -0
  14. package/dist/src/core/backoff.cjs.js +30 -0
  15. package/dist/src/core/backoff.cjs.js.map +1 -0
  16. package/dist/src/core/backoff.d.ts +12 -0
  17. package/dist/src/core/backoff.d.ts.map +1 -0
  18. package/dist/src/core/backoff.esm.js +28 -0
  19. package/dist/src/core/backoff.esm.js.map +1 -0
  20. package/dist/src/core/emitter.cjs.js +57 -0
  21. package/dist/src/core/emitter.cjs.js.map +1 -0
  22. package/dist/src/core/emitter.d.ts +14 -0
  23. package/dist/src/core/emitter.d.ts.map +1 -0
  24. package/dist/src/core/emitter.esm.js +51 -0
  25. package/dist/src/core/emitter.esm.js.map +1 -0
  26. package/dist/src/core/eventNames.cjs.js +64 -0
  27. package/dist/src/core/eventNames.cjs.js.map +1 -0
  28. package/dist/src/core/eventNames.d.ts +31 -0
  29. package/dist/src/core/eventNames.d.ts.map +1 -0
  30. package/dist/src/core/eventNames.esm.js +54 -0
  31. package/dist/src/core/eventNames.esm.js.map +1 -0
  32. package/dist/src/core/retryWait.cjs.js +20 -0
  33. package/dist/src/core/retryWait.cjs.js.map +1 -0
  34. package/dist/src/core/retryWait.d.ts +6 -0
  35. package/dist/src/core/retryWait.d.ts.map +1 -0
  36. package/dist/src/core/retryWait.esm.js +18 -0
  37. package/dist/src/core/retryWait.esm.js.map +1 -0
  38. package/dist/src/core/statusMachine.cjs.js +56 -0
  39. package/dist/src/core/statusMachine.cjs.js.map +1 -0
  40. package/dist/src/core/statusMachine.d.ts +9 -0
  41. package/dist/src/core/statusMachine.d.ts.map +1 -0
  42. package/dist/src/core/statusMachine.esm.js +54 -0
  43. package/dist/src/core/statusMachine.esm.js.map +1 -0
  44. package/dist/src/core/stream.cjs.js +136 -0
  45. package/dist/src/core/stream.cjs.js.map +1 -0
  46. package/dist/src/core/stream.d.ts +42 -0
  47. package/dist/src/core/stream.d.ts.map +1 -0
  48. package/dist/src/core/stream.esm.js +128 -0
  49. package/dist/src/core/stream.esm.js.map +1 -0
  50. package/dist/src/core/ticket.cjs.js +237 -0
  51. package/dist/src/core/ticket.cjs.js.map +1 -0
  52. package/dist/src/core/ticket.d.ts +31 -0
  53. package/dist/src/core/ticket.d.ts.map +1 -0
  54. package/dist/src/core/ticket.esm.js +220 -0
  55. package/dist/src/core/ticket.esm.js.map +1 -0
  56. package/dist/src/core/types.cjs.js +54 -0
  57. package/dist/src/core/types.cjs.js.map +1 -0
  58. package/dist/src/core/types.d.ts +131 -0
  59. package/dist/src/core/types.d.ts.map +1 -0
  60. package/dist/src/core/types.esm.js +50 -0
  61. package/dist/src/core/types.esm.js.map +1 -0
  62. package/dist/src/core/wire.cjs.js +29 -0
  63. package/dist/src/core/wire.cjs.js.map +1 -0
  64. package/dist/src/core/wire.d.ts +7 -0
  65. package/dist/src/core/wire.d.ts.map +1 -0
  66. package/dist/src/core/wire.esm.js +23 -0
  67. package/dist/src/core/wire.esm.js.map +1 -0
  68. package/dist/src/createSseClient.cjs.js +30 -0
  69. package/dist/src/createSseClient.cjs.js.map +1 -0
  70. package/dist/src/createSseClient.d.ts +8 -0
  71. package/dist/src/createSseClient.d.ts.map +1 -0
  72. package/dist/src/createSseClient.esm.js +28 -0
  73. package/dist/src/createSseClient.esm.js.map +1 -0
  74. package/dist/src/index.cjs.js +10 -0
  75. package/dist/src/index.cjs.js.map +1 -0
  76. package/dist/src/index.d.ts +6 -0
  77. package/dist/src/index.d.ts.map +1 -0
  78. package/dist/src/index.esm.js +3 -0
  79. package/dist/src/index.esm.js.map +1 -0
  80. package/dist/src/react/createSseContext.cjs.js +130 -0
  81. package/dist/src/react/createSseContext.cjs.js.map +1 -0
  82. package/dist/src/react/createSseContext.d.ts +32 -0
  83. package/dist/src/react/createSseContext.d.ts.map +1 -0
  84. package/dist/src/react/createSseContext.esm.js +122 -0
  85. package/dist/src/react/createSseContext.esm.js.map +1 -0
  86. package/dist/src/react/index.cjs.js +8 -0
  87. package/dist/src/react/index.cjs.js.map +1 -0
  88. package/dist/src/react/index.d.ts +3 -0
  89. package/dist/src/react/index.d.ts.map +1 -0
  90. package/dist/src/react/index.esm.js +2 -0
  91. package/dist/src/react/index.esm.js.map +1 -0
  92. package/dist/src/testing/doubles.d.ts +50 -0
  93. package/dist/src/testing/doubles.d.ts.map +1 -0
  94. package/dist/src/testing/harness.d.ts +43 -0
  95. package/dist/src/testing/harness.d.ts.map +1 -0
  96. package/dist/src/testing/index.d.ts +3 -0
  97. package/dist/src/testing/index.d.ts.map +1 -0
  98. package/dist/src/worker/WorkerSseClient.cjs.js +240 -0
  99. package/dist/src/worker/WorkerSseClient.cjs.js.map +1 -0
  100. package/dist/src/worker/WorkerSseClient.d.ts +23 -0
  101. package/dist/src/worker/WorkerSseClient.d.ts.map +1 -0
  102. package/dist/src/worker/WorkerSseClient.esm.js +228 -0
  103. package/dist/src/worker/WorkerSseClient.esm.js.map +1 -0
  104. package/dist/src/worker/hub.d.ts +19 -0
  105. package/dist/src/worker/hub.d.ts.map +1 -0
  106. package/dist/src/worker/portRegistry.d.ts +41 -0
  107. package/dist/src/worker/portRegistry.d.ts.map +1 -0
  108. package/dist/src/worker/protocol.d.ts +63 -0
  109. package/dist/src/worker/protocol.d.ts.map +1 -0
  110. package/dist/src/worker/sse.worker.d.ts +2 -0
  111. package/dist/src/worker/sse.worker.d.ts.map +1 -0
  112. package/dist/src/worker/ticketDelegate.d.ts +16 -0
  113. package/dist/src/worker/ticketDelegate.d.ts.map +1 -0
  114. package/dist/src/worker/workerConnection.d.ts +28 -0
  115. package/dist/src/worker/workerConnection.d.ts.map +1 -0
  116. package/dist/worker/sse.worker.js +1865 -0
  117. package/dist/worker/sse.worker.js.map +1 -0
  118. package/package.json +87 -0
  119. package/src/core/BaseSseClient.test.ts +142 -0
  120. package/src/core/BaseSseClient.ts +103 -0
  121. package/src/core/SseClient.connection.test.ts +863 -0
  122. package/src/core/SseClient.scenarios.test.ts +164 -0
  123. package/src/core/SseClient.test.ts +63 -0
  124. package/src/core/SseClient.ts +310 -0
  125. package/src/core/backoff.test.ts +43 -0
  126. package/src/core/backoff.ts +39 -0
  127. package/src/core/emitter.ts +56 -0
  128. package/src/core/eventNames.ts +53 -0
  129. package/src/core/retryWait.ts +18 -0
  130. package/src/core/statusMachine.ts +56 -0
  131. package/src/core/stream.ts +141 -0
  132. package/src/core/ticket.ts +146 -0
  133. package/src/core/types.ts +182 -0
  134. package/src/core/wire.ts +23 -0
  135. package/src/createSseClient.test.ts +82 -0
  136. package/src/createSseClient.ts +34 -0
  137. package/src/index.ts +21 -0
  138. package/src/react/SseProvider.test.tsx +129 -0
  139. package/src/react/createSseContext.test.ts +47 -0
  140. package/src/react/createSseContext.tsx +140 -0
  141. package/src/react/index.ts +2 -0
  142. package/src/react/useSseEvent.test.tsx +71 -0
  143. package/src/react/useSseStatus.test.tsx +40 -0
  144. package/src/testing/doubles.ts +128 -0
  145. package/src/testing/harness.ts +82 -0
  146. package/src/testing/index.ts +3 -0
  147. package/src/worker/SseClientLike.contract.test.ts +270 -0
  148. package/src/worker/WorkerSseClient.scenarios.test.ts +179 -0
  149. package/src/worker/WorkerSseClient.test.ts +377 -0
  150. package/src/worker/WorkerSseClient.ts +190 -0
  151. package/src/worker/hub.connection.test.ts +1023 -0
  152. package/src/worker/hub.test.ts +213 -0
  153. package/src/worker/hub.ts +120 -0
  154. package/src/worker/portRegistry.test.ts +59 -0
  155. package/src/worker/portRegistry.ts +86 -0
  156. package/src/worker/protocol.ts +80 -0
  157. package/src/worker/sse.worker.ts +19 -0
  158. package/src/worker/ticketDelegate.test.ts +99 -0
  159. package/src/worker/ticketDelegate.ts +80 -0
  160. package/src/worker/workerConnection.ts +134 -0
@@ -0,0 +1,863 @@
1
+ // @vitest-environment node
2
+ import { describe, expect, it, vi } from 'vitest';
3
+
4
+ import type { SseStatus } from './types';
5
+
6
+ import {
7
+ createHarness,
8
+ FakeEventSource,
9
+ lastSource,
10
+ openConnection,
11
+ setupFakeTimers,
12
+ } from '../testing';
13
+
14
+ setupFakeTimers();
15
+
16
+ describe('연결 수립', () => {
17
+ it('티켓을 발급받아 티켓이 담긴 주소로 연결하고, hello를 받으면 open 상태가 됩니다', async () => {
18
+ const { client } = createHarness();
19
+ const statuses: SseStatus[] = [];
20
+ client.onStatus((status) => statuses.push(status));
21
+
22
+ client.connect();
23
+ expect(client.status).toBe('connecting');
24
+
25
+ await vi.advanceTimersByTimeAsync(0);
26
+ expect(FakeEventSource.instances).toHaveLength(1);
27
+ expect(lastSource().url).toBe(
28
+ 'https://sse.example.com/v1/events?ticket=ticket-1'
29
+ );
30
+ expect(client.status).toBe('connecting');
31
+
32
+ lastSource().dispatch('hello');
33
+ expect(client.status).toBe('open');
34
+ expect(statuses).toEqual(['connecting', 'open']);
35
+ });
36
+
37
+ it('connect()를 여러 번 불러도 티켓 발급과 연결은 한 번만 일어납니다', async () => {
38
+ const { client, ticketRequests } = createHarness();
39
+
40
+ client.connect();
41
+ client.connect();
42
+ await vi.advanceTimersByTimeAsync(0);
43
+ lastSource().dispatch('hello');
44
+ client.connect();
45
+ await vi.advanceTimersByTimeAsync(0);
46
+
47
+ expect(ticketRequests()).toHaveLength(1);
48
+ expect(FakeEventSource.instances).toHaveLength(1);
49
+ });
50
+
51
+ it('연결 전에 등록한 구독도, 연결 후에 등록한 구독도 이벤트를 받습니다', async () => {
52
+ const { client } = createHarness();
53
+ const before: { id: string }[] = [];
54
+ const after: { id: string }[] = [];
55
+
56
+ client.on('demo.created', (event) => before.push(event));
57
+ await openConnection(client);
58
+ client.on('demo.created', (event) => after.push(event));
59
+
60
+ lastSource().dispatch('demo.created', '{"id":"a"}');
61
+ expect(before).toEqual([{ id: 'a' }]);
62
+ expect(after).toEqual([{ id: 'a' }]);
63
+ });
64
+
65
+ // 서버가 JSON이 아닌 페이로드를 보내도 SDK가 임의로 버리지 않습니다 — 해석은 소비처 몫입니다
66
+ it('JSON이 아닌 페이로드는 원문 문자열로 배달되고, 연결은 유지됩니다', async () => {
67
+ const { client } = createHarness();
68
+ const received: unknown[] = [];
69
+ client.on('demo.created', (payload) => received.push(payload));
70
+
71
+ await openConnection(client);
72
+ lastSource().dispatch('demo.created', 'not-json');
73
+ lastSource().dispatch('demo.created', '{"id":"a"}');
74
+
75
+ expect(received).toEqual(['not-json', { id: 'a' }]);
76
+ expect(client.status).toBe('open');
77
+ });
78
+
79
+ it('티켓 발급 응답이 오지 않으면 10초 후 실패로 처리하고 reconnecting이 됩니다', async () => {
80
+ const { client } = createHarness({
81
+ fetch: (() =>
82
+ new Promise(() => {})) as unknown as typeof globalThis.fetch,
83
+ });
84
+
85
+ client.connect();
86
+ await vi.advanceTimersByTimeAsync(10_000);
87
+ expect(client.status).toBe('reconnecting');
88
+ });
89
+
90
+ it('EventSource 생성 자체가 실패하면(잘못된 주소 등) 재시도 없이 fatal이 되고 logger.error로 알립니다', async () => {
91
+ const errors: string[] = [];
92
+ class ThrowingEventSource {
93
+ constructor() {
94
+ throw new Error('invalid url');
95
+ }
96
+ }
97
+ const { client } = createHarness({
98
+ eventSource: ThrowingEventSource as unknown as typeof EventSource,
99
+ logger: { warn: () => {}, error: (message) => errors.push(message) },
100
+ });
101
+
102
+ client.connect();
103
+ await vi.advanceTimersByTimeAsync(0);
104
+ expect(client.status).toBe('fatal');
105
+ expect(errors).toHaveLength(1);
106
+ });
107
+
108
+ // 생성의 동기 throw는 설정 실수라 재시도가 무의미합니다 — 네트워크성 실패(onerror)는 reconnecting으로 갑니다
109
+ it('EventSource 생성 실패의 fatal은 사유를 config로 알립니다', async () => {
110
+ class ThrowingEventSource {
111
+ constructor() {
112
+ throw new Error('invalid url');
113
+ }
114
+ }
115
+ const { client } = createHarness({
116
+ eventSource: ThrowingEventSource as unknown as typeof EventSource,
117
+ logger: { warn: () => {}, error: () => {} },
118
+ });
119
+ const causes: unknown[] = [];
120
+ client.on('fatal', (payload) => causes.push(payload));
121
+
122
+ client.connect();
123
+ await vi.advanceTimersByTimeAsync(0);
124
+
125
+ expect(causes).toEqual([{ cause: 'config' }]);
126
+ });
127
+
128
+ it('연결은 됐지만 hello가 오지 않으면 10초 후 연결을 닫고 reconnecting이 됩니다', async () => {
129
+ const { client } = createHarness();
130
+
131
+ client.connect();
132
+ await vi.advanceTimersByTimeAsync(0);
133
+ const source = lastSource();
134
+
135
+ await vi.advanceTimersByTimeAsync(10_000);
136
+ expect(source.closed).toBe(true);
137
+ expect(client.status).toBe('reconnecting');
138
+ });
139
+ });
140
+
141
+ describe('티켓 발급 실패', () => {
142
+ it('티켓 발급이 401로 거부되면 fatal이 되고 다시 시도하지 않습니다', async () => {
143
+ const { client, ticketRequests, failTicketsWith } = createHarness();
144
+ failTicketsWith(401);
145
+
146
+ client.connect();
147
+ await vi.advanceTimersByTimeAsync(0);
148
+ expect(client.status).toBe('fatal');
149
+
150
+ await vi.advanceTimersByTimeAsync(120_000);
151
+ expect(ticketRequests()).toHaveLength(1);
152
+ });
153
+
154
+ it('fatal 상태에서 connect()를 다시 불러도 연결하지 않고, disconnect()가 먼저 필요함을 logger.warn으로 알립니다', async () => {
155
+ const warnings: string[] = [];
156
+ const { client, ticketRequests, failTicketsWith } = createHarness({
157
+ logger: { warn: (message) => warnings.push(message), error: () => {} },
158
+ });
159
+ failTicketsWith(401);
160
+
161
+ client.connect();
162
+ await vi.advanceTimersByTimeAsync(0);
163
+ expect(client.status).toBe('fatal');
164
+ const warningsBefore = warnings.length;
165
+
166
+ client.connect();
167
+ await vi.advanceTimersByTimeAsync(0);
168
+ expect(client.status).toBe('fatal');
169
+ expect(ticketRequests()).toHaveLength(1);
170
+ expect(warnings).toHaveLength(warningsBefore + 1);
171
+ expect(warnings.at(-1)).toContain(
172
+ 'fatal 상태에서는 connect()가 무시됩니다'
173
+ );
174
+ });
175
+
176
+ // 소비처가 원인별 안내(재로그인·한도)를 가르는 채널입니다 — status 'fatal'만으로는 구분할 수 없습니다
177
+ it.each([401, 403] as const)(
178
+ '티켓 발급이 %i로 거부되면 사유(auth)를 fatal 이벤트로 알립니다',
179
+ async (status) => {
180
+ const { client, failTicketsWith } = createHarness();
181
+ failTicketsWith(status);
182
+ const causes: unknown[] = [];
183
+ client.on('fatal', (payload) => causes.push(payload));
184
+
185
+ client.connect();
186
+ await vi.advanceTimersByTimeAsync(0);
187
+
188
+ expect(causes).toEqual([{ cause: 'auth', status }]);
189
+ }
190
+ );
191
+
192
+ it('티켓 발급이 429로 거부되면 사유(rate_limit)를 알리고, 알릴 때는 전이가 이미 끝나 있습니다', async () => {
193
+ const warnings: string[] = [];
194
+ const { client, failTicketsWith } = createHarness({
195
+ logger: { warn: (message) => warnings.push(message), error: () => {} },
196
+ });
197
+ failTicketsWith(429);
198
+ const seen: unknown[] = [];
199
+ client.on('fatal', (payload) =>
200
+ seen.push({ payload, status: client.status })
201
+ );
202
+
203
+ client.connect();
204
+ await vi.advanceTimersByTimeAsync(0);
205
+
206
+ expect(seen).toEqual([
207
+ { payload: { cause: 'rate_limit', status: 429 }, status: 'fatal' },
208
+ ]);
209
+ expect(warnings.at(-1)).toContain('429');
210
+ });
211
+
212
+ it('티켓 발급이 5xx로 실패하면 대기 시간이 지난 뒤 자동으로 다시 발급을 시도합니다', async () => {
213
+ const { client, ticketRequests, failTicketsWith } = createHarness();
214
+ failTicketsWith(500);
215
+
216
+ client.connect();
217
+ await vi.advanceTimersByTimeAsync(0);
218
+ expect(client.status).toBe('reconnecting');
219
+
220
+ failTicketsWith(null);
221
+ await vi.advanceTimersByTimeAsync(1000);
222
+ expect(ticketRequests()).toHaveLength(2);
223
+ expect(client.status).toBe('connecting');
224
+ });
225
+
226
+ it('getAuthToken이 예외를 던지면 재시도 없이 fatal(auth_callback)이 되고 logger.error로 알립니다', async () => {
227
+ const errors: { message: string; meta?: unknown }[] = [];
228
+ const { client, ticketRequests } = createHarness({
229
+ getAuthToken: () => {
230
+ throw new Error('broken callback');
231
+ },
232
+ logger: {
233
+ warn: () => {},
234
+ error: (message, meta) => errors.push({ message, meta }),
235
+ },
236
+ });
237
+ const seen: unknown[] = [];
238
+ client.on('fatal', (payload) =>
239
+ seen.push({ payload, status: client.status })
240
+ );
241
+
242
+ client.connect();
243
+ await vi.advanceTimersByTimeAsync(0);
244
+
245
+ expect(seen).toEqual([
246
+ { payload: { cause: 'auth_callback' }, status: 'fatal' },
247
+ ]);
248
+ expect(errors.at(-1)?.message).toContain('getAuthToken');
249
+ // 콜백이 던진 원본 예외가 로그에 보존됩니다 — 소비처가 자기 버그를 고칠 단서
250
+ expect(
251
+ errors.some(({ meta }) => (meta as Error)?.message === 'broken callback')
252
+ ).toBe(true);
253
+
254
+ // 발급 요청(fetch)까지 가지 않았고, 재시도도 예약되지 않습니다
255
+ await vi.advanceTimersByTimeAsync(120_000);
256
+ expect(ticketRequests()).toHaveLength(0);
257
+ });
258
+
259
+ it('티켓 응답이 200인데 본문 파싱에 실패하면 fatal이 아니라 재시도합니다', async () => {
260
+ const brokenBodyFetch = (async () =>
261
+ ({
262
+ ok: true,
263
+ status: 200,
264
+ json: async () => {
265
+ throw new Error('not json');
266
+ },
267
+ }) as unknown as Response) as unknown as typeof globalThis.fetch;
268
+ const { client } = createHarness({ fetch: brokenBodyFetch });
269
+
270
+ client.connect();
271
+ await vi.advanceTimersByTimeAsync(0);
272
+ expect(client.status).toBe('reconnecting');
273
+ });
274
+
275
+ it('티켓 응답 본문에 ticket이 없으면(형태 위반) 그 티켓으로 연결하지 않고 재시도하며, logger.warn으로 알립니다', async () => {
276
+ const wrongShapeFetch = (async () =>
277
+ ({
278
+ ok: true,
279
+ status: 200,
280
+ json: async () => ({}),
281
+ }) as unknown as Response) as unknown as typeof globalThis.fetch;
282
+ const warnings: string[] = [];
283
+ const { client } = createHarness({
284
+ fetch: wrongShapeFetch,
285
+ logger: { warn: (message) => warnings.push(message), error: () => {} },
286
+ });
287
+
288
+ client.connect();
289
+ await vi.advanceTimersByTimeAsync(0);
290
+ expect(FakeEventSource.instances).toHaveLength(0);
291
+ expect(client.status).toBe('reconnecting');
292
+ // stalled·fatal 어느 쪽에도 닿지 않는 침묵 분기라 이 로그가 유일한 단서입니다
293
+ expect(warnings.at(-1)).toContain('본문이 계약');
294
+ });
295
+ });
296
+
297
+ describe('재연결', () => {
298
+ it('수신 중 연결이 끊기면 기존 연결을 닫고 다시 수립하며, 두 번째 open부터 resync 이벤트를 알립니다', async () => {
299
+ const { client } = createHarness();
300
+ let resyncCount = 0;
301
+ client.on('resync', () => {
302
+ resyncCount += 1;
303
+ });
304
+
305
+ await openConnection(client);
306
+ const firstSource = lastSource();
307
+ expect(resyncCount).toBe(0);
308
+
309
+ firstSource.error();
310
+ expect(firstSource.closed).toBe(true);
311
+ expect(client.status).toBe('reconnecting');
312
+
313
+ await vi.advanceTimersByTimeAsync(1000);
314
+ lastSource().dispatch('hello');
315
+ expect(client.status).toBe('open');
316
+ expect(resyncCount).toBe(1);
317
+ expect(FakeEventSource.instances).toHaveLength(2);
318
+ });
319
+
320
+ it('이미 닫힌 이전 연결에서 뒤늦게 hello가 도착해도 새 연결의 상태는 바뀌지 않습니다', async () => {
321
+ const { client } = createHarness();
322
+
323
+ await openConnection(client);
324
+ const firstSource = lastSource();
325
+
326
+ client.disconnect();
327
+ client.connect();
328
+ await vi.advanceTimersByTimeAsync(0);
329
+ expect(client.status).toBe('connecting');
330
+
331
+ // 스펙 위반 소스 흉내 — close 후에도 남은 리스너로 늦은 hello를 직접 배달합니다
332
+ firstSource.listeners.get('hello')?.forEach((listener) => listener({}));
333
+ expect(client.status).toBe('connecting');
334
+
335
+ lastSource().dispatch('hello');
336
+ expect(client.status).toBe('open');
337
+ });
338
+
339
+ it('재연결된 뒤에도 기존 구독이 계속 이벤트를 받습니다', async () => {
340
+ const { client } = createHarness();
341
+ const received: { id: string }[] = [];
342
+ client.on('demo.created', (event) => received.push(event));
343
+
344
+ await openConnection(client);
345
+ lastSource().error();
346
+ await vi.advanceTimersByTimeAsync(1000);
347
+ lastSource().dispatch('hello');
348
+
349
+ lastSource().dispatch('demo.created', '{"id":"b"}');
350
+ expect(received).toEqual([{ id: 'b' }]);
351
+ });
352
+
353
+ it('재연결할 때는 그 시점의 최신 토큰으로 티켓을 발급합니다', async () => {
354
+ let token = 'token-1';
355
+ const { client, ticketRequests } = createHarness({
356
+ getAuthToken: () => token,
357
+ });
358
+
359
+ await openConnection(client);
360
+ token = 'token-2';
361
+ lastSource().error();
362
+ await vi.advanceTimersByTimeAsync(1000);
363
+
364
+ expect(ticketRequests().at(-1)?.authorization).toBe('Bearer token-2');
365
+ });
366
+
367
+ it('연결이 성공하면 재시도 대기 시간이 처음(1초)으로 되돌아갑니다', async () => {
368
+ const { client, ticketRequests, failTicketsWith } = createHarness();
369
+ failTicketsWith(500);
370
+
371
+ client.connect();
372
+ await vi.advanceTimersByTimeAsync(0);
373
+ await vi.advanceTimersByTimeAsync(1000);
374
+ failTicketsWith(null);
375
+ await vi.advanceTimersByTimeAsync(2000);
376
+ lastSource().dispatch('hello');
377
+ expect(ticketRequests()).toHaveLength(3);
378
+
379
+ lastSource().error();
380
+ // 대기 시간이 초기화되지 않았다면 다음 지연은 4초라서, 1초 경과 시점에는 재시도가 일어나지 않아야 합니다
381
+ await vi.advanceTimersByTimeAsync(1000);
382
+ expect(ticketRequests()).toHaveLength(4);
383
+ });
384
+
385
+ it('서버가 goaway로 재연결을 예고하면 대기 없이 즉시 다시 연결합니다', async () => {
386
+ const { client, ticketRequests } = createHarness();
387
+
388
+ await openConnection(client);
389
+ lastSource().dispatch('goaway');
390
+ expect(client.status).toBe('reconnecting');
391
+
392
+ await vi.advanceTimersByTimeAsync(0);
393
+ expect(ticketRequests()).toHaveLength(2);
394
+ });
395
+
396
+ it('goaway로 재수립하면 그 연결은 재연결이라 resync를 알립니다', async () => {
397
+ const { client } = createHarness();
398
+ let resyncCount = 0;
399
+ client.on('resync', () => {
400
+ resyncCount += 1;
401
+ });
402
+
403
+ await openConnection(client);
404
+ lastSource().dispatch('goaway', JSON.stringify({ action: 'retry' }));
405
+ await vi.advanceTimersByTimeAsync(0);
406
+ lastSource().dispatch('hello');
407
+
408
+ // retry는 같은 세션이 이어지는 것이라, 끊긴 구간에 놓친 이벤트가 있을 수 있습니다
409
+ expect(resyncCount).toBe(1);
410
+ });
411
+
412
+ it('goaway의 action이 close면 재연결하지 않고 closed가 됩니다', async () => {
413
+ const { client, ticketRequests } = createHarness();
414
+
415
+ await openConnection(client);
416
+ lastSource().dispatch(
417
+ 'goaway',
418
+ JSON.stringify({ reason: 'user_logout', action: 'close' })
419
+ );
420
+
421
+ expect(client.status).toBe('closed');
422
+ await vi.advanceTimersByTimeAsync(30_000);
423
+ expect(ticketRequests()).toHaveLength(1);
424
+ });
425
+
426
+ it('goaway의 페이로드를 구독자에게 그대로 전달합니다 — reason 대응은 소비처 몫입니다', async () => {
427
+ const { client } = createHarness();
428
+ const received: unknown[] = [];
429
+ client.on('goaway', (payload) => received.push(payload));
430
+
431
+ await openConnection(client);
432
+ lastSource().dispatch(
433
+ 'goaway',
434
+ JSON.stringify({ reason: 'maintenance', action: 'close' })
435
+ );
436
+
437
+ expect(received).toEqual([{ reason: 'maintenance', action: 'close' }]);
438
+ });
439
+
440
+ it('모르는 reason·action은 페이로드에서 빼고 전달합니다', async () => {
441
+ const { client } = createHarness();
442
+ const received: unknown[] = [];
443
+ client.on('goaway', (payload) => received.push(payload));
444
+
445
+ await openConnection(client);
446
+ lastSource().dispatch(
447
+ 'goaway',
448
+ JSON.stringify({ reason: 'alien-reason', action: 'unknown-action' })
449
+ );
450
+
451
+ expect(received).toEqual([{}]);
452
+ });
453
+
454
+ // 알림이 전이보다 먼저면 핸들러가 보는 status가 아직 open이라, 그 자리의 connect()가 조용히 무시됩니다
455
+ it('goaway를 알릴 때는 상태 전이가 이미 끝나 있어, 핸들러가 그 자리에서 다시 연결할 수 있습니다', async () => {
456
+ const { client, ticketRequests } = createHarness();
457
+ const seen: string[] = [];
458
+ client.on('goaway', () => {
459
+ seen.push(client.status);
460
+ client.connect();
461
+ });
462
+
463
+ await openConnection(client);
464
+ lastSource().dispatch('goaway', JSON.stringify({ action: 'close' }));
465
+ await vi.advanceTimersByTimeAsync(0);
466
+
467
+ expect(seen).toEqual(['closed']);
468
+ expect(client.status).toBe('connecting');
469
+ expect(ticketRequests()).toHaveLength(2);
470
+ });
471
+
472
+ it('goaway로 닫히면 재시도 대기 시간도 처음으로 되돌립니다 — hello를 못 받고 닫혀도', async () => {
473
+ const { client, ticketRequests, failTicketsWith } = createHarness();
474
+ failTicketsWith(500);
475
+
476
+ client.connect();
477
+ await vi.advanceTimersByTimeAsync(0);
478
+ await vi.advanceTimersByTimeAsync(1000);
479
+ await vi.advanceTimersByTimeAsync(2000);
480
+ failTicketsWith(null);
481
+ await vi.advanceTimersByTimeAsync(4000);
482
+ expect(ticketRequests()).toHaveLength(4);
483
+
484
+ lastSource().dispatch('goaway', JSON.stringify({ action: 'close' }));
485
+ await vi.advanceTimersByTimeAsync(0);
486
+
487
+ failTicketsWith(500);
488
+ client.connect();
489
+ await vi.advanceTimersByTimeAsync(0);
490
+ expect(ticketRequests()).toHaveLength(5);
491
+
492
+ // 되돌리지 않았다면 다음 지연은 8초라서, 1초 경과 시점에는 재시도가 일어나지 않아야 합니다
493
+ await vi.advanceTimersByTimeAsync(1000);
494
+ expect(ticketRequests()).toHaveLength(6);
495
+ });
496
+
497
+ it('goaway의 action이 close면 그 뒤 다시 connect한 첫 연결에서 resync를 알리지 않습니다', async () => {
498
+ const { client } = createHarness();
499
+ let resyncCount = 0;
500
+ client.on('resync', () => {
501
+ resyncCount += 1;
502
+ });
503
+
504
+ await openConnection(client);
505
+ lastSource().dispatch('goaway', JSON.stringify({ action: 'close' }));
506
+ await vi.advanceTimersByTimeAsync(0);
507
+
508
+ await openConnection(client);
509
+ expect(client.status).toBe('open');
510
+ // close는 세션이 끝난 것이라 다시 connect하면 새 세션입니다 — 놓친 구간이라는 게 없습니다
511
+ expect(resyncCount).toBe(0);
512
+ });
513
+
514
+ it('모르는 action은 없는 것으로 보고 재연결합니다', async () => {
515
+ const { client, ticketRequests } = createHarness();
516
+
517
+ await openConnection(client);
518
+ lastSource().dispatch(
519
+ 'goaway',
520
+ JSON.stringify({ reason: 'deploy', action: 'unknown-action' })
521
+ );
522
+
523
+ expect(client.status).toBe('reconnecting');
524
+ await vi.advanceTimersByTimeAsync(0);
525
+ expect(ticketRequests()).toHaveLength(2);
526
+ });
527
+ });
528
+
529
+ describe('disconnect', () => {
530
+ it('disconnect하면 연결을 닫고 closed가 됩니다', async () => {
531
+ const { client } = createHarness();
532
+
533
+ await openConnection(client);
534
+ const source = lastSource();
535
+ client.disconnect();
536
+
537
+ expect(source.closed).toBe(true);
538
+ expect(client.status).toBe('closed');
539
+ });
540
+
541
+ it('disconnect 후 다시 connect할 수 있고, 그 첫 연결에서는 resync를 알리지 않습니다', async () => {
542
+ const { client, ticketRequests } = createHarness();
543
+ let resyncCount = 0;
544
+ client.on('resync', () => {
545
+ resyncCount += 1;
546
+ });
547
+
548
+ await openConnection(client);
549
+ client.disconnect();
550
+ await vi.advanceTimersByTimeAsync(0);
551
+
552
+ await openConnection(client);
553
+ expect(client.status).toBe('open');
554
+ expect(ticketRequests()).toHaveLength(2);
555
+ expect(resyncCount).toBe(0);
556
+ });
557
+
558
+ it('connecting 알림을 받은 리스너가 그 자리에서 disconnect해도 연결 시도가 시작되지 않습니다', async () => {
559
+ const { client, ticketRequests } = createHarness();
560
+ client.onStatus((status) => {
561
+ if (status === 'connecting') {
562
+ client.disconnect();
563
+ }
564
+ });
565
+
566
+ client.connect();
567
+ expect(client.status).toBe('closed');
568
+
569
+ await vi.advanceTimersByTimeAsync(60_000);
570
+ expect(ticketRequests()).toHaveLength(0);
571
+ expect(FakeEventSource.instances).toHaveLength(0);
572
+ });
573
+ it('open 알림을 받은 리스너가 그 자리에서 disconnect해도, 이후 다시 연결한 첫 연결을 재연결로 오인해 resync를 알리지 않습니다', async () => {
574
+ const { client } = createHarness();
575
+ let resyncCount = 0;
576
+ client.on('resync', () => {
577
+ resyncCount += 1;
578
+ });
579
+ const off = client.onStatus((status) => {
580
+ if (status === 'open') {
581
+ client.disconnect();
582
+ }
583
+ });
584
+
585
+ client.connect();
586
+ await vi.advanceTimersByTimeAsync(0);
587
+ lastSource().dispatch('hello');
588
+ expect(client.status).toBe('closed');
589
+
590
+ off();
591
+ await openConnection(client);
592
+ expect(client.status).toBe('open');
593
+ expect(resyncCount).toBe(0);
594
+ });
595
+
596
+ it('티켓 발급 도중 disconnect하면 뒤늦게 도착한 티켓으로 연결하지 않습니다', async () => {
597
+ let resolveTicket: (response: Response) => void = () => {};
598
+ const pending = new Promise<Response>((resolve) => {
599
+ resolveTicket = resolve;
600
+ });
601
+ const { client } = createHarness({
602
+ fetch: (() => pending) as unknown as typeof globalThis.fetch,
603
+ });
604
+
605
+ client.connect();
606
+ client.disconnect();
607
+ resolveTicket({
608
+ ok: true,
609
+ status: 200,
610
+ json: async () => ({ data: { ticket: 'late' } }),
611
+ } as unknown as Response);
612
+ await vi.advanceTimersByTimeAsync(0);
613
+
614
+ expect(FakeEventSource.instances).toHaveLength(0);
615
+ expect(client.status).toBe('closed');
616
+ });
617
+
618
+ it('재시도 대기 중 disconnect하면 예약된 재시도가 취소됩니다', async () => {
619
+ const { client, ticketRequests, failTicketsWith } = createHarness();
620
+ failTicketsWith(500);
621
+
622
+ client.connect();
623
+ await vi.advanceTimersByTimeAsync(0);
624
+ expect(client.status).toBe('reconnecting');
625
+
626
+ client.disconnect();
627
+ await vi.advanceTimersByTimeAsync(120_000);
628
+ expect(ticketRequests()).toHaveLength(1);
629
+ expect(client.status).toBe('closed');
630
+ });
631
+ });
632
+
633
+ describe('미연결 지속(stalled)', () => {
634
+ const rejectingFetch = (() =>
635
+ Promise.reject(new Error('network'))) as unknown as typeof globalThis.fetch;
636
+
637
+ it('hello에 닿지 못한 연속 실패가 5회에 닿으면 stalled를 한 번만 알리고, 재시도는 계속됩니다', async () => {
638
+ const { client } = createHarness({ fetch: rejectingFetch });
639
+ const seen: string[] = [];
640
+ client.on('stalled', () => seen.push(client.status));
641
+
642
+ client.connect();
643
+ await vi.advanceTimersByTimeAsync(0);
644
+ await vi.advanceTimersByTimeAsync(1000);
645
+ await vi.advanceTimersByTimeAsync(2000);
646
+ await vi.advanceTimersByTimeAsync(4000);
647
+ expect(seen).toHaveLength(0);
648
+
649
+ await vi.advanceTimersByTimeAsync(8000);
650
+ expect(seen).toEqual(['reconnecting']);
651
+
652
+ await vi.advanceTimersByTimeAsync(16_000);
653
+ expect(seen).toHaveLength(1);
654
+ expect(client.status).toBe('reconnecting');
655
+ });
656
+
657
+ it('서버가 응답한 거부(5xx)는 네트워크성이 아니라 세지 않습니다', async () => {
658
+ const { client, failTicketsWith } = createHarness();
659
+ failTicketsWith(500);
660
+ let stalledCount = 0;
661
+ client.on('stalled', () => {
662
+ stalledCount += 1;
663
+ });
664
+
665
+ client.connect();
666
+ await vi.advanceTimersByTimeAsync(0);
667
+ await vi.advanceTimersByTimeAsync(1000);
668
+ await vi.advanceTimersByTimeAsync(2000);
669
+ await vi.advanceTimersByTimeAsync(4000);
670
+ await vi.advanceTimersByTimeAsync(8000);
671
+ await vi.advanceTimersByTimeAsync(16_000);
672
+
673
+ expect(client.status).toBe('reconnecting');
674
+ expect(stalledCount).toBe(0);
675
+ });
676
+
677
+ it('서버가 응답하면(5xx) 네트워크성 연속 판정이 끊깁니다', async () => {
678
+ let mode: 'network' | 'server' = 'network';
679
+ const mixedFetch = (async () => {
680
+ if (mode === 'network') {
681
+ throw new Error('network');
682
+ }
683
+ return { ok: false, status: 500 } as Response;
684
+ }) as unknown as typeof globalThis.fetch;
685
+ const { client } = createHarness({ fetch: mixedFetch });
686
+ let stalledCount = 0;
687
+ client.on('stalled', () => {
688
+ stalledCount += 1;
689
+ });
690
+
691
+ client.connect();
692
+ await vi.advanceTimersByTimeAsync(0);
693
+ await vi.advanceTimersByTimeAsync(1000);
694
+ await vi.advanceTimersByTimeAsync(2000);
695
+ await vi.advanceTimersByTimeAsync(4000);
696
+
697
+ mode = 'server';
698
+ await vi.advanceTimersByTimeAsync(8000);
699
+ mode = 'network';
700
+ await vi.advanceTimersByTimeAsync(16_000);
701
+ expect(stalledCount).toBe(0);
702
+
703
+ await vi.advanceTimersByTimeAsync(30_000);
704
+ await vi.advanceTimersByTimeAsync(30_000);
705
+ await vi.advanceTimersByTimeAsync(30_000);
706
+ await vi.advanceTimersByTimeAsync(30_000);
707
+ expect(stalledCount).toBe(1);
708
+ });
709
+
710
+ it('서버가 응답한 200의 본문 파싱 실패도 네트워크성 연속 판정을 끊습니다', async () => {
711
+ let mode: 'network' | 'broken-body' = 'network';
712
+ const mixedFetch = (async () => {
713
+ if (mode === 'network') {
714
+ throw new Error('network');
715
+ }
716
+ return {
717
+ ok: true,
718
+ status: 200,
719
+ json: async () => {
720
+ throw new Error('not json');
721
+ },
722
+ } as unknown as Response;
723
+ }) as unknown as typeof globalThis.fetch;
724
+ const { client } = createHarness({ fetch: mixedFetch });
725
+ let stalledCount = 0;
726
+ client.on('stalled', () => {
727
+ stalledCount += 1;
728
+ });
729
+
730
+ client.connect();
731
+ await vi.advanceTimersByTimeAsync(0);
732
+ await vi.advanceTimersByTimeAsync(1000);
733
+ await vi.advanceTimersByTimeAsync(2000);
734
+ await vi.advanceTimersByTimeAsync(4000);
735
+
736
+ mode = 'broken-body';
737
+ await vi.advanceTimersByTimeAsync(8000);
738
+ mode = 'network';
739
+ await vi.advanceTimersByTimeAsync(16_000);
740
+ expect(stalledCount).toBe(0);
741
+
742
+ await vi.advanceTimersByTimeAsync(30_000);
743
+ await vi.advanceTimersByTimeAsync(30_000);
744
+ await vi.advanceTimersByTimeAsync(30_000);
745
+ await vi.advanceTimersByTimeAsync(30_000);
746
+ expect(stalledCount).toBe(1);
747
+ });
748
+
749
+ it('연결은 됐지만 hello 전에 끊기는 경우도 셉니다', async () => {
750
+ const { client } = createHarness();
751
+ let stalledCount = 0;
752
+ client.on('stalled', () => {
753
+ stalledCount += 1;
754
+ });
755
+
756
+ client.connect();
757
+ await vi.advanceTimersByTimeAsync(0);
758
+ await vi.advanceTimersByTimeAsync(10_000);
759
+ await vi.advanceTimersByTimeAsync(1000 + 10_000);
760
+ await vi.advanceTimersByTimeAsync(2000 + 10_000);
761
+ await vi.advanceTimersByTimeAsync(4000 + 10_000);
762
+ expect(stalledCount).toBe(0);
763
+
764
+ await vi.advanceTimersByTimeAsync(8000 + 10_000);
765
+ expect(stalledCount).toBe(1);
766
+ });
767
+
768
+ it('stalled를 알릴 때는 전이가 끝나 있어, 핸들러가 그 자리에서 disconnect() → connect()로 재시작할 수 있습니다', async () => {
769
+ const { client } = createHarness({ fetch: rejectingFetch });
770
+ const seen: string[] = [];
771
+ client.on('stalled', () => {
772
+ seen.push(client.status);
773
+ client.disconnect();
774
+ client.connect();
775
+ });
776
+
777
+ client.connect();
778
+ await vi.advanceTimersByTimeAsync(0);
779
+ await vi.advanceTimersByTimeAsync(1000);
780
+ await vi.advanceTimersByTimeAsync(2000);
781
+ await vi.advanceTimersByTimeAsync(4000);
782
+ await vi.advanceTimersByTimeAsync(8000);
783
+ expect(seen).toEqual(['reconnecting']);
784
+
785
+ await vi.advanceTimersByTimeAsync(0);
786
+ await vi.advanceTimersByTimeAsync(1000);
787
+ await vi.advanceTimersByTimeAsync(2000);
788
+ await vi.advanceTimersByTimeAsync(4000);
789
+ expect(seen).toHaveLength(1);
790
+ await vi.advanceTimersByTimeAsync(8000);
791
+ expect(seen).toEqual(['reconnecting', 'reconnecting']);
792
+ });
793
+
794
+ it('disconnect 후 다시 connect하면 판정이 새 세션으로 시작합니다', async () => {
795
+ const { client } = createHarness({ fetch: rejectingFetch });
796
+ let stalledCount = 0;
797
+ client.on('stalled', () => {
798
+ stalledCount += 1;
799
+ });
800
+
801
+ client.connect();
802
+ await vi.advanceTimersByTimeAsync(0);
803
+ await vi.advanceTimersByTimeAsync(1000);
804
+ await vi.advanceTimersByTimeAsync(2000);
805
+
806
+ client.disconnect();
807
+ client.connect();
808
+ await vi.advanceTimersByTimeAsync(0);
809
+ // 이월됐다면 직전 시도가 통산 5회째라 이미 발화했을 것입니다
810
+ await vi.advanceTimersByTimeAsync(1000);
811
+ expect(stalledCount).toBe(0);
812
+
813
+ await vi.advanceTimersByTimeAsync(2000);
814
+ await vi.advanceTimersByTimeAsync(4000);
815
+ await vi.advanceTimersByTimeAsync(8000);
816
+ expect(stalledCount).toBe(1);
817
+ });
818
+
819
+ it('open에 도달하면 판정이 리셋되고, 수립 후 끊김 자체는 세지 않습니다', async () => {
820
+ let failNetwork = true;
821
+ let ticketCounter = 0;
822
+ const toggledFetch = (async () => {
823
+ if (failNetwork) {
824
+ throw new Error('network');
825
+ }
826
+ ticketCounter += 1;
827
+ return {
828
+ ok: true,
829
+ status: 200,
830
+ json: async () => ({ data: { ticket: `ticket-${ticketCounter}` } }),
831
+ } as unknown as Response;
832
+ }) as unknown as typeof globalThis.fetch;
833
+ const { client } = createHarness({ fetch: toggledFetch });
834
+ let stalledCount = 0;
835
+ client.on('stalled', () => {
836
+ stalledCount += 1;
837
+ });
838
+
839
+ client.connect();
840
+ await vi.advanceTimersByTimeAsync(0);
841
+ await vi.advanceTimersByTimeAsync(1000);
842
+ await vi.advanceTimersByTimeAsync(2000);
843
+ await vi.advanceTimersByTimeAsync(4000);
844
+ expect(stalledCount).toBe(0);
845
+
846
+ failNetwork = false;
847
+ await vi.advanceTimersByTimeAsync(8000);
848
+ lastSource().dispatch('hello');
849
+ expect(client.status).toBe('open');
850
+
851
+ lastSource().error();
852
+ failNetwork = true;
853
+ await vi.advanceTimersByTimeAsync(1000);
854
+ await vi.advanceTimersByTimeAsync(2000);
855
+ await vi.advanceTimersByTimeAsync(4000);
856
+ // open 전 4회는 리셋됐고 수립 후 끊김도 세지 않았습니다 — 어느 쪽이든 세졌다면 이미 발화했을 것입니다
857
+ await vi.advanceTimersByTimeAsync(8000);
858
+ expect(stalledCount).toBe(0);
859
+
860
+ await vi.advanceTimersByTimeAsync(16_000);
861
+ expect(stalledCount).toBe(1);
862
+ });
863
+ });