Skip to content

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-lib:validate-plugin-api-payloads debug switch is on (off by default, see getValidatePluginApiPayloadsDebugSwitch), so production pays nothing. The obsidian-dev-utils:PluginApi debugger controls logging only: turning it on changes no behavior.

The contract itself — its types and the schema builders — is not Obsidian-specific and lives in @mnaoumov/typescript-lib/api-contract (ApiContract, createArgumentsSchema, createTypeGuardSchema, defineApiContract). This module owns what is: the registry keyed by plugin id, the revocable handles, the debug switch and the validation errors. A contract’s KEYS drive the shape check that decides whether a published record is usable at all; its schemas are only ever consulted while the switch is on.

Why the shape check is a typeof rather than a schema over the whole API object. In zod 4 z.function() returns a function FACTORY, not a schema, so it cannot be a z.object() member directly. There is a known workaround (zod#4143) — z.custom((fn) => functionSchema.implement(fn)) — and it does buy correct z.infer types for a function member. It does not buy runtime validation here, though: z.custom is a boolean predicate that returns its input unchanged, so the validating wrapper implement builds is discarded and the check collapses back to “is it callable”. Keeping that wrapper would need a further .transform(), and would then move validation onto every call instead of behind the debug switch, and bind the contract to zod specifically — which is exactly what reaching through Standard Schema avoids. So the declared method names are checked with typeof, and the payloads are validated per method instead.

@remarks Every plugin bundles its OWN copy of this library (or of the older 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.

That wire format is also PUBLISHED, as of the Plugin API protocol guide: a plugin that does not use this library reaches an API by reading globalThis.__obsidianDevUtils.pluginApiRegistry.value.records[pluginId] and applying the selection rule selectRecord implements. So PublishedPluginApiRecord’s field names, the state key, and “highest live record satisfying the range wins” are a documented contract with an audience outside this repository, and the guide has to be updated in the same change as any of them. Only pluginApiRegistry is public within the shared-state bag; the rest of it stays internal.

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-lib:validate-plugin-api-payloads debug switch is on (see getValidatePluginApiPayloadsDebugSwitch) — otherwise no payload is validated at all.
Interface Description
FindPluginApiParams Parameters for findPluginApi.
PluginApiDeclaration One API a plugin exposes: the object, the contract version it satisfies, and optionally the contract itself. This is PublishPluginApiParams without the publishing plugin, so a plugin can DECLARE what it exposes without also stating who is publishing it — which is what lets PluginBase.getPluginApis collect the declarations and do the publishing itself.
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.
Function Description
findPluginApi Looks another plugin’s API up ONCE, and answers for this moment only. It applies the same selection rule as watchPluginApi and hands out the same revocable handle, but it subscribes to nothing, so it never learns that the answer changed. That makes it the right shape for a free function that has no component to own a watch and runs on a user-driven path, such as a backlink lookup made while a rename is handled. It is the wrong shape for onload: plugins load in an unspecified order, so a null there means “not yet” as often as “not installed”. Code that has a component and reads the API more than once should hold a watchPluginApi ref instead.
getValidatePluginApiPayloadsDebugSwitch Gets the debug switch that decides whether a plugin-API call’s payloads are validated against the contract. Its id is obsidian-lib:validate-plugin-api-payloads and it is off by default. While it is on, every call through a handle from watchPluginApi or findPluginApi checks its arguments and its return value against the method’s declared schemas and throws a PluginApiValidationError on a mismatch. The obsidian-dev-utils:PluginApi debugger does not affect it: that only logs. The switch is registered on every read, which is idempotent, and whenever an API is published, watched or looked up, so a debug-settings UI lists it as soon as any plugin API is in play.
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 consumer surface for anything that has a component. Its one-shot sibling findPluginApi only answers “now” and never tells you when “now” changed, so calling it 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.
PluginApiMethodContract The payload schemas for a single API method.
PluginApiMethodName The names of the function members of an API.
PluginApiMethods The function members of an API.
TypedPluginApiContract The contract of a named API, with both schemas required on every entry.
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.