feat(quotesdb): add workers-rs WASM entry point to api binary

- Gate native Tokio/Axum main() with #[cfg(not(target_arch = "wasm32"))]
- Add #![cfg_attr(target_arch = "wasm32", no_main)] to suppress missing-main error
- Add #[worker::event(fetch)] entry point using worker::HttpRequest / http::Response<axum::body::Body>
- Enable `http` feature on worker dep so fetch handler uses standard http types
- Add axum (json+query features), tower-service, and http to wasm32 deps
- Move async-trait to shared [dependencies] so both targets have it
- Make db::d1 module pub so main.rs can access D1Repository on wasm32
- Fix worker::d1::Database → D1Database and PreparedStatement → D1PreparedStatement
- Add #[cfg_attr(target_arch = "wasm32", worker::send)] to all 7 handler fns
  so their futures satisfy Axum's Handler<Send> bound on single-threaded wasm32
quotesdb
Elijah Voigt 3 months ago
parent d9f14bfc53
commit bdf99b32c4

@ -25,6 +25,9 @@ serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
# Error types with Display/std::error::Error derive — works on both native and wasm32. # Error types with Display/std::error::Error derive — works on both native and wasm32.
thiserror = "2" thiserror = "2"
# Async trait support for the QuoteRepository abstraction — used on both native
# and wasm32 (with ?Send on wasm32 to allow wrapping non-Send JS values).
async-trait = "0.1"
# Native-only dependencies (API server binary). # Native-only dependencies (API server binary).
# tokio, axum, and rusqlite are incompatible with wasm32-unknown-unknown. # tokio, axum, and rusqlite are incompatible with wasm32-unknown-unknown.
@ -38,8 +41,6 @@ axum = { version = "0.8", features = ["json"] }
rusqlite = { version = "0.32", features = ["bundled"] } rusqlite = { version = "0.32", features = ["bundled"] }
# Async wrapper around rusqlite for use with Tokio. # Async wrapper around rusqlite for use with Tokio.
tokio-rusqlite = "0.6" tokio-rusqlite = "0.6"
# Async trait support for the QuoteRepository abstraction.
async-trait = "0.1"
# WASM-only dependencies (Workers API binary + UI binary). # WASM-only dependencies (Workers API binary + UI binary).
# workers-rs, getrandom/wasm_js, and UI libraries are wasm32-specific. # workers-rs, getrandom/wasm_js, and UI libraries are wasm32-specific.
@ -47,7 +48,17 @@ async-trait = "0.1"
# Add the `js` feature to uuid on wasm32 to enable Web Crypto entropy for v4 UUIDs. # Add the `js` feature to uuid on wasm32 to enable Web Crypto entropy for v4 UUIDs.
uuid = { version = "1", features = ["js"] } uuid = { version = "1", features = ["js"] }
# Cloudflare Workers SDK — provides D1 bindings, fetch, KV, etc. # Cloudflare Workers SDK — provides D1 bindings, fetch, KV, etc.
worker = { version = "0.5", features = ["d1"] } # The `http` feature makes the fetch handler accept/return standard http types,
# enabling direct Axum router integration without manual request conversion.
worker = { version = "0.5", features = ["d1", "http"] }
# Axum HTTP framework for the WASM Workers entry point — minimal feature set
# (no full tokio runtime needed; json and query features required for handlers).
axum = { version = "0.8", default-features = false, features = ["json", "query"] }
# Tower service trait — required by the WASM entry point to call the Axum router.
tower-service = "0.3"
# http crate — re-exported by axum and worker; needed explicitly so the return
# type `http::Response<axum::body::Body>` resolves in the fetch event handler.
http = "1"
# Entropy source for WASM targets: bridges uuid (v4) and rand (OsRng) to # Entropy source for WASM targets: bridges uuid (v4) and rand (OsRng) to
# crypto.getRandomValues() via the Web Crypto API. # crypto.getRandomValues() via the Web Crypto API.
getrandom = { version = "0.4", features = ["wasm_js"] } getrandom = { version = "0.4", features = ["wasm_js"] }

@ -62,11 +62,11 @@ struct CountRow {
/// Cloudflare D1-backed repository (wasm32 only). /// Cloudflare D1-backed repository (wasm32 only).
/// ///
/// Wraps a [`worker::d1::Database`] handle provided by the Workers runtime. /// Wraps a [`worker::d1::D1Database`] handle provided by the Workers runtime.
/// All methods use the D1 prepared-statement API to execute SQL queries. /// All methods use the D1 prepared-statement API to execute SQL queries.
pub struct D1Repository { pub struct D1Repository {
/// The Cloudflare D1 database handle. /// The Cloudflare D1 database handle.
pub db: worker::d1::Database, pub db: worker::d1::D1Database,
} }
// SAFETY: wasm32-unknown-unknown is single-threaded; JS values are never sent // SAFETY: wasm32-unknown-unknown is single-threaded; JS values are never sent
@ -76,7 +76,7 @@ unsafe impl Sync for D1Repository {}
impl D1Repository { impl D1Repository {
/// Create a new [`D1Repository`] wrapping the given D1 database handle. /// Create a new [`D1Repository`] wrapping the given D1 database handle.
pub fn new(db: worker::d1::Database) -> Self { pub fn new(db: worker::d1::D1Database) -> Self {
Self { db } Self { db }
} }
@ -301,7 +301,7 @@ impl QuoteRepository for D1Repository {
// Batch insert tags // Batch insert tags
if !input.tags.is_empty() { if !input.tags.is_empty() {
let tag_stmts: Vec<worker::d1::PreparedStatement> = input let tag_stmts: Vec<worker::d1::D1PreparedStatement> = input
.tags .tags
.iter() .iter()
.map(|tag| { .map(|tag| {
@ -425,7 +425,7 @@ impl QuoteRepository for D1Repository {
.map_err(|e| DbError::Internal(e.to_string()))?; .map_err(|e| DbError::Internal(e.to_string()))?;
if !tags.is_empty() { if !tags.is_empty() {
let tag_stmts: Vec<worker::d1::PreparedStatement> = tags let tag_stmts: Vec<worker::d1::D1PreparedStatement> = tags
.iter() .iter()
.map(|tag| { .map(|tag| {
self.db self.db

@ -15,7 +15,7 @@ pub mod migrations;
mod native; mod native;
#[cfg(target_arch = "wasm32")] #[cfg(target_arch = "wasm32")]
mod d1; pub mod d1;
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
pub mod connection; pub mod connection;
@ -73,8 +73,9 @@ pub enum DbError {
/// ///
/// On native targets the trait uses `async_trait` (Send-capable futures), /// On native targets the trait uses `async_trait` (Send-capable futures),
/// which lets Axum share the repository across Tokio tasks. /// which lets Axum share the repository across Tokio tasks.
/// On wasm32 the trait uses `async_trait(?Send)` because `D1Database` wraps /// On wasm32 the trait uses `async_trait(?Send)` because D1 database methods
/// JS values that are not `Send`. /// internally use `JsFuture` (which is `!Send`). Handler futures are wrapped
/// with `#[worker::send]` at the call site to satisfy Axum's `Handler` bounds.
/// ///
/// Implementations must be backed by a persistent store (SQLite for native, /// Implementations must be backed by a persistent store (SQLite for native,
/// Cloudflare D1 for WASM). The caller holds the repository behind an `Arc` /// Cloudflare D1 for WASM). The caller holds the repository behind an `Arc`

@ -95,6 +95,7 @@ fn default_page() -> u32 {
/// The spec is embedded at compile time from `api/openapi.yaml` (converted to /// The spec is embedded at compile time from `api/openapi.yaml` (converted to
/// JSON by `build.rs`). Returns `Content-Type: application/json` with the raw /// JSON by `build.rs`). Returns `Content-Type: application/json` with the raw
/// spec string. /// spec string.
#[cfg_attr(target_arch = "wasm32", worker::send)]
async fn openapi_handler() -> Response { async fn openapi_handler() -> Response {
const OPENAPI_JSON: &str = include_str!(concat!(env!("OUT_DIR"), "/openapi.json")); const OPENAPI_JSON: &str = include_str!(concat!(env!("OUT_DIR"), "/openapi.json"));
( (
@ -109,6 +110,7 @@ async fn openapi_handler() -> Response {
/// ///
/// Accepts `?page=N&author=X&tag=Y` query parameters. Defaults to page 1 and /// Accepts `?page=N&author=X&tag=Y` query parameters. Defaults to page 1 and
/// no filters. Returns [`crate::db::ListResult`] serialised as JSON. /// no filters. Returns [`crate::db::ListResult`] serialised as JSON.
#[cfg_attr(target_arch = "wasm32", worker::send)]
async fn list_handler(State(repo): State<Repo>, Query(params): Query<ListParams>) -> Response { async fn list_handler(State(repo): State<Repo>, Query(params): Query<ListParams>) -> Response {
match repo match repo
.list_quotes(params.page, params.author.as_deref(), params.tag.as_deref()) .list_quotes(params.page, params.author.as_deref(), params.tag.as_deref())
@ -126,6 +128,7 @@ async fn list_handler(State(repo): State<Repo>, Query(params): Query<ListParams>
/// **Registration order:** this route must be registered before /// **Registration order:** this route must be registered before
/// `GET /api/quotes/:id` in the router to avoid "random" being matched as an /// `GET /api/quotes/:id` in the router to avoid "random" being matched as an
/// id parameter. /// id parameter.
#[cfg_attr(target_arch = "wasm32", worker::send)]
async fn random_handler(State(repo): State<Repo>) -> Response { async fn random_handler(State(repo): State<Repo>) -> Response {
match repo.get_random_quote().await { match repo.get_random_quote().await {
Ok(Some(quote)) => (StatusCode::OK, Json(quote)).into_response(), Ok(Some(quote)) => (StatusCode::OK, Json(quote)).into_response(),
@ -137,6 +140,7 @@ async fn random_handler(State(repo): State<Repo>) -> Response {
/// `GET /api/quotes/:id` — retrieve a single quote by NanoID. /// `GET /api/quotes/:id` — retrieve a single quote by NanoID.
/// ///
/// Returns `404` when no quote has the given id. /// Returns `404` when no quote has the given id.
#[cfg_attr(target_arch = "wasm32", worker::send)]
async fn get_quote_handler(State(repo): State<Repo>, Path(id): Path<String>) -> Response { async fn get_quote_handler(State(repo): State<Repo>, Path(id): Path<String>) -> Response {
match repo.get_quote(&id).await { match repo.get_quote(&id).await {
Ok(Some(quote)) => (StatusCode::OK, Json(quote)).into_response(), Ok(Some(quote)) => (StatusCode::OK, Json(quote)).into_response(),
@ -150,6 +154,7 @@ async fn get_quote_handler(State(repo): State<Repo>, Path(id): Path<String>) ->
/// Accepts a JSON body matching [`CreateQuoteInput`]. Returns `201 Created` /// Accepts a JSON body matching [`CreateQuoteInput`]. Returns `201 Created`
/// with `{"quote": {...}, "auth_code": "..."}`. The `auth_code` is the only /// with `{"quote": {...}, "auth_code": "..."}`. The `auth_code` is the only
/// time it is returned — the client must store it. /// time it is returned — the client must store it.
#[cfg_attr(target_arch = "wasm32", worker::send)]
async fn create_handler(State(repo): State<Repo>, Json(input): Json<CreateQuoteInput>) -> Response { async fn create_handler(State(repo): State<Repo>, Json(input): Json<CreateQuoteInput>) -> Response {
match repo.create_quote(input).await { match repo.create_quote(input).await {
Ok((quote, auth_code)) => ( Ok((quote, auth_code)) => (
@ -175,6 +180,7 @@ fn extract_auth_code(headers: &HeaderMap) -> Option<String> {
/// ///
/// Requires the `X-Auth-Code` header. Returns `403` if missing or wrong, /// Requires the `X-Auth-Code` header. Returns `403` if missing or wrong,
/// `404` if the quote does not exist, or `200` with the updated quote. /// `404` if the quote does not exist, or `200` with the updated quote.
#[cfg_attr(target_arch = "wasm32", worker::send)]
async fn update_handler( async fn update_handler(
State(repo): State<Repo>, State(repo): State<Repo>,
Path(id): Path<String>, Path(id): Path<String>,
@ -195,6 +201,7 @@ async fn update_handler(
/// ///
/// Requires the `X-Auth-Code` header. Returns `403` if missing or wrong, /// Requires the `X-Auth-Code` header. Returns `403` if missing or wrong,
/// `404` if not found, or `204 No Content` on success. /// `404` if not found, or `204 No Content` on success.
#[cfg_attr(target_arch = "wasm32", worker::send)]
async fn delete_handler( async fn delete_handler(
State(repo): State<Repo>, State(repo): State<Repo>,
Path(id): Path<String>, Path(id): Path<String>,

@ -1,17 +1,23 @@
//! API server binary entrypoint. //! API server binary entrypoint.
//! //!
//! Starts the `quotesdb` REST API on `0.0.0.0:3000` (or the port set by //! On native targets: starts a Tokio/Axum HTTP server on `0.0.0.0:3000`.
//! the `PORT` environment variable). Opens a SQLite database at the path //! On wasm32 targets: exports a `fetch` event handler for Cloudflare Workers,
//! given by `DATABASE_URL` (defaults to `quotesdb.sqlite`), runs schema //! wiring Cloudflare D1 through [`db::d1::D1Repository`] into the Axum router.
//! migrations, then serves requests via Axum.
// On wasm32 there is no `main` entry point; the Workers runtime invokes the
// exported `fetch` function produced by `#[worker::event(fetch)]` instead.
#![cfg_attr(target_arch = "wasm32", no_main)]
mod db; mod db;
mod handlers; mod handlers;
#[cfg(not(target_arch = "wasm32"))]
use std::sync::Arc; use std::sync::Arc;
#[cfg(not(target_arch = "wasm32"))]
use db::QuoteRepository as _; use db::QuoteRepository as _;
#[cfg(not(target_arch = "wasm32"))]
#[tokio::main] #[tokio::main]
async fn main() { async fn main() {
let db_path = std::env::var("DATABASE_URL").unwrap_or_else(|_| "quotesdb.sqlite".to_string()); let db_path = std::env::var("DATABASE_URL").unwrap_or_else(|_| "quotesdb.sqlite".to_string());
@ -38,5 +44,51 @@ async fn main() {
axum::serve(listener, app).await.expect("server error"); axum::serve(listener, app).await.expect("server error");
} }
/// Cloudflare Workers `fetch` event handler.
///
/// With the `http` feature enabled on `worker`, the macro converts the
/// incoming JS request into an `http::Request<worker::Body>` before passing
/// it here. The Axum router is called directly and its response is returned
/// as `http::Response<axum::body::Body>`, which the macro converts back to a
/// JS response before returning it to the runtime.
///
/// D1 bindings are retrieved from the Worker environment and wrapped in an
/// `Arc` for sharing across Axum state.
#[cfg(target_arch = "wasm32")]
#[worker::event(fetch)]
pub async fn fetch(
req: worker::HttpRequest,
env: worker::Env,
_ctx: worker::Context,
) -> worker::Result<http::Response<axum::body::Body>> {
use std::sync::Arc;
use tower_service::Service;
// Retrieve the D1 database binding named "DB" from the Worker environment.
let db = env.d1("DB")?;
// Create the D1-backed repository and run schema migrations on startup.
let repo = db::d1::D1Repository::new(db);
// Bring QuoteRepository trait into scope for `run_migrations`.
use db::QuoteRepository as _;
repo.run_migrations()
.await
.map_err(|e| worker::Error::RustError(e.to_string()))?;
// Wrap in Arc so it can be shared across handlers via Axum state.
// D1Repository is `unsafe impl Send + Sync` — safe on single-threaded wasm32.
let repo: Arc<dyn db::QuoteRepository + Send + Sync> = Arc::new(repo);
// Build the Axum router with all routes registered.
let mut router = handlers::router(repo);
// Call the Axum router directly; the http feature ensures request/response
// types are compatible with standard http crate types that Axum expects.
Ok(router
.call(req)
.await
.map_err(|e| worker::Error::RustError(e.to_string()))?)
}
#[cfg(test)] #[cfg(test)]
mod tests {} mod tests {}

Loading…
Cancel
Save