Inter-plugin Communication (Expose API to Other Plugins)

I’ve finally figure out the way to fully publish API type definitions of plugins, including custom events hooked into Vault/ metadataCache.

Solutions

So currently, I’ve worked out two methods to provide API that works:

  • Method 1: export API to global namespace directly (thanks @pjeby for the enlightenment)
  • Method 2: access from plugin.app.plugins.plugins["your-plugin-id"]?.api

For details, here is the example of a minimal project, with some tricks in typescirpt to keep types of on args and trigger args for events in sync:

Example for inter-plugin API declaration in obisidian (github.com)

Real-world demo

In this small project, I’ve used metadataCache.on("chs-patch:ready") to notify api costmers if they are initialized before provider, and use Method 1 to export api.

Limitations & Proposal

However, method 1 is definitely polluting the global namespace, despite the convenience to detect and access API. It is risky if other plugins accidentally use the package that relies on a member exported to global, whose name coincides with the API name. And accessing API via method 2 is truly lengthy.

So here I propose two potential solutions for obsidian to improve:

  1. declare a member pluginApi in the global namespace, similar to app, so that plugin devs can add their API to window.pluginApi.API_NAMEv3 to avoid pollution
  2. add loadApi(api: any) and unloadApi(api: any) method to Plugin class and have an additional member in Plugin class: apis to access other apis. The type of app.apis can be an id-api map like:
    Map<string, { version: string; [key: string]: any }>
    
    (the version can be used to check compatibility of API in case of breaking changes)

All of the window[API_NAME] stuff looks unnecessary to me. You should be able to simply do:

// Access API with Method 1
import "your-package-name";
if (SomeAPIv0) SomeAPIv0.doSomething()

As that’s the whole point of making a global declaration. See Obsidian’s API typings, which include globals like this:

declare global {
    function fish(selector: string): HTMLElement | null;
}

This is then exposed by Obsidian as:

window.fish = function(e) {
        return document.querySelector(e)
}

Any plugin can call the fish function, and if you type fish into VSCode in a Typescript module that imports obsidian, you will see typings and completion for it.

This is literally all you have to do in order to access APIs exposed as globals.

With regard to the idea of adding an extra pluginAPI to the global namespace, I don’t really see the point, since “pollution” is trivial to avoid by convention. If every plugin doing this includes APIv and a number, then there can only be a collision if two plugins choose the same name for their API.

As a practical matter, though, if you want to have more stringent naming requirements, while still making your API easy to use, you can add an index.ts to your plugin like this:

declare global {
    interface Window {
        ["my.api.v0"]?: MyAPI
    }
}

export function getMyAPI() {
    return window["my.api.v0"];
}

And others would use it like this:

import {getMyAPI} from "your-package-name";
const myAPI = getMyAPI();
if (myAPI) myAPI.doSomething();

…with the versioning being determined by the version of “your-package-name” they added to their project. (Though you could also expose multiple version-specific accessor functions, or wrapper functions that get the API and call methods on it or throw an exception if the API isn’t there, or aysnc wrapper functions that wait for the plugin to be loaded… any number of interfacing possibilities are possible here.)

The index.ts would be built using typescript to generate an index.js (which you would list as the main in package.json), and an index.d.ts with exposed typings. (So index.ts should also export type for any types needed to use the exposed API.)

The plugin would still place the object in window under a unique key, but other plugins don’t have to care about that.

In fact, with this approach, you can use any method you like for globally exposing and retrieving the API instance, since the method of access is provided by (and concealed in) the npm package.

Basically, though, the ideal state is to make it so others can consume your plugin’s API just by importing it from an npm package, without any special overhead or ceremony other than checking to make sure the API is actually available at the time of calling.

It just means you have to add an index.ts with any wrapper functions and type declarations, build index.js and index.d.ts from it, and list those in the package.json when you publish the project to npm. (And of course, take care with your API versioning, package versioning, etc. to keep disruption to a minimum.)

I remembered Licat once mentioned that better not access window.app in favor of plugin.app, because it’s meant for debug purpose. I think I should keep the comments for now for Licat’s opinions. And btw, rely on conventions and hoping that all plug-in devs can behave is ideal, but not practical. If someone accidentally replace window.moment or even window.CodeMirror to his api, it would be a disaster for anyone with the plug-in installed.

This is a great idea, but the reason why I first choose not publish js+generated d.ts file is that configs of bundlers can varies significantly depend on project’s config like path alias, import style, etc, therefore more complex than publishing d.ts alone. I’ve encountered this problem in dataview before, and exporting types from source ts files is very tricky, so I doubt if there is an universal rollup config for this.
Anyways, I’ll add it to the example, maybe not so soon. The best solution may still be a built-in standard util function in obsidian to achieve this.

I remembered Licat once mentioned that better not access window.app in favor of plugin.app, because it’s meant for debug purpose

That’s correct, but has little to do with global API exposure. Obsidian itself exposes many global APIs.

If someone accidentally replace window.moment or even window.CodeMirror to his api, it would be a disaster for anyone with the plug-in installed.

This can happen now, and occasionally has happened when people use libraries that are not properly modularized. The solution isn’t to not use the global namespace though, and adding a specialized global doesn’t change anything in this regard.

After all, if we are assuming plugin developers make mistakes of that magnitude (and aren’t caught in review), they might just as well set window.pluginAPI = {myPluginv1: Something() } and wipe out all the other APIs by accident. :slight_smile:

the reason why I first choose not publish js+generated d.ts file is that configs of bundlers can varies significantly depend on project’s config like path alias, import style, etc

You need a separate tsc task for this, it’s not something you can do with rollup. I believe it needs to be a prepublish script, or prepare, or whatever the convention is now. I’d need to look it up. But if you have rollup and typescript installed, adding a tsc command to compile your types and an index.ts is not a big deal at all. If it is, it’s probably because you need to specify the API exports in terms of interfaces rather than classes.

exporting types from source ts files is very tricky, so I doubt if there is an universal rollup config for this.

Huh, I guess you can do it with rollup after all. I just don’t recommend it, it seems way easier to me to just add an npm script that runs tsc to compile the types for export along with a stub access API.

I’m fairly sure I’ll be doing this at some point, so I will have an example to point to eventually. (Though probably not this month.)

The reason I’m using such a complicated config is that tsc will not translate typescript-specific absolute imports to relative ones, like import {} from "main" is allowed to refer to src/main.ts when tsconfigs.baseUrl is set to src, but it won’t work as expected when they are exported to d.ts files, so with the plugins, I am able to translate them to relative imports. Now I’m thinking of a solution to get it working more easily, and I’ll update the example when I update dataview API with success.

And btw, rollup can do both bundling and emitting type defs, so no direct call for tsc is required actually. But if project are using esbuild for the task, it will probably be needed since one of the reason for it to be lightning fast is that it completely skips type checks

import {} from "main" is allowed to refer to src/main.ts when tsconfigs.baseUrl is set to src , but it won’t work as expected when they are exported to d.ts files

Standard practice is to always use explicit relative paths (i.e. ./main rather than just main) when referring to code in the same package, so following that practice would fix this. (The common expectation when reading JS or TS code and seeing an import from "main", would be to assume it’s from an npm package called main, not another file in the project.)

Yes indeed, it’s a typescript specific feature, and setting up path alias for base dir is a better way to achieve the same “absolute import on base folder” feature with higher compatibility. But to be honest, it’s a matter of personal preference, and there is a option for import style in typescript setting of vscode that will use absolute import automatically when relative one becomes lengthy, which may cause confusion. So in my view, finding a way to get types definition exported in all import style is preferred, rather than specifying a standard practice which requires devs to change their existing code and their preferences to expose api.

Anyway, I’ve made some progress in folder-note-core, which now provides getApi() and registerApi(plugin,callback) util functions in npm package alongside the type definitions, so that using api now only need require(“folder-note-core”).getApi(), and also figure out the way to solve the problem of absolute imports. When time permits, I’ll update the guide in details as soon as I successfully update dataview’s api.