@drift-beacon/plugin 0.1.1 → 0.2.0

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
@@ -2,7 +2,7 @@
2
2
 
3
3
  The plugin SDK for [Drift Beacon](https://driftbeacon.app). 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.
4
4
 
5
- Plugin API version: **0.1**. SDK `0.1.x` builds plugins for Drift Beacon servers that run API 0.1.
5
+ Plugin API version: **0.2**. SDK `0.2.x` builds plugins for Drift Beacon servers that run API 0.2.
6
6
 
7
7
  ## Contents
8
8
 
@@ -16,6 +16,8 @@ Plugin API version: **0.1**. SDK `0.1.x` builds plugins for Drift Beacon servers
16
16
  - [Storage](#storage)
17
17
  - [Configuration](#configuration)
18
18
  - [MQTT and HTTP routes](#mqtt-and-http-routes)
19
+ - [Commands between plugins](#commands-between-plugins)
20
+ - [Events, state and status between plugins](#events-state-and-status-between-plugins)
19
21
  - [Errors and limits](#errors-and-limits)
20
22
  - [Developing against your server](#developing-against-your-server)
21
23
  - [Releasing a plugin](#releasing-a-plugin)
@@ -27,7 +29,7 @@ Plugin API version: **0.1**. SDK `0.1.x` builds plugins for Drift Beacon servers
27
29
  mkdir my-plugin && cd my-plugin
28
30
  npm init -y
29
31
  npm pkg set type=module
30
- npm install --save-dev @drift-beacon/plugin@0.1 vite typescript @types/node
32
+ npm install --save-dev @drift-beacon/plugin@0.2 vite typescript @types/node
31
33
  ```
32
34
 
33
35
  `package.json` scripts:
@@ -65,7 +67,7 @@ export default defineConfig({ plugins: [driftBeacon()] }); // with React: [react
65
67
  "id": "hello",
66
68
  "name": "Hello",
67
69
  "version": "1.0.0",
68
- "apiVersion": "0.1",
70
+ "apiVersion": "0.2",
69
71
  "description": "Says hello when a session starts",
70
72
  "author": { "name": "You" },
71
73
  "category": "plugin",
@@ -127,7 +129,7 @@ Then `npm run build` writes `dist/`, and `npm run release` writes `releases/hell
127
129
  my-plugin/
128
130
  ├── package.json # "type": "module"
129
131
  ├── vite.config.ts # plugins: [driftBeacon()]
130
- ├── tsconfig.json # references .drift-beacon/tsconfig.{main,ui}.json
132
+ ├── tsconfig.json # references the main and UI tsconfigs
131
133
  ├── manifest.json # identity, API version, icon, settings
132
134
  ├── main/src/index.ts # main code: runs on the server
133
135
  ├── ui/ # the UI: an ordinary Vite app, shown in an iframe in the web app
@@ -142,14 +144,28 @@ my-plugin/
142
144
  |---|---|
143
145
  | `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. |
144
146
  | `vite build` | Production build into `dist/`: `manifest.json`, `package.json`, `main/index.js` and `ui/`. |
145
- | `dbplugin pack` | Typechecks main and the UI, 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. |
147
+ | `dbplugin pack` | Typechecks main and the UI (see [Compiler options](#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. |
146
148
  | `dbplugin prepare` | Writes `.drift-beacon/`: typed settings and the two tsconfigs. Runs on install and on every build. |
147
149
 
148
150
  Run `dbplugin` through your package scripts or `npx dbplugin …` inside the plugin folder: it's the copy from your installed SDK.
149
151
 
150
152
  - **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.
151
153
  - **The UI** bundles its own dependencies, including `@drift-beacon/plugin/ui`. Use any framework and library versions you like.
152
- - `driftBeacon()` owns Vite's `root` (`ui/`), `base` and `build.outDir`: don't set them. It works with Vite 7 and 8.
154
+ - `driftBeacon()` owns Vite's `root` (`ui/`), `base` and `build.outDir`: don't set them. It works with Vite 7 and 8. Under Vitest it only adds the build, so your tests can share `vite.config.ts` while `npm run dev` runs.
155
+
156
+ ### Compiler options
157
+
158
+ `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:
159
+
160
+ ```json
161
+ { "extends": "../.drift-beacon/tsconfig.ui.json", "compilerOptions": { "jsxImportSource": "preact" } }
162
+ ```
163
+
164
+ ```json
165
+ { "files": [], "references": [{ "path": "./.drift-beacon/tsconfig.main.json" }, { "path": "./ui/tsconfig.json" }] }
166
+ ```
167
+
168
+ Keep `extends`: the generated file brings the settings types, the SDK's module resolution and the source folders.
153
169
 
154
170
  | Import | Used by | Contents |
155
171
  |---|---|---|
@@ -163,12 +179,14 @@ Run `dbplugin` through your package scripts or `npx dbplugin …` inside the plu
163
179
  |---|---|
164
180
  | `id` | Lowercase letters, digits and hyphens, starting with a letter or digit, at most 64 characters |
165
181
  | `version` | Semantic version (`1.2.3`); build metadata is allowed, prereleases are not |
166
- | `apiVersion` | `major.minor` of the SDK you build with (`"0.1"`) |
182
+ | `apiVersion` | `major.minor` of the SDK you build with (`"0.2"`) |
167
183
  | `name`, `description` | Required text |
168
184
  | `author` | `{ name, email? }` |
169
185
  | `category` | `plugin`, `utility` or `ui` |
170
186
  | `icon` | A file name in `ui/public/`, or `""` |
171
187
  | `configuration` | Settings (see [Configuration](#configuration)) |
188
+ | `provides` | Optional: commands, events and state other plugins can use (see [Commands between plugins](#commands-between-plugins) and [Events, state and status](#events-state-and-status-between-plugins)) |
189
+ | `uses` | Optional: the plugins this one uses, by manifest id, with a version range (`{ "magic-cube": "^1.1.0" }`) |
172
190
 
173
191
  Settings items have `name` (unique), `title`, `type` and optional `description` and `required`:
174
192
 
@@ -196,11 +214,13 @@ Settings items have `name` (unique), `title`, `type` and optional `description`
196
214
  | `onDataChange` | ✓ | ✓ | The data changed |
197
215
  | `sessions.onStarted`, `onEnded`, `onMarked` | ✓ | | Live session events |
198
216
  | `storage` | ✓ | ✓ | Key-value storage |
217
+ | `plugins` | ✓ | ✓ | The plugins it uses and itself: status, state and commands (and events, in main code) |
218
+ | `commands`, `events`, `state` | ✓ | | What it provides to other plugins |
199
219
  | `mqtt`, `routes`, `log`, `onStop` | ✓ | | Main code only |
200
220
 
201
221
  ## UI
202
222
 
203
- `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 or storage).
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).
204
224
 
205
225
  With React, re-render on every update:
206
226
 
@@ -294,7 +314,7 @@ Never trigger actions from data changes. Live events fire for everyone's session
294
314
 
295
315
  ## Storage
296
316
 
297
- Per plugin, user and workspace, shared by main code and the UI. Values must be JSON.
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.
298
318
 
299
319
  ```ts
300
320
  const faces = ctx.storage.get<Record<string, string>>("faces") ?? {};
@@ -307,6 +327,8 @@ ctx.storage.onChange((key, value) => { /* changed by main code, a UI or another
307
327
 
308
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.
309
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.
331
+
310
332
  ## MQTT and HTTP routes
311
333
 
312
334
  Main code only.
@@ -322,6 +344,65 @@ ctx.routes.get("status", () => ({ body: { live: ctx.sessions.live({ mine: true }
322
344
  - MQTT uses the workspace's broker; `publish` rejects with `unavailable` without one.
323
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.
324
346
 
347
+ ## Commands between plugins
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.
350
+
351
+ ```jsonc
352
+ // magic-cube/manifest.json
353
+ "provides": { "commands": { "selectPreset": {
354
+ "title": "Select preset",
355
+ "input": { "type": "object", "properties": { "preset": { "type": ["string", "null"] } }, "required": ["preset"] },
356
+ "output": { "type": "object", "properties": { "activePresetId": { "type": ["string", "null"] } } }
357
+ } } }
358
+ // cartridge-reader/manifest.json
359
+ "uses": { "magic-cube": "^1.1.0" }
360
+ ```
361
+
362
+ ```ts
363
+ // magic-cube
364
+ ctx.commands.handle("selectPreset", async ({ preset }, meta) => ({ activePresetId: await select(preset) }));
365
+ // cartridge-reader
366
+ const result = await ctx.plugins.get("magic-cube").command("selectPreset", { preset: "Focus" });
367
+ ```
368
+
369
+ - 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`. Other keywords are rejected.
370
+ - The input is checked before the command runs (`invalid`), the output after (`failed`). Throw a `PluginError` to answer with its code.
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
+ - `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
+ - 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.
375
+
376
+ ## Events, state and status between plugins
377
+
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.
379
+
380
+ ```jsonc
381
+ // magic-cube/manifest.json
382
+ "provides": {
383
+ "events": { "faceChanged": { "title": "Face changed", "payload": { "type": "integer", "minimum": 1, "maximum": 6 } } },
384
+ "state": { "activePreset": { "title": "Active preset", "schema": { "type": ["string", "null"] } } }
385
+ }
386
+ ```
387
+
388
+ ```ts
389
+ // magic-cube
390
+ ctx.events.emit("faceChanged", 3);
391
+ ctx.state.set("activePreset", "focus"); // undefined removes it
392
+ // cartridge-reader
393
+ const cube = ctx.plugins.get("magic-cube");
394
+ cube.onEvent("faceChanged", (face, meta) => ctx.log.info(`Face ${face}, step ${meta.depth}`));
395
+ cube.state.get("activePreset"); // read it in onStart, then follow cube.state.onChange
396
+ cube.onStatusChange(({ state, reason }) => ctx.log.info(state, reason ?? ""));
397
+ ```
398
+
399
+ - `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.
401
+ - In main code, a command handler's events and state changes arrive before its caller's `await` resumes.
402
+ - Listeners continue the chain of what caused them; past 8 steps, events are dropped (with a warning) and state changes call no callbacks.
403
+ - `status.state` is `running`, `starting`, `unavailable`, `disabled`, `incompatible` or `not-installed`, with the resolved `version` and a `reason`.
404
+ - 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.
405
+
325
406
  ## Errors and limits
326
407
 
327
408
  Operations reject with `PluginError`. Check `error.code`, not `instanceof`:
@@ -329,11 +410,13 @@ Operations reject with `PluginError`. Check `error.code`, not `instanceof`:
329
410
  | Code | Meaning |
330
411
  |---|---|
331
412
  | `unsupported` | The app doesn't support this API version or request |
332
- | `invalid` | Bad arguments (tracking a point with `start()`, a value that isn't JSON, …) |
413
+ | `invalid` | Bad arguments (tracking a point with `start()`, a value that isn't JSON, an undeclared event, …) |
333
414
  | `not-found` | The activity or session doesn't exist, or the model was removed |
334
415
  | `unavailable` | Something needed isn't there: no MQTT broker, no answer from the app |
335
416
  | `stopped` | The instance has stopped |
336
417
  | `failed` | Anything else, such as tracking an archived activity |
418
+ | `not-installed`, `disabled`, `incompatible` | The plugin you ran a command on isn't installed, is disabled here, or is outside your `uses` range |
419
+ | `loop`, `timeout` | A chain of commands went deeper than 8 steps; a command didn't answer in time (it may have run) |
337
420
 
338
421
  | | Limit |
339
422
  |---|---|
@@ -341,6 +424,7 @@ Operations reject with `PluginError`. Check `error.code`, not `instanceof`:
341
424
  | Blocking the event loop | The server restarts the plugin host after 10 s |
342
425
  | Actions | 10 s, then `unavailable` |
343
426
  | Route handlers | 30 s, then 504 |
427
+ | Commands to other plugins | 10 s by default (at most 30 s), then `timeout`; 8 steps deep; 64 KiB in and out |
344
428
  | Release package | 32 MiB zipped, 128 MiB unpacked, 2,000 files |
345
429
 
346
430
  ## Developing against your server
@@ -360,7 +444,7 @@ Users add the repository URL in **Plugins → Store** and install from there. Do
360
444
  ## Versions
361
445
 
362
446
  - `apiVersion` in the manifest must match the SDK: `dbplugin`, `vite build` and the server all check it.
363
- - 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.1` to stay on it.
447
+ - 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.
364
448
  - Minor releases may add fields and new string values (such as a new `trackingType`): ignore what you don't recognise.
365
449
 
366
450
  ## License
@@ -1,12 +1,5 @@
1
+ import { deepFreeze } from "./freeze.js";
1
2
  import { jsonEqual } from "./json-equal.js";
2
- function deepFreeze(value) {
3
- if (value && typeof value === "object" && !Object.isFrozen(value)) {
4
- for (const child of Object.values(value))
5
- deepFreeze(child);
6
- Object.freeze(value);
7
- }
8
- return value;
9
- }
10
3
  export class Collection {
11
4
  rows = new Map();
12
5
  cachedList = null;
@@ -0,0 +1,8 @@
1
+ export function deepFreeze(value) {
2
+ if (value && typeof value === "object" && !Object.isFrozen(value)) {
3
+ for (const child of Object.values(value))
4
+ deepFreeze(child);
5
+ Object.freeze(value);
6
+ }
7
+ return value;
8
+ }
@@ -1,8 +1,9 @@
1
1
  import { parseApiVersion } from "./compatibility.js";
2
- const STABLE_VERSION = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/;
3
- export const isStableVersion = (value) => typeof value === "string" &&
4
- STABLE_VERSION.test(value) &&
5
- (value.split("+")[0] ?? "").split(".").every((part) => Number.isSafeInteger(Number(part)));
2
+ import { PEER_LIMITS, PEER_NAME } from "./peers.js";
3
+ import { validateSchema } from "./schema.js";
4
+ import { isStableVersion, validateRange } from "./semver.js";
5
+ const PLUGIN_ID = /^[a-z0-9][a-z0-9-]{0,63}$/;
6
+ export const isManifestId = (id) => PLUGIN_ID.test(id);
6
7
  export function validateManifest(manifest) {
7
8
  if (!manifest || typeof manifest !== "object" || Array.isArray(manifest))
8
9
  throw new Error("Invalid plugin manifest");
@@ -12,8 +13,8 @@ export function validateManifest(manifest) {
12
13
  if (typeof text !== "string" || !text.trim())
13
14
  throw new Error(`Plugin manifest missing required field: ${field}`);
14
15
  }
15
- const { id, version, apiVersion, author, category, icon, configuration } = value;
16
- if (!/^[a-z0-9][a-z0-9-]{0,63}$/.test(id)) {
16
+ const { id, version, apiVersion, author, category, icon, configuration, provides, uses } = value;
17
+ if (!PLUGIN_ID.test(id)) {
17
18
  throw new Error("Plugin id must contain only lowercase letters, numbers and hyphens (maximum 64 characters)");
18
19
  }
19
20
  if (!isStableVersion(version)) {
@@ -34,6 +35,79 @@ export function validateManifest(manifest) {
34
35
  const names = new Set();
35
36
  for (const item of configuration)
36
37
  validateConfigurationItem(item, names);
38
+ if (provides !== undefined)
39
+ validateProvides(provides);
40
+ if (uses !== undefined)
41
+ validateUses(uses, id);
42
+ }
43
+ const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
44
+ const DECLARATION_FIELDS = {
45
+ commands: ["title", "description", "input", "output"],
46
+ events: ["title", "description", "payload"],
47
+ state: ["title", "description", "schema"],
48
+ };
49
+ function validateProvides(provides) {
50
+ if (!isRecord(provides))
51
+ throw new Error("provides: must be an object");
52
+ for (const [kind, declarations] of Object.entries(provides)) {
53
+ if (!Object.hasOwn(DECLARATION_FIELDS, kind))
54
+ throw new Error(`provides: "${kind}" is not supported`);
55
+ const fields = DECLARATION_FIELDS[kind];
56
+ const at = `provides.${kind}`;
57
+ if (!isRecord(declarations))
58
+ throw new Error(`${at}: must be an object`);
59
+ const entries = Object.entries(declarations);
60
+ if (entries.length > PEER_LIMITS.declarations)
61
+ throw new Error(`${at}: at most ${PEER_LIMITS.declarations}`);
62
+ for (const [name, declaration] of entries) {
63
+ if (!PEER_NAME.test(name))
64
+ throw new Error(`${at}: "${name}" must be camelCase (letters and digits, up to 48)`);
65
+ const path = `${at}.${name}`;
66
+ if (!isRecord(declaration))
67
+ throw new Error(`${path}: must be an object`);
68
+ for (const field of Object.keys(declaration))
69
+ if (!fields.includes(field))
70
+ throw new Error(`${path}: "${field}" is not supported`);
71
+ if (typeof declaration.title !== "string" || !declaration.title.trim())
72
+ throw new Error(`${path}: title is required`);
73
+ if (declaration.description !== undefined && typeof declaration.description !== "string")
74
+ throw new Error(`${path}: description must be text`);
75
+ if (kind === "commands") {
76
+ if (declaration.input !== undefined) {
77
+ validateSchema(declaration.input, `${path}.input`);
78
+ if (declaration.input.type !== "object")
79
+ throw new Error(`${path}.input: must be an object schema (named fields)`);
80
+ }
81
+ if (declaration.output !== undefined)
82
+ validateSchema(declaration.output, `${path}.output`);
83
+ }
84
+ else if (kind === "events") {
85
+ if (declaration.payload !== undefined)
86
+ validateSchema(declaration.payload, `${path}.payload`);
87
+ }
88
+ else
89
+ validateSchema(declaration.schema, `${path}.schema`);
90
+ }
91
+ }
92
+ }
93
+ function validateUses(uses, id) {
94
+ if (!isRecord(uses))
95
+ throw new Error("uses: must be an object of plugin ids and version ranges");
96
+ const entries = Object.entries(uses);
97
+ if (entries.length > PEER_LIMITS.uses)
98
+ throw new Error(`uses: at most ${PEER_LIMITS.uses} plugins`);
99
+ for (const [plugin, range] of entries) {
100
+ if (!PLUGIN_ID.test(plugin))
101
+ throw new Error(`uses: "${plugin}" is not a plugin id`);
102
+ if (plugin === id)
103
+ throw new Error("uses: a plugin can always reach itself; don't list it");
104
+ try {
105
+ validateRange(range);
106
+ }
107
+ catch (error) {
108
+ throw new Error(`uses.${plugin}: ${error.message}`);
109
+ }
110
+ }
37
111
  }
38
112
  function validateConfigurationItem(value, names) {
39
113
  const item = value;
@@ -0,0 +1,55 @@
1
+ import { schemaError } from "./schema.js";
2
+ export const PEER_NAME = /^[a-z][A-Za-z0-9]{0,47}$/;
3
+ export const PEER_LIMITS = {
4
+ declarations: 64,
5
+ uses: 32,
6
+ valueBytes: 64 * 1024,
7
+ stateBytes: 256 * 1024,
8
+ depth: 8,
9
+ };
10
+ export const COMMAND_TIMEOUT = { defaultMs: 10_000, maxMs: 30_000, minimumMs: 100 };
11
+ export const commandBudgetMs = (timeoutMs) => Math.min(timeoutMs ?? COMMAND_TIMEOUT.defaultMs, COMMAND_TIMEOUT.maxMs);
12
+ export function commandArgumentsProblem(command, input, options) {
13
+ if (typeof command !== "string" || !command)
14
+ return "Command name must be a non-empty string";
15
+ if (input !== undefined && (typeof input !== "object" || input === null || Array.isArray(input)))
16
+ return "Command input must be an object of named fields";
17
+ const timeoutMs = options?.timeoutMs;
18
+ if (timeoutMs !== undefined && (typeof timeoutMs !== "number" || !(timeoutMs > 0)))
19
+ return "timeoutMs must be a positive number of milliseconds";
20
+ return null;
21
+ }
22
+ export const PLUGIN_ERROR_CODES = [
23
+ "unsupported",
24
+ "invalid",
25
+ "not-found",
26
+ "unavailable",
27
+ "stopped",
28
+ "failed",
29
+ "not-installed",
30
+ "disabled",
31
+ "incompatible",
32
+ "loop",
33
+ "timeout",
34
+ ];
35
+ export function checkedValue(schema, value, path) {
36
+ let json;
37
+ try {
38
+ json = JSON.stringify(value);
39
+ }
40
+ catch {
41
+ return { ok: false, problem: `${path} isn't JSON` };
42
+ }
43
+ if (json === undefined) {
44
+ const problem = schema ? schemaError(schema, undefined, path) : null;
45
+ return problem ? { ok: false, problem } : { ok: true, value: undefined, size: 0 };
46
+ }
47
+ if (!schema)
48
+ return { ok: false, problem: `${path}: none is declared` };
49
+ if (json.length > PEER_LIMITS.valueBytes)
50
+ return { ok: false, problem: `${path} is over ${PEER_LIMITS.valueBytes / 1024} KiB` };
51
+ const plain = JSON.parse(json);
52
+ const problem = schemaError(schema, plain, path);
53
+ return problem ? { ok: false, problem } : { ok: true, value: plain, size: json.length };
54
+ }
55
+ export const samePluginStatus = (a, b) => a.state === b.state && a.version === b.version && a.reason === b.reason;
@@ -0,0 +1,156 @@
1
+ export const SCHEMA_LIMITS = { depth: 6, nodes: 256 };
2
+ const TYPES = ["string", "number", "integer", "boolean", "null", "object", "array"];
3
+ const KEYWORDS = new Set([
4
+ "type",
5
+ "enum",
6
+ "properties",
7
+ "required",
8
+ "additionalProperties",
9
+ "items",
10
+ "minimum",
11
+ "maximum",
12
+ "title",
13
+ "description",
14
+ "default",
15
+ ]);
16
+ const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
17
+ const typesOf = (schema) => typeof schema.type === "string" ? [schema.type] : schema.type;
18
+ export function validateSchema(schema, path) {
19
+ let nodes = 0;
20
+ const visit = (node, at, depth) => {
21
+ if (depth > SCHEMA_LIMITS.depth)
22
+ throw new Error(`${path}: nested more than ${SCHEMA_LIMITS.depth} levels`);
23
+ if (++nodes > SCHEMA_LIMITS.nodes)
24
+ throw new Error(`${path}: more than ${SCHEMA_LIMITS.nodes} schemas`);
25
+ if (!isRecord(node))
26
+ throw new Error(`${at}: type must be one of ${TYPES.join(", ")}`);
27
+ for (const key of Object.keys(node))
28
+ if (!KEYWORDS.has(key))
29
+ throw new Error(`${at}: "${key}" is not supported`);
30
+ const types = typeof node.type === "string" ? [node.type] : node.type;
31
+ if (!Array.isArray(types) ||
32
+ types.length === 0 ||
33
+ types.some((type) => !TYPES.includes(type)) ||
34
+ new Set(types).size !== types.length)
35
+ throw new Error(`${at}: type must be one of ${TYPES.join(", ")}, or a list of them`);
36
+ const schema = node;
37
+ const has = (type) => types.includes(type);
38
+ if (schema.title !== undefined && typeof schema.title !== "string")
39
+ throw new Error(`${at}: title must be text`);
40
+ if (schema.description !== undefined && typeof schema.description !== "string")
41
+ throw new Error(`${at}: description must be text`);
42
+ if (schema.minimum !== undefined || schema.maximum !== undefined) {
43
+ if (!has("number") && !has("integer"))
44
+ throw new Error(`${at}: minimum and maximum need type number or integer`);
45
+ for (const bound of [schema.minimum, schema.maximum])
46
+ if (bound !== undefined && (typeof bound !== "number" || !Number.isFinite(bound)))
47
+ throw new Error(`${at}: minimum and maximum must be numbers`);
48
+ if (schema.minimum !== undefined && schema.maximum !== undefined && schema.minimum > schema.maximum)
49
+ throw new Error(`${at}: minimum exceeds maximum`);
50
+ }
51
+ if (schema.properties !== undefined || schema.required !== undefined || schema.additionalProperties !== undefined) {
52
+ if (!has("object"))
53
+ throw new Error(`${at}: properties need type object`);
54
+ }
55
+ if (has("object")) {
56
+ if (schema.properties !== undefined && !isRecord(schema.properties))
57
+ throw new Error(`${at}: properties must be an object`);
58
+ if (schema.additionalProperties !== undefined && typeof schema.additionalProperties !== "boolean")
59
+ throw new Error(`${at}: additionalProperties must be true or false`);
60
+ for (const [name, property] of Object.entries(schema.properties ?? {}))
61
+ visit(property, `${at}.properties.${name}`, depth + 1);
62
+ if (schema.required !== undefined) {
63
+ if (!Array.isArray(schema.required))
64
+ throw new Error(`${at}: required must be a list of property names`);
65
+ for (const name of schema.required)
66
+ if (typeof name !== "string" || !Object.hasOwn(schema.properties ?? {}, name))
67
+ throw new Error(`${at}: required "${String(name)}" is not in properties`);
68
+ }
69
+ }
70
+ if (schema.items !== undefined && !has("array"))
71
+ throw new Error(`${at}: items need type array`);
72
+ if (has("array")) {
73
+ if (schema.items === undefined)
74
+ throw new Error(`${at}: an array needs items`);
75
+ visit(schema.items, `${at}.items`, depth + 1);
76
+ }
77
+ if (schema.enum !== undefined) {
78
+ if (!Array.isArray(schema.enum) || schema.enum.length === 0)
79
+ throw new Error(`${at}: enum must be a list of values`);
80
+ for (const [index, value] of schema.enum.entries()) {
81
+ const scalar = value === null || ["string", "number", "boolean"].includes(typeof value);
82
+ if (!scalar || valueError({ ...schema, enum: undefined }, value, `${at}.enum[${index}]`))
83
+ throw new Error(`${at}.enum[${index}]: must be a ${types.join(" or ")} value`);
84
+ }
85
+ }
86
+ if (schema.default !== undefined) {
87
+ const error = valueError(schema, schema.default, `${at}.default`);
88
+ if (error)
89
+ throw new Error(error);
90
+ }
91
+ };
92
+ visit(schema, path, 1);
93
+ }
94
+ export function schemaError(schema, value, path) {
95
+ return valueError(schema, value, path);
96
+ }
97
+ function valueError(schema, value, path) {
98
+ const types = typesOf(schema);
99
+ const matches = (type) => {
100
+ switch (type) {
101
+ case "null":
102
+ return value === null;
103
+ case "boolean":
104
+ return typeof value === "boolean";
105
+ case "string":
106
+ return typeof value === "string";
107
+ case "number":
108
+ return typeof value === "number" && Number.isFinite(value);
109
+ case "integer":
110
+ return typeof value === "number" && Number.isInteger(value);
111
+ case "array":
112
+ return Array.isArray(value);
113
+ case "object":
114
+ return isRecord(value) && Object.getPrototypeOf(value) === Object.prototype;
115
+ }
116
+ };
117
+ const type = types.find(matches);
118
+ if (!type)
119
+ return `${path}: expected ${types.join(" or ")}`;
120
+ if (schema.enum && !schema.enum.includes(value))
121
+ return `${path}: must be one of ${schema.enum.map((option) => JSON.stringify(option)).join(", ")}`;
122
+ if (typeof value === "number") {
123
+ if (schema.minimum !== undefined && value < schema.minimum)
124
+ return `${path}: below the minimum ${schema.minimum}`;
125
+ if (schema.maximum !== undefined && value > schema.maximum)
126
+ return `${path}: above the maximum ${schema.maximum}`;
127
+ }
128
+ if (type === "array" && schema.items) {
129
+ for (const [index, item] of value.entries()) {
130
+ const error = valueError(schema.items, item, `${path}[${index}]`);
131
+ if (error)
132
+ return error;
133
+ }
134
+ }
135
+ if (type === "object") {
136
+ const object = value;
137
+ const properties = schema.properties ?? {};
138
+ for (const name of schema.required ?? [])
139
+ if (object[name] === undefined)
140
+ return `${path}: missing "${name}"`;
141
+ for (const [name, property] of Object.entries(object)) {
142
+ if (property === undefined)
143
+ continue;
144
+ const declared = properties[name];
145
+ if (!declared) {
146
+ if (schema.additionalProperties)
147
+ continue;
148
+ return `${path}: unexpected "${name}"`;
149
+ }
150
+ const error = valueError(declared, property, `${path}.${name}`);
151
+ if (error)
152
+ return error;
153
+ }
154
+ }
155
+ return null;
156
+ }
@@ -0,0 +1,47 @@
1
+ const STABLE_VERSION = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/;
2
+ export const isStableVersion = (value) => typeof value === "string" &&
3
+ STABLE_VERSION.test(value) &&
4
+ (value.split("+")[0] ?? "").split(".").every((part) => Number.isSafeInteger(Number(part)));
5
+ const parts = (version) => {
6
+ const [major = 0, minor = 0, patch = 0] = (version.split("+")[0] ?? "").split(".").map(Number);
7
+ return [major, minor, patch];
8
+ };
9
+ export function compareVersions(left, right) {
10
+ if (!isStableVersion(left) || !isStableVersion(right))
11
+ throw new Error("Expected a stable semantic version");
12
+ const a = parts(left);
13
+ const b = parts(right);
14
+ for (let i = 0; i < 3; i++)
15
+ if (a[i] !== b[i])
16
+ return (a[i] ?? 0) > (b[i] ?? 0) ? 1 : -1;
17
+ return 0;
18
+ }
19
+ const RANGE = /^(\^|~|>=)?(.+)$/;
20
+ export function validateRange(range) {
21
+ if (range === "*")
22
+ return;
23
+ const match = typeof range === "string" ? RANGE.exec(range) : null;
24
+ if (!match?.[2] || !isStableVersion(match[2]) || match[2].includes("+"))
25
+ throw new Error(`Unsupported version range "${String(range)}": use 1.2.3, ^1.2.3, ~1.2.3, >=1.2.3 or *`);
26
+ }
27
+ export function satisfiesRange(version, range) {
28
+ validateRange(range);
29
+ if (range === "*")
30
+ return true;
31
+ const [, operator = "", base = ""] = RANGE.exec(range) ?? [];
32
+ if (compareVersions(version, base) < 0)
33
+ return false;
34
+ if (operator === "")
35
+ return compareVersions(version, base) === 0;
36
+ if (operator === ">=")
37
+ return true;
38
+ const [major, minor, patch] = parts(base);
39
+ const [vMajor, vMinor, vPatch] = parts(version);
40
+ if (operator === "~")
41
+ return vMajor === major && vMinor === minor;
42
+ if (major > 0)
43
+ return vMajor === major;
44
+ if (minor > 0)
45
+ return vMajor === 0 && vMinor === minor;
46
+ return vMajor === 0 && vMinor === 0 && vPatch === patch;
47
+ }
package/dist/main.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { ActivitiesApi, CategoriesApi, PluginConfig, PluginInfo, Session, SessionsApi, StorageApi, Unsubscribe, UserInfo, WorkspaceInfo } from "./types.ts";
1
+ import type { ActivitiesApi, CategoriesApi, PeerStateApi, PluginConfig, PluginInfo, PluginPeer, PluginsApi, Session, SessionsApi, StorageApi, Unsubscribe, UserInfo, WorkspaceInfo } from "./types.ts";
2
2
  export type SessionEndReason = "completed" | "discarded";
3
3
  export interface MainSessionsApi extends SessionsApi {
4
4
  /**
@@ -51,6 +51,94 @@ export interface LogApi {
51
51
  warn(...args: unknown[]): void;
52
52
  error(...args: unknown[]): void;
53
53
  }
54
+ /** Who ran a command: another plugin's main code (by manifest id), a plugin UI, or an integration such as Home Assistant. */
55
+ export type CommandCaller = {
56
+ readonly kind: "plugin";
57
+ readonly plugin: string;
58
+ } | {
59
+ readonly kind: "ui";
60
+ readonly plugin: string;
61
+ } | {
62
+ readonly kind: "integration";
63
+ };
64
+ /** The commands this plugin provides. `dbplugin prepare` fills this in from manifest.json `provides` (.drift-beacon/config.d.ts). */
65
+ export interface PluginCommands {
66
+ }
67
+ /** The events this plugin emits. `dbplugin prepare` fills this in from manifest.json `provides`. */
68
+ export interface PluginEvents {
69
+ }
70
+ /** The state this plugin publishes. `dbplugin prepare` fills this in from manifest.json `provides`. */
71
+ export interface PluginState {
72
+ }
73
+ /** A declared map, or any name when nothing is declared (a plugin without generated types). */
74
+ type Declared<T, Fallback> = keyof T extends never ? {
75
+ readonly [name: string]: Fallback;
76
+ } : T;
77
+ type Commands = Declared<PluginCommands, {
78
+ readonly input: Readonly<Record<string, unknown>>;
79
+ readonly output: unknown;
80
+ }>;
81
+ type Events = Declared<PluginEvents, unknown>;
82
+ type States = Declared<PluginState, unknown>;
83
+ type CommandName = keyof Commands & string;
84
+ type EventName = keyof Events & string;
85
+ type StateKey = keyof States & string;
86
+ /** The chain a call belongs to: commands, events and state changes a handler or listener causes continue it. */
87
+ export interface ChainInfo {
88
+ readonly chainId: string;
89
+ /**
90
+ * 1 for anything started outside a handler or listener; one more per step. Past 8, commands fail (`loop`),
91
+ * events are dropped and state changes run no callbacks. 0 for changes Drift Beacon makes itself (a plugin
92
+ * stopping or being replaced).
93
+ */
94
+ readonly depth: number;
95
+ }
96
+ export interface CommandMeta extends ChainInfo {
97
+ readonly caller: CommandCaller;
98
+ /** When the caller stops waiting (epoch milliseconds). */
99
+ readonly deadline: number;
100
+ /** Aborts at the deadline: pass it to `fetch` and the like. */
101
+ readonly signal: AbortSignal;
102
+ }
103
+ /** Handles a command: `input` has the declared fields (`{}` when it declares none); the result is the command's output. */
104
+ export type CommandHandler<K extends CommandName = CommandName> = (input: Commands[K]["input"], meta: CommandMeta) => undefined extends Commands[K]["output"] ? unknown : Commands[K]["output"] | Promise<Commands[K]["output"]>;
105
+ export interface CommandsApi {
106
+ /** Handle a command declared in manifest.json `provides.commands`. One handler per command; throws `invalid` otherwise. */
107
+ handle<K extends CommandName>(name: K, handler: CommandHandler<K>): Unsubscribe;
108
+ }
109
+ export interface EventsApi {
110
+ /**
111
+ * Send a declared event (manifest.json `provides.events`) to the plugins that use this one, and to this plugin
112
+ * itself. Throws `invalid` for an undeclared event or a payload that doesn't match its schema (or is over 64 KiB).
113
+ */
114
+ emit<K extends EventName>(name: K, ...payload: undefined extends Events[K] ? [payload?: Events[K]] : [payload: Events[K]]): void;
115
+ }
116
+ /** The state this plugin publishes to the plugins that use it (manifest.json `provides.state`). Kept in memory while it runs. */
117
+ export interface StateApi {
118
+ get<K extends StateKey>(key: K): States[K] | undefined;
119
+ keys(): readonly StateKey[];
120
+ /**
121
+ * Publish a value (`undefined` removes it). Throws `invalid` for an undeclared key, a value that doesn't match
122
+ * its schema, over 64 KiB, or over 256 KiB of state in all.
123
+ */
124
+ set<K extends StateKey>(key: K, value: States[K] | undefined): void;
125
+ }
126
+ /** Another plugin's state, with the chain of the change that caused each callback. */
127
+ export interface MainPeerStateApi extends PeerStateApi {
128
+ onChange(callback: (key: string, value: unknown, meta: ChainInfo) => void | Promise<void>): Unsubscribe;
129
+ }
130
+ export interface MainPluginPeer extends PluginPeer {
131
+ readonly state: MainPeerStateApi;
132
+ /**
133
+ * Called for each of its events with this name. Commands, events and state changes the callback causes continue
134
+ * its chain. A throw, or a rejection of the promise it returns, stops the instance.
135
+ */
136
+ onEvent(name: string, callback: (payload: unknown, meta: ChainInfo) => void | Promise<void>): Unsubscribe;
137
+ }
138
+ export interface MainPluginsApi extends PluginsApi {
139
+ get(id: string): MainPluginPeer;
140
+ readonly self: MainPluginPeer;
141
+ }
54
142
  /** Everything a plugin's main code can use. One `ctx` per instance: a plugin for one user in one workspace. */
55
143
  export interface MainContext {
56
144
  readonly plugin: PluginInfo;
@@ -66,6 +154,14 @@ export interface MainContext {
66
154
  readonly storage: StorageApi;
67
155
  readonly mqtt: MqttApi;
68
156
  readonly routes: RoutesApi;
157
+ /** The commands this plugin provides to other plugins, its UI and integrations. */
158
+ readonly commands: CommandsApi;
159
+ /** The events this plugin emits to the plugins that use it. */
160
+ readonly events: EventsApi;
161
+ /** The state this plugin publishes to the plugins that use it. */
162
+ readonly state: StateApi;
163
+ /** The plugins this one uses, and itself. */
164
+ readonly plugins: MainPluginsApi;
69
165
  readonly log: LogApi;
70
166
  /**
71
167
  * Run when the instance stops: disabled, reconfigured, updated or shut down. `ctx` still works
@@ -89,4 +185,5 @@ export interface PluginDefinition {
89
185
  * ```
90
186
  */
91
187
  export declare function definePlugin(definition: PluginDefinition): PluginDefinition;
188
+ export {};
92
189
  //# sourceMappingURL=main.d.ts.map
package/dist/pack.js CHANGED
@@ -90,15 +90,14 @@ export async function pack(root, options = {}) {
90
90
  const typescript = require.resolve("typescript/package.json");
91
91
  const { bin } = JSON.parse(await readFile(typescript, "utf8"));
92
92
  const tsc = path.join(path.dirname(typescript), bin.tsc);
93
- for (const project of ["tsconfig.main.json", "tsconfig.ui.json"]) {
94
- const result = spawnSync(process.execPath, [tsc, "-p", path.join(".drift-beacon", project)], {
95
- cwd: pkg,
96
- stdio: ["ignore", 2, 2],
97
- });
93
+ for (const part of ["main", "ui"]) {
94
+ const authored = `${part}/tsconfig.json`;
95
+ const project = existsSync(path.join(pkg, authored)) ? authored : `.drift-beacon/tsconfig.${part}.json`;
96
+ const result = spawnSync(process.execPath, [tsc, "-p", project], { cwd: pkg, stdio: ["ignore", 2, 2] });
98
97
  if (result.error)
99
98
  throw result.error;
100
99
  if (result.status !== 0)
101
- throw new Error(`Type errors (.drift-beacon/${project}); nothing was packed`);
100
+ throw new Error(`Type errors (${project}); nothing was packed`);
102
101
  }
103
102
  temp = await mkdtemp(path.join(tmpdir(), "dbplugin-pack-"));
104
103
  const vite = await import(__rewriteRelativeImportExtension(pathToFileURL(require.resolve("vite")).href));
package/dist/prepare.js CHANGED
@@ -46,7 +46,7 @@ export function prepare(root, { strict }) {
46
46
  const out = path.join(root, ".drift-beacon");
47
47
  mkdirSync(out, { recursive: true });
48
48
  writeIfChanged(path.join(out, ".gitignore"), "*\n");
49
- writeIfChanged(path.join(out, "config.d.ts"), configTypes(manifest.configuration));
49
+ writeIfChanged(path.join(out, "config.d.ts"), configTypes(manifest));
50
50
  writeIfChanged(path.join(out, "tsconfig.main.json"), `${JSON.stringify(TSCONFIG_MAIN, null, 2)}\n`);
51
51
  writeIfChanged(path.join(out, "tsconfig.ui.json"), `${JSON.stringify(TSCONFIG_UI, null, 2)}\n`);
52
52
  return loaded;
@@ -74,23 +74,68 @@ export function loadManifest(root) {
74
74
  throw new Error(`${file}: ${compatibility.message}`);
75
75
  return { manifest, bytes };
76
76
  }
77
- function configTypes(configuration) {
77
+ function configTypes(manifest) {
78
78
  const lines = [
79
79
  "// Generated by dbplugin prepare from manifest.json. Do not edit.",
80
80
  'import type {} from "@drift-beacon/plugin";',
81
81
  'declare module "@drift-beacon/plugin" {',
82
82
  " interface PluginConfig {",
83
83
  ];
84
- for (const item of configuration) {
85
- const doc = [item.title, item.description].filter(Boolean).join(": ").replaceAll("*/", "*\\/");
84
+ const member = (title, description, name, type) => {
85
+ const doc = docComment(title, description);
86
86
  if (doc)
87
- lines.push(` /** ${doc} */`);
87
+ lines.push(` ${doc}`);
88
+ lines.push(` ${name}: ${type};`);
89
+ };
90
+ for (const item of manifest.configuration) {
88
91
  const optional = item.required === true || item.default !== undefined ? "" : "?";
89
- lines.push(` ${JSON.stringify(item.name)}${optional}: ${valueType(item)};`);
92
+ member(item.title, item.description, `${JSON.stringify(item.name)}${optional}`, valueType(item));
90
93
  }
91
- lines.push(" }", "}", "");
94
+ lines.push(" }");
95
+ const { commands = {}, events = {}, state = {} } = manifest.provides ?? {};
96
+ const block = (name, declarations, type) => {
97
+ if (!manifest.provides)
98
+ return;
99
+ lines.push(` interface ${name} {`);
100
+ if (Object.keys(declarations).length === 0)
101
+ lines.push(" readonly [name: string]: never;");
102
+ for (const [key, declaration] of Object.entries(declarations))
103
+ member(declaration.title, declaration.description, JSON.stringify(key), type(declaration));
104
+ lines.push(" }");
105
+ };
106
+ block("PluginCommands", commands, ({ input, output }) => {
107
+ const inputType = input ? tsType(input) : NO_PROPERTIES;
108
+ return `{ readonly input: ${inputType}; readonly output: ${output ? tsType(output) : "undefined"} }`;
109
+ });
110
+ block("PluginEvents", events, ({ payload }) => (payload ? tsType(payload) : "undefined"));
111
+ block("PluginState", state, ({ schema }) => tsType(schema));
112
+ lines.push("}", "");
92
113
  return lines.join("\n");
93
114
  }
115
+ const docComment = (title, description) => {
116
+ const doc = [title, description].filter(Boolean).join(": ").replaceAll("*/", "*\\/");
117
+ return doc ? `/** ${doc} */` : "";
118
+ };
119
+ const NO_PROPERTIES = "{ readonly [key: string]: never }";
120
+ const SCALAR_TYPES = { string: "string", number: "number", integer: "number", boolean: "boolean", null: "null" };
121
+ function tsType(schema) {
122
+ if (schema.enum)
123
+ return unique(schema.enum.map((value) => JSON.stringify(value)));
124
+ const types = typeof schema.type === "string" ? [schema.type] : schema.type;
125
+ return unique(types.map((type) => type === "array"
126
+ ? `readonly (${schema.items ? tsType(schema.items) : "unknown"})[]`
127
+ : type === "object"
128
+ ? objectType(schema)
129
+ : SCALAR_TYPES[type]));
130
+ }
131
+ function objectType(schema) {
132
+ const required = new Set(schema.required ?? []);
133
+ const members = Object.entries(schema.properties ?? {}).map(([name, property]) => `readonly ${JSON.stringify(name)}${required.has(name) ? "" : "?"}: ${tsType(property)}`);
134
+ if (schema.additionalProperties === true)
135
+ members.push("readonly [key: string]: unknown");
136
+ return members.length === 0 ? NO_PROPERTIES : `{ ${members.join("; ")} }`;
137
+ }
138
+ const unique = (types) => [...new Set(types)].join(" | ");
94
139
  const valueType = (item) => item.type !== "dropdown"
95
140
  ? item.type
96
141
  : item.data.length === 0
package/dist/types.d.ts CHANGED
@@ -182,12 +182,67 @@ export interface SessionsApi {
182
182
  export interface StorageApi {
183
183
  get<T = unknown>(key: string): T | undefined;
184
184
  keys(): readonly string[];
185
- /** Applies locally at once and resolves when the server has stored it. */
185
+ /**
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`.
188
+ */
186
189
  set(key: string, value: unknown): Promise<void>;
190
+ /** Applies locally at once and resolves when Drift Beacon has accepted the removal. */
187
191
  remove(key: string): Promise<void>;
188
192
  /** Called for changes made anywhere else: main code, a UI or another device. */
189
193
  onChange(callback: (key: string, value: unknown) => void): Unsubscribe;
190
194
  }
191
- /** Error codes on rejected operations (`error.code`). */
192
- export type PluginErrorCode = "unsupported" | "invalid" | "not-found" | "unavailable" | "stopped" | "failed";
195
+ /**
196
+ * Error codes on rejected operations (`error.code`). For a command to another plugin: `unavailable`
197
+ * means it didn't run (the plugin isn't running here); `timeout` and `stopped` mean it may have run.
198
+ */
199
+ export type PluginErrorCode = "unsupported" | "invalid" | "not-found" | "unavailable" | "stopped" | "failed" | "not-installed" | "disabled" | "incompatible" | "loop" | "timeout";
200
+ export interface CommandOptions {
201
+ /** How long to wait, in milliseconds (default 10 s, at most 30 s). Inside a command, at most what its caller has left. */
202
+ readonly timeoutMs?: number;
203
+ }
204
+ export type PluginStatusState = "running" | "starting" | "unavailable" | "disabled" | "incompatible" | "not-installed";
205
+ /** Whether another plugin can run for this user in this workspace now. */
206
+ export interface PluginStatus {
207
+ readonly state: PluginStatusState;
208
+ /** The version it resolved to, when one did. */
209
+ readonly version?: string;
210
+ /** Why it isn't running, when known. */
211
+ readonly reason?: string;
212
+ }
213
+ /** Another plugin's published state (or this plugin's own, as others see it: after a round trip). Values are read-only. */
214
+ export interface PeerStateApi {
215
+ get<T = unknown>(key: string): T | undefined;
216
+ keys(): readonly string[];
217
+ /** A key changed, or was removed (`undefined`). */
218
+ onChange(callback: (key: string, value: unknown) => void): Unsubscribe;
219
+ }
220
+ /** Another plugin this one uses (listed in manifest.json `uses`), or this plugin itself. */
221
+ export interface PluginPeer {
222
+ /** Its manifest id. */
223
+ readonly id: string;
224
+ /**
225
+ * The same object until it changes. In main code, updated at once when the plugin starts, finishes starting or
226
+ * stops, and otherwise within 250 ms; in a UI, within about 250 ms.
227
+ */
228
+ readonly status: PluginStatus;
229
+ /** Called when its `state`, `version` or `reason` changes. */
230
+ onStatusChange(callback: (status: PluginStatus) => void): Unsubscribe;
231
+ /**
232
+ * Run one of its commands (manifest.json `provides.commands`) as this user in this workspace and wait for
233
+ * the result. Fails fast with `not-installed`, `disabled`, `incompatible` or `unavailable` when it can't run;
234
+ * `timeout` means it may have run. In main code, the state it published reaches this plugin before this resolves;
235
+ * in a UI, it (like storage and workspace data the command changed) can arrive just after.
236
+ */
237
+ command<T = unknown>(name: string, input?: Readonly<Record<string, unknown>>, options?: CommandOptions): Promise<T>;
238
+ /** The state it publishes (manifest.json `provides.state`). */
239
+ readonly state: PeerStateApi;
240
+ }
241
+ /** The plugins this one uses. */
242
+ export interface PluginsApi {
243
+ /** A plugin listed in manifest.json `uses`, or this plugin's own manifest id. Anything else throws `invalid`. */
244
+ get(id: string): PluginPeer;
245
+ /** This plugin itself, as other plugins see it. */
246
+ readonly self: PluginPeer;
247
+ }
193
248
  //# sourceMappingURL=types.d.ts.map
package/dist/ui.d.ts CHANGED
@@ -1,9 +1,10 @@
1
- import type { ActivitiesApi, CategoriesApi, PluginConfig, PluginInfo, SessionsApi, StorageApi, Unsubscribe, UserInfo, WorkspaceInfo } from "./types.ts";
1
+ import type { ActivitiesApi, CategoriesApi, PluginConfig, PluginInfo, PluginsApi, SessionsApi, StorageApi, Unsubscribe, UserInfo, WorkspaceInfo } from "./types.ts";
2
2
  export { PluginError } from "./errors.ts";
3
3
  export type * from "./types.ts";
4
4
  export { API_VERSION } from "./version.ts";
5
5
  /** Everything a plugin UI can use. */
6
6
  export interface UiContext {
7
+ /** The installed plugin; it can change while the UI is open, for example to a new version. */
7
8
  readonly plugin: PluginInfo;
8
9
  readonly user: UserInfo;
9
10
  readonly workspace: WorkspaceInfo;
@@ -12,9 +13,17 @@ export interface UiContext {
12
13
  readonly activities: ActivitiesApi;
13
14
  readonly categories: CategoriesApi;
14
15
  readonly sessions: SessionsApi;
15
- /** Called after each update from the app (workspace data, config or storage) and after local storage writes. */
16
+ /**
17
+ * Called after each update from the app (workspace data, config, storage, or other plugins' status and state) and
18
+ * after local storage writes.
19
+ */
16
20
  onDataChange(callback: () => void): Unsubscribe;
17
21
  readonly storage: StorageApi;
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.
25
+ */
26
+ readonly plugins: PluginsApi;
18
27
  }
19
28
  export interface ConnectOptions {
20
29
  /** How long to wait for the app to answer. Default 10 seconds. */
package/dist/ui.js CHANGED
@@ -1,13 +1,30 @@
1
1
  import { PluginError } from "./errors.js";
2
2
  import { Collection } from "./internal/collection.js";
3
+ import { deepFreeze } from "./internal/freeze.js";
3
4
  import { jsonEqual } from "./internal/json-equal.js";
4
5
  import { WorkspaceModels } from "./internal/models.js";
6
+ import { commandArgumentsProblem, commandBudgetMs, samePluginStatus } from "./internal/peers.js";
5
7
  import { isUiMessage, UI_CHANNEL, } from "./internal/protocol.js";
6
8
  import { API_VERSION } from "./version.js";
7
9
  export { PluginError } from "./errors.js";
8
10
  export { API_VERSION } from "./version.js";
9
11
  const HELLO_INTERVAL_MS = 250;
10
12
  const REQUEST_TIMEOUT_MS = 10_000;
13
+ const COMMAND_GRACE_MS = 2_000;
14
+ const NO_PEERS = Object.freeze({
15
+ state: "unavailable",
16
+ reason: "This version of Drift Beacon doesn't share other plugins with plugin UIs",
17
+ });
18
+ const UNLISTED = Object.freeze({ state: "unavailable", reason: "No longer in manifest.json uses" });
19
+ const PAGE = (() => {
20
+ const bytes = new Uint8Array(6);
21
+ if (typeof crypto !== "undefined" && typeof crypto.getRandomValues === "function")
22
+ crypto.getRandomValues(bytes);
23
+ else
24
+ for (let i = 0; i < bytes.length; i++)
25
+ bytes[i] = Math.floor(Math.random() * 256);
26
+ return Array.from(bytes, (byte) => byte.toString(36).padStart(2, "0")).join("");
27
+ })();
11
28
  let connection = null;
12
29
  export function connect(options = {}) {
13
30
  connection ??= open(options.timeoutMs ?? 10_000);
@@ -63,6 +80,7 @@ class UiClient {
63
80
  post;
64
81
  context;
65
82
  config;
83
+ plugin;
66
84
  rows = {
67
85
  activities: new Collection(),
68
86
  categories: new Collection(),
@@ -81,9 +99,19 @@ class UiClient {
81
99
  nextWrite = 0;
82
100
  pending = new Map();
83
101
  nextRequest = 0;
102
+ peersShared;
103
+ listed = new Set();
104
+ selfId;
105
+ peerViews = new Map();
106
+ peerObjects = new Map();
84
107
  constructor(welcome, post) {
85
108
  this.post = post;
86
109
  this.config = welcome.config;
110
+ this.plugin = welcome.plugin;
111
+ this.peersShared = welcome.peers !== undefined;
112
+ this.selfId = welcome.peers?.self ?? welcome.plugin.id;
113
+ if (welcome.peers)
114
+ this.applyPeers(welcome.peers);
87
115
  this.serverStorage = welcome.storage;
88
116
  this.storage = new Map(Object.entries(welcome.storage));
89
117
  this.models = new WorkspaceModels({
@@ -104,7 +132,9 @@ class UiClient {
104
132
  return () => listeners.delete(callback);
105
133
  };
106
134
  this.context = {
107
- plugin: welcome.plugin,
135
+ get plugin() {
136
+ return self.plugin;
137
+ },
108
138
  user: welcome.user,
109
139
  workspace: welcome.workspace,
110
140
  get config() {
@@ -117,20 +147,136 @@ class UiClient {
117
147
  storage: {
118
148
  get: (key) => this.storage.get(key),
119
149
  keys: () => [...this.storage.keys()],
120
- set: (key, value) => this.write(key, JSON.parse(JSON.stringify(value))),
150
+ set: (key, value) => {
151
+ if (value === undefined)
152
+ return this.write(key, undefined);
153
+ let plain;
154
+ try {
155
+ plain = JSON.parse(JSON.stringify(value));
156
+ }
157
+ catch {
158
+ return Promise.reject(new PluginError("invalid", `Storage value for "${key}" is not JSON-compatible`));
159
+ }
160
+ return this.write(key, plain);
161
+ },
121
162
  remove: (key) => this.write(key, undefined),
122
163
  onChange: (callback) => subscribe(this.storageListeners, callback),
123
164
  },
165
+ plugins: {
166
+ get: (id) => this.peer(id),
167
+ get self() {
168
+ return self.peer(self.selfId);
169
+ },
170
+ },
124
171
  };
125
172
  }
173
+ peer(id) {
174
+ const problem = this.unreachable(id);
175
+ if (problem)
176
+ throw problem;
177
+ const existing = this.peerObjects.get(id);
178
+ if (existing)
179
+ return existing;
180
+ const view = this.viewOf(id);
181
+ const subscribe = (listeners, callback) => {
182
+ listeners.add(callback);
183
+ return () => listeners.delete(callback);
184
+ };
185
+ const peer = Object.freeze({
186
+ id,
187
+ get status() {
188
+ return view.status;
189
+ },
190
+ onStatusChange: (callback) => subscribe(view.statusListeners, callback),
191
+ command: (name, input, options) => this.command(id, name, input, options),
192
+ state: Object.freeze({
193
+ get: (key) => view.state.get(key),
194
+ keys: () => [...view.state.keys()],
195
+ onChange: (callback) => subscribe(view.stateListeners, callback),
196
+ }),
197
+ });
198
+ this.peerObjects.set(id, peer);
199
+ return peer;
200
+ }
201
+ unreachable(id) {
202
+ return typeof id !== "string" || (this.peersShared && !this.listed.has(id))
203
+ ? new PluginError("invalid", `List "${String(id)}" in manifest.json uses to reach it`)
204
+ : null;
205
+ }
206
+ viewOf(id) {
207
+ let view = this.peerViews.get(id);
208
+ if (!view) {
209
+ view = { status: NO_PEERS, state: new Map(), statusListeners: new Set(), stateListeners: new Set() };
210
+ this.peerViews.set(id, view);
211
+ }
212
+ return view;
213
+ }
214
+ applyPeers(peers) {
215
+ this.peersShared = true;
216
+ this.selfId = peers.self;
217
+ this.listed = new Set(Object.keys(peers.plugins));
218
+ const statusChanges = [];
219
+ const stateChanges = [];
220
+ const unlisted = [...this.peerViews.keys()]
221
+ .filter((id) => !this.listed.has(id))
222
+ .map((id) => [id, { status: UNLISTED, state: {} }]);
223
+ for (const [id, next] of [...Object.entries(peers.plugins), ...unlisted]) {
224
+ const view = this.viewOf(id);
225
+ if (!samePluginStatus(view.status, next.status)) {
226
+ const status = deepFreeze({ ...next.status });
227
+ view.status = status;
228
+ statusChanges.push(() => notify(view.statusListeners, (listener) => listener(status)));
229
+ }
230
+ const state = new Map(Object.entries(next.state).map(([key, value]) => [key, deepFreeze(value)]));
231
+ for (const key of new Set([...view.state.keys(), ...state.keys()])) {
232
+ if (view.state.has(key) === state.has(key) && jsonEqual(view.state.get(key), state.get(key)))
233
+ continue;
234
+ const value = state.get(key);
235
+ stateChanges.push(() => notify(view.stateListeners, (listener) => listener(key, value)));
236
+ }
237
+ view.state = state;
238
+ }
239
+ for (const change of [...statusChanges, ...stateChanges])
240
+ change();
241
+ }
242
+ command(plugin, command, input, options) {
243
+ const unreachable = this.unreachable(plugin);
244
+ if (unreachable)
245
+ return Promise.reject(unreachable);
246
+ const problem = commandArgumentsProblem(command, input, options);
247
+ if (problem)
248
+ return Promise.reject(new PluginError("invalid", problem));
249
+ let plain;
250
+ try {
251
+ plain = input === undefined ? undefined : JSON.parse(JSON.stringify(input));
252
+ }
253
+ catch {
254
+ return Promise.reject(new PluginError("invalid", "Command input must be JSON-compatible"));
255
+ }
256
+ const timeoutMs = options?.timeoutMs;
257
+ return this.request({
258
+ method: "plugins.command",
259
+ params: {
260
+ plugin,
261
+ command,
262
+ ...(plain === undefined ? {} : { input: plain }),
263
+ ...(timeoutMs === undefined ? {} : { timeoutMs }),
264
+ },
265
+ }, {
266
+ ms: commandBudgetMs(timeoutMs) + COMMAND_GRACE_MS,
267
+ error: () => new PluginError("timeout", `${plugin} didn't answer "${command}" in time; it may have run`),
268
+ });
269
+ }
126
270
  receive(message) {
127
271
  if (message.type === "welcome") {
272
+ this.plugin = message.plugin;
128
273
  this.receive({
129
274
  channel: message.channel,
130
275
  type: "state",
131
276
  config: message.config,
132
277
  data: message.data,
133
278
  storage: message.storage,
279
+ ...(message.peers ? { peers: message.peers } : {}),
134
280
  });
135
281
  return;
136
282
  }
@@ -156,6 +302,8 @@ class UiClient {
156
302
  this.writesSincePush.clear();
157
303
  this.reconcileStorage();
158
304
  }
305
+ if (message.peers)
306
+ this.applyPeers(message.peers);
159
307
  notify(this.dataListeners, (listener) => listener());
160
308
  }
161
309
  applyData(data) {
@@ -221,16 +369,19 @@ class UiClient {
221
369
  notify(this.dataListeners, (listener) => listener());
222
370
  }
223
371
  }
224
- request(request) {
225
- const id = String(++this.nextRequest);
372
+ request(request, timeout = {
373
+ ms: REQUEST_TIMEOUT_MS,
374
+ error: () => new PluginError("unavailable", `${request.method} timed out`),
375
+ }) {
376
+ const id = `${PAGE}-${++this.nextRequest}`;
226
377
  return new Promise((resolve, reject) => {
227
378
  this.pending.set(id, { resolve, reject });
228
379
  this.post({ channel: UI_CHANNEL, type: "request", id, ...request });
229
380
  setTimeout(() => {
230
381
  if (!this.pending.delete(id))
231
382
  return;
232
- reject(new PluginError("unavailable", `${request.method} timed out`));
233
- }, REQUEST_TIMEOUT_MS);
383
+ reject(timeout.error());
384
+ }, timeout.ms);
234
385
  });
235
386
  }
236
387
  }
package/dist/version.d.ts CHANGED
@@ -2,5 +2,5 @@
2
2
  * The plugin API version this SDK describes, `major.minor`. It matches the package version's
3
3
  * major and minor. Before 1.0 every minor version may break plugins.
4
4
  */
5
- export declare const API_VERSION = "0.1";
5
+ export declare const API_VERSION = "0.2";
6
6
  //# sourceMappingURL=version.d.ts.map
package/dist/version.js CHANGED
@@ -1 +1 @@
1
- export const API_VERSION = "0.1";
1
+ export const API_VERSION = "0.2";
package/dist/vite.js CHANGED
@@ -40,6 +40,8 @@ export function driftBeacon() {
40
40
  return { root: path.join(pkg, "ui"), base: "./", build: { outDir: path.join(dist, "ui"), emptyOutDir: true } };
41
41
  },
42
42
  configureServer(server) {
43
+ if (process.env.VITEST)
44
+ return;
43
45
  serveDev(server, devStates.get(pkg) ?? startDev(pkg, dist, manifest, manifestBytes, server.config.logger));
44
46
  },
45
47
  async writeBundle() {
@@ -90,6 +92,7 @@ function startDev(pkg, dist, manifest, bytes, logger) {
90
92
  manifest,
91
93
  manifestBytes: bytes,
92
94
  watcher: Promise.resolve(undefined),
95
+ idle: Promise.resolve(),
93
96
  buildId: hashDist(dist),
94
97
  uiUrl: null,
95
98
  socket: null,
@@ -110,8 +113,13 @@ async function watchMain(state) {
110
113
  const staging = path.join(state.pkg, ".drift-beacon/main-next");
111
114
  const options = { outDir: staging, minify: false, sourcemap: true, watch: {}, logLevel: "silent" };
112
115
  const watcher = (await vite.build(mainBuild(vite, state.pkg, options)));
116
+ let built = () => { };
113
117
  watcher.on("event", (event) => {
114
- if (event.code === "BUNDLE_END") {
118
+ if (event.code === "START")
119
+ state.idle = new Promise((resolve) => (built = resolve));
120
+ else if (event.code === "END")
121
+ built();
122
+ else if (event.code === "BUNDLE_END") {
115
123
  try {
116
124
  moveFiles(staging, path.join(state.dist, "main"));
117
125
  const changed = writeDevMarker(state);
@@ -132,6 +140,7 @@ async function watchMain(state) {
132
140
  });
133
141
  }
134
142
  });
143
+ await new Promise((resolve) => watcher.on("event", () => resolve()));
135
144
  return watcher;
136
145
  }
137
146
  function serveDev(server, state) {
@@ -154,14 +163,13 @@ function serveDev(server, state) {
154
163
  print(state.logger, { level: "warn", text: noServerHint(state) });
155
164
  }, HINT_DELAY_MS).unref();
156
165
  });
157
- const close = server.close.bind(server);
166
+ const { watcher } = server;
167
+ const closeWatcher = watcher.close.bind(watcher);
158
168
  let open = true;
159
- server.close = () => {
160
- if (open) {
161
- open = false;
162
- release(state);
163
- }
164
- return close();
169
+ watcher.close = async () => {
170
+ const released = open ? release(state) : undefined;
171
+ open = false;
172
+ await Promise.all([closeWatcher(), released]);
165
173
  };
166
174
  server.ws.on("drift-beacon:hello", (data, client) => {
167
175
  if (data?.session !== state.session)
@@ -183,13 +191,15 @@ function serveDev(server, state) {
183
191
  print(state.logger, data);
184
192
  });
185
193
  }
186
- function release(state) {
194
+ async function release(state) {
187
195
  if (--state.servers > 0)
188
196
  return;
189
197
  clearTimeout(state.hint);
190
198
  if (devStates.get(state.pkg) === state)
191
199
  devStates.delete(state.pkg);
192
- void state.watcher.then((watcher) => watcher?.close());
200
+ const watcher = await state.watcher;
201
+ await state.idle;
202
+ await watcher?.close();
193
203
  }
194
204
  function updateManifest(state) {
195
205
  try {
@@ -290,8 +300,14 @@ function writeMarker(dist, { pluginId, session, uiUrl }) {
290
300
  function hashDist(dist) {
291
301
  if (!existsSync(path.join(dist, "main/index.js")))
292
302
  return null;
303
+ const ui = existsSync(path.join(dist, "ui"))
304
+ ? readdirSync(path.join(dist, "ui"), { recursive: true, withFileTypes: true })
305
+ .filter((entry) => entry.isFile())
306
+ .map((entry) => path.relative(dist, path.join(entry.parentPath, entry.name)).split(path.sep).join("/"))
307
+ .sort()
308
+ : [];
293
309
  const hash = createHash("sha256");
294
- for (const name of ["manifest.json", "main/index.js", "main/index.js.map", "ui/index.html"]) {
310
+ for (const name of ["manifest.json", "main/index.js", "main/index.js.map", ...ui]) {
295
311
  const file = path.join(dist, name);
296
312
  if (existsSync(file))
297
313
  hash.update(`${name}\0`).update(readFileSync(file));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@drift-beacon/plugin",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
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": {