@qping/plugin-bus 0.2.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/actions.d.ts +204 -0
- package/dist/bootstrap.d.ts +3 -1
- package/dist/bootstrap.mjs +5 -1
- package/dist/dev.d.ts +5 -0
- package/dist/dev.mjs +80 -0
- package/dist/developmentRefresh.d.ts +9 -0
- package/dist/hostEnv.d.ts +9 -0
- package/dist/hotkeyKeycaps.d.ts +2 -0
- package/dist/node.d.ts +35 -17
- package/dist/node.mjs +209 -26
- package/dist/protocol.d.ts +1 -0
- package/dist/protocol.mjs +1 -0
- package/dist/router.d.ts +3 -1
- package/dist/search.d.ts +7 -0
- package/dist/search.mjs +17 -0
- package/dist/webClient.d.ts +2 -1
- package/dist/webClient.mjs +26 -1
- package/dist/webTypes.d.ts +15 -0
- package/package.json +55 -45
- package/src/actions.ts +158 -0
- package/src/bootstrap.ts +159 -159
- package/src/dev.ts +14 -0
- package/src/developmentRefresh.ts +95 -0
- package/src/hostEnv.ts +21 -0
- package/src/hotkeyKeycaps.ts +28 -0
- package/src/node.ts +163 -44
- package/src/protocol.ts +1 -0
- package/src/router.ts +199 -196
- package/src/search.ts +22 -0
- package/src/webClient.ts +3 -0
- package/src/webTypes.ts +16 -0
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
|
-
*
|
|
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
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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) =>
|
|
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
|
-
|
|
79
|
-
|
|
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<
|
|
132
|
-
|
|
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) =>
|
|
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
|
-
|
|
141
|
-
|
|
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
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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",
|