rea-agents 2.2.0 → 2.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 (179) hide show
  1. package/README.md +57 -10
  2. package/dist/application/ApplicationWorkflowEvidenceResolver.js +162 -0
  3. package/dist/application/ArtifactExtraction.js +5 -1
  4. package/dist/application/ArtifactGraphConstruction.js +3 -15
  5. package/dist/application/ArtifactInventory/classify.js +5 -1
  6. package/dist/application/ArtifactInventory/reader.js +2 -0
  7. package/dist/application/BinarySession.js +9 -2
  8. package/dist/application/BinarySessionStatus.js +40 -1
  9. package/dist/application/CapabilityInventory.js +26 -3
  10. package/dist/application/CommandShimReplay.js +6 -3
  11. package/dist/application/CompositeProvider.js +5 -2
  12. package/dist/application/DirectAnalysis.js +1 -20
  13. package/dist/application/DirectAnalysisStatus.js +83 -0
  14. package/dist/application/Doctor.js +34 -25
  15. package/dist/application/DoctorDiagnostics.js +1 -4
  16. package/dist/application/DoctorProjection.js +43 -0
  17. package/dist/application/DoctorScope.js +102 -0
  18. package/dist/application/EvidenceReferenceResolver.js +26 -0
  19. package/dist/application/JavaScriptApplicationEvidence.js +1 -1
  20. package/dist/application/JavaScriptApplicationEvidenceGraph.js +10 -1
  21. package/dist/application/JavaScriptApplicationService.js +3 -2
  22. package/dist/application/JavaScriptApplicationWorkflowEvidence.js +26 -0
  23. package/dist/application/JavaScriptApplicationWorkflowService.js +49 -1
  24. package/dist/application/JavaScriptArtifactReconstruction.js +7 -0
  25. package/dist/application/JavaScriptModuleRelationships.js +25 -1
  26. package/dist/application/JavaScriptReturnShapeProjection.js +111 -0
  27. package/dist/application/JavaScriptSemanticGraphBuilder.js +294 -0
  28. package/dist/application/JavaScriptSemanticGraphConstruction.js +144 -0
  29. package/dist/application/JavaScriptSemanticGraphEvidence.js +86 -0
  30. package/dist/application/JavaScriptSemanticGraphFlowProjection.js +72 -0
  31. package/dist/application/JavaScriptSemanticGraphProjection.js +33 -0
  32. package/dist/application/JavaScriptSemanticTraceService.js +45 -0
  33. package/dist/application/LoopbackReplay.js +277 -133
  34. package/dist/application/LoopbackReplayRecorder.js +101 -0
  35. package/dist/application/PermissionAuthority.js +7 -3
  36. package/dist/application/ProcessCaptureEnvironment.js +26 -0
  37. package/dist/application/ProcessCaptureJournal.js +95 -0
  38. package/dist/application/ProcessCaptureLifecycle.js +25 -13
  39. package/dist/application/ProcessCheckpoints.js +35 -4
  40. package/dist/application/ProcessCli.js +26 -4
  41. package/dist/application/ProcessHarness.js +64 -83
  42. package/dist/application/ProcessNormalization.js +9 -2
  43. package/dist/application/ProcessReactiveCoordinator.js +225 -0
  44. package/dist/application/ProcessReactiveEffects.js +141 -0
  45. package/dist/application/ProcessReactiveHarness.js +85 -0
  46. package/dist/application/ProcessReactiveObservations.js +105 -0
  47. package/dist/application/ProcessSampling.js +2 -0
  48. package/dist/application/SessionProviderRouter.js +7 -6
  49. package/dist/application/Setup.js +11 -10
  50. package/dist/application/SetupPlan.js +1 -1
  51. package/dist/application/SetupRegistrationEnvironment.js +12 -0
  52. package/dist/application/TerminalRenderer.js +3 -1
  53. package/dist/browser/CdpEndpoint.js +37 -4
  54. package/dist/catalogIdentity.js +16 -7
  55. package/dist/cli/coreAnalysisCommands.js +34 -2
  56. package/dist/cli/javascriptApplicationAnalysis.js +19 -0
  57. package/dist/cli/setupCommands.js +81 -32
  58. package/dist/cli/utilityCommands.js +10 -2
  59. package/dist/cliApplicationCommands.js +79 -16
  60. package/dist/cliCommandNames.js +3 -0
  61. package/dist/cliElectronCommands.js +23 -33
  62. package/dist/cliObservationOptions.js +1 -1
  63. package/dist/cliOutput.js +56 -0
  64. package/dist/cliProcessCommands.js +32 -1
  65. package/dist/cliSetup.js +30 -21
  66. package/dist/contracts/applicationToolContracts.js +54 -7
  67. package/dist/contracts/applicationWorkflowInputContracts.js +119 -0
  68. package/dist/contracts/electronToolContracts.js +53 -32
  69. package/dist/contracts/javascriptApplicationWorkflowExamples.js +32 -9
  70. package/dist/contracts/processCaptureExample.js +1 -0
  71. package/dist/contracts/replayMachineExample.js +37 -0
  72. package/dist/contracts/toolContractExamples.js +2 -0
  73. package/dist/contracts/toolContracts.js +5 -1
  74. package/dist/contracts/toolEffects.js +3 -0
  75. package/dist/contracts/toolOutputSchemaGroups.js +2 -0
  76. package/dist/contracts/toolOutputSchemaPrimitives.js +43 -0
  77. package/dist/doctorRuntime.js +41 -0
  78. package/dist/domain/analysisErrorPresentation.js +5 -3
  79. package/dist/domain/analysisErrorProjection.js +7 -1
  80. package/dist/domain/analysisSnapshot.js +2 -0
  81. package/dist/domain/artifactGraph.js +2 -0
  82. package/dist/domain/binaryTarget.js +3 -6
  83. package/dist/domain/changedBehavior.js +5 -4
  84. package/dist/domain/completionLedgerGeneration.js +190 -0
  85. package/dist/domain/errors.js +4 -2
  86. package/dist/domain/evidence.js +2 -0
  87. package/dist/domain/evidenceCompletionLedger.js +113 -0
  88. package/dist/domain/hopperStartupFailure.js +9 -0
  89. package/dist/domain/javascriptApplicationAnalysis.js +44 -2
  90. package/dist/domain/javascriptExportShapeComparison.js +145 -0
  91. package/dist/domain/javascriptExportShapeComparisonIdentity.js +11 -0
  92. package/dist/domain/javascriptExportShapeComparisonSchemas.js +277 -0
  93. package/dist/domain/javascriptExportShapeSelection.js +132 -0
  94. package/dist/domain/javascriptExportShapeVariants.js +272 -0
  95. package/dist/domain/javascriptRuntimeReconciliationParsing.js +2 -2
  96. package/dist/domain/javascriptSemanticAnalysis.js +17 -36
  97. package/dist/domain/javascriptSemanticCallResolution.js +129 -0
  98. package/dist/domain/javascriptSemanticCalls.js +269 -0
  99. package/dist/domain/javascriptSemanticGraph.js +356 -0
  100. package/dist/domain/javascriptSemanticGraphSchemas.js +329 -0
  101. package/dist/domain/javascriptSemanticGraphSerialization.js +23 -0
  102. package/dist/domain/javascriptSemanticIr.js +14 -1
  103. package/dist/domain/javascriptSemanticPrimitives.js +36 -0
  104. package/dist/domain/javascriptSemanticProjection.js +36 -4
  105. package/dist/domain/javascriptSemanticProvenance.js +29 -0
  106. package/dist/domain/javascriptSemanticQuery.js +294 -0
  107. package/dist/domain/javascriptSemanticQueryAssessment.js +74 -0
  108. package/dist/domain/javascriptSemanticQueryIdentity.js +86 -0
  109. package/dist/domain/javascriptSemanticQueryRelations.js +15 -0
  110. package/dist/domain/javascriptSemanticQuerySchemas.js +188 -0
  111. package/dist/domain/javascriptSemanticReturns.js +187 -0
  112. package/dist/domain/javascriptSemanticState.js +32 -0
  113. package/dist/domain/javascriptSemanticTraceSchemas.js +14 -0
  114. package/dist/domain/javascriptSemanticValues.js +54 -70
  115. package/dist/domain/processCapture.js +100 -0
  116. package/dist/domain/processCaptureReactiveValidation.js +175 -0
  117. package/dist/domain/processCaptureValidation.js +103 -12
  118. package/dist/domain/processComparison.js +78 -25
  119. package/dist/domain/processObservation.js +162 -0
  120. package/dist/domain/processReactiveCheckpointDataflow.js +32 -0
  121. package/dist/domain/processReactiveMatching.js +210 -0
  122. package/dist/domain/processReactiveRuntime.js +120 -0
  123. package/dist/domain/processReactiveScenario.js +361 -0
  124. package/dist/domain/processReactiveScenarioPreflight.js +120 -0
  125. package/dist/domain/processReactiveTransition.js +161 -0
  126. package/dist/domain/processScenario.js +33 -0
  127. package/dist/domain/processTraceComparison.js +43 -0
  128. package/dist/domain/processTraceDimensionProjection.js +50 -0
  129. package/dist/domain/processTraceEvaluation.js +312 -0
  130. package/dist/domain/processTraceSpecification.js +373 -0
  131. package/dist/domain/reconstructionVerification.js +5 -4
  132. package/dist/domain/replayMachine.js +18 -2
  133. package/dist/domain/replayMachineRun.js +216 -0
  134. package/dist/domain/replayMachineRuntime.js +88 -9
  135. package/dist/domain/staticRuntimeCorrelation.js +4 -2
  136. package/dist/domain/zipPackageFormat.js +20 -0
  137. package/dist/evaluation/CodexAgentEval.js +24 -0
  138. package/dist/generatedPackageMetadata.js +1 -1
  139. package/dist/ghidra/GhidraClient.js +13 -2
  140. package/dist/ghidra/GhidraDiagnostics.js +1 -0
  141. package/dist/ghidra/GhidraLauncher.js +7 -3
  142. package/dist/ghidra/GhidraProvider.js +8 -1
  143. package/dist/hopper/BridgeLauncher.js +56 -14
  144. package/dist/hopper/HopperClient.js +23 -10
  145. package/dist/hopper/HopperDiagnostics.js +14 -0
  146. package/dist/hopper/HopperProcessDiagnostic.js +14 -0
  147. package/dist/hopper/HopperProvider.js +8 -1
  148. package/dist/hopper/LinuxPrivateDisplayDiagnostic.js +72 -0
  149. package/dist/hopper/LinuxPrivateDisplayProbe.js +217 -0
  150. package/dist/main/transport.js +2 -0
  151. package/dist/mcpDoctor.js +310 -0
  152. package/dist/native/NativeMacOSProvider.js +1 -0
  153. package/dist/process/ProcessOwnership.js +200 -53
  154. package/dist/process/ProviderRunLineage.js +21 -0
  155. package/dist/server/createServer.js +4 -1
  156. package/dist/server/javascriptApplicationResult.js +22 -1
  157. package/dist/server/registerApplicationTools/characterization.js +3 -3
  158. package/dist/server/registerApplicationTools/compareExportShapes.js +37 -0
  159. package/dist/server/registerApplicationTools/compareVersions.js +12 -6
  160. package/dist/server/registerApplicationTools/controlledReplay.js +2 -2
  161. package/dist/server/registerApplicationTools/coverage.js +3 -3
  162. package/dist/server/registerApplicationTools/helpers.js +37 -25
  163. package/dist/server/registerApplicationTools/traceFeature.js +9 -5
  164. package/dist/server/registerApplicationTools/traceSemantics.js +30 -0
  165. package/dist/server/registerApplicationTools.js +4 -0
  166. package/dist/server/registerEvidenceResources.js +5 -1
  167. package/dist/server/registerProcessComparisonTool.js +17 -7
  168. package/dist/server/registerReplayMachineTool.js +20 -0
  169. package/dist/server/registerSessionTools.js +12 -7
  170. package/dist/server/sessionAvailabilityPolicy.js +2 -1
  171. package/dist/serverIdentity.js +19 -0
  172. package/package.json +62 -30
  173. package/scripts/hopper-demo-x11.py +459 -61
  174. package/scripts/prepack.mjs +55 -0
  175. package/scripts/prepare.mjs +28 -0
  176. package/scripts/rea.mjs +26 -5
  177. package/skills/reverse-engineer-anything/SKILL.md +4 -3
  178. package/skills/reverse-engineer-anything/references/javascript-applications.md +10 -0
  179. package/skills/reverse-engineer-anything/references/native-and-artifacts.md +2 -2
@@ -0,0 +1,72 @@
1
+ import { createJavaScriptSemanticGraphUnknown } from "../domain/javascriptSemanticGraph.js";
2
+ import { addSemanticGraphRelation, addSemanticGraphUnknown, } from "./JavaScriptSemanticGraphConstruction.js";
3
+ import { inferredSemanticEvidenceAt, unknownSemanticEvidence, } from "./JavaScriptSemanticGraphEvidence.js";
4
+ import { semanticNodesWithinRange } from "./JavaScriptSemanticGraphProjection.js";
5
+ /** Link direct return expressions to their retained semantic references. */
6
+ export const projectSemanticReturnValues = (context) => {
7
+ for (const callable of context.ir.callables)
8
+ for (const site of callable.returnSites) {
9
+ const returnNode = context.returnSiteNodes.get(site.returnSiteId);
10
+ const references = semanticNodesWithinRange(context.referenceNodes, site.location);
11
+ for (const reference of references)
12
+ addSemanticGraphRelation(context.state, {
13
+ source: reference,
14
+ target: returnNode,
15
+ relation: "aliases",
16
+ resolution: site.identityReferenceLocation !== null &&
17
+ rangesEqual(reference.identity.source_range, site.identityReferenceLocation)
18
+ ? "resolved"
19
+ : "candidate",
20
+ });
21
+ }
22
+ };
23
+ const rangesEqual = (left, right) => left !== null &&
24
+ right !== null &&
25
+ left.start.line === right.start.line &&
26
+ left.start.column === right.start.column &&
27
+ left.end.line === right.end.line &&
28
+ left.end.column === right.end.column;
29
+ /** Link each captured binding to the callable at the exact reference site. */
30
+ export const projectSemanticClosureCaptures = (context) => {
31
+ for (const capture of context.ir.closureCaptures)
32
+ addSemanticGraphRelation(context.state, {
33
+ source: context.bindingNodes.get(capture.bindingId),
34
+ target: context.callableNodes.get(capture.callableId),
35
+ relation: "captures",
36
+ resolution: "resolved",
37
+ evidence: inferredSemanticEvidenceAt(context.file, capture.referenceLocation),
38
+ });
39
+ };
40
+ /** Retain bounded unresolved dynamic-call and dynamic-property frontiers. */
41
+ export const projectSemanticFrontiers = (context) => {
42
+ for (const frontier of context.ir.frontiers) {
43
+ const unknown = createJavaScriptSemanticGraphUnknown({
44
+ node_id: frontier.callableId === null
45
+ ? context.moduleNode.node_id
46
+ : (context.callableNodes.get(frontier.callableId)?.node_id ??
47
+ context.moduleNode.node_id),
48
+ family: frontier.kind === "dynamic-call" ? "call-flow" : "object-flow",
49
+ relation_kinds: frontier.kind === "dynamic-call"
50
+ ? ["calls"]
51
+ : ["reads-property", "writes-property"],
52
+ reason: frontier.kind,
53
+ detail: frontier.reason,
54
+ candidate_node_ids: [],
55
+ evidence: unknownSemanticEvidence(context.file, frontier.location),
56
+ });
57
+ addSemanticGraphUnknown(context.state, unknown);
58
+ }
59
+ };
60
+ /** Publish exact semantic analyzer limits reached by any admitted source file. */
61
+ export const semanticRecoveryLimits = (analysis) => {
62
+ const limits = new Map();
63
+ for (const { semantic } of analysis.files) {
64
+ if (semantic === null)
65
+ continue;
66
+ for (const name of semantic.ir.coverage.limitsReached)
67
+ limits.set(`semantic.${name}`, semantic.limits[name]);
68
+ }
69
+ return [...limits]
70
+ .sort(([left], [right]) => left.localeCompare(right))
71
+ .map(([name, value]) => ({ name, value, unit: "items" }));
72
+ };
@@ -0,0 +1,33 @@
1
+ import { compareCodePoints } from "../domain/javascriptApplicationGraph.js";
2
+ import { JAVASCRIPT_SEMANTIC_RELATION_FAMILIES } from "../domain/javascriptSemanticGraphSchemas.js";
3
+ /** Find the innermost callable that contains one exact source range. */
4
+ export const owningSemanticCallableNode = (location, ir, nodes) => {
5
+ if (location === null)
6
+ return undefined;
7
+ return ir.callables
8
+ .filter((callable) => contains(callable.location, location))
9
+ .sort((left, right) => compareCodePoints(positionKey(right.location), positionKey(left.location)))
10
+ .map(({ callableId }) => nodes.get(callableId))
11
+ .find((node) => node !== undefined);
12
+ };
13
+ /** Find retained semantic nodes whose exact ranges are within one expression. */
14
+ export const semanticNodesWithinRange = (nodes, range) => nodes.filter((node) => node.identity.source_range !== null &&
15
+ contains(range, node.identity.source_range));
16
+ /** Report extractor support without treating missing families as absence. */
17
+ export const semanticFamilyStatus = (family, analysis, truncated) => {
18
+ if (!["call-flow", "closure", "data-flow", "object-flow"].includes(family))
19
+ return "unsupported";
20
+ if (truncated)
21
+ return "unknown";
22
+ return analysis.truncated_scopes === 0 ? "partial" : "unknown";
23
+ };
24
+ /** Compare two exact semantic source ranges. */
25
+ export const semanticRangesEqual = (left, right) => left !== null &&
26
+ left.start.line === right.start.line &&
27
+ left.start.column === right.start.column &&
28
+ left.end.line === right.end.line &&
29
+ left.end.column === right.end.column;
30
+ const contains = (outer, inner) => pointCompare(outer.start, inner.start) <= 0 &&
31
+ pointCompare(outer.end, inner.end) >= 0;
32
+ const pointCompare = (left, right) => left.line - right.line || left.column - right.column;
33
+ const positionKey = (range) => `${String(range.start.line).padStart(12, "0")}:${String(range.start.column).padStart(12, "0")}`;
@@ -0,0 +1,45 @@
1
+ import { z } from "zod";
2
+ import { AnalysisInputError, AnalysisProtocolError, } from "../domain/errors.js";
3
+ import { jsonValueSchema } from "../domain/jsonValue.js";
4
+ import { queryJavaScriptSemanticGraph } from "../domain/javascriptSemanticQuery.js";
5
+ import { JavaScriptSemanticQueryCursorError } from "../domain/javascriptSemanticQueryIdentity.js";
6
+ import { javaScriptSemanticTraceResultSchema, traceJavaScriptSemanticsInputSchema, } from "../domain/javascriptSemanticTraceSchemas.js";
7
+ import { err, ok } from "../domain/result.js";
8
+ import { parseApplicationGraphEvidence } from "./JavaScriptApplicationEvidenceGraph.js";
9
+ import { createJavaScriptSemanticTraceEvidence } from "./JavaScriptApplicationWorkflowEvidence.js";
10
+ const OPERATION = "trace_javascript_semantics";
11
+ /** Trace semantic relations from input already parsed by a trusted adapter. */
12
+ export const traceJavaScriptSemanticsEvidenceValidated = (input) => {
13
+ try {
14
+ const source = parseApplicationGraphEvidence(input.application);
15
+ if (source.semanticGraph === null)
16
+ return err(new AnalysisInputError(OPERATION, undefined, [
17
+ {
18
+ path: ["application"],
19
+ reason: "invalid_value",
20
+ expected: "analyze_javascript_application v2 Evidence; reanalyze the artifact with the current REA version",
21
+ },
22
+ ]));
23
+ const query = queryJavaScriptSemanticGraph(source.semanticGraph, input.query);
24
+ const result = javaScriptSemanticTraceResultSchema.parse({
25
+ ...query,
26
+ source_evidence_id: source.evidence.evidence_id,
27
+ evidence_links: [source.evidence.evidence_id],
28
+ });
29
+ return ok(createJavaScriptSemanticTraceEvidence({
30
+ application_evidence_id: source.evidence.evidence_id,
31
+ query: jsonValueSchema.parse(input.query),
32
+ }, result));
33
+ }
34
+ catch (cause) {
35
+ if (cause instanceof JavaScriptSemanticQueryCursorError)
36
+ return err(new AnalysisInputError(OPERATION, undefined, [
37
+ {
38
+ path: ["query", "cursor"],
39
+ reason: "invalid_value",
40
+ expected: "a cursor returned by the same graph, query, and limits",
41
+ },
42
+ ]));
43
+ return err(new AnalysisProtocolError("JavaScript semantic trace failed", { cause }));
44
+ }
45
+ };
@@ -1,113 +1,144 @@
1
1
  import { createServer, } from "node:http";
2
2
  import { WebSocketServer } from "ws";
3
- class ReplayRecorder {
4
- scenario;
5
- events = [];
6
- started = Date.now();
7
- truncated = false;
8
- constructor(scenario) {
9
- this.scenario = scenario;
3
+ import {} from "../domain/replayMachineRuntime.js";
4
+ import { closeReplayServer, listenOnLoopback, recordMachineEvent, readReplayRequestBody, ReplayRecorder, requestHeaders, waitForReplayDelay, } from "./LoopbackReplayRecorder.js";
5
+ const recordHttpResponse = (recorder, request, data, outcome) => recorder.record({
6
+ at_ms: recorder.atMs(),
7
+ protocol: "http",
8
+ direction: "response",
9
+ method: request.method,
10
+ path: request.path,
11
+ data,
12
+ outcome,
13
+ });
14
+ const handleMachineHttp = async (recorder, request) => {
15
+ const machine = recorder.machine;
16
+ if (machine === undefined)
17
+ return;
18
+ const decision = recorder.dispatchMachine({
19
+ protocol: "http",
20
+ connection: "not_applicable",
21
+ at_ms: request.rawAtMs,
22
+ recorded_at_ms: request.recordedAtMs,
23
+ method: request.method,
24
+ path: request.path,
25
+ headers: requestHeaders(request.request),
26
+ body: request.body,
27
+ });
28
+ recordMachineEvent(recorder, {
29
+ at_ms: request.recordedAtMs,
30
+ protocol: "http",
31
+ direction: "request",
32
+ method: request.method,
33
+ path: request.path,
34
+ data: request.body,
35
+ }, decision);
36
+ if (decision.outcome !== "matched") {
37
+ request.response.statusCode = decision.outcome === "unmatched" ? 404 : 409;
38
+ request.response.end();
39
+ return;
10
40
  }
11
- atMs() {
12
- return (Math.floor((Date.now() - this.started) /
13
- this.scenario.normalization.time_bucket_ms) * this.scenario.normalization.time_bucket_ms);
41
+ for (const action of decision.actions) {
42
+ if (action.type === "delay")
43
+ await waitForReplayDelay(action.duration_ms);
44
+ else if (action.type === "disconnect") {
45
+ request.response.destroy();
46
+ recordHttpResponse(recorder, request, "", "disconnected");
47
+ return;
48
+ }
49
+ else if (action.type === "http_response") {
50
+ request.response.statusCode = action.status;
51
+ for (const [name, value] of Object.entries(action.headers))
52
+ request.response.setHeader(name, value);
53
+ request.response.end(action.body);
54
+ recordHttpResponse(recorder, request, machine.redact(action.body), "matched");
55
+ }
14
56
  }
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;
57
+ };
58
+ const handleStaticHttp = async (recorder, request, calls) => {
59
+ const index = recorder.scenario.replay.http.findIndex((route) => route.method !== request.method || route.path !== request.path
60
+ ? false
61
+ : (route.request_body === undefined ||
62
+ route.request_body === request.body) &&
63
+ Object.entries(route.request_headers).every(([name, value]) => request.request.headers[name.toLowerCase()] === value));
64
+ const route = recorder.scenario.replay.http[index];
65
+ const used = index < 0 ? 0 : (calls.get(index) ?? 0);
66
+ const outcome = route === undefined
67
+ ? "unmatched"
68
+ : used >= route.max_calls
69
+ ? "script_exhausted"
70
+ : "matched";
71
+ recorder.record({
72
+ at_ms: recorder.atMs(),
73
+ protocol: "http",
74
+ direction: "request",
75
+ method: request.method,
76
+ path: request.path,
77
+ data: request.body.length === 0 ? "" : "<redacted-request-body>",
78
+ outcome,
79
+ });
80
+ if (route === undefined || outcome === "script_exhausted") {
81
+ request.response.statusCode = route === undefined ? 404 : 409;
82
+ request.response.end();
83
+ return;
20
84
  }
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);
85
+ calls.set(index, used + 1);
86
+ if (route.delay_ms > 0)
87
+ await waitForReplayDelay(route.delay_ms);
88
+ if (route.disconnect) {
89
+ request.response.destroy();
90
+ recordHttpResponse(recorder, request, "", "disconnected");
91
+ return;
31
92
  }
32
- return Buffer.concat(chunks).toString("utf8");
93
+ request.response.statusCode = route.status;
94
+ for (const [name, value] of Object.entries(route.response_headers))
95
+ request.response.setHeader(name, value);
96
+ request.response.end(route.body);
97
+ recordHttpResponse(recorder, request, route.body, "matched");
33
98
  };
34
- const createHttpHandler = (recorder) => {
35
- const calls = new Map();
36
- return async (request, response) => {
37
- const method = request.method ?? "GET";
38
- const path = new URL(request.url ?? "/", "http://127.0.0.1").pathname;
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";
99
+ const handleHttpRequest = async (recorder, request, response, calls) => {
100
+ const body = await readReplayRequestBody(request, recorder.scenario.limits.protocol_body_bytes);
101
+ const method = request.method ?? "GET";
102
+ const path = new URL(request.url ?? "/", "http://127.0.0.1").pathname;
103
+ if (body === undefined) {
104
+ recorder.truncated = true;
105
+ response.statusCode = 413;
106
+ response.end();
69
107
  recorder.record({
70
108
  at_ms: recorder.atMs(),
71
109
  protocol: "http",
72
110
  direction: "request",
73
111
  method,
74
112
  path,
75
- data: body.length === 0 ? "" : "<redacted-request-body>",
76
- outcome,
113
+ data: "<body-over-limit>",
114
+ outcome: "unmatched",
77
115
  });
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(),
105
- protocol: "http",
106
- direction: "response",
107
- method,
108
- path,
109
- data: route.body,
110
- outcome: "matched",
116
+ return;
117
+ }
118
+ const replayRequest = {
119
+ request,
120
+ response,
121
+ method,
122
+ path,
123
+ body,
124
+ rawAtMs: recorder.rawAtMs(),
125
+ recordedAtMs: recorder.atMs(),
126
+ };
127
+ await (recorder.machine === undefined
128
+ ? handleStaticHttp(recorder, replayRequest, calls)
129
+ : recorder.enqueueMachine(() => handleMachineHttp(recorder, replayRequest)));
130
+ };
131
+ const createHttpHandler = (recorder) => {
132
+ const calls = new Map();
133
+ return (request, response) => {
134
+ void handleHttpRequest(recorder, request, response, calls).catch(() => {
135
+ recorder.truncated = true;
136
+ if (response.headersSent)
137
+ response.destroy();
138
+ else {
139
+ response.statusCode = 500;
140
+ response.end();
141
+ }
111
142
  });
112
143
  };
113
144
  };
@@ -161,6 +192,119 @@ const sendWebSocketScript = async (client, connection, recorder) => {
161
192
  });
162
193
  }
163
194
  };
195
+ const runWebSocketActions = async (client, recorder, decision, path) => {
196
+ for (const action of decision.actions) {
197
+ if (action.type === "delay") {
198
+ await waitForReplayDelay(action.duration_ms);
199
+ continue;
200
+ }
201
+ if (action.type === "disconnect") {
202
+ client.close();
203
+ recorder.record({
204
+ at_ms: recorder.atMs(),
205
+ protocol: "websocket",
206
+ direction: "sent",
207
+ method: null,
208
+ path,
209
+ data: "",
210
+ outcome: "disconnected",
211
+ });
212
+ return;
213
+ }
214
+ if (action.type === "websocket_send" && client.readyState === 1) {
215
+ client.send(action.data);
216
+ recorder.record({
217
+ at_ms: recorder.atMs(),
218
+ protocol: "websocket",
219
+ direction: "sent",
220
+ method: null,
221
+ path,
222
+ data: recorder.machine?.redact(action.data) ?? action.data,
223
+ outcome: "matched",
224
+ });
225
+ }
226
+ }
227
+ };
228
+ const handleStaticWebSocket = (client, recorder, connection, path) => {
229
+ client.on("message", (value) => recorder.record({
230
+ at_ms: recorder.atMs(),
231
+ protocol: "websocket",
232
+ direction: "received",
233
+ method: null,
234
+ path,
235
+ data: value.toString(),
236
+ outcome: "matched",
237
+ }));
238
+ void sendWebSocketScript(client, connection, recorder);
239
+ };
240
+ const handleMachineWebSocket = (options) => {
241
+ const { client, request, recorder, connection, path } = options;
242
+ const machine = recorder.machine;
243
+ if (machine === undefined)
244
+ return;
245
+ void recorder
246
+ .enqueueMachine(async () => {
247
+ const connectedAt = recorder.atMs();
248
+ const connectionDecision = recorder.dispatchMachine({
249
+ protocol: "websocket_connect",
250
+ connection: connection === 1 ? "initial" : "reconnect",
251
+ at_ms: recorder.rawAtMs(),
252
+ recorded_at_ms: connectedAt,
253
+ method: null,
254
+ path,
255
+ headers: requestHeaders(request),
256
+ body: "",
257
+ });
258
+ recordMachineEvent(recorder, {
259
+ at_ms: connectedAt,
260
+ protocol: "websocket",
261
+ direction: "received",
262
+ method: null,
263
+ path,
264
+ data: "",
265
+ }, connectionDecision);
266
+ if (connectionDecision.outcome === "matched")
267
+ await runWebSocketActions(client, recorder, connectionDecision, path);
268
+ else
269
+ client.close();
270
+ })
271
+ .catch(() => {
272
+ recorder.truncated = true;
273
+ client.terminate();
274
+ });
275
+ client.on("message", (value) => {
276
+ const body = value.toString();
277
+ const rawAtMs = recorder.rawAtMs();
278
+ const recordedAtMs = recorder.atMs();
279
+ void recorder
280
+ .enqueueMachine(async () => {
281
+ const decision = recorder.dispatchMachine({
282
+ protocol: "websocket_message",
283
+ connection: connection === 1 ? "initial" : "reconnect",
284
+ at_ms: rawAtMs,
285
+ recorded_at_ms: recordedAtMs,
286
+ method: null,
287
+ path,
288
+ headers: {},
289
+ body,
290
+ });
291
+ recordMachineEvent(recorder, {
292
+ at_ms: recordedAtMs,
293
+ protocol: "websocket",
294
+ direction: "received",
295
+ method: null,
296
+ path,
297
+ data: body,
298
+ }, decision);
299
+ if (decision.outcome === "matched")
300
+ await runWebSocketActions(client, recorder, decision, path);
301
+ })
302
+ .catch(() => {
303
+ recorder.truncated = true;
304
+ client.terminate();
305
+ });
306
+ });
307
+ };
164
308
  const createWebSocketReplay = (server, recorder) => {
165
309
  let connections = 0;
166
310
  const websocket = new WebSocketServer({
@@ -169,13 +313,14 @@ const createWebSocketReplay = (server, recorder) => {
169
313
  clientTracking: true,
170
314
  });
171
315
  server.on("upgrade", (request, socket, head) => {
172
- if (new URL(request.url ?? "/", "http://127.0.0.1").pathname !== "/ws") {
316
+ if (recorder.machine === undefined &&
317
+ new URL(request.url ?? "/", "http://127.0.0.1").pathname !== "/ws") {
173
318
  socket.destroy();
174
319
  return;
175
320
  }
176
321
  websocket.handleUpgrade(request, socket, head, (client) => websocket.emit("connection", client, request));
177
322
  });
178
- websocket.on("connection", (client) => {
323
+ websocket.on("connection", (client, request) => {
179
324
  connections += 1;
180
325
  if (connections > recorder.scenario.limits.connections) {
181
326
  recorder.truncated = true;
@@ -183,60 +328,59 @@ const createWebSocketReplay = (server, recorder) => {
183
328
  return;
184
329
  }
185
330
  client.on("error", () => undefined);
186
- client.on("message", (value) => recorder.record({
187
- at_ms: recorder.atMs(),
188
- protocol: "websocket",
189
- direction: "received",
190
- method: null,
191
- path: "/ws",
192
- data: value.toString(),
193
- outcome: "matched",
194
- }));
195
- void sendWebSocketScript(client, connections, recorder);
331
+ const path = new URL(request.url ?? "/", "http://127.0.0.1").pathname;
332
+ if (recorder.machine === undefined)
333
+ handleStaticWebSocket(client, recorder, connections, path);
334
+ else
335
+ handleMachineWebSocket({
336
+ client,
337
+ request,
338
+ recorder,
339
+ connection: connections,
340
+ path,
341
+ });
196
342
  });
197
343
  return websocket;
198
344
  };
199
- const listen = async (server) => {
200
- await new Promise((resolveListen, rejectListen) => {
201
- server.once("error", rejectListen);
202
- server.listen(0, "127.0.0.1", () => {
203
- server.off("error", rejectListen);
204
- resolveListen();
205
- });
206
- });
207
- const address = server.address();
208
- if (address === null || typeof address === "string")
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
345
  /** Start bounded HTTP and WebSocket replay endpoints on IPv4 loopback only. */
216
- export const startLoopbackReplay = async (scenario) => {
217
- const recorder = new ReplayRecorder(scenario);
346
+ export const startLoopbackReplay = async (scenario, recordEvent = () => undefined) => {
347
+ const recorder = new ReplayRecorder(scenario, recordEvent);
218
348
  const server = createServer(createHttpHandler(recorder));
219
349
  const websocket = createWebSocketReplay(server, recorder);
350
+ let closePromise;
220
351
  let port;
221
352
  try {
222
- port = await listen(server);
353
+ port = await listenOnLoopback(server);
223
354
  }
224
355
  catch (cause) {
225
- await closeServer(server).catch(() => undefined);
356
+ await closeReplayServer(server).catch(() => undefined);
226
357
  throw cause;
227
358
  }
228
359
  return {
229
360
  httpUrl: `http://127.0.0.1:${String(port)}`,
230
361
  websocketUrl: `ws://127.0.0.1:${String(port)}/ws`,
231
- events: recorder.events,
362
+ get events() {
363
+ return recorder.events.map((event) => ({ ...event }));
364
+ },
365
+ get transitions() {
366
+ return recorder.transitions.map((transition) => ({
367
+ ...transition,
368
+ sensitive_aliases: [...transition.sensitive_aliases],
369
+ }));
370
+ },
232
371
  get truncated() {
233
372
  return recorder.truncated;
234
373
  },
235
374
  async close() {
236
- for (const client of websocket.clients)
237
- client.terminate();
238
- await new Promise((resolveClose) => websocket.close(() => resolveClose()));
239
- await closeServer(server);
375
+ closePromise ??= (async () => {
376
+ recorder.stopMachineAdmission();
377
+ for (const client of websocket.clients)
378
+ client.terminate();
379
+ await new Promise((resolveClose) => websocket.close(() => resolveClose()));
380
+ await closeReplayServer(server);
381
+ await recorder.drainMachine();
382
+ })();
383
+ await closePromise;
240
384
  },
241
385
  };
242
386
  };