refactor progress
This commit is contained in:
57
fluxer_server/.dockerignore
Normal file
57
fluxer_server/.dockerignore
Normal file
@@ -0,0 +1,57 @@
|
||||
!.env.example
|
||||
$RECYCLE.BIN/
|
||||
*.crt
|
||||
*.db
|
||||
*.db-journal
|
||||
*.key
|
||||
*.log
|
||||
*.map
|
||||
*.pem
|
||||
*.spec.ts
|
||||
*.spec.tsx
|
||||
*.swo
|
||||
*.swp
|
||||
*.sqlite
|
||||
*.test.ts
|
||||
*.test.tsx
|
||||
*.tmp
|
||||
*~
|
||||
.AppleDouble
|
||||
.DS_Store
|
||||
.Trash-*
|
||||
.cache/
|
||||
.circleci/
|
||||
.classpath
|
||||
.env
|
||||
.env.*
|
||||
.git/
|
||||
.gitattributes
|
||||
.github/
|
||||
.gitignore
|
||||
.gitlab-ci.yml
|
||||
.idea/
|
||||
.LSOverride
|
||||
.nyc_output/
|
||||
.pnpm-store/
|
||||
.project
|
||||
.settings/
|
||||
.travis.yml
|
||||
.turbo/
|
||||
.vscode/
|
||||
__mocks__/
|
||||
__tests__/
|
||||
build/
|
||||
CHANGELOG.md
|
||||
CONTRIBUTING.md
|
||||
coverage/
|
||||
data/
|
||||
desktop.ini
|
||||
dist/
|
||||
docs/
|
||||
node_modules/
|
||||
out/
|
||||
README.md
|
||||
secrets/
|
||||
temp/
|
||||
Thumbs.db
|
||||
tmp/
|
||||
199
fluxer_server/Dockerfile
Normal file
199
fluxer_server/Dockerfile
Normal file
@@ -0,0 +1,199 @@
|
||||
ARG BUILD_SHA
|
||||
ARG BUILD_NUMBER
|
||||
ARG BUILD_TIMESTAMP
|
||||
ARG RELEASE_CHANNEL=nightly
|
||||
|
||||
FROM node:24-trixie-slim AS base
|
||||
|
||||
WORKDIR /usr/src/app
|
||||
|
||||
RUN corepack enable && corepack prepare pnpm@10.26.0 --activate
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
curl \
|
||||
python3 \
|
||||
make \
|
||||
g++ \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
FROM base AS deps
|
||||
|
||||
COPY pnpm-workspace.yaml ./
|
||||
COPY pnpm-lock.yaml ./
|
||||
COPY package.json ./
|
||||
COPY patches/ ./patches/
|
||||
|
||||
COPY packages/admin/package.json ./packages/admin/
|
||||
COPY packages/api/package.json ./packages/api/
|
||||
COPY packages/app/package.json ./packages/app/
|
||||
COPY packages/cache/package.json ./packages/cache/
|
||||
COPY packages/captcha/package.json ./packages/captcha/
|
||||
COPY packages/cassandra/package.json ./packages/cassandra/
|
||||
COPY packages/config/package.json ./packages/config/
|
||||
COPY packages/constants/package.json ./packages/constants/
|
||||
COPY packages/email/package.json ./packages/email/
|
||||
COPY packages/errors/package.json ./packages/errors/
|
||||
COPY packages/hono/package.json ./packages/hono/
|
||||
COPY packages/hono_types/package.json ./packages/hono_types/
|
||||
COPY packages/initialization/package.json ./packages/initialization/
|
||||
COPY packages/ip_utils/package.json ./packages/ip_utils/
|
||||
COPY packages/logger/package.json ./packages/logger/
|
||||
COPY packages/marketing/package.json ./packages/marketing/
|
||||
COPY packages/media_proxy/package.json ./packages/media_proxy/
|
||||
COPY packages/oauth2/package.json ./packages/oauth2/
|
||||
COPY packages/queue/package.json ./packages/queue/
|
||||
COPY packages/rate_limit/package.json ./packages/rate_limit/
|
||||
COPY packages/s3/package.json ./packages/s3/
|
||||
COPY packages/sentry/package.json ./packages/sentry/
|
||||
COPY packages/sms/package.json ./packages/sms/
|
||||
COPY packages/snowflake/package.json ./packages/snowflake/
|
||||
COPY packages/telemetry/package.json ./packages/telemetry/
|
||||
COPY packages/ui/package.json ./packages/ui/
|
||||
COPY packages/validation/package.json ./packages/validation/
|
||||
COPY packages/virus_scan/package.json ./packages/virus_scan/
|
||||
COPY packages/worker/package.json ./packages/worker/
|
||||
COPY packages/http_client/package.json ./packages/http_client/
|
||||
COPY packages/schema/package.json ./packages/schema/
|
||||
COPY fluxer_server/package.json ./fluxer_server/
|
||||
COPY fluxer_app/package.json ./fluxer_app/
|
||||
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
RUN pnpm approve-builds msgpackr-extract@3.0.3 @parcel/watcher@2.5.6
|
||||
|
||||
RUN pnpm rebuild msgpackr-extract @parcel/watcher
|
||||
|
||||
FROM deps AS build
|
||||
|
||||
COPY tsconfigs /usr/src/app/tsconfigs
|
||||
|
||||
COPY packages/ ./packages/
|
||||
RUN pnpm --filter @fluxer/config generate
|
||||
COPY fluxer_server/ ./fluxer_server/
|
||||
|
||||
RUN pnpm --filter @fluxer/marketing build:css
|
||||
|
||||
COPY fluxer_media_proxy/data/model.onnx ./fluxer_media_proxy/data/model.onnx
|
||||
|
||||
RUN cd fluxer_server && pnpm typecheck
|
||||
|
||||
FROM erlang:28-slim AS gateway-build
|
||||
|
||||
ARG LOGGER_LEVEL=info
|
||||
|
||||
WORKDIR /usr/src/app/gateway
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
git \
|
||||
curl \
|
||||
make \
|
||||
gcc \
|
||||
g++ \
|
||||
libc6-dev \
|
||||
ca-certificates \
|
||||
gettext-base \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN curl -fsSL https://github.com/erlang/rebar3/releases/download/3.24.0/rebar3 -o /usr/local/bin/rebar3 && \
|
||||
chmod +x /usr/local/bin/rebar3
|
||||
|
||||
COPY fluxer_gateway/rebar.config fluxer_gateway/rebar.lock* ./
|
||||
RUN rebar3 compile --deps_only
|
||||
|
||||
COPY fluxer_gateway/. ./fluxer_gateway
|
||||
RUN LOGGER_LEVEL=${LOGGER_LEVEL} envsubst '${LOGGER_LEVEL}' < fluxer_gateway/config/vm.args.template > fluxer_gateway/config/vm.args && \
|
||||
LOGGER_LEVEL=${LOGGER_LEVEL} envsubst '${LOGGER_LEVEL}' < fluxer_gateway/config/sys.config.template > fluxer_gateway/config/sys.config && \
|
||||
(cd fluxer_gateway && rebar3 as prod release)
|
||||
|
||||
FROM deps AS app-build
|
||||
|
||||
COPY tsconfigs /usr/src/app/tsconfigs
|
||||
|
||||
COPY packages/ ./packages/
|
||||
COPY fluxer_app/ ./fluxer_app/
|
||||
|
||||
RUN cd fluxer_app && pnpm build
|
||||
|
||||
FROM node:24-trixie-slim AS production
|
||||
|
||||
ARG BUILD_SHA
|
||||
ARG BUILD_NUMBER
|
||||
ARG BUILD_TIMESTAMP
|
||||
ARG RELEASE_CHANNEL
|
||||
|
||||
LABEL org.opencontainers.image.title="Fluxer Server"
|
||||
LABEL org.opencontainers.image.description="Unified Fluxer server for self-hosting - combines all backend services into a single deployable container"
|
||||
LABEL org.opencontainers.image.vendor="Fluxer Contributors"
|
||||
LABEL org.opencontainers.image.licenses="AGPL-3.0-or-later"
|
||||
LABEL org.opencontainers.image.source="https://github.com/fluxerapp/fluxer"
|
||||
LABEL org.opencontainers.image.documentation="https://docs.fluxer.app"
|
||||
LABEL org.opencontainers.image.revision="${BUILD_SHA}"
|
||||
LABEL org.opencontainers.image.version="${BUILD_NUMBER}"
|
||||
LABEL org.opencontainers.image.created="${BUILD_TIMESTAMP}"
|
||||
|
||||
WORKDIR /usr/src/app
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
curl \
|
||||
ffmpeg \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
|
||||
RUN corepack enable && corepack prepare pnpm@10.26.0 --activate
|
||||
|
||||
COPY --from=build /usr/src/app/node_modules ./node_modules
|
||||
COPY --from=build /usr/src/app/packages ./packages
|
||||
COPY --from=build /usr/src/app/fluxer_server ./fluxer_server
|
||||
COPY --from=build /usr/src/app/tsconfigs ./tsconfigs
|
||||
COPY --from=build /usr/src/app/pnpm-workspace.yaml ./
|
||||
COPY --from=build /usr/src/app/package.json ./
|
||||
COPY --from=gateway-build /usr/src/app/gateway/fluxer_gateway/_build/prod/rel/fluxer_gateway /opt/fluxer_gateway
|
||||
COPY --from=app-build /usr/src/app/fluxer_app/dist /usr/src/app/assets
|
||||
|
||||
RUN mkdir -p /usr/src/app/data/storage && \
|
||||
mkdir -p /usr/src/app/data/db && \
|
||||
mkdir -p /opt/data && \
|
||||
mkdir -p /data/s3 && \
|
||||
mkdir -p /data/sqlite && \
|
||||
mkdir -p /data/queue && \
|
||||
mkdir -p /var/log/fluxer && \
|
||||
chown -R root:root /usr/src/app/data && \
|
||||
chown -R root:root /opt/data && \
|
||||
chown -R root:root /data
|
||||
|
||||
ARG INCLUDE_NSFW_ML=false
|
||||
|
||||
RUN --mount=type=bind,from=build,source=/usr/src/app/fluxer_media_proxy/data/model.onnx,target=/tmp/model.onnx \
|
||||
if [ "$INCLUDE_NSFW_ML" = "true" ]; then \
|
||||
echo "Including NSFW detection model..."; \
|
||||
cp /tmp/model.onnx /opt/data/model.onnx; \
|
||||
else \
|
||||
echo "Skipping NSFW detection model (INCLUDE_NSFW_ML=$INCLUDE_NSFW_ML)"; \
|
||||
fi
|
||||
|
||||
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
|
||||
ENV NODE_ENV=production
|
||||
|
||||
ENV FLUXER_SERVER_HOST=0.0.0.0
|
||||
ENV FLUXER_SERVER_PORT=8080
|
||||
ENV FLUXER_GATEWAY_HOST=127.0.0.1
|
||||
ENV FLUXER_GATEWAY_PORT=8082
|
||||
ENV DATABASE_BACKEND=sqlite
|
||||
ENV SQLITE_PATH=/usr/src/app/data/db/fluxer.db
|
||||
ENV STORAGE_ROOT=/usr/src/app/data/storage
|
||||
ENV SEARCH_BACKEND=sqlite
|
||||
ENV FLUXER_SERVER_STATIC_DIR=/usr/src/app/assets
|
||||
|
||||
ENV BUILD_SHA=${BUILD_SHA}
|
||||
ENV BUILD_NUMBER=${BUILD_NUMBER}
|
||||
ENV BUILD_TIMESTAMP=${BUILD_TIMESTAMP}
|
||||
ENV RELEASE_CHANNEL=${RELEASE_CHANNEL}
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=10s --retries=3 \
|
||||
CMD curl -f http://localhost:8080/_health || exit 1
|
||||
|
||||
ENTRYPOINT ["pnpm", "start"]
|
||||
45
fluxer_server/Dockerfile.dev
Normal file
45
fluxer_server/Dockerfile.dev
Normal file
@@ -0,0 +1,45 @@
|
||||
FROM erlang:28-slim AS gateway-build
|
||||
|
||||
ARG LOGGER_LEVEL=info
|
||||
|
||||
WORKDIR /usr/src/app/gateway
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
git \
|
||||
curl \
|
||||
make \
|
||||
gcc \
|
||||
g++ \
|
||||
libc6-dev \
|
||||
ca-certificates \
|
||||
gettext-base \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN curl -fsSL https://github.com/erlang/rebar3/releases/download/3.24.0/rebar3 -o /usr/local/bin/rebar3 && \
|
||||
chmod +x /usr/local/bin/rebar3
|
||||
|
||||
COPY fluxer_gateway/rebar.config fluxer_gateway/rebar.lock* ./
|
||||
RUN rebar3 compile --deps_only
|
||||
|
||||
COPY fluxer_gateway/. ./fluxer_gateway
|
||||
RUN LOGGER_LEVEL=${LOGGER_LEVEL} envsubst '${LOGGER_LEVEL}' < fluxer_gateway/config/vm.args.template > fluxer_gateway/config/vm.args && \
|
||||
LOGGER_LEVEL=${LOGGER_LEVEL} envsubst '${LOGGER_LEVEL}' < fluxer_gateway/config/sys.config.template > fluxer_gateway/config/sys.config && \
|
||||
(cd fluxer_gateway && rebar3 as prod release)
|
||||
|
||||
FROM node:24-trixie-slim
|
||||
|
||||
WORKDIR /workspace
|
||||
|
||||
RUN corepack enable && corepack prepare pnpm@10.26.0 --activate
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
curl \
|
||||
python3 \
|
||||
make \
|
||||
g++ \
|
||||
ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY --from=gateway-build /usr/src/app/gateway/fluxer_gateway/_build/prod/rel/fluxer_gateway /opt/fluxer_gateway
|
||||
|
||||
RUN mkdir -p /opt/data
|
||||
42
fluxer_server/package.json
Normal file
42
fluxer_server/package.json
Normal file
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"name": "fluxer_server",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"description": "Combined umbrella service for self-hosters bringing together all TypeScript backend services",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "tsx watch --clear-screen=false src/startServer.tsx",
|
||||
"start": "tsx src/startServer.tsx",
|
||||
"typecheck": "tsgo --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fluxer/admin": "workspace:*",
|
||||
"@fluxer/api": "workspace:*",
|
||||
"@fluxer/app_proxy": "workspace:*",
|
||||
"@fluxer/config": "workspace:*",
|
||||
"@fluxer/constants": "workspace:*",
|
||||
"@fluxer/errors": "workspace:*",
|
||||
"@fluxer/hono": "workspace:*",
|
||||
"@fluxer/hono_types": "workspace:*",
|
||||
"@fluxer/initialization": "workspace:*",
|
||||
"@fluxer/ip_utils": "workspace:*",
|
||||
"@fluxer/kv_client": "workspace:*",
|
||||
"@fluxer/logger": "workspace:*",
|
||||
"@fluxer/media_proxy": "workspace:*",
|
||||
"@fluxer/queue": "workspace:*",
|
||||
"@fluxer/s3": "workspace:*",
|
||||
"@fluxer/sentry": "workspace:*",
|
||||
"@fluxer/worker": "workspace:*",
|
||||
"hono": "catalog:",
|
||||
"tsx": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "catalog:",
|
||||
"@typescript/native-preview": "catalog:"
|
||||
},
|
||||
"packageManager": "pnpm@10.29.3",
|
||||
"engines": {
|
||||
"node": ">=24.0.0"
|
||||
}
|
||||
}
|
||||
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,
|
||||
};
|
||||
}
|
||||
10
fluxer_server/tsconfig.json
Normal file
10
fluxer_server/tsconfig.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "../tsconfigs/hono-service.json",
|
||||
"compilerOptions": {
|
||||
"paths": {
|
||||
"@app/*": ["./src/*"],
|
||||
"@fluxer/*": ["./../packages/*", "./../packages/*/src/index.tsx"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*", "../packages/marketing/src/HonoJsx.d.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user