@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,53 @@
|
|
|
1
|
+
// 서버가 /v1/events 스트림으로 보내는 연결 수명주기 이벤트 — 도메인 이벤트와 달리 SDK가 소비합니다
|
|
2
|
+
export const SERVER_CONNECTION_EVENTS = {
|
|
3
|
+
hello: 'hello',
|
|
4
|
+
goaway: 'goaway',
|
|
5
|
+
} as const;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* @description SDK가 스스로 발화하는 이벤트 — 재수립(resync)·멈춘 사유(fatal)·미연결 지속(stalled)을 SDK가 알아채 알립니다
|
|
9
|
+
*/
|
|
10
|
+
export const SDK_EVENTS = {
|
|
11
|
+
resync: 'resync',
|
|
12
|
+
fatal: 'fatal',
|
|
13
|
+
stalled: 'stalled',
|
|
14
|
+
} as const;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* @description 소비처로 배달하는 이름들 — 출처(서버·SDK)와 무관하게 "소비처가 보는가"만 봅니다.
|
|
18
|
+
* 워커는 구독 장부와 별개로 이 목록을 중계해야 합니다: 장부만 보고 붙이면 소비처에 영영 도달하지 않습니다.
|
|
19
|
+
* SDK 이벤트는 배달이 곧 존재 이유라 전부 파생합니다 — 나열하면 이름을 늘릴 때 빠뜨릴 자리가 생깁니다.
|
|
20
|
+
* 서버 연결 이벤트는 SDK가 소비하는 게 기본이라 배달할 것만 골라 넣습니다:
|
|
21
|
+
* goaway는 SDK가 action으로 재연결을 판단하고, 소비처는 reason으로 안내를 정합니다.
|
|
22
|
+
*/
|
|
23
|
+
export const FORWARDED_EVENTS = [
|
|
24
|
+
...Object.values(SDK_EVENTS),
|
|
25
|
+
SERVER_CONNECTION_EVENTS.goaway,
|
|
26
|
+
] as const;
|
|
27
|
+
|
|
28
|
+
// 멤버십 검사는 Set 파생으로 — Object.hasOwn(Safari 15.4+)이 배포 하한을 만들지 않게 합니다
|
|
29
|
+
const SERVER_CONNECTION_EVENT_NAMES: ReadonlySet<string> = new Set(
|
|
30
|
+
Object.values(SERVER_CONNECTION_EVENTS)
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
const FORWARDED_EVENT_NAMES: ReadonlySet<string> = new Set(FORWARDED_EVENTS);
|
|
34
|
+
|
|
35
|
+
const isServerConnectionEvent = (type: string): boolean =>
|
|
36
|
+
SERVER_CONNECTION_EVENT_NAMES.has(type);
|
|
37
|
+
|
|
38
|
+
const isForwardedEvent = (type: string): boolean =>
|
|
39
|
+
FORWARDED_EVENT_NAMES.has(type);
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* @description 서버 스트림에 구독을 걸 이름인지 판정합니다 — 연결 이벤트와 중계 목록은 SDK가 소유합니다.
|
|
43
|
+
* 목록에서 판정을 파생해야 이름을 추가할 때 구독 제외가 따라옵니다: 빠지면 워커가 같은 이벤트를 두 번 배달합니다.
|
|
44
|
+
*/
|
|
45
|
+
export const isSubscribableEvent = (type: string): boolean =>
|
|
46
|
+
!isServerConnectionEvent(type) && !isForwardedEvent(type);
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* @description 구독해도 배달되지 않는 이름인지 판정합니다 — 조용한 무배달 구독을 경고로 알리는 데 씁니다.
|
|
50
|
+
* 중계 목록에 있으면 SDK가 소비하더라도 소비처에 배달되므로 경고 대상이 아닙니다.
|
|
51
|
+
*/
|
|
52
|
+
export const isUndeliverableEvent = (type: string): boolean =>
|
|
53
|
+
isServerConnectionEvent(type) && !isForwardedEvent(type);
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @description 재시도 대기 Promise — 타이머 만료·abort 중 먼저 발생하는 쪽에서 resolve됩니다.
|
|
3
|
+
* 어느 경로로 풀리든 타이머와 abort 리스너를 함께 정리합니다.
|
|
4
|
+
*/
|
|
5
|
+
export const waitForRetry = (
|
|
6
|
+
delayMs: number,
|
|
7
|
+
signal: AbortSignal
|
|
8
|
+
): Promise<void> =>
|
|
9
|
+
new Promise((resolve) => {
|
|
10
|
+
const finish = () => {
|
|
11
|
+
clearTimeout(timer);
|
|
12
|
+
signal.removeEventListener('abort', finish);
|
|
13
|
+
resolve();
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const timer = setTimeout(finish, delayMs);
|
|
17
|
+
signal.addEventListener('abort', finish);
|
|
18
|
+
});
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { SseStatus } from './types';
|
|
2
|
+
|
|
3
|
+
export type TransitionEvent =
|
|
4
|
+
| 'connect'
|
|
5
|
+
| 'hello'
|
|
6
|
+
| 'retryableFailure'
|
|
7
|
+
| 'fatalFailure'
|
|
8
|
+
| 'readyToRetry'
|
|
9
|
+
| 'disconnect';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* @description 상태 전이 규칙을 명시합니다
|
|
13
|
+
*/
|
|
14
|
+
const TRANSITIONS = {
|
|
15
|
+
idle: { connect: 'connecting', disconnect: 'closed' },
|
|
16
|
+
connecting: {
|
|
17
|
+
hello: 'open',
|
|
18
|
+
retryableFailure: 'reconnecting',
|
|
19
|
+
fatalFailure: 'fatal',
|
|
20
|
+
disconnect: 'closed',
|
|
21
|
+
},
|
|
22
|
+
open: {
|
|
23
|
+
retryableFailure: 'reconnecting',
|
|
24
|
+
fatalFailure: 'fatal',
|
|
25
|
+
disconnect: 'closed',
|
|
26
|
+
},
|
|
27
|
+
reconnecting: { readyToRetry: 'connecting', disconnect: 'closed' },
|
|
28
|
+
// closed 후 재연결을 열어둔 이유: React StrictMode 재마운트가 같은 인스턴스로 connect→disconnect→connect를 수행합니다
|
|
29
|
+
closed: { connect: 'connecting' },
|
|
30
|
+
fatal: { disconnect: 'closed' },
|
|
31
|
+
} satisfies Record<SseStatus, Partial<Record<TransitionEvent, SseStatus>>>;
|
|
32
|
+
|
|
33
|
+
/** @description 연결 상태머신 — 표에 선언된 전이만 수행하고, 전이가 일어날 때 onChange를 호출합니다 */
|
|
34
|
+
export const createStatusMachine = (onChange: (status: SseStatus) => void) => {
|
|
35
|
+
let status: SseStatus = 'idle';
|
|
36
|
+
|
|
37
|
+
return {
|
|
38
|
+
get status(): SseStatus {
|
|
39
|
+
return status;
|
|
40
|
+
},
|
|
41
|
+
transition: (event: TransitionEvent): boolean => {
|
|
42
|
+
const transitions: Partial<Record<TransitionEvent, SseStatus>> =
|
|
43
|
+
TRANSITIONS[status];
|
|
44
|
+
const next = transitions[event];
|
|
45
|
+
if (next === undefined) {
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
status = next;
|
|
50
|
+
onChange(next);
|
|
51
|
+
return true;
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
export type StatusMachine = ReturnType<typeof createStatusMachine>;
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { isSubscribableEvent, SERVER_CONNECTION_EVENTS } from './eventNames';
|
|
2
|
+
import type { GoawayPayload } from './types';
|
|
3
|
+
import { isGoawayAction, isGoawayReason } from './types';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* @description 스트림이 끊긴 이유.
|
|
7
|
+
* goaway는 서버가 예고하고 닫는 정상 종료라 백오프 없이 즉시 재수립하고,
|
|
8
|
+
* goaway-close는 서버가 재연결하지 말라고 한 것이라 루프를 끝냅니다.
|
|
9
|
+
*/
|
|
10
|
+
export type StreamOutcome = 'error' | 'goaway' | 'goaway-close';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* @description 끊김의 전말 — goaway 계열에만 페이로드가 붙습니다.
|
|
14
|
+
* 유니온으로 묶어 "outcome이 goaway일 때만 payload가 있다"를 타입이 강제하게 합니다.
|
|
15
|
+
*/
|
|
16
|
+
export type StreamClosure =
|
|
17
|
+
| { outcome: 'error' }
|
|
18
|
+
| { outcome: 'goaway' | 'goaway-close'; goaway: GoawayPayload };
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* @description 한 EventSource에 리스너를 이미 건 이벤트 이름들 — 중복 부착을 막는 장부입니다.
|
|
22
|
+
* 소스가 바뀌면 리스너도 함께 사라지므로, 이 장부는 소스와 수명을 같이해야 합니다.
|
|
23
|
+
*/
|
|
24
|
+
export type AttachedEvents = Set<string>;
|
|
25
|
+
|
|
26
|
+
const parsePayload = (event: { data?: unknown }): unknown => {
|
|
27
|
+
if (typeof event.data !== 'string' || event.data === '') {
|
|
28
|
+
return undefined;
|
|
29
|
+
}
|
|
30
|
+
try {
|
|
31
|
+
return JSON.parse(event.data);
|
|
32
|
+
} catch {
|
|
33
|
+
return event.data;
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
// 구버전 서버·잘린 프레임에서는 필드가 없거나 미지 값이 옵니다 — 아는 값만 남기고 나머지는 버립니다
|
|
38
|
+
const toGoawayPayload = (parsed: unknown): GoawayPayload => {
|
|
39
|
+
if (typeof parsed !== 'object' || parsed === null) {
|
|
40
|
+
return {};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const { reason, action } = parsed as { reason?: unknown; action?: unknown };
|
|
44
|
+
|
|
45
|
+
return {
|
|
46
|
+
...(isGoawayReason(reason) && { reason }),
|
|
47
|
+
...(isGoawayAction(action) && { action }),
|
|
48
|
+
};
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* @description 도메인 이벤트 리스너를 소스에 1회 부착합니다 — SDK 예약 이벤트는 소비처로 흘리지 않습니다
|
|
53
|
+
*/
|
|
54
|
+
export const attachDomainListener = (
|
|
55
|
+
source: EventSource,
|
|
56
|
+
attachedEvents: AttachedEvents,
|
|
57
|
+
type: string,
|
|
58
|
+
deliver: (type: string, payload: unknown) => void
|
|
59
|
+
): void => {
|
|
60
|
+
if (attachedEvents.has(type)) {
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
if (!isSubscribableEvent(type)) {
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
attachedEvents.add(type);
|
|
68
|
+
source.addEventListener(type, (event) => {
|
|
69
|
+
deliver(type, parsePayload(event));
|
|
70
|
+
});
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
export interface StreamHooks {
|
|
74
|
+
helloTimeoutMs: number;
|
|
75
|
+
/** @description hello 프레임 수신 — 수립으로 인정하면 true를 반환해 타임아웃을 해제시킵니다 */
|
|
76
|
+
onHello: () => boolean;
|
|
77
|
+
/**
|
|
78
|
+
* @description 끊김 확정 — resolve 전에 동기로 호출됩니다 (정리·상태 전이는 호출자 몫).
|
|
79
|
+
* goaway면 페이로드가 함께 옵니다: 소비처가 reason으로 안내를 정하도록 그대로 넘깁니다.
|
|
80
|
+
*/
|
|
81
|
+
onClosed: (closure: StreamClosure) => void;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* @description 콜백 세계(EventSource 이벤트·타이머·abort)를 Promise 하나로 바꾸는 경계 어댑터.
|
|
86
|
+
* 아래 결말 4개 중 먼저 발생하는 것으로 정확히 한 번 끝나고, hello는 결말이 아니라 수립 신호입니다.
|
|
87
|
+
*/
|
|
88
|
+
export const streamUntilClosed = (
|
|
89
|
+
source: EventSource,
|
|
90
|
+
signal: AbortSignal,
|
|
91
|
+
hooks: StreamHooks
|
|
92
|
+
): Promise<StreamOutcome> =>
|
|
93
|
+
new Promise((resolve) => {
|
|
94
|
+
let settled = false;
|
|
95
|
+
|
|
96
|
+
const settle = (
|
|
97
|
+
closure: StreamClosure,
|
|
98
|
+
options: { notifyClosed: boolean }
|
|
99
|
+
) => {
|
|
100
|
+
if (settled) {
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
settled = true;
|
|
104
|
+
clearTimeout(helloTimer);
|
|
105
|
+
signal.removeEventListener('abort', onAbort);
|
|
106
|
+
if (options.notifyClosed) {
|
|
107
|
+
hooks.onClosed(closure);
|
|
108
|
+
}
|
|
109
|
+
resolve(closure.outcome);
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
const helloTimer = setTimeout(
|
|
113
|
+
() => settle({ outcome: 'error' }, { notifyClosed: true }),
|
|
114
|
+
hooks.helloTimeoutMs
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
source.addEventListener(SERVER_CONNECTION_EVENTS.goaway, (event) => {
|
|
118
|
+
const goaway = toGoawayPayload(parsePayload(event));
|
|
119
|
+
settle(
|
|
120
|
+
{
|
|
121
|
+
outcome: goaway.action === 'close' ? 'goaway-close' : 'goaway',
|
|
122
|
+
goaway,
|
|
123
|
+
},
|
|
124
|
+
{ notifyClosed: true }
|
|
125
|
+
);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
source.onerror = () => settle({ outcome: 'error' }, { notifyClosed: true });
|
|
129
|
+
|
|
130
|
+
const onAbort = () => settle({ outcome: 'error' }, { notifyClosed: false });
|
|
131
|
+
signal.addEventListener('abort', onAbort);
|
|
132
|
+
|
|
133
|
+
source.addEventListener(SERVER_CONNECTION_EVENTS.hello, () => {
|
|
134
|
+
if (settled) {
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
if (hooks.onHello()) {
|
|
138
|
+
clearTimeout(helloTimer);
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
});
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import type { FatalCause, SseClientOptions, SseLogger } from './types';
|
|
2
|
+
|
|
3
|
+
// 티켓 층이 만드는 cause의 단일 원천 — fatal로 그대로 이어지므로 FatalCause의 부분집합이어야 합니다(satisfies가 강제)
|
|
4
|
+
const TICKET_FAILURE_CAUSES = [
|
|
5
|
+
'auth_callback',
|
|
6
|
+
] as const satisfies readonly FatalCause[];
|
|
7
|
+
|
|
8
|
+
type TicketFailureCause = (typeof TICKET_FAILURE_CAUSES)[number];
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* @description 티켓 발급 실패의 분류 — status 부재 = 네트워크성, cause = 네트워크가 아닌 실패의 출처.
|
|
12
|
+
* 워커 프로토콜이 이 객체를 그대로 실어 나르므로 직렬화 가능한 값만 담습니다.
|
|
13
|
+
*/
|
|
14
|
+
export interface TicketError {
|
|
15
|
+
status?: number;
|
|
16
|
+
// 와이어 너머의 신·구 버전이 미지 cause를 보낼 수 있습니다 — 코어는 아는 값만 동등 비교로 골라 씁니다
|
|
17
|
+
cause?: TicketFailureCause | (string & Record<never, never>);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export type TicketResult =
|
|
21
|
+
| { ok: true; ticket: string }
|
|
22
|
+
| { ok: false; error: TicketError };
|
|
23
|
+
|
|
24
|
+
type ServerContractOptions = Pick<
|
|
25
|
+
SseClientOptions,
|
|
26
|
+
'baseUrl' | 'getAuthToken' | 'fetch' | 'logger'
|
|
27
|
+
>;
|
|
28
|
+
|
|
29
|
+
export const TICKET_TIMEOUT_MS = 10_000;
|
|
30
|
+
|
|
31
|
+
export const TICKET_NETWORK_FAILURE: TicketResult = Object.freeze({
|
|
32
|
+
ok: false,
|
|
33
|
+
error: Object.freeze({}),
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
const TICKET_AUTH_CALLBACK_FAILURE: TicketResult = Object.freeze({
|
|
37
|
+
ok: false,
|
|
38
|
+
error: Object.freeze({ cause: 'auth_callback' as const }),
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
class AuthTokenError extends Error {
|
|
42
|
+
constructor(message: string, options?: ErrorOptions) {
|
|
43
|
+
super(message, options);
|
|
44
|
+
this.name = 'AuthTokenError';
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const resolveAuthToken = async (
|
|
49
|
+
getAuthToken: ServerContractOptions['getAuthToken']
|
|
50
|
+
): Promise<string | null> => {
|
|
51
|
+
try {
|
|
52
|
+
return await getAuthToken();
|
|
53
|
+
} catch (error) {
|
|
54
|
+
throw new AuthTokenError('getAuthToken threw', { cause: error });
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const authHeaders = (token: string | null) =>
|
|
59
|
+
token === null ? undefined : { Authorization: `Bearer ${token}` };
|
|
60
|
+
|
|
61
|
+
const hasTicket = (body: unknown): body is { data: { ticket: string } } => {
|
|
62
|
+
if (typeof body !== 'object' || body === null) {
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
const { data } = body as { data?: unknown };
|
|
66
|
+
return (
|
|
67
|
+
typeof data === 'object' &&
|
|
68
|
+
data !== null &&
|
|
69
|
+
typeof (data as { ticket?: unknown }).ticket === 'string'
|
|
70
|
+
);
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* @description 본문을 계약({ data: { ticket: string } } — 사내 기본 응답 스키마)으로 좁혀 값으로 돌려줍니다 —
|
|
75
|
+
* 파싱 실패(게이트웨이·프록시가 200으로 주는 HTML 등)와 형태 위반(ticket 부재)을 가르지 않습니다:
|
|
76
|
+
* 어느 쪽이든 서버가 응답한 본문 계약 위반이고, 경로 생존 증거라 status를 실어 네트워크성 연속 판정을 끊습니다.
|
|
77
|
+
* 이 분기는 stalled·fatal 어느 쪽에도 닿지 않는 유일한 침묵 실패라 warn을 남깁니다 — 서버 로그에도 흔적이
|
|
78
|
+
* 없는 실패(서버 입장에선 정상 200)여서 이 로그가 유일한 단서입니다.
|
|
79
|
+
*/
|
|
80
|
+
const parseTicketBody = async (
|
|
81
|
+
response: Response,
|
|
82
|
+
logger?: SseLogger
|
|
83
|
+
): Promise<TicketResult> => {
|
|
84
|
+
const body: unknown = await response.json().catch(() => null);
|
|
85
|
+
if (!hasTicket(body)) {
|
|
86
|
+
logger?.warn(
|
|
87
|
+
`sse: 티켓 응답(${response.status}) 본문이 계약({ data: { ticket } })과 다릅니다 — 재시도는 계속합니다`
|
|
88
|
+
);
|
|
89
|
+
return { ok: false, error: { status: response.status } };
|
|
90
|
+
}
|
|
91
|
+
return { ok: true, ticket: body.data.ticket };
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* @description 연결 티켓을 발급합니다 — TTL 15s이므로 발급 즉시 연결해야 합니다
|
|
96
|
+
*/
|
|
97
|
+
export const issueTicket = async (
|
|
98
|
+
options: ServerContractOptions
|
|
99
|
+
): Promise<TicketResult> => {
|
|
100
|
+
const fetchImpl = options.fetch ?? globalThis.fetch;
|
|
101
|
+
|
|
102
|
+
try {
|
|
103
|
+
const token = await resolveAuthToken(options.getAuthToken);
|
|
104
|
+
const response = await fetchImpl(`${options.baseUrl}/v1/events/ticket`, {
|
|
105
|
+
method: 'POST',
|
|
106
|
+
headers: authHeaders(token),
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
if (!response.ok) {
|
|
110
|
+
return { ok: false, error: { status: response.status } };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return await parseTicketBody(response, options.logger);
|
|
114
|
+
} catch (error) {
|
|
115
|
+
if (error instanceof AuthTokenError) {
|
|
116
|
+
// 정지 결정은 SseClient 몫이지만, 로그는 원인 예외가 있는 여기서 한 번만 — 워커 경로에선 탭 스코프라 유일한 기록입니다
|
|
117
|
+
options.logger?.error(
|
|
118
|
+
'sse: getAuthToken이 예외를 던져 멈춥니다 — 콜백은 실패 시 null을 반환해야 합니다',
|
|
119
|
+
error.cause
|
|
120
|
+
);
|
|
121
|
+
return TICKET_AUTH_CALLBACK_FAILURE;
|
|
122
|
+
}
|
|
123
|
+
return TICKET_NETWORK_FAILURE;
|
|
124
|
+
}
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* @description 티켓 발급에 시간 제한을 겁니다 — 타임아웃 이후 도착한 응답은 race가 이미 완료돼 무시됩니다
|
|
129
|
+
*/
|
|
130
|
+
export const issueTicketWithTimeout = async (
|
|
131
|
+
options: ServerContractOptions
|
|
132
|
+
): Promise<TicketResult> => {
|
|
133
|
+
let ticketTimer: ReturnType<typeof setTimeout> | undefined;
|
|
134
|
+
|
|
135
|
+
const ticketTimeout = new Promise<TicketResult>((resolve) => {
|
|
136
|
+
ticketTimer = setTimeout(
|
|
137
|
+
() => resolve(TICKET_NETWORK_FAILURE),
|
|
138
|
+
TICKET_TIMEOUT_MS
|
|
139
|
+
);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
const result = await Promise.race([issueTicket(options), ticketTimeout]);
|
|
143
|
+
clearTimeout(ticketTimer);
|
|
144
|
+
|
|
145
|
+
return result;
|
|
146
|
+
};
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import type { BackoffOptions } from './backoff';
|
|
2
|
+
|
|
3
|
+
// 상태 이름의 단일 원천 — 타입과 런타임 멤버십 검사가 같은 목록을 봅니다
|
|
4
|
+
const SSE_STATUSES = [
|
|
5
|
+
'idle',
|
|
6
|
+
'connecting',
|
|
7
|
+
'open',
|
|
8
|
+
'reconnecting',
|
|
9
|
+
'closed',
|
|
10
|
+
'fatal',
|
|
11
|
+
] as const;
|
|
12
|
+
|
|
13
|
+
export type SseStatus = (typeof SSE_STATUSES)[number];
|
|
14
|
+
|
|
15
|
+
/** @description postMessage 신뢰 경계용 — 버전이 다른 상대가 보낸 미지 상태값이 타입 보증을 깨지 않게 거릅니다 */
|
|
16
|
+
export const isSseStatus = (value: unknown): value is SseStatus =>
|
|
17
|
+
(SSE_STATUSES as readonly unknown[]).includes(value);
|
|
18
|
+
|
|
19
|
+
export type SseEventMap = Record<string, unknown>;
|
|
20
|
+
|
|
21
|
+
// goaway 값의 단일 원천 — 타입과 런타임 검사가 같은 목록을 봅니다
|
|
22
|
+
const GOAWAY_REASONS = [
|
|
23
|
+
'deploy',
|
|
24
|
+
'user_logout',
|
|
25
|
+
'maintenance',
|
|
26
|
+
'manual',
|
|
27
|
+
] as const;
|
|
28
|
+
const GOAWAY_ACTIONS = ['retry', 'close'] as const;
|
|
29
|
+
|
|
30
|
+
export type GoawayReason = (typeof GOAWAY_REASONS)[number];
|
|
31
|
+
export type GoawayAction = (typeof GOAWAY_ACTIONS)[number];
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* @description 포트·스트림 너머의 값을 좁힙니다 — 미지 값은 없는 것으로 봅니다
|
|
35
|
+
*/
|
|
36
|
+
export const isGoawayReason = (value: unknown): value is GoawayReason =>
|
|
37
|
+
(GOAWAY_REASONS as readonly unknown[]).includes(value);
|
|
38
|
+
|
|
39
|
+
export const isGoawayAction = (value: unknown): value is GoawayAction =>
|
|
40
|
+
(GOAWAY_ACTIONS as readonly unknown[]).includes(value);
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* @description 서버가 연결을 닫으며 알려주는 사정 — SDK는 action만 보고, reason 대응은 소비처 몫입니다.
|
|
44
|
+
* 구버전 서버·잘린 프레임에서는 필드가 없을 수 있어 둘 다 선택입니다.
|
|
45
|
+
*/
|
|
46
|
+
export interface GoawayPayload {
|
|
47
|
+
reason?: GoawayReason;
|
|
48
|
+
action?: GoawayAction;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// fatal 값의 단일 원천 — 소비처가 원인별 안내(재로그인·한도·환경 차단)를 가르는 기준입니다
|
|
52
|
+
const FATAL_CAUSES = [
|
|
53
|
+
'auth',
|
|
54
|
+
'auth_callback',
|
|
55
|
+
'rate_limit',
|
|
56
|
+
'blocked',
|
|
57
|
+
'config',
|
|
58
|
+
'unknown',
|
|
59
|
+
] as const;
|
|
60
|
+
|
|
61
|
+
export type FatalCause = (typeof FATAL_CAUSES)[number];
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* @description 연결이 fatal로 멈춘 사유
|
|
65
|
+
* - auth = 티켓 401/403
|
|
66
|
+
* - auth_callback = getAuthToken이 던짐 (소비처 콜백 문제 — 콜백은 실패 시 throw 대신 null을 반환해야 합니다)
|
|
67
|
+
* - rate_limit = 티켓 429 ·
|
|
68
|
+
* - blocked = 워커 생성 차단(CSP 등)
|
|
69
|
+
* - config = EventSource 생성 실패(재시도해도 되살아나지 않습니다)
|
|
70
|
+
* - unknown = 연결 루프의 예상 못 한 예외.
|
|
71
|
+
*
|
|
72
|
+
* 회복(사유 해소 후 disconnect → connect)은 이 이벤트의 핸들러에서 시작하세요
|
|
73
|
+
* - status 리스너에서 끊으면 워커 경로에선 뒤따라오던 사유가 포트와 함께 버려집니다.
|
|
74
|
+
* 사유는 발화 순간의 구독자에게만 배달되므로 핸들러는 앱 루트에 고정하세요.
|
|
75
|
+
*/
|
|
76
|
+
export interface FatalPayload {
|
|
77
|
+
cause: FatalCause;
|
|
78
|
+
/** @description 티켓 발급 거부일 때만 — 서버가 준 HTTP 상태 코드 */
|
|
79
|
+
status?: number;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* @description 재연결 등 SDK 수명주기 이벤트 — 도메인 이벤트 맵(M)과 병합되어 on()/wire()에 노출됩니다.
|
|
84
|
+
* `resync`·`goaway`·`fatal`·`stalled`는 SDK 예약 이벤트명이므로 서버 이벤트 맵에 같은 이름을 정의하지 않습니다.
|
|
85
|
+
*/
|
|
86
|
+
export type WithLifecycle<M extends object> = M & {
|
|
87
|
+
/**
|
|
88
|
+
* @description 재수립 후 "놓친 구간을 다시 조회하라"는 신호 — 끊긴 사이의 정합성은 REST 재조회로 메웁니다.
|
|
89
|
+
* (SDK 합성 — 와이어에 없는 이벤트입니다. 재연결 후 첫 hello에서 resync 이벤트가 발행됩니다)
|
|
90
|
+
*/
|
|
91
|
+
resync: void;
|
|
92
|
+
/**
|
|
93
|
+
* @description 서버의 예고된 끊음 — `action: 'retry'`면 SDK가 즉시 재수립하고, `'close'`면 재연결 없이 닫습니다.
|
|
94
|
+
* 배포·로그아웃·점검처럼 에러가 아닐 수 있으니 안내는 close에서만 reason별로 고려하세요.
|
|
95
|
+
* (서버 발신 — 와이어의 `event: goaway`를 그대로 전달합니다)
|
|
96
|
+
*/
|
|
97
|
+
goaway: GoawayPayload;
|
|
98
|
+
/**
|
|
99
|
+
* @description SDK의 포기 선언 — 재시도 없이 멈췄습니다. `cause`로 사유를 가르고,
|
|
100
|
+
* 사유 해소 후 disconnect() → connect()로 회복합니다.
|
|
101
|
+
* (SDK 합성 — 와이어에 없는 이벤트입니다. SDK가 실패를 판정해 발화합니다)
|
|
102
|
+
*/
|
|
103
|
+
fatal: FatalPayload;
|
|
104
|
+
/**
|
|
105
|
+
* @description 네트워크 에러가 지속되고 있다는 진행형 통지 — fatal과 달리 재시도는 계속됩니다.
|
|
106
|
+
* 응답 status가 없는 실패(오프라인·CORS 차단·접속 거부 — 브라우저는 서로 구분해주지 않습니다)가
|
|
107
|
+
* hello에 닿지 못한 채 연속 5회 이어지는 순간 1회 발화합니다. 서버가 응답한 거부(5xx)는
|
|
108
|
+
* 네트워크 경로가 살아 있다는 증거라 세지 않고 연속을 끊습니다.
|
|
109
|
+
* 회복 시 별도 이벤트는 없습니다 — status가 open으로 바뀌는 것으로 알 수 있고, 판정은 그때 리셋됩니다.
|
|
110
|
+
* (SDK 합성 — 와이어에 없는 이벤트입니다. SDK가 연속 실패를 세어 발화합니다)
|
|
111
|
+
*/
|
|
112
|
+
stalled: void;
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
export interface SseLogger {
|
|
116
|
+
warn(message: string, meta?: unknown): void;
|
|
117
|
+
error(message: string, meta?: unknown): void;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export interface SseClientOptions {
|
|
121
|
+
baseUrl: string;
|
|
122
|
+
/**
|
|
123
|
+
* @description 티켓 발급 요청에만 사용합니다 — null이면 인증 헤더 없이 요청합니다.
|
|
124
|
+
* 실패는 throw 대신 null로 알리세요 — 예외가 새어 나오면 재시도 없이 fatal(cause: 'auth_callback')로 멈춥니다.
|
|
125
|
+
*/
|
|
126
|
+
getAuthToken: () => string | null | Promise<string | null>;
|
|
127
|
+
fetch?: typeof globalThis.fetch;
|
|
128
|
+
/** @description 워커 경로(sharedWorker 제공 시)에서는 적용되지 않습니다 — 스트림은 워커 스코프에서 만들어집니다 */
|
|
129
|
+
eventSource?: typeof EventSource;
|
|
130
|
+
backoff?: Partial<BackoffOptions>;
|
|
131
|
+
logger?: SseLogger;
|
|
132
|
+
/**
|
|
133
|
+
* @description 탭 간 연결 공유용 SharedWorker 팩토리 — 미제공 시 탭마다 직접 연결합니다.
|
|
134
|
+
* 워커 스크립트 URL은 소비처 번들러가 해석해야 하므로 인스턴스가 아니라 팩토리를 받습니다.
|
|
135
|
+
* @example () => new SharedWorker(new URL('@remember-web/sse/worker', import.meta.url), { name: 'rwp-sse', type: 'module' })
|
|
136
|
+
*/
|
|
137
|
+
sharedWorker?: () => SharedWorker;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export type SseEventHandler<Payload> = (payload: Payload) => void;
|
|
141
|
+
|
|
142
|
+
export type Unsubscribe = () => void;
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* @description 리터럴 유니온에 흡수되지 않는 string 인터섹션으로, 자동완성을 유지한 채 임의 이름을 허용합니다
|
|
146
|
+
*/
|
|
147
|
+
export type SseEventName<M extends object> =
|
|
148
|
+
| (keyof WithLifecycle<M> & string)
|
|
149
|
+
| (string & Record<never, never>);
|
|
150
|
+
|
|
151
|
+
/** @description 맵에 있는 이름이면 그 페이로드, 없으면 unknown — "미지 이벤트는 무시" 런타임과 타입을 일치시킵니다 */
|
|
152
|
+
export type PayloadOf<
|
|
153
|
+
M extends object,
|
|
154
|
+
K extends string,
|
|
155
|
+
> = K extends keyof WithLifecycle<M> ? WithLifecycle<M>[K] : unknown;
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* @description 클라이언트 구현들이 공유하는 공개 표면 — M은 interface·type 어느 쪽 맵도 받도록 object로 제약합니다
|
|
159
|
+
*/
|
|
160
|
+
export interface SseClientLike<M extends object = SseEventMap> {
|
|
161
|
+
/** @description 타입 전용 마커 — M이 조건부 타입 안에만 있으면 wire() 등에서 M 추론이 불가능해집니다 */
|
|
162
|
+
readonly __eventMap?: M;
|
|
163
|
+
readonly status: SseStatus;
|
|
164
|
+
connect(): void;
|
|
165
|
+
disconnect(): void;
|
|
166
|
+
/**
|
|
167
|
+
* @description 이벤트 구독 — 해제 함수를 반환합니다. 구독은 connect()보다 먼저 걸어두세요.
|
|
168
|
+
* @param type 이벤트 이름. 도메인 이벤트 외 예약 4종 —
|
|
169
|
+
* `fatal`(SDK의 포기 선언 — 재시도 없이 멈춤, SDK 합성) ·
|
|
170
|
+
* `stalled`(재시도가 계속되는 진행형 통지, SDK 합성) ·
|
|
171
|
+
* `goaway`(서버의 예고된 끊음 — `action: 'close'`만 안내 대상, 서버 발신) ·
|
|
172
|
+
* `resync`(재수립 후 놓친 구간을 REST로 재조회하라는 신호, SDK 합성). 상세는 `WithLifecycle` 문서 참조.
|
|
173
|
+
* @param handler 이벤트 페이로드를 받는 콜백 — 발화 순간의 구독자에게만 배달됩니다
|
|
174
|
+
*/
|
|
175
|
+
on<K extends SseEventName<M>>(
|
|
176
|
+
type: K,
|
|
177
|
+
handler: SseEventHandler<PayloadOf<M, K>>
|
|
178
|
+
): Unsubscribe;
|
|
179
|
+
onStatus(listener: (status: SseStatus) => void): Unsubscribe;
|
|
180
|
+
/** @description 탭이 돌아올 때 자동으로 connect()를 부릅니다 — 반환값으로 떼세요 */
|
|
181
|
+
attachAutoResume(): Unsubscribe;
|
|
182
|
+
}
|
package/src/core/wire.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
SseClientLike,
|
|
3
|
+
SseEventHandler,
|
|
4
|
+
Unsubscribe,
|
|
5
|
+
WithLifecycle,
|
|
6
|
+
} from './types';
|
|
7
|
+
|
|
8
|
+
export type WireHandlers<M extends object> = {
|
|
9
|
+
[K in keyof WithLifecycle<M>]?: SseEventHandler<WithLifecycle<M>[K]>;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
/** @description 이벤트 핸들러 맵을 일괄 등록하고 전체 해제 함수를 반환합니다 — 호출 가능한 값만 등록합니다 */
|
|
13
|
+
export function wire<M extends object>(
|
|
14
|
+
client: SseClientLike<M>,
|
|
15
|
+
handlers: WireHandlers<NoInfer<M>>
|
|
16
|
+
): Unsubscribe {
|
|
17
|
+
const unsubscribers = Object.entries(handlers)
|
|
18
|
+
.filter(([, handler]) => typeof handler === 'function')
|
|
19
|
+
.map(([type, handler]) =>
|
|
20
|
+
client.on(type, handler as SseEventHandler<unknown>)
|
|
21
|
+
);
|
|
22
|
+
return () => unsubscribers.forEach((unsubscribe) => unsubscribe());
|
|
23
|
+
}
|