diff --git a/src/http/kernel/HTTPCookieJar.ts b/src/http/kernel/HTTPCookieJar.ts new file mode 100644 index 0000000..430f331 --- /dev/null +++ b/src/http/kernel/HTTPCookieJar.ts @@ -0,0 +1,49 @@ +import {Request} from "../lifecycle/Request"; +import {infer} from "@extollo/util"; + +/** + * Base type representing a parsed cookie. + */ +export interface HTTPCookie { + key: string, + originalValue: string, + value: any, + exists: boolean, +} + +export type MaybeHTTPCookie = HTTPCookie | undefined; + +export class HTTPCookieJar { + protected parsed: {[key: string]: HTTPCookie} = {} + + constructor( + protected request: Request, + ) { + this.parseCookies() + } + + get(name: string): MaybeHTTPCookie { + if ( name in this.parsed ) { + return this.parsed[name] + } + } + + private parseCookies() { + const cookies = String(this.request.getHeader('cookie')) + cookies.split(';').forEach(cookie => { + const parts = cookie.split('=') + + const key = parts.shift()?.trim() + if ( !key ) return; + + const value = decodeURI(parts.join('=')) + + this.parsed[key] = { + key, + originalValue: value, + value: infer(value), + exists: true, + } + }) + } +} diff --git a/src/http/kernel/HTTPKernel.ts b/src/http/kernel/HTTPKernel.ts new file mode 100644 index 0000000..66c93ff --- /dev/null +++ b/src/http/kernel/HTTPKernel.ts @@ -0,0 +1,150 @@ +import {Instantiable, Singleton, Inject} from "@extollo/di" +import {Collection} from "@extollo/util" +import {HTTPKernelModule} from "./HTTPKernelModule"; +import {Logging} from "../../service/Logging"; +import {AppClass} from "../../lifecycle/AppClass"; +import {Request} from "../lifecycle/Request"; + +/** + * Interface for fluently registering kernel modules into the kernel. + */ +export interface ModuleRegistrationFluency { + before: (other?: Instantiable) => HTTPKernel, + after: (other?: Instantiable) => HTTPKernel, + first: () => HTTPKernel, + last: () => HTTPKernel, + core: () => HTTPKernel, +} + +/** + * Error thrown when a kernel module is requested that does not exist w/in the kernel. + * @extends Error + */ +export class KernelModuleNotFoundError extends Error { + constructor(name: string) { + super(`The kernel module ${name} is not registered with the kernel.`) + } +} + +@Singleton() +export class HTTPKernel extends AppClass { + @Inject() + protected readonly logging!: Logging; + + /** + * Collection of preflight modules to apply. + * @type Collection + */ + protected preflight: Collection = new Collection() + + /** + * Module considered to be the main handler. + * @type HTTPKernelModule + */ + protected inflight?: HTTPKernelModule + + /** + * Collection of postflight modules to apply. + * @type Collection + */ + protected postflight: Collection = new Collection() + + /** + * Handle the incoming request, applying the preflight modules, inflight module, then postflight modules. + * @param {Request} request + * @return Promise + */ + public async handle(request: Request): Promise { + try { + for (const module of this.preflight.toArray()) { + this.logging.verbose(`Applying pre-flight HTTP kernel module: ${module.constructor.name}`) + request = await module.apply(request) + } + + if (this.inflight) { + this.logging.verbose(`Applying core HTTP kernel module: ${this.inflight.constructor.name}`) + request = await this.inflight.apply(request) + } + + for (const module of this.postflight.toArray()) { + this.logging.verbose(`Applying post-flight HTTP kernel module: ${module.constructor.name}`) + request = await module.apply(request) + } + } catch (e: any) { + this.logging.error(e) + // FIXME handle error response + // const error_response = error(e) + // await error_response.write(request) + } + + return request + } + + /** + * Get a fluent interface for registering the given kernel module. + * @param {Instantiable} module + * @return ModuleRegistrationFluency + */ + public register(module: Instantiable): ModuleRegistrationFluency { + this.logging.verbose(`Registering HTTP kernel module: ${module.name}`) + + return { + before: (other?: Instantiable): HTTPKernel => { + if ( !other ) { + this.preflight = this.preflight.push(this.app().make(module)) + return this + } + + let found_index = this.preflight.find((mod: HTTPKernelModule) => mod instanceof other) + if ( typeof found_index !== 'undefined' ) { + this.preflight = this.preflight.put(found_index, this.app().make(module)) + return this + } else { + found_index = this.postflight.find((mod: HTTPKernelModule) => mod instanceof other) + } + + if ( typeof found_index !== 'undefined' ) { + this.postflight = this.postflight.put(found_index, this.app().make(module)) + } else { + throw new KernelModuleNotFoundError(other.name) + } + + return this + }, + after: (other?: Instantiable): HTTPKernel => { + if ( !other ) { + this.postflight = this.postflight.push(this.app().make(module)) + return this + } + + let found_index = this.preflight.find((mod: HTTPKernelModule) => mod instanceof other) + if ( typeof found_index !== 'undefined' ) { + this.preflight = this.preflight.put(found_index + 1, this.app().make(module)) + return this + } else { + found_index = this.postflight.find((mod: HTTPKernelModule) => mod instanceof other) + } + + if ( typeof found_index !== 'undefined' ) { + this.postflight = this.postflight.put(found_index + 1, this.app().make(module)) + } else { + throw new KernelModuleNotFoundError(other.name) + } + + return this + }, + first: (): HTTPKernel => { + this.preflight = this.preflight.put(0, this.app().make(module)) + return this + }, + last: (): HTTPKernel => { + this.postflight = this.postflight.push(this.app().make(module)) + return this + }, + core: (): HTTPKernel => { + this.inflight = this.app().make(module) + return this + }, + } + } +} diff --git a/src/http/kernel/HTTPKernelModule.ts b/src/http/kernel/HTTPKernelModule.ts new file mode 100644 index 0000000..9a11e29 --- /dev/null +++ b/src/http/kernel/HTTPKernelModule.ts @@ -0,0 +1,33 @@ +import {Injectable} from "@extollo/di"; +import {AppClass} from "../../lifecycle/AppClass"; +import {HTTPKernel} from "./HTTPKernel"; +import {Request} from "../lifecycle/Request"; + +@Injectable() +export class HTTPKernelModule extends AppClass { + /** + * Returns true if the given module should be applied to the incoming request. + * @param {Request} request + * @return Promise + */ + public async match(request: Request): Promise { + return true + } + + /** + * Apply the module to the incoming request. + * @param {Request} request + * @return Promise + */ + public async apply(request: Request): Promise { + return request + } + + /** + * Register this module with the given HTTP kernel. + * @param {HTTPKernel} kernel + */ + public static register(kernel: HTTPKernel) { + kernel.register(this).before() + } +} diff --git a/src/http/kernel/module/PrepareRequestHTTPModule.ts b/src/http/kernel/module/PrepareRequestHTTPModule.ts new file mode 100644 index 0000000..521a489 --- /dev/null +++ b/src/http/kernel/module/PrepareRequestHTTPModule.ts @@ -0,0 +1,14 @@ +import {HTTPKernelModule} from "../HTTPKernelModule"; +import {Request} from "../../lifecycle/Request"; +import {HTTPKernel} from "../HTTPKernel"; + +export class PrepareRequestHTTPModule extends HTTPKernelModule { + public static register(kernel: HTTPKernel) { + kernel.register(this).first() + } + + public async apply(request: Request) { + await request.prepare() + return request + } +} diff --git a/src/http/lifecycle/Request.ts b/src/http/lifecycle/Request.ts new file mode 100644 index 0000000..1825829 --- /dev/null +++ b/src/http/lifecycle/Request.ts @@ -0,0 +1,132 @@ +import {Injectable, ScopedContainer, Container} from "@extollo/di" +import {infer} from "@extollo/util" +import {IncomingMessage} from "http" +import {HTTPCookieJar} from "../kernel/HTTPCookieJar"; +import {TLSSocket} from "tls"; +import * as url from "url"; + +// FIXME - add others? +export type HTTPMethod = 'post' | 'get' | 'patch' | 'put' | 'delete' | 'unknown'; +export function isHTTPMethod(what: any): what is HTTPMethod { + return ['post', 'get', 'patch', 'put', 'delete'].includes(what) +} + +export interface HTTPProtocol { + string: string, + major: number, + minor: number, +} + +export interface HTTPSourceAddress { + address: string; + family: 'IPv4' | 'IPv6'; + port: number; +} + +@Injectable() +export class Request extends ScopedContainer { + + public readonly cookies: HTTPCookieJar; + + public readonly url: string; + public readonly fullUrl: string; + public readonly method: HTTPMethod; + public readonly secure: boolean; + public readonly protocol: HTTPProtocol; + public readonly path: string; + public readonly rawQueryData: {[key: string]: string | string[] | undefined}; + public readonly query: {[key: string]: any}; + public readonly isXHR: boolean; + public readonly address: HTTPSourceAddress; + + constructor( + protected clientRequest: IncomingMessage + ) { + super(Container.getContainer()) + + this.secure = !!(clientRequest.connection as TLSSocket).encrypted + + this.cookies = new HTTPCookieJar(this) + this.url = String(clientRequest.url) + this.fullUrl = (this.secure ? 'https' : 'http') + `://${this.getHeader('host')}${this.url}` + + const method = clientRequest.method?.toLowerCase() + this.method = isHTTPMethod(method) ? method : 'unknown' + + this.protocol = { + string: clientRequest.httpVersion, + major: clientRequest.httpVersionMajor, + minor: clientRequest.httpVersionMinor, + } + + this.register(Request) + this.instances.push({ + key: Request, + value: this, + }) + + const parts = url.parse(this.url, true) + + this.path = parts.pathname ?? '/' + this.rawQueryData = parts.query + + const query: {[key: string]: any} = {} + for ( const key in this.rawQueryData ) { + const value = this.rawQueryData[key] + + if ( Array.isArray(value) ) { + query[key] = value.map(x => infer(x)) + } else if ( value ) { + query[key] = infer(value) + } else { + query[key] = value + } + } + + this.query = query + this.isXHR = String(this.clientRequest.headers['x-requested-with']).toLowerCase() === 'xmlhttprequest' + + // @ts-ignore + const {address = '0.0.0.0', family = 'IPv4', port = 0} = this.clientRequest.connection.address() + this.address = { + address, + family, + port + } + } + + public async prepare() { + + } + + public getHeader(name: string) { + return this.clientRequest.headers[name.toLowerCase()] + } + + public toNative() { + return this.clientRequest + } + + public input(key: string) { + if ( key in this.query ) { + return this.query[key] + } + } + + // session + // route + // respond + // body + // hostname + + /* + param + json + fresh/stale - cache + remote ips (proxy) + signedCookies + accepts content type, charsets, encodings, languages + is content type (wants) + range header parser + */ +} diff --git a/src/index.ts b/src/index.ts index 0fce67c..e50997e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,6 +5,9 @@ export * from './lifecycle/Application' export * from './lifecycle/AppClass' export * from './lifecycle/Unit' +export * from './http/kernel/HTTPKernel' +export * from './http/kernel/HTTPKernelModule' + export * from './http/Controller' export * from './service/Canonical' @@ -14,3 +17,4 @@ export * from './service/CanonicalStatic' export * from './service/FakeCanonical' export * from './service/Config' export * from './service/Controllers' +export * from './service/HTTPServer' diff --git a/src/service/HTTPServer.ts b/src/service/HTTPServer.ts new file mode 100644 index 0000000..97f0d0b --- /dev/null +++ b/src/service/HTTPServer.ts @@ -0,0 +1,48 @@ +import {Singleton, Inject} from "@extollo/di" +import {Unit} from "../lifecycle/Unit"; +import {createServer, IncomingMessage, ServerResponse, Server} from "http"; +import {Logging} from "./Logging"; +import {Request} from "../http/lifecycle/Request"; + +@Singleton() +export class HTTPServer extends Unit { + @Inject() + protected readonly logging!: Logging + + protected server?: Server + + public async up() { + const port = 8000 + + await new Promise((res, rej) => { + this.server = createServer(this.handler) + + this.server.listen(port, undefined, undefined, () => { + this.logging.success(`Server listening on port ${port}. Press ^C to stop.`) + }) + + process.on('SIGINT', res) + }) + } + + public async down() { + if ( this.server ) { + this.server.close(err => { + if ( err ) { + this.logging.error(`Error encountered while closing HTTP server: ${err.message}`) + this.logging.debug(err) + } + }) + } + } + + public get handler() { + return (request: IncomingMessage, response: ServerResponse) => { + const extolloReq = new Request(request) + console.log(extolloReq) + console.log(extolloReq.protocol) + response.end('Hi, from Extollo!'); + } + } + +}