Skip to content

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 correct
await ref.whenAvailable(); // one-shot flows; rejects with a typed, classified error
ref.on('change', () => { }); // for consumers that must REACT rather than read

What this fixes, relative to publishing a bare object on window.PluginApi:

  • Load order. The ref subscribes; it does not sample. Reading null during onload means “not yet”, and it becomes non-null on 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 by plugin.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.

ClassDescription
PluginApiRevokedErrorThrown 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.
PluginApiUnavailableErrorThrown when a plugin API cannot be handed over, carrying the PluginApiUnavailabilityReason that says why.
PluginApiValidationErrorThrown 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.
InterfaceDescription
PluginApiMethodContractThe 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.
PluginApiRefA live reference to another plugin’s API.
PluginApiRefEventMapThe events a PluginApiRef fires.
PluginApiUnavailableErrorConstructorParamsParameters for the PluginApiUnavailableError constructor.
PluginApiValidationErrorConstructorParamsParameters for the PluginApiValidationError constructor.
PublishPluginApiParamsParameters for publishPluginApi.
WatchPluginApiParamsParameters for watchPluginApi.
WhenAvailablePluginApiOptionsOptions for PluginApiRef.whenAvailable.
FunctionDescription
publishPluginApiPublishes 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.
watchPluginApiWatches 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 awaitcheckCallback(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.
TypeDescription
PluginApiContractThe 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.
EnumDescription
PluginApiPayloadKindWhich half of a method call a validation failure came from.
PluginApiUnavailabilityReasonThe 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.