@graphty/remote-logger 1.2.2 → 1.3.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.
Files changed (84) hide show
  1. package/README.md +53 -2
  2. package/dist/bundle/browser-entry.d.ts +34 -0
  3. package/dist/bundle/browser-entry.d.ts.map +1 -0
  4. package/dist/bundle/browser-entry.js +129 -0
  5. package/dist/bundle/browser-entry.js.map +1 -0
  6. package/dist/client/RemoteLogClient.d.ts +2 -1
  7. package/dist/client/RemoteLogClient.d.ts.map +1 -1
  8. package/dist/client/RemoteLogClient.js +20 -10
  9. package/dist/client/RemoteLogClient.js.map +1 -1
  10. package/dist/client/types.d.ts +6 -0
  11. package/dist/client/types.d.ts.map +1 -1
  12. package/dist/mcp/index.d.ts +1 -1
  13. package/dist/mcp/index.d.ts.map +1 -1
  14. package/dist/mcp/index.js +20 -1
  15. package/dist/mcp/index.js.map +1 -1
  16. package/dist/mcp/mcp-server.d.ts +5 -0
  17. package/dist/mcp/mcp-server.d.ts.map +1 -1
  18. package/dist/mcp/mcp-server.js +54 -8
  19. package/dist/mcp/mcp-server.js.map +1 -1
  20. package/dist/mcp/tools/index.d.ts +6 -6
  21. package/dist/mcp/tools/index.d.ts.map +1 -1
  22. package/dist/mcp/tools/index.js +1 -1
  23. package/dist/mcp/tools/index.js.map +1 -1
  24. package/dist/mcp/tools/logs-clear.d.ts +1 -2
  25. package/dist/mcp/tools/logs-clear.d.ts.map +1 -1
  26. package/dist/mcp/tools/logs-get-all.d.ts +1 -2
  27. package/dist/mcp/tools/logs-get-all.d.ts.map +1 -1
  28. package/dist/mcp/tools/logs-get-errors.d.ts +1 -2
  29. package/dist/mcp/tools/logs-get-errors.d.ts.map +1 -1
  30. package/dist/mcp/tools/logs-get-file-path.d.ts +8 -2
  31. package/dist/mcp/tools/logs-get-file-path.d.ts.map +1 -1
  32. package/dist/mcp/tools/logs-get-file-path.js +1 -1
  33. package/dist/mcp/tools/logs-get-file-path.js.map +1 -1
  34. package/dist/mcp/tools/logs-receive.d.ts +9 -10
  35. package/dist/mcp/tools/logs-receive.d.ts.map +1 -1
  36. package/dist/mcp/tools/logs-search.d.ts +1 -2
  37. package/dist/mcp/tools/logs-search.d.ts.map +1 -1
  38. package/dist/mcp/tools/logs-status.d.ts.map +1 -1
  39. package/dist/mcp/tools/logs-status.js +4 -1
  40. package/dist/mcp/tools/logs-status.js.map +1 -1
  41. package/dist/remote-logger.browser.js +166 -0
  42. package/dist/remote-logger.browser.js.map +1 -0
  43. package/dist/server/dual-server.d.ts +7 -4
  44. package/dist/server/dual-server.d.ts.map +1 -1
  45. package/dist/server/dual-server.js +124 -50
  46. package/dist/server/dual-server.js.map +1 -1
  47. package/dist/server/log-server.d.ts +12 -2
  48. package/dist/server/log-server.d.ts.map +1 -1
  49. package/dist/server/log-server.js +109 -63
  50. package/dist/server/log-server.js.map +1 -1
  51. package/dist/server/log-storage.d.ts +4 -0
  52. package/dist/server/log-storage.d.ts.map +1 -1
  53. package/dist/server/log-storage.js.map +1 -1
  54. package/dist/server/proxy.d.ts +39 -0
  55. package/dist/server/proxy.d.ts.map +1 -0
  56. package/dist/server/proxy.js +290 -0
  57. package/dist/server/proxy.js.map +1 -0
  58. package/dist/server/self-signed-cert.d.ts +9 -0
  59. package/dist/server/self-signed-cert.d.ts.map +1 -1
  60. package/dist/server/self-signed-cert.js +9 -0
  61. package/dist/server/self-signed-cert.js.map +1 -1
  62. package/dist/ui/ConsoleCaptureUI.d.ts.map +1 -1
  63. package/dist/ui/ConsoleCaptureUI.js +6 -1
  64. package/dist/ui/ConsoleCaptureUI.js.map +1 -1
  65. package/package.json +7 -2
  66. package/src/bundle/browser-entry.ts +157 -0
  67. package/src/client/RemoteLogClient.ts +22 -11
  68. package/src/client/types.ts +6 -0
  69. package/src/mcp/index.ts +42 -0
  70. package/src/mcp/mcp-server.ts +54 -8
  71. package/src/mcp/tools/index.ts +7 -0
  72. package/src/mcp/tools/logs-clear.ts +1 -1
  73. package/src/mcp/tools/logs-get-all.ts +1 -1
  74. package/src/mcp/tools/logs-get-errors.ts +1 -1
  75. package/src/mcp/tools/logs-get-file-path.ts +2 -2
  76. package/src/mcp/tools/logs-receive.ts +1 -1
  77. package/src/mcp/tools/logs-search.ts +1 -1
  78. package/src/mcp/tools/logs-status.ts +4 -1
  79. package/src/server/dual-server.ts +165 -57
  80. package/src/server/log-server.ts +126 -68
  81. package/src/server/log-storage.ts +4 -0
  82. package/src/server/proxy.ts +357 -0
  83. package/src/server/self-signed-cert.ts +9 -0
  84. package/src/ui/ConsoleCaptureUI.ts +6 -1
@@ -0,0 +1,357 @@
1
+ /**
2
+ * Reverse proxy with automatic remote-logger injection.
3
+ *
4
+ * Proxies requests to a target URL and injects the remote-logger script
5
+ * into HTML responses. Non-HTML responses pass through unmodified.
6
+ * @module server/proxy
7
+ */
8
+
9
+ import type * as http from "http";
10
+ import httpProxy from "http-proxy";
11
+ import * as https from "https";
12
+ import { defaultTreeAdapter, type DefaultTreeAdapterMap,html as parse5Html, parse, serialize } from "parse5";
13
+
14
+ const HEADERS_TO_REMOVE = [
15
+ "content-security-policy",
16
+ "content-security-policy-report-only",
17
+ "x-frame-options",
18
+ "strict-transport-security",
19
+ "content-length",
20
+ "content-encoding",
21
+ "transfer-encoding",
22
+ "link",
23
+ "etag",
24
+ "cache-control",
25
+ "age",
26
+ "server",
27
+ "x-sveltekit-page",
28
+ "x-vercel-cache",
29
+ "x-vercel-id",
30
+ "x-powered-by",
31
+ "via",
32
+ ];
33
+
34
+ /**
35
+ * Extract the origin (protocol + host) from a full URL.
36
+ * @param targetUrl - The full target URL
37
+ * @returns The origin portion of the URL
38
+ */
39
+ function extractOrigin(targetUrl: string): string {
40
+ const parsed = new URL(targetUrl);
41
+ return parsed.origin;
42
+ }
43
+
44
+ /**
45
+ * Extract the path portion from a full URL.
46
+ * @param targetUrl - The full target URL
47
+ * @returns The path including query string
48
+ */
49
+ function extractPath(targetUrl: string): string {
50
+ const parsed = new URL(targetUrl);
51
+ return parsed.pathname + parsed.search;
52
+ }
53
+
54
+
55
+ /**
56
+ * Rewrite a Location header to route back through the proxy.
57
+ * @param location - The original Location header value
58
+ * @param proxyBaseUrl - The proxy base URL (e.g., "/proxy/")
59
+ * @returns The rewritten Location header
60
+ */
61
+ function rewriteLocationHeader(location: string, proxyBaseUrl: string): string {
62
+ if (location.startsWith("http://") || location.startsWith("https://")) {
63
+ return proxyBaseUrl + location;
64
+ }
65
+ return location;
66
+ }
67
+
68
+ /**
69
+ * Rewrite Set-Cookie attributes for proxied responses.
70
+ * Removes Domain (wrong domain) and Secure (proxy may serve over HTTP).
71
+ * @param cookie - The original Set-Cookie header value
72
+ * @returns The rewritten cookie
73
+ */
74
+ function rewriteSetCookie(cookie: string): string {
75
+ let rewritten = cookie.replace(/;\s*domain=[^;]*/gi, "");
76
+ rewritten = rewritten.replace(/;\s*secure/gi, "");
77
+ return rewritten;
78
+ }
79
+
80
+ type Element = DefaultTreeAdapterMap["element"];
81
+ type Document = DefaultTreeAdapterMap["document"];
82
+
83
+ /**
84
+ * Find the first element with a given tag name in the AST.
85
+ * @param node - The AST node to search from
86
+ * @param tagName - The tag name to find
87
+ * @returns The element or null
88
+ */
89
+ function findElement(node: Document | Element, tagName: string): Element | null {
90
+ if (defaultTreeAdapter.isElementNode(node) && node.tagName === tagName) {
91
+ return node;
92
+ }
93
+ const children = node.childNodes ?? [];
94
+ for (const child of children) {
95
+ if (defaultTreeAdapter.isElementNode(child)) {
96
+ const found = findElement(child, tagName);
97
+ if (found) {
98
+ return found;
99
+ }
100
+ }
101
+ }
102
+ return null;
103
+ }
104
+
105
+ /**
106
+ * Strip integrity attributes from all script and link elements in the AST.
107
+ * @param node - The AST node to search from
108
+ */
109
+ function stripIntegrityAttributes(node: Document | Element): void {
110
+ if (defaultTreeAdapter.isElementNode(node)) {
111
+ if (node.tagName === "script" || node.tagName === "link") {
112
+ node.attrs = node.attrs.filter(a => a.name !== "integrity");
113
+ }
114
+ }
115
+ const children = node.childNodes ?? [];
116
+ for (const child of children) {
117
+ if (defaultTreeAdapter.isElementNode(child)) {
118
+ stripIntegrityAttributes(child);
119
+ }
120
+ }
121
+ }
122
+
123
+ const XHTML_NS = parse5Html.NS.HTML;
124
+
125
+ const CHROME_UA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36";
126
+
127
+ /**
128
+ * Inject the remote-logger script and base tag into HTML using parse5.
129
+ * Inserts base at start of head, script at end of head, and strips
130
+ * integrity attributes from script/link elements.
131
+ * @param html - The original HTML string
132
+ * @param scriptUrl - URL to the remote-logger script
133
+ * @param baseHref - Base href for rewriting absolute paths
134
+ * @returns The modified HTML
135
+ */
136
+ export function injectScript(html: string, scriptUrl: string, baseHref: string): string {
137
+ const doc = parse(html);
138
+
139
+ const head = findElement(doc, "head");
140
+ if (head) {
141
+ const baseEl = defaultTreeAdapter.createElement("base", XHTML_NS, [{ name: "href", value: baseHref }]);
142
+ const scriptEl = defaultTreeAdapter.createElement("script", XHTML_NS, [{ name: "src", value: scriptUrl }]);
143
+
144
+ // Insert base at start of head (before any relative URLs)
145
+ if (head.childNodes.length > 0) {
146
+ defaultTreeAdapter.insertBefore(head, baseEl, head.childNodes[0]);
147
+ } else {
148
+ defaultTreeAdapter.appendChild(head, baseEl);
149
+ }
150
+
151
+ // Append script at end of head
152
+ defaultTreeAdapter.appendChild(head, scriptEl);
153
+ }
154
+
155
+ stripIntegrityAttributes(doc);
156
+
157
+ return serialize(doc);
158
+ }
159
+
160
+ /**
161
+ * Check if a content-type header indicates HTML.
162
+ * @param contentType - The Content-Type header value
163
+ * @returns True if the content is HTML
164
+ */
165
+ function isHtmlContentType(contentType: string | undefined): boolean {
166
+ if (!contentType) {
167
+ return false;
168
+ }
169
+ return contentType.includes("text/html");
170
+ }
171
+
172
+ /**
173
+ * Validate that a URL is a valid proxy target.
174
+ * @param targetUrl - The URL to validate
175
+ * @returns True if the URL is valid for proxying
176
+ */
177
+ export function isValidProxyTarget(targetUrl: string): boolean {
178
+ try {
179
+ const parsed = new URL(targetUrl);
180
+ return parsed.protocol === "http:" || parsed.protocol === "https:";
181
+ } catch {
182
+ return false;
183
+ }
184
+ }
185
+
186
+ /** Result of creating a proxy instance. */
187
+ export interface ProxyInstance {
188
+ /** Handle an incoming proxy request. */
189
+ handleRequest: (
190
+ req: http.IncomingMessage,
191
+ res: http.ServerResponse,
192
+ targetUrl: string,
193
+ requestProtocol: string,
194
+ requestHost: string,
195
+ ) => void;
196
+ /** Close the proxy and free resources. */
197
+ close: () => void;
198
+ }
199
+
200
+ /**
201
+ * Create a proxy instance that injects the remote-logger into HTML responses.
202
+ * @param proxyBasePath - Base path for the proxy (e.g., "/proxy/")
203
+ * @param quiet - Suppress terminal output
204
+ * @returns A proxy instance with handleRequest and close methods
205
+ */
206
+ export function createProxy(proxyBasePath: string, quiet: boolean): ProxyInstance {
207
+ // Custom TLS agent to avoid JA3 fingerprint-based blocking by CDNs.
208
+ // Fastly, Akamai, etc. blocklist Node.js's default TLS fingerprint.
209
+ // Using a custom sigalgs list changes the fingerprint without restricting
210
+ // TLS version, so sites that only support TLS 1.2 still work.
211
+ const httpsAgent = new https.Agent({
212
+ rejectUnauthorized: false,
213
+ sigalgs: "ecdsa_secp256r1_sha256:rsa_pss_rsae_sha256:rsa_pkcs1_sha256:ecdsa_secp384r1_sha384:rsa_pss_rsae_sha384:rsa_pkcs1_sha384:rsa_pss_rsae_sha512:rsa_pkcs1_sha512",
214
+ });
215
+
216
+ const proxy = httpProxy.createProxyServer({
217
+ selfHandleResponse: true,
218
+ changeOrigin: true,
219
+ followRedirects: false,
220
+ secure: false,
221
+ });
222
+
223
+ proxy.on("proxyRes", (proxyRes, req, res) => {
224
+ const chunks: Buffer[] = [];
225
+
226
+ proxyRes.on("data", (chunk: Buffer) => {
227
+ chunks.push(chunk);
228
+ });
229
+
230
+ proxyRes.on("end", () => {
231
+ const body = Buffer.concat(chunks);
232
+
233
+ // Copy headers with modifications
234
+ const headers: Record<string, string | string[]> = {};
235
+ for (const [key, value] of Object.entries(proxyRes.headers)) {
236
+ if (value === undefined) {
237
+ continue;
238
+ }
239
+ if (HEADERS_TO_REMOVE.includes(key.toLowerCase())) {
240
+ continue;
241
+ }
242
+
243
+ if (key.toLowerCase() === "location" && typeof value === "string") {
244
+ headers[key] = rewriteLocationHeader(value, proxyBasePath);
245
+ continue;
246
+ }
247
+
248
+ if (key.toLowerCase() === "set-cookie" && Array.isArray(value)) {
249
+ headers[key] = value.map(rewriteSetCookie);
250
+ continue;
251
+ }
252
+
253
+ headers[key] = value;
254
+ }
255
+
256
+ const contentType = proxyRes.headers["content-type"];
257
+ const statusCode = proxyRes.statusCode ?? 200;
258
+
259
+ if (isHtmlContentType(contentType)) {
260
+ const html = body.toString("utf-8");
261
+ const reqExtra = req as http.IncomingMessage & { _proxyTargetUrl?: string; _proxyScriptUrl?: string };
262
+ const originalUrl = reqExtra._proxyTargetUrl;
263
+ const reqScriptUrl = reqExtra._proxyScriptUrl ?? "/remote-logger.js";
264
+ const baseHref = originalUrl ? `${proxyBasePath}${extractOrigin(originalUrl)}/` : proxyBasePath;
265
+ const modified = injectScript(html, reqScriptUrl, baseHref);
266
+
267
+ headers["content-type"] = "text/html; charset=utf-8";
268
+ headers["cache-control"] = "no-cache, no-store";
269
+
270
+ res.writeHead(statusCode, headers);
271
+ res.end(modified);
272
+ } else {
273
+ if (body.length > 0) {
274
+ headers["content-length"] = String(body.length);
275
+ }
276
+ res.writeHead(statusCode, headers);
277
+ res.end(body);
278
+ }
279
+ });
280
+ });
281
+
282
+ proxy.on("error", (err, _req, res) => {
283
+ const isTlsError = err.message.includes("SSL") ||
284
+ err.message.includes("TLS") ||
285
+ err.message.includes("EPROTO");
286
+
287
+ const message = isTlsError
288
+ ? `TLS connection failed: ${err.message}`
289
+ : err.message;
290
+
291
+ if (!quiet) {
292
+ console.error(`[proxy] Error: ${message}`);
293
+ }
294
+
295
+ const serverRes = res as http.ServerResponse;
296
+ if (!serverRes.headersSent) {
297
+ serverRes.writeHead(502, { "Content-Type": "application/json" });
298
+ }
299
+ serverRes.end(JSON.stringify({ error: "Bad Gateway", message }));
300
+ });
301
+
302
+ const handleRequest = (
303
+ req: http.IncomingMessage,
304
+ res: http.ServerResponse,
305
+ targetUrl: string,
306
+ requestProtocol: string,
307
+ requestHost: string,
308
+ ): void => {
309
+ if (!isValidProxyTarget(targetUrl)) {
310
+ res.writeHead(400, { "Content-Type": "application/json" });
311
+ res.end(JSON.stringify({ error: "Invalid target URL. Must start with http:// or https://" }));
312
+ return;
313
+ }
314
+
315
+ const target = extractOrigin(targetUrl);
316
+ const targetPath = extractPath(targetUrl);
317
+
318
+ // Stash per-request data for the proxyRes handler
319
+ const reqExtra = req as http.IncomingMessage & { _proxyTargetUrl?: string; _proxyScriptUrl?: string };
320
+ reqExtra._proxyTargetUrl = targetUrl;
321
+ reqExtra._proxyScriptUrl = `${requestProtocol}://${requestHost}/remote-logger.js`;
322
+
323
+ // Override the request path to the target's path
324
+ req.url = targetPath;
325
+
326
+ // Request uncompressed content so we can modify HTML
327
+ req.headers["accept-encoding"] = "identity";
328
+
329
+ // Strip headers that reveal this is a cross-origin/proxy/headless request.
330
+ // CDNs like Fastly use these for bot detection.
331
+ delete req.headers["sec-fetch-site"];
332
+ delete req.headers["sec-fetch-mode"];
333
+ delete req.headers["sec-fetch-dest"];
334
+ delete req.headers["sec-fetch-user"];
335
+ delete req.headers["sec-ch-ua"];
336
+ delete req.headers["sec-ch-ua-mobile"];
337
+ delete req.headers["sec-ch-ua-platform"];
338
+ delete req.headers.origin;
339
+ delete req.headers.referer;
340
+
341
+ // Use a standard Chrome UA to avoid bot detection
342
+ req.headers["user-agent"] = CHROME_UA;
343
+
344
+ const proxyOptions: httpProxy.ServerOptions = { target };
345
+ if (targetUrl.startsWith("https://")) {
346
+ proxyOptions.agent = httpsAgent;
347
+ }
348
+
349
+ proxy.web(req, res, proxyOptions);
350
+ };
351
+
352
+ const close = (): void => {
353
+ proxy.close();
354
+ };
355
+
356
+ return { handleRequest, close };
357
+ }
@@ -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
  }