web-agent-bridge 2.5.0 → 2.6.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.
@@ -19,11 +19,86 @@ const router = express.Router();
19
19
  const protocol = require('../protocol');
20
20
  const { runtime, bus } = require('../runtime');
21
21
  const { logger, tracer, metrics } = require('../observability');
22
+ const { failureAnalyzer } = require('../observability/failure-analysis');
22
23
  const { identity, signer, isolation } = require('../security');
23
24
  const { agentManager, policyEngine } = require('../control-plane');
24
25
  const { executor } = require('../data-plane');
25
26
  const { llm } = require('../llm');
26
27
  const { commandRegistry, siteRegistry, templateRegistry } = require('../registry');
28
+ const { certificationEngine } = require('../registry/certification');
29
+ const { adapterManager, mcpAdapter, restAdapter, browserAdapter } = require('../adapters');
30
+ const { replayEngine } = require('../runtime/replay');
31
+ const { sessionEngine } = require('../runtime/session-engine');
32
+
33
+ // ═══════════════════════════════════════════════════════════════════════════
34
+ // AUTH MIDDLEWARE
35
+ // ═══════════════════════════════════════════════════════════════════════════
36
+
37
+ /**
38
+ * Authenticate requests via API key or session token.
39
+ * Public endpoints (protocol info, agent registration, health) bypass auth.
40
+ */
41
+ const PUBLIC_PATHS = [
42
+ '/protocol',
43
+ '/agents/register',
44
+ '/agents/authenticate',
45
+ '/observability/health',
46
+ '/llm/models',
47
+ '/llm/status',
48
+ '/registry/commands',
49
+ '/registry/sites',
50
+ '/registry/templates',
51
+ ];
52
+
53
+ function authMiddleware(req, res, next) {
54
+ // Allow public GET endpoints
55
+ const matchesPublic = PUBLIC_PATHS.some(p =>
56
+ req.path === p || (req.method === 'GET' && req.path.startsWith(p))
57
+ );
58
+ if (matchesPublic) return next();
59
+
60
+ // Check session token
61
+ const authHeader = req.headers['authorization'];
62
+ if (authHeader && authHeader.startsWith('Bearer ')) {
63
+ const token = authHeader.slice(7);
64
+ const session = identity.validateSession(token);
65
+ if (session) {
66
+ req.agentId = session.agentId;
67
+ req.session = session;
68
+ return next();
69
+ }
70
+ }
71
+
72
+ // Check API key
73
+ const apiKey = req.headers['x-wab-key'];
74
+ if (apiKey) {
75
+ const ip = req.ip || req.connection?.remoteAddress;
76
+ const session = identity.authenticate(apiKey, ip);
77
+ if (session) {
78
+ req.agentId = session.agentId;
79
+ req.session = session;
80
+ return next();
81
+ }
82
+ }
83
+
84
+ // Check agent ID header (for internal/trusted calls)
85
+ const agentHeader = req.headers['x-wab-agent'];
86
+ if (agentHeader) {
87
+ const agent = identity.getAgent(agentHeader);
88
+ if (agent && agent.status === 'active') {
89
+ req.agentId = agentHeader;
90
+ return next();
91
+ }
92
+ }
93
+
94
+ // No auth on non-mutation GET requests (read-only)
95
+ if (req.method === 'GET') return next();
96
+
97
+ metrics.increment('auth.rejected');
98
+ return res.status(401).json({ error: 'Authentication required. Provide X-WAB-Key or Authorization: Bearer <token>' });
99
+ }
100
+
101
+ router.use(authMiddleware);
27
102
 
28
103
  // ═══════════════════════════════════════════════════════════════════════════
29
104
  // PROTOCOL ENDPOINTS
@@ -443,6 +518,11 @@ router.get('/observability/health', (req, res) => {
443
518
  };
444
519
  health.executor = executor.getStats();
445
520
  health.llm = llm.getStatus();
521
+ health.adapters = adapterManager.getStats();
522
+ health.replay = replayEngine.getStats();
523
+ health.sessions = sessionEngine.getStats();
524
+ health.failures = failureAnalyzer.getStats();
525
+ health.certification = certificationEngine.getStats();
446
526
  res.json(health);
447
527
  });
448
528
 
@@ -722,4 +802,335 @@ protocolHandler.handle('wab.commerce.compare', async (payload) => {
722
802
  });
723
803
  });
724
804
 
805
+ // ═══════════════════════════════════════════════════════════════════════════
806
+ // ADAPTERS
807
+ // ═══════════════════════════════════════════════════════════════════════════
808
+
809
+ /**
810
+ * List adapters
811
+ */
812
+ router.get('/adapters', (req, res) => {
813
+ res.json({ adapters: adapterManager.list() });
814
+ });
815
+
816
+ /**
817
+ * Adapter stats
818
+ */
819
+ router.get('/adapters/stats', (req, res) => {
820
+ res.json(adapterManager.getStats());
821
+ });
822
+
823
+ /**
824
+ * MCP: list tools
825
+ */
826
+ router.get('/adapters/mcp/tools', (req, res) => {
827
+ const commands = protocol.schema.listCommands();
828
+ res.json(mcpAdapter.handleListTools(commands));
829
+ });
830
+
831
+ /**
832
+ * MCP: call tool
833
+ */
834
+ router.post('/adapters/mcp/call', async (req, res) => {
835
+ try {
836
+ const result = await mcpAdapter.handleCallTool(req.body, async (wapReq) => {
837
+ const request = protocol.createRequest(wapReq.command, wapReq.payload);
838
+ return protocolHandler.process(request);
839
+ });
840
+ res.json(result);
841
+ } catch (err) {
842
+ res.status(500).json({ error: err.message });
843
+ }
844
+ });
845
+
846
+ /**
847
+ * REST adapter: register endpoint
848
+ */
849
+ router.post('/adapters/rest/endpoints', (req, res) => {
850
+ try {
851
+ const endpoint = restAdapter.registerEndpoint(req.body.id, req.body);
852
+ res.json(endpoint);
853
+ } catch (err) {
854
+ res.status(400).json({ error: err.message });
855
+ }
856
+ });
857
+
858
+ /**
859
+ * REST adapter: list endpoints
860
+ */
861
+ router.get('/adapters/rest/endpoints', (req, res) => {
862
+ res.json({ endpoints: restAdapter.listEndpoints() });
863
+ });
864
+
865
+ /**
866
+ * REST adapter: execute
867
+ */
868
+ router.post('/adapters/rest/execute', async (req, res) => {
869
+ try {
870
+ const result = await restAdapter.execute(req.body.endpoint, req.body.params);
871
+ res.json(result);
872
+ } catch (err) {
873
+ res.status(500).json({ error: err.message });
874
+ }
875
+ });
876
+
877
+ /**
878
+ * Browser adapter: list semantic mappings
879
+ */
880
+ router.get('/adapters/browser/mappings', (req, res) => {
881
+ res.json({ mappings: browserAdapter.listMappings() });
882
+ });
883
+
884
+ /**
885
+ * Browser adapter: resolve semantic action
886
+ */
887
+ router.post('/adapters/browser/resolve', (req, res) => {
888
+ const { domain, action, params } = req.body;
889
+ const plan = browserAdapter.fromWAP({ domain, action, params });
890
+ if (!plan) return res.status(404).json({ error: 'No mapping for this semantic action' });
891
+ res.json(plan);
892
+ });
893
+
894
+ /**
895
+ * Browser adapter: register mapping
896
+ */
897
+ router.post('/adapters/browser/mappings', (req, res) => {
898
+ const { domainAction, plan } = req.body;
899
+ if (!domainAction || !plan) return res.status(400).json({ error: 'domainAction and plan required' });
900
+ browserAdapter.registerMapping(domainAction, plan);
901
+ res.json({ success: true });
902
+ });
903
+
904
+ // ═══════════════════════════════════════════════════════════════════════════
905
+ // REPLAY ENGINE
906
+ // ═══════════════════════════════════════════════════════════════════════════
907
+
908
+ /**
909
+ * List recordings
910
+ */
911
+ router.get('/replay/recordings', (req, res) => {
912
+ res.json({ recordings: replayEngine.listRecordings(parseInt(req.query.limit) || 50) });
913
+ });
914
+
915
+ /**
916
+ * Get recording
917
+ */
918
+ router.get('/replay/recordings/:taskId', (req, res) => {
919
+ const rec = replayEngine.getRecording(req.params.taskId);
920
+ if (!rec) return res.status(404).json({ error: 'Recording not found' });
921
+ res.json(rec);
922
+ });
923
+
924
+ /**
925
+ * Replay a task
926
+ */
927
+ router.post('/replay/:taskId', async (req, res) => {
928
+ try {
929
+ const result = await replayEngine.replay(req.params.taskId, {
930
+ verify: req.body.verify !== false,
931
+ continueOnMismatch: !!req.body.continueOnMismatch,
932
+ });
933
+ res.json(result);
934
+ } catch (err) {
935
+ res.status(400).json({ error: err.message });
936
+ }
937
+ });
938
+
939
+ /**
940
+ * Diff two recordings
941
+ */
942
+ router.get('/replay/diff/:taskId1/:taskId2', (req, res) => {
943
+ const diff = replayEngine.diff(req.params.taskId1, req.params.taskId2);
944
+ if (!diff) return res.status(404).json({ error: 'One or both recordings not found' });
945
+ res.json(diff);
946
+ });
947
+
948
+ /**
949
+ * Replay stats
950
+ */
951
+ router.get('/replay/stats', (req, res) => {
952
+ res.json(replayEngine.getStats());
953
+ });
954
+
955
+ // ═══════════════════════════════════════════════════════════════════════════
956
+ // SESSION ENGINE
957
+ // ═══════════════════════════════════════════════════════════════════════════
958
+
959
+ /**
960
+ * Create browser session
961
+ */
962
+ router.post('/sessions', (req, res) => {
963
+ const session = sessionEngine.create(req.body);
964
+ res.json(session);
965
+ });
966
+
967
+ /**
968
+ * List sessions
969
+ */
970
+ router.get('/sessions', (req, res) => {
971
+ const sessions = sessionEngine.list({
972
+ agentId: req.query.agentId,
973
+ siteId: req.query.siteId,
974
+ state: req.query.state,
975
+ }, parseInt(req.query.limit) || 50);
976
+ res.json({ sessions, total: sessions.length });
977
+ });
978
+
979
+ /**
980
+ * Get session
981
+ */
982
+ router.get('/sessions/:sessionId', (req, res) => {
983
+ const session = sessionEngine.get(req.params.sessionId);
984
+ if (!session) return res.status(404).json({ error: 'Session not found or expired' });
985
+ res.json(session);
986
+ });
987
+
988
+ /**
989
+ * Export session
990
+ */
991
+ router.get('/sessions/:sessionId/export', (req, res) => {
992
+ const data = sessionEngine.export(req.params.sessionId);
993
+ if (!data) return res.status(404).json({ error: 'Session not found' });
994
+ res.json(data);
995
+ });
996
+
997
+ /**
998
+ * Import session
999
+ */
1000
+ router.post('/sessions/import', (req, res) => {
1001
+ const session = sessionEngine.import(req.body);
1002
+ res.json(session);
1003
+ });
1004
+
1005
+ /**
1006
+ * Set cookies
1007
+ */
1008
+ router.post('/sessions/:sessionId/cookies', (req, res) => {
1009
+ sessionEngine.setCookies(req.params.sessionId, req.body.cookies || []);
1010
+ res.json({ success: true });
1011
+ });
1012
+
1013
+ /**
1014
+ * Get cookies
1015
+ */
1016
+ router.get('/sessions/:sessionId/cookies', (req, res) => {
1017
+ const cookies = sessionEngine.getCookies(req.params.sessionId, req.query.domain);
1018
+ res.json({ cookies });
1019
+ });
1020
+
1021
+ /**
1022
+ * Set storage
1023
+ */
1024
+ router.post('/sessions/:sessionId/storage', (req, res) => {
1025
+ const { key, value, type } = req.body;
1026
+ sessionEngine.setStorage(req.params.sessionId, key, value, type);
1027
+ res.json({ success: true });
1028
+ });
1029
+
1030
+ /**
1031
+ * Destroy session
1032
+ */
1033
+ router.delete('/sessions/:sessionId', (req, res) => {
1034
+ sessionEngine.destroy(req.params.sessionId);
1035
+ res.json({ success: true });
1036
+ });
1037
+
1038
+ // ═══════════════════════════════════════════════════════════════════════════
1039
+ // FAILURE ANALYSIS
1040
+ // ═══════════════════════════════════════════════════════════════════════════
1041
+
1042
+ /**
1043
+ * Query failures
1044
+ */
1045
+ router.get('/failures', (req, res) => {
1046
+ const failures = failureAnalyzer.query({
1047
+ classification: req.query.classification,
1048
+ severity: req.query.severity,
1049
+ agentId: req.query.agentId,
1050
+ taskId: req.query.taskId,
1051
+ retryable: req.query.retryable === 'true' ? true : req.query.retryable === 'false' ? false : undefined,
1052
+ since: parseInt(req.query.since) || undefined,
1053
+ }, parseInt(req.query.limit) || 50);
1054
+ res.json({ failures, total: failures.length });
1055
+ });
1056
+
1057
+ /**
1058
+ * Get failure
1059
+ */
1060
+ router.get('/failures/:failureId', (req, res) => {
1061
+ const failure = failureAnalyzer.getFailure(req.params.failureId);
1062
+ if (!failure) return res.status(404).json({ error: 'Failure not found' });
1063
+ res.json(failure);
1064
+ });
1065
+
1066
+ /**
1067
+ * Get failure patterns
1068
+ */
1069
+ router.get('/failures/analysis/patterns', (req, res) => {
1070
+ res.json({ patterns: failureAnalyzer.getPatterns() });
1071
+ });
1072
+
1073
+ /**
1074
+ * Get failure summary
1075
+ */
1076
+ router.get('/failures/analysis/summary', (req, res) => {
1077
+ res.json(failureAnalyzer.getSummary(parseInt(req.query.since) || 0));
1078
+ });
1079
+
1080
+ /**
1081
+ * Classify a failure manually
1082
+ */
1083
+ router.post('/failures/classify', (req, res) => {
1084
+ const { error, context } = req.body;
1085
+ if (!error) return res.status(400).json({ error: 'error object required' });
1086
+ const classification = failureAnalyzer.classify(error, context || {});
1087
+ res.json(classification);
1088
+ });
1089
+
1090
+ // ═══════════════════════════════════════════════════════════════════════════
1091
+ // CERTIFICATION
1092
+ // ═══════════════════════════════════════════════════════════════════════════
1093
+
1094
+ /**
1095
+ * Verify a site
1096
+ */
1097
+ router.post('/certification/verify', async (req, res) => {
1098
+ try {
1099
+ const { domain, probeData } = req.body;
1100
+ if (!domain) return res.status(400).json({ error: 'domain required' });
1101
+ const result = await certificationEngine.verify(domain, probeData || {});
1102
+ res.json(result);
1103
+ } catch (err) {
1104
+ res.status(500).json({ error: err.message });
1105
+ }
1106
+ });
1107
+
1108
+ /**
1109
+ * Get certificate
1110
+ */
1111
+ router.get('/certification/:domain', (req, res) => {
1112
+ const cert = certificationEngine.getCertificate(req.params.domain);
1113
+ if (!cert) return res.status(404).json({ error: 'No active certificate for this domain' });
1114
+ res.json(cert);
1115
+ });
1116
+
1117
+ /**
1118
+ * List certificates
1119
+ */
1120
+ router.get('/certification', (req, res) => {
1121
+ const certs = certificationEngine.listCertificates({
1122
+ level: req.query.level,
1123
+ minScore: parseInt(req.query.minScore) || undefined,
1124
+ }, parseInt(req.query.limit) || 50);
1125
+ res.json({ certificates: certs, total: certs.length });
1126
+ });
1127
+
1128
+ /**
1129
+ * Revoke certificate
1130
+ */
1131
+ router.delete('/certification/:domain', (req, res) => {
1132
+ certificationEngine.revoke(req.params.domain);
1133
+ res.json({ success: true });
1134
+ });
1135
+
725
1136
  module.exports = router;
@@ -0,0 +1,264 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Deterministic Replay Engine
5
+ *
6
+ * Records all task inputs/outputs/side-effects for deterministic replay.
7
+ * Enables debugging, testing, and verification of agent workflows.
8
+ */
9
+
10
+ const crypto = require('crypto');
11
+ const { bus } = require('../runtime/event-bus');
12
+
13
+ class ReplayEngine {
14
+ constructor() {
15
+ this._recordings = new Map(); // taskId → Recording
16
+ this._maxRecordings = 5000;
17
+ this._recordingEnabled = true;
18
+ }
19
+
20
+ /**
21
+ * Start recording a task execution
22
+ */
23
+ startRecording(taskId, input) {
24
+ if (!this._recordingEnabled) return null;
25
+
26
+ const recording = {
27
+ id: `rec_${crypto.randomBytes(8).toString('hex')}`,
28
+ taskId,
29
+ input: this._deepClone(input),
30
+ steps: [],
31
+ sideEffects: [],
32
+ startedAt: Date.now(),
33
+ completedAt: null,
34
+ output: null,
35
+ error: null,
36
+ checksum: null,
37
+ replayable: true,
38
+ };
39
+
40
+ this._recordings.set(taskId, recording);
41
+ this._evict();
42
+ return recording.id;
43
+ }
44
+
45
+ /**
46
+ * Record a step in the execution
47
+ */
48
+ recordStep(taskId, step) {
49
+ const rec = this._recordings.get(taskId);
50
+ if (!rec) return;
51
+
52
+ rec.steps.push({
53
+ index: rec.steps.length,
54
+ type: step.type,
55
+ action: step.action,
56
+ input: this._deepClone(step.input),
57
+ output: this._deepClone(step.output),
58
+ duration: step.duration || 0,
59
+ timestamp: Date.now(),
60
+ });
61
+ }
62
+
63
+ /**
64
+ * Record a side effect (network call, DOM mutation, storage write, etc.)
65
+ */
66
+ recordSideEffect(taskId, effect) {
67
+ const rec = this._recordings.get(taskId);
68
+ if (!rec) return;
69
+
70
+ rec.sideEffects.push({
71
+ index: rec.sideEffects.length,
72
+ type: effect.type, // 'network', 'dom', 'storage', 'event'
73
+ target: effect.target,
74
+ data: this._deepClone(effect.data),
75
+ timestamp: Date.now(),
76
+ reversible: effect.reversible !== false,
77
+ });
78
+ }
79
+
80
+ /**
81
+ * Complete a recording
82
+ */
83
+ completeRecording(taskId, output, error = null) {
84
+ const rec = this._recordings.get(taskId);
85
+ if (!rec) return null;
86
+
87
+ rec.completedAt = Date.now();
88
+ rec.output = this._deepClone(output);
89
+ rec.error = error ? { message: error.message, code: error.code } : null;
90
+ rec.checksum = this._computeChecksum(rec);
91
+
92
+ bus.emit('replay.recording.complete', { taskId, recordingId: rec.id, steps: rec.steps.length });
93
+ return rec;
94
+ }
95
+
96
+ /**
97
+ * Replay a recorded task
98
+ * Returns the replay plan (steps to execute) with recorded outputs for verification
99
+ */
100
+ async replay(taskId, options = {}) {
101
+ const rec = this._recordings.get(taskId);
102
+ if (!rec) throw new Error(`No recording found for task ${taskId}`);
103
+ if (!rec.completedAt) throw new Error('Recording not yet complete');
104
+
105
+ const replayResult = {
106
+ recordingId: rec.id,
107
+ taskId,
108
+ originalInput: rec.input,
109
+ originalOutput: rec.output,
110
+ steps: [],
111
+ match: true,
112
+ verificationMode: options.verify !== false,
113
+ replayedAt: Date.now(),
114
+ };
115
+
116
+ // In verification mode, run each step and compare outputs
117
+ if (options.executor && options.verify !== false) {
118
+ for (const step of rec.steps) {
119
+ try {
120
+ const replayOutput = await options.executor(step);
121
+ const outputMatch = this._deepEqual(step.output, replayOutput);
122
+
123
+ replayResult.steps.push({
124
+ index: step.index,
125
+ action: step.action,
126
+ originalOutput: step.output,
127
+ replayOutput,
128
+ match: outputMatch,
129
+ });
130
+
131
+ if (!outputMatch) {
132
+ replayResult.match = false;
133
+ if (!options.continueOnMismatch) break;
134
+ }
135
+ } catch (err) {
136
+ replayResult.steps.push({
137
+ index: step.index,
138
+ action: step.action,
139
+ error: err.message,
140
+ match: false,
141
+ });
142
+ replayResult.match = false;
143
+ if (!options.continueOnMismatch) break;
144
+ }
145
+ }
146
+ } else {
147
+ // Dry-run mode: just return the recorded steps
148
+ replayResult.steps = rec.steps.map(s => ({
149
+ index: s.index,
150
+ action: s.action,
151
+ input: s.input,
152
+ output: s.output,
153
+ duration: s.duration,
154
+ }));
155
+ }
156
+
157
+ bus.emit('replay.completed', { taskId, match: replayResult.match });
158
+ return replayResult;
159
+ }
160
+
161
+ /**
162
+ * Get recording
163
+ */
164
+ getRecording(taskId) {
165
+ return this._recordings.get(taskId) || null;
166
+ }
167
+
168
+ /**
169
+ * List recordings
170
+ */
171
+ listRecordings(limit = 50) {
172
+ const all = Array.from(this._recordings.values());
173
+ return all.slice(-limit).reverse().map(r => ({
174
+ id: r.id,
175
+ taskId: r.taskId,
176
+ steps: r.steps.length,
177
+ sideEffects: r.sideEffects.length,
178
+ startedAt: r.startedAt,
179
+ completedAt: r.completedAt,
180
+ hasError: !!r.error,
181
+ checksum: r.checksum,
182
+ }));
183
+ }
184
+
185
+ /**
186
+ * Compare two recordings
187
+ */
188
+ diff(taskId1, taskId2) {
189
+ const r1 = this._recordings.get(taskId1);
190
+ const r2 = this._recordings.get(taskId2);
191
+ if (!r1 || !r2) return null;
192
+
193
+ const diffs = [];
194
+ const maxSteps = Math.max(r1.steps.length, r2.steps.length);
195
+
196
+ for (let i = 0; i < maxSteps; i++) {
197
+ const s1 = r1.steps[i];
198
+ const s2 = r2.steps[i];
199
+
200
+ if (!s1 || !s2) {
201
+ diffs.push({ index: i, type: 'missing', in: s1 ? 'recording2' : 'recording1' });
202
+ } else if (!this._deepEqual(s1.output, s2.output)) {
203
+ diffs.push({ index: i, type: 'output_mismatch', action: s1.action, output1: s1.output, output2: s2.output });
204
+ }
205
+ }
206
+
207
+ return {
208
+ match: diffs.length === 0,
209
+ inputMatch: this._deepEqual(r1.input, r2.input),
210
+ outputMatch: this._deepEqual(r1.output, r2.output),
211
+ diffs,
212
+ };
213
+ }
214
+
215
+ /**
216
+ * Enable/disable recording
217
+ */
218
+ setEnabled(enabled) {
219
+ this._recordingEnabled = enabled;
220
+ }
221
+
222
+ getStats() {
223
+ return {
224
+ totalRecordings: this._recordings.size,
225
+ enabled: this._recordingEnabled,
226
+ maxRecordings: this._maxRecordings,
227
+ };
228
+ }
229
+
230
+ // ── Internal ──
231
+
232
+ _computeChecksum(rec) {
233
+ const data = JSON.stringify({
234
+ input: rec.input,
235
+ steps: rec.steps.map(s => ({ action: s.action, output: s.output })),
236
+ output: rec.output,
237
+ });
238
+ return crypto.createHash('sha256').update(data).digest('hex').slice(0, 16);
239
+ }
240
+
241
+ _deepClone(obj) {
242
+ if (obj === undefined || obj === null) return obj;
243
+ try {
244
+ return JSON.parse(JSON.stringify(obj));
245
+ } catch {
246
+ return obj;
247
+ }
248
+ }
249
+
250
+ _deepEqual(a, b) {
251
+ return JSON.stringify(a) === JSON.stringify(b);
252
+ }
253
+
254
+ _evict() {
255
+ if (this._recordings.size <= this._maxRecordings) return;
256
+ const keys = Array.from(this._recordings.keys());
257
+ const toRemove = keys.slice(0, keys.length - this._maxRecordings);
258
+ for (const k of toRemove) this._recordings.delete(k);
259
+ }
260
+ }
261
+
262
+ const replayEngine = new ReplayEngine();
263
+
264
+ module.exports = { ReplayEngine, replayEngine };