@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 +95 -11
- package/dist/internal/collection.js +1 -8
- package/dist/internal/freeze.js +8 -0
- package/dist/internal/manifest.js +80 -6
- package/dist/internal/peers.js +55 -0
- package/dist/internal/schema.js +156 -0
- package/dist/internal/semver.js +47 -0
- package/dist/main.d.ts +98 -1
- package/dist/pack.js +5 -6
- package/dist/prepare.js +52 -7
- package/dist/types.d.ts +58 -3
- package/dist/ui.d.ts +11 -2
- package/dist/ui.js +157 -6
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/vite.js +27 -11
- package/package.json +1 -1
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
|
|
@@ -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;
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { parseApiVersion } from "./compatibility.js";
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
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 (
|
|
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
|
|
94
|
-
const
|
|
95
|
-
|
|
96
|
-
|
|
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 (
|
|
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
|
|
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(
|
|
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
|
-
|
|
85
|
-
const doc =
|
|
84
|
+
const member = (title, description, name, type) => {
|
|
85
|
+
const doc = docComment(title, description);
|
|
86
86
|
if (doc)
|
|
87
|
-
lines.push(`
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
192
|
-
|
|
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
|
-
/**
|
|
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
|
|
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) =>
|
|
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
|
-
|
|
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(
|
|
233
|
-
},
|
|
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.
|
|
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
|
+
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 === "
|
|
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
|
|
166
|
+
const { watcher } = server;
|
|
167
|
+
const closeWatcher = watcher.close.bind(watcher);
|
|
158
168
|
let open = true;
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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
|
-
|
|
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",
|
|
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