rchain-client-api
Version:
RChain client for node.js, browsers
296 lines (263 loc) • 8.02 kB
JavaScript
// @ts-check
import { nodeFetch } from './curl';
import { RNode } from './rnode';
import { startTerm, listenAtDeployId } from './proxy';
// @ts-ignore
const { keys, freeze, fromEntries } = Object;
// TODO: vary rhoDir for local, testnet, mainnet
export const rhoDir = 'rho_modules';
export const rhoInfoPath = (src) =>
`${rhoDir}/${src.replace(/\.rho$/, '.json')}`;
export const importPattern = /match\s*\("import",\s*"(?<specifier>[^"]+)",\s*`(?<uri>rho:id:[^`]*)`\)/g;
function log(...args) {
if (
typeof process !== 'undefined' &&
typeof process.env !== 'undefined' &&
!process.env.LOG_RHOPM_QUIET
) {
console.log(...args);
}
}
/**
* @param {{src: string, dataForDeploy: RhoExprWithBlock }} info
* WARNING: we assume expr is URI
*
* @typedef { import('./rnode-openapi-schema').RhoExpr } RhoExpr
*/
export function depEntry(info) {
const {
src: dep,
dataForDeploy: {
expr: {
// @ts-ignore
ExprUri: { data: uri },
},
},
} = info;
return [dep, uri];
}
/**
* @param {string} src
* @param {string} term
* @param {{[specifier: string]: string}} uriByDep
*/
export function fixupImports(src, term, uriByDep) {
const each = (_match, specifier, _uri) => {
const dep = specifier.replace(/^\.\//, ''); // TODO: more path resolution?
const uri = uriByDep[dep];
if (!uri) {
throw new Error(
`failed to satisfy ${src} -> ${dep} dependency among ${keys(uriByDep)}`,
);
}
return `match ("import", "${specifier}", \`${uri}\`)`;
};
return term.replace(importPattern, each);
}
/**
* WARNING: we assume format of depTargets files are correct:
* {src: string, dataForDeploy: RhoExprWithBlock }
*
* @param {string} src
* @param {string[]} depTargets
* @param {{ readFile: typeof import('fs').promises.readFile }} io
* @returns { Promise<string> }
*/
export async function resolveDeps(src, depTargets, { readFile }) {
log('resolve', { src });
const termRaw = await readFile(src, 'utf8');
log('resolve', { src, depTargets });
const reading = depTargets.map((fn) => readFile(fn, 'utf8'));
const info = (await Promise.all(reading)).map((txt) => JSON.parse(txt));
const byDep = fromEntries(info.map(depEntry));
log({ src, byDep });
return fixupImports(src, termRaw, byDep);
}
/**
* @template T
* @param {undefined | T} x
* @returns {T}
*/
function notNull(x) {
if (!x) {
throw new Error('null!');
}
return x;
}
/**
* @param {string} term
* @returns { string[] }
*/
export function findImports(term) {
return [...term.matchAll(importPattern)].map(
(m) => notNull(m.groups).specifier,
);
}
/** @type { (n: number) => (s: string) => string } */
const abbr = (n) => (s) =>
(s.length > n ? `${s.slice(0, n)}...` : s).replace(/\s+/g, ' ');
/**
* @param {{
* validator: Validator,
* observer: Observer,
* startProposing: () => void,
* stopProposing: () => void,
* }} shard
* @param {Account} account
* @param {{ readFile: typeof import('fs').promises.readFile }} io
*
* @typedef { import('./rnode').Validator } Validator
* @typedef { import('./rnode').Observer } Observer
* @typedef { import('./proxy').Account } Account
* @typedef { import('./rnode-openapi-schema').DeployRequest } DeployRequest
* @typedef { import('./rnode-openapi-schema').RhoExprWithBlock } RhoExprWithBlock
*/
export function PkgManager(shard, account, { readFile }) {
/** @type { (parts?: string[], limit?: number) => (...more: string[]) => void } */
const progressFn = (parts = [], limit = 24) => (...more) => {
more.map(abbr(limit)).forEach((s) => {
parts.push(s);
});
log(...parts);
};
const { validator, observer } = shard;
return freeze({
/**
* @param {string} src
* @param {string[]} deps
* @returns {Promise<{ src: string, signed: DeployRequest, dataForDeploy: RhoExprWithBlock }>}
*/
async deploy(src, deps) {
const depTargets = deps.map(rhoInfoPath);
const progress = progressFn([src]);
progress('deps:', `${depTargets.length}`);
const term = await resolveDeps(src, depTargets, { readFile });
progress('{', abbr(24)(term), '}', observer.apiBase(), 'after:');
const signed = await account.sign(term);
shard.startProposing();
progress(
`${signed.data.validAfterBlockNumber}`,
'sig:',
signed.signature,
'deploy',
);
const progressAcct = {
sign: account.sign,
async polling() {
await account.polling();
progress('@'); // (very) short for "still waiting for data at name"
},
};
const deploy = await startTerm(term, validator, observer, progressAcct);
const dataForDeploy = await listenAtDeployId(observer, deploy);
const { expr: result } = dataForDeploy;
if (!('ExprUri' in result)) {
throw TypeError(`expected URI; got ${result}`);
}
progress(JSON.stringify(result.ExprUri.data));
shard.stopProposing();
return { src, signed, dataForDeploy };
},
});
}
export function makeContractTask(
TARGETS,
{ jake, io: { readFile, writeFile }, shard, account },
) {
const mgr = PkgManager(shard, account, { readFile });
return function contractTask(src, deps = []) {
const depTargets = deps.map((d) => TARGETS[d]);
jake.desc(`deploy ${src}${deps.length ? ' -> ' : ''}${deps}`);
jake.file(TARGETS[src], [src, ...depTargets], async () => {
const { signed, dataForDeploy } = await mgr.deploy(src, deps);
await writeFile(
TARGETS[src],
JSON.stringify({ src, signed, dataForDeploy }, null, 2),
);
});
};
}
/** @type {(txt: string) => {[name: string]: string}} */
function parseEnv(txt) {
const bindings = txt
.split('\n')
.filter((line) => !line.trim().startsWith('#'))
.map((line) => line.match(/(?<name>\w+)\s*=\s*(?<value>.*)/))
.filter((parts) => parts && parts.groups)
// @ts-ignore
.map((parts) => [parts.groups.name, parts.groups.value]);
return Object.fromEntries(bindings);
}
/**
* @param {Record<string, string | undefined>} env
* @param {{ admin?: string, boot: string, read: string }} api
* @param {typeof import('http')} http
* @param {SchedulerAccess} sched
* @param {number=} period
*
* @typedef { {
* setInterval: typeof setInterval,
* clearInterval: typeof clearInterval,
* } } SchedulerAccess
*/
export function shardAccess(env, api, http, sched, period = 2 * 1000) {
const fetch = nodeFetch({ http });
const rnode = RNode(fetch);
let proposing = false;
let waiters = 0;
let pid;
return freeze({
env,
...api,
validator: rnode.validator(api.boot),
observer: rnode.observer(api.read),
startProposing() {
if (!api.admin) return;
const proposer = rnode.admin(api.admin);
waiters += 1;
if (typeof pid !== 'undefined') {
return;
}
pid = sched.setInterval(() => {
if (!proposing) {
proposing = true;
proposer
.propose()
.then(() => {
console.log('proposed', { waiters });
proposing = false;
})
.catch((err) => {
console.log('propose failed', { waiters, err: err.message });
proposing = false;
});
}
}, period);
},
stopProposing() {
if (waiters <= 0) {
return;
}
waiters -= 1;
sched.clearInterval(pid);
pid = undefined;
},
});
}
/**
* Local shard I/O
*
* @param {string} envText
* @param {typeof import('http')} http
* @param {SchedulerAccess} sched
* @param {number} period
*/
export function shardIO(envText, http, sched, period = 2 * 1000) {
const env = parseEnv(envText);
const api = {
admin: `http://${env.MY_NET_IP}:40405`,
boot: `http://${env.MY_NET_IP}:40403`,
read: `http://${env.MY_NET_IP}:40413`,
};
return shardAccess(env, api, http, sched, period);
}