qdadm 0.28.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 +13 -3
- 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/FieldMapper.js +116 -0
- package/src/gen/StorageProfileFactory.js +109 -0
- package/src/gen/connectors/BaseConnector.js +142 -0
- package/src/gen/connectors/ManualConnector.js +385 -0
- package/src/gen/connectors/ManualConnector.test.js +499 -0
- package/src/gen/connectors/OpenAPIConnector.js +568 -0
- package/src/gen/connectors/OpenAPIConnector.test.js +737 -0
- package/src/gen/connectors/__fixtures__/sample-openapi.json +311 -0
- package/src/gen/connectors/index.js +11 -0
- package/src/gen/createManagers.js +224 -0
- package/src/gen/decorators.js +129 -0
- package/src/gen/generateManagers.js +266 -0
- package/src/gen/generateManagers.test.js +358 -0
- package/src/gen/index.js +48 -0
- package/src/gen/schema.js +221 -0
- package/src/gen/vite-plugin.js +105 -0
- package/src/generated/managers/testManager.js +45 -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
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unified Schema Types
|
|
3
|
+
*
|
|
4
|
+
* Core contracts that abstract differences between OpenAPI, Pydantic, and manual schema sources.
|
|
5
|
+
* These types provide a common format for entity and field definitions that can be used
|
|
6
|
+
* by connectors (schema sources) and generators (code output).
|
|
7
|
+
*
|
|
8
|
+
* @module gen/schema
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Field Type Enumeration
|
|
13
|
+
*
|
|
14
|
+
* Unified field types that abstract source-specific types (OpenAPI string/integer,
|
|
15
|
+
* Pydantic str/int, etc.) into a common set.
|
|
16
|
+
*
|
|
17
|
+
* @typedef {'text' | 'number' | 'boolean' | 'date' | 'datetime' | 'email' | 'url' | 'uuid' | 'array' | 'object'} UnifiedFieldType
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Reference Definition
|
|
22
|
+
*
|
|
23
|
+
* Describes a relation to another entity for foreign key fields.
|
|
24
|
+
*
|
|
25
|
+
* @typedef {object} UnifiedFieldReference
|
|
26
|
+
* @property {string} entity - Target entity name (e.g., 'users', 'categories')
|
|
27
|
+
* @property {string} [labelField] - Field to display as label (e.g., 'name', 'title')
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Unified Field Schema
|
|
32
|
+
*
|
|
33
|
+
* Common field definition that abstracts OpenAPI property, Pydantic field, or manual definition.
|
|
34
|
+
* Connectors transform their source-specific formats into this common shape.
|
|
35
|
+
*
|
|
36
|
+
* @typedef {object} UnifiedFieldSchema
|
|
37
|
+
* @property {string} name - Field name (e.g., 'email', 'created_at')
|
|
38
|
+
* @property {UnifiedFieldType} type - Unified field type
|
|
39
|
+
* @property {string} [label] - Human-readable label (e.g., 'Email Address')
|
|
40
|
+
* @property {boolean} [required] - Whether field is required (default: false)
|
|
41
|
+
* @property {boolean} [readOnly] - Whether field is read-only (default: false)
|
|
42
|
+
* @property {boolean} [hidden] - Whether field should be hidden in UI (default: false)
|
|
43
|
+
* @property {string} [format] - Original format hint from source (e.g., 'date-time', 'uri', 'email')
|
|
44
|
+
* @property {string[]} [enum] - Allowed values for enumeration fields
|
|
45
|
+
* @property {*} [default] - Default value for the field
|
|
46
|
+
* @property {UnifiedFieldReference} [reference] - Relation to another entity
|
|
47
|
+
* @property {Record<string, *>} [extensions] - Project-specific extensions
|
|
48
|
+
*
|
|
49
|
+
* @example
|
|
50
|
+
* // Simple text field
|
|
51
|
+
* const nameField = {
|
|
52
|
+
* name: 'name',
|
|
53
|
+
* type: 'text',
|
|
54
|
+
* label: 'Full Name',
|
|
55
|
+
* required: true
|
|
56
|
+
* }
|
|
57
|
+
*
|
|
58
|
+
* @example
|
|
59
|
+
* // Enum field with options
|
|
60
|
+
* const statusField = {
|
|
61
|
+
* name: 'status',
|
|
62
|
+
* type: 'text',
|
|
63
|
+
* label: 'Status',
|
|
64
|
+
* enum: ['draft', 'published', 'archived'],
|
|
65
|
+
* default: 'draft'
|
|
66
|
+
* }
|
|
67
|
+
*
|
|
68
|
+
* @example
|
|
69
|
+
* // Foreign key with reference
|
|
70
|
+
* const authorField = {
|
|
71
|
+
* name: 'author_id',
|
|
72
|
+
* type: 'number',
|
|
73
|
+
* label: 'Author',
|
|
74
|
+
* reference: {
|
|
75
|
+
* entity: 'users',
|
|
76
|
+
* labelField: 'username'
|
|
77
|
+
* }
|
|
78
|
+
* }
|
|
79
|
+
*/
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Unified Entity Schema
|
|
83
|
+
*
|
|
84
|
+
* Common entity definition that abstracts OpenAPI path/schema, Pydantic model, or manual definition.
|
|
85
|
+
* This is the primary contract between schema sources (connectors) and consumers (generators, runtime).
|
|
86
|
+
*
|
|
87
|
+
* @typedef {object} UnifiedEntitySchema
|
|
88
|
+
* @property {string} name - Entity name, typically plural lowercase (e.g., 'users', 'blog_posts')
|
|
89
|
+
* @property {string} endpoint - API endpoint path (e.g., '/users', '/api/v1/posts')
|
|
90
|
+
* @property {string} [label] - Human-readable singular label (e.g., 'User', 'Blog Post')
|
|
91
|
+
* @property {string} [labelPlural] - Human-readable plural label (e.g., 'Users', 'Blog Posts')
|
|
92
|
+
* @property {string} [labelField] - Field used as display label (e.g., 'name', 'title')
|
|
93
|
+
* @property {string} [routePrefix] - Route prefix for admin UI (e.g., 'user', 'blog-post')
|
|
94
|
+
* @property {string} [idField] - Primary key field name (default: 'id')
|
|
95
|
+
* @property {boolean} [readOnly] - Whether entity is read-only (no create/update/delete)
|
|
96
|
+
* @property {Record<string, UnifiedFieldSchema>} fields - Field definitions keyed by field name
|
|
97
|
+
* @property {Record<string, *>} [extensions] - Project-specific extensions
|
|
98
|
+
*
|
|
99
|
+
* @example
|
|
100
|
+
* // Complete entity schema
|
|
101
|
+
* const usersSchema = {
|
|
102
|
+
* name: 'users',
|
|
103
|
+
* endpoint: '/api/users',
|
|
104
|
+
* label: 'User',
|
|
105
|
+
* labelPlural: 'Users',
|
|
106
|
+
* labelField: 'username',
|
|
107
|
+
* routePrefix: 'user',
|
|
108
|
+
* idField: 'id',
|
|
109
|
+
* readOnly: false,
|
|
110
|
+
* fields: {
|
|
111
|
+
* id: { name: 'id', type: 'number', readOnly: true },
|
|
112
|
+
* username: { name: 'username', type: 'text', required: true },
|
|
113
|
+
* email: { name: 'email', type: 'email', required: true },
|
|
114
|
+
* created_at: { name: 'created_at', type: 'datetime', readOnly: true }
|
|
115
|
+
* }
|
|
116
|
+
* }
|
|
117
|
+
*
|
|
118
|
+
* @example
|
|
119
|
+
* // Read-only entity (external API)
|
|
120
|
+
* const countriesSchema = {
|
|
121
|
+
* name: 'countries',
|
|
122
|
+
* endpoint: 'https://restcountries.com/v3.1/all',
|
|
123
|
+
* label: 'Country',
|
|
124
|
+
* labelPlural: 'Countries',
|
|
125
|
+
* labelField: 'name',
|
|
126
|
+
* idField: 'cca3',
|
|
127
|
+
* readOnly: true,
|
|
128
|
+
* fields: {
|
|
129
|
+
* cca3: { name: 'cca3', type: 'text', label: 'Code' },
|
|
130
|
+
* name: { name: 'name', type: 'text', label: 'Name' }
|
|
131
|
+
* }
|
|
132
|
+
* }
|
|
133
|
+
*/
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Valid field types for UnifiedFieldSchema
|
|
137
|
+
*
|
|
138
|
+
* Used for validation and documentation. Maps to common UI input types:
|
|
139
|
+
* - text: Single-line text input
|
|
140
|
+
* - number: Numeric input
|
|
141
|
+
* - boolean: Checkbox/toggle
|
|
142
|
+
* - date: Date picker (date only)
|
|
143
|
+
* - datetime: Date-time picker
|
|
144
|
+
* - email: Email input with validation
|
|
145
|
+
* - url: URL input with validation
|
|
146
|
+
* - uuid: UUID text input
|
|
147
|
+
* - array: Multi-value field (tags, list)
|
|
148
|
+
* - object: Nested object (JSON editor or subform)
|
|
149
|
+
*
|
|
150
|
+
* @type {readonly UnifiedFieldType[]}
|
|
151
|
+
*/
|
|
152
|
+
export const UNIFIED_FIELD_TYPES = Object.freeze([
|
|
153
|
+
'text',
|
|
154
|
+
'number',
|
|
155
|
+
'boolean',
|
|
156
|
+
'date',
|
|
157
|
+
'datetime',
|
|
158
|
+
'email',
|
|
159
|
+
'url',
|
|
160
|
+
'uuid',
|
|
161
|
+
'array',
|
|
162
|
+
'object'
|
|
163
|
+
])
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Check if a type is a valid UnifiedFieldType
|
|
167
|
+
*
|
|
168
|
+
* @param {string} type - Type to validate
|
|
169
|
+
* @returns {type is UnifiedFieldType} - True if valid
|
|
170
|
+
*
|
|
171
|
+
* @example
|
|
172
|
+
* isValidFieldType('text') // true
|
|
173
|
+
* isValidFieldType('string') // false (OpenAPI type, not unified)
|
|
174
|
+
*/
|
|
175
|
+
export function isValidFieldType(type) {
|
|
176
|
+
return UNIFIED_FIELD_TYPES.includes(type)
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Create a minimal field schema with defaults
|
|
181
|
+
*
|
|
182
|
+
* @param {string} name - Field name
|
|
183
|
+
* @param {UnifiedFieldType} type - Field type
|
|
184
|
+
* @param {Partial<UnifiedFieldSchema>} [overrides] - Additional field properties
|
|
185
|
+
* @returns {UnifiedFieldSchema} - Complete field schema
|
|
186
|
+
*
|
|
187
|
+
* @example
|
|
188
|
+
* const field = createFieldSchema('email', 'email', { required: true })
|
|
189
|
+
* // { name: 'email', type: 'email', required: true }
|
|
190
|
+
*/
|
|
191
|
+
export function createFieldSchema(name, type, overrides = {}) {
|
|
192
|
+
return {
|
|
193
|
+
name,
|
|
194
|
+
type,
|
|
195
|
+
...overrides
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Create a minimal entity schema with defaults
|
|
201
|
+
*
|
|
202
|
+
* @param {string} name - Entity name
|
|
203
|
+
* @param {string} endpoint - API endpoint
|
|
204
|
+
* @param {Record<string, UnifiedFieldSchema>} fields - Field definitions
|
|
205
|
+
* @param {Partial<UnifiedEntitySchema>} [overrides] - Additional entity properties
|
|
206
|
+
* @returns {UnifiedEntitySchema} - Complete entity schema
|
|
207
|
+
*
|
|
208
|
+
* @example
|
|
209
|
+
* const schema = createEntitySchema('users', '/api/users', {
|
|
210
|
+
* id: createFieldSchema('id', 'number', { readOnly: true }),
|
|
211
|
+
* name: createFieldSchema('name', 'text', { required: true })
|
|
212
|
+
* })
|
|
213
|
+
*/
|
|
214
|
+
export function createEntitySchema(name, endpoint, fields, overrides = {}) {
|
|
215
|
+
return {
|
|
216
|
+
name,
|
|
217
|
+
endpoint,
|
|
218
|
+
fields,
|
|
219
|
+
...overrides
|
|
220
|
+
}
|
|
221
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Vite Plugin for qdadm EntityManager Generation
|
|
3
|
+
*
|
|
4
|
+
* Integrates generateManagers with Vite's build pipeline, triggering
|
|
5
|
+
* entity manager file generation during the buildStart hook.
|
|
6
|
+
*
|
|
7
|
+
* @module gen/vite-plugin
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { generateManagers } from './generateManagers.js'
|
|
11
|
+
import { pathToFileURL } from 'node:url'
|
|
12
|
+
import { resolve } from 'node:path'
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Plugin options for qdadmGen
|
|
16
|
+
*
|
|
17
|
+
* @typedef {object} QdadmGenOptions
|
|
18
|
+
* @property {string} [config] - Path to qdadm config file (default: 'qdadm.config.js')
|
|
19
|
+
* @property {string} [output] - Override output directory for generated files
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Vite plugin for generating EntityManager files at build time
|
|
24
|
+
*
|
|
25
|
+
* Loads the qdadm configuration file and calls generateManagers during
|
|
26
|
+
* Vite's buildStart hook. Supports both ESM and CommonJS config files.
|
|
27
|
+
*
|
|
28
|
+
* @param {QdadmGenOptions} [options={}] - Plugin options
|
|
29
|
+
* @returns {import('vite').Plugin} Vite plugin object
|
|
30
|
+
*
|
|
31
|
+
* @example
|
|
32
|
+
* // vite.config.js
|
|
33
|
+
* import { defineConfig } from 'vite'
|
|
34
|
+
* import { qdadmGen } from 'qdadm/gen/vite-plugin'
|
|
35
|
+
*
|
|
36
|
+
* export default defineConfig({
|
|
37
|
+
* plugins: [
|
|
38
|
+
* qdadmGen({
|
|
39
|
+
* config: './qdadm.config.js',
|
|
40
|
+
* output: 'src/generated/managers/'
|
|
41
|
+
* })
|
|
42
|
+
* ]
|
|
43
|
+
* })
|
|
44
|
+
*
|
|
45
|
+
* @example
|
|
46
|
+
* // qdadm.config.js
|
|
47
|
+
* export default {
|
|
48
|
+
* output: 'src/generated/managers/',
|
|
49
|
+
* entities: {
|
|
50
|
+
* users: {
|
|
51
|
+
* schema: { name: 'users', fields: { id: { type: 'integer' } } },
|
|
52
|
+
* endpoint: '/api/users',
|
|
53
|
+
* storageImport: 'qdadm',
|
|
54
|
+
* storageClass: 'ApiStorage'
|
|
55
|
+
* }
|
|
56
|
+
* }
|
|
57
|
+
* }
|
|
58
|
+
*/
|
|
59
|
+
export function qdadmGen(options = {}) {
|
|
60
|
+
const configPath = options.config || 'qdadm.config.js'
|
|
61
|
+
|
|
62
|
+
return {
|
|
63
|
+
name: 'qdadm-gen',
|
|
64
|
+
|
|
65
|
+
async buildStart() {
|
|
66
|
+
try {
|
|
67
|
+
// Resolve config path relative to cwd
|
|
68
|
+
const resolvedConfigPath = resolve(process.cwd(), configPath)
|
|
69
|
+
|
|
70
|
+
// Load config file dynamically
|
|
71
|
+
const configUrl = pathToFileURL(resolvedConfigPath).href
|
|
72
|
+
const configModule = await import(configUrl)
|
|
73
|
+
const loadedConfig = configModule.default || configModule
|
|
74
|
+
|
|
75
|
+
// Validate loaded config
|
|
76
|
+
if (!loadedConfig || typeof loadedConfig !== 'object') {
|
|
77
|
+
throw new Error(`Invalid qdadm config at '${configPath}': must export an object`)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (!loadedConfig.entities) {
|
|
81
|
+
throw new Error(`Invalid qdadm config at '${configPath}': missing 'entities' property`)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Merge plugin options with config (plugin options take precedence)
|
|
85
|
+
const mergedConfig = {
|
|
86
|
+
...loadedConfig,
|
|
87
|
+
...(options.output && { output: options.output })
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Generate managers
|
|
91
|
+
const generatedFiles = await generateManagers(mergedConfig)
|
|
92
|
+
|
|
93
|
+
// Log results
|
|
94
|
+
console.log(`[qdadm-gen] Generated ${generatedFiles.length} manager file(s)`)
|
|
95
|
+
for (const file of generatedFiles) {
|
|
96
|
+
console.log(` - ${file}`)
|
|
97
|
+
}
|
|
98
|
+
} catch (error) {
|
|
99
|
+
// Wrap error with plugin context for clearer messages
|
|
100
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
101
|
+
throw new Error(`[qdadm-gen] Failed to generate managers: ${message}`)
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TestManager - Auto-generated EntityManager
|
|
3
|
+
*
|
|
4
|
+
* Generated by qdadm/gen/generateManagers
|
|
5
|
+
* DO NOT EDIT MANUALLY - Changes will be overwritten
|
|
6
|
+
*
|
|
7
|
+
* Entity: test
|
|
8
|
+
* Endpoint: /test
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { EntityManager } from 'qdadm'
|
|
12
|
+
import { ApiStorage } from 'qdadm'
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Schema definition for test
|
|
16
|
+
* @type {import('qdadm/gen').UnifiedEntitySchema}
|
|
17
|
+
*/
|
|
18
|
+
export const testSchema = {
|
|
19
|
+
name: "test",
|
|
20
|
+
endpoint: "/test",
|
|
21
|
+
fields: {}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Storage options for test
|
|
26
|
+
*/
|
|
27
|
+
const storageOptions = {
|
|
28
|
+
endpoint: "/test"
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* TestManager instance
|
|
33
|
+
*
|
|
34
|
+
* Provides CRUD operations for test entity.
|
|
35
|
+
*
|
|
36
|
+
* @type {EntityManager}
|
|
37
|
+
*/
|
|
38
|
+
export const testManager = new EntityManager({
|
|
39
|
+
...{
|
|
40
|
+
name: "test",
|
|
41
|
+
idField: "id",
|
|
42
|
+
fields: {}
|
|
43
|
+
},
|
|
44
|
+
storage: new ApiStorage(storageOptions)
|
|
45
|
+
})
|
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
|
}
|