bunnel
Version:
Websocket reverse tunnel
150 lines (146 loc) • 5.04 kB
JavaScript
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 };