Piral Plugins

A PiralPlugin is a factory function that adds methods to the Pilet API for all pilets. It is the primary extension mechanism for app shell authors.

App Shell createInstance({ plugins }) core API + plugins builds & extends the API constructs Pilet API the glue registerPage showNotification emit · on setData · … typed contract Pilet A setup(api) Pilet B setup(api) uses uses
The app shell builds the Pilet API (core methods plus plugins). Every pilet receives that same API in setup and uses it as the glue — all interaction with the shell and the wider application flows through it.

What plugins are for

Plugins live only in the app shell — a pilet never installs or references one. A plugin has exactly one job: to extend the Pilet API. That is the entire reason any plugin exists. When the app shell registers a plugin, the methods it returns are merged into the api object every pilet receives in setup.

This is worth stressing, because it is a common misconception: a plugin is not what makes a feature work. The underlying capability — fetching data, authenticating, talking to a backend — works with or without a plugin. The plugin merely exposes a convenient, typed entry point for that capability on the Pilet API.

piral-oidc is a good example. It exists to surface OIDC concerns — access tokens, the current user, sign-in and sign-out — to pilets through the API (for instance api.getAccessToken()). You do not need it to use OIDC, or to have authentication at all: the app shell can authenticate however it likes and never install the plugin. You add piral-oidc only when you want pilets to consume those tokens through the Pilet API. The same is true of every official plugin — each one is purely a typed extension of the API surface.

Type signature

type PiralPlugin<TApi = {}> =
  (context: GlobalStateContext) =>
    (api: PiletApi, meta: PiletMetadata) => TApi;

Three layers:

  1. Outer function — receives the global state context (for reading/writing app state)
  2. Inner function — called once per pilet at load time, receives the base API and pilet metadata
  3. Return value — the methods merged into the pilet's api object

Typed plugin example

import { PiralPlugin } from 'piral-core';

// 1. Define the API extension interface
export interface AnalyticsApi {
  trackEvent(name: string, props?: Record<string, unknown>): void;
  trackPageView(path: string): void;
}

// 2. Augment the global PiletApi type
declare module 'piral-core/lib/types/custom' {
  interface PiletCustomApi extends AnalyticsApi {}
}

// 3. Implement
export function createAnalyticsApi(): PiralPlugin<AnalyticsApi> {
  return (_context) => (_api, meta) => ({
    trackEvent(name, props) {
      analytics.track(name, { ...props, pilet: meta.name, version: meta.version });
    },
    trackPageView(path) {
      analytics.page(path, { pilet: meta.name });
    },
  });
}

Register in the shell:

const instance = createInstance({
  plugins: [createAnalyticsApi()],
});

All pilets now have api.trackEvent('button-click') — typed.

Accessing global state

export function createThemeApi(): PiralPlugin {
  return (context) => () => ({
    setTheme(theme: 'light' | 'dark') {
      context.dispatch(state => ({ ...state, theme }));
    },
    getTheme() {
      return context.readState(s => (s as any).theme ?? 'light');
    },
  });
}

Async plugin initialisation

export function createRemoteConfigApi(configUrl: string): PiralPlugin {
  let config: Record<string, string> = {};
  const ready = fetch(configUrl).then(r => r.json()).then(c => { config = c; });

  return () => () => ({
    async getConfig(key: string) {
      await ready;
      return config[key];
    },
  });
}