@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
Maintainers
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
- Project layout and commands
- Manifest
- Main code
- UI
- Workspace data
- Actions and events
- Storage
- Configuration
- MQTT and HTTP routes
- Your UI and main code (0.2.4)
- Commands between plugins
- Events, state and status between plugins
- Choices and controls (0.2.6)
- Errors and limits
- Developing against your server
- Releasing a plugin
- Versions
- Host theme (SDK 0.2.3)
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/nodepackage.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'sroot(ui/),baseandbuild.outDir: don't set them. It works with Vite 7 and 8. Under Vitest it only adds the build, so your tests can sharevite.config.tswhilenpm run devruns.
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 throughctxare released when the instance stops. Keep per-instance state insideonStart, 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 everyctxcall throwsPluginErrorwith codestopped. 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.datachanges. - 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 withnot-found. startedAtandendedAtareDates: copy one before changing it.iconPathon activities and categories is SVG path data (viewBox0 0 24 24), ornull:
{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:
goalis{ type: "duration", seconds }or{ type: "count", count }, ornullwhen 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.periodis"day","week","month"or"year": calendar periods from local midnight, weeks starting on Sunday.nullmeans progress covers all history. Local means the browser's time zone in a UI, like the app's own progress, but the server's (itsTZenvironment 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. SetTZon the server to the household's zone.pinnedBylists 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.isPinnedis 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,periodandpinnedByareundefined, which is why they are optional and how to tell (from 0.2.2,pinnedByis always an array), andisPinnedisfalsein a UI butundefinedin main code. TestisPinnedfor 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.schedulesisundefinedon a Drift Beacon older than 0.2.5 (main code uses the SDK of the Drift Beacon that runs it), hence?..- More values of
kindandbehaviorcan 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;
publishrejects withunavailablewithout one or while it isn't connected, and withinvalidfor 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.apiPathis the path up to/api). Callers sendAuthorization: Bearer <workspace API key>, which picks the user and workspace. Handlers return{ status?, body? };bodyis sent as JSON. WhileonStartruns, 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 missedIn main code:
handle(name, handler, { input? }): one handler per camelCase name, apart fromctx.commands(a name can be in both).inputis 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 withoutput: undefined, or none, can return nothing:ctx.ui.handle("forget", async () => { … }).metais a command's (calleris{ kind: "ui", plugin }, a new chain at depth 1), plusclient(the copy that asked) andrequestId.meta.signalaborts at the deadline, when the UI gives up and when the instance starts stopping; not when the copy goes away (watchonClientsChange), and never once the handler has answered.- Requests reach handlers once
onStarthas returned; after that a name without a handler answersnot-found. A thrownPluginErroranswers with its code and message (it is recognised bynameandcode: another library's error with acodeis anything else); anything else answersfailedwith 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, elsefailed. post(name, payload?, { to? })sends to every open copy of your UI for this user in this workspace, on every device, or only toto(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, andclientssays which copies are open.clientslists the open copies, oldest first (id,platform,version,since), andonClientsChangereports 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.uihandler. 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
signalrejects at once withsignal.reasonand aborts the handler'smeta.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.clientsonce 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
loopdoesn'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, andchoiceson 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 aPluginErrorto answer with its code and message; anything else (another library's error with acodetoo) answersfailedwith 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). APluginError's message is cut at 2,048 characters. Actxcall that rejects does so with aPluginError: left uncaught, it answers your caller with that code and message.meta.signalaborts at the deadline (its reason is aTimeoutError) and, from 0.2.4, when the instance starts stopping (aPluginErrorwith codestopped), before youronStopcallbacks run: pass it tofetch,node:timers/promisesand the like. A handler that rejects because of it (with the signal's reason, or with anAbortError) answerstimeoutorstopped, notfailed. Once the command has answered, its signal never aborts. A command still running when the instance has stopped answersstopped.It fails fast:
not-installed,disabled,incompatible(outside your range) orunavailable(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,stoppedandfailedmean it may have run. Afailedwith(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.timeoutMsdefaults 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 withloop.From a UI:
ctx.plugins.get(id).command(…)as in main code. The UI waits the command's time plus 2 s, then rejectstimeout.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 declareoutput, a call on one workspace can ask for the response{ output }. It runs as that workspace's user withmeta.caller{ kind: "integration" }and 10 s, and needs nouses. 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 thenpm run devterminal shows only the failed ones.dbplugin preparetypes what you provide (.drift-beacon/config.d.ts):ctx.commands.handle,ctx.events.emitandctx.statetake 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 addwrites the copy:npx dbplugin peers add ../magic-cube # a plugin's folder, a manifest file, or a URL that answers with a manifestThe 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'sid,name,versionandprovidesand 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 behttporhttpswithout 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 tounknown.state.onChangenames 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 inuses, one literal at a time, so a misspelt id is a type error. An id you only know at run time (astringvariable) is still taken, and gives an untyped plugin.ctx.plugins.selfis typed from your ownprovides, 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
usesrange, 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
usesor is outside its range failsnpm run dev,vite buildanddbplugin pack, naming the file.dbplugin prepareon 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
useswithout a copy stays untyped: its command names and state keys are any strings, and what it answers and publishes isunknown. 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(MainPluginPeerin main code), typed or not, so a function that needs only what all plugins have, such asstatus, can take that. To keep a typed plugin's names in a signature of your own, useMainPluginPeerOf<"magic-cube">in main code andPluginPeerOf<"magic-cube">in the UI. The other direction needs a cast: a test double of a typed plugin, or ofctx.plugins, is writtenas 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 addwrites 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 ?? ""));emitandsetthrowinvalidfor 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
onStarton; in a UI, right afterconnect()); 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
awaitresumes. - Listeners continue the chain of what caused them; past 8 steps, events are dropped (with a warning) and state changes call no callbacks.
status.stateisrunning,starting,unavailable,disabled,incompatibleornot-installed, with the resolvedversionand areason. For a plugin that failed, the reason isFailed to start,Stopped after an errororIts plugin host stopped, never its error.- In a UI, status and state arrive within about 250 ms (and
ctx.onDataChangefires), as the latest at each update (changes close together arrive as one), so a command's effects can show just after it resolves: render fromonChange, 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:
kindis"select": one option out of a choices list.value.stateis the state key that holds the current value: astring,numberorinteger, andnullis allowed beside it. When the state is an object (or an object or null),value.pathnames the one property to read; it is one name, not a dotted path, and the property is in that schema'srequired, 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.commandandset.fieldname the command, and its input field, that take an option's value. The field carrieschoicesand is the command's only required field: a caller sends{ [field]: value }and nothing else.- A field with
enumcan't be a control's field, sincechoicesisn't allowed besideenum. To offer a fixed set (two modes, say), publish it as a list inonStart, name that list withchoices, takeenumoff the field and refuse any other value in the handler.enumcan stay on the state the control reads and on the list'svalueproperty. - 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
nullor, withpath, when the state's or the property's does. Anenumthere counts with the type: one that leavesnullout 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,presetaccepts null andactivePresetcan be null, so "no preset" is an option. noneis 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.iconis 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 nullFor 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 buildanddbplugin pack, and Drift Beacon refuses to install the plugin.dbplugin prepareonly warns, as for any manifest problem. Nothing new is generated: a field withchoiceskeeps its own type inctx.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 andnullwhen nothing is selected. A development copy warns in your console about each bound key that is still unpublished whenonStartreturns. 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
choicesthat 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'stoLowerCase()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 afteronStart, 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'sstateorvalue; changing a control'sset; 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'slabel, atitleor anicon; adding, rewording or removingnone. Changing a control'svalue(itsstateorpath) 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:
- Bump
versioninmanifest.jsonand commit. - Run
npm run release(ornpx dbplugin pack), which writesreleases/<id>.zip. - Create a GitHub release tagged
<id>-<version>(for examplehello-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
apiVersionin the manifest must match the SDK:dbplugin,vite buildand 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.2to 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 newtrackingType). - 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 astringor a literal id, and a literal must be your own id or one inuses(any other always threwinvalid). A generic id compiles, and keeps the types, when its constraint iskeyof PluginPeers & string; one constrained tostringis refused, as is a template literal type such as`la${string}`. Widen tostringonly 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 itscommandandstate.getcan't be called: narrow it to one id first.- In a plugin that declares
provides,ctx.plugins.selfandctx.plugins.get("<your own id>")are checked against it: only declared commands, state keys and events, and inputs of the declared type. commandandstate.getno 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 givesunknown.
- 0.2.6 adds choices lists and controls to the manifest:
provides.choices,provides.controlsandchoiceson 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.onTriggeredin main code: the current user's schedules as they fire. It needs a Drift Beacon that ships it; on an older onectx.schedulesisundefined. - 0.2.4 adds the private channel between your UI and main code:
ctx.uiin main code andctx.mainin the UI. It needs a Drift Beacon that ships it. It also changes declared commands: a handler's throw that isn't aPluginErroranswersfailedwith a generic message and a reference to your console (it used to answer with the thrown message, so throw aPluginErrorfor what a caller should read);meta.signalalso 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 theunavailableits 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
