@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
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Action registry types.
|
|
3
|
+
*
|
|
4
|
+
* A plugin registers its full action set once at startup via `plugin.actions([...])`. Search
|
|
5
|
+
* items and the detail page only reference actions by id; the parameters an action needs are
|
|
6
|
+
* produced inside its own `execute`, never carried on the item.
|
|
7
|
+
*/
|
|
8
|
+
import type { PluginHostEnv } from "./hostEnv.ts";
|
|
9
|
+
/** An i18n message key plus the English fallback the host uses when the key is missing. */
|
|
10
|
+
export type LocalizedText = {
|
|
11
|
+
key: string;
|
|
12
|
+
defaultValue: string;
|
|
13
|
+
};
|
|
14
|
+
/** Keys the host deliberately permits for action shortcuts. */
|
|
15
|
+
export declare const Key: {
|
|
16
|
+
readonly Enter: "Enter";
|
|
17
|
+
readonly Tab: "Tab";
|
|
18
|
+
readonly Space: "Space";
|
|
19
|
+
readonly Delete: "Delete";
|
|
20
|
+
readonly Backspace: "Backspace";
|
|
21
|
+
readonly Escape: "Escape";
|
|
22
|
+
readonly Left: "Left";
|
|
23
|
+
readonly Right: "Right";
|
|
24
|
+
readonly Up: "Up";
|
|
25
|
+
readonly Down: "Down";
|
|
26
|
+
readonly A: "A";
|
|
27
|
+
readonly B: "B";
|
|
28
|
+
readonly C: "C";
|
|
29
|
+
readonly D: "D";
|
|
30
|
+
readonly E: "E";
|
|
31
|
+
readonly F: "F";
|
|
32
|
+
readonly G: "G";
|
|
33
|
+
readonly H: "H";
|
|
34
|
+
readonly I: "I";
|
|
35
|
+
readonly J: "J";
|
|
36
|
+
readonly K: "K";
|
|
37
|
+
readonly L: "L";
|
|
38
|
+
readonly M: "M";
|
|
39
|
+
readonly N: "N";
|
|
40
|
+
readonly O: "O";
|
|
41
|
+
readonly P: "P";
|
|
42
|
+
readonly Q: "Q";
|
|
43
|
+
readonly R: "R";
|
|
44
|
+
readonly S: "S";
|
|
45
|
+
readonly T: "T";
|
|
46
|
+
readonly U: "U";
|
|
47
|
+
readonly V: "V";
|
|
48
|
+
readonly W: "W";
|
|
49
|
+
readonly X: "X";
|
|
50
|
+
readonly Y: "Y";
|
|
51
|
+
readonly Z: "Z";
|
|
52
|
+
readonly D0: "D0";
|
|
53
|
+
readonly D1: "D1";
|
|
54
|
+
readonly D2: "D2";
|
|
55
|
+
readonly D3: "D3";
|
|
56
|
+
readonly D4: "D4";
|
|
57
|
+
readonly D5: "D5";
|
|
58
|
+
readonly D6: "D6";
|
|
59
|
+
readonly D7: "D7";
|
|
60
|
+
readonly D8: "D8";
|
|
61
|
+
readonly D9: "D9";
|
|
62
|
+
readonly F1: "F1";
|
|
63
|
+
readonly F2: "F2";
|
|
64
|
+
readonly F3: "F3";
|
|
65
|
+
readonly F4: "F4";
|
|
66
|
+
readonly F5: "F5";
|
|
67
|
+
readonly F6: "F6";
|
|
68
|
+
readonly F7: "F7";
|
|
69
|
+
readonly F8: "F8";
|
|
70
|
+
readonly F9: "F9";
|
|
71
|
+
readonly F10: "F10";
|
|
72
|
+
readonly F11: "F11";
|
|
73
|
+
readonly F12: "F12";
|
|
74
|
+
};
|
|
75
|
+
export type HotkeyKey = (typeof Key)[keyof typeof Key];
|
|
76
|
+
/** Permitted modifier combinations, e.g. `Modifiers.ControlShift`. */
|
|
77
|
+
export declare const Modifiers: {
|
|
78
|
+
readonly None: 0;
|
|
79
|
+
readonly Control: 1;
|
|
80
|
+
readonly Alt: 2;
|
|
81
|
+
readonly ControlAlt: 3;
|
|
82
|
+
readonly Shift: 4;
|
|
83
|
+
readonly ControlShift: 5;
|
|
84
|
+
readonly AltShift: 6;
|
|
85
|
+
readonly ControlAltShift: 7;
|
|
86
|
+
};
|
|
87
|
+
export type HotkeyModifiers = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7;
|
|
88
|
+
export type Hotkey = {
|
|
89
|
+
key: HotkeyKey;
|
|
90
|
+
modifiers?: HotkeyModifiers;
|
|
91
|
+
};
|
|
92
|
+
/** Actions the host itself can carry out. Anything else belongs in `execute`. */
|
|
93
|
+
export declare const HostAction: {
|
|
94
|
+
readonly Copy: "copy";
|
|
95
|
+
readonly CopyAndPaste: "copyAndPaste";
|
|
96
|
+
readonly AddClipboardHistory: "addClipboardHistory";
|
|
97
|
+
readonly Execute: "execute";
|
|
98
|
+
readonly OpenInExplorer: "openInExplorer";
|
|
99
|
+
readonly OpenInBrowser: "openInBrowser";
|
|
100
|
+
readonly OpenPlugin: "openPlugin";
|
|
101
|
+
readonly Run: "run";
|
|
102
|
+
readonly Kill: "kill";
|
|
103
|
+
};
|
|
104
|
+
export type HostActionKind = (typeof HostAction)[keyof typeof HostAction];
|
|
105
|
+
/** Command spec for {@link HostAction.Run}. */
|
|
106
|
+
export type RunSpec = {
|
|
107
|
+
name?: string;
|
|
108
|
+
command: string;
|
|
109
|
+
args?: string;
|
|
110
|
+
workingDirectory?: string;
|
|
111
|
+
runAsAdmin?: boolean;
|
|
112
|
+
isBashScript?: boolean;
|
|
113
|
+
scripts?: string | string[];
|
|
114
|
+
};
|
|
115
|
+
/**
|
|
116
|
+
* What the host should do, with the parameters that kind actually needs. The discriminated union
|
|
117
|
+
* is the point: `{ kind: HostAction.Copy, path }` does not compile.
|
|
118
|
+
*/
|
|
119
|
+
export type HostActionRequest = {
|
|
120
|
+
kind: typeof HostAction.Copy;
|
|
121
|
+
text: string;
|
|
122
|
+
} | {
|
|
123
|
+
kind: typeof HostAction.CopyAndPaste;
|
|
124
|
+
text: string;
|
|
125
|
+
} | {
|
|
126
|
+
kind: typeof HostAction.AddClipboardHistory;
|
|
127
|
+
texts: string[];
|
|
128
|
+
} | {
|
|
129
|
+
kind: typeof HostAction.Execute;
|
|
130
|
+
path: string;
|
|
131
|
+
args?: string;
|
|
132
|
+
runAsAdmin?: boolean;
|
|
133
|
+
} | {
|
|
134
|
+
kind: typeof HostAction.OpenInExplorer;
|
|
135
|
+
path: string;
|
|
136
|
+
} | {
|
|
137
|
+
kind: typeof HostAction.OpenInBrowser;
|
|
138
|
+
url: string | string[];
|
|
139
|
+
} | {
|
|
140
|
+
kind: typeof HostAction.OpenPlugin;
|
|
141
|
+
pluginId: string;
|
|
142
|
+
} | {
|
|
143
|
+
kind: typeof HostAction.Run;
|
|
144
|
+
command: RunSpec;
|
|
145
|
+
} | {
|
|
146
|
+
kind: typeof HostAction.Kill;
|
|
147
|
+
pid: number;
|
|
148
|
+
};
|
|
149
|
+
/** Opens a web detail page. `page` defaults to the entry declared in plugin.json. */
|
|
150
|
+
export type DetailRequest = {
|
|
151
|
+
page?: string;
|
|
152
|
+
title?: string;
|
|
153
|
+
initialState?: unknown;
|
|
154
|
+
};
|
|
155
|
+
/**
|
|
156
|
+
* The result of running an action. Every field is optional and they combine: opening an IDE and
|
|
157
|
+
* closing the search window is `{ host: {...}, close: true }`.
|
|
158
|
+
*/
|
|
159
|
+
export type ActionOutcome = {
|
|
160
|
+
/** Run a host-side action (clipboard, process launch, browser, ...). */
|
|
161
|
+
host?: HostActionRequest;
|
|
162
|
+
/** Hand off to the detail page; arrives as `host.event.detailAction` with this payload. */
|
|
163
|
+
web?: {
|
|
164
|
+
payload?: unknown;
|
|
165
|
+
};
|
|
166
|
+
/** Open a web detail page. */
|
|
167
|
+
detail?: DetailRequest;
|
|
168
|
+
/** Status bar text. */
|
|
169
|
+
message?: LocalizedText;
|
|
170
|
+
/** Close the search window afterwards. Defaults to false. */
|
|
171
|
+
close?: boolean;
|
|
172
|
+
/** Refresh the current search results afterwards. Defaults to false. */
|
|
173
|
+
refresh?: boolean;
|
|
174
|
+
};
|
|
175
|
+
/**
|
|
176
|
+
* What an action sees when it runs. `item` is the original object returned by `search()`,
|
|
177
|
+
* including fields the host never saw — the SDK keeps it so actions do not have to re-derive
|
|
178
|
+
* their data from the item id.
|
|
179
|
+
*/
|
|
180
|
+
export type ActionContext<TItem = unknown> = PluginHostEnv & {
|
|
181
|
+
actionId: string;
|
|
182
|
+
itemId: string;
|
|
183
|
+
query: string;
|
|
184
|
+
item?: TItem;
|
|
185
|
+
};
|
|
186
|
+
/**
|
|
187
|
+
* One registered action. `hotkey` is optional: without it the first registered action gets Enter
|
|
188
|
+
* and the rest are click-only, matching search result items.
|
|
189
|
+
*/
|
|
190
|
+
export type ActionDefinition<TItem = any> = {
|
|
191
|
+
id: string;
|
|
192
|
+
title: LocalizedText;
|
|
193
|
+
description?: LocalizedText;
|
|
194
|
+
hotkey?: Hotkey;
|
|
195
|
+
execute: (context: ActionContext<TItem>) => ActionOutcome | void | Promise<ActionOutcome | void>;
|
|
196
|
+
};
|
|
197
|
+
/** The registry shape sent to the host in the initialize response (no `execute`). */
|
|
198
|
+
export type ActionManifestEntry = {
|
|
199
|
+
id: string;
|
|
200
|
+
title: LocalizedText;
|
|
201
|
+
description?: LocalizedText;
|
|
202
|
+
hotkey?: Hotkey;
|
|
203
|
+
};
|
|
204
|
+
export declare function toActionManifest(definition: ActionDefinition): ActionManifestEntry;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* v3 Node SDK bootstrap entry. Reads the bootstrap line from stdin (pipePath\ttoken), connects to
|
|
3
|
+
* the named pipe, completes bus.handshake (presenting the token and receiving bound identity),
|
|
4
|
+
* and starts a HandlerRouter stamped with that identity.
|
|
5
|
+
*
|
|
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
|
|
8
|
+
* and complete handshake before promoting the session to Ready.
|
|
9
|
+
*/
|
|
10
|
+
import { NodeTransport } from "./transport.ts";
|
|
11
|
+
import { HandlerRouter } from "./router.ts";
|
|
12
|
+
export interface PluginHandlers {
|
|
13
|
+
[route: string]: (payload: any, context: {
|
|
14
|
+
sessionId: string;
|
|
15
|
+
}) => Promise<any> | any;
|
|
16
|
+
}
|
|
17
|
+
export interface PluginRuntime {
|
|
18
|
+
transport: NodeTransport;
|
|
19
|
+
router: HandlerRouter;
|
|
20
|
+
close(): Promise<void>;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Connects to the host pipe (reading the bootstrap line from stdin), completes handshake, and
|
|
24
|
+
* returns a runtime whose router dispatches inbound plugin.call.* requests to the given handlers.
|
|
25
|
+
*/
|
|
26
|
+
export declare function runPlugin(handlers: PluginHandlers): Promise<PluginRuntime>;
|
|
27
|
+
/**
|
|
28
|
+
* Sends bus.handshake with the bootstrap token and waits for the host response that binds
|
|
29
|
+
* plugin/entry/session/endpoint identity. Rejects on HandshakeFailed / ProtocolMismatch / timeout.
|
|
30
|
+
*/
|
|
31
|
+
export declare function completeHandshake(transport: NodeTransport, token: string, timeoutMs?: number): Promise<{
|
|
32
|
+
pluginId: string;
|
|
33
|
+
entryId: string;
|
|
34
|
+
sessionId: string;
|
|
35
|
+
endpointId: string;
|
|
36
|
+
}>;
|
package/dist/bootstrap.mjs
CHANGED
|
@@ -110,12 +110,69 @@ var init_framing = __esm({
|
|
|
110
110
|
|
|
111
111
|
// src/bootstrap.ts
|
|
112
112
|
import readline from "node:readline/promises";
|
|
113
|
+
import { randomBytes as randomBytes2 } from "node:crypto";
|
|
113
114
|
|
|
114
115
|
// src/transport.ts
|
|
115
116
|
init_framing();
|
|
116
117
|
import { connect as netConnect } from "node:net";
|
|
117
118
|
|
|
118
119
|
// src/protocol.ts
|
|
120
|
+
var MessageKind = {
|
|
121
|
+
Request: "request",
|
|
122
|
+
Response: "response",
|
|
123
|
+
Event: "event"
|
|
124
|
+
};
|
|
125
|
+
var ErrorCode = {
|
|
126
|
+
ProtocolMismatch: "ProtocolMismatch",
|
|
127
|
+
HandshakeFailed: "HandshakeFailed",
|
|
128
|
+
CapabilityNotDeclared: "CapabilityNotDeclared",
|
|
129
|
+
CapabilityDenied: "CapabilityDenied",
|
|
130
|
+
InvalidPayload: "InvalidPayload",
|
|
131
|
+
MessageTooLarge: "MessageTooLarge",
|
|
132
|
+
RouteNotFound: "RouteNotFound",
|
|
133
|
+
RequestTimeout: "RequestTimeout",
|
|
134
|
+
TooManyRequests: "TooManyRequests",
|
|
135
|
+
TransportDisconnected: "TransportDisconnected",
|
|
136
|
+
PluginUnavailable: "PluginUnavailable",
|
|
137
|
+
InternalError: "InternalError",
|
|
138
|
+
Cancelled: "Cancelled",
|
|
139
|
+
RateLimited: "RateLimited"
|
|
140
|
+
};
|
|
141
|
+
var ProtocolVersion = "3.0";
|
|
142
|
+
var EndpointIds = {
|
|
143
|
+
NodeMain: "node-main",
|
|
144
|
+
Host: "host"
|
|
145
|
+
};
|
|
146
|
+
var Routes = {
|
|
147
|
+
Bus: {
|
|
148
|
+
Handshake: "bus.handshake",
|
|
149
|
+
Ping: "bus.ping",
|
|
150
|
+
Cancel: "bus.cancel",
|
|
151
|
+
Subscribe: "bus.subscribe",
|
|
152
|
+
Unsubscribe: "bus.unsubscribe"
|
|
153
|
+
},
|
|
154
|
+
Prefix: {
|
|
155
|
+
PluginCall: "plugin.call.",
|
|
156
|
+
HostCall: "host.call.",
|
|
157
|
+
PluginEvent: "plugin.event.",
|
|
158
|
+
HostEvent: "host.event.",
|
|
159
|
+
Diagnostics: "diagnostics."
|
|
160
|
+
},
|
|
161
|
+
PluginCall: {
|
|
162
|
+
Initialize: "plugin.call.initialize",
|
|
163
|
+
Search: "plugin.call.search",
|
|
164
|
+
InvokeAction: "plugin.call.invokeAction"
|
|
165
|
+
},
|
|
166
|
+
HostEvent: {
|
|
167
|
+
Initialize: "host.event.initialize",
|
|
168
|
+
Search: "host.event.search",
|
|
169
|
+
Key: "host.event.key",
|
|
170
|
+
DetailAction: "host.event.detailAction",
|
|
171
|
+
LanguageChanged: "host.event.languageChanged",
|
|
172
|
+
ThemeChanged: "host.event.themeChanged",
|
|
173
|
+
InputActionCaptured: "host.event.inputActionCaptured"
|
|
174
|
+
}
|
|
175
|
+
};
|
|
119
176
|
function canonicalStringify(value) {
|
|
120
177
|
return JSON.stringify(stripNulls(value));
|
|
121
178
|
}
|
|
@@ -141,6 +198,9 @@ var NodeTransport = class {
|
|
|
141
198
|
closed = false;
|
|
142
199
|
onMessage(handler) {
|
|
143
200
|
this.messageHandlers.add(handler);
|
|
201
|
+
return () => {
|
|
202
|
+
this.messageHandlers.delete(handler);
|
|
203
|
+
};
|
|
144
204
|
}
|
|
145
205
|
onDisconnect(handler) {
|
|
146
206
|
this.disconnectHandlers.add(handler);
|
|
@@ -207,14 +267,37 @@ var NodeTransport = class {
|
|
|
207
267
|
};
|
|
208
268
|
|
|
209
269
|
// src/router.ts
|
|
270
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
210
271
|
import { randomBytes } from "node:crypto";
|
|
272
|
+
var requestScope = new AsyncLocalStorage();
|
|
273
|
+
var DefaultHostCallTimeoutMs = 3e4;
|
|
274
|
+
function remainingTimeoutMs() {
|
|
275
|
+
const scope = requestScope.getStore();
|
|
276
|
+
if (!scope || scope.deadlineMs == null) {
|
|
277
|
+
return void 0;
|
|
278
|
+
}
|
|
279
|
+
return scope.deadlineMs - Date.now();
|
|
280
|
+
}
|
|
281
|
+
function resolveHostCallTimeoutMs(explicit) {
|
|
282
|
+
const remaining = remainingTimeoutMs();
|
|
283
|
+
if (explicit != null) {
|
|
284
|
+
return remaining == null ? explicit : Math.min(explicit, remaining);
|
|
285
|
+
}
|
|
286
|
+
return remaining ?? DefaultHostCallTimeoutMs;
|
|
287
|
+
}
|
|
288
|
+
function deadlineFromTimeoutMs(timeoutMs) {
|
|
289
|
+
if (typeof timeoutMs !== "number" || timeoutMs <= 0) {
|
|
290
|
+
return null;
|
|
291
|
+
}
|
|
292
|
+
return Date.now() + timeoutMs;
|
|
293
|
+
}
|
|
211
294
|
var HandlerRouter = class {
|
|
212
295
|
handlers = /* @__PURE__ */ new Map();
|
|
213
296
|
pendingHostCalls = /* @__PURE__ */ new Map();
|
|
214
297
|
pluginId = "p";
|
|
215
298
|
entryId = "e";
|
|
216
299
|
sessionId = "s";
|
|
217
|
-
endpointId =
|
|
300
|
+
endpointId = EndpointIds.NodeMain;
|
|
218
301
|
/** Injected transport send fn; tests can override `router.send` directly. */
|
|
219
302
|
send;
|
|
220
303
|
constructor(deps) {
|
|
@@ -232,43 +315,51 @@ var HandlerRouter = class {
|
|
|
232
315
|
}
|
|
233
316
|
/** Dispatches an inbound request/response. Returns once handled. */
|
|
234
317
|
async dispatch(env) {
|
|
235
|
-
if (env.kind ===
|
|
318
|
+
if (env.kind === MessageKind.Response) {
|
|
236
319
|
this.handleHostResponse(env);
|
|
237
320
|
return;
|
|
238
321
|
}
|
|
239
|
-
if (env.kind !==
|
|
240
|
-
if (env.route ===
|
|
322
|
+
if (env.kind !== MessageKind.Request) return;
|
|
323
|
+
if (env.route === Routes.Bus.Ping) {
|
|
241
324
|
this.send(this.responseFor(env, { ok: true }));
|
|
242
325
|
return;
|
|
243
326
|
}
|
|
244
327
|
const handler = this.handlers.get(env.route);
|
|
245
328
|
if (!handler) {
|
|
246
|
-
this.send(this.errorResponseFor(env,
|
|
329
|
+
this.send(this.errorResponseFor(env, ErrorCode.RouteNotFound, `route '${env.route}' has no handler`));
|
|
247
330
|
return;
|
|
248
331
|
}
|
|
332
|
+
const deadlineMs = deadlineFromTimeoutMs(env.timeoutMs);
|
|
249
333
|
try {
|
|
250
|
-
const result = await
|
|
334
|
+
const result = await requestScope.run(
|
|
335
|
+
{ deadlineMs },
|
|
336
|
+
() => handler(env.payload, { sessionId: env.sessionId })
|
|
337
|
+
);
|
|
251
338
|
this.send(this.responseFor(env, result ?? {}));
|
|
252
339
|
} catch (err) {
|
|
253
340
|
const message = err instanceof Error ? err.message : String(err);
|
|
254
|
-
this.send(this.errorResponseFor(env,
|
|
341
|
+
this.send(this.errorResponseFor(env, ErrorCode.InternalError, message));
|
|
255
342
|
}
|
|
256
343
|
}
|
|
257
344
|
/** Calls a host.call.* capability and resolves with the response payload. */
|
|
258
|
-
callHost(route, payload, timeoutMs
|
|
345
|
+
callHost(route, payload, timeoutMs) {
|
|
346
|
+
const effectiveTimeoutMs = resolveHostCallTimeoutMs(timeoutMs);
|
|
347
|
+
if (effectiveTimeoutMs <= 0) {
|
|
348
|
+
return Promise.reject(new Error(`host call ${route} timed out (no time remaining)`));
|
|
349
|
+
}
|
|
259
350
|
return new Promise((resolve, reject) => {
|
|
260
351
|
const id = randomBytesHex();
|
|
261
352
|
const req = {
|
|
262
|
-
version:
|
|
353
|
+
version: ProtocolVersion,
|
|
263
354
|
id,
|
|
264
355
|
traceId: id,
|
|
265
356
|
sessionId: this.sessionId,
|
|
266
357
|
pluginId: this.pluginId,
|
|
267
358
|
entryId: this.entryId,
|
|
268
359
|
endpointId: this.endpointId,
|
|
269
|
-
kind:
|
|
360
|
+
kind: MessageKind.Request,
|
|
270
361
|
route,
|
|
271
|
-
timeoutMs,
|
|
362
|
+
timeoutMs: effectiveTimeoutMs,
|
|
272
363
|
payload
|
|
273
364
|
};
|
|
274
365
|
const pending = { resolve, reject, route };
|
|
@@ -276,9 +367,9 @@ var HandlerRouter = class {
|
|
|
276
367
|
const timer = setTimeout(() => {
|
|
277
368
|
if (this.pendingHostCalls.has(id)) {
|
|
278
369
|
this.pendingHostCalls.delete(id);
|
|
279
|
-
reject(new Error(`host call ${route} timed out after ${
|
|
370
|
+
reject(new Error(`host call ${route} timed out after ${effectiveTimeoutMs}ms`));
|
|
280
371
|
}
|
|
281
|
-
},
|
|
372
|
+
}, effectiveTimeoutMs);
|
|
282
373
|
const origResolve = pending.resolve;
|
|
283
374
|
const origReject = pending.reject;
|
|
284
375
|
pending.resolve = (v) => {
|
|
@@ -305,7 +396,7 @@ var HandlerRouter = class {
|
|
|
305
396
|
}
|
|
306
397
|
responseFor(req, payload) {
|
|
307
398
|
return {
|
|
308
|
-
version:
|
|
399
|
+
version: ProtocolVersion,
|
|
309
400
|
id: randomBytesHex(),
|
|
310
401
|
correlationId: req.id,
|
|
311
402
|
traceId: req.traceId,
|
|
@@ -313,7 +404,7 @@ var HandlerRouter = class {
|
|
|
313
404
|
pluginId: req.pluginId,
|
|
314
405
|
entryId: req.entryId,
|
|
315
406
|
endpointId: this.endpointId,
|
|
316
|
-
kind:
|
|
407
|
+
kind: MessageKind.Response,
|
|
317
408
|
route: req.route,
|
|
318
409
|
payload
|
|
319
410
|
};
|
|
@@ -331,12 +422,29 @@ function randomBytesHex() {
|
|
|
331
422
|
}
|
|
332
423
|
|
|
333
424
|
// src/bootstrap.ts
|
|
425
|
+
var SUPPORTED_VERSIONS = [ProtocolVersion];
|
|
334
426
|
async function runPlugin(handlers) {
|
|
335
427
|
const { pipePath, token } = await readBootstrapLine();
|
|
336
428
|
const transport = new NodeTransport();
|
|
337
429
|
await transport.connect(pipePath);
|
|
430
|
+
const identity = await completeHandshake(transport, token);
|
|
338
431
|
const router = new HandlerRouter({ send: (env) => transport.send(env) });
|
|
432
|
+
router.setIdentity(identity);
|
|
433
|
+
const HOST_LOST_MS = 15e3;
|
|
434
|
+
let lastPingAt = Date.now();
|
|
435
|
+
const watchdog = setInterval(() => {
|
|
436
|
+
if (Date.now() - lastPingAt > HOST_LOST_MS) {
|
|
437
|
+
clearInterval(watchdog);
|
|
438
|
+
process.exit(1);
|
|
439
|
+
}
|
|
440
|
+
}, 1e3);
|
|
441
|
+
watchdog.unref?.();
|
|
442
|
+
transport.onDisconnect(() => {
|
|
443
|
+
clearInterval(watchdog);
|
|
444
|
+
process.exit(1);
|
|
445
|
+
});
|
|
339
446
|
transport.onMessage((env) => {
|
|
447
|
+
if (env.route === Routes.Bus.Ping) lastPingAt = Date.now();
|
|
340
448
|
router.dispatch(env);
|
|
341
449
|
});
|
|
342
450
|
for (const [route, handler] of Object.entries(handlers)) {
|
|
@@ -345,7 +453,10 @@ async function runPlugin(handlers) {
|
|
|
345
453
|
return {
|
|
346
454
|
transport,
|
|
347
455
|
router,
|
|
348
|
-
close: () =>
|
|
456
|
+
close: async () => {
|
|
457
|
+
clearInterval(watchdog);
|
|
458
|
+
await transport.close();
|
|
459
|
+
}
|
|
349
460
|
};
|
|
350
461
|
}
|
|
351
462
|
async function readBootstrapLine() {
|
|
@@ -364,6 +475,53 @@ async function readBootstrapLine() {
|
|
|
364
475
|
rl.close();
|
|
365
476
|
}
|
|
366
477
|
}
|
|
478
|
+
async function completeHandshake(transport, token, timeoutMs = 1e4) {
|
|
479
|
+
const id = randomBytes2(16).toString("hex");
|
|
480
|
+
const req = {
|
|
481
|
+
version: ProtocolVersion,
|
|
482
|
+
id,
|
|
483
|
+
traceId: id,
|
|
484
|
+
sessionId: "",
|
|
485
|
+
pluginId: "",
|
|
486
|
+
entryId: "",
|
|
487
|
+
endpointId: EndpointIds.NodeMain,
|
|
488
|
+
kind: MessageKind.Request,
|
|
489
|
+
route: Routes.Bus.Handshake,
|
|
490
|
+
timeoutMs,
|
|
491
|
+
payload: {
|
|
492
|
+
version: ProtocolVersion,
|
|
493
|
+
supportedVersions: SUPPORTED_VERSIONS,
|
|
494
|
+
token
|
|
495
|
+
}
|
|
496
|
+
};
|
|
497
|
+
return new Promise((resolve, reject) => {
|
|
498
|
+
const timer = setTimeout(() => {
|
|
499
|
+
unsubscribe();
|
|
500
|
+
reject(new Error(`bus.handshake timed out after ${timeoutMs}ms`));
|
|
501
|
+
}, timeoutMs);
|
|
502
|
+
const unsubscribe = transport.onMessage((env) => {
|
|
503
|
+
if (env.kind !== MessageKind.Response || env.correlationId !== id) return;
|
|
504
|
+
clearTimeout(timer);
|
|
505
|
+
unsubscribe();
|
|
506
|
+
if (env.error) {
|
|
507
|
+
reject(new Error(`${env.error.code}: ${env.error.message}`));
|
|
508
|
+
return;
|
|
509
|
+
}
|
|
510
|
+
const p = env.payload ?? {};
|
|
511
|
+
const pluginId = String(p.pluginId ?? "");
|
|
512
|
+
const entryId = String(p.entryId ?? "");
|
|
513
|
+
const sessionId = String(p.sessionId ?? "");
|
|
514
|
+
const endpointId = String(p.endpointId ?? EndpointIds.NodeMain);
|
|
515
|
+
if (!pluginId || !entryId || !sessionId) {
|
|
516
|
+
reject(new Error("bus.handshake success response missing bound identity"));
|
|
517
|
+
return;
|
|
518
|
+
}
|
|
519
|
+
resolve({ pluginId, entryId, sessionId, endpointId });
|
|
520
|
+
});
|
|
521
|
+
transport.send(req);
|
|
522
|
+
});
|
|
523
|
+
}
|
|
367
524
|
export {
|
|
525
|
+
completeHandshake,
|
|
368
526
|
runPlugin
|
|
369
527
|
};
|
package/dist/dev.d.ts
ADDED
package/dist/dev.mjs
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// src/developmentRefresh.ts
|
|
2
|
+
import { createConnection } from "node:net";
|
|
3
|
+
var DEVELOPMENT_REFRESH_PIPE_PATH = "\\\\.\\pipe\\MyTools.DevelopmentPlugins.Refresh";
|
|
4
|
+
var DEFAULT_RETRY_DELAY_MS = 250;
|
|
5
|
+
var DEFAULT_MAX_ATTEMPTS = 2;
|
|
6
|
+
var DEFAULT_REQUEST_TIMEOUT_MS = 2e3;
|
|
7
|
+
var VALID_PLUGIN_ID = /^[a-z0-9](?:[a-z0-9.-]{0,62}[a-z0-9])?$/;
|
|
8
|
+
async function requestDevelopmentPluginRefreshWithOptions(pluginId, options = {}) {
|
|
9
|
+
if (!VALID_PLUGIN_ID.test(pluginId)) {
|
|
10
|
+
throw new TypeError(`Invalid MyTools plugin ID: ${pluginId}`);
|
|
11
|
+
}
|
|
12
|
+
const pipePath = options.pipePath ?? DEVELOPMENT_REFRESH_PIPE_PATH;
|
|
13
|
+
const retryDelayMs = options.retryDelayMs ?? DEFAULT_RETRY_DELAY_MS;
|
|
14
|
+
const maxAttempts = options.maxAttempts ?? DEFAULT_MAX_ATTEMPTS;
|
|
15
|
+
const requestTimeoutMs = options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
|
|
16
|
+
if (!Number.isInteger(maxAttempts) || maxAttempts < 1) {
|
|
17
|
+
throw new RangeError("maxAttempts must be a positive integer");
|
|
18
|
+
}
|
|
19
|
+
if (!Number.isFinite(retryDelayMs) || retryDelayMs < 0) {
|
|
20
|
+
throw new RangeError("retryDelayMs must be a non-negative number");
|
|
21
|
+
}
|
|
22
|
+
if (!Number.isFinite(requestTimeoutMs) || requestTimeoutMs <= 0) {
|
|
23
|
+
throw new RangeError("requestTimeoutMs must be a positive number");
|
|
24
|
+
}
|
|
25
|
+
let lastError;
|
|
26
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
|
27
|
+
try {
|
|
28
|
+
await sendRefreshRequest(pipePath, pluginId, requestTimeoutMs);
|
|
29
|
+
return;
|
|
30
|
+
} catch (error) {
|
|
31
|
+
lastError = error;
|
|
32
|
+
if (attempt < maxAttempts) {
|
|
33
|
+
await delay(retryDelayMs);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
throw new Error(
|
|
38
|
+
`Failed to request MyTools refresh for ${pluginId} after ${maxAttempts} attempts`,
|
|
39
|
+
{ cause: lastError }
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
function sendRefreshRequest(pipePath, pluginId, requestTimeoutMs) {
|
|
43
|
+
return new Promise((resolve, reject) => {
|
|
44
|
+
const socket = createConnection(pipePath);
|
|
45
|
+
let settled = false;
|
|
46
|
+
let timeout;
|
|
47
|
+
const settle = (error) => {
|
|
48
|
+
if (settled) return;
|
|
49
|
+
settled = true;
|
|
50
|
+
if (timeout) clearTimeout(timeout);
|
|
51
|
+
if (error) reject(error);
|
|
52
|
+
else resolve();
|
|
53
|
+
};
|
|
54
|
+
socket.once("connect", () => {
|
|
55
|
+
socket.end(`${pluginId}
|
|
56
|
+
`, () => {
|
|
57
|
+
settle();
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
socket.once("error", (error) => {
|
|
61
|
+
socket.destroy();
|
|
62
|
+
settle(error);
|
|
63
|
+
});
|
|
64
|
+
timeout = setTimeout(() => {
|
|
65
|
+
socket.destroy();
|
|
66
|
+
settle(new Error(`Development refresh request timed out after ${requestTimeoutMs}ms`));
|
|
67
|
+
}, requestTimeoutMs);
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
function delay(milliseconds) {
|
|
71
|
+
return new Promise((resolve) => setTimeout(resolve, milliseconds));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// src/dev.ts
|
|
75
|
+
function requestDevelopmentPluginRefresh(pluginId) {
|
|
76
|
+
return requestDevelopmentPluginRefreshWithOptions(pluginId);
|
|
77
|
+
}
|
|
78
|
+
export {
|
|
79
|
+
requestDevelopmentPluginRefresh
|
|
80
|
+
};
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export declare const DEVELOPMENT_REFRESH_PIPE_PATH = "\\\\.\\pipe\\MyTools.DevelopmentPlugins.Refresh";
|
|
2
|
+
export type DevelopmentRefreshRequestOptions = {
|
|
3
|
+
pipePath?: string;
|
|
4
|
+
retryDelayMs?: number;
|
|
5
|
+
maxAttempts?: number;
|
|
6
|
+
requestTimeoutMs?: number;
|
|
7
|
+
};
|
|
8
|
+
/** Internal implementation with injectable timing and endpoint for protocol tests. */
|
|
9
|
+
export declare function requestDevelopmentPluginRefreshWithOptions(pluginId: string, options?: DevelopmentRefreshRequestOptions): Promise<void>;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Length-prefixed framing for the v3 named-pipe transport, mirroring the C# FrameCodec/FrameDecoder.
|
|
3
|
+
* Wire format: [4-byte little-endian unsigned length][UTF-8 JSON payload].
|
|
4
|
+
*
|
|
5
|
+
* The incremental decoder handles fragmented, sticky and truncated streams, and rejects an oversize
|
|
6
|
+
* length prefix as fatal *before* allocating the payload buffer (so a malicious/buggy peer cannot
|
|
7
|
+
* force a huge allocation). After a fatal error the decoder stays dead.
|
|
8
|
+
*/
|
|
9
|
+
export declare const MAX_FRAME_BYTES: number;
|
|
10
|
+
export declare const PREFIX_BYTES = 4;
|
|
11
|
+
/** Encodes a raw payload buffer into a length-prefixed frame. */
|
|
12
|
+
export declare function encodeFrame(payload: Buffer): Buffer;
|
|
13
|
+
/** Encodes a UTF-8 JSON string into a length-prefixed frame. */
|
|
14
|
+
export declare function encodeFrameString(json: string): Buffer;
|
|
15
|
+
export interface FrameFeedResult {
|
|
16
|
+
hasFrame: boolean;
|
|
17
|
+
payload: Buffer;
|
|
18
|
+
isFatal: boolean;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Incremental length-prefixed frame decoder. Feed byte chunks (fragmented/sticky/partial) and get
|
|
22
|
+
* back one complete payload at a time. Leftover bytes from a chunk that contained more than one
|
|
23
|
+
* frame are buffered internally and surfaced by subsequent feeds (including an empty buffer).
|
|
24
|
+
*/
|
|
25
|
+
export declare class FrameDecoder {
|
|
26
|
+
private prefixBuf;
|
|
27
|
+
private prefixFilled;
|
|
28
|
+
private payload;
|
|
29
|
+
private payloadFilled;
|
|
30
|
+
private payloadLength;
|
|
31
|
+
private fatal;
|
|
32
|
+
private pending;
|
|
33
|
+
feed(chunk: Buffer): FrameFeedResult;
|
|
34
|
+
private bufferLeftover;
|
|
35
|
+
private reset;
|
|
36
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/** Environment the host stamps onto every plugin.call payload. */
|
|
2
|
+
export type PluginTheme = "light" | "dark";
|
|
3
|
+
export type PluginHostEnv = {
|
|
4
|
+
locale: string;
|
|
5
|
+
fallbackLocale: string;
|
|
6
|
+
theme: PluginTheme;
|
|
7
|
+
};
|
|
8
|
+
export declare function asTheme(value: unknown): PluginTheme;
|
|
9
|
+
export declare function asHostEnv(payload: any): PluginHostEnv;
|