@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/actions.d.ts +204 -0
- package/dist/bootstrap.d.ts +36 -0
- package/dist/bootstrap.mjs +174 -16
- package/dist/dev.d.ts +5 -0
- package/dist/dev.mjs +80 -0
- package/dist/developmentRefresh.d.ts +9 -0
- package/dist/framing.d.ts +36 -0
- package/dist/hostEnv.d.ts +9 -0
- package/dist/hotkeyKeycaps.d.ts +2 -0
- package/dist/i18n.d.ts +19 -0
- package/dist/i18n.mjs +2343 -0
- package/dist/node.d.ts +83 -0
- package/dist/node.mjs +851 -0
- package/dist/protocol.d.ts +102 -0
- package/dist/protocol.mjs +74 -1
- package/dist/router.d.ts +43 -0
- package/dist/search.d.ts +7 -0
- package/dist/search.mjs +17 -0
- package/dist/transport.d.ts +24 -0
- package/dist/webClient.d.ts +35 -0
- package/dist/webClient.mjs +2624 -0
- package/dist/webTypes.d.ts +65 -0
- package/package.json +58 -34
- package/src/actions.ts +158 -0
- package/src/bootstrap.ts +159 -70
- 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/i18n.ts +120 -0
- package/src/node.ts +332 -0
- package/src/protocol.ts +83 -17
- package/src/router.ts +199 -149
- package/src/search.ts +22 -0
- package/src/transport.ts +2 -1
- package/src/webClient.ts +288 -0
- package/src/webTypes.ts +68 -0
- package/dist/bootstrap.d.mts +0 -1
- package/dist/protocol.d.mts +0 -1
- package/dist/server.d.mts +0 -1
- package/dist/server.mjs +0 -484
- package/src/server.ts +0 -156
package/src/router.ts
CHANGED
|
@@ -1,149 +1,199 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Handler router for the Node SDK v3. Dispatches inbound plugin.call.* requests to registered
|
|
3
|
-
* handlers, auto-replies to bus.ping, and provides callHost() to invoke host.call.* capabilities
|
|
4
|
-
* and correlate their responses. Mirrors the C# MessageBus routing rules on the Node side.
|
|
5
|
-
*/
|
|
6
|
-
|
|
7
|
-
import {
|
|
8
|
-
import
|
|
9
|
-
|
|
10
|
-
type
|
|
11
|
-
type
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
}
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Handler router for the Node SDK v3. Dispatches inbound plugin.call.* requests to registered
|
|
3
|
+
* handlers, auto-replies to bus.ping, and provides callHost() to invoke host.call.* capabilities
|
|
4
|
+
* and correlate their responses. Mirrors the C# MessageBus routing rules on the Node side.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
8
|
+
import { randomBytes } from "node:crypto";
|
|
9
|
+
import {
|
|
10
|
+
type Envelope,
|
|
11
|
+
type BusError,
|
|
12
|
+
EndpointIds,
|
|
13
|
+
ErrorCode,
|
|
14
|
+
MessageKind,
|
|
15
|
+
ProtocolVersion,
|
|
16
|
+
Routes,
|
|
17
|
+
} from "./protocol.ts";
|
|
18
|
+
|
|
19
|
+
type Handler = (payload: unknown, context: { sessionId: string }) => Promise<unknown> | unknown;
|
|
20
|
+
type Sender = (env: Envelope) => void;
|
|
21
|
+
|
|
22
|
+
interface PendingHostCall {
|
|
23
|
+
resolve: (value: unknown) => void;
|
|
24
|
+
reject: (err: Error) => void;
|
|
25
|
+
route: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
type RequestScope = { deadlineMs: number | null };
|
|
29
|
+
|
|
30
|
+
const requestScope = new AsyncLocalStorage<RequestScope>();
|
|
31
|
+
|
|
32
|
+
export const DefaultHostCallTimeoutMs = 30_000;
|
|
33
|
+
|
|
34
|
+
/** Remaining ms of the inbound plugin.call timeout, if currently inside a request. */
|
|
35
|
+
export function remainingTimeoutMs(): number | undefined {
|
|
36
|
+
const scope = requestScope.getStore();
|
|
37
|
+
if (!scope || scope.deadlineMs == null) {
|
|
38
|
+
return undefined;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return scope.deadlineMs - Date.now();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function resolveHostCallTimeoutMs(explicit?: number): number {
|
|
45
|
+
const remaining = remainingTimeoutMs();
|
|
46
|
+
if (explicit != null) {
|
|
47
|
+
return remaining == null ? explicit : Math.min(explicit, remaining);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return remaining ?? DefaultHostCallTimeoutMs;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function deadlineFromTimeoutMs(timeoutMs: number | null | undefined): number | null {
|
|
54
|
+
if (typeof timeoutMs !== "number" || timeoutMs <= 0) {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return Date.now() + timeoutMs;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export class HandlerRouter {
|
|
62
|
+
private handlers = new Map<string, Handler>();
|
|
63
|
+
private pendingHostCalls = new Map<string, PendingHostCall>();
|
|
64
|
+
private pluginId = "p";
|
|
65
|
+
private entryId = "e";
|
|
66
|
+
private sessionId = "s";
|
|
67
|
+
private endpointId: string = EndpointIds.NodeMain;
|
|
68
|
+
|
|
69
|
+
/** Injected transport send fn; tests can override `router.send` directly. */
|
|
70
|
+
send: Sender;
|
|
71
|
+
|
|
72
|
+
constructor(deps: { send: Sender }) {
|
|
73
|
+
this.send = deps.send;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Sets the bound identity stamped on outbound messages (after handshake). */
|
|
77
|
+
setIdentity(ids: { pluginId: string; entryId: string; sessionId: string; endpointId: string }): void {
|
|
78
|
+
this.pluginId = ids.pluginId;
|
|
79
|
+
this.entryId = ids.entryId;
|
|
80
|
+
this.sessionId = ids.sessionId;
|
|
81
|
+
this.endpointId = ids.endpointId;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
handle(route: string, handler: Handler): void {
|
|
85
|
+
this.handlers.set(route, handler);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Dispatches an inbound request/response. Returns once handled. */
|
|
89
|
+
async dispatch(env: Envelope): Promise<void> {
|
|
90
|
+
if (env.kind === MessageKind.Response) {
|
|
91
|
+
this.handleHostResponse(env);
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
if (env.kind !== MessageKind.Request) return;
|
|
95
|
+
|
|
96
|
+
// bus.ping is always auto-replied; it does not occupy a handler slot.
|
|
97
|
+
if (env.route === Routes.Bus.Ping) {
|
|
98
|
+
this.send(this.responseFor(env, { ok: true }));
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const handler = this.handlers.get(env.route);
|
|
103
|
+
if (!handler) {
|
|
104
|
+
this.send(this.errorResponseFor(env, ErrorCode.RouteNotFound, `route '${env.route}' has no handler`));
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const deadlineMs = deadlineFromTimeoutMs(env.timeoutMs);
|
|
109
|
+
try {
|
|
110
|
+
const result = await requestScope.run(
|
|
111
|
+
{ deadlineMs },
|
|
112
|
+
() => handler(env.payload, { sessionId: env.sessionId }),
|
|
113
|
+
);
|
|
114
|
+
this.send(this.responseFor(env, result ?? {}));
|
|
115
|
+
} catch (err) {
|
|
116
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
117
|
+
this.send(this.errorResponseFor(env, ErrorCode.InternalError, message));
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Calls a host.call.* capability and resolves with the response payload. */
|
|
122
|
+
callHost(route: string, payload: unknown, timeoutMs?: number): Promise<unknown> {
|
|
123
|
+
const effectiveTimeoutMs = resolveHostCallTimeoutMs(timeoutMs);
|
|
124
|
+
if (effectiveTimeoutMs <= 0) {
|
|
125
|
+
return Promise.reject(new Error(`host call ${route} timed out (no time remaining)`));
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return new Promise((resolve, reject) => {
|
|
129
|
+
const id = randomBytesHex();
|
|
130
|
+
const req: Envelope = {
|
|
131
|
+
version: ProtocolVersion,
|
|
132
|
+
id,
|
|
133
|
+
traceId: id,
|
|
134
|
+
sessionId: this.sessionId,
|
|
135
|
+
pluginId: this.pluginId,
|
|
136
|
+
entryId: this.entryId,
|
|
137
|
+
endpointId: this.endpointId,
|
|
138
|
+
kind: MessageKind.Request,
|
|
139
|
+
route,
|
|
140
|
+
timeoutMs: effectiveTimeoutMs,
|
|
141
|
+
payload,
|
|
142
|
+
};
|
|
143
|
+
const pending: PendingHostCall = { resolve, reject, route };
|
|
144
|
+
this.pendingHostCalls.set(id, pending);
|
|
145
|
+
const timer = setTimeout(() => {
|
|
146
|
+
if (this.pendingHostCalls.has(id)) {
|
|
147
|
+
this.pendingHostCalls.delete(id);
|
|
148
|
+
reject(new Error(`host call ${route} timed out after ${effectiveTimeoutMs}ms`));
|
|
149
|
+
}
|
|
150
|
+
}, effectiveTimeoutMs);
|
|
151
|
+
const origResolve = pending.resolve;
|
|
152
|
+
const origReject = pending.reject;
|
|
153
|
+
pending.resolve = (v) => { clearTimeout(timer); origResolve(v); };
|
|
154
|
+
pending.reject = (e) => { clearTimeout(timer); origReject(e); };
|
|
155
|
+
this.send(req);
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
private handleHostResponse(env: Envelope): void {
|
|
160
|
+
if (!env.correlationId) return;
|
|
161
|
+
const pending = this.pendingHostCalls.get(env.correlationId);
|
|
162
|
+
if (!pending) return;
|
|
163
|
+
this.pendingHostCalls.delete(env.correlationId);
|
|
164
|
+
if (env.error) {
|
|
165
|
+
pending.reject(new Error(`${env.error.code}: ${env.error.message}`));
|
|
166
|
+
} else {
|
|
167
|
+
pending.resolve(env.payload);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
private responseFor(req: Envelope, payload: unknown): Envelope {
|
|
172
|
+
return {
|
|
173
|
+
version: ProtocolVersion,
|
|
174
|
+
id: randomBytesHex(),
|
|
175
|
+
correlationId: req.id,
|
|
176
|
+
traceId: req.traceId,
|
|
177
|
+
sessionId: req.sessionId,
|
|
178
|
+
pluginId: req.pluginId,
|
|
179
|
+
entryId: req.entryId,
|
|
180
|
+
endpointId: this.endpointId,
|
|
181
|
+
kind: MessageKind.Response,
|
|
182
|
+
route: req.route,
|
|
183
|
+
payload,
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
private errorResponseFor(req: Envelope, code: BusError["code"], message: string): Envelope {
|
|
188
|
+
return {
|
|
189
|
+
...this.responseFor(req, null),
|
|
190
|
+
payload: undefined,
|
|
191
|
+
error: { code, message, retryable: false } as BusError,
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function randomBytesHex(): string {
|
|
197
|
+
// 16 random bytes -> 32 hex chars, matching the C# GuidIdGenerator format.
|
|
198
|
+
return randomBytes(16).toString("hex");
|
|
199
|
+
}
|
package/src/search.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Returns whether every character in `pattern` occurs in `target` in the same order.
|
|
3
|
+
* Matching is case-insensitive and characters do not need to be adjacent.
|
|
4
|
+
*
|
|
5
|
+
* @example isSubsequence("gthb", "GitHub") // true
|
|
6
|
+
*/
|
|
7
|
+
export function isSubsequence(pattern: string, target: string): boolean {
|
|
8
|
+
if (!pattern) return true;
|
|
9
|
+
if (!target) return false;
|
|
10
|
+
|
|
11
|
+
const needle = pattern.toLowerCase();
|
|
12
|
+
const haystack = target.toLowerCase();
|
|
13
|
+
let patternIndex = 0;
|
|
14
|
+
|
|
15
|
+
for (let targetIndex = 0; targetIndex < haystack.length && patternIndex < needle.length; targetIndex += 1) {
|
|
16
|
+
if (haystack[targetIndex] === needle[patternIndex]) {
|
|
17
|
+
patternIndex += 1;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
return patternIndex === needle.length;
|
|
22
|
+
}
|
package/src/transport.ts
CHANGED
|
@@ -17,8 +17,9 @@ export class NodeTransport {
|
|
|
17
17
|
private disconnectHandlers = new Set<DisconnectHandler>();
|
|
18
18
|
private closed = false;
|
|
19
19
|
|
|
20
|
-
onMessage(handler: MessageHandler): void {
|
|
20
|
+
onMessage(handler: MessageHandler): () => void {
|
|
21
21
|
this.messageHandlers.add(handler);
|
|
22
|
+
return () => { this.messageHandlers.delete(handler); };
|
|
22
23
|
}
|
|
23
24
|
|
|
24
25
|
onDisconnect(handler: DisconnectHandler): void {
|
package/src/webClient.ts
ADDED
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* v3 Web SDK: speaks protocol envelopes over chrome.webview.postMessage.
|
|
3
|
+
* Host stamps identity; the page does not supply plugin/entry/session ids.
|
|
4
|
+
*
|
|
5
|
+
* Connections start with bus.handshake. Subsequent plugin.call.* envelopes use the
|
|
6
|
+
* negotiated version. call("refresh") is sent as plugin.call.refresh.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { mytoolsI18n } from "./i18n.ts";
|
|
10
|
+
import {
|
|
11
|
+
type Envelope,
|
|
12
|
+
type BusError,
|
|
13
|
+
MessageKind,
|
|
14
|
+
ProtocolVersion,
|
|
15
|
+
Routes,
|
|
16
|
+
pluginCallRoute,
|
|
17
|
+
} from "./protocol.ts";
|
|
18
|
+
import type { MyToolsThemePayload } from "./webTypes.ts";
|
|
19
|
+
|
|
20
|
+
export { mytoolsI18n } from "./i18n.ts";
|
|
21
|
+
export { renderHotkeyKeycaps } from "./hotkeyKeycaps.ts";
|
|
22
|
+
export { HostEvents } from "./webTypes.ts";
|
|
23
|
+
export type {
|
|
24
|
+
MyToolsHostActionDefinition,
|
|
25
|
+
MyToolsHostDetailActionPayload,
|
|
26
|
+
MyToolsHostInitializePayload,
|
|
27
|
+
MyToolsHostKeyPayload,
|
|
28
|
+
MyToolsHostSearchPayload,
|
|
29
|
+
MyToolsInputActionCapturedPayload,
|
|
30
|
+
MyToolsLanguageChangedPayload,
|
|
31
|
+
MyToolsThemeChangedPayload,
|
|
32
|
+
MyToolsThemePayload,
|
|
33
|
+
} from "./webTypes.ts";
|
|
34
|
+
|
|
35
|
+
type Pending = {
|
|
36
|
+
resolve: (value: unknown) => void;
|
|
37
|
+
reject: (err: Error) => void;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export interface WebBusClient {
|
|
41
|
+
/** Sends plugin.call.<method>. Bare names are prefixed; full routes are left as-is. */
|
|
42
|
+
call<T = unknown>(method: string, payload?: unknown, timeoutMs?: number): Promise<T>;
|
|
43
|
+
on<T = unknown>(route: string, handler: (payload: T) => void): () => void;
|
|
44
|
+
i18n: typeof mytoolsI18n;
|
|
45
|
+
theme: typeof mytoolsTheme;
|
|
46
|
+
close(): void;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const HandshakeTimeoutMs = 8_000;
|
|
50
|
+
|
|
51
|
+
const mytoolsTheme = {
|
|
52
|
+
current: "dark",
|
|
53
|
+
apply(payload: MyToolsThemePayload): void {
|
|
54
|
+
if (typeof payload.theme === "string") {
|
|
55
|
+
this.current = payload.theme;
|
|
56
|
+
}
|
|
57
|
+
const root = typeof document !== "undefined" ? document.documentElement : null;
|
|
58
|
+
if (!root) {
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
if (typeof payload.theme === "string") {
|
|
62
|
+
root.setAttribute("data-theme", payload.theme);
|
|
63
|
+
root.style.colorScheme = payload.theme;
|
|
64
|
+
}
|
|
65
|
+
if (payload.themeTokens) {
|
|
66
|
+
for (const [key, value] of Object.entries(payload.themeTokens)) {
|
|
67
|
+
if (typeof value === "string") {
|
|
68
|
+
root.style.setProperty(key, value);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
function hasWebView(): boolean {
|
|
76
|
+
return !!(typeof window !== "undefined" && (window as any).chrome?.webview);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function post(env: Envelope): void {
|
|
80
|
+
if (!hasWebView()) throw new Error("chrome.webview is not available");
|
|
81
|
+
(window as any).chrome.webview.postMessage(env);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function randomId(): string {
|
|
85
|
+
const bytes = new Uint8Array(16);
|
|
86
|
+
crypto.getRandomValues(bytes);
|
|
87
|
+
return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function parseMessage(data: unknown): Envelope | null {
|
|
91
|
+
if (typeof data === "string") {
|
|
92
|
+
try {
|
|
93
|
+
data = JSON.parse(data);
|
|
94
|
+
} catch {
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
if (!data || typeof data !== "object") return null;
|
|
99
|
+
const env = data as Envelope;
|
|
100
|
+
if (typeof env.kind !== "string" || typeof env.route !== "string") return null;
|
|
101
|
+
return env;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function applyHostSideEffects(env: Envelope): void {
|
|
105
|
+
const payload = env.payload;
|
|
106
|
+
if (!payload || typeof payload !== "object") return;
|
|
107
|
+
if (env.route === Routes.HostEvent.Initialize) {
|
|
108
|
+
mytoolsI18n.configure(payload);
|
|
109
|
+
mytoolsI18n.apply();
|
|
110
|
+
mytoolsTheme.apply(payload as MyToolsThemePayload);
|
|
111
|
+
} else if (env.route === Routes.HostEvent.LanguageChanged) {
|
|
112
|
+
mytoolsI18n.configure(payload);
|
|
113
|
+
mytoolsI18n.apply();
|
|
114
|
+
} else if (env.route === Routes.HostEvent.ThemeChanged) {
|
|
115
|
+
mytoolsTheme.apply(payload as MyToolsThemePayload);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function negotiatedVersionFrom(payload: unknown): string {
|
|
120
|
+
if (payload && typeof payload === "object" && "negotiatedVersion" in payload) {
|
|
121
|
+
const value = (payload as { negotiatedVersion?: unknown }).negotiatedVersion;
|
|
122
|
+
if (typeof value === "string" && value.length > 0) return value;
|
|
123
|
+
}
|
|
124
|
+
return ProtocolVersion;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Creates a Web bus client. Registers the message listener immediately so host
|
|
129
|
+
* events that arrive before `on()` are buffered and replayed.
|
|
130
|
+
*
|
|
131
|
+
* Handshake is required before call(). Page scripts are bundled as IIFE, so this
|
|
132
|
+
* function is synchronous; handshake runs in the background and gates call().
|
|
133
|
+
*/
|
|
134
|
+
export function createWebBusClient(options?: {
|
|
135
|
+
timeoutMs?: number;
|
|
136
|
+
}): WebBusClient {
|
|
137
|
+
const pending = new Map<string, Pending>();
|
|
138
|
+
const eventHandlers = new Set<(env: Envelope) => void>();
|
|
139
|
+
const lastByRoute = new Map<string, Envelope>();
|
|
140
|
+
const defaultTimeout = options?.timeoutMs ?? 30_000;
|
|
141
|
+
let wireVersion = ProtocolVersion;
|
|
142
|
+
let handshakeError: Error | null = null;
|
|
143
|
+
|
|
144
|
+
const onMessage = (event: MessageEvent) => {
|
|
145
|
+
const env = parseMessage(event.data);
|
|
146
|
+
if (!env) return;
|
|
147
|
+
if (env.kind === MessageKind.Response && env.correlationId) {
|
|
148
|
+
const p = pending.get(env.correlationId);
|
|
149
|
+
if (!p) return;
|
|
150
|
+
pending.delete(env.correlationId);
|
|
151
|
+
if (env.error) {
|
|
152
|
+
p.reject(new Error(`${(env.error as BusError).code}: ${(env.error as BusError).message}`));
|
|
153
|
+
} else {
|
|
154
|
+
p.resolve(env.payload);
|
|
155
|
+
}
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
if (env.kind === MessageKind.Event) {
|
|
159
|
+
lastByRoute.set(env.route, env);
|
|
160
|
+
applyHostSideEffects(env);
|
|
161
|
+
for (const h of eventHandlers) h(env);
|
|
162
|
+
}
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
let handshakeDone: Promise<void> = Promise.resolve();
|
|
166
|
+
if (hasWebView()) {
|
|
167
|
+
(window as any).chrome.webview.addEventListener("message", onMessage);
|
|
168
|
+
handshakeDone = handshake(HandshakeTimeoutMs)
|
|
169
|
+
.then((version) => {
|
|
170
|
+
wireVersion = version;
|
|
171
|
+
})
|
|
172
|
+
.catch((err: unknown) => {
|
|
173
|
+
handshakeError = err instanceof Error ? err : new Error(String(err));
|
|
174
|
+
throw handshakeError;
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function ensureHandshaken(): Promise<void> {
|
|
179
|
+
if (handshakeError) return Promise.reject(handshakeError);
|
|
180
|
+
return handshakeDone.then(() => {
|
|
181
|
+
if (handshakeError) throw handshakeError;
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function call<T = unknown>(method: string, payload?: unknown, timeoutMs = defaultTimeout): Promise<T> {
|
|
186
|
+
const route = pluginCallRoute(method);
|
|
187
|
+
return ensureHandshaken().then(
|
|
188
|
+
() =>
|
|
189
|
+
new Promise<T>((resolve, reject) => {
|
|
190
|
+
const id = randomId();
|
|
191
|
+
const timer = window.setTimeout(() => {
|
|
192
|
+
pending.delete(id);
|
|
193
|
+
reject(new Error(`request timed out: ${route}`));
|
|
194
|
+
}, timeoutMs);
|
|
195
|
+
pending.set(id, {
|
|
196
|
+
resolve: (v) => {
|
|
197
|
+
window.clearTimeout(timer);
|
|
198
|
+
resolve(v as T);
|
|
199
|
+
},
|
|
200
|
+
reject: (e) => {
|
|
201
|
+
window.clearTimeout(timer);
|
|
202
|
+
reject(e);
|
|
203
|
+
},
|
|
204
|
+
});
|
|
205
|
+
post({
|
|
206
|
+
version: wireVersion,
|
|
207
|
+
id,
|
|
208
|
+
traceId: id,
|
|
209
|
+
sessionId: "",
|
|
210
|
+
pluginId: "",
|
|
211
|
+
entryId: "",
|
|
212
|
+
endpointId: "",
|
|
213
|
+
kind: MessageKind.Request,
|
|
214
|
+
route,
|
|
215
|
+
timeoutMs,
|
|
216
|
+
payload: payload ?? {},
|
|
217
|
+
});
|
|
218
|
+
}),
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function subscribe(handler: (env: Envelope) => void): () => void {
|
|
223
|
+
eventHandlers.add(handler);
|
|
224
|
+
for (const env of lastByRoute.values()) handler(env);
|
|
225
|
+
return () => {
|
|
226
|
+
eventHandlers.delete(handler);
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
return {
|
|
231
|
+
call,
|
|
232
|
+
on: (route, handler) =>
|
|
233
|
+
subscribe((env) => {
|
|
234
|
+
if (env.route === route) handler((env.payload ?? {}) as never);
|
|
235
|
+
}),
|
|
236
|
+
i18n: mytoolsI18n,
|
|
237
|
+
theme: mytoolsTheme,
|
|
238
|
+
close: () => {
|
|
239
|
+
if (hasWebView()) {
|
|
240
|
+
(window as any).chrome.webview.removeEventListener("message", onMessage);
|
|
241
|
+
}
|
|
242
|
+
pending.clear();
|
|
243
|
+
eventHandlers.clear();
|
|
244
|
+
lastByRoute.clear();
|
|
245
|
+
},
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function handshake(timeoutMs: number): Promise<string> {
|
|
250
|
+
const id = randomId();
|
|
251
|
+
return new Promise((resolve, reject) => {
|
|
252
|
+
const timer = window.setTimeout(() => {
|
|
253
|
+
cleanup();
|
|
254
|
+
reject(new Error("bus.handshake timed out"));
|
|
255
|
+
}, timeoutMs);
|
|
256
|
+
|
|
257
|
+
const onMessage = (event: MessageEvent) => {
|
|
258
|
+
const env = parseMessage(event.data);
|
|
259
|
+
if (!env || env.kind !== MessageKind.Response || env.correlationId !== id) return;
|
|
260
|
+
cleanup();
|
|
261
|
+
if (env.error) {
|
|
262
|
+
reject(new Error(`${env.error.code}: ${env.error.message}`));
|
|
263
|
+
} else {
|
|
264
|
+
resolve(negotiatedVersionFrom(env.payload));
|
|
265
|
+
}
|
|
266
|
+
};
|
|
267
|
+
|
|
268
|
+
const cleanup = () => {
|
|
269
|
+
window.clearTimeout(timer);
|
|
270
|
+
(window as any).chrome.webview.removeEventListener("message", onMessage);
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
(window as any).chrome.webview.addEventListener("message", onMessage);
|
|
274
|
+
post({
|
|
275
|
+
version: ProtocolVersion,
|
|
276
|
+
id,
|
|
277
|
+
traceId: id,
|
|
278
|
+
sessionId: "",
|
|
279
|
+
pluginId: "",
|
|
280
|
+
entryId: "",
|
|
281
|
+
endpointId: "web",
|
|
282
|
+
kind: MessageKind.Request,
|
|
283
|
+
route: Routes.Bus.Handshake,
|
|
284
|
+
timeoutMs,
|
|
285
|
+
payload: { version: ProtocolVersion, supportedVersions: [ProtocolVersion] },
|
|
286
|
+
});
|
|
287
|
+
});
|
|
288
|
+
}
|