Skip to main content
Module

x/aqua/aqua.ts

A minimal and fast πŸƒ web framework for Deno
Latest
File
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612
import { getAquaRequestFromNativeRequest, getFinalizedStatusCode, Json,} from "./shared.ts";import { findMatchingRegexRoute, findMatchingStaticRoute, findRouteWithMatchingURLParameters, parseRequestPath,} from "./helpers/routing.ts";import { getContentType } from "./helpers/content_identification.ts";
export type Method = | "GET" | "HEAD" | "POST" | "PUT" | "DELETE" | "CONNECT" | "OPTIONS" | "TRACE" | "PATCH";
type ResponseContent = | Uint8Array | Blob | BufferSource | FormData | URLSearchParams | ReadableStream<Uint8Array> | string;
interface ContentResponse { statusCode?: number; headers?: Record<string, string>; cookies?: Record<string, string>; redirect?: string; content: ResponseContent;}
interface RedirectResponse { statusCode?: number; headers?: Record<string, string>; cookies?: Record<string, string>; redirect: string; content?: ResponseContent;}
export type ResponseObject = ContentResponse | RedirectResponse;export type Response = ResponseContent | ResponseObject;
export interface Request { _internal: { respond(res: ResponseObject): void; }; raw: Deno.RequestEvent["request"]; url: string; method: Method; headers: Record<string, string>; query: Record<string, string>; body: Record<string, Json>; files: Record<string, File>; cookies: Record<string, string>; parameters: Record<string, string>; matches: string[]; conn?: Deno.Conn;}
type OutgoingMiddleware = ( req: Request, res: ResponseObject,) => Response | Promise<Response>;type IncomingMiddleware = (req: Request) => Request | Promise<Request>;
type ResponseHandler = (req: Request) => Response | Promise<Response>;
export enum ErrorType { NotFound, SchemaMismatch, ErrorThrownInResponseHandler,}
type FallbackHandler = ( req: Request, errorType: ErrorType,) => Response | Promise<Response> | null | Promise<null>;
interface RouteTemplate { options?: RoutingOptions; responseHandler: ResponseHandler;}
export interface StringRoute extends RouteTemplate { path: string; method: Method; usesURLParameters: boolean; urlParameterRegex?: RegExp;}
export interface RegexRoute extends RouteTemplate { path: RegExp; method: Method;}
export interface StaticRoute extends RouteTemplate { folder: string; path: string;}
export interface Options { ignoreTrailingSlash?: boolean; log?: boolean; tls?: { independentPort?: number; hostname?: string; certFile: string; keyFile: string; };}
export type RoutingSchemaValidationFunction<Context> = ( this: Context, context: Context,) => boolean | Promise<boolean>;
type RoutingSchemaKeys = | "body" | "query" | "cookies" | "parameters" | "headers";
type RoutingSchema = { [requestKey in RoutingSchemaKeys]?: RoutingSchemaValidationFunction< Request[requestKey] >[];};
export interface RoutingOptions { schema?: RoutingSchema;}
export enum MiddlewareType { Incoming = "Incoming", Outgoing = "Outgoing",}
const NOT_FOUND_RESPONSE = { statusCode: 404, content: "Not found." };
export function mustExist( key: string,): RoutingSchemaValidationFunction<Record<string, unknown>> { return function () { return Object.keys(this).includes(key); };}
export function valueMustBeOfType( key: string, type: "string" | "number" | "boolean" | "object" | "undefined",): RoutingSchemaValidationFunction<Record<string, unknown>> { return function () { return Object.keys(this).includes(key) && typeof this[key] === type; };}
export function mustContainValue( key: string, values: unknown[],): RoutingSchemaValidationFunction<Record<string, unknown>> { return function () { return Object.keys(this).includes(key) && values.includes(this[key]); };}
export default class Aqua { protected readonly options: Options = {}; private routes: Record<string, StringRoute> = {}; private regexRoutes: RegexRoute[] = []; private staticRoutes: StaticRoute[] = []; private incomingMiddlewares: IncomingMiddleware[] = []; private outgoingMiddlewares: OutgoingMiddleware[] = []; private fallbackHandler: FallbackHandler | null = null;
constructor(port: number, options?: Options) { this.options = options || {}; this.listen(port, { onlyTls: (options?.tls && !options.tls.independentPort) || options?.tls?.independentPort === port, });
if (this.options.log) { console.log(`Server started (http://localhost:${port})`); } }
protected listen(port: number, { onlyTls }: { onlyTls: boolean }) { const listenerFns = [];
if (this.options.tls) { listenerFns.push( Deno.listenTls.bind(undefined, { hostname: this.options.tls.hostname || "localhost", certFile: this.options.tls.certFile || "./localhost.crt", keyFile: this.options.tls.keyFile || "./localhost.key", port: this.options.tls.independentPort || port, }), ); }
if (!onlyTls) listenerFns.push(Deno.listen.bind(undefined, { port }));
for (const listenerFn of listenerFns) { (async () => { for await (const conn of listenerFn()) { (async () => { for await (const event of Deno.serveHttp(conn)) { const req = await getAquaRequestFromNativeRequest(event, conn); this.handleRequest(req); } })(); } })(); } }
private connectURLParameters( route: StringRoute, requestedPath: string, ): Record<string, string> { const urlParametersWithColon = route.path.match(/:([a-zA-Z0-9_]*)/g) ?? []; const urlParameters: Record<string, string> = {}; const slashSplittedRoutePath = route.path.split("/"); const slashSplittedRequestedPath = requestedPath.split("/");
for (const urlParameterWithColon of urlParametersWithColon) { const indexPos = slashSplittedRoutePath.indexOf(urlParameterWithColon); if (indexPos === -1) continue; const value = slashSplittedRequestedPath[indexPos];
if (!value) continue; urlParameters[urlParameterWithColon.slice(1)] = value; }
return urlParameters; }
private convertResponseToResponseObject(response: Response): ResponseObject { if (typeof response === "string") { return { headers: { "Content-Type": "text/html; charset=UTF-8" }, content: response, }; }
if ( typeof response !== "object" || (!("content" in response) && !("redirect" in response)) ) { return { content: response }; }
return response; }
private isRegexPath(path: string | RegExp): path is RegExp { return path instanceof RegExp; }
private async getOutgoingResponseAfterApplyingMiddlewares( req: Request, res: ResponseObject, ): Promise<ResponseObject> { let responseAfterMiddlewares: ResponseObject = res; for (const middleware of this.outgoingMiddlewares) { responseAfterMiddlewares = this.convertResponseToResponseObject( await middleware(req, responseAfterMiddlewares), ); } return responseAfterMiddlewares; }
private async getIncomingRequestAfterApplyingMiddlewares(req: Request) { let requestAfterMiddleWares: Request = req; for (const middleware of this.incomingMiddlewares) { requestAfterMiddleWares = await middleware(req); } return requestAfterMiddleWares; }
private async respondToRequest( req: Request, requestedPath: string, route: StringRoute | RegexRoute | StaticRoute, additionalResponseOptions: { usesURLParameters?: boolean; customResponseHandler?: ResponseHandler | undefined; } = { usesURLParameters: false, customResponseHandler: undefined }, ) { if (additionalResponseOptions.usesURLParameters) { req.parameters = this.connectURLParameters( route as StringRoute, requestedPath, ); }
if (route.options?.schema) { let passedAllValidations = true;
routingSchemaIterator: for ( const routingSchemaKey of Object.keys( route.options.schema, ) as RoutingSchemaKeys[] ) { for ( const validationFunction of (route.options.schema[ routingSchemaKey ] || []) as RoutingSchemaValidationFunction<unknown>[] ) { const schemaContext = req[routingSchemaKey]; if (!(await validationFunction.bind(schemaContext)(schemaContext))) { passedAllValidations = false; break routingSchemaIterator; } } }
if (!passedAllValidations) { req._internal.respond( await this.getFallbackHandlerResponse( req, ErrorType.SchemaMismatch, NOT_FOUND_RESPONSE, ), ); return; } }
if (this.isRegexPath(route.path)) { req.matches = (requestedPath.match(route.path) as string[]).slice(1) || []; }
try { const formattedResponse = this.convertResponseToResponseObject( await (additionalResponseOptions.customResponseHandler ? additionalResponseOptions.customResponseHandler(req) : (route as StringRoute | RegexRoute).responseHandler(req)), );
const responseAfterMiddlewares = await this .getOutgoingResponseAfterApplyingMiddlewares( req, formattedResponse, );
req._internal.respond(responseAfterMiddlewares); } catch (error) { req._internal.respond( await this.getFallbackHandlerResponse( req, ErrorType.ErrorThrownInResponseHandler, { statusCode: 500, content: String(error) }, ), ); } }
private async getFallbackHandlerResponse( req: Request, errorType: ErrorType, defaultErrorResponse: ResponseObject, ): Promise<ResponseObject> { if (this.fallbackHandler) { const fallbackHandlerResponse = await this.fallbackHandler( req, errorType, );
if (!fallbackHandlerResponse) { return defaultErrorResponse; }
const fallbackResponse = this.convertResponseToResponseObject( fallbackHandlerResponse, );
return { statusCode: getFinalizedStatusCode(fallbackResponse, 404), headers: fallbackResponse.headers, content: fallbackResponse.content || "No fallback response content provided.", }; }
return defaultErrorResponse; }
private async handleStaticRequest( req: Request, { path, folder }: { path: string; folder: string }, ): Promise<Response> { const requestedPath = parseRequestPath(req.url); const resourcePath: string = requestedPath.replace(path, ""); const extension: string = resourcePath.replace( /.*(?=\.[a-zA-Z0-9_]*$)/, "", ); const contentType: string | null = extension ? getContentType(extension) : null;
try { return { headers: contentType ? { "Content-Type": contentType } : undefined, content: await Deno.readFile(`${folder}/${resourcePath}`), }; } catch { return await this.getFallbackHandlerResponse( req, ErrorType.NotFound, NOT_FOUND_RESPONSE, ); } }
protected async handleRequest(req: Request) { if (this.options.ignoreTrailingSlash) { req.url = req.url.replace(/\/$/, "") + "/"; }
req = await this.getIncomingRequestAfterApplyingMiddlewares(req);
const requestedPath = parseRequestPath(req.url);
if (this.options.log) { console.log( `\x1b[33m${req.method} \x1b[0m(\x1b[36mIncoming\x1b[0m) \x1b[0m${requestedPath}\x1b[0m`, ); }
if (this.routes[req.method + requestedPath]) { this.respondToRequest( req, requestedPath, this.routes[req.method + requestedPath], ); return; }
const matchingRouteWithURLParameters = findRouteWithMatchingURLParameters( requestedPath, this.routes, req.method, );
if (matchingRouteWithURLParameters) { this.respondToRequest( req, requestedPath, matchingRouteWithURLParameters, { usesURLParameters: true }, ); return; }
const matchingRegexRoute = findMatchingRegexRoute( requestedPath, this.regexRoutes, req.method, );
if (matchingRegexRoute) { this.respondToRequest(req, requestedPath, matchingRegexRoute); return; }
if (req.method === "GET") { const matchingStaticRoute = findMatchingStaticRoute( requestedPath, this.staticRoutes, );
if (matchingStaticRoute) { this.respondToRequest(req, requestedPath, matchingStaticRoute); return; } }
req._internal.respond( await this.getFallbackHandlerResponse( req, ErrorType.NotFound, NOT_FOUND_RESPONSE, ), ); }
public provideFallback(responseHandler: FallbackHandler): Aqua { this.fallbackHandler = responseHandler; return this; }
public register<_, Type extends MiddlewareType = MiddlewareType.Outgoing>( middleware: Type extends undefined ? OutgoingMiddleware : Type extends MiddlewareType.Incoming ? IncomingMiddleware : OutgoingMiddleware, type?: Type, ): Aqua { if (type === MiddlewareType.Incoming) { this.incomingMiddlewares.push(middleware as IncomingMiddleware); return this; }
this.outgoingMiddlewares.push(middleware as OutgoingMiddleware); return this; }
public route( path: string | RegExp, method: Method, responseHandler: ResponseHandler, options: RoutingOptions = {}, ): Aqua { if (path instanceof RegExp) { this.regexRoutes.push({ path, responseHandler, method }); return this; }
if (!path.startsWith("/")) throw Error("Routes must start with a slash"); if (this.options.ignoreTrailingSlash) path = path.replace(/\/$/, "") + "/";
const usesURLParameters = /:[a-zA-Z]/.test(path);
this.routes[method.toUpperCase() + path] = { path, usesURLParameters, urlParameterRegex: usesURLParameters ? new RegExp(path.replace(/:([a-zA-Z0-9_]*)/g, "([^/]*)")) : undefined, responseHandler, options, method, }; return this; }
public get( path: string | RegExp, responseHandler: ResponseHandler, options: RoutingOptions = {}, ): Aqua { this.route(path, "GET", responseHandler, options); return this; }
public post( path: string | RegExp, responseHandler: ResponseHandler, options: RoutingOptions = {}, ): Aqua { this.route(path, "POST", responseHandler, options); return this; }
public put( path: string | RegExp, responseHandler: ResponseHandler, options: RoutingOptions = {}, ): Aqua { this.route(path, "PUT", responseHandler, options); return this; }
public patch( path: string | RegExp, responseHandler: ResponseHandler, options: RoutingOptions = {}, ): Aqua { this.route(path, "PATCH", responseHandler, options); return this; }
public delete( path: string | RegExp, responseHandler: ResponseHandler, options: RoutingOptions = {}, ): Aqua { this.route(path, "DELETE", responseHandler, options); return this; }
public serve( folder: string, path: string, options: RoutingOptions = {}, ): Aqua { if (!path.startsWith("/")) throw Error("Routes must start with a slash"); this.staticRoutes.push({ folder: folder.replace(/\/$/, "") + "/", path: path.replace(/\/$/, "") + "/", responseHandler: async (req) => await this.handleStaticRequest(req, { path, folder }), options, }); return this; }}