ExtendIt.js is a framework and library that is used to create extensible and scalable JavaScript applications. Its core API design is largely inspired by the Extension API of Visual Studio Code.
ExtendIt.js provides the means for a host application to dynamically import JavaScript modules - extensions - that add new features and capabilities to the application.
ExtendIt.js has been designed to efficiently work with
React, for this purpose it provides a number of
React hooks. However, the library
can be used without React too. It's just a peer dependency.
- Simple, low-level API allowing for complex, loosely coupled application designs offered by dependency inversion.
- Manages extensions, which are JavaScript packages with a minor
package.json
enhancement. - Lets applications and extensions define contribution points that specify the type of contribution that applications and extensions can provide.
- Lets applications and extensions provide contributions to a given
contribution point. Contributions can be
- JSON entries in the extension's
package.json
and/or - JavaScript values registered programmatically in code.
- JSON entries in the extension's
- Allows dynamic loading of code:
- Extensions may be installed at runtime or bound statically.
- Code contributions are loaded on demand only, while JSON entries can be used right after extension installation.
- Provides optional utilities for Web UI development:
- React hooks for reactive access to extensions and contributions.
- Predefined contribution points for typical UI elements.
To see the API in action, you can run the
Demo code
using npm run dev
,
see section Development below. It is a simple React
application that demonstrates how extensions are installed,
activated, and how they can contribute elements such as commands or
UI components to an application.
npm install @forman2/extendit
or
yarn add @forman2/extendit
Any extension must be defined by its
extension manifest,
which is basically a slightly enhanced
package.json
.
{
"name": "my-extension",
"provider": "my-company",
"version": "1.0.0",
"main": "init"
}
The main
field is optional. If you provide it as above, it means you
provide an
extension activator
in a submodule named init
which defines an activate()
function that is called if your extension is activated by the host
application:
import { SomeAppApi } from "some-app/api";
export function activate() {
// Use the SomeAppApi here, e.g.,
// register your contributions to the app
}
The activator may also export an extension-specific API for other extensions
import { MyApi } from "./api";
export function activate(): MyApi {
return new MyApi({ ... });
}
Hence, another dependent extension such as
{
"name": "other-extension",
"provider": "other-company",
"main": "init",
"dependencies": {
"@my-company/my-extension": "1.0.0"
}
}
may consume it in its own init.ts
import { type ExtensionContext, getExtension } from "@forman2/extendit";
import { type MyApi } from "@my-company/my-extension";
export function activate(ctx: ExtensionContext) {
const myExtension = getExtension("my-company.my-extension");
const myApi = myExtension.exports as MyApi;
// Use imported extension API here, e.g., to add some contribution
myApi.registerViewProvider({ ... });
}
If you add extensionDependencies
to your package.json
{
"extensionDependencies": [
"my-company.my-extension"
]
}
then you can save some lines of code in your activator, because the
framework passes desired APIs as a subsequent arguments corresponding
to the extensionDependencies
entries:
import { type ExtensionContext, getExtension } from "@forman2/extendit";
import { type MyApi } from "@my-company/my-extension";
export function activate(ctx: ExtensionContext, myApi: MyApi) {
myApi.registerViewProvider({ ... });
}
The host application registers (installs) extensions by using the
readExtensionManifest
and
registerExtension
functions:
import { readExtensionManifest, registerExtension } from "@forman2/extendit";
export function initApp() {
const extensionsUrls: string[] = [
// Get or read installed extension URLs
];
const pathResolver = (modulePath: string): string => {
// Resolve a relative "main" entry from package.json
};
extensionUrls.forEach((extensionUrl) => {
readExtensionManifest(extensionUrl)
.then((manifest) =>
registerExtension(manifest, { pathResolver })
)
.catch((error) => {
// Handle installation error
});
});
}
The host application (or an extension) can also define handy contribution points:
import { registerContributionPoint } from "@forman2/extendit";
export function initApp() {
registerContributionPoint({
id: "wiseSayings",
manifestInfo: {
schema: {
type: "array",
items: {type: "string"}
}
}
});
}
Extensions can provide contributions to defined contribution points.
Contributions are encoded in the contributes
value of an extension's
package.json
:
{
"name": "my-extension",
"provider": "my-company",
"contributes": {
"wiseSayings": [
"Silence is a true friend who never betrays.",
"Use your head to save your feet.",
"Before Alice went to Wonderland, she had to fall."
]
}
}
A consumer can access a current snapshot of all contributions
found in the application using the
getContributions
function:
const wiseSayings = getContributions<string[]>("wiseSayings");
The return value will be the same value, as long as no other extensions are
installed that contribute to the contribution point wiseSayings
. If this
happens, a new snapshot value will be returned.
If you are building a React application, you can use the
provided React hooks
in @forman2/extend-me/react
for accessing contributions (and other elements
of the ExtendMe.js API) in a reactive way:
import { useContributions } from "@forman2/extend-me/react";
export default function WiseSayingsComponent() {
const wiseSayings = useContributions("wiseSayings");
return (
<div>
<h4>Wise Sayings:</h4>
<ol>{ wiseSayings.map((wiseSaying) => <li>{wiseSaying}</li>) }</ol>
</div>
);
}
The component will be re-rendered if more contributions are added to the contribution point.
A contribution may be fully specified by the JSON data in the
contributes
object in package.json
. It may also require JavaScript to be
loaded and executed. Examples are commands or UI components that are rendered
by React or another UI library. The following contribution point also
defined codeInfo
to express its need of JavaScript code:
import { registerCodeContribution } from "@forman2/extendit";
export function activate() {
registerContributionPoint({
id: "commands",
manifestInfo: {
schema: {
type: "array",
items: {
type: "object",
properties: {
id: {type: "string"},
title: {type: "string"}
}
}
}
},
codeInfo: {
idKey: "id",
activationEvent: "onCommand:${id}"
}
});
}
The entry activationEvent
causes the framework to fire an event of the form
"onCommand:${id}"
if the code contribution with the given "id"
is
requested. In turn, any extension that listens for the fired event will be
activated.
Here is an extension that provide the following JSON contribution to the
defined contribution point commands
in its package.json
{
"contributes": {
"commands": [
{
"id": "openMapView",
"title": "Open Map View"
}
]
}
}
and also defines the corresponding JavaScript code contribution in its activator:
import { registerCodeContribition } from "@forman2/extendit";
import { openMapView } from "./map-view";
export function activate() {
registerCodeContribition("commands", "openMapView", openMapView);
}
Such code contributions are loaded lazily. Only the first time a
code contribution is needed by a consumer, the contributing extension will be
activated. Therefore, code contributions are loaded asynchronously using the
loadCodeContribution
function:
import { loadCodeContribution } from "@forman2/extendit";
import { type Command } from "./command";
async function getCommand(commandId: string): Promise<Command> {
return await loadCodeContribution<Command>("commands", commandId);
}
There is also a corresponding React hook
useLoadCodeContribution
that is used for implementing components:
import { useLoadCodeContribution } from "@forman2/extendit/react";
import { type Command } from "./command";
interface CommandButtonProps {
command: Command;
}
export default function CommandButton({ command }: CommandButtonProps) {
const commandCode = useLoadCodeContribution("commands", command.id);
if (!commandCode) { // Happens on first render only
return null;
}
return (
<button
onClick={commandCode.data}
disabled={commandCode.loading || commandCode.error}
>
{command.title}
</button>
);
}
We currently only have this file and the API docs, sorry.
Get sources and install dependencies first:
$ git clone https://github.com/forman/extendit
$ cd extendit
$ npm install
Now the following scripts are available that can be started with npm run
:
dev
- run the React Demo in development modebuild
- build the library, outputs to./dist
lint
- runeslint
on project sourcestest
- run project unit testscoverage
- generate project coverage report in./coverage
typedoc
- generate project API docs in./docs
You can use .env
files, e.g., .env.local
to configure development options:
# As `vite build` runs a production build by default, you can
# change this and run a development build by using a different mode
# and `.env` file configuration:
NODE_ENV=development
# Set the library's log level (ALL, DEBUG, INFO, WARN, ERROR, OFF)
# Logging is OFF by default.
# Note, if the level is not set or it is OFF, no console outputs
# are suppressed while unit tests are run.
VITE_LOG_LEVEL=ALL
ExtendIt.js welcomes contributions of any form! Please refer to a dedicated document on how to contribute.
ExtendIt.js currently uses the awesome libraries
- ajv for JSON validation (may be turned into peer dependency later)
- memoize-one for implementing state selector functions
- zustand for state management
Copyright © 2023 Norman Fomferra
Permissions are hereby granted under the terms of the MIT License: https://opensource.org/licenses/MIT.