@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,377 @@
1
+ // @vitest-environment node
2
+ import { describe, expect, it, vi } from 'vitest';
3
+
4
+ import type { WorkerToTabMessage } from './protocol';
5
+ import { WorkerSseClient } from './WorkerSseClient';
6
+
7
+ import type { SseStatus } from '../core/types';
8
+ import { FakeSharedWorker, type TestEventMap } from '../testing';
9
+
10
+ const createProxyHarness = () => {
11
+ const workers: FakeSharedWorker[] = [];
12
+ const client = new WorkerSseClient<TestEventMap>({
13
+ baseUrl: 'https://sse.example.com',
14
+ getAuthToken: () => 'token-1',
15
+ sharedWorker: () => {
16
+ const worker = new FakeSharedWorker();
17
+ workers.push(worker);
18
+ return worker as unknown as SharedWorker;
19
+ },
20
+ });
21
+ return { client, workers, lastPort: () => workers.at(-1)!.port };
22
+ };
23
+
24
+ describe('탭 프록시 — 워커 연결', () => {
25
+ it('connect하면 워커를 기동하고 포트를 시작합니다', () => {
26
+ const { client, workers, lastPort } = createProxyHarness();
27
+
28
+ client.connect();
29
+
30
+ expect(workers).toHaveLength(1);
31
+ expect(lastPort().started).toBe(true);
32
+ });
33
+
34
+ it('connect하면 init에 baseUrl을 담아 보냅니다 — 워커가 이 값으로 연결을 세웁니다', () => {
35
+ const { client, lastPort } = createProxyHarness();
36
+
37
+ client.connect();
38
+
39
+ expect(lastPort().posted[0]).toEqual({
40
+ kind: 'init',
41
+ baseUrl: 'https://sse.example.com',
42
+ });
43
+ });
44
+
45
+ it('connect를 여러 번 불러도 워커 기동은 한 번만 일어납니다', () => {
46
+ const { client, workers } = createProxyHarness();
47
+
48
+ client.connect();
49
+ client.connect();
50
+
51
+ expect(workers).toHaveLength(1);
52
+ });
53
+
54
+ it('open 상태에서 connect해도 init을 다시 보냅니다 — 수거됐는지는 워커만 압니다', () => {
55
+ const { client, workers, lastPort } = createProxyHarness();
56
+
57
+ client.connect();
58
+ lastPort().deliver({ kind: 'status', status: 'open' });
59
+ client.connect();
60
+
61
+ expect(workers).toHaveLength(1);
62
+ expect(
63
+ lastPort().posted.filter((message) => message.kind === 'init')
64
+ ).toHaveLength(2);
65
+ });
66
+
67
+ it('sharedWorker 팩토리가 던지면(CSP 차단·잘못된 URL 등) fatal이 되고 logger.error로 알립니다', () => {
68
+ const errors: string[] = [];
69
+ const client = new WorkerSseClient<TestEventMap>({
70
+ baseUrl: 'https://sse.example.com',
71
+ getAuthToken: () => 'token-1',
72
+ logger: { warn: () => {}, error: (message) => errors.push(message) },
73
+ sharedWorker: () => {
74
+ throw new Error('blocked');
75
+ },
76
+ });
77
+
78
+ expect(() => client.connect()).not.toThrow();
79
+ expect(client.status).toBe('fatal');
80
+ expect(errors).toHaveLength(1);
81
+ });
82
+
83
+ it('sharedWorker 팩토리가 던지면 사유를 blocked로 알립니다 — 환경 차단(CSP 등) 안내를 가를 수 있게', () => {
84
+ const client = new WorkerSseClient<TestEventMap>({
85
+ baseUrl: 'https://sse.example.com',
86
+ getAuthToken: () => 'token-1',
87
+ logger: { warn: () => {}, error: () => {} },
88
+ sharedWorker: () => {
89
+ throw new Error('blocked');
90
+ },
91
+ });
92
+ const causes: unknown[] = [];
93
+ client.on('fatal', (payload) => causes.push(payload));
94
+
95
+ client.connect();
96
+
97
+ expect(causes).toEqual([{ cause: 'blocked' }]);
98
+ });
99
+
100
+ it('fatal 상태에서 connect()를 다시 불러도 워커를 재기동하지 않고, disconnect()가 먼저 필요함을 logger.warn으로 알립니다', () => {
101
+ const warnings: string[] = [];
102
+ const workers: FakeSharedWorker[] = [];
103
+ let failCreation = true;
104
+ const client = new WorkerSseClient<TestEventMap>({
105
+ baseUrl: 'https://sse.example.com',
106
+ getAuthToken: () => 'token-1',
107
+ logger: { warn: (message) => warnings.push(message), error: () => {} },
108
+ sharedWorker: () => {
109
+ if (failCreation) {
110
+ throw new Error('blocked');
111
+ }
112
+ const worker = new FakeSharedWorker();
113
+ workers.push(worker);
114
+ return worker as unknown as SharedWorker;
115
+ },
116
+ });
117
+
118
+ client.connect();
119
+ expect(client.status).toBe('fatal');
120
+
121
+ failCreation = false;
122
+ client.connect();
123
+ expect(client.status).toBe('fatal');
124
+ expect(workers).toHaveLength(0);
125
+ expect(warnings).toHaveLength(1);
126
+
127
+ client.disconnect();
128
+ client.connect();
129
+ expect(workers).toHaveLength(1);
130
+ });
131
+
132
+ it('sharedWorker 팩토리 없이 생성된 경우 connect는 아무 일도 하지 않습니다', () => {
133
+ const client = new WorkerSseClient<TestEventMap>({
134
+ baseUrl: 'https://sse.example.com',
135
+ getAuthToken: () => 'token-1',
136
+ });
137
+
138
+ expect(() => client.connect()).not.toThrow();
139
+ expect(client.status).toBe('idle');
140
+ });
141
+ });
142
+
143
+ describe('탭 프록시 — 구독 전달', () => {
144
+ it('연결 전에 등록한 구독은 connect 시, 연결 후 등록한 구독은 그 자리에서 워커에 알립니다', () => {
145
+ const { client, lastPort } = createProxyHarness();
146
+ client.on('demo.created', () => {});
147
+
148
+ client.connect();
149
+ expect(lastPort().posted).toContainEqual({
150
+ kind: 'subscribe',
151
+ event: 'demo.created',
152
+ });
153
+
154
+ client.on('demo.updated', () => {});
155
+ expect(lastPort().posted).toContainEqual({
156
+ kind: 'subscribe',
157
+ event: 'demo.updated',
158
+ });
159
+ });
160
+
161
+ it('같은 이벤트에 핸들러를 여러 개 등록해도 subscribe는 한 번만 보냅니다', () => {
162
+ const { client, lastPort } = createProxyHarness();
163
+
164
+ client.connect();
165
+ client.on('demo.created', () => {});
166
+ client.on('demo.created', () => {});
167
+
168
+ const subscribes = lastPort().posted.filter((m) => m.kind === 'subscribe');
169
+ expect(subscribes).toHaveLength(1);
170
+ });
171
+
172
+ it("SDK 예약 이벤트('hello')와 'resync' 구독은 워커에 subscribe를 보내지 않습니다", () => {
173
+ const { client, lastPort } = createProxyHarness();
174
+
175
+ client.connect();
176
+ client.on('hello', () => {});
177
+ client.on('resync', () => {});
178
+
179
+ expect(
180
+ lastPort().posted.filter((m) => m.kind === 'subscribe')
181
+ ).toHaveLength(0);
182
+ });
183
+ });
184
+
185
+ describe('탭 프록시 — 워커 통지 반영', () => {
186
+ it('워커의 status 통지를 구독자에게 그대로 알립니다', () => {
187
+ const { client, lastPort } = createProxyHarness();
188
+ const statuses: SseStatus[] = [];
189
+ client.onStatus((status) => statuses.push(status));
190
+
191
+ client.connect();
192
+ lastPort().deliver({ kind: 'status', status: 'connecting' });
193
+ lastPort().deliver({ kind: 'status', status: 'open' });
194
+
195
+ expect(statuses).toEqual(['connecting', 'open']);
196
+ expect(client.status).toBe('open');
197
+ });
198
+
199
+ it('워커의 event 통지를 로컬 핸들러에 배달하고, 핸들러 없는 이벤트는 무시합니다', () => {
200
+ const { client, lastPort } = createProxyHarness();
201
+ const received: { id: string }[] = [];
202
+ client.on('demo.created', (event) => received.push(event));
203
+
204
+ client.connect();
205
+ lastPort().deliver({
206
+ kind: 'event',
207
+ event: 'demo.created',
208
+ payload: { id: 'a' },
209
+ });
210
+ expect(() =>
211
+ lastPort().deliver({ kind: 'event', event: 'demo.unknown', payload: {} })
212
+ ).not.toThrow();
213
+
214
+ expect(received).toEqual([{ id: 'a' }]);
215
+ });
216
+
217
+ it('버전이 다른 워커가 보낸 미지의 status 값은 무시합니다 — status 타입 보증을 지킵니다', () => {
218
+ const { client, lastPort } = createProxyHarness();
219
+
220
+ client.connect();
221
+ lastPort().deliver({
222
+ kind: 'status',
223
+ status: 'future-status',
224
+ } as unknown as WorkerToTabMessage);
225
+
226
+ expect(client.status).toBe('idle');
227
+ });
228
+
229
+ it('워커의 ping에 pong으로 응답합니다 — 이 탭이 살아 있음을 알립니다', () => {
230
+ const { client, lastPort } = createProxyHarness();
231
+
232
+ client.connect();
233
+ lastPort().deliver({ kind: 'ping' });
234
+
235
+ expect(lastPort().posted.at(-1)).toEqual({ kind: 'pong' });
236
+ });
237
+
238
+ it('워커가 티켓을 요청하면 토큰으로 발급받아 결과만 넘깁니다 — 토큰은 탭에 남습니다', async () => {
239
+ const fetchCalls: { url: string; authorization?: string }[] = [];
240
+ const client = new WorkerSseClient<TestEventMap>({
241
+ baseUrl: 'https://sse.example.com',
242
+ getAuthToken: () => 'token-1',
243
+ fetch: (async (input: unknown, init?: RequestInit) => {
244
+ const headers = (init?.headers ?? {}) as Record<string, string>;
245
+ fetchCalls.push({
246
+ url: String(input),
247
+ authorization: headers.Authorization,
248
+ });
249
+ return {
250
+ ok: true,
251
+ status: 200,
252
+ json: async () => ({ data: { ticket: 'ticket-1' } }),
253
+ } as unknown as Response;
254
+ }) as unknown as typeof globalThis.fetch,
255
+ sharedWorker: () => {
256
+ const worker = new FakeSharedWorker();
257
+ workersForTicket.push(worker);
258
+ return worker as unknown as SharedWorker;
259
+ },
260
+ });
261
+ const workersForTicket: FakeSharedWorker[] = [];
262
+
263
+ client.connect();
264
+ const port = workersForTicket.at(-1)!.port;
265
+ port.deliver({ kind: 'ticket-request' });
266
+ await vi.waitFor(() =>
267
+ expect(port.posted.some((m) => m.kind === 'ticket-response')).toBe(true)
268
+ );
269
+
270
+ expect(fetchCalls[0]?.authorization).toBe('Bearer token-1');
271
+ expect(port.posted.at(-1)).toEqual({
272
+ kind: 'ticket-response',
273
+ ok: true,
274
+ ticket: 'ticket-1',
275
+ });
276
+ });
277
+
278
+ it('티켓 발급이 실패하면 실패 결과를 그대로 넘깁니다 — 재시도 판단은 워커 몫입니다', async () => {
279
+ const workersForTicket: FakeSharedWorker[] = [];
280
+ const client = new WorkerSseClient<TestEventMap>({
281
+ baseUrl: 'https://sse.example.com',
282
+ getAuthToken: () => 'token-1',
283
+ fetch: (async () =>
284
+ ({
285
+ ok: false,
286
+ status: 401,
287
+ }) as Response) as unknown as typeof globalThis.fetch,
288
+ sharedWorker: () => {
289
+ const worker = new FakeSharedWorker();
290
+ workersForTicket.push(worker);
291
+ return worker as unknown as SharedWorker;
292
+ },
293
+ });
294
+
295
+ client.connect();
296
+ const port = workersForTicket.at(-1)!.port;
297
+ port.deliver({ kind: 'ticket-request' });
298
+ await vi.waitFor(() =>
299
+ expect(port.posted.some((m) => m.kind === 'ticket-response')).toBe(true)
300
+ );
301
+
302
+ expect(port.posted.at(-1)).toEqual({
303
+ kind: 'ticket-response',
304
+ ok: false,
305
+ error: { status: 401 },
306
+ });
307
+ });
308
+
309
+ it('모르는 kind의 통지는 무시합니다 — 신·구 코드가 섞인 배포 중에도 안전합니다', () => {
310
+ const { client, lastPort } = createProxyHarness();
311
+
312
+ client.connect();
313
+
314
+ expect(() =>
315
+ lastPort().deliver({
316
+ kind: 'future-thing',
317
+ } as unknown as WorkerToTabMessage)
318
+ ).not.toThrow();
319
+ });
320
+ });
321
+
322
+ describe('탭 프록시 — 해제', () => {
323
+ it('disconnect하면 close 메시지를 보낸 뒤 포트를 닫고 closed가 됩니다', () => {
324
+ const { client, lastPort } = createProxyHarness();
325
+
326
+ client.connect();
327
+ client.disconnect();
328
+
329
+ expect(lastPort().posted.at(-1)).toEqual({ kind: 'close' });
330
+ expect(lastPort().closed).toBe(true);
331
+ expect(client.status).toBe('closed');
332
+ });
333
+
334
+ it('재연결 직후에는 워커의 상태 통지가 도착할 때까지 직전 상태가 유지됩니다', () => {
335
+ const { client, lastPort } = createProxyHarness();
336
+ const statuses: SseStatus[] = [];
337
+
338
+ client.connect();
339
+ lastPort().deliver({ kind: 'status', status: 'open' });
340
+ client.disconnect();
341
+ client.onStatus((status) => statuses.push(status));
342
+
343
+ // 상태를 소유하지 않으므로 프록시가 임의로 connecting을 만들지 않습니다 — 직결 클라이언트와 갈리는 지점
344
+ client.connect();
345
+ expect(client.status).toBe('closed');
346
+ expect(statuses).toEqual([]);
347
+
348
+ lastPort().deliver({ kind: 'status', status: 'connecting' });
349
+ expect(statuses).toEqual(['connecting']);
350
+ });
351
+
352
+ it('disconnect한 뒤 옛 포트에서 늦게 도착한 통지는 상태를 바꾸지 않습니다', () => {
353
+ const { client, lastPort } = createProxyHarness();
354
+
355
+ client.connect();
356
+ const oldPort = lastPort();
357
+ client.disconnect();
358
+
359
+ oldPort.deliver({ kind: 'status', status: 'open' });
360
+ expect(client.status).toBe('closed');
361
+ });
362
+
363
+ it('disconnect 후 다시 connect하면 새 포트로 붙고 기존 구독을 다시 알립니다', () => {
364
+ const { client, workers, lastPort } = createProxyHarness();
365
+ client.on('demo.created', () => {});
366
+
367
+ client.connect();
368
+ client.disconnect();
369
+ client.connect();
370
+
371
+ expect(workers).toHaveLength(2);
372
+ expect(lastPort().posted).toContainEqual({
373
+ kind: 'subscribe',
374
+ event: 'demo.created',
375
+ });
376
+ });
377
+ });
@@ -0,0 +1,190 @@
1
+ import type {
2
+ Incoming,
3
+ TabToWorkerMessage,
4
+ TicketResponseMessage,
5
+ WorkerToTabMessage,
6
+ } from './protocol';
7
+
8
+ import { BaseSseClient } from '../core/BaseSseClient';
9
+ import { isSubscribableEvent, SDK_EVENTS } from '../core/eventNames';
10
+ import { issueTicketWithTimeout } from '../core/ticket';
11
+ import type {
12
+ FatalPayload,
13
+ SseClientOptions,
14
+ SseEventMap,
15
+ SseStatus,
16
+ } from '../core/types';
17
+ import { isSseStatus } from '../core/types';
18
+
19
+ /**
20
+ * @description SseClient와 같은 시그니처의 탭 측 프록시 — 연결은 SharedWorker가 소유합니다.
21
+ * 워커와 주고받는 메시지의 방향·배선 현황은 protocol.ts의 지도를 보세요.
22
+ *
23
+ * 프록시는 상태를 소유하지 않고 워커의 status 통지를 반영합니다 — 스스로 status를 정하는 건
24
+ * disconnect의 closed와 워커 생성 실패의 fatal뿐이며, (재)connect 직후에는 워커 스냅샷이
25
+ * 도착할 때까지 직전 상태가 잠시 유지됩니다.
26
+ *
27
+ * 얼어붙은 탭에 복귀 절차는 없습니다 — 밀린 메시지는 깨어날 때 그대로 받습니다.
28
+ * 전원이 침묵해 워커가 접은 연결만 connect()가 다시 엽니다 (배선은 attachAutoResume).
29
+ */
30
+ export class WorkerSseClient<
31
+ M extends object = SseEventMap,
32
+ > extends BaseSseClient<M> {
33
+ // 워커의 구독 장부(hub)는 탭 구분 없는 전역 union이라, 같은 이벤트는 탭당 한 번만 알리면 됩니다
34
+ readonly #sentSubscriptions = new Set<string>();
35
+
36
+ #status: SseStatus = 'idle';
37
+ #port: MessagePort | null = null;
38
+
39
+ constructor(
40
+ protected readonly options: Omit<SseClientOptions, 'eventSource'>
41
+ ) {
42
+ super(options.logger);
43
+ }
44
+
45
+ get status(): SseStatus {
46
+ return this.#status;
47
+ }
48
+
49
+ connect(): void {
50
+ // 팩토리 없는 생성은 방어적 no-op — 정상 경로(createSseClient)는 항상 팩토리와 함께 이 클래스를 고릅니다
51
+ const factory = this.options.sharedWorker;
52
+ if (factory === undefined) {
53
+ return;
54
+ }
55
+ // 직결 클라이언트와 같은 회복 계약 — fatal은 disconnect()로 정리한 뒤에만 다시 시도할 수 있습니다
56
+ if (this.#status === 'fatal') {
57
+ this.options.logger?.warn(
58
+ 'sse: fatal 상태에서는 connect()가 무시됩니다 — disconnect()로 정리한 뒤 다시 connect() 하세요'
59
+ );
60
+ return;
61
+ }
62
+ // 연결이 접혔는지(전원 침묵)는 탭의 status로 알 수 없습니다 — 거르지 않고 매번 보냅니다
63
+ if (this.#port !== null) {
64
+ this.#requestConnection();
65
+ return;
66
+ }
67
+
68
+ const worker = this.#createWorker(factory);
69
+ if (worker === null) {
70
+ return;
71
+ }
72
+
73
+ const port = worker.port;
74
+ this.#port = port;
75
+ port.onmessage = (event) => {
76
+ const isSamePort = this.#port === port;
77
+ if (!isSamePort) {
78
+ return;
79
+ }
80
+ this.#handleWorkerMessage(event.data, port);
81
+ };
82
+ port.start();
83
+
84
+ this.#requestConnection();
85
+ this.emitter.handlerTypes().forEach((type) => this.#sendSubscribe(type));
86
+ }
87
+
88
+ disconnect(): void {
89
+ // 이 탭만 해제합니다 — 마지막 탭 판단과 연결 정리는 워커 몫이라 close를 상태 알림보다 먼저 보냅니다
90
+ this.#post({ kind: 'close' });
91
+ this.#port?.close();
92
+ this.#port = null;
93
+ this.#sentSubscriptions.clear();
94
+ this.#setStatus('closed');
95
+ }
96
+
97
+ // 포트가 없으면 조용히 넘어갑니다 — connect()가 그때까지 모인 구독을 한 번에 보냅니다
98
+ protected registerSubscription(type: string): void {
99
+ this.#sendSubscribe(type);
100
+ }
101
+
102
+ // 워커는 baseUrl을 모르므로 탭이 알려줍니다 — 이미 연결이 서 있으면 워커가 무시합니다
103
+ #requestConnection(): void {
104
+ this.#post({ kind: 'init', baseUrl: this.options.baseUrl });
105
+ }
106
+
107
+ // 팩토리는 소비처 코드고 new SharedWorker도 URL 오류·CSP 차단 시 동기로 던집니다 — 직결 클라이언트의 EventSource 생성 실패와 같은 fatal 처리
108
+ #createWorker(factory: () => SharedWorker): SharedWorker | null {
109
+ try {
110
+ return factory();
111
+ } catch (error) {
112
+ this.options.logger?.error(
113
+ 'sse: SharedWorker 생성 실패 — 워커 URL·CSP를 확인하세요',
114
+ error
115
+ );
116
+ this.#setStatus('fatal');
117
+ this.emitter.emit(SDK_EVENTS.fatal, {
118
+ cause: 'blocked',
119
+ } satisfies FatalPayload);
120
+ return null;
121
+ }
122
+ }
123
+
124
+ #sendSubscribe(type: string): void {
125
+ if (this.#port === null || this.#sentSubscriptions.has(type)) {
126
+ return;
127
+ }
128
+ if (!isSubscribableEvent(type)) {
129
+ return;
130
+ }
131
+
132
+ this.#sentSubscriptions.add(type);
133
+ this.#post({ kind: 'subscribe', event: type });
134
+ }
135
+
136
+ #handleWorkerMessage(data: unknown, port: MessagePort): void {
137
+ const message = data as Incoming<WorkerToTabMessage> | null;
138
+ switch (message?.kind) {
139
+ case 'status':
140
+ // 포트 너머는 신뢰 경계 — 검증 없이 반영하면 status 게터의 타입 보증이 깨집니다
141
+ if (isSseStatus(message.status)) {
142
+ this.#setStatus(message.status);
143
+ }
144
+ return;
145
+ case 'event':
146
+ if (typeof message.event === 'string') {
147
+ this.emitter.emit(message.event, message.payload);
148
+ }
149
+ return;
150
+ case 'ping':
151
+ this.#post({ kind: 'pong' });
152
+ return;
153
+ case 'ticket-request':
154
+ // 토큰은 탭에만 있습니다 — 발급까지 여기서 하고 워커에는 결과만 넘깁니다
155
+ void this.#issueTicketForWorker(port);
156
+ return;
157
+ default:
158
+ // 여기 도달하는 건 미지 kind(신·구 코드가 섞인 배포)뿐이라 무시합니다
159
+ message satisfies null;
160
+ return;
161
+ }
162
+ }
163
+
164
+ async #issueTicketForWorker(port: MessagePort): Promise<void> {
165
+ const result = await issueTicketWithTimeout(this.options);
166
+ // 발급 중 재연결로 포트가 바뀌었으면, 이 티켓은 이미 사라진 세션의 것입니다
167
+ if (this.#port !== port) {
168
+ return;
169
+ }
170
+
171
+ const ticketResponse: TicketResponseMessage = result.ok
172
+ ? { kind: 'ticket-response', ok: true, ticket: result.ticket }
173
+ : { kind: 'ticket-response', ok: false, error: result.error };
174
+
175
+ this.#post(ticketResponse);
176
+ }
177
+
178
+ #post(message: TabToWorkerMessage): void {
179
+ this.#port?.postMessage(message);
180
+ }
181
+
182
+ #setStatus(next: SseStatus): void {
183
+ if (this.#status === next) {
184
+ return;
185
+ }
186
+
187
+ this.#status = next;
188
+ this.emitter.emitStatus(next);
189
+ }
190
+ }