AFast
็ฎไฝไธญๆ | English
AFast is a high-performance Rust web backend framework. Annotate functions with
#[handler] โ the framework auto-registers routes, dispatches requests via a
compact binary protocol, and generates TypeScript / JavaScript / Kotlin / Rust
client code with one click.
Highlights
- Zero Route Definitions โ
#[handler]annotation, no manual routing table - Compact Binary Protocol โ smaller and faster than JSON, designed for internal communication
- Zero-Copy State โ
State<T>holds&'static T, no per-request clone, noT: Clonerequired - Auto Code Generation โ TypeScript / JavaScript / Kotlin / Rust clients with full type definitions
- Interactive API Docs โ built-in Web docs with dark/light theme, online testing, WS/SSE debugging
- Multiple Transports โ WebSocket, HTTP/1.1, HTTP/2, TCP, mix and match
- TLS / HTTPS โ rustls with ALPN for HTTP/2 negotiation
- RESTful Endpoints โ
#[get]/#[post]/#[put]/#[delete]with JSON - WebSocket & SSE โ
#[ws]and#[sse]route macros - Long Connections โ bidirectional streaming via
Receiver/Sender - Lifecycle Hooks โ
before_request/on_response/on_error/on_connect/on_disconnect - Request Context โ
Ctx<T>per-request context, hooks write, handlers read - Rate Limiting โ named policies with pluggable storage backend
Quick Example
use afast::{AFast, handler, service, State, Data, Result};
use afast::{AFastDeserialize, AFastSerialize, Tag};
struct AppState { db_url: String }
#[derive(AFastDeserialize, Tag)]
#[tag("Request body")]
struct HelloReq { name: String }
#[derive(AFastSerialize, Tag)]
#[tag("Response body")]
struct HelloResp { message: String }
#[handler(desc("Say hello"))]
async fn hello(
state: State<AppState>,
req: Data<HelloReq>,
) -> Result<HelloResp> {
Ok(HelloResp { message: format!("Hello, {}!", req.name) })
}
#[tokio::main]
async fn main() {
let svc = service!("api" => { h(hello) });
AFast::new()
.state(AppState { db_url: "localhost".into() })
.service(svc)
.http("0.0.0.0:5000")
.run().await.unwrap();
}
Quick Start
Add Dependency
[dependencies]
afast = { version = "0.1.24", features = ["http", "ordinary-http", "ts"] }
tokio = { version = "1", features = ["full"] }
Define State and Handlers
#![allow(unused)]
fn main() {
use afast::{AFast, handler, service, State, Data, Custom, Result};
use afast::{AFastDeserialize, AFastSerialize, Tag};
use std::sync::{Arc, Mutex};
// State โ no Clone required, holds &'static T internally
struct AppState {
db_url: String,
counter: Arc<Mutex<u64>>,
}
#[derive(AFastDeserialize, Tag)]
#[tag("Auth info")]
struct AuthCustom { token: i64, platform: String }
#[derive(AFastDeserialize, Tag)]
#[tag("Request body")]
struct HelloReq { name: String }
#[derive(AFastSerialize, Tag)]
#[tag("Response body")]
struct HelloResp { message: String }
#[handler(desc("Say hello"), name("hello"))]
async fn hello(
state: State<AppState>,
auth: Custom<AuthCustom>,
req: Data<HelloReq>,
) -> Result<HelloResp> {
let mut count = state.counter.lock().unwrap();
*count += 1;
Ok(HelloResp { message: format!("Hello, {}! (count: {})", req.name, count) })
}
}
Register Routes and Run
#[tokio::main]
async fn main() {
let svc = service!("api", "Example API" => {
h(hello),
});
AFast::new()
.state(AppState {
db_url: "localhost".into(),
counter: Arc::new(Mutex::new(0)),
})
.service(svc)
.http("0.0.0.0:5000")
.run().await.unwrap();
}
Run
cargo run
- HTTP API:
POST http://localhost:5000/_api(binary protocol) - API Docs:
http://localhost:5000/doc - Generated TS client:
./client/api.ts
Multiple Transports
#![allow(unused)]
fn main() {
AFast::new()
.state(app_state)
.service(svc)
.ws("0.0.0.0:3001") // Binary WebSocket
.tcp("0.0.0.0:4001") // Binary TCP
.http("0.0.0.0:5001") // HTTP + ordinary routes
.run().await.unwrap();
}
Or merge WS into HTTP (same port):
#![allow(unused)]
fn main() {
AFast::new()
.state(app_state)
.service(svc)
.ws("0.0.0.0:5001")
.http("0.0.0.0:5001") // Same port โ auto-merged
.run().await.unwrap();
}
Features
Core
| Feature | Description | Dependencies |
|---|---|---|
binary | Binary protocol (POST /_api, WS framing, TCP framing) | โ |
http | HTTP server | hyper, hyper-util, http-body-util |
ws | WebSocket server | tokio-tungstenite, futures-util, binary |
tcp | TCP server (length-prefix framing) | binary |
Code Generation
| Feature | Description | Dependencies |
|---|---|---|
ts | TypeScript client generation (ESM + full types) | โ |
js | JavaScript client generation (ESM + JSDoc) | โ |
kt | Kotlin client generation | โ |
rs | Rust client generation (Tokio async / std sync TCP) | โ |
cs | C# / .NET client generation (HttpClient / WebSocket / TCP) | โ |
code | On-demand code generation at /code/{service}/{lang} | http |
Documentation
| Feature | Description | Dependencies |
|---|---|---|
doc | Interactive API docs at /doc endpoint | http, js |
Ordinary Routes
| Feature | Description | Dependencies |
|---|---|---|
ordinary-http | RESTful JSON endpoints (#[get]/#[post]/etc.) | http, serde, serde_json |
ordinary-ws | Path-based WebSocket endpoints (#[ws]) | ws, ordinary-http |
ordinary-sse | Server-Sent Events endpoints (#[sse]) | ordinary-http, futures-util |
Protocol Options
| Feature | Description |
|---|---|
seq64 | WS request ID uses i64 (default i32) |
len64 | WS payload length uses u64 (default u32) |
tag-u8 | Enum tag uses u8 (default) |
tag-u16 | Enum tag uses u16 |
tag-u32 | Enum tag uses u32 |
TLS
| Feature | Description | Dependencies |
|---|---|---|
tls | HTTPS via rustls with ALPN for HTTP/2, hot-reload via channel | http, tokio-rustls, rustls, rustls-pemfile |
Optional Capabilities
| Feature | Description |
|---|---|
marker | Marker-based conditional serialization via AFast::marker() |
hook | Lifecycle hooks (before_request/on_connect/etc.), global and per-service |
rate-limit | Named-policy rate limiting (FixedWindow/SlidingWindow/TokenBucket) |
tls | TLS/HTTPS support via rustls with ALPN |
Note: If the server uses
seq64orlen64, generated client code must use the same feature, otherwise protocol mismatch will occur.
Core Concepts
Handler Registration
The #[handler] proc macro generates the following at compile time:
- The original function unchanged
HandlerMetaโ name, description, parameter list, return type metadataHandlerInvokertrait impl โ type-erased invoker, deserializes params, calls the function- A static invoker instance โ referenced by the
register!macro
#![allow(unused)]
fn main() {
#[handler(desc("Get user"), name("get_user"))]
async fn get_user_handler(
state: State<AppState>,
auth: Custom<Auth>,
req: Data<UserIdRequest>,
) -> Result<UserInfo> {
// ...
}
}
desc("...")โ Sets description used in docs and JSDoc commentsname("...")โ Overrides the client-side method name (defaults to the Rust function name)cache(seconds)โ Enables client-side cachingrate_limit("policy")โ Binds the handler to a named rate-limit policy- Any other attribute โ Collected as custom attributes in
HandlerMeta::attrs
Custom Attributes
You can add arbitrary attributes to handler macros. They are collected into HandlerMeta::attrs as Attr key-value pairs:
#![allow(unused)]
fn main() {
#[handler(desc("Create user"), tag("admin"), timeout(30), deprecated)]
async fn create_user(...) -> ... { ... }
}
At runtime, read them via invoker.meta().unwrap().attrs:
#![allow(unused)]
fn main() {
if let Some(meta) = invoker.meta() {
for attr in meta.attrs {
match attr.value {
AttrValue::Str(v) => println!("{} = {}", attr.key, v),
AttrValue::Int(v) => println!("{} = {}", attr.key, v),
AttrValue::Bool(v) => println!("{} = {}", attr.key, v),
}
}
}
}
Value type is inferred automatically:
- String:
tag("admin")โAttrValue::Str("admin") - Integer:
timeout(30)โAttrValue::Int(30) - Boolean:
deprecatedโAttrValue::Bool(true)
Both syntaxes are supported: tag("admin") and tag = "admin".
Custom attributes are also available in hooks via RequestContext::attrs:
#![allow(unused)]
fn main() {
impl Hook for MyHook {
fn before_request(&self, ctx: &RequestContext) -> Option<Box<dyn RequestGuard>> {
for attr in ctx.attrs {
if attr.key == "deprecated" {
eprintln!("WARNING: {} is deprecated", ctx.handler_name);
}
}
None
}
}
}
Multiple States
AFast supports registering multiple State types. StateMap uses TypeId as keys, with one value per type. State<T> holds a &'static T reference โ the value is allocated once at startup via Box::leak and never cloned per-request:
#![allow(unused)]
fn main() {
struct DbConfig { url: String }
struct RedisConfig { url: String }
struct AppConfig { name: String }
let app = AFast::new()
.state(DbConfig { url: "postgres://...".into() })
.state(RedisConfig { url: "redis://...".into() })
.state(AppConfig { name: "my-app".into() });
#[handler(desc("Multiple State example"))]
async fn my_handler(
db: State<DbConfig>,
redis: State<RedisConfig>,
config: State<AppConfig>,
) -> Result<()> {
println!("DB: {}, Redis: {}, App: {}", db.url, redis.url, config.name);
Ok(())
}
}
If a handler references a State type that was not registered, it returns a CODE_STATE_NOT_FOUND error at runtime.
Interior Mutability
Since State<T> provides a shared &'static T reference, mutations require interior mutability patterns. Wrap mutable fields in Arc<Mutex<...>> or Arc<RwLock<...>>:
#![allow(unused)]
fn main() {
use std::sync::{Arc, Mutex};
struct AppState {
db: Arc<Mutex<Database>>,
counter: Arc<Mutex<u64>>,
}
#[handler(desc("Increment counter"))]
async fn increment(state: State<AppState>) -> Result<()> {
let mut count = state.counter.lock().unwrap();
*count += 1;
Ok(())
}
}
State<T> no longer requires T: Clone โ only T: 'static.
Multiple Data Params
A handler can accept multiple Data<T> parameters, deserialized sequentially from the binary payload:
#![allow(unused)]
fn main() {
#[derive(AFastDeserialize, Tag)]
#[tag("Pagination")]
struct PageRequest { page: i64, size: i64 }
#[derive(AFastDeserialize, Tag)]
#[tag("Filter")]
struct FilterRequest { keyword: String, status: i32 }
#[handler(desc("Search users"))]
async fn search_users(
page: Data<PageRequest>,
filter: Data<FilterRequest>,
) -> Result<PageResponse> {
// ...
}
}
Generated TypeScript client method signature:
async searchUsers(page: PageRequest, filter: FilterRequest): Promise<PageResponse>
Custom Error Types
afast::Result<T> defaults to Error, but all handler macros support custom error types. Implement the AFastError trait:
#![allow(unused)]
fn main() {
use afast::AFastError;
enum AppError {
NotFound { resource: String },
Forbidden { reason: String },
}
impl AFastError for AppError {
fn code(&self) -> i64 {
match self {
AppError::NotFound { .. } => 404,
AppError::Forbidden { .. } => 403,
}
}
fn message(&self) -> String {
match self {
AppError::NotFound { resource } => format!("{} not found", resource),
AppError::Forbidden { reason } => reason.clone(),
}
}
}
}
Then use afast::Result<T, AppError> in handlers:
#![allow(unused)]
fn main() {
#[handler(desc("Get user"))]
async fn get_user(id: Data<UserId>) -> afast::Result<UserInfo, AppError> {
let user = find_user(&id).ok_or(AppError::NotFound { resource: "user".into() })?;
Ok(user)
}
}
Works with all macros: #[handler], #[get]/#[post]/#[put]/#[delete], #[ws], #[sse].
Using afast::Result<T> (without the second parameter) is equivalent to Result<T, Error> and is fully backward compatible.
Extractor Types
| Extractor | Description | Protocols |
|---|---|---|
State<T> | Injects shared state by type from StateMap (T: โstatic, zero-copy &โstatic T) | All |
Ctx<T> | Injects per-request context data set by hooks (T: Clone) | All |
Data<T> | Deserializes request body from binary payload | HTTP/WS/TCP |
Custom<T> | Deserializes client-side custom context (e.g., auth token) | HTTP/WS/TCP |
Receiver | Receives binary messages from the client (long connection) | WS/TCP |
Sender | Sends binary messages to the client (long connection) | WS/TCP |
Query<T> | Deserializes from URL query string (requires ordinary-http) | HTTP |
Param<T> | Deserializes from route path params (:id) (requires ordinary-http) | HTTP |
Body<T> | Deserializes from HTTP JSON body (requires ordinary-http) | HTTP |
Header<T> | Deserializes from HTTP request headers (requires ordinary-http) | HTTP |
FullPath | Extracts the full request path (e.g. /users/123) (requires ordinary-http) | HTTP |
Services and Nesting
The service! macro builds handler trees with group for namespacing:
#![allow(unused)]
fn main() {
let api_svc = service!("api", "User API" => {
h(health),
group("user" => {
h(list_users),
h(get_user),
group("posts" => {
h(list_posts),
}),
}),
group("chat" => {
h(chat), // Persistent connection using Receiver/Sender
}),
});
}
Client namespace paths become api.user.list_users, api.chat.chat, etc.
Binary and ordinary HTTP routes can be mixed within a group:
#![allow(unused)]
fn main() {
group("user" => {
h(get_user), // binary handler
get(":id", get_user_by_id), // GET /user/:id
post("", create_user), // POST /user
delete(":id", delete_user), // DELETE /user/:id
}),
}
Service Merge on Duplicate Name
Registering multiple services with the same name automatically merges the later handlers and routes into the first service:
#![allow(unused)]
fn main() {
let user_svc = service!("api", "User API" => {
h(list_users),
h(create_user),
});
let user_extra_svc = service!("api" => {
h(delete_user),
get(":id", get_user_http),
});
let app = AFast::new()
.service(user_svc)
.service(user_extra_svc); // merge into "api"
}
Empty-Name Service
A service with an empty string name ("") registers handlers callable via binary protocol, but excluded from client code generation and API documentation:
#![allow(unused)]
fn main() {
let internal_svc = service!("", "Internal" => {
h(debug_info),
get("ping", ping),
});
}
Catch-all Routes
Use * or *name syntax to register catch-all routes that capture all requests not matched by other routes:
#![allow(unused)]
fn main() {
let svc = service!("api" => {
get("users/:id", get_user), // Exact match takes priority
get("*", catch_all_get), // Catches all remaining GET requests
post("*path", catch_all_post), // Catches all remaining POST, path stored as "path"
});
}
Matching priority (highest to lowest):
- Exact routes (e.g.,
/users/list) - Parameterized routes (e.g.,
/users/:id) - Catch-all routes (
*or*name)
Even if the catch-all is registered first, specific routes still take precedence. Built-in endpoints (/_api, /_ws, /code, /doc) are never intercepted by catch-all routes.
The captured path is available via the Param extractor:
#![allow(unused)]
fn main() {
use std::collections::HashMap;
use afast::{Param, Json, FullPath};
#[get(desc("Catch-all handler"))]
async fn catch_all_get(
path: FullPath,
Param(params): Param<HashMap<String, String>>,
) -> Json<serde_json::Value> {
let rest = params.get("*").unwrap(); // or params.get("path") if written as *path
Json(serde_json::json!({ "full_path": path.0, "remaining": rest }))
}
}
Type Tags
#[derive(Tag)] generates runtime type metadata for structs and enums. The code generator recursively discovers nested types through FieldMeta.structure function pointers:
#![allow(unused)]
fn main() {
use afast::Tag;
#[derive(Tag)]
#[tag("User role")]
enum Role {
Admin,
User { level: i32 },
Guest { expires_at: i64 },
Custom(String),
}
#[derive(Tag)]
#[tag("User info")]
struct User {
name: String,
role: Role, // Auto-discovers Role fields recursively
tags: Vec<String>, // Vec element type auto-expanded
avatar: Option<Vec<u8>>,
}
}
Validation Rules
| Rule | Example | Description |
|---|---|---|
gt(value, code, "msg") | #[afast(gt(0, 400, "must > 0"))] | Greater than |
gte(value, code, "msg") | #[afast(gte(1, 400, "must >= 1"))] | Greater or equal |
lt(value, code, "msg") | #[afast(lt(100, 400, "must < 100"))] | Less than |
lte(value, code, "msg") | #[afast(lte(99, 400, "must <= 99"))] | Less or equal |
len(min, max, code, "msg") | #[afast(len(1, 20, 400, "len 1-20"))] | Length constraint |
of(["a","b"], code, "msg") | #[afast(of(["a","b"], 400, "a or b"))] | Enum of values |
Conditional Serialization (Marker)
When the marker feature is enabled, AFast::marker() sets a global marker string (default "afast") passed to afastdataโs to_bytes_with / from_bytes_with. Fields annotated with #[afast(skip_with("marker"))] are conditionally skipped during serialization/deserialization based on the active marker.
Skip Modes
#[afast(skip)]โ Field is always skipped, never serialized/deserialized. Must have aDefaultimpl or initialization function.#[afast(skip_with("marker"))]โ Skipped when the marker matches; serialized normally otherwise.
The marker propagates recursively into nested types (Vec<T>, Option<T>, etc.).
Generated client code (TS/JS/KT/RS) and API docs automatically exclude skipped fields.
Example
#![allow(unused)]
fn main() {
#[derive(AFastSerialize, AFastDeserialize, Tag)]
#[tag("User info")]
struct User {
name: String,
#[afast(skip)]
internal_secret: String, // always skipped
#[afast(skip_with("afast"))]
internal_note: String, // skipped when marker is "afast"
}
let app = AFast::new()
.marker("afast") // set marker; default is already "afast"
.service(svc)
.http("0.0.0.0:5000");
}
Without the marker feature, serialize / deserialize use plain to_bytes / from_bytes and all fields are always included. However, #[afast(skip)] fields are still excluded from generated client code.
Rate Limiting
Enable the rate-limit feature to apply named rate-limit policies to handlers. Supports HTTP, WebSocket, and TCP transports.
Configuration
#![allow(unused)]
fn main() {
use afast::{RateLimitConfig, RateLimitPolicy, RateLimitKey, Algorithm};
let app = AFast::new()
.rate_limit(
RateLimitConfig::new()
.policy(RateLimitPolicy {
id: "login".into(),
max_requests: 5,
window_secs: 60,
key: RateLimitKey::Ip,
algorithm: Algorithm::SlidingWindow,
})
.default_policy("global")
.policy(RateLimitPolicy {
id: "global".into(),
max_requests: 100,
window_secs: 1,
key: RateLimitKey::Ip,
algorithm: Algorithm::SlidingWindow,
}),
)
.service(svc)
.http("0.0.0.0:5000");
}
Binding a Handler
#![allow(unused)]
fn main() {
#[handler(rate_limit("login"), desc("User login"))]
async fn login(
state: State<AppState>,
req: Data<LoginRequest>,
) -> Result<LoginResponse> {
// ...
}
}
Handlers without rate_limit automatically use the default_policy. If no default is set, they are not rate-limited.
Rate Limit Keys
| Key | Description | HTTP | WebSocket | TCP |
|---|---|---|---|---|
Ip | Client IP (supports X-Forwarded-For) | โ | โ | โ |
Header("name") | HTTP header value (e.g. API Key) | โ | โ (cached at handshake) | โญ skipped |
Connection | Per-connection (WS/TCP message rate) | โญ skipped | โ | โ |
Global | Shared global counter | โ | โ | โ |
Storage Backend
The default InMemoryStore keeps counters in process memory. Implement RateLimitStore for a custom backend (e.g. Redis):
#![allow(unused)]
fn main() {
use afast::RateLimitStore;
struct RedisStore { /* ... */ }
impl RateLimitStore for RedisStore {
fn incr<'a>(&'a self, key: &'a str, ttl_secs: u64)
-> Pin<Box<dyn Future<Output = u64> + Send + 'a>> { /* INCR + EXPIRE */ }
fn get<'a>(&'a self, key: &'a str)
-> Pin<Box<dyn Future<Output = u64> + Send + 'a>> { /* GET */ }
fn set<'a>(&'a self, key: &'a str, value: u64, ttl_secs: u64)
-> Pin<Box<dyn Future<Output = ()> + Send + 'a>> { /* SET + EXPIRE */ }
fn delete<'a>(&'a self, key: &'a str)
-> Pin<Box<dyn Future<Output = ()> + Send + 'a>> { /* DEL */ }
}
}
Rejection Response
- HTTP: Status
429 Too Many Requests, body:{"code":-90012,"message":"Too many requests"} - WebSocket / TCP: Error frame with code
-90012
Customize via RateLimitConfig::rejected_code() and rejected_message().
Lifecycle Hooks
Enable the hook feature to intercept request lifecycle events for observability, tracing, logging, or custom middleware.
Quick Example
#![allow(unused)]
fn main() {
use afast::hook::{Hook, RequestContext, RequestGuard, ConnectionGuard};
struct LoggingHook;
impl Hook for LoggingHook {
fn before_request(&self, ctx: &RequestContext) -> Option<Box<dyn RequestGuard>> {
println!("โ {} ({})", ctx.handler_name, ctx.transport);
Some(Box::new(std::time::Instant::now()))
}
fn on_connect(&self, ctx: &RequestContext) -> Option<Box<dyn ConnectionGuard>> {
println!("โ connect: {} ({})", ctx.handler_name, ctx.transport);
Some(Box::new(()))
}
}
impl RequestGuard for std::time::Instant {
fn on_response(&mut self, ctx: &RequestContext, _resp: &[u8]) {
println!("โ {} OK ({:?})", ctx.handler_name, self.elapsed());
}
fn on_error(&mut self, ctx: &RequestContext, err: &afast::Error) {
println!("โ {} error: {}", ctx.handler_name, err);
}
}
impl ConnectionGuard for () {
fn on_disconnect(&mut self, ctx: &RequestContext) {
println!("โ disconnect: {} ({})", ctx.handler_name, ctx.transport);
}
}
}
Hook Traits
Hook โ Entry Point
#![allow(unused)]
fn main() {
pub trait Hook: Send + Sync + 'static {
/// Called before each request for request-response interfaces
/// (HTTP binary, WS binary, TCP binary, ordinary HTTP).
/// Return a `RequestGuard` to observe the response.
fn before_request(&self, ctx: &RequestContext) -> Option<Box<dyn RequestGuard>> { None }
/// Called when a connection is established for connection-oriented interfaces
/// (WS long, TCP long, ordinary WS, SSE).
/// Return a `ConnectionGuard` to observe disconnection.
fn on_connect(&self, ctx: &RequestContext) -> Option<Box<dyn ConnectionGuard>> { None }
}
}
RequestGuard โ Per-Request Observer
#![allow(unused)]
fn main() {
pub trait RequestGuard: Send + 'static {
/// Called when the handler returns Ok.
fn on_response(&mut self, ctx: &RequestContext, response: &[u8]) {}
/// Called when the handler returns Err.
fn on_error(&mut self, ctx: &RequestContext, error: &afast::Error) {}
}
}
ConnectionGuard โ Long Connection Observer
#![allow(unused)]
fn main() {
pub trait ConnectionGuard: Send + 'static {
/// Called when the connection is closed.
fn on_disconnect(&mut self, ctx: &RequestContext) {}
}
}
Global and Service Hooks
#![allow(unused)]
fn main() {
let app = AFast::new()
.hook(LoggingHook) // Global: all handlers
.service(
service!("api" => { h(handler) })
.hook(ApiSpecificHook) // Service: only this service's handlers
);
}
- Global hooks run for every handler across all services.
- Service hooks run only for handlers in that service.
- Both always execute โ they never replace each other.
- Execution order: global first, then service (onion model ๐ง ).
Hook Lifecycle by Transport
Hooks are divided into two categories by interface type:
before_request(request-response): HTTP binary, WS binary, TCP binary, ordinary HTTP.on_connect(connection-oriented): WS long, TCP long, ordinary WS, SSE.
Binary Protocol (HTTP POST /_api, WS /_ws, TCP)
Regular handlers (request-response):
before_request โ handler โ on_response / on_error
Long-connection handlers (call_stream):
on_connect โ handler โ on_disconnect
Ordinary HTTP (ordinary-http)
before_request โ handler โ on_response / on_error
on_connect / on_disconnect are not called for ordinary HTTP (stateless request/response).
Ordinary WebSocket (ordinary-ws)
on_connect โ handler โ on_disconnect
on_connect: fires after the WebSocket handshake completes.on_disconnect: fires after the handler returns and forwarding tasks are cleaned up.
Ordinary SSE (ordinary-sse)
on_connect โ handler (spawned) โ on_disconnect
on_connect: fires before the SSE response is sent and the handler is spawned.on_disconnect: fires after the handler task completes.
Request Context Integration
Hooks can read and write per-request data via the ctx field on RequestContext. This data is then available to handlers via the Ctx<T> extractor.
#![allow(unused)]
fn main() {
use afast::hook::{Hook, RequestContext};
#[derive(Clone)]
struct RequestId(pub String);
struct CtxHook;
impl Hook for CtxHook {
fn before_request(&self, ctx: &RequestContext) -> Option<Box<dyn RequestGuard>> {
// Write into the per-request context
ctx.ctx.insert(RequestId(format!("req-{:08x}", /* ... */)));
None
}
}
}
The handler retrieves it automatically:
#![allow(unused)]
fn main() {
#[handler(desc("..."))]
async fn my_handler(ctx: afast::Ctx<RequestId>) -> afast::Result<()> {
println!("request_id = {}", ctx.0 .0);
Ok(())
}
}
The same context is shared across all hooks and the handler for a single request. For long-connection handlers (WS/TCP), the context lives for the entire connection duration.
See Request Context (Ctx) for full documentation.
Accessing Custom Attributes
RequestContext exposes the handlerโs custom attributes via ctx.attrs:
#![allow(unused)]
fn main() {
impl Hook for DeprecationHook {
fn before_request(&self, ctx: &RequestContext) -> Option<Box<dyn RequestGuard>> {
for attr in ctx.attrs {
match attr.key {
"deprecated" => eprintln!("WARNING: {} is deprecated", ctx.handler_name),
"tag" => {
if let AttrValue::Str(v) = attr.value {
eprintln!("tag: {}", v);
}
}
_ => {}
}
}
None
}
}
}
Getting Client IP
RequestContext provides two fields for obtaining the clientโs IP:
#![allow(unused)]
fn main() {
impl Hook for IpHook {
fn before_request(&self, ctx: &RequestContext) -> Option<Box<dyn RequestGuard>> {
// TCP peer address
let peer_ip = &ctx.client_ip;
// Real client IP (from X-Forwarded-For / X-Real-IP headers)
let real_ip = ctx.forwarded_for.as_deref().unwrap_or(&ctx.client_ip);
println!("client: {}, real: {}", peer_ip, real_ip);
None
}
}
}
client_ip: Available for all transportsforwarded_for: Only available for HTTP/WS;Nonefor TCP
Hook Key โ Route Matching
Hooks are matched by "service_name:route_path", not by handler function name. This avoids conflicts when the same function name appears in different groups within the same service:
#![allow(unused)]
fn main() {
service!("admin" => {
group("users" => {
get("info", get_info), // key: "admin:/users/info"
}),
group("posts" => {
get("info", get_info), // key: "admin:/posts/info" โ no conflict!
}),
})
}
For merged services (same service name registered multiple times), hook entries are automatically deduplicated.
RequestContext Fields
| Field | Type | Description |
|---|---|---|
handler_name | &'static str | Handler function name |
handler_desc | &'static str | Description from #[handler(desc(...))] |
transport | &'static str | "http-binary", "http", "ws-binary", "ws", "tcp", or "sse" |
is_binary | bool | Whether this is a binary protocol handler |
method | &'static str | HTTP method ("GET", "POST", etc.), empty for non-HTTP |
long_connection | bool | Whether this is a long-connection handler (Receiver/Sender) |
handler_id | usize | Handler offset in binary dispatch table (0 for ordinary routes) |
state | Arc<StateMap> | Shared application state |
ctx | RequestCtx | Per-request context container (hooks write, handlers read via Ctx<T>) |
attrs | &'static [Attr] | Custom handler attributes from #[handler(...)] |
Supported Extractors
All extractors work across all transports:
| Extractor | HTTP | WS | SSE | TCP |
|---|---|---|---|---|
State<T> | โ | โ | โ | โ |
Query<T> | โ | โ | โ | โ |
Param<T> | โ | โ | โ | โ |
Header<T> | โ | โ | โ | โ |
Body<T> | โ | โ | โ | โ |
Custom<T> | โ | โ | โ | โ |
Data | โ | โ | โ | โ |
WsSender | โ | โ | โ | โ |
WsReceiver | โ | โ | โ | โ |
SseSender | โ | โ | โ | โ |
Sender | โ | โ | โ | โ |
Receiver | โ | โ | โ | โ |
Server Example Output
[hook] โ connect: chat_ws (ws) โ on_connect
[check-svc] โถ chat_ws โ service hook
[ws-chat] client joined room: test โ handler runs
[ws-chat] client left room: test โ handler returns
[hook] โ disconnect: chat_ws (ws) โ on_disconnect
[check-svc] โ chat_ws done โ service hook done
Request Context (Ctx)
The Ctx<T> extractor provides per-request (or per-connection) typed data that flows through the entire handler lifecycle. Unlike State<T> which is application-global, Ctx<T> is scoped to a single request.
Core Concepts
State<T> | Ctx<T> | |
|---|---|---|
| Scope | Application-global | Per-request / per-connection |
| Storage | StateMap (set once at startup) | RequestCtx (created per request) |
| Lifetime | Entire application | Request start โ fully finished |
| Use case | Database pool, config | Request ID, auth info, timing |
| Set by | AFast::state() | Hooks (before_request, on_connect) |
How It Works
Request arrives
โ
โผ
RequestCtx::new() โ framework creates empty context
โ
โผ
Hook: before_request() โ hook inserts values: ctx.ctx.insert(RequestId(...))
โ
โผ
Handler executes โ framework extracts: Ctx<RequestId> from context
โ
โผ
Hook: on_response() โ hook reads values: ctx.ctx.get::<RequestId>()
โ
โผ
RequestCtx dropped โ all values freed
For long-connection handlers (WS/TCP), the RequestCtx lives for the entire connection:
Connection established
โ
โผ
RequestCtx::new() โ created once
โ
โผ
Hook: on_connect() โ hook inserts connection-scoped data
โ
โผ
Message 1: handler executes โ reads Ctx<T>
Message 2: handler executes โ same Ctx<T> (shared across messages)
...
โ
โผ
Hook: on_disconnect() โ hook reads final state
โ
โผ
RequestCtx dropped
Quick Example
1. Define your context data
#![allow(unused)]
fn main() {
#[derive(Clone)]
pub struct RequestInfo {
pub request_id: String,
pub started_at: std::time::Instant,
}
}
The type must be Clone + Send + Sync + 'static (for extraction via Ctx<T>).
2. Create a hook that inserts data
#![allow(unused)]
fn main() {
use afast::hook::{Hook, RequestContext, RequestGuard};
struct CtxHook;
impl Hook for CtxHook {
fn before_request(&self, ctx: &RequestContext) -> Option<Box<dyn RequestGuard>> {
ctx.ctx.insert(RequestInfo {
request_id: format!("req-{:08x}", /* ... */),
started_at: std::time::Instant::now(),
});
None // No guard needed if only writing context
}
}
}
3. Use in handlers
#![allow(unused)]
fn main() {
use afast::{Ctx, handler};
#[handler(desc("My handler"))]
async fn my_handler(ctx: Ctx<RequestInfo>) -> afast::Result<MyResp> {
let elapsed = ctx.0.started_at.elapsed();
println!("request {} took {:?}", ctx.0.request_id, elapsed);
// ...
}
}
ctx.0 gives you the inner RequestInfo value.
Supported Handler Types
Ctx<T> works in all handler types:
| Handler Type | Macro | Example |
|---|---|---|
| Binary protocol | #[handler] | async fn h(ctx: Ctx<Info>) -> Result<T> |
| HTTP ordinary | #[get] / #[post] / etc. | async fn h(ctx: Ctx<Info>) -> HttpResult<Json<T>> |
| WebSocket | #[ws] | async fn h(ctx: Ctx<Info>, sender: WsSender) -> Result<()> |
| SSE | #[sse] | async fn h(ctx: Ctx<Info>, sender: SseSender) -> Result<()> |
| Long-connection | #[handler] + Receiver/Sender | async fn h(ctx: Ctx<Info>, rx: Receiver, tx: Sender) |
Ctx<T> does not participate in the binary/ordinary mutual exclusion check, so it can be combined with any other extractor.
Parameter Position
Ctx<T> can be placed at any position in the parameter list. By convention, put it first:
#![allow(unused)]
fn main() {
#[handler(desc("..."))]
async fn my_handler(
ctx: Ctx<RequestInfo>, // โ context first
state: State<AppState>, // โ then state
data: Data<MyReq>, // โ then data
) -> afast::Result<MyResp> {
// ...
}
}
Reading and Writing in Hooks
Hooks interact with the context via RequestContext::ctx:
#![allow(unused)]
fn main() {
impl Hook for MyHook {
fn before_request(&self, ctx: &RequestContext) -> Option<Box<dyn RequestGuard>> {
// Write
ctx.ctx.insert(MyData { value: 42 });
// Read (useful if another hook wrote earlier)
// ctx.ctx.get::<OtherData>()
Some(Box::new(MyGuard))
}
}
impl RequestGuard for MyGuard {
fn on_response(&mut self, ctx: &RequestContext, _resp: &[u8]) {
// Read what the handler or earlier hooks wrote
if let Some(data) = ctx.ctx.get::<MyData>() {
println!("value was {}", data.value);
}
}
}
}
Getting Client IP
RequestContext provides two fields for obtaining the clientโs IP address:
| Field | Type | Description |
|---|---|---|
client_ip | String | TCP peer address (peer_addr) |
forwarded_for | Option<String> | Real IP from X-Forwarded-For / X-Real-IP headers |
#![allow(unused)]
fn main() {
impl Hook for MyHook {
fn before_request(&self, ctx: &RequestContext) -> Option<Box<dyn RequestGuard>> {
// Direct connection IP (may be proxy IP)
let ip = &ctx.client_ip;
// Real client IP (only available for HTTP/WS)
let real_ip = ctx.forwarded_for.as_deref().unwrap_or(&ctx.client_ip);
println!("client: {} (real: {})", ip, real_ip);
None
}
}
}
Notes:
client_ipis available for all transports (HTTP, WS, TCP, SSE)forwarded_foris only available for HTTP and WebSocket transports; alwaysNonefor TCP- When behind a reverse proxy, prefer using
forwarded_for
API Reference
RequestCtx โ Container
#![allow(unused)]
fn main() {
// Create empty context
let ctx = RequestCtx::new();
// Insert a value (keyed by type)
ctx.insert(my_value);
// Retrieve a cloned value
let val: Option<MyType> = ctx.get::<MyType>();
// Clone (cheap โ shares Arc)
let ctx2 = ctx.clone();
}
Ctx<T> โ Extractor
#![allow(unused)]
fn main() {
pub struct Ctx<T>(pub T);
// Access the inner value
let inner: T = my_ctx.0;
}
Performance
RequestCtx::new()allocates a singleArc<RwLock<HashMap>>โ very cheap.insert()andget()acquire aRwLockโ uncontended in the typical case (sequential write then read), so overhead is negligible.RequestCtx::clone()is anArcclone โ O(1).- For large context values, wrap in
Arc<T>to makeget()clone cheap. - Handlers that donโt use
Ctx<T>pay zero extraction cost โ the empty context is created but never read.
Transport Layer
AFast supports multiple transport layers that can run simultaneously on different ports.
HTTP
HTTP server endpoints:
| Method | Path | Description |
|---|---|---|
| POST | /_api | Binary handler dispatch |
| GET | /_ws | WebSocket upgrade (merged mode) |
| GET | /code/{service}/{lang} | On-demand code gen (requires code) |
| GET | /doc | API docs index (requires doc) |
| GET | /doc/{service} | Service-specific docs (requires doc) |
| * | Ordinary routes | RESTful endpoints (requires ordinary-http) |
| GET | /path/:param | WebSocket upgrade for ordinary-ws routes (requires ordinary-ws) |
| GET | /path | SSE stream for ordinary-sse routes (requires ordinary-sse) |
HTTP Response Format:
- Success:
[0u8][0i64][data: bytes] - Error:
[1u8][code: i64][message: bytes]
WebSocket
WS frame format:
Request: [req_id: SeqId][handler_id: u32][len: Len][payload]
Push: [0: SeqId][conn_id: u32][len: Len][payload]
Heartbeat:[0xFFFFFFFF: SeqId][len: Len][conn_id1: u32]...
SeqId type is controlled by the seq64 feature (i32 or i64), Len type by the len64 feature (u32 or u64).
TCP
TCP uses 4-byte big-endian length-prefix framing with complete binary payloads per frame. Suitable for embedded devices or raw TCP scenarios.
HTTP + WS Port Merging
When ws_addr and http_addr are set to the same address, AFast merges WebSocket into the HTTP server via HTTP Upgrade:
#![allow(unused)]
fn main() {
// Same port for both HTTP and WebSocket
let app = AFast::new()
.service(svc)
.ws("0.0.0.0:5000")
.http("0.0.0.0:5000"); // Same address, auto-merged
}
TLS / HTTPS
AFast supports TLS/HTTPS via rustls with ALPN negotiation for HTTP/2.
Basic Usage
#![allow(unused)]
fn main() {
let app = AFast::new()
.service(svc)
.https("0.0.0.0:5443", "./cert.pem", "./key.pem", None);
}
Graceful Fallback
If certificate files donโt exist, the server automatically falls back to plain HTTP:
afast: TLS cert files not found, starting without encryption: [::]:5443
Hot-Reload Certificates
Reload certificates at runtime via a broadcast channel without restarting:
#![allow(unused)]
fn main() {
let (reload_tx, reload_rx) = tokio::sync::broadcast::channel(1);
let app = AFast::new()
.https("0.0.0.0:5443", "./cert.pem", "./key.pem", Some(reload_rx));
// Reload with original paths
reload_tx.send(None).unwrap();
// Reload with new paths
reload_tx.send(Some(TlsReloadMessage {
cert_path: "/new/cert.pem".into(),
key_path: "/new/key.pem".into(),
})).unwrap();
}
Ordinary HTTP (REST)
With ordinary-http, define RESTful routes using get/post/put/patch/delete inside the service! macro:
#![allow(unused)]
fn main() {
use afast::{get, Query, Param, Body, Header, Json, HttpResult};
#[derive(Deserialize)]
struct UserQuery { page: i64, size: i64 }
#[derive(Serialize)]
struct UserResponse { id: i64, name: String }
#[get(":id")]
async fn get_user(
state: State<AppState>,
param: Param<UserPath>,
query: Query<UserQuery>,
) -> HttpResult<Json<UserResponse>> {
Ok(Json(UserResponse { id: param.id, name: format!("User {}", param.id) }))
}
}
Response Types
| Type | HTTP Status | Content-Type |
|---|---|---|
Json<T> | 200 | application/json |
Text | 200 | text/plain |
Html | 200 | text/html |
File | 200 | Custom + Content-Disposition: attachment |
Status(code) | Custom | โ |
Redirect::temporary(url) / Redirect::permanent(url) | 302 / 301 | Location header |
CORS
Enable CORS (Cross-Origin Resource Sharing) via AFast::cors() (requires the http feature). All HTTP endpoints โ including binary /_api, ordinary HTTP routes, and code/doc endpoints โ automatically include CORS headers:
#![allow(unused)]
fn main() {
use afast::{AFast, CorsConfig};
// Development: allow all origins
AFast::new()
.cors(CorsConfig::permissive())
.http("0.0.0.0:5000")
.run().await;
// Production: specific origins with credentials
AFast::new()
.cors(
CorsConfig::new(vec!["https://example.com", "https://app.example.com"])
.allow_credentials(true)
.max_age(7200)
)
.http("0.0.0.0:5000")
.run().await;
}
The server automatically:
- Responds to
OPTIONSpreflight requests with204 No Content - Injects
Access-Control-Allow-Origininto every HTTP response - Sets
Access-Control-Allow-Methods,Access-Control-Allow-Headers, andAccess-Control-Max-Agefor preflight
Security Headers
Every HTTP response includes these security headers by default:
| Header | Value |
|---|---|
x-content-type-options | nosniff |
x-frame-options | DENY |
content-security-policy | default-src 'self' |
Override via AFast::security_headers():
#![allow(unused)]
fn main() {
AFast::new()
.security_headers(vec![
("x-content-type-options", "nosniff"),
("x-frame-options", "SAMEORIGIN"),
("content-security-policy", "default-src 'self'; script-src 'self' 'unsafe-inline'"),
])
.http("0.0.0.0:5000")
.run().await;
}
Ordinary WebSocket
With ordinary-ws, define WebSocket routes using ws inside the service! macro. These routes use text/JSON frames instead of the binary protocol:
#![allow(unused)]
fn main() {
use afast::{ws, WsSender, WsReceiver, WsParam};
#[derive(Deserialize)]
struct ChatParam { room: String }
#[ws(desc("Chat room"))]
async fn chat_ws(
param: WsParam<ChatParam>,
sender: WsSender,
mut receiver: WsReceiver,
) -> afast::Result<()> {
while let Some(msg) = receiver.recv().await {
if let afast::WsMessage::Text(text) = msg {
sender.send_text(format!("[{}] {}", param.0.room, text)).await?;
}
}
Ok(())
}
let svc = service!("chat" => {
ws("/chat/:room", chat_ws),
});
}
Connect: ws://host:port/chat/general?token=abc
TS/JS clients auto-generate platform-aware WebSocket connection methods, compatible with browsers, UniApp, and WeChat Mini Programs.
Server-Sent Events (SSE)
Requires the ordinary-sse feature. Register SSE routes with sse():
#![allow(unused)]
fn main() {
use afast::{SseSender, Query};
#[derive(Deserialize)]
struct SseQuery { room: Option<String> }
#[afast::sse(desc("SSE event stream"))]
async fn notifications(
query: Query<SseQuery>,
sender: SseSender,
) -> afast::Result<()> {
let room = query.0.room.unwrap_or_default();
// Send named event
sender.send_event("connected", &serde_json::json!({"room": room})).await?;
// Send data event (auto-serialized as JSON)
let mut count = 0u64;
loop {
tokio::time::sleep(Duration::from_secs(1)).await;
count += 1;
if sender.send_event("tick", &serde_json::json!({"count": count})).await.is_err() {
break; // Client disconnected
}
}
Ok(())
}
let svc = service!("events" => {
sse("/notifications", notifications),
});
}
Connect: GET http://host:port/notifications?room=general
Response headers:
Content-Type: text/event-stream; charset=utf-8Cache-Control: no-cacheConnection: keep-aliveTransfer-Encoding: chunked
Wire format:
event: connected
data: {"room":"general"}
event: tick
data: {"count":1}
SseSender Methods
| Method | Description |
|---|---|
send<T: Serialize>(data) | Send a data: event with JSON-serialized value |
send_event<T: Serialize>(event, data) | Send a named event with JSON-serialized value |
SseEvent Fields
| Field | Type | Wire Format |
|---|---|---|
event | Option<&str> | event: name\n |
data | String | data: ...\n |
id | Option<&str> | id: ...\n |
retry | Option<u64> | retry: ...\n |
Client Codegen
- TS/JS: Generates
EventSource-based methods - KT (OkHttp): Uses
okhttp3.sse.EventSource - KT (non-OkHttp): Uses
java.net.http.HttpClientwithBodyHandlers.ofLines()
// TS/JS generated client
const es = client.apis.sse_stream({ room: "general" });
es.addEventListener("connected", (e) => console.log("Connected:", e.data));
es.addEventListener("tick", (e) => console.log("Tick:", JSON.parse(e.data)));
es.close();
Long Connections
Handlers using Receiver/Sender are auto-detected as long-connection mode:
#![allow(unused)]
fn main() {
#[handler(desc("Chat"))]
async fn chat(
state: State<AppState>,
auth: Custom<Auth>,
mut receiver: Receiver,
sender: Sender,
) -> Result<()> {
sender.send(b"Welcome!".to_vec()).await?;
while let Some(msg) = receiver.recv().await {
sender.send(msg).await?; // echo
}
Ok(())
}
}
Generated clients return a Socket object for long-connection handlers, with send()/close() and onMessage callback.
File Upload
AFast supports multipart/form-data file upload via the multer crate. Two extraction modes are available:
Raw Multipart (Multipart)
For direct access to the multipart stream:
#![allow(unused)]
fn main() {
use afast::{post, Multipart, HttpResult, Json, Tag};
use serde::Serialize;
#[derive(Serialize, Tag)]
#[tag("Upload result")]
struct UploadResult {
filename: String,
size: usize,
}
#[post(desc("Upload file"))]
async fn upload(mut form: Multipart) -> HttpResult<Json<UploadResult>> {
let field = form.next_field().await?.ok_or_else(|| {
afast::Error::custom(400, "no file provided")
})?;
let filename = field.file_name().unwrap_or("unknown").to_string();
let data = field.bytes().await?;
Ok(Json(UploadResult { filename, size: data.len() }))
}
}
Typed Extraction (MultipartForm<T>)
For automatic extraction into a struct using #[derive(FromFormData)]:
#![allow(unused)]
fn main() {
use afast::{post, MultipartForm, FileField, HttpResult, Json, Tag, FromFormData};
use serde::Serialize;
#[derive(FromFormData, Tag)]
#[tag("Upload form")]
struct UploadForm {
description: String,
file: FileField,
}
#[derive(Serialize, Tag)]
#[tag("Upload result")]
struct UploadResult {
filename: String,
description: String,
size: usize,
}
#[post(desc("Upload with form data"))]
async fn upload_typed(form: MultipartForm<UploadForm>) -> HttpResult<Json<UploadResult>> {
let data = form.0;
Ok(Json(UploadResult {
filename: data.file.filename.unwrap_or_else(|| "unknown".to_string()),
description: data.description,
size: data.file.bytes.len(),
}))
}
}
#[derive(FromFormData)]
The derive macro automatically implements the FromFormData trait. Each struct field name must match the corresponding form field name.
Supported Field Types
| Rust Type | Form Field | Notes |
|---|---|---|
String | Text field | Direct text value |
i8/i16/i32/i64/u8/u16/u32/u64/f32/f64 | Text field | Parsed from text |
bool | Text field | "true"/"false"/"1"/"0" |
FileField | File field | Collects bytes, filename, content type |
Option<T> | Optional field | Defaults to None if missing |
FileField Structure
#![allow(unused)]
fn main() {
pub struct FileField {
pub name: String, // Form field name
pub filename: Option<String>, // Original filename
pub content_type: Option<String>, // MIME type
pub bytes: Vec<u8>, // File content
}
}
Client Code Generation
The TS/JS/KT code generators automatically produce FormData-based upload code:
// Generated TypeScript client
const formData = new FormData();
formData.append('description', 'My file');
formData.append('file', new Blob(['content']), 'test.txt');
const result = await client.apis.upload_typed({ body: formData });
Testing with curl
# Raw upload
curl -F "file=@test.txt" http://localhost:5001/upload
# Typed upload with multiple fields
curl -F "description=My file" -F "file=@test.txt" http://localhost:5001/upload/typed
Code Generation
Static Generation (compile-time file output)
#![allow(unused)]
fn main() {
use afast::{GenerateTarget, Lang, JsTsCallType, RsCallType, NetCallType};
let app = AFast::new()
.service(api_svc)
.generate(vec![
GenerateTarget {
lang: Lang::TS(vec![JsTsCallType::Fetch, JsTsCallType::Ws]),
path: "./code".into(),
debug: false,
},
GenerateTarget {
lang: Lang::RS(vec![RsCallType::TcpAsync]),
path: "./src/bin/client".into(),
debug: true,
},
GenerateTarget {
lang: Lang::CS(vec![NetCallType::Http, NetCallType::Ws, NetCallType::Tcp]),
path: "./client".into(),
debug: true,
},
]);
}
Dynamic Generation (HTTP endpoint)
GET /code/api/ts?call=fetch,ws
GET /code/api/js?call=fetch,ws
GET /code/pay/kt?call=http,ws,tcp
GET /code/api/rs?call=tcp-async
GET /code/api/cs?call=http,ws,tcp
Supported Transport Types
TS/JS
| Value | API |
|---|---|
fetch | Browser fetch |
ws | Browser WebSocket |
nodetcp | Node.js net |
buntcp | Bun Bun.connect |
unirequest | UniApp uni.request |
uniws | UniApp uni.connectSocket |
wxrequest | WeChat Mini Program wx.request |
wxws | WeChat Mini Program wx.connectSocket |
Kotlin
| Value | API |
|---|---|
http / fetch | java.net.HttpURLConnection |
ws | java.net.http.WebSocket |
tcp | java.net.Socket |
Rust
| Value | API |
|---|---|
tcp-async | tokio::net::TcpStream (async) |
tcp-sync | std::net::TcpStream (sync) |
C# / .NET
| Value | API |
|---|---|
http / fetch | System.Net.Http.HttpClient |
ws | System.Net.WebSockets.ClientWebSocket |
tcp | System.Net.Sockets.TcpClient |
Client Usage
TypeScript / JavaScript
import { ApiClient } from './api';
// Dedicated WS port
const wsClient = new ApiClient({
host: 'localhost',
port: 3001,
tls: false,
transport: 'ws',
debug: false,
});
await wsClient.apis._ready;
const result = await wsClient.apis.user.list_users({ page: 1, size: 20 });
// HTTP (fetch) mode
const httpClient = new ApiClient({
host: 'localhost',
port: 5001,
tls: false,
transport: 'fetch',
debug: false,
});
await httpClient.apis._ready;
const users = await httpClient.apis.user.list_users({ page: 1, size: 20 });
Custom Extractors
Pass customs to provide values for Custom<T> extractors:
const client = new ApiClient({
host: 'localhost',
port: 5001,
tls: false,
transport: 'fetch',
customs: {
AuthCustom: () => ({ token: 'my-token' }),
},
});
Header Extractors
Pass headers to provide values for Header<T> extractors:
const client = new ApiClient({
host: 'localhost',
port: 5001,
tls: false,
transport: 'fetch',
headers: {
AuthHeader: async () => ({ authorization: 'Bearer my-token' }),
},
});
Kotlin
// HTTP mode
val client = ApiClient(host = "localhost", port = 5001, tls = false)
val users = client.userListUsers(page = 1, size = 20)
// OkHttp mode (Android-compatible)
val client = ApiClient(host = "localhost", port = 5001, tls = false, callType = KtCallType.OkHttp)
// WebSocket mode
val wsClient = ApiClient(host = "localhost", port = 3001, tls = false, callType = KtCallType.Ws)
Rust
#![allow(unused)]
fn main() {
// Async TCP client
let mut client = AfastSocket::connect("localhost:4001").await?;
let users: ListUsersResp = client.call(1, &req).await?;
// Sync TCP client
let mut client = AfastSocketSync::connect("localhost:4001")?;
let users: ListUsersResp = client.call(1, &req)?;
}
C# / .NET
// HTTP mode
await using var client = new ApiClient("localhost", 5001, false, ApiClient.Transport.Http);
var users = await client.Apis.User.ListUsers(new UserListUsersRequest { Page = 1, Size = 20 });
// WebSocket mode
await using var wsClient = new ApiClient("localhost", 3001, false, ApiClient.Transport.Ws);
var result = await wsClient.Apis.User.ListUsers(new UserListUsersRequest { Page = 1, Size = 20 });
// TCP mode
await using var tcpClient = new ApiClient("localhost", 4001, false, ApiClient.Transport.Tcp);
var result = await tcpClient.Apis.User.ListUsers(new UserListUsersRequest { Page = 1, Size = 20 });
// Custom extractors
client.Customs["AuthCustom"] = async () => new AuthCustom { Token = "my-token" };
The client transport mode is fixed at construction time.
Note: Ordinary HTTP routes (e.g.,
#[get],#[post]) are only available withfetch/httptransport. WS/TCP transport only supports binary protocol handlers (#[handler]).
Client-Side Caching
The cache(seconds) attribute enables client-side caching:
#![allow(unused)]
fn main() {
#[handler(desc("List users"), cache(60))]
async fn list_users(...) -> Result<ListUsersResponse> { /* ... */ }
}
Generated client:
const users = await client.apis.admin.listUsers({ page: 1, size: 20 });
// Within 60 seconds, same params return cached data
const fresh = await client.apis.admin.listUsers({ page: 1, size: 20 }, true);
// force = true bypasses cache
About TextEncoder / TextDecoder
The generated client code uses TextEncoder and TextDecoder APIs. These are unavailable on React Native (older versions), WeChat Mini Programs, and older browsers.
Solutions:
- Polyfill:
npm install text-encoding+import 'text-encoding' - React Native 0.72+: Built-in support
- WeChat/UniApp: Use
wxrequest/wxws/unirequest/uniwstransport types
Binary Protocol
Type Mapping
| Rust Type | TS/JS Type | Kotlin Type |
|---|---|---|
i8 ~ i64, u8 ~ u64, f32, f64 | number | Int/Long/Float/Double |
bool | boolean | Boolean |
String, &str | string | String |
Vec<u8> | Uint8Array | ByteArray |
Option<T> | T | null | T? |
Vec<T> | T[] | List<T> |
| struct | { field: Type } | data class |
| enum | { tag: 'Variant', data: ... } | sealed class |
Error Codes
System reserved error codes range from -90011 to -90000. User-defined errors must not use this range.
| Constant | Value | Description |
|---|---|---|
CODE_SIGNAL | -90000 | OS signal (e.g., Ctrl+C) |
CODE_MSG_TOO_SHORT | -90001 | Message too short |
CODE_PAYLOAD_MISMATCH | -90002 | Payload length mismatch |
CODE_SERIALIZE | -90003 | Serialization/deserialization error |
CODE_STATE_NOT_FOUND | -90004 | State type not registered |
CODE_HANDLER | -90005 | Handler execution error |
CODE_INVALID_PARAM | -90006 | Invalid parameter |
CODE_IO | -90007 | I/O error |
CODE_WS | -90008 | WebSocket error |
CODE_HTTP | -90009 | HTTP error |
CODE_TCP | -90010 | TCP error |
CODE_LONG_CONNECTION_NOT_SUPPORTED | -90011 | Long connections unsupported in HTTP mode |
CODE_RATE_LIMITED | -90012 | Rate limit exceeded |
#![allow(unused)]
fn main() {
// Custom error (code must be outside the reserved range)
return Err(afast::Error::custom(400, "invalid request parameter"));
}
Custom Error Types
Implement the AFastError trait to define your own error types and return Result<T, MyError> from handlers:
#![allow(unused)]
fn main() {
use afast::AFastError;
enum MyError {
NotFound(String),
Unauthorized,
}
impl AFastError for MyError {
fn code(&self) -> i64 {
match self {
MyError::NotFound(_) => 404,
MyError::Unauthorized => 401,
}
}
fn message(&self) -> String {
match self {
MyError::NotFound(name) => format!("{} not found", name),
MyError::Unauthorized => "unauthorized".into(),
}
}
}
#[handler(desc("Get user"))]
async fn get_user(id: Data<UserId>) -> afast::Result<UserInfo, MyError> {
find_user(id).ok_or(MyError::NotFound("user".into()))
}
}
All handler macros (#[handler], #[get]/#[post]/#[put]/#[delete], #[ws], #[sse]) support custom error types. afast::Result<T> defaults to Error and is fully backward compatible.
Interactive Documentation
With the doc feature, visit http://host:port/doc for interactive API docs.
Setup
#![allow(unused)]
fn main() {
let app = AFast::new()
.service(svc)
.document(afast::DocConfig::with("My API", "./docs"))
.http("0.0.0.0:5001");
}
GET /docโ Index page listing all services (services starting with_are hidden)GET /doc/{service}โ Service docs with type definitions and online test panel- Dark/light theme toggle
- Static HTML files written to
./docsdirectory
Features
Binary Handler Testing
Each binary handler shows a form with:
- Input fields for
Custom,Data, andStateparameters - Send button that serializes to the binary protocol
- Response panel showing deserialized result
Ordinary HTTP Testing
REST endpoints (GET, POST, etc.) show:
- Path parameter inputs
- Query parameter inputs
- JSON body editor
- Response status and body display
WebSocket Debugging
WS routes (#[ws]) show a debugging panel with:
- Path parameter inputs (auto-detected from route pattern)
- Query parameter input
- Connect/Disconnect buttons
- Message input with Send button (Enter shortcut)
- Real-time log panel showing sent/received messages
The panel connects to the HTTP port (ordinary WS routes use HTTP upgrade).
SSE Debugging
SSE routes (#[sse]) show a debugging panel with:
- Path parameter inputs
- Query parameter input
- Connect/Disconnect buttons
- Real-time event log showing named events and data
Configuration Panel
The top-right settings panel lets you configure:
- Transport:
ws(binary),fetch(HTTP),tcp - Host: server hostname (default:
localhost) - Port: auto-detected from server config
- TLS: enable secure connections
Service Visibility
Services with names starting with _ (underscore) are hidden from the doc index page but still accessible via direct URL (/doc/_service). Useful for internal-only endpoints.
Project Structure
afast/ โ Main framework crate (core types, State, transports, code generation)
afast-macros/ โ Proc macros (#[handler], register!, #[derive(Tag)])
example/ โ Example project (full usage including HTTP, WS, TCP, docs)
Dependencies
afastโafast-macros,afastdata,tokioafast-macrosโsyn,quote,proc-macro2- User crates indirectly depend on
afastdata-core(referenced by#[derive(Tag)]expanded code)
Testing
Unit Tests
cargo test --lib
Integration Tests
The example project includes test clients for all supported languages:
1. Start the Example Server
cargo run -p example --bin example
This starts:
- HTTP server on port 5001
- WebSocket server on port 3001
- TCP server on port 4001
- API docs at http://localhost:5001/doc
2. Run Test Clients
Rust (TCP transport):
cargo run -p example --bin test_client
JavaScript (Node.js โ fetch + ws + nodetcp):
cd client && node test_js.mjs
TypeScript (Bun โ fetch + ws + buntcp):
cd client && bun run test_ts.ts
Kotlin (Gradle โ HTTP + WS + TCP):
cd client/kt-test && gradle run
Feature Combinations
Test that the framework compiles cleanly with various feature combinations:
cargo check
cargo check --features hook
cargo check --features rate-limit
cargo check --features ordinary-http
cargo check --features ordinary-ws
cargo check --features ordinary-sse
cargo check --features "ordinary-http,ordinary-ws,ordinary-sse,hook,rate-limit"
cargo check --all-features
Clippy
cargo clippy --all-features
License
MIT
AFast Development Guide for AI
This document is specifically designed for AI assistants to understand how to develop with the AFast framework effectively.
Core Rules
- Always use
#[handler]macro โ Never manually register routes - Use
Result<T>for error handling โ All handlers must returnResult<T> - Use
State<T>for shared state โ Zero-copy, no clone needed - Use
Data<T>for request body โ Auto deserialization from binary payload - Use
Custom<T>for auth in binary protocol โ Client-provided authentication - Use
Header<T>for auth in HTTP routes โ HTTP request header extraction - Use
Ctx<T>for request context โ Set by hooks, read by handlers - All types must derive
Tagโ Request/response types need#[derive(Tag)]and#[tag("desc")] - Parameters must use destructuring syntax โ
afast::X(name): afast::X<Type>
Handler Signature Pattern
Important: Parameters MUST use destructuring syntax afast::X(name): afast::X<Type>
Binary Protocol Handler (Basic)
#![allow(unused)]
fn main() {
use afast::{AFastDeserialize, AFastSerialize, Tag, handler};
use crate::state::AppState;
#[derive(AFastDeserialize, Tag)]
#[tag("Request body description")]
pub struct MyRequest {
#[tag("Field description")]
pub name: String,
}
#[derive(AFastSerialize, Tag)]
#[tag("Response body description")]
pub struct MyResponse {
#[tag("Field description")]
pub message: String,
}
#[handler(desc("Describe what this handler does"))]
pub async fn my_handler(
afast::State(state): afast::State<AppState>,
afast::Data(req): afast::Data<MyRequest>,
) -> afast::Result<MyResponse> {
let db = state.db.lock().await;
Ok(MyResponse { message: format!("Hello, {}!", req.name) })
}
}
Binary Handler with Authentication (Custom)
#![allow(unused)]
fn main() {
use afast::{AFastDeserialize, AFastSerialize, Tag, handler};
#[derive(AFastDeserialize, Tag)]
#[tag("Authentication token")]
pub struct AuthCustom {
#[tag("Bearer token")]
pub token: String,
}
#[handler(desc("Protected endpoint"))]
pub async fn protected(
afast::State(state): afast::State<AppState>,
afast::Custom(auth): afast::Custom<AuthCustom>,
afast::Data(req): afast::Data<MyRequest>,
) -> afast::Result<MyResponse> {
// auth.token is available
Ok(MyResponse { message: "Authorized".into() })
}
}
Binary Handler with Request Context (Ctx)
#![allow(unused)]
fn main() {
use afast::{Tag, handler};
#[derive(Clone, Debug)]
pub struct RequestInfo {
pub request_id: String,
}
#[handler(desc("Endpoint with context"))]
pub async fn with_context(
afast::Ctx(ctx): afast::Ctx<RequestInfo>,
afast::State(state): afast::State<AppState>,
) -> afast::Result<MyResponse> {
println!("Request ID: {}", ctx.request_id);
Ok(MyResponse { message: "Done".into() })
}
}
HTTP REST Handler (Ordinary HTTP)
HTTP handlers use Header<T> for auth and return HttpResult<Json<T>>:
#![allow(unused)]
fn main() {
use afast::{get, post, put, delete, Tag};
use serde::{Deserialize, Serialize};
// HTTP auth uses Header, not Custom
#[derive(Debug, Deserialize, Tag)]
#[tag("HTTP auth header")]
pub struct AuthHeader {
#[tag("Authorization header")]
pub authorization: String,
}
impl AuthHeader {
pub fn token(&self) -> &str {
self.authorization.strip_prefix("Bearer ").unwrap_or(&self.authorization)
}
}
#[derive(Debug, Deserialize, Tag)]
#[tag("Query parameters")]
pub struct ListUsersQuery {
#[tag("Page number")]
pub page: Option<i64>,
#[tag("Items per page")]
pub size: Option<i64>,
}
#[derive(Debug, Serialize, Tag)]
#[tag("User HTTP response")]
pub struct UserHttp {
#[tag("User ID")]
pub id: i64,
#[tag("Username")]
pub username: String,
}
#[derive(Debug, Serialize, Tag)]
#[tag("User list response")]
pub struct ListUsersHttpResponse {
#[tag("Total count")]
pub total: i64,
#[tag("User list")]
pub items: Vec<UserHttp>,
}
// Note: #[get] contains desc, NOT a path! Path is defined in service! macro
#[get(desc("List users via HTTP"))]
pub async fn list_users_http(
afast::State(state): afast::State<AppState>,
afast::Header(auth): afast::Header<AuthHeader>,
afast::Query(query): afast::Query<ListUsersQuery>,
) -> afast::HttpResult<afast::Json<ListUsersHttpResponse>> {
let db = state.db.lock().await;
let _user_id = db.get_user_id_by_token(auth.token()).await
.ok_or_else(|| afast::Error::custom(401, "invalid token"))?;
let items = db.read(0, 100).await;
Ok(afast::Json(ListUsersHttpResponse { total: items.len() as i64, items: vec![] }))
}
}
Service Registration
Paths are defined in the service! macro, NOT in handler attributes:
use afast::{AFast, service};
// Binary handlers are wrapped in h()
// HTTP handler paths are defined in service!
let admin_svc = service!("admin", "Admin Service" => {
group("user" => {
// Binary protocol handlers
h(create_user),
h(list_users),
// HTTP REST handlers โ paths defined here
get("", list_users_http),
post("", create_user_http),
group(":user_id" => {
get("", get_user_http),
put("", update_user_http),
delete("", delete_user_http),
})
})
});
// Catch-all route
let check_svc = service!("check", "Check Service" => {
h(health),
get("*", catch_all_get),
});
#[tokio::main]
async fn main() {
AFast::new()
.state(AppState::new())
.service(admin_svc)
.service(check_svc)
.http("0.0.0.0:5000")
.run()
.await
.unwrap();
}
AppState Pattern
#![allow(unused)]
fn main() {
use std::sync::Arc;
use tokio::sync::Mutex;
#[derive(Clone)]
pub struct AppState {
pub db: Arc<Mutex<Database>>,
}
impl AppState {
pub fn new() -> Self {
Self { db: Arc::new(Mutex::new(Database::new())) }
}
}
}
Type Derivation Rules
Binary protocol types โ for Data<T> and return values in #[handler]:
#![allow(unused)]
fn main() {
#[derive(AFastDeserialize, Tag)]
#[tag("Request description")]
pub struct MyRequest {
#[tag("Field description")]
pub field: String,
}
#[derive(AFastSerialize, Tag)]
#[tag("Response description")]
pub struct MyResponse {
#[tag("Field description")]
pub field: String,
}
}
HTTP types โ for Body<T>, Query<T> and return values in #[get]/#[post] (requires serde):
#![allow(unused)]
fn main() {
use serde::{Deserialize, Serialize};
#[derive(Debug, Deserialize, Tag)]
#[tag("HTTP request body")]
pub struct MyBody {
#[tag("Field description")]
pub field: String,
}
#[derive(Debug, Serialize, Tag)]
#[tag("HTTP response")]
pub struct MyHttpResponse {
#[tag("Field description")]
pub field: String,
}
}
Enum types โ generates tagged union in TypeScript:
#![allow(unused)]
fn main() {
#[derive(AFastSerialize, AFastDeserialize, Tag)]
#[tag("User role")]
pub enum Role {
#[tag("Administrator")]
Admin,
#[tag("Regular user")]
User,
#[tag("Guest")]
Guest,
}
// Generated TS: type Role = { tag: 'Admin', data: null } | { tag: 'User', data: null } | ...
}
Error Handling
Use afast::Error::custom(code, message) to return custom errors:
#![allow(unused)]
fn main() {
#[handler(desc("Handler with error handling"))]
pub async fn with_error(
afast::State(state): afast::State<AppState>,
) -> afast::Result<MyResponse> {
if some_condition {
return Err(afast::Error::custom(400, "Bad request"));
}
let data = state.db.lock().await.query().await
.map_err(|e| afast::Error::custom(500, e.to_string()))?;
Ok(MyResponse { message: "Success".into() })
}
}
HTTP Methods (Ordinary Routes)
Key differences between HTTP and binary handlers:
- Auth uses
Header<T>instead ofCustom<T> - Returns
afast::HttpResult<afast::Json<T>>instead ofafast::Result<T> - Types need
serde::Deserialize/serde::Serialize+Tag - Paths are defined in
service!macro, NOT in#[get]attributes
#![allow(unused)]
fn main() {
use afast::{get, post, put, delete, Tag};
use serde::{Deserialize, Serialize};
#[derive(Debug, Deserialize, Tag)]
#[tag("Query parameters")]
pub struct ListUsersQuery {
#[tag("Page number")]
pub page: Option<i64>,
#[tag("Items per page")]
pub size: Option<i64>,
}
#[derive(Debug, Deserialize, Tag)]
#[tag("Create user request")]
pub struct CreateUserBody {
#[tag("Username")]
pub username: String,
#[tag("Password")]
pub password: String,
#[tag("Display name")]
pub name: String,
}
#[derive(Debug, Deserialize, Tag)]
#[tag("User ID path parameter")]
pub struct UserIdParam {
#[tag("User ID")]
pub user_id: i64,
}
// Paths are defined in service! macro, only desc here
#[get(desc("List users"))]
pub async fn list_users_http(
afast::State(state): afast::State<AppState>,
afast::Header(auth): afast::Header<AuthHeader>,
afast::Query(query): afast::Query<ListUsersQuery>,
) -> afast::HttpResult<afast::Json<ListUsersHttpResponse>> {
// GET /user?page=1&size=10
Ok(afast::Json(ListUsersHttpResponse { total: 0, items: vec![] }))
}
#[post(desc("Create user"))]
pub async fn create_user_http(
afast::State(state): afast::State<AppState>,
afast::Header(auth): afast::Header<AuthHeader>,
afast::Body(body): afast::Body<CreateUserBody>,
) -> afast::HttpResult<afast::Json<CreateUserHttpResponse>> {
// POST /user with JSON body
Ok(afast::Json(CreateUserHttpResponse { id: 1 }))
}
// Registration in service!:
// service!("admin", "Admin Service" => {
// group("user" => {
// get("", list_users_http), // GET /user
// post("", create_user_http), // POST /user
// group(":user_id" => {
// get("", get_user_http), // GET /user/:user_id
// put("", update_user_http), // PUT /user/:user_id
// delete("", delete_user_http), // DELETE /user/:user_id
// })
// })
// })
}
WebSocket Handlers
Path is defined in service! macro:
#![allow(unused)]
fn main() {
use afast::ws;
use afast::extractors::{WsSender, WsReceiver};
#[ws(desc("Chat WebSocket"))]
pub async fn chat_ws(
afast::State(state): afast::State<AppState>,
sender: WsSender,
receiver: WsReceiver,
) {
while let Some(msg) = receiver.recv().await {
sender.send(msg).await;
}
}
// Registration in service!:
// let chat_svc = service!("chat", "Chat Service" => {
// ws("/chat/:room", chat_ws),
// });
}
SSE (Server-Sent Events)
Path is defined in service! macro:
#![allow(unused)]
fn main() {
use afast::sse;
use afast::extractors::SseSender;
#[sse(desc("Event stream"))]
pub async fn sse_stream(sender: SseSender) {
for i in 0..10 {
sender.send(format!("Event {}", i)).await;
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
}
}
// Registration in service!:
// let chat_svc = service!("chat", "Chat Service" => {
// sse("/sse", sse_stream),
// });
}
Lifecycle Hooks
#![allow(unused)]
fn main() {
use afast::hook::{Hook, RequestContext, RequestGuard, ConnectionGuard};
struct MyHook;
impl Hook for MyHook {
fn before_request(&self, ctx: &RequestContext) -> Option<Box<dyn RequestGuard>> {
eprintln!("โ {} ({})", ctx.handler_name, ctx.transport);
Some(Box::new(MyTimer(std::time::Instant::now())))
}
fn on_connect(&self, ctx: &RequestContext) -> Option<Box<dyn ConnectionGuard>> {
eprintln!("โ connect: {}", ctx.handler_name);
Some(Box::new(MyConnGuard))
}
}
struct MyTimer(std::time::Instant);
struct MyConnGuard;
impl RequestGuard for MyTimer {
fn on_response(&mut self, ctx: &RequestContext, _resp: &[u8]) {
eprintln!("โ {} OK ({:?})", ctx.handler_name, self.0.elapsed());
}
fn on_error(&mut self, ctx: &RequestContext, err: &afast::Error) {
eprintln!("โ {} error: {}", ctx.handler_name, err);
}
}
impl ConnectionGuard for MyConnGuard {
fn on_disconnect(&mut self, ctx: &RequestContext) {
eprintln!("โ disconnect: {}", ctx.handler_name);
}
}
// Register in main:
// AFast::new().hook(MyHook)
}
Rate Limiting
Use rate_limit("policy_name") in #[handler] attribute, and configure the policy in main:
#![allow(unused)]
fn main() {
use afast::RateLimitConfig;
#[handler(desc("Rate limited endpoint"), rate_limit("api_limit"))]
pub async fn limited_endpoint(
afast::State(state): afast::State<AppState>,
) -> afast::Result<MyResponse> {
Ok(MyResponse { message: "Success".into() })
}
// Configure rate limiting in main:
// AFast::new()
// .rate_limit(RateLimitConfig::new()
// .policy("api_limit", afast::RateLimitPolicy::fixed_window(100, 60)))
}
Client Code Generation
After starting the server, generate client code per service:
# TypeScript
curl http://localhost:5000/code/auth/ts
# JavaScript
curl http://localhost:5000/code/auth/js
# Kotlin
curl http://localhost:5000/code/auth/kt
# Rust
curl http://localhost:5000/code/auth/rs
Where auth is the service name. Each service generates a separate client file.
Client Usage Examples
TypeScript / JavaScript Client
The generated TS client creates a Client class per service, exposing all handler methods via the apis property.
import { AuthClient } from './auth';
import { AdminClient } from './admin';
// โโ Binary protocol client (fetch mode, most common) โโ
const auth = new AuthClient({
host: 'localhost',
port: 5001,
tls: false,
transport: 'fetch', // 'fetch' | 'ws' | 'nodetcp' | 'buntcp'
debug: true, // prints request/response logs
customs: {
// Custom<T> extractor requires a Promise-returning factory
AuthCustom: async () => ({ token: myToken })
}
});
await auth.apis._ready; // wait for connection (fetch is instant)
// Call binary handler โ fully typed params and return values
const reg = await auth.apis.signup({
username: 'alice',
password: 'secret',
name: 'Alice'
});
console.log(reg.user.username); // 'alice'
console.log(reg.token);
const login = await auth.apis.login({
username: 'alice',
password: 'secret'
});
// Handler with no params
const uid = await auth.apis.get_user_id();
// โโ Service with both binary and HTTP routes โโ
const admin = new AdminClient({
host: 'localhost',
port: 5001,
tls: false,
transport: 'fetch',
customs: {
AuthCustom: async () => ({ token: myToken })
},
headers: {
// Header<T> for HTTP route auth
AuthHeader: async () => ({ authorization: `Bearer ${myToken}` })
}
});
await admin.apis._ready;
// Binary handler โ accessed via nested group
const users = await admin.apis.user.list_users({ page: 1, size: 10 });
console.log(users.total, users.items);
const newUser = await admin.apis.user.create_user({
username: 'bob', password: 'pw', name: 'Bob'
});
// HTTP REST handler โ accessed via nested group
const httpUsers = await admin.apis.user.list_users_http({
queries: { page: 1, size: 10 } // Query params go in queries
});
const created = await admin.apis.user.create_user_http({
body: { username: 'charlie', password: 'pw', name: 'Charlie' } // Body goes in body
});
// Path params are extracted from the param object
const updated = await admin.apis.user.user_id.update_user_http({
user_id: 123, // path param :user_id
body: { name: 'Updated', age: 25, active: true }
});
// โโ WebSocket mode (supports long connections and push) โโ
const wsAuth = new AuthClient({
host: 'localhost',
port: 3001,
tls: false,
transport: 'ws',
customs: { AuthCustom: async () => ({ token: myToken }) }
});
await wsAuth.apis._ready;
// Same API, binary frames over WebSocket
const result = await wsAuth.apis.login({ username: 'alice', password: 'secret' });
Kotlin Client
import afast.generated.*
fun main() = runBlocking {
val auth = AuthClient(
host = "localhost",
port = 5001,
tls = false,
transport = "http", // "http" | "ws" | "tcp"
customFns = AuthCustomFns(
AuthCustom = { AuthCustom(token = myToken) }
)
)
// suspend functions, call directly in coroutine
val reg = auth.apis.signup(RegisterRequest(
username = "alice",
password = "secret",
name = "Alice"
))
println(reg.user.username) // "alice"
println(reg.token)
val login = auth.apis.login(LoginRequest(
username = "alice",
password = "secret"
))
val uid = auth.apis.get_user_id()
// Service with HTTP routes
val admin = AdminClient(
host = "localhost",
port = 5001,
tls = false,
transport = "http",
customFns = AdminCustomFns(
AuthCustom = { AuthCustom(token = myToken) }
),
headerFns = AdminHeaderFns(
AuthHeader = { AuthHeader(authorization = "Bearer $myToken") }
)
)
// Binary handler
val users = admin.apis.user.list_users(ListUsersRequest(page = 1, size = 10))
// HTTP REST handler
val httpUsers = admin.apis.user.list_users_http(
queries = UserListUsersHttpQuery(page = 1, size = 10)
)
}
Client Type Generation Rules
| Rust Type | TypeScript Type | Notes |
|---|---|---|
String | string | |
i32/i64/u32/u64/f32/f64 | number | |
bool | boolean | |
Vec<T> | T[] | |
Option<T> | T | null | |
HashMap<K,V> | Record<K,V> | |
Vec<u8> | Uint8Array | |
enum { A, B } | { tag: 'A', data: null } | { tag: 'B', data: null } | Tagged union |
#[handler(name("signup"))] | apis.signup(...) | Client method name |
group("user" => { ... }) | apis.user.xxx(...) | Nested group |
Supported Client Transports
| Transport | Description | Use Case |
|---|---|---|
fetch | HTTP/1.1 or HTTP/2 | Browser, Node.js (most common) |
ws | WebSocket binary frames | Browser, Node.js long connections |
nodetcp | Node.js TCP | Node.js high-performance |
buntcp | Bun TCP | Bun runtime |
unirequest | uni-app HTTP | Mini programs / mobile apps |
uniws | uni-app WebSocket | Mini programs long connections |
wxrequest | WeChat Mini Program HTTP | WeChat mini programs |
wxws | WeChat Mini Program WebSocket | WeChat mini program long connections |
Common Mistakes to Avoid
- Donโt forget
Tagderive โ All types used in handlers must deriveTagwith#[tag("description")]on fields - Donโt use
Stringfor request body โ Always useData<T>with proper struct - Donโt forget
Resultreturn type โ Binary handlers returnafast::Result<T>, HTTP handlers returnafast::HttpResult<afast::Json<T>> - Donโt manually register routes โ Use
#[handler]macro - Donโt clone State โ
State<T>holds&'static T, just use it directly - Parameters must use destructuring โ Write
afast::State(state): afast::State<AppState>notstate: State<AppState> - HTTP auth uses Header โ HTTP handlers use
Header<T>for auth, notCustom<T> - HTTP types need serde โ HTTP handler request/response types need
Deserialize/Serialize+Tag #[get]takes desc not path โ Paths are defined inservice!macro- Path params use
:paramin service! โ e.g.group(":user_id" => { get("", handler) })
Project Structure Template
my-project/
โโโ Cargo.toml
โโโ src/
โโโ main.rs # Entry point, service! definitions and AFast config
โโโ state.rs # AppState + Database definitions
โโโ handler/
โโโ mod.rs
โโโ auth.rs # Authentication handlers
โโโ admin.rs # Admin handlers (including HTTP routes)
โโโ chat.rs # WebSocket/SSE handlers
Quick Reference
| Macro | Purpose | Example |
|---|---|---|
#[handler] | Binary protocol handler | #[handler(desc("..."), name("..."), cache(60), rate_limit("..."))] |
#[get] | HTTP GET | #[get(desc("..."))] โ path in service! |
#[post] | HTTP POST | #[post(desc("..."))] |
#[put] | HTTP PUT | #[put(desc("..."))] |
#[delete] | HTTP DELETE | #[delete(desc("..."))] |
#[ws] | WebSocket | #[ws(desc("..."))] |
#[sse] | SSE | #[sse(desc("..."))] |
service! | Create service with routes | service!("name", "desc" => { h(fn), get("path", fn) }) |
h() | Register binary handler | h(my_handler) |
group() | Route grouping | group("user" => { get("", fn), group(":id" => { get("", fn) }) }) |
| Extractor | Purpose | Destructuring Syntax |
|---|---|---|
State<T> | Shared app state | afast::State(state): afast::State<AppState> |
Data<T> | Binary request body | afast::Data(req): afast::Data<MyRequest> |
Custom<T> | Binary auth context | afast::Custom(auth): afast::Custom<AuthCustom> |
Ctx<T> | Request context (hook-set) | afast::Ctx(ctx): afast::Ctx<RequestInfo> |
Query<T> | HTTP query parameters | afast::Query(q): afast::Query<MyQuery> |
Param<T> | HTTP path parameters | afast::Param(p): afast::Param<MyParam> |
Body<T> | HTTP request body | afast::Body(b): afast::Body<MyBody> |
Header<T> | HTTP request headers | afast::Header(h): afast::Header<AuthHeader> |