@drift-beacon/plugin 0.2.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -201,9 +201,9 @@ Settings items have `name` (unique), `title`, `type` and optional `description`
201
201
 
202
202
  `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`.
203
203
 
204
- - **Start.** `onStart(ctx)` may be async and must finish within 15 seconds. If it throws, the instance doesn't start.
204
+ - **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.
205
205
  - **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.
206
- - **Stop.** `ctx.onStop(fn)` callbacks run newest first, 5 seconds in total. Afterwards every `ctx` call throws `PluginError` with code `stopped`.
206
+ - **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.
207
207
  - **Faults.** An uncaught error or unhandled rejection stops only that instance, which is retried after 5, 30 and 120 seconds.
208
208
 
209
209
  | `ctx` member | Main | UI | |
@@ -214,13 +214,13 @@ Settings items have `name` (unique), `title`, `type` and optional `description`
214
214
  | `onDataChange` | ✓ | ✓ | The data changed |
215
215
  | `sessions.onStarted`, `onEnded`, `onMarked` | ✓ | | Live session events |
216
216
  | `storage` | ✓ | ✓ | Key-value storage |
217
- | `plugins` | ✓ | ✓ | The plugins it uses and itself: status, state and commands (and events, in main code) |
217
+ | `plugins` | ✓ | ✓ | The plugins it uses and itself: status, state, events and commands |
218
218
  | `commands`, `events`, `state` | ✓ | | What it provides to other plugins |
219
219
  | `mqtt`, `routes`, `log`, `onStop` | ✓ | | Main code only |
220
220
 
221
221
  ## UI
222
222
 
223
- `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).
223
+ `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).
224
224
 
225
225
  With React, re-render on every update:
226
226
 
@@ -301,7 +301,7 @@ await ctx.sessions.end(sessionId); // or session.end(), activity.end()
301
301
  await ctx.sessions.discard(sessionId); // or session.discard()
302
302
  ```
303
303
 
304
- 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.
304
+ 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`.
305
305
 
306
306
  | | Live session events | Data change events |
307
307
  |---|---|---|
@@ -314,7 +314,7 @@ Never trigger actions from data changes. Live events fire for everyone's session
314
314
 
315
315
  ## Storage
316
316
 
317
- Per plugin, user and workspace, shared by main code and the UI. 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.
317
+ 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.
318
318
 
319
319
  ```ts
320
320
  const faces = ctx.storage.get<Record<string, string>>("faces") ?? {};
@@ -327,7 +327,7 @@ ctx.storage.onChange((key, value) => { /* changed by main code, a UI or another
327
327
 
328
328
  `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.
329
329
 
330
- A UI can open before setup is finished. Until then its `ctx.config` holds the defaults under whatever the user has saved, unvalidated: a `required` setting can still be missing. Check before relying on one.
330
+ 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.
331
331
 
332
332
  ## MQTT and HTTP routes
333
333
 
@@ -341,12 +341,12 @@ ctx.routes.post("webhook", async ({ body }) => ({ status: 202, body: { ok: true
341
341
  ctx.routes.get("status", () => ({ body: { live: ctx.sessions.live({ mine: true }).length } }));
342
342
  ```
343
343
 
344
- - MQTT uses the workspace's broker; `publish` rejects with `unavailable` without one.
345
- - 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.
344
+ - 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).
345
+ - 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).
346
346
 
347
347
  ## Commands between plugins
348
348
 
349
- Main code provides commands; main code and UIs run them. 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.
349
+ Main code provides commands; main code, UIs and Home Assistant run them. 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.
350
350
 
351
351
  ```jsonc
352
352
  // magic-cube/manifest.json
@@ -371,11 +371,25 @@ const result = await ctx.plugins.get("magic-cube").command("selectPreset", { pre
371
371
  - It fails fast: `not-installed`, `disabled`, `incompatible` (outside your range) or `unavailable` (not running; it didn't run). `timeout` and `stopped` mean it may have run.
372
372
  - `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`.
373
373
  - 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`.
374
- - `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). Plugins you use aren't typed yet.
374
+ - 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.
375
+ - `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).
376
+ - Plugins you use aren't typed for you: keep a copy of the types you need, written from their manifest. `command<T>()` and `state.get<T>()` take the type, and an event's payload is `unknown` until you narrow it. Update the copy along with your `uses` range.
377
+
378
+ ```ts
379
+ // cartridge-reader: what it uses from magic-cube ^1.1.0, kept by hand
380
+ interface SelectPresetOutput {
381
+ activePresetId: string | null;
382
+ }
383
+
384
+ const cube = ctx.plugins.get("magic-cube");
385
+ const { activePresetId } = await cube.command<SelectPresetOutput>("selectPreset", { preset: "Focus" });
386
+ const preset = cube.state.get<string | null>("activePreset");
387
+ cube.onEvent("faceChanged", (face) => showFace(face as number));
388
+ ```
375
389
 
376
390
  ## Events, state and status between plugins
377
391
 
378
- Main code emits and publishes; UIs read state and status (not events). 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.
392
+ 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.
379
393
 
380
394
  ```jsonc
381
395
  // magic-cube/manifest.json
@@ -397,11 +411,13 @@ cube.onStatusChange(({ state, reason }) => ctx.log.info(state, reason ?? ""));
397
411
  ```
398
412
 
399
413
  - `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.
400
- - Events reach listeners registered by then (from `onStart` on); earlier ones are missed. State is kept in memory while the provider runs, cleared when it stops, never persisted.
414
+ - 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.
415
+ - 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.
401
416
  - In main code, a command handler's events and state changes arrive before its caller's `await` resumes.
402
417
  - Listeners continue the chain of what caused them; past 8 steps, events are dropped (with a warning) and state changes call no callbacks.
403
418
  - `status.state` is `running`, `starting`, `unavailable`, `disabled`, `incompatible` or `not-installed`, with the resolved `version` and a `reason`.
404
419
  - 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.
420
+ - 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.
405
421
 
406
422
  ## Errors and limits
407
423
 
@@ -410,7 +426,7 @@ Operations reject with `PluginError`. Check `error.code`, not `instanceof`:
410
426
  | Code | Meaning |
411
427
  |---|---|
412
428
  | `unsupported` | The app doesn't support this API version or request |
413
- | `invalid` | Bad arguments (tracking a point with `start()`, a value that isn't JSON, an undeclared event, …) |
429
+ | `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, …) |
414
430
  | `not-found` | The activity or session doesn't exist, or the model was removed |
415
431
  | `unavailable` | Something needed isn't there: no MQTT broker, no answer from the app |
416
432
  | `stopped` | The instance has stopped |
@@ -429,7 +445,7 @@ Operations reject with `PluginError`. Check `error.code`, not `instanceof`:
429
445
 
430
446
  ## Developing against your server
431
447
 
432
- On a Drift Beacon server you run yourself, set `DEV_PLUGINS_PATH` to the folder that contains your plugin folders and start the server in development mode. It lists each plugin under **Plugins → 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.
448
+ On a Drift Beacon server you run yourself, set `DEV_PLUGINS_PATH` to the folder that contains your plugin folders 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.
433
449
 
434
450
  ## Releasing a plugin
435
451
 
@@ -439,13 +455,13 @@ Plugins are published on GitHub. In a public repository, each immediate child fo
439
455
  2. Run `npm run release` (or `npx dbplugin pack`), which writes `releases/<id>.zip`.
440
456
  3. Create a GitHub release tagged `<id>-<version>` (for example `hello-1.0.0`) and attach `<id>.zip`.
441
457
 
442
- Users add the repository URL in **Plugins → Store** and install from there. Don't commit `dist/` or `releases/`.
458
+ Users add the repository URL in **Plugins → Repositories** and install from there. Don't commit `dist/` or `releases/`.
443
459
 
444
460
  ## Versions
445
461
 
446
462
  - `apiVersion` in the manifest must match the SDK: `dbplugin`, `vite build` and the server all check it.
447
463
  - 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.
448
- - Minor releases may add fields and new string values (such as a new `trackingType`): ignore what you don't recognise.
464
+ - 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, does nothing on an older Drift Beacon. Ignore fields and string values you don't recognise (such as a new `trackingType`).
449
465
 
450
466
  ## License
451
467
 
@@ -1 +1,23 @@
1
- export {};
1
+ const nonEmpty = (value, what) => typeof value === "string" && value !== "" ? null : `${what} must be a non-empty string`;
2
+ const ARGUMENTS = {
3
+ startSession: (action) => nonEmpty(action.activityId, "Activity id"),
4
+ markPoint: (action) => nonEmpty(action.activityId, "Activity id"),
5
+ endSession: (action) => nonEmpty(action.sessionId, "Session id"),
6
+ discardSession: (action) => nonEmpty(action.sessionId, "Session id"),
7
+ storageSet: (action) => nonEmpty(action.key, "Storage key"),
8
+ storageRemove: (action) => nonEmpty(action.key, "Storage key"),
9
+ mqttSubscribe: (action) => nonEmpty(action.topic, "MQTT topic"),
10
+ mqttUnsubscribe: (action) => nonEmpty(action.topic, "MQTT topic"),
11
+ mqttPublish: (action) => nonEmpty(action.topic, "MQTT topic") ??
12
+ (typeof action.payload === "string" ? null : "MQTT payload must be a string"),
13
+ };
14
+ export function checkedAction(value) {
15
+ if (typeof value !== "object" || value === null)
16
+ return null;
17
+ const action = value;
18
+ const name = action.name;
19
+ if (typeof name !== "string" || !Object.hasOwn(ARGUMENTS, name))
20
+ return null;
21
+ const problem = ARGUMENTS[name](action);
22
+ return problem === null ? { ok: true, action: action } : { ok: false, problem };
23
+ }
@@ -6,9 +6,14 @@ export const PEER_LIMITS = {
6
6
  valueBytes: 64 * 1024,
7
7
  stateBytes: 256 * 1024,
8
8
  depth: 8,
9
+ eventsPerSecond: 50,
10
+ eventBurst: 100,
9
11
  };
10
12
  export const COMMAND_TIMEOUT = { defaultMs: 10_000, maxMs: 30_000, minimumMs: 100 };
11
13
  export const commandBudgetMs = (timeoutMs) => Math.min(timeoutMs ?? COMMAND_TIMEOUT.defaultMs, COMMAND_TIMEOUT.maxMs);
14
+ export const eventNameProblem = (name) => typeof name === "string" && PEER_NAME.test(name)
15
+ ? null
16
+ : `Event names are camelCase letters and digits (got "${String(name)}")`;
12
17
  export function commandArgumentsProblem(command, input, options) {
13
18
  if (typeof command !== "string" || !command)
14
19
  return "Command name must be a non-empty string";
package/dist/main.d.ts CHANGED
@@ -19,9 +19,15 @@ export interface MqttMessage {
19
19
  export interface MqttApi {
20
20
  /** Subscribe to a topic filter. `+` matches one level and `#` the remaining levels. */
21
21
  subscribe(topic: string, callback: (message: MqttMessage) => void): Unsubscribe;
22
- /** Rejects when no broker is configured or the broker refuses the message. */
22
+ /**
23
+ * Rejects with `invalid` when the topic is empty or the payload isn't a string (nothing is sent), and with
24
+ * `unavailable` when the workspace has no broker, the broker isn't connected, or it refuses the message.
25
+ */
23
26
  publish(topic: string, payload: string): Promise<void>;
24
- /** `unavailable` when no broker is configured for the workspace. */
27
+ /**
28
+ * `unavailable` when the workspace has no broker: none is configured, or the server couldn't connect to it when
29
+ * it was saved or when the server started.
30
+ */
25
31
  readonly status: MqttStatus;
26
32
  onStatusChange(callback: (status: MqttStatus) => void): Unsubscribe;
27
33
  }
package/dist/types.d.ts CHANGED
@@ -178,13 +178,13 @@ export interface SessionsApi {
178
178
  /** Data change events. They can arrive in bursts after a sync; don't use them to trigger actions. */
179
179
  onChange(callback: (change: Change<Session>) => void): Unsubscribe;
180
180
  }
181
- /** The plugin's own key-value storage, per user and workspace. Values must be JSON-compatible. */
181
+ /** The plugin's own key-value storage, per user and workspace: non-empty string keys, JSON-compatible values. */
182
182
  export interface StorageApi {
183
183
  get<T = unknown>(key: string): T | undefined;
184
184
  keys(): readonly string[];
185
185
  /**
186
186
  * Applies locally at once and resolves when Drift Beacon has accepted the write. `undefined` removes the key;
187
- * a value that isn't JSON-compatible rejects with `invalid`.
187
+ * an empty key or a value that isn't JSON-compatible rejects with `invalid`.
188
188
  */
189
189
  set(key: string, value: unknown): Promise<void>;
190
190
  /** Applies locally at once and resolves when Drift Beacon has accepted the removal. */
@@ -237,6 +237,13 @@ export interface PluginPeer {
237
237
  command<T = unknown>(name: string, input?: Readonly<Record<string, unknown>>, options?: CommandOptions): Promise<T>;
238
238
  /** The state it publishes (manifest.json `provides.state`). */
239
239
  readonly state: PeerStateApi;
240
+ /**
241
+ * Called for each of its events with this name (manifest.json `provides.events`), with the payload (frozen). In a
242
+ * UI (from 0.2.1): from `connect()` on, with the state its plugin set before it, not replayed, errors logged, and
243
+ * every open copy of the UI gets each one; against an older Drift Beacon it's never called. In main code, see
244
+ * `MainPluginPeer.onEvent`. Throws `invalid` for a name that isn't camelCase.
245
+ */
246
+ onEvent(name: string, callback: (payload: unknown) => void): Unsubscribe;
240
247
  }
241
248
  /** The plugins this one uses. */
242
249
  export interface PluginsApi {
package/dist/ui.d.ts CHANGED
@@ -15,13 +15,14 @@ export interface UiContext {
15
15
  readonly sessions: SessionsApi;
16
16
  /**
17
17
  * Called after each update from the app (workspace data, config, storage, or other plugins' status and state) and
18
- * after local storage writes.
18
+ * after local storage writes; not for other plugins' events.
19
19
  */
20
20
  onDataChange(callback: () => void): Unsubscribe;
21
21
  readonly storage: StorageApi;
22
22
  /**
23
- * Its own plugin and the plugins it uses: their status and published state (within about 250 ms of a change), and
24
- * their commands. A command's effects on their state, storage or data can arrive just after it resolves.
23
+ * Its own plugin and the plugins it uses: their status and published state (within about 250 ms of a change),
24
+ * their events (with them), and their commands. A command's effects on their state, storage or data, and its events,
25
+ * can arrive just after it resolves.
25
26
  */
26
27
  readonly plugins: PluginsApi;
27
28
  }
package/dist/ui.js CHANGED
@@ -1,9 +1,10 @@
1
1
  import { PluginError } from "./errors.js";
2
+ import { checkedAction } from "./internal/actions.js";
2
3
  import { Collection } from "./internal/collection.js";
3
4
  import { deepFreeze } from "./internal/freeze.js";
4
5
  import { jsonEqual } from "./internal/json-equal.js";
5
6
  import { WorkspaceModels } from "./internal/models.js";
6
- import { commandArgumentsProblem, commandBudgetMs, samePluginStatus } from "./internal/peers.js";
7
+ import { commandArgumentsProblem, commandBudgetMs, eventNameProblem, samePluginStatus } from "./internal/peers.js";
7
8
  import { isUiMessage, UI_CHANNEL, } from "./internal/protocol.js";
8
9
  import { API_VERSION } from "./version.js";
9
10
  export { PluginError } from "./errors.js";
@@ -127,10 +128,6 @@ class UiClient {
127
128
  this.applyData(welcome.data);
128
129
  const apis = this.models.apis();
129
130
  const self = this;
130
- const subscribe = (listeners, callback) => {
131
- listeners.add(callback);
132
- return () => listeners.delete(callback);
133
- };
134
131
  this.context = {
135
132
  get plugin() {
136
133
  return self.plugin;
@@ -178,10 +175,6 @@ class UiClient {
178
175
  if (existing)
179
176
  return existing;
180
177
  const view = this.viewOf(id);
181
- const subscribe = (listeners, callback) => {
182
- listeners.add(callback);
183
- return () => listeners.delete(callback);
184
- };
185
178
  const peer = Object.freeze({
186
179
  id,
187
180
  get status() {
@@ -194,6 +187,20 @@ class UiClient {
194
187
  keys: () => [...view.state.keys()],
195
188
  onChange: (callback) => subscribe(view.stateListeners, callback),
196
189
  }),
190
+ onEvent: (name, callback) => {
191
+ const invalidName = eventNameProblem(name);
192
+ if (invalidName)
193
+ throw new PluginError("invalid", invalidName);
194
+ const problem = this.unreachable(id);
195
+ if (problem)
196
+ throw problem;
197
+ let listeners = view.eventListeners.get(name);
198
+ if (!listeners) {
199
+ listeners = new Set();
200
+ view.eventListeners.set(name, listeners);
201
+ }
202
+ return subscribe(listeners, callback);
203
+ },
197
204
  });
198
205
  this.peerObjects.set(id, peer);
199
206
  return peer;
@@ -206,7 +213,13 @@ class UiClient {
206
213
  viewOf(id) {
207
214
  let view = this.peerViews.get(id);
208
215
  if (!view) {
209
- view = { status: NO_PEERS, state: new Map(), statusListeners: new Set(), stateListeners: new Set() };
216
+ view = {
217
+ status: NO_PEERS,
218
+ state: new Map(),
219
+ statusListeners: new Set(),
220
+ stateListeners: new Set(),
221
+ eventListeners: new Map(),
222
+ };
210
223
  this.peerViews.set(id, view);
211
224
  }
212
225
  return view;
@@ -291,6 +304,10 @@ class UiClient {
291
304
  pending.resolve(message.result);
292
305
  return;
293
306
  }
307
+ if (message.type === "event") {
308
+ this.applyEvent(message);
309
+ return;
310
+ }
294
311
  if (message.type !== "state")
295
312
  return;
296
313
  if (message.config)
@@ -306,6 +323,15 @@ class UiClient {
306
323
  this.applyPeers(message.peers);
307
324
  notify(this.dataListeners, (listener) => listener());
308
325
  }
326
+ applyEvent({ plugin, event, payload }) {
327
+ if (!this.listed.has(plugin))
328
+ return;
329
+ const listeners = this.peerViews.get(plugin)?.eventListeners.get(event);
330
+ if (!listeners?.size)
331
+ return;
332
+ const frozen = deepFreeze(payload);
333
+ notify(listeners, (listener) => listener(frozen));
334
+ }
309
335
  applyData(data) {
310
336
  const changes = this.models.update({
311
337
  activities: this.rows.activities.replace(data.activities),
@@ -341,6 +367,9 @@ class UiClient {
341
367
  return changed;
342
368
  }
343
369
  async write(key, value) {
370
+ const checked = checkedAction(value === undefined ? { name: "storageRemove", key } : { name: "storageSet", key, value });
371
+ if (checked?.ok === false)
372
+ throw new PluginError("invalid", checked.problem);
344
373
  const writeId = ++this.nextWrite;
345
374
  this.pendingWrites.set(key, (this.pendingWrites.get(key) ?? 0) + 1);
346
375
  this.writesSincePush.set(key, writeId);
@@ -385,13 +414,24 @@ class UiClient {
385
414
  });
386
415
  }
387
416
  }
417
+ function subscribe(listeners, callback) {
418
+ if (typeof callback !== "function")
419
+ throw new PluginError("invalid", "Callback must be a function");
420
+ listeners.add(callback);
421
+ return () => listeners.delete(callback);
422
+ }
423
+ const logListenerError = (error) => console.error("[drift-beacon] Error in plugin listener:", error);
388
424
  function notify(listeners, call) {
389
- for (const listener of listeners) {
425
+ for (const listener of [...listeners]) {
426
+ if (!listeners.has(listener))
427
+ continue;
390
428
  try {
391
- call(listener);
429
+ const result = call(listener);
430
+ if (result && typeof result.then === "function")
431
+ result.then(undefined, logListenerError);
392
432
  }
393
433
  catch (error) {
394
- console.error("[drift-beacon] Error in plugin listener:", error);
434
+ logListenerError(error);
395
435
  }
396
436
  }
397
437
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@drift-beacon/plugin",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "The Drift Beacon plugin SDK: APIs for plugin main code and UIs, the driftBeacon() Vite plugin and the dbplugin CLI",
5
5
  "license": "MIT",
6
6
  "author": {