@drift-beacon/plugin 0.1.0 → 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.
Files changed (43) hide show
  1. package/README.md +95 -11
  2. package/dist/cli.js +0 -8
  3. package/dist/dev.js +0 -7
  4. package/dist/errors.js +0 -1
  5. package/dist/index.js +0 -5
  6. package/dist/internal/collection.js +1 -12
  7. package/dist/internal/compatibility.js +0 -11
  8. package/dist/internal/freeze.js +8 -0
  9. package/dist/internal/json-equal.js +0 -1
  10. package/dist/internal/manifest.js +80 -7
  11. package/dist/internal/models.js +0 -43
  12. package/dist/internal/package-layout.js +0 -7
  13. package/dist/internal/peers.js +55 -0
  14. package/dist/internal/protocol.js +0 -1
  15. package/dist/internal/schema.js +156 -0
  16. package/dist/internal/semver.js +47 -0
  17. package/dist/main.d.ts +98 -1
  18. package/dist/main.js +0 -11
  19. package/dist/pack.js +5 -21
  20. package/dist/prepare.js +52 -25
  21. package/dist/types.d.ts +58 -3
  22. package/dist/types.js +0 -9
  23. package/dist/ui.d.ts +11 -2
  24. package/dist/ui.js +157 -25
  25. package/dist/version.d.ts +1 -1
  26. package/dist/version.js +1 -5
  27. package/dist/vite.js +28 -77
  28. package/package.json +11 -4
  29. package/dist/cli.d.ts +0 -2
  30. package/dist/dev.d.ts +0 -38
  31. package/dist/internal/actions.d.ts +0 -35
  32. package/dist/internal/collection.d.ts +0 -15
  33. package/dist/internal/compatibility.d.ts +0 -26
  34. package/dist/internal/json-equal.d.ts +0 -3
  35. package/dist/internal/manifest.d.ts +0 -51
  36. package/dist/internal/models.d.ts +0 -62
  37. package/dist/internal/package-layout.d.ts +0 -21
  38. package/dist/internal/protocol.d.ts +0 -96
  39. package/dist/internal/rows.d.ts +0 -41
  40. package/dist/internal.d.ts +0 -17
  41. package/dist/internal.js +0 -13
  42. package/dist/pack.d.ts +0 -24
  43. package/dist/prepare.d.ts +0 -28
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
package/dist/cli.js CHANGED
@@ -1,11 +1,3 @@
1
- /**
2
- * `dbplugin` (Drift Beacon plugins), run through `bin/dbplugin.js`:
3
- *
4
- * dbplugin prepare [root] Generate .drift-beacon/ (config types and tsconfigs). Lenient: a manifest
5
- * problem only warns, because this runs as the plugin's postinstall.
6
- * dbplugin pack [root] [--out <dir>] Typecheck, build fresh and write <out>/<id>.zip (default releases/).
7
- * Prints only the result JSON on stdout.
8
- */
9
1
  import path from "node:path";
10
2
  import { parseArgs } from "node:util";
11
3
  import { pack } from "./pack.js";
package/dist/dev.js CHANGED
@@ -1,8 +1 @@
1
- /**
2
- * The `pnpm dev` handshake between `driftBeacon()` and the Drift Beacon server. The dev tool writes a
3
- * marker into the package's `dist/`; the server polls for it, connects to Vite's HMR socket and trusts
4
- * the socket only after a `welcome` whose `session` equals the marker's. Messages travel as Vite custom
5
- * events (`{ type: "custom", event, data }`). All wording is rendered on the server; the tool only prints.
6
- */
7
- /** File name of the marker in `dist/`. Written as tmp, then renamed, synchronously. Never packed. */
8
1
  export const DEV_MARKER = ".drift-beacon-dev.json";
package/dist/errors.js CHANGED
@@ -1,4 +1,3 @@
1
- /** Rejection from a platform operation. Check `code` rather than `instanceof`, which differs between SDK copies. */
2
1
  export class PluginError extends Error {
3
2
  code;
4
3
  constructor(code, message) {
package/dist/index.js CHANGED
@@ -1,8 +1,3 @@
1
- /**
2
- * Drift Beacon plugin SDK: the API for main code (runs on the server). Plugin UIs import from `@drift-beacon/plugin/ui`.
3
- *
4
- * At run time the plugin host supplies this module, so `driftBeacon()` keeps it external when building main.
5
- */
6
1
  export { PluginError } from "./errors.js";
7
2
  export { definePlugin } from "./main.js";
8
3
  export { API_VERSION } from "./version.js";
@@ -1,16 +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
- /**
11
- * A collection of plain rows keyed by id, frozen as they arrive. `replace` applies a full set of rows, keeps the
12
- * object of every unchanged row (so identity only changes when data does) and reports the changes.
13
- */
14
3
  export class Collection {
15
4
  rows = new Map();
16
5
  cachedList = null;
@@ -1,12 +1,6 @@
1
1
  import { API_VERSION } from "../version.js";
2
- /**
3
- * API versions this build of Drift Beacon runs: the newest minor of each supported major.
4
- * Before 1.0 only the current version is listed, because every 0.x minor may break plugins.
5
- * From 1.0, the previous major stays listed for at least six months after its successor ships.
6
- */
7
2
  export const SUPPORTED_API_VERSIONS = [API_VERSION];
8
3
  const API_VERSION_PATTERN = /^(0|[1-9]\d*)\.(0|[1-9]\d*)$/;
9
- /** Parse a `major.minor` API version; null when malformed. */
10
4
  export function parseApiVersion(value) {
11
5
  if (typeof value !== "string")
12
6
  return null;
@@ -15,11 +9,6 @@ export function parseApiVersion(value) {
15
9
  return null;
16
10
  return { major: Number(match[1]), minor: Number(match[2]) };
17
11
  }
18
- /**
19
- * Whether a plugin built for `pluginVersion` runs on a build supporting `supported`.
20
- * Before 1.0 the minor must match exactly; from 1.0 the major must match and the plugin's
21
- * minor must not be newer than the host's.
22
- */
23
12
  export function checkApiCompatibility(pluginVersion, supported = SUPPORTED_API_VERSIONS) {
24
13
  const plugin = parseApiVersion(pluginVersion);
25
14
  if (!plugin) {
@@ -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,4 +1,3 @@
1
- /** Structural equality for JSON-shaped values such as data rows and storage items. */
2
1
  export function jsonEqual(a, b) {
3
2
  if (a === b)
4
3
  return true;
@@ -1,9 +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)));
6
- /** Throws a readable error when `manifest` is not a valid plugin manifest. Shared by the server and the pack tool. */
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);
7
7
  export function validateManifest(manifest) {
8
8
  if (!manifest || typeof manifest !== "object" || Array.isArray(manifest))
9
9
  throw new Error("Invalid plugin manifest");
@@ -13,8 +13,8 @@ export function validateManifest(manifest) {
13
13
  if (typeof text !== "string" || !text.trim())
14
14
  throw new Error(`Plugin manifest missing required field: ${field}`);
15
15
  }
16
- const { id, version, apiVersion, author, category, icon, configuration } = value;
17
- 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)) {
18
18
  throw new Error("Plugin id must contain only lowercase letters, numbers and hyphens (maximum 64 characters)");
19
19
  }
20
20
  if (!isStableVersion(version)) {
@@ -35,6 +35,79 @@ export function validateManifest(manifest) {
35
35
  const names = new Set();
36
36
  for (const item of configuration)
37
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
+ }
38
111
  }
39
112
  function validateConfigurationItem(value, names) {
40
113
  const item = value;
@@ -1,14 +1,3 @@
1
- /**
2
- * The models plugins see: `Activity`, `Category` and `Session` objects over the workspace rows.
3
- *
4
- * One `WorkspaceModels` per `ctx`. The plugin host builds it for main code; the UI client builds it
5
- * inside every UI bundle, so this file is part of the UI wire contract: change it only additively
6
- * within an API version.
7
- *
8
- * Every read goes to the latest snapshot in the collections, synchronously. Nothing is applied ahead
9
- * of a snapshot. Lists are computed once per snapshot, so they keep their identity until the data
10
- * changes.
11
- */
12
1
  import { PluginError } from "../errors.js";
13
2
  const ACTIVITY_FIELDS = [
14
3
  "id",
@@ -43,10 +32,6 @@ const SESSION_FIELDS = [
43
32
  ];
44
33
  const gone = (what) => Promise.reject(new PluginError("not-found", `${what} no longer exists`));
45
34
  const compareText = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
46
- /**
47
- * Give a model its data fields as enumerable getters, so spreading, `JSON.stringify` and
48
- * `postMessage` copy the current values, then freeze it.
49
- */
50
35
  function seal(model, fields, data) {
51
36
  for (const field of fields) {
52
37
  Object.defineProperty(model, field, { enumerable: true, get: () => data()[field] });
@@ -170,11 +155,9 @@ export class WorkspaceModels {
170
155
  #activities = new Map();
171
156
  #categories = new Map();
172
157
  #sessions = new Map();
173
- /** The last row seen for each id, so a model whose row was removed keeps its values. */
174
158
  #lastActivity = new Map();
175
159
  #lastCategory = new Map();
176
160
  #lastSession = new Map();
177
- /** Session rows with `Date` fields, one per row version. Built per `ctx`: a `Date` can't be frozen. */
178
161
  #sessionData = new WeakMap();
179
162
  #lists = new Map();
180
163
  #listsSnapshot = [];
@@ -183,7 +166,6 @@ export class WorkspaceModels {
183
166
  this.rows = options.rows;
184
167
  this.#actions = options.actions;
185
168
  }
186
- /** The reads and actions for `ctx`. */
187
169
  apis() {
188
170
  return {
189
171
  activities: {
@@ -205,9 +187,6 @@ export class WorkspaceModels {
205
187
  },
206
188
  };
207
189
  }
208
- // --------------------------------------------------------------------------
209
- // Reads
210
- // --------------------------------------------------------------------------
211
190
  activity(id) {
212
191
  return this.rows.activities.get(id) ? this.#activityModel(id) : undefined;
213
192
  }
@@ -217,7 +196,6 @@ export class WorkspaceModels {
217
196
  session(id) {
218
197
  return this.rows.sessions.get(id) ? this.#sessionModel(id) : undefined;
219
198
  }
220
- /** In the app's order: by category (uncategorized last), then `sortOrder`, then name. */
221
199
  activities(filter = {}) {
222
200
  const { categoryId, trackingType, includeArchived = false } = filter;
223
201
  const key = {
@@ -241,13 +219,11 @@ export class WorkspaceModels {
241
219
  .map((row) => this.#activityModel(row.id));
242
220
  });
243
221
  }
244
- /** By `sortOrder`, then name. */
245
222
  categories() {
246
223
  return this.#list({ list: "categories" }, () => [...this.rows.categories.list()]
247
224
  .sort((a, b) => a.sortOrder - b.sortOrder || compareText(a.name, b.name) || compareText(a.id, b.id))
248
225
  .map((row) => this.#categoryModel(row.id)));
249
226
  }
250
- /** Newest first. */
251
227
  sessions(filter = {}) {
252
228
  const { activityId, status, mine = false } = filter;
253
229
  return this.#list({ list: "sessions", activityId, status, mine }, () => this.rows.sessions
@@ -259,9 +235,6 @@ export class WorkspaceModels {
259
235
  .sort((a, b) => b.at - a.at || compareText(a.row.id, b.row.id))
260
236
  .map(({ row }) => this.#sessionModel(row.id)));
261
237
  }
262
- // --------------------------------------------------------------------------
263
- // Actions
264
- // --------------------------------------------------------------------------
265
238
  async start(activityId) {
266
239
  return this.#result(await this.#actions.start(activityId));
267
240
  }
@@ -274,10 +247,6 @@ export class WorkspaceModels {
274
247
  async discard(sessionId) {
275
248
  await this.#actions.discard(sessionId);
276
249
  }
277
- // --------------------------------------------------------------------------
278
- // Deliveries
279
- // --------------------------------------------------------------------------
280
- /** Turn the row changes of a snapshot into model changes. Call it after the collections were replaced. */
281
250
  update(changes) {
282
251
  return {
283
252
  activities: changes.activities.map((change) => this.#change(change, this.#lastActivity, (id) => this.#activityModel(id), (row) => row)),
@@ -285,18 +254,11 @@ export class WorkspaceModels {
285
254
  sessions: changes.sessions.map((change) => this.#change(change, this.#lastSession, (id) => this.#sessionModel(id), (row) => this.#toSessionData(row))),
286
255
  };
287
256
  }
288
- /**
289
- * The model for a session the app sent outside a snapshot (a live event or an action result):
290
- * the snapshot's model, or, when the row isn't in the snapshot, a removed model holding these values.
291
- */
292
257
  sessionFrom(row) {
293
258
  if (!this.rows.sessions.get(row.id))
294
259
  this.#lastSession.set(row.id, row);
295
260
  return this.#sessionModel(row.id);
296
261
  }
297
- // --------------------------------------------------------------------------
298
- // Rows, for the models
299
- // --------------------------------------------------------------------------
300
262
  activityRow(id) {
301
263
  return latest(this.rows.activities.get(id), this.#lastActivity, id);
302
264
  }
@@ -306,9 +268,6 @@ export class WorkspaceModels {
306
268
  sessionData(id) {
307
269
  return this.#toSessionData(latest(this.rows.sessions.get(id), this.#lastSession, id));
308
270
  }
309
- // --------------------------------------------------------------------------
310
- // Internals
311
- // --------------------------------------------------------------------------
312
271
  #activityModel(id) {
313
272
  let model = this.#activities.get(id);
314
273
  if (!model) {
@@ -357,7 +316,6 @@ export class WorkspaceModels {
357
316
  throw new PluginError("failed", "Drift Beacon did not return the session");
358
317
  return this.sessionFrom(value);
359
318
  }
360
- /** A list computed once per snapshot: the same frozen array until any collection changes. */
361
319
  #list(key, compute) {
362
320
  const snapshot = [this.rows.activities.list(), this.rows.categories.list(), this.rows.sessions.list()];
363
321
  if (snapshot.some((list, index) => list !== this.#listsSnapshot[index])) {
@@ -373,7 +331,6 @@ export class WorkspaceModels {
373
331
  return list;
374
332
  }
375
333
  }
376
- /** The row in the snapshot, remembered as the last one seen; otherwise the last one seen. */
377
334
  function latest(row, last, id) {
378
335
  if (row !== undefined) {
379
336
  if (last.get(id) !== row)
@@ -1,8 +1,3 @@
1
- /**
2
- * The plugin package: `manifest.json`, `package.json`, `main/index.js` (plus chunks) and `ui/index.html`
3
- * (plus assets and the manifest icon). Shared by the pack tool and the server. Pure: the web app bundles
4
- * `/internal`, so no `node:` imports.
5
- */
6
1
  export const PACKAGE_LIMITS = {
7
2
  entries: 2000,
8
3
  fileBytes: 32 * 2 ** 20,
@@ -10,10 +5,8 @@ export const PACKAGE_LIMITS = {
10
5
  archiveBytes: 32 * 2 ** 20,
11
6
  manifestBytes: 256 * 2 ** 10,
12
7
  };
13
- /** The `package.json` every package gets: main is ESM. */
14
8
  export const PACKAGE_JSON = { type: "module", private: true };
15
9
  export const REQUIRED_FILES = ["main/index.js", "ui/index.html"];
16
- /** manifest.json, package.json, main/** and ui/** (directories: main, ui and below). `name` has no trailing slash. */
17
10
  export const isPackagePath = (name, directory) => name.startsWith("main/") ||
18
11
  name.startsWith("ui/") ||
19
12
  (directory ? name === "main" || name === "ui" : name === "manifest.json" || name === "package.json");
@@ -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;
@@ -1,4 +1,3 @@
1
- /** Marks our messages so both sides ignore unrelated `postMessage` traffic (dev tools, HMR). */
2
1
  export const UI_CHANNEL = "drift-beacon-plugin";
3
2
  export function isUiMessage(value) {
4
3
  return (typeof value === "object" &&