;
+
+export interface QueryParameters {
+ [name: string]: string;
+}
+
+export interface ResponseHeader {
+ [key: string]: string;
+}
+
+export interface Route {
+ path: string;
+ controller: string;
+ action: string;
+ method?: string;
+}
+
+/**
+ * Tokenizer results.
+ */
+export interface LexToken {
+ type:
+ | "OPEN"
+ | "CLOSE"
+ | "PATTERN"
+ | "NAME"
+ | "CHAR"
+ | "ESCAPED_CHAR"
+ | "MODIFIER"
+ | "END";
+ index: number;
+ value: string;
+}
+
+export interface RegexpToFunctionOptions {
+ /**
+ * Function for decoding strings for params.
+ */
+ decode?: (value: string, token: Key) => string;
+}
+
+/**
+ * A match result contains data about the path match.
+ */
+export interface MatchResult {
+ path: string;
+ index: number;
+ params: P;
+}
+
+/**
+ * A match is either `false` (no match) or a match result.
+ */
+export type Match
= false | MatchResult
;
+
+/**
+ * The match function takes a string and returns whether it matched the path.
+ */
+export type MatchFunction
= (
+ path: string,
+) => Match
;
+
+
+/**
+ * Metadata about a key.
+ */
+export interface Key {
+ name: string | number;
+ prefix: string;
+ suffix: string;
+ pattern: string;
+ modifier: string;
+}
+
+/**
+ * A token is a string (nothing special) or key metadata (capture group).
+ */
+export type Token = string | Key;
+
+export interface TokensToRegexpOptions {
+ /**
+ * When `true` the regexp will be case sensitive. (default: `false`)
+ */
+ sensitive?: boolean;
+ /**
+ * When `true` the regexp won't allow an optional trailing delimiter to match. (default: `false`)
+ */
+ strict?: boolean;
+ /**
+ * When `true` the regexp will match to the end of the string. (default: `true`)
+ */
+ end?: boolean;
+ /**
+ * When `true` the regexp will match from the beginning of the string. (default: `true`)
+ */
+ start?: boolean;
+ /**
+ * Sets the final character for non-ending optimistic matches. (default: `/`)
+ */
+ delimiter?: string;
+ /**
+ * List of characters that can also be "end" characters.
+ */
+ endsWith?: string;
+ /**
+ * Encode path tokens for use in the `RegExp`.
+ */
+ encode?: (value: string) => string;
+}
+
+/**
+ * Supported `path-to-regexp` input types.
+ */
+export type Path = string | RegExp | Array;
+
+export type PathFunction = (data?: P) => string;
+
+
+export interface TokensToFunctionOptions {
+ /**
+ * When `true` the regexp will be case sensitive. (default: `false`)
+ */
+ sensitive?: boolean;
+ /**
+ * Function for encoding input strings for output.
+ */
+ encode?: (value: string, token: Key) => string;
+ /**
+ * When `false` the function can produce an invalid (unmatched) path. (default: `true`)
+ */
+ validate?: boolean;
+}
+
+export interface ParseOptions {
+ /**
+ * Set the default delimiter for repeat parameters. (default: `'/'`)
+ */
+ delimiter?: string;
+ /**
+ * List of characters to automatically consider prefixes when parsing.
+ */
+ prefixes?: string;
+}
diff --git a/src/types/websocket.ts b/src/types/websocket.ts
new file mode 100644
index 00000000..fd322aaa
--- /dev/null
+++ b/src/types/websocket.ts
@@ -0,0 +1,4 @@
+export interface IEvent {
+ name: string;
+ handler: string;
+}
diff --git a/src/utility/check-source.ts b/src/utility/check-source.ts
new file mode 100644
index 00000000..c6ada445
--- /dev/null
+++ b/src/utility/check-source.ts
@@ -0,0 +1,119 @@
+import { ExclusionConfig } from "../types/check-source.ts";
+import { Logger } from "../core/logger.ts";
+import { File } from "../filesystem/file.ts";
+
+/**
+ * Check all files in the specified directories.
+ * Doing this allows the program to start up significantly faster after deployment.
+ * It is **NOT** a replacement for "deno lint".
+ *
+ * @example Basic Usage
+ * ```ts
+ * import { CheckSource } from "https://deno.land/x/chomp/utility/check-source.ts";
+ *
+ * const checker = new CheckSource(['./src']);
+ * await checker.run();
+ * ```
+ *
+ * @example Exclude a directory
+ * ```ts
+ * import { CheckSource } from "https://deno.land/x/chomp/utility/check-source.ts";
+ *
+ * const checker = new CheckSource(['./src'], { directories: 'my-directory' });
+ * await checker.run();
+ * ```
+ *
+ * @example Exclude a file
+ * ```ts
+ * import { CheckSource } from "https://deno.land/x/chomp/utility/check-source.ts";
+ *
+ * const checker = new CheckSource(['./src'], { files: './src/my-directory/my-file.txt' });
+ * await checker.run();
+ * ```
+ */
+export class CheckSource {
+ private files: string[] = [];
+ private errors = 0;
+
+ constructor(
+ private readonly paths: string[],
+ private readonly exclusions: ExclusionConfig = { directories: [], files: [] },
+ ) {}
+
+ public async run(): Promise {
+ // Get all files in all paths
+ for (const path of this.paths) {
+ await this.getFiles(path);
+ }
+
+ // Check all files found
+ Logger.info(`Checking "${this.files.length}" files...`);
+ await this.checkFiles();
+
+ // Exit when done
+ if (this.errors > 0) {
+ Logger.info(
+ `Finished checking files with ${this.errors} errors!\r\nPlease check the logs above for more information.`,
+ );
+ Deno.exit(1);
+ }
+ Logger.info(`Finished checking files without errors!`);
+ Deno.exit(0);
+ }
+
+ /**
+ * Recursively can all files in the given path
+ * Ignore directories and files given in our exclusions
+ *
+ * @param path
+ */
+ private async getFiles(path: string) {
+ Logger.info(`Getting all files in directory "${path}"...`);
+ for await (const entry of Deno.readDir(path)) {
+ if (entry.isDirectory) {
+ if ("directories" in this.exclusions && this.exclusions.directories?.includes(entry.name)) {
+ Logger.debug(`Skipping excluded directory "${path}/${entry.name}"...`);
+ continue;
+ }
+ await this.getFiles(`${path}/${entry.name}`);
+ }
+
+ if (entry.isFile) {
+ if ("files" in this.exclusions && this.exclusions.files?.includes(entry.name)) {
+ Logger.debug(`Skipping excluded file "${path}/${entry.name}"...`);
+ continue;
+ }
+ if (new File(`${path}/${entry.name}`).ext() !== "ts") {
+ Logger.debug(`Skipping non-ts file...`);
+ continue;
+ }
+ Logger.debug(`Found file "${path}/${entry.name}"...`);
+ this.addFile(`${path}/${entry.name}`);
+ }
+ }
+ }
+
+ /**
+ * Add file to array of files
+ *
+ * @param path
+ */
+ private addFile(path: string) {
+ if (this.files.includes(path)) return;
+ this.files.push(path);
+ }
+
+ /**
+ * Check all files found
+ */
+ private async checkFiles() {
+ for await (const file of this.files) {
+ try {
+ await import(`file://${Deno.cwd()}/${file}`);
+ } catch (e) {
+ Logger.error(`Check for "${Deno.cwd()}/${file}" failed: ${e.message}`, e.stack);
+ this.errors++;
+ }
+ }
+ }
+}
diff --git a/src/utility/contract.ts b/src/utility/contract.ts
new file mode 100644
index 00000000..f60a2d3d
--- /dev/null
+++ b/src/utility/contract.ts
@@ -0,0 +1,147 @@
+import { raise } from "../error/raise.ts";
+import { empty } from "./empty.ts";
+import { nameOf } from "./name-of.ts";
+import {valueOrDefault} from "./value-or-default.ts";
+
+/**
+ * Class to more easily throw errors while creating (among others) constructors.
+ * Allows code to be more concise and users to more easily read what it does.
+ *
+ * Its idea was based on the .NET 6 feature of the same name.
+ */
+export class Contract {
+ /**
+ * Make sure the condition is true, otherwise throw an error
+ *
+ * @example Basic Usage
+ * ```ts
+ * import { Contract } from "https://deno.land/x/chomp/utility/contract.ts";
+ *
+ * const myStatement = false;
+ * Contract.requireCondition(myStatement, "Statement must be true");
+ * ```
+ *
+ * @param condition
+ * @param message
+ */
+ public static requireCondition(condition: boolean, message?: string): asserts condition is true {
+ if (!condition) raise(
+ valueOrDefault(message, 'Contract failed, passed condition was null'),
+ "ContractConditionFailed"
+ );
+ }
+
+ public static requireAssertion(argument: unknown, expression: boolean, message?: string): asserts argument is T {
+ if (!expression) raise("Expression evaluated to false");
+
+ Contract.requireNotNullish(argument, message);
+ Contract.requireNotNullish(expression, message);
+ }
+
+ /**
+ * Require the input argument to not be null
+ *
+ * @example Basic Usage
+ * ```ts
+ * import { Contract } from "https://deno.land/x/chomp/utility/contract.ts";
+ *
+ * const myArgument = "blabla";
+ * Contract.requireNotNull(myArgument);
+ * ```
+ *
+ * @param argument
+ * @param message
+ */
+ public static requireNotNull(argument: T, message?: string): asserts argument is Exclude {
+ if (argument === null) raise(
+ valueOrDefault(message,`Contract failed, argument ("${argument}") was null`),
+ "ContractArgumentNull"
+ );
+ }
+
+ /**
+ * Require the input argument to not be undefined
+ *
+ * @example Basic Usage
+ * ```ts
+ * import { Contract } from "https://deno.land/x/chomp/utility/contract.ts";
+ *
+ * const myArgument = "blabla";
+ * Contract.requireNotUndefined(myArgument);
+ * ```
+ *
+ * @param argument
+ * @param message
+ */
+ public static requireNotUndefined(argument: T, message?: string): asserts argument is Exclude {
+ if(argument === undefined) raise(message ? message : `Contract failed, argument ("${argument}") was undefined`, "ContractArgumentUndefined");
+ }
+
+ /**
+ * Require the input to not be nullish.
+ *
+ * Internally acts as a proxy for {@linkcode Contract.requireNotUndefined} and {@linkcode Contract.requireNotNull}.
+ *
+ * @example Basic Usage
+ * ```ts
+ * import { Contract } from "https://deno.land/x/chomp/utility/contract.ts";
+ *
+ * const myArgument = "blabla";
+ * Contract.requireNotNullish(myArgument);
+ * ```
+ *
+ * @param argument
+ * @param message
+ */
+ public static requireNotNullish(argument: T, message?: string): asserts argument is Exclude, undefined> {
+ Contract.requireNotUndefined(argument, message);
+ Contract.requireNotNull(argument, message);
+ }
+
+ /**
+ * Require the input argument to not be empty
+ *
+ * @param argument
+ */
+ public static requireNotEmpty(argument: T): void|never {
+ if(empty(argument)) raise(`${nameOf({ argument })} may not be empty`, "ContractArgumentEmpty")
+ }
+
+ /**
+ * Require the input argument to not be empty
+ *
+ *
+ * @param argument
+ */
+ public static requireEmpty(argument: T): void|never {
+ if(!empty(argument)) raise(`${nameOf({ argument })} must be empty`, "ContractArgumentNotEmpty");
+ }
+
+ /**
+ * Require this call to never be reached.
+ * Used for enforcing exhaustiveness.
+ *
+ * @example Basic usage
+ * ```
+ * type Shape =
+ * | { kind: "circle"; radius: number; }
+ * | { kind: "square"; size: number; }
+ *
+ * function getArea(shape: Shape): number {
+ * switch(shape.kind) {
+ * case "circle":
+ * return Math.PI * shape.radius ** 2;
+ * case "square":
+ * return shape.size ** 2;
+ * default:
+ * Contract.requireUnreachable(shape);
+ * }
+ * }
+ * ```
+ *
+ * @param argument
+ */
+ public static requireUnreachable(argument: never): void {
+ raise(`Case not handled: ${argument}`);
+ }
+}
diff --git a/src/utility/cron.ts b/src/utility/cron.ts
new file mode 100644
index 00000000..ea5e3560
--- /dev/null
+++ b/src/utility/cron.ts
@@ -0,0 +1 @@
+export { Cron } from "https://deno.land/x/croner@5.3.4/src/croner.js";
diff --git a/src/utility/empty.ts b/src/utility/empty.ts
new file mode 100644
index 00000000..c6b69b16
--- /dev/null
+++ b/src/utility/empty.ts
@@ -0,0 +1,27 @@
+/**
+ * Check whether the input is set and empty
+ *
+ * // TODO: Finish documentation
+ *
+ * @param input
+ * @returns boolean
+ */
+export function empty(input: unknown): boolean {
+ // Check if undefined
+ if(input === undefined) return true;
+
+ // Check if null
+ if(input === null) return true;
+
+ // Check if empty string
+ if(input === "") return true;
+
+ // Check if empty array
+ if(Array.isArray(input) && input.length === 0) return true;
+
+ // Check if empty object
+ if(typeof input === "object" && Object.keys(input).length === 0) return true;
+
+ // We have something inside
+ return false;
+}
diff --git a/src/utility/env-or-default.ts b/src/utility/env-or-default.ts
new file mode 100644
index 00000000..3bc83bd3
--- /dev/null
+++ b/src/utility/env-or-default.ts
@@ -0,0 +1,20 @@
+/**
+ * Check if env variable has value, otherwise return a specified default
+ *
+ * @param key
+ * @param defaultValue
+ */
+export function envOrDefault(key: string, defaultValue: T|null = null): T {
+ // Check if we have permission
+ // If not, return the default
+ const hasPermission = Deno.permissions.querySync({name: "env" }).state === "granted";
+ if(!hasPermission) return defaultValue as T;
+
+ // Check if the env has a key
+ // If not, return the default
+ const hasKey = Deno.env.has(key);
+ if(!hasKey) return defaultValue as T;
+
+ // Return the value specified by the env
+ return Deno.env.get(key) as T;
+}
diff --git a/src/utility/error-or-data.ts b/src/utility/error-or-data.ts
new file mode 100644
index 00000000..84bae9a1
--- /dev/null
+++ b/src/utility/error-or-data.ts
@@ -0,0 +1,17 @@
+import { SuccessResponse } from "../types/error-or-data.ts";
+
+export async function errorOrData Error>(promise: Promise, catchables?: E[]): Promise | [InstanceType]> {
+ try {
+ const data = await promise;
+ return [undefined, data] as SuccessResponse;
+ } catch (error) {
+ // If no catchables are defined just return all errors
+ if (catchables === undefined) return [error];
+
+ // Check if our error is any of the catchables
+ if (catchables.some((e: E): boolean => error instanceof e)) return [error];
+
+ // Throw the error
+ throw error;
+ }
+}
diff --git a/src/utility/fetch-with-timeout.ts b/src/utility/fetch-with-timeout.ts
new file mode 100644
index 00000000..14989b40
--- /dev/null
+++ b/src/utility/fetch-with-timeout.ts
@@ -0,0 +1,16 @@
+/**
+ * Allow running a fetch with a timeout
+ *
+ * @param input
+ * @param init
+ * @param timeout Milliseconds to wait before abording
+ */
+export function fetchWithTimeout(input: URL|Request|string, init: RequestInit = {}, timeout = 5000): Promise {
+ // Inject automatic abortion after 5 seconds
+ const controller = new AbortController();
+ init.signal = controller.signal;
+ setTimeout(() => controller.abort(), timeout);
+
+ // Create and return fetch
+ return fetch(input, init);
+}
diff --git a/src/utility/format-bytes.ts b/src/utility/format-bytes.ts
new file mode 100644
index 00000000..1c7ca36c
--- /dev/null
+++ b/src/utility/format-bytes.ts
@@ -0,0 +1,27 @@
+const defaultSizes = ['Bytes', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB', 'YiB'];
+
+/**
+ * Format bytes to a string
+ *
+ * @example Basic usage
+ * ```ts
+ * import { formatBytes } from "https://deno.land/x/chomp/utility/format-bytes.ts"
+ * const size = formatBytes(1024);
+ * ```
+ *
+ * @source https://stackoverflow.com/a/18650828/5001849
+ *
+ * @param bytes
+ * @param decimals
+ * @param sizes Array of sizes
+ * @param si Set to false to use IEC prefixes (1024 instead of 1000)
+ */
+export function formatBytes(bytes: number, decimals: number = 2, sizes: string[] = defaultSizes, si: boolean = false): string {
+ if (!+bytes) return '0 Bytes'
+
+ const k: number = si ? 1000 : 1024;
+ const dm: number = decimals < 0 ? 0 : decimals
+ const i: number = Math.floor(Math.log(bytes) / Math.log(k))
+
+ return `${parseFloat((bytes / Math.pow(k, i)).toFixed(dm))} ${sizes[i]}`
+}
diff --git a/src/utility/inflector.ts b/src/utility/inflector.ts
new file mode 100644
index 00000000..18748193
--- /dev/null
+++ b/src/utility/inflector.ts
@@ -0,0 +1,161 @@
+import { Cache } from "../core/cache.ts";
+import { Configure } from "../core/configure.ts";
+
+/**
+ * Quickly inflect text in common ways.
+ * Idea and code primarily based on CakePHP's code.
+ *
+ * You can change the expiry time using the `chomp_inflector_cache_ttl` configuration key.
+ * Otherwise this will default to `+10 minutes`.
+ *
+ * // TODO: Finish documentation
+ */
+export class Inflector {
+ /**
+ * Return input string with first character uppercased.
+ *
+ * @param input
+ */
+ public static ucfirst(input: string): string {
+ return input.charAt(0).toUpperCase() + input.slice(1);
+ }
+
+ /**
+ * Return input string with first character lowercased.
+ *
+ * @param input
+ */
+ public static lcfirst(input: string): string {
+ return input.charAt(0).toLowerCase() + input.slice(1);
+ }
+
+ /**
+ * Turn a string into PascalCase.
+ *
+ * @param input
+ * @param delimiter Optional delimiter by which to split the string
+ */
+ public static pascalize(input: string, delimiter: string = "_"): string {
+ // Try to look up in cache
+ const type = `pascalize${delimiter}`;
+ let result = this._cache(type, input);
+
+ // Inflect on cache miss and add to cache
+ if(!result) {
+ // Humanize then remove spaces
+ result = this
+ .humanize(input, delimiter)
+ .replaceAll(" ", "");
+
+ // Add to Cache
+ this._cache(type, input, result);
+ }
+
+ return result;
+ }
+
+ /**
+ * Turn a string into camelCase
+ *
+ * @param input
+ * @param delimiter Optional delimiter by which to split the string
+ */
+ public static camelize(input: string, delimiter: string = "_"): string {
+ return this.lcfirst(this.pascalize(input, delimiter));
+ }
+
+ /**
+ * Return the input lower_case_delimited_string as "A Human Readable String".
+ * (Underscores are replaced by spaces and capitalized following words.)
+ *
+ * @param input
+ * @param delimiter
+ */
+ public static humanize(input: string, delimiter: string = "_"): string {
+ // Try to look up in cache
+ const type = `humanize${delimiter}`;
+ let result = this._cache(type, input);
+
+ // Inflect on cache miss and add to cache
+ if(!result) {
+ // Split our string into tokens
+ const tokens: string[] = input
+ .split(delimiter);
+
+ // Uppercase each of the tokens
+ for (let i = 0; i < tokens.length; i++) {
+ tokens[i] = this.ucfirst(tokens[i]);
+ }
+
+ // Join tokens
+ result = tokens.join(" ");
+
+ // Add to cache
+ this._cache(type, input, result);
+ }
+
+ // Join tokens into a string and return
+ return result;
+ }
+
+ /**
+ * Returns the input CamelCasedString as a dashed-string and replace underscores with dashes
+ *
+ * @param input
+ */
+ public static dasherize(input: string): string {
+ return this.delimit(input.replaceAll('_', '-'), '-');
+ }
+
+ /**
+ * Expects a CamelCasedInputString, and produces a lower_case_delimited_string
+ *
+ * @param input
+ * @param delimiter
+ */
+ public static delimit(input: string, delimiter: string = '_'): string {
+ // Try to look up in cache
+ const type = `delimit${delimiter}`;
+ let result = this._cache(type, input);
+
+ // Inflect on cache miss and add to cache
+ if(!result) {
+ // Inflect
+ result = input
+ .replaceAll(/(?<=\w)([A-Z])/g, delimiter + '$1')
+ .toLowerCase();
+
+ // Add to cache
+ this._cache(type, input, result);
+ }
+
+ return result;
+ }
+
+ /**
+ * Cache inflected valued and return if already available
+ *
+ * @param type Inflection type
+ * @param key Original value
+ * @param value Inflected value to cache
+ * @returns Inflected value on cache hit or false on cache miss
+ * @private
+ */
+ private static _cache(type: string, key: string, value: string|false = false): string|false {
+ // Build cache key
+ const cacheKey = `chomp inflector ${type} "${key}"`;
+
+ // Add to cache
+ if(value !== false) {
+ Cache.set(cacheKey, value, Configure.get('chomp_inflector_cache_ttl', '+10 minutes'));
+ return value;
+ }
+
+ // Try to get from cache
+ const cached = Cache.get(cacheKey, true) as string|null;
+ if(cached !== null) return cached;
+
+ // No result
+ return false;
+ }
+}
diff --git a/src/utility/name-of.ts b/src/utility/name-of.ts
new file mode 100644
index 00000000..fd6388a2
--- /dev/null
+++ b/src/utility/name-of.ts
@@ -0,0 +1,16 @@
+/**
+ * Get the name of a passes argument
+ *
+ * TODO: Give this a cleaner API
+ *
+ * @example
+ * ```ts
+ * import { nameOf } from "https://deno.land/x/chomp/utility/name-of.ts";
+ *
+ * const myArgument = true;
+ * const name = nameOf({ myArgument });
+ * ```
+ *
+ * @param variable
+ */
+export const nameOf = (variable: Record) => Object.keys(variable)[0];
diff --git a/src/utility/parse-arguments.ts b/src/utility/parse-arguments.ts
new file mode 100644
index 00000000..3999993c
--- /dev/null
+++ b/src/utility/parse-arguments.ts
@@ -0,0 +1,5 @@
+import { parseArgs, ParseOptions } from "jsr:@std/cli@1.0.23/parse-args";
+
+export function parseArguments(options: ParseOptions = {}) {
+ return parseArgs(Deno.args, options);
+}
diff --git a/src/utility/registry.ts b/src/utility/registry.ts
new file mode 100644
index 00000000..5aa31608
--- /dev/null
+++ b/src/utility/registry.ts
@@ -0,0 +1,49 @@
+import {valueOrDefault} from "./value-or-default.ts";
+
+export class Registry {
+ private static readonly _items: Map = new Map();
+
+ /**
+ * Add an item to the registry
+ *
+ * @example Basic usage
+ * ```ts
+ * const module = await import(`file://path/to/my/file.ts`);
+ * Registry.add('my-module', module);
+ * ```
+ *
+ * @param name
+ * @param module
+ */
+ public static add(name: string, module: any): void {
+ Registry._items.set(name, module);
+ }
+
+ /**
+ * Get an item from the registry
+ *
+ * @example Basic usage
+ * ```ts
+ * const module = Registry.add('my-module');
+ * ```
+ *
+ * @param name
+ */
+ public static get(name: string): any | null {
+ return valueOrDefault(Registry._items.get(name), null);
+ }
+
+ /**
+ * Check whether the registry has an item with name
+ *
+ * @example Basic usage
+ * ```ts
+ * const hasModule = Registry.has('my-module');
+ * ```
+ *
+ * @param name
+ */
+ public static has(name: string): boolean {
+ return Registry._items.has(name);
+ }
+}
diff --git a/src/utility/sleep.ts b/src/utility/sleep.ts
new file mode 100644
index 00000000..cc529017
--- /dev/null
+++ b/src/utility/sleep.ts
@@ -0,0 +1,14 @@
+/**
+ * Sleep (non-blocking) for a defined amount of milliseconds.
+ * There generally should not be a reason to use it outside testing purposes.
+ *
+ * @example Basic usage
+ * ```ts
+ * // ... Do something
+ * await sleep(5_000);
+ * // ... Do more
+ * ```
+ */
+export function sleep(milliseconds: number): Promise {
+ return new Promise(resolve => setTimeout(resolve, milliseconds));
+}
diff --git a/src/utility/text.ts b/src/utility/text.ts
new file mode 100644
index 00000000..61f843db
--- /dev/null
+++ b/src/utility/text.ts
@@ -0,0 +1,43 @@
+export class Text {
+ /**
+ * Generate unique identifiers as per RFC-4122.
+ */
+ public static uuid(): string {
+ return crypto.randomUUID();
+ }
+
+ /**
+ * Tokenize a string into an array of strings.
+ *
+ * @param input
+ * @param limit
+ */
+ public static tokenize(input: string, limit = 3): string[] {
+ const tokens = input.split(" ");
+ if (tokens.length > limit) {
+ const ret = tokens.splice(0, limit);
+ ret.push(tokens.join(" "));
+ return ret;
+ }
+
+ return tokens;
+ }
+
+ /**
+ * Replace special characters with their HTML entities.
+ *
+ * @todo Add support for diacritical marks.
+ *
+ * @param str
+ * @returns string
+ */
+ public static htmlentities(str: string): string {
+ return str.replace(/[&<>'"]/g, (tag: string) => ({
+ "&": "&",
+ "<": "<",
+ ">": ">",
+ "'": "'",
+ '"': """,
+ }[tag] ?? tag));
+ }
+}
diff --git a/util/time-string.ts b/src/utility/time-string.ts
similarity index 72%
rename from util/time-string.ts
rename to src/utility/time-string.ts
index 3a965292..8ccd4460 100644
--- a/util/time-string.ts
+++ b/src/utility/time-string.ts
@@ -2,14 +2,6 @@
* Thanks to Mordo95 for this code
* https://github.com/Mordo95/interval-template-strings/blob/35d55c86ee8cbff947b66740b327e1de4d4f96aa/index.js
*/
-
-interface RegExp {
- groups: {
- digit: number;
- format: string;
- }
-}
-
const TimeRegexp = /(?[+-]?\d+(\.\d+)?)\s*(?[a-zA-Z]+)/g;
const second = 1000;
const minute = second * 60;
@@ -28,7 +20,7 @@ const year = month * 12;
*/
function parseNumberFormat(digit: string, unit: string): number {
const n = Number(digit);
- switch(unit) {
+ switch (unit) {
case "ms":
case "millisecond":
case "milliseconds":
@@ -69,15 +61,24 @@ function parseNumberFormat(digit: string, unit: string): number {
/**
* Takes a time string and turns it into milliseconds
*
+ * @example
+ * ```ts
+ * import { TimeString } from "https://deno.land/x/chomp/utility/time-string.ts";
+ *
+ * const milliseconds = TimeString`+1 minute`;
+ * ```
+ *
* @param strIn
* @param parts
* @returns number
*/
-export function T(strIn: TemplateStringsArray, ...parts: any[]) {
- const str = String.raw(strIn, parts).toLowerCase().replace(/\s/g, '');
+// deno-lint-ignore no-explicit-any -- TODO
+export function TimeString(strIn: TemplateStringsArray, ...parts: any[]): number {
+ const str = String.raw(strIn, parts).toLowerCase().replace(/\s/g, "");
const parsed = [...str.matchAll(TimeRegexp)];
- if (parsed.length === 0)
+ if (parsed.length === 0) {
throw new Error(`"${str}" is not a valid interval string`);
+ }
let out = 0;
for (const res of parsed) {
out += Math.round(parseNumberFormat(res.groups!.value, res.groups!.unit));
@@ -88,10 +89,18 @@ export function T(strIn: TemplateStringsArray, ...parts: any[]) {
/**
* Takes a time string and turns it into round seconds
*
+ * @example
+ * ```ts
+ * import { TimeStringSeconds } from "https://deno.land/x/chomp/utility/time-string.ts";
+ *
+ * const seconds = TimeStringSeconds`+1 minute`;
+ * ```
+ *
* @param strIn
* @param parts
* @returns number
*/
-export function _T(strIn: TemplateStringsArray, ...parts: any[]) {
- return Math.round(T(strIn, parts) / 1000);
+// deno-lint-ignore no-explicit-any -- TODO
+export function TimeStringSeconds(strIn: TemplateStringsArray, ...parts: any[]): number {
+ return Math.round(TimeString(strIn, parts) / 1000);
}
diff --git a/src/utility/time.ts b/src/utility/time.ts
new file mode 100644
index 00000000..8864a687
--- /dev/null
+++ b/src/utility/time.ts
@@ -0,0 +1,69 @@
+import { time as timets } from "https://denopkg.com/burhanahmeed/time.ts@v2.0.1/mod.ts";
+import { format as formatter } from "https://cdn.deno.land/std/versions/0.77.0/raw/datetime/mod.ts";
+import { TimeString } from "./time-string.ts";
+import {envOrDefault} from "./env-or-default.ts";
+
+/**
+ * Try to alleviate the pain of working with time.
+ *
+ * // TODO: Finish documentation
+ */
+export class Time {
+ private readonly time;
+ public get getTime() {
+ return this.time;
+ }
+ public get milliseconds() {
+ return this.time.getMilliseconds();
+ }
+ public get seconds() {
+ return this.time.getSeconds();
+ }
+ public get minutes() {
+ return this.time.getMinutes();
+ }
+ public get hours() {
+ return this.time.getHours();
+ }
+ public get weekDay() {
+ return this.time.getDay();
+ }
+ public get monthDay() {
+ return this.time.getDate();
+ }
+ public get month() {
+ return this.time.getMonth();
+ }
+ public get year() {
+ return this.time.getFullYear();
+ }
+
+ public constructor(time: string | undefined = undefined) {
+ const timezone = envOrDefault("TZ", "Europe/Amsterdam");
+ this.time = timets(time).tz(timezone).t;
+ }
+
+ public format(format: string) {
+ return formatter(this.time, format);
+ }
+
+ public midnight() {
+ this.time.setHours(0, 0, 0, 0);
+ return this;
+ }
+
+ public add(input: string) {
+ this.time.setMilliseconds(this.time.getMilliseconds() + TimeString`${input}`);
+ return this;
+ }
+
+ public addDay(days: number = 1) {
+ this.time.setDate(this.time.getDate() + days);
+ return this;
+ }
+
+ public addWeek(weeks: number = 1) {
+ this.time.setDate(this.time.getDate() + (weeks * 7));
+ return this;
+ }
+}
diff --git a/src/utility/value-or-default.ts b/src/utility/value-or-default.ts
new file mode 100644
index 00000000..4263bc99
--- /dev/null
+++ b/src/utility/value-or-default.ts
@@ -0,0 +1,9 @@
+/**
+ * Check if input has value, otherwise return a specified default
+ *
+ * @param input
+ * @param defaultValue
+ */
+export function valueOrDefault(input: T|undefined|null, defaultValue: T|null = null): T {
+ return input ?? defaultValue as T;
+}
diff --git a/src/validation/rules.ts b/src/validation/rules.ts
new file mode 100644
index 00000000..6c06c157
--- /dev/null
+++ b/src/validation/rules.ts
@@ -0,0 +1,14 @@
+import { ValidationCallback } from "../types/validator.ts";
+import { isEmpty } from "./rules/is-empty.ts";
+import { isNull } from "./rules/is-null.ts";
+import { isUndefined } from "./rules/is-undefined.ts";
+import { minLength } from "./rules/min-length.ts";
+import { maxLength } from "./rules/max-length.ts";
+
+export const Validators = new Map([
+ ['isEmpty', isEmpty],
+ ['isNull', isNull],
+ ['isUndefined', isUndefined],
+ ['minLength', minLength],
+ ['maxLength', maxLength],
+]);
diff --git a/src/validation/rules/is-empty.ts b/src/validation/rules/is-empty.ts
new file mode 100644
index 00000000..93d4eb02
--- /dev/null
+++ b/src/validation/rules/is-empty.ts
@@ -0,0 +1,12 @@
+import {ValidationCallbackResponse, ValidationOptions} from "../../types/validator.ts";
+import {empty} from "../../../mod.ts";
+
+export function isEmpty(input: any, options: ValidationOptions): ValidationCallbackResponse {
+ // Check if input is empty
+ // If so, return the message
+ const inputIsEmpty = empty(input);
+ if(inputIsEmpty) return [undefined];
+
+ // We have something inside
+ return [options.message];
+}
diff --git a/src/validation/rules/is-null.ts b/src/validation/rules/is-null.ts
new file mode 100644
index 00000000..e2d73dcc
--- /dev/null
+++ b/src/validation/rules/is-null.ts
@@ -0,0 +1,6 @@
+import {ValidationCallbackResponse, ValidationOptions} from "../../types/validator.ts";
+
+export function isNull(input: any, options: ValidationOptions): ValidationCallbackResponse {
+ if(input === null) return [undefined];
+ return [options.message];
+}
diff --git a/src/validation/rules/is-undefined.ts b/src/validation/rules/is-undefined.ts
new file mode 100644
index 00000000..99911fd6
--- /dev/null
+++ b/src/validation/rules/is-undefined.ts
@@ -0,0 +1,6 @@
+import {ValidationCallbackResponse, ValidationOptions} from "../../types/validator.ts";
+
+export function isUndefined(input: any, options: ValidationOptions): ValidationCallbackResponse {
+ if(input === undefined) return [undefined];
+ return [options.message];
+}
diff --git a/src/validation/rules/max-length.ts b/src/validation/rules/max-length.ts
new file mode 100644
index 00000000..1b2758fc
--- /dev/null
+++ b/src/validation/rules/max-length.ts
@@ -0,0 +1,15 @@
+import {ValidationCallbackResponse, ValidationOptions} from "../../types/validator.ts";
+import {valueOrDefault} from "../../utility/value-or-default.ts";
+
+export function maxLength(input: any, options: ValidationOptions): ValidationCallbackResponse {
+ // Get the max length
+ // If not set, default to 0
+ const max = valueOrDefault(options.parameters?.length, 0);
+
+ // Check if we are above the maximum length
+ const maxLengthExceeded = input.length > max;
+ if(maxLengthExceeded) return [options.message];
+
+ // Did not exceed max length
+ return [undefined];
+}
diff --git a/src/validation/rules/min-length.ts b/src/validation/rules/min-length.ts
new file mode 100644
index 00000000..f8db33bc
--- /dev/null
+++ b/src/validation/rules/min-length.ts
@@ -0,0 +1,15 @@
+import {ValidationCallbackResponse, ValidationOptions} from "../../types/validator.ts";
+import {valueOrDefault} from "../../utility/value-or-default.ts";
+
+export function minLength(input: any, options: ValidationOptions): ValidationCallbackResponse {
+ // Check if a minimum length was specified
+ // If not, default to 0
+ const min = valueOrDefault(options.parameters?.length, 0);
+
+ // Check if we are above the minimum length
+ const minLengthReached = input.length >= min;
+ if(minLengthReached) return [undefined];
+
+ // Minimum length was not reached
+ return [options.message];
+}
diff --git a/src/validation/validator.ts b/src/validation/validator.ts
new file mode 100644
index 00000000..405cf842
--- /dev/null
+++ b/src/validation/validator.ts
@@ -0,0 +1,87 @@
+import { ValidationCallback, ValidationOptions, ValidationStep } from "../types/validator.ts";
+import {raise} from "../error/raise.ts";
+import { Validators } from "./rules.ts";
+
+/**
+ * Run validator functions on inputs.
+ *
+ * **NOTE**: This is currently still an alpha feature.
+ */
+export class Validator {
+ private _stopOnFailure: boolean = false;
+ private _validators: ValidationStep[] = [];
+
+ public create(name: string, validator: ValidationCallback, overwrite: boolean = false) {
+ // Check if a validator already exists
+ // Skip if we want to override
+ if(!overwrite && Validators.has(name)) raise(`Validator named "${name}" already exists!`);
+
+ // Add validator
+ Validators.set(name, validator);
+ }
+
+ /**
+ * Add a validator step
+ *
+ * @param validator
+ * @param options
+ */
+ public add(validator: ValidationCallback|string, options: ValidationOptions = {}) {
+ // Check if validator type is a string
+ // If so, check with built-ins
+ if(typeof validator === 'string') {
+ if(!Validators.has(validator)) raise(`Validator "${validator}" was not found`, 'ValidatorNotFound');
+ validator = Validators.get(validator)!;
+ }
+
+ // Check if we need to enable the "last" options
+ if(this._stopOnFailure) options.last = true;
+
+ // Add step to validators
+ this._validators.push({
+ callback: validator,
+ options: options,
+ });
+
+ return this;
+ }
+
+ /**
+ * Stop validation on the first failing rule instead of checking all possible rules.
+ *
+ * @param existing Whether to enable this for all existing rules
+ */
+ public setStopOnFailure(existing: boolean = false) {
+ // Enable "last" flag for all new rules
+ this._stopOnFailure = true;
+
+ // Enable "last" flag for existing rules if need be
+ if(existing) this._validators.forEach((step: ValidationStep) => step.options.last = true);
+
+ return this;
+ }
+
+ /**
+ * Execute all validator steps
+ *
+ * @param input
+ */
+ public execute(input: any) {
+ // Create array to collect validation errors
+ const errors = [];
+
+ // Execute all validators
+ for(const validator of this._validators) {
+ // Execute validator
+ const [error] = validator['callback'](input, validator.options);
+
+ // Add error to list
+ if(error) errors.push(error);
+
+ // Check if we need to keep running
+ if(errors.length > 0 && validator['options'].last) break;
+ }
+
+ return errors;
+ }
+}
diff --git a/src/webserver/controller/component.ts b/src/webserver/controller/component.ts
new file mode 100644
index 00000000..84a65b0d
--- /dev/null
+++ b/src/webserver/controller/component.ts
@@ -0,0 +1,12 @@
+import { Controller } from "./controller.ts";
+
+export class Component {
+ public constructor(
+ private readonly controller: Controller,
+ ) {
+ }
+
+ protected getController(): typeof this.controller {
+ return this.controller;
+ }
+}
diff --git a/src/webserver/controller/controller.ts b/src/webserver/controller/controller.ts
new file mode 100644
index 00000000..17a1f9ac
--- /dev/null
+++ b/src/webserver/controller/controller.ts
@@ -0,0 +1,152 @@
+import {ViewVariables} from "../../types/webserver.ts";
+import { Logger } from "../../core/logger.ts";
+import { Inflector } from "../../utility/inflector.ts";
+import {Handlebars, Json, OctetStream, Plaintext} from "../renderers/mod.ts";
+import { ResponseBuilder } from "../http/response-builder.ts";
+import { Request } from "../http/request.ts";
+import { raise } from "../../error/raise.ts";
+import { Component } from "./component.ts";
+import { Registry } from "../../utility/registry.ts";
+import { compress as compressBrotli } from "https://deno.land/x/brotli@v0.1.4/mod.ts";
+import {valueOrDefault} from "../../utility/value-or-default.ts";
+
+export class Controller {
+ private static readonly _templateDir = `./src/templates`;
+ private static readonly _componentDir = `file:///${Deno.cwd()}/src/controller/component`;
+ private _response: ResponseBuilder = new ResponseBuilder();
+ private _vars: ViewVariables = new Map();
+
+ /**
+ * Set the 'Content-Type' header
+ *
+ * @deprecated Please use "Controller.getResponse().withType()" instead.
+ * @param value
+ */
+ // @ts-ignore Deprecated function anyways
+ public set type(value: string = "text/html") {
+ Logger.warning(
+ 'Setting type on controller itself is deprecated, please use "Controller.getResponse().withType()" instead.',
+ );
+ this.getResponse().withHeader("Content-Type", value);
+ }
+
+ constructor(
+ protected readonly request: Request,
+ ) {
+ }
+
+ /**
+ * Initialize the controller.
+ * Literally does nothing at this moment except exist to prevent errors.
+ *
+ * @protected
+ */
+ public async initialize(): Promise {}
+
+ /**
+ * Get the request object for this controller
+ *
+ * @protected
+ */
+ protected getRequest(): Request {
+ return this.request;
+ }
+
+ /**
+ * Get the response object for this controller
+ *
+ * @protected
+ */
+ protected getResponse(): ResponseBuilder {
+ return this._response;
+ }
+
+ protected async loadComponent(name: string): Promise {
+ // Check if we already loaded the component before
+ // Use that if so
+ if (Registry.has(`${Inflector.ucfirst(name)}Component`)) {
+ const module = Registry.get(`${Inflector.ucfirst(name)}Component`);
+ // TODO: Fix index signature
+ // @ts-ignore --
+ this[Inflector.ucfirst(name)] = new module[`${Inflector.ucfirst(name)}Component`](this);
+ return this;
+ }
+
+ // Import the module
+ const module = await import(`${Controller._componentDir}/${Inflector.lcfirst(name)}.ts`);
+
+ // Make sure the component class was found
+ if (!(`${Inflector.ucfirst(name)}Component` in module)) {
+ raise(`No class "${Inflector.ucfirst(name)}Component" could be found.`);
+ }
+
+ // Make sure the component class extends our base controller
+ if (!(module[`${Inflector.ucfirst(name)}Component`].prototype instanceof Component)) {
+ raise(`Class "${Inflector.ucfirst(name)}Component" does not properly extend Chomp's component.`);
+ }
+
+ // Add the component to the registry
+ Registry.add(`${Inflector.ucfirst(name)}Component`, module);
+
+ // Add the module as class property
+ // TODO: Fix index signature
+ // @ts-ignore --
+ this[Inflector.ucfirst(name)] = new module[`${Inflector.ucfirst(name)}Component`](this);
+
+ return this;
+ }
+
+ /**
+ * Set a view variable
+ *
+ * @param key
+ * @param value
+ */
+ protected set(key: string, value: string | number | unknown) {
+ this._vars.set(key, value);
+ }
+
+ /**
+ * Render the page output
+ * Will try to decide the best way of doing it based on the MIME set
+ *
+ * @returns Promise
+ */
+ public async render(): Promise {
+ let body: string | Uint8Array = "";
+ const canCompress = true;
+ switch (this.getResponse().getHeaderLine("Content-Type").toLowerCase()) {
+ case "application/json": {
+ body = Json.render(this._vars);
+ break;
+ }
+ case "text/plain": {
+ body = Plaintext.render(this._vars);
+ break;
+ }
+ case "text/html": {
+ const controller = Inflector.lcfirst(this.getRequest().getRoute().getController());
+ const action = this.getRequest().getRoute().getAction();
+ const rendered = await Handlebars.render(`${Controller._templateDir}/${controller}/${action}.hbs`, this._vars);
+ body = valueOrDefault(rendered, '');
+ break;
+ }
+ case "application/octet-stream": {
+ body = OctetStream.render(this._vars);
+ break;
+ }
+ }
+
+ // Check if we can compress with Brotli
+ // TODO: Hope that Deno will make this obsolete.
+ if (this.getRequest().getHeaders().get("accept-encoding")?.includes("br") && canCompress && body.length > 1024 && typeof body === 'string') {
+ Logger.debug(`Compressing body with brotli: ${body.length}-bytes`);
+ body = compressBrotli(new TextEncoder().encode(body));
+ Logger.debug(`Compressed body with brotli: ${body.length}-bytes`);
+ this.getResponse().withHeader("Content-Encoding", "br");
+ }
+
+ // Set our final body
+ this.getResponse().withBody(body);
+ }
+}
diff --git a/src/webserver/controller/mod.ts b/src/webserver/controller/mod.ts
new file mode 100644
index 00000000..35860cb6
--- /dev/null
+++ b/src/webserver/controller/mod.ts
@@ -0,0 +1,2 @@
+export * from "./component.ts";
+export * from "./controller.ts";
diff --git a/src/webserver/http/mod.ts b/src/webserver/http/mod.ts
new file mode 100644
index 00000000..4a93eacf
--- /dev/null
+++ b/src/webserver/http/mod.ts
@@ -0,0 +1,2 @@
+export * from "./request.ts";
+export * from "./status-codes.ts";
diff --git a/src/webserver/http/request.ts b/src/webserver/http/request.ts
new file mode 100644
index 00000000..6a0a2f37
--- /dev/null
+++ b/src/webserver/http/request.ts
@@ -0,0 +1,62 @@
+import { RequestParameters, QueryParameters } from "../../types/webserver.ts";
+import { Route } from "../routing/route.ts";
+import {valueOrDefault} from "../../utility/value-or-default.ts";
+
+export class Request {
+ constructor(
+ private readonly url: string,
+ private readonly method: string,
+ private readonly route: Route,
+ private readonly headers: Headers,
+ private readonly body: string,
+ private readonly params: RequestParameters,
+ private readonly query: QueryParameters,
+ private readonly auth: string,
+ private readonly ip: string | null = null,
+ ) {
+ }
+
+ public getUrl(): string {
+ return this.url;
+ }
+
+ public getMethod(): string {
+ return this.method;
+ }
+
+ public getRoute(): Route {
+ return this.route;
+ }
+
+ public getHeaders(): Headers {
+ return this.headers;
+ }
+
+ public getBody(): string {
+ return this.body;
+ }
+
+ public getParams(): RequestParameters {
+ return this.params;
+ }
+
+ public getParam(name: string): string | null {
+ return valueOrDefault(this.params[name], null);
+ }
+
+ public getQueryParams(): QueryParameters {
+ return this.query;
+ }
+
+ public getQuery(name: string): string | null {
+ return valueOrDefault(this.query[name], null);
+ }
+
+ public getAuth(): string {
+ return this.auth;
+ }
+
+ public getIp(): string | null {
+ return this.ip;
+ }
+}
diff --git a/src/webserver/http/response-builder.ts b/src/webserver/http/response-builder.ts
new file mode 100644
index 00000000..b3d0b1e1
--- /dev/null
+++ b/src/webserver/http/response-builder.ts
@@ -0,0 +1,163 @@
+import { ResponseHeader } from "../../types/webserver.ts";
+import { StatusCodes } from "./status-codes.ts";
+import { TimeString } from "../../utility/time-string.ts";
+import {valueOrDefault} from "../../utility/value-or-default.ts";
+
+export class ResponseBuilder {
+ private readonly _headers: Map> = new Map>();
+ private _status: StatusCodes = StatusCodes.OK;
+ private _body: string | Uint8Array = "";
+
+ public constructor() {
+ // Set default headers
+ this.withHeader("Content-Type", "text/html");
+ }
+
+ /**
+ * Get all headers we've set.
+ */
+ public getHeaders(): typeof this._headers {
+ return this._headers;
+ }
+
+ /**
+ * Get a header as an array.
+ * Use ResponseBuilder.getHeaderLine() if wanted as a string instead.
+ *
+ * @param name
+ */
+ public getHeader(name: string): string[] {
+ return valueOrDefault(this._headers.get(name), []);
+ }
+
+ /**
+ * Get a header as a string.
+ *
+ * @param name
+ */
+ public getHeaderLine(name: string): string {
+ const header = this.getHeader(name);
+ return header.join(", ");
+ }
+
+ /**
+ * Check if a header is set.
+ *
+ * @param name
+ */
+ public hasHeader(name: string): boolean {
+ return this._headers.has(name);
+ }
+
+ /**
+ * Set a header, overriding the old value.
+ * Use ResponseBuilder.withAddedHeader() if you want to set multiple values.
+ *
+ * @param name
+ * @param value
+ */
+ public withHeader(name: string, value: string): ResponseBuilder {
+ this._headers.set(name, [value]);
+ return this;
+ }
+
+ /**
+ * Add a value to our headers.
+ * Use ResponseBuilder.withHeader() if you want to override it instead.
+ *
+ * @param name
+ * @param value
+ */
+ public withAddedHeader(name: string, value: string): ResponseBuilder {
+ // Check if we have existing headers
+ // If not, start with an empty array
+ const existing = this._headers.get(name) ?? [];
+
+ // Add our value
+ existing.push(value);
+
+ // Save our header
+ this._headers.set(name, existing);
+
+ // Return this route builder
+ return this;
+ }
+
+ /**
+ * Set the response MIME
+ *
+ * @param mime
+ */
+ public withType(mime = "text/html"): ResponseBuilder {
+ this.withHeader("Content-Type", mime);
+ return this;
+ }
+
+ /**
+ * Set our response status.
+ *
+ * @param status
+ */
+ public withStatus(status: StatusCodes = StatusCodes.OK): ResponseBuilder {
+ this._status = status;
+ return this;
+ }
+
+ /**
+ * Set our response body.
+ *
+ * @param body
+ */
+ public withBody(body: string|Uint8Array): ResponseBuilder {
+ this._body = body;
+ return this;
+ }
+
+ /**
+ * Add headers to enable client caching
+ *
+ * @param duration
+ */
+ public withCache(duration = "+1 day"): ResponseBuilder {
+ const now = new Date();
+ this
+ .withHeader("Date", now.toUTCString())
+ .withHeader("Last-Modified", now.toUTCString())
+ .withHeader("Expires", new Date(now.getTime() + TimeString`${duration}`).toUTCString())
+ .withHeader("max-age", (Math.round(TimeString`${duration}` / 1000)).toString());
+
+ return this;
+ }
+
+ /**
+ * Add headers to instruct the client not to cache the response.
+ */
+ public withDisabledCache(): ResponseBuilder {
+ this
+ .withHeader("Expires", "Mon, 26 Jul 1997 05:00:00 GMT")
+ .withHeader("Last-Modified", new Date().toUTCString())
+ .withHeader("Cache-Control", "no-store, no-cache, must-revalidate, post-check=0, pre-check=0");
+
+ return this;
+ }
+
+ /**
+ * Build our final response that can be sent back to the client.
+ */
+ public build(): Response {
+ // Build our headers
+ const headers: ResponseHeader = {};
+ for (const name of this.getHeaders().keys()) {
+ headers[name] = this.getHeaderLine(name);
+ }
+
+ // Return our final response
+ return new Response(
+ this._body,
+ {
+ status: this._status,
+ headers: headers,
+ },
+ );
+ }
+}
diff --git a/src/webserver/http/status-codes.ts b/src/webserver/http/status-codes.ts
new file mode 100644
index 00000000..f66ea45a
--- /dev/null
+++ b/src/webserver/http/status-codes.ts
@@ -0,0 +1,69 @@
+export enum StatusCodes {
+ CONTINUE = 100,
+ SWITCHING_PROTOCOLS = 101,
+ PROCESSING = 102,
+ EARLY_HINTS = 103,
+
+ OK = 200,
+ CREATED = 201,
+ ACCEPTED = 202,
+ NON_AUTHORATIVE_INFORMATION = 203,
+ NO_CONTENT = 204,
+ RESET_CONTENT = 205,
+ PARTIAL_CONTENT = 206,
+ MULTI_STATUS = 207,
+ ALREADY_REPORTED = 208,
+ IM_USED = 226,
+
+ MULTIPLE_CHOICES = 300,
+ MOVED_PERMANENTLY = 301,
+ FOUND = 302,
+ SEE_OTHER = 303,
+ NOT_MODIFIED = 304,
+ USE_PROXY = 305,
+ UNUSED = 306,
+ TEMPORARY_REDIRECT = 307,
+ PERMANENT_REDIRECT = 308,
+
+ BAD_REQUEST = 400,
+ UNAUTHORIZED = 401,
+ PAYMENT_REQUIRED = 402,
+ FORBIDDEN = 403,
+ NOT_FOUND = 404,
+ METHOD_NOT_ALLOWED = 405,
+ NOT_ACCEPTABLE = 406,
+ PROXY_AUTHENTICATION_REQUIRED = 407,
+ REQUEST_TIMEOUT = 408,
+ CONFLICT = 409,
+ GONE = 410,
+ LENGTH_REQUIRED = 411,
+ PRECONDITION_FAILED = 412,
+ PAYLOAD_TOO_LARGE = 413,
+ URI_TOO_LONG = 414,
+ UNSUPPORTED_MEDIA_TYPE = 415,
+ RANGE_NOT_SATISFIABLE = 416,
+ EXPECTATION_FAILED = 417,
+ IM_A_TEAPOT = 418,
+ MISDIRECTED_REQUEST = 421,
+ UNPROCESSABLE_CONTENT = 422,
+ LOCKED = 423,
+ FAILED_DEPENDENCY = 424,
+ TOO_EARLY = 425,
+ UPGRADE_REQUIRED = 426,
+ PRECONDITION_REQUIRED = 428,
+ TOO_MANY_REQUESTS = 429,
+ REQUEST_HEADER_FIELDS_TOO_LARGE = 431,
+ UNAVAILABLE_FOR_LEGAL_REASONS = 451,
+
+ INTERNAL_SERVER_ERROR = 500,
+ NOT_IMPLEMENTED = 501,
+ BAD_GATEWAY = 502,
+ SERVICE_UNAVAILABLE = 503,
+ GATEWAY_TIMEOUT = 504,
+ HTTP_VERSION_NOT_SUPPORTED = 505,
+ VARIANT_ALSO_NEGOTIATED = 506,
+ INSUFFICIENT_STORAGE = 507,
+ LOOP_DETECTED = 508,
+ NOT_EXTENDED = 510,
+ NETWORK_AUTHENTICATION_REQUIRED = 511,
+}
diff --git a/src/webserver/mod.ts b/src/webserver/mod.ts
new file mode 100644
index 00000000..265235a2
--- /dev/null
+++ b/src/webserver/mod.ts
@@ -0,0 +1,4 @@
+export * from "./controller/mod.ts";
+export * from "./http/mod.ts";
+export * from "./routing/mod.ts";
+export * from "./webserver.ts";
diff --git a/webserver/pathToRegexp.ts b/src/webserver/pathToRegexp.ts
similarity index 75%
rename from webserver/pathToRegexp.ts
rename to src/webserver/pathToRegexp.ts
index 57e045c2..74df45c6 100644
--- a/webserver/pathToRegexp.ts
+++ b/src/webserver/pathToRegexp.ts
@@ -1,19 +1,15 @@
-/**
- * Tokenizer results.
- */
-interface LexToken {
- type:
- | "OPEN"
- | "CLOSE"
- | "PATTERN"
- | "NAME"
- | "CHAR"
- | "ESCAPED_CHAR"
- | "MODIFIER"
- | "END";
- index: number;
- value: string;
-}
+import {
+ LexToken,
+ RegexpToFunctionOptions,
+ MatchFunction,
+ Key,
+ Token,
+ TokensToRegexpOptions,
+ Path,
+ PathFunction,
+ TokensToFunctionOptions,
+ ParseOptions
+} from "../types/webserver.ts";
/**
* Tokenize input string.
@@ -123,17 +119,6 @@ function lexer(str: string): LexToken[] {
return tokens;
}
-export interface ParseOptions {
- /**
- * Set the default delimiter for repeat parameters. (default: `'/'`)
- */
- delimiter?: string;
- /**
- * List of characters to automatically consider prefixes when parsing.
- */
- prefixes?: string;
-}
-
/**
* Parse a string for the raw tokens.
*/
@@ -190,7 +175,7 @@ export function parse(str: string, options: ParseOptions = {}): Token[] {
prefix,
suffix: "",
pattern: pattern || defaultPattern,
- modifier: tryConsume("MODIFIER") || ""
+ modifier: tryConsume("MODIFIER") || "",
});
continue;
}
@@ -220,7 +205,7 @@ export function parse(str: string, options: ParseOptions = {}): Token[] {
pattern: name && !pattern ? defaultPattern : pattern,
prefix,
suffix,
- modifier: tryConsume("MODIFIER") || ""
+ modifier: tryConsume("MODIFIER") || "",
});
continue;
}
@@ -231,50 +216,34 @@ export function parse(str: string, options: ParseOptions = {}): Token[] {
return result;
}
-export interface TokensToFunctionOptions {
- /**
- * When `true` the regexp will be case sensitive. (default: `false`)
- */
- sensitive?: boolean;
- /**
- * Function for encoding input strings for output.
- */
- encode?: (value: string, token: Key) => string;
- /**
- * When `false` the function can produce an invalid (unmatched) path. (default: `true`)
- */
- validate?: boolean;
-}
-
/**
* Compile a string to a template function for the path.
*/
export function compile(
str: string,
- options?: ParseOptions & TokensToFunctionOptions
+ options?: ParseOptions & TokensToFunctionOptions,
) {
return tokensToFunction
(parse(str, options), options);
}
-export type PathFunction
= (data?: P) => string;
-
/**
* Expose a method for transforming tokens into the path function.
*/
export function tokensToFunction
(
tokens: Token[],
- options: TokensToFunctionOptions = {}
+ options: TokensToFunctionOptions = {},
): PathFunction
{
const reFlags = flags(options);
const { encode = (x: string) => x, validate = true } = options;
// Compile all the tokens into regexps.
- const matches = tokens.map(token => {
+ const matches = tokens.map((token) => {
if (typeof token === "object") {
return new RegExp(`^(?:${token.pattern})$`, reFlags);
}
});
+ // deno-lint-ignore no-explicit-any -- TODO
return (data: Record | null | undefined) => {
let path = "";
@@ -293,7 +262,7 @@ export function tokensToFunction(
if (Array.isArray(value)) {
if (!repeat) {
throw new TypeError(
- `Expected "${token.name}" to not repeat, but got an array`
+ `Expected "${token.name}" to not repeat, but got an array`,
);
}
@@ -308,7 +277,7 @@ export function tokensToFunction
(
if (validate && !(matches[i] as RegExp).test(segment)) {
throw new TypeError(
- `Expected all "${token.name}" to match "${token.pattern}", but got "${segment}"`
+ `Expected all "${token.name}" to match "${token.pattern}", but got "${segment}"`,
);
}
@@ -323,7 +292,7 @@ export function tokensToFunction
(
if (validate && !(matches[i] as RegExp).test(segment)) {
throw new TypeError(
- `Expected "${token.name}" to match "${token.pattern}", but got "${segment}"`
+ `Expected "${token.name}" to match "${token.pattern}", but got "${segment}"`,
);
}
@@ -341,40 +310,12 @@ export function tokensToFunction
(
};
}
-export interface RegexpToFunctionOptions {
- /**
- * Function for decoding strings for params.
- */
- decode?: (value: string, token: Key) => string;
-}
-
-/**
- * A match result contains data about the path match.
- */
-export interface MatchResult
{
- path: string;
- index: number;
- params: P;
-}
-
-/**
- * A match is either `false` (no match) or a match result.
- */
-export type Match
= false | MatchResult
;
-
-/**
- * The match function takes a string and returns whether it matched the path.
- */
-export type MatchFunction
= (
- path: string
-) => Match
;
-
/**
* Create path match function from `path-to-regexp` spec.
*/
export function match
(
str: Path,
- options?: ParseOptions & TokensToRegexpOptions & RegexpToFunctionOptions
+ options?: ParseOptions & TokensToRegexpOptions & RegexpToFunctionOptions,
) {
const keys: Key[] = [];
const re = pathToRegexp(str, keys, options);
@@ -387,11 +328,11 @@ export function match
(
export function regexpToFunction
(
re: RegExp,
keys: Key[],
- options: RegexpToFunctionOptions = {}
+ options: RegexpToFunctionOptions = {},
): MatchFunction
{
const { decode = (x: string) => x } = options;
- return function(pathname: string) {
+ return function (pathname: string) {
const m = re.exec(pathname);
if (!m) return false;
@@ -405,7 +346,7 @@ export function regexpToFunction
(
const key = keys[i - 1];
if (key.modifier === "*" || key.modifier === "+") {
- params[key.name] = m[i].split(key.prefix + key.suffix).map(value => {
+ params[key.name] = m[i].split(key.prefix + key.suffix).map((value) => {
return decode(value, key);
});
} else {
@@ -431,22 +372,6 @@ function flags(options?: { sensitive?: boolean }) {
return options && options.sensitive ? "" : "i";
}
-/**
- * Metadata about a key.
- */
-export interface Key {
- name: string | number;
- prefix: string;
- suffix: string;
- pattern: string;
- modifier: string;
-}
-
-/**
- * A token is a string (nothing special) or key metadata (capture group).
- */
-export type Token = string | Key;
-
/**
* Pull out keys from a regexp.
*/
@@ -464,7 +389,7 @@ function regexpToRegexp(path: RegExp, keys?: Key[]): RegExp {
prefix: "",
suffix: "",
modifier: "",
- pattern: ""
+ pattern: "",
});
execResult = groupsRegex.exec(path.source);
}
@@ -478,9 +403,9 @@ function regexpToRegexp(path: RegExp, keys?: Key[]): RegExp {
function arrayToRegexp(
paths: Array,
keys?: Key[],
- options?: TokensToRegexpOptions & ParseOptions
+ options?: TokensToRegexpOptions & ParseOptions,
): RegExp {
- const parts = paths.map(path => pathToRegexp(path, keys, options).source);
+ const parts = paths.map((path) => pathToRegexp(path, keys, options).source);
return new RegExp(`(?:${parts.join("|")})`, flags(options));
}
@@ -490,55 +415,24 @@ function arrayToRegexp(
function stringToRegexp(
path: string,
keys?: Key[],
- options?: TokensToRegexpOptions & ParseOptions
+ options?: TokensToRegexpOptions & ParseOptions,
) {
return tokensToRegexp(parse(path, options), keys, options);
}
-export interface TokensToRegexpOptions {
- /**
- * When `true` the regexp will be case sensitive. (default: `false`)
- */
- sensitive?: boolean;
- /**
- * When `true` the regexp won't allow an optional trailing delimiter to match. (default: `false`)
- */
- strict?: boolean;
- /**
- * When `true` the regexp will match to the end of the string. (default: `true`)
- */
- end?: boolean;
- /**
- * When `true` the regexp will match from the beginning of the string. (default: `true`)
- */
- start?: boolean;
- /**
- * Sets the final character for non-ending optimistic matches. (default: `/`)
- */
- delimiter?: string;
- /**
- * List of characters that can also be "end" characters.
- */
- endsWith?: string;
- /**
- * Encode path tokens for use in the `RegExp`.
- */
- encode?: (value: string) => string;
-}
-
/**
* Expose a function for taking tokens and returning a RegExp.
*/
export function tokensToRegexp(
tokens: Token[],
keys?: Key[],
- options: TokensToRegexpOptions = {}
+ options: TokensToRegexpOptions = {},
) {
const {
strict = false,
start = true,
end = true,
- encode = (x: string) => x
+ encode = (x: string) => x,
} = options;
const endsWith = `[${escapeString(options.endsWith || "")}]|$`;
const delimiter = `[${escapeString(options.delimiter || "/#?")}]`;
@@ -577,11 +471,10 @@ export function tokensToRegexp(
route += !options.endsWith ? "$" : `(?=${endsWith})`;
} else {
const endToken = tokens[tokens.length - 1];
- const isEndDelimited =
- typeof endToken === "string"
- ? delimiter.indexOf(endToken[endToken.length - 1]) > -1
- : // tslint:disable-next-line
- endToken === undefined;
+ const isEndDelimited = typeof endToken === "string"
+ ? delimiter.indexOf(endToken[endToken.length - 1]) > -1
+ // tslint:disable-next-line
+ : endToken === undefined;
if (!strict) {
route += `(?:${delimiter}(?=${endsWith}))?`;
@@ -595,11 +488,6 @@ export function tokensToRegexp(
return new RegExp(route, flags(options));
}
-/**
- * Supported `path-to-regexp` input types.
- */
-export type Path = string | RegExp | Array;
-
/**
* Normalize the given path string, returning a regular expression.
*
@@ -610,7 +498,7 @@ export type Path = string | RegExp | Array;
export function pathToRegexp(
path: Path,
keys?: Key[],
- options?: TokensToRegexpOptions & ParseOptions
+ options?: TokensToRegexpOptions & ParseOptions,
) {
if (path instanceof RegExp) return regexpToRegexp(path, keys);
if (Array.isArray(path)) return arrayToRegexp(path, keys, options);
diff --git a/src/webserver/registry/registry.ts b/src/webserver/registry/registry.ts
new file mode 100644
index 00000000..736377f8
--- /dev/null
+++ b/src/webserver/registry/registry.ts
@@ -0,0 +1,35 @@
+import { Registry as newRegistry } from "../../utility/registry.ts";
+
+/**
+ * @deprecated Use {@linkcode ../../utility/Registry} instead.
+ * This class only serves as a legacy proxy to it.
+ */
+export class Registry {
+ /**
+ * Add an item to the registry
+ *
+ * @param name
+ * @param module
+ */
+ public static add(name: string, module: any): void {
+ newRegistry.add(name, module);
+ }
+
+ /**
+ * Get an item from the registry
+ *
+ * @param name
+ */
+ public static get(name: string): any | null {
+ return newRegistry.get(name);
+ }
+
+ /**
+ * Check whether the registry has an item with name
+ *
+ * @param name
+ */
+ public static has(name: string): boolean {
+ return newRegistry.has(name);
+ }
+}
diff --git a/src/webserver/renderers/handlebars.ts b/src/webserver/renderers/handlebars.ts
new file mode 100644
index 00000000..47c1eb19
--- /dev/null
+++ b/src/webserver/renderers/handlebars.ts
@@ -0,0 +1,62 @@
+import { ViewVariables } from "../../types/webserver.ts";
+import { default as hbs } from "https://jspm.dev/handlebars@4.7.6";
+import { Cache } from "../../core/cache.ts";
+
+export class Handlebars {
+
+ /**
+ * Render the Handlebars template
+ *
+ * @param path
+ * @param vars
+ * @param expiry Time to cache the rendered template. Use null to cache indefinitely.
+ */
+ public static async render(
+ path: string,
+ vars: ViewVariables = new Map(),
+ expiry: string|null = "+1 hour",
+ ): Promise {
+ // Load and compile template
+ const template = await Handlebars._compileTemplate(path, expiry);
+
+ // Render template with our view vars
+ return template(vars);
+ }
+
+ public static async _compileTemplate(path: string, expiry: string|null = "+1 hour") {
+ // Build Cache key
+ const key = `Webserver.Rendered.Handlebars "${path}"`;
+
+ // Check if we have a cached version
+ // Return it if we do
+ const inCache = Cache.exists(key);
+ const isValid = !Cache.expired(key);
+ if(inCache && isValid) return Cache.get(key);
+
+ // Load our template
+ const template = await Handlebars._getTemplate(path);
+
+ // Compile our template
+ // TODO: Fix type
+ // @ts-ignore See TODO
+ const compiled = hbs.compile(template);
+
+ // Cache template
+ Cache.set(key, compiled, expiry)
+
+ // Return compiled template
+ return compiled;
+ }
+
+ private static async _getTemplate(path: string): Promise {
+ // Make sure out template exists
+ try {
+ await Deno.stat(path);
+ } catch (e) {
+ throw new Error(`Could not render handlebars template: Could not read template at "${path}"`, e.stack);
+ }
+
+ // Read and our template
+ return await Deno.readTextFile(path);
+ }
+}
diff --git a/src/webserver/renderers/json.ts b/src/webserver/renderers/json.ts
new file mode 100644
index 00000000..95a4c8e5
--- /dev/null
+++ b/src/webserver/renderers/json.ts
@@ -0,0 +1,15 @@
+import {ViewVariables} from "../../types/webserver.ts";
+
+export class Json {
+ public static render(
+ vars: ViewVariables = new Map(),
+ ) {
+ // Check if vars contains a data object
+ // If not, return empty object
+ const hasData = vars.has('data');
+ if(!hasData) return JSON.stringify({});
+
+ // Return stringified data
+ return JSON.stringify(vars.get('data'));
+ }
+}
diff --git a/src/webserver/renderers/mod.ts b/src/webserver/renderers/mod.ts
new file mode 100644
index 00000000..c7fd1955
--- /dev/null
+++ b/src/webserver/renderers/mod.ts
@@ -0,0 +1,4 @@
+export * from "./handlebars.ts";
+export * from "./json.ts";
+export * from "./octet-stream.ts";
+export * from "./plaintext.ts";
diff --git a/src/webserver/renderers/octet-stream.ts b/src/webserver/renderers/octet-stream.ts
new file mode 100644
index 00000000..001ea449
--- /dev/null
+++ b/src/webserver/renderers/octet-stream.ts
@@ -0,0 +1,15 @@
+import {ViewVariables} from "../../types/webserver.ts";
+
+export class OctetStream {
+ public static render(
+ vars: ViewVariables = new Map(),
+ ): Uint8Array {
+ // Check if vars contains a data object
+ // If not, return empty array
+ const hasData = vars.has('data');
+ if(!hasData) return new Uint8Array();
+
+ // Return stringified data
+ return vars.get('data');
+ }
+}
diff --git a/src/webserver/renderers/plaintext.ts b/src/webserver/renderers/plaintext.ts
new file mode 100644
index 00000000..a1f9ca38
--- /dev/null
+++ b/src/webserver/renderers/plaintext.ts
@@ -0,0 +1,15 @@
+import {ViewVariables} from "../../types/webserver.ts";
+
+export class Plaintext {
+ public static render(
+ vars: ViewVariables = new Map(),
+ ): string {
+ // Check if vars contains a data object
+ // If not, return empty string
+ const hasData = vars.has('message');
+ if(!hasData) return '';
+
+ // Return stringified data
+ return vars.get('message');
+ }
+}
diff --git a/src/webserver/routing/mod.ts b/src/webserver/routing/mod.ts
new file mode 100644
index 00000000..d18015da
--- /dev/null
+++ b/src/webserver/routing/mod.ts
@@ -0,0 +1 @@
+export * from "./router.ts";
diff --git a/src/webserver/routing/route.ts b/src/webserver/routing/route.ts
new file mode 100644
index 00000000..ed83697d
--- /dev/null
+++ b/src/webserver/routing/route.ts
@@ -0,0 +1,25 @@
+export class Route {
+ public constructor(
+ private readonly path: URLPattern,
+ private readonly controller: string,
+ private readonly action: string,
+ private readonly method: string,
+ ) {
+ }
+
+ public getPath(): URLPattern {
+ return this.path;
+ }
+
+ public getController(): typeof this.controller {
+ return this.controller;
+ }
+
+ public getAction(): typeof this.action {
+ return this.action;
+ }
+
+ public getMethod(): typeof this.method {
+ return this.method;
+ }
+}
diff --git a/src/webserver/routing/router.ts b/src/webserver/routing/router.ts
new file mode 100644
index 00000000..1581c03f
--- /dev/null
+++ b/src/webserver/routing/router.ts
@@ -0,0 +1,222 @@
+import { Route, QueryParameters } from "../../types/webserver.ts";
+import { readerFromStreamReader } from "https://deno.land/std@0.126.0/io/mod.ts";
+import { readAll } from "https://deno.land/std@0.213.0/io/read_all.ts";
+import { Inflector } from "../../utility/inflector.ts";
+import { Logger } from "../../core/logger.ts";
+import { Request as ChompRequest } from "../http/request.ts";
+import { StatusCodes } from "../http/status-codes.ts";
+import { Route as ChompRoute } from "./route.ts";
+import { Controller } from "../controller/controller.ts";
+import { Registry } from "../../utility/registry.ts";
+import { raise } from "../../error/raise.ts";
+import {valueOrDefault} from "../../utility/value-or-default.ts";
+
+export class Router {
+ private static readonly _controllerDir = `file://${Deno.cwd()}/src/controller`;
+ private static routes: ChompRoute[] = [];
+ public static getRoutes() {
+ return Router.routes;
+ }
+
+ /**
+ * Match the controller and action to a route
+ *
+ * @param request
+ */
+ public static route(request: Request) {
+ // Get the request path minus the domain
+ const host = request.headers.get("host");
+ let path = request.url
+ .replace("http://", "")
+ .replace("https://", "");
+ if (host !== null) path = path.replace(host, "");
+
+ // Ignore query parameters
+ path = path.split("?", 1)[0];
+
+ // Loop over each route
+ // Check if it is the right method
+ // Check if it's the right path
+ // Return the route if route found
+ for (const route of Router.routes) {
+ if (route.getMethod() !== request.method) continue;
+
+ // Make sure we have a matching route
+ const isMatch = route.getPath().test(request.url);
+ if(!isMatch) continue;
+
+ return {
+ route: route,
+ path: path,
+ data: route.getPath().exec(request.url),
+ };
+ }
+
+ // No suitable route was found
+ return null;
+ }
+
+ /**
+ * Execute the requested controller action
+ *
+ * @param request
+ * @param clientIp
+ * @returns Promise
+ */
+ public static async execute(request: Request, clientIp: string): Promise {
+ // Make sure a route was found
+ // Otherwise return a 404 response
+ const route = Router.route(request);
+ const hasRoute = route !== null;
+ if (!hasRoute) {
+ return new Response(
+ "The requested page could not be found.",
+ {
+ status: StatusCodes.NOT_FOUND,
+ headers: {
+ "Content-Type": "text/plain",
+ },
+ },
+ );
+ }
+
+ // Build our Request object
+ const req = new ChompRequest(
+ request.url,
+ request.method,
+ route.route,
+ request.headers,
+ await Router.getBody(request),
+ route.data!.pathname.groups,
+ Router.getQuery(request.url),
+ Router.getAuth(request),
+ clientIp,
+ );
+
+ // Import and cache controller file if need be
+ if (!Registry.has(req.getRoute().getController())) {
+ try {
+ // Import the module
+ const module = await import(
+ `${Router._controllerDir}/${Inflector.lcfirst(req.getRoute().getController())}.controller.ts`
+ );
+
+ // Make sure the controller class was found
+ if (!(`${req.getRoute().getController()}Controller` in module)) {
+ raise(`No class "${req.getRoute().getController()}Controller" could be found.`);
+ }
+
+ // Make sure the controller class extends our base controller
+ if (!(module[`${req.getRoute().getController()}Controller`].prototype instanceof Controller)) {
+ raise(`Class "${req.getRoute().getController()}Controller" does not properly extend Chomp's controller.`);
+ }
+
+ // Add the module to our registry
+ Registry.add(`${req.getRoute().getController()}Controller`, module);
+ } catch (e) {
+ Logger.error(`Could not import "${req.getRoute().getController()}": ${e.message}`, e.stack);
+ return new Response(
+ "Internal Server Error",
+ {
+ status: 500,
+ headers: {
+ "content-type": "text/plain",
+ },
+ },
+ );
+ }
+ }
+
+ // Run our controller
+ try {
+ // Instantiate the controller
+ const module = Registry.get(`${req.getRoute().getController()}Controller`) ??
+ raise(`"${req.getRoute().getController()}Controller" was not found in registry.`);
+ const controller = new module[`${req.getRoute().getController()}Controller`](req);
+
+ // Run the controller's initializer
+ await controller.initialize();
+
+ // Execute our action
+ await controller[Inflector.camelize(req.getRoute().getAction(), '-')]();
+
+ // Render the body
+ await controller.render();
+
+ // Return our response
+ return controller.getResponse().build();
+ } catch (e) {
+ Logger.error(`Could not execute "${req.getRoute().getController()}": ${e.message}`, e.stack);
+ return new Response(
+ "An Internal Server Error Occurred",
+ {
+ status: 500,
+ headers: {
+ "Content-Type": "text/plain",
+ },
+ },
+ );
+ }
+ }
+
+ /**
+ * Get the query parameters for the given route
+ *
+ * @param path
+ * @returns QueryParameters
+ */
+ public static getQuery(path: string): QueryParameters {
+ const params = new URLSearchParams(path.split("?")[1]);
+ return Object.fromEntries(params.entries());
+ }
+
+ /**
+ * Get the body from the request
+ *
+ * @param request
+ * @returns Promise
+ */
+ public static async getBody(request: Request): Promise {
+ // Make sure a body is set
+ if (request.body === null) return "";
+
+ // Create a reader
+ const reader = readerFromStreamReader(request.body.getReader());
+
+ // Read all bytes
+ const buf: Uint8Array = await readAll(reader);
+
+ // Decode and return
+ return new TextDecoder("utf-8").decode(buf);
+ }
+
+ /**
+ * Check if there is an authorization header set, return it if so
+ *
+ * @param request
+ * @returns string
+ */
+ public static getAuth(request: Request): string {
+ // Get our authorization header
+ // Return it or empty string if none found
+ return valueOrDefault(request.headers.get("authorization"), "");
+ }
+
+ /**
+ * Add a route.
+ * Defaults to 'GET'
+ *
+ * @param route
+ * @returns void
+ */
+ public static add(route: Route): void {
+ Router.routes.push(
+ new ChompRoute(
+ new URLPattern({pathname: route.path}),
+ Inflector.pascalize(route.controller),
+ route.action,
+ valueOrDefault(route.method, "GET"),
+ ),
+ );
+ }
+}
diff --git a/src/webserver/webserver.ts b/src/webserver/webserver.ts
new file mode 100644
index 00000000..54a58ea9
--- /dev/null
+++ b/src/webserver/webserver.ts
@@ -0,0 +1,68 @@
+import { Logger } from "../core/logger.ts";
+import { Router } from "./routing/router.ts";
+import { StatusCodes } from "./http/status-codes.ts";
+import {valueOrDefault} from "../utility/value-or-default.ts";
+import {Configure} from "../core/configure.ts";
+
+export class Webserver {
+ private server: Deno.Listener | null = null;
+
+ constructor(
+ private readonly port: number = 80,
+ ) {
+ }
+
+ public async start() {
+ // Start listening
+ this.server = Deno.listen({ port: this.port });
+
+ // Serve connections
+ for await (const conn of this.server) {
+ try {
+ // No need to await
+ void this.serve(conn);
+ } catch(e) {
+ Logger.error(`Could not serve connection: ${e.message}`, e.stack);
+ }
+ }
+ }
+
+ private async serve(conn: Deno.Conn) {
+ // Upgrade the connection to HTTP
+ // deno-lint-ignore no-deprecated-deno-api -- TODO
+ const httpConn: Deno.HttpConn = Deno.serveHttp(conn);
+
+ // Handle each request for this connection
+ for await (const request of httpConn) {
+ const clientIp = valueOrDefault(
+ request.request.headers.get(Configure.get("real_ip_header", "X-Forwarded-For")),
+ (conn.remoteAddr as Deno.NetAddr).hostname!
+ );
+
+ Logger.debug(
+ `Request from "${clientIp}:${(conn.remoteAddr as Deno.NetAddr)
+ .port!}": ${request.request.method} | ${request.request.url}`,
+ );
+ try {
+ // Run the required route
+ const response: Response = await Router.execute(request.request, (conn.remoteAddr as Deno.NetAddr).hostname!);
+
+ // Send our response
+ await request.respondWith(response);
+ } catch (e) {
+ Logger.error(`Could not serve response: ${e.message}`, e.stack);
+ await request.respondWith(
+ new Response(
+ "An Internal Server Error Occurred",
+ {
+ status: StatusCodes.INTERNAL_SERVER_ERROR,
+ headers: {
+ "Content-Type": "text/plain",
+ },
+ },
+ ),
+ );
+ }
+ }
+ }
+}
diff --git a/websocket/authenticator.ts b/src/websocket/authenticator.ts
similarity index 52%
rename from websocket/authenticator.ts
rename to src/websocket/authenticator.ts
index 80321461..4f8fdb31 100644
--- a/websocket/authenticator.ts
+++ b/src/websocket/authenticator.ts
@@ -1,5 +1,5 @@
-import { Logger } from "../logging/logger.ts";
-import { Configure } from "../common/configure.ts";
+import { Logger } from "../core/logger.ts";
+import { Configure } from "../core/configure.ts";
export class Authenticator {
/**
@@ -7,11 +7,11 @@ export class Authenticator {
*
* @param token
*/
- public static client(token: string = ''): boolean {
- if(!token) {
+ public static client(token: string = ""): boolean {
+ if (!token) {
Logger.debug(`No token has been set! (this may be a bug)`);
return false;
}
- return token === Configure.get('websocket_client_auth', '');
+ return token === Configure.get("websocket_client_auth", "");
}
}
diff --git a/websocket/events.ts b/src/websocket/events.ts
similarity index 67%
rename from websocket/events.ts
rename to src/websocket/events.ts
index 35463e5d..759bfd09 100644
--- a/websocket/events.ts
+++ b/src/websocket/events.ts
@@ -1,14 +1,13 @@
-import { Logger } from "../logging/logger.ts";
-
-interface IEvent {
- name: string;
- handler: string;
-}
+import { IEvent } from "../types/websocket.ts";
+import { Logger } from "../core/logger.ts";
export class Events {
private static list: IEvent[] = [];
+ // deno-lint-ignore no-explicit-any -- TODO
private static handlers: any = {};
- public static getEvents() { return Events.list; }
+ public static getEvents() {
+ return Events.list;
+ }
public static getHandler(name: string) {
return Events.list.find((event: IEvent) => event.name === name);
@@ -17,8 +16,8 @@ export class Events {
public static async add(event: IEvent) {
try {
// Import the event handler
- Events.handlers[event.handler] = await import(`file://${Deno.cwd()}/src/events/${event.handler}.ts`)
- } catch(e) {
+ Events.handlers[event.handler] = await import(`file://${Deno.cwd()}/src/events/${event.handler}.ts`);
+ } catch (e) {
Logger.error(`Could not register event handler for "${event}": ${e.message}`, e.stack);
return;
}
@@ -26,18 +25,19 @@ export class Events {
Events.list.push(event);
}
+ // deno-lint-ignore no-explicit-any -- TODO
public static async dispatch(event: string, data: any = {}) {
// Get the event handler
const handler = Events.getHandler(event);
- if(!handler) return Logger.warning(`Event "${event}" does not exist! (did you register it?`);
+ if (!handler) return Logger.warning(`Event "${event}" does not exist! (did you register it?`);
// Create an instance of the event handler
const controller = new Events.handlers[handler.handler][`${event}Event`](data);
// Execute the handler's execute method
try {
- await controller['execute'](data);
- } catch(e) {
+ await controller["execute"](data);
+ } catch (e) {
Logger.error(`Could not dispatch event "${event}": "${e.message}"`, e.stack);
}
}
diff --git a/src/websocket/mod.ts b/src/websocket/mod.ts
new file mode 100644
index 00000000..c3bc65ea
--- /dev/null
+++ b/src/websocket/mod.ts
@@ -0,0 +1,3 @@
+export * from "./authenticator.ts";
+export * from "./events.ts";
+export * from "./websocket.ts";
diff --git a/websocket/websocket.ts b/src/websocket/websocket.ts
similarity index 50%
rename from websocket/websocket.ts
rename to src/websocket/websocket.ts
index 2464e9c7..c2323db9 100644
--- a/websocket/websocket.ts
+++ b/src/websocket/websocket.ts
@@ -1,13 +1,20 @@
-import { WebSocketServer, WebSocketAcceptedClient } from "https://deno.land/x/websocket@v0.1.3/mod.ts";
-import { Logger } from "../logging/logger.ts";
+import { WebSocketAcceptedClient, WebSocketServer } from "https://deno.land/x/websocket@v0.1.3/mod.ts";
+import { Logger } from "../core/logger.ts";
import { Events } from "./events.ts";
import { Authenticator } from "./authenticator.ts";
-import { Configure } from "../common/configure.ts";
+import { Configure } from "../core/configure.ts";
+import {valueOrDefault} from "../utility/value-or-default.ts";
+
+declare global {
+ interface Window {
+ websocket: Websocket;
+ }
+}
export class Websocket {
private readonly port: number = 80;
private readonly authenticate: boolean = false;
- private server: WebSocketServer|null = null;
+ private server: WebSocketServer | null = null;
constructor(port: number = 80, authenticate: boolean = false) {
this.port = port;
@@ -15,67 +22,76 @@ export class Websocket {
}
public start() {
- this.server = new WebSocketServer(this.port, Configure.get('real_ip_header', 'X-Forwarded-For') ?? null);
+ const header = Configure.get("real_ip_header", "X-Forwarded-For");
+ this.server = new WebSocketServer(this.port, valueOrDefault(header, null));
this.server.on("connection", (client: WebSocketAcceptedClient, url: string) => {
Logger.info(`New WebSocket connection from "${(client.webSocket.conn.remoteAddr as Deno.NetAddr).hostname!}"...`);
// Authenticate if required
- if(this.authenticate === true && !Authenticator.client(url.replace('/', ''))) {
- Logger.warning(`Closing connection with "${(client.webSocket.conn.remoteAddr as Deno.NetAddr).hostname!}": Invalid token!`);
- client.close(1000, 'Invalid authentication token!');
+ if (this.authenticate === true && !Authenticator.client(url.replace("/", ""))) {
+ Logger.warning(
+ `Closing connection with "${(client.webSocket.conn.remoteAddr as Deno.NetAddr).hostname!}": Invalid token!`,
+ );
+ // void: No need to await
+ void client.close(1000, "Invalid authentication token!");
return;
}
// Dispatch "ClientConnect" event
- this.handleEvent('ClientConnect', {client: client});
+ // void: No need to await
+ void this.handleEvent("ClientConnect", { client: client });
client.on("message", (message: string) => this.onMessage(message));
});
}
- public async broadcast(eventString: string, data: any) {
+ // deno-lint-ignore no-explicit-any -- Any arbitrary data may be used
+ public broadcast(eventString: string, data: any) {
// Make sure the server has started
- if(!this.server) return;
+ if (!this.server) return;
// Loop over each client
// Check whether they are still alive
// Send the event to the clients that are still alive
- for(let client of this.server.clients) {
- if(!client) continue;
- if(client.isClosed) continue;
- client.send(JSON.stringify({
+ for (const client of this.server.clients) {
+ if (!client) continue;
+ if (client.isClosed) continue;
+ // void: No need to await
+ void client.send(JSON.stringify({
event: eventString,
- data: data
+ data: data,
}));
}
}
private async onMessage(message: string) {
// Check if a message was set
- if(!message) return;
+ if (!message) return;
// Decode the message
- let data = JSON.parse(message);
+ const data = JSON.parse(message);
+
// Get the Event
let event = data.event;
- let tokens = [];
- for(let token of event.split('_')) {
+ const tokens = [];
+ for (let token of event.split("_")) {
token = token.toLowerCase();
token = token[0].toUpperCase() + token.slice(1);
tokens.push(token);
}
- event = tokens.join('');
+ event = tokens.join("");
try {
await this.handleEvent(event, data.data);
- } catch(e) {
+ } catch (e) {
Logger.error(e.message);
}
}
- private async handleEvent(event: string, data: any = []) {
+ // deno-lint-ignore no-explicit-any -- Any arbitrary data may be used
+ private async handleEvent(event: string, data: any = {}) {
const handler = Events.getHandler(event);
- if(!handler) return Logger.warning(`Event "${event}" does not exists! (did you register it?)`);
+ if (!handler) return Logger.warning(`Event "${event}" does not exists! (did you register it?)`);
// Import the event handler
const imported = await import(`file://${Deno.cwd()}/src/events/${handler.handler}.ts`);
@@ -85,8 +101,8 @@ export class Websocket {
// Execute the event handler's execute method
try {
- await controller['execute'](data);
- } catch(e) {
+ await controller["execute"](data);
+ } catch (e) {
Logger.error(`Could not dispatch event "${event}": "${e.message}"`, e.stack);
}
}
diff --git a/tests/common/configure.test.ts b/tests/common/configure.test.ts
new file mode 100644
index 00000000..f32c4570
--- /dev/null
+++ b/tests/common/configure.test.ts
@@ -0,0 +1,28 @@
+import { assertEquals } from "https://deno.land/std@0.152.0/testing/asserts.ts";
+import { Configure } from "../../src/core/configure.ts";
+
+Deno.test("Configure Test", () => {
+ // Add a test variable and test against it
+ Configure.set("test1", "chomp");
+ assertEquals(Configure.check("test1"), true);
+ assertEquals(Configure.get("test1"), "chomp");
+
+ // Make sure consume works as intended
+ assertEquals(Configure.consume("test1"), "chomp");
+ assertEquals(Configure.check("test1"), false);
+
+ // Add a new test variable and immediately try to delete it
+ Configure.set("test2", "chomp");
+ Configure.delete("test2");
+ assertEquals(Configure.check("test2"), false);
+
+ // Make sure clearing works
+ Configure.set("test3", "chomp");
+ Configure.clear();
+ // deno-lint-ignore no-explicit-any -- Arbitrary data may be used
+ assertEquals(Configure.dump(), new Map());
+
+ // Make sure default values work on get and consume
+ assertEquals(Configure.get("test4", "default value"), "default value");
+ assertEquals(Configure.get("test5", "default value"), "default value");
+});
diff --git a/tests/common/configure.ts b/tests/common/configure.ts
deleted file mode 100644
index 62372443..00000000
--- a/tests/common/configure.ts
+++ /dev/null
@@ -1,27 +0,0 @@
-import { assertEquals } from "https://deno.land/std@0.152.0/testing/asserts.ts";
-import { Configure } from "../../common/configure.ts";
-
-Deno.test("Configure Test", async (t) => {
- // Add a test variable and test against it
- Configure.set('test1', 'chomp');
- assertEquals(Configure.check('test1'), true);
- assertEquals(Configure.get('test1'), 'chomp');
-
- // Make sure consume works as intended
- assertEquals(Configure.consume('test1'), 'chomp');
- assertEquals(Configure.check('test1'), false);
-
- // Add a new test variable and immediately try to delete it
- Configure.set('test2', 'chomp');
- Configure.delete('test2');
- assertEquals(Configure.check('test2'), false);
-
- // Make sure clearing works
- Configure.set('test3', 'chomp');
- Configure.clear();
- assertEquals(Configure.dump(), new Map());
-
- // Make sure default values work on get and consume
- assertEquals(Configure.get('test4', 'default value'), 'default value');
- assertEquals(Configure.get('test5', 'default value'), 'default value');
-});
diff --git a/tests/error/raise.test.ts b/tests/error/raise.test.ts
new file mode 100644
index 00000000..1b587c54
--- /dev/null
+++ b/tests/error/raise.test.ts
@@ -0,0 +1,19 @@
+import { assertThrows } from "https://deno.land/std@0.152.0/testing/asserts.ts";
+import { raise } from "../../src/error/raise.ts";
+
+class CustomError extends Error {
+ constructor(public message: string) {
+ super(message);
+ }
+}
+
+Deno.test("Errors Test", () => {
+ // Check with a "simple" raise
+ assertThrows(() => raise("Some Error Message"), Error, "Some Error Message");
+
+ // Check with custom Error type (via string)
+ assertThrows(() => raise("Some Error Message", "CustomError"), Error, "Some Error Message");
+
+ // Check with custom Error type (via class)
+ assertThrows(() => raise("Some Error Message", CustomError), CustomError, "Some Error Message");
+});
diff --git a/tests/extensions/array.test.ts b/tests/extensions/array.test.ts
new file mode 100644
index 00000000..45fa47c6
--- /dev/null
+++ b/tests/extensions/array.test.ts
@@ -0,0 +1,13 @@
+import "../../src/extensions/array/includes-any.ts";
+import {assertEquals} from "https://deno.land/std@0.152.0/testing/asserts.ts";
+
+Deno.test("Array Extensions", async (t) => {
+ await t.step("includesAny", () => {
+ const a = [1,2,3,4];
+ const b = [2, 5];
+ const c = [5, 6];
+
+ assertEquals(a.includesAny(b), true);
+ assertEquals(a.includesAny(c), false);
+ });
+});
diff --git a/tests/extensions/date.test.ts b/tests/extensions/date.test.ts
new file mode 100644
index 00000000..d489f331
--- /dev/null
+++ b/tests/extensions/date.test.ts
@@ -0,0 +1,45 @@
+import "../../src/extensions/date/is-after.ts";
+import "../../src/extensions/date/is-after-or-equal.ts";
+import "../../src/extensions/date/is-before.ts";
+import "../../src/extensions/date/is-before-or-equal.ts";
+import "../../src/extensions/date/set-midnight.ts";
+import { assertEquals } from "https://deno.land/std@0.152.0/testing/asserts.ts";
+
+Deno.test("Date Extensions Test", async (t) => {
+ const a = new Date(0);
+ const b = new Date(1);
+ const c = new Date('2025-09-16T12:13:56.123Z').setMidnight();
+ const d = new Date('2025-09-16T12:13:56.123Z').setMidnight(true);
+
+ await t.step("isAfter", () => {
+ assertEquals(b.isAfter(a), true);
+ assertEquals(a.isAfter(b), false);
+ assertEquals(b.isAfter(b), false);
+ });
+
+ await t.step("isAfterOrEqual", () => {
+ assertEquals(b.isAfterOrEqual(a), true);
+ assertEquals(a.isAfterOrEqual(b), false);
+ assertEquals(b.isAfterOrEqual(b), true);
+ });
+
+ await t.step("isBefore", () => {
+ assertEquals(a.isBefore(b), true);
+ assertEquals(b.isBefore(a), false);
+ assertEquals(b.isBefore(b), false);
+ });
+
+ await t.step("isBeforeOrEqual", () => {
+ assertEquals(a.isBeforeOrEqual(b), true);
+ assertEquals(b.isBeforeOrEqual(a), false);
+ assertEquals(b.isBeforeOrEqual(b), true);
+ });
+
+ await t.step("setMidnight", () => {
+ assertEquals(c.toISOString(), '2025-09-15T22:00:00.000Z');
+ assertEquals(d.toISOString(), '2025-09-16T22:00:00.000Z');
+ assertEquals(a.isBeforeOrEqual(b), true);
+ assertEquals(b.isBeforeOrEqual(a), false);
+ assertEquals(b.isBeforeOrEqual(b), true);
+ });
+});
diff --git a/tests/extensions/string.test.ts b/tests/extensions/string.test.ts
new file mode 100644
index 00000000..936f2bed
--- /dev/null
+++ b/tests/extensions/string.test.ts
@@ -0,0 +1,9 @@
+import "../../src/extensions/string/empty.ts";
+import {assertEquals} from "https://deno.land/std@0.152.0/testing/asserts.ts";
+
+Deno.test("String Extensions", async (t) => {
+ await t.step("Empty", () => {
+ assertEquals(String.empty === "", true);
+ assertEquals(String.empty === "foo", false);
+ });
+});
diff --git a/tests/queue/queue.test.ts b/tests/queue/queue.test.ts
new file mode 100644
index 00000000..16429c46
--- /dev/null
+++ b/tests/queue/queue.test.ts
@@ -0,0 +1,115 @@
+import { assertEquals } from "https://deno.land/std@0.152.0/testing/asserts.ts";
+import { Queue } from "../../src/queue/queue.ts";
+import { default as fifo } from "../../src/queue/scheduler/first-in-first-out.ts";
+import { default as lifo } from "../../src/queue/scheduler/last-in-first-out.ts";
+import { default as wfifo } from "../../src/queue/scheduler/weighted-first-in-first-out.ts";
+
+Deno.test("Queue Test", async (t) => {
+ await t.step("Common", () => {
+ // Create our queue
+ const queue = new Queue(fifo);
+
+ // Test isEmpty and count without items
+ assertEquals(queue.isEmpty, true);
+ assertEquals(queue.count, 0);
+
+ // Test next and peek on an empty queue
+ assertEquals(queue.peek, null);
+ assertEquals(queue.next, null);
+
+ // Add test items to the queue
+ queue.add({ data: { job: "test1" } });
+ queue.add({ data: { job: "test2" } });
+ queue.add({ data: { job: "test3" } });
+ queue.add({ data: { job: "test4" } });
+
+ // Test isEmpty and count with items
+ assertEquals(queue.isEmpty, false);
+ assertEquals(queue.count, 4);
+
+ // Make sure clearing works
+ queue.clear();
+ assertEquals(queue.isEmpty, true);
+ assertEquals(queue.count, 0);
+ });
+
+ await t.step("FIFO Scheduler", () => {
+ // Create our queue
+ const queue = new Queue(fifo);
+
+ // Add test items to the queue
+ queue.add({ data: { job: "test1" } });
+ queue.add({ data: { job: "test2" } });
+ queue.add({ data: { job: "test3" } });
+ queue.add({ data: { job: "test4" } });
+
+ // Make sure peeking works without removal
+ assertEquals(queue.peek, { data: { job: "test1" } });
+ assertEquals(queue.count, 4);
+
+ // Make sure next works with removal
+ assertEquals(queue.next, { data: { job: "test1" } });
+ assertEquals(queue.count, 3);
+ assertEquals(queue.peek, { data: { job: "test2" } });
+
+ // Make sure contains works
+ assertEquals(queue.contains({ data: { job: "test2" } }), true);
+ assertEquals(queue.contains({ data: { job: "test4" } }), true);
+ assertEquals(queue.contains({ data: { job: "test5" } }), false);
+ });
+
+ await t.step("LIFO Scheduler", () => {
+ // Create our queue
+ const queue = new Queue(lifo);
+
+ // Add test items to the queue
+ queue.add({ data: { job: "test1" } });
+ queue.add({ data: { job: "test2" } });
+ queue.add({ data: { job: "test3" } });
+ queue.add({ data: { job: "test4" } });
+
+ // Make sure peeking works without removal
+ assertEquals(queue.peek, { data: { job: "test4" } });
+ assertEquals(queue.count, 4);
+
+ // Make sure next works with removal
+ assertEquals(queue.next, { data: { job: "test4" } });
+ assertEquals(queue.count, 3);
+ assertEquals(queue.peek, { data: { job: "test3" } });
+
+ // Make sure contains works
+ assertEquals(queue.contains({ data: { job: "test1" } }), true);
+ assertEquals(queue.contains({ data: { job: "test3" } }), true);
+ assertEquals(queue.contains({ data: { job: "test5" } }), false);
+ });
+
+ await t.step("WEIGHTED Scheduler", () => {
+ // Create our queue
+ const queue = new Queue(wfifo);
+
+ // Add test items to the queue
+ queue.add({ weight: 0, data: { job: "test1" } });
+ queue.add({ weight: 0, data: { job: "test2" } });
+ queue.add({ weight: 1, data: { job: "test3" } });
+ queue.add({ weight: 2, data: { job: "test4" } });
+ queue.add({ weight: 3, data: { job: "test5" } });
+ queue.add({ data: { job: "test6" } });
+
+ // Make sure peeking works without removal
+ assertEquals(queue.peek, { weight: 3, data: { job: "test5" } });
+ assertEquals(queue.count, 6);
+
+ // Make sure next works with removal
+ assertEquals(queue.next, { weight: 3, data: { job: "test5" } });
+ assertEquals(queue.count, 5);
+ assertEquals(queue.peek, { weight: 2, data: { job: "test4" } });
+
+ // Make sure contains works
+ assertEquals(queue.contains({ weight: 0, data: { job: "test1" } }), true);
+ assertEquals(queue.contains({ weight: 1, data: { job: "test3" } }), true);
+ assertEquals(queue.contains({ weight: 2, data: { job: "test3" } }), false);
+
+ // Make sure we didn't add "weightless" items
+ assertEquals(queue.contains({ data: { job: "test6" } }), false);
+ });
+});
diff --git a/tests/queue/queue.ts b/tests/queue/queue.ts
deleted file mode 100644
index 5f2c9eef..00000000
--- a/tests/queue/queue.ts
+++ /dev/null
@@ -1,112 +0,0 @@
-import { assertEquals } from "https://deno.land/std@0.152.0/testing/asserts.ts";
-import { Queue, Scheduler } from "../../queue/queue.ts";
-
-Deno.test("Queue Test", async (t) => {
- await t.step("Common", () => {
- // Create our queue
- const queue = new Queue(Scheduler.FIFO);
-
- // Test isEmpty and count without items
- assertEquals(queue.isEmpty, true);
- assertEquals(queue.count, 0);
-
- // Test next and peek on an empty queue
- assertEquals(queue.peek, null);
- assertEquals(queue.next, null);
-
- // Add test items to the queue
- queue.add({ data: { job: 'test1', } });
- queue.add({ data: { job: 'test2', } });
- queue.add({ data: { job: 'test3', } });
- queue.add({ data: { job: 'test4', } });
-
- // Test isEmpty and count with items
- assertEquals(queue.isEmpty, false);
- assertEquals(queue.count, 4);
-
- // Make sure clearing works
- queue.clear();
- assertEquals(queue.isEmpty, true);
- assertEquals(queue.count, 0);
- })
-
- await t.step("FIFO Scheduler", () => {
- // Create our queue
- const queue = new Queue(Scheduler.FIFO);
-
- // Add test items to the queue
- queue.add({ data: { job: 'test1', } });
- queue.add({ data: { job: 'test2', } });
- queue.add({ data: { job: 'test3', } });
- queue.add({ data: { job: 'test4', } });
-
- // Make sure peeking works without removal
- assertEquals(queue.peek, { data: { job: 'test1', } });
- assertEquals(queue.count, 4);
-
- // Make sure next works with removal
- assertEquals(queue.next, { data: { job: 'test1', } });
- assertEquals(queue.count, 3);
- assertEquals(queue.peek, { data: { job: 'test2', } });
-
- // Make sure contains works
- assertEquals(queue.contains({ data: { job: 'test2', } }), true);
- assertEquals(queue.contains({ data: { job: 'test4', } }), true);
- assertEquals(queue.contains({ data: { job: 'test5', } }), false);
- });
-
- await t.step("LIFO Scheduler", () => {
- // Create our queue
- const queue = new Queue(Scheduler.LIFO);
-
- // Add test items to the queue
- queue.add({ data: { job: 'test1', } });
- queue.add({ data: { job: 'test2', } });
- queue.add({ data: { job: 'test3', } });
- queue.add({ data: { job: 'test4', } });
-
- // Make sure peeking works without removal
- assertEquals(queue.peek, { data: { job: 'test4', } });
- assertEquals(queue.count, 4);
-
- // Make sure next works with removal
- assertEquals(queue.next, { data: { job: 'test4', } });
- assertEquals(queue.count, 3);
- assertEquals(queue.peek, { data: { job: 'test3', } });
-
- // Make sure contains works
- assertEquals(queue.contains({ data: { job: 'test1', } }), true);
- assertEquals(queue.contains({ data: { job: 'test3', } }), true);
- assertEquals(queue.contains({ data: { job: 'test5', } }), false);
- });
-
- await t.step("WEIGHTED Scheduler", () => {
- // Create our queue
- const queue = new Queue(Scheduler.WEIGHTED);
-
- // Add test items to the queue
- queue.add({ weight: 0, data: { job: 'test1', } });
- queue.add({ weight: 0, data: { job: 'test2', } });
- queue.add({ weight: 1, data: { job: 'test3', } });
- queue.add({ weight: 2, data: { job: 'test4', } });
- queue.add({ weight: 3, data: { job: 'test5', } });
- queue.add({ data: { job: 'test6', } });
-
- // Make sure peeking works without removal
- assertEquals(queue.peek, { weight: 3, data: { job: 'test5', } });
- assertEquals(queue.count, 6);
-
- // Make sure next works with removal
- assertEquals(queue.next, { weight: 3, data: { job: 'test5', } });
- assertEquals(queue.count, 5);
- assertEquals(queue.peek, { weight: 2, data: { job: 'test4', } });
-
- // Make sure contains works
- assertEquals(queue.contains({ weight: 0, data: { job: 'test1', } }), true);
- assertEquals(queue.contains({ weight: 1, data: { job: 'test3', } }), true);
- assertEquals(queue.contains({ weight: 2, data: { job: 'test3', } }), false);
-
- // Make sure we didn't add "weightless" items
- assertEquals(queue.contains({ data: { job: 'test6', } }), false);
- });
-})
diff --git a/tests/security/hash.test.ts b/tests/security/hash.test.ts
new file mode 100644
index 00000000..f7c009ae
--- /dev/null
+++ b/tests/security/hash.test.ts
@@ -0,0 +1,18 @@
+import {Hash} from "../../src/security/hash.ts";
+import {Algorithms} from "../../src/types/hash.ts";
+import { assertEquals} from "https://deno.land/std@0.152.0/testing/asserts.ts";
+
+Deno.test("Hash Test", async (t) => {
+ // Create Hash
+ const h = new Hash("test", Algorithms.SHA3_256);
+
+ await t.step("Digest", async () => {
+ // TODO: Actually test it properly
+ // I can't find it out for the love of god
+ await h.digest();
+ });
+
+ await t.step("Hex", () => {
+ assertEquals(h.hex(), "36f028580bb02cc8272a9a020f4200e346e276ae664e45ee80745574e2f5ab80");
+ });
+});
diff --git a/tests/security/password.test.ts b/tests/security/password.test.ts
new file mode 100644
index 00000000..b4078bdd
--- /dev/null
+++ b/tests/security/password.test.ts
@@ -0,0 +1,45 @@
+import { Password } from "../../src/security/password.ts";
+import { Algorithms } from "../../src/types/hash.ts";
+import {assertEquals, assertNotEquals, assertRejects} from "https://deno.land/std@0.152.0/testing/asserts.ts";
+
+const testHash = "d88!10!insecure-salt!a7129d5e75c40adc84235abac626f2bccd863600ad9db0dcc03950974bd8c9c1";
+const testPassword = "lamepassword";
+const invalidTestPassword = "incorrect-password";
+const testSalt = "insecure-salt";
+const insecureTestHash = "db7!10!insecure-salt!5d555c2d9ebfce2f53e138231ed3e3e9";
+
+Deno.test("Password Test", async (t) => {
+ await t.step("Hash", async () => {
+ // Static salt
+ assertEquals(
+ await Password.hash(testPassword, Algorithms.SHA3_256, {
+ salt: testSalt,
+ }),
+ testHash
+ );
+
+ // Randomized salt
+ const a = await Password.hash(testPassword, Algorithms.SHA3_256);
+ const b = await Password.hash(testPassword, Algorithms.SHA3_256);
+ assertNotEquals(a, b);
+ });
+
+ await t.step("Verify", async () => {
+ assertEquals(await Password.verify(testPassword, testHash), true);
+ assertEquals(await Password.verify(invalidTestPassword, testHash), false);
+ });
+
+ await t.step("Insecure Algorithm Selection", async() => {
+ // Test when not allowing an insecure algorithm (default)
+ await assertRejects(async () => await Password.hash(testPassword, Algorithms.MD5));
+
+ // Test override
+ assertEquals(
+ await Password.hash(testPassword, Algorithms.MD5, {
+ salt: testSalt,
+ allowInsecure: true
+ }),
+ insecureTestHash
+ );
+ })
+});
diff --git a/tests/utility/contract.test.ts b/tests/utility/contract.test.ts
new file mode 100644
index 00000000..0a97fe28
--- /dev/null
+++ b/tests/utility/contract.test.ts
@@ -0,0 +1,67 @@
+import { Contract } from "../../src/utility/contract.ts";
+import { assert, assertThrows } from "https://deno.land/std@0.152.0/testing/asserts.ts";
+
+Deno.test("Contract Test", async (t) => {
+ await t.step("requireCondition", () => {
+ // Test when the condition is false
+ assertThrows(() => Contract.requireCondition(false, "Condition must be true"));
+
+ // Test when the condition if true
+ assert(() => Contract.requireCondition(true, "Condition must be true"));
+ });
+
+ await t.step("requireNotNull", () => {
+ // Test when the argument is null
+ assertThrows(() => Contract.requireNotNull(null));
+
+ // Test when the argument is not null
+ assert(() => Contract.requireNotNull("blabla"));
+ });
+
+ await t.step("requireNotUndefined", () => {
+ // Test when the argument is undefined
+ assertThrows(() => Contract.requireNotUndefined(undefined));
+
+ // Test when the argument is not undefined
+ assert(() => Contract.requireNotUndefined("blabla"));
+ });
+
+ await t.step("requireNotNullish", () => {
+ // Test when argument is nullish
+ assertThrows(() => Contract.requireNotNullish(null));
+ assertThrows(() => Contract.requireNotNullish(undefined));
+
+ // Test when argument is not nullish
+ assert(() => Contract.requireNotNullish("blabla"));
+ });
+
+ await t.step("requireNotEmpty", () => {
+ // Test when the argument is empty
+ assertThrows(() => Contract.requireNotEmpty(undefined));
+ assertThrows(() => Contract.requireNotEmpty(null));
+ assertThrows(() => Contract.requireNotEmpty(""));
+ assertThrows(() => Contract.requireNotEmpty([]));
+ assertThrows(() => Contract.requireNotEmpty({}));
+
+ // Test when the argument is not empty
+ assert(() => Contract.requireNotEmpty(0));
+ assert(() => Contract.requireNotEmpty("blabla"));
+ assert(() => Contract.requireNotEmpty([1]));
+ assert(() => Contract.requireNotEmpty({key: "value"}));
+ });
+
+ await t.step("requireEmpty", () => {
+ // Test when the argument is not empty
+ assertThrows(() => Contract.requireEmpty(0));
+ assertThrows(() => Contract.requireEmpty("blabla"));
+ assertThrows(() => Contract.requireEmpty([1]));
+ assertThrows(() => Contract.requireEmpty({key: "value"}));
+
+ // Test when the argument is empty
+ assert(() => Contract.requireEmpty(undefined));
+ assert(() => Contract.requireEmpty(null));
+ assert(() => Contract.requireEmpty(""));
+ assert(() => Contract.requireEmpty([]));
+ assert(() => Contract.requireEmpty({}));
+ });
+});
diff --git a/tests/utility/error-or-data.test.ts b/tests/utility/error-or-data.test.ts
new file mode 100644
index 00000000..b8dc5940
--- /dev/null
+++ b/tests/utility/error-or-data.test.ts
@@ -0,0 +1,40 @@
+import { errorOrData } from "../../src/utility/error-or-data.ts";
+import { assertEquals, assertRejects, assertInstanceOf } from "https://deno.land/std@0.152.0/testing/asserts.ts";
+import {assertNotInstanceOf} from "https://deno.land/std@0.159.0/testing/asserts.ts";
+
+class AError extends Error {
+ name = "AError";
+}
+
+class BError extends Error {
+ name = "BError";
+}
+
+async function good() {
+ return "foo";
+}
+
+async function bad() {
+ throw new AError('AError');
+}
+
+Deno.test("errorOrData Test", async (t) => {
+ // Test where the result would be good
+ await t.step("Good", async () => {
+ assertEquals(await errorOrData(good()), [undefined, "foo"]);
+ });
+
+ // Test where the result would be a caught AError
+ await t.step("Bad (Caught)", async () => {
+ const [error, _data] = await errorOrData(bad(), [AError]);
+ assertInstanceOf(error, AError);
+ assertNotInstanceOf(error, BError);
+ });
+
+ // Test where the result would be a thrown BError
+ await t.step("Bad (Uncaught)", () => {
+ assertRejects(async () => {
+ const [_error, _data] = await errorOrData(bad(), [BError]);
+ });
+ });
+});
diff --git a/tests/utility/inflector.test.ts b/tests/utility/inflector.test.ts
new file mode 100644
index 00000000..e67d9a20
--- /dev/null
+++ b/tests/utility/inflector.test.ts
@@ -0,0 +1,47 @@
+import { Inflector } from "../../src/utility/inflector.ts";
+import { assertEquals } from "https://deno.land/std@0.152.0/testing/asserts.ts";
+
+Deno.test("Inflector Test", async (t) => {
+ await t.step("ucfirst", () => {
+ assertEquals(Inflector.ucfirst("hello world"), "Hello world");
+ assertEquals(Inflector.ucfirst("hello World"), "Hello World");
+ });
+
+ await t.step("lcfirst", () => {
+ assertEquals(Inflector.lcfirst("Hello world"), "hello world");
+ assertEquals(Inflector.lcfirst("Hello World"), "hello World");
+ });
+
+ await t.step("pascalize", () => {
+ assertEquals(Inflector.pascalize("hello-world"), "Hello-world");
+ assertEquals(Inflector.pascalize("hello_World"), "HelloWorld");
+ assertEquals(Inflector.pascalize("hello-world", "-"), "HelloWorld");
+ assertEquals(Inflector.pascalize("hello_World", "-"), "Hello_World");
+ });
+
+ await t.step("camelize", () => {
+ assertEquals(Inflector.camelize("hello-world"), "hello-world");
+ assertEquals(Inflector.camelize("hello_world"), "helloWorld");
+ assertEquals(Inflector.camelize("hello-world", "-"), "helloWorld");
+ assertEquals(Inflector.camelize("hello_world", "-"), "hello_world");
+ })
+
+ await t.step("humanize", () => {
+ assertEquals(Inflector.humanize("hello-world"), "Hello-world");
+ assertEquals(Inflector.humanize("hello_World"), "Hello World");
+ assertEquals(Inflector.humanize("hello-world", "-"), "Hello World");
+ assertEquals(Inflector.humanize("hello_World", "-"), "Hello_World");
+ });
+
+ await t.step("dasherize", () => {
+ assertEquals(Inflector.dasherize("HelloWorld"), "hello-world");
+ assertEquals(Inflector.dasherize("hello_world"), "hello-world");
+ });
+
+ await t.step("delimit", () => {
+ assertEquals(Inflector.delimit("HelloWorld"), "hello_world");
+ assertEquals(Inflector.delimit("HelloWorld", "-"), "hello-world");
+ assertEquals(Inflector.delimit("Hello World"), "hello world");
+ assertEquals(Inflector.delimit("Hello-World"), "hello-world");
+ });
+});
diff --git a/tests/utility/name-of.test.ts b/tests/utility/name-of.test.ts
new file mode 100644
index 00000000..788b4ec6
--- /dev/null
+++ b/tests/utility/name-of.test.ts
@@ -0,0 +1,8 @@
+import { assertEquals, assertNotEquals } from "https://deno.land/std@0.152.0/testing/asserts.ts";
+import { nameOf } from "../../src/utility/name-of.ts";
+
+Deno.test("nameOf Test", () => {
+ const testArgument = "blabla";
+ assertEquals(nameOf({ testArgument }), "testArgument");
+ assertNotEquals(nameOf({ testArgument }), "testargument");
+});
diff --git a/tests/utility/text.test.ts b/tests/utility/text.test.ts
new file mode 100644
index 00000000..f614a4a4
--- /dev/null
+++ b/tests/utility/text.test.ts
@@ -0,0 +1,29 @@
+import { assertEquals, assertNotEquals } from "https://deno.land/std@0.152.0/testing/asserts.ts";
+import { Text } from "../../src/utility/text.ts";
+
+Deno.test("Text Test", async (t) => {
+ await t.step("tokenize", () => {
+ // Test without limits
+ assertEquals(Text.tokenize("this is a sentence."), ["this", "is", "a", "sentence."]);
+ assertNotEquals(Text.tokenize("this is a sentence."), ["this", "is", "a sentence."]);
+
+ // Test with limits
+ assertEquals(Text.tokenize("this is a sentence.", 2), ["this", "is", "a sentence."]);
+ assertNotEquals(Text.tokenize("this is a sentence.", 2), ["this", "is", "a", "sentence."]);
+ });
+
+ await t.step("htmlentities", () => {
+ // Test all supported entities
+ assertEquals(Text.htmlentities("&"), "&");
+ assertEquals(Text.htmlentities("<"), "<");
+ assertEquals(Text.htmlentities(">"), ">");
+ assertEquals(Text.htmlentities("'"), "'");
+ assertEquals(Text.htmlentities('"'), """);
+
+ // Test regular characters
+ assertEquals(Text.htmlentities("1"), "1");
+ assertEquals(Text.htmlentities("2"), "2");
+ assertEquals(Text.htmlentities("a"), "a");
+ assertEquals(Text.htmlentities("b"), "b");
+ });
+});
diff --git a/tests/validation/rules.test.ts b/tests/validation/rules.test.ts
new file mode 100644
index 00000000..00bf0ced
--- /dev/null
+++ b/tests/validation/rules.test.ts
@@ -0,0 +1,68 @@
+import { isEmpty } from "../../src/validation/rules/is-empty.ts";
+import { isNull } from "../../src/validation/rules/is-null.ts";
+import { isUndefined } from "../../src/validation/rules/is-undefined.ts";
+import { minLength } from "../../src/validation/rules/min-length.ts";
+import { maxLength } from "../../src/validation/rules/max-length.ts";
+import { assertEquals } from "https://deno.land/std@0.152.0/testing/asserts.ts";
+
+Deno.test("Validator Rules Test", async (t) => {
+ await t.step("isEmpty", () => {
+ const message = "Passed argument not empty!";
+ const params = {message: message}
+
+ assertEquals(isEmpty([], params), [undefined]);
+ assertEquals(isEmpty([0], params), [message]);
+ });
+
+ await t.step("isNull", () => {
+ const message = "Passed argument not null!";
+ const params = {message: message}
+
+ assertEquals(isNull(null, params), [undefined]);
+ assertEquals(isNull(0, params), [message]);
+ });
+
+ await t.step("isUndefined", () => {
+ const message = "Passed argument not undefined!";
+ const params = {message: message}
+
+ assertEquals(isUndefined(undefined, params), [undefined]);
+ assertEquals(isUndefined(0, params), [message]);
+ });
+
+ await t.step("minLength", () => {
+ const message = "Passed argument not long enough!";
+ const params = {
+ message: message,
+ parameters: {
+ length: 2
+ }
+ };
+
+ // Test for strings
+ assertEquals(minLength("ab", params), [undefined]);
+ assertEquals(minLength("a", params), [message]);
+
+ // Test for arrays
+ assertEquals(minLength([1,2], params), [undefined]);
+ assertEquals(minLength([1], params), [message]);
+ });
+
+ await t.step("maxLength", () => {
+ const message = "Passed argument too long!";
+ const params = {
+ message: message,
+ parameters: {
+ length: 1
+ }
+ };
+
+ // Test for strings
+ assertEquals(maxLength("a", params), [undefined]);
+ assertEquals(maxLength("ab", params), [message]);
+
+ // Test for arrays
+ assertEquals(maxLength([1], params), [undefined]);
+ assertEquals(maxLength([1,2], params), [message]);
+ });
+});
diff --git a/tests/validation/validator.test.ts b/tests/validation/validator.test.ts
new file mode 100644
index 00000000..b80d4eee
--- /dev/null
+++ b/tests/validation/validator.test.ts
@@ -0,0 +1,36 @@
+import {assertEquals, assertThrows} from "https://deno.land/std@0.152.0/testing/asserts.ts";
+import {Validator} from "../../src/validation/validator.ts";
+
+
+Deno.test("Validator Test", async (t) => {
+ // Create Validator object
+ let validator = new Validator();
+
+ await t.step("Create Rule", () => {
+ validator.create('Mock Rule', () => [undefined]);
+ validator.create('Mock Rule', () => [undefined], true);
+ validator.create('Always Fail', () => ['Always fails']);
+ validator.create('Always Fail Again', () => ['Always fails as well']);
+
+ assertThrows(
+ () => validator.create('Mock Rule', () => [undefined], false)
+ )
+ });
+
+ await t.step("Add rule", () => {
+ validator.add('Mock Rule');
+ });
+
+ await t.step("Execute Validators", () => {
+ assertEquals(validator.execute(0), []);
+
+ validator.add('Always Fail');
+ assertEquals(validator.execute(0), ['Always fails']);
+ });
+
+ await t.step("Stop on Failure", () => {
+ validator.add('Always Fail Again');
+ validator.setStopOnFailure(true);
+ assertEquals(validator.execute(0), ['Always fails']);
+ });
+});
diff --git a/util/check-source.ts b/util/check-source.ts
deleted file mode 100644
index 076ffc1e..00000000
--- a/util/check-source.ts
+++ /dev/null
@@ -1,100 +0,0 @@
-import { Logger } from "../logging/logger.ts";
-
-export interface ExclusionConfig {
- directories?: string[];
- files?: string[];
-}
-
-export class CheckSource {
- private files: string[] = [];
- private errors = 0;
-
- constructor(
- private readonly path: string,
- private readonly exclusions: ExclusionConfig = { directories: [], files: [] }
- ) {}
-
- public async run(): Promise {
- // Get list of files
- await this.getFiles(this.path);
-
- // Checkk all files found
- Logger.info(`Checking "${this.files.length}" files...`);
- await this.checkFiles();
-
- // Exit when done
- if(this.errors > 0) {
- Logger.info(`Finished checking files with ${this.errors} errors!\r\nPlease check the logs above for more information.`);
- Deno.exit(1);
- }
- Logger.info(`Finished checking files!`);
- Deno.exit(0);
- }
-
- /**
- * Recursively can all files in the given path
- * Ignore directories and files given in our exclusions
- *
- * @param path
- */
- private async getFiles(path: string) {
- Logger.info(`Getting all files in directory "${path}"...`);
- for await(const entry of Deno.readDir(path)) {
- if(entry.isDirectory) {
- if('directories' in this.exclusions && this.exclusions.directories?.includes(entry.name)) {
- Logger.debug(`Skipping excluded directory "${path}/${entry.name}"...`);
- continue;
- }
- await this.getFiles(`${path}/${entry.name}`);
- }
-
- if(entry.isFile) {
- if('files' in this.exclusions && this.exclusions.files?.includes(entry.name)) {
- Logger.debug(`Skipping excluded file "${path}/${entry.name}"...`);
- continue;
- }
- if(!this.isTs(entry.name)) {
- Logger.debug(`Skipping non-ts file...`);
- continue;
- }
- Logger.debug(`Found file "${path}/${entry.name}"...`);
- this.addFile(`${path}/${entry.name}`);
- }
- }
- }
-
- /**
- * Add file to array of files
- *
- * @param path
- */
- private addFile(path: string) {
- if(this.files.includes(path)) return;
- this.files.push(path);
- }
-
- /**
- * Check all files found
- */
- private async checkFiles() {
- for await(const file of this.files) {
- try {
- await import(`file://${Deno.cwd()}/${file}`);
- } catch(e) {
- Logger.error(`Check for "${Deno.cwd()}/${file}" failed: ${e.message}`, e.stack);
- this.errors++;
- }
- }
- }
-
- /**
- * Checks whether the file is a ".ts" file
- *
- * @returns boolean
- */
- private isTs(name: string): boolean {
- const pos = name.lastIndexOf(".");
- if(pos < 1) return false;
- return name.slice(pos + 1) === 'ts';
- }
-}
diff --git a/util/lcfirst.ts b/util/lcfirst.ts
deleted file mode 100644
index 4fd59743..00000000
--- a/util/lcfirst.ts
+++ /dev/null
@@ -1,3 +0,0 @@
-export function lcfirst(input: string): string {
- return input.charAt(0).toLowerCase() + input.slice(1);
-}
diff --git a/util/tokenizer.ts b/util/tokenizer.ts
deleted file mode 100644
index e709eed0..00000000
--- a/util/tokenizer.ts
+++ /dev/null
@@ -1,10 +0,0 @@
-export function tokenizer(input: string, limit = 3) {
- const tokens = input.split(" ");
- if(tokens.length > limit) {
- let ret = tokens.splice(0, limit);
- ret.push(tokens.join(" "));
- return ret;
- }
-
- return tokens;
-}
diff --git a/util/ucfirst.ts b/util/ucfirst.ts
deleted file mode 100644
index 421ce516..00000000
--- a/util/ucfirst.ts
+++ /dev/null
@@ -1,3 +0,0 @@
-export function ucfirst(input: string): string {
- return input.charAt(0).toUpperCase() + input.slice(1);
-}
diff --git a/webserver/controller/controller.ts b/webserver/controller/controller.ts
deleted file mode 100644
index 8b4a23c2..00000000
--- a/webserver/controller/controller.ts
+++ /dev/null
@@ -1,84 +0,0 @@
-import { handlebarsEngine } from "https://raw.githubusercontent.com/FinlayDaG33k/view-engine/patch-1/mod.ts";
-import { Logger } from "../../logging/logger.ts";
-
-export class Controller {
- protected name = ''
- protected action = '';
- protected vars: any = {};
- protected status = 200;
- protected body = '';
- protected type = 'text/html';
-
- constructor(
- name: string,
- action: string = 'index'
- ) {
- this.name = name;
- this.action = action;
- }
-
- /**
- * Set a view variable
- *
- * @param key
- * @param value
- */
- protected set(key: string, value: any) { this.vars[key] = value; }
-
- /**
- * Render the page output
- * Will try to decide the best way of doing it based on the MIME set
- *
- * @returns Promise
- */
- public async render(): Promise {
- switch(this.type) {
- case 'application/json':
- this.body = JSON.stringify(this.vars['data']);
- break;
- case 'text/plain':
- this.body = this.vars['message'];
- break;
- case 'text/html':
- default:
- this.body = await this.handlebars();
- }
- }
-
- /**
- * Render Handlebars templates
- *
- * @returns Promise
- */
- private async handlebars(): Promise {
- // Get our template location
- const path = `./src/templates/${this.name[0].toLowerCase() + this.name.slice(1)}/${this.action}.hbs`;
-
- // Make sure out template exists
- try {
- await Deno.stat(path);
- } catch(e) {
- Logger.error(`Could not find template for "${this.name[0].toLowerCase() + this.name.slice(1)}#${this.action}"`, e.stack);
- return;
- }
-
- // Read our template
- const template = await Deno.readTextFile(`./src/templates/${this.name[0].toLowerCase() + this.name.slice(1)}/${this.action}.hbs`);
-
- // Let the engine render
- return handlebarsEngine(template, this.vars);
- }
-
- public response() {
- return new Response(
- this.body,
- {
- status: this.status,
- headers: {
- 'content-type': this.type,
- 'Access-Control-Allow-Origin': '*'
- }
- }
- );
- }
-}
diff --git a/webserver/routing/router.ts b/webserver/routing/router.ts
deleted file mode 100644
index 8675164e..00000000
--- a/webserver/routing/router.ts
+++ /dev/null
@@ -1,133 +0,0 @@
-import { readerFromStreamReader } from "https://deno.land/std@0.126.0/io/mod.ts";
-import { pathToRegexp } from "../pathToRegexp.ts";
-
-interface Route {
- path: string;
- controller: string;
- action: string;
- method: string;
-}
-
-export interface RouteArgs {
- route: Route;
- body: string;
- params: any;
- auth?: string;
-}
-
-export class Router {
- private static routes: Route[] = [];
- public static getRoutes() { return Router.routes; }
-
- /**
- * Match the controller and action to a route
- *
- * @param request
- */
- public async route(request: Request) {
- // Get the request path minus the domain
- const host = request.headers.get("host");
- let path = request.url
- .replace("http://", "")
- .replace("https://", "");
- if(host !== null) path = path.replace(host, "");
-
- // Loop over each route
- // Check if it is the right method
- // Check if it's the right path
- // Return the route if route found
- for await(let route of Router.routes) {
- if(route.method !== request.method) continue;
-
- // Make sure we have a matching route
- const matches = pathToRegexp(route.path).exec(path);
- if(matches) return {
- route: route,
- path: path
- };
- }
- }
-
- /**
- * Execute the requested controller action
- *
- * @param args
- * @returns Promise
- */
- public async execute(args: RouteArgs): Promise {
- // Make sure a route was specified
- if(args.route === null) return null;
-
- // Import the controller file
- const imported = await import(`file://${Deno.cwd()}/src/controller/${args.route.controller[0].toLowerCase() + args.route.controller.slice(1)}.controller.ts`);
-
- // Instantiate the controller
- const controller = new imported[`${args.route.controller}Controller`](args.route.controller, args.route.action);
-
- // Execute our action
- await controller[args.route.action](args);
-
- // Render the body
- await controller.render();
-
- // Return our response
- return controller.response();
- }
-
- /**
- * Get the parameters for the given route
- *
- * @param route
- * @param path
- * @returns Promise<{ [key: string]: string }>
- */
- public async getParams(route: Route, path: string): Promise<{ [key: string]: string }> {
- const keys: any[] = [];
- const r = pathToRegexp(route.path, keys).exec(path) || [];
-
- return keys.reduce((acc, key, i) => ({ [key.name]: r[i + 1], ...acc }), {});
- }
-
- /**
- * Get the body from the request
- *
- * @param request
- * @returns Promise
- */
- public async getBody(request: Request): Promise {
- // Make sure a body is set
- if(request.body === null) return '';
-
- // Create a reader
- const reader = readerFromStreamReader(request.body.getReader());
-
- // Read all bytes
- const buf: Uint8Array = await Deno.readAll(reader);
-
- // Decode and return
- return new TextDecoder("utf-8").decode(buf);
- }
-
- /**
- * Check if there is an authorization header set, return it if so
- *
- * @param request
- * @returns string
- */
- public getAuth(request: Request): string {
- // Get our authorization header
- // Return it or empty string if none found
- const header = request.headers.get("authorization");
- return header ?? '';
- }
-
- /**
- * Add a route
- *
- * @param route
- * @returns void
- */
- public static add(route: Route): void {
- Router.routes.push(route);
- }
-}
diff --git a/webserver/webserver.ts b/webserver/webserver.ts
deleted file mode 100644
index 5ec9ce7b..00000000
--- a/webserver/webserver.ts
+++ /dev/null
@@ -1,58 +0,0 @@
-import { Logger } from "../logging/logger.ts";
-import { Router } from "./routing/router.ts";
-
-export class Webserver {
- private server: any = null;
- private port: number = 0;
- private router: Router = new Router();
-
- constructor(port: number = 80) {
- this.port = port;
- }
-
- public async start() {
- // Start listening
- this.server = Deno.listen({ port: this.port });
-
- // Serve connections
- for await (const conn of this.server) {
- this.serve(conn);
- }
- }
-
- private async serve(conn: Deno.Conn) {
- // Upgrade the connection to HTTP
- const httpConn: Deno.HttpConn = Deno.serveHttp(conn);
-
- // Handle each request for this connection
- for await(const request of httpConn) {
- Logger.debug(`Request from "${(conn.remoteAddr as Deno.NetAddr).hostname!}:${(conn.remoteAddr as Deno.NetAddr).port!}": ${request.request.method} | ${request.request.url}`);
- try {
- const routing = await this.router.route(request.request);
- if(!routing || !routing.route) {
- return new Response(
- 'The requested page could not be found.',
- {
- status: 404,
- headers: {
- 'content-type': 'text/plain',
- 'Access-Control-Allow-Origin': '*'
- }
- }
- );
- }
- const response = await this.router.execute({
- route: routing.route,
- body: await this.router.getBody(request.request),
- params: await this.router.getParams(routing.route, routing.path ?? '/'),
- auth: this.router.getAuth(request.request)
- });
- if(!response) throw Error('Response was empty');
- await request.respondWith(response);
- } catch(e) {
- Logger.error(`Could not serve response: ${e.message}`, e.stack);
- await request.respondWith(new Response('Internal server error', {status: 500}));
- }
- }
- }
-}