mirror of
https://github.com/koala73/worldmonitor.git
synced 2026-04-25 17:14:57 +02:00
Add Tauri local API sidecar with desktop routing fallback
This commit is contained in:
26
docs/local-backend-audit.md
Normal file
26
docs/local-backend-audit.md
Normal 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.
|
||||
@@ -1,3 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
tauri_build::build()
|
||||
}
|
||||
|
||||
127
src-tauri/sidecar/local-api-server.mjs
Normal file
127
src-tauri/sidecar/local-api-server.mjs
Normal 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})`);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user