@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.
package/src/framing.ts ADDED
@@ -0,0 +1,130 @@
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
+
10
+ export const MAX_FRAME_BYTES = 4 * 1024 * 1024;
11
+ export const PREFIX_BYTES = 4;
12
+
13
+ /** Encodes a raw payload buffer into a length-prefixed frame. */
14
+ export function encodeFrame(payload: Buffer): Buffer {
15
+ const length = payload.length;
16
+ const frame = Buffer.alloc(PREFIX_BYTES + length);
17
+ frame[0] = length & 0xff;
18
+ frame[1] = (length >> 8) & 0xff;
19
+ frame[2] = (length >> 16) & 0xff;
20
+ frame[3] = (length >> 24) & 0xff;
21
+ payload.copy(frame, PREFIX_BYTES);
22
+ return frame;
23
+ }
24
+
25
+ /** Encodes a UTF-8 JSON string into a length-prefixed frame. */
26
+ export function encodeFrameString(json: string): Buffer {
27
+ return encodeFrame(Buffer.from(json, "utf8"));
28
+ }
29
+
30
+ export interface FrameFeedResult {
31
+ hasFrame: boolean;
32
+ payload: Buffer;
33
+ isFatal: boolean;
34
+ }
35
+
36
+ /**
37
+ * Incremental length-prefixed frame decoder. Feed byte chunks (fragmented/sticky/partial) and get
38
+ * back one complete payload at a time. Leftover bytes from a chunk that contained more than one
39
+ * frame are buffered internally and surfaced by subsequent feeds (including an empty buffer).
40
+ */
41
+ export class FrameDecoder {
42
+ private prefixBuf = Buffer.alloc(PREFIX_BYTES);
43
+ private prefixFilled = 0;
44
+ private payload: Buffer | null = null;
45
+ private payloadFilled = 0;
46
+ private payloadLength = 0;
47
+ private fatal = false;
48
+ private pending: Buffer = Buffer.alloc(0);
49
+
50
+ feed(chunk: Buffer): FrameFeedResult {
51
+ const empty = { hasFrame: false, payload: Buffer.alloc(0), isFatal: false };
52
+ if (this.fatal) {
53
+ return { hasFrame: false, payload: Buffer.alloc(0), isFatal: true };
54
+ }
55
+
56
+ // Merge pending leftover with the new chunk into the working buffer.
57
+ let current: Buffer;
58
+ if (this.pending.length > 0) {
59
+ current = Buffer.concat([this.pending, chunk]);
60
+ this.pending = Buffer.alloc(0);
61
+ } else {
62
+ current = chunk;
63
+ }
64
+
65
+ let offset = 0;
66
+ while (offset < current.length) {
67
+ // Phase 1: accumulate the 4-byte length prefix.
68
+ if (this.payload === null) {
69
+ const need = PREFIX_BYTES - this.prefixFilled;
70
+ const take = Math.min(need, current.length - offset);
71
+ current.copy(this.prefixBuf, this.prefixFilled, offset, offset + take);
72
+ this.prefixFilled += take;
73
+ offset += take;
74
+
75
+ if (this.prefixFilled < PREFIX_BYTES) {
76
+ return empty; // still waiting for the full prefix
77
+ }
78
+
79
+ this.payloadLength =
80
+ this.prefixBuf[0] |
81
+ (this.prefixBuf[1] << 8) |
82
+ (this.prefixBuf[2] << 16) |
83
+ (this.prefixBuf[3] << 24);
84
+
85
+ if (this.payloadLength < 0 || this.payloadLength > MAX_FRAME_BYTES) {
86
+ this.fatal = true;
87
+ return { hasFrame: false, payload: Buffer.alloc(0), isFatal: true };
88
+ }
89
+ if (this.payloadLength === 0) {
90
+ this.reset();
91
+ // Buffer any leftover and return the empty frame.
92
+ this.bufferLeftover(current, offset);
93
+ return { hasFrame: true, payload: Buffer.alloc(0), isFatal: false };
94
+ }
95
+
96
+ this.payload = Buffer.alloc(this.payloadLength);
97
+ this.payloadFilled = 0;
98
+ }
99
+
100
+ // Phase 2: accumulate the payload.
101
+ const payloadNeed = this.payloadLength - this.payloadFilled;
102
+ const payloadTake = Math.min(payloadNeed, current.length - offset);
103
+ current.copy(this.payload!, this.payloadFilled, offset, offset + payloadTake);
104
+ this.payloadFilled += payloadTake;
105
+ offset += payloadTake;
106
+
107
+ if (this.payloadFilled >= this.payloadLength) {
108
+ const out = this.payload!;
109
+ this.reset();
110
+ this.bufferLeftover(current, offset);
111
+ return { hasFrame: true, payload: out, isFatal: false };
112
+ }
113
+ }
114
+
115
+ return empty;
116
+ }
117
+
118
+ private bufferLeftover(src: Buffer, offset: number): void {
119
+ if (offset < src.length) {
120
+ this.pending = src.subarray(offset);
121
+ }
122
+ }
123
+
124
+ private reset(): void {
125
+ this.prefixFilled = 0;
126
+ this.payload = null;
127
+ this.payloadFilled = 0;
128
+ this.payloadLength = 0;
129
+ }
130
+ }
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Hand-written TypeScript protocol types for the plugin message bus v3. These MUST stay
3
+ * byte-for-byte aligned with the C# types in MyTools.Protocol (see the canonical fixtures in
4
+ * MyTools.Protocol.Test/Fixtures/*.json). The drift-prevention self-check
5
+ * (fixtures-selfcheck.mjs) encodes/decodes those fixtures through these types.
6
+ *
7
+ * Field names are camelCase on the wire (System.Text.Json camelCase policy on the C# side).
8
+ * Null fields are omitted on the wire (WhenWritingNull).
9
+ */
10
+
11
+ export type MessageKind = "request" | "response" | "event";
12
+
13
+ export type ErrorCode =
14
+ | "ProtocolMismatch"
15
+ | "HandshakeFailed"
16
+ | "CapabilityNotDeclared"
17
+ | "CapabilityDenied"
18
+ | "InvalidPayload"
19
+ | "MessageTooLarge"
20
+ | "RouteNotFound"
21
+ | "RequestTimeout"
22
+ | "TooManyRequests"
23
+ | "TransportDisconnected"
24
+ | "PluginUnavailable"
25
+ | "InternalError"
26
+ | "Cancelled"
27
+ | "RateLimited";
28
+
29
+ export interface BusError {
30
+ code: ErrorCode;
31
+ message: string;
32
+ retryable: boolean;
33
+ details?: unknown;
34
+ }
35
+
36
+ /**
37
+ * The frozen Phase-1 envelope. All fields except correlationId/timeoutMs/error/payload are
38
+ * required; the optional ones are omitted on the wire when null.
39
+ */
40
+ export interface Envelope {
41
+ version: string; // e.g. "3.0"
42
+ id: string;
43
+ correlationId?: string | null;
44
+ traceId: string;
45
+ sessionId: string;
46
+ pluginId: string;
47
+ entryId: string;
48
+ endpointId: string;
49
+ kind: MessageKind;
50
+ route: string;
51
+ timeoutMs?: number | null;
52
+ payload?: unknown;
53
+ error?: BusError | null;
54
+ }
55
+
56
+ /** Omit null/undefined-valued keys to match the C# WhenWritingNull behavior. */
57
+ export function canonicalStringify(value: unknown): string {
58
+ return JSON.stringify(stripNulls(value));
59
+ }
60
+
61
+ function stripNulls(value: unknown): unknown {
62
+ if (value === null || value === undefined) return undefined;
63
+ if (Array.isArray(value)) return value.map(stripNulls);
64
+ if (typeof value === "object") {
65
+ const out: Record<string, unknown> = {};
66
+ for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
67
+ const stripped = stripNulls(v);
68
+ if (stripped !== undefined) out[k] = stripped;
69
+ }
70
+ return out;
71
+ }
72
+ return value;
73
+ }
74
+
75
+ /** Parse + re-canonicalize, returning the canonical JSON string (stable key order via JSON.stringify). */
76
+ export function canonicalize(json: string): string {
77
+ return canonicalStringify(JSON.parse(json));
78
+ }
package/src/router.ts ADDED
@@ -0,0 +1,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 { randomBytes } from "node:crypto";
8
+ import type { Envelope, BusError } from "./protocol.ts";
9
+
10
+ type Handler = (payload: unknown) => Promise<unknown> | unknown;
11
+ type Sender = (env: Envelope) => void;
12
+
13
+ interface PendingHostCall {
14
+ resolve: (value: unknown) => void;
15
+ reject: (err: Error) => void;
16
+ route: string;
17
+ }
18
+
19
+ export class HandlerRouter {
20
+ private handlers = new Map<string, Handler>();
21
+ private pendingHostCalls = new Map<string, PendingHostCall>();
22
+ private pluginId = "p";
23
+ private entryId = "e";
24
+ private sessionId = "s";
25
+ private endpointId = "node-main";
26
+
27
+ /** Injected transport send fn; tests can override `router.send` directly. */
28
+ send: Sender;
29
+
30
+ constructor(deps: { send: Sender }) {
31
+ this.send = deps.send;
32
+ }
33
+
34
+ /** Sets the bound identity stamped on outbound messages (after handshake). */
35
+ setIdentity(ids: { pluginId: string; entryId: string; sessionId: string; endpointId: string }): void {
36
+ this.pluginId = ids.pluginId;
37
+ this.entryId = ids.entryId;
38
+ this.sessionId = ids.sessionId;
39
+ this.endpointId = ids.endpointId;
40
+ }
41
+
42
+ handle(route: string, handler: Handler): void {
43
+ this.handlers.set(route, handler);
44
+ }
45
+
46
+ /** Dispatches an inbound request/response. Returns once handled. */
47
+ async dispatch(env: Envelope): Promise<void> {
48
+ if (env.kind === "response") {
49
+ this.handleHostResponse(env);
50
+ return;
51
+ }
52
+ if (env.kind !== "request") return;
53
+
54
+ // bus.ping is always auto-replied; it does not occupy a handler slot.
55
+ if (env.route === "bus.ping") {
56
+ this.send(this.responseFor(env, { ok: true }));
57
+ return;
58
+ }
59
+
60
+ const handler = this.handlers.get(env.route);
61
+ if (!handler) {
62
+ this.send(this.errorResponseFor(env, "RouteNotFound", `route '${env.route}' has no handler`));
63
+ return;
64
+ }
65
+
66
+ try {
67
+ const result = await handler(env.payload);
68
+ this.send(this.responseFor(env, result ?? {}));
69
+ } catch (err) {
70
+ const message = err instanceof Error ? err.message : String(err);
71
+ this.send(this.errorResponseFor(env, "InternalError", message));
72
+ }
73
+ }
74
+
75
+ /** Calls a host.call.* capability and resolves with the response payload. */
76
+ callHost(route: string, payload: unknown, timeoutMs = 30000): Promise<unknown> {
77
+ return new Promise((resolve, reject) => {
78
+ const id = randomBytesHex();
79
+ const req: Envelope = {
80
+ version: "3.0",
81
+ id,
82
+ traceId: id,
83
+ sessionId: this.sessionId,
84
+ pluginId: this.pluginId,
85
+ entryId: this.entryId,
86
+ endpointId: this.endpointId,
87
+ kind: "request",
88
+ route,
89
+ timeoutMs,
90
+ payload,
91
+ };
92
+ const pending: PendingHostCall = { resolve, reject, route };
93
+ this.pendingHostCalls.set(id, pending);
94
+ const timer = setTimeout(() => {
95
+ if (this.pendingHostCalls.has(id)) {
96
+ this.pendingHostCalls.delete(id);
97
+ reject(new Error(`host call ${route} timed out after ${timeoutMs}ms`));
98
+ }
99
+ }, timeoutMs);
100
+ // Clear the timer when settled.
101
+ const origResolve = pending.resolve;
102
+ const origReject = pending.reject;
103
+ pending.resolve = (v) => { clearTimeout(timer); origResolve(v); };
104
+ pending.reject = (e) => { clearTimeout(timer); origReject(e); };
105
+ this.send(req);
106
+ });
107
+ }
108
+
109
+ private handleHostResponse(env: Envelope): void {
110
+ if (!env.correlationId) return;
111
+ const pending = this.pendingHostCalls.get(env.correlationId);
112
+ if (!pending) return;
113
+ this.pendingHostCalls.delete(env.correlationId);
114
+ if (env.error) {
115
+ pending.reject(new Error(`${env.error.code}: ${env.error.message}`));
116
+ } else {
117
+ pending.resolve(env.payload);
118
+ }
119
+ }
120
+
121
+ private responseFor(req: Envelope, payload: unknown): Envelope {
122
+ return {
123
+ version: "3.0",
124
+ id: randomBytesHex(),
125
+ correlationId: req.id,
126
+ traceId: req.traceId,
127
+ sessionId: req.sessionId,
128
+ pluginId: req.pluginId,
129
+ entryId: req.entryId,
130
+ endpointId: this.endpointId,
131
+ kind: "response",
132
+ route: req.route,
133
+ payload,
134
+ };
135
+ }
136
+
137
+ private errorResponseFor(req: Envelope, code: string, message: string): Envelope {
138
+ return {
139
+ ...this.responseFor(req, null),
140
+ payload: undefined,
141
+ error: { code, message, retryable: false } as BusError,
142
+ };
143
+ }
144
+ }
145
+
146
+ function randomBytesHex(): string {
147
+ // 16 random bytes -> 32 hex chars, matching the C# GuidIdGenerator format.
148
+ return randomBytes(16).toString("hex");
149
+ }
package/src/server.ts ADDED
@@ -0,0 +1,156 @@
1
+ /**
2
+ * v3 server-side tool SDK: a fluent `createTool()` API that mirrors the v2 @qping/plugin-common/server
3
+ * surface (initialize/search/action/handle/publish/hostCall/start) but runs over the v3 named-pipe
4
+ * message bus via bootstrap.ts. This lets existing plugin backends switch to v3 transport by
5
+ * changing only the import — no handler-logic rewrite.
6
+ *
7
+ * Legacy method names map to v3 routes:
8
+ * initialize -> plugin.call.initialize
9
+ * search -> plugin.call.search
10
+ * invokeAction -> plugin.call.invokeAction
11
+ * detailEvent -> plugin.call.detailEvent
12
+ * detailCall -> plugin.call.detailCall (+ named handlers via plugin.call.<action>)
13
+ * publish -> plugin.event.<subjectId>
14
+ * hostCall -> host.call.<method>
15
+ */
16
+
17
+ import { runPlugin, type PluginRuntime } from "./bootstrap.ts";
18
+
19
+ type NodeToolContext = {
20
+ action: string;
21
+ itemId: string;
22
+ query: string;
23
+ locale: string;
24
+ fallbackLocale: string;
25
+ };
26
+
27
+ type NodeToolHostHandler = (params: any) => unknown | Promise<unknown>;
28
+ type NodeToolHandler = (payload: any, context: NodeToolContext) => unknown | Promise<unknown>;
29
+
30
+ export class NodeTool {
31
+ #handlers = new Map<string, NodeToolHandler>();
32
+ #searchHandler: NodeToolHostHandler | null = null;
33
+ #actionHandler: NodeToolHostHandler | null = null;
34
+ #initializeHandler: NodeToolHostHandler | null = null;
35
+ #runtime: PluginRuntime | null = null;
36
+
37
+ initialize(handler: NodeToolHostHandler): this {
38
+ this.#initializeHandler = handler;
39
+ return this;
40
+ }
41
+
42
+ search(handler: NodeToolHostHandler): this {
43
+ this.#searchHandler = handler;
44
+ return this;
45
+ }
46
+
47
+ action(handler: NodeToolHostHandler): this {
48
+ this.#actionHandler = handler;
49
+ return this;
50
+ }
51
+
52
+ handle(action: string, handler: NodeToolHandler): this {
53
+ if (!action || typeof action !== "string") {
54
+ throw new Error("tool.handle requires an action name.");
55
+ }
56
+ if (typeof handler !== "function") {
57
+ throw new Error("tool.handle requires a handler.");
58
+ }
59
+ this.#handlers.set(action, handler);
60
+ return this;
61
+ }
62
+
63
+ /** Publishes a plugin.event.<subjectId> event to all webviews in the session. */
64
+ publish(subjectId: string, payload: unknown = {}): void {
65
+ if (!this.#runtime) throw new Error("tool not started");
66
+ // Strip the legacy prefix to form a clean route; the host EventReceived surfaces the route.
67
+ const route = subjectId.startsWith("plugin.event.") ? subjectId : `plugin.event.${subjectId}`;
68
+ this.#runtime.transport.send({
69
+ version: "3.0",
70
+ id: crypto.randomUUID().replace(/-/g, "").slice(0, 32),
71
+ traceId: crypto.randomUUID().replace(/-/g, "").slice(0, 32),
72
+ sessionId: "",
73
+ pluginId: "",
74
+ entryId: "",
75
+ endpointId: "node-main",
76
+ kind: "event",
77
+ route,
78
+ payload,
79
+ });
80
+ }
81
+
82
+ /** Calls a host.call.<method> capability and awaits the response. */
83
+ hostCall(method: string, params: Record<string, unknown> = {}): Promise<unknown> {
84
+ if (!this.#runtime) return Promise.reject(new Error("tool not started"));
85
+ return this.#runtime.router.callHost(`host.call.${method}`, params);
86
+ }
87
+
88
+ /** Connects to the host pipe and begins dispatching. Must be called last. */
89
+ async start(): Promise<void> {
90
+ const routes = this.buildRoutes();
91
+ this.#runtime = await runPlugin(routes);
92
+ }
93
+
94
+ /**
95
+ * Builds the v3 route map from the fluent registrations. Exposed for unit testing the mapping
96
+ * without connecting a pipe.
97
+ */
98
+ buildRoutes(): Record<string, (payload: any) => unknown | Promise<unknown>> {
99
+ const routes: Record<string, (payload: any) => unknown | Promise<unknown>> = {};
100
+
101
+ if (this.#initializeHandler) {
102
+ routes["plugin.call.initialize"] = (p) => this.#initializeHandler!(p);
103
+ }
104
+ if (this.#searchHandler) {
105
+ routes["plugin.call.search"] = (p) => this.#searchHandler!(p);
106
+ }
107
+ if (this.#actionHandler) {
108
+ routes["plugin.call.invokeAction"] = (p) => {
109
+ return this.#actionHandler!({ ...p, itemId: p.itemId, query: p.query });
110
+ };
111
+ }
112
+ // Legacy detail calls: plugin.call.detailCall carries an `action` field selecting the handler.
113
+ routes["plugin.call.detailCall"] = async (p) => {
114
+ const action = p?.action ?? "";
115
+ const handler = this.#handlers.get(action);
116
+ if (!handler) {
117
+ throw new Error(`no handler registered for action '${action}'`);
118
+ }
119
+ const ctx = extractContext(p);
120
+ const result = await handler(p?.payload ?? {}, ctx);
121
+ return { result: result ?? {} };
122
+ };
123
+ routes["plugin.call.detailEvent"] = async (p) => {
124
+ return { state: p?.payload ?? {} };
125
+ };
126
+ for (const [action, handler] of this.#handlers) {
127
+ const route = `plugin.call.${action}`;
128
+ if (!routes[route]) {
129
+ routes[route] = async (p) => {
130
+ const ctx = extractContext(p);
131
+ return handler(p, ctx);
132
+ };
133
+ }
134
+ }
135
+ return routes;
136
+ }
137
+
138
+ async stop(): Promise<void> {
139
+ if (this.#runtime) await this.#runtime.close();
140
+ }
141
+ }
142
+
143
+ function extractContext(p: any): NodeToolContext {
144
+ return {
145
+ action: p?.action ?? "",
146
+ itemId: p?.itemId ?? "",
147
+ query: p?.query ?? "",
148
+ locale: p?.locale ?? "en-US",
149
+ fallbackLocale: p?.fallbackLocale ?? "en-US",
150
+ };
151
+ }
152
+
153
+ /** Creates a v3 NodeTool. Drop-in replacement for @qping/plugin-common/server's createTool(). */
154
+ export function createTool(): NodeTool {
155
+ return new NodeTool();
156
+ }
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Node-side named-pipe transport for the v3 message bus. Connects to the host's pipe server
3
+ * (created by NamedPipeTransport on the C# side), runs an incremental FrameDecoder read loop,
4
+ * and sends length-prefixed frames. Mirrors MyTools.Host.Transports.NamedPipeTransport.
5
+ */
6
+
7
+ import { connect as netConnect, type Socket } from "node:net";
8
+ import { encodeFrameString } from "./framing.ts";
9
+ import { canonicalStringify, type Envelope } from "./protocol.ts";
10
+
11
+ type MessageHandler = (env: Envelope) => void;
12
+ type DisconnectHandler = () => void;
13
+
14
+ export class NodeTransport {
15
+ private socket: Socket | null = null;
16
+ private messageHandlers = new Set<MessageHandler>();
17
+ private disconnectHandlers = new Set<DisconnectHandler>();
18
+ private closed = false;
19
+
20
+ onMessage(handler: MessageHandler): void {
21
+ this.messageHandlers.add(handler);
22
+ }
23
+
24
+ onDisconnect(handler: DisconnectHandler): void {
25
+ this.disconnectHandlers.add(handler);
26
+ }
27
+
28
+ get isConnected(): boolean {
29
+ return this.socket !== null && !this.closed;
30
+ }
31
+
32
+ /** Connects to a Windows named pipe (\\.\pipe\<name>). */
33
+ async connect(pipePath: string): Promise<void> {
34
+ this.socket = (netConnect as any)(pipePath) as Socket;
35
+
36
+ await new Promise<void>((resolve, reject) => {
37
+ this.socket!.once("connect", () => resolve());
38
+ this.socket!.once("error", (err: Error) => reject(err));
39
+ });
40
+
41
+ // Single-writer ordering is guaranteed by the socket itself; a dedicated read loop decodes
42
+ // frames incrementally and dispatches each complete envelope.
43
+ const { FrameDecoder } = await import("./framing.ts");
44
+ const decoder = new FrameDecoder();
45
+
46
+ this.socket.on("data", (chunk: Buffer) => {
47
+ let result = decoder.feed(chunk);
48
+ if (result.isFatal) {
49
+ this.handleDisconnect();
50
+ return;
51
+ }
52
+ while (result.hasFrame) {
53
+ try {
54
+ const env = JSON.parse(result.payload.toString("utf8")) as Envelope;
55
+ for (const h of this.messageHandlers) h(env);
56
+ } catch {
57
+ // Illegal JSON closes the connection per design.
58
+ this.handleDisconnect();
59
+ return;
60
+ }
61
+ result = decoder.feed(Buffer.alloc(0));
62
+ if (result.isFatal) {
63
+ this.handleDisconnect();
64
+ return;
65
+ }
66
+ }
67
+ });
68
+
69
+ this.socket.on("close", () => this.handleDisconnect());
70
+ this.socket.on("error", () => this.handleDisconnect());
71
+ }
72
+
73
+ /** Serializes an envelope to a length-prefixed frame and writes it. */
74
+ send(env: Envelope): void {
75
+ if (!this.socket || this.closed) {
76
+ throw new Error("transport is not connected");
77
+ }
78
+ this.socket.write(encodeFrameString(canonicalStringify(env)));
79
+ }
80
+
81
+ async close(): Promise<void> {
82
+ this.closed = true;
83
+ if (this.socket) {
84
+ this.socket.end();
85
+ await new Promise<void>((resolve) => {
86
+ if (this.socket!.destroyed) return resolve();
87
+ this.socket!.once("close", () => resolve());
88
+ });
89
+ this.socket = null;
90
+ }
91
+ }
92
+
93
+ private handleDisconnect(): void {
94
+ if (this.closed) return;
95
+ this.closed = true;
96
+ for (const h of this.disconnectHandlers) h();
97
+ }
98
+ }