2025-09-21 19:35:41 +01:00
|
|
|
import { type ApiClientConfig, ApiError, type RequestConfig } from './types';
|
|
|
|
|
|
2026-05-21 11:29:47 +01:00
|
|
|
import { dev } from '$app/environment';
|
2025-09-21 19:35:41 +01:00
|
|
|
import { DEFAULT_CONFIG } from './constants';
|
|
|
|
|
import { delay } from './utils';
|
|
|
|
|
import { hcWithType } from '@cio/api/rpc-types';
|
2025-12-02 09:54:38 +01:00
|
|
|
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.
|
2026-05-21 11:29:47 +01:00
|
|
|
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.
|
2026-05-20 07:03:18 +01:00
|
|
|
return `${window.location.origin}/proxy`;
|
2026-03-09 11:18:22 +01:00
|
|
|
};
|
2025-09-21 19:35:41 +01:00
|
|
|
|
2026-03-30 08:24:55 +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;
|
|
|
|
|
}
|
|
|
|
|
|
2025-09-21 19:35:41 +01:00
|
|
|
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> {
|
2025-11-24 01:00:50 +01:00
|
|
|
const { timeout = this.config.timeout, retries = this.config.retries, ...fetchConfig } = requestConfig;
|
2025-09-21 19:35:41 +01:00
|
|
|
|
2026-05-20 07:20:09 +01:00
|
|
|
// 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();
|
2025-09-21 19:35:41 +01:00
|
|
|
|
|
|
|
|
// Prepare headers
|
|
|
|
|
const headers = new Headers(fetchConfig.headers);
|
|
|
|
|
|
|
|
|
|
// Add default headers
|
|
|
|
|
if (!headers.has('Accept')) {
|
|
|
|
|
headers.set('Accept', 'application/json');
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-02 09:54:38 +01:00
|
|
|
// Add organization ID header if available
|
|
|
|
|
const org = get(currentOrg);
|
|
|
|
|
if (org?.id) {
|
|
|
|
|
headers.set('cio-org-id', org.id);
|
|
|
|
|
}
|
|
|
|
|
|
2025-09-21 19:35:41 +01:00
|
|
|
// Handle body serialization and content-type
|
|
|
|
|
let requestBody = fetchConfig.body;
|
2025-12-02 09:54:38 +01:00
|
|
|
const isFormData = fetchConfig.body instanceof FormData;
|
|
|
|
|
|
|
|
|
|
if (fetchConfig.body && typeof fetchConfig.body === 'object' && !isFormData) {
|
2025-09-21 19:35:41 +01:00
|
|
|
// 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');
|
|
|
|
|
}
|
|
|
|
|
|
2025-09-21 19:35:41 +01:00
|
|
|
// Create abort controller for timeout
|
2026-03-30 08:24:55 +01:00
|
|
|
const timeoutController = new AbortController();
|
|
|
|
|
const timeoutId = setTimeout(() => timeoutController.abort(), timeout);
|
2025-09-21 19:35:41 +01:00
|
|
|
|
|
|
|
|
const requestInit: RequestInit = {
|
|
|
|
|
...fetchConfig,
|
2026-04-27 11:40:38 +01:00
|
|
|
headers,
|
2025-09-21 19:35:41 +01:00
|
|
|
body: requestBody,
|
2026-03-30 08:24:55 +01:00
|
|
|
signal: mergeAbortSignals(fetchConfig.signal, timeoutController.signal)
|
2025-09-21 19:35:41 +01:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
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);
|
2025-09-21 19:35:41 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (response.status >= 400 && response.status < 500) {
|
|
|
|
|
const errorText = await response.text().catch(() => 'Unknown error');
|
2025-11-24 01:00:50 +01:00
|
|
|
throw new ApiError(errorText, response.status, response.statusText, response);
|
2025-09-21 19:35:41 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (response.status >= 500) {
|
2025-11-24 01:00:50 +01:00
|
|
|
const error = new ApiError(response.statusText, response.status, response.statusText, response);
|
2025-09-21 19:35:41 +01:00
|
|
|
|
|
|
|
|
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);
|
2025-09-21 19:35:41 +01:00
|
|
|
} catch (error) {
|
|
|
|
|
clearTimeout(timeoutId);
|
|
|
|
|
|
|
|
|
|
// Handle abort (timeout)
|
|
|
|
|
if (error instanceof Error && error.name === 'AbortError') {
|
2026-03-30 08:24:55 +01:00
|
|
|
if (!timeoutController.signal.aborted) {
|
|
|
|
|
throw error;
|
|
|
|
|
}
|
|
|
|
|
|
2025-09-21 19:35:41 +01:00
|
|
|
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(), {
|
2025-09-21 19:35:41 +01:00
|
|
|
fetch: async (input: RequestInfo | URL, requestInit?: RequestInit) => {
|
|
|
|
|
return apiClient.request(input, requestInit);
|
2025-11-24 01:00:50 +01:00
|
|
|
},
|
|
|
|
|
init: {
|
|
|
|
|
credentials: 'include' // Required for sending cookies cross-origin
|
2025-09-21 19:35:41 +01:00
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// 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';
|
2025-11-24 01:00:50 +01:00
|
|
|
|
|
|
|
|
// 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);
|
2026-03-11 14:38:03 +01:00
|
|
|
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 };
|
|
|
|
|
}
|