Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 19 additions & 4 deletions .env.example
Original file line number Diff line number Diff line change
@@ -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"
24 changes: 18 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

---

Expand Down Expand Up @@ -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://<your-indexer-host>` 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://<your-indexer-host>/api/:path*" }` above the SPA rule in `vercel.json` (note that WebSockets still require direct connection via `VITE_API_URL`).

---

## 🔌 Backend API contract
Expand Down
57 changes: 53 additions & 4 deletions src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Location, "protocol" | "host"> = 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();
Expand Down Expand Up @@ -429,13 +466,21 @@ export interface NodeMetrics {
}

export interface DoctorReport {
runtimes: Record<string, { status: string; version?: string; message: string }>;
runtimes: Record<
string,
{ status: string; version?: string; message: string }
>;
database: { connected: boolean; message: string };
env: Record<string, { status: string; value: string; message: string }>;
ports: Record<string, { status: string; inUse: boolean; message: string }>;
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 };
Expand Down Expand Up @@ -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" },
Expand Down
27 changes: 21 additions & 6 deletions src/components/BackendStatusBanner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> }).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<boolean> {
export async function checkHealth(
env: { VITE_API_URL?: string } = import.meta.env,
): Promise<boolean> {
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;
Expand Down
3 changes: 2 additions & 1 deletion src/components/ExportButton.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { useState, useRef, useEffect } from "react";
import { buildApiUrl } from "../api";

type ExportTarget = "events" | "contracts";
type ExportFormat = "csv" | "json";
Expand Down Expand Up @@ -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) {
Expand Down
3 changes: 2 additions & 1 deletion src/components/SimulateButton.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
// Contract execution simulation estimator button
import { useState } from "react";
import { buildApiUrl } from "../api";

interface SimResult {
success: boolean;
Expand Down Expand Up @@ -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({
Expand Down
5 changes: 2 additions & 3 deletions src/hooks/useEventStream.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
7 changes: 5 additions & 2 deletions src/hooks/useTxStatus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
*/

import { useEffect, useState } from "react";
import { buildApiUrl } from "../api";

export type TxStatus =
| "pending"
Expand All @@ -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 {
Expand All @@ -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(() => {});
Expand Down
11 changes: 5 additions & 6 deletions src/services/sandbox-api.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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({
Expand All @@ -39,7 +38,7 @@ export async function saveSandbox(
}

export async function loadSandbox(sandboxId: string): Promise<SavedSandbox> {
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}`);
Expand All @@ -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) {
Expand All @@ -67,7 +66,7 @@ export async function listSandboxes(
}

export async function deleteSandbox(sandboxId: string): Promise<void> {
const response = await fetch(`${API_BASE}/api/sandbox/${sandboxId}`, {
const response = await fetch(buildApiUrl(`/sandbox/${sandboxId}`), {
method: "DELETE",
});

Expand Down
42 changes: 41 additions & 1 deletion test/BackendStatusBanner.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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");
});
});
Loading