dcp-client
Version:
Core libraries for accessing DCP network
1,219 lines (1,092 loc) • 64.3 kB
JavaScript
/**
* @file index.js
* NodeJS entry point for the dcp-client package.
*
* During module initialization, we load dist/dcp-client-bundle.js from the
* same directory as this file, and inject the exported modules into the NodeJS
* module environment.
*
* There are three initialization styles provided, which are all basically the same,
* have different calling styles;
* 1. initSync - blocks until initialization is complete
* 2. init - returns a Promise, is an "async" function; resolve when initialization is complete
* 3. initcb - invokes a callback when initialization is complete
*
* During initialization, we
* 2. build the layered dcp-config for the program invoking init
* (see: https://people.kingsds.network/wesgarland/dcp-client-priorities.html /wg aug 2020)
* 3. download a new bundle if auto-update is on
* 4. make the bundle "see" the layered dcp-config as their global dcpConfig
* 5. re-inject the bundle modules (possibly from the new bundle)
*
* @author Wes Garland, wes@kingsds.network
* @date July 2019
*/
'use strict';
var reportErrors = true; /* can be overridden by options.reportErrors during init() */
var KVIN; /* KVIN context from internal kvin */
var XMLHttpRequest; /* from internal dcp-xhr */
const os = require('os');
const fs = require('fs')
const path = require('path');
const process = require('process');
const assert = require('assert');
const debug = require('debug');
const moduleSystem = require('module');
const { spawnSync } = require('child_process');
const vm = require('vm');
const protectedDcpConfigKeys = [ 'system', 'bundle', 'worker', 'evaluator' ];
let initInvoked = false; /* flag to help us detect use of Compute API before init */
let originalDcpConfig = globalThis.dcpConfig || undefined; /* not undefined if user set their own dcpConfig global variable before init */
globalThis.dcpConfig = originalDcpConfig || { __filename };
const distDir = path.resolve(path.dirname(module.filename), 'dist');
/* Registry import code knows to make strings into URLs when going on top of URLs; so we need to provide
* nodes of the right that we can expect to be overwritten by the registry.
*/
const bootstrapConfig = {
__bootstrapConfig: true,
bundleConfig: true,
scheduler: { location: new URL('http://bootstrap.distributed.computer/') },
bank: { location: new URL('http://bootstrap.distributed.computer/') },
packageManager: { location: new URL('http://bootstrap.distributed.computer/') },
portal: { location: new URL('http://bootstrap.distributed.computer/') },
pxAuth: { location: new URL('http://bootstrap.distributed.computer/') },
oAuth: { location: new URL('http://bootstrap.distributed.computer/') },
cdn: { location: new URL('http://bootstrap.distributed.computer/') },
worker: {},
global: {},
dcp: {},
};
const bundleScope = {
URL,
URLSearchParams,
// GPU,
// GPUDevice,
// GPUAdapter,
Function,
Object,
Array,
Date,
Int8Array,
Int16Array,
Int32Array,
Uint8Array,
Uint32Array,
Uint8ClampedArray,
Uint16Array,
Float32Array,
Float64Array,
BigInt64Array,
BigUint64Array,
Promise,
Error,
ArrayBuffer,
require, /* becomes requireNative in webpack-native-bridge */
console,
setInterval, clearInterval,
setTimeout, clearTimeout,
setImmediate, clearImmediate,
crypto: { getRandomValues: require('polyfill-crypto.getrandomvalues') },
window: globalThis,
};
/**
* Run code with symbols injected into the function scope.
* @param {Object} symbols An object whose properties are names of symbols which will be available
* in the evaluation scope; the symbol values are the object values.
*/
function runCodeWithSymbols(symbols, code, options)
{
const wrapperFunArgs = Object.keys(symbols);
const invokeArgs = Object.values(symbols);
const wrappedCode = `"use strict";\n(function __rcws_wrapper(${wrapperFunArgs.join(',')}) { ${code} })`;
options = Object.assign({ columnOffset: Number(options?.columnOffset) + 12 }, options);
const script = new vm.Script(wrappedCode, options);
const rcwsWrapper = script.runInThisContext(options);
return rcwsWrapper.apply(globalThis, invokeArgs);
}
/**
* Evaluate a file inside a namespace-protecting IIFE; similar the new vm contexts used for configury,
* but using the current context so that Object literals are instances of this context's Object.
*
* @param {string} filename The name of a file which contains a single JS expression
* @param {object} gsymbox An object which simulates the global object via symbol collision; any
* symbols which don't collide are resolved up the scope chain against this
* context's global object.
* @param {object} options Options to pass to vm.runInThisContext()
*
* @returns the value of the expression in the file
*/
function evalBundleCodeInIIFE(code, gsymbox, options)
{
const prologue = '(function __dynamic_evalBundle__IIFE(' + Object.keys(gsymbox).join(',') + '){ return ';
const epilogue = '\n});';
options = Object.assign({ filename: '(eval code)', lineOffset: 0 }, options);
if (options?.filename)
debug('dcp-client:evalBundle')('evaluating file', options.filename);
else
debug('dcp-client:evalBundle')(`evaluating code ${code.slice(0,40).replace('\n', ' ')}...`);
const fun = vm.runInThisContext(prologue + code + epilogue, options);
return fun.apply(null, Object.values(gsymbox));
}
/**
* Evaluate a file inside a namespace-protecting IIFE; similar the new vm contexts used for configury,
* but using the current context so that Object literals are instances of this context's Object.
*
* @param {string} filename The name of a file which contains a single JS expression
* @param {object} gsymbox An object which simulates the global object via symbol collision; any
* symbols which don't collide are resolved up the scope chain against this
* context's global object.
* @returns the value of the expression in the file
*/
function evalBundleFileInIIFE(filename, gsymbox)
{
const fileContents = fs.readFileSync(path.resolve(distDir, filename), 'utf8');
return evalBundleCodeInIIFE(fileContents, gsymbox, { filename });
}
/**
* Evaluate a config file (eg. dcp-config fragment) in a function scope of the current context that has
* extra symbols injected for use by the config file.
*
* @param symbols {object} An object used for injecting 'global' symbols as needed
* @param filename {string} The name of the file we're evaluating for stack-
* trace purposes.
*/
function evalConfigFile(symbols, filename)
{
var code = fs.readFileSync(filename, 'utf-8');
const codeHasVeryLongLine = Boolean(/[^\n]{1000,}[^\n]*\n/.test(code));
const runOptions = {
filename,
lineOffset: 0,
columnOffset: 0,
displayErrors: !codeHasVeryLongLine
};
if (withoutComments(code).match(/^\s*{/)) /* config file is just a JS object literal */
{
code = `return (${code})`;
runOptions.columnOffset = 8;
}
debug('dcp-client:evalConfigFile')('evaluating config file', runOptions.filename);
return runCodeWithSymbols(symbols, code, runOptions);
}
/**
* Return a version of the code without comments. The algorithm here is pretty basic
* feel free to improve it.
* @param {string} code String to change
*/
function withoutComments(code) {
return code.replace(/(\/\*([\s\S]*?)\*\/)|(\/\/(.*)$)/gm, '')
}
/**
* Load the bootstrap bundle - used primarily to plumb in utils::justFetch.
* Runs in a different, but identical, scope the config files and client code. This code is evaluated
* with the bootstrap config as dcpConfig so that static initialization of dcpConfig in any of the
* bootstrap modules can mutate the bottom-most layer of the dcpConfig stack.
*/
function loadBootstrapBundle() {
const bundleFilename = path.resolve(distDir, 'dcp-client-bundle.js');
const scope = Object.assign(bundleScope, { dcpConfig: bootstrapConfig });
scope.globalThis = scope;
scope.window = scope;
scope.dcpConfig = bootstrapConfig;
debug('dcp-client:bundle')(' - loading bootstrap bundle');
return evalBundleFileInIIFE(bundleFilename, Object.assign({}, bundleScope));
}
const injectedModules = {};
const resolveFilenamePrevious = moduleSystem._resolveFilename;
moduleSystem._resolveFilename = function dcpClient$$injectModule$resolveFilenameShim(moduleIdentifier) {
if (injectedModules.hasOwnProperty(moduleIdentifier)) {
if (!initInvoked) {
if( moduleIdentifier === 'dcp/compute') throw new Error(`module ${moduleIdentifier} cannot be required until the dcp-client::init() promise has been resolved.`);
}
return moduleIdentifier;
}
return resolveFilenamePrevious.apply(null, arguments)
}
/**
* Inject an initialized module into the native NodeJS module system.
*
* @param id {string} module identifier
* @param moduleExports {object} the module's exports object
* @param clobber {boolean} inject on top of an existing module identifier
* if there is a collsion.
* @throw Error if there is a collision and clobber is not truey.
*/
function injectModule(id, moduleExports, clobber) {
if (!clobber && typeof moduleSystem._cache[id] !== 'undefined')
throw new Error(`Module ${id} has already been injected`);
moduleSystem._cache[id] = new (moduleSystem.Module)
moduleSystem._cache[id].id = id
moduleSystem._cache[id].parent = module
moduleSystem._cache[id].exports = moduleExports
moduleSystem._cache[id].filename = id
moduleSystem._cache[id].loaded = true
injectedModules[id] = true;
debug('dcp-client:modules')(` - injected module ${id}: ${typeof moduleExports === 'object' ? Object.keys(moduleExports) : '(' + typeof moduleExports + ')'}`);
}
/**
* Inject modules from a bundle according to a namespace map.
* The namespace map maps bundle exports onto the internal require(dcp/...) namespace.
*
* @param nsMap the namespace map object
* @param bundle the webpack bundle (~moduleGroup object)
* @param clobber {boolean} inject on top of an existing module identifier
* if there is a collsion.
*/
function injectNsMapModules(nsMap, bundle, bundleLabel, clobber) {
bundle = Object.assign({}, bundle);
for (let moduleId in nsMap)
{
let moduleExports = bundle[nsMap[moduleId]];
if (!moduleExports)
{
if (injectedModules[moduleId])
console.warn(`Warning: Bundle '${bundleLabel}' is missing exports for module '${moduleId}'; using version from bootstrap bundle`);
else
console.warn(`Warning: Bundle '${bundleLabel}' is missing exports for module '${moduleId}'; using empty exports object`);
}
injectModule(moduleId, moduleExports || {}, clobber);
}
const nsMapValues = Object.values(nsMap);
for (let moduleId in bundle)
{
if (nsMapValues.indexOf(moduleId) === -1)
{
const moduleExports = bundle[moduleId];
injectModule('dcp/internal/' + moduleId, moduleExports, clobber);
}
}
}
injectModule('dcp/env-native', { platform: 'nodejs' })
/* Inject all properties of the bundle object as modules in the
* native NodeJS module system.
*/
debug('dcp-client:modules')('Begin phase 1 module injection') /* Just enough to be able to load a second bundle */
injectNsMapModules(require('./ns-map'), loadBootstrapBundle(), 'bootstrap');
injectModule('dcp/bootstrap-build', require('dcp/build'));
KVIN = new (require('dcp/internal/kvin')).KVIN();
const bootstrapClasses = {
DcpURL: require('dcp/dcp-url').DcpURL,
Address: require('dcp/wallet').Address,
};
KVIN.userCtors.dcpUrl$$DcpURL = require('dcp/dcp-url').DcpURL;
KVIN.userCtors.dcpEth$$Address = require('dcp/wallet').Address;
/**
* Merge a new configuration object on top of an existing one. The new object is overlaid on the
* existing object, so that own properties specified in the existing object graph overwrite, but
* unspecified edges are left alone.
*
* Instances of Address, URL and dcpUrl::DcpURL receive special treatment: if they are being overwritten
* by another type, that value is used as the argument to the constructor to create a new object that
* replaces the entire value. This allows us, for example, to replace a URL with a registry string and
* still have the correct type once the program loads.
*
* URL types are always copied by value, not by reference.
*
*
* Arrays are concatenated together when merging. In the case where an Array and an Object are merged,
* the result will be an Array and it will be merged with the object's values.
*
* Objects not mentioned above whose constructors are neither Function nor Object are treated as though
* they are primitive values, as they may contain internal state that is not represented solely by
* their own properties.
*
* It is possible to "merge" Objects with Arrays. We basically decide that the Array indicies have no
* special meaning and neither do the own property names.
*
* {} -> {} => ~leaf merge
* [] -> [] => concat except it leaves out duplicate elements
* {} -> [] => Object.entries({}) -> [] => concat except it leaves out duplicate elements onto array
* [] -> {} => [] -> Object with dynamic keys => merge into object
*
* @param {object} existing Top node of an object graph whose edges may be replaced
* @param {object|undefined} neo Top node of an object graph whose edges describe the replacement
*/
globalThis.addConfig = addConfig;
function addConfig (existing, neo, dotPath)
{
const { DcpURL } = require('dcp/dcp-url');
if (neo === undefined || neo === existing)
return;
/* Caveat debuggor: the "adding" output will only show changes from config files where they return
* objects. Any changes which are the result of the config file directly mutating properties of
* dcpConfig might not be displayed. See "magicView" for info about those.
*/
debug('dcp-client:config-verbose')('adding', neo);
if (typeof neo !== 'object')
throw new TypeError(`Unable to merge ${typeof neo} value '${neo}' into ${nodeName()}`);
function isIntrinsic(val)
{
return (val === null || (typeof val !== 'object' && typeof val !== 'function'))
}
function nodeName(edge)
{
if (edge)
return dotPath ? `${dotPath}.${edge}` : edge;
else
return dotPath || 'dcpConfig';
}
/** Make an object from an array so that it can be merged into another object without key collision */
function objFromArrForObj(arr)
{
const numericKeys = Object.keys(arr).map(key => Number(key)).filter(key => !isNaN(key))
const maxNumericKey = numericKeys.length ? numericKeys[numericKeys.length - 1] : NaN;
const nonNumericKeys = Object.keys(arr).filter(key => isNaN(Number(key)));
const tmp = {};
for (let key of nonNumericKeys)
tmp[key] = arr[key];
for (let idx of numericKeys)
tmp[idx + maxNumericKey] = arr[idx];
return tmp;
}
for (const prop of Object.getOwnPropertyNames(neo))
{
/* When existing prop is URL and new prop isn't, use new prop to build a URL.
* Higher precedence than neo[prop] being intrinsic, convert 'http://example.com' to a URL if the existing is a URL
*/
if (DcpURL.isURL(existing?.[prop]))
{
existing[prop] = new existing[prop].constructor(neo[prop]);
continue;
}
if (isIntrinsic(neo[prop]))
{
existing[prop] = neo[prop];
continue;
}
if (neo[prop].constructor === RegExp) /* Regexps are unmergeable and must be cloned */
{
existing[prop] = new RegExp(neo[prop].source, neo[prop].flags);
continue;
}
if (neo[prop].constructor === Promise)
{
existing[prop] = neo[prop];
continue;
}
/* When existing prop is URL and new prop isn't, use new prop to build a URL */
if (DcpURL.isURL(existing?.[prop]))
{
existing[prop] = new existing[prop].constructor(neo[prop]);
continue;
}
if (typeof neo[prop] !== 'object')
throw new TypeError(`Unable to merge ${typeof neo[prop]} value into ${nodeName(prop)}`);
/* When new prop is URL, copy it by value instead of merging */
if (DcpURL.isURL(neo[prop]))
{
const neoUrl = neo[prop];
existing[prop] = new neoUrl.constructor(neoUrl.href);
continue;
}
/* When existing prop is Address and new prop isn't, use new prop to build an Address */
if (existing[prop]?.constructor === bootstrapClasses.Address &&
neo [prop] .constructor !== bootstrapClasses.Address)
{
existing[prop] = new bootstrapClasses.Address(neo[prop]);
continue;
}
switch(neo[prop].constructor)
{
case RegExp: case bootstrapClasses.DcpURL: case DcpURL: case URL:
throw new TypeError(`Unexpected URL type merging ${neo[prop].constructor.name} object into ${nodeName(prop)}`);
case Object:
case Array:
case bootstrapClasses.Address:
break;
case Function: /* previously supported: do we really need this? /wg May 2025 */
default:
throw new TypeError(`Unable to merge ${neo[prop].constructor.name} object into ${nodeName(prop)}`);
}
if (!existing.hasOwnProperty(prop) || isIntrinsic(existing[prop])) /* clone by merging into empty */
existing[prop] = Array.isArray(neo[prop]) ? [] : {};
const neoPropIsArray = Array.isArray(neo [prop]);
const existingPropIsArray = Array.isArray(existing?.[prop]);
const isNotMergeable = (obj) => {
return false
|| typeof obj[prop] !== 'object'
|| (obj[prop].constructor !== Object && obj[prop].constructor !== Array);
};
if (isNotMergeable(existing, prop))
throw new TypeError(`Unable to merge into ${existing?.[prop].constructor?.name || typeof existing} value of ${nodeName(prop)}`);
if (isNotMergeable(neo, prop))
throw new TypeError(`Unable to merge ${neo[prop].constructor?.name || typeof neo[prop]} value into ${nodeName(prop)}`);
let neoOfProp = neo[prop]; /* !!! do not use neo[prop] below here !!! */
/* When one of the objects is an array and the other is a plain object, we transform the new one to
* match the existing one.
*/
if (!neoPropIsArray && existingPropIsArray) /* {} -> [] - merge object onto an array? make an array from the object and merge that. */
neoOfProp = Object.entries(neoOfProp);
else if (neoPropIsArray && !existingPropIsArray) /* [] -> {} - merge array onto an object? make an object from the array and merge that. */
neoOfProp = objFromArrForObj(neoOfProp, existing[prop]);
/* Either merge objects or arrays. Objects collide props, Arrays collide exactly equal values. */
if (!existingPropIsArray)
addConfig(existing[prop], neoOfProp, nodeName(prop));
else
{
/* concat new array at end of old array, omitting values which are already in the array */
for (let value of neoOfProp)
{
if (!existing[prop].includes(value))
existing[prop].push(value);
}
}
}
}
/**
* Returns a graph of empty objects with the same edges as the passed-in node. Only base Objects are
* considered, not instances of derived classes (like URL). The newly-created nodes inherit from their
* equivalent nodes. The net effect is that the returned graph can have its nodes read like usual, but
* writes "stick" to the new nodes instead of modifying the original node.
*
* @param {object} node the top of the object graph
* @param {object} seen internal use only
*
* @returns {object}
*
* @todo inherited objects should actually be read-only Proxies or similar, to prevent users from
* accidentally mutating the internal contents of the underlying object; eg setting the href
* property of a URL inside the default config, when they meant to set the URL itself.
*/
function magicView(node, seen)
{
var edgeNode = Object.create(node);
if (!seen)
seen = new Map();
if (seen.has(node))
return seen.get(node);
for (let prop in node)
{
if (node.hasOwnProperty(prop) && typeof node[prop] === 'object' && node[prop].constructor === {}.constructor)
{
if (node[prop] === node)
edgeNode[prop] = edgeNode;
else
edgeNode[prop] = magicView(node[prop], seen);
seen.set(node[prop], edgeNode[prop]);
}
}
return edgeNode;
}
/**
* Throw an exception if the given fullPath is not a "safe" file to load.
* "Safe" files are those that are unlikely to contain malicious code, as
* they are owned by an administrator or the same person who loaded the
* code.
*
* Returns false is the file simply does not exist.
*
* Setting `DCP_CLIENT_ALLOW_INSECURE_CONFIGURATION` to a non-empty value disables
* the security check.
*
* @param {string} fullPath the full path to the file to check
* @param {object} statBuf [optional] existing stat buf for the file
*/
function checkConfigFileSafePerms(fullPath, statBuf)
{
const fun = checkConfigFileSafePerms;
if (!fs.existsSync(fullPath))
return false;
if (process.env.DCP_CLIENT_ALLOW_INSECURE_CONFIGURATION)
return true;
if (!fun.selfStat)
fun.selfStat = fs.statSync(module.filename);
if (!fun.mainStat)
fun.mainStat = require.main ? fs.statSync(require.main.filename) : {};
statBuf = fs.statSync(fullPath);
/* Disallow files with world-writeable path components. @todo reduce redundant checks */
if (os.platform() !== 'win32')
{
const args = fullPath.split(path.sep);
args[0] = path.sep;
do
{
let check = path.resolve.apply(null, args);
if (fs.statSync(check).mode & 0o002)
throw new Error(`Config ${fullPath} insecure due to world-writeable path component ${check}`);
args.pop();
} while(args.length);
}
/* Permit based on ownership */
if (statBuf.uid === fun.selfStat.uid)
return true; /* owned by same user as dcp-client */
if (statBuf.uid === fun.mainStat.uid)
return true; /* owned by same user as main program */
if (statBuf.uid === process.getuid())
return true; /* owned by user running the code */
if (statBuf.uid === 0)
return true; /* owned by root */
/* Permit based on group membership */
if (statBuf.gid === fun.mainStat.gid)
return true; /* conf and program in same group */
if ((fun.mainStat.mode & 0o020) && (statBuf.gid === process.getgid()))
return true; /* program is group-writeable and we are in group */
throw new Error('did not load configuration file due to invalid permissions: ' + fullPath);
}
/** Merge a new configuration object on top of an existing one, via
* addConfig(). The file is read, turned into an object, and becomes
* the neo config.
*
* Any falsey path component causes us to not read the file. This silent
* failure is desired behaviour.
*/
function addConfigFile(existing /*, file path components ... */) {
let fullPath = process.env.DCP_CLIENT_FILESYSTEM_ROOT || '';
for (let i=1; i < arguments.length; i++) {
if (!arguments[i])
return;
fullPath = path.join(fullPath, arguments[i]);
}
const fpSnap = fullPath;
/**
* Make a global object for this context for this config file's evaluation.
* - Top-level keys from dcpConfig become properties of this object, so that we can write statements
* like scheduler.location='XXX' in the file.
* - A variable named `dcpConfig` is also added, so that we could replace nodes wholesale, eg
* `dcpConfig.scheduler = { location: 'XXX' }`.
* - A require object that resolves relative to the config file is injected
* - All of the globals that we use for evaluating the bundle are also injected
*/
function makeConfigFileSymbols()
{
var configFileScope = Object.assign({}, bundleScope, {
__filename: fullPath,
dcpConfig: existing,
require: moduleSystem.createRequire(fullPath),
url: (href) => new (require('dcp/dcp-url').DcpURL)(href),
env: process.env,
dcp: { 'dcp-env': require('dcp/dcp-env') }, /* used for web-compat confs */
});
for (let key in existing)
if (!configFileScope.hasOwnProperty(key))
configFileScope[key] = configFileScope.dcpConfig[key];
assert(configFileScope.console);
return configFileScope;
}
if (fullPath && checkConfigFileSafePerms(fullPath + '.json'))
{
fullPath = fullPath + './json';
debug('dcp-client:config')(` * Loading configuration from ${fullPath}`);
addConfig(existing, require(fullPath));
return fullPath;
}
if (fullPath && checkConfigFileSafePerms(fullPath + '.kvin'))
{
fullPath = fullPath + './kvin';
debug('dcp-client:config')(` * Loading configuration from ${fullPath}`);
addConfig(existing, KVIN.parse(fs.readFileSync(fullPath)));
return fullPath;
}
if (fullPath && checkConfigFileSafePerms(fullPath + '.js'))
{
fullPath = fullPath + '.js';
debug('dcp-client:config')(` * Loading configuration from ${fullPath}`);
addConfig(existing, evalConfigFile(makeConfigFileSymbols(), fullPath));
return fullPath;
}
debug('dcp-client:config')(` . did not load configuration file ${fpSnap}.*`);
}
/**
* Since there are limited types in the registry, we have decided that certain property
* names coming from the registry will always be represented by specific types in dcpConfig.
*
* o.href => o is a URL
*/
function coerceMagicRegProps(o, seen)
{
/* seen list keeps up from blowing the stack on graphs with cycles */
if (!seen)
seen = [];
if (seen.indexOf(o) !== -1)
return;
seen.push(o);
for (let key in o)
{
if (!o.hasOwnProperty(key) || typeof o[key] !== 'object')
continue;
if (o[key].hasOwnProperty('href'))
o[key] = new URL(o[key].href);
else
coerceMagicRegProps(o[key], seen)
}
}
/** Merge a new configuration object on top of an existing one, via
* addConfig(). The registry key is read, turned into an object, and
* becomes the neo config.
*/
async function addConfigRKey(existing, hive, keyTail) {
var neo;
// make sure RKey calls do not execute the windows registry calls on non-windows platforms
if (os.platform() !== 'win32')
return;
neo = await require('./windows-registry').getObject(hive, keyTail);
debug('dcp-client:config')(` * Loading configuration from ${hive} ${keyTail}`, neo);
if (neo)
{
coerceMagicRegProps(neo); // mutates `neo` in place
addConfig(existing, neo);
}
}
/** Merge a new configuration object on top of an existing one, via
* addConfig(). The environment is read, turned into an object, and
* becomes the neo config.
*/
function addConfigEnv(existing, prefix) {
var re = new RegExp('^' + prefix);
var neo = {};
for (let v in process.env) {
if (!process.env.hasOwnProperty(v) || !v.match(re)) {
continue
}
if (process.env[v][0] === '{') {
debug('dcp-client:config')(` * Loading configuration object from env ${v}`);
let prop = fixCase(v.slice(prefix.length))
if (typeof neo[prop] !== 'object') {
neo[prop] = {}
addConfig(neo[prop], eval(`"use strict"; (${process.env[v]})`));
} else {
if (typeof neo[prop] === "object") {
throw new Error("Cannot override configuration property " + prop + " with a string (is an object)")
}
neo[prop] = process.env[v]
}
}
}
addConfig(existing, neo);
}
/** Turn UGLY_STRING into uglyString */
function fixCase(ugly)
{
var fixed = ugly.toLowerCase();
var idx;
while ((idx = fixed.indexOf('_')) !== -1)
fixed = fixed.slice(0, idx) + fixed[idx + 1].toUpperCase() + fixed.slice(idx + 2);
return fixed;
}
/**
* Patch up an object graph to fix up minor class instance issues. For example, if we get a DcpURL from
* the internal bundle and then download a new DcpURL class, it won't be an instance of the new class
* and it won't benefit from any bug fixes, new functionality, etc.
*
* @param {object} patchupList a mapping which tells us how to fix these problems for specific
* classes. This map is an array with each element having the shape
* { how, right, wrong }.
*
* right - the right constructor to use
* wrong - the wrong constructor we want to fixup
* how - the method to use getting from wrong to right
*/
function patchupClasses(patchupList, o, seen)
{
const pucKVIN = new (require('dcp/internal/kvin')).KVIN();
/* seen list keeps us from blowing the stack on graphs with cycles */
if (!seen)
seen = [];
if (seen.indexOf(o) !== -1)
return;
seen.push(o);
for (let key in o)
{
let moreTraverse = true;
if (!o.hasOwnProperty(key) || typeof o[key] !== 'object')
continue;
for (let i=0; i < patchupList.length; i++)
{
if (typeof o[key] !== 'object' || o[key] === null || (Object.getPrototypeOf(o[key]) !== patchupList[i].wrong.prototype))
continue;
assert(patchupList[i].wrong !== patchupList[i].right);
switch (patchupList[i].how)
{
case 'kvin':
{
const className = o[key].constructor.name;
pucKVIN.userCtors[className] = patchupList[i].wrong;
const tmp = pucKVIN.marshal(o[key]);
pucKVIN.userCtors[o[key].constructor.name] = patchupList[i].right;
o[key] = pucKVIN.unmarshal(tmp);
break;
}
case 'ctorStr':
o[key] = new (patchupList[i].right)(String(o[key]));
break;
case 'ctor':
o[key] = new (patchupList[i].right)(o[key]);
break;
case 'cast':
o[key] = (patchupList[i].right)(o[key]);
break;
case 'from':
o[key] = (patchupList[i].right).from(o[key]);
break;
case 'proto':
Object.setPrototypeOf(o[key], patchupList[i].right.prototype);
break;
default:
throw new Error(`unknown patchup method ${patchupList[i].how}`);
}
assert(o[key].constructor === patchupList[i].right);
moreTraverse = false; /* don't patch up props of stuff we've patched up */
break;
}
if (moreTraverse)
patchupClasses(patchupList, o[key], seen);
}
}
/**
* Tasks which are run in the early phases of initialization
* - plumb in global.XMLHttpRequest which lives forever -- that way KeepAlive etc works.
*/
exports._initHead = function dcpClient$$initHead() {
initInvoked = true; /* Allow us to eval require("dcp/compute"); from config */
if (typeof XMLHttpRequest === 'undefined')
XMLHttpRequest = require('dcp/dcp-xhr').XMLHttpRequest;
require('dcp/signal-handler').init(); /* plumb in dcpExit event support for dcp-client applications */
}
/**
* Tasks which are run in the late phases of initialization:
* 1 - activate either the local bundle or the remote bundle in a fresh funtion scope
* using the latest config (future: bootstrap bundle will export fewer modules until init;
* bundle will provide post-initialization nsMap).
* 2 - inject modules from the final bundle on top of the bootstrap modules
* 3 - patch up internal (to the final bundle) references to dcpConfig to reference our generated config
* 4 - verify versioning information for key core components against running scheduler
* 5 - load and cache identity & bank keystores if they are provided and options.parseArgv allows (default)
* 6 - create the return object
*
* @param {object} configFrags configuration fragments;
* .localConfig - config we figured out locally
* .defaultConfig - config we figured out internally
* .remoteConfigKVIN - serialized config we downloaded from scheduler
* .internalConfig - reference to bootstrap bundle's dcpConfig object
* @param {object} options options argument passed to init()
* @param {string} finalBundleCode [optional] the code to evaluate as the final bundle, eg autoupdate
* @param {object} finalBundleURL [optional] instance of URL telling us the location where we
* downloaded the final bundle from
* @returns the same `dcp` object as we expose in the vanilla-web dcp-client
*/
function initTail(configFrags, options, finalBundleCode, finalBundleURL)
{
var nsMap; /* the final namespace map to go from bundle->dcp-client environment */
var bundle; /* the final bundle, usually a copy of the bootstrap bundle */
var finalBundleLabel; /* symbolic label used for logs describing the source of the final bundle */
var ret; /* the return value of the current function - usually the `dcp` object but
possibly edited by the postInitTailHook function. */
var schedConfLocFun = require('dcp/protocol').getSchedulerConfigLocation;
/* 1 */
bundleScope.dcpConfig = configFrags.internalConfig;
if (finalBundleCode) {
finalBundleLabel = String(finalBundleURL);
debug('dcp-client:bundle')(' - loading final bundle from web');
bundle = evalBundleCodeInIIFE(finalBundleCode, bundleScope, { filename: finalBundleLabel });
} else {
const bundleFilename = path.resolve(distDir, 'dcp-client-bundle.js');
finalBundleLabel = bundleFilename;
debug('dcp-client:bundle')(' - loading final bundle from disk');
bundle = evalBundleFileInIIFE(bundleFilename, bundleScope);
}
nsMap = bundle.nsMap || require('./ns-map'); /* future: need to move non-bootstrap nsMap into bundle for stable auto-update */
if (bundle.initTailHook) /* for use by auto-update future backwards compat */
bundle.initTailHook(configFrags, bundle, finalBundleLabel, bundleScope, injectModule);
/* 2 */
debug('dcp-client:modules')(`Begin phase 2 module injection '${finalBundleLabel}'`);
delete nsMap['dcp-config'];
injectNsMapModules(nsMap, bundle, finalBundleLabel, true);
injectModule('dcp/client', exports);
injectModule('dcp/client-bundle', bundle);
/**
* We preserve the initial instance of the function from the initial bundle evaluation, otherwise it
* closes over the wrong variable and returns `undefined` even though fetch has been used.
*/
if (schedConfLocFun)
require('dcp/protocol').getSchedulerConfigLocation = schedConfLocFun;
/* Class patch-up is necessary because the KVIN deserialzation and default initializations earlier
* would have made instances of classes inside the first bundle instead of the final bundle.
*
* URL->DcpURL patch is not strictly necessary at this stage, but it saves is from using the
* dcpUrl patchup utility and thus a full traversal of the dcpConfig object graph.
*/
const patchupList = [
{ how: 'kvin', wrong: bootstrapClasses.Address, right: require('dcp/wallet').Address },
{ how: 'kvin', wrong: bootstrapClasses.DcpURL, right: require('dcp/dcp-url').DcpURL },
{ how: 'ctor', wrong: URL, right: require('dcp/dcp-url').DcpURL },
];
assert(require('dcp/dcp-url').DcpURL !== bootstrapClasses.DcpURL);
patchupClasses(patchupList, configFrags);
/* Ensure KVIN deserialization from now on uses the current bundle's implementation for these classes */
KVIN.userCtors.dcpUrl$$DcpURL = require('dcp/dcp-url').DcpURL;
KVIN.userCtors.dcpEth$$Address = require('dcp/wallet').Address;
/* 3. Rebuild the final dcpConfig from the config fragments and other sources. The config fragments in
* the bundle are considered secure even if they came from the auto-update bundle, as a user choosing
* that feature is already executing arbitrary code from the scheduler with this loader.
*
* The remote config is re-deserialized, in case the auto-update carried bugfixes in the serializer.
*/
const workingDcpConfig = require('dcp/dcp-config'); /* reference to final bundle's internal global dcpConfig */
const remoteConfig = configFrags.remoteConfigKVIN ? KVIN.parse(configFrags.remoteConfigKVIN) : {};
for (let protectedKey of protectedDcpConfigKeys) /* never accept modifications to these keys from scheduler */
delete remoteConfig[protectedKey];
addConfig(workingDcpConfig, configFrags.internalConfig);
addConfig(workingDcpConfig, configFrags.defaultConfig);
addConfig(workingDcpConfig, remoteConfig);
addConfig(workingDcpConfig, configFrags.localConfig);
addConfig(workingDcpConfig, originalDcpConfig);
globalThis.dcpConfig = bundleScope.dcpConfig = workingDcpConfig;
bundleScope.dcpConfig.build = require('dcp/build').config.build; /* dcpConfig.build deprecated mar 2023 /wg */
/* 4 */
if (workingDcpConfig.scheduler.configLocation !== false && typeof process.env.DCP_CLIENT_SKIP_VERSION_CHECK === 'undefined')
{
if (!workingDcpConfig.scheduler.compatibility || !workingDcpConfig.scheduler.compatibility.minimum)
throw require('dcp/utils').versionError(workingDcpConfig.scheduler.location.href, 'scheduler', 'dcp-client', '4.0.0', 'EDCP_CLIENT_VERSION');
if (workingDcpConfig.scheduler.compatibility)
{
let ourVer = require('dcp/protocol').version.provides;
let minVer = workingDcpConfig.scheduler.compatibility.minimum.dcp;
let ok = require('semver').satisfies(ourVer, minVer);
debug('dcp-client:version')(` * Checking compatibility; dcp-client=${ourVer}, scheduler=${minVer} => ${ok ? 'ok' : 'fail'}`);
if (!ok)
throw require('dcp/utils').versionError('DCP Protocol', 'dcp-client', workingDcpConfig.scheduler.location.href, minVer, 'EDCP_PROTOCOL_VERSION');
}
}
/* 5 */
if (options.parseArgv !== false)
{
let optarg;
if ((optarg = consumeArg('identity')))
require('dcp/identity').setDefault(optarg);
}
/* 6 */
ret = makeInitReturnObject();
if (bundle.postInitTailHook) /* for use by auto-update future backwards compat */
ret = bundle.postInitTailHook(ret, configFrags, bundle, finalBundleLabel, bundleScope, injectModule);
dcpConfig.build = bundleScope.dcpConfig.build = require('dcp/build').config.build; /* dcpConfig.build deprecated March 2023 */
return ret;
}
/**
* Takes the arguments passed to init() or initSync(), works out the overload, and returns an
* object with two properies:
* - localConfig: a dcpConfig fragment
* - options: an options object
*
* This routine also populates certain key default values, such as the scheduler and program name that
* need to always be defined, and updates the localConfig fragment to reflect appropriate options.
*
* Form 1 - {string} scheduler location
* Form 2 - {URL} scheduler location
* Form 3 - {object} options
* Form 4 - {object} dcpConfig fragment, {object} options (DEPRECATED)
*
* Rather than use form 4, pass a dcpConfig option to form 3
*
* See init() for complete documentation of what this function can parse.
*/
function handleInitArgs(initArgv)
{
var initConfig = { scheduler: {}, bundle: {} };
var options;
const defaultOptions = {
programName: process.mainModule ? path.basename(process.mainModule.filename, '.js') : 'node-repl',
parseArgv: !Boolean(process.env.DCP_CLIENT_NO_PARSE_ARGV),
};
switch (initArgv.length)
{
case 0:
options = defaultOptions;
break;
case 1:
if (typeof initArgv[0] === 'string') /* form 1 */
options = { scheduler: new URL(initArgv[0]) };
else if (initArgv[0] instanceof URL)
options = { scheduler: initArgv[0] }; /* form 2 */
else
options = Object.assign(defaultOptions, initArgv[0]); /* form 3 */
break;
default:
throw new Error('Too many arguments dcp-client::init()!');
case 2:
options = Object.assign(defaultOptions, { dcpConfig: initArgv[0] }, initArgv[1]); /* form 4 - deprecated */
break;
}
options.dcpConfig = Object.assign(initConfig, options.dcpConfig);
if (options.scheduler)
initConfig.scheduler.location = new URL(options.scheduler);
if (options.autoUpdate)
initConfig.bundle.autoUpdate = true;
if (options.bundleLocation)
initConfig.bundle.location = new URL(options.bundleLocation);
return {
initConfig, /* configuration derived from call to dcp-client::init() or initSync() */
options /* generic options - eg parseArgv */
};
}
/**
* Initialize the dcp-client bundle for use by the compute API, etc. - Form 1
*
* @param {string} url
* Location of scheduler, from whom we download
* dcp-config.js, which in turn tells us where to
* find the bundle.
* @returns a Promise which resolves to the dcpConfig which bundle-supplied libraries will see.
*/
/**
* Form 2
*
* @param {URL object} url
* Location of scheduler, from whom we download
* dcp-config.js, which in turn tells us where to
* find the bundle.
*/
/**
* Form 3
*
* @param {object} options an options object, higher precedence config of
* - scheduler (URL or string)
* - parseArgv; false => not parse cli for scheduler/wallet
* - bundleLocation (URL or string)
* - reportErrors; false => throw, else=>console.log, exit(1)
* - configName: filename to load as part of default dcpConfig
* - dcpConfig: object to include as part of default dcpConfig
* - enableSourceMaps: boolean to activate legible error stack traces
*/
/**
* Form 4
* @deprecated
*
* @param {object} dcpConfig a dcpConfig object which can have
* scheduler.location, bundle.location, bundle.autoUpdate
* @param {object} options an options object, higher precedence config of
* - scheduler (URL or string)
* - parseArgv; false => not parse cli for scheduler/wallet
* - bundleLocation (URL or string)
* - reportErrors; false => throw, else=>console.log, exit(1)
* - configName: filename to load as part of default dcpConfig
* - dcpConfig: object to include as part of default dcpConfig
* - enableSourceMaps: boolean to activate legible error stack traces
*/
exports.init = async function dcpClient$$init() {
var { initConfig, options } = handleInitArgs(arguments);
var configFrags;
var finalBundleCode = false;
var finalBundleURL;
debug('dcp-client:init')(' * Initializing dcp-client');
reportErrors = options.reportErrors;
exports._initHead();
configFrags = await exports.createConfigFragments(initConfig, options);
finalBundleURL = configFrags.localConfig.bundle.autoUpdate ? configFrags.localConfig.bundle.location : false;
if (finalBundleURL) {
try {
debug('dcp-client:bundle')(` * Loading autoUpdate bundle from ${finalBundleURL.href}`);
finalBundleCode = await require('dcp/utils').justFetch(finalBundleURL.href);
} catch(error) {
if (reportErrors !== false)
{
console.error('Error downloading autoUpdate bundle from ' + finalBundleURL);
console.debug(require('dcp/utils').justFetchPrettyError(error));
process.exit(1);
}
throw error;
}
}
if (process.env.DCP_CLIENT_ENABLE_SOURCEMAPS || options.enableSourceMaps)
{
debug('dcp-client:init')(' * Installing source maps');
require('source-map-support').install();
}
return initTail(configFrags, options, finalBundleCode, finalBundleURL);
}
/**
* Sync version of dcp-client.init().
*/
exports.initSync = function dcpClient$$initSync() {
var { initConfig, options } = handleInitArgs(arguments);
var configFrags;
var finalBundleCode = false;
var finalBundleURL;
exports._initHead();
configFrags = createConfigFragmentsSync(initConfig, options);
finalBundleURL = configFrags.localConfig.bundle.autoUpdate ? configFrags.localConfig.bundle.location : false;
if (finalBundleURL) {
try {
debug('dcp-client:bundle')(` * Loading autoUpdate bundle from ${finalBundleURL.href}`);
finalBundleCode = exports.fetchSync(finalBundleURL);
} catch(error) {
if (reportErrors !== false)
{
console.error('Error downloading autoUpdate bundle from ' + finalBundleURL);
/* detailed error output comes from fetchSync via stdin/stderr passthrough */
process.exit(1);
}
throw error;
}
}
return initTail(configFrags, options, finalBundleCode, finalBundleURL);
}
/**
* Generate a local config object from the environment
*/
function mkEnvConfig()
{
debug('dcp-client:config')(` * Loading configuration from environment`);
const envConfig = { scheduler: {}, bundle: {} };
const env = process.env;
if (env.DCP_SCHEDULER_LOCATION) addConfig(envConfig.scheduler, { location: new URL(env.DCP_SCHEDULER_LOCATION) });
if (env.DCP_CONFIG_LOCATION) addConfig(envConfig.scheduler, { configLocation: new URL(env.DCP_CONFIG_LOCATION) });
if (env.DCP_CONFIG_LOCATION === '') addConfig(envConfig.scheduler, { configLocation: false }); /* explicitly request no remote config */
if (env.DCP_BUNDLE_AUTOUPDATE) addConfig(envConfig.bundle, { autoUpdate: !!env.DCP_BUNDLE_AUTOUPDATE.match(/^true$/i) } );
if (env.DCP_BUNDLE_LOCATION) addConfig(envConfig.bundle, { location: new URL(env.DCP_BUNDLE_LOCATION) });
if (env.DCP_BUNDLE_LOCATION === '') addConfig(envConfig.bundle, { location: false }); /* explicitly request no remote bundle */
return envConfig;
}
/**
* Generate a local config object from the program's command line
* Side effect: process.argv is modified to remove these options
*/
function mkCliConfig(options)
{
const cliConfig = mkCliConfig.__cache || { scheduler: {}, bundle: {} };
mkCliConfig.__cache = cliConfig;
if (options.parseArgv !== false)
{
let s;
debug('dcp-client:config')(` * Loading configuration from command-line arguments`);
if ((s = consumeArg('scheduler')))
cliConfig.scheduler.location = new URL(s);
if ((s = consumeArg('config-location')))
cliConfig.scheduler.configLocation = s;
if ((s = consumeArg('bundle-location')))
cliConfig.scheduler.configLocation = s;
if ((consumeArg('bundle-auto-update', true)))
cliConfig.scheduler.configLocation = true;
}
return cliConfig;
}
/**
* Create the aggregate dcpConfig for the running program. This config is based on things like
* - command-line options
* - environment variables
* - files in various locations
* - various registry keys
* - baked-in defaults
* - parameters passed to init() or initSync()
*
* @param {object} initConfig dcpConfig fragment passed to init() or initSync()
* @param {object} options options object passed to init() or initSync() or derived some other way.
* Options include:
* - programName {string}: the name of the program (usually derived from argv[1])
* - parseArgv {boolean}: false to ignore cli opt parsing
* - scheduler {string|URL}: location of the DCP scheduler
* - autoUpdate {boolean}: true to download a fresh dcp-client bundle from the scheduler
* - bundleLocation {string|URL}: location from where we will download a new bundle
* - configName {string}: arbitrary name to use to resolve a config fragment filename
* relative to the program module
* @returns {object} with the following properties:
* - defaultConfig: this is the configuration buried in dcp-client (mostly from the bundle but also index.js)
* - localConfig: this is the configuration we determined solely from local sources
* - remoteConfigKVIN: this is the serialized configuration we downloaded from the scheduler
* - internalConfig: this is a reference to the internal dcpConfig object
*/
exports.createConfigFragments = async function dcpClient$$createConfigFragments(initConfig, options)
{
/* The steps that are followed are in a very careful order; there are default configuration options
* which can be overridden by either the API consumer or the scheduler; it is important that the wishes
* of the API consumer always take priority, and that the scheduler is unable to override parts of
* dcpConfig which are security-critical, like allowOrigins, minimum wage, bun