@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 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
+ ![LocalHook Dashboard](.github/screenshot.png)
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
+ }