UNPKG

@spfn/core

Version:

SPFN Framework Core - File-based routing, transactions, repository pattern

357 lines (355 loc) 10.9 kB
// src/client/contract-client.ts var ApiClientError = class extends Error { constructor(message, status, url, response, errorType) { super(message); this.status = status; this.url = url; this.response = response; this.errorType = errorType; this.name = "ApiClientError"; } }; var ContractClient = class _ContractClient { config; interceptors = []; constructor(config = {}) { this.config = { baseUrl: config.baseUrl || process.env.SERVER_API_URL || process.env.NEXT_PUBLIC_API_URL || "http://localhost:4000", headers: config.headers || {}, timeout: config.timeout || 3e4, fetch: config.fetch || globalThis.fetch.bind(globalThis) }; } /** * Add request interceptor */ use(interceptor) { this.interceptors.push(interceptor); } /** * Make a type-safe API call using a contract * * @param contract - Route contract with absolute path * @param options - Call options (params, query, body, headers) */ async call(contract, options) { const baseUrl = options?.baseUrl || this.config.baseUrl; const urlPath = _ContractClient.buildUrl( contract.path, options?.params ); const queryString = _ContractClient.buildQuery( options?.query ); const url = `${baseUrl}${urlPath}${queryString}`; const method = _ContractClient.getHttpMethod(contract, options); const headers = { ...this.config.headers, ...options?.headers }; const isFormData = _ContractClient.isFormData(options?.body); if (options?.body !== void 0 && !isFormData && !headers["Content-Type"]) { headers["Content-Type"] = "application/json"; } let init = { method, headers, ...options?.fetchOptions // Spread environment-specific options (e.g., Next.js cache/next) }; if (options?.body !== void 0) { init.body = isFormData ? options.body : JSON.stringify(options.body); } const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), this.config.timeout); init.signal = controller.signal; for (const interceptor of this.interceptors) { init = await interceptor(url, init); } const response = await this.config.fetch(url, init).catch((error) => { clearTimeout(timeoutId); if (error instanceof Error && error.name === "AbortError") { throw new ApiClientError( `Request timed out after ${this.config.timeout}ms`, 0, url, void 0, "timeout" ); } if (error instanceof Error) { throw new ApiClientError( `Network error: ${error.message}`, 0, url, void 0, "network" ); } throw error; }); clearTimeout(timeoutId); if (!response.ok) { const errorBody = await response.json().catch(() => null); throw new ApiClientError( `${method} ${urlPath} failed: ${response.status} ${response.statusText}`, response.status, url, errorBody, "http" ); } const data = await response.json(); return data; } /** * Create a new client with merged configuration */ withConfig(config) { return new _ContractClient({ baseUrl: config.baseUrl || this.config.baseUrl, headers: { ...this.config.headers, ...config.headers }, timeout: config.timeout || this.config.timeout, fetch: config.fetch || this.config.fetch }); } static buildUrl(path, params) { if (!params) return path; let url = path; for (const [key, value] of Object.entries(params)) { url = url.replace(`:${key}`, String(value)); } return url; } static buildQuery(query) { if (!query || Object.keys(query).length === 0) return ""; const params = new URLSearchParams(); for (const [key, value] of Object.entries(query)) { if (Array.isArray(value)) { value.forEach((v) => params.append(key, String(v))); } else if (value !== void 0 && value !== null) { params.append(key, String(value)); } } const queryString = params.toString(); return queryString ? `?${queryString}` : ""; } static getHttpMethod(contract, options) { if ("method" in contract && typeof contract.method === "string") { return contract.method.toUpperCase(); } if (options?.body !== void 0) { return "POST"; } return "GET"; } static isFormData(body) { return body instanceof FormData; } }; var _clientInstance = new ContractClient(); new Proxy({}, { get(_target, prop) { return _clientInstance[prop]; } }); function isTimeoutError(error) { return error instanceof ApiClientError && error.errorType === "timeout"; } function isNetworkError(error) { return error instanceof ApiClientError && error.errorType === "network"; } function isHttpError(error) { return error instanceof ApiClientError && error.errorType === "http"; } function isServerError(error, errorType) { if (!isHttpError(error)) return false; const response = error.response; return response?.error?.type === errorType; } function getServerErrorType(error) { const response = error.response; return response?.error?.type; } function getServerErrorDetails(error) { const response = error.response; return response?.error?.details; } // src/client/universal-client.ts function isServerEnvironment() { return typeof window === "undefined"; } var UniversalClient = class _UniversalClient { directClient; proxyBasePath; isServer; fetchImpl; constructor(config = {}) { this.isServer = isServerEnvironment(); this.directClient = new ContractClient({ baseUrl: config.apiUrl, headers: config.headers, timeout: config.timeout, fetch: config.fetch }); this.proxyBasePath = config.proxyBasePath || "/api/actions"; this.fetchImpl = config.fetch || globalThis.fetch.bind(globalThis); } /** * Make a type-safe API call using a contract * * Automatically routes based on environment: * - Server: Direct SPFN API call * - Browser: Next.js API Route proxy * * @param contract - Route contract with absolute path * @param options - Call options (params, query, body, headers) */ async call(contract, options) { if (this.isServer) { return this.directClient.call(contract, options); } else { return this.callViaProxy(contract, options); } } /** * Call via Next.js API Route proxy (client-side) * * Routes request through /api/proxy/[...path] to enable: * - HttpOnly cookie forwarding * - CORS security * - Server-side session management * * @private */ async callViaProxy(contract, options) { const path = this.buildUrlPath( contract.path, options?.params ); const queryString = this.buildQueryString( options?.query ); const proxyUrl = `${this.proxyBasePath}${path}${queryString}`; const method = this.getHttpMethod(contract, options); const headers = { ...options?.headers }; const isFormData = this.isFormData(options?.body); if (options?.body !== void 0 && !isFormData && !headers["Content-Type"]) { headers["Content-Type"] = "application/json"; } const init = { method, headers, credentials: "include", // Important: Include cookies for session ...options?.fetchOptions // Spread environment-specific options (e.g., Next.js cache/next) }; if (options?.body !== void 0) { init.body = isFormData ? options.body : JSON.stringify(options.body); } const response = await this.fetchImpl(proxyUrl, init); if (!response.ok) { const errorBody = await response.json().catch(() => null); throw new ApiClientError( `${method} ${path} failed: ${response.status} ${response.statusText}`, response.status, proxyUrl, errorBody, "http" ); } const data = await response.json(); return data; } /** * Build URL path with parameter substitution */ buildUrlPath(path, params) { if (!params) return path; let url = path; for (const [key, value] of Object.entries(params)) { url = url.replace(`:${key}`, String(value)); } return url; } /** * Build query string from query parameters */ buildQueryString(query) { if (!query || Object.keys(query).length === 0) return ""; const params = new URLSearchParams(); for (const [key, value] of Object.entries(query)) { if (Array.isArray(value)) { value.forEach((v) => params.append(key, String(v))); } else if (value !== void 0 && value !== null) { params.append(key, String(value)); } } const queryString = params.toString(); return queryString ? `?${queryString}` : ""; } /** * Get HTTP method from contract or infer from options */ getHttpMethod(contract, options) { if ("method" in contract && typeof contract.method === "string") { return contract.method.toUpperCase(); } if (options?.body !== void 0) { return "POST"; } return "GET"; } /** * Check if body is FormData */ isFormData(body) { return typeof FormData !== "undefined" && body instanceof FormData; } /** * Check if currently running in server environment */ isServerEnv() { return this.isServer; } /** * Create a new client with merged configuration */ withConfig(config) { return new _UniversalClient({ apiUrl: config.apiUrl || this.directClient["config"].baseUrl, proxyBasePath: config.proxyBasePath || this.proxyBasePath, headers: { ...this.directClient["config"].headers, ...config.headers }, timeout: config.timeout || this.directClient["config"].timeout, fetch: config.fetch || this.fetchImpl }); } }; function createUniversalClient(config) { return new UniversalClient(config); } var _universalClientInstance = null; function configureUniversalClient(config) { _universalClientInstance = new UniversalClient(config); } function getUniversalClient() { if (!_universalClientInstance) { _universalClientInstance = new UniversalClient(); } return _universalClientInstance; } var universalClient = new Proxy({}, { get(_target, prop) { const instance = getUniversalClient(); return instance[prop]; } }); export { ApiClientError, ContractClient, UniversalClient, universalClient as client, configureUniversalClient as configureClient, configureUniversalClient, createUniversalClient as createClient, createUniversalClient, getServerErrorDetails, getServerErrorType, getUniversalClient, isHttpError, isNetworkError, isServerError, isTimeoutError, universalClient }; //# sourceMappingURL=index.js.map //# sourceMappingURL=index.js.map