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.
Files changed (2) hide show
  1. package/bin/tunnel.js +255 -72
  2. 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.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
- // Build WebSocket URL
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: 30000
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
- // Send as base64 to preserve binary data
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
- // Main tunnel connection
165
- function connect() {
166
- console.log(`${timestamp()} Connecting to WebhookDrop...`);
167
- console.log(`${timestamp()} Forwarding to: ${chalk.green(options.local)}`);
168
- console.log('');
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
- const ws = new WebSocket(wsUrl.replace('https://', 'wss://').replace('http://', 'ws://'));
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
- console.log(`${timestamp()} ${chalk.green('✓')} ${chalk.bold('Tunnel connected!')}`);
176
- console.log(`${timestamp()} Endpoint: ${chalk.cyan(endpointId.substring(0, 8))}...`);
177
- console.log('');
178
- console.log(`${timestamp()} ${chalk.yellow('Waiting for webhooks...')} (Press Ctrl+C to stop)`);
179
- console.log('');
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
- // Send ping every 25 seconds to keep connection alive
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
- }, 25000);
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 - already handled in 'open'
229
+ // Initial connection confirmation
195
230
  return;
196
231
  }
197
232
 
198
233
  if (message.type === 'pong') {
199
- // Ignore pong responses
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 || message;
206
- const method = requestData.method || 'POST';
207
- const path = requestData.path || '/';
240
+ const requestData = message.data || {};
208
241
 
209
- console.log(`${timestamp()} ${chalk.blue('→')} ${chalk.bold(method)} ${path}`);
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
- if (response.success) {
215
- console.log(`${timestamp()} ${chalk.green('←')} ${response.status_code} OK`);
216
- } else {
217
- console.log(`${timestamp()} ${chalk.red('')} ${response.status_code} ${response.error}`);
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 to server
221
- ws.send(JSON.stringify({
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
- if (message.type === 'error') {
229
- console.log(`${timestamp()} ${chalk.red('Server error:')} ${message.error}`);
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 parsing message:')} ${e.message}`);
388
+ console.error(`${timestamp()} ${chalk.red('Error processing message:')} ${e.message}`);
234
389
  }
235
390
  });
236
391
 
237
- ws.on('close', (code, reason) => {
238
- clearInterval(pingInterval);
239
- console.log('');
240
- console.log(`${timestamp()} ${chalk.yellow('Connection closed:')} ${reason || 'Unknown reason'}`);
392
+ ws.on('close', () => {
393
+ if (isConnected) {
394
+ connectedCount--;
395
+ }
396
+ isConnected = false;
241
397
 
242
- // Reconnect after 5 seconds
243
- console.log(`${timestamp()} Reconnecting in 5 seconds...`);
244
- setTimeout(connect, 5000);
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
- clearInterval(pingInterval);
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
- // Handle graceful shutdown
265
- process.on('SIGINT', () => {
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()} ${chalk.yellow('Shutting down tunnel...')}`);
268
- process.exit(0);
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
- // Start the tunnel
272
- connect();
454
+ process.on('SIGINT', shutdown);
455
+ process.on('SIGTERM', shutdown);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "webhookdrop-tunnel",
3
- "version": "1.0.1",
3
+ "version": "1.0.9",
4
4
  "description": "Forward webhooks from WebhookDrop to your local development server",
5
5
  "main": "index.js",
6
6
  "bin": {