From af51bbf943b13aa8e489db935e9d612358ebc54e Mon Sep 17 00:00:00 2001 From: Worph Date: Sun, 13 Sep 2026 19:49:45 +0200 Subject: [PATCH] change service discovery --- .../src/container-plugins/ContainerManager.ts | 59 +-- .../meta-sort-core/src/discovery/meshdisco.ts | 441 ++++++++++++++++++ packages/meta-sort-core/src/index.ts | 16 +- packages/meta-sort-core/src/kv/KVManager.ts | 28 +- .../meta-sort-core/src/kv/LeaderClient.ts | 313 ++++++------- .../src/components/ServiceNav.tsx | 196 ++------ .../meta-sort-ui/src/meta-service-menu.js | 303 ++++++++++++ 7 files changed, 957 insertions(+), 399 deletions(-) create mode 100644 packages/meta-sort-core/src/discovery/meshdisco.ts create mode 100644 packages/meta-sort-ui/src/meta-service-menu.js diff --git a/packages/meta-sort-core/src/container-plugins/ContainerManager.ts b/packages/meta-sort-core/src/container-plugins/ContainerManager.ts index a2524e1..1c61b20 100644 --- a/packages/meta-sort-core/src/container-plugins/ContainerManager.ts +++ b/packages/meta-sort-core/src/container-plugins/ContainerManager.ts @@ -142,53 +142,18 @@ export class ContainerManager extends EventEmitter { return; } - // Fall back to service discovery for any missing URLs - const serviceFile = join(config.META_CORE_PATH, 'services', 'meta-sort.json'); - - try { - const content = await fs.readFile(serviceFile, 'utf-8'); - const serviceInfo: ServiceInfo = JSON.parse(content); - - // Extract URLs from service info - const api = serviceInfo.api; - const endpoints = serviceInfo.endpoints || {}; - - // Use service discovery for callback URL if not set - if (!this.callbackUrl) { - if (endpoints.callback) { - this.callbackUrl = endpoints.callback; - console.log(`[ContainerManager] Discovered callback URL: ${this.callbackUrl}`); - } else if (api) { - // Fallback: construct from api base - this.callbackUrl = `${api}/api/plugins/callback`; - console.log(`[ContainerManager] Constructed callback URL: ${this.callbackUrl}`); - } - } - - // Use service discovery for WebDAV URL if not set (fallback) - if (!this.webdavUrl) { - if (endpoints.webdav) { - this.webdavUrl = endpoints.webdav; - console.log(`[ContainerManager] Discovered WebDAV URL: ${this.webdavUrl}`); - } else if (api) { - // Fallback: construct from api base - this.webdavUrl = `${api}/webdav`; - console.log(`[ContainerManager] Constructed WebDAV URL: ${this.webdavUrl}`); - } - } - - if (endpoints.health) { - // Extract meta-core URL from health endpoint - const healthUrl = new URL(endpoints.health); - this.metaCoreUrl = `${healthUrl.protocol}//${healthUrl.host}`; - console.log(`[ContainerManager] Discovered meta-core URL: ${this.metaCoreUrl}`); - } - - console.log('[ContainerManager] Service discovery successful'); - } catch (error) { - console.warn(`[ContainerManager] Service discovery failed: ${error}`); - console.log(`[ContainerManager] Using callback URL: ${this.callbackUrl}`); - console.log(`[ContainerManager] Using WebDAV URL: ${this.webdavUrl || 'not set'}`); + // Anything still missing is a configuration problem, not something to + // guess at. The old fallback here read + // `${META_CORE_PATH}/services/meta-sort.json` — a filename that stopped + // existing when registration files became hostname-suffixed, using a + // schema (`.api` / `.endpoints`) that no longer matched either. It had + // been falling into its own catch block for a long time, and the + // volume it read is gone since meta-discovery v1. + if (!this.callbackUrl) { + console.warn('[ContainerManager] No callback URL — set CONTAINER_CALLBACK_URL'); + } + if (!this.webdavUrl) { + console.warn('[ContainerManager] No WebDAV URL — meta-core did not supply one'); } } diff --git a/packages/meta-sort-core/src/discovery/meshdisco.ts b/packages/meta-sort-core/src/discovery/meshdisco.ts new file mode 100644 index 0000000..c40e77a --- /dev/null +++ b/packages/meta-sort-core/src/discovery/meshdisco.ts @@ -0,0 +1,441 @@ +/** + * meta-discovery v1 — UDP multicast service discovery. + * + * Replaces the two file-based mechanisms that required a shared /meta-core + * volume: `locks/kv-leader.info` (locating meta-core at boot) and + * `services/-.json` (the dashboard nav registry). Both answered the + * same question — where is meta-core, and who else is on this network — so one + * announce packet answers both, and discovery scope becomes the docker network + * rather than the mounted volume. + * + * The normative spec is docs/project-architecture/service-discovery.md. + * + * ⚠ MIRRORED FILE. Byte-identical copies live in meta-sort-core, meta-fuse-core + * and meta-dup-core. They cannot share a package: meta-fuse-core and + * meta-dup-core do not depend on @metazla/meta-interface at all, and each + * submodule is its own Docker build context, so a workspace dep resolves + * outside the build. Edit one copy → edit all three, and + * `scripts/check-mirrors.sh` will tell you if you forgot. + */ + +import dgram from 'dgram'; +import { hostname, networkInterfaces } from 'os'; + +export const PROTOCOL_VERSION = 1; +export const DEFAULT_GROUP = '239.255.77.1'; +export const DEFAULT_PORT = 9399; +export const DEFAULT_INTERVAL_MS = 10_000; +/** A neighbour unseen for interval × this is dropped. Replaces the reaper. */ +export const LIVENESS_FACTOR = 3; + +export const TYPE_DISCOVERY = 'discovery'; +export const TYPE_ANNOUNCE = 'announce'; +export const ROLE_CORE = 'core'; +export const ROLE_SERVICE = 'service'; + +/** Mirrors meta-core's GET /urls response minus redisUrl (retired). */ +export interface MeshUrls { + hostname: string; + baseUrl: string; + apiUrl: string; + webdavUrl: string; + webdavUrlInternal: string; +} + +export interface MeshMessage { + v: number; + type: string; + name?: string; + instance?: string; + role?: string; + version?: string; + status?: string; + baseUrl?: string; + /** Present only when role === 'core'. */ + urls?: MeshUrls; + /** Reserved for a future HMAC; v1 ignores it. */ + token?: string; +} + +export interface MeshNeighbor extends MeshMessage { + /** Source address taken from the packet, never from the payload. */ + addr: string; + lastSeen: number; +} + +export interface MeshNodeConfig { + name: string; + instance?: string; + role?: string; + version?: string; + group?: string; + port?: number; + intervalMs?: number; + enabled?: boolean; + /** Rebuilt on every announce so the payload never goes stale. */ + payload?: () => { baseUrl?: string; status?: string; urls?: MeshUrls }; +} + +/** IPv4 addresses of every up, non-internal interface. */ +function localInterfaceAddresses(): string[] { + const out: string[] = []; + const nets = networkInterfaces(); + for (const name of Object.keys(nets)) { + for (const ni of nets[name] ?? []) { + // Node has flip-flopped between 'IPv4' and 4 for `family`. + const isV4 = (ni as { family: string | number }).family === 'IPv4' || + (ni as { family: string | number }).family === 4; + if (isV4 && !ni.internal && ni.address) out.push(ni.address); + } + } + return out; +} + +/** First non-internal IPv4 address, or the hostname. Matches the Go helper. */ +export function localIPv4(): string { + return localInterfaceAddresses()[0] ?? hostname(); +} + +/** + * One participant in the mesh: announces itself, answers probes, and keeps a + * TTL map of everyone else. + */ +export class MeshNode { + private cfg: Required> & Pick; + private socket: dgram.Socket | null = null; + private timer: NodeJS.Timeout | null = null; + private neighbors = new Map(); + private ifaceAddrs: string[] = []; + private listeners: ((n: MeshNeighbor) => void)[] = []; + private started = false; + + constructor(config: MeshNodeConfig) { + this.cfg = { + name: config.name, + instance: config.instance ?? hostname(), + role: config.role ?? ROLE_SERVICE, + version: config.version ?? '', + group: config.group ?? DEFAULT_GROUP, + port: config.port ?? DEFAULT_PORT, + intervalMs: config.intervalMs ?? DEFAULT_INTERVAL_MS, + enabled: config.enabled ?? true, + payload: config.payload, + }; + } + + async start(): Promise { + if (this.started || !this.cfg.enabled) { + if (!this.cfg.enabled) console.log('[meshdisco] Disabled by configuration'); + return; + } + this.started = true; + + await new Promise((resolve) => { + const socket = dgram.createSocket({ type: 'udp4', reuseAddr: true }); + this.socket = socket; + + socket.on('message', (buf, rinfo) => this.onMessage(buf, rinfo)); + socket.on('error', (err) => { + // Never fatal: a host that blocks multicast must degrade, not + // take the service down. + console.error('[meshdisco] Socket error:', err.message); + }); + + socket.bind(this.cfg.port, '0.0.0.0', () => { + try { + socket.setMulticastTTL(1); // link-local only + } catch (e) { + console.warn('[meshdisco] Could not set multicast TTL:', e); + } + + // Join on EVERY interface. A container on two docker networks + // that joins only the default route silently sees one network. + this.ifaceAddrs = localInterfaceAddresses(); + let joined = 0; + for (const addr of this.ifaceAddrs) { + try { + socket.addMembership(this.cfg.group, addr); + joined++; + } catch (e) { + console.warn(`[meshdisco] Join ${this.cfg.group} on ${addr} failed:`, e); + } + } + console.log( + `[meshdisco] Listening on ${this.cfg.group}:${this.cfg.port} as ` + + `${this.cfg.name}/${this.cfg.instance} (joined ${joined} of ${this.ifaceAddrs.length} interfaces)` + ); + resolve(); + }); + }); + + this.announce(); + this.probe(); + this.timer = setInterval(() => this.announce(), this.cfg.intervalMs); + // Don't hold the event loop open on shutdown. + this.timer.unref?.(); + } + + async stop(): Promise { + if (!this.started) return; + this.started = false; + if (this.timer) { clearInterval(this.timer); this.timer = null; } + // Final "stopping" announce so neighbours drop us immediately rather + // than waiting out the staleness window. + this.announce('stopping'); + await new Promise((resolve) => { + if (!this.socket) return resolve(); + try { this.socket.close(() => resolve()); } catch { resolve(); } + }); + this.socket = null; + this.listeners = []; + } + + /** Multicast a probe; every listener replies immediately. */ + probe(): void { + this.send({ v: PROTOCOL_VERSION, type: TYPE_DISCOVERY }); + } + + /** Everyone heard from inside the staleness window, sorted by name. */ + getNeighbors(): MeshNeighbor[] { + const cutoff = Date.now() - this.cfg.intervalMs * LIVENESS_FACTOR; + const out: MeshNeighbor[] = []; + for (const [key, nb] of this.neighbors) { + if (nb.lastSeen < cutoff) { this.neighbors.delete(key); continue; } + out.push(nb); + } + return out.sort((a, b) => (a.name ?? '').localeCompare(b.name ?? '')); + } + + /** One row per service name, most recently seen instance wins. */ + getNeighborsByName(): MeshNeighbor[] { + const best = new Map(); + for (const nb of this.getNeighbors()) { + const cur = best.get(nb.name ?? ''); + if (!cur || nb.lastSeen > cur.lastSeen) best.set(nb.name ?? '', nb); + } + return [...best.values()].sort((a, b) => (a.name ?? '').localeCompare(b.name ?? '')); + } + + /** Cores only — what a locator filters on. */ + getCores(): MeshNeighbor[] { + return this.getNeighbors().filter((n) => n.role === ROLE_CORE && n.urls); + } + + /** This node's own announce, so a UI can render itself without waiting. */ + self(): MeshNeighbor { + return { ...this.buildAnnounce('running'), addr: '', lastSeen: Date.now() }; + } + + /** Fires for every announce received (after the self-echo filter). */ + onAnnounce(cb: (n: MeshNeighbor) => void): void { + this.listeners.push(cb); + } + + private onMessage(buf: Buffer, rinfo: dgram.RemoteInfo): void { + let msg: MeshMessage; + try { + msg = JSON.parse(buf.toString('utf8')) as MeshMessage; + } catch { + return; // foreign traffic on the group + } + if (msg.v !== PROTOCOL_VERSION) return; + // Drop our own multicast echo. + if (msg.instance && msg.instance === this.cfg.instance && msg.name === this.cfg.name) return; + + if (msg.type === TYPE_DISCOVERY) { + this.replyTo(rinfo); + return; + } + if (msg.type !== TYPE_ANNOUNCE || !msg.name) return; + + const key = msg.instance ? `${msg.name}|${msg.instance}` : msg.name; + if (msg.status === 'stopping') { this.neighbors.delete(key); return; } + + const nb: MeshNeighbor = { ...msg, addr: rinfo.address, lastSeen: Date.now() }; + this.neighbors.set(key, nb); + for (const cb of this.listeners) { + try { cb(nb); } catch (e) { console.error('[meshdisco] Listener threw:', e); } + } + } + + private buildAnnounce(status: string): MeshMessage { + const msg: MeshMessage = { + v: PROTOCOL_VERSION, + type: TYPE_ANNOUNCE, + name: this.cfg.name, + instance: this.cfg.instance, + role: this.cfg.role, + version: this.cfg.version, + status, + }; + if (this.cfg.payload) { + const p = this.cfg.payload(); + if (p.baseUrl) msg.baseUrl = p.baseUrl; + if (p.status && status === 'running') msg.status = p.status; + // Only a core may carry a URLs block — a service announcing one + // would let any container impersonate meta-core. + if (this.cfg.role === ROLE_CORE && p.urls) msg.urls = p.urls; + } + return msg; + } + + private announce(status = 'running'): void { + this.send(this.buildAnnounce(status)); + } + + private replyTo(rinfo: dgram.RemoteInfo): void { + const body = Buffer.from(JSON.stringify(this.buildAnnounce('running'))); + try { + this.socket?.send(body, rinfo.port, rinfo.address); + } catch (e) { + console.warn('[meshdisco] Reply failed:', e); + } + } + + /** Writes once per interface — the default route alone reaches one network. */ + private send(msg: MeshMessage): void { + const socket = this.socket; + if (!socket) return; + const body = Buffer.from(JSON.stringify(msg)); + for (const addr of this.ifaceAddrs) { + try { + socket.setMulticastInterface(addr); + socket.send(body, this.cfg.port, this.cfg.group); + } catch (e) { + console.warn(`[meshdisco] Send on ${addr} failed:`, e); + } + } + } +} + +export interface MetaCoreLocatorConfig { + /** This service's name, for its own announce. */ + serviceName: string; + /** Browser-facing URL for the nav menu. */ + baseUrl?: string; + version?: string; + /** + * Explicit meta-core API URL. When set this ALWAYS wins and the wire is + * never consulted for core selection — see the spec's "the pin always + * wins". This is what stops a client box from latching onto a gateway + * box's core when both are reachable on a shared network. + */ + metaCoreUrl?: string; + group?: string; + port?: number; + intervalMs?: number; + enabled?: boolean; +} + +/** + * Locates meta-core over UDP, replacing the read of + * /meta-core/locks/kv-leader.info. + */ +export class MetaCoreLocator { + private node: MeshNode; + private cfg: MetaCoreLocatorConfig; + private current: MeshUrls | null = null; + private changeCallbacks: (() => void)[] = []; + + constructor(config: MetaCoreLocatorConfig) { + this.cfg = config; + this.node = new MeshNode({ + name: config.serviceName, + role: ROLE_SERVICE, + version: config.version, + group: config.group, + port: config.port, + intervalMs: config.intervalMs, + enabled: config.enabled, + payload: () => ({ baseUrl: config.baseUrl ?? `http://${localIPv4()}` }), + }); + + this.node.onAnnounce((nb) => { + if (nb.role !== ROLE_CORE || !nb.urls) return; + if (this.cfg.metaCoreUrl) return; // pinned: the wire cannot move us + + const prev = this.current; + if (!prev) { + this.current = nb.urls; + console.log(`[meshdisco] meta-core discovered at ${nb.urls.apiUrl} (${nb.addr})`); + this.notifyChange(); + return; + } + if (prev.apiUrl === nb.urls.apiUrl) { + this.current = nb.urls; // refresh the rest of the fields + return; + } + // A second, different core is announcing. Do not flap between + // them — keep the first and say so loudly, because on a PCS box + // this means a client container can see the gateway's core. + console.warn( + `[meshdisco] Ignoring a second meta-core at ${nb.urls.apiUrl} (${nb.addr}); ` + + `staying with ${prev.apiUrl}. Set META_CORE_URL to pin this explicitly.` + ); + }); + } + + async start(): Promise { + if (this.cfg.metaCoreUrl) { + console.log(`[meshdisco] meta-core pinned to ${this.cfg.metaCoreUrl}; discovery is advisory`); + } + await this.node.start(); + } + + async stop(): Promise { + await this.node.stop(); + this.changeCallbacks = []; + } + + /** The pin if set, else whatever discovery elected. */ + getApiUrl(): string | null { + if (this.cfg.metaCoreUrl) return this.cfg.metaCoreUrl; + return this.current?.apiUrl ?? null; + } + + /** Full URLs block. null when pinned and /urls has not been fetched yet. */ + getUrls(): MeshUrls | null { + return this.current; + } + + /** + * Block until meta-core is located. Probes immediately and re-probes every + * 500ms — the same loop shape as the old LeaderClient.waitForLeader, so the + * 30s boot timeout semantics are preserved exactly. + */ + async waitForCore(timeoutMs = 30_000): Promise { + const deadline = Date.now() + timeoutMs; + let logged = false; + while (Date.now() < deadline) { + const url = this.getApiUrl(); + if (url) return url; + if (!logged) { console.log('[meshdisco] Waiting for meta-core...'); logged = true; } + this.node.probe(); + await new Promise((r) => setTimeout(r, 500)); + } + throw new Error(`[meshdisco] No meta-core found within ${timeoutMs}ms`); + } + + /** Fires when the elected core's apiUrl changes. Replaces the fs.watch. */ + onChange(cb: () => void): void { + this.changeCallbacks.push(cb); + } + + getNeighbors(): MeshNeighbor[] { + return this.node.getNeighborsByName(); + } + + self(): MeshNeighbor { + return this.node.self(); + } + + probe(): void { + this.node.probe(); + } + + private notifyChange(): void { + for (const cb of this.changeCallbacks) { + try { cb(); } catch (e) { console.error('[meshdisco] Change callback threw:', e); } + } + } +} diff --git a/packages/meta-sort-core/src/index.ts b/packages/meta-sort-core/src/index.ts index 824f0cf..2190cc8 100644 --- a/packages/meta-sort-core/src/index.ts +++ b/packages/meta-sort-core/src/index.ts @@ -125,22 +125,18 @@ process.on('SIGINT', async () => { // Start initial discovery and processing (streaming pipeline) (async () => { try { - // Validate META_CORE_PATH is configured (required for leader discovery) - if (!config.META_CORE_PATH) { - console.error('[Startup] ERROR: META_CORE_PATH is required. Set META_CORE_PATH environment variable.'); - console.error('[Startup] Example: META_CORE_PATH=/meta-core'); - process.exit(1); - } - - // Initialize KV Manager (Redis via leader discovery) - if (config.META_CORE_PATH) { - console.log('[Startup] Initializing KV Manager (leader discovery)...'); + // meta-core is located over UDP (meta-discovery v1) — no /meta-core + // volume required. META_CORE_URL, when set, pins it and discovery + // never overrides; see docs/project-architecture/service-discovery.md. + { + console.log('[Startup] Initializing KV Manager (meta-core discovery)...'); kvManager = new KVManager({ metaCorePath: config.META_CORE_PATH, filesPath: config.FILES_PATH, serviceName: config.SERVICE_NAME, apiPort: config.FUSE_API_PORT, baseUrl: config.BASE_URL, + metaCoreUrl: config.META_CORE_URL, }); // Wait for KV to be ready diff --git a/packages/meta-sort-core/src/kv/KVManager.ts b/packages/meta-sort-core/src/kv/KVManager.ts index 5d2d7d3..815584b 100644 --- a/packages/meta-sort-core/src/kv/KVManager.ts +++ b/packages/meta-sort-core/src/kv/KVManager.ts @@ -28,7 +28,6 @@ import { hostname } from 'os'; import { LeaderClient } from './LeaderClient.js'; -import { ServiceRegistration } from './ServiceRegistration.js'; import { RedisKVClient } from './RedisClient.js'; import type { IKVClient, LeaderLockInfo } from './IKVClient.js'; import { configure as configureWebdav } from '../webdav/WebdavClient.js'; @@ -59,7 +58,6 @@ interface KVManagerConfig { export class KVManager { private config: KVManagerConfig; private leaderClient: LeaderClient | null = null; - private serviceRegistration: ServiceRegistration | null = null; private kvClient: IKVClient | null = null; private isStarted = false; private isShuttingDown = false; @@ -112,8 +110,9 @@ export class KVManager { // Initialize leader client this.leaderClient = new LeaderClient({ - metaCorePath: this.config.metaCorePath, - metaCoreUrl: this.config.metaCoreUrl + metaCoreUrl: this.config.metaCoreUrl, + serviceName: this.config.serviceName, + baseUrl: this.config.baseUrl || `http://${hostname()}:${this.config.apiPort}`, }); // Watch for leader changes @@ -149,15 +148,9 @@ export class KVManager { // Start watching for leader changes this.leaderClient.startWatching(); - // Initialize and start service registration for heartbeat - const apiUrl = this.config.baseUrl || `http://${hostname()}:${this.config.apiPort}`; - this.serviceRegistration = new ServiceRegistration({ - metaCorePath: this.config.metaCorePath, - serviceName: this.config.serviceName, - version: '1.0.0', - apiUrl - }); - await this.serviceRegistration.start(); + // 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. this.isStarted = true; this.notifyReady(); @@ -224,10 +217,6 @@ export class KVManager { } // Service registration stop (if it was started) - if (this.serviceRegistration) { - await this.serviceRegistration.stop(); - this.serviceRegistration = null; - } this.isStarted = false; console.log('[KVManager] Stopped'); @@ -324,8 +313,9 @@ export class KVManager { /** * Get service registration instance */ - getServiceRegistration(): ServiceRegistration | null { - return this.serviceRegistration; + /** @deprecated File-based registration is gone; always null. */ + getServiceRegistration(): null { + return null; } /** diff --git a/packages/meta-sort-core/src/kv/LeaderClient.ts b/packages/meta-sort-core/src/kv/LeaderClient.ts index 5944a69..a4aed31 100644 --- a/packages/meta-sort-core/src/kv/LeaderClient.ts +++ b/packages/meta-sort-core/src/kv/LeaderClient.ts @@ -1,26 +1,48 @@ /** - * LeaderClient - Client for reading leader info from meta-core + * LeaderClient - locates meta-core and exposes its URLs. * - * This replaces the old LeaderElection.ts by delegating leader election - * to meta-core (Go sidecar) and just reading the results. + * Since meta-discovery v1 this is a thin adapter over MetaCoreLocator + * (../discovery/meshdisco.js): meta-core is found by UDP announce instead of by + * reading /meta-core/locks/kv-leader.info, which means this service no longer + * needs the /meta-core volume mounted at all. * - * Features: - * - Reads kv-leader.info file for leader URLs - * - Calls meta-core /urls API for current leader URLs - * - Watches for leader changes via file system watcher + * The public surface is deliberately unchanged so KVManager and its callers did + * not have to move: + * - getLeaderInfo() / getUrls() / getApiUrl() / getWebdavUrl*() + * - waitForLeader(timeoutMs) — same 30s default, same "poll until it answers" + * - startWatching() / onChange() — the fs.watch on the lock dir became "an + * announce arrived carrying a different apiUrl", feeding the same callback. + * + * Precedence is the pin: when metaCoreUrl (META_CORE_URL) is set it always + * wins and the wire is never consulted for core selection. See + * docs/project-architecture/service-discovery.md. + * + * The name "leader" is legacy. meta-core's flock election is vestigial — the Go + * binary only ever runs as leader — and redisUrl has been empty since the + * api-mediated-access lockdown; everything routes over HTTP. */ -import { promises as fs } from 'fs'; -import { watch, FSWatcher } from 'fs'; -import { dirname } from 'path'; +import { MetaCoreLocator, type MeshNeighbor } from '../discovery/meshdisco.js'; import type { LeaderLockInfo } from './IKVClient.js'; export interface LeaderClientConfig { - /** Path to META_CORE_VOLUME (e.g., /meta-core) */ - metaCorePath: string; + /** + * @deprecated Unused since meta-discovery v1. Kept so existing callers + * compile unchanged; nothing reads the volume any more. + */ + metaCorePath?: string; - /** meta-core API URL (e.g., http://meta-core:9000 or http://localhost:9000) */ + /** meta-core API URL. When set, discovery never overrides it. */ metaCoreUrl?: string; + + /** This service's name, used for its own announce. */ + serviceName?: string; + + /** Browser-facing URL announced for the nav menu. */ + baseUrl?: string; + + /** Service version, display only. */ + version?: string; } export interface URLsResponse { @@ -35,76 +57,79 @@ export interface URLsResponse { export class LeaderClient { private config: LeaderClientConfig; - private infoFilePath: string; + private locator: MetaCoreLocator; private leaderInfo: LeaderLockInfo | null = null; - private watcher: FSWatcher | null = null; private onChangeCallbacks: (() => void)[] = []; + private started = false; - // URL caching + // URL caching — only used on the pinned path, where the URLs still come + // from an HTTP GET /urls. A discovered core carries them in the announce. private cachedUrls: URLsResponse | null = null; - private urlsCacheTime: number = 0; - private readonly urlsCacheTTL: number = 5000; // 5 seconds + private urlsCacheTime = 0; + private readonly urlsCacheTTL = 5000; constructor(config: LeaderClientConfig) { this.config = config; - this.infoFilePath = `${config.metaCorePath}/locks/kv-leader.info`; + this.locator = new MetaCoreLocator({ + serviceName: config.serviceName ?? 'meta-sort', + baseUrl: config.baseUrl, + version: config.version, + metaCoreUrl: config.metaCoreUrl, + enabled: process.env.ENABLE_UDP_DISCOVERY !== 'false' && + process.env.ENABLE_UDP_DISCOVERY !== '0', + }); + this.locator.onChange(() => { + this.cachedUrls = null; + this.urlsCacheTime = 0; + this.notifyChange(); + }); + } + + /** Idempotent; safe to call from any entry point. */ + private async ensureStarted(): Promise { + if (this.started) return; + this.started = true; + await this.locator.start(); } /** - * Read API URL from file (plain text format) + * Guard against a rollback that reintroduces direct Redis exposure. + * api-mediated-access PR D removed redisUrl; if it comes back, fail loudly + * so it can't go unnoticed. ALLOW_LEGACY_REDIS_URL=1 downgrades to a warn. */ - private async getApiUrlFromFile(): Promise { - try { - const content = await fs.readFile(this.infoFilePath, 'utf-8'); - return content.trim() || null; - } catch (error) { - console.error('[LeaderClient] Failed to read API URL from file:', error); - return null; + private assertNoRedisUrl(redisUrl: string | undefined): void { + if (!redisUrl) return; + const msg = + '[LeaderClient] meta-core still publishes redisUrl; direct Redis access ' + + 'was retired by the api-mediated-access lockdown. Verify meta-core version.'; + if (process.env.ALLOW_LEGACY_REDIS_URL === '1') { + console.warn('WARNING: ' + msg); + } else { + throw new Error(msg); } } - /** - * Fetch URLs from meta-core /urls API with caching - */ + /** HTTP GET {apiUrl}/urls — still needed when meta-core is pinned. */ private async fetchUrls(apiUrl: string): Promise { - // Check cache const now = Date.now(); - if (this.cachedUrls && (now - this.urlsCacheTime) < this.urlsCacheTTL) { + if (this.cachedUrls && now - this.urlsCacheTime < this.urlsCacheTTL) { return this.cachedUrls; } - try { const response = await fetch(`${apiUrl}/urls`, { method: 'GET', - headers: { 'Accept': 'application/json' }, - signal: AbortSignal.timeout(5000) + headers: { Accept: 'application/json' }, + signal: AbortSignal.timeout(5000), }); - if (!response.ok) { console.error('[LeaderClient] Failed to fetch URLs:', response.status, response.statusText); return null; } - - const parsed = await response.json() as URLsResponse; - // api-mediated-access PR D: redisUrl is no longer published by - // meta-core. If it shows up, an older build snuck back in — - // fail loudly so the rollback can't go unnoticed. Set - // ALLOW_LEGACY_REDIS_URL=1 to downgrade to a warning during a - // deliberate temporary rollback. - if (parsed.redisUrl) { - const msg = - '[LeaderClient] meta-core still publishes redisUrl; ' + - 'direct Redis access was retired by the api-mediated-access ' + - 'lockdown. Verify meta-core version.'; - if (process.env.ALLOW_LEGACY_REDIS_URL === '1') { - console.warn('WARNING: ' + msg); - } else { - throw new Error(msg); - } - } + const parsed = (await response.json()) as URLsResponse; + this.assertNoRedisUrl(parsed.redisUrl); this.cachedUrls = parsed; this.urlsCacheTime = now; - return this.cachedUrls; + return parsed; } catch (error) { console.error('[LeaderClient] Error calling /urls API:', error); return null; @@ -112,23 +137,37 @@ export class LeaderClient { } /** - * Read leader info from file and /urls API + * Resolve meta-core's URLs — from the announce when discovered (no HTTP + * hop needed, the payload already carries them), or over HTTP when pinned. */ + async getUrls(): Promise { + await this.ensureStarted(); + + const wire = this.locator.getUrls(); + if (wire && !this.config.metaCoreUrl) { + return { + hostname: wire.hostname, + baseUrl: wire.baseUrl, + apiUrl: wire.apiUrl, + redisUrl: '', + webdavUrl: wire.webdavUrl, + webdavUrlInternal: wire.webdavUrlInternal, + isLeader: true, + }; + } + + const apiUrl = this.locator.getApiUrl(); + if (!apiUrl) { + console.error('[LeaderClient] No meta-core API URL available'); + return null; + } + return this.fetchUrls(apiUrl); + } + async getLeaderInfo(): Promise { try { - // Read API URL from file (plain text) - const apiUrl = await this.getApiUrlFromFile(); - if (!apiUrl) { - return null; - } - - // Fetch full info from /urls API - const urls = await this.fetchUrls(apiUrl); - if (!urls) { - return null; - } - - // Convert URLsResponse to LeaderLockInfo + const urls = await this.getUrls(); + if (!urls) return null; this.leaderInfo = { hostname: urls.hostname, baseUrl: urls.baseUrl, @@ -137,7 +176,7 @@ export class LeaderClient { webdavUrl: urls.webdavUrl, webdavUrlInternal: urls.webdavUrlInternal, timestamp: Date.now(), - pid: 0 // Unknown for remote leader + pid: 0, }; return this.leaderInfo; } catch (error) { @@ -146,134 +185,67 @@ export class LeaderClient { } } - /** - * Get Redis URL from leader info - */ async getRedisUrl(): Promise { - const info = await this.getLeaderInfo(); - return info?.redisUrl ?? null; + return (await this.getLeaderInfo())?.redisUrl ?? null; } - /** - * Get WebDAV URL from leader info (external, via nginx/HTTPS) - */ async getWebdavUrl(): Promise { - const info = await this.getLeaderInfo(); - return info?.webdavUrl ?? null; + return (await this.getLeaderInfo())?.webdavUrl ?? null; } - /** - * Get internal WebDAV URL from leader info (direct to port 9000) - * Use this for container-to-container communication - */ + /** Internal WebDAV URL — use for container-to-container access. */ async getWebdavUrlInternal(): Promise { - const info = await this.getLeaderInfo(); - return info?.webdavUrlInternal ?? null; + return (await this.getLeaderInfo())?.webdavUrlInternal ?? null; } - /** - * Get meta-core API URL from leader info - */ async getApiUrl(): Promise { - const info = await this.getLeaderInfo(); - return info?.apiUrl ?? null; + return (await this.getLeaderInfo())?.apiUrl ?? null; } /** - * Call meta-core /urls API to get current URLs - * Useful for getting URLs when you don't want to read the file directly - */ - async getUrls(): Promise { - // First try using configured metaCoreUrl - let apiUrl = this.config.metaCoreUrl; - - // Fall back to reading from file - if (!apiUrl) { - apiUrl = await this.getApiUrlFromFile(); - } - - if (!apiUrl) { - console.error('[LeaderClient] No meta-core API URL available'); - return null; - } - - return this.fetchUrls(apiUrl); - } - - /** - * Wait for leader info to be available + * Block until meta-core is reachable. Probes immediately then every 500ms, + * matching the previous file-polling loop's timing exactly. */ async waitForLeader(timeoutMs: number = 30000): Promise { - const startTime = Date.now(); - const pollInterval = 500; + await this.ensureStarted(); + const deadline = Date.now() + timeoutMs; - while (Date.now() - startTime < timeoutMs) { + // Locate the core (pin returns instantly; discovery probes). + await this.locator.waitForCore(timeoutMs); + + // Then wait for a usable URLs payload — on the pinned path this is the + // first successful GET /urls, so meta-core still has to be up. + while (Date.now() < deadline) { const info = await this.getLeaderInfo(); if (info) { - console.log(`[LeaderClient] Leader found: ${info.hostname} at ${info.redisUrl}`); + console.log(`[LeaderClient] meta-core found: ${info.hostname} at ${info.apiUrl}`); return info; } - - console.log('[LeaderClient] Waiting for leader...'); - await new Promise(resolve => setTimeout(resolve, pollInterval)); + console.log('[LeaderClient] Waiting for meta-core...'); + await new Promise((resolve) => setTimeout(resolve, 500)); } - - throw new Error(`No leader found within ${timeoutMs}ms`); + throw new Error(`No meta-core found within ${timeoutMs}ms`); } /** - * Start watching for leader changes + * Previously an fs.watch on the lock directory. Discovery is always + * listening, so this only has to make sure the node is running. */ startWatching(): void { - if (this.watcher) { - return; // Already watching - } - - const lockDir = dirname(this.infoFilePath); - - try { - this.watcher = watch(lockDir, (eventType, filename) => { - if (filename === 'kv-leader.info') { - console.log('[LeaderClient] Leader info changed, invalidating cache...'); - // Invalidate cache to force fresh API call - this.cachedUrls = null; - this.urlsCacheTime = 0; - - this.getLeaderInfo().then(() => { - this.notifyChange(); - }).catch(console.error); - } - }); - - console.log(`[LeaderClient] Watching for leader changes in ${lockDir}`); - } catch (error) { - console.error('[LeaderClient] Failed to start watching:', error); - } + void this.ensureStarted(); } - /** - * Stop watching for leader changes - */ stopWatching(): void { - if (this.watcher) { - this.watcher.close(); - this.watcher = null; - console.log('[LeaderClient] Stopped watching for leader changes'); - } + // No-op: the locator keeps listening until close(). } - /** - * Register callback for leader changes - */ onChange(callback: () => void): this { this.onChangeCallbacks.push(callback); return this; } - /** - * Notify all change callbacks - */ private notifyChange(): void { + console.log('[LeaderClient] meta-core changed, invalidating cache...'); for (const callback of this.onChangeCallbacks) { try { callback(); @@ -283,18 +255,23 @@ export class LeaderClient { } } - /** - * Get cached leader info (without re-reading file) - */ getCachedLeaderInfo(): LeaderLockInfo | null { return this.leaderInfo; } - /** - * Clean up resources - */ + /** Neighbours for the nav menu (one row per service name). */ + getNeighbors(): MeshNeighbor[] { + return this.locator.getNeighbors(); + } + + /** This service's own announce, so the menu can show itself. */ + self(): MeshNeighbor { + return this.locator.self(); + } + close(): void { - this.stopWatching(); this.onChangeCallbacks = []; + void this.locator.stop(); + this.started = false; } } diff --git a/packages/meta-sort-ui/src/components/ServiceNav.tsx b/packages/meta-sort-ui/src/components/ServiceNav.tsx index c88f65e..049941d 100644 --- a/packages/meta-sort-ui/src/components/ServiceNav.tsx +++ b/packages/meta-sort-ui/src/components/ServiceNav.tsx @@ -1,165 +1,51 @@ -import { useState, useEffect } from 'react'; +import { useEffect } from 'react'; +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-sort'; + 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; diff --git a/packages/meta-sort-ui/src/meta-service-menu.js b/packages/meta-sort-ui/src/meta-service-menu.js new file mode 100644 index 0000000..17fa6b2 --- /dev/null +++ b/packages/meta-sort-ui/src/meta-service-menu.js @@ -0,0 +1,303 @@ +/** + * — the neighbouring meta-* services, as a burger menu. + * + * Replaces the eight divergent "services bar" implementations (four copies of + * a React `ServiceNav.tsx`, two mirrored `services.js`, two bespoke inline + * ones) with a single framework-free custom element. It drops into the React + * dashboards as a plain tag — no React dependency — and into the vanilla UIs + * as an ES module. + * + * Data comes from the host service's own `GET /api/neighbors`, same-origin. + * Never cross-origin: the dev stack's self-signed certs and the OIDC redirects + * make a fetch to a neighbour a dead end. Links are plain navigations. + * + * ⚠ MIRRORED FILE. Byte-identical copies live in every meta-* UI tree; see + * `scripts/check-mirrors.sh`. Edit one → edit all. + * + * Shadow DOM is not optional here. The eight UIs use four incompatible token + * vocabularies, and two of them define *different colours under the same + * names* (`--bg-secondary` is #12121a in meta-sort, #16213e in meta-core, + * #161b22 in meta-dup). meta-search and meta-share are also the only + * light-mode-capable UIs, so a hardcoded dark palette would break them. + * The element therefore styles itself from four tokens with dark defaults: + * + * --mm-nav-fg, --mm-nav-bg, --mm-nav-border, --mm-nav-accent + * + * Each host maps its own variables onto those, e.g. + * meta-service-menu { --mm-nav-bg: var(--bg-secondary); } + * + * Attributes: + * current name of the host service, so it can be marked and not linked + * endpoint override the default "/api/neighbors" + * label button tooltip (default "Services") + * + * Property: + * rewriteUrl optional (name, url, neighbour) => url, for hosts that must + * translate internal hostnames into browser-reachable ones + * (meta-gateway does this for its dev/prod split). + */ + +const ICONS = { + "meta-core": "⚙️", + "meta-sort": "📁", + "meta-fuse": "🗂️", + "meta-stremio": "🎬", + "meta-dup": "🔍", + "meta-search": "🔎", + "meta-share": "🔗", + "meta-gateway": "🌉", + "meta-watch": "📺", + "meta-listen": "🎧", + "meta-read": "📖", +}; +const DEFAULT_ICON = "📦"; +const POLL_MS = 30000; + +function titleCase(name) { + return String(name || "") + .split("-") + .map((w) => w.charAt(0).toUpperCase() + w.slice(1)) + .join(" "); +} + +const TEMPLATE = ` + + + +`; + +class MetaServiceMenu extends HTMLElement { + constructor() { + super(); + this.attachShadow({ mode: "open" }); + this.shadowRoot.innerHTML = TEMPLATE; + this._btn = this.shadowRoot.querySelector("button.trigger"); + this._panel = this.shadowRoot.querySelector(".panel"); + this._neighbors = []; + this._timer = null; + this._open = false; + + this._onDocClick = (e) => { + if (!this._open) return; + if (e.composedPath().includes(this)) return; + this._setOpen(false); + }; + this._onKey = (e) => { + if (e.key === "Escape" && this._open) { + this._setOpen(false); + this._btn.focus(); + } + }; + } + + connectedCallback() { + this._btn.title = this.getAttribute("label") || "Services"; + this._btn.setAttribute("aria-label", this._btn.title); + this._btn.addEventListener("click", () => this._setOpen(!this._open)); + document.addEventListener("click", this._onDocClick); + document.addEventListener("keydown", this._onKey); + + this.refresh(); + this._timer = setInterval(() => this.refresh(), POLL_MS); + } + + disconnectedCallback() { + if (this._timer) clearInterval(this._timer); + this._timer = null; + document.removeEventListener("click", this._onDocClick); + document.removeEventListener("keydown", this._onKey); + } + + get endpoint() { + return this.getAttribute("endpoint") || "/api/neighbors"; + } + + get current() { + return this.getAttribute("current") || ""; + } + + async refresh() { + try { + const res = await fetch(this.endpoint, { + headers: { Accept: "application/json" }, + }); + if (!res.ok) throw new Error(String(res.status)); + const data = await res.json(); + // `services` is the legacy alias some backends still emit. + this._neighbors = data.neighbors || data.services || []; + } catch { + // Silent: the menu is navigation, not a health indicator. Keep whatever + // we last had rather than blanking the list on one failed poll. + } + this._render(); + } + + _setOpen(open) { + this._open = open; + this._panel.hidden = !open; + this._btn.setAttribute("aria-expanded", String(open)); + if (open) this.refresh(); + } + + _render() { + const panel = this._panel; + panel.textContent = ""; + + const seen = new Set(); + const rows = []; + for (const n of this._neighbors) { + const name = n.name || ""; + if (!name || seen.has(name)) continue; + seen.add(name); + rows.push(n); + } + // Always show the host itself, even before its own echo comes back. + if (this.current && !seen.has(this.current)) { + rows.push({ name: this.current, baseUrl: "" }); + } + rows.sort((a, b) => String(a.name).localeCompare(String(b.name))); + + if (rows.length === 0) { + const empty = document.createElement("div"); + empty.className = "empty"; + empty.textContent = "No services found"; + panel.appendChild(empty); + return; + } + + for (const n of rows) { + const isActive = n.name === this.current; + let url = n.baseUrl || ""; + if (typeof this.rewriteUrl === "function") { + try { + url = this.rewriteUrl(n.name, url, n) || ""; + } catch { + /* a bad hook must not blank the menu */ + } + } + + // A neighbour with no reachable URL is shown but not linked — better + // than a link that goes nowhere. + const el = document.createElement(isActive || !url ? "span" : "a"); + el.className = + "item" + (isActive ? " active" : "") + (!isActive && !url ? " dead" : ""); + if (el.tagName === "A") { + el.href = url; + el.setAttribute("role", "menuitem"); + } + if (!isActive && !url) el.title = "No reachable URL announced"; + + const icon = document.createElement("span"); + icon.className = "icon"; + icon.textContent = ICONS[n.name] || DEFAULT_ICON; + + const label = document.createElement("span"); + label.textContent = titleCase(n.name); + + el.append(icon, label); + if (n.status === "running" || isActive) { + const dot = document.createElement("span"); + dot.className = "dot"; + dot.title = n.status || "running"; + el.appendChild(dot); + } + panel.appendChild(el); + } + } +} + +if (!customElements.get("meta-service-menu")) { + customElements.define("meta-service-menu", MetaServiceMenu); +} + +export default MetaServiceMenu;