// HTTP as FILIUS writes and reads it (filius.software.www.HTTPNachricht): // lines ended by "\n", a request line, a Host header, for POST an empty line // and the form's data; a response line with Java's status text, a // Content-type header, an empty line and the body. Paths go encoded as // Spring's UriUtils.encodePath encodes them (UTF-8, every character a path // segment may not hold) and are decoded by the server. // // Better than Java: a request Java cannot read (a first line without path, // or an unknown method) gets 400 or 501 (HTTPNachricht throws, the server's // thread closes the connection without a word); a request without a version // is HTTP/1.1, the version HTTPNachricht starts with. export const HTTP_GET = 'GET'; export const HTTP_POST = 'POST'; export type HttpContentType = 'text/html' | 'image/png' | 'image/bmp' | 'image/gif' | 'image/jpg' | 'text/plain'; export interface HttpRequest { readonly method: string; /** Decoded, with its query; `httpPath` gives it without. */ readonly path: string; readonly protocolVersion: string; readonly host: string | undefined; /** POST only: what follows the empty line. */ readonly data: string | undefined; } export interface HttpResponse { readonly protocolVersion: string; readonly statusCode: number; readonly contentType: string | undefined; readonly body: string | undefined; } /** A first line FILIUS' server cannot read: no method, or a method without path (400), or another method (501). */ export type HttpRequestParse = { readonly type: 'request'; readonly request: HttpRequest } | { readonly type: 'invalid'; readonly statusCode: 400 | 501 }; /** Java's StringTokenizer on spaces: the words, without empty ones. */ function words(line: string): string[] { return line.split(' ').filter((word) => word !== ''); } /** Java's String.trim. */ function javaTrim(text: string): string { let start = 0; let end = text.length; while (start < end && text.charCodeAt(start) <= 0x20) start += 1; while (end > start && text.charCodeAt(end - 1) <= 0x20) end -= 1; return text.slice(start, end); } /** Java's String.split("\n"): trailing empty lines dropped. */ function javaLines(text: string): string[] { const lines = text.split('\n'); while (lines.length > 1 && lines[lines.length - 1] === '') lines.pop(); return lines; } /** HTTPNachricht(String) for a request, as the server reads it. */ export function parseHttpRequest(message: string): HttpRequestParse { const lines = javaLines(message); const [method, rawPath, version] = words(lines[0] ?? '').map(javaTrim); if (method === undefined) return { type: 'invalid', statusCode: 400 }; const upper = method.toUpperCase(); if (upper !== HTTP_GET && upper !== HTTP_POST) return { type: 'invalid', statusCode: 501 }; if (rawPath === undefined) return { type: 'invalid', statusCode: 400 }; const path = decodeUriPath(rawPath); if (path === undefined) return { type: 'invalid', statusCode: 400 }; let host: string | undefined; let data: string | undefined; for (let index = 1; index < lines.length; index += 1) { const line = lines[index]!; if (line === '' && upper === HTTP_POST) { data = lines.slice(index).join('\n'); break; } const [name, value] = words(line).map(javaTrim); if (name !== undefined && name.toLowerCase() === 'host:' && value !== undefined) host = value; } return { type: 'request', request: { method, path, protocolVersion: version ?? 'HTTP/1.1', host, data } }; } /** HTTPNachricht.getPfad: "/" for none, else without the query. */ export function httpPath(path: string): string { if (javaTrim(path) === '') return '/'; const query = path.indexOf('?'); return query > 0 ? path.slice(0, query) : path; } /** HTTPNachricht.toString for a request (CLIENT): the path without its query, encoded. */ export function formatHttpRequest(request: { method: string; path: string; host: string | undefined; data?: string | undefined }): string { let text = `${request.method} ${encodeUriPath(httpPath(request.path))} HTTP/1.1\n`; if (request.host !== undefined) text += `Host: ${request.host}\n`; if (request.method === HTTP_POST && request.data !== undefined) text += `\n${request.data}`; return text; } /** HTTPNachricht.toString for a response (SERVER). */ export function formatHttpResponse(response: HttpResponse): string { let text = `${response.protocolVersion} ${response.statusCode} ${httpStatusText(response.statusCode)}\n`; if (response.contentType !== undefined) text += `Content-type: ${response.contentType}\n`; if (response.body !== undefined) text += `\n${response.body}`; return text; } /** HTTPNachricht(String) for a response, as the browser reads it; undefined when its status is no number. */ export function parseHttpResponse(message: string): HttpResponse | undefined { const lines = javaLines(message); const [version, status] = words(lines[0] ?? '').map(javaTrim); if (version === undefined || status === undefined || !/^[+-]?\d+$/.test(status)) return undefined; let contentType: string | undefined; let body: string | undefined; for (let index = 1; index < lines.length; index += 1) { const line = lines[index]!; if (line === '') { body = lines.slice(index + 1).join('\n'); break; } const [name, value] = words(line).map(javaTrim); if (name !== undefined && name.toLowerCase() === 'content-type:' && value !== undefined) contentType = value; } return { protocolVersion: version, statusCode: Number(status), contentType, body }; } const STATUS_TEXTS: Record = { 100: 'Continue', 101: 'Switching Protocols', 102: 'Processing', 200: 'OK', 201: 'Created', 202: 'Accepted', 203: 'Non-Authoritative Information', 204: 'No Content', 205: 'Reset Content', 206: 'Partial Content', 207: 'Multi-Status', 300: 'Multiple Choice', 301: 'Moved Permanently', 302: 'Found', 303: 'See Other', 304: 'Not Modified', 305: 'Use Proxy', 307: 'Temporary Redirect', 400: 'Bad Request', 401: 'Unauthorized', 402: 'Payment Required', 403: 'Forbidden', 404: 'Not Found', 405: 'Method Not Allowed', 406: 'Not Acceptable', 407: 'Proxy Authentication Required', 408: 'Request Time-out', 409: 'Conflict', 410: 'Gone', 411: 'Length Required', 412: 'Precondition Failed', 413: 'Request Entity Too Large', 414: 'Request-URI Too Long', 415: 'Unsupported Media Type', 416: 'Requested range not satisfiable', 417: 'Expectation Failed', 422: 'Unprocessable Entity', 423: 'Locked', 424: 'Failed Dependency', 500: 'Internal Server Error', 501: 'Not Implemented', 502: 'Bad Gateway', 503: 'Service Unavailable', 504: 'Gateway Time-out', 505: 'HTTP Version not supported', 507: 'Insufficient Storage', 509: 'Bandwidth Limit Exceeded', }; /** HTTPNachricht.holeStatusNachricht for the codes FILIUS' server sends (Java's texts for others are its message bundle's). */ export function httpStatusText(code: number): string { return STATUS_TEXTS[code] ?? ''; } /** Characters a path may hold as they are (RFC 3986 pchar and "/"), as Spring's UriUtils.encodePath keeps them. */ const PATH_CHARACTERS = /[A-Za-z0-9\-._~!$&'()*+,;=:@/]/; /** UriUtils.encodePath(path, "utf8"). */ export function encodeUriPath(path: string): string { let text = ''; for (const character of path) { if (PATH_CHARACTERS.test(character)) { text += character; continue; } for (const byte of new TextEncoder().encode(character)) text += `%${byte.toString(16).toUpperCase().padStart(2, '0')}`; } return text; } /** UriUtils.decode(path, "utf8"): undefined where Java throws (a "%" without two hex digits). */ export function decodeUriPath(path: string): string | undefined { const bytes: number[] = []; const encoder = new TextEncoder(); for (let index = 0; index < path.length; index += 1) { if (path[index] === '%') { const hex = path.slice(index + 1, index + 3); if (!/^[0-9A-Fa-f]{2}$/.test(hex)) return undefined; bytes.push(Number.parseInt(hex, 16)); index += 2; } else { const code = path.codePointAt(index)!; if (code > 0xffff) index += 1; bytes.push(...encoder.encode(String.fromCodePoint(code))); } } return new TextDecoder().decode(new Uint8Array(bytes)); }