diff --git a/.github/ISSUE_TEMPLATE/1-feature_request.yml b/.github/ISSUE_TEMPLATE/1-feature_request.yml new file mode 100644 index 00000000..58e95485 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/1-feature_request.yml @@ -0,0 +1,28 @@ +name: Feature Request +description: Suggest an idea for Chomp! +title: 'Suggestion: ' +body: + - type: textarea + attributes: + label: Tell us what problem the feature would solve + description: Provide as much information as possible and please be descriptive. + validations: + required: true + - type: textarea + attributes: + label: Describe the solution you'd like + description: A clear and concise description of what you want to happen. + validations: + required: true + - type: textarea + attributes: + label: Did you consider any alternatives? If so, why is this the best? + description: A clear and concise description of any alternative solutions or features you've considered but why you've picked this one. + validations: + required: true + - type: textarea + attributes: + label: Additional Info + description: If applicable, add any other context or screenshots about the feature request here. + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/2-bug_report.yml b/.github/ISSUE_TEMPLATE/2-bug_report.yml new file mode 100644 index 00000000..85d7379a --- /dev/null +++ b/.github/ISSUE_TEMPLATE/2-bug_report.yml @@ -0,0 +1,46 @@ +name: Bug Report +description: File a bug report. +title: "Bug: " +body: + - type: textarea + id: what-happened + attributes: + label: What happened? + description: Provide as much information as possible and please be descriptive. + validations: + required: true + - type: dropdown + id: runtime + attributes: + label: Runtime + description: What runtime (Node, Deno, etc) are you using? + options: + - Deno + - NodeJS + - Bun + - Other (add the name to the version) + default: 0 + validations: + required: true + - type: input + id: runtime-version + attributes: + label: Runtime Version + description: What version of the runtime are you using? In Deno you could get this through `deno --version`. + placeholder: 1.46.3 + validations: + required: true + - type: textarea + id: logs + attributes: + label: Relevant log output + description: Please copy and paste any relevant log output. This will be automatically formatted into code, so no need for backticks. + render: shell + - type: checkboxes + id: latest + attributes: + label: Latest Release + description: By submitting this issue, you confirm that you are using the latest release when this issue is present. + options: + - label: I confirm that I am running on the latest release. + required: true diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 00000000..0086358d --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1 @@ +blank_issues_enabled: true diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 00000000..c9c8ff9d --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,27 @@ +### What changes are being made + + +- [INSERT CHANGE HERE] + +### What is the context? + + +I made this PR because ... + +### Some checks you should make + + +- [ ] You have no left-over console logs. +- [ ] You have no left-over debuggers. +- [ ] You have no left-over other pieces or code. +- [ ] You have kept the documentation in-sync with the code as much as possible. +- [ ] You have nothing to add after taking a break and coming in cold. + +### Some checks we should make + + +- [ ] Changes have no left-over console logs. +- [ ] Changes have no left-over debuggers. +- [ ] Changes have no left-over "other pieces or code". +- [ ] Changes have kept the documentation in-sync with the code as much as possible. +- [ ] Changes still look good after taking a break and coming in cold. diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 00000000..2143bab1 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,53 @@ +name: Tests +on: + push: + branches: [main] + pull_request: + branches: [main] +jobs: + #chomp-format: + # runs-on: ubuntu-latest + # steps: + # - name: Clone repository + # uses: actions/checkout@v3 + # with: + # submodules: true + # - name: Set up Deno + # uses: denoland/setup-deno@v1 + # with: + # deno-version: "1.40.0" + # - name: Check format + # run: deno fmt --check + #chomp-lint: + # runs-on: ubuntu-latest + # steps: + # - name: Clone repository + # uses: actions/checkout@v3 + # with: + # submodules: true + # - name: Set up Deno + # uses: denoland/setup-deno@v1 + # with: + # deno-version: "1.40.0" + # - name: Check lint + # run: deno lint + chomp-tests: + runs-on: ubuntu-latest + steps: + - name: Clone repository + uses: actions/checkout@v3 + with: + submodules: true + - name: Set up Deno + uses: denoland/setup-deno@v1 + with: + deno-version: "1.40.0" + - name: Run tests generating coverage + run: deno task test:coverage + - name: Generate lcov report + run: deno task coverage > cov.lcov + - name: upload coverage + uses: codecov/codecov-action@v4 + with: + files: ./cov.lcov + token: ${{ secrets.CODECOV_TOKEN }} diff --git a/.gitignore b/.gitignore index 27b77b5c..aa681aae 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,9 @@ # Ignore IDE-specific folders /.idea + +# Ignore reports +/cov +/cov.lcov + +# Ignore docs +/src/docs diff --git a/.run/Deno_ Test.run.xml b/.run/Deno_ Test.run.xml new file mode 100644 index 00000000..9b85a6e0 --- /dev/null +++ b/.run/Deno_ Test.run.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/README.md b/README.md index 379d3717..e5329721 100644 --- a/README.md +++ b/README.md @@ -1,25 +1,66 @@ # Chomp -Library of (arguably) useful Deno classes. -Should work just fine but comes with no warranties whatsoever. + +Library of (arguably) useful stuff. +This library prioritizes "ease of use" over "efficiency". + +Should work just fine but comes with no warranties whatsoever. ## Usage -Add the following to your file: + +Chomp is structured in such a way that you can import just what you need for your app.\ +A good start would be to import the most common things you might use: + ```ts -import * from "https://deno.land/x/chomp/mod.ts"; +import * from "https://deno.land/x/chomp/common.ts"; ``` -That's it! -You can visit the [documentation](https://doc.deno.land/https://deno.land/x/chomp/mod.ts) to see what Chomp is capable off! -Someday I'll write a better usage guide. +This includes (list might not always be up-to-date): + +- [Cache](docs/core/cache.md) +- [Configure](docs/core/configure.md) +- [Logger](docs/logging/logger.md) +- [File](docs/filesystem/file.md) +- [Folder](docs/filesystem/folder.md) +- [CheckSource](docs/utility/check-source.md) + +However, there are many more things included so feel free to explore the [docs](/docs) or [Deno.land](https://doc.deno.land/https://deno.land/x/chomp/mod.ts) +to see what more Chomp is capable off! + +**NOTE**: While you can import `https://deno.land/x/chomp/mod.ts`, I advice against this as it'll load the entire +codebase, including stuff you may not actually be using. + +### Configuration keys + +While Chomp does try to have a lot of "good enough" defaults, sometimes you may want to set things to your own needs. +As a result, some things can be configured by you by adding entries to the Configure. + +| Key | Default Value | Comment | +|-----|---------------|-------------------------------------------------------------------------------------------------------------------------------------| +| `chomp_optimistic_delay` | `'+1 hour'` | Additional time a cache entry may exist for optimistic caching. Uses the `utility/time-string` formats | +| `chomp_couchdb_cache` | `'+1 hour'` | Time a document for `communication/couchdb` will be kept in the cache to improve read times. Uses the `utility/time-string` formats | + | `log_level` | `1.0` | Bitmask for the enabled log levels. | + +### Extensions + +Chomp includes a few "extensions" that _modify JavaScript's built-in prototypes_. +Most of these should be free from interference unless you use other libraries that do this. +You can load extensions by simply including them into your project. + +```ts +import "https://deno.land/x/chomp/extensions/date/is-before.ts"; +``` +## Versioning -## Compatibility -Below a chart indicating for which Deno version this library is built and tested. -Compatibility may be more flexible, however, chances are this library may not work with older of newer versions than indicated. +As of `?.0.0.0-0`, versioning adheres to the following versioning system of `a.b.c.d-e` where: -| Library Version | Deno Version | -|-----------------|--------------| -| 1.1.0 | 1.24.0 | -| 1.0.0 | 1.15.3 | -| 0.0.2 | ??? | -| 0.0.1 | ??? | +- `a`: Some previous behaviour may have changed in a non-backwards compatible fashion (breaking). + - Impact: Serious updates may be required on your end. +- `b`: All previous deprecations were removed (potentially breaking). + - Impact: Nothing if you kept up with deprecations. +- `c`: Deprecations were added in this release. + - Impact: Deprecations may need to be fixed on your end. +- `d`: New feature(s) were added. + - Impact: New goodies for you to use. +- `e`: Small fixes (typo's, bugs, documentation etc.) + - Impact: Generally none. diff --git a/benchmarks/202512220028_hex-encode-std.ts b/benchmarks/202512220028_hex-encode-std.ts new file mode 100644 index 00000000..3efb9220 --- /dev/null +++ b/benchmarks/202512220028_hex-encode-std.ts @@ -0,0 +1,31 @@ +import { encodeHex as std } from "jsr:@std/encoding@1.0.10"; +import { Random } from "../src/security/random.ts"; + +// Generate dataset +const dataset: Uint8Array[] = []; +for(let i = 0; i < 10_000; i++) { + dataset.push(Random.bytes(128)); +} + +/** + * Old hex encoding function + * + * @param input + */ +function old(input: Uint8Array): string { + return [...new Uint8Array(input)].map((x) => x.toString(16).padStart(2, "0")).join(""); +} + + +Deno.bench("Old Method", () => { + for(const entry of dataset) { + old(entry); + } +}); + +Deno.bench("Standard Library", () => { + for(const entry of dataset) { + std(entry); + } +}); + diff --git a/common.ts b/common.ts new file mode 100644 index 00000000..1c381d62 --- /dev/null +++ b/common.ts @@ -0,0 +1,8 @@ +/** + * These are just the exports you'll most commonly use. + * You can view the "docs"-directory to see what else there is! + */ +export { Cache, Configure, Logger } from "./src/core/mod.ts"; +export { File } from "./src/filesystem/file.ts"; +export { Folder } from "./src/filesystem/folder.ts"; +export { CheckSource } from "./src/utility/check-source.ts"; diff --git a/common/configure.ts b/common/configure.ts deleted file mode 100644 index 0e6d9efd..00000000 --- a/common/configure.ts +++ /dev/null @@ -1,123 +0,0 @@ -import { Logger } from "../logging/logger.ts"; - -export class Configure { - private static config = new Map([ - ['error_log', `${Deno.cwd()}/logs/error.log`] - ]); - private static hasLoaded = false; - - /** - * Load our configure date from file - * - * @param force Set to true to force re-loading the configure - * @returns void - */ - public static async load(force = false): Promise { - // Make sure we don't have loaded already - if(Configure.hasLoaded === true && force === false) return; - Logger.info(`Loading data into Configure...`); - - // Make sure our file exists - try { - await Deno.stat(`${Deno.cwd()}/config.json`); - } catch(e) { - Logger.warning(`Could not find file "config.json" at "${Deno.cwd()}". Configure will be empty!`); - Configure.hasLoaded = true; - return; - } - - // Read our JSON - try { - const json = await Deno.readTextFile(`${Deno.cwd()}/config.json`); - const data = JSON.parse(json); - for(const entry of Object.keys(data)) { - Configure.set(entry, data[entry]); - } - } catch(e) { - Logger.error(`Could not load JSON: "${e.message}"`, e.stack); - return; - } - - // Mark configure as loaded - Logger.info(`Finished loading Configure!`); - Configure.hasLoaded = true; - } - - /** - * Obtain the value of a key in the configure - * - * @param key Key to look for - * @param defaultValue Default value to return when no result was found - * @returns any - */ - public static get(key: string, defaultValue: any = null): any { - // Check if the key exists. - // If not: return the default value - // Else: return the value in the Configure - if(!Configure.config.has(key)) return defaultValue; - return Configure.config.get(key); - } - - /** - * Set a configure item - * - * @param key - * @param value - * @returns void - */ - public static set(key: string, value: any): void { - Configure.config.set(key, value); - } - - /** - * Return whether a key exists - * - * @param key - * @returns boolean - */ - public static check(key: string): boolean { - return Configure.config.has(key); - } - - public static consume(key: string, defaultValue: any = null): any { - // Check if the key exists, if not, return the default value - if(!Configure.config.has(key)) return defaultValue; - - // Hack together a reference to our item's value - const ref = [Configure.config.get(key)]; - - // Delete the original item - Configure.config.delete(key); - - // Return the value - return ref[0]; - } - - /** - * Delete a ConfigureItem from the Configure - * - * @param key - * @returns void - */ - public static delete(key: string): void { - Configure.config.delete(key); - } - - /** - * Dump all contents of the Configure - * - * @returns ConfigureItem[] - */ - public static dump(): Map { - return Configure.config; - } - - /** - * Clear all items in the configure (including defaults) - * - * @returns void - */ - public static clear(): void { - Configure.config.clear(); - } -} diff --git a/common/cron.ts b/common/cron.ts deleted file mode 100644 index 2753eaa4..00000000 --- a/common/cron.ts +++ /dev/null @@ -1,140 +0,0 @@ -import { Time } from './time.ts'; - -type JobType = () => void; - -enum TIME_PART { - SECOND = 'SECOND', - MINUTE = 'MINUTE', - HOUR = 'HOUR', - DAY_OF_WEEK = 'DAY_OF_WEEK', - DAY_OF_MONTH = 'DAY_OF_MONTH', - MONTH = 'MONTH', -} - -const schedules = new Map>(); - -let schedulerTimeIntervalID: ReturnType = 0; -let shouldStopRunningScheduler = false; - -export const cron = (schedule: string = '', job: JobType) => { - let jobs = schedules.has(schedule) - ? [...(schedules.get(schedule) || []), job] - : [job]; - schedules.set(schedule, jobs); -}; - -const isRange = (text: string) => /^\d\d?\-\d\d?$/.test(text); - -const getRange = (min: number, max: number) => { - const numRange = []; - let lowerBound = min; - while (lowerBound <= max) { - numRange.push(lowerBound); - lowerBound += 1; - } - return numRange; -}; - -const { DAY_OF_MONTH, DAY_OF_WEEK, HOUR, MINUTE, MONTH, SECOND } = TIME_PART; - -const getTimePart = (date: Date, type: TIME_PART): number => - ({ - [SECOND]: date.getSeconds(), - [MINUTE]: date.getMinutes(), - [HOUR]: date.getHours(), - [MONTH]: date.getMonth() + 1, - [DAY_OF_WEEK]: date.getDay(), - [DAY_OF_MONTH]: date.getDate(), - }[type]); - -const isMatched = (date: Date, timeFlag: string, type: TIME_PART): boolean => { - const timePart = getTimePart(date, type); - - if (timeFlag === '*') { - return true; - } else if (Number(timeFlag) === timePart) { - return true; - } else if (timeFlag.includes('/')) { - const [_, executeAt = '1'] = timeFlag.split('/'); - return timePart % Number(executeAt) === 0; - } else if (timeFlag.includes(',')) { - const list = timeFlag.split(',').map((num: string) => parseInt(num)); - return list.includes(timePart); - } else if (isRange(timeFlag)) { - const [start, end] = timeFlag.split('-'); - const list = getRange(parseInt(start), parseInt(end)); - return list.includes(timePart); - } - return false; -}; - -export const validate = (schedule: string, date: Date = new Time().getTime) => { - // @ts-ignore - const timeObj: Record = {}; - - const [ - dayOfWeek, - month, - dayOfMonth, - hour, - minute, - second = '01', - ] = schedule.split(' ').reverse(); - - const cronValues = { - [SECOND]: second, - [MINUTE]: minute, - [HOUR]: hour, - [MONTH]: month, - [DAY_OF_WEEK]: dayOfWeek, - [DAY_OF_MONTH]: dayOfMonth, - }; - - for (const key in cronValues) { - timeObj[key as TIME_PART] = isMatched( - date, - cronValues[key as TIME_PART], - key as TIME_PART, - ); - } - - const didMatch = Object.values(timeObj).every(Boolean); - return { - didMatch, - entries: timeObj, - }; -}; - -const executeJobs = () => { - const date = new Time().getTime; - schedules.forEach((jobs, schedule) => { - if (validate(schedule, date).didMatch) { - jobs.forEach((job) => { - job() - }); - } - }); -}; - -const runScheduler = () => { - schedulerTimeIntervalID = setInterval(() => { - if (shouldStopRunningScheduler) { - clearInterval(schedulerTimeIntervalID); - return; - } - executeJobs(); - }, 1000); -}; - -export const start = () => { - if (shouldStopRunningScheduler) { - shouldStopRunningScheduler = false; - runScheduler(); - } -}; - -export const stop = () => { - shouldStopRunningScheduler = true; -}; - -runScheduler(); diff --git a/common/env.ts b/common/env.ts deleted file mode 100644 index 912e1e9d..00000000 --- a/common/env.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { Logger } from "../logging/logger.ts"; -export { config as env } from "https://deno.land/x/dotenv@v3.2.0/mod.ts"; -Logger.warning("Usage of \"deno-lib/common/env.ts\" is deprecated and may be removed soon, please use \"deno-lib/common/configure.ts\" instead."); diff --git a/common/time.ts b/common/time.ts deleted file mode 100644 index a1376bee..00000000 --- a/common/time.ts +++ /dev/null @@ -1,44 +0,0 @@ -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 { T } from "../util/time-string.ts"; - -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) { - this.time = timets(time).tz(Deno.env.get('TZ')!).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() + T`${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/communication/druid.ts b/communication/druid.ts deleted file mode 100644 index af50ee03..00000000 --- a/communication/druid.ts +++ /dev/null @@ -1,26 +0,0 @@ -export class Druid { - private spec: any = null; - public set setSpec(spec: any) { this.spec = spec; } - public get getSpec() { return this.spec; } - - public constructor( - private readonly host: string, - ) { - } - - /** - * Create a new task in Apache Druid - * - * @returns Promise - */ - public async create(): Promise { - if(!this.spec) throw Error('No task specification has been set!'); - return await fetch(`${this.host}/druid/indexer/v1/task`, { - method: 'POST', - body: this.spec, - headers: { - 'Content-Type': 'application/json' - } - }); - } -} diff --git a/communication/redis.ts b/communication/redis.ts deleted file mode 100644 index 93b59d67..00000000 --- a/communication/redis.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { connect as redisConnect } from "https://deno.land/x/redis@v0.25.2/mod.ts" -import { Logger } from "../logging/logger.ts"; - -export class Redis { - private static connection: any = null; - - /** - * Connect to a Redis node - * - * @param hostname - * @param port - * @returns Promise - */ - public static async connect(hostname = '127.0.0.1', port = 6379): Promise { - Redis.connection = await redisConnect({ - hostname: hostname, - port: port - }); - } - - /** - * Return the redis connection - * TODO: Find out type of Redis.connection - * - * @return any - */ - public static getConnection(): any { - if(!Redis.connection) Logger.error(`Redis connection requested before connecting!`); - return Redis.connection; - } -} diff --git a/deno.json b/deno.json new file mode 100644 index 00000000..54fb79bc --- /dev/null +++ b/deno.json @@ -0,0 +1,21 @@ +{ + "fmt": { + "useTabs": false, + "singleQuote": false, + "semiColons": true, + "indentWidth": 2, + "lineWidth": 120, + "exclude": [".github", ".idea", "docs", "README.md"] + }, + "lint": { + "exclude": [".github", ".idea", "docs"], + "rules": { + "exclude": ["no-inferrable-types"] + } + }, + "tasks": { + "coverage": "deno coverage --lcov ./cov", + "test": "deno test --allow-read", + "test:coverage": "deno test --coverage=./cov --allow-read" + } +} diff --git a/deno.lock b/deno.lock new file mode 100644 index 00000000..9a40fec9 --- /dev/null +++ b/deno.lock @@ -0,0 +1,425 @@ +{ + "version": "3", + "remote": { + "https://cdn.deno.land/std/versions/0.77.0/raw/datetime/formatter.ts": "2862d48a44d2a307795deaeb913773c62704e79e3a21b76f11443f2a8650de32", + "https://cdn.deno.land/std/versions/0.77.0/raw/datetime/mod.ts": "5d31444e61524f399ac17aa75401b7e47a7b2f6ccaa36575416a1253a8f61afa", + "https://cdn.deno.land/std/versions/0.77.0/raw/datetime/tokenizer.ts": "ae21a459f2f017ac81b1b49caa81174b6b8ab8a4d8d82195dcf25bb67b565c71", + "https://deno.land/std@0.117.0/fmt/colors.ts": "8368ddf2d48dfe413ffd04cdbb7ae6a1009cf0dccc9c7ff1d76259d9c61a0621", + "https://deno.land/std@0.152.0/fmt/colors.ts": "6f9340b7fb8cc25a993a99e5efc56fe81bb5af284ff412129dd06df06f53c0b4", + "https://deno.land/std@0.152.0/testing/_diff.ts": "029a00560b0d534bc0046f1bce4bd36b3b41ada3f2a3178c85686eb2ff5f1413", + "https://deno.land/std@0.152.0/testing/_format.ts": "0d8dc79eab15b67cdc532826213bbe05bccfd276ca473a50a3fc7bbfb7260642", + "https://deno.land/std@0.152.0/testing/asserts.ts": "093735c88f52bbead7f60a1f7a97a2ce4df3c2d5fab00a46956f20b4a5793ccd", + "https://deno.land/std@0.159.0/fmt/colors.ts": "ff7dc9c9f33a72bd48bc24b21bbc1b4545d8494a431f17894dbc5fe92a938fc4", + "https://deno.land/std@0.159.0/testing/_diff.ts": "a23e7fc2b4d8daa3e158fa06856bedf5334ce2a2831e8bf9e509717f455adb2c", + "https://deno.land/std@0.159.0/testing/_format.ts": "cd11136e1797791045e639e9f0f4640d5b4166148796cad37e6ef75f7d7f3832", + "https://deno.land/std@0.159.0/testing/asserts.ts": "9ff3259f6cdc2908af478f9340f4e470d23234324bd33e7f74c683a00ed4d211", + "https://deno.land/x/croner@5.3.4/src/croner.js": "a7e06cd5c262c60bc9736d735eb65ae2e401eed3d965c467087a5a575abd8ec2", + "https://deno.land/x/croner@5.3.4/src/date.js": "e5bfdf17750207a00e1399c50bda7be8746429619c9f2fc0679c678aed22febc", + "https://deno.land/x/croner@5.3.4/src/helpers/minitz.js": "8b3824fadd0130b4faf38335a064febdf77c5582dcaa06f443b901e9b8233130", + "https://deno.land/x/croner@5.3.4/src/options.js": "1c6fc9851d195cc94fdd06f947270aa25939eb34e47e03cb5e34f5451926f632", + "https://deno.land/x/croner@5.3.4/src/pattern.js": "e37c2047f04ba80311e5c1ea3764c10410760ad7ab40f669648a9533635982b5", + "https://deno.land/x/discordeno@18.0.0/bot.ts": "b6c4f1c966f1a968186921619b6e5ebfec7c5eb0dc2e49a66d2c86b37cb2acc7", + "https://deno.land/x/discordeno@18.0.0/gateway/manager/calculateTotalShards.ts": "2d2ebe860861d58524416446426d78e5b881c17b3a565ea4822c67f5534214bc", + "https://deno.land/x/discordeno@18.0.0/gateway/manager/calculateWorkerId.ts": "44c46f2977104a5f92cc21cf31d6b2bc5dcfcefba23495cd619dbdf074a00af1", + "https://deno.land/x/discordeno@18.0.0/gateway/manager/gatewayManager.ts": "d82dedc56ef044e1aff108400cad647f3d5c6eb5b574e7ed7b812dc85a260d7b", + "https://deno.land/x/discordeno@18.0.0/gateway/manager/mod.ts": "11f0721de12ab2d923320d877673f617bb77a2222452dd284bf4aff66df25674", + "https://deno.land/x/discordeno@18.0.0/gateway/manager/prepareBuckets.ts": "a92b60fbcf7fb67051504f568932db525e29a6e7c202216ca8583d60ecb8ac11", + "https://deno.land/x/discordeno@18.0.0/gateway/manager/shardManager.ts": "6cfaeae1f367d7978f7b6b505066755a76a29bc46af8643fd5393d757d807fef", + "https://deno.land/x/discordeno@18.0.0/gateway/manager/spawnShards.ts": "687163c5a8d5f057864a9b2803df2b664ae5fef9301718925edc81a4397fbe8d", + "https://deno.land/x/discordeno@18.0.0/gateway/manager/stop.ts": "448cb12cc5f5653bca13fe6fb7b1dfb1da7524c60efab80a4f71233ee76b173e", + "https://deno.land/x/discordeno@18.0.0/gateway/manager/tellWorkerToIdentify.ts": "453ed3d92a6ae23438b4e85c735ed751d89888fa05e36b5d0e43dab73f6fe1e9", + "https://deno.land/x/discordeno@18.0.0/gateway/mod.ts": "d884e34fb4e3e39a7925e0f160af33fad57c9012fa36a5de3e0e5f23600e8aa4", + "https://deno.land/x/discordeno@18.0.0/gateway/shard/calculateSafeRequests.ts": "f70e9eec53db888ae0a2afee4073743d883acd58de153f17298dfbe082136ae7", + "https://deno.land/x/discordeno@18.0.0/gateway/shard/close.ts": "e2e7bc1435c0c13a6e17491463f1b755a81d87b6291711f27ad8b9a89215a758", + "https://deno.land/x/discordeno@18.0.0/gateway/shard/connect.ts": "a774fd7d538daccfde01bad3a91b5ec5e0000f0143a3d81cfcaf78f3174ca6eb", + "https://deno.land/x/discordeno@18.0.0/gateway/shard/createShard.ts": "6cf8c45bd1e2c1c1c532be2353f7f590249d6d541f897fe9c9f4634749bfd232", + "https://deno.land/x/discordeno@18.0.0/gateway/shard/deps.ts": "e96984eb90ac1d22f9a6f215d0263d87fc758ca04d6fd03ba519d8196f347354", + "https://deno.land/x/discordeno@18.0.0/gateway/shard/handleClose.ts": "7a93f5dc12236504b99464876d18ebaf8e0472f942f2c571ea0e8913826a3c11", + "https://deno.land/x/discordeno@18.0.0/gateway/shard/handleMessage.ts": "145682505b8ccb4441f7305a3c8b2c1bbc937e7904e4762f58f6fd0d438f6805", + "https://deno.land/x/discordeno@18.0.0/gateway/shard/identify.ts": "bf82e87e70b588d747f10700fcc614ae072adbc008549171729e85112f1ce012", + "https://deno.land/x/discordeno@18.0.0/gateway/shard/isOpen.ts": "abb14f52b50a523d56678be9b5b901698604d8c791aa63078ef51ae88f02bd00", + "https://deno.land/x/discordeno@18.0.0/gateway/shard/mod.ts": "8a58a564317d9f84bc11a492b6b6ad857a6f3a652e46c6ad6a4c2d0e0edb369d", + "https://deno.land/x/discordeno@18.0.0/gateway/shard/resume.ts": "4713f3c76b5cb9d46a5e839532f0332f530cd2aa035a580f01b8478a3858237b", + "https://deno.land/x/discordeno@18.0.0/gateway/shard/send.ts": "653158bc0522651962f4b2b3a2b9e02c2fbb18540d5432b2f5c1007d160c989a", + "https://deno.land/x/discordeno@18.0.0/gateway/shard/shutdown.ts": "6d9a7479754bac8021a80766b9b73930af52bc22e38e15569346ea3a6315d9c3", + "https://deno.land/x/discordeno@18.0.0/gateway/shard/startHeartbeating.ts": "cbb0bff61f3fb617ea11189e76233e085baa2ec952c66ed9bfa1896f871f01e5", + "https://deno.land/x/discordeno@18.0.0/gateway/shard/stopHeartbeating.ts": "e7b0feb488029720dc21ba5183d0594263b58e2a573af47aaeda95dbbf378364", + "https://deno.land/x/discordeno@18.0.0/gateway/shard/types.ts": "28b4dbb06a3af12919136ebdac748d14cd0fd7a850a6a5968d383449f2de5b98", + "https://deno.land/x/discordeno@18.0.0/handlers/channels/CHANNEL_CREATE.ts": "3b31e183076d49b5f6a005b7b976e14139f3ca52d69659f6fb68039b80e9ac96", + "https://deno.land/x/discordeno@18.0.0/handlers/channels/CHANNEL_DELETE.ts": "655ce49f189016768a10eaeca26835292bc8336d37fcf495013836d3ce557505", + "https://deno.land/x/discordeno@18.0.0/handlers/channels/CHANNEL_PINS_UPDATE.ts": "580a8dd76b052f00a1b8b8e8cdd0ab5eed603cd8372987fd0e7f37710b933a23", + "https://deno.land/x/discordeno@18.0.0/handlers/channels/CHANNEL_UPDATE.ts": "56e716355c503e2877cb1413be331e34fec36d6487fb542840eb7d5db1b79b41", + "https://deno.land/x/discordeno@18.0.0/handlers/channels/STAGE_INSTANCE_CREATE.ts": "d8051cd818ab569b53617dda0e77b12a4a4d0c8c7b8ffee620f0ed9900d06eb1", + "https://deno.land/x/discordeno@18.0.0/handlers/channels/STAGE_INSTANCE_DELETE.ts": "f8fccae29afec36c9cf0ec86e9d8e3aab06e3fbd2d19eec00dc939d37528e305", + "https://deno.land/x/discordeno@18.0.0/handlers/channels/STAGE_INSTANCE_UPDATE.ts": "297f0f5604e429d28db558b93373f912f1ea636dd7cbdbccbd5a9f4e6ceaf9d4", + "https://deno.land/x/discordeno@18.0.0/handlers/channels/THREAD_CREATE.ts": "c7f2190b84255def74cde520c9743ee24cdb68804567abf0db8b75e8a0b4c228", + "https://deno.land/x/discordeno@18.0.0/handlers/channels/THREAD_DELETE.ts": "6d608c49a4d4bc3618c3a01f0bce0b2b87ddd0f5e8696612ec6974c6dc7a0960", + "https://deno.land/x/discordeno@18.0.0/handlers/channels/THREAD_LIST_SYNC.ts": "d3af47b41e9e0c648861f4b85e3a0933c180fbb6ee32af983288f47ef982b36d", + "https://deno.land/x/discordeno@18.0.0/handlers/channels/THREAD_MEMBERS_UPDATE.ts": "12d75e4296535f9af9cfe2ec527c35b144cb6ebc01df87b017fe8680cf5b6588", + "https://deno.land/x/discordeno@18.0.0/handlers/channels/THREAD_UPDATE.ts": "82ffae3c6e031c1485700872caafd29367401bf952c99d7207234e3f3bf8517d", + "https://deno.land/x/discordeno@18.0.0/handlers/channels/mod.ts": "91e2381859886ed05029d57e9ce0d11c6499fe86a1aac04c4c6744fb29bdecc9", + "https://deno.land/x/discordeno@18.0.0/handlers/emojis/GUILD_EMOJIS_UPDATE.ts": "3a86ea7a35ce09ca4e21d0d712c61d129687eb5a44a92b2109f5d4847c0a1daa", + "https://deno.land/x/discordeno@18.0.0/handlers/emojis/mod.ts": "fb22fde276e903e6242ca89d97b59aaed6f60e6839f6444913acaf3f389f0571", + "https://deno.land/x/discordeno@18.0.0/handlers/guilds/GUILD_AUDIT_LOG_ENTRY_CREATE.ts": "d610cebc5709a6b8d7776e3137a6d500fd9b2e0186ae5aa4a5e59429039f8866", + "https://deno.land/x/discordeno@18.0.0/handlers/guilds/GUILD_BAN_ADD.ts": "cba1779e0770128cb350a51b44ed89db4ccb919cf6d67ed8d760e7935932230f", + "https://deno.land/x/discordeno@18.0.0/handlers/guilds/GUILD_BAN_REMOVE.ts": "03a4821425d938f76cf7296d860cb97f49ff045c171fd19c4278ef64434a6d74", + "https://deno.land/x/discordeno@18.0.0/handlers/guilds/GUILD_CREATE.ts": "9e7391ea65653117a8115fdfbbad0a9f4d07fc48e4b078313dcf0c4909c7ef77", + "https://deno.land/x/discordeno@18.0.0/handlers/guilds/GUILD_DELETE.ts": "1ba57603c0ddb3d50b90acddc1148100f5acc9f6c4b52f37b5b5ad8bda469e99", + "https://deno.land/x/discordeno@18.0.0/handlers/guilds/GUILD_INTEGRATIONS_UPDATE.ts": "fdac5b8ccd75f9a57a27731107d7a75a933b9b4ba33e0c824f118592fd691654", + "https://deno.land/x/discordeno@18.0.0/handlers/guilds/GUILD_UPDATE.ts": "164fb507ce4544979966d90ee2cbf3c99c7abcabd6ab76fbafec80f15ee37b9a", + "https://deno.land/x/discordeno@18.0.0/handlers/guilds/mod.ts": "34450dcd3f62143a6211ec5096eb4e52e17f770a5cc1670b1bc89c62ade87473", + "https://deno.land/x/discordeno@18.0.0/handlers/guilds/scheduledEvents/GUILD_SCHEDULED_EVENT_CREATE.ts": "aed7232fa7bc1e4dc0d31b3484aabdc9dea09fc7fd06dafb2ba8fee050613adc", + "https://deno.land/x/discordeno@18.0.0/handlers/guilds/scheduledEvents/GUILD_SCHEDULED_EVENT_DELETE.ts": "e7965fb15e87add066d5dddb4ca5c7dd9e44d3ca268571259cd09c137a555e74", + "https://deno.land/x/discordeno@18.0.0/handlers/guilds/scheduledEvents/GUILD_SCHEDULED_EVENT_UPDATE.ts": "c63d7073c5b2433afae545baf824931272b3ba2b76b79b35e23482bf1d9d1590", + "https://deno.land/x/discordeno@18.0.0/handlers/guilds/scheduledEvents/GUILD_SCHEDULED_EVENT_USER_ADD.ts": "f8f50e9c50e4c0e05ea6e49bef6db4423c01ef23b5029041ba6d816d0afdf9af", + "https://deno.land/x/discordeno@18.0.0/handlers/guilds/scheduledEvents/GUILD_SCHEDULED_EVENT_USER_REMOVE.ts": "7cc8e0eef7336e1ea4774204ce9b375db5ecfeaeed4c2e50bae10c5910b9d70c", + "https://deno.land/x/discordeno@18.0.0/handlers/guilds/scheduledEvents/mod.ts": "f0a28cd8b0fcf0909ad2b027234b91edcba97b05135de4b49637143302170af3", + "https://deno.land/x/discordeno@18.0.0/handlers/integrations/INTEGRATION_CREATE.ts": "2d6d4d2d01bab786191297f2c7edd24173263b8de550d8bb190c41d523611629", + "https://deno.land/x/discordeno@18.0.0/handlers/integrations/INTEGRATION_DELETE.ts": "bb87b79956bd353018836714fb8c60966b74518d19fec821b17ca2371afcd7d0", + "https://deno.land/x/discordeno@18.0.0/handlers/integrations/INTEGRATION_UPDATE.ts": "be74f47ab9258c4a30d5054877be7b0ee73648214972ba40cd9f7189e4cf5b07", + "https://deno.land/x/discordeno@18.0.0/handlers/integrations/mod.ts": "6b7588d8963c0f98cbccb07ae1b6043de50a3528784f2fa3d4fc6aa70e7573b7", + "https://deno.land/x/discordeno@18.0.0/handlers/interactions/INTERACTION_CREATE.ts": "081f55732d8d86ee420804523aba5c674589af5eb54ce25e5a22239e433be278", + "https://deno.land/x/discordeno@18.0.0/handlers/interactions/mod.ts": "57005c5079d1cbd6e9f93bfe60e4c97829651884f7d0bb5de46642d8568ddca3", + "https://deno.land/x/discordeno@18.0.0/handlers/invites/INVITE_CREATE.ts": "c0435a2a60ef473ac212abc12b97ee84001a6b67c47c646f69f30b69269dcd24", + "https://deno.land/x/discordeno@18.0.0/handlers/invites/INVITE_DELETE.ts": "c7f3b3e28a90acca37835b1c8f89cb96e2d227785941c2de789fd45515a15dad", + "https://deno.land/x/discordeno@18.0.0/handlers/invites/mod.ts": "b226b3e4f5b16147adfbf9660228f0d353e5a8950eb296357e90659ea7230d7a", + "https://deno.land/x/discordeno@18.0.0/handlers/members/GUILD_MEMBERS_CHUNK.ts": "36661ca5c2d12b9f9ae87d3a797a52a0f30ac4017390c066c5f1645cf74165b0", + "https://deno.land/x/discordeno@18.0.0/handlers/members/GUILD_MEMBER_ADD.ts": "4fcc0daed4c1e9e9ca91c778db7cfb759ec499e6af444d1b5a593e985d0f8afa", + "https://deno.land/x/discordeno@18.0.0/handlers/members/GUILD_MEMBER_REMOVE.ts": "608eb5d110e7d8f36497a9ecf14a990358896879655c678d95f7ff52a30b1dd1", + "https://deno.land/x/discordeno@18.0.0/handlers/members/GUILD_MEMBER_UPDATE.ts": "645406a06ee71fa7cf9c712fb310eb5f8cc16ebbec2c8f88e3b5721237e2f201", + "https://deno.land/x/discordeno@18.0.0/handlers/members/mod.ts": "77976a7904c7ab820c029d76debcc2453dd884a55573884d0f488474b7a88ee6", + "https://deno.land/x/discordeno@18.0.0/handlers/messages/MESSAGE_CREATE.ts": "15ce7ba5d7ed42de886471fa6a92dd9f8a0cf2cf5b3e5c5b0660814b8663d364", + "https://deno.land/x/discordeno@18.0.0/handlers/messages/MESSAGE_DELETE.ts": "bf24f87388b055d0ccfe9169f23e1788d6788f540ff5cb631b295bbe213bfe1c", + "https://deno.land/x/discordeno@18.0.0/handlers/messages/MESSAGE_DELETE_BULK.ts": "397f72939f3d2d5b9456e9318fafe241e421c41de83d408f2f052f941a0517ef", + "https://deno.land/x/discordeno@18.0.0/handlers/messages/MESSAGE_REACTION_ADD.ts": "2a335d2c6a89342d124e7ca0905b667afd194ab42294ef52df23d28a687e739e", + "https://deno.land/x/discordeno@18.0.0/handlers/messages/MESSAGE_REACTION_REMOVE.ts": "f8f7cc7886bb08e2baa4a77b42f1139cf4af4c4d32cdfb2962521f24b25d6fe8", + "https://deno.land/x/discordeno@18.0.0/handlers/messages/MESSAGE_REACTION_REMOVE_ALL.ts": "cac06a4d738e86ec72a2fbb83402982d7fb9eec396e3a4383a53b190f911bd44", + "https://deno.land/x/discordeno@18.0.0/handlers/messages/MESSAGE_REACTION_REMOVE_EMOJI.ts": "4241f53d9f5eab021bc976a2749fae5799bbd4ae1a59e70f5dc2e920fa4b718c", + "https://deno.land/x/discordeno@18.0.0/handlers/messages/MESSAGE_UPDATE.ts": "2093206a9c22eff287b6a8989fc3d291dac7791d11670c68d0a6adcce70efb25", + "https://deno.land/x/discordeno@18.0.0/handlers/messages/mod.ts": "9b760a5a32f2491464ebd5eef55a88c0f214488dc641bfefe3c65816b8a1c87d", + "https://deno.land/x/discordeno@18.0.0/handlers/misc/PRESENCE_UPDATE.ts": "19bc72ded73e938973a47e2010fa88ed257682b7b40f4ff9272908b6a92dccc5", + "https://deno.land/x/discordeno@18.0.0/handlers/misc/READY.ts": "954f0ed0a292c7c8eba8320cc632345e02df24cfaf4ec14ca832bd35c41153ea", + "https://deno.land/x/discordeno@18.0.0/handlers/misc/TYPING_START.ts": "9a5b4dcd9a8dbd08a17f8d884a5ea2d7df3d7134ef0350aef39cd10763579f3a", + "https://deno.land/x/discordeno@18.0.0/handlers/misc/USER_UPDATE.ts": "964f4b51c003be4c09cbda1887fdd1c0c841e852d6f95377e62b703c4bf47cf8", + "https://deno.land/x/discordeno@18.0.0/handlers/misc/mod.ts": "9b60823b169254e472c7f57075e15bee29d32627c123a9037bdded0aa2bdb6d4", + "https://deno.land/x/discordeno@18.0.0/handlers/mod.ts": "b7616067cc062fd4a903dd95a0622cb4f253932955bffc5e7f96d3e039d34156", + "https://deno.land/x/discordeno@18.0.0/handlers/roles/GUILD_ROLE_CREATE.ts": "0cedfdda9fccec57eeadf0c26042e612f820a6315cfada72fc5d16ac71f9e6a6", + "https://deno.land/x/discordeno@18.0.0/handlers/roles/GUILD_ROLE_DELETE.ts": "47bab68f49cf9941edd62b7007b34c0beb575a4d896123761129da2b558fd98d", + "https://deno.land/x/discordeno@18.0.0/handlers/roles/GUILD_ROLE_UPDATE.ts": "a3fd5443422949245fb24023205302ead102484822d1fc17eb21638abc232e74", + "https://deno.land/x/discordeno@18.0.0/handlers/roles/mod.ts": "04731e168f2c896cfca5b0ce9a47679eae3dbdfed9d3c78f1f3f7c4e59b58b4c", + "https://deno.land/x/discordeno@18.0.0/handlers/voice/VOICE_SERVER_UPDATE.ts": "b18886db601ee02e58737f175f335f286ef3f0de558e4465985e7136761cd23b", + "https://deno.land/x/discordeno@18.0.0/handlers/voice/VOICE_STATE_UPDATE.ts": "2b52f6d1928df4099cc03a0f0946fcbdc18df8c17098dbb72ef70258b9ffae2a", + "https://deno.land/x/discordeno@18.0.0/handlers/voice/mod.ts": "5fbf23ba2bf6f921b2f72a660dbf84bd7c10c2d17f26bc50bda366c5218fdb9c", + "https://deno.land/x/discordeno@18.0.0/handlers/webhooks/WEBHOOKS_UPDATE.ts": "d995a409e18c6a209de0b0734373288d57dd1341fa461f04c97f574b3e05ccc9", + "https://deno.land/x/discordeno@18.0.0/handlers/webhooks/mod.ts": "7b974b6515cb6701be25eccb1e4f8c403fe2d925628df43dd356bd7bed7f8a6f", + "https://deno.land/x/discordeno@18.0.0/helpers/channels/announcements/followAnnouncementChannel.ts": "aae23b70be826238b07d612e1d57d78a498849a2428111969bde9554e31aab3b", + "https://deno.land/x/discordeno@18.0.0/helpers/channels/announcements/mod.ts": "948f4ee5a341dae0094636736c7e05f60f559c6168b60b162cb4f5554d2cb4fe", + "https://deno.land/x/discordeno@18.0.0/helpers/channels/createChannel.ts": "4c64c3847ea5241b384ef9ef1146739fba04857967ef8a1e49e2e937455a5d01", + "https://deno.land/x/discordeno@18.0.0/helpers/channels/deleteChannel.ts": "810b6476c2a1825c069832b0c56443677299c9b097d20306cfd46164afc7e15c", + "https://deno.land/x/discordeno@18.0.0/helpers/channels/deleteChannelPermissionOverride.ts": "4b82b8ea40bdd27f8cbc6064879c03cc5cc85894b4d4d71c362653921f5e98a2", + "https://deno.land/x/discordeno@18.0.0/helpers/channels/editChannel.ts": "e8bb361983e7cc7d3514c01c6e91ee4bb1c655b8afd2d32af04830138a8a2a9c", + "https://deno.land/x/discordeno@18.0.0/helpers/channels/editChannelPermissionOverrides.ts": "61c8f99bc6f54cb7424be49b6e61431ae2b3cb1892ba2caa68780be7eefd5b82", + "https://deno.land/x/discordeno@18.0.0/helpers/channels/editChannelPositions.ts": "368f7a517b00a23db8dd04d83fcf47b54e630256d3886b4c09c8324c66f10e2a", + "https://deno.land/x/discordeno@18.0.0/helpers/channels/forums/createForumThread.ts": "19a407d69fee54c0566bdd604bf20d9ed0bae81562e4db20d14fd9c73ffc70d3", + "https://deno.land/x/discordeno@18.0.0/helpers/channels/forums/mod.ts": "b969a6eb5f97199f5efd69915114bcf9ebdd54c7c1c28f67a7575d3d92c73bbc", + "https://deno.land/x/discordeno@18.0.0/helpers/channels/getChannel.ts": "cc65b4ac424a5124671d902362c40bed3e9e83070c2a398c9c48328a1215948b", + "https://deno.land/x/discordeno@18.0.0/helpers/channels/getChannelInvites.ts": "175de2476805e1908c8444c6bfad496487ec53fe67e637cb882c6d7264d0c5ac", + "https://deno.land/x/discordeno@18.0.0/helpers/channels/getChannels.ts": "dde4d5af0c2ac2d0d8189dfb273ce20a6b27f1dda1ecd13ebad575811e1b03bc", + "https://deno.land/x/discordeno@18.0.0/helpers/channels/mod.ts": "1aac0206e6393e64763eacda9fc98fa1ab7013841b43e9e082e76fe9cef29754", + "https://deno.land/x/discordeno@18.0.0/helpers/channels/stages/createStageInstance.ts": "a4b8e5f4f91c49b902b79b693b8433ec87116640ea5ac50930639c0c5159caba", + "https://deno.land/x/discordeno@18.0.0/helpers/channels/stages/deleteStageInstance.ts": "a85ee26a96352bbf9547437139efc8f73602fd36f7fca5fe70c8a9d8efa19148", + "https://deno.land/x/discordeno@18.0.0/helpers/channels/stages/editStageInstance.ts": "3f5867db229d1d4fdaea58191dd4badf2769dcea786b8797371087c5267afd08", + "https://deno.land/x/discordeno@18.0.0/helpers/channels/stages/getStageInstance.ts": "5b0e0df81d5607bbfcd954b8d6be3fc50f42d2ee5fc50dffb1b0f3235f52e596", + "https://deno.land/x/discordeno@18.0.0/helpers/channels/stages/mod.ts": "1a57f96eadaab12dbb1cea18a6e52fcabea2e8771ae8eed17650702b416fafc1", + "https://deno.land/x/discordeno@18.0.0/helpers/channels/threads/addThreadMember.ts": "d7b9bcc230dee323686aaa4ee59f9aeb7d38851127a939ef9ec36304934fdaa8", + "https://deno.land/x/discordeno@18.0.0/helpers/channels/threads/getActiveThreads.ts": "3c97a8bcf1a9fe2e804964540fbb9e108820f6fcc18663de136d5c82e4b7dbf6", + "https://deno.land/x/discordeno@18.0.0/helpers/channels/threads/getPrivateArchivedThreads.ts": "8f7a46421897e07f1397d01d56a48c1e01e082151c7127e9d749668c7dab3aa2", + "https://deno.land/x/discordeno@18.0.0/helpers/channels/threads/getPrivateJoinedArchivedThreads.ts": "caa8c91e9999f79c0cceaaeb641066216db649faae8958de8e5e8a7c203159ff", + "https://deno.land/x/discordeno@18.0.0/helpers/channels/threads/getPublicArchivedThreads.ts": "09e6729950816e726db852c83412d1b1275bf44b7f552f203c8e5c304003ec5b", + "https://deno.land/x/discordeno@18.0.0/helpers/channels/threads/getThreadMember.ts": "f2b80396f2272fd155a9ce92e5faf4ec454da97a4ad9a152816da155dd59e899", + "https://deno.land/x/discordeno@18.0.0/helpers/channels/threads/getThreadMembers.ts": "5e7f26a59bd037e2aa0c85e2d0e14f5cf9480bac470c46bbe30dd158344d8515", + "https://deno.land/x/discordeno@18.0.0/helpers/channels/threads/joinThread.ts": "52edec63af84954786f55c1916d2ce8904dcac51d3416cc448d61032ca0dc9d8", + "https://deno.land/x/discordeno@18.0.0/helpers/channels/threads/leaveThread.ts": "6f868e87b599e7c6e26eadc0d5ccd5d95e628998b16bbe8e4c7348d6565a5c05", + "https://deno.land/x/discordeno@18.0.0/helpers/channels/threads/mod.ts": "70a936adea1c2d1da88bfa92da75aeae59f8f5a382f8de8682fc1cb1e9ce5140", + "https://deno.land/x/discordeno@18.0.0/helpers/channels/threads/removeThreadMember.ts": "528b689ab05403073d8389ad0aed4576f3e86de90e1ca8794ee1681ae7acac28", + "https://deno.land/x/discordeno@18.0.0/helpers/channels/threads/startThreadWithMessage.ts": "d8865cee8b6f786c82f1a402ebcac2b58ca1ff399b8419530a6580667088aa2f", + "https://deno.land/x/discordeno@18.0.0/helpers/channels/threads/startThreadWithoutMessage.ts": "b5fab248fa8075e58c8518c109518ff07a4915fedee05e29593a64431721de38", + "https://deno.land/x/discordeno@18.0.0/helpers/channels/triggerTypingIndicator.ts": "34f06f635c12548629fc79c6348dd09a713385a4aa07fcd09749e1fbd93fa7bb", + "https://deno.land/x/discordeno@18.0.0/helpers/emojis/createEmoji.ts": "fc6718b1f19d70e0eef9e48a3862f5feadeab42caa2504426789dbf6103d461f", + "https://deno.land/x/discordeno@18.0.0/helpers/emojis/deleteEmoji.ts": "d212193fcd34ea47d437870a9ca36f454698ab5551e482b952260abecf1a856d", + "https://deno.land/x/discordeno@18.0.0/helpers/emojis/editEmoji.ts": "5b163a72335ad50fa4345bbcb9e77cb8efdd5a3cebc8a40d8f4c3edf7a003f3f", + "https://deno.land/x/discordeno@18.0.0/helpers/emojis/getEmoji.ts": "4fbe403ddf6069c1ac8c2d65f68f4597917d5499774b4323a3b123cb4c994206", + "https://deno.land/x/discordeno@18.0.0/helpers/emojis/getEmojiUrl.ts": "0d0b8da6275d55228d0144cc93c579fcea51e0c7f502fa21391d3b47eda15761", + "https://deno.land/x/discordeno@18.0.0/helpers/emojis/getEmojis.ts": "776eab9413d0cb8364d7bc0e6b1fdf38b63451a81162ea97784ded0b2bb43a72", + "https://deno.land/x/discordeno@18.0.0/helpers/emojis/mod.ts": "0c9b2e86b392aa3d698702dc04e350b4d35c9173fdf868bb3e3a0dd7d89a30ca", + "https://deno.land/x/discordeno@18.0.0/helpers/guilds/automod/createAutomodRule.ts": "e0f1fe34f92e3fb0c9a6e636e671853bea08132e4963287665486ab99cad710c", + "https://deno.land/x/discordeno@18.0.0/helpers/guilds/automod/deleteAutomodRule.ts": "b5b3262ec3a47e7bfca2418082a005adbd2832a70e77777af5cc558278648f5f", + "https://deno.land/x/discordeno@18.0.0/helpers/guilds/automod/editAutomodRule.ts": "0655662b1a8b030d4a1c51d04507b5fd42a88e74a3d7046befb253c6c73a0ce1", + "https://deno.land/x/discordeno@18.0.0/helpers/guilds/automod/getAutomodRule.ts": "8579fbc14fa7d222c0fb8e0f9a7ddc025870657190b22c63f7f3a187b1c34001", + "https://deno.land/x/discordeno@18.0.0/helpers/guilds/automod/getAutomodRules.ts": "79ac5eb57d140919a115f25edb2c9f2e94e41fcb55839e56781d093aedce0e1d", + "https://deno.land/x/discordeno@18.0.0/helpers/guilds/automod/mod.ts": "5d1eb9f8501d1f9e929025be86028c8714bbd93f5f2a79bd6506596c0c1dbc1e", + "https://deno.land/x/discordeno@18.0.0/helpers/guilds/createGuild.ts": "5361278b053acb5081e79d40f11f975e0ec69e6a2b6f673d5fd708ec3d6c9186", + "https://deno.land/x/discordeno@18.0.0/helpers/guilds/deleteGuild.ts": "5b2d6b581817981528b8be85faeae42f6e098e241fc36e2069374a348f3ec543", + "https://deno.land/x/discordeno@18.0.0/helpers/guilds/editGuild.ts": "78bc0e66d0b953521b0fa2bbc3d6d0459ed2385ed2aafd8bb7cf7529cce5bf35", + "https://deno.land/x/discordeno@18.0.0/helpers/guilds/editGuildMfaLevel.ts": "14b10e70eb731b57ca169656bac2c13d8b97ed7028fc20fcdf2230fd843a09fc", + "https://deno.land/x/discordeno@18.0.0/helpers/guilds/editWelcomeScreen.ts": "dd25c4470acf75149bf18ab34b4b35871920b430ceb709654aeaa6c1b28eec1f", + "https://deno.land/x/discordeno@18.0.0/helpers/guilds/events/createScheduledEvent.ts": "ae526707edc545f98ce23cf951d10a9ef205a64dce391a3669a832d5973de821", + "https://deno.land/x/discordeno@18.0.0/helpers/guilds/events/deleteScheduledEvent.ts": "e556878c5fbfbe0c2c794ef8d9cf2815574a6372a82a58cb8cfa0c75253f2ec8", + "https://deno.land/x/discordeno@18.0.0/helpers/guilds/events/editScheduledEvent.ts": "c0cee76fdcd477efeca2787b7372b8bd21ad8895535878fedca1ae8a137d582f", + "https://deno.land/x/discordeno@18.0.0/helpers/guilds/events/getScheduledEvent.ts": "6d909db7808a8a2f96a824106e79db7aaff392e8c24cd2e47fac9b6538b067d1", + "https://deno.land/x/discordeno@18.0.0/helpers/guilds/events/getScheduledEventUsers.ts": "2bdea83518bd86f64dd0b997de375fd1840d3cb60256f15b8be55f6545c5f7ba", + "https://deno.land/x/discordeno@18.0.0/helpers/guilds/events/getScheduledEvents.ts": "5f98bb604e439690c5d6e5b93e6962255a89f0e3d58292dcdf0c58063c68b976", + "https://deno.land/x/discordeno@18.0.0/helpers/guilds/events/mod.ts": "ae3428a9164ee36a14243900dc4fe699a1b9b85c9e3cc5c073882e957f92db90", + "https://deno.land/x/discordeno@18.0.0/helpers/guilds/getAuditLog.ts": "16cd657d472484ef8eaa6ec520dda8e800203593a2f8cea449276101b0022aca", + "https://deno.land/x/discordeno@18.0.0/helpers/guilds/getBan.ts": "d071e9f0ac8dd372ae777b4b759c79d9b1bd1dabdaf1f1de71ca951cf29c4950", + "https://deno.land/x/discordeno@18.0.0/helpers/guilds/getBans.ts": "45a72714dbcfcec84df81fb8e920d9c373080600365ac7a1e7fa71e888533fa0", + "https://deno.land/x/discordeno@18.0.0/helpers/guilds/getGuild.ts": "2726a90b09e1b20b24e41e0eb53dfef8e2c804a59dc3114adb09b6c4d91ab235", + "https://deno.land/x/discordeno@18.0.0/helpers/guilds/getGuildBannerUrl.ts": "d8f54da9d84a6446ef4d8ff04c4317f16bf139ce663ab2e95655238c17ef9743", + "https://deno.land/x/discordeno@18.0.0/helpers/guilds/getGuildIconUrl.ts": "31d7fec2fa0b5008e216cebeb623216df9a2fb890602ba7db56e1501dc1813ed", + "https://deno.land/x/discordeno@18.0.0/helpers/guilds/getGuildPreview.ts": "97b2f5e30084e066f65bd86ec31e9f79a4602d23f6b351cc30a4cf62fd786415", + "https://deno.land/x/discordeno@18.0.0/helpers/guilds/getGuildSplashUrl.ts": "b350b62886cded7ef2ede24b8e11da72d2c67829fe171b2c599070231635999b", + "https://deno.land/x/discordeno@18.0.0/helpers/guilds/getPruneCount.ts": "c1b42a2bfd11b6e8514c76d61420eb5ef2e95d36b87ab4fe0c3b16538a0eb949", + "https://deno.land/x/discordeno@18.0.0/helpers/guilds/getVanityUrl.ts": "a392a31a6d0e1f88373dffd6bd28f8713b2c5245e6389cf9ff6f38bdae039f30", + "https://deno.land/x/discordeno@18.0.0/helpers/guilds/getWelcomeScreen.ts": "fc7abc71de5c19087192c8000e1ffd45307d6be8019467e751ee2e57af855c5a", + "https://deno.land/x/discordeno@18.0.0/helpers/guilds/integrations/deleteIntegration.ts": "c2a829750b2626e3ab88c36dc9e4d4cb292e14bf182b4fbd132644979ad3d240", + "https://deno.land/x/discordeno@18.0.0/helpers/guilds/integrations/getIntegrations.ts": "75baf8e19ac3250b824df055bbbd49aa17ec93938776e99f9dab6799236426cd", + "https://deno.land/x/discordeno@18.0.0/helpers/guilds/integrations/mod.ts": "971be8c462243af6db336545e78d46d5b573b6b978eb30ccadea345920de0c34", + "https://deno.land/x/discordeno@18.0.0/helpers/guilds/invites/createInvite.ts": "25fe725a19a27fc695c9c90557bf07896a2050834603c59c97896cf4e8e8cab6", + "https://deno.land/x/discordeno@18.0.0/helpers/guilds/invites/deleteInvite.ts": "7d58565b833fbb7bf002417e7050398f648abb611415060748d512f1d0547c71", + "https://deno.land/x/discordeno@18.0.0/helpers/guilds/invites/getInvite.ts": "000e46d77ac8ca8f1c433c516467cb9a3ab1c6ce143a30c276b217a476c5bbeb", + "https://deno.land/x/discordeno@18.0.0/helpers/guilds/invites/getInvites.ts": "3f3fe66ae3da0ae07bc2d89ed95f8097640c286a95f3d0fb00bb6665d8314245", + "https://deno.land/x/discordeno@18.0.0/helpers/guilds/invites/mod.ts": "bc8f68c4f2da324104c44b598f661480aa3e6512dd6dec1ffab780afb1fdf0c8", + "https://deno.land/x/discordeno@18.0.0/helpers/guilds/leaveGuild.ts": "70785da8de9809326075598ff79eb6fc8066c90469a4943401ac146137070a56", + "https://deno.land/x/discordeno@18.0.0/helpers/guilds/mod.ts": "5a087689e8f5dfc359f7856fad177466b18c0c4814cac55c47919dfaca69158b", + "https://deno.land/x/discordeno@18.0.0/helpers/guilds/voice/connectToVoiceChannel.ts": "a656d1921f6d592ecc89e6b44f57856161e0d0c6c89e0bf2e9dbc91842d3a193", + "https://deno.land/x/discordeno@18.0.0/helpers/guilds/voice/editVoiceState.ts": "e01ee60cacb79311c0504d21cab017ddbf8f848ed0085daedb2748c77f4a7d54", + "https://deno.land/x/discordeno@18.0.0/helpers/guilds/voice/getAvailableVoiceRegions.ts": "342f89b1aea5492ac015131a42352da565f02181647e065e6e3f8968e9230d6d", + "https://deno.land/x/discordeno@18.0.0/helpers/guilds/voice/getVoiceRegions.ts": "dd7c5b1f92a9305a8e0f483d3e1828b12049d9f4e414623bf3c4d4fbce41e09a", + "https://deno.land/x/discordeno@18.0.0/helpers/guilds/voice/leaveVoiceChannel.ts": "e58cb1a3e4de76af5aff173e09bac8d1f4897d24defc81164def9f39356748cc", + "https://deno.land/x/discordeno@18.0.0/helpers/guilds/voice/mod.ts": "66740600945538976f33cc93567a1d56b25813d2e68f38c4adf39ce6feeb4236", + "https://deno.land/x/discordeno@18.0.0/helpers/guilds/widget/editWidgetSettings.ts": "9e782483c30c5cf763ca877eaffbe981af63a9ccd83a88847595add5410d5a13", + "https://deno.land/x/discordeno@18.0.0/helpers/guilds/widget/getWidget.ts": "95dc9515e126e4d0c6c9ec61049201874acd129dd52431c909d0cf2e4065d635", + "https://deno.land/x/discordeno@18.0.0/helpers/guilds/widget/getWidgetImageUrl.ts": "bfb5fb302fa4c7ed245b2b8e31e1ccb67d40a5cc8c700b86aa4ea574a81f1c63", + "https://deno.land/x/discordeno@18.0.0/helpers/guilds/widget/getWidgetSettings.ts": "02af061e32959e95de9bf9a7dcab2b4455fe263f4952888b6a622029f6940c2d", + "https://deno.land/x/discordeno@18.0.0/helpers/guilds/widget/mod.ts": "ee930d39d2d54d251ad85c0f77ccd7fb9629b25aa18fa30bbcdbe11c2855d1d4", + "https://deno.land/x/discordeno@18.0.0/helpers/interactions/commands/createGlobalApplicationCommand.ts": "49e8460e7469561839f54f42176bb3259f3c61005581f6c9fd6be43813a4d6ca", + "https://deno.land/x/discordeno@18.0.0/helpers/interactions/commands/createGuildApplicationCommand.ts": "be30bd72480dd8b2f8b8140b55763a33a92764835dbc6268355c6f5cb5257002", + "https://deno.land/x/discordeno@18.0.0/helpers/interactions/commands/deleteGlobalApplicationCommand.ts": "437d84b1a641281c4bd3a0dd60b71ab139dff118a3b62059c97e24b253df7fb5", + "https://deno.land/x/discordeno@18.0.0/helpers/interactions/commands/deleteGuildApplicationCommand.ts": "84f0844215952231e22ab24cc69e68fad00b565dbbc05efafc165f2a35e98c7e", + "https://deno.land/x/discordeno@18.0.0/helpers/interactions/commands/editApplicationCommandPermissions.ts": "cd50cca4eaea9a62dce04cedcceb03ef7fdaef1f053e178a382793fae55e0f83", + "https://deno.land/x/discordeno@18.0.0/helpers/interactions/commands/editGlobalApplicationCommand.ts": "0ab7bc1e399f1e5bd60e28f8f289c9c30fe2001ae81232c0889ad8927487c9e5", + "https://deno.land/x/discordeno@18.0.0/helpers/interactions/commands/editGuildApplicationCommand.ts": "c3ff698832e5f5a9d844fa96a3df0268a83f3ad4fdb8d9840fec682b145809c9", + "https://deno.land/x/discordeno@18.0.0/helpers/interactions/commands/getApplicationCommandPermission.ts": "8f2d493937389354cfbbfe654721784c966be45ce34a37ab79e203017b6248a8", + "https://deno.land/x/discordeno@18.0.0/helpers/interactions/commands/getApplicationCommandPermissions.ts": "a8778945c47d3c5e14dfa52daa9651e937da73324cbf11864ae32da86bd41d33", + "https://deno.land/x/discordeno@18.0.0/helpers/interactions/commands/getGlobalApplicationCommand.ts": "1efdf81d7fa24289890e65332ef26da4b66978471c2bf58a08a62c7b11268cf8", + "https://deno.land/x/discordeno@18.0.0/helpers/interactions/commands/getGlobalApplicationCommands.ts": "cbb2c4bef74b9768d521bd134fc967c356475596783dcebe475da6c90f7924c1", + "https://deno.land/x/discordeno@18.0.0/helpers/interactions/commands/getGuildApplicationCommand.ts": "137b09b1b2335f604970b7cd39b3fea84a8405a3fb52f0812f1b1e9b591a344b", + "https://deno.land/x/discordeno@18.0.0/helpers/interactions/commands/getGuildApplicationCommands.ts": "01199f85482619639be729061bf43df31f7914e52ac9abce8fca47c7e9bd8867", + "https://deno.land/x/discordeno@18.0.0/helpers/interactions/commands/mod.ts": "819587332aa0900a9289cb955dad2f438e3b586cf3e3a62fd8dd1f54e4bb4b15", + "https://deno.land/x/discordeno@18.0.0/helpers/interactions/commands/upsertGlobalApplicationCommands.ts": "f8db384f9ce2850e3fbb3d2bf90ee1cd40253c5109e32b496106c4b189f15144", + "https://deno.land/x/discordeno@18.0.0/helpers/interactions/commands/upsertGuildApplicationCommands.ts": "4c2233fee33d221c38e75d4b30c229c9c86ddc6288ce691fe802feb931908394", + "https://deno.land/x/discordeno@18.0.0/helpers/interactions/mod.ts": "734a879422865899f5e87912af0e4dff83de7132d4c875cb2c021f07997ef163", + "https://deno.land/x/discordeno@18.0.0/helpers/interactions/responses/deleteFollowupMessage.ts": "560080e24bfaee8cbb31fd510995234eb45f2ee467c89863378a0368aa0cc16f", + "https://deno.land/x/discordeno@18.0.0/helpers/interactions/responses/deleteOriginalInteractionResponse.ts": "5761ac422d3979f4023189a5d26e580d870093b572235afe74e99d97fc4c5e76", + "https://deno.land/x/discordeno@18.0.0/helpers/interactions/responses/editFollowupMessage.ts": "56e377fbcdbc6bd7afe1cfdec8aac50921b4ee174942c342f9f6bb3d458ff254", + "https://deno.land/x/discordeno@18.0.0/helpers/interactions/responses/editOriginalInteractionResponse.ts": "5d870c2f59a615ea571a7d2e4bb9a688a52e986e66385143711d7c58c528bcc2", + "https://deno.land/x/discordeno@18.0.0/helpers/interactions/responses/getFollowupMessage.ts": "5cc166cfced967e7dec57526c6bde3cc3ff66c14497d00595adb6f832ba8cf5b", + "https://deno.land/x/discordeno@18.0.0/helpers/interactions/responses/getOriginalInteractionResponse.ts": "fd754d2d6a41d33affb7756b04b7fe176c2e00e08f45648b4dbc1fbcf3fcb134", + "https://deno.land/x/discordeno@18.0.0/helpers/interactions/responses/mod.ts": "b1e9d06f552aa0c880e7dd3491878af405cd0b9b55da5cfb28f1dcb4d8bf11d1", + "https://deno.land/x/discordeno@18.0.0/helpers/interactions/responses/sendFollowupMessage.ts": "56692227ba540dd0eb910833b32bf9b620c5353e98f191df76c7859dbaa67aab", + "https://deno.land/x/discordeno@18.0.0/helpers/interactions/responses/sendInteractionResponse.ts": "ffd7b30e80a276eff7e8e685ae7399d6a44d04c803c144b3c14d7bf4f10ff0be", + "https://deno.land/x/discordeno@18.0.0/helpers/members/banMember.ts": "3dc6fe3608e4c9e6ae07edf049ce683e61f54532e9e3f5072b7ae918a4a3a705", + "https://deno.land/x/discordeno@18.0.0/helpers/members/editBotMember.ts": "09f3c8437a4c51690dfccc9114199e9544d5fc43804f33206aa172162b87b444", + "https://deno.land/x/discordeno@18.0.0/helpers/members/editMember.ts": "69e6ed21ccc45b0d1fff6cc8ebe47dd456ad4d575cf04fd23ad73dd235c586bd", + "https://deno.land/x/discordeno@18.0.0/helpers/members/fetchMembers.ts": "640f136dcd68facef890f2d2e913bfbb2988235615916dc7c3a0f0f2a521e47e", + "https://deno.land/x/discordeno@18.0.0/helpers/members/getAvatarUrl.ts": "da5e05e2a70ce09b27867c3e6adfdedcb665cc073a4a1fc50175864514f836b8", + "https://deno.land/x/discordeno@18.0.0/helpers/members/getDmChannel.ts": "8e1d0c44adb5a30fc2817a034d535348546262861b706335c423da63b55a4be7", + "https://deno.land/x/discordeno@18.0.0/helpers/members/getMember.ts": "b519fdf2d7a2a0f7ababfe3465141477e4f294cd84caf561c49d712b915da18d", + "https://deno.land/x/discordeno@18.0.0/helpers/members/getMembers.ts": "e93536f1cdd233ea12305172d5193c7152f47e5f638652eb8225dff75feef5f9", + "https://deno.land/x/discordeno@18.0.0/helpers/members/kickMember.ts": "9fc2e8611543bd170b748086543d3807ddc549ba97c373a04b341b2746548333", + "https://deno.land/x/discordeno@18.0.0/helpers/members/mod.ts": "6e2165e135f6e7d34d847bcf870143ec574f230c26391ad6e9516d92b380884f", + "https://deno.land/x/discordeno@18.0.0/helpers/members/pruneMembers.ts": "62fd7aa7eb03474ad30bdf9c8891aff69976575a0278a17ba8a7bd6f27b69857", + "https://deno.land/x/discordeno@18.0.0/helpers/members/searchMembers.ts": "bc2436e47b8beb316ca38e1d11cea5305a25f923cb1ee7386583bf7706e4f567", + "https://deno.land/x/discordeno@18.0.0/helpers/members/unbanMember.ts": "f188379c1add49b2c928eda59fbdbc4604cc7b6d126020fb39ebfaea799b2094", + "https://deno.land/x/discordeno@18.0.0/helpers/messages/crosspostMessage.ts": "681fd0aeed7593c1ba43c04ffaa4ae6fb2e3a5cabffe4dc22a042a7b4ebb7558", + "https://deno.land/x/discordeno@18.0.0/helpers/messages/deleteMessage.ts": "ba7b712dd29a777e94e004a218e82bdfcaf6c3ea4774210fbe87e5bc1e864b9d", + "https://deno.land/x/discordeno@18.0.0/helpers/messages/deleteMessages.ts": "d7172a936cf89632ed1068bf3ed61947643060b4d32dd497630e45423176295c", + "https://deno.land/x/discordeno@18.0.0/helpers/messages/editMessage.ts": "bab1cedb8271eab872ea496543164fe2e5664272d9e6ec770b595690aa270709", + "https://deno.land/x/discordeno@18.0.0/helpers/messages/getMessage.ts": "d2b176701c26e01310c4a7c5e61c7cb9d9eb4f33608944befb8dd06fea460cfa", + "https://deno.land/x/discordeno@18.0.0/helpers/messages/getMessages.ts": "2f3258a8c00f67bda48182e8f000d4e902520880158540c5c719c9abd85812c8", + "https://deno.land/x/discordeno@18.0.0/helpers/messages/getPinnedMessages.ts": "df4e213740ed9c4710f5d65371f414614df60b348a20ed6c7ef83a1ddebde08f", + "https://deno.land/x/discordeno@18.0.0/helpers/messages/mod.ts": "1fb89a5a70cb1b9e927c5bbc3d746cfee1795874cadf7ed94daa5e74138d7cb4", + "https://deno.land/x/discordeno@18.0.0/helpers/messages/pinMessage.ts": "0a9c24b6aeb9f8e9c024fce2db3e3d4191f594990b8ee5b8d37012332d04a2c1", + "https://deno.land/x/discordeno@18.0.0/helpers/messages/reactions/addReaction.ts": "2e9b82c3e738bd55bfa6bd38ae8cc99ffcfc23e33d435e70cd248ee3cdadd5f0", + "https://deno.land/x/discordeno@18.0.0/helpers/messages/reactions/addReactions.ts": "9c3dc066b146c968b8ba39680ffb63d23796df148ff657cf5eb41ab04a38cfec", + "https://deno.land/x/discordeno@18.0.0/helpers/messages/reactions/deleteReaction.ts": "3274cd5312fab0463a5bf101bf0dc9f4af6f5a9ddeae5e961499a88eaea3d697", + "https://deno.land/x/discordeno@18.0.0/helpers/messages/reactions/deleteReactionsAll.ts": "8e15ddf88c50342ff84539a8c9c298fc0aa8380550ba35d0f4c4a6bbe10680f2", + "https://deno.land/x/discordeno@18.0.0/helpers/messages/reactions/deleteReactionsEmoji.ts": "d4c2c0fad2a9448ce1d28797353f8a08793d5bb7b86eafbeeb889c5829567f57", + "https://deno.land/x/discordeno@18.0.0/helpers/messages/reactions/getReactions.ts": "11c505c7ca4c7938605f4075a70786a1f7f92c73919bf884d1e8fe99a2aee785", + "https://deno.land/x/discordeno@18.0.0/helpers/messages/reactions/mod.ts": "aacc08f1fe8deb08787fc114d4c2657baa08fbfa1fccd7cd7b867df554938a1e", + "https://deno.land/x/discordeno@18.0.0/helpers/messages/sendMessage.ts": "40774d6a5f7086b1d64f1106073c3501484a511703c87a74b06695724bb87b66", + "https://deno.land/x/discordeno@18.0.0/helpers/messages/unpinMessage.ts": "471a45e1cb77726c2c9be7c938216e6c6b7ba17e0d09d1907f306dd66e9bedfd", + "https://deno.land/x/discordeno@18.0.0/helpers/misc/editBotProfile.ts": "ecbeb13cef06ce8c1a6f445774f80ae3515934ebf0da953fb32339c68e046b24", + "https://deno.land/x/discordeno@18.0.0/helpers/misc/editBotStatus.ts": "ea2b89437ac396af2d78fb8fd182ab6537c64ecf62a59634305cb617f7f97bc9", + "https://deno.land/x/discordeno@18.0.0/helpers/misc/editShardStatus.ts": "525befc88f8f2dda7c9564ddf5451aff2f8acace6a62e8a22fb1f974ed6cd663", + "https://deno.land/x/discordeno@18.0.0/helpers/misc/getApplicationInfo.ts": "b5afd7d7c6a42dd810864bb14c2a486bb216368c31c1f1704668603b7223ce2f", + "https://deno.land/x/discordeno@18.0.0/helpers/misc/getGatewayBot.ts": "cd80410fc62e9eda9b374278bdbb7b5a6d3d8da5c13969ff231ee576cc39156f", + "https://deno.land/x/discordeno@18.0.0/helpers/misc/getNitroStickerPacks.ts": "fefc173c5b4796b21ed98f0db4b34f7cf6ea6ad5b5db476d667fc152abac661f", + "https://deno.land/x/discordeno@18.0.0/helpers/misc/getUser.ts": "3833adca7024ba3ef9d84e53fa8695f7f028590ff29bed6ec99ca2714db7fe8a", + "https://deno.land/x/discordeno@18.0.0/helpers/misc/mod.ts": "74c73a1828f29cbdf5bf36821276b168ad0334065e6210a0ebb66619b17a992e", + "https://deno.land/x/discordeno@18.0.0/helpers/mod.ts": "b0a87ac1f272302c4502517a433c73c9643c4cd772ebc04f6a542b9785f4570b", + "https://deno.land/x/discordeno@18.0.0/helpers/roles/addRole.ts": "f44efc4425c5494a2e8441287195fba968c30d2a61b275b34eab4cbf951cdf68", + "https://deno.land/x/discordeno@18.0.0/helpers/roles/createRole.ts": "798874cf9033f58ef016f342602ad242e6429f07fefddd5bb2ce2b00cb844070", + "https://deno.land/x/discordeno@18.0.0/helpers/roles/deleteRole.ts": "51b5b62d33b3d6d3e5fd6f4037cd0fca891ce608d2b7bc0d79981aaeada1ad91", + "https://deno.land/x/discordeno@18.0.0/helpers/roles/editRole.ts": "00011dfb356e1f427c8261c1be9491b73109a8a12b7586e781c0f77954f67ffe", + "https://deno.land/x/discordeno@18.0.0/helpers/roles/editRolePositions.ts": "69359d651c800779ac7775cfb2178e577dbb4ea3511ccc58af9262abd409058a", + "https://deno.land/x/discordeno@18.0.0/helpers/roles/getRoles.ts": "1bf991efb5c9d6d9dfe87c720d24f47b5b1a9f7fb92c7b0a2c2037c3a8cc409c", + "https://deno.land/x/discordeno@18.0.0/helpers/roles/mod.ts": "b9dec4f420c57dde0a096d615b68b55682dcbf7aac50048e8df909d354df5a87", + "https://deno.land/x/discordeno@18.0.0/helpers/roles/removeRole.ts": "ae3cf5e39b711800c41fc74b4ce1bb7a9d97b23e436b544c577d3887e865bbfa", + "https://deno.land/x/discordeno@18.0.0/helpers/stickers/createGuildSticker.ts": "8204360975b5e74fe778219881538600e246a35dd6c83475bc722c031b8be24b", + "https://deno.land/x/discordeno@18.0.0/helpers/stickers/deleteGuildSticker.ts": "7487ff0421f7320dcfe40f44660e47e8d5e1bc3617a86659bba3f8d58b0b2cfe", + "https://deno.land/x/discordeno@18.0.0/helpers/stickers/editGuildSticker.ts": "5cbacd5f9fb763bd3210a7e31f1810187c731a1613bec1cd81747abf681ea92c", + "https://deno.land/x/discordeno@18.0.0/helpers/stickers/getGuildSticker.ts": "bcd54189cc52fef8421ed7f645a56b64560b0aab9d06be5deb85170c7f2e1e1a", + "https://deno.land/x/discordeno@18.0.0/helpers/stickers/getGuildStickers.ts": "988512f633db3aae201239321acf46004f0826b3db4d578309785ba66fbf95cf", + "https://deno.land/x/discordeno@18.0.0/helpers/stickers/getSticker.ts": "5f9567d2152b74e5cbcdd666a6792b5fb2c91fbde9b332b7445970c623a8f33b", + "https://deno.land/x/discordeno@18.0.0/helpers/stickers/mod.ts": "2874ae9fa1a90ca62ea2c534654233849c78068ff9e8a8179faa3417d2e8136b", + "https://deno.land/x/discordeno@18.0.0/helpers/templates/createGuildFromTemplate.ts": "96cbca166348b5b5ecd33cdea68f0d71b20152e60017b82ec8a11ada04c26afc", + "https://deno.land/x/discordeno@18.0.0/helpers/templates/createGuildTemplate.ts": "251644641d451a134efcd741d3317d36a36379e36ee3db87cd402bab2c1b1f5d", + "https://deno.land/x/discordeno@18.0.0/helpers/templates/deleteGuildTemplate.ts": "9264e941ad21a928d14941fe130cba45015efe2239b5cab0bcd83acad67152aa", + "https://deno.land/x/discordeno@18.0.0/helpers/templates/editGuildTemplate.ts": "700e295973cf2a5607a4cfafcb781793acddaca2743dfe4983f980480e9a9782", + "https://deno.land/x/discordeno@18.0.0/helpers/templates/getGuildTemplate.ts": "9975079b8acc6e91cdf548fa568a1913a3c92222c0deb5f9836d11a6f842b6d1", + "https://deno.land/x/discordeno@18.0.0/helpers/templates/getGuildTemplates.ts": "2097321ca6d15db97268b522ef4c0659a8327f8fc47c9a6d7e48369a283b6f16", + "https://deno.land/x/discordeno@18.0.0/helpers/templates/mod.ts": "d43d90c92258e98350fed109481eecc9c789cc068d425650e584edeffb8eb376", + "https://deno.land/x/discordeno@18.0.0/helpers/templates/syncGuildTemplate.ts": "a91610d0fa3c870f9c1397fc9968fe247c44bf04f923f69e9a95a17de72fe771", + "https://deno.land/x/discordeno@18.0.0/helpers/webhooks/createWebhook.ts": "cbda8371f73e1fddb4be3ec3ee3243f09fb6753b28df45cba0b76a01f468419d", + "https://deno.land/x/discordeno@18.0.0/helpers/webhooks/deleteWebhook.ts": "71cbc17d1f8265adf2fb964f4b0761bd3c0b273344dcf2a949b6e0d4cac6e3a9", + "https://deno.land/x/discordeno@18.0.0/helpers/webhooks/deleteWebhookMessage.ts": "6fb6064a0dd1a968d1717cb7d2a714479711063462d72f7f196f5e5430bc74ce", + "https://deno.land/x/discordeno@18.0.0/helpers/webhooks/deleteWebhookWithToken.ts": "0966d5b0841b167c313ca8ea2dd300e599824319fcb3dd6b48ceb5fe9e1ebff6", + "https://deno.land/x/discordeno@18.0.0/helpers/webhooks/editOriginalWebhookMessage.ts": "3410c95ae90afacbcbd058c43df6e15c5a6ddb5a8abd1d40e8aabb5c1a4e9626", + "https://deno.land/x/discordeno@18.0.0/helpers/webhooks/editWebhook.ts": "a2b718e52783aa3a11128d96624f97cc8e4ea18695e14f5b17c87e0d5fe279e8", + "https://deno.land/x/discordeno@18.0.0/helpers/webhooks/editWebhookMessage.ts": "3e92c9988f86cf64b6d7b287524b3b347f7175dd25388290be0a3efd6c3a602a", + "https://deno.land/x/discordeno@18.0.0/helpers/webhooks/editWebhookWithToken.ts": "c388f0a1e0323b0f88dc576b602362855f1a3e0b241cb2f5aa3e56255c4e9607", + "https://deno.land/x/discordeno@18.0.0/helpers/webhooks/executeWebhook.ts": "8c9f8dfa9c870e591680b2d7d79ea03da616c45a3dfa2e65e81f1c995227f625", + "https://deno.land/x/discordeno@18.0.0/helpers/webhooks/getChannelWebhooks.ts": "e7aaddcb3b5315061dbd820747c184c6b5e3ddba0b777aee4c94287768d8aa19", + "https://deno.land/x/discordeno@18.0.0/helpers/webhooks/getGuildWebhooks.ts": "45c5efbeac51add558281b473e0653cc96896063f4095f3fe18f460d85b11eca", + "https://deno.land/x/discordeno@18.0.0/helpers/webhooks/getWebhook.ts": "0592eccf3e632c02832e76f44e610666ee874ef35b773fa19a4b4a9233361610", + "https://deno.land/x/discordeno@18.0.0/helpers/webhooks/getWebhookMessage.ts": "8b38c1f9fd1ba0168e9e924f0a853b683233b986e93f63d82d1f51549578631e", + "https://deno.land/x/discordeno@18.0.0/helpers/webhooks/getWebhookWithToken.ts": "f5d314d0bc8ca8ab35f3ed6834bdc33cce0ced33761cc4d8ea6b2d01e2e57f45", + "https://deno.land/x/discordeno@18.0.0/helpers/webhooks/mod.ts": "df2aaaacc457b27b59d132dd99e74c447d181e7b26b866180cd92364e0c698ea", + "https://deno.land/x/discordeno@18.0.0/mod.ts": "f76db2786e39c24f58b221512a1386677e71a6163668de7219c16d971e4c9e77", + "https://deno.land/x/discordeno@18.0.0/plugins/cache/deps.ts": "e7421b4414f8f366eea6cd279e18a6d476833c5e93ae8dc8b073209520816b40", + "https://deno.land/x/discordeno@18.0.0/plugins/cache/mod.ts": "de20cd90fc3f9a7ed239e0a00985d2083ffbb53dcb392daa3a9a9959266fdb30", + "https://deno.land/x/discordeno@18.0.0/plugins/cache/src/addCacheCollections.ts": "b8680e417c427a187099ef86b0cac6782753f775682e681c8644025c1ca8aa12", + "https://deno.land/x/discordeno@18.0.0/plugins/cache/src/dispatchRequirements.ts": "d7fe984e031bd3fbdb96bddf6a134a37e3e3825d12db9c27a317e61f1043fa6a", + "https://deno.land/x/discordeno@18.0.0/plugins/cache/src/setupCacheEdits.ts": "c07d49ae381a3a2ce0d33ff9621519cd09003424ec0da1f7a90218d4c9344065", + "https://deno.land/x/discordeno@18.0.0/plugins/cache/src/setupCacheRemovals.ts": "6510a20cd1e0566b55ecf07833754780a71a2e36410294f8cb2f5b477bb495c1", + "https://deno.land/x/discordeno@18.0.0/plugins/cache/src/sweepers.ts": "208861ebd8659b930e1d861c09bfa169e0f217ea4bd53195700db2a9d73b93bc", + "https://deno.land/x/discordeno@18.0.0/rest/checkRateLimits.ts": "52e7b62c5d60ced4acf141d76c4366cf113caee807fcc6c7414cc9ce55ae6445", + "https://deno.land/x/discordeno@18.0.0/rest/cleanupQueues.ts": "c034bb1c129e1d01a3061ed6b87b698c2f6a5f91a1037bbf25e786b0494a35b7", + "https://deno.land/x/discordeno@18.0.0/rest/convertRestError.ts": "77159dc9684732807ac1b3d86e089371f7b4522167dd67f3399c81d4a800c853", + "https://deno.land/x/discordeno@18.0.0/rest/createInvalidRequestBucket.ts": "0dcd335124c993d8f3fe77e1f4824ab85b0e233ab0e73f71f168bc2401e7d6d6", + "https://deno.land/x/discordeno@18.0.0/rest/createQueueBucket.ts": "0c0f70e2d2581cc93c21f44915087e7f85892d1d33a3680f77a08f087ad5fcbc", + "https://deno.land/x/discordeno@18.0.0/rest/createRequestBody.ts": "8090742839045bbc1018f971a9e5a8c99cee310b6aeb08138d15e66c58d2a38f", + "https://deno.land/x/discordeno@18.0.0/rest/mod.ts": "ba7b2696916f1c7d1a6d9b0cb9a534d49aa60e21811dbd28859048b950cca0b1", + "https://deno.land/x/discordeno@18.0.0/rest/processGlobalQueue.ts": "c81eacfa8d10925440d71b12797950d3259eaeeac40150767d5ee9030d1ced09", + "https://deno.land/x/discordeno@18.0.0/rest/processQueue.ts": "1abe7716e45a50f04d877d1a6ba398889b0fd038269c949d7c236b4b27e61b6d", + "https://deno.land/x/discordeno@18.0.0/rest/processRateLimitedPaths.ts": "d7f8c464f76cfcb8fa761a4c3249a80fa156f090c57c0a1284d179b7cacae8a7", + "https://deno.land/x/discordeno@18.0.0/rest/processRequest.ts": "2739c769254d2c616ba7c23620596789dbef0dbde49a4a1947a20f0bb956eefc", + "https://deno.land/x/discordeno@18.0.0/rest/processRequestHeaders.ts": "63338cf9803ce46f3e70525f620e5e9ac646504e63c8b2f763613b44745d987e", + "https://deno.land/x/discordeno@18.0.0/rest/rest.ts": "3e47bfd31fe2901e581caf9dbee6f0bd428ac436a53b0eb37a4ad0329baf7488", + "https://deno.land/x/discordeno@18.0.0/rest/restManager.ts": "5264368f5fa2e0235bccc0579c839375f3e5d4ca45f3de9560453441f62fbf2f", + "https://deno.land/x/discordeno@18.0.0/rest/runMethod.ts": "091f7da468ede8451502a9990fc5e0322fff0f510fe1be02f631c42c98d0df87", + "https://deno.land/x/discordeno@18.0.0/rest/sendRequest.ts": "c3ee092cc9f97aaa4caab428d22d9026510cf60a4cb63c5f2be9c092741d558c", + "https://deno.land/x/discordeno@18.0.0/rest/simplifyUrl.ts": "1b2661a776bc5c2fb84ee73312926183f51f11532e7d8f62ce44ba823c992295", + "https://deno.land/x/discordeno@18.0.0/transformers/activity.ts": "bfb5245a7bd8c5fbdfbef8a7965f744368264f07b726244d65a578ba80542125", + "https://deno.land/x/discordeno@18.0.0/transformers/application.ts": "cd41c186b3e54d1060233514498b8ae0e4be05bc8e36d854f0ed011f4577c0ab", + "https://deno.land/x/discordeno@18.0.0/transformers/applicationCommand.ts": "ab793ac543e5f472396eed24a9c06704429dc8b60d70a0c6faebe9e595e01a25", + "https://deno.land/x/discordeno@18.0.0/transformers/applicationCommandOption.ts": "329112f7a60df518af0a835e46cc79d744c10ddd4971ea8d050ea3b4dcdc05ec", + "https://deno.land/x/discordeno@18.0.0/transformers/applicationCommandOptionChoice.ts": "2118fa6989af6006e7266ab9599f1402cfcb9211227e296bab019e92ee0e2875", + "https://deno.land/x/discordeno@18.0.0/transformers/applicationCommandPermission.ts": "8d13f217325ce9d67068464fe2c9d42a07d152fc70558e2c0092b49010419d9b", + "https://deno.land/x/discordeno@18.0.0/transformers/attachment.ts": "8963729b4fe321bc4d60d13d6ead7e815729dba42b28d3d3117c637d009545a0", + "https://deno.land/x/discordeno@18.0.0/transformers/auditLogEntry.ts": "fdf6e1d7e4ba1b1ea3fd2d8bd21968dc9026139c1701952f485c709b7702a319", + "https://deno.land/x/discordeno@18.0.0/transformers/automodActionExecution.ts": "cdbc1570678e8c0779972d6897755208fc73bdc6921db24817ad9b385fa2a3eb", + "https://deno.land/x/discordeno@18.0.0/transformers/automodRule.ts": "83ea7f0f51568e231e9f0a76816c6d33a5d82da11c366e72dde935ee1e0d1a1e", + "https://deno.land/x/discordeno@18.0.0/transformers/channel.ts": "707d570b297b0d4146e373d68a8f8ed7cfc637e716ad17afea3e9e7031e801a9", + "https://deno.land/x/discordeno@18.0.0/transformers/component.ts": "503a2b32110587f5c763a59d1d746b79efeb82d2be6f6e97f1714e1ffb02ce4f", + "https://deno.land/x/discordeno@18.0.0/transformers/embed.ts": "abe227b8d2b9f062c526cdf279656703180c8e5f44c3691080ef5e32c0b3156b", + "https://deno.land/x/discordeno@18.0.0/transformers/emoji.ts": "58da6a8eb8c5cb9573bf43608e5a34ae2649f1d0a67fd4e74d99b5e8afe84d1b", + "https://deno.land/x/discordeno@18.0.0/transformers/gatewayBot.ts": "0c7dfebdaf142462fbd6cc94c61a64e8f2032834c4308b7ad06e493172c4fedc", + "https://deno.land/x/discordeno@18.0.0/transformers/guild.ts": "9e6e646d0eed2205f866008fc0b81edc1451c984c8fc890265e9b4fa8e1c7f9f", + "https://deno.land/x/discordeno@18.0.0/transformers/integration.ts": "0bda973c99949f906fe823e91d55b329a6da520eadb9eab8040946b14f8121f6", + "https://deno.land/x/discordeno@18.0.0/transformers/interaction.ts": "eb273d1fdb3240a8a40a5fad4a23a389582c5de0ae609913d1117f7f49458b20", + "https://deno.land/x/discordeno@18.0.0/transformers/invite.ts": "b9aeb5c51f653f11f2ca3974eab37fe28afc5e378e2f74ae975e0ccbb1f78027", + "https://deno.land/x/discordeno@18.0.0/transformers/member.ts": "ee82dc0c90d002d7f1ffdad8c86cb2ca05fa6d522173cf9f96f4912a63f695e4", + "https://deno.land/x/discordeno@18.0.0/transformers/message.ts": "cc5a068a00d497b98edfe83116486330636d026d73c06ec3dc7f978a3d2e38e8", + "https://deno.land/x/discordeno@18.0.0/transformers/mod.ts": "a29fab81b5d41439a854ed5d8d708be202740b035599284c637def13eb9f9e5b", + "https://deno.land/x/discordeno@18.0.0/transformers/presence.ts": "c4e6e468be742665ca904cbf1377613256a6f940d133c92838cef7ec48b6c9a7", + "https://deno.land/x/discordeno@18.0.0/transformers/reverse/activity.ts": "4a3e30ffd3721c737e1697590cb9849b584a6ebdf28af13b973034289c134b42", + "https://deno.land/x/discordeno@18.0.0/transformers/reverse/allowedMentions.ts": "1978ecb464310d8f2bc1baf7e67ede45a29b67929c0b647463b65efc50d8ed1e", + "https://deno.land/x/discordeno@18.0.0/transformers/reverse/application.ts": "3ddccb0063a9ddbb907bea57a0318ad6d00fc1f34acbbb531fc686a10668e7f3", + "https://deno.land/x/discordeno@18.0.0/transformers/reverse/applicationCommand.ts": "647418c1f7f175f40557adcc3c22449e965f3813f505ef3f13c9444422f0cd9d", + "https://deno.land/x/discordeno@18.0.0/transformers/reverse/applicationCommandOption.ts": "4a8f10906d292a12c9aa22c144b0f54b1ad46e5e36f1bbb9f2c897b2a4ab3fdd", + "https://deno.land/x/discordeno@18.0.0/transformers/reverse/applicationCommandOptionChoice.ts": "f6f45eebe9964acb3183ab94c05c20710f25524d88e4cec5b8a39e39477c3cfe", + "https://deno.land/x/discordeno@18.0.0/transformers/reverse/applicationCommandPermission.ts": "fbccf9f08b5c0191c3066e1aff3d7b2ba52ff141a492b76a4bcc5a081cc2bb8e", + "https://deno.land/x/discordeno@18.0.0/transformers/reverse/attachment.ts": "80c839a176cfe5850eee76f935c5ecac63093ab98b527539a8b15f76023adf7d", + "https://deno.land/x/discordeno@18.0.0/transformers/reverse/auditLogEntry.ts": "f534162a5ead7f2af0a7dff10ebc62783fa2c2bb75f80e9f55eea2d7614445ba", + "https://deno.land/x/discordeno@18.0.0/transformers/reverse/component.ts": "fff5d9b50ee04070c5155be4d66ae1dcd40cd6d22c80e1d31511d81e2dacb9e4", + "https://deno.land/x/discordeno@18.0.0/transformers/reverse/createApplicationCommand.ts": "3cd6b1450dea84031a10544a577c69830683e81b7c16c759b560b2ced3b5839f", + "https://deno.land/x/discordeno@18.0.0/transformers/reverse/embed.ts": "3f670eed57d512104888a55b9d7f4b213b32d8624d992cc5a30bcbd656046d2e", + "https://deno.land/x/discordeno@18.0.0/transformers/reverse/emoji.ts": "5aa260f373d748206a1f56ed61530af9f8ddd761660b303f8c9e9adf64707ec8", + "https://deno.land/x/discordeno@18.0.0/transformers/reverse/gatewayBot.ts": "8ae9a6a7c432f3485206e0ccb50e114cfbf3ca7789a60a01c660139ce499c8a8", + "https://deno.land/x/discordeno@18.0.0/transformers/reverse/interactionResponse.ts": "2a2dae0e50d160e632c935473344d90beb8f8fe7ffddd3c1c18dde78f14f2ec8", + "https://deno.land/x/discordeno@18.0.0/transformers/reverse/member.ts": "81f2450b4d5c326988773d07271757c0be615e325de1564d1fd0519c3f8bb820", + "https://deno.land/x/discordeno@18.0.0/transformers/reverse/mod.ts": "ae8ba055d871c3c3c423d34ea7cec0a4e9a328f7d5666eb18774c256627440ef", + "https://deno.land/x/discordeno@18.0.0/transformers/reverse/presence.ts": "4a6a0cfd7b5e7012d735ce50ba3a307bc4be1886348cf32a54bf6abc0ce23cf5", + "https://deno.land/x/discordeno@18.0.0/transformers/reverse/team.ts": "e2b584c75d08259ac967a37ba70157e0e67a666f3f1e2a640168288335f56b7e", + "https://deno.land/x/discordeno@18.0.0/transformers/reverse/widgetSettings.ts": "69142807adba5bcdc11fc75371e6137265f8dd8150bd0b6edf74aa7c43b9beba", + "https://deno.land/x/discordeno@18.0.0/transformers/role.ts": "22b009e642da7a4ce60e2d7ecc2d1a9dbf9b2a62c4157e11e9ad47bd317bc1a2", + "https://deno.land/x/discordeno@18.0.0/transformers/scheduledEvent.ts": "d42a5705128c09c4d7ba9553aefb720b5dde45530d1321351952f1bb7e9c7fe7", + "https://deno.land/x/discordeno@18.0.0/transformers/stageInstance.ts": "32feef2844d958f0b2d72a35b78b9ec1d0898b7c8eae6f4376327634cf7b4a1f", + "https://deno.land/x/discordeno@18.0.0/transformers/sticker.ts": "b8395b490610d113e9bfd2697aaad1ed4c80862937aa894b7bc1a1edeef4dd1c", + "https://deno.land/x/discordeno@18.0.0/transformers/team.ts": "bae401ece7629270b3d08699b7a50a948ee30874e7b3b53cf415af5802780ec0", + "https://deno.land/x/discordeno@18.0.0/transformers/template.ts": "f0bc99775bd27d83145c8a245e2b5b5d2c2095179eb1ef3c1bca21ff012f0f75", + "https://deno.land/x/discordeno@18.0.0/transformers/threadMember.ts": "698ba17d1bdcc927b83f373a0a9ea666623b9560a0009f0c366f17c4e10c92f5", + "https://deno.land/x/discordeno@18.0.0/transformers/toggles/ToggleBitfield.ts": "5192c9636cd9d6295970e357d40d00696d0cadca290db2ae052109fbc7f34294", + "https://deno.land/x/discordeno@18.0.0/transformers/toggles/emoji.ts": "c87f5ee9e6ce5765f4d9c4413ec8c38f427abc7b61ca33347b240749ad13fec8", + "https://deno.land/x/discordeno@18.0.0/transformers/toggles/guild.ts": "fdc7ef617f8585b4f8e0cd1af8701c6f6800c204834f0869a11c63242206cd09", + "https://deno.land/x/discordeno@18.0.0/transformers/toggles/member.ts": "10621e31e24371a05fa663e6367f87484d98a8df1b477f42607565c7ecc74cae", + "https://deno.land/x/discordeno@18.0.0/transformers/toggles/mod.ts": "258db99ff582782ce080648b7fe99b98addd535926d30b07846f1328711d178e", + "https://deno.land/x/discordeno@18.0.0/transformers/toggles/role.ts": "2003bbf8fb4d2c2a05f199f6dddf2f226710056f73589645de5b87cd9140a4ae", + "https://deno.land/x/discordeno@18.0.0/transformers/toggles/user.ts": "56d99df1676757790a9a98d26ffa6e2ef329b59044211ef95f4fd309fd0ba172", + "https://deno.land/x/discordeno@18.0.0/transformers/toggles/voice.ts": "60afbfe8ae9b4c2f8903720792fcbd9354181d4764a7035ab2437398a4deabfc", + "https://deno.land/x/discordeno@18.0.0/transformers/voiceRegion.ts": "a2aafa0e6e8b5f93e872ed6ae9d39422612f05ee827735a8b67518dbe0aae33b", + "https://deno.land/x/discordeno@18.0.0/transformers/voiceState.ts": "8bcd18fbf5ac3f4c74d7e5a8fefc9a8387e20ef653933dd75789b1030a67dba1", + "https://deno.land/x/discordeno@18.0.0/transformers/webhook.ts": "554d5067bb062c2e3b7acd695524ad1623f718ec018b12e24baa19780d546750", + "https://deno.land/x/discordeno@18.0.0/transformers/welcomeScreen.ts": "752fd62e3fd02661731da8e99748dfa070ea47b40fcf60a99edcde0150c799d1", + "https://deno.land/x/discordeno@18.0.0/transformers/widget.ts": "4ac5d91e08397d02c0122864499e7b787b8a021aa188e6c33ea7049adbf2025c", + "https://deno.land/x/discordeno@18.0.0/transformers/widgetSettings.ts": "f6b18308e30225ff52fb685421e3fa3fee26e5cb08dfa937979221f8ecf54bb3", + "https://deno.land/x/discordeno@18.0.0/types/discord.ts": "9eff1f4aef5baedf3542d3fb8580b41ab56868a00cb64d621db901c477d85ba6", + "https://deno.land/x/discordeno@18.0.0/types/discordeno.ts": "678d939172542efe770d6426b89419c9a0bda5185f0dbae1802378ae741ccfc3", + "https://deno.land/x/discordeno@18.0.0/types/mod.ts": "fe28c2252f98d3e699be672a0503e5829ac03c22bcf28d50f5ea11ccbcb21e5e", + "https://deno.land/x/discordeno@18.0.0/types/shared.ts": "fea0ac588e04e76309277ba8c7e5e1918249bc7f4c48d773b65cae36df82611e", + "https://deno.land/x/discordeno@18.0.0/util/base64.ts": "d8d1e2aece75aeaf38edb0169f44024a8c3d2acdea93eb7994e11e9138648c1d", + "https://deno.land/x/discordeno@18.0.0/util/bigint.ts": "5f516ad9023401eb984b56b6fbf56ab4bc2bad881ce58200963992bb99812659", + "https://deno.land/x/discordeno@18.0.0/util/bucket.ts": "86247c46a217eafe421ae3cb0a964d82136306f11031693ab72296f287c7a0fd", + "https://deno.land/x/discordeno@18.0.0/util/calculateShardId.ts": "897f63e613124c2f2aba77f9c909fe5e27f3a29759c813e39050e7945fce3e7a", + "https://deno.land/x/discordeno@18.0.0/util/collection.ts": "3ac580f642568416d74b70ae060f30a660aaf072c851e7fc59aadf8d6381d98b", + "https://deno.land/x/discordeno@18.0.0/util/constants.ts": "5f9d26e10b107035a494982f3232deaac86e2fb2d12410645e705a5015309526", + "https://deno.land/x/discordeno@18.0.0/util/hash.ts": "23047c82bc271fb9ecf4744f37ac68adfbd97a910bee193e45987e4868a57db8", + "https://deno.land/x/discordeno@18.0.0/util/mod.ts": "066f668d4bfa07110c8de4f671de881d87242e05608982c863d7dc5b60d67a25", + "https://deno.land/x/discordeno@18.0.0/util/permissions.ts": "24af940cfecdc19931f1440ca1c9f4a3d4581129a80385d873018fb5ca4f4bb6", + "https://deno.land/x/discordeno@18.0.0/util/routes.ts": "c322be8cd13fddc5d7d89d994f32c655aa752651ad8fdbe7528e53334e0addc5", + "https://deno.land/x/discordeno@18.0.0/util/token.ts": "4b5f415ee8a462866c35d60551e7cdc361ad88172d63d124e5722f61e0e03c08", + "https://deno.land/x/discordeno@18.0.0/util/urlToBase64.ts": "8fcb7373327e151883bddb28baf9581044402fabaa548230e12ec35611e6a204", + "https://deno.land/x/discordeno@18.0.0/util/utils.ts": "b16797ea1918af635f0c04c345a7c9b57c078310ac18d0c86936ec8abfaeddeb", + "https://deno.land/x/discordeno@18.0.0/util/validateLength.ts": "7c610911d72082f9cfe2c455737cd37d8ce8f323483f0ef65fdfea6a993984b5", + "https://deno.land/x/discordeno@18.0.0/util/verifySignature.ts": "8ba1c3d2698f347b4f32a76bd33edeb67ee9d23c34f419a797c393926786bb97", + "https://denopkg.com/burhanahmeed/time.ts@v2.0.1/lib/new-api.ts": "eb5ea9896fb54ff1a7d5fdd90446e3302d130cf6ee02d5132dd62c2dd4320713", + "https://denopkg.com/burhanahmeed/time.ts@v2.0.1/lib/time.ts": "b44f0471340af96d86799b63348ae86318f09104b7d1a13ad5f9b9db72afb174", + "https://denopkg.com/burhanahmeed/time.ts@v2.0.1/lib/timezone.ts": "7a228ddf5a80b8df7fb08a543f095631d9850be086ef783cf46984b40623b14f", + "https://denopkg.com/burhanahmeed/time.ts@v2.0.1/lib/type.ts": "272b84228582febfea09d9de64ef2b0f9b87668f7bbe5f8b655698a37a76f935", + "https://denopkg.com/burhanahmeed/time.ts@v2.0.1/mod.ts": "4cdaa33f88584fd832d57e9a9c1817ebf6bc9770d7d021d8c874902a4205a196", + "https://unpkg.com/@evan/wasm@0.0.93/target/ed25519/deno.js": "9728126890f17b71cee41afdab8a4bc362f6b9f409fe0a5c4f6ca9f3b510fd3a", + "https://unpkg.com/@evan/wasm@0.0.94/target/zlib/deno.js": "e65131e1c1f45e64960f2699a0b868927ea5c9201c4b986a7cfc57b18be5cc09" + } +} diff --git a/discord/discord.ts b/discord/discord.ts deleted file mode 100644 index 25dc0956..00000000 --- a/discord/discord.ts +++ /dev/null @@ -1,235 +0,0 @@ -import { createBot, startBot } from "https://deno.land/x/discordeno@13.0.0/mod.ts"; -import { enableCachePlugin, enableCacheSweepers } from "https://deno.land/x/discordeno@13.0.0/plugins/cache/mod.ts"; -import { EventDispatcher } from "./event-dispatcher.ts"; -import { Logger } from "../logging/logger.ts"; - -export * from "https://deno.land/x/discordeno@13.0.0/mod.ts"; - -export interface DiscordInitOpts { - token: string; - intents: any[]; - botId: bigint|number; - withCache?: boolean; -} - -export class Discord { - protected static bot: any; - protected token = ''; - protected intents: any; - protected botId = BigInt(0); - - /** - * Return the instance of the Discord bot connection - * TODO: Find out type of Discord.bot - * - * @returns any - */ - public static getBot(): any { return Discord.bot; } - - public constructor(opts: DiscordInitOpts) { - // Make sure required parameters are present - if('token' in opts) { - this.token = opts.token; - } else { - Logger.error('No Discord bot token was provided!'); - Deno.exit(1); - } - if('intents' in opts) this.intents = opts.intents; - if('botId' in opts) this.botId = BigInt(opts.botId); - - const baseBot = createBot({ - token: this.token, - intents: this.intents, - botId: this.botId, - events: { - botUpdate(_bot, user) { - EventDispatcher.dispatch('BotUpdate', user); - }, - channelCreate(_bot, channel) { - EventDispatcher.dispatch('ChannelCreate', channel); - }, - channelDelete(_bot, channel) { - EventDispatcher.dispatch('ChannelDelete', channel); - }, - channelPinsUpdate(_bot, data) { - EventDispatcher.dispatch('ChannelPinsUpdate', data); - }, - channelUpdate(_bot, channel) { - EventDispatcher.dispatch('ChannelUpdate', channel); - }, - debug(text, ...args: any[]) { - EventDispatcher.dispatch('Debug', { text: text, args: args }); - }, - dispatchRequirements(_bot, data, shardId) { - EventDispatcher.dispatch('DispatchRequirements', { data: data, shardId: shardId }); - }, - guildBanAdd(_bot, user, guildId) { - EventDispatcher.dispatch('GuildBanAdd', { user: user, guildId: guildId }); - }, - guildBanRemove(_bot, user, guildId) { - EventDispatcher.dispatch('GuildBanRemove', { user: user, guildId: guildId }); - }, - guildCreate(_bot, guild) { - EventDispatcher.dispatch('GuildCreate', guild); - }, - guildDelete(_bot, id, shardId) { - EventDispatcher.dispatch('GuildDelete', { id: id, shardId: shardId }); - }, - guildEmojisUpdate(_bot, payload) { - EventDispatcher.dispatch('guildEmojisUpdate', payload); - }, - guildLoaded(_bot, data) { - EventDispatcher.dispatch('GuildLoaded', data); - }, - guildMemberAdd(_bot, member, user) { - EventDispatcher.dispatch('GuildMemberAdd', { member: member, user: user }); - }, - guildMemberRemove(_bot, user) { - EventDispatcher.dispatch('GuildMemberRemove', user); - }, - guildMemberUpdate(_bot, member, user) { - EventDispatcher.dispatch('GuildMemberUpdate', { member: member, user: user }); - }, - guildUpdate(_bot, guild) { - EventDispatcher.dispatch('InteractionCreate', guild); - }, - integrationCreate(_bot, integration) { - EventDispatcher.dispatch('IntegrationCreate', integration); - }, - integrationDelete(_bot, payload) { - EventDispatcher.dispatch('IntegrationDelete', payload); - }, - integrationUpdate(_bot, payload) { - EventDispatcher.dispatch('IntegrationUpdate', payload); - }, - interactionCreate(_bot, interaction) { - EventDispatcher.dispatch('InteractionCreate', interaction); - }, - inviteCreate(_bot, invite) { - EventDispatcher.dispatch('InviteCreate', invite); - }, - inviteDelete(_bot, payload) { - EventDispatcher.dispatch('InviteDelete', payload); - }, - messageCreate(_bot, message) { - // Remapped to "MessageReceive" - // Reason: Easier to understand - EventDispatcher.dispatch('MessageReceive', { message: message }); - }, - messageDelete(_bot, payload, message?) { - EventDispatcher.dispatch('MessageDelete', { payload: payload, message: message }); - }, - messageUpdate(_bot, message, oldMessage?) { - EventDispatcher.dispatch('MessageUpdate', { message: message, oldMessage: oldMessage }); - }, - presenceUpdate(_bot, presence, oldPresence?) { - EventDispatcher.dispatch('PresenceUpdate', { presence: presence, oldPresence: oldPresence }); - }, - raw(_bot, data, shardId) { - EventDispatcher.dispatch('Raw', { data: data, shardId: shardId }); - }, - reactionAdd(_bot, payload) { - // Remapped to "MessageReactionAdd" - // Reason: Easier to understand - EventDispatcher.dispatch('MessageReactionAdd', payload); - }, - reactionRemove(_bot, data) { - // Remapped to "MessageReactionRemove" - // Reason: Easier to understand - EventDispatcher.dispatch('MessageReactionRemove', data); - }, - reactionRemoveAll(_bot, payload) { - // Remapped to "MessageReactionRemoveAll" - // Reason: Easier to understand - EventDispatcher.dispatch('MessageReactionRemoveAll', payload); - }, - reactionRemoveEmoji(_bot, payload) { - EventDispatcher.dispatch('ReactionRemoveEmoji', payload); - }, - ready(_bot, payload, rawPayload) { - // Remapped to "BotReady" - // Reason: Easier to understand - EventDispatcher.dispatch('BotReady', { payload: payload, rawPayload: rawPayload }); - }, - roleCreate(_bot, role) { - EventDispatcher.dispatch('RoleCreate', role); - }, - roleDelete(_bot, role) { - EventDispatcher.dispatch('RoleDelete', role); - }, - roleUpdate(_bot, role) { - EventDispatcher.dispatch('RoleUpdate', role); - }, - scheduledEventCreate(_bot, event) { - EventDispatcher.dispatch('ScheduledEventCreate', event); - }, - scheduledEventDelete(_bot,event) { - EventDispatcher.dispatch('ScheduledEventDelete', event); - }, - scheduledEventUpdate(_bot,event) { - EventDispatcher.dispatch('ScheduledEventUpdate', event); - }, - scheduledEventUserAdd(_bot, payload) { - EventDispatcher.dispatch('ScheduledEventUserAdd', payload); - }, - scheduledEventUserRemove(_bot, payload) { - EventDispatcher.dispatch('ScheduledEventUserRemove', payload); - }, - stageInstanceCreate(_bot, data) { - EventDispatcher.dispatch('StageInstanceCreate', data); - }, - stageInstanceDelete(_bot, data) { - EventDispatcher.dispatch('StageInstanceDelete', data); - }, - stageInstanceUpdate(_bot, data) { - EventDispatcher.dispatch('StageInstanceUpdate', data); - }, - threadCreate(_bot, thread) { - EventDispatcher.dispatch('ThreadCreate', thread); - }, - threadDelete(_bot, thread) { - EventDispatcher.dispatch('ThreadDelete', thread); - }, - threadMembersUpdate(_bot, payload) { - EventDispatcher.dispatch('ThreadMembersUpdate', payload); - }, - threadUpdate(_bot, thread) { - EventDispatcher.dispatch('ThreadUpdate', thread); - }, - typingStart(_bot, payload) { - EventDispatcher.dispatch('TypingStart', payload); - }, - voiceChannelLeave(_bot, voiceState, guild, channel) { - EventDispatcher.dispatch('VoiceChannelLeave', { voiceState: voiceState, guild: guild, channel: channel }); - }, - voiceServerUpdate(_bot, payload) { - EventDispatcher.dispatch('VoiceServerUpdate', payload); - }, - voiceStateUpdate(_bot, voiceState) { - EventDispatcher.dispatch('VoiceStateUpdate', voiceState); - }, - webhooksUpdate(_bot, payload) { - EventDispatcher.dispatch('WebhooksUpdate', payload); - }, - } - }); - - // Enable cache if required - if(opts.withCache === true) { - const bot = enableCachePlugin(baseBot); - enableCacheSweepers(bot); - Discord.bot = bot; - } else { - Discord.bot = baseBot; - } - } - - /** - * Start the bot and connect to the Discord gateway - * - * @returns Promise - */ - public async start(): Promise { - await startBot(Discord.bot); - } -} diff --git a/discord/event-dispatcher.ts b/discord/event-dispatcher.ts deleted file mode 100644 index e4027e52..00000000 --- a/discord/event-dispatcher.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { Logger } from "../logging/logger.ts"; - -interface EventConfig { - name: string; - handler: string; -} - -export interface Event { - execute(opts: any): Promise; -} - -export class EventDispatcher { - private static list: EventConfig[] = []; - private static handlers: any = {}; - - /** - * Return the complete list of events - * - * @returns EventConfig[] - */ - public static getEvents() { return EventDispatcher.list; } - - /** - * Find an Event by name - * - * @param name - * @returns IEvent|undefined - */ - public static getHandler(name: string): EventConfig|undefined { - return EventDispatcher.list.find((event: EventConfig) => event.name === name); - } - - /** - * Import an event handler and add it to the list of handlers - * - * @param event - * @returns Promise - */ - public static async add(event: EventConfig): Promise { - try { - // Import the event handler - EventDispatcher.handlers[event.handler] = await import(`file://${Deno.cwd()}/src/events/${event.handler}.ts`) - } catch(e) { - Logger.error(`Could not register event handler for "${event.name}": ${e.message}`, e.stack); - return; - } - - EventDispatcher.list.push(event); - } - - /** - * Run an instance of the Feature handler - * - * @param event - * @param data - * @returns Promise - */ - public static async dispatch(event: string, data: any = {}): Promise { - // Get the event handler - const handler = EventDispatcher.getHandler(event); - if(!handler) return Logger.debug(`Event "${event}" does not exist! (did you register it?)`); - - // Create an instance of the event handler - const controller = new EventDispatcher.handlers[handler.handler][`${event}Event`](data); - - // Execute the handler's execute method - try { - await controller['execute'](data); - } catch(e) { - Logger.error(`Could not dispatch event "${event}": "${e.message}"`, e.stack); - } - } -} diff --git a/discord/interaction-dispatcher.ts b/discord/interaction-dispatcher.ts deleted file mode 100644 index bcd506da..00000000 --- a/discord/interaction-dispatcher.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { ApplicationCommandOption } from "https://deno.land/x/discordeno@13.0.0/mod.ts"; -import { Discord } from "./discord.ts"; -import { Logger } from "../logging/logger.ts"; - -interface InteractionConfig { - name: string; - description: string; - type: any; - options?: ApplicationCommandOption[]; - handler: string; -} - -export interface Interaction { - execute(): Promise; -} - -export class InteractionDispatcher { - private static list: InteractionConfig[] = []; - private static handlers: any = {}; - - /** - * Return the complete list of interactions - * - * @returns IInteraction[] - */ - public static getInteractions() { return InteractionDispatcher.list; } - - /** - * Find an Interaction by name - * - * @param name - * @returns InteractionConfig|undefined - */ - public static getHandler(name: string): InteractionConfig|undefined { - return InteractionDispatcher.list.find((interaction: InteractionConfig) => interaction.name === name); - } - - /** - * Update all interactions registered to the Discord gateway - * - * @param opts - * @returns Promise - */ - public static async update(opts: any): Promise { - try { - await Discord.getBot().helpers.upsertApplicationCommands(InteractionDispatcher.getInteractions(), opts.guildId); - } catch(e) { - Logger.error(`Could not update interactions: ${e.message}`); - } - } - - /** - * Import an interaction handler and add it to the list of handlers - * - * @param interaction - * @returns Promise - */ - public static async add(interaction: InteractionConfig): Promise { - try { - // Import the interaction handler - InteractionDispatcher.handlers[interaction.handler] = await import(`file://${Deno.cwd()}/src/interactions/${interaction.handler}.ts`) - } catch(e) { - Logger.error(`Could not register interaction handler for "${interaction}": ${e.message}`); - return; - } - - InteractionDispatcher.list.push(interaction); - } - - /** - * Run an instance of the Interaction handler - * - * @param interaction - * @param data - * @returns Promise - */ - public static async dispatch(interaction: string, data: any = {}): Promise { - // Get the handler - const handler = InteractionDispatcher.getHandler(interaction); - if(!handler) { - Logger.warning(`Interaction "${interaction}" does not exist! (did you register it?)`); - return; - } - - // Create an instance of the handler - const controller = new InteractionDispatcher.handlers[handler.handler][`${interaction[0].toUpperCase() + interaction.slice(1)}Interaction`](data); - - // Execute the handler's execute method - try { - await controller['execute'](); - } catch(e) { - Logger.error(`Could not dispatch interaction "${interaction}": "${e.message}"`, e.stack); - } - } -} diff --git a/interfaces/window.interface.ts b/interfaces/window.interface.ts deleted file mode 100644 index 9cf1293c..00000000 --- a/interfaces/window.interface.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { Websocket } from "../websocket/websocket.ts"; - -declare global { - interface Window { - websocket: Websocket; - } -} diff --git a/logging/logger.ts b/logging/logger.ts deleted file mode 100644 index 74279b16..00000000 --- a/logging/logger.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { Time } from "../common/time.ts"; -import { Configure } from "../common/configure.ts"; -import { cyan, yellow, red, magenta, bold } from "https://deno.land/std@0.117.0/fmt/colors.ts"; - -export class Logger { - /** - * Write an info message to the console - * - * @param message The message to write - * @returns void - */ - public static info(message: string): void { console.log(`[${Logger.time()}] ${cyan('INFO')} > ${message}`); } - - /** - * Write a warning message to the console - * - * @param message The message to write - * @returns void - */ - public static warning(message: string): void { console.error(`[${Logger.time()}] ${yellow('WARN')} > ${message}`); } - - /** - * Write an error message to the console. - * If the "error_log" Configure item is set, will also write to file. - * - * @param message The message to write - * @param stack Optional stacktrace - * @returns void - */ - public static error(message: string, stack: string|null = null): void { - // Get current time - const now = Logger.time(); - - // Check if we need to write to file - // Write to file if need be - if(Configure.get('error_log')) { - try { - let output = `[${now}] ERROR > ${message}`; - if(stack) output += `\r\n${stack}`; - Deno.writeTextFile(Configure.get('error_log'), output, {append: true}); - } catch(e) { - console.error(`Could not append to error log: "${e.message}"`); - } - } - - // Write to console - let output = `[${now}] ${red(bold('ERROR'))} > ${message}`; - if(stack) output += `\r\n${stack}`; - console.error(output); - } - - /** - * Write a debug message to the console - * Only shows up when the "DEBUG" env is set to truthy - * - * @param message The message to write - * @returns void - */ - public static debug(message: string): void { - if(Deno.env.get('DEBUG') == "true" || Configure.get('debug', false)) console.log(`[${Logger.time()}] ${magenta('DEBUG')} > ${message}`); - } - - /** - * Write a stacktrace to the console - * - * @param stacktrace The stacktrace - * @returns void - */ - public static trace(stacktrace: any): void { - Logger.warning('Use of Logger#stack is deprecated, please pass trace to Logger#error instead.'); - console.error(stacktrace); - } - - /** - * Return the current time in format. - * Configurable using the "logger.timeformat" key. - * Defaults to "yyyy/MM/dd HH:mm:ss" (2020/11/28 20:50:30) - * - * @returns string The formatted time - */ - private static time(): string { - return new Time().format(Configure.get('logger.timeformat', 'yyyy/MM/dd HH:mm:ss')); - } -} diff --git a/mod.ts b/mod.ts index b3fb9148..7d3032d9 100644 --- a/mod.ts +++ b/mod.ts @@ -1,38 +1,72 @@ -export { Configure } from "./common/configure.ts"; -export { cron } from "./common/cron.ts"; -export { env } from "./common/env.ts"; -export { Time } from "./common/time.ts"; - -export { Ntfy } from "./communication/ntfy.ts"; -export { RCON } from "./communication/rcon.ts"; -export { Redis } from "./communication/redis.ts"; - -export { - Discord, - InteractionResponseTypes, - ApplicationCommandTypes, - ApplicationCommandOptionTypes -} from "./discord/discord.ts"; -export type { DiscordEmbed, Intents } from "./discord/discord.ts"; -export { EventDispatcher } from "./discord/event-dispatcher.ts"; -export { InteractionDispatcher } from "./discord/interaction-dispatcher.ts"; - -export { Logger } from "./logging/logger.ts"; -export { Queue, Scheduler } from "./queue/queue.ts"; - -export { Hash, Algorithms } from "./security/hash.ts"; -export { Password } from "./security/password.ts"; -export { Random } from "./security/random.ts"; - -export { CheckSource } from "./util/check-source.ts"; -export { tokenizer } from "./util/tokenizer.ts"; -export { lcfirst } from "./util/lcfirst.ts"; -export { ucfirst } from "./util/ucfirst.ts"; - -export { Controller } from "./webserver/controller/controller.ts"; -export { Router } from "./webserver/routing/router.ts"; -export { Webserver } from "./webserver/webserver.ts"; -export type { RouteArgs } from "./webserver/routing/router.ts"; - -export { Websocket } from "./websocket/websocket.ts"; -export { Events } from "./websocket/events.ts"; +/** + * Communication + */ +export { CouchDB } from "./src/communication/couchdb.ts"; +export { Druid } from "./src/communication/druid.ts"; +export { GraphQL } from "./src/communication/graphql.ts"; +export { InfluxDB } from "./src/communication/influxdb.ts"; +export { Loki } from "./src/communication/loki.ts"; +export { Ntfy } from "./src/communication/ntfy.ts"; +export { Nut } from "./src/communication/nut.ts"; +export { RCON } from "./src/communication/rcon.ts"; +export { Redis } from "./src/communication/redis.ts"; +export { UptimeKuma } from "./src/communication/uptime-kuma.ts"; + +/** + * Chomp Core + */ +export * from "./src/core/mod.ts"; + +/** + * Error + */ +export type { ErrorCodes } from "./src/error/error-codes.ts"; +export { raise } from "./src/error/raise.ts"; + +/** + * Filesystem + */ +export { File } from "./src/filesystem/file.ts"; +export { Folder } from "./src/filesystem/folder.ts"; + +/** + * Queue + */ +export { Queue } from "./src/queue/queue.ts"; + +/** + * Security + */ +export { Hash } from "./src/security/hash.ts"; +export { Password } from "./src/security/password.ts"; +export { Random } from "./src/security/random.ts"; +export type { Algorithms, INSECURE_ALGORITHMS } from "./src/types/hash.ts"; +export type { DEFAULT_OPTS, PASSWORD_DEFAULT } from "./src/security/password.ts"; +export type { PasswordOptions } from "./src/types/password.ts"; + +/** + * Utility + */ +export { CheckSource } from "./src/utility/check-source.ts"; +export { Contract } from "./src/utility/contract.ts"; +export { Cron } from "./src/utility/cron.ts"; +export { empty } from "./src/utility/empty.ts"; +export { errorOrData } from "./src/utility/error-or-data.ts"; +export { fetchWithTimeout } from "./src/utility/fetch-with-timeout.ts"; +export { formatBytes } from "./src/utility/format-bytes.ts"; +export { Inflector } from "./src/utility/inflector.ts"; +export { nameOf } from "./src/utility/name-of.ts"; +export { Text } from "./src/utility/text.ts"; +export { Time } from "./src/utility/time.ts"; +export { TimeString, TimeStringSeconds } from "./src/utility/time-string.ts"; +export type { ExclusionConfig } from "./src/types/check-source.ts"; + +/** + * Webserver + */ +export * from "./src/webserver/mod.ts"; + +/** + * Websocket + */ +export * from "./src/websocket/mod.ts"; diff --git a/queue/queue.ts b/queue/queue.ts deleted file mode 100644 index c5e93a1f..00000000 --- a/queue/queue.ts +++ /dev/null @@ -1,152 +0,0 @@ -import { Logger } from "../logging/logger.ts"; - -interface QueueItem { - weight?: number; - data: any; -} - -export enum Scheduler { - /* First In, First Out */ - FIFO = 0, - - /* Last In, First Out */ - LIFO = 1, - - /* Highest Weight go out FIFO */ - WEIGHTED = 2, -} - -export class Queue { - private items: QueueItem[] = []; - private readonly scheduler: Scheduler; - - public constructor(scheduler: Scheduler = Scheduler.FIFO) { - this.scheduler = scheduler; - } - - /** - * Get the number of items contained in the Queue - * - * @returns number - */ - public get count(): number { return this.items.length; } - - /** - * Check whether the Queue has any items - * - * @returns boolean - */ - public get isEmpty(): boolean { return this.items.length === 0; } - - /** - * Get the next item from the queue. - * Unlike the Queue#peek() method, this *does* remove the item. - * - * @returns QueueItem - */ - public get next(): QueueItem|null { - // Make sure we have items in our queue - if(this.items.length === 0) return null; - - // Return the first item in our queue and remove it - // @ts-ignore We already return null when no items are present - return this.items.shift(); - } - - /** - * Get the next item from the queue. - * Unlike the Queue#next() method, this does *not* remove the item. - * - * @returns QueueItem|null - */ - public get peek(): QueueItem|null { - // Make sure we have items in our queue - if(this.items.length === 0) return null; - - // Return the first item in our queue - return this.items[0]; - } - - /** - * Return all the items in the queue - * - * @returns QueueItems[] - */ - public get dump(): QueueItem[] { return [...this.items]; } - - /** - * Add an item to the queue based on the scheduler used - * - * @param item Item to add to the queue - */ - public add(item: QueueItem): void { - // Make sure data was set - if(Object.keys(item.data).length === 0) throw Error('Data for queue item may not be empty!'); - - // Add item to the queue based on the scheduler used - switch(this.scheduler) { - case Scheduler.FIFO: - if(item.hasOwnProperty('weight')) { - Logger.debug('A weight was set without the weighted scheduler, removing it...'); - delete item.weight; - } - this.items.push(item); - break; - case Scheduler.LIFO: - if(item.hasOwnProperty('weight')) { - Logger.debug('A weight was set without the weighted scheduler, removing it...'); - delete item.weight; - } - this.items.unshift(item); - break; - case Scheduler.WEIGHTED: - if(!item.hasOwnProperty('weight')) { - Logger.debug('No weight was set with weighted scheduler, defaulting to 0...'); - item.weight = 0; - } - - // Loop over all items in queue, add it at the bottom of it's weight - for (let i=0; i this.items[i].weight || i === this.items.length) { - this.items.splice(i, 0, item); - return; - } - } - - // Queue is empty, just push - this.items.push(item); - break; - default: - throw Error('No scheduler has been set, this is a bug!'); - } - } - - /** - * Check whether the queue already contains an identical item - * - * @returns boolean - */ - public contains(item: QueueItem): boolean { - const itemKeys = Object.keys(item.data); - const match = this.items.find((queued: QueueItem) => { - // Check if the weights are the same - if(queued.weight !== item.weight) return false; - - // Check if all keys exists and if they have the same value - for(const key of itemKeys) { - if(!queued.data.hasOwnProperty(key)) return false; - if(queued.data[key] !== item.data[key]) return false; - } - return true; - }); - return typeof match !== 'undefined'; - } - - /** - * Remove all items from the Queue - * - * @returns void - */ - public clear(): void { this.items = []; } -} diff --git a/security/password.ts b/security/password.ts deleted file mode 100644 index 1f862d8a..00000000 --- a/security/password.ts +++ /dev/null @@ -1,129 +0,0 @@ -import { Random } from "./random.ts"; -import { Algorithms, Hash, INSECURE_ALGORITHMS } from "./hash.ts"; - -/** - * Recommended hashing algorithm for most use-cases. - * May change over time to keep up with NIST approved algorithms - */ -export const PASSWORD_DEFAULT = Algorithms.SHA3_256; - -/** - * Create a mapping of algorithms to identifiers - * To add new identifier: - * - Hash enum value using SHA1 - * - Add first 2 characters as identifier - * - Prefix with "d" (to make linter happy) - */ -enum HASH_IDENTIFIERS { - 'd5e' = 'SHA-384', - 'd6d' = 'SHA3-224', - 'd88' = 'SHA3-256', - 'def' = 'SHA3-384', - 'd81' = 'SHA3-512', - 'dfa' = 'SHAKE128', - 'de3' = 'SHAKE256', - 'd34' = 'BLAKE2B-256', - 'd20' = 'BLAKE2B-384', - 'd85' = 'BLAKE2B', - 'd05' = "BLAKE2S", - 'd63' = "BLAKE3", - 'd87' = "KECCAK-224", - 'd78' = "KECCAK-256", - 'd1c' = "KECCAK-384", - 'df6' = "KECCAK-512", - /* Insecure, please do not use in production */ - 'dc0' = 'RIPEMD-160', - /* Insecure, please do not use in production */ - 'dba' = 'SHA-224', - /* Insecure, please do not use in production */ - 'd45' = 'SHA-256', - /* Insecure, please do not use in production */ - 'db8' = 'SHA-512', - /* Insecure, please do not use in production */ - 'dc5' = 'SHA-1', - /* Insecure, please do not use in production */ - 'db7' = 'MD5', -} - -// Set default options for hashing -export const DEFAULT_OPTS: IPasswordOpts = { - cost: 10, - allowInsecure: false -} - -/** - * Options for hashing a password - */ -export interface IPasswordOpts { - /* Cost factor for hashing (2**cost) */ - cost?: number; - /* Allow the use of insecure algorithms */ - allowInsecure?: boolean; -} - -export class Password { - /** - * Hash the password using the specified password algorithm - * - * @param password - * @param algo - * @param options - * @returns Promise Hash string containing algo, cost, salt and hash - */ - public static async hash(password: string, algo: Algorithms = PASSWORD_DEFAULT, options: IPasswordOpts = DEFAULT_OPTS): Promise { - // Make sure we are not using an insecure algorithm - if(INSECURE_ALGORITHMS.includes(algo) && !options.allowInsecure) throw Error('Insecure hashing algorithm selected, aborting.'); - - // Make sure cost is set, else, use a default - if(typeof options.cost !== 'number' || options.cost <= 0) options.cost = DEFAULT_OPTS.cost; - - // Get our identifier - const identifierIndex = Object.values(HASH_IDENTIFIERS).indexOf(algo as unknown as HASH_IDENTIFIERS); - if(!identifierIndex) throw Error(`Identifier for algorithm "${algo}" could not be found!`); - const identifier = Object.keys(HASH_IDENTIFIERS)[identifierIndex]; - - // Create our hash - const salt = await Random.string(32); - const result = await Password.doHash(password, algo, salt, options.cost!); - - // Return our final hash string - return `${identifier}!${options.cost}!${salt}!${result}`; - } - - /** - * - * @param password Input password to check against - * @param hash Hash string input from Password.hash() - * @returns Promise Whether the password was valid or not - */ - public static async verify(password: string, hash: string): Promise { - // Split input hash at the delimiter - // Then build our data - const tokens = hash.split('!'); - if(tokens.length < 4) throw Error('Malformed input hash'); - const data = { - algo: HASH_IDENTIFIERS[tokens[0] as keyof typeof HASH_IDENTIFIERS], - cost: Number(tokens[1]), - salt: tokens[2], - hash: tokens[3] - }; - - // Create our hash - const result = await Password.doHash(password, data.algo, data.salt, data.cost); - - // Compare hash and return the result - return result === data.hash; - } - - private static async doHash(input: string, algo: string, salt: string, cost: number): Promise { - const rounds = 2 ** cost; - let result = input; - for(let round = 0; round < rounds; round++) { - const h = new Hash(`${salt}${input}`, algo); - await h.digest(); - result = await h.hex(); - } - - return result; - } -} diff --git a/security/random.ts b/security/random.ts deleted file mode 100644 index c37bf56b..00000000 --- a/security/random.ts +++ /dev/null @@ -1,24 +0,0 @@ -export class Random { - /** - * Generate random bytes - * - * @param length Amount of bytes to be generated - * @returns Uint8Array - */ - public static bytes(length: number): Uint8Array { - const buf = new Uint8Array(length); - crypto.getRandomValues(buf); - return buf; - } - - /** - * Generate a random string - * - * @param length Length of the string to be generated - * @returns Promise - */ - public static async string(length: number): Promise { - const buf = await Random.bytes(length / 2); - return Array.from(buf, (dec: number) => dec.toString(16).padStart(2, "0")).join(''); - } -} diff --git a/src/communication/couchdb.ts b/src/communication/couchdb.ts new file mode 100644 index 00000000..43193bda --- /dev/null +++ b/src/communication/couchdb.ts @@ -0,0 +1,408 @@ +import { Auth, CouchResponse, CouchRequest, CachedResponse, CouchOverrides} from "../types/couchdb.ts"; +import { Cache } from "../core/cache.ts"; +import { Configure } from "../core/configure.ts"; + +/** + * Default cache time + */ +const CACHE_TIME = '+1 hour'; + +/** + * Interact with {@link https://couchdb.apache.org/ Apache CouchDB}. + * + * You can specify the read cache expiry used for {@link CouchDB.get} by setting the `chomp_couchdb_cache` configuration key. + */ +export class CouchDB { + private auth = ""; + + /** + * @example + * ```ts + * import { CouchDB } from "https://deno.land/x/chomp/communication/couchdb.ts"; + * + * const couchdb = new CouchDB( + * 'http://localhost:5984', + * 'my_database', + * { + * username: 'couchuser', + * password: 'lamepassword' + * } + * ); + * ``` + * + * @param host + * @param database + * @param auth + */ + public constructor( + private readonly host: string = "http://localhost:5984", + private readonly database: string, + auth: Auth = { username: "", password: "" }, + ) { + this.auth = btoa(`${auth.username}:${auth.password}`); + } + + /** + * Update the username for this instance. + * This does *not* update the username on the server. + * + * @example + * ```ts + * import { CouchDB } from "https://deno.land/x/chomp/communication/couchdb.ts"; + * + * const couchdb = new CouchDB(); + * couchdb.username = 'couchuser'; + * ``` + * + * @param username + */ + public set username(username: string) { + // Get the password from the data + const password = atob(this.auth).split(":")[1]; + + // Update auth string + this.auth = btoa(`${username}:${password}`); + } + + /** + * Update the password for this instance. + * This does *not* update the password on the server. + * + * @example + * ```ts + * import { CouchDB } from "https://deno.land/x/chomp/communication/couchdb.ts"; + * + * const couchdb = new CouchDB(); + * couchdb.password = 'lamepassword'; + * ``` + * + * @param password + */ + public set password(password: string) { + // Get the password from the data + const username = atob(this.auth).split(":")[0]; + + // Update auth string + this.auth = btoa(`${username}:${password}`); + } + + /** + * Get the name of the database we're working with + * + * @example + * ```ts + * import { CouchDB } from "https://deno.land/x/chomp/communication/couchdb.ts"; + * + * const couchdb = new CouchDB(...); + * const database = couchdb.databaseName; + * ``` + */ + public get databaseName(): string { + return this.database; + } + + /** + * Get a document from the database. + * + * **Note**: Responses will always be stored in cache, regardless of the `cache` parameter. + * + * @example + * ```ts + * import { CouchDB } from "https://deno.land/x/chomp/communication/couchdb.ts"; + * + * const couchdb = new CouchDB(...); + * const existing = await couchdb.get('my-key'); + * + * if(existing.status === 404) { + * // Handle non-existing document + * } + * ``` + * + * @param id + * @param cache + */ + public async get(id: string, cache: boolean = true): Promise { + // Check if we want to cache + // If not, just run the request without etag + if(!cache) return this.raw(id); + + // Get the etag from cache + const cached = Cache.get(`chomp.couchdb.cache ${id}`); + + // Check if cached version was found + // If not, run the request without etag + if(!cached) return this.raw(id); + + // Run the request with the etag + const [error, data, status] = await this.raw(id, null, { + etag: cached.etag, + }); + + // If we somehow have a 304 still, use the cached version we have already + // This can happen in rare cases where the existing entry expired while querying the database + if(status === 304) return cached.data; + + // Return our response object + return [error, data, status]; + } + + /** + * Insert a document into the database. + * + * Any document passed to the method will be attempted to insert "as-is". + * The more convenient "{@linkcode CouchDB.upsert()}" method should be used most of the time. + * + * @example + * ```ts + * import { CouchDB } from "https://deno.land/x/chomp/communication/couchdb.ts"; + * + * const couchdb = new CouchDB(...); + * const resp = await couchdb.insert({ + * '_id': 'my-key', + * 'data': 'my-data', + * }); + * + * if(resp.status !== 201) { + * // Handle insert error + * } + * ``` + * + * @param data + */ + // deno-lint-ignore no-explicit-any -- Any arbitrary data may be used + public insert(data: any): Promise { + return this.raw("", data); + } + + /** + * Update a document in the database. + * + * This is only useful if you know the latest revision. + * The more convenient "{@linkcode CouchDB.upsert()}" should be used most of the time. + * + * @example + * ```ts + * import { CouchDB } from "https://deno.land/x/chomp/communication/couchdb.ts"; + * + * const couchdb = new CouchDB(...); + * const resp = await couchdb.update(`my-key`, '1-abcdef', 'my-data'); + * + * if(resp.status !== 201) { + * // Handle update error + * } + * ``` + * + * @param id + * @param revision + * @param data + */ + // deno-lint-ignore no-explicit-any -- Any arbitrary data may be used + public update(id: string, revision: string, data: any): Promise { + // Make sure the id and revision are set in the data + if (!data["_id"] || data["_id"] !== id) data["_id"] = id; + if (!data["_rev"] || data["_rev"] !== revision) data["_rev"] = revision; + + return this.raw(id, data, { method: "PUT" }); + } + + /** + * Update or insert a document into the database. + * This method will automatically check if an existing document exists and try to update it. + * If no document exists, it will be created instead. + * + * @example + * ```ts + * import { CouchDB } from "https://deno.land/x/chomp/communication/couchdb.ts"; + * + * const couchdb = new CouchDB(...); + * const resp = await couchdb.upsert(`my-key`, 'my-data'); + * + * if(resp.status !== 201) { + * // Handle upsert error + * } + * ``` + * + * @param id + * @param data + */ + // deno-lint-ignore no-explicit-any -- Any arbitrary data may be used + public async upsert(id: string, data: any): Promise { + // Check if a document already exists + // Insert a new document if not + const [error, document, status] = await this.get(id); + if (status === 404) { + data["_id"] = id; + delete data["_rev"]; + return this.insert(data); + } + + // Make sure we got an "OK" status before + if(error) return [error, document, status]; + + // Update the document + return this.update(id, document["_rev"], data); + } + + /** + * Delete a document from the database. + * TODO: Automatically find revision. + * + * @example + * ```ts + * import { CouchDB } from "https://deno.land/x/chomp/communication/couchdb.ts"; + * + * const couchdb = new CouchDB(...); + * const existing = await couchdb.get('my-key'); + * if(existing.status === 404) return; + * const resp = await couchdb.delete('my-key', existing.data['_rev']); + * + * if(resp.status !== 200) { + * // Handle deletion error + * } + * ``` + * + * @param id + * @param revision + */ + public delete(id: string, revision: string): Promise { + return this.raw(`${id}?rev=${revision}`, null, { method: "DELETE" }); + } + + /** + * Execute a view design + * + * @example + * ```ts + * import { CouchDB } from "https://deno.land/x/chomp/communication/couchdb.ts"; + * + * const couchdb = new CouchDB(...); + * const resp = await couchdb.viewDesign('my-design', 'my-view', 'my-partition'); + * if(resp.status !== 200) { + * // Handle view error + * } + * ``` + * + * @param design + * @param view + * @param partition + */ + public viewDesign(design: string, view: string, partition: string): Promise { + return this.raw(`_partition/${partition}/_design/${design}/_view/${view}`); + } + + /** + * Find a document + * + * @example Basic usage + * ```ts + * import { CouchDB } from "https://deno.land/x/chomp/communication/couchdb.ts"; + * + * const couchdb = new CouchDB(...); + * const resp = await couchdb.find({"_id": "example}); + * ``` + * + * @example Specific fields only + * ```ts + * import { CouchDB } from "https://deno.land/x/chomp/communication/couchdb.ts"; + * + * const couchdb = new CouchDB(...); + * const resp = await couchdb.find({"_id": "example}, ["_id", "_rev", "example_field"]); + * ``` + * + * @param selector + * @param fields + */ + public find(selector: any, fields: string[]|null = null): Promise { + // Instantiate body with selector + const body: {selector: any, fields?:string[]} = { + selector: selector, + }; + + // Check if we want only specific fields + if(fields !== null) body.fields = fields; + + // Execute query + return this.raw(`_find`, body, {method: 'POST'}); + } + + /** + * Main request handler. + * This method is used for most of our other methods as well. + * + * @example + * ```ts + * // TODO: Write example + * ``` + * + * @param endpoint + * @param body + * @param overrides + */ + // deno-lint-ignore no-explicit-any -- Any arbitrary data may be used + public async raw(endpoint: string, body: any = null, overrides: CouchOverrides = {}): Promise { + // Start building opts + const opts: CouchRequest = { + method: overrides["method"] ? overrides["method"] : "GET", + headers: { + Authorization: `Basic ${this.auth}`, + "If-None-Match": overrides["etag"] ? overrides["etag"] : undefined, + }, + }; + + // Add body if specified + if (body !== null) { + opts["method"] = opts.method !== "GET" ? opts.method : "POST"; + opts["body"] = JSON.stringify(body); + opts.headers["Content-Type"] = "application/json"; + } + + // Make sure the endpoint starts with a leading slash + const cacheKey = endpoint; + if (endpoint.charAt(0) !== "/" && endpoint !== "") endpoint = `/${endpoint}`; + + // Send our request + const resp = await fetch(`${this.host}/${this.database}${endpoint}`, opts); + + // Check if we have a 304 + // If so, return here + if(resp.status === 304) return [undefined, {_id: endpoint, _rev: overrides["etag"]}, 304]; + + // Check whether we have an error + // If so, return + if(!resp.ok) { + return [ + resp.status === 404 ? await resp.json() : { error: resp.status, reason: resp.statusText }, + undefined, + resp.status, + ]; + } + + // Get data from request + let data = null; + switch(opts.method.toUpperCase()) { + case "HEAD": + case "DELETE": + break; + case "PUT": + // Put input body as data since CouchDB doesn't send this back + data = body; + + // Overwrite revision with revision from the etag to prevent conflicts + if(resp.headers.get("etag")) data._rev = resp.headers.get("etag")!.replaceAll("\"", ""); + break; + case "POST": + case "GET": + data = await resp.json(); + break; + } + + // Save etag and (slightly modified) response to cache + if(resp.headers.get("etag")) Cache.set(`chomp.couchdb.cache ${cacheKey}`, { + etag: resp.headers.get("etag"), + data: [undefined, data, 200], + }, Configure.get('chomp_couchdb_cache', CACHE_TIME)); + + // Return our response + return [undefined, data, resp.status]; + } +} diff --git a/src/communication/druid.ts b/src/communication/druid.ts new file mode 100644 index 00000000..2be0b0d5 --- /dev/null +++ b/src/communication/druid.ts @@ -0,0 +1,38 @@ +/** + * Interact with {@link https://druid.apache.org/ Apache Druid}. + * + * @deprecated No longer actively maintained + */ +export class Druid { + // deno-lint-ignore no-explicit-any -- TODO + private spec: any = null; + // deno-lint-ignore no-explicit-any -- TODO + public set setSpec(spec: any) { + this.spec = spec; + } + // deno-lint-ignore no-explicit-any -- TODO + public get getSpec(): any { + return this.spec; + } + + public constructor( + private readonly host: string, + ) { + } + + /** + * Create a new task in Apache Druid + * + * @returns Promise + */ + public async create(): Promise { + if (!this.spec) throw Error("No task specification has been set!"); + return await fetch(`${this.host}/druid/indexer/v1/task`, { + method: "POST", + body: this.spec, + headers: { + "Content-Type": "application/json", + }, + }); + } +} diff --git a/src/communication/graphql.ts b/src/communication/graphql.ts new file mode 100644 index 00000000..08678211 --- /dev/null +++ b/src/communication/graphql.ts @@ -0,0 +1,52 @@ +/** + * Interact with a {@link https://graphql.org/ GraphQL} API. + */ +export class GraphQL { + private _variables = {}; + private _query: string = "query{}"; + + public constructor( + private readonly endpoint = "/graphql", + ) { + } + + public execute() { + return fetch(this.endpoint, { + method: "POST", + headers: { + "Content-Type": "application/json", + "Accept": "application/json", + }, + body: JSON.stringify({ + query: this._query, + variables: this._variables, + }), + }).then((r) => r.json()); + } + + /** + * Set our query string + * + * @param query + * @return The instance of this class + */ + + public setQuery(query: string): GraphQL { + this._query = query; + return this; + } + + /** + * Add a variable to our variables object + * + * @param key + * @param value + * @return The instance of this class + */ + public addVariable(key: string, value: string): GraphQL { + // TODO: Find out type + // @ts-ignore See TODO + this._variables[key] = value; + return this; + } +} diff --git a/src/communication/influxdb.ts b/src/communication/influxdb.ts new file mode 100644 index 00000000..a2284994 --- /dev/null +++ b/src/communication/influxdb.ts @@ -0,0 +1,149 @@ +import { Precision, Api } from "../types/influxdb.ts"; +import { Logger } from "../core/logger.ts"; + +/** + * Interact with InfluxDB + */ +export class InfluxDB { + private _api: Api; + + public constructor( + url: string, + token: string, + org: string, + bucket: string, + precision: Precision = Precision.us + ) { + this._api = { + url: `${url}/api/v2/write?org=${org}&bucket=${bucket}&precision=${Precision[precision]}`, + auth: `Token ${token}`, + precision: precision, + }; + } + + /** + * Write our datapoint(s) to InfluxDB + * + * @param data + */ + public async write(data: Point | Point[]): Promise { + // Convert point(s) to Line Protocol entry + let points = ""; + if (Array.isArray(data)) { + for await (const point of data) { + points += `${point.toLine(this._api.precision)}\n`; + } + } else { + points = data.toLine(this._api.precision); + } + + // Write line to InfluxDB + try { + const resp = await fetch(this._api.url, { + headers: { + Authorization: this._api.auth, + "Content-Type": "text/plain", + }, + method: "POST", + body: points, + }); + return resp.ok; + } catch (e) { + Logger.error(`Could not write point(s) to InfluxDB`, e.stack); + return false; + } + } +} + +export class Point { + private _tags: Map = new Map(); + private _fields: Map = new Map(); + private _timestamp: Date | number = 0; + + public constructor( + private readonly measurement: string, + ) { + } + + /** + * Add a tag to our point + * + * @param key + * @param value + */ + public addTag(key: string, value: string): this { + this._tags.set(key, value); + return this; + } + + /** + * Add a field to our point + * + * @param key + * @param value + */ + public addField(key: string, value: string | number): this { + this._fields.set(key, value); + return this; + } + + /** + * Set our timestamp for the point. + * Can be either a date or a number. + * In the case that this is a number, it must be in the correct precision units. + * + * @param ts + */ + public setTimestamp(ts: Date | number): this { + this._timestamp = ts; + return this; + } + + public toLine(precision: Precision = Precision.us): string { + // Start off with a blank string + let line = ""; + + // Set the measurement + line += this.measurement; + + // Add all tags + for (const [key, value] of this._tags.entries()) { + line += `,${key}=${value}`; + } + + // Add separator before fieldset + line += " "; + + // Add all fields + const entries = []; + for (const [key, value] of this._fields.entries()) { + entries.push(`${key}=${value}`); + } + line += entries.join(","); + + // Add timestamp + let ts = 0; + if (this._timestamp instanceof Date) { + ts = this._timestamp.getTime(); + switch (precision) { + case Precision.s: + ts = Math.trunc(ts / 1_000); + break; + case Precision.ms: + break; + case Precision.us: + ts = ts * 1_000; + break; + case Precision.ns: + ts = ts * 1_000_000; + break; + } + } else { + ts = this._timestamp; + } + line += ` ${ts}`; + + // Return our final line + return line; + } +} diff --git a/src/communication/loki.ts b/src/communication/loki.ts new file mode 100644 index 00000000..6d948070 --- /dev/null +++ b/src/communication/loki.ts @@ -0,0 +1,40 @@ +import { LokiStream } from "../types/loki.ts"; +import { Logger } from "../core/logger.ts"; + +/** + * Interact with {@link https://grafana.com/oss/loki/ Loki}. + */ +export class Loki { + /** + * @param host + * @param tenant Tenant ID to use for the log item. Can be ommitted if not using multi-tenant mode. + */ + public constructor( + private readonly host: string = "http://localhost:3100", + private readonly tenant: string = "fake", + ) { + } + + /** + * Send a log entry to the Grafana Loki database. + * + * @param stream + */ + public async send(stream: LokiStream | LokiStream[]) { + try { + const resp = await fetch(`${this.host}/loki/api/v1/push`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-Scope-OrgID": this.tenant, + }, + body: JSON.stringify({ + streams: Array.isArray(stream) ? stream : [stream], + }), + }); + if (resp.ok === false) throw Error(`Response non-OK: ${resp.statusText}`); + } catch (e) { + Logger.error(`Could not add message to Loki: ${e.message}`, e.stack); + } + } +} diff --git a/communication/ntfy.ts b/src/communication/ntfy.ts similarity index 62% rename from communication/ntfy.ts rename to src/communication/ntfy.ts index 72b8cd15..9454a7ec 100644 --- a/communication/ntfy.ts +++ b/src/communication/ntfy.ts @@ -1,11 +1,14 @@ -import { Logger } from "../logging/logger.ts"; +import { Logger } from "../core/logger.ts"; +/** + * Interact with Ntfy. + */ export class Ntfy { public constructor( private readonly host: string, private readonly topic: string, - private readonly username: string = '', - private readonly password: string = '' + private readonly username: string = "", + private readonly password: string = "", ) { } @@ -18,16 +21,16 @@ export class Ntfy { try { const auth = btoa(`${this.username}:${this.password}`); const resp = await fetch(`${this.host}/${this.topic}`, { - method: 'POST', + method: "POST", headers: { - 'Content-Type': 'text/plain', - 'Authorization': `Basic ${auth}` + "Content-Type": "text/plain", + "Authorization": `Basic ${auth}`, }, - body: message + body: message, }); - if(resp.status === 200) return; + if (resp.status === 200) return; throw Error(`${resp.status} - ${resp.statusText}`); - } catch(e) { + } catch (e) { Logger.error(`Could not send notification: "${e.message}"`, e.stack); } } diff --git a/src/communication/nut.ts b/src/communication/nut.ts new file mode 100644 index 00000000..67ae3db8 --- /dev/null +++ b/src/communication/nut.ts @@ -0,0 +1,244 @@ +import { NutState } from "../types/nut.ts"; +import { Logger } from "../core/logger.ts"; + +/** + * Interact with NUT (Network UPS Tools). + */ +export class Nut { + private readonly host: string = ""; + private readonly port: number = 3493; + private client: Deno.TcpConn | null = null; + private _status: number = NutState.IDLE; + // deno-lint-ignore no-explicit-any -- TODO + private callback: any = null; + private dataBuf: string = ""; + + public get status(): number { + return this._status; + } + + constructor(host: string | undefined, port: number = 3493) { + if (typeof host === "undefined") { + Logger.error(`Could not register monitor for "UPS": No NUT host defined!`); + return this; + } + + this.host = host; + this.port = port; + } + + public async connect() { + // Instantiate a new client + this.client = await Deno.connect({ hostname: this.host, port: this.port }); + + // Create pseudo-event handler + this.onReceive(); + } + + // deno-lint-ignore no-explicit-any -- TODO + public async send(cmd: string, callback: any) { + if (this._status !== NutState.IDLE) throw new Error(`NUT not ready to send new data yet!`); + this._status = NutState.WAITING; + this.callback = callback; + + // Encode our command string + const data = new TextEncoder().encode(`${cmd}\n`); + + // Send our data over the connection + await this.client!.write(data); + } + + public close() { + //this.send(`LOGOUT`); + this.client!.close(); + } + + private async onReceive() { + // deno-lint-ignore no-deprecated-deno-api -- TODO + for await (const buffer of Deno.iter(this.client!)) { + this.dataBuf += new TextDecoder().decode(buffer); + this.callback(this.dataBuf); + } + } + + public getLoad(name: string | undefined): Promise { + if (typeof name === "undefined") Promise.reject("UPS name must be specified!"); + + // deno-lint-ignore no-explicit-any no-async-promise-executor -- TODO + return new Promise(async (resolve: any) => { + // deno-lint-ignore no-explicit-any -- TODO + await this.send(`GET VAR ${name} ups.load`, (data: any) => { + // Get our power + const matches = /VAR (?:[a-zA-Z0-9]+) ups\.load "([0-9]+)"/.exec(data); + if (matches === null) { + this._status = NutState.IDLE; + this.dataBuf = ""; + resolve(0); + return; + } + if (typeof matches![1] === "undefined" || matches![1] === null) { + this._status = NutState.IDLE; + this.dataBuf = ""; + resolve(0); + return; + } + + this._status = NutState.IDLE; + this.dataBuf = ""; + resolve(Number(matches![1])); + }); + }); + } + + public getPowerLimit(name: string | undefined): Promise { + if (typeof name === "undefined") Promise.reject("UPS name must be specified!"); + + // deno-lint-ignore no-explicit-any no-async-promise-executor -- TODO + return new Promise(async (resolve: any) => { + // deno-lint-ignore no-explicit-any -- TODO + await this.send(`GET VAR ${name} ups.realpower.nominal`, (data: any) => { + // Get our power + const matches = /VAR (?:[a-zA-Z0-9]+) ups\.realpower\.nominal "([0-9]+)"/.exec(data); + if (matches === null) { + this._status = NutState.IDLE; + this.dataBuf = ""; + resolve(0); + return; + } + if (typeof matches![1] === "undefined" || matches![1] === null) { + this._status = NutState.IDLE; + this.dataBuf = ""; + resolve(0); + return; + } + this._status = NutState.IDLE; + this.dataBuf = ""; + resolve(Number(matches![1])); + }); + }); + } + + public getCharge(name: string | undefined): Promise { + if (typeof name === "undefined") Promise.reject("UPS name must be specified!"); + + // deno-lint-ignore no-explicit-any no-async-promise-executor -- TODO + return new Promise(async (resolve: any) => { + // deno-lint-ignore no-explicit-any -- TODO + await this.send(`GET VAR ${name} battery.charge`, (data: any) => { + // Get our power + const matches = /VAR (?:[a-zA-Z0-9]+) battery\.charge "([0-9]+)"/.exec(data); + if (matches === null) { + this._status = NutState.IDLE; + this.dataBuf = ""; + resolve(0); + return; + } + if (typeof matches![1] === "undefined" || matches![1] === null) { + this._status = NutState.IDLE; + this.dataBuf = ""; + resolve(0); + return; + } + this._status = NutState.IDLE; + this.dataBuf = ""; + resolve(Number(matches![1])); + }); + }); + } + + public getRuntime(name: string | undefined): Promise { + if (typeof name === "undefined") Promise.reject("UPS name must be specified!"); + + // deno-lint-ignore no-explicit-any no-async-promise-executor -- TODO + return new Promise(async (resolve: any) => { + // deno-lint-ignore no-explicit-any -- TODO + await this.send(`GET VAR ${name} battery.runtime`, (data: any) => { + // Get our power + const matches = /VAR (?:[a-zA-Z0-9]+) battery\.runtime "([0-9]+)"/.exec(data); + if (matches === null) { + this._status = NutState.IDLE; + this.dataBuf = ""; + resolve(0); + return; + } + if (typeof matches![1] === "undefined" || matches![1] === null) { + this._status = NutState.IDLE; + this.dataBuf = ""; + resolve(0); + return; + } + this._status = NutState.IDLE; + this.dataBuf = ""; + resolve(Number(matches![1])); + }); + }); + } + + public getStatus(name: string | undefined): Promise { + if (typeof name === "undefined") Promise.reject("UPS name must be specified!"); + + // deno-lint-ignore no-explicit-any no-async-promise-executor -- TODO + return new Promise(async (resolve: any) => { + // deno-lint-ignore no-explicit-any -- TODO + await this.send(`GET VAR ${name} ups.status`, (data: any) => { + // Get our power + const matches = /VAR (?:[a-zA-Z0-9]+) ups\.status "([0-9]+)"/.exec(data); + if (matches === null) { + this._status = NutState.IDLE; + this.dataBuf = ""; + resolve(0); + return; + } + if (typeof matches![1] === "undefined" || matches![1] === null) { + this._status = NutState.IDLE; + this.dataBuf = ""; + resolve(0); + return; + } + this._status = NutState.IDLE; + this.dataBuf = ""; + resolve(Number(matches![1])); + }); + }); + } + + public get UPSList() { + // deno-lint-ignore no-explicit-any no-async-promise-executor -- TODO + return new Promise(async (resolve: any, reject: any) => { + // deno-lint-ignore no-explicit-any -- TODO + await this.send(`LIST UPS`, (data: any) => { + const dataArray = data.split("\n"); + // deno-lint-ignore no-explicit-any -- TODO + const vars: any = {}; + for (const line of dataArray) { + // Check if we have an error + if (line.indexOf("ERR") === 0) { + this._status = NutState.IDLE; + this.dataBuf = ""; + reject(line.slice(4)); + return; + } + + // Find UPS entries by regex + // Check if 3 items have been found + // Add them to our object + if (line.indexOf("UPS ") === 0) { + const matches = /^UPS\s+(.+)\s+"(.*)"/.exec(line); + if (matches === null) continue; + if (matches.length < 3) continue; + vars[matches[1]] = matches[2]; + continue; + } + + // Resolve if we hit the end + if (line.indexOf("END LIST UPS") === 0) { + this._status = NutState.IDLE; + this.dataBuf = ""; + resolve(vars); + return; + } + } + }); + }); + } +} diff --git a/communication/rcon.ts b/src/communication/rcon.ts similarity index 91% rename from communication/rcon.ts rename to src/communication/rcon.ts index 8d4d5bbd..1c1f0219 100644 --- a/communication/rcon.ts +++ b/src/communication/rcon.ts @@ -1,10 +1,9 @@ +import { PacketType } from "../types/rcon.ts"; import { Buffer } from "https://deno.land/std@0.87.0/node/buffer.ts"; -export enum PacketType { - COMMAND = 0x02, - AUTH = 0x03, -} - +/** + * Interact with RCON. + */ export class RCON { private conn!: Deno.Conn; @@ -15,13 +14,13 @@ export class RCON { * @param port * @param password Optional password for authentication */ - public async connect(ip: string, port: number, password: string|null = null) { + public async connect(ip: string, port: number, password: string | null = null) { this.conn = await Deno.connect({ hostname: ip, port: port, }); - if(password) await this.send(password, "AUTH"); + if (password) await this.send(password, "AUTH"); } /** @@ -86,7 +85,6 @@ export class RCON { private async recv(): Promise { const data = new Buffer(2048); // TODO: Fix await this.conn.read(data); - const length = data.readInt32LE(0); const id = data.readInt32LE(4); const type = data.readInt32LE(8); @@ -98,6 +96,6 @@ export class RCON { str = str.substring(0, str.length - 1); } - return str.replace(/\0/g, '') || ""; + return str.replace(/\0/g, "") || ""; } } diff --git a/src/communication/redis.ts b/src/communication/redis.ts new file mode 100644 index 00000000..6d81fc4c --- /dev/null +++ b/src/communication/redis.ts @@ -0,0 +1,35 @@ +import { connect as redisConnect, Redis as RedisConn } from "https://deno.land/x/redis@v0.25.2/mod.ts"; +import { Logger } from "../core/logger.ts"; + +/** + * Interact with {@link https://redis.io/ Redis} using the {@link https://deno.land/x/redis@v0.25.2/mod.ts Redis library}. + * + * @deprecated No longer actively maintained + */ +export class Redis { + private static connection: RedisConn | null = null; + + /** + * Connect to a Redis node + * + * @param hostname + * @param port + * @returns Promise + */ + public static async connect(hostname = "127.0.0.1", port = 6379): Promise { + Redis.connection = await redisConnect({ + hostname: hostname, + port: port, + }); + } + + /** + * Return the redis connection + * + * @return any + */ + public static getConnection(): RedisConn { + if (!Redis.connection) Logger.error(`Redis connection requested before connecting!`); + return Redis.connection!; + } +} diff --git a/src/communication/uptime-kuma.ts b/src/communication/uptime-kuma.ts new file mode 100644 index 00000000..a7429c37 --- /dev/null +++ b/src/communication/uptime-kuma.ts @@ -0,0 +1,35 @@ +import { UptimeKumaInstance } from "../types/uptime-kuma.ts"; +import {fetchWithTimeout} from "../utility/fetch-with-timeout.ts"; +import {raise} from "../../mod.ts"; + +/** + * Interact with an Uptime Kuma instance. + */ +export class UptimeKuma { + private readonly _host: string = 'http://localhost:3001'; + private readonly _id: string; + + public constructor(config: UptimeKumaInstance) { + if(config.host) this._host = config.host; + this._id = config.id; + } + + /** + * Send a heartbeat to Uptime Kuma + * + * @example + * ```ts + * const kuma = new UptimeKuma(data); + * await kuma.heartbeat(); + * ``` + */ + public async heartbeat(): Promise { + const resp = await fetchWithTimeout( + `${this._host}/api/push/${this._id}?status=up&msg=OK&ping=`, + {method: 'GET'}, + 5000 + ); + if(resp.status !== 200) raise(`${resp.status} - ${resp.statusText}`, 'UptimeKumaHeartbeatNotOK') + return true; + } +} diff --git a/src/core/cache.ts b/src/core/cache.ts new file mode 100644 index 00000000..276a8e33 --- /dev/null +++ b/src/core/cache.ts @@ -0,0 +1,343 @@ +import { CacheItem, CacheMetrics } from "../types/cache.ts"; +import { TimeString } from "../utility/time-string.ts"; +import { Logger } from "./logger.ts"; +import { Configure } from "./configure.ts"; +import {Contract} from "../utility/contract.ts"; + +/** + * Default optimistic boundaries + */ +const OPTIMISTIC_DELAY = '+1 hour'; + +/** + * Very crude but effective in-memory caching + * + * Running {@linkcode Cache.sweep} is up to the app itself. + */ +export class Cache { + private static _items: Map = new Map(); + private static _metrics: CacheMetrics = { + reads: { + hit: 0, + miss: 0 + }, + writes: 0, + swept: 0, + }; + + /** + * Get the metrics for the cache + * + * **NOTE:** Rate will be returned 0-1 + * + * @example Basic usage + * ```ts + * import { Cache } from "https://deno.land/x/chomp/core/cache.ts"; + * const metrics = Cache.metrics(); + * const hits = Cache.metrics("hit"); + * const misses = Cache.metrics("miss"); + * const rate = Cache.metrics("rate"); + * const writes = Cache.metrics("writes"); + * const swept = Cache.metrics("swept"); + * ``` + * + * @param key + */ + public static metrics(key: keyof CacheMetrics["reads"]|"rate"|"total"|"writes"|"swept"|"size"|null = null): T|number|CacheMetrics { + switch(key) { + case "hit": + return Cache._metrics.reads.hit; + case "miss": + return Cache._metrics.reads.miss; + case "total": + return Cache._metrics.reads.hit + Cache._metrics.reads.miss; + case "rate": { + const total: number = Cache.metrics("total") as number; + const percentile = +(Cache._metrics.reads.hit / total).toFixed(4); + if(percentile > 0) return percentile; + return 0; + } + case "writes": + return Cache._metrics.writes; + case "swept": + return Cache._metrics.swept; + case "size": + return Cache._items.size; + default: + return Cache._metrics; + } + } + + /** + * Add an item to the cache. + * + * @example Basic Usage + * ```ts + * import { Cache } from "https://deno.land/x/chomp/core/cache.ts"; + * + * Cache.set('I expire in 1 minute', 'foo'); + * Cache.set('I expire in 10 minutes', 'bar', '+10 minutes'); + * Cache.set('I never expire', 'baz', null); + * ``` + * + * **NOTE**: Expiry times use {@linkcode TimeString} formats. + * + * @param key + * @param value + * @param expiry Can be set to null for never expiring items + */ + // deno-lint-ignore no-explicit-any -- Any arbitrary data may be added to cache + public static set(key: string, value: any, expiry: string | null = "+1 minute"): void { + let expiresAt = null; + let optimisticExpiry = undefined; + + // Check if an expiry is specified + // Calculate the expiry values if so + if (expiry !== null) { + const now = new Date(); + expiresAt = new Date(now.getTime() + TimeString`${expiry}`); + optimisticExpiry = new Date(expiresAt.getTime() + TimeString`${Configure.get('chomp_optimistic_delay', OPTIMISTIC_DELAY)}`) + } + + // Set item in the Cache + Cache._items.set(key, { + data: value, + expires: expiresAt, + optimistic: optimisticExpiry + }); + + // Increase write metric + Cache._metrics.writes++; + } + + /** + * Get an item from the cache + * + * @example Basic Usage + * ```ts + * import { Cache } from "https://deno.land/x/chomp/core/cache.ts"; + * + * Cache.get('cache item name'); + * ``` + * + * @example Getting expired items + * ```ts + * import { Cache } from "https://deno.land/x/chomp/core/cache.ts"; + * + * const item = Cache.get('cache item name', true); + * ``` + * + * @param key + * @param allowOptimism Whether to allow optimistically serve expired items from the cache + */ + public static get(key: string, allowOptimism = false): T | null { + // Return null if the item doesn't exist + if (!Cache.exists(key)) { + Cache._metrics.reads.miss++; + return null; + } + + // Return null if the item expired + const itemHasExpired = Cache.expired(key); + const disallowOptimism = !allowOptimism; + if (itemHasExpired && disallowOptimism) { + Cache._metrics.reads.miss++; + return null; + } + + // Get item from cache + const item = Cache._items.get(key); + Contract.requireNotUndefined(item); + + // Increase hit counter + Cache._metrics.reads.hit++; + + // Return Cache data + return item.data; + } + + /** + * Check whether an item exists in the cache. + * This does *not* check whether the item has expired or not. + * + * @example Basic Usage + * ```ts + * import { Cache } from "https://deno.land/x/chomp/core/cache.ts"; + * + * const doesExist = Cache.exists('cache item name'); + * ``` + * + * @param key + */ + public static exists(key: string): boolean { + return Cache._items.has(key); + } + + /** + * Check whether an item has expired. + * + * @example Basic Usage + * ```ts + * import { Cache } from "https://deno.land/x/chomp/core/cache.ts"; + * + * const hasExpired = Cache.expired('cache item name'); + * ``` + * + * @param key + */ + public static expired(key: string): boolean { + // Get the item from cache + const item = Cache._items.get(key); + + // Make sure the item exists + if(item === undefined) return true; + + // Make sure the item has an expiry + if(item.expires === null) return false; + + // Check whether the item has expired + return item.expires < new Date(); + } + + /** + * Consume an item from the cache. + * Differs from "Cache.get()" in that it removes the item afterwards. + * + * @example Basic Usage + * ```ts + * import { Cache } from "https://deno.land/x/chomp/core/cache.ts"; + * + * const item = Cache.consume('cache item name'); + * ``` + * + * @param key + * @param allowOptimism Whether to allow optimistically serve expired items from the cache + */ + public static consume(key: string, allowOptimism = false): T | null { + // Copy item from cache + const data = Cache.get(key, allowOptimism); + + // Remove item from cache + Cache.remove(key); + + // Return the item + return data; + } + + /** + * Remove an item from the cache + * + * @example Basic Usage + * ```ts + * import { Cache } from "https://deno.land/x/chomp/core/cache.ts"; + * + * Cache.remove('cache item name'); + * ``` + * + * @param key + */ + public static remove(key: string): void { + Cache._items.delete(key); + } + + /** + * Read-through cache. + * + * Will check if the cache item can be obtained and if not, will execute the callable function. + * The result will then be stored in the cache. + * + * **NOTE:** This feature is currently experimental. + * + * TODO: Test whether it actually works as intended + * + * @example Basic Usage + * ```ts + * const res = Cache.remember('cache item name, "+1 minute", async function { return true }); + * ``` + * + * @param key + * @param expiry + * @param callable + */ + public static async remember(key: string, expiry: string | null = "+1 minute", callable: Promise|(() => Promise)): Promise { + // Check if cache item exists and hasn't expired + // If so, return the cached item + const itemNotExpired = !Cache.expired(key); + if(itemNotExpired) return Cache.get(key); + + // Increase metrics + Cache._metrics.reads.miss++; + + // Cache does not exist, run callable + // TODO: Fix "no call signatures" in lint + // @ts-ignore See TODO + const res = await callable(); + + // Add result to cache + Cache.set(key, res, expiry); + + // Return result + return res; + } + + /** + * Dumps the raw cache contents. + * Should only be used for debugging purposes. + * + * @example Basic Usage + * ```ts + * import { Cache } from "https://deno.land/x/chomp/core/cache.ts"; + * + * console.log(Cache.dump()); + * ``` + */ + public static dump(): Map { + return Cache._items; + } + + /** + * Scan the cache and clean up expired items while keeping optimistic caching in tact. + * + * @example Basic Usage + * ```ts + * import { Cache } from "https://deno.land/x/chomp/core/cache.ts"; + * + * Cache.sweep(); + * ``` + */ + public static sweep(): void { + Logger.debug('Starting cache sweep...'); + + // Set the start time of this sweep + const now = new Date(); + + // Loop over each item in the cache + for (const [key, item] of Cache._items) { + // Keep items that do not expire + if (item.expires === null) { + Logger.trace(`Keeping cache item "${key}": Does not expire`); + continue; + } + + // Keep items that have not yet expired + const itemNotExpired = item.expires >= now; + if (itemNotExpired) { + Logger.trace(`Keeping cache item "${key}": Has not expired`); + continue; + } + + // Keep items that may be served optimistically + if (item.optimistic !== undefined && item.optimistic >= now) { + Logger.trace(`Keeping cache item "${key}": Keep for optimistic caching`); + continue; + } + + // Clean up items that have expired + Logger.trace(`Removing expired cache item "${key}"`); + Cache._items.delete(key); + Cache._metrics.swept++; + } + + Logger.debug('Finished cache sweep!'); + } +} diff --git a/src/core/configure.ts b/src/core/configure.ts new file mode 100644 index 00000000..029cb8d3 --- /dev/null +++ b/src/core/configure.ts @@ -0,0 +1,273 @@ +import {Logger, LogLevels} from "./logger.ts"; +import {valueOrDefault} from "../utility/value-or-default.ts"; +import { File } from "../filesystem/file.ts"; +import {empty} from "../utility/empty.ts"; + +// deno-lint-ignore no-explicit-any -- Arbitrary data may be used +const defaults = new Map([ + ["debug", false], + ["log_level", LogLevels.All], + ["error_log", `${Deno.cwd()}/logs/error.log`], +]); + +/** + * In-memory configuration handler. + */ +export class Configure { + // deno-lint-ignore no-explicit-any -- Arbitrary data may be used + private static config: Map = defaults; + private static hasLoaded = false; + + /** + * Load our configure data from file at `${Deno.cwd()}/config.json` or `${Deno.cwd()}/config.ts`. + * + * **NOTE**: Loading from `config.json` is deprecated and will be removed in the future but is currently still the default. + * + * @example Basic Usage + * ```ts + * import { Configure } from "https://deno.land/x/chomp/core/configure.ts"; + * + * await Configure.load(); + * ``` + * + * @example Load from config.ts + * ``` + * import { Configure } from "https://deno.land/x/chomp/core/configure.ts"; + * + * await Configure.load(false, true); + * ``` + * + * @param force Set to true to force re-loading the configure + * @param useTs Set to true to load from config.ts instead of config.json + * @returns void + */ + public static async load(force = false, useTs = false): Promise { + // Make sure we don't have loaded already + if (Configure.hasLoaded === true && force === false) return; + Logger.info(`Loading data into Configure...`); + + if(!useTs) { + Logger.warning('Loading Configure from JSON is deprecated!'); + await Configure._loadJson(); + } else { + const module = await import(`file:///${Deno.cwd()}/config.ts`); + if(!('default' in module)) { + Logger.warning(`Could not load Configure: "${Deno.cwd()}/config.ts" has no default export...`); + Configure.hasLoaded = true; + return; + } + Configure.config = new Map(function*() { yield* defaults; yield* module['default']; }()); + } + + // Mark configure as loaded + Logger.info(`Finished loading Configure!`); + Configure.hasLoaded = true; + } + + /** + * Read the config.json file + * + * @deprecated Switching to using solely TS-based configs + */ + private static async _loadJson() { + const file = new File(`${Deno.cwd()}/config.json`); + + // Make sure our file exists + const isFileMissing = !await file.exists(); + if(isFileMissing) { + Logger.warning(`Could not find file "config.json" at "${Deno.cwd()}". Configure will be empty!`); + Configure.hasLoaded = true; + return; + } + + // Read our JSON content + const json = await file.readTextFile(); + + // Parse JSON + try { + const data = JSON.parse(json); + for (const entry of Object.keys(data)) { + Logger.debug(`Adding "${entry}" into Configure...`); + Configure.set(entry, data[entry]); + } + } catch (e) { + Logger.error(`Could not load JSON: "${e.message}"`, e.stack); + } + } + + /** + * Obtain the value of a key in the configure. + * + * @example Basic Usage + * ```ts + * import { Configure } from "https://deno.land/x/chomp/core/configure.ts"; + * + * await Configure.load(); + * const item = Configure.get('my-item'); + * ``` + * + * @example Setting a default value + * ```ts + * import { Configure } from "https://deno.land/x/chomp/core/configure.ts"; + * + * await Configure.load(); + * const item = Configure.get('my-item', 'my-default'); + * ``` + * + * @param key Key to look for + * @param defaultValue Default value to return when no result was found + * @returns any|null + */ + public static get(key: string, defaultValue: T|null = null): T { + // Return null if we do not have the key + return valueOrDefault(Configure.config.get(key), defaultValue); + } + + /** + * Set a configure item + * It is not possible to store null values + * + * @example Basic Usage + * ```ts + * import { Configure } from "https://deno.land/x/chomp/core/configure.ts"; + * + * await Configure.load(); + * Configure.set('my-item', 'my-value); + * ``` + * + * @param key + * @param value + * @returns void + */ + // deno-lint-ignore no-explicit-any -- Any arbitrary data may be used + public static set(key: string, value: any): void { + const hasData = !empty(value); + if (!hasData) return; + Configure.config.set(key, value); + } + + /** + * Return whether a key exists + * + * @example Basic Usage + * ```ts + * import { Configure } from "https://deno.land/x/chomp/core/configure.ts"; + * + * await Configure.load(); + * const exists = Configure.check('my-item'); + * ``` + * + * @param key + * @returns boolean + */ + public static check(key: string): boolean { + return Configure.config.has(key); + } + + /** + * Consume a key from configure (removing it). + * + * @example Basic Usage + * ```ts + * import { Configure } from "https://deno.land/x/chomp/core/configure.ts"; + * + * await Configure.load(); + * const exists = Configure.consume('my-item'); + * ``` + * + * @example Setting a default value + * ```ts + * import { Configure } from "https://deno.land/x/chomp/core/configure.ts"; + * + * await Configure.load(); + * const exists = Configure.consume('my-item', 'default-value'); + * ``` + * + * @param key + * @param defaultValue + */ + public static consume(key: string, defaultValue: T|null = null): T { + // Check if the key exists, if not, return the default value + const hasConfigureItem = Configure.config.has(key); + if (!hasConfigureItem) return defaultValue as T; + + // Hack together a reference to our item's value + const ref = [Configure.config.get(key)]; + + // Delete the original item + Configure.config.delete(key); + + // Return the value + return ref[0]; + } + + /** + * Delete a ConfigureItem from the Configure + * + * @example Basic Usage + * ```ts + * import { Configure } from "https://deno.land/x/chomp/core/configure.ts"; + * + * await Configure.load(); + * Configure.delete('my-item'); + * ``` + * + * @param key + * @returns void + */ + public static delete(key: string): void { + Configure.config.delete(key); + } + + /** + * Dump all contents of the Configure + * + * @example Basic Usage + * ```ts + * import { Configure } from "https://deno.land/x/chomp/core/configure.ts"; + * + * await Configure.load(); + * console.log(Configure.dump()); + * ``` + * + * @returns ConfigureItem[] + */ + // deno-lint-ignore no-explicit-any -- Any arbitrary data may be used + public static dump(): Map { + return Configure.config; + } + + /** + * Clear all items in the configure (including defaults). + * If you want to keep the defaults, use {@linkcode Configure.reset()} instead. + * + * @example Basic Usage + * ```ts + * import { Configure } from "https://deno.land/x/chomp/core/configure.ts"; + * + * await Configure.load(); + * Configure.clear(); + * ``` + * + * @returns void + */ + public static clear(): void { + Configure.config.clear(); + } + + /** + * Resets the configure to the defaults. + * If you do not want to keep the defaults, use "Configure.clear()" instead. + * + * @example Basic Usage + * ```ts + * import { Configure } from "https://deno.land/x/chomp/core/configure.ts"; + * + * await Configure.load(); + * Configure.reset(); + * ``` + */ + public static reset(): void { + Configure.config = defaults; + } +} diff --git a/src/core/logger.ts b/src/core/logger.ts new file mode 100644 index 00000000..3c543c6f --- /dev/null +++ b/src/core/logger.ts @@ -0,0 +1,210 @@ +import { LogLevels, LogLevelKeys, LogHandlers, LogLevelHandlerKeys } from "../types/logging.ts"; +import { Time } from "../utility/time.ts"; +import { Configure } from "./configure.ts"; +import { bold, cyan, magenta, red, yellow, blue, green, gray } from "https://deno.land/std@0.117.0/fmt/colors.ts"; + +/** + * Exporting LogLevels to make it easier to use this class. + */ +export { LogLevels }; + +const handlers: LogHandlers = { + error: (message: string, stack: string | null = null): void => { + // Get current time + const now = Logger.time(); + + // Check if we need to write to file + // Write to file if need be + if (Configure.get("error_log", false)) { + try { + let output = `[${now}] ERROR > ${message}`; + if (stack) output += `\r\n${stack}`; + void Deno.writeTextFile(Configure.get("error_log"), output, { append: true }); + } catch (e) { + console.error(`Could not append to error log: "${e.message}"`); + } + } + + // Write to console + let output = `[${now}] ${red(bold("ERROR"))} > ${message}`; + if (stack) output += `\r\n${stack}`; + console.error(output); + }, + success: (message: string): void => { + console.log(`[${Logger.time()}] ${green("SUCCESS")} > ${message}`); + }, + warning: (message: string): void => { + console.error(`[${Logger.time()}] ${yellow("WARN")} > ${message}`); + }, + notice: (message: string): void => { + console.error(`[${Logger.time()}] ${blue("NOTICE")} > ${message}`); + }, + info: (message: string): void => { + console.log(`[${Logger.time()}] ${cyan("INFO")} > ${message}`); + }, + monitor: (message: string): void => { + console.log(`[${Logger.time()}] ${green("MONIT")} > ${message}`); + }, + debug: (message: string): void => { + if (Configure.get("debug", false)) { + console.log(`[${Logger.time()}] ${magenta("DEBUG")} > ${message}`); + } + }, + trace: (message: string): void => { + if (Configure.get("debug", false)) { + console.log(`[${Logger.time()}] ${gray("TRACE")} > ${message}`); + } + } +}; + +/** + * Logging handler for writing to console + */ +export class Logger { + private static _handlers: LogHandlers = handlers; + + /** + * Override a handler app-wide. + * + * @param level {LogLevels} + * @param handler {any} + */ + // deno-lint-ignore no-explicit-any -- TODO: Figure out how to replace any type with something more sane + public static setHandler(level: LogLevelHandlerKeys, handler: any): void { + Logger._handlers[level] = handler; + } + + /** + * Write an error message to the console. + * + * Using the default handler, if the "error_log" Configure item is set, will also write to file. + * + * Available in any log level. + * + * @param {string} message The message to write + * @param {string|null} stack Optional stacktrace + * @returns {void} + */ + public static error(message: string, stack: string | null = null): void { + if(Logger.shouldLog("Error")) Logger._handlers["error"](message, stack); + } + + /** + * Write a success message to the console. + * + * Available in any log level. + * + * @param {string} message The message to write + * @returns {void} + */ + public static success(message: string): void { + if(Logger.shouldLog("Success")) Logger._handlers["success"](message); + } + + /** + * Write a warning message to the console + * + * Available in log levels 0 and higher. + * + * @param {string} message The message to write + * @returns {void} + */ + public static warning(message: string): void { + if(Logger.shouldLog("Warning")) Logger._handlers["warning"](message); + } + + /** + * Write a notice to the console + * + * Available in log levels 1 and higher. + * + * @param {string} message The message to write + * @returns {void} + */ + public static notice(message: string): void { + if(Logger.shouldLog("Notice")) Logger._handlers["notice"](message); + } + + /** + * Write an info message to the console + * + * Available in log levels 2 and higher. + * + * @param {string} message The message to write + * @returns {void} + */ + public static info(message: string): void { + if(Logger.shouldLog("Info")) Logger._handlers["info"](message); + } + + /** + * Write a monitor message to the console + * Useful for when you want to write performance-related messages + * + * Available in log levels 3 and higher. + * + * @param message + * @returns {void} + */ + public static monitor(message: string): void { + if(Logger.shouldLog("Monitor")) Logger._handlers["monitor"](message); + } + + /** + * Write a debug message to the console + * + * Available in log levels 4 and higher. + * + * @param {string} message The message to write + * @returns {void} + */ + public static debug(message: string): void { + if(Logger.shouldLog("Debug")) Logger._handlers["debug"](message); + } + + /** + * Write a trace message to the console. + * By default, only shows up when the "DEBUG" env is set to truthy. + * + * Available in log levels 5 and higher. + * + * @param message + * @returns {void} + */ + public static trace(message: string): void { + if(Logger.shouldLog("Trace")) Logger._handlers["trace"](message); + } + + /** + * Return the current time in format. + * Configurable using the "logger.timeformat" key. + * Defaults to "yyyy/MM/dd HH:mm:ss" (2020/11/28 20:50:30) + * https://github.com/denoland/deno_std/tree/0.77.0/datetime#datetime + * + * @returns {string} The formatted time + */ + public static time(): string { + return new Time().format(Configure.get("logger.timeformat", "yyyy/MM/dd HH:mm:ss")); + } + + /** + * Check whether the log level is enabled + * + * @param level + * @private + */ + private static shouldLog(level: LogLevelKeys): boolean { + // Get enabled log levels + const enabledLevels = Configure.get("log_level", LogLevels.All); + + // Check if log enabled levels includes "all" + const allEnabled = (enabledLevels & LogLevels.All) === LogLevels.All; + if(allEnabled) return true; + + // Get bitmask for requested level + const bitmask = LogLevels[level]; + + // Check whether the current level is enabled + return (enabledLevels & bitmask) === bitmask; + } +} diff --git a/src/core/mod.ts b/src/core/mod.ts new file mode 100644 index 00000000..0053c51e --- /dev/null +++ b/src/core/mod.ts @@ -0,0 +1,3 @@ +export * from "./cache.ts"; +export * from "./configure.ts"; +export * from "./logger.ts"; diff --git a/src/error/error-codes.ts b/src/error/error-codes.ts new file mode 100644 index 00000000..8e1515da --- /dev/null +++ b/src/error/error-codes.ts @@ -0,0 +1,32 @@ +/** + * Standardized error codes, because nobody likes meaningless error codes. + */ +export enum ErrorCodes { + // Standard Errors + UNKNOWN_ERROR = 0x0000000, + DEPRECATED_FUNCTION_CALL = 0x000001, + + // Data errors + // TODO: Implement + + // Filesystem Errors + FILE_READ_GENERIC = 0x210, + FILE_READ_NOT_FOUND = 0x211, + FILE_READ_NO_PERM = 0x212, + FILE_WRITE_GENERIC = 0x220, + FILE_WRITE_NOT_FOUND = 0x221, + FILE_WRITE_NO_PERM = 0x222, + + // Upstream Errors + UPSTREAM_HTTP_BAD_REQUEST = 0x300400, + UPSTREAM_HTTP_UNAUTHORIZED = 0x300401, + UPSTREAM_HTTP_FORBIDDEN = 0x300403, + UPSTREAM_HTTP_NOT_FOUND = 0x300404, + UPSTREAM_HTTP_METHOD_NOT_ALLOWED = 0x300405, + UPSTREAM_HTTP_REQUEST_TIMEOUT = 0x300408, + UPSTREAM_HTTP_TOO_MANY_REQUESTS = 0x300429, + UPSTREAM_HTTP_INTERNAL_SERVER_ERROR = 0x300500, + UPSTREAM_HTTP_BAD_GATEWAY = 0x300502, + UPSTREAM_HTTP_SERVICE_UNAVAILABLE = 0x300503, + UPSTREAM_HTTP_GATEWAY_TIMEOUT = 0x300504, +} diff --git a/src/error/raise.ts b/src/error/raise.ts new file mode 100644 index 00000000..0362b23b --- /dev/null +++ b/src/error/raise.ts @@ -0,0 +1,60 @@ +/** + * Utility function that throws an error. + * Band-aid for JS not supporting throwing in null-coalescing. + * + * @example Basic Usage + * ```ts + * import { raise } from "https://deno.land/x/chomp/error/raise.ts"; + * + * const myVar = null ?? raise('Error Message'); + * ``` + * + * @example Custom Error types + * ```ts + * import { raise } from "https://deno.land/x/chomp/error/raise.ts"; + * + * const myVar = null ?? raise('Error Message', 'CustomError'); + * + * // Will automatically append "Error" to the name + * const myVar = null ?? raise('Error Message', 'Custom'); + * ``` + * + * @example Custom Error (using Error-classes) + * ```ts + * import { raise } from "https://deno.land/x/chomp/error/raise.ts"; + * + * class CustomError extends Error { + * constructor(public message: string) { + * super(message); + * } + * } + * + * const myVar = null ?? raise('Error Message', CustomError); + * ``` + * + * @param err + * @param type + */ +export function raise( + err: string, + type: string | (new (err: string) => CustomError) | "Error" = "Error", +): never { + // Check if we want to throw a specific class + if (typeof type === "function" && type.prototype instanceof Error) { + const e = new type(err); + Error.captureStackTrace(e, raise); + e.name = type.name; + throw e; + } + + // Initialize regular error + // Then add our stacktrace + const e = new Error(err); + Error.captureStackTrace(e, raise); + + // Check if we want to change the name + if (type !== "Error") e.name = (type as string).slice(-5).toLowerCase() !== "error" ? `${type}Error` : type as string; + + // Throw the error + throw e; +} diff --git a/src/extensions/array/includes-any.ts b/src/extensions/array/includes-any.ts new file mode 100644 index 00000000..f5c293d7 --- /dev/null +++ b/src/extensions/array/includes-any.ts @@ -0,0 +1,12 @@ +/** + * Add function to Array interface + */ +declare global { + interface Array { + includesAny: (compareTo: T[]) => boolean; + } +} + +Array.prototype.includesAny = function(compareTo: T[]) { + return compareTo.some((value: T) => this.includes(value)); +} diff --git a/src/extensions/bigint/to-json.ts b/src/extensions/bigint/to-json.ts new file mode 100644 index 00000000..b22cb320 --- /dev/null +++ b/src/extensions/bigint/to-json.ts @@ -0,0 +1,16 @@ +/** + * Add function to BigInt interface + */ +declare global { + interface BigInt { + toJSON(): string; + } +} + +/** + * Turn BigInt into String when turning it into JSON. + * (Why isn't this a thing in JS itself?) + */ +BigInt.prototype.toJSON = function () { + return this.toString(); +}; diff --git a/src/extensions/date/is-after-or-equal.ts b/src/extensions/date/is-after-or-equal.ts new file mode 100644 index 00000000..6a78c022 --- /dev/null +++ b/src/extensions/date/is-after-or-equal.ts @@ -0,0 +1,17 @@ +/** + * Add function to Date interface + */ +declare global { + interface Date { + isAfterOrEqual(date: Date): boolean; + } +} + +/** + * Check whether the date is after or equal to the input date. + * + * @param date + */ +Date.prototype.isAfterOrEqual = function(date: Date) { + return this.getTime() >= date.getTime(); +} diff --git a/src/extensions/date/is-after.ts b/src/extensions/date/is-after.ts new file mode 100644 index 00000000..a4552948 --- /dev/null +++ b/src/extensions/date/is-after.ts @@ -0,0 +1,17 @@ +/** + * Add function to Date interface + */ +declare global { + interface Date { + isAfter(date: Date): boolean; + } +} + +/** + * Check whether the date is after the input date. + * + * @param date + */ +Date.prototype.isAfter = function(date: Date) { + return this.getTime() > date.getTime(); +} diff --git a/src/extensions/date/is-before-or-equal.ts b/src/extensions/date/is-before-or-equal.ts new file mode 100644 index 00000000..77b19d37 --- /dev/null +++ b/src/extensions/date/is-before-or-equal.ts @@ -0,0 +1,17 @@ +/** + * Add function to Date interface + */ +declare global { + interface Date { + isBeforeOrEqual(date: Date): boolean; + } +} + +/** + * Check whether the date is before or equal to the input date. + * + * @param date + */ +Date.prototype.isBeforeOrEqual = function(date: Date) { + return date.getTime() >= this.getTime(); +} diff --git a/src/extensions/date/is-before.ts b/src/extensions/date/is-before.ts new file mode 100644 index 00000000..74fc16b4 --- /dev/null +++ b/src/extensions/date/is-before.ts @@ -0,0 +1,17 @@ +/** + * Add function to Date interface + */ +declare global { + interface Date { + isBefore(date: Date): boolean; + } +} + +/** + * Check whether the date is before the input date. + * + * @param date + */ +Date.prototype.isBefore = function(date: Date) { + return date.getTime() > this.getTime(); +} diff --git a/src/extensions/date/set-midnight.ts b/src/extensions/date/set-midnight.ts new file mode 100644 index 00000000..1570f0db --- /dev/null +++ b/src/extensions/date/set-midnight.ts @@ -0,0 +1,33 @@ +/** + * Add function to Date interface + */ +declare global { + interface Date { + setMidnight: (goToTomorrow?: boolean) => Date; + } +} + +/** + * Set the date to midnight (00:00:00.000). + * + * @example Basic usage + * ```ts + * const start = new Date('2025-09-16T12:13:56.123Z'); + * start.setMidnight(); + * console.log(start.toISOString()); // 2025-09-16T23:00:00.000Z + * ``` + * + * @example Go to tomorrow + * ```ts + * const start = new Date('2025-09-16T12:13:56.123Z'); + * start.setMidnight(true); + * console.log(start.toISOString()); // 2025-09-17T23:00:00.000Z + * ``` + * + * @param goToTomorrow Whether to set to midnight tomorrow + */ +Date.prototype.setMidnight = function(goToTomorrow: boolean = false) { + this.setHours(0, 0, 0, 0); + if(goToTomorrow) this.setDate(this.getDate() + 1); + return this; +} diff --git a/src/extensions/string/empty.ts b/src/extensions/string/empty.ts new file mode 100644 index 00000000..9789f5da --- /dev/null +++ b/src/extensions/string/empty.ts @@ -0,0 +1,13 @@ +/** + * Add function to String interface + */ +declare global { + interface StringConstructor { + empty: string; + } +} + +/** + * Add empty type + */ +String.empty = ""; diff --git a/src/filesystem/file.ts b/src/filesystem/file.ts new file mode 100644 index 00000000..221c8837 --- /dev/null +++ b/src/filesystem/file.ts @@ -0,0 +1,79 @@ +/** + * Interact with a file + * + * TODO: Finish documentation + */ +export class File { + public constructor( + private readonly path: string, + ) { + } + + public async exists(): Promise { + try { + const target = await Deno.stat(this.path); + return target.isFile; + } catch (e) { + if (e instanceof Deno.errors.NotFound) return false; + throw e; + } + } + + public async create(): Promise { + await Deno.create(this.path); + } + + public async delete(): Promise { + await Deno.remove(this.path); + } + + public async move(path: string): Promise { + try { + await Deno.rename(this.path, path); + return new File(path); + }catch(e) { + return false; + } + } + + public async copy(path: string): Promise { + try { + await Deno.copyFile(this.path, path); + return new File(path); + }catch(e) { + return false; + } + } + + public ext(): string { + const pos = this.path.lastIndexOf("."); + if (pos < 1) return ""; + return this.path.slice(pos + 1); + } + + public readTextFile(): Promise { + return Deno.readTextFile(this.path); + } + + public readFile(): Promise { + return Deno.readFile(this.path); + } + + public async writeTextFile(data: string|ReadableStream, options?: Deno.WriteFileOptions): Promise { + try { + await Deno.writeTextFile(this.path, data, options); + return true; + } catch(e) { + return false; + } + } + + public async writeFile(data: Uint8Array|ReadableStream, options?: Deno.WriteFileOptions): Promise { + try { + await Deno.writeFile(this.path, data, options); + return true; + } catch(e) { + return false; + } + } +} diff --git a/src/filesystem/folder.ts b/src/filesystem/folder.ts new file mode 100644 index 00000000..5396b724 --- /dev/null +++ b/src/filesystem/folder.ts @@ -0,0 +1,41 @@ +import { Logger } from "../core/logger.ts"; + +/** + * Interact with a file + * + * TODO: Finish documentation + */ +export class Folder { + public constructor( + private readonly path: string, + ) { + } + + /** + * Check whether the folder exists at the path. + */ + public async exists(): Promise { + try { + const target = await Deno.stat(this.path); + return target.isDirectory; + } catch (e) { + if (e instanceof Deno.errors.NotFound) return false; + throw e; + } + } + + /** + * Create the directory if it does not exist yet. + * + * @param options Options with which to create the directory + */ + public async create(options?: Deno.MkdirOptions): Promise { + try { + if (await this.exists()) throw new Error("The specified folder already exists!"); + } catch (e) { + Logger.warning(e.message); + } + + await Deno.mkdir(this.path, options); + } +} diff --git a/src/queue/queue.ts b/src/queue/queue.ts new file mode 100644 index 00000000..b23c820a --- /dev/null +++ b/src/queue/queue.ts @@ -0,0 +1,114 @@ +import { QueueItem, Scheduler } from "../types/queue.ts"; +import { default as defaultScheduler } from "./scheduler/first-in-first-out.ts"; + +/** + * Crude yet effective in-memory Queue system + */ +export class Queue { + private items: QueueItem[] = []; + private readonly scheduler: Scheduler; + + public constructor(scheduler: Scheduler = defaultScheduler) { + this.scheduler = scheduler; + } + + /** + * Get the number of items contained in the Queue + * + * @returns number + */ + public get count(): number { + return this.items.length; + } + + /** + * Check whether the Queue has any items + * + * @returns boolean + */ + public get isEmpty(): boolean { + return this.items.length === 0; + } + + /** + * Get the next item from the queue. + * Unlike the Queue#peek() method, this *does* remove the item. + * + * @returns QueueItem + */ + public get next(): QueueItem | null { + // Make sure we have items in our queue + if (this.items.length === 0) return null; + + // Return the first item in our queue and remove it + // @ts-ignore We already return null when no items are present + return this.items.shift(); + } + + /** + * Get the next item from the queue. + * Unlike the Queue#next() method, this does *not* remove the item. + * + * @returns QueueItem|null + */ + public get peek(): QueueItem | null { + // Make sure we have items in our queue + if (this.items.length === 0) return null; + + // Return the first item in our queue + return this.items[0]; + } + + /** + * Return all the items in the queue + * + * @returns QueueItems[] + */ + public get dump(): QueueItem[] { + return [...this.items]; + } + + /** + * Add an item to the queue + * + * @param item Item to add to the queue + */ + public add(item: QueueItem): void { + // Make sure data was set + if (Object.keys(item.data).length === 0) throw Error("Data for queue item may not be empty!"); + + // Add item to the queue via the scheduler + this.items = this.scheduler(item, this.items); + } + + /** + * Check whether the queue already contains an identical item + * + * @returns boolean + */ + public contains(item: QueueItem): boolean { + const itemKeys = Object.keys(item.data); + const match = this.items.find((queued: QueueItem) => { + // Check if the weights are the same + if (queued.weight !== item.weight) return false; + + // Check if all keys exists and if they have the same value + for (const key of itemKeys) { + // deno-lint-ignore no-prototype-builtins -- TODO + if (!queued.data.hasOwnProperty(key)) return false; + if (queued.data[key] !== item.data[key]) return false; + } + return true; + }); + return typeof match !== "undefined"; + } + + /** + * Remove all items from the Queue + * + * @returns void + */ + public clear(): void { + this.items = []; + } +} diff --git a/src/queue/scheduler/first-in-first-out.ts b/src/queue/scheduler/first-in-first-out.ts new file mode 100644 index 00000000..69c249b4 --- /dev/null +++ b/src/queue/scheduler/first-in-first-out.ts @@ -0,0 +1,12 @@ +import {QueueItem} from "../../types/queue.ts"; + +export default function(item: QueueItem, items: QueueItem[] = []) { + // Remove weight if specified + if ("weight" in item) delete item.weight; + + // Add item to the queue + items.push(item); + + // Return the queue + return items; +} diff --git a/src/queue/scheduler/last-in-first-out.ts b/src/queue/scheduler/last-in-first-out.ts new file mode 100644 index 00000000..d82d7d29 --- /dev/null +++ b/src/queue/scheduler/last-in-first-out.ts @@ -0,0 +1,12 @@ +import {QueueItem} from "../../types/queue.ts"; + +export default function(item: QueueItem, items: QueueItem[] = []) { + // Remove weight if specified + if ("weight" in item) delete item.weight; + + // Add item to the queue + items.unshift(item); + + // Return the queue + return items; +} diff --git a/src/queue/scheduler/weighted-first-in-first-out.ts b/src/queue/scheduler/weighted-first-in-first-out.ts new file mode 100644 index 00000000..b33a11e4 --- /dev/null +++ b/src/queue/scheduler/weighted-first-in-first-out.ts @@ -0,0 +1,22 @@ +import {QueueItem} from "../../types/queue.ts"; +import {valueOrDefault} from "../../utility/value-or-default.ts"; + +export default function(item: QueueItem, items: QueueItem[] = []) { + // Check if weight was set, otherwise default to 0 + item.weight = valueOrDefault(item.weight, 0); + + // Loop over all items in queue, add it at the bottom of it's weight + for (let i = 0; i < items.length; i++) { + // @ts-ignore Weight is set to 0 by default + if (item.weight > items[i].weight || i === items.length) { + items.splice(i, 0, item); + return items; + } + } + + // Add item to the queue + items.push(item); + + // Return the queue + return items; +} diff --git a/src/security/hash.ts b/src/security/hash.ts new file mode 100644 index 00000000..77a1625e --- /dev/null +++ b/src/security/hash.ts @@ -0,0 +1,58 @@ +import { Algorithms } from "../types/hash.ts"; +import { DigestAlgorithm } from "https://cdn.deno.land/std/versions/0.113.0/raw/_wasm_crypto/mod.ts"; +import { crypto } from "https://deno.land/std@0.113.0/crypto/mod.ts"; +import { encodeHex } from "jsr:@std/encoding@1.0.10"; + +/** + * Create hashes + * + * **NOTE**: If you want to hash passwords, use the {@linkcode Password} class instead! + */ +export class Hash { + private result!: ArrayBuffer; + + constructor( + private input: string, + private algo: Algorithms, + ) {} + + /** + * Digest the input + * + * @example Basic usage + * ```ts + * import { Hash } from "https://deno.land/x/chomp/security/hash.ts"; + * + * const hash = new Hash("some data"); + * await hash.digest(); + * console.log(hash.hex()); + * ``` + * + * @example Using BLAKE2B384 + * ```ts + * import { Hash, Algorithms } from "https://deno.land/x/chomp/security/hash.ts"; + * + * const hash = new Hash("some data", Algorithms.BLAKE2B384); + * await hash.digest(); + * ``` + */ + public async digest() { + this.result = await crypto.subtle.digest(this.algo as DigestAlgorithm, new TextEncoder().encode(this.input)); + } + + /** + * Digest the input + * + * @example Basic usage + * ```ts + * import { Hash } from "https://deno.land/x/chomp/security/hash.ts"; + * + * const hash = new Hash("some data"); + * await hash.digest(); + * console.log(hash.hex()); + * ``` + */ + public hex() { + return encodeHex(this.result); + } +} diff --git a/src/security/password.ts b/src/security/password.ts new file mode 100644 index 00000000..d122261d --- /dev/null +++ b/src/security/password.ts @@ -0,0 +1,112 @@ +import {Algorithms, INSECURE_ALGORITHMS} from "../types/hash.ts"; +import {HASH_IDENTIFIERS, PasswordOptions} from "../types/password.ts"; +import {Hash} from "./hash.ts"; +import {Random} from "./random.ts"; +import {Logger, raise} from "../../mod.ts"; +import {valueOrDefault} from "../utility/value-or-default.ts"; + +/** + * Recommended hashing algorithm for most use-cases. + * May change over time to keep up with NIST approved algorithms + */ +export const PASSWORD_DEFAULT = Algorithms.SHA3_256; + +/** + * Default options for password hashing. + * These defaults offer a good balance between performance and security. + */ +export const DEFAULT_OPTS: PasswordOptions = { + cost: 10, + allowInsecure: false, +}; + +/** + * Create password hashes and easily verify them. + * Automatically salts the hashes. + * + * Heavily inspired by PHP's {@link https://www.php.net/manual/en/function.password-hash.php password_hash} + * and {@link https://www.php.net/manual/en/function.password-verify.php password_verify} functions. + * However, not compatible with one and another at the moment. + * + * **NOTE**: If you want to create deterministic hashes, use the {@linkcode Hash} class instead! + */ +export class Password { + /** + * Hash the password using the specified password algorithm + * + * @param password + * @param algo + * @param options + * @returns Hash string containing algo, cost, salt and hash + */ + public static async hash( + password: string, + algo: Algorithms = PASSWORD_DEFAULT, + options: PasswordOptions = DEFAULT_OPTS, + ): Promise { + // Make sure we are not using an insecure algorithm + const isUsingInsecureAlgorithm = INSECURE_ALGORITHMS.includes(algo); + const mayUseInsecureAlgorithms = options.allowInsecure; + if (isUsingInsecureAlgorithm && !mayUseInsecureAlgorithms) raise("Insecure hashing algorithm selected, aborting."); + + // Make sure cost is set, else, use a default + const cost = valueOrDefault(options.cost, 0); + const costIsAboveZero = cost > 0; + if (!costIsAboveZero) options.cost = DEFAULT_OPTS.cost; + + // Get our identifier + const identifierIndex = Object.values(HASH_IDENTIFIERS).indexOf(algo as unknown as HASH_IDENTIFIERS); + if (!identifierIndex) throw Error(`Identifier for algorithm "${algo}" could not be found!`); + const identifier = Object.keys(HASH_IDENTIFIERS)[identifierIndex]; + + // Create salt if need be + // Warn if we use a static salt + if(options.salt !== undefined) { + Logger.warning("Using statically defined salt, this is not suitable for production usage!"); + } + const salt = valueOrDefault(options.salt, Random.string(32)); + + // Hash our password + const result = await Password.doHash(password, algo, salt, options.cost!); + + // Return our final hash string + return `${identifier}!${options.cost}!${salt}!${result}`; + } + + /** + * @param password Input password to check against + * @param hash Hash string input from Password.hash() + * @returns Promise Whether the password was valid or not + */ + public static async verify(password: string, hash: string): Promise { + // Split input hash at the delimiter + // Then build our data + const tokens = hash.split("!"); + if (tokens.length < 4) throw Error("Malformed input hash"); + const data = { + algo: HASH_IDENTIFIERS[tokens[0] as keyof typeof HASH_IDENTIFIERS], + cost: Number(tokens[1]), + salt: tokens[2], + hash: tokens[3], + }; + + // Create our hash + const result = await Password.doHash(password, data.algo, data.salt, data.cost); + + // Compare hash and return the result + return result === data.hash; + } + + private static async doHash(input: string, algo: string, salt: string, cost: number): Promise { + const rounds = 2 ** cost; + let result = input; + + for (let round = 0; round < rounds; round++) { + const h = new Hash(`${salt}${input}`, algo as Algorithms); + await h.digest(); + result = h.hex(); + } + + return result; + } +} diff --git a/src/security/random.ts b/src/security/random.ts new file mode 100644 index 00000000..75e4636a --- /dev/null +++ b/src/security/random.ts @@ -0,0 +1,130 @@ +/** + * Create some randomness because I learned how to exit VIM. + */ +export class Random { + /** + * Generate random bytes. + * These bytes are generated using the Web Crypto API, this is cryptographically secure. + * + * @example Basic Usage + * ```ts + * import { Random } from "https://deno.land/x/chomp/security/random.ts"; + * + * // Generates 16 random bytes + * const bytes = Random.bytes(16); + * ``` + * + * @param length Amount of bytes to be generated + * @returns Uint8Array + */ + public static bytes(length: number): Uint8Array { + const buf = new Uint8Array(length); + crypto.getRandomValues(buf); + return buf; + } + + /** + * Generate a random string. + * These strings are generated using the Web Crypto API, this is cryptographically secure. + * + * @example Basic Usage + * ```ts + * import { Random } from "https://deno.land/x/chomp/security/random.ts"; + * + * // Generates a string with 16 characters + * const str = await Random.string(16); + * ``` + * + * @param length Length of the string to be generated + * @returns Promise + */ + public static string(length: number): string { + // Calculate how much bytes we need to generate to fulfill this request + // If we have an odd number, generate one extra byte + let generateLength = length; + if(generateLength % 2 !== 0) generateLength++; + + // Generate our random bytes + const buf = Random.bytes(generateLength / 2); + + // Turn bytes into string + const str = + Array + .from(buf, (dec: number) => dec.toString(16).padStart(2, "0")) + .join(""); + + // Return requested length + return str.substring(0, length); + } + + /** + * Inclusively generate a random integer between min and max. + * If you want to use decimals, please use "{@linkcode Random.float()}" instead. + * + * By default, these integers are **NOT** cryptographically secure (for performance reasons). + * Set the "secure" argument to "true" if you are using this for cryptographic purposes! + * + * @example Basic Usage + * ```ts + * import { Random } from "https://deno.land/x/chomp/security/random.ts"; + * + * // Generate a random integer between 0 and 10 + * const num = await Random.integer(0, 10); + * ``` + * + * @example Cryptographically secure + * ```ts + * import { Random } from "https://deno.land/x/chomp/security/random.ts"; + * + * // Generate a secure random integer between 0 and 10 + * const num = await Random.integer(0, 10, true); + * ``` + * + * @param min Minimum allowable integer + * @param max Maximum allowable integer + * @param secure Using this for cryptographic purposes? + */ + public static integer(min = 0, max = 1, secure = false): number { + // Strip decimals + min = Math.ceil(min); + max = Math.floor(max); + + // Generate a number using Random.float and floor that + return Math.floor(Random.float(min, max, secure)); + } + + /** + * Inclusively generate a random float between min and max. + * If you do not want to use decimals, please use Random.integer() instead. + * + * By default, these floats are **NOT** cryptographically secure (for performance reasons). + * Set the "secure" argument to "true" if you are using this for cryptographic purposes! + * + * @example Basic Usage + * ```ts + * import { Random } from "https://deno.land/x/chomp/security/random.ts"; + * + * // Generate a random float between 0 and 1 + * const num = await Random.float(0, 1); + * ``` + * + * @example Cryptographically secure + * ```ts + * import { Random } from "https://deno.land/x/chomp/security/random.ts"; + * + * // Generate a secure random float between 0 and 1 + * const num = await Random.float(0, 1, true); + * ``` + * + * @param min Minimum allowable float + * @param max Maximum allowable float + * @param secure Using this for cryptographic purposes? + */ + public static float(min = 0, max = 1, secure = false): number { + // Generate our randomness + const random = secure ? crypto.getRandomValues(new Uint32Array(1))[0] / Math.pow(2, 32) : Math.random(); + + // Limit and return + return random * (max - min + 1) + min; + } +} diff --git a/src/types/cache.ts b/src/types/cache.ts new file mode 100644 index 00000000..c9d8b127 --- /dev/null +++ b/src/types/cache.ts @@ -0,0 +1,16 @@ +export type CacheItem = { + // deno-lint-ignore no-explicit-any -- Any arbitrary data may be added to cache + data: any; + expires: Date | null; + optimistic?: Date; +} + +export type CacheMetrics = { + reads: { + hit: number; + miss: number; + }; + writes: number; + swept: number; +} + diff --git a/src/types/check-source.ts b/src/types/check-source.ts new file mode 100644 index 00000000..eebdb2f2 --- /dev/null +++ b/src/types/check-source.ts @@ -0,0 +1,4 @@ +export interface ExclusionConfig { + directories?: string[]; + files?: string[]; +} diff --git a/src/types/couchdb.ts b/src/types/couchdb.ts new file mode 100644 index 00000000..ba4a4a48 --- /dev/null +++ b/src/types/couchdb.ts @@ -0,0 +1,51 @@ +export type Auth = { + username: string; + password: string; +} + +export type CouchRequest = { + method: string; + // deno-lint-ignore no-explicit-any -- TODO: Figure out proper type + headers: any; + body?: string; +} + +export type CachedResponse = { + etag: string; + data: CouchResponse; +} + +export type CouchFailure = [ + // Error data + { error: string; reason: string; } | undefined, + + // No document + undefined, + + // HTTP Status code + number, +] + +export type CouchSuccess = [ + // No error + undefined, + + // Document + // deno-lint-ignore no-explicit-any -- TODO: Figure out proper type + DocumentHeader & any, + + // HTTP Status code + number, +] + +export type CouchResponse = CouchSuccess|CouchFailure; + +export interface CouchOverrides { + method?: string; + etag?: string; +} + +export type DocumentHeader = { + _id: string; + _rev?: string; +} diff --git a/src/types/error-or-data.ts b/src/types/error-or-data.ts new file mode 100644 index 00000000..62f0c36a --- /dev/null +++ b/src/types/error-or-data.ts @@ -0,0 +1 @@ +export type SuccessResponse = [undefined, T]; diff --git a/security/hash.ts b/src/types/hash.ts similarity index 50% rename from security/hash.ts rename to src/types/hash.ts index 87342388..e45b4a5c 100644 --- a/security/hash.ts +++ b/src/types/hash.ts @@ -1,18 +1,15 @@ -import { DigestAlgorithm } from "https://cdn.deno.land/std/versions/0.113.0/raw/_wasm_crypto/mod.ts"; -import { crypto } from "https://deno.land/std@0.113.0/crypto/mod.ts"; - /** * List of algorithms supported by this library */ export enum Algorithms { - SHA384 = 'SHA-384', - SHA3_224 = 'SHA3-224', - SHA3_256 = 'SHA3-256', - SHA3_384 = 'SHA3-384', - SHA3_512 = 'SHA3-512', - SHAKE128 = 'SHAKE128', - SHAKE256 = 'SHAKE256', - BLAKE2B256 = 'BLAKE2B-256', + SHA384 = "SHA-384", + SHA3_224 = "SHA3-224", + SHA3_256 = "SHA3-256", + SHA3_384 = "SHA3-384", + SHA3_512 = "SHA3-512", + SHAKE128 = "SHAKE128", + SHAKE256 = "SHAKE256", + BLAKE2B256 = "BLAKE2B-256", BLAKE2B384 = "BLAKE2B-384", BLAKE2B = "BLAKE2B", BLAKE2S = "BLAKE2S", @@ -24,15 +21,15 @@ export enum Algorithms { /* Insecure, please do not use in production */ RIPEMD160 = "RIPEMD-160", /* Insecure, please do not use in production */ - SHA224 = 'SHA-224', + SHA224 = "SHA-224", /* Insecure, please do not use in production */ - SHA256 = 'SHA-256', + SHA256 = "SHA-256", /* Insecure, please do not use in production */ - SHA512 = 'SHA-512', + SHA512 = "SHA-512", /* Insecure, please do not use in production */ - SHA1 = 'SHA-1', + SHA1 = "SHA-1", /* Insecure, please do not use in production */ - MD5 = 'MD5', + MD5 = "MD5", } /** @@ -46,20 +43,3 @@ export const INSECURE_ALGORITHMS: string[] = [ Algorithms.SHA1, Algorithms.MD5, ]; - -export class Hash { - private result: any; - - constructor( - private input: string, - private algo: string, - ) {} - - public async digest() { - this.result = await crypto.subtle.digest(this.algo as DigestAlgorithm, new TextEncoder().encode(this.input)); - } - - public hex() { - return [...new Uint8Array(this.result)].map(x => x.toString(16).padStart(2, '0')).join(''); - } -} diff --git a/src/types/influxdb.ts b/src/types/influxdb.ts new file mode 100644 index 00000000..bfffb00e --- /dev/null +++ b/src/types/influxdb.ts @@ -0,0 +1,12 @@ +export enum Precision { + s, + ms, + us, + ns, +} + +export interface Api { + url: string; + auth: string; + precision: Precision; +} diff --git a/src/types/logging.ts b/src/types/logging.ts new file mode 100644 index 00000000..a76a9484 --- /dev/null +++ b/src/types/logging.ts @@ -0,0 +1,26 @@ +export enum LogLevels { + All = 1 << 0, + Error = 1 << 1, + Success = 1 << 2, + Warning = 1 << 3, + Notice = 1 << 4, + Info = 1 << 5, + Monitor= 1 << 6, + Debug = 1 << 7, + Trace = 1 << 8, +} + +export type LogHandlers = { + error: (message: string, stack: string | null) => void; + success: (message: string) => void; + warning: (message: string) => void; + notice: (message: string) => void; + info: (message: string) => void; + monitor: (message: string) => void; + debug: (message: string) => void; + trace: (message: string) => void; +}; + + +export type LogLevelKeys = keyof typeof LogLevels; +export type LogLevelHandlerKeys = keyof LogHandlers; diff --git a/src/types/loki.ts b/src/types/loki.ts new file mode 100644 index 00000000..a6163ca2 --- /dev/null +++ b/src/types/loki.ts @@ -0,0 +1,5 @@ +export interface LokiStream { + // deno-lint-ignore no-explicit-any -- TODO + stream: any; + values: Array>; +} diff --git a/src/types/nut.ts b/src/types/nut.ts new file mode 100644 index 00000000..448215ca --- /dev/null +++ b/src/types/nut.ts @@ -0,0 +1,4 @@ +export class NutState { + public static readonly WAITING = 0; + public static readonly IDLE = 1; +} diff --git a/src/types/password.ts b/src/types/password.ts new file mode 100644 index 00000000..a422cf31 --- /dev/null +++ b/src/types/password.ts @@ -0,0 +1,52 @@ +/** + * Create a mapping of algorithms to identifiers + * To add new identifier: + * - Hash enum value using SHA1 + * - Add first 2 characters as identifier + * - Prefix with "d" (to make linter happy) + */ +export enum HASH_IDENTIFIERS { + "d5e" = "SHA-384", + "d6d" = "SHA3-224", + "d88" = "SHA3-256", + "def" = "SHA3-384", + "d81" = "SHA3-512", + "dfa" = "SHAKE128", + "de3" = "SHAKE256", + "d34" = "BLAKE2B-256", + "d20" = "BLAKE2B-384", + "d85" = "BLAKE2B", + "d05" = "BLAKE2S", + "d63" = "BLAKE3", + "d87" = "KECCAK-224", + "d78" = "KECCAK-256", + "d1c" = "KECCAK-384", + "df6" = "KECCAK-512", + /* Insecure, please do not use in production */ + "dc0" = "RIPEMD-160", + /* Insecure, please do not use in production */ + "dba" = "SHA-224", + /* Insecure, please do not use in production */ + "d45" = "SHA-256", + /* Insecure, please do not use in production */ + "db8" = "SHA-512", + /* Insecure, please do not use in production */ + "dc5" = "SHA-1", + /* Insecure, please do not use in production */ + "db7" = "MD5", +} + +/** + * Options for hashing a password + */ +export interface PasswordOptions { + /* Cost factor for hashing (2**cost) */ + cost?: number; + /* Allow the use of insecure algorithms */ + allowInsecure?: boolean; + /** + * Specify a static salt + * Usage of this should be limited to testing only! + */ + salt?: string; +} diff --git a/src/types/queue.ts b/src/types/queue.ts new file mode 100644 index 00000000..904c70be --- /dev/null +++ b/src/types/queue.ts @@ -0,0 +1,15 @@ +export type QueueItem = { + /** + * Weight for the item. + * Only used in weighted algorithms. + */ + weight?: number; + + /** + * Main data for the QueueItem + */ + // deno-lint-ignore no-explicit-any -- Any arbitrary data may be used + data: any; +} + +export type Scheduler = (item: QueueItem, items: QueueItem[]) => QueueItem[]; diff --git a/src/types/rcon.ts b/src/types/rcon.ts new file mode 100644 index 00000000..c317a5d1 --- /dev/null +++ b/src/types/rcon.ts @@ -0,0 +1,4 @@ +export enum PacketType { + COMMAND = 0x02, + AUTH = 0x03, +} diff --git a/src/types/uptime-kuma.ts b/src/types/uptime-kuma.ts new file mode 100644 index 00000000..8201d1dd --- /dev/null +++ b/src/types/uptime-kuma.ts @@ -0,0 +1,4 @@ +export interface UptimeKumaInstance { + host?: string; + id: string; +} diff --git a/src/types/validator.ts b/src/types/validator.ts new file mode 100644 index 00000000..f9af6d04 --- /dev/null +++ b/src/types/validator.ts @@ -0,0 +1,20 @@ +export type ValidationCallback = (input: any, parameters: any) => ValidationCallbackResponse; + +export type ValidationCallbackResponse = ValidationCallbackSuccess|ValidationCallbackError; + +export type ValidationCallbackParameters = {[key: string]: any}; + +export type ValidationOptions = { + last?: boolean; + message?: string; + parameters?: ValidationCallbackParameters; +}; + +export type ValidationStep = { + callback: ValidationCallback + options: ValidationOptions; +}; + +export type ValidationCallbackSuccess = [undefined]; + +export type ValidationCallbackError = [string]; diff --git a/src/types/webserver.ts b/src/types/webserver.ts new file mode 100644 index 00000000..d5f95505 --- /dev/null +++ b/src/types/webserver.ts @@ -0,0 +1,145 @@ +export type ViewVariables = Map; + +export type RequestParameters = Record; + +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})); - } - } - } -}