From fc9dc1760e2a21f1b650daf54e07343225de5015 Mon Sep 17 00:00:00 2001 From: Abhijith V Date: Sat, 14 Feb 2026 15:20:26 +0530 Subject: [PATCH] feat: add request inspector with API composer and cURL export - Request inspector web UI with filtering, detail views, and resend - API Composer (Postman-like) to build and send HTTP requests - Edit in Composer to pre-fill from any captured request - cURL export with copy button on request detail pages - SQLite-backed request/response logging --- README.md | 27 + package-lock.json | 432 ++++++++++-- packages/client/README.md | 25 +- packages/client/package.json | 5 +- packages/client/src/@types/index.d.ts | 71 +- packages/client/src/index.ts | 18 +- packages/client/src/lib/db.ts | 200 ++++++ packages/client/src/lib/inspector.ts | 946 ++++++++++++++++++++++++++ packages/client/src/lib/socket.ts | 131 +++- packages/server/src/index.ts | 4 +- packages/server/src/lib/tunnel.ts | 1 + 11 files changed, 1806 insertions(+), 54 deletions(-) create mode 100644 packages/client/src/lib/db.ts create mode 100644 packages/client/src/lib/inspector.ts diff --git a/README.md b/README.md index acbec25..0fff121 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,9 @@ That's it! Your local server running on port 3000 is now accessible from the int ## Features - **Instant Setup** - No account required, just run one command +- **Request Inspector** - Built-in web UI to monitor all proxied requests and responses in real-time +- **API Composer** - Postman-like request builder inside the inspector to craft and send HTTP requests +- **cURL Export** - Generate copy-ready cURL commands from any captured request - **Token Protection** - Secure your tunnels with authentication tokens - **Self-Hostable** - Run your own ProxyHub server - **Session Timeouts** - Configurable session duration limits @@ -49,6 +52,28 @@ proxyhub -p 3000 proxyhub -p 3000 --debug ``` +### Request Inspector + +Enable the built-in inspector to monitor all proxied requests and responses through a local web UI: + +```bash +# Enable inspector (opens on port + 1000 by default) +proxyhub -p 3000 --inspect + +# Custom inspector port +proxyhub -p 3000 --inspect --inspect-port 9000 +``` + +Once enabled, open the inspector URL shown in the terminal (e.g., `http://localhost:4000`) to view requests in real-time. + +Inspector features: +- Filter by method, status code, and path +- View full request/response headers and bodies +- Resend captured requests with one click +- **API Composer** — click "Compose" to build requests from scratch, or "Edit in Composer" on any captured request to pre-fill the form with its method, URL, headers, and body +- **cURL export** — expand the cURL section on any request detail page to get a ready-to-copy command +- Light/dark theme + ### Token Protection Secure your tunnel so only requests with the correct token can access it: @@ -73,6 +98,8 @@ curl -H "X-Proxy-Token: mysecrettoken" https://your-tunnel.proxyhub.cloud/ |--------|-------------| | `-p, --port ` | Port number to proxy (required) | | `-t, --token ` | Token for tunnel protection | +| `-i, --inspect` | Enable request inspector UI | +| `--inspect-port ` | Port for inspector UI (default: port + 1000) | | `-d, --debug` | Enable debug mode | | `-V, --version` | Output version number | | `-h, --help` | Display help | diff --git a/package-lock.json b/package-lock.json index 58d34e2..141d7e7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1163,10 +1163,6 @@ "node": ">=12" } }, - "node_modules/@proxyhub/client": { - "resolved": "packages/client", - "link": true - }, "node_modules/@proxyhub/server": { "resolved": "packages/server", "link": true @@ -1232,6 +1228,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/better-sqlite3": { + "version": "7.6.13", + "resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz", + "integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/body-parser": { "version": "1.19.6", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", @@ -1535,7 +1541,6 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", - "dev": true, "license": "MIT" }, "node_modules/ast-types": { @@ -1579,6 +1584,26 @@ "dev": true, "license": "MIT" }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/base64id": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/base64id/-/base64id-2.0.0.tgz", @@ -1605,6 +1630,17 @@ "dev": true, "license": "Apache-2.0" }, + "node_modules/better-sqlite3": { + "version": "11.10.0", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-11.10.0.tgz", + "integrity": "sha512-EwhOpyXiOEL/lKzHz9AW1msWFNzGc/z+LzeB3/jnFJpxu+th2yqvzsSWas1v9jgs9+xiXJcD5A8CJxAG2TaghQ==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "bindings": "^1.5.0", + "prebuild-install": "^7.1.1" + } + }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", @@ -1618,6 +1654,26 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "license": "MIT", + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, "node_modules/body-parser": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.0.tgz", @@ -1729,6 +1785,30 @@ "node": ">=8" } }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, "node_modules/bundle-name": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", @@ -1850,6 +1930,12 @@ "fsevents": "~2.3.2" } }, + "node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "license": "ISC" + }, "node_modules/ci-info": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.4.0.tgz", @@ -2131,11 +2217,25 @@ } } }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/deep-extend": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", - "dev": true, "license": "MIT", "engines": { "node": ">=4.0.0" @@ -2212,13 +2312,21 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.8", "npm": "1.2.8000 || >= 1.4.16" } }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, "node_modules/diff": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", @@ -2306,6 +2414,15 @@ "node": ">= 0.8" } }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, "node_modules/engine.io": { "version": "6.6.4", "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.4.tgz", @@ -2627,6 +2744,15 @@ "url": "https://github.com/sindresorhus/execa?sponsor=1" } }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "license": "(MIT OR WTFPL)", + "engines": { + "node": ">=6" + } + }, "node_modules/express": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/express/-/express-5.1.0.tgz", @@ -2729,6 +2855,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "license": "MIT" + }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", @@ -2777,6 +2909,12 @@ "node": ">= 0.8" } }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "license": "MIT" + }, "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", @@ -2924,6 +3062,12 @@ "git-up": "^8.0.0" } }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "license": "MIT" + }, "node_modules/glob": { "version": "7.2.3", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", @@ -3137,6 +3281,26 @@ "node": ">=0.10.0" } }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -3747,7 +3911,6 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.6" @@ -3771,7 +3934,6 @@ "version": "1.6.0", "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", - "dev": true, "license": "MIT", "bin": { "mime": "cli.js" @@ -3827,6 +3989,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/minimatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", @@ -3844,12 +4018,17 @@ "version": "1.2.8", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", - "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "license": "MIT" + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -3866,6 +4045,12 @@ "node": "^18.17.0 || >=20.5.0" } }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "license": "MIT" + }, "node_modules/negotiator": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", @@ -3914,6 +4099,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/node-abi": { + "version": "3.87.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.87.0.tgz", + "integrity": "sha512-+CGM1L1CgmtheLcBuleyYOn7NWPVu0s0EJH2C4puxgEZb9h8QpR9G2dBfZJOAUhi7VQxuBPMd0hiISWcTyiYyQ==", + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/nodemon": { "version": "3.1.10", "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.10.tgz", @@ -4314,6 +4511,32 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/pretty-ms": { "version": "9.3.0", "resolved": "https://registry.npmjs.org/pretty-ms/-/pretty-ms-9.3.0.tgz", @@ -4384,6 +4607,10 @@ "dev": true, "license": "MIT" }, + "node_modules/proxyhub": { + "resolved": "packages/client", + "link": true + }, "node_modules/pstree.remy": { "version": "1.1.8", "resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz", @@ -4391,6 +4618,16 @@ "dev": true, "license": "MIT" }, + "node_modules/pump": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz", + "integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, "node_modules/pupa": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/pupa/-/pupa-3.3.0.tgz", @@ -4471,7 +4708,6 @@ "version": "1.2.8", "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", - "dev": true, "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", "dependencies": { "deep-extend": "^0.6.0", @@ -4487,9 +4723,22 @@ "version": "1.3.8", "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", - "dev": true, "license": "ISC" }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/readdirp": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", @@ -4812,7 +5061,6 @@ "version": "7.7.2", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", - "dev": true, "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -4990,6 +5238,51 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, "node_modules/simple-update-notifier": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", @@ -5266,6 +5559,15 @@ "component-emitter": "^2.0.0" } }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, "node_modules/string-width": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", @@ -5317,7 +5619,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -5366,6 +5667,34 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/tar-fs": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", + "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", + "license": "MIT", + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -5469,6 +5798,18 @@ "fsevents": "~2.3.3" } }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, "node_modules/type-fest": { "version": "0.21.3", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", @@ -5597,11 +5938,16 @@ "node": "^12.20.0 || ^14.13.1 || >=16.0.0" } }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, "node_modules/utils-merge": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4.0" @@ -5971,27 +6317,56 @@ } }, "packages/client": { - "name": "@proxyhub/client", - "version": "0.0.1", + "name": "proxyhub", + "version": "0.0.2", "license": "MIT", "dependencies": { + "better-sqlite3": "^11.0.0", "chalk": "^5.3.0", "commander": "^12.0.0", "dotenv": "^16.4.5", + "express": "^4.18.2", "socket.io-client": "^4.8.1" }, "bin": { "proxyhub": "dist/index.js" }, "devDependencies": { + "@types/better-sqlite3": "^7.6.8", + "@types/express": "^4.17.21", "@types/node": "^20.0.0", - "express": "^4.18.2", "nodemon": "^3.1.10", "ts-node": "^10.9.2", "tsx": "^4.0.0", "typescript": "^5.8.3" } }, + "packages/client/node_modules/@types/express": { + "version": "4.17.25", + "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.25.tgz", + "integrity": "sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^4.17.33", + "@types/qs": "*", + "@types/serve-static": "^1" + } + }, + "packages/client/node_modules/@types/express-serve-static-core": { + "version": "4.19.8", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.8.tgz", + "integrity": "sha512-02S5fmqeoKzVZCHPZid4b8JH2eM5HzQLZWN2FohQEy/0eXTq8VXZfSN6Pcr3F6N9R/vNrj7cpgbhjie6m/1tCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, "packages/client/node_modules/@types/node": { "version": "20.19.4", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.4.tgz", @@ -6006,7 +6381,6 @@ "version": "1.3.8", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", - "dev": true, "license": "MIT", "dependencies": { "mime-types": "~2.1.34", @@ -6020,7 +6394,6 @@ "version": "1.20.4", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", "integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==", - "dev": true, "license": "MIT", "dependencies": { "bytes": "~3.1.2", @@ -6045,7 +6418,6 @@ "version": "0.5.4", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", - "dev": true, "license": "MIT", "dependencies": { "safe-buffer": "5.2.1" @@ -6058,14 +6430,12 @@ "version": "1.0.7", "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", - "dev": true, "license": "MIT" }, "packages/client/node_modules/debug": { "version": "2.6.9", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "dev": true, "license": "MIT", "dependencies": { "ms": "2.0.0" @@ -6075,7 +6445,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "dev": true, "license": "MIT" }, "packages/client/node_modules/dotenv": { @@ -6094,7 +6463,6 @@ "version": "4.22.1", "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", - "dev": true, "license": "MIT", "dependencies": { "accepts": "~1.3.8", @@ -6141,7 +6509,6 @@ "version": "1.3.2", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", - "dev": true, "license": "MIT", "dependencies": { "debug": "2.6.9", @@ -6160,7 +6527,6 @@ "version": "0.5.2", "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.6" @@ -6170,7 +6536,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", - "dev": true, "license": "MIT", "dependencies": { "depd": "~2.0.0", @@ -6191,7 +6556,6 @@ "version": "0.4.24", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", - "dev": true, "license": "MIT", "dependencies": { "safer-buffer": ">= 2.1.2 < 3" @@ -6204,7 +6568,6 @@ "version": "0.3.0", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.6" @@ -6214,7 +6577,6 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", - "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -6224,7 +6586,6 @@ "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.6" @@ -6234,7 +6595,6 @@ "version": "2.1.35", "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "dev": true, "license": "MIT", "dependencies": { "mime-db": "1.52.0" @@ -6247,7 +6607,6 @@ "version": "0.6.3", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.6" @@ -6257,14 +6616,12 @@ "version": "0.1.12", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", - "dev": true, "license": "MIT" }, "packages/client/node_modules/raw-body": { "version": "2.5.3", "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", - "dev": true, "license": "MIT", "dependencies": { "bytes": "~3.1.2", @@ -6280,7 +6637,6 @@ "version": "0.19.2", "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", - "dev": true, "license": "MIT", "dependencies": { "debug": "2.6.9", @@ -6305,7 +6661,6 @@ "version": "1.16.3", "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", - "dev": true, "license": "MIT", "dependencies": { "encodeurl": "~2.0.0", @@ -6321,7 +6676,6 @@ "version": "1.6.18", "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", - "dev": true, "license": "MIT", "dependencies": { "media-typer": "0.3.0", diff --git a/packages/client/README.md b/packages/client/README.md index 67ca1de..f9f0447 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -37,6 +37,28 @@ proxyhub -p proxyhub -p 3000 ``` +### Request Inspector + +Enable the built-in inspector to monitor all proxied requests and responses through a local web UI: + +```bash +# Enable inspector (opens on port + 1000 by default) +proxyhub -p 3000 --inspect + +# Custom inspector port +proxyhub -p 3000 --inspect --inspect-port 9000 +``` + +Once enabled, open the inspector URL shown in the terminal (e.g., `http://localhost:4000`) to view requests in real-time. + +Inspector features: +- Filter by method, status code, and path +- View full request/response headers and bodies +- Resend captured requests with one click +- **API Composer** — click "Compose" to build requests from scratch, or "Edit in Composer" on any captured request to pre-fill the form with its method, URL, headers, and body +- **cURL export** — expand the cURL section on any request detail page to get a ready-to-copy command +- Light/dark theme + ### Token Protection Secure your tunnel so only requests with the correct token can access it: @@ -63,8 +85,9 @@ proxyhub -p 3000 --debug |--------|-------------| | `-p, --port ` | Port number to proxy (required) | | `-t, --token ` | Token for tunnel protection | +| `-i, --inspect` | Enable request inspector UI | +| `--inspect-port ` | Port for inspector UI (default: port + 1000) | | `-d, --debug` | Enable debug mode | -| `-keep, --keep-history` | Keep request history on disconnect | | `-V, --version` | Output version number | | `-h, --help` | Display help | diff --git a/packages/client/package.json b/packages/client/package.json index c7ba643..41d5ab3 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -26,14 +26,17 @@ "author": "", "license": "MIT", "devDependencies": { + "@types/better-sqlite3": "^7.6.8", + "@types/express": "^4.17.21", "@types/node": "^20.0.0", - "express": "^4.18.2", "nodemon": "^3.1.10", "ts-node": "^10.9.2", "tsx": "^4.0.0", "typescript": "^5.8.3" }, "dependencies": { + "better-sqlite3": "^11.0.0", + "express": "^4.18.2", "socket.io-client": "^4.8.1", "commander": "^12.0.0", "chalk": "^5.3.0", diff --git a/packages/client/src/@types/index.d.ts b/packages/client/src/@types/index.d.ts index d442e1e..3c35091 100644 --- a/packages/client/src/@types/index.d.ts +++ b/packages/client/src/@types/index.d.ts @@ -3,6 +3,8 @@ declare global { port: number; debug?: boolean; token?: string; + inspect?: boolean; + inspectPort?: number; } interface TunnelRequestArgument { @@ -10,6 +12,7 @@ declare global { headers: Record; path: string; body?: any; + clientIp?: string; } interface ResponseHeaderInfo { @@ -19,6 +22,72 @@ declare global { httpVersion?: string; } + interface RequestLogInsert { + id: string; + method: string; + path: string; + headers: string; + body?: string; + client_ip?: string; + created_at: string; + } + + interface ResponseHeadersUpdate { + id: string; + status_code?: number; + status_message?: string; + response_headers: string; + http_version?: string; + } + + interface RequestCompleteUpdate { + id: string; + response_body_size: number; + response_body?: string; + duration_ms: number; + completed_at: string; + } + + interface RequestErrorUpdate { + id: string; + error: string; + } + + interface RequestLogRecord { + id: string; + method: string; + path: string; + headers: string; + body: string | null; + client_ip: string | null; + status_code: number | null; + status_message: string | null; + response_headers: string | null; + response_body: string | null; + http_version: string | null; + response_body_size: number; + duration_ms: number | null; + error: string | null; + created_at: string; + completed_at: string | null; + } + + interface RequestQueryFilters { + page?: number; + limit?: number; + method?: string; + status?: number; + path?: string; + since?: string; + } + + interface RequestStats { + total: number; + success: number; + error: number; + avg_duration_ms: number | null; + } + } -export {}; \ No newline at end of file +export {}; \ No newline at end of file diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 2999734..72ed47e 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -7,6 +7,7 @@ import * as path from 'path'; import chalk from 'chalk'; import { portNumberCustomValidationForCommander } from './utils/index.js'; import socketHandler from './lib/socket.js'; +import { startInspector } from './lib/inspector.js'; import 'dotenv/config'; // Get package.json version @@ -24,7 +25,9 @@ program .version(version) .requiredOption('-p, --port ', 'Port number for proxying', portNumberCustomValidationForCommander) .option('-d, --debug', 'Enable debug mode', false) - .option('-t, --token ', 'Token for tunnel protection'); + .option('-t, --token ', 'Token for tunnel protection') + .option('-i, --inspect', 'Enable request inspector', false) + .option('--inspect-port ', 'Port for inspector UI', parseInt); // Parse command line arguments program.parse(process.argv); @@ -34,7 +37,9 @@ const parsedOpts = program.opts() as ClientInitializationOptions; const options: ClientInitializationOptions = { port: parsedOpts.port, debug: parsedOpts.debug, - token: parsedOpts.token || process.env.PROXYHUB_TOKEN + token: parsedOpts.token || process.env.PROXYHUB_TOKEN, + inspect: parsedOpts.inspect, + inspectPort: parsedOpts.inspectPort, }; // Startup logging @@ -46,8 +51,17 @@ if (options.token) { if (options.debug) { console.log('Debug mode:', chalk.green('enabled')); } +if (options.inspect) { + console.log('Inspector:', chalk.green('enabled')); +} console.log(''); // Start the ProxyHub client socketHandler(options); +// Start inspector if enabled +if (options.inspect) { + const inspectPort = options.inspectPort || options.port + 1000; + startInspector(inspectPort, options.port); +} + diff --git a/packages/client/src/lib/db.ts b/packages/client/src/lib/db.ts new file mode 100644 index 0000000..540c3b3 --- /dev/null +++ b/packages/client/src/lib/db.ts @@ -0,0 +1,200 @@ +import Database from 'better-sqlite3'; +import * as path from 'path'; +import * as fs from 'fs'; +import * as os from 'os'; + +let db: Database.Database | null = null; + +const DB_DIR = path.join(os.homedir(), '.proxyhub'); +const DB_PATH = path.join(DB_DIR, 'logs.db'); + +export function initDb(): void { + if (db) return; + + fs.mkdirSync(DB_DIR, { recursive: true }); + + db = new Database(DB_PATH); + db.pragma('journal_mode = WAL'); + + db.exec(` + CREATE TABLE IF NOT EXISTS requests ( + id TEXT PRIMARY KEY, + method TEXT NOT NULL, + path TEXT NOT NULL, + headers TEXT NOT NULL DEFAULT '{}', + body TEXT, + client_ip TEXT, + status_code INTEGER, + status_message TEXT, + response_headers TEXT, + response_body TEXT, + http_version TEXT, + response_body_size INTEGER DEFAULT 0, + duration_ms INTEGER, + error TEXT, + created_at TEXT NOT NULL, + completed_at TEXT + ); + + CREATE INDEX IF NOT EXISTS idx_requests_created_at ON requests(created_at DESC); + CREATE INDEX IF NOT EXISTS idx_requests_method ON requests(method); + CREATE INDEX IF NOT EXISTS idx_requests_status_code ON requests(status_code); + CREATE INDEX IF NOT EXISTS idx_requests_path ON requests(path); + `); + + // Migrate: add response_body column if missing (existing DBs) + try { + db.exec('ALTER TABLE requests ADD COLUMN response_body TEXT'); + } catch { + // Column already exists + } +} + +export function closeDb(): void { + if (db) { + db.close(); + db = null; + } +} + +function getDb(): Database.Database { + if (!db) throw new Error('Database not initialized. Call initDb() first.'); + return db; +} + +export function insertRequest(data: RequestLogInsert): void { + const stmt = getDb().prepare(` + INSERT INTO requests (id, method, path, headers, body, client_ip, created_at) + VALUES (@id, @method, @path, @headers, @body, @client_ip, @created_at) + `); + stmt.run({ + ...data, + body: data.body ?? null, + client_ip: data.client_ip ?? null, + }); +} + +export function updateResponseHeaders(data: ResponseHeadersUpdate): void { + const stmt = getDb().prepare(` + UPDATE requests + SET status_code = @status_code, + status_message = @status_message, + response_headers = @response_headers, + http_version = @http_version + WHERE id = @id + `); + stmt.run(data); +} + +export function completeRequest(data: RequestCompleteUpdate): void { + const stmt = getDb().prepare(` + UPDATE requests + SET response_body_size = @response_body_size, + response_body = @response_body, + duration_ms = @duration_ms, + completed_at = @completed_at + WHERE id = @id + `); + stmt.run({ + ...data, + response_body: data.response_body ?? null, + }); +} + +export function updateRequestError(data: RequestErrorUpdate): void { + const stmt = getDb().prepare(` + UPDATE requests + SET error = @error, + completed_at = @completed_at + WHERE id = @id + `); + stmt.run({ ...data, completed_at: new Date().toISOString() }); +} + +export function getRequests(filters: RequestQueryFilters = {}): RequestLogRecord[] { + const { page = 1, limit = 50, method, status, path: pathFilter, since } = filters; + const conditions: string[] = []; + const params: Record = {}; + + if (method) { + conditions.push('method = @method'); + params.method = method.toUpperCase(); + } + if (status) { + conditions.push('status_code = @status'); + params.status = status; + } + if (pathFilter) { + conditions.push('path LIKE @pathFilter'); + params.pathFilter = `%${pathFilter}%`; + } + if (since) { + conditions.push('created_at >= @since'); + params.since = since; + } + + const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''; + const offset = (page - 1) * limit; + + const stmt = getDb().prepare(` + SELECT * FROM requests ${where} + ORDER BY created_at DESC + LIMIT @limit OFFSET @offset + `); + return stmt.all({ ...params, limit, offset }) as RequestLogRecord[]; +} + +export function getRequestById(id: string): RequestLogRecord | undefined { + const stmt = getDb().prepare('SELECT * FROM requests WHERE id = @id'); + return stmt.get({ id }) as RequestLogRecord | undefined; +} + +export function getRequestCount(filters: RequestQueryFilters = {}): number { + const { method, status, path: pathFilter, since } = filters; + const conditions: string[] = []; + const params: Record = {}; + + if (method) { + conditions.push('method = @method'); + params.method = method.toUpperCase(); + } + if (status) { + conditions.push('status_code = @status'); + params.status = status; + } + if (pathFilter) { + conditions.push('path LIKE @pathFilter'); + params.pathFilter = `%${pathFilter}%`; + } + if (since) { + conditions.push('created_at >= @since'); + params.since = since; + } + + const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''; + const stmt = getDb().prepare(`SELECT COUNT(*) as count FROM requests ${where}`); + const result = stmt.get(params) as { count: number }; + return result.count; +} + +export function getStats(): RequestStats { + const stmt = getDb().prepare(` + SELECT + COUNT(*) as total, + SUM(CASE WHEN status_code >= 200 AND status_code < 400 THEN 1 ELSE 0 END) as success, + SUM(CASE WHEN status_code >= 400 OR error IS NOT NULL THEN 1 ELSE 0 END) as error, + AVG(duration_ms) as avg_duration_ms + FROM requests + `); + const result = stmt.get() as RequestStats; + return { + total: result.total || 0, + success: result.success || 0, + error: result.error || 0, + avg_duration_ms: result.avg_duration_ms ? Math.round(result.avg_duration_ms) : null, + }; +} + +export function clearLogs(): void { + getDb().exec('DELETE FROM requests'); +} diff --git a/packages/client/src/lib/inspector.ts b/packages/client/src/lib/inspector.ts new file mode 100644 index 0000000..94d2866 --- /dev/null +++ b/packages/client/src/lib/inspector.ts @@ -0,0 +1,946 @@ +import express from 'express'; +import * as http from 'http'; +import * as crypto from 'crypto'; +import { + getRequests, + getRequestById, + getRequestCount, + getStats, + clearLogs, + insertRequest, + updateResponseHeaders, + completeRequest, + updateRequestError, +} from './db.js'; + +let tunnelUrl: string | null = null; +let localTargetPort: number = 3000; + +export function setTunnelUrl(url: string): void { + tunnelUrl = url; +} + +function parseJsonFields(record: RequestLogRecord): any { + return { + ...record, + headers: tryParseJson(record.headers), + response_headers: record.response_headers ? tryParseJson(record.response_headers) : null, + }; +} + +function tryParseJson(str: string): any { + try { + return JSON.parse(str); + } catch { + return str; + } +} + +function escapeHtml(str: string): string { + return str + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} + +function statusBadge(code: number | null): string { + if (code === null) return 'pending'; + if (code < 300) return `${code}`; + if (code < 400) return `${code}`; + return `${code}`; +} + +function methodBadge(method: string): string { + const colors: Record = { + GET: 'success', POST: 'warning', PUT: 'outline', PATCH: 'outline', + DELETE: 'danger', HEAD: 'secondary', OPTIONS: 'secondary', + }; + const cls = colors[method.toUpperCase()] || 'secondary'; + return `${escapeHtml(method)}`; +} + +const OAT_CSS = 'https://unpkg.com/@knadh/oat@latest/oat.min.css'; + +function pageHead(title: string): string { + return ` + + + + +${escapeHtml(title)} + + + + + +
`; +} + +const PAGE_FOOT = `
+ + +`; + +const THEME_TOGGLE = ``; + +function renderPage(port: number, filters: RequestQueryFilters): string { + const requests = getRequests(filters); + const total = getRequestCount(filters); + const stats = getStats(); + const page = filters.page || 1; + const limit = filters.limit || 50; + const pages = Math.ceil(total / limit); + + const qs = (overrides: Record) => { + const params: Record = {}; + if (filters.method) params.method = filters.method; + if (filters.status) params.status = String(filters.status); + if (filters.path) params.path = filters.path; + for (const [k, v] of Object.entries(overrides)) params[k] = String(v); + return new URLSearchParams(params).toString(); + }; + + const tableRows = requests.map(r => { + const duration = r.duration_ms !== null ? `${r.duration_ms}ms` : '—'; + const size = r.response_body_size ? formatBytes(r.response_body_size) : '—'; + const time = new Date(r.created_at).toLocaleTimeString('en-US', { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' }); + const errIcon = r.error ? ` err` : ''; + return ` + ${methodBadge(r.method)} + ${escapeHtml(r.path)} + ${statusBadge(r.status_code)} + ${duration} + ${size} + ${r.client_ip || '—'} + ${time}${errIcon} + + `; + }).join('\n'); + + const mobileCards = requests.map(r => { + const duration = r.duration_ms !== null ? `${r.duration_ms}ms` : '—'; + const size = r.response_body_size ? formatBytes(r.response_body_size) : '—'; + const time = new Date(r.created_at).toLocaleTimeString('en-US', { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' }); + return `
+
+ ${methodBadge(r.method)} ${statusBadge(r.status_code)} + +
+
${escapeHtml(r.path)}
+
${time} · ${duration} · ${size}${r.client_ip ? ` · ${r.client_ip}` : ''}
+
`; + }).join('\n'); + + const prevLink = page > 1 ? `← Prev` : '← Prev'; + const nextLink = page < pages ? `Next →` : 'Next →'; + + return `${pageHead('ProxyHub Inspector')} + +
+
+

ProxyHub Inspector

+

Request & response monitor

+
+ ${THEME_TOGGLE} +
+ +
+
+
${stats.total}
+
Total
+
+
+
${stats.success}
+
Success
+
+
+
${stats.error}
+
Errors
+
+
+
${stats.avg_duration_ms !== null ? stats.avg_duration_ms + 'ms' : '—'}
+
Avg Duration
+
+
+ +
+
+
+ + + + +
+
+
+ +
+ + + ${stats.total > 0 ? '' : ''} +
+ +
+ + + + + +${tableRows || ``} + +
MethodPathStatusDurationSizeClient IPTime

No requests captured yet.

Send a request through your tunnel to see it here.

+
+ +
+${mobileCards || `

No requests captured yet.

Send a request through your tunnel to see it here.

`} +
+ +${total > 0 ? `` : ''} + + + +${PAGE_FOOT}`; +} + +function generateCurl(record: RequestLogRecord): string { + const baseUrl = tunnelUrl || `http://localhost:${localTargetPort}`; + const fullUrl = `${baseUrl}${record.path}`; + const headers = tryParseJson(record.headers); + + const parts: string[] = ['curl']; + + if (record.method !== 'GET') { + parts.push(`-X ${record.method}`); + } + + parts.push(`'${fullUrl}'`); + + // Skip hop-by-hop and host headers + const skipHeaders = new Set(['host', 'connection', 'content-length']); + if (headers && typeof headers === 'object') { + for (const [key, value] of Object.entries(headers as Record)) { + if (skipHeaders.has(key.toLowerCase())) continue; + parts.push(`-H '${key}: ${String(value).replace(/'/g, "'\\''")}'`); + } + } + + if (record.body && record.method !== 'GET' && record.method !== 'HEAD') { + parts.push(`-d '${record.body.replace(/'/g, "'\\''")}'`); + } + + return parts.join(' \\\n '); +} + +function renderDetail(record: RequestLogRecord): string { + const parsed = parseJsonFields(record); + const prettyHeaders = JSON.stringify(parsed.headers, null, 2); + const prettyResHeaders = parsed.response_headers ? JSON.stringify(parsed.response_headers, null, 2) : '—'; + const duration = record.duration_ms !== null ? `${record.duration_ms}ms` : '—'; + const size = record.response_body_size ? formatBytes(record.response_body_size) : '—'; + const curlCmd = generateCurl(record); + + return `${pageHead(`${record.method} ${record.path} — ProxyHub Inspector`)} + +
+
+ ← Back + + +
+ ${THEME_TOGGLE} +
+ +
+

${methodBadge(record.method)} ${escapeHtml(record.path)}

+
+ +
+
+
Request ID
${escapeHtml(record.id)}
+
Status
${statusBadge(record.status_code)} ${record.status_message ? escapeHtml(record.status_message) : ''}
+
Duration
${duration}
+
Response Size
${size}
+
Client IP
${record.client_ip || '—'}
+
HTTP Version
${record.http_version || '—'}
+
Created
${record.created_at}
+
Completed
${record.completed_at || '—'}
+
+
+ +${record.error ? `` : ''} + +
Request Headers
+
${escapeHtml(prettyHeaders)}
+ +${record.body ? `
Request Body
${escapeHtml(record.body)}
` : ''} + +
Response Headers
+
${escapeHtml(prettyResHeaders)}
+ +${record.response_body ? `
Response Body
${escapeHtml(record.response_body)}
` : ''} + +
+ cURL +
+ +
${escapeHtml(curlCmd)}
+
+
+ + + +${PAGE_FOOT}`; +} + +function renderCompose(targetPort: number, prefill?: RequestLogRecord): string { + const pfMethod = prefill ? prefill.method.toUpperCase() : ''; + const pfHeaders = prefill ? tryParseJson(prefill.headers) : null; + const pfBody = prefill?.body || ''; + // Build the full URL — prefer tunnel URL (forwarding URL), fall back to localhost + const baseUrl = tunnelUrl || `http://localhost:${targetPort}`; + const pfUrl = prefill ? `${baseUrl}${prefill.path}` : baseUrl; + + // Build prefill header rows HTML + let headerRowsHtml: string; + if (pfHeaders && typeof pfHeaders === 'object' && Object.keys(pfHeaders).length > 0) { + headerRowsHtml = Object.entries(pfHeaders as Record).map(([k, v]) => + `
+ + + +
` + ).join('\n'); + } else { + headerRowsHtml = `
+ + + +
`; + } + + // Detect content type from prefill headers + let pfContentType = 'application/json'; + if (pfHeaders && typeof pfHeaders === 'object') { + const ct = (pfHeaders as Record)['content-type'] || (pfHeaders as Record)['Content-Type'] || ''; + if (ct.includes('text/plain')) pfContentType = 'text/plain'; + else if (ct.includes('x-www-form-urlencoded')) pfContentType = 'application/x-www-form-urlencoded'; + else if (ct.includes('xml')) pfContentType = 'text/xml'; + } + + const hasBody = pfMethod && pfMethod !== 'GET' && pfMethod !== 'HEAD' && pfMethod !== 'OPTIONS'; + + return `${pageHead('API Composer — ProxyHub Inspector')} + +
+
+ ← Back +
+ ${THEME_TOGGLE} +
+ +
+

API Composer

+

Build and send HTTP requests

+
+ +
+
+ + + +
+ +
+ Headers +
+ ${headerRowsHtml} +
+ +
+ +
+ Body +
+ + +
+
+
+ + + + + + + +${PAGE_FOOT}`; +} + +function formatBytes(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} + +export function startInspector(port: number, targetPort: number): void { + localTargetPort = targetPort; + const app = express(); + + app.use((_, res, next) => { + res.header('Access-Control-Allow-Origin', '*'); + res.header('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS'); + res.header('Access-Control-Allow-Headers', 'Content-Type'); + next(); + }); + + app.use(express.json({ limit: '2mb' })); + + app.options('*', (_, res) => { + res.sendStatus(204); + }); + + // HTML table view + app.get('/', (req, res) => { + const filters: RequestQueryFilters = { + page: req.query.page ? parseInt(req.query.page as string, 10) : 1, + limit: req.query.limit ? parseInt(req.query.limit as string, 10) : 50, + method: req.query.method as string | undefined, + status: req.query.status ? parseInt(req.query.status as string, 10) : undefined, + path: req.query.path as string | undefined, + }; + res.type('html').send(renderPage(port, filters)); + }); + + // Compose page + app.get('/compose', (req, res) => { + const fromId = req.query.from as string | undefined; + const prefill = fromId ? getRequestById(fromId) : undefined; + res.type('html').send(renderCompose(targetPort, prefill)); + }); + + // Resend a request + app.post('/api/requests/:id/resend', (req, res) => { + const original = getRequestById(req.params.id); + if (!original) { + res.status(404).json({ error: 'Request not found' }); + return; + } + + const newId = crypto.randomUUID(); + const headers = tryParseJson(original.headers); + + insertRequest({ + id: newId, + method: original.method, + path: original.path, + headers: original.headers, + body: original.body || undefined, + client_ip: original.client_ip || undefined, + created_at: new Date().toISOString(), + }); + + const startTime = Date.now(); + + // Remove headers that shouldn't be forwarded directly + const outHeaders: Record = { ...headers }; + delete outHeaders['host']; + delete outHeaders['connection']; + + const reqOptions: http.RequestOptions = { + hostname: 'localhost', + port: targetPort, + path: original.path, + method: original.method, + headers: outHeaders, + }; + + const proxyReq = http.request(reqOptions, (proxyRes) => { + updateResponseHeaders({ + id: newId, + status_code: proxyRes.statusCode, + status_message: proxyRes.statusMessage, + response_headers: JSON.stringify(proxyRes.headers), + http_version: proxyRes.httpVersion, + }); + + const chunks: Buffer[] = []; + let bodySize = 0; + proxyRes.on('data', (chunk: Buffer) => { + bodySize += chunk.length; + if (bodySize <= 512 * 1024) { + chunks.push(chunk); + } + }); + + proxyRes.on('end', () => { + completeRequest({ + id: newId, + response_body_size: bodySize, + response_body: Buffer.concat(chunks).toString('utf-8'), + duration_ms: Date.now() - startTime, + completed_at: new Date().toISOString(), + }); + res.json({ id: newId }); + }); + }); + + proxyReq.on('error', (err) => { + updateRequestError({ + id: newId, + error: err.message, + }); + res.json({ id: newId }); + }); + + if (original.body) { + proxyReq.write(original.body); + } + proxyReq.end(); + }); + + // Compose API — execute a request + app.post('/api/compose', (req, res) => { + const { method, url: rawUrl, headers: reqHeaders, body: reqBody } = req.body || {}; + + if (!method || !rawUrl) { + res.status(400).json({ error: 'method and url are required' }); + return; + } + + // Parse the URL — accept full URLs or paths (default to localhost:targetPort) + let parsedUrl: URL; + try { + if (rawUrl.startsWith('http://') || rawUrl.startsWith('https://')) { + parsedUrl = new URL(rawUrl); + } else { + parsedUrl = new URL(rawUrl, `http://localhost:${targetPort}`); + } + } catch { + res.status(400).json({ error: 'Invalid URL' }); + return; + } + + // If the URL matches the tunnel URL, rewrite to localhost:targetPort + // to avoid a duplicate DB entry from the tunnel-request handler + if (tunnelUrl) { + const tunnelOrigin = new URL(tunnelUrl).origin; + if (parsedUrl.origin === tunnelOrigin) { + parsedUrl = new URL(parsedUrl.pathname + parsedUrl.search, `http://localhost:${targetPort}`); + } + } + + const newId = crypto.randomUUID(); + const outHeaders: Record = { ...(reqHeaders || {}) }; + + insertRequest({ + id: newId, + method: method.toUpperCase(), + path: parsedUrl.pathname + parsedUrl.search, + headers: JSON.stringify(outHeaders), + body: reqBody || undefined, + client_ip: 'compose', + created_at: new Date().toISOString(), + }); + + const startTime = Date.now(); + + // Don't forward hop-by-hop headers + delete outHeaders['host']; + delete outHeaders['connection']; + + const reqOptions: http.RequestOptions = { + hostname: parsedUrl.hostname, + port: parsedUrl.port ? parseInt(parsedUrl.port, 10) : (parsedUrl.protocol === 'https:' ? 443 : 80), + path: parsedUrl.pathname + parsedUrl.search, + method: method.toUpperCase(), + headers: outHeaders, + timeout: 30000, + }; + + const proxyReq = http.request(reqOptions, (proxyRes) => { + updateResponseHeaders({ + id: newId, + status_code: proxyRes.statusCode, + status_message: proxyRes.statusMessage, + response_headers: JSON.stringify(proxyRes.headers), + http_version: proxyRes.httpVersion, + }); + + const chunks: Buffer[] = []; + let bodySize = 0; + proxyRes.on('data', (chunk: Buffer) => { + bodySize += chunk.length; + if (bodySize <= 512 * 1024) { + chunks.push(chunk); + } + }); + + proxyRes.on('end', () => { + const responseBody = Buffer.concat(chunks).toString('utf-8'); + const durationMs = Date.now() - startTime; + completeRequest({ + id: newId, + response_body_size: bodySize, + response_body: responseBody, + duration_ms: durationMs, + completed_at: new Date().toISOString(), + }); + res.json({ + id: newId, + status_code: proxyRes.statusCode, + status_message: proxyRes.statusMessage, + response_headers: JSON.stringify(proxyRes.headers), + response_body: responseBody, + duration_ms: durationMs, + response_body_size: bodySize, + }); + }); + }); + + proxyReq.on('timeout', () => { + proxyReq.destroy(); + updateRequestError({ id: newId, error: 'Request timed out (30s)' }); + res.json({ id: newId, error: 'Request timed out (30s)' }); + }); + + proxyReq.on('error', (err) => { + updateRequestError({ id: newId, error: err.message }); + res.json({ id: newId, error: err.message }); + }); + + if (reqBody && method.toUpperCase() !== 'GET' && method.toUpperCase() !== 'HEAD') { + proxyReq.write(reqBody); + } + proxyReq.end(); + }); + + // HTML detail view + app.get('/:id', (req, res, next) => { + if (req.params.id === 'api' || req.params.id === 'health') return next(); + const record = getRequestById(req.params.id); + if (!record) { res.status(404).type('html').send('

Not found

'); return; } + res.type('html').send(renderDetail(record)); + }); + + // JSON API + app.get('/api/requests', (req, res) => { + const filters: RequestQueryFilters = { + page: req.query.page ? parseInt(req.query.page as string, 10) : 1, + limit: req.query.limit ? parseInt(req.query.limit as string, 10) : 50, + method: req.query.method as string | undefined, + status: req.query.status ? parseInt(req.query.status as string, 10) : undefined, + path: req.query.path as string | undefined, + since: req.query.since as string | undefined, + }; + + const requests = getRequests(filters).map(parseJsonFields); + const total = getRequestCount(filters); + + res.json({ + data: requests, + pagination: { + page: filters.page || 1, + limit: filters.limit || 50, + total, + pages: Math.ceil(total / (filters.limit || 50)), + }, + }); + }); + + app.get('/api/requests/:id', (req, res) => { + const record = getRequestById(req.params.id); + if (!record) { + res.status(404).json({ error: 'Request not found' }); + return; + } + res.json(parseJsonFields(record)); + }); + + app.get('/api/stats', (_, res) => { + res.json(getStats()); + }); + + app.delete('/api/requests', (_, res) => { + clearLogs(); + res.json({ message: 'All logs cleared' }); + }); + + app.get('/health', (_, res) => { + res.json({ status: 'ok' }); + }); + + app.listen(port, () => { + console.log(`Inspector: http://localhost:${port}`); + }); +} diff --git a/packages/client/src/lib/socket.ts b/packages/client/src/lib/socket.ts index d3c6875..5b47f06 100644 --- a/packages/client/src/lib/socket.ts +++ b/packages/client/src/lib/socket.ts @@ -4,6 +4,37 @@ import { printError, printDebug } from "../utils/index.js"; import * as http from "http"; import { ResponseChannel } from "./tunnel.js"; import * as crypto from "crypto"; +import { Transform, TransformCallback } from "stream"; +import { + initDb, + closeDb, + clearLogs, + insertRequest, + updateResponseHeaders as updateDbResponseHeaders, + completeRequest, + updateRequestError, +} from "./db.js"; +import { setTunnelUrl } from "./inspector.js"; + +const MAX_BODY_CAPTURE = 512 * 1024; // 512KB + +class ByteCounter extends Transform { + public byteCount = 0; + public chunks: Buffer[] = []; + + _transform(chunk: Buffer, _encoding: string, callback: TransformCallback): void { + this.byteCount += chunk.length; + if (this.byteCount <= MAX_BODY_CAPTURE) { + this.chunks.push(chunk); + } + this.push(chunk); + callback(); + } + + getBody(): string { + return Buffer.concat(this.chunks).toString('utf-8'); + } +} // Global state for timeout tracking let timeoutInterval: NodeJS.Timeout | null = null; @@ -62,6 +93,10 @@ const displayTunnelInfo = (data: any) => { console.log(formatLine('Token Protection', 'Enabled (X-Proxy-Token header required)', chalk.green)); } + if (data.inspectPort) { + console.log(formatLine('Inspector', `http://localhost:${data.inspectPort}`, chalk.magenta)); + } + if (data.timeout?.enabled) { const expirationTime = formatExpirationTime(data.timeout.sessionStartTime, data.timeout.durationMs); console.log(formatLine('Session Timeout', `${data.timeout.minutes} min (expires ${expirationTime})`, chalk.yellow)); @@ -128,6 +163,11 @@ const generateStableTunnelId = (port: number): string => { }; const socketHandler = (option: ClientInitializationOptions) => { + if (option.inspect) { + initDb(); + clearLogs(); + } + const stableTunnelId = generateStableTunnelId(option.port); let socketUrl = ( @@ -227,7 +267,11 @@ const socketHandler = (option: ClientInitializationOptions) => { }); socket.on("on-connect-tunnel", (data) => { - displayTunnelInfo({ ...data, port: option.port }); + const inspectPort = option.inspect ? (option.inspectPort || option.port + 1000) : undefined; + displayTunnelInfo({ ...data, port: option.port, inspectPort }); + if (data.tunnelUrl && option.inspect) { + setTunnelUrl(data.tunnelUrl); + } if (option.debug) { printDebug("Tunnel data", data); } @@ -240,6 +284,8 @@ const socketHandler = (option: ClientInitializationOptions) => { hostname: 'localhost', }; + const startTime = Date.now(); + if (option.debug) { printDebug("Tunnel request received", { requestId, @@ -249,6 +295,23 @@ const socketHandler = (option: ClientInitializationOptions) => { }); } + // Log request to inspector DB + if (option.inspect) { + const requestBody = + typeof request.body === "object" + ? JSON.stringify(request.body) + : request.body; + insertRequest({ + id: requestId, + method: request.method, + path: request.path, + headers: JSON.stringify(request.headers), + body: requestBody || undefined, + client_ip: requestData.clientIp || undefined, + created_at: new Date().toISOString(), + }); + } + const proxyRequest = http.request({ hostname: request.hostname, port: request.port, @@ -271,6 +334,12 @@ const socketHandler = (option: ClientInitializationOptions) => { proxyRequest.on("timeout", () => { printError("Request timeout after 5 seconds"); proxyRequest.destroy(); + if (option.inspect) { + updateRequestError({ + id: requestId, + error: 'Request timeout after 5 seconds', + }); + } if (option.debug) { printDebug("Request timeout", { path: request.path }); } @@ -278,6 +347,12 @@ const socketHandler = (option: ClientInitializationOptions) => { proxyRequest.once("error", (error: Error) => { printError(`Request failed: ${error.message}`); + if (option.inspect) { + updateRequestError({ + id: requestId, + error: error.message, + }); + } if (option.debug) { printDebug("Request error", error); } @@ -299,24 +374,59 @@ const socketHandler = (option: ClientInitializationOptions) => { statusColor(response.statusCode?.toString() || '') ); + const responseHeaders = Object.fromEntries( + Object.entries(response.headers).map(([key, value]) => [ + key, + Array.isArray(value) ? value.join(', ') : value || '' + ]) + ); + responseChannel.sendHeaders({ statusCode: response.statusCode, statusMessage: response.statusMessage, - headers: Object.fromEntries( - Object.entries(response.headers).map(([key, value]) => [ - key, - Array.isArray(value) ? value.join(', ') : value || '' - ]) - ), + headers: responseHeaders, httpVersion: response.httpVersion, }); - // Use pipe for automatic backpressure handling - response.pipe(responseChannel); + // Log response headers to inspector DB + if (option.inspect) { + updateDbResponseHeaders({ + id: requestId, + status_code: response.statusCode, + status_message: response.statusMessage, + response_headers: JSON.stringify(responseHeaders), + http_version: response.httpVersion, + }); + } + + if (option.inspect) { + // Use ByteCounter to capture response body for inspector + const byteCounter = new ByteCounter(); + response.pipe(byteCounter).pipe(responseChannel); + + byteCounter.on('end', () => { + completeRequest({ + id: requestId, + response_body_size: byteCounter.byteCount, + response_body: byteCounter.getBody(), + duration_ms: Date.now() - startTime, + completed_at: new Date().toISOString(), + }); + }); + } else { + // Use pipe for automatic backpressure handling + response.pipe(responseChannel); + } response.on("error", (error: Error) => { printError(`Response error: ${error.message}`); responseChannel.destroy(error); + if (option.inspect) { + updateRequestError({ + id: requestId, + error: error.message, + }); + } if (option.debug) { printDebug("Response error", error); } @@ -333,6 +443,9 @@ const socketHandler = (option: ClientInitializationOptions) => { // Graceful shutdown handlers const handleShutdown = () => { console.log(chalk.yellow.bold('\nShutting down ProxyHub client...')); + if (option.inspect) { + closeDb(); + } cleanup(socket); process.exit(0); }; diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index c4d17bb..267e8aa 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -63,7 +63,8 @@ app.use("/", RequestMiddleware, (req, res) => { debug('No tunnel mapping found for ID:', socketId); res.status(404).json({ error: 'Tunnel not found', - message: 'No active tunnel found for this URL. Please check your connection.' + message: 'No active tunnel found for this URL. Please check your connection.', + docs: 'https://proxyhub.app' }); return; } @@ -113,6 +114,7 @@ app.use("/", RequestMiddleware, (req, res) => { }, path: req.url, body: req.body, + clientIp: req.ip || req.socket?.remoteAddress || 'unknown', }); const timeoutMs = 30000; diff --git a/packages/server/src/lib/tunnel.ts b/packages/server/src/lib/tunnel.ts index 578992b..6b6b926 100644 --- a/packages/server/src/lib/tunnel.ts +++ b/packages/server/src/lib/tunnel.ts @@ -7,6 +7,7 @@ interface RequestData { headers: object path: string body: object + clientIp?: string } interface TunnelRequestHandlers {