refactor progress

This commit is contained in:
Hampus Kraft
2026-02-17 12:22:36 +00:00
parent cb31608523
commit d5abd1a7e4
8257 changed files with 1190207 additions and 761040 deletions

View File

@@ -0,0 +1,58 @@
FROM node:24-bookworm-slim AS base
WORKDIR /app
RUN corepack enable && corepack prepare pnpm@10.26.0 --activate
FROM base AS deps
# Copy workspace configuration
COPY pnpm-workspace.yaml pnpm-lock.yaml ./
COPY patches/ ./patches/
COPY fluxer_relay_directory/package.json ./fluxer_relay_directory/
COPY packages/hono/package.json ./packages/hono/
COPY packages/schema/package.json ./packages/schema/
# Install dependencies
RUN pnpm install --frozen-lockfile --prod --filter fluxer-relay-directory...
FROM node:24-bookworm-slim
WORKDIR /app
# Install runtime utilities (curl for healthcheck)
RUN apt-get update && apt-get install -y --no-install-recommends \
curl && \
rm -rf /var/lib/apt/lists/*
RUN corepack enable && corepack prepare pnpm@10.26.0 --activate
# Copy node_modules from deps stage
COPY --from=deps /app/node_modules ./node_modules
COPY --from=deps /app/fluxer_relay_directory/node_modules ./fluxer_relay_directory/node_modules
COPY --from=deps /app/packages ./packages
# Copy relay directory source
COPY fluxer_relay_directory/package.json fluxer_relay_directory/tsconfig.json ./fluxer_relay_directory/
COPY fluxer_relay_directory/src ./fluxer_relay_directory/src
COPY fluxer_relay_directory/config ./fluxer_relay_directory/config
# Create data directory for SQLite database
RUN mkdir -p /app/fluxer_relay_directory/data && \
mkdir -p /app/.cache/corepack && \
chown -R nobody:nogroup /app
WORKDIR /app/fluxer_relay_directory
ENV HOME=/app
ENV COREPACK_HOME=/app/.cache/corepack
ENV NODE_ENV=production
USER nobody
EXPOSE 8080
HEALTHCHECK --interval=10s --timeout=5s --retries=5 \
CMD curl -f http://localhost:8080/_health || exit 1
CMD ["pnpm", "start"]

View File

@@ -0,0 +1,16 @@
{
"$schema": "../../packages/config/src/ConfigSchema.json",
"server": {
"host": "0.0.0.0",
"port": 8080
},
"database": {
"path": "./data/relays.db"
},
"health_check": {
"interval_ms": 30000,
"timeout_ms": 5000,
"unhealthy_threshold": 3
},
"bootstrap_relays": []
}

View File

@@ -0,0 +1,28 @@
{
"name": "fluxer_relay_directory",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"build": "tsgo",
"dev": "tsx watch src/index.tsx",
"openapi:generate": "tsx scripts/GenerateOpenAPI.tsx",
"start": "tsx src/index.tsx",
"typecheck": "tsgo --noEmit"
},
"dependencies": {
"@fluxer/config": "workspace:*",
"@fluxer/hono": "workspace:*",
"@fluxer/openapi": "workspace:*",
"@fluxer/schema": "workspace:*",
"@hono/node-server": "catalog:",
"hono": "catalog:",
"pino": "catalog:",
"zod": "catalog:"
},
"devDependencies": {
"@types/node": "catalog:",
"@typescript/native-preview": "catalog:",
"tsx": "catalog:"
}
}

View File

@@ -0,0 +1,399 @@
#!/usr/bin/env tsx
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import * as fs from 'node:fs';
import * as path from 'node:path';
import {zodToOpenAPISchema} from '@fluxer/openapi/src/converters/ZodToOpenAPI';
import {
HealthCheckResponse,
RegisterRelayRequest,
RelayDeletedResponse,
RelayHeartbeatResponse,
RelayIdParam,
RelayInfoResponse,
RelayListQuery,
RelayListResponse,
RelayStatusResponse,
RelayWithDistanceResponse,
} from '@fluxer/schema/src/domains/relay/RelaySchemas';
import type {ZodTypeAny} from 'zod';
interface OpenAPISpec {
openapi: string;
info: {
title: string;
version: string;
description: string;
contact?: {
name?: string;
url?: string;
email?: string;
};
license?: {
name: string;
url?: string;
};
};
servers: Array<{
url: string;
description: string;
}>;
paths: Record<string, PathItem>;
components: {
schemas: Record<string, unknown>;
};
}
interface PathItem {
get?: Operation;
post?: Operation;
put?: Operation;
patch?: Operation;
delete?: Operation;
}
interface Operation {
operationId: string;
summary: string;
description?: string;
tags?: Array<string>;
parameters?: Array<Parameter>;
requestBody?: RequestBody;
responses: Record<string, Response>;
}
interface Parameter {
name: string;
in: 'query' | 'path' | 'header' | 'cookie';
description?: string;
required?: boolean;
schema: unknown;
}
interface RequestBody {
description?: string;
required?: boolean;
content: {
'application/json': {
schema: unknown;
};
};
}
interface Response {
description: string;
content?: {
'application/json': {
schema: unknown;
};
};
}
function convertZodSchema(schema: ZodTypeAny): unknown {
return zodToOpenAPISchema(schema);
}
function generateSpec(): OpenAPISpec {
const schemas: Record<string, unknown> = {
HealthCheckResponse: convertZodSchema(HealthCheckResponse),
RelayInfoResponse: convertZodSchema(RelayInfoResponse),
RelayWithDistanceResponse: convertZodSchema(RelayWithDistanceResponse),
RelayListResponse: convertZodSchema(RelayListResponse),
RelayStatusResponse: convertZodSchema(RelayStatusResponse),
RelayHeartbeatResponse: convertZodSchema(RelayHeartbeatResponse),
RelayDeletedResponse: convertZodSchema(RelayDeletedResponse),
RegisterRelayRequest: convertZodSchema(RegisterRelayRequest),
RelayIdParam: convertZodSchema(RelayIdParam),
RelayListQuery: convertZodSchema(RelayListQuery),
RelayNotFoundError: {
type: 'object',
properties: {
error: {type: 'string', description: 'Error message'},
code: {type: 'string', enum: ['RELAY_NOT_FOUND'], description: 'Error code'},
},
required: ['error', 'code'],
},
};
const spec: OpenAPISpec = {
openapi: '3.1.0',
info: {
title: 'Fluxer Relay Directory API',
version: '1.0.0',
description:
'API for discovering and managing Fluxer relay servers. ' +
'The relay directory service maintains a registry of available relay servers ' +
'that clients can use to connect to Fluxer instances through encrypted tunnels.',
contact: {
name: 'Fluxer Developers',
email: 'developers@fluxer.app',
},
license: {
name: 'AGPL-3.0',
url: 'https://www.gnu.org/licenses/agpl-3.0.html',
},
},
servers: [
{
url: 'https://relay.fluxer.app',
description: 'Production relay directory',
},
],
paths: {
'/_health': {
get: {
operationId: 'getHealth',
summary: 'Health check',
description: 'Returns the health status of the relay directory service.',
tags: ['Health'],
responses: {
'200': {
description: 'Service is healthy',
content: {
'application/json': {
schema: {$ref: '#/components/schemas/HealthCheckResponse'},
},
},
},
},
},
},
'/v1/relays': {
get: {
operationId: 'listRelays',
summary: 'List available relays',
description:
'Returns a list of available relay servers. ' +
'Optionally, provide client coordinates to sort relays by proximity.',
tags: ['Relays'],
parameters: [
{
name: 'lat',
in: 'query',
description: 'Client latitude for proximity-based sorting',
required: false,
schema: {type: 'string'},
},
{
name: 'lon',
in: 'query',
description: 'Client longitude for proximity-based sorting',
required: false,
schema: {type: 'string'},
},
{
name: 'limit',
in: 'query',
description: 'Maximum number of relays to return',
required: false,
schema: {type: 'string'},
},
],
responses: {
'200': {
description: 'List of available relays',
content: {
'application/json': {
schema: {$ref: '#/components/schemas/RelayListResponse'},
},
},
},
},
},
},
'/v1/relays/register': {
post: {
operationId: 'registerRelay',
summary: 'Register a new relay',
description:
'Registers a new relay server with the directory. ' +
'The relay must provide its public key for E2E encryption.',
tags: ['Relays'],
requestBody: {
required: true,
content: {
'application/json': {
schema: {$ref: '#/components/schemas/RegisterRelayRequest'},
},
},
},
responses: {
'201': {
description: 'Relay registered successfully',
content: {
'application/json': {
schema: {$ref: '#/components/schemas/RelayInfoResponse'},
},
},
},
},
},
},
'/v1/relays/{id}/status': {
get: {
operationId: 'getRelayStatus',
summary: 'Get relay status',
description: 'Returns the current status of a specific relay server.',
tags: ['Relays'],
parameters: [
{
name: 'id',
in: 'path',
description: 'Relay UUID',
required: true,
schema: {type: 'string', format: 'uuid'},
},
],
responses: {
'200': {
description: 'Relay status',
content: {
'application/json': {
schema: {$ref: '#/components/schemas/RelayStatusResponse'},
},
},
},
'404': {
description: 'Relay not found',
content: {
'application/json': {
schema: {$ref: '#/components/schemas/RelayNotFoundError'},
},
},
},
},
},
},
'/v1/relays/{id}/heartbeat': {
post: {
operationId: 'sendHeartbeat',
summary: 'Send relay heartbeat',
description:
'Sends a heartbeat to indicate the relay is still alive. ' +
'Relays should send heartbeats periodically to maintain their healthy status.',
tags: ['Relays'],
parameters: [
{
name: 'id',
in: 'path',
description: 'Relay UUID',
required: true,
schema: {type: 'string', format: 'uuid'},
},
],
responses: {
'200': {
description: 'Heartbeat received',
content: {
'application/json': {
schema: {$ref: '#/components/schemas/RelayHeartbeatResponse'},
},
},
},
'404': {
description: 'Relay not found',
content: {
'application/json': {
schema: {$ref: '#/components/schemas/RelayNotFoundError'},
},
},
},
},
},
},
'/v1/relays/{id}': {
delete: {
operationId: 'deleteRelay',
summary: 'Unregister a relay',
description: 'Removes a relay server from the directory.',
tags: ['Relays'],
parameters: [
{
name: 'id',
in: 'path',
description: 'Relay UUID',
required: true,
schema: {type: 'string', format: 'uuid'},
},
],
responses: {
'200': {
description: 'Relay deleted',
content: {
'application/json': {
schema: {$ref: '#/components/schemas/RelayDeletedResponse'},
},
},
},
'404': {
description: 'Relay not found',
content: {
'application/json': {
schema: {$ref: '#/components/schemas/RelayNotFoundError'},
},
},
},
},
},
},
},
components: {
schemas,
},
};
return spec;
}
function main(): void {
console.log('Fluxer Relay Directory OpenAPI Generator');
console.log('========================================');
console.log('');
const spec = generateSpec();
const scriptDir = path.dirname(new URL(import.meta.url).pathname);
const outputPath = path.resolve(scriptDir, '../../fluxer_docs/relay-api/openapi.json');
const outputDir = path.dirname(outputPath);
console.log(`Output path: ${outputPath}`);
console.log('');
fs.mkdirSync(outputDir, {recursive: true});
fs.writeFileSync(outputPath, JSON.stringify(spec, null, '\t'));
const pathCount = Object.keys(spec.paths).length;
let operationCount = 0;
for (const pathItem of Object.values(spec.paths)) {
operationCount += Object.keys(pathItem).length;
}
const schemaCount = Object.keys(spec.components.schemas).length;
console.log('Summary');
console.log('-------');
console.log(`Paths: ${pathCount}`);
console.log(`Operations: ${operationCount}`);
console.log(`Schemas: ${schemaCount}`);
console.log('');
console.log('OpenAPI specification generated successfully.');
}
main();

View File

@@ -0,0 +1,87 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {DatabaseSync} from 'node:sqlite';
import {RelayController} from '@app/controllers/RelayController';
import type {RelayDirectoryEnv} from '@app/middleware/ServiceMiddleware';
import {initializeServices, ServiceMiddleware} from '@app/middleware/ServiceMiddleware';
import type {IRelayRepository} from '@app/repositories/RelayRepository';
import type {HealthCheckConfig} from '@app/services/HealthCheckService';
import {HealthCheckService} from '@app/services/HealthCheckService';
import {applyMiddlewareStack} from '@fluxer/hono/src/middleware/MiddlewareStack';
import {Hono} from 'hono';
import type {Logger} from 'pino';
export interface AppDependencies {
db: DatabaseSync;
healthCheckConfig: HealthCheckConfig;
logger: Logger;
}
export interface AppResult {
app: Hono<RelayDirectoryEnv>;
healthCheckService: HealthCheckService;
repository: IRelayRepository;
shutdown: () => void;
}
export function createApp(deps: AppDependencies): AppResult {
const {db, healthCheckConfig, logger} = deps;
const {repository} = initializeServices({db});
const healthCheckService = new HealthCheckService(repository, healthCheckConfig, logger);
const app = new Hono<RelayDirectoryEnv>({strict: true});
applyMiddlewareStack(app, {
requestId: {},
logger: {
log: (data) => {
logger.info(
{
method: data.method,
path: data.path,
status: data.status,
durationMs: data.durationMs,
},
'Request completed',
);
},
skip: ['/_health'],
},
});
app.use('*', ServiceMiddleware);
app.onError((err, ctx) => {
logger.error({error: err, path: ctx.req.path}, 'Request error');
return ctx.json({error: 'Internal server error', code: 'INTERNAL_ERROR'}, 500);
});
RelayController(app);
healthCheckService.start();
function shutdown(): void {
healthCheckService.stop();
}
return {app, healthCheckService, repository, shutdown};
}

View File

@@ -0,0 +1,90 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {existsSync, readFileSync} from 'node:fs';
import {deepMerge, isPlainObject} from '@fluxer/config/src/config_loader/ConfigObjectMerge';
import {buildEnvOverrides} from '@fluxer/config/src/config_loader/EnvironmentOverrides';
import {z} from 'zod';
export const BootstrapRelayConfigSchema = z.object({
id: z.string(),
url: z.url(),
lat: z.number(),
lon: z.number(),
region: z.string(),
capacity: z.number().int().positive(),
public_key: z.string().optional(),
});
export type BootstrapRelayConfig = z.infer<typeof BootstrapRelayConfigSchema>;
const ServerSchema = z.object({
host: z.string().default('0.0.0.0'),
port: z.number().int().positive().default(8080),
});
const DatabaseSchema = z.object({
path: z.string().default('./data/relays.db'),
});
const HealthCheckSchema = z.object({
interval_ms: z.number().int().positive().default(30000),
timeout_ms: z.number().int().positive().default(5000),
unhealthy_threshold: z.number().int().positive().default(3),
});
const DirectoryConfigSchema = z.object({
server: ServerSchema.default(() => ServerSchema.parse({})),
database: DatabaseSchema.default(() => DatabaseSchema.parse({})),
health_check: HealthCheckSchema.default(() => HealthCheckSchema.parse({})),
bootstrap_relays: z.array(BootstrapRelayConfigSchema).default([]),
});
export type DirectoryConfig = z.infer<typeof DirectoryConfigSchema>;
const CONFIG_PATHS = [
process.env['RELAY_DIRECTORY_CONFIG'],
'./config/directory.json',
'/etc/fluxer-relay-directory/directory.json',
].filter((path): path is string => Boolean(path));
const ENV_OVERRIDE_PREFIX = 'RELAY_DIRECTORY__';
function loadConfigFile(): Record<string, unknown> {
for (const configPath of CONFIG_PATHS) {
if (existsSync(configPath)) {
const content = readFileSync(configPath, 'utf-8');
const parsed = JSON.parse(content);
if (isPlainObject(parsed)) {
return parsed;
}
}
}
return {};
}
function loadDirectoryConfig(): DirectoryConfig {
const fileConfig = loadConfigFile();
const envOverrides = buildEnvOverrides(process.env, ENV_OVERRIDE_PREFIX);
const merged = deepMerge(fileConfig, envOverrides);
return DirectoryConfigSchema.parse(merged);
}
export const Config = loadDirectoryConfig();
export type Config = typeof Config;

View File

@@ -0,0 +1,39 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import pino from 'pino';
const isDevelopment = process.env['NODE_ENV'] !== 'production';
export const Logger = pino({
name: 'fluxer-relay-directory',
level: isDevelopment ? 'debug' : 'info',
transport: isDevelopment
? {
target: 'pino-pretty',
options: {
colorize: true,
translateTime: 'SYS:standard',
ignore: 'pid,hostname',
},
}
: undefined,
});
export type Logger = typeof Logger;

View File

@@ -0,0 +1,141 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {RelayDirectoryEnv} from '@app/middleware/ServiceMiddleware';
import {Validator} from '@app/middleware/Validator';
import {
type HealthCheckResponse,
RegisterRelayRequest,
type RelayDeletedResponse,
type RelayHeartbeatResponse,
RelayIdParam,
type RelayInfoResponse,
RelayListQuery,
type RelayListResponse,
type RelayStatusResponse,
} from '@fluxer/schema/src/domains/relay/RelaySchemas';
import type {Hono} from 'hono';
export function RelayController(app: Hono<RelayDirectoryEnv>): void {
app.get('/_health', (ctx) => {
const response: typeof HealthCheckResponse._output = {
status: 'ok',
timestamp: new Date().toISOString(),
};
return ctx.json(response);
});
app.get('/v1/relays', Validator('query', RelayListQuery), async (ctx) => {
const query = ctx.req.valid('query');
const registryService = ctx.get('registryService');
const clientLocation =
query.lat && query.lon
? {
latitude: Number.parseFloat(query.lat),
longitude: Number.parseFloat(query.lon),
}
: undefined;
const limit = query.limit ? Number.parseInt(query.limit, 10) : undefined;
const relays = registryService.listRelays(clientLocation, limit);
const response: typeof RelayListResponse._output = {
relays,
count: relays.length,
};
return ctx.json(response);
});
app.post('/v1/relays/register', Validator('json', RegisterRelayRequest), async (ctx) => {
const body = ctx.req.valid('json');
const registryService = ctx.get('registryService');
const relay = registryService.registerRelay({
name: body.name,
url: body.url,
latitude: body.latitude,
longitude: body.longitude,
region: body.region ?? 'unknown',
capacity: body.capacity ?? 1000,
public_key: body.public_key,
});
const response: typeof RelayInfoResponse._output = relay;
return ctx.json(response, 201);
});
app.get('/v1/relays/:id/status', Validator('param', RelayIdParam), async (ctx) => {
const {id} = ctx.req.valid('param');
const registryService = ctx.get('registryService');
const relay = registryService.getRelayStatus(id);
if (!relay) {
return ctx.json({error: 'Relay not found', code: 'RELAY_NOT_FOUND'}, 404);
}
const response: typeof RelayStatusResponse._output = {
id: relay.id,
name: relay.name,
url: relay.url,
region: relay.region,
healthy: relay.healthy,
current_connections: relay.current_connections,
capacity: relay.capacity,
last_seen_at: relay.last_seen_at,
};
return ctx.json(response);
});
app.post('/v1/relays/:id/heartbeat', Validator('param', RelayIdParam), async (ctx) => {
const {id} = ctx.req.valid('param');
const registryService = ctx.get('registryService');
const relay = registryService.getRelay(id);
if (!relay) {
return ctx.json({error: 'Relay not found', code: 'RELAY_NOT_FOUND'}, 404);
}
registryService.updateRelayHeartbeat(id);
const response: typeof RelayHeartbeatResponse._output = {status: 'ok'};
return ctx.json(response);
});
app.delete('/v1/relays/:id', Validator('param', RelayIdParam), async (ctx) => {
const {id} = ctx.req.valid('param');
const registryService = ctx.get('registryService');
const relay = registryService.getRelay(id);
if (!relay) {
return ctx.json({error: 'Relay not found', code: 'RELAY_NOT_FOUND'}, 404);
}
registryService.removeRelay(id);
const response: typeof RelayDeletedResponse._output = {status: 'deleted'};
return ctx.json(response);
});
}

View File

@@ -0,0 +1,55 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {mkdirSync} from 'node:fs';
import {dirname} from 'node:path';
import {DatabaseSync} from 'node:sqlite';
const SCHEMA = `
CREATE TABLE IF NOT EXISTS relays (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
url TEXT NOT NULL UNIQUE,
latitude REAL NOT NULL,
longitude REAL NOT NULL,
region TEXT NOT NULL,
capacity INTEGER NOT NULL,
current_connections INTEGER NOT NULL DEFAULT 0,
public_key TEXT NOT NULL,
registered_at TEXT NOT NULL,
last_seen_at TEXT NOT NULL,
healthy INTEGER NOT NULL DEFAULT 1,
failed_checks INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_relays_healthy ON relays(healthy);
CREATE INDEX IF NOT EXISTS idx_relays_region ON relays(region);
`;
export function createDatabase(dbPath: string): DatabaseSync {
const dir = dirname(dbPath);
mkdirSync(dir, {recursive: true});
const db = new DatabaseSync(dbPath);
db.exec('PRAGMA journal_mode = WAL');
db.exec('PRAGMA synchronous = NORMAL');
db.exec(SCHEMA);
return db;
}

View File

@@ -0,0 +1,123 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {createApp} from '@app/App';
import type {BootstrapRelayConfig} from '@app/Config';
import {Config} from '@app/Config';
import {createDatabase} from '@app/database/Database';
import {Logger} from '@app/Logger';
import type {IRelayRepository, RelayInfo} from '@app/repositories/RelayRepository';
import {serve} from '@hono/node-server';
function loadBootstrapRelays(repository: IRelayRepository, bootstrapRelays: Array<BootstrapRelayConfig>): void {
for (const bootstrapRelay of bootstrapRelays) {
const existingRelay = repository.getRelay(bootstrapRelay.id);
if (existingRelay) {
Logger.debug({relay_id: bootstrapRelay.id}, 'Bootstrap relay already exists, skipping');
continue;
}
const now = new Date().toISOString();
const relayInfo: RelayInfo = {
id: bootstrapRelay.id,
name: bootstrapRelay.id,
url: bootstrapRelay.url,
latitude: bootstrapRelay.lat,
longitude: bootstrapRelay.lon,
region: bootstrapRelay.region,
capacity: bootstrapRelay.capacity,
current_connections: 0,
public_key: bootstrapRelay.public_key ?? '',
registered_at: now,
last_seen_at: now,
healthy: true,
failed_checks: 0,
};
repository.saveRelay(relayInfo);
Logger.info(
{
relay_id: bootstrapRelay.id,
url: bootstrapRelay.url,
region: bootstrapRelay.region,
},
'Registered bootstrap relay',
);
}
}
function main(): void {
Logger.info(
{
host: Config.server.host,
port: Config.server.port,
database_path: Config.database.path,
},
'Starting Fluxer Relay Directory',
);
const db = createDatabase(Config.database.path);
Logger.info({path: Config.database.path}, 'Database initialized');
const {app, repository, shutdown} = createApp({
db,
healthCheckConfig: Config.health_check,
logger: Logger,
});
if (Config.bootstrap_relays.length > 0) {
Logger.info({count: Config.bootstrap_relays.length}, 'Loading bootstrap relays from config');
loadBootstrapRelays(repository, Config.bootstrap_relays);
}
const server = serve({
fetch: app.fetch,
hostname: Config.server.host,
port: Config.server.port,
});
Logger.info(
{
host: Config.server.host,
port: Config.server.port,
},
'Fluxer Relay Directory listening',
);
function gracefulShutdown(signal: string): void {
Logger.info({signal}, 'Received shutdown signal');
shutdown();
server.close(() => {
Logger.info('HTTP server closed');
});
db.close();
Logger.info('Database connection closed');
process.exit(0);
}
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
process.on('SIGINT', () => gracefulShutdown('SIGINT'));
}
main();

View File

@@ -0,0 +1,78 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {DatabaseSync} from 'node:sqlite';
import type {IRelayRepository} from '@app/repositories/RelayRepository';
import {RelayRepository} from '@app/repositories/RelayRepository';
import type {IGeoSelectionService} from '@app/services/GeoSelectionService';
import {GeoSelectionService} from '@app/services/GeoSelectionService';
import type {IRelayRegistryService} from '@app/services/RelayRegistryService';
import {RelayRegistryService} from '@app/services/RelayRegistryService';
import {createMiddleware} from 'hono/factory';
export interface RelayDirectoryEnv {
Variables: {
relayRepository: IRelayRepository;
geoService: IGeoSelectionService;
registryService: IRelayRegistryService;
};
}
export interface ServiceMiddlewareOptions {
db: DatabaseSync;
}
let _repository: RelayRepository | null = null;
let _geoService: GeoSelectionService | null = null;
let _registryService: RelayRegistryService | null = null;
export function initializeServices(options: ServiceMiddlewareOptions): {
repository: RelayRepository;
geoService: GeoSelectionService;
registryService: RelayRegistryService;
} {
_repository = new RelayRepository(options.db);
_geoService = new GeoSelectionService();
_registryService = new RelayRegistryService(_repository, _geoService);
return {
repository: _repository,
geoService: _geoService,
registryService: _registryService,
};
}
export function getRepository(): RelayRepository {
if (!_repository) {
throw new Error('Services not initialized. Call initializeServices first.');
}
return _repository;
}
export const ServiceMiddleware = createMiddleware<RelayDirectoryEnv>(async (ctx, next) => {
if (!_repository || !_geoService || !_registryService) {
throw new Error('Services not initialized. Call initializeServices first.');
}
ctx.set('relayRepository', _repository);
ctx.set('geoService', _geoService);
ctx.set('registryService', _registryService);
await next();
});

View File

@@ -0,0 +1,99 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {Context, Env, Input, MiddlewareHandler, ValidationTargets} from 'hono';
import type {ZodError, ZodType} from 'zod';
interface ValidationError {
path: string;
message: string;
code: string;
}
type HasUndefined<T> = undefined extends T ? true : false;
export const Validator = <
T extends ZodType,
Target extends keyof ValidationTargets,
E extends Env,
P extends string,
In = T['_input'],
Out = T['_output'],
I extends Input = {
in: HasUndefined<In> extends true
? {[K in Target]?: In extends ValidationTargets[K] ? In : {[K2 in keyof In]?: ValidationTargets[K][K2]}}
: {[K in Target]: In extends ValidationTargets[K] ? In : {[K2 in keyof In]: ValidationTargets[K][K2]}};
out: {[K in Target]: Out};
},
V extends I = I,
>(
target: Target,
schema: T,
): MiddlewareHandler<E, P, V> => {
return async (c, next): Promise<Response | undefined> => {
let value: unknown;
switch (target) {
case 'json':
try {
value = await c.req.json<unknown>();
} catch {
value = {};
}
break;
case 'query':
value = Object.fromEntries(
Object.entries(c.req.queries()).map(([k, v]) => (v.length === 1 ? [k, v[0]] : [k, v])),
);
break;
case 'param':
value = c.req.param();
break;
case 'header':
value = c.req.header();
break;
default:
value = {};
}
const result = await schema.safeParseAsync(value);
if (!result.success) {
const errors: Array<ValidationError> = [];
const zodError = result.error as ZodError;
for (const issue of zodError.issues) {
const path = issue.path.length > 0 ? issue.path.join('.') : 'root';
errors.push({path, message: issue.message, code: issue.code});
}
return (c as Context).json(
{
error: 'Validation failed',
code: 'VALIDATION_ERROR',
errors,
},
400,
);
}
c.req.addValidatedData(target, result.data as ValidationTargets[Target]);
await next();
return;
};
};

View File

@@ -0,0 +1,175 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {DatabaseSync, StatementSync} from 'node:sqlite';
export interface RelayInfo {
id: string;
name: string;
url: string;
latitude: number;
longitude: number;
region: string;
capacity: number;
current_connections: number;
public_key: string;
registered_at: string;
last_seen_at: string;
healthy: boolean;
failed_checks: number;
}
interface RelayRow {
id: string;
name: string;
url: string;
latitude: number;
longitude: number;
region: string;
capacity: number;
current_connections: number;
public_key: string;
registered_at: string;
last_seen_at: string;
healthy: number;
failed_checks: number;
}
export interface IRelayRepository {
getRelay(id: string): RelayInfo | null;
getAllRelays(): Array<RelayInfo>;
getHealthyRelays(): Array<RelayInfo>;
saveRelay(relay: RelayInfo): void;
updateRelayHealth(id: string, healthy: boolean, failedChecks: number): void;
updateRelayLastSeen(id: string): void;
removeRelay(id: string): void;
}
function rowToRelayInfo(row: RelayRow): RelayInfo {
return {
...row,
healthy: row.healthy === 1,
};
}
export class RelayRepository implements IRelayRepository {
private readonly db: DatabaseSync;
private readonly cache: Map<string, RelayInfo>;
private readonly getRelayStmt: StatementSync;
private readonly getAllRelaysStmt: StatementSync;
private readonly insertRelayStmt: StatementSync;
private readonly updateHealthStmt: StatementSync;
private readonly updateLastSeenStmt: StatementSync;
private readonly deleteRelayStmt: StatementSync;
constructor(db: DatabaseSync) {
this.db = db;
this.cache = new Map();
this.getRelayStmt = this.db.prepare('SELECT * FROM relays WHERE id = ?');
this.getAllRelaysStmt = this.db.prepare('SELECT * FROM relays');
this.insertRelayStmt = this.db.prepare(`
INSERT OR REPLACE INTO relays
(id, name, url, latitude, longitude, region, capacity, current_connections, public_key, registered_at, last_seen_at, healthy, failed_checks)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
this.updateHealthStmt = this.db.prepare('UPDATE relays SET healthy = ?, failed_checks = ? WHERE id = ?');
this.updateLastSeenStmt = this.db.prepare(
'UPDATE relays SET last_seen_at = ?, healthy = 1, failed_checks = 0 WHERE id = ?',
);
this.deleteRelayStmt = this.db.prepare('DELETE FROM relays WHERE id = ?');
this.loadCache();
}
private loadCache(): void {
const rows = this.getAllRelaysStmt.all() as unknown as Array<RelayRow>;
for (const row of rows) {
this.cache.set(row.id, rowToRelayInfo(row));
}
}
getRelay(id: string): RelayInfo | null {
const cached = this.cache.get(id);
if (cached) {
return cached;
}
const row = this.getRelayStmt.get(id) as RelayRow | undefined;
if (!row) {
return null;
}
const relay = rowToRelayInfo(row);
this.cache.set(id, relay);
return relay;
}
getAllRelays(): Array<RelayInfo> {
return Array.from(this.cache.values());
}
getHealthyRelays(): Array<RelayInfo> {
return Array.from(this.cache.values()).filter((relay) => relay.healthy);
}
saveRelay(relay: RelayInfo): void {
this.insertRelayStmt.run(
relay.id,
relay.name,
relay.url,
relay.latitude,
relay.longitude,
relay.region,
relay.capacity,
relay.current_connections,
relay.public_key,
relay.registered_at,
relay.last_seen_at,
relay.healthy ? 1 : 0,
relay.failed_checks,
);
this.cache.set(relay.id, relay);
}
updateRelayHealth(id: string, healthy: boolean, failedChecks: number): void {
this.updateHealthStmt.run(healthy ? 1 : 0, failedChecks, id);
const cached = this.cache.get(id);
if (cached) {
cached.healthy = healthy;
cached.failed_checks = failedChecks;
}
}
updateRelayLastSeen(id: string): void {
const now = new Date().toISOString();
this.updateLastSeenStmt.run(now, id);
const cached = this.cache.get(id);
if (cached) {
cached.last_seen_at = now;
cached.healthy = true;
cached.failed_checks = 0;
}
}
removeRelay(id: string): void {
this.deleteRelayStmt.run(id);
this.cache.delete(id);
}
}

View File

@@ -0,0 +1,77 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {RelayInfo} from '@app/repositories/RelayRepository';
const EARTH_RADIUS_KM = 6371;
export interface GeoLocation {
latitude: number;
longitude: number;
}
export interface RelayWithDistance extends RelayInfo {
distance_km: number;
}
export interface IGeoSelectionService {
calculateDistance(from: GeoLocation, to: GeoLocation): number;
sortByProximity(relays: Array<RelayInfo>, clientLocation: GeoLocation): Array<RelayWithDistance>;
selectNearestRelays(relays: Array<RelayInfo>, clientLocation: GeoLocation, limit: number): Array<RelayWithDistance>;
}
export class GeoSelectionService implements IGeoSelectionService {
calculateDistance(from: GeoLocation, to: GeoLocation): number {
const lat1Rad = this.toRadians(from.latitude);
const lat2Rad = this.toRadians(to.latitude);
const deltaLat = this.toRadians(to.latitude - from.latitude);
const deltaLon = this.toRadians(to.longitude - from.longitude);
const a =
Math.sin(deltaLat / 2) * Math.sin(deltaLat / 2) +
Math.cos(lat1Rad) * Math.cos(lat2Rad) * Math.sin(deltaLon / 2) * Math.sin(deltaLon / 2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return EARTH_RADIUS_KM * c;
}
sortByProximity(relays: Array<RelayInfo>, clientLocation: GeoLocation): Array<RelayWithDistance> {
const relaysWithDistance: Array<RelayWithDistance> = relays.map((relay) => ({
...relay,
distance_km: this.calculateDistance(clientLocation, {
latitude: relay.latitude,
longitude: relay.longitude,
}),
}));
relaysWithDistance.sort((a, b) => a.distance_km - b.distance_km);
return relaysWithDistance;
}
selectNearestRelays(relays: Array<RelayInfo>, clientLocation: GeoLocation, limit: number): Array<RelayWithDistance> {
const sorted = this.sortByProximity(relays, clientLocation);
return sorted.slice(0, limit);
}
private toRadians(degrees: number): number {
return degrees * (Math.PI / 180);
}
}

View File

@@ -0,0 +1,144 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {IRelayRepository, RelayInfo} from '@app/repositories/RelayRepository';
import type {Logger} from 'pino';
export interface HealthCheckConfig {
interval_ms: number;
timeout_ms: number;
unhealthy_threshold: number;
}
export interface IHealthCheckService {
start(): void;
stop(): void;
checkRelay(relay: RelayInfo): Promise<boolean>;
}
export class HealthCheckService implements IHealthCheckService {
private readonly repository: IRelayRepository;
private readonly config: HealthCheckConfig;
private readonly logger: Logger;
private intervalHandle: ReturnType<typeof setInterval> | null = null;
constructor(repository: IRelayRepository, config: HealthCheckConfig, logger: Logger) {
this.repository = repository;
this.config = config;
this.logger = logger.child({service: 'health-check'});
}
start(): void {
if (this.intervalHandle) {
return;
}
this.logger.info({interval_ms: this.config.interval_ms}, 'Starting relay health check service');
this.intervalHandle = setInterval(() => {
this.checkAllRelays().catch((err) => {
this.logger.error({error: err}, 'Health check cycle failed');
});
}, this.config.interval_ms);
this.checkAllRelays().catch((err) => {
this.logger.error({error: err}, 'Initial health check failed');
});
}
stop(): void {
if (this.intervalHandle) {
clearInterval(this.intervalHandle);
this.intervalHandle = null;
this.logger.info('Stopped relay health check service');
}
}
async checkRelay(relay: RelayInfo): Promise<boolean> {
const healthUrl = new URL('/_health', relay.url).toString();
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), this.config.timeout_ms);
const response = await fetch(healthUrl, {
method: 'GET',
signal: controller.signal,
});
clearTimeout(timeoutId);
const isHealthy = response.ok;
if (isHealthy) {
this.repository.updateRelayHealth(relay.id, true, 0);
this.logger.debug({relay_id: relay.id, relay_name: relay.name}, 'Relay health check passed');
} else {
this.handleFailedCheck(relay);
this.logger.warn(
{relay_id: relay.id, relay_name: relay.name, status: response.status},
'Relay health check failed with non-OK status',
);
}
return isHealthy;
} catch (error) {
this.handleFailedCheck(relay);
this.logger.warn(
{relay_id: relay.id, relay_name: relay.name, error: String(error)},
'Relay health check failed with error',
);
return false;
}
}
private handleFailedCheck(relay: RelayInfo): void {
const newFailedChecks = relay.failed_checks + 1;
const isHealthy = newFailedChecks < this.config.unhealthy_threshold;
this.repository.updateRelayHealth(relay.id, isHealthy, newFailedChecks);
if (!isHealthy) {
this.logger.warn(
{
relay_id: relay.id,
relay_name: relay.name,
failed_checks: newFailedChecks,
threshold: this.config.unhealthy_threshold,
},
'Relay marked as unhealthy',
);
}
}
private async checkAllRelays(): Promise<void> {
const relays = this.repository.getAllRelays();
this.logger.debug({relay_count: relays.length}, 'Running health checks');
const results = await Promise.allSettled(relays.map((relay: RelayInfo) => this.checkRelay(relay)));
const healthy = results.filter(
(r): r is PromiseFulfilledResult<boolean> => r.status === 'fulfilled' && r.value,
).length;
const unhealthy = relays.length - healthy;
this.logger.info({total: relays.length, healthy, unhealthy}, 'Health check cycle completed');
}
}

View File

@@ -0,0 +1,103 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {randomUUID} from 'node:crypto';
import type {IRelayRepository, RelayInfo} from '@app/repositories/RelayRepository';
import type {GeoLocation, IGeoSelectionService, RelayWithDistance} from '@app/services/GeoSelectionService';
export interface RegisterRelayRequest {
name: string;
url: string;
latitude: number;
longitude: number;
region: string;
capacity: number;
public_key: string;
}
export interface IRelayRegistryService {
registerRelay(request: RegisterRelayRequest): RelayInfo;
getRelay(id: string): RelayInfo | null;
getRelayStatus(id: string): RelayInfo | null;
listRelays(clientLocation?: GeoLocation, limit?: number): Array<RelayInfo | RelayWithDistance>;
updateRelayHeartbeat(id: string): void;
removeRelay(id: string): void;
}
export class RelayRegistryService implements IRelayRegistryService {
private readonly repository: IRelayRepository;
private readonly geoService: IGeoSelectionService;
constructor(repository: IRelayRepository, geoService: IGeoSelectionService) {
this.repository = repository;
this.geoService = geoService;
}
registerRelay(request: RegisterRelayRequest): RelayInfo {
const now = new Date().toISOString();
const relay: RelayInfo = {
id: randomUUID(),
name: request.name,
url: request.url,
latitude: request.latitude,
longitude: request.longitude,
region: request.region,
capacity: request.capacity,
current_connections: 0,
public_key: request.public_key,
registered_at: now,
last_seen_at: now,
healthy: true,
failed_checks: 0,
};
this.repository.saveRelay(relay);
return relay;
}
getRelay(id: string): RelayInfo | null {
return this.repository.getRelay(id);
}
getRelayStatus(id: string): RelayInfo | null {
return this.repository.getRelay(id);
}
listRelays(clientLocation?: GeoLocation, limit?: number): Array<RelayInfo | RelayWithDistance> {
const healthyRelays = this.repository.getHealthyRelays();
if (!clientLocation) {
return limit ? healthyRelays.slice(0, limit) : healthyRelays;
}
if (limit) {
return this.geoService.selectNearestRelays(healthyRelays, clientLocation, limit);
}
return this.geoService.sortByProximity(healthyRelays, clientLocation);
}
updateRelayHeartbeat(id: string): void {
this.repository.updateRelayLastSeen(id);
}
removeRelay(id: string): void {
this.repository.removeRelay(id);
}
}

View File

@@ -0,0 +1,11 @@
{
"extends": "../tsconfigs/service.json",
"compilerOptions": {
"paths": {
"@app/*": ["./src/*"],
"@fluxer/relay_directory/*": ["./src/*"],
"@fluxer/*": ["./../packages/*", "./../packages/*/src/index.tsx"]
}
},
"include": ["src/**/*", "scripts/**/*"]
}