import { ExtractRequestBody, ExtractResponseBody, Routes } from "../rpc/routes"; import { SerializableRequest, SerializableResponse, UploadProgress, } from "../rpc/types"; export type RPCRequest = { id: string; route: K; body: ExtractRequestBody; }; export type RPCResponse = { id: string; route: K; body: ExtractResponseBody; }; export type RPCError = { id: string; error: string; }; export type RPCStreamChunk = { id: string; chunk: string; }; export type RPCStreamEnd = { id: string; route: K; body: ExtractResponseBody; }; export type RPCFetchOptions = { baseUrl: string; token?: string; streaming?: boolean; debug?: boolean; onUploadProgress?: (progress: UploadProgress) => void; }; export type RPCManagerMessage = | { id: string; type: "end"; response: SerializableResponse; } | { id: string; type: "chunk"; chunk: string; } | { id: string; type: "error"; error: string; }; function formatRPCErrorMessage({ status, statusText, body, }: { status: number; statusText: string; body?: string; }) { const trimmedBody = body?.trim(); let errorMessageFromBody: string | undefined; if (trimmedBody) { try { const parsed = JSON.parse(trimmedBody); errorMessageFromBody = typeof parsed === "string" ? parsed : typeof parsed?.message === "string" ? parsed.message : typeof parsed?.error === "string" ? parsed.error : typeof parsed?.error?.message === "string" ? parsed.error.message : trimmedBody; } catch { errorMessageFromBody = trimmedBody; } } if (errorMessageFromBody) { return `RPC request failed (${status}): ${errorMessageFromBody}`; } if (statusText) { return `RPC request failed (${status}): ${statusText}`; } return `RPC request failed (${status})`; } export async function* fetchRPCStream( request: SerializableRequest, options: RPCFetchOptions ): AsyncGenerator { const { baseUrl, token, debug = false } = options; try { const requestUrl = new URL(request.url, baseUrl); requestUrl.protocol = requestUrl.protocol === "ws:" ? "http:" : "https:"; if (debug) { console.info("Fetching RPC stream", { url: requestUrl.toString(), body: request.options.body, }); } const response = await fetch(requestUrl, { ...request.options, headers: { ...request.options?.headers, ...(token ? { Authorization: `Bearer ${token}` } : {}), }, }); if (!response.ok) { const text = await response.text(); throw new Error( formatRPCErrorMessage({ status: response.status, statusText: response.statusText, body: text, }) ); } const reader = response.body?.getReader(); const decoder = new TextDecoder(); if (!reader) { throw new Error("Stream not available"); } try { while (true) { const { done, value } = await reader.read(); if (done) { yield { id: request.id!, type: "end", response: { status: response.status, statusText: response.statusText, headers: Object.fromEntries(response.headers.entries()), body: "", }, }; break; } const text = decoder.decode(value); yield { id: request.id!, type: "chunk", chunk: text }; } } finally { reader.releaseLock(); } } catch (error) { yield { id: request.id!, type: "error", error: error instanceof Error ? error.message : String(error), }; } } export async function fetchRPC( request: SerializableRequest, options: RPCFetchOptions ): Promise { const { baseUrl, token, debug = false } = options; try { const requestUrl = new URL(request.url, baseUrl); requestUrl.protocol = requestUrl.protocol === "ws:" ? "http:" : "https:"; if (debug) { console.info("Fetching RPC", { url: requestUrl.toString(), body: request.options.body, }); } const shouldUseXHR = typeof XMLHttpRequest !== "undefined" && typeof window !== "undefined" && !!options.onUploadProgress && !request.streaming; if (!shouldUseXHR) { const response = await fetch(requestUrl, { ...request.options, headers: { ...request.options?.headers, ...(token ? { Authorization: `Bearer ${token}` } : {}), }, }); if (!response.ok) { const text = await response.text(); throw new Error( formatRPCErrorMessage({ status: response.status, statusText: response.statusText, body: text, }) ); } const text = await response.text(); if (debug) { console.info("RPC response", { text }); } return { id: request.id!, type: "end", response: { status: response.status, statusText: response.statusText, headers: Object.fromEntries(response.headers.entries()), body: text, }, }; } // XHR path for upload progress const headers = { ...request.options?.headers, ...(token ? { Authorization: `Bearer ${token}` } : {}), } as Record; const method = request.options.method ?? "GET"; const xhrResponse = await new Promise( (resolve, reject) => { const xhr = new XMLHttpRequest(); xhr.open(method, requestUrl.toString(), true); Object.entries(headers).forEach(([key, value]) => { try { xhr.setRequestHeader(key, value); } catch { // Some headers may be restricted; ignore } }); if (options.onUploadProgress) { xhr.upload.onprogress = (event) => { const loaded = event.loaded; const total = event.lengthComputable ? event.total : undefined; const percent = total ? (loaded / total) * 100 : undefined; options.onUploadProgress?.({ loaded, total, percent }); }; } xhr.onreadystatechange = () => { if (xhr.readyState !== XMLHttpRequest.DONE) return; const responseHeaders: Record = {}; const raw = xhr.getAllResponseHeaders(); raw .trim() .split(/\r?\n/) .forEach((line) => { const index = line.indexOf(": "); if (index > 0) { const key = line.slice(0, index).toLowerCase(); const value = line.slice(index + 2); responseHeaders[key] = value; } }); resolve({ id: request.id, status: xhr.status, statusText: xhr.statusText, headers: responseHeaders, body: xhr.responseText ?? "", }); }; xhr.onerror = () => { reject(new Error("Network error")); }; xhr.onabort = () => { reject(new Error("Request aborted")); }; const body = request.options.body ?? undefined; xhr.send(body); } ); if (xhrResponse.status < 200 || xhrResponse.status >= 300) { throw new Error( formatRPCErrorMessage({ status: xhrResponse.status, statusText: xhrResponse.statusText, body: xhrResponse.body, }) ); } if (debug) { console.info("RPC response", { text: xhrResponse.body }); } return { id: request.id!, type: "end", response: xhrResponse, }; } catch (error) { return { id: request.id!, type: "error", error: error instanceof Error ? error.message : String(error), }; } }