slicejs-web-framework 4.0.2 → 4.1.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.
- package/LICENSE +21 -21
- package/README.md +97 -97
- package/Slice/Components/Structural/ContextManager/ContextManager.js +422 -422
- package/Slice/Components/Structural/ContextManager/ContextManagerDebugger.js +470 -470
- package/Slice/Components/Structural/Controller/Controller.js +1406 -1406
- package/Slice/Components/Structural/Controller/allowedValuesValidation.js +52 -52
- package/Slice/Components/Structural/Debugger/Debugger.html +72 -72
- package/Slice/Components/Structural/Debugger/Debugger.js +1505 -1505
- package/Slice/Components/Structural/EventManager/EventManager.js +651 -651
- package/Slice/Components/Structural/EventManager/EventManagerDebugger.js +985 -985
- package/Slice/Components/Structural/LeakInspector/LeakInspector.js +296 -296
- package/Slice/Components/Structural/Logger/Log.js +10 -10
- package/Slice/Components/Structural/Logger/LogViewer/LogViewer.js +662 -662
- package/Slice/Components/Structural/Logger/Logger.js +225 -225
- package/Slice/Components/Structural/Router/Router.js +820 -810
- package/Slice/Components/Structural/Router/applyRouteMetadata.js +39 -0
- package/Slice/Components/Structural/StylesManager/StylesManager.js +82 -82
- package/Slice/Components/Structural/StylesManager/ThemeManager/ThemeManager.js +175 -175
- package/Slice/Slice.js +711 -710
- package/api/framework/devDepsOptimizer.js +335 -335
- package/api/framework/express.js +3 -3
- package/api/framework/precompressedStatic.js +102 -93
- package/api/framework/publicEnvResolver.js +123 -123
- package/api/framework/securityMiddleware.js +197 -197
- package/api/framework/seo.js +387 -0
- package/api/framework/server.js +777 -410
- package/api/index.js +14 -4
- package/package.json +6 -2
- package/sliceConfig.schema.json +122 -122
- package/src/App/index.html +69 -69
- package/src/App/index.js +20 -20
- package/src/App/style.css +11 -11
- package/src/Components/AppComponents/AboutSection/AboutSection.css +9 -9
- package/src/Components/AppComponents/AboutSection/AboutSection.html +8 -8
- package/src/Components/AppComponents/AboutSection/AboutSection.js +12 -12
- package/src/Components/AppComponents/AppShell/AppShell.css +10 -10
- package/src/Components/AppComponents/AppShell/AppShell.html +4 -4
- package/src/Components/AppComponents/AppShell/AppShell.js +36 -36
- package/src/Components/AppComponents/HomeSection/HomeSection.css +20 -20
- package/src/Components/AppComponents/HomeSection/HomeSection.html +10 -10
- package/src/Components/AppComponents/HomeSection/HomeSection.js +19 -19
- package/src/Components/components.js +8 -8
- package/src/public/Styles/sliceStyles.css +34 -34
- package/src/public/Themes/Dark.css +42 -42
- package/src/public/Themes/Light.css +31 -31
- package/src/public/Themes/Slice.css +47 -47
- package/src/public/manifest.json +15 -15
- package/src/public/robots.txt +4 -4
- package/src/public/sitemap.xml +8 -8
- package/src/routes.js +9 -9
- package/src/sliceConfig.json +72 -72
- package/types/index.d.ts +219 -219
- package/.opencode/opencode.json +0 -15
- package/Slice/tests/build-bundled-component-without-category.test.js +0 -103
- package/Slice/tests/build-js-only-visual-components.test.js +0 -144
- package/Slice/tests/build-singleton.test.js +0 -244
- package/Slice/tests/bundle-v2-runtime-contract.test.js +0 -747
- package/Slice/tests/context-patch-use.test.js +0 -95
- package/Slice/tests/destroy-cascade.test.js +0 -215
- package/Slice/tests/event-emitters.test.js +0 -79
- package/Slice/tests/event-registry.test.js +0 -141
- package/Slice/tests/fixtures/real-runtime-loader.mjs +0 -21
- package/Slice/tests/leak-orphans.test.js +0 -131
- package/Slice/tests/props-allowed-values-validation.test.js +0 -119
- package/Slice/tests/public-env-runtime-accessors.test.js +0 -44
- package/Slice/tests/public-env-typed-accessors.test.js +0 -66
- package/Slice/tests/router-loading-finally.test.js +0 -68
- package/api/tests/dev-deps-optimizer.test.js +0 -137
- package/api/tests/dev-server-external.test.js +0 -56
- package/api/tests/precompressed-static.test.js +0 -119
- package/api/tests/public-env-resolver.test.js +0 -193
- package/tests/router-build-failure.test.js +0 -125
- package/tests/router-emit-ordering.test.js +0 -63
|
@@ -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
|
+
}
|