@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.
@@ -0,0 +1,28 @@
1
+ /** Render a host-formatted shortcut as the same sequence of keycaps used by the desktop action bar. */
2
+ export function renderHotkeyKeycaps(element: HTMLElement, hotkey: string): void {
3
+ const normalized = hotkey.trim();
4
+ element.hidden = normalized.length === 0;
5
+ element.setAttribute("aria-label", normalized);
6
+ element.classList.add("hotkey-keycaps");
7
+
8
+ const keycaps = normalized.length === 0
9
+ ? []
10
+ : normalized.split("+").map((token) => createKeycap(token.trim()));
11
+ element.replaceChildren(...keycaps);
12
+ }
13
+
14
+ function createKeycap(token: string): HTMLSpanElement {
15
+ const keycap = document.createElement("span");
16
+ keycap.className = "hotkey-keycap";
17
+ keycap.setAttribute("aria-hidden", "true");
18
+
19
+ if (token.toLowerCase() === "enter" || token.toLowerCase() === "return") {
20
+ keycap.classList.add("hotkey-keycap-enter");
21
+ keycap.textContent = "↵";
22
+ keycap.title = "Enter";
23
+ } else {
24
+ keycap.textContent = token;
25
+ }
26
+
27
+ return keycap;
28
+ }
package/src/i18n.ts ADDED
@@ -0,0 +1,120 @@
1
+ import i18next, { type i18n } from "i18next";
2
+
3
+ export type TranslationValues = Record<string, unknown>;
4
+
5
+ export type TranslationOptions = TranslationValues & {
6
+ defaultValue: string;
7
+ translatorComment?: string;
8
+ };
9
+
10
+ type LocalizationPayload = {
11
+ locale?: string;
12
+ fallbackLocale?: string;
13
+ messages?: Record<string, string>;
14
+ };
15
+
16
+ class MyToolsI18n {
17
+ readonly #instance: i18n = i18next.createInstance();
18
+ #locale = "en-US";
19
+ #fallbackLocale = "en-US";
20
+ #messages: Record<string, string> = {};
21
+
22
+ get language(): string {
23
+ return this.#locale;
24
+ }
25
+
26
+ configure(payload: LocalizationPayload | Record<string, unknown> | null | undefined): void {
27
+ if (!payload || typeof payload !== "object") {
28
+ return;
29
+ }
30
+ this.#locale = typeof payload.locale === "string" ? payload.locale : this.#locale;
31
+ this.#fallbackLocale = typeof payload.fallbackLocale === "string"
32
+ ? payload.fallbackLocale
33
+ : this.#fallbackLocale;
34
+ this.#messages = isRecord(payload.messages)
35
+ ? Object.fromEntries(Object.entries(payload.messages).filter((entry): entry is [string, string] => typeof entry[1] === "string"))
36
+ : {};
37
+ if (this.#instance.isInitialized) {
38
+ this.#instance.addResourceBundle(this.#locale, "translation", this.#messages, true, true);
39
+ void this.#instance.changeLanguage(this.#locale);
40
+ return;
41
+ }
42
+
43
+ void this.#instance.init({
44
+ lng: this.#locale,
45
+ fallbackLng: this.#fallbackLocale,
46
+ initImmediate: false,
47
+ debug: false,
48
+ showSupportNotice: false,
49
+ keySeparator: false,
50
+ nsSeparator: false,
51
+ interpolation: { escapeValue: false },
52
+ resources: {
53
+ [this.#locale]: { translation: this.#messages }
54
+ }
55
+ });
56
+ }
57
+
58
+ t(key: string, options: TranslationOptions): string {
59
+ if (!key || typeof key !== "string") {
60
+ throw new Error("i18n.t requires a stable key.");
61
+ }
62
+ if (!options || typeof options.defaultValue !== "string") {
63
+ throw new Error("i18n.t requires a string defaultValue.");
64
+ }
65
+
66
+ const { translatorComment: _, ...runtimeOptions } = options;
67
+ if (!this.#instance.isInitialized) {
68
+ return options.defaultValue.replace(/\{\{\s*([A-Za-z_][A-Za-z0-9_.-]*)\s*\}\}/g, (placeholder, name: string) => {
69
+ const value = options[name];
70
+ return value === undefined || value === null ? placeholder : String(value);
71
+ });
72
+ }
73
+ return this.#instance.t(key, runtimeOptions);
74
+ }
75
+
76
+ apply(root?: ParentNode): void {
77
+ if (typeof document === "undefined") {
78
+ return;
79
+ }
80
+ const target = root ?? document;
81
+ target.querySelectorAll<HTMLElement>("[data-i18n]").forEach((element) => {
82
+ const descriptor = element.dataset.i18n ?? "";
83
+ const parsed = parseDescriptor(descriptor);
84
+ const defaultValue = element.dataset.i18nDefaultValue ?? element.textContent ?? parsed.key;
85
+ const value = this.t(parsed.key, { defaultValue });
86
+ parsed.attributes.forEach((attribute) => {
87
+ if (attribute === "text") {
88
+ element.textContent = value;
89
+ } else {
90
+ element.setAttribute(attribute, value);
91
+ }
92
+ });
93
+ });
94
+ document.documentElement.lang = this.#locale;
95
+ }
96
+ }
97
+
98
+ function parseDescriptor(descriptor: string): { attributes: string[]; key: string } {
99
+ const attributes: string[] = [];
100
+ let key = descriptor;
101
+ while (key.startsWith("[")) {
102
+ const closingBracket = key.indexOf("]");
103
+ if (closingBracket <= 1) {
104
+ break;
105
+ }
106
+
107
+ attributes.push(key.slice(1, closingBracket));
108
+ key = key.slice(closingBracket + 1);
109
+ }
110
+
111
+ return key
112
+ ? { attributes: attributes.length > 0 ? attributes : ["text"], key }
113
+ : { attributes: ["text"], key: descriptor };
114
+ }
115
+
116
+ function isRecord(value: unknown): value is Record<string, unknown> {
117
+ return typeof value === "object" && value !== null;
118
+ }
119
+
120
+ export const mytoolsI18n = new MyToolsI18n();
package/src/node.ts ADDED
@@ -0,0 +1,332 @@
1
+ /**
2
+ * Node-side plugin SDK: fluent `createPlugin()` over the v3 named-pipe message bus.
3
+ *
4
+ * Method names map to v3 routes:
5
+ * initialize -> plugin.call.initialize
6
+ * search -> plugin.call.search
7
+ * actions -> plugin.call.invokeAction (dispatched by action id)
8
+ * handle(name) -> plugin.call.<name>
9
+ * publish -> plugin.event.<subjectId>
10
+ * hostCall -> host.call.<method>
11
+ */
12
+
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";
16
+ import {
17
+ EndpointIds,
18
+ MessageKind,
19
+ ProtocolVersion,
20
+ Routes,
21
+ hostCallRoute,
22
+ pluginCallRoute,
23
+ pluginEventRoute,
24
+ } from "./protocol.ts";
25
+
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";
43
+
44
+ export type PluginContext = PluginHostEnv & {
45
+ action: string;
46
+ itemId: string;
47
+ query: string;
48
+ };
49
+
50
+ /** Payload of plugin.call.initialize. Host sends locale, theme, and the resolved message bag. */
51
+ export type PluginInitializeParams = PluginHostEnv & {
52
+ messages: Record<string, string>;
53
+ };
54
+
55
+ /** Payload of plugin.call.search. */
56
+ export type PluginSearchParams = PluginHostEnv & {
57
+ query: string;
58
+ mode: "global" | "plugin";
59
+ };
60
+
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[];
83
+ };
84
+
85
+ /** Keeps `context.item` available without letting a long-lived session grow without bound. */
86
+ const ItemCacheLimit = 1000;
87
+ const SessionCacheLimit = 8;
88
+
89
+ type PluginInitializeHandler = (params: PluginInitializeParams) => unknown | Promise<unknown>;
90
+ type PluginSearchHandler = (params: PluginSearchParams) => SearchResult | Promise<SearchResult>;
91
+ type PluginHandler = (payload: any, context: PluginContext) => unknown | Promise<unknown>;
92
+
93
+ export class Plugin {
94
+ #handlers = new Map<string, PluginHandler>();
95
+ #actions = new Map<string, ActionDefinition<any>>();
96
+ #searchHandler: PluginSearchHandler | null = null;
97
+ #initializeHandler: PluginInitializeHandler | null = null;
98
+ #runtime: PluginRuntime | null = null;
99
+ #itemsBySession = new Map<string, Map<string, unknown>>();
100
+
101
+ initialize(handler: PluginInitializeHandler): this {
102
+ this.#initializeHandler = handler;
103
+ return this;
104
+ }
105
+
106
+ search(handler: PluginSearchHandler): this {
107
+ this.#searchHandler = handler;
108
+ return this;
109
+ }
110
+
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
+ }
132
+ return this;
133
+ }
134
+
135
+ handle(action: string, handler: PluginHandler): this {
136
+ if (!action || typeof action !== "string") {
137
+ throw new Error("plugin.handle requires an action name.");
138
+ }
139
+ if (typeof handler !== "function") {
140
+ throw new Error("plugin.handle requires a handler.");
141
+ }
142
+ this.#handlers.set(action, handler);
143
+ return this;
144
+ }
145
+
146
+ /** Publishes a plugin.event.<subjectId> event to all webviews in the session. */
147
+ publish(subjectId: string, payload: unknown = {}): void {
148
+ if (!this.#runtime) throw new Error("plugin not started");
149
+ const route = pluginEventRoute(subjectId);
150
+ this.#runtime.transport.send({
151
+ version: ProtocolVersion,
152
+ id: crypto.randomUUID().replace(/-/g, "").slice(0, 32),
153
+ traceId: crypto.randomUUID().replace(/-/g, "").slice(0, 32),
154
+ sessionId: "",
155
+ pluginId: "",
156
+ entryId: "",
157
+ endpointId: EndpointIds.NodeMain,
158
+ kind: MessageKind.Event,
159
+ route,
160
+ payload,
161
+ });
162
+ }
163
+
164
+ /** Calls a host.call.<method> capability and awaits the response.
165
+ * `timeoutMs` defaults to the remaining timeout of the inbound plugin.call
166
+ * (from a page `bus.call`) when inside a handler; otherwise 30s.
167
+ */
168
+ hostCall(method: string, params: Record<string, unknown> = {}, timeoutMs?: number): Promise<unknown> {
169
+ if (!this.#runtime) return Promise.reject(new Error("plugin not started"));
170
+ return this.#runtime.router.callHost(hostCallRoute(method), params, timeoutMs);
171
+ }
172
+
173
+ /** Connects to the host pipe and begins dispatching. Must be called last. */
174
+ async start(): Promise<void> {
175
+ const routes = this.buildRoutes();
176
+ this.#runtime = await runPlugin(routes);
177
+ }
178
+
179
+ /**
180
+ * Builds the v3 route map from the fluent registrations. Exposed for unit testing the mapping
181
+ * without connecting a pipe.
182
+ */
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
+ };
201
+
202
+ if (this.#searchHandler) {
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
+ };
207
+ }
208
+
209
+ if (this.#actions.size > 0) {
210
+ routes[Routes.PluginCall.InvokeAction] = (p, request) =>
211
+ this.#invokeAction(request?.sessionId ?? "default", p);
212
+ }
213
+
214
+ for (const [action, handler] of this.#handlers) {
215
+ const route = pluginCallRoute(action);
216
+ if (!routes[route]) {
217
+ routes[route] = async (p) => {
218
+ const ctx = extractContext(p, action);
219
+ return handler(p ?? {}, ctx);
220
+ };
221
+ }
222
+ }
223
+ return routes;
224
+ }
225
+
226
+ async stop(): Promise<void> {
227
+ if (this.#runtime) await this.#runtime.close();
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
+ }
286
+ }
287
+
288
+ function toWireItem(item: SearchItem): Record<string, unknown> {
289
+ const wire: Record<string, unknown> = {
290
+ id: item.id,
291
+ title: item.title,
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;
298
+ }
299
+
300
+ function asInitializeParams(p: any): PluginInitializeParams {
301
+ const messages = p?.messages;
302
+ return {
303
+ ...asHostEnv(p),
304
+ messages: isStringRecord(messages) ? messages : {},
305
+ };
306
+ }
307
+
308
+ function asSearchParams(p: any): PluginSearchParams {
309
+ return {
310
+ ...asHostEnv(p),
311
+ query: typeof p?.query === "string" ? p.query : "",
312
+ mode: p?.mode === "plugin" ? "plugin" : "global",
313
+ };
314
+ }
315
+
316
+ function isStringRecord(value: unknown): value is Record<string, string> {
317
+ return !!value && typeof value === "object" && !Array.isArray(value)
318
+ && Object.values(value).every((v) => typeof v === "string");
319
+ }
320
+
321
+ function extractContext(p: any, action: string): PluginContext {
322
+ return {
323
+ ...asHostEnv(p),
324
+ action,
325
+ itemId: typeof p?.itemId === "string" ? p.itemId : "",
326
+ query: typeof p?.query === "string" ? p.query : "",
327
+ };
328
+ }
329
+
330
+ export function createPlugin(): Plugin {
331
+ return new Plugin();
332
+ }
package/src/protocol.ts CHANGED
@@ -6,25 +6,91 @@
6
6
  *
7
7
  * Field names are camelCase on the wire (System.Text.Json camelCase policy on the C# side).
8
8
  * Null fields are omitted on the wire (WhenWritingNull).
9
+ *
10
+ * Runtime constants mirror MyTools.Protocol (MessageKindWire, Routes, EndpointIds,
11
+ * ProtocolVersion.CurrentWire). Do not re-hardcode those strings in SDK source.
9
12
  */
10
13
 
11
- export type MessageKind = "request" | "response" | "event";
14
+ export const MessageKind = {
15
+ Request: "request",
16
+ Response: "response",
17
+ Event: "event",
18
+ } as const;
19
+ export type MessageKind = (typeof MessageKind)[keyof typeof MessageKind];
20
+
21
+ export const ErrorCode = {
22
+ ProtocolMismatch: "ProtocolMismatch",
23
+ HandshakeFailed: "HandshakeFailed",
24
+ CapabilityNotDeclared: "CapabilityNotDeclared",
25
+ CapabilityDenied: "CapabilityDenied",
26
+ InvalidPayload: "InvalidPayload",
27
+ MessageTooLarge: "MessageTooLarge",
28
+ RouteNotFound: "RouteNotFound",
29
+ RequestTimeout: "RequestTimeout",
30
+ TooManyRequests: "TooManyRequests",
31
+ TransportDisconnected: "TransportDisconnected",
32
+ PluginUnavailable: "PluginUnavailable",
33
+ InternalError: "InternalError",
34
+ Cancelled: "Cancelled",
35
+ RateLimited: "RateLimited",
36
+ } as const;
37
+ export type ErrorCode = (typeof ErrorCode)[keyof typeof ErrorCode];
38
+
39
+ export const ProtocolVersion = "3.0";
40
+
41
+ export const EndpointIds = {
42
+ NodeMain: "node-main",
43
+ Host: "host",
44
+ } as const;
45
+
46
+ export const Routes = {
47
+ Bus: {
48
+ Handshake: "bus.handshake",
49
+ Ping: "bus.ping",
50
+ Cancel: "bus.cancel",
51
+ Subscribe: "bus.subscribe",
52
+ Unsubscribe: "bus.unsubscribe",
53
+ },
54
+ Prefix: {
55
+ PluginCall: "plugin.call.",
56
+ HostCall: "host.call.",
57
+ PluginEvent: "plugin.event.",
58
+ HostEvent: "host.event.",
59
+ Diagnostics: "diagnostics.",
60
+ },
61
+ PluginCall: {
62
+ Initialize: "plugin.call.initialize",
63
+ Search: "plugin.call.search",
64
+ InvokeAction: "plugin.call.invokeAction",
65
+ },
66
+ HostEvent: {
67
+ Initialize: "host.event.initialize",
68
+ Search: "host.event.search",
69
+ Key: "host.event.key",
70
+ DetailAction: "host.event.detailAction",
71
+ LanguageChanged: "host.event.languageChanged",
72
+ ThemeChanged: "host.event.themeChanged",
73
+ InputActionCaptured: "host.event.inputActionCaptured",
74
+ },
75
+ } as const;
12
76
 
13
- export type ErrorCode =
14
- | "ProtocolMismatch"
15
- | "HandshakeFailed"
16
- | "CapabilityNotDeclared"
17
- | "CapabilityDenied"
18
- | "InvalidPayload"
19
- | "MessageTooLarge"
20
- | "RouteNotFound"
21
- | "RequestTimeout"
22
- | "TooManyRequests"
23
- | "TransportDisconnected"
24
- | "PluginUnavailable"
25
- | "InternalError"
26
- | "Cancelled"
27
- | "RateLimited";
77
+ export function pluginCallRoute(method: string): string {
78
+ return method.startsWith(Routes.Prefix.PluginCall)
79
+ ? method
80
+ : `${Routes.Prefix.PluginCall}${method}`;
81
+ }
82
+
83
+ export function hostCallRoute(method: string): string {
84
+ return method.startsWith(Routes.Prefix.HostCall)
85
+ ? method
86
+ : `${Routes.Prefix.HostCall}${method}`;
87
+ }
88
+
89
+ export function pluginEventRoute(subjectId: string): string {
90
+ return subjectId.startsWith(Routes.Prefix.PluginEvent)
91
+ ? subjectId
92
+ : `${Routes.Prefix.PluginEvent}${subjectId}`;
93
+ }
28
94
 
29
95
  export interface BusError {
30
96
  code: ErrorCode;
@@ -38,7 +104,7 @@ export interface BusError {
38
104
  * required; the optional ones are omitted on the wire when null.
39
105
  */
40
106
  export interface Envelope {
41
- version: string; // e.g. "3.0"
107
+ version: string; // e.g. ProtocolVersion
42
108
  id: string;
43
109
  correlationId?: string | null;
44
110
  traceId: string;