aweft

@aweftjs/modules

Modules from a directory, a bundle or a document, loaded in dependency order with their dependencies injected. Tools to load and unload. No opinion about which, when, or for whom.

Loading a module runs its code, with the privileges of the process that loaded it. Nothing here isolates anything. Load what you trust, or put your own boundary in front of this.

Quickstart

import { createLoader, fromDocument } from '@aweftjs/modules';
import { fromDirectory } from '@aweftjs/modules/node';

const loader = createLoader({
	sources: [fromDirectory('./modules'), fromDocument(plugins)],
	props: { store },
});

const { 'posts/Create': create } = await loader.load(['posts/Create']);
await loader.unload('posts/Create');

load instantiates what you name and everything it depends on, dependencies first, and hands back what you named. unload lets go of exactly one module, calling its stop if it has one.

props is for what the platform hands in, such as the store an application made before there was a loader; anything the application itself makes is a module that others name in deps. @aweftjs/server builds its loader that way and hands in store alone.

A module

A module is a file, a bundle entry or a document entry that exports:

export const deps = ['auth/Session', 'lib/Log'];   // what it needs, by name
export const defaults = { maxLength: 80 };         // its configuration when nothing configures it

export default ({ imports, config, extensions, store }) => ({
	make: (title) => { imports.Log.log(imports.Session.userOf()); return title.slice(0, config.maxLength); },
	stop: () => { /* let go of whatever this holds */ },
});

The factory receives imports, config, extensions, and whatever props the loader was made with. imports is keyed by the last segment of each dependency's name: auth/Session is imports.Session. Two dependencies whose names end the same way are refused at load.

An extension is a same-named entry from another source that exports config or extensions and no factory. Its config merges over the implementation's defaults, plain objects one level at a time, arrays and everything else replaced whole. When several sources carry one name, the earliest source's implementation wins and every source's config contributes, earliest winning.

The load order is dependency order, then the order you listed. A module loads after everything it depends on; between modules that do not depend on each other, the one whose source is earlier in sources loads first, and within one source the one listed first. A module in several sources takes the place of the source that implements it, so a file that only configures a library module does not move it. A directory lists sorted, a bundle in the order of its map, a document sorted. loaded() is that order, and a server walks it: the first module to answer a request no route matched is the answer, so a module that answers everything goes in the last source.

Whatever the factory returns is the instance. If it has a stop function, unload calls and awaits it. Nothing else is read off an instance.

Where modules come from

A source lists candidates and evaluates nothing until load asks for a name.

sourcenamesevaluates by
fromDirectory(path), on @aweftjs/modules/nodethe file's path under path, no extension: auth/Sessionimport() of the file
fromBundle(map, { prefix })the key without prefix (or a leading ./) and its extensionthe entry, or calling it when it is a function
fromDocument(document, { compile })the document's keyscompile(entry.source)

fromDirectory is on its own subpath because it reads the filesystem; the main entry loads in a browser. fromBundle takes the two shapes a bundler's glob import produces, eager or lazy.

fromDirectory hands its path to node:fs, which resolves a relative one against the process's working directory rather than the file that called it, so './modules' finds nothing when the program is started from anywhere else. Build an absolute path from import.meta.url:

const here = fileURLToPath(new URL('.', import.meta.url));
const loader = createLoader({ sources: [fromDirectory(join(here, 'modules'))] });

A module document is an observable object whose keys are module names and whose values carry a source string. Every other field in an entry is yours: an author, a note, a list of earlier versions. The loader ignores them.

const plugins = createObject({
	'plugin/Shout': createObject({ source: 'export default () => ({ ... })', author: 'ada' }),
});

That document is an ordinary document. Open it through a store and it is persisted. Share it over a link and a second node loads the same modules. Its commit history is its version history. This package does none of that and knows none of it: it reads source and nothing about how it got there.

Compile

compile(source) turns module text into exports. The default imports the text as an ES module through a data URL, with no dependency. If your modules need a transform, pass your own as fromDocument(document, { compile }).

compile is exported on its own so you can check a source before you store it:

try { await compile(draft); } catch (error) { /* refuse the draft */ }

Nothing here does that for you.

The default keeps every distinct source in the runtime's module cache for the life of the process. bench/compile.ts measured it on Node 25: about 5.6 KB of heap per distinct source, 53 MB for 10,000, and the same text imported twice is one module. An application that compiles many versions of many modules supplies a compile that does not keep them.

The tools

load(names)instantiate these and what they need; returns the named instances
unload(name)call stop if present and drop the instance; true if it was loaded
loaded()the loaded names, in the order their factories finished, always a dependency order
get(name)a loaded instance, or undefined
dependencies(name)what a loaded module declared, or undefined
dependents(name)the loaded modules that depend directly on it

unload unloads exactly the named module. A loaded module that depends on it keeps the reference it was handed, and will use it; ask dependents and unload those first if that is what you want. A load that fails part way leaves what it already instantiated loaded and throws naming the module that failed.

A loader is an instance. Make one per tenant, one per module, or one for everything; two loaders share nothing.

Every error this package raises carries a reason you can branch on and the module it is about: missing (in no source, or gone from it while loading), no-implementation, duplicate (one source lists a name twice, such as thing.js beside thing.ts), invalid-name, cycle, ambiguous-import, and failed (with cause). A stop that throws is the module's own error and reaches you from unload as it was thrown.

Declare dependencies in deps; do not load them from inside a factory. A cycle written in deps is refused by name. A factory that calls load for a module whose own factory is waiting on this one cannot be told apart from an ordinary concurrent load, so it waits forever. The loader has no way to see who called it.

Following a document

Nothing here reloads a module on its own. If you want a loader's modules to track a document, say so:

const stop = follow(loader, plugins, {
	failed: (name, error) => log.warn(name, error),
	applied: (name, action) => log.info(`${name} ${action}`),   // 'reloaded' or 'unloaded'
});

While following, a loaded module whose entry's source changes is unloaded and loaded again, together with its loaded dependents: dependents first on the way out, dependency order on the way back. A loaded module whose entry is removed is unloaded with its dependents. Anything else does nothing: a changed field other than source, an entry nothing has loaded. Reloads run one at a time, in the order the changes landed.

A reload happens after the commit that changed the source, not during it, so the loader has not caught up the moment your assignment returns. applied is told when it has, with the module's name and what happened to it; by then the new instances are in place. A reload that fails goes to failed instead, or is raised where nothing catches it when you gave no handler, and the process reports it as uncaught. One change failing never stops the follow: a module whose stop throws is reported to failed and the reload still lands, a handler that throws is reported the same way, and the next change is followed as usual. stop() stops following.

const caughtUp = new Promise<void>((resolve) => {
	const stop = follow(loader, plugins, { applied: () => { stop(); resolve(); } });
});
plugins['plugin/Count'].source = newSource;
await caughtUp;

What this package never decides

Which modules load, when, how many, for how long, in which process, or at what rate. Who may read, write, load or run one. Where a document comes from, whether anything persists it, or what its history is. Whether the code is safe to run.

Reading on

The design notes are in docs/design/ 061 to 065. The contract this package is built to is the one an application's own modules are written to.

API

Every export of @aweftjs/modules, its signature as the compiler resolves it, and its block comment.

@aweftjs/modules

BundleEntry

type BundleEntry = ModuleExports | (() => Promise<ModuleExports>);

One entry of a bundle map: the exports, or a function that loads them.

Candidate

interface Candidate { readonly name: string; exports(): Promise<ModuleExports>; }

One module a source can hand over: its name, and the way to get its exports when asked.

Compile

type Compile = (source: string) => Promise<ModuleExports>;

Turns module text into its exports. The default is compile; an application may pass its own.

Factory

type Factory = (props: ModuleProps) => unknown;

Builds a module's instance. May return a promise. Whatever it returns is the instance.

FollowHandlers

interface FollowHandlers { readonly failed?: ((name: string, error: unknown) => void) | undefined; readonly applied?: ((name: string, action: 'reloaded' | 'unloaded') => void) | undefined; }

No block comment on this export.

Loader

interface Loader { load(names: readonly string[]): Promise<Readonly<Record<string, unknown>>>; unload(name: string): Promise<boolean>; loaded(): readonly string[]; get(name: string): unknown; dependencies(name: string): readonly string[] | undefined; dependents(name: string): readonly string[]; }

A loader: the loaded graph and the tools that change it.

Every method is about this loader alone. Two loaders share nothing.

ModuleExports

interface ModuleExports { readonly deps?: readonly string[]; readonly defaults?: Readonly<Record<string, unknown>>; readonly config?: Readonly<Record<string, unknown>>; readonly extensions?: Readonly<Record<string, unknown>>; readonly default?: Factory; }

What a module exports.

An implementation exports default, usually deps, and sometimes defaults. An extension exports config or extensions and no default; its contribution merges into the module of the same name from another source. One file may do both.

ModuleProps

interface ModuleProps { readonly imports: Readonly<Record<string, unknown>>; readonly config: Readonly<Record<string, unknown>>; readonly extensions: Readonly<Record<string, unknown>>; readonly [key: string]: unknown; }

What a module's factory receives.

The three named fields win over anything of the same name in the loader's props, which are spread in first.

ModulesError

interface ModulesError extends Error { readonly reason: string; readonly module: string; }

An error this package raises, with a reason a caller can branch on.

Reasons: missing (a name is in no source, or left it while it was being loaded), no-implementation (only extensions for it), duplicate (one source lists a name twice), invalid-name (a path that leaves no name), cycle, ambiguous-import (two dependencies share a last segment), failed (a factory threw; cause carries what it threw).

Source

interface Source { candidates(): Promise<readonly Candidate[]>; }

Somewhere modules come from. Listing evaluates nothing.

compile

compile: Compile

The default compile: import the text as an ES module through a data URL.

Params

  • source: the text of a module, exporting what a module exports

Returns its exports. The source runs with this process's own privileges, the same as any import(); nothing here isolates anything (design 065). Every distinct source stays in the runtime's module cache for the life of the process: bench/compile.ts measured about 5.6 KB of heap per distinct source on Node 25 (10,000 sources, 53 MB), and the same text imported twice is the same module. An application that compiles many versions of many modules passes its own compile to fromDocument instead.

Example

const { deps, default: factory } = await compile(entry.source);

createLoader

createLoader: ({ sources, props }: { sources: readonly Source[]; props?: Readonly<Record<string, unknown>> | undefined; }) => Loader

Make a loader.

Params

  • sources: where modules come from, in order of precedence; the first source with an implementation of a name wins, and every source's configuration for it contributes

  • props: spread into every factory's props, under imports, config and extensions

Returns a loader with nothing loaded.

Example

const loader = createLoader({ sources: [fromDirectory('./modules'), fromDocument(plugins)] });
await loader.load(['posts/Create']);

follow

follow: (loader: Loader, document: object, handlers?: FollowHandlers) => () => void

Keep a loader's loaded modules matching a document.

Params

  • loader: the loader whose loaded modules follow the document

  • document: the same object handed to fromDocument

  • handlers.failed: where a reload failure goes

  • handlers.applied: told when a reload or an unload has finished, by module name

Returns the function that stops following. While following: a loaded module whose entry's source changes is unloaded and loaded again, together with the loaded modules that depend on it, dependents first on the way out and dependency order on the way back. A loaded module whose entry is removed is unloaded together with its loaded dependents. A change to any other field of an entry, or to an entry nothing has loaded, does nothing. Reloads run one at a time, in the order the changes landed, and applied hears each one as it finishes.

Example

const stop = follow(loader, plugins, {
  failed: (name, error) => log.warn(name, error),
  applied: (name, action) => log.info(`${name} ${action}`),
});

fromBundle

fromBundle: (map: Readonly<Record<string, BundleEntry>>, options?: { readonly prefix?: string | undefined; }) => Source

A source over a bundle map.

Params

  • map: path to entry, in the shape a bundler's glob import produces, eager or lazy

  • options.prefix: the leading part of every key to drop; without it, a leading ./ is dropped

Returns a source whose names are the keys without that prefix and without the file extension, so ./modules/auth/Session.js is auth/Session under prefix: './modules/'.

Throws a ModulesError with reason invalid-name, at once, for a key that leaves no name once its extension is removed.

Example

const source = fromBundle(import.meta.glob('./modules/**\/*.js'), { prefix: './modules/' });

fromDocument

fromDocument: (document: object, options?: { readonly compile?: Compile | undefined; }) => Source

A source over a document.

Params

  • document: an observable object whose keys are module names and whose values carry a source string; other fields are yours and are ignored

  • options.compile: what turns a source into exports; the default imports it as an ES module

Returns a source. It reads the document each time it is listed, so a module added to the document is a candidate on the next load, and exports() compiles the source as it is at that moment.

Throws a ModulesError with reason missing, out of the load that is using it, when an entry leaves the document between being listed and being compiled.

Example

const plugins = createObject({ 'plugin/Shout': { source: 'export default () => ({ ... })' } });
const loader = createLoader({ sources: [fromDocument(plugins)] });

@aweftjs/modules/node

fromDirectory

fromDirectory: (path: string) => Source

A source over a directory tree.

Params

  • path: the directory. A relative path is resolved by node:fs against the process's working directory, not against the file that called this, so a program run from anywhere else finds nothing; pass an absolute path built from import.meta.url, as the example does

Returns a source over every .js, .mjs and .ts file under the directory (never a .d.ts), named by the file's path relative to the directory with / separators and no extension, so <path>/auth/Session.ts is auth/Session. Listing walks the tree; evaluating a candidate is import() of its file. Two files that would share one name, thing.js beside thing.ts, are refused by the loader as duplicate.

Throws a ModulesError with reason invalid-name, out of the load that lists it, for a file whose name is nothing but an extension.

Example

const here = fileURLToPath(new URL('.', import.meta.url));
const loader = createLoader({ sources: [fromDirectory(join(here, 'modules'))] });

Refusals

Every error this package raises carries a reason to switch on and a fix that says what to do. These are its reasons, from errors.txt.

reasonfix
ambiguous-importRename one of the two modules so their last segments differ.
cycleDrop one of the deps, or move what the two share into a third module.
duplicateKeep one file per module name in a source.
failedFix what the factory threw; the error carries it as its cause.
invalid-nameGive the file a name before its extension.
missingAdd the module to one of the loader sources, or fix the name.
missingDo not unload a module from a factory while a load is running.
missingLeave the entry in the document until the load is over.
missingLoad the dependency first, or list it in the module deps.
no-implementationGive one of the files for this name a default export, the factory.

Recipes

The programs in the stack's gate that use this package, each a job someone would have.

  • recipes/full-stack: A page and a server in one directory: the page reaching the server in development through the dev server's proxy, and a sign-in that sets the cookie on that one origin

  • recipes/translated-site: A site written in one language and launched in three: the build finds every string and writes the catalog an agent fills, each language is a tree of pages with the plural rules of its own, a stored act is compiled where it runs, and the Ukrainian page hydrates in place

  • recipes/static: A generated site served by the stack's own server: one process is the whole deployment, and the page still comes alive where it stands

  • recipes/health: A deploy's verification: the health endpoint polled until the shipped build is the one answering, and the two states a poll must not mistake for health

  • recipes/logs: A page recorded end to end in a browser and the visit read back: an error, a rejection, a console line, a failed call on both sides, a commit's shape with a private slot absent, a typed character never stored, sign-in mid-visit

  • recipes/uploads: A page uploads pictures under the gate and they paint from /files/<id>; each refusal reaches the page with its reason, a module makes a file of its own, and the static battery behind it never sees a file

  • recipes/notify: Two pages of one user hear a send live and mark it read for each other, a device registered from the page, email and push against two fakes, a failed mail kept, a forged write refused, a restart, and a server with no store sending a contact form's mail

  • recipes/posts-to-pages: Pages written while the application runs: a post published over a socket becomes a page, and a scheduled full write refreshes the sitemap

  • recipes/backend: The boot pattern to copy: a twelve-line boot file and a folder of modules, one holding a document, one the rules, one a scheduler, one the gate, and one configuring a battery module

  • recipes/modules: the package's own recipe