Files
classroomio/apps/dashboard/src/lib/utils/services/api/index.ts
T

292 lines
9.1 KiB
TypeScript
Raw Normal View History

import { type ApiClientConfig, ApiError, type RequestConfig } from './types';
import { dev } from '$app/environment';
import { DEFAULT_CONFIG } from './constants';
import { delay } from './utils';
import { hcWithType } from '@cio/api/rpc-types';
import { get } from 'svelte/store';
import { currentOrg } from '$lib/utils/store/org';
2026-03-09 11:18:22 +01:00
import type { Cookies } from '@sveltejs/kit';
import { env } from '$env/dynamic/public';
2026-03-12 08:57:39 +01:00
import { getCioCookieString } from '$lib/utils/functions/cookies';
2026-03-09 11:18:22 +01:00
export const getRequestBaseUrl = () => {
if (typeof window === 'undefined') {
// When on the server, we want to hit the private url which is the docker container of `api` or the private network url of `api`.
// if that isn't set then it will fallback to the public url of the `api`
return process.env.PRIVATE_SERVER_URL || env.PUBLIC_SERVER_URL;
}
2026-05-20 16:33:11 +01:00
// Self-hosted: dashboard and API are on different subdomains of the
// operator's apex. Browser calls go straight to PUBLIC_SERVER_URL;
// cookies cross subdomains via AUTH_COOKIE_DOMAIN. No Worker proxy.
if (env.PUBLIC_IS_SELFHOSTED === 'true' || dev) {
2026-05-20 16:33:11 +01:00
return env.PUBLIC_SERVER_URL ?? '';
}
// Cloud (multi-tenant): same-origin via the Cloudflare Worker `/proxy`
// prefix so auth cookies stay host-only on whichever tenant or BYOD
// domain the user is currently visiting. The Worker strips `/proxy`
// before forwarding to the API.
return `${window.location.origin}/proxy`;
2026-03-09 11:18:22 +01:00
};
function mergeAbortSignals(...signals: Array<AbortSignal | null | undefined>) {
const activeSignals = signals.filter((signal): signal is AbortSignal => Boolean(signal));
if (activeSignals.length === 0) {
return undefined;
}
if (activeSignals.length === 1) {
return activeSignals[0];
}
const controller = new AbortController();
const abortWithSignal = (signal: AbortSignal) => {
if (controller.signal.aborted) {
return;
}
controller.abort(signal.reason);
};
for (const signal of activeSignals) {
if (signal.aborted) {
abortWithSignal(signal);
break;
}
signal.addEventListener('abort', () => abortWithSignal(signal), { once: true });
}
return controller.signal;
}
class ApiClient {
private config: Required<ApiClientConfig>;
constructor(config: ApiClientConfig = {}) {
this.config = { ...DEFAULT_CONFIG, ...config };
}
2025-11-01 15:49:29 +01:00
private async makeRequest(input: RequestInfo | URL, requestConfig: RequestConfig = {}): Promise<Response> {
const { timeout = this.config.timeout, retries = this.config.retries, ...fetchConfig } = requestConfig;
// The Hono RPC client already builds a full URL using the base it was
// constructed with (PRIVATE_SERVER_URL on the server, `${origin}/proxy`
// in the browser — see `getRequestBaseUrl()` above). Forward it as-is.
const fullUrl = typeof input === 'string' ? input : input.toString();
// Prepare headers
const headers = new Headers(fetchConfig.headers);
// Add default headers
if (!headers.has('Accept')) {
headers.set('Accept', 'application/json');
}
// Add organization ID header if available
const org = get(currentOrg);
if (org?.id) {
headers.set('cio-org-id', org.id);
}
// Handle body serialization and content-type
let requestBody = fetchConfig.body;
const isFormData = fetchConfig.body instanceof FormData;
if (fetchConfig.body && typeof fetchConfig.body === 'object' && !isFormData) {
// Auto-stringify objects (except FormData)
requestBody = JSON.stringify(fetchConfig.body);
if (!headers.has('Content-Type')) {
headers.set('Content-Type', 'application/json');
}
} else if (fetchConfig.body && !headers.has('Content-Type')) {
// Set content-type for other body types if not already set
if (typeof fetchConfig.body === 'string') {
headers.set('Content-Type', 'text/plain');
}
}
2026-04-27 11:40:38 +01:00
if (isFormData) {
// Let the browser set the multipart boundary, but keep auth/org headers.
headers.delete('Content-Type');
}
// Create abort controller for timeout
const timeoutController = new AbortController();
const timeoutId = setTimeout(() => timeoutController.abort(), timeout);
const requestInit: RequestInit = {
...fetchConfig,
2026-04-27 11:40:38 +01:00
headers,
body: requestBody,
signal: mergeAbortSignals(fetchConfig.signal, timeoutController.signal)
};
let lastError: Error | null = null;
// Retry logic with exponential backoff
for (let attempt = 0; attempt <= retries; attempt++) {
try {
const response = await this.config.customFetch(fullUrl, requestInit);
clearTimeout(timeoutId);
await this.config.onResponse(response);
if (response.ok) {
return response;
}
if (response.status === 401) {
await this.config.onAuthError();
2025-11-01 15:49:29 +01:00
throw new ApiError('Authentication failed', response.status, response.statusText, response);
}
if (response.status >= 400 && response.status < 500) {
const errorText = await response.text().catch(() => 'Unknown error');
throw new ApiError(errorText, response.status, response.statusText, response);
}
if (response.status >= 500) {
const error = new ApiError(response.statusText, response.status, response.statusText, response);
if (attempt === retries) {
throw error;
}
lastError = error;
await delay(this.config.retryDelay * Math.pow(2, attempt));
continue;
}
// Other status codes
2025-11-01 15:49:29 +01:00
throw new ApiError(`Unexpected status: ${response.statusText}`, response.status, response.statusText, response);
} catch (error) {
clearTimeout(timeoutId);
// Handle abort (timeout)
if (error instanceof Error && error.name === 'AbortError') {
if (!timeoutController.signal.aborted) {
throw error;
}
throw new ApiError('Request timeout', 408, 'Request Timeout');
}
// Handle network errors - retryable
if (error instanceof TypeError && error.message.includes('fetch')) {
const networkError = new ApiError('Network error', 0, 'Network Error');
if (attempt === retries) {
await this.config.onNetworkError(networkError);
throw networkError;
}
lastError = networkError;
await delay(this.config.retryDelay * Math.pow(2, attempt));
continue;
}
// Re-throw non-retryable errors
throw error;
}
}
// If we get here, all retries failed
throw lastError || new ApiError('Request failed after all retries');
}
// Generic request method - handles all HTTP methods
async request(input: RequestInfo | URL, config: RequestConfig = {}): Promise<Response> {
return this.makeRequest(input, config);
}
// Update configuration
updateConfig(newConfig: Partial<ApiClientConfig>): void {
this.config = { ...this.config, ...newConfig };
}
}
// Create default instance
export const apiClient = new ApiClient();
// RPC client using the new fetch wrapper
2026-03-09 11:18:22 +01:00
export const classroomio = hcWithType(getRequestBaseUrl(), {
fetch: async (input: RequestInfo | URL, requestInit?: RequestInit) => {
return apiClient.request(input, requestInit);
},
init: {
credentials: 'include' // Required for sending cookies cross-origin
}
});
// Utility functions for common use cases
export const createApiClient = (config: ApiClientConfig) => new ApiClient(config);
// Re-export utility functions for convenience
export {
handleJsonResponse,
handleTextResponse,
isApiError,
isNetworkError,
isAuthError,
isClientError,
isServerError,
isTimeoutError,
safeRequest,
safeRequestText,
safeRequestRaw,
type Result
} from './utils';
export type { InferResponseType, InferRequestType } from '@cio/api/rpc-types';
// Export base API classes
export { BaseApi, BaseApiWithErrors } from './base.svelte';
2026-03-09 11:18:22 +01:00
/**
* Gets headers with cookies and organization ID for API calls
* Use this in load functions (+page.server.ts, +layout.server.ts) to pass user session cookies
* and organization ID to API calls. This ensures authentication and organization context work correctly.
*
* @param cookies Cookies object from SvelteKit load function
* @param orgId Organization ID to include in headers
* @returns Headers object with cookie string and cio-org-id header
*
* @example
* ```typescript
* export const load = async ({ cookies, parent }) => {
* const { org } = await parent();
* const response = await classroomio.organization.$get(
* { query: { siteName } },
* getApiHeaders(cookies, org?.id)
* );
* };
* ```
*/
export function getApiHeaders(
cookies: Cookies,
orgId?: string
): { headers: { cookie: string; 'cio-org-id'?: string } } {
2026-03-12 08:57:39 +01:00
const cioCookies = getCioCookieString(cookies);
2026-03-09 11:18:22 +01:00
2026-03-11 14:26:34 +01:00
console.log('cioCookies', cioCookies);
console.log('cookies', cookies.getAll());
2026-03-09 11:18:22 +01:00
const headers: { cookie: string; 'cio-org-id'?: string } = {
cookie: cioCookies || ''
};
2026-03-11 14:26:34 +01:00
console.log('headers', headers);
console.log('orgId', orgId);
2026-03-09 11:18:22 +01:00
if (orgId) {
headers['cio-org-id'] = orgId;
}
return { headers };
}