219 lines
5.3 KiB
TypeScript
219 lines
5.3 KiB
TypeScript
/**
|
|
* HTTP Protocol Parser
|
|
* Generic HTTP parsing utilities
|
|
*/
|
|
|
|
import { HTTP_METHODS, type THttpMethod, type THttpVersion } from './constants.js';
|
|
import type { IHttpRequestLine, IHttpHeader } from './types.js';
|
|
|
|
/**
|
|
* HTTP parser utilities
|
|
*/
|
|
export class HttpParser {
|
|
/**
|
|
* Check if string is a valid HTTP method
|
|
*/
|
|
static isHttpMethod(str: string): str is THttpMethod {
|
|
return HTTP_METHODS.includes(str as THttpMethod);
|
|
}
|
|
|
|
/**
|
|
* Parse HTTP request line
|
|
*/
|
|
static parseRequestLine(line: string): IHttpRequestLine | null {
|
|
const parts = line.trim().split(' ');
|
|
|
|
if (parts.length !== 3) {
|
|
return null;
|
|
}
|
|
|
|
const [method, path, version] = parts;
|
|
|
|
// Validate method
|
|
if (!this.isHttpMethod(method)) {
|
|
return null;
|
|
}
|
|
|
|
// Validate version
|
|
if (!version.startsWith('HTTP/')) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
method: method as THttpMethod,
|
|
path,
|
|
version: version as THttpVersion
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Parse HTTP header line
|
|
*/
|
|
static parseHeaderLine(line: string): IHttpHeader | null {
|
|
const colonIndex = line.indexOf(':');
|
|
|
|
if (colonIndex === -1) {
|
|
return null;
|
|
}
|
|
|
|
const name = line.slice(0, colonIndex).trim();
|
|
const value = line.slice(colonIndex + 1).trim();
|
|
|
|
if (!name) {
|
|
return null;
|
|
}
|
|
|
|
return { name, value };
|
|
}
|
|
|
|
/**
|
|
* Parse HTTP headers from lines
|
|
*/
|
|
static parseHeaders(lines: string[]): Record<string, string> {
|
|
const headers: Record<string, string> = {};
|
|
|
|
for (const line of lines) {
|
|
const header = this.parseHeaderLine(line);
|
|
if (header) {
|
|
// Convert header names to lowercase for consistency
|
|
headers[header.name.toLowerCase()] = header.value;
|
|
}
|
|
}
|
|
|
|
return headers;
|
|
}
|
|
|
|
/**
|
|
* Extract domain from Host header value
|
|
*/
|
|
static extractDomainFromHost(hostHeader: string): string {
|
|
// Remove port if present
|
|
const colonIndex = hostHeader.lastIndexOf(':');
|
|
if (colonIndex !== -1) {
|
|
// Check if it's not part of IPv6 address
|
|
const beforeColon = hostHeader.slice(0, colonIndex);
|
|
if (!beforeColon.includes(']')) {
|
|
return beforeColon;
|
|
}
|
|
}
|
|
return hostHeader;
|
|
}
|
|
|
|
/**
|
|
* Validate domain name
|
|
*/
|
|
static isValidDomain(domain: string): boolean {
|
|
// Basic domain validation
|
|
if (!domain || domain.length > 253) {
|
|
return false;
|
|
}
|
|
|
|
// Check for valid characters and structure
|
|
const domainRegex = /^(?!-)[A-Za-z0-9-]{1,63}(?<!-)(\.[A-Za-z0-9-]{1,63})*$/;
|
|
return domainRegex.test(domain);
|
|
}
|
|
|
|
/**
|
|
* Extract line from buffer
|
|
*/
|
|
static extractLine(buffer: Buffer, offset: number = 0): { line: string; nextOffset: number } | null {
|
|
// Look for CRLF
|
|
const crlfIndex = buffer.indexOf('\r\n', offset);
|
|
if (crlfIndex === -1) {
|
|
// Look for just LF
|
|
const lfIndex = buffer.indexOf('\n', offset);
|
|
if (lfIndex === -1) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
line: buffer.slice(offset, lfIndex).toString('utf8'),
|
|
nextOffset: lfIndex + 1
|
|
};
|
|
}
|
|
|
|
return {
|
|
line: buffer.slice(offset, crlfIndex).toString('utf8'),
|
|
nextOffset: crlfIndex + 2
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Check if buffer contains printable ASCII
|
|
*/
|
|
static isPrintableAscii(buffer: Buffer, length?: number): boolean {
|
|
const checkLength = Math.min(length || buffer.length, buffer.length);
|
|
|
|
for (let i = 0; i < checkLength; i++) {
|
|
const byte = buffer[i];
|
|
// Allow printable ASCII (32-126) plus tab (9), LF (10), and CR (13)
|
|
if (byte < 32 || byte > 126) {
|
|
if (byte !== 9 && byte !== 10 && byte !== 13) {
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Quick check if buffer starts with HTTP method
|
|
*/
|
|
static quickCheck(buffer: Buffer): boolean {
|
|
if (buffer.length < 3) {
|
|
return false;
|
|
}
|
|
|
|
// Check common HTTP methods
|
|
const start = buffer.slice(0, 7).toString('ascii');
|
|
return start.startsWith('GET ') ||
|
|
start.startsWith('POST ') ||
|
|
start.startsWith('PUT ') ||
|
|
start.startsWith('DELETE ') ||
|
|
start.startsWith('HEAD ') ||
|
|
start.startsWith('OPTIONS') ||
|
|
start.startsWith('PATCH ') ||
|
|
start.startsWith('CONNECT') ||
|
|
start.startsWith('TRACE ');
|
|
}
|
|
|
|
/**
|
|
* Parse query string
|
|
*/
|
|
static parseQueryString(queryString: string): Record<string, string> {
|
|
const params: Record<string, string> = {};
|
|
|
|
if (!queryString) {
|
|
return params;
|
|
}
|
|
|
|
// Remove leading '?' if present
|
|
if (queryString.startsWith('?')) {
|
|
queryString = queryString.slice(1);
|
|
}
|
|
|
|
const pairs = queryString.split('&');
|
|
for (const pair of pairs) {
|
|
const [key, value] = pair.split('=');
|
|
if (key) {
|
|
params[decodeURIComponent(key)] = value ? decodeURIComponent(value) : '';
|
|
}
|
|
}
|
|
|
|
return params;
|
|
}
|
|
|
|
/**
|
|
* Build query string from params
|
|
*/
|
|
static buildQueryString(params: Record<string, string>): string {
|
|
const pairs: string[] = [];
|
|
|
|
for (const [key, value] of Object.entries(params)) {
|
|
pairs.push(`${encodeURIComponent(key)}=${encodeURIComponent(value)}`);
|
|
}
|
|
|
|
return pairs.length > 0 ? '?' + pairs.join('&') : '';
|
|
}
|
|
} |