From b7ee69dbb74e36f0422faaf3312c07008614dece Mon Sep 17 00:00:00 2001 From: Elie Habib Date: Fri, 13 Feb 2026 08:59:22 +0400 Subject: [PATCH] Add Tauri local API sidecar with desktop routing fallback --- docs/local-backend-audit.md | 26 +++++ src-tauri/build.rs | 2 +- src-tauri/sidecar/local-api-server.mjs | 127 +++++++++++++++++++++++++ src-tauri/src/main.rs | 100 ++++++++++++++++++- src-tauri/tauri.conf.json | 8 +- src/components/ServiceStatusPanel.ts | 35 +++++++ src/main.ts | 4 + src/services/runtime.ts | 37 +++++++ 8 files changed, 332 insertions(+), 7 deletions(-) create mode 100644 docs/local-backend-audit.md create mode 100644 src-tauri/sidecar/local-api-server.mjs diff --git a/docs/local-backend-audit.md b/docs/local-backend-audit.md new file mode 100644 index 000000000..65fa05218 --- /dev/null +++ b/docs/local-backend-audit.md @@ -0,0 +1,26 @@ +# Local backend endpoint audit (desktop sidecar) + +Critical `/api/*` endpoints used by `src/services/*` were reviewed and prioritized for desktop parity: + +## Priority 1: News + summarization +- `/api/rss-proxy` (feed ingestion) +- `/api/hackernews` +- `/api/github-trending` +- `/api/groq-summarize` +- `/api/openrouter-summarize` + +## Priority 2: Markets + core telemetry +- `/api/coingecko` +- `/api/polymarket` +- `/api/finnhub` +- `/api/yahoo-finance` +- `/api/cache-telemetry` + +## Priority 3: Status / runtime health +- `/api/service-status` +- `/api/local-status` (new local-sidecar health endpoint) + +## Localization strategy +- The desktop sidecar now executes existing `api/*.js` handlers directly when available, avoiding reliance on Vercel edge runtime for core behavior. +- If a handler is not present or fails locally, the sidecar can optionally pass through to cloud (`https://worldmonitor.app`) so functionality degrades gracefully. +- `ServiceStatusPanel` renders local backend status in desktop mode so users can see whether local mode is active and which cloud fallback target is configured. diff --git a/src-tauri/build.rs b/src-tauri/build.rs index 795b9b7c8..d860e1e6a 100644 --- a/src-tauri/build.rs +++ b/src-tauri/build.rs @@ -1,3 +1,3 @@ fn main() { - tauri_build::build() + tauri_build::build() } diff --git a/src-tauri/sidecar/local-api-server.mjs b/src-tauri/sidecar/local-api-server.mjs new file mode 100644 index 000000000..1813f20bd --- /dev/null +++ b/src-tauri/sidecar/local-api-server.mjs @@ -0,0 +1,127 @@ +#!/usr/bin/env node +import { createServer } from 'node:http'; +import { existsSync } from 'node:fs'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; + +const port = Number(process.env.LOCAL_API_PORT || 46123); +const remoteBase = (process.env.LOCAL_API_REMOTE_BASE || 'https://worldmonitor.app').replace(/\/$/, ''); +const resourceDir = process.env.LOCAL_API_RESOURCE_DIR || process.cwd(); +const apiDir = path.join(resourceDir, 'api'); +const mode = process.env.LOCAL_API_MODE || 'desktop-sidecar'; + +function json(data, status = 200, extraHeaders = {}) { + return new Response(JSON.stringify(data), { + status, + headers: { 'content-type': 'application/json', ...extraHeaders }, + }); +} + +async function readBody(req) { + const chunks = []; + for await (const chunk of req) chunks.push(chunk); + return chunks.length ? Buffer.concat(chunks) : undefined; +} + +function toHeaders(nodeHeaders) { + const headers = new Headers(); + Object.entries(nodeHeaders).forEach(([key, value]) => { + if (Array.isArray(value)) { + value.forEach(v => headers.append(key, v)); + } else if (typeof value === 'string') { + headers.set(key, value); + } + }); + return headers; +} + +function endpointFromPath(pathname) { + return pathname.replace(/^\/api\//, '').replace(/\/$/, '') || 'index'; +} + +async function handleServiceStatus() { + return json({ + success: true, + timestamp: new Date().toISOString(), + summary: { operational: 2, degraded: 0, outage: 0, unknown: 0 }, + services: [ + { id: 'local-api', name: 'Local Desktop API', category: 'dev', status: 'operational', description: `Running on 127.0.0.1:${port}` }, + { id: 'cloud-pass-through', name: 'Cloud pass-through', category: 'cloud', status: 'operational', description: `Fallback target ${remoteBase}` }, + ], + local: { enabled: true, mode, port, remoteBase }, + }); +} + +async function proxyToCloud(requestUrl, req) { + const target = `${remoteBase}${requestUrl.pathname}${requestUrl.search}`; + const body = ['GET', 'HEAD'].includes(req.method) ? undefined : await readBody(req); + const upstream = await fetch(target, { + method: req.method, + headers: toHeaders(req.headers), + body, + }); + return upstream; +} + +async function dispatch(requestUrl, req) { + if (requestUrl.pathname === '/api/service-status') { + return handleServiceStatus(); + } + if (requestUrl.pathname === '/api/local-status') { + return json({ success: true, mode, port, apiDir, remoteBase }); + } + + const endpoint = endpointFromPath(requestUrl.pathname); + const modulePath = path.join(apiDir, `${endpoint}.js`); + + if (!existsSync(modulePath)) { + return proxyToCloud(requestUrl, req); + } + + try { + const mod = await import(`${pathToFileURL(modulePath).href}?v=${Date.now()}`); + if (typeof mod.default !== 'function') { + return json({ error: `Invalid handler for ${endpoint}` }, 500); + } + + const body = ['GET', 'HEAD'].includes(req.method) ? undefined : await readBody(req); + const request = new Request(requestUrl.toString(), { + method: req.method, + headers: toHeaders(req.headers), + body, + }); + + return await mod.default(request); + } catch (error) { + console.error('[local-api] handler failed, using cloud fallback', endpoint, error); + try { + return await proxyToCloud(requestUrl, req); + } catch { + return json({ error: 'Local handler failed and cloud fallback unavailable' }, 502); + } + } +} + +createServer(async (req, res) => { + const requestUrl = new URL(req.url || '/', `http://127.0.0.1:${port}`); + + if (!requestUrl.pathname.startsWith('/api/')) { + res.writeHead(404, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ error: 'Not found' })); + return; + } + + try { + const response = await dispatch(requestUrl, req); + const body = Buffer.from(await response.arrayBuffer()); + const headers = Object.fromEntries(response.headers.entries()); + res.writeHead(response.status, headers); + res.end(body); + } catch (error) { + console.error('[local-api] fatal', error); + res.writeHead(500, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ error: 'Internal server error' })); + } +}).listen(port, '127.0.0.1', () => { + console.log(`[local-api] listening on http://127.0.0.1:${port} (apiDir=${apiDir})`); +}); diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index be07fe23f..b1d86525f 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -1,7 +1,99 @@ #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] -fn main() { - tauri::Builder::default() - .run(tauri::generate_context!()) - .expect("error while running world-monitor tauri application"); +use std::path::PathBuf; +use std::process::{Child, Command, Stdio}; +use std::sync::Mutex; + +use tauri::{AppHandle, Manager, RunEvent}; + +const LOCAL_API_PORT: &str = "46123"; + +#[derive(Default)] +struct LocalApiState { + child: Mutex>, +} + +fn local_api_paths(app: &AppHandle) -> (PathBuf, PathBuf) { + let resource_dir = app + .path() + .resource_dir() + .unwrap_or_else(|_| PathBuf::from(".")); + + let sidecar_script = if cfg!(debug_assertions) { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("sidecar/local-api-server.mjs") + } else { + resource_dir.join("sidecar/local-api-server.mjs") + }; + + let api_dir_root = if cfg!(debug_assertions) { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .parent() + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from(".")) + } else { + resource_dir + }; + + (sidecar_script, api_dir_root) +} + +fn start_local_api(app: &AppHandle) -> Result<(), String> { + let state = app.state::(); + let mut slot = state + .child + .lock() + .map_err(|_| "Failed to lock local API state".to_string())?; + if slot.is_some() { + return Ok(()); + } + + let (script, resource_root) = local_api_paths(app); + if !script.exists() { + return Err(format!( + "Local API sidecar script missing at {}", + script.display() + )); + } + + let mut cmd = Command::new("node"); + cmd.arg(&script) + .env("LOCAL_API_PORT", LOCAL_API_PORT) + .env("LOCAL_API_RESOURCE_DIR", resource_root) + .env("LOCAL_API_MODE", "tauri-sidecar") + .stdout(Stdio::null()) + .stderr(Stdio::inherit()); + + let child = cmd + .spawn() + .map_err(|e| format!("Failed to launch local API: {e}"))?; + *slot = Some(child); + Ok(()) +} + +fn stop_local_api(app: &AppHandle) { + if let Ok(state) = app.try_state::().ok_or(()) { + if let Ok(mut slot) = state.child.lock() { + if let Some(mut child) = slot.take() { + let _ = child.kill(); + } + } + } +} + +fn main() { + tauri::Builder::default() + .manage(LocalApiState::default()) + .setup(|app| { + if let Err(err) = start_local_api(&app.handle()) { + eprintln!("[tauri] local API sidecar failed to start: {err}"); + } + Ok(()) + }) + .build(tauri::generate_context!()) + .expect("error while running world-monitor tauri application") + .run(|app, event| { + if matches!(event, RunEvent::ExitRequested { .. } | RunEvent::Exit) { + stop_local_api(&app); + } + }); } diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 8b7f8588a..14c147d75 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -23,7 +23,7 @@ } ], "security": { - "csp": "default-src 'self'; connect-src 'self' https: http://localhost:5173 ws: wss: blob: data:; img-src 'self' data: blob: https:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline' 'wasm-unsafe-eval'; worker-src 'self' blob:; font-src 'self' data: https:; media-src 'self' data: blob: https:" + "csp": "default-src 'self'; connect-src 'self' https: http://localhost:5173 http://127.0.0.1:46123 ws: wss: blob: data:; img-src 'self' data: blob: https:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline' 'wasm-unsafe-eval'; worker-src 'self' blob:; font-src 'self' data: https:; media-src 'self' data: blob: https:" } }, "bundle": { @@ -32,6 +32,10 @@ "category": "Productivity", "shortDescription": "World Monitor desktop app (supports World and Tech variants)", "longDescription": "World Monitor desktop app for real-time global intelligence. Build with VITE_VARIANT=tech to package Tech Monitor branding and dataset defaults.", - "icon": [] + "icon": [], + "resources": [ + "../api", + "sidecar/local-api-server.mjs" + ] } } diff --git a/src/components/ServiceStatusPanel.ts b/src/components/ServiceStatusPanel.ts index ea4dcfe45..fc2e0d534 100644 --- a/src/components/ServiceStatusPanel.ts +++ b/src/components/ServiceStatusPanel.ts @@ -1,5 +1,6 @@ import { Panel } from './Panel'; import { escapeHtml } from '@/utils/sanitize'; +import { isDesktopRuntime } from '@/services/runtime'; interface ServiceStatus { id: string; @@ -9,6 +10,13 @@ interface ServiceStatus { description: string; } +interface LocalBackendStatus { + enabled?: boolean; + mode?: string; + port?: number; + remoteBase?: string; +} + interface ServiceStatusResponse { success: boolean; timestamp: string; @@ -19,6 +27,7 @@ interface ServiceStatusResponse { unknown: number; }; services: ServiceStatus[]; + local?: LocalBackendStatus; } type CategoryFilter = 'all' | 'cloud' | 'dev' | 'comm' | 'ai' | 'saas'; @@ -38,6 +47,7 @@ export class ServiceStatusPanel extends Panel { private error: string | null = null; private filter: CategoryFilter = 'all'; private refreshInterval: ReturnType | null = null; + private localBackend: LocalBackendStatus | null = null; constructor() { super({ id: 'service-status', title: 'Service Status', showCount: false }); @@ -61,6 +71,7 @@ export class ServiceStatusPanel extends Panel { if (!data.success) throw new Error('Failed to load status'); this.services = data.services; + this.localBackend = data.local ?? null; this.error = null; } catch (err) { this.error = err instanceof Error ? err.message : 'Failed to fetch'; @@ -110,11 +121,13 @@ export class ServiceStatusPanel extends Panel { const filtered = this.getFilteredServices(); const issues = filtered.filter(s => s.status !== 'operational'); + const backendHtml = this.renderBackendStatus(); const summaryHtml = this.renderSummary(filtered); const filtersHtml = this.renderFilters(); const servicesHtml = this.renderServices(filtered); this.content.innerHTML = ` + ${backendHtml} ${summaryHtml} ${filtersHtml}
@@ -126,6 +139,28 @@ export class ServiceStatusPanel extends Panel { this.attachFilterListeners(); } + + private renderBackendStatus(): string { + if (!isDesktopRuntime()) return ''; + + if (!this.localBackend?.enabled) { + return ` +
+ Desktop local backend unavailable. Falling back to cloud API. +
+ `; + } + + const port = this.localBackend.port ?? 46123; + const remote = this.localBackend.remoteBase ?? 'https://worldmonitor.app'; + + return ` +
+ Local backend active on 127.0.0.1:${port} ยท cloud fallback: ${escapeHtml(remote)} +
+ `; + } + private renderSummary(services: ServiceStatus[]): string { const operational = services.filter(s => s.status === 'operational').length; const degraded = services.filter(s => s.status === 'degraded').length; diff --git a/src/main.ts b/src/main.ts index 0ade2a1f8..c17e1b131 100644 --- a/src/main.ts +++ b/src/main.ts @@ -4,6 +4,7 @@ import { inject } from '@vercel/analytics'; import { App } from './App'; import { debugInjectTestEvents, debugGetCells, getCellCount } from '@/services/geo-convergence'; import { initMetaTags } from '@/services/meta-tags'; +import { installRuntimeFetchPatch } from '@/services/runtime'; // Initialize Vercel Analytics inject(); @@ -11,6 +12,9 @@ inject(); // Initialize dynamic meta tags for sharing initMetaTags(); +// In desktop mode, route /api/* calls to the local Tauri sidecar backend. +installRuntimeFetchPatch(); + const app = new App('app'); app.init().catch(console.error); diff --git a/src/services/runtime.ts b/src/services/runtime.ts index ea536b498..de9801518 100644 --- a/src/services/runtime.ts +++ b/src/services/runtime.ts @@ -4,6 +4,8 @@ const DEFAULT_REMOTE_HOSTS: Record = { world: 'https://worldmonitor.app', }; +const DEFAULT_LOCAL_API_BASE = 'http://127.0.0.1:46123'; + function normalizeBaseUrl(baseUrl: string): string { return baseUrl.replace(/\/$/, ''); } @@ -26,6 +28,15 @@ export function getApiBaseUrl(): string { return normalizeBaseUrl(configuredBaseUrl); } + return DEFAULT_LOCAL_API_BASE; +} + +export function getRemoteApiBaseUrl(): string { + const configuredRemoteBase = import.meta.env.VITE_TAURI_REMOTE_API_BASE_URL; + if (configuredRemoteBase) { + return normalizeBaseUrl(configuredRemoteBase); + } + const variant = import.meta.env.VITE_VARIANT || 'world'; return DEFAULT_REMOTE_HOSTS[variant] ?? DEFAULT_REMOTE_HOSTS.world ?? 'https://worldmonitor.app'; } @@ -42,3 +53,29 @@ export function toRuntimeUrl(path: string): string { return `${baseUrl}${path}`; } + +export function installRuntimeFetchPatch(): void { + if (!isDesktopRuntime() || typeof window === 'undefined' || (window as unknown as Record).__wmFetchPatched) { + return; + } + + const nativeFetch = window.fetch.bind(window); + const localBase = getApiBaseUrl(); + const remoteBase = getRemoteApiBaseUrl(); + + window.fetch = async (input: RequestInfo | URL, init?: RequestInit): Promise => { + const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url; + if (!url.startsWith('/api/')) { + return nativeFetch(input, init); + } + + try { + return await nativeFetch(`${localBase}${url}`, init); + } catch (error) { + console.warn(`[runtime] Local API fetch failed for ${url}, falling back to cloud`, error); + return nativeFetch(`${remoteBase}${url}`, init); + } + }; + + (window as unknown as Record).__wmFetchPatched = true; +}