Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Services & RPC

This is the chapter where Vilan’s full-stack story comes together. The short version: you write one ordinary struct on the server, mark a few things on it, and you get a typed client, live data sync, and reconnect handling without writing any protocol code.

A service is that struct. Three attributes do the work:

  • [service(ClientName)] on the struct names the generated client type.
  • [rpc] on a method makes it callable from the client.
  • [expose] on a SignalCell<T> field mirrors it: every connected client gets a live copy that updates when the server writes it.

There are no REST endpoints, fetch calls, or JSON shapes to keep in sync by hand. The compiler knows both sides.

The attribute reads the [rpc] methods off this module’s inherent impl blocks, and export impl is one of them — export is visibility, not shape. A service that declares nothing at all — no [rpc] method, no [expose]d field, no client = .. handler — is refused: its client could call nothing, and because both generated sides would agree about that empty surface, even verify() would say they match.

Here’s a complete little server:

import std::reactive::{ Signal, SignalCell };
import std::json::json_codec;
import std::http::{ Response, Server };
import std::rpc::server::Service;
import std::shared::Shared;

[derive(Wire, PartialEq, Debug)]
struct Note {
	id: i32,
	text: str,
}

[service(NotesClient)]
struct Notes {
	[expose] entries: SignalCell<List<Note>>,
	next_id: Shared<i32>,
}

impl Notes {
	[rpc]
	fun add(self, text: str): i32 {
		let id = self.next_id.read();
		self.next_id.write() = id + 1;
		self.entries.set_with(|list| {
			mut updated = list;
			updated.push(Note { id = id, text = text });
			updated
		});
		id
	}
}

fun main() {
	let notes = Notes {
		entries = Signal::new([]),
		next_id = Shared::new(1),
	};
	Server::builder()
		.port(4000)
		.with_service(Service::new(notes.dispatcher().into_protocol(json_codec())))
		.on_request(|request| Response::builder().body("app shell here").build())
		.on_start(|server| print(i"listening on {server.url()}"))
		.build()
		.start();
}

And a client. NotesClient::connect gives you an object whose exposed fields are typed mirrors (RemoteSource<T>, one per [expose]) and whose rpc methods are ordinary calls that return Result:

import std::debug::Debug;
import std::reactive::{ Signal, SignalCell };
import std::json::json_codec;
import std::result::Result::{ self, Ok, Err };
import std::shared::Shared;

[derive(Wire, PartialEq, Debug)]
struct Note {
	id: i32,
	text: str,
}

[service(NotesClient)]
struct Notes {
	[expose] entries: SignalCell<List<Note>>,
	next_id: Shared<i32>,
}

impl Notes {
	[rpc]
	fun add(self, text: str): i32 {
		let id = self.next_id.read();
		self.next_id.write() = id + 1;
		id
	}
}

async fun main() {
	match NotesClient::connect("/", json_codec()) {
		Ok(let client) => {
			// The mirror, watched by hand: the first `sub` opens the channel,
			// and the observer fires on every server-side change, on every
			// client. Disposing the last watcher closes the channel again.
			let watching = client.entries.sub(|list: List<Note>| print(list.len()));
			// An rpc call: implicitly awaited, Result-typed.
			match client.add("hello") {
				Ok(let id) => print(id),
				Err(let error) => print(i"rpc failed: {error.debug()}"),
			}
			watching.dispose();
		},
		Err(let error) => print(i"connect failed: {error.debug()}"),
	}
}

In a real app the service lives in its own module, next to the resources its bodies use, and the client entry imports the generated NotesClient from it. See Where the service lives below for why the browser build may do that, and the walkthrough for the full shape.

What can cross the wire: Wire

Everything that travels — an rpc’s parameters, the values a call answers with, mirrored payloads — must be serializable, which Vilan calls Wire. The scalars are Wire (bool, every integer width from i8 to u53, usize, both floats, str). List, Option, Result and HashMap of Wire types are Wire — so a fallible reply, Result<Row, str>, is an ordinary return type. And your own types opt in with a derive:

[derive(Wire, PartialEq, Debug)]
struct Note { id: i32, text: str }

That triple is the standard shape for payload types: Wire to travel, PartialEq because mirrors and UI reconciliation compare values, and Debug for error paths.

derive(Wire) checks every field recursively. A closure or a Signal hiding inside a payload type is a compile error at the derive, which is exactly where you want to find out.

Wire is an ordinary trait, so the derive is a convenience and not the only door: a type with a hand-written impl … with Wire is Wire everywhere the compiler asks — in an [rpc] signature, in a payload type’s fields, and as an [expose]d source’s element. A conditional impl carries its own recursion: impl Pair<type A: Wire, type B: Wire> with Wire makes Pair<i53, str> sendable and Pair<Conn, str> refused, naming the argument, while an impl whose binder declares no bound — impl Handle<type T> with Wire, which is what the derive emits — leaves its argument unconstrained, because a phantom parameter never reaches the payload.

A resource never travels, in either position: not as a field, and not as the derived type itself — derive(Wire) and derive(Json) are both refused for a resource struct or enum. A resource is an owned handle, and the receiving side would rebuild one out of bytes: a second handle to the same thing, owned by nobody. Send a name for it instead, which is what the next section is about.

The codec is chosen at connect time: json_codec() for a readable wire, binary_codec() for a compact one. Client and server must use the same one.

A method with nothing to say: void

One return type is not Wire and does not have to be. An [rpc] method that returns nothing — write no return type, or write : void, they mean the same thing — has no reply payload to encode, so its reply is just the acknowledgement, and the generated stub waits for it:

[rpc]
fun remove_row(self, id: i53) {
	self.rows.update(|&mut rows| { … });
}

The stub answers Result<void, RpcError> — the shape every other stub has, with nothing in its Ok: void is Vilan’s unit value, so the ack is Ok(void) and a call that did not land is Err(error).

// Wait and check.
match client.remove_row(id) {
	Ok(_) => {},
	Err(let error) => print(i"remove failed: {error.debug()}"),
}

// Wait and don't check — still a round trip, the value discarded.
let _ = client.remove_row(id);

This is not a notification, and the difference is the wait. A notification ([client_service], or the server’s connection.client().…) returns as soon as the request is sent: no wait, no failure, nothing that orders it against what comes next. An awaited void means it happened — the handler ran to completion on the server before the stub returned, so the next call sees its effect. Reach for void when you need the ordering and have nothing to report, and for a notification when you need neither.

Naming server entities: Handle<T>

Payloads carry data. When a client needs to talk about a thing the server owns (the node to update, the draft to commit, the route’s entity), it needs a name for it, and the name has to survive the round trip. That name is Handle<T> from std::arena, which is Wire:

[derive(Wire)]
struct Rename { node: Handle<Doc>, title: str }

[service]
struct Docs {
	docs: Arena<Doc>,
}

The server hands out handles; the client stores them and quotes them back. Only { index, generation } travels (the T is phantom), so the entity itself never has to be Wire, and never has to leave the server.

The payoff is what happens when the entity is gone. The arena’s generation check answers a stale handle with None, so a client acting on something another client just deleted gets a clean “not there” instead of a phantom write into a reused slot. It is the same rule local code already lives by, extended to the wire, and it is why a handle beats an integer id you check by hand.

Scope the arena to the session. (index, generation) is guessable, so an arena shared across tenants hands every client names that mean something to the others. Create the arena when the session is established and drop it with the session: a handle from one session then names nothing in another, by construction. Authorize the session first; then look the handle up in that session’s arena.

When the arena has to be shared, Arena::branded() numbers its generations from a random base instead of 0, so its handles resolve to None in any other arena rather than naming the slot of the same index. Nothing else changes: staleness, reuse and the None answer are as before. Treat it as a confusion guard, not an authorization check: the brand rides inside the handles it issues, so a client with one valid handle can derive it. It keeps tenants’ names from colliding; the session check is still what decides who may act.

What rpc calls do

  • On the client they return Result<T, RpcError> and are implicitly awaited, like any async call.
  • RpcError tells you what went wrong, in six variants: Transport(str) (couldn’t reach the server), Decode(str), Remote(str) (the handler failed), Contract(str) (the connect-time check below refused a drifted server), Unauthorized (the credential will not open this connection) and Unavailable (the server is up and refusing for now — retry later, and re-authenticating is beside the point). Errors are values. Look at them and decide.
  • At connect time, both sides compare a hash of the service’s shape. If a stale client meets a redeployed server, the connect fails cleanly — as Contract(reason) — instead of calls corrupting halfway. This is the contract check.
  • On the server, each handler runs inside a turn, so all the signal writes one rpc makes are broadcast as a single consistent update.
  • Handler bodies can await: call another service, sleep_for, wait on I/O. The reply is sent when the body finishes, and the turn holds across the awaits: writes before and after a suspension still coalesce into that same single update.

Mirrors

The [expose] mirror is the piece that replaces most “fetch on mount, refetch on focus, invalidate on mutation” client code. The server writes its signal whenever and however it likes. Every connected client’s copy updates. That’s it.

Three patterns follow from it:

  • Derive views locally. Expose one tasks list and let each page derive it down (filter by workspace, sort by date). Don’t add an rpc per view.
  • Mutate via rpc, observe via mirror. Your create/delete handlers write the server signal. The confirmation the user sees is their own change arriving back through the mirror.
  • Edit through drafts. Bind text inputs to drafts whose commit is the rpc, and adopt mirror updates into them. Typing stays instant, remote edits fold in, and your own echoes are no-ops.

Reading a mirror

A mirror is a RemoteSource<T>, not a SignalCell<T>, for one honest reason: before the first update lands it has no value, and nothing about the type pretends otherwise. You read it one of four ways:

  • mirror.or(initial) — the common one, for a view. A pipe you hand to bind_text or a {…} hole (seal it with .memo() for each, which takes a source): initial until the first sync, the mirrored value after. Building it opens nothing, and the view that consumes it is what subscribes: that opens the channel, and it is released when the view is unmounted.
  • mirror.derive(|value| …) — the same, with the Option<T> in your hands once, which is where a fallback of a different type belongs ("loading…" from a RemoteSource<i32>). or is derive for the same-type case. Seal with .memo() where one value is read in several places.
  • mirror.sub(|value| …): Subscription — the manual form: an observer of present values, and a handle you dispose yourself. For code with no view and no owner (a probe, a script).
  • mirror.get(): Option<T> and mirror.status(): SignalCell<Status> (Waiting / Ready / Absent / Failed(RpcError)) — passive reads. They open nothing. The last two arms are a handle mirror’s (below): a call that answered “no such source”, and a call that failed.

A mirror is also a Source<Option<T>>, so everything the trait gives every other observable value is on it: on_change (the lazy attach — no immediate call, and the channel’s first frame is a change), effect / effect_on_change, and any generic S: Source<…> function, selector included. The trait argument is Option<T> because that is what a mirror holds; a RemoteSource<List<Note>> is therefore not a Source<List<Note>>, and each still takes mirror.or([]) rather than the mirror. sub has one spelling per view of the value: mirror.sub(|note| …) is the inherent present-only one above, and the trait’s sub — reached through a generic receiver — hands you the Option<T>. A derivation over a mirror is an ordinary pipe: it leases nothing until it is consumed, and the consumer’s lease is the one the owner rule is asked of.

import std::json::json_codec;
import std::reactive::{ Signal, SignalCell };
import std::result::Result::{ self, Ok, Err };
import std::rpc::SocketTransport;
import std::shared::Shared;
import std::web::ui::{ View, each, mount_root };

[derive(Wire, PartialEq, Debug)]
struct Note {
	id: i32,
	text: str,
}

[service(NotesClient)]
struct Notes {
	[expose] entries: SignalCell<List<Note>>,
	next_id: Shared<i32>,
}

fun notes_panel(client: NotesClient<SocketTransport>): View {
	// Counted, and released when this view is unmounted: the channel is
	// open while — and only while — the panel is showing. `[]` until the
	// first sync; the empty list takes its element type from the mirror.
	let entries = client.entries.or([]).memo();
	<ul>{each(entries, |note| note.id, |note| <li>{note.text}</li>)}</ul>
}

async fun main() {
	match NotesClient::connect("/", json_codec()) {
		Ok(let client) => {
			let _root = mount_root("app", || notes_panel(client));
		},
		Err(let error) => print(i"connect failed: {error.debug()}"),
	}
}

Subscription follows demand. Every consumer — a binding, an each, an effect, a .memo(), a sub — takes a counted lease on the channel, whether it sits on the mirror or on an or/derive over it: the first one sends Subscribe, the last release sends Unsubscribe (deferred to the end of the turn, so a view that re-renders in place churns nothing). Ten bindings on one mirror cost one channel; unmounting the page closes it. The owner is asked where the lease is taken: an effect on a mirror (or on its or) needs an ambient owner (inside a view, or under run_with_owner) — a network subscription with nobody to release it is a compile error, not a slow leak — and a .memo() ties its lease to the owner that is ambient.

One sentence to keep in mind: status reports; it does not ask. A status() observer alone never sees Waiting → Ready, because nothing opened the channel — the mirror stays Waiting until something that renders the value (a binding over or, an effect, a sub) subscribes. That is the passive read being honest, and the count is what makes the active ones cheap.

Handles: a method that returns a source

[expose] names its channels at compile time, one per field per connection, and that is right for state the whole client watches — a task list, a topic. It is wrong for state the client watches a fraction of. A chat with a hundred thousand messages cannot expose them all and cannot expose them one field at a time.

An [rpc] method whose return type is a source is the other half. The server hands back a SignalCell<T>; what crosses the wire is a channel id, and what the client’s stub answers is a mirror:

the server writesthe client’s stub returns
SignalCell<T>RemoteSource<T>
Option<SignalCell<T>>RemoteSource<T>
MemoCell<T>RemoteSource<T>
Option<MemoCell<T>>RemoteSource<T>
KeyedCell<K, T>KeyedSource<K, T>
HashMapEntry<K, V>, MemoEntry<K, V>RemoteSource<Option<V>>

A handle is only ever read over the wire, so the read-only seal of a derivation — MemoCell<T>, what .memo() answers — crosses exactly as the writable cell does. Keep it on the service (a field, or a Memo whose maker writes .memo_global()) rather than sealing one per call.

A map’s per-key handle — messages.at(id) on a HashMapCell, or at(id) on a sealed map pipe — crosses as a mirror of Option<V>, the same mirror a MemoCell<Option<V>> reply makes, and needs no view kept for it: the forward rides the key’s own slot in the cell, so a write to another key sends that mirror nothing.

The stub is sync, and it makes no call. No async, no !, no Result — because there is nothing to await: the mirror is handed back unleased, and the first lease is what issues the call, mints the channel and seeds it. A handle nothing watches costs nothing at all — not a call, not a capability on the server, not a frame.

Both written forms map to the same mirror. An Option<SignalCell<T>> does not become an Option<RemoteSource<T>>: a mirror already has a word for “the server has no such source” — Status::Absent, with get() answering None — and wrapping it would ask you to take apart an absence that was only true at the instant of the reply and then hold whichever half you got forever.

[service(ChatClient)]
struct Chat {
	[expose] topic: SignalCell<str>,
	bodies: Shared<List<(str, SignalCell<MessageBody>)>>,
}

impl Chat {
	// The index: cheap, plain, and a hundred of them is one reply.
	[rpc]
	fun get_messages(self, conversation: str, amount: i32): List<str> { … }

	// The detail: a handle per message. The client subscribes to the ones
	// on screen and to no others.
	[rpc]
	fun get_message(self, id: str): SignalCell<MessageBody> {
		self.cell_for(id)
	}
}

// At the client — no await, one mirror, read like any other:
let ids = client.get_messages("general", 100)!;
let body = client.get_message(ids[3]);
<p>
	{body.derive(|value| match value {
		Some(let message) => message.body,
		None => "loading…",
	})}
</p>

Everything you already know about a mirror applies to this one: it is a RemoteSource<T>, you read it with or / derive / sub / get / status, and subscription follows demand. That last rule is what makes the shape affordable — a hundred handles held and ten watched is ten calls and ten forwards, and the other ninety are free.

A failure is a status, not a return. A sync stub has no Result to put one in, so what the minting call was told is reported where every other fact about a mirror already lives:

status()what happened
Waitingnothing has arrived — including “nothing has been asked”, which is what an unwatched handle reads, forever
Readythe mirror holds a value
Absentthe call answered None — this is a method written Option<SignalCell<T>> saying there is no such source
Failed(error)the call failed, and this is what it said

Absent and Failed are both answers about now: the mirror keeps whatever it last held, and the next 0→1 lease asks again. So a row that re-renders after a failure retries by itself, and a view that shows a spinner or a retry button reads status() to decide which.

A mirror is also a transient source: state() answers the same facts as a TransientState — Waiting is Pending, Ready is Ready(v), Absent is Absent, and Failed(e) is Failed(e, stale), carrying what the mirror last held. latest() and is_pending() are pipes that lease the mirror while a view binds them: bind latest() to keep the last value on screen while a re-mint is on its way.

One handle per origin. A stub call is keyed by its origin — the method and its arguments, as the wire would carry them — and the client hands back the mirror it already minted for that origin: two views calling get_message(id) share one mirror, one call and one Subscribe. The entry lasts as long as something watches the mirror; once its last lease closes, the next call mints afresh (and a mirror you kept a handle to still works — its next lease re-issues its call). The server collapses the channel the same way underneath: a reply carrying a source it has already exported answers the channel it already minted, and withdraws it only when the last mirror lets go.

That is what makes a stub safe inside a selector. Under A124’s cold nodes and_then ran its select on every read, so a select that called a stub minted a fresh mirror per read — a .cell() leased one mirror and refreshed from another, and read [] forever. A pipe’s selector runs once per change of its input, inside its one instance, and with the per-origin table every call answers the SAME mirror besides:

let channels: MemoCell<List<i32>> = client_cell
	.and_then(|client| client.get_channels())
	.derive(|ids| ids.unwrap_or_default())
	.memo();

A Memo is no longer needed to get one handle per id; reach for one when you want to keep something built over the handle — and then what the maker builds outlives the caller, so a derivation in a maker is .memo_global() (.cell_global() for a handle), never .memo().

The element must be Wire, not the source. The SignalCell never crosses; its values do, one Update frame at a time. So the Wire rule lands on T, and a handle over a non-Wire element is refused naming the element rather than the wrapper.

The contract hash covers the mapped type. get_message’s surface entry is written get_message(str)->RemoteSource<MessageBody>; — what the client will see, because the hash exists to protect the client. A server that changes a handle return to a plain value moves the hash and a stale client is refused at connect, instead of decoding a channel id as a message.

A handle method is WebSocket-only. Its reply is a channel id minted in this connection’s capability table, so the connectionless legs cannot serve one. Over POST {mount}rpc the call fails at its first lease and status() reads Failed, naming the method — the service’s plain methods keep answering beside it, and the same mount’s WebSocket leg serves the handle fine, so it is the route the client dialled that is wrong and not the mount. In process, a local_rpc transport over a protocol no for_connection stamped is refused where it is wired, before the first call: register the session and stamp it (register_session(id, end, codec), then into_protocol(codec).for_connection(id)) and handles work in process too, which is how vilan/examples/rpc is written. Everything a generated Client::connect builds rides the socket, so reaching this at all means having assembled the client by hand. In process, delivery is inline: a duplex_pair answers a mirror’s Subscribe with its seed inside the send, where a socket answers on a later task. So a subscriber over a mirror can be told the seed twice in process and once over a socket: the same value, one more notification. That is the in-process transport’s contract, and a count of notifications is measured over a socket.

A handle-returning method must be safe to re-run. Its return type is the declaration that it is a getter: the runtime issues the call at the first lease, again when a released mirror is watched afresh, and again after a reconnect. Write it as a lookup — self.cell_for(id) — not as something that counts, charges, or appends. (This is also why the method body does not run when you call the stub: nothing has been asked yet. If you need a server-side effect, that is a plain [rpc] method, not a handle.)

Return a cell that outlives the call. The server dedups a reply by the cell it carries, so a getter that answers a cell the service keeps — a field, a row’s cell — is one channel however often it is asked. A body that ends in .derive(..).cell() mints a fresh cell per call instead: the dedup never hits (one capability and one forward per call), and the compiler warns at that .cell(). Every handler runs under the owner of the instance that answers it: a Service::factory instance is one per connection, so its handlers run under the connection’s owner and such a cell’s subscription is released when the connection closes; a Service::new instance answers every client, so its handlers run under the service’s owner and what they build lives as long as the service. Either way the fix is to keep the derived cell, keyed by the arguments, in a Memo on the service whose maker writes .cell_global():

[rpc]
fun get_channel_ids(self): SignalCell<List<i32>> {
	self.derived.get_or_insert("ids", || self.channels.derive(|all| all.keys()).cell_global())
}

A cache the connections share outlives each of them. A Service::factory handler that stores a derived cell on a store every instance shares — a module-level cell, through Shared::write or a get_or_insert maker — builds it under its connection’s owner, and the store hands it out dead once that connection closes. The compiler warns at a .cell() a handler stores that way (a Memo maker’s warns wherever it is written). A derivation cached across calls is .cell_global().

When the server frees it. Demand decides. A mirror’s last lease going away sends Unsubscribe, and for a channel a reply minted that withdraws the capability whole: the forward stops, the starter is dropped, and the source it captured is released. Nothing accumulates over a long session. The mirror remembers the call it came from, so the next lease re-issues it, rebinds to the fresh channel, and paints its last known value until the first update lands. (An [expose]d field channel does not work this way: its Unsubscribe is demand-only and its capability survives, because it is minted once per connection and a remount must find it on the same id.)

“Last lease” means the last one on the channel, not on your mirror: where two mirrors ended up sharing a channel because they named the same source — two methods answering one cell, say — the first to let go withdraws nothing. The client keeps one forward per channel, at the union of its mirrors’ demands: the second mirror’s lease joins what the first already holds, nothing goes on the wire, and the client seeds it from its sibling, so it reads the channel’s value at once. On a keyed channel a whole-collection lease covers every key: a sibling’s sub_key(k) beside it asks the server for nothing and is seeded with the sibling’s element for k. Each mirror takes only the changes its own leases ask for, so a mirror holding one key is never told about the others, and no change reaches a mirror twice.

Two consequences worth having in mind. A dispose and a remount anywhere inside one macrotask — two event handlers, a route change, an each rebuilding rows — send nothing: a handle’s close waits for the turn’s settle and then one microtask, and a lease returning in that window cancels it. (A [expose]d field mirror keeps the prompter cadence: its close is due at the settle and goes then, because its channel survives it and there is nothing to buy by waiting.) And a handle held inside a view — anywhere an owner is ambient — that nothing ever leases is released when that owner is disposed; held with no ambient owner (at the top of main, say) it lives with the connection, which frees every channel it ever minted when the socket closes. A handle that never reached a lease has nothing to free either way: it never asked.

Keyed mirrors: [expose(keyed)]

A plain [expose] sends the whole value on every change. That is the right trade for a settings record or a counter, and the wrong one for a collection that grows: exposing a message platform’s five thousand messages means every edit, every post and every delete resends all five thousand, to every connected client.

[expose(keyed)] makes the collection keyed, and then only what moved crosses. Two things are required of it:

  • the key type is written somewhere the expansion can read it, before any type resolves — a HashMap<K, V> element names it in the collection and takes the bare [expose(keyed)]; every other collection names it in the attribute, as [expose(keyed = K)];
  • the value implements Keyed<K> (its own identity) and PartialEq (what “changed” means).

The two spellings are one contract: same frames, same KeyedSource<K, T> on the client, same surface entry, same contract hash. What differs is only what the server stores.

import std::reactive::SignalCell;

[service(TaskClient)]
struct Tasks {
	// A list needs the key in the attribute: `List<Task>` names only the
	// element, and a keyed mirror is a `KeyedSource<K, T>`.
	[expose(keyed = str)] items: SignalCell<List<Task>>,
}
import std::hash_map::HashMap;
import std::reactive::SignalCell;
import std::wire::{ Keyed, Wire };

[derive(Wire, PartialEq, Debug)]
struct Message {
	id: str,
	channel: i32,
	body: str,
}

impl Message with Keyed<str> {
	fun key(self): str {
		self.id
	}
}

[service(ChatClient)]
struct Chat {
	[expose] topic: SignalCell<str>,
	[expose(keyed)] messages: SignalCell<HashMap<str, Message>>,
}

The server writes self.messages exactly as before — insert, remove, whatever it likes. What changed is the wire: the channel diffs successive snapshots by key and sends a Patch of Delta ops (Reset, Insert, Update, Remove) instead of an Update carrying the whole map.

A third spelling that skips the diff: KeyedCell<K, T>

Diffing is what a SignalCell forces: the channel is handed two snapshots and has to work out what moved, which re-keys the whole collection on every change — and pays for it once per subscribed connection. Only the mutation knows what changed, so KeyedCell<K, T> is the field type that says it:

import std::rpc::KeyedCell;

[service(ChatClient2)]
struct Chat2 {
	[expose] messages: KeyedCell<str, Message>,
}
fun edit(messages: KeyedCell<str, Message>) {
	messages.insert(Message { id = "m9", channel = 1, body = "hello" });
	messages.update("m9", |&mut message| {
		message.body = "hello again";
	});
	messages.remove("m9");
}

Each write appends the Delta op it is, and a subscriber’s forward sends the ops since it last looked. The cell names both its key and its element in its own type, so keyed is redundant on it and there is nothing for keyed = K to add; and PartialEq is no longer required of the element, because nothing is compared.

Nothing on the wire changes — the same Patch frames, the same KeyedSource<K, T> on the client, the same contract hash as the [expose(keyed = str)] SignalCell<List<Message>> spelling, so a service may swap one field for the other without breaking a deployed client. What changes is the cost per change per connection: 0.0078 ms at 1,000 rows and 0.0103 at 10,000, where diffing the same edits costs 0.315 and 3.814 (children CPU). Bind it locally with each(messages, …) — it is a Source<List<T>> with no Option in it, because a cell always holds a collection.

A keyed handle: a method that returns a KeyedCell

[expose(keyed)] names one collection per field per connection, which is the same limit the plain form has — and the same answer applies. An [rpc] method whose return type is a KeyedCell<K, T> hands the client a KeyedSource<K, T>, minted per call:

impl Board {
	// One board per workspace — not a set the compiler can name as fields.
	[rpc]
	fun tasks_in(self, workspace: str): KeyedCell<i32, Task> {
		self.board_for(workspace)
	}
}

// At the client — sync and unleased, exactly like a plain handle:
let tasks: KeyedSource<i32, Task> = client.tasks_in("alpha");
<ul>{each(tasks.or([]), |task| task.id, |task| <li>{task.title}</li>)}</ul>

Everything a plain handle does, this one does at the keyed type: no call until the first lease, status() for what that call was told, and demand deciding the channel’s life. The one difference is what counts as demand — a per-key lease is a first demand too, and mints the channel exactly as a whole-collection one does — and what the channel is finished with: a keyed channel carries one forward per demand, so it is withdrawn when the last of them goes, not when the first does.

An Option<KeyedCell<K, T>> is not a handle return in v1; it is refused as a non-Wire return like any other unrecognized type.

Reading a keyed mirror

The mirror is a KeyedSource<K, T>, and it holds exactly what this client subscribed to, in the server’s order. It reads two ways.

The whole collection — the same four calls a RemoteSource has, with List<T> in place of T:

fun feed(client: ChatClient<SocketTransport>): View {
	let messages = client.messages.or([]);
	<ul>{each(messages, |message| message.id, |message| <li>{message.body}</li>)}</ul>
}

One key — the part a plain mirror has no spelling for:

fun message_row(client: ChatClient<SocketTransport>, id: str): View {
	// Counted per KEY: this opens a subscription for `id` alone, and the
	// server forwards that message's changes and nothing else. Released
	// when the row unmounts, like any other lease.
	let message = client.messages.of(id);
	<li>
		{message.derive(|held| match held {
			Some(let found) => found.body,
			None => "…",
		})}
	</li>
}

of(key) is the view seam (owner-released, like or/map); sub_key(key, |value| …) is its manual form, for code with no owner. get(), status() and fault() are passive.

Whole-collection demand subsumes per-key demand. Holding both leases on one channel does not double anything: while the whole collection is held, a key asks for nothing, and when the whole lease is released the keys still held take the wire back. You never have to reason about which of two forwards a message arrived through.

fault() is the keyed mirror’s one extra read: Some(reason) if a patch ever named a key the mirror does not hold. That is a server bug or a lost frame — never something application code can cause — so the op is refused rather than applied, and the first fault is the one kept.

What it costs

Measured over twenty channels of fifty messages (reactive_channels.rs), server → client bytes for one event:

event[expose], whole platform[expose], one channel[expose(keyed)]one key leased
open the channel123,8166,156123,827160
edit one message123,7536,0939595
edit another message123,6936,033980
post a message123,8136,0331630
delete a message123,6915,911340

Three things that table says out loud:

  • a keyed channel is not cheaper to open — it sends the collection once, as a Reset. Leasing one key is what makes a seed small;
  • an edit costs the message, not the platform;
  • a per-key subscriber is told nothing about keys it did not ask for, which is the column no amount of client-side filtering can produce.

The rules worth knowing

  • A reorder falls back to Reset. There is no move op: when the elements retained across a change are not in the same relative order on both sides, the whole collection goes again. Appending, editing and deleting never reorder, so the fallback is rare — and correct where a cleverer diff would be subtly wrong.
  • A reconnect clears the mirror first. A plain mirror may keep its last value across a reconnect because the fresh subscription resends the whole value; a keyed forward reseeds only what it is asked for, so a keeping mirror could hold an element deleted while the connection was down and no later op would ever name it. The keys you still hold are re-subscribed and re-seeded.
  • A key written twice has to agree. A HashMap<K, V> element names the key and takes the bare [expose(keyed)]; writing the argument beside it as well is redundant but fine — while the two spellings agree. A [expose(keyed = i32)] over a SignalCell<HashMap<str, Message>> is refused at the attribute, naming both spellings. Neither is knowably the intended one, and the expansion reads K off the annotation before any type resolves, so it has nothing to pick between them with: drop the argument, or write the map with the key the argument names.
  • The contract hash moves — and only for services that use the form. A keyed exposure is its own surface entry, so a client built against [expose] will not connect to a server that has since made the field keyed. That is the point: the frames differ.
  • Hand-wired exposures have the same three shapes. ReactiveServer::expose_keyed(source, key_of) for a List<T>, expose_keyed_map(source, key_of) for a HashMap<K, V> and expose_keyed_cell(cell) for a KeyedCell<K, T> (no key_of — the cell names its key), with ReactiveClient::attached_keyed_source / keyed_source on the other end. [expose(keyed = K)] generates the first, [expose(keyed)] over a map the second and [expose] over a cell the third, frame for frame — the hand-wired form is the escape for a source none of the three attributes can name, not for the List.

Connection state and reconnection

Connections drop. The transport handles it: it reconnects with backoff, re-verifies the contract, and re-attaches every mirror, so state resyncs on its own. Your code sees two things.

First, a signal you can bind a banner to:

let state = client.transport.connection_state();
<p .show(state.derive(|current| current == ConnectionState::Reconnecting))>"reconnecting…"</p>

Second, explicit call failures. A call in flight when the connection drops rejects with “connection lost”. A call made while down fails immediately with “not connected”. Nothing is silently retried, because an rpc might not be safe to repeat. Retrying is the app’s decision: a draft’s next push, or the user pressing the button again.

Third — when you ask for it — a hook that runs once the connection is back and the mirrors have resynced. That is the moment to re-send whatever the outage swallowed, and for an edited draft it is one line:

let title = draft(page.title, |value: str| { … client.rename(value) … });
client.transport.on_reconnect(|| title.repush());

Without it the user’s text survives in the input but never reaches the server until they type again. With it, the reconnect carries it. repush sends only when the remote is actually behind, and its delivery is at-least-once — see local-first drafts.

Going deeper. The backoff dials at 250 ms doubling to a 4 s cap, ten attempts before giving up (Closed). Mirrors rebind by re-running the contract check and re-attaching each subscription; you never re-subscribe manually — and if either step is refused, the connection goes Closed too, rather than reporting itself live over mirrors that can no longer update. Reaching Closed — by any of those routes, the spent budget included — also tears the client’s own wiring down, since those mirrors are unreachable from that socket forever; a redial that is merely slow does not. The full state machine is in the rpc reference.

Authentication

The straightforward shape, and the one a first draft usually reaches for: a login rpc returns a token, later rpcs take the token as their first parameter, and the server validates it per call.

[rpc]
fun login(self, username: str, password: str): AuthOutcome { … }

[rpc]
fun create_task(self, token: str, workspace_id: i32, name: str): i32 { … }

It works, and it has two costs: every method carries a parameter that is not about what the method does, and the socket itself is open to anyone — a client that never logs in still connects, still holds a connection, and still receives whatever the service exposes.

Authorizing the connection

The refinement is to decide once, at the handshake, before a socket exists: Service::authorize.

Service::factory(|connection: Connection| Store {
	user = connection.session.identity,
	notes = Signal::new([]),
}, json_codec())
	.authorize(|handshake: Handshake| match handshake.token() {
		Some(let token) => match verify(token) {
			Some(let subject) => Result::Ok(Session::of(subject)),
			None => Result::Err(Reject::Forbidden),
		},
		None => Result::Err(Reject::Unauthorized),
	})

Err answers the socket a status and destroys it: no connection id, no reactive session, no service instance — nothing of the service is built for a client it refused. Ok(session) becomes Connection.session, which the factory reads to build that client’s instance. So identity arrives on the handshake, and the methods lose their token parameter.

Four arms, and the line they are drawn on is who decided:

RejectStatusThe refusal it makes
Unauthorized401no credential, or one that did not verify
Forbidden403a good credential for someone who may not have this
TooMany429a limit, not a judgement — max_connections, the handshake rate, authorize_timeout, or your own
Unavailable503the app’s own “not now”: the token may be perfect and the client the only one asking, and the database is down or the node is draining

503 is the one arm that reports a judgement the app made, which is why std never answers it on your behalf: a verifier authorize_timeout cut off said nothing at all, so reporting its silence as the app’s judgement would be a claim std cannot make. That refusal is a 429 — below.

The mechanism is yours. Vilan verifies nothing and knows no token format — authorize may await, so signing checks (std::jwt, WebCrypto) belong right there.

How the credential gets there. A browser cannot put a header on a WebSocket handshake. The one field it can fill is the subprotocol list, so that is the convention: ["vilan-rpc", "token." + credential], which std::rpc::rpc_protocols(credential) writes for you.

let client = TodoClient::connect_with(url, json_codec(), rpc_protocols(token))!;

The server selects vilan-rpc, echoes it in the 101 (required — a browser closes a connection whose subprotocol offer went unanswered), and hands the whole offer list to authorize as Handshake.protocols; handshake.token() picks the "token." one out. A subprotocol is a token, not a header value: no commas, no spaces — every JWT already qualifies. The same list is re-presented on each reconnect, so an authorized connection re-authorizes itself automatically.

Client::connect(url, codec) carries no credential and offers only vilan-rpc, which is right for a service with no gate — and enough for one with a gate to tell it apart from a stranger, which is what the next section is about.

An authorized service answers only the WebSocket upgrade: the connectionless SSE and POST legs carry no handshake to authorize, so they answer 401 rather than standing open as the way around the gate — the rpc leg with a typed RpcError::Unauthorized envelope. The POST leg can be gated in its own vocabulary instead, with a second hook: authorize_request.

A refused client is told, and stops

connect/connect_with against a credential the server refuses returns RpcError::Unauthorized, on the first attempt.

That reads like a triviality and is not one. No host WebSocket shows a client the HTTP status of a failed handshake. Measured on node 24 (undici’s global WebSocket, which is what std::rpc binds): a 401, a 403, a 429, a socket destroyed mid-handshake, a refused TCP connection and a server with no upgrade handler all raise the identical error and the identical close code. The browser API exposes less still, deliberately. So a refused client could not tell “my token is wrong” from “the server is down”, and did the only safe thing — ten redials over about 24 seconds of backoff, then Transport("could not reach …"), which is a false sentence about a server that answered immediately.

The fix is that the refusal is sent, not inferred. A client that offered vilan-rpc — every vilan client — is upgraded and handed one frame naming the status before the socket closes. Everything else still gets the plain HTTP status line: a browser opening the URL, curl, a probe, a health check, a scanner. The HTTP semantics of a refused handshake are unchanged for everything that speaks HTTP and not this protocol.

The cost is one 101 and one small frame per refused vilan client, on a socket destroyed in the same turn either way. It is bounded by which refusals are eligible: Reject::TooMany is never one, and TooMany is exactly what max_connections, handshake_rate and authorize_timeout produce — so the refusals a flood produces are the ones that never upgrade, and nobody can reach the upgrading path more often than the rate limiter admits.

401 and 403 both arrive as RpcError::Unauthorized; the client’s answer to either is the same, and this credential will not open this connection. 503 arrives as its own arm, RpcError::Unavailable, because the answer to it is a different one: not “not with this credential” but “not now” — retry later, and re-authenticating is beside the point.

Cheap limits, with or without a gate

Four knobs on the same seam, all refusing at the handshake, all working with no authorize at all:

Service::new(protocol)
	.max_connections(500)          // 429 over the ceiling
	.handshake_rate(20, 10000.0)   // 20 handshakes per client per 10s
	.handshake_timeout(5000)       // a socket that never greets is destroyed
	.authorize_timeout(2000)       // a verifier that never answers is refused

max_connections counts upgraded sockets on this mount, and nothing else: not the SSE or POST legs, which hold no connection to count, and not a handshake still inside authorize. A service whose clients arrive over {mount}events is not limited by it at all.

handshake_timeout bounds the greeting, not idleness: the first inbound byte disarms it, so a client that connected and is only watching mirrors is never touched.

authorize_timeout is its twin on the other side of the upgrade. The hook is awaited inside the upgrade handler, so a verifier that hangs — a token endpoint that stopped answering, a database that is gone — holds an unanswered socket for as long as it hangs, one per client trying to connect. That is a denial of service the server inflicts on itself, reached without a single malformed byte from anyone. handshake_rate caps how fast the pile grows; only this caps how big it gets. A hook that answers late is not raced back in — the socket is already gone.

Its refusal is 429, not 503, and the reason is the line the table above draws. 503 is the app’s own judgement, given from its own authorize as Reject::Unavailable; a verifier that ran out of time gave no judgement at all, and std answering 503 on its behalf would report a decision the app never made. A timeout is std’s limit — which is also the reading the client wants: it says nothing about the credential, and it is the one refusal to retry.

Behind a proxy, every client shares the proxy’s socket address, so a per-address handshake_rate becomes a global one that refuses everybody as soon as one client is noisy. trust_forwarded_for(true) reads the client’s address from the first entry of X-Forwarded-For instead — for handshake_rate, for Handshake.remote_addr, and for Connection.remote_addr, one switch for all three.

Service::new(protocol)
	.trust_forwarded_for(true)
	.handshake_rate(20, 10000.0)

It is off by default and must stay off unless a proxy you control is the only way to reach the server: the header is written by whoever spoke last, so a directly-exposed server that trusts it lets any client pick its own rate-limit bucket — and its own audit trail — by writing one line.

Where the service lives

The service lives next to the resources its methods use: a database handle, the filesystem, other services. In a single-package app (one [package] with an [entry.client] and an [entry.server]; see Platforms), that’s a module both entries can see:

// src/store.vl — bodies use server std directly
[service(TodoClient)]
struct TodoStore { … }

// src/client.vl
import pkg::store::TodoClient;

In a multi-package workspace the same idea reads: the service sits in the server package, and the client package depends on it, importing only the generated client (import server::store::TodoClient;).

Either way, the browser build takes only the stub and the contract hash from that module; the method bodies and the dispatcher are server-colored and out of its reach. A shared common library is still a fine home for the payload types both sides speak; it’s no longer the only legal home for anything.

The server side

Server::builder()
	.port(port)
	.with_service(Service::new(service.dispatcher().into_protocol(json_codec())))
	.serve_build(require_build("client"))
	.on_request(|request| …)   // every path neither claims: the app shell
	.on_start(|server| …)      // `server.port()` is the bound port
	.build()
	.start();

dispatcher() is generated by [service], into_protocol pairs it with a codec, and Service::new mounts the result at / — with_service installs its routes and its WebSocket handshake on the builder. serve_build serves the client leg’s own artifacts from the build’s description rather than from paths you typed, and on_request answers every plain http request neither of them claims — return the app shell there, so deep links work (see Routing; the shell, read and checked against the build, is in Persistence). For custom per-connection state, Service::on_connect/on_disconnect replace the default session lifecycle (see the rpc reference).

One instance per connected client

Service::new mounts one instance for the whole process: every client is answered by the same self, so a method cannot tell who called and an [expose]d field is one cell shared by everybody. When a method needs to know its caller — which is most apps the moment they have users — mount the service with a factory instead, and the struct instance becomes the connection’s session:

Server::builder()
	.port(port)
	.with_service(Service::factory(|connection: Connection| Store {
		user = user_of(connection.session.identity),
		notes = Signal::new([]),
	}, json_codec()))
	.build()
	.start();

build runs once per connection, with that connection’s Connection (id, the session an authorize hook proved, and remote_addr), and every route of that connection’s dispatcher closes over the instance it returned. Three things follow:

  • A method reads its caller off self — no token parameter, no per-call re-resolution.
  • [expose]d fields are per client: each instance has its own cells, so each connection’s mirrors carry that client’s own data.
  • The instance dies with the connection, alongside the reactive session on_disconnect already releases.

Service::new(protocol) is the stateless shorthand for Service::factory(|_connection| shared, codec), and stays exactly as useful for a service with nothing per-client to hold.

One limitation, stated plainly: the connectionless POST {mount}rpc leg has no connection to build an instance for, so a factory service refuses it (501, with the reason in the body). A factory service is reached over the WebSocket transport — which is what every generated Client::connect uses.

Mutable session state

That instance is the connection’s session, so its fields are ordinary mutable state — and the receiver you write on the method decides where the write goes.

&mut self is the idiomatic one. The connection’s dispatcher is built once, around that connection’s instance, and a &mut self method writes to it in place — so the write is still there on the next call over the same socket, and no other connection can see it:

[service(GateClient)]
struct Gate {
	is_authenticated: bool,
}

impl Gate {
	[rpc]
	fun login(&mut self, password: str): bool {
		if password == "hunter2" {
			self.is_authenticated = true;
		}
		self.is_authenticated
	}

	[rpc]
	fun secret(self): str {
		if self.is_authenticated { "the answer is 42" } else { "sign in first" }
	}
}

mut self is refused on an [rpc] method, naming the method. It reads like the same thing and is not: mut self is a copy the handler may write, so the reply carries the new value and the next call reads the old field — a write lost in silence. The compiler says so instead:

`[rpc]` method `login` takes `mut self`, and an `[rpc]` method's `mut self`
copy is discarded after the call: the handler mutates it, the reply carries
the new value, and the next call on this connection reads the old one. Write
`&mut self` to mutate this connection's instance, or hold the state in a
`Shared<T>` field

Shared<T> is the third spelling, and it is not merely the older one: reach for it when something other than a method body must reach the state — a callback the service stored, a timer, a task that outlives the call. A &mut self borrow lasts for the call; a Shared<T> handle is the instance’s own cell and can be captured. A [expose]d SignalCell is the same idea with a wire behind it.

The other direction: calling the client

Sometimes the server is the one with news. A session was revoked, a background job finished, another user moved something on the screen you are looking at — facts the client cannot poll for without asking a hundred times to hear once.

Declare the functions the server may call on a struct in the browser half and mark it [client_service]:

[client_service]
struct KoltHandlers {
	revoked: SignalCell<str>,
}

impl KoltHandlers {
	[rpc]
	fun session_revoked(self, reason: str) {
		self.revoked.set(reason);
	}
}

Then name it on the service, and hold the generated proxy as a field:

[service(KoltClient, client = KoltHandlers)]
struct KoltStore {
	user: str,
	client: KoltHandlersProxy,
}

impl KoltStore {
	[rpc]
	fun sign_out(self): bool {
		self.client.session_revoked("signed out elsewhere");
		true
	}
}

Service::factory(|connection: Connection| KoltStore {
	user = connection.session.identity,
	client = connection.client(),
}, json_codec())

connection.client() needs no annotation: the FIELD’s declared type is what the attribute named, and that is what resolves it. The proxy is the connection’s id and nothing else, so it is cheap to copy into as many places as want it, and it goes quiet the moment the connection closes.

On the browser side, mount the handler instance on the connected client:

let handlers = KoltHandlers { revoked = Signal::new("") };
let client = KoltClient::connect(url, json_codec())!.with_handlers(handlers);

Notifications only, in this version. A [client_service] method declares no return type and is refused if it does — : void included, which is the one place that spelling is not interchangeable with omission. A server→client notification has no reply lane to settle on and no pending table to correlate with, so there is nothing for a value to come back through, and nothing to wait for either: self.client.session_revoked(reason) is synchronous, returns nothing, and cannot fail visibly. That is why the awaited void above is a server-side shape only. If the client must answer, have its handler call back on the connection it already holds.

Both attributes on one struct is peer-to-peer, and needs no new spelling: one dispatcher, a transport client, and a proxy, all from the same [rpc] method set.

What the turn model owes you here

Three sentences, and they are the same three whichever direction a handler runs in.

  1. The router never blocks on a handler. A chunk off the socket is parsed into its events and every handler’s turn is STARTED before any of them is awaited. Two frames that TCP coalesced into one read are two handlers in flight, not a queue.
  2. A held turn is per handler, not per connection. Each dispatch establishes its own turn, so two handlers on one connection settle independently. Signal writes inside one handler coalesce into one wave, exactly as they do for a client→server call.
  3. Reply order within a chunk is not promised. Replies carry their request id and arrive when their handler is done — a slow first frame does not hold a fast second one behind it. Nothing in the protocol ever depended on the order; this only makes it visible.

One consequence of the first two, and it is now ENFORCED: two handlers in flight on one connection share that connection’s one instance. A &mut self method that never awaits runs to completion before the next handler starts and is safe by construction; a &mut self method that AWAITS can have its instance written by another handler underneath it. So an [rpc] method declared async may not take &mut self — the attribute refuses it, naming the method — and the rule is read off the KEYWORD and the receiver rather than off the body, because a handler that grows its first await a week later would otherwise turn silent. Keep a &mut self handler synchronous, or hold the state somewhere other than the receiver reaches (a Shared<T> field) when the handler must await.

What happens to a peer that has not heard of any of this

The contract hash covers BOTH directions. A service that declares client = KoltHandlers appends the handler surface to its own, so its hash moves — and a client generated against that surface is refused at __contract by a server that declares no handler, before it has a client value to mount handlers on and therefore before any reverse frame could be dispatched. A service that declares no client = … hashes exactly as it always did.

(The reverse lane is a s:<id>:<payload> text frame, or a 0x73 tag byte, beside the r:/d: lanes that were already there. Both routers ignore prefixes and tags they do not know, so a client too old to have a router arm for it drops the frame instead of failing — but the hash check above is what is meant to catch that case, and the silent drop is only the floor under it.)

Growing past one service

That chain is the whole layer — Service::new(protocol), installed with ServerBuilder::with_service. A service’s routes answer before on_request, which is what lets a page and a service sit on the same builder chain instead of one replacing the other:

import std::shared::Shared;
import std::json::json_codec;
import std::http::{ Response, Server };
import std::rpc::server::Service;

[service(TodosClient)]
struct Todos {
	count: Shared<i32>,
}

impl Todos {
	[rpc]
	fun add(self, by: i32): i32 {
		self.count.write() = self.count.read() + by;
		self.count.read()
	}
}

fun main() {
	let todos = Todos { count = Shared::new(0) };
	Server::builder()
		.port(4600)
		.with_service(Service::new(todos.dispatcher().into_protocol(json_codec())))
		.on_request(|request| Response::builder().body("app shell here").build())
		.on_start(|server| print(i"listening on {server.url()}"))
		.build()
		.start();
}

Delete the .with_service(…) line and the program still compiles and still serves the page — the property a boot function that owns the whole port can’t have. with_service is repeatable: a second service goes on its own mount, .at("/admin/"), so Client::connect("/admin/", codec) reaches it and the first service’s routes are untouched. Two constants either way: services always answer before on_request (so an app route can’t accidentally shadow a service route), and the connection lifecycle is the service’s own knob — Service::on_connect/on_disconnect swap the default session registry for the app’s per-connection state (an app-written attach), and Service::factory is the same knob for CONSTRUCTION — without changing anything else about the chain.

Reaching a service over plain HTTP

A service you can hold without a connection — no [expose]d field, no handle-returning method, no client = .. — gets a second constructor, for the connectionless POST {mount}rpc route the server already installs beside every mount:

let auth = AuthClient::over_http("/auth/", json_codec());
match auth.login(name.get(), password.get()) {
	Ok(let outcome) => match outcome {
		Ok(let token) => sign_in(token),
		Err(let message) => show_error(message),
	},
	Err(let failure) => show_error(offline_text(failure)),
}

This is the shape for a login door, and login is why it exists: the token a socket’s authorize reads back is what login RETURNS, so login has to happen before there is a socket to authorize. The nesting is the point — the outer arm is “did the call happen”, the inner is “what did the server decide”.

over_http takes the same mount string connect takes, and it makes no call: an HTTP client is unversioned unless you call verify(). There are no mirrors and no reverse direction over this leg, which is why the constructor exists only for a service that declares neither.

A service that is MEANT to be an HTTP API can say so, and hear about a mistake where it made it:

[service(AuthClient, http)]
struct Auth {}

The http marker generates nothing. It refuses, at the member that declared it, each thing the connectionless leg cannot carry — a method returning a SignalCell/KeyedCell handle, an [expose]d field, a client = .. handler — so a handle added to the login door a month later is an error on that method, not a puzzle at the page that can no longer build its client. Leave it off a service that wants both legs.

Gating the POST leg: authorize_request

A login door is open on purpose. The services after it are not, and a POST has no handshake for authorize to read — but it has headers, so it gets its own hook:

Service::new(Billing {}.dispatcher().into_protocol(json_codec()))
	.at("/billing/")
	.authorize_request(|request: Request| match request.header("authorization") {
		Some(let bearer) => match verify(bearer) {
			Some(let subject) => Result::Ok(Session::of(subject)),
			None => Result::Err(Reject::Forbidden),
		},
		None => Result::Err(Reject::Unauthorized),
	})

It runs on every POST, after the leg’s own method and content-type checks and before the frame is read. Ok(session) is stamped on that one request — RpcRequest.session, which a hand-written Dispatcher route reads — and Err answers the reject’s status: 401/403 as an Unauthorized envelope and 503 as Unavailable, the arms a refused socket already gives a vilan client, and 429 as a bare status, which a client reads as Transport(..).

A second hook rather than authorize reading a request, because the credential lives somewhere else — a socket’s in the "token." subprotocol, a POST’s in a header or a cookie — and a handshake.token() that answered None for a request carrying a perfectly good Authorization header would be a trap. The two compose, and neither opens the other’s leg:

installedthe socket upgradePOST {mount}rpc
neitheropenopen
authorize onlygated401 — no silent back door
authorize_request onlyopengated per request
bothgatedgated per request

The SSE pair is a connection, so it follows the upgrade’s column. A Service::factory service stays 501 on the POST leg whatever the hook says: the instance, not the identity, is what a POST cannot supply.

The client end of a bearer is over_http_with, which sends its headers on every POST the client makes:

let billing = BillingClient::over_http_with("/billing/", json_codec(), [("Authorization", i"Bearer {token}")]);

A cookie rides a same-origin POST without being asked; a token the page keeps in storage does not, and over_http carries no header at all.

CORS, and the credential

std does no CORS. The mechanism is already on the builder, a service’s mount is a string you wrote, and an allowed-origin list is a security decision std has no information for — so it is three lines of yours:

.on_request(|request| match request.method() {
	"OPTIONS" => Response::builder()
		.code(204)
		.set_header("Access-Control-Allow-Origin", "https://app.example.com")
		.set_header("Access-Control-Allow-Headers", "Content-Type, Authorization")
		.build(),
	_ => page(request),
})

Two sentences are the whole posture. A credential in a HEADER is CSRF-immune by construction: a cross-site page cannot set one without a preflight, and the rpc leg requires Content-Type: application/json, which a cross-site HTML form cannot produce. A credential in a COOKIE needs SameSite=Lax and that content-type check — and std will not read a cookie for you.

Traps

  • Mysterious contract-mismatch failures while developing usually mean an old server process is still holding the port. Check with ss -tlnp | grep <port> and kill it by pid.
  • The wire is value-semantic. A mirrored list is a fresh copy per update. Mutate through rpcs, never by writing the signal or/map handed you — that writes the local derivative only, and the next sync overwrites it.
  • An rpc handler’s reply is its return value, so the handler runs to completion before the client hears back. Long work belongs in spawned tasks that write signals when done.
  • Minting channels at runtime — an rpc that calls session_of + ReactiveServer::expose, with ReactiveClient::source(channel) on the other end, rather than [expose] — is hand-wiring, and what makes it hand-wiring is that the mirror has no origin: nothing on the client side records which call minted that channel, so nothing but your own code can decide when it is finished or bring it back. Two things follow, and [expose] gets both for free. Withdraw a channel you are done with, explicitly — ReactiveServer::revoke(channel) — rather than reading a client that stopped watching as a client that is done. And register invalidate_on_reconnect(socket, client) beside your ReactiveClient, because a reconnect mints a fresh session that has never heard of a channel your method minted: the mirror is invalidated (status back to Waiting), and re-running the rpc that minted it is your app’s job.