Keyboard shortcuts

Press โ† or โ†’ to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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, no T: Clone required
  • 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

FeatureDescriptionDependencies
binaryBinary protocol (POST /_api, WS framing, TCP framing)โ€”
httpHTTP serverhyper, hyper-util, http-body-util
wsWebSocket servertokio-tungstenite, futures-util, binary
tcpTCP server (length-prefix framing)binary

Code Generation

FeatureDescriptionDependencies
tsTypeScript client generation (ESM + full types)โ€”
jsJavaScript client generation (ESM + JSDoc)โ€”
ktKotlin client generationโ€”
rsRust client generation (Tokio async / std sync TCP)โ€”
csC# / .NET client generation (HttpClient / WebSocket / TCP)โ€”
codeOn-demand code generation at /code/{service}/{lang}http

Documentation

FeatureDescriptionDependencies
docInteractive API docs at /doc endpointhttp, js

Ordinary Routes

FeatureDescriptionDependencies
ordinary-httpRESTful JSON endpoints (#[get]/#[post]/etc.)http, serde, serde_json
ordinary-wsPath-based WebSocket endpoints (#[ws])ws, ordinary-http
ordinary-sseServer-Sent Events endpoints (#[sse])ordinary-http, futures-util

Protocol Options

FeatureDescription
seq64WS request ID uses i64 (default i32)
len64WS payload length uses u64 (default u32)
tag-u8Enum tag uses u8 (default)
tag-u16Enum tag uses u16
tag-u32Enum tag uses u32

TLS

FeatureDescriptionDependencies
tlsHTTPS via rustls with ALPN for HTTP/2, hot-reload via channelhttp, tokio-rustls, rustls, rustls-pemfile

Optional Capabilities

FeatureDescription
markerMarker-based conditional serialization via AFast::marker()
hookLifecycle hooks (before_request/on_connect/etc.), global and per-service
rate-limitNamed-policy rate limiting (FixedWindow/SlidingWindow/TokenBucket)
tlsTLS/HTTPS support via rustls with ALPN

Note: If the server uses seq64 or len64, 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:

  1. The original function unchanged
  2. HandlerMeta โ€” name, description, parameter list, return type metadata
  3. HandlerInvoker trait impl โ€” type-erased invoker, deserializes params, calls the function
  4. 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 comments
  • name("...") โ€” Overrides the client-side method name (defaults to the Rust function name)
  • cache(seconds) โ€” Enables client-side caching
  • rate_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

ExtractorDescriptionProtocols
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 payloadHTTP/WS/TCP
Custom<T>Deserializes client-side custom context (e.g., auth token)HTTP/WS/TCP
ReceiverReceives binary messages from the client (long connection)WS/TCP
SenderSends 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
FullPathExtracts 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):

  1. Exact routes (e.g., /users/list)
  2. Parameterized routes (e.g., /users/:id)
  3. 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

RuleExampleDescription
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 a Default impl 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

KeyDescriptionHTTPWebSocketTCP
IpClient IP (supports X-Forwarded-For)โœ…โœ…โœ…
Header("name")HTTP header value (e.g. API Key)โœ…โœ… (cached at handshake)โญ skipped
ConnectionPer-connection (WS/TCP message rate)โญ skippedโœ…โœ…
GlobalShared 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 transports
  • forwarded_for: Only available for HTTP/WS; None for 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

FieldTypeDescription
handler_name&'static strHandler function name
handler_desc&'static strDescription from #[handler(desc(...))]
transport&'static str"http-binary", "http", "ws-binary", "ws", "tcp", or "sse"
is_binaryboolWhether this is a binary protocol handler
method&'static strHTTP method ("GET", "POST", etc.), empty for non-HTTP
long_connectionboolWhether this is a long-connection handler (Receiver/Sender)
handler_idusizeHandler offset in binary dispatch table (0 for ordinary routes)
stateArc<StateMap>Shared application state
ctxRequestCtxPer-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:

ExtractorHTTPWSSSETCP
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>
ScopeApplication-globalPer-request / per-connection
StorageStateMap (set once at startup)RequestCtx (created per request)
LifetimeEntire applicationRequest start โ†’ fully finished
Use caseDatabase pool, configRequest ID, auth info, timing
Set byAFast::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 TypeMacroExample
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/Senderasync 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:

FieldTypeDescription
client_ipStringTCP peer address (peer_addr)
forwarded_forOption<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_ip is available for all transports (HTTP, WS, TCP, SSE)
  • forwarded_for is only available for HTTP and WebSocket transports; always None for 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 single Arc<RwLock<HashMap>> โ€” very cheap.
  • insert() and get() acquire a RwLock โ€” uncontended in the typical case (sequential write then read), so overhead is negligible.
  • RequestCtx::clone() is an Arc clone โ€” O(1).
  • For large context values, wrap in Arc<T> to make get() 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:

MethodPathDescription
POST/_apiBinary handler dispatch
GET/_wsWebSocket upgrade (merged mode)
GET/code/{service}/{lang}On-demand code gen (requires code)
GET/docAPI docs index (requires doc)
GET/doc/{service}Service-specific docs (requires doc)
*Ordinary routesRESTful endpoints (requires ordinary-http)
GET/path/:paramWebSocket upgrade for ordinary-ws routes (requires ordinary-ws)
GET/pathSSE 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

TypeHTTP StatusContent-Type
Json<T>200application/json
Text200text/plain
Html200text/html
File200Custom + Content-Disposition: attachment
Status(code)Customโ€”
Redirect::temporary(url) / Redirect::permanent(url)302 / 301Location 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 OPTIONS preflight requests with 204 No Content
  • Injects Access-Control-Allow-Origin into every HTTP response
  • Sets Access-Control-Allow-Methods, Access-Control-Allow-Headers, and Access-Control-Max-Age for preflight

Security Headers

Every HTTP response includes these security headers by default:

HeaderValue
x-content-type-optionsnosniff
x-frame-optionsDENY
content-security-policydefault-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-8
  • Cache-Control: no-cache
  • Connection: keep-alive
  • Transfer-Encoding: chunked

Wire format:

event: connected
data: {"room":"general"}

event: tick
data: {"count":1}

SseSender Methods

MethodDescription
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

FieldTypeWire Format
eventOption<&str>event: name\n
dataStringdata: ...\n
idOption<&str>id: ...\n
retryOption<u64>retry: ...\n

Client Codegen

  • TS/JS: Generates EventSource-based methods
  • KT (OkHttp): Uses okhttp3.sse.EventSource
  • KT (non-OkHttp): Uses java.net.http.HttpClient with BodyHandlers.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 TypeForm FieldNotes
StringText fieldDirect text value
i8/i16/i32/i64/u8/u16/u32/u64/f32/f64Text fieldParsed from text
boolText field"true"/"false"/"1"/"0"
FileFieldFile fieldCollects bytes, filename, content type
Option<T>Optional fieldDefaults 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

ValueAPI
fetchBrowser fetch
wsBrowser WebSocket
nodetcpNode.js net
buntcpBun Bun.connect
unirequestUniApp uni.request
uniwsUniApp uni.connectSocket
wxrequestWeChat Mini Program wx.request
wxwsWeChat Mini Program wx.connectSocket

Kotlin

ValueAPI
http / fetchjava.net.HttpURLConnection
wsjava.net.http.WebSocket
tcpjava.net.Socket

Rust

ValueAPI
tcp-asynctokio::net::TcpStream (async)
tcp-syncstd::net::TcpStream (sync)

C# / .NET

ValueAPI
http / fetchSystem.Net.Http.HttpClient
wsSystem.Net.WebSockets.ClientWebSocket
tcpSystem.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 with fetch/http transport. 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:

  1. Polyfill: npm install text-encoding + import 'text-encoding'
  2. React Native 0.72+: Built-in support
  3. WeChat/UniApp: Use wxrequest/wxws/unirequest/uniws transport types

Binary Protocol

Type Mapping

Rust TypeTS/JS TypeKotlin Type
i8 ~ i64, u8 ~ u64, f32, f64numberInt/Long/Float/Double
boolbooleanBoolean
String, &strstringString
Vec<u8>Uint8ArrayByteArray
Option<T>T | nullT?
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.

ConstantValueDescription
CODE_SIGNAL-90000OS signal (e.g., Ctrl+C)
CODE_MSG_TOO_SHORT-90001Message too short
CODE_PAYLOAD_MISMATCH-90002Payload length mismatch
CODE_SERIALIZE-90003Serialization/deserialization error
CODE_STATE_NOT_FOUND-90004State type not registered
CODE_HANDLER-90005Handler execution error
CODE_INVALID_PARAM-90006Invalid parameter
CODE_IO-90007I/O error
CODE_WS-90008WebSocket error
CODE_HTTP-90009HTTP error
CODE_TCP-90010TCP error
CODE_LONG_CONNECTION_NOT_SUPPORTED-90011Long connections unsupported in HTTP mode
CODE_RATE_LIMITED-90012Rate 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 ./docs directory

Features

Binary Handler Testing

Each binary handler shows a form with:

  • Input fields for Custom, Data, and State parameters
  • 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, tokio
  • afast-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

  1. Always use #[handler] macro โ€” Never manually register routes
  2. Use Result<T> for error handling โ€” All handlers must return Result<T>
  3. Use State<T> for shared state โ€” Zero-copy, no clone needed
  4. Use Data<T> for request body โ€” Auto deserialization from binary payload
  5. Use Custom<T> for auth in binary protocol โ€” Client-provided authentication
  6. Use Header<T> for auth in HTTP routes โ€” HTTP request header extraction
  7. Use Ctx<T> for request context โ€” Set by hooks, read by handlers
  8. All types must derive Tag โ€” Request/response types need #[derive(Tag)] and #[tag("desc")]
  9. 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 of Custom<T>
  • Returns afast::HttpResult<afast::Json<T>> instead of afast::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 TypeTypeScript TypeNotes
Stringstring
i32/i64/u32/u64/f32/f64number
boolboolean
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

TransportDescriptionUse Case
fetchHTTP/1.1 or HTTP/2Browser, Node.js (most common)
wsWebSocket binary framesBrowser, Node.js long connections
nodetcpNode.js TCPNode.js high-performance
buntcpBun TCPBun runtime
unirequestuni-app HTTPMini programs / mobile apps
uniwsuni-app WebSocketMini programs long connections
wxrequestWeChat Mini Program HTTPWeChat mini programs
wxwsWeChat Mini Program WebSocketWeChat mini program long connections

Common Mistakes to Avoid

  1. Donโ€™t forget Tag derive โ€” All types used in handlers must derive Tag with #[tag("description")] on fields
  2. Donโ€™t use String for request body โ€” Always use Data<T> with proper struct
  3. Donโ€™t forget Result return type โ€” Binary handlers return afast::Result<T>, HTTP handlers return afast::HttpResult<afast::Json<T>>
  4. Donโ€™t manually register routes โ€” Use #[handler] macro
  5. Donโ€™t clone State โ€” State<T> holds &'static T, just use it directly
  6. Parameters must use destructuring โ€” Write afast::State(state): afast::State<AppState> not state: State<AppState>
  7. HTTP auth uses Header โ€” HTTP handlers use Header<T> for auth, not Custom<T>
  8. HTTP types need serde โ€” HTTP handler request/response types need Deserialize/Serialize + Tag
  9. #[get] takes desc not path โ€” Paths are defined in service! macro
  10. Path params use :param in 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

MacroPurposeExample
#[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 routesservice!("name", "desc" => { h(fn), get("path", fn) })
h()Register binary handlerh(my_handler)
group()Route groupinggroup("user" => { get("", fn), group(":id" => { get("", fn) }) })
ExtractorPurposeDestructuring Syntax
State<T>Shared app stateafast::State(state): afast::State<AppState>
Data<T>Binary request bodyafast::Data(req): afast::Data<MyRequest>
Custom<T>Binary auth contextafast::Custom(auth): afast::Custom<AuthCustom>
Ctx<T>Request context (hook-set)afast::Ctx(ctx): afast::Ctx<RequestInfo>
Query<T>HTTP query parametersafast::Query(q): afast::Query<MyQuery>
Param<T>HTTP path parametersafast::Param(p): afast::Param<MyParam>
Body<T>HTTP request bodyafast::Body(b): afast::Body<MyBody>
Header<T>HTTP request headersafast::Header(h): afast::Header<AuthHeader>
Copied to clipboard!