@spfn/core 0.2.0-beta.12 → 0.2.0-beta.13
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/dist/event/sse/client.d.ts +50 -1
- package/dist/event/sse/client.js +57 -22
- package/dist/event/sse/client.js.map +1 -1
- package/dist/event/sse/index.d.ts +10 -4
- package/dist/event/sse/index.js +125 -12
- package/dist/event/sse/index.js.map +1 -1
- package/dist/server/index.d.ts +3 -2
- package/dist/server/index.js +151 -15
- package/dist/server/index.js.map +1 -1
- package/dist/types-B-lVqv6b.d.ts +298 -0
- package/docs/event.md +88 -0
- package/package.json +1 -1
- package/dist/types-B-e_f2dQ.d.ts +0 -121
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { E as EventRouterDef, I as InferEventNames } from '../../router-Di7ENoah.js';
|
|
2
|
-
import {
|
|
2
|
+
import { k as SSESubscribeOptions, m as SSEUnsubscribe, l as SSEConnectionState, h as SSEClientConfig } from '../../types-B-lVqv6b.js';
|
|
3
3
|
import '@sinclair/typebox';
|
|
4
|
+
import 'hono';
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* SSE Client
|
|
@@ -21,6 +22,18 @@ import '@sinclair/typebox';
|
|
|
21
22
|
* pathname: '/sse',
|
|
22
23
|
* });
|
|
23
24
|
*
|
|
25
|
+
* // With token authentication
|
|
26
|
+
* const client = createSSEClient<EventRouter>({
|
|
27
|
+
* acquireToken: async () => {
|
|
28
|
+
* const res = await fetch('/api/events/token', {
|
|
29
|
+
* method: 'POST',
|
|
30
|
+
* credentials: 'include',
|
|
31
|
+
* });
|
|
32
|
+
* const data = await res.json();
|
|
33
|
+
* return data.token;
|
|
34
|
+
* },
|
|
35
|
+
* });
|
|
36
|
+
*
|
|
24
37
|
* const unsubscribe = client.subscribe({
|
|
25
38
|
* events: ['userCreated', 'orderPlaced'],
|
|
26
39
|
* handlers: {
|
|
@@ -51,6 +64,42 @@ interface SSEClient<TRouter extends EventRouterDef<any>> {
|
|
|
51
64
|
*/
|
|
52
65
|
close(): void;
|
|
53
66
|
}
|
|
67
|
+
/**
|
|
68
|
+
* Create type-safe SSE client
|
|
69
|
+
*
|
|
70
|
+
* @example
|
|
71
|
+
* ```typescript
|
|
72
|
+
* // Uses defaults (NEXT_PUBLIC_SPFN_API_URL + /events/stream)
|
|
73
|
+
* const client = createSSEClient<EventRouter>();
|
|
74
|
+
*
|
|
75
|
+
* // Or with custom configuration
|
|
76
|
+
* const client = createSSEClient<EventRouter>({
|
|
77
|
+
* host: 'https://api.example.com',
|
|
78
|
+
* pathname: '/sse',
|
|
79
|
+
* reconnect: true,
|
|
80
|
+
* reconnectDelay: 3000,
|
|
81
|
+
* });
|
|
82
|
+
*
|
|
83
|
+
* // Subscribe to events
|
|
84
|
+
* const unsubscribe = client.subscribe({
|
|
85
|
+
* events: ['userCreated', 'orderPlaced'],
|
|
86
|
+
* handlers: {
|
|
87
|
+
* userCreated: (payload) => {
|
|
88
|
+
* console.log('New user:', payload.userId);
|
|
89
|
+
* },
|
|
90
|
+
* orderPlaced: (payload) => {
|
|
91
|
+
* console.log('New order:', payload.orderId);
|
|
92
|
+
* },
|
|
93
|
+
* },
|
|
94
|
+
* onOpen: () => console.log('Connected'),
|
|
95
|
+
* onError: (err) => console.error('Error:', err),
|
|
96
|
+
* onReconnect: (attempt) => console.log('Reconnecting...', attempt),
|
|
97
|
+
* });
|
|
98
|
+
*
|
|
99
|
+
* // Cleanup
|
|
100
|
+
* unsubscribe();
|
|
101
|
+
* ```
|
|
102
|
+
*/
|
|
54
103
|
declare function createSSEClient<TRouter extends EventRouterDef<any>>(config?: SSEClientConfig): SSEClient<TRouter>;
|
|
55
104
|
/**
|
|
56
105
|
* Simple subscribe function for one-off subscriptions
|
package/dist/event/sse/client.js
CHANGED
|
@@ -11,7 +11,8 @@ function createSSEClient(config = {}) {
|
|
|
11
11
|
reconnect = true,
|
|
12
12
|
reconnectDelay = 3e3,
|
|
13
13
|
maxReconnectAttempts = 0,
|
|
14
|
-
withCredentials = false
|
|
14
|
+
withCredentials = false,
|
|
15
|
+
acquireToken
|
|
15
16
|
} = config;
|
|
16
17
|
const baseUrl = url || `${host}${pathname}`;
|
|
17
18
|
let eventSource = null;
|
|
@@ -21,45 +22,51 @@ function createSSEClient(config = {}) {
|
|
|
21
22
|
function subscribe(options) {
|
|
22
23
|
const { events, handlers, onOpen, onError, onReconnect } = options;
|
|
23
24
|
const eventNames = events;
|
|
24
|
-
const streamUrl = `${baseUrl}?events=${eventNames.join(",")}`;
|
|
25
25
|
function connect() {
|
|
26
26
|
state = "connecting";
|
|
27
|
-
|
|
28
|
-
|
|
27
|
+
const init = async () => {
|
|
28
|
+
let tokenParam = "";
|
|
29
|
+
if (acquireToken) {
|
|
30
|
+
const token = await acquireToken();
|
|
31
|
+
tokenParam = `&token=${encodeURIComponent(token)}`;
|
|
32
|
+
}
|
|
33
|
+
const streamUrl = `${baseUrl}?events=${eventNames.join(",")}${tokenParam}`;
|
|
34
|
+
eventSource = new EventSource(streamUrl, {
|
|
35
|
+
withCredentials
|
|
36
|
+
});
|
|
37
|
+
setupEventHandlers(eventSource, eventNames, handlers, onOpen, onError);
|
|
38
|
+
setupReconnect(onReconnect);
|
|
39
|
+
};
|
|
40
|
+
init().catch(() => {
|
|
41
|
+
state = "error";
|
|
42
|
+
attemptReconnect(onReconnect);
|
|
29
43
|
});
|
|
30
|
-
|
|
44
|
+
}
|
|
45
|
+
function setupEventHandlers(es, names, handlerMap, onOpenCb, onErrorCb) {
|
|
46
|
+
es.onopen = () => {
|
|
31
47
|
state = "open";
|
|
32
48
|
reconnectAttempts = 0;
|
|
33
|
-
|
|
49
|
+
onOpenCb?.();
|
|
34
50
|
};
|
|
35
|
-
|
|
51
|
+
es.onerror = (error) => {
|
|
36
52
|
state = "error";
|
|
37
|
-
|
|
38
|
-
if (reconnect && eventSource?.readyState === EventSource.CLOSED) {
|
|
39
|
-
if (maxReconnectAttempts === 0 || reconnectAttempts < maxReconnectAttempts) {
|
|
40
|
-
reconnectAttempts++;
|
|
41
|
-
onReconnect?.(reconnectAttempts);
|
|
42
|
-
reconnectTimer = setTimeout(() => {
|
|
43
|
-
connect();
|
|
44
|
-
}, reconnectDelay);
|
|
45
|
-
}
|
|
46
|
-
}
|
|
53
|
+
onErrorCb?.(error);
|
|
47
54
|
};
|
|
48
|
-
|
|
55
|
+
es.addEventListener("connected", (e) => {
|
|
49
56
|
try {
|
|
50
57
|
const data = JSON.parse(e.data);
|
|
51
58
|
console.debug("[SSE] Connected:", data);
|
|
52
59
|
} catch {
|
|
53
60
|
}
|
|
54
61
|
});
|
|
55
|
-
|
|
62
|
+
es.addEventListener("ping", () => {
|
|
56
63
|
});
|
|
57
|
-
for (const eventName of
|
|
58
|
-
const handler =
|
|
64
|
+
for (const eventName of names) {
|
|
65
|
+
const handler = handlerMap[eventName];
|
|
59
66
|
if (!handler) {
|
|
60
67
|
continue;
|
|
61
68
|
}
|
|
62
|
-
|
|
69
|
+
es.addEventListener(eventName, (e) => {
|
|
63
70
|
try {
|
|
64
71
|
const message = JSON.parse(e.data);
|
|
65
72
|
handler(message.data);
|
|
@@ -69,6 +76,34 @@ function createSSEClient(config = {}) {
|
|
|
69
76
|
});
|
|
70
77
|
}
|
|
71
78
|
}
|
|
79
|
+
function setupReconnect(onReconnectCb) {
|
|
80
|
+
if (!eventSource) {
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
const currentEs = eventSource;
|
|
84
|
+
const originalOnError = currentEs.onerror;
|
|
85
|
+
currentEs.onerror = (error) => {
|
|
86
|
+
if (originalOnError) {
|
|
87
|
+
originalOnError(error);
|
|
88
|
+
}
|
|
89
|
+
if (reconnect && currentEs.readyState === EventSource.CLOSED) {
|
|
90
|
+
attemptReconnect(onReconnectCb);
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
function attemptReconnect(onReconnectCb) {
|
|
95
|
+
if (!reconnect) {
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
if (maxReconnectAttempts > 0 && reconnectAttempts >= maxReconnectAttempts) {
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
reconnectAttempts++;
|
|
102
|
+
onReconnectCb?.(reconnectAttempts);
|
|
103
|
+
reconnectTimer = setTimeout(() => {
|
|
104
|
+
connect();
|
|
105
|
+
}, reconnectDelay);
|
|
106
|
+
}
|
|
72
107
|
connect();
|
|
73
108
|
return () => {
|
|
74
109
|
if (reconnectTimer) {
|
|
@@ -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 * 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 * 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 * 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\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 } = 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\n function subscribe(options: SSESubscribeOptions<TRouter>): SSEUnsubscribe\n {\n const { events, handlers, onOpen, onError, onReconnect } = options;\n\n // Build URL with events query parameter\n const eventNames = events as string[];\n const streamUrl = `${baseUrl}?events=${eventNames.join(',')}`;\n\n function connect()\n {\n state = 'connecting';\n\n eventSource = new EventSource(streamUrl, {\n withCredentials,\n });\n\n // Handle open\n eventSource.onopen = () =>\n {\n state = 'open';\n reconnectAttempts = 0;\n onOpen?.();\n };\n\n // Handle errors\n eventSource.onerror = (error) =>\n {\n state = 'error';\n onError?.(error);\n\n // Auto reconnect\n if (reconnect && eventSource?.readyState === EventSource.CLOSED)\n {\n if (maxReconnectAttempts === 0 || reconnectAttempts < maxReconnectAttempts)\n {\n reconnectAttempts++;\n onReconnect?.(reconnectAttempts);\n\n reconnectTimer = setTimeout(() =>\n {\n connect();\n }, reconnectDelay);\n }\n }\n };\n\n // Handle connected event (server sends this on connection)\n eventSource.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 eventSource.addEventListener('ping', () =>\n {\n // Ping received, connection is alive\n });\n\n // Register handlers for each event\n for (const eventName of eventNames)\n {\n // Type assertion needed here - runtime type safety is ensured by EventRouter\n const handler = (handlers as Record<string, ((payload: unknown) => void) | undefined>)[eventName];\n\n if (!handler)\n {\n continue;\n }\n\n eventSource.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 // 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 };\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 }\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"]}
|
|
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\n * const client = createSSEClient<EventRouter>({\n * acquireToken: async () => {\n * const res = await fetch('/api/events/token', {\n * method: 'POST',\n * credentials: 'include',\n * });\n * const data = await res.json();\n * return data.token;\n * },\n * });\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\n function subscribe(options: SSESubscribeOptions<TRouter>): SSEUnsubscribe\n {\n const { events, handlers, onOpen, onError, onReconnect } = options;\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 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 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 };\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 }\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"]}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { Context } from 'hono';
|
|
2
2
|
import { E as EventRouterDef } from '../../router-Di7ENoah.js';
|
|
3
|
-
import { S as SSEHandlerConfig } from '../../types-B-
|
|
4
|
-
export {
|
|
3
|
+
import { S as SSEHandlerConfig, b as SSETokenManager } from '../../types-B-lVqv6b.js';
|
|
4
|
+
export { a as SSEAuthConfig, h as SSEClientConfig, l as SSEConnectionState, i as SSEEventHandler, j as SSEEventHandlers, g as SSEHandlerAuthConfig, f as SSEMessage, k as SSESubscribeOptions, c as SSEToken, e as SSETokenManagerConfig, d as SSETokenStore, m as SSEUnsubscribe } from '../../types-B-lVqv6b.js';
|
|
5
5
|
import '@sinclair/typebox';
|
|
6
6
|
|
|
7
7
|
/**
|
|
@@ -22,11 +22,17 @@ import '@sinclair/typebox';
|
|
|
22
22
|
* ```
|
|
23
23
|
*/
|
|
24
24
|
|
|
25
|
+
declare module 'hono' {
|
|
26
|
+
interface ContextVariableMap {
|
|
27
|
+
sseSubject?: string;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
25
30
|
/**
|
|
26
31
|
* Create SSE handler for Hono
|
|
27
32
|
*
|
|
28
33
|
* Query parameters:
|
|
29
34
|
* - events: Comma-separated list of event names to subscribe
|
|
35
|
+
* - token: One-time auth token (when auth is enabled)
|
|
30
36
|
*
|
|
31
37
|
* @example
|
|
32
38
|
* ```typescript
|
|
@@ -35,6 +41,6 @@ import '@sinclair/typebox';
|
|
|
35
41
|
* }));
|
|
36
42
|
* ```
|
|
37
43
|
*/
|
|
38
|
-
declare function createSSEHandler<TRouter extends EventRouterDef<any>>(router: TRouter, config?: SSEHandlerConfig): (c: Context) => Promise<Response>;
|
|
44
|
+
declare function createSSEHandler<TRouter extends EventRouterDef<any>>(router: TRouter, config?: SSEHandlerConfig, tokenManager?: SSETokenManager): (c: Context) => Promise<Response>;
|
|
39
45
|
|
|
40
|
-
export { SSEHandlerConfig, createSSEHandler };
|
|
46
|
+
export { SSEHandlerConfig, SSETokenManager, createSSEHandler };
|
package/dist/event/sse/index.js
CHANGED
|
@@ -1,19 +1,29 @@
|
|
|
1
1
|
import { streamSSE } from 'hono/streaming';
|
|
2
2
|
import { logger } from '@spfn/core/logger';
|
|
3
|
+
import { randomBytes } from 'crypto';
|
|
3
4
|
|
|
4
5
|
// src/event/sse/handler.ts
|
|
5
6
|
var sseLogger = logger.child("@spfn/core:sse");
|
|
6
|
-
function createSSEHandler(router, config = {}) {
|
|
7
|
+
function createSSEHandler(router, config = {}, tokenManager) {
|
|
7
8
|
const {
|
|
8
|
-
pingInterval = 3e4
|
|
9
|
-
|
|
9
|
+
pingInterval = 3e4,
|
|
10
|
+
auth: authConfig
|
|
10
11
|
} = config;
|
|
11
12
|
return async (c) => {
|
|
12
|
-
const
|
|
13
|
-
if (
|
|
13
|
+
const subject = await authenticateToken(c, tokenManager);
|
|
14
|
+
if (subject === false) {
|
|
15
|
+
return c.json({ error: "Missing token parameter" }, 401);
|
|
16
|
+
}
|
|
17
|
+
if (subject === null) {
|
|
18
|
+
return c.json({ error: "Invalid or expired token" }, 401);
|
|
19
|
+
}
|
|
20
|
+
if (subject) {
|
|
21
|
+
c.set("sseSubject", subject);
|
|
22
|
+
}
|
|
23
|
+
const requestedEvents = parseRequestedEvents(c);
|
|
24
|
+
if (!requestedEvents) {
|
|
14
25
|
return c.json({ error: "Missing events parameter" }, 400);
|
|
15
26
|
}
|
|
16
|
-
const requestedEvents = eventsParam.split(",").map((e) => e.trim());
|
|
17
27
|
const validEventNames = router.eventNames;
|
|
18
28
|
const invalidEvents = requestedEvents.filter((e) => !validEventNames.includes(e));
|
|
19
29
|
if (invalidEvents.length > 0) {
|
|
@@ -23,19 +33,29 @@ function createSSEHandler(router, config = {}) {
|
|
|
23
33
|
validEvents: validEventNames
|
|
24
34
|
}, 400);
|
|
25
35
|
}
|
|
36
|
+
const allowedEvents = await authorizeEvents(subject, requestedEvents, authConfig);
|
|
37
|
+
if (allowedEvents === null) {
|
|
38
|
+
return c.json({ error: "Not authorized for any requested events" }, 403);
|
|
39
|
+
}
|
|
26
40
|
sseLogger.debug("SSE connection requested", {
|
|
27
|
-
events:
|
|
41
|
+
events: allowedEvents,
|
|
42
|
+
subject: subject || void 0,
|
|
28
43
|
clientIp: c.req.header("x-forwarded-for") || c.req.header("x-real-ip")
|
|
29
44
|
});
|
|
30
45
|
return streamSSE(c, async (stream) => {
|
|
31
46
|
const unsubscribes = [];
|
|
32
47
|
let messageId = 0;
|
|
33
|
-
for (const eventName of
|
|
48
|
+
for (const eventName of allowedEvents) {
|
|
34
49
|
const eventDef = router.events[eventName];
|
|
35
50
|
if (!eventDef) {
|
|
36
51
|
continue;
|
|
37
52
|
}
|
|
38
53
|
const unsubscribe = eventDef.subscribe((payload) => {
|
|
54
|
+
if (subject && authConfig?.filter?.[eventName]) {
|
|
55
|
+
if (!authConfig.filter[eventName](subject, payload)) {
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
39
59
|
messageId++;
|
|
40
60
|
const message = {
|
|
41
61
|
event: eventName,
|
|
@@ -54,13 +74,13 @@ function createSSEHandler(router, config = {}) {
|
|
|
54
74
|
unsubscribes.push(unsubscribe);
|
|
55
75
|
}
|
|
56
76
|
sseLogger.info("SSE connection established", {
|
|
57
|
-
events:
|
|
77
|
+
events: allowedEvents,
|
|
58
78
|
subscriptionCount: unsubscribes.length
|
|
59
79
|
});
|
|
60
80
|
await stream.writeSSE({
|
|
61
81
|
event: "connected",
|
|
62
82
|
data: JSON.stringify({
|
|
63
|
-
subscribedEvents:
|
|
83
|
+
subscribedEvents: allowedEvents,
|
|
64
84
|
timestamp: Date.now()
|
|
65
85
|
})
|
|
66
86
|
});
|
|
@@ -77,7 +97,7 @@ function createSSEHandler(router, config = {}) {
|
|
|
77
97
|
clearInterval(pingTimer);
|
|
78
98
|
unsubscribes.forEach((fn) => fn());
|
|
79
99
|
sseLogger.info("SSE connection closed", {
|
|
80
|
-
events:
|
|
100
|
+
events: allowedEvents
|
|
81
101
|
});
|
|
82
102
|
}, async (err) => {
|
|
83
103
|
sseLogger.error("SSE stream error", {
|
|
@@ -86,7 +106,100 @@ function createSSEHandler(router, config = {}) {
|
|
|
86
106
|
});
|
|
87
107
|
};
|
|
88
108
|
}
|
|
109
|
+
async function authenticateToken(c, tokenManager) {
|
|
110
|
+
if (!tokenManager) {
|
|
111
|
+
return void 0;
|
|
112
|
+
}
|
|
113
|
+
const token = c.req.query("token");
|
|
114
|
+
if (!token) {
|
|
115
|
+
return false;
|
|
116
|
+
}
|
|
117
|
+
return await tokenManager.verify(token);
|
|
118
|
+
}
|
|
119
|
+
function parseRequestedEvents(c) {
|
|
120
|
+
const eventsParam = c.req.query("events");
|
|
121
|
+
if (!eventsParam) {
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
return eventsParam.split(",").map((e) => e.trim());
|
|
125
|
+
}
|
|
126
|
+
async function authorizeEvents(subject, requestedEvents, authConfig) {
|
|
127
|
+
if (!subject || !authConfig?.authorize) {
|
|
128
|
+
return requestedEvents;
|
|
129
|
+
}
|
|
130
|
+
const allowed = await authConfig.authorize(subject, requestedEvents);
|
|
131
|
+
if (allowed.length === 0) {
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
return allowed;
|
|
135
|
+
}
|
|
136
|
+
var InMemoryTokenStore = class {
|
|
137
|
+
tokens = /* @__PURE__ */ new Map();
|
|
138
|
+
async set(token, data) {
|
|
139
|
+
this.tokens.set(token, data);
|
|
140
|
+
}
|
|
141
|
+
async consume(token) {
|
|
142
|
+
const data = this.tokens.get(token);
|
|
143
|
+
if (!data) {
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
this.tokens.delete(token);
|
|
147
|
+
return data;
|
|
148
|
+
}
|
|
149
|
+
async cleanup() {
|
|
150
|
+
const now = Date.now();
|
|
151
|
+
for (const [token, data] of this.tokens) {
|
|
152
|
+
if (data.expiresAt <= now) {
|
|
153
|
+
this.tokens.delete(token);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
};
|
|
158
|
+
var SSETokenManager = class {
|
|
159
|
+
store;
|
|
160
|
+
ttl;
|
|
161
|
+
cleanupTimer = null;
|
|
162
|
+
constructor(config) {
|
|
163
|
+
this.ttl = config?.ttl ?? 3e4;
|
|
164
|
+
this.store = config?.store ?? new InMemoryTokenStore();
|
|
165
|
+
const cleanupInterval = config?.cleanupInterval ?? 6e4;
|
|
166
|
+
this.cleanupTimer = setInterval(() => void this.store.cleanup(), cleanupInterval);
|
|
167
|
+
this.cleanupTimer.unref();
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* Issue a new one-time-use token for the given subject
|
|
171
|
+
*/
|
|
172
|
+
async issue(subject) {
|
|
173
|
+
const token = randomBytes(32).toString("hex");
|
|
174
|
+
await this.store.set(token, {
|
|
175
|
+
token,
|
|
176
|
+
subject,
|
|
177
|
+
expiresAt: Date.now() + this.ttl
|
|
178
|
+
});
|
|
179
|
+
return token;
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* Verify and consume a token
|
|
183
|
+
* @returns subject string if valid, null if invalid/expired/already consumed
|
|
184
|
+
*/
|
|
185
|
+
async verify(token) {
|
|
186
|
+
const data = await this.store.consume(token);
|
|
187
|
+
if (!data || data.expiresAt <= Date.now()) {
|
|
188
|
+
return null;
|
|
189
|
+
}
|
|
190
|
+
return data.subject;
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* Cleanup timer and resources
|
|
194
|
+
*/
|
|
195
|
+
destroy() {
|
|
196
|
+
if (this.cleanupTimer) {
|
|
197
|
+
clearInterval(this.cleanupTimer);
|
|
198
|
+
this.cleanupTimer = null;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
};
|
|
89
202
|
|
|
90
|
-
export { createSSEHandler };
|
|
203
|
+
export { SSETokenManager, createSSEHandler };
|
|
91
204
|
//# sourceMappingURL=index.js.map
|
|
92
205
|
//# sourceMappingURL=index.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../src/event/sse/handler.ts"],"names":[],"mappings":"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","file":"index.js","sourcesContent":["/**\n * SSE Handler for Hono\n *\n * Creates SSE stream endpoint for event subscription\n *\n * @example\n * ```typescript\n * import { Hono } from 'hono';\n * import { createSSEHandler } from '@spfn/core/event/sse';\n * import { eventRouter } from './events';\n *\n * const app = new Hono();\n *\n * // GET /events/stream?events=userCreated,orderPlaced\n * app.get('/events/stream', createSSEHandler(eventRouter));\n * ```\n */\n\nimport type { Context } from 'hono';\nimport { streamSSE } from 'hono/streaming';\nimport { logger } from '@spfn/core/logger';\nimport type { EventRouterDef, InferEventNames } from '../router';\nimport type { SSEHandlerConfig } from './types';\n\nconst sseLogger = logger.child('@spfn/core:sse');\n\n/**\n * Create SSE handler for Hono\n *\n * Query parameters:\n * - events: Comma-separated list of event names to subscribe\n *\n * @example\n * ```typescript\n * app.get('/events/stream', createSSEHandler(eventRouter, {\n * pingInterval: 30000,\n * }));\n * ```\n */\nexport function createSSEHandler<TRouter extends EventRouterDef<any>>(\n router: TRouter,\n config: SSEHandlerConfig = {}\n)\n{\n const {\n pingInterval = 30000,\n // headers: customHeaders = {}, // Reserved for future use\n } = config;\n\n return async (c: Context) =>\n {\n // Parse events from query parameter\n const eventsParam = c.req.query('events');\n\n if (!eventsParam)\n {\n return c.json({ error: 'Missing events parameter' }, 400);\n }\n\n const requestedEvents = eventsParam.split(',').map(e => e.trim());\n\n // Validate event names\n const validEventNames = router.eventNames as string[];\n const invalidEvents = requestedEvents.filter(e => !validEventNames.includes(e));\n\n if (invalidEvents.length > 0)\n {\n return c.json({\n error: 'Invalid event names',\n invalidEvents,\n validEvents: validEventNames,\n }, 400);\n }\n\n sseLogger.debug('SSE connection requested', {\n events: requestedEvents,\n clientIp: c.req.header('x-forwarded-for') || c.req.header('x-real-ip'),\n });\n\n // Start SSE stream\n return streamSSE(c, async (stream) =>\n {\n const unsubscribes: (() => void)[] = [];\n let messageId = 0;\n\n // Subscribe to each requested event\n for (const eventName of requestedEvents as InferEventNames<TRouter>[])\n {\n const eventDef = router.events[eventName];\n\n if (!eventDef)\n {\n continue;\n }\n\n const unsubscribe = eventDef.subscribe((payload: unknown) =>\n {\n messageId++;\n\n const message = {\n event: eventName,\n data: payload,\n };\n\n sseLogger.debug('SSE sending event', {\n event: eventName,\n messageId,\n });\n\n // Fire-and-forget in sync callback\n void stream.writeSSE({\n id: String(messageId),\n event: eventName as string,\n data: JSON.stringify(message),\n });\n });\n\n unsubscribes.push(unsubscribe);\n }\n\n sseLogger.info('SSE connection established', {\n events: requestedEvents,\n subscriptionCount: unsubscribes.length,\n });\n\n // Send initial connection message\n await stream.writeSSE({\n event: 'connected',\n data: JSON.stringify({\n subscribedEvents: requestedEvents,\n timestamp: Date.now(),\n }),\n });\n\n // Keep-alive ping\n const pingTimer = setInterval(() =>\n {\n // Fire-and-forget in sync callback\n void stream.writeSSE({\n event: 'ping',\n data: JSON.stringify({ timestamp: Date.now() }),\n });\n }, pingInterval);\n\n // Wait for client disconnect using abort signal\n const abortSignal = c.req.raw.signal;\n\n while (!abortSignal.aborted)\n {\n await stream.sleep(pingInterval);\n }\n\n // Cleanup\n clearInterval(pingTimer);\n unsubscribes.forEach(fn => fn());\n\n sseLogger.info('SSE connection closed', {\n events: requestedEvents,\n });\n }, async (err: Error) =>\n {\n sseLogger.error('SSE stream error', {\n error: err.message,\n });\n });\n };\n}\n\n"]}
|
|
1
|
+
{"version":3,"sources":["../../../src/event/sse/handler.ts","../../../src/event/sse/token-manager.ts"],"names":[],"mappings":"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","file":"index.js","sourcesContent":["/**\n * SSE Handler for Hono\n *\n * Creates SSE stream endpoint for event subscription\n *\n * @example\n * ```typescript\n * import { Hono } from 'hono';\n * import { createSSEHandler } from '@spfn/core/event/sse';\n * import { eventRouter } from './events';\n *\n * const app = new Hono();\n *\n * // GET /events/stream?events=userCreated,orderPlaced\n * app.get('/events/stream', createSSEHandler(eventRouter));\n * ```\n */\n\nimport type { Context } from 'hono';\nimport { streamSSE } from 'hono/streaming';\nimport { logger } from '@spfn/core/logger';\nimport type { EventRouterDef, InferEventNames } from '../router';\nimport type { SSEHandlerConfig, SSEHandlerAuthConfig } from './types';\nimport type { SSETokenManager } from './token-manager';\n\nconst sseLogger = logger.child('@spfn/core:sse');\n\n// Extend Hono context with SSE subject\ndeclare module 'hono'\n{\n interface ContextVariableMap\n {\n sseSubject?: string;\n }\n}\n\n/**\n * Create SSE handler for Hono\n *\n * Query parameters:\n * - events: Comma-separated list of event names to subscribe\n * - token: One-time auth token (when auth is enabled)\n *\n * @example\n * ```typescript\n * app.get('/events/stream', createSSEHandler(eventRouter, {\n * pingInterval: 30000,\n * }));\n * ```\n */\nexport function createSSEHandler<TRouter extends EventRouterDef<any>>(\n router: TRouter,\n config: SSEHandlerConfig = {},\n tokenManager?: SSETokenManager\n)\n{\n const {\n pingInterval = 30000,\n auth: authConfig,\n } = config;\n\n return async (c: Context) =>\n {\n // ── 1. Token Authentication ──\n const subject = await authenticateToken(c, tokenManager);\n if (subject === false)\n {\n return c.json({ error: 'Missing token parameter' }, 401);\n }\n if (subject === null)\n {\n return c.json({ error: 'Invalid or expired token' }, 401);\n }\n if (subject)\n {\n c.set('sseSubject', subject);\n }\n\n // ── 2. Parse events from query parameter ──\n const requestedEvents = parseRequestedEvents(c);\n if (!requestedEvents)\n {\n return c.json({ error: 'Missing events parameter' }, 400);\n }\n\n // ── 3. Validate event names ──\n const validEventNames = router.eventNames as string[];\n const invalidEvents = requestedEvents.filter(e => !validEventNames.includes(e));\n\n if (invalidEvents.length > 0)\n {\n return c.json({\n error: 'Invalid event names',\n invalidEvents,\n validEvents: validEventNames,\n }, 400);\n }\n\n // ── 4. Subscription Authorization ──\n const allowedEvents = await authorizeEvents(subject, requestedEvents, authConfig);\n if (allowedEvents === null)\n {\n return c.json({ error: 'Not authorized for any requested events' }, 403);\n }\n\n sseLogger.debug('SSE connection requested', {\n events: allowedEvents,\n subject: subject || undefined,\n clientIp: c.req.header('x-forwarded-for') || c.req.header('x-real-ip'),\n });\n\n // ── 5. SSE Stream ──\n return streamSSE(c, async (stream) =>\n {\n const unsubscribes: (() => void)[] = [];\n let messageId = 0;\n\n for (const eventName of allowedEvents as InferEventNames<TRouter>[])\n {\n const eventDef = router.events[eventName];\n\n if (!eventDef)\n {\n continue;\n }\n\n const unsubscribe = eventDef.subscribe((payload: unknown) =>\n {\n // ── Payload Filtering ──\n if (subject && authConfig?.filter?.[eventName as string])\n {\n if (!authConfig.filter[eventName as string](subject, payload))\n {\n return;\n }\n }\n\n messageId++;\n\n const message = {\n event: eventName,\n data: payload,\n };\n\n sseLogger.debug('SSE sending event', {\n event: eventName,\n messageId,\n });\n\n // Fire-and-forget in sync callback\n void stream.writeSSE({\n id: String(messageId),\n event: eventName as string,\n data: JSON.stringify(message),\n });\n });\n\n unsubscribes.push(unsubscribe);\n }\n\n sseLogger.info('SSE connection established', {\n events: allowedEvents,\n subscriptionCount: unsubscribes.length,\n });\n\n // Send initial connection message\n await stream.writeSSE({\n event: 'connected',\n data: JSON.stringify({\n subscribedEvents: allowedEvents,\n timestamp: Date.now(),\n }),\n });\n\n // Keep-alive ping\n const pingTimer = setInterval(() =>\n {\n // Fire-and-forget in sync callback\n void stream.writeSSE({\n event: 'ping',\n data: JSON.stringify({ timestamp: Date.now() }),\n });\n }, pingInterval);\n\n // Wait for client disconnect using abort signal\n const abortSignal = c.req.raw.signal;\n\n while (!abortSignal.aborted)\n {\n await stream.sleep(pingInterval);\n }\n\n // Cleanup\n clearInterval(pingTimer);\n unsubscribes.forEach(fn => fn());\n\n sseLogger.info('SSE connection closed', {\n events: allowedEvents,\n });\n }, async (err: Error) =>\n {\n sseLogger.error('SSE stream error', {\n error: err.message,\n });\n });\n };\n}\n\n// ============================================================================\n// Helper Functions\n// ============================================================================\n\n/**\n * Authenticate via one-time token\n * @returns subject string if authenticated, undefined if no auth required,\n * false if token missing, null if token invalid/expired\n */\nasync function authenticateToken(\n c: Context,\n tokenManager?: SSETokenManager\n): Promise<string | undefined | false | null>\n{\n if (!tokenManager)\n {\n return undefined;\n }\n\n const token = c.req.query('token');\n if (!token)\n {\n return false;\n }\n\n return await tokenManager.verify(token);\n}\n\n/**\n * Parse requested events from query parameter\n */\nfunction parseRequestedEvents(c: Context): string[] | null\n{\n const eventsParam = c.req.query('events');\n if (!eventsParam)\n {\n return null;\n }\n\n return eventsParam.split(',').map(e => e.trim());\n}\n\n/**\n * Authorize event subscription via auth hook\n * @returns allowed events array, or null if rejected\n */\nasync function authorizeEvents(\n subject: string | undefined,\n requestedEvents: string[],\n authConfig?: SSEHandlerAuthConfig\n): Promise<string[] | null>\n{\n if (!subject || !authConfig?.authorize)\n {\n return requestedEvents;\n }\n\n const allowed = await authConfig.authorize(subject, requestedEvents);\n\n if (allowed.length === 0)\n {\n return null;\n }\n\n return allowed;\n}\n","/**\n * SSE Token Manager\n *\n * Auth-agnostic token issuance and verification for SSE connections.\n * Issues one-time-use tokens with TTL for Token Exchange pattern.\n *\n * @example\n * ```typescript\n * const manager = new SSETokenManager({ ttl: 30000 });\n *\n * // Issue token for authenticated user\n * const token = await manager.issue('user-123');\n *\n * // Verify and consume token (one-time use)\n * const subject = await manager.verify(token); // 'user-123'\n * const again = await manager.verify(token); // null (already consumed)\n *\n * // Cleanup on shutdown\n * manager.destroy();\n * ```\n */\n\nimport { randomBytes } from 'crypto';\n\n// ============================================================================\n// Types\n// ============================================================================\n\n/**\n * Stored SSE token data\n */\nexport interface SSEToken\n{\n token: string;\n subject: string;\n expiresAt: number;\n}\n\n/**\n * Token storage interface\n *\n * Implement this for custom storage backends (e.g., Redis for multi-instance).\n */\nexport interface SSETokenStore\n{\n /** Store a token */\n set(token: string, data: SSEToken): Promise<void>;\n\n /** Get and delete a token (one-time use) */\n consume(token: string): Promise<SSEToken | null>;\n\n /** Remove expired tokens */\n cleanup(): Promise<void>;\n}\n\n/**\n * SSETokenManager configuration\n */\nexport interface SSETokenManagerConfig\n{\n /**\n * Token time-to-live in milliseconds\n * @default 30000\n */\n ttl?: number;\n\n /**\n * Custom token store (default: in-memory Map)\n */\n store?: SSETokenStore;\n\n /**\n * Cleanup interval in milliseconds\n * @default 60000\n */\n cleanupInterval?: number;\n}\n\n// ============================================================================\n// InMemoryTokenStore\n// ============================================================================\n\nclass InMemoryTokenStore implements SSETokenStore\n{\n private tokens = new Map<string, SSEToken>();\n\n async set(token: string, data: SSEToken): Promise<void>\n {\n this.tokens.set(token, data);\n }\n\n async consume(token: string): Promise<SSEToken | null>\n {\n const data = this.tokens.get(token);\n if (!data)\n {\n return null;\n }\n\n this.tokens.delete(token);\n return data;\n }\n\n async cleanup(): Promise<void>\n {\n const now = Date.now();\n\n for (const [token, data] of this.tokens)\n {\n if (data.expiresAt <= now)\n {\n this.tokens.delete(token);\n }\n }\n }\n}\n\n// ============================================================================\n// SSETokenManager\n// ============================================================================\n\nexport class SSETokenManager\n{\n private store: SSETokenStore;\n private ttl: number;\n private cleanupTimer: ReturnType<typeof setInterval> | null = null;\n\n constructor(config?: SSETokenManagerConfig)\n {\n this.ttl = config?.ttl ?? 30000;\n this.store = config?.store ?? new InMemoryTokenStore();\n\n const cleanupInterval = config?.cleanupInterval ?? 60000;\n this.cleanupTimer = setInterval(() => void this.store.cleanup(), cleanupInterval);\n this.cleanupTimer.unref();\n }\n\n /**\n * Issue a new one-time-use token for the given subject\n */\n async issue(subject: string): Promise<string>\n {\n const token = randomBytes(32).toString('hex');\n\n await this.store.set(token, {\n token,\n subject,\n expiresAt: Date.now() + this.ttl,\n });\n\n return token;\n }\n\n /**\n * Verify and consume a token\n * @returns subject string if valid, null if invalid/expired/already consumed\n */\n async verify(token: string): Promise<string | null>\n {\n const data = await this.store.consume(token);\n\n if (!data || data.expiresAt <= Date.now())\n {\n return null;\n }\n\n return data.subject;\n }\n\n /**\n * Cleanup timer and resources\n */\n destroy(): void\n {\n if (this.cleanupTimer)\n {\n clearInterval(this.cleanupTimer);\n this.cleanupTimer = null;\n }\n }\n}\n"]}
|
package/dist/server/index.d.ts
CHANGED
|
@@ -4,7 +4,7 @@ import { serve } from '@hono/node-server';
|
|
|
4
4
|
import { NamedMiddleware, Router } from '@spfn/core/route';
|
|
5
5
|
import { J as JobRouter, B as BossOptions } from '../boss-DI1r4kTS.js';
|
|
6
6
|
import { E as EventRouterDef } from '../router-Di7ENoah.js';
|
|
7
|
-
import { S as SSEHandlerConfig } from '../types-B-
|
|
7
|
+
import { S as SSEHandlerConfig, a as SSEAuthConfig } from '../types-B-lVqv6b.js';
|
|
8
8
|
import '@sinclair/typebox';
|
|
9
9
|
import 'pg-boss';
|
|
10
10
|
|
|
@@ -669,8 +669,9 @@ declare class ServerConfigBuilder {
|
|
|
669
669
|
* .events(eventRouter, { path: '/sse' })
|
|
670
670
|
* ```
|
|
671
671
|
*/
|
|
672
|
-
events
|
|
672
|
+
events<TRouter extends EventRouterDef<any>>(router: TRouter, config?: Omit<SSEHandlerConfig, 'auth'> & {
|
|
673
673
|
path?: string;
|
|
674
|
+
auth?: SSEAuthConfig<TRouter>;
|
|
674
675
|
}): this;
|
|
675
676
|
/**
|
|
676
677
|
* Enable/disable debug mode
|