qdadm 0.29.0 → 0.30.0
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/package.json +10 -2
- package/src/components/index.js +5 -3
- package/src/composables/index.js +1 -1
- package/src/composables/useSSEBridge.js +118 -0
- package/src/editors/index.js +12 -0
- package/src/gen/index.js +3 -0
- package/src/kernel/Kernel.js +228 -3
- package/src/kernel/SSEBridge.js +354 -0
- package/src/kernel/SignalBus.js +8 -0
- package/src/kernel/index.js +5 -0
- package/src/composables/useSSE.js +0 -212
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "qdadm",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.30.0",
|
|
4
4
|
"description": "Vue 3 framework for admin dashboards with PrimeVue",
|
|
5
5
|
"author": "quazardous",
|
|
6
6
|
"license": "MIT",
|
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
".": "./src/index.js",
|
|
23
23
|
"./composables": "./src/composables/index.js",
|
|
24
24
|
"./components": "./src/components/index.js",
|
|
25
|
+
"./editors": "./src/editors/index.js",
|
|
25
26
|
"./module": "./src/module/index.js",
|
|
26
27
|
"./utils": "./src/utils/index.js",
|
|
27
28
|
"./styles": "./src/styles/index.scss",
|
|
@@ -40,10 +41,17 @@
|
|
|
40
41
|
"peerDependencies": {
|
|
41
42
|
"pinia": "^2.0.0",
|
|
42
43
|
"primevue": "^4.0.0",
|
|
43
|
-
"vanilla-jsoneditor": "^0.23.0",
|
|
44
44
|
"vue": "^3.3.0",
|
|
45
45
|
"vue-router": "^4.0.0"
|
|
46
46
|
},
|
|
47
|
+
"peerDependenciesMeta": {
|
|
48
|
+
"vanilla-jsoneditor": {
|
|
49
|
+
"optional": true
|
|
50
|
+
}
|
|
51
|
+
},
|
|
52
|
+
"optionalDependencies": {
|
|
53
|
+
"vanilla-jsoneditor": "^0.23.0"
|
|
54
|
+
},
|
|
47
55
|
"keywords": [
|
|
48
56
|
"vue",
|
|
49
57
|
"vue3",
|
package/src/components/index.js
CHANGED
|
@@ -32,15 +32,17 @@ export { default as ActionButtons } from './lists/ActionButtons.vue'
|
|
|
32
32
|
export { default as ActionColumn } from './lists/ActionColumn.vue'
|
|
33
33
|
export { default as FilterBar } from './lists/FilterBar.vue'
|
|
34
34
|
|
|
35
|
-
// Editors
|
|
35
|
+
// Editors (vanilla-jsoneditor free)
|
|
36
36
|
export { default as KeyValueEditor } from './editors/KeyValueEditor.vue'
|
|
37
37
|
export { default as LanguageEditor } from './editors/LanguageEditor.vue'
|
|
38
38
|
export { default as ScopeEditor } from './editors/ScopeEditor.vue'
|
|
39
|
-
export { default as VanillaJsonEditor } from './editors/VanillaJsonEditor.vue'
|
|
40
39
|
export { default as JsonEditorFoldable } from './editors/JsonEditorFoldable.vue'
|
|
41
|
-
export { default as JsonStructuredField } from './editors/JsonStructuredField.vue'
|
|
42
40
|
export { default as JsonViewer } from './editors/JsonViewer.vue'
|
|
43
41
|
|
|
42
|
+
// NOTE: VanillaJsonEditor and JsonStructuredField require vanilla-jsoneditor
|
|
43
|
+
// Import from 'qdadm/editors' instead:
|
|
44
|
+
// import { VanillaJsonEditor, JsonStructuredField } from 'qdadm/editors'
|
|
45
|
+
|
|
44
46
|
// Dialogs
|
|
45
47
|
export { default as SimpleDialog } from './dialogs/SimpleDialog.vue'
|
|
46
48
|
export { default as MultiStepDialog } from './dialogs/MultiStepDialog.vue'
|
package/src/composables/index.js
CHANGED
|
@@ -20,5 +20,5 @@ export { useSignals } from './useSignals'
|
|
|
20
20
|
export { useZoneRegistry } from './useZoneRegistry'
|
|
21
21
|
export { useHooks } from './useHooks'
|
|
22
22
|
export { useLayoutResolver, createLayoutComponents, layoutMeta, LAYOUT_TYPES } from './useLayoutResolver'
|
|
23
|
-
export {
|
|
23
|
+
export { useSSEBridge } from './useSSEBridge'
|
|
24
24
|
export { useDeferred, useDeferredValue, DEFERRED_INJECTION_KEY } from './useDeferred'
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useSSEBridge - Composable for SSE connection status
|
|
3
|
+
*
|
|
4
|
+
* Provides reactive connection status for SSEBridge.
|
|
5
|
+
* Components use this instead of managing their own EventSource.
|
|
6
|
+
*
|
|
7
|
+
* Usage:
|
|
8
|
+
* ```js
|
|
9
|
+
* const { connected, reconnecting, error, onEvent } = useSSEBridge()
|
|
10
|
+
*
|
|
11
|
+
* // Reactive connection status
|
|
12
|
+
* watchEffect(() => {
|
|
13
|
+
* if (connected.value) {
|
|
14
|
+
* console.log('SSE connected')
|
|
15
|
+
* }
|
|
16
|
+
* })
|
|
17
|
+
*
|
|
18
|
+
* // Subscribe to specific event (auto-cleanup on unmount)
|
|
19
|
+
* onEvent('task:completed', (payload) => {
|
|
20
|
+
* console.log('Task completed:', payload.data)
|
|
21
|
+
* })
|
|
22
|
+
* ```
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { ref, inject, onUnmounted, onMounted } from 'vue'
|
|
26
|
+
import { SSE_SIGNALS } from '../kernel/SSEBridge.js'
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* @typedef {object} UseSSEBridgeReturn
|
|
30
|
+
* @property {import('vue').Ref<boolean>} connected - Connection status
|
|
31
|
+
* @property {import('vue').Ref<boolean>} reconnecting - Reconnection in progress
|
|
32
|
+
* @property {import('vue').Ref<string|null>} error - Last error message
|
|
33
|
+
* @property {function} onEvent - Subscribe to SSE event (auto-cleanup)
|
|
34
|
+
* @property {function} onAnyEvent - Subscribe to all SSE events (wildcard)
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Composable for SSEBridge connection status
|
|
39
|
+
* @returns {UseSSEBridgeReturn}
|
|
40
|
+
*/
|
|
41
|
+
export function useSSEBridge() {
|
|
42
|
+
const signals = inject('qdadmSignals')
|
|
43
|
+
const sseBridge = inject('qdadmSSEBridge', null)
|
|
44
|
+
|
|
45
|
+
const connected = ref(sseBridge?.isConnected() ?? false)
|
|
46
|
+
const reconnecting = ref(sseBridge?.isReconnecting() ?? false)
|
|
47
|
+
const error = ref(null)
|
|
48
|
+
|
|
49
|
+
const unbinders = []
|
|
50
|
+
|
|
51
|
+
// Track connection status via signals
|
|
52
|
+
if (signals) {
|
|
53
|
+
const unbindConnected = signals.on(SSE_SIGNALS.CONNECTED, () => {
|
|
54
|
+
connected.value = true
|
|
55
|
+
reconnecting.value = false
|
|
56
|
+
error.value = null
|
|
57
|
+
})
|
|
58
|
+
unbinders.push(unbindConnected)
|
|
59
|
+
|
|
60
|
+
const unbindDisconnected = signals.on(SSE_SIGNALS.DISCONNECTED, () => {
|
|
61
|
+
connected.value = false
|
|
62
|
+
})
|
|
63
|
+
unbinders.push(unbindDisconnected)
|
|
64
|
+
|
|
65
|
+
const unbindError = signals.on(SSE_SIGNALS.ERROR, (payload) => {
|
|
66
|
+
error.value = payload.error
|
|
67
|
+
reconnecting.value = true
|
|
68
|
+
})
|
|
69
|
+
unbinders.push(unbindError)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Subscribe to a specific SSE event
|
|
74
|
+
* Automatically cleans up on component unmount.
|
|
75
|
+
*
|
|
76
|
+
* @param {string} eventName - SSE event name (without 'sse:' prefix)
|
|
77
|
+
* @param {function} handler - Event handler (payload) => void
|
|
78
|
+
* @returns {function} Unbind function
|
|
79
|
+
*/
|
|
80
|
+
const onEvent = (eventName, handler) => {
|
|
81
|
+
if (!signals) return () => {}
|
|
82
|
+
|
|
83
|
+
const signal = `sse:${eventName}`
|
|
84
|
+
const unbind = signals.on(signal, handler)
|
|
85
|
+
unbinders.push(unbind)
|
|
86
|
+
|
|
87
|
+
return unbind
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Subscribe to all SSE events (wildcard)
|
|
92
|
+
* Automatically cleans up on component unmount.
|
|
93
|
+
*
|
|
94
|
+
* @param {function} handler - Event handler (payload) => void
|
|
95
|
+
* @returns {function} Unbind function
|
|
96
|
+
*/
|
|
97
|
+
const onAnyEvent = (handler) => {
|
|
98
|
+
if (!signals) return () => {}
|
|
99
|
+
|
|
100
|
+
const unbind = signals.on('sse:*', handler)
|
|
101
|
+
unbinders.push(unbind)
|
|
102
|
+
|
|
103
|
+
return unbind
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Cleanup on unmount
|
|
107
|
+
onUnmounted(() => {
|
|
108
|
+
unbinders.forEach(unbind => unbind())
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
return {
|
|
112
|
+
connected,
|
|
113
|
+
reconnecting,
|
|
114
|
+
error,
|
|
115
|
+
onEvent,
|
|
116
|
+
onAnyEvent
|
|
117
|
+
}
|
|
118
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* qdadm/editors - Optional editors that require vanilla-jsoneditor
|
|
3
|
+
*
|
|
4
|
+
* Import from 'qdadm/editors' only if you need VanillaJsonEditor.
|
|
5
|
+
* This keeps vanilla-jsoneditor out of the main bundle.
|
|
6
|
+
*
|
|
7
|
+
* Usage:
|
|
8
|
+
* import { VanillaJsonEditor, JsonStructuredField } from 'qdadm/editors'
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export { default as VanillaJsonEditor } from '../components/editors/VanillaJsonEditor.vue'
|
|
12
|
+
export { default as JsonStructuredField } from '../components/editors/JsonStructuredField.vue'
|
package/src/gen/index.js
CHANGED
|
@@ -31,6 +31,9 @@ export { BaseConnector } from './connectors/index.js'
|
|
|
31
31
|
// T00313: ManualConnector - Inline entity/field definitions
|
|
32
32
|
export { ManualConnector } from './connectors/index.js'
|
|
33
33
|
|
|
34
|
+
// OpenAPIConnector - Parse OpenAPI 3.x specifications
|
|
35
|
+
export { OpenAPIConnector } from './connectors/index.js'
|
|
36
|
+
|
|
34
37
|
// T00316: StorageProfileFactory - Type definitions for storage profile pattern
|
|
35
38
|
// JSDoc types available when importing this module (no runtime exports)
|
|
36
39
|
import './StorageProfileFactory.js'
|
package/src/kernel/Kernel.js
CHANGED
|
@@ -56,6 +56,7 @@ import { createManagers } from '../entity/factory.js'
|
|
|
56
56
|
import { defaultStorageResolver } from '../entity/storage/factory.js'
|
|
57
57
|
import { createDeferredRegistry } from '../deferred/DeferredRegistry.js'
|
|
58
58
|
import { createEventRouter } from './EventRouter.js'
|
|
59
|
+
import { createSSEBridge } from './SSEBridge.js'
|
|
59
60
|
|
|
60
61
|
export class Kernel {
|
|
61
62
|
/**
|
|
@@ -82,6 +83,7 @@ export class Kernel {
|
|
|
82
83
|
* @param {object} options.security - Security config { role_hierarchy, role_permissions, entity_permissions }
|
|
83
84
|
* @param {boolean} options.warmup - Enable warmup at boot (default: true)
|
|
84
85
|
* @param {object} options.eventRouter - EventRouter config { 'source:signal': ['target:signal', ...] }
|
|
86
|
+
* @param {object} options.sse - SSEBridge config { url, reconnectDelay, signalPrefix, autoConnect, events }
|
|
85
87
|
*/
|
|
86
88
|
constructor(options) {
|
|
87
89
|
this.options = options
|
|
@@ -93,6 +95,7 @@ export class Kernel {
|
|
|
93
95
|
this.hookRegistry = null
|
|
94
96
|
this.deferred = null
|
|
95
97
|
this.eventRouter = null
|
|
98
|
+
this.sseBridge = null
|
|
96
99
|
this.layoutComponents = null
|
|
97
100
|
this.securityChecker = null
|
|
98
101
|
}
|
|
@@ -113,15 +116,19 @@ export class Kernel {
|
|
|
113
116
|
this._initModules()
|
|
114
117
|
// 4. Create router (needs routes from modules)
|
|
115
118
|
this._createRouter()
|
|
116
|
-
// 5.
|
|
119
|
+
// 5. Setup auth:expired handler (needs router + authAdapter)
|
|
120
|
+
this._setupAuthExpiredHandler()
|
|
121
|
+
// 6. Create orchestrator and remaining components
|
|
117
122
|
this._createOrchestrator()
|
|
118
|
-
//
|
|
123
|
+
// 7. Create EventRouter (needs signals + orchestrator)
|
|
119
124
|
this._createEventRouter()
|
|
125
|
+
// 8. Create SSEBridge (needs signals + authAdapter for token)
|
|
126
|
+
this._createSSEBridge()
|
|
120
127
|
this._setupSecurity()
|
|
121
128
|
this._createLayoutComponents()
|
|
122
129
|
this._createVueApp()
|
|
123
130
|
this._installPlugins()
|
|
124
|
-
//
|
|
131
|
+
// 9. Fire warmups (fire-and-forget, pages await via DeferredRegistry)
|
|
125
132
|
this._fireWarmups()
|
|
126
133
|
return this.vueApp
|
|
127
134
|
}
|
|
@@ -144,6 +151,58 @@ export class Kernel {
|
|
|
144
151
|
})
|
|
145
152
|
}
|
|
146
153
|
|
|
154
|
+
/**
|
|
155
|
+
* Setup handler for auth:expired signal
|
|
156
|
+
*
|
|
157
|
+
* When auth:expired is emitted (e.g., from API 401/403 response),
|
|
158
|
+
* this handler:
|
|
159
|
+
* 1. Calls authAdapter.logout() to clear tokens
|
|
160
|
+
* 2. Redirects to login page
|
|
161
|
+
* 3. Optionally calls onAuthExpired callback
|
|
162
|
+
*
|
|
163
|
+
* To emit auth:expired from your API client:
|
|
164
|
+
* ```js
|
|
165
|
+
* axios.interceptors.response.use(
|
|
166
|
+
* response => response,
|
|
167
|
+
* error => {
|
|
168
|
+
* if (error.response?.status === 401 || error.response?.status === 403) {
|
|
169
|
+
* signals.emit('auth:expired', { status: error.response.status })
|
|
170
|
+
* }
|
|
171
|
+
* return Promise.reject(error)
|
|
172
|
+
* }
|
|
173
|
+
* )
|
|
174
|
+
* ```
|
|
175
|
+
*/
|
|
176
|
+
_setupAuthExpiredHandler() {
|
|
177
|
+
const { authAdapter, onAuthExpired } = this.options
|
|
178
|
+
if (!authAdapter) return
|
|
179
|
+
|
|
180
|
+
this.signals.on('auth:expired', async (payload) => {
|
|
181
|
+
const debug = this.options.debug ?? false
|
|
182
|
+
if (debug) {
|
|
183
|
+
console.warn('[Kernel] auth:expired received:', payload)
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// 1. Logout (clear tokens)
|
|
187
|
+
if (authAdapter.logout) {
|
|
188
|
+
authAdapter.logout()
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// 2. Emit auth:logout signal
|
|
192
|
+
await this.signals.emit('auth:logout', { reason: 'expired', ...payload })
|
|
193
|
+
|
|
194
|
+
// 3. Redirect to login (if not already there)
|
|
195
|
+
if (this.router.currentRoute.value.name !== 'login') {
|
|
196
|
+
this.router.push({ name: 'login', query: { expired: '1' } })
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// 4. Optional callback
|
|
200
|
+
if (onAuthExpired) {
|
|
201
|
+
onAuthExpired(payload)
|
|
202
|
+
}
|
|
203
|
+
})
|
|
204
|
+
}
|
|
205
|
+
|
|
147
206
|
/**
|
|
148
207
|
* Fire entity cache warmups
|
|
149
208
|
* Fire-and-forget: pages that need cache will await via DeferredRegistry.
|
|
@@ -368,6 +427,52 @@ export class Kernel {
|
|
|
368
427
|
})
|
|
369
428
|
}
|
|
370
429
|
|
|
430
|
+
/**
|
|
431
|
+
* Create SSEBridge for Server-Sent Events to SignalBus integration
|
|
432
|
+
*
|
|
433
|
+
* SSE config:
|
|
434
|
+
* ```js
|
|
435
|
+
* sse: {
|
|
436
|
+
* url: '/api/events', // SSE endpoint
|
|
437
|
+
* reconnectDelay: 5000, // Reconnect delay (ms), 0 to disable
|
|
438
|
+
* signalPrefix: 'sse', // Signal prefix (default: 'sse')
|
|
439
|
+
* autoConnect: false, // Connect immediately vs wait for auth:login
|
|
440
|
+
* events: ['task:completed', 'bot:status'] // Event names to register
|
|
441
|
+
* }
|
|
442
|
+
* ```
|
|
443
|
+
*/
|
|
444
|
+
_createSSEBridge() {
|
|
445
|
+
const { sse, authAdapter } = this.options
|
|
446
|
+
if (!sse?.url) return
|
|
447
|
+
|
|
448
|
+
const debug = this.options.debug ?? false
|
|
449
|
+
|
|
450
|
+
// Build getToken from authAdapter
|
|
451
|
+
const getToken = authAdapter?.getToken
|
|
452
|
+
? () => authAdapter.getToken()
|
|
453
|
+
: () => localStorage.getItem('auth_token')
|
|
454
|
+
|
|
455
|
+
this.sseBridge = createSSEBridge({
|
|
456
|
+
signals: this.signals,
|
|
457
|
+
url: sse.url,
|
|
458
|
+
reconnectDelay: sse.reconnectDelay ?? 5000,
|
|
459
|
+
signalPrefix: sse.signalPrefix ?? 'sse',
|
|
460
|
+
autoConnect: sse.autoConnect ?? false,
|
|
461
|
+
withCredentials: sse.withCredentials ?? false,
|
|
462
|
+
tokenParam: sse.tokenParam ?? 'token',
|
|
463
|
+
getToken,
|
|
464
|
+
debug
|
|
465
|
+
})
|
|
466
|
+
|
|
467
|
+
// Register known event names if provided
|
|
468
|
+
if (sse.events?.length) {
|
|
469
|
+
// Wait for connection before registering
|
|
470
|
+
this.signals.once('sse:connected').then(() => {
|
|
471
|
+
this.sseBridge.registerEvents(sse.events)
|
|
472
|
+
})
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
|
|
371
476
|
/**
|
|
372
477
|
* Create layout components map for useLayoutResolver
|
|
373
478
|
* Maps layout types to their Vue components.
|
|
@@ -438,6 +543,11 @@ export class Kernel {
|
|
|
438
543
|
// Signal bus injection
|
|
439
544
|
app.provide('qdadmSignals', this.signals)
|
|
440
545
|
|
|
546
|
+
// SSEBridge injection (if configured)
|
|
547
|
+
if (this.sseBridge) {
|
|
548
|
+
app.provide('qdadmSSEBridge', this.sseBridge)
|
|
549
|
+
}
|
|
550
|
+
|
|
441
551
|
// Hook registry injection
|
|
442
552
|
app.provide('qdadmHooks', this.hookRegistry)
|
|
443
553
|
|
|
@@ -538,6 +648,22 @@ export class Kernel {
|
|
|
538
648
|
return this.eventRouter
|
|
539
649
|
}
|
|
540
650
|
|
|
651
|
+
/**
|
|
652
|
+
* Get the SSEBridge instance
|
|
653
|
+
* @returns {import('./SSEBridge.js').SSEBridge|null}
|
|
654
|
+
*/
|
|
655
|
+
getSSEBridge() {
|
|
656
|
+
return this.sseBridge
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
/**
|
|
660
|
+
* Shorthand accessor for SSE bridge
|
|
661
|
+
* @returns {import('./SSEBridge.js').SSEBridge|null}
|
|
662
|
+
*/
|
|
663
|
+
get sse() {
|
|
664
|
+
return this.sseBridge
|
|
665
|
+
}
|
|
666
|
+
|
|
541
667
|
/**
|
|
542
668
|
* Get the layout components map
|
|
543
669
|
* @returns {object} Layout components by type
|
|
@@ -570,4 +696,103 @@ export class Kernel {
|
|
|
570
696
|
get security() {
|
|
571
697
|
return this.securityChecker
|
|
572
698
|
}
|
|
699
|
+
|
|
700
|
+
/**
|
|
701
|
+
* Setup an axios client with automatic auth and error handling
|
|
702
|
+
*
|
|
703
|
+
* Adds interceptors that:
|
|
704
|
+
* - Add Authorization header with token from authAdapter
|
|
705
|
+
* - Emit auth:expired on 401/403 responses (triggers auto-logout)
|
|
706
|
+
* - Emit api:error on all errors for centralized handling
|
|
707
|
+
*
|
|
708
|
+
* Usage:
|
|
709
|
+
* ```js
|
|
710
|
+
* import axios from 'axios'
|
|
711
|
+
*
|
|
712
|
+
* const apiClient = axios.create({ baseURL: '/api' })
|
|
713
|
+
* kernel.setupApiClient(apiClient)
|
|
714
|
+
*
|
|
715
|
+
* // Now 401/403 errors automatically trigger logout
|
|
716
|
+
* const storage = new ApiStorage({ endpoint: '/users', client: apiClient })
|
|
717
|
+
* ```
|
|
718
|
+
*
|
|
719
|
+
* Or let Kernel create the client:
|
|
720
|
+
* ```js
|
|
721
|
+
* const kernel = new Kernel({
|
|
722
|
+
* ...options,
|
|
723
|
+
* apiClient: { baseURL: '/api' } // axios.create() options
|
|
724
|
+
* })
|
|
725
|
+
* const apiClient = kernel.getApiClient()
|
|
726
|
+
* ```
|
|
727
|
+
*
|
|
728
|
+
* @param {object} client - Axios instance to configure
|
|
729
|
+
* @returns {object} The configured axios instance
|
|
730
|
+
*/
|
|
731
|
+
setupApiClient(client) {
|
|
732
|
+
const { authAdapter } = this.options
|
|
733
|
+
const signals = this.signals
|
|
734
|
+
const debug = this.options.debug ?? false
|
|
735
|
+
|
|
736
|
+
// Request interceptor: add Authorization header
|
|
737
|
+
client.interceptors.request.use(
|
|
738
|
+
(config) => {
|
|
739
|
+
if (authAdapter?.getToken) {
|
|
740
|
+
const token = authAdapter.getToken()
|
|
741
|
+
if (token) {
|
|
742
|
+
config.headers = config.headers || {}
|
|
743
|
+
config.headers.Authorization = `Bearer ${token}`
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
return config
|
|
747
|
+
},
|
|
748
|
+
(error) => Promise.reject(error)
|
|
749
|
+
)
|
|
750
|
+
|
|
751
|
+
// Response interceptor: handle auth errors
|
|
752
|
+
client.interceptors.response.use(
|
|
753
|
+
(response) => response,
|
|
754
|
+
async (error) => {
|
|
755
|
+
const status = error.response?.status
|
|
756
|
+
const url = error.config?.url
|
|
757
|
+
|
|
758
|
+
// Emit api:error for all errors
|
|
759
|
+
await signals.emit('api:error', {
|
|
760
|
+
status,
|
|
761
|
+
message: error.message,
|
|
762
|
+
url,
|
|
763
|
+
error
|
|
764
|
+
})
|
|
765
|
+
|
|
766
|
+
// Emit auth:expired for 401/403
|
|
767
|
+
if (status === 401 || status === 403) {
|
|
768
|
+
if (debug) {
|
|
769
|
+
console.warn(`[Kernel] API ${status} error on ${url}, emitting auth:expired`)
|
|
770
|
+
}
|
|
771
|
+
await signals.emit('auth:expired', { status, url })
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
return Promise.reject(error)
|
|
775
|
+
}
|
|
776
|
+
)
|
|
777
|
+
|
|
778
|
+
// Store reference
|
|
779
|
+
this._apiClient = client
|
|
780
|
+
return client
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
/**
|
|
784
|
+
* Get the configured API client
|
|
785
|
+
* @returns {object|null} Axios instance or null if not configured
|
|
786
|
+
*/
|
|
787
|
+
getApiClient() {
|
|
788
|
+
return this._apiClient
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
/**
|
|
792
|
+
* Shorthand accessor for API client
|
|
793
|
+
* @returns {object|null}
|
|
794
|
+
*/
|
|
795
|
+
get api() {
|
|
796
|
+
return this._apiClient
|
|
797
|
+
}
|
|
573
798
|
}
|
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SSEBridge - Server-Sent Events to SignalBus bridge
|
|
3
|
+
*
|
|
4
|
+
* Manages a single SSE connection and emits all events to SignalBus.
|
|
5
|
+
* Components subscribe via signals.on('sse:eventName', handler) instead of
|
|
6
|
+
* managing their own EventSource connections.
|
|
7
|
+
*
|
|
8
|
+
* Benefits:
|
|
9
|
+
* - Single SSE connection per app (vs. one per component)
|
|
10
|
+
* - Decoupled event handling via SignalBus
|
|
11
|
+
* - Automatic reconnection with configurable delay
|
|
12
|
+
* - Connection status available via signals
|
|
13
|
+
*
|
|
14
|
+
* Signal naming:
|
|
15
|
+
* - `sse:connected` - Emitted when connection established
|
|
16
|
+
* - `sse:disconnected` - Emitted when connection lost
|
|
17
|
+
* - `sse:error` - Emitted on connection error
|
|
18
|
+
* - `sse:{eventName}` - SSE event forwarded (e.g., sse:task:completed)
|
|
19
|
+
*
|
|
20
|
+
* Usage in Kernel:
|
|
21
|
+
* ```js
|
|
22
|
+
* new Kernel({
|
|
23
|
+
* sse: {
|
|
24
|
+
* url: '/api/events',
|
|
25
|
+
* reconnectDelay: 5000,
|
|
26
|
+
* signalPrefix: 'sse',
|
|
27
|
+
* autoConnect: true
|
|
28
|
+
* }
|
|
29
|
+
* })
|
|
30
|
+
* ```
|
|
31
|
+
*
|
|
32
|
+
* Usage in components:
|
|
33
|
+
* ```js
|
|
34
|
+
* const signals = inject('qdadmSignals')
|
|
35
|
+
*
|
|
36
|
+
* // Subscribe to specific SSE events
|
|
37
|
+
* signals.on('sse:task:completed', ({ data }) => {
|
|
38
|
+
* console.log('Task completed:', data)
|
|
39
|
+
* })
|
|
40
|
+
*
|
|
41
|
+
* // Subscribe to all SSE events via wildcard
|
|
42
|
+
* signals.on('sse:*', ({ event, data }) => {
|
|
43
|
+
* console.log(`SSE event ${event}:`, data)
|
|
44
|
+
* })
|
|
45
|
+
* ```
|
|
46
|
+
*/
|
|
47
|
+
|
|
48
|
+
export const SSE_SIGNALS = {
|
|
49
|
+
CONNECTED: 'sse:connected',
|
|
50
|
+
DISCONNECTED: 'sse:disconnected',
|
|
51
|
+
ERROR: 'sse:error',
|
|
52
|
+
MESSAGE: 'sse:message'
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export class SSEBridge {
|
|
56
|
+
/**
|
|
57
|
+
* @param {object} options
|
|
58
|
+
* @param {SignalBus} options.signals - SignalBus instance
|
|
59
|
+
* @param {string} options.url - SSE endpoint URL
|
|
60
|
+
* @param {number} options.reconnectDelay - Delay before reconnect (ms), 0 to disable
|
|
61
|
+
* @param {string} options.signalPrefix - Prefix for emitted signals (default: 'sse')
|
|
62
|
+
* @param {boolean} options.autoConnect - Connect immediately (default: false, waits for auth:login)
|
|
63
|
+
* @param {boolean} options.withCredentials - Include credentials in request
|
|
64
|
+
* @param {string} options.tokenParam - Query param name for auth token
|
|
65
|
+
* @param {function} options.getToken - Function to get auth token
|
|
66
|
+
* @param {boolean} options.debug - Enable debug logging
|
|
67
|
+
*/
|
|
68
|
+
constructor(options) {
|
|
69
|
+
const {
|
|
70
|
+
signals,
|
|
71
|
+
url,
|
|
72
|
+
reconnectDelay = 5000,
|
|
73
|
+
signalPrefix = 'sse',
|
|
74
|
+
autoConnect = false,
|
|
75
|
+
withCredentials = false,
|
|
76
|
+
tokenParam = 'token',
|
|
77
|
+
getToken = null,
|
|
78
|
+
debug = false
|
|
79
|
+
} = options
|
|
80
|
+
|
|
81
|
+
if (!signals) {
|
|
82
|
+
throw new Error('[SSEBridge] signals (SignalBus) is required')
|
|
83
|
+
}
|
|
84
|
+
if (!url) {
|
|
85
|
+
throw new Error('[SSEBridge] url is required')
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
this._signals = signals
|
|
89
|
+
this._url = url
|
|
90
|
+
this._reconnectDelay = reconnectDelay
|
|
91
|
+
this._signalPrefix = signalPrefix
|
|
92
|
+
this._withCredentials = withCredentials
|
|
93
|
+
this._tokenParam = tokenParam
|
|
94
|
+
this._getToken = getToken
|
|
95
|
+
this._debug = debug
|
|
96
|
+
|
|
97
|
+
this._eventSource = null
|
|
98
|
+
this._reconnectTimer = null
|
|
99
|
+
this._connected = false
|
|
100
|
+
this._reconnecting = false
|
|
101
|
+
|
|
102
|
+
// Auto-connect or wait for auth:login
|
|
103
|
+
if (autoConnect) {
|
|
104
|
+
this.connect()
|
|
105
|
+
} else {
|
|
106
|
+
// Wait for auth:login signal to connect
|
|
107
|
+
this._signals.once('auth:login').then(() => {
|
|
108
|
+
this._log('Received auth:login, connecting SSE')
|
|
109
|
+
this.connect()
|
|
110
|
+
})
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Disconnect on auth:logout
|
|
114
|
+
this._signals.on('auth:logout', () => {
|
|
115
|
+
this._log('Received auth:logout, disconnecting SSE')
|
|
116
|
+
this.disconnect()
|
|
117
|
+
})
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Build signal name with prefix
|
|
122
|
+
* @param {string} eventName - Event name
|
|
123
|
+
* @returns {string} Full signal name
|
|
124
|
+
*/
|
|
125
|
+
_buildSignal(eventName) {
|
|
126
|
+
return `${this._signalPrefix}:${eventName}`
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Debug logging
|
|
131
|
+
*/
|
|
132
|
+
_log(...args) {
|
|
133
|
+
if (this._debug) {
|
|
134
|
+
console.debug('[SSEBridge]', ...args)
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Build SSE URL with auth token
|
|
140
|
+
* @returns {string}
|
|
141
|
+
*/
|
|
142
|
+
_buildUrl() {
|
|
143
|
+
const token = this._getToken?.()
|
|
144
|
+
const sseUrl = new URL(this._url, window.location.origin)
|
|
145
|
+
|
|
146
|
+
if (token && this._tokenParam) {
|
|
147
|
+
sseUrl.searchParams.set(this._tokenParam, token)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return sseUrl.toString()
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Connect to SSE endpoint
|
|
155
|
+
*/
|
|
156
|
+
connect() {
|
|
157
|
+
// Clean up existing
|
|
158
|
+
if (this._eventSource) {
|
|
159
|
+
this._eventSource.close()
|
|
160
|
+
this._eventSource = null
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Clear pending reconnect
|
|
164
|
+
if (this._reconnectTimer) {
|
|
165
|
+
clearTimeout(this._reconnectTimer)
|
|
166
|
+
this._reconnectTimer = null
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
try {
|
|
170
|
+
const url = this._buildUrl()
|
|
171
|
+
this._log('Connecting to', url)
|
|
172
|
+
|
|
173
|
+
this._eventSource = new EventSource(url, {
|
|
174
|
+
withCredentials: this._withCredentials
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
this._eventSource.onopen = () => {
|
|
178
|
+
this._connected = true
|
|
179
|
+
this._reconnecting = false
|
|
180
|
+
this._log('Connected')
|
|
181
|
+
|
|
182
|
+
this._signals.emit(SSE_SIGNALS.CONNECTED, {
|
|
183
|
+
url: this._url,
|
|
184
|
+
timestamp: new Date()
|
|
185
|
+
})
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
this._eventSource.onerror = (err) => {
|
|
189
|
+
this._connected = false
|
|
190
|
+
this._log('Connection error')
|
|
191
|
+
|
|
192
|
+
this._signals.emit(SSE_SIGNALS.ERROR, {
|
|
193
|
+
error: 'Connection error',
|
|
194
|
+
timestamp: new Date()
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
// Close broken connection
|
|
198
|
+
if (this._eventSource) {
|
|
199
|
+
this._eventSource.close()
|
|
200
|
+
this._eventSource = null
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
this._signals.emit(SSE_SIGNALS.DISCONNECTED, {
|
|
204
|
+
timestamp: new Date()
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
// Schedule reconnect
|
|
208
|
+
this._scheduleReconnect()
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Handle generic message events (event: message)
|
|
212
|
+
this._eventSource.onmessage = (event) => {
|
|
213
|
+
this._handleEvent('message', event)
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// For named events, we need to add listeners dynamically
|
|
217
|
+
// Since EventSource doesn't expose event names, we use a wrapper approach:
|
|
218
|
+
// The server should send events with `event:` field, we handle them generically
|
|
219
|
+
|
|
220
|
+
// Note: Named events require explicit addEventListener.
|
|
221
|
+
// To support arbitrary event names, the app should either:
|
|
222
|
+
// 1. Use `message` event type and include event name in data
|
|
223
|
+
// 2. Pre-register known event names via registerEvents()
|
|
224
|
+
|
|
225
|
+
} catch (err) {
|
|
226
|
+
this._log('Connect error:', err.message)
|
|
227
|
+
this._signals.emit(SSE_SIGNALS.ERROR, {
|
|
228
|
+
error: err.message,
|
|
229
|
+
timestamp: new Date()
|
|
230
|
+
})
|
|
231
|
+
this._scheduleReconnect()
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Register listeners for specific SSE event types
|
|
237
|
+
* Required because EventSource requires explicit addEventListener for named events.
|
|
238
|
+
*
|
|
239
|
+
* @param {string[]} eventNames - Event names to listen for
|
|
240
|
+
*/
|
|
241
|
+
registerEvents(eventNames) {
|
|
242
|
+
if (!this._eventSource) {
|
|
243
|
+
this._log('Cannot register events: not connected')
|
|
244
|
+
return
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
for (const eventName of eventNames) {
|
|
248
|
+
this._eventSource.addEventListener(eventName, (event) => {
|
|
249
|
+
this._handleEvent(eventName, event)
|
|
250
|
+
})
|
|
251
|
+
this._log('Registered event:', eventName)
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Handle incoming SSE event
|
|
257
|
+
* @param {string} eventName - Event name
|
|
258
|
+
* @param {MessageEvent} event - SSE event
|
|
259
|
+
*/
|
|
260
|
+
_handleEvent(eventName, event) {
|
|
261
|
+
let data
|
|
262
|
+
try {
|
|
263
|
+
data = JSON.parse(event.data)
|
|
264
|
+
} catch {
|
|
265
|
+
data = event.data
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const signal = this._buildSignal(eventName)
|
|
269
|
+
this._log(`Emitting ${signal}:`, data)
|
|
270
|
+
|
|
271
|
+
this._signals.emit(signal, {
|
|
272
|
+
event: eventName,
|
|
273
|
+
data,
|
|
274
|
+
timestamp: new Date(),
|
|
275
|
+
lastEventId: event.lastEventId
|
|
276
|
+
})
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Schedule reconnection
|
|
281
|
+
*/
|
|
282
|
+
_scheduleReconnect() {
|
|
283
|
+
if (this._reconnectDelay <= 0) return
|
|
284
|
+
if (this._reconnectTimer) return
|
|
285
|
+
|
|
286
|
+
this._reconnecting = true
|
|
287
|
+
this._log(`Reconnecting in ${this._reconnectDelay}ms`)
|
|
288
|
+
|
|
289
|
+
this._reconnectTimer = setTimeout(() => {
|
|
290
|
+
this._reconnectTimer = null
|
|
291
|
+
if (!this._connected) {
|
|
292
|
+
this.connect()
|
|
293
|
+
}
|
|
294
|
+
}, this._reconnectDelay)
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Disconnect from SSE endpoint
|
|
299
|
+
*/
|
|
300
|
+
disconnect() {
|
|
301
|
+
if (this._reconnectTimer) {
|
|
302
|
+
clearTimeout(this._reconnectTimer)
|
|
303
|
+
this._reconnectTimer = null
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
if (this._eventSource) {
|
|
307
|
+
this._eventSource.close()
|
|
308
|
+
this._eventSource = null
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
if (this._connected) {
|
|
312
|
+
this._connected = false
|
|
313
|
+
this._signals.emit(SSE_SIGNALS.DISCONNECTED, {
|
|
314
|
+
timestamp: new Date()
|
|
315
|
+
})
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
this._reconnecting = false
|
|
319
|
+
this._log('Disconnected')
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Reconnect (disconnect + connect)
|
|
324
|
+
*/
|
|
325
|
+
reconnect() {
|
|
326
|
+
this.disconnect()
|
|
327
|
+
this.connect()
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Check if connected
|
|
332
|
+
* @returns {boolean}
|
|
333
|
+
*/
|
|
334
|
+
isConnected() {
|
|
335
|
+
return this._connected
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Check if reconnecting
|
|
340
|
+
* @returns {boolean}
|
|
341
|
+
*/
|
|
342
|
+
isReconnecting() {
|
|
343
|
+
return this._reconnecting
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Factory function to create SSEBridge
|
|
349
|
+
* @param {object} options - SSEBridge options
|
|
350
|
+
* @returns {SSEBridge}
|
|
351
|
+
*/
|
|
352
|
+
export function createSSEBridge(options) {
|
|
353
|
+
return new SSEBridge(options)
|
|
354
|
+
}
|
package/src/kernel/SignalBus.js
CHANGED
|
@@ -25,6 +25,14 @@ export const SIGNALS = {
|
|
|
25
25
|
ENTITY_UPDATED: 'entity:updated',
|
|
26
26
|
ENTITY_DELETED: 'entity:deleted',
|
|
27
27
|
|
|
28
|
+
// Auth lifecycle signals
|
|
29
|
+
AUTH_LOGIN: 'auth:login',
|
|
30
|
+
AUTH_LOGOUT: 'auth:logout',
|
|
31
|
+
AUTH_EXPIRED: 'auth:expired', // Emitted on 401/403 API responses
|
|
32
|
+
|
|
33
|
+
// API error signals
|
|
34
|
+
API_ERROR: 'api:error', // Emitted on any API error { status, message, url }
|
|
35
|
+
|
|
28
36
|
// Pattern for entity-specific signals
|
|
29
37
|
// Use buildSignal(entityName, action) for these
|
|
30
38
|
}
|
package/src/kernel/index.js
CHANGED
|
@@ -1,212 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* useSSE - Server-Sent Events composable
|
|
3
|
-
*
|
|
4
|
-
* Manages EventSource connection with automatic reconnection and cleanup.
|
|
5
|
-
* Uses authAdapter.getToken() for authentication if available.
|
|
6
|
-
*
|
|
7
|
-
* Usage:
|
|
8
|
-
* const { connected, error, reconnect, close } = useSSE('/api/events', {
|
|
9
|
-
* eventHandlers: {
|
|
10
|
-
* 'bot:status': (data) => console.log('Bot status:', data),
|
|
11
|
-
* 'task:complete': (data) => handleTaskComplete(data)
|
|
12
|
-
* }
|
|
13
|
-
* })
|
|
14
|
-
*
|
|
15
|
-
* Options:
|
|
16
|
-
* - eventHandlers: Object mapping event names to handler functions
|
|
17
|
-
* - reconnectDelay: Delay in ms before reconnecting (default: 5000)
|
|
18
|
-
* - autoConnect: Start connection immediately (default: true)
|
|
19
|
-
* - withCredentials: Include credentials in request (default: false)
|
|
20
|
-
* - tokenParam: Query param name for token (default: 'token')
|
|
21
|
-
* - getToken: Custom token getter function (default: uses authAdapter)
|
|
22
|
-
*/
|
|
23
|
-
|
|
24
|
-
import { ref, inject, onUnmounted, onMounted } from 'vue'
|
|
25
|
-
|
|
26
|
-
export function useSSE(url, options = {}) {
|
|
27
|
-
const {
|
|
28
|
-
eventHandlers = {},
|
|
29
|
-
reconnectDelay = 5000,
|
|
30
|
-
autoConnect = true,
|
|
31
|
-
withCredentials = false,
|
|
32
|
-
tokenParam = 'token',
|
|
33
|
-
getToken: customGetToken = null
|
|
34
|
-
} = options
|
|
35
|
-
|
|
36
|
-
const authAdapter = inject('authAdapter', null)
|
|
37
|
-
|
|
38
|
-
const connected = ref(false)
|
|
39
|
-
const error = ref(null)
|
|
40
|
-
const reconnecting = ref(false)
|
|
41
|
-
|
|
42
|
-
let eventSource = null
|
|
43
|
-
let reconnectTimer = null
|
|
44
|
-
|
|
45
|
-
/**
|
|
46
|
-
* Get authentication token
|
|
47
|
-
*/
|
|
48
|
-
const getToken = () => {
|
|
49
|
-
// Custom getter takes precedence
|
|
50
|
-
if (customGetToken) {
|
|
51
|
-
return customGetToken()
|
|
52
|
-
}
|
|
53
|
-
// Try authAdapter
|
|
54
|
-
if (authAdapter?.getToken) {
|
|
55
|
-
return authAdapter.getToken()
|
|
56
|
-
}
|
|
57
|
-
// Fallback to localStorage
|
|
58
|
-
return localStorage.getItem('auth_token')
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
/**
|
|
62
|
-
* Build SSE URL with token
|
|
63
|
-
*/
|
|
64
|
-
const buildUrl = () => {
|
|
65
|
-
const token = getToken()
|
|
66
|
-
const sseUrl = new URL(url, window.location.origin)
|
|
67
|
-
|
|
68
|
-
if (token && tokenParam) {
|
|
69
|
-
sseUrl.searchParams.set(tokenParam, token)
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
return sseUrl.toString()
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
/**
|
|
76
|
-
* Connect to SSE endpoint
|
|
77
|
-
*/
|
|
78
|
-
const connect = () => {
|
|
79
|
-
// Clean up existing connection
|
|
80
|
-
if (eventSource) {
|
|
81
|
-
eventSource.close()
|
|
82
|
-
eventSource = null
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
// Clear any pending reconnect
|
|
86
|
-
if (reconnectTimer) {
|
|
87
|
-
clearTimeout(reconnectTimer)
|
|
88
|
-
reconnectTimer = null
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
try {
|
|
92
|
-
const sseUrl = buildUrl()
|
|
93
|
-
|
|
94
|
-
eventSource = new EventSource(sseUrl, {
|
|
95
|
-
withCredentials
|
|
96
|
-
})
|
|
97
|
-
|
|
98
|
-
eventSource.onopen = () => {
|
|
99
|
-
connected.value = true
|
|
100
|
-
error.value = null
|
|
101
|
-
reconnecting.value = false
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
eventSource.onerror = (err) => {
|
|
105
|
-
connected.value = false
|
|
106
|
-
error.value = 'Connection error'
|
|
107
|
-
|
|
108
|
-
// Close broken connection
|
|
109
|
-
if (eventSource) {
|
|
110
|
-
eventSource.close()
|
|
111
|
-
eventSource = null
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
// Schedule reconnect
|
|
115
|
-
if (reconnectDelay > 0) {
|
|
116
|
-
reconnecting.value = true
|
|
117
|
-
reconnectTimer = setTimeout(() => {
|
|
118
|
-
if (!connected.value) {
|
|
119
|
-
connect()
|
|
120
|
-
}
|
|
121
|
-
}, reconnectDelay)
|
|
122
|
-
}
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
// Register custom event handlers
|
|
126
|
-
for (const [eventName, handler] of Object.entries(eventHandlers)) {
|
|
127
|
-
if (eventName === 'message') continue // Handle below
|
|
128
|
-
|
|
129
|
-
eventSource.addEventListener(eventName, (event) => {
|
|
130
|
-
try {
|
|
131
|
-
const data = JSON.parse(event.data)
|
|
132
|
-
handler(data, event)
|
|
133
|
-
} catch (err) {
|
|
134
|
-
console.error(`[useSSE] Error parsing event "${eventName}":`, err)
|
|
135
|
-
// Call handler with raw data on parse error
|
|
136
|
-
handler(event.data, event)
|
|
137
|
-
}
|
|
138
|
-
})
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
// Handle generic message events
|
|
142
|
-
eventSource.onmessage = (event) => {
|
|
143
|
-
if (!eventHandlers.message) return
|
|
144
|
-
|
|
145
|
-
try {
|
|
146
|
-
const data = JSON.parse(event.data)
|
|
147
|
-
eventHandlers.message(data, event)
|
|
148
|
-
} catch (err) {
|
|
149
|
-
console.error('[useSSE] Error parsing message:', err)
|
|
150
|
-
eventHandlers.message(event.data, event)
|
|
151
|
-
}
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
} catch (err) {
|
|
155
|
-
error.value = err.message
|
|
156
|
-
connected.value = false
|
|
157
|
-
}
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
/**
|
|
161
|
-
* Close connection
|
|
162
|
-
*/
|
|
163
|
-
const close = () => {
|
|
164
|
-
if (reconnectTimer) {
|
|
165
|
-
clearTimeout(reconnectTimer)
|
|
166
|
-
reconnectTimer = null
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
if (eventSource) {
|
|
170
|
-
eventSource.close()
|
|
171
|
-
eventSource = null
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
connected.value = false
|
|
175
|
-
reconnecting.value = false
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
/**
|
|
179
|
-
* Reconnect (close and connect)
|
|
180
|
-
*/
|
|
181
|
-
const reconnect = () => {
|
|
182
|
-
close()
|
|
183
|
-
connect()
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
// Auto-connect on mount if enabled
|
|
187
|
-
onMounted(() => {
|
|
188
|
-
if (autoConnect) {
|
|
189
|
-
connect()
|
|
190
|
-
}
|
|
191
|
-
})
|
|
192
|
-
|
|
193
|
-
// Cleanup on unmount
|
|
194
|
-
onUnmounted(() => {
|
|
195
|
-
close()
|
|
196
|
-
})
|
|
197
|
-
|
|
198
|
-
return {
|
|
199
|
-
/** Reactive connection state */
|
|
200
|
-
connected,
|
|
201
|
-
/** Reactive error message */
|
|
202
|
-
error,
|
|
203
|
-
/** Reactive reconnecting state */
|
|
204
|
-
reconnecting,
|
|
205
|
-
/** Manually connect */
|
|
206
|
-
connect,
|
|
207
|
-
/** Close connection */
|
|
208
|
-
close,
|
|
209
|
-
/** Reconnect (close + connect) */
|
|
210
|
-
reconnect
|
|
211
|
-
}
|
|
212
|
-
}
|