diff --git a/.env.example b/.env.example index 3965bed..b6eb5e5 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,19 @@ -# Environment Configuration Template -NODE_ENV=development -PORT=3000 -API_URL=http://localhost:8000 +# Frontend environment — copy this file to .env before running `npm run dev` +# +# cp .env.example .env + +# Dev-server proxy target for relative "/api" requests (see server.proxy in +# vite.config.ts). Only read by `npm run dev`; nginx handles the equivalent +# "/api/" proxy in the Docker image (see nginx.conf). +VITE_INDEXER_URL=http://localhost:3001 + +# Public indexer origin used directly by browser API/SSE/WebSocket calls in +# static production deployments (such as Vercel) where no local "/api" proxy +# exists. Leave unset in local dev or Docker/nginx to keep relative "/api" +# requests routed through Vite or nginx. +# VITE_API_URL=https://indexer.example.com + +# Active network's Soroban RPC endpoint and passphrase, resolved by +# src/config/network.ts (defaults to Stellar testnet when unset). +VITE_SOROBAN_RPC_URL=https://soroban-testnet.stellar.org +VITE_NETWORK_PASSPHRASE="Test SDF Network ; September 2015" diff --git a/README.md b/README.md index 091c7af..9c8a5ba 100644 --- a/README.md +++ b/README.md @@ -150,15 +150,16 @@ npm run dev # http://localhost:5173 Copy `.env.example` → `.env` and adjust: -| Variable | Default | Description | -| ------------------------- | ------------------------------------- | ------------------------------------------ | -| `VITE_INDEXER_URL` | `http://localhost:3001` | Octraban indexer API the UI reads from | -| `VITE_SOROBAN_RPC_URL` | `https://soroban-testnet.stellar.org` | Soroban RPC endpoint | -| `VITE_NETWORK_PASSPHRASE` | `Test SDF Network ; September 2015` | Stellar network passphrase | +| Variable | Default | Description | +| ------------------------- | ------------------------------------- | -------------------------------------------------------------------------------------------- | +| `VITE_API_URL` | _(unset → `/api`)_ | Public indexer origin for static production builds (e.g. Vercel); unset uses relative `/api` | +| `VITE_INDEXER_URL` | `http://localhost:3001` | Dev-server proxy target used by `npm run dev` (`vite.config.ts`) | +| `VITE_SOROBAN_RPC_URL` | `https://soroban-testnet.stellar.org` | Soroban RPC endpoint | +| `VITE_NETWORK_PASSPHRASE` | `Test SDF Network ; September 2015` | Stellar network passphrase | The defaults point at **Stellar testnet**, so a fresh checkout talks to the live testnet deployment out of the box. -Both variables are resolved by a single module, [`src/config/network.ts`](src/config/network.ts), rather than being read ad hoc — anywhere the app builds, simulates, or signs a transaction (Sandbox template scaffolding, SDK snippet generation) reads the RPC URL and passphrase from there (directly, or via `useNetwork()`'s `active` network, which is seeded from it). Switching networks at runtime — including adding a custom RPC endpoint — is handled by the network switcher in the header, which layers builtin (testnet/mainnet/futurenet) and custom networks on top of this env-configured default. +API URLs are resolved in one place by `resolveApiBase()` / `buildApiUrl()` in [`src/api.ts`](src/api.ts), while RPC and passphrase defaults are resolved by [`src/config/network.ts`](src/config/network.ts) — anywhere the app builds, simulates, or signs a transaction (Sandbox template scaffolding, SDK snippet generation) reads the RPC URL and passphrase from there (directly, or via `useNetwork()`'s `active` network, which is seeded from it). Switching networks at runtime — including adding a custom RPC endpoint — is handled by the network switcher in the header, which layers builtin (testnet/mainnet/futurenet) and custom networks on top of this env-configured default. --- @@ -196,6 +197,17 @@ octraban_contract (deployed testnet contracts) In development, Vite proxies every `/api/*` request to `http://localhost:3001` (configured in `vite.config.ts`). The production Docker image uses nginx for the same proxy. **The frontend will display a banner and all data views will fail if the backend is not running.** +### Deployment targets + +Each hosting environment reaches the `octraban_backend` indexer as follows: + +1. **Local development (`npm run dev` — Vite proxy):** + Leave `VITE_API_URL` unset. All API, SSE, and health requests use relative `/api/*` paths, which Vite's `server.proxy` in [`vite.config.ts`](vite.config.ts) forwards to `VITE_INDEXER_URL` (`http://localhost:3001` by default). +2. **Docker / nginx ([`nginx.conf`](nginx.conf)):** + Leave `VITE_API_URL` unset at build time. Nginx serves the static `dist/` bundle on port `80` and proxies `location /api/` (including HTTP/1.1 `Upgrade` headers) to `http://indexer:3001`. +3. **Vercel ([`vercel.json`](vercel.json)):** + Vercel deploys only the static Vite bundle and has no colocated backend process. Set `VITE_API_URL=https://` in Vercel Project Environment Variables before building so `resolveApiBase()`, `buildApiUrl()`, and `resolveWsUrl()` route all `/api/*` REST, SSE, export, sandbox, health, and WebSocket traffic directly to the public indexer origin (ensure CORS on the indexer allows your Vercel domain). `vercel.json` scopes the SPA fallback rewrite to `/((?!api(?:/|$)).*)` → `/index.html` so unconfigured relative `/api` requests fail fast with `404` instead of returning `index.html` with `200 OK` and breaking JSON parsing. If you prefer same-origin HTTP proxying on Vercel, prepend `{ "source": "/api/:path*", "destination": "https:///api/:path*" }` above the SPA rule in `vercel.json` (note that WebSockets still require direct connection via `VITE_API_URL`). + --- ## 🔌 Backend API contract diff --git a/src/api.ts b/src/api.ts index 9000093..f1c6b99 100644 --- a/src/api.ts +++ b/src/api.ts @@ -6,7 +6,44 @@ import { BatchCall } from "./types/batch"; export function resolveApiBase( env: { VITE_API_URL?: string } = import.meta.env, ): string { - return `${env.VITE_API_URL ?? ""}/api`; + const configured = env.VITE_API_URL?.trim().replace(/\/+$/, "") ?? ""; + if (!configured) { + return "/api"; + } + return configured.endsWith("/api") ? configured : `${configured}/api`; +} + +export function buildApiUrl( + path: string, + env: { VITE_API_URL?: string } = import.meta.env, +): string { + const base = resolveApiBase(env); + const cleanPath = path.startsWith("/api/") + ? path.slice(4) + : path === "/api" + ? "" + : path.startsWith("/") + ? path + : `/${path}`; + return `${base}${cleanPath}`; +} + +export function resolveWsUrl( + env: { VITE_API_URL?: string } = import.meta.env, + loc: Pick = window.location, +): string { + const configured = env.VITE_API_URL?.trim() ?? ""; + if (configured) { + try { + const parsed = new URL(configured); + const wsProtocol = parsed.protocol === "https:" ? "wss:" : "ws:"; + return `${wsProtocol}//${parsed.host}`; + } catch { + // Fall back to window location when VITE_API_URL is relative or malformed. + } + } + const protocol = loc.protocol === "https:" ? "wss" : "ws"; + return `${protocol}://${loc.host}`; } const BASE = resolveApiBase(); @@ -429,13 +466,21 @@ export interface NodeMetrics { } export interface DoctorReport { - runtimes: Record; + runtimes: Record< + string, + { status: string; version?: string; message: string } + >; database: { connected: boolean; message: string }; env: Record; ports: Record; system: { disk: { status: string; freeGB?: string; message: string }; - memory: { status: string; totalGB?: string; freeGB?: string; message: string }; + memory: { + status: string; + totalGB?: string; + freeGB?: string; + message: string; + }; }; gitHooks: { status: string; message: string }; docker: { status: string; message: string }; @@ -747,7 +792,11 @@ export const api = { headers: { "Content-Type": "application/json" }, body: JSON.stringify({ databaseUrl }), }).then((r) => r.json()), - setupSaveConfig: (config: { sorobanRpcUrl: string; databaseUrl: string; pollMs: string }) => + setupSaveConfig: (config: { + sorobanRpcUrl: string; + databaseUrl: string; + pollMs: string; + }) => fetch(`${BASE}/setup/save-config`, { method: "POST", headers: { "Content-Type": "application/json" }, diff --git a/src/components/BackendStatusBanner.tsx b/src/components/BackendStatusBanner.tsx index cfa1078..4e12a98 100644 --- a/src/components/BackendStatusBanner.tsx +++ b/src/components/BackendStatusBanner.tsx @@ -11,22 +11,37 @@ */ import { useEffect, useRef, useState } from "react"; +import { buildApiUrl } from "../api"; /** How often to re-check when the backend is unreachable (ms). */ const POLL_INTERVAL = 15_000; -/** Resolved from VITE_INDEXER_URL at build time; falls back to localhost. */ -const INDEXER_URL = - (import.meta as unknown as { env: Record }).env - .VITE_INDEXER_URL ?? "http://localhost:3001"; +export function resolveIndexerDisplayUrl( + env: { VITE_API_URL?: string; VITE_INDEXER_URL?: string } = import.meta.env, +): string { + return ( + env.VITE_API_URL?.trim() || + env.VITE_INDEXER_URL?.trim() || + "http://localhost:3001" + ); +} + +/** Resolved from VITE_API_URL or VITE_INDEXER_URL at build time; falls back to localhost. */ +const INDEXER_URL = resolveIndexerDisplayUrl(); type Status = "checking" | "ok" | "unreachable"; -async function checkHealth(): Promise { +export async function checkHealth( + env: { VITE_API_URL?: string } = import.meta.env, +): Promise { try { - const res = await fetch("/api/health", { + const res = await fetch(buildApiUrl("/health", env), { signal: AbortSignal.timeout(4000), }); + const contentType = res.headers?.get("content-type") ?? ""; + if (contentType.includes("text/html")) { + return false; + } return res.ok; } catch { return false; diff --git a/src/components/ExportButton.tsx b/src/components/ExportButton.tsx index 6be59bc..9055deb 100644 --- a/src/components/ExportButton.tsx +++ b/src/components/ExportButton.tsx @@ -1,4 +1,5 @@ import { useState, useRef, useEffect } from "react"; +import { buildApiUrl } from "../api"; type ExportTarget = "events" | "contracts"; type ExportFormat = "csv" | "json"; @@ -29,7 +30,7 @@ export default function ExportButton({ for (const [k, v] of Object.entries(params)) { if (v) q.set(k, v); } - return `/api/export/${target}?${q}`; + return buildApiUrl(`/export/${target}?${q}`); } function download(format: ExportFormat) { diff --git a/src/components/SimulateButton.tsx b/src/components/SimulateButton.tsx index 90a04bb..0345878 100644 --- a/src/components/SimulateButton.tsx +++ b/src/components/SimulateButton.tsx @@ -1,5 +1,6 @@ // Contract execution simulation estimator button import { useState } from "react"; +import { buildApiUrl } from "../api"; interface SimResult { success: boolean; @@ -27,7 +28,7 @@ export default function SimulateButton({ setLoading(true); setResult(null); try { - const res = await fetch("/api/simulate", { + const res = await fetch(buildApiUrl("/simulate"), { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ diff --git a/src/hooks/useEventStream.ts b/src/hooks/useEventStream.ts index 8c1786b..57ed3cb 100644 --- a/src/hooks/useEventStream.ts +++ b/src/hooks/useEventStream.ts @@ -3,12 +3,11 @@ * Connects to the backend WebSocket and calls onEvent for each incoming event. */ import { useEffect } from "react"; -import type { DecodedEvent } from "../api"; +import { resolveWsUrl, type DecodedEvent } from "../api"; export function useEventStream(onEvent: (ev: DecodedEvent) => void) { useEffect(() => { - const protocol = window.location.protocol === "https:" ? "wss" : "ws"; - const ws = new WebSocket(`${protocol}://${window.location.host}`); + const ws = new WebSocket(resolveWsUrl()); ws.onmessage = (msg) => { try { diff --git a/src/hooks/useTxStatus.ts b/src/hooks/useTxStatus.ts index 728104b..c89b277 100644 --- a/src/hooks/useTxStatus.ts +++ b/src/hooks/useTxStatus.ts @@ -6,6 +6,7 @@ */ import { useEffect, useState } from "react"; +import { buildApiUrl } from "../api"; export type TxStatus = | "pending" @@ -28,7 +29,9 @@ export function useTxStatus(txHash: string | null | undefined) { useEffect(() => { if (!txHash) return; - const es = new EventSource(`/api/transactions/${txHash}/status/stream`); + const es = new EventSource( + buildApiUrl(`/transactions/${txHash}/status/stream`), + ); es.onmessage = (e) => { try { @@ -45,7 +48,7 @@ export function useTxStatus(txHash: string | null | undefined) { es.onerror = () => { es.close(); // Fallback: single poll - fetch(`/api/transactions/${txHash}/status`) + fetch(buildApiUrl(`/transactions/${txHash}/status`)) .then((r) => r.json()) .then(setPayload) .catch(() => {}); diff --git a/src/services/sandbox-api.ts b/src/services/sandbox-api.ts index a9e9dee..b781398 100644 --- a/src/services/sandbox-api.ts +++ b/src/services/sandbox-api.ts @@ -1,7 +1,6 @@ +import { buildApiUrl } from "../api"; import { SandboxFile } from "./webcontainer"; -const API_BASE = import.meta.env.VITE_API_URL || "http://localhost:3001"; - export interface SavedSandbox { sandboxId: string; templateId: string; @@ -22,7 +21,7 @@ export async function saveSandbox( filesObj[key] = file; } - const response = await fetch(`${API_BASE}/api/sandbox`, { + const response = await fetch(buildApiUrl("/sandbox"), { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ @@ -39,7 +38,7 @@ export async function saveSandbox( } export async function loadSandbox(sandboxId: string): Promise { - const response = await fetch(`${API_BASE}/api/sandbox/${sandboxId}`); + const response = await fetch(buildApiUrl(`/sandbox/${sandboxId}`)); if (!response.ok) { throw new Error(`Failed to load sandbox: ${response.statusText}`); @@ -56,7 +55,7 @@ export async function listSandboxes( total: number; }> { const response = await fetch( - `${API_BASE}/api/sandboxes?limit=${limit}&offset=${offset}`, + buildApiUrl(`/sandboxes?limit=${limit}&offset=${offset}`), ); if (!response.ok) { @@ -67,7 +66,7 @@ export async function listSandboxes( } export async function deleteSandbox(sandboxId: string): Promise { - const response = await fetch(`${API_BASE}/api/sandbox/${sandboxId}`, { + const response = await fetch(buildApiUrl(`/sandbox/${sandboxId}`), { method: "DELETE", }); diff --git a/test/BackendStatusBanner.test.tsx b/test/BackendStatusBanner.test.tsx index 91c001b..bf41721 100644 --- a/test/BackendStatusBanner.test.tsx +++ b/test/BackendStatusBanner.test.tsx @@ -11,7 +11,10 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import BackendStatusBanner from "../src/components/BackendStatusBanner"; +import BackendStatusBanner, { + checkHealth, + resolveIndexerDisplayUrl, +} from "../src/components/BackendStatusBanner"; // --------------------------------------------------------------------------- // Helpers @@ -132,4 +135,41 @@ describe("BackendStatusBanner", () => { expect(screen.getByRole("alert")).toBeInTheDocument(); }); }); + + it("resolves health URL from VITE_API_URL and rejects SPA HTML fallbacks", async () => { + global.fetch = vi.fn().mockResolvedValueOnce({ + ok: true, + status: 200, + headers: new Headers({ "content-type": "application/json" }), + }); + await expect( + checkHealth({ VITE_API_URL: "https://indexer.example.com/" }), + ).resolves.toBe(true); + expect(global.fetch).toHaveBeenCalledWith( + "https://indexer.example.com/api/health", + expect.any(Object), + ); + + global.fetch = vi.fn().mockResolvedValueOnce({ + ok: true, + status: 200, + headers: new Headers({ "content-type": "text/html; charset=utf-8" }), + }); + await expect(checkHealth({})).resolves.toBe(false); + }); + + it("prefers VITE_API_URL over VITE_INDEXER_URL for banner display text", () => { + expect( + resolveIndexerDisplayUrl({ + VITE_API_URL: "https://indexer.example.com", + VITE_INDEXER_URL: "http://localhost:3001", + }), + ).toBe("https://indexer.example.com"); + expect( + resolveIndexerDisplayUrl({ + VITE_INDEXER_URL: "http://localhost:4000", + }), + ).toBe("http://localhost:4000"); + expect(resolveIndexerDisplayUrl({})).toBe("http://localhost:3001"); + }); }); diff --git a/test/api-base.test.ts b/test/api-base.test.ts index 4c582b2..38130d3 100644 --- a/test/api-base.test.ts +++ b/test/api-base.test.ts @@ -1,5 +1,15 @@ -import { describe, it, expect } from "vitest"; -import { resolveApiBase } from "../src/api"; +import { describe, it, expect, vi, afterEach } from "vitest"; +import { buildApiUrl, resolveApiBase, resolveWsUrl } from "../src/api"; +import { + saveSandbox, + loadSandbox, + listSandboxes, + deleteSandbox, +} from "../src/services/sandbox-api"; + +afterEach(() => { + vi.restoreAllMocks(); +}); describe("resolveApiBase", () => { it("returns the env value joined with /api when VITE_API_URL is set", () => { @@ -8,7 +18,92 @@ describe("resolveApiBase", () => { ); }); - it("falls back to the relative /api path (dev proxy) when unset", () => { + it("strips trailing slashes before appending /api", () => { + expect( + resolveApiBase({ VITE_API_URL: "https://indexer.example.com///" }), + ).toBe("https://indexer.example.com/api"); + }); + + it("does not duplicate /api when VITE_API_URL already ends with /api", () => { + expect( + resolveApiBase({ VITE_API_URL: "https://indexer.example.com/api/" }), + ).toBe("https://indexer.example.com/api"); + }); + + it("falls back to the relative /api path when unset or blank", () => { expect(resolveApiBase({})).toBe("/api"); + expect(resolveApiBase({ VITE_API_URL: " " })).toBe("/api"); + }); +}); + +describe("buildApiUrl", () => { + it("builds relative /api paths when VITE_API_URL is unset", () => { + expect(buildApiUrl("/health", {})).toBe("/api/health"); + expect(buildApiUrl("events?page=1", {})).toBe("/api/events?page=1"); + expect(buildApiUrl("/api/simulate", {})).toBe("/api/simulate"); + expect(buildApiUrl("/api", {})).toBe("/api"); + }); + + it("builds absolute indexer URLs when VITE_API_URL is configured", () => { + const env = { VITE_API_URL: "https://indexer.example.com/" }; + expect(buildApiUrl("/health", env)).toBe( + "https://indexer.example.com/api/health", + ); + expect(buildApiUrl("/api/sandbox/abc", env)).toBe( + "https://indexer.example.com/api/sandbox/abc", + ); + }); +}); + +describe("resolveWsUrl", () => { + it("derives wss/ws host from VITE_API_URL when configured", () => { + expect( + resolveWsUrl( + { VITE_API_URL: "https://indexer.example.com/api" }, + { protocol: "https:", host: "app.vercel.app" }, + ), + ).toBe("wss://indexer.example.com"); + + expect( + resolveWsUrl( + { VITE_API_URL: "http://localhost:3001" }, + { protocol: "http:", host: "localhost:5173" }, + ), + ).toBe("ws://localhost:3001"); + }); + + it("falls back to window location when VITE_API_URL is unset or relative", () => { + expect( + resolveWsUrl({}, { protocol: "https:", host: "explorer.example.com" }), + ).toBe("wss://explorer.example.com"); + expect( + resolveWsUrl( + { VITE_API_URL: "/not-an-origin" }, + { protocol: "http:", host: "localhost:5173" }, + ), + ).toBe("ws://localhost:5173"); + }); +}); + +describe("sandbox-api routing", () => { + it("routes save, load, list, and delete calls through /api/sandbox*", async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + statusText: "OK", + json: async () => ({ sandboxes: [], total: 0 }), + }); + global.fetch = fetchMock; + + await saveSandbox("sb-1", "tpl", new Map([["index.ts", { content: "1" }]])); + await loadSandbox("sb-1"); + await listSandboxes(10, 5); + await deleteSandbox("sb-1"); + + expect(fetchMock.mock.calls.map((c) => c[0])).toEqual([ + "/api/sandbox", + "/api/sandbox/sb-1", + "/api/sandboxes?limit=10&offset=5", + "/api/sandbox/sb-1", + ]); }); }); diff --git a/vercel.json b/vercel.json index c084b71..5c1df2f 100644 --- a/vercel.json +++ b/vercel.json @@ -3,7 +3,7 @@ "outputDirectory": "dist", "rewrites": [ { - "source": "/((?!api/).*)", + "source": "/((?!api(?:/|$)).*)", "destination": "/index.html" } ]