@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 +32 -0
- package/cli.js +17 -1
- package/lib/server.js +76 -6
- package/package.json +2 -2
- package/public/index.html +111 -3
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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": ">=
|
|
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">×</button>
|
|
839
891
|
</div>
|
|
840
|
-
|
|
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();
|