@moonwall/cli
Version:
Testing framework for the Moon family of projects
1,386 lines (1,375 loc) • 62.7 kB
JavaScript
// 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 fs 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 fs.promises.access(dirPath);
} catch {
await fs.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: () => fs.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
});
var makeNodeReadinessServiceTest = (socketLayer) => Layer3.succeed(NodeReadinessService, {
checkReady: (config) => checkReadyWithRetryInternal(config).pipe(Effect3.provide(socketLayer))
});
// 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 os 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 === os.hostname();
if (!isSameHost) return false;
const alive = yield* isProcessAlive(info.pid);
return !alive;
});
var cleanupStaleLock = (lockPath) => Effect6.gen(function* () {
const fs4 = yield* FileSystem.FileSystem;
const infoPath = `${lockPath}/lock.json`;
const exists = yield* fs4.exists(infoPath).pipe(Effect6.orElseSucceed(() => false));
if (!exists) return;
const content = yield* fs4.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* fs4.remove(lockPath, { recursive: true }).pipe(Effect6.ignore);
}
});
var writeLockInfo = (lockPath) => Effect6.gen(function* () {
const fs4 = yield* FileSystem.FileSystem;
const info = {
pid: process.pid,
timestamp: Date.now(),
hostname: os.hostname()
};
yield* fs4.writeFileString(`${lockPath}/lock.json`, JSON.stringify(info)).pipe(Effect6.ignore);
});
var tryAcquireLock = (lockPath) => Effect6.gen(function* () {
const fs4 = yield* FileSystem.FileSystem;
yield* cleanupStaleLock(lockPath);
yield* fs4.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 fs4 = yield* FileSystem.FileSystem;
yield* fs4.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 fs4 = yield* FileSystem2.FileSystem;
const hash = crypto.createHash("sha256");
yield* fs4.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 fs4 = yield* FileSystem2.FileSystem;
const pathService = yield* Path2.Path;
const exists = yield* fs4.exists(dir);
if (!exists) return Option2.none();
const files = yield* fs4.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 fs4 = yield* FileSystem2.FileSystem;
const savedHash = yield* fs4.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* fs4.access(wasmPath.value).pipe(
Effect7.as(true),
Effect7.orElseSucceed(() => false)
);
return accessible ? wasmPath : Option2.none();
});
var runPrecompile = (binPath, chainArg, outputDir) => Effect7.gen(function* () {
const fs4 = 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* fs4.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 fs4 = 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* fs4.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 fs4 = yield* FileSystem2.FileSystem;
const pathService = yield* Path2.Path;
const rawSpecPath = pathService.join(cacheSubDir, `${chainName}-raw.json`);
const exists = yield* fs4.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 fs4 = 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* fs4.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* fs4.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 fs3 from "fs";
import * as path from "path";
// src/internal/effect/chopsticksConfigParser.ts
import { Effect as Effect9 } from "effect";
import * as fs2 from "fs";
import * as yaml from "yaml";
import { createLogger as createLogger6 } from "@moonwall/util";
var BuildBlockModeValues = {
Batch: "Batch",
Manual: "Manual",
Instant: "Instant"
};
var logger6 = createLogger6({ name: "chopsticksConfigParser" });
var resolveEnvVars = (value) => {
return value.replace(/\$\{env\.([^}]+)\}/g, (_, varName) => {
const envValue = process.env[varName];
if (envValue === void 0) {
logger6.warn(`Environment variable ${varName} is not set`);
return "";
}
return envValue;
});
};
var resolveEnvVarsDeep = (value) => {
if (typeof value === "string") {
return resolveEnvVars(value);
}
if (Array.isArray(value)) {
return value.map(resolveEnvVarsDeep);
}
if (value !== null && typeof value === "object") {
const result = {};
for (const [key, val] of Object.entries(value)) {
result[key] = resolveEnvVarsDeep(val);
}
return result;
}
return value;
};
var parseBuildBlockMode = (mode) => {
switch (mode?.toLowerCase()) {
case "batch":
return BuildBlockModeValues.Batch;
case "instant":
return BuildBlockModeValues.Instant;
case "manual":
default:
return BuildBlockModeValues.Manual;
}
};
var parseBlockField = (block) => {
if (block === void 0 || block === null) {
return block;
}
if (typeof block === "number") {
return block;
}
if (block === "") {
return void 0;
}
const blockNum = Number(block);
if (!Number.isNaN(blockNum)) {
return blockNum;
}
return block;
};
var parseChopsticksConfigFile = (configPath, overrides) => Effect9.gen(function* () {
const fileContent = yield* Effect9.tryPromise({
try: async () => {
const content = await fs2.promises.readFile(configPath, "utf-8");
return content;
},
catch: (cause) => new ChopsticksSetupError({
cause,
endpoint: `file://${configPath}`
})
});
const rawConfigUnresolved = yield* Effect9.try({
try: () => yaml.parse(fileContent),
catch: (cause) => new ChopsticksSetupError({
cause: new Error(`Failed to parse YAML config: ${cause}`),
endpoint: `file://${configPath}`
})
});
const rawConfig = resolveEnvVarsDeep(rawConfigUnresolved);
const rawEndpoint = rawConfig.endpoint;
const endpoint = typeof rawEndpoint === "string" ? rawEndpoint : Array.isArray(rawEndpoint) ? rawEndpoint[0] : "";
if (!endpoint) {
return yield* Effect9.fail(
new ChopsticksSetupError({
cause: new Error(
`Endpoint is required but not configured. Check that the environment variable in your chopsticks config is set. Raw value: "${rawConfigUnresolved.endpoint ?? ""}"`
),
endpoint: String(rawConfigUnresolved.endpoint) || "undefined"
})
);
}
if (!endpoint.startsWith("ws://") && !endpoint.startsWith("wss://")) {
return yield* Effect9.fail(
new ChopsticksSetupError({
cause: new Error(
`Invalid endpoint format: "${endpoint}" - must start with ws:// or wss://`
),
endpoint
})
);
}
const block = parseBlockField(rawConfig.block);
const rawBuildBlockMode = rawConfig["build-block-mode"];
const buildBlockMode = overrides?.buildBlockMode !== void 0 ? parseBuildBlockMode(overrides.buildBlockMode) : rawBuildBlockMode !== void 0 ? parseBuildBlockMode(rawBuildBlockMode) : BuildBlockModeValues.Manual;
const finalPort = overrides?.port ?? rawConfig.port ?? 8e3;
logger6.debug(`Parsed chopsticks config from ${configPath}`);
logger6.debug(` endpoint: ${endpoint}`);
logger6.debug(` port: ${finalPort}`);
const config = {
...rawConfig,
block,
"build-block-mode": buildBlockMode,
port: finalPort,
...overrides?.host !== void 0 && { host: overrides.host },
...overrides?.wasmOverride !== void 0 && { "wasm-override": overrides.wasmOverride },
...overrides?.allowUnresolvedImports !== void 0 && {
"allow-unresolved-imports": overrides.allowUnresolvedImports
}
};
return config;
});
var validateChopsticksConfig = (config) => Effect9.gen(function* () {
const endpoint = typeof config.endpoint === "string" ? config.endpoint : Array.isArray(config.endpoint) ? config.endpoint[0] : void 0;
if (!endpoint) {
return yield* Effect9.fail(
new ChopsticksSetupError({
cause: new Error("Endpoint is required - check your environment variables"),
endpoint: "undefined"
})
);
}
if (!endpoint.startsWith("ws://") && !endpoint.startsWith("wss://")) {
return yield* Effect9.fail(
new ChopsticksSetupError({
cause: new Error(
`Invalid endpoint format: "${endpoint}" - must start with ws:// or wss://`
),
endpoint
})
);
}
return config;
});
// src/internal/effect/launchChopsticksEffect.ts
var BuildBlockModeValues2 = {
Batch: "Batch",
Manual: "Manual",
Instant: "Instant"
};
var chopsticksLoggerConfigured = false;
var chopsticksModuleCache = null;
var logger7 = createLogger7({ name: "launchChopsticksEffect" });
function createLogFile(port) {
const dirPath = path.join(process.cwd(), "tmp", "node_logs");
if (!fs3.existsSync(dirPath)) {
fs3.mkdirSync(dirPath, { recursive: true });
}
const logPath = path.join(dirPath, `chopsticks_${port}_${Date.now()}.log`);
const writeStream = fs3.createWriteStream(logPath);
process.env.MOON_LOG_LOCATION = logPath;
return { logPath, writeStream };
}
function writeLog(stream, level, message) {
const timestamp = (/* @__PURE__ */ new Date()).toISOString();
stream.write(`[${timestamp}] [${level.toUpperCase()}] ${message}
`);
}
var chopsticksLogStream = null;
function setChopsticksLogStream(stream) {
chopsticksLogStream = stream;
}
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;
};
}
}
function configureChopsticksLogger(level = "inherit") {
if (chopsticksLoggerConfigured) {
return;
}
chopsticksLoggerConfigured = true;
if (!chopsticksModuleCache) {
return;
}
const resolvedLevel = level === "inherit" ? process.env.LOG_LEVEL || "info" : level;
chopsticksModuleCache.pinoLogger.level = resolvedLevel;
logger7.debug(`Chopsticks internal logger level: ${resolvedLevel}`);
}
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
})
})
});
async function launchChopsticksEffect(config) {
const startTime = Date.now();
logger7.debug(`[T+0ms] Starting chopsticks with endpoint: ${config.endpoint}`);
const port = config.port ?? 8e3;
const { logPath, writeStream } = createLogFile(port);
setChopsticksLogStream(writeStream);
const program = Effect10.gen(function* () {
const chopsticksModules = yield* Effect10.promise(() => getChopsticksModules("inherit"));
const args = prepareConfigForSetup(config);
logger7.debug(`[T+${Date.now() - startTime}ms] Calling setupWithServer...`);
const context = yield* Effect10.tryPromise({
try: () => chopsticksModules.setupWithServer(args),
catch: (cause) => new ChopsticksSetupError({
cause,
endpoint: getEndpointString(config.endpoint),
block: config.block ?? void 0
})
});
const actualPort = Number.parseInt(context.addr.split(":")[1], 10);
const chainName = yield* Effect10.promise(() => context.chain.api.getSystemChain());
logger7.info(`${chainName} RPC listening on ws://${context.addr}`);
logger7.debug(`[T+${Date.now() - startTime}ms] Chopsticks started at ${context.addr}`);
logger7.debug(`Log file: ${logPath}`);
writeLog(writeStream, "info", `Chopsticks started for ${chainName}`);
writeLog(writeStream, "info", `RPC listening on ws://${context.addr}`);
writeLog(writeStream, "info", `Endpoint: ${config.endpoint}`);
if (config.block) {
writeLog(writeStream, "info", `Block: ${config.block}`);
}
const cleanup2 = Effect10.tryPromise({
try: async () => {
logger7.debug("Closing chopsticks...");
writeLog(writeStream, "info", "Shutting down chopsticks...");
await context.close();
writeLog(writeStream, "info", "Chopsticks closed");
setChopsticksLogStream(null);
writeStream.end();
logger7.debug("Chopsticks closed");
},
catch: (cause) => new ChopsticksCleanupError({ cause })
}).pipe(
Effect10.catchAll(
(error) => Effect10.sync(() => {
logger7.error(`Failed to cleanly close chopsticks: ${error}`);
writeLog(writeStream, "error", `Failed to close: ${error}`);
setChopsticksLogStream(null);
writeStream.end();
})
)
);
const serviceMethods = createServiceMethods(context.chain);
const service2 = {
chain: context.chain,
addr: context.addr,
port: actualPort,
...serviceMethods
};
return { service: service2, cleanup: cleanup2 };
});
const { service, cleanup } = await Effect10.runPromise(program);
return {
result: service,
cleanup: () => Effect10.runPromise(cleanup)
};
}
var launchChopsticksEffectProgram = (config) => Effect10.gen(function* () {
const chopsticksModules = yield* Effect10.promise(() => getChopsticksModules("silent"