/extendit

Framework and library for creating extensible and scalable TS/JS applications

Primary LanguageTypeScriptMIT LicenseMIT

image

CI codecov npm TypeScript Prettier MIT

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.

Highlights

  • 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.
  • 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.

Demo

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.

Installation

npm install @forman2/extendit

or

yarn add @forman2/extendit

Usage

Extension basics

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
}

Extension-specific APIs

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({ ... });
}

Extension installation

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
     });
   });
}

Contribution points and contributions

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>
  );    
}  

Documentation

We currently only have this file and the API docs, sorry.

Development

Source code

Get sources and install dependencies first:

$ git clone https://github.com/forman/extendit
$ cd extendit
$ npm install

Scripts

Now the following scripts are available that can be started with npm run:

  • dev - run the React Demo in development mode
  • build - build the library, outputs to ./dist
  • lint - run eslint on project sources
  • test - run project unit tests
  • coverage - generate project coverage report in ./coverage
  • typedoc - generate project API docs in ./docs

Configuration

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

Contributing

ExtendIt.js welcomes contributions of any form! Please refer to a dedicated document on how to contribute.

Acknowledgements

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

License

Copyright © 2023 Norman Fomferra

Permissions are hereby granted under the terms of the MIT License: https://opensource.org/licenses/MIT.