@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 +33 -17
- package/dist/internal/actions.js +23 -1
- package/dist/internal/peers.js +5 -0
- package/dist/main.d.ts +8 -2
- package/dist/types.d.ts +9 -2
- package/dist/ui.d.ts +4 -3
- package/dist/ui.js +53 -13
- package/package.json +1 -1
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
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
-
-
|
|
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;
|
|
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 →
|
|
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 →
|
|
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
|
-
-
|
|
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
|
|
package/dist/internal/actions.js
CHANGED
|
@@ -1 +1,23 @@
|
|
|
1
|
-
|
|
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
|
+
}
|
package/dist/internal/peers.js
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
|
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),
|
|
24
|
-
* their commands. A command's effects on their state, storage or data
|
|
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 = {
|
|
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
|
-
|
|
434
|
+
logListenerError(error);
|
|
395
435
|
}
|
|
396
436
|
}
|
|
397
437
|
}
|
package/package.json
CHANGED