@follow-app/client-sdk
Version:
TypeScript client SDK for Follow RSS Server API
476 lines (416 loc) • 12.9 kB
text/typescript
import type {
ClientConfig,
FollowAPIErrorResponse,
FollowAPIResponse,
RequestContentType,
RequestOptions,
} from "../types"
import {
FollowAPIError,
FollowAuthError,
FollowTimeoutError,
FollowValidationError,
} from "../types/errors"
import { InterceptorManager } from "./interceptors"
/**
* Core HTTP client for Follow API using native fetch
*/
export class HttpClient {
private config: Required<ClientConfig>
private fetchInstance: typeof fetch
private interceptors: InterceptorManager
constructor(config: ClientConfig) {
this.config = {
timeout: 30000,
headers: {},
credentials: "include",
fetch: globalThis.fetch,
...config,
}
this.fetchInstance = this.config.fetch
this.interceptors = new InterceptorManager()
}
/**
* Build URL with query parameters
*/
private buildURL(
path: string,
query?: Record<string, string | number | boolean | string[]>,
): string {
const url = new URL(path, this.config.baseURL)
if (query) {
Object.entries(query).forEach(([key, value]) => {
if (value !== undefined && value !== null) {
if (Array.isArray(value)) {
value.forEach((v) => {
url.searchParams.append(key, String(v))
})
} else {
url.searchParams.append(key, String(value))
}
}
})
}
return url.toString()
}
/**
* Process request body based on content type
*/
private processRequestBody(
body: unknown,
requestType?: RequestContentType | undefined,
): { processedBody: BodyInit | undefined, headers: Record<string, string> } {
if (!body) {
return { processedBody: undefined, headers: {} }
}
if (!requestType) {
if (body instanceof FormData) {
requestType = "formData"
} else if (body instanceof ArrayBuffer) {
requestType = "arrayBuffer"
} else if (body instanceof Blob) {
requestType = "blob"
} else {
requestType = "json"
}
}
switch (requestType) {
case "json": {
return {
processedBody: JSON.stringify(body),
headers: { "Content-Type": "application/json" },
}
}
case "formData": {
if (body instanceof FormData) {
return { processedBody: body, headers: {} }
}
if (typeof body === "object" && body !== null) {
const formData = new FormData()
Object.entries(body).forEach(([key, value]) => {
if (value instanceof File || value instanceof Blob) {
formData.append(key, value)
} else if (value !== undefined && value !== null) {
formData.append(key, String(value))
}
})
return { processedBody: formData, headers: {} }
}
throw new Error("Invalid body type for formData request")
}
case "text": {
return {
processedBody: typeof body === "string" ? body : String(body),
headers: { "Content-Type": "text/plain" },
}
}
case "blob": {
if (body instanceof Blob) {
return { processedBody: body, headers: {} }
}
throw new Error("Body must be a Blob for blob request type")
}
case "arrayBuffer": {
if (body instanceof ArrayBuffer) {
return {
processedBody: body,
headers: { "Content-Type": "application/octet-stream" },
}
}
throw new Error(
"Body must be an ArrayBuffer for arrayBuffer request type",
)
}
default: {
throw new Error(`Unsupported request type: ${requestType}`)
}
}
}
/**
* Handle response parsing and error handling
*/
private async handleResponse<T>(
response: Response,
originalPath: string,
finalUrl: string,
requestOptions: RequestOptions,
): Promise<T> {
const contentType = response.headers?.get("content-type") || ""
if (!response.ok) {
let errorData: FollowAPIErrorResponse | null = null
// Try to parse error response
if (contentType.includes("application/json")) {
try {
errorData = await response.json()
} catch {
// Fall back to status text if JSON parsing fails
errorData = { code: response.status, message: response.statusText }
}
} else {
errorData = { code: response.status, message: response.statusText }
}
// Extract pathname from final URL for error context
const finalPathname = new URL(finalUrl).pathname
// Create detailed error context
const requestContext = {
originalPath,
finalPathname,
method: requestOptions.method || "GET",
query: requestOptions.query,
body: requestOptions.body,
headers: requestOptions.headers,
}
const contextStr = `${requestContext.method} ${finalPathname} (original: ${originalPath})`
const argsStr = JSON.stringify(
{
query: requestContext.query,
body: requestContext.body,
headers: requestContext.headers,
},
null,
2,
)
// Handle specific error types
if (response.status === 401) {
throw new FollowAuthError(
`${errorData?.message || "Authentication required"}\nRequest: ${contextStr}\nArgs: ${argsStr}`,
errorData,
)
}
if (response.status === 400 && errorData?.data) {
throw new FollowValidationError(
`${errorData.message || "Validation error"}\nRequest: ${contextStr}\nArgs: ${argsStr}`,
Array.isArray(errorData.data) ? errorData.data : [errorData.data],
)
}
throw new FollowAPIError(
`${errorData?.message || response.statusText}\nRequest: ${contextStr}\nArgs: ${argsStr}`,
response.status,
errorData?.code?.toString(),
errorData?.data,
)
}
// Handle successful responses based on content type
return requestOptions.asRaw ? response as unknown as T : this.parseResponseByContentType<T>(response, contentType)
}
/**
* Parse response based on content type
*/
private async parseResponseByContentType<T>(
response: Response,
contentType: string,
): Promise<T> {
// Handle event stream
if (contentType.includes("text/event-stream")) {
return response as unknown as T
}
// Handle JSON responses
if (contentType.includes("application/json")) {
const jsonResponse = await response.json()
// Handle Follow API response format
if (typeof jsonResponse === "object" && "code" in jsonResponse) {
const apiResponse = jsonResponse as FollowAPIResponse<T>
return apiResponse as unknown as T
}
return jsonResponse
}
// Handle blob responses
if (
contentType.includes("application/octet-stream") ||
contentType.includes("image/") ||
contentType.includes("video/") ||
contentType.includes("audio/")
) {
const blobResponse = await response.blob()
return blobResponse as unknown as T
}
// Handle text responses
if (contentType.includes("text/")) {
const textResponse = await response.text()
return textResponse as T
}
// Default to arrayBuffer for unknown binary types
if (contentType.includes("application/")) {
const arrayBufferResponse = await response.arrayBuffer()
return arrayBufferResponse as unknown as T
}
// Fallback to text
const textResponse = await response.text()
return textResponse as T
}
/**
* Make an HTTP request
*/
async request<T>(path: string, options: RequestOptions = {}): Promise<T> {
let currentUrl = this.buildURL(path, options.query)
let currentOptions = options
let response: Response | null = null
try {
// Process request interceptors
const interceptedRequest = await this.interceptors.processRequest(
currentUrl,
currentOptions,
)
currentUrl = interceptedRequest.url
currentOptions = interceptedRequest.options
const timeout = currentOptions.timeout || this.config.timeout
// Create abort controller for timeout
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), timeout)
// Combine signals if user provided one
let { signal } = controller
if (currentOptions.signal) {
const combinedController = new AbortController()
const cleanup = () => {
clearTimeout(timeoutId)
combinedController.abort()
}
currentOptions.signal.addEventListener("abort", cleanup)
controller.signal.addEventListener("abort", cleanup)
signal = combinedController.signal
}
// Process request body based on content type
const { processedBody, headers: bodyHeaders } = this.processRequestBody(
currentOptions.body,
currentOptions.requestType,
)
response = await this.fetchInstance(currentUrl, {
method: currentOptions.method || "GET",
headers: {
...this.config.headers,
...bodyHeaders,
...currentOptions.headers,
},
credentials: this.config.credentials,
body: processedBody,
signal,
})
clearTimeout(timeoutId)
// Process response interceptors
const interceptedResponse = await this.interceptors.processResponse(
response,
currentUrl,
currentOptions,
)
return this.handleResponse<T>(
interceptedResponse,
path,
currentUrl,
currentOptions,
)
} catch (error) {
const processedError = await this.interceptors.processError(
error instanceof Error ? error : new Error("Unknown error"),
response,
currentUrl,
currentOptions,
)
if (processedError) {
throw processedError
}
// Handle specific error types
if (error instanceof DOMException && error.name === "AbortError") {
throw new FollowTimeoutError("Request timeout")
}
// Re-throw our custom errors
if (error instanceof FollowAPIError) {
throw error
}
throw error
}
}
/**
* Convenience methods for different HTTP verbs
*/
async get<T>(
path: string,
options?: Omit<RequestOptions, "method">,
): Promise<T> {
return this.request<T>(path, { ...options, method: "GET" })
}
async post<T>(
path: string,
body?: unknown,
options?: Omit<RequestOptions, "method" | "body">,
): Promise<T> {
return this.request<T>(path, { ...options, method: "POST", body })
}
async put<T>(
path: string,
body?: unknown,
options?: Omit<RequestOptions, "method" | "body">,
): Promise<T> {
return this.request<T>(path, { ...options, method: "PUT", body })
}
async patch<T>(
path: string,
body?: unknown,
options?: Omit<RequestOptions, "method" | "body">,
): Promise<T> {
return this.request<T>(path, { ...options, method: "PATCH", body })
}
/**
* Convenience method for form data uploads
*/
async postForm<T>(
path: string,
formData: FormData | Record<string, unknown>,
options?: Omit<RequestOptions, "method" | "body" | "requestType">,
): Promise<T> {
return this.request<T>(path, {
...options,
method: "POST",
body: formData,
requestType: "formData",
})
}
/**
* Convenience method for event stream responses
*/
async getStream(
path: string,
options?: Omit<RequestOptions, "method">,
): Promise<Response> {
return this.request<Response>(path, { ...options, method: "GET" })
}
async delete<T>(
path: string,
options?: Omit<RequestOptions, "method">,
): Promise<T> {
return this.request<T>(path, { ...options, method: "DELETE" })
}
/**
* Update client configuration
*/
setConfig(config: Partial<ClientConfig>): void {
this.config = { ...this.config, ...config }
if (config.fetch) {
this.fetchInstance = config.fetch
}
}
/**
* Set additional headers
*/
setHeaders(headers: Record<string, string>): void {
this.config.headers = { ...this.config.headers, ...headers }
}
/**
* Set custom fetch instance
*/
setFetch(fetchInstance: typeof fetch): void {
this.fetchInstance = fetchInstance
this.config.fetch = fetchInstance
}
/**
* Get current configuration (readonly)
*/
getConfig(): Readonly<Required<ClientConfig>> {
return { ...this.config }
}
/**
* Get interceptor manager for advanced usage
*/
getInterceptors(): InterceptorManager {
return this.interceptors
}
}