Skip to content

use from a plugin

github-actions[bot] edited this page Aug 25, 2026 · 1 revision

Using openstation from your own plugin

This doc explains how a sibling WordPress plugin can use openstation's TypeScript types and component classes (OsLog, OsCode, OsTabs, …) without publishing openstation to npm and without reaching into its src/ tree via relative paths.

Shipping a plugin as a zip? Start with wp.os.loadComponents(). The file: dependency below is for a plugin developed beside this repo in one checkout, and it resolves at install time — a contributor cloning your plugin on its own has no ../openstation to point at. If what you want is a working <os-switch> on a site that has OpenStation installed somewhere, one await gets you the whole kit at runtime with no build-time relationship at all:

await wp.os.loadComponents( [ 'os-switch', 'os-number-field' ] );

The rest of this doc is about the build-time route: types, class imports, and the smaller bundles you get from importing only what you render.

The short version

openstation's package.json is "private": true (so it can never be accidentally npm published) but exposes its public API via the exports map. Any sibling plugin can install it as a local file dependency:

// my-plugin/package.json
{
    "dependencies": {
        "openstation": "file:../openstation"
    }
}
cd my-plugin
npm install

That's it. After install, the import resolves through openstation's exports:

import { OsLog, type OsLogRowRenderer, HOOKS } from 'openstation';

No relative paths, no monorepo refactor, no npm registry.

What you get

Everything re-exported from src/public-api.ts:

  • TypeScript typesWindowConfig, WallpaperDef, WidgetDef, DragManagerApi, DragBridgePayload, WindowConnection, …
  • Component classesOsLog, OsCode, OsTabs, OsAvatar, OsBadge, …
  • Hook constantsHOOKS.WINDOW_OPENED, HOOKS.CONNECTION_OPENED, HOOKS.WINDOW_FOCUSED, …
  • Public surface helpersDRAG_EVENTS, DRAG_BRIDGE_EVENTS, etc.

Both runtime values AND types — the same file backs both conditions in the exports map.

Runtime use of components — the import is what registers the tags

os-* custom elements are side-effect registered at import time, per bundle — they are not all registered globally by desktop.min.js. The shell bundle registers only a core subset and pre-loads shell-overlays.min.js (the toast / confirm-dialog / context-menu / menu / select / window-chrome kit) right after first paint; every other tag upgrades only once a loaded bundle has imported its module. Emitting a tag that no loaded bundle has imported renders inert HTML, and the missing-component warner logs a console.error with the exact import line to add.

For plugin bundles the fix is built in: any import from 'openstation' registers every tag as a side effect (the package entry re-exports the component barrel). Once your bundle imports it, templates can just emit the markup:

<os-log id="agent-trace" max-rows="500"></os-log>
const log = document.getElementById( 'agent-trace' );
log.push( { level: 'info', message: 'Agent started' } );

See components-reference.md for the full tag → class → source mapping. Beyond registration, the named class import (import { OsLog } from 'openstation') is useful for:

  1. TypeScript type-checking of the element handle (document.getElementById('agent-trace') as OsLog).
  2. Subclassing a component to override behavior.
  3. Programmatic instantiation (new OsLog() then document.body.appendChild(el)) — rare; the HTML route is preferred.

Avoiding duplicate bundling

If your plugin bundles its own JS (Vite, esbuild, webpack, …) and imports OsLog, the bundler will include the component's source in your bundle by default. For a single-component import this is ~3 KB gzip; for the full kit it's much more.

The runtime route avoids this entirely. await wp.os.loadComponents( [ … ] ) registers the tags from the shell's own copy — nothing about the components enters your bundle, and you need no file: dependency to reach them. The trade is that the kit bundle is all-or-nothing: 309 KB raw / 77 KB gzip, fetched once, cached, and only by plugins that ask. Two or three components? Import them. Want the kit, or want to ship a zip with no build-time link to this repo? Load it. See examples/load-components.md.

If instead you want to externalize — keep the import syntax but resolve it to a runtime global rather than your own bundle — your bundler config needs:

// vite.config.js
export default {
    build: {
        rollupOptions: {
            external: [ 'openstation' ],
        },
    },
};

Combined with a small browser shim that resolves the import to a runtime global. Note that no such global exists today — the shell publishes window.wp.os (an API surface: methods, stores, registries), not a module namespace of component classes, so an external: [ 'openstation' ] build has nothing to resolve against at runtime. If you were reaching for this to avoid duplicate bytes, wp.os.loadComponents() above is the supported answer; if you need the classes (subclassing, instanceof, programmatic construction), the file: dependency is still the route, and opening an issue about a class-namespace global is worthwhile.

Why not just publish to npm?

We could. We deliberately don't, because:

  • Bundle distribution within WordPress.org plugin reviews is already covered by the build step in this repo (assets/js/*.min.js). Publishing a parallel npm artifact would be a second source of truth that drifts.
  • The TypeScript surface is the contract; type-only consumers don't need an npm fetch — they need a path.
  • file: dependencies are stable, version-locked at install time, and don't require a registry.

If you genuinely need a registry-based install (e.g. a CI runner that can't see this repo's filesystem), open an issue and we'll re-evaluate.

Iframe bridge gotcha — whenWindowId() never rejects

If your plugin code runs inside a chromeless wp-admin iframe and calls wp.os.iframe.whenWindowId(), be aware that the returned Promise never rejects. When the page is not running inside OpenStation (a cross-origin parent, a direct admin URL visit, a unit-test harness) the Promise simply hangs forever — any await after it will never resume.

Always guard with isParentReachable() first:

if ( ! wp.os.iframe.isParentReachable() ) {
    return; // Not inside OpenStation — skip iframe-bridge code.
}
const windowId = await wp.os.iframe.whenWindowId();

The same caveat applies to Window.whenContentReady() on the shell side — it never rejects if the content iframe never signals readiness.

Troubleshooting

  • Cannot find module 'openstation' — confirm the file: path is correct relative to your plugin's package.json location, then re-run npm install.
  • Types resolve but runtime imports fail — your bundler is configured to externalize without a runtime shim. Either remove the externalization or wire a global resolver.
  • Defining os-* elements twice — the elements register themselves on import through a guarded defineComponent() that silently skips already-defined tags, so loading both your bundle and desktop.min.js is a no-op (the first-loaded class wins for each tag); no browser warning is logged.
  • Editing desktop-mode/src/* doesn't reflect in your pluginfile: dependencies on some npm versions copy at install time. Run npm install again, or switch to npm link for an active symlink while developing both packages in parallel.

Home

Guides

Migration notes

Examples

All examples

Clone this wiki locally