obsidian/plugin/plugin-api
A typed, versioned, lifecycle-aware registry for cross-plugin APIs — the advanced successor to @vanakat/plugin-api.
A provider publishes its API once, under a contract version that is independent of its plugin version:
publishPluginApi({ api: new MyApi(this), apiVersion: '2.1.0', contract: MY_API_CONTRACT_V2, plugin: this});A consumer gets a LIVE REF whose PluginApiRef.value is always current — null before the provider loads, the API once it publishes, null again once it unloads, and the API again after a re-enable:
const ref = watchPluginApi<MyApi>({ apiVersionRange: '^2', app: this.app, component: this, pluginId: 'my-provider'});
ref.value; // MyApi | null — sync, free, always correctawait ref.whenAvailable(); // one-shot flows; rejects with a typed, classified errorref.on('change', () => { }); // for consumers that must REACT rather than readWhat this fixes, relative to publishing a bare object on window.PluginApi:
- Load order. The ref subscribes; it does not sample. Reading
nullduringonloadmeans “not yet”, and it becomes non-nullon its own. - Version negotiation. The provider declares a contract version; the consumer declares the range it compiled against; the highest satisfying record wins. - Stale handles. The value is a revocable handle — after the provider unloads, a property read throws a PluginApiRevokedError naming the provider instead of a null deref deep in someone else’s stack. - Free-string names. Records are keyed byplugin.manifest.id, which Obsidian already keeps unique. - Legible failure. PluginApiRef.whenAvailable rejects with a PluginApiUnavailableError carrying a PluginApiUnavailabilityReason, so the five distinct causes are told apart.
Payload validation is opt-in per method through Standard Schema, so zod / valibot / arktype / a hand-written validator all plug in and none of them is a dependency here. It runs only while the obsidian-dev-utils:PluginApi debugger is enabled, so production pays nothing.
@remarks Every plugin bundles its OWN copy of obsidian-dev-utils, so a registry record is a wire format between different library versions and must stay backward-compatible forever. Nothing crossing it may be instanceof-checked — reads are structural, and only plain data objects and plain functions are stored.
Classes
Section titled “Classes”| Class | Description |
|---|---|
| PluginApiRevokedError | Thrown when a property is read from a handle whose provider has since unloaded. This is the point of handing out a revocable handle rather than the raw object: a consumer that cached the API in a field and kept using it across a disable gets an error naming the provider, instead of Cannot read properties of undefined somewhere deep inside a torn-down plugin. |
| PluginApiUnavailableError | Thrown when a plugin API cannot be handed over, carrying the PluginApiUnavailabilityReason that says why. |
| PluginApiValidationError | Thrown when a published API’s payload fails the schema its contract declares. Only ever thrown while the obsidian-dev-utils:PluginApi debugger is enabled — in production the methods are not wrapped at all. |
Interfaces
Section titled “Interfaces”| Interface | Description |
|---|---|
| PluginApiMethodContract | The payload schemas for a single API method. Both halves are optional: a method may declare only its input, only its output, neither (the entry then just declares that the method must exist), or both. |
| PluginApiRef | A live reference to another plugin’s API. |
| PluginApiRefEventMap | The events a PluginApiRef fires. |
| PluginApiUnavailableErrorConstructorParams | Parameters for the PluginApiUnavailableError constructor. |
| PluginApiValidationErrorConstructorParams | Parameters for the PluginApiValidationError constructor. |
| PublishPluginApiParams | Parameters for publishPluginApi. |
| WatchPluginApiParams | Parameters for watchPluginApi. |
| WhenAvailablePluginApiOptions | Options for PluginApiRef.whenAvailable. |
Functions
Section titled “Functions”| Function | Description |
|---|---|
| publishPluginApi | Publishes a plugin’s API so other plugins can consume it, keyed by the plugin’s manifest.id and revoked automatically when the plugin unloads. Several contract versions may be published side by side, so a provider can move to 2.0.0 without breaking consumers still pinned to ^1. |
| watchPluginApi | Watches another plugin’s API and returns a live PluginApiRef whose PluginApiRef.value stays correct across the provider’s load, unload, and re-enable. This is the entire consumer surface. There is deliberately no synchronous “get it or null” probe: a probe only answers “now” and never tells you when “now” changed, so calling one during onload reads null and invites the conclusion “not installed”. A consumer that needs a synchronous answer inside a callback whose signature forbids await — checkCallback(isChecking): boolean, canExecute(), a settings-row visible predicate — holds the ref (or the value it maintains) in a field and reads that, which is both cheaper and correct across BOTH edges. |
| Type | Description |
|---|---|
| PluginApiContract | The contract a provider declares for its API: every key is a method the API is promising to expose, and its value optionally carries the payload schemas for that method. The KEYS are the part that always matters — they drive the shape check that decides whether a published record is usable at all. The schemas are optional and only ever consulted while debugging. |
| Enum | Description |
|---|---|
| PluginApiPayloadKind | Which half of a method call a validation failure came from. |
| PluginApiUnavailabilityReason | The reason a plugin API could not be handed over. The whole point of the enum is that these are told apart. @vanakat/plugin-api cannot tell any of them apart, and not uniformly: NotInstalled / NotEnabled / NotPublished all reduce to one Notice plus undefined, while VersionMismatch / ShapeMismatch produce NO signal at all — it never examines what it found, so it hands the object over and the mismatch surfaces later, somewhere else, as somebody else’s TypeError. |