From bdf99b32c4ac8a6beaf91b558a1d87348487cd95 Mon Sep 17 00:00:00 2001 From: Elijah Voigt Date: Wed, 4 Mar 2026 09:49:05 -0800 Subject: [PATCH] feat(quotesdb): add workers-rs WASM entry point to api binary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 - 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 bound on single-threaded wasm32 --- quotesdb/Cargo.toml | 17 ++++++-- quotesdb/src/bin/api/db/d1.rs | 10 ++--- quotesdb/src/bin/api/db/mod.rs | 7 ++-- quotesdb/src/bin/api/handlers/mod.rs | 7 ++++ quotesdb/src/bin/api/main.rs | 60 ++++++++++++++++++++++++++-- 5 files changed, 86 insertions(+), 15 deletions(-) diff --git a/quotesdb/Cargo.toml b/quotesdb/Cargo.toml index d80bbe5..07b0f72 100644 --- a/quotesdb/Cargo.toml +++ b/quotesdb/Cargo.toml @@ -25,6 +25,9 @@ serde = { version = "1", features = ["derive"] } serde_json = "1" # Error types with Display/std::error::Error derive — works on both native and wasm32. 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). # 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"] } # Async wrapper around rusqlite for use with Tokio. tokio-rusqlite = "0.6" -# Async trait support for the QuoteRepository abstraction. -async-trait = "0.1" # WASM-only dependencies (Workers API binary + UI binary). # 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. uuid = { version = "1", features = ["js"] } # 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` resolves in the fetch event handler. +http = "1" # Entropy source for WASM targets: bridges uuid (v4) and rand (OsRng) to # crypto.getRandomValues() via the Web Crypto API. getrandom = { version = "0.4", features = ["wasm_js"] } diff --git a/quotesdb/src/bin/api/db/d1.rs b/quotesdb/src/bin/api/db/d1.rs index 082aca3..e3ec68a 100644 --- a/quotesdb/src/bin/api/db/d1.rs +++ b/quotesdb/src/bin/api/db/d1.rs @@ -62,11 +62,11 @@ struct CountRow { /// 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. pub struct D1Repository { /// 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 @@ -76,7 +76,7 @@ unsafe impl Sync for D1Repository {} impl D1Repository { /// 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 } } @@ -301,7 +301,7 @@ impl QuoteRepository for D1Repository { // Batch insert tags if !input.tags.is_empty() { - let tag_stmts: Vec = input + let tag_stmts: Vec = input .tags .iter() .map(|tag| { @@ -425,7 +425,7 @@ impl QuoteRepository for D1Repository { .map_err(|e| DbError::Internal(e.to_string()))?; if !tags.is_empty() { - let tag_stmts: Vec = tags + let tag_stmts: Vec = tags .iter() .map(|tag| { self.db diff --git a/quotesdb/src/bin/api/db/mod.rs b/quotesdb/src/bin/api/db/mod.rs index c3f76fc..fef2729 100644 --- a/quotesdb/src/bin/api/db/mod.rs +++ b/quotesdb/src/bin/api/db/mod.rs @@ -15,7 +15,7 @@ pub mod migrations; mod native; #[cfg(target_arch = "wasm32")] -mod d1; +pub mod d1; #[cfg(not(target_arch = "wasm32"))] pub mod connection; @@ -73,8 +73,9 @@ pub enum DbError { /// /// On native targets the trait uses `async_trait` (Send-capable futures), /// which lets Axum share the repository across Tokio tasks. -/// On wasm32 the trait uses `async_trait(?Send)` because `D1Database` wraps -/// JS values that are not `Send`. +/// On wasm32 the trait uses `async_trait(?Send)` because D1 database methods +/// 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, /// Cloudflare D1 for WASM). The caller holds the repository behind an `Arc` diff --git a/quotesdb/src/bin/api/handlers/mod.rs b/quotesdb/src/bin/api/handlers/mod.rs index d72e126..b12dc28 100644 --- a/quotesdb/src/bin/api/handlers/mod.rs +++ b/quotesdb/src/bin/api/handlers/mod.rs @@ -95,6 +95,7 @@ fn default_page() -> u32 { /// 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 /// spec string. +#[cfg_attr(target_arch = "wasm32", worker::send)] async fn openapi_handler() -> Response { 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 /// no filters. Returns [`crate::db::ListResult`] serialised as JSON. +#[cfg_attr(target_arch = "wasm32", worker::send)] async fn list_handler(State(repo): State, Query(params): Query) -> Response { match repo .list_quotes(params.page, params.author.as_deref(), params.tag.as_deref()) @@ -126,6 +128,7 @@ async fn list_handler(State(repo): State, Query(params): Query /// **Registration order:** this route must be registered before /// `GET /api/quotes/:id` in the router to avoid "random" being matched as an /// id parameter. +#[cfg_attr(target_arch = "wasm32", worker::send)] async fn random_handler(State(repo): State) -> Response { match repo.get_random_quote().await { Ok(Some(quote)) => (StatusCode::OK, Json(quote)).into_response(), @@ -137,6 +140,7 @@ async fn random_handler(State(repo): State) -> Response { /// `GET /api/quotes/:id` — retrieve a single quote by NanoID. /// /// Returns `404` when no quote has the given id. +#[cfg_attr(target_arch = "wasm32", worker::send)] async fn get_quote_handler(State(repo): State, Path(id): Path) -> Response { match repo.get_quote(&id).await { Ok(Some(quote)) => (StatusCode::OK, Json(quote)).into_response(), @@ -150,6 +154,7 @@ async fn get_quote_handler(State(repo): State, Path(id): Path) -> /// Accepts a JSON body matching [`CreateQuoteInput`]. Returns `201 Created` /// with `{"quote": {...}, "auth_code": "..."}`. The `auth_code` is the only /// time it is returned — the client must store it. +#[cfg_attr(target_arch = "wasm32", worker::send)] async fn create_handler(State(repo): State, Json(input): Json) -> Response { match repo.create_quote(input).await { Ok((quote, auth_code)) => ( @@ -175,6 +180,7 @@ fn extract_auth_code(headers: &HeaderMap) -> Option { /// /// 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. +#[cfg_attr(target_arch = "wasm32", worker::send)] async fn update_handler( State(repo): State, Path(id): Path, @@ -195,6 +201,7 @@ async fn update_handler( /// /// Requires the `X-Auth-Code` header. Returns `403` if missing or wrong, /// `404` if not found, or `204 No Content` on success. +#[cfg_attr(target_arch = "wasm32", worker::send)] async fn delete_handler( State(repo): State, Path(id): Path, diff --git a/quotesdb/src/bin/api/main.rs b/quotesdb/src/bin/api/main.rs index c33d98d..ac2eb05 100644 --- a/quotesdb/src/bin/api/main.rs +++ b/quotesdb/src/bin/api/main.rs @@ -1,17 +1,23 @@ //! API server binary entrypoint. //! -//! Starts the `quotesdb` REST API on `0.0.0.0:3000` (or the port set by -//! the `PORT` environment variable). Opens a SQLite database at the path -//! given by `DATABASE_URL` (defaults to `quotesdb.sqlite`), runs schema -//! migrations, then serves requests via Axum. +//! On native targets: starts a Tokio/Axum HTTP server on `0.0.0.0:3000`. +//! On wasm32 targets: exports a `fetch` event handler for Cloudflare Workers, +//! wiring Cloudflare D1 through [`db::d1::D1Repository`] into the Axum router. + +// 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 handlers; +#[cfg(not(target_arch = "wasm32"))] use std::sync::Arc; +#[cfg(not(target_arch = "wasm32"))] use db::QuoteRepository as _; +#[cfg(not(target_arch = "wasm32"))] #[tokio::main] async fn main() { 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"); } +/// Cloudflare Workers `fetch` event handler. +/// +/// With the `http` feature enabled on `worker`, the macro converts the +/// incoming JS request into an `http::Request` before passing +/// it here. The Axum router is called directly and its response is returned +/// as `http::Response`, 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> { + 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 = 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)] mod tests {}