@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.
- package/README.md +95 -11
- package/dist/cli.js +0 -8
- package/dist/dev.js +0 -7
- package/dist/errors.js +0 -1
- package/dist/index.js +0 -5
- package/dist/internal/collection.js +1 -12
- package/dist/internal/compatibility.js +0 -11
- package/dist/internal/freeze.js +8 -0
- package/dist/internal/json-equal.js +0 -1
- package/dist/internal/manifest.js +80 -7
- package/dist/internal/models.js +0 -43
- package/dist/internal/package-layout.js +0 -7
- package/dist/internal/peers.js +55 -0
- package/dist/internal/protocol.js +0 -1
- package/dist/internal/schema.js +156 -0
- package/dist/internal/semver.js +47 -0
- package/dist/main.d.ts +98 -1
- package/dist/main.js +0 -11
- package/dist/pack.js +5 -21
- package/dist/prepare.js +52 -25
- package/dist/types.d.ts +58 -3
- package/dist/types.js +0 -9
- package/dist/ui.d.ts +11 -2
- package/dist/ui.js +157 -25
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -5
- package/dist/vite.js +28 -77
- package/package.json +11 -4
- package/dist/cli.d.ts +0 -2
- package/dist/dev.d.ts +0 -38
- package/dist/internal/actions.d.ts +0 -35
- package/dist/internal/collection.d.ts +0 -15
- package/dist/internal/compatibility.d.ts +0 -26
- package/dist/internal/json-equal.d.ts +0 -3
- package/dist/internal/manifest.d.ts +0 -51
- package/dist/internal/models.d.ts +0 -62
- package/dist/internal/package-layout.d.ts +0 -21
- package/dist/internal/protocol.d.ts +0 -96
- package/dist/internal/rows.d.ts +0 -41
- package/dist/internal.d.ts +0 -17
- package/dist/internal.js +0 -13
- package/dist/pack.d.ts +0 -24
- 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.
|
|
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.
|
|
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.
|
|
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
|
|
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.
|
|
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
|
|
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.
|
|
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
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) {
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { parseApiVersion } from "./compatibility.js";
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
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 (
|
|
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;
|
package/dist/internal/models.js
CHANGED
|
@@ -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;
|