npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@drift-beacon/plugin

v0.2.7

Published

The Drift Beacon plugin SDK: APIs for plugin main code and UIs, the driftBeacon() Vite plugin and the dbplugin CLI

Readme

@drift-beacon/plugin

The plugin SDK for Drift Beacon. A plugin has main code, which runs on your Drift Beacon server, and a UI, which opens in the web app. This package gives you their APIs, a Vite plugin that builds both, and the dbplugin command that packages a release.

Plugin API version: 0.2. SDK 0.2.x builds plugins for Drift Beacon servers that run API 0.2.

Contents

Quick start

mkdir my-plugin && cd my-plugin
npm init -y
npm pkg set type=module
npm install --save-dev @drift-beacon/[email protected] vite typescript @types/node

package.json scripts:

{
  "scripts": {
    "dev": "vite",
    "build": "vite build",
    "release": "dbplugin pack",
    "postinstall": "dbplugin prepare"
  }
}

vite.config.ts:

import { driftBeacon } from "@drift-beacon/plugin/vite";
import { defineConfig } from "vite";

export default defineConfig({ plugins: [driftBeacon()] }); // with React: [react(), driftBeacon()]

tsconfig.json:

{ "files": [], "references": [{ "path": "./.drift-beacon/tsconfig.main.json" }, { "path": "./.drift-beacon/tsconfig.ui.json" }] }

manifest.json:

{
  "id": "hello",
  "name": "Hello",
  "version": "1.0.0",
  "apiVersion": "0.2",
  "description": "Says hello when a session starts",
  "author": { "name": "You" },
  "category": "plugin",
  "icon": "",
  "configuration": []
}

main/src/index.ts:

import { definePlugin } from "@drift-beacon/plugin";

export default definePlugin({
  onStart(ctx) {
    ctx.sessions.onStarted((session) => {
      if (session.isMine) ctx.log.info(`Started ${session.activity?.name}`);
    });
  },
});

ui/index.html and ui/src/main.ts:

<!doctype html>
<html>
  <body>
    <ul id="activities"></ul>
    <script type="module" src="/src/main.ts"></script>
  </body>
</html>
import { connect } from "@drift-beacon/plugin/ui";

const ctx = await connect();
const list = document.getElementById("activities")!;
const render = () => {
  list.replaceChildren(
    ...ctx.activities.list().map((activity) => {
      const item = document.createElement("li");
      item.textContent = `${activity.name}${activity.isLive ? " (live)" : ""}`;
      item.onclick = () => void activity.track();
      return item;
    }),
  );
};
ctx.onDataChange(render);
render();

Then npm run build writes dist/, and npm run release writes releases/hello.zip.

Project layout and commands

my-plugin/
├── package.json        # "type": "module"
├── vite.config.ts      # plugins: [driftBeacon()]
├── tsconfig.json       # references the main and UI tsconfigs
├── manifest.json       # identity, API version, icon, settings
├── main/src/index.ts   # main code: runs on the server
├── ui/                 # the UI: an ordinary Vite app, shown in an iframe in the web app
│   ├── index.html
│   ├── src/…
│   └── public/         # the manifest icon, plus any static files
├── .drift-beacon/      # generated by dbplugin prepare; ignores itself
└── dist/               # the build: exactly the release package

| Command | What it does | |---|---| | vite (npm run dev) | Serves the UI with hot reload and rebuilds main on every save. A Drift Beacon server in development mode applies each build and prints the plugin's status and logs in the same terminal. | | vite build | Production build into dist/: manifest.json, package.json, main/index.js and ui/. | | dbplugin pack | Typechecks main and the UI (see Compiler options), builds fresh into a temporary folder and writes releases/<id>.zip. Prints { id, version, tag, asset, archivePath } as JSON on stdout. dist/ is untouched. --out <dir> picks another folder. | | dbplugin prepare | Writes .drift-beacon/: typed settings, the types of what you provide and of the plugins you use, and the two tsconfigs. Runs on install and on every build. | | dbplugin peers add <path or URL> | Copies the manifest of a plugin you use into peers/<id>.json, so its commands, state and events are typed (0.2.7; see Commands between plugins). |

Run dbplugin through your package scripts or npx dbplugin … inside the plugin folder: it's the copy from your installed SDK.

  • Main is bundled into one ESM file with every dependency except @drift-beacon/plugin, which the server supplies at run time. Native modules aren't supported.
  • The UI bundles its own dependencies, including @drift-beacon/plugin/ui. Use any framework and library versions you like.
  • driftBeacon() owns Vite's root (ui/), base and build.outDir: don't set them. It works with Vite 7 and 8. Under Vitest it only adds the build, so your tests can share vite.config.ts while npm run dev runs.

Compiler options

dbplugin prepare generates .drift-beacon/tsconfig.main.json and .drift-beacon/tsconfig.ui.json (React JSX in the UI). To change a compiler option, add main/tsconfig.json or ui/tsconfig.json that extends the generated file, and reference it from tsconfig.json instead. dbplugin pack typechecks it in place of the generated one. For a Preact UI:

{ "extends": "../.drift-beacon/tsconfig.ui.json", "compilerOptions": { "jsxImportSource": "preact" } }
{ "files": [], "references": [{ "path": "./.drift-beacon/tsconfig.main.json" }, { "path": "./ui/tsconfig.json" }] }

Keep extends: the generated file brings the settings types, the SDK's module resolution and the source folders.

| Import | Used by | Contents | |---|---|---| | @drift-beacon/plugin | Main code | definePlugin, PluginError, API_VERSION and every type | | @drift-beacon/plugin/ui | UI | connect(), PluginError, API_VERSION and the shared types | | @drift-beacon/plugin/vite | vite.config.ts | driftBeacon() |

Manifest

| Field | Rules | |---|---| | id | Lowercase letters, digits and hyphens, starting with a letter or digit, at most 64 characters | | version | Semantic version (1.2.3); build metadata is allowed, prereleases are not | | apiVersion | major.minor of the SDK you build with ("0.2") | | name, description | Required text | | author | { name, email? } | | category | plugin, utility or ui | | icon | A file name in ui/public/, or "" | | configuration | Settings (see Configuration) | | provides | Optional: commands, events and state other plugins can use, and the choices lists and controls that tell callers how to offer them (see Commands between plugins, Events, state and status and Choices and controls) | | uses | Optional: the plugins this one uses, by manifest id, with a version range ({ "magic-cube": "^1.1.0" }) |

Settings items have name (unique), title, type and optional description and required:

| type | Extra fields | Value in ctx.config | |---|---|---| | string | default?, placeholder? | string | | number | default?, minimum?, maximum? | number | | boolean | default? | boolean | | dropdown | default?, data: [{ title, value }] | the selected value |

Main code

main/src/index.ts default-exports definePlugin({ onStart(ctx) { … } }). The server runs one instance for each user and workspace that enabled the plugin; each gets its own ctx.

  • Start. onStart(ctx) may be async and must finish within 15 seconds. Callbacks and routes work as soon as they are registered. If it throws, the instance doesn't start.
  • Register through ctx. Callbacks, routes and MQTT subscriptions made through ctx are released when the instance stops. Keep per-instance state inside onStart, never at module scope: the module is shared by every instance.
  • Stop. ctx.onStop(fn) callbacks run newest first, 5 seconds in total, whatever stops the instance (a fault or server shutdown too, even when something else stops it meanwhile). Afterwards every ctx call throws PluginError with code stopped. A server that is killed rather than shut down cuts them short.
  • Faults. An uncaught error or unhandled rejection stops only that instance, which is retried after 5, 30 and 120 seconds.

| ctx member | Main | UI | | |---|:-:|:-:|---| | plugin, user, workspace | ✓ | ✓ | Installed identity (id, version, apiPath), the user and the workspace | | config | ✓ | ✓ | Settings, validated, defaults applied | | activities, categories, sessions | ✓ | ✓ | Workspace data and actions | | onDataChange | ✓ | ✓ | The data changed | | sessions.onStarted, onEnded, onMarked | ✓ | | Live session events | | schedules.onTriggered | ✓ | | One of the user's schedules just fired (0.2.5) | | storage | ✓ | ✓ | Key-value storage | | plugins | ✓ | ✓ | The plugins it uses and itself: status, state, events and commands | | commands, events, state | ✓ | | What it provides to other plugins | | ui | ✓ | | The private channel to its own UI (0.2.4) | | main | | ✓ | The private channel to its own main code (0.2.4) | | mqtt, routes, log, onStop | ✓ | | Main code only |

UI

connect() performs a handshake with the web app and resolves to the UI ctx. It rejects with unsupported when the app doesn't run this UI's API version, and with unavailable outside Drift Beacon or after 10 seconds without an answer. ctx.onDataChange fires after every update (data, settings, storage, or other plugins' status and state; not their events).

The app runs a built UI in a sandboxed frame (allow-scripts only): it has no storage, cookies, popups or modal dialogs, and a <form> never submits. Handle Enter and your button's press yourself instead of relying on a form's submit.

With React, re-render on every update:

import { connect, type UiContext } from "@drift-beacon/plugin/ui";
import { useSyncExternalStore } from "react";

let context: UiContext | null = null;
let revision = 0;
const subscribe = (listener: () => void) => (context ? context.onDataChange(listener) : () => {});

export function useDriftBeacon(): UiContext {
  useSyncExternalStore(subscribe, () => revision);
  if (!context) throw new Error("Not connected");
  return context;
}

export async function start() {
  context = await connect();
  context.onDataChange(() => {
    revision += 1;
  });
}

Models keep their identity when their data changes: key React.memo and useMemo on model.data.

Workspace data

ctx.activities, ctx.categories and ctx.sessions return models: the row's fields plus links, derived values and actions.

const activity = ctx.activities.get(id);
activity?.category?.name;   // links
activity?.isLive;           // the current user has a live session of it
activity?.isPinned;         // the current user has it pinned
activity?.goal;             // { type: "duration", seconds } or { type: "count", count }, or null
await activity?.track();    // start a span / mark a point
activity?.data;             // the plain row, a new frozen object whenever it changes

ctx.activities.list();                                  // sorted like the app, archived hidden
ctx.activities.list({ includeArchived: true });
ctx.activities.list({ categoryId: null });              // uncategorized
ctx.categories.list().map((category) => category.activities());
ctx.sessions.list({ activityId, status: "completed" }); // newest first
ctx.sessions.live({ mine: true });
  • Models read the latest snapshot synchronously; nothing fetches. One model per row: identity stays, model.data changes.
  • Lists return the same frozen array until the data changes.
  • A removed row's model keeps its last values with exists: false, and its actions reject with not-found.
  • startedAt and endedAt are Dates: copy one before changing it.
  • iconPath on activities and categories is SVG path data (viewBox 0 0 24 24), or null:
{activity.iconPath && <svg viewBox="0 0 24 24" fill="currentColor"><path d={activity.iconPath} /></svg>}
  • From 0.2.2, activities carry their goal, its period and their pins:
    • goal is { type: "duration", seconds } or { type: "count", count }, or null when the activity has none. A point activity's goal always counts marks; a span activity's counts sessions or totals their duration, as the user chose.
    • period is "day", "week", "month" or "year": calendar periods from local midnight, weeks starting on Sunday. null means progress covers all history. Local means the browser's time zone in a UI, like the app's own progress, but the server's (its TZ environment variable) in main code: if the server runs in UTC and the user doesn't, main code's periods are off by the whole difference, all period long. Set TZ on the server to the household's zone.
    • pinnedBy lists the users who have the activity pinned (each user pins at most one). Main code sees every user's pins, a UI only its own user's. isPinned is the current user's pin in both.
    • Progress isn't included: add up sessions() in the current period. Drift Beacon counts every member's completed sessions from the period's start (a span counts on the day it started), plus the time so far of the current user's live session.
    • A Drift Beacon older than 0.2.2 provides none of these, to main code as well as to UIs (main code uses the models of the Drift Beacon that runs it): goal, period and pinnedBy are undefined, which is why they are optional and how to tell (from 0.2.2, pinnedBy is always an array), and isPinned is false in a UI but undefined in main code. Test isPinned for truthiness.
// The activity to show: the user's live one, else the one they pinned.
const shown = ctx.sessions.live({ mine: true })[0]?.activity ?? ctx.activities.list().find((a) => a.isPinned);

| Activity | | |---|---| | id, name, description, trackingType, icon, iconPath, color, categoryId, archived, sortOrder, unit, goal, period, pinnedBy | Fields (color is resolved from the category when needed) | | isSpan, isPoint, isLive, isPinned, category | Derived values and links | | sessions(filter?), live({ mine? }) | Its sessions | | start(), mark(), track(), end() | Actions (end() ends the current user's live session of it) |

| Session | | |---|---| | id, activityId, type, status, memberIds, startedAt, endedAt | Fields | | isSpan, isPoint, isLive, isMine, activity, duration(at?) | Derived values and links | | end(), discard() | Actions on a live span |

Categories have id, name, description, color, icon, iconPath, sortOrder and activities(filter?).

Actions and events

await ctx.sessions.start(activityId);   // or activity.start()
await ctx.sessions.mark(activityId);    // or activity.mark()
await ctx.sessions.end(sessionId);      // or session.end(), activity.end()
await ctx.sessions.discard(sessionId);  // or session.discard()

Actions run as the current user and resolve once ctx shows their result. Starting a session ends the user's other live session, as in the app. An id that isn't a non-empty string rejects with invalid.

| | Live session events | Data change events | |---|---|---| | API | ctx.sessions.onStarted, onEnded(session, reason), onMarked | activities/categories/sessions.onChange, ctx.onDataChange | | Means | A user just did something | The data is different now, possibly from a sync burst | | Use for | Reacting: start, end, publish, notify | Refreshing what you show | | Where | Main code | Main code and UI |

Never trigger actions from data changes. Live events fire for everyone's sessions in the workspace: check session.isMine. They fire when the action happens or not at all, so onEnded can come without onStarted (a session started and ended at once).

From 0.2.5, main code also hears the current user's schedules as they fire. A schedule pins an activity, or adds it to Next (the user's queue of what to do after the pinned activity), at a time the user chose:

ctx.schedules?.onTriggered((trigger) => {
  ctx.log.info(`Time for ${trigger.activity?.name}`, trigger.behavior);
});

| trigger | | |---|---| | id | This occurrence. Everything told of it gets the same id (every plugin, Home Assistant, the phone's reminder): use it to ignore a duplicate | | scheduleId, kind | The schedule, and how it is timed: once (at one instant), recurring (chosen weekdays at a time) or sinceLast (a while after the activity last happened) | | behavior | What it just did with the activity: pin, or queue / queueBack (the front or the back of Next) | | activityId, activity | The activity, as it is now. ctx is at least as recent as the firing, but something later (another schedule firing at the same time, the user) may have changed it since: after a pin, trigger.activity.isPinned is usually true, not always. activity is undefined only if it was deleted meanwhile | | triggeredAt | A Date: copy it before changing it |

  • It fires once, when a schedule acts, and is never replayed: not for an occurrence the schedule skipped (the activity's target was met, or it is archived), and not again after a restart or to an instance that wasn't running. A schedule can act late, for example a one-time schedule due while the server was stopped for less than an hour; it is reported when it acts.
  • Only the current user's schedules, and whether or not the schedule also sends a reminder to their phone.
  • ctx.schedules is undefined on a Drift Beacon older than 0.2.5 (main code uses the SDK of the Drift Beacon that runs it), hence ?..
  • More values of kind and behavior can appear: ignore the ones you don't know.

Storage

Per plugin, user and workspace, shared by main code and the UI. Keys are non-empty strings and values must be JSON: anything else rejects with invalid, and setting undefined removes the key. set and remove apply at once and resolve when Drift Beacon has accepted the write.

const faces = ctx.storage.get<Record<string, string>>("faces") ?? {};
await ctx.storage.set("faces", { ...faces, [side]: activityId });
await ctx.storage.remove("draft");
ctx.storage.onChange((key, value) => { /* changed by main code, a UI or another device */ });

Configuration

ctx.config holds the settings from the manifest's configuration. dbplugin prepare turns them into types (.drift-beacon/config.d.ts), so ctx.config.mqttTopic is typed in main code and the UI. A setting is optional unless it is required or has a default. An instance only runs with valid settings; changing them restarts it.

The app blocks opening a UI when required settings are missing or blank. This presence check does not validate every setting: if saved values fail schema validation, the UI receives defaults under the saved values while main does not run. Check values before relying on them.

MQTT and HTTP routes

Main code only.

ctx.mqtt.subscribe("zigbee2mqtt/cube/#", ({ topic, payload }) => { /* payload is a string */ });
await ctx.mqtt.publish("zigbee2mqtt/lamp/set", JSON.stringify({ state: "ON" }));

ctx.routes.post("webhook", async ({ body }) => ({ status: 202, body: { ok: true } }));
ctx.routes.get("status", () => ({ body: { live: ctx.sessions.live({ mine: true }).length } }));
  • MQTT uses the workspace's broker; publish rejects with unavailable without one or while it isn't connected, and with invalid for an empty topic or a payload that isn't a string (nothing is sent).
  • Routes are served at <server>/api/plugins/<plugin id>/api/<name> (ctx.plugin.apiPath is the path up to /api). Callers send Authorization: Bearer <workspace API key>, which picks the user and workspace. Handlers return { status?, body? }; body is sent as JSON. While onStart runs, a route not registered yet answers 503 (retry after 5 s).

Your UI and main code (0.2.4)

ctx.ui in main code and ctx.main in the UI are a private channel between your main code and your own UI: nothing goes in manifest.json, and other plugins, Home Assistant, routes and API keys can neither reach nor list it. A declared command is your public API; the channel is private plumbing. Declare a command only when something other than your own UI should run it.

| Need | Use | |---|---| | Settings the user edits, kept and synced | Storage | | Ask main code to do something and get an answer, from your own UI | ctx.main.request → ctx.ui.handle | | Tell your open UIs something changed now | ctx.ui.post → ctx.main.onMessage (use storage too if the value must last) | | Something other plugins or Home Assistant should run | A declared command | | Values other plugins or Home Assistant read or react to | Declared state or events | | A value with a setter that a Stream Deck key shows and changes | A declared control over state you publish | | Devices on the network calling in | Routes or MQTT |

// shared/ui-channel.ts, imported (type-only) by main and UI: nothing is generated. Importing from
// "@drift-beacon/plugin" (not /ui) is what makes the augmentation apply in the UI too.
import type { ValueSchema } from "@drift-beacon/plugin";
declare module "@drift-beacon/plugin" {
  interface PluginUiRequests {
    pair: { input: { host: string; port?: number }; output: { paired: boolean } };
  }
  interface PluginUiMessages {
    pairing: { step: "waiting" | "asking" };
  }
}
export const PAIR_INPUT: ValueSchema = {
  type: "object",
  properties: { host: { type: "string" }, port: { type: "integer", minimum: 1, maximum: 65535 } },
  required: ["host"],
};

// main
ctx.ui.handle("pair", async ({ host, port }, meta) => {
  if (!isLanAddress(host)) throw new PluginError("invalid", "Enter the controller's address on your network");
  ctx.ui.post("pairing", { step: "waiting" }, { to: meta.client.id });
  return { paired: await controller.pair(host, port ?? 16021, meta.signal) };
}, { input: PAIR_INPUT });

// UI
const pairing = new AbortController(); // the Cancel button calls pairing.abort()
const stop = ctx.main.onMessage("pairing", ({ step }) => setStep(step));
try {
  setResult(await ctx.main.request("pair", { host }, { timeoutMs: 30_000, signal: pairing.signal }));
} catch (error) {
  if ((error as PluginError).code === "timeout") recheckConnection(); // it may have paired
  else showError(error);
} finally {
  stop();
}
ctx.main.onResync(() => void refetch()); // posts may have been missed

In main code:

  • handle(name, handler, { input? }): one handler per camelCase name, apart from ctx.commands (a name can be in both). input is the manifest's schema subset or any Standard Schema (Zod, Valibot, ArkType, Effect Schema), checked before the handler runs (invalid); a Standard Schema's output is what the handler gets, and a request cancelled or timed out while an async schema checks it never reaches the handler. Types aren't checks: without a schema, validate in the handler. A request declared with output: undefined, or none, can return nothing: ctx.ui.handle("forget", async () => { … }).
  • meta is a command's (caller is { kind: "ui", plugin }, a new chain at depth 1), plus client (the copy that asked) and requestId. meta.signal aborts at the deadline, when the UI gives up and when the instance starts stopping; not when the copy goes away (watch onClientsChange), and never once the handler has answered.
  • Requests reach handlers once onStart has returned; after that a name without a handler answers not-found. A thrown PluginError answers with its code and message (it is recognised by name and code: another library's error with a code is anything else); anything else answers failed with a generic message and a reference, and your console gets the message and stack (a development copy's UI gets the message too). The output must be JSON of at most 64 KiB, else failed.
  • post(name, payload?, { to? }) sends to every open copy of your UI for this user in this workspace, on every device, or only to to (at most 16 ids). At most once, never replayed; a post made before a handler returns reaches that copy before the answer, and one made after it answered arrives after the answer. Past 50 a second (bursts of 100) posts are dropped with a warning and the copies are told to fetch again. It returns nothing: a post is one-way, and clients says which copies are open.
  • clients lists the open copies, oldest first (id, platform, version, since), and onClientsChange reports each change. They're advisory: keep time limits on anything a copy holds.
  • Values are JSON of at most 64 KiB (UTF-8), nested at most 32 levels, with no __proto__ keys.
  • Private means not declared, not listed and not routed from anywhere but your own UI, for that user in that workspace. It isn't a guard against the user's own signed-in clients: validate input, and keep each handler a narrow verb, never "fetch this URL" or "run this". Strings main code posts or returns that come from outside (device names, mDNS or SSDP names, a device's error text) are untrusted: render them as text, never as HTML, because script that runs in your UI can call every ctx.ui handler. Nor does it hide what you store: storage is synced whole to every open copy of your UI.

In the UI:

  • ctx.main.request(name, input?, { timeoutMs?, signal? }) resolves with the handler's output. The input can be any JSON value; nothing is sent when a check fails (invalid). It waits 10 s by default (100 ms to 30 s) plus 2 s for the answer; while main code starts, it waits within that time. At most 16 are in flight (unavailable); one frees its place as it settles or is aborted. Messages main posted to this copy before answering arrive first.
  • Aborting signal rejects at once with signal.reason and aborts the handler's meta.signal; it may have run already.
  • It didn't run: invalid, unsupported, unavailable, not-installed, disabled, incompatible, not-found. It may have: timeout, stopped, failed, or a code the handler threw.
  • ctx.main.onMessage(name, callback) gets the posts with that name, frozen, in order; ctx.main.onResync(callback) is called with "reconnected", "missed" or "main-restarted" when posts may have been missed (never for the first connection): fetch what you render again. Listener errors are logged.
  • Your UI shows up in ctx.ui.clients once it has used the channel: a request, or its first listener.
  • React to posts by rendering. A post that makes the UI ask main code starts a new chain each time, so loop doesn't stop a cycle through your UIs; put such reactions in main code.

Commands between plugins

Main code provides commands; main code, UIs and Home Assistant run them. They are your plugin's public API: to reach your own main code from your own UI, use ctx.ui instead. A plugin declares the commands it provides in manifest.json and handles them; another plugin lists it in uses and runs them, as the same user in the same workspace.

// magic-cube/manifest.json
"provides": { "commands": { "selectPreset": {
  "title": "Select preset",
  "input": { "type": "object", "properties": { "preset": { "type": ["string", "null"] } }, "required": ["preset"] },
  "output": { "type": "object", "properties": { "activePresetId": { "type": ["string", "null"] } } }
} } }
// cartridge-player/manifest.json
"uses": { "magic-cube": "^1.1.0" }
// magic-cube
ctx.commands.handle("selectPreset", async ({ preset }, meta) => ({ activePresetId: await select(preset) }));
// cartridge-player
const result = await ctx.plugins.get("magic-cube").command("selectPreset", { preset: "Focus" });
  • Names are camelCase. Schemas are a subset of JSON Schema: type (a name or a list such as ["string", "null"]), enum, properties, required, additionalProperties (objects are closed by default), items, minimum, maximum, title, description, default, and choices on a field of a command's input (0.2.6, see Choices and controls). Other keywords are rejected.

  • The input is checked before the command runs (invalid), the output after (failed). Throw a PluginError to answer with its code and message; anything else (another library's error with a code too) answers failed with a generic message and a reference, and your console gets the message and stack (0.2.4). A development copy also gives the message to its own UI, never to other plugins, Home Assistant or its own main code (ctx.plugins.self). A PluginError's message is cut at 2,048 characters. A ctx call that rejects does so with a PluginError: left uncaught, it answers your caller with that code and message.

  • meta.signal aborts at the deadline (its reason is a TimeoutError) and, from 0.2.4, when the instance starts stopping (a PluginError with code stopped), before your onStop callbacks run: pass it to fetch, node:timers/promises and the like. A handler that rejects because of it (with the signal's reason, or with an AbortError) answers timeout or stopped, not failed. Once the command has answered, its signal never aborts. A command still running when the instance has stopped answers stopped.

  • It fails fast: not-installed, disabled, incompatible (outside your range) or unavailable (not running; it didn't run). A plugin that failed says only how (… isn't running: Stopped after an error); what it threw stays in its own console. timeout, stopped and failed mean it may have run. A failed with (ref 1a2b3c4d) in its message is something the other plugin's handler threw, or a fault in that plugin while the command ran: the reference finds the error in that plugin's console, the only place its message and stack go.

  • timeoutMs defaults to 10 s (at most 30 s). A command run inside another continues its chain and shares what's left of its time; the ninth step of a chain is refused with loop.

  • From a UI: ctx.plugins.get(id).command(…) as in main code. The UI waits the command's time plus 2 s, then rejects timeout.

  • In Home Assistant, each command is an action, drift_beacon.<manifest id>_<command in snake_case> (drift_beacon.magic_cube_select_preset), on a workspace's device: its fields are your input's properties; if you declare output, a call on one workspace can ask for the response { output }. It runs as that workspace's user with meta.caller { kind: "integration" } and 10 s, and needs no uses. Renaming a command or input field breaks automations as it breaks other plugins.

  • Each command a client with an API key runs (a Home Assistant action, a Stream Deck key) leaves one line in your console once it is answered, naming the key: Command "selectPreset" from API key "Stream Deck": ok in 12 ms, or the code and the message the caller was told. The key's name is all Drift Beacon knows of a client, so name each API key after its client. Past 5 a second (bursts of 20) the calls are counted, not listed, and the npm run dev terminal shows only the failed ones.

  • dbplugin prepare types what you provide (.drift-beacon/config.d.ts): ctx.commands.handle, ctx.events.emit and ctx.state take only declared names, with their declared types (none for a kind you declare nothing of).

  • From 0.2.7 it also types the plugins you use, each from a copy of its manifest that you commit as peers/<id>.json. dbplugin peers add writes the copy:

    npx dbplugin peers add ../magic-cube   # a plugin's folder, a manifest file, or a URL that answers with a manifest

    The plugin must already be in your uses, with a range that holds the copy's version; the command says which line to add when it isn't, and never edits your manifest. It keeps the plugin's id, name, version and provides and nothing else, and refuses a plugin that declares no command, event or state. Run it again to replace the copy; delete the file to go back to an untyped plugin. A URL must be http or https without a user name or password, and answer within 15 seconds with at most 256 KiB.

  • With a copy, ctx.plugins.get("magic-cube") takes only the commands, state keys and events that version declared, with their types, in main code and in the UI. A command's input can be left out when none of its fields is required, and a command that declares no output resolves to unknown. state.onChange names the key that changed as a plain string, since the version that runs may publish one the copy doesn't declare.

  • get() itself takes your own id and the ids in uses, one literal at a time, so a misspelt id is a type error. An id you only know at run time (a string variable) is still taken, and gives an untyped plugin. ctx.plugins.self is typed from your own provides, with or without copies.

  • The types say what the copied version declared, not what runs. A user can run any version of that plugin inside your uses range, and nothing checks a value against your types. Keep the range as narrow as what you tested, add the copy again when you widen it, and treat what arrives as you would any input.

  • A copy is checked whenever you build: one that isn't a manifest, isn't named after its plugin, isn't in uses or is outside its range fails npm run dev, vite build and dbplugin pack, naming the file. dbplugin prepare on install only warns. peers/ is never part of the release. A copy is checked by your SDK: if its plugin was built with a newer one and declares something yours doesn't know, the copy is refused until you update @drift-beacon/plugin.

  • A plugin in uses without a copy stays untyped: its command names and state keys are any strings, and what it answers and publishes is unknown. Say what you expect where you use it: (await peer.command("x")) as Answer, peer.state.get("mode") as string | undefined. Until 0.2.7 both took that type as an argument (command<Answer>("x")), which was the same cast in other clothes.

  • Every plugin is a PluginPeer (MainPluginPeer in main code), typed or not, so a function that needs only what all plugins have, such as status, can take that. To keep a typed plugin's names in a signature of your own, use MainPluginPeerOf<"magic-cube"> in main code and PluginPeerOf<"magic-cube"> in the UI. The other direction needs a cast: a test double of a typed plugin, or of ctx.plugins, is written as unknown as MainPluginPeerOf<"magic-cube">.

  • A copy can be trimmed by hand to the commands, state keys and events you use, as long as what remains still refers only to what is there: a control needs its command and its choices list, and a list the state it reads. dbplugin peers add writes the whole of what the plugin provides.

// cartridge-player, after `npx dbplugin peers add ../magic-cube`
const cube = ctx.plugins.get("magic-cube");
const { activePresetId } = await cube.command("selectPreset", { preset: "Focus" });
const preset = cube.state.get("activePreset"); // { id, name, mode } | null | undefined
cube.onEvent("rolled", (roll) => showFace(roll.side));
// @ts-expect-error: magic-cube 1.4.0 declares no such command
await cube.command("selectPrest", { preset: "Focus" });

Events, state and status between plugins

Main code emits and publishes; main code and UIs receive them. A plugin emits the events and publishes the state it declares in provides; the plugins that use it (and the plugin itself, through ctx.plugins.self) receive them, and see its status.

// magic-cube/manifest.json
"provides": {
  "events": { "faceChanged": { "title": "Face changed", "payload": { "type": "integer", "minimum": 1, "maximum": 6 } } },
  "state": { "activePreset": {
    "title": "Active preset",
    // { "id": "p_1", "name": "Focus", "mode": "duel" }, or null
    "schema": {
      "type": ["object", "null"],
      "properties": { "id": { "type": "string" }, "name": { "type": "string" }, "mode": { "type": "string" } },
      "required": ["id", "name", "mode"]
    }
  } }
}
// magic-cube
ctx.events.emit("faceChanged", 3);
ctx.state.set("activePreset", { id: "p_1", name: "Focus", mode: "duel" }); // undefined removes it
// cartridge-player
const cube = ctx.plugins.get("magic-cube");
cube.onEvent("faceChanged", (face, meta) => ctx.log.info(`Face ${face}, step ${meta.depth}`));
cube.state.get("activePreset"); // read it in onStart, then follow cube.state.onChange
cube.onStatusChange(({ state, reason }) => ctx.log.info(state, reason ?? ""));
  • emit and set throw invalid for an undeclared name, a value that doesn't match its schema, or over 64 KiB (256 KiB of state in all). Received values are frozen.
  • Events reach listeners registered by then (from onStart on; in a UI, right after connect()); earlier ones are missed. State is kept in memory while the provider runs, cleared when it stops, never persisted.
  • A plugin can emit 50 events a second on average, in bursts up to 100; Drift Beacon drops the rest, with a warning in your console at most every 10 s (each after the first says how many it dropped). For a value that changes faster, publish the latest as state (not limited) or send fewer, bigger events.
  • In main code, a command handler's events and state changes arrive before its caller's await resumes.
  • Listeners continue the chain of what caused them; past 8 steps, events are dropped (with a warning) and state changes call no callbacks.
  • status.state is running, starting, unavailable, disabled, incompatible or not-installed, with the resolved version and a reason. For a plugin that failed, the reason is Failed to start, Stopped after an error or Its plugin host stopped, never its error.
  • In a UI, status and state arrive within about 250 ms (and ctx.onDataChange fires), as the latest at each update (changes close together arrive as one), so a command's effects can show just after it resolves: render from onChange, or use its output.
  • In a UI (from 0.2.1), events come with those updates, each after the state its plugin set before it. None are replayed (reconnects, reloads, more than about 100 between two updates), every open copy of the UI gets each one, and a command a UI listener runs starts a new chain: render in UI listeners, and react with commands in main code. On an older Drift Beacon they never arrive.

Choices and controls (0.2.6)

A command's schema can say a field is a string. It can't say the string is one of the user's presets, or which preset is active now. A choices list and a control say that, from state you publish. A plugin that already publishes that state, and publishes it again whenever it changes, adds manifest lines and no code.

// magic-cube/manifest.json
"provides": {
  "state": {
    // [{ "id": "p_1", "name": "Deep work", "mode": "duel" }, …]: id, name and mode are required strings
    "presets": { "title": "Presets", "schema": { "type": "array", "items": { "type": "object", … } } },
    // One of them, with the same required id, or null
    "activePreset": { "title": "Active preset", "schema": { "type": ["object", "null"], … } }
  },
  "choices": {
    "preset": { "title": "Preset", "state": "presets", "value": "id", "label": "name", "detail": "mode" }
  },
  "commands": { "selectPreset": {
    "title": "Select preset",
    "input": {
      "type": "object",
      "properties": { "preset": { "type": ["string", "null"], "choices": "preset" } },
      "required": ["preset"]
    }
  } },
  "controls": {
    "preset": {
      "kind": "select",
      "title": "Preset",
      "icon": "mdi:cube-outline",
      "value": { "state": "activePreset", "path": "id" },
      "set": { "command": "selectPreset", "field": "preset" },
      "none": "No preset"
    }
  }
}
// magic-cube: nothing new. It publishes both keys in onStart and again whenever either changes, as it did.
ctx.state.set("presets", presets); // [] when the user has none
ctx.state.set("activePreset", active ?? null);

| You declare | Fields | Rules | |---|---|---| | provides.choices.<name>: a list of options | title, state, value, label, optional detail | state is a state key you declare whose schema is an array of objects (neither may be null). value and label name required properties of those objects: value a string, number or integer, label a string. detail names a string property, which may also be null or optional | | choices on a field: the options it takes | The name of a list | Only on a field of a command's input: not on a nested property or an array's items, and not in output, events, state or a ctx.ui.handle schema. The field has the list's value type, and null is allowed beside it; a number field also takes a list of integers. Not beside enum | | provides.controls.<name>: a current value and the command that sets it | kind, title, value, set, optional icon and none | Below |

A control's fields:

  • kind is "select": one option out of a choices list.
  • value.state is the state key that holds the current value: a string, number or integer, and null is allowed beside it. When the state is an object (or an object or null), value.path names the one property to read; it is one name, not a dotted path, and the property is in that schema's required, so every object you publish has it. The value is text when the list's values are text, and a number when they are numbers.
  • set.command and set.field name the command, and its input field, that take an option's value. The field carries choices and is the command's only required field: a caller sends { [field]: value } and nothing else.
  • A field with enum can't be a control's field, since choices isn't allowed beside enum. To offer a fixed set (two modes, say), publish it as a list in onStart, name that list with choices, take enum off the field and refuse any other value in the handler. enum can stay on the state the control reads and on the list's value property.
  • A control is nullable when both hold: its field accepts null, and its value can be null. The value can be null when the state's type includes null or, with path, when the state's or the property's does. An enum there counts with the type: one that leaves null out means that state or property can't be null. A caller offers null as an option, beside the list's, exactly when the control is nullable. Above, preset accepts null and activePreset can be null, so "no preset" is an option.
  • none is what to call null, such as "No preset". It is wording only: it is allowed only on a nullable control, and it is optional there. Without it a caller uses a word of its own.
  • icon is an icon id, mdi:<name> (Material Design Icons). Only its form is checked: whoever shows the control uses an icon of its own when it can't draw yours.

Names follow the other kinds' rules (camelCase, at most 64 of each kind). Neither kind takes a description.

A refusal names the declaration and what is wrong with it. For a path that isn't required, and for none on a control that isn't nullable (naming the half that is missing, or both):

provides.controls.preset.value: path "id" must be required in state "activePreset"
provides.controls.preset: none needs field "preset" of command "selectPreset" to accept null
provides.controls.preset: none needs state "activePreset" or its property "id" to allow null

For the first, list the property in the state schema's required. For the others, add "null" to the type the message names, and to its enum when it has one, or take none off. Without a path the message reads none needs state "selected" to allow null, and when both halves are missing it names both: none needs field "preset" of command "selectPreset" to accept null, and state "activePreset" or its property "id" to allow null.

  • Checked when you build. A name that isn't declared or a type that doesn't fit fails npm run dev, vite build and dbplugin pack, and Drift Beacon refuses to install the plugin. dbplugin prepare only warns, as for any manifest problem. Nothing new is generated: a field with choices keeps its own type in ctx.commands.handle.
  • Publish what you bind in onStart. No build can check that. Until a key is published, callers see its list or control as unknown, which is neither an empty list nor a value of null. So publish [] when there are no options and null when nothing is selected. A development copy warns in your console about each bound key that is still unpublished when onStart returns. A plugin that isn't running is unknown too.
  • Publish a control's key again whenever its value changes. That includes the handler of the command that sets it: the command's answer says nothing of the control. A Stream Deck key lights only when the published value equals its option's value, and nothing warns of a handler that doesn't publish it.
  • A name is taken for an id. When a caller sends a string for a field with choices that is no option's value and is exactly one option's label, ignoring case, your handler gets that option's value instead (from a Drift Beacon that has the rule; an older one passes the name on). It is for people typing a name into a Home Assistant action. It works only once you have published the list, never refuses, and passes on a name that no option or several options have. Case is ignored as JavaScript's toLowerCase() ignores it, with no trimming. So keep your own name matching only for what it can't do: an older Drift Beacon, a label two options share, a list you publish some time after onStart, and the moment after a rename. A word your handler takes that isn't an option ("next", say) is replaced too if it is exactly one option's label, and for a list of numbers a string is always read as a name.
  • A list is a hint. Drift Beacon never refuses an input for not being in the list: your handler still gets other values, and has the last word. Publish every valid option; a caller offers nothing else.
  • Options are told apart by value. Callers store the value and show the label. An item without a usable value or label is left out, and of two items with one value the first is kept.
  • A control belongs to the plugin, not to a device. It reads one state key and has one value: there is no copy of it per device, and its callers send its one field and nothing else, so a control can't say which device. For several devices, give a command a field that says which, for callers that write the input themselves: another plugin, a Home Assistant action, a Stream Deck Plugin Command key. On the command a control sets, that field has to be optional, because the control's field is the only required one, and a control's callers never send it.
  • Names are API. Callers keep a control as <manifest id>/<control name> and an option by its value, and they follow the installed version without a range. So these break what they kept: renaming or removing a control or a list; changing a list's state or value; changing a control's set; making a control stop being nullable, by a field that no longer accepts null or a value that can no longer be null. Null is an option exactly while the control is nullable: once it isn't, a Stream Deck key that stored null shows its option as missing and no longer runs the command. These change only what people see: adding a list or a control; changing a list's label, a title or an icon; adding, rewording or removing none. Changing a control's value (its state or path) is compatible only while what it reads is still one of the list's values, or null: otherwise no stored option is ever the current one, and a Stream Deck key never lights.
  • Who reads it. Every API key of that user for that workspace can read the options and current value of everything you bind. Nothing else of your state leaves Drift Beacon this way: an option is its value, label and detail, and a control is one string, number or null. Bind only what may be shown outside.
  • Who uses it. The Drift Beacon Stream Deck plugin's Plugin Control key: the user picks a control, then an option, and the key shows that option and lights while it is the current value. And Home Assistant, where each control is a select entity on the workspace's device: its options are your labels, so keep them short and distinct, and its state follows the value you publish. Other plugins and your own UI read your state and run your commands as before.

Errors and limits

Operations reject with PluginError. Check error.code, not instanceof:

| Code | Meaning | |---|---| | unsupported | The app doesn't support this API version or request | | invalid | Bad arguments (an empty id or storage key, an MQTT payload that isn't a string, tracking a point with start(), a value that isn't JSON, an undeclared event, …) | | not-found | The activity or session doesn't exist, or the model was removed | | unavailable | Something needed isn't there: no MQTT broker, no answer from the app | | stopped | The instance has stopped | | failed | Anything else, such as tracking an archived activity, or a command or UI request whose handler threw something that isn't a PluginError (the message is generic, with a reference to that plugin's console) | | not-installed, disabled, incompatible | The plugin you ran a command on isn't installed, is disabled here, or is outside your uses range | | loop, timeout | A chain of commands went deeper than 8 steps; a command didn't answer in time (it may have run) |

| | Limit | |---|---| | onStart / onStop | 15 s / 5 s | | Blocking the event loop | The server restarts the plugin host after 10 s | | Actions | 10 s, then unavailable | | Route handlers | 30 s, then 504 | | Commands to other plugins | 10 s by default (at most 30 s), then timeout; 8 steps deep; 64 KiB in and out | | Your own UI (ctx.ui) | 64 KiB of UTF-8 per value, 32 levels deep; posts 50 a second (bursts of 100) | | Release package | 32 MiB zipped, 128 MiB unpacked, 2,000 files |

Developing against your server

On a Drift Beacon server you run yourself, set DEV_PLUGINS_PATH to the folder that contains your plugin folders and their catalogue.json, and start the server in development mode. It lists each plugin under Plugins → Repositories → Local development. Enable it in a workspace, then run npm run dev in the plugin folder: every save rebuilds main, the server restarts the plugin, and its status, logs and errors (with stack traces pointing at your source) print in your terminal. The UI updates in place.

Releasing a plugin

Plugins are published on GitHub, from a public repository with a catalogue.json at its top. It names your catalogue and lists the folders that are plugins; no other folder is read:

{
  "name": "My plugins",
  "plugins": ["hello"]
}

For each release:

  1. Bump version in manifest.json and commit.
  2. Run npm run release (or npx dbplugin pack), which writes releases/<id>.zip.
  3. Create a GitHub release tagged <id>-<version> (for example hello-1.0.0) and attach <id>.zip.

Drift Beacon offers the version in manifest.json on the default branch and installs the release with that tag. Every version you push there needs its release: until it exists, installing fails. A released version is final. The manifest inside the ZIP must equal the one on the default branch, so any change to manifest.json, even to its description, needs a new version and release.

To automate this, copy the workflow of the official plugins: each push to the default branch publishes the versions that have no release yet.

Users add the repository URL in Plugins → Repositories and install from there. Don't commit dist/ or releases/.

Versions

  • apiVersion in the manifest must match the SDK: dbplugin, vite build and the server all check it.
  • Before 1.0, each minor version (0.1, 0.2) may change the API, and a server runs only its exact version. Install the SDK with @0.2 to stay on it.
  • Patch releases (0.2.x) fix bugs and may add to API 0.2. An addition that needs the app, such as UI events in 0.2.1 or activity goals and pins in 0.2.2, does nothing on an older Drift Beacon. Ignore fields and string values you don't recognise (such as a new trackingType).
  • 0.2.7 adds types for the plugins you use, from copies of their manifests in peers/ (dbplugin peers add). It changes nothing at run time and needs no newer Drift Beacon. Three things are checked that weren't, so code that compiled may not:
    • ctx.plugins.get() takes a string or a literal id, and a literal must be your own id or one in uses (any other always threw invalid). A generic id compiles, and keeps the types, when its constraint is keyof PluginPeers & string; one constrained to string is refused, as is a template literal type such as `la${string}`. Widen to string only when the id really is unknown. A union of valid ids is taken, and what the plugins share (id, status, onEvent) works on it, but its command and state.get can't be called: narrow it to one id first.
    • In a plugin that declares provides, ctx.plugins.self and ctx.plugins.get("<your own id>") are checked against it: only declared commands, state keys and events, and inputs of the declared type.
    • command and state.get no longer take a type argument, on any plugin: command<T>(…) becomes (await command(…)) as T. A typed plugin gives the declared type, and any other gives unknown.
  • 0.2.6 adds choices lists and controls to the manifest: provides.choices, provides.controls and choices on a field of a command's input. It needs a Drift Beacon that ships it: an older one refuses a manifest that declares them, at install.
  • 0.2.5 adds ctx.schedules.onTriggered in main code: the current user's schedules as they fire. It needs a Drift Beacon that ships it; on an older one ctx.schedules is undefined.
  • 0.2.4 adds the private channel between your UI and main code: ctx.ui in main code and ctx.main in the UI. It needs a Drift Beacon that ships it. It also changes declared commands: a handler's throw that isn't a PluginError answers failed with a generic message and a reference to your console (it used to answer with the thrown message, so throw a PluginError for what a caller should read); meta.signal also aborts when the instance starts stopping, not only at the deadline; and a plugin that failed tells other plugins and Home Assistant only how it failed (status.reason, and the unavailable its commands answer), not its error.

Host theme (SDK 0.2.3)

connect() installs the host's theme as --db-* CSS variables on <html> before resolving. It keeps them synchronized without reloading the UI. It also sets data-color-mode, color-scheme, and the light/dark class. Use semantic tokens instead of copying the app's palette or forcing a dark class.

The SDK also installs the dashboard's thin scrollbar defaults inside the UI document, including nested scroll areas. Tracks are transparent, and thumbs use --db-foreground and, where the pointer can hover, appear only on hover or keyboard focus, so their colors follow theme changes. No gutter is reserved. No plugin stylesheet is needed; authored scrollbar CSS can override the low-specificity defaults, and <html data-db-scrollbars="native"> in ui/index.html turns them off and keeps the browser's own scrollbars. Rebuild existing UI bundles with the updated SDK to adopt this styling.

body { background: var(--db-background); color: var(--db-foreground); }
.card { background: var(--db-surface); color: var(--db-surface-foreground); border: 1px solid var(--db-border); }

The tokens are background, foreground, surface, surface-foreground, surface-raised, muted, border, accent, accent-foreground, danger, danger-foreground, success, success-foreground, warning, warning-foreground, and focus, all prefixed with --db-. background is the containing surface; surface is a card; surface-raised is a more prominent surface. Values are complete CSS colors, not HSL channels. Activity and device colors remain domain data, separate from these UI colors.

ctx.theme: UiTheme is an immutable snapshot (mode: "light" | "dark", colors: UiThemeColors using camelCase token names). ctx.onThemeChange(callback): Unsubscribe runs after CSS is updated, only when the theme changes. Canvas/chart renderers can subscribe here; theme-only updates do not fire onDataChange. Convert CSS colors to sRGB if the rendering library does not accept modern CSS color syntax.

Older hosts omit the theme. The SDK then supplies a stable dark fallback palette; an old-host reconnect restores that fallback. Missing theme fields in ordinary state updates leave the current theme unchanged. Invalid theme snapshots are ignored. Older UI bundles ignore the new fields and keep their existing appearance; rebuild and migrate their styles to adopt them.

Render the main UI after connect() resolves. Give loading/error UI fallback colors, and handle a rejected connection. Framework adapters should map their library's colors to these tokens; the SDK does not require React or HeroUI.

License

MIT