From 94add3d471c5281e0115f3fe11e2ad83fe3a91ab Mon Sep 17 00:00:00 2001 From: garrettmills Date: Sun, 7 Mar 2021 09:58:21 -0600 Subject: [PATCH] Add support for responses --- src/http/kernel/HTTPCookieJar.ts | 87 +++++++++++++++++++++++- src/http/lifecycle/Request.ts | 9 ++- src/http/lifecycle/Response.ts | 111 +++++++++++++++++++++++++++++++ src/service/HTTPServer.ts | 7 +- 4 files changed, 207 insertions(+), 7 deletions(-) create mode 100644 src/http/lifecycle/Response.ts diff --git a/src/http/kernel/HTTPCookieJar.ts b/src/http/kernel/HTTPCookieJar.ts index 430f331..98c5704 100644 --- a/src/http/kernel/HTTPCookieJar.ts +++ b/src/http/kernel/HTTPCookieJar.ts @@ -1,5 +1,5 @@ import {Request} from "../lifecycle/Request"; -import {infer} from "@extollo/util"; +import {uninfer, infer, uuid_v4} from "@extollo/util"; /** * Base type representing a parsed cookie. @@ -9,10 +9,22 @@ export interface HTTPCookie { originalValue: string, value: any, exists: boolean, + options?: HTTPCookieOptions, } export type MaybeHTTPCookie = HTTPCookie | undefined; +export interface HTTPCookieOptions { + domain?: string, + expires?: Date, // encodeURIComponent + httpOnly?: boolean, + maxAge?: number, + path?: string, + secure?: boolean, + signed?: boolean, + sameSite?: 'strict' | 'lax' | 'none-secure', +} + export class HTTPCookieJar { protected parsed: {[key: string]: HTTPCookie} = {} @@ -28,6 +40,79 @@ export class HTTPCookieJar { } } + set(name: string, value: any, options?: HTTPCookieOptions) { + this.parsed[name] = { + key: name, + value, + originalValue: uninfer(value), + exists: false, + options, + } + } + + clear(name: string, options?: HTTPCookieOptions) { + if ( !options ) options = {} + options.expires = new Date(0) + + this.parsed[name] = { + key: name, + value: undefined, + originalValue: uuid_v4(), + exists: false, + options, + } + } + + getSetCookieHeaders(): string[] { + const headers: string[] = [] + + for ( const key in this.parsed ) { + if ( !this.parsed.hasOwnProperty(key) ) continue + const cookie = this.parsed[key] + + const parts = [] + parts.push(`${key}=${encodeURIComponent(cookie.originalValue)}`) + + if ( cookie.options?.expires ) { + parts.push(`Expires=${cookie.options.expires.toUTCString()}`) + } + + if ( cookie.options?.maxAge ) { + parts.push(`Max-Age=${Math.floor(cookie.options.maxAge)}`) + } + + if ( cookie.options?.domain ) { + parts.push(`Domain=${cookie.options.domain}`) + } + + if ( cookie.options?.path ) { + parts.push(`Path=${cookie.options.path}`) + } + + if ( cookie.options?.secure ) { + parts.push('Secure') + } + + if ( cookie.options?.httpOnly ) { + parts.push('HttpOnly') + } + + if ( cookie.options?.sameSite ) { + const map = { + strict: 'Strict', + lax: 'Lax', + 'none-secure': 'None; Secure' + } + + parts.push(map[cookie.options.sameSite]) + } + + headers.push(parts.join('; ')) + } + + return headers + } + private parseCookies() { const cookies = String(this.request.getHeader('cookie')) cookies.split(';').forEach(cookie => { diff --git a/src/http/lifecycle/Request.ts b/src/http/lifecycle/Request.ts index 1825829..2d785b5 100644 --- a/src/http/lifecycle/Request.ts +++ b/src/http/lifecycle/Request.ts @@ -1,9 +1,10 @@ import {Injectable, ScopedContainer, Container} from "@extollo/di" import {infer} from "@extollo/util" -import {IncomingMessage} from "http" +import {IncomingMessage, ServerResponse} from "http" import {HTTPCookieJar} from "../kernel/HTTPCookieJar"; import {TLSSocket} from "tls"; import * as url from "url"; +import {Response} from "./Response"; // FIXME - add others? export type HTTPMethod = 'post' | 'get' | 'patch' | 'put' | 'delete' | 'unknown'; @@ -38,9 +39,11 @@ export class Request extends ScopedContainer { public readonly query: {[key: string]: any}; public readonly isXHR: boolean; public readonly address: HTTPSourceAddress; + public readonly response: Response; constructor( - protected clientRequest: IncomingMessage + protected clientRequest: IncomingMessage, + protected serverResponse: ServerResponse, ) { super(Container.getContainer()) @@ -93,6 +96,8 @@ export class Request extends ScopedContainer { family, port } + + this.response = new Response(this, serverResponse) } public async prepare() { diff --git a/src/http/lifecycle/Response.ts b/src/http/lifecycle/Response.ts new file mode 100644 index 0000000..44c992b --- /dev/null +++ b/src/http/lifecycle/Response.ts @@ -0,0 +1,111 @@ +import {Request} from "./Request"; +import {ErrorWithContext, HTTPStatus} from "@extollo/util" +import {ServerResponse} from "http" + +export class HeadersAlreadySentError extends ErrorWithContext { + constructor(response: Response, headerName?: string) { + super(`Cannot modify or re-send headers for this request as they have already been sent.`); + this.context = { headerName } + } +} + +export class ResponseAlreadySentError extends ErrorWithContext { + constructor(response: Response) { + super(`Cannot modify or re-send response as it has already ended.`); + } +} + +export class Response { + private headers: {[key: string]: string | string[]} = {} + private _sentHeaders: boolean = false + private _responseEnded: boolean = false + private _status: HTTPStatus = HTTPStatus.OK + public body: any + + constructor( + public readonly request: Request, + protected readonly serverResponse: ServerResponse, + ) { } + + public getStatus() { + return this._status + } + + public setStatus(status: HTTPStatus) { + if ( this._sentHeaders ) throw new HeadersAlreadySentError(this, 'status') + this._status = status + } + + public get cookies() { + return this.request.cookies + } + + public getHeader(name: string): string | string[] | undefined { + return this.headers[name] + } + + public setHeader(name: string, value: string | string[]) { + if ( this._sentHeaders ) throw new HeadersAlreadySentError(this, name) + this.headers[name] = value + return this + } + + public setHeaders(data: {[name: string]: string | string[]}) { + if ( this._sentHeaders ) throw new HeadersAlreadySentError(this) + this.headers = {...this.headers, ...data} + return this + } + + public appendHeader(name: string, value: string | string[]) { + if ( this._sentHeaders ) throw new HeadersAlreadySentError(this, name) + if ( !Array.isArray(value) ) value = [value] + let existing = this.headers[name] ?? [] + if ( !Array.isArray(existing) ) existing = [existing] + + existing = [...existing, ...value] + if ( existing.length === 1 ) existing = existing[0] + this.headers[name] = existing + } + + public sendHeaders() { + const headers = {} as any + + const setCookieHeaders = this.cookies.getSetCookieHeaders() + if ( setCookieHeaders.length ) headers['Set-Cookie'] = setCookieHeaders + + for ( const key in this.headers ) { + if ( !this.headers.hasOwnProperty(key) ) continue + headers[key] = this.headers[key] + } + + this.serverResponse.writeHead(this._status, headers) + this._sentHeaders = true + } + + public hasSentHeaders() { + return this._sentHeaders + } + + public async write(data: any) { + return new Promise((res, rej) => { + if ( !this._sentHeaders ) this.sendHeaders() + this.serverResponse.write(data, error => { + if ( error ) rej(error) + else res() + }) + }) + } + + public async send(data?: any) { + await this.write(data ?? this.body ?? '') + this.end() + } + + public end() { + if ( this._responseEnded ) throw new ResponseAlreadySentError(this) + this._sentHeaders = true + this.serverResponse.end() + } + + // location? +} diff --git a/src/service/HTTPServer.ts b/src/service/HTTPServer.ts index 97f0d0b..39aa774 100644 --- a/src/service/HTTPServer.ts +++ b/src/service/HTTPServer.ts @@ -38,10 +38,9 @@ export class HTTPServer extends Unit { 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!'); + const extolloReq = new Request(request, response) + extolloReq.cookies.set('testing123', {foo: 'bar', bob: 123}) + extolloReq.response.send('Hi, from Extollo!!') } }