rea-agents 0.3.0 → 0.4.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 (122) hide show
  1. package/README.md +124 -20
  2. package/bridge/hopper_bridge.py +141 -13
  3. package/dist/application/AnalysisProvider.js +12 -1
  4. package/dist/application/ArtifactExtraction.js +166 -0
  5. package/dist/application/ArtifactGraphConstruction.js +257 -0
  6. package/dist/application/ArtifactInventory.js +253 -0
  7. package/dist/application/BinarySession.js +164 -14
  8. package/dist/application/CompositeProvider.js +73 -0
  9. package/dist/application/DirectAnalysis.js +27 -3
  10. package/dist/application/Doctor.js +35 -6
  11. package/dist/application/EnhancedTools.js +8 -7
  12. package/dist/application/EvidenceBundleCommands.js +29 -0
  13. package/dist/application/EvidenceBundleFiles.js +127 -0
  14. package/dist/application/EvidenceLedger.js +225 -18
  15. package/dist/application/FilesystemSnapshot.js +124 -0
  16. package/dist/application/LinuxHopper.js +186 -0
  17. package/dist/application/LoopbackReplay.js +195 -37
  18. package/dist/application/ProcessHarness.js +200 -191
  19. package/dist/application/ProcessNormalization.js +44 -0
  20. package/dist/application/ProcessOwnership.js +84 -0
  21. package/dist/application/ProcessSampling.js +284 -0
  22. package/dist/application/RealHopperAssertions.js +105 -0
  23. package/dist/application/ReferenceSourceImport.js +182 -0
  24. package/dist/application/ReferenceSourceImportEntries.js +122 -0
  25. package/dist/application/ReferenceSourceImportPolicy.js +73 -0
  26. package/dist/application/ReferenceSourceImportTypes.js +18 -0
  27. package/dist/application/ReferenceSourceVcsAdapter.js +34 -0
  28. package/dist/application/Setup.js +192 -41
  29. package/dist/application/Uninstall.js +130 -0
  30. package/dist/application/runtime.js +8 -1
  31. package/dist/artifacts/ArtifactPaths.js +51 -0
  32. package/dist/artifacts/ArtifactProvider.js +130 -0
  33. package/dist/artifacts/ArtifactReader.js +9 -0
  34. package/dist/artifacts/AsarArtifactReader.js +62 -0
  35. package/dist/artifacts/DirectoryArtifactReader.js +107 -0
  36. package/dist/artifacts/MachOSliceArtifactReader.js +66 -0
  37. package/dist/artifacts/SafeOutputTree.js +199 -0
  38. package/dist/artifacts/StreamBytes.js +10 -0
  39. package/dist/artifacts/ZipArtifactReader.js +109 -0
  40. package/dist/cli.js +149 -2
  41. package/dist/config.js +35 -1
  42. package/dist/contracts/artifactComparisonExample.js +95 -0
  43. package/dist/contracts/artifactToolContracts.js +84 -0
  44. package/dist/contracts/enhancedInputs.js +4 -0
  45. package/dist/contracts/functionComparisonExample.js +57 -0
  46. package/dist/contracts/investigationExamples.js +118 -0
  47. package/dist/contracts/nativeToolContracts.js +52 -0
  48. package/dist/contracts/processCaptureExample.js +25 -0
  49. package/dist/contracts/toolContractExamples.js +69 -0
  50. package/dist/contracts/toolContracts.js +99 -36
  51. package/dist/contracts/toolOutputSchemas.js +154 -82
  52. package/dist/contracts/unknownContractExamples.js +33 -0
  53. package/dist/domain/artifactComparison.js +273 -0
  54. package/dist/domain/artifactGraph.js +194 -0
  55. package/dist/domain/artifactInventoryEvidence.js +150 -0
  56. package/dist/domain/binaryTarget.js +55 -0
  57. package/dist/domain/bundleComparison.js +266 -0
  58. package/dist/domain/callPath.js +346 -0
  59. package/dist/domain/changedBehavior.js +294 -0
  60. package/dist/domain/errors.js +192 -4
  61. package/dist/domain/evidence.js +41 -10
  62. package/dist/domain/evidenceBundle.js +187 -6
  63. package/dist/domain/functionComparison.js +201 -0
  64. package/dist/domain/functionComparisonNormalization.js +112 -0
  65. package/dist/domain/functionComparisonResults.js +54 -0
  66. package/dist/domain/functionComparisonSchemas.js +82 -0
  67. package/dist/domain/functionDossierEvidence.js +171 -0
  68. package/dist/domain/hopperValues.js +14 -7
  69. package/dist/domain/nativeInspection.js +142 -0
  70. package/dist/domain/processCapture.js +152 -54
  71. package/dist/domain/processComparison.js +106 -0
  72. package/dist/domain/reconstructionUnknowns.js +90 -0
  73. package/dist/domain/reconstructionVerification.js +285 -0
  74. package/dist/domain/reconstructionVerificationSchemas.js +126 -0
  75. package/dist/domain/referenceSourceClassification.js +496 -0
  76. package/dist/domain/referenceSourceGraph.js +376 -0
  77. package/dist/domain/referenceSourceImportParsing.js +235 -0
  78. package/dist/domain/referenceSourcePolicy.js +1 -0
  79. package/dist/domain/residualUnknown.js +239 -0
  80. package/dist/domain/staticRuntimeCorrelation.js +375 -0
  81. package/dist/hopper/BridgeLauncher.js +40 -3
  82. package/dist/hopper/HopperClient.js +14 -5
  83. package/dist/hopper/HopperProvider.js +57 -22
  84. package/dist/identity.js +1 -0
  85. package/dist/main.js +5 -1
  86. package/dist/native/CommandRunner.js +156 -0
  87. package/dist/native/NativeMacOSProvider.js +306 -0
  88. package/dist/native/NativeMachoInspection.js +135 -0
  89. package/dist/native/parsers/codesign.js +55 -0
  90. package/dist/native/parsers/demangle.js +26 -0
  91. package/dist/native/parsers/dyldInfo.js +25 -0
  92. package/dist/native/parsers/lipo.js +67 -0
  93. package/dist/native/parsers/otool.js +193 -0
  94. package/dist/native/parsers/plist.js +23 -0
  95. package/dist/reference/ReferenceSourceReader.js +73 -0
  96. package/dist/reference/ReferenceSourceReaderEntries.js +206 -0
  97. package/dist/reference/ReferenceSourceReaderErrors.js +19 -0
  98. package/dist/reference/ReferenceSourceReaderFile.js +119 -0
  99. package/dist/reference/ReferenceSourceReaderPaths.js +23 -0
  100. package/dist/reference/ReferenceSourceReaderTypes.js +2 -0
  101. package/dist/reference/ReferenceSourceReaderValidate.js +71 -0
  102. package/dist/server/createServer.js +31 -5
  103. package/dist/server/recordDerivedEvidence.js +10 -0
  104. package/dist/server/registerArtifactComparisonTool.js +62 -0
  105. package/dist/server/registerArtifactTools.js +6 -0
  106. package/dist/server/registerBundleComparisonTool.js +47 -0
  107. package/dist/server/registerEnhancedTools.js +64 -12
  108. package/dist/server/registerEvidenceTools.js +36 -0
  109. package/dist/server/registerFunctionComparisonTool.js +68 -0
  110. package/dist/server/registerInvestigationTools.js +224 -0
  111. package/dist/server/registerNativeTools.js +6 -0
  112. package/dist/server/registerOfficialTools.js +47 -14
  113. package/dist/server/registerProcessComparisonTool.js +106 -0
  114. package/dist/server/registerSessionTools.js +179 -70
  115. package/dist/server/sessionEvidence.js +28 -0
  116. package/dist/server/sessionToolPolicies.js +64 -0
  117. package/dist/server/toolRegistrationOptions.js +7 -0
  118. package/dist/server/toolResult.js +8 -5
  119. package/install.sh +198 -0
  120. package/package.json +18 -1
  121. package/scripts/rea.mjs +5 -1
  122. package/skills/rea-analysis/SKILL.md +77 -2
@@ -0,0 +1,186 @@
1
+ import { createHash } from "node:crypto";
2
+ import { execFile } from "node:child_process";
3
+ import { access, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { promisify } from "node:util";
7
+ import { z } from "zod";
8
+ const execFileAsync = promisify(execFile);
9
+ const RELEASES_URL = "https://www.hopperapp.com/include/files-api.php?request=releases&public=true";
10
+ const DOWNLOAD_PREFIX = "https://www.hopperapp.com:443/downloader/public/";
11
+ const MAX_PACKAGE_BYTES = 100_000_000;
12
+ /** Parse an os-release document without executing its shell syntax. */
13
+ export const parseLinuxDistribution = (text) => {
14
+ const fields = new Map();
15
+ for (const line of text.split("\n")) {
16
+ const match = /^([A-Z_]+)=(.*)$/u.exec(line.trim());
17
+ if (match === null)
18
+ continue;
19
+ const key = match[1];
20
+ const encoded = match[2];
21
+ if (key === undefined || encoded === undefined)
22
+ continue;
23
+ const value = encoded.startsWith('"') && encoded.endsWith('"')
24
+ ? encoded.slice(1, -1).replaceAll('\\"', '"').replaceAll("\\\\", "\\")
25
+ : encoded;
26
+ fields.set(key, value);
27
+ }
28
+ const id = (fields.get("ID") ?? "unknown").toLowerCase();
29
+ const versionId = fields.get("VERSION_ID");
30
+ const packageFamily = packageFamilyFor(id, fields.get("ID_LIKE"));
31
+ const supported = id === "arch" ||
32
+ (id === "ubuntu" && versionAtLeast(versionId, 24)) ||
33
+ (id === "fedora" && versionAtLeast(versionId, 41));
34
+ return {
35
+ id,
36
+ ...(versionId === undefined ? {} : { versionId }),
37
+ packageFamily,
38
+ supported,
39
+ };
40
+ };
41
+ /** Read and classify the current Linux distribution. */
42
+ export const readLinuxDistribution = async () => {
43
+ try {
44
+ return parseLinuxDistribution(await readFile("/etc/os-release", "utf8"));
45
+ }
46
+ catch {
47
+ return undefined;
48
+ }
49
+ };
50
+ /** Download, verify, install, and read back the official Hopper Linux package. */
51
+ export const installLinuxHopper = async (host = systemLinuxHopperInstallHost(), options = {}) => {
52
+ if (options.signal?.aborted === true)
53
+ return { status: "failed", reason: "cancelled" };
54
+ const distribution = await host.distribution();
55
+ if (distribution?.supported !== true ||
56
+ distribution.packageFamily === undefined)
57
+ return { status: "failed", reason: "unsupported_host" };
58
+ let temporary;
59
+ try {
60
+ const metadataDownload = await host.download(RELEASES_URL, options);
61
+ if (!metadataDownload.ok)
62
+ return { status: "failed", reason: "release_metadata" };
63
+ const releases = parseReleases(metadataDownload.bytes);
64
+ if (releases === undefined)
65
+ return { status: "failed", reason: "release_metadata" };
66
+ const release = releases[releaseKey(distribution.packageFamily)];
67
+ if (!release.filename.startsWith(DOWNLOAD_PREFIX))
68
+ return { status: "failed", reason: "release_metadata" };
69
+ const archiveDownload = await host.download(release.filename, options);
70
+ if (!archiveDownload.ok)
71
+ return { status: "failed", reason: "download" };
72
+ if (!packageIntegrityMatches(archiveDownload.bytes, release))
73
+ return { status: "failed", reason: "integrity" };
74
+ temporary = await host.createTemporaryDirectory();
75
+ const archive = join(temporary, `hopper.${distribution.packageFamily}`);
76
+ await host.writeArchive(archive, archiveDownload.bytes);
77
+ if (!(await host.installPackage(distribution.packageFamily, archive)))
78
+ return { status: "failed", reason: "authorization_or_package_manager" };
79
+ const launcherPath = "/opt/hopper/bin/Hopper";
80
+ if (!(await host.launcherReady(launcherPath)))
81
+ return { status: "failed", reason: "launcher_missing" };
82
+ return { status: "installed", launcherPath };
83
+ }
84
+ catch (cause) {
85
+ if (isAbortCause(cause))
86
+ return { status: "failed", reason: "cancelled" };
87
+ return { status: "failed", reason: "download" };
88
+ }
89
+ finally {
90
+ if (temporary !== undefined)
91
+ await host.cleanup(temporary);
92
+ }
93
+ };
94
+ /** Canonical launcher path for a legacy user-local Linux Hopper installation. */
95
+ export const linuxHopperLauncherPath = (home) => join(home, ".local/share/rea/hopper/bin/Hopper");
96
+ const systemLinuxHopperInstallHost = () => ({
97
+ distribution: readLinuxDistribution,
98
+ async download(url, options) {
99
+ const response = await fetch(url, {
100
+ headers: { accept: "application/json", "user-agent": "rea-installer" },
101
+ ...(options.signal === undefined ? {} : { signal: options.signal }),
102
+ });
103
+ return {
104
+ ok: response.ok,
105
+ bytes: new Uint8Array(await response.arrayBuffer()),
106
+ };
107
+ },
108
+ createTemporaryDirectory: () => mkdtemp(join(tmpdir(), "rea-hopper-")),
109
+ writeArchive: (path, bytes) => writeFile(path, bytes, { mode: 0o600 }),
110
+ installPackage: installSystemPackage,
111
+ async launcherReady(path) {
112
+ try {
113
+ await access(path);
114
+ const linked = await execFileAsync("ldd", [path]);
115
+ return linuxSharedLibrariesAvailable(`${linked.stdout}\n${linked.stderr}`);
116
+ }
117
+ catch {
118
+ return false;
119
+ }
120
+ },
121
+ cleanup: (path) => rm(path, { recursive: true, force: true }),
122
+ });
123
+ /** Interpret ldd output conservatively so a present but broken launcher is unhealthy. */
124
+ export const linuxSharedLibrariesAvailable = (output) => !/(?:=>\s+not found|error while loading shared libraries)/iu.test(output);
125
+ const installSystemPackage = async (family, archive) => {
126
+ try {
127
+ const command = linuxPackageManagerCommand(family, archive, process.getuid?.() === 0);
128
+ await execFileAsync(command.executable, command.args);
129
+ return true;
130
+ }
131
+ catch {
132
+ return false;
133
+ }
134
+ };
135
+ /** Select the native package manager and authorization boundary without shell evaluation. */
136
+ export const linuxPackageManagerCommand = (family, archive, isRoot) => {
137
+ const command = family === "deb"
138
+ ? ["apt-get", "install", "-y", archive]
139
+ : family === "rpm"
140
+ ? ["dnf", "install", "-y", archive]
141
+ : ["pacman", "-U", "--noconfirm", archive];
142
+ const executable = isRoot ? command[0] : "pkexec";
143
+ if (executable === undefined)
144
+ throw new Error("package command is empty");
145
+ return { executable, args: isRoot ? command.slice(1) : command };
146
+ };
147
+ const parseReleases = (bytes) => {
148
+ try {
149
+ const parsed = JSON.parse(new TextDecoder().decode(bytes));
150
+ const result = releasesSchema.safeParse(parsed);
151
+ return result.success ? result.data : undefined;
152
+ }
153
+ catch {
154
+ return undefined;
155
+ }
156
+ };
157
+ const packageIntegrityMatches = (bytes, release) => bytes.byteLength === Number(release.file_length) &&
158
+ bytes.byteLength <= MAX_PACKAGE_BYTES &&
159
+ createHash("sha1").update(bytes).digest("hex") === release.file_hash;
160
+ const packageFamilyFor = (id, idLike) => {
161
+ const identities = new Set([
162
+ id,
163
+ ...(idLike?.toLowerCase().split(/\s+/u) ?? []),
164
+ ]);
165
+ if (identities.has("ubuntu") || identities.has("debian"))
166
+ return "deb";
167
+ if (identities.has("fedora") || identities.has("rhel"))
168
+ return "rpm";
169
+ if (identities.has("arch"))
170
+ return "arch";
171
+ return undefined;
172
+ };
173
+ const versionAtLeast = (version, minimum) => version !== undefined &&
174
+ Number.parseInt(version.split(".")[0] ?? "0", 10) >= minimum;
175
+ const releaseKey = (family) => family === "deb" ? "Ubuntu / Mint" : family === "rpm" ? "Fedora" : "Arch";
176
+ const isAbortCause = (cause) => cause instanceof Error && cause.name === "AbortError";
177
+ const releaseSchema = z.object({
178
+ filename: z.string().url(),
179
+ file_length: z.string().regex(/^\d+$/u),
180
+ file_hash: z.string().regex(/^[a-f0-9]{40}$/u),
181
+ });
182
+ const releasesSchema = z.object({
183
+ "Ubuntu / Mint": releaseSchema,
184
+ Fedora: releaseSchema,
185
+ Arch: releaseSchema,
186
+ });
@@ -1,34 +1,171 @@
1
- import { createServer } from "node:http";
1
+ import { createServer, } from "node:http";
2
2
  import { WebSocketServer } from "ws";
3
- const closeServer = async (server) => new Promise((resolveClose, rejectClose) => {
4
- server.close((error) => error === undefined ? resolveClose() : rejectClose(error));
5
- });
6
- /** Start bounded HTTP and WebSocket replay endpoints bound only to IPv4 loopback. */
7
- export const startLoopbackReplay = async (scenario) => {
8
- const events = [];
9
- const record = (event) => {
10
- if (events.length < 10_000)
11
- events.push({ sequence: events.length, ...event });
12
- };
13
- const server = createServer((request, response) => {
3
+ class ReplayRecorder {
4
+ scenario;
5
+ events = [];
6
+ started = Date.now();
7
+ truncated = false;
8
+ constructor(scenario) {
9
+ this.scenario = scenario;
10
+ }
11
+ atMs() {
12
+ return (Math.floor((Date.now() - this.started) /
13
+ this.scenario.normalization.time_bucket_ms) * this.scenario.normalization.time_bucket_ms);
14
+ }
15
+ record(event) {
16
+ if (this.events.length < this.scenario.limits.protocol_events)
17
+ this.events.push({ sequence: this.events.length, ...event });
18
+ else
19
+ this.truncated = true;
20
+ }
21
+ }
22
+ const readRequestBody = async (request, limit) => {
23
+ const chunks = [];
24
+ let bytes = 0;
25
+ for await (const chunk of request) {
26
+ const value = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
27
+ bytes += value.byteLength;
28
+ if (bytes > limit)
29
+ return undefined;
30
+ chunks.push(value);
31
+ }
32
+ return Buffer.concat(chunks).toString("utf8");
33
+ };
34
+ const createHttpHandler = (recorder) => {
35
+ const calls = new Map();
36
+ return async (request, response) => {
14
37
  const method = request.method ?? "GET";
15
38
  const path = new URL(request.url ?? "/", "http://127.0.0.1").pathname;
16
- record({ protocol: "http", direction: "request", method, path, data: "" });
17
- const route = scenario.replay.http.find((candidate) => candidate.method === method && candidate.path === path);
18
- response.statusCode = route?.status ?? 404;
19
- const body = route?.body ?? "";
20
- response.end(body);
21
- record({
39
+ const body = await readRequestBody(request, recorder.scenario.limits.protocol_body_bytes);
40
+ if (body === undefined) {
41
+ recorder.truncated = true;
42
+ response.statusCode = 413;
43
+ response.end();
44
+ recorder.record({
45
+ at_ms: recorder.atMs(),
46
+ protocol: "http",
47
+ direction: "request",
48
+ method,
49
+ path,
50
+ data: "<body-over-limit>",
51
+ outcome: "unmatched",
52
+ });
53
+ return;
54
+ }
55
+ const index = recorder.scenario.replay.http.findIndex((route) => {
56
+ if (route.method !== method || route.path !== path)
57
+ return false;
58
+ if (route.request_body !== undefined && route.request_body !== body)
59
+ return false;
60
+ return Object.entries(route.request_headers).every(([name, value]) => request.headers[name.toLowerCase()] === value);
61
+ });
62
+ const route = recorder.scenario.replay.http[index];
63
+ const used = index < 0 ? 0 : (calls.get(index) ?? 0);
64
+ const outcome = route === undefined
65
+ ? "unmatched"
66
+ : used >= route.max_calls
67
+ ? "script_exhausted"
68
+ : "matched";
69
+ recorder.record({
70
+ at_ms: recorder.atMs(),
71
+ protocol: "http",
72
+ direction: "request",
73
+ method,
74
+ path,
75
+ data: body.length === 0 ? "" : "<redacted-request-body>",
76
+ outcome,
77
+ });
78
+ if (route === undefined || outcome === "script_exhausted") {
79
+ response.statusCode = route === undefined ? 404 : 409;
80
+ response.end();
81
+ return;
82
+ }
83
+ calls.set(index, used + 1);
84
+ if (route.delay_ms > 0)
85
+ await new Promise((resolveDelay) => setTimeout(resolveDelay, route.delay_ms));
86
+ if (route.disconnect) {
87
+ response.destroy();
88
+ recorder.record({
89
+ at_ms: recorder.atMs(),
90
+ protocol: "http",
91
+ direction: "response",
92
+ method,
93
+ path,
94
+ data: "",
95
+ outcome: "disconnected",
96
+ });
97
+ return;
98
+ }
99
+ response.statusCode = route.status;
100
+ for (const [name, value] of Object.entries(route.response_headers))
101
+ response.setHeader(name, value);
102
+ response.end(route.body);
103
+ recorder.record({
104
+ at_ms: recorder.atMs(),
22
105
  protocol: "http",
23
106
  direction: "response",
24
107
  method,
25
108
  path,
26
- data: body,
109
+ data: route.body,
110
+ outcome: "matched",
27
111
  });
28
- });
112
+ };
113
+ };
114
+ const sendWebSocketScript = async (client, connection, recorder) => {
115
+ const scripts = recorder.scenario.replay.websocket_connections;
116
+ const script = scripts[connection - 1];
117
+ if (scripts.length > 0 && script === undefined) {
118
+ recorder.record({
119
+ at_ms: recorder.atMs(),
120
+ protocol: "websocket",
121
+ direction: "sent",
122
+ method: null,
123
+ path: "/ws",
124
+ data: "",
125
+ outcome: "script_exhausted",
126
+ });
127
+ client.terminate();
128
+ return;
129
+ }
130
+ const messages = script?.messages ??
131
+ recorder.scenario.replay.websocket_messages.map((data) => ({
132
+ data,
133
+ delay_ms: 0,
134
+ }));
135
+ for (const message of messages) {
136
+ if (message.delay_ms > 0)
137
+ await new Promise((resolveDelay) => setTimeout(resolveDelay, message.delay_ms));
138
+ if (client.readyState !== 1)
139
+ return;
140
+ client.send(message.data);
141
+ recorder.record({
142
+ at_ms: recorder.atMs(),
143
+ protocol: "websocket",
144
+ direction: "sent",
145
+ method: null,
146
+ path: "/ws",
147
+ data: message.data,
148
+ outcome: "matched",
149
+ });
150
+ }
151
+ if (script?.disconnect_after === true) {
152
+ client.close();
153
+ recorder.record({
154
+ at_ms: recorder.atMs(),
155
+ protocol: "websocket",
156
+ direction: "sent",
157
+ method: null,
158
+ path: "/ws",
159
+ data: "",
160
+ outcome: "disconnected",
161
+ });
162
+ }
163
+ };
164
+ const createWebSocketReplay = (server, recorder) => {
165
+ let connections = 0;
29
166
  const websocket = new WebSocketServer({
30
167
  noServer: true,
31
- maxPayload: 1_000_000,
168
+ maxPayload: recorder.scenario.limits.protocol_body_bytes,
32
169
  clientTracking: true,
33
170
  });
34
171
  server.on("upgrade", (request, socket, head) => {
@@ -39,25 +176,27 @@ export const startLoopbackReplay = async (scenario) => {
39
176
  websocket.handleUpgrade(request, socket, head, (client) => websocket.emit("connection", client, request));
40
177
  });
41
178
  websocket.on("connection", (client) => {
179
+ connections += 1;
180
+ if (connections > recorder.scenario.limits.connections) {
181
+ recorder.truncated = true;
182
+ client.terminate();
183
+ return;
184
+ }
42
185
  client.on("error", () => undefined);
43
- client.on("message", (value) => record({
186
+ client.on("message", (value) => recorder.record({
187
+ at_ms: recorder.atMs(),
44
188
  protocol: "websocket",
45
189
  direction: "received",
46
190
  method: null,
47
191
  path: "/ws",
48
192
  data: value.toString(),
193
+ outcome: "matched",
49
194
  }));
50
- for (const message of scenario.replay.websocket_messages) {
51
- client.send(message);
52
- record({
53
- protocol: "websocket",
54
- direction: "sent",
55
- method: null,
56
- path: "/ws",
57
- data: message,
58
- });
59
- }
195
+ void sendWebSocketScript(client, connections, recorder);
60
196
  });
197
+ return websocket;
198
+ };
199
+ const listen = async (server) => {
61
200
  await new Promise((resolveListen, rejectListen) => {
62
201
  server.once("error", rejectListen);
63
202
  server.listen(0, "127.0.0.1", () => {
@@ -66,14 +205,33 @@ export const startLoopbackReplay = async (scenario) => {
66
205
  });
67
206
  });
68
207
  const address = server.address();
69
- if (address === null || typeof address === "string") {
70
- await closeServer(server);
208
+ if (address === null || typeof address === "string")
71
209
  throw new Error("loopback replay did not acquire a TCP port");
210
+ return address.port;
211
+ };
212
+ const closeServer = (server) => new Promise((resolveClose, rejectClose) => {
213
+ server.close((error) => error === undefined ? resolveClose() : rejectClose(error));
214
+ });
215
+ /** Start bounded HTTP and WebSocket replay endpoints on IPv4 loopback only. */
216
+ export const startLoopbackReplay = async (scenario) => {
217
+ const recorder = new ReplayRecorder(scenario);
218
+ const server = createServer(createHttpHandler(recorder));
219
+ const websocket = createWebSocketReplay(server, recorder);
220
+ let port;
221
+ try {
222
+ port = await listen(server);
223
+ }
224
+ catch (cause) {
225
+ await closeServer(server).catch(() => undefined);
226
+ throw cause;
72
227
  }
73
228
  return {
74
- httpUrl: `http://127.0.0.1:${String(address.port)}`,
75
- websocketUrl: `ws://127.0.0.1:${String(address.port)}/ws`,
76
- events,
229
+ httpUrl: `http://127.0.0.1:${String(port)}`,
230
+ websocketUrl: `ws://127.0.0.1:${String(port)}/ws`,
231
+ events: recorder.events,
232
+ get truncated() {
233
+ return recorder.truncated;
234
+ },
77
235
  async close() {
78
236
  for (const client of websocket.clients)
79
237
  client.terminate();