@moonwall/cli
Version:
Testing framework for the Moon family of projects
591 lines (587 loc) • 20.3 kB
JavaScript
// src/internal/effect/launchChopsticksEffect.ts
import { Effect as Effect3, Layer } from "effect";
import { createLogger as createLogger2 } from "@moonwall/util";
import * as fs2 from "fs";
import * as path from "path";
// src/internal/effect/ChopsticksService.ts
import { Context, Data } from "effect";
var ChopsticksSetupError = class extends Data.TaggedError("ChopsticksSetupError") {
};
var ChopsticksBlockError = class extends Data.TaggedError("ChopsticksBlockError") {
};
var ChopsticksStorageError = class extends Data.TaggedError("ChopsticksStorageError") {
};
var ChopsticksExtrinsicError = class extends Data.TaggedError("ChopsticksExtrinsicError") {
};
var ChopsticksXcmError = class extends Data.TaggedError("ChopsticksXcmError") {
};
var ChopsticksCleanupError = class extends Data.TaggedError("ChopsticksCleanupError") {
};
var ChopsticksService = class extends Context.Tag("ChopsticksService")() {
};
var ChopsticksConfigTag = class extends Context.Tag("ChopsticksConfig")() {
};
// src/internal/effect/chopsticksConfigParser.ts
import { Effect as Effect2 } from "effect";
import * as fs from "fs";
import * as yaml from "yaml";
import { createLogger } from "@moonwall/util";
var BuildBlockModeValues = {
Batch: "Batch",
Manual: "Manual",
Instant: "Instant"
};
var logger = createLogger({ name: "chopsticksConfigParser" });
var resolveEnvVars = (value) => {
return value.replace(/\$\{env\.([^}]+)\}/g, (_, varName) => {
const envValue = process.env[varName];
if (envValue === void 0) {
logger.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) => Effect2.gen(function* () {
const fileContent = yield* Effect2.tryPromise({
try: async () => {
const content = await fs.promises.readFile(configPath, "utf-8");
return content;
},
catch: (cause) => new ChopsticksSetupError({
cause,
endpoint: `file://${configPath}`
})
});
const rawConfigUnresolved = yield* Effect2.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* Effect2.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* Effect2.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;
logger.debug(`Parsed chopsticks config from ${configPath}`);
logger.debug(` endpoint: ${endpoint}`);
logger.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;
});
// src/internal/effect/launchChopsticksEffect.ts
var BuildBlockModeValues2 = {
Batch: "Batch",
Manual: "Manual",
Instant: "Instant"
};
var chopsticksLoggerConfigured = false;
var chopsticksModuleCache = null;
var logger2 = createLogger2({ name: "launchChopsticksEffect" });
function createLogFile(port) {
const dirPath = path.join(process.cwd(), "tmp", "node_logs");
if (!fs2.existsSync(dirPath)) {
fs2.mkdirSync(dirPath, { recursive: true });
}
const logPath = path.join(dirPath, `chopsticks_${port}_${Date.now()}.log`);
const writeStream = fs2.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;
logger2.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;
logger2.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) => Effect3.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) => Effect3.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) => Effect3.tryPromise({
try: () => chain.submitExtrinsic(extrinsic),
catch: (cause) => new ChopsticksExtrinsicError({
cause,
operation: "submit",
extrinsic
})
}),
dryRunExtrinsic: (extrinsic, at) => Effect3.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) => Effect3.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) => Effect3.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) => Effect3.try({
try: () => chain.submitUpwardMessages(paraId, messages),
catch: (cause) => new ChopsticksXcmError({
cause,
messageType: "ump",
paraId
})
}),
submitDownwardMessages: (messages) => Effect3.try({
try: () => chain.submitDownwardMessages(messages),
catch: (cause) => new ChopsticksXcmError({
cause,
messageType: "dmp"
})
}),
submitHorizontalMessages: (paraId, messages) => Effect3.try({
try: () => chain.submitHorizontalMessages(paraId, messages),
catch: (cause) => new ChopsticksXcmError({
cause,
messageType: "hrmp",
paraId
})
})
});
async function launchChopsticksEffect(config) {
const startTime = Date.now();
logger2.debug(`[T+0ms] Starting chopsticks with endpoint: ${config.endpoint}`);
const port = config.port ?? 8e3;
const { logPath, writeStream } = createLogFile(port);
setChopsticksLogStream(writeStream);
const program = Effect3.gen(function* () {
const chopsticksModules = yield* Effect3.promise(() => getChopsticksModules("inherit"));
const args = prepareConfigForSetup(config);
logger2.debug(`[T+${Date.now() - startTime}ms] Calling setupWithServer...`);
const context = yield* Effect3.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* Effect3.promise(() => context.chain.api.getSystemChain());
logger2.info(`${chainName} RPC listening on ws://${context.addr}`);
logger2.debug(`[T+${Date.now() - startTime}ms] Chopsticks started at ${context.addr}`);
logger2.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 = Effect3.tryPromise({
try: async () => {
logger2.debug("Closing chopsticks...");
writeLog(writeStream, "info", "Shutting down chopsticks...");
await context.close();
writeLog(writeStream, "info", "Chopsticks closed");
setChopsticksLogStream(null);
writeStream.end();
logger2.debug("Chopsticks closed");
},
catch: (cause) => new ChopsticksCleanupError({ cause })
}).pipe(
Effect3.catchAll(
(error) => Effect3.sync(() => {
logger2.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 Effect3.runPromise(program);
return {
result: service,
cleanup: () => Effect3.runPromise(cleanup)
};
}
var launchChopsticksEffectProgram = (config) => Effect3.gen(function* () {
const chopsticksModules = yield* Effect3.promise(() => getChopsticksModules("silent"));
const args = prepareConfigForSetup(config);
const context = yield* Effect3.tryPromise({
try: () => chopsticksModules.setupWithServer(args),
catch: (cause) => new ChopsticksSetupError({
cause,
endpoint: getEndpointString(config.endpoint),
block: config.block ?? void 0
})
});
const port = Number.parseInt(context.addr.split(":")[1], 10);
const cleanup = Effect3.tryPromise({
try: () => context.close(),
catch: (cause) => new ChopsticksCleanupError({ cause })
}).pipe(
Effect3.catchAll(
(error) => Effect3.sync(() => {
logger2.error(`Failed to cleanly close chopsticks: ${error}`);
})
)
);
const serviceMethods = createServiceMethods(context.chain);
const service = {
chain: context.chain,
addr: context.addr,
port,
...serviceMethods
};
return { result: service, cleanup };
});
var acquireChopsticks = (config) => Effect3.acquireRelease(
// Acquire: Setup chopsticks (first get modules, then setup)
Effect3.promise(() => getChopsticksModules("silent")).pipe(
Effect3.flatMap(
(modules) => Effect3.tryPromise({
try: () => modules.setupWithServer(prepareConfigForSetup(config)),
catch: (cause) => new ChopsticksSetupError({
cause,
endpoint: getEndpointString(config.endpoint),
block: config.block ?? void 0
})
})
),
Effect3.tap(
(context) => Effect3.sync(() => logger2.debug(`Chopsticks started at ${context.addr}`))
)
),
// Release: Cleanup chopsticks
(context) => Effect3.tryPromise({
try: async () => {
logger2.debug("Closing chopsticks...");
await context.close();
logger2.debug("Chopsticks closed");
},
catch: (cause) => new ChopsticksCleanupError({ cause })
}).pipe(
Effect3.catchAll(
(error) => Effect3.sync(() => {
logger2.error(`Failed to cleanly close chopsticks: ${error}`);
})
)
)
);
var ChopsticksServiceLayer = (config) => Layer.scoped(
ChopsticksService,
Effect3.gen(function* () {
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
};
})
);
var ChopsticksServiceLive = Layer.scoped(
ChopsticksService,
Effect3.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
};
})
);
async function launchChopsticksFromSpec(spec, options) {
const timeout = options?.timeout ?? 6e4;
const startTime = Date.now();
logger2.debug(`Launching chopsticks from spec: ${spec.configPath}`);
const parseEffect = parseChopsticksConfigFile(spec.configPath, {
port: spec.wsPort,
host: spec.address,
buildBlockMode: spec.buildBlockMode,
wasmOverride: spec.wasmOverride,
allowUnresolvedImports: spec.allowUnresolvedImports
});
let configResult;
try {
configResult = await Effect3.runPromise(
parseEffect.pipe(
Effect3.timeout(timeout),
Effect3.catchTag(
"TimeoutException",
() => Effect3.fail(
new ChopsticksSetupError({
cause: new Error(`Config parsing timed out after ${timeout}ms`),
endpoint: spec.configPath
})
)
)
)
);
} catch (error) {
const errorString = String(error);
const causeMatch = errorString.match(/\[cause\]:\s*Error:\s*(.+)/s);
if (causeMatch) {
throw new Error(`Chopsticks config validation failed: ${causeMatch[1].trim()}`);
}
throw new Error(`Chopsticks config validation failed: ${errorString}`);
}
logger2.debug(`Config parsed in ${Date.now() - startTime}ms`);
logger2.debug(` endpoint: ${configResult.endpoint}`);
logger2.debug(` port: ${configResult.port}`);
let service;
let cleanup;
try {
const result = await launchChopsticksEffect(configResult);
service = result.result;
cleanup = result.cleanup;
} catch (error) {
const errorString = String(error);
const causeMatch = errorString.match(/\[cause\]:\s*Error:\s*(.+)/s);
const causeMessage = causeMatch ? causeMatch[1].trim() : errorString;
throw new Error(
`Chopsticks failed to connect to endpoint '${configResult.endpoint}': ${causeMessage}`
);
}
logger2.debug(`Chopsticks launched in ${Date.now() - startTime}ms at ${service.addr}`);
return {
service,
cleanup,
port: service.port,
addr: service.addr
};
}
export {
BuildBlockModeValues2 as BuildBlockModeValues,
ChopsticksServiceLayer,
ChopsticksServiceLive,
configureChopsticksLogger,
launchChopsticksEffect,
launchChopsticksEffectProgram,
launchChopsticksFromSpec,
setChopsticksLogStream
};