A full-stack walkthrough
Every guide so far taught one layer. This chapter builds a whole app, so you can see the layers meet. The app is Notes: sign in, a note list that syncs live between browser windows, and an editor that saves as you type.
The finished app lives in the repo at
vilan/examples/walkthrough/, about 500
lines in one package. Every snippet below is quoted from those files,
and the test suite builds the app on every run, so this chapter can’t
quietly rot. To run it, install the
toolchain, clone the
repository, and start it:
git clone https://github.com/vilan-lang/vilan
cd vilan/vilan/examples/walkthrough
vilan run . # builds both entries, starts the server
# → http://localhost:4600
(You can also read straight through without running anything; every snippet below is reproduced in full.)
Open two browser windows side by side. Sign in, add a note in one window, and watch it appear in the other. Open a note and type. The other window follows keystroke by keystroke.
The shape
[package]
name = "notes"
[entry.client]
target = "browser"
[entry.server]
walkthrough/
vilan.toml
src/
client.vl the browser entry
server.vl the node entry
store.vl the service, next to its database
notes.vl the types that cross the wire
routes.vl the route enum
views.vl the UI
app.html the shell the server serves
One package, two entries (Platforms introduced
this layout). There is no client/server directory split and no shared
common package. Every file is visible to both entries, and the
compiler sorts out what may run where by what each entry reaches.
store.vl uses SQLite freely because only the server entry calls into
it; if client code ever reached that far, the build would fail with the
call chain.
The data flows in one loop, and the whole app hangs off it:
you type → rpc → server writes SQL → server writes its signal
→ the mirror updates every client → the UI re-renders one binding
Your own edit comes back to you the same way everyone else’s does. There is no “local state vs server state” bookkeeping: the mirror is the state, and drafts smooth over the last inch (the input you’re typing in).
The wire types
src/notes.vl
declares the payloads both sides speak. This is most of the
client/server contract. There is no schema file, endpoint list, or
client SDK to regenerate:
[derive(Wire, PartialEq, Debug)]
struct Note {
id: i32,
title: str,
body: str,
}
The service: next to its resources
src/store.vl
is the heart of the app. Its database is module-level: opened once
at startup, closed only when the process ends:
// Process lifetime: opened once, never dropped (a serve-forever server's
// `Database` is exactly this). Every method reaches it by loan.
let db: Database = open_database();
[service(NotesClient)]
struct NotesStore {
[expose] notes: SignalCell<List<Note>>,
}
Why module-level, and not a field on NotesStore? A Database is a
resource: it has a single owner, it moves rather than copies, and it closes
itself when its owner’s scope ends. A struct that owns a resource is itself a
resource. [service] generates a dispatcher that captures the store into
one closure per [rpc] method, which a resource can’t be (a closure capturing a
resource is the double-owner bug the class exists to prevent). So the long-lived
database lives at module scope, and the store holds only the reactive state it
exposes. Each [rpc] body reaches db directly, by loan:
[rpc]
fun retitle_note(self, token: str, note_id: i32, title: str): i32 {
match session_user(db, token) {
Some(let _user) => {
db.prepare("UPDATE note SET title = ? WHERE id = ?").run([title, note_id]);
self.notes.set_with(|list| list.map(|note| {
if note.id == note_id {
Note { id = note.id, title = title, body = note.body }
} else {
note
}
}));
note_id
},
None => -1,
}
}
Three things worth pausing on:
- A module-level resource is loan-only.
db.prepare(...)andsession_user(db, ...)borrow it; the compiler rejects taking ownership away, whether by moving it (let mine = db) or bydrop(db), because process-lifetime state has no scope to be handed off to. It lives for the whole run. - No injected hooks. The service used to be forced into a shared
package that couldn’t name
Database, so its methods called closures the server installed at boot. Platform coloring removed the need: the service lives with its database, the browser build takes only the generated stub and contract hash from this module, and the bodies stay server-side because only the server entry reaches them (Services & RPC). - The order matters. Persist first, then update the signal. The signal write is what broadcasts to every client, so a crash between the two can never announce state that was never stored (Persistence covers this).
Title and body commit separately (retitle_note and rewrite_note):
the editor uses one draft per field, and per-field rpcs mean one field’s
edit never re-sends the other’s text.
Auth is register-or-login in one rpc: an unknown username creates the
account (pbkdf2-hashed password), a known one checks it, and either path
opens a session row whose token identifies later calls (Services &
RPC). open_database() creates the tables at
startup; boot() loads the mirror once from the already-open database.
The server entry: describe, boot, serve
src/server.vl
is now the boring file, which is the point:
async fun main() {
let build = require_build("client");
let page = require_shell("src/app.html", build).html();
let store = boot();
Server::builder()
.port(4600)
.with_service(Service::new(store.dispatcher().into_protocol(json_codec())))
.serve_build(build)
.on_request(|request| Response::builder().set_header("Content-Type", "text/html").body(page).build())
.on_start(|server| print(i"notes server listening on {server.url()}"))
.build()
.start();
}
require_build("client") asks what the client leg’s build emitted — the
bundle, and the stylesheet its const styles produced — and
serve_build turns that into one route per artifact, with the content
type each extension implies. No dist/ path is spelled here, so renaming
the leg cannot leave this file compiling and the page blank.
require_shell reads src/app.html once at boot and holds it against
that same build: a <link> for a stylesheet the build no longer emits, a
<script> naming a bundle that was renamed, a mount <div id> that
drifted from mount_root("app", …) — each stops the server here, naming
the file and the fix, instead of serving a page that renders wrong and
looks right.
The service goes on the same chain as everything else. Service::new(…)
plus with_service installs the
rpc routes and the WebSocket upgrade in front of on_request, so the page
and the service share one port without one of them replacing the other’s
boot function — delete the .with_service(…) line and this file still
compiles and still serves the page. (A server that is only a service is
the same chain with the serve_build/on_request lines left off.)
on_request then serves the shell for every path neither the service nor
the build claims. That’s what makes deep links like /note/7 load
(Routing). vilan build compiles
browser entries first, so the client’s artifacts are always there by the
time the server entry builds.
This file is quoted from the example as it stands, and the example is one
rung below the top of the ladder: the shell is still a file you write,
held against the build. The last rung is Document::of(build), which
skips the shell altogether and writes the page from the build — the
<link>, the <script> and the mount <div id> derived from what the
build emitted, so there is nothing left to check. The
to-do example
stands there: it has no app.html at all. Both rungs are covered in
Persistence; which one you want is a
question of whether you care about the document.
The client entry: two signals, a connect, and a mount
src/client.vl is
the whole wiring diagram:
async fun main() {
let token = Signal::new(storage::get("notes-token"));
let route = current_path().map(parse);
match NotesClient::connect("/", json_codec()) {
Ok(let client) => {
let _root = mount_root("app", || screen(client, token, route));
},
Err(let error) => print(i"connect failed: {error.debug()}"),
}
}
Read it as: token from localStorage (a reload stays signed in), the
typed route derived from the URL, connect, mount. NotesClient comes
from import pkg::store::NotesClient;, the same module whose bodies run
SQL on the server. This build sees only the stub. The mirror is not
wired here at all: client.notes is a RemoteSource<List<Note>>, and
the screen reads it where it is shown —
fun screen(client: NotesClient<SocketTransport>, token: SignalCell<str>, route: SignalCell<Route>): View {
let notes = client.notes.or([]);
…
}
— [] until the first sync, the live list after, and a subscription
that opens when the screen mounts and closes when it unmounts
(Services: reading a mirror).
Everything after this line is views reading those signals.
Routes
src/routes.vl
is the enum-router pattern from Routing, at its smallest:
[derive(PartialEq)]
enum Route {
Home,
Note(i32),
NotFound,
}
plus parse and href as the inverse pair, and pages that swap on
route.
The views
src/views.vl
has three layers, each one guide’s idea:
The gate. The sign-in panel shows while the token is empty; the
routed app shows once it isn’t. Signing in stores the token; signing
out removes it and navigates home.
The list page. An add form bound to a local signal, and the list
itself, one keyed bind_each over the mirror:
.child(view("ul").bind_each(notes, |note| note.id, |note| note_row(client, note, token)))
That single line is the live sync. When any client adds or deletes a note, the mirror updates and the keyed rows reconcile (Building UI).
The editor. The note page finds its note in the mirror, waits for it
under when(present) (so a deep link shows “loading…” until the first
sync), and then the editor binds one draft per field:
let title = draft(seed_title, |value: str| commit_outcome(client.retitle_note(token.get(), note_id, value), note_id));
let body = draft(seed_body, |value: str| commit_outcome(client.rewrite_note(token.get(), note_id, value), note_id));
// Remote edits (another session's typing — or our own echo) fold in.
entry.effect(|current: Option<Note>| {
match current {
Some(let note) => {
title.adopt(note.title);
body.adopt(note.body);
},
None => {},
}
});
view("div")
.child(view("input").styled(field).attr("placeholder", "Title…").bind_draft(title))
.child(view("span").styled(muted).bind_text(title.state.map(state_text)))
…
This is the local-first loop from Reactive state closed
end to end: typing updates the input instantly, each keystroke commits
through its rpc, the server broadcasts, and the adopt in the effect
folds remote changes in. Your own echo changes nothing. Another
session’s edit updates your field unless you’re mid-edit, in which
case your text wins until it commits. There is no Save button because
there is nothing left for one to do.
Things to try
- Two windows. Type a title in one and watch the other follow. Then type in both fields at once, one per window.
- Kill the server (Ctrl-C) with the app open. The “reconnecting…”
banner appears (one
showon the transport’s state signal). Restart the server: the banner clears and the mirror resyncs by itself. - Restart the server and reload: the notes are still there. SQLite did that, not the mirror.
- Deep-link to a note (
/note/1) in a fresh window: “loading…” flashes until the first sync, then the editor seeds. - Cross the platform line. Add a call to
store::open_database()(or anypkg::storefunction) somewhere the client entry reaches, and rebuild: the error names the whole chain frommaindown to the SQL.
Where each idea came from
| In this app | Taught in |
|---|---|
| the package, its two entries | Hello Vilan, Platforms |
Note, derives, the enums | Data & traits |
| signals, effects, drafts | Reactive state |
views, bind_each, when, show | Building UI |
the const styles | Styling |
the route enum, swap, link | Routing |
[service], mirrors, reconnect | Services & RPC |
| SQLite, the fallback, boot order | Persistence |
From here, the next step is to change something: add a
created_at: Instant to Note (the compiler will walk you through
every place it matters), or add a second entity. The shape you’d follow
is the one above.