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"));
}
}