Skip to content

Commit a678bf1

Browse files
committed
fix(connect): fix connect issue
1 parent 312a331 commit a678bf1

File tree

7 files changed

+267
-35
lines changed

7 files changed

+267
-35
lines changed

src/client/batch-mock-collector.ts

Lines changed: 129 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import {
55
type BatchMockRequestMessage,
66
type BatchMockResponseMessage,
77
type MockRequestDescriptor,
8+
type MockResponseDescriptor,
9+
type ResolvedMock,
810
} from "../types.js";
911
import { isEnabled } from "./util.js";
1012

@@ -44,6 +46,18 @@ export interface BatchMockCollectorOptions {
4446
* Optional custom logger. Defaults to `console`.
4547
*/
4648
logger?: Logger;
49+
/**
50+
* Interval for WebSocket heartbeats in milliseconds. Set to 0 to disable.
51+
*
52+
* @default 15000
53+
*/
54+
heartbeatIntervalMs?: number;
55+
/**
56+
* Automatically attempt to reconnect when the WebSocket closes unexpectedly.
57+
*
58+
* @default true
59+
*/
60+
enableReconnect?: boolean;
4761
}
4862

4963
export interface RequestMockOptions {
@@ -54,7 +68,7 @@ export interface RequestMockOptions {
5468

5569
interface PendingRequest {
5670
request: MockRequestDescriptor;
57-
resolve: (data: unknown) => void;
71+
resolve: (mock: MockResponseDescriptor) => void;
5872
reject: (error: Error) => void;
5973
timeoutId: NodeJS.Timeout;
6074
completion: Promise<PromiseSettledResult<void>>;
@@ -64,42 +78,46 @@ const DEFAULT_TIMEOUT = 60_000;
6478
const DEFAULT_BATCH_DEBOUNCE_MS = 0;
6579
const DEFAULT_MAX_BATCH_SIZE = 50;
6680
const DEFAULT_PORT = 3002;
81+
const DEFAULT_HEARTBEAT_INTERVAL_MS = 15_000;
6782

6883
/**
6984
* Collects HTTP requests issued during a single macrotask and forwards them to
7085
* the MCP server as a batch for AI-assisted mock generation.
7186
*/
7287
export class BatchMockCollector {
73-
private readonly ws: WebSocket;
88+
private ws: WebSocket;
7489
private readonly pendingRequests = new Map<string, PendingRequest>();
7590
private readonly queuedRequestIds = new Set<string>();
7691
private readonly timeout: number;
7792
private readonly batchDebounceMs: number;
7893
private readonly maxBatchSize: number;
7994
private readonly logger: Logger;
95+
private readonly heartbeatIntervalMs: number;
96+
private readonly enableReconnect: boolean;
97+
private readonly port: number;
8098

8199
private batchTimer: NodeJS.Timeout | null = null;
100+
private heartbeatTimer: NodeJS.Timeout | null = null;
101+
private reconnectTimer: NodeJS.Timeout | null = null;
82102
private requestIdCounter = 0;
83103
private closed = false;
84104

85105
private readyResolve?: () => void;
86106
private readyReject?: (error: Error) => void;
87-
private readonly readyPromise: Promise<void>;
107+
private readyPromise: Promise<void>;
88108

89109
constructor(options: BatchMockCollectorOptions = {}) {
90110
this.timeout = options.timeout ?? DEFAULT_TIMEOUT;
91111
this.batchDebounceMs = options.batchDebounceMs ?? DEFAULT_BATCH_DEBOUNCE_MS;
92112
this.maxBatchSize = options.maxBatchSize ?? DEFAULT_MAX_BATCH_SIZE;
93113
this.logger = options.logger ?? console;
94-
const port = options.port ?? DEFAULT_PORT;
114+
this.heartbeatIntervalMs =
115+
options.heartbeatIntervalMs ?? DEFAULT_HEARTBEAT_INTERVAL_MS;
116+
this.enableReconnect = options.enableReconnect ?? true;
117+
this.port = options.port ?? DEFAULT_PORT;
95118

96-
this.readyPromise = new Promise<void>((resolve, reject) => {
97-
this.readyResolve = resolve;
98-
this.readyReject = reject;
99-
});
100-
101-
const wsUrl = `ws://localhost:${port}`;
102-
this.ws = new WebSocket(wsUrl);
119+
this.resetReadyPromise();
120+
this.ws = this.createWebSocket();
103121
this.setupWebSocket();
104122
}
105123

@@ -117,7 +135,7 @@ export class BatchMockCollector {
117135
endpoint: string,
118136
method: string,
119137
options: RequestMockOptions = {}
120-
): Promise<T> {
138+
): Promise<ResolvedMock<T>> {
121139
if (this.closed) {
122140
throw new Error("BatchMockCollector has been closed");
123141
}
@@ -139,7 +157,7 @@ export class BatchMockCollector {
139157
settleCompletion = resolve;
140158
});
141159

142-
return new Promise<T>((resolve, reject) => {
160+
return new Promise<ResolvedMock<T>>((resolve, reject) => {
143161
const timeoutId = setTimeout(() => {
144162
this.rejectRequest(
145163
requestId,
@@ -151,9 +169,9 @@ export class BatchMockCollector {
151169

152170
this.pendingRequests.set(requestId, {
153171
request,
154-
resolve: (data) => {
172+
resolve: (mock) => {
155173
settleCompletion({ status: "fulfilled", value: undefined });
156-
resolve(data as T);
174+
resolve(this.buildResolvedMock<T>(mock));
157175
},
158176
reject: (error) => {
159177
settleCompletion({ status: "rejected", reason: error });
@@ -202,6 +220,14 @@ export class BatchMockCollector {
202220
clearTimeout(this.batchTimer);
203221
this.batchTimer = null;
204222
}
223+
if (this.heartbeatTimer) {
224+
clearInterval(this.heartbeatTimer);
225+
this.heartbeatTimer = null;
226+
}
227+
if (this.reconnectTimer) {
228+
clearTimeout(this.reconnectTimer);
229+
this.reconnectTimer = null;
230+
}
205231
this.queuedRequestIds.clear();
206232

207233
const closePromise = new Promise<void>((resolve) => {
@@ -218,6 +244,7 @@ export class BatchMockCollector {
218244
this.ws.on("open", () => {
219245
this.logger.log("🔌 Connected to mock MCP WebSocket endpoint");
220246
this.readyResolve?.();
247+
this.startHeartbeat();
221248
});
222249

223250
this.ws.on("message", (data: RawData) => this.handleMessage(data));
@@ -234,10 +261,79 @@ export class BatchMockCollector {
234261

235262
this.ws.on("close", () => {
236263
this.logger.warn("🔌 WebSocket connection closed");
264+
this.stopHeartbeat();
237265
this.failAllPending(new Error("WebSocket connection closed"));
266+
if (!this.closed && this.enableReconnect) {
267+
this.scheduleReconnect();
268+
}
269+
});
270+
}
271+
272+
private createWebSocket() {
273+
const wsUrl = `ws://localhost:${this.port}`;
274+
return new WebSocket(wsUrl);
275+
}
276+
277+
private resetReadyPromise() {
278+
this.readyPromise = new Promise<void>((resolve, reject) => {
279+
this.readyResolve = resolve;
280+
this.readyReject = reject;
238281
});
239282
}
240283

284+
private startHeartbeat() {
285+
if (this.heartbeatIntervalMs <= 0 || this.heartbeatTimer) {
286+
return;
287+
}
288+
289+
let lastPong = Date.now();
290+
this.ws.on("pong", () => {
291+
lastPong = Date.now();
292+
});
293+
294+
this.heartbeatTimer = setInterval(() => {
295+
if (this.ws.readyState !== WebSocket.OPEN) {
296+
return;
297+
}
298+
299+
const now = Date.now();
300+
if (now - lastPong > this.heartbeatIntervalMs * 2) {
301+
this.logger.warn(
302+
"Heartbeat missed; closing socket to trigger reconnect..."
303+
);
304+
this.ws.close();
305+
return;
306+
}
307+
308+
this.ws.ping();
309+
}, this.heartbeatIntervalMs);
310+
this.heartbeatTimer.unref?.();
311+
}
312+
313+
private stopHeartbeat() {
314+
if (this.heartbeatTimer) {
315+
clearInterval(this.heartbeatTimer);
316+
this.heartbeatTimer = null;
317+
}
318+
}
319+
320+
private scheduleReconnect() {
321+
if (this.reconnectTimer || this.closed) {
322+
return;
323+
}
324+
325+
this.reconnectTimer = setTimeout(() => {
326+
this.reconnectTimer = null;
327+
this.logger.warn("🔄 Reconnecting to mock MCP WebSocket endpoint...");
328+
this.stopHeartbeat();
329+
this.resetReadyPromise();
330+
this.ws = this.createWebSocket();
331+
this.setupWebSocket();
332+
}, 1_000);
333+
334+
this.reconnectTimer.unref?.();
335+
}
336+
241337
private handleMessage(data: RawData) {
242338
let parsed: BatchMockResponseMessage | undefined;
243339

@@ -271,7 +367,12 @@ export class BatchMockCollector {
271367

272368
clearTimeout(pending.timeoutId);
273369
this.pendingRequests.delete(mock.requestId);
274-
pending.resolve(mock.data);
370+
const resolve = () => pending.resolve(mock);
371+
if (mock.delayMs && mock.delayMs > 0) {
372+
setTimeout(resolve, mock.delayMs);
373+
} else {
374+
resolve();
375+
}
275376
}
276377

277378
private enqueueRequest(requestId: string) {
@@ -332,6 +433,18 @@ export class BatchMockCollector {
332433
this.ws.send(JSON.stringify(payload));
333434
}
334435

436+
private buildResolvedMock<T>(
437+
mock: MockResponseDescriptor
438+
): ResolvedMock<T> {
439+
return {
440+
requestId: mock.requestId,
441+
data: mock.data as T,
442+
status: mock.status,
443+
headers: mock.headers,
444+
delayMs: mock.delayMs,
445+
};
446+
}
447+
335448
private rejectRequest(requestId: string, error: Error) {
336449
const pending = this.pendingRequests.get(requestId);
337450
if (!pending) {

src/client/connect.ts

Lines changed: 40 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,57 @@
11
import { BatchMockCollector } from "./batch-mock-collector.js";
2-
import type { BatchMockCollectorOptions } from "./batch-mock-collector.js";
2+
import type {
3+
BatchMockCollectorOptions,
4+
RequestMockOptions,
5+
} from "./batch-mock-collector.js";
6+
import type { ResolvedMock } from "../types.js";
37
import { isEnabled } from "./util.js";
48

59
export type ConnectOptions = number | BatchMockCollectorOptions | undefined;
10+
export interface MockClient {
11+
waitUntilReady(): Promise<void>;
12+
requestMock<T = unknown>(
13+
endpoint: string,
14+
method: string,
15+
options?: RequestMockOptions
16+
): Promise<ResolvedMock<T>>;
17+
waitForPendingRequests(): Promise<void>;
18+
close(code?: number): Promise<void>;
19+
}
20+
21+
class DisabledMockClient implements MockClient {
22+
async waitUntilReady(): Promise<void> {
23+
return;
24+
}
25+
26+
async requestMock<T>(): Promise<ResolvedMock<T>> {
27+
throw new Error(
28+
"[mock-mcp] MOCK_MCP is not enabled. Set MOCK_MCP=1 to enable mock generation."
29+
);
30+
}
31+
32+
async waitForPendingRequests(): Promise<void> {
33+
return;
34+
}
35+
36+
async close(): Promise<void> {
37+
return;
38+
}
39+
}
640

741
/**
842
* Convenience helper that creates a {@link BatchMockCollector} and waits for the
943
* underlying WebSocket connection to become ready before resolving.
1044
*/
1145
export const connect = async (
1246
options?: ConnectOptions
13-
): Promise<BatchMockCollector | void> => {
47+
): Promise<MockClient> => {
48+
const resolvedOptions: BatchMockCollectorOptions =
49+
typeof options === "number" ? { port: options } : options ?? {};
50+
1451
if (!isEnabled()) {
1552
console.log("[mock-mcp] Skipping (set MOCK_MCP=1 to enable)");
16-
return;
53+
return new DisabledMockClient();
1754
}
18-
const resolvedOptions: BatchMockCollectorOptions =
19-
typeof options === "number" ? { port: options } : options ?? {};
2055

2156
const collector = new BatchMockCollector(resolvedOptions);
2257
await collector.waitUntilReady();

src/client/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,4 @@ export {
33
type BatchMockCollectorOptions,
44
type RequestMockOptions,
55
} from "./batch-mock-collector.js";
6-
export { connect, type ConnectOptions } from "./connect.js";
6+
export { connect, type ConnectOptions, type MockClient } from "./connect.js";

src/index.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@ import {
1111
type BatchMockCollectorOptions,
1212
type RequestMockOptions,
1313
} from "./client/batch-mock-collector.js";
14-
import { connect, type ConnectOptions } from "./client/connect.js";
14+
import { connect, type ConnectOptions, type MockClient } from "./client/connect.js";
15+
import type { ResolvedMock } from "./types.js";
1516

1617
const DEFAULT_PORT = 3002;
1718

@@ -81,4 +82,4 @@ export { BatchMockCollector };
8182
export type { BatchMockCollectorOptions, RequestMockOptions };
8283

8384
export { connect };
84-
export type { ConnectOptions };
85+
export type { ConnectOptions, MockClient, ResolvedMock };

0 commit comments

Comments
 (0)