refactor progress

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

View File

@@ -0,0 +1,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;

View 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);
};
}

View 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,
},
});

View 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});
}

View 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,
};
}

View 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
View 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,
};
}

View 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);
});

View 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;
}

View 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,
};
}

View 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,
};
}