/* * Copyright (C) 2026 Mateusz GruszczyƄski @linuxiarz.pl * Source-Available Code / Dual-Licensed. * * Free for non-commercial and evaluation use under terms of BSL/GPLv3. * Commercial or production use requires a valid paid license. * See LICENSE file in repository root for details. */ import { logDebug, logError, logWarn } from "@rustpad/logger"; const DEFAULT_ERRORS = { 400: "Invalid request.", 401: "Authentication required.", 403: "Access denied.", 404: "The requested resource was not found.", 405: "This operation is not allowed.", 408: "The request timed out. Try again.", 409: "The requested change conflicts with existing data.", 413: "The selected file exceeds the allowed upload limit.", 425: "The request was sent too early. Try again.", 429: "Too many requests. Try again later.", 500: "Server error. Try again later.", 502: "The server returned an invalid response. Try again.", 503: "Service temporarily unavailable.", 504: "The server took too long to respond. Try again.", }; function formatBytes(bytes) { if (bytes >= 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(bytes % (1024 * 1024) ? 1 : 0)} MB`; if (bytes >= 1024) return `${Math.ceil(bytes / 1024)} KB`; return `${bytes} B`; } function clearExpiredSession() { localStorage.removeItem("rustpad:auth-state"); localStorage.removeItem("rustpad:auth-token"); sessionStorage.removeItem("rustpad:auth-token"); localStorage.removeItem("rustpad:nickname"); sessionStorage.removeItem("rustpad:nickname"); document.cookie = "rustpad_nickname=; Path=/; SameSite=Lax; Max-Age=0"; window.dispatchEvent(new CustomEvent("rustpad:session-expired")); } async function clearSessionIfInvalid() { if (!localStorage.getItem("rustpad:auth-state")) return; try { const response = await fetch("/api/auth/me", { signal: AbortSignal.timeout(5000), }); if (response.status === 401) clearExpiredSession(); } catch { // A network failure does not prove that the session is invalid. } } function validateUploadSize(body) { if (!(body instanceof FormData)) return; const maxBytes = Number(window.__RUSTPAD_CONFIG__?.uploadMaxSizeBytes || 0); if (!Number.isFinite(maxBytes) || maxBytes <= 0) return; for (const value of body.values()) { if (value instanceof File && value.size > maxBytes) { const error = new Error(`The selected file is ${formatBytes(value.size)}. The upload limit is ${formatBytes(maxBytes)}.`); error.status = 413; throw error; } } } function requestHeaders(options, body) { const headers = new Headers(options.headers || {}); headers.delete("x-rustpad-user-token"); if (!(body instanceof FormData) && !headers.has("content-type")) headers.set("content-type", "application/json"); return headers; } function requestError(status, data = {}) { const error = new Error(data.error || DEFAULT_ERRORS[status] || `Request failed (${status}).`); error.status = status; return error; } function formDataFileSize(body) { if (!(body instanceof FormData)) return 0; let size = 0; for (const value of body.values()) if (value instanceof File) size += value.size; return size; } export async function api(path, options = {}) { validateUploadSize(options.body); const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 12000); try { const headers = requestHeaders(options, options.body); const started = performance.now(); logDebug("api.request", { method: options.method || "GET", path }); const response = await fetch(path, { ...options, headers, signal: controller.signal }); const durationMs = Math.round(performance.now() - started); logDebug("api.response", { method: options.method || "GET", path, status: response.status, durationMs }); const contentType = response.headers.get("content-type") || ""; const data = contentType.includes("application/json") ? await response.json().catch(() => ({})) : {}; if (!response.ok) { if (response.status === 401) await clearSessionIfInvalid(); const error = requestError(response.status, data); logWarn("api.failed", { method: options.method || "GET", path, status: response.status, message: error.message }); throw error; } return data; } catch (error) { if (error.name === "AbortError") { logWarn("api.timeout", { method: options.method || "GET", path }); throw new Error("Timed out"); } logError("api.network_error", error, { method: options.method || "GET", path }); if (options.body instanceof FormData && error instanceof TypeError) { throw new Error("Upload failed before the server returned a response. The file may exceed the server or proxy upload limit."); } throw error; } finally { clearTimeout(timeout); } } export function uploadWithProgress(path, options = {}) { validateUploadSize(options.body); const method = options.method || "POST"; const fallbackTotal = formDataFileSize(options.body); const stallTimeoutMs = Number(options.stallTimeoutMs) > 0 ? Number(options.stallTimeoutMs) : 90000; const responseTimeoutMs = Number(options.responseTimeoutMs) > 0 ? Number(options.responseTimeoutMs) : 120000; return new Promise((resolve, reject) => { const xhr = new XMLHttpRequest(); const headers = requestHeaders(options, options.body); const started = performance.now(); let lastAt = started; let lastLoaded = 0; let speed = 0; let stallTimer = null; let responseTimer = null; let stalled = false; let responseTimedOut = false; let externallyAborted = false; const clearStallTimer = () => { clearTimeout(stallTimer); stallTimer = null; }; const armStallTimer = () => { clearStallTimer(); stallTimer = setTimeout(() => { stalled = true; xhr.abort(); }, stallTimeoutMs); }; const cleanup = () => { clearStallTimer(); clearTimeout(responseTimer); responseTimer = null; options.signal?.removeEventListener("abort", abortFromSignal); }; const abortFromSignal = () => { externallyAborted = true; xhr.abort(); }; const fail = error => { cleanup(); reject(error); }; xhr.open(method, path, true); xhr.responseType = "text"; for (const [name, value] of headers.entries()) xhr.setRequestHeader(name, value); xhr.upload.addEventListener("loadstart", () => { armStallTimer(); options.onProgress?.({ loaded: 0, total: fallbackTotal, speed: 0, percent: 0 }); }); xhr.upload.addEventListener("progress", event => { const now = performance.now(); const elapsedSeconds = Math.max((now - lastAt) / 1000, 0.001); const deltaBytes = Math.max(0, event.loaded - lastLoaded); const instantaneousSpeed = deltaBytes / elapsedSeconds; speed = speed > 0 ? speed * 0.72 + instantaneousSpeed * 0.28 : instantaneousSpeed; lastAt = now; lastLoaded = event.loaded; const total = event.lengthComputable ? event.total : fallbackTotal; const percent = total > 0 ? Math.min(100, (event.loaded / total) * 100) : null; options.onProgress?.({ loaded: event.loaded, total, speed, percent }); if (total > 0 && event.loaded >= total) clearStallTimer(); else armStallTimer(); }); xhr.upload.addEventListener("load", event => { clearStallTimer(); clearTimeout(responseTimer); responseTimer = setTimeout(() => { responseTimedOut = true; xhr.abort(); }, responseTimeoutMs); const total = event.lengthComputable ? event.total : fallbackTotal; options.onProgress?.({ loaded: total || lastLoaded, total, speed, percent: total > 0 ? 100 : null, phase: "processing" }); }); xhr.addEventListener("load", () => { cleanup(); const durationMs = Math.round(performance.now() - started); logDebug("api.response", { method, path, status: xhr.status, durationMs }); let data = {}; try { data = xhr.responseText ? JSON.parse(xhr.responseText) : {}; } catch { } if (xhr.status >= 200 && xhr.status < 300) { resolve(data); return; } if (xhr.status === 401) void clearSessionIfInvalid(); const error = requestError(xhr.status, data); logWarn("api.failed", { method, path, status: xhr.status, message: error.message }); reject(error); }); xhr.addEventListener("error", () => { const error = new Error("Upload failed before the server returned a response. Check the connection and try again."); logError("api.network_error", error, { method, path }); fail(error); }); xhr.addEventListener("abort", () => { const error = new Error(stalled ? "Upload stopped making progress. Check the connection and try again." : responseTimedOut ? "The file was sent, but the server did not finish processing it. Try again." : externallyAborted ? "Upload cancelled." : "Upload interrupted. Try again."); error.name = externallyAborted ? "AbortError" : "UploadError"; logWarn(stalled ? "api.upload_stalled" : responseTimedOut ? "api.upload_response_timeout" : "api.upload_aborted", { method, path }); fail(error); }); if (options.signal) { if (options.signal.aborted) { externallyAborted = true; const error = new Error("Upload cancelled."); error.name = "AbortError"; fail(error); return; } options.signal.addEventListener("abort", abortFromSignal, { once: true }); } logDebug("api.request", { method, path }); xhr.send(options.body ?? null); }); }