@hkdigital/lib-core 0.5.92 → 0.5.93

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.
@@ -0,0 +1,520 @@
1
+ # Service Manager Plugins
2
+
3
+ Plugin system for extending ServiceManager functionality, primarily for
4
+ dynamic configuration resolution.
5
+
6
+ **See also:**
7
+ - **API Reference**: [README.md](./README.md) - ServiceBase and
8
+ ServiceManager API
9
+ - **Patterns**: [PATTERNS.md](./PATTERNS.md) - Service implementation
10
+ patterns
11
+ - **Architecture**: [docs/setup/services-logging.md](../../docs/setup/services-logging.md)
12
+ - Integration examples
13
+
14
+ ## Plugin System Overview
15
+
16
+ ServiceManager supports plugins to extend its functionality. Plugins
17
+ can:
18
+
19
+ - Resolve service configurations dynamically
20
+ - Transform configuration before services receive it
21
+ - React to service lifecycle events
22
+ - Add custom validation logic
23
+
24
+ ## ConfigPlugin
25
+
26
+ The most common plugin for resolving service configuration from a
27
+ pre-parsed configuration object. Perfect for environment variables,
28
+ config files, or any structured configuration source.
29
+
30
+ ### Basic Usage with Environment Variables
31
+
32
+ ```javascript
33
+ import { ServiceManager } from '$lib/services/index.js';
34
+
35
+ import ConfigPlugin from '$lib/services/manager-plugins/ConfigPlugin.js';
36
+
37
+ import { getPrivateEnv } from '$lib/util/sveltekit/env-private.js';
38
+
39
+ // Load and auto-group environment variables
40
+ const envConfig = getPrivateEnv();
41
+ //
42
+ // Example:
43
+ //
44
+ // DATABASE_HOST=localhost
45
+ // DATABASE_PORT=5432
46
+ // DATABASE_NAME=myapp
47
+ // REDIS_HOST=cache-server
48
+ // REDIS_PORT=6379
49
+ // JWT_SECRET=mysecret
50
+ // =>
51
+ // {
52
+ // database: { host: 'localhost', port: 5432, name: 'myapp' },
53
+ // redis: { host: 'cache-server', port: 6379 },
54
+ // jwtSecret: 'mysecret'
55
+ // }
56
+ //
57
+
58
+ // Create plugin with grouped config
59
+ const configPlugin = new ConfigPlugin(envConfig);
60
+
61
+ // Attach to ServiceManager
62
+ const manager = new ServiceManager();
63
+ manager.attachPlugin(configPlugin);
64
+
65
+ // Register services with config labels (not config objects)
66
+ manager.register('database', DatabaseService, 'database'); // Uses envConfig.database
67
+ manager.register('cache', RedisService, 'redis'); // Uses envConfig.redis
68
+
69
+ await manager.startAll();
70
+ ```
71
+
72
+ **Key concept:**
73
+ Instead of passing configuration objects directly, pass config **labels**
74
+ (strings) that the plugin resolves to actual configuration.
75
+
76
+ ### Configuration Sources
77
+
78
+ The plugin constructor accepts an object with configuration data, which
79
+ can come from any source.
80
+
81
+ **Environment variables:**
82
+
83
+ ```javascript
84
+ import { getPrivateEnv } from '$lib/util/sveltekit/env-private.js';
85
+
86
+ const envConfig = getPrivateEnv();
87
+ const plugin = new ConfigPlugin(envConfig);
88
+ ```
89
+
90
+ **Config files:**
91
+
92
+ ```javascript
93
+ import configData from './config.json' with { type: 'json' };
94
+
95
+ const plugin = new ConfigPlugin(configData);
96
+ ```
97
+
98
+ **Combine multiple sources:**
99
+
100
+ ```javascript
101
+ // Combine multiple config sources
102
+ const config = {
103
+ ...getPrivateEnv(), // Environment variables
104
+ ...(await loadConfigFile()), // Config file
105
+ database: {
106
+ // Override specific settings
107
+ ...envConfig.database,
108
+ connectionTimeout: 5000
109
+ }
110
+ };
111
+
112
+ const plugin = new ConfigPlugin(config);
113
+ ```
114
+
115
+ ### Methods
116
+
117
+ #### replaceAllConfigs()
118
+
119
+ Replace all configurations and clean up unused ones.
120
+
121
+ ```javascript
122
+ // Update entire configuration set
123
+ await configPlugin.replaceAllConfigs(newConfig);
124
+ ```
125
+
126
+ **Use case:**
127
+ When you need to reload configuration from a file or external source.
128
+
129
+ #### replaceConfig()
130
+
131
+ Replace configuration for a specific label.
132
+
133
+ ```javascript
134
+ // Update single service configuration
135
+ await configPlugin.replaceConfig('database', newDatabaseConfig);
136
+ ```
137
+
138
+ **Use case:**
139
+ When you need to update a specific service's configuration without
140
+ affecting others.
141
+
142
+ #### cleanupConfigs()
143
+
144
+ Clean up configurations not used by any service.
145
+
146
+ ```javascript
147
+ // Remove unused configurations
148
+ await configPlugin.cleanupConfigs();
149
+ ```
150
+
151
+ **Use case:**
152
+ Memory optimization when configuration object is large and contains
153
+ many unused entries.
154
+
155
+ ## Live Configuration Updates
156
+
157
+ The ConfigPlugin supports pushing configuration updates to running
158
+ services without restart.
159
+
160
+ ### Basic Live Update
161
+
162
+ ```javascript
163
+ // Replace config for a specific label and notify all affected services
164
+ const updatedServices = await configPlugin.replaceConfig('database', {
165
+ host: 'new-host.example.com',
166
+ port: 5433,
167
+ maxConnections: 50
168
+ });
169
+
170
+ // Returns array of service names that were updated
171
+ console.log(`Updated ${updatedServices.length} services`);
172
+ // => "Updated 2 services" (e.g., ['user-service', 'order-service'])
173
+ ```
174
+
175
+ **What happens:**
176
+ 1. Plugin updates the stored configuration for the label
177
+ 2. Plugin finds all services using that config label
178
+ 3. Each service's `configure()` method is called with new config
179
+ 4. Services intelligently apply changes (see patterns below)
180
+
181
+ ### Service Requirements for Live Updates
182
+
183
+ For services to support live configuration updates, they must:
184
+
185
+ 1. **Implement intelligent `_configure()` logic** that handles both
186
+ initial setup and reconfiguration
187
+ 2. **Check for meaningful changes** between old and new config
188
+ 3. **Apply changes without full restart** when possible
189
+
190
+ **Example implementation:**
191
+
192
+ ```javascript
193
+ class DatabaseService extends ServiceBase {
194
+ // eslint-disable-next-line no-unused-vars
195
+ async _configure(newConfig, oldConfig = null) {
196
+ if (!oldConfig) {
197
+ // Initial configuration
198
+ this.connectionString = newConfig.connectionString;
199
+ this.maxConnections = newConfig.maxConnections || 10;
200
+ return;
201
+ }
202
+
203
+ // Live reconfiguration - handle changes intelligently
204
+ if (oldConfig.connectionString !== newConfig.connectionString) {
205
+ // Connection changed - need to reconnect
206
+ await this.connection?.close();
207
+ this.connectionString = newConfig.connectionString;
208
+
209
+ if (this.state === 'running') {
210
+ this.connection = await createConnection(this.connectionString);
211
+ }
212
+ }
213
+
214
+ if (oldConfig.maxConnections !== newConfig.maxConnections) {
215
+ // Pool size changed - update without reconnect
216
+ this.maxConnections = newConfig.maxConnections;
217
+ await this.connection?.setMaxConnections(this.maxConnections);
218
+ }
219
+ }
220
+ }
221
+ ```
222
+
223
+ ### Live Update Patterns
224
+
225
+ **Hot-reload safe properties:**
226
+
227
+ ```javascript
228
+ async _configure(newConfig, oldConfig = null) {
229
+ // These can be updated without restart
230
+ this.timeout = newConfig.timeout || 5000;
231
+ this.retryAttempts = newConfig.retryAttempts || 3;
232
+ this.logLevel = newConfig.logLevel || 'INFO';
233
+ }
234
+ ```
235
+
236
+ **Properties requiring reconnect:**
237
+
238
+ ```javascript
239
+ async _configure(newConfig, oldConfig = null) {
240
+ if (!oldConfig ||
241
+ oldConfig.host !== newConfig.host ||
242
+ oldConfig.port !== newConfig.port) {
243
+
244
+ // Close existing connection
245
+ await this.connection?.close();
246
+
247
+ // Update config
248
+ this.host = newConfig.host;
249
+ this.port = newConfig.port;
250
+
251
+ // Reconnect if running
252
+ if (this.state === 'running') {
253
+ this.connection = await this.#connect();
254
+ }
255
+ }
256
+ }
257
+ ```
258
+
259
+ **No-op for unchanged config:**
260
+
261
+ ```javascript
262
+ async _configure(newConfig, oldConfig = null) {
263
+ if (oldConfig &&
264
+ oldConfig.apiKey === newConfig.apiKey &&
265
+ oldConfig.endpoint === newConfig.endpoint) {
266
+ // Nothing changed, skip reconfiguration
267
+ return;
268
+ }
269
+
270
+ // Apply changes
271
+ this.apiKey = newConfig.apiKey;
272
+ this.endpoint = newConfig.endpoint;
273
+ }
274
+ ```
275
+
276
+ ## Advanced Usage
277
+
278
+ ### Multiple Services Sharing Configuration
279
+
280
+ Multiple services can share the same configuration label.
281
+
282
+ ```javascript
283
+ const config = {
284
+ database: {
285
+ host: 'localhost',
286
+ port: 5432,
287
+ name: 'myapp'
288
+ }
289
+ };
290
+
291
+ const plugin = new ConfigPlugin(config);
292
+ manager.attachPlugin(plugin);
293
+
294
+ // Both services use the same config
295
+ manager.register('user-db', UserDatabaseService, 'database');
296
+ manager.register('order-db', OrderDatabaseService, 'database');
297
+
298
+ // Update affects both services
299
+ await plugin.replaceConfig('database', {
300
+ host: 'new-host',
301
+ port: 5432,
302
+ name: 'myapp'
303
+ });
304
+ // Both user-db and order-db reconnect to new host
305
+ ```
306
+
307
+ ### Configuration with Defaults
308
+
309
+ Provide base configuration and let services apply defaults.
310
+
311
+ ```javascript
312
+ const config = {
313
+ database: {
314
+ host: 'localhost',
315
+ port: 5432
316
+ // maxConnections not specified
317
+ }
318
+ };
319
+
320
+ const plugin = new ConfigPlugin(config);
321
+ manager.attachPlugin(plugin);
322
+
323
+ manager.register('database', DatabaseService, 'database');
324
+
325
+ // Service applies defaults in _configure()
326
+ class DatabaseService extends ServiceBase {
327
+ async _configure(newConfig, oldConfig = null) {
328
+ this.host = newConfig.host;
329
+ this.port = newConfig.port;
330
+ this.maxConnections = newConfig.maxConnections || 10; // Default
331
+ }
332
+ }
333
+ ```
334
+
335
+ ### Environment-Specific Configuration
336
+
337
+ Use environment variables to switch configurations.
338
+
339
+ ```javascript
340
+ import { getPrivateEnv } from '$lib/util/sveltekit/env-private.js';
341
+
342
+ const env = getPrivateEnv();
343
+
344
+ const config = {
345
+ database: {
346
+ host: env.DATABASE_HOST || 'localhost',
347
+ port: env.DATABASE_PORT || 5432,
348
+ name: env.DATABASE_NAME || 'myapp',
349
+ ssl: env.NODE_ENV === 'production'
350
+ }
351
+ };
352
+
353
+ const plugin = new ConfigPlugin(config);
354
+ ```
355
+
356
+ ## Error Handling
357
+
358
+ ### Invalid Configuration Labels
359
+
360
+ When a service requests a non-existent config label:
361
+
362
+ ```javascript
363
+ manager.register('cache', CacheService, 'redis'); // 'redis' not in config
364
+
365
+ // Plugin will pass through the label as-is to the service
366
+ // Service's _configure() receives 'redis' string instead of object
367
+ ```
368
+
369
+ **Best practice:**
370
+ Validate configuration in service's `_configure()`:
371
+
372
+ ```javascript
373
+ async _configure(newConfig, oldConfig = null) {
374
+ if (typeof newConfig === 'string') {
375
+ throw new Error(
376
+ `Configuration label '${newConfig}' not found in ConfigPlugin`
377
+ );
378
+ }
379
+
380
+ // Continue with valid config object
381
+ this.host = newConfig.host;
382
+ }
383
+ ```
384
+
385
+ ### Configuration Validation
386
+
387
+ Validate configuration before applying:
388
+
389
+ ```javascript
390
+ async _configure(newConfig, oldConfig = null) {
391
+ // Validate required fields
392
+ if (!newConfig.host || !newConfig.port) {
393
+ throw new Error('Database config requires host and port');
394
+ }
395
+
396
+ // Validate types
397
+ if (typeof newConfig.port !== 'number') {
398
+ throw new Error('Database port must be a number');
399
+ }
400
+
401
+ // Validate ranges
402
+ if (newConfig.port < 1 || newConfig.port > 65535) {
403
+ throw new Error('Database port must be between 1 and 65535');
404
+ }
405
+
406
+ // Apply validated config
407
+ this.host = newConfig.host;
408
+ this.port = newConfig.port;
409
+ }
410
+ ```
411
+
412
+ ## Best Practices
413
+
414
+ 1. **Use config labels consistently** - Match label names to service
415
+ names when possible
416
+ 2. **Group related config** - Use prefixes (DATABASE_*, REDIS_*) for
417
+ auto-grouping
418
+ 3. **Validate config in services** - Don't assume ConfigPlugin has all
419
+ labels
420
+ 4. **Implement intelligent reconfiguration** - Check what changed
421
+ before taking action
422
+ 5. **Test live updates** - Ensure services handle config changes
423
+ gracefully
424
+ 6. **Provide sensible defaults** - Make services work with minimal
425
+ config
426
+ 7. **Document required fields** - Make it clear what each service needs
427
+ 8. **Use environment variables** - Keep secrets out of code
428
+
429
+ ## Creating Custom Plugins
430
+
431
+ Custom plugins can extend ServiceManager functionality. Plugin
432
+ interface:
433
+
434
+ ```javascript
435
+ class CustomPlugin {
436
+ constructor(options) {
437
+ this.options = options;
438
+ }
439
+
440
+ /**
441
+ * Called when plugin is attached to ServiceManager
442
+ *
443
+ * @param {ServiceManager} manager
444
+ */
445
+ attach(manager) {
446
+ this.manager = manager;
447
+ // Setup listeners, initialization, etc.
448
+ }
449
+
450
+ /**
451
+ * Called when ServiceManager resolves config for a service
452
+ *
453
+ * @param {string} serviceName
454
+ * @param {*} config
455
+ *
456
+ * @returns {*} Resolved config
457
+ */
458
+ resolveConfig(serviceName, config) {
459
+ // Transform or resolve config
460
+ return config;
461
+ }
462
+
463
+ /**
464
+ * Called when plugin is detached
465
+ */
466
+ detach() {
467
+ // Cleanup
468
+ }
469
+ }
470
+ ```
471
+
472
+ **Example - Validation Plugin:**
473
+
474
+ ```javascript
475
+ class ValidationPlugin {
476
+ constructor(schemas) {
477
+ this.schemas = schemas; // Map of service name -> validation schema
478
+ }
479
+
480
+ attach(manager) {
481
+ this.manager = manager;
482
+ }
483
+
484
+ resolveConfig(serviceName, config) {
485
+ const schema = this.schemas[serviceName];
486
+
487
+ if (schema) {
488
+ const result = schema.safeParse(config);
489
+
490
+ if (!result.success) {
491
+ throw new Error(
492
+ `Invalid config for ${serviceName}: ${result.error.message}`
493
+ );
494
+ }
495
+
496
+ return result.data;
497
+ }
498
+
499
+ return config;
500
+ }
501
+
502
+ detach() {
503
+ this.manager = null;
504
+ }
505
+ }
506
+
507
+ // Usage
508
+ import { v } from '$lib/valibot/valibot.js';
509
+
510
+ const schemas = {
511
+ database: v.object({
512
+ host: v.string(),
513
+ port: v.number(),
514
+ name: v.string()
515
+ })
516
+ };
517
+
518
+ const plugin = new ValidationPlugin(schemas);
519
+ manager.attachPlugin(plugin);
520
+ ```