replace file discovery by udp discovery

This commit is contained in:
Worph
2026-09-14 11:24:54 +02:00
parent f4a283d3d2
commit 4eb8afa08b
5 changed files with 71 additions and 374 deletions
+19 -1
View File
@@ -153,7 +153,25 @@ export class APIServer {
}; };
}); });
// Note: /api/services is handled by nginx proxy to meta-core (centralized service discovery) // meta-discovery v1: neighbours heard over UDP, from this service's
// own map. The previous note here claimed nginx proxied /api/services
// to meta-core — it never did (no such location), so meta-dup's nav
// has 404'd for its whole life. This one is served locally.
this.app.get('/api/neighbors', async (_request, reply) => {
const leaderClient = this.kvManager?.getLeaderClient();
if (!leaderClient) {
return reply.send({ current: 'meta-dup', enabled: false, count: 0, neighbors: [] });
}
const neighbors = leaderClient.getNeighbors();
return reply.send({
current: 'meta-dup',
enabled: true,
count: neighbors.length,
neighbors,
services: neighbors,
self: leaderClient.self(),
});
});
// Catch-all for SPA routing (serve index.html for non-API routes) // Catch-all for SPA routing (serve index.html for non-API routes)
this.app.setNotFoundHandler(async (request: FastifyRequest, reply: FastifyReply) => { this.app.setNotFoundHandler(async (request: FastifyRequest, reply: FastifyReply) => {
+1 -1
View File
@@ -58,7 +58,7 @@ export interface IKVClient {
} }
/** /**
* Leader lock file content format (kv-leader.info) * meta-core's identity and URL set (from its UDP announce, or /urls)
* Written by meta-core leader, read by other services * Written by meta-core leader, read by other services
*/ */
export interface LeaderLockInfo { export interface LeaderLockInfo {
+7 -6
View File
@@ -2,7 +2,7 @@
* KV Manager for meta-dup (FOLLOWER-only) * KV Manager for meta-dup (FOLLOWER-only)
* *
* Simplified KV manager that: * Simplified KV manager that:
* 1. Discovers the leader via LeaderClient (reads kv-leader.info) * 1. Locates meta-core via LeaderClient (UDP announce, meta-discovery v1)
* 2. Creates Redis client connection * 2. Creates Redis client connection
* 3. Handles reconnection on leader failure * 3. Handles reconnection on leader failure
* *
@@ -81,7 +81,12 @@ export class KVManager {
// No separate service registration step any more: the LeaderClient's // No separate service registration step any more: the LeaderClient's
// discovery node announces this service on the same UDP group it // discovery node announces this service on the same UDP group it
// listens on, so being discoverable and discovering are one thing. // listens on, so being discoverable and discovering are one thing.
const apiUrl = this.config.baseUrl || `http://${hostname()}:${this.config.apiPort}`; // Browser-facing URL for the nav menu, in the order the spec requires:
// PUBLIC_URL (reachable when there is no Caddy perimeter in front — a
// debug-direct port) -> BASE_URL (the Caddy URL) -> container host.
// Announcing a Caddy URL on a stack with no Caddy running is what puts
// dead links in every neighbour's menu.
const apiUrl = process.env.PUBLIC_URL || this.config.baseUrl || `http://${hostname()}:${this.config.apiPort}`;
// If direct Redis URL provided, skip leader discovery // If direct Redis URL provided, skip leader discovery
if (this.config.redisUrl) { if (this.config.redisUrl) {
@@ -334,10 +339,6 @@ export class KVManager {
/** /**
* Get service registration instance * Get service registration instance
*/ */
/** @deprecated File-based registration is gone; always null. */
getServiceRegistration(): null {
return null;
}
/** /**
* Get configured paths * Get configured paths
@@ -1,211 +0,0 @@
/**
* Service Registration using shared filesystem
*
* Each service registers itself in /meta-core/services/{service-name}-{hostname}.json
* with its base URL, status, and heartbeat timestamp.
*
* Service discovery is centralized in meta-core - this class only handles registration.
* For service discovery, use the /api/services endpoint (proxied to meta-core via nginx).
*
* Each service must call start() to register itself and begin heartbeat updates.
* meta-core runs a cleanup process that removes services with stale heartbeats.
*/
import { promises as fs } from 'fs';
import { hostname } from 'os';
import { Logger } from 'tslog';
import type { ServiceInfo } from './IKVClient.js';
const logger = new Logger({ name: 'ServiceRegistration' });
interface ServiceRegistrationConfig {
/** Path to META_CORE_VOLUME (e.g., /meta-core) */
metaCorePath: string;
/** Service name */
serviceName: string;
/** Base URL for the service */
apiUrl: string;
/** Heartbeat interval in ms (default: 30000) */
heartbeatInterval?: number;
/** Stale threshold in ms (default: 60000) */
staleThreshold?: number;
}
export class ServiceRegistration {
private config: ServiceRegistrationConfig;
private servicesDir: string;
private serviceFilePath: string;
private heartbeatTimer: NodeJS.Timeout | null = null;
private isShuttingDown = false;
private isStarted = false;
private currentHostname: string;
constructor(config: ServiceRegistrationConfig) {
this.config = {
heartbeatInterval: 30000,
staleThreshold: 60000,
...config
};
this.currentHostname = hostname();
this.servicesDir = `${this.config.metaCorePath}/services`;
// Use hostname-based file naming
this.serviceFilePath = `${this.servicesDir}/${this.config.serviceName}-${this.currentHostname}.json`;
}
/**
* Ensure services directory exists
*/
private async ensureServicesDir(): Promise<void> {
await fs.mkdir(this.servicesDir, { recursive: true });
}
/**
* Build service info for this service
*/
private buildServiceInfo(status: ServiceInfo['status']): ServiceInfo {
return {
name: this.config.serviceName,
hostname: this.currentHostname,
baseUrl: this.config.apiUrl,
status,
lastHeartbeat: new Date().toISOString()
};
}
/**
* Register this service by writing service info to the shared filesystem.
* meta-core's cleanup process monitors these files and removes stale entries.
*/
async register(): Promise<void> {
await this.ensureServicesDir();
const info = this.buildServiceInfo('running');
await fs.writeFile(this.serviceFilePath, JSON.stringify(info, null, 2));
logger.info(`Registered ${this.config.serviceName}-${this.currentHostname}`);
}
/**
* Update service status
*/
async updateStatus(status: ServiceInfo['status']): Promise<void> {
try {
const content = await fs.readFile(this.serviceFilePath, 'utf-8');
const info = JSON.parse(content) as ServiceInfo;
info.status = status;
info.lastHeartbeat = new Date().toISOString();
await fs.writeFile(this.serviceFilePath, JSON.stringify(info, null, 2));
} catch (error) {
logger.error('Failed to update status:', error);
}
}
/**
* Send heartbeat (update lastHeartbeat timestamp)
*/
async heartbeat(): Promise<void> {
try {
const content = await fs.readFile(this.serviceFilePath, 'utf-8');
const info = JSON.parse(content) as ServiceInfo;
info.lastHeartbeat = new Date().toISOString();
await fs.writeFile(this.serviceFilePath, JSON.stringify(info, null, 2));
} catch (error: any) {
// If file was deleted, re-register
if (error.code === 'ENOENT') {
await this.register();
} else {
logger.error('Heartbeat failed:', error);
}
}
}
/**
* Start heartbeat loop
*/
startHeartbeat(): void {
this.heartbeatTimer = setInterval(async () => {
if (this.isShuttingDown) return;
await this.heartbeat();
}, this.config.heartbeatInterval);
}
/**
* Stop heartbeat loop
*/
stopHeartbeat(): void {
if (this.heartbeatTimer) {
clearInterval(this.heartbeatTimer);
this.heartbeatTimer = null;
}
}
/**
* Unregister this service (on shutdown)
*/
async unregister(): Promise<void> {
this.isShuttingDown = true;
this.stopHeartbeat();
try {
// Remove the service file
await fs.unlink(this.serviceFilePath);
logger.info(`Unregistered ${this.config.serviceName}-${this.currentHostname}`);
} catch (error: any) {
if (error.code !== 'ENOENT') {
logger.error('Failed to unregister:', error);
}
}
}
// ========================================================================
// Lifecycle
// ========================================================================
/**
* Full startup sequence: register service and start heartbeat loop.
*/
async start(): Promise<void> {
if (this.isStarted) return;
await this.register();
this.startHeartbeat();
this.isStarted = true;
logger.info(`Started ${this.config.serviceName} at ${this.config.apiUrl}`);
}
/**
* Full shutdown sequence
*/
async stop(): Promise<void> {
if (!this.isStarted) return;
await this.unregister();
this.isStarted = false;
}
// ========================================================================
// Getters
// ========================================================================
getServiceFilePath(): string {
return this.serviceFilePath;
}
getServicesDir(): string {
return this.servicesDir;
}
getIsStarted(): boolean {
return this.isStarted;
}
}
@@ -1,164 +1,53 @@
import { useState, useEffect } from 'react'; import { useEffect } from 'react';
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore -- plain ES module, no .d.ts; imported for its side effect
// of defining the custom element.
import '../meta-service-menu.js';
interface ServiceInfo { /**
name: string; * ServiceNav — the neighbouring meta-* services, as a burger menu.
hostname: string; *
baseUrl: string; * Since meta-discovery v1 this is a thin wrapper around <meta-service-menu>,
status: string; * a framework-free custom element shared by every meta-* UI (see
lastHeartbeat: string; * scripts/check-mirrors.sh). The old implementation was ~165 lines of React
} * that existed in four near-identical copies and polled meta-core's
* /api/services; the element polls this service's own /api/neighbors instead,
* so the nav still renders when meta-core is down.
*
* The element lives in shadow DOM and cannot see this app's CSS variables, so
* the four --mm-nav-* tokens below map our palette onto it.
*/
interface ServicesResponse { declare global {
services: ServiceInfo[]; // eslint-disable-next-line @typescript-eslint/no-namespace
current: string; namespace JSX {
} interface IntrinsicElements {
'meta-service-menu': React.DetailedHTMLProps<
const serviceIcons: Record<string, string> = { React.HTMLAttributes<HTMLElement>,
'meta-sort': '📁', HTMLElement
'meta-fuse': '🗂️', > & { current?: string; endpoint?: string; label?: string };
'meta-stremio': '🎬', }
'meta-core': '⚙️', }
'meta-dup': '🔍',
'meta-orbit': '🌐',
'default': '📦'
};
function formatServiceName(name: string): string {
return name.split('-').map(word =>
word.charAt(0).toUpperCase() + word.slice(1)
).join(' ');
} }
function ServiceNav() { function ServiceNav() {
const [services, setServices] = useState<ServiceInfo[]>([]);
const [loading, setLoading] = useState(true);
const currentService = 'meta-dup';
useEffect(() => { useEffect(() => {
const fetchServices = async () => { // Import above is for the side effect of defining the element; this
try { // keeps bundlers from tree-shaking it away in production builds.
const response = await fetch('/api/services');
if (response.ok) {
const data: ServicesResponse = await response.json();
setServices(data.services || []);
}
} catch (error) {
console.error('Failed to fetch services:', error);
} finally {
setLoading(false);
}
};
fetchServices();
const interval = setInterval(fetchServices, 30000);
return () => clearInterval(interval);
}, []); }, []);
if (loading) return null;
const sortedServices = [...services].sort((a, b) => a.name.localeCompare(b.name));
if (sortedServices.length === 0) return null;
return ( return (
<nav className="services-nav"> <>
<span className="services-nav-label">Services:</span> <meta-service-menu current="meta-dup" />
<div className="services-nav-items">
{sortedServices.map(service => {
const icon = serviceIcons[service.name] || serviceIcons.default;
const isActive = service.name === currentService;
return (
<a
key={service.name}
href={isActive ? '#' : service.baseUrl}
className={`service-link${isActive ? ' active' : ''}`}
onClick={(e) => {
e.preventDefault();
if (!isActive) {
window.location.href = service.baseUrl;
}
}}
>
<span className="service-icon">{icon}</span>
<span>{formatServiceName(service.name)}</span>
<span className="service-status"></span>
</a>
);
})}
</div>
<style>{` <style>{`
.services-nav { meta-service-menu {
background: var(--bg-secondary); --mm-nav-fg: var(--text-primary, #e0e0e0);
border-radius: 12px; --mm-nav-bg: var(--bg-tertiary, var(--bg-secondary, #1a1a2e));
padding: 0.5rem; --mm-nav-border: var(--border-color, rgba(255,255,255,0.14));
margin-bottom: 1.5rem; --mm-nav-accent: var(--accent-primary, #4ecdc4);
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
border: 1px solid var(--border-color);
}
.services-nav-label {
color: var(--text-secondary);
font-size: 0.85rem;
padding: 0.5rem 0.75rem;
white-space: nowrap;
}
.services-nav-items {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.service-link {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.5rem 1rem;
border-radius: 8px;
text-decoration: none;
font-size: 0.9rem;
font-weight: 500;
transition: all 0.2s;
color: var(--text-primary);
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
}
.service-link:hover {
background: var(--border-color);
border-color: rgba(78, 205, 196, 0.5);
text-decoration: none;
}
.service-link.active {
background: linear-gradient(135deg, rgba(78, 205, 196, 0.2), rgba(68, 160, 141, 0.2));
border-color: rgba(78, 205, 196, 0.5);
}
.service-link .service-icon {
font-size: 1.1rem;
}
.service-link .service-status {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--accent-primary);
margin-left: 0.25rem;
}
@media (max-width: 600px) {
.services-nav {
flex-direction: column;
align-items: flex-start;
}
} }
`}</style> `}</style>
</nav> </>
); );
} }