Modules are very similar in purpose and capabilities to blobs, but they differ in the sense that they have already been consumed by the app at creation time. This also allows them to have privileged access to the app's "official" global object.
Although modules are useful for creating custom kits, they do not need to be understood if you are using a pre-configured one. Modules are defined by a function which receives reference to the app-wide bus and to the global object (as defined at app creation).
const myModule = ({on, send}, global) => {
// ...
};
Modules are then passed to the core which generates the okwolo function. If module A depends on module B, module B should be included earlier than A.
const myKit = core({
modules: [
myModule,
// ...
],
options: {
// ...
},
});
Module names should express their purpose and make dependencies clear. For example, the state handler module depends on the state one and is therefore named "state.handler", even if handler might suffice.
Another convention followed by the modules is that all events being fired or listened for should be clearly indicated in a comment at the top of the file. Here is an example header from the state module.
// @fires state [state]
// @fires blob.api [core]
// @fires blob.handler [state]
// @listens state
// @listens blob.handler
In this notation, fired events include additional information about the receiving modules (even when it is the same module).
The state module's primary purpose is to offer lightweight "canonical" state management for the application. It adds the getState and setState methods for convenient access to a copy of its state. It also accepts a state handler which is discussed in the following section.
const app = core({
modules: [stateModule],
})();
app.setState('test');
app.getState(); // 'test'
app.use('handler', handlerBlob);
The state module makes it possible for other modules (or a blob) to become the state handler in it's place. This allows the handler to manage the "setState" calls and add features around this event. This pattern is used in the standard kit where a state handler adds actions, watchers and middleware to state management.
For consistency, the standard handler adds a "SET_STATE" action and uses it to modify state when setState is called. This allows watchers and/or middleware to be interact with the action as usual.
const customHandler = (newState) => {
// ...
// the handler must fire an event to make the state module aware of the new state.
app.send('state', newState);
};
// the function given to the handler blob has direct access to the real state variable.
const handlerBlob = (getState) => {
return customHandler;
}
This pattern of callbacks establishes a two-way communication channel between the state module and the optional handler while remaining optional for kits that do not need more state logic.
The following snippet shows the different ways to interact with the "state.handler" module used in the standard kit.
const app = core({
modules: [
stateModule,
stateHandlerModule,
],
})();
app.use('watcher', myWatcher);
app.use('middleware', myMiddleware);
app.use('action', myAction);
app.act('actionName', actionParams...);
The state handler history module adds actions to undo and redo the application's state. It also exposes these actions and a function to reset the stored history on the app's api.
const app = core({
modules: [
stateModule,
stateHandlerModule,
stateHandlerHistoryModule,
],
})();
app.undo();
app.redo();
app.resetHistory();
The module keeps track of a maximum of 20 past states.
The router module offers utilities to manage the browser's location and to respond to it's changes. This includes registering routes, changing the base path and changing the view. By default, the router only calls functions. Its relationship with the view module is handled by the "primary.router.builder" module.
const app = core({
modules: [routerModule],
})();
app.use('route', {
path: '/users',
handler: () => {
// ...
},
})();
app.use('base', '/acme');
app.redirect('/home');
app.show('/home');
Alone, this module does not contain functions to encode and decode the pathname. It relies on implementations of these two functions to be added by another module or through blobs. By modifying both functions, it is possible to define an entirely different syntax/storage mechanism for the routes while not needing to think about the browser's location. This also means the router module has absolutely no opinions on the format of the storage and that the format needs to be managed by fetch and register.
The fetch module adds the functionality to the router module to call registered path/handler combos. This same module is currently being used in both the standard and lite kits, because the route store format is the same.
// store format:
// [{
// pattern: RegExp,
// keys: [String],
// handler: Function,
// }]
const fetchBlob = (store, path, params) => {
// path handler should be called here.
// ...
return hasPathMatched;
};
This router registering module leans heavily on the "path-to-regex" npm package also being used by express. This means the features and syntax should be familiar to a large proportion of developers.
Path strings are converted to regular expressions with included capture groups for each parameter in the path. Params are variables inside the path that act as placeholders. For example, the route "/user/:id" contains an "id" param which should match with "/user/123" and "/user/456". These named params are mapped to capture groups in the fetch blob to extract information from paths.
For convenience, this module also adds support for a catch-all pattern ("**") which will match any path.
const registerBlob = (store, path, handler) => {
// ...
return store;
};
The lite version of the register module implements the string-to-regexp conversion instead of importing the expensive package. This means that some path variations are not supported (like parameter modifiers). However, it keeps support for simple path params.
For convenience, this module also adds support for a catch-all pattern ("**") which will match any path.
The view module is built to have no assumptions about the environment or about what type of render target is being output. This makes it more of a controller/orchestrator and allows things like render-to-string to exist. To do it's job, it needs a target, a builder function, a build function, a draw function, and an update function. More details about each of these blobs can be found on the dedicated page . The module allows any of these blobs to change at any time and provides clear feedback if something is missing or invalid.
The view module keeps track of two variables. The first one is a copy of the state. This copy is updated each time the state changes (by listening for the "state" event) and is used to re-render layout when one of the view blobs change. The second variable is the arbitrary layout data returned by both the draw and update blobs. This data is also passed to the update blob and can therefore be used to store any information about the view (ex. vdom).
The view module also changes the app's primary function to make it update the builder func.
const app = core({
modules: [viewModule],
})();
app.use('target', targetBlob);
app.use('builder', builderBlob);
app.use('build', buildBlob);
app.use('draw', drawBlob);
app.use('update', updateBlob);
app(() => builderBlob);
This module can also be interacted with using two events: "update" and "sync". Both these events are also used internally and can be listened for from outside to give insight into the app's inner workings.
const app = core({
modules: [viewModule],
})();
// updates layout using the update blob or the draw blob.
app.send('update', forceRedraw);
// updates a specific section of layout using the update blob.
// the successor layout bypasses the state > builder > build pipeline.
// identity can be used to confirm that the right element is being updated.
app.send('sync', address, successor, identity);
The "view.build" module is a responsible for adding the build blob to the app. This build function is responsible for taking the output of the builder and transforming it into valid vdom, or throwing a syntax error. This also means it needs to be able to "unroll" components and initiate their updates using the "sync" event.
const buildBlob = (element, queue) => {
// ...
return vdom;
};
The "view.dom" module adds both the "draw" and the "update" blobs to the app. This is the only module (except "router") which should be aware of the browser and the document.
const drawBlob = (target, buildOutput) => {
// ...
return buildOutput;
};
const updateBlob = (target, buildOutput, address, view, identity) => {
// ...
return view;
};
The "view.string" module also adds the "draw" and "update" blobs, but makes them render to a safe html string.
const toString = (target, vdom) => {
target.innerHTML = magicallyStringify(vdom);
return vdom;
};
const drawBlob = toString;
const updateBlob = toString;
This module exists to avoid making the router and view modules coupled. It changes the app's primary function to a wrapper which makes it easier to create routes which change the builder.
// without primary.router.builder module.
app.use('route', {
path: '/user/:id',
handler: ({id}) => {
app.use('builder', (state) => {
// ...
return layout;
});
},
});
// with primary.router.builder module.
app('/user/:id', ({id}) => (state) => {
// ...
return layout;
});
// maintains support for route-less builders.
app(() => (state) => {
// ...
return layout;
});