@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,65 @@
|
|
|
1
|
+
export interface MyToolsHostActionDefinition {
|
|
2
|
+
id: string;
|
|
3
|
+
/** Host-localized action display name. */
|
|
4
|
+
name: string;
|
|
5
|
+
/** Host-validated display form, for example `Ctrl+H`; absent means click-only. */
|
|
6
|
+
hotkey?: string | null;
|
|
7
|
+
}
|
|
8
|
+
export interface MyToolsHostInitializePayload {
|
|
9
|
+
protocolVersion: string;
|
|
10
|
+
pluginId: string;
|
|
11
|
+
version?: string;
|
|
12
|
+
itemId: string;
|
|
13
|
+
query: string;
|
|
14
|
+
keyword: string;
|
|
15
|
+
initialState: unknown;
|
|
16
|
+
locale: string;
|
|
17
|
+
fallbackLocale: string;
|
|
18
|
+
translationRevision: string;
|
|
19
|
+
messages: Record<string, string>;
|
|
20
|
+
actions: MyToolsHostActionDefinition[];
|
|
21
|
+
theme?: string;
|
|
22
|
+
themeTokens?: Record<string, string>;
|
|
23
|
+
}
|
|
24
|
+
export interface MyToolsLanguageChangedPayload {
|
|
25
|
+
locale: string;
|
|
26
|
+
fallbackLocale: string;
|
|
27
|
+
translationRevision: string;
|
|
28
|
+
messages: Record<string, string>;
|
|
29
|
+
}
|
|
30
|
+
export interface MyToolsThemeChangedPayload {
|
|
31
|
+
theme: string;
|
|
32
|
+
themeTokens: Record<string, string>;
|
|
33
|
+
}
|
|
34
|
+
export interface MyToolsHostSearchPayload {
|
|
35
|
+
query: string;
|
|
36
|
+
}
|
|
37
|
+
export interface MyToolsHostKeyPayload {
|
|
38
|
+
key: string;
|
|
39
|
+
}
|
|
40
|
+
/** Payload explicitly returned in an action outcome's `web.payload`. */
|
|
41
|
+
export interface MyToolsHostDetailActionPayload {
|
|
42
|
+
actionId?: string;
|
|
43
|
+
action?: string;
|
|
44
|
+
[key: string]: unknown;
|
|
45
|
+
}
|
|
46
|
+
export interface MyToolsInputActionCapturedPayload {
|
|
47
|
+
requestId: string;
|
|
48
|
+
cancelled?: boolean;
|
|
49
|
+
kind?: "hotkey" | "mouse";
|
|
50
|
+
hotKey?: string | null;
|
|
51
|
+
mouseButton?: string | null;
|
|
52
|
+
}
|
|
53
|
+
export interface MyToolsThemePayload {
|
|
54
|
+
theme?: string;
|
|
55
|
+
themeTokens?: Record<string, string>;
|
|
56
|
+
}
|
|
57
|
+
export declare const HostEvents: {
|
|
58
|
+
readonly Initialize: "host.event.initialize";
|
|
59
|
+
readonly Search: "host.event.search";
|
|
60
|
+
readonly Key: "host.event.key";
|
|
61
|
+
readonly DetailAction: "host.event.detailAction";
|
|
62
|
+
readonly LanguageChanged: "host.event.languageChanged";
|
|
63
|
+
readonly ThemeChanged: "host.event.themeChanged";
|
|
64
|
+
readonly InputActionCaptured: "host.event.inputActionCaptured";
|
|
65
|
+
};
|
package/package.json
CHANGED
|
@@ -1,36 +1,60 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "@qping/plugin-bus",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "v3 message-bus runtime for MyTools plugins (named-pipe transport).",
|
|
5
|
-
"type": "module",
|
|
6
|
-
"files": [
|
|
7
|
-
"dist",
|
|
8
|
-
"src"
|
|
9
|
-
],
|
|
10
|
-
"exports": {
|
|
11
|
-
"./
|
|
12
|
-
"types": "./dist/
|
|
13
|
-
"import": "./dist/
|
|
14
|
-
"default": "./dist/
|
|
1
|
+
{
|
|
2
|
+
"name": "@qping/plugin-bus",
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "v3 message-bus runtime for MyTools plugins (named-pipe transport).",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"files": [
|
|
7
|
+
"dist",
|
|
8
|
+
"src"
|
|
9
|
+
],
|
|
10
|
+
"exports": {
|
|
11
|
+
"./node": {
|
|
12
|
+
"types": "./dist/node.d.ts",
|
|
13
|
+
"import": "./dist/node.mjs",
|
|
14
|
+
"default": "./dist/node.mjs"
|
|
15
|
+
},
|
|
16
|
+
"./protocol": {
|
|
17
|
+
"types": "./dist/protocol.d.ts",
|
|
18
|
+
"import": "./dist/protocol.mjs",
|
|
19
|
+
"default": "./dist/protocol.mjs"
|
|
20
|
+
},
|
|
21
|
+
"./bootstrap": {
|
|
22
|
+
"types": "./dist/bootstrap.d.ts",
|
|
23
|
+
"import": "./dist/bootstrap.mjs",
|
|
24
|
+
"default": "./dist/bootstrap.mjs"
|
|
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"
|
|
15
35
|
},
|
|
16
|
-
"./
|
|
17
|
-
"types": "./dist/
|
|
18
|
-
"import": "./dist/
|
|
19
|
-
"default": "./dist/
|
|
36
|
+
"./search": {
|
|
37
|
+
"types": "./dist/search.d.ts",
|
|
38
|
+
"import": "./dist/search.mjs",
|
|
39
|
+
"default": "./dist/search.mjs"
|
|
20
40
|
},
|
|
21
|
-
"./
|
|
22
|
-
"types": "./dist/
|
|
23
|
-
"import": "./dist/
|
|
24
|
-
"default": "./dist/
|
|
25
|
-
},
|
|
26
|
-
"./package.json": "./package.json"
|
|
27
|
-
},
|
|
28
|
-
"
|
|
29
|
-
"
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
"
|
|
35
|
-
}
|
|
36
|
-
|
|
41
|
+
"./dev": {
|
|
42
|
+
"types": "./dist/dev.d.ts",
|
|
43
|
+
"import": "./dist/dev.mjs",
|
|
44
|
+
"default": "./dist/dev.mjs"
|
|
45
|
+
},
|
|
46
|
+
"./package.json": "./package.json"
|
|
47
|
+
},
|
|
48
|
+
"dependencies": {
|
|
49
|
+
"i18next": "^25.3.2"
|
|
50
|
+
},
|
|
51
|
+
"scripts": {
|
|
52
|
+
"clean": "node -e \"fs.rmSync('dist',{recursive:true,force:true})\"",
|
|
53
|
+
"build": "npm run clean && node build-sdk.mjs",
|
|
54
|
+
"check": "node build-sdk.mjs"
|
|
55
|
+
},
|
|
56
|
+
"devDependencies": {
|
|
57
|
+
"esbuild": "^0.25.8",
|
|
58
|
+
"typescript": "^7.0.2"
|
|
59
|
+
}
|
|
60
|
+
}
|
package/src/actions.ts
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
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
|
+
|
|
9
|
+
import type { PluginHostEnv } from "./hostEnv.ts";
|
|
10
|
+
|
|
11
|
+
/** An i18n message key plus the English fallback the host uses when the key is missing. */
|
|
12
|
+
export type LocalizedText = {
|
|
13
|
+
key: string;
|
|
14
|
+
defaultValue: string;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
/** Keys the host deliberately permits for action shortcuts. */
|
|
18
|
+
export const Key = {
|
|
19
|
+
Enter: "Enter", Tab: "Tab", Space: "Space", Delete: "Delete", Backspace: "Backspace",
|
|
20
|
+
Escape: "Escape", Left: "Left", Right: "Right", Up: "Up", Down: "Down",
|
|
21
|
+
A: "A", B: "B", C: "C", D: "D", E: "E", F: "F", G: "G", H: "H", I: "I",
|
|
22
|
+
J: "J", K: "K", L: "L", M: "M", N: "N", O: "O", P: "P", Q: "Q", R: "R",
|
|
23
|
+
S: "S", T: "T", U: "U", V: "V", W: "W", X: "X", Y: "Y", Z: "Z",
|
|
24
|
+
D0: "D0", D1: "D1", D2: "D2", D3: "D3", D4: "D4",
|
|
25
|
+
D5: "D5", D6: "D6", D7: "D7", D8: "D8", D9: "D9",
|
|
26
|
+
F1: "F1", F2: "F2", F3: "F3", F4: "F4", F5: "F5", F6: "F6",
|
|
27
|
+
F7: "F7", F8: "F8", F9: "F9", F10: "F10", F11: "F11", F12: "F12",
|
|
28
|
+
} as const;
|
|
29
|
+
|
|
30
|
+
export type HotkeyKey = (typeof Key)[keyof typeof Key];
|
|
31
|
+
|
|
32
|
+
/** Permitted modifier combinations, e.g. `Modifiers.ControlShift`. */
|
|
33
|
+
export const Modifiers = {
|
|
34
|
+
None: 0,
|
|
35
|
+
Control: 1,
|
|
36
|
+
Alt: 2,
|
|
37
|
+
ControlAlt: 3,
|
|
38
|
+
Shift: 4,
|
|
39
|
+
ControlShift: 5,
|
|
40
|
+
AltShift: 6,
|
|
41
|
+
ControlAltShift: 7,
|
|
42
|
+
} as const;
|
|
43
|
+
|
|
44
|
+
export type HotkeyModifiers = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7;
|
|
45
|
+
|
|
46
|
+
export type Hotkey = {
|
|
47
|
+
key: HotkeyKey;
|
|
48
|
+
modifiers?: HotkeyModifiers;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/** Actions the host itself can carry out. Anything else belongs in `execute`. */
|
|
52
|
+
export const HostAction = {
|
|
53
|
+
Copy: "copy",
|
|
54
|
+
CopyAndPaste: "copyAndPaste",
|
|
55
|
+
AddClipboardHistory: "addClipboardHistory",
|
|
56
|
+
Execute: "execute",
|
|
57
|
+
OpenInExplorer: "openInExplorer",
|
|
58
|
+
OpenInBrowser: "openInBrowser",
|
|
59
|
+
OpenPlugin: "openPlugin",
|
|
60
|
+
Run: "run",
|
|
61
|
+
Kill: "kill",
|
|
62
|
+
} as const;
|
|
63
|
+
|
|
64
|
+
export type HostActionKind = (typeof HostAction)[keyof typeof HostAction];
|
|
65
|
+
|
|
66
|
+
/** Command spec for {@link HostAction.Run}. */
|
|
67
|
+
export type RunSpec = {
|
|
68
|
+
name?: string;
|
|
69
|
+
command: string;
|
|
70
|
+
args?: string;
|
|
71
|
+
workingDirectory?: string;
|
|
72
|
+
runAsAdmin?: boolean;
|
|
73
|
+
isBashScript?: boolean;
|
|
74
|
+
scripts?: string | string[];
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* What the host should do, with the parameters that kind actually needs. The discriminated union
|
|
79
|
+
* is the point: `{ kind: HostAction.Copy, path }` does not compile.
|
|
80
|
+
*/
|
|
81
|
+
export type HostActionRequest =
|
|
82
|
+
| { kind: typeof HostAction.Copy; text: string }
|
|
83
|
+
| { kind: typeof HostAction.CopyAndPaste; text: string }
|
|
84
|
+
| { kind: typeof HostAction.AddClipboardHistory; texts: string[] }
|
|
85
|
+
| { kind: typeof HostAction.Execute; path: string; args?: string; runAsAdmin?: boolean }
|
|
86
|
+
| { kind: typeof HostAction.OpenInExplorer; path: string }
|
|
87
|
+
| { kind: typeof HostAction.OpenInBrowser; url: string | string[] }
|
|
88
|
+
| { kind: typeof HostAction.OpenPlugin; pluginId: string }
|
|
89
|
+
| { kind: typeof HostAction.Run; command: RunSpec }
|
|
90
|
+
| { kind: typeof HostAction.Kill; pid: number };
|
|
91
|
+
|
|
92
|
+
/** Opens a web detail page. `page` defaults to the entry declared in plugin.json. */
|
|
93
|
+
export type DetailRequest = {
|
|
94
|
+
page?: string;
|
|
95
|
+
title?: string;
|
|
96
|
+
initialState?: unknown;
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* The result of running an action. Every field is optional and they combine: opening an IDE and
|
|
101
|
+
* closing the search window is `{ host: {...}, close: true }`.
|
|
102
|
+
*/
|
|
103
|
+
export type ActionOutcome = {
|
|
104
|
+
/** Run a host-side action (clipboard, process launch, browser, ...). */
|
|
105
|
+
host?: HostActionRequest;
|
|
106
|
+
/** Hand off to the detail page; arrives as `host.event.detailAction` with this payload. */
|
|
107
|
+
web?: { payload?: unknown };
|
|
108
|
+
/** Open a web detail page. */
|
|
109
|
+
detail?: DetailRequest;
|
|
110
|
+
/** Status bar text. */
|
|
111
|
+
message?: LocalizedText;
|
|
112
|
+
/** Close the search window afterwards. Defaults to false. */
|
|
113
|
+
close?: boolean;
|
|
114
|
+
/** Refresh the current search results afterwards. Defaults to false. */
|
|
115
|
+
refresh?: boolean;
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* What an action sees when it runs. `item` is the original object returned by `search()`,
|
|
120
|
+
* including fields the host never saw — the SDK keeps it so actions do not have to re-derive
|
|
121
|
+
* their data from the item id.
|
|
122
|
+
*/
|
|
123
|
+
export type ActionContext<TItem = unknown> = PluginHostEnv & {
|
|
124
|
+
actionId: string;
|
|
125
|
+
itemId: string;
|
|
126
|
+
query: string;
|
|
127
|
+
item?: TItem;
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* One registered action. `hotkey` is optional: without it the first registered action gets Enter
|
|
132
|
+
* and the rest are click-only, matching search result items.
|
|
133
|
+
*/
|
|
134
|
+
export type ActionDefinition<TItem = any> = {
|
|
135
|
+
id: string;
|
|
136
|
+
title: LocalizedText;
|
|
137
|
+
description?: LocalizedText;
|
|
138
|
+
hotkey?: Hotkey;
|
|
139
|
+
execute: (context: ActionContext<TItem>) => ActionOutcome | void | Promise<ActionOutcome | void>;
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
/** The registry shape sent to the host in the initialize response (no `execute`). */
|
|
143
|
+
export type ActionManifestEntry = {
|
|
144
|
+
id: string;
|
|
145
|
+
title: LocalizedText;
|
|
146
|
+
description?: LocalizedText;
|
|
147
|
+
hotkey?: Hotkey;
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
export function toActionManifest(definition: ActionDefinition): ActionManifestEntry {
|
|
151
|
+
const entry: ActionManifestEntry = {
|
|
152
|
+
id: definition.id,
|
|
153
|
+
title: definition.title,
|
|
154
|
+
};
|
|
155
|
+
if (definition.description) entry.description = definition.description;
|
|
156
|
+
if (definition.hotkey) entry.hotkey = definition.hotkey;
|
|
157
|
+
return entry;
|
|
158
|
+
}
|
package/src/bootstrap.ts
CHANGED
|
@@ -1,70 +1,159 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* v3 Node SDK bootstrap entry. Reads the bootstrap line from stdin (pipePath\ttoken), connects to
|
|
3
|
-
* the named pipe,
|
|
4
|
-
*
|
|
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
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
import
|
|
12
|
-
import {
|
|
13
|
-
import
|
|
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
|
-
|
|
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
|
+
|
|
11
|
+
import readline from "node:readline/promises";
|
|
12
|
+
import { randomBytes } from "node:crypto";
|
|
13
|
+
import { NodeTransport } from "./transport.ts";
|
|
14
|
+
import { HandlerRouter } from "./router.ts";
|
|
15
|
+
import {
|
|
16
|
+
type Envelope,
|
|
17
|
+
EndpointIds,
|
|
18
|
+
MessageKind,
|
|
19
|
+
ProtocolVersion,
|
|
20
|
+
Routes,
|
|
21
|
+
} from "./protocol.ts";
|
|
22
|
+
|
|
23
|
+
export interface PluginHandlers {
|
|
24
|
+
[route: string]: (payload: any, context: { sessionId: string }) => Promise<any> | any;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface PluginRuntime {
|
|
28
|
+
transport: NodeTransport;
|
|
29
|
+
router: HandlerRouter;
|
|
30
|
+
close(): Promise<void>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const SUPPORTED_VERSIONS = [ProtocolVersion];
|
|
34
|
+
|
|
35
|
+
/**
|
|
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.
|
|
38
|
+
*/
|
|
39
|
+
export async function runPlugin(handlers: PluginHandlers): Promise<PluginRuntime> {
|
|
40
|
+
const { pipePath, token } = await readBootstrapLine();
|
|
41
|
+
|
|
42
|
+
const transport = new NodeTransport();
|
|
43
|
+
await transport.connect(pipePath);
|
|
44
|
+
|
|
45
|
+
const identity = await completeHandshake(transport, token);
|
|
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
|
+
});
|
|
65
|
+
|
|
66
|
+
transport.onMessage((env) => {
|
|
67
|
+
if (env.route === Routes.Bus.Ping) lastPingAt = Date.now();
|
|
68
|
+
router.dispatch(env);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
for (const [route, handler] of Object.entries(handlers)) {
|
|
72
|
+
router.handle(route, handler);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return {
|
|
76
|
+
transport,
|
|
77
|
+
router,
|
|
78
|
+
close: async () => {
|
|
79
|
+
clearInterval(watchdog);
|
|
80
|
+
await transport.close();
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Reads line 1 of stdin: "<pipePath>\t<token>". */
|
|
86
|
+
async function readBootstrapLine(): Promise<{ pipePath: string; token: string }> {
|
|
87
|
+
const rl = readline.createInterface({ input: process.stdin, crlfDelay: Infinity });
|
|
88
|
+
try {
|
|
89
|
+
const line = await new Promise<string>((resolve, reject) => {
|
|
90
|
+
rl.once("line", (l) => resolve(l));
|
|
91
|
+
rl.once("close", () => reject(new Error("stdin closed before bootstrap line")));
|
|
92
|
+
});
|
|
93
|
+
const [pipePath, token] = line.split("\t");
|
|
94
|
+
if (!pipePath || !token) {
|
|
95
|
+
throw new Error(`malformed bootstrap line: ${JSON.stringify(line)}`);
|
|
96
|
+
}
|
|
97
|
+
return { pipePath, token };
|
|
98
|
+
} finally {
|
|
99
|
+
rl.close();
|
|
100
|
+
}
|
|
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/dev.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Development-time helpers for MyTools plugins.
|
|
3
|
+
*
|
|
4
|
+
* This entry point is intended for build and watch scripts, not plugin runtime code.
|
|
5
|
+
*/
|
|
6
|
+
import { requestDevelopmentPluginRefreshWithOptions } from "./developmentRefresh.ts";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Notifies a running MyTools instance that a development plugin was rebuilt.
|
|
10
|
+
* The request is retried once after a short delay when the pipe is not yet available.
|
|
11
|
+
*/
|
|
12
|
+
export function requestDevelopmentPluginRefresh(pluginId: string): Promise<void> {
|
|
13
|
+
return requestDevelopmentPluginRefreshWithOptions(pluginId);
|
|
14
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { createConnection } from "node:net";
|
|
2
|
+
|
|
3
|
+
export const DEVELOPMENT_REFRESH_PIPE_PATH = "\\\\.\\pipe\\MyTools.DevelopmentPlugins.Refresh";
|
|
4
|
+
|
|
5
|
+
const DEFAULT_RETRY_DELAY_MS = 250;
|
|
6
|
+
const DEFAULT_MAX_ATTEMPTS = 2;
|
|
7
|
+
const DEFAULT_REQUEST_TIMEOUT_MS = 2_000;
|
|
8
|
+
const VALID_PLUGIN_ID = /^[a-z0-9](?:[a-z0-9.-]{0,62}[a-z0-9])?$/;
|
|
9
|
+
|
|
10
|
+
export type DevelopmentRefreshRequestOptions = {
|
|
11
|
+
pipePath?: string;
|
|
12
|
+
retryDelayMs?: number;
|
|
13
|
+
maxAttempts?: number;
|
|
14
|
+
requestTimeoutMs?: number;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
/** Internal implementation with injectable timing and endpoint for protocol tests. */
|
|
18
|
+
export async function requestDevelopmentPluginRefreshWithOptions(
|
|
19
|
+
pluginId: string,
|
|
20
|
+
options: DevelopmentRefreshRequestOptions = {},
|
|
21
|
+
): Promise<void> {
|
|
22
|
+
if (!VALID_PLUGIN_ID.test(pluginId)) {
|
|
23
|
+
throw new TypeError(`Invalid MyTools plugin ID: ${pluginId}`);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const pipePath = options.pipePath ?? DEVELOPMENT_REFRESH_PIPE_PATH;
|
|
27
|
+
const retryDelayMs = options.retryDelayMs ?? DEFAULT_RETRY_DELAY_MS;
|
|
28
|
+
const maxAttempts = options.maxAttempts ?? DEFAULT_MAX_ATTEMPTS;
|
|
29
|
+
const requestTimeoutMs = options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
|
|
30
|
+
if (!Number.isInteger(maxAttempts) || maxAttempts < 1) {
|
|
31
|
+
throw new RangeError("maxAttempts must be a positive integer");
|
|
32
|
+
}
|
|
33
|
+
if (!Number.isFinite(retryDelayMs) || retryDelayMs < 0) {
|
|
34
|
+
throw new RangeError("retryDelayMs must be a non-negative number");
|
|
35
|
+
}
|
|
36
|
+
if (!Number.isFinite(requestTimeoutMs) || requestTimeoutMs <= 0) {
|
|
37
|
+
throw new RangeError("requestTimeoutMs must be a positive number");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
let lastError: unknown;
|
|
41
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
|
42
|
+
try {
|
|
43
|
+
await sendRefreshRequest(pipePath, pluginId, requestTimeoutMs);
|
|
44
|
+
return;
|
|
45
|
+
} catch (error) {
|
|
46
|
+
lastError = error;
|
|
47
|
+
if (attempt < maxAttempts) {
|
|
48
|
+
await delay(retryDelayMs);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
throw new Error(
|
|
54
|
+
`Failed to request MyTools refresh for ${pluginId} after ${maxAttempts} attempts`,
|
|
55
|
+
{ cause: lastError },
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function sendRefreshRequest(
|
|
60
|
+
pipePath: string,
|
|
61
|
+
pluginId: string,
|
|
62
|
+
requestTimeoutMs: number,
|
|
63
|
+
): Promise<void> {
|
|
64
|
+
return new Promise((resolve, reject) => {
|
|
65
|
+
const socket = createConnection(pipePath);
|
|
66
|
+
let settled = false;
|
|
67
|
+
let timeout: ReturnType<typeof setTimeout> | undefined;
|
|
68
|
+
|
|
69
|
+
const settle = (error?: Error) => {
|
|
70
|
+
if (settled) return;
|
|
71
|
+
settled = true;
|
|
72
|
+
if (timeout) clearTimeout(timeout);
|
|
73
|
+
if (error) reject(error);
|
|
74
|
+
else resolve();
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
socket.once("connect", () => {
|
|
78
|
+
socket.end(`${pluginId}\n`, () => {
|
|
79
|
+
settle();
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
socket.once("error", (error) => {
|
|
83
|
+
socket.destroy();
|
|
84
|
+
settle(error);
|
|
85
|
+
});
|
|
86
|
+
timeout = setTimeout(() => {
|
|
87
|
+
socket.destroy();
|
|
88
|
+
settle(new Error(`Development refresh request timed out after ${requestTimeoutMs}ms`));
|
|
89
|
+
}, requestTimeoutMs);
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function delay(milliseconds: number): Promise<void> {
|
|
94
|
+
return new Promise((resolve) => setTimeout(resolve, milliseconds));
|
|
95
|
+
}
|
package/src/hostEnv.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/** Environment the host stamps onto every plugin.call payload. */
|
|
2
|
+
|
|
3
|
+
export type PluginTheme = "light" | "dark";
|
|
4
|
+
|
|
5
|
+
export type PluginHostEnv = {
|
|
6
|
+
locale: string;
|
|
7
|
+
fallbackLocale: string;
|
|
8
|
+
theme: PluginTheme;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export function asTheme(value: unknown): PluginTheme {
|
|
12
|
+
return value === "light" ? "light" : "dark";
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function asHostEnv(payload: any): PluginHostEnv {
|
|
16
|
+
return {
|
|
17
|
+
locale: typeof payload?.locale === "string" ? payload.locale : "en-US",
|
|
18
|
+
fallbackLocale: typeof payload?.fallbackLocale === "string" ? payload.fallbackLocale : "en-US",
|
|
19
|
+
theme: asTheme(payload?.theme),
|
|
20
|
+
};
|
|
21
|
+
}
|