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. It eliminates manual route definitions โ€” annotate functions with #[handler] and the framework auto-registers and dispatches requests. Data transport uses a compact binary protocol that is smaller and faster than JSON. It supports one-click generation of TypeScript, JavaScript, Kotlin, and Rust client code, with built-in interactive API documentation.

Highlights

  • Zero Route Definitions โ€” #[handler] annotation, no manual routing table
  • Compact Binary Protocol โ€” Smaller and faster than JSON, designed for internal communication
  • Auto Code Generation โ€” TypeScript / JavaScript / Kotlin / Rust clients with full type definitions
  • Interactive API Docs โ€” Built-in Web docs with dark/light theme and online API testing
  • Multiple Transports โ€” WebSocket, HTTP/1.1, HTTP/2, and TCP, mix and match as needed
  • TLS / HTTPS โ€” Based on rustls with ALPN for HTTP/2 negotiation
  • Lifecycle Hooks โ€” before_request/on_response/on_error/on_connect/on_disconnect
  • Rate Limiting โ€” Named-policy rate limiting with pluggable storage backend

Quick Example

use afast::{AFast, handler, service, State, Data, Result};
use afast::{AFastDeserialize, AFastSerialize, Tag};

#[derive(Clone)]
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.9", features = ["http", "ws", "ts"] }
tokio = { version = "1", features = ["full"] }

Define a Handler

use afast::{AFast, handler, service, State, Data, Custom, Result};
use afast::{AFastDeserialize, AFastSerialize, Tag};

#[derive(Clone)]
struct AppState { db_url: String }

#[derive(Clone)]
struct CacheState { redis_url: String }

#[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>,
    cache: State<CacheState>,
    auth: Custom<AuthCustom>,
    req: Data<HelloReq>,
) -> Result<HelloResp> {
    println!("DB: {}, Cache: {}", state.db_url, cache.redis_url);
    Ok(HelloResp { message: format!("Hello, {}!", req.name) })
}

#[tokio::main]
async fn main() {
    let svc = service!("api", "Example API" => {
        h(hello),
    });

    let app = AFast::new()
        .state(AppState { db_url: "localhost".into() })
        .state(CacheState { redis_url: "redis://localhost".into() })
        .service(svc)
        .ws("0.0.0.0:3000")
        .http("0.0.0.0:5000");

    app.run().await.unwrap();
}

Run

cargo run --features "http,ws,ts"
  • WebSocket API: ws://localhost:3000
  • HTTP API: POST http://localhost:5000/_api
  • Generated TS client code in ./code/api.ts

Features

FeatureDescriptionDependencies
httpHTTP serverhyper, hyper-util, http-body-util
wsWebSocket servertokio-tungstenite, futures-util
tcpTCP server (length-prefix framing)โ€”
tsTypeScript client generation (ESM + full types)โ€”
jsJavaScript client generation (ESM + JSDoc)โ€”
ktKotlin client generationโ€”
rsRust client generation (Tokio async / std sync TCP)โ€”
codeOn-demand code generation at /code/{service}/{lang}http
docInteractive API docs at /doc endpointhttp, js
ordinary-httpRESTful JSON endpoints (GET/POST/PUT/DELETE)http, serde, serde_json
seq64WS request ID uses i64 (default i32)โ€”
len64WS payload length uses u64 (default u32)โ€”
tag-u8Enum tag uses u8 (default)afastdata/tag-u8
tag-u16Enum tag uses u16afastdata/tag-u16
tag-u32Enum tag uses u32afastdata/tag-u32
markerEnable marker-based conditional serialization; set marker via AFast::marker() (default "afast")โ€”
hookLifecycle hooks (before_request/on_response/on_error/on_connect/on_disconnect), global and per-serviceโ€”
rate-limitNamed-policy rate limiting (FixedWindow/SlidingWindow/TokenBucket), pluggable store (built-in InMemoryStore)โ€”

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

Multiple States

AFast supports registering multiple State types. StateMap uses TypeId as keys, with one value per type:

#![allow(unused)]
fn main() {
#[derive(Clone)]
struct DbConfig { url: String }
#[derive(Clone)]
struct RedisConfig { url: String }
#[derive(Clone)]
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.

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>

Extractor Types

ExtractorDescriptionProtocols
State<T>Injects shared state by type from StateMap (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

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),
});
}

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.

Example

#![allow(unused)]
fn main() {
use afast::hook::{Hook, RequestContext, RequestGuard};

struct TimingHook;

impl Hook for TimingHook {
    fn before_request(&self, ctx: &RequestContext) -> Option<Box<dyn RequestGuard>> {
        println!("โ†’ {} ({})", ctx.handler_name, ctx.transport);
        Some(Box::new(std::time::Instant::now()))
    }
}

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);
    }
}
}

Global and Service Hooks

#![allow(unused)]
fn main() {
let app = AFast::new()
    .hook(TimingHook)                    // Global: all handlers
    .service(
        service!("api" => { h(handler) })
            .hook(ApiSpecificHook)       // Service: only this service's handlers
    );
}
  • Global hooks run for every handler.
  • 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 ๐Ÿง…).
    • before_request: Hook1 โ†’ Hook2 โ†’ handler
    • on_response/on_error: Guard2 โ†’ Guard1 (reversed)

Long Connection Hooks

For WebSocket/TCP long-connection handlers, the full lifecycle is:

on_connect โ†’ before_request โ†’ handler โ†’ on_response/on_error โ†’ on_disconnect

RequestContext Fields

FieldTypeDescription
handler_name&'static strHandler function name
handler_desc&'static strDescription from #[handler(desc(...))]
transport&'static str"tcp", "ws", or "http"
handler_idusizeHandler offset in dispatch table
stateArc<StateMap>Shared application state

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)

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
}

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(url)302Location header

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.

Code Generation

Static Generation (compile-time file output)

#![allow(unused)]
fn main() {
use afast::{GenerateTarget, Lang, JsTsCallType, RsCallType};

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,
        },
    ]);
}

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

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)

Client Usage

import { ApiClient } from './api';

// Dedicated WS port
const wsClient = new ApiClient('ws://localhost:3000');
const wsResult = await wsClient.apis.user.list_users({ page: 1, size: 20 });

// Merged mode (WS and HTTP on the same port)
const mergedClient = new ApiClient('ws://localhost:5000');
// Auto-connects to ws://localhost:5000/_ws

The client transport mode is fixed at construction time.

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"));
}

Interactive Documentation

With the doc feature, visit http://host:port/doc for interactive API docs:

#![allow(unused)]
fn main() {
let app = AFast::new()
    .service(svc)
    .document(afast::DocConfig::new()
        .title("My API Documentation")
        .output("./docs")  // Also write static HTML to disk
    )
    .http("0.0.0.0:5000");
}
  • GET /doc โ€” Index page listing all services
  • GET /doc/{service} โ€” Service docs with type definitions and online test panel
  • Dark/light theme toggle
  • Online test panel can send real requests to the server

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

# Start the example server
cargo run -p example

# Run the test client
cargo run -p example --bin test_client

License

MIT