@drift-beacon/plugin 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +95 -11
- package/dist/cli.js +0 -8
- package/dist/dev.js +0 -7
- package/dist/errors.js +0 -1
- package/dist/index.js +0 -5
- package/dist/internal/collection.js +1 -12
- package/dist/internal/compatibility.js +0 -11
- package/dist/internal/freeze.js +8 -0
- package/dist/internal/json-equal.js +0 -1
- package/dist/internal/manifest.js +80 -7
- package/dist/internal/models.js +0 -43
- package/dist/internal/package-layout.js +0 -7
- package/dist/internal/peers.js +55 -0
- package/dist/internal/protocol.js +0 -1
- package/dist/internal/schema.js +156 -0
- package/dist/internal/semver.js +47 -0
- package/dist/main.d.ts +98 -1
- package/dist/main.js +0 -11
- package/dist/pack.js +5 -21
- package/dist/prepare.js +52 -25
- package/dist/types.d.ts +58 -3
- package/dist/types.js +0 -9
- package/dist/ui.d.ts +11 -2
- package/dist/ui.js +157 -25
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -5
- package/dist/vite.js +28 -77
- package/package.json +11 -4
- package/dist/cli.d.ts +0 -2
- package/dist/dev.d.ts +0 -38
- package/dist/internal/actions.d.ts +0 -35
- package/dist/internal/collection.d.ts +0 -15
- package/dist/internal/compatibility.d.ts +0 -26
- package/dist/internal/json-equal.d.ts +0 -3
- package/dist/internal/manifest.d.ts +0 -51
- package/dist/internal/models.d.ts +0 -62
- package/dist/internal/package-layout.d.ts +0 -21
- package/dist/internal/protocol.d.ts +0 -96
- package/dist/internal/rows.d.ts +0 -41
- package/dist/internal.d.ts +0 -17
- package/dist/internal.js +0 -13
- package/dist/pack.d.ts +0 -24
- package/dist/prepare.d.ts +0 -28
|
@@ -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/main.js
CHANGED
|
@@ -1,14 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Define a plugin's main code. Export the result as the module's default export:
|
|
3
|
-
*
|
|
4
|
-
* ```ts
|
|
5
|
-
* export default definePlugin({
|
|
6
|
-
* onStart(ctx) {
|
|
7
|
-
* ctx.mqtt.subscribe(ctx.config.mqttTopic, (message) => ctx.log.info(message.payload));
|
|
8
|
-
* },
|
|
9
|
-
* });
|
|
10
|
-
* ```
|
|
11
|
-
*/
|
|
12
1
|
export function definePlugin(definition) {
|
|
13
2
|
return definition;
|
|
14
3
|
}
|
package/dist/pack.js
CHANGED
|
@@ -6,10 +6,6 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
|
|
|
6
6
|
}
|
|
7
7
|
return path;
|
|
8
8
|
};
|
|
9
|
-
/**
|
|
10
|
-
* Release packaging. `packDirectory` zips a finished package folder (`dbplugin pack` and the server's
|
|
11
|
-
* `plugin:install`); `pack` typechecks a plugin package, builds it fresh into a temp folder and packs that.
|
|
12
|
-
*/
|
|
13
9
|
import { spawnSync } from "node:child_process";
|
|
14
10
|
import { existsSync } from "node:fs";
|
|
15
11
|
import { lstat, mkdir, mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
@@ -20,10 +16,6 @@ import { pathToFileURL } from "node:url";
|
|
|
20
16
|
import { strToU8, zipSync } from "fflate";
|
|
21
17
|
import { PACKAGE_JSON, PACKAGE_LIMITS, REQUIRED_FILES } from "./internal/package-layout.js";
|
|
22
18
|
import { loadManifest, prepare } from "./prepare.js";
|
|
23
|
-
/**
|
|
24
|
-
* Zip the package in `dir` into `<outDir>/<id>.zip`: its manifest, a generated `package.json`, and `main/**`
|
|
25
|
-
* and `ui/**`. Anything else in `dir` (the dev marker, its `package.json`) is left out. Never runs plugin code.
|
|
26
|
-
*/
|
|
27
19
|
export async function packDirectory(dir, outDir) {
|
|
28
20
|
const source = path.resolve(dir);
|
|
29
21
|
const { manifest, bytes: manifestBytes } = loadManifest(source);
|
|
@@ -87,12 +79,6 @@ export async function packDirectory(dir, outDir) {
|
|
|
87
79
|
await writeFile(archivePath, archive);
|
|
88
80
|
return { id: manifest.id, version: manifest.version, tag: `${manifest.id}-${manifest.version}`, asset, archivePath };
|
|
89
81
|
}
|
|
90
|
-
/**
|
|
91
|
-
* Build a release of the plugin package at `root`: prepare, typecheck main and UI with the package's own
|
|
92
|
-
* TypeScript, `vite build` into a fresh temp folder with the package's own Vite, then `packDirectory`. The
|
|
93
|
-
* package's `dist/` is never read or written. Everything printed meanwhile goes to stderr, so a caller's
|
|
94
|
-
* stdout carries only the result.
|
|
95
|
-
*/
|
|
96
82
|
export async function pack(root, options = {}) {
|
|
97
83
|
const pkg = path.resolve(root);
|
|
98
84
|
const write = process.stdout.write;
|
|
@@ -101,19 +87,17 @@ export async function pack(root, options = {}) {
|
|
|
101
87
|
try {
|
|
102
88
|
prepare(pkg, { strict: true });
|
|
103
89
|
const require = createRequire(path.join(pkg, "package.json"));
|
|
104
|
-
// Found through its package.json `bin`: TypeScript 7 doesn't export `typescript/bin/tsc`.
|
|
105
90
|
const typescript = require.resolve("typescript/package.json");
|
|
106
91
|
const { bin } = JSON.parse(await readFile(typescript, "utf8"));
|
|
107
92
|
const tsc = path.join(path.dirname(typescript), bin.tsc);
|
|
108
|
-
for (const
|
|
109
|
-
const
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
});
|
|
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] });
|
|
113
97
|
if (result.error)
|
|
114
98
|
throw result.error;
|
|
115
99
|
if (result.status !== 0)
|
|
116
|
-
throw new Error(`Type errors (
|
|
100
|
+
throw new Error(`Type errors (${project}); nothing was packed`);
|
|
117
101
|
}
|
|
118
102
|
temp = await mkdtemp(path.join(tmpdir(), "dbplugin-pack-"));
|
|
119
103
|
const vite = await import(__rewriteRelativeImportExtension(pathToFileURL(require.resolve("vite")).href));
|
package/dist/prepare.js
CHANGED
|
@@ -1,8 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Writes a plugin package's generated `.drift-beacon/` folder: `config.d.ts` (the `ctx.config` types from
|
|
3
|
-
* manifest.json), the main and UI tsconfigs, and a `.gitignore` that ignores the folder itself. Also reads
|
|
4
|
-
* and checks manifests for `driftBeacon()` and `dbplugin pack` (`loadManifest`).
|
|
5
|
-
*/
|
|
6
1
|
import { existsSync, lstatSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
7
2
|
import path from "node:path";
|
|
8
3
|
import { checkApiCompatibility } from "./internal/compatibility.js";
|
|
@@ -22,7 +17,6 @@ const COMPILER_OPTIONS = {
|
|
|
22
17
|
noFallthroughCasesInSwitch: true,
|
|
23
18
|
noUncheckedSideEffectImports: true,
|
|
24
19
|
};
|
|
25
|
-
// Explicit `types` keep UI typings (React, three.js) out of main; `vite/client` allows CSS and asset imports in the UI.
|
|
26
20
|
const TSCONFIG_MAIN = {
|
|
27
21
|
compilerOptions: { ...COMPILER_OPTIONS, lib: ["ES2023"], types: ["node"] },
|
|
28
22
|
include: ["../main/src", "../vite.config.ts", "./config.d.ts"],
|
|
@@ -52,16 +46,11 @@ export function prepare(root, { strict }) {
|
|
|
52
46
|
const out = path.join(root, ".drift-beacon");
|
|
53
47
|
mkdirSync(out, { recursive: true });
|
|
54
48
|
writeIfChanged(path.join(out, ".gitignore"), "*\n");
|
|
55
|
-
writeIfChanged(path.join(out, "config.d.ts"), configTypes(manifest
|
|
49
|
+
writeIfChanged(path.join(out, "config.d.ts"), configTypes(manifest));
|
|
56
50
|
writeIfChanged(path.join(out, "tsconfig.main.json"), `${JSON.stringify(TSCONFIG_MAIN, null, 2)}\n`);
|
|
57
51
|
writeIfChanged(path.join(out, "tsconfig.ui.json"), `${JSON.stringify(TSCONFIG_UI, null, 2)}\n`);
|
|
58
52
|
return loaded;
|
|
59
53
|
}
|
|
60
|
-
/**
|
|
61
|
-
* `<root>/manifest.json`, the one read of a manifest in the SDK (dev, build, prepare and pack): a regular
|
|
62
|
-
* file within the package limit, valid, and for an `apiVersion` this SDK runs (the server's check). The
|
|
63
|
-
* error names the file.
|
|
64
|
-
*/
|
|
65
54
|
export function loadManifest(root) {
|
|
66
55
|
const file = path.join(root, "manifest.json");
|
|
67
56
|
let manifest;
|
|
@@ -85,35 +74,73 @@ export function loadManifest(root) {
|
|
|
85
74
|
throw new Error(`${file}: ${compatibility.message}`);
|
|
86
75
|
return { manifest, bytes };
|
|
87
76
|
}
|
|
88
|
-
|
|
89
|
-
* Augments the SDK's `PluginConfig`. Mirrors the server's config validation: a setting can be missing
|
|
90
|
-
* unless it is required or has a default. The type-only import makes this a module, so `declare module`
|
|
91
|
-
* augments instead of declaring an ambient module, and it loads the SDK entry into every program: the UI
|
|
92
|
-
* imports only `@drift-beacon/plugin/ui`, and an augmentation of a module the program never loads has no effect.
|
|
93
|
-
*/
|
|
94
|
-
function configTypes(configuration) {
|
|
77
|
+
function configTypes(manifest) {
|
|
95
78
|
const lines = [
|
|
96
79
|
"// Generated by dbplugin prepare from manifest.json. Do not edit.",
|
|
97
80
|
'import type {} from "@drift-beacon/plugin";',
|
|
98
81
|
'declare module "@drift-beacon/plugin" {',
|
|
99
82
|
" interface PluginConfig {",
|
|
100
83
|
];
|
|
101
|
-
|
|
102
|
-
const doc =
|
|
84
|
+
const member = (title, description, name, type) => {
|
|
85
|
+
const doc = docComment(title, description);
|
|
103
86
|
if (doc)
|
|
104
|
-
lines.push(`
|
|
87
|
+
lines.push(` ${doc}`);
|
|
88
|
+
lines.push(` ${name}: ${type};`);
|
|
89
|
+
};
|
|
90
|
+
for (const item of manifest.configuration) {
|
|
105
91
|
const optional = item.required === true || item.default !== undefined ? "" : "?";
|
|
106
|
-
|
|
92
|
+
member(item.title, item.description, `${JSON.stringify(item.name)}${optional}`, valueType(item));
|
|
107
93
|
}
|
|
108
|
-
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("}", "");
|
|
109
113
|
return lines.join("\n");
|
|
110
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(" | ");
|
|
111
139
|
const valueType = (item) => item.type !== "dropdown"
|
|
112
140
|
? item.type
|
|
113
141
|
: item.data.length === 0
|
|
114
142
|
? "string"
|
|
115
143
|
: [...new Set(item.data.map((option) => JSON.stringify(option.value)))].join(" | ");
|
|
116
|
-
/** Leaves the file (and its mtime) alone when it already has `content`, so editors and watchers stay quiet. */
|
|
117
144
|
function writeIfChanged(file, content) {
|
|
118
145
|
if (existsSync(file) && readFileSync(file, "utf8") === content)
|
|
119
146
|
return;
|
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/types.js
CHANGED
|
@@ -1,10 +1 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Data and APIs shared by plugin main code (server) and UIs (iframe).
|
|
3
|
-
*
|
|
4
|
-
* Workspace data comes as models: an `Activity`, `Category` or `Session` with its fields, links,
|
|
5
|
-
* derived values and actions. Models read the latest snapshot the app sent, synchronously; there is
|
|
6
|
-
* one model per row for the life of a `ctx`, and `model.data` is its plain, frozen row. Later API
|
|
7
|
-
* versions may add fields and new values to string unions such as `trackingType`, so plugins must
|
|
8
|
-
* ignore what they don't recognise.
|
|
9
|
-
*/
|
|
10
1
|
export {};
|
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. */
|