feat(protocols): refactor protocol utilities into centralized protocols module
This commit is contained in:
219
ts/protocols/http/constants.ts
Normal file
219
ts/protocols/http/constants.ts
Normal file
@@ -0,0 +1,219 @@
|
||||
/**
|
||||
* HTTP Protocol Constants
|
||||
*/
|
||||
|
||||
/**
|
||||
* HTTP methods
|
||||
*/
|
||||
export const HTTP_METHODS = [
|
||||
'GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD', 'OPTIONS', 'CONNECT', 'TRACE'
|
||||
] as const;
|
||||
|
||||
export type THttpMethod = typeof HTTP_METHODS[number];
|
||||
|
||||
/**
|
||||
* HTTP version strings
|
||||
*/
|
||||
export const HTTP_VERSIONS = ['HTTP/1.0', 'HTTP/1.1', 'HTTP/2', 'HTTP/3'] as const;
|
||||
|
||||
export type THttpVersion = typeof HTTP_VERSIONS[number];
|
||||
|
||||
/**
|
||||
* HTTP status codes
|
||||
*/
|
||||
export enum HttpStatus {
|
||||
// 1xx Informational
|
||||
CONTINUE = 100,
|
||||
SWITCHING_PROTOCOLS = 101,
|
||||
PROCESSING = 102,
|
||||
EARLY_HINTS = 103,
|
||||
|
||||
// 2xx Success
|
||||
OK = 200,
|
||||
CREATED = 201,
|
||||
ACCEPTED = 202,
|
||||
NON_AUTHORITATIVE_INFORMATION = 203,
|
||||
NO_CONTENT = 204,
|
||||
RESET_CONTENT = 205,
|
||||
PARTIAL_CONTENT = 206,
|
||||
MULTI_STATUS = 207,
|
||||
ALREADY_REPORTED = 208,
|
||||
IM_USED = 226,
|
||||
|
||||
// 3xx Redirection
|
||||
MULTIPLE_CHOICES = 300,
|
||||
MOVED_PERMANENTLY = 301,
|
||||
FOUND = 302,
|
||||
SEE_OTHER = 303,
|
||||
NOT_MODIFIED = 304,
|
||||
USE_PROXY = 305,
|
||||
TEMPORARY_REDIRECT = 307,
|
||||
PERMANENT_REDIRECT = 308,
|
||||
|
||||
// 4xx Client Error
|
||||
BAD_REQUEST = 400,
|
||||
UNAUTHORIZED = 401,
|
||||
PAYMENT_REQUIRED = 402,
|
||||
FORBIDDEN = 403,
|
||||
NOT_FOUND = 404,
|
||||
METHOD_NOT_ALLOWED = 405,
|
||||
NOT_ACCEPTABLE = 406,
|
||||
PROXY_AUTHENTICATION_REQUIRED = 407,
|
||||
REQUEST_TIMEOUT = 408,
|
||||
CONFLICT = 409,
|
||||
GONE = 410,
|
||||
LENGTH_REQUIRED = 411,
|
||||
PRECONDITION_FAILED = 412,
|
||||
PAYLOAD_TOO_LARGE = 413,
|
||||
URI_TOO_LONG = 414,
|
||||
UNSUPPORTED_MEDIA_TYPE = 415,
|
||||
RANGE_NOT_SATISFIABLE = 416,
|
||||
EXPECTATION_FAILED = 417,
|
||||
IM_A_TEAPOT = 418,
|
||||
MISDIRECTED_REQUEST = 421,
|
||||
UNPROCESSABLE_ENTITY = 422,
|
||||
LOCKED = 423,
|
||||
FAILED_DEPENDENCY = 424,
|
||||
TOO_EARLY = 425,
|
||||
UPGRADE_REQUIRED = 426,
|
||||
PRECONDITION_REQUIRED = 428,
|
||||
TOO_MANY_REQUESTS = 429,
|
||||
REQUEST_HEADER_FIELDS_TOO_LARGE = 431,
|
||||
UNAVAILABLE_FOR_LEGAL_REASONS = 451,
|
||||
|
||||
// 5xx Server Error
|
||||
INTERNAL_SERVER_ERROR = 500,
|
||||
NOT_IMPLEMENTED = 501,
|
||||
BAD_GATEWAY = 502,
|
||||
SERVICE_UNAVAILABLE = 503,
|
||||
GATEWAY_TIMEOUT = 504,
|
||||
HTTP_VERSION_NOT_SUPPORTED = 505,
|
||||
VARIANT_ALSO_NEGOTIATES = 506,
|
||||
INSUFFICIENT_STORAGE = 507,
|
||||
LOOP_DETECTED = 508,
|
||||
NOT_EXTENDED = 510,
|
||||
NETWORK_AUTHENTICATION_REQUIRED = 511,
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP status text mapping
|
||||
*/
|
||||
export const HTTP_STATUS_TEXT: Record<HttpStatus, string> = {
|
||||
// 1xx
|
||||
[HttpStatus.CONTINUE]: 'Continue',
|
||||
[HttpStatus.SWITCHING_PROTOCOLS]: 'Switching Protocols',
|
||||
[HttpStatus.PROCESSING]: 'Processing',
|
||||
[HttpStatus.EARLY_HINTS]: 'Early Hints',
|
||||
|
||||
// 2xx
|
||||
[HttpStatus.OK]: 'OK',
|
||||
[HttpStatus.CREATED]: 'Created',
|
||||
[HttpStatus.ACCEPTED]: 'Accepted',
|
||||
[HttpStatus.NON_AUTHORITATIVE_INFORMATION]: 'Non-Authoritative Information',
|
||||
[HttpStatus.NO_CONTENT]: 'No Content',
|
||||
[HttpStatus.RESET_CONTENT]: 'Reset Content',
|
||||
[HttpStatus.PARTIAL_CONTENT]: 'Partial Content',
|
||||
[HttpStatus.MULTI_STATUS]: 'Multi-Status',
|
||||
[HttpStatus.ALREADY_REPORTED]: 'Already Reported',
|
||||
[HttpStatus.IM_USED]: 'IM Used',
|
||||
|
||||
// 3xx
|
||||
[HttpStatus.MULTIPLE_CHOICES]: 'Multiple Choices',
|
||||
[HttpStatus.MOVED_PERMANENTLY]: 'Moved Permanently',
|
||||
[HttpStatus.FOUND]: 'Found',
|
||||
[HttpStatus.SEE_OTHER]: 'See Other',
|
||||
[HttpStatus.NOT_MODIFIED]: 'Not Modified',
|
||||
[HttpStatus.USE_PROXY]: 'Use Proxy',
|
||||
[HttpStatus.TEMPORARY_REDIRECT]: 'Temporary Redirect',
|
||||
[HttpStatus.PERMANENT_REDIRECT]: 'Permanent Redirect',
|
||||
|
||||
// 4xx
|
||||
[HttpStatus.BAD_REQUEST]: 'Bad Request',
|
||||
[HttpStatus.UNAUTHORIZED]: 'Unauthorized',
|
||||
[HttpStatus.PAYMENT_REQUIRED]: 'Payment Required',
|
||||
[HttpStatus.FORBIDDEN]: 'Forbidden',
|
||||
[HttpStatus.NOT_FOUND]: 'Not Found',
|
||||
[HttpStatus.METHOD_NOT_ALLOWED]: 'Method Not Allowed',
|
||||
[HttpStatus.NOT_ACCEPTABLE]: 'Not Acceptable',
|
||||
[HttpStatus.PROXY_AUTHENTICATION_REQUIRED]: 'Proxy Authentication Required',
|
||||
[HttpStatus.REQUEST_TIMEOUT]: 'Request Timeout',
|
||||
[HttpStatus.CONFLICT]: 'Conflict',
|
||||
[HttpStatus.GONE]: 'Gone',
|
||||
[HttpStatus.LENGTH_REQUIRED]: 'Length Required',
|
||||
[HttpStatus.PRECONDITION_FAILED]: 'Precondition Failed',
|
||||
[HttpStatus.PAYLOAD_TOO_LARGE]: 'Payload Too Large',
|
||||
[HttpStatus.URI_TOO_LONG]: 'URI Too Long',
|
||||
[HttpStatus.UNSUPPORTED_MEDIA_TYPE]: 'Unsupported Media Type',
|
||||
[HttpStatus.RANGE_NOT_SATISFIABLE]: 'Range Not Satisfiable',
|
||||
[HttpStatus.EXPECTATION_FAILED]: 'Expectation Failed',
|
||||
[HttpStatus.IM_A_TEAPOT]: "I'm a teapot",
|
||||
[HttpStatus.MISDIRECTED_REQUEST]: 'Misdirected Request',
|
||||
[HttpStatus.UNPROCESSABLE_ENTITY]: 'Unprocessable Entity',
|
||||
[HttpStatus.LOCKED]: 'Locked',
|
||||
[HttpStatus.FAILED_DEPENDENCY]: 'Failed Dependency',
|
||||
[HttpStatus.TOO_EARLY]: 'Too Early',
|
||||
[HttpStatus.UPGRADE_REQUIRED]: 'Upgrade Required',
|
||||
[HttpStatus.PRECONDITION_REQUIRED]: 'Precondition Required',
|
||||
[HttpStatus.TOO_MANY_REQUESTS]: 'Too Many Requests',
|
||||
[HttpStatus.REQUEST_HEADER_FIELDS_TOO_LARGE]: 'Request Header Fields Too Large',
|
||||
[HttpStatus.UNAVAILABLE_FOR_LEGAL_REASONS]: 'Unavailable For Legal Reasons',
|
||||
|
||||
// 5xx
|
||||
[HttpStatus.INTERNAL_SERVER_ERROR]: 'Internal Server Error',
|
||||
[HttpStatus.NOT_IMPLEMENTED]: 'Not Implemented',
|
||||
[HttpStatus.BAD_GATEWAY]: 'Bad Gateway',
|
||||
[HttpStatus.SERVICE_UNAVAILABLE]: 'Service Unavailable',
|
||||
[HttpStatus.GATEWAY_TIMEOUT]: 'Gateway Timeout',
|
||||
[HttpStatus.HTTP_VERSION_NOT_SUPPORTED]: 'HTTP Version Not Supported',
|
||||
[HttpStatus.VARIANT_ALSO_NEGOTIATES]: 'Variant Also Negotiates',
|
||||
[HttpStatus.INSUFFICIENT_STORAGE]: 'Insufficient Storage',
|
||||
[HttpStatus.LOOP_DETECTED]: 'Loop Detected',
|
||||
[HttpStatus.NOT_EXTENDED]: 'Not Extended',
|
||||
[HttpStatus.NETWORK_AUTHENTICATION_REQUIRED]: 'Network Authentication Required',
|
||||
};
|
||||
|
||||
/**
|
||||
* Common HTTP headers
|
||||
*/
|
||||
export const HTTP_HEADERS = {
|
||||
// Request headers
|
||||
HOST: 'host',
|
||||
USER_AGENT: 'user-agent',
|
||||
ACCEPT: 'accept',
|
||||
ACCEPT_LANGUAGE: 'accept-language',
|
||||
ACCEPT_ENCODING: 'accept-encoding',
|
||||
AUTHORIZATION: 'authorization',
|
||||
CACHE_CONTROL: 'cache-control',
|
||||
CONNECTION: 'connection',
|
||||
CONTENT_TYPE: 'content-type',
|
||||
CONTENT_LENGTH: 'content-length',
|
||||
COOKIE: 'cookie',
|
||||
|
||||
// Response headers
|
||||
SET_COOKIE: 'set-cookie',
|
||||
LOCATION: 'location',
|
||||
SERVER: 'server',
|
||||
DATE: 'date',
|
||||
EXPIRES: 'expires',
|
||||
LAST_MODIFIED: 'last-modified',
|
||||
ETAG: 'etag',
|
||||
|
||||
// CORS headers
|
||||
ACCESS_CONTROL_ALLOW_ORIGIN: 'access-control-allow-origin',
|
||||
ACCESS_CONTROL_ALLOW_METHODS: 'access-control-allow-methods',
|
||||
ACCESS_CONTROL_ALLOW_HEADERS: 'access-control-allow-headers',
|
||||
|
||||
// Security headers
|
||||
STRICT_TRANSPORT_SECURITY: 'strict-transport-security',
|
||||
X_CONTENT_TYPE_OPTIONS: 'x-content-type-options',
|
||||
X_FRAME_OPTIONS: 'x-frame-options',
|
||||
X_XSS_PROTECTION: 'x-xss-protection',
|
||||
CONTENT_SECURITY_POLICY: 'content-security-policy',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Get HTTP status text
|
||||
*/
|
||||
export function getStatusText(status: HttpStatus): string {
|
||||
return HTTP_STATUS_TEXT[status] || 'Unknown';
|
||||
}
|
8
ts/protocols/http/index.ts
Normal file
8
ts/protocols/http/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* HTTP Protocol Module
|
||||
* Generic HTTP protocol knowledge and parsing utilities
|
||||
*/
|
||||
|
||||
export * from './constants.js';
|
||||
export * from './types.js';
|
||||
export * from './parser.js';
|
219
ts/protocols/http/parser.ts
Normal file
219
ts/protocols/http/parser.ts
Normal file
@@ -0,0 +1,219 @@
|
||||
/**
|
||||
* 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('&') : '';
|
||||
}
|
||||
}
|
70
ts/protocols/http/types.ts
Normal file
70
ts/protocols/http/types.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* HTTP Protocol Type Definitions
|
||||
*/
|
||||
|
||||
import type { THttpMethod, THttpVersion, HttpStatus } from './constants.js';
|
||||
|
||||
/**
|
||||
* HTTP request line structure
|
||||
*/
|
||||
export interface IHttpRequestLine {
|
||||
method: THttpMethod;
|
||||
path: string;
|
||||
version: THttpVersion;
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP response line structure
|
||||
*/
|
||||
export interface IHttpResponseLine {
|
||||
version: THttpVersion;
|
||||
status: HttpStatus;
|
||||
statusText: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP header structure
|
||||
*/
|
||||
export interface IHttpHeader {
|
||||
name: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP message structure (base for request and response)
|
||||
*/
|
||||
export interface IHttpMessage {
|
||||
headers: Record<string, string>;
|
||||
body?: Buffer;
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP request structure
|
||||
*/
|
||||
export interface IHttpRequest extends IHttpMessage {
|
||||
method: THttpMethod;
|
||||
path: string;
|
||||
version: THttpVersion;
|
||||
query?: Record<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP response structure
|
||||
*/
|
||||
export interface IHttpResponse extends IHttpMessage {
|
||||
status: HttpStatus;
|
||||
statusText: string;
|
||||
version: THttpVersion;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parsed URL structure
|
||||
*/
|
||||
export interface IParsedUrl {
|
||||
protocol?: string;
|
||||
hostname?: string;
|
||||
port?: number;
|
||||
path?: string;
|
||||
query?: string;
|
||||
fragment?: string;
|
||||
}
|
Reference in New Issue
Block a user