@qping/plugin-bus 0.1.0 → 0.3.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/dist/node.mjs ADDED
@@ -0,0 +1,851 @@
1
+ var __defProp = Object.defineProperty;
2
+ var __getOwnPropNames = Object.getOwnPropertyNames;
3
+ var __esm = (fn, res) => function __init() {
4
+ return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
5
+ };
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+
11
+ // src/framing.ts
12
+ var framing_exports = {};
13
+ __export(framing_exports, {
14
+ FrameDecoder: () => FrameDecoder,
15
+ MAX_FRAME_BYTES: () => MAX_FRAME_BYTES,
16
+ PREFIX_BYTES: () => PREFIX_BYTES,
17
+ encodeFrame: () => encodeFrame,
18
+ encodeFrameString: () => encodeFrameString
19
+ });
20
+ function encodeFrame(payload) {
21
+ const length = payload.length;
22
+ const frame = Buffer.alloc(PREFIX_BYTES + length);
23
+ frame[0] = length & 255;
24
+ frame[1] = length >> 8 & 255;
25
+ frame[2] = length >> 16 & 255;
26
+ frame[3] = length >> 24 & 255;
27
+ payload.copy(frame, PREFIX_BYTES);
28
+ return frame;
29
+ }
30
+ function encodeFrameString(json) {
31
+ return encodeFrame(Buffer.from(json, "utf8"));
32
+ }
33
+ var MAX_FRAME_BYTES, PREFIX_BYTES, FrameDecoder;
34
+ var init_framing = __esm({
35
+ "src/framing.ts"() {
36
+ MAX_FRAME_BYTES = 4 * 1024 * 1024;
37
+ PREFIX_BYTES = 4;
38
+ FrameDecoder = class {
39
+ prefixBuf = Buffer.alloc(PREFIX_BYTES);
40
+ prefixFilled = 0;
41
+ payload = null;
42
+ payloadFilled = 0;
43
+ payloadLength = 0;
44
+ fatal = false;
45
+ pending = Buffer.alloc(0);
46
+ feed(chunk) {
47
+ const empty = { hasFrame: false, payload: Buffer.alloc(0), isFatal: false };
48
+ if (this.fatal) {
49
+ return { hasFrame: false, payload: Buffer.alloc(0), isFatal: true };
50
+ }
51
+ let current;
52
+ if (this.pending.length > 0) {
53
+ current = Buffer.concat([this.pending, chunk]);
54
+ this.pending = Buffer.alloc(0);
55
+ } else {
56
+ current = chunk;
57
+ }
58
+ let offset = 0;
59
+ while (offset < current.length) {
60
+ if (this.payload === null) {
61
+ const need = PREFIX_BYTES - this.prefixFilled;
62
+ const take = Math.min(need, current.length - offset);
63
+ current.copy(this.prefixBuf, this.prefixFilled, offset, offset + take);
64
+ this.prefixFilled += take;
65
+ offset += take;
66
+ if (this.prefixFilled < PREFIX_BYTES) {
67
+ return empty;
68
+ }
69
+ this.payloadLength = this.prefixBuf[0] | this.prefixBuf[1] << 8 | this.prefixBuf[2] << 16 | this.prefixBuf[3] << 24;
70
+ if (this.payloadLength < 0 || this.payloadLength > MAX_FRAME_BYTES) {
71
+ this.fatal = true;
72
+ return { hasFrame: false, payload: Buffer.alloc(0), isFatal: true };
73
+ }
74
+ if (this.payloadLength === 0) {
75
+ this.reset();
76
+ this.bufferLeftover(current, offset);
77
+ return { hasFrame: true, payload: Buffer.alloc(0), isFatal: false };
78
+ }
79
+ this.payload = Buffer.alloc(this.payloadLength);
80
+ this.payloadFilled = 0;
81
+ }
82
+ const payloadNeed = this.payloadLength - this.payloadFilled;
83
+ const payloadTake = Math.min(payloadNeed, current.length - offset);
84
+ current.copy(this.payload, this.payloadFilled, offset, offset + payloadTake);
85
+ this.payloadFilled += payloadTake;
86
+ offset += payloadTake;
87
+ if (this.payloadFilled >= this.payloadLength) {
88
+ const out = this.payload;
89
+ this.reset();
90
+ this.bufferLeftover(current, offset);
91
+ return { hasFrame: true, payload: out, isFatal: false };
92
+ }
93
+ }
94
+ return empty;
95
+ }
96
+ bufferLeftover(src, offset) {
97
+ if (offset < src.length) {
98
+ this.pending = src.subarray(offset);
99
+ }
100
+ }
101
+ reset() {
102
+ this.prefixFilled = 0;
103
+ this.payload = null;
104
+ this.payloadFilled = 0;
105
+ this.payloadLength = 0;
106
+ }
107
+ };
108
+ }
109
+ });
110
+
111
+ // src/bootstrap.ts
112
+ import readline from "node:readline/promises";
113
+ import { randomBytes as randomBytes2 } from "node:crypto";
114
+
115
+ // src/transport.ts
116
+ init_framing();
117
+ import { connect as netConnect } from "node:net";
118
+
119
+ // src/protocol.ts
120
+ var MessageKind = {
121
+ Request: "request",
122
+ Response: "response",
123
+ Event: "event"
124
+ };
125
+ var ErrorCode = {
126
+ ProtocolMismatch: "ProtocolMismatch",
127
+ HandshakeFailed: "HandshakeFailed",
128
+ CapabilityNotDeclared: "CapabilityNotDeclared",
129
+ CapabilityDenied: "CapabilityDenied",
130
+ InvalidPayload: "InvalidPayload",
131
+ MessageTooLarge: "MessageTooLarge",
132
+ RouteNotFound: "RouteNotFound",
133
+ RequestTimeout: "RequestTimeout",
134
+ TooManyRequests: "TooManyRequests",
135
+ TransportDisconnected: "TransportDisconnected",
136
+ PluginUnavailable: "PluginUnavailable",
137
+ InternalError: "InternalError",
138
+ Cancelled: "Cancelled",
139
+ RateLimited: "RateLimited"
140
+ };
141
+ var ProtocolVersion = "3.0";
142
+ var EndpointIds = {
143
+ NodeMain: "node-main",
144
+ Host: "host"
145
+ };
146
+ var Routes = {
147
+ Bus: {
148
+ Handshake: "bus.handshake",
149
+ Ping: "bus.ping",
150
+ Cancel: "bus.cancel",
151
+ Subscribe: "bus.subscribe",
152
+ Unsubscribe: "bus.unsubscribe"
153
+ },
154
+ Prefix: {
155
+ PluginCall: "plugin.call.",
156
+ HostCall: "host.call.",
157
+ PluginEvent: "plugin.event.",
158
+ HostEvent: "host.event.",
159
+ Diagnostics: "diagnostics."
160
+ },
161
+ PluginCall: {
162
+ Initialize: "plugin.call.initialize",
163
+ Search: "plugin.call.search",
164
+ InvokeAction: "plugin.call.invokeAction"
165
+ },
166
+ HostEvent: {
167
+ Initialize: "host.event.initialize",
168
+ Search: "host.event.search",
169
+ Key: "host.event.key",
170
+ DetailAction: "host.event.detailAction",
171
+ LanguageChanged: "host.event.languageChanged",
172
+ ThemeChanged: "host.event.themeChanged",
173
+ InputActionCaptured: "host.event.inputActionCaptured"
174
+ }
175
+ };
176
+ function pluginCallRoute(method) {
177
+ return method.startsWith(Routes.Prefix.PluginCall) ? method : `${Routes.Prefix.PluginCall}${method}`;
178
+ }
179
+ function hostCallRoute(method) {
180
+ return method.startsWith(Routes.Prefix.HostCall) ? method : `${Routes.Prefix.HostCall}${method}`;
181
+ }
182
+ function pluginEventRoute(subjectId) {
183
+ return subjectId.startsWith(Routes.Prefix.PluginEvent) ? subjectId : `${Routes.Prefix.PluginEvent}${subjectId}`;
184
+ }
185
+ function canonicalStringify(value) {
186
+ return JSON.stringify(stripNulls(value));
187
+ }
188
+ function stripNulls(value) {
189
+ if (value === null || value === void 0) return void 0;
190
+ if (Array.isArray(value)) return value.map(stripNulls);
191
+ if (typeof value === "object") {
192
+ const out = {};
193
+ for (const [k, v] of Object.entries(value)) {
194
+ const stripped = stripNulls(v);
195
+ if (stripped !== void 0) out[k] = stripped;
196
+ }
197
+ return out;
198
+ }
199
+ return value;
200
+ }
201
+
202
+ // src/transport.ts
203
+ var NodeTransport = class {
204
+ socket = null;
205
+ messageHandlers = /* @__PURE__ */ new Set();
206
+ disconnectHandlers = /* @__PURE__ */ new Set();
207
+ closed = false;
208
+ onMessage(handler) {
209
+ this.messageHandlers.add(handler);
210
+ return () => {
211
+ this.messageHandlers.delete(handler);
212
+ };
213
+ }
214
+ onDisconnect(handler) {
215
+ this.disconnectHandlers.add(handler);
216
+ }
217
+ get isConnected() {
218
+ return this.socket !== null && !this.closed;
219
+ }
220
+ /** Connects to a Windows named pipe (\\.\pipe\<name>). */
221
+ async connect(pipePath) {
222
+ this.socket = netConnect(pipePath);
223
+ await new Promise((resolve, reject) => {
224
+ this.socket.once("connect", () => resolve());
225
+ this.socket.once("error", (err) => reject(err));
226
+ });
227
+ const { FrameDecoder: FrameDecoder2 } = await Promise.resolve().then(() => (init_framing(), framing_exports));
228
+ const decoder = new FrameDecoder2();
229
+ this.socket.on("data", (chunk) => {
230
+ let result = decoder.feed(chunk);
231
+ if (result.isFatal) {
232
+ this.handleDisconnect();
233
+ return;
234
+ }
235
+ while (result.hasFrame) {
236
+ try {
237
+ const env = JSON.parse(result.payload.toString("utf8"));
238
+ for (const h of this.messageHandlers) h(env);
239
+ } catch {
240
+ this.handleDisconnect();
241
+ return;
242
+ }
243
+ result = decoder.feed(Buffer.alloc(0));
244
+ if (result.isFatal) {
245
+ this.handleDisconnect();
246
+ return;
247
+ }
248
+ }
249
+ });
250
+ this.socket.on("close", () => this.handleDisconnect());
251
+ this.socket.on("error", () => this.handleDisconnect());
252
+ }
253
+ /** Serializes an envelope to a length-prefixed frame and writes it. */
254
+ send(env) {
255
+ if (!this.socket || this.closed) {
256
+ throw new Error("transport is not connected");
257
+ }
258
+ this.socket.write(encodeFrameString(canonicalStringify(env)));
259
+ }
260
+ async close() {
261
+ this.closed = true;
262
+ if (this.socket) {
263
+ this.socket.end();
264
+ await new Promise((resolve) => {
265
+ if (this.socket.destroyed) return resolve();
266
+ this.socket.once("close", () => resolve());
267
+ });
268
+ this.socket = null;
269
+ }
270
+ }
271
+ handleDisconnect() {
272
+ if (this.closed) return;
273
+ this.closed = true;
274
+ for (const h of this.disconnectHandlers) h();
275
+ }
276
+ };
277
+
278
+ // src/router.ts
279
+ import { AsyncLocalStorage } from "node:async_hooks";
280
+ import { randomBytes } from "node:crypto";
281
+ var requestScope = new AsyncLocalStorage();
282
+ var DefaultHostCallTimeoutMs = 3e4;
283
+ function remainingTimeoutMs() {
284
+ const scope = requestScope.getStore();
285
+ if (!scope || scope.deadlineMs == null) {
286
+ return void 0;
287
+ }
288
+ return scope.deadlineMs - Date.now();
289
+ }
290
+ function resolveHostCallTimeoutMs(explicit) {
291
+ const remaining = remainingTimeoutMs();
292
+ if (explicit != null) {
293
+ return remaining == null ? explicit : Math.min(explicit, remaining);
294
+ }
295
+ return remaining ?? DefaultHostCallTimeoutMs;
296
+ }
297
+ function deadlineFromTimeoutMs(timeoutMs) {
298
+ if (typeof timeoutMs !== "number" || timeoutMs <= 0) {
299
+ return null;
300
+ }
301
+ return Date.now() + timeoutMs;
302
+ }
303
+ var HandlerRouter = class {
304
+ handlers = /* @__PURE__ */ new Map();
305
+ pendingHostCalls = /* @__PURE__ */ new Map();
306
+ pluginId = "p";
307
+ entryId = "e";
308
+ sessionId = "s";
309
+ endpointId = EndpointIds.NodeMain;
310
+ /** Injected transport send fn; tests can override `router.send` directly. */
311
+ send;
312
+ constructor(deps) {
313
+ this.send = deps.send;
314
+ }
315
+ /** Sets the bound identity stamped on outbound messages (after handshake). */
316
+ setIdentity(ids) {
317
+ this.pluginId = ids.pluginId;
318
+ this.entryId = ids.entryId;
319
+ this.sessionId = ids.sessionId;
320
+ this.endpointId = ids.endpointId;
321
+ }
322
+ handle(route, handler) {
323
+ this.handlers.set(route, handler);
324
+ }
325
+ /** Dispatches an inbound request/response. Returns once handled. */
326
+ async dispatch(env) {
327
+ if (env.kind === MessageKind.Response) {
328
+ this.handleHostResponse(env);
329
+ return;
330
+ }
331
+ if (env.kind !== MessageKind.Request) return;
332
+ if (env.route === Routes.Bus.Ping) {
333
+ this.send(this.responseFor(env, { ok: true }));
334
+ return;
335
+ }
336
+ const handler = this.handlers.get(env.route);
337
+ if (!handler) {
338
+ this.send(this.errorResponseFor(env, ErrorCode.RouteNotFound, `route '${env.route}' has no handler`));
339
+ return;
340
+ }
341
+ const deadlineMs = deadlineFromTimeoutMs(env.timeoutMs);
342
+ try {
343
+ const result = await requestScope.run(
344
+ { deadlineMs },
345
+ () => handler(env.payload, { sessionId: env.sessionId })
346
+ );
347
+ this.send(this.responseFor(env, result ?? {}));
348
+ } catch (err) {
349
+ const message = err instanceof Error ? err.message : String(err);
350
+ this.send(this.errorResponseFor(env, ErrorCode.InternalError, message));
351
+ }
352
+ }
353
+ /** Calls a host.call.* capability and resolves with the response payload. */
354
+ callHost(route, payload, timeoutMs) {
355
+ const effectiveTimeoutMs = resolveHostCallTimeoutMs(timeoutMs);
356
+ if (effectiveTimeoutMs <= 0) {
357
+ return Promise.reject(new Error(`host call ${route} timed out (no time remaining)`));
358
+ }
359
+ return new Promise((resolve, reject) => {
360
+ const id = randomBytesHex();
361
+ const req = {
362
+ version: ProtocolVersion,
363
+ id,
364
+ traceId: id,
365
+ sessionId: this.sessionId,
366
+ pluginId: this.pluginId,
367
+ entryId: this.entryId,
368
+ endpointId: this.endpointId,
369
+ kind: MessageKind.Request,
370
+ route,
371
+ timeoutMs: effectiveTimeoutMs,
372
+ payload
373
+ };
374
+ const pending = { resolve, reject, route };
375
+ this.pendingHostCalls.set(id, pending);
376
+ const timer = setTimeout(() => {
377
+ if (this.pendingHostCalls.has(id)) {
378
+ this.pendingHostCalls.delete(id);
379
+ reject(new Error(`host call ${route} timed out after ${effectiveTimeoutMs}ms`));
380
+ }
381
+ }, effectiveTimeoutMs);
382
+ const origResolve = pending.resolve;
383
+ const origReject = pending.reject;
384
+ pending.resolve = (v) => {
385
+ clearTimeout(timer);
386
+ origResolve(v);
387
+ };
388
+ pending.reject = (e) => {
389
+ clearTimeout(timer);
390
+ origReject(e);
391
+ };
392
+ this.send(req);
393
+ });
394
+ }
395
+ handleHostResponse(env) {
396
+ if (!env.correlationId) return;
397
+ const pending = this.pendingHostCalls.get(env.correlationId);
398
+ if (!pending) return;
399
+ this.pendingHostCalls.delete(env.correlationId);
400
+ if (env.error) {
401
+ pending.reject(new Error(`${env.error.code}: ${env.error.message}`));
402
+ } else {
403
+ pending.resolve(env.payload);
404
+ }
405
+ }
406
+ responseFor(req, payload) {
407
+ return {
408
+ version: ProtocolVersion,
409
+ id: randomBytesHex(),
410
+ correlationId: req.id,
411
+ traceId: req.traceId,
412
+ sessionId: req.sessionId,
413
+ pluginId: req.pluginId,
414
+ entryId: req.entryId,
415
+ endpointId: this.endpointId,
416
+ kind: MessageKind.Response,
417
+ route: req.route,
418
+ payload
419
+ };
420
+ }
421
+ errorResponseFor(req, code, message) {
422
+ return {
423
+ ...this.responseFor(req, null),
424
+ payload: void 0,
425
+ error: { code, message, retryable: false }
426
+ };
427
+ }
428
+ };
429
+ function randomBytesHex() {
430
+ return randomBytes(16).toString("hex");
431
+ }
432
+
433
+ // src/bootstrap.ts
434
+ var SUPPORTED_VERSIONS = [ProtocolVersion];
435
+ async function runPlugin(handlers) {
436
+ const { pipePath, token } = await readBootstrapLine();
437
+ const transport = new NodeTransport();
438
+ await transport.connect(pipePath);
439
+ const identity = await completeHandshake(transport, token);
440
+ const router = new HandlerRouter({ send: (env) => transport.send(env) });
441
+ router.setIdentity(identity);
442
+ const HOST_LOST_MS = 15e3;
443
+ let lastPingAt = Date.now();
444
+ const watchdog = setInterval(() => {
445
+ if (Date.now() - lastPingAt > HOST_LOST_MS) {
446
+ clearInterval(watchdog);
447
+ process.exit(1);
448
+ }
449
+ }, 1e3);
450
+ watchdog.unref?.();
451
+ transport.onDisconnect(() => {
452
+ clearInterval(watchdog);
453
+ process.exit(1);
454
+ });
455
+ transport.onMessage((env) => {
456
+ if (env.route === Routes.Bus.Ping) lastPingAt = Date.now();
457
+ router.dispatch(env);
458
+ });
459
+ for (const [route, handler] of Object.entries(handlers)) {
460
+ router.handle(route, handler);
461
+ }
462
+ return {
463
+ transport,
464
+ router,
465
+ close: async () => {
466
+ clearInterval(watchdog);
467
+ await transport.close();
468
+ }
469
+ };
470
+ }
471
+ async function readBootstrapLine() {
472
+ const rl = readline.createInterface({ input: process.stdin, crlfDelay: Infinity });
473
+ try {
474
+ const line = await new Promise((resolve, reject) => {
475
+ rl.once("line", (l) => resolve(l));
476
+ rl.once("close", () => reject(new Error("stdin closed before bootstrap line")));
477
+ });
478
+ const [pipePath, token] = line.split(" ");
479
+ if (!pipePath || !token) {
480
+ throw new Error(`malformed bootstrap line: ${JSON.stringify(line)}`);
481
+ }
482
+ return { pipePath, token };
483
+ } finally {
484
+ rl.close();
485
+ }
486
+ }
487
+ async function completeHandshake(transport, token, timeoutMs = 1e4) {
488
+ const id = randomBytes2(16).toString("hex");
489
+ const req = {
490
+ version: ProtocolVersion,
491
+ id,
492
+ traceId: id,
493
+ sessionId: "",
494
+ pluginId: "",
495
+ entryId: "",
496
+ endpointId: EndpointIds.NodeMain,
497
+ kind: MessageKind.Request,
498
+ route: Routes.Bus.Handshake,
499
+ timeoutMs,
500
+ payload: {
501
+ version: ProtocolVersion,
502
+ supportedVersions: SUPPORTED_VERSIONS,
503
+ token
504
+ }
505
+ };
506
+ return new Promise((resolve, reject) => {
507
+ const timer = setTimeout(() => {
508
+ unsubscribe();
509
+ reject(new Error(`bus.handshake timed out after ${timeoutMs}ms`));
510
+ }, timeoutMs);
511
+ const unsubscribe = transport.onMessage((env) => {
512
+ if (env.kind !== MessageKind.Response || env.correlationId !== id) return;
513
+ clearTimeout(timer);
514
+ unsubscribe();
515
+ if (env.error) {
516
+ reject(new Error(`${env.error.code}: ${env.error.message}`));
517
+ return;
518
+ }
519
+ const p = env.payload ?? {};
520
+ const pluginId = String(p.pluginId ?? "");
521
+ const entryId = String(p.entryId ?? "");
522
+ const sessionId = String(p.sessionId ?? "");
523
+ const endpointId = String(p.endpointId ?? EndpointIds.NodeMain);
524
+ if (!pluginId || !entryId || !sessionId) {
525
+ reject(new Error("bus.handshake success response missing bound identity"));
526
+ return;
527
+ }
528
+ resolve({ pluginId, entryId, sessionId, endpointId });
529
+ });
530
+ transport.send(req);
531
+ });
532
+ }
533
+
534
+ // src/hostEnv.ts
535
+ function asTheme(value) {
536
+ return value === "light" ? "light" : "dark";
537
+ }
538
+ function asHostEnv(payload) {
539
+ return {
540
+ locale: typeof payload?.locale === "string" ? payload.locale : "en-US",
541
+ fallbackLocale: typeof payload?.fallbackLocale === "string" ? payload.fallbackLocale : "en-US",
542
+ theme: asTheme(payload?.theme)
543
+ };
544
+ }
545
+
546
+ // src/actions.ts
547
+ var Key = {
548
+ Enter: "Enter",
549
+ Tab: "Tab",
550
+ Space: "Space",
551
+ Delete: "Delete",
552
+ Backspace: "Backspace",
553
+ Escape: "Escape",
554
+ Left: "Left",
555
+ Right: "Right",
556
+ Up: "Up",
557
+ Down: "Down",
558
+ A: "A",
559
+ B: "B",
560
+ C: "C",
561
+ D: "D",
562
+ E: "E",
563
+ F: "F",
564
+ G: "G",
565
+ H: "H",
566
+ I: "I",
567
+ J: "J",
568
+ K: "K",
569
+ L: "L",
570
+ M: "M",
571
+ N: "N",
572
+ O: "O",
573
+ P: "P",
574
+ Q: "Q",
575
+ R: "R",
576
+ S: "S",
577
+ T: "T",
578
+ U: "U",
579
+ V: "V",
580
+ W: "W",
581
+ X: "X",
582
+ Y: "Y",
583
+ Z: "Z",
584
+ D0: "D0",
585
+ D1: "D1",
586
+ D2: "D2",
587
+ D3: "D3",
588
+ D4: "D4",
589
+ D5: "D5",
590
+ D6: "D6",
591
+ D7: "D7",
592
+ D8: "D8",
593
+ D9: "D9",
594
+ F1: "F1",
595
+ F2: "F2",
596
+ F3: "F3",
597
+ F4: "F4",
598
+ F5: "F5",
599
+ F6: "F6",
600
+ F7: "F7",
601
+ F8: "F8",
602
+ F9: "F9",
603
+ F10: "F10",
604
+ F11: "F11",
605
+ F12: "F12"
606
+ };
607
+ var Modifiers = {
608
+ None: 0,
609
+ Control: 1,
610
+ Alt: 2,
611
+ ControlAlt: 3,
612
+ Shift: 4,
613
+ ControlShift: 5,
614
+ AltShift: 6,
615
+ ControlAltShift: 7
616
+ };
617
+ var HostAction = {
618
+ Copy: "copy",
619
+ CopyAndPaste: "copyAndPaste",
620
+ AddClipboardHistory: "addClipboardHistory",
621
+ Execute: "execute",
622
+ OpenInExplorer: "openInExplorer",
623
+ OpenInBrowser: "openInBrowser",
624
+ OpenPlugin: "openPlugin",
625
+ Run: "run",
626
+ Kill: "kill"
627
+ };
628
+ function toActionManifest(definition) {
629
+ const entry = {
630
+ id: definition.id,
631
+ title: definition.title
632
+ };
633
+ if (definition.description) entry.description = definition.description;
634
+ if (definition.hotkey) entry.hotkey = definition.hotkey;
635
+ return entry;
636
+ }
637
+
638
+ // src/node.ts
639
+ var ItemCacheLimit = 1e3;
640
+ var SessionCacheLimit = 8;
641
+ var Plugin = class {
642
+ #handlers = /* @__PURE__ */ new Map();
643
+ #actions = /* @__PURE__ */ new Map();
644
+ #searchHandler = null;
645
+ #initializeHandler = null;
646
+ #runtime = null;
647
+ #itemsBySession = /* @__PURE__ */ new Map();
648
+ initialize(handler) {
649
+ this.#initializeHandler = handler;
650
+ return this;
651
+ }
652
+ search(handler) {
653
+ this.#searchHandler = handler;
654
+ return this;
655
+ }
656
+ /**
657
+ * Registers every action this plugin offers. The list is sent to the host in the initialize
658
+ * response, so the host knows the ids, labels and hotkeys before any search runs; search items
659
+ * and the detail page then reference them by id only.
660
+ */
661
+ actions(definitions) {
662
+ if (!Array.isArray(definitions)) {
663
+ throw new Error("plugin.actions requires an array of action definitions.");
664
+ }
665
+ for (const definition of definitions) {
666
+ if (!definition?.id) {
667
+ throw new Error("plugin.actions requires every action to have an id.");
668
+ }
669
+ if (this.#actions.has(definition.id)) {
670
+ throw new Error(`plugin.actions has a duplicate action id: ${definition.id}`);
671
+ }
672
+ if (typeof definition.execute !== "function") {
673
+ throw new Error(`plugin.actions requires an execute function for action: ${definition.id}`);
674
+ }
675
+ this.#actions.set(definition.id, definition);
676
+ }
677
+ return this;
678
+ }
679
+ handle(action, handler) {
680
+ if (!action || typeof action !== "string") {
681
+ throw new Error("plugin.handle requires an action name.");
682
+ }
683
+ if (typeof handler !== "function") {
684
+ throw new Error("plugin.handle requires a handler.");
685
+ }
686
+ this.#handlers.set(action, handler);
687
+ return this;
688
+ }
689
+ /** Publishes a plugin.event.<subjectId> event to all webviews in the session. */
690
+ publish(subjectId, payload = {}) {
691
+ if (!this.#runtime) throw new Error("plugin not started");
692
+ const route = pluginEventRoute(subjectId);
693
+ this.#runtime.transport.send({
694
+ version: ProtocolVersion,
695
+ id: crypto.randomUUID().replace(/-/g, "").slice(0, 32),
696
+ traceId: crypto.randomUUID().replace(/-/g, "").slice(0, 32),
697
+ sessionId: "",
698
+ pluginId: "",
699
+ entryId: "",
700
+ endpointId: EndpointIds.NodeMain,
701
+ kind: MessageKind.Event,
702
+ route,
703
+ payload
704
+ });
705
+ }
706
+ /** Calls a host.call.<method> capability and awaits the response.
707
+ * `timeoutMs` defaults to the remaining timeout of the inbound plugin.call
708
+ * (from a page `bus.call`) when inside a handler; otherwise 30s.
709
+ */
710
+ hostCall(method, params = {}, timeoutMs) {
711
+ if (!this.#runtime) return Promise.reject(new Error("plugin not started"));
712
+ return this.#runtime.router.callHost(hostCallRoute(method), params, timeoutMs);
713
+ }
714
+ /** Connects to the host pipe and begins dispatching. Must be called last. */
715
+ async start() {
716
+ const routes = this.buildRoutes();
717
+ this.#runtime = await runPlugin(routes);
718
+ }
719
+ /**
720
+ * Builds the v3 route map from the fluent registrations. Exposed for unit testing the mapping
721
+ * without connecting a pipe.
722
+ */
723
+ buildRoutes() {
724
+ const routes = {};
725
+ routes[Routes.PluginCall.Initialize] = async (p) => {
726
+ const result = this.#initializeHandler ? await this.#initializeHandler(asInitializeParams(p)) : {};
727
+ const body = result && typeof result === "object" ? { ...result } : {};
728
+ return { ...body, actions: [...this.#actions.values()].map(toActionManifest) };
729
+ };
730
+ if (this.#searchHandler) {
731
+ routes[Routes.PluginCall.Search] = async (p, request) => {
732
+ const result = await this.#searchHandler(asSearchParams(p));
733
+ return { items: this.#trackItems(request?.sessionId ?? "default", result?.items ?? []) };
734
+ };
735
+ }
736
+ if (this.#actions.size > 0) {
737
+ routes[Routes.PluginCall.InvokeAction] = (p, request) => this.#invokeAction(request?.sessionId ?? "default", p);
738
+ }
739
+ for (const [action, handler] of this.#handlers) {
740
+ const route = pluginCallRoute(action);
741
+ if (!routes[route]) {
742
+ routes[route] = async (p) => {
743
+ const ctx = extractContext(p, action);
744
+ return handler(p ?? {}, ctx);
745
+ };
746
+ }
747
+ }
748
+ return routes;
749
+ }
750
+ async stop() {
751
+ if (this.#runtime) await this.#runtime.close();
752
+ }
753
+ /** Remembers the full items and returns the trimmed rows the host actually renders. */
754
+ #trackItems(sessionId, items) {
755
+ const sessionItems = this.#sessionItems(sessionId);
756
+ const wire = [];
757
+ for (const item of items) {
758
+ if (!item || typeof item !== "object") continue;
759
+ const id = typeof item.id === "string" ? item.id : "";
760
+ if (id) {
761
+ sessionItems.delete(id);
762
+ sessionItems.set(id, item);
763
+ }
764
+ wire.push(toWireItem(item));
765
+ }
766
+ while (sessionItems.size > ItemCacheLimit) {
767
+ const oldest = sessionItems.keys().next();
768
+ if (oldest.done) break;
769
+ sessionItems.delete(oldest.value);
770
+ }
771
+ return wire;
772
+ }
773
+ async #invokeAction(sessionId, payload) {
774
+ const env = asHostEnv(payload);
775
+ const actionId = typeof payload?.actionId === "string" ? payload.actionId : "";
776
+ const itemId = typeof payload?.itemId === "string" ? payload.itemId : "";
777
+ const query = typeof payload?.query === "string" ? payload.query : "";
778
+ const definition = this.#actions.get(actionId);
779
+ if (!definition) {
780
+ throw new Error(`unknown action: ${actionId}`);
781
+ }
782
+ const outcome = await definition.execute({
783
+ ...env,
784
+ actionId,
785
+ itemId,
786
+ query,
787
+ item: this.#itemsBySession.get(sessionId)?.get(itemId)
788
+ });
789
+ return outcome ?? {};
790
+ }
791
+ #sessionItems(sessionId) {
792
+ const key = sessionId || "default";
793
+ let items = this.#itemsBySession.get(key);
794
+ if (!items) {
795
+ items = /* @__PURE__ */ new Map();
796
+ this.#itemsBySession.set(key, items);
797
+ while (this.#itemsBySession.size > SessionCacheLimit) {
798
+ const oldest = this.#itemsBySession.keys().next();
799
+ if (oldest.done) break;
800
+ this.#itemsBySession.delete(oldest.value);
801
+ }
802
+ }
803
+ return items;
804
+ }
805
+ };
806
+ function toWireItem(item) {
807
+ const wire = {
808
+ id: item.id,
809
+ title: item.title
810
+ };
811
+ if (typeof item.subtitle === "string") wire.subtitle = item.subtitle;
812
+ if (typeof item.priority === "number") wire.priority = item.priority;
813
+ if (item.icon) wire.icon = item.icon;
814
+ if (Array.isArray(item.actions)) wire.actions = item.actions.filter((id) => typeof id === "string");
815
+ return wire;
816
+ }
817
+ function asInitializeParams(p) {
818
+ const messages = p?.messages;
819
+ return {
820
+ ...asHostEnv(p),
821
+ messages: isStringRecord(messages) ? messages : {}
822
+ };
823
+ }
824
+ function asSearchParams(p) {
825
+ return {
826
+ ...asHostEnv(p),
827
+ query: typeof p?.query === "string" ? p.query : "",
828
+ mode: p?.mode === "plugin" ? "plugin" : "global"
829
+ };
830
+ }
831
+ function isStringRecord(value) {
832
+ return !!value && typeof value === "object" && !Array.isArray(value) && Object.values(value).every((v) => typeof v === "string");
833
+ }
834
+ function extractContext(p, action) {
835
+ return {
836
+ ...asHostEnv(p),
837
+ action,
838
+ itemId: typeof p?.itemId === "string" ? p.itemId : "",
839
+ query: typeof p?.query === "string" ? p.query : ""
840
+ };
841
+ }
842
+ function createPlugin() {
843
+ return new Plugin();
844
+ }
845
+ export {
846
+ HostAction,
847
+ Key,
848
+ Modifiers,
849
+ Plugin,
850
+ createPlugin
851
+ };