@qping/plugin-bus 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,484 @@
1
+ var __defProp = Object.defineProperty;
2
+ var __getOwnPropNames = Object.getOwnPropertyNames;
3
+ var __esm = (fn, res) => function __init() {
4
+ return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
5
+ };
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+
11
+ // src/framing.ts
12
+ var framing_exports = {};
13
+ __export(framing_exports, {
14
+ FrameDecoder: () => FrameDecoder,
15
+ MAX_FRAME_BYTES: () => MAX_FRAME_BYTES,
16
+ PREFIX_BYTES: () => PREFIX_BYTES,
17
+ encodeFrame: () => encodeFrame,
18
+ encodeFrameString: () => encodeFrameString
19
+ });
20
+ function encodeFrame(payload) {
21
+ const length = payload.length;
22
+ const frame = Buffer.alloc(PREFIX_BYTES + length);
23
+ frame[0] = length & 255;
24
+ frame[1] = length >> 8 & 255;
25
+ frame[2] = length >> 16 & 255;
26
+ frame[3] = length >> 24 & 255;
27
+ payload.copy(frame, PREFIX_BYTES);
28
+ return frame;
29
+ }
30
+ function encodeFrameString(json) {
31
+ return encodeFrame(Buffer.from(json, "utf8"));
32
+ }
33
+ var MAX_FRAME_BYTES, PREFIX_BYTES, FrameDecoder;
34
+ var init_framing = __esm({
35
+ "src/framing.ts"() {
36
+ MAX_FRAME_BYTES = 4 * 1024 * 1024;
37
+ PREFIX_BYTES = 4;
38
+ FrameDecoder = class {
39
+ prefixBuf = Buffer.alloc(PREFIX_BYTES);
40
+ prefixFilled = 0;
41
+ payload = null;
42
+ payloadFilled = 0;
43
+ payloadLength = 0;
44
+ fatal = false;
45
+ pending = Buffer.alloc(0);
46
+ feed(chunk) {
47
+ const empty = { hasFrame: false, payload: Buffer.alloc(0), isFatal: false };
48
+ if (this.fatal) {
49
+ return { hasFrame: false, payload: Buffer.alloc(0), isFatal: true };
50
+ }
51
+ let current;
52
+ if (this.pending.length > 0) {
53
+ current = Buffer.concat([this.pending, chunk]);
54
+ this.pending = Buffer.alloc(0);
55
+ } else {
56
+ current = chunk;
57
+ }
58
+ let offset = 0;
59
+ while (offset < current.length) {
60
+ if (this.payload === null) {
61
+ const need = PREFIX_BYTES - this.prefixFilled;
62
+ const take = Math.min(need, current.length - offset);
63
+ current.copy(this.prefixBuf, this.prefixFilled, offset, offset + take);
64
+ this.prefixFilled += take;
65
+ offset += take;
66
+ if (this.prefixFilled < PREFIX_BYTES) {
67
+ return empty;
68
+ }
69
+ this.payloadLength = this.prefixBuf[0] | this.prefixBuf[1] << 8 | this.prefixBuf[2] << 16 | this.prefixBuf[3] << 24;
70
+ if (this.payloadLength < 0 || this.payloadLength > MAX_FRAME_BYTES) {
71
+ this.fatal = true;
72
+ return { hasFrame: false, payload: Buffer.alloc(0), isFatal: true };
73
+ }
74
+ if (this.payloadLength === 0) {
75
+ this.reset();
76
+ this.bufferLeftover(current, offset);
77
+ return { hasFrame: true, payload: Buffer.alloc(0), isFatal: false };
78
+ }
79
+ this.payload = Buffer.alloc(this.payloadLength);
80
+ this.payloadFilled = 0;
81
+ }
82
+ const payloadNeed = this.payloadLength - this.payloadFilled;
83
+ const payloadTake = Math.min(payloadNeed, current.length - offset);
84
+ current.copy(this.payload, this.payloadFilled, offset, offset + payloadTake);
85
+ this.payloadFilled += payloadTake;
86
+ offset += payloadTake;
87
+ if (this.payloadFilled >= this.payloadLength) {
88
+ const out = this.payload;
89
+ this.reset();
90
+ this.bufferLeftover(current, offset);
91
+ return { hasFrame: true, payload: out, isFatal: false };
92
+ }
93
+ }
94
+ return empty;
95
+ }
96
+ bufferLeftover(src, offset) {
97
+ if (offset < src.length) {
98
+ this.pending = src.subarray(offset);
99
+ }
100
+ }
101
+ reset() {
102
+ this.prefixFilled = 0;
103
+ this.payload = null;
104
+ this.payloadFilled = 0;
105
+ this.payloadLength = 0;
106
+ }
107
+ };
108
+ }
109
+ });
110
+
111
+ // src/bootstrap.ts
112
+ import readline from "node:readline/promises";
113
+
114
+ // src/transport.ts
115
+ init_framing();
116
+ import { connect as netConnect } from "node:net";
117
+
118
+ // src/protocol.ts
119
+ function canonicalStringify(value) {
120
+ return JSON.stringify(stripNulls(value));
121
+ }
122
+ function stripNulls(value) {
123
+ if (value === null || value === void 0) return void 0;
124
+ if (Array.isArray(value)) return value.map(stripNulls);
125
+ if (typeof value === "object") {
126
+ const out = {};
127
+ for (const [k, v] of Object.entries(value)) {
128
+ const stripped = stripNulls(v);
129
+ if (stripped !== void 0) out[k] = stripped;
130
+ }
131
+ return out;
132
+ }
133
+ return value;
134
+ }
135
+
136
+ // src/transport.ts
137
+ var NodeTransport = class {
138
+ socket = null;
139
+ messageHandlers = /* @__PURE__ */ new Set();
140
+ disconnectHandlers = /* @__PURE__ */ new Set();
141
+ closed = false;
142
+ onMessage(handler) {
143
+ this.messageHandlers.add(handler);
144
+ }
145
+ onDisconnect(handler) {
146
+ this.disconnectHandlers.add(handler);
147
+ }
148
+ get isConnected() {
149
+ return this.socket !== null && !this.closed;
150
+ }
151
+ /** Connects to a Windows named pipe (\\.\pipe\<name>). */
152
+ async connect(pipePath) {
153
+ this.socket = netConnect(pipePath);
154
+ await new Promise((resolve, reject) => {
155
+ this.socket.once("connect", () => resolve());
156
+ this.socket.once("error", (err) => reject(err));
157
+ });
158
+ const { FrameDecoder: FrameDecoder2 } = await Promise.resolve().then(() => (init_framing(), framing_exports));
159
+ const decoder = new FrameDecoder2();
160
+ this.socket.on("data", (chunk) => {
161
+ let result = decoder.feed(chunk);
162
+ if (result.isFatal) {
163
+ this.handleDisconnect();
164
+ return;
165
+ }
166
+ while (result.hasFrame) {
167
+ try {
168
+ const env = JSON.parse(result.payload.toString("utf8"));
169
+ for (const h of this.messageHandlers) h(env);
170
+ } catch {
171
+ this.handleDisconnect();
172
+ return;
173
+ }
174
+ result = decoder.feed(Buffer.alloc(0));
175
+ if (result.isFatal) {
176
+ this.handleDisconnect();
177
+ return;
178
+ }
179
+ }
180
+ });
181
+ this.socket.on("close", () => this.handleDisconnect());
182
+ this.socket.on("error", () => this.handleDisconnect());
183
+ }
184
+ /** Serializes an envelope to a length-prefixed frame and writes it. */
185
+ send(env) {
186
+ if (!this.socket || this.closed) {
187
+ throw new Error("transport is not connected");
188
+ }
189
+ this.socket.write(encodeFrameString(canonicalStringify(env)));
190
+ }
191
+ async close() {
192
+ this.closed = true;
193
+ if (this.socket) {
194
+ this.socket.end();
195
+ await new Promise((resolve) => {
196
+ if (this.socket.destroyed) return resolve();
197
+ this.socket.once("close", () => resolve());
198
+ });
199
+ this.socket = null;
200
+ }
201
+ }
202
+ handleDisconnect() {
203
+ if (this.closed) return;
204
+ this.closed = true;
205
+ for (const h of this.disconnectHandlers) h();
206
+ }
207
+ };
208
+
209
+ // src/router.ts
210
+ import { randomBytes } from "node:crypto";
211
+ var HandlerRouter = class {
212
+ handlers = /* @__PURE__ */ new Map();
213
+ pendingHostCalls = /* @__PURE__ */ new Map();
214
+ pluginId = "p";
215
+ entryId = "e";
216
+ sessionId = "s";
217
+ endpointId = "node-main";
218
+ /** Injected transport send fn; tests can override `router.send` directly. */
219
+ send;
220
+ constructor(deps) {
221
+ this.send = deps.send;
222
+ }
223
+ /** Sets the bound identity stamped on outbound messages (after handshake). */
224
+ setIdentity(ids) {
225
+ this.pluginId = ids.pluginId;
226
+ this.entryId = ids.entryId;
227
+ this.sessionId = ids.sessionId;
228
+ this.endpointId = ids.endpointId;
229
+ }
230
+ handle(route, handler) {
231
+ this.handlers.set(route, handler);
232
+ }
233
+ /** Dispatches an inbound request/response. Returns once handled. */
234
+ async dispatch(env) {
235
+ if (env.kind === "response") {
236
+ this.handleHostResponse(env);
237
+ return;
238
+ }
239
+ if (env.kind !== "request") return;
240
+ if (env.route === "bus.ping") {
241
+ this.send(this.responseFor(env, { ok: true }));
242
+ return;
243
+ }
244
+ const handler = this.handlers.get(env.route);
245
+ if (!handler) {
246
+ this.send(this.errorResponseFor(env, "RouteNotFound", `route '${env.route}' has no handler`));
247
+ return;
248
+ }
249
+ try {
250
+ const result = await handler(env.payload);
251
+ this.send(this.responseFor(env, result ?? {}));
252
+ } catch (err) {
253
+ const message = err instanceof Error ? err.message : String(err);
254
+ this.send(this.errorResponseFor(env, "InternalError", message));
255
+ }
256
+ }
257
+ /** Calls a host.call.* capability and resolves with the response payload. */
258
+ callHost(route, payload, timeoutMs = 3e4) {
259
+ return new Promise((resolve, reject) => {
260
+ const id = randomBytesHex();
261
+ const req = {
262
+ version: "3.0",
263
+ id,
264
+ traceId: id,
265
+ sessionId: this.sessionId,
266
+ pluginId: this.pluginId,
267
+ entryId: this.entryId,
268
+ endpointId: this.endpointId,
269
+ kind: "request",
270
+ route,
271
+ timeoutMs,
272
+ payload
273
+ };
274
+ const pending = { resolve, reject, route };
275
+ this.pendingHostCalls.set(id, pending);
276
+ const timer = setTimeout(() => {
277
+ if (this.pendingHostCalls.has(id)) {
278
+ this.pendingHostCalls.delete(id);
279
+ reject(new Error(`host call ${route} timed out after ${timeoutMs}ms`));
280
+ }
281
+ }, timeoutMs);
282
+ const origResolve = pending.resolve;
283
+ const origReject = pending.reject;
284
+ pending.resolve = (v) => {
285
+ clearTimeout(timer);
286
+ origResolve(v);
287
+ };
288
+ pending.reject = (e) => {
289
+ clearTimeout(timer);
290
+ origReject(e);
291
+ };
292
+ this.send(req);
293
+ });
294
+ }
295
+ handleHostResponse(env) {
296
+ if (!env.correlationId) return;
297
+ const pending = this.pendingHostCalls.get(env.correlationId);
298
+ if (!pending) return;
299
+ this.pendingHostCalls.delete(env.correlationId);
300
+ if (env.error) {
301
+ pending.reject(new Error(`${env.error.code}: ${env.error.message}`));
302
+ } else {
303
+ pending.resolve(env.payload);
304
+ }
305
+ }
306
+ responseFor(req, payload) {
307
+ return {
308
+ version: "3.0",
309
+ id: randomBytesHex(),
310
+ correlationId: req.id,
311
+ traceId: req.traceId,
312
+ sessionId: req.sessionId,
313
+ pluginId: req.pluginId,
314
+ entryId: req.entryId,
315
+ endpointId: this.endpointId,
316
+ kind: "response",
317
+ route: req.route,
318
+ payload
319
+ };
320
+ }
321
+ errorResponseFor(req, code, message) {
322
+ return {
323
+ ...this.responseFor(req, null),
324
+ payload: void 0,
325
+ error: { code, message, retryable: false }
326
+ };
327
+ }
328
+ };
329
+ function randomBytesHex() {
330
+ return randomBytes(16).toString("hex");
331
+ }
332
+
333
+ // src/bootstrap.ts
334
+ async function runPlugin(handlers) {
335
+ const { pipePath, token } = await readBootstrapLine();
336
+ const transport = new NodeTransport();
337
+ await transport.connect(pipePath);
338
+ const router = new HandlerRouter({ send: (env) => transport.send(env) });
339
+ transport.onMessage((env) => {
340
+ router.dispatch(env);
341
+ });
342
+ for (const [route, handler] of Object.entries(handlers)) {
343
+ router.handle(route, handler);
344
+ }
345
+ return {
346
+ transport,
347
+ router,
348
+ close: () => transport.close()
349
+ };
350
+ }
351
+ async function readBootstrapLine() {
352
+ const rl = readline.createInterface({ input: process.stdin, crlfDelay: Infinity });
353
+ try {
354
+ const line = await new Promise((resolve, reject) => {
355
+ rl.once("line", (l) => resolve(l));
356
+ rl.once("close", () => reject(new Error("stdin closed before bootstrap line")));
357
+ });
358
+ const [pipePath, token] = line.split(" ");
359
+ if (!pipePath || !token) {
360
+ throw new Error(`malformed bootstrap line: ${JSON.stringify(line)}`);
361
+ }
362
+ return { pipePath, token };
363
+ } finally {
364
+ rl.close();
365
+ }
366
+ }
367
+
368
+ // src/server.ts
369
+ var NodeTool = class {
370
+ #handlers = /* @__PURE__ */ new Map();
371
+ #searchHandler = null;
372
+ #actionHandler = null;
373
+ #initializeHandler = null;
374
+ #runtime = null;
375
+ initialize(handler) {
376
+ this.#initializeHandler = handler;
377
+ return this;
378
+ }
379
+ search(handler) {
380
+ this.#searchHandler = handler;
381
+ return this;
382
+ }
383
+ action(handler) {
384
+ this.#actionHandler = handler;
385
+ return this;
386
+ }
387
+ handle(action, handler) {
388
+ if (!action || typeof action !== "string") {
389
+ throw new Error("tool.handle requires an action name.");
390
+ }
391
+ if (typeof handler !== "function") {
392
+ throw new Error("tool.handle requires a handler.");
393
+ }
394
+ this.#handlers.set(action, handler);
395
+ return this;
396
+ }
397
+ /** Publishes a plugin.event.<subjectId> event to all webviews in the session. */
398
+ publish(subjectId, payload = {}) {
399
+ if (!this.#runtime) throw new Error("tool not started");
400
+ const route = subjectId.startsWith("plugin.event.") ? subjectId : `plugin.event.${subjectId}`;
401
+ this.#runtime.transport.send({
402
+ version: "3.0",
403
+ id: crypto.randomUUID().replace(/-/g, "").slice(0, 32),
404
+ traceId: crypto.randomUUID().replace(/-/g, "").slice(0, 32),
405
+ sessionId: "",
406
+ pluginId: "",
407
+ entryId: "",
408
+ endpointId: "node-main",
409
+ kind: "event",
410
+ route,
411
+ payload
412
+ });
413
+ }
414
+ /** Calls a host.call.<method> capability and awaits the response. */
415
+ hostCall(method, params = {}) {
416
+ if (!this.#runtime) return Promise.reject(new Error("tool not started"));
417
+ return this.#runtime.router.callHost(`host.call.${method}`, params);
418
+ }
419
+ /** Connects to the host pipe and begins dispatching. Must be called last. */
420
+ async start() {
421
+ const routes = this.buildRoutes();
422
+ this.#runtime = await runPlugin(routes);
423
+ }
424
+ /**
425
+ * Builds the v3 route map from the fluent registrations. Exposed for unit testing the mapping
426
+ * without connecting a pipe.
427
+ */
428
+ buildRoutes() {
429
+ const routes = {};
430
+ if (this.#initializeHandler) {
431
+ routes["plugin.call.initialize"] = (p) => this.#initializeHandler(p);
432
+ }
433
+ if (this.#searchHandler) {
434
+ routes["plugin.call.search"] = (p) => this.#searchHandler(p);
435
+ }
436
+ if (this.#actionHandler) {
437
+ routes["plugin.call.invokeAction"] = (p) => {
438
+ return this.#actionHandler({ ...p, itemId: p.itemId, query: p.query });
439
+ };
440
+ }
441
+ routes["plugin.call.detailCall"] = async (p) => {
442
+ const action = p?.action ?? "";
443
+ const handler = this.#handlers.get(action);
444
+ if (!handler) {
445
+ throw new Error(`no handler registered for action '${action}'`);
446
+ }
447
+ const ctx = extractContext(p);
448
+ const result = await handler(p?.payload ?? {}, ctx);
449
+ return { result: result ?? {} };
450
+ };
451
+ routes["plugin.call.detailEvent"] = async (p) => {
452
+ return { state: p?.payload ?? {} };
453
+ };
454
+ for (const [action, handler] of this.#handlers) {
455
+ const route = `plugin.call.${action}`;
456
+ if (!routes[route]) {
457
+ routes[route] = async (p) => {
458
+ const ctx = extractContext(p);
459
+ return handler(p, ctx);
460
+ };
461
+ }
462
+ }
463
+ return routes;
464
+ }
465
+ async stop() {
466
+ if (this.#runtime) await this.#runtime.close();
467
+ }
468
+ };
469
+ function extractContext(p) {
470
+ return {
471
+ action: p?.action ?? "",
472
+ itemId: p?.itemId ?? "",
473
+ query: p?.query ?? "",
474
+ locale: p?.locale ?? "en-US",
475
+ fallbackLocale: p?.fallbackLocale ?? "en-US"
476
+ };
477
+ }
478
+ function createTool() {
479
+ return new NodeTool();
480
+ }
481
+ export {
482
+ NodeTool,
483
+ createTool
484
+ };
package/package.json ADDED
@@ -0,0 +1,36 @@
1
+ {
2
+ "name": "@qping/plugin-bus",
3
+ "version": "0.1.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
+ "./server": {
12
+ "types": "./dist/server.d.mts",
13
+ "import": "./dist/server.mjs",
14
+ "default": "./dist/server.mjs"
15
+ },
16
+ "./protocol": {
17
+ "types": "./dist/protocol.d.mts",
18
+ "import": "./dist/protocol.mjs",
19
+ "default": "./dist/protocol.mjs"
20
+ },
21
+ "./bootstrap": {
22
+ "types": "./dist/bootstrap.d.mts",
23
+ "import": "./dist/bootstrap.mjs",
24
+ "default": "./dist/bootstrap.mjs"
25
+ },
26
+ "./package.json": "./package.json"
27
+ },
28
+ "scripts": {
29
+ "clean": "node -e \"fs.rmSync('dist',{recursive:true,force:true})\"",
30
+ "build": "npm run clean && node build-sdk.mjs",
31
+ "check": "node build-sdk.mjs"
32
+ },
33
+ "devDependencies": {
34
+ "esbuild": "^0.25.8"
35
+ }
36
+ }
@@ -0,0 +1,70 @@
1
+ /**
2
+ * v3 Node SDK bootstrap entry. Reads the bootstrap line from stdin (pipePath\ttoken), connects to
3
+ * the named pipe, and starts a HandlerRouter wired to the transport. Plugin authors call
4
+ * `runPlugin(...)` passing their route handlers; this module owns the connection lifecycle.
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
+ import readline from "node:readline/promises";
11
+ import { NodeTransport } from "./transport.ts";
12
+ import { HandlerRouter } from "./router.ts";
13
+ import type { Envelope } from "./protocol.ts";
14
+
15
+ export interface PluginHandlers {
16
+ [route: string]: (payload: any) => Promise<any> | any;
17
+ }
18
+
19
+ export interface PluginRuntime {
20
+ transport: NodeTransport;
21
+ router: HandlerRouter;
22
+ close(): Promise<void>;
23
+ }
24
+
25
+ /**
26
+ * Connects to the host pipe (reading the bootstrap line from stdin) and returns a runtime whose
27
+ * router dispatches inbound plugin.call.* requests to the given handlers. The caller may also use
28
+ * runtime.router.callHost(...) to invoke host.call.* capabilities.
29
+ */
30
+ export async function runPlugin(handlers: PluginHandlers): Promise<PluginRuntime> {
31
+ const { pipePath, token } = await readBootstrapLine();
32
+
33
+ const transport = new NodeTransport();
34
+ await transport.connect(pipePath);
35
+
36
+ const router = new HandlerRouter({ send: (env: Envelope) => transport.send(env) });
37
+
38
+ // Inbound envelopes from the host arrive on the transport.
39
+ transport.onMessage((env) => {
40
+ router.dispatch(env);
41
+ });
42
+
43
+ for (const [route, handler] of Object.entries(handlers)) {
44
+ router.handle(route, handler);
45
+ }
46
+
47
+ return {
48
+ transport,
49
+ router,
50
+ close: () => transport.close(),
51
+ };
52
+ }
53
+
54
+ /** Reads line 1 of stdin: "<pipePath>\t<token>". */
55
+ async function readBootstrapLine(): Promise<{ pipePath: string; token: string }> {
56
+ const rl = readline.createInterface({ input: process.stdin, crlfDelay: Infinity });
57
+ try {
58
+ const line = await new Promise<string>((resolve, reject) => {
59
+ rl.once("line", (l) => resolve(l));
60
+ rl.once("close", () => reject(new Error("stdin closed before bootstrap line")));
61
+ });
62
+ const [pipePath, token] = line.split("\t");
63
+ if (!pipePath || !token) {
64
+ throw new Error(`malformed bootstrap line: ${JSON.stringify(line)}`);
65
+ }
66
+ return { pipePath, token };
67
+ } finally {
68
+ rl.close();
69
+ }
70
+ }