Compare commits

...
15 Commits
Author SHA1 Message Date
Worph fb9c987c0b CI: push to ipcr-push-85-17-246-67.ip.nsl.sh (the box's cert no longer covers nip.io) [skip ci] 2026-11-04 14:31:30 +00:00
Manielle 5c03b2c320 CI accepts four-part tags (v1.2.3.1) for fixes
Build and Push Docker Image / build (push) Successful in 1m9s
2026-10-25 14:22:03 +01:00
Worph 4a408301c2 release (first build on gitea-watch / IPCR)
Build and Push Docker Image / build (push) Successful in 1m20s
2026-10-13 11:02:28 +00:00
Worph ad82b2bdef gitea workflow: push to IPCR's direct host (Cloudflare 100 MB cap) [skip ci] 2026-10-13 10:02:03 +00:00
Worph 9b47444985 gitea workflow: publish to IPCR (metadec.eth) instead of ghcr
Build and Push Docker Image / build (push) Successful in 1m15s
2026-10-12 18:30:15 +00:00
Worph 5d0bea08d3 meshdisco.ts: beacon v2 scan (probe + classify) mirrored from meta-sort; LeaderClient.scan passthrough
Build and Push Docker Image / build (push) Failing after 13s
2026-10-04 17:19:29 +00:00
Worph cceeb07e95 beacon v2 discovery: meshdisco.ts on 239.255.99.1:9099, core located by metamesh.core cap, /api/neighbors ?cap=, nav lists services only 2026-10-04 11:51:09 +00:00
Worph b8fd46c956 README: sync with current code 2026-10-03 11:51:55 +00:00
Worph 4eb8afa08b replace file discovery by udp discovery 2026-09-14 11:24:54 +02:00
Worph f4a283d3d2 change service discovery 2026-09-13 19:49:04 +02:00
Worph aae309134a align with metadata standard 2026-09-13 15:57:21 +02:00
Worph 7f4beb6c06 gitattributes 2026-07-14 16:09:01 +02:00
Pierre fec2eb105d update cid management 2026-05-31 11:40:01 +02:00
Pierre d34370d1b3 improve doc 2026-05-25 23:46:54 +02:00
Pierre 5194bcf52b bugfix event 2026-03-07 14:47:14 +01:00
20 changed files with 1432 additions and 1426 deletions
+6
View File
@@ -0,0 +1,6 @@
# FIX CRLF always when developer has not set
# Linux/Mac: git config --global core.autocrlf input
# Windows: git config --global core.autocrlf true
# Auto detect text files and perform LF normalization
* text=auto
* eol=lf
+98
View File
@@ -0,0 +1,98 @@
# Gitea twin of .github/workflows/docker-publish.yml. Gitea runs only .gitea/workflows/ once this
# folder exists, and GitHub never reads it.
#
# Publishes to IPCR on watch.nsl.sh instead of ghcr.io:
# push ipcr-push-85-17-246-67.ip.nsl.sh/metadec.eth/<repo>:<tag> docker login as metadec.eth with the
# IPCR_PUSH_TOKEN secret (the metadec.eth namespace token from the IPCR dashboard)
# pull ipcr-watch.nsl.sh/ipns/metadec.eth/metadec.eth/<repo>:<tag> anonymous
# The push goes to ipcr-push-85-17-246-67.ip.nsl.sh, the box's own address, not ipcr-push-watch.nsl.sh: Cloudflare fronts
# *.watch.nsl.sh and refuses any request over 100 MB (413), which a large image layer is.
# That host's certificate is signed by the box's private Mesh Router CA, given to buildkit from
# the IPCR_CA_PEM repo variable. docker login would make the job's Docker daemon check the
# certificate without that CA, so the login is written straight into ~/.docker/config.json.
# The runner's job image (node:22-bookworm) has no docker CLI, so the static one is
# installed first; buildx installs itself. No `type=gha` cache: the runner has none.
name: Build and Push Docker Image
on:
push:
branches:
- main
tags:
- 'v*'
workflow_dispatch:
inputs:
tag:
description: 'Tag for the image (optional, defaults to sha)'
required: false
type: string
meta_core_version:
description: 'meta-core version to use (default: latest)'
required: false
type: string
default: 'latest'
env:
REGISTRY: ipcr-push-85-17-246-67.ip.nsl.sh
IPCR_PULL: ipcr-watch.nsl.sh/ipns/metadec.eth
IMAGE_NAME: metadec.eth/${{ github.event.repository.name }}
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Install Docker CLI
run: curl -fsSL https://download.docker.com/linux/static/stable/x86_64/docker-28.5.1.tgz | tar -xz -C /usr/local/bin --strip-components=1 docker/docker
- name: Trust the Mesh Router CA
run: printf '%s\n' "$IPCR_CA_PEM" > /tmp/ipcr-ca.pem
env:
IPCR_CA_PEM: ${{ vars.IPCR_CA_PEM }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
with:
version: latest
buildkitd-config-inline: |
[registry."${{ env.REGISTRY }}"]
ca = ["/tmp/ipcr-ca.pem"]
- name: Log in to IPCR
env:
IPCR_PUSH_TOKEN: ${{ secrets.IPCR_PUSH_TOKEN }}
run: |
mkdir -p ~/.docker
printf '{"auths":{"%s":{"auth":"%s"}}}\n' "$REGISTRY" "$(printf 'metadec.eth:%s' "$IPCR_PUSH_TOKEN" | base64 -w0)" > ~/.docker/config.json
- name: Extract metadata (tags, labels)
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=semver,pattern={{version}}
# A fix to release 1.2.3 is tagged v1.2.3.1: four numbers are not semver, so
# the pattern above makes no tag for it; this one does (image tag 1.2.3.1).
type=match,pattern=v(\d+\.\d+\.\d+\.\d+),group=1
type=semver,pattern={{major}}.{{minor}}
type=sha,prefix=
type=raw,value=latest,enable={{is_default_branch}}
type=raw,value=${{ inputs.tag }},enable=${{ inputs.tag != '' }}
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: |
META_CORE_IMAGE=${{ env.IPCR_PULL }}/metadec.eth/meta-core:${{ inputs.meta_core_version || 'latest' }}
+3
View File
@@ -50,6 +50,9 @@ jobs:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: | tags: |
type=semver,pattern={{version}} type=semver,pattern={{version}}
# A fix to release 1.2.3 is tagged v1.2.3.1: four numbers are not semver, so
# the pattern above makes no tag for it; this one does (image tag 1.2.3.1).
type=match,pattern=v(\d+\.\d+\.\d+\.\d+),group=1
type=semver,pattern={{major}}.{{minor}} type=semver,pattern={{major}}.{{minor}}
type=sha,prefix= type=sha,prefix=
type=raw,value=latest,enable={{is_default_branch}} type=raw,value=latest,enable={{is_default_branch}}
+2
View File
@@ -1,2 +1,4 @@
node_modules node_modules
packages/meta-dup-core/dist packages/meta-dup-core/dist
packages/meta-dup-ui/dist
packages/meta-dup-ui/node_modules
+55 -41
View File
@@ -1,31 +1,38 @@
# meta-dup # meta-dup
Standalone duplicate detection service for MetaMesh v2. Detects duplicate files by: Standalone duplicate detection service for MetaMesh v2. Detects duplicate files by:
- **Hash (SHA-256)**: Files with identical content - **Hash**: Files with identical content — same `midhash256` (record address) seen at several paths, or same full-file SHA-256
- **Title**: Files with the same parsed title (from filename patterns) - **Title**: Files with the same parsed title (+ year / season / episode when known)
> **Status: not wired into the current stack.** meta-dup was removed from the main dev compose (`dev/docker-compose.yml`) in March 2026 and has no MetaAppStore app. Its storage layer still talks to Redis directly (ioredis + Redis Streams), while meta-core has since stopped publishing a `redisUrl` (api-mediated-access lockdown) — so against a current meta-core it has no Redis to connect to. meta-fuse shows the migrated pattern (HTTP reads via `/meta/{hash}*` + SSE `/api/events/meta`). Treat the sections below as a description of the code, not of a running service.
## Architecture ## Architecture
meta-dup is a read-only service that: meta-dup is a read-only service that:
1. Subscribes to Redis Streams (`meta-sort:events`) for real-time updates 1. Locates meta-core over UDP multicast (beacon v2, `239.255.99.1:9099`) — or uses `META_CORE_URL` when pinned
2. Maintains an in-memory index of duplicates 2. Builds an in-memory duplicate index from every record in Redis on startup
3. Provides REST API and web dashboard 3. Consumes Redis Streams for real-time updates (`file:events` for file add/change/delete/rename, `meta:events` for title-field changes)
4. Provides a REST API and web dashboard (nginx on port 80 → Fastify on 3000)
``` ```
meta-sort ──► Redis Streams ──► meta-dup ──► Dashboard (port 8183) meta-core ──► Redis Streams ──► meta-dup ──► Dashboard / REST API
(events) (consumer) (file:events, (consumer)
meta:events)
``` ```
See [beacon-v2.md](../../docs/project-architecture/beacon-v2.md) for the discovery protocol.
## API Endpoints ## API Endpoints
| Endpoint | Method | Description | | Endpoint | Method | Description |
|----------|--------|-------------| |----------|--------|-------------|
| `/health` | GET | Health check with stats | | `/health` | GET | Health check with stats (`status: ok\|degraded`) |
| `/api/duplicates` | GET | All duplicates (hash + title) | | `/api/duplicates` | GET | All duplicates (hash + title) |
| `/api/duplicates/hash` | GET | Hash duplicates only | | `/api/duplicates/hash` | GET | Hash duplicates only |
| `/api/duplicates/title` | GET | Title duplicates only | | `/api/duplicates/title` | GET | Title duplicates only |
| `/api/duplicates/stats` | GET | Duplicate statistics | | `/api/duplicates/stats` | GET | Duplicate statistics |
| `/api/duplicates/rebuild` | POST | Force rebuild from Redis | | `/api/duplicates/rebuild` | POST | Force rebuild from Redis |
| `/api/neighbors` | GET | Services heard over beacon v2 (dashboard nav menu) |
## Response Format ## Response Format
@@ -33,7 +40,7 @@ meta-sort ──► Redis Streams ──► meta-dup ──► Dashboard (port 8
{ {
"hashDuplicates": [ "hashDuplicates": [
{ {
"key": "sha256-hash", "key": "<midhash256 or sha256>",
"files": [ "files": [
{ "hashId": "...", "filePath": "/files/...", "sizeByte": 123456 } { "hashId": "...", "filePath": "/files/...", "sizeByte": 123456 }
] ]
@@ -42,10 +49,13 @@ meta-sort ──► Redis Streams ──► meta-dup ──► Dashboard (port 8
"titleDuplicates": [...], "titleDuplicates": [...],
"stats": { "stats": {
"hashGroupCount": 5, "hashGroupCount": 5,
"hashFileCount": 12,
"titleGroupCount": 3, "titleGroupCount": 3,
"totalFilesTracked": 100 "titleFileCount": 7,
"totalFilesTracked": 100,
"lastUpdated": "2026-01-01T00:00:00.000Z"
}, },
"computedAt": 1234567890 "computedAt": "2026-01-01T00:00:00.000Z"
} }
``` ```
@@ -58,34 +68,35 @@ pnpm install
pnpm build pnpm build
``` ```
### Run (in Docker dev environment) ### Docker image
```bash ```bash
cd dev docker build -t meta-dup .
./start.sh # Starts all services including meta-dup # The image also copies the meta-core binary from META_CORE_IMAGE
# (default ghcr.io/worph/meta-core:latest) and runs it under supervisord.
``` ```
### Rebuild CI publishes `ghcr.io/worph/meta-dup` on `v*` tags (`.github/workflows/docker-publish.yml`).
```bash
cd dev
./reload-meta-dup.sh
```
### Tests ### Dev stack / tests
```bash The main dev stack no longer defines a meta-dup container, so `dev/scripts/reload-meta-dup.sh` (targets container `meta-dup`) and the `dup` suite of `dev/test/test.sh` have nothing to run against until it is re-added.
docker exec meta-test-runner /app/test/test.sh dup
```
## Configuration ## Configuration
meta-dup uses service discovery via `/meta-core/locks/kv-leader.info` to find the Redis instance. No additional configuration required. meta-core is located over UDP (beacon v2); no `/meta-core` volume mount or `REDIS_URL` is needed.
### Environment Variables ### Environment Variables
| Variable | Default | Description | | Variable | Default | Description |
|----------|---------|-------------| |----------|---------|-------------|
| `BASE_URL` | `http://localhost:8183` | External URL for service | | `META_CORE_URL` | — | Pin meta-core's API URL; UDP discovery never overrides it |
| `SERVICE_NAME` | `meta-dup` | Service identifier | | `ENABLE_UDP_DISCOVERY` | on | Set `false`/`0` to disable meta-discovery |
| `BASE_URL` | — | External URL (announced for the nav menu) |
| `PUBLIC_URL` | — | Announced URL; wins over `BASE_URL` (debug-direct, no Caddy) |
| `API_PORT` | `3000` | Internal API port (nginx proxies to 80) | | `API_PORT` | `3000` | Internal API port (nginx proxies to 80) |
| `API_HOST` | `0.0.0.0` | API bind address |
| `REDIS_PREFIX` | `''` | Key prefix in Redis |
| `FILES_VOLUME` | `/files` | Prepended to the relative `filePath` of each record |
| `ALLOW_LEGACY_REDIS_URL` | — | `1` downgrades the "meta-core still publishes redisUrl" startup error to a warning |
## Package Structure ## Package Structure
@@ -93,14 +104,19 @@ meta-dup uses service discovery via `/meta-core/locks/kv-leader.info` to find th
packages/meta-dup/ packages/meta-dup/
├── package.json # Workspace root ├── package.json # Workspace root
├── pnpm-workspace.yaml ├── pnpm-workspace.yaml
├── Dockerfile
├── docker/ # nginx.conf, supervisord.conf
└── packages/ └── packages/
├── meta-dup-core/ # Backend service ├── meta-dup-core/ # Backend service
│ ├── src/ │ ├── src/
│ │ ├── index.ts # Entry point │ │ ├── index.ts # Entry point, file:events consumer
│ │ ├── DuplicateIndex.ts # Core duplicate detection │ │ ├── DuplicateIndex.ts # Core duplicate detection
│ │ ├── MetaEventConsumer.ts # meta:events consumer (title updates)
│ │ ├── api/ │ │ ├── api/
│ │ │ └── APIServer.ts # Fastify REST API │ │ │ └── APIServer.ts # Fastify REST API
│ │ └── kv/ # Redis client & leader discovery │ │ ├── discovery/
│ │ │ └── meshdisco.ts # beacon v2 (mirrored, see scripts/check-mirrors.sh)
│ │ └── kv/ # Redis client + meta-core locator (LeaderClient)
│ └── package.json │ └── package.json
└── meta-dup-ui/ # React dashboard └── meta-dup-ui/ # React dashboard
├── src/ ├── src/
@@ -112,23 +128,21 @@ packages/meta-dup/
## How Duplicate Detection Works ## How Duplicate Detection Works
### Hash Duplicates ### Hash Duplicates
Files with the same `cid_sha2-256` (full file hash) are exact content duplicates. - **Path-based**: `file:events` add/change/rename messages carry `midhash256` + `path`; one midhash seen at several paths is a duplicate.
- **Full hash**: the full-file SHA-256 is read from the record's bare-CID key-set (`cids/<cid>`, picking the sha2-256 multicodec). Records with the same SHA-256 are exact content duplicates. See [METADATA_KEYS.md](../../METADATA_KEYS.md).
### Title Duplicates ### Title Duplicates
Files parsed to the same title (e.g., "Movie Name (2020)") from their filename patterns. Useful for finding different quality versions of the same content. Files resolving to the same normalized key `title[:year][:sN][:eN]`. Useful for finding different quality versions of the same content. Title fields (`title`, `originalTitle`, `fileName`, `year`, `movieYear`, `season`, `episode`, `tmdb`) are re-read on `meta:events` changes, debounced 500 ms.
## Redis Streams Integration ## Redis Streams Integration
meta-dup subscribes to `meta-sort:events` stream with consumer group `meta-dup-consumer`: | Stream | Consumer group | Handles |
|--------|----------------|---------|
``` | `file:events` | `meta-dup-consumer` | `add`, `change`, `delete`, `rename`; legacy `batch`, `reset`, `plugin:complete` |
Event types: | `meta:events` | `meta-dup-meta-consumer` | `set`/`del` on `file:{hashId}/{field}` for title fields |
- batch: File batch processed (add/update duplicates)
- reset: Full index rebuild required
```
On startup, meta-dup: On startup, meta-dup:
1. Creates/joins consumer group 1. Rebuilds the full index from Redis (via the `file:__index__` set)
2. Processes any pending entries 2. Creates/joins the `file:events` consumer group
3. Rebuilds full index from Redis `file:*` keys 3. Processes pending entries idle > 30 s
4. Starts listening for new events 4. Starts listening for new events on both streams
@@ -189,9 +189,6 @@ export class DuplicateIndex {
if (metadata.title) { if (metadata.title) {
return metadata.title; return metadata.title;
} }
if (metadata.titles?.eng) {
return metadata.titles.eng;
}
if (metadata.originalTitle) { if (metadata.originalTitle) {
return metadata.originalTitle; return metadata.originalTitle;
} }
@@ -19,7 +19,7 @@ const CONSUMER_GROUP = 'meta-dup-meta-consumer';
// Fields that affect title duplicate detection // Fields that affect title duplicate detection
const TITLE_FIELDS = [ const TITLE_FIELDS = [
'title', 'titles/eng', 'originalTitle', 'fileName', 'title', 'originalTitle', 'fileName',
'year', 'movieYear', 'season', 'episode', 'tmdb' 'year', 'movieYear', 'season', 'episode', 'tmdb'
]; ];
+17 -31
View File
@@ -153,39 +153,25 @@ export class APIServer {
}; };
}); });
// Service discovery (for dashboard navigation) // beacon v2: neighbours heard over UDP, from this service's
this.app.get('/api/services', async (request: FastifyRequest, reply: FastifyReply) => { // own map. The previous note here claimed nginx proxied /api/services
const services: Array<{ // to meta-core — it never did (no such location), so meta-dup's nav
name: string; // has 404'd for its whole life. This one is served locally.
url: string; this.app.get('/api/neighbors', async (request, reply) => {
api: string; const leaderClient = this.kvManager?.getLeaderClient();
status: string; if (!leaderClient) {
role?: string; return reply.send({ current: 'meta-dup', enabled: false, count: 0, neighbors: [] });
}> = [];
try {
const serviceDiscovery = this.kvManager.getServiceDiscovery();
if (serviceDiscovery) {
const allServices = await serviceDiscovery.discoverAllServices();
for (const svc of allServices) {
services.push({
name: svc.name || 'Unknown',
url: svc.baseUrl || '',
api: svc.baseUrl || '',
status: svc.status || 'unknown',
role: svc.role,
});
} }
} const q = (request.query ?? {}) as { all?: string; cap?: string };
} catch (error: any) { const neighbors = leaderClient.getNeighbors({ all: !!q.all, cap: q.cap || undefined });
logger.error('Error discovering services:', error); return reply.send({
}
return {
services,
current: 'meta-dup', 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)
@@ -0,0 +1,666 @@
/**
* Beacon v2 — local resource advertise / discover over UDP multicast
* (239.255.99.1:9099).
*
* Every node advertises resources tagged with capability strings
* (`metamesh.core`, `metamesh.service/meta-sort`, `metamesh.transport/nzb@1`)
* and keeps a live view of everyone else's. This service uses it to locate
* meta-core (the `metamesh.core` resource carries its /urls block) and to feed
* the nav menu (`metamesh.service/*`), with no shared volume.
*
* The normative spec is docs/project-architecture/beacon-v2.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 PROTO = 'beacon';
export const VERSION = 2;
export const DEFAULT_GROUP = '239.255.99.1';
export const DEFAULT_PORT = 9099;
export const DEFAULT_INTERVAL_MS = 10_000;
/** A node unheard for interval × this is dropped (evaluated at read time). */
export const LIVENESS_FACTOR = 3;
/** Senders stay under this so nothing fragments. */
export const MAX_DATAGRAM = 1400;
export const TYPE_PROBE = 'probe';
export const TYPE_ADVERTISE = 'advertise';
export const TYPE_BYE = 'bye';
export const CAP_CORE = 'metamesh.core';
export const CAP_SERVICE_PREFIX = 'metamesh.service/';
export const CAP_ANY_SERVICE = 'metamesh.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 BeaconNodeInfo {
name: string;
instance: string;
version?: string;
/** 'starting' | 'running'; absent reads as running. */
status?: string;
}
export interface BeaconResource {
id: string;
caps: string[];
/** Well-known: http, ui, manifest, mcp. A '/…' value is relative to http. */
endpoints?: Record<string, string>;
rev?: string;
/** The one consumer instance this resource belongs to. */
binds?: string;
data?: Record<string, unknown>;
}
export interface BeaconMessage {
proto: string;
v: number;
type: string;
node?: BeaconNodeInfo;
resources?: BeaconResource[];
from?: string;
want?: string[];
}
/** A node as served by /api/neighbors. */
export interface MeshNeighbor extends BeaconNodeInfo {
resources: BeaconResource[];
/** Every resource's caps, flattened. */
caps: string[];
/** The metamesh.service/* resource's endpoints.ui (v1-compatible field). */
baseUrl?: string;
/** Source address taken from the packet, never from the payload. */
addr: string;
/** Unix seconds. */
lastSeen: number;
}
export interface MeshNodeConfig {
name: string;
instance?: string;
version?: string;
group?: string;
port?: number;
intervalMs?: number;
enabled?: boolean;
/** Rebuilt on every advertise so the payload never goes stale. */
payload?: () => { status?: string; resources: BeaconResource[] };
}
/** How a discovered resource relates to this consumer (Scan card). */
export type ScanState = 'configured' | 'addable' | 'bound-elsewhere';
export interface ScanCandidate {
instance: string;
name: string;
version?: string;
resourceId: string;
caps: string[];
/** endpoints.http — what the consumer would register. */
url: string;
binds?: string;
state: ScanState;
/** The list entry that already holds this URL (state `configured`). */
configuredAs?: string;
/** A list-safe name to register it under. */
suggestedName: string;
}
/** Same JSON as meta-feeder-sdk `beacon::ScanReport`. */
export interface ScanReport {
cap: string;
self: string;
durationMs: number;
summary: { nodes: number; capable: number; configured: number; boundElsewhere: number; addable: number };
candidates: ScanCandidate[];
}
/** Lower-case, `[a-z0-9._-]` only — accepted by every consumer's name rules. */
export function suggestedName(instance: string): string {
const s = instance.trim().toLowerCase().replace(/[^a-z0-9._-]/g, '-');
return s || 'plugin';
}
const normUrl = (u: string) => u.trim().replace(/\/+$/, '');
/**
* Classify every resource matching `pattern` against the consumer's
* `(name, url)` list. Mirrors meta-feeder-sdk `beacon::classify`.
*/
export function classifyScan(
pattern: string,
self: string,
neighbors: MeshNeighbor[],
configured: Array<[string, string]>,
): ScanReport {
const summary = { nodes: neighbors.length, capable: 0, configured: 0, boundElsewhere: 0, addable: 0 };
const candidates: ScanCandidate[] = [];
for (const n of neighbors) {
for (const r of n.resources) {
if (!resourceMatches(r, pattern)) continue;
const http = resourceEndpoint(r, 'http');
if (!http) continue; // nothing a consumer could register
const url = normUrl(http);
summary.capable++;
const hit = configured.find(([, u]) => normUrl(u) === url);
let state: ScanState;
if (hit) { state = 'configured'; summary.configured++; }
else if (r.binds && r.binds !== self) { state = 'bound-elsewhere'; summary.boundElsewhere++; }
else { state = 'addable'; summary.addable++; }
candidates.push({
instance: n.instance,
name: n.name,
...(n.version ? { version: n.version } : {}),
resourceId: r.id,
caps: r.caps ?? [],
url,
...(r.binds ? { binds: r.binds } : {}),
state,
...(hit ? { configuredAs: hit[0] } : {}),
suggestedName: suggestedName(n.instance),
});
}
}
const rank: Record<ScanState, number> = { addable: 0, configured: 1, 'bound-elsewhere': 2 };
candidates.sort((a, b) => rank[a.state] - rank[b.state] || a.instance.localeCompare(b.instance));
return { cap: pattern, self, durationMs: 0, summary, candidates };
}
/**
* Does capability `cap` satisfy `pattern`? `*` matches everything; an `@N` on
* the pattern must equal the cap's contract (none ignores it); `x/*` matches
* any variant of `x`; otherwise the bases must be equal.
*/
export function capMatches(pattern: string, cap: string): boolean {
if (pattern === '*') return true;
const split = (s: string): [string, string | null] => {
const i = s.lastIndexOf('@');
return i >= 0 ? [s.slice(0, i), s.slice(i + 1)] : [s, null];
};
const [pbase, pc] = split(pattern);
const [cbase, cc] = split(cap);
if (pc !== null && cc !== pc) return false;
if (pbase.endsWith('/*')) {
const prefix = pbase.slice(0, -2);
if (!cbase.startsWith(prefix)) return false;
const rest = cbase.slice(prefix.length);
return rest.startsWith('/') && rest.length > 1;
}
return pbase === cbase;
}
export function resourceMatches(r: BeaconResource, pattern: string): boolean {
return (r.caps ?? []).some((c) => capMatches(pattern, c));
}
/** The named endpoint as an absolute URL ('/…' joins onto endpoints.http). */
export function resourceEndpoint(r: BeaconResource, name: string): string | undefined {
const v = r.endpoints?.[name];
if (!v) return undefined;
if (!v.startsWith('/')) return v;
const base = r.endpoints?.http;
return base ? base.replace(/\/+$/, '') + v : undefined;
}
/**
* Decode a datagram; null for anything that is not a well-formed beacon v2
* message (beacon v1, meta-discovery v1, other versions, junk).
*/
export function parseMessage(buf: Buffer): BeaconMessage | null {
let m: BeaconMessage;
try {
m = JSON.parse(buf.toString('utf8')) as BeaconMessage;
} catch {
return null;
}
if (!m || m.proto !== PROTO || m.v !== VERSION) return null;
if (m.type === TYPE_PROBE) return m;
if (m.type === TYPE_ADVERTISE || m.type === TYPE_BYE) {
if (!m.node?.name || !m.node?.instance) return null;
return m;
}
return null;
}
/** 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();
}
function envNum(key: string): number | undefined {
const n = Number(process.env[key]);
return Number.isFinite(n) && n > 0 ? n : undefined;
}
interface Seen {
node: BeaconNodeInfo;
resources: BeaconResource[];
addr: string;
lastSeen: number; // ms
}
function toNeighbor(s: Seen): MeshNeighbor {
const caps: string[] = [];
let baseUrl: string | undefined;
for (const r of s.resources) {
for (const c of r.caps ?? []) if (!caps.includes(c)) caps.push(c);
if (!baseUrl && resourceMatches(r, CAP_ANY_SERVICE)) baseUrl = resourceEndpoint(r, 'ui');
}
return {
...s.node,
resources: s.resources,
caps,
...(baseUrl ? { baseUrl } : {}),
addr: s.addr,
lastSeen: s.lastSeen / 1000,
};
}
/**
* One beacon v2 participant: advertises its resources, answers probes, and
* keeps a TTL map of every other node.
*/
export class MeshNode {
private cfg: Required<Omit<MeshNodeConfig, 'payload'>> & Pick<MeshNodeConfig, 'payload'>;
private socket: dgram.Socket | null = null;
private timer: NodeJS.Timeout | null = null;
private nodes = new Map<string, Seen>();
private ifaceAddrs: string[] = [];
private listeners: ((n: MeshNeighbor) => void)[] = [];
private started = false;
constructor(config: MeshNodeConfig) {
this.cfg = {
name: config.name,
instance: config.instance ?? hostname(),
version: config.version ?? '',
group: config.group ?? process.env.BEACON_GROUP ?? DEFAULT_GROUP,
port: config.port ?? envNum('BEACON_PORT') ?? DEFAULT_PORT,
intervalMs: config.intervalMs ?? envNum('BEACON_INTERVAL_MS') ?? DEFAULT_INTERVAL_MS,
enabled: config.enabled ?? true,
payload: config.payload,
};
}
async start(): Promise<void> {
if (this.started || !this.cfg.enabled) {
if (!this.cfg.enabled) console.log('[beacon] Disabled by configuration');
return;
}
this.started = true;
await new Promise<void>((resolve) => {
// reuseAddr sets SO_REUSEADDR (+ SO_REUSEPORT on Linux for udp4),
// so several listeners can share 9099 on one host.
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('[beacon] Socket error:', err.message);
});
socket.bind(this.cfg.port, '0.0.0.0', () => {
try {
socket.setMulticastTTL(1); // link-local only
} catch (e) {
console.warn('[beacon] 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(`[beacon] Join ${this.cfg.group} on ${addr} failed:`, e);
}
}
console.log(
`[beacon] Listening on ${this.cfg.group}:${this.cfg.port} as ` +
`${this.cfg.name}/${this.cfg.instance} (joined ${joined} of ${this.ifaceAddrs.length} interfaces)`
);
resolve();
});
});
this.advertise();
this.probe();
this.timer = setInterval(() => this.advertise(), this.cfg.intervalMs);
// Don't hold the event loop open on shutdown.
this.timer.unref?.();
}
async stop(): Promise<void> {
if (!this.started) return;
this.started = false;
if (this.timer) { clearInterval(this.timer); this.timer = null; }
// Bye, so neighbours drop us immediately rather than waiting out the
// staleness window.
this.multicast({ proto: PROTO, v: VERSION, type: TYPE_BYE, node: this.nodeInfo() });
await new Promise<void>((resolve) => {
if (!this.socket) return resolve();
try { this.socket.close(() => resolve()); } catch { resolve(); }
});
this.socket = null;
this.listeners = [];
}
/**
* Multicast a probe. Every node owning a resource matching one of `want`
* (every node, when empty) replies immediately.
*/
probe(want: string[] = []): void {
const msg: BeaconMessage = { proto: PROTO, v: VERSION, type: TYPE_PROBE, from: this.cfg.instance };
if (want.length) msg.want = want;
this.multicast(msg);
}
/** Every live node, sorted by (name, instance). */
getNeighbors(): MeshNeighbor[] {
const cutoff = Date.now() - this.cfg.intervalMs * LIVENESS_FACTOR;
const out: MeshNeighbor[] = [];
for (const [key, s] of this.nodes) {
if (s.lastSeen < cutoff) { this.nodes.delete(key); continue; }
out.push(toNeighbor(s));
}
return out.sort((a, b) => a.name.localeCompare(b.name) || a.instance.localeCompare(b.instance));
}
/** One row per node name, most recently seen instance wins. */
getNeighborsByName(): MeshNeighbor[] {
const best = new Map<string, MeshNeighbor>();
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));
}
/**
* The Scan card: probe everyone, wait `waitMs` for the replies, then
* classify what matches `pattern` against `configured` ([name, url]).
*/
async scan(pattern: string, configured: Array<[string, string]>, waitMs = 1500): Promise<ScanReport> {
const started = Date.now();
this.probe();
await new Promise((r) => setTimeout(r, waitMs));
const report = classifyScan(pattern, this.cfg.instance, this.getNeighbors(), configured);
report.durationMs = Date.now() - started;
return report;
}
/** This node as a row, so a UI can render itself without waiting. */
self(): MeshNeighbor {
const { status, resources } = this.payload();
return toNeighbor({ node: this.nodeInfo(status), resources, addr: '', lastSeen: Date.now() });
}
/** Fires for every advertise received (after the self-echo filter). */
onAdvertise(cb: (n: MeshNeighbor) => void): void {
this.listeners.push(cb);
}
private payload(): { status?: string; resources: BeaconResource[] } {
return this.cfg.payload ? this.cfg.payload() : { resources: [] };
}
private nodeInfo(status?: string): BeaconNodeInfo {
const n: BeaconNodeInfo = { name: this.cfg.name, instance: this.cfg.instance };
if (this.cfg.version) n.version = this.cfg.version;
if (status) n.status = status;
return n;
}
private advertiseMsg(): BeaconMessage {
const { status, resources } = this.payload();
return { proto: PROTO, v: VERSION, type: TYPE_ADVERTISE, node: this.nodeInfo(status), resources };
}
private onMessage(buf: Buffer, rinfo: dgram.RemoteInfo): void {
const msg = parseMessage(buf);
if (!msg) return; // v1 or foreign traffic on the shared group
if (msg.type === TYPE_PROBE) {
if (msg.from === this.cfg.instance) return;
const ad = this.advertiseMsg();
const want = msg.want ?? [];
const wanted = want.length === 0 ||
(ad.resources ?? []).some((r) => want.some((p) => resourceMatches(r, p)));
if (wanted) this.replyTo(ad, rinfo);
return;
}
const node = msg.node!;
if (node.instance === this.cfg.instance) return; // our own echo
if (msg.type === TYPE_BYE) { this.nodes.delete(node.instance); return; }
const seen: Seen = { node, resources: msg.resources ?? [], addr: rinfo.address, lastSeen: Date.now() };
this.nodes.set(node.instance, seen);
const nb = toNeighbor(seen);
for (const cb of this.listeners) {
try { cb(nb); } catch (e) { console.error('[beacon] Listener threw:', e); }
}
}
private encode(msg: BeaconMessage): Buffer {
const body = Buffer.from(JSON.stringify(msg));
if (body.length > MAX_DATAGRAM) {
console.warn(`[beacon] ${body.length}-byte datagram exceeds the fragmentation-safe ${MAX_DATAGRAM}`);
}
return body;
}
private advertise(): void {
this.multicast(this.advertiseMsg());
}
private replyTo(msg: BeaconMessage, rinfo: dgram.RemoteInfo): void {
try {
this.socket?.send(this.encode(msg), rinfo.port, rinfo.address);
} catch (e) {
console.warn('[beacon] Reply failed:', e);
}
}
/** Writes once per interface — the default route alone reaches one network. */
private multicast(msg: BeaconMessage): void {
const socket = this.socket;
if (!socket) return;
const body = this.encode(msg);
for (const addr of this.ifaceAddrs) {
try {
socket.setMulticastInterface(addr);
socket.send(body, this.cfg.port, this.cfg.group);
} catch (e) {
console.warn(`[beacon] Send on ${addr} failed:`, e);
}
}
}
}
export interface MetaCoreLocatorConfig {
/** This service's name, for its own advertise. */
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
* metamesh.core pin". 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 beacon v2 (the first `metamesh.core` resource that
* carries `data.urls`), and advertises this service as
* `metamesh.service/<serviceName>`.
*/
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,
version: config.version,
group: config.group,
port: config.port,
intervalMs: config.intervalMs,
enabled: config.enabled,
payload: () => ({
resources: [{
id: 'service',
caps: [CAP_SERVICE_PREFIX + config.serviceName],
endpoints: { ui: config.baseUrl ?? `http://${localIPv4()}` },
}],
}),
});
this.node.onAdvertise((nb) => {
if (this.cfg.metaCoreUrl) return; // pinned: the wire cannot move us
const core = nb.resources.find((r) => resourceMatches(r, CAP_CORE));
const urls = core?.data?.urls as MeshUrls | undefined;
if (!urls?.apiUrl) return;
const prev = this.current;
if (!prev) {
this.current = urls;
console.log(`[beacon] meta-core discovered at ${urls.apiUrl} (${nb.addr})`);
this.notifyChange();
return;
}
if (prev.apiUrl === urls.apiUrl) {
this.current = urls; // refresh the rest of the fields
return;
}
// A second, different core is advertising. 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(
`[beacon] Ignoring a second meta-core at ${urls.apiUrl} (${nb.addr}); ` +
`staying with ${prev.apiUrl}. Set META_CORE_URL to pin this explicitly.`
);
});
}
async start(): Promise<void> {
if (this.cfg.metaCoreUrl) {
console.log(`[beacon] meta-core pinned to ${this.cfg.metaCoreUrl}; discovery is advisory`);
}
await this.node.start();
}
async stop(): Promise<void> {
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<string> {
const deadline = Date.now() + timeoutMs;
let logged = false;
while (Date.now() < deadline) {
const url = this.getApiUrl();
if (url) return url;
if (!logged) { console.log('[beacon] Waiting for meta-core...'); logged = true; }
this.node.probe([CAP_CORE]);
await new Promise((r) => setTimeout(r, 500));
}
throw new Error(`[beacon] 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);
}
/**
* Neighbours for /api/neighbors: one row per name (or every instance with
* `all`), optionally only nodes with a resource matching `cap`.
*/
getNeighbors(opts: { all?: boolean; cap?: string } = {}): MeshNeighbor[] {
const list = opts.all ? this.node.getNeighbors() : this.node.getNeighborsByName();
const cap = opts.cap;
return cap ? list.filter((n) => n.resources.some((r) => resourceMatches(r, cap))) : list;
}
self(): MeshNeighbor {
return this.node.self();
}
/** The Scan card — see {@link MeshNode.scan}. */
scan(pattern: string, configured: Array<[string, string]>, waitMs = 1500): Promise<ScanReport> {
return this.node.scan(pattern, configured, waitMs);
}
probe(want: string[] = []): void {
this.node.probe(want);
}
private notifyChange(): void {
for (const cb of this.changeCallbacks) {
try { cb(); } catch (e) { console.error('[beacon] Change callback threw:', e); }
}
}
}
+3
View File
@@ -166,6 +166,9 @@ async function main(): Promise<void> {
serviceName: 'meta-dup', serviceName: 'meta-dup',
apiPort: config.apiPort, apiPort: config.apiPort,
baseUrl: config.baseUrl, baseUrl: config.baseUrl,
// Pin: when META_CORE_URL is set, UDP discovery never overrides it.
// See docs/project-architecture/service-discovery.md.
metaCoreUrl: process.env.META_CORE_URL,
redisPrefix: config.redisPrefix, redisPrefix: config.redisPrefix,
}); });
+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 {
+15 -22
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, beacon v2)
* 2. Creates Redis client connection * 2. Creates Redis client connection
* 3. Handles reconnection on leader failure * 3. Handles reconnection on leader failure
* *
@@ -13,7 +13,6 @@
import { hostname } from 'os'; import { hostname } from 'os';
import { Logger } from 'tslog'; import { Logger } from 'tslog';
import { LeaderClient } from './LeaderClient.js'; import { LeaderClient } from './LeaderClient.js';
import { ServiceDiscovery } from './ServiceDiscovery.js';
import { RedisClient } from './RedisClient.js'; import { RedisClient } from './RedisClient.js';
import type { LeaderLockInfo } from './IKVClient.js'; import type { LeaderLockInfo } from './IKVClient.js';
@@ -48,7 +47,6 @@ interface KVManagerConfig {
export class KVManager { export class KVManager {
private config: Required<KVManagerConfig>; private config: Required<KVManagerConfig>;
private leaderClient: LeaderClient | null = null; private leaderClient: LeaderClient | null = null;
private serviceDiscovery: ServiceDiscovery | null = null;
private redisClient: RedisClient | null = null; private redisClient: RedisClient | null = null;
private isStarted = false; private isStarted = false;
private isShuttingDown = false; private isShuttingDown = false;
@@ -80,14 +78,15 @@ export class KVManager {
logger.info(`Starting KVManager for ${this.config.serviceName}...`); logger.info(`Starting KVManager for ${this.config.serviceName}...`);
// Initialize and start service discovery for registration and heartbeat // No separate service registration step any more: the LeaderClient's
const apiUrl = this.config.baseUrl || `http://${hostname()}:${this.config.apiPort}`; // discovery node announces this service on the same UDP group it
this.serviceDiscovery = new ServiceDiscovery({ // listens on, so being discoverable and discovering are one thing.
metaCorePath: this.config.metaCorePath, // Browser-facing URL for the nav menu, in the order the spec requires:
serviceName: this.config.serviceName, // PUBLIC_URL (reachable when there is no Caddy perimeter in front — a
apiUrl // debug-direct port) -> BASE_URL (the Caddy URL) -> container host.
}); // Announcing a Caddy URL on a stack with no Caddy running is what puts
await this.serviceDiscovery.start(); // 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) {
@@ -99,8 +98,9 @@ export class KVManager {
// Use leader client // Use leader client
this.leaderClient = new LeaderClient({ this.leaderClient = new LeaderClient({
metaCorePath: this.config.metaCorePath, metaCoreUrl: this.config.metaCoreUrl || undefined,
metaCoreUrl: this.config.metaCoreUrl || undefined serviceName: this.config.serviceName,
baseUrl: apiUrl,
}); });
// Set up leader change callback // Set up leader change callback
@@ -162,11 +162,7 @@ export class KVManager {
this.leaderClient = null; this.leaderClient = null;
} }
// Stop service discovery (if it was started) // Stop service registration (if it was started)
if (this.serviceDiscovery) {
await this.serviceDiscovery.stop();
this.serviceDiscovery = null;
}
// Disconnect Redis // Disconnect Redis
await this.disconnectRedis(); await this.disconnectRedis();
@@ -341,11 +337,8 @@ export class KVManager {
} }
/** /**
* Get service discovery (for inter-service navigation) * Get service registration instance
*/ */
getServiceDiscovery(): ServiceDiscovery | null {
return this.serviceDiscovery;
}
/** /**
* Get configured paths * Get configured paths
+140 -149
View File
@@ -1,29 +1,49 @@
/** /**
* LeaderClient - Client for reading leader info from meta-core * LeaderClient - locates meta-core and exposes its URLs.
* *
* This replaces the old LeaderDiscovery.ts by delegating leader election * Since UDP discovery (meta-discovery v1, now beacon v2) this is a thin adapter
* to meta-core (Go sidecar) and just reading the results. * 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: * The public surface is deliberately unchanged so KVManager and its callers did
* - Reads kv-leader.info file for leader URLs * not have to move: getLeaderInfo / getUrls / getApiUrl / getWebdavUrl*,
* - Calls meta-core /urls API for current leader URLs * waitForLeader (same 30s default), startWatching / onChange (the fs.watch on
* - Watches for leader changes via file system watcher * the lock dir became "an announce arrived carrying a different apiUrl").
*
* 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, and
* redisUrl has been empty since the api-mediated-access lockdown.
*/ */
import { promises as fs } from 'fs';
import { watch, FSWatcher } from 'fs';
import { dirname } from 'path';
import { Logger } from 'tslog'; import { Logger } from 'tslog';
import { MetaCoreLocator, type MeshNeighbor, type ScanReport } from '../discovery/meshdisco.js';
import type { LeaderLockInfo } from './IKVClient.js'; import type { LeaderLockInfo } from './IKVClient.js';
const logger = new Logger({ name: 'LeaderClient' }); const logger = new Logger({ name: 'LeaderClient' });
export interface LeaderClientConfig { 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; 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 { export interface URLsResponse {
@@ -38,57 +58,75 @@ export interface URLsResponse {
export class LeaderClient { export class LeaderClient {
private config: LeaderClientConfig; private config: LeaderClientConfig;
private infoFilePath: string; private locator: MetaCoreLocator;
private leaderInfo: LeaderLockInfo | null = null; private leaderInfo: LeaderLockInfo | null = null;
private watcher: FSWatcher | null = null;
private onChangeCallbacks: (() => void)[] = []; private onChangeCallbacks: (() => void)[] = [];
private started = false;
// URL caching // Only used on the pinned path, where URLs still come from GET /urls.
// A discovered core carries them in the announce itself.
private cachedUrls: URLsResponse | null = null; private cachedUrls: URLsResponse | null = null;
private urlsCacheTime: number = 0; private urlsCacheTime: number = 0;
private readonly urlsCacheTTL: number = 5000; // 5 seconds private readonly urlsCacheTTL: number = 5000;
constructor(config: LeaderClientConfig) { constructor(config: LeaderClientConfig) {
this.config = config; this.config = config;
this.infoFilePath = `${config.metaCorePath}/locks/kv-leader.info`; this.locator = new MetaCoreLocator({
serviceName: config.serviceName ?? 'meta-dup',
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();
});
}
private async ensureStarted(): Promise<void> {
if (this.started) return;
this.started = true;
await this.locator.start();
} }
/** /**
* Read API URL from file (plain text format) * Guard against a rollback reintroducing direct Redis exposure.
* api-mediated-access PR D removed redisUrl; ALLOW_LEGACY_REDIS_URL=1
* downgrades this to a warning during a deliberate temporary rollback.
*/ */
private async getApiUrlFromFile(): Promise<string | null> { private assertNoRedisUrl(redisUrl: string | undefined): void {
try { if (!redisUrl) return;
const content = await fs.readFile(this.infoFilePath, 'utf-8'); const msg =
return content.trim() || null; 'meta-core still publishes redisUrl; direct Redis access was retired ' +
} catch (error: any) { 'by the api-mediated-access lockdown. Verify meta-core version.';
logger.error(`Failed to read API URL from file: ${error.message}`); if (process.env.ALLOW_LEGACY_REDIS_URL === '1') {
return null; logger.warn('WARNING: ' + msg);
} else {
throw new Error(msg);
} }
} }
/**
* Fetch URLs from meta-core /urls API with caching
*/
private async fetchUrls(apiUrl: string): Promise<URLsResponse | null> { private async fetchUrls(apiUrl: string): Promise<URLsResponse | null> {
// Check cache
const now = Date.now(); const now = Date.now();
if (this.cachedUrls && (now - this.urlsCacheTime) < this.urlsCacheTTL) { if (this.cachedUrls && (now - this.urlsCacheTime) < this.urlsCacheTTL) {
return this.cachedUrls; return this.cachedUrls;
} }
try { try {
const response = await fetch(`${apiUrl}/urls`, { const response = await fetch(`${apiUrl}/urls`, {
method: 'GET', method: 'GET',
headers: { 'Accept': 'application/json' }, headers: { 'Accept': 'application/json' },
signal: AbortSignal.timeout(5000) signal: AbortSignal.timeout(5000)
}); });
if (!response.ok) { if (!response.ok) {
logger.error(`Failed to fetch URLs: ${response.status} ${response.statusText}`); logger.error(`Failed to fetch URLs: ${response.status} ${response.statusText}`);
return null; return null;
} }
const parsed = await response.json() as URLsResponse;
this.cachedUrls = await response.json() as URLsResponse; this.assertNoRedisUrl(parsed.redisUrl);
this.cachedUrls = parsed;
this.urlsCacheTime = now; this.urlsCacheTime = now;
return this.cachedUrls; return this.cachedUrls;
} catch (error: any) { } catch (error: any) {
@@ -98,23 +136,37 @@ export class LeaderClient {
} }
/** /**
* Read leader info from file and /urls API * Resolve meta-core's URLs — straight from the announce when discovered
* (no HTTP hop needed), or over HTTP when pinned.
*/ */
async getUrls(): Promise<URLsResponse | null> {
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) {
logger.error('No meta-core API URL available');
return null;
}
return this.fetchUrls(apiUrl);
}
async getLeaderInfo(): Promise<LeaderLockInfo | null> { async getLeaderInfo(): Promise<LeaderLockInfo | null> {
try { try {
// Read API URL from file (plain text) const urls = await this.getUrls();
const apiUrl = await this.getApiUrlFromFile(); if (!urls) return null;
if (!apiUrl) {
return null;
}
// Fetch full info from /urls API
const urls = await this.fetchUrls(apiUrl);
if (!urls) {
return null;
}
// Convert URLsResponse to LeaderLockInfo
this.leaderInfo = { this.leaderInfo = {
hostname: urls.hostname, hostname: urls.hostname,
baseUrl: urls.baseUrl, baseUrl: urls.baseUrl,
@@ -123,7 +175,7 @@ export class LeaderClient {
webdavUrl: urls.webdavUrl, webdavUrl: urls.webdavUrl,
webdavUrlInternal: urls.webdavUrlInternal, webdavUrlInternal: urls.webdavUrlInternal,
timestamp: Date.now(), timestamp: Date.now(),
pid: 0 // Unknown for remote leader pid: 0
}; };
return this.leaderInfo; return this.leaderInfo;
} catch (error: any) { } catch (error: any) {
@@ -132,138 +184,64 @@ export class LeaderClient {
} }
} }
/**
* Get Redis URL from leader info
*/
async getRedisUrl(): Promise<string | null> { async getRedisUrl(): Promise<string | null> {
const info = await this.getLeaderInfo(); return (await this.getLeaderInfo())?.redisUrl ?? null;
return info?.redisUrl ?? null;
} }
/**
* Get WebDAV URL from leader info (external, via nginx/HTTPS)
*/
async getWebdavUrl(): Promise<string | null> { async getWebdavUrl(): Promise<string | null> {
const info = await this.getLeaderInfo(); return (await this.getLeaderInfo())?.webdavUrl ?? null;
return info?.webdavUrl ?? null;
} }
/** /** Internal WebDAV URL — use for container-to-container access. */
* Get internal WebDAV URL from leader info (direct to port 9000)
* Use this for container-to-container communication
*/
async getWebdavUrlInternal(): Promise<string | null> { async getWebdavUrlInternal(): Promise<string | null> {
const info = await this.getLeaderInfo(); return (await this.getLeaderInfo())?.webdavUrlInternal ?? null;
return info?.webdavUrlInternal ?? null;
} }
/**
* Get meta-core API URL from leader info
*/
async getApiUrl(): Promise<string | null> { async getApiUrl(): Promise<string | null> {
const info = await this.getLeaderInfo(); return (await this.getLeaderInfo())?.apiUrl ?? null;
return info?.apiUrl ?? null;
} }
/** /**
* Call meta-core /urls API to get current URLs * Block until meta-core is reachable. Probes immediately then every 500ms,
* Useful for getting URLs when you don't want to read the file directly * matching the previous file-polling loop's timing exactly.
*/
async getUrls(): Promise<URLsResponse | null> {
// First try using configured metaCoreUrl
let apiUrl: string | null = this.config.metaCoreUrl ?? null;
// Fall back to reading from file
if (!apiUrl) {
apiUrl = await this.getApiUrlFromFile();
}
if (!apiUrl) {
logger.error('No meta-core API URL available');
return null;
}
return this.fetchUrls(apiUrl);
}
/**
* Wait for leader info to be available
*/ */
async waitForLeader(timeoutMs: number = 30000): Promise<LeaderLockInfo> { async waitForLeader(timeoutMs: number = 30000): Promise<LeaderLockInfo> {
const startTime = Date.now(); await this.ensureStarted();
const pollInterval = 500; const deadline = Date.now() + timeoutMs;
while (Date.now() - startTime < timeoutMs) { await this.locator.waitForCore(timeoutMs);
while (Date.now() < deadline) {
const info = await this.getLeaderInfo(); const info = await this.getLeaderInfo();
if (info) { if (info) {
logger.info(`Leader found: ${info.hostname} at ${info.redisUrl}`); logger.info(`meta-core found: ${info.hostname} at ${info.apiUrl}`);
return info; return info;
} }
logger.debug('Waiting for meta-core...');
logger.debug('Waiting for leader...'); await new Promise(resolve => setTimeout(resolve, 500));
await new Promise(resolve => setTimeout(resolve, pollInterval));
} }
throw new Error(`No meta-core found within ${timeoutMs}ms`);
throw new Error(`No leader 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 { startWatching(): void {
if (this.watcher) { void this.ensureStarted();
return; // Already watching
} }
const lockDir = dirname(this.infoFilePath);
try {
this.watcher = watch(lockDir, (eventType, filename) => {
if (filename === 'kv-leader.info') {
logger.debug(`Lock file ${eventType}, invalidating cache...`);
// Invalidate cache to force fresh API call
this.cachedUrls = null;
this.urlsCacheTime = 0;
this.getLeaderInfo().then(() => {
this.notifyChange();
}).catch((err) => logger.error(`Error reloading leader info: ${err.message}`));
}
});
this.watcher.on('error', (error: any) => {
logger.warn(`File watcher error: ${error.message}`);
});
logger.info(`Watching for leader changes in ${lockDir}`);
} catch (error: any) {
logger.error(`Failed to start watching: ${error.message}`);
}
}
/**
* Stop watching for leader changes
*/
stopWatching(): void { stopWatching(): void {
if (this.watcher) { // No-op: the locator keeps listening until close().
this.watcher.close();
this.watcher = null;
logger.debug('Stopped watching for leader changes');
}
} }
/**
* Register callback for leader changes
*/
onChange(callback: () => void): this { onChange(callback: () => void): this {
this.onChangeCallbacks.push(callback); this.onChangeCallbacks.push(callback);
return this; return this;
} }
/**
* Notify all change callbacks
*/
private notifyChange(): void { private notifyChange(): void {
logger.debug('meta-core changed, invalidating cache...');
for (const callback of this.onChangeCallbacks) { for (const callback of this.onChangeCallbacks) {
try { try {
callback(); callback();
@@ -273,18 +251,31 @@ export class LeaderClient {
} }
} }
/**
* Get cached leader info (without re-reading file)
*/
getCachedLeaderInfo(): LeaderLockInfo | null { getCachedLeaderInfo(): LeaderLockInfo | null {
return this.leaderInfo; return this.leaderInfo;
} }
/** /**
* Clean up resources * Neighbours for /api/neighbors: one row per name (every instance with
* `all`), optionally only nodes with a capability matching `cap`.
*/ */
getNeighbors(opts: { all?: boolean; cap?: string } = {}): MeshNeighbor[] {
return this.locator.getNeighbors(opts);
}
/** This service's own row, so the menu can show itself. */
self(): MeshNeighbor {
return this.locator.self();
}
/** Beacon v2 Scan card: probe, wait, classify `pattern` against `configured`. */
scan(pattern: string, configured: Array<[string, string]>, waitMs = 1500): Promise<ScanReport> {
return this.locator.scan(pattern, configured, waitMs);
}
close(): void { close(): void {
this.stopWatching();
this.onChangeCallbacks = []; this.onChangeCallbacks = [];
void this.locator.stop();
this.started = false;
} }
} }
+71 -11
View File
@@ -15,6 +15,68 @@ import type { IKVClient, KeyValuePair } from './IKVClient.js';
const logger = new Logger({ name: 'RedisClient' }); const logger = new Logger({ name: 'RedisClient' });
// --- bare-CID key-set decoding -------------------------------------------
// Sibling CIDs live on the record as a key-set `cids/<cid> = "true"` (no
// per-algorithm field name). A CIDv1 is self-describing — its algorithm is
// the multicodec — so we decode each member's multihash code to pick the
// digest we need: sha2-256 (0x12) is the full-file content hash used for
// exact-duplicate detection; midhash256 (0x1000) is the record address.
// Self-contained decoder (no multiformats dep). See METADATA_KEYS.md §2/§14.13.
const MH_SHA256 = 0x12;
const MH_MIDHASH256 = 0x1000;
const CID_B32_ALPHABET = 'abcdefghijklmnopqrstuvwxyz234567';
function decodeBase32Lower(s: string): Uint8Array | null {
let bits = 0, value = 0;
const out: number[] = [];
for (const ch of s) {
const idx = CID_B32_ALPHABET.indexOf(ch);
if (idx < 0) return null;
value = (value << 5) | idx;
bits += 5;
if (bits >= 8) {
bits -= 8;
out.push((value >> bits) & 0xff);
}
}
return new Uint8Array(out);
}
function readUvarint(buf: Uint8Array, pos: number): [number, number] {
let result = 0, shift = 0, p = pos;
while (p < buf.length) {
const b = buf[p++];
result |= (b & 0x7f) << shift;
if ((b & 0x80) === 0) return [result >>> 0, p];
shift += 7;
}
return [0, pos];
}
/** Multihash code of a bare multibase-base32 CIDv1, or null. */
function cidMultihashCode(cid: string): number | null {
if (!cid.startsWith('b')) return null;
const bytes = decodeBase32Lower(cid.slice(1));
if (!bytes || bytes.length < 3) return null;
let v: number, pos = 0;
[v, pos] = readUvarint(bytes, pos); // version
if (v !== 1) return null;
[v, pos] = readUvarint(bytes, pos); // codec (ignored)
[v, pos] = readUvarint(bytes, pos); // multihash code
return v;
}
/** Pick the sibling CID with the given multihash code from a record's
* `cids/<cid>` key-set. */
function pickCidByMulticodec(data: Record<string, any>, mhCode: number): string | undefined {
for (const key of Object.keys(data)) {
if (!key.startsWith('cids/')) continue;
const cid = key.slice('cids/'.length);
if (cid && cidMultihashCode(cid) === mhCode) return cid;
}
return undefined;
}
/** /**
* Stream message from Redis Streams * Stream message from Redis Streams
* Supports both: * Supports both:
@@ -58,7 +120,6 @@ export interface FileMetadata {
// Title information (for folder organization) // Title information (for folder organization)
title?: string; title?: string;
titles?: { eng?: string; [key: string]: string | undefined };
originalTitle?: string; originalTitle?: string;
fileName?: string; fileName?: string;
@@ -374,12 +435,6 @@ export class RedisClient implements Partial<IKVClient> {
mtime = isNaN(parsed) ? parseFloat(data.mtime) : parsed; mtime = isNaN(parsed) ? parseFloat(data.mtime) : parsed;
} }
// Parse titles - can be nested object or simple string
let titles: { eng?: string; [key: string]: string | undefined } | undefined;
if (data['titles/eng']) {
titles = { eng: data['titles/eng'] };
}
// Parse season/episode (can be 0 for specials) // Parse season/episode (can be 0 for specials)
const season = data.season !== undefined ? parseInt(data.season, 10) : undefined; const season = data.season !== undefined ? parseInt(data.season, 10) : undefined;
const episode = data.episode !== undefined ? parseInt(data.episode, 10) : undefined; const episode = data.episode !== undefined ? parseInt(data.episode, 10) : undefined;
@@ -398,13 +453,18 @@ export class RedisClient implements Partial<IKVClient> {
mtime, mtime,
ctime: data.ctime ? parseFloat(data.ctime) : mtime, ctime: data.ctime ? parseFloat(data.ctime) : mtime,
// Identification // Identification. The midhash (record address) and the full-file
hashId: hashId ?? data.cid_midhash256 ?? data.hashId, // sha2-256 (exact-dup key) are recovered from the bare-CID key-set by
sha256: data['cid_sha2-256'] ?? data.sha256, // multicodec. The legacy `cid_midhash256` / `cid_sha2-256` fallbacks
// are gone: meta-core rejects those fields at the write boundary now
// (400), because a `cid_*` field is stored but never reverse-indexed —
// a record carrying one is unresolvable by that CID. There is nothing
// left to fall back to. See METADATA_KEYS.md §2/§14.13.
hashId: hashId ?? pickCidByMulticodec(data, MH_MIDHASH256) ?? data.hashId,
sha256: pickCidByMulticodec(data, MH_SHA256) ?? data.sha256,
// Title information // Title information
title: data.title, title: data.title,
titles,
originalTitle: data.originalTitle, originalTitle: data.originalTitle,
fileName: data.fileName, fileName: data.fileName,
@@ -1,331 +0,0 @@
/**
* Service Discovery using shared filesystem
*
* Each service registers itself in /meta-core/services/{service-name}-{hostname}.json
* with its base URL, status, and heartbeat timestamp.
*
* Services can discover each other by reading these JSON files.
* Stale detection: if lastHeartbeat > 60 seconds ago, service is considered stale.
*
* 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 { join } from 'path';
import { Logger } from 'tslog';
import type { ServiceInfo } from './IKVClient.js';
const logger = new Logger({ name: 'ServiceDiscovery' });
interface ServiceDiscoveryConfig {
/** 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 ServiceDiscovery {
private config: ServiceDiscoveryConfig;
private servicesDir: string;
private serviceFilePath: string;
private heartbeatTimer: NodeJS.Timeout | null = null;
private isShuttingDown = false;
private isStarted = false;
private currentHostname: string;
constructor(config: ServiceDiscoveryConfig) {
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);
}
}
}
/**
* Check if a service info is stale
*/
private isStale(info: ServiceInfo): boolean {
const lastHeartbeat = new Date(info.lastHeartbeat).getTime();
return Date.now() - lastHeartbeat > this.config.staleThreshold!;
}
// ========================================================================
// Discovery Methods
// ========================================================================
/**
* Discover a service by name
* Looks for files matching pattern: {name}-*.json (hostname-based naming)
*/
async discoverService(name: string): Promise<ServiceInfo | null> {
try {
// First try exact match for backward compatibility
const exactPath = join(this.servicesDir, `${name}.json`);
try {
const content = await fs.readFile(exactPath, 'utf-8');
const service = JSON.parse(content) as ServiceInfo;
if (this.isStale(service)) {
service.status = 'stale';
}
return service.status === 'running' ? service : null;
} catch {
// Try hostname-based files
}
// Search for hostname-based files: name-*.json
const files = await fs.readdir(this.servicesDir);
const matchingFiles = files.filter(f =>
f.startsWith(`${name}-`) && f.endsWith('.json')
);
// Return first valid (non-stale) service
for (const file of matchingFiles) {
const filePath = join(this.servicesDir, file);
const content = await fs.readFile(filePath, 'utf-8');
const service = JSON.parse(content) as ServiceInfo;
if (this.isStale(service)) {
service.status = 'stale';
continue; // Skip stale services
}
if (service.status === 'running') {
return service;
}
}
return null;
} catch (error: any) {
if (error.code === 'ENOENT') {
return null; // Service not registered
}
throw error;
}
}
/**
* Discover all registered services
*/
async discoverAllServices(): Promise<ServiceInfo[]> {
try {
const files = await fs.readdir(this.servicesDir);
const services: ServiceInfo[] = [];
for (const file of files) {
if (!file.endsWith('.json')) continue;
const filePath = join(this.servicesDir, file);
try {
const content = await fs.readFile(filePath, 'utf-8');
const service = JSON.parse(content) as ServiceInfo;
if (this.isStale(service)) {
service.status = 'stale';
}
services.push(service);
} catch (error) {
logger.error(`Failed to read ${file}:`, error);
}
}
return services;
} catch (error: any) {
if (error.code === 'ENOENT') {
return []; // Services directory doesn't exist yet
}
throw error;
}
}
/**
* Check if a service is healthy
*/
async isServiceHealthy(name: string): Promise<boolean> {
const service = await this.discoverService(name);
if (!service) return false;
// Try to ping the service's health endpoint
const url = `${service.baseUrl}/health`;
try {
const response = await fetch(url, {
signal: AbortSignal.timeout(5000)
});
return response.ok;
} catch {
return false;
}
}
// ========================================================================
// 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;
}
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
-13
View File
@@ -1,13 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>meta-dup - Duplicate Detection</title>
<script type="module" crossorigin src="/assets/index-GT6exvk4.js"></script>
</head>
<body>
<div id="root"></div>␍
</body>
</html>
@@ -1,169 +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.
url: string; *
api: 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
capabilities: string[]; * scripts/check-mirrors.sh). The old implementation was ~165 lines of React
version: string; * that existed in four near-identical copies and polled meta-core's
role?: string; // "leader", "follower", or undefined (for non-meta-core services) * /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-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;
// Filter out follower meta-core instances (only show leader)
const filteredServices = services.filter(
s => s.name !== 'meta-core' || s.role === 'leader'
);
const sortedServices = [...filteredServices].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.url}
className={`service-link${isActive ? ' active' : ''}`}
onClick={(e) => {
e.preventDefault();
if (!isActive) {
window.location.href = service.url;
}
}}
>
<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> </>
); );
} }
@@ -0,0 +1,308 @@
/**
* <meta-service-menu> — 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 = `
<style>
:host {
--_fg: var(--mm-nav-fg, #e8eaed);
--_bg: var(--mm-nav-bg, #1a1a2e);
--_border: var(--mm-nav-border, rgba(255, 255, 255, 0.14));
--_accent: var(--mm-nav-accent, #4ecdc4);
position: relative;
display: inline-block;
font-family: inherit;
font-size: 14px;
line-height: 1.4;
}
button.trigger {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.4rem;
width: 2.1rem;
height: 2.1rem;
padding: 0;
cursor: pointer;
color: var(--_fg);
background: transparent;
border: 1px solid var(--_border);
border-radius: 8px;
font: inherit;
transition: border-color 0.15s, background 0.15s;
}
button.trigger:hover { border-color: var(--_accent); }
button.trigger:focus-visible {
outline: 2px solid var(--_accent);
outline-offset: 2px;
}
button.trigger[aria-expanded="true"] { border-color: var(--_accent); }
.bars { display: block; width: 15px; }
.bars span {
display: block;
height: 2px;
margin: 3px 0;
border-radius: 2px;
background: currentColor;
}
.panel {
position: absolute;
top: calc(100% + 6px);
right: 0;
z-index: 1000;
min-width: 13rem;
max-height: 70vh;
overflow-y: auto;
padding: 0.3rem;
background: var(--_bg);
border: 1px solid var(--_border);
border-radius: 10px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.35);
}
.panel[hidden] { display: none; }
a.item, .item {
display: flex;
align-items: center;
gap: 0.55rem;
padding: 0.45rem 0.6rem;
border-radius: 6px;
color: var(--_fg);
text-decoration: none;
white-space: nowrap;
}
a.item:hover { background: color-mix(in srgb, var(--_accent) 18%, transparent); }
.item.active { color: var(--_accent); font-weight: 600; cursor: default; }
.item.dead { opacity: 0.55; cursor: not-allowed; }
.icon { font-size: 1rem; line-height: 1; }
.dot {
width: 6px;
height: 6px;
margin-left: auto;
border-radius: 50%;
background: var(--_accent);
}
.empty {
padding: 0.6rem;
opacity: 0.7;
font-size: 0.85em;
white-space: nowrap;
}
/* Respect a host that opts out of motion. */
@media (prefers-reduced-motion: reduce) {
button.trigger { transition: none; }
}
</style>
<button class="trigger" type="button" aria-haspopup="menu" aria-expanded="false">
<span class="bars" aria-hidden="true"><span></span><span></span><span></span></span>
</button>
<div class="panel" role="menu" hidden></div>
`;
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;
// Beacon v2 neighbours include plugins; the nav lists services only.
// (A row without `caps` comes from a pre-v2 backend — keep it.)
const isService = (c) => String(c).startsWith("metamesh.service/");
if (Array.isArray(n.caps) && !n.caps.some(isService)) 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 advertised";
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);
// Beacon v2 omits `status` when running.
if ((n.status || "running") === "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;