Create Custom Plugin Provider (Javascript API) for Web App
We are not creating a Plugin but a Plugin Provider (Javascript API)!
That is we write code for other people to use to write code.
We use those all the time, for example, target.addEventListener(type, listener) to add custom handlers to specific events, but we probably don’t get much chances in creating one!
So!
In this article, let’s create a Custom Plugin Provider (Javascript API) for our own Web App here for other developers to use to add custom callbacks to specific events using some custom data we provide.
Specifically, We will have two different kinds of data.
- Predefined static data on the
windowobject - Dynamic data created when an event is fired
We can also separate the events we will be providing into two different kinds.
- Custom event on document load with custom data. This will be where we don’t care about what other developers do in the callback.
- Custom event on button click. In this case, we have some predefined
onclickfunctions on the button. We will fire our custom event, execute the callbacks (if there is any) provided by other developers using the API interface we provide. Iffalseis returned from the callback, we will terminate the process, ie: not execute the originalonclickhandler; process on otherwise. This can be really useful, for example, for other developers to provide additional validation check on the inputs.
We will be starting from zero here, from creating a project, installing webpack, configuring it, writing our handlers code, packing it and trying it out!
You can grab the final version from my GitHub! Feel free to use it as a quick start for your own project!
Let’s start!
Final Version
I am not sure if I am being clear on what we are trying to achieve here so let’s take a look at the final version.
Let’s say we have the following Web application.
<!DOCTYPE html>
<html lang="ja">
<!-- ************* provided by Itsuki ************* -->
<head>
<meta charset="UTF-8">
<title>Page Provided By Itsuki</title>
<script>
// script provided by itsuki
// predefined page data to pass into the API
window.itsuki = {
"pageData": {
"title": "Itsuki World!"
}
};
function formConfirm() {
console.log("form confirmed");
document.getElementById("confirmResult").innerHTML = "Confirmed!";
}
</script>
<!-- JavascriptAPI provided -->
<script src="bundle.js"></script>
</head>
<body>
<h3>Page Provided By Itsuki</h3>
<label for="name">Name: </label>
<input id="name" /><br>
<label for="email">Email: </label>
<input id="email" /><br>
<p>Confirm Button to fire "itsuki.confirm" events</p>
<button onclick="formConfirm()">Confrim</button>
<p id="confirmResult"></p>
</body>
<!-- ********************* End ********************* -->
<!-- *********************************************** -->
<!-- Script Provided by other developers -->
<script>
// script provided by other developers for customizing our page behavior with the Javascript API We provide
// event with custom data
(function () {
// triggered on page show
itsuki.events.on("itsuki.show", function (event) {
console.log(event);
// do something with custom event data
});
// triggered on confirm button click
// event with custom data
// return true to procced onto the button click event
// return false to terminate
itsuki.events.on("itsuki.confirm", function (event) {
console.log(event);
const name = event.data.name;
if (name.length === 0) {
alert('No empty name!');
return false;
}
const isnum = /^\d+$/.test(name);
if (isnum) {
alert('You cannot have a name with only digit!');
return false;
}
return true; //or false to terminate the default even
});
})();
</script>
</html>I have included The Provided by other developers part as inline-javascript here, but in real world, you will have them upload javascript files and included those instead.
What we (who make the application) are providing here in the original application are
- some predefined page data
- couple inputs for some dynamic data
- a function to perform on button click
- plugin provider / Javascript API with two events, one fired on page load, and one fired on button click
What other developers can do by utilizing the Javascript API we are providing are
- Listen to specific events and provide custom callbacks using the
itsuki.events.on(name, callback)syntax. - Terminate button processing by returning
falsein the callback.
As you can see, even though our original button click handler formConfirm does not checks on the inputs, other developers can validate those through our API!
Hope that is enough to clarify our final goal here!
Set Up
Create Directory
mkdir plugin-provider
cd plugin-provider
npm init -yLet’s also add couple folders and files.
.
+ ├── dist
+ │ └── index.html
├── package-lock.json
├── package.json
+ ├── src
+ │ ├── index.ts
+ └── └── event.tsWebpack Installation
What is webpack?
At a super high level, it is just a bundler to compile multiple JavaScript/Typescript modules into one.
We are using Typescript here so we have to compile it anyway, but even if you are using JavaScript, it is still strongly recommended.
By using it, we can reduce the number of requests from the browser, able to bundle not only ES Modules and CommonJS modules, but also CSS and images!
Any way!
Let’s install it. We will need both the main package as well as the CLI to run webpack on the command line.
npm install --save-dev webpack webpack-cliAdd Typescript
To use Typescript with webpack, we will need ts-loader, the TypeScript loader for webpack.
npm install --save-dev typescript ts-loaderConfigure Typescript
Let’s first create our tsconfig.json.
npx tsc --initand change it to the following.
{
"compilerOptions": {
"outDir": "./dist/",
"target": "es2016", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */
"module": "commonjs", /* Specify what module code is generated. */
"forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */
"strict": true, /* Enable all strict type-checking options. */
"noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */
"skipLibCheck": true, /* Skip type checking all .d.ts files. */
"removeComments": true,
"preserveConstEnums": true,
"sourceMap": true
},
"include": [
"src/*.ts"
],
"exclude": [
"node_modules"
]
}See TypeScript’s documentation to learn more about configuration options.
Configure Package.json
Two things here.
- Set our package as
private - Remove the
mainentry.
I have also added couple script commands for packing the modules.
// package.json
{
"name": "plugin-provider",
"version": "1.0.0",
"private": true,
"scripts": {
"build": "webpack --mode production",
"dev": "webpack --mode development",
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC",
"description": "",
"devDependencies": {
"ts-loader": "^9.5.1",
"typescript": "^5.7.2",
"webpack": "^5.96.1",
"webpack-cli": "^5.1.4"
}
}Configure Webpack
If you are using javascript and not need additional configurations, you can actually just pack your application with npx webpack without adding any configuration file.
But! We need it here!
Create a webpack.config.js in the root directory with the following contents.
const path = require('path');
module.exports = {
entry: './src/index.ts',
devtool: 'inline-source-map',
module: {
rules: [
{
test: /\.tsx?$/,
use: 'ts-loader',
exclude: /node_modules/,
},
],
},
resolve: {
extensions: ['.tsx', '.ts', '.js'],
},
output: {
filename: 'bundle.js',
path: path.resolve(__dirname, 'dist'),
},
};This will tell webpack to enter through ./src/index.ts, load all .ts and .tsx files through the ts-loader, and output a bundle.js file in the dist directory.
Get Itsuki’s stories in your inbox
Join Medium for free to get updates from this writer.
You can find out more about the configurations you can make here.
****************
That’s a long set up!
We are ready!
****************
index.ts
As we see from the webpack configuration file above, this will be the entry point.
// index.ts
import { MyEvents } from "./event";
const global_window: any = <any>window;
if (!global_window.hasOwnProperty('itsuki')) {
global_window.itsuki = {};
}
const pageData: { [key: string]: any } = global_window.itsuki.hasOwnProperty('pageData') ? global_window.itsuki.pageData : {};
global_window.itsuki['events'] = new MyEvents(pageData);We are using Typescript so we will need to cast the window to an any first.
Since we want other developers to be able to call our Javascript API with itsuki.events, we will create our MyEvents (the class that is responsible for firing events and executing callbacks) instance on global_window.itsuki with the event key.
We will also retrieve the predefined data if there is any and pass that into MyEvents while constructing.
In the example I have shared with you in the beginning, the predefined data is given by the following.
window.itsuki = {
"pageData": {
"title": "Itsuki World!"
}
};Since we might want to add our Javascript API to different pages through out the application, this is a great place to pass in some page-specific static data.
event.ts
This is where ALL the interesting stuff will happen!
Specifically, we
- Provide a interface for other developers to set callbacks for events
- “Fire/Dispatch” the event with data
- Execute callbacks for an event when provided
Full Code
Full code before we dive in!
// event.ts
export class MyEvents {
private _eventCallbacks: {
[key: string]: Function[];
};
private _pageData: { [key: string]: any } = {};
public constructor(pageData: { [key: string]: any }) {
this._eventCallbacks = {};
this._pageData = pageData
this.injectEvents()
}
// set the callback for a event
public on(eventName: string, callback: Function): void {
if (!this._eventCallbacks.hasOwnProperty(eventName)) {
this._eventCallbacks[eventName] = [];
}
this._eventCallbacks[eventName].push(callback);
return;
}
private injectEvents(): void {
window.addEventListener('load', (_event: Event) => {
// inject event on page load
this.injectOnLoadEvent();
// inject event on button click
this.injectConfirmEvent();
});
}
private injectOnLoadEvent() {
const showEventName = `itsuki.show`;
if (!this.has(showEventName)) {
return;
}
this.fire(showEventName, {
"created_at": new Date(),
"message": "Welcome to Itsuki's world!",
"data": this._pageData
});
}
private injectConfirmEvent() {
const functionName = 'formConfirm';
if (!window.hasOwnProperty(functionName)) {
console.log("not defined")
return;
}
const self: MyEvents = this as MyEvents;
const confirmFunction: Function = (<any>window)[functionName];
(<any>window)[functionName] = async function (): Promise<any> {
const arguments_org = arguments;
const eventName = `itsuki.confirm`;
if (!self.has(eventName)) {
return confirmFunction.apply(this, arguments_org);
}
const canProgress = await self.fire(eventName, {
"created_at": new Date(),
"message": "Return false to terminate the process!",
"data": { ...self._pageData, ...self.buildInputData() }
})
if (canProgress) {
// proceeed on to the original process
return confirmFunction.apply(this, arguments_org);
}
};
}
private buildInputData(): { [key: string]: any } {
let inputData: { [key: string]: any } = {}
const inputs = document.getElementsByTagName("input");
[...inputs].forEach(element => {
inputData[element.id] = element.value
});
return inputData
}
private has(eventName: string): boolean {
return this._eventCallbacks.hasOwnProperty(eventName);
}
private async fire(eventName: string, eventData: { [key: string]: any }): Promise<boolean> {
const callbacks: Function[] = this._eventCallbacks[eventName];
if (!callbacks) {
return true;
}
const callbackResults: any[] = await Promise.all(
callbacks.map(async (callback: Function) => {
try {
return await callback(eventData);
} catch (error) {
console.error(error);
return false;
}
})
);
for (const result of callbackResults) {
if (typeof result === 'boolean' && !result) {
return false;
}
}
return true;
}
}Time to break it down!
Properties
Two properties.
_eventCallbacks: map between an event name and a list of callback functions added for the specific event_pageData: predefined page data
Set Callbacks
Since I want the syntax of adding callback to a specific event to be itsuki.events.on(name, callback) and we have defined our itsuki.events to be an instance of MyEvents in our index.ts, the function will simply be on(eventName: string, callback: Function).
public on(eventName: string, callback: Function): void {
if (!this._eventCallbacks.hasOwnProperty(eventName)) {
this._eventCallbacks[eventName] = [];
}
this._eventCallbacks[eventName].push(callback);
return;
}“Fire/Dispatch” Event & Execute Callbacks
I kept putting quotes on it because we are not actually dispatching the event with dispatchEvent.
Three reasons.
One!
We want to control the syntax that other developers use when calling our API, in this case, itsuki.events.on(name, callback). This is really important because if we were to use dispatchEvent, there comes in the concept of the target that fires the event and the target that listen to the event. We cannot control which target that other developers call addEventListener on which may result in unwanted behavior.
Two!
We don’t have access to the callback provided for a specific event.
Three!
We cannot await dispatchEvent for the callback provided to finish.
The second and third cause problems in the case for the button click event.
We need the callback so that we can inject it into the current onclick handler, and we need to wait for the callback to finis to get the result to determine whether if we will continue onto the predefined onclick function, or return from there.
So!
Even though I used the word “fire”, all we are doing is executing the callbacks if they are defined for the event as you can see from the fire function.
private async fire(eventName: string, eventData: { [key: string]: any }): Promise<boolean> {
const callbacks: Function[] = this._eventCallbacks[eventName];
if (!callbacks) {
return true;
}
const callbackResults: any[] = await Promise.all(
callbacks.map(async (callback: Function) => {
try {
return await callback(eventData);
} catch (error) {
console.error(error);
return false;
}
})
);
for (const result of callbackResults) {
if (typeof result === 'boolean' && !result) {
return false;
}
}
return true;
}Since we want other developers to be able to cancel the default button click handler processing, in my example at the beginning, the formConfirm function, we will be returning a boolean here with false indicating termination.
When we dispatch the itsuki.show event, since we don’t care about the result, we will just fire it with some data without await in our injectOnLoadEvent.
In the case of button click itsuki.confirm event, things get a little bit more complex.
private injectConfirmEvent() {
const functionName = 'formConfirm';
if (!window.hasOwnProperty(functionName)) {
console.log("not defined")
return;
}
const self: MyEvents = this as MyEvents;
const confirmFunction: Function = (<any>window)[functionName];
(<any>window)[functionName] = async function (): Promise<any> {
const arguments_org = arguments;
const eventName = `itsuki.confirm`;
if (!self.has(eventName)) {
return confirmFunction.apply(this, arguments_org);
}
const canProgress = await self.fire(eventName, {
"created_at": new Date(),
"message": "Return false to terminate the process!",
"data": { ...self._pageData, ...self.buildInputData() }
})
if (canProgress) {
// proceeed on to the original process
return confirmFunction.apply(this, arguments_org);
}
};
}We first check if the function we try to inject in is defined, that is the functionName here should be the same as that in the HTML button onclick. If it is defined, we retrieve it, inject the callbacks added for itsuki.confirm event into it, wait for the callbacks to finish, and decide whether if will move on to the original handler based on the results.
Important!
selfandthisis not the same within the(<any>window)[functionName] = async function (): Promise<any>.
Make sure not to mess it up!
Pack and Try
That’s it for the code!
We are ready to pack it and test it out!
You can either run npx webpack — mode development or since we have added as a script in package.json, npm run dev will also work.
You should see a bundle.js showing up in the dist directory in couple milliseconds(?!).
The index.html will just be the one I shared with you previously, so copy and paste it and let’s try!
Of course, exactly what we had above!
Thank you for reading!
That’s it for this article!
Again, feel free to grab the code from my GitHub and use it anyway you like!
Happy writing code to have others to use to write code!











