@moonwall/cli
Version:
Testing framework for the Moon family of projects
1,406 lines (1,389 loc) • 54.5 kB
JavaScript
// src/internal/node.ts
import fs5 from "fs";
import path4 from "path";
import WebSocket from "ws";
// src/internal/fileCheckers.ts
import fs from "fs";
import { execSync } from "child_process";
import chalk from "chalk";
import os from "os";
import path from "path";
import { select } from "@inquirer/prompts";
async function checkExists(path5) {
const binPath = path5.split(" ")[0];
const fsResult = fs.existsSync(binPath);
if (!fsResult) {
throw new Error(
`No binary file found at location: ${binPath}
Are you sure your ${chalk.bgWhiteBright.blackBright(
"moonwall.config.json"
)} file has the correct "binPath" in launchSpec?`
);
}
const binArch = await getBinaryArchitecture(binPath);
const currentArch = os.arch();
if (binArch !== currentArch && binArch !== "unknown") {
throw new Error(
`The binary architecture ${chalk.bgWhiteBright.blackBright(
binArch
)} does not match this system's architecture ${chalk.bgWhiteBright.blackBright(
currentArch
)}
Download or compile a new binary executable for ${chalk.bgWhiteBright.blackBright(
currentArch
)} `
);
}
return true;
}
function checkAccess(path5) {
const binPath = path5.split(" ")[0];
try {
fs.accessSync(binPath, fs.constants.X_OK);
} catch (_err) {
console.error(`The file ${binPath} is not executable`);
throw new Error(`The file at ${binPath} , lacks execute permissions.`);
}
}
async function getBinaryArchitecture(filePath) {
return new Promise((resolve, reject) => {
const architectureMap = {
0: "unknown",
3: "x86",
62: "x64",
183: "arm64"
};
fs.open(filePath, "r", (err, fd) => {
if (err) {
reject(err);
return;
}
const buffer = Buffer.alloc(20);
fs.read(fd, buffer, 0, 20, 0, (err2, _bytesRead, buffer2) => {
if (err2) {
reject(err2);
return;
}
const e_machine = buffer2.readUInt16LE(18);
const architecture = architectureMap[e_machine] || "unknown";
resolve(architecture);
});
});
});
}
// src/internal/node.ts
import { createLogger as createLogger9 } from "@moonwall/util";
import { setTimeout as timer } from "timers/promises";
import Docker from "dockerode";
import invariant from "tiny-invariant";
// src/lib/configReader.ts
import { readFile, access } from "fs/promises";
import { readFileSync, existsSync, constants } from "fs";
import JSONC from "jsonc-parser";
import path2, { extname } from "path";
var cachedConfig;
function parseConfigSync(filePath) {
let result;
const file = readFileSync(filePath, "utf8");
switch (extname(filePath)) {
case ".json":
result = JSON.parse(file);
break;
case ".config":
result = JSONC.parse(file);
break;
default:
result = void 0;
break;
}
return result;
}
function isEthereumZombieConfig() {
const env = getEnvironmentFromConfig();
return env.foundation.type === "zombie" && !env.foundation.zombieSpec.disableDefaultEthProviders;
}
function isEthereumDevConfig() {
const env = getEnvironmentFromConfig();
return env.foundation.type === "dev" && !env.foundation.launchSpec[0].disableDefaultEthProviders;
}
function getEnvironmentFromConfig() {
const globalConfig = importJsonConfig();
const config = globalConfig.environments.find(({ name }) => name === process.env.MOON_TEST_ENV);
if (!config) {
throw new Error(`Environment ${process.env.MOON_TEST_ENV} not found in config`);
}
return config;
}
function importJsonConfig() {
if (cachedConfig) {
return cachedConfig;
}
const configPath = process.env.MOON_CONFIG_PATH;
if (!configPath) {
throw new Error("No moonwall config path set. This is a defect, please raise it.");
}
const filePath = path2.isAbsolute(configPath) ? configPath : path2.join(process.cwd(), configPath);
try {
const config = parseConfigSync(filePath);
const replacedConfig = replaceEnvVars(config);
cachedConfig = replacedConfig;
return cachedConfig;
} catch (e) {
console.error(e);
throw new Error(`Error import config at ${filePath}`);
}
}
function replaceEnvVars(value) {
if (typeof value === "string") {
return value.replace(/\$\{([^}]+)\}/g, (match, group) => {
const envVarValue = process.env[group];
return envVarValue || match;
});
}
if (Array.isArray(value)) {
return value.map(replaceEnvVars);
}
if (typeof value === "object" && value !== null) {
return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, replaceEnvVars(v)]));
}
return value;
}
// src/internal/effect/errors.ts
import { Data } from "effect";
var PortDiscoveryError = class extends Data.TaggedError("PortDiscoveryError") {
};
var NodeLaunchError = class extends Data.TaggedError("NodeLaunchError") {
};
var NodeReadinessError = class extends Data.TaggedError("NodeReadinessError") {
};
var ProcessError = class extends Data.TaggedError("ProcessError") {
};
var StartupCacheError = class extends Data.TaggedError("StartupCacheError") {
};
var FileLockError = class extends Data.TaggedError("FileLockError") {
};
// src/internal/effect/ProcessManagerService.ts
import { Context, Effect, Layer, pipe } from "effect";
import { Path } from "@effect/platform";
import { NodePath } from "@effect/platform-node";
import { spawn } from "child_process";
import * as fs2 from "fs";
import { createLogger } from "@moonwall/util";
var logger = createLogger({ name: "ProcessManagerService" });
var ProcessManagerService = class extends Context.Tag("ProcessManagerService")() {
};
var getLogPath = (config, pid) => pipe(
Effect.all([Path.Path, Effect.sync(() => process.cwd())]),
Effect.map(([pathService, cwd]) => {
const dirPath = config.logDirectory || pathService.join(cwd, "tmp", "node_logs");
const portArg = config.args.find((a) => a.includes("port"));
const port = portArg?.split("=")[1] || "unknown";
const baseName = pathService.basename(config.command);
return pathService.join(dirPath, `${baseName}_node_${port}_${pid}.log`).replace(/node_node_undefined/g, "chopsticks");
}),
Effect.provide(NodePath.layer),
Effect.mapError(
(cause) => new ProcessError({
cause,
operation: "spawn"
})
)
);
var ensureLogDirectory = (dirPath) => Effect.tryPromise({
try: async () => {
try {
await fs2.promises.access(dirPath);
} catch {
await fs2.promises.mkdir(dirPath, { recursive: true });
}
},
catch: (cause) => new ProcessError({
cause,
operation: "spawn"
})
});
var spawnProcess = (config) => Effect.try({
try: () => {
const child = spawn(config.command, [...config.args]);
child.on("error", (error) => {
logger.error(`Process spawn error: ${error}`);
});
return child;
},
catch: (cause) => new NodeLaunchError({
cause,
command: config.command,
args: [...config.args]
})
});
var createLogStream = (logPath) => Effect.try({
try: () => fs2.createWriteStream(logPath, { flags: "a" }),
catch: (cause) => new ProcessError({
cause,
operation: "spawn"
})
});
var setupLogHandlers = (process2, logStream) => Effect.sync(() => {
const logHandler = (chunk) => {
logStream.write(chunk);
};
process2.stdout?.on("data", logHandler);
process2.stderr?.on("data", logHandler);
});
var constructExitMessage = (process2, code, signal) => {
const timestamp = (/* @__PURE__ */ new Date()).toISOString();
if (process2.isMoonwallTerminating) {
return `${timestamp} [moonwall] process killed. reason: ${process2.moonwallTerminationReason || "unknown"}
`;
}
if (code !== null) {
return `${timestamp} [moonwall] process closed with status code ${code}
`;
}
if (signal !== null) {
return `${timestamp} [moonwall] process terminated by signal ${signal}
`;
}
return `${timestamp} [moonwall] process closed unexpectedly
`;
};
var setupExitHandler = (process2, logStream) => Effect.sync(() => {
process2.once("close", (code, signal) => {
const message = constructExitMessage(process2, code, signal);
logStream.end(message);
});
});
var killProcess = (process2, logStream, reason) => Effect.sync(() => {
process2.isMoonwallTerminating = true;
process2.moonwallTerminationReason = reason;
}).pipe(
Effect.flatMap(
() => process2.pid ? Effect.try({
try: () => {
process2.kill("SIGTERM");
},
catch: (cause) => new ProcessError({
cause,
pid: process2.pid,
operation: "kill"
})
}) : Effect.sync(() => logStream.end())
)
);
var launchProcess = (config) => pipe(
Effect.all([Path.Path, Effect.sync(() => config.logDirectory || void 0)]),
Effect.flatMap(([pathService, customLogDir]) => {
const dirPath = customLogDir || pathService.join(process.cwd(), "tmp", "node_logs");
return pipe(
ensureLogDirectory(dirPath),
Effect.flatMap(() => spawnProcess(config)),
Effect.flatMap((childProcess) => {
if (childProcess.pid === void 0) {
return Effect.fail(
new ProcessError({
cause: new Error("Process PID is undefined after spawn"),
operation: "spawn"
})
);
}
return pipe(
getLogPath(config, childProcess.pid),
Effect.flatMap(
(logPath) => pipe(
createLogStream(logPath),
Effect.flatMap(
(logStream) => pipe(
setupLogHandlers(childProcess, logStream),
Effect.flatMap(() => setupExitHandler(childProcess, logStream)),
Effect.map(() => {
const processInfo = {
process: childProcess,
logPath
};
const cleanup = pipe(
killProcess(childProcess, logStream, "Manual cleanup requested"),
Effect.catchAll(
(error) => Effect.sync(() => {
logger.error(`Failed to cleanly kill process: ${error}`);
})
)
);
return { result: processInfo, cleanup };
})
)
)
)
)
);
})
);
}),
Effect.provide(NodePath.layer)
);
var ProcessManagerServiceLive = Layer.succeed(ProcessManagerService, {
launch: launchProcess
});
// src/internal/effect/PortDiscoveryService.ts
import { Effect as Effect2, Context as Context2, Layer as Layer2, Schedule } from "effect";
import { exec } from "child_process";
import { promisify } from "util";
var execAsync = promisify(exec);
var PortDiscoveryService = class extends Context2.Tag("PortDiscoveryService")() {
};
var parsePortsFromLsof = (stdout) => {
const ports = [];
const lines = stdout.split("\n");
for (const line of lines) {
const regex = /(?:.+):(\d+)/;
const match = line.match(regex);
if (match) {
ports.push(Number.parseInt(match[1], 10));
}
}
return ports;
};
var attemptPortDiscovery = (pid) => Effect2.tryPromise({
try: () => execAsync(`lsof -p ${pid} -n -P | grep LISTEN`),
catch: (cause) => new PortDiscoveryError({
cause,
pid,
attempts: 1
})
}).pipe(
Effect2.flatMap(({ stdout }) => {
const ports = parsePortsFromLsof(stdout);
if (ports.length === 0) {
return Effect2.fail(
new PortDiscoveryError({
cause: new Error("No ports found in lsof output"),
pid,
attempts: 1
})
);
}
const rpcPort = ports.find((p) => p !== 30333 && p !== 9615);
if (!rpcPort) {
if (ports.length === 1) {
return Effect2.succeed(ports[0]);
}
return Effect2.fail(
new PortDiscoveryError({
cause: new Error(
`No RPC port found in range 9000-20000 and multiple ports detected (found ports: ${ports.join(", ")})`
),
pid,
attempts: 1
})
);
}
return Effect2.succeed(rpcPort);
})
);
var discoverPortWithRetry = (pid, maxAttempts = 1200) => attemptPortDiscovery(pid).pipe(
Effect2.retry(
Schedule.fixed("50 millis").pipe(Schedule.compose(Schedule.recurs(maxAttempts - 1)))
),
Effect2.catchAll(
(error) => Effect2.fail(
new PortDiscoveryError({
cause: error,
pid,
attempts: maxAttempts
})
)
)
);
var PortDiscoveryServiceLive = Layer2.succeed(PortDiscoveryService, {
discoverPort: discoverPortWithRetry
});
// src/internal/effect/NodeReadinessService.ts
import { Socket } from "@effect/platform";
import * as NodeSocket from "@effect/platform-node/NodeSocket";
import { createLogger as createLogger2 } from "@moonwall/util";
import { Context as Context3, Deferred, Effect as Effect3, Fiber, Layer as Layer3, Schedule as Schedule2 } from "effect";
var logger2 = createLogger2({ name: "NodeReadinessService" });
var debug = logger2.debug.bind(logger2);
var NodeReadinessService = class extends Context3.Tag("NodeReadinessService")() {
};
var sendRpcRequest = (method) => Effect3.flatMap(
Deferred.make(),
(responseDeferred) => Effect3.flatMap(
Socket.Socket,
(socket) => Effect3.flatMap(socket.writer, (writer) => {
debug(`Checking method: ${method}`);
const request = new TextEncoder().encode(
JSON.stringify({
jsonrpc: "2.0",
id: Math.floor(Math.random() * 1e4),
method,
params: []
})
);
const handleMessages = socket.runRaw((data) => {
try {
const message = typeof data === "string" ? data : new TextDecoder().decode(data);
debug(`Got message for ${method}: ${message.substring(0, 100)}`);
const response = JSON.parse(message);
debug(`Parsed response: jsonrpc=${response.jsonrpc}, error=${response.error}`);
if (response.jsonrpc === "2.0" && !response.error) {
debug(`Method ${method} succeeded!`);
return Deferred.succeed(responseDeferred, true);
}
} catch (e) {
debug(`Parse error for ${method}: ${e}`);
}
return Effect3.void;
});
return Effect3.flatMap(
Effect3.fork(handleMessages),
(messageFiber) => Effect3.flatMap(
writer(request),
() => (
// Wait for either:
// 1. Deferred resolving with response (success)
// 2. Timeout (fails with error)
// Also check if the message fiber has failed
Deferred.await(responseDeferred).pipe(
Effect3.timeoutFail({
duration: "2 seconds",
onTimeout: () => new Error("RPC request timed out waiting for response")
}),
// After getting result, check if fiber failed and prefer that error
Effect3.flatMap(
(result) => Fiber.poll(messageFiber).pipe(
Effect3.flatMap((pollResult) => {
if (pollResult._tag === "Some") {
const exit = pollResult.value;
if (exit._tag === "Failure") {
return Effect3.failCause(exit.cause);
}
}
return Effect3.succeed(result);
})
)
)
)
)
).pipe(
Effect3.ensuring(Fiber.interrupt(messageFiber)),
Effect3.catchAll(
(cause) => Effect3.fail(
new NodeReadinessError({
cause,
port: 0,
// Will be filled in by caller
attemptsExhausted: 1
})
)
)
)
);
})
)
);
var attemptReadinessCheck = (config) => Effect3.logDebug(
`Attempting readiness check on port ${config.port}, isEthereum: ${config.isEthereumChain}`
).pipe(
Effect3.flatMap(
() => Effect3.scoped(
Effect3.flatMap(sendRpcRequest("system_chain"), (systemChainOk) => {
if (systemChainOk) {
return Effect3.succeed(true);
}
if (config.isEthereumChain) {
return sendRpcRequest("eth_chainId");
}
return Effect3.succeed(false);
})
).pipe(
Effect3.timeoutFail({
duration: "3 seconds",
onTimeout: () => new NodeReadinessError({
cause: new Error("Readiness check timed out"),
port: config.port,
attemptsExhausted: 1
})
}),
Effect3.catchAll(
(cause) => Effect3.fail(
new NodeReadinessError({
cause,
port: config.port,
attemptsExhausted: 1
})
)
)
)
)
);
var checkReadyWithRetryInternal = (config) => {
const maxAttempts = config.maxAttempts || 200;
return attemptReadinessCheck(config).pipe(
Effect3.retry(
Schedule2.fixed("50 millis").pipe(Schedule2.compose(Schedule2.recurs(maxAttempts - 1)))
),
Effect3.catchAll(
(error) => Effect3.fail(
new NodeReadinessError({
cause: error,
port: config.port,
attemptsExhausted: maxAttempts
})
)
)
);
};
var checkReadyWithRetry = (config) => {
return checkReadyWithRetryInternal(config).pipe(
Effect3.provide(NodeSocket.layerWebSocket(`ws://localhost:${config.port}`))
);
};
var NodeReadinessServiceLive = Layer3.succeed(NodeReadinessService, {
checkReady: checkReadyWithRetry
});
// src/internal/effect/RpcPortDiscoveryService.ts
import { Command, Socket as Socket2 } from "@effect/platform";
import * as NodeCommandExecutor from "@effect/platform-node/NodeCommandExecutor";
import * as NodeContext from "@effect/platform-node/NodeContext";
import * as NodeFileSystem from "@effect/platform-node/NodeFileSystem";
import * as NodeSocket2 from "@effect/platform-node/NodeSocket";
import { createLogger as createLogger3 } from "@moonwall/util";
import { Context as Context4, Deferred as Deferred2, Effect as Effect4, Layer as Layer4, Option, Schedule as Schedule3 } from "effect";
var logger3 = createLogger3({ name: "RpcPortDiscoveryService" });
var debug2 = logger3.debug.bind(logger3);
var RpcPortDiscoveryService = class extends Context4.Tag("RpcPortDiscoveryService")() {
};
var parsePortsFromLsof2 = (stdout) => {
const regex = /(?:.+):(\d+)/;
return stdout.split("\n").flatMap((line) => {
const match = line.match(regex);
return match ? [Number.parseInt(match[1], 10)] : [];
});
};
var getAllPorts = (pid) => Command.make("lsof", "-p", `${pid}`, "-n", "-P").pipe(
Command.pipeTo(Command.make("grep", "LISTEN")),
Command.string,
Effect4.map(parsePortsFromLsof2),
Effect4.flatMap(
(ports) => ports.length === 0 ? Effect4.fail(
new PortDiscoveryError({
cause: new Error("No listening ports found"),
pid,
attempts: 1
})
) : Effect4.succeed(ports)
),
Effect4.catchAll(
(cause) => Effect4.fail(
new PortDiscoveryError({
cause,
pid,
attempts: 1
})
)
)
);
var testRpcMethod = (method) => Effect4.flatMap(
Deferred2.make(),
(responseDeferred) => Effect4.flatMap(
Socket2.Socket,
(socket) => Effect4.flatMap(socket.writer, (writer) => {
const request = new TextEncoder().encode(
JSON.stringify({
jsonrpc: "2.0",
id: Math.floor(Math.random() * 1e4),
method,
params: []
})
);
const handleMessages = socket.runRaw(
(data) => Effect4.try(() => {
const message = typeof data === "string" ? data : new TextDecoder().decode(data);
const response = JSON.parse(message);
return response.jsonrpc === "2.0" && !response.error;
}).pipe(
Effect4.orElseSucceed(() => false),
Effect4.flatMap(
(shouldSucceed) => shouldSucceed ? Deferred2.succeed(responseDeferred, true) : Effect4.void
)
)
);
return Effect4.all([
Effect4.fork(handleMessages),
Effect4.flatMap(writer(request), () => Effect4.void),
Deferred2.await(responseDeferred).pipe(
Effect4.timeoutOption("3 seconds"),
Effect4.map((opt) => opt._tag === "Some" ? opt.value : false)
)
]).pipe(
Effect4.map(([_, __, result]) => result),
Effect4.catchAll(
(cause) => Effect4.fail(
new PortDiscoveryError({
cause,
pid: 0,
attempts: 1
})
)
)
);
})
)
);
var testRpcPort = (port, isEthereumChain) => Effect4.scoped(
Effect4.provide(
Effect4.flatMap(testRpcMethod("system_chain"), (success) => {
if (success) {
debug2(`Port ${port} responded to system_chain`);
return Effect4.succeed(port);
}
if (!isEthereumChain) {
return Effect4.fail(
new PortDiscoveryError({
cause: new Error(`Port ${port} did not respond to system_chain`),
pid: 0,
attempts: 1
})
);
}
return Effect4.flatMap(testRpcMethod("eth_chainId"), (ethSuccess) => {
if (ethSuccess) {
debug2(`Port ${port} responded to eth_chainId`);
return Effect4.succeed(port);
}
return Effect4.fail(
new PortDiscoveryError({
cause: new Error(`Port ${port} did not respond to eth_chainId`),
pid: 0,
attempts: 1
})
);
});
}),
NodeSocket2.layerWebSocket(`ws://localhost:${port}`)
)
).pipe(
Effect4.timeoutOption("7 seconds"),
Effect4.flatMap(
(opt) => Option.match(opt, {
onNone: () => Effect4.fail(
new PortDiscoveryError({
cause: new Error(`Port ${port} connection timeout`),
pid: 0,
attempts: 1
})
),
onSome: (val) => Effect4.succeed(val)
})
)
);
var discoverRpcPortWithRace = (config) => {
const maxAttempts = config.maxAttempts || 2400;
return getAllPorts(config.pid).pipe(
Effect4.flatMap((allPorts) => {
debug2(`Discovered ports: ${allPorts.join(", ")}`);
const candidatePorts = allPorts.filter(
(p) => p >= 1024 && p <= 65535 && p !== 30333 && p !== 9615
// Exclude p2p & metrics port
);
if (candidatePorts.length === 0) {
return Effect4.fail(
new PortDiscoveryError({
cause: new Error(`No candidate RPC ports found in: ${allPorts.join(", ")}`),
pid: config.pid,
attempts: 1
})
);
}
debug2(`Testing candidate ports: ${candidatePorts.join(", ")}`);
return Effect4.raceAll(
candidatePorts.map((port) => testRpcPort(port, config.isEthereumChain))
).pipe(
Effect4.catchAll(
(_error) => Effect4.fail(
new PortDiscoveryError({
cause: new Error(`All candidate ports failed RPC test: ${candidatePorts.join(", ")}`),
pid: config.pid,
attempts: 1
})
)
)
);
}),
// Retry the entire discovery process
Effect4.retry(
Schedule3.fixed("50 millis").pipe(Schedule3.compose(Schedule3.recurs(maxAttempts - 1)))
),
Effect4.catchAll(
(error) => Effect4.fail(
new PortDiscoveryError({
cause: error,
pid: config.pid,
attempts: maxAttempts
})
)
),
Effect4.provide(
NodeCommandExecutor.layer.pipe(
Layer4.provide(NodeContext.layer),
Layer4.provide(NodeFileSystem.layer)
)
)
);
};
var RpcPortDiscoveryServiceLive = Layer4.succeed(RpcPortDiscoveryService, {
discoverRpcPort: discoverRpcPortWithRace
});
// src/internal/effect/launchNodeEffect.ts
import { Effect as Effect5, Layer as Layer5 } from "effect";
import { createLogger as createLogger4 } from "@moonwall/util";
var logger4 = createLogger4({ name: "launchNodeEffect" });
var debug3 = logger4.debug.bind(logger4);
var AllServicesLive = Layer5.mergeAll(ProcessManagerServiceLive, RpcPortDiscoveryServiceLive);
async function launchNodeEffect(config) {
const startTime = Date.now();
logger4.debug(`[T+0ms] Starting with command: ${config.command}, name: ${config.name}`);
const nodeConfig = {
isChopsticks: config.args.some((arg) => arg.includes("chopsticks.cjs")),
hasRpcPort: config.args.some((arg) => arg.includes("--rpc-port")),
hasPort: config.args.some((arg) => arg.includes("--port"))
};
const finalArgs = !nodeConfig.isChopsticks && !nodeConfig.hasRpcPort ? [
...config.args,
// If MOONWALL_RPC_PORT was pre-allocated by LaunchCommandParser, respect it; otherwise fall back to 0.
process.env.MOONWALL_RPC_PORT ? `--rpc-port=${process.env.MOONWALL_RPC_PORT}` : "--rpc-port=0"
] : config.args;
debug3(`Final args: ${JSON.stringify(finalArgs)}`);
const program = ProcessManagerService.pipe(
Effect5.flatMap(
(processManager) => Effect5.sync(() => logger4.debug(`[T+${Date.now() - startTime}ms] Launching process...`)).pipe(
Effect5.flatMap(
() => processManager.launch({
command: config.command,
args: finalArgs,
name: config.name
})
)
)
),
Effect5.flatMap(
({ result: processResult, cleanup: processCleanup }) => Effect5.sync(
() => logger4.debug(
`[T+${Date.now() - startTime}ms] Process launched with PID: ${processResult.process.pid}`
)
).pipe(
Effect5.flatMap(() => {
const pid = processResult.process.pid;
if (pid === void 0) {
return Effect5.fail(
new ProcessError({
cause: new Error("Process PID is undefined after launch"),
operation: "check"
})
);
}
return RpcPortDiscoveryService.pipe(
Effect5.flatMap(
(rpcDiscovery) => Effect5.sync(
() => logger4.debug(
`[T+${Date.now() - startTime}ms] Discovering RPC port for PID ${pid}...`
)
).pipe(
Effect5.flatMap(
() => rpcDiscovery.discoverRpcPort({
pid,
isEthereumChain: config.isEthereumChain,
maxAttempts: 600
// Match PortDiscoveryService: 600 × 200ms = 120s
})
)
)
),
Effect5.mapError(
(error) => new ProcessError({
cause: error,
pid,
operation: "check"
})
),
Effect5.flatMap(
(port) => Effect5.sync(
() => logger4.debug(
`[T+${Date.now() - startTime}ms] Discovered and validated RPC port: ${port}`
)
).pipe(
Effect5.map(() => ({
processInfo: {
process: processResult.process,
port,
logPath: processResult.logPath
},
cleanup: processCleanup
}))
)
)
);
})
)
)
).pipe(Effect5.provide(AllServicesLive));
return Effect5.runPromise(
Effect5.map(program, ({ processInfo, cleanup }) => ({
result: {
runningNode: processInfo.process,
port: processInfo.port,
logPath: processInfo.logPath
},
cleanup: () => Effect5.runPromise(cleanup)
}))
);
}
// src/internal/effect/FileLock.ts
import { FileSystem } from "@effect/platform";
import { Duration, Effect as Effect6, Schedule as Schedule4 } from "effect";
import * as os2 from "os";
var LOCK_MAX_AGE = Duration.minutes(2);
var LOCK_POLL_INTERVAL = Duration.millis(500);
var isProcessAlive = (pid) => Effect6.try(() => {
process.kill(pid, 0);
return true;
}).pipe(Effect6.orElseSucceed(() => false));
var isLockStale = (info) => Effect6.gen(function* () {
const isTimedOut = Date.now() - info.timestamp > Duration.toMillis(LOCK_MAX_AGE);
if (isTimedOut) return true;
const isSameHost = info.hostname === os2.hostname();
if (!isSameHost) return false;
const alive = yield* isProcessAlive(info.pid);
return !alive;
});
var cleanupStaleLock = (lockPath) => Effect6.gen(function* () {
const fs6 = yield* FileSystem.FileSystem;
const infoPath = `${lockPath}/lock.json`;
const exists = yield* fs6.exists(infoPath).pipe(Effect6.orElseSucceed(() => false));
if (!exists) return;
const content = yield* fs6.readFileString(infoPath).pipe(Effect6.orElseSucceed(() => ""));
const info = yield* Effect6.try(() => JSON.parse(content)).pipe(
Effect6.orElseSucceed(() => null)
);
if (!info) return;
const stale = yield* isLockStale(info);
if (stale) {
yield* fs6.remove(lockPath, { recursive: true }).pipe(Effect6.ignore);
}
});
var writeLockInfo = (lockPath) => Effect6.gen(function* () {
const fs6 = yield* FileSystem.FileSystem;
const info = {
pid: process.pid,
timestamp: Date.now(),
hostname: os2.hostname()
};
yield* fs6.writeFileString(`${lockPath}/lock.json`, JSON.stringify(info)).pipe(Effect6.ignore);
});
var tryAcquireLock = (lockPath) => Effect6.gen(function* () {
const fs6 = yield* FileSystem.FileSystem;
yield* cleanupStaleLock(lockPath);
yield* fs6.makeDirectory(lockPath).pipe(Effect6.mapError(() => new FileLockError({ reason: "acquisition_failed", lockPath })));
yield* writeLockInfo(lockPath);
});
var acquireFileLock = (lockPath, timeout = Duration.minutes(2)) => tryAcquireLock(lockPath).pipe(
Effect6.retry(Schedule4.fixed(LOCK_POLL_INTERVAL).pipe(Schedule4.upTo(timeout))),
Effect6.catchAll(() => Effect6.fail(new FileLockError({ reason: "timeout", lockPath })))
);
var releaseFileLock = (lockPath) => Effect6.gen(function* () {
const fs6 = yield* FileSystem.FileSystem;
yield* fs6.remove(lockPath, { recursive: true }).pipe(Effect6.ignore);
});
var withFileLock = (lockPath, effect, timeout = Duration.minutes(2)) => Effect6.acquireUseRelease(
acquireFileLock(lockPath, timeout),
() => effect,
() => releaseFileLock(lockPath)
);
// src/internal/effect/StartupCacheService.ts
import { Command as Command2, FileSystem as FileSystem2, Path as Path2 } from "@effect/platform";
import { NodeContext as NodeContext2 } from "@effect/platform-node";
import { createLogger as createLogger5 } from "@moonwall/util";
import { Context as Context5, Duration as Duration2, Effect as Effect7, Layer as Layer6, Option as Option2, Stream } from "effect";
import * as crypto from "crypto";
var logger5 = createLogger5({ name: "StartupCacheService" });
var StartupCacheService = class extends Context5.Tag("StartupCacheService")() {
};
var hashFile = (filePath) => Effect7.gen(function* () {
const fs6 = yield* FileSystem2.FileSystem;
const hash = crypto.createHash("sha256");
yield* fs6.stream(filePath).pipe(Stream.runForEach((chunk) => Effect7.sync(() => hash.update(chunk))));
return hash.digest("hex");
}).pipe(Effect7.mapError((cause) => new StartupCacheError({ cause, operation: "hash" })));
var findPrecompiledWasm = (dir) => Effect7.gen(function* () {
const fs6 = yield* FileSystem2.FileSystem;
const pathService = yield* Path2.Path;
const exists = yield* fs6.exists(dir);
if (!exists) return Option2.none();
const files = yield* fs6.readDirectory(dir).pipe(Effect7.orElseSucceed(() => []));
const wasmFile = files.find(
(f) => f.startsWith("precompiled_wasm_") || f.endsWith(".cwasm") || f.endsWith(".wasm")
);
return wasmFile ? Option2.some(pathService.join(dir, wasmFile)) : Option2.none();
}).pipe(Effect7.catchAll(() => Effect7.succeed(Option2.none())));
var checkCache = (cacheDir, hashPath, expectedHash) => Effect7.gen(function* () {
const fs6 = yield* FileSystem2.FileSystem;
const savedHash = yield* fs6.readFileString(hashPath).pipe(Effect7.orElseSucceed(() => ""));
if (savedHash.trim() !== expectedHash) return Option2.none();
const wasmPath = yield* findPrecompiledWasm(cacheDir);
if (Option2.isNone(wasmPath)) return Option2.none();
const accessible = yield* fs6.access(wasmPath.value).pipe(
Effect7.as(true),
Effect7.orElseSucceed(() => false)
);
return accessible ? wasmPath : Option2.none();
});
var runPrecompile = (binPath, chainArg, outputDir) => Effect7.gen(function* () {
const fs6 = yield* FileSystem2.FileSystem;
const pathService = yield* Path2.Path;
const args = chainArg ? ["precompile-wasm", chainArg, outputDir] : ["precompile-wasm", outputDir];
logger5.debug(`Precompiling: ${binPath} ${args.join(" ")}`);
const startTime = Date.now();
const exitCode = yield* Command2.exitCode(Command2.make(binPath, ...args)).pipe(
Effect7.mapError(
(e) => new StartupCacheError({ cause: e, operation: "precompile" })
)
);
const files = yield* fs6.readDirectory(outputDir).pipe(Effect7.mapError((e) => new StartupCacheError({ cause: e, operation: "precompile" })));
const wasmFile = files.find(
(f) => f.startsWith("precompiled_wasm_") || f.endsWith(".cwasm") || f.endsWith(".wasm")
);
if (!wasmFile) {
return yield* Effect7.fail(
new StartupCacheError({
cause: `precompile-wasm failed (code ${exitCode}): no WASM file generated`,
operation: "precompile"
})
);
}
const wasmPath = pathService.join(outputDir, wasmFile);
logger5.debug(`Precompiled in ${Date.now() - startTime}ms: ${wasmPath}`);
return wasmPath;
});
var generateRawChainSpec = (binPath, chainName, outputPath) => Effect7.gen(function* () {
const fs6 = yield* FileSystem2.FileSystem;
const args = chainName === "dev" || chainName === "default" ? ["build-spec", "--dev", "--raw"] : ["build-spec", `--chain=${chainName}`, "--raw"];
logger5.debug(`Generating raw chain spec: ${binPath} ${args.join(" ")}`);
const stdout = yield* Command2.string(Command2.make(binPath, ...args)).pipe(
Effect7.mapError(
(e) => new StartupCacheError({ cause: e, operation: "chainspec" })
)
);
if (!stdout.length) {
return yield* Effect7.fail(
new StartupCacheError({ cause: "build-spec produced no output", operation: "chainspec" })
);
}
yield* fs6.writeFileString(outputPath, stdout).pipe(Effect7.mapError((e) => new StartupCacheError({ cause: e, operation: "chainspec" })));
return outputPath;
});
var maybeGetRawChainSpec = (binPath, chainName, cacheSubDir, shouldGenerate) => Effect7.gen(function* () {
if (!shouldGenerate) return Option2.none();
const fs6 = yield* FileSystem2.FileSystem;
const pathService = yield* Path2.Path;
const rawSpecPath = pathService.join(cacheSubDir, `${chainName}-raw.json`);
const exists = yield* fs6.exists(rawSpecPath).pipe(Effect7.orElseSucceed(() => false));
if (exists) return Option2.some(rawSpecPath);
return yield* generateRawChainSpec(binPath, chainName, rawSpecPath).pipe(
Effect7.map(Option2.some),
Effect7.catchAll(() => Effect7.succeed(Option2.none()))
);
});
var getCachedArtifactsImpl = (config) => Effect7.gen(function* () {
const fs6 = yield* FileSystem2.FileSystem;
const pathService = yield* Path2.Path;
const binaryHash = yield* hashFile(config.binPath);
const shortHash = binaryHash.substring(0, 12);
const chainName = config.isDevMode ? "dev" : config.chainArg?.match(/--chain[=\s]?(\S+)/)?.[1] || "default";
const binName = pathService.basename(config.binPath);
const cacheSubDir = pathService.join(config.cacheDir, `${binName}-${chainName}-${shortHash}`);
const hashPath = pathService.join(cacheSubDir, "binary.hash");
const lockPath = pathService.join(config.cacheDir, `${binName}-${chainName}.lock`);
yield* fs6.makeDirectory(cacheSubDir, { recursive: true }).pipe(Effect7.mapError((e) => new StartupCacheError({ cause: e, operation: "cache" })));
const cached = yield* checkCache(cacheSubDir, hashPath, binaryHash);
if (Option2.isSome(cached)) {
logger5.debug(`Using cached precompiled WASM: ${cached.value}`);
const rawChainSpecPath = yield* maybeGetRawChainSpec(
config.binPath,
chainName,
cacheSubDir,
config.generateRawChainSpec ?? false
);
return {
precompiledPath: cached.value,
fromCache: true,
rawChainSpecPath: Option2.getOrUndefined(rawChainSpecPath)
};
}
return yield* withFileLock(
lockPath,
Effect7.gen(function* () {
const nowCached = yield* checkCache(cacheSubDir, hashPath, binaryHash);
if (Option2.isSome(nowCached)) {
logger5.debug(
`Using cached precompiled WASM (created by another process): ${nowCached.value}`
);
const rawChainSpecPath2 = yield* maybeGetRawChainSpec(
config.binPath,
chainName,
cacheSubDir,
config.generateRawChainSpec ?? false
);
return {
precompiledPath: nowCached.value,
fromCache: true,
rawChainSpecPath: Option2.getOrUndefined(rawChainSpecPath2)
};
}
logger5.debug("Precompiling WASM (this may take a moment)...");
const wasmPath = yield* runPrecompile(config.binPath, config.chainArg, cacheSubDir);
yield* fs6.writeFileString(hashPath, binaryHash).pipe(Effect7.mapError((e) => new StartupCacheError({ cause: e, operation: "cache" })));
const rawChainSpecPath = yield* maybeGetRawChainSpec(
config.binPath,
chainName,
cacheSubDir,
config.generateRawChainSpec ?? false
);
return {
precompiledPath: wasmPath,
fromCache: false,
rawChainSpecPath: Option2.getOrUndefined(rawChainSpecPath)
};
}),
Duration2.minutes(2)
);
});
var StartupCacheServiceLive = Layer6.succeed(StartupCacheService, {
getCachedArtifacts: (config) => getCachedArtifactsImpl(config).pipe(
Effect7.mapError(
(e) => e._tag === "FileLockError" ? new StartupCacheError({ cause: e, operation: "lock" }) : e
),
Effect7.provide(NodeContext2.layer)
)
});
var StartupCacheServiceTestable = Layer6.succeed(StartupCacheService, {
getCachedArtifacts: (config) => getCachedArtifactsImpl(config).pipe(
Effect7.mapError(
(e) => e._tag === "FileLockError" ? new StartupCacheError({ cause: e, operation: "lock" }) : e
)
)
});
// src/internal/effect/ChopsticksService.ts
import { Context as Context6, Data as Data2 } from "effect";
var ChopsticksSetupError = class extends Data2.TaggedError("ChopsticksSetupError") {
};
var ChopsticksBlockError = class extends Data2.TaggedError("ChopsticksBlockError") {
};
var ChopsticksStorageError = class extends Data2.TaggedError("ChopsticksStorageError") {
};
var ChopsticksExtrinsicError = class extends Data2.TaggedError("ChopsticksExtrinsicError") {
};
var ChopsticksXcmError = class extends Data2.TaggedError("ChopsticksXcmError") {
};
var ChopsticksCleanupError = class extends Data2.TaggedError("ChopsticksCleanupError") {
};
var ChopsticksService = class extends Context6.Tag("ChopsticksService")() {
};
var ChopsticksConfigTag = class extends Context6.Tag("ChopsticksConfig")() {
};
// src/internal/effect/launchChopsticksEffect.ts
import { Effect as Effect10, Layer as Layer7 } from "effect";
import { createLogger as createLogger7 } from "@moonwall/util";
import * as fs4 from "fs";
import * as path3 from "path";
// src/internal/effect/chopsticksConfigParser.ts
import { Effect as Effect9 } from "effect";
import * as fs3 from "fs";
import * as yaml from "yaml";
import { createLogger as createLogger6 } from "@moonwall/util";
var logger6 = createLogger6({ name: "chopsticksConfigParser" });
// src/internal/effect/launchChopsticksEffect.ts
var chopsticksLoggerConfigured = false;
var chopsticksModuleCache = null;
var logger7 = createLogger7({ name: "launchChopsticksEffect" });
var chopsticksLogStream = null;
function hookLoggerToFile(pinoInstance, loggerName) {
const wrapMethod = (level, originalMethod) => {
return function(...args) {
originalMethod.apply(this, args);
if (chopsticksLogStream && args.length > 0) {
const timestamp = (/* @__PURE__ */ new Date()).toISOString();
const message = typeof args[0] === "string" ? args[0] : JSON.stringify(args[0]);
chopsticksLogStream.write(
`[${timestamp}] [${level.toUpperCase()}] (${loggerName}) ${message}
`
);
}
};
};
const originalInfo = pinoInstance.info.bind(pinoInstance);
const originalWarn = pinoInstance.warn.bind(pinoInstance);
const originalError = pinoInstance.error.bind(pinoInstance);
const originalDebug = pinoInstance.debug.bind(pinoInstance);
pinoInstance.info = wrapMethod("info", originalInfo);
pinoInstance.warn = wrapMethod("warn", originalWarn);
pinoInstance.error = wrapMethod("error", originalError);
pinoInstance.debug = wrapMethod("debug", originalDebug);
const originalChild = pinoInstance.child?.bind(pinoInstance);
if (originalChild) {
pinoInstance.child = (bindings) => {
const child = originalChild(bindings);
const childName = bindings.name || bindings.child || loggerName;
hookLoggerToFile(child, childName);
return child;
};
}
}
async function getChopsticksModules(logLevel = "inherit") {
if (chopsticksModuleCache) {
return chopsticksModuleCache;
}
const chopsticks = await import("@acala-network/chopsticks");
chopsticksModuleCache = {
setupWithServer: chopsticks.setupWithServer,
setStorage: chopsticks.setStorage,
pinoLogger: chopsticks.pinoLogger,
defaultLogger: chopsticks.defaultLogger
};
const resolvedLevel = logLevel === "inherit" ? process.env.LOG_LEVEL || "info" : logLevel;
chopsticksModuleCache.pinoLogger.level = resolvedLevel;
hookLoggerToFile(chopsticksModuleCache.defaultLogger, "chopsticks");
chopsticksLoggerConfigured = true;
logger7.debug(`Chopsticks internal logger level: ${resolvedLevel}`);
return chopsticksModuleCache;
}
var getEndpointString = (endpoint) => {
if (typeof endpoint === "string") return endpoint;
if (Array.isArray(endpoint)) return endpoint[0];
return void 0;
};
var prepareConfigForSetup = (config) => ({
...config,
port: config.port ?? 8e3,
host: config.host ?? "127.0.0.1",
"build-block-mode": config["build-block-mode"] ?? "Manual"
});
var createServiceMethods = (chain) => ({
createBlock: (params) => Effect10.tryPromise({
try: async () => {
const block = await chain.newBlock({
transactions: params?.transactions ?? [],
upwardMessages: params?.ump ?? {},
downwardMessages: params?.dmp ?? [],
horizontalMessages: params?.hrmp ?? {}
});
return {
block: {
hash: block.hash,
number: block.number
}
};
},
catch: (cause) => new ChopsticksBlockError({
cause,
operation: "newBlock"
})
}),
setStorage: (params) => Effect10.tryPromise({
try: async () => {
const storage = { [params.module]: { [params.method]: params.params } };
const modules = await getChopsticksModules();
await modules.setStorage(chain, storage);
},
catch: (cause) => new ChopsticksStorageError({
cause,
module: params.module,
method: params.method
})
}),
submitExtrinsic: (extrinsic) => Effect10.tryPromise({
try: () => chain.submitExtrinsic(extrinsic),
catch: (cause) => new ChopsticksExtrinsicError({
cause,
operation: "submit",
extrinsic
})
}),
dryRunExtrinsic: (extrinsic, at) => Effect10.tryPromise({
try: async () => {
const result = await chain.dryRunExtrinsic(extrinsic, at);
const isOk = result.outcome.isOk;
return {
success: isOk,
storageDiff: result.storageDiff,
error: isOk ? void 0 : result.outcome.asErr?.toString()
};
},
catch: (cause) => new ChopsticksExtrinsicError({
cause,
operation: "dryRun",
extrinsic: typeof extrinsic === "string" ? extrinsic : extrinsic.call
})
}),
getBlock: (hashOrNumber) => Effect10.tryPromise({
try: async () => {
const block = hashOrNumber === void 0 ? chain.head : typeof hashOrNumber === "number" ? await chain.getBlockAt(hashOrNumber) : await chain.getBlock(hashOrNumber);
if (!block) return void 0;
return {
hash: block.hash,
number: block.number
};
},
catch: (cause) => new ChopsticksBlockError({
cause,
operation: "getBlock",
blockIdentifier: hashOrNumber
})
}),
setHead: (hashOrNumber) => Effect10.tryPromise({
try: async () => {
const block = typeof hashOrNumber === "number" ? await chain.getBlockAt(hashOrNumber) : await chain.getBlock(hashOrNumber);
if (!block) {
throw new Error(`Block not found: ${hashOrNumber}`);
}
await chain.setHead(block);
},
catch: (cause) => new ChopsticksBlockError({
cause,
operation: "setHead",
blockIdentifier: hashOrNumber
})
}),
submitUpwardMessages: (paraId, messages) => Effect10.try({
try: () => chain.submitUpwardMessages(paraId, messages),
catch: (cause) => new ChopsticksXcmError({
cause,
messageType: "ump",
paraId
})
}),
submitDownwardMessages: (messages) => Effect10.try({
try: () => chain.submitDownwardMessages(messages),
catch: (cause) => new ChopsticksXcmError({
cause,
messageType: "dmp"
})
}),
submitHorizontalMessages: (paraId, messages) => Effect10.try({
try: () => chain.submitHorizontalMessages(paraId, messages),
catch: (cause) => new ChopsticksXcmError({
cause,
messageType: "hrmp",
paraId
})
})
});
var acquireChopsticks = (config) => Effect10.acquireRelease(
// Acquire: Setup chopsticks (first get modules, then setup)
Effect10.promise(() => getChopsticksModules("silent")).pipe(
Effect10.flatMap(
(modules) => Effect10.tryPromise({
try: () => modules.setupWithServer(prepareConfigForSetup(config)),
catch: (cause) => new ChopsticksSetupError({
cause,
endpoint: getEndpointString(config.endpoint),
block: config.block ?? void 0
})
})
),
Effect10.tap(
(context) => Effect10.sync(() => logger7.debug(`Chopsticks started at ${context.addr}`))
)
),
// Release: Cleanup chopsticks
(context) => Effect10.tryPromise({
try: async () => {
logger7.debug("Closing chopsticks...");
await context.close();
logger7.debug("Chopsticks closed");
},
catch: (cause) => new ChopsticksCleanupError({ cause })
}).pipe(
Effect10.catchAll(
(error) => Effect10.sync(() => {
logger7.error(`Failed to cleanly close chopsticks: ${error}`);
})
)
)
);
var ChopsticksServiceLive = Layer7.scoped(
ChopsticksService,
Effect10.gen(function* () {
const config = yield* ChopsticksConfigTag;
const context = yield* acquireChopsticks(config);
const port = Number.parseInt(context.addr.split(":")[1], 10);
const serviceMethods = createServiceMethods(context.chain);
return {
chain: context.chain,
addr: context.addr,
port,
...serviceMethods
};
})
);
// src/internal/effect/ChopsticksMultiChain.ts
import { Context as Context7, Data as Data3, Effect as Effect11, Layer as Layer8 } from "effect";
import { createLogger as createLogger8 } from "@moonwall/util";
var logger8 = createLogger8({ name: "ChopsticksMultiChain" });
var ChopsticksOrchestrationError = class extends Data3.TaggedError("ChopsticksOrchestrationError") {
};
var ChopsticksMultiChainService = class extends Context7.Tag("ChopsticksMultiChainService")() {
};
// src/internal/node.ts
var logger9 = createLogger9({ name: "node" });
var debug4 = logger9.debug.bind(logger9);
async function launchDockerContainer(imageName, args, name, dockerConfig) {
const docker = new Docker();
const port = args.find((a) => a.includes("port"))?.split("=")[1];
debug4(`\x1B[36mStarting Docker container ${imageName} on port ${port}...\x1B[0m`);
const dirPath = path4.join(process.cwd(), "tmp", "node_logs");
const logLocation = path4.join(dirPath, `${name}_docker_${Date.now()}.log`);
const fsStream = fs5.createWriteStream(logLocation);
process.env.MOON_LOG_LOCATION = logLocation;
const portBindings = dockerConfig?.exposePorts?.reduce(
(acc, { hostPort, internalPort }) => {
acc[`${internalPort}/tcp`] = [{ HostPort: hostPort.toString() }];
return acc;
},
{}
);
const rpcPort = args.find((a) => a.includes("rpc-port"))?.split("=")[1];
invariant(rpcPort, "RPC port not found, this is a bug");
const containerOptions = {
Image: imageName,
platform: "linux/amd64",
Cmd: args,
name: dockerConfig?.containerName || `moonwall_${name}_${Date.now()}`,
ExposedPorts: {
...Object.fromEntries(
dockerConfig?.exposePorts?.map(({ internalPort }) => [`${internalPort}/tcp`, {}]) || []
),
[`${rpcPort}/tcp`]: {}
},
HostConfig: {
PortBindings: {
...portBindings,
[`${rpcPort}/tcp`]: [{ HostPort: rpcPort }]
}
},
Env: dockerConfig?.runArgs?.filter((arg) => arg.startsWith("env:")).map((arg) => arg.slice(4))
};
try {
await pullImage(imageName, docker);
const container = await docker.createContainer(containerOptions);
await container.start();
const containerInfo = await container.inspect();
if (!containerInfo.State.Running) {
const errorMessage = `Container failed to start: ${containerInfo.State.Error}`;
console.error(errorMessage);
fs5.appendFileSync(logLocation, `${errorMessage}
`);
throw new Error(errorMessage);
}
for (let i = 0; i < 300; i++) {
const isReady = await checkWebSocketJSONRPC(Number.parseInt(rpcPort, 10));
if (isReady) {