@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,164 @@
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
+ lastSource,
9
+ openConnection,
10
+ setupFakeTimers,
11
+ } from '../testing';
12
+
13
+ /**
14
+ * 유저 여정 시나리오 — 기계 계약(전이·호출 단위)은 SseClient.connection.test.ts가 고정하고,
15
+ * 이 파일은 사용자 경험 하나를 처음부터 끝까지 걷습니다. 여기가 깨지면 "어떤 경험이 깨졌는지"가 이름에 드러납니다.
16
+ */
17
+
18
+ setupFakeTimers();
19
+
20
+ describe('유저 시나리오', () => {
21
+ it('화면에 처음 들어오면 실시간 수신이 시작된다', async () => {
22
+ const { client } = createHarness();
23
+ const received: { id: string }[] = [];
24
+
25
+ client.on('demo.created', (event) => received.push(event));
26
+ client.connect();
27
+ await vi.advanceTimersByTimeAsync(0);
28
+ lastSource().dispatch('hello');
29
+
30
+ lastSource().dispatch('demo.created', '{"id":"m1"}');
31
+ expect(client.status).toBe('open');
32
+ expect(received).toEqual([{ id: 'm1' }]);
33
+ });
34
+
35
+ it('이동 중 네트워크가 끊겨도 개입 없이 다시 연결되고, 놓친 구간을 메울 수 있게 resync를 알린다', async () => {
36
+ // 이 테스트가 의존하는 재시도 지연을 명시합니다 — 기본값 튜닝이 아래 대기 시간과 어긋나지 않게
37
+ const { client } = createHarness({ backoff: { initialDelayMs: 1000 } });
38
+ const received: { id: string }[] = [];
39
+ const statuses: SseStatus[] = [];
40
+ let resyncFired = false;
41
+ client.on('demo.created', (event) => received.push(event));
42
+ client.on('resync', () => {
43
+ resyncFired = true;
44
+ });
45
+ client.onStatus((status) => statuses.push(status));
46
+
47
+ await openConnection(client);
48
+ lastSource().dispatch('demo.created', '{"id":"m1"}');
49
+
50
+ lastSource().error();
51
+ expect(statuses.at(-1)).toBe('reconnecting');
52
+
53
+ await vi.advanceTimersByTimeAsync(1000);
54
+ lastSource().dispatch('hello');
55
+ expect(resyncFired).toBe(true);
56
+
57
+ lastSource().dispatch('demo.created', '{"id":"m2"}');
58
+ expect(received).toEqual([{ id: 'm1' }, { id: 'm2' }]);
59
+ });
60
+
61
+ it('서버가 배포로 연결을 정리해도(goaway) 대기 없이 즉시 재수립되어 수신이 이어진다', async () => {
62
+ const { client, ticketRequests } = createHarness();
63
+ const received: { id: string }[] = [];
64
+ let resyncFired = false;
65
+ client.on('demo.created', (event) => received.push(event));
66
+ client.on('resync', () => {
67
+ resyncFired = true;
68
+ });
69
+
70
+ await openConnection(client);
71
+ lastSource().dispatch('goaway');
72
+
73
+ // 타이머를 진행하지 않았는데 티켓이 재발급됨 — 대기 없는 즉시 재수립
74
+ await vi.advanceTimersByTimeAsync(0);
75
+ expect(ticketRequests()).toHaveLength(2);
76
+ lastSource().dispatch('hello');
77
+ expect(resyncFired).toBe(true);
78
+
79
+ lastSource().dispatch('demo.created', '{"id":"m1"}');
80
+ expect(client.status).toBe('open');
81
+ expect(received).toEqual([{ id: 'm1' }]);
82
+ });
83
+
84
+ it('세션이 만료되면 멈춰서 알리고, 재로그인 후 다시 연결할 수 있다', async () => {
85
+ const { client, ticketRequests, failTicketsWith } = createHarness();
86
+ const statuses: SseStatus[] = [];
87
+ client.onStatus((status) => statuses.push(status));
88
+
89
+ failTicketsWith(401);
90
+ client.connect();
91
+ await vi.advanceTimersByTimeAsync(0);
92
+
93
+ expect(statuses.at(-1)).toBe('fatal');
94
+ await vi.advanceTimersByTimeAsync(120_000);
95
+ expect(ticketRequests()).toHaveLength(1);
96
+
97
+ // fatal의 출구는 disconnect뿐이라, 정리한 뒤 다시 connect한다
98
+ failTicketsWith(null);
99
+ client.disconnect();
100
+ client.connect();
101
+ await vi.advanceTimersByTimeAsync(0);
102
+ lastSource().dispatch('hello');
103
+
104
+ expect(statuses.at(-1)).toBe('open');
105
+ });
106
+
107
+ it('사내 차단 등으로 연결이 계속 안 붙으면 안내(stalled)를 받고, 환경이 풀리면 개입 없이 회복된다', async () => {
108
+ let blocked = true;
109
+ let ticketCounter = 0;
110
+ const firewallFetch = (async () => {
111
+ if (blocked) {
112
+ throw new Error('network');
113
+ }
114
+ ticketCounter += 1;
115
+ return {
116
+ ok: true,
117
+ status: 200,
118
+ json: async () => ({ data: { ticket: `ticket-${ticketCounter}` } }),
119
+ } as unknown as Response;
120
+ }) as unknown as typeof globalThis.fetch;
121
+ const { client } = createHarness({ fetch: firewallFetch });
122
+ const received: { id: string }[] = [];
123
+ let stalledCount = 0;
124
+ client.on('stalled', () => {
125
+ stalledCount += 1;
126
+ });
127
+ client.on('demo.created', (event) => received.push(event));
128
+
129
+ client.connect();
130
+ await vi.advanceTimersByTimeAsync(0);
131
+ await vi.advanceTimersByTimeAsync(1000);
132
+ await vi.advanceTimersByTimeAsync(2000);
133
+ await vi.advanceTimersByTimeAsync(4000);
134
+ expect(stalledCount).toBe(0);
135
+
136
+ await vi.advanceTimersByTimeAsync(8000);
137
+ expect(stalledCount).toBe(1);
138
+ // 안내가 나가도 재시도는 멈추지 않는다 — 회복의 전제
139
+ expect(client.status).toBe('reconnecting');
140
+
141
+ blocked = false;
142
+ await vi.advanceTimersByTimeAsync(16_000);
143
+ lastSource().dispatch('hello');
144
+ expect(client.status).toBe('open');
145
+
146
+ lastSource().dispatch('demo.created', '{"id":"m1"}');
147
+ expect(received).toEqual([{ id: 'm1' }]);
148
+ expect(stalledCount).toBe(1);
149
+ });
150
+
151
+ it('수신하던 화면을 떠나면 연결을 닫는다 — 서버 슬롯은 유휴 정리에 맡긴다', async () => {
152
+ const { client } = createHarness();
153
+ const received: { id: string }[] = [];
154
+ client.on('demo.created', (event) => received.push(event));
155
+
156
+ await openConnection(client);
157
+ lastSource().dispatch('demo.created', '{"id":"m1"}');
158
+ expect(received).toEqual([{ id: 'm1' }]);
159
+
160
+ client.disconnect();
161
+ expect(client.status).toBe('closed');
162
+ expect(lastSource().closed).toBe(true);
163
+ });
164
+ });
@@ -0,0 +1,63 @@
1
+ // @vitest-environment node
2
+ import { describe, expect, it } from 'vitest';
3
+
4
+ import { SseClient } from './SseClient';
5
+ import type { SseClientLike } from './types';
6
+ import { wire } from './wire';
7
+
8
+ // interface 픽스처인 이유: M 제약이 Record로 조여지면 이 파일이 컴파일 에러로 잡습니다
9
+ interface TestEventMap {
10
+ 'demo.created': { id: string };
11
+ }
12
+
13
+ const createClient = () =>
14
+ new SseClient<TestEventMap>({
15
+ baseUrl: 'https://example.test',
16
+ getAuthToken: () => '',
17
+ });
18
+
19
+ // no-throw·구독·리스너 격리 계약은 SseClientLike.contract.test.ts가 두 구현을 함께 검증합니다
20
+ describe('SSR 안전성', () => {
21
+ it('EventSource가 없으면 connect()는 상태 전이 없이 조용한 no-op입니다', () => {
22
+ const client = createClient();
23
+ client.connect();
24
+ expect(client.status).toBe('idle');
25
+ });
26
+ });
27
+
28
+ describe('wire()', () => {
29
+ it('모든 핸들러의 일괄 해제 함수를 반환합니다', () => {
30
+ const client = createClient();
31
+ const unwire = wire(client, {
32
+ resync: () => {},
33
+ 'demo.created': () => {},
34
+ });
35
+ expect(() => unwire()).not.toThrow();
36
+ });
37
+
38
+ it('undefined·null 같은 호출 불가능한 핸들러는 등록하지 않고 건너뜁니다', () => {
39
+ const registered: string[] = [];
40
+ const observingClient = {
41
+ on: (type: string) => {
42
+ registered.push(type);
43
+ return () => {};
44
+ },
45
+ } as unknown as SseClientLike<TestEventMap>;
46
+ const jsConsumerHandlers = {
47
+ resync: undefined,
48
+ 'null.handler': null,
49
+ 'demo.created': () => {},
50
+ } as unknown as Parameters<typeof wire>[1];
51
+
52
+ wire(observingClient, jsConsumerHandlers);
53
+
54
+ expect(registered).toEqual(['demo.created']);
55
+ });
56
+
57
+ it('맵에 없는 키를 컴파일에서 거부합니다', () => {
58
+ const client = createClient();
59
+ // @ts-expect-error 맵(M)에 없는 이벤트 키는 WireHandlers가 거부합니다
60
+ const unwire = wire(client, { 'not.in.map': () => {} });
61
+ expect(() => unwire()).not.toThrow();
62
+ });
63
+ });
@@ -0,0 +1,310 @@
1
+ import type { Backoff } from './backoff';
2
+ import { createBackoff } from './backoff';
3
+ import { BaseSseClient } from './BaseSseClient';
4
+ import { SDK_EVENTS, SERVER_CONNECTION_EVENTS } from './eventNames';
5
+ import { waitForRetry } from './retryWait';
6
+ import type { StatusMachine } from './statusMachine';
7
+ import { createStatusMachine } from './statusMachine';
8
+ import type { AttachedEvents, StreamOutcome } from './stream';
9
+ import { attachDomainListener, streamUntilClosed } from './stream';
10
+ import type { TicketResult } from './ticket';
11
+ import { issueTicketWithTimeout } from './ticket';
12
+ import type {
13
+ FatalCause,
14
+ FatalPayload,
15
+ SseClientOptions,
16
+ SseEventMap,
17
+ SseStatus,
18
+ } from './types';
19
+
20
+ // 상태 코드 → 사유 매핑이 단일 원천입니다 — 멈출 코드 판정과 사유가 같은 표에서 나와 어긋날 수 없습니다
21
+ const TICKET_FATAL_CAUSES = {
22
+ 401: 'auth',
23
+ 403: 'auth',
24
+ 429: 'rate_limit',
25
+ } as const satisfies Record<number, FatalCause>;
26
+
27
+ type FatalTicketStatus = keyof typeof TICKET_FATAL_CAUSES;
28
+
29
+ const isFatalTicketStatus = (status: number): status is FatalTicketStatus =>
30
+ status in TICKET_FATAL_CAUSES;
31
+
32
+ const HELLO_TIMEOUT_MS = 10_000;
33
+
34
+ // 즉시 실패(CORS·오프라인)의 기본 백오프(full jitter) 기준 최대 15초, 보통 7~8초 안에 알립니다 — 순간 끊김에는 침묵합니다
35
+ const STALLED_AFTER_ATTEMPTS = 5;
36
+
37
+ /**
38
+ * @description 내부 확장 지점 — 워커가 티켓 발급을 탭에 위임할 때 씁니다.
39
+ * 공개 옵션(SseClientOptions)에는 노출하지 않습니다: 소비처가 티켓 발급 절차를 바꿀 이유가 없습니다.
40
+ */
41
+ export interface SseClientInternals {
42
+ issueTicket?: () => Promise<TicketResult>;
43
+ /** @description 워커가 인스턴스를 갈아끼워도 재수립 판정이 이어지게 합니다 — 첫 hello가 resync를 쏠지 가릅니다 */
44
+ hasOpenedBefore?: boolean;
45
+ }
46
+
47
+ /** @description SSR 환경에서도 안전하게 생성할 수 있습니다 — 연결은 connect()를 호출할 때 시작됩니다 */
48
+ export class SseClient<
49
+ M extends object = SseEventMap,
50
+ > extends BaseSseClient<M> {
51
+ readonly #backoff: Backoff;
52
+ readonly #machine: StatusMachine;
53
+
54
+ #currentStream: {
55
+ source: EventSource;
56
+ attachedEvents: AttachedEvents;
57
+ } | null = null;
58
+
59
+ #abort: AbortController | null = null;
60
+ #hasOpened: boolean;
61
+ #networkFailures = 0;
62
+
63
+ constructor(
64
+ protected readonly options: SseClientOptions,
65
+ protected readonly internals: SseClientInternals = {}
66
+ ) {
67
+ super(options.logger);
68
+ this.#hasOpened = internals.hasOpenedBefore ?? false;
69
+ this.#backoff = createBackoff(options.backoff);
70
+ this.#machine = createStatusMachine((status) =>
71
+ this.emitter.emitStatus(status)
72
+ );
73
+ }
74
+
75
+ get status(): SseStatus {
76
+ return this.#machine.status;
77
+ }
78
+
79
+ connect(): void {
80
+ if ((this.options.eventSource ?? globalThis.EventSource) === undefined) {
81
+ // SSR 대응 - no-op
82
+ return;
83
+ }
84
+
85
+ const controller = new AbortController();
86
+ const previous = this.#abort;
87
+ // 전이 통지 중 리스너가 disconnect()해도 이 controller가 abort되도록 전이보다 먼저 교체합니다
88
+ this.#abort = controller;
89
+
90
+ if (!this.#machine.transition('connect')) {
91
+ this.#abort = previous;
92
+ if (this.#machine.status === 'fatal') {
93
+ this.options.logger?.warn(
94
+ 'sse: fatal 상태에서는 connect()가 무시됩니다 — disconnect()로 정리한 뒤 다시 connect() 하세요'
95
+ );
96
+ }
97
+ return;
98
+ }
99
+ if (controller.signal.aborted) {
100
+ return;
101
+ }
102
+
103
+ // 루프가 예외로 죽으면 재시도가 영영 없습니다 — fatal로 떨어뜨려 회복 경로(정리 후 connect)에 맡깁니다
104
+ void this.#runConnection(controller.signal).catch((error) => {
105
+ this.options.logger?.error('sse: 연결 루프가 예외로 중단됐습니다', error);
106
+ // 전이가 거부된 상태(reconnecting 등)에서 발화하면 "전이 후 발화" 계약이 깨집니다
107
+ if (this.#machine.transition('fatalFailure')) {
108
+ this.emitter.emit(SDK_EVENTS.fatal, {
109
+ cause: 'unknown',
110
+ } satisfies FatalPayload);
111
+ }
112
+ });
113
+ }
114
+
115
+ disconnect(): void {
116
+ // abort가 연결 루프를 중단합니다 — 진행 중이던 티켓 발급·대기·스트림 전부
117
+ this.#abort?.abort();
118
+ this.#abort = null;
119
+ this.#endSession();
120
+ this.#machine.transition('disconnect');
121
+ }
122
+
123
+ // 한 연결 세션을 접습니다 — 다음 connect()가 첫 연결처럼 시작하도록 재수립 판단과 백오프도 함께 되돌립니다
124
+ #endSession(): void {
125
+ this.#closeStream();
126
+ this.#hasOpened = false;
127
+ this.#backoff.reset();
128
+ this.#networkFailures = 0;
129
+ }
130
+
131
+ /**
132
+ * @description hello에 닿지 못한 네트워크성(응답 status 부재) 연속 실패를 셉니다 — 임계값 도달 순간에만
133
+ * stalled를 발화해 한 구간에 한 번만 알립니다. 반드시 상태 전이 뒤에 부르세요.
134
+ * 연속은 open 도달과 서버 응답(5xx)이 끊고, 스트림 단계 실패는 status가 없어 전부 여기 해당합니다.
135
+ */
136
+ #countNetworkFailure(): void {
137
+ this.#networkFailures += 1;
138
+ if (this.#networkFailures === STALLED_AFTER_ATTEMPTS) {
139
+ this.emitter.emit(SDK_EVENTS.stalled, undefined);
140
+ }
141
+ }
142
+
143
+ // 열린 스트림이 없으면 조용히 넘어갑니다 — 다음 #openStream이 구독 장부를 보고 한 번에 붙입니다
144
+ protected registerSubscription(type: string): void {
145
+ this.#attachSourceListener(type);
146
+ }
147
+
148
+ // 티켓 발급 → 스트림 유지 → 끊김 → 대기 → 재시도. disconnect(abort)만이 루프를 종료합니다
149
+ async #runConnection(signal: AbortSignal): Promise<void> {
150
+ while (!signal.aborted) {
151
+ const next = await this.#attemptConnection(signal);
152
+ if (signal.aborted || next === 'stop') {
153
+ return;
154
+ }
155
+
156
+ if (next === 'retryWithDelay') {
157
+ await waitForRetry(this.#backoff.nextDelayMs(), signal);
158
+ if (signal.aborted) {
159
+ return;
160
+ }
161
+ }
162
+ this.#machine.transition('readyToRetry');
163
+ }
164
+ }
165
+
166
+ // 스트림이 끊길 때까지 대기하고, 루프의 다음 동작('stop'·'retryWithDelay'·'retryNow')을 반환합니다
167
+ async #attemptConnection(
168
+ signal: AbortSignal
169
+ ): Promise<'stop' | 'retryWithDelay' | 'retryNow'> {
170
+ const result = await (this.internals.issueTicket?.() ??
171
+ issueTicketWithTimeout(this.options));
172
+
173
+ if (signal.aborted) {
174
+ return 'stop';
175
+ }
176
+
177
+ if (!result.ok) {
178
+ const { status, cause } = result.error;
179
+
180
+ // 로그는 원인 예외를 쥔 ticket.ts가 남깁니다 — 여기서 또 찍으면 직결 경로에서 같은 말이 두 줄 됩니다
181
+ if (cause === 'auth_callback') {
182
+ this.#machine.transition('fatalFailure');
183
+ this.emitter.emit(SDK_EVENTS.fatal, {
184
+ cause: 'auth_callback',
185
+ } satisfies FatalPayload);
186
+ return 'stop';
187
+ }
188
+
189
+ if (status !== undefined && isFatalTicketStatus(status)) {
190
+ this.options.logger?.warn(
191
+ `sse: 티켓 발급이 ${status}로 거부되어 멈춥니다 — disconnect()로 정리한 뒤 다시 connect() 하세요`
192
+ );
193
+ this.#machine.transition('fatalFailure');
194
+ // 전이를 마친 뒤 알립니다 — 핸들러가 그 자리에서 disconnect()·connect() 회복을 시작할 수 있게
195
+ this.emitter.emit(SDK_EVENTS.fatal, {
196
+ cause: TICKET_FATAL_CAUSES[status],
197
+ status,
198
+ } satisfies FatalPayload);
199
+ return 'stop';
200
+ }
201
+
202
+ this.#machine.transition('retryableFailure');
203
+ // 서버가 응답한 거부(5xx 등)는 네트워크성이 아니라 세지 않습니다 — 서버 장애에 네트워크 안내가 나가는 오안내를 막습니다
204
+ if (status === undefined) {
205
+ this.#countNetworkFailure();
206
+ } else {
207
+ this.#networkFailures = 0;
208
+ }
209
+ return 'retryWithDelay';
210
+ }
211
+
212
+ try {
213
+ const outcome = await this.#openStream(result.ticket, signal);
214
+ if (outcome === 'goaway-close') {
215
+ return 'stop';
216
+ }
217
+ return outcome === 'goaway' ? 'retryNow' : 'retryWithDelay';
218
+ } catch (error) {
219
+ this.options.logger?.error(
220
+ 'sse: EventSource 생성 실패 — baseUrl을 확인하세요',
221
+ error
222
+ );
223
+
224
+ this.#machine.transition('fatalFailure');
225
+ // 생성의 동기 throw는 설정 실수입니다 — 네트워크성 실패는 onerror로 와서 reconnecting으로 갑니다
226
+ this.emitter.emit(SDK_EVENTS.fatal, {
227
+ cause: 'config',
228
+ } satisfies FatalPayload);
229
+ return 'stop';
230
+ }
231
+ }
232
+
233
+ // EventSource를 생성하고 스트림이 끊길 때까지 대기합니다 — 이벤트 연결은 stream.ts, 여기서는 상태 전이만 담당합니다
234
+ #openStream(ticket: string, signal: AbortSignal): Promise<StreamOutcome> {
235
+ const EventSourceImpl = this.options.eventSource ?? globalThis.EventSource;
236
+ const source = new EventSourceImpl(
237
+ `${this.options.baseUrl}/v1/events?ticket=${encodeURIComponent(ticket)}`
238
+ );
239
+ this.#currentStream = { source, attachedEvents: new Set() };
240
+ this.emitter
241
+ .handlerTypes()
242
+ .forEach((type) => this.#attachSourceListener(type));
243
+
244
+ // hello 전 끊김(타임아웃 포함)만 미연결로 셉니다 — 수립 후 끊김은 다음 시도부터 다시 셉니다
245
+ let opened = false;
246
+
247
+ return streamUntilClosed(source, signal, {
248
+ helloTimeoutMs: HELLO_TIMEOUT_MS,
249
+ onHello: () => {
250
+ if (!this.#machine.transition('hello')) {
251
+ return false;
252
+ }
253
+
254
+ if (signal.aborted) {
255
+ return false;
256
+ }
257
+ opened = true;
258
+ this.#backoff.reset();
259
+ this.#networkFailures = 0;
260
+
261
+ // 갱신 전에 읽어야 이번 hello를 뺀 값입니다 — 두 번째부터가 재수립입니다
262
+ const isReestablished = this.#hasOpened;
263
+ this.#hasOpened = true;
264
+
265
+ if (isReestablished) {
266
+ this.emitter.emit(SDK_EVENTS.resync, undefined);
267
+ }
268
+ return true;
269
+ },
270
+
271
+ onClosed: (closure) => {
272
+ if (closure.outcome === 'error') {
273
+ this.#closeStream();
274
+ this.#machine.transition('retryableFailure');
275
+ if (!opened) {
276
+ this.#countNetworkFailure();
277
+ }
278
+ return;
279
+ }
280
+
281
+ // 서버가 재연결하지 말라고 했으면 reconnecting을 거치지 않습니다 — 소비처에 헛된 재연결 중을 보이지 않게
282
+ if (closure.outcome === 'goaway-close') {
283
+ this.#endSession();
284
+ this.#machine.transition('disconnect');
285
+ } else {
286
+ this.#closeStream();
287
+ this.#machine.transition('retryableFailure');
288
+ }
289
+
290
+ // 전이를 마친 뒤 알립니다 — 핸들러가 그 자리에서 connect()·disconnect()를 부를 수 있게
291
+ this.emitter.emit(SERVER_CONNECTION_EVENTS.goaway, closure.goaway);
292
+ },
293
+ });
294
+ }
295
+
296
+ #attachSourceListener(type: string): void {
297
+ if (this.#currentStream === null) {
298
+ return;
299
+ }
300
+ const { source, attachedEvents } = this.#currentStream;
301
+ attachDomainListener(source, attachedEvents, type, (t, payload) =>
302
+ this.emitter.emit(t, payload)
303
+ );
304
+ }
305
+
306
+ #closeStream(): void {
307
+ this.#currentStream?.source.close();
308
+ this.#currentStream = null;
309
+ }
310
+ }
@@ -0,0 +1,43 @@
1
+ // @vitest-environment node
2
+ import { describe, expect, it } from 'vitest';
3
+
4
+ import { createBackoff } from './backoff';
5
+
6
+ // random을 1로 고정하면 jitter 상한 = 계산된 지연 그대로
7
+ const fixedRandom = () => 1;
8
+
9
+ describe('createBackoff', () => {
10
+ it('시도마다 multiplier 배로 늘고 maxDelayMs에서 멈춥니다', () => {
11
+ const backoff = createBackoff(
12
+ { initialDelayMs: 1000, maxDelayMs: 4000, multiplier: 2 },
13
+ fixedRandom
14
+ );
15
+
16
+ expect(backoff.nextDelayMs()).toBe(1000);
17
+ expect(backoff.nextDelayMs()).toBe(2000);
18
+ expect(backoff.nextDelayMs()).toBe(4000);
19
+ expect(backoff.nextDelayMs()).toBe(4000);
20
+ });
21
+
22
+ it('full jitter — 지연은 [0, 계산값] 균등이라 random 비율이 그대로 곱해집니다', () => {
23
+ const backoff = createBackoff(
24
+ { initialDelayMs: 1000, maxDelayMs: 30_000, multiplier: 2 },
25
+ () => 0.5
26
+ );
27
+
28
+ expect(backoff.nextDelayMs()).toBe(500);
29
+ expect(backoff.nextDelayMs()).toBe(1000);
30
+ });
31
+
32
+ it('reset() 후에는 첫 지연부터 다시 시작합니다', () => {
33
+ const backoff = createBackoff(
34
+ { initialDelayMs: 1000, maxDelayMs: 30_000, multiplier: 2 },
35
+ fixedRandom
36
+ );
37
+
38
+ backoff.nextDelayMs();
39
+ backoff.nextDelayMs();
40
+ backoff.reset();
41
+ expect(backoff.nextDelayMs()).toBe(1000);
42
+ });
43
+ });
@@ -0,0 +1,39 @@
1
+ export interface BackoffOptions {
2
+ initialDelayMs: number;
3
+ maxDelayMs: number;
4
+ multiplier: number;
5
+ }
6
+
7
+ const DEFAULT_BACKOFF: BackoffOptions = {
8
+ initialDelayMs: 1000,
9
+ maxDelayMs: 30_000,
10
+ multiplier: 2,
11
+ };
12
+
13
+ /** @description 지수 백오프 + full jitter — 동시 재연결 폭주(thundering herd)를 분산시킵니다 */
14
+ export const createBackoff = (
15
+ options?: Partial<BackoffOptions>,
16
+ random: () => number = Math.random
17
+ ) => {
18
+ const initialDelayMs =
19
+ options?.initialDelayMs ?? DEFAULT_BACKOFF.initialDelayMs;
20
+ const maxDelayMs = options?.maxDelayMs ?? DEFAULT_BACKOFF.maxDelayMs;
21
+ const multiplier = options?.multiplier ?? DEFAULT_BACKOFF.multiplier;
22
+ let attempt = 0;
23
+
24
+ return {
25
+ nextDelayMs: (): number => {
26
+ const capped = Math.min(
27
+ maxDelayMs,
28
+ initialDelayMs * multiplier ** attempt
29
+ );
30
+ attempt += 1;
31
+ return random() * capped;
32
+ },
33
+ reset: (): void => {
34
+ attempt = 0;
35
+ },
36
+ };
37
+ };
38
+
39
+ export type Backoff = ReturnType<typeof createBackoff>;
@@ -0,0 +1,56 @@
1
+ import type {
2
+ SseEventHandler,
3
+ SseLogger,
4
+ SseStatus,
5
+ Unsubscribe,
6
+ } from './types';
7
+
8
+ /**
9
+ * @description SseClient·WorkerSseClient가 공유하는 구독·발행 코어.
10
+ * 리스너 예외를 격리합니다 — 하나가 던져도 나머지 리스너와 호출자의 정리 로직이 중단되지 않게.
11
+ */
12
+ export const createEmitter = (logger?: SseLogger) => {
13
+ const handlers = new Map<string, Set<SseEventHandler<unknown>>>();
14
+ const statusListeners = new Set<(status: SseStatus) => void>();
15
+
16
+ const safeCall = <T>(listener: (value: T) => void, value: T): void => {
17
+ try {
18
+ listener(value);
19
+ } catch (error) {
20
+ logger?.warn('sse: 리스너 예외를 격리했습니다', error);
21
+ }
22
+ };
23
+
24
+ return {
25
+ on: (type: string, handler: SseEventHandler<unknown>): Unsubscribe => {
26
+ const set = handlers.get(type) ?? new Set();
27
+
28
+ set.add(handler);
29
+ handlers.set(type, set);
30
+
31
+ return () => {
32
+ set.delete(handler);
33
+ };
34
+ },
35
+
36
+ onStatus: (listener: (status: SseStatus) => void): Unsubscribe => {
37
+ statusListeners.add(listener);
38
+
39
+ return () => {
40
+ statusListeners.delete(listener);
41
+ };
42
+ },
43
+
44
+ emit: (type: string, payload: unknown): void => {
45
+ handlers.get(type)?.forEach((handler) => safeCall(handler, payload));
46
+ },
47
+
48
+ emitStatus: (status: SseStatus): void => {
49
+ statusListeners.forEach((listener) => safeCall(listener, status));
50
+ },
51
+
52
+ handlerTypes: (): string[] => [...handlers.keys()],
53
+ };
54
+ };
55
+
56
+ export type Emitter = ReturnType<typeof createEmitter>;