Server Functions and SSR
Defined across replay-control-app/src/main.rs, src/pages/, and src/server_fns/.
Leptos 0.7 SSR with WASM Hydration
The app uses server-side rendering with client-side WASM hydration. Six build profiles handle this:
| Environment | SSR Server | WASM Client |
|---|---|---|
Fast dev (dev.sh) | dev-fast (opt 0) | wasm-dev-fast (opt 0) |
| Debug / compact dev | dev (opt 1) | wasm-dev (opt “s”) |
| Prod | release (opt 3) | wasm-release (opt “z”) |
dev.sh uses the fast profiles because rebuild latency matters more than artifact size during iteration. wasm-dev remains available when a smaller development WASM payload is worth the extra compile time.
any_spawner::Executor::init_tokio()
Called at the top of run() in main.rs:
let _ = any_spawner::Executor::init_tokio();This is required for the Leptos reactive system to run async tasks during SSR. Without it, Resource async tasks silently don’t execute. Leptos’s generate_route_list and leptos_routes_with_handler initialize the executor automatically, but since this app uses render_app_to_stream_with_context directly (custom Axum routing), the executor must be initialized explicitly.
register_explicit
All ~70 server functions are registered explicitly in main.rs:
server_fn::axum::register_explicit::<replay_control_app::server_fns::GetInfo>();
server_fn::axum::register_explicit::<replay_control_app::server_fns::GetSystems>();
// ... ~68 more
This is necessary because the server functions are defined in the library crate (replay-control-app), not the binary crate (main.rs). Rust’s inventory crate auto-registration relies on linker magic that gets stripped when the functions are in a library – the linker sees no direct references from main to the inventory items and discards them.
First-Run Setup
Two server functions support the setup checklist:
get_setup_status(force)— returns which setup steps are pending (metadata downloaded, thumbnail index updated). Whenforceis true, re-checks even if previously dismissed.dismiss_setup()— sets thesetup_dismissedpreference so the banner no longer appears on the home page.
Resource Patterns
Resource::new_blocking (critical path)
Used for data that must be available in the initial HTML (no loading state). Blocks SSR until the data is ready:
let info = Resource::new_blocking(|| (), |_| server_fns::get_info());
let systems = Resource::new_blocking(|| (), |_| server_fns::get_systems());The home page uses new_blocking for system info and library data so the page has meaningful content on first paint.
Resource::new (streaming)
Used for data that can load after the initial HTML ships. The page renders with a skeleton/loading state, then streams the data via a <Suspense> boundary:
let recents = Resource::new(|| (), |_| server_fns::get_recents());
let recommendations = Resource::new(|| (), |_| server_fns::get_recommendations(6));Live device panels and nested streaming
The active-game overrides, Link Play, and Insider panels use LocalResource inside a shared ClientOnly boundary. The page shell is server-rendered; these panels mount after hydration and contain their own loading/error boundaries.
This is also a workaround for the current Leptos 0.7 nested streaming path: when these panels were built inside the page’s async Suspend, the server emitted a nested loading fallback without the markers expected by hydration. Merely changing Resource to LocalResource did not fix it. Revisit the client-only boundary when upgrading the rendering framework, with both direct loads and SPA navigation covered by the device browser regression test.
RePlayOS capabilities
get_replay_capabilities reads get_info and the global get_config snapshot without exposing the token or hardware details. DeviceCapabilities evaluates the shared ReplayFeature mapping: version support comes first, then Insider access for mapped systems, then Vulkan hardware support and Insider access, then the selected global video_engine for GameCube, Wii, Triforce, and PS2. RePlayOS currently exposes Vulkan only on Raspberry Pi 5; get_info does not provide a dedicated Vulkan hardware flag, so RC derives that check from model. An unlisted vulkan ID is treated as public on supported hardware, not as proof of unavailability. Game settings and basic playtime retain the 1.8.0 baseline; newer systems and Link Play require 2.0.0, while Wii, Triforce, and Virtual Boy first appear in 2.0.52. An unknown version disables gated controls with an explanation.
For a system RC maps to an insider_features ID, a listed ID requires insider_enabled: true; an unlisted ID is public on a supported RePlayOS version, including after promotion out of Insider. A missing list is treated as empty. Other 2.0 features such as Link Play use only their version gate. Older 1.8 responses have no Insider fields and are restricted by version first. Wii and Triforce provisionally share GameCube’s gamecube ID because all three use RePlayOS’s Dolphin core; Virtual Boy is version-gated but not Insider-gated. RePlayOS’s feature list is independent of token activation, so absence alone cannot distinguish a public promotion from unsupported hardware.
The browser shares one capability snapshot across launch controls, refreshing on Net Control connection changes and every minute while connected. Request failures are not treated as legacy responses. The launch server function checks fresh capabilities before sending load_game, so a stale browser snapshot cannot bypass the gate. Link Play reads and writes enforce the same policy server-side. Expanded playtime metrics use optional fields: absent means unavailable, while an explicit zero remains zero. Their availability does not depend on a guessed version cutoff. Library reads, metadata enrichment, and Replay Control’s own update channel do not use this gate.
Active-game settings retain the preflight get_status identity check on all supported versions. The Game and System views share the same managed controls; the latter writes type=core for the active system and display type. Saves send only changed options, avoiding unnecessary default entries and settings files. Explicit CRT mode omits the LCD-only integer scaling and video filtering controls and rejects writes to them. Automatic video mode keeps those controls labelled LCD-only because the API does not expose the resolved active display type. RePlayOS resolves saved settings in Game → Folder → System → Default order; saved Game settings mask the whole System set even when every managed value is default. Each view offers removal when its own scope has a saved file. Removal checks the active identity, selects the CRT or LCD game or system settings file when video mode is explicit, removes the entire file including emulator options, and restarts the game through Net Control. RePlayOS has no deletion API; automatic mode removes only when the matching file exists in exactly one display directory. If both match, it fails without deleting either. On RePlayOS 2.0 or newer, writes include the exact target identity (system for core, system and full game_file for game). RePlayOS verifies the target immediately before writing and returns 409 Target Changed if it changed; the server function maps it to the existing GameChanged outcome. On 1.8, it uses the original unguarded API request after the preflight check. The Link Play panel reads its four values from get_config?type=replay, so TV-side changes appear on the next load. Discovery defaults to enabled when its API config key is absent, following the current RePlayOS 2 contract.
Skeleton Loaders
Non-blocking resources use <Suspense> with skeleton fallbacks. The pattern:
<ErrorBoundary fallback=|errors| view! { <ErrorDisplay errors /> }>
<Suspense fallback=move || view! { <HeroCardSkeleton /> }>
{move || Suspend::new(async move {
let data = resource.await;
// render with data
})}
</Suspense>
</ErrorBoundary>Suspend::new(async { resource.await }) eliminates .get().map() boilerplate. ErrorBoundary catches server function errors.
Response Cache
Defined in replay-control-app/src/api/response_cache.rs.
A ResponseCache on AppState caches fully assembled server function responses with a 10-second TTL:
get_recommendationsresponseget_favorites_recommendationsresponse
This means back-navigation and rapid reloads within 10 seconds skip all DB queries and box art resolution entirely.
The cache is invalidated:
- On any mutation (favorite add/remove, ROM delete, etc.)
- After enrichment completes (new box art URLs)
- After on-demand thumbnail downloads complete
- Via
invalidate_all()when storage changes
Now-Playing Hydration
Now-playing must be visible on the first page load when a game is already running. The server render reads AppState.now_playing() in Shell and emits a small bootstrap script before HydrationScripts. App initializes a RwSignal<NowPlayingState> from the same server value during SSR, and from the bootstrap value during browser hydration.
After hydration, SseEventsListener subscribes to /sse/events. The multiplexed stream sends the current config, activity, and now-playing snapshots first, then later topic-specific changes. Consumers use use_now_playing(), which exposes a Signal<NowPlayingState> derived from the app-root signal.
Do not wrap the route body in an empty Suspense only to satisfy now-playing reads. It can change SSR marker placement and cause hydration mismatches. If a now-playing branch needs async detail data on first paint, use a blocking resource scoped to that branch and keep the conditional shape stable with <Show>.
Now-Playing Runtime Model
The detector lives in replay-control-app/src/api/now_playing.rs and polls the official RePlayOS get_status endpoint through ReplayApi. It only polls when both conditions are true:
ReplayApiStatusisActive, which means Net Control is reachable and authorized.- At least one browser is subscribed to the now-playing SSE stream.
When either condition is false, the loop sleeps on a cheap in-process check and makes no RePlayOS API calls. Poll failures are reported back into the Replay API status machine; unauthorized responses move the integration out of Active, while transient network errors back off up to 60 seconds.
replay-control-core::replay_api::classify reduces raw get_status payloads before the app touches the library:
- Missing load-bearing fields during UI transitions classify as
Hold, so the previous state stays visible instead of flickering through menu or not-running states. - An empty
game_fileclassifies asMenu; RePlayOS has no explicit “exit game” state, so an empty file is the reliable “nothing loaded” signal. - A non-empty
game_fileclassifies asLoaded, withgame_nameas the primary library key and the absolute file path as a fallback.
Play state is intentionally derived from multiple fields. halted wins over everything else, view_id == GamePlay with paused == false becomes Playing, paused == true becomes Paused, and other loaded-game views become InMenu. Menu view IDs alone are not trusted because RePlayOS can report different menu IDs while the same game remains loaded behind the UI.
Library resolution is session-scoped. On a (system, game_name) transition, the app resolves the running game once, caches the display fields, and resets started_at_unix_secs. Steady-state ticks reuse that resolution instead of hitting the DB every poll. A same-core game switch changes the transition key, forces a fresh resolution, invalidates launch-derived caches, and broadcasts the new state.
Disc status is deliberately separate from game detection. For loaded games, the loop calls get_media_status on each tick so TV-side disc swaps update live, but failures from that decoration path are ignored and never replace the now-playing state.
AppState::set_now_playing deduplicates unchanged states before broadcasting. Both the multiplexed stream and the legacy per-topic now-playing stream send the current state immediately on connect and re-send the current snapshot after a broadcast lag, so newly opened or briefly stalled clients converge without polling.
SSE Endpoints
The hydrated app uses one multiplexed SSE connection. Legacy per-topic streams remain available:
/sse/events
Multiplexes config, activity, and now-playing events. Each event is wrapped as { "stream": "...", "payload": ... }. The stream sends initial snapshots for all three topics on connect, re-sends the appropriate snapshot after broadcast lag, and keeps the connection alive every 15 seconds.
/sse/activity
Legacy per-topic stream for Activity enum updates as tagged JSON. Clients receive import progress, thumbnail download counts, rebuild status. See
Activity System. Keep-alive every 30 seconds.
/sse/now-playing
Legacy per-topic stream for NowPlayingState updates as JSON. Clients receive the current value immediately, then only state changes. Keep-alive every 15 seconds.
/sse/config
Legacy per-topic stream for skin changes, storage changes, available-update notifications, and database-corruption flag transitions. Sends an initial state snapshot on connect (which seeds the client’s corruption banner among other things), then event-driven updates. Keep-alive every 30 seconds. The corruption events come from the pool-level callback in DbPool::set_corruption_callback; see
Connection Pooling.
Authorization Guard
Device mode wraps app pages, REST handlers, SSE streams, media routes, and server functions in one authorization middleware. Standalone mode stays open by default because it runs off-device as a local ROM manager; stale device cookies are ignored there.
Sessions are stateless HMAC-signed cookies. The server stores only the signing key, rate-limit state, and app settings, not per-session rows. Rotating the signing key invalidates every session. Session claims include the current role, base role, expiration, optional admin-elevation expiration, and fingerprints tied to the RePlayOS Net Control code stored by Replay Control or device password state, so changing those stored credentials invalidates old cookies without an auth database. A TV-side Net Control code reset is detected separately when API probes/actions return unauthorized; that detection does not delete app sessions by itself.
Normal-user sessions last 30 days. Admin access defaults to 1 hour and can be configured to 1 hour, 3 hours, or 12 hours in settings.cfg. A direct device-password login expires back to anonymous, while a normal user who temporarily unlocks admin expires back to normal user access and can also downgrade from Settings > Access & Security. Changing the admin unlock duration refreshes the current admin cookie from the time of the change.
Server functions are explicitly classified by function name before they reach their per-function handler:
- Public functions support auth bootstrap and status checks.
- User functions support normal app usage such as browsing, favorites, launching, player controls, and read/write user actions.
- Admin functions change device state, credentials, network/storage identity, metadata, logs, updates, or certificates.
Unknown server functions fail closed as admin-only. A regression test scans src/server_fns/*.rs so adding a new server-function file requires updating the authorization inventory.
Signed-out sessions can only reach sign-in, setup, static assets, and health/version bootstrap endpoints. App browsing, media routes, REST endpoints, SSE streams, and non-bootstrap server functions require at least normal-user access. Authenticated unsafe requests also pass a CSRF check using Fetch Metadata and Origin/Referer validation.
Storage Guard Middleware
When storage is unavailable, a middleware redirects ALL requests to /waiting (a static HTML page with auto-refresh). Only these paths bypass the guard:
/waitingitself/static/*(CSS, JS, WASM)/api/version- Auth bootstrap paths needed before storage is ready:
/login,/first-setup, and anonymous server functions such as auth status, sign-in, first setup completion, and logout