webhookdrop-tunnel 1.0.1 → 1.0.9
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/bin/tunnel.js +255 -72
- package/package.json +1 -1
package/bin/tunnel.js
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
* WebhookDrop Tunnel Client
|
|
5
5
|
*
|
|
6
6
|
* Forwards webhooks from WebhookDrop to your local development server.
|
|
7
|
+
* Uses connection pooling for high-concurrency SPA tunneling.
|
|
7
8
|
*
|
|
8
9
|
* Usage:
|
|
9
10
|
* npx webhookdrop-tunnel --token YOUR_TOKEN --local http://localhost:3000
|
|
@@ -28,12 +29,15 @@ try {
|
|
|
28
29
|
blue: (s) => s,
|
|
29
30
|
gray: (s) => s,
|
|
30
31
|
bold: (s) => s,
|
|
31
|
-
cyan: (s) => s
|
|
32
|
+
cyan: (s) => s,
|
|
33
|
+
magenta: (s) => s
|
|
32
34
|
};
|
|
33
35
|
}
|
|
34
36
|
|
|
35
|
-
const VERSION = '1.0.
|
|
37
|
+
const VERSION = '1.0.8';
|
|
36
38
|
const DEFAULT_SERVER = 'wss://webhookdrop.app';
|
|
39
|
+
const POOL_SIZE = 20; // Number of WebSocket connections
|
|
40
|
+
const PING_INTERVAL = 10000; // 10 seconds
|
|
37
41
|
|
|
38
42
|
// Parse command line arguments
|
|
39
43
|
program
|
|
@@ -44,9 +48,11 @@ program
|
|
|
44
48
|
.requiredOption('-l, --local <url>', 'Local server URL to forward webhooks to (e.g., http://localhost:3000)')
|
|
45
49
|
.option('-e, --endpoint <id>', 'Endpoint ID (usually embedded in token)')
|
|
46
50
|
.option('-s, --server <url>', 'WebhookDrop server URL', DEFAULT_SERVER)
|
|
51
|
+
.option('-p, --pool <size>', 'Connection pool size', String(POOL_SIZE))
|
|
47
52
|
.parse(process.argv);
|
|
48
53
|
|
|
49
54
|
const options = program.opts();
|
|
55
|
+
const poolSize = parseInt(options.pool) || POOL_SIZE;
|
|
50
56
|
|
|
51
57
|
// Validate local URL
|
|
52
58
|
let localUrl;
|
|
@@ -75,21 +81,11 @@ if (!endpointId) {
|
|
|
75
81
|
process.exit(1);
|
|
76
82
|
}
|
|
77
83
|
|
|
78
|
-
//
|
|
79
|
-
const wsUrl = `${options.server}/ws/tunnel/${endpointId}?token=${encodeURIComponent(options.token)}&local_url=${encodeURIComponent(options.local)}`;
|
|
80
|
-
|
|
81
|
-
// Format timestamp
|
|
84
|
+
// Timestamp helper
|
|
82
85
|
function timestamp() {
|
|
83
86
|
return chalk.gray(new Date().toLocaleTimeString());
|
|
84
87
|
}
|
|
85
88
|
|
|
86
|
-
// Print banner
|
|
87
|
-
console.log('');
|
|
88
|
-
console.log(chalk.cyan('╔══════════════════════════════════════════╗'));
|
|
89
|
-
console.log(chalk.cyan('║') + chalk.bold(' WebhookDrop Tunnel Client ') + chalk.cyan('║'));
|
|
90
|
-
console.log(chalk.cyan('╚══════════════════════════════════════════╝'));
|
|
91
|
-
console.log('');
|
|
92
|
-
|
|
93
89
|
// Forward HTTP request to local server
|
|
94
90
|
async function forwardRequest(data) {
|
|
95
91
|
return new Promise((resolve) => {
|
|
@@ -117,7 +113,7 @@ async function forwardRequest(data) {
|
|
|
117
113
|
// Request uncompressed content to avoid encoding issues
|
|
118
114
|
'accept-encoding': 'identity'
|
|
119
115
|
},
|
|
120
|
-
timeout:
|
|
116
|
+
timeout: 300000 // 5 minutes for very large files like n8n's nodes.json
|
|
121
117
|
};
|
|
122
118
|
|
|
123
119
|
const req = httpModule.request(requestOptions, (res) => {
|
|
@@ -126,13 +122,28 @@ async function forwardRequest(data) {
|
|
|
126
122
|
res.on('data', (chunk) => chunks.push(chunk));
|
|
127
123
|
res.on('end', () => {
|
|
128
124
|
const buffer = Buffer.concat(chunks);
|
|
125
|
+
const contentType = res.headers['content-type'] || '';
|
|
126
|
+
|
|
127
|
+
// Determine if content is text-based (like OnLocal approach)
|
|
128
|
+
const isText = contentType.includes('text/') ||
|
|
129
|
+
contentType.includes('json') ||
|
|
130
|
+
contentType.includes('javascript') ||
|
|
131
|
+
contentType.includes('xml') ||
|
|
132
|
+
contentType.includes('css');
|
|
133
|
+
|
|
134
|
+
// Use structured body format: {type, data}
|
|
135
|
+
let body;
|
|
136
|
+
if (isText) {
|
|
137
|
+
body = { type: 'text', data: buffer.toString('utf-8') };
|
|
138
|
+
} else {
|
|
139
|
+
body = { type: 'binary', data: buffer.toString('base64') };
|
|
140
|
+
}
|
|
141
|
+
|
|
129
142
|
resolve({
|
|
130
143
|
success: true,
|
|
131
144
|
status_code: res.statusCode,
|
|
132
145
|
headers: res.headers,
|
|
133
|
-
|
|
134
|
-
body: buffer.toString('base64'),
|
|
135
|
-
body_encoding: 'base64'
|
|
146
|
+
body: body
|
|
136
147
|
});
|
|
137
148
|
});
|
|
138
149
|
});
|
|
@@ -161,29 +172,53 @@ async function forwardRequest(data) {
|
|
|
161
172
|
});
|
|
162
173
|
}
|
|
163
174
|
|
|
164
|
-
//
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
175
|
+
// Connection pool
|
|
176
|
+
const connections = new Map();
|
|
177
|
+
// Local WebSocket sessions for passthrough
|
|
178
|
+
const localWsSessions = new Map();
|
|
179
|
+
let connectedCount = 0;
|
|
180
|
+
let totalRequests = 0;
|
|
181
|
+
let isShuttingDown = false;
|
|
169
182
|
|
|
170
|
-
|
|
183
|
+
// Create a single WebSocket connection
|
|
184
|
+
function createConnection(connectionId) {
|
|
185
|
+
const wsUrl = `${options.server}/ws/tunnel/${endpointId}?token=${encodeURIComponent(options.token)}&local_url=${encodeURIComponent(options.local)}&connection_id=${connectionId}`;
|
|
171
186
|
|
|
187
|
+
const ws = new WebSocket(wsUrl.replace('https://', 'wss://').replace('http://', 'ws://'));
|
|
172
188
|
let pingInterval;
|
|
189
|
+
let isConnected = false;
|
|
173
190
|
|
|
174
191
|
ws.on('open', () => {
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
192
|
+
isConnected = true;
|
|
193
|
+
connectedCount++;
|
|
194
|
+
|
|
195
|
+
if (connectedCount === 1) {
|
|
196
|
+
// First connection - show banner
|
|
197
|
+
console.log('');
|
|
198
|
+
console.log(chalk.cyan('╔══════════════════════════════════════════════════╗'));
|
|
199
|
+
console.log(chalk.cyan('║') + chalk.bold(' WebhookDrop Tunnel Client (Pool Mode) ') + chalk.cyan('║'));
|
|
200
|
+
console.log(chalk.cyan('╚══════════════════════════════════════════════════╝'));
|
|
201
|
+
console.log('');
|
|
202
|
+
console.log(`${timestamp()} Forwarding to: ${chalk.cyan(options.local)}`);
|
|
203
|
+
console.log('');
|
|
204
|
+
}
|
|
180
205
|
|
|
181
|
-
|
|
206
|
+
if (connectedCount === poolSize) {
|
|
207
|
+
console.log(`${timestamp()} ${chalk.green('✓')} ${chalk.bold(`All ${poolSize} connections established!`)}`);
|
|
208
|
+
console.log(`${timestamp()} Endpoint: ${chalk.cyan(endpointId.substring(0, 8))}...`);
|
|
209
|
+
console.log('');
|
|
210
|
+
console.log(`${timestamp()} ${chalk.yellow('Waiting for requests...')} (Press Ctrl+C to stop)`);
|
|
211
|
+
console.log('');
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Send ping every 10 seconds to keep connection alive
|
|
182
215
|
pingInterval = setInterval(() => {
|
|
183
216
|
if (ws.readyState === WebSocket.OPEN) {
|
|
184
217
|
ws.send(JSON.stringify({ type: 'ping' }));
|
|
185
218
|
}
|
|
186
|
-
},
|
|
219
|
+
}, PING_INTERVAL);
|
|
220
|
+
|
|
221
|
+
connections.set(connectionId, { ws, pingInterval, isConnected: true });
|
|
187
222
|
});
|
|
188
223
|
|
|
189
224
|
ws.on('message', async (data) => {
|
|
@@ -191,82 +226,230 @@ function connect() {
|
|
|
191
226
|
const message = JSON.parse(data.toString());
|
|
192
227
|
|
|
193
228
|
if (message.type === 'connected') {
|
|
194
|
-
// Initial connection confirmation
|
|
229
|
+
// Initial connection confirmation
|
|
195
230
|
return;
|
|
196
231
|
}
|
|
197
232
|
|
|
198
233
|
if (message.type === 'pong') {
|
|
199
|
-
//
|
|
234
|
+
// Heartbeat response - connection healthy
|
|
200
235
|
return;
|
|
201
236
|
}
|
|
202
237
|
|
|
203
238
|
if (message.type === 'webhook_request' || message.type === 'http_request') {
|
|
204
239
|
const requestId = message.request_id;
|
|
205
|
-
const requestData = message.data ||
|
|
206
|
-
const method = requestData.method || 'POST';
|
|
207
|
-
const path = requestData.path || '/';
|
|
240
|
+
const requestData = message.data || {};
|
|
208
241
|
|
|
209
|
-
|
|
242
|
+
totalRequests++;
|
|
243
|
+
console.log(`${timestamp()} ${chalk.blue('→')} ${requestData.method || 'POST'} ${requestData.path || '/'}`);
|
|
210
244
|
|
|
211
245
|
// Forward to local server
|
|
212
246
|
const response = await forwardRequest(requestData);
|
|
213
247
|
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
console.log(`${timestamp()} ${chalk.
|
|
248
|
+
// Log response size for debugging large files
|
|
249
|
+
const bodySize = response.body?.data?.length || 0;
|
|
250
|
+
if (bodySize > 100000) {
|
|
251
|
+
console.log(`${timestamp()} ${chalk.yellow('⚠')} Large response: ${Math.round(bodySize / 1024)}KB`);
|
|
218
252
|
}
|
|
219
253
|
|
|
220
|
-
// Send response back
|
|
221
|
-
|
|
222
|
-
type: 'webhook_response',
|
|
254
|
+
// Send response back
|
|
255
|
+
const responseMessage = {
|
|
256
|
+
type: message.type === 'webhook_request' ? 'webhook_response' : 'http_response',
|
|
223
257
|
request_id: requestId,
|
|
224
258
|
response: response
|
|
225
|
-
}
|
|
259
|
+
};
|
|
260
|
+
|
|
261
|
+
try {
|
|
262
|
+
const jsonStr = JSON.stringify(responseMessage);
|
|
263
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
264
|
+
ws.send(jsonStr);
|
|
265
|
+
}
|
|
266
|
+
} catch (jsonErr) {
|
|
267
|
+
console.error(`${timestamp()} ${chalk.red('✗')} JSON serialize error: ${jsonErr.message}`);
|
|
268
|
+
// Send error response instead
|
|
269
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
270
|
+
ws.send(JSON.stringify({
|
|
271
|
+
type: 'http_response',
|
|
272
|
+
request_id: requestId,
|
|
273
|
+
response: { success: false, status_code: 500, error: 'Response too large to serialize' }
|
|
274
|
+
}));
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const statusColor = response.success && response.status_code < 400 ? chalk.green : chalk.red;
|
|
279
|
+
console.log(`${timestamp()} ${statusColor('←')} ${response.status_code || 502} ${response.success ? 'OK' : response.error || 'Error'}`);
|
|
226
280
|
}
|
|
227
281
|
|
|
228
|
-
|
|
229
|
-
|
|
282
|
+
// WebSocket passthrough: connect to local WebSocket
|
|
283
|
+
else if (message.type === 'ws_connect') {
|
|
284
|
+
const sessionId = message.data?.session_id || message.session_id;
|
|
285
|
+
const wsPath = message.data?.path || message.path || '/';
|
|
286
|
+
const wsQuery = message.data?.query || message.query || '';
|
|
287
|
+
const requestId = message.request_id;
|
|
288
|
+
|
|
289
|
+
console.log(`${timestamp()} ${chalk.magenta('⚡')} WS Connect: ${wsPath}`);
|
|
290
|
+
|
|
291
|
+
try {
|
|
292
|
+
// Build local WebSocket URL
|
|
293
|
+
const localWsUrl = new URL(wsPath, options.local);
|
|
294
|
+
localWsUrl.protocol = localWsUrl.protocol.replace('http', 'ws');
|
|
295
|
+
if (wsQuery) {
|
|
296
|
+
localWsUrl.search = wsQuery;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// Connect to local WebSocket
|
|
300
|
+
const localWs = new WebSocket(localWsUrl.toString());
|
|
301
|
+
|
|
302
|
+
localWs.on('open', () => {
|
|
303
|
+
console.log(`${timestamp()} ${chalk.green('✓')} WS Connected: ${sessionId.slice(0, 8)}`);
|
|
304
|
+
localWsSessions.set(sessionId, { ws: localWs, tunnelWs: ws });
|
|
305
|
+
|
|
306
|
+
// Send success response
|
|
307
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
308
|
+
ws.send(JSON.stringify({
|
|
309
|
+
type: 'http_response',
|
|
310
|
+
request_id: requestId,
|
|
311
|
+
response: { success: true }
|
|
312
|
+
}));
|
|
313
|
+
}
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
localWs.on('message', (data) => {
|
|
317
|
+
// Forward message from local WebSocket to server
|
|
318
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
319
|
+
ws.send(JSON.stringify({
|
|
320
|
+
type: 'ws_message',
|
|
321
|
+
session_id: sessionId,
|
|
322
|
+
message: data.toString()
|
|
323
|
+
}));
|
|
324
|
+
}
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
localWs.on('close', () => {
|
|
328
|
+
console.log(`${timestamp()} ${chalk.yellow('○')} WS Closed: ${sessionId.slice(0, 8)}`);
|
|
329
|
+
localWsSessions.delete(sessionId);
|
|
330
|
+
|
|
331
|
+
// Notify server
|
|
332
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
333
|
+
ws.send(JSON.stringify({
|
|
334
|
+
type: 'ws_closed',
|
|
335
|
+
session_id: sessionId
|
|
336
|
+
}));
|
|
337
|
+
}
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
localWs.on('error', (err) => {
|
|
341
|
+
console.log(`${timestamp()} ${chalk.red('✗')} WS Error: ${err.message}`);
|
|
342
|
+
localWsSessions.delete(sessionId);
|
|
343
|
+
|
|
344
|
+
// Send error response
|
|
345
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
346
|
+
ws.send(JSON.stringify({
|
|
347
|
+
type: 'http_response',
|
|
348
|
+
request_id: requestId,
|
|
349
|
+
response: { success: false, error: err.message }
|
|
350
|
+
}));
|
|
351
|
+
}
|
|
352
|
+
});
|
|
353
|
+
} catch (e) {
|
|
354
|
+
console.log(`${timestamp()} ${chalk.red('✗')} WS Connect Error: ${e.message}`);
|
|
355
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
356
|
+
ws.send(JSON.stringify({
|
|
357
|
+
type: 'http_response',
|
|
358
|
+
request_id: requestId,
|
|
359
|
+
response: { success: false, error: e.message }
|
|
360
|
+
}));
|
|
361
|
+
}
|
|
362
|
+
}
|
|
230
363
|
}
|
|
231
364
|
|
|
365
|
+
// WebSocket passthrough: forward message to local WebSocket
|
|
366
|
+
else if (message.type === 'ws_message') {
|
|
367
|
+
const sessionId = message.data?.session_id || message.session_id;
|
|
368
|
+
const wsMessage = message.data?.message || message.message;
|
|
369
|
+
|
|
370
|
+
const session = localWsSessions.get(sessionId);
|
|
371
|
+
if (session && session.ws.readyState === WebSocket.OPEN) {
|
|
372
|
+
session.ws.send(wsMessage);
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// WebSocket passthrough: close local WebSocket
|
|
377
|
+
else if (message.type === 'ws_close') {
|
|
378
|
+
const sessionId = message.data?.session_id || message.session_id;
|
|
379
|
+
|
|
380
|
+
const session = localWsSessions.get(sessionId);
|
|
381
|
+
if (session) {
|
|
382
|
+
session.ws.close();
|
|
383
|
+
localWsSessions.delete(sessionId);
|
|
384
|
+
console.log(`${timestamp()} ${chalk.yellow('○')} WS Close: ${sessionId.slice(0, 8)}`);
|
|
385
|
+
}
|
|
386
|
+
}
|
|
232
387
|
} catch (e) {
|
|
233
|
-
console.error(`${timestamp()} ${chalk.red('Error
|
|
388
|
+
console.error(`${timestamp()} ${chalk.red('Error processing message:')} ${e.message}`);
|
|
234
389
|
}
|
|
235
390
|
});
|
|
236
391
|
|
|
237
|
-
ws.on('close', (
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
392
|
+
ws.on('close', () => {
|
|
393
|
+
if (isConnected) {
|
|
394
|
+
connectedCount--;
|
|
395
|
+
}
|
|
396
|
+
isConnected = false;
|
|
241
397
|
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
398
|
+
if (pingInterval) {
|
|
399
|
+
clearInterval(pingInterval);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
connections.delete(connectionId);
|
|
403
|
+
|
|
404
|
+
if (!isShuttingDown) {
|
|
405
|
+
// Reconnect after delay
|
|
406
|
+
setTimeout(() => {
|
|
407
|
+
if (!isShuttingDown) {
|
|
408
|
+
createConnection(connectionId);
|
|
409
|
+
}
|
|
410
|
+
}, 2000);
|
|
411
|
+
}
|
|
245
412
|
});
|
|
246
413
|
|
|
247
414
|
ws.on('error', (error) => {
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
if (error.message.includes('401')) {
|
|
251
|
-
console.error(`${timestamp()} ${chalk.red('❌ Authentication failed. Please check your token.')}`);
|
|
252
|
-
process.exit(1);
|
|
253
|
-
} else if (error.message.includes('404')) {
|
|
254
|
-
console.error(`${timestamp()} ${chalk.red('❌ Endpoint not found. Please check your endpoint ID.')}`);
|
|
255
|
-
process.exit(1);
|
|
256
|
-
} else {
|
|
415
|
+
// Suppress error logging for normal reconnection cases
|
|
416
|
+
if (!isShuttingDown && error.code !== 'ECONNREFUSED') {
|
|
257
417
|
console.error(`${timestamp()} ${chalk.red('Connection error:')} ${error.message}`);
|
|
258
|
-
console.log(`${timestamp()} Reconnecting in 5 seconds...`);
|
|
259
|
-
setTimeout(connect, 5000);
|
|
260
418
|
}
|
|
261
419
|
});
|
|
420
|
+
|
|
421
|
+
return ws;
|
|
262
422
|
}
|
|
263
423
|
|
|
264
|
-
//
|
|
265
|
-
|
|
424
|
+
// Start connection pool
|
|
425
|
+
console.log('');
|
|
426
|
+
console.log(`${timestamp()} Starting connection pool (${poolSize} connections)...`);
|
|
427
|
+
|
|
428
|
+
for (let i = 0; i < poolSize; i++) {
|
|
429
|
+
const connectionId = `conn-${i.toString().padStart(2, '0')}`;
|
|
430
|
+
setTimeout(() => createConnection(connectionId), i * 100); // Stagger connections
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// Handle shutdown
|
|
434
|
+
function shutdown() {
|
|
435
|
+
if (isShuttingDown) return;
|
|
436
|
+
isShuttingDown = true;
|
|
437
|
+
|
|
266
438
|
console.log('');
|
|
267
|
-
console.log(`${timestamp()}
|
|
268
|
-
|
|
269
|
-
|
|
439
|
+
console.log(`${timestamp()} Shutting down tunnel...`);
|
|
440
|
+
console.log(`${timestamp()} Total requests handled: ${totalRequests}`);
|
|
441
|
+
|
|
442
|
+
for (const [id, conn] of connections) {
|
|
443
|
+
if (conn.pingInterval) {
|
|
444
|
+
clearInterval(conn.pingInterval);
|
|
445
|
+
}
|
|
446
|
+
if (conn.ws && conn.ws.readyState === WebSocket.OPEN) {
|
|
447
|
+
conn.ws.close();
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
setTimeout(() => process.exit(0), 1000);
|
|
452
|
+
}
|
|
270
453
|
|
|
271
|
-
|
|
272
|
-
|
|
454
|
+
process.on('SIGINT', shutdown);
|
|
455
|
+
process.on('SIGTERM', shutdown);
|