Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 74 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,80 @@ All notable changes to this project are documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

Developer-experience changes: clear failures, less code in handlers, less setup.

### Added

- `Validated<T>` (`validator` feature) and `GardeValidated<T>` (`garde`
feature): body extractors that validate, answer Precognition requests, and
redirect back with the errors.
- `InertiaConfig::share`: shared props from a closure that returns any
`Serialize` value.
- `RequestInfo::extension` / `extensions`: shared props can read data that a
middleware put in the request extensions (the signed-in user).
- `Inertia::page(props)` (`ts` feature): the component name comes from
`register_page!`.
- `InertiaResponse::render`: a page without the `Inertia` extractor, for the
`IntoResponse` impl of an error type. `InertiaResponse::status` sets the
HTTP status of a page.
- `ViteRootView::auto`: dev mode in a debug build, production mode in a
release build.
- `RootView::version`: a production `ViteRootView` gives the manifest hash as
the default asset version. `InertiaConfig::version_str` for a constant.
- `veer::testing` (`testing` feature): `visit`, `TestPage`, `MemorySession`.
- A warning when errors or flash data are set and there is no session store.
- docs.rs shows all features, with a label on each feature-gated item.
- Guides: error pages, testing.

### Changed

- **Breaking:** page props that do not serialize give a `500` (the cause is in the
body in a debug build). Before, the page rendered with `null` props.
- **Breaking:** the rejection of the `Inertia` extractor is
`MissingInertiaLayer`, not `StatusCode`. Its body names the cause in a debug
build.
- **Breaking:** `ViteManifest::load` and `ViteManifest::from_str` return
`ViteManifestError`, not `String`.
- **Breaking:** `InertiaConfig::version` has no `"1"` default when the root
view has a version (see `RootView::version`).
- The mode-specific setters of `ViteRootView` (`dev_server`, `react_refresh`,
`manifest`, `asset_base`) do nothing in the other mode. Before, they
panicked.
- The bindings generator writes a file only when its content changes, and
`Split` deletes the generated files of removed controllers from its actions
subdirectory.

### Security

- `Inertia::back` and the empty-response redirect use only the path and query
of the `Referer`. Before, a `Referer` of another site made an open redirect.
**Breaking:** `RequestInfo::referer` is now that path, or `None`.
- A request URL that starts with `//` is read as a path of this site
(`/evil.test/x`), so that the page URL and `X-Inertia-Location` cannot point
at another site.
- `CsrfLayer` checks each method except `GET`, `HEAD`, `OPTIONS` and `TRACE`.
Before, a method other than `POST`, `PUT`, `PATCH` and `DELETE` was not
checked.
- An SSR failure with `ssr_required`, and a root view failure, give a `500`
with a generic body in a release build. Before, the error text went to the
client, and the root view error had status `200`.
- `EmbeddedAssets` answers `404` for a path with `..`, `\`, or `%`, and does
not call the resolver. It sets `X-Content-Type-Options: nosniff`.
- `CookieSessionStore` signs the cookie name with the value, so that the value
of one cookie is not valid as another cookie. Cookies from an older version
are ignored one time.
- `ViteRootView` and `MinimalRootView` escape URLs in the tags that they emit.
- The `500` bodies that veer makes are `text/plain`.

### Fixed

- A `500` from a failed page render (props, a prop closure, SSR, the root
view) keeps the flash data of the request for the request that follows.
Before, the data was lost.
- The `Router` documentation used the axum 0.7 path syntax (`/:id`).

## [0.2.0] - 2026-10-03

Brings the protocol surface up to Inertia client 3.8.0 / `inertia-laravel` 3.5.1.
Expand Down
5 changes: 5 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,11 @@ csrf = ["axum", "dep:cookie", "dep:hmac", "dep:sha2", "dep:base64", "dep:getrand
embed = ["axum"]
devtools = ["axum"]
ts = ["dep:ts-rs", "dep:inventory"]
testing = ["axum"]

[package.metadata.docs.rs]
all-features = true
rustdoc-args = ["--cfg", "docsrs"]

[dependencies]
serde = { version = "1.0", features = ["derive"] }
Expand Down
32 changes: 18 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ Or with `cargo add`:
cargo add veer
```

The default feature set includes the axum adapter. See [Feature flags](#️-feature-flags) for everything else. Coming from 0.1? Read the [upgrade guide](docs/upgrading.md).
The default feature set includes the axum adapter. See [Feature flags](#️-feature-flags) for everything else. Coming from an older version? Read the [upgrade guide](docs/upgrading.md).

## 🚀 Quick Start

Expand All @@ -88,7 +88,6 @@ use veer::{Inertia, InertiaConfig, InertiaLayer, MinimalRootView};
#[tokio::main]
async fn main() {
let cfg = InertiaConfig::new()
.version(|| "1".into())
.root_view(
MinimalRootView::new()
.title("Acme")
Expand Down Expand Up @@ -124,21 +123,20 @@ inertia
**Handle a form like a classic server app.** Validate, redirect, and the errors and the flash message show on the next page. → [Forms and validation](docs/forms-and-validation.md)

```rust,ignore
async fn users_store(inertia: Inertia, InertiaForm(body): InertiaForm<NewUser>) -> impl IntoResponse {
if let Err(errors) = body.validate() {
return inertia.with_errors(errors).redirect("/users/new");
}
async fn users_store(inertia: Inertia, Validated(body): Validated<NewUser>) -> impl IntoResponse {
// Invalid input went back to the form with its errors already.
create_user(body).await;
inertia.redirect("/users").with_flash("success", json!("User created"))
inertia.redirect("/users").with_flash("success", "User created")
}
```

**Share data with every page.** → [Shared props](docs/props.md#shared-props)

```rust,ignore
let cfg = InertiaConfig::new().shared(shared_props_fn(|_req| async move {
json!({ "auth": { "user": current_user().await } })
}));
let cfg = InertiaConfig::new().share(|req| {
let user = req.extension::<CurrentUser>().cloned(); // set by your auth middleware
async move { json!({ "auth": { "user": user } }) }
});
```

**Get TypeScript types from your Rust structs.** → [TypeScript bindings](docs/typescript.md)
Expand All @@ -148,6 +146,9 @@ let cfg = InertiaConfig::new().shared(shared_props_fn(|_req| async move {
#[ts(export)]
pub struct UsersIndexProps { pub users: Vec<User> }
veer::register_page!(UsersIndexProps, "Users/Index");

// The component name comes from the registration.
inertia.page(UsersIndexProps { users })
```

```tsx
Expand All @@ -162,15 +163,17 @@ import { users, type UsersIndexProps } from "./gen";
|---|---|
| [Getting started](docs/getting-started.md) | Install, first page, how a request flows, the frontend entry point |
| [Props](docs/props.md) | Partial reloads, lazy / deferred / once props, merging, infinite scroll, shared props, big integers |
| [Forms and validation](docs/forms-and-validation.md) | `InertiaForm`, validation errors, error bags, flash data, Precognition, file uploads |
| [Forms and validation](docs/forms-and-validation.md) | `Validated`, `InertiaForm`, validation errors, error bags, flash data, Precognition, file uploads |
| [Redirects and history](docs/redirects-and-history.md) | `redirect`, `back`, external redirects, URL fragments, history encryption |
| [Sessions](docs/sessions.md) | The cookie store, `tower-sessions`, writing your own store |
| [Error pages](docs/error-pages.md) | `404` and application errors as Inertia pages |
| [Testing](docs/testing.md) | `veer::testing`: page assertions for your handlers |
| [Vite, SSR and assets](docs/vite-ssr-assets.md) | `ViteRootView`, server-side rendering, embedded assets, `<head>` elements |
| [TypeScript bindings](docs/typescript.md) | Typed page props and route helpers generated from Rust |
| [CSRF protection](docs/csrf.md) | `CsrfLayer` and the `XSRF-TOKEN` convention |
| [DevTools](docs/devtools.md) | The recorder for the Inertia DevTools browser extension |
| [Architecture](docs/architecture.md) | Crate layout, protocol coverage, extension points |
| [Upgrading from 0.1](docs/upgrading.md) | Every breaking change in 0.2 and what to do |
| [Upgrading](docs/upgrading.md) | Every breaking change of each version and what to do |

API reference: [docs.rs/veer](https://docs.rs/veer). For the client side, use the [Inertia documentation](https://inertiajs.com/docs/v3).

Expand All @@ -183,12 +186,13 @@ API reference: [docs.rs/veer](https://docs.rs/veer). For the client side, use th
| `ssr` | off | HTTP SSR client (`reqwest`) |
| `cookie-session` | off | Signed-cookie session store |
| `tower-sessions` | off | Session store backed by [`tower-sessions`](https://crates.io/crates/tower-sessions) |
| `validator` | off | `IntoErrorBag` impl for `validator::ValidationErrors` |
| `garde` | off | `IntoErrorBag` impl for `garde::Report` |
| `validator` | off | `Validated<T>` extractor + `IntoErrorBag` impl for `validator::ValidationErrors` |
| `garde` | off | `GardeValidated<T>` extractor + `IntoErrorBag` impl for `garde::Report` |
| `csrf` | off | CSRF protection (`CsrfLayer`) |
| `embed` | off | Embedded-asset serving for single-binary deploys (`EmbeddedAssets`) |
| `devtools` | off | Recorder + read API for the Inertia DevTools browser extension |
| `ts` | off | End-to-end TypeScript bindings codegen (`ts-rs` + `inventory`) |
| `testing` | off | Test helpers (`veer::testing`) |

Disabling a feature drops its transitive deps entirely.

Expand Down
6 changes: 4 additions & 2 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,18 @@ Veer is the server side of the [Inertia.js v3 protocol](https://inertiajs.com/do
| Guide | What it covers |
|---|---|
| [Getting started](getting-started.md) | Install, first page, how a request flows, the frontend entry point |
| [Upgrading from 0.1](upgrading.md) | Every breaking change in 0.2 and what to do |
| [Upgrading](upgrading.md) | Every breaking change of each version and what to do |

## Building pages

| Guide | What it covers |
|---|---|
| [Props](props.md) | Partial reloads, lazy / deferred / once props, merging, infinite scroll, shared props, big integers |
| [Forms and validation](forms-and-validation.md) | `InertiaForm`, validation errors, error bags, flash data, Precognition, file uploads |
| [Forms and validation](forms-and-validation.md) | `Validated`, `InertiaForm`, validation errors, error bags, flash data, Precognition, file uploads |
| [Redirects and history](redirects-and-history.md) | `redirect`, `back`, external redirects, URL fragments, history encryption |
| [Sessions](sessions.md) | The cookie store, `tower-sessions`, writing your own store |
| [Error pages](error-pages.md) | `404` and application errors as Inertia pages |
| [Testing](testing.md) | `veer::testing`: page assertions for your handlers |

## Frontend integration

Expand Down
9 changes: 5 additions & 4 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ The protocol core has no I/O and no framework types. `protocol::decide` is a pur
| `RootView` | The HTML shell of the first page load | `MinimalRootView`, `ViteRootView`, `ClosureRootView` |
| `SessionStore` | Errors and flash data between requests | `CookieSessionStore`, `TowerSessionStore` |
| `SsrClient` | Server-side rendering | `HttpSsrClient` |
| `SharedProps` | Props for every page | `shared_props_fn` |
| `SharedProps` | Props for every page | `InertiaConfig::share` (closure) |
| `IntoErrorBag` | Validation errors from any library | `validator`, `garde`, maps and pairs |

## Feature flags
Expand All @@ -55,12 +55,13 @@ The protocol core has no I/O and no framework types. `protocol::decide` is a pur
| `ssr` | off | HTTP SSR client (`reqwest`) |
| `cookie-session` | off | Signed-cookie session store |
| `tower-sessions` | off | Session store on [`tower-sessions`](https://crates.io/crates/tower-sessions) |
| `validator` | off | `IntoErrorBag` for `validator::ValidationErrors` |
| `garde` | off | `IntoErrorBag` for `garde::Report` |
| `validator` | off | `Validated<T>`; `IntoErrorBag` for `validator::ValidationErrors` |
| `garde` | off | `GardeValidated<T>`; `IntoErrorBag` for `garde::Report` |
| `csrf` | off | `CsrfLayer` and `CsrfTokens` |
| `embed` | off | `EmbeddedAssets` for a single-binary deploy |
| `devtools` | off | Recorder for the Inertia DevTools extension |
| `ts` | off | TypeScript bindings (`ts-rs`, `inventory`) |
| `ts` | off | TypeScript bindings (`ts-rs`, `inventory`), `Inertia::page` |
| `testing` | off | `veer::testing`: helpers for tests of your handlers |

A feature that is off brings in none of its dependencies.

Expand Down
77 changes: 77 additions & 0 deletions docs/error-pages.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
# Error pages

An error response that is not an Inertia page shows in a modal on the client. To show an error as a page of your app, render a component with an HTTP status.

## Not found

Use a fallback handler. `status` sets the HTTP status of the page response:

```rust,ignore
use axum::http::StatusCode;
use veer::Inertia;

async fn not_found(inertia: Inertia) -> impl IntoResponse {
inertia
.render("Error", json!({ "status": 404 }))
.status(StatusCode::NOT_FOUND)
}

let app = router()
.fallback(not_found)
.layer(InertiaLayer::new(cfg)); // after the fallback, so that it covers it
```

## Application errors

A handler that returns `Result<_, AppError>` needs `AppError: IntoResponse`, and that impl has no `Inertia` extractor. `InertiaResponse::render` makes a page without one:

```rust,ignore
use veer::InertiaResponse;

enum AppError {
NotFound,
Internal(anyhow::Error),
}

impl IntoResponse for AppError {
fn into_response(self) -> Response {
let status = match &self {
AppError::NotFound => StatusCode::NOT_FOUND,
AppError::Internal(error) => {
tracing::error!(%error, "request failed");
StatusCode::INTERNAL_SERVER_ERROR
}
};
InertiaResponse::render("Error", json!({ "status": status.as_u16() }))
.status(status)
.into_response()
}
}

async fn users_show(inertia: Inertia, Path(id): Path<u64>) -> Result<impl IntoResponse, AppError> {
let user = find_user(id).await?.ok_or(AppError::NotFound)?;
Ok(inertia.render("Users/Show", UsersShowProps { user }))
}
```

The page gets the shared props and works with SSR, like any page. The route must be inside `InertiaLayer`.

## The component

```tsx
// pages/Error.tsx
const titles: Record<number, string> = {
404: "Page not found",
500: "Server error",
};

export default function Error({ status }: { status: number }) {
return <h1>{titles[status] ?? "Error"}</h1>;
}
```

## Other failures

- **A panic or a `500` from another layer** is not a page. In development the modal is useful, because it shows the response. In production, keep the body free of internal data.
- **An expired CSRF token** gives `419` from [`CsrfLayer`](csrf.md), which is outside `InertiaLayer`. Handle it on the client.
- **A failed deferred prop** can be rescued, so that the page loads without it; see [Props](props.md).
33 changes: 25 additions & 8 deletions docs/forms-and-validation.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@ Inertia handles a form submit like a classic server-rendered app: the handler va

## A form handler

With the `validator` feature, `Validated<T>` decodes the body and validates it before your handler runs:

```rust,ignore
use veer::{Inertia, InertiaForm};
use veer::{Inertia, Validated};
use validator::Validate;

#[derive(serde::Deserialize, Validate)]
Expand All @@ -16,6 +18,21 @@ struct NewUser {
email: String,
}

async fn users_store(inertia: Inertia, Validated(body): Validated<NewUser>) -> impl IntoResponse {
create_user(body).await;
inertia.redirect("/users").with_flash("success", "User created")
}
```

The handler runs only for valid input. For invalid input, `Validated` redirects back to the page that the user came from ([`back`](redirects-and-history.md)), with the errors. `back` needs the `Referer` header, which browsers send by default. If your site sets `Referrer-Policy: no-referrer`, turn on `InertiaConfig::store_previous_url(true)`, or validate by hand. It also answers [Precognition](#precognition-live-validation) requests. With the `garde` feature, use `GardeValidated<T>` in the same way.

`Validated<T>` reads the body as `InertiaForm<T>` does: `application/json`, `application/x-www-form-urlencoded`, and, with the `multipart` feature, `multipart/form-data`. One handler works for all three.

### Validation by hand

Use `InertiaForm<T>` when you want control of the steps, for example to redirect to a different page or to add an error from a database check:

```rust,ignore
async fn users_store(
inertia: Inertia,
InertiaForm(body): InertiaForm<NewUser>,
Expand All @@ -24,12 +41,10 @@ async fn users_store(
return inertia.with_errors(errors).redirect("/users/new");
}
create_user(body).await;
inertia.redirect("/users").with_flash("success", json!("User created"))
inertia.redirect("/users").with_flash("success", "User created")
}
```

`InertiaForm<T>` reads `application/json`, `application/x-www-form-urlencoded`, and, with the `multipart` feature, `multipart/form-data`. One handler works for all three.

## Validation errors

`with_errors` takes anything that implements `IntoErrorBag`:
Expand All @@ -47,16 +62,18 @@ On the next page, the errors are in `props.errors`, which is always present (`{}
## Flash data

```rust,ignore
inertia.redirect("/users").with_flash("success", json!("User created"))
inertia.redirect("/users").with_flash("success", "User created")
```

Flash data is one-shot. It is the top-level `flash` field of the page object (`usePage().flash` on the frontend), and the client does not keep it in the browser history. It survives a chain of redirects, and responses that are not pages do not use it up.

`with_flash` on a render puts the data on that page.
The value is anything that converts to JSON: a string, a number, or `json!({ … })`. `with_flash` on a render puts the data on that page.

## Precognition (live validation)

[Precognition](https://inertiajs.com/docs/v3/the-basics/forms#precognition) validates a field while the user fills in the form, with the validation rules of the server. The client sends the normal request with a `Precognition: true` header. Answer it before the action runs:
[Precognition](https://inertiajs.com/docs/v3/the-basics/forms#precognition) validates a field while the user fills in the form, with the validation rules of the server. The client sends the normal request with a `Precognition: true` header.

`Validated<T>` and `GardeValidated<T>` answer these requests; your handler does not run. With `InertiaForm<T>`, answer the request before the action runs:

```rust,ignore
async fn users_store(
Expand Down Expand Up @@ -113,7 +130,7 @@ async fn upload_avatar(
InertiaForm(form): InertiaForm<CreateAvatar>,
) -> impl IntoResponse {
save_avatar(&form.user_id, &form.avatar.bytes, form.avatar.filename.as_deref()).await;
inertia.redirect("/profile").with_flash("success", json!("Avatar updated"))
inertia.redirect("/profile").with_flash("success", "Avatar updated")
}
```

Expand Down
Loading
Loading