Pilets

A pilet is a self-contained JavaScript module: independently developed, independently deployed, loaded at runtime by the app shell. It has one public interface — its setup function — through which it registers everything it contributes.

The setup function

import type { PiletApi } from 'my-app-shell';

export function setup(api: PiletApi) {
  // Everything the pilet contributes goes here
}

The shell calls setup once when the pilet loads. After it returns, all registrations are live.

setup can be async. The shell waits for the promise to resolve:

export async function setup(api: PiletApi) {
  const flags = await fetch('/api/flags').then(r => r.json());

  // Conditionally register different pages based on feature flags
  if (flags.newCheckoutFlow) {
    api.registerPage('/checkout', NewCheckoutPage);
  } else {
    api.registerPage('/checkout', LegacyCheckoutPage);
  }
}

The teardown function

Alongside the required setup, a pilet may export an optional teardown(api). The shell calls it when the pilet is removed at runtime — most commonly when it is hot-reloaded during development, but also when it is updated to a new version or disabled via the feed.

import type { PiletApi } from 'my-app-shell';

let stop: () => void;

export function setup(api: PiletApi) {
  const handler = () => { /* ... */ };
  window.addEventListener('resize', handler);
  stop = () => window.removeEventListener('resize', handler);
}

export function teardown(api: PiletApi) {
  stop?.();
}

You don't need teardown for anything the shell tracks itself — pages, menus, and extensions are unregistered automatically when the pilet goes away. Use it for custom cleanup the shell can't see: removing DOM/event listeners, closing sockets or timers, or otherwise releasing resources your setup acquired.

What a pilet can register

MethodDescription
api.registerPage(path, component)Mount a component at a URL route
api.registerMenu(component, opts?)Add an item to a named navigation zone
api.registerExtension(name, component)Contribute to a named extension slot
api.registerModal(name, component)Register a named modal dialog
api.registerTile(component, prefs?)Add a tile to the dashboard (piral-dashboard)

The exact available methods depend on which plugins the app shell has installed. TypeScript types reflect exactly what's available — no guessing.

Cross-pilet communication

Pilets don't share a module scope. Three mechanisms exist for interaction:

Events

// Fire an event
api.emit('cart:item-added', { productId: 'p1', qty: 2 });

// Listen for it (in any pilet)
api.on('cart:item-added', ({ productId, qty }) => {
  updateCartBadge(qty);
});

Events don't replay for late-loading pilets. Use them for real-time notifications, not for initial state.

Shared data store

// Write (first pilet to write a key owns it)
api.setData('currentUser', { id: 'u1', name: 'Alice', roles: ['admin'] });

// Read
const user = api.getData('currentUser');

// Subscribe to changes
api.on('store-data', ({ name, value }) => {
  if (name === 'currentUser') refreshUserUI(value);
});

Write to the store early in setup so pilets that load later find the value already there.

Extension slots

For sharing UI across pilet boundaries — one pilet declares a slot, another fills it. See Extension slots for the full story.

Sharing dependencies

App Shell · central importmap react · react-dom · react-router-dom — pure externals, never bundled provided to every pilet Pilet A · analytics own importmap: recharts resolves recharts first → becomes a side-bundle Pilet B · reports declares same name: recharts reuses A's instance → no second download Pilet C · account doesn't need recharts unaffected — only loads what it declares
Central sharing flows from the shell to all pilets; distributed sharing lets the first pilet to resolve a package supply it to the rest.

Shared dependencies in Piral are not limited to what the app shell declares. Any pilet can declare shared packages via the importmap field in its package.json:

{
  "name": "pilet-analytics",
  "version": "1.0.0",
  "importmap": {
    "imports": {
      "recharts": "",
      "date-fns": ""
    }
  }
}

When this pilet loads, recharts and date-fns are placed into side-bundles. If another pilet needs those packages — and they've already been resolved — it reuses the existing instances. No duplicate downloads, no central coordination required.

Central importmap (app shell's package.json): packages are pure externals, always provided by the shell, never bundled by any pilet.

Pilet importmap: creates side-bundles. First pilet to resolve a named package wins; all subsequent pilets reuse it.

Tip

Use central sharing for universal packages like React and your design system; use a pilet-level importmap for domain libraries only some teams need. For the full mechanics — externals vs side-bundles, resolution order, and where each dependency belongs — see Dependency sharing.

Pilet lifecycle

1 feed metadata 2 fetch bundle 3 evaluate 4 setup(api) 5 live
A pilet goes from feed metadata to live registrations in five steps.

Registrations persist for the lifetime of the page session. If the pilet is removed at runtime — hot-reloaded, updated, or disabled via the feed — the shell unregisters everything it tracked and calls the pilet's optional teardown for any custom cleanup. A full page reload clears everything and restarts from step 1.

Building and publishing

npx pilet build      # build the bundle
npx pilet validate   # check format and metadata

# Publish to a feed service (interactive browser login — no secret to handle)
npx pilet publish --url https://feed.example.com/api/v1/pilets --interactive

Publishing POSTs the packed .tgz to the feed's publish endpoint. The feed stores the bundle on your CDN and records the new version. The next feed response includes it.

In automation (CI/CD) there's no browser, so authenticate with an API key from a secret instead: npx pilet publish --url … --api-key $FEED_API_KEY. See Deploying to production.

Non-React pilets

Official converters exist for Angular, Vue, Svelte, Solid, Preact, Blazor, and more. Set the converter up inside the pilet — import its standalone /convert submodule and wrap your component. No app-shell changes are required:

import { fromVue3 } from 'piral-vue-3/convert';
import ProductPage from './ProductPage.vue';

export function setup(api: PiletApi) {
  api.registerPage('/products', fromVue3(ProductPage));
}

See Multi-framework pilets for the full setup (and the rare case for registering a converter centrally in the shell).