Files
xo-cli/src/services/sync-client.ts
2026-01-29 07:13:33 +00:00

163 lines
4.4 KiB
TypeScript

/**
* 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<T> {
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<T>(
method: 'GET' | 'POST' | 'PUT' | 'DELETE',
path: string,
body?: unknown,
): Promise<T> {
const url = `${this.baseUrl}${path}`;
const headers: Record<string, string> = {
'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<XOInvitation> {
return this.request<XOInvitation>('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<XOInvitation | undefined> {
try {
// Use query parameter for GET request (can't have body)
const response = await this.request<XOInvitation>(
'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<XOInvitation> {
// 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<boolean> {
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)}`;
}
}