Add Tauri local API sidecar with desktop routing fallback

This commit is contained in:
Elie Habib
2026-02-13 08:59:22 +04:00
parent eb0f396d16
commit b7ee69dbb7
8 changed files with 332 additions and 7 deletions

View File

@@ -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.

View File

@@ -1,3 +1,3 @@
fn main() {
tauri_build::build()
tauri_build::build()
}

View File

@@ -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})`);
});

View File

@@ -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<Option<Child>>,
}
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::<LocalApiState>();
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::<LocalApiState>().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);
}
});
}

View File

@@ -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"
]
}
}

View File

@@ -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<typeof setInterval> | 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}
<div class="service-status-list">
@@ -126,6 +139,28 @@ export class ServiceStatusPanel extends Panel {
this.attachFilterListeners();
}
private renderBackendStatus(): string {
if (!isDesktopRuntime()) return '';
if (!this.localBackend?.enabled) {
return `
<div class="service-status-backend warning">
Desktop local backend unavailable. Falling back to cloud API.
</div>
`;
}
const port = this.localBackend.port ?? 46123;
const remote = this.localBackend.remoteBase ?? 'https://worldmonitor.app';
return `
<div class="service-status-backend">
Local backend active on <strong>127.0.0.1:${port}</strong> · cloud fallback: <strong>${escapeHtml(remote)}</strong>
</div>
`;
}
private renderSummary(services: ServiceStatus[]): string {
const operational = services.filter(s => s.status === 'operational').length;
const degraded = services.filter(s => s.status === 'degraded').length;

View File

@@ -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);

View File

@@ -4,6 +4,8 @@ const DEFAULT_REMOTE_HOSTS: Record<string, string> = {
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<string, unknown>).__wmFetchPatched) {
return;
}
const nativeFetch = window.fetch.bind(window);
const localBase = getApiBaseUrl();
const remoteBase = getRemoteApiBaseUrl();
window.fetch = async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
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<string, unknown>).__wmFetchPatched = true;
}