@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/dist/ui.js
CHANGED
|
@@ -1,13 +1,31 @@
|
|
|
1
1
|
import { PluginError } from "./errors.js";
|
|
2
|
+
import { checkedAction } from "./internal/actions.js";
|
|
2
3
|
import { Collection } from "./internal/collection.js";
|
|
4
|
+
import { deepFreeze } from "./internal/freeze.js";
|
|
3
5
|
import { jsonEqual } from "./internal/json-equal.js";
|
|
4
6
|
import { WorkspaceModels } from "./internal/models.js";
|
|
7
|
+
import { commandArgumentsProblem, commandBudgetMs, eventNameProblem, samePluginStatus } from "./internal/peers.js";
|
|
5
8
|
import { isUiMessage, UI_CHANNEL, } from "./internal/protocol.js";
|
|
6
9
|
import { API_VERSION } from "./version.js";
|
|
7
10
|
export { PluginError } from "./errors.js";
|
|
8
11
|
export { API_VERSION } from "./version.js";
|
|
9
12
|
const HELLO_INTERVAL_MS = 250;
|
|
10
13
|
const REQUEST_TIMEOUT_MS = 10_000;
|
|
14
|
+
const COMMAND_GRACE_MS = 2_000;
|
|
15
|
+
const NO_PEERS = Object.freeze({
|
|
16
|
+
state: "unavailable",
|
|
17
|
+
reason: "This version of Drift Beacon doesn't share other plugins with plugin UIs",
|
|
18
|
+
});
|
|
19
|
+
const UNLISTED = Object.freeze({ state: "unavailable", reason: "No longer in manifest.json uses" });
|
|
20
|
+
const PAGE = (() => {
|
|
21
|
+
const bytes = new Uint8Array(6);
|
|
22
|
+
if (typeof crypto !== "undefined" && typeof crypto.getRandomValues === "function")
|
|
23
|
+
crypto.getRandomValues(bytes);
|
|
24
|
+
else
|
|
25
|
+
for (let i = 0; i < bytes.length; i++)
|
|
26
|
+
bytes[i] = Math.floor(Math.random() * 256);
|
|
27
|
+
return Array.from(bytes, (byte) => byte.toString(36).padStart(2, "0")).join("");
|
|
28
|
+
})();
|
|
11
29
|
let connection = null;
|
|
12
30
|
export function connect(options = {}) {
|
|
13
31
|
connection ??= open(options.timeoutMs ?? 10_000);
|
|
@@ -63,6 +81,7 @@ class UiClient {
|
|
|
63
81
|
post;
|
|
64
82
|
context;
|
|
65
83
|
config;
|
|
84
|
+
plugin;
|
|
66
85
|
rows = {
|
|
67
86
|
activities: new Collection(),
|
|
68
87
|
categories: new Collection(),
|
|
@@ -81,9 +100,19 @@ class UiClient {
|
|
|
81
100
|
nextWrite = 0;
|
|
82
101
|
pending = new Map();
|
|
83
102
|
nextRequest = 0;
|
|
103
|
+
peersShared;
|
|
104
|
+
listed = new Set();
|
|
105
|
+
selfId;
|
|
106
|
+
peerViews = new Map();
|
|
107
|
+
peerObjects = new Map();
|
|
84
108
|
constructor(welcome, post) {
|
|
85
109
|
this.post = post;
|
|
86
110
|
this.config = welcome.config;
|
|
111
|
+
this.plugin = welcome.plugin;
|
|
112
|
+
this.peersShared = welcome.peers !== undefined;
|
|
113
|
+
this.selfId = welcome.peers?.self ?? welcome.plugin.id;
|
|
114
|
+
if (welcome.peers)
|
|
115
|
+
this.applyPeers(welcome.peers);
|
|
87
116
|
this.serverStorage = welcome.storage;
|
|
88
117
|
this.storage = new Map(Object.entries(welcome.storage));
|
|
89
118
|
this.models = new WorkspaceModels({
|
|
@@ -99,12 +128,10 @@ class UiClient {
|
|
|
99
128
|
this.applyData(welcome.data);
|
|
100
129
|
const apis = this.models.apis();
|
|
101
130
|
const self = this;
|
|
102
|
-
const subscribe = (listeners, callback) => {
|
|
103
|
-
listeners.add(callback);
|
|
104
|
-
return () => listeners.delete(callback);
|
|
105
|
-
};
|
|
106
131
|
this.context = {
|
|
107
|
-
plugin
|
|
132
|
+
get plugin() {
|
|
133
|
+
return self.plugin;
|
|
134
|
+
},
|
|
108
135
|
user: welcome.user,
|
|
109
136
|
workspace: welcome.workspace,
|
|
110
137
|
get config() {
|
|
@@ -117,20 +144,152 @@ class UiClient {
|
|
|
117
144
|
storage: {
|
|
118
145
|
get: (key) => this.storage.get(key),
|
|
119
146
|
keys: () => [...this.storage.keys()],
|
|
120
|
-
set: (key, value) =>
|
|
147
|
+
set: (key, value) => {
|
|
148
|
+
if (value === undefined)
|
|
149
|
+
return this.write(key, undefined);
|
|
150
|
+
let plain;
|
|
151
|
+
try {
|
|
152
|
+
plain = JSON.parse(JSON.stringify(value));
|
|
153
|
+
}
|
|
154
|
+
catch {
|
|
155
|
+
return Promise.reject(new PluginError("invalid", `Storage value for "${key}" is not JSON-compatible`));
|
|
156
|
+
}
|
|
157
|
+
return this.write(key, plain);
|
|
158
|
+
},
|
|
121
159
|
remove: (key) => this.write(key, undefined),
|
|
122
160
|
onChange: (callback) => subscribe(this.storageListeners, callback),
|
|
123
161
|
},
|
|
162
|
+
plugins: {
|
|
163
|
+
get: (id) => this.peer(id),
|
|
164
|
+
get self() {
|
|
165
|
+
return self.peer(self.selfId);
|
|
166
|
+
},
|
|
167
|
+
},
|
|
124
168
|
};
|
|
125
169
|
}
|
|
170
|
+
peer(id) {
|
|
171
|
+
const problem = this.unreachable(id);
|
|
172
|
+
if (problem)
|
|
173
|
+
throw problem;
|
|
174
|
+
const existing = this.peerObjects.get(id);
|
|
175
|
+
if (existing)
|
|
176
|
+
return existing;
|
|
177
|
+
const view = this.viewOf(id);
|
|
178
|
+
const peer = Object.freeze({
|
|
179
|
+
id,
|
|
180
|
+
get status() {
|
|
181
|
+
return view.status;
|
|
182
|
+
},
|
|
183
|
+
onStatusChange: (callback) => subscribe(view.statusListeners, callback),
|
|
184
|
+
command: (name, input, options) => this.command(id, name, input, options),
|
|
185
|
+
state: Object.freeze({
|
|
186
|
+
get: (key) => view.state.get(key),
|
|
187
|
+
keys: () => [...view.state.keys()],
|
|
188
|
+
onChange: (callback) => subscribe(view.stateListeners, callback),
|
|
189
|
+
}),
|
|
190
|
+
onEvent: (name, callback) => {
|
|
191
|
+
const invalidName = eventNameProblem(name);
|
|
192
|
+
if (invalidName)
|
|
193
|
+
throw new PluginError("invalid", invalidName);
|
|
194
|
+
const problem = this.unreachable(id);
|
|
195
|
+
if (problem)
|
|
196
|
+
throw problem;
|
|
197
|
+
let listeners = view.eventListeners.get(name);
|
|
198
|
+
if (!listeners) {
|
|
199
|
+
listeners = new Set();
|
|
200
|
+
view.eventListeners.set(name, listeners);
|
|
201
|
+
}
|
|
202
|
+
return subscribe(listeners, callback);
|
|
203
|
+
},
|
|
204
|
+
});
|
|
205
|
+
this.peerObjects.set(id, peer);
|
|
206
|
+
return peer;
|
|
207
|
+
}
|
|
208
|
+
unreachable(id) {
|
|
209
|
+
return typeof id !== "string" || (this.peersShared && !this.listed.has(id))
|
|
210
|
+
? new PluginError("invalid", `List "${String(id)}" in manifest.json uses to reach it`)
|
|
211
|
+
: null;
|
|
212
|
+
}
|
|
213
|
+
viewOf(id) {
|
|
214
|
+
let view = this.peerViews.get(id);
|
|
215
|
+
if (!view) {
|
|
216
|
+
view = {
|
|
217
|
+
status: NO_PEERS,
|
|
218
|
+
state: new Map(),
|
|
219
|
+
statusListeners: new Set(),
|
|
220
|
+
stateListeners: new Set(),
|
|
221
|
+
eventListeners: new Map(),
|
|
222
|
+
};
|
|
223
|
+
this.peerViews.set(id, view);
|
|
224
|
+
}
|
|
225
|
+
return view;
|
|
226
|
+
}
|
|
227
|
+
applyPeers(peers) {
|
|
228
|
+
this.peersShared = true;
|
|
229
|
+
this.selfId = peers.self;
|
|
230
|
+
this.listed = new Set(Object.keys(peers.plugins));
|
|
231
|
+
const statusChanges = [];
|
|
232
|
+
const stateChanges = [];
|
|
233
|
+
const unlisted = [...this.peerViews.keys()]
|
|
234
|
+
.filter((id) => !this.listed.has(id))
|
|
235
|
+
.map((id) => [id, { status: UNLISTED, state: {} }]);
|
|
236
|
+
for (const [id, next] of [...Object.entries(peers.plugins), ...unlisted]) {
|
|
237
|
+
const view = this.viewOf(id);
|
|
238
|
+
if (!samePluginStatus(view.status, next.status)) {
|
|
239
|
+
const status = deepFreeze({ ...next.status });
|
|
240
|
+
view.status = status;
|
|
241
|
+
statusChanges.push(() => notify(view.statusListeners, (listener) => listener(status)));
|
|
242
|
+
}
|
|
243
|
+
const state = new Map(Object.entries(next.state).map(([key, value]) => [key, deepFreeze(value)]));
|
|
244
|
+
for (const key of new Set([...view.state.keys(), ...state.keys()])) {
|
|
245
|
+
if (view.state.has(key) === state.has(key) && jsonEqual(view.state.get(key), state.get(key)))
|
|
246
|
+
continue;
|
|
247
|
+
const value = state.get(key);
|
|
248
|
+
stateChanges.push(() => notify(view.stateListeners, (listener) => listener(key, value)));
|
|
249
|
+
}
|
|
250
|
+
view.state = state;
|
|
251
|
+
}
|
|
252
|
+
for (const change of [...statusChanges, ...stateChanges])
|
|
253
|
+
change();
|
|
254
|
+
}
|
|
255
|
+
command(plugin, command, input, options) {
|
|
256
|
+
const unreachable = this.unreachable(plugin);
|
|
257
|
+
if (unreachable)
|
|
258
|
+
return Promise.reject(unreachable);
|
|
259
|
+
const problem = commandArgumentsProblem(command, input, options);
|
|
260
|
+
if (problem)
|
|
261
|
+
return Promise.reject(new PluginError("invalid", problem));
|
|
262
|
+
let plain;
|
|
263
|
+
try {
|
|
264
|
+
plain = input === undefined ? undefined : JSON.parse(JSON.stringify(input));
|
|
265
|
+
}
|
|
266
|
+
catch {
|
|
267
|
+
return Promise.reject(new PluginError("invalid", "Command input must be JSON-compatible"));
|
|
268
|
+
}
|
|
269
|
+
const timeoutMs = options?.timeoutMs;
|
|
270
|
+
return this.request({
|
|
271
|
+
method: "plugins.command",
|
|
272
|
+
params: {
|
|
273
|
+
plugin,
|
|
274
|
+
command,
|
|
275
|
+
...(plain === undefined ? {} : { input: plain }),
|
|
276
|
+
...(timeoutMs === undefined ? {} : { timeoutMs }),
|
|
277
|
+
},
|
|
278
|
+
}, {
|
|
279
|
+
ms: commandBudgetMs(timeoutMs) + COMMAND_GRACE_MS,
|
|
280
|
+
error: () => new PluginError("timeout", `${plugin} didn't answer "${command}" in time; it may have run`),
|
|
281
|
+
});
|
|
282
|
+
}
|
|
126
283
|
receive(message) {
|
|
127
284
|
if (message.type === "welcome") {
|
|
285
|
+
this.plugin = message.plugin;
|
|
128
286
|
this.receive({
|
|
129
287
|
channel: message.channel,
|
|
130
288
|
type: "state",
|
|
131
289
|
config: message.config,
|
|
132
290
|
data: message.data,
|
|
133
291
|
storage: message.storage,
|
|
292
|
+
...(message.peers ? { peers: message.peers } : {}),
|
|
134
293
|
});
|
|
135
294
|
return;
|
|
136
295
|
}
|
|
@@ -145,6 +304,10 @@ class UiClient {
|
|
|
145
304
|
pending.resolve(message.result);
|
|
146
305
|
return;
|
|
147
306
|
}
|
|
307
|
+
if (message.type === "event") {
|
|
308
|
+
this.applyEvent(message);
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
148
311
|
if (message.type !== "state")
|
|
149
312
|
return;
|
|
150
313
|
if (message.config)
|
|
@@ -156,8 +319,19 @@ class UiClient {
|
|
|
156
319
|
this.writesSincePush.clear();
|
|
157
320
|
this.reconcileStorage();
|
|
158
321
|
}
|
|
322
|
+
if (message.peers)
|
|
323
|
+
this.applyPeers(message.peers);
|
|
159
324
|
notify(this.dataListeners, (listener) => listener());
|
|
160
325
|
}
|
|
326
|
+
applyEvent({ plugin, event, payload }) {
|
|
327
|
+
if (!this.listed.has(plugin))
|
|
328
|
+
return;
|
|
329
|
+
const listeners = this.peerViews.get(plugin)?.eventListeners.get(event);
|
|
330
|
+
if (!listeners?.size)
|
|
331
|
+
return;
|
|
332
|
+
const frozen = deepFreeze(payload);
|
|
333
|
+
notify(listeners, (listener) => listener(frozen));
|
|
334
|
+
}
|
|
161
335
|
applyData(data) {
|
|
162
336
|
const changes = this.models.update({
|
|
163
337
|
activities: this.rows.activities.replace(data.activities),
|
|
@@ -193,6 +367,9 @@ class UiClient {
|
|
|
193
367
|
return changed;
|
|
194
368
|
}
|
|
195
369
|
async write(key, value) {
|
|
370
|
+
const checked = checkedAction(value === undefined ? { name: "storageRemove", key } : { name: "storageSet", key, value });
|
|
371
|
+
if (checked?.ok === false)
|
|
372
|
+
throw new PluginError("invalid", checked.problem);
|
|
196
373
|
const writeId = ++this.nextWrite;
|
|
197
374
|
this.pendingWrites.set(key, (this.pendingWrites.get(key) ?? 0) + 1);
|
|
198
375
|
this.writesSincePush.set(key, writeId);
|
|
@@ -221,26 +398,40 @@ class UiClient {
|
|
|
221
398
|
notify(this.dataListeners, (listener) => listener());
|
|
222
399
|
}
|
|
223
400
|
}
|
|
224
|
-
request(request
|
|
225
|
-
|
|
401
|
+
request(request, timeout = {
|
|
402
|
+
ms: REQUEST_TIMEOUT_MS,
|
|
403
|
+
error: () => new PluginError("unavailable", `${request.method} timed out`),
|
|
404
|
+
}) {
|
|
405
|
+
const id = `${PAGE}-${++this.nextRequest}`;
|
|
226
406
|
return new Promise((resolve, reject) => {
|
|
227
407
|
this.pending.set(id, { resolve, reject });
|
|
228
408
|
this.post({ channel: UI_CHANNEL, type: "request", id, ...request });
|
|
229
409
|
setTimeout(() => {
|
|
230
410
|
if (!this.pending.delete(id))
|
|
231
411
|
return;
|
|
232
|
-
reject(
|
|
233
|
-
},
|
|
412
|
+
reject(timeout.error());
|
|
413
|
+
}, timeout.ms);
|
|
234
414
|
});
|
|
235
415
|
}
|
|
236
416
|
}
|
|
417
|
+
function subscribe(listeners, callback) {
|
|
418
|
+
if (typeof callback !== "function")
|
|
419
|
+
throw new PluginError("invalid", "Callback must be a function");
|
|
420
|
+
listeners.add(callback);
|
|
421
|
+
return () => listeners.delete(callback);
|
|
422
|
+
}
|
|
423
|
+
const logListenerError = (error) => console.error("[drift-beacon] Error in plugin listener:", error);
|
|
237
424
|
function notify(listeners, call) {
|
|
238
|
-
for (const listener of listeners) {
|
|
425
|
+
for (const listener of [...listeners]) {
|
|
426
|
+
if (!listeners.has(listener))
|
|
427
|
+
continue;
|
|
239
428
|
try {
|
|
240
|
-
call(listener);
|
|
429
|
+
const result = call(listener);
|
|
430
|
+
if (result && typeof result.then === "function")
|
|
431
|
+
result.then(undefined, logListenerError);
|
|
241
432
|
}
|
|
242
433
|
catch (error) {
|
|
243
|
-
|
|
434
|
+
logListenerError(error);
|
|
244
435
|
}
|
|
245
436
|
}
|
|
246
437
|
}
|
package/dist/version.d.ts
CHANGED
|
@@ -2,5 +2,5 @@
|
|
|
2
2
|
* The plugin API version this SDK describes, `major.minor`. It matches the package version's
|
|
3
3
|
* major and minor. Before 1.0 every minor version may break plugins.
|
|
4
4
|
*/
|
|
5
|
-
export declare const API_VERSION = "0.
|
|
5
|
+
export declare const API_VERSION = "0.2";
|
|
6
6
|
//# sourceMappingURL=version.d.ts.map
|
package/dist/version.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const API_VERSION = "0.
|
|
1
|
+
export const API_VERSION = "0.2";
|
package/dist/vite.js
CHANGED
|
@@ -40,6 +40,8 @@ export function driftBeacon() {
|
|
|
40
40
|
return { root: path.join(pkg, "ui"), base: "./", build: { outDir: path.join(dist, "ui"), emptyOutDir: true } };
|
|
41
41
|
},
|
|
42
42
|
configureServer(server) {
|
|
43
|
+
if (process.env.VITEST)
|
|
44
|
+
return;
|
|
43
45
|
serveDev(server, devStates.get(pkg) ?? startDev(pkg, dist, manifest, manifestBytes, server.config.logger));
|
|
44
46
|
},
|
|
45
47
|
async writeBundle() {
|
|
@@ -90,6 +92,7 @@ function startDev(pkg, dist, manifest, bytes, logger) {
|
|
|
90
92
|
manifest,
|
|
91
93
|
manifestBytes: bytes,
|
|
92
94
|
watcher: Promise.resolve(undefined),
|
|
95
|
+
idle: Promise.resolve(),
|
|
93
96
|
buildId: hashDist(dist),
|
|
94
97
|
uiUrl: null,
|
|
95
98
|
socket: null,
|
|
@@ -110,8 +113,13 @@ async function watchMain(state) {
|
|
|
110
113
|
const staging = path.join(state.pkg, ".drift-beacon/main-next");
|
|
111
114
|
const options = { outDir: staging, minify: false, sourcemap: true, watch: {}, logLevel: "silent" };
|
|
112
115
|
const watcher = (await vite.build(mainBuild(vite, state.pkg, options)));
|
|
116
|
+
let built = () => { };
|
|
113
117
|
watcher.on("event", (event) => {
|
|
114
|
-
if (event.code === "
|
|
118
|
+
if (event.code === "START")
|
|
119
|
+
state.idle = new Promise((resolve) => (built = resolve));
|
|
120
|
+
else if (event.code === "END")
|
|
121
|
+
built();
|
|
122
|
+
else if (event.code === "BUNDLE_END") {
|
|
115
123
|
try {
|
|
116
124
|
moveFiles(staging, path.join(state.dist, "main"));
|
|
117
125
|
const changed = writeDevMarker(state);
|
|
@@ -132,6 +140,7 @@ async function watchMain(state) {
|
|
|
132
140
|
});
|
|
133
141
|
}
|
|
134
142
|
});
|
|
143
|
+
await new Promise((resolve) => watcher.on("event", () => resolve()));
|
|
135
144
|
return watcher;
|
|
136
145
|
}
|
|
137
146
|
function serveDev(server, state) {
|
|
@@ -154,14 +163,13 @@ function serveDev(server, state) {
|
|
|
154
163
|
print(state.logger, { level: "warn", text: noServerHint(state) });
|
|
155
164
|
}, HINT_DELAY_MS).unref();
|
|
156
165
|
});
|
|
157
|
-
const
|
|
166
|
+
const { watcher } = server;
|
|
167
|
+
const closeWatcher = watcher.close.bind(watcher);
|
|
158
168
|
let open = true;
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
}
|
|
164
|
-
return close();
|
|
169
|
+
watcher.close = async () => {
|
|
170
|
+
const released = open ? release(state) : undefined;
|
|
171
|
+
open = false;
|
|
172
|
+
await Promise.all([closeWatcher(), released]);
|
|
165
173
|
};
|
|
166
174
|
server.ws.on("drift-beacon:hello", (data, client) => {
|
|
167
175
|
if (data?.session !== state.session)
|
|
@@ -183,13 +191,15 @@ function serveDev(server, state) {
|
|
|
183
191
|
print(state.logger, data);
|
|
184
192
|
});
|
|
185
193
|
}
|
|
186
|
-
function release(state) {
|
|
194
|
+
async function release(state) {
|
|
187
195
|
if (--state.servers > 0)
|
|
188
196
|
return;
|
|
189
197
|
clearTimeout(state.hint);
|
|
190
198
|
if (devStates.get(state.pkg) === state)
|
|
191
199
|
devStates.delete(state.pkg);
|
|
192
|
-
|
|
200
|
+
const watcher = await state.watcher;
|
|
201
|
+
await state.idle;
|
|
202
|
+
await watcher?.close();
|
|
193
203
|
}
|
|
194
204
|
function updateManifest(state) {
|
|
195
205
|
try {
|
|
@@ -290,8 +300,14 @@ function writeMarker(dist, { pluginId, session, uiUrl }) {
|
|
|
290
300
|
function hashDist(dist) {
|
|
291
301
|
if (!existsSync(path.join(dist, "main/index.js")))
|
|
292
302
|
return null;
|
|
303
|
+
const ui = existsSync(path.join(dist, "ui"))
|
|
304
|
+
? readdirSync(path.join(dist, "ui"), { recursive: true, withFileTypes: true })
|
|
305
|
+
.filter((entry) => entry.isFile())
|
|
306
|
+
.map((entry) => path.relative(dist, path.join(entry.parentPath, entry.name)).split(path.sep).join("/"))
|
|
307
|
+
.sort()
|
|
308
|
+
: [];
|
|
293
309
|
const hash = createHash("sha256");
|
|
294
|
-
for (const name of ["manifest.json", "main/index.js", "main/index.js.map",
|
|
310
|
+
for (const name of ["manifest.json", "main/index.js", "main/index.js.map", ...ui]) {
|
|
295
311
|
const file = path.join(dist, name);
|
|
296
312
|
if (existsSync(file))
|
|
297
313
|
hash.update(`${name}\0`).update(readFileSync(file));
|
package/package.json
CHANGED