Compare commits
3 commits
116dc232a8
...
fd9322923b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fd9322923b | ||
|
|
db920c87ed | ||
|
|
06460874c1 |
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -1,3 +1,6 @@
|
|||
**/build/*
|
||||
**/target/*
|
||||
pritty/outputs/*
|
||||
|
||||
**/*.db
|
||||
*.db
|
||||
|
|
|
|||
22
deno.jsonc
Normal file
22
deno.jsonc
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"checkJs": true,
|
||||
"lib": [
|
||||
"esnext",
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"deno.window",
|
||||
"deno.unstable"
|
||||
]
|
||||
},
|
||||
"fmt": {
|
||||
"semiColons": true,
|
||||
"singleQuote": true,
|
||||
"useTabs": true,
|
||||
"lineWidth": 90
|
||||
},
|
||||
"imports": {
|
||||
"@deno/emit": "jsr:@deno/emit@^0.46.0",
|
||||
"htmx.org": "npm:htmx.org@2.0.4"
|
||||
}
|
||||
}
|
||||
68
deno.lock
Normal file
68
deno.lock
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
{
|
||||
"version": "4",
|
||||
"specifiers": {
|
||||
"jsr:@deno/cache-dir@0.13.2": "0.13.2",
|
||||
"jsr:@deno/emit@0.46": "0.46.0",
|
||||
"jsr:@std/assert@0.223": "0.223.0",
|
||||
"jsr:@std/bytes@0.223": "0.223.0",
|
||||
"jsr:@std/fmt@0.223": "0.223.0",
|
||||
"jsr:@std/fs@0.223": "0.223.0",
|
||||
"jsr:@std/io@0.223": "0.223.0",
|
||||
"jsr:@std/path@0.223": "0.223.0",
|
||||
"npm:htmx.org@2.0.4": "2.0.4"
|
||||
},
|
||||
"jsr": {
|
||||
"@deno/cache-dir@0.13.2": {
|
||||
"integrity": "c22419dfe27ab85f345bee487aaaadba498b005cce3644e9d2528db035c5454d",
|
||||
"dependencies": [
|
||||
"jsr:@std/fmt",
|
||||
"jsr:@std/fs",
|
||||
"jsr:@std/io",
|
||||
"jsr:@std/path"
|
||||
]
|
||||
},
|
||||
"@deno/emit@0.46.0": {
|
||||
"integrity": "e276be2c77bac1b93caf775762e2a49a54cb00da2d48ca2b01ed8d7cba9d082c",
|
||||
"dependencies": [
|
||||
"jsr:@deno/cache-dir",
|
||||
"jsr:@std/path"
|
||||
]
|
||||
},
|
||||
"@std/assert@0.223.0": {
|
||||
"integrity": "eb8d6d879d76e1cc431205bd346ed4d88dc051c6366365b1af47034b0670be24"
|
||||
},
|
||||
"@std/bytes@0.223.0": {
|
||||
"integrity": "84b75052cd8680942c397c2631318772b295019098f40aac5c36cead4cba51a8"
|
||||
},
|
||||
"@std/fmt@0.223.0": {
|
||||
"integrity": "6deb37794127dfc7d7bded2586b9fc6f5d50e62a8134846608baf71ffc1a5208"
|
||||
},
|
||||
"@std/fs@0.223.0": {
|
||||
"integrity": "3b4b0550b2c524cbaaa5a9170c90e96cbb7354e837ad1bdaf15fc9df1ae9c31c"
|
||||
},
|
||||
"@std/io@0.223.0": {
|
||||
"integrity": "2d8c3c2ab3a515619b90da2c6ff5ea7b75a94383259ef4d02116b228393f84f1",
|
||||
"dependencies": [
|
||||
"jsr:@std/assert",
|
||||
"jsr:@std/bytes"
|
||||
]
|
||||
},
|
||||
"@std/path@0.223.0": {
|
||||
"integrity": "593963402d7e6597f5a6e620931661053572c982fc014000459edc1f93cc3989",
|
||||
"dependencies": [
|
||||
"jsr:@std/assert"
|
||||
]
|
||||
}
|
||||
},
|
||||
"npm": {
|
||||
"htmx.org@2.0.4": {
|
||||
"integrity": "sha512-HLxMCdfXDOJirs3vBZl/ZLoY+c7PfM4Ahr2Ad4YXh6d22T5ltbTXFFkpx9Tgb2vvmWFMbIc3LqN2ToNkZJvyYQ=="
|
||||
}
|
||||
},
|
||||
"workspace": {
|
||||
"dependencies": [
|
||||
"jsr:@deno/emit@0.46",
|
||||
"npm:htmx.org@2.0.4"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -113,6 +113,29 @@ function bindGet(palette: Palette) {
|
|||
return (entry: TableEntry | undefined) => get(entry, palette);
|
||||
}
|
||||
|
||||
async function generateSublime(
|
||||
dir: string,
|
||||
name: string,
|
||||
variantName: string,
|
||||
tables: Tables,
|
||||
palette: Palette,
|
||||
) {
|
||||
let output = '{';
|
||||
const get = bindGet(palette);
|
||||
|
||||
const w = (prop: string, v: string | undefined) => {
|
||||
if (v !== undefined) {
|
||||
}
|
||||
};
|
||||
|
||||
output += `"globals": {`;
|
||||
w('background', get(tables['background']?.['primary']));
|
||||
w('foreground', get(tables['text']?.['primary']));
|
||||
output += `},`;
|
||||
|
||||
output += '}';
|
||||
}
|
||||
|
||||
async function generateGhostty(
|
||||
dir: string,
|
||||
name: string,
|
||||
|
|
|
|||
10
rs/Cargo.toml
Normal file
10
rs/Cargo.toml
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
[package]
|
||||
name = "atelier"
|
||||
version = "0.0.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
rusqlite = { version = "*" }
|
||||
async-channel = { version = "*" }
|
||||
urlpattern = { version = "*" }
|
||||
url = { version = "*" }
|
||||
71
rs/src/containers.rs
Normal file
71
rs/src/containers.rs
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
use core::fmt::Debug;
|
||||
use std::borrow::Borrow;
|
||||
|
||||
pub struct VecMap<K, V> {
|
||||
items: Vec<(K, V)>,
|
||||
}
|
||||
|
||||
impl<K, V> Debug for VecMap<K, V>
|
||||
where
|
||||
K: Debug,
|
||||
V: Debug,
|
||||
{
|
||||
fn fmt(&self, fmt: &mut core::fmt::Formatter) -> core::fmt::Result {
|
||||
fmt.debug_map()
|
||||
.entries(self.iter().map(|&(ref k, ref v)| (k, v)))
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl<K, V> Default for VecMap<K, V> {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
impl<K, V> VecMap<K, V> {
|
||||
pub fn new() -> Self {
|
||||
Self { items: vec![] }
|
||||
}
|
||||
|
||||
pub fn iter(&self) -> impl Iterator<Item = &(K, V)> {
|
||||
self.items.iter()
|
||||
}
|
||||
}
|
||||
|
||||
impl<K, V> VecMap<K, V>
|
||||
where
|
||||
K: Eq,
|
||||
{
|
||||
pub fn get_mut<Q>(&mut self, k: &Q) -> Option<&mut V>
|
||||
where
|
||||
K: Borrow<Q>,
|
||||
Q: Eq + ?Sized,
|
||||
{
|
||||
self.items
|
||||
.iter_mut()
|
||||
.find(|(ki, _)| ki.borrow() == k)
|
||||
.map(|(_, v)| v)
|
||||
}
|
||||
|
||||
pub fn get<Q>(&self, k: &Q) -> Option<&V>
|
||||
where
|
||||
K: Borrow<Q>,
|
||||
Q: Eq + ?Sized,
|
||||
{
|
||||
self.items
|
||||
.iter()
|
||||
.find(|(ki, _)| ki.borrow() == k)
|
||||
.map(|(_, v)| v)
|
||||
}
|
||||
|
||||
pub fn insert(&mut self, k: K, mut v: V) -> Option<V> {
|
||||
if let Some(vi) = self.get_mut(&k) {
|
||||
core::mem::swap(vi, &mut v);
|
||||
|
||||
return Some(v);
|
||||
}
|
||||
|
||||
self.items.push((k, v));
|
||||
None
|
||||
}
|
||||
}
|
||||
18
rs/src/lib.rs
Normal file
18
rs/src/lib.rs
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
pub mod containers;
|
||||
pub mod router;
|
||||
pub mod rusqlite_thread_pool;
|
||||
|
||||
pub fn add(left: u64, right: u64) -> u64 {
|
||||
left + right
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn it_works() {
|
||||
let result = add(2, 2);
|
||||
assert_eq!(result, 4);
|
||||
}
|
||||
}
|
||||
82
rs/src/router.rs
Normal file
82
rs/src/router.rs
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
use crate::containers::VecMap;
|
||||
use std::collections::HashMap;
|
||||
use url::Url;
|
||||
use urlpattern::{UrlPattern, UrlPatternInit, UrlPatternMatchInput};
|
||||
|
||||
pub type PathParams = HashMap<String, Option<String>>;
|
||||
|
||||
struct UrlPatternWithInput {
|
||||
pathname: String,
|
||||
pattern: UrlPattern,
|
||||
}
|
||||
impl PartialEq for UrlPatternWithInput {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
self.pathname == other.pathname
|
||||
}
|
||||
}
|
||||
impl Eq for UrlPatternWithInput {
|
||||
}
|
||||
pub struct Router<T> {
|
||||
patterns: VecMap<UrlPatternWithInput, VecMap<String, T>>,
|
||||
}
|
||||
|
||||
impl<T> Router<T> {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
patterns: VecMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn register(&mut self, method: String, pathname: String, value: T) {
|
||||
let upwi = UrlPatternWithInput {
|
||||
pathname: pathname.clone(),
|
||||
pattern: UrlPattern::parse(
|
||||
UrlPatternInit {
|
||||
pathname: Some(pathname),
|
||||
..Default::default()
|
||||
},
|
||||
Default::default(),
|
||||
)
|
||||
.unwrap(),
|
||||
};
|
||||
|
||||
if let Some(methods) = self.patterns.get_mut(&upwi) {
|
||||
methods.insert(method, value);
|
||||
} else {
|
||||
let mut methods = VecMap::new();
|
||||
methods.insert(method, value);
|
||||
self.patterns.insert(upwi, methods);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get(
|
||||
&self,
|
||||
method: &str,
|
||||
url: &str,
|
||||
) -> Result<(&T, PathParams), u16> {
|
||||
let url = match Url::parse(url) {
|
||||
Ok(u) => u,
|
||||
Err(_) => match Url::parse(&format!("http://example.com{url}")) {
|
||||
Ok(u) => u,
|
||||
Err(_) => return Err(400),
|
||||
},
|
||||
};
|
||||
|
||||
for (upwi, methods) in self.patterns.iter() {
|
||||
let pattern = &upwi.pattern;
|
||||
let result = pattern.exec(UrlPatternMatchInput::Url(url.clone()));
|
||||
let pathGroups = match result {
|
||||
Ok(Some(res)) => res.pathname.groups,
|
||||
_ => continue,
|
||||
};
|
||||
|
||||
if let Some(t) = methods.get(method) {
|
||||
return Ok((t, pathGroups));
|
||||
}
|
||||
|
||||
return Err(405);
|
||||
}
|
||||
|
||||
Err(404)
|
||||
}
|
||||
}
|
||||
111
rs/src/rusqlite_thread_pool.rs
Normal file
111
rs/src/rusqlite_thread_pool.rs
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
use std::thread::JoinHandle;
|
||||
use std::time::Duration;
|
||||
|
||||
use async_channel::{Receiver, Sender};
|
||||
use rusqlite::Connection;
|
||||
|
||||
pub type RusqliteThreadPoolTask =
|
||||
Box<dyn FnOnce(&mut Connection) -> () + Send + Sync + 'static>;
|
||||
pub type RusqliteThreadPoolWorker =
|
||||
(JoinHandle<()>, Sender<RusqliteThreadPoolTask>);
|
||||
|
||||
pub type PoolReceiver = Receiver<RusqliteThreadPoolTask>;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct PoolSender {
|
||||
tx: Sender<RusqliteThreadPoolTask>,
|
||||
}
|
||||
impl PoolSender {
|
||||
pub async fn send<T>(
|
||||
&self,
|
||||
f: impl FnOnce(&mut Connection) -> rusqlite::Result<T>
|
||||
+ Send
|
||||
+ Sync
|
||||
+ 'static,
|
||||
) -> rusqlite::Result<T>
|
||||
where
|
||||
T: Send + Sync + 'static,
|
||||
{
|
||||
let (tx, rx) = async_channel::bounded(1);
|
||||
let task = move |conn: &mut Connection| {
|
||||
let out = f(conn);
|
||||
tx.send_blocking(out).unwrap();
|
||||
};
|
||||
let task: RusqliteThreadPoolTask = Box::new(task);
|
||||
self.tx.send(task).await.unwrap();
|
||||
|
||||
rx.recv().await.unwrap()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn spawn_worker(
|
||||
mut conn: Connection,
|
||||
rx: Receiver<RusqliteThreadPoolTask>,
|
||||
) -> JoinHandle<()> {
|
||||
std::thread::spawn(move || {
|
||||
while let Ok(task) = rx.try_recv() {
|
||||
task(&mut conn);
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn spawn_threadpool(
|
||||
n_workers: u8,
|
||||
db_path: &str,
|
||||
mut init: impl FnMut(&mut Connection) -> rusqlite::Result<()>,
|
||||
) -> rusqlite::Result<(PoolSender, Vec<JoinHandle<()>>, PoolReceiver)> {
|
||||
assert!(n_workers > 0);
|
||||
|
||||
let (tx, rx) = async_channel::unbounded();
|
||||
let mut workers = vec![];
|
||||
for _ in 0..n_workers {
|
||||
let mut conn = Connection::open(db_path)?;
|
||||
init(&mut conn)?;
|
||||
|
||||
let handle = spawn_worker(conn, rx.clone());
|
||||
|
||||
workers.push(handle);
|
||||
}
|
||||
|
||||
return Ok((PoolSender { tx }, workers, rx));
|
||||
}
|
||||
|
||||
pub fn spawn_threadpool_supervised(
|
||||
n_workers: u8,
|
||||
db_path: &str,
|
||||
mut init: impl FnMut(&mut Connection) -> rusqlite::Result<()>
|
||||
+ Send
|
||||
+ Sync
|
||||
+ Clone
|
||||
+ 'static,
|
||||
) -> rusqlite::Result<(PoolSender, JoinHandle<()>, PoolReceiver)> {
|
||||
let (tx, mut workers, rx) =
|
||||
spawn_threadpool(n_workers, db_path, init.clone())?;
|
||||
|
||||
let rxt = rx.clone();
|
||||
let db_path = db_path.to_string();
|
||||
let supervisor = std::thread::spawn(move || {
|
||||
let rx = rxt;
|
||||
let db_path = db_path;
|
||||
|
||||
loop {
|
||||
let mut dead_worker_idxs = vec![];
|
||||
for (i, worker) in workers.iter().enumerate() {
|
||||
if worker.is_finished() {
|
||||
dead_worker_idxs.push(i);
|
||||
}
|
||||
}
|
||||
|
||||
for i in dead_worker_idxs {
|
||||
let mut conn = Connection::open(&db_path).unwrap();
|
||||
init(&mut conn).unwrap();
|
||||
|
||||
workers[i] = spawn_worker(conn, rx.clone());
|
||||
}
|
||||
|
||||
std::thread::sleep(Duration::from_secs(1));
|
||||
}
|
||||
});
|
||||
|
||||
return Ok((tx, supervisor, rx));
|
||||
}
|
||||
1162
shelves/Cargo.lock
generated
Normal file
1162
shelves/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
16
shelves/Cargo.toml
Normal file
16
shelves/Cargo.toml
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
[package]
|
||||
name = "shelves"
|
||||
version = "0.0.0"
|
||||
edition = "2024"
|
||||
|
||||
[[bin]]
|
||||
name = "shelves"
|
||||
path = "backend/main.rs"
|
||||
|
||||
[dependencies]
|
||||
tokio = { version = "1.43.0", features = ["rt-multi-thread", "fs", "net"] }
|
||||
axum = { version = "0.8.1", features = ["http1", "macros"] }
|
||||
http = "1.2.0"
|
||||
|
||||
atelier = { path = "../rs" }
|
||||
rusqlite = { version = "0.33.0", features = ["bundled"] }
|
||||
46
shelves/backend/db-schema.ts
Normal file
46
shelves/backend/db-schema.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import { Database } from '@db/sqlite';
|
||||
|
||||
export function migrate(db: Database) {
|
||||
const migrationDirEntries = Deno.readDirSync(import.meta.dirname + '/migrations');
|
||||
const migrations = [];
|
||||
for (const migration of migrationDirEntries) {
|
||||
const content = Deno.readTextFileSync(
|
||||
import.meta.dirname + `/migrations/${migration.name}`,
|
||||
);
|
||||
migrations.push([migration.name, content]);
|
||||
}
|
||||
|
||||
migrations.sort((a, b) => a[0].localeCompare(b[0]));
|
||||
|
||||
const schemaVersionDesired = migrations.length;
|
||||
const { user_version: schemaVersionDb } = db.prepare('pragma user_version').get<
|
||||
{ user_version: number }
|
||||
>()!;
|
||||
|
||||
let applyMigrations = false;
|
||||
if (schemaVersionDb != schemaVersionDesired) {
|
||||
console.error(
|
||||
`The database schema is on ${schemaVersionDb}, but the desired version is ${schemaVersionDesired}`,
|
||||
);
|
||||
applyMigrations = confirm('Apply migrations?');
|
||||
}
|
||||
|
||||
if (!applyMigrations) {
|
||||
return;
|
||||
}
|
||||
|
||||
const migrationsToApply = migrations.slice(schemaVersionDb);
|
||||
const txn = db.transaction(() => {
|
||||
for (const [name, migration] of migrationsToApply) {
|
||||
try {
|
||||
db.exec(migration);
|
||||
} catch (e) {
|
||||
throw new Error(`While running migration ${name}`, { cause: e });
|
||||
}
|
||||
}
|
||||
|
||||
db.exec(`pragma user_version = ${schemaVersionDesired}`);
|
||||
});
|
||||
|
||||
txn.immediate();
|
||||
}
|
||||
115
shelves/backend/main.rs
Normal file
115
shelves/backend/main.rs
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
use std::net::SocketAddr;
|
||||
use std::pin::Pin;
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::handler::HandlerWithoutStateExt;
|
||||
use axum::response::IntoResponse;
|
||||
use std::error::Error;
|
||||
use tokio::net::TcpListener;
|
||||
|
||||
pub type AnyError = Box<dyn Error + Send + Sync + 'static>;
|
||||
pub type Result<T> = core::result::Result<T, AnyError>;
|
||||
|
||||
pub type Dbs = atelier::rusqlite_thread_pool::PoolSender;
|
||||
|
||||
fn migrate(connection: &mut rusqlite::Connection) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn init(connection: &mut rusqlite::Connection) -> rusqlite::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
type Request = axum::extract::Request;
|
||||
type Response = axum::response::Response;
|
||||
type Handler = Box<
|
||||
dyn Fn(Request) -> Pin<Box<dyn Future<Output = Response> + Send + 'static>>
|
||||
+ Send
|
||||
+ Sync
|
||||
+ 'static,
|
||||
>;
|
||||
type Router = atelier::router::Router<Handler>;
|
||||
|
||||
fn make_handler<Fut, F>(f: F) -> Handler
|
||||
where
|
||||
Fut: Future<Output = Response> + Send + 'static,
|
||||
F: FnMut(Request) -> Fut + Clone + Send + Sync + 'static,
|
||||
{
|
||||
Box::new(move |req| {
|
||||
let mut f = f.clone();
|
||||
Box::pin(async move { f(req).await })
|
||||
})
|
||||
}
|
||||
|
||||
async fn get_hello(req: Request) -> Response {
|
||||
"hello".into_response()
|
||||
}
|
||||
|
||||
fn make_router() -> Router {
|
||||
let mut router = atelier::router::Router::new();
|
||||
|
||||
macro_rules! r {
|
||||
($m:expr, $p:expr, $h:expr) => {
|
||||
router.register($m.to_string(), $p.to_string(), make_handler($h))
|
||||
};
|
||||
}
|
||||
|
||||
r!("GET", "/hello", get_hello);
|
||||
|
||||
router
|
||||
}
|
||||
|
||||
async fn serve(
|
||||
dbs: Dbs,
|
||||
router: Arc<Router>,
|
||||
req: axum::extract::Request,
|
||||
) -> axum::response::Response {
|
||||
let method = req.method().as_str();
|
||||
let uri = format!("{}", req.uri());
|
||||
|
||||
let (handler, pathParams) = match router.get(method, &uri) {
|
||||
Ok(h) => h,
|
||||
Err(sc) => {
|
||||
return (http::status::StatusCode::from_u16(sc).unwrap(), "")
|
||||
.into_response()
|
||||
},
|
||||
};
|
||||
|
||||
handler(req).await
|
||||
}
|
||||
|
||||
async fn go() -> Result<()> {
|
||||
let mut conn = rusqlite::Connection::open("./shelves.db")?;
|
||||
migrate(&mut conn)?;
|
||||
|
||||
let (tx, _, _) =
|
||||
atelier::rusqlite_thread_pool::spawn_threadpool_supervised(
|
||||
4,
|
||||
"./shelves.db",
|
||||
|conn| init(conn),
|
||||
)?;
|
||||
|
||||
let addr = "127.0.0.1:8333".parse::<SocketAddr>()?;
|
||||
let listener = TcpListener::bind(addr).await?;
|
||||
|
||||
let router = Arc::new(make_router());
|
||||
let s = move |req: Request| {
|
||||
let tx = tx.clone();
|
||||
let router = router.clone();
|
||||
|
||||
serve(tx, router.clone(), req)
|
||||
};
|
||||
|
||||
axum::serve(listener, s.into_make_service()).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let rt = tokio::runtime::Builder::new_multi_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.unwrap();
|
||||
|
||||
rt.block_on(go()).unwrap();
|
||||
}
|
||||
15
shelves/backend/main.ts
Normal file
15
shelves/backend/main.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import { Database } from '@db/sqlite';
|
||||
|
||||
import { migrate } from '@/backend/db-schema.ts';
|
||||
import { makeRoutes } from '@/backend/routes.ts';
|
||||
|
||||
async function main() {
|
||||
const db = new Database('shelves.db');
|
||||
await migrate(db);
|
||||
|
||||
const handler = makeRoutes(db);
|
||||
|
||||
await Deno.serve({ port: 8444 }, handler);
|
||||
}
|
||||
|
||||
await main();
|
||||
64
shelves/backend/migrations/001_init.sql
Normal file
64
shelves/backend/migrations/001_init.sql
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
create table blob (
|
||||
blob_id integer primary key autoincrement,
|
||||
data blob not null,
|
||||
content_type text not null,
|
||||
|
||||
created_timestamp text not null
|
||||
) strict;
|
||||
|
||||
create table shelf (
|
||||
shelf_id integer primary key autoincrement,
|
||||
shelf_xid text not null unique,
|
||||
|
||||
name text not null,
|
||||
|
||||
created_timestamp text not null
|
||||
) strict;
|
||||
|
||||
create table item (
|
||||
item_id integer primary key autoincrement,
|
||||
item_xid text not null unique,
|
||||
|
||||
item_type text not null,
|
||||
name text not null,
|
||||
image_blob_id integer,
|
||||
|
||||
started_timestamp text,
|
||||
finished_timestamp text,
|
||||
|
||||
rating real,
|
||||
review text not null,
|
||||
|
||||
other_metadata_json text,
|
||||
|
||||
foreign key (image_blob_id) references blob(blob_id)
|
||||
) strict;
|
||||
|
||||
create table shelf_item (
|
||||
shelf_item_id integer primary key,
|
||||
|
||||
item_id integer not null,
|
||||
collection_id integer not null,
|
||||
|
||||
foreign key (item_id) references item(item_id),
|
||||
foreign key (collection_id) references collection(collection_id),
|
||||
unique (item_id, collection_id)
|
||||
) strict;
|
||||
|
||||
create table shelf_arrangement (
|
||||
shelf_arrangement_id integer primary key,
|
||||
shelf_arrangement_xid text not null unique,
|
||||
|
||||
name text not null,
|
||||
|
||||
collection_id integer not null,
|
||||
shelf_arrangement_json_blob text not null,
|
||||
|
||||
foreign key (collection_id) references collection(collection_id)
|
||||
) strict;
|
||||
|
||||
create table activity (
|
||||
activity_id integer primary key,
|
||||
|
||||
activity_json_blob text not null
|
||||
) strict;
|
||||
99
shelves/backend/routes.ts
Normal file
99
shelves/backend/routes.ts
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
import { Match, Method, Router } from '@atelier/router.ts';
|
||||
import { OrPromise } from '@atelier/util.ts';
|
||||
|
||||
import { Database } from '@db/sqlite';
|
||||
|
||||
import { viewShelf, viewShelves } from '@/backend/routes/shelves.ts';
|
||||
import { postItem, viewCreateItem, viewItem, viewItems } from '@/backend/routes/items.ts';
|
||||
import { viewHome } from '@/backend/routes/home.ts';
|
||||
|
||||
const { GET, POST, PATCH } = Method;
|
||||
|
||||
export type RequestCtx = {
|
||||
pathParams: Record<string, string>;
|
||||
db: Database;
|
||||
};
|
||||
|
||||
export type Handler = (req: Request, ctx: RequestCtx) => OrPromise<Response>;
|
||||
|
||||
export function makeRoutes(db: Database) {
|
||||
const router = new Router();
|
||||
const r = (m: Method, p: Match, h: Handler) =>
|
||||
router.register(m, p, (req, pathParams) => {
|
||||
return h(req, { pathParams, db });
|
||||
});
|
||||
|
||||
r(GET, '/', viewHome);
|
||||
r(GET, '/shelves', viewShelves);
|
||||
r(GET, '/shelves/:shelfXid', viewShelf);
|
||||
r(GET, '/items', viewItems);
|
||||
r(GET, '/items/create', viewCreateItem);
|
||||
r(GET, '/items/:itemXid', viewItem);
|
||||
r(POST, '/items/create', postItem);
|
||||
|
||||
r(GET, '/static/js/bundle.js', serveJsBundle);
|
||||
r(GET, '/static/*', serveStatic);
|
||||
|
||||
return router.handle.bind(router);
|
||||
}
|
||||
|
||||
const EXT_TO_MIME = {
|
||||
'.css': 'text/css',
|
||||
};
|
||||
async function serveStatic(req: Request) {
|
||||
const url = new URL(req.url);
|
||||
const path = url.pathname.substring('/static'.length);
|
||||
|
||||
const staticPath = import.meta.dirname + '/../static' + path;
|
||||
const data = await Deno.readFile(staticPath);
|
||||
let mimetype = 'text/plain';
|
||||
for (const [ext, mime] of Object.entries(EXT_TO_MIME)) {
|
||||
if (path.endsWith(ext)) {
|
||||
mimetype = mime;
|
||||
}
|
||||
}
|
||||
|
||||
return new Response(data, { headers: { 'Content-Type': mimetype } });
|
||||
}
|
||||
|
||||
const isProduction = false;
|
||||
let cachedBundlePath: string | null = null;
|
||||
let cachedBundleAt = new Date(0);
|
||||
async function serveJsBundle(req: Request) {
|
||||
if (isProduction) {
|
||||
throw new Error('todo');
|
||||
}
|
||||
|
||||
if (cachedBundlePath === null) {
|
||||
cachedBundlePath = await Deno.makeTempFile({ prefix: 'bundle', suffix: '.js' });
|
||||
}
|
||||
|
||||
const bundlePath = import.meta.dirname + '/../frontend/bundle.ts';
|
||||
const stat = await Deno.stat(bundlePath);
|
||||
const mtime = stat.mtime ?? new Date(0);
|
||||
if (cachedBundleAt > mtime) {
|
||||
const file = await Deno.open(cachedBundlePath);
|
||||
return new Response(file.readable, {
|
||||
headers: { 'Content-Type': 'text/javascript' },
|
||||
});
|
||||
}
|
||||
|
||||
const esbuild = await import('npm:esbuild');
|
||||
const { denoPlugins } = await import('jsr:@luca/esbuild-deno-loader');
|
||||
|
||||
const result = await esbuild.build({
|
||||
plugins: [...denoPlugins()],
|
||||
entryPoints: [import.meta.dirname + '/../frontend/bundle.ts'],
|
||||
outfile: cachedBundlePath,
|
||||
bundle: true,
|
||||
format: 'esm',
|
||||
sourcemap: 'inline',
|
||||
});
|
||||
esbuild.stop();
|
||||
|
||||
cachedBundleAt = new Date();
|
||||
|
||||
return new Response((await Deno.open(cachedBundlePath)).readable, {
|
||||
headers: { 'Content-Type': 'text/javascript' },
|
||||
});
|
||||
}
|
||||
40
shelves/backend/routes/home.ts
Normal file
40
shelves/backend/routes/home.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import { RequestCtx } from '@/backend/routes.ts';
|
||||
|
||||
import { View } from '@/backend/templates/index.ts';
|
||||
import { html } from '@atelier/responses.ts';
|
||||
import { arrayIsEmpty } from '@atelier/array.ts';
|
||||
|
||||
type ActivityType = 'created_item';
|
||||
type Activity = {
|
||||
activityType: ActivityType;
|
||||
itemXid: string;
|
||||
createdTimestamp: string;
|
||||
};
|
||||
|
||||
function Activity(props: { activity: Activity }) {}
|
||||
function ActivityList(props: { activities: Activity[] }) {
|
||||
if (arrayIsEmpty(props.activities)) {
|
||||
return 'No activities yet!';
|
||||
}
|
||||
|
||||
const activities = props.activities.map((activity) => Activity({ activity }));
|
||||
return `
|
||||
<ul>
|
||||
|
||||
</ul>
|
||||
`;
|
||||
}
|
||||
|
||||
export function viewHome(req: Request, { db }: RequestCtx) {
|
||||
const recentActivities: Activity[] = db.prepare(`
|
||||
select activity_json_blob from activity
|
||||
order by json_extract(activity_json_blob, '$.createdTimestamp')
|
||||
limit 20
|
||||
`).values<[string]>().map(([blob]) => JSON.parse(blob));
|
||||
|
||||
const body = `
|
||||
<h1>Recent Activity</h1>
|
||||
${ActivityList({ activities: recentActivities })}
|
||||
`;
|
||||
return html(View({ title: 'Home', body }));
|
||||
}
|
||||
55
shelves/backend/routes/items.ts
Normal file
55
shelves/backend/routes/items.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
import { RequestCtx } from '@/backend/routes.ts';
|
||||
|
||||
import { html } from '@atelier/responses.ts';
|
||||
import { Form, View } from '@/backend/templates/index.ts';
|
||||
|
||||
export function viewItems(req: Request, ctx: RequestCtx) {
|
||||
return new Response('');
|
||||
}
|
||||
|
||||
export function viewItem(req: Request, ctx: RequestCtx) {
|
||||
return new Response('');
|
||||
}
|
||||
|
||||
/*
|
||||
item_type text not null,
|
||||
name text not null,
|
||||
image_blob_id integer,
|
||||
|
||||
started_timestamp text,
|
||||
finished_timestamp text,
|
||||
|
||||
rating real,
|
||||
review text,
|
||||
|
||||
other_metadata_json text,
|
||||
|
||||
foreign key (image_blob_id) references blob(blob_id)
|
||||
|
||||
*/
|
||||
|
||||
export function viewCreateItem(req: Request, ctx: RequestCtx) {
|
||||
const body = `
|
||||
${
|
||||
Form({
|
||||
hasFileData: true,
|
||||
action: '/items/create',
|
||||
fields: [
|
||||
{ name: 'name', type: 'text', label: 'Name' },
|
||||
{ name: 'image', type: 'file', label: 'Image', required: false },
|
||||
{ name: 'rating', type: 'number', label: 'Rating', required: false },
|
||||
{ name: 'review', type: 'textarea', label: 'Review', required: false },
|
||||
],
|
||||
})
|
||||
}
|
||||
`;
|
||||
|
||||
return html(View({ title: 'Create item', body }));
|
||||
}
|
||||
|
||||
export async function postItem(req: Request, ctx: RequestCtx) {
|
||||
const form = await req.formData();
|
||||
console.debug({ form });
|
||||
|
||||
return new Response('');
|
||||
}
|
||||
9
shelves/backend/routes/shelves.ts
Normal file
9
shelves/backend/routes/shelves.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import { RequestCtx } from '@/backend/routes.ts';
|
||||
|
||||
export function viewShelves(req: Request, ctx: RequestCtx) {
|
||||
return new Response('');
|
||||
}
|
||||
|
||||
export function viewShelf(req: Request, ctx: RequestCtx) {
|
||||
return new Response('');
|
||||
}
|
||||
57
shelves/backend/templates/index.ts
Normal file
57
shelves/backend/templates/index.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
export function View(props: { title: string; body: string }) {
|
||||
return `
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title>${props.title} - Shelves</title>
|
||||
</head>
|
||||
<body>
|
||||
${props.body}
|
||||
<script src="/static/js/bundle.js" type="text/javascript" module></script>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
}
|
||||
|
||||
export type FormFieldType = 'text' | 'textarea' | 'file' | 'number';
|
||||
export type FormFieldProps = {
|
||||
name: string;
|
||||
type: FormFieldType;
|
||||
label: string;
|
||||
error?: string;
|
||||
required?: boolean;
|
||||
};
|
||||
export function FormField(props: FormFieldProps) {
|
||||
const input = () => {
|
||||
const attrs = `name='${props.name}' id="${props.name}" ${
|
||||
(props.required ?? true) && "required aria-required='true'"
|
||||
}`;
|
||||
if (props.type === 'textarea') {
|
||||
return `<textarea ${attrs}></textarea>`;
|
||||
} else {
|
||||
return `<input type="${props.type}" ${attrs}>`;
|
||||
}
|
||||
};
|
||||
|
||||
return `
|
||||
<div class="form-input">
|
||||
<label for="${props.name}">${props.label}</label>
|
||||
${input()}
|
||||
<div class="error" id="${props.name}-error" aria-live="polite">${
|
||||
props.error ?? ''
|
||||
}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
export function Form(
|
||||
props: { action: string; fields: FormFieldProps[]; hasFileData?: boolean },
|
||||
) {
|
||||
return `
|
||||
<form action="${props.action}" method="POST" ${
|
||||
props.hasFileData ? "enctype='multipart/form-data'" : ''
|
||||
}>
|
||||
${props.fields.map(FormField).join('')}
|
||||
<input type="submit" value="Submit">
|
||||
</form>
|
||||
`;
|
||||
}
|
||||
33
shelves/deno.jsonc
Normal file
33
shelves/deno.jsonc
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"checkJs": true,
|
||||
"lib": [
|
||||
"esnext",
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"deno.window",
|
||||
"deno.unstable"
|
||||
]
|
||||
},
|
||||
"tasks": {
|
||||
"run": "deno run -A backend/main.ts",
|
||||
"check": "deno check backend/main.ts",
|
||||
"watch": "deno run --watch -A backend/main.ts",
|
||||
"check-watch": "deno run --check --watch -A backend/main.ts"
|
||||
},
|
||||
"imports": {
|
||||
"@atelier/": "../ts/",
|
||||
"@luca/esbuild-deno-loader": "jsr:@luca/esbuild-deno-loader@^0.11.1",
|
||||
"@std/http": "jsr:@std/http",
|
||||
"@db/sqlite": "jsr:@db/sqlite",
|
||||
"@/": "./",
|
||||
"esbuild": "npm:esbuild@^0.24.2",
|
||||
"@htmx": "https://unpkg.com/htmx.org@2.0.4/dist/htmx.js"
|
||||
},
|
||||
"fmt": {
|
||||
"semiColons": true,
|
||||
"singleQuote": true,
|
||||
"useTabs": true,
|
||||
"lineWidth": 90
|
||||
}
|
||||
}
|
||||
289
shelves/deno.lock
Normal file
289
shelves/deno.lock
Normal file
|
|
@ -0,0 +1,289 @@
|
|||
{
|
||||
"version": "4",
|
||||
"specifiers": {
|
||||
"jsr:@db/sqlite@*": "0.12.0",
|
||||
"jsr:@deno/cache-dir@0.13.2": "0.13.2",
|
||||
"jsr:@deno/emit@*": "0.46.0",
|
||||
"jsr:@deno/graph@~0.73.1": "0.73.1",
|
||||
"jsr:@denosaurs/plug@1": "1.0.6",
|
||||
"jsr:@luca/esbuild-deno-loader@*": "0.11.1",
|
||||
"jsr:@luca/esbuild-deno-loader@~0.11.1": "0.11.1",
|
||||
"jsr:@std/assert@0.217": "0.217.0",
|
||||
"jsr:@std/assert@0.221": "0.221.0",
|
||||
"jsr:@std/assert@0.223": "0.223.0",
|
||||
"jsr:@std/bytes@0.223": "0.223.0",
|
||||
"jsr:@std/bytes@^1.0.2": "1.0.4",
|
||||
"jsr:@std/cli@^1.0.6": "1.0.6",
|
||||
"jsr:@std/encoding@0.221": "0.221.0",
|
||||
"jsr:@std/encoding@^1.0.5": "1.0.5",
|
||||
"jsr:@std/fmt@0.221": "0.221.0",
|
||||
"jsr:@std/fmt@0.223": "0.223.0",
|
||||
"jsr:@std/fmt@^1.0.2": "1.0.2",
|
||||
"jsr:@std/fs@0.221": "0.221.0",
|
||||
"jsr:@std/fs@0.223": "0.223.0",
|
||||
"jsr:@std/http@*": "1.0.7",
|
||||
"jsr:@std/io@0.223": "0.223.0",
|
||||
"jsr:@std/media-types@^1.0.3": "1.0.3",
|
||||
"jsr:@std/net@^1.0.4": "1.0.4",
|
||||
"jsr:@std/path@0.217": "0.217.0",
|
||||
"jsr:@std/path@0.221": "0.221.0",
|
||||
"jsr:@std/path@0.223": "0.223.0",
|
||||
"jsr:@std/path@^1.0.6": "1.0.6",
|
||||
"jsr:@std/streams@^1.0.6": "1.0.6",
|
||||
"npm:esbuild@*": "0.24.2",
|
||||
"npm:esbuild@~0.24.2": "0.24.2"
|
||||
},
|
||||
"jsr": {
|
||||
"@db/sqlite@0.12.0": {
|
||||
"integrity": "dd1ef7f621ad50fc1e073a1c3609c4470bd51edc0994139c5bf9851de7a6d85f",
|
||||
"dependencies": [
|
||||
"jsr:@denosaurs/plug",
|
||||
"jsr:@std/path@0.217"
|
||||
]
|
||||
},
|
||||
"@deno/cache-dir@0.13.2": {
|
||||
"integrity": "c22419dfe27ab85f345bee487aaaadba498b005cce3644e9d2528db035c5454d",
|
||||
"dependencies": [
|
||||
"jsr:@deno/graph",
|
||||
"jsr:@std/fmt@0.223",
|
||||
"jsr:@std/fs@0.223",
|
||||
"jsr:@std/io",
|
||||
"jsr:@std/path@0.223"
|
||||
]
|
||||
},
|
||||
"@deno/emit@0.46.0": {
|
||||
"integrity": "e276be2c77bac1b93caf775762e2a49a54cb00da2d48ca2b01ed8d7cba9d082c",
|
||||
"dependencies": [
|
||||
"jsr:@deno/cache-dir",
|
||||
"jsr:@std/path@0.223"
|
||||
]
|
||||
},
|
||||
"@deno/graph@0.73.1": {
|
||||
"integrity": "cd69639d2709d479037d5ce191a422eabe8d71bb68b0098344f6b07411c84d41"
|
||||
},
|
||||
"@denosaurs/plug@1.0.6": {
|
||||
"integrity": "6cf5b9daba7799837b9ffbe89f3450510f588fafef8115ddab1ff0be9cb7c1a7",
|
||||
"dependencies": [
|
||||
"jsr:@std/encoding@0.221",
|
||||
"jsr:@std/fmt@0.221",
|
||||
"jsr:@std/fs@0.221",
|
||||
"jsr:@std/path@0.221"
|
||||
]
|
||||
},
|
||||
"@luca/esbuild-deno-loader@0.11.1": {
|
||||
"integrity": "dc020d16d75b591f679f6b9288b10f38bdb4f24345edb2f5732affa1d9885267",
|
||||
"dependencies": [
|
||||
"jsr:@std/bytes@^1.0.2",
|
||||
"jsr:@std/encoding@^1.0.5",
|
||||
"jsr:@std/path@^1.0.6"
|
||||
]
|
||||
},
|
||||
"@std/assert@0.217.0": {
|
||||
"integrity": "c98e279362ca6982d5285c3b89517b757c1e3477ee9f14eb2fdf80a45aaa9642"
|
||||
},
|
||||
"@std/assert@0.221.0": {
|
||||
"integrity": "a5f1aa6e7909dbea271754fd4ab3f4e687aeff4873b4cef9a320af813adb489a"
|
||||
},
|
||||
"@std/assert@0.223.0": {
|
||||
"integrity": "eb8d6d879d76e1cc431205bd346ed4d88dc051c6366365b1af47034b0670be24"
|
||||
},
|
||||
"@std/bytes@0.223.0": {
|
||||
"integrity": "84b75052cd8680942c397c2631318772b295019098f40aac5c36cead4cba51a8"
|
||||
},
|
||||
"@std/bytes@1.0.4": {
|
||||
"integrity": "11a0debe522707c95c7b7ef89b478c13fb1583a7cfb9a85674cd2cc2e3a28abc"
|
||||
},
|
||||
"@std/cli@1.0.6": {
|
||||
"integrity": "d22d8b38c66c666d7ad1f2a66c5b122da1704f985d3c47f01129f05abb6c5d3d"
|
||||
},
|
||||
"@std/encoding@0.221.0": {
|
||||
"integrity": "d1dd76ef0dc5d14088411e6dc1dede53bf8308c95d1537df1214c97137208e45"
|
||||
},
|
||||
"@std/encoding@1.0.5": {
|
||||
"integrity": "ecf363d4fc25bd85bd915ff6733a7e79b67e0e7806334af15f4645c569fefc04"
|
||||
},
|
||||
"@std/fmt@0.221.0": {
|
||||
"integrity": "379fed69bdd9731110f26b9085aeb740606b20428ce6af31ef6bd45ef8efa62a"
|
||||
},
|
||||
"@std/fmt@0.223.0": {
|
||||
"integrity": "6deb37794127dfc7d7bded2586b9fc6f5d50e62a8134846608baf71ffc1a5208"
|
||||
},
|
||||
"@std/fmt@1.0.2": {
|
||||
"integrity": "87e9dfcdd3ca7c066e0c3c657c1f987c82888eb8103a3a3baa62684ffeb0f7a7"
|
||||
},
|
||||
"@std/fs@0.221.0": {
|
||||
"integrity": "028044450299de8ed5a716ade4e6d524399f035513b85913794f4e81f07da286",
|
||||
"dependencies": [
|
||||
"jsr:@std/assert@0.221",
|
||||
"jsr:@std/path@0.221"
|
||||
]
|
||||
},
|
||||
"@std/fs@0.223.0": {
|
||||
"integrity": "3b4b0550b2c524cbaaa5a9170c90e96cbb7354e837ad1bdaf15fc9df1ae9c31c"
|
||||
},
|
||||
"@std/http@1.0.7": {
|
||||
"integrity": "9b904fc256678a5c9759f1a53a24a3fdcc59d83dc62099bb472683b6f819194c",
|
||||
"dependencies": [
|
||||
"jsr:@std/cli",
|
||||
"jsr:@std/encoding@^1.0.5",
|
||||
"jsr:@std/fmt@^1.0.2",
|
||||
"jsr:@std/media-types",
|
||||
"jsr:@std/net",
|
||||
"jsr:@std/path@^1.0.6",
|
||||
"jsr:@std/streams"
|
||||
]
|
||||
},
|
||||
"@std/io@0.223.0": {
|
||||
"integrity": "2d8c3c2ab3a515619b90da2c6ff5ea7b75a94383259ef4d02116b228393f84f1",
|
||||
"dependencies": [
|
||||
"jsr:@std/assert@0.223",
|
||||
"jsr:@std/bytes@0.223"
|
||||
]
|
||||
},
|
||||
"@std/media-types@1.0.3": {
|
||||
"integrity": "b12d30a7852f7578f4d210622df713bbfd1cbdd9b4ec2eaf5c1845ab70bab159"
|
||||
},
|
||||
"@std/net@1.0.4": {
|
||||
"integrity": "2f403b455ebbccf83d8a027d29c5a9e3a2452fea39bb2da7f2c04af09c8bc852"
|
||||
},
|
||||
"@std/path@0.217.0": {
|
||||
"integrity": "1217cc25534bca9a2f672d7fe7c6f356e4027df400c0e85c0ef3e4343bc67d11",
|
||||
"dependencies": [
|
||||
"jsr:@std/assert@0.217"
|
||||
]
|
||||
},
|
||||
"@std/path@0.221.0": {
|
||||
"integrity": "0a36f6b17314ef653a3a1649740cc8db51b25a133ecfe838f20b79a56ebe0095",
|
||||
"dependencies": [
|
||||
"jsr:@std/assert@0.221"
|
||||
]
|
||||
},
|
||||
"@std/path@0.223.0": {
|
||||
"integrity": "593963402d7e6597f5a6e620931661053572c982fc014000459edc1f93cc3989",
|
||||
"dependencies": [
|
||||
"jsr:@std/assert@0.223"
|
||||
]
|
||||
},
|
||||
"@std/path@1.0.6": {
|
||||
"integrity": "ab2c55f902b380cf28e0eec501b4906e4c1960d13f00e11cfbcd21de15f18fed"
|
||||
},
|
||||
"@std/streams@1.0.6": {
|
||||
"integrity": "022ed94e380d06b4d91c49eb70241b7289ab78b8c2b4c4bbb7eb265e4997c25c"
|
||||
}
|
||||
},
|
||||
"npm": {
|
||||
"@esbuild/aix-ppc64@0.24.2": {
|
||||
"integrity": "sha512-thpVCb/rhxE/BnMLQ7GReQLLN8q9qbHmI55F4489/ByVg2aQaQ6kbcLb6FHkocZzQhxc4gx0sCk0tJkKBFzDhA=="
|
||||
},
|
||||
"@esbuild/android-arm64@0.24.2": {
|
||||
"integrity": "sha512-cNLgeqCqV8WxfcTIOeL4OAtSmL8JjcN6m09XIgro1Wi7cF4t/THaWEa7eL5CMoMBdjoHOTh/vwTO/o2TRXIyzg=="
|
||||
},
|
||||
"@esbuild/android-arm@0.24.2": {
|
||||
"integrity": "sha512-tmwl4hJkCfNHwFB3nBa8z1Uy3ypZpxqxfTQOcHX+xRByyYgunVbZ9MzUUfb0RxaHIMnbHagwAxuTL+tnNM+1/Q=="
|
||||
},
|
||||
"@esbuild/android-x64@0.24.2": {
|
||||
"integrity": "sha512-B6Q0YQDqMx9D7rvIcsXfmJfvUYLoP722bgfBlO5cGvNVb5V/+Y7nhBE3mHV9OpxBf4eAS2S68KZztiPaWq4XYw=="
|
||||
},
|
||||
"@esbuild/darwin-arm64@0.24.2": {
|
||||
"integrity": "sha512-kj3AnYWc+CekmZnS5IPu9D+HWtUI49hbnyqk0FLEJDbzCIQt7hg7ucF1SQAilhtYpIujfaHr6O0UHlzzSPdOeA=="
|
||||
},
|
||||
"@esbuild/darwin-x64@0.24.2": {
|
||||
"integrity": "sha512-WeSrmwwHaPkNR5H3yYfowhZcbriGqooyu3zI/3GGpF8AyUdsrrP0X6KumITGA9WOyiJavnGZUwPGvxvwfWPHIA=="
|
||||
},
|
||||
"@esbuild/freebsd-arm64@0.24.2": {
|
||||
"integrity": "sha512-UN8HXjtJ0k/Mj6a9+5u6+2eZ2ERD7Edt1Q9IZiB5UZAIdPnVKDoG7mdTVGhHJIeEml60JteamR3qhsr1r8gXvg=="
|
||||
},
|
||||
"@esbuild/freebsd-x64@0.24.2": {
|
||||
"integrity": "sha512-TvW7wE/89PYW+IevEJXZ5sF6gJRDY/14hyIGFXdIucxCsbRmLUcjseQu1SyTko+2idmCw94TgyaEZi9HUSOe3Q=="
|
||||
},
|
||||
"@esbuild/linux-arm64@0.24.2": {
|
||||
"integrity": "sha512-7HnAD6074BW43YvvUmE/35Id9/NB7BeX5EoNkK9obndmZBUk8xmJJeU7DwmUeN7tkysslb2eSl6CTrYz6oEMQg=="
|
||||
},
|
||||
"@esbuild/linux-arm@0.24.2": {
|
||||
"integrity": "sha512-n0WRM/gWIdU29J57hJyUdIsk0WarGd6To0s+Y+LwvlC55wt+GT/OgkwoXCXvIue1i1sSNWblHEig00GBWiJgfA=="
|
||||
},
|
||||
"@esbuild/linux-ia32@0.24.2": {
|
||||
"integrity": "sha512-sfv0tGPQhcZOgTKO3oBE9xpHuUqguHvSo4jl+wjnKwFpapx+vUDcawbwPNuBIAYdRAvIDBfZVvXprIj3HA+Ugw=="
|
||||
},
|
||||
"@esbuild/linux-loong64@0.24.2": {
|
||||
"integrity": "sha512-CN9AZr8kEndGooS35ntToZLTQLHEjtVB5n7dl8ZcTZMonJ7CCfStrYhrzF97eAecqVbVJ7APOEe18RPI4KLhwQ=="
|
||||
},
|
||||
"@esbuild/linux-mips64el@0.24.2": {
|
||||
"integrity": "sha512-iMkk7qr/wl3exJATwkISxI7kTcmHKE+BlymIAbHO8xanq/TjHaaVThFF6ipWzPHryoFsesNQJPE/3wFJw4+huw=="
|
||||
},
|
||||
"@esbuild/linux-ppc64@0.24.2": {
|
||||
"integrity": "sha512-shsVrgCZ57Vr2L8mm39kO5PPIb+843FStGt7sGGoqiiWYconSxwTiuswC1VJZLCjNiMLAMh34jg4VSEQb+iEbw=="
|
||||
},
|
||||
"@esbuild/linux-riscv64@0.24.2": {
|
||||
"integrity": "sha512-4eSFWnU9Hhd68fW16GD0TINewo1L6dRrB+oLNNbYyMUAeOD2yCK5KXGK1GH4qD/kT+bTEXjsyTCiJGHPZ3eM9Q=="
|
||||
},
|
||||
"@esbuild/linux-s390x@0.24.2": {
|
||||
"integrity": "sha512-S0Bh0A53b0YHL2XEXC20bHLuGMOhFDO6GN4b3YjRLK//Ep3ql3erpNcPlEFed93hsQAjAQDNsvcK+hV90FubSw=="
|
||||
},
|
||||
"@esbuild/linux-x64@0.24.2": {
|
||||
"integrity": "sha512-8Qi4nQcCTbLnK9WoMjdC9NiTG6/E38RNICU6sUNqK0QFxCYgoARqVqxdFmWkdonVsvGqWhmm7MO0jyTqLqwj0Q=="
|
||||
},
|
||||
"@esbuild/netbsd-arm64@0.24.2": {
|
||||
"integrity": "sha512-wuLK/VztRRpMt9zyHSazyCVdCXlpHkKm34WUyinD2lzK07FAHTq0KQvZZlXikNWkDGoT6x3TD51jKQ7gMVpopw=="
|
||||
},
|
||||
"@esbuild/netbsd-x64@0.24.2": {
|
||||
"integrity": "sha512-VefFaQUc4FMmJuAxmIHgUmfNiLXY438XrL4GDNV1Y1H/RW3qow68xTwjZKfj/+Plp9NANmzbH5R40Meudu8mmw=="
|
||||
},
|
||||
"@esbuild/openbsd-arm64@0.24.2": {
|
||||
"integrity": "sha512-YQbi46SBct6iKnszhSvdluqDmxCJA+Pu280Av9WICNwQmMxV7nLRHZfjQzwbPs3jeWnuAhE9Jy0NrnJ12Oz+0A=="
|
||||
},
|
||||
"@esbuild/openbsd-x64@0.24.2": {
|
||||
"integrity": "sha512-+iDS6zpNM6EnJyWv0bMGLWSWeXGN/HTaF/LXHXHwejGsVi+ooqDfMCCTerNFxEkM3wYVcExkeGXNqshc9iMaOA=="
|
||||
},
|
||||
"@esbuild/sunos-x64@0.24.2": {
|
||||
"integrity": "sha512-hTdsW27jcktEvpwNHJU4ZwWFGkz2zRJUz8pvddmXPtXDzVKTTINmlmga3ZzwcuMpUvLw7JkLy9QLKyGpD2Yxig=="
|
||||
},
|
||||
"@esbuild/win32-arm64@0.24.2": {
|
||||
"integrity": "sha512-LihEQ2BBKVFLOC9ZItT9iFprsE9tqjDjnbulhHoFxYQtQfai7qfluVODIYxt1PgdoyQkz23+01rzwNwYfutxUQ=="
|
||||
},
|
||||
"@esbuild/win32-ia32@0.24.2": {
|
||||
"integrity": "sha512-q+iGUwfs8tncmFC9pcnD5IvRHAzmbwQ3GPS5/ceCyHdjXubwQWI12MKWSNSMYLJMq23/IUCvJMS76PDqXe1fxA=="
|
||||
},
|
||||
"@esbuild/win32-x64@0.24.2": {
|
||||
"integrity": "sha512-7VTgWzgMGvup6aSqDPLiW5zHaxYJGTO4OokMjIlrCtf+VpEL+cXKtCvg723iguPYI5oaUNdS+/V7OU2gvXVWEg=="
|
||||
},
|
||||
"esbuild@0.24.2": {
|
||||
"integrity": "sha512-+9egpBW8I3CD5XPe0n6BfT5fxLzxrlDzqydF3aviG+9ni1lDC/OvMHcxqEFV0+LANZG5R1bFMWfUrjVsdwxJvA==",
|
||||
"dependencies": [
|
||||
"@esbuild/aix-ppc64",
|
||||
"@esbuild/android-arm",
|
||||
"@esbuild/android-arm64",
|
||||
"@esbuild/android-x64",
|
||||
"@esbuild/darwin-arm64",
|
||||
"@esbuild/darwin-x64",
|
||||
"@esbuild/freebsd-arm64",
|
||||
"@esbuild/freebsd-x64",
|
||||
"@esbuild/linux-arm",
|
||||
"@esbuild/linux-arm64",
|
||||
"@esbuild/linux-ia32",
|
||||
"@esbuild/linux-loong64",
|
||||
"@esbuild/linux-mips64el",
|
||||
"@esbuild/linux-ppc64",
|
||||
"@esbuild/linux-riscv64",
|
||||
"@esbuild/linux-s390x",
|
||||
"@esbuild/linux-x64",
|
||||
"@esbuild/netbsd-arm64",
|
||||
"@esbuild/netbsd-x64",
|
||||
"@esbuild/openbsd-arm64",
|
||||
"@esbuild/openbsd-x64",
|
||||
"@esbuild/sunos-x64",
|
||||
"@esbuild/win32-arm64",
|
||||
"@esbuild/win32-ia32",
|
||||
"@esbuild/win32-x64"
|
||||
]
|
||||
}
|
||||
},
|
||||
"workspace": {
|
||||
"dependencies": [
|
||||
"jsr:@db/sqlite@*",
|
||||
"jsr:@luca/esbuild-deno-loader@~0.11.1",
|
||||
"jsr:@std/http@*",
|
||||
"npm:esbuild@~0.24.2"
|
||||
]
|
||||
}
|
||||
}
|
||||
3
shelves/frontend/bundle.ts
Normal file
3
shelves/frontend/bundle.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
import { skibidi } from '@htmx';
|
||||
|
||||
console.debug({ skibidi });
|
||||
3
ts/array.ts
Normal file
3
ts/array.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export function arrayIsEmpty(a: any[]): boolean {
|
||||
return a.length === 0;
|
||||
}
|
||||
3
ts/promise.ts
Normal file
3
ts/promise.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export function sleep(timeoutMs: number): Promise<void> {
|
||||
return new Promise((res) => setTimeout(res, timeoutMs));
|
||||
}
|
||||
13
ts/responses.ts
Normal file
13
ts/responses.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
export type Body = ConstructorParameters<typeof Response>[0];
|
||||
export type ResponseOptions = ConstructorParameters<typeof Response>[1];
|
||||
|
||||
export function badRequest(body?: Body, options?: ResponseOptions): Response {
|
||||
return new Response(body, { status: 400, ...options });
|
||||
}
|
||||
|
||||
export function html(body: Body, options?: ResponseOptions): Response {
|
||||
return new Response(body, {
|
||||
...options,
|
||||
headers: { 'Content-Type': 'text/html', ...options?.headers },
|
||||
});
|
||||
}
|
||||
77
ts/router.ts
Normal file
77
ts/router.ts
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
import { STATUS_CODE } from '@std/http';
|
||||
|
||||
import { OrPromise } from './util.ts';
|
||||
|
||||
export type Method = string;
|
||||
|
||||
export const Method = {
|
||||
GET: 'GET',
|
||||
POST: 'POST',
|
||||
PUT: 'PUT',
|
||||
PATCH: 'PATCH',
|
||||
Any: '___INTERNAL_ANY',
|
||||
};
|
||||
|
||||
export type Handler = (
|
||||
request: Request,
|
||||
pathParams: Record<string, string>,
|
||||
) => OrPromise<Response>;
|
||||
|
||||
export type Match = string | URLPattern;
|
||||
export class Router {
|
||||
#pathnameCache: Map<string, URLPattern>;
|
||||
#routes: Map<URLPattern, Map<Method, Handler>>;
|
||||
|
||||
constructor() {
|
||||
this.#routes = new Map();
|
||||
this.#pathnameCache = new Map();
|
||||
}
|
||||
|
||||
register(method: Method, match: Match, handler: Handler) {
|
||||
const urlPattern = (match instanceof URLPattern) ? match : this.#cachePathname(match);
|
||||
if (!this.#routes.has(urlPattern)) {
|
||||
this.#routes.set(urlPattern, new Map());
|
||||
}
|
||||
|
||||
const methodMap = this.#routes.get(urlPattern)!;
|
||||
method = method.toUpperCase();
|
||||
methodMap.set(method, handler);
|
||||
}
|
||||
|
||||
#cachePathname(pathname: string) {
|
||||
const cached = this.#pathnameCache.get(pathname);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const pattern = new URLPattern({ pathname });
|
||||
this.#pathnameCache.set(pathname, pattern);
|
||||
|
||||
return pattern;
|
||||
}
|
||||
|
||||
async handle(req: Request): Promise<Response> {
|
||||
const method = req.method.toUpperCase();
|
||||
|
||||
for (const [pattern, methodMap] of this.#routes.entries()) {
|
||||
const match = pattern.exec(req.url);
|
||||
if (!match) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let handler = methodMap.get(method);
|
||||
if (!handler) {
|
||||
handler = methodMap.get(Method.Any);
|
||||
}
|
||||
if (!handler) {
|
||||
return new Response('Method not allowed', {
|
||||
status: STATUS_CODE.MethodNotAllowed,
|
||||
});
|
||||
}
|
||||
|
||||
return handler(req, match.pathname.groups as Record<string, string>);
|
||||
}
|
||||
|
||||
return new Response('Not found', { status: STATUS_CODE.NotFound });
|
||||
}
|
||||
}
|
||||
1
ts/util.ts
Normal file
1
ts/util.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export type OrPromise<T> = T | Promise<T>;
|
||||
Loading…
Reference in a new issue