UNPKG

dcp-client

Version:

Core libraries for accessing DCP network

1,219 lines (1,092 loc) 64.3 kB
/** * @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