Generated Bindings
Type-safe frontend bindings generated from your Rust code and Forge macros.
The Code
Run codegen after adding or modifying functions:
forge generate
forge generate auto-detects the frontend target from frontend/ and can also be forced with --target sveltekit or --target dioxus.
SvelteKit Output
Your Rust models, input DTOs, and function signatures become TypeScript:
// types.ts - generated from #[forge::model] and input DTOs
export interface User {
id: string;
email: string;
created_at: string;
}
// api.ts - generated from #[forge::query] and #[forge::mutation]
export const getUser = (args: { id: string }): Promise<User> =>
getForgeClient().call("get_user", args);
export const createUser = (args: {
name: string;
email: string;
}): Promise<User> => getForgeClient().call("create_user", args);
// Reactive subscription (Svelte store)
export const getUserStore$ = (args: { id: string }) =>
createSubscriptionStore<{ id: string }, User>("get_user", args);
What Happens
The forge generate command:
- Scans your
src/directory for models, enums, and functions - Extracts type information from Rust macros
- Generates TypeScript interfaces with correct nullability
- Creates API bindings that call your functions with type safety
- Produces subscription factories for queries with real-time support
When a function takes a single argument whose type name ends in Args or Input and that type exists in the schema registry, the generator uses it directly as the params type instead of wrapping it in a generated struct. This means your custom input DTOs pass through as-is.
Output goes to frontend/src/lib/forge/ by default for SvelteKit projects.
Dioxus Output
For Dioxus projects, Forge generates Rust bindings under frontend/src/forge/:
// api.rs - generated from #[forge::query] and #[forge::mutation]
pub struct GetUserParams {
pub id: String,
}
impl GetUserParams {
pub fn new(id: impl Into<String>) -> Self {
Self { id: id.into() }
}
}
pub async fn get_user(client: &ForgeClient, args: GetUserParams) -> Result<User, ForgeClientError> {
client.call("get_user", args).await
}
// Default: one-shot query
pub fn use_get_user(args: GetUserParams) -> QueryState<User> {
use_forge_query("get_user", args)
}
// Opt-in: real-time subscription
pub fn use_get_user_live(args: GetUserParams) -> SubscriptionState<User> {
use_forge_subscription("get_user", args)
}
// Signal variant — returns Signal<QueryState<T>> for reactive child component passing
pub fn use_get_user_signal(args: GetUserParams) -> Signal<QueryState<User>> {
use_forge_query_signal("get_user", args)
}
// Mutations return a typed Mutation handle
pub fn use_create_user() -> Mutation<CreateUserParams, User> {
use_forge_mutation("create_user")
}
The generated mod.rs also re-exports ForgeProvider, ForgeClient, error types, and the Dioxus hooks from forge-dioxus.
use_<name>() is a one-shot query. Append _live for real-time subscriptions. Append _signal for Signal<QueryState<T>> variants useful when passing reactive query state to child components.
Mutations return a Mutation<Args, Result> with a .call(args) method.
Generated params and DTO structs include new(...) constructors plus builder methods for
optional fields, so common calls avoid verbose Rust struct literals.
Dioxus Auth
The generated mod.rs re-exports ForgeAuthProvider from forge-dioxus. Wrap your app in it to provide auth context:
use crate::forge::ForgeAuthProvider;
fn App() -> Element {
rsx! {
ForgeAuthProvider {
// your routes / app content
}
}
}
Inside the provider tree, use these hooks:
| Hook | Returns | Purpose |
|---|---|---|
use_forge_auth() | ForgeAuth | Auth handle with login(), logout(), update_tokens(), viewer::<T>() |
use_viewer::<T>() | Option<T> | Typed viewer access (shorthand for use_forge_auth().viewer::<T>()) |
use_auth_key() | usize | Counter that increments on identity changes, useful for router keying |
Type Mapping
Rust to TypeScript
| Rust Type | TypeScript Type |
|---|---|
String, &str | string |
Uuid | string |
i32, i64, f32, f64 | number |
bool | boolean |
Instant | string |
LocalDate, LocalTime | string |
Option<T> | T | null |
Vec<T> | T[] |
HashMap<K, V> | Record<string, V> |
Value (JSON) | unknown |
Page<T> | Page<T> |
Cursor | string |
Upload | File | Blob |
Bytes (return) | Blob |
Bytes (arg) | Uint8Array |
Rust to Dioxus
| Rust Type | Dioxus Type |
|---|---|
String, &str | String |
Uuid | String |
Instant | String (ISO 8601) |
i32, i64, f32, f64 | same |
bool | bool |
Option<T> | Option<T> |
Vec<T> | Vec<T> |
Page<T> | forge_core::Page<T> |
Upload | ForgeUpload |
serde_json::Value | JsonValue |
Bytes | Vec<u8> |
Enums to Union Types
Rust enums become TypeScript union types:
#[forge::forge_enum]
pub enum TaskStatus {
Draft,
Active,
Completed,
}
export type TaskStatus = "draft" | "active" | "completed";
Query Bindings
One-Shot RPC
Call a query once and get the result:
import { getUser, listTodos } from "$lib/forge";
// With arguments
const user = await getUser({ id: userId });
// No arguments
const todos = await listTodos();
Returns a Promise<T> that resolves with the data or rejects with a ForgeClientError.
Reactive Subscriptions
Subscribe to query changes with automatic updates:
import { getUserStore$ } from "$lib/forge";
// Create a subscription store
const userStore = getUserStore$({ id: userId });
// Use in Svelte component
$: ({ loading, data, error, stale } = $userStore);
Svelte 5 Runes
For Svelte 5 runes-based reactivity:
import { getUser$ } from '$lib/forge';
// Returns a reactive object
const user = getUser$({ id: userId });
// Access properties directly
{#if user.loading}
Loading...
{:else if user.error}
Error: {user.error.message}
{:else}
Welcome, {user.data.name}
{/if}
The $ suffix functions use $state internally for fine-grained reactivity.
SvelteKit Lower-Level APIs
The generated stores are built on top of a few primitives you can use directly:
| Function | Purpose |
|---|---|
createQueryStore(fn, args) | Underlying factory used by generated store functions. Creates a subscription store for any function name and argument object. |
setForgeClient(client) | Manually inject a ForgeClient into Svelte context. Useful for testing or custom setups where you don't use the generated provider. |
setAuthState(state) | Manually inject auth state into Svelte context. Same use case as setForgeClient but for the auth layer. |
Subscription State
All subscription stores expose the same shape:
interface SubscriptionResult<T> {
loading: boolean; // true until first data arrives
data: T | null; // the query result
error: Error | null;
stale: boolean; // reserved for reconnect-aware UIs
connection_state: ConnectionState; // Disconnected | Connecting | Connected
}
| Field | When True |
|---|---|
loading | Initial fetch in progress |
stale | Currently reserved in the Svelte runtime; use createConnectionStore() for connection status |
error | Initial query or subscription registration failed |
connection_state | Tracks the underlying SSE connection (Disconnected, Connecting, Connected) |
The current Svelte runtime keeps stale at false and exposes connection state separately through createConnectionStore().
In Dioxus generated types, WorkflowStepState.status is an untyped String rather than a union type. TypeScript targets get a proper string union, but the Dioxus codegen emits a plain String since Rust doesn't have an equivalent inline union.
Mutations
Mutations return Promises; subscriptions are not required for mutation calls:
import { createUser, updateTodo, deleteTodo } from "$lib/forge";
// Create
const user = await createUser({ name: "Alice", email: "alice@example.com" });
// Update
await updateTodo({ id: todoId, completed: true });
// Delete
await deleteTodo({ id: todoId });
On success, all active subscriptions that depend on affected tables re-evaluate automatically via server-side change detection.
File Uploads
The Upload type maps to File | Blob on the client:
#[forge::mutation]
pub async fn upload_avatar(
ctx: &MutationContext,
file: Upload,
user_id: Uuid,
) -> Result<String> {
let bytes = file.bytes();
let filename = file.name();
let content_type = file.content_type();
// Store file, return URL
}
import { uploadAvatar } from "$lib/forge";
// From file input
const url = await uploadAvatar({
file: inputElement.files[0],
userId: currentUserId,
});
// From Blob
const blob = new Blob([data], { type: "application/pdf" });
await uploadDocument({ file: blob, name: "report.pdf" });
When arguments contain File or Blob, the client automatically:
- Switches to
multipart/form-dataencoding - Encodes non-file arguments into the
_jsonmultipart field - Extracts filename and content type from the
Fileobject
Auto-Reconnect
The client handles connection drops automatically:
| Behavior | Value |
|---|---|
| Max attempts | 10 |
| Initial delay | 1 second |
| Backoff | Exponential with jitter |
| Max delay | 30s (SvelteKit), 16s (Dioxus) |
The Dioxus client caps at 16 seconds (1s * 2^min(attempts, 4)), while SvelteKit caps at 30 seconds.
During reconnection:
- The SSE transport reconnects with backoff
- Active query subscriptions re-register on reconnect
- One-shot RPC calls do not retry automatically
Connection Lifecycle
import { createConnectionStore } from "$lib/forge";
const connection = createConnectionStore();
$: state = $connection; // 'disconnected' | 'connecting' | 'connected'
Connection IDs prevent race conditions. If a reconnect starts while another is in progress, the stale attempt is cancelled.
Token Change Detection
When the auth token changes, the client detects it and reconnects:
// In auth.svelte.ts
auth.setAuth(newAccessToken, newRefreshToken, user); // Triggers reconnect
auth.clearAuth(); // Triggers reconnect
The client hashes the current token and compares it before each subscription registration. If the hash differs from the connected token, it triggers a full reconnect to re-authenticate subscriptions.
This handles:
- User login (token acquired)
- Token refresh (token replaced)
- User logout (token cleared)
Generated Files
SvelteKit files
After forge generate, a SvelteKit target gets:
| File | Contents |
|---|---|
types.ts | Interfaces from #[forge::model], input DTOs, and union types from #[forge::forge_enum] |
api.ts | Query, mutation, job, and workflow bindings |
stores.ts | Re-exports from @forge-rs/svelte |
runes.svelte.ts | Svelte 5 toReactive() helper |
reactive.svelte.ts | Runes-based subscription functions (getUser$), generated when queries exist |
auth.svelte.ts | Auth store (when auth configured) |
index.ts | Barrel export |
Dioxus files
After forge generate --target dioxus, a Dioxus target gets:
| File | Contents |
|---|---|
types.rs | Rust structs for models and input DTOs, plus enums from #[forge::forge_enum] |
api.rs | Query hooks, _live subscription hooks, Mutation handles, job/workflow hooks |
mod.rs | Re-exports generated modules plus forge-dioxus runtime types |
CLI Options
forge generate [OPTIONS]
| Option | Description |
|---|---|
--force | Regenerate even if files exist |
--output, -o | Output directory (defaults to the target's standard binding path) |
--target | Frontend target (sveltekit or dioxus) |
--src, -s | Source directory to scan (default: src) |
-y, --yes | Auto-accept prompts (for CI) |
Usage in SvelteKit Components
<script lang="ts">
import { listTodos$, createTodo, toggleTodo } from '$lib/forge';
const todos = listTodos$();
async function addTodo(title: string) {
await createTodo({ title });
// Refetches are handled by the subscription updates
}
async function toggle(id: string, completed: boolean) {
await toggleTodo({ id, completed });
}
</script>
{#if todos.loading}
<p>Loading...</p>
{:else if todos.error}
<p>Error: {todos.error.message}</p>
{:else}
<ul>
{#each todos.data as todo}
<li>
<input
type="checkbox"
checked={todo.completed}
on:change={() => toggle(todo.id, !todo.completed)}
/>
{todo.title}
</li>
{/each}
</ul>
{/if}
For Dioxus, the same backend contract is consumed through generated Rust hooks:
use dioxus::prelude::*;
use crate::forge::{CreateTodoInput, use_create_todo, use_list_todos_live};
#[component]
fn TodoList() -> Element {
let create_todo = use_create_todo();
let todos = use_list_todos_live();
rsx! {
button {
onclick: move |_| {
let create_todo = create_todo.clone();
spawn(async move {
let _ = create_todo.call(CreateTodoInput::new("Ship it")).await;
});
},
"Add"
}
ul {
for todo in todos.data.clone().unwrap_or_default() {
li { "{todo.title}" }
}
}
}
}
Under the Hood
SSE Transport
Subscriptions use Server-Sent Events with a dedicated connection. The client:
- Opens an EventSource to
/_api/eventswith the auth token - Receives a session ID and session secret on connection
- Registers each subscription via POST to
/_api/subscribe - Receives updates via the SSE channel with
targetrouting
SSE was chosen over WebSocket for:
- HTTP/2 multiplexing support
- Compatible with standard proxy configuration
- Automatic reconnection built into EventSource
- Simpler firewall traversal
Subscription Re-registration
On reconnect, the client re-registers all active query subscriptions. Each subscription tracks:
- Function name and arguments
- Failed attempt count
- Callback reference
After 3 consecutive re-registration failures, the subscription emits a MAX_RETRIES_EXCEEDED error and stops retrying.
Connection ID Pattern
Each connect() call increments a connection ID. All async operations check this ID before modifying state:
const currentConnectionId = ++this.connectionId;
// ... async work ...
if (currentConnectionId !== this.connectionId) return; // Stale, abort
This prevents race conditions when rapid reconnects overlap.