slicejs-web-framework 4.0.0 → 4.0.1

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.
Files changed (70) hide show
  1. package/.opencode/opencode.json +14 -14
  2. package/LICENSE +21 -21
  3. package/README.md +97 -97
  4. package/Slice/Components/Structural/ContextManager/ContextManager.js +422 -422
  5. package/Slice/Components/Structural/ContextManager/ContextManagerDebugger.js +470 -470
  6. package/Slice/Components/Structural/Controller/Controller.js +1406 -1406
  7. package/Slice/Components/Structural/Controller/allowedValuesValidation.js +52 -52
  8. package/Slice/Components/Structural/Debugger/Debugger.html +72 -72
  9. package/Slice/Components/Structural/Debugger/Debugger.js +1505 -1505
  10. package/Slice/Components/Structural/EventManager/EventManager.js +651 -651
  11. package/Slice/Components/Structural/EventManager/EventManagerDebugger.js +985 -985
  12. package/Slice/Components/Structural/LeakInspector/LeakInspector.js +296 -296
  13. package/Slice/Components/Structural/Logger/Log.js +10 -10
  14. package/Slice/Components/Structural/Logger/LogViewer/LogViewer.js +662 -662
  15. package/Slice/Components/Structural/Logger/Logger.js +225 -225
  16. package/Slice/Components/Structural/Router/Router.js +793 -793
  17. package/Slice/Components/Structural/StylesManager/StylesManager.js +82 -82
  18. package/Slice/Components/Structural/StylesManager/ThemeManager/ThemeManager.js +162 -162
  19. package/Slice/Slice.js +686 -697
  20. package/Slice/tests/build-bundled-component-without-category.test.js +103 -103
  21. package/Slice/tests/build-js-only-visual-components.test.js +144 -144
  22. package/Slice/tests/build-singleton.test.js +244 -244
  23. package/Slice/tests/bundle-v2-runtime-contract.test.js +738 -738
  24. package/Slice/tests/context-patch-use.test.js +95 -95
  25. package/Slice/tests/destroy-cascade.test.js +215 -215
  26. package/Slice/tests/event-emitters.test.js +79 -79
  27. package/Slice/tests/event-registry.test.js +141 -141
  28. package/Slice/tests/fixtures/real-runtime-loader.mjs +21 -21
  29. package/Slice/tests/leak-orphans.test.js +131 -131
  30. package/Slice/tests/props-allowed-values-validation.test.js +119 -119
  31. package/Slice/tests/public-env-runtime-accessors.test.js +44 -44
  32. package/Slice/tests/public-env-typed-accessors.test.js +66 -66
  33. package/Slice/tests/router-loading-finally.test.js +68 -68
  34. package/api/framework/devDepsOptimizer.js +335 -335
  35. package/api/framework/express.js +3 -3
  36. package/api/framework/precompressedStatic.js +93 -93
  37. package/api/framework/publicEnvResolver.js +123 -123
  38. package/api/framework/securityMiddleware.js +197 -197
  39. package/api/framework/server.js +411 -411
  40. package/api/index.js +7 -7
  41. package/api/tests/dev-deps-optimizer.test.js +137 -137
  42. package/api/tests/dev-server-external.test.js +56 -56
  43. package/api/tests/precompressed-static.test.js +119 -119
  44. package/api/tests/public-env-resolver.test.js +193 -193
  45. package/package.json +49 -49
  46. package/sliceConfig.schema.json +122 -122
  47. package/src/App/index.html +69 -69
  48. package/src/App/index.js +20 -20
  49. package/src/App/style.css +11 -11
  50. package/src/Components/AppComponents/AboutSection/AboutSection.css +9 -9
  51. package/src/Components/AppComponents/AboutSection/AboutSection.html +8 -8
  52. package/src/Components/AppComponents/AboutSection/AboutSection.js +12 -12
  53. package/src/Components/AppComponents/AppShell/AppShell.css +10 -10
  54. package/src/Components/AppComponents/AppShell/AppShell.html +4 -4
  55. package/src/Components/AppComponents/AppShell/AppShell.js +36 -36
  56. package/src/Components/AppComponents/HomeSection/HomeSection.css +20 -20
  57. package/src/Components/AppComponents/HomeSection/HomeSection.html +10 -10
  58. package/src/Components/AppComponents/HomeSection/HomeSection.js +19 -19
  59. package/src/Components/components.js +8 -8
  60. package/src/public/Styles/sliceStyles.css +34 -34
  61. package/src/public/Themes/Dark.css +42 -42
  62. package/src/public/Themes/Light.css +31 -31
  63. package/src/public/Themes/Slice.css +47 -47
  64. package/src/public/manifest.json +15 -15
  65. package/src/public/robots.txt +4 -4
  66. package/src/public/sitemap.xml +8 -8
  67. package/src/routes.js +9 -9
  68. package/src/sliceConfig.json +72 -72
  69. package/tests/router-emit-ordering.test.js +63 -63
  70. package/types/index.d.ts +219 -219
@@ -1,651 +1,651 @@
1
- /**
2
- * EventManager - Sistema de eventos pub/sub para Slice.js
3
- * Ubicación: /Slice/Components/Structural/EventManager/EventManager.js
4
- *
5
- * Características:
6
- * - Suscripciones globales y vinculadas a componentes
7
- * - Auto-limpieza cuando componentes se destruyen
8
- * - API simple: subscribe, subscribeOnce, unsubscribe, emit
9
- */
10
- /**
11
- * @typedef {Object} EventManagerBind
12
- * @property {(eventName: string, callback: Function) => string|null} subscribe
13
- * @property {(eventName: string, callback: Function) => string|null} subscribeOnce
14
- * @property {(eventName: string, data?: any) => void} emit
15
- */
16
-
17
-
18
- export default class EventManager {
19
- constructor() {
20
- // Map<eventName, Map<subscriptionId, { callback, componentSliceId, once }>>
21
- this.subscriptions = new Map();
22
-
23
- // Map<sliceId, Set<{ eventName, subscriptionId }>> - Para auto-cleanup
24
- this.componentSubscriptions = new Map();
25
-
26
- // Ring buffer de últimos emits (max 500) — solo se llena cuando el debugger está abierto
27
- this.emitHistory = [];
28
-
29
- // Contador de emits por evento en la sesión actual de grabación
30
- this.emitCounts = new Map();
31
-
32
- // Atribución de emisores por evento (tracing): Map<eventName, Map<key, { sliceId?, name?, source?, count, lastTs }>>
33
- // Se llena solo mientras un debugger está abierto (igual que emitHistory).
34
- this.emitters = new Map();
35
-
36
- // Flag: solo grabamos cuando algún debugger UI está visible
37
- this._recording = false;
38
-
39
- // Contador para IDs únicos
40
- this.idCounter = 0;
41
-
42
- // ── Event registry (catálogo declarado vía register()) ──
43
- // Map<eventName, { description, payload }>
44
- this.registry = new Map();
45
- // Validación activa: se enciende al primer register(). En loose mode (sin
46
- // registry) NO se advierte nada — comportamiento idéntico al histórico.
47
- this._validating = false;
48
- // Warn-once: nombres ya advertidos como no declarados (evita spam).
49
- this.undeclaredSeen = new Set();
50
- // Drift para el debugger: eventos usados (emit/subscribe) pero no declarados.
51
- this.undeclared = new Set();
52
- // Warn-once: nombres ya advertidos por no tener namespace.
53
- this._noNamespaceSeen = new Set();
54
-
55
- // Grafo estático (documentación) generado por `slice types generate`:
56
- // { events: { name: { payload, emitters[], listeners[] } }, dynamic: {...} }.
57
- // Complementa el tracing observacional (this.emitters / this.emitHistory).
58
- this.graph = null;
59
- }
60
-
61
- init() {
62
- return true;
63
- }
64
-
65
- // ============================================
66
- // REGISTRY - Catálogo de eventos declarados
67
- // ============================================
68
-
69
- /**
70
- * Declarar un catálogo de eventos. Mergeable: se puede llamar varias veces
71
- * desde varios módulos. Enciende la validación dev (warn-on-undeclared).
72
- *
73
- * Dos formas:
74
- * - register(catalog) con claves planas `'namespace:event'`.
75
- * - register(namespace, catalog) que prefija cada clave con `namespace:`,
76
- * para declarar un grupo sin repetir el prefijo.
77
- *
78
- * @param {string|Object<string, { description?: string, payload?: object|null }>} namespaceOrCatalog
79
- * @param {Object<string, { description?: string, payload?: object|null }>} [maybeCatalog]
80
- * @returns {EventManager} this (encadenable)
81
- *
82
- * @example
83
- * slice.events.register({
84
- * 'user:login': { description: 'User logged in', payload: { id: 'number', name: 'string' } },
85
- * 'cart:cleared': { description: 'Cart emptied', payload: null },
86
- * });
87
- *
88
- * @example
89
- * // Agrupado por namespace
90
- * slice.events.register('user', {
91
- * login: { payload: { id: 'number', name: 'string' } },
92
- * logout: { payload: null },
93
- * }); // => 'user:login', 'user:logout'
94
- */
95
- register(namespaceOrCatalog, maybeCatalog) {
96
- let prefix = '';
97
- let catalog = namespaceOrCatalog;
98
-
99
- if (typeof namespaceOrCatalog === 'string') {
100
- const ns = namespaceOrCatalog.trim();
101
- prefix = ns.endsWith(':') ? ns : `${ns}:`;
102
- catalog = maybeCatalog;
103
- }
104
-
105
- if (!catalog || typeof catalog !== 'object') {
106
- slice.logger.logError('EventManager', 'register() requiere un objeto catálogo de eventos');
107
- return this;
108
- }
109
-
110
- // Al primer register() sembramos los eventos internos del framework para
111
- // que nunca disparen falsos warnings, y activamos la validación.
112
- if (!this._validating) {
113
- this._seedFrameworkEvents();
114
- this._validating = true;
115
- }
116
-
117
- for (const [key, definition] of Object.entries(catalog)) {
118
- const eventName = `${prefix}${key}`;
119
- this._warnIfNoNamespace(eventName);
120
-
121
- const entry = {
122
- description: definition?.description || '',
123
- payload: definition?.payload ?? null,
124
- };
125
-
126
- const existing = this.registry.get(eventName);
127
- if (existing && JSON.stringify(existing.payload) !== JSON.stringify(entry.payload)) {
128
- slice.logger.logWarning(
129
- 'EventManager',
130
- `Evento "${eventName}" redeclarado con un payload distinto; se usa la última definición`
131
- );
132
- }
133
-
134
- this.registry.set(eventName, entry);
135
- // Si estaba marcado como drift, ya no lo está.
136
- this.undeclared.delete(eventName);
137
- this.undeclaredSeen.delete(eventName);
138
- }
139
-
140
- return this;
141
- }
142
-
143
- /**
144
- * Cargar el grafo estático de eventos (manifest generado por el CLI). Es la capa
145
- * de DOCUMENTACIÓN (emisores/oyentes por análisis de código, sin ejecutar), que
146
- * complementa el tracing observacional en runtime.
147
- * @param {{ events: object, dynamic?: object }} graph
148
- * @returns {EventManager} this
149
- */
150
- loadGraph(graph) {
151
- if (graph && typeof graph === 'object' && graph.events) {
152
- this.graph = graph;
153
- }
154
- return this;
155
- }
156
-
157
- /**
158
- * Emisores conocidos de un evento por análisis estático (documentación).
159
- * @param {string} eventName
160
- * @returns {Array<{ file: string, line: number, component: string|null }>}
161
- */
162
- staticEmittersOf(eventName) {
163
- return this.graph?.events?.[eventName]?.emitters || [];
164
- }
165
-
166
- /**
167
- * Oyentes conocidos de un evento por análisis estático (documentación).
168
- * @param {string} eventName
169
- * @returns {Array<{ file: string, line: number, component: string|null }>}
170
- */
171
- staticListenersOf(eventName) {
172
- return this.graph?.events?.[eventName]?.listeners || [];
173
- }
174
-
175
- /**
176
- * Namespace de un evento: el segmento antes del primer ':' (o null si no tiene).
177
- * @param {string} eventName
178
- * @returns {string|null}
179
- */
180
- namespaceOf(eventName) {
181
- const index = typeof eventName === 'string' ? eventName.indexOf(':') : -1;
182
- return index > 0 ? eventName.slice(0, index) : null;
183
- }
184
-
185
- /**
186
- * Sembrar los eventos internos del framework en el registry.
187
- * @private
188
- */
189
- _seedFrameworkEvents() {
190
- const framework = {
191
- 'router:change': { description: 'Router navigated to a new route', payload: null },
192
- 'context:__created': { description: 'A context was created', payload: { name: 'string', state: 'object' } },
193
- };
194
- for (const [name, def] of Object.entries(framework)) {
195
- if (!this.registry.has(name)) this.registry.set(name, def);
196
- }
197
- }
198
-
199
- /**
200
- * ¿El evento está declarado en el registry?
201
- * @param {string} eventName
202
- * @returns {boolean}
203
- */
204
- isDeclared(eventName) {
205
- if (this.registry.has(eventName)) return true;
206
- // Familia dinámica context:<name> emitida por el ContextManager.
207
- if (eventName.startsWith('context:')) return true;
208
- return false;
209
- }
210
-
211
- /**
212
- * Advertir (una sola vez, solo en dev) si el evento no está declarado.
213
- * @private
214
- */
215
- _warnIfUndeclared(eventName) {
216
- if (!this._validating) return;
217
- if (this.isDeclared(eventName)) return;
218
-
219
- this.undeclared.add(eventName);
220
- if (this.undeclaredSeen.has(eventName)) return;
221
- this.undeclaredSeen.add(eventName);
222
-
223
- if (!slice.isProduction || !slice.isProduction()) {
224
- slice.logger.logWarning(
225
- 'EventManager',
226
- `Undeclared event "${eventName}" — register it via slice.events.register({ '${eventName}': { payload: ... } })`
227
- );
228
- }
229
- }
230
-
231
- /**
232
- * Advertir (una sola vez, solo en dev) si el evento no tiene namespace.
233
- * Solo activo cuando hay un registry en uso (this._validating).
234
- * @private
235
- */
236
- _warnIfNoNamespace(eventName) {
237
- if (!this._validating) return;
238
- if (typeof eventName !== 'string' || eventName.includes(':')) return;
239
- if (this._noNamespaceSeen.has(eventName)) return;
240
- this._noNamespaceSeen.add(eventName);
241
-
242
- if (!slice.isProduction || !slice.isProduction()) {
243
- slice.logger.logWarning(
244
- 'EventManager',
245
- `Event "${eventName}" has no namespace — prefer "namespace:${eventName}" (e.g. group it with slice.events.register('app', { ${eventName}: ... })).`
246
- );
247
- }
248
- }
249
-
250
- // ============================================
251
- // API PRINCIPAL
252
- // ============================================
253
-
254
- /**
255
- * Suscribirse a un evento
256
- * @param {string} eventName - Nombre del evento
257
- * @param {(data?: any) => void} callback - Funcion a ejecutar cuando se emita el evento
258
- * @param {{ component?: HTMLElement }} [options]
259
- * @returns {string|null} subscriptionId - ID para desuscribirse
260
- *
261
- * @example
262
- * // Suscripcion global
263
- * const id = slice.events.subscribe('user:login', (user) => {
264
- * console.log('Usuario:', user);
265
- * });
266
- *
267
- * // Suscripcion con auto-cleanup
268
- * slice.events.subscribe('user:login', (user) => {
269
- * this.actualizar(user);
270
- * }, { component: this });
271
- */
272
- subscribe(eventName, callback, options = {}) {
273
- if (typeof callback !== 'function') {
274
- slice.logger.logError('EventManager', 'El callback debe ser una función');
275
- return null;
276
- }
277
-
278
- this._warnIfUndeclared(eventName);
279
- this._warnIfNoNamespace(eventName);
280
-
281
- const subscriptionId = `evt_${++this.idCounter}`;
282
-
283
- // Crear Map para este evento si no existe
284
- if (!this.subscriptions.has(eventName)) {
285
- this.subscriptions.set(eventName, new Map());
286
- }
287
-
288
- // Guardar la suscripción
289
- this.subscriptions.get(eventName).set(subscriptionId, {
290
- callback,
291
- componentSliceId: options.component?.sliceId || null,
292
- once: false,
293
- });
294
-
295
- // Si hay componente, registrar para auto-cleanup
296
- if (options.component?.sliceId) {
297
- this._registerComponentSubscription(options.component.sliceId, eventName, subscriptionId);
298
- }
299
-
300
- slice.logger.logInfo('EventManager', `Suscrito a "${eventName}" [${subscriptionId}]`);
301
-
302
- return subscriptionId;
303
- }
304
-
305
- /**
306
- * Suscribirse a un evento una sola vez
307
- * @param {string} eventName - Nombre del evento
308
- * @param {(data?: any) => void} callback - Funcion a ejecutar
309
- * @param {{ component?: HTMLElement }} [options]
310
- * @returns {string|null} subscriptionId
311
- *
312
- * @example
313
- * slice.events.subscribeOnce('app:ready', () => {
314
- * console.log('App lista!');
315
- * });
316
- */
317
- /**
318
- * Activar registro de emits (lo llama el debugger al abrirse).
319
- */
320
- startRecording() {
321
- this._recording = true;
322
- this.emitHistory = [];
323
- this.emitCounts = new Map();
324
- this.emitters = new Map();
325
- }
326
-
327
- /**
328
- * Desactivar registro de emits (lo llama el debugger al cerrarse).
329
- */
330
- stopRecording() {
331
- this._recording = false;
332
- }
333
-
334
- subscribeOnce(eventName, callback, options = {}) {
335
- if (typeof callback !== 'function') {
336
- slice.logger.logError('EventManager', 'El callback debe ser una función');
337
- return null;
338
- }
339
-
340
- this._warnIfUndeclared(eventName);
341
- this._warnIfNoNamespace(eventName);
342
-
343
- const subscriptionId = `evt_${++this.idCounter}`;
344
-
345
- if (!this.subscriptions.has(eventName)) {
346
- this.subscriptions.set(eventName, new Map());
347
- }
348
-
349
- this.subscriptions.get(eventName).set(subscriptionId, {
350
- callback,
351
- componentSliceId: options.component?.sliceId || null,
352
- once: true,
353
- });
354
-
355
- if (options.component?.sliceId) {
356
- this._registerComponentSubscription(options.component.sliceId, eventName, subscriptionId);
357
- }
358
-
359
- slice.logger.logInfo('EventManager', `Suscrito (once) a "${eventName}" [${subscriptionId}]`);
360
-
361
- return subscriptionId;
362
- }
363
-
364
- /**
365
- * Desuscribirse de un evento
366
- * @param {string} eventName - Nombre del evento
367
- * @param {string} subscriptionId - ID de la suscripcion
368
- * @returns {boolean} true si se elimino correctamente
369
- *
370
- * @example
371
- * const id = slice.events.subscribe('evento', callback);
372
- * // Despues...
373
- * slice.events.unsubscribe('evento', id);
374
- */
375
- unsubscribe(eventName, subscriptionId) {
376
- if (!this.subscriptions.has(eventName)) {
377
- return false;
378
- }
379
-
380
- const removed = this.subscriptions.get(eventName).delete(subscriptionId);
381
-
382
- // Limpiar Map vacío
383
- if (this.subscriptions.get(eventName).size === 0) {
384
- this.subscriptions.delete(eventName);
385
- }
386
-
387
- if (removed) {
388
- slice.logger.logInfo('EventManager', `Desuscrito de "${eventName}" [${subscriptionId}]`);
389
- }
390
-
391
- return removed;
392
- }
393
-
394
- /**
395
- * Emitir un evento
396
- * @param {string} eventName - Nombre del evento
397
- * @param {any} [data] - Datos a pasar a los callbacks
398
- * @returns {void}
399
- *
400
- * @example
401
- * slice.events.emit('user:login', { id: 123, name: 'Juan' });
402
- * slice.events.emit('cart:cleared'); // Sin datos
403
- */
404
- emit(eventName, ...args) {
405
- // Global (unbound) emit — emitter resolved best-effort from the call stack.
406
- this._emitFrom(null, eventName, args);
407
- }
408
-
409
- /**
410
- * Core emit. `emitter` is the known source ({ sliceId, name }) when emitted via
411
- * a bound API, or null for global emits (then a dev stack-trace fallback runs).
412
- * @param {{ sliceId?: string, name?: string }|null} emitter
413
- * @param {string} eventName
414
- * @param {any[]} args
415
- * @private
416
- */
417
- _emitFrom(emitter, eventName, args) {
418
- slice.logger.info('EventManager', `Emitting "${eventName}"`, args[0] ?? null);
419
-
420
- this._warnIfUndeclared(eventName);
421
- this._warnIfNoNamespace(eventName);
422
-
423
- // Solo grabamos el histórico (y la atribución de emisores) si algún debugger está abierto.
424
- if (this._recording) {
425
- const source = emitter || this._captureEmitterSource();
426
- this.emitHistory.push({ eventName, timestamp: Date.now(), emitter: source });
427
- if (this.emitHistory.length > 500) this.emitHistory.shift();
428
- this.emitCounts.set(eventName, (this.emitCounts.get(eventName) || 0) + 1);
429
- this._recordEmitter(eventName, source);
430
- }
431
-
432
- if (!this.subscriptions.has(eventName)) {
433
- return;
434
- }
435
-
436
- const toRemove = [];
437
-
438
- for (const [subscriptionId, subscription] of this.subscriptions.get(eventName)) {
439
- if (subscription.componentSliceId) {
440
- if (!slice.controller.activeComponents.has(subscription.componentSliceId)) {
441
- toRemove.push(subscriptionId);
442
- continue;
443
- }
444
- }
445
-
446
- try {
447
- subscription.callback(...args);
448
- } catch (error) {
449
- slice.logger.error('EventManager', `Error in callback for "${eventName}" [${subscriptionId}]`, error);
450
- }
451
-
452
- // Si es subscribeOnce, marcar para eliminar
453
- if (subscription.once) {
454
- toRemove.push(subscriptionId);
455
- }
456
- }
457
-
458
- // Limpiar suscripciones marcadas
459
- toRemove.forEach((id) => {
460
- this.subscriptions.get(eventName).delete(id);
461
- });
462
-
463
- // Limpiar Map vacío
464
- if (this.subscriptions.get(eventName).size === 0) {
465
- this.subscriptions.delete(eventName);
466
- }
467
- }
468
-
469
- /**
470
- * Aggregate an emitter under an event: who emits, with a count. Powers the
471
- * "emitted by" tracing view in the debugger.
472
- * @param {{ sliceId?: string, name?: string, source?: string }|null} emitter
473
- * @private
474
- */
475
- _recordEmitter(eventName, emitter) {
476
- if (!emitter) return;
477
- const key = emitter.sliceId || emitter.source || 'unknown';
478
- if (!this.emitters.has(eventName)) this.emitters.set(eventName, new Map());
479
- const bucket = this.emitters.get(eventName);
480
- const existing = bucket.get(key);
481
- if (existing) {
482
- existing.count += 1;
483
- existing.lastTs = Date.now();
484
- } else {
485
- bucket.set(key, { ...emitter, count: 1, lastTs: Date.now() });
486
- }
487
- }
488
-
489
- /**
490
- * Best-effort source of a global emit from the call stack (dev only). Returns
491
- * { source: 'file.js:line' } or null. Robust: never throws, never runs in prod.
492
- * @private
493
- */
494
- _captureEmitterSource() {
495
- if (slice.isProduction && slice.isProduction()) return null;
496
- try {
497
- const stack = new Error().stack || '';
498
- for (const line of stack.split('\n')) {
499
- // Skip the Error header and every frame inside this manager.
500
- if (line.includes('EventManager.js')) continue;
501
- const match = line.match(/([^/\\\s(]+\.js):(\d+):\d+/);
502
- if (match) return { source: `${match[1]}:${match[2]}` };
503
- }
504
- } catch {
505
- /* tracing is best-effort */
506
- }
507
- return null;
508
- }
509
-
510
- // ============================================
511
- // BIND - Vinculación a Componente
512
- // ============================================
513
-
514
- /**
515
- * Vincular el EventManager a un componente para auto-cleanup
516
- * @param {HTMLElement} component - Componente Slice con sliceId
517
- * @returns {EventManagerBind|null} API vinculada al componente
518
- *
519
- * @example
520
- * class MiComponente extends HTMLElement {
521
- * async init() {
522
- * this.events = slice.events.bind(this);
523
- *
524
- * this.events.subscribe('user:login', (user) => {
525
- * this.actualizar(user);
526
- * });
527
- * }
528
- * }
529
- */
530
- bind(component) {
531
- if (!component?.sliceId) {
532
- slice.logger.logError('EventManager', 'bind() requiere un componente Slice válido con sliceId');
533
- return null;
534
- }
535
-
536
- const self = this;
537
-
538
- return {
539
- /**
540
- * Suscribirse a un evento (auto-cleanup)
541
- */
542
- subscribe: (eventName, callback) => {
543
- return self.subscribe(eventName, callback, { component });
544
- },
545
-
546
- /**
547
- * Suscribirse una sola vez (auto-cleanup)
548
- */
549
- subscribeOnce: (eventName, callback) => {
550
- return self.subscribeOnce(eventName, callback, { component });
551
- },
552
-
553
- /**
554
- * Emitir un evento (atribuido a este componente para el tracing).
555
- */
556
- emit: (eventName, data) => {
557
- self._emitFrom(
558
- { sliceId: component.sliceId, name: component.constructor?.name || 'Component' },
559
- eventName,
560
- [data]
561
- );
562
- },
563
-
564
- /**
565
- * Declarar eventos en el registry (passthrough a register()).
566
- */
567
- register: (namespaceOrCatalog, maybeCatalog) => {
568
- self.register(namespaceOrCatalog, maybeCatalog);
569
- return self;
570
- },
571
- };
572
- }
573
-
574
- // ============================================
575
- // AUTO-CLEANUP
576
- // ============================================
577
-
578
- /**
579
- * Registrar suscripción para un componente (interno)
580
- */
581
- _registerComponentSubscription(sliceId, eventName, subscriptionId) {
582
- if (!this.componentSubscriptions.has(sliceId)) {
583
- this.componentSubscriptions.set(sliceId, new Set());
584
- }
585
- this.componentSubscriptions.get(sliceId).add({ eventName, subscriptionId });
586
- }
587
-
588
- /**
589
- * Limpiar todas las suscripciones de un componente
590
- * Se llama automáticamente cuando el componente se destruye
591
- * @param {string} sliceId - ID del componente
592
- * @returns {number} Cantidad de suscripciones eliminadas
593
- */
594
- cleanupComponent(sliceId) {
595
- if (!this.componentSubscriptions.has(sliceId)) {
596
- return 0;
597
- }
598
-
599
- const subscriptions = this.componentSubscriptions.get(sliceId);
600
- let count = 0;
601
-
602
- for (const { eventName, subscriptionId } of subscriptions) {
603
- if (this.unsubscribe(eventName, subscriptionId)) {
604
- count++;
605
- }
606
- }
607
-
608
- this.componentSubscriptions.delete(sliceId);
609
-
610
- if (count > 0) {
611
- slice.logger.logInfo('EventManager', `Limpiadas ${count} suscripción(es) de ${sliceId}`);
612
- }
613
-
614
- return count;
615
- }
616
-
617
- // ============================================
618
- // UTILIDADES
619
- // ============================================
620
-
621
- /**
622
- * Verificar si hay suscriptores para un evento
623
- * @param {string} eventName - Nombre del evento
624
- * @returns {boolean}
625
- */
626
- hasSubscribers(eventName) {
627
- return this.subscriptions.has(eventName) && this.subscriptions.get(eventName).size > 0;
628
- }
629
-
630
- /**
631
- * Obtener cantidad de suscriptores para un evento
632
- * @param {string} eventName - Nombre del evento
633
- * @returns {number}
634
- */
635
- subscriberCount(eventName) {
636
- if (!this.subscriptions.has(eventName)) {
637
- return 0;
638
- }
639
- return this.subscriptions.get(eventName).size;
640
- }
641
-
642
- /**
643
- * Limpiar TODAS las suscripciones (usar con cuidado)
644
- */
645
- clear() {
646
- this.subscriptions.clear();
647
- this.componentSubscriptions.clear();
648
- this.idCounter = 0;
649
- slice.logger.logInfo('EventManager', 'Todas las suscripciones eliminadas');
650
- }
651
- }
1
+ /**
2
+ * EventManager - Sistema de eventos pub/sub para Slice.js
3
+ * Ubicación: /Slice/Components/Structural/EventManager/EventManager.js
4
+ *
5
+ * Características:
6
+ * - Suscripciones globales y vinculadas a componentes
7
+ * - Auto-limpieza cuando componentes se destruyen
8
+ * - API simple: subscribe, subscribeOnce, unsubscribe, emit
9
+ */
10
+ /**
11
+ * @typedef {Object} EventManagerBind
12
+ * @property {(eventName: string, callback: Function) => string|null} subscribe
13
+ * @property {(eventName: string, callback: Function) => string|null} subscribeOnce
14
+ * @property {(eventName: string, data?: any) => void} emit
15
+ */
16
+
17
+
18
+ export default class EventManager {
19
+ constructor() {
20
+ // Map<eventName, Map<subscriptionId, { callback, componentSliceId, once }>>
21
+ this.subscriptions = new Map();
22
+
23
+ // Map<sliceId, Set<{ eventName, subscriptionId }>> - Para auto-cleanup
24
+ this.componentSubscriptions = new Map();
25
+
26
+ // Ring buffer de últimos emits (max 500) — solo se llena cuando el debugger está abierto
27
+ this.emitHistory = [];
28
+
29
+ // Contador de emits por evento en la sesión actual de grabación
30
+ this.emitCounts = new Map();
31
+
32
+ // Atribución de emisores por evento (tracing): Map<eventName, Map<key, { sliceId?, name?, source?, count, lastTs }>>
33
+ // Se llena solo mientras un debugger está abierto (igual que emitHistory).
34
+ this.emitters = new Map();
35
+
36
+ // Flag: solo grabamos cuando algún debugger UI está visible
37
+ this._recording = false;
38
+
39
+ // Contador para IDs únicos
40
+ this.idCounter = 0;
41
+
42
+ // ── Event registry (catálogo declarado vía register()) ──
43
+ // Map<eventName, { description, payload }>
44
+ this.registry = new Map();
45
+ // Validación activa: se enciende al primer register(). En loose mode (sin
46
+ // registry) NO se advierte nada — comportamiento idéntico al histórico.
47
+ this._validating = false;
48
+ // Warn-once: nombres ya advertidos como no declarados (evita spam).
49
+ this.undeclaredSeen = new Set();
50
+ // Drift para el debugger: eventos usados (emit/subscribe) pero no declarados.
51
+ this.undeclared = new Set();
52
+ // Warn-once: nombres ya advertidos por no tener namespace.
53
+ this._noNamespaceSeen = new Set();
54
+
55
+ // Grafo estático (documentación) generado por `slice types generate`:
56
+ // { events: { name: { payload, emitters[], listeners[] } }, dynamic: {...} }.
57
+ // Complementa el tracing observacional (this.emitters / this.emitHistory).
58
+ this.graph = null;
59
+ }
60
+
61
+ init() {
62
+ return true;
63
+ }
64
+
65
+ // ============================================
66
+ // REGISTRY - Catálogo de eventos declarados
67
+ // ============================================
68
+
69
+ /**
70
+ * Declarar un catálogo de eventos. Mergeable: se puede llamar varias veces
71
+ * desde varios módulos. Enciende la validación dev (warn-on-undeclared).
72
+ *
73
+ * Dos formas:
74
+ * - register(catalog) con claves planas `'namespace:event'`.
75
+ * - register(namespace, catalog) que prefija cada clave con `namespace:`,
76
+ * para declarar un grupo sin repetir el prefijo.
77
+ *
78
+ * @param {string|Object<string, { description?: string, payload?: object|null }>} namespaceOrCatalog
79
+ * @param {Object<string, { description?: string, payload?: object|null }>} [maybeCatalog]
80
+ * @returns {EventManager} this (encadenable)
81
+ *
82
+ * @example
83
+ * slice.events.register({
84
+ * 'user:login': { description: 'User logged in', payload: { id: 'number', name: 'string' } },
85
+ * 'cart:cleared': { description: 'Cart emptied', payload: null },
86
+ * });
87
+ *
88
+ * @example
89
+ * // Agrupado por namespace
90
+ * slice.events.register('user', {
91
+ * login: { payload: { id: 'number', name: 'string' } },
92
+ * logout: { payload: null },
93
+ * }); // => 'user:login', 'user:logout'
94
+ */
95
+ register(namespaceOrCatalog, maybeCatalog) {
96
+ let prefix = '';
97
+ let catalog = namespaceOrCatalog;
98
+
99
+ if (typeof namespaceOrCatalog === 'string') {
100
+ const ns = namespaceOrCatalog.trim();
101
+ prefix = ns.endsWith(':') ? ns : `${ns}:`;
102
+ catalog = maybeCatalog;
103
+ }
104
+
105
+ if (!catalog || typeof catalog !== 'object') {
106
+ slice.logger.logError('EventManager', 'register() requiere un objeto catálogo de eventos');
107
+ return this;
108
+ }
109
+
110
+ // Al primer register() sembramos los eventos internos del framework para
111
+ // que nunca disparen falsos warnings, y activamos la validación.
112
+ if (!this._validating) {
113
+ this._seedFrameworkEvents();
114
+ this._validating = true;
115
+ }
116
+
117
+ for (const [key, definition] of Object.entries(catalog)) {
118
+ const eventName = `${prefix}${key}`;
119
+ this._warnIfNoNamespace(eventName);
120
+
121
+ const entry = {
122
+ description: definition?.description || '',
123
+ payload: definition?.payload ?? null,
124
+ };
125
+
126
+ const existing = this.registry.get(eventName);
127
+ if (existing && JSON.stringify(existing.payload) !== JSON.stringify(entry.payload)) {
128
+ slice.logger.logWarning(
129
+ 'EventManager',
130
+ `Evento "${eventName}" redeclarado con un payload distinto; se usa la última definición`
131
+ );
132
+ }
133
+
134
+ this.registry.set(eventName, entry);
135
+ // Si estaba marcado como drift, ya no lo está.
136
+ this.undeclared.delete(eventName);
137
+ this.undeclaredSeen.delete(eventName);
138
+ }
139
+
140
+ return this;
141
+ }
142
+
143
+ /**
144
+ * Cargar el grafo estático de eventos (manifest generado por el CLI). Es la capa
145
+ * de DOCUMENTACIÓN (emisores/oyentes por análisis de código, sin ejecutar), que
146
+ * complementa el tracing observacional en runtime.
147
+ * @param {{ events: object, dynamic?: object }} graph
148
+ * @returns {EventManager} this
149
+ */
150
+ loadGraph(graph) {
151
+ if (graph && typeof graph === 'object' && graph.events) {
152
+ this.graph = graph;
153
+ }
154
+ return this;
155
+ }
156
+
157
+ /**
158
+ * Emisores conocidos de un evento por análisis estático (documentación).
159
+ * @param {string} eventName
160
+ * @returns {Array<{ file: string, line: number, component: string|null }>}
161
+ */
162
+ staticEmittersOf(eventName) {
163
+ return this.graph?.events?.[eventName]?.emitters || [];
164
+ }
165
+
166
+ /**
167
+ * Oyentes conocidos de un evento por análisis estático (documentación).
168
+ * @param {string} eventName
169
+ * @returns {Array<{ file: string, line: number, component: string|null }>}
170
+ */
171
+ staticListenersOf(eventName) {
172
+ return this.graph?.events?.[eventName]?.listeners || [];
173
+ }
174
+
175
+ /**
176
+ * Namespace de un evento: el segmento antes del primer ':' (o null si no tiene).
177
+ * @param {string} eventName
178
+ * @returns {string|null}
179
+ */
180
+ namespaceOf(eventName) {
181
+ const index = typeof eventName === 'string' ? eventName.indexOf(':') : -1;
182
+ return index > 0 ? eventName.slice(0, index) : null;
183
+ }
184
+
185
+ /**
186
+ * Sembrar los eventos internos del framework en el registry.
187
+ * @private
188
+ */
189
+ _seedFrameworkEvents() {
190
+ const framework = {
191
+ 'router:change': { description: 'Router navigated to a new route', payload: null },
192
+ 'context:__created': { description: 'A context was created', payload: { name: 'string', state: 'object' } },
193
+ };
194
+ for (const [name, def] of Object.entries(framework)) {
195
+ if (!this.registry.has(name)) this.registry.set(name, def);
196
+ }
197
+ }
198
+
199
+ /**
200
+ * ¿El evento está declarado en el registry?
201
+ * @param {string} eventName
202
+ * @returns {boolean}
203
+ */
204
+ isDeclared(eventName) {
205
+ if (this.registry.has(eventName)) return true;
206
+ // Familia dinámica context:<name> emitida por el ContextManager.
207
+ if (eventName.startsWith('context:')) return true;
208
+ return false;
209
+ }
210
+
211
+ /**
212
+ * Advertir (una sola vez, solo en dev) si el evento no está declarado.
213
+ * @private
214
+ */
215
+ _warnIfUndeclared(eventName) {
216
+ if (!this._validating) return;
217
+ if (this.isDeclared(eventName)) return;
218
+
219
+ this.undeclared.add(eventName);
220
+ if (this.undeclaredSeen.has(eventName)) return;
221
+ this.undeclaredSeen.add(eventName);
222
+
223
+ if (!slice.isProduction || !slice.isProduction()) {
224
+ slice.logger.logWarning(
225
+ 'EventManager',
226
+ `Undeclared event "${eventName}" — register it via slice.events.register({ '${eventName}': { payload: ... } })`
227
+ );
228
+ }
229
+ }
230
+
231
+ /**
232
+ * Advertir (una sola vez, solo en dev) si el evento no tiene namespace.
233
+ * Solo activo cuando hay un registry en uso (this._validating).
234
+ * @private
235
+ */
236
+ _warnIfNoNamespace(eventName) {
237
+ if (!this._validating) return;
238
+ if (typeof eventName !== 'string' || eventName.includes(':')) return;
239
+ if (this._noNamespaceSeen.has(eventName)) return;
240
+ this._noNamespaceSeen.add(eventName);
241
+
242
+ if (!slice.isProduction || !slice.isProduction()) {
243
+ slice.logger.logWarning(
244
+ 'EventManager',
245
+ `Event "${eventName}" has no namespace — prefer "namespace:${eventName}" (e.g. group it with slice.events.register('app', { ${eventName}: ... })).`
246
+ );
247
+ }
248
+ }
249
+
250
+ // ============================================
251
+ // API PRINCIPAL
252
+ // ============================================
253
+
254
+ /**
255
+ * Suscribirse a un evento
256
+ * @param {string} eventName - Nombre del evento
257
+ * @param {(data?: any) => void} callback - Funcion a ejecutar cuando se emita el evento
258
+ * @param {{ component?: HTMLElement }} [options]
259
+ * @returns {string|null} subscriptionId - ID para desuscribirse
260
+ *
261
+ * @example
262
+ * // Suscripcion global
263
+ * const id = slice.events.subscribe('user:login', (user) => {
264
+ * console.log('Usuario:', user);
265
+ * });
266
+ *
267
+ * // Suscripcion con auto-cleanup
268
+ * slice.events.subscribe('user:login', (user) => {
269
+ * this.actualizar(user);
270
+ * }, { component: this });
271
+ */
272
+ subscribe(eventName, callback, options = {}) {
273
+ if (typeof callback !== 'function') {
274
+ slice.logger.logError('EventManager', 'El callback debe ser una función');
275
+ return null;
276
+ }
277
+
278
+ this._warnIfUndeclared(eventName);
279
+ this._warnIfNoNamespace(eventName);
280
+
281
+ const subscriptionId = `evt_${++this.idCounter}`;
282
+
283
+ // Crear Map para este evento si no existe
284
+ if (!this.subscriptions.has(eventName)) {
285
+ this.subscriptions.set(eventName, new Map());
286
+ }
287
+
288
+ // Guardar la suscripción
289
+ this.subscriptions.get(eventName).set(subscriptionId, {
290
+ callback,
291
+ componentSliceId: options.component?.sliceId || null,
292
+ once: false,
293
+ });
294
+
295
+ // Si hay componente, registrar para auto-cleanup
296
+ if (options.component?.sliceId) {
297
+ this._registerComponentSubscription(options.component.sliceId, eventName, subscriptionId);
298
+ }
299
+
300
+ slice.logger.logInfo('EventManager', `Suscrito a "${eventName}" [${subscriptionId}]`);
301
+
302
+ return subscriptionId;
303
+ }
304
+
305
+ /**
306
+ * Suscribirse a un evento una sola vez
307
+ * @param {string} eventName - Nombre del evento
308
+ * @param {(data?: any) => void} callback - Funcion a ejecutar
309
+ * @param {{ component?: HTMLElement }} [options]
310
+ * @returns {string|null} subscriptionId
311
+ *
312
+ * @example
313
+ * slice.events.subscribeOnce('app:ready', () => {
314
+ * console.log('App lista!');
315
+ * });
316
+ */
317
+ /**
318
+ * Activar registro de emits (lo llama el debugger al abrirse).
319
+ */
320
+ startRecording() {
321
+ this._recording = true;
322
+ this.emitHistory = [];
323
+ this.emitCounts = new Map();
324
+ this.emitters = new Map();
325
+ }
326
+
327
+ /**
328
+ * Desactivar registro de emits (lo llama el debugger al cerrarse).
329
+ */
330
+ stopRecording() {
331
+ this._recording = false;
332
+ }
333
+
334
+ subscribeOnce(eventName, callback, options = {}) {
335
+ if (typeof callback !== 'function') {
336
+ slice.logger.logError('EventManager', 'El callback debe ser una función');
337
+ return null;
338
+ }
339
+
340
+ this._warnIfUndeclared(eventName);
341
+ this._warnIfNoNamespace(eventName);
342
+
343
+ const subscriptionId = `evt_${++this.idCounter}`;
344
+
345
+ if (!this.subscriptions.has(eventName)) {
346
+ this.subscriptions.set(eventName, new Map());
347
+ }
348
+
349
+ this.subscriptions.get(eventName).set(subscriptionId, {
350
+ callback,
351
+ componentSliceId: options.component?.sliceId || null,
352
+ once: true,
353
+ });
354
+
355
+ if (options.component?.sliceId) {
356
+ this._registerComponentSubscription(options.component.sliceId, eventName, subscriptionId);
357
+ }
358
+
359
+ slice.logger.logInfo('EventManager', `Suscrito (once) a "${eventName}" [${subscriptionId}]`);
360
+
361
+ return subscriptionId;
362
+ }
363
+
364
+ /**
365
+ * Desuscribirse de un evento
366
+ * @param {string} eventName - Nombre del evento
367
+ * @param {string} subscriptionId - ID de la suscripcion
368
+ * @returns {boolean} true si se elimino correctamente
369
+ *
370
+ * @example
371
+ * const id = slice.events.subscribe('evento', callback);
372
+ * // Despues...
373
+ * slice.events.unsubscribe('evento', id);
374
+ */
375
+ unsubscribe(eventName, subscriptionId) {
376
+ if (!this.subscriptions.has(eventName)) {
377
+ return false;
378
+ }
379
+
380
+ const removed = this.subscriptions.get(eventName).delete(subscriptionId);
381
+
382
+ // Limpiar Map vacío
383
+ if (this.subscriptions.get(eventName).size === 0) {
384
+ this.subscriptions.delete(eventName);
385
+ }
386
+
387
+ if (removed) {
388
+ slice.logger.logInfo('EventManager', `Desuscrito de "${eventName}" [${subscriptionId}]`);
389
+ }
390
+
391
+ return removed;
392
+ }
393
+
394
+ /**
395
+ * Emitir un evento
396
+ * @param {string} eventName - Nombre del evento
397
+ * @param {any} [data] - Datos a pasar a los callbacks
398
+ * @returns {void}
399
+ *
400
+ * @example
401
+ * slice.events.emit('user:login', { id: 123, name: 'Juan' });
402
+ * slice.events.emit('cart:cleared'); // Sin datos
403
+ */
404
+ emit(eventName, ...args) {
405
+ // Global (unbound) emit — emitter resolved best-effort from the call stack.
406
+ this._emitFrom(null, eventName, args);
407
+ }
408
+
409
+ /**
410
+ * Core emit. `emitter` is the known source ({ sliceId, name }) when emitted via
411
+ * a bound API, or null for global emits (then a dev stack-trace fallback runs).
412
+ * @param {{ sliceId?: string, name?: string }|null} emitter
413
+ * @param {string} eventName
414
+ * @param {any[]} args
415
+ * @private
416
+ */
417
+ _emitFrom(emitter, eventName, args) {
418
+ slice.logger.info('EventManager', `Emitting "${eventName}"`, args[0] ?? null);
419
+
420
+ this._warnIfUndeclared(eventName);
421
+ this._warnIfNoNamespace(eventName);
422
+
423
+ // Solo grabamos el histórico (y la atribución de emisores) si algún debugger está abierto.
424
+ if (this._recording) {
425
+ const source = emitter || this._captureEmitterSource();
426
+ this.emitHistory.push({ eventName, timestamp: Date.now(), emitter: source });
427
+ if (this.emitHistory.length > 500) this.emitHistory.shift();
428
+ this.emitCounts.set(eventName, (this.emitCounts.get(eventName) || 0) + 1);
429
+ this._recordEmitter(eventName, source);
430
+ }
431
+
432
+ if (!this.subscriptions.has(eventName)) {
433
+ return;
434
+ }
435
+
436
+ const toRemove = [];
437
+
438
+ for (const [subscriptionId, subscription] of this.subscriptions.get(eventName)) {
439
+ if (subscription.componentSliceId) {
440
+ if (!slice.controller.activeComponents.has(subscription.componentSliceId)) {
441
+ toRemove.push(subscriptionId);
442
+ continue;
443
+ }
444
+ }
445
+
446
+ try {
447
+ subscription.callback(...args);
448
+ } catch (error) {
449
+ slice.logger.error('EventManager', `Error in callback for "${eventName}" [${subscriptionId}]`, error);
450
+ }
451
+
452
+ // Si es subscribeOnce, marcar para eliminar
453
+ if (subscription.once) {
454
+ toRemove.push(subscriptionId);
455
+ }
456
+ }
457
+
458
+ // Limpiar suscripciones marcadas
459
+ toRemove.forEach((id) => {
460
+ this.subscriptions.get(eventName).delete(id);
461
+ });
462
+
463
+ // Limpiar Map vacío
464
+ if (this.subscriptions.get(eventName).size === 0) {
465
+ this.subscriptions.delete(eventName);
466
+ }
467
+ }
468
+
469
+ /**
470
+ * Aggregate an emitter under an event: who emits, with a count. Powers the
471
+ * "emitted by" tracing view in the debugger.
472
+ * @param {{ sliceId?: string, name?: string, source?: string }|null} emitter
473
+ * @private
474
+ */
475
+ _recordEmitter(eventName, emitter) {
476
+ if (!emitter) return;
477
+ const key = emitter.sliceId || emitter.source || 'unknown';
478
+ if (!this.emitters.has(eventName)) this.emitters.set(eventName, new Map());
479
+ const bucket = this.emitters.get(eventName);
480
+ const existing = bucket.get(key);
481
+ if (existing) {
482
+ existing.count += 1;
483
+ existing.lastTs = Date.now();
484
+ } else {
485
+ bucket.set(key, { ...emitter, count: 1, lastTs: Date.now() });
486
+ }
487
+ }
488
+
489
+ /**
490
+ * Best-effort source of a global emit from the call stack (dev only). Returns
491
+ * { source: 'file.js:line' } or null. Robust: never throws, never runs in prod.
492
+ * @private
493
+ */
494
+ _captureEmitterSource() {
495
+ if (slice.isProduction && slice.isProduction()) return null;
496
+ try {
497
+ const stack = new Error().stack || '';
498
+ for (const line of stack.split('\n')) {
499
+ // Skip the Error header and every frame inside this manager.
500
+ if (line.includes('EventManager.js')) continue;
501
+ const match = line.match(/([^/\\\s(]+\.js):(\d+):\d+/);
502
+ if (match) return { source: `${match[1]}:${match[2]}` };
503
+ }
504
+ } catch {
505
+ /* tracing is best-effort */
506
+ }
507
+ return null;
508
+ }
509
+
510
+ // ============================================
511
+ // BIND - Vinculación a Componente
512
+ // ============================================
513
+
514
+ /**
515
+ * Vincular el EventManager a un componente para auto-cleanup
516
+ * @param {HTMLElement} component - Componente Slice con sliceId
517
+ * @returns {EventManagerBind|null} API vinculada al componente
518
+ *
519
+ * @example
520
+ * class MiComponente extends HTMLElement {
521
+ * async init() {
522
+ * this.events = slice.events.bind(this);
523
+ *
524
+ * this.events.subscribe('user:login', (user) => {
525
+ * this.actualizar(user);
526
+ * });
527
+ * }
528
+ * }
529
+ */
530
+ bind(component) {
531
+ if (!component?.sliceId) {
532
+ slice.logger.logError('EventManager', 'bind() requiere un componente Slice válido con sliceId');
533
+ return null;
534
+ }
535
+
536
+ const self = this;
537
+
538
+ return {
539
+ /**
540
+ * Suscribirse a un evento (auto-cleanup)
541
+ */
542
+ subscribe: (eventName, callback) => {
543
+ return self.subscribe(eventName, callback, { component });
544
+ },
545
+
546
+ /**
547
+ * Suscribirse una sola vez (auto-cleanup)
548
+ */
549
+ subscribeOnce: (eventName, callback) => {
550
+ return self.subscribeOnce(eventName, callback, { component });
551
+ },
552
+
553
+ /**
554
+ * Emitir un evento (atribuido a este componente para el tracing).
555
+ */
556
+ emit: (eventName, data) => {
557
+ self._emitFrom(
558
+ { sliceId: component.sliceId, name: component.constructor?.name || 'Component' },
559
+ eventName,
560
+ [data]
561
+ );
562
+ },
563
+
564
+ /**
565
+ * Declarar eventos en el registry (passthrough a register()).
566
+ */
567
+ register: (namespaceOrCatalog, maybeCatalog) => {
568
+ self.register(namespaceOrCatalog, maybeCatalog);
569
+ return self;
570
+ },
571
+ };
572
+ }
573
+
574
+ // ============================================
575
+ // AUTO-CLEANUP
576
+ // ============================================
577
+
578
+ /**
579
+ * Registrar suscripción para un componente (interno)
580
+ */
581
+ _registerComponentSubscription(sliceId, eventName, subscriptionId) {
582
+ if (!this.componentSubscriptions.has(sliceId)) {
583
+ this.componentSubscriptions.set(sliceId, new Set());
584
+ }
585
+ this.componentSubscriptions.get(sliceId).add({ eventName, subscriptionId });
586
+ }
587
+
588
+ /**
589
+ * Limpiar todas las suscripciones de un componente
590
+ * Se llama automáticamente cuando el componente se destruye
591
+ * @param {string} sliceId - ID del componente
592
+ * @returns {number} Cantidad de suscripciones eliminadas
593
+ */
594
+ cleanupComponent(sliceId) {
595
+ if (!this.componentSubscriptions.has(sliceId)) {
596
+ return 0;
597
+ }
598
+
599
+ const subscriptions = this.componentSubscriptions.get(sliceId);
600
+ let count = 0;
601
+
602
+ for (const { eventName, subscriptionId } of subscriptions) {
603
+ if (this.unsubscribe(eventName, subscriptionId)) {
604
+ count++;
605
+ }
606
+ }
607
+
608
+ this.componentSubscriptions.delete(sliceId);
609
+
610
+ if (count > 0) {
611
+ slice.logger.logInfo('EventManager', `Limpiadas ${count} suscripción(es) de ${sliceId}`);
612
+ }
613
+
614
+ return count;
615
+ }
616
+
617
+ // ============================================
618
+ // UTILIDADES
619
+ // ============================================
620
+
621
+ /**
622
+ * Verificar si hay suscriptores para un evento
623
+ * @param {string} eventName - Nombre del evento
624
+ * @returns {boolean}
625
+ */
626
+ hasSubscribers(eventName) {
627
+ return this.subscriptions.has(eventName) && this.subscriptions.get(eventName).size > 0;
628
+ }
629
+
630
+ /**
631
+ * Obtener cantidad de suscriptores para un evento
632
+ * @param {string} eventName - Nombre del evento
633
+ * @returns {number}
634
+ */
635
+ subscriberCount(eventName) {
636
+ if (!this.subscriptions.has(eventName)) {
637
+ return 0;
638
+ }
639
+ return this.subscriptions.get(eventName).size;
640
+ }
641
+
642
+ /**
643
+ * Limpiar TODAS las suscripciones (usar con cuidado)
644
+ */
645
+ clear() {
646
+ this.subscriptions.clear();
647
+ this.componentSubscriptions.clear();
648
+ this.idCounter = 0;
649
+ slice.logger.logInfo('EventManager', 'Todas las suscripciones eliminadas');
650
+ }
651
+ }