import type { ApiResponse } from "../../../src/api/contracts/common.js"; export const API_URL = process.env.API_URL || "http://localhost:3001"; const cookieJars = new Map>(); export function getCookieJar(sessionId = "default"): Map { if (!cookieJars.has(sessionId)) { cookieJars.set(sessionId, new Map()); } return cookieJars.get(sessionId)!; } export function clearCookieJar(sessionId = "default") { const jar = cookieJars.get(sessionId); if (jar) { jar.clear(); } } export type FetchOptions = RequestInit & { sessionId?: string; }; export type ApiResponseWithStatus = ApiResponse & { $status: number; $statusText: string; $headers: Headers; }; export async function apiFetch( endpoint: string, options: FetchOptions = {}, ): Promise> { const { sessionId = "default", ...fetchArgs } = options; const url = endpoint.startsWith("http") ? endpoint : `${API_URL}${endpoint}`; // Prepare headers const headersInit: Record = { "Content-Type": "application/json", ...(fetchArgs.headers as Record), }; // Inject cookies from the specific session's jar const jar = getCookieJar(sessionId); if (jar.size > 0) { const cookiePairs: string[] = []; for (const [key, value] of jar.entries()) { cookiePairs.push(`${key}=${value}`); } headersInit["Cookie"] = cookiePairs.join("; "); } const finalOptions: RequestInit = { ...fetchArgs, headers: headersInit, }; if (process.env.DEBUG_E2E) { console.log(`[E2E Request] ${options.method || "GET"} ${url}`, { headers: headersInit, body: fetchArgs.body, }); } let response: Response; try { response = await fetch(url, finalOptions); } catch (err: any) { if (process.env.DEBUG_E2E) { console.log(`[E2E Request Failed] ${options.method || "GET"} ${url} Error:`, err.message); } throw err; } // Extract and save cookies const setCookieHeader = typeof response.headers.getSetCookie === "function" ? response.headers.getSetCookie() : response.headers.get("set-cookie"); if (setCookieHeader) { const cookies = Array.isArray(setCookieHeader) ? setCookieHeader : [setCookieHeader]; for (const cookieStr of cookies) { const parts = cookieStr.split(/,(?=[^;]*=)/); for (const part of parts) { const cleanPart = part.trim(); if (!cleanPart) continue; const [cookieKV] = cleanPart.split(";"); const [key, ...valParts] = cookieKV.split("="); if (key) { jar.set(key.trim(), valParts.join("=").trim()); } } } } const bodyText = await response.text(); if (process.env.DEBUG_E2E) { console.log(`[E2E Response] ${response.status} ${response.statusText} ${url}`, { body: bodyText, }); } let json: any; try { json = JSON.parse(bodyText); } catch (_parseErr) { throw new Error( `Response body is not valid JSON. Status: ${response.status}. Body: ${bodyText}`, ); } // Envelope validation if (json === null || typeof json !== "object") { throw new Error(`Response envelope is not an object: ${bodyText}`); } if (!("data" in json) || !("meta" in json) || !("error" in json)) { throw new Error( `Response envelope is missing required keys {data, meta, error}. Body: ${bodyText}`, ); } if (json.meta === null || typeof json.meta !== "object") { throw new Error(`Response envelope 'meta' must be a non-null object. Body: ${bodyText}`); } if (typeof json.meta.requestId !== "string" || !json.meta.requestId) { throw new Error( `Response envelope 'meta.requestId' must be a non-empty string. Body: ${bodyText}`, ); } if (json.error !== null) { if (typeof json.error !== "object") { throw new Error(`Response envelope 'error' must be null or an object. Body: ${bodyText}`); } if (typeof json.error.code !== "string") { throw new Error(`Response envelope 'error.code' must be a string. Body: ${bodyText}`); } if (typeof json.error.message !== "string") { throw new Error(`Response envelope 'error.message' must be a string. Body: ${bodyText}`); } if (json.data !== null) { throw new Error( `Response envelope cannot have both 'data' and 'error' populated. Body: ${bodyText}`, ); } } const result = json as ApiResponse; Object.defineProperties(result, { $status: { value: response.status, enumerable: false }, $statusText: { value: response.statusText, enumerable: false }, $headers: { value: response.headers, enumerable: false }, }); return result as ApiResponseWithStatus; }