@remember-web/sse 0.1.0-alpha.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +238 -0
- package/dist/src/core/BaseSseClient.cjs.js +97 -0
- package/dist/src/core/BaseSseClient.cjs.js.map +1 -0
- package/dist/src/core/BaseSseClient.d.ts +33 -0
- package/dist/src/core/BaseSseClient.d.ts.map +1 -0
- package/dist/src/core/BaseSseClient.esm.js +89 -0
- package/dist/src/core/BaseSseClient.esm.js.map +1 -0
- package/dist/src/core/SseClient.cjs.js +382 -0
- package/dist/src/core/SseClient.cjs.js.map +1 -0
- package/dist/src/core/SseClient.d.ts +24 -0
- package/dist/src/core/SseClient.d.ts.map +1 -0
- package/dist/src/core/SseClient.esm.js +370 -0
- package/dist/src/core/SseClient.esm.js.map +1 -0
- package/dist/src/core/backoff.cjs.js +30 -0
- package/dist/src/core/backoff.cjs.js.map +1 -0
- package/dist/src/core/backoff.d.ts +12 -0
- package/dist/src/core/backoff.d.ts.map +1 -0
- package/dist/src/core/backoff.esm.js +28 -0
- package/dist/src/core/backoff.esm.js.map +1 -0
- package/dist/src/core/emitter.cjs.js +57 -0
- package/dist/src/core/emitter.cjs.js.map +1 -0
- package/dist/src/core/emitter.d.ts +14 -0
- package/dist/src/core/emitter.d.ts.map +1 -0
- package/dist/src/core/emitter.esm.js +51 -0
- package/dist/src/core/emitter.esm.js.map +1 -0
- package/dist/src/core/eventNames.cjs.js +64 -0
- package/dist/src/core/eventNames.cjs.js.map +1 -0
- package/dist/src/core/eventNames.d.ts +31 -0
- package/dist/src/core/eventNames.d.ts.map +1 -0
- package/dist/src/core/eventNames.esm.js +54 -0
- package/dist/src/core/eventNames.esm.js.map +1 -0
- package/dist/src/core/retryWait.cjs.js +20 -0
- package/dist/src/core/retryWait.cjs.js.map +1 -0
- package/dist/src/core/retryWait.d.ts +6 -0
- package/dist/src/core/retryWait.d.ts.map +1 -0
- package/dist/src/core/retryWait.esm.js +18 -0
- package/dist/src/core/retryWait.esm.js.map +1 -0
- package/dist/src/core/statusMachine.cjs.js +56 -0
- package/dist/src/core/statusMachine.cjs.js.map +1 -0
- package/dist/src/core/statusMachine.d.ts +9 -0
- package/dist/src/core/statusMachine.d.ts.map +1 -0
- package/dist/src/core/statusMachine.esm.js +54 -0
- package/dist/src/core/statusMachine.esm.js.map +1 -0
- package/dist/src/core/stream.cjs.js +136 -0
- package/dist/src/core/stream.cjs.js.map +1 -0
- package/dist/src/core/stream.d.ts +42 -0
- package/dist/src/core/stream.d.ts.map +1 -0
- package/dist/src/core/stream.esm.js +128 -0
- package/dist/src/core/stream.esm.js.map +1 -0
- package/dist/src/core/ticket.cjs.js +237 -0
- package/dist/src/core/ticket.cjs.js.map +1 -0
- package/dist/src/core/ticket.d.ts +31 -0
- package/dist/src/core/ticket.d.ts.map +1 -0
- package/dist/src/core/ticket.esm.js +220 -0
- package/dist/src/core/ticket.esm.js.map +1 -0
- package/dist/src/core/types.cjs.js +54 -0
- package/dist/src/core/types.cjs.js.map +1 -0
- package/dist/src/core/types.d.ts +131 -0
- package/dist/src/core/types.d.ts.map +1 -0
- package/dist/src/core/types.esm.js +50 -0
- package/dist/src/core/types.esm.js.map +1 -0
- package/dist/src/core/wire.cjs.js +29 -0
- package/dist/src/core/wire.cjs.js.map +1 -0
- package/dist/src/core/wire.d.ts +7 -0
- package/dist/src/core/wire.d.ts.map +1 -0
- package/dist/src/core/wire.esm.js +23 -0
- package/dist/src/core/wire.esm.js.map +1 -0
- package/dist/src/createSseClient.cjs.js +30 -0
- package/dist/src/createSseClient.cjs.js.map +1 -0
- package/dist/src/createSseClient.d.ts +8 -0
- package/dist/src/createSseClient.d.ts.map +1 -0
- package/dist/src/createSseClient.esm.js +28 -0
- package/dist/src/createSseClient.esm.js.map +1 -0
- package/dist/src/index.cjs.js +10 -0
- package/dist/src/index.cjs.js.map +1 -0
- package/dist/src/index.d.ts +6 -0
- package/dist/src/index.d.ts.map +1 -0
- package/dist/src/index.esm.js +3 -0
- package/dist/src/index.esm.js.map +1 -0
- package/dist/src/react/createSseContext.cjs.js +130 -0
- package/dist/src/react/createSseContext.cjs.js.map +1 -0
- package/dist/src/react/createSseContext.d.ts +32 -0
- package/dist/src/react/createSseContext.d.ts.map +1 -0
- package/dist/src/react/createSseContext.esm.js +122 -0
- package/dist/src/react/createSseContext.esm.js.map +1 -0
- package/dist/src/react/index.cjs.js +8 -0
- package/dist/src/react/index.cjs.js.map +1 -0
- package/dist/src/react/index.d.ts +3 -0
- package/dist/src/react/index.d.ts.map +1 -0
- package/dist/src/react/index.esm.js +2 -0
- package/dist/src/react/index.esm.js.map +1 -0
- package/dist/src/testing/doubles.d.ts +50 -0
- package/dist/src/testing/doubles.d.ts.map +1 -0
- package/dist/src/testing/harness.d.ts +43 -0
- package/dist/src/testing/harness.d.ts.map +1 -0
- package/dist/src/testing/index.d.ts +3 -0
- package/dist/src/testing/index.d.ts.map +1 -0
- package/dist/src/worker/WorkerSseClient.cjs.js +240 -0
- package/dist/src/worker/WorkerSseClient.cjs.js.map +1 -0
- package/dist/src/worker/WorkerSseClient.d.ts +23 -0
- package/dist/src/worker/WorkerSseClient.d.ts.map +1 -0
- package/dist/src/worker/WorkerSseClient.esm.js +228 -0
- package/dist/src/worker/WorkerSseClient.esm.js.map +1 -0
- package/dist/src/worker/hub.d.ts +19 -0
- package/dist/src/worker/hub.d.ts.map +1 -0
- package/dist/src/worker/portRegistry.d.ts +41 -0
- package/dist/src/worker/portRegistry.d.ts.map +1 -0
- package/dist/src/worker/protocol.d.ts +63 -0
- package/dist/src/worker/protocol.d.ts.map +1 -0
- package/dist/src/worker/sse.worker.d.ts +2 -0
- package/dist/src/worker/sse.worker.d.ts.map +1 -0
- package/dist/src/worker/ticketDelegate.d.ts +16 -0
- package/dist/src/worker/ticketDelegate.d.ts.map +1 -0
- package/dist/src/worker/workerConnection.d.ts +28 -0
- package/dist/src/worker/workerConnection.d.ts.map +1 -0
- package/dist/worker/sse.worker.js +1865 -0
- package/dist/worker/sse.worker.js.map +1 -0
- package/package.json +87 -0
- package/src/core/BaseSseClient.test.ts +142 -0
- package/src/core/BaseSseClient.ts +103 -0
- package/src/core/SseClient.connection.test.ts +863 -0
- package/src/core/SseClient.scenarios.test.ts +164 -0
- package/src/core/SseClient.test.ts +63 -0
- package/src/core/SseClient.ts +310 -0
- package/src/core/backoff.test.ts +43 -0
- package/src/core/backoff.ts +39 -0
- package/src/core/emitter.ts +56 -0
- package/src/core/eventNames.ts +53 -0
- package/src/core/retryWait.ts +18 -0
- package/src/core/statusMachine.ts +56 -0
- package/src/core/stream.ts +141 -0
- package/src/core/ticket.ts +146 -0
- package/src/core/types.ts +182 -0
- package/src/core/wire.ts +23 -0
- package/src/createSseClient.test.ts +82 -0
- package/src/createSseClient.ts +34 -0
- package/src/index.ts +21 -0
- package/src/react/SseProvider.test.tsx +129 -0
- package/src/react/createSseContext.test.ts +47 -0
- package/src/react/createSseContext.tsx +140 -0
- package/src/react/index.ts +2 -0
- package/src/react/useSseEvent.test.tsx +71 -0
- package/src/react/useSseStatus.test.tsx +40 -0
- package/src/testing/doubles.ts +128 -0
- package/src/testing/harness.ts +82 -0
- package/src/testing/index.ts +3 -0
- package/src/worker/SseClientLike.contract.test.ts +270 -0
- package/src/worker/WorkerSseClient.scenarios.test.ts +179 -0
- package/src/worker/WorkerSseClient.test.ts +377 -0
- package/src/worker/WorkerSseClient.ts +190 -0
- package/src/worker/hub.connection.test.ts +1023 -0
- package/src/worker/hub.test.ts +213 -0
- package/src/worker/hub.ts +120 -0
- package/src/worker/portRegistry.test.ts +59 -0
- package/src/worker/portRegistry.ts +86 -0
- package/src/worker/protocol.ts +80 -0
- package/src/worker/sse.worker.ts +19 -0
- package/src/worker/ticketDelegate.test.ts +99 -0
- package/src/worker/ticketDelegate.ts +80 -0
- package/src/worker/workerConnection.ts +134 -0
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
// @vitest-environment node
|
|
2
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
3
|
+
|
|
4
|
+
import { createWorkerHub, PING_INTERVAL_MS } from './hub';
|
|
5
|
+
|
|
6
|
+
import { FakePort as FakeTabPort, kindsOf, setupFakeTimers } from '../testing';
|
|
7
|
+
|
|
8
|
+
setupFakeTimers();
|
|
9
|
+
|
|
10
|
+
describe('워커 허브 — 포트 장부', () => {
|
|
11
|
+
it('새 포트가 등록되면 현재 상태를 즉시 알려줍니다', () => {
|
|
12
|
+
const hub = createWorkerHub();
|
|
13
|
+
const port = new FakeTabPort();
|
|
14
|
+
|
|
15
|
+
hub.addPort(port);
|
|
16
|
+
|
|
17
|
+
expect(port.sent).toEqual([{ kind: 'status', status: 'idle' }]);
|
|
18
|
+
expect(hub.tabCount()).toBe(1);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('broadcast는 등록된 모든 포트에 전달됩니다', () => {
|
|
22
|
+
const hub = createWorkerHub();
|
|
23
|
+
const first = new FakeTabPort();
|
|
24
|
+
const second = new FakeTabPort();
|
|
25
|
+
hub.addPort(first);
|
|
26
|
+
hub.addPort(second);
|
|
27
|
+
|
|
28
|
+
hub.broadcast({
|
|
29
|
+
kind: 'event',
|
|
30
|
+
event: 'demo.created',
|
|
31
|
+
payload: { id: 'a' },
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
expect(first.sent.at(-1)).toEqual({
|
|
35
|
+
kind: 'event',
|
|
36
|
+
event: 'demo.created',
|
|
37
|
+
payload: { id: 'a' },
|
|
38
|
+
});
|
|
39
|
+
expect(second.sent.at(-1)).toEqual(first.sent.at(-1));
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('close 메시지를 보낸 포트는 장부에서 빠지고 이후 broadcast를 받지 않습니다', () => {
|
|
43
|
+
const hub = createWorkerHub();
|
|
44
|
+
const leaving = new FakeTabPort();
|
|
45
|
+
const staying = new FakeTabPort();
|
|
46
|
+
hub.addPort(leaving);
|
|
47
|
+
hub.addPort(staying);
|
|
48
|
+
|
|
49
|
+
hub.handleMessage(leaving, { kind: 'close' });
|
|
50
|
+
hub.broadcast({ kind: 'ping' });
|
|
51
|
+
|
|
52
|
+
expect(hub.tabCount()).toBe(1);
|
|
53
|
+
expect(leaving.closed).toBe(true);
|
|
54
|
+
expect(leaving.sent.some((m) => m.kind === 'ping')).toBe(false);
|
|
55
|
+
expect(staying.sent.at(-1)).toEqual({ kind: 'ping' });
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
// 무응답 판정은 portRegistry가 소유하지만, 탭이 겪는 건 허브를 거친 결과라 여기서 고정합니다
|
|
60
|
+
describe('워커 허브 — 생존 확인', () => {
|
|
61
|
+
const pingsOf = (port: FakeTabPort) => kindsOf(port, 'ping');
|
|
62
|
+
|
|
63
|
+
it('주기가 되면 모든 포트에 ping을 보냅니다', () => {
|
|
64
|
+
const hub = createWorkerHub();
|
|
65
|
+
const first = new FakeTabPort();
|
|
66
|
+
const second = new FakeTabPort();
|
|
67
|
+
hub.addPort(first);
|
|
68
|
+
hub.addPort(second);
|
|
69
|
+
|
|
70
|
+
vi.advanceTimersByTime(PING_INTERVAL_MS);
|
|
71
|
+
|
|
72
|
+
expect(pingsOf(first)).toHaveLength(1);
|
|
73
|
+
expect(pingsOf(second)).toHaveLength(1);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
// 빼는 순간부터가 그 탭이 놓치는 구간입니다
|
|
77
|
+
it('무응답 포트도 장부에 남아 broadcast를 계속 받습니다', () => {
|
|
78
|
+
const hub = createWorkerHub();
|
|
79
|
+
const frozen = new FakeTabPort();
|
|
80
|
+
hub.addPort(frozen);
|
|
81
|
+
|
|
82
|
+
vi.advanceTimersByTime(PING_INTERVAL_MS);
|
|
83
|
+
vi.advanceTimersByTime(PING_INTERVAL_MS);
|
|
84
|
+
vi.advanceTimersByTime(PING_INTERVAL_MS);
|
|
85
|
+
|
|
86
|
+
hub.broadcast({ kind: 'event', event: 'demo.created', payload: undefined });
|
|
87
|
+
|
|
88
|
+
expect(hub.tabCount()).toBe(1);
|
|
89
|
+
expect(frozen.closed).toBe(false);
|
|
90
|
+
expect(frozen.sent.at(-1)).toEqual({
|
|
91
|
+
kind: 'event',
|
|
92
|
+
event: 'demo.created',
|
|
93
|
+
payload: undefined,
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('무응답 포트도 계속 ping을 받습니다 — 깨어나 pong하면 그 자리에서 복귀합니다', () => {
|
|
98
|
+
const hub = createWorkerHub();
|
|
99
|
+
const frozen = new FakeTabPort();
|
|
100
|
+
hub.addPort(frozen);
|
|
101
|
+
|
|
102
|
+
vi.advanceTimersByTime(PING_INTERVAL_MS);
|
|
103
|
+
vi.advanceTimersByTime(PING_INTERVAL_MS);
|
|
104
|
+
vi.advanceTimersByTime(PING_INTERVAL_MS);
|
|
105
|
+
|
|
106
|
+
expect(pingsOf(frozen)).toHaveLength(3);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('떠난 포트의 늦은 메시지는 무시합니다 — 닫힌 핸들을 대신해 아무것도 하지 않습니다', () => {
|
|
110
|
+
const hub = createWorkerHub();
|
|
111
|
+
const leaving = new FakeTabPort();
|
|
112
|
+
const staying = new FakeTabPort();
|
|
113
|
+
hub.addPort(leaving);
|
|
114
|
+
hub.addPort(staying);
|
|
115
|
+
|
|
116
|
+
hub.handleMessage(leaving, { kind: 'close' });
|
|
117
|
+
hub.handleMessage(leaving, { kind: 'pong' });
|
|
118
|
+
hub.handleMessage(leaving, {
|
|
119
|
+
kind: 'init',
|
|
120
|
+
baseUrl: 'https://sse.example.com',
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
expect(hub.tabCount()).toBe(1);
|
|
124
|
+
expect(hub.subscribedEvents()).toEqual([]);
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
describe('워커 허브 — 메시지 라우팅', () => {
|
|
129
|
+
it('subscribe는 이벤트 이름을 중복 없이 모읍니다', () => {
|
|
130
|
+
const hub = createWorkerHub();
|
|
131
|
+
const port = new FakeTabPort();
|
|
132
|
+
hub.addPort(port);
|
|
133
|
+
|
|
134
|
+
hub.handleMessage(port, { kind: 'subscribe', event: 'demo.created' });
|
|
135
|
+
hub.handleMessage(port, { kind: 'subscribe', event: 'demo.created' });
|
|
136
|
+
hub.handleMessage(port, { kind: 'subscribe', event: 'demo.updated' });
|
|
137
|
+
|
|
138
|
+
expect(hub.subscribedEvents().sort()).toEqual([
|
|
139
|
+
'demo.created',
|
|
140
|
+
'demo.updated',
|
|
141
|
+
]);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("SDK 예약 이벤트('hello')와 'resync' 구독 요청은 장부에 넣지 않습니다", () => {
|
|
145
|
+
const hub = createWorkerHub();
|
|
146
|
+
const port = new FakeTabPort();
|
|
147
|
+
hub.addPort(port);
|
|
148
|
+
|
|
149
|
+
hub.handleMessage(port, { kind: 'subscribe', event: 'hello' });
|
|
150
|
+
hub.handleMessage(port, { kind: 'subscribe', event: 'resync' });
|
|
151
|
+
hub.handleMessage(port, { kind: 'subscribe', event: 'demo.created' });
|
|
152
|
+
|
|
153
|
+
expect(hub.subscribedEvents()).toEqual(['demo.created']);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('event가 문자열이 아닌 subscribe는 장부에 넣지 않습니다', () => {
|
|
157
|
+
const hub = createWorkerHub();
|
|
158
|
+
const port = new FakeTabPort();
|
|
159
|
+
hub.addPort(port);
|
|
160
|
+
|
|
161
|
+
hub.handleMessage(port, { kind: 'subscribe' });
|
|
162
|
+
hub.handleMessage(port, { kind: 'subscribe', event: 42 });
|
|
163
|
+
|
|
164
|
+
expect(hub.subscribedEvents()).toEqual([]);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('형식이 어긋난 ticket-response는 위임에 넘기지 않습니다 — 연결 루프가 예외로 죽지 않게', () => {
|
|
168
|
+
const hub = createWorkerHub();
|
|
169
|
+
const port = new FakeTabPort();
|
|
170
|
+
hub.addPort(port);
|
|
171
|
+
|
|
172
|
+
expect(() =>
|
|
173
|
+
hub.handleMessage(port, {
|
|
174
|
+
kind: 'ticket-response',
|
|
175
|
+
ok: false,
|
|
176
|
+
error: null,
|
|
177
|
+
})
|
|
178
|
+
).not.toThrow();
|
|
179
|
+
expect(() =>
|
|
180
|
+
hub.handleMessage(port, { kind: 'ticket-response', ok: true, ticket: 42 })
|
|
181
|
+
).not.toThrow();
|
|
182
|
+
expect(() =>
|
|
183
|
+
hub.handleMessage(port, { kind: 'ticket-response', ok: false })
|
|
184
|
+
).not.toThrow();
|
|
185
|
+
expect(() =>
|
|
186
|
+
hub.handleMessage(port, {
|
|
187
|
+
kind: 'ticket-response',
|
|
188
|
+
ok: false,
|
|
189
|
+
error: [],
|
|
190
|
+
})
|
|
191
|
+
).not.toThrow();
|
|
192
|
+
// status는 fatal 판정에 쓰이므로 문자열이면 재시도 대상으로도 넘기지 않습니다
|
|
193
|
+
expect(() =>
|
|
194
|
+
hub.handleMessage(port, {
|
|
195
|
+
kind: 'ticket-response',
|
|
196
|
+
ok: false,
|
|
197
|
+
error: { status: '401' },
|
|
198
|
+
})
|
|
199
|
+
).not.toThrow();
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it('모르는 kind의 메시지는 무시합니다 — 신·구 코드가 섞인 배포 중에도 안전합니다', () => {
|
|
203
|
+
const hub = createWorkerHub();
|
|
204
|
+
const port = new FakeTabPort();
|
|
205
|
+
hub.addPort(port);
|
|
206
|
+
|
|
207
|
+
expect(() =>
|
|
208
|
+
hub.handleMessage(port, { kind: 'future-thing' })
|
|
209
|
+
).not.toThrow();
|
|
210
|
+
expect(() => hub.handleMessage(port, null)).not.toThrow();
|
|
211
|
+
expect(hub.tabCount()).toBe(1);
|
|
212
|
+
});
|
|
213
|
+
});
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import type { HubPort } from './portRegistry';
|
|
2
|
+
import { createPortRegistry } from './portRegistry';
|
|
3
|
+
import type {
|
|
4
|
+
Incoming,
|
|
5
|
+
TabToWorkerMessage,
|
|
6
|
+
WorkerToTabMessage,
|
|
7
|
+
} from './protocol';
|
|
8
|
+
import { isTicketResponse } from './protocol';
|
|
9
|
+
import { createTicketDelegate } from './ticketDelegate';
|
|
10
|
+
import type { WorkerConnectionOptions } from './workerConnection';
|
|
11
|
+
import { createWorkerConnection } from './workerConnection';
|
|
12
|
+
|
|
13
|
+
import { isSubscribableEvent } from '../core/eventNames';
|
|
14
|
+
import type { SseStatus } from '../core/types';
|
|
15
|
+
|
|
16
|
+
export { PING_INTERVAL_MS } from './portRegistry';
|
|
17
|
+
export type { HubPort } from './portRegistry';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* @description SharedWorker 쪽 메시지 라우팅 — 탭이 보낸 것을 연결에 전하고, 연결이 낸 것을 탭에 전합니다.
|
|
21
|
+
* 포트 장부(frozen 탭을 빼지 않는 이유 포함)는 portRegistry가, 연결 수립·정리는 workerConnection이 소유합니다.
|
|
22
|
+
* 배선 현황은 protocol.ts의 지도를 보세요.
|
|
23
|
+
*/
|
|
24
|
+
export const createWorkerHub = (hubOptions: WorkerConnectionOptions = {}) => {
|
|
25
|
+
// 늦게 합류한 탭에 스냅샷으로 보냅니다 — 저장과 통지가 어긋나지 않게 setStatus로만 바꿉니다
|
|
26
|
+
let status: SseStatus = 'idle';
|
|
27
|
+
|
|
28
|
+
const registry = createPortRegistry();
|
|
29
|
+
|
|
30
|
+
const broadcast = (message: WorkerToTabMessage): void => {
|
|
31
|
+
registry.forEach((port) => port.postMessage(message));
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const setStatus = (next: SseStatus): void => {
|
|
35
|
+
status = next;
|
|
36
|
+
broadcast({ kind: 'status', status: next });
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const ticketDelegate = createTicketDelegate(() => {
|
|
40
|
+
const target = registry.livelyPort();
|
|
41
|
+
if (target !== undefined) {
|
|
42
|
+
target.postMessage({ kind: 'ticket-request' });
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
// 전원이 침묵하면 접습니다 — 세션 종료가 아니라 휴면이라, 재수립 판정은 그대로 둡니다.
|
|
46
|
+
// microtask인 이유: 이 콜백은 연결 루프의 티켓 발급 스택 안이라, 동기로 접으면 그 루프를 재진입으로 끊습니다
|
|
47
|
+
queueMicrotask(() => connection.close());
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
const connection = createWorkerConnection({
|
|
51
|
+
options: hubOptions,
|
|
52
|
+
ticketDelegate,
|
|
53
|
+
onStatus: setStatus,
|
|
54
|
+
onEvent: (event, payload) => broadcast({ kind: 'event', event, payload }),
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
return {
|
|
58
|
+
addPort: (port: HubPort): void => {
|
|
59
|
+
registry.add(port);
|
|
60
|
+
port.postMessage({ kind: 'status', status });
|
|
61
|
+
},
|
|
62
|
+
|
|
63
|
+
handleMessage: (port: HubPort, data: unknown): void => {
|
|
64
|
+
// close로 떠난 포트의 늦은 메시지만 거릅니다 — frozen 탭은 장부에 남아 그대로 지나갑니다
|
|
65
|
+
if (!registry.has(port)) {
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// 어떤 메시지든 생존 증거입니다 — pong만 세면 깨어나 init부터 보낸 탭이 침묵으로 남아 티켓을 못 받습니다
|
|
70
|
+
registry.markAlive(port);
|
|
71
|
+
|
|
72
|
+
const message = data as Incoming<TabToWorkerMessage> | null;
|
|
73
|
+
switch (message?.kind) {
|
|
74
|
+
case 'init':
|
|
75
|
+
// 휴면(전원 침묵)으로 접힌 연결은 깨어난 탭의 이 신호로 다시 섭니다
|
|
76
|
+
if (typeof message.baseUrl === 'string') {
|
|
77
|
+
connection.ensureOpen(message.baseUrl);
|
|
78
|
+
}
|
|
79
|
+
return;
|
|
80
|
+
case 'subscribe':
|
|
81
|
+
// 예약 이벤트·비문자열은 프록시가 걸러 보내지만, 구버전·타 구현 탭에 대비해 한 번 더 거릅니다
|
|
82
|
+
if (
|
|
83
|
+
typeof message.event === 'string' &&
|
|
84
|
+
isSubscribableEvent(message.event)
|
|
85
|
+
) {
|
|
86
|
+
connection.subscribe(message.event);
|
|
87
|
+
}
|
|
88
|
+
return;
|
|
89
|
+
case 'ticket-response':
|
|
90
|
+
// 여기서 거르지 않으면 잘못된 응답이 연결 루프를 예외로 죽여 워커가 고착됩니다
|
|
91
|
+
if (isTicketResponse(message)) {
|
|
92
|
+
ticketDelegate.settle(message);
|
|
93
|
+
}
|
|
94
|
+
return;
|
|
95
|
+
case 'pong':
|
|
96
|
+
// 생존 표시는 위에서 이미 끝났습니다 — pong은 그 증거만 나르는 메시지입니다
|
|
97
|
+
return;
|
|
98
|
+
case 'close': {
|
|
99
|
+
const { wasLast } = registry.close(port);
|
|
100
|
+
// 모두 스스로 떠났으니 세션이 끝났습니다 — 다음 탭에게는 첫 연결입니다
|
|
101
|
+
if (wasLast) {
|
|
102
|
+
connection.close({ endSession: true });
|
|
103
|
+
}
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
default:
|
|
107
|
+
// 여기 도달하는 건 미지 kind(신·구 코드가 섞인 배포)뿐이라 무시합니다
|
|
108
|
+
message satisfies null;
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
},
|
|
112
|
+
|
|
113
|
+
broadcast,
|
|
114
|
+
|
|
115
|
+
tabCount: registry.size,
|
|
116
|
+
subscribedEvents: connection.subscribedEvents,
|
|
117
|
+
};
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
export type WorkerHub = ReturnType<typeof createWorkerHub>;
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// @vitest-environment node
|
|
2
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
3
|
+
|
|
4
|
+
import { createPortRegistry, PING_INTERVAL_MS } from './portRegistry';
|
|
5
|
+
|
|
6
|
+
import { FakePort, setupFakeTimers } from '../testing';
|
|
7
|
+
|
|
8
|
+
setupFakeTimers();
|
|
9
|
+
|
|
10
|
+
// sendPings 세 줄의 순서가 규칙 전부입니다 — 승격이 재무장보다 먼저여야 "한 주기 유예"가 성립합니다
|
|
11
|
+
describe('portRegistry — 침묵 판정', () => {
|
|
12
|
+
it('ping 직후에는 아무도 침묵으로 판정하지 않습니다 — 전원이 응답 대기일 뿐입니다', () => {
|
|
13
|
+
const registry = createPortRegistry();
|
|
14
|
+
const port = new FakePort();
|
|
15
|
+
registry.add(port);
|
|
16
|
+
|
|
17
|
+
vi.advanceTimersByTime(PING_INTERVAL_MS);
|
|
18
|
+
|
|
19
|
+
expect(registry.livelyPort()).toBe(port);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('한 주기를 통째로 놓치면 침묵으로 판정합니다', () => {
|
|
23
|
+
const registry = createPortRegistry();
|
|
24
|
+
const port = new FakePort();
|
|
25
|
+
registry.add(port);
|
|
26
|
+
|
|
27
|
+
vi.advanceTimersByTime(PING_INTERVAL_MS);
|
|
28
|
+
vi.advanceTimersByTime(PING_INTERVAL_MS);
|
|
29
|
+
|
|
30
|
+
expect(registry.livelyPort()).toBeUndefined();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('침묵하던 포트도 markAlive면 그 자리에서 되돌아옵니다', () => {
|
|
34
|
+
const registry = createPortRegistry();
|
|
35
|
+
const port = new FakePort();
|
|
36
|
+
registry.add(port);
|
|
37
|
+
|
|
38
|
+
vi.advanceTimersByTime(PING_INTERVAL_MS);
|
|
39
|
+
vi.advanceTimersByTime(PING_INTERVAL_MS);
|
|
40
|
+
expect(registry.livelyPort()).toBeUndefined();
|
|
41
|
+
|
|
42
|
+
registry.markAlive(port);
|
|
43
|
+
|
|
44
|
+
expect(registry.livelyPort()).toBe(port);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('이번 주기에 답한 포트를 판정 전 포트보다 먼저 고릅니다', () => {
|
|
48
|
+
const registry = createPortRegistry();
|
|
49
|
+
const awaiting = new FakePort();
|
|
50
|
+
const answered = new FakePort();
|
|
51
|
+
registry.add(awaiting);
|
|
52
|
+
registry.add(answered);
|
|
53
|
+
|
|
54
|
+
vi.advanceTimersByTime(PING_INTERVAL_MS);
|
|
55
|
+
registry.markAlive(answered);
|
|
56
|
+
|
|
57
|
+
expect(registry.livelyPort()).toBe(answered);
|
|
58
|
+
});
|
|
59
|
+
});
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import type { WorkerToTabMessage } from './protocol';
|
|
2
|
+
|
|
3
|
+
/** @description 장부가 포트에 요구하는 최소 표면 — 실제 MessagePort와 테스트 대역이 함께 만족합니다 */
|
|
4
|
+
export interface HubPort {
|
|
5
|
+
postMessage(message: WorkerToTabMessage): void;
|
|
6
|
+
close(): void;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
// 15초는 소비자 요구가 아직 없는 임의 기본값입니다 — 티켓 대상 판별의 지연 허용치가 정해지면 조정합니다
|
|
10
|
+
export const PING_INTERVAL_MS = 15_000;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* @description 탭 포트의 장부 — 등록·떠남과 ping 주기를 소유합니다.
|
|
14
|
+
*
|
|
15
|
+
* 무응답 포트를 장부에서 빼지 않습니다: frozen 탭의 메시지는 브라우저가 큐잉했다가
|
|
16
|
+
* 깨어날 때 전부 배달하므로, 계속 보내는 것이 곧 공백 없는 복구입니다 —
|
|
17
|
+
* 빼는 순간부터가 그 탭이 놓치는 구간이 됩니다.
|
|
18
|
+
* close 없이 사라진 탭(크래시·창 닫기)의 포트는 장부에 남지만 무해합니다 — 전송은 조용히
|
|
19
|
+
* 버려지고, 상한은 워커 생존 중 닫힌 탭 수이며, 마지막 클라이언트가 사라지면 브라우저가 워커째 거둡니다.
|
|
20
|
+
*/
|
|
21
|
+
export const createPortRegistry = () => {
|
|
22
|
+
const ports = new Set<HubPort>();
|
|
23
|
+
// 이번 주기 pong을 아직 안 낸 포트 — ping 직후에는 전원이 여기 있으므로 무응답 판정에 쓰면 안 됩니다
|
|
24
|
+
const awaitingPong = new Set<HubPort>();
|
|
25
|
+
// 한 주기를 통째로 놓친 포트 — frozen이거나 죽은 것. pong이 오면 되돌아옵니다
|
|
26
|
+
const silent = new Set<HubPort>();
|
|
27
|
+
|
|
28
|
+
const sendPings = (): void => {
|
|
29
|
+
awaitingPong.forEach((port) => silent.add(port));
|
|
30
|
+
|
|
31
|
+
ports.forEach((port) => awaitingPong.add(port));
|
|
32
|
+
ports.forEach((port) => port.postMessage({ kind: 'ping' }));
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
// 해제 수단을 두지 않습니다 — 이 타이머의 수명은 워커 수명이고, 워커는 브라우저가 통째로 거둡니다.
|
|
36
|
+
// 테스트의 가짜 타이머는 vitest.setup의 afterEach(useRealTimers)가 시계째 버려 교차 오염이 없습니다
|
|
37
|
+
setInterval(sendPings, PING_INTERVAL_MS);
|
|
38
|
+
|
|
39
|
+
return {
|
|
40
|
+
add: (port: HubPort): void => {
|
|
41
|
+
ports.add(port);
|
|
42
|
+
},
|
|
43
|
+
|
|
44
|
+
/** @description 떠난 포트의 늦은 메시지를 거르는 유일한 기준 — frozen 탭은 장부에 남아 있습니다 */
|
|
45
|
+
has: (port: HubPort): boolean => ports.has(port),
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* @description 탭이 스스로 떠났습니다 — 장부에서 빼고 포트를 닫습니다.
|
|
49
|
+
* 마지막이었는지 함께 알려 호출부가 세션을 접을지 정하게 합니다.
|
|
50
|
+
*/
|
|
51
|
+
close: (port: HubPort): { wasLast: boolean } => {
|
|
52
|
+
ports.delete(port);
|
|
53
|
+
awaitingPong.delete(port);
|
|
54
|
+
silent.delete(port);
|
|
55
|
+
port.close();
|
|
56
|
+
|
|
57
|
+
return { wasLast: ports.size === 0 };
|
|
58
|
+
},
|
|
59
|
+
|
|
60
|
+
markAlive: (port: HubPort): void => {
|
|
61
|
+
awaitingPong.delete(port);
|
|
62
|
+
silent.delete(port);
|
|
63
|
+
},
|
|
64
|
+
|
|
65
|
+
size: (): number => ports.size,
|
|
66
|
+
|
|
67
|
+
forEach: (visit: (port: HubPort) => void): void => ports.forEach(visit),
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* @description 티켓을 물어볼 탭 하나 — EventSource가 하나라 티켓도 한 장이면 됩니다.
|
|
71
|
+
* 전원에게 뿌리면 탭 수만큼 POST가 나가고, 그 증폭이 429(fatal)를 자초합니다.
|
|
72
|
+
* 이번 주기에 답한 탭을 먼저 고르고, 없으면 아직 판정 전인 탭에게 묻습니다.
|
|
73
|
+
* 전원이 확정 침묵이면 undefined: 답이 올 수 없는 요청을 큐에 쌓으면
|
|
74
|
+
* 깨어난 탭이 그 수만큼 한꺼번에 POST를 쏘게 됩니다.
|
|
75
|
+
*/
|
|
76
|
+
livelyPort: (): HubPort | undefined => {
|
|
77
|
+
const candidates = [...ports];
|
|
78
|
+
return (
|
|
79
|
+
candidates.find((port) => !awaitingPong.has(port)) ??
|
|
80
|
+
candidates.find((port) => !silent.has(port))
|
|
81
|
+
);
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
export type PortRegistry = ReturnType<typeof createPortRegistry>;
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import type { TicketError } from '../core/ticket';
|
|
2
|
+
import type { SseStatus } from '../core/types';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* @description 탭 프록시(WorkerSseClient) ↔ 워커 허브(hub)가 포트로 주고받는 메시지 어휘와 배선 지도.
|
|
6
|
+
* 수신 처리가 아직 없는 kind는 미지 kind와 같은 규칙으로 무시됩니다 —
|
|
7
|
+
* 배포 skew로 신·구 코드가 섞여도 안전하게 (코어의 "미지 이벤트는 무시"와 대칭).
|
|
8
|
+
*
|
|
9
|
+
* subscribe·close : WorkerSseClient → hub. 배선 완료
|
|
10
|
+
* status·event : hub → WorkerSseClient. 배선 완료 — frozen 탭에게도 계속 보냅니다
|
|
11
|
+
* (밀린 몫은 브라우저가 큐잉했다 배달 — 근거는 portRegistry)
|
|
12
|
+
* ping / pong : hub → 탭 / 탭 → hub. 배선 완료 — 무응답이어도 장부에서 빼지 않고
|
|
13
|
+
* 티켓 대상에서만 제외합니다. 전원이 침묵하면 연결을 접습니다(휴면)
|
|
14
|
+
* init : 탭 → hub. 배선 완료 — 첫 탭의 baseUrl로 연결을 세우고,
|
|
15
|
+
* 휴면으로 접힌 연결을 깨어난 탭이 이 신호로 다시 엽니다
|
|
16
|
+
* ticket-request / response : hub → 탭 / 탭 → hub. 배선 완료 — 토큰은 탭에 남고 발급 결과만 오갑니다
|
|
17
|
+
*
|
|
18
|
+
* 로그아웃은 서버가 goaway(reason: user_logout)로 알려 모든 탭에 함께 닿습니다 — 탭이 워커에 알리는 kind는 두지 않습니다.
|
|
19
|
+
*/
|
|
20
|
+
/** @description 티켓 위임의 응답 — 토큰이 아니라 발급 결과(티켓 문자열)만 워커로 넘어갑니다 */
|
|
21
|
+
export type TicketResponseMessage =
|
|
22
|
+
| { kind: 'ticket-response'; ok: true; ticket: string }
|
|
23
|
+
| { kind: 'ticket-response'; ok: false; error: TicketError };
|
|
24
|
+
|
|
25
|
+
export type TabToWorkerMessage =
|
|
26
|
+
| { kind: 'init'; baseUrl: string }
|
|
27
|
+
// 구독은 워커 수명 동안 유지됩니다 — 탭별 필터링은 프록시의 로컬 dispatch 몫
|
|
28
|
+
| { kind: 'subscribe'; event: string }
|
|
29
|
+
| { kind: 'pong' }
|
|
30
|
+
| { kind: 'close' }
|
|
31
|
+
| TicketResponseMessage;
|
|
32
|
+
|
|
33
|
+
export type WorkerToTabMessage =
|
|
34
|
+
| { kind: 'event'; event: string; payload: unknown }
|
|
35
|
+
| { kind: 'status'; status: SseStatus }
|
|
36
|
+
| { kind: 'ping' }
|
|
37
|
+
| { kind: 'ticket-request' };
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* @description 포트로 갓 도착한 메시지 — kind만 믿고 나머지 필드는 검사 전까지 unknown입니다.
|
|
41
|
+
* 보낸 쪽이 구버전·타 구현일 수 있어, 검사를 빠뜨리면 컴파일이 막히도록 여기서 좁혀둡니다.
|
|
42
|
+
*/
|
|
43
|
+
export type Incoming<M extends { kind: string }> = {
|
|
44
|
+
[K in keyof M]: K extends 'kind' ? M[K] : unknown;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/** @description 판정을 메시지 정의 옆에 둡니다 — 필드가 바뀌면 여기도 같이 눈에 띄도록 */
|
|
48
|
+
export const isTicketResponse = (
|
|
49
|
+
message: Incoming<TabToWorkerMessage>
|
|
50
|
+
): message is TicketResponseMessage => {
|
|
51
|
+
if (message.kind !== 'ticket-response') {
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const { ok, ticket, error } = message as {
|
|
56
|
+
ok?: unknown;
|
|
57
|
+
ticket?: unknown;
|
|
58
|
+
error?: unknown;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
if (ok === true) {
|
|
62
|
+
return typeof ticket === 'string';
|
|
63
|
+
}
|
|
64
|
+
if (
|
|
65
|
+
ok !== false ||
|
|
66
|
+
typeof error !== 'object' ||
|
|
67
|
+
error === null ||
|
|
68
|
+
Array.isArray(error)
|
|
69
|
+
) {
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// status는 fatal 판정에 쓰이므로 숫자가 아니면 재시도 대상으로 흘려보내지 않습니다
|
|
74
|
+
const { status, cause } = error as { status?: unknown; cause?: unknown };
|
|
75
|
+
if (status !== undefined && typeof status !== 'number') {
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
78
|
+
// 미지 cause 문자열은 버리지 않고 통과시킵니다 — 코어가 모르는 값은 status 규칙으로 처리하므로 배포 skew에 안전합니다
|
|
79
|
+
return cause === undefined || typeof cause === 'string';
|
|
80
|
+
};
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { createWorkerHub } from './hub';
|
|
2
|
+
|
|
3
|
+
// dom lib의 self(Window)와 다른 SharedWorker 전역 스코프 — 필요한 표면만 좁혀 캐스팅합니다
|
|
4
|
+
interface SharedWorkerScope {
|
|
5
|
+
onconnect: ((event: MessageEvent) => void) | null;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
const hub = createWorkerHub();
|
|
9
|
+
|
|
10
|
+
(self as unknown as SharedWorkerScope).onconnect = (event) => {
|
|
11
|
+
const port = event.ports[0];
|
|
12
|
+
if (port === undefined) {
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
hub.addPort(port);
|
|
17
|
+
port.onmessage = (message) => hub.handleMessage(port, message.data);
|
|
18
|
+
port.start();
|
|
19
|
+
};
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// @vitest-environment node
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
createTicketDelegate,
|
|
6
|
+
TICKET_RESPONSE_TIMEOUT_MS,
|
|
7
|
+
} from './ticketDelegate';
|
|
8
|
+
|
|
9
|
+
beforeEach(() => {
|
|
10
|
+
vi.useFakeTimers();
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
afterEach(() => {
|
|
14
|
+
vi.useRealTimers();
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
describe('티켓 위임 — 탭에 발급을 요청합니다', () => {
|
|
18
|
+
it('요청하면 탭에 ticket-request를 보내고, 응답이 오면 그 결과로 완료됩니다', async () => {
|
|
19
|
+
const requests: number[] = [];
|
|
20
|
+
const delegate = createTicketDelegate(() => requests.push(1));
|
|
21
|
+
|
|
22
|
+
const pending = delegate.request();
|
|
23
|
+
expect(requests).toHaveLength(1);
|
|
24
|
+
|
|
25
|
+
delegate.settle({ kind: 'ticket-response', ok: true, ticket: 'ticket-1' });
|
|
26
|
+
|
|
27
|
+
await expect(pending).resolves.toEqual({ ok: true, ticket: 'ticket-1' });
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('실패 응답은 상태 코드를 그대로 전달합니다 — 재시도 가능 여부는 코어가 판단합니다', async () => {
|
|
31
|
+
const delegate = createTicketDelegate(() => {});
|
|
32
|
+
|
|
33
|
+
const pending = delegate.request();
|
|
34
|
+
delegate.settle({
|
|
35
|
+
kind: 'ticket-response',
|
|
36
|
+
ok: false,
|
|
37
|
+
error: { status: 401 },
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
await expect(pending).resolves.toEqual({
|
|
41
|
+
ok: false,
|
|
42
|
+
error: { status: 401 },
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('응답이 오지 않으면 시간 제한 뒤 네트워크성 실패로 완료됩니다 — 탭이 죽어도 연결 루프가 멈추지 않습니다', async () => {
|
|
47
|
+
const delegate = createTicketDelegate(() => {});
|
|
48
|
+
|
|
49
|
+
const pending = delegate.request();
|
|
50
|
+
await vi.advanceTimersByTimeAsync(TICKET_RESPONSE_TIMEOUT_MS);
|
|
51
|
+
|
|
52
|
+
await expect(pending).resolves.toEqual({ ok: false, error: {} });
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('시간 제한 뒤 뒤늦게 도착한 응답은 무시합니다', async () => {
|
|
56
|
+
const delegate = createTicketDelegate(() => {});
|
|
57
|
+
|
|
58
|
+
const pending = delegate.request();
|
|
59
|
+
await vi.advanceTimersByTimeAsync(TICKET_RESPONSE_TIMEOUT_MS);
|
|
60
|
+
expect(() =>
|
|
61
|
+
delegate.settle({ kind: 'ticket-response', ok: true, ticket: 'late' })
|
|
62
|
+
).not.toThrow();
|
|
63
|
+
|
|
64
|
+
await expect(pending).resolves.toEqual({ ok: false, error: {} });
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('요청이 진행 중이면 새 요청을 겹쳐 보내지 않고 같은 응답을 함께 기다립니다', async () => {
|
|
68
|
+
let requestCount = 0;
|
|
69
|
+
const delegate = createTicketDelegate(() => {
|
|
70
|
+
requestCount += 1;
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
const first = delegate.request();
|
|
74
|
+
const second = delegate.request();
|
|
75
|
+
expect(requestCount).toBe(1);
|
|
76
|
+
|
|
77
|
+
delegate.settle({ kind: 'ticket-response', ok: true, ticket: 'ticket-1' });
|
|
78
|
+
|
|
79
|
+
await expect(first).resolves.toEqual({ ok: true, ticket: 'ticket-1' });
|
|
80
|
+
await expect(second).resolves.toEqual({ ok: true, ticket: 'ticket-1' });
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('완료된 뒤 다시 요청하면 탭에 새로 요청합니다', async () => {
|
|
84
|
+
let requestCount = 0;
|
|
85
|
+
const delegate = createTicketDelegate(() => {
|
|
86
|
+
requestCount += 1;
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
const first = delegate.request();
|
|
90
|
+
delegate.settle({ kind: 'ticket-response', ok: true, ticket: 'ticket-1' });
|
|
91
|
+
await first;
|
|
92
|
+
|
|
93
|
+
const second = delegate.request();
|
|
94
|
+
expect(requestCount).toBe(2);
|
|
95
|
+
delegate.settle({ kind: 'ticket-response', ok: true, ticket: 'ticket-2' });
|
|
96
|
+
|
|
97
|
+
await expect(second).resolves.toEqual({ ok: true, ticket: 'ticket-2' });
|
|
98
|
+
});
|
|
99
|
+
});
|