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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "qdadm",
3
- "version": "0.29.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",
@@ -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'
@@ -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 { useSSE } from './useSSE'
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'
@@ -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. Create orchestrator and remaining components
119
+ // 5. Setup auth:expired handler (needs router + authAdapter)
120
+ this._setupAuthExpiredHandler()
121
+ // 6. Create orchestrator and remaining components
117
122
  this._createOrchestrator()
118
- // 6. Create EventRouter (needs signals + orchestrator)
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
- // 6. Fire warmups (fire-and-forget, pages await via DeferredRegistry)
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
+ }
@@ -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
  }
@@ -16,3 +16,8 @@ export {
16
16
  EventRouter,
17
17
  createEventRouter,
18
18
  } from './EventRouter.js'
19
+ export {
20
+ SSEBridge,
21
+ createSSEBridge,
22
+ SSE_SIGNALS,
23
+ } from './SSEBridge.js'
@@ -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
- }