refactor progress
This commit is contained in:
44
fluxer_server/src/Config.tsx
Normal file
44
fluxer_server/src/Config.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
/*
|
||||
* 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 {loadConfig} from '@fluxer/config/src/ConfigLoader';
|
||||
|
||||
const master = await loadConfig();
|
||||
|
||||
if (!master.internal) {
|
||||
throw new Error('internal configuration is required for fluxer_server');
|
||||
}
|
||||
|
||||
export const Config = {
|
||||
...master,
|
||||
internal: master.internal,
|
||||
port: master.services.server.port,
|
||||
host: master.services.server.host,
|
||||
deploymentMode: master.instance.deployment_mode,
|
||||
isMonolith: master.instance.deployment_mode === 'monolith',
|
||||
healthCheck: {
|
||||
latencyThresholdMs: 1000,
|
||||
rpcTimeoutMs: 30000,
|
||||
},
|
||||
proxy: {
|
||||
trust_cf_connecting_ip: master.proxy.trust_cf_connecting_ip,
|
||||
},
|
||||
};
|
||||
|
||||
export type Config = typeof Config;
|
||||
335
fluxer_server/src/HealthCheck.tsx
Normal file
335
fluxer_server/src/HealthCheck.tsx
Normal file
@@ -0,0 +1,335 @@
|
||||
/*
|
||||
* 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 {InitializedServices} from '@app/ServiceInitializer';
|
||||
import type {Context} from 'hono';
|
||||
|
||||
export type ServiceStatus = 'healthy' | 'degraded' | 'unhealthy' | 'disabled';
|
||||
export interface ServiceHealth {
|
||||
status: ServiceStatus;
|
||||
message?: string;
|
||||
latencyMs?: number;
|
||||
details?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface HealthCheckResponse {
|
||||
status: ServiceStatus;
|
||||
timestamp: string;
|
||||
uptime: number;
|
||||
version: string;
|
||||
services: {
|
||||
kv: ServiceHealth;
|
||||
s3: ServiceHealth;
|
||||
queue: ServiceHealth;
|
||||
mediaProxy: ServiceHealth;
|
||||
admin: ServiceHealth;
|
||||
api: ServiceHealth;
|
||||
app: ServiceHealth;
|
||||
};
|
||||
}
|
||||
|
||||
export interface HealthCheckConfig {
|
||||
services: InitializedServices;
|
||||
staticDir?: string;
|
||||
version: string;
|
||||
startTime: number;
|
||||
latencyThresholdMs: number;
|
||||
}
|
||||
|
||||
async function checkKVHealth(services: InitializedServices, latencyThresholdMs: number): Promise<ServiceHealth> {
|
||||
if (services.kv === undefined) {
|
||||
return {status: 'disabled'};
|
||||
}
|
||||
|
||||
try {
|
||||
const start = Date.now();
|
||||
const healthy = await services.kv.health();
|
||||
const latencyMs = Date.now() - start;
|
||||
|
||||
if (!healthy) {
|
||||
return {
|
||||
status: 'unhealthy',
|
||||
latencyMs,
|
||||
message: 'KV provider health check failed',
|
||||
};
|
||||
}
|
||||
|
||||
if (latencyMs > latencyThresholdMs) {
|
||||
return {
|
||||
status: 'degraded',
|
||||
latencyMs,
|
||||
message: 'High latency detected',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
status: 'healthy',
|
||||
latencyMs,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
status: 'unhealthy',
|
||||
message: error instanceof Error ? error.message : 'Unknown error',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function checkS3Health(services: InitializedServices, latencyThresholdMs: number): Promise<ServiceHealth> {
|
||||
if (services.s3 === undefined) {
|
||||
return {status: 'disabled'};
|
||||
}
|
||||
|
||||
try {
|
||||
const start = Date.now();
|
||||
const s3Service = services.s3.getS3Service();
|
||||
const buckets = await s3Service.listBuckets();
|
||||
const latencyMs = Date.now() - start;
|
||||
|
||||
if (latencyMs > latencyThresholdMs) {
|
||||
return {
|
||||
status: 'degraded',
|
||||
latencyMs,
|
||||
message: 'High latency detected',
|
||||
details: {bucketCount: buckets.length},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
status: 'healthy',
|
||||
latencyMs,
|
||||
details: {bucketCount: buckets.length},
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
status: 'unhealthy',
|
||||
message: error instanceof Error ? error.message : 'Unknown error',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function checkQueueHealth(services: InitializedServices, latencyThresholdMs: number): Promise<ServiceHealth> {
|
||||
if (services.queue === undefined) {
|
||||
return {status: 'disabled'};
|
||||
}
|
||||
|
||||
try {
|
||||
const start = Date.now();
|
||||
const engine = services.queue.engine;
|
||||
const stats = engine.getStats();
|
||||
const details: Record<string, unknown> = {...stats};
|
||||
const latencyMs = Date.now() - start;
|
||||
|
||||
if (latencyMs > latencyThresholdMs) {
|
||||
return {
|
||||
status: 'degraded',
|
||||
latencyMs,
|
||||
message: 'High latency detected',
|
||||
details,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
status: 'healthy',
|
||||
latencyMs,
|
||||
details,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
status: 'unhealthy',
|
||||
message: error instanceof Error ? error.message : 'Unknown error',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function checkMediaProxyHealth(services: InitializedServices): Promise<ServiceHealth> {
|
||||
if (services.mediaProxy === undefined) {
|
||||
return {status: 'disabled'};
|
||||
}
|
||||
|
||||
return {
|
||||
status: 'healthy',
|
||||
};
|
||||
}
|
||||
|
||||
async function checkAdminHealth(services: InitializedServices): Promise<ServiceHealth> {
|
||||
if (services.admin === undefined) {
|
||||
return {status: 'disabled'};
|
||||
}
|
||||
|
||||
return {
|
||||
status: 'healthy',
|
||||
};
|
||||
}
|
||||
|
||||
async function checkAPIHealth(services: InitializedServices): Promise<ServiceHealth> {
|
||||
if (services.api === undefined) {
|
||||
return {status: 'disabled'};
|
||||
}
|
||||
|
||||
return {
|
||||
status: 'healthy',
|
||||
};
|
||||
}
|
||||
|
||||
async function checkAppServerHealth(services: InitializedServices, staticDir?: string): Promise<ServiceHealth> {
|
||||
if (services.appServer === undefined) {
|
||||
if (staticDir === undefined) {
|
||||
return {
|
||||
status: 'disabled',
|
||||
message: 'No static directory configured',
|
||||
};
|
||||
}
|
||||
return {status: 'disabled'};
|
||||
}
|
||||
|
||||
return {
|
||||
status: 'healthy',
|
||||
};
|
||||
}
|
||||
|
||||
function determineOverallStatus(services: HealthCheckResponse['services']): ServiceStatus {
|
||||
const statuses = Object.values(services).map((h) => h.status);
|
||||
|
||||
if (statuses.some((s) => s === 'unhealthy')) {
|
||||
return 'unhealthy';
|
||||
}
|
||||
|
||||
if (statuses.some((s) => s === 'degraded')) {
|
||||
return 'degraded';
|
||||
}
|
||||
|
||||
return 'healthy';
|
||||
}
|
||||
|
||||
export function createHealthCheckHandler(config: HealthCheckConfig) {
|
||||
return async (c: Context): Promise<Response> => {
|
||||
const {services, staticDir, version, startTime, latencyThresholdMs} = config;
|
||||
|
||||
const healthChecks: HealthCheckResponse['services'] = {
|
||||
kv: await checkKVHealth(services, latencyThresholdMs),
|
||||
s3: await checkS3Health(services, latencyThresholdMs),
|
||||
queue: await checkQueueHealth(services, latencyThresholdMs),
|
||||
mediaProxy: await checkMediaProxyHealth(services),
|
||||
admin: await checkAdminHealth(services),
|
||||
api: await checkAPIHealth(services),
|
||||
app: await checkAppServerHealth(services, staticDir),
|
||||
};
|
||||
|
||||
const overallStatus = determineOverallStatus(healthChecks);
|
||||
|
||||
const response: HealthCheckResponse = {
|
||||
status: overallStatus,
|
||||
timestamp: new Date().toISOString(),
|
||||
uptime: Math.floor((Date.now() - startTime) / 1000),
|
||||
version,
|
||||
services: healthChecks,
|
||||
};
|
||||
|
||||
const statusCode = overallStatus === 'unhealthy' ? 503 : 200;
|
||||
return c.json(response, statusCode);
|
||||
};
|
||||
}
|
||||
|
||||
export interface ReadinessCheckResponse {
|
||||
ready: boolean;
|
||||
timestamp: string;
|
||||
checks: {
|
||||
database?: {ready: boolean; message?: string};
|
||||
kv?: {ready: boolean; message?: string};
|
||||
s3?: {ready: boolean; message?: string};
|
||||
queue?: {ready: boolean; message?: string};
|
||||
};
|
||||
}
|
||||
|
||||
export function createReadinessCheckHandler(config: HealthCheckConfig) {
|
||||
return async (c: Context): Promise<Response> => {
|
||||
const {services} = config;
|
||||
|
||||
const checks: ReadinessCheckResponse['checks'] = {};
|
||||
let allReady = true;
|
||||
|
||||
if (services.kv !== undefined) {
|
||||
try {
|
||||
const healthy = await services.kv.health();
|
||||
checks.kv = healthy ? {ready: true} : {ready: false, message: 'KV provider health check failed'};
|
||||
if (!healthy) {
|
||||
allReady = false;
|
||||
}
|
||||
} catch (error) {
|
||||
checks.kv = {
|
||||
ready: false,
|
||||
message: error instanceof Error ? error.message : 'Unknown error',
|
||||
};
|
||||
allReady = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (services.s3 !== undefined) {
|
||||
try {
|
||||
const s3Service = services.s3.getS3Service();
|
||||
await s3Service.listBuckets();
|
||||
checks.s3 = {ready: true};
|
||||
} catch (error) {
|
||||
checks.s3 = {
|
||||
ready: false,
|
||||
message: error instanceof Error ? error.message : 'Unknown error',
|
||||
};
|
||||
allReady = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (services.queue !== undefined) {
|
||||
try {
|
||||
services.queue.engine.getStats();
|
||||
checks.queue = {ready: true};
|
||||
} catch (error) {
|
||||
checks.queue = {
|
||||
ready: false,
|
||||
message: error instanceof Error ? error.message : 'Unknown error',
|
||||
};
|
||||
allReady = false;
|
||||
}
|
||||
}
|
||||
|
||||
const response: ReadinessCheckResponse = {
|
||||
ready: allReady,
|
||||
timestamp: new Date().toISOString(),
|
||||
checks,
|
||||
};
|
||||
|
||||
const statusCode = allReady ? 200 : 503;
|
||||
return c.json(response, statusCode);
|
||||
};
|
||||
}
|
||||
|
||||
export interface LivenessCheckResponse {
|
||||
alive: boolean;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export function createLivenessCheckHandler() {
|
||||
return async (c: Context): Promise<Response> => {
|
||||
const response: LivenessCheckResponse = {
|
||||
alive: true,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
|
||||
return c.json(response, 200);
|
||||
};
|
||||
}
|
||||
32
fluxer_server/src/Instrument.tsx
Normal file
32
fluxer_server/src/Instrument.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
/*
|
||||
* 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 {Config} from '@app/Config';
|
||||
import {createServiceInstrumentation} from '@fluxer/initialization/src/CreateServiceInstrumentation';
|
||||
|
||||
export const shutdownInstrumentation = createServiceInstrumentation({
|
||||
serviceName: 'fluxer-server',
|
||||
config: Config,
|
||||
ignoreIncomingPaths: ['/_health', '/_live', '/_ready'],
|
||||
instrumentations: {
|
||||
cassandra: Config.database.backend === 'cassandra',
|
||||
aws: true,
|
||||
fetch: true,
|
||||
},
|
||||
});
|
||||
63
fluxer_server/src/Logger.tsx
Normal file
63
fluxer_server/src/Logger.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
/*
|
||||
* 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 {createLogger, type Logger as FluxerLogger} from '@fluxer/logger/src/Logger';
|
||||
|
||||
let _logger: FluxerLogger | null = null;
|
||||
|
||||
export interface LoggerInitOptions {
|
||||
environment: string;
|
||||
}
|
||||
|
||||
export function initializeLogger(options: LoggerInitOptions): FluxerLogger {
|
||||
if (_logger !== null) {
|
||||
return _logger;
|
||||
}
|
||||
_logger = createLogger('fluxer-server', {environment: options.environment});
|
||||
return _logger;
|
||||
}
|
||||
|
||||
export function getLogger(): FluxerLogger {
|
||||
if (_logger === null) {
|
||||
throw new Error('Logger has not been initialized. Call initializeLogger() first.');
|
||||
}
|
||||
return _logger;
|
||||
}
|
||||
|
||||
export const Logger: FluxerLogger = new Proxy({} as FluxerLogger, {
|
||||
get(_target, prop: keyof FluxerLogger | symbol) {
|
||||
if (_logger === null) {
|
||||
throw new Error('Logger has not been initialized. Call initializeLogger() first.');
|
||||
}
|
||||
const value = _logger[prop as keyof FluxerLogger];
|
||||
if (typeof value === 'function') {
|
||||
return value.bind(_logger);
|
||||
}
|
||||
return value;
|
||||
},
|
||||
set() {
|
||||
throw new Error('Cannot modify Logger directly. Use initializeLogger() instead.');
|
||||
},
|
||||
});
|
||||
|
||||
export type Logger = FluxerLogger;
|
||||
|
||||
export function createComponentLogger(component: string): FluxerLogger {
|
||||
return getLogger().child({component});
|
||||
}
|
||||
198
fluxer_server/src/Routes.tsx
Normal file
198
fluxer_server/src/Routes.tsx
Normal file
@@ -0,0 +1,198 @@
|
||||
/*
|
||||
* 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 {Config} from '@app/Config';
|
||||
import {createHealthCheckHandler, createLivenessCheckHandler, createReadinessCheckHandler} from '@app/HealthCheck';
|
||||
import {createComponentLogger} from '@app/Logger';
|
||||
import {
|
||||
type InitializedServices,
|
||||
initializeAllServices,
|
||||
runServiceInitialization,
|
||||
type ServiceInitializer,
|
||||
shutdownAllServices,
|
||||
startBackgroundServices,
|
||||
} from '@app/ServiceInitializer';
|
||||
import {getBuildMetadata} from '@fluxer/config/src/BuildMetadata';
|
||||
import {AppErrorHandler, AppNotFoundHandler} from '@fluxer/errors/src/domains/core/ErrorHandlers';
|
||||
import {applyMiddlewareStack} from '@fluxer/hono/src/middleware/MiddlewareStack';
|
||||
import {createServiceTelemetry} from '@fluxer/hono/src/middleware/TelemetryAdapters';
|
||||
import type {BaseHonoEnv} from '@fluxer/hono_types/src/HonoTypes';
|
||||
import {Hono} from 'hono';
|
||||
import {trimTrailingSlash} from 'hono/trailing-slash';
|
||||
|
||||
export interface MountedRoutes {
|
||||
app: Hono<BaseHonoEnv>;
|
||||
services: InitializedServices;
|
||||
initialize: () => Promise<void>;
|
||||
start: () => Promise<void>;
|
||||
shutdown: () => Promise<void>;
|
||||
}
|
||||
|
||||
export interface MountRoutesOptions {
|
||||
config: Config;
|
||||
staticDir?: string | undefined;
|
||||
}
|
||||
|
||||
const startTime = Date.now();
|
||||
const BUILD_METADATA = getBuildMetadata();
|
||||
|
||||
export async function mountRoutes(options: MountRoutesOptions): Promise<MountedRoutes> {
|
||||
const {config, staticDir} = options;
|
||||
const logger = createComponentLogger('routes');
|
||||
const VERSION = BUILD_METADATA.buildNumber ?? '0.0.0';
|
||||
|
||||
logger.info('Starting route mounting and service initialization');
|
||||
|
||||
const app = new Hono<BaseHonoEnv>();
|
||||
|
||||
app.use(trimTrailingSlash());
|
||||
|
||||
const telemetry = createServiceTelemetry({
|
||||
serviceName: 'fluxer-server',
|
||||
skipPaths: ['/_health', '/_ready', '/_live'],
|
||||
});
|
||||
|
||||
applyMiddlewareStack(app, {
|
||||
requestId: {},
|
||||
tracing: telemetry.tracing,
|
||||
metrics: {
|
||||
enabled: true,
|
||||
collector: telemetry.metricsCollector,
|
||||
skipPaths: ['/_health', '/_ready', '/_live'],
|
||||
},
|
||||
logger: {
|
||||
log: (data) => {
|
||||
logger.info(
|
||||
{
|
||||
method: data.method,
|
||||
path: data.path,
|
||||
status: data.status,
|
||||
durationMs: data.durationMs,
|
||||
},
|
||||
'Request completed',
|
||||
);
|
||||
},
|
||||
skip: ['/_health', '/_ready', '/_live'],
|
||||
},
|
||||
skipErrorHandler: true,
|
||||
});
|
||||
let initializers: Array<ServiceInitializer> = [];
|
||||
let services: InitializedServices = {};
|
||||
|
||||
try {
|
||||
const result = await initializeAllServices({
|
||||
config,
|
||||
logger,
|
||||
staticDir,
|
||||
});
|
||||
|
||||
initializers = result.initializers;
|
||||
services = result.services;
|
||||
|
||||
if (services.s3 !== undefined) {
|
||||
app.route('/s3', services.s3.app);
|
||||
logger.info(config.isMonolith ? 'S3 service mounted at /s3 (restricted mode)' : 'S3 service mounted at /s3');
|
||||
}
|
||||
|
||||
if (services.queue !== undefined && !config.isMonolith) {
|
||||
app.route('/queue', services.queue.app);
|
||||
logger.info('Queue service mounted at /queue');
|
||||
} else if (services.queue !== undefined) {
|
||||
logger.info('Queue service available internally only (monolith mode)');
|
||||
}
|
||||
|
||||
if (services.mediaProxy !== undefined) {
|
||||
app.route('/media', services.mediaProxy.app);
|
||||
logger.info(
|
||||
config.isMonolith
|
||||
? 'Media Proxy service mounted at /media (public-only mode)'
|
||||
: 'Media Proxy service mounted at /media',
|
||||
);
|
||||
}
|
||||
|
||||
if (services.admin !== undefined) {
|
||||
app.route('/admin', services.admin.app);
|
||||
logger.info('Admin service mounted at /admin');
|
||||
}
|
||||
|
||||
if (services.api !== undefined) {
|
||||
const apiService = services.api;
|
||||
app.route('/api', apiService.app);
|
||||
app.get('/.well-known/fluxer', (ctx) => apiService.app.fetch(ctx.req.raw));
|
||||
logger.info('API service mounted at /api');
|
||||
}
|
||||
|
||||
const healthHandler = createHealthCheckHandler({
|
||||
services,
|
||||
staticDir,
|
||||
version: VERSION,
|
||||
startTime,
|
||||
latencyThresholdMs: config.healthCheck.latencyThresholdMs,
|
||||
});
|
||||
app.get('/_health', healthHandler);
|
||||
logger.info('Health check endpoint mounted at /_health');
|
||||
|
||||
const readinessHandler = createReadinessCheckHandler({
|
||||
services,
|
||||
staticDir,
|
||||
version: VERSION,
|
||||
startTime,
|
||||
latencyThresholdMs: config.healthCheck.latencyThresholdMs,
|
||||
});
|
||||
app.get('/_ready', readinessHandler);
|
||||
logger.info('Readiness check endpoint mounted at /_ready');
|
||||
|
||||
const livenessHandler = createLivenessCheckHandler();
|
||||
app.get('/_live', livenessHandler);
|
||||
logger.info('Liveness check endpoint mounted at /_live');
|
||||
|
||||
if (services.appServer !== undefined) {
|
||||
app.route('/', services.appServer.app);
|
||||
logger.info('SPA App server mounted at /');
|
||||
}
|
||||
|
||||
app.onError(AppErrorHandler);
|
||||
app.notFound(AppNotFoundHandler);
|
||||
|
||||
logger.info({serviceCount: initializers.length}, 'All services mounted successfully');
|
||||
} catch (error) {
|
||||
logger.error({error: error instanceof Error ? error.message : 'Unknown error'}, 'Failed to mount routes');
|
||||
throw error;
|
||||
}
|
||||
|
||||
const initialize = async (): Promise<void> => {
|
||||
await runServiceInitialization(initializers, logger);
|
||||
};
|
||||
|
||||
const start = async (): Promise<void> => {
|
||||
await startBackgroundServices(initializers, logger);
|
||||
};
|
||||
|
||||
const shutdown = async (): Promise<void> => {
|
||||
await shutdownAllServices(initializers, logger);
|
||||
};
|
||||
|
||||
return {
|
||||
app,
|
||||
services,
|
||||
initialize,
|
||||
start,
|
||||
shutdown,
|
||||
};
|
||||
}
|
||||
524
fluxer_server/src/ServiceInitializer.tsx
Normal file
524
fluxer_server/src/ServiceInitializer.tsx
Normal file
@@ -0,0 +1,524 @@
|
||||
/*
|
||||
* 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 {Config} from '@app/Config';
|
||||
import {requireValue} from '@app/utils/ConfigUtils';
|
||||
import {type AdminAppResult, createAdminApp} from '@fluxer/admin/src/App';
|
||||
import type {AdminConfig} from '@fluxer/admin/src/types/Config';
|
||||
import {type APIAppResult, createAPIApp} from '@fluxer/api/src/App';
|
||||
import {buildAPIConfigFromMaster, initializeConfig} from '@fluxer/api/src/Config';
|
||||
import {DirectMediaService} from '@fluxer/api/src/infrastructure/DirectMediaService';
|
||||
import {initializeLogger} from '@fluxer/api/src/Logger';
|
||||
import {
|
||||
setInjectedKVProvider,
|
||||
setInjectedMediaService,
|
||||
setInjectedS3Service,
|
||||
setInjectedWorkerService,
|
||||
} from '@fluxer/api/src/middleware/ServiceRegistry';
|
||||
import {createTestHarnessResetHandler, registerTestHarnessReset} from '@fluxer/api/src/test/TestHarnessReset';
|
||||
import {DirectWorkerService} from '@fluxer/api/src/worker/DirectWorkerService';
|
||||
import {createAppServer} from '@fluxer/app_proxy/src/AppServer';
|
||||
import type {AppServerResult} from '@fluxer/app_proxy/src/AppServerTypes';
|
||||
import {getBuildMetadata} from '@fluxer/config/src/BuildMetadata';
|
||||
import {ADMIN_OAUTH2_APPLICATION_ID} from '@fluxer/constants/src/Core';
|
||||
import {createServiceTelemetry} from '@fluxer/hono/src/middleware/TelemetryAdapters';
|
||||
import type {IKVProvider} from '@fluxer/kv_client/src/IKVProvider';
|
||||
import {KVClient} from '@fluxer/kv_client/src/KVClient';
|
||||
import type {Logger} from '@fluxer/logger/src/Logger';
|
||||
import type {MediaProxyAppResult} from '@fluxer/media_proxy/src/App';
|
||||
import {createMediaProxyApp} from '@fluxer/media_proxy/src/App';
|
||||
import {createQueueApp, type QueueAppResult} from '@fluxer/queue/src/App';
|
||||
import {defaultQueueConfig} from '@fluxer/queue/src/types/QueueConfig';
|
||||
import type {S3AppResult} from '@fluxer/s3/src/App';
|
||||
import {createS3App} from '@fluxer/s3/src/App';
|
||||
import {setUser} from '@fluxer/sentry/src/Sentry';
|
||||
import {DirectQueueProvider} from '@fluxer/worker/src/providers/DirectQueueProvider';
|
||||
|
||||
type LoggerFactory = (name: string) => Logger;
|
||||
|
||||
export interface ServiceInitializationContext {
|
||||
config: Config;
|
||||
logger: Logger;
|
||||
staticDir?: string;
|
||||
}
|
||||
|
||||
export interface InitializedServices {
|
||||
kv?: IKVProvider;
|
||||
s3?: S3AppResult;
|
||||
queue?: QueueAppResult;
|
||||
mediaProxy?: MediaProxyAppResult;
|
||||
admin?: AdminAppResult;
|
||||
api?: APIAppResult;
|
||||
appServer?: AppServerResult;
|
||||
}
|
||||
|
||||
export interface ServiceInitializer {
|
||||
name: string;
|
||||
initialize?: () => Promise<void> | void;
|
||||
start?: () => Promise<void>;
|
||||
shutdown: () => Promise<void>;
|
||||
service: unknown;
|
||||
}
|
||||
|
||||
function createKVProvider(config: Config): IKVProvider {
|
||||
return new KVClient({
|
||||
url: requireValue(config.internal.kv, 'internal.kv'),
|
||||
});
|
||||
}
|
||||
|
||||
function createS3Initializer(context: ServiceInitializationContext): ServiceInitializer {
|
||||
const {config, logger} = context;
|
||||
const componentLogger = logger.child({component: 's3'});
|
||||
const telemetry = createServiceTelemetry({serviceName: 'fluxer-s3', skipPaths: ['/_health']});
|
||||
|
||||
const globalS3Config = requireValue(config.s3, 's3');
|
||||
const bucketConfig = requireValue(globalS3Config.buckets, 's3.buckets');
|
||||
const buckets = Object.values(bucketConfig) as Array<string>;
|
||||
|
||||
const s3App = createS3App({
|
||||
logger: componentLogger,
|
||||
s3Config: {
|
||||
root: requireValue(config.services.s3?.data_dir, 'services.s3.data_dir'),
|
||||
buckets,
|
||||
},
|
||||
authConfig: {
|
||||
accessKey: requireValue(globalS3Config.access_key_id, 's3.access_key_id'),
|
||||
secretKey: requireValue(globalS3Config.secret_access_key, 's3.secret_access_key'),
|
||||
},
|
||||
metricsCollector: telemetry.metricsCollector,
|
||||
tracing: telemetry.tracing,
|
||||
});
|
||||
|
||||
return {
|
||||
name: 'S3',
|
||||
initialize: async () => {
|
||||
componentLogger.info('Initializing S3 storage buckets');
|
||||
await s3App.initialize();
|
||||
},
|
||||
shutdown: async () => {
|
||||
componentLogger.info('Shutting down S3 service');
|
||||
s3App.shutdown();
|
||||
},
|
||||
service: s3App,
|
||||
};
|
||||
}
|
||||
|
||||
function createQueueInitializer(context: ServiceInitializationContext): ServiceInitializer {
|
||||
const {config, logger} = context;
|
||||
const baseLogger = logger.child({component: 'queue'});
|
||||
const telemetry = createServiceTelemetry({serviceName: 'fluxer-queue', skipPaths: ['/_health']});
|
||||
|
||||
const loggerFactory: LoggerFactory = (name: string) => baseLogger.child({module: name});
|
||||
|
||||
const queueApp = createQueueApp({
|
||||
loggerFactory,
|
||||
config: {
|
||||
...defaultQueueConfig,
|
||||
dataDir: requireValue(config.services.queue?.data_dir, 'services.queue.data_dir'),
|
||||
defaultVisibilityTimeoutMs: requireValue(
|
||||
config.services.queue?.default_visibility_timeout_ms,
|
||||
'services.queue.default_visibility_timeout_ms',
|
||||
),
|
||||
},
|
||||
metricsCollector: telemetry.metricsCollector,
|
||||
tracing: telemetry.tracing,
|
||||
});
|
||||
|
||||
return {
|
||||
name: 'Queue',
|
||||
initialize: () => {
|
||||
baseLogger.info('Queue service initialized');
|
||||
},
|
||||
start: async () => {
|
||||
baseLogger.info('Starting Queue engine and cron scheduler');
|
||||
await queueApp.start();
|
||||
},
|
||||
shutdown: async () => {
|
||||
baseLogger.info('Shutting down Queue service');
|
||||
await queueApp.shutdown();
|
||||
},
|
||||
service: queueApp,
|
||||
};
|
||||
}
|
||||
|
||||
interface MediaProxyInitializerOptions {
|
||||
publicOnly?: boolean;
|
||||
}
|
||||
|
||||
async function createMediaProxyInitializer(
|
||||
context: ServiceInitializationContext,
|
||||
options: MediaProxyInitializerOptions = {},
|
||||
): Promise<ServiceInitializer> {
|
||||
const {config, logger} = context;
|
||||
const {publicOnly = false} = options;
|
||||
const componentLogger = logger.child({component: 'media-proxy'});
|
||||
const telemetry = createServiceTelemetry({
|
||||
serviceName: 'fluxer-media-proxy',
|
||||
skipPaths: ['/_health', '/internal/telemetry'],
|
||||
});
|
||||
|
||||
const globalS3Config = requireValue(config.s3, 's3');
|
||||
const bucketCdn = requireValue(globalS3Config.buckets?.cdn, 's3.buckets.cdn');
|
||||
const bucketUploads = requireValue(globalS3Config.buckets?.uploads, 's3.buckets.uploads');
|
||||
const s3Host = requireValue(config.services.s3?.host, 'services.s3.host');
|
||||
const s3Port = requireValue(config.services.s3?.port, 'services.s3.port');
|
||||
const s3Endpoint = globalS3Config.endpoint ?? `http://${s3Host}:${s3Port}`;
|
||||
|
||||
const mediaProxySecretKey = requireValue(config.services.media_proxy?.secret_key, 'services.media_proxy.secret_key');
|
||||
|
||||
const mediaProxyApp = await createMediaProxyApp({
|
||||
logger: componentLogger,
|
||||
config: {
|
||||
nodeEnv: config.env === 'development' ? 'development' : 'production',
|
||||
secretKey: mediaProxySecretKey,
|
||||
requireCloudflareEdge: false,
|
||||
staticMode: false,
|
||||
s3: {
|
||||
endpoint: s3Endpoint,
|
||||
region: requireValue(globalS3Config.region, 's3.region'),
|
||||
accessKeyId: requireValue(globalS3Config.access_key_id, 's3.access_key_id'),
|
||||
secretAccessKey: requireValue(globalS3Config.secret_access_key, 's3.secret_access_key'),
|
||||
bucketCdn,
|
||||
bucketUploads,
|
||||
},
|
||||
},
|
||||
requestMetricsCollector: telemetry.metricsCollector,
|
||||
requestTracing: telemetry.tracing,
|
||||
publicOnly,
|
||||
});
|
||||
|
||||
return {
|
||||
name: 'Media Proxy',
|
||||
initialize: () => {
|
||||
componentLogger.info('Media Proxy service initialized');
|
||||
},
|
||||
shutdown: async () => {
|
||||
componentLogger.info('Shutting down Media Proxy service');
|
||||
await mediaProxyApp.shutdown();
|
||||
},
|
||||
service: mediaProxyApp,
|
||||
};
|
||||
}
|
||||
|
||||
function createAdminInitializer(
|
||||
context: ServiceInitializationContext,
|
||||
kvProvider?: IKVProvider | null,
|
||||
): ServiceInitializer {
|
||||
const {config, logger} = context;
|
||||
const componentLogger = logger.child({component: 'admin'});
|
||||
|
||||
const adminConfigSrc = requireValue(config.services.admin, 'services.admin');
|
||||
const adminBasePath = requireValue(adminConfigSrc.base_path, 'services.admin.base_path');
|
||||
const adminEndpoint = requireValue(config.endpoints.admin, 'endpoints.admin');
|
||||
const adminRateLimit =
|
||||
adminConfigSrc.rate_limit && adminConfigSrc.rate_limit.limit != null && adminConfigSrc.rate_limit.window_ms != null
|
||||
? {
|
||||
limit: adminConfigSrc.rate_limit.limit,
|
||||
windowMs: adminConfigSrc.rate_limit.window_ms,
|
||||
}
|
||||
: undefined;
|
||||
const buildMetadata = getBuildMetadata();
|
||||
const adminOAuthRedirectUri = `${adminEndpoint}/oauth2_callback`;
|
||||
|
||||
const adminConfig: AdminConfig = {
|
||||
env: config.env,
|
||||
secretKeyBase: requireValue(adminConfigSrc.secret_key_base, 'services.admin.secret_key_base'),
|
||||
apiEndpoint: requireValue(config.endpoints.api, 'endpoints.api'),
|
||||
mediaEndpoint: requireValue(config.endpoints.media, 'endpoints.media'),
|
||||
staticCdnEndpoint: requireValue(config.endpoints.static_cdn, 'endpoints.static_cdn'),
|
||||
adminEndpoint,
|
||||
webAppEndpoint: requireValue(config.endpoints.app, 'endpoints.app'),
|
||||
kvUrl: requireValue(config.internal.kv, 'internal.kv'),
|
||||
oauthClientId: ADMIN_OAUTH2_APPLICATION_ID.toString(),
|
||||
oauthClientSecret: requireValue(adminConfigSrc.oauth_client_secret, 'services.admin.oauth_client_secret'),
|
||||
oauthRedirectUri: adminOAuthRedirectUri,
|
||||
basePath: adminBasePath,
|
||||
selfHosted: config.instance.self_hosted,
|
||||
buildTimestamp: buildMetadata.buildTimestamp,
|
||||
releaseChannel: buildMetadata.releaseChannel,
|
||||
rateLimit: adminRateLimit,
|
||||
};
|
||||
|
||||
const adminApp = createAdminApp({
|
||||
config: adminConfig,
|
||||
logger: componentLogger,
|
||||
kvProvider,
|
||||
});
|
||||
|
||||
return {
|
||||
name: 'Admin',
|
||||
initialize: () => {
|
||||
componentLogger.info('Admin service initialized');
|
||||
},
|
||||
shutdown: async () => {
|
||||
componentLogger.info('Shutting down Admin service');
|
||||
adminApp.shutdown();
|
||||
},
|
||||
service: adminApp,
|
||||
};
|
||||
}
|
||||
|
||||
function createAppServerInitializer(context: ServiceInitializationContext): ServiceInitializer {
|
||||
const {config, logger, staticDir} = context;
|
||||
const componentLogger = logger.child({component: 'app'});
|
||||
const telemetry = createServiceTelemetry({serviceName: 'fluxer-app', skipPaths: ['/_health']});
|
||||
|
||||
if (staticDir === undefined) {
|
||||
throw new Error('Static directory is required for App Server');
|
||||
}
|
||||
|
||||
const publicUrlHost = new URL(requireValue(config.endpoints.app, 'endpoints.app')).origin;
|
||||
const mediaUrlHost = new URL(requireValue(config.endpoints.media, 'endpoints.media')).origin;
|
||||
|
||||
const appServer = createAppServer({
|
||||
staticDir,
|
||||
logger: componentLogger,
|
||||
env: config.env,
|
||||
telemetry: {
|
||||
metricsCollector: telemetry.metricsCollector,
|
||||
tracing: telemetry.tracing,
|
||||
},
|
||||
cspDirectives: {
|
||||
defaultSrc: ["'self'"],
|
||||
scriptSrc: ["'self'", "'unsafe-inline'"],
|
||||
styleSrc: ["'self'", "'unsafe-inline'"],
|
||||
imgSrc: ["'self'", 'data:', 'blob:', publicUrlHost, mediaUrlHost],
|
||||
connectSrc: ["'self'", 'wss:', 'ws:', publicUrlHost],
|
||||
fontSrc: ["'self'"],
|
||||
mediaSrc: ["'self'", 'blob:', mediaUrlHost],
|
||||
frameSrc: ["'none'"],
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
name: 'App Server',
|
||||
initialize: () => {
|
||||
componentLogger.info({staticDir}, 'SPA App server initialized');
|
||||
},
|
||||
shutdown: async () => {
|
||||
componentLogger.info('Shutting down App server');
|
||||
appServer.shutdown();
|
||||
},
|
||||
service: appServer,
|
||||
};
|
||||
}
|
||||
|
||||
async function createAPIInitializer(context: ServiceInitializationContext): Promise<ServiceInitializer> {
|
||||
const {config, logger} = context;
|
||||
const componentLogger = logger.child({component: 'api'});
|
||||
|
||||
const apiConfig = buildAPIConfigFromMaster(config);
|
||||
|
||||
initializeConfig(apiConfig);
|
||||
initializeLogger(componentLogger);
|
||||
|
||||
const apiApp = await createAPIApp({
|
||||
config: apiConfig,
|
||||
logger: componentLogger,
|
||||
setSentryUser: setUser,
|
||||
isTelemetryActive: () => config.telemetry.enabled,
|
||||
});
|
||||
|
||||
return {
|
||||
name: 'API',
|
||||
initialize: async () => {
|
||||
componentLogger.info('Running API service initialization');
|
||||
await apiApp.initialize();
|
||||
},
|
||||
shutdown: async () => {
|
||||
componentLogger.info('Shutting down API service');
|
||||
await apiApp.shutdown();
|
||||
},
|
||||
service: apiApp,
|
||||
};
|
||||
}
|
||||
|
||||
export async function initializeAllServices(context: ServiceInitializationContext): Promise<{
|
||||
services: InitializedServices;
|
||||
initializers: Array<ServiceInitializer>;
|
||||
}> {
|
||||
const {staticDir} = context;
|
||||
const rootLogger = context.logger.child({component: 'service-initializer'});
|
||||
|
||||
const initializers: Array<ServiceInitializer> = [];
|
||||
const services: InitializedServices = {};
|
||||
let kvProvider: IKVProvider | null = null;
|
||||
|
||||
rootLogger.info('Starting service initialization');
|
||||
|
||||
try {
|
||||
rootLogger.info('Initializing KV provider');
|
||||
kvProvider = createKVProvider(context.config);
|
||||
services.kv = kvProvider;
|
||||
setInjectedKVProvider(kvProvider);
|
||||
|
||||
rootLogger.info('Initializing S3 service');
|
||||
const s3Init = createS3Initializer(context);
|
||||
initializers.push(s3Init);
|
||||
services.s3 = s3Init.service as S3AppResult;
|
||||
|
||||
if (services.s3) {
|
||||
rootLogger.info('Wiring DirectS3StorageService for in-process communication');
|
||||
setInjectedS3Service(services.s3.getS3Service());
|
||||
}
|
||||
|
||||
rootLogger.info('Initializing Queue service');
|
||||
const queueInit = createQueueInitializer(context);
|
||||
initializers.push(queueInit);
|
||||
services.queue = queueInit.service as QueueAppResult;
|
||||
|
||||
if (services.queue) {
|
||||
rootLogger.info('Wiring DirectWorkerService for in-process communication');
|
||||
const directQueueProvider = new DirectQueueProvider({
|
||||
engine: services.queue.engine,
|
||||
cronScheduler: services.queue.cronScheduler,
|
||||
});
|
||||
const directWorkerService = new DirectWorkerService({
|
||||
queueProvider: directQueueProvider,
|
||||
logger: context.logger.child({component: 'direct-worker-service'}),
|
||||
});
|
||||
setInjectedWorkerService(directWorkerService);
|
||||
}
|
||||
|
||||
rootLogger.info('Initializing Media Proxy service');
|
||||
const mediaProxyInit = await createMediaProxyInitializer(context, {publicOnly: context.config.isMonolith});
|
||||
initializers.push(mediaProxyInit);
|
||||
services.mediaProxy = mediaProxyInit.service as MediaProxyAppResult;
|
||||
|
||||
if (services.mediaProxy.services) {
|
||||
rootLogger.info('Wiring DirectMediaService for in-process communication');
|
||||
const directMediaService = new DirectMediaService({
|
||||
metadataService: services.mediaProxy.services.metadataService,
|
||||
frameService: services.mediaProxy.services.frameService,
|
||||
mediaProxyEndpoint: requireValue(context.config.endpoints.media, 'endpoints.media'),
|
||||
mediaProxySecretKey: requireValue(
|
||||
context.config.services.media_proxy?.secret_key,
|
||||
'services.media_proxy.secret_key',
|
||||
),
|
||||
logger: context.logger.child({component: 'direct-media-service'}),
|
||||
});
|
||||
setInjectedMediaService(directMediaService);
|
||||
}
|
||||
|
||||
rootLogger.info('Initializing Admin service');
|
||||
const adminInit = createAdminInitializer(context, kvProvider);
|
||||
initializers.push(adminInit);
|
||||
services.admin = adminInit.service as AdminAppResult;
|
||||
|
||||
rootLogger.info('Initializing API service');
|
||||
const apiInit = await createAPIInitializer(context);
|
||||
initializers.push(apiInit);
|
||||
services.api = apiInit.service as APIAppResult;
|
||||
|
||||
if (staticDir !== undefined) {
|
||||
rootLogger.info('Initializing App Server');
|
||||
const appServerInit = createAppServerInitializer(context);
|
||||
initializers.push(appServerInit);
|
||||
services.appServer = appServerInit.service as AppServerResult;
|
||||
} else {
|
||||
rootLogger.info('No static directory configured, SPA App server disabled');
|
||||
}
|
||||
|
||||
rootLogger.info({serviceCount: initializers.length}, 'All services created successfully');
|
||||
|
||||
if (context.config.dev.test_mode_enabled) {
|
||||
registerTestHarnessReset(
|
||||
createTestHarnessResetHandler({
|
||||
kvProvider: services.kv,
|
||||
queueEngine: services.queue?.engine,
|
||||
s3Service: services.s3?.getS3Service(),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return {services, initializers};
|
||||
} catch (error) {
|
||||
rootLogger.error(
|
||||
{error: error instanceof Error ? error.message : 'Unknown error'},
|
||||
'Failed to initialize services',
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function runServiceInitialization(initializers: Array<ServiceInitializer>, logger: Logger): Promise<void> {
|
||||
const rootLogger = logger.child({component: 'service-initializer'});
|
||||
|
||||
rootLogger.info('Running service initialization tasks');
|
||||
|
||||
for (const initializer of initializers) {
|
||||
try {
|
||||
if (initializer.initialize !== undefined) {
|
||||
rootLogger.debug({service: initializer.name}, 'Running initialization');
|
||||
await initializer.initialize();
|
||||
}
|
||||
} catch (error) {
|
||||
rootLogger.error(
|
||||
{service: initializer.name, error: error instanceof Error ? error.message : 'Unknown error'},
|
||||
'Service initialization failed',
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
rootLogger.info('All service initialization tasks completed');
|
||||
}
|
||||
|
||||
export async function startBackgroundServices(initializers: Array<ServiceInitializer>, logger: Logger): Promise<void> {
|
||||
const rootLogger = logger.child({component: 'service-initializer'});
|
||||
|
||||
rootLogger.info('Starting background services');
|
||||
|
||||
for (const initializer of initializers) {
|
||||
try {
|
||||
if (initializer.start !== undefined) {
|
||||
rootLogger.debug({service: initializer.name}, 'Starting background tasks');
|
||||
await initializer.start();
|
||||
}
|
||||
} catch (error) {
|
||||
rootLogger.error(
|
||||
{service: initializer.name, error: error instanceof Error ? error.message : 'Unknown error'},
|
||||
'Failed to start background service',
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
rootLogger.info('All background services started');
|
||||
}
|
||||
|
||||
export async function shutdownAllServices(initializers: Array<ServiceInitializer>, logger: Logger): Promise<void> {
|
||||
const rootLogger = logger.child({component: 'service-initializer'});
|
||||
|
||||
rootLogger.info('Beginning graceful shutdown of all services');
|
||||
|
||||
for (const initializer of initializers.reverse()) {
|
||||
try {
|
||||
rootLogger.debug({service: initializer.name}, 'Shutting down service');
|
||||
await initializer.shutdown();
|
||||
} catch (error) {
|
||||
rootLogger.error(
|
||||
{service: initializer.name, error: error instanceof Error ? error.message : 'Unknown error'},
|
||||
'Error during service shutdown',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
rootLogger.info('All services shut down');
|
||||
}
|
||||
240
fluxer_server/src/index.tsx
Normal file
240
fluxer_server/src/index.tsx
Normal file
@@ -0,0 +1,240 @@
|
||||
/*
|
||||
* 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 {Server} from 'node:http';
|
||||
import {Config, type Config as FluxerServerConfig} from '@app/Config';
|
||||
import {shutdownInstrumentation} from '@app/Instrument';
|
||||
import {createComponentLogger, Logger} from '@app/Logger';
|
||||
import {mountRoutes} from '@app/Routes';
|
||||
import {createGatewayProcessManager, type GatewayProcessManager} from '@app/utils/GatewayProcessManager';
|
||||
import {createGatewayProxy} from '@app/utils/GatewayProxy';
|
||||
import {getSnowflakeService} from '@fluxer/api/src/middleware/ServiceRegistry';
|
||||
import {initializeWorkerDependencies} from '@fluxer/api/src/worker/WorkerDependencies';
|
||||
import {workerTasks} from '@fluxer/api/src/worker/WorkerTaskRegistry';
|
||||
import {createServerWithUpgrade} from '@fluxer/hono/src/Server';
|
||||
import type {BaseHonoEnv} from '@fluxer/hono_types/src/HonoTypes';
|
||||
import {DirectQueueProvider} from '@fluxer/worker/src/providers/DirectQueueProvider';
|
||||
import {createWorker, type WorkerResult} from '@fluxer/worker/src/runtime/WorkerFactory';
|
||||
import type {Hono} from 'hono';
|
||||
|
||||
export interface FluxerServerOptions {
|
||||
config?: FluxerServerConfig;
|
||||
staticDir?: string;
|
||||
}
|
||||
|
||||
export interface FluxerServerResult {
|
||||
app: Hono<BaseHonoEnv>;
|
||||
initialize: () => Promise<void>;
|
||||
start: () => Promise<void>;
|
||||
shutdown: () => Promise<void>;
|
||||
}
|
||||
|
||||
export async function createFluxerServer(options: FluxerServerOptions = {}): Promise<FluxerServerResult> {
|
||||
const config = options.config ?? Config;
|
||||
const staticDir = options.staticDir;
|
||||
|
||||
const mounted = await mountRoutes({
|
||||
config,
|
||||
staticDir,
|
||||
});
|
||||
|
||||
let server: Server | null = null;
|
||||
let worker: WorkerResult | null = null;
|
||||
let gatewayManager: GatewayProcessManager | null = null;
|
||||
let isShuttingDown = false;
|
||||
|
||||
const start = async (): Promise<void> => {
|
||||
Logger.info(
|
||||
{
|
||||
host: config.host,
|
||||
port: config.port,
|
||||
env: config.env,
|
||||
database: config.database.backend,
|
||||
workerEnabled: config.services.queue !== undefined,
|
||||
},
|
||||
'Starting Fluxer Server',
|
||||
);
|
||||
|
||||
Logger.info('Starting background services (queue engine, cron scheduler)');
|
||||
await mounted.start();
|
||||
|
||||
const shouldStartGatewayProcess =
|
||||
config.services.gateway && (config.env === 'production' || config.dev.test_mode_enabled);
|
||||
if (shouldStartGatewayProcess) {
|
||||
Logger.info('Initializing Gateway Process Manager');
|
||||
gatewayManager = createGatewayProcessManager();
|
||||
await gatewayManager.start();
|
||||
}
|
||||
|
||||
if (config.services.queue !== undefined) {
|
||||
const workerLogger = createComponentLogger('worker');
|
||||
|
||||
let queueProvider: DirectQueueProvider | undefined;
|
||||
if (mounted.services.queue) {
|
||||
workerLogger.info('Creating DirectQueueProvider for in-process communication');
|
||||
queueProvider = new DirectQueueProvider({
|
||||
engine: mounted.services.queue.engine,
|
||||
cronScheduler: mounted.services.queue.cronScheduler,
|
||||
});
|
||||
}
|
||||
|
||||
workerLogger.info('Initializing worker dependencies');
|
||||
const snowflakeService = getSnowflakeService();
|
||||
await snowflakeService.initialize();
|
||||
const workerDependencies = await initializeWorkerDependencies(snowflakeService);
|
||||
|
||||
worker = createWorker({
|
||||
queue: {
|
||||
queueBaseUrl: config.internal.queue,
|
||||
queueProvider,
|
||||
},
|
||||
runtime: {
|
||||
concurrency: config.services.queue.concurrency ?? 1,
|
||||
},
|
||||
logger: workerLogger,
|
||||
dependencies: workerDependencies,
|
||||
});
|
||||
|
||||
workerLogger.info({taskCount: Object.keys(workerTasks).length}, 'Registering worker tasks');
|
||||
worker.registerTasks(workerTasks);
|
||||
|
||||
workerLogger.info({concurrency: config.services.queue.concurrency ?? 1}, 'Starting embedded worker');
|
||||
await worker.start();
|
||||
}
|
||||
|
||||
const gatewayProxy = createGatewayProxy();
|
||||
|
||||
const onUpgrade = gatewayProxy.onUpgrade;
|
||||
|
||||
return await new Promise((resolve) => {
|
||||
server = createServerWithUpgrade(mounted.app, {
|
||||
hostname: config.host,
|
||||
port: config.port,
|
||||
onUpgrade,
|
||||
onListen: (info) => {
|
||||
Logger.info(
|
||||
{
|
||||
address: info.address,
|
||||
port: info.port,
|
||||
},
|
||||
'Fluxer Server listening',
|
||||
);
|
||||
resolve();
|
||||
},
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const shutdown = async (): Promise<void> => {
|
||||
if (isShuttingDown) {
|
||||
Logger.warn('Shutdown already in progress, ignoring duplicate signal');
|
||||
return;
|
||||
}
|
||||
isShuttingDown = true;
|
||||
|
||||
Logger.info('Beginning graceful shutdown of Fluxer Server');
|
||||
|
||||
const shutdownSteps = [
|
||||
{
|
||||
name: 'Worker',
|
||||
fn: async () => {
|
||||
if (worker !== null) {
|
||||
Logger.info('Stopping embedded worker');
|
||||
await worker.shutdown();
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'HTTP Server',
|
||||
fn: async () => {
|
||||
if (server !== null) {
|
||||
Logger.info('Stopping HTTP server');
|
||||
server.closeAllConnections();
|
||||
await new Promise<void>((resolve) => {
|
||||
const timeout = setTimeout(() => {
|
||||
Logger.warn('HTTP server close timeout, forcing shutdown');
|
||||
resolve();
|
||||
}, 3000);
|
||||
server!.close((err) => {
|
||||
clearTimeout(timeout);
|
||||
if (err !== undefined) {
|
||||
Logger.error({error: err.message}, 'Error closing HTTP server');
|
||||
} else {
|
||||
Logger.info('HTTP server closed');
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Application Services',
|
||||
fn: async () => {
|
||||
Logger.info('Shutting down application services');
|
||||
await mounted.shutdown();
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Gateway Process',
|
||||
fn: async () => {
|
||||
if (gatewayManager) {
|
||||
Logger.info('Stopping Gateway process');
|
||||
await gatewayManager.stop();
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Instrumentation',
|
||||
fn: async () => {
|
||||
Logger.info('Shutting down telemetry and instrumentation');
|
||||
await shutdownInstrumentation();
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
for (const step of shutdownSteps) {
|
||||
try {
|
||||
await step.fn();
|
||||
} catch (error) {
|
||||
Logger.error(
|
||||
{
|
||||
step: step.name,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
},
|
||||
'Error during shutdown step',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Logger.info('Fluxer Server shutdown complete');
|
||||
};
|
||||
|
||||
const initialize = async (): Promise<void> => {
|
||||
Logger.info('Initializing services');
|
||||
await mounted.initialize();
|
||||
};
|
||||
|
||||
return {
|
||||
app: mounted.app,
|
||||
initialize,
|
||||
start,
|
||||
shutdown,
|
||||
};
|
||||
}
|
||||
98
fluxer_server/src/startServer.tsx
Normal file
98
fluxer_server/src/startServer.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
/*
|
||||
* 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 {Config} from '@app/Config';
|
||||
import {createFluxerServer, type FluxerServerResult} from '@app/index';
|
||||
import {initializeLogger, Logger} from '@app/Logger';
|
||||
import {setupGracefulShutdown} from '@fluxer/hono/src/Server';
|
||||
|
||||
initializeLogger({environment: Config.env});
|
||||
|
||||
let fluxerServer: FluxerServerResult | null = null;
|
||||
let isExiting = false;
|
||||
|
||||
async function shutdownServer(reason: string): Promise<void> {
|
||||
if (isExiting) {
|
||||
Logger.warn({reason}, 'Already shutting down, ignoring signal');
|
||||
return;
|
||||
}
|
||||
isExiting = true;
|
||||
|
||||
Logger.info({reason}, 'Initiating shutdown');
|
||||
|
||||
if (fluxerServer !== null) {
|
||||
try {
|
||||
await fluxerServer.shutdown();
|
||||
Logger.info('Shutdown completed successfully');
|
||||
} catch (error) {
|
||||
Logger.error({error: error instanceof Error ? error.message : 'Unknown error'}, 'Error during shutdown');
|
||||
throw error;
|
||||
}
|
||||
} else {
|
||||
Logger.warn('No server instance to shut down');
|
||||
throw new Error('No server instance to shut down');
|
||||
}
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
try {
|
||||
Logger.info('Creating Fluxer Server');
|
||||
fluxerServer = await createFluxerServer({
|
||||
staticDir: Config.services.server?.static_dir,
|
||||
});
|
||||
|
||||
Logger.info('Running service initialization');
|
||||
await fluxerServer.initialize();
|
||||
|
||||
setupGracefulShutdown(async () => shutdownServer('signal'), {logger: Logger, timeoutMs: 30000});
|
||||
|
||||
process.on('uncaughtException', (error) => {
|
||||
Logger.fatal({error: error.message, stack: error.stack}, 'Uncaught exception - forcing shutdown');
|
||||
void shutdownServer('uncaughtException').then(
|
||||
() => process.exit(1),
|
||||
() => process.exit(1),
|
||||
);
|
||||
});
|
||||
|
||||
process.on('unhandledRejection', (reason) => {
|
||||
Logger.fatal(
|
||||
{
|
||||
reason: reason instanceof Error ? reason.message : String(reason),
|
||||
stack: reason instanceof Error ? reason.stack : undefined,
|
||||
},
|
||||
'Unhandled promise rejection - forcing shutdown',
|
||||
);
|
||||
void shutdownServer('unhandledRejection').then(
|
||||
() => process.exit(1),
|
||||
() => process.exit(1),
|
||||
);
|
||||
});
|
||||
|
||||
Logger.info('Starting Fluxer Server');
|
||||
await fluxerServer.start();
|
||||
} catch (error) {
|
||||
Logger.fatal({error: error instanceof Error ? error.message : 'Unknown error'}, 'Failed to start server');
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
Logger.fatal({error: err}, 'Failed to start server');
|
||||
process.exit(1);
|
||||
});
|
||||
25
fluxer_server/src/utils/ConfigUtils.tsx
Normal file
25
fluxer_server/src/utils/ConfigUtils.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
export function requireValue<T>(value: T | undefined | null, name: string): T {
|
||||
if (value === undefined || value === null) {
|
||||
throw new Error(`Missing required config: ${name}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
201
fluxer_server/src/utils/GatewayProcessManager.tsx
Normal file
201
fluxer_server/src/utils/GatewayProcessManager.tsx
Normal file
@@ -0,0 +1,201 @@
|
||||
/*
|
||||
* 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 ChildProcess, spawn, spawnSync} from 'node:child_process';
|
||||
import {randomBytes} from 'node:crypto';
|
||||
import {existsSync} from 'node:fs';
|
||||
import {Config} from '@app/Config';
|
||||
import {Logger} from '@app/Logger';
|
||||
|
||||
const GATEWAY_STARTUP_DELAY_MS = 1000;
|
||||
const GATEWAY_SHUTDOWN_TIMEOUT_MS = 2000;
|
||||
const GATEWAY_PATH = '/opt/fluxer_gateway/bin/fluxer_gateway';
|
||||
const GATEWAY_DIST_PORT = 9100;
|
||||
|
||||
export interface GatewayProcessManager {
|
||||
start: () => Promise<void>;
|
||||
stop: () => Promise<void>;
|
||||
isRunning: () => boolean;
|
||||
}
|
||||
|
||||
function generateUniqueNodeName(): string {
|
||||
const uniqueId = randomBytes(4).toString('hex');
|
||||
const timestamp = Date.now();
|
||||
return `fluxer_gateway_${timestamp}_${uniqueId}@127.0.0.1`;
|
||||
}
|
||||
|
||||
function killStaleGatewayProcesses(logger: Logger): void {
|
||||
const result = spawnSync('pgrep', ['-f', 'fluxer_gateway.*@'], {timeout: 1000, encoding: 'utf-8'});
|
||||
if (result.status === 0 && result.stdout) {
|
||||
const pids = result.stdout.trim().split('\n').filter(Boolean);
|
||||
for (const pid of pids) {
|
||||
logger.info({pid}, 'Killing stale gateway BEAM process');
|
||||
spawnSync('kill', ['-9', pid], {timeout: 1000});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function killEpmd(logger: Logger): void {
|
||||
const result = spawnSync('pgrep', ['-f', 'epmd'], {timeout: 1000, encoding: 'utf-8'});
|
||||
if (result.status === 0 && result.stdout) {
|
||||
const pids = result.stdout.trim().split('\n').filter(Boolean);
|
||||
for (const pid of pids) {
|
||||
logger.info({pid}, 'Killing EPMD process');
|
||||
spawnSync('kill', ['-9', pid], {timeout: 1000});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function freeDistributionPort(logger: Logger): void {
|
||||
const result = spawnSync('lsof', ['-ti', `:${GATEWAY_DIST_PORT}`], {timeout: 1000, encoding: 'utf-8'});
|
||||
if (result.status === 0 && result.stdout) {
|
||||
const pids = result.stdout.trim().split('\n').filter(Boolean);
|
||||
for (const pid of pids) {
|
||||
logger.info({pid, port: GATEWAY_DIST_PORT}, 'Killing process holding distribution port');
|
||||
spawnSync('kill', ['-9', pid], {timeout: 1000});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function createGatewayProcessManager(): GatewayProcessManager {
|
||||
let gatewayProcess: ChildProcess | null = null;
|
||||
let startupFailed = false;
|
||||
let currentNodeName: string | null = null;
|
||||
const logger = Logger.child({component: 'gateway-process'});
|
||||
|
||||
function forwardStream(stream: NodeJS.ReadableStream, level: 'info' | 'error'): void {
|
||||
stream.on('data', (data) => {
|
||||
const msg = data.toString().trim();
|
||||
if (msg) {
|
||||
if (msg.includes('GLIBC') && msg.includes('not found')) {
|
||||
logger.warn(
|
||||
{source: 'gateway'},
|
||||
'Gateway binary requires newer glibc. WebSocket gateway unavailable. Update your Docker base image or rebuild the gateway.',
|
||||
);
|
||||
startupFailed = true;
|
||||
} else {
|
||||
logger[level]({source: 'gateway'}, msg);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForStartup(): Promise<void> {
|
||||
await new Promise((resolve) => setTimeout(resolve, GATEWAY_STARTUP_DELAY_MS));
|
||||
}
|
||||
|
||||
async function gracefulShutdown(process: ChildProcess): Promise<void> {
|
||||
logger.info('Sending SIGTERM to gateway process');
|
||||
process.kill('SIGTERM');
|
||||
await new Promise((resolve) => setTimeout(resolve, GATEWAY_SHUTDOWN_TIMEOUT_MS));
|
||||
if (gatewayProcess) {
|
||||
logger.warn('Gateway did not shutdown gracefully, force killing');
|
||||
process.kill('SIGKILL');
|
||||
}
|
||||
}
|
||||
|
||||
const start = async (): Promise<void> => {
|
||||
if (!existsSync(GATEWAY_PATH)) {
|
||||
logger.warn('Gateway binary not found, WebSocket gateway unavailable');
|
||||
startupFailed = true;
|
||||
return;
|
||||
}
|
||||
|
||||
killStaleGatewayProcesses(logger);
|
||||
killEpmd(logger);
|
||||
freeDistributionPort(logger);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
currentNodeName = generateUniqueNodeName();
|
||||
logger.info({nodeName: currentNodeName}, 'Starting Fluxer Gateway process with unique node name');
|
||||
|
||||
const gatewayEnv = {
|
||||
...process.env,
|
||||
FLUXER_GATEWAY_HOST: Config.services.gateway.port ? '0.0.0.0' : '127.0.0.1',
|
||||
FLUXER_GATEWAY_PORT: Config.services.gateway.port.toString(),
|
||||
FLUXER_GATEWAY_API_HOST: Config.services.gateway.api_host,
|
||||
FLUXER_GATEWAY_NODE_FLAG: '-name',
|
||||
FLUXER_GATEWAY_NODE_NAME: currentNodeName,
|
||||
ERL_DIST_PORT: GATEWAY_DIST_PORT.toString(),
|
||||
};
|
||||
|
||||
try {
|
||||
gatewayProcess = spawn(GATEWAY_PATH, ['foreground'], {
|
||||
env: gatewayEnv,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
|
||||
if (gatewayProcess.stdout) {
|
||||
forwardStream(gatewayProcess.stdout, 'info');
|
||||
}
|
||||
|
||||
if (gatewayProcess.stderr) {
|
||||
forwardStream(gatewayProcess.stderr, 'error');
|
||||
}
|
||||
|
||||
gatewayProcess.on('error', (err) => {
|
||||
logger.warn({error: err.message}, 'Gateway process error, WebSocket gateway unavailable');
|
||||
startupFailed = true;
|
||||
});
|
||||
|
||||
gatewayProcess.on('exit', (code, signal) => {
|
||||
if (code !== 0 && code !== null) {
|
||||
if (!startupFailed) {
|
||||
logger.warn({code, signal}, 'Gateway process exited unexpectedly, WebSocket gateway unavailable');
|
||||
}
|
||||
startupFailed = true;
|
||||
} else {
|
||||
logger.info({code, signal}, 'Gateway process exited');
|
||||
}
|
||||
gatewayProcess = null;
|
||||
currentNodeName = null;
|
||||
});
|
||||
|
||||
await waitForStartup();
|
||||
|
||||
if (gatewayProcess && !startupFailed) {
|
||||
logger.info('Gateway process started successfully');
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
{error: error instanceof Error ? error.message : 'Unknown error'},
|
||||
'Failed to spawn gateway process, WebSocket gateway unavailable',
|
||||
);
|
||||
startupFailed = true;
|
||||
}
|
||||
};
|
||||
|
||||
const stop = async (): Promise<void> => {
|
||||
if (gatewayProcess) {
|
||||
logger.info('Stopping Gateway process');
|
||||
await gracefulShutdown(gatewayProcess);
|
||||
}
|
||||
};
|
||||
|
||||
const isRunning = (): boolean => {
|
||||
return gatewayProcess !== null && !startupFailed;
|
||||
};
|
||||
|
||||
return {
|
||||
start,
|
||||
stop,
|
||||
isRunning,
|
||||
};
|
||||
}
|
||||
162
fluxer_server/src/utils/GatewayProxy.tsx
Normal file
162
fluxer_server/src/utils/GatewayProxy.tsx
Normal file
@@ -0,0 +1,162 @@
|
||||
/*
|
||||
* 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 http from 'node:http';
|
||||
import type {Socket} from 'node:net';
|
||||
import type {Duplex} from 'node:stream';
|
||||
import {Config} from '@app/Config';
|
||||
import {Logger} from '@app/Logger';
|
||||
import {extractClientIpFromHeaders} from '@fluxer/ip_utils/src/ClientIp';
|
||||
|
||||
export interface GatewayProxy {
|
||||
onUpgrade: (req: http.IncomingMessage, socket: Duplex, head: Buffer) => void;
|
||||
}
|
||||
|
||||
function formatHeaderValue(value: string | Array<string> | undefined): string {
|
||||
if (value === undefined) {
|
||||
return '';
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value.join(', ');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function createResponseHeaders(res: http.IncomingMessage): string {
|
||||
const headers = Object.entries(res.headers)
|
||||
.filter(([, v]) => v !== undefined)
|
||||
.map(([k, v]) => `${k}: ${formatHeaderValue(v)}`);
|
||||
return [`HTTP/${res.httpVersion} ${res.statusCode} ${res.statusMessage}`, ...headers, '', ''].join('\r\n');
|
||||
}
|
||||
|
||||
function cleanupSockets(clientSocket: Socket, proxySocket?: Socket): void {
|
||||
if (!clientSocket.destroyed) {
|
||||
clientSocket.destroy();
|
||||
}
|
||||
if (proxySocket && !proxySocket.destroyed) {
|
||||
proxySocket.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
export function createGatewayProxy(): GatewayProxy {
|
||||
const gatewayHost = '127.0.0.1';
|
||||
const gatewayPort = Config.services.gateway.port;
|
||||
|
||||
Logger.info({host: gatewayHost, port: gatewayPort}, 'Gateway Proxy initialized');
|
||||
|
||||
const onUpgrade = (req: http.IncomingMessage, socket: Duplex, head: Buffer): void => {
|
||||
const clientSocket = socket as Socket;
|
||||
|
||||
Logger.info({url: req.url, method: req.method}, 'Gateway proxy: received upgrade request');
|
||||
|
||||
if (!req.url?.startsWith('/gateway')) {
|
||||
Logger.warn({url: req.url}, 'Gateway proxy: invalid path, destroying connection');
|
||||
clientSocket.destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
const stripped = req.url.replace(/^\/gateway/, '');
|
||||
const forwardPath = stripped === '' || stripped.startsWith('?') ? `/${stripped}` : stripped;
|
||||
|
||||
Logger.info({forwardPath, host: gatewayHost, port: gatewayPort}, 'Gateway proxy: forwarding to gateway');
|
||||
|
||||
const clientIp = extractClientIpFromHeaders(req.headers, {
|
||||
trustCfConnectingIp: Config.proxy.trust_cf_connecting_ip,
|
||||
});
|
||||
|
||||
const forwardedHeaders = {...req.headers};
|
||||
if (clientIp) {
|
||||
forwardedHeaders['x-forwarded-for'] = clientIp;
|
||||
}
|
||||
|
||||
const options = {
|
||||
hostname: gatewayHost,
|
||||
port: gatewayPort,
|
||||
path: forwardPath,
|
||||
method: req.method,
|
||||
headers: forwardedHeaders,
|
||||
};
|
||||
|
||||
const proxyReq = http.request(options);
|
||||
|
||||
proxyReq.on('upgrade', (res, proxySocket, proxyHead) => {
|
||||
Logger.info({status: res.statusCode}, 'Gateway proxy: received upgrade response from gateway');
|
||||
if (!clientSocket.writable || !proxySocket.writable) {
|
||||
Logger.warn('Gateway proxy: socket not writable, cleaning up');
|
||||
cleanupSockets(clientSocket, proxySocket);
|
||||
return;
|
||||
}
|
||||
|
||||
clientSocket.write(createResponseHeaders(res));
|
||||
clientSocket.write(proxyHead);
|
||||
proxySocket.write(head);
|
||||
|
||||
clientSocket.pipe(proxySocket);
|
||||
proxySocket.pipe(clientSocket);
|
||||
|
||||
proxySocket.on('error', (err) => {
|
||||
Logger.debug({error: err.message}, 'Gateway proxy socket error');
|
||||
cleanupSockets(clientSocket, proxySocket);
|
||||
});
|
||||
|
||||
clientSocket.on('error', (err) => {
|
||||
Logger.debug({error: err.message}, 'Client socket error during proxy');
|
||||
cleanupSockets(clientSocket, proxySocket);
|
||||
});
|
||||
|
||||
clientSocket.on('close', () => {
|
||||
if (!proxySocket.destroyed) {
|
||||
proxySocket.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
proxySocket.on('close', () => {
|
||||
if (!clientSocket.destroyed) {
|
||||
clientSocket.destroy();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
proxyReq.on('error', (err) => {
|
||||
Logger.error(
|
||||
{error: err.message, code: (err as NodeJS.ErrnoException).code},
|
||||
'Gateway proxy: failed to proxy upgrade request',
|
||||
);
|
||||
clientSocket.write('HTTP/1.1 502 Bad Gateway\r\n\r\n');
|
||||
cleanupSockets(clientSocket);
|
||||
});
|
||||
|
||||
proxyReq.on('response', (res) => {
|
||||
Logger.info(
|
||||
{statusCode: res.statusCode, headers: res.headers},
|
||||
'Gateway proxy: received HTTP response (not upgrade)',
|
||||
);
|
||||
});
|
||||
|
||||
clientSocket.on('error', () => {
|
||||
proxyReq.destroy();
|
||||
});
|
||||
|
||||
proxyReq.end();
|
||||
};
|
||||
|
||||
return {
|
||||
onUpgrade,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user