55 type BatchMockRequestMessage ,
66 type BatchMockResponseMessage ,
77 type MockRequestDescriptor ,
8+ type MockResponseDescriptor ,
9+ type ResolvedMock ,
810} from "../types.js" ;
911import { 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
4963export interface RequestMockOptions {
@@ -54,7 +68,7 @@ export interface RequestMockOptions {
5468
5569interface 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;
6478const DEFAULT_BATCH_DEBOUNCE_MS = 0 ;
6579const DEFAULT_MAX_BATCH_SIZE = 50 ;
6680const 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 */
7287export 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 ) {
0 commit comments