forge-codegen 0.7.4

TypeScript code generator for the Forge framework
Documentation
//! TypeScript client template (static content).

use crate::Error;

#[allow(dead_code)]
pub fn generate() -> Result<String, Error> {
    Ok(CLIENT_TEMPLATE.to_string())
}

#[allow(dead_code)]
const CLIENT_TEMPLATE: &str = r#"// Auto-generated by FORGE - DO NOT EDIT
import type { ForgeError } from './types';

// Client configuration
export interface ForgeClientConfig {
  url: string;
  getToken?: () => string | null | Promise<string | null>;
  onAuthError?: (error: ForgeError) => void;
  timeout?: number;
  retries?: number;
}

// WebSocket connection state
export type ConnectionState = 'connecting' | 'connected' | 'reconnecting' | 'disconnected';

// RPC request
interface RpcRequest {
  function: string;
  args: unknown;
  requestId?: string;
}

// RPC response
interface RpcResponse<T = unknown> {
  success: boolean;
  data?: T;
  error?: ForgeError;
  requestId?: string;
}

// The main FORGE client
export class ForgeClient {
  private config: ForgeClientConfig;
  private ws: WebSocket | null = null;
  private connectionState: ConnectionState = 'disconnected';
  private reconnectAttempts = 0;
  private maxReconnectAttempts = 10;
  private reconnectDelay = 1000;
  private subscriptions = new Map<string, (data: unknown) => void>();
  private pendingRequests = new Map<string, {
    resolve: (value: unknown) => void;
    reject: (error: Error) => void;
  }>();

  constructor(config: ForgeClientConfig) {
    this.config = {
      timeout: 30000,
      retries: 3,
      ...config,
    };
  }

  // Get the current connection state
  getConnectionState(): ConnectionState {
    return this.connectionState;
  }

  // Connect to the WebSocket server
  async connect(): Promise<void> {
    if (this.ws && this.ws.readyState === WebSocket.OPEN) {
      return;
    }

    return new Promise((resolve, reject) => {
      const wsUrl = this.config.url.replace(/^http/, 'ws') + '/ws';
      this.ws = new WebSocket(wsUrl);
      this.connectionState = 'connecting';

      this.ws.onopen = () => {
        this.connectionState = 'connected';
        this.reconnectAttempts = 0;
        resolve();
      };

      this.ws.onerror = (_event) => {
        reject(new Error('WebSocket connection failed'));
      };

      this.ws.onclose = () => {
        this.connectionState = 'disconnected';
        this.handleDisconnect();
      };

      this.ws.onmessage = (event) => {
        this.handleMessage(event.data);
      };
    });
  }

  // Disconnect from the server
  disconnect(): void {
    if (this.ws) {
      this.ws.close();
      this.ws = null;
    }
    this.connectionState = 'disconnected';
    this.subscriptions.clear();
    this.pendingRequests.clear();
  }

  // Call a remote function
  async call<T>(functionName: string, args: unknown): Promise<T> {
    const token = await this.getToken();

    const response = await fetch(`${this.config.url}/_api/rpc/${functionName}`, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        ...(token ? { 'Authorization': `Bearer ${token}` } : {}),
      },
      body: JSON.stringify({ args }),
    });

    const result: RpcResponse<T> = await response.json();

    if (!result.success || result.error) {
      let error = result.error;
      if (!error) {
        error = { code: 'UNKNOWN', message: 'Unknown error' };
      }
      if (error.code === 'UNAUTHORIZED' && this.config.onAuthError) {
        this.config.onAuthError(error);
      }
      throw new ForgeClientError(error.code, error.message, error.details);
    }

    return result.data as T;
  }

  // Call a remote function with file uploads (multipart/form-data)
  async callWithFiles<T>(functionName: string, args: Record<string, unknown>): Promise<T> {
    const token = await this.getToken();
    const formData = new FormData();
    const jsonArgs: Record<string, unknown> = {};

    for (const [key, value] of Object.entries(args)) {
      if (value instanceof File || value instanceof Blob) {
        formData.append(key, value);
      } else {
        jsonArgs[key] = value;
      }
    }

    formData.append('_json', JSON.stringify(jsonArgs));

    const response = await fetch(`${this.config.url}/_api/rpc/${functionName}/upload`, {
      method: 'POST',
      headers: {
        ...(token ? { 'Authorization': `Bearer ${token}` } : {}),
      },
      body: formData,
    });

    const result: RpcResponse<T> = await response.json();

    if (!result.success || result.error) {
      let error = result.error;
      if (!error) {
        error = { code: 'UNKNOWN', message: 'Unknown error' };
      }
      if (error.code === 'UNAUTHORIZED' && this.config.onAuthError) {
        this.config.onAuthError(error);
      }
      throw new ForgeClientError(error.code, error.message, error.details);
    }

    return result.data as T;
  }

  // Subscribe to a query
  subscribe<T>(
    functionName: string,
    args: unknown,
    callback: (data: T) => void
  ): () => void {
    const subscriptionId = this.generateId();

    this.subscriptions.set(subscriptionId, callback as (data: unknown) => void);

    if (this.ws && this.ws.readyState === WebSocket.OPEN) {
      this.ws.send(JSON.stringify({
        type: 'subscribe',
        id: subscriptionId,
        function: functionName,
        args,
      }));
    }

    return () => {
      this.subscriptions.delete(subscriptionId);
      if (this.ws && this.ws.readyState === WebSocket.OPEN) {
        this.ws.send(JSON.stringify({
          type: 'unsubscribe',
          id: subscriptionId,
        }));
      }
    };
  }

  // Get the auth token
  private async getToken(): Promise<string | null> {
    if (!this.config.getToken) {
      return null;
    }
    return this.config.getToken();
  }

  // Handle WebSocket messages
  private handleMessage(data: string): void {
    try {
      const message = JSON.parse(data);

      switch (message.type) {
        case 'data':
        case 'delta': {
          const callback = this.subscriptions.get(message.subscriptionId);
          if (callback) {
            callback(message.data);
          }
          break;
        }
        case 'response': {
          const pending = this.pendingRequests.get(message.requestId);
          if (pending) {
            if (message.success) {
              pending.resolve(message.data);
            } else {
              pending.reject(new ForgeClientError(
                message.error?.code || 'UNKNOWN',
                message.error?.message || 'Unknown error',
                message.error?.details
              ));
            }
            this.pendingRequests.delete(message.requestId);
          }
          break;
        }
        case 'error': {
          console.error('FORGE error:', message.error);
          break;
        }
      }
    } catch (e) {
      console.error('Failed to parse WebSocket message:', e);
    }
  }

  // Handle disconnection
  private handleDisconnect(): void {
    if (this.reconnectAttempts >= this.maxReconnectAttempts) {
      return;
    }

    this.connectionState = 'reconnecting';
    this.reconnectAttempts++;

    setTimeout(() => {
      this.connect().catch((error) => {
        console.warn('Reconnection failed:', error);
      });
    }, this.reconnectDelay * this.reconnectAttempts);
  }

  // Generate a unique ID
  private generateId(): string {
    return Math.random().toString(36).substring(2, 15);
  }
}

// FORGE-specific error class
export class ForgeClientError extends Error {
  constructor(
    public code: string,
    message: string,
    public details?: unknown
  ) {
    super(message);
    this.name = 'ForgeClientError';
  }
}

// React context for the client
let globalClient: ForgeClient | null = null;

export function createForgeClient(config: ForgeClientConfig): ForgeClient {
  globalClient = new ForgeClient(config);
  return globalClient;
}

export function getForgeClient(): ForgeClient | null {
  return globalClient;
}

// Svelte provider helper
export function ForgeProvider(config: ForgeClientConfig): ForgeClient {
  return createForgeClient(config);
}
"#;

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_generate_client_content() {
        let output = generate().expect("client template should generate");
        assert!(output.contains("export class ForgeClient"));
        assert!(output.contains("export class ForgeClientError"));
        assert!(output.contains("export function createForgeClient"));
        assert!(output.contains("export function ForgeProvider"));
    }
}