@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.
@@ -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
  /**
@@ -19,9 +19,15 @@ export interface MqttMessage {
19
19
  export interface MqttApi {
20
20
  /** Subscribe to a topic filter. `+` matches one level and `#` the remaining levels. */
21
21
  subscribe(topic: string, callback: (message: MqttMessage) => void): Unsubscribe;
22
- /** Rejects when no broker is configured or the broker refuses the message. */
22
+ /**
23
+ * Rejects with `invalid` when the topic is empty or the payload isn't a string (nothing is sent), and with
24
+ * `unavailable` when the workspace has no broker, the broker isn't connected, or it refuses the message.
25
+ */
23
26
  publish(topic: string, payload: string): Promise<void>;
24
- /** `unavailable` when no broker is configured for the workspace. */
27
+ /**
28
+ * `unavailable` when the workspace has no broker: none is configured, or the server couldn't connect to it when
29
+ * it was saved or when the server started.
30
+ */
25
31
  readonly status: MqttStatus;
26
32
  onStatusChange(callback: (status: MqttStatus) => void): Unsubscribe;
27
33
  }
@@ -51,6 +57,94 @@ export interface LogApi {
51
57
  warn(...args: unknown[]): void;
52
58
  error(...args: unknown[]): void;
53
59
  }
60
+ /** Who ran a command: another plugin's main code (by manifest id), a plugin UI, or an integration such as Home Assistant. */
61
+ export type CommandCaller = {
62
+ readonly kind: "plugin";
63
+ readonly plugin: string;
64
+ } | {
65
+ readonly kind: "ui";
66
+ readonly plugin: string;
67
+ } | {
68
+ readonly kind: "integration";
69
+ };
70
+ /** The commands this plugin provides. `dbplugin prepare` fills this in from manifest.json `provides` (.drift-beacon/config.d.ts). */
71
+ export interface PluginCommands {
72
+ }
73
+ /** The events this plugin emits. `dbplugin prepare` fills this in from manifest.json `provides`. */
74
+ export interface PluginEvents {
75
+ }
76
+ /** The state this plugin publishes. `dbplugin prepare` fills this in from manifest.json `provides`. */
77
+ export interface PluginState {
78
+ }
79
+ /** A declared map, or any name when nothing is declared (a plugin without generated types). */
80
+ type Declared<T, Fallback> = keyof T extends never ? {
81
+ readonly [name: string]: Fallback;
82
+ } : T;
83
+ type Commands = Declared<PluginCommands, {
84
+ readonly input: Readonly<Record<string, unknown>>;
85
+ readonly output: unknown;
86
+ }>;
87
+ type Events = Declared<PluginEvents, unknown>;
88
+ type States = Declared<PluginState, unknown>;
89
+ type CommandName = keyof Commands & string;
90
+ type EventName = keyof Events & string;
91
+ type StateKey = keyof States & string;
92
+ /** The chain a call belongs to: commands, events and state changes a handler or listener causes continue it. */
93
+ export interface ChainInfo {
94
+ readonly chainId: string;
95
+ /**
96
+ * 1 for anything started outside a handler or listener; one more per step. Past 8, commands fail (`loop`),
97
+ * events are dropped and state changes run no callbacks. 0 for changes Drift Beacon makes itself (a plugin
98
+ * stopping or being replaced).
99
+ */
100
+ readonly depth: number;
101
+ }
102
+ export interface CommandMeta extends ChainInfo {
103
+ readonly caller: CommandCaller;
104
+ /** When the caller stops waiting (epoch milliseconds). */
105
+ readonly deadline: number;
106
+ /** Aborts at the deadline: pass it to `fetch` and the like. */
107
+ readonly signal: AbortSignal;
108
+ }
109
+ /** Handles a command: `input` has the declared fields (`{}` when it declares none); the result is the command's output. */
110
+ 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"]>;
111
+ export interface CommandsApi {
112
+ /** Handle a command declared in manifest.json `provides.commands`. One handler per command; throws `invalid` otherwise. */
113
+ handle<K extends CommandName>(name: K, handler: CommandHandler<K>): Unsubscribe;
114
+ }
115
+ export interface EventsApi {
116
+ /**
117
+ * Send a declared event (manifest.json `provides.events`) to the plugins that use this one, and to this plugin
118
+ * itself. Throws `invalid` for an undeclared event or a payload that doesn't match its schema (or is over 64 KiB).
119
+ */
120
+ emit<K extends EventName>(name: K, ...payload: undefined extends Events[K] ? [payload?: Events[K]] : [payload: Events[K]]): void;
121
+ }
122
+ /** The state this plugin publishes to the plugins that use it (manifest.json `provides.state`). Kept in memory while it runs. */
123
+ export interface StateApi {
124
+ get<K extends StateKey>(key: K): States[K] | undefined;
125
+ keys(): readonly StateKey[];
126
+ /**
127
+ * Publish a value (`undefined` removes it). Throws `invalid` for an undeclared key, a value that doesn't match
128
+ * its schema, over 64 KiB, or over 256 KiB of state in all.
129
+ */
130
+ set<K extends StateKey>(key: K, value: States[K] | undefined): void;
131
+ }
132
+ /** Another plugin's state, with the chain of the change that caused each callback. */
133
+ export interface MainPeerStateApi extends PeerStateApi {
134
+ onChange(callback: (key: string, value: unknown, meta: ChainInfo) => void | Promise<void>): Unsubscribe;
135
+ }
136
+ export interface MainPluginPeer extends PluginPeer {
137
+ readonly state: MainPeerStateApi;
138
+ /**
139
+ * Called for each of its events with this name. Commands, events and state changes the callback causes continue
140
+ * its chain. A throw, or a rejection of the promise it returns, stops the instance.
141
+ */
142
+ onEvent(name: string, callback: (payload: unknown, meta: ChainInfo) => void | Promise<void>): Unsubscribe;
143
+ }
144
+ export interface MainPluginsApi extends PluginsApi {
145
+ get(id: string): MainPluginPeer;
146
+ readonly self: MainPluginPeer;
147
+ }
54
148
  /** Everything a plugin's main code can use. One `ctx` per instance: a plugin for one user in one workspace. */
55
149
  export interface MainContext {
56
150
  readonly plugin: PluginInfo;
@@ -66,6 +160,14 @@ export interface MainContext {
66
160
  readonly storage: StorageApi;
67
161
  readonly mqtt: MqttApi;
68
162
  readonly routes: RoutesApi;
163
+ /** The commands this plugin provides to other plugins, its UI and integrations. */
164
+ readonly commands: CommandsApi;
165
+ /** The events this plugin emits to the plugins that use it. */
166
+ readonly events: EventsApi;
167
+ /** The state this plugin publishes to the plugins that use it. */
168
+ readonly state: StateApi;
169
+ /** The plugins this one uses, and itself. */
170
+ readonly plugins: MainPluginsApi;
69
171
  readonly log: LogApi;
70
172
  /**
71
173
  * Run when the instance stops: disabled, reconfigured, updated or shut down. `ctx` still works
@@ -89,4 +191,5 @@ export interface PluginDefinition {
89
191
  * ```
90
192
  */
91
193
  export declare function definePlugin(definition: PluginDefinition): PluginDefinition;
194
+ export {};
92
195
  //# sourceMappingURL=main.d.ts.map
package/dist/pack.js CHANGED
@@ -90,15 +90,14 @@ export async function pack(root, options = {}) {
90
90
  const typescript = require.resolve("typescript/package.json");
91
91
  const { bin } = JSON.parse(await readFile(typescript, "utf8"));
92
92
  const tsc = path.join(path.dirname(typescript), bin.tsc);
93
- for (const project of ["tsconfig.main.json", "tsconfig.ui.json"]) {
94
- const result = spawnSync(process.execPath, [tsc, "-p", path.join(".drift-beacon", project)], {
95
- cwd: pkg,
96
- stdio: ["ignore", 2, 2],
97
- });
93
+ for (const part of ["main", "ui"]) {
94
+ const authored = `${part}/tsconfig.json`;
95
+ const project = existsSync(path.join(pkg, authored)) ? authored : `.drift-beacon/tsconfig.${part}.json`;
96
+ const result = spawnSync(process.execPath, [tsc, "-p", project], { cwd: pkg, stdio: ["ignore", 2, 2] });
98
97
  if (result.error)
99
98
  throw result.error;
100
99
  if (result.status !== 0)
101
- throw new Error(`Type errors (.drift-beacon/${project}); nothing was packed`);
100
+ throw new Error(`Type errors (${project}); nothing was packed`);
102
101
  }
103
102
  temp = await mkdtemp(path.join(tmpdir(), "dbplugin-pack-"));
104
103
  const vite = await import(__rewriteRelativeImportExtension(pathToFileURL(require.resolve("vite")).href));
package/dist/prepare.js CHANGED
@@ -46,7 +46,7 @@ export function prepare(root, { strict }) {
46
46
  const out = path.join(root, ".drift-beacon");
47
47
  mkdirSync(out, { recursive: true });
48
48
  writeIfChanged(path.join(out, ".gitignore"), "*\n");
49
- writeIfChanged(path.join(out, "config.d.ts"), configTypes(manifest.configuration));
49
+ writeIfChanged(path.join(out, "config.d.ts"), configTypes(manifest));
50
50
  writeIfChanged(path.join(out, "tsconfig.main.json"), `${JSON.stringify(TSCONFIG_MAIN, null, 2)}\n`);
51
51
  writeIfChanged(path.join(out, "tsconfig.ui.json"), `${JSON.stringify(TSCONFIG_UI, null, 2)}\n`);
52
52
  return loaded;
@@ -74,23 +74,68 @@ export function loadManifest(root) {
74
74
  throw new Error(`${file}: ${compatibility.message}`);
75
75
  return { manifest, bytes };
76
76
  }
77
- function configTypes(configuration) {
77
+ function configTypes(manifest) {
78
78
  const lines = [
79
79
  "// Generated by dbplugin prepare from manifest.json. Do not edit.",
80
80
  'import type {} from "@drift-beacon/plugin";',
81
81
  'declare module "@drift-beacon/plugin" {',
82
82
  " interface PluginConfig {",
83
83
  ];
84
- for (const item of configuration) {
85
- const doc = [item.title, item.description].filter(Boolean).join(": ").replaceAll("*/", "*\\/");
84
+ const member = (title, description, name, type) => {
85
+ const doc = docComment(title, description);
86
86
  if (doc)
87
- lines.push(` /** ${doc} */`);
87
+ lines.push(` ${doc}`);
88
+ lines.push(` ${name}: ${type};`);
89
+ };
90
+ for (const item of manifest.configuration) {
88
91
  const optional = item.required === true || item.default !== undefined ? "" : "?";
89
- lines.push(` ${JSON.stringify(item.name)}${optional}: ${valueType(item)};`);
92
+ member(item.title, item.description, `${JSON.stringify(item.name)}${optional}`, valueType(item));
90
93
  }
91
- lines.push(" }", "}", "");
94
+ lines.push(" }");
95
+ const { commands = {}, events = {}, state = {} } = manifest.provides ?? {};
96
+ const block = (name, declarations, type) => {
97
+ if (!manifest.provides)
98
+ return;
99
+ lines.push(` interface ${name} {`);
100
+ if (Object.keys(declarations).length === 0)
101
+ lines.push(" readonly [name: string]: never;");
102
+ for (const [key, declaration] of Object.entries(declarations))
103
+ member(declaration.title, declaration.description, JSON.stringify(key), type(declaration));
104
+ lines.push(" }");
105
+ };
106
+ block("PluginCommands", commands, ({ input, output }) => {
107
+ const inputType = input ? tsType(input) : NO_PROPERTIES;
108
+ return `{ readonly input: ${inputType}; readonly output: ${output ? tsType(output) : "undefined"} }`;
109
+ });
110
+ block("PluginEvents", events, ({ payload }) => (payload ? tsType(payload) : "undefined"));
111
+ block("PluginState", state, ({ schema }) => tsType(schema));
112
+ lines.push("}", "");
92
113
  return lines.join("\n");
93
114
  }
115
+ const docComment = (title, description) => {
116
+ const doc = [title, description].filter(Boolean).join(": ").replaceAll("*/", "*\\/");
117
+ return doc ? `/** ${doc} */` : "";
118
+ };
119
+ const NO_PROPERTIES = "{ readonly [key: string]: never }";
120
+ const SCALAR_TYPES = { string: "string", number: "number", integer: "number", boolean: "boolean", null: "null" };
121
+ function tsType(schema) {
122
+ if (schema.enum)
123
+ return unique(schema.enum.map((value) => JSON.stringify(value)));
124
+ const types = typeof schema.type === "string" ? [schema.type] : schema.type;
125
+ return unique(types.map((type) => type === "array"
126
+ ? `readonly (${schema.items ? tsType(schema.items) : "unknown"})[]`
127
+ : type === "object"
128
+ ? objectType(schema)
129
+ : SCALAR_TYPES[type]));
130
+ }
131
+ function objectType(schema) {
132
+ const required = new Set(schema.required ?? []);
133
+ const members = Object.entries(schema.properties ?? {}).map(([name, property]) => `readonly ${JSON.stringify(name)}${required.has(name) ? "" : "?"}: ${tsType(property)}`);
134
+ if (schema.additionalProperties === true)
135
+ members.push("readonly [key: string]: unknown");
136
+ return members.length === 0 ? NO_PROPERTIES : `{ ${members.join("; ")} }`;
137
+ }
138
+ const unique = (types) => [...new Set(types)].join(" | ");
94
139
  const valueType = (item) => item.type !== "dropdown"
95
140
  ? item.type
96
141
  : item.data.length === 0
package/dist/types.d.ts CHANGED
@@ -178,16 +178,78 @@ export interface SessionsApi {
178
178
  /** Data change events. They can arrive in bursts after a sync; don't use them to trigger actions. */
179
179
  onChange(callback: (change: Change<Session>) => void): Unsubscribe;
180
180
  }
181
- /** The plugin's own key-value storage, per user and workspace. Values must be JSON-compatible. */
181
+ /** The plugin's own key-value storage, per user and workspace: non-empty string keys, JSON-compatible values. */
182
182
  export interface StorageApi {
183
183
  get<T = unknown>(key: string): T | undefined;
184
184
  keys(): readonly string[];
185
- /** Applies locally at once and resolves when the server has stored it. */
185
+ /**
186
+ * Applies locally at once and resolves when Drift Beacon has accepted the write. `undefined` removes the key;
187
+ * an empty key or a value that isn't JSON-compatible rejects with `invalid`.
188
+ */
186
189
  set(key: string, value: unknown): Promise<void>;
190
+ /** Applies locally at once and resolves when Drift Beacon has accepted the removal. */
187
191
  remove(key: string): Promise<void>;
188
192
  /** Called for changes made anywhere else: main code, a UI or another device. */
189
193
  onChange(callback: (key: string, value: unknown) => void): Unsubscribe;
190
194
  }
191
- /** Error codes on rejected operations (`error.code`). */
192
- export type PluginErrorCode = "unsupported" | "invalid" | "not-found" | "unavailable" | "stopped" | "failed";
195
+ /**
196
+ * Error codes on rejected operations (`error.code`). For a command to another plugin: `unavailable`
197
+ * means it didn't run (the plugin isn't running here); `timeout` and `stopped` mean it may have run.
198
+ */
199
+ export type PluginErrorCode = "unsupported" | "invalid" | "not-found" | "unavailable" | "stopped" | "failed" | "not-installed" | "disabled" | "incompatible" | "loop" | "timeout";
200
+ export interface CommandOptions {
201
+ /** How long to wait, in milliseconds (default 10 s, at most 30 s). Inside a command, at most what its caller has left. */
202
+ readonly timeoutMs?: number;
203
+ }
204
+ export type PluginStatusState = "running" | "starting" | "unavailable" | "disabled" | "incompatible" | "not-installed";
205
+ /** Whether another plugin can run for this user in this workspace now. */
206
+ export interface PluginStatus {
207
+ readonly state: PluginStatusState;
208
+ /** The version it resolved to, when one did. */
209
+ readonly version?: string;
210
+ /** Why it isn't running, when known. */
211
+ readonly reason?: string;
212
+ }
213
+ /** Another plugin's published state (or this plugin's own, as others see it: after a round trip). Values are read-only. */
214
+ export interface PeerStateApi {
215
+ get<T = unknown>(key: string): T | undefined;
216
+ keys(): readonly string[];
217
+ /** A key changed, or was removed (`undefined`). */
218
+ onChange(callback: (key: string, value: unknown) => void): Unsubscribe;
219
+ }
220
+ /** Another plugin this one uses (listed in manifest.json `uses`), or this plugin itself. */
221
+ export interface PluginPeer {
222
+ /** Its manifest id. */
223
+ readonly id: string;
224
+ /**
225
+ * The same object until it changes. In main code, updated at once when the plugin starts, finishes starting or
226
+ * stops, and otherwise within 250 ms; in a UI, within about 250 ms.
227
+ */
228
+ readonly status: PluginStatus;
229
+ /** Called when its `state`, `version` or `reason` changes. */
230
+ onStatusChange(callback: (status: PluginStatus) => void): Unsubscribe;
231
+ /**
232
+ * Run one of its commands (manifest.json `provides.commands`) as this user in this workspace and wait for
233
+ * the result. Fails fast with `not-installed`, `disabled`, `incompatible` or `unavailable` when it can't run;
234
+ * `timeout` means it may have run. In main code, the state it published reaches this plugin before this resolves;
235
+ * in a UI, it (like storage and workspace data the command changed) can arrive just after.
236
+ */
237
+ command<T = unknown>(name: string, input?: Readonly<Record<string, unknown>>, options?: CommandOptions): Promise<T>;
238
+ /** The state it publishes (manifest.json `provides.state`). */
239
+ readonly state: PeerStateApi;
240
+ /**
241
+ * Called for each of its events with this name (manifest.json `provides.events`), with the payload (frozen). In a
242
+ * UI (from 0.2.1): from `connect()` on, with the state its plugin set before it, not replayed, errors logged, and
243
+ * every open copy of the UI gets each one; against an older Drift Beacon it's never called. In main code, see
244
+ * `MainPluginPeer.onEvent`. Throws `invalid` for a name that isn't camelCase.
245
+ */
246
+ onEvent(name: string, callback: (payload: unknown) => void): Unsubscribe;
247
+ }
248
+ /** The plugins this one uses. */
249
+ export interface PluginsApi {
250
+ /** A plugin listed in manifest.json `uses`, or this plugin's own manifest id. Anything else throws `invalid`. */
251
+ get(id: string): PluginPeer;
252
+ /** This plugin itself, as other plugins see it. */
253
+ readonly self: PluginPeer;
254
+ }
193
255
  //# 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,18 @@ export interface UiContext {
12
13
  readonly activities: ActivitiesApi;
13
14
  readonly categories: CategoriesApi;
14
15
  readonly sessions: SessionsApi;
15
- /** Called after each update from the app (workspace data, config or storage) and after local storage writes. */
16
+ /**
17
+ * Called after each update from the app (workspace data, config, storage, or other plugins' status and state) and
18
+ * after local storage writes; not for other plugins' events.
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),
24
+ * their events (with them), and their commands. A command's effects on their state, storage or data, and its events,
25
+ * can arrive just after it resolves.
26
+ */
27
+ readonly plugins: PluginsApi;
18
28
  }
19
29
  export interface ConnectOptions {
20
30
  /** How long to wait for the app to answer. Default 10 seconds. */