forge-codegen 0.8.1

TypeScript code generator for the Forge framework
Documentation
//! TypeScript code generation for SvelteKit frontends.
//!
//! The orchestrator generates all TypeScript artifacts from the schema registry.
//! Each sub-module is a pure function `generate(...) -> Result<String>` that
//! produces file content without touching the filesystem.

mod api;
mod reactive;
mod stores;
mod types;

use std::path::PathBuf;

use forge_core::schema::{FunctionKind, SchemaRegistry};

use crate::binding::BindingSet;

/// Runes content shared between schema-aware and stub generation paths.
pub const RUNES_SVELTE_TS: &str = r#"// Auto-generated by FORGE - DO NOT EDIT
import type { SubscriptionStore } from "@forge-rs/svelte";
import type { SubscriptionResult } 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: () => {
      unsubscribeCallback();
      store.unsubscribe();
    },
  });

  const unsubscribeCallback = store.subscribe((s) => {
    state.loading = s.loading;
    state.data = s.data;
    state.error = s.error;
    state.stale = s.stale;
  });

  return state;
}
"#;

/// Options for TypeScript code generation.
#[derive(Debug, Clone, Default)]
pub struct GenerateOptions {
    /// Generate auth.svelte.ts when auth is configured.
    pub generate_auth_store: bool,
}

pub struct TypeScriptGenerator {
    output_dir: PathBuf,
    options: GenerateOptions,
}

impl TypeScriptGenerator {
    /// Create a new TypeScript generator.
    pub fn new(output_dir: impl Into<PathBuf>) -> Self {
        Self {
            output_dir: output_dir.into(),
            options: GenerateOptions::default(),
        }
    }

    /// Create a new TypeScript generator with options.
    pub fn with_options(output_dir: impl Into<PathBuf>, options: GenerateOptions) -> Self {
        Self {
            output_dir: output_dir.into(),
            options,
        }
    }

    /// Generate all TypeScript artifacts and write them to disk.
    pub fn generate(&self, registry: &SchemaRegistry) -> Result<(), Error> {
        std::fs::create_dir_all(&self.output_dir)?;

        let bindings = BindingSet::from_registry(registry);

        // Collect type names referenced by API bindings so types.ts
        // can emit built-in types that aren't in the user's schema.
        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
    }

    /// Get the output directory.
    pub fn output_dir(&self) -> &PathBuf {
        &self.output_dir
    }
}

/// Code generation error.
#[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(&registry);
        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"));
    }
}