@qping/plugin-bus 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/dist/bootstrap.d.ts +34 -0
- package/dist/bootstrap.mjs +170 -16
- package/dist/framing.d.ts +36 -0
- package/dist/i18n.d.ts +19 -0
- package/dist/i18n.mjs +2343 -0
- package/dist/node.d.ts +65 -0
- package/dist/{server.mjs → node.mjs} +244 -60
- package/dist/protocol.d.ts +101 -0
- package/dist/protocol.mjs +73 -1
- package/dist/router.d.ts +41 -0
- package/dist/transport.d.ts +24 -0
- package/dist/webClient.d.ts +34 -0
- package/dist/webClient.mjs +2599 -0
- package/dist/webTypes.d.ts +50 -0
- package/package.json +22 -8
- package/src/bootstrap.ts +98 -9
- package/src/i18n.ts +120 -0
- package/src/node.ts +213 -0
- package/src/protocol.ts +82 -17
- package/src/router.ts +65 -18
- package/src/transport.ts +2 -1
- package/src/webClient.ts +285 -0
- package/src/webTypes.ts +52 -0
- package/dist/bootstrap.d.mts +0 -1
- package/dist/protocol.d.mts +0 -1
- package/dist/server.d.mts +0 -1
- package/src/server.ts +0 -156
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
export interface MyToolsHostInitializePayload {
|
|
2
|
+
protocolVersion: string;
|
|
3
|
+
pluginId: string;
|
|
4
|
+
version?: string;
|
|
5
|
+
itemId: string;
|
|
6
|
+
query: string;
|
|
7
|
+
keyword: string;
|
|
8
|
+
initialState: unknown;
|
|
9
|
+
locale: string;
|
|
10
|
+
fallbackLocale: string;
|
|
11
|
+
translationRevision: string;
|
|
12
|
+
messages: Record<string, string>;
|
|
13
|
+
theme?: string;
|
|
14
|
+
themeTokens?: Record<string, string>;
|
|
15
|
+
}
|
|
16
|
+
export interface MyToolsLanguageChangedPayload {
|
|
17
|
+
locale: string;
|
|
18
|
+
fallbackLocale: string;
|
|
19
|
+
translationRevision: string;
|
|
20
|
+
messages: Record<string, string>;
|
|
21
|
+
}
|
|
22
|
+
export interface MyToolsThemeChangedPayload {
|
|
23
|
+
theme: string;
|
|
24
|
+
themeTokens: Record<string, string>;
|
|
25
|
+
}
|
|
26
|
+
export interface MyToolsHostSearchPayload {
|
|
27
|
+
query: string;
|
|
28
|
+
}
|
|
29
|
+
export interface MyToolsHostKeyPayload {
|
|
30
|
+
key: string;
|
|
31
|
+
}
|
|
32
|
+
export interface MyToolsInputActionCapturedPayload {
|
|
33
|
+
requestId: string;
|
|
34
|
+
cancelled?: boolean;
|
|
35
|
+
kind?: "hotkey" | "mouse";
|
|
36
|
+
hotKey?: string | null;
|
|
37
|
+
mouseButton?: string | null;
|
|
38
|
+
}
|
|
39
|
+
export interface MyToolsThemePayload {
|
|
40
|
+
theme?: string;
|
|
41
|
+
themeTokens?: Record<string, string>;
|
|
42
|
+
}
|
|
43
|
+
export declare const HostEvents: {
|
|
44
|
+
readonly Initialize: "host.event.initialize";
|
|
45
|
+
readonly Search: "host.event.search";
|
|
46
|
+
readonly Key: "host.event.key";
|
|
47
|
+
readonly LanguageChanged: "host.event.languageChanged";
|
|
48
|
+
readonly ThemeChanged: "host.event.themeChanged";
|
|
49
|
+
readonly InputActionCaptured: "host.event.inputActionCaptured";
|
|
50
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@qping/plugin-bus",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "v3 message-bus runtime for MyTools plugins (named-pipe transport).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"files": [
|
|
@@ -8,29 +8,43 @@
|
|
|
8
8
|
"src"
|
|
9
9
|
],
|
|
10
10
|
"exports": {
|
|
11
|
-
"./
|
|
12
|
-
"types": "./dist/
|
|
13
|
-
"import": "./dist/
|
|
14
|
-
"default": "./dist/
|
|
11
|
+
"./node": {
|
|
12
|
+
"types": "./dist/node.d.ts",
|
|
13
|
+
"import": "./dist/node.mjs",
|
|
14
|
+
"default": "./dist/node.mjs"
|
|
15
15
|
},
|
|
16
16
|
"./protocol": {
|
|
17
|
-
"types": "./dist/protocol.d.
|
|
17
|
+
"types": "./dist/protocol.d.ts",
|
|
18
18
|
"import": "./dist/protocol.mjs",
|
|
19
19
|
"default": "./dist/protocol.mjs"
|
|
20
20
|
},
|
|
21
21
|
"./bootstrap": {
|
|
22
|
-
"types": "./dist/bootstrap.d.
|
|
22
|
+
"types": "./dist/bootstrap.d.ts",
|
|
23
23
|
"import": "./dist/bootstrap.mjs",
|
|
24
24
|
"default": "./dist/bootstrap.mjs"
|
|
25
25
|
},
|
|
26
|
+
"./web": {
|
|
27
|
+
"types": "./dist/webClient.d.ts",
|
|
28
|
+
"import": "./dist/webClient.mjs",
|
|
29
|
+
"default": "./dist/webClient.mjs"
|
|
30
|
+
},
|
|
31
|
+
"./i18n": {
|
|
32
|
+
"types": "./dist/i18n.d.ts",
|
|
33
|
+
"import": "./dist/i18n.mjs",
|
|
34
|
+
"default": "./dist/i18n.mjs"
|
|
35
|
+
},
|
|
26
36
|
"./package.json": "./package.json"
|
|
27
37
|
},
|
|
38
|
+
"dependencies": {
|
|
39
|
+
"i18next": "^25.3.2"
|
|
40
|
+
},
|
|
28
41
|
"scripts": {
|
|
29
42
|
"clean": "node -e \"fs.rmSync('dist',{recursive:true,force:true})\"",
|
|
30
43
|
"build": "npm run clean && node build-sdk.mjs",
|
|
31
44
|
"check": "node build-sdk.mjs"
|
|
32
45
|
},
|
|
33
46
|
"devDependencies": {
|
|
34
|
-
"esbuild": "^0.25.8"
|
|
47
|
+
"esbuild": "^0.25.8",
|
|
48
|
+
"typescript": "^7.0.2"
|
|
35
49
|
}
|
|
36
50
|
}
|
package/src/bootstrap.ts
CHANGED
|
@@ -1,16 +1,24 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* v3 Node SDK bootstrap entry. Reads the bootstrap line from stdin (pipePath\ttoken), connects to
|
|
3
|
-
* the named pipe,
|
|
4
|
-
*
|
|
3
|
+
* the named pipe, completes bus.handshake (presenting the token and receiving bound identity),
|
|
4
|
+
* and starts a HandlerRouter stamped with that identity.
|
|
5
5
|
*
|
|
6
6
|
* This mirrors the C# NodeProcessController's spawn contract: the host writes one line to the
|
|
7
|
-
* Node process's stdin — "<pipePath>\t<token>" — then waits for the Node side to connect the pipe
|
|
7
|
+
* Node process's stdin — "<pipePath>\t<token>" — then waits for the Node side to connect the pipe
|
|
8
|
+
* and complete handshake before promoting the session to Ready.
|
|
8
9
|
*/
|
|
9
10
|
|
|
10
11
|
import readline from "node:readline/promises";
|
|
12
|
+
import { randomBytes } from "node:crypto";
|
|
11
13
|
import { NodeTransport } from "./transport.ts";
|
|
12
14
|
import { HandlerRouter } from "./router.ts";
|
|
13
|
-
import
|
|
15
|
+
import {
|
|
16
|
+
type Envelope,
|
|
17
|
+
EndpointIds,
|
|
18
|
+
MessageKind,
|
|
19
|
+
ProtocolVersion,
|
|
20
|
+
Routes,
|
|
21
|
+
} from "./protocol.ts";
|
|
14
22
|
|
|
15
23
|
export interface PluginHandlers {
|
|
16
24
|
[route: string]: (payload: any) => Promise<any> | any;
|
|
@@ -22,10 +30,11 @@ export interface PluginRuntime {
|
|
|
22
30
|
close(): Promise<void>;
|
|
23
31
|
}
|
|
24
32
|
|
|
33
|
+
const SUPPORTED_VERSIONS = [ProtocolVersion];
|
|
34
|
+
|
|
25
35
|
/**
|
|
26
|
-
* Connects to the host pipe (reading the bootstrap line from stdin)
|
|
27
|
-
* router dispatches inbound plugin.call.* requests to the given handlers.
|
|
28
|
-
* runtime.router.callHost(...) to invoke host.call.* capabilities.
|
|
36
|
+
* Connects to the host pipe (reading the bootstrap line from stdin), completes handshake, and
|
|
37
|
+
* returns a runtime whose router dispatches inbound plugin.call.* requests to the given handlers.
|
|
29
38
|
*/
|
|
30
39
|
export async function runPlugin(handlers: PluginHandlers): Promise<PluginRuntime> {
|
|
31
40
|
const { pipePath, token } = await readBootstrapLine();
|
|
@@ -33,10 +42,29 @@ export async function runPlugin(handlers: PluginHandlers): Promise<PluginRuntime
|
|
|
33
42
|
const transport = new NodeTransport();
|
|
34
43
|
await transport.connect(pipePath);
|
|
35
44
|
|
|
45
|
+
const identity = await completeHandshake(transport, token);
|
|
36
46
|
const router = new HandlerRouter({ send: (env: Envelope) => transport.send(env) });
|
|
47
|
+
router.setIdentity(identity);
|
|
48
|
+
|
|
49
|
+
// Passive host-liveness watchdog: exit if Host stops sending bus.ping (orphan process).
|
|
50
|
+
// Threshold is well above the host ping interval (~2s) to tolerate brief host stalls.
|
|
51
|
+
const HOST_LOST_MS = 15_000;
|
|
52
|
+
let lastPingAt = Date.now();
|
|
53
|
+
const watchdog = setInterval(() => {
|
|
54
|
+
if (Date.now() - lastPingAt > HOST_LOST_MS) {
|
|
55
|
+
clearInterval(watchdog);
|
|
56
|
+
process.exit(1);
|
|
57
|
+
}
|
|
58
|
+
}, 1_000);
|
|
59
|
+
watchdog.unref?.();
|
|
60
|
+
|
|
61
|
+
transport.onDisconnect(() => {
|
|
62
|
+
clearInterval(watchdog);
|
|
63
|
+
process.exit(1);
|
|
64
|
+
});
|
|
37
65
|
|
|
38
|
-
// Inbound envelopes from the host arrive on the transport.
|
|
39
66
|
transport.onMessage((env) => {
|
|
67
|
+
if (env.route === Routes.Bus.Ping) lastPingAt = Date.now();
|
|
40
68
|
router.dispatch(env);
|
|
41
69
|
});
|
|
42
70
|
|
|
@@ -47,7 +75,10 @@ export async function runPlugin(handlers: PluginHandlers): Promise<PluginRuntime
|
|
|
47
75
|
return {
|
|
48
76
|
transport,
|
|
49
77
|
router,
|
|
50
|
-
close: () =>
|
|
78
|
+
close: async () => {
|
|
79
|
+
clearInterval(watchdog);
|
|
80
|
+
await transport.close();
|
|
81
|
+
},
|
|
51
82
|
};
|
|
52
83
|
}
|
|
53
84
|
|
|
@@ -68,3 +99,61 @@ async function readBootstrapLine(): Promise<{ pipePath: string; token: string }>
|
|
|
68
99
|
rl.close();
|
|
69
100
|
}
|
|
70
101
|
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Sends bus.handshake with the bootstrap token and waits for the host response that binds
|
|
105
|
+
* plugin/entry/session/endpoint identity. Rejects on HandshakeFailed / ProtocolMismatch / timeout.
|
|
106
|
+
*/
|
|
107
|
+
export async function completeHandshake(
|
|
108
|
+
transport: NodeTransport,
|
|
109
|
+
token: string,
|
|
110
|
+
timeoutMs = 10000,
|
|
111
|
+
): Promise<{ pluginId: string; entryId: string; sessionId: string; endpointId: string }> {
|
|
112
|
+
const id = randomBytes(16).toString("hex");
|
|
113
|
+
const req: Envelope = {
|
|
114
|
+
version: ProtocolVersion,
|
|
115
|
+
id,
|
|
116
|
+
traceId: id,
|
|
117
|
+
sessionId: "",
|
|
118
|
+
pluginId: "",
|
|
119
|
+
entryId: "",
|
|
120
|
+
endpointId: EndpointIds.NodeMain,
|
|
121
|
+
kind: MessageKind.Request,
|
|
122
|
+
route: Routes.Bus.Handshake,
|
|
123
|
+
timeoutMs,
|
|
124
|
+
payload: {
|
|
125
|
+
version: ProtocolVersion,
|
|
126
|
+
supportedVersions: SUPPORTED_VERSIONS,
|
|
127
|
+
token,
|
|
128
|
+
},
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
return new Promise((resolve, reject) => {
|
|
132
|
+
const timer = setTimeout(() => {
|
|
133
|
+
unsubscribe();
|
|
134
|
+
reject(new Error(`bus.handshake timed out after ${timeoutMs}ms`));
|
|
135
|
+
}, timeoutMs);
|
|
136
|
+
|
|
137
|
+
const unsubscribe = transport.onMessage((env: Envelope) => {
|
|
138
|
+
if (env.kind !== MessageKind.Response || env.correlationId !== id) return;
|
|
139
|
+
clearTimeout(timer);
|
|
140
|
+
unsubscribe();
|
|
141
|
+
if (env.error) {
|
|
142
|
+
reject(new Error(`${env.error.code}: ${env.error.message}`));
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
const p = (env.payload ?? {}) as Record<string, unknown>;
|
|
146
|
+
const pluginId = String(p.pluginId ?? "");
|
|
147
|
+
const entryId = String(p.entryId ?? "");
|
|
148
|
+
const sessionId = String(p.sessionId ?? "");
|
|
149
|
+
const endpointId = String(p.endpointId ?? EndpointIds.NodeMain);
|
|
150
|
+
if (!pluginId || !entryId || !sessionId) {
|
|
151
|
+
reject(new Error("bus.handshake success response missing bound identity"));
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
resolve({ pluginId, entryId, sessionId, endpointId });
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
transport.send(req);
|
|
158
|
+
});
|
|
159
|
+
}
|
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,213 @@
|
|
|
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
|
+
* action -> plugin.call.invokeAction
|
|
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 {
|
|
15
|
+
EndpointIds,
|
|
16
|
+
MessageKind,
|
|
17
|
+
ProtocolVersion,
|
|
18
|
+
Routes,
|
|
19
|
+
hostCallRoute,
|
|
20
|
+
pluginCallRoute,
|
|
21
|
+
pluginEventRoute,
|
|
22
|
+
} from "./protocol.ts";
|
|
23
|
+
|
|
24
|
+
export type PluginTheme = "light" | "dark";
|
|
25
|
+
|
|
26
|
+
export type PluginHostEnv = {
|
|
27
|
+
locale: string;
|
|
28
|
+
fallbackLocale: string;
|
|
29
|
+
theme: PluginTheme;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export type PluginContext = PluginHostEnv & {
|
|
33
|
+
action: string;
|
|
34
|
+
itemId: string;
|
|
35
|
+
query: string;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/** Payload of plugin.call.initialize. Host sends locale, theme, and the resolved message bag. */
|
|
39
|
+
export type PluginInitializeParams = PluginHostEnv & {
|
|
40
|
+
messages: Record<string, string>;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/** Payload of plugin.call.search. */
|
|
44
|
+
export type PluginSearchParams = PluginHostEnv & {
|
|
45
|
+
query: string;
|
|
46
|
+
mode: "global" | "plugin";
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/** Payload of plugin.call.invokeAction. */
|
|
50
|
+
export type PluginActionParams = PluginHostEnv & {
|
|
51
|
+
itemId: string;
|
|
52
|
+
actionId: string;
|
|
53
|
+
query: string;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
type PluginInitializeHandler = (params: PluginInitializeParams) => unknown | Promise<unknown>;
|
|
57
|
+
type PluginSearchHandler = (params: PluginSearchParams) => unknown | Promise<unknown>;
|
|
58
|
+
type PluginActionHandler = (params: PluginActionParams) => unknown | Promise<unknown>;
|
|
59
|
+
type PluginHandler = (payload: any, context: PluginContext) => unknown | Promise<unknown>;
|
|
60
|
+
|
|
61
|
+
export class Plugin {
|
|
62
|
+
#handlers = new Map<string, PluginHandler>();
|
|
63
|
+
#searchHandler: PluginSearchHandler | null = null;
|
|
64
|
+
#actionHandler: PluginActionHandler | null = null;
|
|
65
|
+
#initializeHandler: PluginInitializeHandler | null = null;
|
|
66
|
+
#runtime: PluginRuntime | null = null;
|
|
67
|
+
|
|
68
|
+
initialize(handler: PluginInitializeHandler): this {
|
|
69
|
+
this.#initializeHandler = handler;
|
|
70
|
+
return this;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
search(handler: PluginSearchHandler): this {
|
|
74
|
+
this.#searchHandler = handler;
|
|
75
|
+
return this;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
action(handler: PluginActionHandler): this {
|
|
79
|
+
this.#actionHandler = handler;
|
|
80
|
+
return this;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
handle(action: string, handler: PluginHandler): this {
|
|
84
|
+
if (!action || typeof action !== "string") {
|
|
85
|
+
throw new Error("plugin.handle requires an action name.");
|
|
86
|
+
}
|
|
87
|
+
if (typeof handler !== "function") {
|
|
88
|
+
throw new Error("plugin.handle requires a handler.");
|
|
89
|
+
}
|
|
90
|
+
this.#handlers.set(action, handler);
|
|
91
|
+
return this;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Publishes a plugin.event.<subjectId> event to all webviews in the session. */
|
|
95
|
+
publish(subjectId: string, payload: unknown = {}): void {
|
|
96
|
+
if (!this.#runtime) throw new Error("plugin not started");
|
|
97
|
+
const route = pluginEventRoute(subjectId);
|
|
98
|
+
this.#runtime.transport.send({
|
|
99
|
+
version: ProtocolVersion,
|
|
100
|
+
id: crypto.randomUUID().replace(/-/g, "").slice(0, 32),
|
|
101
|
+
traceId: crypto.randomUUID().replace(/-/g, "").slice(0, 32),
|
|
102
|
+
sessionId: "",
|
|
103
|
+
pluginId: "",
|
|
104
|
+
entryId: "",
|
|
105
|
+
endpointId: EndpointIds.NodeMain,
|
|
106
|
+
kind: MessageKind.Event,
|
|
107
|
+
route,
|
|
108
|
+
payload,
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Calls a host.call.<method> capability and awaits the response.
|
|
113
|
+
* `timeoutMs` defaults to the remaining timeout of the inbound plugin.call
|
|
114
|
+
* (from a page `bus.call`) when inside a handler; otherwise 30s.
|
|
115
|
+
*/
|
|
116
|
+
hostCall(method: string, params: Record<string, unknown> = {}, timeoutMs?: number): Promise<unknown> {
|
|
117
|
+
if (!this.#runtime) return Promise.reject(new Error("plugin not started"));
|
|
118
|
+
return this.#runtime.router.callHost(hostCallRoute(method), params, timeoutMs);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Connects to the host pipe and begins dispatching. Must be called last. */
|
|
122
|
+
async start(): Promise<void> {
|
|
123
|
+
const routes = this.buildRoutes();
|
|
124
|
+
this.#runtime = await runPlugin(routes);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Builds the v3 route map from the fluent registrations. Exposed for unit testing the mapping
|
|
129
|
+
* without connecting a pipe.
|
|
130
|
+
*/
|
|
131
|
+
buildRoutes(): Record<string, (payload: any) => unknown | Promise<unknown>> {
|
|
132
|
+
const routes: Record<string, (payload: any) => unknown | Promise<unknown>> = {};
|
|
133
|
+
|
|
134
|
+
if (this.#initializeHandler) {
|
|
135
|
+
routes[Routes.PluginCall.Initialize] = (p) => this.#initializeHandler!(asInitializeParams(p));
|
|
136
|
+
}
|
|
137
|
+
if (this.#searchHandler) {
|
|
138
|
+
routes[Routes.PluginCall.Search] = (p) => this.#searchHandler!(asSearchParams(p));
|
|
139
|
+
}
|
|
140
|
+
if (this.#actionHandler) {
|
|
141
|
+
routes[Routes.PluginCall.InvokeAction] = (p) => this.#actionHandler!(asActionParams(p));
|
|
142
|
+
}
|
|
143
|
+
for (const [action, handler] of this.#handlers) {
|
|
144
|
+
const route = pluginCallRoute(action);
|
|
145
|
+
if (!routes[route]) {
|
|
146
|
+
routes[route] = async (p) => {
|
|
147
|
+
const ctx = extractContext(p, action);
|
|
148
|
+
return handler(p ?? {}, ctx);
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return routes;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async stop(): Promise<void> {
|
|
156
|
+
if (this.#runtime) await this.#runtime.close();
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
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),
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function asInitializeParams(p: any): PluginInitializeParams {
|
|
169
|
+
const messages = p?.messages;
|
|
170
|
+
return {
|
|
171
|
+
...asHostEnv(p),
|
|
172
|
+
messages: isStringRecord(messages) ? messages : {},
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function asSearchParams(p: any): PluginSearchParams {
|
|
177
|
+
return {
|
|
178
|
+
...asHostEnv(p),
|
|
179
|
+
query: typeof p?.query === "string" ? p.query : "",
|
|
180
|
+
mode: p?.mode === "plugin" ? "plugin" : "global",
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
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
|
+
function isStringRecord(value: unknown): value is Record<string, string> {
|
|
198
|
+
return !!value && typeof value === "object" && !Array.isArray(value)
|
|
199
|
+
&& Object.values(value).every((v) => typeof v === "string");
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function extractContext(p: any, action: string): PluginContext {
|
|
203
|
+
return {
|
|
204
|
+
...asHostEnv(p),
|
|
205
|
+
action,
|
|
206
|
+
itemId: typeof p?.itemId === "string" ? p.itemId : "",
|
|
207
|
+
query: typeof p?.query === "string" ? p.query : "",
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export function createPlugin(): Plugin {
|
|
212
|
+
return new Plugin();
|
|
213
|
+
}
|