Developer Guide
This guide is intended for developers who want to create plugins for the Bajo framework. It provides an overview of the core classes, their relationships, and how to extend the framework with custom plugins.
Plugins are the building blocks of Bajo. They can be created to add new features, extend existing functionality, or integrate with external services. This guide will walk you through the process of creating a plugin, understanding the class hierarchy, and best practices for plugin development.
Finished plugins can be used in your own projects or shared with the community by publishing them to npm or other package managers. If you decide to share your plugin, please let us know so we can include it in our list of community plugins. We're more than happy to help you promote your plugin and make it available to other developers.
Class Hierarchy
The core of Bajo is built around a few classes that form the foundation of the framework. The following diagram illustrates the class hierarchy and their relationships:
+-----------+ +-----------+ +-----------+ +-----------+
| Plugin | | Tools | | App | | Log |
+-----+-----+ +-----+-----+ +-----------+ +-----------+
| |
+----------------+ +----------------+
| | | |
+-----+-----+ +-----+-----+ +-----+-----+ +-----+-----+
| Bajo | | Base | | Err | | Print |
+-----------+ +-----+-----+ +-----------+ +-----------+
|
+----------------+----------------+-------------------+
| | | |
+-----+-----+ +-----+-----+ +-----+-----+ +-----+-----+
| Main | | Dobo | | Waibu | ... | MyPlugin |
+-----------+ +-----------+ +-----------+ +-----------+
All plugins in Bajo are derived from the Base class (derived in turn from the abstract class Plugin), which provides the basic structure and functionality for all plugins. The Tools class provides utility functions that can be used by plugins, while the App class represents the main application and manages the lifecycle of plugins.
Meanwhile, the Log class provides logging capabilities for debugging and monitoring.
Since every plugin is derived from the Base class, it inherits a reference to the main application instance through the this.app property, which allows plugins to access the application's inner properties and methods. And since all plugins are dynamically attached to the app instance as this.app.pluginName properties, they can also access other plugins' properties and methods. This allows easy communication and collaboration between plugins.
Example:
// In your plugin class
...
async myMethod (params) {
const { getModel } = this.app.dobo
const model = await getModel('CdbCountry') // get `CdbCountry` model from `bajoCommonDatabase` plugin
const query = { id: { $in: ['ID', 'MY', 'AU' ] } } // define your query here
const limit = 10 // define your limit
const sort = { name: 1 } // define your sort order
const countries = await model.findRecord({ query, limit, sort }, { dataOnly: true }) // find records from the model
console.log(countries) // log the result
}
...
Anatomy of a Plugin
A plugin is a normal JavaScript package with a package.json file and an entry point file (usually index.js) that exports a factory function. The factory function acts as a registration mechanism to the framework and is called by the Bajo at boot time.
The factory function must return a class that extends the Base class.
Directory Structure
Shown below is a typical directory structure of a plugin:
.
├── asset
│ └── logo.png
├── extend
│ ├── bajo
│ ├── intl
│ │ ├── en-US.json
│ │ └── id.json
│ ├── hook
│ │ └── ...
│ └── ...
├── lib
│ └── ...
├── package.json
└── index.js
While the above structure is a common convention, it is not mandatory. A plugin can have any structure as long as it has a valid package.json file and an entry point file that exports a factory function explained below. However, following the conventions outlined in this guide will help ensure consistency and maintainability across plugins.
package.json
package.json file must be a valid npm package with a unique name in ES6 module format. It must include a bajo property with at least type set to plugin to be recognized as a plugin by the Bajo framework, e.g.:
{
"name": "my-plugin",
"version": "1.0.0",
"type": "module",
"bajo": {
"type": "plugin",
"alias": "myplugin",
"dependencies": ["dobo", "dobo-common-database"]
},
"main": "index.js",
...
}
Please note that the alias property is optional and can be used to define a custom name for the plugin when it is attached to the app instance. If not provided, the plugin will be attached using its kebab cased plugin namespace.
If the plugin has dependencies on other plugins, they must be listed in the dependencies array. The Bajo framework will automatically load the required plugins before loading the current plugin.
Click here for details.
Boot File
Boot file (usually index.js) must export a factory function with one single parameter named pkgName and returns a class extending the Base class, e.g.:
// index.js
async function factory (pkgName) {
const { Base } = this.app.baseClass
const me = this
return class MyPlugin extends Base {
constructor () {
super(pkgName, me.app)
this.config = {
key: {
subKey: 'value'
}
}
}
async init () {
// Initialization code here
}
}
}
export default factory
Other Files and Directories
While the above two files are mandatory, a plugin can also include other files and directories as needed, such as assets, or additional modules. The inner structure of the plugin is flexible and can be organized according to the developer's preferences. Bajo class offers a set of methods, conventions, and best practices for organizing plugin files, which should be followed to ensure consistency and maintainability. Some conventions you could follow are:
asset/directory: a plugin should have its own transparent png logo. If you have one, place it here with the namelogo.png. This logo will be used in the Bajo framework's UI to represent your plugin. Other than the logo, you can also include other assets in this directory, such as images, icons, or other media files that your plugin may require.lib/directory: this is where you can place your plugin's libraries or modules. You can organize your code into multiple files and directories within this folder, following a structure that makes sense for your plugin's functionality.extend/{otherPluginNs}directory: if your plugin extends functionalities of other plugins, you can place the resource required for the extension here. This could include additional modules, configuration files, or other assets needed to properly extend the functionality of the other plugins. More on this in the Extending Other Plugins section below.- Use kebab case for file and directory names. This is a common convention in the JavaScript ecosystem and helps maintain consistency across your plugin's structure.
Configuration object
A plugin should use a configuration object to define its configurable options as much as possible. This allows users to easily customize the behavior of the plugin without modifying its code directly.
More on this see this.config section below.
Class Properties and Methods
Since all plugins are derived from the Base class, they inherit a set of properties and methods that can be used to interact with the Bajo framework and other plugins. Some of the most commonly used properties and methods include:
this.app
A reference to the main app instance. This is may be the most used property in a plugin, as it allows you to interact with the rest of the Bajo framework and other plugins. You can use this property to access other plugins, call their methods, or retrieve their configuration:
this.app.{pluginNs}: access to other plugins' properties and methods. For example, if you want to access thedoboplugin, you can usethis.app.dobo.this.app.lib: access to the most commonly used libraries in Bajo, such as_(lodash),fs,fastGlob,dayjs, etc.this.app.baseClass: access to the base class definition of Bajo, such asBase,Dobo,Waibu, etc.this.app.getAllNs(): retrieves an array of all plugin namespaces loaded in the app- and many more. See App for a complete list of properties and methods available in the app instance.
this.config
The default configuration object, which can be overridden by environment variables, command line arguments, or configuration files. If this property is missing, Bajo will assign an empty object to it.
You are strongly recommended to define a default configuration object in your plugin class, as it allows users to easily customize the behavior of the plugin without modifying its code directly.
The default configuration object defined here can later be overridden by a set of methods listed below in the order of priority:
- Environment variables: a plugin's configuration can be overridden by names starting with
{PLUGIN_NAMESPACE}.{KEY}(e.g.,MY_PLUGIN.KEY__SUB_KEY). - Command line arguments: a plugin's configuration can be overridden by names starting with
--{pluginNs}:{key}(e.g.,--myPlugin:key-subKey). - Configuration files with env: a plugin's configuration can be overridden by editing
{dataDir}/config/{pluginNs}-{env}.{ext}file, where{env}is the environment and{ext}is the file extension of your choice. - Default configuration file: a plugin's configuration can be overridden by editing
{dataDir}/config/{pluginNs}.{ext}file, where{ext}is the file extension of your choice. - If none are provided, the plugin will use its default configuration object defined in the plugin class.
Warning: It is important to note that only values that are defined in the default configuration object can be overridden. If a key is not present in the default configuration object, it will be ignored even if it is provided in the environment variables, command line arguments, or configuration files. If you leave it empty, users will not be able to override any configuration options for your plugin.
this.init()
After a plugin is successfully instantiated and loaded, the init() method is called. This is where you can perform any setup or initialization tasks for your plugin, or even modify the configuration object that has been overridden by the user.
This is where you can sanitize the configuration object, validate its values, or set up any necessary resources for your plugin.
this.start()
After all plugins (including yours) have been loaded and initialized, Bajo begins to start the plugin one by one in the same order determined by the boot process. Your plugin's start() method is then called.
This is where you can safely perform your plugin's main tasks, such as starting servers, connecting to databases, etc.
After this method is called, the plugin is considered to be fully loaded and ready to use. this.config will be frozen and cannot be modified.
this.exit()
When the app is exiting gracefully (e.g. by pressing Ctrl+C), the exit() method is called for each plugin in the reverse order of the boot process. This is where you can perform any cleanup tasks for your plugin, such as closing database connections, stopping servers, or releasing resources.
This method won't be called if the app is terminated abruptly (e.g. by calling this.fatal() method or this.app.exit(true) or by killing the process). In such cases, the app will exit immediately without giving the plugin a chance to clean up.
Own Members
You can define your own properties and methods in your plugin class as needed. These properties and methods can then be used to implement the functionality of your plugin, and can be accessed by other plugins through the this.app.{pluginNs} property.
It is advisable to always use anonymous functions for your methods to avoid issues with this binding. This is especially important because this may not refer to the plugin instance when the method is called as a callback or event handler. Even if your class becomes big and to overcome this complexity you must import your methods from other files, you should still force bind these functions to this to the plugin instance.
You can do this manually by using bind(this) or using the inherited this.bindThis() method, as shown in the example below:
import { myMethod1, myMethod2, myMethod3 } from './lib/methods.js'
async function factory (pkgName) {
const { Base } = this.app.baseClass
const me = this
return class MyPlugin extends Base {
constructor () {
super(pkgName, me.app)
this.config = {
key: {
subKey: 'value'
}
}
this.bindThis(myMethod1, myMethod2, myMethod3) // bind all methods to this plugin instance
}
}
}
Note: It is recommended to always put your methods in the same file as your plugin class, unless the method becomes too big. Putting your methods in the same file as your plugin class will make it easier to read and understand the code, and will also make it easier to maintain and debug. It is also important to note that having too many imported methods from other files can impact the load time and performance.
Extending Other Plugins
The beauty of Bajo is that it allows you to extend other plugins without modifying their code. By convention, this is done by placing your extension files in the extend/{otherPluginNs} directory, where {otherPluginNs} is the namespace of the plugin you want to extend.
There are no strict rules on how other plugins can be extended because it is up to the plugin developer to decide how they want to expose their plugin's functionality for extension. For this reason, it is important to read the documentation of the plugin you want to extend to understand how to properly extend it.
Core bajo itself is a plugin and offers a set of extension points to extend its own functionality. Shown below are some of the most commonly used extension points in Bajo:
Translation
Your plugin is i18n-ready by default and you should use it extensively by providing translation files in the extend/bajo/intl directory. The translation files should be named using the locale code (e.g., en-US.json, id.json, etc.) and should contain key-value pairs for the translations.
Example:
- Your english translation file in
extend/bajo/intl/en-US.json:{ "hello%s": "Hello %s, welcome to Bajo!", "goodbye%s": "Goodbye %s, see you later!" } - Your indonesian translation file in
extend/bajo/intl/id.json:{ "hello%s": "Halo %s, selamat datang di Bajo!", "goodbye%s": "Sampai jumpa %s, sampai bertemu lagi!" } - Somewhere in your plugin or module:
// Assuming the current locale is set to 'en-US' const greeting = this.t('hello%s', 'John') // returns "Hello John, welcome to Bajo!" const farewell = this.t('goodbye%s', 'John') // returns "Goodbye John, see you later!"
Text translation always begins using your own translation file. If the key is not found, it will be looked in all other plugins, including the core Bajo's translation files. If none is found, it will return the key itself; interpolated with the provided arguments if any.
It is advisable to always look for the key in core Bajo or other plugins first before adding your own translation key to minimize duplication and maintain consistency. Unless you intend to override it, in which case you should use the same key as the original translation.
Note: Even though Bajo supports multiple formats, only JSON format is allowed for translation files. The reason is that JSON parsing is very fast and needs lower overhead compared to other formats. This is important for performance, especially when dealing with large translation files.
Hook
A hook is a way to extend the functionality of a plugin by allowing other plugins to register their own functions to be called at specific points in the plugin's lifecycle. This allows for a high degree of flexibility and customization, as other plugins can modify the behavior of the plugin without modifying its code.
As a plugin developer, you can define your own insertion points in your plugin by using the Bajo's runHook() method. And as a plugin user, you can create your own functions to be called at those insertion points as shown below:
- Insertion points within your plugin:
... doSomething = async (filter, options = {}) => { const { getModel } = this.app.dobo const model = await getModel('CdbCountry') // get `CdbCountry` model from `bajoCommonDatabase` plugin return await model.findRecord(filter, options) // find records from the model } start = async () => { const { runHook } = this.app.bajo await runHook('myPlugin:beforeDoSomething', filter, options) // first insertion point const result = await this.doSomething(filter, options) // your plugin's main function await runHook('myPlugin:afterDoSomething', filter, result, options) // second insertion point } ... - Hook listeners in other plugins (or even in the same plugin), using one
hook.jsfile:// create a hook listener in `/extend/myPlugin/hook.js` file const hooks = [ { name: 'myPlugin:beforeDoSomething', handler: async (filter, options) => { // do something before the main function is called console.log('Before doing something:', filter, options) } }, { name: 'myPlugin:afterDoSomething', handler: async (filter, result, options) => { // do something after the main function is called console.log('After doing something:', filter, result, options) } } ] export default hooks - Hook listeners using multiple files per hook:
// create the first listener in `/extend/myPlugin/hook/my-plugin@before-do-something.js` file async function beforeDoSomething (param, options) { // do something before the main function is called console.log('Before doing something:', param, options) } export default beforeDoSomething // and the second listener in `/extend/myPlugin/hook/my-plugin@after-do-something.js` file async function afterDoSomething (param, result, options) { // do something after the main function is called console.log('After doing something:', param, result, options) } export default afterDoSomething
Warning: Even though hooks are a powerful feature, they should be used sparingly and only when necessary. Overusing hooks can lead to code that is difficult to understand and maintain. It is important to carefully consider whether a hook is the best solution for a given problem before implementing it.
Creating Extendable Plugins
There are no clear rules on how to create extendable plugins because it is up to the plugin developer to decide how they want to expose their plugin's functionality for extension. However, there are some best practices that can be followed to make your plugin more extendable and easier to use by other developers.
Let's take an example on how Dobo was created.
Case Study: Dobo Models
As you might already know, Dobo is a plugin that provides a set of tools for working with databases. It is designed to be extendable so that other plugins can add their own functionality to it. And it becomes the sub-framework of its own, which is used by many other plugins to provide database-related functionality.
Dobo grew out of the need to have a common set of tools for working with databases in Bajo. It supposed to be:
- A plugin that provides a set of tools for working with databases, such as models, queries, and migrations
- A plugin that supports many adapters for many different databases, be it SQL or NoSQL, relational or non-relational, and even in-memory databases
- A plugin that provides one common interface for CRUD operations, including database queries, regardless of the underlying database technology
How do we solve this problem?
We could make a whole book about this, but let's dive on one particular problem and make a case study: how do we make Dobo collect and manage database models from other plugins. This is done as follows:
- Create a directory
extend/dobo/modelin your plugin directory. This is where your table schemas will be placed. - Create a schema file in it with the name
{table-name}.{js|json|yml}where{table-name}is the name of the table. You can use any format supported by App's config handler, but we recommend using JSON format for consistency and performance reasons. This results a model named{Alias}{TableName}where{Alias}is the alias of the plugin that provides the model, and{TableName}is the name of the table in PascalCase format. For example, if your plugin alias ismypluginand you create a schema file namedcountry.jsonit will result a model namedMypluginCountry. - The model schema file should contain the table schema in a specific format. Please refer to the Dobo documentation for the more details. In short, it should contain the fields, and their types, as well as any other relevant information such as indexes etc.
Now comes the tricky part: how do we make Dobo collect all those schemas and turned into a database model so that it can be used anywhere? By using Bajo's eachPlugins() method.
eachPlugins(callback, options) method is a powerful method that allows you to iterate over all plugins loaded in the app. It takes a callback function as its first parameter, which will be called for each iteration and an options object as its second parameter to control the operation
To make things clear, let's take a look at the code below taken directly from Dobo's source code and reformatted for better readability:
...
init = async () => {
const { eachPlugins } = this.app.bajo
const { dobo } = this.app
// options parameter
const options = {
prefix: dobo.ns, // read all files started from `extend/dobo` directory
glob: ['model/*.*', 'model.*'] // read all files in `model` directory or its parent that match with `model.*` pattern
}
// callback function
async function callback ({ file }) {
// 'this' is always the calling plugin instance, in this case is your plugin instance
// `file` is the file name, in absolute path, e.g. `/path/to/your/plugin/extend/dobo/model/country.json`
const model = await dobo.createModelFromSchema(file) // fictitious method to create model from schema file
dobo.models.push(model) // add the model to the list of models in Dobo
}
// now read all schema files from all plugins and create models from them
await eachPlugins(callback, options)
// by now all models from all plugins have been collected and added to Dobo's model list, and can be used anywhere
}
...