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#"// @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
? e
: new ForgeClientError("UNKNOWN", 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#"// @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("// @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)]
#[allow(clippy::unwrap_used, clippy::indexing_slicing)]
mod tests {
use super::*;
use forge_core::schema::{FunctionDef, RustType};
#[test]
fn with_options_stores_auth_flag() {
let opts = GenerateOptions {
generate_auth_store: true,
};
let g = TypeScriptGenerator::with_options("/tmp/forge", opts);
assert!(g.options.generate_auth_store);
}
#[test]
fn default_options_disables_auth_store() {
let g = TypeScriptGenerator::new("/tmp/forge");
assert!(!g.options.generate_auth_store);
}
#[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"));
}
#[test]
fn generate_index_emits_reactive_only_when_a_query_exists() {
let generator = TypeScriptGenerator::new("/tmp/forge");
let registry = SchemaRegistry::new();
registry.register_function(FunctionDef::query("list_users", RustType::String));
let index = generator.generate_index(®istry);
assert!(
index.contains("'./reactive.svelte'"),
"queries must trigger reactive export"
);
}
#[test]
fn generate_index_skips_reactive_when_only_mutations_present() {
let generator = TypeScriptGenerator::new("/tmp/forge");
let registry = SchemaRegistry::new();
registry.register_function(FunctionDef::mutation("create_user", RustType::String));
let index = generator.generate_index(®istry);
assert!(
!index.contains("'./reactive.svelte'"),
"no queries => no reactive export"
);
}
#[test]
fn generate_index_emits_auth_only_when_flag_set() {
let registry = SchemaRegistry::new();
let off = TypeScriptGenerator::new("/tmp/forge");
assert!(!off.generate_index(®istry).contains("'./auth.svelte'"));
let on = TypeScriptGenerator::with_options(
"/tmp/forge",
GenerateOptions {
generate_auth_store: true,
},
);
assert!(on.generate_index(®istry).contains("'./auth.svelte'"));
}
#[test]
fn runes_content_is_the_published_constant() {
assert_eq!(TypeScriptGenerator::runes_content(), RUNES_SVELTE_TS);
}
#[test]
fn runes_constant_exports_expected_helpers() {
assert!(RUNES_SVELTE_TS.contains("export function toReactive<"));
assert!(RUNES_SVELTE_TS.contains("export function toReactiveMutation<"));
assert!(RUNES_SVELTE_TS.contains("@generated by FORGE"));
}
#[test]
fn auth_content_exports_singleton_and_helpers() {
let body = TypeScriptGenerator::auth_content();
assert!(body.contains("export const auth = new AuthStore();"));
assert!(body.contains("export function getToken("));
assert!(body.contains("@generated by FORGE"));
}
#[test]
fn error_display_includes_underlying_message() {
let io = Error::Io(std::io::Error::other("disk full"));
assert!(io.to_string().contains("disk full"));
let parse = Error::Parse {
file: "users.rs".into(),
message: "bad token".into(),
};
let s = parse.to_string();
assert!(s.contains("users.rs"));
assert!(s.contains("bad token"));
let gen_err = Error::Generation("schema empty".into());
assert!(gen_err.to_string().contains("schema empty"));
}
}