@spfn/core
Version:
SPFN Framework Core - File-based routing, transactions, repository pattern
357 lines (355 loc) • 10.9 kB
JavaScript
// 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