imajin/services/imajin-processing/client/src/client.ts

292 lines
7.4 KiB
TypeScript

/**
* Image Processing Client
*
* HTTP client for the image processing service.
*/
import type {
SanitizeRequest,
SanitizeResponse,
ResizeRequest,
ResizeResponse,
ThumbnailRequest,
ThumbnailResponse,
DerivativesRequest,
DerivativesResponse,
MasterRequest,
MasterResponse,
SingleDerivativeRequest,
SingleDerivativeResponse,
ConvertRequest,
OverlayRequest,
OverlayResponse,
ConvertResponse,
MetadataRequest,
MetadataResponse,
ProcessRequest,
ProcessResponse,
ProcessOperation,
HealthResponse,
AllowedMimeType,
FamilyName,
OptimizationPreset,
ImageFormat,
ResizeMode,
SanitizeOptions,
} from '@lilith/imajin-processing-types';
export interface ImageProcessingClientConfig {
/** Base URL of the image processing service */
baseUrl: string;
/** Request timeout in milliseconds (default: 60000) */
timeout?: number;
}
export class ImageProcessingError extends Error {
constructor(
message: string,
public readonly statusCode?: number,
public readonly code?: string
) {
super(message);
this.name = 'ImageProcessingError';
}
}
export class ImageProcessingClient {
private readonly baseUrl: string;
private readonly timeout: number;
constructor(config: ImageProcessingClientConfig) {
this.baseUrl = config.baseUrl.replace(/\/$/, '');
this.timeout = config.timeout ?? 60000;
}
// ==========================================================================
// Private Helpers
// ==========================================================================
private async fetch<T>(path: string, init?: RequestInit): Promise<T> {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), this.timeout);
try {
const response = await fetch(`${this.baseUrl}${path}`, {
...init,
signal: controller.signal,
headers: {
'Content-Type': 'application/json',
...init?.headers,
},
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({ message: response.statusText })) as { message?: string; code?: string };
throw new ImageProcessingError(
errorData.message ?? 'Request failed',
response.status,
errorData.code
);
}
return response.json() as Promise<T>;
} catch (error) {
if (error instanceof ImageProcessingError) throw error;
if (error instanceof Error) {
if (error.name === 'AbortError') {
throw new ImageProcessingError('Request timeout', 408, 'TIMEOUT');
}
throw new ImageProcessingError(error.message);
}
throw new ImageProcessingError('Unknown error');
} finally {
clearTimeout(timeoutId);
}
}
// ==========================================================================
// Public API
// ==========================================================================
/**
* Check service health.
*/
async health(): Promise<HealthResponse> {
return this.fetch<HealthResponse>('/health');
}
/**
* Process an image through a pipeline of operations.
* Operations are executed sequentially in the order specified.
*
* @param image - Base64-encoded image data
* @param mimeType - MIME type of the image
* @param operations - Array of operations to apply
* @param family - Image family (required if 'derivatives' operation is included)
*/
async process(
image: string,
mimeType: AllowedMimeType,
operations: ProcessOperation[],
family?: FamilyName
): Promise<ProcessResponse> {
const request: ProcessRequest = { image, mimeType, operations, family };
return this.fetch<ProcessResponse>('/process', {
method: 'POST',
body: JSON.stringify(request),
});
}
/**
* Sanitize an image by re-encoding it.
* Strips EXIF data and potential malicious content.
*/
async sanitize(
image: string,
mimeType: AllowedMimeType,
options?: SanitizeOptions
): Promise<SanitizeResponse> {
const request: SanitizeRequest = { image, mimeType, options };
return this.fetch<SanitizeResponse>('/sanitize', {
method: 'POST',
body: JSON.stringify(request),
});
}
/**
* Resize an image with the specified mode.
*/
async resize(
image: string,
width: number,
height: number,
options?: {
mode?: ResizeMode;
format?: ImageFormat;
quality?: number;
background?: string;
}
): Promise<ResizeResponse> {
const request: ResizeRequest = {
image,
options: { width, height, ...options },
};
return this.fetch<ResizeResponse>('/resize', {
method: 'POST',
body: JSON.stringify(request),
});
}
/**
* Generate a thumbnail.
*/
async thumbnail(
image: string,
size?: number,
quality?: number
): Promise<ThumbnailResponse> {
const request: ThumbnailRequest = { image, size, quality };
return this.fetch<ThumbnailResponse>('/thumbnail', {
method: 'POST',
body: JSON.stringify(request),
});
}
/**
* Generate all derivatives for a family.
*/
async derivatives(
image: string,
family: FamilyName,
preset?: OptimizationPreset
): Promise<DerivativesResponse> {
const request: DerivativesRequest = {
image,
options: { family, preset },
};
return this.fetch<DerivativesResponse>('/derivatives', {
method: 'POST',
body: JSON.stringify(request),
});
}
/**
* Clip a single derivative from an image.
*/
async singleDerivative(
image: string,
width: number,
height: number,
preset?: OptimizationPreset
): Promise<SingleDerivativeResponse> {
const request: SingleDerivativeRequest = { image, width, height, preset };
return this.fetch<SingleDerivativeResponse>('/derivatives/single', {
method: 'POST',
body: JSON.stringify(request),
});
}
/**
* Prepare a master image for a family.
*/
async master(
image: string,
family: FamilyName,
preset?: OptimizationPreset
): Promise<MasterResponse> {
const request: MasterRequest = { image, family, preset };
return this.fetch<MasterResponse>('/master', {
method: 'POST',
body: JSON.stringify(request),
});
}
/**
* Convert an image to a different format.
*/
async convert(
image: string,
format: ImageFormat,
quality?: number
): Promise<ConvertResponse> {
const request: ConvertRequest = {
image,
options: { format, quality },
};
return this.fetch<ConvertResponse>('/convert', {
method: 'POST',
body: JSON.stringify(request),
});
}
/**
* Composite a sticker/emoji onto the image at normalized region centers
* (censoring overlays — e.g. hearts over regions from imajin-moderator).
*/
async overlay(request: OverlayRequest): Promise<OverlayResponse> {
return this.fetch<OverlayResponse>('/overlay', {
method: 'POST',
body: JSON.stringify(request),
});
}
/**
* Get image metadata.
*/
async metadata(image: string): Promise<MetadataResponse> {
const request: MetadataRequest = { image };
return this.fetch<MetadataResponse>('/metadata', {
method: 'POST',
body: JSON.stringify(request),
});
}
}
/**
* Create a client for local development.
*/
export function createLocalClient(port: number = 8004): ImageProcessingClient {
return new ImageProcessingClient({
baseUrl: `http://localhost:${port}`,
});
}