baremetal.js 1.0.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.
package/docs/api.md ADDED
@@ -0,0 +1,109 @@
1
+ # BareMetal.js - Exposed Methods
2
+
3
+ BareMetal exports its core engine as a global configuration object. Here are the documented methods available for developers.
4
+
5
+ ## 1. Core Engine Initialization
6
+
7
+ ### `BareMetal.init(config)`
8
+ Initializes the engine, sets up the Router, and binds global configurations.
9
+
10
+ **Parameters:**
11
+ - `config.debug` *(boolean)*: Enable verbose console logs (default: `false`).
12
+ - `config.keepAliveSameModules` *(boolean)*: If true, modules shared between routes are not destroyed during navigation (default: `true`).
13
+ - `config.autoWrap` *(boolean)*: If true, modules that do not export a `mount` function are automatically wrapped in a dynamic Blob URL to prevent syntax errors (default: `true`).
14
+ - `config.transition` *(object)*: Configuration for the native page transition API.
15
+ - `config.transition.enabled` *(boolean)*: Enables the protected transition module.
16
+ - `config.transition.module` *(string)*: Optional path to a custom transition module (defaults to `/src/transition.js`).
17
+ - `config.transition.simulatedDelay` *(number)*: Optional artificial delay in milliseconds (useful for testing animations locally).
18
+
19
+ ---
20
+
21
+ ## 2. Router Navigation
22
+
23
+ ### `BareMetal.router.reload()`
24
+ Executes a hard reload of the current SPA page by calling `window.location.reload()`. This is useful when you want to force the browser to wipe the entire SPA state and fetch fresh assets.
25
+
26
+ ### `BareMetal.router.handleRoute()`
27
+ Forces the router to evaluate the current `window.location.pathname`, fetch the corresponding HTML, and swap the DOM. Usually called automatically via `popstate` or link clicks.
28
+
29
+ ---
30
+
31
+ ## 3. Reactive State & Event Bus
32
+
33
+ The state manager is accessible via `BareMetal.state` (or its alias `BareMetal.events`).
34
+
35
+ ### `state.init(key, defaultValue)`
36
+ Initializes a piece of state. If the key already exists, it is ignored.
37
+
38
+ ### `state.update(key, value)`
39
+ Updates a state variable and synchronously triggers all callbacks subscribed to this specific key.
40
+
41
+ ### `state.get(key)`
42
+ Returns the current synchronous value of the state key.
43
+
44
+ ### `state.subscribe(key, callback)`
45
+ Subscribes a function to changes on a specific state key.
46
+ - **Returns:** A function that, when called, unsubscribes the callback.
47
+ - *Note:* The callback is invoked immediately upon subscription with the current value.
48
+
49
+ ### `state.publish(event, payload)`
50
+ Fires a standard, one-off Pub/Sub event across the application.
51
+
52
+ ### `state.on(event, callback)`
53
+ Listens for a Pub/Sub event.
54
+ - **Returns:** An unsubscribe function.
55
+
56
+ ---
57
+
58
+ ## 5. Writing Custom Page Transitions
59
+
60
+ You can replace the default progress bar and fade overlay by pointing `config.transition.module` to your own JavaScript file. A custom transition is just a standard BareMetal module that listens to routing events.
61
+
62
+ **Rules for Custom Transitions:**
63
+ 1. **The Protected Node:** You must inject your transition UI into an element with `id="baremetal-transition-root"`. The Router explicitly protects this specific ID from being destroyed when it swaps the `document.body.innerHTML`.
64
+ 2. **Listen to Events:** Use `state.on()` to listen to `ROUTE_START`, `ROUTE_PROGRESS`, `ROUTE_END`, and `ROUTE_ERROR`.
65
+
66
+ **Example:**
67
+ ```javascript
68
+ // custom_transition.js
69
+ export function mount({ state }) {
70
+ // 1. Create or get the protected root node
71
+ let root = document.getElementById('baremetal-transition-root');
72
+ if (!root) {
73
+ root = document.createElement('div');
74
+ root.id = 'baremetal-transition-root';
75
+ document.body.appendChild(root);
76
+ }
77
+
78
+ // 2. Build your custom UI (e.g., a spinner)
79
+ const spinner = document.createElement('div');
80
+ spinner.innerText = "Loading...";
81
+ spinner.style.display = 'none';
82
+ root.appendChild(spinner);
83
+
84
+ const unsubs = [];
85
+
86
+ // 3. Listen to Router events
87
+ unsubs.push(state.on('ROUTE_START', () => {
88
+ spinner.style.display = 'block';
89
+ }));
90
+
91
+ unsubs.push(state.on('ROUTE_PROGRESS', (payload) => {
92
+ // payload.progress goes from 0 to 100
93
+ spinner.innerText = `Loading... ${Math.round(payload.progress)}%`;
94
+ }));
95
+
96
+ unsubs.push(state.on('ROUTE_END', () => {
97
+ spinner.style.display = 'none';
98
+ }));
99
+
100
+ return {
101
+ destroy: () => {
102
+ unsubs.forEach(u => u());
103
+ if (root.parentNode) root.parentNode.removeChild(root);
104
+ }
105
+ };
106
+ }
107
+ ```
108
+
109
+
package/package.json ADDED
@@ -0,0 +1,29 @@
1
+ {
2
+ "name": "baremetal.js",
3
+ "version": "1.0.0",
4
+ "description": "A lightweight, dependency-free Vanilla JavaScript SPA engine prioritizing extreme performance, native browser features, and explicit lifecycle management.",
5
+ "main": "src/index.js",
6
+ "type": "module",
7
+ "scripts": {
8
+ "test": "echo \"No tests written yet.\" && exit 0"
9
+ },
10
+ "repository": {
11
+ "type": "git",
12
+ "url": "git+https://github.com/dkydivyansh/BareMetal.js.git"
13
+ },
14
+ "keywords": [
15
+ "spa",
16
+ "vanilla",
17
+ "framework",
18
+ "router",
19
+ "state-management",
20
+ "baremetal",
21
+ "performance"
22
+ ],
23
+ "author": "Divyansh",
24
+ "license": "GPL-3.0",
25
+ "bugs": {
26
+ "url": "https://github.com/dkydivyansh/BareMetal.js/issues"
27
+ },
28
+ "homepage": "https://BareMetal.dkydivyansh.com"
29
+ }
package/src/index.js ADDED
@@ -0,0 +1,25 @@
1
+ import { stateManager } from './state.js';
2
+ import { Loader, loader } from './loader.js';
3
+ import { Router } from './router.js';
4
+
5
+ const BareMetal = {
6
+ state: stateManager,
7
+ events: stateManager, // Aliased for backward compatibility with Pub/Sub mental model
8
+ loader: loader,
9
+ router: Router,
10
+
11
+ init(config = {}) {
12
+ if (config.debug !== undefined) Loader.setConfig({ debug: config.debug });
13
+ if (config.keepAliveSameModules !== undefined) Loader.setConfig({ keepAliveSameModules: config.keepAliveSameModules });
14
+ if (config.transition !== undefined) Loader.setConfig({ transition: config.transition });
15
+ if (config.autoWrap !== undefined) Loader.setConfig({ autoWrap: config.autoWrap });
16
+ if (config.hoverPrefetch !== undefined) Loader.setConfig({ hoverPrefetch: config.hoverPrefetch });
17
+ if (config.showErrorNotification !== undefined) Loader.setConfig({ showErrorNotification: config.showErrorNotification });
18
+
19
+ // Initialize Router
20
+ Router.init();
21
+ Loader.log("Initialized BareMetal Engine with config:", config);
22
+ }
23
+ };
24
+
25
+ export { BareMetal, loader };
package/src/loader.js ADDED
@@ -0,0 +1,149 @@
1
+ import { stateManager } from './state.js';
2
+
3
+ export const Loader = {
4
+ activeModules: {}, // { key: { path: "./dash.js", module: exportedModule } }
5
+ config: { keepAliveSameModules: true, debug: false, autoWrap: true, hoverPrefetch: false, showErrorNotification: false, transition: { enabled: false, simulatedDelay: 0, module: null } },
6
+
7
+ setConfig(globalConfig) {
8
+ this.config = { ...this.config, ...globalConfig };
9
+ },
10
+
11
+ log(...args) {
12
+ if (this.config.debug) console.log('[BareMetal Loader]', ...args);
13
+ },
14
+
15
+ /**
16
+ * Prepares the transition by destroying obsolete modules and identifying new ones.
17
+ */
18
+ async prepare(newConfig) {
19
+ // Auto-inject transition module if enabled
20
+ if (this.config.transition && this.config.transition.enabled) {
21
+ const transitionPath = this.config.transition.module || '/src/transition.js';
22
+ newConfig['__baremetal_transition'] = transitionPath;
23
+ }
24
+
25
+ const modulesToKeep = {};
26
+ const modulesToDestroy = [];
27
+ const modulesToLoad = {};
28
+
29
+ for (const [key, mod] of Object.entries(this.activeModules)) {
30
+ const isImmortal = key === '__baremetal_transition';
31
+ if ((this.config.keepAliveSameModules || isImmortal) && newConfig[key] === mod.path) {
32
+ // Keep alive
33
+ modulesToKeep[key] = mod;
34
+ this.log(`Keep-Alive: ${key} (${mod.path})`);
35
+ } else {
36
+ // Mark for destruction
37
+ modulesToDestroy.push(mod);
38
+ }
39
+ }
40
+
41
+ // Destroy obsolete modules
42
+ for (const mod of modulesToDestroy) {
43
+ this.log(`Destroying module: ${mod.path}`);
44
+ if (mod.module && typeof mod.module.destroy === 'function') {
45
+ try {
46
+ mod.module.destroy();
47
+ } catch (e) {
48
+ console.error(`Error destroying module ${mod.path}`, e);
49
+ }
50
+ }
51
+ }
52
+
53
+ // Determine what's new
54
+ for (const [key, path] of Object.entries(newConfig)) {
55
+ if (!modulesToKeep[key]) {
56
+ modulesToLoad[key] = path;
57
+ }
58
+ }
59
+
60
+ // Temporarily update active modules to only the kept ones
61
+ this.activeModules = modulesToKeep;
62
+
63
+ return modulesToLoad;
64
+ },
65
+
66
+ /**
67
+ * Loads the prepared modules. Called by Router after DOM swap.
68
+ */
69
+ async loadPrepared(modulesToLoad) {
70
+ const total = Object.keys(modulesToLoad).length;
71
+ let loaded = 0;
72
+
73
+ const loadPromises = Object.entries(modulesToLoad).map(async ([key, path]) => {
74
+ this.log(`Importing module: ${path}`);
75
+ try {
76
+ const resolvedPath = new URL(path, document.baseURI).href;
77
+ const noCachePath = `${resolvedPath}?t=${Date.now()}`;
78
+
79
+ let module;
80
+
81
+ if (this.config.autoWrap) {
82
+ // Fetch source to check if it needs auto-wrapping
83
+ const response = await fetch(noCachePath);
84
+ if (!response.ok) throw new Error(`Failed to fetch ${noCachePath}`);
85
+ const sourceText = await response.text();
86
+
87
+ const hasMount = /export\s+(function|const|let|var)\s+mount\b/.test(sourceText) || /export\s+\{.*?\bmount\b.*?\}/.test(sourceText);
88
+
89
+ if (!hasMount) {
90
+ console.warn(`[BareMetal] WARNING: Module ${path} does not explicitly export a mount() function. Auto-wrapping it...`);
91
+
92
+ const wrappedSource = `
93
+ export async function mount(context) {
94
+ const { state } = context;
95
+ ${sourceText}
96
+ }
97
+ `;
98
+
99
+ const blob = new Blob([wrappedSource], { type: 'application/javascript' });
100
+ const blobUrl = URL.createObjectURL(blob);
101
+ module = await import(blobUrl);
102
+ URL.revokeObjectURL(blobUrl);
103
+ } else {
104
+ module = await import(noCachePath);
105
+ }
106
+ } else {
107
+ // Auto-wrapping disabled, load natively
108
+ module = await import(noCachePath);
109
+ }
110
+
111
+ if (typeof module.mount === 'function') {
112
+ this.log(`Mounting module: ${path}`);
113
+ const instance = await module.mount({ state: stateManager });
114
+
115
+ this.activeModules[key] = {
116
+ path: path,
117
+ module: instance ? { destroy: instance.destroy } : module
118
+ };
119
+ } else {
120
+ console.error(`[BareMetal] Module ${path} failed to provide a mount function even after wrapping.`);
121
+ }
122
+ } catch (err) {
123
+ console.error(`Failed to load module: ${path}`, err);
124
+ } finally {
125
+ loaded++;
126
+ if (total > 0) {
127
+ const progress = 50 + (loaded / total) * 50;
128
+ stateManager.publish('ROUTE_PROGRESS', { url: window.location.pathname, progress });
129
+ }
130
+ }
131
+ });
132
+
133
+ await Promise.all(loadPromises);
134
+ stateManager.publish('ROUTE_END', { url: window.location.pathname });
135
+ },
136
+
137
+ /**
138
+ * Legacy standalone load (used on first page load directly)
139
+ */
140
+ async load(config) {
141
+ const modulesToLoad = await this.prepare(config);
142
+ await this.loadPrepared(modulesToLoad);
143
+ }
144
+ };
145
+
146
+ // The user-facing API
147
+ export function loader(config) {
148
+ return Loader.load(config);
149
+ }
package/src/router.js ADDED
@@ -0,0 +1,240 @@
1
+ import { Loader } from './loader.js';
2
+ import { stateManager } from './state.js';
3
+
4
+ export const Router = {
5
+ htmlCache: {},
6
+ scrollMemory: {},
7
+ historyStack: [],
8
+
9
+ init() {
10
+ if ('scrollRestoration' in history) {
11
+ history.scrollRestoration = 'manual';
12
+ }
13
+ window.addEventListener('popstate', this.handleRoute.bind(this));
14
+
15
+ // Intercept all link clicks
16
+ document.body.addEventListener('click', e => {
17
+ // Find closest anchor tag
18
+ const anchor = e.target.closest('a');
19
+ if (!anchor) return;
20
+
21
+ // Ignore external domains, target="_blank", noreferrer, or downloads
22
+ if (
23
+ anchor.origin !== window.location.origin ||
24
+ anchor.target === '_blank' ||
25
+ (anchor.rel && anchor.rel.includes('noreferrer')) ||
26
+ anchor.hasAttribute('download')
27
+ ) {
28
+ return; // Let browser handle it naturally
29
+ }
30
+
31
+ // Intercept same-origin internal links
32
+ e.preventDefault();
33
+
34
+ // Save current state before navigating away
35
+ this.historyStack.push(window.location.pathname);
36
+ this.scrollMemory[window.location.pathname] = window.scrollY;
37
+
38
+ history.pushState(null, '', anchor.href);
39
+ this.handleRoute();
40
+ });
41
+
42
+ // Hover Pre-fetching
43
+ document.body.addEventListener('mouseover', e => {
44
+ if (!Loader.config.hoverPrefetch) return;
45
+ const anchor = e.target.closest('a');
46
+ if (!anchor) return;
47
+ if (
48
+ anchor.origin === window.location.origin &&
49
+ anchor.target !== '_blank' &&
50
+ !anchor.hasAttribute('download') &&
51
+ !this.htmlCache[anchor.href]
52
+ ) {
53
+ this.htmlCache[anchor.href] = 'fetching'; // Prevent duplicate fetches
54
+ fetch(anchor.href)
55
+ .then(res => {
56
+ if (res.ok) return res.text();
57
+ throw new Error('Failed to prefetch');
58
+ })
59
+ .then(html => this.htmlCache[anchor.href] = html)
60
+ .catch(() => delete this.htmlCache[anchor.href]);
61
+ }
62
+ });
63
+ },
64
+
65
+ back() {
66
+ // Custom programmatic back button
67
+ if (this.historyStack.length > 0) {
68
+ const prevUrl = this.historyStack.pop();
69
+ history.pushState(null, '', prevUrl);
70
+ this.handleRoute();
71
+ } else {
72
+ history.back(); // Fallback to browser's native back
73
+ }
74
+ },
75
+
76
+ reload() {
77
+ window.location.reload();
78
+ },
79
+
80
+ async handleRoute() {
81
+ const url = window.location.pathname;
82
+ try {
83
+ Loader.log(`Navigating to ${url}`);
84
+ stateManager.publish('ROUTE_START', { url });
85
+
86
+ if (Loader.config.transition && Loader.config.transition.simulatedDelay) {
87
+ stateManager.publish('ROUTE_PROGRESS', { url, progress: 10 });
88
+ await new Promise(r => setTimeout(r, Loader.config.transition.simulatedDelay / 2));
89
+ stateManager.publish('ROUTE_PROGRESS', { url, progress: 30 });
90
+ await new Promise(r => setTimeout(r, Loader.config.transition.simulatedDelay / 2));
91
+ }
92
+
93
+ let htmlText;
94
+ const fullUrl = new URL(url, document.baseURI).href;
95
+
96
+ if (Loader.config.hoverPrefetch && this.htmlCache[fullUrl] && this.htmlCache[fullUrl] !== 'fetching') {
97
+ htmlText = this.htmlCache[fullUrl];
98
+ Loader.log(`Used pre-fetched cache for ${url}`);
99
+ } else {
100
+ const response = await fetch(url);
101
+ if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
102
+ htmlText = await response.text();
103
+ }
104
+
105
+ stateManager.publish('ROUTE_PROGRESS', { url, progress: 50 });
106
+
107
+ const parser = new DOMParser();
108
+ const doc = parser.parseFromString(htmlText, 'text/html');
109
+
110
+ // 1. Extract Config
111
+ let config = null;
112
+ const scriptTags = doc.querySelectorAll('script');
113
+ for (const script of scriptTags) {
114
+ if (script.textContent.includes('loader(')) {
115
+ // Extract the JSON-like object from loader({...})
116
+ const match = script.textContent.match(/loader\s*\(\s*(\{[\s\S]*?\})\s*\)/);
117
+ if (match && match[1]) {
118
+ try {
119
+ // Using Function to safely parse object string that might not be strict JSON
120
+ config = new Function('return ' + match[1])();
121
+ } catch (e) {
122
+ console.error("Failed to parse loader config in new page", e);
123
+ }
124
+ }
125
+ }
126
+ }
127
+
128
+ // If no BareMetal loader config is found, fallback to standard navigation
129
+ if (!config) {
130
+ Loader.log(`No BareMetal config found on ${url}. Falling back to native navigation.`);
131
+ window.location.assign(url);
132
+ return;
133
+ }
134
+
135
+ // 2. Prepare transition: identify kept vs destroyed modules
136
+ const modulesToLoad = await Loader.prepare(config);
137
+
138
+ // 3. Swap DOM and Head
139
+ document.title = doc.title;
140
+
141
+ // Swap Head Styles (prevent CSS leak)
142
+ const oldStyles = document.head.querySelectorAll('link[data-baremetal="style"], style[data-baremetal="style"]');
143
+ oldStyles.forEach(s => s.remove());
144
+ const newStyles = doc.head.querySelectorAll('link[data-baremetal="style"], style[data-baremetal="style"]');
145
+ newStyles.forEach(s => document.head.appendChild(s.cloneNode(true)));
146
+
147
+ // User Protected Elements (data-baremetal-preserve)
148
+ const preservedNodes = [];
149
+ const persistentElements = document.querySelectorAll('[data-baremetal-preserve]');
150
+
151
+ persistentElements.forEach(el => {
152
+ if (!el.id) return;
153
+ if (doc.getElementById(el.id)) {
154
+ // Synchronously detach the live node
155
+ const placeholder = document.createElement('div');
156
+ el.parentNode.replaceChild(placeholder, el);
157
+ preservedNodes.push(el);
158
+ }
159
+ });
160
+
161
+ // Engine Protected Elements (Immortal)
162
+ const transitionRoot = document.getElementById('baremetal-transition-root');
163
+ if (transitionRoot) {
164
+ transitionRoot.parentNode.removeChild(transitionRoot);
165
+ }
166
+
167
+ // The actual synchronous DOM swap and restoration
168
+ const executeDOMSwap = () => {
169
+ document.body.innerHTML = doc.body.innerHTML;
170
+
171
+ // Restore User Protected Elements into their exact new positions
172
+ preservedNodes.forEach(el => {
173
+ const newEl = document.getElementById(el.id);
174
+ if (newEl) {
175
+ // Sync attributes from the new HTML node so classes/styles update
176
+ Array.from(el.attributes).forEach(attr => {
177
+ if (attr.name !== 'id' && attr.name !== 'data-baremetal-preserve') el.removeAttribute(attr.name);
178
+ });
179
+ Array.from(newEl.attributes).forEach(attr => {
180
+ if (attr.name !== 'id') el.setAttribute(attr.name, attr.value);
181
+ });
182
+
183
+ newEl.parentNode.replaceChild(el, newEl);
184
+ }
185
+ });
186
+
187
+ if (transitionRoot) {
188
+ document.body.appendChild(transitionRoot);
189
+ }
190
+
191
+ // Notify keep-alive modules that the DOM has been swapped so they can re-bind UI elements
192
+ stateManager.publish('DOM_SWAPPED', null);
193
+ };
194
+
195
+ // Execute DOM swap and restore scroll synchronously
196
+ executeDOMSwap();
197
+ window.scrollTo(0, this.scrollMemory[url] || 0);
198
+
199
+ // 4. Mount new modules (this emits ROUTE_END)
200
+ await Loader.loadPrepared(modulesToLoad);
201
+
202
+ } catch (err) {
203
+ console.error("Routing error:", err);
204
+ stateManager.publish('ROUTE_ERROR', { url, error: err.message });
205
+
206
+ if (Loader.config.showErrorNotification) {
207
+ // Revert URL bar since we are aborting
208
+ if (this.historyStack.length > 0) {
209
+ const prev = this.historyStack.pop();
210
+ history.replaceState(null, '', prev);
211
+ }
212
+
213
+ // Show floating notification
214
+ const notif = document.createElement('div');
215
+ notif.style.position = 'fixed';
216
+ notif.style.bottom = '20px';
217
+ notif.style.left = '20px';
218
+ notif.style.background = '#e74c3c';
219
+ notif.style.color = 'white';
220
+ notif.style.padding = '15px 20px';
221
+ notif.style.borderRadius = '8px';
222
+ notif.style.boxShadow = '0 4px 12px rgba(0,0,0,0.15)';
223
+ notif.style.zIndex = '999999';
224
+ notif.style.fontFamily = 'sans-serif';
225
+ notif.style.transition = 'opacity 0.3s ease';
226
+ notif.innerHTML = `<strong>Navigation Failed:</strong> ${err.message}`;
227
+
228
+ document.body.appendChild(notif);
229
+
230
+ setTimeout(() => {
231
+ notif.style.opacity = '0';
232
+ setTimeout(() => notif.remove(), 300);
233
+ }, 4000);
234
+ } else {
235
+ // Formal redirect to let server handle the error
236
+ window.location.assign(url);
237
+ }
238
+ }
239
+ }
240
+ };
package/src/state.js ADDED
@@ -0,0 +1,83 @@
1
+ class StateManager {
2
+ constructor() {
3
+ this.state = {};
4
+ this.listeners = {};
5
+ this.eventBus = {};
6
+ }
7
+
8
+ // --- Reactive State ---
9
+
10
+ /**
11
+ * Initialize a state property with a default value
12
+ */
13
+ init(key, defaultValue) {
14
+ if (this.state[key] === undefined) {
15
+ this.state[key] = defaultValue;
16
+ }
17
+ }
18
+
19
+ /**
20
+ * Subscribe to changes on a specific state key
21
+ */
22
+ subscribe(key, callback) {
23
+ if (!this.listeners[key]) {
24
+ this.listeners[key] = [];
25
+ }
26
+ this.listeners[key].push(callback);
27
+
28
+ // Call immediately with current value if exists
29
+ if (this.state[key] !== undefined) {
30
+ callback(this.state[key]);
31
+ }
32
+
33
+ return () => this.unsubscribe(key, callback);
34
+ }
35
+
36
+ /**
37
+ * Unsubscribe from a state key
38
+ */
39
+ unsubscribe(key, callback) {
40
+ if (!this.listeners[key]) return;
41
+ this.listeners[key] = this.listeners[key].filter(cb => cb !== callback);
42
+ }
43
+
44
+ /**
45
+ * Update a state value and trigger listeners
46
+ */
47
+ update(key, value) {
48
+ this.state[key] = value;
49
+ if (this.listeners[key]) {
50
+ this.listeners[key].forEach(callback => callback(value));
51
+ }
52
+ }
53
+
54
+ /**
55
+ * Get current state value
56
+ */
57
+ get(key) {
58
+ return this.state[key];
59
+ }
60
+
61
+ // --- Event Bus (Pub/Sub) for one-off actions ---
62
+
63
+ on(event, callback) {
64
+ if (!this.eventBus[event]) {
65
+ this.eventBus[event] = [];
66
+ }
67
+ this.eventBus[event].push(callback);
68
+ return () => this.off(event, callback);
69
+ }
70
+
71
+ off(event, callback) {
72
+ if (!this.eventBus[event]) return;
73
+ this.eventBus[event] = this.eventBus[event].filter(cb => cb !== callback);
74
+ }
75
+
76
+ publish(event, data) {
77
+ if (this.eventBus[event]) {
78
+ this.eventBus[event].forEach(callback => callback(data));
79
+ }
80
+ }
81
+ }
82
+
83
+ export const stateManager = new StateManager();