working on bundling
136
.gitignore
vendored
Normal file
@ -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.*
|
||||||
7
client/.gitignore
vendored
Normal file
@ -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
|
||||||
25
client/Cargo.toml
Normal file
@ -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"
|
||||||
|
|
||||||
3
client/build.rs
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
fn main() {
|
||||||
|
tauri_build::build()
|
||||||
|
}
|
||||||
10
client/capabilities/default.json
Normal file
@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
BIN
client/icons/128x128.png
Normal file
|
After Width: | Height: | Size: 3.4 KiB |
BIN
client/icons/128x128@2x.png
Normal file
|
After Width: | Height: | Size: 6.8 KiB |
BIN
client/icons/32x32.png
Normal file
|
After Width: | Height: | Size: 974 B |
BIN
client/icons/Square107x107Logo.png
Normal file
|
After Width: | Height: | Size: 2.8 KiB |
BIN
client/icons/Square142x142Logo.png
Normal file
|
After Width: | Height: | Size: 3.8 KiB |
BIN
client/icons/Square150x150Logo.png
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
BIN
client/icons/Square284x284Logo.png
Normal file
|
After Width: | Height: | Size: 7.6 KiB |
BIN
client/icons/Square30x30Logo.png
Normal file
|
After Width: | Height: | Size: 903 B |
BIN
client/icons/Square310x310Logo.png
Normal file
|
After Width: | Height: | Size: 8.4 KiB |
BIN
client/icons/Square44x44Logo.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
client/icons/Square71x71Logo.png
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
BIN
client/icons/Square89x89Logo.png
Normal file
|
After Width: | Height: | Size: 2.4 KiB |
BIN
client/icons/StoreLogo.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
client/icons/icon.icns
Normal file
BIN
client/icons/icon.ico
Normal file
|
After Width: | Height: | Size: 85 KiB |
BIN
client/icons/icon.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
43
client/index.html
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="stylesheet" href="/src/styles.css" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Tauri App</title>
|
||||||
|
<script type="module" src="/src/main.ts" defer></script>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<main class="container">
|
||||||
|
<h1>Welcome to Tauri</h1>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<a href="https://vitejs.dev" target="_blank">
|
||||||
|
<img src="/src/assets/vite.svg" class="logo vite" alt="Vite logo" />
|
||||||
|
</a>
|
||||||
|
<a href="https://tauri.app" target="_blank">
|
||||||
|
<img
|
||||||
|
src="/src/assets/tauri.svg"
|
||||||
|
class="logo tauri"
|
||||||
|
alt="Tauri logo"
|
||||||
|
/>
|
||||||
|
</a>
|
||||||
|
<a href="https://www.typescriptlang.org/docs" target="_blank">
|
||||||
|
<img
|
||||||
|
src="/src/assets/typescript.svg"
|
||||||
|
class="logo typescript"
|
||||||
|
alt="typescript logo"
|
||||||
|
/>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<p>Click on the Tauri logo to learn more about the framework</p>
|
||||||
|
|
||||||
|
<form class="row" id="greet-form">
|
||||||
|
<input id="greet-input" placeholder="Enter a name..." />
|
||||||
|
<button type="submit">Greet</button>
|
||||||
|
</form>
|
||||||
|
<p id="greet-msg"></p>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
21
client/package.json
Normal file
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
6
client/src/assets/tauri.svg
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
<svg width="206" height="231" viewBox="0 0 206 231" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M143.143 84C143.143 96.1503 133.293 106 121.143 106C108.992 106 99.1426 96.1503 99.1426 84C99.1426 71.8497 108.992 62 121.143 62C133.293 62 143.143 71.8497 143.143 84Z" fill="#FFC131"/>
|
||||||
|
<ellipse cx="84.1426" cy="147" rx="22" ry="22" transform="rotate(180 84.1426 147)" fill="#24C8DB"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M166.738 154.548C157.86 160.286 148.023 164.269 137.757 166.341C139.858 160.282 141 153.774 141 147C141 144.543 140.85 142.121 140.558 139.743C144.975 138.204 149.215 136.139 153.183 133.575C162.73 127.404 170.292 118.608 174.961 108.244C179.63 97.8797 181.207 86.3876 179.502 75.1487C177.798 63.9098 172.884 53.4021 165.352 44.8883C157.82 36.3744 147.99 30.2165 137.042 27.1546C126.095 24.0926 114.496 24.2568 103.64 27.6274C92.7839 30.998 83.1319 37.4317 75.8437 46.1553C74.9102 47.2727 74.0206 48.4216 73.176 49.5993C61.9292 50.8488 51.0363 54.0318 40.9629 58.9556C44.2417 48.4586 49.5653 38.6591 56.679 30.1442C67.0505 17.7298 80.7861 8.57426 96.2354 3.77762C111.685 -1.01901 128.19 -1.25267 143.769 3.10474C159.348 7.46215 173.337 16.2252 184.056 28.3411C194.775 40.457 201.767 55.4101 204.193 71.404C206.619 87.3978 204.374 103.752 197.73 118.501C191.086 133.25 180.324 145.767 166.738 154.548ZM41.9631 74.275L62.5557 76.8042C63.0459 72.813 63.9401 68.9018 65.2138 65.1274C57.0465 67.0016 49.2088 70.087 41.9631 74.275Z" fill="#FFC131"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M38.4045 76.4519C47.3493 70.6709 57.2677 66.6712 67.6171 64.6132C65.2774 70.9669 64 77.8343 64 85.0001C64 87.1434 64.1143 89.26 64.3371 91.3442C60.0093 92.8732 55.8533 94.9092 51.9599 97.4256C42.4128 103.596 34.8505 112.392 30.1816 122.756C25.5126 133.12 23.9357 144.612 25.6403 155.851C27.3449 167.09 32.2584 177.598 39.7906 186.112C47.3227 194.626 57.153 200.784 68.1003 203.846C79.0476 206.907 90.6462 206.743 101.502 203.373C112.359 200.002 122.011 193.568 129.299 184.845C130.237 183.722 131.131 182.567 131.979 181.383C143.235 180.114 154.132 176.91 164.205 171.962C160.929 182.49 155.596 192.319 148.464 200.856C138.092 213.27 124.357 222.426 108.907 227.222C93.458 232.019 76.9524 232.253 61.3736 227.895C45.7948 223.538 31.8055 214.775 21.0867 202.659C10.3679 190.543 3.37557 175.59 0.949823 159.596C-1.47592 143.602 0.768139 127.248 7.41237 112.499C14.0566 97.7497 24.8183 85.2327 38.4045 76.4519ZM163.062 156.711L163.062 156.711C162.954 156.773 162.846 156.835 162.738 156.897C162.846 156.835 162.954 156.773 163.062 156.711Z" fill="#24C8DB"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.5 KiB |
25
client/src/assets/typescript.svg
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
<?xml version="1.0" standalone="no"?>
|
||||||
|
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
|
||||||
|
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
|
||||||
|
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="512.000000pt" height="512.000000pt" viewBox="0 0 512.000000 512.000000"
|
||||||
|
preserveAspectRatio="xMidYMid meet">
|
||||||
|
|
||||||
|
<g transform="translate(0.000000,512.000000) scale(0.100000,-0.100000)"
|
||||||
|
fill="#2D79C7" stroke="none">
|
||||||
|
<path d="M430 5109 c-130 -19 -248 -88 -325 -191 -53 -71 -83 -147 -96 -247
|
||||||
|
-6 -49 -9 -813 -7 -2166 l3 -2090 22 -65 c54 -159 170 -273 328 -323 l70 -22
|
||||||
|
2140 0 2140 0 66 23 c160 55 272 169 322 327 l22 70 0 2135 0 2135 -22 70
|
||||||
|
c-49 157 -155 265 -319 327 l-59 23 -2115 1 c-1163 1 -2140 -2 -2170 -7z
|
||||||
|
m3931 -2383 c48 -9 120 -26 160 -39 l74 -23 3 -237 c1 -130 0 -237 -2 -237 -3
|
||||||
|
0 -26 14 -53 30 -61 38 -197 84 -310 106 -110 20 -293 15 -368 -12 -111 -39
|
||||||
|
-175 -110 -175 -193 0 -110 97 -197 335 -300 140 -61 309 -146 375 -189 30
|
||||||
|
-20 87 -68 126 -107 119 -117 164 -234 164 -426 0 -310 -145 -518 -430 -613
|
||||||
|
-131 -43 -248 -59 -445 -60 -243 -1 -405 24 -577 90 l-68 26 0 242 c0 175 -3
|
||||||
|
245 -12 254 -9 9 -9 12 0 12 7 0 12 -4 12 -9 0 -17 139 -102 223 -138 136 -57
|
||||||
|
233 -77 382 -76 145 0 224 19 295 68 75 52 100 156 59 242 -41 84 -135 148
|
||||||
|
-374 253 -367 161 -522 300 -581 520 -23 86 -23 253 -1 337 73 275 312 448
|
||||||
|
682 492 109 13 401 6 506 -13z m-1391 -241 l0 -205 -320 0 -320 0 0 -915 0
|
||||||
|
-915 -255 0 -255 0 0 915 0 915 -320 0 -320 0 0 205 0 205 895 0 895 0 0 -205z"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.4 KiB |
1
client/src/assets/vite.svg
Normal file
@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
14
client/src/lib.rs
Normal file
@ -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");
|
||||||
|
}
|
||||||
6
client/src/main.rs
Normal file
@ -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()
|
||||||
|
}
|
||||||
22
client/src/main.ts
Normal file
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
116
client/src/styles.css
Normal file
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
36
client/tauri.conf.json
Normal file
@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
32
client/tsconfig.json
Normal file
@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
35
client/vite.config.ts
Normal file
@ -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"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
27
deno.json
Normal file
@ -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"
|
||||||
|
}
|
||||||
13
docker-compose.yaml
Normal file
@ -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"
|
||||||
8
main.ts
Normal file
@ -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));
|
||||||
|
}
|
||||||
6
main_test.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
import { assertEquals } from "@std/assert";
|
||||||
|
import { add } from "./main.ts";
|
||||||
|
|
||||||
|
Deno.test(function addTest() {
|
||||||
|
assertEquals(add(2, 3), 5);
|
||||||
|
});
|
||||||
18
server/autoBundler.ts
Normal file
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
76
server/bundler.ts
Normal file
@ -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;
|
||||||
20
server/deno.json
Normal file
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
48
server/main.ts
Normal file
@ -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;
|
||||||
1
server/public/js/login.js
Normal file
@ -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)});
|
||||||
1
server/public/js/shared.bundle.js
Normal file
1
server/public/js/test.js
Normal file
@ -0,0 +1 @@
|
|||||||
|
import{none as o}from"./shared.bundle.js";console.log(o);
|
||||||
219
server/src/admin.ts
Normal file
@ -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<AdminRaw, []>(
|
||||||
|
"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<Option<string>, QueryExecutionError> {
|
||||||
|
if (this.passwordHash) {
|
||||||
|
return ok(some(this.passwordHash));
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.statements
|
||||||
|
.fetchPasswordHash()
|
||||||
|
.mapOption(({ passwordHash }) => {
|
||||||
|
this.passwordHash = passwordHash;
|
||||||
|
return passwordHash;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public isPasswordSet(): Result<boolean, QueryExecutionError> {
|
||||||
|
return this.getPasswordHash().map((opt) => opt.toBoolean());
|
||||||
|
}
|
||||||
|
|
||||||
|
public verifyPassword(
|
||||||
|
password: string,
|
||||||
|
): ResultAsync<boolean, QueryExecutionError | AdminPasswordNotSetError> {
|
||||||
|
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<void, QueryExecutionError> {
|
||||||
|
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<string, Token> = 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<AdminSessionRaw, [token: string]>(
|
||||||
|
"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<string, QueryExecutionError> {
|
||||||
|
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<boolean, QueryExecutionError> {
|
||||||
|
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<Option<Token>, QueryExecutionError> {
|
||||||
|
return this.statements
|
||||||
|
.fetchSessionByToken(token)
|
||||||
|
.mapOption(({ id, expiresAt }) => {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
expiresAt: new Date(expiresAt),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public deleteSessionById(id: number): Result<number, QueryExecutionError> {
|
||||||
|
return this.statements.deleteSessionById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public deleteSessionByToken(
|
||||||
|
token: string,
|
||||||
|
): Result<number, QueryExecutionError> {
|
||||||
|
return this.statements.deleteSessionByToken(token);
|
||||||
|
}
|
||||||
|
|
||||||
|
public deleteAllSessions(): Result<number, QueryExecutionError> {
|
||||||
|
return this.statements.deleteAllSessions();
|
||||||
|
}
|
||||||
|
|
||||||
|
public clearExpiredSessions(): Result<number, QueryExecutionError> {
|
||||||
|
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;
|
||||||
10
server/src/apiValidator.ts
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import { Result } from "@shared/utils/result.ts";
|
||||||
|
|
||||||
|
class Api<Req extends object, Res extends object> {
|
||||||
|
client = {
|
||||||
|
validate(res: Response): Result<Req, any>,
|
||||||
|
};
|
||||||
|
server = {
|
||||||
|
validate(req: Request): Result<Res, any>,
|
||||||
|
};
|
||||||
|
}
|
||||||
34
server/src/client_js/login.ts
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
/// <reference lib="dom" />
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
1
server/src/client_js/shared.bundle.ts
Symbolic link
@ -0,0 +1 @@
|
|||||||
|
../../../shared/utils/index.ts
|
||||||
3
server/src/client_js/test.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
import { none, some } from "./shared.bundle.ts";
|
||||||
|
|
||||||
|
console.log(none);
|
||||||
202
server/src/context.ts
Normal file
@ -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<S extends string = string> {
|
||||||
|
private _url?: URL;
|
||||||
|
private _hostname?: string;
|
||||||
|
private _port?: number;
|
||||||
|
private _cookies?: Record<string, string>;
|
||||||
|
private _responseHeaders: Headers = new Headers();
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
public readonly req: Request,
|
||||||
|
public readonly info: Deno.ServeHandlerInfo<Deno.Addr>,
|
||||||
|
public readonly params: Params<ExtractRouteParams<S>>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
get url(): URL {
|
||||||
|
return this._url ?? (this._url = new URL(this.req.url));
|
||||||
|
}
|
||||||
|
|
||||||
|
get path(): ExtractPath<S> {
|
||||||
|
return this.url.pathname as ExtractPath<S>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<string> {
|
||||||
|
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<number> {
|
||||||
|
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<string> {
|
||||||
|
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<S extends string>(
|
||||||
|
ctx: Context<string>,
|
||||||
|
params: Params<ExtractRouteParams<S>>,
|
||||||
|
): Context<S> {
|
||||||
|
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 string> = S extends
|
||||||
|
`${infer _Start}:${infer Param}/${infer Rest}` ? string
|
||||||
|
: S extends `${infer _Start}/:${infer Param}` ? string
|
||||||
|
: S extends `${infer _Start}*` ? string
|
||||||
|
: S;
|
||||||
36
server/src/db.bkp/db.ts
Normal file
@ -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<T>(sql: string, values?: (string | number)[]) {
|
||||||
|
return ResultAsync.fromPromise(this.pool.query<T>(sql, values), (e) => {
|
||||||
|
const errorMessage = getMessageFromError(e);
|
||||||
|
return new QueryExecutionError(errorMessage);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public getAll<T>(sql: string, values?: (string | number)[]) {
|
||||||
|
return this.query<T[]>(sql, values).nullableToOption();
|
||||||
|
}
|
||||||
|
|
||||||
|
public getFirst<T>(sql: string, values?: (string | number)[]) {
|
||||||
|
return this.query<T[]>(sql, values).map((v) =>
|
||||||
|
v[0] ? some(v[0]) : none,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public insert(sql: string, values?: (string | number)[]) {
|
||||||
|
return this.query<OkPacket>(sql, values);
|
||||||
|
}
|
||||||
|
|
||||||
|
public update(sql: string, values?: (string | number)[]) {
|
||||||
|
return this.query<OkPacket>(sql, values);
|
||||||
|
}
|
||||||
|
}
|
||||||
39
server/src/db.bkp/generateTypes.ts
Normal file
@ -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,
|
||||||
|
});
|
||||||
90
server/src/db.bkp/index.ts
Normal file
@ -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;
|
||||||
72
server/src/db.bkp/mariadbCon.ts
Normal file
@ -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<mariadb.Pool> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
222
server/src/db.bkp/migrations.ts
Normal file
@ -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<T>(
|
||||||
|
query: string,
|
||||||
|
params?: string[],
|
||||||
|
): Promise<T[]> {
|
||||||
|
try {
|
||||||
|
const rows = await this.pool.query<T[]>(query, params);
|
||||||
|
return rows;
|
||||||
|
} catch (e) {
|
||||||
|
const errMsg = getMessageFromError(e);
|
||||||
|
log.critical(`Database query failed: ${errMsg}`);
|
||||||
|
Deno.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async doesMigrationTableExist(): Promise<boolean> {
|
||||||
|
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<Exists>(query, params);
|
||||||
|
return result.table_exists === 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
async hasOtherTables(): Promise<boolean> {
|
||||||
|
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<Count>(query, params);
|
||||||
|
return result.count > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getAppliedMigrations(): Promise<
|
||||||
|
Option<MigrationTableEntry[]>
|
||||||
|
> {
|
||||||
|
const query = `SELECT * FROM ${MIGRATION_TABLE} ORDER BY step ASC;`;
|
||||||
|
const migrations = await this.executeOrExit<MigrationTableEntry>(query);
|
||||||
|
|
||||||
|
return migrations ? none : some(migrations);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getLastAppliedMigration(): Promise<
|
||||||
|
Option<MigrationTableEntry>
|
||||||
|
> {
|
||||||
|
const query = `
|
||||||
|
SELECT * FROM ${MIGRATION_TABLE}
|
||||||
|
ORDER BY step DESC
|
||||||
|
LIMIT 1;
|
||||||
|
`;
|
||||||
|
const [migration] =
|
||||||
|
await this.executeOrExit<MigrationTableEntry>(query);
|
||||||
|
return migration ? some(migration) : none;
|
||||||
|
}
|
||||||
|
|
||||||
|
async areMigrationsAppliedInOrder(): Promise<boolean> {
|
||||||
|
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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
48
server/src/db.bkp/types/types.ts
Normal file
@ -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;
|
||||||
|
};
|
||||||
80
server/src/db/dbWrapper.ts
Normal file
@ -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<T>(fn: () => T): Result<T, QueryExecutionError> {
|
||||||
|
try {
|
||||||
|
return ok(fn());
|
||||||
|
} catch (e) {
|
||||||
|
const message = getMessageFromError(e);
|
||||||
|
return err(new QueryExecutionError(message));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
exec(
|
||||||
|
sql: string,
|
||||||
|
...params: RestBindParameters
|
||||||
|
): Result<number, QueryExecutionError> {
|
||||||
|
return this.safeExecute(() => this.db.exec(sql, params));
|
||||||
|
}
|
||||||
|
|
||||||
|
first<T extends object>(
|
||||||
|
sql: string,
|
||||||
|
...params: RestBindParameters
|
||||||
|
): Result<Option<T>, QueryExecutionError> {
|
||||||
|
return this.safeExecute(() =>
|
||||||
|
fromNullableVal(this.db.prepare(sql).get<T>(params)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
all<T extends object>(
|
||||||
|
sql: string,
|
||||||
|
...params: RestBindParameters
|
||||||
|
): Result<Option<T[]>, QueryExecutionError> {
|
||||||
|
return this.safeExecute(() => this.db.prepare(sql).all<T>(params)).map(
|
||||||
|
(results) => (results.length > 0 ? some(results) : none),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
prepareFetch<
|
||||||
|
T extends object,
|
||||||
|
P extends RestBindParameters = RestBindParameters,
|
||||||
|
>(sql: string): PreparedStatement<T> {
|
||||||
|
const stmt = this.db.prepare(sql);
|
||||||
|
|
||||||
|
const get = (
|
||||||
|
...params: P
|
||||||
|
): Result<Option<NonNullable<T>>, QueryExecutionError> =>
|
||||||
|
this.safeExecute(() => fromNullableVal(stmt.get<T>(params)));
|
||||||
|
|
||||||
|
const all = (
|
||||||
|
...params: P
|
||||||
|
): Result<Option<NonNullable<T[]>>, QueryExecutionError> =>
|
||||||
|
this.safeExecute(() => stmt.all<T>(params)).map((result) =>
|
||||||
|
result.length > 0 ? some(result) : none,
|
||||||
|
);
|
||||||
|
|
||||||
|
return { get, all };
|
||||||
|
}
|
||||||
|
|
||||||
|
prepareExec<P extends RestBindParameters = RestBindParameters>(
|
||||||
|
sql: string,
|
||||||
|
): (...params: P) => Result<number, QueryExecutionError> {
|
||||||
|
const stmt = this.db.prepare(sql);
|
||||||
|
|
||||||
|
return (...params: P): Result<number, QueryExecutionError> => {
|
||||||
|
return this.safeExecute(() => stmt.run(params));
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PreparedStatement<T extends object> {
|
||||||
|
get(...params: RestBindParameters): Result<Option<T>, QueryExecutionError>;
|
||||||
|
all(
|
||||||
|
...params: RestBindParameters
|
||||||
|
): Result<Option<T[]>, QueryExecutionError>;
|
||||||
|
}
|
||||||
127
server/src/db/index.ts
Normal file
@ -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;
|
||||||
213
server/src/db/migrations.ts
Normal file
@ -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<Record<"step", number>>(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<T extends Record<string, string | number>>(
|
||||||
|
sql: string,
|
||||||
|
...params: (string | number)[]
|
||||||
|
): T[] {
|
||||||
|
try {
|
||||||
|
return this.db.prepare(sql).all<T>(params);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
Deno.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private getOrExit<T extends Record<string, string | number>>(
|
||||||
|
sql: string,
|
||||||
|
...params: (string | number)[]
|
||||||
|
): T | undefined {
|
||||||
|
try {
|
||||||
|
return this.db.prepare(sql).get<T>(params);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
Deno.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
server/src/db/test.db
Normal file
13
server/src/db/types/index.ts
Normal file
@ -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;
|
||||||
|
}
|
||||||
50
server/src/errors.ts
Normal file
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
27
server/src/middleware/auth.ts
Normal file
@ -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;
|
||||||
55
server/src/middleware/rateLimiter.ts
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
import { Middleware } from "@src/router.ts";
|
||||||
|
import { none, some } from "@shared/utils/option.ts";
|
||||||
|
|
||||||
|
const requestCounts: Partial<
|
||||||
|
Record<string, { count: number; lastReset: number }>
|
||||||
|
> = {};
|
||||||
|
|
||||||
|
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;
|
||||||
49
server/src/requestHandler.ts
Normal file
@ -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<T extends string>(
|
||||||
|
route: T,
|
||||||
|
method: "GET" | "POST",
|
||||||
|
handler: (req: Request, params: Params<ExtractParams<T>>) => 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 string> = Keys extends never
|
||||||
|
? never
|
||||||
|
: {
|
||||||
|
[K in Keys]: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ExtractParams<T extends string> = T extends string
|
||||||
|
? T extends `${infer _Start}:${infer Param}/${infer Rest}`
|
||||||
|
? Param | ExtractParams<Rest>
|
||||||
|
: T extends `${infer _Start}:${infer Param}`
|
||||||
|
? Param
|
||||||
|
: never
|
||||||
|
: never;
|
||||||
138
server/src/router.ts
Normal file
@ -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<S extends string> = (
|
||||||
|
c: Context<S>,
|
||||||
|
) => Promise<Response> | Response;
|
||||||
|
|
||||||
|
export type Middleware = (
|
||||||
|
c: Context<string>,
|
||||||
|
) => Promise<Response | undefined> | Response | undefined;
|
||||||
|
|
||||||
|
type MethodHandlers<S extends string> = Partial<
|
||||||
|
Record<string, RequestHandler<S>>
|
||||||
|
>;
|
||||||
|
|
||||||
|
const DEFAULT_NOT_FOUND_HANDLER = () => new Response("404 Not found");
|
||||||
|
|
||||||
|
class HttpRouter {
|
||||||
|
routerTree = new RouterTree<MethodHandlers<any>>();
|
||||||
|
pathPreprocessor?: (path: string) => string;
|
||||||
|
middlewareChain: Middleware[] = [];
|
||||||
|
defaultNotFoundHandler: RequestHandler<string> = DEFAULT_NOT_FOUND_HANDLER;
|
||||||
|
|
||||||
|
setPathProcessor(processor: (path: string) => string) {
|
||||||
|
this.pathPreprocessor = processor;
|
||||||
|
}
|
||||||
|
|
||||||
|
use(mw: Middleware): HttpRouter {
|
||||||
|
this.middlewareChain.push(mw);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
add<S extends string>(
|
||||||
|
path: S,
|
||||||
|
method: string,
|
||||||
|
handler: RequestHandler<S>,
|
||||||
|
): HttpRouter;
|
||||||
|
add<S extends string>(
|
||||||
|
path: S[],
|
||||||
|
method: string,
|
||||||
|
handler: RequestHandler<string>,
|
||||||
|
): HttpRouter;
|
||||||
|
|
||||||
|
add(
|
||||||
|
path: string | string[],
|
||||||
|
method: string,
|
||||||
|
handler: RequestHandler<string>,
|
||||||
|
): 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<string> = {};
|
||||||
|
mth[method] = handler;
|
||||||
|
this.routerTree.add(p, mth);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Overload signatures for 'get'
|
||||||
|
get<S extends string>(path: S, handler: RequestHandler<S>): HttpRouter;
|
||||||
|
get<S extends string>(
|
||||||
|
path: S[],
|
||||||
|
handler: RequestHandler<string>,
|
||||||
|
): HttpRouter;
|
||||||
|
|
||||||
|
// Non-generic implementation for 'get'
|
||||||
|
get(path: string | string[], handler: RequestHandler<string>): HttpRouter {
|
||||||
|
if (Array.isArray(path)) {
|
||||||
|
return this.add(path, "GET", handler);
|
||||||
|
}
|
||||||
|
return this.add(path, "GET", handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
post<S extends string>(path: S, handler: RequestHandler<S>): HttpRouter;
|
||||||
|
post<S extends string>(
|
||||||
|
path: string[],
|
||||||
|
handler: RequestHandler<string>,
|
||||||
|
): HttpRouter;
|
||||||
|
|
||||||
|
post(path: string | string[], handler: RequestHandler<string>): HttpRouter {
|
||||||
|
if (Array.isArray(path)) {
|
||||||
|
return this.add(path, "POST", handler);
|
||||||
|
}
|
||||||
|
return this.add(path, "POST", handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleRequest(
|
||||||
|
req: Request,
|
||||||
|
connInfo: Deno.ServeHandlerInfo<Deno.Addr>,
|
||||||
|
): Promise<Response> {
|
||||||
|
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 string
|
||||||
|
? T extends `${infer _Start}:${infer Param}/${infer Rest}`
|
||||||
|
? Param | ExtractRouteParams<Rest>
|
||||||
|
: T extends `${infer _Start}:${infer Param}` ? Param
|
||||||
|
: T extends `${infer _Start}*` ? "restOfThePath"
|
||||||
|
: never
|
||||||
|
: never;
|
||||||
|
|
||||||
|
export type Params<Keys extends string> = {
|
||||||
|
[K in Keys]: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default HttpRouter;
|
||||||
223
server/src/routerTree.ts
Normal file
@ -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<T> {
|
||||||
|
handler: Option<T>;
|
||||||
|
addChild(
|
||||||
|
segment: string,
|
||||||
|
wildcardSymbol: string,
|
||||||
|
paramPrefixSymbol: string,
|
||||||
|
handler?: T,
|
||||||
|
): Node<T>;
|
||||||
|
getChild(segment: string): Option<Node<T>>;
|
||||||
|
isDynamicNode(): this is DynamicNode<T>;
|
||||||
|
isWildcardNode(): this is WildcardNode<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
class StaticNode<T> implements Node<T> {
|
||||||
|
protected staticChildren = new Map<string, StaticNode<T>>();
|
||||||
|
protected dynamicChild: Option<DynamicNode<T>> = none;
|
||||||
|
protected wildcardChild: Option<WildcardNode<T>> = none;
|
||||||
|
public handler: Option<T> = none;
|
||||||
|
|
||||||
|
constructor(handler?: T) {
|
||||||
|
this.handler = fromNullableVal(handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
addStaticChild(segment: string, handler?: T): StaticNode<T> {
|
||||||
|
const child = new StaticNode(handler);
|
||||||
|
this.staticChildren.set(segment, child);
|
||||||
|
return child;
|
||||||
|
}
|
||||||
|
|
||||||
|
setDynamicChild(paramName: string, handler?: T): DynamicNode<T> {
|
||||||
|
const child = new DynamicNode(paramName, handler);
|
||||||
|
this.dynamicChild = some(child);
|
||||||
|
return child;
|
||||||
|
}
|
||||||
|
|
||||||
|
setWildcardNode(handler?: T): WildcardNode<T> {
|
||||||
|
const child = new WildcardNode(handler);
|
||||||
|
this.wildcardChild = some(child);
|
||||||
|
return child;
|
||||||
|
}
|
||||||
|
|
||||||
|
addChild(
|
||||||
|
segment: string,
|
||||||
|
wildcardSymbol: string,
|
||||||
|
paramPrefixSymbol: string,
|
||||||
|
handler?: T,
|
||||||
|
): Node<T> {
|
||||||
|
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<StaticNode<T>> {
|
||||||
|
return fromNullableVal(this.staticChildren.get(segment));
|
||||||
|
}
|
||||||
|
|
||||||
|
getDynamicChild(): Option<DynamicNode<T>> {
|
||||||
|
return this.dynamicChild;
|
||||||
|
}
|
||||||
|
|
||||||
|
getWildcardChild(): Option<WildcardNode<T>> {
|
||||||
|
return this.wildcardChild;
|
||||||
|
}
|
||||||
|
|
||||||
|
getChild(segment: string): Option<Node<T>> {
|
||||||
|
return this.getStaticChild(segment)
|
||||||
|
.orElse(() => this.getWildcardChild())
|
||||||
|
.orElse(() => this.getDynamicChild());
|
||||||
|
}
|
||||||
|
|
||||||
|
public isDynamicNode(): this is DynamicNode<T> {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public isWildcardNode(): this is WildcardNode<T> {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class DynamicNode<T> extends StaticNode<T> implements Node<T> {
|
||||||
|
constructor(
|
||||||
|
public readonly paramName: string,
|
||||||
|
handler?: T,
|
||||||
|
) {
|
||||||
|
super(handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
public override isDynamicNode(): this is DynamicNode<T> {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class WildcardNode<T> implements Node<T> {
|
||||||
|
public handler: Option<T>;
|
||||||
|
|
||||||
|
constructor(handler?: T) {
|
||||||
|
this.handler = fromNullableVal(handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Override to prevent adding children to a wildcard node
|
||||||
|
public addChild(): Node<T> {
|
||||||
|
throw new Error("Cannot add child to a WildcardNode.");
|
||||||
|
}
|
||||||
|
|
||||||
|
public getChild(): Option<Node<T>> {
|
||||||
|
return none;
|
||||||
|
}
|
||||||
|
|
||||||
|
public isWildcardNode(): this is WildcardNode<T> {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public isDynamicNode(): this is DynamicNode<T> {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Using Node<T> as the unified type for tree nodes.
|
||||||
|
type TreeNode<T> = Node<T>;
|
||||||
|
|
||||||
|
export class RouterTree<T> {
|
||||||
|
public readonly root: StaticNode<T>;
|
||||||
|
|
||||||
|
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<T> = 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<RouteMatch<T>> {
|
||||||
|
const segments = this.splitPath(path);
|
||||||
|
const params: Record<string, string> = {};
|
||||||
|
let current: TreeNode<T> = 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<T> {
|
||||||
|
const segments = this.splitPath(path);
|
||||||
|
let current: TreeNode<T> = 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<string, string>;
|
||||||
|
|
||||||
|
interface RouteMatch<T> {
|
||||||
|
value: T;
|
||||||
|
params: Params;
|
||||||
|
}
|
||||||
33
server/src/utils.ts
Normal file
@ -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;
|
||||||
|
};
|
||||||
166
server/src/wasm_example/Cargo.lock
generated
Normal file
@ -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",
|
||||||
|
]
|
||||||
12
server/src/wasm_example/Cargo.toml
Normal file
@ -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"]
|
||||||
309
server/src/wasm_example/src/lib.rs
Normal file
@ -0,0 +1,309 @@
|
|||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
use wasm_bindgen::prelude::*;
|
||||||
|
|
||||||
|
type Handler = Option<JsValue>;
|
||||||
|
type Params = HashMap<String, String>;
|
||||||
|
|
||||||
|
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<String, StaticTreeNode>,
|
||||||
|
dynamic_child: Option<Box<DynamicTreeNode>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<StaticTreeNode> {
|
||||||
|
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<TreeNode> {
|
||||||
|
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<TreeNodeMut> {
|
||||||
|
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<HandlerAndParams> {
|
||||||
|
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<String>,
|
||||||
|
path_separator: Option<String>,
|
||||||
|
wildcard_symbol: Option<String>,
|
||||||
|
) -> 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<HandlerAndParams> {
|
||||||
|
self.traverse_path(&path)
|
||||||
|
.map(|v| v.extract_handler())
|
||||||
|
.flatten()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn traverse_path(&self, path: &String) -> Option<TraversePathReturn> {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
server/test.db
Normal file
3
server/views/index.html
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
<% layout("./layouts/layout.html") %>
|
||||||
|
|
||||||
|
this is an index.html
|
||||||
17
server/views/layouts/basic.html
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Keyborg</title>
|
||||||
|
<link href="/public/css/style.css" rel="stylesheet">
|
||||||
|
<script type="module" src="/public/js/shared.bundle.js"></script>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<%~ it.body %>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
|
||||||
|
</html>
|
||||||
7
server/views/layouts/layout.html
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
<% layout("./basic.html") %>
|
||||||
|
|
||||||
|
<header></header>
|
||||||
|
<main>
|
||||||
|
<%~ it.body %>
|
||||||
|
</main>
|
||||||
|
<footer></footer>
|
||||||
11
server/views/login.html
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
<% layout("./layouts/basic.html") %>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<form method="POST" id="loginForm">
|
||||||
|
<p>password</p>
|
||||||
|
<input type="password" name="password" id="passwordInput">
|
||||||
|
<input type="submit" value="sign in">
|
||||||
|
</form>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script type="module" src="/public/js/login.js" defer></script>
|
||||||
17
shared/deno.json
Normal file
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
0
shared/main.ts
Normal file
3
shared/mod.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export function add(a: number, b: number): number {
|
||||||
|
return a + b;
|
||||||
|
}
|
||||||
6
shared/mod_test.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
import { assertEquals } from "@std/assert";
|
||||||
|
import { add } from "./mod.ts";
|
||||||
|
|
||||||
|
Deno.test(function addTest() {
|
||||||
|
assertEquals(add(2, 3), 5);
|
||||||
|
});
|
||||||
11
shared/utils/api.ts
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
interface LoginRequest {
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LoginResponse {
|
||||||
|
err: "InvalidPassword" | "InvalidInput";
|
||||||
|
}
|
||||||
|
|
||||||
|
class Api<Req, Res> {
|
||||||
|
makeRequest();
|
||||||
|
}
|
||||||
3
shared/utils/index.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export * from "@shared/utils/option.ts";
|
||||||
|
export * from "@shared/utils/result.ts";
|
||||||
|
export * from "@shared/utils/resultasync.ts";
|
||||||
18
shared/utils/logger.ts
Normal file
@ -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;
|
||||||
391
shared/utils/option.ts
Normal file
@ -0,0 +1,391 @@
|
|||||||
|
import { err, ok } from "@shared/utils/result.ts";
|
||||||
|
|
||||||
|
interface IOption<T> {
|
||||||
|
/**
|
||||||
|
* 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<T>;
|
||||||
|
|
||||||
|
ifSome(fn: (value: T) => void): Option<T>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if the `Option<T>` 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<T>;
|
||||||
|
|
||||||
|
ifNone(fn: () => void): Option<T>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Uses the function `fn` to map a value of type `T`, stored inside of the `Option<T>`, to a value of type `U`. Then wraps a mapped value to the new `Option<U>` 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<U>} a new `Option<U>` wrapping the mapped value.
|
||||||
|
*/
|
||||||
|
map<U>(fn: (value: T) => U): Option<U>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Uses the function `fn` to map a value of type `T`, stored inside of the `Option<T>`, to a value of type `Option<U>` 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<T>` and returns a new `Option<U>`.
|
||||||
|
* @returns {Option<U>} A new `Option<U>` wrapping the result of the flatMap operation.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
flatMap<U>(fn: (value: T) => Option<U>): Option<U>;
|
||||||
|
|
||||||
|
andThen<U>(fn: (value: T) => Option<U>): Option<U>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* **UNSAFE** method for extracting value `T` from an `Option<T>`.
|
||||||
|
* - If the `Option<T>` is `Some<T>` => returns value `T`
|
||||||
|
* - If the `Option<T>` 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<T>`.
|
||||||
|
* - If the `Option<T>` is `Some<T>` => returns value `T`
|
||||||
|
* - If the `Option<T>` 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<T>`, otherwise the `defaultValue`.
|
||||||
|
*/
|
||||||
|
unwrapOr<U>(defaultValue: U): T | U;
|
||||||
|
|
||||||
|
unwrapOrElse<U>(fn: () => U): T | U;
|
||||||
|
|
||||||
|
or<U>(optb: Option<U>): Option<T | U>;
|
||||||
|
|
||||||
|
orElse<U>(fn: () => Option<U>): Option<T | U>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Matches on the `Option<T>` and applies the corresponding function for `Some<T>` or `None`.
|
||||||
|
* - If the `Option<T>` is `Some<T>` => 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<A, B = A>(some: (value: T) => A, none: () => B): A | B;
|
||||||
|
|
||||||
|
toNullable(): T | null;
|
||||||
|
|
||||||
|
toBoolean(): boolean;
|
||||||
|
|
||||||
|
okOrElse<E>(errFn: () => E): Result<T, E>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents a `Some<T>` value in the Option type, wrapping a value of type `T`.
|
||||||
|
* @template `T` The type of the value inside the `Some<T>`.
|
||||||
|
*/
|
||||||
|
export class Some<T> implements IOption<T> {
|
||||||
|
public readonly tag = "Some";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new `Some<T>` instance.
|
||||||
|
* @param {T} The value `T` to wrap inside the `Some<T>`.
|
||||||
|
*/
|
||||||
|
constructor(public readonly value: T) {
|
||||||
|
Object.defineProperties(this, {
|
||||||
|
tag: {
|
||||||
|
writable: false,
|
||||||
|
enumerable: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
isSome(): this is Some<T> {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
ifSome(fn: (value: T) => void): this {
|
||||||
|
fn(this.value);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
isNone(): this is None<T> {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
ifNone(fn: () => void): Option<T> {
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
map<U>(fn: (value: T) => U): Option<U> {
|
||||||
|
return new Some(fn(this.value));
|
||||||
|
}
|
||||||
|
|
||||||
|
flatMap<U>(fn: (value: T) => Option<U>): Option<U> {
|
||||||
|
return fn(this.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
andThen<U>(fn: (value: T) => Option<U>): Option<U> {
|
||||||
|
return fn(this.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
unwrap(): T {
|
||||||
|
return this.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
unwrapOr<U>(defaultValue: U): T | U {
|
||||||
|
return this.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
unwrapOrElse<U>(fn: () => U): T | U {
|
||||||
|
return this.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
or<U>(optb: Option<U>): Option<T | U> {
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
orElse<U>(fn: () => Option<U>): Option<T | U> {
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
match<A, B = A>(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<E>(errFn: () => E): Result<T, E> {
|
||||||
|
return ok<T, E>(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<T> implements IOption<T> {
|
||||||
|
public readonly tag = "None";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new `None` instance.
|
||||||
|
*/
|
||||||
|
constructor() {
|
||||||
|
Object.defineProperties(this, {
|
||||||
|
tag: {
|
||||||
|
writable: false,
|
||||||
|
enumerable: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
isSome(): this is Some<T> {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
ifSome(fn: (value: T) => void): Option<T> {
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
isNone(): this is None<T> {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
ifNone(fn: () => void): Option<T> {
|
||||||
|
fn();
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
map<U>(fn: (value: T) => U): Option<U> {
|
||||||
|
return new None<U>();
|
||||||
|
}
|
||||||
|
|
||||||
|
andThen<U>(fn: (value: T) => Option<U>): Option<U> {
|
||||||
|
return none;
|
||||||
|
}
|
||||||
|
|
||||||
|
flatMap<U>(fn: (value: T) => Option<U>): Option<U> {
|
||||||
|
return new None<U>();
|
||||||
|
}
|
||||||
|
|
||||||
|
unwrap(): T {
|
||||||
|
throw new Error("Tried to unwrap a non-existent value");
|
||||||
|
}
|
||||||
|
|
||||||
|
unwrapOr<U>(defaultValue: U): T | U {
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
unwrapOrElse<U>(fn: () => U): T | U {
|
||||||
|
return fn();
|
||||||
|
}
|
||||||
|
|
||||||
|
or<U>(optb: Option<U>): Option<T | U> {
|
||||||
|
return optb;
|
||||||
|
}
|
||||||
|
|
||||||
|
orElse<U>(fn: () => Option<U>): Option<T | U> {
|
||||||
|
return fn();
|
||||||
|
}
|
||||||
|
|
||||||
|
match<A, B = A>(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<E>(errFn: () => E): Result<T, E> {
|
||||||
|
return err<T, E>(errFn());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Option<T> = Some<T> | None<T>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<T>} A new `Some<T>` instance wrapping the provided value.
|
||||||
|
*/
|
||||||
|
export function some<T>(value: T): Option<T>;
|
||||||
|
export function some<T extends void = void>(value: void): Option<T>;
|
||||||
|
export function some<T>(value: T): Option<T> {
|
||||||
|
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<never>();
|
||||||
|
|
||||||
|
export function fromNullableVal<T>(value: T): Option<NonNullable<T>> {
|
||||||
|
if (!value) {
|
||||||
|
return none;
|
||||||
|
}
|
||||||
|
return some(value);
|
||||||
|
}
|
||||||
339
shared/utils/result.ts
Normal file
@ -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<T, E> {
|
||||||
|
isOk(): this is Ok<T, E>;
|
||||||
|
ifOk(fn: (value: T) => void): Result<T, E>;
|
||||||
|
isErr(): this is Err<T, E>;
|
||||||
|
ifErr(fn: (err: E) => void): Result<T, E>;
|
||||||
|
isErrOrNone(): this is Err<None<T>, E>;
|
||||||
|
unwrap(): T;
|
||||||
|
unwrapOr<U>(defaultValue: U): T | U;
|
||||||
|
unwrapOrElse<U>(fn: () => U): T | U;
|
||||||
|
match<A, B = A>(ok: (value: T) => A, err: (error: E) => B): A | B;
|
||||||
|
map<U>(fn: (value: T) => U): Result<U, E>;
|
||||||
|
mapErr<U>(fn: (err: E) => U): Result<T, U>;
|
||||||
|
andThen<U, F>(fn: (value: T) => Result<U, F>): Result<U, E | F>;
|
||||||
|
flatten(): FlattenResult<Result<T, E>>;
|
||||||
|
flattenOption<U>(errFn: () => U): Result<UnwrapOption<T>, U | E>;
|
||||||
|
flattenOptionOr<D = UnwrapOption<T>>(
|
||||||
|
defaultValue: D,
|
||||||
|
): Result<UnwrapOption<T> | D, E>;
|
||||||
|
mapOption<U>(fn: (value: UnwrapOption<T>) => U): Result<Option<U>, E>;
|
||||||
|
matchOption<A, B>(
|
||||||
|
some: (value: UnwrapOption<T>) => A,
|
||||||
|
none: () => B,
|
||||||
|
): Result<A | B, E>;
|
||||||
|
toNullable(): T | null;
|
||||||
|
toAsync(): ResultAsync<T, E>;
|
||||||
|
void(): Result<void, E>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Ok<T, E> implements IResult<T, E> {
|
||||||
|
public readonly tag = "Ok";
|
||||||
|
|
||||||
|
constructor(public readonly value: T) {
|
||||||
|
this.value = value;
|
||||||
|
|
||||||
|
Object.defineProperties(this, {
|
||||||
|
tag: {
|
||||||
|
writable: false,
|
||||||
|
enumerable: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
isErr(): this is Err<T, E> {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
ifErr(fn: (err: E) => void): Result<T, E> {
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
isErrOrNone(): this is Err<None<T>, E> {
|
||||||
|
if (this.value instanceof None) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
isOk(): this is Ok<T, E> {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
ifOk(fn: (value: T) => void): Result<T, E> {
|
||||||
|
fn(this.value);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
unwrap(): T {
|
||||||
|
return this.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
unwrapOr<U>(defaultValue: U): T {
|
||||||
|
return this.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
unwrapOrElse<U>(fn: () => U): T | U {
|
||||||
|
return this.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
match<A, B = A>(ok: (value: T) => A, err: (error: E) => B): A | B {
|
||||||
|
return ok(this.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
map<U>(fn: (value: T) => U): Result<U, E> {
|
||||||
|
const mappedValue = fn(this.value);
|
||||||
|
|
||||||
|
return new Ok<U, E>(mappedValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
mapOption<U>(fn: (value: UnwrapOption<T>) => U): Result<Option<U>, E> {
|
||||||
|
if (this.value instanceof None || this.value instanceof Some) {
|
||||||
|
return ok(this.value.map(fn));
|
||||||
|
}
|
||||||
|
return ok(some(fn(this.value as UnwrapOption<T>)));
|
||||||
|
}
|
||||||
|
|
||||||
|
andThen<U, F>(fn: (value: T) => Result<U, F>): Result<U, E | F> {
|
||||||
|
return fn(this.value) as Result<U, E | F>;
|
||||||
|
}
|
||||||
|
|
||||||
|
mapErr<U>(fn: (err: E) => U): Result<T, U> {
|
||||||
|
return new Ok<T, U>(this.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
flatten(): FlattenResult<Result<T, E>> {
|
||||||
|
return flattenResult(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
flattenOption<U = E>(errFn: () => U): Result<UnwrapOption<T>, E | U> {
|
||||||
|
if (this.value instanceof None || this.value instanceof Some) {
|
||||||
|
return this.value.okOrElse(errFn);
|
||||||
|
}
|
||||||
|
return new Ok<UnwrapOption<T>, E | U>(this.value as UnwrapOption<T>);
|
||||||
|
}
|
||||||
|
|
||||||
|
flattenOptionOr<D = UnwrapOption<T>>(
|
||||||
|
defaultValue: D,
|
||||||
|
): Result<UnwrapOption<T> | D, E> {
|
||||||
|
if (this.value instanceof None || this.value instanceof Some) {
|
||||||
|
return this.value.unwrapOr(defaultValue);
|
||||||
|
}
|
||||||
|
return new Ok<UnwrapOption<T> | D, E>(this.value as UnwrapOption<T>);
|
||||||
|
}
|
||||||
|
|
||||||
|
matchOption<A, B>(
|
||||||
|
some: (value: UnwrapOption<T>) => A,
|
||||||
|
none: () => B,
|
||||||
|
): Result<A | B, E> {
|
||||||
|
if (this.value instanceof None || this.value instanceof Some) {
|
||||||
|
return ok(this.value.match(some, none));
|
||||||
|
}
|
||||||
|
return ok(some(this.value as UnwrapOption<T>));
|
||||||
|
}
|
||||||
|
|
||||||
|
toNullable(): T | null {
|
||||||
|
return this.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
toAsync(): ResultAsync<T, E> {
|
||||||
|
return okAsync(this.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
void(): Result<void, E> {
|
||||||
|
return ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Err<T, E> implements IResult<T, E> {
|
||||||
|
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<T, E> {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
ifErr(fn: (err: E) => void): Result<T, E> {
|
||||||
|
fn(this.error);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
isOk(): this is Ok<T, E> {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
ifOk(fn: (value: T) => void): Result<T, E> {
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
isErrOrNone(): this is Err<None<T>, E> {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
unwrap(): T {
|
||||||
|
const message = `Tried to unwrap error: ${
|
||||||
|
getMessageFromError(this.error)
|
||||||
|
}`;
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
unwrapOr<U>(defaultValue: U): U {
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
unwrapOrElse<U>(fn: () => U): T | U {
|
||||||
|
return fn();
|
||||||
|
}
|
||||||
|
match<A, B = A>(ok: (value: T) => A, err: (error: E) => B): A | B {
|
||||||
|
return err(this.error);
|
||||||
|
}
|
||||||
|
map<U>(fn: (value: T) => U): Result<U, E> {
|
||||||
|
return new Err<U, E>(this.error);
|
||||||
|
}
|
||||||
|
mapErr<U>(fn: (err: E) => U): Result<T, U> {
|
||||||
|
const mappedError = fn(this.error);
|
||||||
|
return new Err<T, U>(mappedError);
|
||||||
|
}
|
||||||
|
mapOption<U>(fn: (value: UnwrapOption<T>) => U): Result<Option<U>, E> {
|
||||||
|
return err(this.error);
|
||||||
|
}
|
||||||
|
andThen<U, F>(fn: (value: T) => Result<U, F>): Result<U, E | F> {
|
||||||
|
return new Err<U, E | F>(this.error);
|
||||||
|
}
|
||||||
|
flatten(): FlattenResult<Result<T, E>> {
|
||||||
|
return flattenResult(this);
|
||||||
|
}
|
||||||
|
flattenOption<U>(errFn: () => U): Result<UnwrapOption<T>, E | U> {
|
||||||
|
return new Err<UnwrapOption<T>, E | U>(this.error);
|
||||||
|
}
|
||||||
|
flattenOptionOr<D = UnwrapOption<T>>(
|
||||||
|
defaultValue: D,
|
||||||
|
): Result<D | UnwrapOption<T>, E> {
|
||||||
|
return new Err<UnwrapOption<T> | D, E>(this.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
matchOption<A, B>(
|
||||||
|
some: (value: UnwrapOption<T>) => A,
|
||||||
|
none: () => B,
|
||||||
|
): Result<A | B, E> {
|
||||||
|
return err<A | B, E>(this.error);
|
||||||
|
}
|
||||||
|
toNullable(): T | null {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
toAsync(): ResultAsync<T, E> {
|
||||||
|
return errAsync(this.error);
|
||||||
|
}
|
||||||
|
void(): Result<void, E> {
|
||||||
|
return err(this.error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Result<T, E> = Ok<T, E> | Err<T, E>;
|
||||||
|
//#endregion
|
||||||
|
|
||||||
|
//#region Ok and Err factory functions
|
||||||
|
export function ok<T, E = never>(val: T): Ok<T, E>;
|
||||||
|
export function ok<T extends void = void, E = never>(val: void): Ok<void, E>;
|
||||||
|
export function ok<T, E = never>(val: T): Ok<T, E> {
|
||||||
|
return new Ok(val) as Ok<T, E>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function err<T = never, E extends string = string>(err: E): Err<T, E>;
|
||||||
|
export function err<T = never, E = unknown>(err: E): Err<T, E>;
|
||||||
|
export function err<T = never, E extends void = void>(err: void): Err<T, void>;
|
||||||
|
export function err<T, E>(err: E): Err<T, E> {
|
||||||
|
return new Err(err) as Err<T, E>;
|
||||||
|
}
|
||||||
|
|
||||||
|
//#endregion
|
||||||
|
|
||||||
|
export function fromThrowable<Fn extends (...args: readonly any[]) => any, E>(
|
||||||
|
fn: Fn,
|
||||||
|
errorMapper?: (e: unknown) => E,
|
||||||
|
): (...args: Parameters<Fn>) => Result<ReturnType<Fn>, 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<R extends Result<any, any>>(
|
||||||
|
nestedResult: R,
|
||||||
|
): FlattenResult<R> {
|
||||||
|
let currentResult = nestedResult;
|
||||||
|
|
||||||
|
while (currentResult instanceof Ok) {
|
||||||
|
currentResult = currentResult.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return currentResult as FlattenResult<R>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ResultFromJSON<T = unknown, E = unknown>(
|
||||||
|
str: string,
|
||||||
|
): Result<T, E> {
|
||||||
|
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> = T extends Option<infer V> ? V : T;
|
||||||
|
|
||||||
|
export type FlattenResult<R> = R extends Result<infer T, infer E>
|
||||||
|
? T extends Result<any, any>
|
||||||
|
? FlattenResult<T> extends Result<infer V, infer innerE>
|
||||||
|
? Result<V, E | innerE>
|
||||||
|
: never
|
||||||
|
: R
|
||||||
|
: never;
|
||||||
237
shared/utils/resultasync.ts
Normal file
@ -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<T, E> implements PromiseLike<Result<T, E>> {
|
||||||
|
constructor(private readonly _promise: Promise<Result<T, E>>) {
|
||||||
|
this._promise = _promise;
|
||||||
|
}
|
||||||
|
|
||||||
|
static fromPromise<T, E>(
|
||||||
|
promise: Promise<T>,
|
||||||
|
errorMapper: (error: unknown) => E,
|
||||||
|
) {
|
||||||
|
const promiseOfResult: Promise<Result<T, E>> = promise
|
||||||
|
.then((value: T): Result<T, E> => {
|
||||||
|
return new Ok<T, E>(value);
|
||||||
|
})
|
||||||
|
.catch((error: unknown): Result<T, E> => {
|
||||||
|
return new Err<T, E>(errorMapper(error));
|
||||||
|
});
|
||||||
|
|
||||||
|
return new ResultAsync<T, E>(promiseOfResult);
|
||||||
|
}
|
||||||
|
|
||||||
|
static fromSafePromise<T>(promise: Promise<T>) {
|
||||||
|
const promiseOfResult: Promise<Result<T, never>> = promise.then(
|
||||||
|
(value: T): Result<T, never> => {
|
||||||
|
return new Ok<T, never>(value);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return new ResultAsync<T, never>(promiseOfResult);
|
||||||
|
}
|
||||||
|
|
||||||
|
static fromThrowable<Fn extends (...args: readonly any[]) => any, E>(
|
||||||
|
fn: Fn,
|
||||||
|
errorMapper?: (e: unknown) => E,
|
||||||
|
): (...args: Parameters<Fn>) => ResultAsync<ReturnType<Fn>, E> {
|
||||||
|
return (...args: Parameters<Fn>): ResultAsync<ReturnType<Fn>, E> => {
|
||||||
|
try {
|
||||||
|
return okAsync(fn(args));
|
||||||
|
} catch (e) {
|
||||||
|
return errAsync(errorMapper ? errorMapper(e) : e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async unwrap(): Promise<T> {
|
||||||
|
const result = await this._promise;
|
||||||
|
|
||||||
|
if (result.isErr()) {
|
||||||
|
throw result.error;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
async match<A, B = A>(
|
||||||
|
ok: (value: T) => A,
|
||||||
|
err: (err: E) => B,
|
||||||
|
): Promise<A | B> {
|
||||||
|
const result = await this._promise;
|
||||||
|
|
||||||
|
if (result.isErr()) {
|
||||||
|
return err(result.error);
|
||||||
|
}
|
||||||
|
return ok(result.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
map<U>(fn: (value: T) => U): ResultAsync<U, E> {
|
||||||
|
return new ResultAsync(
|
||||||
|
this._promise.then((result: Result<T, E>): Result<U, E> => {
|
||||||
|
if (result.isErr()) {
|
||||||
|
return new Err<U, E>(result.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Ok<U, E>(fn(result.value));
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
mapAsync<U>(fn: (value: T) => Promise<U>): ResultAsync<U, E> {
|
||||||
|
return new ResultAsync(
|
||||||
|
this._promise.then(
|
||||||
|
async (result: Result<T, E>): Promise<Result<U, E>> => {
|
||||||
|
if (result.isErr()) {
|
||||||
|
return errAsync<U, E>(result.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Ok<U, E>(await fn(result.value));
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
mapErr<U>(fn: (err: E) => U): ResultAsync<T, U> {
|
||||||
|
return new ResultAsync(
|
||||||
|
this._promise.then((result: Result<T, E>): Result<T, U> => {
|
||||||
|
if (result.isErr()) {
|
||||||
|
return new Err<T, U>(fn(result.error));
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Ok<T, U>(result.value);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
mapErrAsync<U>(fn: (value: T) => Promise<U>): ResultAsync<T, U> {
|
||||||
|
return new ResultAsync(
|
||||||
|
this._promise.then(
|
||||||
|
async (result: Result<T, E>): Promise<Result<T, U>> => {
|
||||||
|
if (result.isErr()) {
|
||||||
|
return errAsync<T, U>(await fn(result.error));
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Ok<T, U>(result.value);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
andThen<U, F>(fn: (value: T) => ResultAsync<U, F>): ResultAsync<U, E | F> {
|
||||||
|
return new ResultAsync(
|
||||||
|
this._promise.then(
|
||||||
|
(result: Result<T, E>): ResultAsync<U, E | F> => {
|
||||||
|
if (result.isErr()) {
|
||||||
|
return errAsync(result.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return fn(result.value) as ResultAsync<U, E | F>;
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
nullableToOption(): ResultAsync<Option<NonNullable<T>>, E> {
|
||||||
|
return this.map((v) => (v ? some(v) : none));
|
||||||
|
}
|
||||||
|
|
||||||
|
flatten(): FlattenResultAsync<ResultAsync<T, E>> {
|
||||||
|
return new ResultAsync(
|
||||||
|
this._promise.then(
|
||||||
|
(result: Result<T, E>): FlattenResult<Result<T, E>> => {
|
||||||
|
return result.flatten();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
) as FlattenResultAsync<ResultAsync<T, E>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
flattenOption<U = never>(
|
||||||
|
errFn: () => U,
|
||||||
|
): ResultAsync<UnwrapOption<T>, E | U> {
|
||||||
|
return new ResultAsync(
|
||||||
|
this._promise.then(
|
||||||
|
(result: Result<T, E>): Result<UnwrapOption<T>, E | U> => {
|
||||||
|
return result.flattenOption(errFn);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
flattenOptionOrDefault<D = UnwrapOption<T>>(
|
||||||
|
defaultValue: D,
|
||||||
|
): ResultAsync<UnwrapOption<T> | D, E> {
|
||||||
|
return new ResultAsync(
|
||||||
|
this._promise.then(
|
||||||
|
(result: Result<T, E>): Result<UnwrapOption<T> | D, E> => {
|
||||||
|
return result.flattenOptionOrDefault(defaultValue);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
matchOption<A, B = A>(
|
||||||
|
some: (value: UnwrapOption<T>) => A,
|
||||||
|
none: () => B,
|
||||||
|
): ResultAsync<A | B, E> {
|
||||||
|
return new ResultAsync(
|
||||||
|
this._promise.then((result: Result<T, E>): Result<A | B, E> => {
|
||||||
|
return result.matchOption(some, none);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
matchOptionAndFlatten<A, B, U, F>(
|
||||||
|
some: (value: UnwrapOption<T>) => Result<A, U>,
|
||||||
|
none: () => Result<B, F>,
|
||||||
|
): ResultAsync<A | B, E | U | F> {
|
||||||
|
return new ResultAsync(
|
||||||
|
this._promise.then(
|
||||||
|
(result: Result<T, E>): Result<A | B, E | U | F> => {
|
||||||
|
return result.matchOptionAndFlatten(some, none);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
then<A, B>(
|
||||||
|
onFulfilled?: (res: Result<T, E>) => A | PromiseLike<A>,
|
||||||
|
onRejected?: (reason: unknown) => B | PromiseLike<B>,
|
||||||
|
): PromiseLike<A | B> {
|
||||||
|
return this._promise.then(onFulfilled, onRejected);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function okAsync<T, E = never>(value: T): ResultAsync<T, E>;
|
||||||
|
export function okAsync<T extends void = void, E = never>(
|
||||||
|
value: void,
|
||||||
|
): ResultAsync<void, E>;
|
||||||
|
export function okAsync<T, E = never>(value: T): ResultAsync<T, E> {
|
||||||
|
return new ResultAsync(Promise.resolve(new Ok<T, E>(value)));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function errAsync<T = never, E extends string = string>(
|
||||||
|
err: E,
|
||||||
|
): ResultAsync<T, E>;
|
||||||
|
export function errAsync<T = never, E = unknown>(err: E): ResultAsync<T, E>;
|
||||||
|
export function errAsync<T = never, E extends void = void>(
|
||||||
|
err: void,
|
||||||
|
): ResultAsync<T, void>;
|
||||||
|
export function errAsync<E, T = never>(err: E): ResultAsync<T, E> {
|
||||||
|
return new ResultAsync(Promise.resolve(new Err<T, E>(err)));
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FlattenResultAsync<R> =
|
||||||
|
R extends ResultAsync<infer T, infer E>
|
||||||
|
? T extends ResultAsync<any, any>
|
||||||
|
? FlattenResultAsync<T> extends ResultAsync<infer V, infer innerE>
|
||||||
|
? ResultAsync<V, E | innerE>
|
||||||
|
: never
|
||||||
|
: R
|
||||||
|
: never;
|
||||||
28
tsconfig.json
Normal file
@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||