@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
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
|
|
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) =>
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
252
|
-
},
|
|
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.
|
|
5
|
+
export declare const API_VERSION = "0.2";
|
|
6
6
|
//# sourceMappingURL=version.d.ts.map
|
package/dist/version.js
CHANGED
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
|
-
|
|
116
|
+
let built = () => { };
|
|
146
117
|
watcher.on("event", (event) => {
|
|
147
|
-
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") {
|
|
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
|
-
|
|
195
|
-
const
|
|
166
|
+
const { watcher } = server;
|
|
167
|
+
const closeWatcher = watcher.close.bind(watcher);
|
|
196
168
|
let open = true;
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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);
|
|
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
|
-
|
|
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
|
-
|
|
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",
|
|
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.
|
|
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
|
|
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
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
|