@cmer/localhook 1.1.0 → 1.2.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 CHANGED
@@ -34,6 +34,12 @@ npx @cmer/localhook --port 8080 --tailscale
34
34
 
35
35
  # Allow dashboard access from the public URL (password-protected)
36
36
  npx @cmer/localhook --tailscale --allow-remote-access --password mysecret
37
+
38
+ # Forward webhooks to your local app
39
+ npx @cmer/localhook --forward-to http://localhost:4444
40
+
41
+ # Forward with a base path
42
+ npx @cmer/localhook --forward-to http://localhost:4444/api/webhooks
37
43
  ```
38
44
 
39
45
  | Flag | Short | Description |
@@ -44,6 +50,7 @@ npx @cmer/localhook --tailscale --allow-remote-access --password mysecret
44
50
  | `--allow-remote-access` | | Allow dashboard/API access from non-localhost (e.g. via tunnel) |
45
51
  | `--password <value>` | | Require HTTP Basic Auth for remote dashboard/API access (localhost is never challenged) |
46
52
  | `--data-file <path>` | | Path to data file (default: `~/.localhook/data.json`) |
53
+ | `--forward-to <url>` | | Forward incoming webhooks to a local app (preserves method, path, headers, body) |
47
54
  | `--help` | `-h` | Show help |
48
55
 
49
56
  Open `http://localhost:3000` in your browser to see the dashboard.
@@ -74,6 +81,31 @@ Incoming requests are also logged in the terminal:
74
81
  - **Zero config** -- no database, no build step, no accounts
75
82
  - **Terminal logging** -- see requests in your terminal without opening the dashboard
76
83
 
84
+ ## Webhook Forwarding
85
+
86
+ Use `--forward-to` to forward incoming webhooks to your local application while still capturing them in the dashboard:
87
+
88
+ ```bash
89
+ npx @cmer/localhook --forward-to http://localhost:4444
90
+ ```
91
+
92
+ Every captured webhook is forwarded synchronously — your app receives the original HTTP method, path, query string, headers (including signature headers like `x-stripe-signature`), and body. The caller (e.g. Stripe) receives your app's actual response, so retries work correctly on 5xx errors.
93
+
94
+ You can also specify a base path that gets prepended to the webhook path:
95
+
96
+ ```bash
97
+ # Webhook to /events → forwarded to http://localhost:4444/api/webhooks/events
98
+ npx @cmer/localhook --forward-to http://localhost:4444/api/webhooks
99
+ ```
100
+
101
+ The dashboard shows forwarding results: status code, duration, and response body for each request. If the target is unreachable, the caller receives a 502 and the error is displayed in the dashboard.
102
+
103
+ Combine with a tunnel for end-to-end webhook testing:
104
+
105
+ ```bash
106
+ npx @cmer/localhook --cloudflare --forward-to http://localhost:4444
107
+ ```
108
+
77
109
  ## Testing with External Services
78
110
 
79
111
  If you need to receive webhooks from external services like Stripe, GitHub, or Shopify, they need a public URL to send requests to.
package/cli.js CHANGED
@@ -10,6 +10,7 @@ let allowRemoteAccess = false;
10
10
  let password = null;
11
11
  let poll = false;
12
12
  let dataFile = null;
13
+ let forwardTo = null;
13
14
 
14
15
  for (let i = 0; i < args.length; i++) {
15
16
  if ((args[i] === '--port' || args[i] === '-p') && args[i + 1]) {
@@ -29,6 +30,9 @@ for (let i = 0; i < args.length; i++) {
29
30
  } else if (args[i] === '--data-file' && args[i + 1]) {
30
31
  dataFile = args[i + 1];
31
32
  i++;
33
+ } else if (args[i] === '--forward-to' && args[i + 1]) {
34
+ forwardTo = args[i + 1];
35
+ i++;
32
36
  } else if (args[i] === '--help' || args[i] === '-h') {
33
37
  console.log(`
34
38
  localhook - Local webhook testing tool
@@ -44,6 +48,7 @@ for (let i = 0; i < args.length; i++) {
44
48
  --password <value> Require HTTP Basic Auth for remote dashboard/API access
45
49
  --poll Force polling instead of SSE for dashboard updates
46
50
  --data-file <path> Path to data file (default: ~/.localhook/data.json)
51
+ --forward-to <url> Forward incoming webhooks to a local app (e.g. http://localhost:4444)
47
52
  -h, --help Show this help message
48
53
  `);
49
54
  process.exit(0);
@@ -55,4 +60,15 @@ if (tailscale && cloudflare) {
55
60
  process.exit(1);
56
61
  }
57
62
 
58
- createServer(port, { tailscale, cloudflare, allowRemoteAccess, password, poll, dataFile });
63
+ if (forwardTo) {
64
+ try {
65
+ const u = new URL(forwardTo);
66
+ if (u.protocol !== 'http:' && u.protocol !== 'https:') throw new Error();
67
+ forwardTo = forwardTo.replace(/\/+$/, '');
68
+ } catch {
69
+ console.error('\n Error: --forward-to must be a valid http:// or https:// URL.\n');
70
+ process.exit(1);
71
+ }
72
+ }
73
+
74
+ createServer(port, { tailscale, cloudflare, allowRemoteAccess, password, poll, dataFile, forwardTo });
package/lib/server.js CHANGED
@@ -186,6 +186,54 @@ function generateId() {
186
186
  return crypto.randomBytes(8).toString('hex');
187
187
  }
188
188
 
189
+ const FORWARD_TIMEOUT_MS = 10000;
190
+ const MAX_RESPONSE_BODY = 100 * 1024;
191
+
192
+ async function forwardWebhook(webhook, forwardTo) {
193
+ const targetUrl = forwardTo + webhook.path;
194
+ const headers = {};
195
+ for (const [k, v] of Object.entries(webhook.headers)) {
196
+ const lower = k.toLowerCase();
197
+ if (lower === 'host' || lower === 'connection' || lower === 'content-length' || lower === 'transfer-encoding') continue;
198
+ headers[k] = v;
199
+ }
200
+ try {
201
+ const targetHost = new URL(targetUrl).host;
202
+ headers['host'] = targetHost;
203
+ } catch {}
204
+
205
+ const fetchOptions = {
206
+ method: webhook.method,
207
+ headers,
208
+ signal: AbortSignal.timeout(FORWARD_TIMEOUT_MS),
209
+ };
210
+ if (webhook.method !== 'GET' && webhook.method !== 'HEAD' && webhook.body) {
211
+ fetchOptions.body = webhook.body;
212
+ }
213
+
214
+ const start = Date.now();
215
+ try {
216
+ const res = await fetch(targetUrl, fetchOptions);
217
+ let responseBody = await res.text();
218
+ if (responseBody.length > MAX_RESPONSE_BODY) {
219
+ responseBody = responseBody.slice(0, MAX_RESPONSE_BODY) + '\n... (truncated)';
220
+ }
221
+ return {
222
+ url: targetUrl,
223
+ status: res.status,
224
+ statusText: res.statusText,
225
+ responseBody,
226
+ duration: Date.now() - start,
227
+ };
228
+ } catch (err) {
229
+ return {
230
+ url: targetUrl,
231
+ error: err.name === 'TimeoutError' ? 'Request timed out (10s)' : err.message,
232
+ duration: Date.now() - start,
233
+ };
234
+ }
235
+ }
236
+
189
237
  function createServer(port, options = {}) {
190
238
  if (options.dataFile) {
191
239
  dataFile = path.resolve(options.dataFile);
@@ -274,7 +322,7 @@ function createServer(port, options = {}) {
274
322
  });
275
323
 
276
324
  app.get('/_/api/public_url', (req, res) => {
277
- res.json({ url: publicUrl, service: tunnelService, poll: !!options.poll });
325
+ res.json({ url: publicUrl, service: tunnelService, poll: !!options.poll, forwardTo: options.forwardTo || null });
278
326
  });
279
327
 
280
328
  app.get('/_/api/webhooks', (req, res) => {
@@ -312,7 +360,7 @@ function createServer(port, options = {}) {
312
360
 
313
361
  const chunks = [];
314
362
  req.on('data', chunk => chunks.push(chunk));
315
- req.on('end', () => {
363
+ req.on('end', async () => {
316
364
  const body = Buffer.concat(chunks).toString('utf-8');
317
365
 
318
366
  const webhook = {
@@ -327,12 +375,25 @@ function createServer(port, options = {}) {
327
375
  timestamp: new Date().toISOString(),
328
376
  };
329
377
 
378
+ if (options.forwardTo) {
379
+ webhook.forward = await forwardWebhook(webhook, options.forwardTo);
380
+ }
381
+
330
382
  // Log to terminal
331
383
  const d = new Date(webhook.timestamp);
332
384
  const time = d.toLocaleDateString() + ' ' + d.toLocaleTimeString();
333
385
  const ct = webhook.headers['content-type'] || '';
334
386
  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`);
387
+ let forwardInfo = '';
388
+ if (webhook.forward) {
389
+ if (webhook.forward.error) {
390
+ forwardInfo = ` \x1b[31m=> ERR ${webhook.forward.error}\x1b[0m`;
391
+ } else {
392
+ const statusColor = webhook.forward.status < 400 ? '\x1b[32m' : webhook.forward.status < 500 ? '\x1b[33m' : '\x1b[31m';
393
+ forwardInfo = ` ${statusColor}=> ${webhook.forward.status} ${webhook.forward.duration}ms\x1b[0m`;
394
+ }
395
+ }
396
+ console.log(` \x1b[2m${time}\x1b[0m \x1b[1m${webhook.method.padEnd(7)}\x1b[0m ${webhook.path}\x1b[2m${sizeStr}${ct ? ' ' + ct : ''}\x1b[0m${forwardInfo}`);
336
397
 
337
398
  webhooks.unshift(webhook);
338
399
  if (webhooks.length > MAX_WEBHOOKS) {
@@ -341,7 +402,15 @@ function createServer(port, options = {}) {
341
402
  saveData();
342
403
  broadcast({ type: 'webhook', webhook });
343
404
 
344
- res.status(200).json({ ok: true, id: webhook.id });
405
+ if (webhook.forward) {
406
+ if (webhook.forward.error) {
407
+ res.status(502).json({ ok: false, error: webhook.forward.error, id: webhook.id });
408
+ } else {
409
+ res.status(webhook.forward.status).send(webhook.forward.responseBody);
410
+ }
411
+ } else {
412
+ res.status(200).json({ ok: true, id: webhook.id });
413
+ }
345
414
  });
346
415
  });
347
416
 
@@ -355,6 +424,7 @@ function createServer(port, options = {}) {
355
424
  const magenta = '\x1b[35m';
356
425
 
357
426
  const passwordLine = options.password ? `\n ${magenta}Password${reset} enabled (remote only)` : '';
427
+ const forwardLine = options.forwardTo ? `\n ${yellow}Forwarding${reset} ${options.forwardTo}` : '';
358
428
 
359
429
  if (options.tailscale || options.cloudflare) {
360
430
  const serviceName = options.tailscale ? 'Tailscale Funnel' : 'Cloudflare Quick Tunnel';
@@ -372,7 +442,7 @@ function createServer(port, options = {}) {
372
442
  ${green}Webhook URL${reset} http://localhost:${port}${dim}/any-path${reset}
373
443
  ${result.url}${dim}/any-path${reset}
374
444
 
375
- ${cyan}Dashboard${reset} http://localhost:${port}/${options.allowRemoteAccess ? `\n ${result.url}/` : ''}${passwordLine}
445
+ ${cyan}Dashboard${reset} http://localhost:${port}/${options.allowRemoteAccess ? `\n ${result.url}/` : ''}${passwordLine}${forwardLine}
376
446
 
377
447
  ${dim}Send any HTTP request to capture it.${reset}
378
448
  ${dim}Press Ctrl+C to stop.${reset}
@@ -404,7 +474,7 @@ function createServer(port, options = {}) {
404
474
  ${bold}LocalHook${reset} is running!
405
475
 
406
476
  ${green}Webhook URL${reset} http://localhost:${port}${dim}/any-path${reset}
407
- ${cyan}Dashboard${reset} http://localhost:${port}/${passwordLine}
477
+ ${cyan}Dashboard${reset} http://localhost:${port}/${passwordLine}${forwardLine}
408
478
 
409
479
  ${dim}Send any HTTP request to capture it.${reset}
410
480
  ${dim}Press Ctrl+C to stop.${reset}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cmer/localhook",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
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
5
  "author": "Carl Mercier",
6
6
  "license": "MIT",
@@ -31,7 +31,7 @@
31
31
  "server"
32
32
  ],
33
33
  "engines": {
34
- "node": ">=14"
34
+ "node": ">=18"
35
35
  },
36
36
  "scripts": {
37
37
  "test": "node --test test/*.test.js"
package/public/index.html CHANGED
@@ -563,6 +563,35 @@ body {
563
563
  [data-theme="light"] .webhook-item .delete-btn:hover {
564
564
  background: rgba(220,38,38,0.08);
565
565
  }
566
+
567
+ /* Forward badges */
568
+ .forward-badge {
569
+ display: inline-flex;
570
+ align-items: center;
571
+ padding: 1px 6px;
572
+ border-radius: 3px;
573
+ font-size: 9px;
574
+ font-weight: 600;
575
+ letter-spacing: 0.02em;
576
+ flex-shrink: 0;
577
+ }
578
+
579
+ .forward-badge.success { background: rgba(74,222,128,0.12); color: var(--green); }
580
+ .forward-badge.client-error { background: rgba(251,146,60,0.12); color: var(--orange); }
581
+ .forward-badge.server-error { background: rgba(248,113,113,0.12); color: var(--red); }
582
+ .forward-badge.error { background: rgba(248,113,113,0.12); color: var(--red); }
583
+
584
+ [data-theme="light"] .forward-badge.success { background: rgba(22,163,74,0.1); }
585
+ [data-theme="light"] .forward-badge.client-error { background: rgba(234,88,12,0.1); }
586
+ [data-theme="light"] .forward-badge.server-error { background: rgba(220,38,38,0.1); }
587
+ [data-theme="light"] .forward-badge.error { background: rgba(220,38,38,0.1); }
588
+
589
+ .forward-info {
590
+ margin-top: 4px;
591
+ font-size: 10px;
592
+ color: var(--text-muted);
593
+ font-family: var(--mono);
594
+ }
566
595
  </style>
567
596
  </head>
568
597
  <body>
@@ -810,6 +839,16 @@ function copyBody() {
810
839
  navigator.clipboard.writeText(text).then(() => showToast('Copied!'));
811
840
  }
812
841
 
842
+ function copyForwardBody() {
843
+ const w = state.webhooks.find(w => w.id === state.selected);
844
+ if (!w || !w.forward || !w.forward.responseBody) return;
845
+ let text = w.forward.responseBody;
846
+ if (state.formatJson) {
847
+ try { text = JSON.stringify(JSON.parse(w.forward.responseBody), null, 2); } catch {}
848
+ }
849
+ navigator.clipboard.writeText(text).then(() => showToast('Copied!'));
850
+ }
851
+
813
852
  function toggleFormat() {
814
853
  state.formatJson = !state.formatJson;
815
854
  renderDetail();
@@ -827,17 +866,31 @@ function renderList() {
827
866
  const n = state.webhooks.length;
828
867
  count.textContent = `${n} request${n !== 1 ? 's' : ''}`;
829
868
 
830
- list.innerHTML = state.webhooks.map(w => `
869
+ list.innerHTML = state.webhooks.map(w => {
870
+ let forwardBadge = '';
871
+ if (w.forward) {
872
+ if (w.forward.error) {
873
+ forwardBadge = '<span class="forward-badge error">ERR</span>';
874
+ } else if (w.forward.status >= 500) {
875
+ forwardBadge = `<span class="forward-badge server-error">${w.forward.status}</span>`;
876
+ } else if (w.forward.status >= 400) {
877
+ forwardBadge = `<span class="forward-badge client-error">${w.forward.status}</span>`;
878
+ } else {
879
+ forwardBadge = `<span class="forward-badge success">${w.forward.status}</span>`;
880
+ }
881
+ }
882
+ return `
831
883
  <div class="webhook-item ${w.id === state.selected ? 'selected' : ''}"
832
884
  onclick="selectWebhook('${w.id}')">
833
885
  <span class="method-badge method-${w.method}">${esc(w.method)}</span>
834
886
  <div class="webhook-item-info">
835
887
  <div class="webhook-item-path">${esc(w.path)}</div>
836
- <div class="webhook-item-time">${formatTime(w.timestamp)}</div>
888
+ <div class="webhook-item-time">${formatTime(w.timestamp)}${forwardBadge ? ' ' + forwardBadge : ''}</div>
837
889
  </div>
838
890
  <button class="delete-btn" onclick="deleteWebhook('${w.id}', event)" title="Delete">&times;</button>
839
891
  </div>
840
- `).join('');
892
+ `;
893
+ }).join('');
841
894
  }
842
895
 
843
896
  function renderDetail() {
@@ -941,6 +994,55 @@ function renderDetail() {
941
994
  </div>
942
995
  ${bodyHtml}
943
996
  </div>
997
+
998
+ ${w.forward ? (() => {
999
+ let fwdBodyHtml = '';
1000
+ if (w.forward.responseBody) {
1001
+ let fwdContent = w.forward.responseBody;
1002
+ let fwdHighlighted = false;
1003
+ if (state.formatJson) {
1004
+ try {
1005
+ const parsed = JSON.parse(w.forward.responseBody);
1006
+ fwdContent = JSON.stringify(parsed, null, 2);
1007
+ fwdHighlighted = true;
1008
+ } catch {}
1009
+ }
1010
+ const fwdDisplay = fwdHighlighted ? highlightJson(fwdContent) : esc(fwdContent);
1011
+ fwdBodyHtml = '<pre class="body-pre ' + (state.wordWrap ? 'word-wrap' : '') + '">' + fwdDisplay + '</pre>';
1012
+ } else {
1013
+ fwdBodyHtml = '<p class="none-text">No response body</p>';
1014
+ }
1015
+ return `
1016
+ <div class="section">
1017
+ <div class="section-header">Forwarding</div>
1018
+ <div class="detail-grid" style="max-width:600px">
1019
+ <div class="label">Target URL</div>
1020
+ <div class="value" style="font-family:var(--mono);font-size:11px">${esc(w.forward.url)}</div>
1021
+ <div class="label">Status</div>
1022
+ <div class="value">${w.forward.error
1023
+ ? '<span class="forward-badge error">ERR</span> ' + esc(w.forward.error)
1024
+ : w.forward.status >= 500
1025
+ ? '<span class="forward-badge server-error">' + w.forward.status + '</span> ' + esc(w.forward.statusText)
1026
+ : w.forward.status >= 400
1027
+ ? '<span class="forward-badge client-error">' + w.forward.status + '</span> ' + esc(w.forward.statusText)
1028
+ : '<span class="forward-badge success">' + w.forward.status + '</span> ' + esc(w.forward.statusText)
1029
+ }</div>
1030
+ <div class="label">Duration</div>
1031
+ <div class="value">${w.forward.duration}ms</div>
1032
+ </div>
1033
+ </div>
1034
+ <div class="section">
1035
+ <div class="section-header">
1036
+ Response Body
1037
+ <div class="body-controls">
1038
+ <label><input type="checkbox" ${state.formatJson ? 'checked' : ''} onchange="toggleFormat()"> Format JSON</label>
1039
+ <label><input type="checkbox" ${state.wordWrap ? 'checked' : ''} onchange="toggleWrap()"> Word Wrap</label>
1040
+ <button class="btn" onclick="copyForwardBody()">Copy</button>
1041
+ </div>
1042
+ </div>
1043
+ ${fwdBodyHtml}
1044
+ </div>`;
1045
+ })() : ''}
944
1046
  </div>`;
945
1047
  }
946
1048
 
@@ -1032,6 +1134,12 @@ loadWebhooks();
1032
1134
  const res = await fetch('/_/api/public_url');
1033
1135
  const info = await res.json();
1034
1136
  if (info.poll) forcePoll = true;
1137
+ if (info.forwardTo) {
1138
+ const fwdEl = document.createElement('div');
1139
+ fwdEl.className = 'forward-info';
1140
+ fwdEl.textContent = 'Forwarding to ' + info.forwardTo;
1141
+ document.getElementById('url-box').after(fwdEl);
1142
+ }
1035
1143
  } catch {}
1036
1144
  if (forcePoll) {
1037
1145
  startPolling();