Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
73e3936
fix(core): treat empty env values as unset; keep log files open
javad-zobeidi Oct 22, 2026
93d3779
feat(core): stream files with Range support, fix SSE and error responses
javad-zobeidi Oct 22, 2026
7a37463
fix(core): parse requests lazily and fix Request accessors
javad-zobeidi Oct 22, 2026
7ba83b2
feat(core): add the HTTP QUERY method and rework route matching
javad-zobeidi Oct 22, 2026
efa7ba6
fix(core): harden sessions, flash messages, CSRF and throttling
javad-zobeidi Oct 22, 2026
678f967
feat(core): write the response after the middleware chain
javad-zobeidi Oct 22, 2026
63a5771
feat(core): serve from several isolates and shut down gracefully
javad-zobeidi Oct 22, 2026
190f41b
chore(core): release 2.1.0
javad-zobeidi Oct 22, 2026
77a7880
feat(swagger): move to OpenAPI 3.2 and document QUERY routes
javad-zobeidi Oct 22, 2026
0ca112c
chore(swagger): release 1.1.0
javad-zobeidi Oct 22, 2026
1a40436
docs: cover the 2.1 HTTP changes
javad-zobeidi Oct 22, 2026
e4f770c
feat(sample-core-showcase): add a sample that exercises the HTTP core
javad-zobeidi Oct 22, 2026
a831658
fix(core): don't adopt unknown session ids from API requests
javad-zobeidi Oct 22, 2026
3f921e4
feat(core): add a setup callback that runs in every server isolate
javad-zobeidi Oct 22, 2026
cd6ad98
docs(core): note setup and the session id fix in the 2.1.0 changelog
javad-zobeidi Oct 22, 2026
fdc6314
fix(core): make Throttle's development bypass opt-in
javad-zobeidi Oct 22, 2026
3b16bd4
fix(core): cap the restart backoff for crashing server isolates
javad-zobeidi Oct 22, 2026
1bc391a
fix(core): keep same-path routes on different domains apart
javad-zobeidi Oct 22, 2026
185db21
fix(core): let the XSRF cookie live as long as the session
javad-zobeidi Oct 22, 2026
1e73ddd
docs(core): say that REQUEST_TIMEOUT covers reading the request body
javad-zobeidi Oct 22, 2026
b717be6
docs(core): add the latest fixes to the 2.1.0 changelog
javad-zobeidi Oct 22, 2026
667a861
style(core): dart format
javad-zobeidi Oct 22, 2026
2927ed0
chore: point the core and swagger repository links at the monorepo
javad-zobeidi Oct 22, 2026
4bf965e
chore(core): ship the LICENSE with the package again
javad-zobeidi Oct 22, 2026
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
2 changes: 1 addition & 1 deletion documentation/docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ Your server is now running at `http://localhost:8000`.
| `vania_elasticsearch` | Elasticsearch client with query builder | 1.0.0 |
| `vania_graphql` | GraphQL server with subscriptions | 1.0.0 |
| `vania_grpc` | gRPC server and client | 1.0.0 |
| `vania_swagger` | OpenAPI 3.0 documentation and Swagger UI | 1.0.0 |
| `vania_swagger` | OpenAPI 3.2 documentation and Swagger UI | 1.0.0 |
| `vania_websocket` | WebSocket channels, presence, and broadcasting | 1.0.0 |
| `vania_cli` | CLI for scaffolding, serving, and migrations | 1.0.0 |

Expand Down
20 changes: 12 additions & 8 deletions documentation/docs/core/error-handling.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ throw ForbiddenException(message: 'You do not have access');
// 401 Unauthorized
throw Unauthenticated(message: 'Invalid credentials');

// 500 Internal Server Error
// 500 Internal Server Error — the client sees the message only in debug
throw InternalServerError(message: 'Something went wrong');

// Custom HTTP status — the class is HttpResponseException, with a `code`
Expand Down Expand Up @@ -66,6 +66,8 @@ class AdminMiddleware extends Middleware {

See [Middleware](middleware.md) for the full `handle` / `process` model.

An exception gets the same response whether a middleware or the controller threw it.

## Error Response Format

### JSON Requests
Expand All @@ -78,15 +80,17 @@ For API requests, exceptions are returned as JSON:
}
```

A 5xx exception you throw yourself is treated like any other server failure: it is logged, and its message reaches the client only when `APP_DEBUG=true`. Messages of 4xx exceptions are written for the client and always shown.

### HTML Requests

For web requests, the framework renders error view templates if they exist:
A request that asks for HTML, in an app that renders pages (it has views, or CSRF protection on), gets HTML instead:

- `views/errors/404.html` for 404 errors
- `views/errors/500.html` for 500 errors
- `views/errors/419.html` for CSRF failures
- `RedirectException` redirects to its `message`. So does an `Unauthenticated` created with `responseType: ResponseType.html`, which is how a session guard sends a browser to its login page: `Unauthenticated(message: '/login', responseType: ResponseType.html)`. Any other `Unauthenticated` is a `401`, even for a browser — its message is text for the client, not an address.
- A 404, 500 or 419 (CSRF failure) renders `lib/resources/view/errors/404.html`, `500.html` or `419.html`, when it exists.
- Anything else, or a missing view, is a plain HTML page with the status code and the escaped message.

If the view file does not exist, a plain text response is returned.
An app without views answers in JSON, even a browser.

## Validation Errors

Expand All @@ -102,12 +106,12 @@ Validation failures return a `422` response with structured error data:
}
```

For HTML requests, the user is redirected back with errors and old input in the session.
For HTML requests, the user is redirected back with errors and old input in the session. An app without sessions (no views and CSRF off) has nowhere to keep them across a redirect, so it answers HTML requests with the `422` too.

## Database Errors

`DatabaseException` and `QueryException` are caught by the request handler. In debug mode (`APP_DEBUG=true`), the full error message is returned. In production, a generic "Internal Server Error" message is shown.

## Unhandled Exceptions

Any exception not caught by your code is caught by the request pipeline. It logs the error and returns a 500 response. In debug mode, the exception message and stack trace are included in the response.
Any exception not caught by your code is caught by the request pipeline. It logs the error with its stack trace and returns a 500 response. In debug mode, the exception message is included in the response; the stack trace only goes to the log.
47 changes: 41 additions & 6 deletions documentation/docs/core/middleware.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,39 @@ class Timing extends Middleware {
}
```

Not calling `next()` stops the chain: no later middleware runs, the controller is never invoked, and your middleware becomes responsible for the response. Override **either** `handle` or `process`, not both — if you define `process`, `handle` is ignored.
The response is written only once every middleware has finished, so the header above still reaches the client. An exception from the controller or a later middleware comes back out of `await next()`: a `process` middleware can catch it to log or measure it, and should rethrow it so it is still answered.

Not calling `next()` stops the chain: no later middleware runs, the controller is never invoked, and your middleware becomes responsible for the response. Calling it twice is an error, since it would run the controller twice. Override **either** `handle` or `process`, not both — if you define `process`, `handle` is ignored.

## Reading the body in middleware

Middleware runs **before** the request body is read, so `req.input(...)` in a middleware sees only the query string and route parameters. This is deliberate: CSRF protection checks the token before it lets the framework read a possibly large body. (With CSRF protection on, a `POST`, `PUT`, `PATCH` or `DELETE` that passed the check has had its body read by it — but don't rely on that.) A middleware that needs the body reads it first:

```dart
@override
Future<void> handle(Request req) async {
await req.extractBody();
final email = req.input('email');
// ...
}
```

Reading it here costs nothing extra later: the body is read once, and the controller gets the same input.

## One instance for every request

The middleware objects you attach to routes are created once, when routes are registered, and shared by every request — including requests running at the same time. Keep per-request data out of their fields:

```dart
class Bad extends Middleware {
String? userId; // another request can overwrite this before you read it

@override
Future<void> handle(Request req) async => userId = req.header('x-user');
}
```

Pass such data on with `req.merge({...})` instead, as in the first example. Fields that hold configuration, or state meant to be shared such as `Throttle`'s counters, are fine.

## Attaching middleware to routes

Expand Down Expand Up @@ -75,10 +107,12 @@ Group middleware runs before route middleware, and each list runs in order.
import 'package:vania/http/middleware.dart';

Router.post('/login', authController.login)
.middleware([Throttle(maxRequests: 5, perMinutes: 1)]);
.middleware([Throttle(maxAttempts: 5, duration: Duration(minutes: 1))]);
```

Five requests per minute per client; excess requests get `429 Too Many Requests`.
Five requests per minute per client; excess requests get `429 Too Many Requests` with a `Retry-After` header. Every response carries `X-RateLimit-Limit`, `X-RateLimit-Remaining` (what is left after this request) and `X-RateLimit-Reset` (when the count starts over).

Clients are told apart by IP address, which behind a reverse proxy is the proxy's unless it is listed in `TRUSTED_PROXIES`. Pass `includeUserIdentifier: true` to count each signed-in user separately. Pass `bypassInDevelopment: true` to skip throttling while `APP_ENV` is `local` or `development`; it is off by default, so a production app that kept a development `.env` is still rate limited. The counts are kept in memory, so they are per process, and per isolate with `APP_ISOLATES`.

### Security headers

Expand Down Expand Up @@ -122,9 +156,10 @@ Router.websocket('/ws', eventHandler, middleware: [WsAuth()]);

## Execution order

1. Group middleware (outer groups first, then inner)
2. Route middleware, in the order listed
3. The controller action
1. CSRF protection, when it is on and the request changes state (see [Sessions](sessions.md#csrf-tokens))
2. Group middleware (outer groups first, then inner)
3. Route middleware, in the order listed
4. The controller action

A middleware that throws, or that overrides `process` and doesn't call `next()`, stops the chain there.

Expand Down
10 changes: 10 additions & 0 deletions documentation/docs/core/requests.md
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,16 @@ Parse a specific field as JSON:
Map<String, dynamic> metadata = req.json('metadata');
```

## Raw Content

`req.rawBody` is the request content exactly as received, as bytes. Use it for a media type the framework does not read into fields (the SQL or GraphQL of a [`QUERY`](routing.md#query-routes) request, say) or to verify a webhook signature over the bytes that were signed:

```dart
final text = utf8.decode(req.rawBody);
```

It is empty for multipart uploads, which are streamed rather than held in memory.

## Authenticated User

If the authentication package is installed and the route uses auth middleware:
Expand Down
23 changes: 23 additions & 0 deletions documentation/docs/core/responses.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,8 @@ return Response.back('status', 'Profile updated');
return Response.backWithInput('email', 'That address is already taken');
```

The previous page is taken from the `Referer` header, and only when it points at this same site, so a link from elsewhere cannot use `back()` to redirect off your site. Without one, `back()` from a form post returns to the page at the same path, and from anything else goes to `/` — never to the URL it was called from, which would redirect to itself.

## File Responses

### Stream a File
Expand All @@ -101,6 +103,25 @@ return Response.download(
);
```

### Serve a File from Disk

`Response.file` and `Response.download` send bytes you already hold in memory. For a file on disk — a video, a large export — use `Response.fromPath`, which streams it without loading it into memory:

```dart
return Response.fromPath('storage/videos/intro.mp4');

// Saved by the browser, under a name of your choosing
return Response.fromPath(
'storage/exports/2026-10.csv',
download: true,
fileName: 'october.csv',
);
```

It answers `Range` requests with `206 Partial Content`, so video players can seek and interrupted downloads can resume. A path that is not a file gets a 404.

The path is read as given. Never build it from request input without confining it to a directory you mean to serve.

## Server-Sent Events (SSE)

Stream real-time events to the client:
Expand All @@ -122,6 +143,8 @@ Future<Response> notifications(Request req) async {
}
```

Each event is sent as soon as the stream yields it. While the stream is quiet, a comment line goes out every `SSE_HEARTBEAT` seconds (default 15), which keeps proxies from closing the connection. When the client goes away, the subscription to your stream is cancelled, so a `StreamController`'s `onCancel` is the place to release whatever feeds it. An error from the stream ends the response and is logged.

## Empty / No Content

Return an empty 204 response:
Expand Down
37 changes: 35 additions & 2 deletions documentation/docs/core/routing.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,11 +42,39 @@ Router.put(path, action);
Router.patch(path, action);
Router.delete(path, action);
Router.options(path, action);
Router.query(path, action); // HTTP QUERY, see below
Router.any(path, action); // matches all methods
```

Additional methods for specialized protocols: `Router.purge`, `Router.copy`, `Router.link`, `Router.unlink`, `Router.lock`, `Router.unlock`, `Router.propfind`.

### QUERY Routes

`QUERY` ([RFC 10008](https://www.rfc-editor.org/rfc/rfc10008)) is a read like `GET`, safe and idempotent, that carries the query in the request content as `POST` does. Use it when a query is too large or too complex for a URL, or too sensitive to leave in one: URLs end up in logs and browser history, request content generally does not.

```dart
Router.query('/contacts', contactController.search)
.acceptQuery(['application/json', 'application/x-www-form-urlencoded']);
```

A JSON or form-encoded query reaches the action as ordinary input, so `req.input('surname')` and `req.validate(...)` work as they do for a `POST`. Any other media type, such as SQL or GraphQL, is in `req.rawBody`:

```dart
Router.query('/reports', (Request req) {
final sql = utf8.decode(req.rawBody); // Content-Type: application/sql
// ...
});
```

The framework applies the RFC's rules for you:

- A `QUERY` without a `Content-Type`, or whose content does not parse as that type, is answered with **400**.
- With `acceptQuery`, a request of any other media type gets **415**, and responses carry an `Accept-Query` header listing the accepted types. `type/*` and `*/*` are allowed.
- An `OPTIONS` request to the path answers with an `Allow` header naming its methods (`GET, QUERY, OPTIONS`) and, when set, `Accept-Query`. That is how a client finds out a resource supports `QUERY`.
- `QUERY` is safe, so CSRF protection does not apply to it, and `Response.fromPath` honours `Range` for it as for `GET`.

Browsers cannot send `QUERY` from an HTML form, only with `fetch()`. A cross-origin `QUERY` is preflighted, so add `QUERY` to the CORS `methods` (see [Configuration](../getting-started/configuration.md#cors)).

## Route Actions

A route action can be a controller method or an inline closure:
Expand All @@ -73,11 +101,11 @@ Router.get('/users/{id}', userController.show);
Router.get('/posts/{postId}/comments/{commentId}', commentController.show);
```

Parameters are passed as positional arguments to the action. The framework auto-parses `int`, `double`, and `bool` values based on the action's type signature.
Parameters are passed as positional arguments to the action, decoded: a request for `/posts/%D8%B3%D9%84%D8%A7%D9%85` gives `سلام`. Routes may be written in any script — `Router.get('/مقالات/{id}', ...)` matches the encoded request a browser sends for it. An unconstrained parameter is converted from its text: `42` arrives as an `int`, `4.2` as a `double`, `true` or `false` as a `bool`, and anything else as a `String`. To receive digits as a `String` (a slug such as `2024`, say), constrain it with `whereString`.

### Parameter Type Constraints

Constrain parameter types to reject non-matching requests early:
Constrain parameter types to reject non-matching requests early. A constrained parameter reaches the action as that type, and a value that does not fit is a 404:

```dart
Router.get('/users/{id}', userController.show)
Expand All @@ -89,11 +117,16 @@ Router.get('/price/{amount}', handler)
Router.get('/active/{flag}', handler)
.whereBool('flag');

Router.get('/archive/{year}', handler)
.whereString('year'); // "2024" stays a String

// Custom regex constraint
Router.get('/slug/{slug}', handler)
.where('slug', r'[a-z0-9-]+');
```

A `where` pattern has to match the whole segment: `r'\d+'` takes `123` but not `abc123`.

## Route Groups

Group related routes to share a prefix, middleware, or domain:
Expand Down
37 changes: 29 additions & 8 deletions documentation/docs/core/sessions.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,31 +53,52 @@ Access the current session identifier:
String? sessionId = SessionManager().getSessionId();
```

### After signing in

Give the session a new id whenever a user signs in, or their privileges change:

```dart
await regenerateSession();
await setSession('logged_in', true);
```

The data stays; only the id changes, and the old one stops working. Someone who learned the old id — by planting a cookie in the browser before the user signed in, say — is left holding nothing. For the same reason, an id the server never issued is not taken up: a request carrying one starts a fresh session with an id of the server's choosing. That holds for an API call made with `fetch` as much as for a page, so a session written by a JSON login lands under a new id too.

## CSRF Tokens

CSRF protection for web forms is handled for you. In templates, emit the token with the `@csrf` directive (a hidden form field) or `@csrfToken` (the raw value) — see [Views & Templates](views-templates.md). The session layer generates and validates the token behind the scenes; you do not read it from `SessionManager` directly.
CSRF protection is off until you turn it on:

```env
CSRF_PROTECTION_ENABLED=true
```

From then on every `POST`, `PUT`, `PATCH` and `DELETE` request must carry the session's token, or it is refused with a `419`. `GET`, `HEAD`, `OPTIONS` and `QUERY` change nothing, so they need none. Routes under `api/` are left out, since an API normally authenticates with a token rather than the session cookie; set `csrf_protect_api: true` in the config map if yours uses the cookie, and list any other paths to leave out under `csrf_except`.

In templates, emit the token with the `@csrf` directive (a hidden form field) or `@csrfToken` (the raw value) — see [Views & Templates](views-templates.md). A request sent from JavaScript, which is the only way to send a `PUT`, `PATCH` or `DELETE` from a page, passes it in the `X-CSRF-TOKEN` header instead. The session layer generates and validates the token behind the scenes; you do not read it from `SessionManager` directly.

## Session Configuration

Sessions are configured through environment variables:

| Variable | Default | Description |
|----------|---------|-------------|
| `SESSION_LIFETIME` | `9000` | Session duration in seconds |
| `SESSION_LIFETIME` | `9000` | Seconds a session lasts, for both the cookie and the stored session |
| `SESSION_CACHE_SIZE` | `5000` | Maximum sessions cached in memory |
| `SECURE_SESSION` | `false` | Set `true` to enable the `Secure` cookie flag |
| `SECURE_SESSION` | `true` | Sends the cookie only over HTTPS; set `false` for local development over plain HTTP |

A session that is in use stays alive: once it is past half its lifetime, the next request renews it. So an idle session ends between half and all of `SESSION_LIFETIME` after its last request. Expired session files are swept from disk about once an hour.

Session files are stored in `storage/framework/sessions/`. Each session is AES-GCM encrypted using `APP_KEY`, and session IDs are HMAC-SHA256 hashed for filenames.

The session cookie is named `{APP_NAME}_session`, is `httpOnly`, and uses `SameSite=Lax`.

## How Sessions Work

1. When an HTML request arrives, the `SessionManager` checks for the session cookie.
2. If a session ID exists, it loads the encrypted session data from the file store (or in-memory LRU cache).
3. If no session exists, a new one is created.
4. At the end of the request, session data is persisted to the file store.
5. API requests (JSON content type) skip session handling entirely for performance.
1. When a request asks for HTML (its `Accept` header) in an app that renders pages, the `SessionManager` checks for the session cookie.
2. If the cookie names an existing session, its encrypted data is loaded from the file store (or the in-memory LRU cache).
3. Otherwise a new session is started, under a new id.
4. Changes are saved once, just before the response is written, and only if there were any.
5. Every other request — an API call, or any request to an app without views — has no session at all.

## Using Sessions in Templates

Expand Down
2 changes: 1 addition & 1 deletion documentation/docs/core/validation.md
Original file line number Diff line number Diff line change
Expand Up @@ -210,4 +210,4 @@ When validation fails, the framework returns a `422` response with this structur
}
```

For HTML requests (web routes), the user is redirected back with errors and old input available in the session.
For HTML requests (web routes), the user is redirected back with errors and old input available in the session. They are kept for the next request only. Fields whose name contains `password` are never kept as old input, and neither are uploaded files. An app without sessions (no views and CSRF off) has nowhere to keep them across a redirect, so it answers HTML requests with the `422` too.
Loading
Loading