@graphty/remote-logger 1.2.1 → 1.2.3

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.
Files changed (39) hide show
  1. package/README.md +1 -1
  2. package/dist/client/RemoteLogClient.d.ts +2 -1
  3. package/dist/client/RemoteLogClient.d.ts.map +1 -1
  4. package/dist/client/RemoteLogClient.js +20 -10
  5. package/dist/client/RemoteLogClient.js.map +1 -1
  6. package/dist/client/types.d.ts +6 -0
  7. package/dist/client/types.d.ts.map +1 -1
  8. package/dist/mcp/index.d.ts +1 -1
  9. package/dist/mcp/index.d.ts.map +1 -1
  10. package/dist/mcp/index.js +20 -1
  11. package/dist/mcp/index.js.map +1 -1
  12. package/dist/mcp/mcp-server.d.ts +5 -0
  13. package/dist/mcp/mcp-server.d.ts.map +1 -1
  14. package/dist/mcp/mcp-server.js +6 -5
  15. package/dist/mcp/mcp-server.js.map +1 -1
  16. package/dist/server/dual-server.d.ts +7 -4
  17. package/dist/server/dual-server.d.ts.map +1 -1
  18. package/dist/server/dual-server.js +123 -44
  19. package/dist/server/dual-server.js.map +1 -1
  20. package/dist/server/log-server.d.ts +2 -2
  21. package/dist/server/log-server.d.ts.map +1 -1
  22. package/dist/server/log-server.js +32 -55
  23. package/dist/server/log-server.js.map +1 -1
  24. package/dist/server/self-signed-cert.d.ts +9 -0
  25. package/dist/server/self-signed-cert.d.ts.map +1 -1
  26. package/dist/server/self-signed-cert.js +9 -0
  27. package/dist/server/self-signed-cert.js.map +1 -1
  28. package/dist/ui/ConsoleCaptureUI.d.ts.map +1 -1
  29. package/dist/ui/ConsoleCaptureUI.js +6 -1
  30. package/dist/ui/ConsoleCaptureUI.js.map +1 -1
  31. package/package.json +3 -1
  32. package/src/client/RemoteLogClient.ts +22 -11
  33. package/src/client/types.ts +6 -0
  34. package/src/mcp/index.ts +42 -0
  35. package/src/mcp/mcp-server.ts +6 -5
  36. package/src/server/dual-server.ts +165 -51
  37. package/src/server/log-server.ts +35 -60
  38. package/src/server/self-signed-cert.ts +9 -0
  39. package/src/ui/ConsoleCaptureUI.ts +6 -1
@@ -8,8 +8,9 @@
8
8
 
9
9
  import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
10
10
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
11
- import type * as http from "http";
12
- import type * as https from "https";
11
+ import * as http from "http";
12
+ import * as https from "https";
13
+ import { internalIpV4Sync } from "internal-ip";
13
14
  import * as net from "net";
14
15
  import * as os from "os";
15
16
  import * as path from "path";
@@ -35,6 +36,90 @@ const MAX_PORT_SCAN_ATTEMPTS = 100;
35
36
  /** Maximum port number allowed (ports 9000-9099 per project guidelines) */
36
37
  const MAX_PORT_NUMBER = 9099;
37
38
 
39
+ /** Maximum retries for binding after EADDRINUSE during listen */
40
+ const MAX_BIND_RETRIES = 10;
41
+
42
+ /**
43
+ * Attempt to bind an HTTP/HTTPS server to a port with retry on EADDRINUSE.
44
+ * This handles race conditions where a port becomes unavailable between
45
+ * port scanning and actual binding.
46
+ * @param serverFactory - Function to create a new server instance
47
+ * @param startPort - Starting port to try
48
+ * @param host - Host to bind to
49
+ * @param quiet - Suppress output messages
50
+ * @param maxPort - Maximum port number to scan up to (default: 9099)
51
+ * @returns Promise resolving to bound server and actual port
52
+ */
53
+ async function tryBindWithRetry(
54
+ serverFactory: () => http.Server | https.Server,
55
+ startPort: number,
56
+ host: string,
57
+ quiet: boolean,
58
+ maxPort: number = MAX_PORT_NUMBER,
59
+ ): Promise<{ server: http.Server | https.Server; port: number }> {
60
+ let port = startPort;
61
+ let attempts = 0;
62
+
63
+ while (attempts < MAX_BIND_RETRIES) {
64
+ if (port > maxPort) {
65
+ break;
66
+ }
67
+
68
+ const server = serverFactory();
69
+
70
+ try {
71
+ await new Promise<void>((resolve, reject) => {
72
+ const errorHandler = (err: NodeJS.ErrnoException): void => {
73
+ if (err.code === "EADDRINUSE") {
74
+ if (!quiet) {
75
+ // eslint-disable-next-line no-console
76
+ console.log(
77
+ `${colors.yellow}Port ${port} claimed during bind, trying ${port + 1}...${colors.reset}`,
78
+ );
79
+ }
80
+ reject(err);
81
+ } else {
82
+ reject(err);
83
+ }
84
+ };
85
+
86
+ server.once("error", errorHandler);
87
+ server.listen({ port, host, exclusive: false }, () => {
88
+ server.removeListener("error", errorHandler);
89
+ resolve();
90
+ });
91
+ });
92
+
93
+ // Success - server is bound
94
+ if (!quiet) {
95
+ // eslint-disable-next-line no-console
96
+ console.log(
97
+ `${colors.green}HTTP server listening on ${host}:${port}${colors.reset}`,
98
+ );
99
+ }
100
+ return { server, port };
101
+ } catch (err) {
102
+ if ((err as NodeJS.ErrnoException).code === "EADDRINUSE") {
103
+ port++;
104
+ attempts++;
105
+ continue;
106
+ }
107
+ // Re-throw non-EADDRINUSE errors
108
+ throw err;
109
+ }
110
+ }
111
+
112
+ // Exhausted retries
113
+ const errorMsg = `Could not bind to any port after ${attempts} attempts. ` +
114
+ `Ports ${startPort}-${port - 1} are all in use or were claimed during bind.`;
115
+
116
+ if (!quiet) {
117
+ console.error(`${colors.red}${errorMsg}${colors.reset}`);
118
+ }
119
+
120
+ throw new Error(errorMsg);
121
+ }
122
+
38
123
  /**
39
124
  * Check if a port is available for binding.
40
125
  * @param port - Port number to check
@@ -72,16 +157,22 @@ async function isPortAvailable(port: number, host: string): Promise<boolean> {
72
157
  * @param basePort - Starting port number
73
158
  * @param host - Host to bind to
74
159
  * @param quiet - Suppress output messages
160
+ * @param maxPort - Maximum port number to scan up to (default: 9099)
75
161
  * @returns Promise resolving to available port number
76
162
  * @throws Error if no available port found within MAX_PORT_SCAN_ATTEMPTS
77
163
  */
78
- export async function findAvailablePort(basePort: number, host: string, quiet: boolean = false): Promise<number> {
164
+ export async function findAvailablePort(
165
+ basePort: number,
166
+ host: string,
167
+ quiet: boolean = false,
168
+ maxPort: number = MAX_PORT_NUMBER,
169
+ ): Promise<number> {
79
170
  let port = basePort;
80
171
  let attempts = 0;
81
172
 
82
173
  while (attempts < MAX_PORT_SCAN_ATTEMPTS) {
83
- if (port > MAX_PORT_NUMBER) {
84
- // Wrap around if we exceed max port (shouldn't happen normally)
174
+ if (port > maxPort) {
175
+ // Stop if we exceed max port
85
176
  break;
86
177
  }
87
178
 
@@ -107,12 +198,12 @@ export async function findAvailablePort(basePort: number, host: string, quiet: b
107
198
  }
108
199
 
109
200
  // No available port found
110
- const errorMsg = `Could not find available port after ${MAX_PORT_SCAN_ATTEMPTS} attempts starting from ${basePort}. ` +
111
- `Ports ${basePort}-${port - 1} are all in use. ` +
201
+ const errorMsg = `Could not find available port after ${attempts} attempts starting from ${basePort}. ` +
202
+ `Ports ${basePort}-${Math.min(port - 1, maxPort)} are all in use. ` +
112
203
  `Try killing existing processes: pkill -f "remote-log-server"`;
113
204
 
114
205
  if (!quiet) {
115
-
206
+
116
207
  console.error(`${colors.red}${errorMsg}${colors.reset}`);
117
208
  }
118
209
 
@@ -125,7 +216,7 @@ export async function findAvailablePort(basePort: number, host: string, quiet: b
125
216
  export interface DualServerOptions {
126
217
  /** Port for HTTP server (default: 9080) */
127
218
  httpPort?: number;
128
- /** Host for HTTP server (default: localhost) */
219
+ /** Host for HTTP server (default: 0.0.0.0) */
129
220
  httpHost?: string;
130
221
  /** Enable MCP server (default: true) */
131
222
  mcpEnabled?: boolean;
@@ -145,6 +236,8 @@ export interface DualServerOptions {
145
236
  jsonlWriter?: JsonlWriter;
146
237
  /** Only serve /log POST and /health GET endpoints (default: false) */
147
238
  logReceiveOnly?: boolean;
239
+ /** Maximum port number to scan up to (default: 9099) */
240
+ maxPortNumber?: number;
148
241
  }
149
242
 
150
243
  /**
@@ -176,7 +269,7 @@ export interface DualServerResult {
176
269
  export async function createDualServer(options: DualServerOptions = {}): Promise<DualServerResult> {
177
270
  const {
178
271
  httpPort: requestedPort = 9080,
179
- httpHost = "localhost",
272
+ httpHost = "0.0.0.0",
180
273
  mcpEnabled = true,
181
274
  httpEnabled = true,
182
275
  quiet = false,
@@ -185,6 +278,7 @@ export async function createDualServer(options: DualServerOptions = {}): Promise
185
278
  logReceiveOnly = false,
186
279
  certPath,
187
280
  keyPath,
281
+ maxPortNumber = MAX_PORT_NUMBER,
188
282
  } = options;
189
283
 
190
284
  // Create or use provided JSONL writer
@@ -198,53 +292,50 @@ export async function createDualServer(options: DualServerOptions = {}): Promise
198
292
  let mcpServer: McpServer | undefined;
199
293
  let actualHttpPort: number | undefined;
200
294
 
295
+ // Track active connections for graceful shutdown
296
+ const activeConnections = new Set<net.Socket>();
297
+
201
298
  // Start HTTP server if enabled
202
299
  if (httpEnabled) {
203
300
  // Find an available port starting from the requested port
204
- actualHttpPort = await findAvailablePort(requestedPort, httpHost, quiet);
205
-
206
- const result = createLogServer({
207
- port: actualHttpPort,
208
- host: httpHost,
209
- storage,
301
+ const startPort = await findAvailablePort(requestedPort, httpHost, quiet, maxPortNumber);
302
+
303
+ // Factory function to create server instances (for retry on bind failure)
304
+ const createServerInstance = (): http.Server | https.Server => {
305
+ return createLogServer({
306
+ port: startPort, // Port is set but not used until bind
307
+ host: httpHost,
308
+ storage,
309
+ quiet: true, // Quiet during factory, messages handled by tryBindWithRetry
310
+ logReceiveOnly,
311
+ certPath,
312
+ keyPath,
313
+ }).server;
314
+ };
315
+
316
+ // Bind with retry logic to handle race conditions
317
+ const bindResult = await tryBindWithRetry(
318
+ createServerInstance,
319
+ startPort,
320
+ httpHost,
210
321
  quiet,
211
- logReceiveOnly,
212
- certPath,
213
- keyPath,
214
- });
215
- httpServer = result.server;
216
-
217
- // Wait for HTTP server to be listening
218
- const serverToStart = httpServer;
219
- const portToUse = actualHttpPort;
220
- await new Promise<void>((resolve, reject) => {
221
- serverToStart.on("error", (err: NodeJS.ErrnoException) => {
222
- // Provide helpful error message for port conflicts
223
- if (err.code === "EADDRINUSE") {
224
- const errorMsg = `Port ${portToUse} is already in use. ` +
225
- `This shouldn't happen after port scanning - there may be a race condition. ` +
226
- `Try again or kill existing processes: pkill -f "remote-log-server"`;
227
- if (!quiet) {
228
-
229
- console.error(`${colors.red}${errorMsg}${colors.reset}`);
230
- }
231
- reject(new Error(errorMsg));
232
- } else {
233
- reject(err);
234
- }
235
- });
236
- serverToStart.listen({ port: portToUse, host: httpHost, exclusive: false }, () => {
237
- serverToStart.removeListener("error", reject);
238
- if (!quiet) {
239
- // eslint-disable-next-line no-console
240
- console.log(
241
- `${colors.green}HTTP server listening on ${httpHost}:${portToUse}${colors.reset}`,
242
- );
243
- }
244
- resolve();
322
+ maxPortNumber,
323
+ );
324
+ httpServer = bindResult.server;
325
+ actualHttpPort = bindResult.port;
326
+
327
+ // Track connections for graceful shutdown
328
+ httpServer.on("connection", (socket: net.Socket) => {
329
+ activeConnections.add(socket);
330
+ socket.once("close", () => {
331
+ activeConnections.delete(socket);
245
332
  });
246
333
  });
247
334
 
335
+ // Set short keep-alive timeout for tests (connections close faster)
336
+ httpServer.keepAliveTimeout = 1000;
337
+ httpServer.headersTimeout = 2000;
338
+
248
339
  // Set server config in storage so MCP tools can report it
249
340
  // Determine protocol based on whether valid cert files were provided
250
341
  const useHttps = certPath && keyPath && certFilesExist(certPath, keyPath);
@@ -258,11 +349,28 @@ export async function createDualServer(options: DualServerOptions = {}): Promise
258
349
  } else {
259
350
  mode = "dual";
260
351
  }
352
+ // When bound to all interfaces (0.0.0.0), detect the machine's IP address
353
+ // for the endpoint URL since 0.0.0.0 is not a routable address for clients
354
+ let endpointHost = httpHost;
355
+ if (httpHost === "0.0.0.0") {
356
+ const internalIp = internalIpV4Sync();
357
+ if (internalIp) {
358
+ endpointHost = internalIp;
359
+ } else {
360
+ // Fallback for local-only use - don't use hostname as it may not resolve
361
+ endpointHost = "127.0.0.1";
362
+ if (!quiet) {
363
+ console.warn(
364
+ `${colors.yellow}Could not detect LAN IP. Endpoint URL will only work locally.${colors.reset}`,
365
+ );
366
+ }
367
+ }
368
+ }
261
369
  storage.setServerConfig({
262
370
  httpPort: actualHttpPort,
263
371
  httpHost,
264
372
  protocol,
265
- httpEndpoint: `${protocol}://${httpHost}:${actualHttpPort}/log`,
373
+ httpEndpoint: `${protocol}://${endpointHost}:${actualHttpPort}/log`,
266
374
  mode,
267
375
  });
268
376
  }
@@ -281,6 +389,12 @@ export async function createDualServer(options: DualServerOptions = {}): Promise
281
389
  const shutdown = async (): Promise<void> => {
282
390
  // Close HTTP server
283
391
  if (httpServer?.listening) {
392
+ // Destroy all active connections to ensure clean shutdown
393
+ for (const socket of activeConnections) {
394
+ socket.destroy();
395
+ }
396
+ activeConnections.clear();
397
+
284
398
  await new Promise<void>((resolve) => {
285
399
  httpServer.close(() => { resolve(); });
286
400
  });
@@ -81,7 +81,7 @@ const colors = {
81
81
  export interface LogServerOptions {
82
82
  /** Port to listen on (default: 9080) */
83
83
  port?: number;
84
- /** Hostname to bind to (default: localhost) */
84
+ /** Hostname to bind to (default: 0.0.0.0) */
85
85
  host?: string;
86
86
  /** Path to SSL certificate file (HTTPS only used if both certPath and keyPath provided) */
87
87
  certPath?: string;
@@ -477,7 +477,7 @@ export function createLogServer(options: CreateLogServerOptions): CreateLogServe
477
477
  */
478
478
  export function startLogServer(options: LogServerOptions = {}): http.Server | https.Server {
479
479
  const port = options.port ?? 9080;
480
- const host = options.host ?? "localhost";
480
+ const host = options.host ?? "0.0.0.0";
481
481
  const quiet = options.quiet ?? false;
482
482
 
483
483
  // Set up log file if specified
@@ -555,7 +555,7 @@ Usage:
555
555
 
556
556
  Options:
557
557
  --port, -p <port> Port to listen on (default: 9080)
558
- --host, -h <host> Hostname to bind to (default: localhost)
558
+ --host, -h <host> Hostname to bind to (default: 0.0.0.0)
559
559
  --cert, -c <path> Path to SSL certificate file (enables HTTPS)
560
560
  --key, -k <path> Path to SSL private key file (enables HTTPS)
561
561
  --log-file, -l <path> Write logs to file
@@ -677,87 +677,62 @@ export async function main(): Promise<void> {
677
677
 
678
678
  const { options } = result;
679
679
 
680
+ // Common server options shared across all modes
681
+ const baseOptions = {
682
+ httpPort: options.port ?? 9080,
683
+ httpHost: options.host ?? "0.0.0.0",
684
+ quiet: options.quiet ?? false,
685
+ certPath: options.certPath,
686
+ keyPath: options.keyPath,
687
+ };
688
+
680
689
  // Determine mode: mcp-only, http-only, or dual (default)
681
690
  // All modes now use createDualServer with different options
682
691
  const { createDualServer } = await import("./dual-server.js");
683
692
 
693
+ let dualServer;
694
+ let modeMessage: string;
695
+
684
696
  if (options.mcpOnly) {
685
697
  // MCP-only mode: HTTP only serves /log endpoint, MCP enabled
686
- const dualServer = await createDualServer({
687
- httpPort: options.port ?? 9080,
688
- httpHost: options.host ?? "localhost",
698
+ dualServer = await createDualServer({
699
+ ...baseOptions,
689
700
  httpEnabled: true,
690
701
  mcpEnabled: true,
691
- quiet: options.quiet ?? false,
692
702
  logReceiveOnly: true, // Only serve /log and /health endpoints
693
- certPath: options.certPath,
694
- keyPath: options.keyPath,
695
- });
696
-
697
- // Handle graceful shutdown
698
- process.on("SIGINT", () => {
699
- // eslint-disable-next-line no-console
700
- console.log("\nShutting down...");
701
- void dualServer.shutdown().then(() => {
702
- process.exit(0);
703
- });
704
703
  });
705
-
706
- if (!options.quiet) {
707
- // eslint-disable-next-line no-console
708
- console.log("MCP mode: Log receive endpoint and MCP tools running");
709
- }
704
+ modeMessage = "MCP mode: Log receive endpoint and MCP tools running";
710
705
  } else if (options.httpOnly) {
711
706
  // HTTP-only mode: All HTTP endpoints, no MCP
712
- const dualServer = await createDualServer({
713
- httpPort: options.port ?? 9080,
714
- httpHost: options.host ?? "localhost",
707
+ dualServer = await createDualServer({
708
+ ...baseOptions,
715
709
  httpEnabled: true,
716
710
  mcpEnabled: false,
717
- quiet: options.quiet ?? false,
718
- certPath: options.certPath,
719
- keyPath: options.keyPath,
720
711
  logFile: options.logFile,
721
712
  });
722
-
723
- // Handle graceful shutdown
724
- process.on("SIGINT", () => {
725
- // eslint-disable-next-line no-console
726
- console.log("\nShutting down...");
727
- void dualServer.shutdown().then(() => {
728
- process.exit(0);
729
- });
730
- });
731
-
732
- if (!options.quiet) {
733
- // eslint-disable-next-line no-console
734
- console.log("HTTP-only mode: All HTTP endpoints running");
735
- }
713
+ modeMessage = "HTTP-only mode: All HTTP endpoints running";
736
714
  } else {
737
715
  // Dual mode (default): All HTTP endpoints and MCP
738
- const dualServer = await createDualServer({
739
- httpPort: options.port ?? 9080,
740
- httpHost: options.host ?? "localhost",
716
+ dualServer = await createDualServer({
717
+ ...baseOptions,
741
718
  httpEnabled: true,
742
719
  mcpEnabled: true,
743
- quiet: options.quiet ?? false,
744
- certPath: options.certPath,
745
- keyPath: options.keyPath,
746
720
  logFile: options.logFile,
747
721
  });
722
+ modeMessage = "Dual mode: HTTP and MCP servers running";
723
+ }
748
724
 
749
- // Handle graceful shutdown
750
- process.on("SIGINT", () => {
751
- // eslint-disable-next-line no-console
752
- console.log("\nShutting down...");
753
- void dualServer.shutdown().then(() => {
754
- process.exit(0);
755
- });
725
+ // Single SIGINT handler for all modes
726
+ process.on("SIGINT", () => {
727
+ // eslint-disable-next-line no-console
728
+ console.log("\nShutting down...");
729
+ void dualServer.shutdown().then(() => {
730
+ process.exit(0);
756
731
  });
732
+ });
757
733
 
758
- if (!options.quiet) {
759
- // eslint-disable-next-line no-console
760
- console.log("Dual mode: HTTP and MCP servers running");
761
- }
734
+ if (!options.quiet) {
735
+ // eslint-disable-next-line no-console
736
+ console.log(modeMessage);
762
737
  }
763
738
  }
@@ -14,8 +14,17 @@ export interface GeneratedCert {
14
14
  /**
15
15
  * Generate a self-signed certificate for HTTPS.
16
16
  * The certificate is valid for localhost and common local development hostnames.
17
+ *
18
+ * NOTE: This function is currently unused because modern browsers reject
19
+ * self-signed certificates by default. It is retained for potential use cases:
20
+ * - Node.js clients that can disable certificate verification
21
+ * - Testing environments
22
+ * - Development setups where users manually trust the certificate
23
+ *
24
+ * For browser use, provide valid certificates via --cert and --key flags.
17
25
  * @param hostname - Optional hostname to include in the certificate (default: localhost)
18
26
  * @returns Object containing PEM-encoded certificate and private key
27
+ * @internal
19
28
  */
20
29
  export function generateSelfSignedCert(hostname = "localhost"): GeneratedCert {
21
30
  const attrs = [
@@ -313,6 +313,8 @@ export class ConsoleCaptureUI {
313
313
  */
314
314
  private setupGlobalMethods(): void {
315
315
  if (typeof window !== "undefined") {
316
+ // Create a function that returns current logs to avoid stale reference
317
+ const getCurrentLogs = (): CapturedLogEntry[] => [...this.logs];
316
318
  window.__console__ = {
317
319
  copy: () => this.copyLogs(),
318
320
  download: () => {
@@ -322,7 +324,10 @@ export class ConsoleCaptureUI {
322
324
  this.clearLogs();
323
325
  },
324
326
  get: () => this.getLogs(),
325
- logs: this.logs,
327
+ // Use getter to always return fresh copy of current logs
328
+ get logs() {
329
+ return getCurrentLogs();
330
+ },
326
331
  };
327
332
  }
328
333
  }