@qping/plugin-bus 0.2.0 → 0.4.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/src/node.ts CHANGED
@@ -4,13 +4,15 @@
4
4
  * Method names map to v3 routes:
5
5
  * initialize -> plugin.call.initialize
6
6
  * search -> plugin.call.search
7
- * action -> plugin.call.invokeAction
7
+ * actions -> plugin.call.invokeAction (dispatched by action id)
8
8
  * handle(name) -> plugin.call.<name>
9
9
  * publish -> plugin.event.<subjectId>
10
10
  * hostCall -> host.call.<method>
11
11
  */
12
12
 
13
13
  import { runPlugin, type PluginRuntime } from "./bootstrap.ts";
14
+ import { asHostEnv, type PluginHostEnv, type PluginTheme } from "./hostEnv.ts";
15
+ import { toActionManifest, type ActionDefinition, type ActionOutcome } from "./actions.ts";
14
16
  import {
15
17
  EndpointIds,
16
18
  MessageKind,
@@ -21,13 +23,23 @@ import {
21
23
  pluginEventRoute,
22
24
  } from "./protocol.ts";
23
25
 
24
- export type PluginTheme = "light" | "dark";
25
-
26
- export type PluginHostEnv = {
27
- locale: string;
28
- fallbackLocale: string;
29
- theme: PluginTheme;
30
- };
26
+ export type { PluginHostEnv, PluginTheme } from "./hostEnv.ts";
27
+ export {
28
+ HostAction,
29
+ Key,
30
+ Modifiers,
31
+ type ActionContext,
32
+ type ActionDefinition,
33
+ type ActionOutcome,
34
+ type DetailRequest,
35
+ type HostActionKind,
36
+ type HostActionRequest,
37
+ type Hotkey,
38
+ type HotkeyKey,
39
+ type HotkeyModifiers,
40
+ type LocalizedText,
41
+ type RunSpec,
42
+ } from "./actions.ts";
31
43
 
32
44
  export type PluginContext = PluginHostEnv & {
33
45
  action: string;
@@ -46,24 +58,45 @@ export type PluginSearchParams = PluginHostEnv & {
46
58
  mode: "global" | "plugin";
47
59
  };
48
60
 
49
- /** Payload of plugin.call.invokeAction. */
50
- export type PluginActionParams = PluginHostEnv & {
51
- itemId: string;
52
- actionId: string;
53
- query: string;
61
+ export type SearchIcon = {
62
+ kind: string;
63
+ value: string;
64
+ };
65
+
66
+ /**
67
+ * A search result row. Only `id`/`title`/`subtitle`/`priority`/`icon`/`actions` reach the host;
68
+ * any other field stays on the Node side and comes back as `context.item` when an action runs.
69
+ */
70
+ export type SearchItem = {
71
+ id: string;
72
+ title: string;
73
+ subtitle?: string;
74
+ priority?: number;
75
+ icon?: SearchIcon;
76
+ /** Ids of registered actions, in display order. The first one is bound to Enter. */
77
+ actions?: string[];
78
+ [extra: string]: unknown;
79
+ };
80
+
81
+ export type SearchResult = {
82
+ items: SearchItem[];
54
83
  };
55
84
 
85
+ /** Keeps `context.item` available without letting a long-lived session grow without bound. */
86
+ const ItemCacheLimit = 1000;
87
+ const SessionCacheLimit = 8;
88
+
56
89
  type PluginInitializeHandler = (params: PluginInitializeParams) => unknown | Promise<unknown>;
57
- type PluginSearchHandler = (params: PluginSearchParams) => unknown | Promise<unknown>;
58
- type PluginActionHandler = (params: PluginActionParams) => unknown | Promise<unknown>;
90
+ type PluginSearchHandler = (params: PluginSearchParams) => SearchResult | Promise<SearchResult>;
59
91
  type PluginHandler = (payload: any, context: PluginContext) => unknown | Promise<unknown>;
60
92
 
61
93
  export class Plugin {
62
94
  #handlers = new Map<string, PluginHandler>();
95
+ #actions = new Map<string, ActionDefinition<any>>();
63
96
  #searchHandler: PluginSearchHandler | null = null;
64
- #actionHandler: PluginActionHandler | null = null;
65
97
  #initializeHandler: PluginInitializeHandler | null = null;
66
98
  #runtime: PluginRuntime | null = null;
99
+ #itemsBySession = new Map<string, Map<string, unknown>>();
67
100
 
68
101
  initialize(handler: PluginInitializeHandler): this {
69
102
  this.#initializeHandler = handler;
@@ -75,8 +108,27 @@ export class Plugin {
75
108
  return this;
76
109
  }
77
110
 
78
- action(handler: PluginActionHandler): this {
79
- this.#actionHandler = handler;
111
+ /**
112
+ * Registers every action this plugin offers. The list is sent to the host in the initialize
113
+ * response, so the host knows the ids, labels and hotkeys before any search runs; search items
114
+ * and the detail page then reference them by id only.
115
+ */
116
+ actions<TItem = any>(definitions: ActionDefinition<TItem>[]): this {
117
+ if (!Array.isArray(definitions)) {
118
+ throw new Error("plugin.actions requires an array of action definitions.");
119
+ }
120
+ for (const definition of definitions) {
121
+ if (!definition?.id) {
122
+ throw new Error("plugin.actions requires every action to have an id.");
123
+ }
124
+ if (this.#actions.has(definition.id)) {
125
+ throw new Error(`plugin.actions has a duplicate action id: ${definition.id}`);
126
+ }
127
+ if (typeof definition.execute !== "function") {
128
+ throw new Error(`plugin.actions requires an execute function for action: ${definition.id}`);
129
+ }
130
+ this.#actions.set(definition.id, definition);
131
+ }
80
132
  return this;
81
133
  }
82
134
 
@@ -128,18 +180,37 @@ export class Plugin {
128
180
  * Builds the v3 route map from the fluent registrations. Exposed for unit testing the mapping
129
181
  * without connecting a pipe.
130
182
  */
131
- buildRoutes(): Record<string, (payload: any) => unknown | Promise<unknown>> {
132
- const routes: Record<string, (payload: any) => unknown | Promise<unknown>> = {};
183
+ buildRoutes(): Record<
184
+ string,
185
+ (payload: any, context?: { sessionId: string }) => unknown | Promise<unknown>
186
+ > {
187
+ const routes: Record<
188
+ string,
189
+ (payload: any, context?: { sessionId: string }) => unknown | Promise<unknown>
190
+ > = {};
191
+
192
+ // initialize always answers, even without a handler, because the host reads the action
193
+ // registry out of this response.
194
+ routes[Routes.PluginCall.Initialize] = async (p) => {
195
+ const result = this.#initializeHandler
196
+ ? await this.#initializeHandler(asInitializeParams(p))
197
+ : {};
198
+ const body = result && typeof result === "object" ? { ...(result as object) } : {};
199
+ return { ...body, actions: [...this.#actions.values()].map(toActionManifest) };
200
+ };
133
201
 
134
- if (this.#initializeHandler) {
135
- routes[Routes.PluginCall.Initialize] = (p) => this.#initializeHandler!(asInitializeParams(p));
136
- }
137
202
  if (this.#searchHandler) {
138
- routes[Routes.PluginCall.Search] = (p) => this.#searchHandler!(asSearchParams(p));
203
+ routes[Routes.PluginCall.Search] = async (p, request) => {
204
+ const result = await this.#searchHandler!(asSearchParams(p));
205
+ return { items: this.#trackItems(request?.sessionId ?? "default", result?.items ?? []) };
206
+ };
139
207
  }
140
- if (this.#actionHandler) {
141
- routes[Routes.PluginCall.InvokeAction] = (p) => this.#actionHandler!(asActionParams(p));
208
+
209
+ if (this.#actions.size > 0) {
210
+ routes[Routes.PluginCall.InvokeAction] = (p, request) =>
211
+ this.#invokeAction(request?.sessionId ?? "default", p);
142
212
  }
213
+
143
214
  for (const [action, handler] of this.#handlers) {
144
215
  const route = pluginCallRoute(action);
145
216
  if (!routes[route]) {
@@ -155,14 +226,75 @@ export class Plugin {
155
226
  async stop(): Promise<void> {
156
227
  if (this.#runtime) await this.#runtime.close();
157
228
  }
229
+
230
+ /** Remembers the full items and returns the trimmed rows the host actually renders. */
231
+ #trackItems(sessionId: string, items: SearchItem[]): Record<string, unknown>[] {
232
+ const sessionItems = this.#sessionItems(sessionId);
233
+ const wire: Record<string, unknown>[] = [];
234
+ for (const item of items) {
235
+ if (!item || typeof item !== "object") continue;
236
+ const id = typeof item.id === "string" ? item.id : "";
237
+ if (id) {
238
+ sessionItems.delete(id);
239
+ sessionItems.set(id, item);
240
+ }
241
+ wire.push(toWireItem(item));
242
+ }
243
+ while (sessionItems.size > ItemCacheLimit) {
244
+ const oldest = sessionItems.keys().next();
245
+ if (oldest.done) break;
246
+ sessionItems.delete(oldest.value);
247
+ }
248
+ return wire;
249
+ }
250
+
251
+ async #invokeAction(sessionId: string, payload: any): Promise<ActionOutcome> {
252
+ const env = asHostEnv(payload);
253
+ const actionId = typeof payload?.actionId === "string" ? payload.actionId : "";
254
+ const itemId = typeof payload?.itemId === "string" ? payload.itemId : "";
255
+ const query = typeof payload?.query === "string" ? payload.query : "";
256
+
257
+ const definition = this.#actions.get(actionId);
258
+ if (!definition) {
259
+ throw new Error(`unknown action: ${actionId}`);
260
+ }
261
+
262
+ const outcome = (await definition.execute({
263
+ ...env,
264
+ actionId,
265
+ itemId,
266
+ query,
267
+ item: this.#itemsBySession.get(sessionId)?.get(itemId),
268
+ })) as ActionOutcome | undefined;
269
+ return outcome ?? {};
270
+ }
271
+
272
+ #sessionItems(sessionId: string): Map<string, unknown> {
273
+ const key = sessionId || "default";
274
+ let items = this.#itemsBySession.get(key);
275
+ if (!items) {
276
+ items = new Map<string, unknown>();
277
+ this.#itemsBySession.set(key, items);
278
+ while (this.#itemsBySession.size > SessionCacheLimit) {
279
+ const oldest = this.#itemsBySession.keys().next();
280
+ if (oldest.done) break;
281
+ this.#itemsBySession.delete(oldest.value);
282
+ }
283
+ }
284
+ return items;
285
+ }
158
286
  }
159
287
 
160
- function asHostEnv(p: any): PluginHostEnv {
161
- return {
162
- locale: typeof p?.locale === "string" ? p.locale : "en-US",
163
- fallbackLocale: typeof p?.fallbackLocale === "string" ? p.fallbackLocale : "en-US",
164
- theme: asTheme(p?.theme),
288
+ function toWireItem(item: SearchItem): Record<string, unknown> {
289
+ const wire: Record<string, unknown> = {
290
+ id: item.id,
291
+ title: item.title,
165
292
  };
293
+ if (typeof item.subtitle === "string") wire.subtitle = item.subtitle;
294
+ if (typeof item.priority === "number") wire.priority = item.priority;
295
+ if (item.icon) wire.icon = item.icon;
296
+ if (Array.isArray(item.actions)) wire.actions = item.actions.filter((id) => typeof id === "string");
297
+ return wire;
166
298
  }
167
299
 
168
300
  function asInitializeParams(p: any): PluginInitializeParams {
@@ -181,19 +313,6 @@ function asSearchParams(p: any): PluginSearchParams {
181
313
  };
182
314
  }
183
315
 
184
- function asActionParams(p: any): PluginActionParams {
185
- return {
186
- ...asHostEnv(p),
187
- itemId: typeof p?.itemId === "string" ? p.itemId : "",
188
- actionId: typeof p?.actionId === "string" ? p.actionId : "",
189
- query: typeof p?.query === "string" ? p.query : "",
190
- };
191
- }
192
-
193
- function asTheme(value: unknown): PluginTheme {
194
- return value === "light" ? "light" : "dark";
195
- }
196
-
197
316
  function isStringRecord(value: unknown): value is Record<string, string> {
198
317
  return !!value && typeof value === "object" && !Array.isArray(value)
199
318
  && Object.values(value).every((v) => typeof v === "string");
package/src/protocol.ts CHANGED
@@ -67,6 +67,7 @@ export const Routes = {
67
67
  Initialize: "host.event.initialize",
68
68
  Search: "host.event.search",
69
69
  Key: "host.event.key",
70
+ DetailAction: "host.event.detailAction",
70
71
  LanguageChanged: "host.event.languageChanged",
71
72
  ThemeChanged: "host.event.themeChanged",
72
73
  InputActionCaptured: "host.event.inputActionCaptured",