2025-09-21 19:35:41 +01:00
import { type ApiClientConfig , ApiError , type RequestConfig } from './types' ;
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 00:30:25 +01:00
// Browser: same-origin via the Cloudflare Worker /api proxy. Keeps auth
// cookies host-only on whichever domain the user is currently visiting
// (app.classroomio.com, <org>.classroomio.school, or a BYOD domain).
return ` ${ window . location . origin } /api` ;
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-03-09 11:18:22 +01:00
const inputUrl = typeof input === 'string' ? input : input.toString ();
const inputUrlObject = new URL ( inputUrl , 'http://_' );
// We are dynamically always getting the base URL to make sure we are getting the right url for the right environment.
// For example, when you use classroomio.account.$get(), on the server in a docker container, we want it to hit the api container directly instead of hitting the public url which is extra bandwidth.
// However when on the client, we want it to hit the public url which still points to the api container but this time with some bandwidth.
const base = ( getRequestBaseUrl () ?? this . config . baseURL ) ? . replace ( /\/$/ , '' ) ?? '' ;
const fullUrl = ` ${ base }${ inputUrlObject . pathname }${ inputUrlObject . search } ` ;
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 };
}