UNPKG

gurted

Version:

A lightweight Node.js implementation of the gurt:// protocol

298 lines (297 loc) 12.5 kB
// 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); } }