@spfn/core 0.2.0-beta.48 → 0.2.0-beta.49
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.
|
@@ -56,42 +56,6 @@ interface SSEClient<TRouter extends EventRouterDef<any>> {
|
|
|
56
56
|
*/
|
|
57
57
|
close(): void;
|
|
58
58
|
}
|
|
59
|
-
/**
|
|
60
|
-
* Create type-safe SSE client
|
|
61
|
-
*
|
|
62
|
-
* @example
|
|
63
|
-
* ```typescript
|
|
64
|
-
* // Uses defaults (NEXT_PUBLIC_SPFN_API_URL + /events/stream)
|
|
65
|
-
* const client = createSSEClient<EventRouter>();
|
|
66
|
-
*
|
|
67
|
-
* // Or with custom configuration
|
|
68
|
-
* const client = createSSEClient<EventRouter>({
|
|
69
|
-
* host: 'https://api.example.com',
|
|
70
|
-
* pathname: '/sse',
|
|
71
|
-
* reconnect: true,
|
|
72
|
-
* reconnectDelay: 3000,
|
|
73
|
-
* });
|
|
74
|
-
*
|
|
75
|
-
* // Subscribe to events
|
|
76
|
-
* const unsubscribe = client.subscribe({
|
|
77
|
-
* events: ['userCreated', 'orderPlaced'],
|
|
78
|
-
* handlers: {
|
|
79
|
-
* userCreated: (payload) => {
|
|
80
|
-
* console.log('New user:', payload.userId);
|
|
81
|
-
* },
|
|
82
|
-
* orderPlaced: (payload) => {
|
|
83
|
-
* console.log('New order:', payload.orderId);
|
|
84
|
-
* },
|
|
85
|
-
* },
|
|
86
|
-
* onOpen: () => console.log('Connected'),
|
|
87
|
-
* onError: (err) => console.error('Error:', err),
|
|
88
|
-
* onReconnect: (attempt) => console.log('Reconnecting...', attempt),
|
|
89
|
-
* });
|
|
90
|
-
*
|
|
91
|
-
* // Cleanup
|
|
92
|
-
* unsubscribe();
|
|
93
|
-
* ```
|
|
94
|
-
*/
|
|
95
59
|
declare function createSSEClient<TRouter extends EventRouterDef<any>>(config?: SSEClientConfig): SSEClient<TRouter>;
|
|
96
60
|
/**
|
|
97
61
|
* Simple subscribe function for one-off subscriptions
|
package/dist/event/sse/client.js
CHANGED
|
@@ -15,14 +15,40 @@ function createSSEClient(config = {}) {
|
|
|
15
15
|
acquireToken
|
|
16
16
|
} = config;
|
|
17
17
|
const baseUrl = url || `${host}${pathname}`;
|
|
18
|
-
let eventSource = null;
|
|
19
18
|
let state = "closed";
|
|
20
|
-
let
|
|
21
|
-
|
|
22
|
-
|
|
19
|
+
let active = null;
|
|
20
|
+
function closeConn(conn) {
|
|
21
|
+
if (conn.closed) {
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
conn.closed = true;
|
|
25
|
+
if (conn.reconnectTimer) {
|
|
26
|
+
clearTimeout(conn.reconnectTimer);
|
|
27
|
+
conn.reconnectTimer = null;
|
|
28
|
+
}
|
|
29
|
+
if (conn.eventSource) {
|
|
30
|
+
conn.eventSource.close();
|
|
31
|
+
conn.eventSource = null;
|
|
32
|
+
}
|
|
33
|
+
if (active === conn) {
|
|
34
|
+
active = null;
|
|
35
|
+
state = "closed";
|
|
36
|
+
}
|
|
37
|
+
conn.onClose?.();
|
|
38
|
+
}
|
|
23
39
|
function subscribe(options) {
|
|
24
40
|
const { events, handlers, onOpen, onError, onReconnect, onClose } = options;
|
|
25
|
-
|
|
41
|
+
if (active) {
|
|
42
|
+
closeConn(active);
|
|
43
|
+
}
|
|
44
|
+
const conn = {
|
|
45
|
+
eventSource: null,
|
|
46
|
+
reconnectTimer: null,
|
|
47
|
+
reconnectAttempts: 0,
|
|
48
|
+
closed: false,
|
|
49
|
+
onClose
|
|
50
|
+
};
|
|
51
|
+
active = conn;
|
|
26
52
|
const eventNames = events;
|
|
27
53
|
function connect() {
|
|
28
54
|
state = "connecting";
|
|
@@ -30,16 +56,25 @@ function createSSEClient(config = {}) {
|
|
|
30
56
|
let tokenParam = "";
|
|
31
57
|
if (acquireToken) {
|
|
32
58
|
const token = await acquireToken();
|
|
59
|
+
if (conn.closed) {
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
33
62
|
tokenParam = `&token=${encodeURIComponent(token)}`;
|
|
34
63
|
}
|
|
64
|
+
if (conn.closed) {
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
35
67
|
const streamUrl = `${baseUrl}?events=${eventNames.join(",")}${tokenParam}`;
|
|
36
|
-
eventSource = new EventSource(streamUrl, {
|
|
68
|
+
conn.eventSource = new EventSource(streamUrl, {
|
|
37
69
|
withCredentials
|
|
38
70
|
});
|
|
39
|
-
setupEventHandlers(eventSource, eventNames, handlers, onOpen, onError);
|
|
71
|
+
setupEventHandlers(conn.eventSource, eventNames, handlers, onOpen, onError);
|
|
40
72
|
setupReconnect(onReconnect);
|
|
41
73
|
};
|
|
42
74
|
init().catch(() => {
|
|
75
|
+
if (conn.closed) {
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
43
78
|
state = "error";
|
|
44
79
|
attemptReconnect(onReconnect);
|
|
45
80
|
});
|
|
@@ -47,7 +82,7 @@ function createSSEClient(config = {}) {
|
|
|
47
82
|
function setupEventHandlers(es, names, handlerMap, onOpenCb, onErrorCb) {
|
|
48
83
|
es.onopen = () => {
|
|
49
84
|
state = "open";
|
|
50
|
-
reconnectAttempts = 0;
|
|
85
|
+
conn.reconnectAttempts = 0;
|
|
51
86
|
onOpenCb?.();
|
|
52
87
|
};
|
|
53
88
|
es.onerror = (error) => {
|
|
@@ -79,10 +114,10 @@ function createSSEClient(config = {}) {
|
|
|
79
114
|
}
|
|
80
115
|
}
|
|
81
116
|
function setupReconnect(onReconnectCb) {
|
|
82
|
-
if (!eventSource) {
|
|
117
|
+
if (!conn.eventSource) {
|
|
83
118
|
return;
|
|
84
119
|
}
|
|
85
|
-
const currentEs = eventSource;
|
|
120
|
+
const currentEs = conn.eventSource;
|
|
86
121
|
const originalOnError = currentEs.onerror;
|
|
87
122
|
currentEs.onerror = (error) => {
|
|
88
123
|
if (originalOnError) {
|
|
@@ -97,48 +132,33 @@ function createSSEClient(config = {}) {
|
|
|
97
132
|
};
|
|
98
133
|
}
|
|
99
134
|
function attemptReconnect(onReconnectCb) {
|
|
100
|
-
if (!reconnect) {
|
|
135
|
+
if (conn.closed || !reconnect) {
|
|
101
136
|
return;
|
|
102
137
|
}
|
|
103
|
-
if (maxReconnectAttempts > 0 && reconnectAttempts >= maxReconnectAttempts) {
|
|
104
|
-
|
|
105
|
-
onClose?.();
|
|
138
|
+
if (maxReconnectAttempts > 0 && conn.reconnectAttempts >= maxReconnectAttempts) {
|
|
139
|
+
closeConn(conn);
|
|
106
140
|
return;
|
|
107
141
|
}
|
|
108
|
-
reconnectAttempts++;
|
|
109
|
-
onReconnectCb?.(reconnectAttempts);
|
|
110
|
-
reconnectTimer = setTimeout(() => {
|
|
142
|
+
conn.reconnectAttempts++;
|
|
143
|
+
onReconnectCb?.(conn.reconnectAttempts);
|
|
144
|
+
conn.reconnectTimer = setTimeout(() => {
|
|
111
145
|
connect();
|
|
112
146
|
}, reconnectDelay);
|
|
113
147
|
}
|
|
114
148
|
connect();
|
|
115
149
|
return () => {
|
|
116
|
-
|
|
117
|
-
clearTimeout(reconnectTimer);
|
|
118
|
-
reconnectTimer = null;
|
|
119
|
-
}
|
|
120
|
-
if (eventSource) {
|
|
121
|
-
eventSource.close();
|
|
122
|
-
eventSource = null;
|
|
123
|
-
}
|
|
124
|
-
state = "closed";
|
|
125
|
-
onClose?.();
|
|
150
|
+
closeConn(conn);
|
|
126
151
|
};
|
|
127
152
|
}
|
|
128
153
|
function getState() {
|
|
129
154
|
return state;
|
|
130
155
|
}
|
|
131
156
|
function close() {
|
|
132
|
-
if (
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
if (eventSource) {
|
|
137
|
-
eventSource.close();
|
|
138
|
-
eventSource = null;
|
|
157
|
+
if (active) {
|
|
158
|
+
closeConn(active);
|
|
159
|
+
} else {
|
|
160
|
+
state = "closed";
|
|
139
161
|
}
|
|
140
|
-
state = "closed";
|
|
141
|
-
activeOnClose?.();
|
|
142
162
|
}
|
|
143
163
|
return {
|
|
144
164
|
subscribe,
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../src/event/sse/client.ts"],"names":[],"mappings":"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","file":"client.js","sourcesContent":["/**\n * SSE Client\n *\n * Type-safe EventSource wrapper for event subscription\n *\n * @example\n * ```typescript\n * import { createSSEClient } from '@spfn/core/event/sse/client';\n * import type { EventRouter } from '@/server/events';\n *\n * // Uses defaults: NEXT_PUBLIC_SPFN_API_URL + /events/stream\n * const client = createSSEClient<EventRouter>();\n *\n * // Or with custom host/pathname\n * const client = createSSEClient<EventRouter>({\n * host: 'https://api.example.com',\n * pathname: '/sse',\n * });\n *\n * // With token authentication (recommended: use createAuthSSEClient)\n * import { createAuthSSEClient } from '@spfn/core/event/sse/client';\n * const client = createAuthSSEClient<EventRouter>();\n *\n * const unsubscribe = client.subscribe({\n * events: ['userCreated', 'orderPlaced'],\n * handlers: {\n * userCreated: (payload) => console.log('User:', payload.userId),\n * orderPlaced: (payload) => console.log('Order:', payload.orderId),\n * },\n * });\n *\n * // Later: cleanup\n * unsubscribe();\n * ```\n */\n\nimport type { EventRouterDef, InferEventNames } from '../router';\nimport type {\n SSEClientConfig,\n SSESubscribeOptions,\n SSEUnsubscribe,\n SSEConnectionState,\n SSEMessage,\n} from './types';\n\n/**\n * SSE Client instance\n */\nexport interface SSEClient<TRouter extends EventRouterDef<any>>\n{\n /**\n * Subscribe to events\n */\n subscribe(options: SSESubscribeOptions<TRouter>): SSEUnsubscribe;\n\n /**\n * Get current connection state\n */\n getState(): SSEConnectionState;\n\n /**\n * Close all connections\n */\n close(): void;\n}\n\n/**\n * Default SSE configuration\n */\nconst SSE_DEFAULTS = {\n host: typeof process !== 'undefined'\n ? (process.env.NEXT_PUBLIC_SPFN_API_URL || 'http://localhost:8790')\n : 'http://localhost:8790',\n pathname: '/events/stream',\n} as const;\n\n/**\n * Create type-safe SSE client\n *\n * @example\n * ```typescript\n * // Uses defaults (NEXT_PUBLIC_SPFN_API_URL + /events/stream)\n * const client = createSSEClient<EventRouter>();\n *\n * // Or with custom configuration\n * const client = createSSEClient<EventRouter>({\n * host: 'https://api.example.com',\n * pathname: '/sse',\n * reconnect: true,\n * reconnectDelay: 3000,\n * });\n *\n * // Subscribe to events\n * const unsubscribe = client.subscribe({\n * events: ['userCreated', 'orderPlaced'],\n * handlers: {\n * userCreated: (payload) => {\n * console.log('New user:', payload.userId);\n * },\n * orderPlaced: (payload) => {\n * console.log('New order:', payload.orderId);\n * },\n * },\n * onOpen: () => console.log('Connected'),\n * onError: (err) => console.error('Error:', err),\n * onReconnect: (attempt) => console.log('Reconnecting...', attempt),\n * });\n *\n * // Cleanup\n * unsubscribe();\n * ```\n */\nexport function createSSEClient<TRouter extends EventRouterDef<any>>(\n config: SSEClientConfig = {}\n): SSEClient<TRouter>\n{\n const {\n url,\n host = SSE_DEFAULTS.host,\n pathname = SSE_DEFAULTS.pathname,\n reconnect = true,\n reconnectDelay = 3000,\n maxReconnectAttempts = 0,\n withCredentials = false,\n acquireToken,\n } = config;\n\n // Build base URL: url takes precedence, otherwise host + pathname\n const baseUrl = url || `${host}${pathname}`;\n\n let eventSource: EventSource | null = null;\n let state: SSEConnectionState = 'closed';\n let reconnectAttempts = 0;\n let reconnectTimer: ReturnType<typeof setTimeout> | null = null;\n let activeOnClose: (() => void) | undefined;\n\n function subscribe(options: SSESubscribeOptions<TRouter>): SSEUnsubscribe\n {\n const { events, handlers, onOpen, onError, onReconnect, onClose } = options;\n activeOnClose = onClose;\n\n const eventNames = events as string[];\n\n function connect()\n {\n state = 'connecting';\n\n const init = async () =>\n {\n let tokenParam = '';\n\n if (acquireToken)\n {\n const token = await acquireToken();\n tokenParam = `&token=${encodeURIComponent(token)}`;\n }\n\n const streamUrl = `${baseUrl}?events=${eventNames.join(',')}${tokenParam}`;\n\n eventSource = new EventSource(streamUrl, {\n withCredentials,\n });\n\n setupEventHandlers(eventSource, eventNames, handlers, onOpen, onError);\n setupReconnect(onReconnect);\n };\n\n init().catch(() =>\n {\n state = 'error';\n attemptReconnect(onReconnect);\n });\n }\n\n function setupEventHandlers(\n es: EventSource,\n names: string[],\n handlerMap: SSESubscribeOptions<TRouter>['handlers'],\n onOpenCb?: () => void,\n onErrorCb?: (error: Event) => void\n )\n {\n es.onopen = () =>\n {\n state = 'open';\n reconnectAttempts = 0;\n onOpenCb?.();\n };\n\n es.onerror = (error) =>\n {\n state = 'error';\n onErrorCb?.(error);\n };\n\n // Handle connected event (server sends this on connection)\n es.addEventListener('connected', (e: MessageEvent) =>\n {\n try\n {\n const data = JSON.parse(e.data);\n console.debug('[SSE] Connected:', data);\n }\n catch\n {\n // Ignore parse errors\n }\n });\n\n // Handle ping (keep-alive)\n es.addEventListener('ping', () =>\n {\n // Ping received, connection is alive\n });\n\n // Register handlers for each event\n for (const eventName of names)\n {\n const handler = (handlerMap as Record<string, ((payload: unknown) => void) | undefined>)[eventName];\n\n if (!handler)\n {\n continue;\n }\n\n es.addEventListener(eventName, (e: MessageEvent) =>\n {\n try\n {\n const message: SSEMessage = JSON.parse(e.data);\n handler(message.data);\n }\n catch (err)\n {\n console.error(`[SSE] Failed to parse event \"${eventName}\":`, err);\n }\n });\n }\n }\n\n function setupReconnect(onReconnectCb?: (attempt: number) => void)\n {\n if (!eventSource)\n {\n return;\n }\n\n const currentEs = eventSource;\n const originalOnError = currentEs.onerror;\n\n currentEs.onerror = (error) =>\n {\n if (originalOnError)\n {\n (originalOnError as (ev: Event) => void)(error);\n }\n\n // Token-auth 사용 시 브라우저 auto-retry는 소비된 토큰으로 재시도하므로\n // 즉시 close하고 우리 reconnect로 새 토큰 발급받아 재연결\n if (reconnect && acquireToken)\n {\n currentEs.close();\n attemptReconnect(onReconnectCb);\n }\n else if (reconnect && currentEs.readyState === EventSource.CLOSED)\n {\n attemptReconnect(onReconnectCb);\n }\n };\n }\n\n function attemptReconnect(onReconnectCb?: (attempt: number) => void)\n {\n if (!reconnect)\n {\n return;\n }\n\n if (maxReconnectAttempts > 0 && reconnectAttempts >= maxReconnectAttempts)\n {\n state = 'closed';\n onClose?.();\n return;\n }\n\n reconnectAttempts++;\n onReconnectCb?.(reconnectAttempts);\n\n reconnectTimer = setTimeout(() =>\n {\n connect();\n }, reconnectDelay);\n }\n\n // Start connection\n connect();\n\n // Return unsubscribe function\n return () =>\n {\n if (reconnectTimer)\n {\n clearTimeout(reconnectTimer);\n reconnectTimer = null;\n }\n\n if (eventSource)\n {\n eventSource.close();\n eventSource = null;\n }\n\n state = 'closed';\n onClose?.();\n };\n }\n\n function getState(): SSEConnectionState\n {\n return state;\n }\n\n function close()\n {\n if (reconnectTimer)\n {\n clearTimeout(reconnectTimer);\n reconnectTimer = null;\n }\n\n if (eventSource)\n {\n eventSource.close();\n eventSource = null;\n }\n\n state = 'closed';\n activeOnClose?.();\n }\n\n return {\n subscribe,\n getState,\n close,\n };\n}\n\n/**\n * Simple subscribe function for one-off subscriptions\n *\n * @example\n * ```typescript\n * import { subscribeToEvents } from '@spfn/core/event/sse/client';\n * import type { EventRouter } from '@/server/events';\n *\n * // Using defaults\n * const unsubscribe = subscribeToEvents<EventRouter>(\n * ['userCreated', 'orderPlaced'],\n * {\n * userCreated: (payload) => console.log('User:', payload),\n * orderPlaced: (payload) => console.log('Order:', payload),\n * }\n * );\n *\n * // With custom host\n * const unsubscribe = subscribeToEvents<EventRouter>(\n * ['userCreated'],\n * { userCreated: (payload) => console.log('User:', payload) },\n * { host: 'https://api.example.com' }\n * );\n * ```\n */\nexport function subscribeToEvents<TRouter extends EventRouterDef<any>>(\n events: InferEventNames<TRouter>[],\n handlers: SSESubscribeOptions<TRouter>['handlers'],\n options?: SSEClientConfig\n): SSEUnsubscribe\n{\n const client = createSSEClient<TRouter>(options);\n\n return client.subscribe({\n events,\n handlers,\n });\n}\n\n// ============================================================================\n// Auth SSE Client\n// ============================================================================\n\n/**\n * SSE client configuration for authenticated connections\n *\n * Same as SSEClientConfig but without acquireToken (auto-configured).\n */\nexport interface AuthSSEClientConfig extends Omit<SSEClientConfig, 'acquireToken'>\n{\n /**\n * RPC proxy base URL for token acquisition\n * @default '/api/rpc'\n */\n rpcBaseUrl?: string;\n}\n\n/**\n * Create SSE client with built-in token authentication\n *\n * Acquires one-time SSE tokens via RPC proxy automatically.\n * Requires eventRouteMap to be merged into RPC proxy config.\n *\n * @example\n * ```typescript\n * import { createAuthSSEClient } from '@spfn/core/event/sse/client';\n * import type { EventRouter } from '@/server/events';\n *\n * const client = createAuthSSEClient<EventRouter>();\n *\n * client.subscribe({\n * events: ['userCreated'],\n * handlers: {\n * userCreated: (payload) => console.log(payload),\n * },\n * });\n * ```\n */\nexport function createAuthSSEClient<TRouter extends EventRouterDef<any>>(\n config: AuthSSEClientConfig = {}\n): SSEClient<TRouter>\n{\n const { rpcBaseUrl = '/api/rpc', ...sseConfig } = config;\n\n return createSSEClient<TRouter>({\n ...sseConfig,\n acquireToken: async () =>\n {\n const res = await fetch(`${rpcBaseUrl}/eventsToken`, {\n method: 'POST',\n credentials: 'include',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({}),\n });\n\n if (!res.ok)\n {\n throw new Error(`Failed to acquire SSE token: ${res.status}`);\n }\n\n const data = await res.json();\n return data.token;\n },\n });\n}\n"]}
|
|
1
|
+
{"version":3,"sources":["../../../src/event/sse/client.ts"],"names":[],"mappings":"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","file":"client.js","sourcesContent":["/**\n * SSE Client\n *\n * Type-safe EventSource wrapper for event subscription\n *\n * @example\n * ```typescript\n * import { createSSEClient } from '@spfn/core/event/sse/client';\n * import type { EventRouter } from '@/server/events';\n *\n * // Uses defaults: NEXT_PUBLIC_SPFN_API_URL + /events/stream\n * const client = createSSEClient<EventRouter>();\n *\n * // Or with custom host/pathname\n * const client = createSSEClient<EventRouter>({\n * host: 'https://api.example.com',\n * pathname: '/sse',\n * });\n *\n * // With token authentication (recommended: use createAuthSSEClient)\n * import { createAuthSSEClient } from '@spfn/core/event/sse/client';\n * const client = createAuthSSEClient<EventRouter>();\n *\n * const unsubscribe = client.subscribe({\n * events: ['userCreated', 'orderPlaced'],\n * handlers: {\n * userCreated: (payload) => console.log('User:', payload.userId),\n * orderPlaced: (payload) => console.log('Order:', payload.orderId),\n * },\n * });\n *\n * // Later: cleanup\n * unsubscribe();\n * ```\n */\n\nimport type { EventRouterDef, InferEventNames } from '../router';\nimport type {\n SSEClientConfig,\n SSESubscribeOptions,\n SSEUnsubscribe,\n SSEConnectionState,\n SSEMessage,\n} from './types';\n\n/**\n * SSE Client instance\n */\nexport interface SSEClient<TRouter extends EventRouterDef<any>>\n{\n /**\n * Subscribe to events\n */\n subscribe(options: SSESubscribeOptions<TRouter>): SSEUnsubscribe;\n\n /**\n * Get current connection state\n */\n getState(): SSEConnectionState;\n\n /**\n * Close all connections\n */\n close(): void;\n}\n\n/**\n * Default SSE configuration\n */\nconst SSE_DEFAULTS = {\n host: typeof process !== 'undefined'\n ? (process.env.NEXT_PUBLIC_SPFN_API_URL || 'http://localhost:8790')\n : 'http://localhost:8790',\n pathname: '/events/stream',\n} as const;\n\n/**\n * Create type-safe SSE client\n *\n * @example\n * ```typescript\n * // Uses defaults (NEXT_PUBLIC_SPFN_API_URL + /events/stream)\n * const client = createSSEClient<EventRouter>();\n *\n * // Or with custom configuration\n * const client = createSSEClient<EventRouter>({\n * host: 'https://api.example.com',\n * pathname: '/sse',\n * reconnect: true,\n * reconnectDelay: 3000,\n * });\n *\n * // Subscribe to events\n * const unsubscribe = client.subscribe({\n * events: ['userCreated', 'orderPlaced'],\n * handlers: {\n * userCreated: (payload) => {\n * console.log('New user:', payload.userId);\n * },\n * orderPlaced: (payload) => {\n * console.log('New order:', payload.orderId);\n * },\n * },\n * onOpen: () => console.log('Connected'),\n * onError: (err) => console.error('Error:', err),\n * onReconnect: (attempt) => console.log('Reconnecting...', attempt),\n * });\n *\n * // Cleanup\n * unsubscribe();\n * ```\n */\n/**\n * Per-subscription connection resources.\n *\n * Grouped so an in-flight async connect can be cancelled atomically and\n * `close()` can tear down whichever subscription is currently active —\n * the shared module-level EventSource used to leak across the token await.\n */\ninterface SSEConnection\n{\n eventSource: EventSource | null;\n reconnectTimer: ReturnType<typeof setTimeout> | null;\n reconnectAttempts: number;\n closed: boolean;\n onClose?: () => void;\n}\n\nexport function createSSEClient<TRouter extends EventRouterDef<any>>(\n config: SSEClientConfig = {}\n): SSEClient<TRouter>\n{\n const {\n url,\n host = SSE_DEFAULTS.host,\n pathname = SSE_DEFAULTS.pathname,\n reconnect = true,\n reconnectDelay = 3000,\n maxReconnectAttempts = 0,\n withCredentials = false,\n acquireToken,\n } = config;\n\n // Build base URL: url takes precedence, otherwise host + pathname\n const baseUrl = url || `${host}${pathname}`;\n\n let state: SSEConnectionState = 'closed';\n let active: SSEConnection | null = null;\n\n // Idempotent teardown: closes the connection, fires onClose exactly once,\n // and clears the active slot if this was the live connection.\n function closeConn(conn: SSEConnection)\n {\n if (conn.closed)\n {\n return;\n }\n\n conn.closed = true;\n\n if (conn.reconnectTimer)\n {\n clearTimeout(conn.reconnectTimer);\n conn.reconnectTimer = null;\n }\n\n if (conn.eventSource)\n {\n conn.eventSource.close();\n conn.eventSource = null;\n }\n\n if (active === conn)\n {\n active = null;\n state = 'closed';\n }\n\n conn.onClose?.();\n }\n\n function subscribe(options: SSESubscribeOptions<TRouter>): SSEUnsubscribe\n {\n const { events, handlers, onOpen, onError, onReconnect, onClose } = options;\n\n // A new subscription supersedes any previous one on this client.\n if (active)\n {\n closeConn(active);\n }\n\n const conn: SSEConnection = {\n eventSource: null,\n reconnectTimer: null,\n reconnectAttempts: 0,\n closed: false,\n onClose,\n };\n active = conn;\n\n const eventNames = events as string[];\n\n function connect()\n {\n state = 'connecting';\n\n const init = async () =>\n {\n let tokenParam = '';\n\n if (acquireToken)\n {\n const token = await acquireToken();\n\n // Cancelled during the token await (e.g. StrictMode cleanup) —\n // don't open a connection that has no teardown waiting for it.\n if (conn.closed)\n {\n return;\n }\n\n tokenParam = `&token=${encodeURIComponent(token)}`;\n }\n\n if (conn.closed)\n {\n return;\n }\n\n const streamUrl = `${baseUrl}?events=${eventNames.join(',')}${tokenParam}`;\n\n conn.eventSource = new EventSource(streamUrl, {\n withCredentials,\n });\n\n setupEventHandlers(conn.eventSource, eventNames, handlers, onOpen, onError);\n setupReconnect(onReconnect);\n };\n\n init().catch(() =>\n {\n // Don't resurrect a torn-down subscription via reconnect.\n if (conn.closed)\n {\n return;\n }\n\n state = 'error';\n attemptReconnect(onReconnect);\n });\n }\n\n function setupEventHandlers(\n es: EventSource,\n names: string[],\n handlerMap: SSESubscribeOptions<TRouter>['handlers'],\n onOpenCb?: () => void,\n onErrorCb?: (error: Event) => void\n )\n {\n es.onopen = () =>\n {\n state = 'open';\n conn.reconnectAttempts = 0;\n onOpenCb?.();\n };\n\n es.onerror = (error) =>\n {\n state = 'error';\n onErrorCb?.(error);\n };\n\n // Handle connected event (server sends this on connection)\n es.addEventListener('connected', (e: MessageEvent) =>\n {\n try\n {\n const data = JSON.parse(e.data);\n console.debug('[SSE] Connected:', data);\n }\n catch\n {\n // Ignore parse errors\n }\n });\n\n // Handle ping (keep-alive)\n es.addEventListener('ping', () =>\n {\n // Ping received, connection is alive\n });\n\n // Register handlers for each event\n for (const eventName of names)\n {\n const handler = (handlerMap as Record<string, ((payload: unknown) => void) | undefined>)[eventName];\n\n if (!handler)\n {\n continue;\n }\n\n es.addEventListener(eventName, (e: MessageEvent) =>\n {\n try\n {\n const message: SSEMessage = JSON.parse(e.data);\n handler(message.data);\n }\n catch (err)\n {\n console.error(`[SSE] Failed to parse event \"${eventName}\":`, err);\n }\n });\n }\n }\n\n function setupReconnect(onReconnectCb?: (attempt: number) => void)\n {\n if (!conn.eventSource)\n {\n return;\n }\n\n const currentEs = conn.eventSource;\n const originalOnError = currentEs.onerror;\n\n currentEs.onerror = (error) =>\n {\n if (originalOnError)\n {\n (originalOnError as (ev: Event) => void)(error);\n }\n\n // Token-auth 사용 시 브라우저 auto-retry는 소비된 토큰으로 재시도하므로\n // 즉시 close하고 우리 reconnect로 새 토큰 발급받아 재연결\n if (reconnect && acquireToken)\n {\n currentEs.close();\n attemptReconnect(onReconnectCb);\n }\n else if (reconnect && currentEs.readyState === EventSource.CLOSED)\n {\n attemptReconnect(onReconnectCb);\n }\n };\n }\n\n function attemptReconnect(onReconnectCb?: (attempt: number) => void)\n {\n if (conn.closed || !reconnect)\n {\n return;\n }\n\n if (maxReconnectAttempts > 0 && conn.reconnectAttempts >= maxReconnectAttempts)\n {\n closeConn(conn);\n return;\n }\n\n conn.reconnectAttempts++;\n onReconnectCb?.(conn.reconnectAttempts);\n\n conn.reconnectTimer = setTimeout(() =>\n {\n connect();\n }, reconnectDelay);\n }\n\n // Start connection\n connect();\n\n // Return unsubscribe function\n return () =>\n {\n closeConn(conn);\n };\n }\n\n function getState(): SSEConnectionState\n {\n return state;\n }\n\n function close()\n {\n if (active)\n {\n closeConn(active);\n }\n else\n {\n state = 'closed';\n }\n }\n\n return {\n subscribe,\n getState,\n close,\n };\n}\n\n/**\n * Simple subscribe function for one-off subscriptions\n *\n * @example\n * ```typescript\n * import { subscribeToEvents } from '@spfn/core/event/sse/client';\n * import type { EventRouter } from '@/server/events';\n *\n * // Using defaults\n * const unsubscribe = subscribeToEvents<EventRouter>(\n * ['userCreated', 'orderPlaced'],\n * {\n * userCreated: (payload) => console.log('User:', payload),\n * orderPlaced: (payload) => console.log('Order:', payload),\n * }\n * );\n *\n * // With custom host\n * const unsubscribe = subscribeToEvents<EventRouter>(\n * ['userCreated'],\n * { userCreated: (payload) => console.log('User:', payload) },\n * { host: 'https://api.example.com' }\n * );\n * ```\n */\nexport function subscribeToEvents<TRouter extends EventRouterDef<any>>(\n events: InferEventNames<TRouter>[],\n handlers: SSESubscribeOptions<TRouter>['handlers'],\n options?: SSEClientConfig\n): SSEUnsubscribe\n{\n const client = createSSEClient<TRouter>(options);\n\n return client.subscribe({\n events,\n handlers,\n });\n}\n\n// ============================================================================\n// Auth SSE Client\n// ============================================================================\n\n/**\n * SSE client configuration for authenticated connections\n *\n * Same as SSEClientConfig but without acquireToken (auto-configured).\n */\nexport interface AuthSSEClientConfig extends Omit<SSEClientConfig, 'acquireToken'>\n{\n /**\n * RPC proxy base URL for token acquisition\n * @default '/api/rpc'\n */\n rpcBaseUrl?: string;\n}\n\n/**\n * Create SSE client with built-in token authentication\n *\n * Acquires one-time SSE tokens via RPC proxy automatically.\n * Requires eventRouteMap to be merged into RPC proxy config.\n *\n * @example\n * ```typescript\n * import { createAuthSSEClient } from '@spfn/core/event/sse/client';\n * import type { EventRouter } from '@/server/events';\n *\n * const client = createAuthSSEClient<EventRouter>();\n *\n * client.subscribe({\n * events: ['userCreated'],\n * handlers: {\n * userCreated: (payload) => console.log(payload),\n * },\n * });\n * ```\n */\nexport function createAuthSSEClient<TRouter extends EventRouterDef<any>>(\n config: AuthSSEClientConfig = {}\n): SSEClient<TRouter>\n{\n const { rpcBaseUrl = '/api/rpc', ...sseConfig } = config;\n\n return createSSEClient<TRouter>({\n ...sseConfig,\n acquireToken: async () =>\n {\n const res = await fetch(`${rpcBaseUrl}/eventsToken`, {\n method: 'POST',\n credentials: 'include',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({}),\n });\n\n if (!res.ok)\n {\n throw new Error(`Failed to acquire SSE token: ${res.status}`);\n }\n\n const data = await res.json();\n return data.token;\n },\n });\n}\n"]}
|