@cmer/localhook 1.1.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.
- package/README.md +125 -0
- package/cli.js +58 -0
- package/lib/server.js +418 -0
- package/package.json +42 -0
- package/public/index.html +1044 -0
package/README.md
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# LocalHook
|
|
2
|
+
|
|
3
|
+
Local webhook testing tool. Like [webhook.site](https://webhook.site), but on your machine.
|
|
4
|
+
|
|
5
|
+
Send webhooks to `localhost` instead of a third-party service. Inspect request details, headers, and body in a real-time dashboard.
|
|
6
|
+
|
|
7
|
+

|
|
8
|
+
|
|
9
|
+
## Quick Start
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
npx @cmer/localhook
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Then send requests to `http://localhost:3000/any-path` (or the public URL) and watch them appear in the dashboard.
|
|
16
|
+
|
|
17
|
+
## Usage
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
# Default port 3000
|
|
21
|
+
npx @cmer/localhook
|
|
22
|
+
|
|
23
|
+
# Custom port
|
|
24
|
+
npx @cmer/localhook --port 8080
|
|
25
|
+
|
|
26
|
+
# Expose via Tailscale Funnel
|
|
27
|
+
npx @cmer/localhook --tailscale
|
|
28
|
+
|
|
29
|
+
# Expose via Cloudflare Quick Tunnel (no account required)
|
|
30
|
+
npx @cmer/localhook --cloudflare
|
|
31
|
+
|
|
32
|
+
# Custom port with Tailscale
|
|
33
|
+
npx @cmer/localhook --port 8080 --tailscale
|
|
34
|
+
|
|
35
|
+
# Allow dashboard access from the public URL (password-protected)
|
|
36
|
+
npx @cmer/localhook --tailscale --allow-remote-access --password mysecret
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
| Flag | Short | Description |
|
|
40
|
+
|---|---|---|
|
|
41
|
+
| `--port <port>` | `-p` | Port to listen on (default: 3000) |
|
|
42
|
+
| `--tailscale` | | Start Tailscale Funnel for a public HTTPS URL |
|
|
43
|
+
| `--cloudflare` | | Start Cloudflare Quick Tunnel for a public HTTPS URL |
|
|
44
|
+
| `--allow-remote-access` | | Allow dashboard/API access from non-localhost (e.g. via tunnel) |
|
|
45
|
+
| `--password <value>` | | Require HTTP Basic Auth for remote dashboard/API access (localhost is never challenged) |
|
|
46
|
+
| `--data-file <path>` | | Path to data file (default: `~/.localhook/data.json`) |
|
|
47
|
+
| `--help` | `-h` | Show help |
|
|
48
|
+
|
|
49
|
+
Open `http://localhost:3000` in your browser to see the dashboard.
|
|
50
|
+
|
|
51
|
+
Any HTTP request to any path (except `/`) gets captured:
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
curl -X POST http://localhost:3000/webhook \
|
|
55
|
+
-H "Content-Type: application/json" \
|
|
56
|
+
-d '{"event": "user.created", "user_id": "123"}'
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Incoming requests are also logged in the terminal:
|
|
60
|
+
|
|
61
|
+
```
|
|
62
|
+
3/6/2026 5:37:24 PM POST /webhooks/stripe 494b application/json
|
|
63
|
+
3/6/2026 5:37:25 PM GET /api/health?status=ok
|
|
64
|
+
3/6/2026 5:37:26 PM DELETE /api/sessions/sess_9fKx2mNpQ
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## Features
|
|
68
|
+
|
|
69
|
+
- **Public HTTPS Tunnel** -- built-in support for HTTPS tunnel via Tailscale or Cloudflare
|
|
70
|
+
- **Real-time** -- requests appear instantly via Server-Sent Events, no refresh needed
|
|
71
|
+
- **All HTTP methods** -- GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS
|
|
72
|
+
- **Request inspection** -- method, URL, headers, query parameters, body
|
|
73
|
+
- **JSON formatting** -- auto-detects and pretty-prints JSON with syntax highlighting
|
|
74
|
+
- **Zero config** -- no database, no build step, no accounts
|
|
75
|
+
- **Terminal logging** -- see requests in your terminal without opening the dashboard
|
|
76
|
+
|
|
77
|
+
## Testing with External Services
|
|
78
|
+
|
|
79
|
+
If you need to receive webhooks from external services like Stripe, GitHub, or Shopify, they need a public URL to send requests to.
|
|
80
|
+
|
|
81
|
+
### Tailscale Funnel (built-in)
|
|
82
|
+
|
|
83
|
+
My favorite. Use the `--tailscale` flag to automatically start [Tailscale Funnel](https://tailscale.com/kb/1223/funnel) alongside LocalHook:
|
|
84
|
+
|
|
85
|
+
```bash
|
|
86
|
+
npx @cmer/localhook --tailscale
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
This gives you a public HTTPS URL like `https://myhost.tail1234.ts.net`. Use that as your webhook URL in Stripe, GitHub, etc.
|
|
90
|
+
|
|
91
|
+
Requires [Tailscale](https://tailscale.com/) to be installed with [Funnel enabled](https://tailscale.com/kb/1223/funnel).
|
|
92
|
+
|
|
93
|
+
### Cloudflare Quick Tunnel (built-in)
|
|
94
|
+
|
|
95
|
+
Use the `--cloudflare` flag to start a [Cloudflare Quick Tunnel](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/do-more-with-tunnels/trycloudflare/) — no account or login required:
|
|
96
|
+
|
|
97
|
+
```bash
|
|
98
|
+
npx @cmer/localhook --cloudflare
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
This gives you a public HTTPS URL like `https://random-words.trycloudflare.com`. Use that as your webhook URL in Stripe, GitHub, etc.
|
|
102
|
+
|
|
103
|
+
Requires [`cloudflared`](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/) to be installed. The URL changes each time you restart.
|
|
104
|
+
|
|
105
|
+
> **Note:** `--tailscale` and `--cloudflare` are mutually exclusive — use one or the other.
|
|
106
|
+
|
|
107
|
+
> **Note:** The dashboard is always restricted to localhost by default. Requests through reverse proxies (Tailscale Funnel, ngrok, Cloudflare Tunnel, etc.) are automatically detected and blocked from accessing the dashboard. Use `--allow-remote-access` to override this, and `--password` to require authentication for remote access.
|
|
108
|
+
|
|
109
|
+
### Other tunneling services
|
|
110
|
+
|
|
111
|
+
You can also use any other tunneling service manually, such as ngrok.
|
|
112
|
+
|
|
113
|
+
## REST API
|
|
114
|
+
|
|
115
|
+
LocalHook has a REST API for programmatic access to captured webhooks. See [API.md](API.md) for full documentation with example requests.
|
|
116
|
+
|
|
117
|
+
## How It Works
|
|
118
|
+
|
|
119
|
+
LocalHook runs a single Express server. `GET /` serves the dashboard. Every other request is captured as a webhook and broadcast to the dashboard via SSE.
|
|
120
|
+
|
|
121
|
+
Data is stored in `~/.localhook/data.json` by default (max 500 entries). Use `--data-file` to override.
|
|
122
|
+
|
|
123
|
+
## License
|
|
124
|
+
|
|
125
|
+
MIT
|
package/cli.js
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
const { createServer } = require('./lib/server');
|
|
4
|
+
|
|
5
|
+
const args = process.argv.slice(2);
|
|
6
|
+
let port = 3000;
|
|
7
|
+
let tailscale = false;
|
|
8
|
+
let cloudflare = false;
|
|
9
|
+
let allowRemoteAccess = false;
|
|
10
|
+
let password = null;
|
|
11
|
+
let poll = false;
|
|
12
|
+
let dataFile = null;
|
|
13
|
+
|
|
14
|
+
for (let i = 0; i < args.length; i++) {
|
|
15
|
+
if ((args[i] === '--port' || args[i] === '-p') && args[i + 1]) {
|
|
16
|
+
port = parseInt(args[i + 1], 10);
|
|
17
|
+
i++;
|
|
18
|
+
} else if (args[i] === '--tailscale') {
|
|
19
|
+
tailscale = true;
|
|
20
|
+
} else if (args[i] === '--cloudflare') {
|
|
21
|
+
cloudflare = true;
|
|
22
|
+
} else if (args[i] === '--allow-remote-access') {
|
|
23
|
+
allowRemoteAccess = true;
|
|
24
|
+
} else if (args[i] === '--password' && args[i + 1]) {
|
|
25
|
+
password = args[i + 1];
|
|
26
|
+
i++;
|
|
27
|
+
} else if (args[i] === '--poll') {
|
|
28
|
+
poll = true;
|
|
29
|
+
} else if (args[i] === '--data-file' && args[i + 1]) {
|
|
30
|
+
dataFile = args[i + 1];
|
|
31
|
+
i++;
|
|
32
|
+
} else if (args[i] === '--help' || args[i] === '-h') {
|
|
33
|
+
console.log(`
|
|
34
|
+
localhook - Local webhook testing tool
|
|
35
|
+
|
|
36
|
+
Usage:
|
|
37
|
+
localhook [options]
|
|
38
|
+
|
|
39
|
+
Options:
|
|
40
|
+
-p, --port <port> Port to listen on (default: 3000)
|
|
41
|
+
--tailscale Start Tailscale Funnel for a public HTTPS URL
|
|
42
|
+
--cloudflare Start Cloudflare Quick Tunnel for a public HTTPS URL
|
|
43
|
+
--allow-remote-access Allow dashboard/API access from non-localhost (e.g. via tunnel)
|
|
44
|
+
--password <value> Require HTTP Basic Auth for remote dashboard/API access
|
|
45
|
+
--poll Force polling instead of SSE for dashboard updates
|
|
46
|
+
--data-file <path> Path to data file (default: ~/.localhook/data.json)
|
|
47
|
+
-h, --help Show this help message
|
|
48
|
+
`);
|
|
49
|
+
process.exit(0);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (tailscale && cloudflare) {
|
|
54
|
+
console.error('\n Error: --tailscale and --cloudflare are mutually exclusive. Use one or the other.\n');
|
|
55
|
+
process.exit(1);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
createServer(port, { tailscale, cloudflare, allowRemoteAccess, password, poll, dataFile });
|
package/lib/server.js
ADDED
|
@@ -0,0 +1,418 @@
|
|
|
1
|
+
const express = require('express');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const crypto = require('crypto');
|
|
5
|
+
const os = require('os');
|
|
6
|
+
const { spawn } = require('child_process');
|
|
7
|
+
|
|
8
|
+
const DEFAULT_DATA_DIR = path.join(os.homedir(), '.localhook');
|
|
9
|
+
const DEFAULT_DATA_FILE = path.join(DEFAULT_DATA_DIR, 'data.json');
|
|
10
|
+
const MAX_WEBHOOKS = 500;
|
|
11
|
+
const HEARTBEAT_INTERVAL_MS = 5000;
|
|
12
|
+
|
|
13
|
+
let dataFile = DEFAULT_DATA_FILE;
|
|
14
|
+
let webhooks = [];
|
|
15
|
+
let sseClients = new Set();
|
|
16
|
+
let saveTimeout = null;
|
|
17
|
+
|
|
18
|
+
let publicUrl = null;
|
|
19
|
+
let tunnelService = null;
|
|
20
|
+
let tunnelChild = null;
|
|
21
|
+
|
|
22
|
+
function findTailscaleBinary() {
|
|
23
|
+
// macOS App Store install location
|
|
24
|
+
const macosPath = '/Applications/Tailscale.app/Contents/MacOS/Tailscale';
|
|
25
|
+
if (process.platform === 'darwin' && fs.existsSync(macosPath)) {
|
|
26
|
+
return macosPath;
|
|
27
|
+
}
|
|
28
|
+
return 'tailscale';
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function startTailscaleFunnel(port) {
|
|
32
|
+
return new Promise((resolve, reject) => {
|
|
33
|
+
const bin = findTailscaleBinary();
|
|
34
|
+
const child = spawn(bin, ['funnel', String(port)], {
|
|
35
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
let stdout = '';
|
|
39
|
+
let stderr = '';
|
|
40
|
+
let resolved = false;
|
|
41
|
+
|
|
42
|
+
const timeout = setTimeout(() => {
|
|
43
|
+
if (!resolved) {
|
|
44
|
+
resolved = true;
|
|
45
|
+
child.kill();
|
|
46
|
+
reject(new Error('Timed out waiting for Tailscale Funnel URL (15s). Is Funnel enabled?'));
|
|
47
|
+
}
|
|
48
|
+
}, 15000);
|
|
49
|
+
|
|
50
|
+
const onStdout = (data) => {
|
|
51
|
+
stdout += data.toString();
|
|
52
|
+
const match = stdout.match(/https:\/\/\S+\.ts\.net/);
|
|
53
|
+
if (match && !resolved) {
|
|
54
|
+
resolved = true;
|
|
55
|
+
clearTimeout(timeout);
|
|
56
|
+
child.stdout.removeListener('data', onStdout);
|
|
57
|
+
child.stderr.removeListener('data', onStderr);
|
|
58
|
+
resolve({ child, url: match[0] });
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const onStderr = (data) => {
|
|
63
|
+
stderr += data.toString();
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
child.stdout.on('data', onStdout);
|
|
67
|
+
child.stderr.on('data', onStderr);
|
|
68
|
+
|
|
69
|
+
child.on('error', (err) => {
|
|
70
|
+
if (!resolved) {
|
|
71
|
+
resolved = true;
|
|
72
|
+
clearTimeout(timeout);
|
|
73
|
+
if (err.code === 'ENOENT') {
|
|
74
|
+
reject(new Error('Tailscale is not installed or not in PATH'));
|
|
75
|
+
} else {
|
|
76
|
+
reject(new Error(`Failed to start Tailscale Funnel: ${err.message}`));
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
child.on('close', (code) => {
|
|
82
|
+
if (!resolved) {
|
|
83
|
+
resolved = true;
|
|
84
|
+
clearTimeout(timeout);
|
|
85
|
+
const msg = stderr.trim() || stdout.trim() || `Process exited with code ${code}`;
|
|
86
|
+
reject(new Error(`Tailscale Funnel failed: ${msg}`));
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function startCloudflaredTunnel(port) {
|
|
93
|
+
return new Promise((resolve, reject) => {
|
|
94
|
+
const child = spawn('cloudflared', ['tunnel', '--url', `http://localhost:${port}`, '--protocol', 'http2'], {
|
|
95
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
let stderr = '';
|
|
99
|
+
let tunnelUrl = null;
|
|
100
|
+
let resolved = false;
|
|
101
|
+
|
|
102
|
+
const timeout = setTimeout(() => {
|
|
103
|
+
if (!resolved) {
|
|
104
|
+
resolved = true;
|
|
105
|
+
child.kill();
|
|
106
|
+
reject(new Error('Timed out waiting for Cloudflare Quick Tunnel (60s). Is cloudflared installed?'));
|
|
107
|
+
}
|
|
108
|
+
}, 60000);
|
|
109
|
+
|
|
110
|
+
const onStderr = (data) => {
|
|
111
|
+
stderr += data.toString();
|
|
112
|
+
|
|
113
|
+
if (!tunnelUrl) {
|
|
114
|
+
const match = stderr.match(/https:\/\/[a-zA-Z0-9-]+\.trycloudflare\.com/);
|
|
115
|
+
if (match) tunnelUrl = match[0];
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (tunnelUrl && !resolved && /Registered tunnel connection/.test(stderr)) {
|
|
119
|
+
resolved = true;
|
|
120
|
+
clearTimeout(timeout);
|
|
121
|
+
child.stderr.removeListener('data', onStderr);
|
|
122
|
+
resolve({ child, url: tunnelUrl });
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
child.stderr.on('data', onStderr);
|
|
127
|
+
|
|
128
|
+
child.on('error', (err) => {
|
|
129
|
+
if (!resolved) {
|
|
130
|
+
resolved = true;
|
|
131
|
+
clearTimeout(timeout);
|
|
132
|
+
if (err.code === 'ENOENT') {
|
|
133
|
+
reject(new Error('cloudflared is not installed or not in PATH. Install from https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/'));
|
|
134
|
+
} else {
|
|
135
|
+
reject(new Error(`Failed to start Cloudflare Quick Tunnel: ${err.message}`));
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
child.on('close', (code) => {
|
|
141
|
+
if (!resolved) {
|
|
142
|
+
resolved = true;
|
|
143
|
+
clearTimeout(timeout);
|
|
144
|
+
const msg = stderr.trim() || `Process exited with code ${code}`;
|
|
145
|
+
reject(new Error(`Cloudflare Quick Tunnel failed: ${msg}`));
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function loadData() {
|
|
152
|
+
try {
|
|
153
|
+
if (fs.existsSync(dataFile)) {
|
|
154
|
+
const raw = fs.readFileSync(dataFile, 'utf-8');
|
|
155
|
+
webhooks = JSON.parse(raw);
|
|
156
|
+
if (!Array.isArray(webhooks)) webhooks = [];
|
|
157
|
+
}
|
|
158
|
+
} catch {
|
|
159
|
+
webhooks = [];
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function saveData() {
|
|
164
|
+
if (saveTimeout) clearTimeout(saveTimeout);
|
|
165
|
+
saveTimeout = setTimeout(() => {
|
|
166
|
+
try {
|
|
167
|
+
const dir = path.dirname(dataFile);
|
|
168
|
+
if (!fs.existsSync(dir)) {
|
|
169
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
170
|
+
}
|
|
171
|
+
fs.writeFileSync(dataFile, JSON.stringify(webhooks, null, 2));
|
|
172
|
+
} catch (err) {
|
|
173
|
+
console.error('Failed to save data:', err.message);
|
|
174
|
+
}
|
|
175
|
+
}, 300);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function broadcast(data) {
|
|
179
|
+
const msg = `data: ${JSON.stringify(data)}\n\n`;
|
|
180
|
+
for (const client of sseClients) {
|
|
181
|
+
client.write(msg);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function generateId() {
|
|
186
|
+
return crypto.randomBytes(8).toString('hex');
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function createServer(port, options = {}) {
|
|
190
|
+
if (options.dataFile) {
|
|
191
|
+
dataFile = path.resolve(options.dataFile);
|
|
192
|
+
}
|
|
193
|
+
loadData();
|
|
194
|
+
|
|
195
|
+
const app = express();
|
|
196
|
+
|
|
197
|
+
// --- Basic Auth middleware ---
|
|
198
|
+
// When --password is set, require HTTP Basic Auth for remote dashboard/API access.
|
|
199
|
+
// Localhost requests are never challenged. Webhook capture routes are never protected.
|
|
200
|
+
if (options.password) {
|
|
201
|
+
const expectedPassword = Buffer.from(options.password);
|
|
202
|
+
app.use((req, res, next) => {
|
|
203
|
+
const isDashboardRoute = req.path === '/' || req.path.startsWith('/_/');
|
|
204
|
+
if (!isDashboardRoute) return next();
|
|
205
|
+
|
|
206
|
+
const isLocalhostHost = req.hostname === 'localhost' || req.hostname === '127.0.0.1';
|
|
207
|
+
const isProxied = !!(req.headers['x-forwarded-for'] || req.headers['x-forwarded-host'] || req.headers['x-forwarded-proto'] || req.headers['x-real-ip']);
|
|
208
|
+
if (isLocalhostHost && !isProxied) return next();
|
|
209
|
+
|
|
210
|
+
const authHeader = req.headers.authorization;
|
|
211
|
+
if (authHeader && authHeader.startsWith('Basic ')) {
|
|
212
|
+
const decoded = Buffer.from(authHeader.slice(6), 'base64').toString();
|
|
213
|
+
const password = Buffer.from(decoded.split(':').slice(1).join(':'));
|
|
214
|
+
if (password.length === expectedPassword.length && crypto.timingSafeEqual(password, expectedPassword)) {
|
|
215
|
+
return next();
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
res.set('WWW-Authenticate', 'Basic realm="LocalHook"');
|
|
220
|
+
return res.status(401).send('Authentication required');
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// --- Dashboard security guard ---
|
|
225
|
+
// Block dashboard/API access from reverse proxies (ngrok, Cloudflare Tunnel,
|
|
226
|
+
// Tailscale Funnel, etc.) unless --allow-dashboard-from-remote is set.
|
|
227
|
+
// Detection: proxy headers indicate the request was forwarded, and a non-local
|
|
228
|
+
// Host header indicates the client used a public URL.
|
|
229
|
+
|
|
230
|
+
if (!options.allowRemoteAccess) {
|
|
231
|
+
app.use((req, res, next) => {
|
|
232
|
+
const isDashboardRoute = req.path === '/' || req.path.startsWith('/_/');
|
|
233
|
+
if (!isDashboardRoute) return next();
|
|
234
|
+
|
|
235
|
+
const isProxied = !!(req.headers['x-forwarded-for'] || req.headers['x-forwarded-host'] || req.headers['x-forwarded-proto'] || req.headers['x-real-ip']);
|
|
236
|
+
const isLocalhostHost = req.hostname === 'localhost' || req.hostname === '127.0.0.1';
|
|
237
|
+
|
|
238
|
+
if (isProxied || !isLocalhostHost) {
|
|
239
|
+
return res.status(403).send('Dashboard and API access is restricted to localhost. Use --allow-remote-access to override.');
|
|
240
|
+
}
|
|
241
|
+
next();
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// --- UI & API routes (reserved under /_/) ---
|
|
246
|
+
|
|
247
|
+
app.use('/_/api', (req, res, next) => {
|
|
248
|
+
res.set('Cache-Control', 'no-store');
|
|
249
|
+
next();
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
app.get('/', (req, res) => {
|
|
253
|
+
res.sendFile(path.join(__dirname, '..', 'public', 'index.html'));
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
app.get('/_/events', (req, res) => {
|
|
257
|
+
res.writeHead(200, {
|
|
258
|
+
'Content-Type': 'text/event-stream',
|
|
259
|
+
'Cache-Control': 'no-cache',
|
|
260
|
+
'Connection': 'keep-alive',
|
|
261
|
+
'X-Accel-Buffering': 'no',
|
|
262
|
+
});
|
|
263
|
+
res.write('\n');
|
|
264
|
+
sseClients.add(res);
|
|
265
|
+
|
|
266
|
+
const heartbeatInterval = setInterval(() => {
|
|
267
|
+
res.write('event: heartbeat\ndata: \n\n');
|
|
268
|
+
}, options.heartbeatInterval || HEARTBEAT_INTERVAL_MS);
|
|
269
|
+
|
|
270
|
+
req.on('close', () => {
|
|
271
|
+
clearInterval(heartbeatInterval);
|
|
272
|
+
sseClients.delete(res);
|
|
273
|
+
});
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
app.get('/_/api/public_url', (req, res) => {
|
|
277
|
+
res.json({ url: publicUrl, service: tunnelService, poll: !!options.poll });
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
app.get('/_/api/webhooks', (req, res) => {
|
|
281
|
+
res.json(webhooks);
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
app.get('/_/api/webhooks/:id', (req, res) => {
|
|
285
|
+
const webhook = webhooks.find(w => w.id === req.params.id);
|
|
286
|
+
if (!webhook) return res.status(404).json({ error: 'Not found' });
|
|
287
|
+
res.json(webhook);
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
app.delete('/_/api/webhooks/:id', (req, res) => {
|
|
291
|
+
const idx = webhooks.findIndex(w => w.id === req.params.id);
|
|
292
|
+
if (idx === -1) return res.status(404).json({ error: 'Not found' });
|
|
293
|
+
webhooks.splice(idx, 1);
|
|
294
|
+
saveData();
|
|
295
|
+
broadcast({ type: 'delete', id: req.params.id });
|
|
296
|
+
res.json({ ok: true });
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
app.delete('/_/api/webhooks', (req, res) => {
|
|
300
|
+
webhooks = [];
|
|
301
|
+
saveData();
|
|
302
|
+
broadcast({ type: 'clear' });
|
|
303
|
+
res.json({ ok: true });
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
// --- Webhook capture (everything else) ---
|
|
307
|
+
|
|
308
|
+
app.get('/favicon.ico', (req, res) => res.status(204).end());
|
|
309
|
+
|
|
310
|
+
app.use((req, res, next) => {
|
|
311
|
+
if (req.path.startsWith('/_/')) return next();
|
|
312
|
+
|
|
313
|
+
const chunks = [];
|
|
314
|
+
req.on('data', chunk => chunks.push(chunk));
|
|
315
|
+
req.on('end', () => {
|
|
316
|
+
const body = Buffer.concat(chunks).toString('utf-8');
|
|
317
|
+
|
|
318
|
+
const webhook = {
|
|
319
|
+
id: generateId(),
|
|
320
|
+
method: req.method,
|
|
321
|
+
path: req.originalUrl,
|
|
322
|
+
headers: req.headers,
|
|
323
|
+
query: req.query,
|
|
324
|
+
body: body,
|
|
325
|
+
size: Buffer.byteLength(body),
|
|
326
|
+
ip: req.ip === '::1' ? '127.0.0.1' : (req.ip || '127.0.0.1'),
|
|
327
|
+
timestamp: new Date().toISOString(),
|
|
328
|
+
};
|
|
329
|
+
|
|
330
|
+
// Log to terminal
|
|
331
|
+
const d = new Date(webhook.timestamp);
|
|
332
|
+
const time = d.toLocaleDateString() + ' ' + d.toLocaleTimeString();
|
|
333
|
+
const ct = webhook.headers['content-type'] || '';
|
|
334
|
+
const sizeStr = webhook.size > 0 ? ` ${webhook.size}b` : '';
|
|
335
|
+
console.log(` \x1b[2m${time}\x1b[0m \x1b[1m${webhook.method.padEnd(7)}\x1b[0m ${webhook.path}\x1b[2m${sizeStr}${ct ? ' ' + ct : ''}\x1b[0m`);
|
|
336
|
+
|
|
337
|
+
webhooks.unshift(webhook);
|
|
338
|
+
if (webhooks.length > MAX_WEBHOOKS) {
|
|
339
|
+
webhooks = webhooks.slice(0, MAX_WEBHOOKS);
|
|
340
|
+
}
|
|
341
|
+
saveData();
|
|
342
|
+
broadcast({ type: 'webhook', webhook });
|
|
343
|
+
|
|
344
|
+
res.status(200).json({ ok: true, id: webhook.id });
|
|
345
|
+
});
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
const server = app.listen(port, async () => {
|
|
349
|
+
const reset = '\x1b[0m';
|
|
350
|
+
const bold = '\x1b[1m';
|
|
351
|
+
const dim = '\x1b[2m';
|
|
352
|
+
const cyan = '\x1b[36m';
|
|
353
|
+
const green = '\x1b[32m';
|
|
354
|
+
const yellow = '\x1b[33m';
|
|
355
|
+
const magenta = '\x1b[35m';
|
|
356
|
+
|
|
357
|
+
const passwordLine = options.password ? `\n ${magenta}Password${reset} enabled (remote only)` : '';
|
|
358
|
+
|
|
359
|
+
if (options.tailscale || options.cloudflare) {
|
|
360
|
+
const serviceName = options.tailscale ? 'Tailscale Funnel' : 'Cloudflare Quick Tunnel';
|
|
361
|
+
try {
|
|
362
|
+
const result = options.tailscale
|
|
363
|
+
? await startTailscaleFunnel(port)
|
|
364
|
+
: await startCloudflaredTunnel(port);
|
|
365
|
+
tunnelChild = result.child;
|
|
366
|
+
publicUrl = result.url;
|
|
367
|
+
tunnelService = options.tailscale ? 'tailscale' : 'cloudflare';
|
|
368
|
+
|
|
369
|
+
console.log(`
|
|
370
|
+
${bold}LocalHook${reset} is running!
|
|
371
|
+
|
|
372
|
+
${green}Webhook URL${reset} http://localhost:${port}${dim}/any-path${reset}
|
|
373
|
+
${result.url}${dim}/any-path${reset}
|
|
374
|
+
|
|
375
|
+
${cyan}Dashboard${reset} http://localhost:${port}/${options.allowRemoteAccess ? `\n ${result.url}/` : ''}${passwordLine}
|
|
376
|
+
|
|
377
|
+
${dim}Send any HTTP request to capture it.${reset}
|
|
378
|
+
${dim}Press Ctrl+C to stop.${reset}
|
|
379
|
+
`);
|
|
380
|
+
|
|
381
|
+
const cleanup = () => {
|
|
382
|
+
if (tunnelChild) {
|
|
383
|
+
tunnelChild.kill();
|
|
384
|
+
tunnelChild = null;
|
|
385
|
+
}
|
|
386
|
+
};
|
|
387
|
+
|
|
388
|
+
process.on('SIGINT', () => { cleanup(); process.exit(0); });
|
|
389
|
+
process.on('SIGTERM', () => { cleanup(); process.exit(0); });
|
|
390
|
+
process.on('exit', cleanup);
|
|
391
|
+
|
|
392
|
+
tunnelChild.on('close', (code) => {
|
|
393
|
+
if (tunnelChild) {
|
|
394
|
+
console.warn(`\n ${yellow}Warning:${reset} ${serviceName} process exited unexpectedly (code ${code}). Public URL is no longer available.\n`);
|
|
395
|
+
tunnelChild = null;
|
|
396
|
+
}
|
|
397
|
+
});
|
|
398
|
+
} catch (err) {
|
|
399
|
+
console.error(`\n Error: ${err.message}\n`);
|
|
400
|
+
process.exit(1);
|
|
401
|
+
}
|
|
402
|
+
} else {
|
|
403
|
+
console.log(`
|
|
404
|
+
${bold}LocalHook${reset} is running!
|
|
405
|
+
|
|
406
|
+
${green}Webhook URL${reset} http://localhost:${port}${dim}/any-path${reset}
|
|
407
|
+
${cyan}Dashboard${reset} http://localhost:${port}/${passwordLine}
|
|
408
|
+
|
|
409
|
+
${dim}Send any HTTP request to capture it.${reset}
|
|
410
|
+
${dim}Press Ctrl+C to stop.${reset}
|
|
411
|
+
`);
|
|
412
|
+
}
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
return server;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
module.exports = { createServer };
|
package/package.json
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@cmer/localhook",
|
|
3
|
+
"version": "1.1.0",
|
|
4
|
+
"description": "Webhook interceptor and testing tool. Capture and inspect HTTP requests locally with a real-time dashboard. Test Stripe, GitHub, Shopify, Twilio, SendGrid, and any other webhooks without third-party services. Zero config",
|
|
5
|
+
"author": "Carl Mercier",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/cmer/localhook.git"
|
|
10
|
+
},
|
|
11
|
+
"homepage": "https://github.com/cmer/localhook",
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/cmer/localhook/issues"
|
|
14
|
+
},
|
|
15
|
+
"bin": {
|
|
16
|
+
"localhook": "./cli.js"
|
|
17
|
+
},
|
|
18
|
+
"files": [
|
|
19
|
+
"cli.js",
|
|
20
|
+
"lib",
|
|
21
|
+
"public"
|
|
22
|
+
],
|
|
23
|
+
"keywords": [
|
|
24
|
+
"webhook",
|
|
25
|
+
"testing",
|
|
26
|
+
"local",
|
|
27
|
+
"development",
|
|
28
|
+
"http",
|
|
29
|
+
"inspector",
|
|
30
|
+
"debug",
|
|
31
|
+
"server"
|
|
32
|
+
],
|
|
33
|
+
"engines": {
|
|
34
|
+
"node": ">=14"
|
|
35
|
+
},
|
|
36
|
+
"scripts": {
|
|
37
|
+
"test": "node --test test/*.test.js"
|
|
38
|
+
},
|
|
39
|
+
"dependencies": {
|
|
40
|
+
"express": "^4.21.0"
|
|
41
|
+
}
|
|
42
|
+
}
|