@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.
Files changed (43) hide show
  1. package/README.md +95 -11
  2. package/dist/cli.js +0 -8
  3. package/dist/dev.js +0 -7
  4. package/dist/errors.js +0 -1
  5. package/dist/index.js +0 -5
  6. package/dist/internal/collection.js +1 -12
  7. package/dist/internal/compatibility.js +0 -11
  8. package/dist/internal/freeze.js +8 -0
  9. package/dist/internal/json-equal.js +0 -1
  10. package/dist/internal/manifest.js +80 -7
  11. package/dist/internal/models.js +0 -43
  12. package/dist/internal/package-layout.js +0 -7
  13. package/dist/internal/peers.js +55 -0
  14. package/dist/internal/protocol.js +0 -1
  15. package/dist/internal/schema.js +156 -0
  16. package/dist/internal/semver.js +47 -0
  17. package/dist/main.d.ts +98 -1
  18. package/dist/main.js +0 -11
  19. package/dist/pack.js +5 -21
  20. package/dist/prepare.js +52 -25
  21. package/dist/types.d.ts +58 -3
  22. package/dist/types.js +0 -9
  23. package/dist/ui.d.ts +11 -2
  24. package/dist/ui.js +157 -25
  25. package/dist/version.d.ts +1 -1
  26. package/dist/version.js +1 -5
  27. package/dist/vite.js +28 -77
  28. package/package.json +11 -4
  29. package/dist/cli.d.ts +0 -2
  30. package/dist/dev.d.ts +0 -38
  31. package/dist/internal/actions.d.ts +0 -35
  32. package/dist/internal/collection.d.ts +0 -15
  33. package/dist/internal/compatibility.d.ts +0 -26
  34. package/dist/internal/json-equal.d.ts +0 -3
  35. package/dist/internal/manifest.d.ts +0 -51
  36. package/dist/internal/models.d.ts +0 -62
  37. package/dist/internal/package-layout.d.ts +0 -21
  38. package/dist/internal/protocol.d.ts +0 -96
  39. package/dist/internal/rows.d.ts +0 -41
  40. package/dist/internal.d.ts +0 -17
  41. package/dist/internal.js +0 -13
  42. package/dist/pack.d.ts +0 -24
  43. package/dist/prepare.d.ts +0 -28
package/dist/ui.js CHANGED
@@ -1,23 +1,31 @@
1
- /**
2
- * The UI client: runs inside a plugin's iframe and talks to the Drift Beacon web app.
3
- *
4
- * ```ts
5
- * const ctx = await connect();
6
- * ctx.onDataChange(render);
7
- * ```
8
- */
9
1
  import { PluginError } from "./errors.js";
10
2
  import { Collection } from "./internal/collection.js";
3
+ import { deepFreeze } from "./internal/freeze.js";
11
4
  import { jsonEqual } from "./internal/json-equal.js";
12
5
  import { WorkspaceModels } from "./internal/models.js";
6
+ import { commandArgumentsProblem, commandBudgetMs, samePluginStatus } from "./internal/peers.js";
13
7
  import { isUiMessage, UI_CHANNEL, } from "./internal/protocol.js";
14
8
  import { API_VERSION } from "./version.js";
15
9
  export { PluginError } from "./errors.js";
16
10
  export { API_VERSION } from "./version.js";
17
11
  const HELLO_INTERVAL_MS = 250;
18
12
  const REQUEST_TIMEOUT_MS = 10_000;
13
+ const COMMAND_GRACE_MS = 2_000;
14
+ const NO_PEERS = Object.freeze({
15
+ state: "unavailable",
16
+ reason: "This version of Drift Beacon doesn't share other plugins with plugin UIs",
17
+ });
18
+ const UNLISTED = Object.freeze({ state: "unavailable", reason: "No longer in manifest.json uses" });
19
+ const PAGE = (() => {
20
+ const bytes = new Uint8Array(6);
21
+ if (typeof crypto !== "undefined" && typeof crypto.getRandomValues === "function")
22
+ crypto.getRandomValues(bytes);
23
+ else
24
+ for (let i = 0; i < bytes.length; i++)
25
+ bytes[i] = Math.floor(Math.random() * 256);
26
+ return Array.from(bytes, (byte) => byte.toString(36).padStart(2, "0")).join("");
27
+ })();
19
28
  let connection = null;
20
- /** Connect to the Drift Beacon web app. Resolves once the app accepts this UI; later calls return the same context. */
21
29
  export function connect(options = {}) {
22
30
  connection ??= open(options.timeoutMs ?? 10_000);
23
31
  return connection;
@@ -59,7 +67,6 @@ function open(timeoutMs) {
59
67
  };
60
68
  window.addEventListener("message", onMessage);
61
69
  const sayHello = () => post({ channel: UI_CHANNEL, type: "hello", apiVersion: API_VERSION });
62
- // Repeat until answered: the app may still be setting up when the UI first loads.
63
70
  const hello = setInterval(sayHello, HELLO_INTERVAL_MS);
64
71
  sayHello();
65
72
  const timeout = setTimeout(() => {
@@ -73,6 +80,7 @@ class UiClient {
73
80
  post;
74
81
  context;
75
82
  config;
83
+ plugin;
76
84
  rows = {
77
85
  activities: new Collection(),
78
86
  categories: new Collection(),
@@ -86,19 +94,24 @@ class UiClient {
86
94
  storageListeners = new Set();
87
95
  storage = new Map();
88
96
  serverStorage;
89
- /** Keys with writes the app hasn't answered; older pushes must not revert them. */
90
97
  pendingWrites = new Map();
91
- /**
92
- * Keys written since the app last sent storage, with the latest write's ID. The app may answer a
93
- * write before its next push, so an answered write stays in place until that push arrives.
94
- */
95
98
  writesSincePush = new Map();
96
99
  nextWrite = 0;
97
100
  pending = new Map();
98
101
  nextRequest = 0;
102
+ peersShared;
103
+ listed = new Set();
104
+ selfId;
105
+ peerViews = new Map();
106
+ peerObjects = new Map();
99
107
  constructor(welcome, post) {
100
108
  this.post = post;
101
109
  this.config = welcome.config;
110
+ this.plugin = welcome.plugin;
111
+ this.peersShared = welcome.peers !== undefined;
112
+ this.selfId = welcome.peers?.self ?? welcome.plugin.id;
113
+ if (welcome.peers)
114
+ this.applyPeers(welcome.peers);
102
115
  this.serverStorage = welcome.storage;
103
116
  this.storage = new Map(Object.entries(welcome.storage));
104
117
  this.models = new WorkspaceModels({
@@ -119,7 +132,9 @@ class UiClient {
119
132
  return () => listeners.delete(callback);
120
133
  };
121
134
  this.context = {
122
- plugin: welcome.plugin,
135
+ get plugin() {
136
+ return self.plugin;
137
+ },
123
138
  user: welcome.user,
124
139
  workspace: welcome.workspace,
125
140
  get config() {
@@ -132,21 +147,136 @@ class UiClient {
132
147
  storage: {
133
148
  get: (key) => this.storage.get(key),
134
149
  keys: () => [...this.storage.keys()],
135
- set: (key, value) => this.write(key, JSON.parse(JSON.stringify(value))),
150
+ set: (key, value) => {
151
+ if (value === undefined)
152
+ return this.write(key, undefined);
153
+ let plain;
154
+ try {
155
+ plain = JSON.parse(JSON.stringify(value));
156
+ }
157
+ catch {
158
+ return Promise.reject(new PluginError("invalid", `Storage value for "${key}" is not JSON-compatible`));
159
+ }
160
+ return this.write(key, plain);
161
+ },
136
162
  remove: (key) => this.write(key, undefined),
137
163
  onChange: (callback) => subscribe(this.storageListeners, callback),
138
164
  },
165
+ plugins: {
166
+ get: (id) => this.peer(id),
167
+ get self() {
168
+ return self.peer(self.selfId);
169
+ },
170
+ },
139
171
  };
140
172
  }
173
+ peer(id) {
174
+ const problem = this.unreachable(id);
175
+ if (problem)
176
+ throw problem;
177
+ const existing = this.peerObjects.get(id);
178
+ if (existing)
179
+ return existing;
180
+ const view = this.viewOf(id);
181
+ const subscribe = (listeners, callback) => {
182
+ listeners.add(callback);
183
+ return () => listeners.delete(callback);
184
+ };
185
+ const peer = Object.freeze({
186
+ id,
187
+ get status() {
188
+ return view.status;
189
+ },
190
+ onStatusChange: (callback) => subscribe(view.statusListeners, callback),
191
+ command: (name, input, options) => this.command(id, name, input, options),
192
+ state: Object.freeze({
193
+ get: (key) => view.state.get(key),
194
+ keys: () => [...view.state.keys()],
195
+ onChange: (callback) => subscribe(view.stateListeners, callback),
196
+ }),
197
+ });
198
+ this.peerObjects.set(id, peer);
199
+ return peer;
200
+ }
201
+ unreachable(id) {
202
+ return typeof id !== "string" || (this.peersShared && !this.listed.has(id))
203
+ ? new PluginError("invalid", `List "${String(id)}" in manifest.json uses to reach it`)
204
+ : null;
205
+ }
206
+ viewOf(id) {
207
+ let view = this.peerViews.get(id);
208
+ if (!view) {
209
+ view = { status: NO_PEERS, state: new Map(), statusListeners: new Set(), stateListeners: new Set() };
210
+ this.peerViews.set(id, view);
211
+ }
212
+ return view;
213
+ }
214
+ applyPeers(peers) {
215
+ this.peersShared = true;
216
+ this.selfId = peers.self;
217
+ this.listed = new Set(Object.keys(peers.plugins));
218
+ const statusChanges = [];
219
+ const stateChanges = [];
220
+ const unlisted = [...this.peerViews.keys()]
221
+ .filter((id) => !this.listed.has(id))
222
+ .map((id) => [id, { status: UNLISTED, state: {} }]);
223
+ for (const [id, next] of [...Object.entries(peers.plugins), ...unlisted]) {
224
+ const view = this.viewOf(id);
225
+ if (!samePluginStatus(view.status, next.status)) {
226
+ const status = deepFreeze({ ...next.status });
227
+ view.status = status;
228
+ statusChanges.push(() => notify(view.statusListeners, (listener) => listener(status)));
229
+ }
230
+ const state = new Map(Object.entries(next.state).map(([key, value]) => [key, deepFreeze(value)]));
231
+ for (const key of new Set([...view.state.keys(), ...state.keys()])) {
232
+ if (view.state.has(key) === state.has(key) && jsonEqual(view.state.get(key), state.get(key)))
233
+ continue;
234
+ const value = state.get(key);
235
+ stateChanges.push(() => notify(view.stateListeners, (listener) => listener(key, value)));
236
+ }
237
+ view.state = state;
238
+ }
239
+ for (const change of [...statusChanges, ...stateChanges])
240
+ change();
241
+ }
242
+ command(plugin, command, input, options) {
243
+ const unreachable = this.unreachable(plugin);
244
+ if (unreachable)
245
+ return Promise.reject(unreachable);
246
+ const problem = commandArgumentsProblem(command, input, options);
247
+ if (problem)
248
+ return Promise.reject(new PluginError("invalid", problem));
249
+ let plain;
250
+ try {
251
+ plain = input === undefined ? undefined : JSON.parse(JSON.stringify(input));
252
+ }
253
+ catch {
254
+ return Promise.reject(new PluginError("invalid", "Command input must be JSON-compatible"));
255
+ }
256
+ const timeoutMs = options?.timeoutMs;
257
+ return this.request({
258
+ method: "plugins.command",
259
+ params: {
260
+ plugin,
261
+ command,
262
+ ...(plain === undefined ? {} : { input: plain }),
263
+ ...(timeoutMs === undefined ? {} : { timeoutMs }),
264
+ },
265
+ }, {
266
+ ms: commandBudgetMs(timeoutMs) + COMMAND_GRACE_MS,
267
+ error: () => new PluginError("timeout", `${plugin} didn't answer "${command}" in time; it may have run`),
268
+ });
269
+ }
141
270
  receive(message) {
142
271
  if (message.type === "welcome") {
143
- // A reconnect: identity is unchanged, so only the state is new.
272
+ this.plugin = message.plugin;
144
273
  this.receive({
145
274
  channel: message.channel,
146
275
  type: "state",
147
276
  config: message.config,
148
277
  data: message.data,
149
278
  storage: message.storage,
279
+ ...(message.peers ? { peers: message.peers } : {}),
150
280
  });
151
281
  return;
152
282
  }
@@ -172,9 +302,10 @@ class UiClient {
172
302
  this.writesSincePush.clear();
173
303
  this.reconcileStorage();
174
304
  }
305
+ if (message.peers)
306
+ this.applyPeers(message.peers);
175
307
  notify(this.dataListeners, (listener) => listener());
176
308
  }
177
- /** Replace all three collections before telling anyone, so callbacks see one consistent snapshot. */
178
309
  applyData(data) {
179
310
  const changes = this.models.update({
180
311
  activities: this.rows.activities.replace(data.activities),
@@ -188,7 +319,6 @@ class UiClient {
188
319
  for (const change of changes.sessions)
189
320
  notify(this.sessionListeners, (listener) => listener(change));
190
321
  }
191
- /** Apply the app's storage, keeping local values for keys with unconfirmed writes. Returns whether anything changed. */
192
322
  reconcileStorage() {
193
323
  const isProtected = (key) => this.pendingWrites.has(key) || this.writesSincePush.has(key);
194
324
  const next = new Map();
@@ -225,7 +355,6 @@ class UiClient {
225
355
  : { method: "storage.set", params: { key, value } });
226
356
  }
227
357
  catch (error) {
228
- // Roll back to the app's value, unless a later local write replaced this one.
229
358
  if (this.writesSincePush.get(key) === writeId)
230
359
  this.writesSincePush.delete(key);
231
360
  throw error;
@@ -240,16 +369,19 @@ class UiClient {
240
369
  notify(this.dataListeners, (listener) => listener());
241
370
  }
242
371
  }
243
- request(request) {
244
- const id = String(++this.nextRequest);
372
+ request(request, timeout = {
373
+ ms: REQUEST_TIMEOUT_MS,
374
+ error: () => new PluginError("unavailable", `${request.method} timed out`),
375
+ }) {
376
+ const id = `${PAGE}-${++this.nextRequest}`;
245
377
  return new Promise((resolve, reject) => {
246
378
  this.pending.set(id, { resolve, reject });
247
379
  this.post({ channel: UI_CHANNEL, type: "request", id, ...request });
248
380
  setTimeout(() => {
249
381
  if (!this.pending.delete(id))
250
382
  return;
251
- reject(new PluginError("unavailable", `${request.method} timed out`));
252
- }, REQUEST_TIMEOUT_MS);
383
+ reject(timeout.error());
384
+ }, timeout.ms);
253
385
  });
254
386
  }
255
387
  }
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,5 +1 @@
1
- /**
2
- * The plugin API version this SDK describes, `major.minor`. It matches the package version's
3
- * major and minor. Before 1.0 every minor version may break plugins.
4
- */
5
- export const API_VERSION = "0.1";
1
+ export const API_VERSION = "0.2";
package/dist/vite.js CHANGED
@@ -6,18 +6,6 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
6
6
  }
7
7
  return path;
8
8
  };
9
- /**
10
- * `driftBeacon()` (`@drift-beacon/plugin/vite`): the Vite plugin of a plugin package. The UI is the author's
11
- * own Vite app rooted at `ui/`; this plugin builds main next to it, so `dist/` is exactly the release package.
12
- *
13
- * `vite build` builds main once the UI is written. `vite` (`pnpm dev`) serves the UI with HMR, watch-builds main
14
- * into `dist/` and keeps the manifest, the icon and the config types in sync. It writes a marker (dev.ts) that
15
- * the Drift Beacon server polls; the server then connects to Vite's HMR socket and sends lines to print here.
16
- *
17
- * Through `link:`, a bare `vite` import from here would resolve to Cartridge's copy, whose types never match
18
- * another copy's. So vite is only a type here: at run time the plugin loads the project's own vite, and the
19
- * public signature names no vite type.
20
- */
21
9
  import { createHash, randomUUID } from "node:crypto";
22
10
  import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
23
11
  import { createRequire } from "node:module";
@@ -27,12 +15,7 @@ import { stripVTControlCharacters, styleText } from "node:util";
27
15
  import { DEV_MARKER } from "./dev.js";
28
16
  import { PACKAGE_JSON } from "./internal/package-layout.js";
29
17
  import { prepare } from "./prepare.js";
30
- /** How long `pnpm dev` waits for a Drift Beacon server before saying how to connect one. */
31
18
  const HINT_DELAY_MS = 5_000;
32
- /**
33
- * The Vite plugin for a Drift Beacon plugin package: `plugins: [driftBeacon()]`. It owns `root`, `base` and
34
- * `build.outDir`. Typed as `{ name: string }` so it fits any Vite version's `plugins`.
35
- */
36
19
  export function driftBeacon() {
37
20
  let pkg = "";
38
21
  let dist = "";
@@ -44,7 +27,6 @@ export function driftBeacon() {
44
27
  if (env.isPreview)
45
28
  return;
46
29
  pkg = path.resolve(config.root ?? process.cwd());
47
- // Read, not assumed: `dbplugin pack` redirects the whole build into a temp folder through `build.outDir`.
48
30
  dist = config.build?.outDir ? path.resolve(pkg, config.build.outDir) : path.join(pkg, "dist");
49
31
  for (const file of ["ui/index.html", "main/src/index.ts"]) {
50
32
  if (!existsSync(path.join(pkg, file)))
@@ -55,14 +37,13 @@ export function driftBeacon() {
55
37
  throw new Error("driftBeacon(): a custom server.hmr port or path is not supported");
56
38
  }
57
39
  ({ manifest, bytes: manifestBytes } = prepare(pkg, { strict: true }));
58
- // `dist/ui` is outside the root `ui/`, so Vite only empties it when asked to.
59
40
  return { root: path.join(pkg, "ui"), base: "./", build: { outDir: path.join(dist, "ui"), emptyOutDir: true } };
60
41
  },
61
- // `vite` (`pnpm dev`). A Vite restart creates the new server before closing the old one: both share the state.
62
42
  configureServer(server) {
43
+ if (process.env.VITEST)
44
+ return;
63
45
  serveDev(server, devStates.get(pkg) ?? startDev(pkg, dist, manifest, manifestBytes, server.config.logger));
64
46
  },
65
- // `vite build` (and `dbplugin pack`). Runs after the UI is written (and Vite has copied `ui/public/` into `dist/ui/`).
66
47
  async writeBundle() {
67
48
  const vite = await loadVite(pkg);
68
49
  await vite.build(mainBuild(vite, pkg, { outDir: path.join(dist, "main"), minify: true, sourcemap: false, logLevel: "warn" }));
@@ -73,16 +54,12 @@ export function driftBeacon() {
73
54
  };
74
55
  return plugin;
75
56
  }
76
- /** The project's own vite: through `link:`, a bare import would resolve to the SDK's copy. */
77
57
  const loadVite = async (pkg) => import(__rewriteRelativeImportExtension(pathToFileURL(createRequire(path.join(pkg, "package.json")).resolve("vite")).href));
78
- /** Main as one ESM file, with every dependency bundled except the SDK, which the plugin host supplies. */
79
58
  function mainBuild(vite, pkg, options) {
80
- // One file on both Vite 7 (Rollup) and Vite 8 (Rolldown, which deprecates `inlineDynamicImports`).
81
59
  const single = "rolldownVersion" in vite ? { codeSplitting: false } : { inlineDynamicImports: true };
82
60
  return {
83
61
  configFile: false,
84
62
  root: pkg,
85
- // Vite copies publicDir in every build: a stray `<pkg>/public/` must not reach `dist/main/`.
86
63
  publicDir: false,
87
64
  logLevel: options.logLevel,
88
65
  build: {
@@ -101,13 +78,10 @@ function mainBuild(vite, pkg, options) {
101
78
  ssr: { noExternal: true },
102
79
  };
103
80
  }
104
- // On globalThis, so the state survives whatever reloads this module; keyed by package root, so one process
105
- // (a test file) can serve several roots.
106
81
  const DEV_STATES = Symbol.for("drift-beacon.dev");
107
82
  const shared = globalThis;
108
83
  shared[DEV_STATES] ??= new Map();
109
84
  const devStates = shared[DEV_STATES];
110
- /** Once per root: seed `dist/` and start the main watch build. */
111
85
  function startDev(pkg, dist, manifest, bytes, logger) {
112
86
  writeAtomic(path.join(dist, "manifest.json"), bytes);
113
87
  writeAtomic(path.join(dist, "package.json"), `${JSON.stringify(PACKAGE_JSON, null, 2)}\n`);
@@ -118,6 +92,7 @@ function startDev(pkg, dist, manifest, bytes, logger) {
118
92
  manifest,
119
93
  manifestBytes: bytes,
120
94
  watcher: Promise.resolve(undefined),
95
+ idle: Promise.resolve(),
121
96
  buildId: hashDist(dist),
122
97
  uiUrl: null,
123
98
  socket: null,
@@ -133,18 +108,18 @@ function startDev(pkg, dist, manifest, bytes, logger) {
133
108
  });
134
109
  return state;
135
110
  }
136
- /**
137
- * Builds main into `.drift-beacon/main-next/` on every save, then moves the files into `dist/main/`: never a
138
- * half-written bundle in `dist/`, and at the same depth, so the map's relative sources still resolve.
139
- */
140
111
  async function watchMain(state) {
141
112
  const vite = await loadVite(state.pkg);
142
113
  const staging = path.join(state.pkg, ".drift-beacon/main-next");
143
114
  const options = { outDir: staging, minify: false, sourcemap: true, watch: {}, logLevel: "silent" };
144
115
  const watcher = (await vite.build(mainBuild(vite, state.pkg, options)));
145
- // Vite closes each event's bundle itself.
116
+ let built = () => { };
146
117
  watcher.on("event", (event) => {
147
- 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") {
148
123
  try {
149
124
  moveFiles(staging, path.join(state.dist, "main"));
150
125
  const changed = writeDevMarker(state);
@@ -158,7 +133,6 @@ async function watchMain(state) {
158
133
  }
159
134
  }
160
135
  else if (event.code === "ERROR") {
161
- // dist/ is untouched, so the server keeps the last good build.
162
136
  const reason = shorten(state, event.error.message).replaceAll(/\n\s*\n/g, "\n");
163
137
  print(state.logger, {
164
138
  level: "error",
@@ -166,15 +140,14 @@ async function watchMain(state) {
166
140
  });
167
141
  }
168
142
  });
143
+ await new Promise((resolve) => watcher.on("event", () => resolve()));
169
144
  return watcher;
170
145
  }
171
- /** Per dev server: the manifest and icon watchers, the marker, and the server's socket. */
172
146
  function serveDev(server, state) {
173
147
  state.servers += 1;
174
148
  state.logger = server.config.logger;
175
149
  const manifestFile = path.join(state.pkg, "manifest.json");
176
150
  server.watcher.add(manifestFile);
177
- // Vite already watches its root `ui/`, so the icon's changes arrive here too.
178
151
  server.watcher.on("all", (event, file) => {
179
152
  if (file === manifestFile && event !== "unlink")
180
153
  updateManifest(state);
@@ -183,7 +156,6 @@ function serveDev(server, state) {
183
156
  });
184
157
  server.httpServer?.on("listening", () => {
185
158
  state.uiUrl = server.resolvedUrls?.local[0] ?? null;
186
- // Even before main has built: the server can connect and serve the UI meanwhile.
187
159
  writeDevMarker(state);
188
160
  clearTimeout(state.hint);
189
161
  state.hint = setTimeout(() => {
@@ -191,22 +163,19 @@ function serveDev(server, state) {
191
163
  print(state.logger, { level: "warn", text: noServerHint(state) });
192
164
  }, HINT_DELAY_MS).unref();
193
165
  });
194
- // On the dev server's own close: its http server emits "close" only if it listened, and has none in middleware mode.
195
- const close = server.close.bind(server);
166
+ const { watcher } = server;
167
+ const closeWatcher = watcher.close.bind(watcher);
196
168
  let open = true;
197
- server.close = () => {
198
- if (open) {
199
- open = false;
200
- release(state);
201
- }
202
- return close();
169
+ watcher.close = async () => {
170
+ const released = open ? release(state) : undefined;
171
+ open = false;
172
+ await Promise.all([closeWatcher(), released]);
203
173
  };
204
- // Vite custom events over the HMR socket. Never `server.ws.send`: it broadcasts, plugin iframes included.
205
174
  server.ws.on("drift-beacon:hello", (data, client) => {
206
175
  if (data?.session !== state.session)
207
176
  return;
208
177
  const welcome = { v: 1, session: state.session, pluginId: state.manifest.id };
209
- client.send("drift-beacon:welcome", welcome); // this socket only
178
+ client.send("drift-beacon:welcome", welcome);
210
179
  clearTimeout(state.hint);
211
180
  const { socket } = client;
212
181
  state.socket = socket;
@@ -222,25 +191,23 @@ function serveDev(server, state) {
222
191
  print(state.logger, data);
223
192
  });
224
193
  }
225
- /** One dev server closed: the last one for the root stops the main watch build and forgets the root. */
226
- function release(state) {
194
+ async function release(state) {
227
195
  if (--state.servers > 0)
228
196
  return;
229
197
  clearTimeout(state.hint);
230
198
  if (devStates.get(state.pkg) === state)
231
199
  devStates.delete(state.pkg);
232
- void state.watcher.then((watcher) => watcher?.close());
200
+ const watcher = await state.watcher;
201
+ await state.idle;
202
+ await watcher?.close();
233
203
  }
234
- /** A saved manifest: checked, copied, and its config types and icon refreshed. An invalid one keeps the old copy. */
235
204
  function updateManifest(state) {
236
205
  try {
237
206
  const { manifest, bytes } = prepare(state.pkg, { strict: true });
238
- // A missing icon was already reported under its name; say it again only for a new name.
239
207
  const renamed = manifest.icon !== state.manifest.icon;
240
208
  state.manifest = manifest;
241
209
  state.manifestBytes = bytes;
242
210
  syncIcon(state, renamed);
243
- // Copies the manifest.
244
211
  writeDevMarker(state);
245
212
  }
246
213
  catch (error) {
@@ -250,7 +217,6 @@ function updateManifest(state) {
250
217
  });
251
218
  }
252
219
  }
253
- /** Copies the manifest icon from `ui/public/` into `dist/ui/` (Vite does it in a build), or removes a deleted one. */
254
220
  function syncIcon(state, warnMissing) {
255
221
  const { icon } = state.manifest;
256
222
  if (!icon)
@@ -275,7 +241,6 @@ function syncIcon(state, warnMissing) {
275
241
  });
276
242
  }
277
243
  }
278
- /** Moves every staged file into `to`, maps first and `index.js` last, so the new index.js never meets an old map. */
279
244
  function moveFiles(from, to) {
280
245
  const rank = (file) => (file === "index.js" ? 2 : file.endsWith(".map") ? 0 : 1);
281
246
  const files = readdirSync(from, { recursive: true, withFileTypes: true })
@@ -287,10 +252,6 @@ function moveFiles(from, to) {
287
252
  renameSync(path.join(from, file), path.join(to, file));
288
253
  }
289
254
  }
290
- /**
291
- * Writes the dev marker for what is in `dist/` now; true when its build id changed. The manifest, `package.json`
292
- * and a missing icon are written first, so a `dist/` deleted while `pnpm dev` runs is whole again.
293
- */
294
255
  function writeDevMarker(state) {
295
256
  writeAtomic(path.join(state.dist, "manifest.json"), state.manifestBytes);
296
257
  writeAtomic(path.join(state.dist, "package.json"), `${JSON.stringify(PACKAGE_JSON, null, 2)}\n`);
@@ -307,7 +268,6 @@ const noServerHint = (state) => [
307
268
  `The server connects when it finds dist/${DEV_MARKER} in a folder directly`,
308
269
  `under its DEV_PLUGINS_PATH. Run it in development mode with DEV_PLUGINS_PATH=${path.dirname(state.pkg)}`,
309
270
  ].join("\n");
310
- /** Plain text with package paths made relative, for messages that come from Vite or the file system. */
311
271
  const shorten = (state, text) => stripVTControlCharacters(text).replaceAll(`${state.pkg}${path.sep}`, "").trim();
312
272
  const TIME = new Intl.DateTimeFormat(undefined, { hour: "numeric", minute: "numeric", second: "numeric" });
313
273
  const STYLES = {
@@ -316,11 +276,6 @@ const STYLES = {
316
276
  warn: "yellow",
317
277
  error: "red",
318
278
  };
319
- /**
320
- * Prints a line (from the server, or about the local build) through Vite's logger, timed like Vite's own lines
321
- * but tagged `[beacon]` (Vite's `timestamp` option would tag it `[vite]`). The first line is coloured by level;
322
- * the others are dimmed and indented under it.
323
- */
324
279
  function print(logger, { level, text }) {
325
280
  const time = TIME.format(new Date());
326
281
  const [first = "", ...rest] = String(text).split("\n");
@@ -336,33 +291,29 @@ function print(logger, { level, text }) {
336
291
  else
337
292
  logger.info(message);
338
293
  }
339
- // ============================================================================
340
- // Shared
341
- // ============================================================================
342
- /** Writes the marker for what is in `dist/` now and returns its build id. */
343
294
  function writeMarker(dist, { pluginId, session, uiUrl }) {
344
295
  const buildId = hashDist(dist);
345
- // The HMR socket shares the UI's host and port; a relative base serves it at `/`.
346
296
  const marker = { v: 1, pluginId, buildId, session, uiUrl, wsUrl: uiUrl?.replace(/^http/, "ws") ?? null };
347
297
  writeAtomic(path.join(dist, DEV_MARKER), `${JSON.stringify(marker, null, 2)}\n`);
348
298
  return buildId;
349
299
  }
350
- /**
351
- * The build id: sha256 of what the server loads, first 16 hex characters. Null until main is built. It
352
- * covers `ui/index.html` so a UI-only build still refreshes an open static UI.
353
- */
354
300
  function hashDist(dist) {
355
301
  if (!existsSync(path.join(dist, "main/index.js")))
356
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
+ : [];
357
309
  const hash = createHash("sha256");
358
- 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]) {
359
311
  const file = path.join(dist, name);
360
312
  if (existsSync(file))
361
313
  hash.update(`${name}\0`).update(readFileSync(file));
362
314
  }
363
315
  return hash.digest("hex").slice(0, 16);
364
316
  }
365
- /** Writes through a temporary file and a rename, so the server never reads a partial file. */
366
317
  function writeAtomic(file, content) {
367
318
  mkdirSync(path.dirname(file), { recursive: true });
368
319
  writeFileSync(`${file}.tmp`, content);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@drift-beacon/plugin",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
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": {
@@ -38,8 +38,15 @@
38
38
  },
39
39
  "files": [
40
40
  "bin",
41
- "dist",
42
- "!dist/**/*.map"
41
+ "dist/**/*.js",
42
+ "!dist/internal.js",
43
+ "dist/index.d.ts",
44
+ "dist/ui.d.ts",
45
+ "dist/vite.d.ts",
46
+ "dist/main.d.ts",
47
+ "dist/types.d.ts",
48
+ "dist/errors.d.ts",
49
+ "dist/version.d.ts"
43
50
  ],
44
51
  "publishConfig": {
45
52
  "access": "public"
@@ -65,7 +72,7 @@
65
72
  "vite": "7.1.2"
66
73
  },
67
74
  "scripts": {
68
- "build": "rm -rf dist && tsc -p tsconfig.json",
75
+ "build": "rm -rf dist && tsc -p tsconfig.json --removeComments && tsc -p tsconfig.json --emitDeclarationOnly",
69
76
  "clean": "rm -rf dist",
70
77
  "typecheck": "tsc --noEmit && node scripts/api-report.mjs --check",
71
78
  "api:update": "node scripts/api-report.mjs"
package/dist/cli.d.ts DELETED
@@ -1,2 +0,0 @@
1
- export {};
2
- //# sourceMappingURL=cli.d.ts.map
package/dist/dev.d.ts DELETED
@@ -1,38 +0,0 @@
1
- /**
2
- * The `pnpm dev` handshake between `driftBeacon()` and the Drift Beacon server. The dev tool writes a
3
- * marker into the package's `dist/`; the server polls for it, connects to Vite's HMR socket and trusts
4
- * the socket only after a `welcome` whose `session` equals the marker's. Messages travel as Vite custom
5
- * events (`{ type: "custom", event, data }`). All wording is rendered on the server; the tool only prints.
6
- */
7
- /** File name of the marker in `dist/`. Written as tmp, then renamed, synchronously. Never packed. */
8
- export declare const DEV_MARKER = ".drift-beacon-dev.json";
9
- export interface DevMarker {
10
- readonly v: 1;
11
- /** `manifest.id`; the server routes logs and the dev UI URL by it. */
12
- readonly pluginId: string;
13
- /** Hash of the built package; null until `dist/main/index.js` exists. A new id means a new build. */
14
- readonly buildId: string | null;
15
- /** One per `pnpm dev` process and package root; null after `vite build`. */
16
- readonly session: string | null;
17
- /** The dev server's UI URL, for example `http://localhost:5174/`. */
18
- readonly uiUrl: string | null;
19
- /** The dev server's HMR socket, for example `ws://localhost:5174/`. */
20
- readonly wsUrl: string | null;
21
- }
22
- /** Server → tool, event `drift-beacon:hello`. */
23
- export interface DevHello {
24
- readonly v: 1;
25
- readonly session: string;
26
- }
27
- /** Tool → server, event `drift-beacon:welcome`, sent only to the socket that said hello. */
28
- export interface DevWelcome {
29
- readonly v: 1;
30
- readonly session: string;
31
- readonly pluginId: string;
32
- }
33
- /** Server → tool, event `drift-beacon:print`: one line (or block) for the plugin's terminal. */
34
- export interface DevPrint {
35
- readonly level: "info" | "success" | "warn" | "error";
36
- readonly text: string;
37
- }
38
- //# sourceMappingURL=dev.d.ts.map