Kangaroos carry puppies in their pouches. kangaroo carries server-side data in static web applications, served by Axum.
This Rust crate allows you to serve static web applications and inject server-side data through Axum, making it available to the client as JSON objects. Kangaroo aims to avoid having heavy server runtimes serving apps through full SSR, but still be able to manipulate response status codes and reduce network round trips.
cargo add kangaroo-axumTo easily get started, add the following in your existing Axum app as a bare minimum setup:
// Imports π
use kangaroo_axum::{IntoKangarooError, KangarooConfig, KangarooRouterExtension, kangarooise};
// ...
// App extension π
#[tokio::main]
async fn main() {
let app = Router::new()
.route("/", get(get_home))
// ...
.with_kangaroo(KangarooConfig::new("public/"));
//...
}
// ...
// Define your serialisable data model π
#[derive(Serialize)]
struct HomeData {
pub current_username: Option<String>,
}
// ...
// Kangarooise endpoint π
#[kangarooise]
async fn get_home() -> HomeData {
// ...
}And in your frontend JavaScript code, add the following to retrieve the server-side injected data:
const data = JSON.parse(document.getElementById("kangaroo-data")?.textContent ?? null);Requests to / will return the content of index.html with an injected JSON script with ID kangaroo-data containing your server-side data.
In single-page applications with client-side routing, Kangaroo data is fetched when a given URL is browsed, but not when navigation to that page is triggered by the client router or its links. Therefore, it's recommended to handle these cases by making the data consumable only once and fall back to APIs if it's already consumed.
For example, you could use a context in SolidJS:
type KangarooData = {
getData: () => unknown;
};
const KANGAROO_DATA = JSON.parse(document.getElementById("kangaroo-data")?.textContent ?? null);
const KangarooContext = createContext<KangarooData | undefined>();
export function useKangaroo() {
const context = useContext(KangarooContext);
if (!context) {
throw new Error("useKangaroo must be used inside KangarooProvider");
}
return context;
}
export const KangarooProvider = (props: { children: JSX.Element }) => {
const [consumed, setIsConsumed] = createSignal<boolean>(false);
return (
<KangarooContext.Provider value={{
getData: () => {
const wasConsumed = consumed();
setIsConsumed(true);
return wasConsumed ? null : KANGAROO_DATA;
}
}}>
{props.children}
</KangarooContext.Provider>
);
};and then consume it with API fallback
const kangaroo = useKangaroo();
const [data] = createResource(async () => kangaroo.getData() ?? await (await fetch("/your/apis")).json());Kangaroo can gracefully handle errors raised by your endpoints:
#[kangarooise]
async fn get_home() -> Result<HomeData, Error> {
// ...
}You just need to implement the IntoKangarooError to make it handle gracefully. The only member must return status code and a payload to inject as kangaroo-data JSON script.
impl IntoKangarooError for Error {
fn into_kangaroo_error(self) -> (StatusCode, impl Serialize) {
// ...
}
}The frontend can read the serialised error payload from the kangaroo-data JSON script as usual.
Kangaroo supports some layers of customisation to pick the correct HTML document based on route and status code. The default document path is index.html, relative to the folder path passed to the KangarooConfig object.
To set another default:
KangarooConfig::new("public/")
.with_default_document("default.html")You can set specific document paths for each HTTP response status code coming from your Error objects:
use axum::http::StatusCode;
KangarooConfig::new("public/")
.with_document_for_status(StatusCode::NOT_FOUND, "404.html")
.with_document_for_status(StatusCode::INTERNAL_SERVER_ERROR, "5xx.html")You can also set overrides for each route:
#[kangarooise(
file = "home/index.html"
not_found = "home/404.html"
internal_server_error = "home/5xx.html"
)]
async fn get_home() -> Result<HomeData, Error> {
// ...
}file is the default path override for the route, the other arguments must be valid lowercased variants of the members of axum::http::StatusCode.
The precedence order for the document to load is the following, from top to bottom priority:
- Route-specific override for the returning status code
#[kangarooise(not_found = "404.html")] - Status code-specific default path
KangarooConfig::new("public/") .with_document_for_status(StatusCode::NOT_FOUND, "404.html")
- Route-specific override for the default document
#[kangarooise(file = "home.html")] - Custom default document
KangarooConfig::new("public/") .with_default_document("home.html")
index.html
If you're serving your frontend locally using a development server like Vite's, you can make Kangaroo forward requests to it instead of loading static files:
KangarooConfig::new("public/")
.with_frontend_development_server(Some("http://127.0.0.1:3000"))Since you'll access frontend pages through the backend proxy, hot module reloading may break, if in use. Therefore, make sure to set the frontend development server configuration properly to force HMR target the correct port. For instance, in Vite, you can set:
import { defineConfig } from "vite";
export default defineConfig({
server: {
port: 3000,
hmr: {
protocol: "ws",
host: "localhost",
port: 3000,
},
},
// ...
});For a full simple working example, check out the examples folder.
cargo run --example appcargo fmt --all
cargo clippy --workspace --all-targets -- --deny warnings
cargo test
RUSTDOCFLAGS="-D rustdoc::all -A rustdoc::private-doc-tests" cargo doc --all-features --no-deps