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.
- package/README.ar.md +524 -31
- package/README.md +592 -47
- package/bin/agent-runner.js +10 -1
- package/package.json +1 -1
- package/public/agent-workspace.html +347 -0
- package/public/browser.html +484 -0
- package/public/css/agent-workspace.css +1713 -0
- package/public/index.html +94 -0
- package/public/js/agent-workspace.js +1740 -0
- package/sdk/index.d.ts +253 -0
- package/sdk/index.js +360 -1
- package/sdk/package.json +1 -1
- package/server/config/secrets.js +13 -5
- package/server/control-plane/index.js +301 -0
- package/server/data-plane/index.js +354 -0
- package/server/index.js +185 -4
- package/server/llm/index.js +404 -0
- package/server/middleware/adminAuth.js +6 -1
- package/server/middleware/auth.js +11 -2
- package/server/middleware/rateLimits.js +78 -2
- package/server/migrations/003_ads_integer_cents.sql +33 -0
- package/server/models/db.js +126 -25
- package/server/observability/index.js +394 -0
- package/server/protocol/capabilities.js +223 -0
- package/server/protocol/index.js +243 -0
- package/server/protocol/schema.js +584 -0
- package/server/registry/index.js +326 -0
- package/server/routes/admin.js +16 -2
- package/server/routes/ads.js +130 -0
- package/server/routes/agent-workspace.js +378 -0
- package/server/routes/api.js +21 -2
- package/server/routes/auth.js +26 -6
- package/server/routes/runtime.js +725 -0
- package/server/routes/sovereign.js +78 -0
- package/server/routes/universal.js +177 -0
- package/server/routes/wab-api.js +20 -5
- package/server/runtime/event-bus.js +210 -0
- package/server/runtime/index.js +233 -0
- package/server/runtime/sandbox.js +266 -0
- package/server/runtime/scheduler.js +395 -0
- package/server/runtime/state-manager.js +188 -0
- package/server/security/index.js +355 -0
- package/server/services/agent-chat.js +506 -0
- package/server/services/agent-symphony.js +6 -0
- package/server/services/agent-tasks.js +1807 -0
- package/server/services/fairness-engine.js +409 -0
- package/server/services/plugins.js +27 -3
- package/server/services/price-intelligence.js +565 -0
- package/server/services/price-shield.js +1137 -0
- package/server/services/search-engine.js +357 -0
- package/server/services/security.js +513 -0
- package/server/services/universal-scraper.js +661 -0
- 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;
|
package/server/routes/wab-api.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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 };
|