refactor progress
This commit is contained in:
87
fluxer_relay_directory/src/App.tsx
Normal file
87
fluxer_relay_directory/src/App.tsx
Normal 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};
|
||||
}
|
||||
90
fluxer_relay_directory/src/Config.tsx
Normal file
90
fluxer_relay_directory/src/Config.tsx
Normal 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;
|
||||
39
fluxer_relay_directory/src/Logger.tsx
Normal file
39
fluxer_relay_directory/src/Logger.tsx
Normal 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;
|
||||
141
fluxer_relay_directory/src/controllers/RelayController.tsx
Normal file
141
fluxer_relay_directory/src/controllers/RelayController.tsx
Normal 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);
|
||||
});
|
||||
}
|
||||
55
fluxer_relay_directory/src/database/Database.tsx
Normal file
55
fluxer_relay_directory/src/database/Database.tsx
Normal 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;
|
||||
}
|
||||
123
fluxer_relay_directory/src/index.tsx
Normal file
123
fluxer_relay_directory/src/index.tsx
Normal 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();
|
||||
78
fluxer_relay_directory/src/middleware/ServiceMiddleware.tsx
Normal file
78
fluxer_relay_directory/src/middleware/ServiceMiddleware.tsx
Normal 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();
|
||||
});
|
||||
99
fluxer_relay_directory/src/middleware/Validator.tsx
Normal file
99
fluxer_relay_directory/src/middleware/Validator.tsx
Normal 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;
|
||||
};
|
||||
};
|
||||
175
fluxer_relay_directory/src/repositories/RelayRepository.tsx
Normal file
175
fluxer_relay_directory/src/repositories/RelayRepository.tsx
Normal 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);
|
||||
}
|
||||
}
|
||||
77
fluxer_relay_directory/src/services/GeoSelectionService.tsx
Normal file
77
fluxer_relay_directory/src/services/GeoSelectionService.tsx
Normal 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);
|
||||
}
|
||||
}
|
||||
144
fluxer_relay_directory/src/services/HealthCheckService.tsx
Normal file
144
fluxer_relay_directory/src/services/HealthCheckService.tsx
Normal 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');
|
||||
}
|
||||
}
|
||||
103
fluxer_relay_directory/src/services/RelayRegistryService.tsx
Normal file
103
fluxer_relay_directory/src/services/RelayRegistryService.tsx
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user