feat(frontend): runtime API base + desktop overlay extension points

Adds `lib/apiBase.ts` so the SPA can be repointed at a non-default backend
at runtime (via `window.__VELXIO_API_BASE__`) without losing the existing
`VITE_API_BASE` build-time override or the default `/api` reverse-proxy
behaviour. compilation / libraryService / projectService / metricsService
all flow through it now; axios clients use a request interceptor so the
base resolves per-request rather than at module-load time.

main.tsx grows a `VITE_DESKTOP` flag: when set, the @pro overlay is
skipped (the desktop shell handles license + auth natively) and a
small `./desktop/index` module is dynamic-imported in its place. OSS
builds tree-shake both branches.

LandingPage gets a `data-velxio-slot="landing-hero-primary-cta"` marker
above the existing hero CTAs so velxio.dev can inject an OS-detect
"Download Velxio Desktop" button as the visual primary. The slot is
empty in pure OSS.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
davidmonterocrespo24 2026-05-21 04:50:19 +02:00
parent be24243e4b
commit 24f84442e8
7 changed files with 82 additions and 17 deletions

View File

@ -0,0 +1,37 @@
/**
* Resolve the base URL of the velxio FastAPI backend at runtime.
*
* Two layers of override:
*
* 1. `window.__VELXIO_API_BASE__` set by a thin wrapper that hosts
* the SPA against a non-default backend (e.g. the Tauri desktop
* shell injects this before the bundle runs, pointing at the
* locally spawned Python sidecar on `http://127.0.0.1:<port>`).
* 2. `import.meta.env.VITE_API_BASE` set at build time. Used by
* bespoke deployments that want a fixed backend URL baked in.
* 3. Default `/api` the standard same-origin reverse-proxy setup
* that velxio.dev and the OSS Docker image use.
*
* Resolved on every call rather than memoised so a host can swap the
* window var late (e.g. on a sidecar restart). The lookup is cheap.
*/
export function getApiBase(): string {
if (typeof window !== 'undefined') {
const w = window as { __VELXIO_API_BASE__?: string };
if (typeof w.__VELXIO_API_BASE__ === 'string' && w.__VELXIO_API_BASE__) {
return w.__VELXIO_API_BASE__.replace(/\/+$/, '');
}
}
const fromEnv = import.meta.env.VITE_API_BASE;
if (typeof fromEnv === 'string' && fromEnv) {
return fromEnv.replace(/\/+$/, '');
}
return '/api';
}
declare global {
interface Window {
__VELXIO_API_BASE__?: string;
}
}

View File

@ -29,8 +29,22 @@ createRoot(document.getElementById('root')!).render(<App />);
// open-source build (see vite.config.ts) and to the real overlay only when
// VITE_PRO_BUILD=true at build time. The dynamic import keeps the pro chunk
// out of the OSS bundle entirely (Vite tree-shakes the never-taken branch).
if (import.meta.env.VITE_PRO_BUILD) {
//
// VITE_DESKTOP=true is set by the Tauri desktop build. The desktop shell
// owns its own license + auth UI (Phase 3 of paid-clients) and runs against
// a locally spawned sidecar, so the velxio.dev-coupled overlay (trackers,
// billing, cloud auth, admin) is intentionally NOT loaded — even if a
// build accidentally sets both flags.
if (import.meta.env.VITE_PRO_BUILD && !import.meta.env.VITE_DESKTOP) {
import('@pro/index')
.then((m) => m.mountPro?.())
.catch((err) => console.warn('[pro] failed to load overlay:', err));
}
// Desktop-only hooks (ESP32 QEMU prompt now, welcome screen in Phase 3).
// Dynamic import so the OSS bundle never pulls this in.
if (import.meta.env.VITE_DESKTOP) {
import('./desktop/index')
.then((m) => m.mountDesktop?.())
.catch((err) => console.warn('[desktop] failed to load hooks:', err));
}

View File

@ -680,6 +680,12 @@ export const LandingPage: React.FC = () => {
<span className="hero-accent">{t('landing.hero.titleAccent')}</span>
</h1>
<p className="hero-subtitle">{t('landing.hero.subtitle')}</p>
{/*
Slot for the pro overlay's OS-detect Velxio Desktop download
CTA. Pure OSS leaves it empty; velxio.dev mounts a
DesktopDownloadButton here as the visual primary.
*/}
<div data-velxio-slot="landing-hero-primary-cta" />
<div className="hero-ctas">
<Link
to={localize('/editor')}

View File

@ -1,8 +1,7 @@
import axios from 'axios';
import { getApiBase } from '../lib/apiBase';
import type { ESP32BoardOptions, SpiffsFile } from '../types/boardOptions';
const API_BASE = import.meta.env.VITE_API_BASE || '/api';
export interface SketchFile {
name: string;
content: string;
@ -82,7 +81,7 @@ export async function compileCode(
onProgress?: CompileProgress,
extras?: CompileExtras,
): Promise<CompileResult> {
console.log('Sending compilation request to:', `${API_BASE}/compile/start`);
console.log('Sending compilation request to:', `${getApiBase()}/compile/start`);
console.log('Board:', board);
console.log(
'Files:',
@ -100,7 +99,7 @@ export async function compileCode(
let jobId: string;
try {
const startResp = await axios.post<CompileStartResponse>(
`${API_BASE}/compile/start`,
`${getApiBase()}/compile/start`,
{
files,
board_fqbn: board,
@ -139,7 +138,7 @@ export async function compileCode(
let status: CompileStatusResponse;
try {
const resp = await axios.get<CompileStatusResponse>(
`${API_BASE}/compile/status/${jobId}`,
`${getApiBase()}/compile/status/${jobId}`,
{ withCredentials: true, timeout: 30000 },
);
status = resp.data;

View File

@ -1,4 +1,5 @@
const API_BASE = `${import.meta.env.VITE_API_BASE || '/api'}/libraries`;
import { getApiBase } from '../lib/apiBase';
const apiBase = () => `${getApiBase()}/libraries`;
export interface ArduinoLibrary {
name: string;
@ -34,7 +35,7 @@ export interface InstalledLibrary {
}
export async function searchLibraries(query: string): Promise<ArduinoLibrary[]> {
const res = await fetch(`${API_BASE}/search?q=${encodeURIComponent(query)}`);
const res = await fetch(`${apiBase()}/search?q=${encodeURIComponent(query)}`);
if (!res.ok) {
const err = await res.json().catch(() => ({ detail: 'Unknown error' }));
throw new Error(err.detail || 'Failed to search libraries');
@ -44,7 +45,7 @@ export async function searchLibraries(query: string): Promise<ArduinoLibrary[]>
}
export async function installLibrary(name: string, version?: string): Promise<{ success: boolean; error?: string; fallback?: boolean; requested_version?: string }> {
const res = await fetch(`${API_BASE}/install`, {
const res = await fetch(`${apiBase()}/install`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, version: version ?? null }),
@ -54,7 +55,7 @@ export async function installLibrary(name: string, version?: string): Promise<{
}
export async function uninstallLibrary(name: string): Promise<{ success: boolean; error?: string }> {
const res = await fetch(`${API_BASE}/uninstall`, {
const res = await fetch(`${apiBase()}/uninstall`, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name }),
@ -64,7 +65,7 @@ export async function uninstallLibrary(name: string): Promise<{ success: boolean
}
export async function getInstalledLibraries(): Promise<InstalledLibrary[]> {
const res = await fetch(`${API_BASE}/list`);
const res = await fetch(`${apiBase()}/list`);
if (!res.ok) {
const err = await res.json().catch(() => ({ detail: 'Unknown error' }));
throw new Error(err.detail || 'Failed to fetch installed libraries');

View File

@ -1,8 +1,11 @@
import axios from 'axios';
import { getApiBase } from '../lib/apiBase';
const API_BASE = import.meta.env.VITE_API_BASE || '/api';
const api = axios.create({ baseURL: API_BASE, withCredentials: true });
const api = axios.create({ withCredentials: true });
api.interceptors.request.use((config) => {
config.baseURL = getApiBase();
return config;
});
// ── Client-side tracking ─────────────────────────────────────────────────────

View File

@ -1,8 +1,13 @@
import axios from 'axios';
import { getApiBase } from '../lib/apiBase';
const API_BASE = import.meta.env.VITE_API_BASE || '/api';
const api = axios.create({ baseURL: API_BASE, withCredentials: true });
// baseURL is resolved on every request so a host (e.g. the Tauri desktop
// shell) can swap the backend port at runtime.
const api = axios.create({ withCredentials: true });
api.interceptors.request.use((config) => {
config.baseURL = getApiBase();
return config;
});
export interface SketchFile {
name: string;