refactor progress
This commit is contained in:
58
fluxer_relay_directory/Dockerfile
Normal file
58
fluxer_relay_directory/Dockerfile
Normal 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"]
|
||||
16
fluxer_relay_directory/config/directory.json
Normal file
16
fluxer_relay_directory/config/directory.json
Normal 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": []
|
||||
}
|
||||
28
fluxer_relay_directory/package.json
Normal file
28
fluxer_relay_directory/package.json
Normal 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:"
|
||||
}
|
||||
}
|
||||
399
fluxer_relay_directory/scripts/GenerateOpenAPI.tsx
Normal file
399
fluxer_relay_directory/scripts/GenerateOpenAPI.tsx
Normal 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();
|
||||
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);
|
||||
}
|
||||
}
|
||||
11
fluxer_relay_directory/tsconfig.json
Normal file
11
fluxer_relay_directory/tsconfig.json
Normal 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/**/*"]
|
||||
}
|
||||
Reference in New Issue
Block a user