diff --git a/packages/meta-dup-core/src/api/APIServer.ts b/packages/meta-dup-core/src/api/APIServer.ts index 1be7fec..f2d9525 100644 --- a/packages/meta-dup-core/src/api/APIServer.ts +++ b/packages/meta-dup-core/src/api/APIServer.ts @@ -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) this.app.setNotFoundHandler(async (request: FastifyRequest, reply: FastifyReply) => { diff --git a/packages/meta-dup-core/src/kv/IKVClient.ts b/packages/meta-dup-core/src/kv/IKVClient.ts index 31aa6e4..fa3b4f2 100644 --- a/packages/meta-dup-core/src/kv/IKVClient.ts +++ b/packages/meta-dup-core/src/kv/IKVClient.ts @@ -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 */ export interface LeaderLockInfo { diff --git a/packages/meta-dup-core/src/kv/KVManager.ts b/packages/meta-dup-core/src/kv/KVManager.ts index a4bc5ea..dc4016c 100644 --- a/packages/meta-dup-core/src/kv/KVManager.ts +++ b/packages/meta-dup-core/src/kv/KVManager.ts @@ -2,7 +2,7 @@ * KV Manager for meta-dup (FOLLOWER-only) * * 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 * 3. Handles reconnection on leader failure * @@ -81,7 +81,12 @@ export class KVManager { // No separate service registration step any more: the LeaderClient's // discovery node announces this service on the same UDP group it // 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 (this.config.redisUrl) { @@ -334,10 +339,6 @@ export class KVManager { /** * Get service registration instance */ - /** @deprecated File-based registration is gone; always null. */ - getServiceRegistration(): null { - return null; - } /** * Get configured paths diff --git a/packages/meta-dup-core/src/kv/ServiceRegistration.ts b/packages/meta-dup-core/src/kv/ServiceRegistration.ts deleted file mode 100644 index 2601e6f..0000000 --- a/packages/meta-dup-core/src/kv/ServiceRegistration.ts +++ /dev/null @@ -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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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; - } -} diff --git a/packages/meta-dup-ui/src/components/ServiceNav.tsx b/packages/meta-dup-ui/src/components/ServiceNav.tsx index 407eb73..cbfe0df 100644 --- a/packages/meta-dup-ui/src/components/ServiceNav.tsx +++ b/packages/meta-dup-ui/src/components/ServiceNav.tsx @@ -1,165 +1,54 @@ -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; - hostname: string; - baseUrl: string; - status: string; - lastHeartbeat: string; -} +/** + * ServiceNav — the neighbouring meta-* services, as a burger menu. + * + * Since meta-discovery v1 this is a thin wrapper around , + * a framework-free custom element shared by every meta-* UI (see + * 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 { - services: ServiceInfo[]; - current: string; -} - -const serviceIcons: Record = { - 'meta-sort': '📁', - 'meta-fuse': '🗂️', - '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(' '); +declare global { + // eslint-disable-next-line @typescript-eslint/no-namespace + namespace JSX { + interface IntrinsicElements { + 'meta-service-menu': React.DetailedHTMLProps< + React.HTMLAttributes, + HTMLElement + > & { current?: string; endpoint?: string; label?: string }; + } + } } function ServiceNav() { - const [services, setServices] = useState([]); - const [loading, setLoading] = useState(true); - const currentService = 'meta-dup'; + useEffect(() => { + // Import above is for the side effect of defining the element; this + // keeps bundlers from tree-shaking it away in production builds. + }, []); - useEffect(() => { - const fetchServices = async () => { - try { - 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 ( - - ); + `} + + ); } export default ServiceNav;