@drift-beacon/plugin 0.1.1 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +119 -19
- package/dist/internal/actions.js +23 -1
- 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 +60 -0
- package/dist/internal/schema.js +156 -0
- package/dist/internal/semver.js +47 -0
- package/dist/main.d.ts +106 -3
- package/dist/pack.js +5 -6
- package/dist/prepare.js +52 -7
- package/dist/types.d.ts +66 -4
- package/dist/ui.d.ts +12 -2
- package/dist/ui.js +204 -13
- 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
|
|
|
@@ -183,9 +201,9 @@ Settings items have `name` (unique), `title`, `type` and optional `description`
|
|
|
183
201
|
|
|
184
202
|
`main/src/index.ts` default-exports `definePlugin({ onStart(ctx) { … } })`. The server runs one **instance** for each user and workspace that enabled the plugin; each gets its own `ctx`.
|
|
185
203
|
|
|
186
|
-
- **Start.** `onStart(ctx)` may be async and must finish within 15 seconds. If it throws, the instance doesn't start.
|
|
204
|
+
- **Start.** `onStart(ctx)` may be async and must finish within 15 seconds. Callbacks and routes work as soon as they are registered. If it throws, the instance doesn't start.
|
|
187
205
|
- **Register through `ctx`.** Callbacks, routes and MQTT subscriptions made through `ctx` are released when the instance stops. Keep per-instance state inside `onStart`, never at module scope: the module is shared by every instance.
|
|
188
|
-
- **Stop.** `ctx.onStop(fn)` callbacks run newest first, 5 seconds in total. Afterwards every `ctx` call throws `PluginError` with code `stopped`.
|
|
206
|
+
- **Stop.** `ctx.onStop(fn)` callbacks run newest first, 5 seconds in total, whatever stops the instance (a fault or server shutdown too, even when something else stops it meanwhile). Afterwards every `ctx` call throws `PluginError` with code `stopped`. A server that is killed rather than shut down cuts them short.
|
|
189
207
|
- **Faults.** An uncaught error or unhandled rejection stops only that instance, which is retried after 5, 30 and 120 seconds.
|
|
190
208
|
|
|
191
209
|
| `ctx` member | Main | UI | |
|
|
@@ -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, events and commands |
|
|
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; not their events).
|
|
204
224
|
|
|
205
225
|
With React, re-render on every update:
|
|
206
226
|
|
|
@@ -281,7 +301,7 @@ await ctx.sessions.end(sessionId); // or session.end(), activity.end()
|
|
|
281
301
|
await ctx.sessions.discard(sessionId); // or session.discard()
|
|
282
302
|
```
|
|
283
303
|
|
|
284
|
-
Actions run as the current user and resolve once `ctx` shows their result. Starting a session ends the user's other live session, as in the app.
|
|
304
|
+
Actions run as the current user and resolve once `ctx` shows their result. Starting a session ends the user's other live session, as in the app. An id that isn't a non-empty string rejects with `invalid`.
|
|
285
305
|
|
|
286
306
|
| | Live session events | Data change events |
|
|
287
307
|
|---|---|---|
|
|
@@ -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.
|
|
317
|
+
Per plugin, user and workspace, shared by main code and the UI. Keys are non-empty strings and values must be JSON: anything else rejects with `invalid`, and setting `undefined` removes the key. `set` and `remove` apply at once and resolve when Drift Beacon has accepted the write.
|
|
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
|
+
The app blocks opening a UI when required settings are missing or blank. This presence check does not validate every setting: if saved values fail schema validation, the UI receives defaults under the saved values while main does not run. Check values before relying on them.
|
|
331
|
+
|
|
310
332
|
## MQTT and HTTP routes
|
|
311
333
|
|
|
312
334
|
Main code only.
|
|
@@ -319,8 +341,83 @@ ctx.routes.post("webhook", async ({ body }) => ({ status: 202, body: { ok: true
|
|
|
319
341
|
ctx.routes.get("status", () => ({ body: { live: ctx.sessions.live({ mine: true }).length } }));
|
|
320
342
|
```
|
|
321
343
|
|
|
322
|
-
- MQTT uses the workspace's broker; `publish` rejects with `unavailable` without one.
|
|
323
|
-
- Routes are served at `<server>/api/plugins/<plugin id>/api/<name>` (`ctx.plugin.apiPath` is the path up to `/api`). Callers send `Authorization: Bearer <workspace API key>`, which picks the user and workspace. Handlers return `{ status?, body? }`; `body` is sent as JSON.
|
|
344
|
+
- MQTT uses the workspace's broker; `publish` rejects with `unavailable` without one or while it isn't connected, and with `invalid` for an empty topic or a payload that isn't a string (nothing is sent).
|
|
345
|
+
- Routes are served at `<server>/api/plugins/<plugin id>/api/<name>` (`ctx.plugin.apiPath` is the path up to `/api`). Callers send `Authorization: Bearer <workspace API key>`, which picks the user and workspace. Handlers return `{ status?, body? }`; `body` is sent as JSON. While `onStart` runs, a route not registered yet answers 503 (retry after 5 s).
|
|
346
|
+
|
|
347
|
+
## Commands between plugins
|
|
348
|
+
|
|
349
|
+
Main code provides commands; main code, UIs and Home Assistant run them. A plugin declares the commands it provides in `manifest.json` and handles them; another plugin lists it in `uses` and runs them, as the same user in the same workspace.
|
|
350
|
+
|
|
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
|
+
- In Home Assistant, each command is an action, `drift_beacon.<manifest id>_<command in snake_case>` (`drift_beacon.magic_cube_select_preset`), on a workspace's device: its fields are your input's properties; if you declare `output`, a call on one workspace can ask for the response `{ output }`. It runs as that workspace's user with `meta.caller` `{ kind: "integration" }` and 10 s, and needs no `uses`. Renaming a command or input field breaks automations as it breaks other plugins.
|
|
375
|
+
- `dbplugin prepare` types what you provide (`.drift-beacon/config.d.ts`): `ctx.commands.handle`, `ctx.events.emit` and `ctx.state` take only declared names, with their declared types (none for a kind you declare nothing of).
|
|
376
|
+
- Plugins you use aren't typed for you: keep a copy of the types you need, written from their manifest. `command<T>()` and `state.get<T>()` take the type, and an event's payload is `unknown` until you narrow it. Update the copy along with your `uses` range.
|
|
377
|
+
|
|
378
|
+
```ts
|
|
379
|
+
// cartridge-reader: what it uses from magic-cube ^1.1.0, kept by hand
|
|
380
|
+
interface SelectPresetOutput {
|
|
381
|
+
activePresetId: string | null;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
const cube = ctx.plugins.get("magic-cube");
|
|
385
|
+
const { activePresetId } = await cube.command<SelectPresetOutput>("selectPreset", { preset: "Focus" });
|
|
386
|
+
const preset = cube.state.get<string | null>("activePreset");
|
|
387
|
+
cube.onEvent("faceChanged", (face) => showFace(face as number));
|
|
388
|
+
```
|
|
389
|
+
|
|
390
|
+
## Events, state and status between plugins
|
|
391
|
+
|
|
392
|
+
Main code emits and publishes; main code and UIs receive them. A plugin emits the events and publishes the state it declares in `provides`; the plugins that use it (and the plugin itself, through `ctx.plugins.self`) receive them, and see its status.
|
|
393
|
+
|
|
394
|
+
```jsonc
|
|
395
|
+
// magic-cube/manifest.json
|
|
396
|
+
"provides": {
|
|
397
|
+
"events": { "faceChanged": { "title": "Face changed", "payload": { "type": "integer", "minimum": 1, "maximum": 6 } } },
|
|
398
|
+
"state": { "activePreset": { "title": "Active preset", "schema": { "type": ["string", "null"] } } }
|
|
399
|
+
}
|
|
400
|
+
```
|
|
401
|
+
|
|
402
|
+
```ts
|
|
403
|
+
// magic-cube
|
|
404
|
+
ctx.events.emit("faceChanged", 3);
|
|
405
|
+
ctx.state.set("activePreset", "focus"); // undefined removes it
|
|
406
|
+
// cartridge-reader
|
|
407
|
+
const cube = ctx.plugins.get("magic-cube");
|
|
408
|
+
cube.onEvent("faceChanged", (face, meta) => ctx.log.info(`Face ${face}, step ${meta.depth}`));
|
|
409
|
+
cube.state.get("activePreset"); // read it in onStart, then follow cube.state.onChange
|
|
410
|
+
cube.onStatusChange(({ state, reason }) => ctx.log.info(state, reason ?? ""));
|
|
411
|
+
```
|
|
412
|
+
|
|
413
|
+
- `emit` and `set` throw `invalid` for an undeclared name, a value that doesn't match its schema, or over 64 KiB (256 KiB of state in all). Received values are frozen.
|
|
414
|
+
- Events reach listeners registered by then (from `onStart` on; in a UI, right after `connect()`); earlier ones are missed. State is kept in memory while the provider runs, cleared when it stops, never persisted.
|
|
415
|
+
- A plugin can emit 50 events a second on average, in bursts up to 100; Drift Beacon drops the rest, with a warning in your console at most every 10 s (each after the first says how many it dropped). For a value that changes faster, publish the latest as state (not limited) or send fewer, bigger events.
|
|
416
|
+
- In main code, a command handler's events and state changes arrive before its caller's `await` resumes.
|
|
417
|
+
- Listeners continue the chain of what caused them; past 8 steps, events are dropped (with a warning) and state changes call no callbacks.
|
|
418
|
+
- `status.state` is `running`, `starting`, `unavailable`, `disabled`, `incompatible` or `not-installed`, with the resolved `version` and a `reason`.
|
|
419
|
+
- In a UI, status and state arrive within about 250 ms (and `ctx.onDataChange` fires), as the latest at each update (changes close together arrive as one), so a command's effects can show just after it resolves: render from `onChange`, or use its output.
|
|
420
|
+
- In a UI (from 0.2.1), events come with those updates, each after the state its plugin set before it. None are replayed (reconnects, reloads, more than about 100 between two updates), every open copy of the UI gets each one, and a command a UI listener runs starts a new chain: render in UI listeners, and react with commands in main code. On an older Drift Beacon they never arrive.
|
|
324
421
|
|
|
325
422
|
## Errors and limits
|
|
326
423
|
|
|
@@ -329,11 +426,13 @@ Operations reject with `PluginError`. Check `error.code`, not `instanceof`:
|
|
|
329
426
|
| Code | Meaning |
|
|
330
427
|
|---|---|
|
|
331
428
|
| `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, …) |
|
|
429
|
+
| `invalid` | Bad arguments (an empty id or storage key, an MQTT payload that isn't a string, tracking a point with `start()`, a value that isn't JSON, an undeclared event, …) |
|
|
333
430
|
| `not-found` | The activity or session doesn't exist, or the model was removed |
|
|
334
431
|
| `unavailable` | Something needed isn't there: no MQTT broker, no answer from the app |
|
|
335
432
|
| `stopped` | The instance has stopped |
|
|
336
433
|
| `failed` | Anything else, such as tracking an archived activity |
|
|
434
|
+
| `not-installed`, `disabled`, `incompatible` | The plugin you ran a command on isn't installed, is disabled here, or is outside your `uses` range |
|
|
435
|
+
| `loop`, `timeout` | A chain of commands went deeper than 8 steps; a command didn't answer in time (it may have run) |
|
|
337
436
|
|
|
338
437
|
| | Limit |
|
|
339
438
|
|---|---|
|
|
@@ -341,11 +440,12 @@ Operations reject with `PluginError`. Check `error.code`, not `instanceof`:
|
|
|
341
440
|
| Blocking the event loop | The server restarts the plugin host after 10 s |
|
|
342
441
|
| Actions | 10 s, then `unavailable` |
|
|
343
442
|
| Route handlers | 30 s, then 504 |
|
|
443
|
+
| Commands to other plugins | 10 s by default (at most 30 s), then `timeout`; 8 steps deep; 64 KiB in and out |
|
|
344
444
|
| Release package | 32 MiB zipped, 128 MiB unpacked, 2,000 files |
|
|
345
445
|
|
|
346
446
|
## Developing against your server
|
|
347
447
|
|
|
348
|
-
On a Drift Beacon server you run yourself, set `DEV_PLUGINS_PATH` to the folder that contains your plugin folders and start the server in development mode. It lists each plugin under **Plugins →
|
|
448
|
+
On a Drift Beacon server you run yourself, set `DEV_PLUGINS_PATH` to the folder that contains your plugin folders and start the server in development mode. It lists each plugin under **Plugins → Repositories → Local development**. Enable it in a workspace, then run `npm run dev` in the plugin folder: every save rebuilds main, the server restarts the plugin, and its status, logs and errors (with stack traces pointing at your source) print in your terminal. The UI updates in place.
|
|
349
449
|
|
|
350
450
|
## Releasing a plugin
|
|
351
451
|
|
|
@@ -355,13 +455,13 @@ Plugins are published on GitHub. In a public repository, each immediate child fo
|
|
|
355
455
|
2. Run `npm run release` (or `npx dbplugin pack`), which writes `releases/<id>.zip`.
|
|
356
456
|
3. Create a GitHub release tagged `<id>-<version>` (for example `hello-1.0.0`) and attach `<id>.zip`.
|
|
357
457
|
|
|
358
|
-
Users add the repository URL in **Plugins →
|
|
458
|
+
Users add the repository URL in **Plugins → Repositories** and install from there. Don't commit `dist/` or `releases/`.
|
|
359
459
|
|
|
360
460
|
## Versions
|
|
361
461
|
|
|
362
462
|
- `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.
|
|
364
|
-
-
|
|
463
|
+
- Before 1.0, each minor version (`0.1`, `0.2`) may change the API, and a server runs only its exact version. Install the SDK with `@0.2` to stay on it.
|
|
464
|
+
- Patch releases (`0.2.x`) fix bugs and may add to API 0.2. An addition that needs the app, such as UI events in 0.2.1, does nothing on an older Drift Beacon. Ignore fields and string values you don't recognise (such as a new `trackingType`).
|
|
365
465
|
|
|
366
466
|
## License
|
|
367
467
|
|
package/dist/internal/actions.js
CHANGED
|
@@ -1 +1,23 @@
|
|
|
1
|
-
|
|
1
|
+
const nonEmpty = (value, what) => typeof value === "string" && value !== "" ? null : `${what} must be a non-empty string`;
|
|
2
|
+
const ARGUMENTS = {
|
|
3
|
+
startSession: (action) => nonEmpty(action.activityId, "Activity id"),
|
|
4
|
+
markPoint: (action) => nonEmpty(action.activityId, "Activity id"),
|
|
5
|
+
endSession: (action) => nonEmpty(action.sessionId, "Session id"),
|
|
6
|
+
discardSession: (action) => nonEmpty(action.sessionId, "Session id"),
|
|
7
|
+
storageSet: (action) => nonEmpty(action.key, "Storage key"),
|
|
8
|
+
storageRemove: (action) => nonEmpty(action.key, "Storage key"),
|
|
9
|
+
mqttSubscribe: (action) => nonEmpty(action.topic, "MQTT topic"),
|
|
10
|
+
mqttUnsubscribe: (action) => nonEmpty(action.topic, "MQTT topic"),
|
|
11
|
+
mqttPublish: (action) => nonEmpty(action.topic, "MQTT topic") ??
|
|
12
|
+
(typeof action.payload === "string" ? null : "MQTT payload must be a string"),
|
|
13
|
+
};
|
|
14
|
+
export function checkedAction(value) {
|
|
15
|
+
if (typeof value !== "object" || value === null)
|
|
16
|
+
return null;
|
|
17
|
+
const action = value;
|
|
18
|
+
const name = action.name;
|
|
19
|
+
if (typeof name !== "string" || !Object.hasOwn(ARGUMENTS, name))
|
|
20
|
+
return null;
|
|
21
|
+
const problem = ARGUMENTS[name](action);
|
|
22
|
+
return problem === null ? { ok: true, action: action } : { ok: false, problem };
|
|
23
|
+
}
|
|
@@ -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,60 @@
|
|
|
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
|
+
eventsPerSecond: 50,
|
|
10
|
+
eventBurst: 100,
|
|
11
|
+
};
|
|
12
|
+
export const COMMAND_TIMEOUT = { defaultMs: 10_000, maxMs: 30_000, minimumMs: 100 };
|
|
13
|
+
export const commandBudgetMs = (timeoutMs) => Math.min(timeoutMs ?? COMMAND_TIMEOUT.defaultMs, COMMAND_TIMEOUT.maxMs);
|
|
14
|
+
export const eventNameProblem = (name) => typeof name === "string" && PEER_NAME.test(name)
|
|
15
|
+
? null
|
|
16
|
+
: `Event names are camelCase letters and digits (got "${String(name)}")`;
|
|
17
|
+
export function commandArgumentsProblem(command, input, options) {
|
|
18
|
+
if (typeof command !== "string" || !command)
|
|
19
|
+
return "Command name must be a non-empty string";
|
|
20
|
+
if (input !== undefined && (typeof input !== "object" || input === null || Array.isArray(input)))
|
|
21
|
+
return "Command input must be an object of named fields";
|
|
22
|
+
const timeoutMs = options?.timeoutMs;
|
|
23
|
+
if (timeoutMs !== undefined && (typeof timeoutMs !== "number" || !(timeoutMs > 0)))
|
|
24
|
+
return "timeoutMs must be a positive number of milliseconds";
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
export const PLUGIN_ERROR_CODES = [
|
|
28
|
+
"unsupported",
|
|
29
|
+
"invalid",
|
|
30
|
+
"not-found",
|
|
31
|
+
"unavailable",
|
|
32
|
+
"stopped",
|
|
33
|
+
"failed",
|
|
34
|
+
"not-installed",
|
|
35
|
+
"disabled",
|
|
36
|
+
"incompatible",
|
|
37
|
+
"loop",
|
|
38
|
+
"timeout",
|
|
39
|
+
];
|
|
40
|
+
export function checkedValue(schema, value, path) {
|
|
41
|
+
let json;
|
|
42
|
+
try {
|
|
43
|
+
json = JSON.stringify(value);
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
return { ok: false, problem: `${path} isn't JSON` };
|
|
47
|
+
}
|
|
48
|
+
if (json === undefined) {
|
|
49
|
+
const problem = schema ? schemaError(schema, undefined, path) : null;
|
|
50
|
+
return problem ? { ok: false, problem } : { ok: true, value: undefined, size: 0 };
|
|
51
|
+
}
|
|
52
|
+
if (!schema)
|
|
53
|
+
return { ok: false, problem: `${path}: none is declared` };
|
|
54
|
+
if (json.length > PEER_LIMITS.valueBytes)
|
|
55
|
+
return { ok: false, problem: `${path} is over ${PEER_LIMITS.valueBytes / 1024} KiB` };
|
|
56
|
+
const plain = JSON.parse(json);
|
|
57
|
+
const problem = schemaError(schema, plain, path);
|
|
58
|
+
return problem ? { ok: false, problem } : { ok: true, value: plain, size: json.length };
|
|
59
|
+
}
|
|
60
|
+
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
|
+
}
|