@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/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: welcome.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) => this.write(key, JSON.parse(JSON.stringify(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
- const id = String(++this.nextRequest);
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(new PluginError("unavailable", `${request.method} timed out`));
233
- }, REQUEST_TIMEOUT_MS);
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
- console.error("[drift-beacon] Error in plugin listener:", error);
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.1";
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";
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 === "BUNDLE_END") {
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 close = server.close.bind(server);
166
+ const { watcher } = server;
167
+ const closeWatcher = watcher.close.bind(watcher);
158
168
  let open = true;
159
- server.close = () => {
160
- if (open) {
161
- open = false;
162
- release(state);
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
- void state.watcher.then((watcher) => watcher?.close());
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", "ui/index.html"]) {
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@drift-beacon/plugin",
3
- "version": "0.1.1",
3
+ "version": "0.2.1",
4
4
  "description": "The Drift Beacon plugin SDK: APIs for plugin main code and UIs, the driftBeacon() Vite plugin and the dbplugin CLI",
5
5
  "license": "MIT",
6
6
  "author": {