Skip to content

Commit 2942be7

Browse files
committed
fullstack note app
1 parent 8f9861d commit 2942be7

7 files changed

Lines changed: 897 additions & 1 deletion

File tree

Cargo.toml

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,9 @@
11
[workspace]
2-
members = ["oxide-browser", "oxide-sdk", "examples/hello-oxide"]
2+
members = [
3+
"oxide-browser",
4+
"oxide-sdk",
5+
"examples/hello-oxide",
6+
"examples/fullstack-notes/frontend",
7+
"examples/fullstack-notes/backend",
8+
]
39
resolver = "2"

examples/fullstack-notes/README.md

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
# Fullstack Notes
2+
3+
A full-stack example for the Oxide browser: a **Rust HTTP backend** exchanges data
4+
with a **WebAssembly frontend** using Protocol Buffers over HTTP.
5+
6+
## Architecture
7+
8+
```
9+
┌─────────────────────────┐ HTTP + Protobuf ┌──────────────────────┐
10+
│ WASM Frontend │ ◄──────────────────────────────► │ Rust Backend │
11+
│ (oxide-sdk guest) │ GET/POST/DELETE /api/notes │ (axum server) │
12+
│ renders on canvas │ │ in-memory store │
13+
└─────────────────────────┘ └──────────────────────┘
14+
```
15+
16+
The frontend performs a full **CRUD cycle** on startup:
17+
18+
1. **GET** `/api/notes` — fetch initial notes
19+
2. **POST** `/api/notes` — create a new note (protobuf body)
20+
3. **POST** `/api/notes/2/toggle` — toggle a note's done status
21+
4. **DELETE** `/api/notes/3` — delete a note
22+
5. **GET** `/api/notes` — fetch final state
23+
24+
All request and response bodies use the Protocol Buffers binary wire format
25+
(the same `ProtoEncoder`/`ProtoDecoder` from `oxide-sdk`).
26+
27+
## Quick Start
28+
29+
### 1. Start the backend
30+
31+
```sh
32+
cargo run -p fullstack-notes-backend
33+
# => notes-server listening on http://0.0.0.0:3333
34+
```
35+
36+
### 2. Build the frontend WASM module
37+
38+
```sh
39+
cargo build -p fullstack-notes-frontend --target wasm32-unknown-unknown --release
40+
```
41+
42+
The compiled module is at:
43+
44+
```
45+
target/wasm32-unknown-unknown/release/fullstack_notes_frontend.wasm
46+
```
47+
48+
### 3. Load in the Oxide browser
49+
50+
```sh
51+
cargo run -p oxide
52+
```
53+
54+
Open the `.wasm` file via **File → Open** (or drag-and-drop).
55+
56+
## Proto Schema
57+
58+
Both sides agree on the same field numbers (no `.proto` files needed):
59+
60+
```
61+
Note {
62+
1: uint32 id
63+
2: string title
64+
3: bool done
65+
4: uint64 created_at
66+
}
67+
68+
NoteList {
69+
1: repeated Note (sub-messages)
70+
2: uint32 total
71+
3: uint32 done_count
72+
}
73+
74+
CreateNoteRequest {
75+
1: string title
76+
}
77+
```
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
[package]
2+
name = "fullstack-notes-backend"
3+
version = "0.1.0"
4+
edition = "2021"
5+
description = "Backend server for the fullstack-notes Oxide example"
6+
7+
[[bin]]
8+
name = "notes-server"
9+
path = "src/main.rs"
10+
11+
[dependencies]
12+
axum = "0.8"
13+
tokio = { version = "1", features = ["full"] }
Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
mod proto;
2+
3+
use axum::{
4+
body::Bytes,
5+
extract::{Path, State},
6+
http::{header, StatusCode},
7+
response::IntoResponse,
8+
routing::{delete, get, post},
9+
Router,
10+
};
11+
use proto::{ProtoDecoder, ProtoEncoder};
12+
use std::sync::{Arc, Mutex};
13+
use std::time::{SystemTime, UNIX_EPOCH};
14+
15+
// ── Domain ───────────────────────────────────────────────────────────────────
16+
17+
struct Note {
18+
id: u32,
19+
title: String,
20+
done: bool,
21+
created_at: u64,
22+
}
23+
24+
/// Proto field layout (shared with WASM frontend):
25+
/// Note { 1: uint32 id, 2: string title, 3: bool done, 4: uint64 created_at }
26+
/// NoteList { 1: repeated Note (sub-msg), 2: uint32 total, 3: uint32 done_count }
27+
/// CreateReq { 1: string title }
28+
impl Note {
29+
fn to_proto(&self) -> ProtoEncoder {
30+
ProtoEncoder::new()
31+
.uint32(1, self.id)
32+
.string(2, &self.title)
33+
.bool(3, self.done)
34+
.uint64(4, self.created_at)
35+
}
36+
}
37+
38+
fn now_ms() -> u64 {
39+
SystemTime::now()
40+
.duration_since(UNIX_EPOCH)
41+
.unwrap()
42+
.as_millis() as u64
43+
}
44+
45+
fn proto_response(status: StatusCode, body: Vec<u8>) -> impl IntoResponse {
46+
(
47+
status,
48+
[(header::CONTENT_TYPE, "application/protobuf")],
49+
body,
50+
)
51+
}
52+
53+
// ── Shared State ─────────────────────────────────────────────────────────────
54+
55+
struct AppState {
56+
notes: Mutex<Vec<Note>>,
57+
next_id: Mutex<u32>,
58+
}
59+
60+
type SharedState = Arc<AppState>;
61+
62+
// ── Handlers ─────────────────────────────────────────────────────────────────
63+
64+
async fn list_notes(State(state): State<SharedState>) -> impl IntoResponse {
65+
let notes = state.notes.lock().unwrap();
66+
let total = notes.len() as u32;
67+
let done_count = notes.iter().filter(|n| n.done).count() as u32;
68+
69+
let mut enc = ProtoEncoder::new();
70+
for note in notes.iter() {
71+
enc = enc.message(1, &note.to_proto());
72+
}
73+
enc = enc.uint32(2, total).uint32(3, done_count);
74+
75+
proto_response(StatusCode::OK, enc.finish())
76+
}
77+
78+
async fn create_note(
79+
State(state): State<SharedState>,
80+
body: Bytes,
81+
) -> impl IntoResponse {
82+
let mut title = String::new();
83+
let mut decoder = ProtoDecoder::new(&body);
84+
while let Some(field) = decoder.next() {
85+
if field.number == 1 {
86+
title = field.as_str().to_string();
87+
}
88+
}
89+
90+
if title.is_empty() {
91+
return proto_response(
92+
StatusCode::BAD_REQUEST,
93+
ProtoEncoder::new().string(1, "title is required").finish(),
94+
);
95+
}
96+
97+
let mut next_id = state.next_id.lock().unwrap();
98+
let id = *next_id;
99+
*next_id += 1;
100+
drop(next_id);
101+
102+
let note = Note {
103+
id,
104+
title,
105+
done: false,
106+
created_at: now_ms(),
107+
};
108+
let resp = note.to_proto().finish();
109+
state.notes.lock().unwrap().push(note);
110+
111+
proto_response(StatusCode::CREATED, resp)
112+
}
113+
114+
async fn toggle_note(
115+
State(state): State<SharedState>,
116+
Path(id): Path<u32>,
117+
) -> impl IntoResponse {
118+
let mut notes = state.notes.lock().unwrap();
119+
if let Some(note) = notes.iter_mut().find(|n| n.id == id) {
120+
note.done = !note.done;
121+
let resp = note.to_proto().finish();
122+
proto_response(StatusCode::OK, resp)
123+
} else {
124+
proto_response(StatusCode::NOT_FOUND, Vec::new())
125+
}
126+
}
127+
128+
async fn delete_note(
129+
State(state): State<SharedState>,
130+
Path(id): Path<u32>,
131+
) -> impl IntoResponse {
132+
let mut notes = state.notes.lock().unwrap();
133+
if let Some(pos) = notes.iter().position(|n| n.id == id) {
134+
let removed = notes.remove(pos);
135+
let resp = removed.to_proto().finish();
136+
proto_response(StatusCode::OK, resp)
137+
} else {
138+
proto_response(StatusCode::NOT_FOUND, Vec::new())
139+
}
140+
}
141+
142+
// ── Main ─────────────────────────────────────────────────────────────────────
143+
144+
#[tokio::main]
145+
async fn main() {
146+
let state = Arc::new(AppState {
147+
notes: Mutex::new(vec![
148+
Note {
149+
id: 1,
150+
title: "Learn the Oxide browser".into(),
151+
done: true,
152+
created_at: 1710000000000,
153+
},
154+
Note {
155+
id: 2,
156+
title: "Build a WASM guest app".into(),
157+
done: false,
158+
created_at: 1710000060000,
159+
},
160+
Note {
161+
id: 3,
162+
title: "Deploy to production".into(),
163+
done: false,
164+
created_at: 1710000120000,
165+
},
166+
]),
167+
next_id: Mutex::new(4),
168+
});
169+
170+
let app = Router::new()
171+
.route("/api/notes", get(list_notes).post(create_note))
172+
.route("/api/notes/{id}/toggle", post(toggle_note))
173+
.route("/api/notes/{id}", delete(delete_note))
174+
.with_state(state);
175+
176+
let addr = "0.0.0.0:3333";
177+
println!("notes-server listening on http://{addr}");
178+
179+
let listener = tokio::net::TcpListener::bind(addr).await.unwrap();
180+
axum::serve(listener, app).await.unwrap();
181+
}

0 commit comments

Comments
 (0)