gurted
Version:
A lightweight Node.js implementation of the gurt:// protocol
298 lines (297 loc) • 12.5 kB
JavaScript
// src/index.ts
import { GurtError } from './error.js';
import { GurtRequest, GurtResponse, GurtMethod } from './message.js';
import { DEFAULT_PORT, DEFAULT_CONNECTION_TIMEOUT, DEFAULT_REQUEST_TIMEOUT, DEFAULT_HANDSHAKE_TIMEOUT, BODY_SEPARATOR } from './protocol.js';
import { Socket } from 'net';
import { connect } from 'tls';
import { URL, URLSearchParams } from 'url';
export class GurtClientConfig {
constructor(options = {}) {
this.connectTimeout = options.connectTimeout ?? DEFAULT_CONNECTION_TIMEOUT;
this.requestTimeout = options.requestTimeout ?? DEFAULT_REQUEST_TIMEOUT;
this.handshakeTimeout = options.handshakeTimeout ?? DEFAULT_HANDSHAKE_TIMEOUT;
this.userAgent = options.userAgent ?? `GURT-Client/1.0.0`;
this.maxRedirects = options.maxRedirects ?? 5;
this.enableConnectionPooling = options.enableConnectionPooling !== false;
this.maxConnectionsPerHost = options.maxConnectionsPerHost ?? 4;
this.customCaCertificates = options.customCaCertificates ?? [];
this.dnsServerIp = options.dnsServerIp ?? '135.125.163.131';
this.dnsServerPort = options.dnsServerPort ?? 4878;
}
}
export class GurtClient {
constructor(config = new GurtClientConfig()) {
this.config = config;
this.connectionPool = new Map();
this.dnsCache = new Map();
}
static new() {
return new GurtClient();
}
static withConfig(config) {
return new GurtClient(config);
}
// Methods
async get(url, options) {
return this.request({ url, method: GurtMethod.GET, headers: options?.headers, params: options?.params });
}
async post(url, data, options) {
return this.request({ url, method: GurtMethod.POST, headers: options?.headers, params: options?.params, data });
}
async put(url, data, options) {
return this.request({ url, method: GurtMethod.PUT, headers: options?.headers, params: options?.params, data });
}
async patch(url, data, options) {
return this.request({ url, method: GurtMethod.PATCH, headers: options?.headers, params: options?.params, data });
}
async delete(url, options) {
return this.request({ url, method: GurtMethod.DELETE, headers: options?.headers, params: options?.params, data: options?.data });
}
async head(url, options) {
return this.request({ url, method: GurtMethod.HEAD, headers: options?.headers, params: options?.params });
}
async options(url, options) {
return this.request({ url, method: GurtMethod.OPTIONS, headers: options?.headers, params: options?.params });
}
// General Request
async request(opts) {
let { url, method, headers, params, data } = opts;
// Query Params etc.
if (params) {
const parsed = new URL(url);
const search = new URLSearchParams(params).toString();
parsed.search = parsed.search ? parsed.search + '&' + search : search;
url = parsed.toString();
}
const { host, port, path } = this.parseGurtUrl(url);
const request = GurtRequest.new(method, path)
.withHeader('User-Agent', this.config.userAgent)
.withHeader('Accept', '*/*')
.withHeader('Host', host);
// Headers for auth and more (dns.web etc.)
if (headers) {
for (const [key, value] of Object.entries(headers)) {
request.withHeader(key, value);
}
}
// Request Body
if (data !== undefined) {
if (typeof data === 'string' || Buffer.isBuffer(data)) {
request.withHeader('Content-Type', 'text/plain').withStringBody(data.toString());
}
else {
request.withHeader('Content-Type', 'application/json').withBody(data);
}
}
const resolvedHost = await this.resolveDomain(host);
const resp = await this.sendRequestInternal(resolvedHost, port, request, host);
let parsedData = resp.text();
const contentType = resp.header('content-type');
if (contentType && contentType.includes('application/json')) {
try {
parsedData = JSON.parse(parsedData);
}
catch { }
}
// Response
return {
data: parsedData,
status: resp.statusCode,
statusText: resp.statusMessage,
headers: Object.fromEntries(resp.headers)
};
}
// Here come Santas Elves
async sendRequestInternal(host, port, request, originalHost) {
const tlsSocket = await this.getPooledConnection(host, port, originalHost);
await this.writeToSocket(tlsSocket, request.toBytes());
const responseData = await this.readResponse(tlsSocket);
const response = GurtResponse.parseBytes(responseData);
this.returnConnectionToPool(host, port, tlsSocket);
return response;
}
async getPooledConnection(host, port, originalHost) {
if (!this.config.enableConnectionPooling)
return this.performHandshake(host, port, originalHost);
const key = `${host}:${port}`;
const now = Date.now();
if (this.connectionPool.has(key)) {
const conns = this.connectionPool.get(key).filter(c => now - c.lastUsed < 30000);
this.connectionPool.set(key, conns);
if (conns.length > 0)
return conns.pop().socket;
}
return this.performHandshake(host, port, originalHost);
}
returnConnectionToPool(host, port, socket) {
if (!this.config.enableConnectionPooling) {
socket.end();
return;
}
const key = `${host}:${port}`;
if (!this.connectionPool.has(key))
this.connectionPool.set(key, []);
const conns = this.connectionPool.get(key);
if (conns.length < this.config.maxConnectionsPerHost) {
conns.push({ socket, lastUsed: Date.now() });
}
else {
socket.end();
}
}
async performHandshake(host, port, originalHost) {
const plainSocket = await this.createConnection(host, port);
const handshakeRequest = GurtRequest.new(GurtMethod.HANDSHAKE, '/')
.withHeader('Host', originalHost || host)
.withHeader('User-Agent', this.config.userAgent);
await this.writeToSocket(plainSocket, handshakeRequest.toBytes());
const handshakeResp = GurtResponse.parseBytes(await this.readResponse(plainSocket));
if (handshakeResp.statusCode !== 101) {
throw GurtError.protocol(`Handshake failed: ${handshakeResp.statusCode} ${handshakeResp.statusMessage}`);
}
return this.upgradeToTls(plainSocket, originalHost || host);
}
async upgradeToTls(socket, host) {
return new Promise((resolve, reject) => {
const tlsSocket = connect({
host,
port: socket.remotePort,
socket,
rejectUnauthorized: false,
ALPNProtocols: ['GURT/1.0']
}, () => resolve(tlsSocket));
tlsSocket.on('error', reject);
});
}
async createConnection(host, port) {
return new Promise((resolve, reject) => {
const socket = new Socket();
let timeoutId;
if (this.config.connectTimeout > 0) {
timeoutId = setTimeout(() => {
socket.destroy();
reject(GurtError.timeout('Connection timeout'));
}, this.config.connectTimeout);
}
socket.connect(port, host, () => {
if (timeoutId)
clearTimeout(timeoutId);
resolve(socket);
});
socket.on('error', err => {
if (timeoutId)
clearTimeout(timeoutId);
reject(GurtError.connection(`Failed to connect: ${err.message}`));
});
});
}
async readResponse(socket) {
return new Promise((resolve, reject) => {
let buffer = Buffer.alloc(0);
let headersParsed = false;
let expectedLength = null;
let headersEnd = 0;
let timeoutId;
if (this.config.requestTimeout > 0) {
timeoutId = setTimeout(() => {
socket.destroy();
reject(GurtError.timeout('Response timeout'));
}, this.config.requestTimeout);
}
const cleanup = () => {
if (timeoutId)
clearTimeout(timeoutId);
socket.removeAllListeners();
};
socket.on('data', chunk => {
buffer = Buffer.concat([buffer, chunk]);
if (!headersParsed) {
const sep = buffer.indexOf(BODY_SEPARATOR);
if (sep !== -1) {
headersParsed = true;
headersEnd = sep + BODY_SEPARATOR.length;
const lines = buffer.subarray(0, sep).toString().split('\r\n');
for (const line of lines) {
if (line.toLowerCase().startsWith('content-length:')) {
expectedLength = parseInt(line.split(':')[1].trim(), 10);
break;
}
}
}
}
if (headersParsed && expectedLength !== null && buffer.length - headersEnd >= expectedLength) {
cleanup();
resolve(buffer);
}
if (buffer.length > 10 * 1024 * 1024) {
cleanup();
reject(GurtError.protocol('Message too large'));
}
});
socket.on('end', () => {
cleanup();
if (!buffer.length)
reject(GurtError.connection('Connection closed unexpectedly'));
else
resolve(buffer);
});
socket.on('error', err => {
cleanup();
reject(GurtError.connection(`Read error: ${err.message}`));
});
});
}
async writeToSocket(socket, data) {
return new Promise((resolve, reject) => {
socket.write(data, err => {
if (err)
reject(GurtError.connection(`Failed to write: ${err.message}`));
else
resolve();
});
});
}
parseGurtUrl(url) {
try {
const parsed = new URL(url);
if (parsed.protocol !== 'gurt:')
throw GurtError.invalidMessage('URL must use gurt:// scheme');
return {
host: parsed.hostname,
port: parsed.port ? parseInt(parsed.port, 10) : DEFAULT_PORT,
path: parsed.pathname + parsed.search
};
}
catch (err) {
throw GurtError.invalidMessage(`Invalid URL: ${err.message}`);
}
}
async resolveDomain(domain) {
if (this.dnsCache.has(domain))
return this.dnsCache.get(domain);
if (this.isIpAddress(domain))
return domain;
if (domain === 'localhost')
return '127.0.0.1';
const dnsIp = this.config.dnsServerIp === 'localhost' ? '127.0.0.1' : this.config.dnsServerIp;
const dnsRequest = GurtRequest.new(GurtMethod.POST, '/resolve-full')
.withHeader('Host', dnsIp)
.withHeader('Content-Type', 'application/json')
.withStringBody(JSON.stringify({ domain }));
const dnsResp = await this.sendRequestInternal(dnsIp, this.config.dnsServerPort, dnsRequest, null);
const data = JSON.parse(dnsResp.text());
if (Array.isArray(data.records)) {
const rec = data.records.find((r) => r.type === 'A' && r.value);
if (rec) {
this.dnsCache.set(domain, rec.value);
return rec.value;
}
}
throw GurtError.invalidMessage(`No A record found for ${domain}`);
}
isIpAddress(addr) {
const v4 = /^(\d{1,3}\.){3}\d{1,3}$/;
const v6 = /^([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}$/;
return v4.test(addr) || v6.test(addr);
}
}