mod api;
mod reactive;
mod stores;
mod types;
use std::path::PathBuf;
use forge_core::schema::{FunctionKind, SchemaRegistry};
use crate::binding::BindingSet;
pub const RUNES_SVELTE_TS: &str = r#"// Auto-generated by FORGE - DO NOT EDIT
import type { SubscriptionStore, ForgeError } from "@forge-rs/svelte";
import type { SubscriptionResult } from "@forge-rs/svelte";
import { ForgeClientError } from "@forge-rs/svelte";
export interface ReactiveQuery<T> extends SubscriptionResult<T> {
unsubscribe: () => void;
}
export function toReactive<T>(store: SubscriptionStore<T>): ReactiveQuery<T> {
const state: ReactiveQuery<T> = $state({
loading: true,
data: null,
error: null,
stale: false,
unsubscribe: () => {},
});
$effect(() => {
const unsubCallback = store.subscribe((s) => {
state.loading = s.loading;
state.data = s.data;
state.error = s.error;
state.stale = s.stale;
});
const cleanup = () => {
unsubCallback();
store.unsubscribe();
};
state.unsubscribe = cleanup;
return cleanup;
});
return state;
}
export interface ReactiveMutation<TArgs, TResult> {
mutate: (args: TArgs) => Promise<TResult>;
pending: boolean;
error: ForgeError | null;
}
export function toReactiveMutation<TArgs, TResult>(
fn: (args: TArgs) => Promise<TResult>,
): ReactiveMutation<TArgs, TResult> {
const state: ReactiveMutation<TArgs, TResult> = $state({
mutate: async (args: TArgs) => {
state.pending = true;
state.error = null;
try {
return await fn(args);
} catch (e) {
const err =
e instanceof ForgeClientError
? { code: e.code, message: e.message, details: e.details }
: { code: "UNKNOWN", message: String(e) };
state.error = err;
throw e;
} finally {
state.pending = false;
}
},
pending: false,
error: null,
});
return state;
}
"#;
#[derive(Debug, Clone, Default)]
pub struct GenerateOptions {
pub generate_auth_store: bool,
}
pub struct TypeScriptGenerator {
output_dir: PathBuf,
options: GenerateOptions,
}
impl TypeScriptGenerator {
pub fn new(output_dir: impl Into<PathBuf>) -> Self {
Self {
output_dir: output_dir.into(),
options: GenerateOptions::default(),
}
}
pub fn with_options(output_dir: impl Into<PathBuf>, options: GenerateOptions) -> Self {
Self {
output_dir: output_dir.into(),
options,
}
}
pub fn generate(&self, registry: &SchemaRegistry) -> Result<(), Error> {
std::fs::create_dir_all(&self.output_dir)?;
let bindings = BindingSet::from_registry(registry);
let mut referenced_types = Vec::new();
for binding in bindings.all() {
for arg in &binding.args {
crate::emit::collect_type_imports(&arg.rust_type, &mut referenced_types);
}
crate::emit::collect_type_imports(&binding.return_type, &mut referenced_types);
}
referenced_types.sort();
referenced_types.dedup();
std::fs::write(
self.output_dir.join("types.ts"),
types::generate(registry, &referenced_types)?,
)?;
std::fs::write(self.output_dir.join("api.ts"), api::generate(&bindings)?)?;
std::fs::write(
self.output_dir.join("runes.svelte.ts"),
Self::runes_content(),
)?;
let reactive_content = reactive::generate(&bindings)?;
if !reactive_content.is_empty() {
std::fs::write(self.output_dir.join("reactive.svelte.ts"), reactive_content)?;
}
std::fs::write(self.output_dir.join("stores.ts"), stores::generate()?)?;
if self.options.generate_auth_store {
std::fs::write(self.output_dir.join("auth.svelte.ts"), Self::auth_content())?;
}
std::fs::write(
self.output_dir.join("index.ts"),
self.generate_index(registry),
)?;
Ok(())
}
fn runes_content() -> &'static str {
RUNES_SVELTE_TS
}
fn auth_content() -> &'static str {
r#"// Auto-generated by FORGE - DO NOT EDIT
// Auth store for Svelte 5 with localStorage persistence and refresh token support
import { getForgeClient } from "@forge-rs/svelte";
interface User {
id: string;
email: string;
name?: string;
}
interface AuthState {
token: string | null;
refreshToken: string | null;
user: User | null;
}
const AUTH_KEY = "forge_auth";
function loadFromStorage(): AuthState {
if (typeof localStorage === "undefined") {
return { token: null, refreshToken: null, user: null };
}
const stored = localStorage.getItem(AUTH_KEY);
if (!stored) {
return { token: null, refreshToken: null, user: null };
}
try {
return JSON.parse(stored);
} catch {
return { token: null, refreshToken: null, user: null };
}
}
function saveToStorage(state: AuthState): void {
if (typeof localStorage === "undefined") return;
if (state.token) {
localStorage.setItem(AUTH_KEY, JSON.stringify(state));
} else {
localStorage.removeItem(AUTH_KEY);
}
}
class AuthStore {
#state: AuthState = $state(loadFromStorage());
#refreshInterval: ReturnType<typeof setInterval> | null = null;
#apiUrl: string = "";
get token(): string | null {
return this.#state.token;
}
get refreshToken(): string | null {
return this.#state.refreshToken;
}
get user(): User | null {
return this.#state.user;
}
get isAuthenticated(): boolean {
return this.#state.token !== null;
}
/** Initialize periodic refresh. Call once from your root layout. */
startRefreshLoop(apiUrl: string, intervalMs = 40 * 60 * 1000): void {
this.#apiUrl = apiUrl;
this.stopRefreshLoop();
this.#refreshInterval = setInterval(() => {
if (this.isAuthenticated) {
this.tryRefresh();
}
}, intervalMs);
}
stopRefreshLoop(): void {
if (this.#refreshInterval) {
clearInterval(this.#refreshInterval);
this.#refreshInterval = null;
}
}
setAuth(token: string, refreshToken: string, user: User): void {
this.#state = { token, refreshToken, user };
saveToStorage(this.#state);
getForgeClient()?.reconnect();
}
updateTokens(token: string, refreshToken: string): void {
this.#state = { ...this.#state, token, refreshToken };
saveToStorage(this.#state);
}
updateUser(user: User): void {
this.#state = { ...this.#state, user };
saveToStorage(this.#state);
}
clearAuth(): void {
this.#state = { token: null, refreshToken: null, user: null };
saveToStorage(this.#state);
this.stopRefreshLoop();
getForgeClient()?.reconnect();
}
/** Attempt to refresh tokens using the backend refresh mutation.
* Only clears auth on definitive failures (401/403). Network errors
* are silently ignored so transient issues don't force logouts. */
async tryRefresh(): Promise<boolean> {
if (!this.#state.refreshToken || !this.#apiUrl) return false;
try {
const res = await fetch(`${this.#apiUrl}/_api/rpc/refresh`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ args: { refresh_token: this.#state.refreshToken } }),
});
if (!res.ok) {
if (res.status === 401 || res.status === 403) {
this.clearAuth();
}
return false;
}
const envelope = await res.json();
if (envelope.success && envelope.data) {
this.updateTokens(envelope.data.access_token, envelope.data.refresh_token);
return true;
}
this.clearAuth();
return false;
} catch {
// Network error - keep tokens and retry later
return false;
}
}
#refreshPromise: Promise<boolean> | null = null;
/** Call from ForgeProvider's onAuthError callback.
* Coalesces concurrent calls so multiple 401s don't race. */
async handleAuthError(): Promise<void> {
if (!this.isAuthenticated) return;
if (!this.#refreshPromise) {
this.#refreshPromise = this.tryRefresh().finally(() => {
this.#refreshPromise = null;
});
}
const ok = await this.#refreshPromise;
if (!ok) this.clearAuth();
}
}
export const auth = new AuthStore();
export function getToken(): string | null {
return auth.token;
}
"#
}
fn generate_index(&self, registry: &SchemaRegistry) -> String {
let has_queries = registry
.all_functions()
.iter()
.any(|f| matches!(f.kind, FunctionKind::Query));
let mut output = String::from("// Auto-generated by FORGE - DO NOT EDIT\n");
output.push_str("export * from './types';\n");
output.push_str("export * from './api';\n");
output.push_str("export * from './stores';\n");
output.push_str("export * from './runes.svelte';\n");
if has_queries {
output.push_str("export * from './reactive.svelte';\n");
}
if self.options.generate_auth_store {
output.push_str("export * from './auth.svelte';\n");
}
output.push_str(
"export { ForgeClient, ForgeClientError, createForgeClient, ForgeProvider } from '@forge-rs/svelte';\n",
);
output
}
pub fn output_dir(&self) -> &PathBuf {
&self.output_dir
}
}
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
#[error("Parse error in {file}: {message}")]
Parse { file: String, message: String },
#[error("Generation error: {0}")]
Generation(String),
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_generator_creation() {
let generator = TypeScriptGenerator::new("/tmp/forge");
assert_eq!(generator.output_dir(), &PathBuf::from("/tmp/forge"));
}
#[test]
fn test_generate_index() {
let generator = TypeScriptGenerator::new("/tmp/forge");
let registry = SchemaRegistry::new();
let index = generator.generate_index(®istry);
assert!(index.contains("'./types'"));
assert!(index.contains("'./api'"));
assert!(index.contains("'./stores'"));
assert!(index.contains("'./runes.svelte'"));
assert!(index.contains("ForgeClient"));
assert!(index.contains("createForgeClient"));
assert!(index.contains("ForgeProvider"));
}
}