slicejs-web-framework 3.4.3 → 3.5.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.
Files changed (27) hide show
  1. package/.opencode/opencode.json +14 -14
  2. package/Slice/Components/Structural/ContextManager/ContextManager.js +422 -422
  3. package/Slice/Components/Structural/ContextManager/ContextManagerDebugger.js +468 -468
  4. package/Slice/Components/Structural/Controller/Controller.js +1406 -1198
  5. package/Slice/Components/Structural/Debugger/Debugger.js +1503 -1503
  6. package/Slice/Components/Structural/EventManager/EventManager.js +651 -366
  7. package/Slice/Components/Structural/EventManager/EventManagerDebugger.js +368 -30
  8. package/Slice/Components/Structural/LeakInspector/LeakInspector.js +296 -0
  9. package/Slice/Components/Structural/Logger/Logger.js +225 -225
  10. package/Slice/Components/Structural/Router/Router.js +775 -775
  11. package/Slice/Components/Structural/StylesManager/StylesManager.js +82 -82
  12. package/Slice/Components/Structural/StylesManager/ThemeManager/ThemeManager.js +106 -106
  13. package/Slice/Slice.js +663 -627
  14. package/Slice/tests/build-singleton.test.js +244 -244
  15. package/Slice/tests/bundle-v2-runtime-contract.test.js +738 -738
  16. package/Slice/tests/context-patch-use.test.js +95 -95
  17. package/Slice/tests/event-emitters.test.js +79 -0
  18. package/Slice/tests/event-registry.test.js +141 -0
  19. package/Slice/tests/fixtures/real-runtime-loader.mjs +21 -21
  20. package/Slice/tests/leak-orphans.test.js +131 -0
  21. package/Slice/tests/public-env-typed-accessors.test.js +66 -66
  22. package/api/index.js +308 -281
  23. package/api/utils/publicEnvResolver.js +123 -123
  24. package/package.json +44 -44
  25. package/sliceConfig.schema.json +10 -1
  26. package/src/sliceConfig.json +5 -1
  27. package/types/index.d.ts +12 -0
@@ -1,366 +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
- // Flag: solo grabamos cuando algún debugger UI está visible
33
- this._recording = false;
34
-
35
- // Contador para IDs únicos
36
- this.idCounter = 0;
37
- }
38
-
39
- init() {
40
- return true;
41
- }
42
-
43
- // ============================================
44
- // API PRINCIPAL
45
- // ============================================
46
-
47
- /**
48
- * Suscribirse a un evento
49
- * @param {string} eventName - Nombre del evento
50
- * @param {(data?: any) => void} callback - Funcion a ejecutar cuando se emita el evento
51
- * @param {{ component?: HTMLElement }} [options]
52
- * @returns {string|null} subscriptionId - ID para desuscribirse
53
- *
54
- * @example
55
- * // Suscripcion global
56
- * const id = slice.events.subscribe('user:login', (user) => {
57
- * console.log('Usuario:', user);
58
- * });
59
- *
60
- * // Suscripcion con auto-cleanup
61
- * slice.events.subscribe('user:login', (user) => {
62
- * this.actualizar(user);
63
- * }, { component: this });
64
- */
65
- subscribe(eventName, callback, options = {}) {
66
- if (typeof callback !== 'function') {
67
- slice.logger.logError('EventManager', 'El callback debe ser una función');
68
- return null;
69
- }
70
-
71
- const subscriptionId = `evt_${++this.idCounter}`;
72
-
73
- // Crear Map para este evento si no existe
74
- if (!this.subscriptions.has(eventName)) {
75
- this.subscriptions.set(eventName, new Map());
76
- }
77
-
78
- // Guardar la suscripción
79
- this.subscriptions.get(eventName).set(subscriptionId, {
80
- callback,
81
- componentSliceId: options.component?.sliceId || null,
82
- once: false,
83
- });
84
-
85
- // Si hay componente, registrar para auto-cleanup
86
- if (options.component?.sliceId) {
87
- this._registerComponentSubscription(options.component.sliceId, eventName, subscriptionId);
88
- }
89
-
90
- slice.logger.logInfo('EventManager', `Suscrito a "${eventName}" [${subscriptionId}]`);
91
-
92
- return subscriptionId;
93
- }
94
-
95
- /**
96
- * Suscribirse a un evento una sola vez
97
- * @param {string} eventName - Nombre del evento
98
- * @param {(data?: any) => void} callback - Funcion a ejecutar
99
- * @param {{ component?: HTMLElement }} [options]
100
- * @returns {string|null} subscriptionId
101
- *
102
- * @example
103
- * slice.events.subscribeOnce('app:ready', () => {
104
- * console.log('App lista!');
105
- * });
106
- */
107
- /**
108
- * Activar registro de emits (lo llama el debugger al abrirse).
109
- */
110
- startRecording() {
111
- this._recording = true;
112
- this.emitHistory = [];
113
- this.emitCounts = new Map();
114
- }
115
-
116
- /**
117
- * Desactivar registro de emits (lo llama el debugger al cerrarse).
118
- */
119
- stopRecording() {
120
- this._recording = false;
121
- }
122
-
123
- subscribeOnce(eventName, callback, options = {}) {
124
- if (typeof callback !== 'function') {
125
- slice.logger.logError('EventManager', 'El callback debe ser una función');
126
- return null;
127
- }
128
-
129
- const subscriptionId = `evt_${++this.idCounter}`;
130
-
131
- if (!this.subscriptions.has(eventName)) {
132
- this.subscriptions.set(eventName, new Map());
133
- }
134
-
135
- this.subscriptions.get(eventName).set(subscriptionId, {
136
- callback,
137
- componentSliceId: options.component?.sliceId || null,
138
- once: true,
139
- });
140
-
141
- if (options.component?.sliceId) {
142
- this._registerComponentSubscription(options.component.sliceId, eventName, subscriptionId);
143
- }
144
-
145
- slice.logger.logInfo('EventManager', `Suscrito (once) a "${eventName}" [${subscriptionId}]`);
146
-
147
- return subscriptionId;
148
- }
149
-
150
- /**
151
- * Desuscribirse de un evento
152
- * @param {string} eventName - Nombre del evento
153
- * @param {string} subscriptionId - ID de la suscripcion
154
- * @returns {boolean} true si se elimino correctamente
155
- *
156
- * @example
157
- * const id = slice.events.subscribe('evento', callback);
158
- * // Despues...
159
- * slice.events.unsubscribe('evento', id);
160
- */
161
- unsubscribe(eventName, subscriptionId) {
162
- if (!this.subscriptions.has(eventName)) {
163
- return false;
164
- }
165
-
166
- const removed = this.subscriptions.get(eventName).delete(subscriptionId);
167
-
168
- // Limpiar Map vacío
169
- if (this.subscriptions.get(eventName).size === 0) {
170
- this.subscriptions.delete(eventName);
171
- }
172
-
173
- if (removed) {
174
- slice.logger.logInfo('EventManager', `Desuscrito de "${eventName}" [${subscriptionId}]`);
175
- }
176
-
177
- return removed;
178
- }
179
-
180
- /**
181
- * Emitir un evento
182
- * @param {string} eventName - Nombre del evento
183
- * @param {any} [data] - Datos a pasar a los callbacks
184
- * @returns {void}
185
- *
186
- * @example
187
- * slice.events.emit('user:login', { id: 123, name: 'Juan' });
188
- * slice.events.emit('cart:cleared'); // Sin datos
189
- */
190
- emit(eventName, ...args) {
191
- slice.logger.info('EventManager', `Emitting "${eventName}"`, args[0] ?? null);
192
-
193
- // Solo grabamos el histórico si algún debugger está abierto
194
- if (this._recording) {
195
- this.emitHistory.push({ eventName, timestamp: Date.now() });
196
- if (this.emitHistory.length > 500) this.emitHistory.shift();
197
- this.emitCounts.set(eventName, (this.emitCounts.get(eventName) || 0) + 1);
198
- }
199
-
200
- if (!this.subscriptions.has(eventName)) {
201
- return;
202
- }
203
-
204
- const toRemove = [];
205
-
206
- for (const [subscriptionId, subscription] of this.subscriptions.get(eventName)) {
207
- if (subscription.componentSliceId) {
208
- if (!slice.controller.activeComponents.has(subscription.componentSliceId)) {
209
- toRemove.push(subscriptionId);
210
- continue;
211
- }
212
- }
213
-
214
- try {
215
- subscription.callback(...args);
216
- } catch (error) {
217
- slice.logger.error('EventManager', `Error in callback for "${eventName}" [${subscriptionId}]`, error);
218
- }
219
-
220
- // Si es subscribeOnce, marcar para eliminar
221
- if (subscription.once) {
222
- toRemove.push(subscriptionId);
223
- }
224
- }
225
-
226
- // Limpiar suscripciones marcadas
227
- toRemove.forEach((id) => {
228
- this.subscriptions.get(eventName).delete(id);
229
- });
230
-
231
- // Limpiar Map vacío
232
- if (this.subscriptions.get(eventName).size === 0) {
233
- this.subscriptions.delete(eventName);
234
- }
235
- }
236
-
237
- // ============================================
238
- // BIND - Vinculación a Componente
239
- // ============================================
240
-
241
- /**
242
- * Vincular el EventManager a un componente para auto-cleanup
243
- * @param {HTMLElement} component - Componente Slice con sliceId
244
- * @returns {EventManagerBind|null} API vinculada al componente
245
- *
246
- * @example
247
- * class MiComponente extends HTMLElement {
248
- * async init() {
249
- * this.events = slice.events.bind(this);
250
- *
251
- * this.events.subscribe('user:login', (user) => {
252
- * this.actualizar(user);
253
- * });
254
- * }
255
- * }
256
- */
257
- bind(component) {
258
- if (!component?.sliceId) {
259
- slice.logger.logError('EventManager', 'bind() requiere un componente Slice válido con sliceId');
260
- return null;
261
- }
262
-
263
- const self = this;
264
-
265
- return {
266
- /**
267
- * Suscribirse a un evento (auto-cleanup)
268
- */
269
- subscribe: (eventName, callback) => {
270
- return self.subscribe(eventName, callback, { component });
271
- },
272
-
273
- /**
274
- * Suscribirse una sola vez (auto-cleanup)
275
- */
276
- subscribeOnce: (eventName, callback) => {
277
- return self.subscribeOnce(eventName, callback, { component });
278
- },
279
-
280
- /**
281
- * Emitir un evento
282
- */
283
- emit: (eventName, data) => {
284
- self.emit(eventName, data);
285
- },
286
- };
287
- }
288
-
289
- // ============================================
290
- // AUTO-CLEANUP
291
- // ============================================
292
-
293
- /**
294
- * Registrar suscripción para un componente (interno)
295
- */
296
- _registerComponentSubscription(sliceId, eventName, subscriptionId) {
297
- if (!this.componentSubscriptions.has(sliceId)) {
298
- this.componentSubscriptions.set(sliceId, new Set());
299
- }
300
- this.componentSubscriptions.get(sliceId).add({ eventName, subscriptionId });
301
- }
302
-
303
- /**
304
- * Limpiar todas las suscripciones de un componente
305
- * Se llama automáticamente cuando el componente se destruye
306
- * @param {string} sliceId - ID del componente
307
- * @returns {number} Cantidad de suscripciones eliminadas
308
- */
309
- cleanupComponent(sliceId) {
310
- if (!this.componentSubscriptions.has(sliceId)) {
311
- return 0;
312
- }
313
-
314
- const subscriptions = this.componentSubscriptions.get(sliceId);
315
- let count = 0;
316
-
317
- for (const { eventName, subscriptionId } of subscriptions) {
318
- if (this.unsubscribe(eventName, subscriptionId)) {
319
- count++;
320
- }
321
- }
322
-
323
- this.componentSubscriptions.delete(sliceId);
324
-
325
- if (count > 0) {
326
- slice.logger.logInfo('EventManager', `Limpiadas ${count} suscripción(es) de ${sliceId}`);
327
- }
328
-
329
- return count;
330
- }
331
-
332
- // ============================================
333
- // UTILIDADES
334
- // ============================================
335
-
336
- /**
337
- * Verificar si hay suscriptores para un evento
338
- * @param {string} eventName - Nombre del evento
339
- * @returns {boolean}
340
- */
341
- hasSubscribers(eventName) {
342
- return this.subscriptions.has(eventName) && this.subscriptions.get(eventName).size > 0;
343
- }
344
-
345
- /**
346
- * Obtener cantidad de suscriptores para un evento
347
- * @param {string} eventName - Nombre del evento
348
- * @returns {number}
349
- */
350
- subscriberCount(eventName) {
351
- if (!this.subscriptions.has(eventName)) {
352
- return 0;
353
- }
354
- return this.subscriptions.get(eventName).size;
355
- }
356
-
357
- /**
358
- * Limpiar TODAS las suscripciones (usar con cuidado)
359
- */
360
- clear() {
361
- this.subscriptions.clear();
362
- this.componentSubscriptions.clear();
363
- this.idCounter = 0;
364
- slice.logger.logInfo('EventManager', 'Todas las suscripciones eliminadas');
365
- }
366
- }
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
+ }