web-agent-bridge 2.3.1 → 2.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 (53) hide show
  1. package/README.ar.md +524 -31
  2. package/README.md +592 -47
  3. package/bin/agent-runner.js +10 -1
  4. package/package.json +1 -1
  5. package/public/agent-workspace.html +347 -0
  6. package/public/browser.html +484 -0
  7. package/public/css/agent-workspace.css +1713 -0
  8. package/public/index.html +94 -0
  9. package/public/js/agent-workspace.js +1740 -0
  10. package/sdk/index.d.ts +253 -0
  11. package/sdk/index.js +360 -1
  12. package/sdk/package.json +1 -1
  13. package/server/config/secrets.js +13 -5
  14. package/server/control-plane/index.js +301 -0
  15. package/server/data-plane/index.js +354 -0
  16. package/server/index.js +185 -4
  17. package/server/llm/index.js +404 -0
  18. package/server/middleware/adminAuth.js +6 -1
  19. package/server/middleware/auth.js +11 -2
  20. package/server/middleware/rateLimits.js +78 -2
  21. package/server/migrations/003_ads_integer_cents.sql +33 -0
  22. package/server/models/db.js +126 -25
  23. package/server/observability/index.js +394 -0
  24. package/server/protocol/capabilities.js +223 -0
  25. package/server/protocol/index.js +243 -0
  26. package/server/protocol/schema.js +584 -0
  27. package/server/registry/index.js +326 -0
  28. package/server/routes/admin.js +16 -2
  29. package/server/routes/ads.js +130 -0
  30. package/server/routes/agent-workspace.js +378 -0
  31. package/server/routes/api.js +21 -2
  32. package/server/routes/auth.js +26 -6
  33. package/server/routes/runtime.js +725 -0
  34. package/server/routes/sovereign.js +78 -0
  35. package/server/routes/universal.js +177 -0
  36. package/server/routes/wab-api.js +20 -5
  37. package/server/runtime/event-bus.js +210 -0
  38. package/server/runtime/index.js +233 -0
  39. package/server/runtime/sandbox.js +266 -0
  40. package/server/runtime/scheduler.js +395 -0
  41. package/server/runtime/state-manager.js +188 -0
  42. package/server/security/index.js +355 -0
  43. package/server/services/agent-chat.js +506 -0
  44. package/server/services/agent-symphony.js +6 -0
  45. package/server/services/agent-tasks.js +1807 -0
  46. package/server/services/fairness-engine.js +409 -0
  47. package/server/services/plugins.js +27 -3
  48. package/server/services/price-intelligence.js +565 -0
  49. package/server/services/price-shield.js +1137 -0
  50. package/server/services/search-engine.js +357 -0
  51. package/server/services/security.js +513 -0
  52. package/server/services/universal-scraper.js +661 -0
  53. package/server/ws.js +61 -1
@@ -12,6 +12,7 @@ const { authenticateToken } = require('../middleware/auth');
12
12
  const reputation = require('../services/reputation');
13
13
  const negotiation = require('../services/negotiation');
14
14
  const verification = require('../services/verification');
15
+ const priceShield = require('../services/price-shield');
15
16
 
16
17
  // ═══════════════════════════════════════════════════════════════════════
17
18
  // REPUTATION API
@@ -304,4 +305,81 @@ router.get('/dashboard/sovereign', authenticateToken, (req, res) => {
304
305
  res.json(dashboardData);
305
306
  });
306
307
 
308
+ // ═══════════════════════════════════════════════════════════════════════
309
+ // DYNAMIC PRICING SHIELD API
310
+ // ═══════════════════════════════════════════════════════════════════════
311
+
312
+ // Get available identity personas
313
+ router.get('/price-shield/personas', (req, res) => {
314
+ res.json(priceShield.getPersonas());
315
+ });
316
+
317
+ // Create a new price scan
318
+ router.post('/price-shield/scans', (req, res) => {
319
+ const { siteId, url, itemName, category } = req.body;
320
+ if (!url) {
321
+ return res.status(400).json({ error: 'url is required' });
322
+ }
323
+ const result = priceShield.createScan({ siteId, url, itemName, category });
324
+ res.json(result);
325
+ });
326
+
327
+ // Record a probe result for a scan
328
+ router.post('/price-shield/scans/:scanId/probes', (req, res) => {
329
+ const { personaId, priceText, currency, responseHeaders, cookiesReceived, durationMs } = req.body;
330
+ if (!personaId || !priceText) {
331
+ return res.status(400).json({ error: 'personaId and priceText are required' });
332
+ }
333
+ const result = priceShield.recordProbe(req.params.scanId, {
334
+ personaId, priceText, currency, responseHeaders, cookiesReceived, durationMs
335
+ });
336
+ if (result.error) return res.status(400).json(result);
337
+ res.json(result);
338
+ });
339
+
340
+ // Analyze a scan (after probes are recorded)
341
+ router.post('/price-shield/scans/:scanId/analyze', (req, res) => {
342
+ const result = priceShield.analyzeScan(req.params.scanId);
343
+ if (result.error) return res.status(400).json(result);
344
+ res.json(result);
345
+ });
346
+
347
+ // Quick scan — all-in-one (provide probes + get analysis)
348
+ router.post('/price-shield/quick-scan', (req, res) => {
349
+ const { url, itemName, siteId, category, probes } = req.body;
350
+ if (!url || !probes || !Array.isArray(probes) || probes.length < 2) {
351
+ return res.status(400).json({ error: 'url and at least 2 probes are required' });
352
+ }
353
+ const result = priceShield.quickScan({ url, itemName, siteId, category, probes });
354
+ if (result.error) return res.status(400).json(result);
355
+ res.json(result);
356
+ });
357
+
358
+ // Get scan report
359
+ router.get('/price-shield/scans/:scanId', (req, res) => {
360
+ const result = priceShield.getScanReport(req.params.scanId);
361
+ if (result.error) return res.status(404).json(result);
362
+ res.json(result);
363
+ });
364
+
365
+ // Get global price shield statistics
366
+ router.get('/price-shield/stats', (req, res) => {
367
+ res.json(priceShield.getGlobalStats());
368
+ });
369
+
370
+ // Get price history for a URL
371
+ router.get('/price-shield/history', (req, res) => {
372
+ const url = req.query.url;
373
+ if (!url) return res.status(400).json({ error: 'url query parameter is required' });
374
+ const limit = Math.min(parseInt(req.query.limit) || 30, 100);
375
+ res.json(priceShield.getPriceHistory(url, limit));
376
+ });
377
+
378
+ // Get manipulation log for a site
379
+ router.get('/price-shield/manipulations/:siteId', (req, res) => {
380
+ const limit = Math.min(parseInt(req.query.limit) || 20, 100);
381
+ const result = priceShield.getGlobalStats();
382
+ res.json(result.topManipulators.find(m => m.siteId === req.params.siteId) || { siteId: req.params.siteId, incidents: 0 });
383
+ });
384
+
307
385
  module.exports = router;
@@ -0,0 +1,177 @@
1
+ /**
2
+ * WAB Universal Agent — Server Routes
3
+ * ═══════════════════════════════════════════════════════════════════
4
+ * API endpoints for the Universal Agent mode.
5
+ * Used by: WAB Browser, Chrome Extension, direct API calls.
6
+ *
7
+ * All endpoints work WITHOUT requiring the target site to install any script.
8
+ */
9
+
10
+ const express = require('express');
11
+ const router = express.Router();
12
+ const scraper = require('../services/universal-scraper');
13
+ const priceIntel = require('../services/price-intelligence');
14
+ const fairness = require('../services/fairness-engine');
15
+
16
+ // ─── POST /api/universal/extract ─────────────────────────────────────
17
+ // Extract prices/products from a URL (server-side fetch)
18
+ router.post('/extract', async (req, res) => {
19
+ const { url } = req.body;
20
+ if (!url) return res.status(400).json({ error: 'URL required' });
21
+
22
+ try {
23
+ const result = await scraper.fetchAndExtract(url);
24
+ res.json(result);
25
+ } catch (err) {
26
+ res.status(500).json({ error: err.message });
27
+ }
28
+ });
29
+
30
+ // ─── POST /api/universal/analyze ─────────────────────────────────────
31
+ // Full analysis: extract + fraud detection + trust score
32
+ // Accepts either a URL (server-side fetch) or pre-extracted data (from browser)
33
+ router.post('/analyze', async (req, res) => {
34
+ const { url, extraction } = req.body;
35
+
36
+ try {
37
+ let result;
38
+
39
+ if (extraction) {
40
+ // Data already extracted by browser/extension
41
+ const processed = scraper.processBrowserExtraction(extraction);
42
+ result = await priceIntel.analyzePrice(extraction.url || url || '', processed);
43
+
44
+ // Add dark pattern detection if text available
45
+ if (extraction.darkPatterns) {
46
+ result.darkPatterns = extraction.darkPatterns;
47
+ }
48
+ } else if (url) {
49
+ // Server-side fetch and analyze
50
+ result = await priceIntel.analyzePrice(url);
51
+ } else {
52
+ return res.status(400).json({ error: 'URL or extraction data required' });
53
+ }
54
+
55
+ // Add fairness score for the domain
56
+ if (result.domain) {
57
+ result.fairness = fairness.calculateFairnessScore(result.domain, {
58
+ fraudAlerts: (result.alerts || []).length,
59
+ });
60
+ }
61
+
62
+ res.json(result);
63
+ } catch (err) {
64
+ res.status(500).json({ error: err.message });
65
+ }
66
+ });
67
+
68
+ // ─── POST /api/universal/compare ─────────────────────────────────────
69
+ // Compare prices across multiple sources
70
+ router.post('/compare', async (req, res) => {
71
+ const { query, category, maxSources } = req.body;
72
+ if (!query) return res.status(400).json({ error: 'Query required' });
73
+
74
+ try {
75
+ const result = await priceIntel.compareAcrossSources(
76
+ query,
77
+ category || 'product',
78
+ { maxSources: maxSources || 8 }
79
+ );
80
+
81
+ // Apply fairness ranking
82
+ if (result.results && result.results.length > 0) {
83
+ result.results = fairness.rankWithFairness(result.results, {
84
+ avgPrice: result.avgPrice,
85
+ });
86
+ }
87
+
88
+ res.json(result);
89
+ } catch (err) {
90
+ res.status(500).json({ error: err.message });
91
+ }
92
+ });
93
+
94
+ // ─── POST /api/universal/deals ───────────────────────────────────────
95
+ // Find best deals with fairness ranking + fraud detection
96
+ router.post('/deals', async (req, res) => {
97
+ const { query, category, lang } = req.body;
98
+ if (!query) return res.status(400).json({ error: 'Query required' });
99
+
100
+ try {
101
+ const result = await priceIntel.findBestDeals(
102
+ query,
103
+ category || 'product',
104
+ { lang: lang || 'en' }
105
+ );
106
+
107
+ // Apply fairness ranking to deals
108
+ if (result.deals && result.deals.length > 0) {
109
+ result.deals = fairness.rankWithFairness(result.deals, {
110
+ avgPrice: result.deals.reduce((s, d) => s + (d.priceUsd || 0), 0) / result.deals.length,
111
+ });
112
+ }
113
+
114
+ res.json(result);
115
+ } catch (err) {
116
+ res.status(500).json({ error: err.message });
117
+ }
118
+ });
119
+
120
+ // ─── POST /api/universal/fairness ────────────────────────────────────
121
+ // Get fairness score for a domain
122
+ router.post('/fairness', (req, res) => {
123
+ const { domain, url } = req.body;
124
+ const d = domain || (url ? (() => { try { return new URL(url).hostname; } catch (_) { return ''; } })() : '');
125
+ if (!d) return res.status(400).json({ error: 'Domain or URL required' });
126
+
127
+ const score = fairness.calculateFairnessScore(d);
128
+ res.json(score);
129
+ });
130
+
131
+ // ─── POST /api/universal/dark-patterns ───────────────────────────────
132
+ // Detect dark patterns in page text
133
+ router.post('/dark-patterns', (req, res) => {
134
+ const { text, lang } = req.body;
135
+ if (!text) return res.status(400).json({ error: 'Text required' });
136
+
137
+ const patterns = fairness.detectDarkPatterns(text, lang || 'en');
138
+ res.json({ patterns, count: patterns.length });
139
+ });
140
+
141
+ // ─── GET /api/universal/history ──────────────────────────────────────
142
+ // Get price history for a URL
143
+ router.get('/history', (req, res) => {
144
+ const { url } = req.query;
145
+ if (!url) return res.status(400).json({ error: 'URL required' });
146
+
147
+ const history = scraper.getPriceHistory(url, 30);
148
+ res.json({ url, history });
149
+ });
150
+
151
+ // ─── GET /api/universal/top-fair ─────────────────────────────────────
152
+ // Get top fairness-ranked sites
153
+ router.get('/top-fair', (req, res) => {
154
+ const limit = parseInt(req.query.limit) || 20;
155
+ const sites = fairness.getTopFairSites(limit);
156
+ res.json({ sites });
157
+ });
158
+
159
+ // ─── GET /api/universal/extraction-script ────────────────────────────
160
+ // Get the browser extraction script (for dynamic injection)
161
+ router.get('/extraction-script', (req, res) => {
162
+ res.set('Content-Type', 'application/javascript');
163
+ res.send(scraper.getBrowserExtractionScript());
164
+ });
165
+
166
+ // ─── GET /api/universal/sources ──────────────────────────────────────
167
+ // List all competing sources by category
168
+ router.get('/sources', (req, res) => {
169
+ const { category } = req.query;
170
+ if (category && priceIntel.COMPETING_SOURCES[category]) {
171
+ res.json({ category, sources: priceIntel.COMPETING_SOURCES[category] });
172
+ } else {
173
+ res.json(priceIntel.COMPETING_SOURCES);
174
+ }
175
+ });
176
+
177
+ module.exports = router;
@@ -9,8 +9,11 @@
9
9
 
10
10
  const express = require('express');
11
11
  const router = express.Router();
12
+ const crypto = require('crypto');
12
13
  const { findSiteById, findSiteByLicense, recordAnalytic, db } = require('../models/db');
13
14
  const { broadcastAnalytic } = require('../ws');
15
+ const { wabAuthenticateLimiter, wabActionLimiter, searchLimiter } = require('../middleware/rateLimits');
16
+ const { auditLog } = require('../services/security');
14
17
  const {
15
18
  calculateNeutralityScore,
16
19
  fairnessWeightedSearch,
@@ -82,7 +85,7 @@ function buildErrorResponse(id, code, message) {
82
85
  // POST /api/wab/authenticate — session token exchange
83
86
  // ═════════════════════════════════════════════════════════════════════
84
87
 
85
- router.post('/authenticate', (req, res) => {
88
+ router.post('/authenticate', wabAuthenticateLimiter, (req, res) => {
86
89
  try {
87
90
  const { siteId, apiKey, meta } = req.body;
88
91
  if (!siteId && !apiKey) {
@@ -91,12 +94,22 @@ router.post('/authenticate', (req, res) => {
91
94
 
92
95
  let site;
93
96
  if (apiKey) {
94
- site = db.prepare('SELECT * FROM sites WHERE api_key = ? AND active = 1').get(apiKey);
97
+ // Timing-safe API key lookup: hash the provided key and compare against stored hashes
98
+ // to prevent timing attacks on the raw key comparison
99
+ const allActive = db.prepare('SELECT * FROM sites WHERE active = 1 AND api_key IS NOT NULL').all();
100
+ site = allActive.find(s => {
101
+ if (!s.api_key) return false;
102
+ const a = Buffer.from(s.api_key);
103
+ const b = Buffer.from(apiKey);
104
+ if (a.length !== b.length) return false;
105
+ return crypto.timingSafeEqual(a, b);
106
+ }) || null;
95
107
  } else {
96
108
  site = findSiteById.get(siteId);
97
109
  }
98
110
 
99
111
  if (!site) {
112
+ auditLog({ actorType: 'agent', action: 'wab_auth_failed', details: { siteId }, ip: req.ip, outcome: 'denied', severity: 'warning' });
100
113
  return res.status(404).json(buildErrorResponse(null, 'not_found', 'Site not found or invalid credentials'));
101
114
  }
102
115
 
@@ -105,7 +118,9 @@ router.post('/authenticate', (req, res) => {
105
118
  try {
106
119
  const reqDomain = new URL(origin).hostname.replace(/^www\./, '');
107
120
  const siteDomain = site.domain.replace(/^www\./, '');
108
- if (reqDomain !== siteDomain && reqDomain !== 'localhost' && reqDomain !== '127.0.0.1') {
121
+ const isProduction = process.env.NODE_ENV === 'production';
122
+ const isLocalhost = reqDomain === 'localhost' || reqDomain === '127.0.0.1';
123
+ if (reqDomain !== siteDomain && !(isLocalhost && !isProduction)) {
109
124
  return res.status(403).json(buildErrorResponse(null, 'origin_mismatch', 'Origin does not match site domain'));
110
125
  }
111
126
  } catch (_) {}
@@ -193,7 +208,7 @@ router.get('/actions', (req, res) => {
193
208
  // POST /api/wab/actions/:name — execute action (with tracking)
194
209
  // ═════════════════════════════════════════════════════════════════════
195
210
 
196
- router.post('/actions/:name', requireSession, (req, res) => {
211
+ router.post('/actions/:name', requireSession, wabActionLimiter, (req, res) => {
197
212
  try {
198
213
  const actionName = req.params.name;
199
214
  const site = findSiteById.get(req.wabSession.siteId);
@@ -333,7 +348,7 @@ router.get('/page-info', (req, res) => {
333
348
  // GET /api/wab/search — fairness-weighted search (MCP adapter uses this)
334
349
  // ═════════════════════════════════════════════════════════════════════
335
350
 
336
- router.get('/search', (req, res) => {
351
+ router.get('/search', searchLimiter, (req, res) => {
337
352
  try {
338
353
  const query = req.query.q || '';
339
354
  const category = req.query.category || null;
@@ -0,0 +1,210 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * WAB Runtime - Event Bus
5
+ *
6
+ * Async event system with typed events, middleware, replay buffer,
7
+ * and dead-letter queue. This is the nervous system of the Agent OS.
8
+ */
9
+
10
+ const crypto = require('crypto');
11
+
12
+ class EventBus {
13
+ constructor(options = {}) {
14
+ this._listeners = new Map(); // event → Set<{ id, handler, filter, once }>
15
+ this._middleware = []; // global middleware
16
+ this._history = []; // event replay buffer
17
+ this._deadLetter = []; // failed events
18
+ this._maxHistory = options.maxHistory || 10000;
19
+ this._maxDeadLetter = options.maxDeadLetter || 1000;
20
+ this._stats = { emitted: 0, delivered: 0, failed: 0, dropped: 0 };
21
+ }
22
+
23
+ /**
24
+ * Subscribe to an event
25
+ * @returns {string} subscription ID for unsubscribe
26
+ */
27
+ on(event, handler, options = {}) {
28
+ if (!this._listeners.has(event)) this._listeners.set(event, new Set());
29
+ const sub = {
30
+ id: `sub_${crypto.randomBytes(8).toString('hex')}`,
31
+ handler,
32
+ filter: options.filter || null,
33
+ once: options.once || false,
34
+ priority: options.priority || 0,
35
+ };
36
+ this._listeners.get(event).add(sub);
37
+ return sub.id;
38
+ }
39
+
40
+ /**
41
+ * Subscribe once
42
+ */
43
+ once(event, handler, options = {}) {
44
+ return this.on(event, handler, { ...options, once: true });
45
+ }
46
+
47
+ /**
48
+ * Unsubscribe by subscription ID
49
+ */
50
+ off(subId) {
51
+ for (const [, subs] of this._listeners) {
52
+ for (const sub of subs) {
53
+ if (sub.id === subId) { subs.delete(sub); return true; }
54
+ }
55
+ }
56
+ return false;
57
+ }
58
+
59
+ /**
60
+ * Remove all listeners for an event
61
+ */
62
+ removeAll(event) {
63
+ if (event) this._listeners.delete(event);
64
+ else this._listeners.clear();
65
+ }
66
+
67
+ /**
68
+ * Emit an event
69
+ */
70
+ async emit(event, data, metadata = {}) {
71
+ const envelope = {
72
+ id: `evt_${crypto.randomBytes(12).toString('hex')}`,
73
+ event,
74
+ data,
75
+ metadata: {
76
+ ...metadata,
77
+ timestamp: Date.now(),
78
+ source: metadata.source || 'system',
79
+ },
80
+ };
81
+
82
+ // Run global middleware
83
+ for (const mw of this._middleware) {
84
+ try {
85
+ const result = await mw(envelope);
86
+ if (result === false) {
87
+ this._stats.dropped++;
88
+ return envelope;
89
+ }
90
+ } catch (_) { /* middleware errors don't block */ }
91
+ }
92
+
93
+ // Store in history
94
+ this._history.push(envelope);
95
+ if (this._history.length > this._maxHistory) {
96
+ this._history = this._history.slice(-Math.floor(this._maxHistory * 0.8));
97
+ }
98
+
99
+ this._stats.emitted++;
100
+
101
+ // Get listeners (event + wildcard)
102
+ const listeners = [];
103
+ const exact = this._listeners.get(event);
104
+ if (exact) for (const sub of exact) listeners.push(sub);
105
+ const wild = this._listeners.get('*');
106
+ if (wild) for (const sub of wild) listeners.push(sub);
107
+
108
+ // Also match namespace wildcards: 'task.*' matches 'task.completed'
109
+ for (const [pattern, subs] of this._listeners) {
110
+ if (pattern === event || pattern === '*') continue;
111
+ if (pattern.endsWith('.*') && event.startsWith(pattern.slice(0, -1))) {
112
+ for (const sub of subs) listeners.push(sub);
113
+ }
114
+ }
115
+
116
+ // Sort by priority (higher first)
117
+ listeners.sort((a, b) => b.priority - a.priority);
118
+
119
+ // Dispatch
120
+ const toRemove = [];
121
+ for (const sub of listeners) {
122
+ try {
123
+ if (sub.filter && !sub.filter(envelope.data, envelope.metadata)) continue;
124
+ await sub.handler(envelope.data, envelope.metadata, envelope);
125
+ this._stats.delivered++;
126
+ if (sub.once) toRemove.push(sub);
127
+ } catch (err) {
128
+ this._stats.failed++;
129
+ this._deadLetter.push({ envelope, error: err.message, subscriberId: sub.id, timestamp: Date.now() });
130
+ if (this._deadLetter.length > this._maxDeadLetter) {
131
+ this._deadLetter = this._deadLetter.slice(-Math.floor(this._maxDeadLetter * 0.8));
132
+ }
133
+ }
134
+ }
135
+
136
+ // Cleanup one-time subs
137
+ for (const sub of toRemove) {
138
+ for (const [, subs] of this._listeners) subs.delete(sub);
139
+ }
140
+
141
+ return envelope;
142
+ }
143
+
144
+ /**
145
+ * Add global middleware
146
+ */
147
+ use(middleware) {
148
+ this._middleware.push(middleware);
149
+ }
150
+
151
+ /**
152
+ * Replay events matching filter since a timestamp
153
+ */
154
+ async replay(since, filter, handler) {
155
+ const events = this._history.filter(
156
+ e => e.metadata.timestamp >= since && (!filter || filter(e))
157
+ );
158
+ for (const e of events) {
159
+ await handler(e.data, e.metadata, e);
160
+ }
161
+ return events.length;
162
+ }
163
+
164
+ /**
165
+ * Wait for a specific event (returns a promise)
166
+ */
167
+ waitFor(event, timeout = 30000, filter = null) {
168
+ return new Promise((resolve, reject) => {
169
+ const timer = setTimeout(() => {
170
+ this.off(subId);
171
+ reject(new Error(`Timeout waiting for event: ${event}`));
172
+ }, timeout);
173
+
174
+ const subId = this.once(event, (data, meta) => {
175
+ clearTimeout(timer);
176
+ resolve({ data, meta });
177
+ }, { filter });
178
+ });
179
+ }
180
+
181
+ /**
182
+ * Get dead letter queue
183
+ */
184
+ getDeadLetters(limit = 50) {
185
+ return this._deadLetter.slice(-limit);
186
+ }
187
+
188
+ /**
189
+ * Get stats
190
+ */
191
+ getStats() {
192
+ return {
193
+ ...this._stats,
194
+ listeners: this._countListeners(),
195
+ historySize: this._history.length,
196
+ deadLetterSize: this._deadLetter.length,
197
+ };
198
+ }
199
+
200
+ _countListeners() {
201
+ let count = 0;
202
+ for (const [, subs] of this._listeners) count += subs.size;
203
+ return count;
204
+ }
205
+ }
206
+
207
+ // Singleton event bus for the runtime
208
+ const bus = new EventBus();
209
+
210
+ module.exports = { EventBus, bus };