/** * Sync Server Client - HTTP client for sync-server communication. * * Handles: * - Creating/updating invitations on the server * - Fetching invitations by ID * - ExtJSON encoding/decoding for data transfer */ import type { XOInvitation } from '@xo-cash/types'; import { encodeExtendedJson, decodeExtendedJson } from '../utils/ext-json.js'; /** * Response from the sync server. */ export interface SyncServerResponse { success: boolean; data?: T; error?: string; } /** * HTTP client for sync-server communication. */ export class SyncClient { /** Base URL of the sync server */ private baseUrl: string; /** * Creates a new sync client. * @param baseUrl - Base URL of the sync server (e.g., http://localhost:3000) */ constructor(baseUrl: string) { // Remove trailing slash if present this.baseUrl = baseUrl.replace(/\/$/, ''); } /** * Makes an HTTP request to the sync server. * @param method - HTTP method * @param path - Request path * @param body - Optional request body * @returns Response data */ private async request( method: 'GET' | 'POST' | 'PUT' | 'DELETE', path: string, body?: unknown, ): Promise { const url = `${this.baseUrl}${path}`; const headers: Record = { 'Content-Type': 'application/json', 'Accept': 'application/json', }; const options: RequestInit = { method, headers, }; if (body !== undefined) { // Encode body using ExtJSON for proper BigInt and Uint8Array serialization options.body = encodeExtendedJson(body); } const response = await fetch(url, options); if (!response.ok) { const errorText = await response.text(); throw new Error(`HTTP ${response.status}: ${errorText}`); } const responseText = await response.text(); // Return empty object if no response body if (!responseText) { return {} as T; } // Decode response using ExtJSON return decodeExtendedJson(responseText) as T; } // ============================================================================ // Invitation Operations // ============================================================================ /** * Posts an invitation to the sync server (create or update). * @param invitation - Invitation to post * @returns The stored invitation */ async postInvitation(invitation: XOInvitation): Promise { return this.request('POST', '/invitations', invitation); } /** * Gets an invitation from the sync server. * @param invitationIdentifier - Invitation ID to fetch * @returns The invitation or undefined if not found */ async getInvitation(invitationIdentifier: string): Promise { try { // Use query parameter for GET request (can't have body) const response = await this.request( 'GET', `/invitations?invitationIdentifier=${encodeURIComponent(invitationIdentifier)}` ); return response; } catch (error) { // Return undefined if not found (404) if (error instanceof Error && error.message.includes('404')) { return undefined; } throw error; } } /** * Updates an invitation on the sync server. * @param invitation - Updated invitation * @returns The updated invitation */ async updateInvitation(invitation: XOInvitation): Promise { // Uses the same POST endpoint which handles both create and update return this.postInvitation(invitation); } // ============================================================================ // Health Check // ============================================================================ /** * Checks if the sync server is healthy. * @returns True if server is healthy */ async isHealthy(): Promise { try { const response = await this.request<{ status: string }>('GET', '/health'); return response.status === 'ok'; } catch { return false; } } /** * Gets the base URL of the sync server. */ getBaseUrl(): string { return this.baseUrl; } /** * Gets the SSE endpoint URL for an invitation. * @param invitationId - Invitation ID to subscribe to * @returns SSE endpoint URL */ getSSEUrl(invitationIdentifier: string): string { return `${this.baseUrl}/invitations?invitationIdentifier=${encodeURIComponent(invitationIdentifier)}`; } }