UNPKG

bunnel

Version:
150 lines (146 loc) 5.04 kB
import pino from 'pino'; import WebSocket from 'ws'; const logger = pino({ level: process.env.BUNNEL_LOG_LEVEL || "error" }); const DEFAULT_OPTIONS = { serverCheckTimeout: 5e3 }; class TunnelClient { constructor(options) { this.ws = null; this.options = { ...DEFAULT_OPTIONS, ...options }; this.localServerUrl = this.options.localServerUrl; this.tunnelServerUrl = this.options.tunnelServerUrl; this.serverCheckTimeout = this.options.serverCheckTimeout; } /** * Check if the local server is available * @throws Error if the server is unavailable */ async checkLocalServerAvailability() { try { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), this.serverCheckTimeout); const response = await fetch(this.localServerUrl, { method: "HEAD", signal: controller.signal }); clearTimeout(timeoutId); if (!response.ok) { throw new Error(`Local server responded with status: ${response.status}`); } } catch (error) { throw new Error(`Local server at ${this.localServerUrl} is unavailable: ${error instanceof Error ? error.message : String(error)}`); } } /** * Connect to the tunnel server * @returns Promise with connection info * @throws Error if connection fails or local server is unavailable */ async connect() { if (this.ws) { throw new Error("Already connected"); } await this.checkLocalServerAvailability(); return new Promise((resolve, reject) => { this.ws = new WebSocket(this.tunnelServerUrl); this.ws.on("open", () => { logger.debug("Connected to tunnel server"); }); this.ws.on("message", async (rawData) => { try { const data = JSON.parse(rawData.toString()); logger.debug("Received WS message:"); logger.debug(data); if (data.type === "connected") { const message = data; const tunnelPort = new URL(this.tunnelServerUrl).port || "4444"; resolve({ subdomain: message.subdomain, tunnelUrl: `http://${message.subdomain}.localhost:${tunnelPort}` }); return; } const request = data; logger.debug(`Forwarding request to local server: ${this.localServerUrl}${request.path}`); logger.debug(`Request method: ${request.method}`); logger.debug(`Original request headers:`, request.headers); const localResponse = await fetch(`${this.localServerUrl}${request.path}`, { method: request.method, headers: request.headers, body: request.body }); logger.debug(`Response from local server: Status ${localResponse.status}`); logger.debug(`Response headers from local server:`); localResponse.headers.forEach((value, key) => { logger.debug(` ${key}: ${value}`); }); const headers = {}; localResponse.headers.forEach((value, key) => { headers[key] = value; }); const responseBody = await localResponse.text(); const tunnelResponse = { id: request.id, status: localResponse.status, headers, body: responseBody }; logger.debug(`Sending response back through tunnel: Status ${tunnelResponse.status}`); logger.debug(`Response headers being sent back:`); Object.entries(tunnelResponse.headers).forEach(([key, value]) => { logger.debug(` ${key}: ${value}`); }); logger.debug(`Response body length: ${responseBody.length} characters`); this.ws?.send(JSON.stringify(tunnelResponse)); } catch (error) { logger.warn("Error handling tunnel message:", error); if (!this.isConnected()) { reject(error); return; } try { const reqData = JSON.parse(rawData.toString()); const errorResponse = { id: reqData.id, status: 502, headers: {}, body: "Bad Gateway" }; this.ws?.send(JSON.stringify(errorResponse)); } catch { logger.warn("Failed to send tunnel error response"); } } }); this.ws.on("close", () => { logger.debug("Disconnected from tunnel server"); this.ws = null; this.options.onClosed?.(); }); this.ws.on("error", (error) => { logger.warn("WebSocket error:", error); reject(new Error(`WebSocket connection error: ${error}`)); }); }); } /** * Disconnect from the tunnel server */ disconnect() { this.ws?.close(); this.ws = null; } /** * Check if connected to the tunnel server */ isConnected() { return this.ws !== null && this.ws.readyState === WebSocket.OPEN; } } export { TunnelClient };