UNPKG

@moonwall/cli

Version:

Testing framework for the Moon family of projects

1,406 lines (1,389 loc) 54.5 kB
// 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) {