cyclecad 1.3.2 → 1.3.3

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,684 @@
1
+ /**
2
+ * cycleCAD Microkernel
3
+ *
4
+ * A lightweight, modular architecture for pluggable CAD components.
5
+ * Manages module lifecycle, dependencies, lazy loading, hot-swapping,
6
+ * and inter-module communication via events and commands.
7
+ *
8
+ * @module kernel
9
+ * @version 1.0.0
10
+ */
11
+
12
+ /**
13
+ * Module lifecycle states
14
+ * @enum {string}
15
+ */
16
+ const ModuleState = {
17
+ REGISTERED: 'registered',
18
+ LOADING: 'loading',
19
+ ACTIVE: 'active',
20
+ INACTIVE: 'inactive',
21
+ UNLOADING: 'unloading',
22
+ UNLOADED: 'unloaded',
23
+ ERROR: 'error',
24
+ };
25
+
26
+ /**
27
+ * Kernel class — manages all modules and inter-module communication
28
+ */
29
+ class Kernel {
30
+ constructor(config = {}) {
31
+ this.config = {
32
+ memoryBudget: 512, // MB
33
+ autoGC: true,
34
+ ...config,
35
+ };
36
+
37
+ // Registries
38
+ this.modules = new Map(); // id → {definition, state, instance, metadata}
39
+ this.commands = new Map(); // 'module.cmd' → handler
40
+ this.shortcuts = new Map(); // 'shortcut' → 'module.cmd'
41
+
42
+ // Event bus
43
+ this.eventListeners = new Map(); // event → Set of handlers
44
+ this.onceListeners = new Map(); // event → Map(handler → true)
45
+
46
+ // Shared state (accessible to all modules)
47
+ this.state = {
48
+ _values: new Map(),
49
+ _watchers: new Map(), // key → Set of handlers
50
+
51
+ set: (key, value) => {
52
+ const oldValue = this.state._values.get(key);
53
+ this.state._values.set(key, value);
54
+
55
+ // Notify watchers
56
+ const watchers = this.state._watchers.get(key);
57
+ if (watchers) {
58
+ watchers.forEach(handler => {
59
+ try {
60
+ handler(value, oldValue);
61
+ } catch (err) {
62
+ console.error(`[Kernel] State watcher error for "${key}":`, err);
63
+ }
64
+ });
65
+ }
66
+ },
67
+
68
+ get: (key) => this.state._values.get(key),
69
+
70
+ watch: (key, handler) => {
71
+ if (!this.state._watchers.has(key)) {
72
+ this.state._watchers.set(key, new Set());
73
+ }
74
+ this.state._watchers.get(key).add(handler);
75
+
76
+ return () => {
77
+ this.state._watchers.get(key).delete(handler);
78
+ };
79
+ },
80
+
81
+ all: () => Object.fromEntries(this.state._values),
82
+ };
83
+
84
+ // Memory manager
85
+ this.memory = {
86
+ _estimates: new Map(), // moduleId → MB
87
+
88
+ usage: () => {
89
+ let total = 0;
90
+ for (const [moduleId, state] of this.modules.entries()) {
91
+ if (state.state !== ModuleState.UNLOADED && state.state !== ModuleState.ERROR) {
92
+ total += this.memory._estimates.get(moduleId) || 0;
93
+ }
94
+ }
95
+ return total;
96
+ },
97
+
98
+ pressure: () => {
99
+ return Math.min(1, this.memory.usage() / this.config.memoryBudget);
100
+ },
101
+
102
+ budget: this.config.memoryBudget,
103
+
104
+ gc: () => {
105
+ if (this.config.autoGC && this.memory.pressure() > 0.8) {
106
+ this._evictLRU();
107
+ }
108
+ },
109
+ };
110
+
111
+ // Module load order tracking (for LRU eviction)
112
+ this._lastUsed = new Map(); // moduleId → timestamp
113
+
114
+ // Module dependencies tracking
115
+ this._dependents = new Map(); // moduleId → Set of modules that depend on it
116
+
117
+ console.log('[Kernel] Initialized with memory budget:', this.config.memoryBudget, 'MB');
118
+ }
119
+
120
+ /**
121
+ * Register a module definition
122
+ * @param {Object} definition - Module definition object
123
+ * @returns {boolean} Success
124
+ */
125
+ register(definition) {
126
+ if (!definition.id || !definition.name) {
127
+ console.error('[Kernel] Module registration requires id and name', definition);
128
+ return false;
129
+ }
130
+
131
+ if (this.modules.has(definition.id)) {
132
+ console.warn(`[Kernel] Module "${definition.id}" already registered, replacing`);
133
+ }
134
+
135
+ this.modules.set(definition.id, {
136
+ definition,
137
+ state: ModuleState.REGISTERED,
138
+ instance: null,
139
+ metadata: {
140
+ loadedAt: null,
141
+ activatedAt: null,
142
+ lastUsedAt: null,
143
+ errorMessage: null,
144
+ },
145
+ });
146
+
147
+ // Track dependencies for reverse lookup
148
+ if (definition.dependencies) {
149
+ definition.dependencies.forEach(dep => {
150
+ if (!this._dependents.has(dep)) {
151
+ this._dependents.set(dep, new Set());
152
+ }
153
+ this._dependents.get(dep).add(definition.id);
154
+ });
155
+ }
156
+
157
+ console.log(`[Kernel] Module registered: "${definition.id}" (${definition.category})`);
158
+ return true;
159
+ }
160
+
161
+ /**
162
+ * Get module info
163
+ * @param {string} moduleId
164
+ * @returns {Object|null}
165
+ */
166
+ get(moduleId) {
167
+ const module = this.modules.get(moduleId);
168
+ if (!module) return null;
169
+
170
+ return {
171
+ id: module.definition.id,
172
+ name: module.definition.name,
173
+ version: module.definition.version,
174
+ category: module.definition.category,
175
+ state: module.state,
176
+ dependencies: module.definition.dependencies || [],
177
+ replaces: module.definition.replaces || [],
178
+ memory: this.memory._estimates.get(moduleId) || 0,
179
+ ...module.metadata,
180
+ };
181
+ }
182
+
183
+ /**
184
+ * List all registered modules
185
+ * @returns {Array<Object>}
186
+ */
187
+ list() {
188
+ return Array.from(this.modules.keys()).map(id => this.get(id));
189
+ }
190
+
191
+ /**
192
+ * List modules by category
193
+ * @param {string} category
194
+ * @returns {Array<Object>}
195
+ */
196
+ listByCategory(category) {
197
+ return this.list().filter(m => m.category === category);
198
+ }
199
+
200
+ /**
201
+ * Load a module (async)
202
+ * Resolves dependencies first, then calls module.load()
203
+ * @param {string} moduleId
204
+ * @returns {Promise<boolean>}
205
+ */
206
+ async load(moduleId) {
207
+ const module = this.modules.get(moduleId);
208
+ if (!module) {
209
+ console.error(`[Kernel] Module "${moduleId}" not found`);
210
+ return false;
211
+ }
212
+
213
+ // Already loaded or loading
214
+ if (module.state === ModuleState.ACTIVE ||
215
+ module.state === ModuleState.INACTIVE ||
216
+ module.state === ModuleState.LOADING) {
217
+ return true;
218
+ }
219
+
220
+ // Mark as loading
221
+ module.state = ModuleState.LOADING;
222
+
223
+ try {
224
+ // Resolve dependencies first
225
+ if (module.definition.dependencies) {
226
+ for (const depId of module.definition.dependencies) {
227
+ const depLoaded = await this.load(depId);
228
+ if (!depLoaded) {
229
+ throw new Error(`Dependency "${depId}" failed to load`);
230
+ }
231
+ }
232
+ }
233
+
234
+ // Call module's load hook
235
+ if (typeof module.definition.load === 'function') {
236
+ await module.definition.load(this);
237
+ }
238
+
239
+ module.metadata.loadedAt = Date.now();
240
+ module.state = ModuleState.INACTIVE;
241
+
242
+ this.emit('module:loaded', { id: moduleId });
243
+ console.log(`[Kernel] Module loaded: "${moduleId}"`);
244
+
245
+ return true;
246
+ } catch (err) {
247
+ module.state = ModuleState.ERROR;
248
+ module.metadata.errorMessage = err.message;
249
+ this.emit('module:error', { id: moduleId, error: err });
250
+ console.error(`[Kernel] Module load failed: "${moduleId}"`, err);
251
+ return false;
252
+ }
253
+ }
254
+
255
+ /**
256
+ * Activate a module (load first if needed, then call activate hook)
257
+ * @param {string} moduleId
258
+ * @returns {Promise<boolean>}
259
+ */
260
+ async activate(moduleId) {
261
+ const module = this.modules.get(moduleId);
262
+ if (!module) {
263
+ console.error(`[Kernel] Module "${moduleId}" not found`);
264
+ return false;
265
+ }
266
+
267
+ // Already active
268
+ if (module.state === ModuleState.ACTIVE) {
269
+ return true;
270
+ }
271
+
272
+ // Load first if needed
273
+ if (module.state === ModuleState.REGISTERED || module.state === ModuleState.ERROR) {
274
+ const loaded = await this.load(moduleId);
275
+ if (!loaded) return false;
276
+ }
277
+
278
+ try {
279
+ // Call activate hook
280
+ if (typeof module.definition.activate === 'function') {
281
+ await module.definition.activate(this);
282
+ }
283
+
284
+ // Register commands from this module
285
+ if (module.definition.provides?.commands) {
286
+ const prefix = moduleId;
287
+ for (const [cmd, handler] of Object.entries(module.definition.provides.commands)) {
288
+ const fullName = `${prefix}.${cmd}`;
289
+ this.commands.set(fullName, handler);
290
+ }
291
+ }
292
+
293
+ // Register shortcuts from this module
294
+ if (module.definition.provides?.ui?.shortcuts) {
295
+ for (const [key, cmd] of Object.entries(module.definition.provides.ui.shortcuts)) {
296
+ this.shortcuts.set(key, cmd);
297
+ }
298
+ }
299
+
300
+ module.metadata.activatedAt = Date.now();
301
+ module.state = ModuleState.ACTIVE;
302
+ this._lastUsed.set(moduleId, Date.now());
303
+
304
+ this.emit('module:activated', { id: moduleId });
305
+ console.log(`[Kernel] Module activated: "${moduleId}"`);
306
+
307
+ return true;
308
+ } catch (err) {
309
+ module.state = ModuleState.ERROR;
310
+ module.metadata.errorMessage = err.message;
311
+ this.emit('module:error', { id: moduleId, error: err });
312
+ console.error(`[Kernel] Module activation failed: "${moduleId}"`, err);
313
+ return false;
314
+ }
315
+ }
316
+
317
+ /**
318
+ * Deactivate a module (keep in memory, call deactivate hook)
319
+ * @param {string} moduleId
320
+ * @returns {Promise<boolean>}
321
+ */
322
+ async deactivate(moduleId) {
323
+ const module = this.modules.get(moduleId);
324
+ if (!module) {
325
+ console.error(`[Kernel] Module "${moduleId}" not found`);
326
+ return false;
327
+ }
328
+
329
+ if (module.state !== ModuleState.ACTIVE) {
330
+ return true;
331
+ }
332
+
333
+ try {
334
+ // Call deactivate hook
335
+ if (typeof module.definition.deactivate === 'function') {
336
+ await module.definition.deactivate(this);
337
+ }
338
+
339
+ // Unregister commands
340
+ for (const cmd of this.commands.keys()) {
341
+ if (cmd.startsWith(moduleId + '.')) {
342
+ this.commands.delete(cmd);
343
+ }
344
+ }
345
+
346
+ // Unregister shortcuts
347
+ for (const [key, cmd] of this.shortcuts.entries()) {
348
+ if (cmd.startsWith(moduleId + '.')) {
349
+ this.shortcuts.delete(key);
350
+ }
351
+ }
352
+
353
+ module.state = ModuleState.INACTIVE;
354
+ this.emit('module:deactivated', { id: moduleId });
355
+ console.log(`[Kernel] Module deactivated: "${moduleId}"`);
356
+
357
+ return true;
358
+ } catch (err) {
359
+ console.error(`[Kernel] Module deactivation failed: "${moduleId}"`, err);
360
+ return false;
361
+ }
362
+ }
363
+
364
+ /**
365
+ * Unload a module (full cleanup, free memory)
366
+ * @param {string} moduleId
367
+ * @returns {Promise<boolean>}
368
+ */
369
+ async unload(moduleId) {
370
+ const module = this.modules.get(moduleId);
371
+ if (!module) {
372
+ console.error(`[Kernel] Module "${moduleId}" not found`);
373
+ return false;
374
+ }
375
+
376
+ if (module.state === ModuleState.UNLOADED) {
377
+ return true;
378
+ }
379
+
380
+ try {
381
+ // Deactivate first if active
382
+ if (module.state === ModuleState.ACTIVE) {
383
+ await this.deactivate(moduleId);
384
+ }
385
+
386
+ module.state = ModuleState.UNLOADING;
387
+
388
+ // Call unload hook
389
+ if (typeof module.definition.unload === 'function') {
390
+ await module.definition.unload(this);
391
+ }
392
+
393
+ module.state = ModuleState.UNLOADED;
394
+ module.instance = null;
395
+ this.memory._estimates.delete(moduleId);
396
+ this._lastUsed.delete(moduleId);
397
+
398
+ this.emit('module:unloaded', { id: moduleId });
399
+ console.log(`[Kernel] Module unloaded: "${moduleId}"`);
400
+
401
+ return true;
402
+ } catch (err) {
403
+ console.error(`[Kernel] Module unload failed: "${moduleId}"`, err);
404
+ return false;
405
+ }
406
+ }
407
+
408
+ /**
409
+ * Hot-swap one module for another
410
+ * @param {string} oldModuleId
411
+ * @param {string} newModuleId
412
+ * @returns {Promise<boolean>}
413
+ */
414
+ async swap(oldModuleId, newModuleId) {
415
+ const oldModule = this.modules.get(oldModuleId);
416
+ const newModule = this.modules.get(newModuleId);
417
+
418
+ if (!oldModule || !newModule) {
419
+ console.error('[Kernel] Swap: modules not found', { oldModuleId, newModuleId });
420
+ return false;
421
+ }
422
+
423
+ // Verify that newModule claims to replace oldModule
424
+ if (newModule.definition.replaces && !newModule.definition.replaces.includes(oldModuleId)) {
425
+ console.warn(`[Kernel] Module "${newModuleId}" doesn't declare replaces: ["${oldModuleId}"]`);
426
+ }
427
+
428
+ console.log(`[Kernel] Swapping "${oldModuleId}" → "${newModuleId}"`);
429
+
430
+ try {
431
+ // Emit swapping event so modules can migrate state
432
+ this.emit('module:swapping', { oldId: oldModuleId, newId: newModuleId });
433
+
434
+ const wasActive = oldModule.state === ModuleState.ACTIVE;
435
+
436
+ // Deactivate old
437
+ await this.deactivate(oldModuleId);
438
+ await this.unload(oldModuleId);
439
+
440
+ // Activate new
441
+ const loaded = await this.load(newModuleId);
442
+ if (!loaded) {
443
+ console.error('[Kernel] Failed to load replacement module');
444
+ return false;
445
+ }
446
+
447
+ if (wasActive) {
448
+ const activated = await this.activate(newModuleId);
449
+ if (!activated) {
450
+ console.error('[Kernel] Failed to activate replacement module');
451
+ return false;
452
+ }
453
+ }
454
+
455
+ this.emit('module:swapped', { oldId: oldModuleId, newId: newModuleId });
456
+ console.log(`[Kernel] Swap complete`);
457
+
458
+ return true;
459
+ } catch (err) {
460
+ console.error('[Kernel] Swap failed:', err);
461
+ return false;
462
+ }
463
+ }
464
+
465
+ /**
466
+ * Load all modules in a category
467
+ * @param {string} category
468
+ * @returns {Promise<number>} Number of successfully loaded modules
469
+ */
470
+ async loadAll(category) {
471
+ const modules = this.listByCategory(category);
472
+ let loaded = 0;
473
+
474
+ for (const mod of modules) {
475
+ const success = await this.load(mod.id);
476
+ if (success) loaded++;
477
+ }
478
+
479
+ return loaded;
480
+ }
481
+
482
+ /**
483
+ * Execute a named command
484
+ * Auto-loads the command's module if not already loaded
485
+ * @param {string} commandName - e.g., "brep.makeBox"
486
+ * @param {Object} params - Command parameters
487
+ * @returns {Promise<any>} Command result
488
+ */
489
+ async exec(commandName, params = {}) {
490
+ // Check if command exists
491
+ if (this.commands.has(commandName)) {
492
+ const handler = this.commands.get(commandName);
493
+ this._lastUsed.set(this._getModuleFromCommand(commandName), Date.now());
494
+ return await handler(params);
495
+ }
496
+
497
+ // Try to auto-load the module and retry
498
+ const moduleId = this._getModuleFromCommand(commandName);
499
+ const loaded = await this.load(moduleId);
500
+
501
+ if (!loaded) {
502
+ throw new Error(`Module "${moduleId}" not found`);
503
+ }
504
+
505
+ // Activate to register commands
506
+ const activated = await this.activate(moduleId);
507
+ if (!activated) {
508
+ throw new Error(`Module "${moduleId}" failed to activate`);
509
+ }
510
+
511
+ // Try again
512
+ if (this.commands.has(commandName)) {
513
+ const handler = this.commands.get(commandName);
514
+ return await handler(params);
515
+ }
516
+
517
+ throw new Error(`Command "${commandName}" not found`);
518
+ }
519
+
520
+ /**
521
+ * Check if a command exists
522
+ * @param {string} commandName
523
+ * @returns {boolean}
524
+ */
525
+ hasCommand(commandName) {
526
+ return this.commands.has(commandName);
527
+ }
528
+
529
+ /**
530
+ * List all registered commands
531
+ * @returns {Array<string>}
532
+ */
533
+ listCommands() {
534
+ return Array.from(this.commands.keys());
535
+ }
536
+
537
+ /**
538
+ * Execute a keyboard shortcut
539
+ * @param {string} shortcut - e.g., "Ctrl+B"
540
+ * @returns {Promise<any>} Command result
541
+ */
542
+ async execShortcut(shortcut) {
543
+ const commandName = this.shortcuts.get(shortcut);
544
+ if (!commandName) {
545
+ console.warn(`[Kernel] No command bound to shortcut "${shortcut}"`);
546
+ return null;
547
+ }
548
+
549
+ return await this.exec(commandName, { fromShortcut: true });
550
+ }
551
+
552
+ /**
553
+ * Subscribe to an event
554
+ * @param {string} event - Event name or pattern (e.g., "module:*")
555
+ * @param {Function} handler
556
+ */
557
+ on(event, handler) {
558
+ if (!this.eventListeners.has(event)) {
559
+ this.eventListeners.set(event, new Set());
560
+ }
561
+ this.eventListeners.get(event).add(handler);
562
+ }
563
+
564
+ /**
565
+ * Unsubscribe from an event
566
+ * @param {string} event
567
+ * @param {Function} handler
568
+ */
569
+ off(event, handler) {
570
+ const listeners = this.eventListeners.get(event);
571
+ if (listeners) {
572
+ listeners.delete(handler);
573
+ }
574
+ }
575
+
576
+ /**
577
+ * Subscribe to an event, fire only once
578
+ * @param {string} event
579
+ * @param {Function} handler
580
+ */
581
+ once(event, handler) {
582
+ const wrapper = (...args) => {
583
+ handler(...args);
584
+ this.off(event, wrapper);
585
+ };
586
+ this.on(event, wrapper);
587
+ }
588
+
589
+ /**
590
+ * Emit an event
591
+ * @param {string} event
592
+ * @param {Object} data
593
+ */
594
+ emit(event, data = {}) {
595
+ // Direct listeners
596
+ const listeners = this.eventListeners.get(event);
597
+ if (listeners) {
598
+ listeners.forEach(handler => {
599
+ try {
600
+ handler(data);
601
+ } catch (err) {
602
+ console.error(`[Kernel] Event handler error for "${event}":`, err);
603
+ }
604
+ });
605
+ }
606
+
607
+ // Wildcard listeners (e.g., "module:*" matches "module:loaded")
608
+ const eventPrefix = event.split(':')[0];
609
+ const wildcardEvent = eventPrefix + ':*';
610
+ const wildcardListeners = this.eventListeners.get(wildcardEvent);
611
+ if (wildcardListeners) {
612
+ wildcardListeners.forEach(handler => {
613
+ try {
614
+ handler(data);
615
+ } catch (err) {
616
+ console.error(`[Kernel] Wildcard event handler error for "${wildcardEvent}":`, err);
617
+ }
618
+ });
619
+ }
620
+ }
621
+
622
+ /**
623
+ * Internal: extract module ID from command name
624
+ * @private
625
+ */
626
+ _getModuleFromCommand(commandName) {
627
+ return commandName.split('.')[0];
628
+ }
629
+
630
+ /**
631
+ * Internal: evict least-recently-used inactive modules
632
+ * @private
633
+ */
634
+ _evictLRU() {
635
+ const inactive = Array.from(this.modules.entries())
636
+ .filter(([id, m]) => m.state === ModuleState.INACTIVE)
637
+ .sort((a, b) => {
638
+ const timeA = this._lastUsed.get(a[0]) || 0;
639
+ const timeB = this._lastUsed.get(b[0]) || 0;
640
+ return timeA - timeB; // oldest first
641
+ });
642
+
643
+ if (inactive.length > 0) {
644
+ const [lruId] = inactive[0];
645
+ console.log(`[Kernel] Memory pressure > 0.8, evicting "${lruId}"`);
646
+ this.unload(lruId);
647
+ }
648
+ }
649
+
650
+ /**
651
+ * Get detailed kernel status
652
+ * @returns {Object}
653
+ */
654
+ status() {
655
+ return {
656
+ modules: {
657
+ registered: this.modules.size,
658
+ active: Array.from(this.modules.values()).filter(m => m.state === ModuleState.ACTIVE).length,
659
+ inactive: Array.from(this.modules.values()).filter(m => m.state === ModuleState.INACTIVE).length,
660
+ error: Array.from(this.modules.values()).filter(m => m.state === ModuleState.ERROR).length,
661
+ },
662
+ commands: this.commands.size,
663
+ shortcuts: this.shortcuts.size,
664
+ memory: {
665
+ usage: this.memory.usage(),
666
+ budget: this.memory.budget,
667
+ pressure: (this.memory.pressure() * 100).toFixed(1) + '%',
668
+ },
669
+ state: {
670
+ keys: this.state._values.size,
671
+ watchers: this.state._watchers.size,
672
+ },
673
+ };
674
+ }
675
+ }
676
+
677
+ // Create singleton kernel instance
678
+ const kernel = new Kernel();
679
+
680
+ // Export and attach to window
681
+ export default kernel;
682
+ if (typeof window !== 'undefined') {
683
+ window.kernel = kernel;
684
+ }