Skip to main content

Modules

Modules are essentially named singletons structured in a dependency graph.

They may be classes (which will be instantiated on-demand):

class MyModule {}
moduleManager.register(MyModule, options);

These classes should have a no-argument constructor.

Or, they can be pre-created instances if their construction is complex:

const myComplicatedModule = new MyComplicatedModule('xyzzy', 'plugh');
moduleManager.registerInstance(myComplicatedModule, options);

You can also register fairly arbitrary objects as instances, but you will need to specify a module name via the registration options:

const foo = {bar: true}
moduleManager.registerInstance(foo, {name: "foo"})

Module Settings

Each module has optional settings that control how it is managed.

settingtypepurpose
namestringmodule name; inferred when possible
configurablesarrayconfigurable schema definition
injectbooleanwhether to inject dependencies; inferred true if init is not implemented
lifecyclebooleanwhether to run lifecycle methods
isMainbooleanmarks entry point; inferred true if main is implemented
referencesarrayother modules used, see below
selectionsarraymodules selected by this module, see below
providesstringabstract/interface type name to provide
configstringif full, init receives entire application config
lifecycleInitstringmethod name override; defaults to init
lifecycleStartstring...defaults to start
lifecycleMainstring...defaults to main
lifecycleStopstring...defaults to stop
lifecycleTerminatestring...defaults to terminate

These settings can be provided in one of three ways:

  1. as an individual static value in the module class prepended with module, in camelCase:
class MyModule {
static moduleLifecycle = false;
}
  1. as a member of a static moduleInfo object:
class MyModule {
static moduleInfo = {
lifecycle: false
}
}
  1. as part of the options object passed to the register call:
class MyModule {}
moduleManager.register(MyModule, { lifecycle: false })
tip

Module settings are individually merged with base class settings, allowing selective overrides. Any settings passed to the register call then override any internally defined settings.

Module References

Some modules make use of internal sub-modules that may well require their own configuration. Exporting all these to the top level of the application would add unnecessary direct dependencies, so modules can provide a list of modules to register when they are themselves loaded.

For example, this might be in one file..

output.js
import { ModuleManager} from '@versionzero/module-manager';

class Formatter {
static moduleProvides = "formatter"
format(s) { return s }
}
class UpperCase extends Formatter {
format(s) {
return s.toUpperCase();
}
}
class EndPadder extends Formatter {
static moduleInfo = {
name: 'pad',
configurables: [
{field: "length", type: "number", validator: "$positive", default: 50},
{field: "pad", type: "string", default: " "}
]
}
format(s) {
return s.padEnd(this.length, this.pad);
}
}
class Replacer extends Formatter {
static moduleConfigurables = [
{field: "pattern", type: "string", default: "x"},
{field: "replacement", type: "string", default: "_"}
]

format(s) {
return s.replaceAll(this.pattern, this.replacement);
}
}

export class Output {
static moduleInfo = {
configurables: [{field: "formatter", type: "formatter"}],
references: [Replacer, {ModuleClass: UpperCase, options: { name: 'upper'}}, EndPadder]
}

write(s) {
const line = (this.formatter? this.formatter.format(s) : s);
console.log(line);
}
}

this way, the application doesn't need to know the internal modules;

myapp.js
import { ModuleManager } from '@versionzero/module-manager';
import { Output } from './output.js'

class MyApp {
static moduleReferences = [Output]
static moduleConfigurables = [{field: 'output', type: 'Output'}, {field: 'message', default: 'hello, world'}]

async main() {
this.output.write(this.message);
}
}

await new ModuleManager()
.register(MyApp)
.run();



Module Selectors and Selections

By marking a field with selector: true and providing a list of moduleSelections, you can add a module as a child of another module that only is activated if the selector field matches the selection:

class CryptoCommand {

static moduleInfo = {
provides: 'Command',
configurables: [ {field: 'key', type: 'string', required: true},
]
}
execute() { throw new Error('not implemented')}
}

class Encrypt extends CryptoCommand {
static moduleConfigurables = [
{field: 'text', type: 'string', required: true, general: true, description: 'text to encrypt', valueDescription: 'string-to-encrypt'}
{field: ''}
]
execute() {
const iv = crypto.randomBytes(12);
const keyBuffer = crypto.scryptSync(this.key, 'salt', 32);
const cipher = crypto.createCipheriv('aes-256-gcm', keyBuffer, iv);
const encrypted = cipher.update(this.text, 'utf8', 'hex') + cipher.final('hex');
const tag = cipher.getAuthTag();
console.log(`Encrypted "${this.text}": ${iv.toString('hex')}:${encrypted}:${tag.toString('hex')}`);
}
}

class Decrypt extends CryptoCommand {
static moduleConfigurables = [
{field: 'data', type: 'string', required: true, general: true, description: 'data to decrypt', valueDescription: 'encrypted-string', validator: /^[0-9a-f]{24}:[0-9a-f]+:[0-9a-f]{32}$/i}
]
execute() {
const [ivHex, encryptedHex, tagHex] = this.data.split(':');
const keyBuffer = crypto.scryptSync(this.key, 'salt', 32);
const decipher = crypto.createDecipheriv('aes-256-gcm', keyBuffer, Buffer.from(ivHex, 'hex'));
decipher.setAuthTag(Buffer.from(tagHex, 'hex'));
const decrypted = decipher.update(encryptedHex, 'hex', 'utf8') + decipher.final('utf8');
console.log(`Decrypted text: ${decrypted}`);
}
}

class SecretApp {
static moduleInfo = {
configurables: [{field: 'command', type: 'Command', selector: true, required: true}],
selections: [Encrypt, Decrypt]
}
command;

async main() {
this.command.execute();
}
}


await new ModuleManager()
.register(SecretApp)
.run({
// argv: ['encrypt', '-k', 'mypasswd']
});