Initial commit: C3 (Cached Content Conduit)
A minimal CDN for serving versioned, gzip-compressed static assets, backed by SQLite and an actor-based (smarm) storage server, with HTTP serving via urus. - CLI: create/add/archive packages and versions, serve over HTTP - Storage: SQLite-backed asset store (src/store.rs) with gzip compression on ingest and content-type sniffing by extension - HTTP: GET /packages (list packages+versions), GET /assets/:package/:version/:filename (serves gzip or transparently decompressed, with immutable long-lived cache headers)
This commit is contained in:
+291
@@ -0,0 +1,291 @@
|
||||
mod store;
|
||||
|
||||
use rusqlite::{params, Connection};
|
||||
use std::io::Read;
|
||||
use std::sync::OnceLock;
|
||||
use flate2::read::GzDecoder;
|
||||
use serde::Serialize;
|
||||
use urus::{Config, Conn, Next, Pipeline, Router, serve_with};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct AssetPayload {
|
||||
pub gzipped_bytes: Vec<u8>,
|
||||
pub mime_type: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct PackageListing {
|
||||
name: String,
|
||||
versions: Vec<String>,
|
||||
}
|
||||
|
||||
pub enum GenServerMsg {
|
||||
FetchAsset {
|
||||
package: String,
|
||||
version: String,
|
||||
filename: String,
|
||||
reply_to: smarm::Sender<Result<Option<AssetPayload>, String>>,
|
||||
},
|
||||
ListPackages {
|
||||
reply_to: smarm::Sender<Vec<PackageListing>>,
|
||||
},
|
||||
}
|
||||
|
||||
pub struct AssetStoreServer {
|
||||
conn: Connection,
|
||||
}
|
||||
|
||||
impl AssetStoreServer {
|
||||
pub fn new() -> Self {
|
||||
let conn = store::open().expect("Failed to open SQLite database");
|
||||
Self { conn }
|
||||
}
|
||||
|
||||
pub fn loop_runner(self, rx: smarm::Receiver<GenServerMsg>) {
|
||||
while let Ok(msg) = rx.recv() {
|
||||
match msg {
|
||||
GenServerMsg::FetchAsset { package, version, filename, reply_to } => {
|
||||
let lookup = self.fetch_asset(&package, &version, &filename);
|
||||
let _ = reply_to.send(lookup);
|
||||
}
|
||||
GenServerMsg::ListPackages { reply_to } => {
|
||||
let listing = self.list_packages();
|
||||
let _ = reply_to.send(listing);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn fetch_asset(&self, package: &str, version: &str, filename: &str) -> Result<Option<AssetPayload>, String> {
|
||||
let mut stmt = self.conn
|
||||
.prepare(
|
||||
"SELECT gzipped_bytes, mime_type FROM versions
|
||||
WHERE package = ? AND version = ? AND filename = ?",
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let mut rows = stmt.query(params![package, version, filename]).map_err(|e| e.to_string())?;
|
||||
|
||||
if let Some(row) = rows.next().map_err(|e| e.to_string())? {
|
||||
let gzipped_bytes: Vec<u8> = row.get(0).map_err(|e| e.to_string())?;
|
||||
let mime_type: String = row.get(1).map_err(|e| e.to_string())?;
|
||||
Ok(Some(AssetPayload { gzipped_bytes, mime_type }))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
fn list_packages(&self) -> Vec<PackageListing> {
|
||||
let mut listings = Vec::new();
|
||||
let mut stmt = match self.conn.prepare(
|
||||
"SELECT name FROM packages WHERE archived = 0 ORDER BY name",
|
||||
) {
|
||||
Ok(s) => s,
|
||||
Err(_) => return listings,
|
||||
};
|
||||
let names: Vec<String> = match stmt.query_map([], |r| r.get::<_, String>(0)) {
|
||||
Ok(rows) => rows.filter_map(Result::ok).collect(),
|
||||
Err(_) => return listings,
|
||||
};
|
||||
|
||||
for name in names {
|
||||
let mut vstmt = match self.conn.prepare(
|
||||
"SELECT version FROM versions WHERE package = ? AND archived = 0 ORDER BY version",
|
||||
) {
|
||||
Ok(s) => s,
|
||||
Err(_) => continue,
|
||||
};
|
||||
let versions: Vec<String> = match vstmt.query_map(params![name], |r| r.get::<_, String>(0)) {
|
||||
Ok(rows) => rows.filter_map(Result::ok).collect(),
|
||||
Err(_) => Vec::new(),
|
||||
};
|
||||
listings.push(PackageListing { name, versions });
|
||||
}
|
||||
listings
|
||||
}
|
||||
}
|
||||
|
||||
fn print_usage() {
|
||||
eprintln!(
|
||||
"usage:\n\
|
||||
\x20 ccc create <package>\n\
|
||||
\x20 ccc add <package> <filepath> <version>\n\
|
||||
\x20 ccc archive <package> [<version>]\n\
|
||||
\x20 ccc serve [-p|--port <port>]\n"
|
||||
);
|
||||
}
|
||||
|
||||
fn run_cli() -> Option<u16> {
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
match args.get(1).map(String::as_str) {
|
||||
Some("create") => {
|
||||
let Some(package) = args.get(2) else {
|
||||
print_usage();
|
||||
std::process::exit(2);
|
||||
};
|
||||
match store::create_package(package) {
|
||||
Ok(()) => println!("created package '{package}'"),
|
||||
Err(e) => {
|
||||
eprintln!("error: {e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
Some("add") => {
|
||||
let (Some(package), Some(filepath), Some(version)) =
|
||||
(args.get(2), args.get(3), args.get(4))
|
||||
else {
|
||||
print_usage();
|
||||
std::process::exit(2);
|
||||
};
|
||||
match store::add_version(package, filepath, version) {
|
||||
Ok(()) => println!("added {package}@{version} from {filepath}"),
|
||||
Err(e) => {
|
||||
eprintln!("error: {e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
Some("archive") => {
|
||||
let Some(package) = args.get(2) else {
|
||||
print_usage();
|
||||
std::process::exit(2);
|
||||
};
|
||||
let version = args.get(3).map(String::as_str);
|
||||
match store::archive(package, version) {
|
||||
Ok(()) => match version {
|
||||
Some(v) => println!("archived {package}@{v}"),
|
||||
None => println!("archived package '{package}'"),
|
||||
},
|
||||
Err(e) => {
|
||||
eprintln!("error: {e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
Some("serve") | None => {
|
||||
let mut port: u16 = 8333;
|
||||
let mut i = 2;
|
||||
while i < args.len() {
|
||||
match args[i].as_str() {
|
||||
"-p" | "--port" => {
|
||||
let Some(v) = args.get(i + 1).and_then(|s| s.parse().ok()) else {
|
||||
eprintln!("error: --port requires a numeric argument");
|
||||
std::process::exit(2);
|
||||
};
|
||||
port = v;
|
||||
i += 2;
|
||||
}
|
||||
other => {
|
||||
eprintln!("unknown option '{other}'");
|
||||
print_usage();
|
||||
std::process::exit(2);
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(port)
|
||||
}
|
||||
Some(other) => {
|
||||
eprintln!("unknown subcommand '{other}'");
|
||||
print_usage();
|
||||
std::process::exit(2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The store actor can only be spawned from inside `smarm::Runtime::run()`
|
||||
// (i.e. from inside a connection actor, once `serve_with` has booted the
|
||||
// runtime). Lazily spawn it on first request and cache the handle.
|
||||
static STORE_TX: OnceLock<smarm::Sender<GenServerMsg>> = OnceLock::new();
|
||||
|
||||
fn store_handle() -> &'static smarm::Sender<GenServerMsg> {
|
||||
STORE_TX.get_or_init(|| {
|
||||
let (tx, rx) = smarm::channel::<GenServerMsg>();
|
||||
smarm::spawn(move || {
|
||||
let server = AssetStoreServer::new();
|
||||
server.loop_runner(rx);
|
||||
});
|
||||
tx
|
||||
})
|
||||
}
|
||||
|
||||
fn list_packages_handler(c: Conn, _n: Next) -> Conn {
|
||||
let (reply_tx, reply_rx) = smarm::channel();
|
||||
if store_handle().send(GenServerMsg::ListPackages { reply_to: reply_tx }).is_err() {
|
||||
return c.put_status(500).put_body("Internal DB Actor Failure");
|
||||
}
|
||||
match reply_rx.recv() {
|
||||
Ok(listing) => {
|
||||
let body = serde_json::to_vec(&listing).unwrap_or_default();
|
||||
c.put_status(200)
|
||||
.put_header("content-type", "application/json")
|
||||
.put_body(body)
|
||||
}
|
||||
Err(_) => c.put_status(500).put_body("Internal DB Actor Failure"),
|
||||
}
|
||||
}
|
||||
|
||||
fn fetch_asset_handler(c: Conn, _n: Next) -> Conn {
|
||||
let package = c.params.get("package").unwrap_or_default().to_string();
|
||||
let version = c.params.get("version").unwrap_or_default().to_string();
|
||||
let filename = c.params.get("filename").unwrap_or_default().to_string();
|
||||
|
||||
let (reply_tx, reply_rx) = smarm::channel();
|
||||
|
||||
if store_handle().send(GenServerMsg::FetchAsset {
|
||||
package,
|
||||
version,
|
||||
filename,
|
||||
reply_to: reply_tx,
|
||||
}).is_err() {
|
||||
return c.put_status(500).put_body("Internal DB Actor Failure");
|
||||
}
|
||||
|
||||
match reply_rx.recv() {
|
||||
Ok(Ok(Some(asset))) => {
|
||||
let accepts_gzip = c.headers
|
||||
.get("accept-encoding")
|
||||
.map(|v| v.contains("gzip"))
|
||||
.unwrap_or(false);
|
||||
|
||||
if accepts_gzip {
|
||||
c.put_status(200)
|
||||
.put_header("content-type", &asset.mime_type)
|
||||
.put_header("content-encoding", "gzip")
|
||||
.put_header("cache-control", "public, max-age=31536000, immutable")
|
||||
.put_header("access-control-allow-origin", "*")
|
||||
.put_body(asset.gzipped_bytes)
|
||||
} else {
|
||||
let mut decoder = GzDecoder::new(&asset.gzipped_bytes[..]);
|
||||
let mut raw_bytes = Vec::new();
|
||||
if decoder.read_to_end(&mut raw_bytes).is_ok() {
|
||||
c.put_status(200)
|
||||
.put_header("content-type", &asset.mime_type)
|
||||
.put_header("cache-control", "public, max-age=31536000, immutable")
|
||||
.put_header("access-control-allow-origin", "*")
|
||||
.put_body(raw_bytes)
|
||||
} else {
|
||||
c.put_status(500).put_body("Decompression Error")
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Ok(None)) => c.put_status(404).put_body("Asset Not Found"),
|
||||
_ => c.put_status(500).put_body("Database Error Encountered"),
|
||||
}
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let Some(port) = run_cli() else { return };
|
||||
|
||||
let router = Router::new()
|
||||
.get("/packages", list_packages_handler)
|
||||
.get("/assets/:package/:version/:filename", fetch_asset_handler);
|
||||
|
||||
println!("C3 on 0.0.0.0:{port}...");
|
||||
let cfg = Config::new(format!("0.0.0.0:{port}").parse().unwrap());
|
||||
let pipeline = Pipeline::new().plug(router);
|
||||
serve_with(cfg, pipeline).unwrap();
|
||||
}
|
||||
+146
@@ -0,0 +1,146 @@
|
||||
use rusqlite::{params, Connection};
|
||||
use std::io;
|
||||
use std::path::PathBuf;
|
||||
|
||||
pub fn db_path() -> PathBuf {
|
||||
std::env::var_os("CCC_DB_PATH")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| PathBuf::from("cdn.db"))
|
||||
}
|
||||
|
||||
pub fn open() -> rusqlite::Result<Connection> {
|
||||
let conn = Connection::open(db_path())?;
|
||||
init_schema(&conn)?;
|
||||
Ok(conn)
|
||||
}
|
||||
|
||||
fn init_schema(conn: &Connection) -> rusqlite::Result<()> {
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE IF NOT EXISTS packages (
|
||||
name TEXT PRIMARY KEY,
|
||||
archived INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS versions (
|
||||
package TEXT NOT NULL REFERENCES packages(name),
|
||||
version TEXT NOT NULL,
|
||||
filename TEXT NOT NULL,
|
||||
mime_type TEXT NOT NULL,
|
||||
gzipped_bytes BLOB NOT NULL,
|
||||
archived INTEGER NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (package, version)
|
||||
);",
|
||||
)
|
||||
}
|
||||
|
||||
fn io_err(e: rusqlite::Error) -> io::Error {
|
||||
io::Error::new(io::ErrorKind::Other, e.to_string())
|
||||
}
|
||||
|
||||
pub fn create_package(package: &str) -> io::Result<()> {
|
||||
let conn = open().map_err(io_err)?;
|
||||
let existing: i64 = conn
|
||||
.query_row("SELECT COUNT(*) FROM packages WHERE name = ?", params![package], |r| r.get(0))
|
||||
.map_err(io_err)?;
|
||||
if existing > 0 {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::AlreadyExists,
|
||||
format!("package '{package}' already exists"),
|
||||
));
|
||||
}
|
||||
conn.execute("INSERT INTO packages (name, archived) VALUES (?, 0)", params![package])
|
||||
.map_err(io_err)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn add_version(package: &str, filepath: &str, version: &str) -> io::Result<()> {
|
||||
let conn = open().map_err(io_err)?;
|
||||
let exists: i64 = conn
|
||||
.query_row("SELECT COUNT(*) FROM packages WHERE name = ?", params![package], |r| r.get(0))
|
||||
.map_err(io_err)?;
|
||||
if exists == 0 {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::NotFound,
|
||||
format!("package '{package}' does not exist (run `create` first)"),
|
||||
));
|
||||
}
|
||||
|
||||
let src = std::path::Path::new(filepath);
|
||||
let filename = src
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "bad filepath"))?
|
||||
.to_string();
|
||||
|
||||
let mut raw = Vec::new();
|
||||
use std::io::Read;
|
||||
std::fs::File::open(src)?.read_to_end(&mut raw)?;
|
||||
|
||||
use std::io::Write;
|
||||
let mut gz = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
|
||||
gz.write_all(&raw)?;
|
||||
let compressed = gz.finish()?;
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO versions (package, version, filename, mime_type, gzipped_bytes, archived)
|
||||
VALUES (?, ?, ?, ?, ?, 0)
|
||||
ON CONFLICT(package, version) DO UPDATE SET
|
||||
filename = excluded.filename,
|
||||
mime_type = excluded.mime_type,
|
||||
gzipped_bytes = excluded.gzipped_bytes,
|
||||
archived = 0",
|
||||
params![package, version, filename, guess_mime(src), compressed],
|
||||
)
|
||||
.map_err(io_err)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn archive(package: &str, version: Option<&str>) -> io::Result<()> {
|
||||
let conn = open().map_err(io_err)?;
|
||||
let exists: i64 = conn
|
||||
.query_row("SELECT COUNT(*) FROM packages WHERE name = ?", params![package], |r| r.get(0))
|
||||
.map_err(io_err)?;
|
||||
if exists == 0 {
|
||||
return Err(io::Error::new(io::ErrorKind::NotFound, format!("package '{package}' does not exist")));
|
||||
}
|
||||
|
||||
match version {
|
||||
Some(v) => {
|
||||
let n = conn
|
||||
.execute(
|
||||
"UPDATE versions SET archived = 1 WHERE package = ? AND version = ?",
|
||||
params![package, v],
|
||||
)
|
||||
.map_err(io_err)?;
|
||||
if n == 0 {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::NotFound,
|
||||
format!("version '{v}' does not exist on package '{package}'"),
|
||||
));
|
||||
}
|
||||
}
|
||||
None => {
|
||||
conn.execute("UPDATE packages SET archived = 1 WHERE name = ?", params![package])
|
||||
.map_err(io_err)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn guess_mime(path: &std::path::Path) -> &'static str {
|
||||
match path.extension().and_then(|e| e.to_str()).unwrap_or("") {
|
||||
"js" | "mjs" => "application/javascript",
|
||||
"css" => "text/css",
|
||||
"html" | "htm" => "text/html",
|
||||
"json" => "application/json",
|
||||
"svg" => "image/svg+xml",
|
||||
"png" => "image/png",
|
||||
"jpg" | "jpeg" => "image/jpeg",
|
||||
"gif" => "image/gif",
|
||||
"webp" => "image/webp",
|
||||
"wasm" => "application/wasm",
|
||||
"txt" => "text/plain",
|
||||
"pdf" => "application/pdf",
|
||||
_ => "application/octet-stream",
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user