import { IncomingMessage, ServerResponse } from 'http'; import { Directive, DirectiveType, Middleware, MiddlewareContext, MiddlewareReturn, Pipeline, } from '@cardboardrobots/pipeline'; import { Color } from '@cardboardrobots/console-style'; import { Context } from '../context'; import { auto, AutoDirective, error, ErrorDirective, json, JsonDirective, RawDirective, ResponseDirective, TextDirective, view, ViewDirective, } from '../directive'; import { Header } from '../header'; import { ErrorMessage, NonStringViewError, SierraError } from './Errors'; import { LogLevel } from './LogLevel'; const DEFAULT_TEMPLATE = 'index'; const ERROR_TEMPLATE = 'error'; export type ViewContext = CONTEXT & { view: ViewDirective }; export type ErrorContext = CONTEXT & { error: Error }; export class Handler { pipeline: Pipeline = new Pipeline(); errorPipeline: Pipeline, any, any, any> = new Pipeline(); viewPipeline: Pipeline, RESULT, any, any> = new Pipeline(); logging: LogLevel = LogLevel.Errors; defaultTemplate = DEFAULT_TEMPLATE; callback = async (request: IncomingMessage, response: ServerResponse) => { const context = new Context(request, response); try { const result = await this.pipeline.run(context as any, undefined); // If Headers have already sent, we cannot send. if (!context.response.headersSent && !context.response.writableEnded) { this.send(context as CONTEXT, result); } } catch (err) { const errorContext: ErrorContext = context as any; const wrappedError = err instanceof Error ? err : new Error(err); errorContext.error = wrappedError; let errorStatus = 500; if (wrappedError instanceof SierraError) { switch (wrappedError.message) { case ErrorMessage.NotFound: errorStatus = 404; break; } } if (this.errorPipeline.middlewares.length) { try { const result = await this.errorPipeline.run(errorContext, wrappedError); switch (result.type) { case DirectiveType.Exit: case DirectiveType.End: { const wrappedError = result.value instanceof Error ? result.value : new Error(result.value); this.sendError( errorContext, error(wrappedError, { status: errorStatus }) ); break; } default: this.send(errorContext, result); break; } } catch (err) { const wrappedWrror = err instanceof Error ? err : Error(err); errorContext.error = wrappedWrror; this.sendError(errorContext, error(wrappedWrror, { status: errorStatus })); } } else { this.sendError(errorContext, error(wrappedError, { status: errorStatus })); } } }; sendJson(context: CONTEXT, directive: JsonDirective) { const { value, options } = directive; this.log(context, options.status); return this.write(context, JSON.stringify(value ?? null), options.status, { 'Content-Type': 'application/json', ...options.header, }); } sendRaw(context: CONTEXT, directive: RawDirective) { const { value, options } = directive; this.log(context, options.status); if (typeof value === 'string') { return this.write(context, value, options.status, { 'Content-Type': options.contentType ?? 'text/plain', ...options.header, }); } else if (Buffer.isBuffer(value)) { return this.write(context, value, options.status, { 'Content-Type': options.contentType ?? 'octet-stream', ...options.header, }); } else { return this.write(context, JSON.stringify(value ?? null), options.status, { 'Content-Type': options.contentType ?? 'application/json', ...options.header, }); } } sendText(context: CONTEXT, directive: TextDirective) { const { value, options } = directive; this.log(context, options.status); let output: string; if (typeof value === 'object') { output = JSON.stringify(value ?? null); } else { output = `${value ?? ''}`; } return this.write(context, output, options.status, { 'Content-Type': 'text/plain', ...options.header, }); } async sendView(context: CONTEXT, directive: ViewDirective) { const { options } = directive; try { const viewContext: ViewContext = context as any; viewContext.view = directive; const { value } = await this.viewPipeline.run(viewContext, directive.value); // Ensure output is a string if (typeof value !== 'string') { throw new NonStringViewError(value); } this.log(context, options.status); return this.write(context, value, options.status, { 'Content-Type': 'text/html', ...options.header, }); } catch (err) { this.log(context, 500); const result = await this.write(context, errorTemplate(err), 500, { 'Content-Type': 'text/html', ...options.header, }); if (this.logging >= LogLevel.Errors) { console.error(err); } return result; } } async sendAuto(context: CONTEXT, directive: AutoDirective) { const accept = getAccept(context.request); if (this.viewPipeline.middlewares.length && accept && accept.indexOf('text/html') > -1) { const { value, options } = directive; this.sendView(context, view(value, options)); } else if (accept && accept.indexOf('application/json')) { this.sendJson(context, directive); } else { this.sendRaw(context, directive); } } async sendError(context: CONTEXT, directive: ErrorDirective) { const { accept } = context.request.headers; const { value, options } = directive; const { status, header } = options; if (Math.floor(status / 100) === 5) { if (this.logging >= LogLevel.Errors) { console.error(value); } } try { if ( this.viewPipeline.middlewares.length && accept && accept.indexOf('text/html') > -1 ) { // TODO: Fix sendView for Error await this.sendView( context, view(value.message, { status, header, template: ERROR_TEMPLATE }) as any ); } else { this.sendJson(context, json(value.message ?? value.name, { status, header })); } } catch (err) { if (this.logging >= LogLevel.Errors) { console.error(err); } } } send(context: CONTEXT, directive: Directive) { if (directive instanceof ResponseDirective) { if (directive instanceof JsonDirective) { return this.sendJson(context, directive); } else if (directive instanceof RawDirective) { return this.sendRaw(context, directive); } else if (directive instanceof ViewDirective) { return this.sendView(context, directive); } else if (directive instanceof AutoDirective) { return this.sendAuto(context, directive); } else if (directive instanceof TextDirective) { return this.sendText(context, directive); } else if (directive instanceof ErrorDirective) { return this.sendError(context, directive); } else { // TODO: Should this be Json or Auto? // This case should not happen return this.sendAuto(context, auto(directive.value)); } } else { // TODO: Should this be Json or Auto? return this.sendAuto(context, auto(directive.value)); } } log(context: Context, status = 500) { if (this.logging >= LogLevel.Verbose) { console.log(context.request.method, context.request.url, colorStatus(status)); } } use(middleware: Middleware): this; use, NEWRESULT = RESULT>( middleware: Middleware>, RESULT, NEWRESULT> ): Handler, NEWRESULT>; use>( middleware: MIDDLEWARE ): Handler, MiddlewareReturn>; use(middleware: Middleware): any { this.pipeline.use(middleware); return this as any; } useError(middlware: Middleware, Error, NEWRESULT>) { return this.errorPipeline.use, NEWRESULT>(middlware); } useView(middlware: Middleware, RESULT, NEWRESULT>) { return this.viewPipeline.use(middlware); } private async write(context: Context, value: T, status: number, header: Header) { const { response } = context; response.statusCode = status; Object.keys(header).forEach((headerName) => { const value = header[headerName as never] as string; response.setHeader(headerName, value); }); try { const result = await new Promise((resolve, reject) => { const result = response.write(value, (error) => { if (error) { reject(error); } else { resolve(result); } }); }); return result; } finally { await new Promise((resolve) => { response.end(() => resolve()); }); } } } export function errorTemplate(error: any) { return ` Sierra Error

Sierra Error

${error}
`; } export function colorStatus(status: number) { switch (Math.floor(status / 100)) { case 1: return Color.white(status); case 2: return Color.green(status); case 3: return Color.blue(status); case 4: return Color.yellow(status); case 5: return Color.red(status); default: return Color.brightBlack(status); } } export function getAccept(request: IncomingMessage) { const accept = request.headers.accept; if (accept) { const types = accept.split(','); return types.map((type) => { const parts = type.split(';'); return parts[0]; }); } else { return []; } }