commit f8012d9b78e6f927860de9614bf40a59edb29d72 Author: ton1c Date: Tue Jan 21 23:19:14 2025 +0300 working on bundling diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1170717 --- /dev/null +++ b/.gitignore @@ -0,0 +1,136 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* +.pnpm-debug.log* + +# Diagnostic reports (https://nodejs.org/api/report.html) +report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage +*.lcov + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules/ +jspm_packages/ + +# Snowpack dependency directory (https://snowpack.dev/) +web_modules/ + +# TypeScript cache +*.tsbuildinfo + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Optional stylelint cache +.stylelintcache + +# Microbundle cache +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variable files +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# parcel-bundler cache (https://parceljs.org/) +.cache +.parcel-cache + +# Next.js build output +.next +out + +# Nuxt.js build / generate output +.nuxt +dist + +# Gatsby files +.cache/ +# Comment in the public line in if your project uses Gatsby and not Next.js +# https://nextjs.org/blog/next-9-1#public-directory-support +# public + +# vuepress build output +.vuepress/dist + +# vuepress v2.x temp and cache directory +.temp +.cache + +# vitepress build output +**/.vitepress/dist + +# vitepress cache directory +**/.vitepress/cache + +# Docusaurus cache and generated files +.docusaurus + +# Serverless directories +.serverless/ + +# FuseBox cache +.fusebox/ + +# DynamoDB Local files +.dynamodb/ + +# TernJS port file +.tern-port + +# Stores VSCode versions used for testing VSCode extensions +.vscode-test + +# yarn v2 +.yarn/cache +.yarn/unplugged +.yarn/build-state.yml +.yarn/install-state.gz +.pnp.* diff --git a/client/.gitignore b/client/.gitignore new file mode 100644 index 0000000..b21bd68 --- /dev/null +++ b/client/.gitignore @@ -0,0 +1,7 @@ +# Generated by Cargo +# will have compiled files and executables +/target/ + +# Generated by Tauri +# will have schema files for capabilities auto-completion +/gen/schemas diff --git a/client/Cargo.toml b/client/Cargo.toml new file mode 100644 index 0000000..c174557 --- /dev/null +++ b/client/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "keyborg-client" +version = "0.1.0" +description = "A Tauri App" +authors = ["you"] +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[lib] +# The `_lib` suffix may seem redundant but it is necessary +# to make the lib name unique and wouldn't conflict with the bin name. +# This seems to be only an issue on Windows, see https://github.com/rust-lang/cargo/issues/8519 +name = "keyborg_client_lib" +crate-type = ["staticlib", "cdylib", "rlib"] + +[build-dependencies] +tauri-build = { version = "2", features = [] } + +[dependencies] +tauri = { version = "2", features = [] } +tauri-plugin-shell = "2" +serde = { version = "1", features = ["derive"] } +serde_json = "1" + diff --git a/client/build.rs b/client/build.rs new file mode 100644 index 0000000..d860e1e --- /dev/null +++ b/client/build.rs @@ -0,0 +1,3 @@ +fn main() { + tauri_build::build() +} diff --git a/client/capabilities/default.json b/client/capabilities/default.json new file mode 100644 index 0000000..3bb4cc4 --- /dev/null +++ b/client/capabilities/default.json @@ -0,0 +1,10 @@ +{ + "$schema": "../gen/schemas/desktop-schema.json", + "identifier": "default", + "description": "Capability for the main window", + "windows": ["main"], + "permissions": [ + "core:default", + "shell:allow-open" + ] +} diff --git a/client/icons/128x128.png b/client/icons/128x128.png new file mode 100644 index 0000000..6be5e50 Binary files /dev/null and b/client/icons/128x128.png differ diff --git a/client/icons/128x128@2x.png b/client/icons/128x128@2x.png new file mode 100644 index 0000000..e81bece Binary files /dev/null and b/client/icons/128x128@2x.png differ diff --git a/client/icons/32x32.png b/client/icons/32x32.png new file mode 100644 index 0000000..a437dd5 Binary files /dev/null and b/client/icons/32x32.png differ diff --git a/client/icons/Square107x107Logo.png b/client/icons/Square107x107Logo.png new file mode 100644 index 0000000..0ca4f27 Binary files /dev/null and b/client/icons/Square107x107Logo.png differ diff --git a/client/icons/Square142x142Logo.png b/client/icons/Square142x142Logo.png new file mode 100644 index 0000000..b81f820 Binary files /dev/null and b/client/icons/Square142x142Logo.png differ diff --git a/client/icons/Square150x150Logo.png b/client/icons/Square150x150Logo.png new file mode 100644 index 0000000..624c7bf Binary files /dev/null and b/client/icons/Square150x150Logo.png differ diff --git a/client/icons/Square284x284Logo.png b/client/icons/Square284x284Logo.png new file mode 100644 index 0000000..c021d2b Binary files /dev/null and b/client/icons/Square284x284Logo.png differ diff --git a/client/icons/Square30x30Logo.png b/client/icons/Square30x30Logo.png new file mode 100644 index 0000000..6219700 Binary files /dev/null and b/client/icons/Square30x30Logo.png differ diff --git a/client/icons/Square310x310Logo.png b/client/icons/Square310x310Logo.png new file mode 100644 index 0000000..f9bc048 Binary files /dev/null and b/client/icons/Square310x310Logo.png differ diff --git a/client/icons/Square44x44Logo.png b/client/icons/Square44x44Logo.png new file mode 100644 index 0000000..d5fbfb2 Binary files /dev/null and b/client/icons/Square44x44Logo.png differ diff --git a/client/icons/Square71x71Logo.png b/client/icons/Square71x71Logo.png new file mode 100644 index 0000000..63440d7 Binary files /dev/null and b/client/icons/Square71x71Logo.png differ diff --git a/client/icons/Square89x89Logo.png b/client/icons/Square89x89Logo.png new file mode 100644 index 0000000..f3f705a Binary files /dev/null and b/client/icons/Square89x89Logo.png differ diff --git a/client/icons/StoreLogo.png b/client/icons/StoreLogo.png new file mode 100644 index 0000000..4556388 Binary files /dev/null and b/client/icons/StoreLogo.png differ diff --git a/client/icons/icon.icns b/client/icons/icon.icns new file mode 100644 index 0000000..12a5bce Binary files /dev/null and b/client/icons/icon.icns differ diff --git a/client/icons/icon.ico b/client/icons/icon.ico new file mode 100644 index 0000000..b3636e4 Binary files /dev/null and b/client/icons/icon.ico differ diff --git a/client/icons/icon.png b/client/icons/icon.png new file mode 100644 index 0000000..e1cd261 Binary files /dev/null and b/client/icons/icon.png differ diff --git a/client/index.html b/client/index.html new file mode 100644 index 0000000..0380b20 --- /dev/null +++ b/client/index.html @@ -0,0 +1,43 @@ + + + + + + + Tauri App + + + + +
+

Welcome to Tauri

+ + +

Click on the Tauri logo to learn more about the framework

+ +
+ + +
+

+
+ + diff --git a/client/package.json b/client/package.json new file mode 100644 index 0000000..955a341 --- /dev/null +++ b/client/package.json @@ -0,0 +1,21 @@ +{ + "name": "@keyborg/client", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview", + "tauri": "tauri" + }, + "dependencies": { + "@tauri-apps/api": "^2", + "@tauri-apps/plugin-shell": "^2" + }, + "devDependencies": { + "@tauri-apps/cli": "^2", + "vite": "^5.3.1", + "typescript": "^5.2.2" + } +} diff --git a/client/src/assets/tauri.svg b/client/src/assets/tauri.svg new file mode 100644 index 0000000..31b62c9 --- /dev/null +++ b/client/src/assets/tauri.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/client/src/assets/typescript.svg b/client/src/assets/typescript.svg new file mode 100644 index 0000000..30a5edd --- /dev/null +++ b/client/src/assets/typescript.svg @@ -0,0 +1,25 @@ + + + + + + + + diff --git a/client/src/assets/vite.svg b/client/src/assets/vite.svg new file mode 100644 index 0000000..e7b8dfb --- /dev/null +++ b/client/src/assets/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/client/src/lib.rs b/client/src/lib.rs new file mode 100644 index 0000000..f91b35e --- /dev/null +++ b/client/src/lib.rs @@ -0,0 +1,14 @@ +// Learn more about Tauri commands at https://tauri.app/develop/calling-rust/ +#[tauri::command] +fn greet(name: &str) -> String { + format!("Hello, {}! You've been greeted from Rust!", name) +} + +#[cfg_attr(mobile, tauri::mobile_entry_point)] +pub fn run() { + tauri::Builder::default() + .plugin(tauri_plugin_shell::init()) + .invoke_handler(tauri::generate_handler![greet]) + .run(tauri::generate_context!()) + .expect("error while running tauri application"); +} diff --git a/client/src/main.rs b/client/src/main.rs new file mode 100644 index 0000000..ef6c0ee --- /dev/null +++ b/client/src/main.rs @@ -0,0 +1,6 @@ +// Prevents additional console window on Windows in release, DO NOT REMOVE!! +#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] + +fn main() { + keyborg_client_lib::run() +} diff --git a/client/src/main.ts b/client/src/main.ts new file mode 100644 index 0000000..4783341 --- /dev/null +++ b/client/src/main.ts @@ -0,0 +1,22 @@ +import { invoke } from "@tauri-apps/api/core"; + +let greetInputEl: HTMLInputElement | null; +let greetMsgEl: HTMLElement | null; + +async function greet() { + if (greetMsgEl && greetInputEl) { + // Learn more about Tauri commands at https://tauri.app/develop/calling-rust/ + greetMsgEl.textContent = await invoke("greet", { + name: greetInputEl.value, + }); + } +} + +window.addEventListener("DOMContentLoaded", () => { + greetInputEl = document.querySelector("#greet-input"); + greetMsgEl = document.querySelector("#greet-msg"); + document.querySelector("#greet-form")?.addEventListener("submit", (e) => { + e.preventDefault(); + greet(); + }); +}); diff --git a/client/src/styles.css b/client/src/styles.css new file mode 100644 index 0000000..7011746 --- /dev/null +++ b/client/src/styles.css @@ -0,0 +1,116 @@ +.logo.vite:hover { + filter: drop-shadow(0 0 2em #747bff); +} + +.logo.typescript:hover { + filter: drop-shadow(0 0 2em #2d79c7); +} +:root { + font-family: Inter, Avenir, Helvetica, Arial, sans-serif; + font-size: 16px; + line-height: 24px; + font-weight: 400; + + color: #0f0f0f; + background-color: #f6f6f6; + + font-synthesis: none; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + -webkit-text-size-adjust: 100%; +} + +.container { + margin: 0; + padding-top: 10vh; + display: flex; + flex-direction: column; + justify-content: center; + text-align: center; +} + +.logo { + height: 6em; + padding: 1.5em; + will-change: filter; + transition: 0.75s; +} + +.logo.tauri:hover { + filter: drop-shadow(0 0 2em #24c8db); +} + +.row { + display: flex; + justify-content: center; +} + +a { + font-weight: 500; + color: #646cff; + text-decoration: inherit; +} + +a:hover { + color: #535bf2; +} + +h1 { + text-align: center; +} + +input, +button { + border-radius: 8px; + border: 1px solid transparent; + padding: 0.6em 1.2em; + font-size: 1em; + font-weight: 500; + font-family: inherit; + color: #0f0f0f; + background-color: #ffffff; + transition: border-color 0.25s; + box-shadow: 0 2px 2px rgba(0, 0, 0, 0.2); +} + +button { + cursor: pointer; +} + +button:hover { + border-color: #396cd8; +} +button:active { + border-color: #396cd8; + background-color: #e8e8e8; +} + +input, +button { + outline: none; +} + +#greet-input { + margin-right: 5px; +} + +@media (prefers-color-scheme: dark) { + :root { + color: #f6f6f6; + background-color: #2f2f2f; + } + + a:hover { + color: #24c8db; + } + + input, + button { + color: #ffffff; + background-color: #0f0f0f98; + } + button:active { + background-color: #0f0f0f69; + } +} diff --git a/client/tauri.conf.json b/client/tauri.conf.json new file mode 100644 index 0000000..5260635 --- /dev/null +++ b/client/tauri.conf.json @@ -0,0 +1,36 @@ +{ + "$schema": "https://schema.tauri.app/config/2", + "productName": "keyborg-client", + "version": "0.1.0", + "identifier": "com.keyborg-client.app", + "build": { + "beforeDevCommand": "deno task dev", + "devUrl": "http://localhost:1420", + "beforeBuildCommand": "deno task build", + "frontendDist": "../dist" + }, + "app": { + "withGlobalTauri": true, + "windows": [ + { + "title": "keyborg-client", + "width": 800, + "height": 600 + } + ], + "security": { + "csp": null + } + }, + "bundle": { + "active": true, + "targets": "all", + "icon": [ + "icons/32x32.png", + "icons/128x128.png", + "icons/128x128@2x.png", + "icons/icon.icns", + "icons/icon.ico" + ] + } +} diff --git a/client/tsconfig.json b/client/tsconfig.json new file mode 100644 index 0000000..1b5445d --- /dev/null +++ b/client/tsconfig.json @@ -0,0 +1,32 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "module": "ESNext", + "lib": [ + "ES2020", + "DOM", + "DOM.Iterable" + ], + "skipLibCheck": true, + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "paths": { + "@shared": [ + "../shared" + ] + } + }, + "include": [ + "src" + ] +} diff --git a/client/vite.config.ts b/client/vite.config.ts new file mode 100644 index 0000000..a90756f --- /dev/null +++ b/client/vite.config.ts @@ -0,0 +1,35 @@ +import { defineConfig } from "vite"; +import path from "node:path"; + +// @ts-expect-error process is a nodejs global +const host = process.env.TAURI_DEV_HOST; + +// https://vitejs.dev/config/ +export default defineConfig(async () => ({ + // Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build` + // + // 1. prevent vite from obscuring rust errors + clearScreen: false, + // 2. tauri expects a fixed port, fail if that port is not available + server: { + port: 1420, + strictPort: true, + host: host || false, + hmr: host + ? { + protocol: "ws", + host, + port: 1421, + } + : undefined, + watch: { + // 3. tell vite to ignore watching `src-tauri` + ignored: ["**/src-tauri/**"], + }, + }, + resolve: { + alias: { + "@shared": path.resolve(__dirname, "../shared"), + }, + }, +})); diff --git a/deno.json b/deno.json new file mode 100644 index 0000000..5f84973 --- /dev/null +++ b/deno.json @@ -0,0 +1,27 @@ +{ + "tasks": { + "dev": "deno run --watch main.ts" + }, + "imports": { + "@luca/esbuild-deno-loader": "jsr:@luca/esbuild-deno-loader@^0.11.1", + "@std/assert": "jsr:@std/assert@1", + "@shared/": "./shared/", + "esbuild": "npm:esbuild@^0.24.2", + "esbuild-plugin-tsc": "npm:esbuild-plugin-tsc@^0.4.0", + "fast-glob": "npm:fast-glob@^3.3.3", + "typescript": "npm:typescript@^5.7.3" + }, + "compilerOptions": { + "jsx": "precompile", + "jsxImportSource": "hono/jsx" + }, + "workspace": [ + "./client", + "./server", + "./shared" + ], + "fmt": { + "indentWidth": 4 + }, + "nodeModulesDir": "auto" +} diff --git a/deno.lock b/deno.lock new file mode 100644 index 0000000..783207d --- /dev/null +++ b/deno.lock @@ -0,0 +1,1118 @@ +{ + "version": "4", + "specifiers": { + "jsr:@db/sqlite@*": "0.12.0", + "jsr:@db/sqlite@0.12": "0.12.0", + "jsr:@denosaurs/plug@1": "1.0.6", + "jsr:@denosaurs/plug@^1.0.6": "1.0.6", + "jsr:@eta-dev/eta@^3.5.0": "3.5.0", + "jsr:@felix/bcrypt@^1.0.5": "1.0.5", + "jsr:@luca/esbuild-deno-loader@*": "0.11.1", + "jsr:@luca/esbuild-deno-loader@~0.11.1": "0.11.1", + "jsr:@std/assert@0.217": "0.217.0", + "jsr:@std/assert@0.221": "0.221.0", + "jsr:@std/assert@1": "1.0.10", + "jsr:@std/bytes@^1.0.2": "1.0.4", + "jsr:@std/cli@^1.0.8": "1.0.10", + "jsr:@std/crypto@^1.0.3": "1.0.3", + "jsr:@std/dotenv@~0.225.3": "0.225.3", + "jsr:@std/encoding@0.221": "0.221.0", + "jsr:@std/encoding@^1.0.5": "1.0.6", + "jsr:@std/fmt@0.221": "0.221.0", + "jsr:@std/fmt@^1.0.3": "1.0.4", + "jsr:@std/fmt@^1.0.4": "1.0.4", + "jsr:@std/fs@0.221": "0.221.0", + "jsr:@std/fs@^1.0.9": "1.0.9", + "jsr:@std/html@^1.0.3": "1.0.3", + "jsr:@std/http@*": "1.0.12", + "jsr:@std/http@^1.0.12": "1.0.12", + "jsr:@std/internal@^1.0.5": "1.0.5", + "jsr:@std/io@0.225": "0.225.0", + "jsr:@std/log@*": "0.224.13", + "jsr:@std/media-types@^1.1.0": "1.1.0", + "jsr:@std/net@^1.0.4": "1.0.4", + "jsr:@std/path@0.217": "0.217.0", + "jsr:@std/path@0.221": "0.221.0", + "jsr:@std/path@^1.0.6": "1.0.8", + "jsr:@std/path@^1.0.8": "1.0.8", + "jsr:@std/streams@^1.0.8": "1.0.8", + "npm:@ryanflorence/sqlite-typegen@0.2": "0.2.0", + "npm:@tauri-apps/api@2": "2.2.0", + "npm:@tauri-apps/cli@2": "2.2.5", + "npm:@tauri-apps/plugin-shell@2": "2.2.0", + "npm:better-sqlite3@^11.8.0": "11.8.1", + "npm:esbuild-plugin-tsc@*": "0.4.0_typescript@5.7.3", + "npm:esbuild-plugin-tsc@0.4": "0.4.0_typescript@5.7.3", + "npm:esbuild@*": "0.24.2", + "npm:esbuild@~0.24.2": "0.24.2", + "npm:fast-glob@*": "3.3.3", + "npm:fast-glob@^3.3.3": "3.3.3", + "npm:mariadb@^3.4.0": "3.4.0", + "npm:typescript@^5.2.2": "5.7.3", + "npm:typescript@^5.7.3": "5.7.3", + "npm:vite@^5.3.1": "5.4.13" + }, + "jsr": { + "@db/sqlite@0.12.0": { + "integrity": "dd1ef7f621ad50fc1e073a1c3609c4470bd51edc0994139c5bf9851de7a6d85f", + "dependencies": [ + "jsr:@denosaurs/plug@1", + "jsr:@std/path@0.217" + ] + }, + "@denosaurs/plug@1.0.6": { + "integrity": "6cf5b9daba7799837b9ffbe89f3450510f588fafef8115ddab1ff0be9cb7c1a7", + "dependencies": [ + "jsr:@std/encoding@0.221", + "jsr:@std/fmt@0.221", + "jsr:@std/fs@0.221", + "jsr:@std/path@0.221" + ] + }, + "@eta-dev/eta@3.5.0": { + "integrity": "6b70827efc14c7cbf08498ac7a922ecab003641caf3852a6cb5b1b12ee58fb37" + }, + "@felix/bcrypt@1.0.5": { + "integrity": "c8312e10cfda0e34c06c0d906667a0c602b472b7c64c75cfdcb44b934b885836", + "dependencies": [ + "jsr:@denosaurs/plug@^1.0.6" + ] + }, + "@luca/esbuild-deno-loader@0.11.1": { + "integrity": "dc020d16d75b591f679f6b9288b10f38bdb4f24345edb2f5732affa1d9885267", + "dependencies": [ + "jsr:@std/bytes", + "jsr:@std/encoding@^1.0.5", + "jsr:@std/path@^1.0.6" + ] + }, + "@std/assert@0.217.0": { + "integrity": "c98e279362ca6982d5285c3b89517b757c1e3477ee9f14eb2fdf80a45aaa9642" + }, + "@std/assert@0.221.0": { + "integrity": "a5f1aa6e7909dbea271754fd4ab3f4e687aeff4873b4cef9a320af813adb489a" + }, + "@std/assert@1.0.10": { + "integrity": "59b5cbac5bd55459a19045d95cc7c2ff787b4f8527c0dd195078ff6f9481fbb3", + "dependencies": [ + "jsr:@std/internal" + ] + }, + "@std/bytes@1.0.4": { + "integrity": "11a0debe522707c95c7b7ef89b478c13fb1583a7cfb9a85674cd2cc2e3a28abc" + }, + "@std/cli@1.0.10": { + "integrity": "d047f6f4954a5c2827fe0963765ddd3d8b6cc7b7518682842645b95f571539dc" + }, + "@std/crypto@1.0.3": { + "integrity": "a2a32f51ddef632d299e3879cd027c630dcd4d1d9a5285d6e6788072f4e51e7f" + }, + "@std/dotenv@0.225.3": { + "integrity": "a95e5b812c27b0854c52acbae215856d9cce9d4bbf774d938c51d212711e8d4a" + }, + "@std/encoding@0.221.0": { + "integrity": "d1dd76ef0dc5d14088411e6dc1dede53bf8308c95d1537df1214c97137208e45" + }, + "@std/encoding@1.0.6": { + "integrity": "ca87122c196e8831737d9547acf001766618e78cd8c33920776c7f5885546069" + }, + "@std/fmt@0.221.0": { + "integrity": "379fed69bdd9731110f26b9085aeb740606b20428ce6af31ef6bd45ef8efa62a" + }, + "@std/fmt@1.0.4": { + "integrity": "e14fe5bedee26f80877e6705a97a79c7eed599e81bb1669127ef9e8bc1e29a74" + }, + "@std/fs@0.221.0": { + "integrity": "028044450299de8ed5a716ade4e6d524399f035513b85913794f4e81f07da286", + "dependencies": [ + "jsr:@std/assert@0.221", + "jsr:@std/path@0.221" + ] + }, + "@std/fs@1.0.9": { + "integrity": "3eef7e3ed3d317b29432c7dcb3b20122820dbc574263f721cb0248ad91bad890" + }, + "@std/html@1.0.3": { + "integrity": "7a0ac35e050431fb49d44e61c8b8aac1ebd55937e0dc9ec6409aa4bab39a7988" + }, + "@std/http@1.0.12": { + "integrity": "85246d8bfe9c8e2538518725b158bdc31f616e0869255f4a8d9e3de919cab2aa", + "dependencies": [ + "jsr:@std/cli", + "jsr:@std/encoding@^1.0.5", + "jsr:@std/fmt@^1.0.3", + "jsr:@std/html", + "jsr:@std/media-types", + "jsr:@std/net", + "jsr:@std/path@^1.0.8", + "jsr:@std/streams" + ] + }, + "@std/internal@1.0.5": { + "integrity": "54a546004f769c1ac9e025abd15a76b6671ddc9687e2313b67376125650dc7ba" + }, + "@std/io@0.225.0": { + "integrity": "c1db7c5e5a231629b32d64b9a53139445b2ca640d828c26bf23e1c55f8c079b3" + }, + "@std/log@0.224.13": { + "integrity": "f04d82f676c9eb4306194ca166d296d9f1456fe4b7edf2a404a0d55c94d31df7", + "dependencies": [ + "jsr:@std/fmt@^1.0.4", + "jsr:@std/fs@^1.0.9", + "jsr:@std/io" + ] + }, + "@std/media-types@1.1.0": { + "integrity": "c9d093f0c05c3512932b330e3cc1fe1d627b301db33a4c2c2185c02471d6eaa4" + }, + "@std/net@1.0.4": { + "integrity": "2f403b455ebbccf83d8a027d29c5a9e3a2452fea39bb2da7f2c04af09c8bc852" + }, + "@std/path@0.217.0": { + "integrity": "1217cc25534bca9a2f672d7fe7c6f356e4027df400c0e85c0ef3e4343bc67d11", + "dependencies": [ + "jsr:@std/assert@0.217" + ] + }, + "@std/path@0.221.0": { + "integrity": "0a36f6b17314ef653a3a1649740cc8db51b25a133ecfe838f20b79a56ebe0095", + "dependencies": [ + "jsr:@std/assert@0.221" + ] + }, + "@std/path@1.0.8": { + "integrity": "548fa456bb6a04d3c1a1e7477986b6cffbce95102d0bb447c67c4ee70e0364be" + }, + "@std/streams@1.0.8": { + "integrity": "b41332d93d2cf6a82fe4ac2153b930adf1a859392931e2a19d9fabfb6f154fb3" + } + }, + "npm": { + "@esbuild/aix-ppc64@0.21.5": { + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==" + }, + "@esbuild/aix-ppc64@0.24.2": { + "integrity": "sha512-thpVCb/rhxE/BnMLQ7GReQLLN8q9qbHmI55F4489/ByVg2aQaQ6kbcLb6FHkocZzQhxc4gx0sCk0tJkKBFzDhA==" + }, + "@esbuild/android-arm64@0.21.5": { + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==" + }, + "@esbuild/android-arm64@0.24.2": { + "integrity": "sha512-cNLgeqCqV8WxfcTIOeL4OAtSmL8JjcN6m09XIgro1Wi7cF4t/THaWEa7eL5CMoMBdjoHOTh/vwTO/o2TRXIyzg==" + }, + "@esbuild/android-arm@0.21.5": { + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==" + }, + "@esbuild/android-arm@0.24.2": { + "integrity": "sha512-tmwl4hJkCfNHwFB3nBa8z1Uy3ypZpxqxfTQOcHX+xRByyYgunVbZ9MzUUfb0RxaHIMnbHagwAxuTL+tnNM+1/Q==" + }, + "@esbuild/android-x64@0.21.5": { + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==" + }, + "@esbuild/android-x64@0.24.2": { + "integrity": "sha512-B6Q0YQDqMx9D7rvIcsXfmJfvUYLoP722bgfBlO5cGvNVb5V/+Y7nhBE3mHV9OpxBf4eAS2S68KZztiPaWq4XYw==" + }, + "@esbuild/darwin-arm64@0.21.5": { + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==" + }, + "@esbuild/darwin-arm64@0.24.2": { + "integrity": "sha512-kj3AnYWc+CekmZnS5IPu9D+HWtUI49hbnyqk0FLEJDbzCIQt7hg7ucF1SQAilhtYpIujfaHr6O0UHlzzSPdOeA==" + }, + "@esbuild/darwin-x64@0.21.5": { + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==" + }, + "@esbuild/darwin-x64@0.24.2": { + "integrity": "sha512-WeSrmwwHaPkNR5H3yYfowhZcbriGqooyu3zI/3GGpF8AyUdsrrP0X6KumITGA9WOyiJavnGZUwPGvxvwfWPHIA==" + }, + "@esbuild/freebsd-arm64@0.21.5": { + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==" + }, + "@esbuild/freebsd-arm64@0.24.2": { + "integrity": "sha512-UN8HXjtJ0k/Mj6a9+5u6+2eZ2ERD7Edt1Q9IZiB5UZAIdPnVKDoG7mdTVGhHJIeEml60JteamR3qhsr1r8gXvg==" + }, + "@esbuild/freebsd-x64@0.21.5": { + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==" + }, + "@esbuild/freebsd-x64@0.24.2": { + "integrity": "sha512-TvW7wE/89PYW+IevEJXZ5sF6gJRDY/14hyIGFXdIucxCsbRmLUcjseQu1SyTko+2idmCw94TgyaEZi9HUSOe3Q==" + }, + "@esbuild/linux-arm64@0.21.5": { + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==" + }, + "@esbuild/linux-arm64@0.24.2": { + "integrity": "sha512-7HnAD6074BW43YvvUmE/35Id9/NB7BeX5EoNkK9obndmZBUk8xmJJeU7DwmUeN7tkysslb2eSl6CTrYz6oEMQg==" + }, + "@esbuild/linux-arm@0.21.5": { + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==" + }, + "@esbuild/linux-arm@0.24.2": { + "integrity": "sha512-n0WRM/gWIdU29J57hJyUdIsk0WarGd6To0s+Y+LwvlC55wt+GT/OgkwoXCXvIue1i1sSNWblHEig00GBWiJgfA==" + }, + "@esbuild/linux-ia32@0.21.5": { + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==" + }, + "@esbuild/linux-ia32@0.24.2": { + "integrity": "sha512-sfv0tGPQhcZOgTKO3oBE9xpHuUqguHvSo4jl+wjnKwFpapx+vUDcawbwPNuBIAYdRAvIDBfZVvXprIj3HA+Ugw==" + }, + "@esbuild/linux-loong64@0.21.5": { + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==" + }, + "@esbuild/linux-loong64@0.24.2": { + "integrity": "sha512-CN9AZr8kEndGooS35ntToZLTQLHEjtVB5n7dl8ZcTZMonJ7CCfStrYhrzF97eAecqVbVJ7APOEe18RPI4KLhwQ==" + }, + "@esbuild/linux-mips64el@0.21.5": { + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==" + }, + "@esbuild/linux-mips64el@0.24.2": { + "integrity": "sha512-iMkk7qr/wl3exJATwkISxI7kTcmHKE+BlymIAbHO8xanq/TjHaaVThFF6ipWzPHryoFsesNQJPE/3wFJw4+huw==" + }, + "@esbuild/linux-ppc64@0.21.5": { + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==" + }, + "@esbuild/linux-ppc64@0.24.2": { + "integrity": "sha512-shsVrgCZ57Vr2L8mm39kO5PPIb+843FStGt7sGGoqiiWYconSxwTiuswC1VJZLCjNiMLAMh34jg4VSEQb+iEbw==" + }, + "@esbuild/linux-riscv64@0.21.5": { + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==" + }, + "@esbuild/linux-riscv64@0.24.2": { + "integrity": "sha512-4eSFWnU9Hhd68fW16GD0TINewo1L6dRrB+oLNNbYyMUAeOD2yCK5KXGK1GH4qD/kT+bTEXjsyTCiJGHPZ3eM9Q==" + }, + "@esbuild/linux-s390x@0.21.5": { + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==" + }, + "@esbuild/linux-s390x@0.24.2": { + "integrity": "sha512-S0Bh0A53b0YHL2XEXC20bHLuGMOhFDO6GN4b3YjRLK//Ep3ql3erpNcPlEFed93hsQAjAQDNsvcK+hV90FubSw==" + }, + "@esbuild/linux-x64@0.21.5": { + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==" + }, + "@esbuild/linux-x64@0.24.2": { + "integrity": "sha512-8Qi4nQcCTbLnK9WoMjdC9NiTG6/E38RNICU6sUNqK0QFxCYgoARqVqxdFmWkdonVsvGqWhmm7MO0jyTqLqwj0Q==" + }, + "@esbuild/netbsd-arm64@0.24.2": { + "integrity": "sha512-wuLK/VztRRpMt9zyHSazyCVdCXlpHkKm34WUyinD2lzK07FAHTq0KQvZZlXikNWkDGoT6x3TD51jKQ7gMVpopw==" + }, + "@esbuild/netbsd-x64@0.21.5": { + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==" + }, + "@esbuild/netbsd-x64@0.24.2": { + "integrity": "sha512-VefFaQUc4FMmJuAxmIHgUmfNiLXY438XrL4GDNV1Y1H/RW3qow68xTwjZKfj/+Plp9NANmzbH5R40Meudu8mmw==" + }, + "@esbuild/openbsd-arm64@0.24.2": { + "integrity": "sha512-YQbi46SBct6iKnszhSvdluqDmxCJA+Pu280Av9WICNwQmMxV7nLRHZfjQzwbPs3jeWnuAhE9Jy0NrnJ12Oz+0A==" + }, + "@esbuild/openbsd-x64@0.21.5": { + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==" + }, + "@esbuild/openbsd-x64@0.24.2": { + "integrity": "sha512-+iDS6zpNM6EnJyWv0bMGLWSWeXGN/HTaF/LXHXHwejGsVi+ooqDfMCCTerNFxEkM3wYVcExkeGXNqshc9iMaOA==" + }, + "@esbuild/sunos-x64@0.21.5": { + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==" + }, + "@esbuild/sunos-x64@0.24.2": { + "integrity": "sha512-hTdsW27jcktEvpwNHJU4ZwWFGkz2zRJUz8pvddmXPtXDzVKTTINmlmga3ZzwcuMpUvLw7JkLy9QLKyGpD2Yxig==" + }, + "@esbuild/win32-arm64@0.21.5": { + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==" + }, + "@esbuild/win32-arm64@0.24.2": { + "integrity": "sha512-LihEQ2BBKVFLOC9ZItT9iFprsE9tqjDjnbulhHoFxYQtQfai7qfluVODIYxt1PgdoyQkz23+01rzwNwYfutxUQ==" + }, + "@esbuild/win32-ia32@0.21.5": { + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==" + }, + "@esbuild/win32-ia32@0.24.2": { + "integrity": "sha512-q+iGUwfs8tncmFC9pcnD5IvRHAzmbwQ3GPS5/ceCyHdjXubwQWI12MKWSNSMYLJMq23/IUCvJMS76PDqXe1fxA==" + }, + "@esbuild/win32-x64@0.21.5": { + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==" + }, + "@esbuild/win32-x64@0.24.2": { + "integrity": "sha512-7VTgWzgMGvup6aSqDPLiW5zHaxYJGTO4OokMjIlrCtf+VpEL+cXKtCvg723iguPYI5oaUNdS+/V7OU2gvXVWEg==" + }, + "@nodelib/fs.scandir@2.1.5": { + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dependencies": [ + "@nodelib/fs.stat", + "run-parallel" + ] + }, + "@nodelib/fs.stat@2.0.5": { + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==" + }, + "@nodelib/fs.walk@1.2.8": { + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dependencies": [ + "@nodelib/fs.scandir", + "fastq" + ] + }, + "@rollup/rollup-android-arm-eabi@4.31.0": { + "integrity": "sha512-9NrR4033uCbUBRgvLcBrJofa2KY9DzxL2UKZ1/4xA/mnTNyhZCWBuD8X3tPm1n4KxcgaraOYgrFKSgwjASfmlA==" + }, + "@rollup/rollup-android-arm64@4.31.0": { + "integrity": "sha512-iBbODqT86YBFHajxxF8ebj2hwKm1k8PTBQSojSt3d1FFt1gN+xf4CowE47iN0vOSdnd+5ierMHBbu/rHc7nq5g==" + }, + "@rollup/rollup-darwin-arm64@4.31.0": { + "integrity": "sha512-WHIZfXgVBX30SWuTMhlHPXTyN20AXrLH4TEeH/D0Bolvx9PjgZnn4H677PlSGvU6MKNsjCQJYczkpvBbrBnG6g==" + }, + "@rollup/rollup-darwin-x64@4.31.0": { + "integrity": "sha512-hrWL7uQacTEF8gdrQAqcDy9xllQ0w0zuL1wk1HV8wKGSGbKPVjVUv/DEwT2+Asabf8Dh/As+IvfdU+H8hhzrQQ==" + }, + "@rollup/rollup-freebsd-arm64@4.31.0": { + "integrity": "sha512-S2oCsZ4hJviG1QjPY1h6sVJLBI6ekBeAEssYKad1soRFv3SocsQCzX6cwnk6fID6UQQACTjeIMB+hyYrFacRew==" + }, + "@rollup/rollup-freebsd-x64@4.31.0": { + "integrity": "sha512-pCANqpynRS4Jirn4IKZH4tnm2+2CqCNLKD7gAdEjzdLGbH1iO0zouHz4mxqg0uEMpO030ejJ0aA6e1PJo2xrPA==" + }, + "@rollup/rollup-linux-arm-gnueabihf@4.31.0": { + "integrity": "sha512-0O8ViX+QcBd3ZmGlcFTnYXZKGbFu09EhgD27tgTdGnkcYXLat4KIsBBQeKLR2xZDCXdIBAlWLkiXE1+rJpCxFw==" + }, + "@rollup/rollup-linux-arm-musleabihf@4.31.0": { + "integrity": "sha512-w5IzG0wTVv7B0/SwDnMYmbr2uERQp999q8FMkKG1I+j8hpPX2BYFjWe69xbhbP6J9h2gId/7ogesl9hwblFwwg==" + }, + "@rollup/rollup-linux-arm64-gnu@4.31.0": { + "integrity": "sha512-JyFFshbN5xwy6fulZ8B/8qOqENRmDdEkcIMF0Zz+RsfamEW+Zabl5jAb0IozP/8UKnJ7g2FtZZPEUIAlUSX8cA==" + }, + "@rollup/rollup-linux-arm64-musl@4.31.0": { + "integrity": "sha512-kpQXQ0UPFeMPmPYksiBL9WS/BDiQEjRGMfklVIsA0Sng347H8W2iexch+IEwaR7OVSKtr2ZFxggt11zVIlZ25g==" + }, + "@rollup/rollup-linux-loongarch64-gnu@4.31.0": { + "integrity": "sha512-pMlxLjt60iQTzt9iBb3jZphFIl55a70wexvo8p+vVFK+7ifTRookdoXX3bOsRdmfD+OKnMozKO6XM4zR0sHRrQ==" + }, + "@rollup/rollup-linux-powerpc64le-gnu@4.31.0": { + "integrity": "sha512-D7TXT7I/uKEuWiRkEFbed1UUYZwcJDU4vZQdPTcepK7ecPhzKOYk4Er2YR4uHKme4qDeIh6N3XrLfpuM7vzRWQ==" + }, + "@rollup/rollup-linux-riscv64-gnu@4.31.0": { + "integrity": "sha512-wal2Tc8O5lMBtoePLBYRKj2CImUCJ4UNGJlLwspx7QApYny7K1cUYlzQ/4IGQBLmm+y0RS7dwc3TDO/pmcneTw==" + }, + "@rollup/rollup-linux-s390x-gnu@4.31.0": { + "integrity": "sha512-O1o5EUI0+RRMkK9wiTVpk2tyzXdXefHtRTIjBbmFREmNMy7pFeYXCFGbhKFwISA3UOExlo5GGUuuj3oMKdK6JQ==" + }, + "@rollup/rollup-linux-x64-gnu@4.31.0": { + "integrity": "sha512-zSoHl356vKnNxwOWnLd60ixHNPRBglxpv2g7q0Cd3Pmr561gf0HiAcUBRL3S1vPqRC17Zo2CX/9cPkqTIiai1g==" + }, + "@rollup/rollup-linux-x64-musl@4.31.0": { + "integrity": "sha512-ypB/HMtcSGhKUQNiFwqgdclWNRrAYDH8iMYH4etw/ZlGwiTVxBz2tDrGRrPlfZu6QjXwtd+C3Zib5pFqID97ZA==" + }, + "@rollup/rollup-win32-arm64-msvc@4.31.0": { + "integrity": "sha512-JuhN2xdI/m8Hr+aVO3vspO7OQfUFO6bKLIRTAy0U15vmWjnZDLrEgCZ2s6+scAYaQVpYSh9tZtRijApw9IXyMw==" + }, + "@rollup/rollup-win32-ia32-msvc@4.31.0": { + "integrity": "sha512-U1xZZXYkvdf5MIWmftU8wrM5PPXzyaY1nGCI4KI4BFfoZxHamsIe+BtnPLIvvPykvQWlVbqUXdLa4aJUuilwLQ==" + }, + "@rollup/rollup-win32-x64-msvc@4.31.0": { + "integrity": "sha512-ul8rnCsUumNln5YWwz0ted2ZHFhzhRRnkpBZ+YRuHoRAlUji9KChpOUOndY7uykrPEPXVbHLlsdo6v5yXo/TXw==" + }, + "@ryanflorence/sqlite-typegen@0.2.0": { + "integrity": "sha512-5zDtou8+wd0Qoz5COkX2Nf8JgrSFukjMZVEZdoXct2q5EmH1TcrQc1vjngniDNAKIMDQBZHSIaMEyvtDBtkpXA==", + "dependencies": [ + "arg", + "better-sqlite3@11.6.0", + "cli-highlight", + "picocolors", + "tiny-invariant" + ] + }, + "@tauri-apps/api@2.2.0": { + "integrity": "sha512-R8epOeZl1eJEl603aUMIGb4RXlhPjpgxbGVEaqY+0G5JG9vzV/clNlzTeqc+NLYXVqXcn8mb4c5b9pJIUDEyAg==" + }, + "@tauri-apps/cli-darwin-arm64@2.2.5": { + "integrity": "sha512-qdPmypQE7qj62UJy3Wl/ccCJZwsv5gyBByOrAaG7u5c/PB3QSxhNPegice2k4EHeIuApaVJOoe/CEYVgm/og2Q==" + }, + "@tauri-apps/cli-darwin-x64@2.2.5": { + "integrity": "sha512-8JVlCAb2c3n0EcGW7n/1kU4Rq831SsoLDD/0hNp85Um8HGIH2Mg/qos/MLOc8Qv2mOaoKcRKf4hd0I1y0Rl9Cg==" + }, + "@tauri-apps/cli-linux-arm-gnueabihf@2.2.5": { + "integrity": "sha512-mzxQCqZg7ljRVgekPpXQ5TOehCNgnXh/DNWU6kFjALaBvaw4fGzc369/hV94wOt29htNFyxf8ty2DaQaYljEHw==" + }, + "@tauri-apps/cli-linux-arm64-gnu@2.2.5": { + "integrity": "sha512-M9nkzx5jsSJSNpp7aSza0qv0/N13SUNzH8ysYSZ7IaCN8coGeMg2KgQ5qC6tqUVij2rbg8A/X1n0pPo/gtLx0A==" + }, + "@tauri-apps/cli-linux-arm64-musl@2.2.5": { + "integrity": "sha512-tFhZu950HNRLR1RM5Q9Xj5gAlA6AhyyiZgeoXGFAWto+s2jpWmmA3Qq2GUxnVDr7Xui8PF4UY5kANDIOschuwg==" + }, + "@tauri-apps/cli-linux-x64-gnu@2.2.5": { + "integrity": "sha512-eaGhTQLr3EKeksGsp2tK/Ndi7/oyo3P53Pye6kg0zqXiqu8LQjg1CgvDm1l+5oit04S60zR4AqlDFpoeEtDGgw==" + }, + "@tauri-apps/cli-linux-x64-musl@2.2.5": { + "integrity": "sha512-NLAO/SymDxeGuOWWQZCpwoED1K1jaHUvW+u9ip+rTetnxFPLvf3zXthx4QVKfCZLdj2WLQz4cLjHyQdMDXAM+w==" + }, + "@tauri-apps/cli-win32-arm64-msvc@2.2.5": { + "integrity": "sha512-yG5KFbqrHfGjkAQAaaCD4i7cJklBjmMxZ2C92DEnqCOujSsEuLxrwwoKxQ4+hqEHOmF3lyX0vfqhgZcS03H38w==" + }, + "@tauri-apps/cli-win32-ia32-msvc@2.2.5": { + "integrity": "sha512-G5lq+2EdxOc8ttg3uhME5t9U3hMGTxwaKz0X4DplTG2Iv4lcNWqw/AESIJVHa5a+EB+ZCC8I+yOfIykp/Cd5mQ==" + }, + "@tauri-apps/cli-win32-x64-msvc@2.2.5": { + "integrity": "sha512-vw4fPVOo0rIQIlqw6xUvK2nwiRFBHNgayDE2Z/SomJlQJAJ1q4VgpHOPl12ouuicmTjK1gWKm7RTouQe3Nig0Q==" + }, + "@tauri-apps/cli@2.2.5": { + "integrity": "sha512-PaefTQUCYYqvZWdH8EhXQkyJEjQwtoy/OHGoPcZx7Gk3D3K6AtGSxZ9OlHIz3Bu5LDGgVBk36vKtHW0WYsWnbw==", + "dependencies": [ + "@tauri-apps/cli-darwin-arm64", + "@tauri-apps/cli-darwin-x64", + "@tauri-apps/cli-linux-arm-gnueabihf", + "@tauri-apps/cli-linux-arm64-gnu", + "@tauri-apps/cli-linux-arm64-musl", + "@tauri-apps/cli-linux-x64-gnu", + "@tauri-apps/cli-linux-x64-musl", + "@tauri-apps/cli-win32-arm64-msvc", + "@tauri-apps/cli-win32-ia32-msvc", + "@tauri-apps/cli-win32-x64-msvc" + ] + }, + "@tauri-apps/plugin-shell@2.2.0": { + "integrity": "sha512-iC3Ic1hLmasoboG7BO+7p+AriSoqAwKrIk+Hpk+S/bjTQdXqbl2GbdclghI4gM32X0bls7xHzIFqhRdrlvJeaA==", + "dependencies": [ + "@tauri-apps/api" + ] + }, + "@types/estree@1.0.6": { + "integrity": "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==" + }, + "@types/geojson@7946.0.15": { + "integrity": "sha512-9oSxFzDCT2Rj6DfcHF8G++jxBKS7mBqXl5xrRW+Kbvjry6Uduya2iiwqHPhVXpasAVMBYKkEPGgKhd3+/HZ6xA==" + }, + "@types/node@22.10.7": { + "integrity": "sha512-V09KvXxFiutGp6B7XkpaDXlNadZxrzajcY50EuoLIpQ6WWYCSvf19lVIazzfIzQvhUN2HjX12spLojTnhuKlGg==", + "dependencies": [ + "undici-types" + ] + }, + "ansi-regex@5.0.1": { + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==" + }, + "ansi-styles@4.3.0": { + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dependencies": [ + "color-convert" + ] + }, + "any-promise@1.3.0": { + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==" + }, + "arg@5.0.2": { + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==" + }, + "base64-js@1.5.1": { + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==" + }, + "better-sqlite3@11.6.0": { + "integrity": "sha512-2J6k/eVxcFYY2SsTxsXrj6XylzHWPxveCn4fKPKZFv/Vqn/Cd7lOuX4d7rGQXT5zL+97MkNL3nSbCrIoe3LkgA==", + "dependencies": [ + "bindings", + "prebuild-install" + ] + }, + "better-sqlite3@11.8.1": { + "integrity": "sha512-9BxNaBkblMjhJW8sMRZxnxVTRgbRmssZW0Oxc1MPBTfiR+WW21e2Mk4qu8CzrcZb1LwPCnFsfDEzq+SNcBU8eg==", + "dependencies": [ + "bindings", + "prebuild-install" + ] + }, + "bindings@1.5.0": { + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "dependencies": [ + "file-uri-to-path" + ] + }, + "bl@4.1.0": { + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "dependencies": [ + "buffer", + "inherits", + "readable-stream" + ] + }, + "braces@3.0.3": { + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dependencies": [ + "fill-range" + ] + }, + "buffer@5.7.1": { + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "dependencies": [ + "base64-js", + "ieee754" + ] + }, + "chalk@4.1.2": { + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dependencies": [ + "ansi-styles", + "supports-color" + ] + }, + "chownr@1.1.4": { + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==" + }, + "cli-highlight@2.1.11": { + "integrity": "sha512-9KDcoEVwyUXrjcJNvHD0NFc/hiwe/WPVYIleQh2O1N2Zro5gWJZ/K+3DGn8w8P/F6FxOgzyC5bxDyHIgCSPhGg==", + "dependencies": [ + "chalk", + "highlight.js", + "mz", + "parse5@5.1.1", + "parse5-htmlparser2-tree-adapter", + "yargs" + ] + }, + "cliui@7.0.4": { + "integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==", + "dependencies": [ + "string-width", + "strip-ansi", + "wrap-ansi" + ] + }, + "color-convert@2.0.1": { + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dependencies": [ + "color-name" + ] + }, + "color-name@1.1.4": { + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" + }, + "decompress-response@6.0.0": { + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "dependencies": [ + "mimic-response" + ] + }, + "deep-extend@0.6.0": { + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==" + }, + "denque@2.1.0": { + "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==" + }, + "detect-libc@2.0.3": { + "integrity": "sha512-bwy0MGW55bG41VqxxypOsdSdGqLwXPI/focwgTYCFMbdUiBAxLg9CFzG08sz2aqzknwiX7Hkl0bQENjg8iLByw==" + }, + "emoji-regex@8.0.0": { + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" + }, + "end-of-stream@1.4.4": { + "integrity": "sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==", + "dependencies": [ + "once" + ] + }, + "esbuild-plugin-tsc@0.4.0_typescript@5.7.3": { + "integrity": "sha512-q9gWIovt1nkwchMLc2zhyksaiHOv3kDK4b0AUol8lkMCRhJ1zavgfb2fad6BKp7FT9rh/OHmEBXVjczLoi/0yw==", + "dependencies": [ + "strip-comments", + "typescript" + ] + }, + "esbuild@0.21.5": { + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dependencies": [ + "@esbuild/aix-ppc64@0.21.5", + "@esbuild/android-arm@0.21.5", + "@esbuild/android-arm64@0.21.5", + "@esbuild/android-x64@0.21.5", + "@esbuild/darwin-arm64@0.21.5", + "@esbuild/darwin-x64@0.21.5", + "@esbuild/freebsd-arm64@0.21.5", + "@esbuild/freebsd-x64@0.21.5", + "@esbuild/linux-arm@0.21.5", + "@esbuild/linux-arm64@0.21.5", + "@esbuild/linux-ia32@0.21.5", + "@esbuild/linux-loong64@0.21.5", + "@esbuild/linux-mips64el@0.21.5", + "@esbuild/linux-ppc64@0.21.5", + "@esbuild/linux-riscv64@0.21.5", + "@esbuild/linux-s390x@0.21.5", + "@esbuild/linux-x64@0.21.5", + "@esbuild/netbsd-x64@0.21.5", + "@esbuild/openbsd-x64@0.21.5", + "@esbuild/sunos-x64@0.21.5", + "@esbuild/win32-arm64@0.21.5", + "@esbuild/win32-ia32@0.21.5", + "@esbuild/win32-x64@0.21.5" + ] + }, + "esbuild@0.24.2": { + "integrity": "sha512-+9egpBW8I3CD5XPe0n6BfT5fxLzxrlDzqydF3aviG+9ni1lDC/OvMHcxqEFV0+LANZG5R1bFMWfUrjVsdwxJvA==", + "dependencies": [ + "@esbuild/aix-ppc64@0.24.2", + "@esbuild/android-arm@0.24.2", + "@esbuild/android-arm64@0.24.2", + "@esbuild/android-x64@0.24.2", + "@esbuild/darwin-arm64@0.24.2", + "@esbuild/darwin-x64@0.24.2", + "@esbuild/freebsd-arm64@0.24.2", + "@esbuild/freebsd-x64@0.24.2", + "@esbuild/linux-arm@0.24.2", + "@esbuild/linux-arm64@0.24.2", + "@esbuild/linux-ia32@0.24.2", + "@esbuild/linux-loong64@0.24.2", + "@esbuild/linux-mips64el@0.24.2", + "@esbuild/linux-ppc64@0.24.2", + "@esbuild/linux-riscv64@0.24.2", + "@esbuild/linux-s390x@0.24.2", + "@esbuild/linux-x64@0.24.2", + "@esbuild/netbsd-arm64", + "@esbuild/netbsd-x64@0.24.2", + "@esbuild/openbsd-arm64", + "@esbuild/openbsd-x64@0.24.2", + "@esbuild/sunos-x64@0.24.2", + "@esbuild/win32-arm64@0.24.2", + "@esbuild/win32-ia32@0.24.2", + "@esbuild/win32-x64@0.24.2" + ] + }, + "escalade@3.2.0": { + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==" + }, + "expand-template@2.0.3": { + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==" + }, + "fast-glob@3.3.3": { + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dependencies": [ + "@nodelib/fs.stat", + "@nodelib/fs.walk", + "glob-parent", + "merge2", + "micromatch" + ] + }, + "fastq@1.18.0": { + "integrity": "sha512-QKHXPW0hD8g4UET03SdOdunzSouc9N4AuHdsX8XNcTsuz+yYFILVNIX4l9yHABMhiEI9Db0JTTIpu0wB+Y1QQw==", + "dependencies": [ + "reusify" + ] + }, + "file-uri-to-path@1.0.0": { + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==" + }, + "fill-range@7.1.1": { + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dependencies": [ + "to-regex-range" + ] + }, + "fs-constants@1.0.0": { + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==" + }, + "fsevents@2.3.3": { + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==" + }, + "get-caller-file@2.0.5": { + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==" + }, + "github-from-package@0.0.0": { + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==" + }, + "glob-parent@5.1.2": { + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dependencies": [ + "is-glob" + ] + }, + "has-flag@4.0.0": { + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==" + }, + "highlight.js@10.7.3": { + "integrity": "sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==" + }, + "iconv-lite@0.6.3": { + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dependencies": [ + "safer-buffer" + ] + }, + "ieee754@1.2.1": { + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==" + }, + "inherits@2.0.4": { + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" + }, + "ini@1.3.8": { + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==" + }, + "is-extglob@2.1.1": { + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==" + }, + "is-fullwidth-code-point@3.0.0": { + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==" + }, + "is-glob@4.0.3": { + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dependencies": [ + "is-extglob" + ] + }, + "is-number@7.0.0": { + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==" + }, + "lru-cache@10.4.3": { + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==" + }, + "mariadb@3.4.0": { + "integrity": "sha512-hdRPcAzs+MTxK5VG1thBW18gGTlw6yWBe9YnLB65GLo7q0fO5DWsgomIevV/pXSaWRmD3qi6ka4oSFRTExRiEQ==", + "dependencies": [ + "@types/geojson", + "@types/node", + "denque", + "iconv-lite", + "lru-cache" + ] + }, + "merge2@1.4.1": { + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==" + }, + "micromatch@4.0.8": { + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dependencies": [ + "braces", + "picomatch" + ] + }, + "mimic-response@3.1.0": { + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==" + }, + "minimist@1.2.8": { + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==" + }, + "mkdirp-classic@0.5.3": { + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==" + }, + "mz@2.7.0": { + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dependencies": [ + "any-promise", + "object-assign", + "thenify-all" + ] + }, + "nanoid@3.3.8": { + "integrity": "sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==" + }, + "napi-build-utils@1.0.2": { + "integrity": "sha512-ONmRUqK7zj7DWX0D9ADe03wbwOBZxNAfF20PlGfCWQcD3+/MakShIHrMqx9YwPTfxDdF1zLeL+RGZiR9kGMLdg==" + }, + "node-abi@3.73.0": { + "integrity": "sha512-z8iYzQGBu35ZkTQ9mtR8RqugJZ9RCLn8fv3d7LsgDBzOijGQP3RdKTX4LA7LXw03ZhU5z0l4xfhIMgSES31+cg==", + "dependencies": [ + "semver" + ] + }, + "object-assign@4.1.1": { + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==" + }, + "once@1.4.0": { + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dependencies": [ + "wrappy" + ] + }, + "parse5-htmlparser2-tree-adapter@6.0.1": { + "integrity": "sha512-qPuWvbLgvDGilKc5BoicRovlT4MtYT6JfJyBOMDsKoiT+GiuP5qyrPCnR9HcPECIJJmZh5jRndyNThnhhb/vlA==", + "dependencies": [ + "parse5@6.0.1" + ] + }, + "parse5@5.1.1": { + "integrity": "sha512-ugq4DFI0Ptb+WWjAdOK16+u/nHfiIrcE+sh8kZMaM0WllQKLI9rOUq6c2b7cwPkXdzfQESqvoqK6ug7U/Yyzug==" + }, + "parse5@6.0.1": { + "integrity": "sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw==" + }, + "picocolors@1.1.1": { + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==" + }, + "picomatch@2.3.1": { + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==" + }, + "postcss@8.5.1": { + "integrity": "sha512-6oz2beyjc5VMn/KV1pPw8fliQkhBXrVn1Z3TVyqZxU8kZpzEKhBdmCFqI6ZbmGtamQvQGuU1sgPTk8ZrXDD7jQ==", + "dependencies": [ + "nanoid", + "picocolors", + "source-map-js" + ] + }, + "prebuild-install@7.1.2": { + "integrity": "sha512-UnNke3IQb6sgarcZIDU3gbMeTp/9SSU1DAIkil7PrqG1vZlBtY5msYccSKSHDqa3hNg436IXK+SNImReuA1wEQ==", + "dependencies": [ + "detect-libc", + "expand-template", + "github-from-package", + "minimist", + "mkdirp-classic", + "napi-build-utils", + "node-abi", + "pump", + "rc", + "simple-get", + "tar-fs", + "tunnel-agent" + ] + }, + "pump@3.0.2": { + "integrity": "sha512-tUPXtzlGM8FE3P0ZL6DVs/3P58k9nk8/jZeQCurTJylQA8qFYzHFfhBJkuqyE0FifOsQ0uKWekiZ5g8wtr28cw==", + "dependencies": [ + "end-of-stream", + "once" + ] + }, + "queue-microtask@1.2.3": { + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==" + }, + "rc@1.2.8": { + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "dependencies": [ + "deep-extend", + "ini", + "minimist", + "strip-json-comments" + ] + }, + "readable-stream@3.6.2": { + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dependencies": [ + "inherits", + "string_decoder", + "util-deprecate" + ] + }, + "require-directory@2.1.1": { + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==" + }, + "reusify@1.0.4": { + "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==" + }, + "rollup@4.31.0": { + "integrity": "sha512-9cCE8P4rZLx9+PjoyqHLs31V9a9Vpvfo4qNcs6JCiGWYhw2gijSetFbH6SSy1whnkgcefnUwr8sad7tgqsGvnw==", + "dependencies": [ + "@rollup/rollup-android-arm-eabi", + "@rollup/rollup-android-arm64", + "@rollup/rollup-darwin-arm64", + "@rollup/rollup-darwin-x64", + "@rollup/rollup-freebsd-arm64", + "@rollup/rollup-freebsd-x64", + "@rollup/rollup-linux-arm-gnueabihf", + "@rollup/rollup-linux-arm-musleabihf", + "@rollup/rollup-linux-arm64-gnu", + "@rollup/rollup-linux-arm64-musl", + "@rollup/rollup-linux-loongarch64-gnu", + "@rollup/rollup-linux-powerpc64le-gnu", + "@rollup/rollup-linux-riscv64-gnu", + "@rollup/rollup-linux-s390x-gnu", + "@rollup/rollup-linux-x64-gnu", + "@rollup/rollup-linux-x64-musl", + "@rollup/rollup-win32-arm64-msvc", + "@rollup/rollup-win32-ia32-msvc", + "@rollup/rollup-win32-x64-msvc", + "@types/estree", + "fsevents" + ] + }, + "run-parallel@1.2.0": { + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dependencies": [ + "queue-microtask" + ] + }, + "safe-buffer@5.2.1": { + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==" + }, + "safer-buffer@2.1.2": { + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" + }, + "semver@7.6.3": { + "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==" + }, + "simple-concat@1.0.1": { + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==" + }, + "simple-get@4.0.1": { + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "dependencies": [ + "decompress-response", + "once", + "simple-concat" + ] + }, + "source-map-js@1.2.1": { + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==" + }, + "string-width@4.2.3": { + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dependencies": [ + "emoji-regex", + "is-fullwidth-code-point", + "strip-ansi" + ] + }, + "string_decoder@1.3.0": { + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dependencies": [ + "safe-buffer" + ] + }, + "strip-ansi@6.0.1": { + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dependencies": [ + "ansi-regex" + ] + }, + "strip-comments@2.0.1": { + "integrity": "sha512-ZprKx+bBLXv067WTCALv8SSz5l2+XhpYCsVtSqlMnkAXMWDq+/ekVbl1ghqP9rUHTzv6sm/DwCOiYutU/yp1fw==" + }, + "strip-json-comments@2.0.1": { + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==" + }, + "supports-color@7.2.0": { + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dependencies": [ + "has-flag" + ] + }, + "tar-fs@2.1.2": { + "integrity": "sha512-EsaAXwxmx8UB7FRKqeozqEPop69DXcmYwTQwXvyAPF352HJsPdkVhvTaDPYqfNgruveJIJy3TA2l+2zj8LJIJA==", + "dependencies": [ + "chownr", + "mkdirp-classic", + "pump", + "tar-stream" + ] + }, + "tar-stream@2.2.0": { + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "dependencies": [ + "bl", + "end-of-stream", + "fs-constants", + "inherits", + "readable-stream" + ] + }, + "thenify-all@1.6.0": { + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dependencies": [ + "thenify" + ] + }, + "thenify@3.3.1": { + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dependencies": [ + "any-promise" + ] + }, + "tiny-invariant@1.3.3": { + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==" + }, + "to-regex-range@5.0.1": { + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dependencies": [ + "is-number" + ] + }, + "tunnel-agent@0.6.0": { + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "dependencies": [ + "safe-buffer" + ] + }, + "typescript@5.7.3": { + "integrity": "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==" + }, + "undici-types@6.20.0": { + "integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==" + }, + "util-deprecate@1.0.2": { + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==" + }, + "vite@5.4.13": { + "integrity": "sha512-7zp3N4YSjXOSAFfdBe9pPD3FrO398QlJ/5QpFGm3L8xDP1IxDn1XRxArPw4ZKk5394MM8rcTVPY4y1Hvo62bog==", + "dependencies": [ + "esbuild@0.21.5", + "fsevents", + "postcss", + "rollup" + ] + }, + "wrap-ansi@7.0.0": { + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dependencies": [ + "ansi-styles", + "string-width", + "strip-ansi" + ] + }, + "wrappy@1.0.2": { + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" + }, + "y18n@5.0.8": { + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==" + }, + "yargs-parser@20.2.9": { + "integrity": "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==" + }, + "yargs@16.2.0": { + "integrity": "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==", + "dependencies": [ + "cliui", + "escalade", + "get-caller-file", + "require-directory", + "string-width", + "y18n", + "yargs-parser" + ] + } + }, + "workspace": { + "dependencies": [ + "jsr:@luca/esbuild-deno-loader@~0.11.1", + "jsr:@std/assert@1", + "npm:esbuild-plugin-tsc@0.4", + "npm:esbuild@~0.24.2", + "npm:fast-glob@^3.3.3", + "npm:typescript@^5.7.3" + ], + "members": { + "client": { + "packageJson": { + "dependencies": [ + "npm:@tauri-apps/api@2", + "npm:@tauri-apps/cli@2", + "npm:@tauri-apps/plugin-shell@2", + "npm:typescript@^5.2.2", + "npm:vite@^5.3.1" + ] + } + }, + "server": { + "dependencies": [ + "jsr:@db/sqlite@0.12", + "jsr:@eta-dev/eta@^3.5.0", + "jsr:@felix/bcrypt@^1.0.5", + "jsr:@std/assert@1", + "jsr:@std/crypto@^1.0.3", + "jsr:@std/dotenv@~0.225.3", + "jsr:@std/http@^1.0.12", + "npm:@ryanflorence/sqlite-typegen@0.2", + "npm:better-sqlite3@^11.8.0", + "npm:esbuild@~0.24.2", + "npm:mariadb@^3.4.0" + ] + }, + "shared": { + "dependencies": [ + "jsr:@std/assert@1", + "jsr:@std/fmt@^1.0.3" + ] + } + } + } +} diff --git a/docker-compose.yaml b/docker-compose.yaml new file mode 100644 index 0000000..da6c7cf --- /dev/null +++ b/docker-compose.yaml @@ -0,0 +1,13 @@ +services: + db: + image: mariadb + restart: always + environment: + MYSQL_ROOT_PASSWORD: "password" + MYSQL_DATABASE: "keyborg" + MYSQL_USER: "keyborg" + MYSQL_PASSWORD: "password" + volumes: + - /home/t/work/Keyborg/data:/var/lib/mysql + ports: + - "3306:3306" diff --git a/main.ts b/main.ts new file mode 100644 index 0000000..292ce5f --- /dev/null +++ b/main.ts @@ -0,0 +1,8 @@ +export function add(a: number, b: number): number { + return a + b; +} + +// Learn more at https://docs.deno.com/runtime/manual/examples/module_metadata#concepts +if (import.meta.main) { + console.log("Add 2 + 3 =", add(2, 3)); +} diff --git a/main_test.ts b/main_test.ts new file mode 100644 index 0000000..3d981e9 --- /dev/null +++ b/main_test.ts @@ -0,0 +1,6 @@ +import { assertEquals } from "@std/assert"; +import { add } from "./main.ts"; + +Deno.test(function addTest() { + assertEquals(add(2, 3), 5); +}); diff --git a/server/autoBundler.ts b/server/autoBundler.ts new file mode 100644 index 0000000..39a70fe --- /dev/null +++ b/server/autoBundler.ts @@ -0,0 +1,18 @@ +import bundle from "./bundler.ts"; + +const directoryToWatch = "./src/client_js"; // Update this to your directory + +const watcher = Deno.watchFs(directoryToWatch); + +for await (const event of watcher) { + if ( + event.kind === "modify" || event.kind === "create" || + event.kind === "remove" + ) { + try { + await bundle(); + } catch (e) { + console.log(e); + } + } +} diff --git a/server/bundler.ts b/server/bundler.ts new file mode 100644 index 0000000..5a106d2 --- /dev/null +++ b/server/bundler.ts @@ -0,0 +1,76 @@ +import * as esbuild from "npm:esbuild"; +import * as fg from "npm:fast-glob"; +import { denoPlugins } from "jsr:@luca/esbuild-deno-loader"; + +async function bundleSharedObject() { + await esbuild.build({ + plugins: [ + ...denoPlugins(), + ], + entryPoints: [ + "../shared/utils/index.ts", + ], + outfile: "./public/js/shared.bundle.js", + bundle: true, + format: "esm", + minify: true, + }); +} + +await bundleSharedObject(); + +async function bundle() { + const entryPoints = fg.default.sync([ + "./src/client_js/*.ts", + "!./src/client_js/shared.bundle.ts", + ]); + + async function bundleClientJS() { + await esbuild.build({ + plugins: [ + ...denoPlugins(), + ], + entryPoints: entryPoints, + outbase: "./src/client_js", + outdir: "./public/js", + bundle: false, + format: "esm", + minify: true, + }); + } + + await bundleClientJS(); + + const bundledFiles = fg.default.sync([ + "./public/js/*.js", + "!./public/js/shared.bundle.js", + ]); + + for (const file of bundledFiles) { + const content = await Deno.readTextFile(file); + + const staticImportRegex = + /(import\s*[^'"]+from\s*['"])([^'"]+?)(\.ts)(['"])/g; + + const dynamicImportRegex = + /(import\(\s*['"])([^'"]+?)(\.ts)(['"]\s*\))/g; + + let updatedContent = content.replace( + staticImportRegex, + (_, p1, p2, p3, p4) => { + return `${p1}${p2}.js${p4}`; + }, + ); + + updatedContent = updatedContent.replace( + dynamicImportRegex, + (_, p1, p2, p3, p4) => { + return `${p1}${p2}.js${p4}`; + }, + ); + + await Deno.writeTextFile(file, updatedContent); + } +} + +export default bundle; diff --git a/server/deno.json b/server/deno.json new file mode 100644 index 0000000..fb05289 --- /dev/null +++ b/server/deno.json @@ -0,0 +1,20 @@ +{ + "tasks": { + "dev": "deno run --allow-read --allow-write --allow-sys --allow-env --allow-run ./autoBundler.ts & deno serve --allow-read --allow-write --allow-sys --allow-env --allow-ffi --watch -R main.ts" + }, + "imports": { + "@db/sqlite": "jsr:@db/sqlite@^0.12.0", + "@eta-dev/eta": "jsr:@eta-dev/eta@^3.5.0", + "@felix/bcrypt": "jsr:@felix/bcrypt@^1.0.5", + "@minify-html/node": "npm:@minify-html/node@^0.15.0", + "@ryanflorence/sqlite-typegen": "npm:@ryanflorence/sqlite-typegen@^0.2.0", + "@std/assert": "jsr:@std/assert@1", + "@std/crypto": "jsr:@std/crypto@^1.0.3", + "@std/dotenv": "jsr:@std/dotenv@^0.225.3", + "@std/http": "jsr:@std/http@^1.0.12", + "@src/": "./src/", + "better-sqlite3": "npm:better-sqlite3@^11.8.0", + "esbuild": "npm:esbuild@^0.24.2", + "mariadb": "npm:mariadb@^3.4.0" + } +} diff --git a/server/main.ts b/server/main.ts new file mode 100644 index 0000000..07e6130 --- /dev/null +++ b/server/main.ts @@ -0,0 +1,48 @@ +import HttpRouter from "@src/router.ts"; +import { Eta } from "@eta-dev/eta"; +import { serveFile } from "jsr:@std/http/file-server"; +import rateLimitMiddleware from "@src/middleware/rateLimiter.ts"; +import authMiddleware from "@src/middleware/auth.ts"; + +const router = new HttpRouter(); + +const views = Deno.cwd() + "/views/"; +const eta = new Eta({ views }); + +router.use(rateLimitMiddleware); +router.use(authMiddleware); + +const filesToCache = new Set(["/public/js/shared.bundle.js"]); + +router.get("/public/*", async (c) => { + const filePath = "." + c.path; + + const res = await serveFile(c.req, filePath); + + if (filesToCache.has(filePath)) { + res.headers.set("Cache-Control", "public max-age=31536000"); + } + + return res; +}); + +router + .get(["", "/index.html"], (c) => { + return c.html(eta.render("./index.html", {})); + }) + .get(["/login", "/login.html"], (c) => { + return c.html(eta.render("./login.html", {})); + }) + .post("/login", async (c) => { + const body = await c.req.text(); + + console.log(JSON.parse(body)); + + return c.json({ mes: "got you" }); + }); + +export default { + async fetch(req, connInfo) { + return await router.handleRequest(req, connInfo); + }, +} satisfies Deno.ServeDefaultExport; diff --git a/server/public/js/login.js b/server/public/js/login.js new file mode 100644 index 0000000..495a1e5 --- /dev/null +++ b/server/public/js/login.js @@ -0,0 +1 @@ +import{ok as e}from"./shared.bundle.js";const a=e("test");console.log(a);const c=document.getElementById("loginForm"),d=document.getElementById("passwordInput");c.addEventListener("submit",async t=>{t.preventDefault();const s=d.value,o=JSON.stringify(e({password:s}));console.log(o);const n=await(await fetch("/login",{method:"POST",headers:{accept:"application/json"},body:o})).json();console.log(n)}); diff --git a/server/public/js/shared.bundle.js b/server/public/js/shared.bundle.js new file mode 100644 index 0000000..983d593 --- /dev/null +++ b/server/public/js/shared.bundle.js @@ -0,0 +1 @@ +var U=class r{constructor(e){this._promise=e;this._promise=e}static fromPromise(e,t){let n=e.then(p=>new s(p)).catch(p=>new i(t(p)));return new r(n)}static fromSafePromise(e){let t=e.then(n=>new s(n));return new r(t)}static fromThrowable(e,t){return(...n)=>{try{return v(e(n))}catch(p){return T(t?t(p):p)}}}async unwrap(){let e=await this._promise;if(e.isErr())throw e.error;return e.value}async match(e,t){let n=await this._promise;return n.isErr()?t(n.error):e(n.value)}map(e){return new r(this._promise.then(t=>t.isErr()?new i(t.error):new s(e(t.value))))}mapAsync(e){return new r(this._promise.then(async t=>t.isErr()?T(t.error):new s(await e(t.value))))}mapErr(e){return new r(this._promise.then(t=>t.isErr()?new i(e(t.error)):new s(t.value)))}mapErrAsync(e){return new r(this._promise.then(async t=>t.isErr()?T(await e(t.error)):new s(t.value)))}andThen(e){return new r(this._promise.then(t=>t.isErr()?T(t.error):e(t.value)))}nullableToOption(){return this.map(e=>e?E(e):f)}flatten(){return new r(this._promise.then(e=>e.flatten()))}flattenOption(e){return new r(this._promise.then(t=>t.flattenOption(e)))}flattenOptionOrDefault(e){return new r(this._promise.then(t=>t.flattenOptionOrDefault(e)))}matchOption(e,t){return new r(this._promise.then(n=>n.matchOption(e,t)))}matchOptionAndFlatten(e,t){return new r(this._promise.then(n=>n.matchOptionAndFlatten(e,t)))}then(e,t){return this._promise.then(e,t)}};function v(r){return new U(Promise.resolve(new s(r)))}function T(r){return new U(Promise.resolve(new i(r)))}var s=class r{constructor(e){this.value=e;this.value=e,Object.defineProperties(this,{tag:{writable:!1,enumerable:!1}})}tag="Ok";isErr(){return!1}ifErr(e){return this}isErrOrNone(){return this.value instanceof o}isOk(){return!0}ifOk(e){return e(this.value),this}unwrap(){return this.value}unwrapOr(e){return this.value}unwrapOrElse(e){return this.value}match(e,t){return e(this.value)}map(e){let t=e(this.value);return new r(t)}mapOption(e){return this.value instanceof o||this.value instanceof l?u(this.value.map(e)):u(E(e(this.value)))}andThen(e){return e(this.value)}mapErr(e){return new r(this.value)}flatten(){return c(this)}flattenOption(e){return this.value instanceof o||this.value instanceof l?this.value.okOrElse(e):new r(this.value)}flattenOptionOr(e){return this.value instanceof o||this.value instanceof l?this.value.unwrapOr(e):new r(this.value)}matchOption(e,t){return this.value instanceof o||this.value instanceof l?u(this.value.match(e,t)):u(e(this.value))}toNullable(){return this.value}toAsync(){return v(this.value)}void(){return u()}},i=class r{constructor(e){this.error=e;this.error=e,Object.defineProperties(this,{tag:{writable:!1,configurable:!1,enumerable:!1}})}tag="Err";isErr(){return!0}ifErr(e){return e(this.error),this}isOk(){return!1}ifOk(e){return this}isErrOrNone(){return!0}unwrap(){let e=`Tried to unwrap error: ${O(this.error)}`;throw new Error(e)}unwrapOr(e){return e}unwrapOrElse(e){return e()}match(e,t){return t(this.error)}map(e){return new r(this.error)}mapErr(e){let t=e(this.error);return new r(t)}mapOption(e){return a(this.error)}andThen(e){return new r(this.error)}flatten(){return c(this)}flattenOption(e){return new r(this.error)}flattenOptionOr(e){return new r(this.error)}matchOption(e,t){return a(this.error)}toNullable(){return null}toAsync(){return T(this.error)}void(){return a(this.error)}};function u(r){return new s(r)}function a(r){return new i(r)}function b(r,e){return(...t)=>{try{let n=r(...t);return u(n)}catch(n){return a(e?e(n):n)}}}function O(r){if(r instanceof Error)return r.message?r.message:"code"in r&&typeof r.code=="string"?r.code:"An unknown error occurred";if(typeof r=="string")return r;if(typeof r=="object"&&r!==null&&"message"in r){let e=r;return typeof e.message=="string"?e.message:String(e.message)}return"An unknown error occurred"}function c(r){let e=r;for(;e instanceof s;)e=e.value;return e}function g(r){let e=JSON.parse(r);if(obj.value)return u(obj.value);if(obj.error)return a(obj.error)}var l=class r{constructor(e){this.value=e;Object.defineProperties(this,{tag:{writable:!1,enumerable:!1}})}tag="Some";isSome(){return!0}ifSome(e){return e(this.value),this}isNone(){return!1}ifNone(e){return this}map(e){return new r(e(this.value))}flatMap(e){return e(this.value)}andThen(e){return e(this.value)}unwrap(){return this.value}unwrapOr(e){return this.value}unwrapOrElse(e){return this.value}or(e){return this}orElse(e){return this}match(e,t){return e(this.value)}toJSON(){return{value:this.value}}toString(){return`Some(${this.value})`}toNullable(){return this.value}toBoolean(){return!0}okOrElse(e){return u(this.value)}},o=class r{tag="None";constructor(){Object.defineProperties(this,{tag:{writable:!1,enumerable:!1}})}isSome(){return!1}ifSome(e){return this}isNone(){return!0}ifNone(e){return e(),this}map(e){return new r}andThen(e){return f}flatMap(e){return new r}unwrap(){throw new Error("Tried to unwrap a non-existent value")}unwrapOr(e){return e}unwrapOrElse(e){return e()}or(e){return e}orElse(e){return e()}match(e,t){return t()}toJSON(){return{_tag:this._tag}}toString(){return"None"}toNullable(){return null}toBoolean(){return!1}okOrElse(e){return a(e())}};function E(r){return new l(r)}var f=new o;function S(r){return r?E(r):f}export{i as Err,o as None,s as Ok,U as ResultAsync,g as ResultFromJSON,l as Some,a as err,T as errAsync,c as flattenResult,S as fromNullableVal,b as fromThrowable,O as getMessageFromError,f as none,u as ok,v as okAsync,E as some}; diff --git a/server/public/js/test.js b/server/public/js/test.js new file mode 100644 index 0000000..39a479a --- /dev/null +++ b/server/public/js/test.js @@ -0,0 +1 @@ +import{none as o}from"./shared.bundle.js";console.log(o); diff --git a/server/src/admin.ts b/server/src/admin.ts new file mode 100644 index 0000000..4c0e760 --- /dev/null +++ b/server/src/admin.ts @@ -0,0 +1,219 @@ +import { Option, some } from "@shared/utils/option.ts"; +import db from "@src/db/index.ts"; +import { ok, Result } from "@shared/utils/result.ts"; +import { AdminPasswordNotSetError, QueryExecutionError } from "@src/errors.ts"; +import { AdminRaw, AdminSessionRaw } from "@src/db/types/index.ts"; +import { generateRandomString, passwd } from "@src/utils.ts"; +import { errAsync, ResultAsync } from "@shared/utils/resultasync.ts"; + +const TOKEN_LENGTH = 128; +const EXPIRED_TOKENS_DELETION_INTERVAL = 120 * 60 * 1000; +const DEFAULT_SESSION_EXPIRATION_OFFSET = 15; // minutes + +class Admin { + private passwordHash?: string; + + private readonly statements = { + fetchAdmin: db.prepareFetch( + "SELECT * FROM admin WHERE id = 1", + ).get, + updatePasswordHash: db.prepareExec<[hash: string]>( + "UPDATE admin SET passwordHash = ? WHERE id = 1", + ), + insertPasswordHash: db.prepareExec<[hash: string]>( + "INSERT INTO admin (id, passwordHash) VALUES (1, ?)", + ), + fetchPasswordHash: db.prepareFetch<{ passwordHash: string }, []>( + "SELECT passwordHash FROM admin WHERE id=1", + ).get, + }; + + private getPasswordHash(): Result, QueryExecutionError> { + if (this.passwordHash) { + return ok(some(this.passwordHash)); + } + + return this.statements + .fetchPasswordHash() + .mapOption(({ passwordHash }) => { + this.passwordHash = passwordHash; + return passwordHash; + }); + } + + public isPasswordSet(): Result { + return this.getPasswordHash().map((opt) => opt.toBoolean()); + } + + public verifyPassword( + password: string, + ): ResultAsync { + const result = this.getPasswordHash().flattenOption( + () => new AdminPasswordNotSetError("Admin password is not set"), + ); + + if (result.isErr()) { + return errAsync(result.error); + } + + const hash = result.value; + return ResultAsync.fromSafePromise(passwd.verify(password, hash)); + } + + public setPassword( + password: string, + ): ResultAsync { + return ResultAsync.fromSafePromise(passwd.hash(password)).andThen( + (hash) => + this.getPasswordHash() + .matchOption( + () => { + this.statements.updatePasswordHash(hash); + this.passwordHash = hash; + }, + () => { + this.statements.insertPasswordHash(hash); + this.passwordHash = hash; + }, + ) + .toAsync(), + ); + } + + public readonly sessions = new AdminSessions(); +} + +class AdminSessions { + private cachedTokens: Map = new Map(); + + private readonly statements = { + insertSessionToken: db.prepareExec<[token: string]>( + "INSERT INTO adminSessions (token) VALUES (?)", + ), + insertSessionTokenWithExpiry: db.prepareExec< + [token: string, date: string] + >("INSERT INTO adminSessions (token, expiresAt) VALUES (?, ?)"), + fetchSessionByToken: db.prepareFetch( + "SELECT * FROM adminSessions WHERE token = ?", + ).get, + deleteSessionById: db.prepareExec<[id: number]>( + "DELETE FROM adminSessions WHERE id = ?", + ), + deleteSessionByToken: db.prepareExec<[token: string]>( + "DELETE FROM adminSessions WHERE token = ?", + ), + deleteAllSessions: db.prepareExec<[]>("DELETE FROM adminSessions"), + deleteExpiredSessions: db.prepareExec<[]>( + `DELETE FROM adminSessions + WHERE expiresAt < datetime('now')`, + ), + }; + + constructor() { + setInterval(() => { + this.clearExpiredSessions().match( + (clearedCount) => { + if (clearedCount > 0) + console.info( + `cleared ${clearedCount} expired token${clearedCount === 1 ? "" : "s"}`, + ); + }, + (error) => + console.error(`failed to clear expired tokens: ${error}`), + ); + }, EXPIRED_TOKENS_DELETION_INTERVAL); + } + + public create(expiresAt?: Date): Result { + const token = generateRandomString(TOKEN_LENGTH); + + if (expiresAt) { + return this.statements + .insertSessionTokenWithExpiry(token, expiresAt.toISOString()) + .map(() => token); + } + + const now = new Date(); + const expiresAtDefault = new Date(); + expiresAtDefault.setMinutes( + now.getMinutes() + DEFAULT_SESSION_EXPIRATION_OFFSET, + ); + + return this.statements + .insertSessionTokenWithExpiry(token, expiresAtDefault.toISOString()) + .map(() => token); + } + + public verifyToken(token: string): Result { + const cached = this.cachedTokens.get(token); + + if (cached) { + if (cached.expiresAt > new Date()) { + return ok(true); + } + this.cachedTokens.delete(token); + } + + return this.getToken(token).andThen((opt) => + opt.match( + (session) => { + if (session.expiresAt > new Date()) { + this.cachedTokens.set(token, session); + return ok(true); + } + return this.deleteSessionById(session.id).map(() => false); + }, + () => ok(false), + ), + ); + } + + private getToken( + token: string, + ): Result, QueryExecutionError> { + return this.statements + .fetchSessionByToken(token) + .mapOption(({ id, expiresAt }) => { + return { + id, + expiresAt: new Date(expiresAt), + }; + }); + } + + public deleteSessionById(id: number): Result { + return this.statements.deleteSessionById(id); + } + + public deleteSessionByToken( + token: string, + ): Result { + return this.statements.deleteSessionByToken(token); + } + + public deleteAllSessions(): Result { + return this.statements.deleteAllSessions(); + } + + public clearExpiredSessions(): Result { + const now = new Date(); + + for (const token of this.cachedTokens.keys()) { + const tokenMeta = this.cachedTokens.get(token); + if (tokenMeta && tokenMeta.expiresAt < now) { + this.cachedTokens.delete(token); + } + } + + return this.statements.deleteExpiredSessions(); + } +} + +interface Token { + id: number; + expiresAt: Date; +} + +const admin = new Admin(); + +export default admin; diff --git a/server/src/apiValidator.ts b/server/src/apiValidator.ts new file mode 100644 index 0000000..e353eb4 --- /dev/null +++ b/server/src/apiValidator.ts @@ -0,0 +1,10 @@ +import { Result } from "@shared/utils/result.ts"; + +class Api { + client = { + validate(res: Response): Result, + }; + server = { + validate(req: Request): Result, + }; +} diff --git a/server/src/client_js/login.ts b/server/src/client_js/login.ts new file mode 100644 index 0000000..1db5078 --- /dev/null +++ b/server/src/client_js/login.ts @@ -0,0 +1,34 @@ +/// + +import { ok } from "./shared.bundle.ts"; + +const a = ok("test"); + +console.log(a); + +const form = document.getElementById("loginForm") as HTMLFormElement; +const passwordInput = document.getElementById( + "passwordInput", +) as HTMLInputElement; + +form.addEventListener("submit", async (e) => { + e.preventDefault(); + + const password = passwordInput.value; + + const bodyReq = JSON.stringify(ok({ + password: password, + })); + + console.log(bodyReq); + + const response = await fetch("/login", { + method: "POST", + headers: { accept: "application/json" }, + body: bodyReq, + }); + + const body = await response.json(); + + console.log(body); +}); diff --git a/server/src/client_js/shared.bundle.ts b/server/src/client_js/shared.bundle.ts new file mode 120000 index 0000000..e4719d5 --- /dev/null +++ b/server/src/client_js/shared.bundle.ts @@ -0,0 +1 @@ +../../../shared/utils/index.ts \ No newline at end of file diff --git a/server/src/client_js/test.ts b/server/src/client_js/test.ts new file mode 100644 index 0000000..cb3d709 --- /dev/null +++ b/server/src/client_js/test.ts @@ -0,0 +1,3 @@ +import { none, some } from "./shared.bundle.ts"; + +console.log(none); diff --git a/server/src/context.ts b/server/src/context.ts new file mode 100644 index 0000000..d61326d --- /dev/null +++ b/server/src/context.ts @@ -0,0 +1,202 @@ +import { type Params } from "@src/router.ts"; +import { ExtractRouteParams } from "@src/router.ts"; +import { fromNullableVal, none, Option, some } from "@shared/utils/option.ts"; +import { deleteCookie, getCookies, setCookie } from "@std/http/cookie"; +import { type Cookie } from "@std/http/cookie"; + +// https://cheatsheetseries.owasp.org/cheatsheets/HTTP_Headers_Cheat_Sheet.html +const SECURITY_HEADERS: Headers = new Headers({ + "X-Frame-Option": "DENY", + "X-Content-Type-Options": "nosniff", + //"Referrer-Policy": "strict-origin-when-cross-origin", + //"Cross-Origin-Opener-Policy": "same-origin", + //"Cross-Origin-Resource-Policy": "same-site", + "Permissions-Policy": "geolocation=(), camera=(), microphone=()", + Server: "webserver", + //"Content-Security-Policy": + // "default-src 'self'; script-src 'self' 'unsafe-inline'", +}); +const HTML_CONTENT_TYPE: [string, string] = [ + "Content-Type", + "text/html; charset=UTF-8", +]; +const JSON_CONTENT_TYPE: [string, string] = [ + "Content-Type", + "application/json; charset=utf-8", +]; + +function mergeHeaders(...headers: Headers[]): Headers { + const mergedHeaders = new Headers(); + for (const _headers of headers) { + for (const [key, value] of _headers.entries()) { + mergedHeaders.set(key, value); + } + } + return mergedHeaders; +} + +export class Context { + private _url?: URL; + private _hostname?: string; + private _port?: number; + private _cookies?: Record; + private _responseHeaders: Headers = new Headers(); + + constructor( + public readonly req: Request, + public readonly info: Deno.ServeHandlerInfo, + public readonly params: Params>, + ) {} + + get url(): URL { + return this._url ?? (this._url = new URL(this.req.url)); + } + + get path(): ExtractPath { + return this.url.pathname as ExtractPath; + } + + get preferredType(): Option<"json" | "html"> { + const headers = new Headers(this.req.headers); + + return fromNullableVal(headers.get("accept")).andThen( + (types_header) => { + const types = types_header.split(";")[0].trim().split(","); + + for (const type of types) { + if (type === "text/html") { + return some("html"); + } + if (type === "application/json") { + return some("json"); + } + } + return none; + }, + ); + } + + get hostname(): Option { + if (this._hostname) return some(this._hostname); + + const remoteAddr = this.info.remoteAddr; + + if (remoteAddr.transport === "tcp" || remoteAddr.transport === "udp") { + this._hostname = remoteAddr.hostname; + return some(remoteAddr.hostname); + } + return none; + } + + get port(): Option { + if (this._port) return some(this._port); + + const remoteAddr = this.info.remoteAddr; + + if (remoteAddr.transport === "tcp" || remoteAddr.transport === "udp") { + this._port = remoteAddr.port; + return some(remoteAddr.port); + } + return none; + } + + public json(body?: object | string, init: ResponseInit = {}): Response { + const headers = mergeHeaders( + SECURITY_HEADERS, + this._responseHeaders, + new Headers(init.headers), + ); + headers.set(...JSON_CONTENT_TYPE); + let status = init.status || 200; + + let responseBody: BodyInit | null = null; + if (typeof body === "string") { + responseBody = body; + } else if (body !== undefined) { + try { + responseBody = JSON.stringify(body); + } catch (error) { + console.error("Failed to serialize JSON body:", error); + responseBody = JSON.stringify({ + err: "Internal Server Error", + }); + status = 400; + } + } + + return new Response(responseBody, { + status, + headers, + }); + } + + public html(body?: BodyInit | null, init: ResponseInit = {}): Response { + const headers = mergeHeaders( + SECURITY_HEADERS, + this._responseHeaders, + new Headers(init.headers), + ); + headers.set(...HTML_CONTENT_TYPE); + const status = init.status ?? 200; + + return new Response(body ?? null, { + status, + headers, + }); + } + + public redirect(url: string, permanent = false): Response { + const headers = mergeHeaders( + this._responseHeaders, + new Headers({ location: url }), + ); + + return new Response(null, { + status: permanent ? 301 : 302, + headers, + }); + } + + public cookies = (() => { + const self = this; + + return { + get(name: string): Option { + if (!self._cookies) { + self._cookies = getCookies(self.req.headers); + } + + return fromNullableVal(self._cookies[name]); + }, + + set(cookie: Cookie) { + setCookie(self._responseHeaders, cookie); + }, + + delete(name: string) { + deleteCookie(self._responseHeaders, name); + }, + }; + })(); + + static setParams( + ctx: Context, + params: Params>, + ): Context { + const newCtx = new Context(ctx.req, ctx.info, params); + + newCtx._url = ctx._url; + newCtx._hostname = ctx._hostname; + newCtx._port = ctx._port; + newCtx._cookies = ctx._cookies; + newCtx._responseHeaders = ctx._responseHeaders; + + return newCtx; + } +} + +type ExtractPath = S extends + `${infer _Start}:${infer Param}/${infer Rest}` ? string + : S extends `${infer _Start}/:${infer Param}` ? string + : S extends `${infer _Start}*` ? string + : S; diff --git a/server/src/db.bkp/db.ts b/server/src/db.bkp/db.ts new file mode 100644 index 0000000..c5f123b --- /dev/null +++ b/server/src/db.bkp/db.ts @@ -0,0 +1,36 @@ +import mariadb from "npm:mariadb"; +import "jsr:@std/dotenv/load"; +import { getMessageFromError } from "@shared/utils/result.ts"; +import { QueryExecutionError } from "@src/errors.ts"; +import { none, some } from "@shared/utils/option.ts"; +import { ResultAsync } from "@shared/utils/resultasync.ts"; +import OkPacket from "../../../../../.cache/deno/npm/registry.npmjs.org/mariadb/3.4.0/lib/cmd/class/ok-packet.js"; + +export class DB { + constructor(private readonly pool: mariadb.Pool) {} + + public query(sql: string, values?: (string | number)[]) { + return ResultAsync.fromPromise(this.pool.query(sql, values), (e) => { + const errorMessage = getMessageFromError(e); + return new QueryExecutionError(errorMessage); + }); + } + + public getAll(sql: string, values?: (string | number)[]) { + return this.query(sql, values).nullableToOption(); + } + + public getFirst(sql: string, values?: (string | number)[]) { + return this.query(sql, values).map((v) => + v[0] ? some(v[0]) : none, + ); + } + + public insert(sql: string, values?: (string | number)[]) { + return this.query(sql, values); + } + + public update(sql: string, values?: (string | number)[]) { + return this.query(sql, values); + } +} diff --git a/server/src/db.bkp/generateTypes.ts b/server/src/db.bkp/generateTypes.ts new file mode 100644 index 0000000..e363a28 --- /dev/null +++ b/server/src/db.bkp/generateTypes.ts @@ -0,0 +1,39 @@ +import { generateMysqlTypes } from "npm:mysql-types-generator"; +import { loadMariaDBConfigFromEnv } from "@lib/db/db.ts"; + +const { host, user, password, port, database } = loadMariaDBConfigFromEnv(); + +const dbConfig = { + host, + port, + user, + password, + database, + //ssl: { + // rejectUnauthorized: true, + //}, +}; + +generateMysqlTypes({ + db: dbConfig, + output: { + // Specify only one of the following 2 options: + file: "types/types.ts", + }, + //suffix: "PO", + ignoreTables: ["_keyborgMigrations"], + //overrides: [ + // { + // tableName: "my_table", + // columnName: "my_actual_tinyint_column", + // columnType: "int", + // }, + // { + // tableName: "my_table", + // columnName: "my_column", + // columnType: "enum", + // enumString: `enum('a','b','c')`, + // }, + //], + tinyintIsBoolean: true, +}); diff --git a/server/src/db.bkp/index.ts b/server/src/db.bkp/index.ts new file mode 100644 index 0000000..73feac6 --- /dev/null +++ b/server/src/db.bkp/index.ts @@ -0,0 +1,90 @@ +import { + createAndTestMariaDBPool, + loadMariaDBConfigFromEnv, +} from "@src/db/mariadbCon.ts"; +import { Migration, MigrationManager } from "@src/db/migrations.ts"; +import "jsr:@std/dotenv/load"; +import { DB } from "@src/db/db.ts"; + +// ── Establish connection to mariadb ───────────────────────────────── + +const config = loadMariaDBConfigFromEnv(); +const pool = await createAndTestMariaDBPool(config); + +// ── Migrate database to the latest version ────────────────────────── +//#region Migrations +const migrations = [ + Migration.create( + "initialize-database", + "Creates tables that are essential for the app", + [ + `CREATE TABLE IF NOT EXISTS admin ( + id INT PRIMARY KEY AUTO_INCREMENT, + passwordHash CHAR(60) NOT NULL + );`, + + `CREATE TABLE IF NOT EXISTS adminSessions ( + id INT PRIMARY KEY AUTO_INCREMENT, + sessionId CHAR(255) NOT NULL, + expiresAt DATETIME NOT NULL + );`, + + `CREATE TABLE IF NOT EXISTS users ( + id INT PRIMARY KEY AUTO_INCREMENT, + login VARCHAR(255) NOT NULL UNIQUE, + name VARCHAR(512), + telegramId VARCHAR(32), + passwordHash CHAR(60) NOT NULL, + createdAt DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updatedAt DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + deleted TINYINT(1) NOT NULL DEFAULT 0 + );`, + + `CREATE TABLE IF NOT EXISTS userSessions ( + id INT PRIMARY KEY AUTO_INCREMENT, + userId INT NOT NULL, + sessionId CHAR(255) NOT NULL, + expiresAt DATETIME NOT NULL DEFAULT (CURRENT_TIMESTAMP + INTERVAL 2 WEEK), + FOREIGN KEY (userId) REFERENCES users(id) ON DELETE CASCADE + );`, + + `CREATE TABLE IF NOT EXISTS devices ( + uuid CHAR(36) PRIMARY KEY, + busid VARCHAR(512) NOT NULL, + usbid CHAR(9) NOT NULL, + vendor VARCHAR(1024) NOT NULL DEFAULT 'unknown vendor', + deviceName VARCHAR(1024) NOT NULL DEFAULT 'unknown device', + displayName VARCHAR(255), + description TEXT, + connectedAt DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updatedAt DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP + );`, + + `CREATE TABLE IF NOT EXISTS deviceEvents ( + id INT PRIMARY KEY AUTO_INCREMENT, + userId INT, + targetUuid CHAR(36), + event ENUM('attached', 'detached'), + occuredAt DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY(targetUuid) REFERENCES devices(uuid) ON DELETE CASCADE, + FOREIGN KEY(userId) REFERENCES users(id) ON DELETE CASCADE + );`, + ], + [ + `DROP TABLE IF EXISTS adminSessions;`, + `DROP TABLE IF EXISTS admin;`, + `DROP TABLE IF EXISTS deviceEvents;`, + `DROP TABLE IF EXISTS devices;`, + `DROP TABLE IF EXISTS userSessions;`, + `DROP TABLE IF EXISTS users;`, + ], + ), +]; +//#endregion + +await MigrationManager.initialize(pool, config.database, migrations); + +// ── initialize db helper ──────────────────────────────────────────── +const db = new DB(pool); + +export default db; diff --git a/server/src/db.bkp/mariadbCon.ts b/server/src/db.bkp/mariadbCon.ts new file mode 100644 index 0000000..d425af7 --- /dev/null +++ b/server/src/db.bkp/mariadbCon.ts @@ -0,0 +1,72 @@ +import log from "@shared/utils/logger.ts"; +import * as mariadb from "npm:mariadb"; +import { getMessageFromError } from "@shared/utils/result.ts"; + +interface MariaDBConfig { + user: string; + password: string; + host: string; + port: number; + database: string; +} + +export function loadMariaDBConfigFromEnv(): MariaDBConfig { + const requiredVars = [ + "KBRG_MYSQL_USER", + "KBRG_MYSQL_PASSWORD", + "KBRG_MYSQL_DATABASE", + ]; + + const missingVars = requiredVars.filter( + (varName) => !Deno.env.has(varName), + ); + + if (missingVars.length > 0) { + log.critical( + `Missing required environment variables: ${missingVars.join(", ")}`, + ); + Deno.exit(1); + } + + const host = Deno.env.get("KBRG_MYSQL_HOST") || "localhost"; + const user = Deno.env.get("KBRG_MYSQL_USER")!; + const password = Deno.env.get("KBRG_MYSQL_PASSWORD")!; + const database = Deno.env.get("KBRG_MYSQL_DATABASE")!; + const port = parseInt(Deno.env.get("KBRG_MYSQL_PORT") || "3306"); + + if (isNaN(port)) { + log.critical(`Invalid port number for KBRG_MYSQL_PORT: ${port}`); + Deno.exit(1); + } + + return { user, password, host, port, database }; +} + +export async function createAndTestMariaDBPool( + config: MariaDBConfig, +): Promise { + const { user, password, host, port, database } = config; + + try { + const pool = mariadb.createPool({ + host, + port, + user, + password, + database, + connectionLimit: 10, + }); + + // Test Connection + const connection = await pool.getConnection(); + connection.release(); + + log.info("Successfully connected to MySQL"); + + return pool; + } catch (e) { + const errorMessage = getMessageFromError(e); + log.critical(`Failed to create MySQL pool: ${errorMessage}`); + Deno.exit(1); + } +} diff --git a/server/src/db.bkp/migrations.ts b/server/src/db.bkp/migrations.ts new file mode 100644 index 0000000..09a64f7 --- /dev/null +++ b/server/src/db.bkp/migrations.ts @@ -0,0 +1,222 @@ +import { Pool } from "mariadb"; +import log from "@shared/utils/logger.ts"; +import { none, Option, some } from "@shared/utils/option.ts"; +import { getMessageFromError } from "@shared/utils/result.ts"; + +interface MigrationTableEntry { + name: string; + description: string; + step: number; +} + +export class Migration { + constructor( + public readonly name: string, + public readonly description: string, + public readonly up: string[], + public readonly down: string[], + public readonly step: number, + ) {} + + static create = (() => { + let step = 0; + + return ( + name: string, + description: string, + up: string[], + down: string[], + ) => new Migration(name, description, up, down, step++); + })(); +} + +interface Exists { + table_exists: 0 | 1; +} + +interface Count { + count: number; +} + +const MIGRATION_TABLE = "_keyborgMigrations"; + +export class MigrationManager { + constructor( + private readonly pool: Pool, + private readonly databaseName: string, + private readonly migrations: Migration[], + ) {} + + static async initialize( + pool: Pool, + databaseName: string, + migrations: Migration[], + ) { + const manager = new MigrationManager(pool, databaseName, migrations); + + if (await manager.doesMigrationTableExist()) { + if (!(await manager.areMigrationsAppliedInOrder())) { + log.critical("Migrations are not applied in order"); + Deno.exit(1); + } + } else { + if (await manager.hasOtherTables()) { + log.critical( + "Migration table not found, but the database is not empty. Clean the database or create a new one.", + ); + Deno.exit(1); + } + await manager.createMigrationTable(); + } + + await manager.migrateToLatest(); + //await manager.migrateDown(); + } + + private async executeOrExit( + query: string, + params?: string[], + ): Promise { + try { + const rows = await this.pool.query(query, params); + return rows; + } catch (e) { + const errMsg = getMessageFromError(e); + log.critical(`Database query failed: ${errMsg}`); + Deno.exit(1); + } + } + + private async doesMigrationTableExist(): Promise { + const query = ` + SELECT EXISTS ( + SELECT 1 + FROM INFORMATION_SCHEMA.TABLES + WHERE TABLE_SCHEMA = ? + AND TABLE_NAME = ? + ) AS table_exists; + `; + const params = [this.databaseName, MIGRATION_TABLE]; + const [result] = await this.executeOrExit(query, params); + return result.table_exists === 1; + } + + async hasOtherTables(): Promise { + const query = ` + SELECT COUNT(*) AS count + FROM INFORMATION_SCHEMA.TABLES + WHERE TABLE_SCHEMA = ? + AND TABLE_NAME != ?; + `; + const params = [this.databaseName, MIGRATION_TABLE]; + const [result] = await this.executeOrExit(query, params); + return result.count > 0; + } + + private async getAppliedMigrations(): Promise< + Option + > { + const query = `SELECT * FROM ${MIGRATION_TABLE} ORDER BY step ASC;`; + const migrations = await this.executeOrExit(query); + + return migrations ? none : some(migrations); + } + + private async getLastAppliedMigration(): Promise< + Option + > { + const query = ` + SELECT * FROM ${MIGRATION_TABLE} + ORDER BY step DESC + LIMIT 1; + `; + const [migration] = + await this.executeOrExit(query); + return migration ? some(migration) : none; + } + + async areMigrationsAppliedInOrder(): Promise { + const migrations = await this.getAppliedMigrations(); + + if (migrations.isSome()) { + let step = 0; + for (const migration of migrations.value) { + if (migration.step !== step++) { + return false; + } + } + } + + return true; + } + + async createMigrationTable() { + const query = ` + CREATE TABLE ? ( + id INT PRIMARY KEY AUTO_INCREMENT, + name VARCHAR(256) NOT NULL, + description TEXT, + step INT NOT NULL, + appliedAt DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + `; + + await this.executeOrExit(query, { MIGRATION_TABLE }); + } + + /** + * Return an Some(number) where number represents the step of an applied migration or none if no migrations were applied + */ + private async applyMigration(migration: Migration) { + for (const statement of migration.up) { + await this.executeOrExit(statement); + } + const query = ` + INSERT INTO ${MIGRATION_TABLE} (name, description, step) + VALUES (?, ?, ?); + `; + await this.executeOrExit(query, [ + migration.name, + migration.description, + migration.step.toString(), + ]); + } + + private async destroyMigration(migration: Migration) { + for (const statement of migration.down) { + await this.executeOrExit(statement); + } + const query = ` + DELETE FROM ${MIGRATION_TABLE} WHERE step = ?; + `; + await this.executeOrExit(query, [migration.step.toString()]); + } + + async migrateToLatest() { + const lastAppliedMigration = ( + await this.getLastAppliedMigration() + ).toNullable(); + let nextStep = lastAppliedMigration ? lastAppliedMigration.step + 1 : 0; + + while (nextStep < this.migrations.length) { + const migration = this.migrations[nextStep]; + await this.applyMigration(migration); + nextStep++; + } + + log.info("All migrations have been applied."); + } + + async migrateDown() { + const lastAppliedMigration = await this.getLastAppliedMigration(); + + if (lastAppliedMigration.isNone()) { + log.warn( + "Cannot migrate down because no migrations has been applied", + ); + } else { + const step = lastAppliedMigration.value.step; + this.destroyMigration(this.migrations[step]); + } + } +} diff --git a/server/src/db.bkp/types/types.ts b/server/src/db.bkp/types/types.ts new file mode 100644 index 0000000..efb4e87 --- /dev/null +++ b/server/src/db.bkp/types/types.ts @@ -0,0 +1,48 @@ +/** + * This file is auto-generated and should not be edited. + * It will be overwritten the next time types are generated. + */ + +export type Admin = { + id: number; + passwordHash: string; +}; +export type AdminSessions = { + id: number; + sessionId: string; + expiresAt: Date; +}; +export type Users = { + id: number; + login: string; + name: string | null; + telegramId: string | null; + passwordHash: string; + createdAt: Date; + updatedAt: Date; + deleted: boolean; +}; +export type UserSessions = { + id: number; + userId: number; + sessionId: string; + expiresAt: Date; +}; +export type Devices = { + uuid: string; + busid: string; + usbid: string; + vendor: string; + deviceName: string; + displayName: string | null; + description: string | null; + connectedAt: Date; + updatedAt: Date; +}; +export type DeviceEvents = { + id: number; + userId: number | null; + targetUuid: string | null; + event: "attached" | "detached" | null; + occuredAt: Date; +}; diff --git a/server/src/db/dbWrapper.ts b/server/src/db/dbWrapper.ts new file mode 100644 index 0000000..ed1b136 --- /dev/null +++ b/server/src/db/dbWrapper.ts @@ -0,0 +1,80 @@ +import { Database, RestBindParameters, Statement } from "@db/sqlite"; +import { err, getMessageFromError, ok, Result } from "@shared/utils/result.ts"; +import { QueryExecutionError } from "@src/errors.ts"; +import { fromNullableVal, none, Option, some } from "@shared/utils/option.ts"; + +export class DatabaseClient { + constructor(private readonly db: Database) {} + + private safeExecute(fn: () => T): Result { + try { + return ok(fn()); + } catch (e) { + const message = getMessageFromError(e); + return err(new QueryExecutionError(message)); + } + } + + exec( + sql: string, + ...params: RestBindParameters + ): Result { + return this.safeExecute(() => this.db.exec(sql, params)); + } + + first( + sql: string, + ...params: RestBindParameters + ): Result, QueryExecutionError> { + return this.safeExecute(() => + fromNullableVal(this.db.prepare(sql).get(params)), + ); + } + + all( + sql: string, + ...params: RestBindParameters + ): Result, QueryExecutionError> { + return this.safeExecute(() => this.db.prepare(sql).all(params)).map( + (results) => (results.length > 0 ? some(results) : none), + ); + } + + prepareFetch< + T extends object, + P extends RestBindParameters = RestBindParameters, + >(sql: string): PreparedStatement { + const stmt = this.db.prepare(sql); + + const get = ( + ...params: P + ): Result>, QueryExecutionError> => + this.safeExecute(() => fromNullableVal(stmt.get(params))); + + const all = ( + ...params: P + ): Result>, QueryExecutionError> => + this.safeExecute(() => stmt.all(params)).map((result) => + result.length > 0 ? some(result) : none, + ); + + return { get, all }; + } + + prepareExec

( + sql: string, + ): (...params: P) => Result { + const stmt = this.db.prepare(sql); + + return (...params: P): Result => { + return this.safeExecute(() => stmt.run(params)); + }; + } +} + +interface PreparedStatement { + get(...params: RestBindParameters): Result, QueryExecutionError>; + all( + ...params: RestBindParameters + ): Result, QueryExecutionError>; +} diff --git a/server/src/db/index.ts b/server/src/db/index.ts new file mode 100644 index 0000000..e00f716 --- /dev/null +++ b/server/src/db/index.ts @@ -0,0 +1,127 @@ +import { Migration, MigrationManager } from "@src/db/migrations.ts"; +import { Database } from "@db/sqlite"; +import { DatabaseClient } from "@src/db/dbWrapper.ts"; + +const sqliteDbPath = Deno.env.get("KBRG_SQLITE_DB_PATH") || "./test.db"; + +const sqliteDB = new Database(sqliteDbPath); + +const migrationManager = new MigrationManager( + [ + Migration.create( + "initial migration", + "create necessery tables", + [ + ` + CREATE TABLE IF NOT EXISTS admin ( + id INTEGER PRIMARY KEY AUTOINCREMENT CHECK(id = 1), + passwordHash TEXT NOT NULL, + createdAt TIMESTAMP NOT NULL DEFAULT (datetime('now')), + updatedAt DATETIME NOT NULL DEFAULT (datetime('now')) + );`, + + `CREATE TABLE IF NOT EXISTS adminSessions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + token TEXT NOT NULL, + createdAt DATETIME NOT NULL DEFAULT (datetime('now')), + expiresAt DATETIME NOT NULL DEFAULT (datetime('now', '+15 minutes')) + );`, + + `CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + login TEXT NOT NULL UNIQUE, + passwordHash TEXT NOT NULL, + displayName TEXT DEFAULT NULL, + telegramId TEXT DEFAULT NULL, + phone TEXT DEFAULT NULL, + email TEXT DEFAULT NULL, + deleted BOOLEAN NOT NULL DEFAULT (false), + createdAt TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updatedAt DATETIME DEFAULT (datetime('now')) + );`, + + `CREATE TABLE IF NOT EXISTS userSessions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + userId INT NOT NULL, + token TEXT NOT NULL, + createdAt DATETIME NOT NULL DEFAULT (datetime('now')), + expiredAt DATETIME NOT NULL DEFAULT (datetime('now', '+3 hours')), + + FOREIGN KEY(userId) REFERENCES users(id) + ON DELETE CASCADE + ON UPDATE NO ACTION + );`, + + `CREATE TABLE IF NOT EXISTS devices ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + busid TEXT NOT NULL, + usbid TEXT NOT NULL, + vendor TEXT NOT NULL DEFAULT 'unknown vendor', + deviceName TEXT NOT NULL DEFAULT 'unknown device', + displayName TEXT DEFAULT NULL, + description TEXT DEFAULT NULL, + connectedAt TIMESTAMP NOT NULL DEFAULT (datetime('now')), + disconnectedAt TIMESTAMP DEFAULT NULL, + updatedAt DATETIME NOT NULL DEFAULT (datetime('now')) + );`, + + `CREATE TABLE IF NOT EXISTS deviceAttachedEvent ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + userId INTEGER, + deviceId INTEGER, + event TEXT NOT NULL CHECK(event IN ('exported', 'released')), + + FOREIGN KEY(userId) REFERENCES users(id) + ON DELETE CASCADE + ON UPDATE NO ACTION + + FOREIGN KEY(deviceId) REFERENCES users(id) + ON DELETE CASCADE + ON UPDATE NO ACTION + );`, + + `CREATE TRIGGER update_admin_timestamp + AFTER UPDATE ON admin + FOR EACH ROW WHEN NEW.updatedAt = OLD.updatedAt + BEGIN + UPDATE admin + SET updatedAt = datetime('now') + WHERE id = OLD.id; + END;`, + + `CREATE TRIGGER update_user_timestamp + AFTER UPDATE ON users + FOR EACH ROW WHEN NEW.updatedAt = OLD.updatedAt + BEGIN + UPDATE users + SET updatedAt = datetime('now') + WHERE id = OLD.id; + END;`, + + `CREATE TRIGGER update_device_timestamp + AFTER UPDATE ON devices + FOR EACH ROW WHEN NEW.updatedAt = OLD.updatedAt + BEGIN + UPDATE devices + SET updatedAt = datetime('now') + WHERE id = OLD.id; + END;`, + ], + + [ + `DELETE TABLE admin;`, + `DELETE TABLE adminSessions;`, + `DELETE TABLE users;`, + `DELETE TABLE userSessions;`, + `DELETE TABLE devices;`, + ], + ), + ], + sqliteDB, +); + +migrationManager.init(); + +const db = new DatabaseClient(sqliteDB); + +export default db; diff --git a/server/src/db/migrations.ts b/server/src/db/migrations.ts new file mode 100644 index 0000000..93f95a5 --- /dev/null +++ b/server/src/db/migrations.ts @@ -0,0 +1,213 @@ +import { Database } from "jsr:@db/sqlite"; + +const MIGRATION_TABLE = "_keyborgMigrations"; + +interface MigrationRecord { + name: string; + description: string; + step: number; + appliedAt: string; +} + +export class Migration { + constructor( + public readonly name: string, + public readonly description: string, + public readonly upQueries: string[], + public readonly downQueries: string[], + public readonly step: number, + ) {} + + static create = (() => { + let step = 0; + + return ( + name: string, + description: string, + upQueries: string[], + downQueries: string[], + ) => new Migration(name, description, upQueries, downQueries, step++); + })(); +} + +export class MigrationManager { + constructor( + private readonly migrations: Migration[], + private readonly db: Database, + ) {} + + public init() { + if (!this.doesMigrationTableExist()) { + if (this.hasExistingTables()) { + console.error( + "Attempting to initialize migrations on a non-empty database.", + ); + Deno.exit(1); + } + + this.createMigrationsTable(); + } + + if (!this.areMigrationsSequential()) { + console.error("Migrations are not applied in sequential order!"); + Deno.exit(1); + } + this.applyPendingMigrations(); + } + + private createMigrationsTable(): void { + const sql = ` + CREATE TABLE IF NOT EXISTS ${MIGRATION_TABLE} ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT, + description TEXT, + step INTEGER NOT NULL UNIQUE, + appliedAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + `; + + this.executeSQLOrExit(sql); + } + + private doesMigrationTableExist() { + const sql = ` + SELECT name FROM sqlite_master WHERE type='table' AND name='${MIGRATION_TABLE}'; + `; + + return !!this.getOrExit(sql); + } + + private hasExistingTables() { + const sql = ` + SELECT name FROM sqlite_master + WHERE type='table' + AND name NOT IN ('sqlite_sequence', '${MIGRATION_TABLE}'); + `; + + const tables = this.allOrExit(sql); + return tables.length > 0; + } + + private areMigrationsSequential(): boolean { + const sql = ` + SELECT step FROM ${MIGRATION_TABLE} ORDER BY step ASC; + `; + + const records = this.allOrExit<{ step: number }>(sql); + + // check if array is dense + for (let i = 0; i < records.length; i++) { + if (records[i].step !== i) { + return false; + } + } + + return true; + } + + private getCurrentMigrationStep(): number { + const sql = ` + SELECT MAX(step) AS step FROM ${MIGRATION_TABLE}; + `; + + const latestMigrationStep = this.getOrExit>(sql); + + return latestMigrationStep?.step || -1; + } + + private applyPendingMigrations(): void { + const currentStep = this.getCurrentMigrationStep(); + const pendingMigrations = this.migrations.filter( + (migration) => migration.step > currentStep, + ); + + for (const migration of pendingMigrations) { + this.applyMigration(migration); + } + } + + private applyMigration(migration: Migration): void { + if ( + !this.getOrExit( + `SELECT step FROM ${MIGRATION_TABLE} WHERE step = ?`, + migration.step, + ) + ) { + for (const query of migration.upQueries) { + this.executeSQLOrExit(query); + } + + const sql = ` + INSERT INTO ${MIGRATION_TABLE} (name, description, step) + VALUES (?, ?, ?); + `; + + this.executeSQLOrExit( + sql, + migration.name, + migration.description, + migration.step, + ); + + console.info(`Applied migratoin: ${migration.name}`); + } + } + + public rollbackMigration(migration: Migration): void { + try { + for (const query of migration.downQueries.reverse()) { + this.executeSQLOrExit(query); + } + + const deleteSQL = ` + DELETE FROM ${MIGRATION_TABLE} + WHERE step = ?; + `; + this.executeSQLOrExit(deleteSQL, migration.step); + + console.log(`Rolled back migration: ${migration.name}`); + } catch (error) { + console.error( + `Failed to rollback migration: ${migration.name}`, + error, + ); + Deno.exit(1); + } + } + + private executeSQLOrExit( + sql: string, + ...params: (string | number)[] + ): void { + try { + this.db.exec(sql, params); + } catch (e) { + console.error(e); + Deno.exit(1); + } + } + + private allOrExit>( + sql: string, + ...params: (string | number)[] + ): T[] { + try { + return this.db.prepare(sql).all(params); + } catch (e) { + console.error(e); + Deno.exit(1); + } + } + + private getOrExit>( + sql: string, + ...params: (string | number)[] + ): T | undefined { + try { + return this.db.prepare(sql).get(params); + } catch (e) { + console.error(e); + Deno.exit(1); + } + } +} diff --git a/server/src/db/test.db b/server/src/db/test.db new file mode 100644 index 0000000..cb0068e Binary files /dev/null and b/server/src/db/test.db differ diff --git a/server/src/db/types/index.ts b/server/src/db/types/index.ts new file mode 100644 index 0000000..4e2b856 --- /dev/null +++ b/server/src/db/types/index.ts @@ -0,0 +1,13 @@ +export interface AdminRaw { + id: number; + passwordHash: string; + createdAt: string; + updatedAt: string; +} + +export interface AdminSessionRaw { + id: number; + token: string; + createdAt: string; + expiresAt: string; +} diff --git a/server/src/errors.ts b/server/src/errors.ts new file mode 100644 index 0000000..e30c723 --- /dev/null +++ b/server/src/errors.ts @@ -0,0 +1,50 @@ +import log from "@shared/utils/logger.ts"; + +export class ErrorBase extends Error { + constructor(message: string = "An unknown error has occurred") { + super(message); + this.name = this.constructor.name; + } +} + +export class QueryExecutionError extends ErrorBase { + public readonly code = "QueryExecutionError"; + constructor(message: string) { + super(message); + } +} + +export class NoAdminEntryError extends ErrorBase { + public readonly code = "NoAdminEntry"; + constructor(message: string) { + super(message); + } +} + +export class FailedToReadFileError extends ErrorBase { + public readonly code = "FailedToReadFileError"; + constructor(message: string) { + super(message); + } +} + +export class InvalidSyntaxError extends ErrorBase { + public readonly code = "InvalidSyntax"; + constructor(message: string) { + super(message); + } +} + +export class InvalidPathError extends ErrorBase { + public readonly code = "InvalidPath"; + constructor(message: string) { + super(message); + } +} + +export class AdminPasswordNotSetError extends ErrorBase { + public readonly code = "AdminPasswordNotSetError"; + constructor(message: string) { + super(message); + } +} diff --git a/server/src/middleware/auth.ts b/server/src/middleware/auth.ts new file mode 100644 index 0000000..b413a0c --- /dev/null +++ b/server/src/middleware/auth.ts @@ -0,0 +1,27 @@ +import { Middleware } from "@src/router.ts"; +import { none } from "@shared/utils/option.ts"; +import admin from "@src/admin.ts"; + +const LOGIN_PATH = "/login"; + +const authMiddleware: Middleware = async (c) => { + const token = c.cookies.get("token"); + const isValid = token + .map((token) => admin.sessions.verifyToken(token)) + .toBoolean(); + const path = c.path; + + if (path.startsWith("/public")) { + return; + } + + if (path !== LOGIN_PATH && !isValid) { + return c.redirect("/login"); + } + + if (path === LOGIN_PATH && isValid) { + return c.redirect(""); + } +}; + +export default authMiddleware; diff --git a/server/src/middleware/rateLimiter.ts b/server/src/middleware/rateLimiter.ts new file mode 100644 index 0000000..129f0ea --- /dev/null +++ b/server/src/middleware/rateLimiter.ts @@ -0,0 +1,55 @@ +import { Middleware } from "@src/router.ts"; +import { none, some } from "@shared/utils/option.ts"; + +const requestCounts: Partial< + Record +> = {}; + +const MAX_REQUESTS_PER_WINDOW = 300; +const RATE_LIMIT_WINDOW = 60000; + +const rateLimitMiddleware: Middleware = (c) => { + const hostnameOpt = c.hostname; + + if (hostnameOpt.isSome()) { + const hostname = hostnameOpt.value; + + const clientCount = requestCounts[hostname]; + const now = Date.now(); + + if (!clientCount || now - clientCount.lastReset > RATE_LIMIT_WINDOW) { + requestCounts[hostname] = { count: 1, lastReset: now }; + return; + } + + if (clientCount.count < MAX_REQUESTS_PER_WINDOW) { + clientCount.count++; + return; + } + + if (c.preferredType.isSome()) { + switch (c.preferredType.value) { + case "html": { + return c.html("429 Too Many Requests", { + status: 429, + }); + } + case "json": { + return c.json( + { + err: "429 Too Many Requests", + }, + { + status: 429, + }, + ); + } + } + } + return new Response("429 Too Many Request", { + status: 429, + }); + } +}; + +export default rateLimitMiddleware; diff --git a/server/src/requestHandler.ts b/server/src/requestHandler.ts new file mode 100644 index 0000000..cfcdba4 --- /dev/null +++ b/server/src/requestHandler.ts @@ -0,0 +1,49 @@ +type Methods = "GET" | "POST"; + +class RequestHandler { + constructor( + public readonly method: Methods, + public readonly handler: (req: Request) => Response, + ) {} +} + +class Router { + handlers: { + [x in string]: { + [method in Methods]?: RequestHandler; + }; + } = {}; + + add( + route: T, + method: "GET" | "POST", + handler: (req: Request, params: Params>) => Response, + ) {} + + handleRequest(req: Request) { + const url = new URL(req.url); + const pathname = url.pathname; + } +} + +const router = new Router(); + +router.add("/users/:slug", "POST", (req, params) => { + const _url = req.url; + + return new Response(_url); +}); + +type Params = Keys extends never + ? never + : { + [K in Keys]: string; + }; + +type ExtractParams = T extends string + ? T extends `${infer _Start}:${infer Param}/${infer Rest}` + ? Param | ExtractParams + : T extends `${infer _Start}:${infer Param}` + ? Param + : never + : never; diff --git a/server/src/router.ts b/server/src/router.ts new file mode 100644 index 0000000..30b1578 --- /dev/null +++ b/server/src/router.ts @@ -0,0 +1,138 @@ +import { RouterTree } from "@src/routerTree.ts"; +import { none, Option, some } from "@shared/utils/option.ts"; +import { Context } from "@src/context.ts"; + +type RequestHandler = ( + c: Context, +) => Promise | Response; + +export type Middleware = ( + c: Context, +) => Promise | Response | undefined; + +type MethodHandlers = Partial< + Record> +>; + +const DEFAULT_NOT_FOUND_HANDLER = () => new Response("404 Not found"); + +class HttpRouter { + routerTree = new RouterTree>(); + pathPreprocessor?: (path: string) => string; + middlewareChain: Middleware[] = []; + defaultNotFoundHandler: RequestHandler = DEFAULT_NOT_FOUND_HANDLER; + + setPathProcessor(processor: (path: string) => string) { + this.pathPreprocessor = processor; + } + + use(mw: Middleware): HttpRouter { + this.middlewareChain.push(mw); + return this; + } + + add( + path: S, + method: string, + handler: RequestHandler, + ): HttpRouter; + add( + path: S[], + method: string, + handler: RequestHandler, + ): HttpRouter; + + add( + path: string | string[], + method: string, + handler: RequestHandler, + ): HttpRouter { + const paths = Array.isArray(path) ? path : [path]; + + for (const p of paths) { + this.routerTree.getHandler(p).match( + (mth) => { + mth[method] = handler; + }, + () => { + const mth: MethodHandlers = {}; + mth[method] = handler; + this.routerTree.add(p, mth); + }, + ); + } + + return this; + } + + // Overload signatures for 'get' + get(path: S, handler: RequestHandler): HttpRouter; + get( + path: S[], + handler: RequestHandler, + ): HttpRouter; + + // Non-generic implementation for 'get' + get(path: string | string[], handler: RequestHandler): HttpRouter { + if (Array.isArray(path)) { + return this.add(path, "GET", handler); + } + return this.add(path, "GET", handler); + } + + post(path: S, handler: RequestHandler): HttpRouter; + post( + path: string[], + handler: RequestHandler, + ): HttpRouter; + + post(path: string | string[], handler: RequestHandler): HttpRouter { + if (Array.isArray(path)) { + return this.add(path, "POST", handler); + } + return this.add(path, "POST", handler); + } + + async handleRequest( + req: Request, + connInfo: Deno.ServeHandlerInfo, + ): Promise { + const c = new Context(req, connInfo, {}); + + for (const mw of this.middlewareChain) { + const res = await mw(c); + if (res) { + return res; + } + } + + const path = this.pathPreprocessor + ? this.pathPreprocessor(c.path) + : c.path; + + return await this.routerTree + .find(path) + .andThen((routeMatch) => { + const { value, params } = routeMatch; + const handler = value[req.method]; + return handler + ? some(handler(Context.setParams(c, params))) + : none; + }) + .unwrapOrElse(() => this.defaultNotFoundHandler(c)); + } +} + +export type ExtractRouteParams = T extends string + ? T extends `${infer _Start}:${infer Param}/${infer Rest}` + ? Param | ExtractRouteParams + : T extends `${infer _Start}:${infer Param}` ? Param + : T extends `${infer _Start}*` ? "restOfThePath" + : never + : never; + +export type Params = { + [K in Keys]: string; +}; + +export default HttpRouter; diff --git a/server/src/routerTree.ts b/server/src/routerTree.ts new file mode 100644 index 0000000..2df44db --- /dev/null +++ b/server/src/routerTree.ts @@ -0,0 +1,223 @@ +import { fromNullableVal, none, Option, some } from "@shared/utils/option.ts"; + +const DEFAULT_WILDCARD_SYMBOL = "*"; +const DEFAULT_PARAM_PREFIX = ":"; +const DEFAULT_PATH_SEPARATOR = "/"; + +interface Node { + handler: Option; + addChild( + segment: string, + wildcardSymbol: string, + paramPrefixSymbol: string, + handler?: T, + ): Node; + getChild(segment: string): Option>; + isDynamicNode(): this is DynamicNode; + isWildcardNode(): this is WildcardNode; +} + +class StaticNode implements Node { + protected staticChildren = new Map>(); + protected dynamicChild: Option> = none; + protected wildcardChild: Option> = none; + public handler: Option = none; + + constructor(handler?: T) { + this.handler = fromNullableVal(handler); + } + + addStaticChild(segment: string, handler?: T): StaticNode { + const child = new StaticNode(handler); + this.staticChildren.set(segment, child); + return child; + } + + setDynamicChild(paramName: string, handler?: T): DynamicNode { + const child = new DynamicNode(paramName, handler); + this.dynamicChild = some(child); + return child; + } + + setWildcardNode(handler?: T): WildcardNode { + const child = new WildcardNode(handler); + this.wildcardChild = some(child); + return child; + } + + addChild( + segment: string, + wildcardSymbol: string, + paramPrefixSymbol: string, + handler?: T, + ): Node { + if (segment === wildcardSymbol) { + return this.setWildcardNode(handler); + } + if (segment.startsWith(paramPrefixSymbol)) { + const paramName = segment.slice(paramPrefixSymbol.length); + return this.setDynamicChild(paramName, handler); + } + return this.addStaticChild(segment, handler); + } + + getStaticChild(segment: string): Option> { + return fromNullableVal(this.staticChildren.get(segment)); + } + + getDynamicChild(): Option> { + return this.dynamicChild; + } + + getWildcardChild(): Option> { + return this.wildcardChild; + } + + getChild(segment: string): Option> { + return this.getStaticChild(segment) + .orElse(() => this.getWildcardChild()) + .orElse(() => this.getDynamicChild()); + } + + public isDynamicNode(): this is DynamicNode { + return false; + } + + public isWildcardNode(): this is WildcardNode { + return false; + } +} + +class DynamicNode extends StaticNode implements Node { + constructor( + public readonly paramName: string, + handler?: T, + ) { + super(handler); + } + + public override isDynamicNode(): this is DynamicNode { + return true; + } +} + +class WildcardNode implements Node { + public handler: Option; + + constructor(handler?: T) { + this.handler = fromNullableVal(handler); + } + + // Override to prevent adding children to a wildcard node + public addChild(): Node { + throw new Error("Cannot add child to a WildcardNode."); + } + + public getChild(): Option> { + return none; + } + + public isWildcardNode(): this is WildcardNode { + return true; + } + + public isDynamicNode(): this is DynamicNode { + return false; + } +} + +// Using Node as the unified type for tree nodes. +type TreeNode = Node; + +export class RouterTree { + public readonly root: StaticNode; + + constructor( + handler?: T, + private readonly wildcardSymbol: string = DEFAULT_WILDCARD_SYMBOL, + private readonly paramPrefixSymbol: string = DEFAULT_PARAM_PREFIX, + private readonly pathSeparator: string = DEFAULT_PATH_SEPARATOR, + ) { + this.root = new StaticNode(handler); + } + + public add(path: string, handler: T): void { + const segments = this.splitPath(path); + let current: TreeNode = this.root; + + for (const segment of segments) { + current = current + .getChild(segment) + .unwrapOrElse(() => + current.addChild( + segment, + this.wildcardSymbol, + this.paramPrefixSymbol, + ) + ); + + if (current.isWildcardNode()) break; + } + + current.handler = some(handler); + } + + public find(path: string): Option> { + const segments = this.splitPath(path); + const params: Record = {}; + let current: TreeNode = this.root; + let i = 0; + + for (; i < segments.length; i++) { + const segment = segments[i]; + if (current.isWildcardNode()) break; + + const nextNode = current.getChild(segment).ifSome((child) => { + if (child.isDynamicNode()) { + params[child.paramName] = segment; + } + current = child; + }); + + if (nextNode.isNone()) return none; + } + + if (current.isWildcardNode()) { + const rest = segments.slice(i - 1); + if (rest.length > 0) { + params["restOfThePath"] = rest.join(this.pathSeparator); + } + } + + return current.handler.map((value) => ({ value, params })); + } + + public getHandler(path: string): Option { + const segments = this.splitPath(path); + let current: TreeNode = this.root; + + for (const segment of segments) { + if (current.isWildcardNode()) break; + + const child = current.getChild(segment).ifSome((child) => { + current = child; + }); + + if (child.isNone()) return none; + } + + return current.handler; + } + + private splitPath(path: string): string[] { + const trimmed = path.trim().replace(/^\/+/, "").replace(/\/+$/, ""); + return trimmed ? trimmed.split(this.pathSeparator) : []; + } +} + +export type Params = Record; + +interface RouteMatch { + value: T; + params: Params; +} diff --git a/server/src/utils.ts b/server/src/utils.ts new file mode 100644 index 0000000..a0196b4 --- /dev/null +++ b/server/src/utils.ts @@ -0,0 +1,33 @@ +import { hash, verify } from "@felix/bcrypt"; + +const DEFAULT_PASSWORD_LENGTH = 32; + +class Password { + public generate(length: number = DEFAULT_PASSWORD_LENGTH): string { + return generateRandomString(length); + } + + public async hash(password: string) { + return await hash(password); + } + + public async verify(password: string, hash: string) { + return await verify(password, hash); + } +} + +export const passwd = new Password(); + +export const generateRandomString = (length: number): string => { + const charset = + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + const charsetLength = charset.length; + const randomValues = new Uint8Array(length); + crypto.getRandomValues(randomValues); + + let result = ""; + for (let i = 0; i < length; i++) { + result += charset[randomValues[i] % charsetLength]; + } + return result; +}; diff --git a/server/src/wasm_example/Cargo.lock b/server/src/wasm_example/Cargo.lock new file mode 100644 index 0000000..54af417 --- /dev/null +++ b/server/src/wasm_example/Cargo.lock @@ -0,0 +1,166 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "bumpalo" +version = "3.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79296716171880943b8470b5f8d03aa55eb2e645a4874bdbb28adb49162e012c" + +[[package]] +name = "cfg-if" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" + +[[package]] +name = "js-sys" +version = "0.3.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6717b6b5b077764fb5966237269cb3c64edddde4b14ce42647430a78ced9e7b7" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "log" +version = "0.4.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7a70ba024b9dc04c27ea2f0c0548feb474ec5c54bba33a7f72f873a39d07b24" + +[[package]] +name = "once_cell" +version = "1.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1261fe7e33c73b354eab43b1273a57c8f967d0391e80353e51f764ac02cf6775" + +[[package]] +name = "proc-macro2" +version = "1.0.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37d3544b3f2748c54e147655edb5025752e2303145b5aefb3c3ea2c78b973bb0" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e4dccaaaf89514f546c693ddc140f729f958c247918a13380cccc6078391acc" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "serde" +version = "1.0.217" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02fc4265df13d6fa1d00ecff087228cc0a2b5f3c0e87e258d8b94a156e984c70" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde-wasm-bindgen" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3b4c031cd0d9014307d82b8abf653c0290fbdaeb4c02d00c63cf52f728628bf" +dependencies = [ + "js-sys", + "serde", + "wasm-bindgen", +] + +[[package]] +name = "serde_derive" +version = "1.0.217" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a9bf7cf98d04a2b28aead066b7496853d4779c9cc183c440dbac457641e19a0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "syn" +version = "2.0.95" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46f71c0377baf4ef1cc3e3402ded576dccc315800fbc62dfc7fe04b009773b4a" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adb9e6ca4f869e1180728b7950e35922a7fc6397f7b641499e8f3ef06e50dc83" + +[[package]] +name = "wasm-bindgen" +version = "0.2.99" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a474f6281d1d70c17ae7aa6a613c87fce69a127e2624002df63dcb39d6cf6396" +dependencies = [ + "cfg-if", + "once_cell", + "wasm-bindgen-macro", +] + +[[package]] +name = "wasm-bindgen-backend" +version = "0.2.99" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f89bb38646b4f81674e8f5c3fb81b562be1fd936d84320f3264486418519c79" +dependencies = [ + "bumpalo", + "log", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.99" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cc6181fd9a7492eef6fef1f33961e3695e4579b9872a6f7c83aee556666d4fe" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.99" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d7a95b763d3c45903ed6c81f156801839e5ee968bb07e534c44df0fcd330c2" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-backend", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.99" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "943aab3fdaaa029a6e0271b35ea10b72b943135afe9bffca82384098ad0e06a6" + +[[package]] +name = "wasm_example" +version = "0.1.0" +dependencies = [ + "serde", + "serde-wasm-bindgen", + "wasm-bindgen", +] diff --git a/server/src/wasm_example/Cargo.toml b/server/src/wasm_example/Cargo.toml new file mode 100644 index 0000000..da63bee --- /dev/null +++ b/server/src/wasm_example/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "wasm_example" +version = "0.1.0" +edition = "2021" + +[dependencies] +wasm-bindgen = "0.2.99" +serde = { version = "1.0", features = ["derive"] } +serde-wasm-bindgen = "0.4" + +[lib] +crate-type = ["cdylib"] diff --git a/server/src/wasm_example/src/lib.rs b/server/src/wasm_example/src/lib.rs new file mode 100644 index 0000000..ca92307 --- /dev/null +++ b/server/src/wasm_example/src/lib.rs @@ -0,0 +1,309 @@ +use std::collections::HashMap; + +use wasm_bindgen::prelude::*; + +type Handler = Option; +type Params = HashMap; + +const PARAM_PREFIX_DEFAULT: &str = ":"; +const PATH_SEPARATOR_DEFAULT: &str = "/"; +const WILDCARD_SYMBOL_DEFAULT: char = '*'; + +enum TreeNode<'a> { + Static(&'a StaticTreeNode), + Dynamic(&'a DynamicTreeNode), +} + +impl<'a> TreeNode<'a> { + pub fn extract_static_node(&self) -> &StaticTreeNode { + match self { + Self::Static(n) => n, + Self::Dynamic(n) => &n.node, + } + } +} + +enum TreeNodeMut<'a> { + Static(&'a mut StaticTreeNode), + Dynamic(&'a mut DynamicTreeNode), +} + +impl TreeNodeMut<'_> { + pub fn extract_static_node(&mut self) -> &mut StaticTreeNode { + match self { + Self::Static(n) => n, + Self::Dynamic(n) => &mut n.node, + } + } +} + +#[derive(Default)] +struct StaticTreeNode { + handler: Handler, + wildcard_handler: Handler, + + static_children: HashMap, + dynamic_child: Option>, +} + +struct DynamicTreeNode { + node: StaticTreeNode, + param_name: String, +} + +impl DynamicTreeNode { + pub fn new(handler: Handler, param_name: &str) -> Self { + DynamicTreeNode { + node: StaticTreeNode::new(handler), + param_name: param_name.to_string(), + } + } +} + +impl StaticTreeNode { + pub fn new(handler: Handler) -> Self { + StaticTreeNode { + handler, + ..Default::default() + } + } + + pub fn add_static_child(&mut self, segment: &str, handler: Handler) { + let child = StaticTreeNode::new(handler); + + self.static_children.insert(segment.to_string(), child); + } + + pub fn delete_static_child(&mut self, segment: &str) -> Option { + self.static_children.remove(segment) + } + + pub fn set_dynamic_child(&mut self, param_name: &str, handler: Handler) { + let child = DynamicTreeNode::new(handler, param_name); + + self.dynamic_child = Some(Box::new(child)); + } + + pub fn delete_dynamic_child(&mut self) { + self.dynamic_child = None + } + + pub fn set_wildcard_handler(&mut self, handler: Handler) { + self.wildcard_handler = handler + } + + pub fn delete_wildcard_handler(&mut self) { + self.wildcard_handler = None + } + + pub fn get_static_child(&self, segment: &str) -> Option<&StaticTreeNode> { + self.static_children.get(segment) + } + + pub fn has_static_child(&self, segment: &str) -> bool { + self.static_children.contains_key(segment) + } + + pub fn get_dynamic_child(&self) -> Option<&DynamicTreeNode> { + self.dynamic_child.as_ref().map(|n| n.as_ref()) + } + + pub fn has_dynamic_child(&self) -> bool { + match self.dynamic_child { + Some(_) => true, + None => false, + } + } + + pub fn get_child(&self, segment: &str) -> Option { + self.get_static_child(segment) + .map(|n| TreeNode::Static(&n)) + .or_else(|| self.get_dynamic_child().map(|n| TreeNode::Dynamic(n))) + } + + pub fn get_static_child_mut(&mut self, segment: &str) -> Option<&mut StaticTreeNode> { + self.static_children.get_mut(segment) + } + + pub fn get_dynamic_child_mut(&mut self) -> Option<&mut DynamicTreeNode> { + self.dynamic_child.as_mut().map(|n| n.as_mut()) + } + + pub fn get_child_mut(&mut self, segment: &str) -> Option { + let static_child = self + .static_children + .get_mut(segment) + .map(|c| TreeNodeMut::Static(c)); + + if let Some(static_child) = static_child { + Some(static_child) + } else { + self.dynamic_child.as_mut().map(|n| TreeNodeMut::Dynamic(n)) + } + } +} + +struct TraversePathReturn<'a> { + node: &'a StaticTreeNode, + params: Params, +} + +impl TraversePathReturn<'_> { + pub fn extract_handler(&self) -> Option { + self.node.handler.as_ref().map(|handler| HandlerAndParams { + handler: handler.clone(), + params: serde_wasm_bindgen::to_value(&self.params).unwrap(), + }) + } +} + +fn js_value_to_option(js_value: JsValue) -> Handler { + if js_value.is_undefined() || js_value.is_null() { + None + } else { + Some(js_value) + } +} + +#[wasm_bindgen] +struct HandlerAndParams { + handler: JsValue, + params: JsValue, +} + +#[wasm_bindgen] +impl HandlerAndParams { + #[wasm_bindgen(getter)] + pub fn handler(&self) -> JsValue { + self.handler.clone() + } + + #[wasm_bindgen(getter)] + pub fn params(&self) -> JsValue { + self.params.clone() + } +} + +#[wasm_bindgen] +struct RouterTree { + root: StaticTreeNode, + path_separator: String, + param_prefix: String, + wildcard_symbol: String, +} + +#[wasm_bindgen] +impl RouterTree { + #[wasm_bindgen(constructor)] + pub fn new( + handler: JsValue, + param_prefix: Option, + path_separator: Option, + wildcard_symbol: Option, + ) -> Self { + let root = StaticTreeNode::new(js_value_to_option(handler)); + + RouterTree { + root, + path_separator: path_separator.unwrap_or(PATH_SEPARATOR_DEFAULT.to_string()), + param_prefix: param_prefix.unwrap_or(PARAM_PREFIX_DEFAULT.to_string()), + wildcard_symbol: wildcard_symbol.unwrap_or(WILDCARD_SYMBOL_DEFAULT.to_string()), + } + } + + #[wasm_bindgen] + pub fn add(&mut self, path: String, handler: JsValue) { + let segments = self.parse_path(&path); + let param_prefix = self.param_prefix.as_str(); + let mut current_node = &mut self.root; + + for segment in segments { + current_node = if RouterTree::is_dynamic_segment(segment, param_prefix) { + let param_name = RouterTree::strip_param_prefix(segment, param_prefix); + + if !current_node.has_dynamic_child() { + current_node.set_dynamic_child(param_name, None); + } + &mut current_node.get_dynamic_child_mut().unwrap().node + } else { + if !current_node.has_static_child(segment) { + current_node.add_static_child(segment, None); + } + current_node.get_static_child_mut(segment).unwrap() + } + } + + current_node.handler = js_value_to_option(handler); + } + + #[wasm_bindgen] + pub fn get(&self, path: String) -> Option { + self.traverse_path(&path) + .map(|v| v.extract_handler()) + .flatten() + } + + fn traverse_path(&self, path: &String) -> Option { + let segments = self.parse_path(path); + let mut params: Params = HashMap::new(); + let mut current_node = &self.root; + + for segment in segments { + if current_node.wildcard_handler.is_some() { + break; + } + if let Some(child) = current_node.get_child(segment) { + match child { + TreeNode::Static(node) => current_node = node, + TreeNode::Dynamic(node) => { + params.insert(node.param_name.clone(), segment.to_string()); + current_node = &node.node; + } + } + } else { + return None; + } + } + + return Some(TraversePathReturn { + node: current_node, + params, + }); + } + + fn parse_path<'a>(&self, path: &'a String) -> Vec<&'a str> { + path.trim_start_matches(&self.path_separator) + .split(&self.path_separator) + .collect() + } + + fn get_root_node(&self) -> TreeNode { + TreeNode::Static(&self.root) + } + + fn is_dynamic_segment(segment: &str, param_prefix: &str) -> bool { + segment.starts_with(param_prefix) + } + + fn strip_param_prefix<'a>(segment: &'a str, param_prefix: &str) -> &'a str { + segment.strip_prefix(param_prefix).unwrap_or("") + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn it_works() { + let mut router = RouterTree::new(JsValue::null(), None, None, None); + + router.add("/user/:id".to_string(), JsValue::null()); + + let result = router.get("/user/123".to_string()); + + dbg!("I am here!!!"); + + assert_eq!(1, 1) + } +} diff --git a/server/test.db b/server/test.db new file mode 100644 index 0000000..f52513d Binary files /dev/null and b/server/test.db differ diff --git a/server/views/index.html b/server/views/index.html new file mode 100644 index 0000000..87f29a7 --- /dev/null +++ b/server/views/index.html @@ -0,0 +1,3 @@ +<% layout("./layouts/layout.html") %> + + this is an index.html diff --git a/server/views/layouts/basic.html b/server/views/layouts/basic.html new file mode 100644 index 0000000..57cb5c8 --- /dev/null +++ b/server/views/layouts/basic.html @@ -0,0 +1,17 @@ + + + + + + + Keyborg + + + + + + <%~ it.body %> + + + + diff --git a/server/views/layouts/layout.html b/server/views/layouts/layout.html new file mode 100644 index 0000000..7f9c13d --- /dev/null +++ b/server/views/layouts/layout.html @@ -0,0 +1,7 @@ +<% layout("./basic.html") %> + +

+
+ <%~ it.body %> +
+
diff --git a/server/views/login.html b/server/views/login.html new file mode 100644 index 0000000..ad7bf9b --- /dev/null +++ b/server/views/login.html @@ -0,0 +1,11 @@ +<% layout("./layouts/basic.html") %> + +
+
+

password

+ + +
+
+ + diff --git a/shared/deno.json b/shared/deno.json new file mode 100644 index 0000000..e9e8304 --- /dev/null +++ b/shared/deno.json @@ -0,0 +1,17 @@ +{ + "name": "@keyborg/shared", + "version": "0.1.0", + "exports": "./mod.ts", + "tasks": { + "dev": "deno test --watch mod.ts" + }, + "license": "MIT", + "imports": { + "@std/assert": "jsr:@std/assert@1", + "@std/fmt": "jsr:@std/fmt@^1.0.3" + }, + "compilerOption": { + "declation": true, + "emitDeclarationOnly": true + } +} diff --git a/shared/main.ts b/shared/main.ts new file mode 100644 index 0000000..e69de29 diff --git a/shared/mod.ts b/shared/mod.ts new file mode 100644 index 0000000..8d9b8a2 --- /dev/null +++ b/shared/mod.ts @@ -0,0 +1,3 @@ +export function add(a: number, b: number): number { + return a + b; +} diff --git a/shared/mod_test.ts b/shared/mod_test.ts new file mode 100644 index 0000000..0784d25 --- /dev/null +++ b/shared/mod_test.ts @@ -0,0 +1,6 @@ +import { assertEquals } from "@std/assert"; +import { add } from "./mod.ts"; + +Deno.test(function addTest() { + assertEquals(add(2, 3), 5); +}); diff --git a/shared/utils/api.ts b/shared/utils/api.ts new file mode 100644 index 0000000..6a845a0 --- /dev/null +++ b/shared/utils/api.ts @@ -0,0 +1,11 @@ +interface LoginRequest { + password: string; +} + +interface LoginResponse { + err: "InvalidPassword" | "InvalidInput"; +} + +class Api { + makeRequest(); +} diff --git a/shared/utils/index.ts b/shared/utils/index.ts new file mode 100644 index 0000000..3956bca --- /dev/null +++ b/shared/utils/index.ts @@ -0,0 +1,3 @@ +export * from "@shared/utils/option.ts"; +export * from "@shared/utils/result.ts"; +export * from "@shared/utils/resultasync.ts"; diff --git a/shared/utils/logger.ts b/shared/utils/logger.ts new file mode 100644 index 0000000..1721d17 --- /dev/null +++ b/shared/utils/logger.ts @@ -0,0 +1,18 @@ +import * as logger from "jsr:@std/log"; + +logger.setup({ + handlers: { + console: new logger.ConsoleHandler("DEBUG"), + }, + + loggers: { + default: { + level: "INFO", + handlers: ["console"], + }, + }, +}); + +const log = logger.getLogger("default"); + +export default log; diff --git a/shared/utils/option.ts b/shared/utils/option.ts new file mode 100644 index 0000000..7149c7b --- /dev/null +++ b/shared/utils/option.ts @@ -0,0 +1,391 @@ +import { err, ok } from "@shared/utils/result.ts"; + +interface IOption { + /** + * Checks if the `Option` is a `Some`. + * ```typescript + * // Example + * const a = some(5) + * const b = none + * a.isSome() // true b.isSome() // false + * ``` + * @returns {boolean} `true` if the Option is a Some, otherwise `false`. + */ + isSome(): this is Some; + + ifSome(fn: (value: T) => void): Option; + + /** + * Checks if the `Option` is a `None`. + * ```typescript + * // Example + * const a = some(5); + * const b = none; + * a.isNone(); // false + * b.isNone(); // true + * ``` + * + * @returns {boolean} `true` if the Option is a None, otherwise `false`. + */ + isNone(): this is None; + + ifNone(fn: () => void): Option; + + /** + * Uses the function `fn` to map a value of type `T`, stored inside of the `Option`, to a value of type `U`. Then wraps a mapped value to the new `Option` and returns it. The difference from a `.flatMap()` is that `.flatMap()` does not wraps a mapped value into the `Option`, but rather requires `fn` to take care of it. + * + * ```typescript + * // Example + * const a = some(5); + * const b = a.map((value) => {value + 5}); // => Some(10) + * + * // compare .map() and .flatMap(): + * const mapFn = (value: number) => some(value + 5); + * const c = a.map(mapFn); // => Some(Some(10)) + * const d = a.flatMap(mapFn); // => Some(10) + * ``` + * @template `U` - The type of the result of the mapping. + * @param {Function} `fn` - The function to apply to the value inside the Option. + * @returns {Option} a new `Option` wrapping the mapped value. + */ + map(fn: (value: T) => U): Option; + + /** + * Uses the function `fn` to map a value of type `T`, stored inside of the `Option`, to a value of type `Option` and returns it. The difference from a `.map()` is that `.flatMap()` does not wrap the mapped value `U` into the Option by itself, but rather requires a function `fn` to take care of it. + * ```typescript + * // Example + * const a = some(5); + * const b = a.flatMap((value) => some(value + 5)); // Some(10) + * + * // compare .map() and .flatMap(): + * const mapFn = (value: number) => some(value + 5); + * const c = a.map(mapFn); // Some(Some(10)) + * const d = a.flatMap(mapFn); // Some(10) + * ``` + * @param {Function} The function `fn` that takes the value inside the `Option` and returns a new `Option`. + * @returns {Option} A new `Option` wrapping the result of the flatMap operation. + * + */ + flatMap(fn: (value: T) => Option): Option; + + andThen(fn: (value: T) => Option): Option; + + /** + * **UNSAFE** method for extracting value `T` from an `Option`. + * - If the `Option` is `Some` => returns value `T` + * - If the `Option` is `None` => throws a Error + * + * Should not be used in production + * ```typescript + * // Example + * const a = some(5); + * const b = none; + * const unwrappedA = a.unwrap(); // 5 + * const unwrappedB = a.unwrap(); // Throws error + * ``` + * @returns {T} The value inside the Option. + * @throws {Error} If the Option is a None, an error is thrown. + */ + unwrap(): T; + + /** + * safe method for extracting value `T` from an `Option`. + * - If the `Option` is `Some` => returns value `T` + * - If the `Option` is `None` => returns `defaultValue` + * ```typescript + * // Example + * const a = some(5); + * const b = none; + * const unwrappedA = a.getOrElse(10); // 5 + * const unwrappedB = a.getOrElse(10); // 10 + * ``` + * @param {T} defaultValue The value to return if the Option is a None. + * @returns {T} The value `T` inside the Option if it's a `Some`, otherwise the `defaultValue`. + */ + unwrapOr(defaultValue: U): T | U; + + unwrapOrElse(fn: () => U): T | U; + + or(optb: Option): Option; + + orElse(fn: () => Option): Option; + + /** + * Matches on the `Option` and applies the corresponding function for `Some` or `None`. + * - If the `Option` is `Some` => applies the first function `some` + * - If the Option is None => applies the second function `none` + * ```typescript + * // Example + * const a = some(5); + * const b = none; + * + * const someFn = (value) => value + 3; + * const noneFn = () => 10 + * + * const matchedA = a.match(someFn, noneFn) // 8 + * const matchedB = b.match(someFn, noneFn) // 10 + * ``` + * @param {Function} some The function to apply if the Option is a Some. + * @param {Function} none The function to apply if the Option is a None. + * @returns {unknown} The result of the matching function applied. + */ + match(some: (value: T) => A, none: () => B): A | B; + + toNullable(): T | null; + + toBoolean(): boolean; + + okOrElse(errFn: () => E): Result; +} + +/** + * Represents a `Some` value in the Option type, wrapping a value of type `T`. + * @template `T` The type of the value inside the `Some`. + */ +export class Some implements IOption { + public readonly tag = "Some"; + + /** + * Creates a new `Some` instance. + * @param {T} The value `T` to wrap inside the `Some`. + */ + constructor(public readonly value: T) { + Object.defineProperties(this, { + tag: { + writable: false, + enumerable: false, + }, + }); + } + + isSome(): this is Some { + return true; + } + + ifSome(fn: (value: T) => void): this { + fn(this.value); + return this; + } + + isNone(): this is None { + return false; + } + + ifNone(fn: () => void): Option { + return this; + } + + map(fn: (value: T) => U): Option { + return new Some(fn(this.value)); + } + + flatMap(fn: (value: T) => Option): Option { + return fn(this.value); + } + + andThen(fn: (value: T) => Option): Option { + return fn(this.value); + } + + unwrap(): T { + return this.value; + } + + unwrapOr(defaultValue: U): T | U { + return this.value; + } + + unwrapOrElse(fn: () => U): T | U { + return this.value; + } + + or(optb: Option): Option { + return this; + } + + orElse(fn: () => Option): Option { + return this; + } + + match(some: (value: T) => A, none: () => B): A | B { + return some(this.value); + } + + toJSON() { + return { + //_tag: this._tag, + value: this.value, + }; + } + + toString() { + return `Some(${this.value})`; + } + + toNullable(): T | null { + return this.value; + } + + toBoolean(): boolean { + return true; + } + + okOrElse(errFn: () => E): Result { + return ok(this.value); + } +} + +/** + * Represents a `None` value in the Option type, indicating no value. + * @template `T` The type that would be inside the Option, but it is not used because this is a None. + */ +export class None implements IOption { + public readonly tag = "None"; + + /** + * Creates a new `None` instance. + */ + constructor() { + Object.defineProperties(this, { + tag: { + writable: false, + enumerable: false, + }, + }); + } + + isSome(): this is Some { + return false; + } + + ifSome(fn: (value: T) => void): Option { + return this; + } + + isNone(): this is None { + return true; + } + + ifNone(fn: () => void): Option { + fn(); + return this; + } + + map(fn: (value: T) => U): Option { + return new None(); + } + + andThen(fn: (value: T) => Option): Option { + return none; + } + + flatMap(fn: (value: T) => Option): Option { + return new None(); + } + + unwrap(): T { + throw new Error("Tried to unwrap a non-existent value"); + } + + unwrapOr(defaultValue: U): T | U { + return defaultValue; + } + + unwrapOrElse(fn: () => U): T | U { + return fn(); + } + + or(optb: Option): Option { + return optb; + } + + orElse(fn: () => Option): Option { + return fn(); + } + + match(some: (value: T) => A, none: () => B): A | B { + return none(); + } + + toJSON() { + return { + _tag: this._tag, + }; + } + + toString() { + return `None`; + } + + toNullable(): T | null { + return null; + } + + toBoolean(): boolean { + return false; + } + + okOrElse(errFn: () => E): Result { + return err(errFn()); + } +} + +export type Option = Some | None; + +/** + * Creates a new `Some` instance wrapping a `value`. + * This function is a convenience method for creating `Some` values. + * ```typescript + * // Example + * const a = some(5); // Some(5) + * const b = some("foo") // Some("foo") + * const c = none + * + * console.log(a) // Some { _tag: "Some", value: 5 } + * console.log(b) // Some { _tag: "Some", value: "foo" } + * console.log(c) // None { _tag: "None" } + * + * // Accessing the value stored inside: + * const valueA = a.unwrap(); // 5 | unsafe method + * const valueB = b.getOrElse("bar"); // "foo" | safe method + * const valueC = c.getOrElse("bar"); // "bar" | safe method + * + * console.log(valueA) // 5 + * console.log(valueB) // "foo" + * console.log(valueC) // "bar" + * + * const unsafe = c.unwrap() // throws Error + * ``` + * @template `T` The type of the value being wrapped in the `Some`. + * @param {T} `value` The value to wrap inside the `Some`. + * @returns {Option} A new `Some` instance wrapping the provided value. + */ +export function some(value: T): Option; +export function some(value: void): Option; +export function some(value: T): Option { + return new Some(value); +} + +/** + * A singleton representing a `None` instance. + * This is used to represent the absence of a value and is often used as the default value for Option types. + * ```typescript + * // Example + * const a = some(5); + * const b = none; + * + * const valueA = a.unwrap() // 5 + * const valueB = b.unwrap() // throws a error + * + * const valueA = a.getOrElse(10) // 5 + * const valueB = a.getOrElse(10) // 10 + * ``` + */ +export const none = new None(); + +export function fromNullableVal(value: T): Option> { + if (!value) { + return none; + } + return some(value); +} diff --git a/shared/utils/result.ts b/shared/utils/result.ts new file mode 100644 index 0000000..d4bf14e --- /dev/null +++ b/shared/utils/result.ts @@ -0,0 +1,339 @@ +import { some } from "@shared/utils/option.ts"; +import { None, type Option, Some } from "@shared/utils/option.ts"; +import { errAsync, okAsync, ResultAsync } from "@shared/utils/resultasync.ts"; + +//#region Ok, Err and Result +interface IResult { + isOk(): this is Ok; + ifOk(fn: (value: T) => void): Result; + isErr(): this is Err; + ifErr(fn: (err: E) => void): Result; + isErrOrNone(): this is Err, E>; + unwrap(): T; + unwrapOr(defaultValue: U): T | U; + unwrapOrElse(fn: () => U): T | U; + match(ok: (value: T) => A, err: (error: E) => B): A | B; + map(fn: (value: T) => U): Result; + mapErr(fn: (err: E) => U): Result; + andThen(fn: (value: T) => Result): Result; + flatten(): FlattenResult>; + flattenOption(errFn: () => U): Result, U | E>; + flattenOptionOr>( + defaultValue: D, + ): Result | D, E>; + mapOption(fn: (value: UnwrapOption) => U): Result, E>; + matchOption( + some: (value: UnwrapOption) => A, + none: () => B, + ): Result; + toNullable(): T | null; + toAsync(): ResultAsync; + void(): Result; +} + +export class Ok implements IResult { + public readonly tag = "Ok"; + + constructor(public readonly value: T) { + this.value = value; + + Object.defineProperties(this, { + tag: { + writable: false, + enumerable: false, + }, + }); + } + + isErr(): this is Err { + return false; + } + + ifErr(fn: (err: E) => void): Result { + return this; + } + + isErrOrNone(): this is Err, E> { + if (this.value instanceof None) { + return true; + } + return false; + } + + isOk(): this is Ok { + return true; + } + + ifOk(fn: (value: T) => void): Result { + fn(this.value); + return this; + } + + unwrap(): T { + return this.value; + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + unwrapOr(defaultValue: U): T { + return this.value; + } + + unwrapOrElse(fn: () => U): T | U { + return this.value; + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + match(ok: (value: T) => A, err: (error: E) => B): A | B { + return ok(this.value); + } + + map(fn: (value: T) => U): Result { + const mappedValue = fn(this.value); + + return new Ok(mappedValue); + } + + mapOption(fn: (value: UnwrapOption) => U): Result, E> { + if (this.value instanceof None || this.value instanceof Some) { + return ok(this.value.map(fn)); + } + return ok(some(fn(this.value as UnwrapOption))); + } + + andThen(fn: (value: T) => Result): Result { + return fn(this.value) as Result; + } + + mapErr(fn: (err: E) => U): Result { + return new Ok(this.value); + } + + flatten(): FlattenResult> { + return flattenResult(this); + } + + flattenOption(errFn: () => U): Result, E | U> { + if (this.value instanceof None || this.value instanceof Some) { + return this.value.okOrElse(errFn); + } + return new Ok, E | U>(this.value as UnwrapOption); + } + + flattenOptionOr>( + defaultValue: D, + ): Result | D, E> { + if (this.value instanceof None || this.value instanceof Some) { + return this.value.unwrapOr(defaultValue); + } + return new Ok | D, E>(this.value as UnwrapOption); + } + + matchOption( + some: (value: UnwrapOption) => A, + none: () => B, + ): Result { + if (this.value instanceof None || this.value instanceof Some) { + return ok(this.value.match(some, none)); + } + return ok(some(this.value as UnwrapOption)); + } + + toNullable(): T | null { + return this.value; + } + + toAsync(): ResultAsync { + return okAsync(this.value); + } + + void(): Result { + return ok(); + } +} + +export class Err implements IResult { + public readonly tag = "Err"; + + constructor(public readonly error: E) { + this.error = error; + + Object.defineProperties(this, { + tag: { + writable: false, + configurable: false, + enumerable: false, + }, + }); + } + isErr(): this is Err { + return true; + } + ifErr(fn: (err: E) => void): Result { + fn(this.error); + return this; + } + isOk(): this is Ok { + return false; + } + ifOk(fn: (value: T) => void): Result { + return this; + } + isErrOrNone(): this is Err, E> { + return true; + } + unwrap(): T { + const message = `Tried to unwrap error: ${ + getMessageFromError(this.error) + }`; + throw new Error(message); + } + unwrapOr(defaultValue: U): U { + return defaultValue; + } + unwrapOrElse(fn: () => U): T | U { + return fn(); + } + match(ok: (value: T) => A, err: (error: E) => B): A | B { + return err(this.error); + } + map(fn: (value: T) => U): Result { + return new Err(this.error); + } + mapErr(fn: (err: E) => U): Result { + const mappedError = fn(this.error); + return new Err(mappedError); + } + mapOption(fn: (value: UnwrapOption) => U): Result, E> { + return err(this.error); + } + andThen(fn: (value: T) => Result): Result { + return new Err(this.error); + } + flatten(): FlattenResult> { + return flattenResult(this); + } + flattenOption(errFn: () => U): Result, E | U> { + return new Err, E | U>(this.error); + } + flattenOptionOr>( + defaultValue: D, + ): Result, E> { + return new Err | D, E>(this.error); + } + + matchOption( + some: (value: UnwrapOption) => A, + none: () => B, + ): Result { + return err(this.error); + } + toNullable(): T | null { + return null; + } + toAsync(): ResultAsync { + return errAsync(this.error); + } + void(): Result { + return err(this.error); + } +} + +export type Result = Ok | Err; +//#endregion + +//#region Ok and Err factory functions +export function ok(val: T): Ok; +export function ok(val: void): Ok; +export function ok(val: T): Ok { + return new Ok(val) as Ok; +} + +export function err(err: E): Err; +export function err(err: E): Err; +export function err(err: void): Err; +export function err(err: E): Err { + return new Err(err) as Err; +} + +//#endregion + +export function fromThrowable any, E>( + fn: Fn, + errorMapper?: (e: unknown) => E, +): (...args: Parameters) => Result, E> { + return (...args) => { + try { + const result = fn(...args); + return ok(result); + } catch (e) { + return err(errorMapper ? errorMapper(e) : e); + } + }; +} + +/** + * utility function to get an error message from an thrown unknown type + */ +export function getMessageFromError(e: unknown): string { + if (e instanceof Error) { + if (e.message) { + return e.message; + } + + if ("code" in e && typeof e.code === "string") { + return e.code; + } + + return "An unknown error occurred"; + } + + if (typeof e === "string") { + return e; + } + + if (typeof e === "object" && e !== null && "message" in e) { + // If e is an object with a message property (could be a custom error-like object) + const obj = e as { message: unknown }; + return typeof obj.message === "string" + ? obj.message + : String(obj.message); + } + + return "An unknown error occurred"; +} + +export function flattenResult>( + nestedResult: R, +): FlattenResult { + let currentResult = nestedResult; + + while (currentResult instanceof Ok) { + currentResult = currentResult.value; + } + + return currentResult as FlattenResult; +} + +export function ResultFromJSON( + str: string, +): Result { + const result: { value: T } | { error: E } = JSON.parse(str); + + if (obj.value) { + return ok(obj.value); + } + + if (obj.error) { + return err(obj.error); + } +} + +export type UnwrapOption = T extends Option ? V : T; + +export type FlattenResult = R extends Result + ? T extends Result + ? FlattenResult extends Result + ? Result + : never + : R + : never; diff --git a/shared/utils/resultasync.ts b/shared/utils/resultasync.ts new file mode 100644 index 0000000..77fed31 --- /dev/null +++ b/shared/utils/resultasync.ts @@ -0,0 +1,237 @@ +import { + Err, + type UnwrapOption, + Ok, + type Result, + FlattenResult, +} from "@shared/utils/result.ts"; +import { none, None, Option, some, Some } from "@shared/utils/option.ts"; + +export class ResultAsync implements PromiseLike> { + constructor(private readonly _promise: Promise>) { + this._promise = _promise; + } + + static fromPromise( + promise: Promise, + errorMapper: (error: unknown) => E, + ) { + const promiseOfResult: Promise> = promise + .then((value: T): Result => { + return new Ok(value); + }) + .catch((error: unknown): Result => { + return new Err(errorMapper(error)); + }); + + return new ResultAsync(promiseOfResult); + } + + static fromSafePromise(promise: Promise) { + const promiseOfResult: Promise> = promise.then( + (value: T): Result => { + return new Ok(value); + }, + ); + + return new ResultAsync(promiseOfResult); + } + + static fromThrowable any, E>( + fn: Fn, + errorMapper?: (e: unknown) => E, + ): (...args: Parameters) => ResultAsync, E> { + return (...args: Parameters): ResultAsync, E> => { + try { + return okAsync(fn(args)); + } catch (e) { + return errAsync(errorMapper ? errorMapper(e) : e); + } + }; + } + + async unwrap(): Promise { + const result = await this._promise; + + if (result.isErr()) { + throw result.error; + } + + return result.value; + } + + async match( + ok: (value: T) => A, + err: (err: E) => B, + ): Promise { + const result = await this._promise; + + if (result.isErr()) { + return err(result.error); + } + return ok(result.value); + } + + map(fn: (value: T) => U): ResultAsync { + return new ResultAsync( + this._promise.then((result: Result): Result => { + if (result.isErr()) { + return new Err(result.error); + } + + return new Ok(fn(result.value)); + }), + ); + } + + mapAsync(fn: (value: T) => Promise): ResultAsync { + return new ResultAsync( + this._promise.then( + async (result: Result): Promise> => { + if (result.isErr()) { + return errAsync(result.error); + } + + return new Ok(await fn(result.value)); + }, + ), + ); + } + + mapErr(fn: (err: E) => U): ResultAsync { + return new ResultAsync( + this._promise.then((result: Result): Result => { + if (result.isErr()) { + return new Err(fn(result.error)); + } + + return new Ok(result.value); + }), + ); + } + + mapErrAsync(fn: (value: T) => Promise): ResultAsync { + return new ResultAsync( + this._promise.then( + async (result: Result): Promise> => { + if (result.isErr()) { + return errAsync(await fn(result.error)); + } + + return new Ok(result.value); + }, + ), + ); + } + + andThen(fn: (value: T) => ResultAsync): ResultAsync { + return new ResultAsync( + this._promise.then( + (result: Result): ResultAsync => { + if (result.isErr()) { + return errAsync(result.error); + } + + return fn(result.value) as ResultAsync; + }, + ), + ); + } + + nullableToOption(): ResultAsync>, E> { + return this.map((v) => (v ? some(v) : none)); + } + + flatten(): FlattenResultAsync> { + return new ResultAsync( + this._promise.then( + (result: Result): FlattenResult> => { + return result.flatten(); + }, + ), + ) as FlattenResultAsync>; + } + + flattenOption( + errFn: () => U, + ): ResultAsync, E | U> { + return new ResultAsync( + this._promise.then( + (result: Result): Result, E | U> => { + return result.flattenOption(errFn); + }, + ), + ); + } + + flattenOptionOrDefault>( + defaultValue: D, + ): ResultAsync | D, E> { + return new ResultAsync( + this._promise.then( + (result: Result): Result | D, E> => { + return result.flattenOptionOrDefault(defaultValue); + }, + ), + ); + } + + matchOption( + some: (value: UnwrapOption) => A, + none: () => B, + ): ResultAsync { + return new ResultAsync( + this._promise.then((result: Result): Result => { + return result.matchOption(some, none); + }), + ); + } + + matchOptionAndFlatten( + some: (value: UnwrapOption) => Result, + none: () => Result, + ): ResultAsync { + return new ResultAsync( + this._promise.then( + (result: Result): Result => { + return result.matchOptionAndFlatten(some, none); + }, + ), + ); + } + + then( + onFulfilled?: (res: Result) => A | PromiseLike, + onRejected?: (reason: unknown) => B | PromiseLike, + ): PromiseLike { + return this._promise.then(onFulfilled, onRejected); + } +} + +export function okAsync(value: T): ResultAsync; +export function okAsync( + value: void, +): ResultAsync; +export function okAsync(value: T): ResultAsync { + return new ResultAsync(Promise.resolve(new Ok(value))); +} + +export function errAsync( + err: E, +): ResultAsync; +export function errAsync(err: E): ResultAsync; +export function errAsync( + err: void, +): ResultAsync; +export function errAsync(err: E): ResultAsync { + return new ResultAsync(Promise.resolve(new Err(err))); +} + +export type FlattenResultAsync = + R extends ResultAsync + ? T extends ResultAsync + ? FlattenResultAsync extends ResultAsync + ? ResultAsync + : never + : R + : never; diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..8c4871f --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,28 @@ +// tsconfig.json +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "declaration": true, // Enable declaration file generation + "emitDeclarationOnly": false, // Emit both JS and declaration files + "outDir": "./server/public/bundle/types", // Directory for .d.ts files + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "moduleResolution": "node", + "baseUrl": ".", + "paths": { + "*": [ + "./*" + ] + } + }, + "include": [ + "./shared/**/*" + ], + "exclude": [ + "node_modules", + "dist" + ] +}