added circuit breaker

This commit is contained in:
Dmitri 2026-07-03 15:59:42 +02:00
parent 57a705f3ba
commit 9e6e0e0dbe
18 changed files with 747 additions and 177 deletions

View File

@ -5,14 +5,9 @@ import './load-env.ts';
import { validateEnv } from './utils/env-validation.ts'; import { validateEnv } from './utils/env-validation.ts';
import type { BackendConfig } from './types/index.ts'; import type { BackendConfig } from './types/index.ts';
validateEnv(); const env = validateEnv();
function envNumber(name: string, fallback: number): number { const isProduction = env.NODE_ENV === 'production';
const parsed = Number.parseInt(process.env[name] || '', 10);
return Number.isFinite(parsed) ? parsed : fallback;
}
const isProduction = process.env.NODE_ENV === 'production';
const remote = ''; const remote = '';
const port = isProduction ? '' : '8080'; const port = isProduction ? '' : '8080';
const hostUI = isProduction ? '' : 'http://localhost'; const hostUI = isProduction ? '' : 'http://localhost';
@ -21,36 +16,77 @@ const swaggerUI = isProduction ? '' : 'http://localhost';
const swaggerPort = isProduction ? '' : ':8080'; const swaggerPort = isProduction ? '' : ':8080';
const host = isProduction ? remote : 'http://localhost'; const host = isProduction ? remote : 'http://localhost';
const getBaseUrl = (url: string): string => {
if (!url) return '';
return url.endsWith('/api') ? url.slice(0, -4) : url;
};
const getServerPort = (): number => {
if (process.env.PORT !== undefined) {
return env.PORT;
}
return env.NODE_ENV === 'dev_stage' ? 3000 : 8080;
};
const serverPort = getServerPort();
const swaggerServerUrl =
getBaseUrl(env.NEXT_PUBLIC_BACK_API) || `${swaggerUI}${swaggerPort}`;
const config: BackendConfig = { const config: BackendConfig = {
gcloud: { gcloud: {
bucket: 'fldemo-files', bucket: 'fldemo-files',
hash: 'afeefb9d49f5b7977577876b99532ac7', hash: 'afeefb9d49f5b7977577876b99532ac7',
projectId: env.GC_PROJECT_ID,
clientEmail: env.GC_CLIENT_EMAIL,
privateKey: env.GC_PRIVATE_KEY,
},
fileStorage: {
provider: env.FILE_STORAGE_PROVIDER,
}, },
s3: { s3: {
bucket: process.env.AWS_S3_BUCKET || '', bucket: env.AWS_S3_BUCKET,
region: process.env.AWS_S3_REGION || 'us-east-1', region: env.AWS_S3_REGION,
accessKeyId: process.env.AWS_ACCESS_KEY_ID || '', accessKeyId: env.AWS_ACCESS_KEY_ID,
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY || '', secretAccessKey: env.AWS_SECRET_ACCESS_KEY,
prefix: process.env.AWS_S3_PREFIX || 'afeefb9d49f5b7977577876b99532ac7', prefix: env.AWS_S3_PREFIX,
connectionTimeout: envNumber('AWS_S3_CONNECTION_TIMEOUT', 5000), connectionTimeout: env.AWS_S3_CONNECTION_TIMEOUT,
requestTimeout: envNumber('AWS_S3_REQUEST_TIMEOUT', 30000), requestTimeout: env.AWS_S3_REQUEST_TIMEOUT,
maxAttempts: envNumber('AWS_S3_MAX_ATTEMPTS', 3), maxAttempts: env.AWS_S3_MAX_ATTEMPTS,
maxSockets: envNumber('AWS_S3_MAX_SOCKETS', 50), maxSockets: env.AWS_S3_MAX_SOCKETS,
keepAlive: process.env.AWS_S3_KEEP_ALIVE !== 'false', keepAlive: env.AWS_S3_KEEP_ALIVE !== 'false',
presignExpirySeconds: envNumber('AWS_S3_PRESIGN_EXPIRY', 3600), presignExpirySeconds: env.AWS_S3_PRESIGN_EXPIRY,
},
resilience: {
ffmpeg: {
reverseTimeoutMs: env.FFMPEG_REVERSE_TIMEOUT_MS,
ffprobeTimeoutMs: env.FFPROBE_TIMEOUT_MS,
breaker: {
failureThreshold: env.FFMPEG_BREAKER_FAILURE_THRESHOLD,
cooldownMs: env.FFMPEG_BREAKER_COOLDOWN_MS,
successThreshold: env.FFMPEG_BREAKER_SUCCESS_THRESHOLD,
},
},
fileStorage: {
breaker: {
failureThreshold: env.FILE_STORAGE_BREAKER_FAILURE_THRESHOLD,
cooldownMs: env.FILE_STORAGE_BREAKER_COOLDOWN_MS,
successThreshold: env.FILE_STORAGE_BREAKER_SUCCESS_THRESHOLD,
},
},
}, },
bcrypt: { bcrypt: {
saltRounds: 12, saltRounds: 12,
}, },
admin_pass: process.env.ADMIN_PASS || '88dbeaf8', admin_pass: env.ADMIN_PASS,
user_pass: process.env.USER_PASS || 'c3baadeda5c6', user_pass: env.USER_PASS,
admin_email: process.env.ADMIN_EMAIL || 'admin@flatlogic.com', admin_email: env.ADMIN_EMAIL,
providers: { providers: {
LOCAL: 'local', LOCAL: 'local',
GOOGLE: 'google', GOOGLE: 'google',
MICROSOFT: 'microsoft', MICROSOFT: 'microsoft',
}, },
secret_key: process.env.SECRET_KEY || '88dbeaf8-e906-405e-9e41-c3baadeda5c6', secret_key: env.SECRET_KEY,
remote, remote,
port, port,
host, host,
@ -60,33 +96,38 @@ const config: BackendConfig = {
swaggerUI, swaggerUI,
swaggerPort, swaggerPort,
google: { google: {
clientId: process.env.GOOGLE_CLIENT_ID || '', clientId: env.GOOGLE_CLIENT_ID,
clientSecret: process.env.GOOGLE_CLIENT_SECRET || '', clientSecret: env.GOOGLE_CLIENT_SECRET,
}, },
microsoft: { microsoft: {
clientId: process.env.MS_CLIENT_ID || '', clientId: env.MS_CLIENT_ID,
clientSecret: process.env.MS_CLIENT_SECRET || '', clientSecret: env.MS_CLIENT_SECRET,
}, },
uploadDir: os.tmpdir(), uploadDir: os.tmpdir(),
s3CacheDir: process.env.S3_CACHE_DIR || path.join(os.tmpdir(), 's3-cache'), s3CacheDir: env.S3_CACHE_DIR || path.join(os.tmpdir(), 's3-cache'),
s3CacheEnabled: process.env.S3_CACHE_ENABLED !== 'false', s3CacheEnabled: env.S3_CACHE_ENABLED !== 'false',
s3CacheMaxAge: envNumber('S3_CACHE_MAX_AGE', 86400), s3CacheMaxAge: env.S3_CACHE_MAX_AGE,
email: { email: {
from: 'Tour Builder Platform <app@flatlogic.app>', from: 'Tour Builder Platform <app@flatlogic.app>',
host: 'email-smtp.us-east-1.amazonaws.com', host: 'email-smtp.us-east-1.amazonaws.com',
port: 587, port: 587,
auth: { auth: {
user: process.env.EMAIL_USER || '', user: env.EMAIL_USER,
pass: process.env.EMAIL_PASS || '', pass: env.EMAIL_PASS,
}, },
tls: { tls: {
rejectUnauthorized: process.env.EMAIL_TLS_REJECT_UNAUTHORIZED !== 'false', rejectUnauthorized: env.EMAIL_TLS_REJECT_UNAUTHORIZED !== 'false',
}, },
}, },
roles: { roles: {
admin: 'Administrator', admin: 'Administrator',
user: 'Analytics Viewer', user: 'Analytics Viewer',
}, },
server: {
env: env.NODE_ENV,
port: serverPort,
swaggerServerUrl,
},
apiUrl: `${host}${port ? `:${port}` : ''}/api`, apiUrl: `${host}${port ? `:${port}` : ''}/api`,
swaggerUrl: `${swaggerUI}${swaggerPort}`, swaggerUrl: `${swaggerUI}${swaggerPort}`,
uiUrl: `${hostUI}${portUI ? `:${portUI}` : ''}/#`, uiUrl: `${hostUI}${portUI ? `:${portUI}` : ''}/#`,

View File

@ -55,7 +55,7 @@ const commonErrorHandler: ErrorRequestHandler = (
if ( if (
statusCode !== undefined && statusCode !== undefined &&
[400, 401, 403, 404, 409, 422].includes(statusCode) [400, 401, 403, 404, 409, 422, 503].includes(statusCode)
) { ) {
return res.status(statusCode).send(error.message); return res.status(statusCode).send(error.message);
} }

View File

@ -145,13 +145,8 @@ const searchRoutes = getExpressRouter('search', searchRoutesModule);
const tourPagesRoutes = getExpressRouter('tour_pages', tourPagesRoutesModule); const tourPagesRoutes = getExpressRouter('tour_pages', tourPagesRoutesModule);
const usersRoutes = getExpressRouter('users', usersRoutesModule); const usersRoutes = getExpressRouter('users', usersRoutesModule);
const getBaseUrl = (url: string | undefined): string => {
if (!url) return '';
return url.endsWith('/api') ? url.slice(0, -4) : url;
};
const specs = createOpenApiDocument({ const specs = createOpenApiDocument({
serverUrl: getBaseUrl(process.env.NEXT_PUBLIC_BACK_API) || config.swaggerUrl, serverUrl: config.server.swaggerServerUrl,
}); });
app.use( app.use(
@ -271,7 +266,7 @@ app.get('/api/health', wrapAsync(async (_req, res) => {
status: 'ok', status: 'ok',
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
uptime: process.uptime(), uptime: process.uptime(),
environment: process.env.NODE_ENV || 'development', environment: config.server.env,
}; };
try { try {
@ -370,39 +365,52 @@ if (fs.existsSync(publicDir)) {
} }
// Generic error handler // Generic error handler
const getNumericErrorProperty = (
error: unknown,
property: 'code' | 'status',
): number | null => {
if (typeof error !== 'object' || error === null) {
return null;
}
const descriptor = Object.getOwnPropertyDescriptor(error, property);
return typeof descriptor?.value === 'number' ? descriptor.value : null;
};
const appErrorHandler: AppErrorHandler = (err, req, res, _next) => { const appErrorHandler: AppErrorHandler = (err, req, res, _next) => {
if (!res.headersSent) { if (!res.headersSent) {
const requestLog = getRequestLogger(req) ?? logger; const requestLog = getRequestLogger(req) ?? logger;
const statusCode =
getNumericErrorProperty(err, 'code') ??
getNumericErrorProperty(err, 'status') ??
500;
const safeStatusCode = statusCode === 503 ? 503 : 500;
requestLog.error( requestLog.error(
{ err, url: req.url, method: req.method }, { err, url: req.url, method: req.method },
'Express error middleware caught unhandled error', 'Express error middleware caught unhandled error',
); );
res.status(500).json({ message: 'Internal server error' }); res.status(safeStatusCode).json({
message:
safeStatusCode === 503
? 'Service temporarily unavailable'
: 'Internal server error',
});
} }
}; };
app.use(appErrorHandler); app.use(appErrorHandler);
function getServerPort(): number { const PORT = config.server.port;
const configuredPort = Number.parseInt(process.env.PORT || '', 10);
if (Number.isFinite(configuredPort)) {
return configuredPort;
}
return process.env.NODE_ENV === 'dev_stage' ? 3000 : 8080;
}
const PORT = getServerPort();
const server = app.listen(PORT, () => { const server = app.listen(PORT, () => {
logger.info( logger.info(
{ port: PORT, env: process.env.NODE_ENV || 'development' }, { port: PORT, env: config.server.env },
'Server started', 'Server started',
); );
}); });
server.on('error', (err: NodeJS.ErrnoException) => { server.on('error', (err: NodeJS.ErrnoException) => {
logger.error( logger.error(
{ err, port: PORT, env: process.env.NODE_ENV || 'development' }, { err, port: PORT, env: config.server.env },
'Server failed to start', 'Server failed to start',
); );
exitAfterLogging(); exitAfterLogging();

View File

@ -12,6 +12,7 @@
import type { RequestHandler } from 'express'; import type { RequestHandler } from 'express';
import config from '../config.ts';
import type { RateLimitEntry, RateLimiterOptions } from '../types/index.ts'; import type { RateLimitEntry, RateLimiterOptions } from '../types/index.ts';
import { logger } from '../utils/logger.ts'; import { logger } from '../utils/logger.ts';
import { getCurrentUser } from '../utils/request-context.ts'; import { getCurrentUser } from '../utils/request-context.ts';
@ -72,7 +73,7 @@ const createRateLimiter = (options: RateLimiterOptions = {}): RequestHandler =>
// Skip in development when accessing from localhost (optional) // Skip in development when accessing from localhost (optional)
if ( if (
process.env.NODE_ENV === 'development' && config.server.env === 'development' &&
(req.ip === '127.0.0.1' || req.ip === '::1') (req.ip === '127.0.0.1' || req.ip === '::1')
) { ) {
return next(); return next();

View File

@ -53,6 +53,7 @@ import type {
UploadSessionInitRequest, UploadSessionInitRequest,
UploadSessionRequest, UploadSessionRequest,
} from '../types/index.ts'; } from '../types/index.ts';
import { CircuitBreaker } from '../utils/circuit-breaker.ts';
import { logger } from '../utils/logger.ts'; import { logger } from '../utils/logger.ts';
import LocalStorageProvider from './file/LocalStorageProvider.ts'; import LocalStorageProvider from './file/LocalStorageProvider.ts';
import S3StorageProvider from './file/S3StorageProvider.ts'; import S3StorageProvider from './file/S3StorageProvider.ts';
@ -232,10 +233,19 @@ let gcloudBucket: GCloudBucketState['bucket'] | null = null;
let gcloudHash: string | null = null; let gcloudHash: string | null = null;
let uploadSessionManager: UploadSessionManager | null = null; let uploadSessionManager: UploadSessionManager | null = null;
const externalStorageBreaker = new CircuitBreaker({
name: 'external-file-storage',
failureThreshold: config.resilience.fileStorage.breaker.failureThreshold,
cooldownMs: config.resilience.fileStorage.breaker.cooldownMs,
successThreshold: config.resilience.fileStorage.breaker.successThreshold,
shouldRecordFailure: (error) =>
getFileStorageProvider() === 's3'
? S3StorageProvider.isRetryableError(error)
: true,
});
const getFileStorageProvider = (): FileStorageProviderName => { const getFileStorageProvider = (): FileStorageProviderName => {
const provider = (process.env.FILE_STORAGE_PROVIDER || '') const provider = config.fileStorage.provider;
.trim()
.toLowerCase();
if (provider === 's3' || provider === 'gcloud' || provider === 'local') { if (provider === 's3' || provider === 'gcloud' || provider === 'local') {
return provider; return provider;
} }
@ -249,9 +259,9 @@ const getFileStorageProvider = (): FileStorageProviderName => {
if (hasS3) return 's3'; if (hasS3) return 's3';
const hasGCloud = Boolean( const hasGCloud = Boolean(
process.env.GC_PROJECT_ID && config.gcloud.projectId &&
process.env.GC_CLIENT_EMAIL && config.gcloud.clientEmail &&
process.env.GC_PRIVATE_KEY && config.gcloud.privateKey &&
config.gcloud.bucket && config.gcloud.bucket &&
config.gcloud.hash, config.gcloud.hash,
); );
@ -260,6 +270,21 @@ const getFileStorageProvider = (): FileStorageProviderName => {
return 'local'; return 'local';
}; };
const executeStorageOperation = async <TResult>(
provider: FileStorageProviderName,
operationName: string,
operation: () => Promise<TResult>,
): Promise<TResult> => {
if (provider === 'local') {
return operation();
}
return externalStorageBreaker.execute(
`${provider}.${operationName}`,
operation,
);
};
const getS3Provider = (): S3StorageProvider => { const getS3Provider = (): S3StorageProvider => {
if (!s3Provider) { if (!s3Provider) {
s3Provider = new S3StorageProvider({ s3Provider = new S3StorageProvider({
@ -300,16 +325,11 @@ const getLocalProvider = (): LocalStorageProvider => {
const getGCloudBucket = (): GCloudBucketState => { const getGCloudBucket = (): GCloudBucketState => {
if (!gcloudBucket) { if (!gcloudBucket) {
const privateKey = (process.env.GC_PRIVATE_KEY || '').replace( const privateKey = config.gcloud.privateKey.replace(/\\\n/g, '\n');
/\\\n/g,
'\n',
);
const clientEmail = process.env.GC_CLIENT_EMAIL || '';
const projectId = process.env.GC_PROJECT_ID || '';
const storage = new Storage({ const storage = new Storage({
projectId, projectId: config.gcloud.projectId,
credentials: { credentials: {
client_email: clientEmail, client_email: config.gcloud.clientEmail,
private_key: privateKey, private_key: privateKey,
}, },
}); });
@ -918,18 +938,20 @@ const deleteFile = async (
const provider = getFileStorageProvider(); const provider = getFileStorageProvider();
try { try {
if (provider === 's3') { await executeStorageOperation(provider, 'deleteFile', async () => {
const s3 = getS3Provider(); if (provider === 's3') {
await s3.delete(privateUrl); const s3 = getS3Provider();
} else if (provider === 'gcloud') { await s3.delete(privateUrl);
const { bucket, hash } = getGCloudBucket(); } else if (provider === 'gcloud') {
const file = bucket.file(`${hash}/${privateUrl}`); const { bucket, hash } = getGCloudBucket();
const [exists] = await file.exists(); const file = bucket.file(`${hash}/${privateUrl}`);
if (exists) await file.delete(); const [exists] = await file.exists();
} else { if (exists) await file.delete();
const local = getLocalProvider(); } else {
await local.delete(privateUrl); const local = getLocalProvider();
} await local.delete(privateUrl);
}
});
logger.debug({ provider, privateUrl }, 'File deleted successfully'); logger.debug({ provider, privateUrl }, 'File deleted successfully');
return { success: true }; return { success: true };
@ -956,34 +978,36 @@ const deleteFile = async (
const downloadToBuffer = async (privateUrl: string): Promise<Buffer> => { const downloadToBuffer = async (privateUrl: string): Promise<Buffer> => {
const provider = getFileStorageProvider(); const provider = getFileStorageProvider();
if (provider === 's3') { return executeStorageOperation(provider, 'downloadToBuffer', async () => {
const s3 = getS3Provider(); if (provider === 's3') {
const result = await s3.download(privateUrl); const s3 = getS3Provider();
const result = await s3.download(privateUrl);
// Convert stream to buffer // Convert stream to buffer
if (hasByteArrayTransformer(result.body)) { if (hasByteArrayTransformer(result.body)) {
const bytes = await result.body.transformToByteArray(); const bytes = await result.body.transformToByteArray();
return Buffer.from(bytes); return Buffer.from(bytes);
} }
// Handle readable stream // Handle readable stream
if (!isReadableStream(result.body)) { if (!isReadableStream(result.body)) {
return Buffer.from([]); return Buffer.from([]);
} }
const chunks: Buffer[] = []; const chunks: Buffer[] = [];
for await (const chunk of result.body) { for await (const chunk of result.body) {
chunks.push(toBufferChunk(chunk)); chunks.push(toBufferChunk(chunk));
}
return Buffer.concat(chunks);
} else if (provider === 'gcloud') {
const { bucket, hash } = getGCloudBucket();
const file = bucket.file(`${hash}/${privateUrl}`);
const [data] = await file.download();
return data;
} else {
// Local provider - read directly from filesystem
return fs.readFileSync(path.join(config.uploadDir, privateUrl));
} }
return Buffer.concat(chunks); });
} else if (provider === 'gcloud') {
const { bucket, hash } = getGCloudBucket();
const file = bucket.file(`${hash}/${privateUrl}`);
const [data] = await file.download();
return data;
} else {
// Local provider - read directly from filesystem
return fs.readFileSync(path.join(config.uploadDir, privateUrl));
}
}; };
/** /**
@ -1008,28 +1032,30 @@ const downloadToTempFile = async (
}; };
try { try {
if (provider === 's3') { await executeStorageOperation(provider, 'downloadToTempFile', async () => {
const s3 = getS3Provider(); if (provider === 's3') {
const result = await s3.download(privateUrl); const s3 = getS3Provider();
if (!result.body) { const result = await s3.download(privateUrl);
throw new Error(`Empty S3 response body for ${privateUrl}`); if (!result.body) {
} throw new Error(`Empty S3 response body for ${privateUrl}`);
if (isReadableStream(result.body)) { }
await pipeline(result.body, fs.createWriteStream(filePath)); if (isReadableStream(result.body)) {
} else if (hasByteArrayTransformer(result.body)) { await pipeline(result.body, fs.createWriteStream(filePath));
const bytes = await result.body.transformToByteArray(); } else if (hasByteArrayTransformer(result.body)) {
await fs.promises.writeFile(filePath, Buffer.from(bytes)); const bytes = await result.body.transformToByteArray();
await fs.promises.writeFile(filePath, Buffer.from(bytes));
} else {
throw new Error(`Unsupported S3 response body for ${privateUrl}`);
}
} else if (provider === 'gcloud') {
const { bucket, hash } = getGCloudBucket();
const file = bucket.file(`${hash}/${privateUrl}`);
await file.download({ destination: filePath });
} else { } else {
throw new Error(`Unsupported S3 response body for ${privateUrl}`); const local = getLocalProvider();
await fs.promises.copyFile(local.buildPath(privateUrl), filePath);
} }
} else if (provider === 'gcloud') { });
const { bucket, hash } = getGCloudBucket();
const file = bucket.file(`${hash}/${privateUrl}`);
await file.download({ destination: filePath });
} else {
const local = getLocalProvider();
await fs.promises.copyFile(local.buildPath(privateUrl), filePath);
}
return { filePath, cleanup }; return { filePath, cleanup };
} catch (error) { } catch (error) {
@ -1054,30 +1080,32 @@ const uploadBuffer = async (
const provider = getFileStorageProvider(); const provider = getFileStorageProvider();
const { contentType = 'application/octet-stream' } = options; const { contentType = 'application/octet-stream' } = options;
if (provider === 's3') { return executeStorageOperation(provider, 'uploadBuffer', async () => {
const s3 = getS3Provider(); if (provider === 's3') {
const result = await s3.upload(privateUrl, buffer, { contentType }); const s3 = getS3Provider();
return { url: getStorageUploadUrl(result.url) }; const result = await s3.upload(privateUrl, buffer, { contentType });
} else if (provider === 'gcloud') { return { url: getStorageUploadUrl(result.url) };
const { bucket, hash } = getGCloudBucket(); } else if (provider === 'gcloud') {
const filePath = `${hash}/${privateUrl}`; const { bucket, hash } = getGCloudBucket();
const blob = bucket.file(filePath); const filePath = `${hash}/${privateUrl}`;
await new Promise<void>((resolve, reject) => { const blob = bucket.file(filePath);
const blobStream = blob.createWriteStream({ resumable: false }); await new Promise<void>((resolve, reject) => {
blobStream.on('error', reject); const blobStream = blob.createWriteStream({ resumable: false });
blobStream.on('finish', resolve); blobStream.on('error', reject);
blobStream.end(buffer); blobStream.on('finish', resolve);
}); blobStream.end(buffer);
return { });
url: `https://storage.googleapis.com/${bucket.name}/${blob.name}`, return {
}; url: `https://storage.googleapis.com/${bucket.name}/${blob.name}`,
} else { };
const local = getLocalProvider(); } else {
await local.upload(privateUrl, buffer); const local = getLocalProvider();
return { await local.upload(privateUrl, buffer);
url: `/api/file/download?privateUrl=${encodeURIComponent(privateUrl)}`, return {
}; url: `/api/file/download?privateUrl=${encodeURIComponent(privateUrl)}`,
} };
}
});
}; };
// ============================================================================ // ============================================================================

View File

@ -13,7 +13,9 @@ import path from 'path';
import ffmpeg from 'fluent-ffmpeg'; import ffmpeg from 'fluent-ffmpeg';
import { path as ffprobePath } from 'ffprobe-static'; import { path as ffprobePath } from 'ffprobe-static';
import config from '../config.ts';
import type { FfmpegJobRunner, MediaMetadata } from '../types/index.ts'; import type { FfmpegJobRunner, MediaMetadata } from '../types/index.ts';
import { CircuitBreaker } from '../utils/circuit-breaker.ts';
import { logger } from '../utils/logger.ts'; import { logger } from '../utils/logger.ts';
const loadCommonJsModule = createRequire(import.meta.url); const loadCommonJsModule = createRequire(import.meta.url);
@ -29,6 +31,16 @@ let ffmpegQueueTail: Promise<unknown> = Promise.resolve();
let queuedFfmpegJobs = 0; let queuedFfmpegJobs = 0;
let ffmpegJobSequence = 0; let ffmpegJobSequence = 0;
const reverseVideoBreaker = new CircuitBreaker({
name: 'ffmpeg-reverse-video',
failureThreshold: config.resilience.ffmpeg.breaker.failureThreshold,
cooldownMs: config.resilience.ffmpeg.breaker.cooldownMs,
successThreshold: config.resilience.ffmpeg.breaker.successThreshold,
});
const FFMPEG_REVERSE_TIMEOUT_MS = config.resilience.ffmpeg.reverseTimeoutMs;
const FFPROBE_TIMEOUT_MS = config.resilience.ffmpeg.ffprobeTimeoutMs;
function parseFrameRate(value: unknown): number | null { function parseFrameRate(value: unknown): number | null {
if (!value) { if (!value) {
return null; return null;
@ -115,7 +127,9 @@ async function reverseVideo(
filename: string, filename: string,
): Promise<Buffer> { ): Promise<Buffer> {
return enqueueFfmpegJob('reverseVideo', () => return enqueueFfmpegJob('reverseVideo', () =>
reverseVideoWithoutQueue(inputBuffer, filename), reverseVideoBreaker.execute('reverseVideo', () =>
reverseVideoWithoutQueue(inputBuffer, filename),
),
); );
} }
@ -131,10 +145,30 @@ async function reverseVideoWithoutQueue(
try { try {
await fs.writeFile(inputPath, inputBuffer); await fs.writeFile(inputPath, inputBuffer);
logger.info({ inputPath, outputPath }, 'Starting video reversal'); let inputMetadata: MediaMetadata | null = null;
try {
inputMetadata = await probeMediaMetadata(inputPath);
} catch (metadataError) {
logger.warn(
{ err: metadataError, inputPath },
'Failed to probe input video metadata before reversal',
);
}
logger.info(
{
inputPath,
outputPath,
inputSizeBytes: inputBuffer.length,
inputMetadata,
timeoutMs: FFMPEG_REVERSE_TIMEOUT_MS,
breakerState: reverseVideoBreaker.getState(),
},
'Starting video reversal',
);
await new Promise<void>((resolve, reject) => { await new Promise<void>((resolve, reject) => {
ffmpeg(inputPath) const command = ffmpeg(inputPath)
.outputOptions([ .outputOptions([
'-vf', '-vf',
'reverse', 'reverse',
@ -161,17 +195,54 @@ async function reverseVideoWithoutQueue(
} }
}) })
.on('end', () => { .on('end', () => {
clearTimeout(timeout);
logger.info({ outputPath }, 'Video reversal complete'); logger.info({ outputPath }, 'Video reversal complete');
resolve(); resolve();
}) })
.on('error', (err, stdout, stderr) => { .on('error', (err, stdout, stderr) => {
clearTimeout(timeout);
logger.error({ err, stdout, stderr }, 'FFmpeg error'); logger.error({ err, stdout, stderr }, 'FFmpeg error');
reject(err); reject(err);
}) });
.run();
const timeout = setTimeout(() => {
const error = new Error(
`FFmpeg reverse video timed out after ${FFMPEG_REVERSE_TIMEOUT_MS}ms`,
);
logger.error(
{ err: error, inputPath, outputPath },
'FFmpeg reverse video timeout',
);
command.kill('SIGKILL');
reject(error);
}, FFMPEG_REVERSE_TIMEOUT_MS);
timeout.unref();
command.run();
}); });
return await fs.readFile(outputPath); const reversedBuffer = await fs.readFile(outputPath);
let outputMetadata: MediaMetadata | null = null;
try {
outputMetadata = await probeMediaMetadata(outputPath);
} catch (metadataError) {
logger.warn(
{ err: metadataError, outputPath },
'Failed to probe output video metadata after reversal',
);
}
logger.info(
{
inputSizeBytes: inputBuffer.length,
outputSizeBytes: reversedBuffer.length,
inputMetadata,
outputMetadata,
},
'Video reversal output ready',
);
return reversedBuffer;
} finally { } finally {
try { try {
await fs.rm(tempDir, { recursive: true, force: true }); await fs.rm(tempDir, { recursive: true, force: true });
@ -183,7 +254,15 @@ async function reverseVideoWithoutQueue(
async function probeMediaMetadata(filePath: string): Promise<MediaMetadata> { async function probeMediaMetadata(filePath: string): Promise<MediaMetadata> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
reject(
new Error(`FFprobe timed out after ${FFPROBE_TIMEOUT_MS}ms: ${filePath}`),
);
}, FFPROBE_TIMEOUT_MS);
timeout.unref();
ffmpeg.ffprobe(filePath, (err: unknown, metadata) => { ffmpeg.ffprobe(filePath, (err: unknown, metadata) => {
clearTimeout(timeout);
if (err) { if (err) {
reject(toError(err)); reject(toError(err));
return; return;

View File

@ -11,6 +11,12 @@ export interface BackendConfig {
gcloud: { gcloud: {
bucket: string; bucket: string;
hash: string; hash: string;
projectId: string;
clientEmail: string;
privateKey: string;
};
fileStorage: {
provider: '' | 's3' | 'gcloud' | 'local';
}; };
s3: { s3: {
bucket: string; bucket: string;
@ -25,6 +31,24 @@ export interface BackendConfig {
keepAlive: boolean; keepAlive: boolean;
presignExpirySeconds: number; presignExpirySeconds: number;
}; };
resilience: {
ffmpeg: {
reverseTimeoutMs: number;
ffprobeTimeoutMs: number;
breaker: {
failureThreshold: number;
cooldownMs: number;
successThreshold: number;
};
};
fileStorage: {
breaker: {
failureThreshold: number;
cooldownMs: number;
successThreshold: number;
};
};
};
secret_key: string; secret_key: string;
admin_pass: string; admin_pass: string;
user_pass: string; user_pass: string;
@ -50,6 +74,11 @@ export interface BackendConfig {
admin: string; admin: string;
user?: string; user?: string;
}; };
server: {
env: string;
port: number;
swaggerServerUrl: string;
};
remote: string; remote: string;
port: string; port: string;
host: string; host: string;

View File

@ -7,6 +7,7 @@ export type NodeEnvironment =
export interface ValidatedEnvironment { export interface ValidatedEnvironment {
NODE_ENV: NodeEnvironment; NODE_ENV: NodeEnvironment;
PORT: number; PORT: number;
NEXT_PUBLIC_BACK_API: string;
DB_HOST: string; DB_HOST: string;
DB_PORT: number; DB_PORT: number;
DB_NAME: string; DB_NAME: string;
@ -25,9 +26,31 @@ export interface ValidatedEnvironment {
AWS_S3_BUCKET: string; AWS_S3_BUCKET: string;
AWS_S3_REGION: string; AWS_S3_REGION: string;
AWS_S3_PREFIX: string; AWS_S3_PREFIX: string;
AWS_S3_CONNECTION_TIMEOUT: number;
AWS_S3_REQUEST_TIMEOUT: number;
AWS_S3_MAX_ATTEMPTS: number;
AWS_S3_MAX_SOCKETS: number;
AWS_S3_KEEP_ALIVE: 'true' | 'false';
AWS_S3_PRESIGN_EXPIRY: number;
FILE_STORAGE_PROVIDER: '' | 's3' | 'gcloud' | 'local';
GC_PROJECT_ID: string;
GC_CLIENT_EMAIL: string;
GC_PRIVATE_KEY: string;
S3_CACHE_DIR: string;
S3_CACHE_ENABLED: 'true' | 'false';
S3_CACHE_MAX_AGE: number;
FFMPEG_REVERSE_TIMEOUT_MS: number;
FFPROBE_TIMEOUT_MS: number;
FFMPEG_BREAKER_FAILURE_THRESHOLD: number;
FFMPEG_BREAKER_COOLDOWN_MS: number;
FFMPEG_BREAKER_SUCCESS_THRESHOLD: number;
FILE_STORAGE_BREAKER_FAILURE_THRESHOLD: number;
FILE_STORAGE_BREAKER_COOLDOWN_MS: number;
FILE_STORAGE_BREAKER_SUCCESS_THRESHOLD: number;
EMAIL_USER: string; EMAIL_USER: string;
EMAIL_PASS: string; EMAIL_PASS: string;
EMAIL_TLS_REJECT_UNAUTHORIZED: 'true' | 'false'; EMAIL_TLS_REJECT_UNAUTHORIZED: 'true' | 'false';
PEXELS_KEY: string; PEXELS_KEY: string;
LOG_LEVEL: 'fatal' | 'error' | 'warn' | 'info' | 'debug' | 'trace'; LOG_LEVEL: 'fatal' | 'error' | 'warn' | 'info' | 'debug' | 'trace';
LOG_PRETTY: 'true' | 'false';
} }

View File

@ -0,0 +1,170 @@
import { logger } from './logger.ts';
type CircuitBreakerState = 'closed' | 'open' | 'half-open';
interface CircuitBreakerOptions {
name: string;
failureThreshold?: number;
cooldownMs?: number;
successThreshold?: number;
shouldRecordFailure?: (error: unknown) => boolean;
}
class CircuitBreakerOpenError extends Error {
code = 503;
status = 503;
constructor(name: string) {
super(`${name} circuit breaker is open`);
this.name = 'CircuitBreakerOpenError';
}
}
class CircuitBreaker {
private readonly name: string;
private readonly failureThreshold: number;
private readonly cooldownMs: number;
private readonly successThreshold: number;
private readonly shouldRecordFailure: (error: unknown) => boolean;
private state: CircuitBreakerState = 'closed';
private failures = 0;
private consecutiveHalfOpenSuccesses = 0;
private openedAt = 0;
private halfOpenProbeInFlight = false;
constructor(options: CircuitBreakerOptions) {
this.name = options.name;
this.failureThreshold = options.failureThreshold ?? 5;
this.cooldownMs = options.cooldownMs ?? 30_000;
this.successThreshold = options.successThreshold ?? 2;
this.shouldRecordFailure = options.shouldRecordFailure ?? (() => true);
}
async execute<TResult>(
operationName: string,
operation: () => Promise<TResult>,
): Promise<TResult> {
this.refreshState();
if (this.state === 'open') {
logger.warn(
{ breaker: this.name, operationName, state: this.state },
'Circuit breaker rejected operation',
);
throw new CircuitBreakerOpenError(this.name);
}
if (this.state === 'half-open') {
if (this.halfOpenProbeInFlight) {
logger.warn(
{ breaker: this.name, operationName, state: this.state },
'Circuit breaker rejected concurrent half-open probe',
);
throw new CircuitBreakerOpenError(this.name);
}
this.halfOpenProbeInFlight = true;
}
try {
const result = await operation();
this.recordSuccess(operationName);
return result;
} catch (error) {
if (this.shouldRecordFailure(error)) {
this.recordFailure(operationName, error);
}
throw error;
} finally {
if (this.state === 'half-open') {
this.halfOpenProbeInFlight = false;
}
}
}
getState(): CircuitBreakerState {
this.refreshState();
return this.state;
}
private refreshState(): void {
if (this.state !== 'open') {
return;
}
if (Date.now() - this.openedAt < this.cooldownMs) {
return;
}
this.state = 'half-open';
this.consecutiveHalfOpenSuccesses = 0;
logger.warn(
{ breaker: this.name, cooldownMs: this.cooldownMs },
'Circuit breaker moved to half-open',
);
}
private recordSuccess(operationName: string): void {
if (this.state === 'half-open') {
this.consecutiveHalfOpenSuccesses += 1;
if (this.consecutiveHalfOpenSuccesses >= this.successThreshold) {
this.close(operationName);
}
return;
}
this.failures = 0;
}
private recordFailure(operationName: string, error: unknown): void {
this.failures += 1;
this.consecutiveHalfOpenSuccesses = 0;
logger.warn(
{
err: error,
breaker: this.name,
operationName,
failures: this.failures,
failureThreshold: this.failureThreshold,
state: this.state,
},
'Circuit breaker recorded operation failure',
);
if (this.state === 'half-open' || this.failures >= this.failureThreshold) {
this.open(operationName);
}
}
private open(operationName: string): void {
this.state = 'open';
this.openedAt = Date.now();
this.halfOpenProbeInFlight = false;
logger.error(
{
breaker: this.name,
operationName,
failures: this.failures,
cooldownMs: this.cooldownMs,
},
'Circuit breaker opened',
);
}
private close(operationName: string): void {
this.state = 'closed';
this.failures = 0;
this.consecutiveHalfOpenSuccesses = 0;
this.halfOpenProbeInFlight = false;
logger.info(
{ breaker: this.name, operationName },
'Circuit breaker closed',
);
}
}
export { CircuitBreaker, CircuitBreakerOpenError };

View File

@ -12,6 +12,7 @@ const envSchema = Joi.object({
.default('development'), .default('development'),
PORT: Joi.number().default(8080), PORT: Joi.number().default(8080),
NEXT_PUBLIC_BACK_API: Joi.string().uri().allow('').default(''),
DB_HOST: Joi.string().default('localhost'), DB_HOST: Joi.string().default('localhost'),
DB_PORT: Joi.number().default(5432), DB_PORT: Joi.number().default(5432),
@ -37,6 +38,52 @@ const envSchema = Joi.object({
AWS_S3_BUCKET: Joi.string().allow('').default(''), AWS_S3_BUCKET: Joi.string().allow('').default(''),
AWS_S3_REGION: Joi.string().default('us-east-1'), AWS_S3_REGION: Joi.string().default('us-east-1'),
AWS_S3_PREFIX: Joi.string().default('afeefb9d49f5b7977577876b99532ac7'), AWS_S3_PREFIX: Joi.string().default('afeefb9d49f5b7977577876b99532ac7'),
AWS_S3_CONNECTION_TIMEOUT: Joi.number().integer().positive().default(5000),
AWS_S3_REQUEST_TIMEOUT: Joi.number().integer().positive().default(30000),
AWS_S3_MAX_ATTEMPTS: Joi.number().integer().positive().default(3),
AWS_S3_MAX_SOCKETS: Joi.number().integer().positive().default(50),
AWS_S3_KEEP_ALIVE: Joi.string().valid('true', 'false').default('true'),
AWS_S3_PRESIGN_EXPIRY: Joi.number().integer().positive().default(3600),
FILE_STORAGE_PROVIDER: Joi.string()
.trim()
.lowercase()
.valid('', 's3', 'gcloud', 'local')
.default(''),
GC_PROJECT_ID: Joi.string().allow('').default(''),
GC_CLIENT_EMAIL: Joi.string().allow('').default(''),
GC_PRIVATE_KEY: Joi.string().allow('').default(''),
S3_CACHE_DIR: Joi.string().allow('').default(''),
S3_CACHE_ENABLED: Joi.string().valid('true', 'false').default('true'),
S3_CACHE_MAX_AGE: Joi.number().integer().positive().default(86400),
FFMPEG_REVERSE_TIMEOUT_MS: Joi.number().integer().positive().default(600000),
FFPROBE_TIMEOUT_MS: Joi.number().integer().positive().default(30000),
FFMPEG_BREAKER_FAILURE_THRESHOLD: Joi.number()
.integer()
.positive()
.default(3),
FFMPEG_BREAKER_COOLDOWN_MS: Joi.number()
.integer()
.positive()
.default(120000),
FFMPEG_BREAKER_SUCCESS_THRESHOLD: Joi.number()
.integer()
.positive()
.default(1),
FILE_STORAGE_BREAKER_FAILURE_THRESHOLD: Joi.number()
.integer()
.positive()
.default(5),
FILE_STORAGE_BREAKER_COOLDOWN_MS: Joi.number()
.integer()
.positive()
.default(30000),
FILE_STORAGE_BREAKER_SUCCESS_THRESHOLD: Joi.number()
.integer()
.positive()
.default(2),
EMAIL_USER: Joi.string().allow('').default(''), EMAIL_USER: Joi.string().allow('').default(''),
EMAIL_PASS: Joi.string().allow('').default(''), EMAIL_PASS: Joi.string().allow('').default(''),
@ -49,6 +96,7 @@ const envSchema = Joi.object({
LOG_LEVEL: Joi.string() LOG_LEVEL: Joi.string()
.valid('fatal', 'error', 'warn', 'info', 'debug', 'trace') .valid('fatal', 'error', 'warn', 'info', 'debug', 'trace')
.default('info'), .default('info'),
LOG_PRETTY: Joi.string().valid('true', 'false').default('false'),
}).unknown(true); }).unknown(true);
function isNodeEnvironment(value: unknown): value is NodeEnvironment { function isNodeEnvironment(value: unknown): value is NodeEnvironment {
@ -103,10 +151,15 @@ function toValidatedEnvironment(
const nodeEnv = values.NODE_ENV; const nodeEnv = values.NODE_ENV;
const tlsRejectUnauthorized = values.EMAIL_TLS_REJECT_UNAUTHORIZED; const tlsRejectUnauthorized = values.EMAIL_TLS_REJECT_UNAUTHORIZED;
const logLevel = values.LOG_LEVEL; const logLevel = values.LOG_LEVEL;
const s3KeepAlive = values.AWS_S3_KEEP_ALIVE;
const fileStorageProvider = values.FILE_STORAGE_PROVIDER;
const s3CacheEnabled = values.S3_CACHE_ENABLED;
const logPretty = values.LOG_PRETTY;
return { return {
NODE_ENV: isNodeEnvironment(nodeEnv) ? nodeEnv : 'development', NODE_ENV: isNodeEnvironment(nodeEnv) ? nodeEnv : 'development',
PORT: readNumber(values, 'PORT', 8080), PORT: readNumber(values, 'PORT', 8080),
NEXT_PUBLIC_BACK_API: readString(values, 'NEXT_PUBLIC_BACK_API', ''),
DB_HOST: readString(values, 'DB_HOST', 'localhost'), DB_HOST: readString(values, 'DB_HOST', 'localhost'),
DB_PORT: readNumber(values, 'DB_PORT', 5432), DB_PORT: readNumber(values, 'DB_PORT', 5432),
DB_NAME: readString(values, 'DB_NAME', 'db_tour_builder_platform'), DB_NAME: readString(values, 'DB_NAME', 'db_tour_builder_platform'),
@ -133,6 +186,74 @@ function toValidatedEnvironment(
'AWS_S3_PREFIX', 'AWS_S3_PREFIX',
'afeefb9d49f5b7977577876b99532ac7', 'afeefb9d49f5b7977577876b99532ac7',
), ),
AWS_S3_CONNECTION_TIMEOUT: readNumber(
values,
'AWS_S3_CONNECTION_TIMEOUT',
5000,
),
AWS_S3_REQUEST_TIMEOUT: readNumber(
values,
'AWS_S3_REQUEST_TIMEOUT',
30000,
),
AWS_S3_MAX_ATTEMPTS: readNumber(values, 'AWS_S3_MAX_ATTEMPTS', 3),
AWS_S3_MAX_SOCKETS: readNumber(values, 'AWS_S3_MAX_SOCKETS', 50),
AWS_S3_KEEP_ALIVE: isEnvBooleanString(s3KeepAlive) ? s3KeepAlive : 'true',
AWS_S3_PRESIGN_EXPIRY: readNumber(
values,
'AWS_S3_PRESIGN_EXPIRY',
3600,
),
FILE_STORAGE_PROVIDER:
fileStorageProvider === 's3' ||
fileStorageProvider === 'gcloud' ||
fileStorageProvider === 'local'
? fileStorageProvider
: '',
GC_PROJECT_ID: readString(values, 'GC_PROJECT_ID', ''),
GC_CLIENT_EMAIL: readString(values, 'GC_CLIENT_EMAIL', ''),
GC_PRIVATE_KEY: readString(values, 'GC_PRIVATE_KEY', ''),
S3_CACHE_DIR: readString(values, 'S3_CACHE_DIR', ''),
S3_CACHE_ENABLED: isEnvBooleanString(s3CacheEnabled)
? s3CacheEnabled
: 'true',
S3_CACHE_MAX_AGE: readNumber(values, 'S3_CACHE_MAX_AGE', 86400),
FFMPEG_REVERSE_TIMEOUT_MS: readNumber(
values,
'FFMPEG_REVERSE_TIMEOUT_MS',
600000,
),
FFPROBE_TIMEOUT_MS: readNumber(values, 'FFPROBE_TIMEOUT_MS', 30000),
FFMPEG_BREAKER_FAILURE_THRESHOLD: readNumber(
values,
'FFMPEG_BREAKER_FAILURE_THRESHOLD',
3,
),
FFMPEG_BREAKER_COOLDOWN_MS: readNumber(
values,
'FFMPEG_BREAKER_COOLDOWN_MS',
120000,
),
FFMPEG_BREAKER_SUCCESS_THRESHOLD: readNumber(
values,
'FFMPEG_BREAKER_SUCCESS_THRESHOLD',
1,
),
FILE_STORAGE_BREAKER_FAILURE_THRESHOLD: readNumber(
values,
'FILE_STORAGE_BREAKER_FAILURE_THRESHOLD',
5,
),
FILE_STORAGE_BREAKER_COOLDOWN_MS: readNumber(
values,
'FILE_STORAGE_BREAKER_COOLDOWN_MS',
30000,
),
FILE_STORAGE_BREAKER_SUCCESS_THRESHOLD: readNumber(
values,
'FILE_STORAGE_BREAKER_SUCCESS_THRESHOLD',
2,
),
EMAIL_USER: readString(values, 'EMAIL_USER', ''), EMAIL_USER: readString(values, 'EMAIL_USER', ''),
EMAIL_PASS: readString(values, 'EMAIL_PASS', ''), EMAIL_PASS: readString(values, 'EMAIL_PASS', ''),
EMAIL_TLS_REJECT_UNAUTHORIZED: isEnvBooleanString(tlsRejectUnauthorized) EMAIL_TLS_REJECT_UNAUTHORIZED: isEnvBooleanString(tlsRejectUnauthorized)
@ -140,6 +261,7 @@ function toValidatedEnvironment(
: 'true', : 'true',
PEXELS_KEY: readString(values, 'PEXELS_KEY', ''), PEXELS_KEY: readString(values, 'PEXELS_KEY', ''),
LOG_LEVEL: isEnvLogLevel(logLevel) ? logLevel : 'info', LOG_LEVEL: isEnvLogLevel(logLevel) ? logLevel : 'info',
LOG_PRETTY: isEnvBooleanString(logPretty) ? logPretty : 'false',
}; };
} }

View File

@ -1,6 +1,7 @@
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import test from 'node:test'; import test from 'node:test';
import config from '../src/config.ts';
import { import {
createErrorResponse, createErrorResponse,
generatePresignedUrls, generatePresignedUrls,
@ -37,8 +38,8 @@ void test('createErrorResponse returns a stable structured error payload', () =>
}); });
void test('generatePresignedUrls returns backend download URLs for local storage provider', async () => { void test('generatePresignedUrls returns backend download URLs for local storage provider', async () => {
const previousProvider = process.env.FILE_STORAGE_PROVIDER; const previousProvider = config.fileStorage.provider;
process.env.FILE_STORAGE_PROVIDER = 'local'; config.fileStorage.provider = 'local';
try { try {
assert.deepEqual( assert.deepEqual(
await generatePresignedUrls(['projects/demo/image 1.jpg']), await generatePresignedUrls(['projects/demo/image 1.jpg']),
@ -48,10 +49,6 @@ void test('generatePresignedUrls returns backend download URLs for local storage
}, },
); );
} finally { } finally {
if (previousProvider === undefined) { config.fileStorage.provider = previousProvider;
delete process.env.FILE_STORAGE_PROVIDER;
} else {
process.env.FILE_STORAGE_PROVIDER = previousProvider;
}
} }
}); });

View File

@ -2,6 +2,8 @@ import assert from 'node:assert/strict';
import test from 'node:test'; import test from 'node:test';
import { assertRouteIdMatchesBody } from '../src/helpers.ts'; import { assertRouteIdMatchesBody } from '../src/helpers.ts';
import { CircuitBreaker, CircuitBreakerOpenError } from '../src/utils/circuit-breaker.ts';
import { envSchema } from '../src/utils/env-validation.ts';
import { normalizeLoggedError } from '../src/utils/logger.ts'; import { normalizeLoggedError } from '../src/utils/logger.ts';
void test('assertRouteIdMatchesBody allows matching top-level body id', () => { void test('assertRouteIdMatchesBody allows matching top-level body id', () => {
@ -52,3 +54,56 @@ void test('normalizeLoggedError uses string reasons as messages', () => {
assert.equal(error.message, 'plain rejection'); assert.equal(error.message, 'plain rejection');
}); });
void test('envSchema normalizes file storage provider override', () => {
const result = envSchema.validate({ FILE_STORAGE_PROVIDER: ' S3 ' });
const providerDescriptor =
typeof result.value === 'object' && result.value !== null
? Object.getOwnPropertyDescriptor(result.value, 'FILE_STORAGE_PROVIDER')
: undefined;
assert.ifError(result.error);
assert.equal(providerDescriptor?.value, 's3');
});
void test('CircuitBreaker can ignore non-breaker failures', async () => {
const breaker = new CircuitBreaker({
name: 'test-breaker',
failureThreshold: 1,
shouldRecordFailure: () => false,
});
await assert.rejects(
() =>
breaker.execute('ignored-failure', () =>
Promise.reject(new Error('Validation failure')),
),
/Validation failure/,
);
assert.equal(breaker.getState(), 'closed');
await assert.doesNotReject(() =>
breaker.execute('next-call', () => Promise.resolve('ok')),
);
});
void test('CircuitBreaker opens after recorded failures', async () => {
const breaker = new CircuitBreaker({
name: 'test-breaker',
failureThreshold: 1,
});
await assert.rejects(
() =>
breaker.execute('recorded-failure', () =>
Promise.reject(new Error('Transient failure')),
),
/Transient failure/,
);
await assert.rejects(
() => breaker.execute('blocked-call', () => Promise.resolve('ok')),
CircuitBreakerOpenError,
);
});

View File

@ -2,6 +2,7 @@ import React, { Component, ErrorInfo, ReactNode } from 'react';
import { mdiAlertCircle } from '@mdi/js'; import { mdiAlertCircle } from '@mdi/js';
import BaseIcon from './BaseIcon'; import BaseIcon from './BaseIcon';
import { logger } from '../lib/logger'; import { logger } from '../lib/logger';
import { isProduction } from '../config';
// Define the props and state interfaces // Define the props and state interfaces
interface ErrorBoundaryProps { interface ErrorBoundaryProps {
@ -42,13 +43,13 @@ class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
prevState: Readonly<ErrorBoundaryState>, prevState: Readonly<ErrorBoundaryState>,
snapshot?: any, snapshot?: any,
) { ) {
if (process.env.NODE_ENV !== 'production') { if (!isProduction) {
logger.debug('componentDidUpdate'); logger.debug('componentDidUpdate');
} }
} }
componentWillUnmount() { componentWillUnmount() {
if (process.env.NODE_ENV !== 'production') { if (!isProduction) {
logger.debug('componentWillUnmount'); logger.debug('componentWillUnmount');
} }
} }
@ -60,7 +61,7 @@ class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
}); });
// Only perform logging in non-production environments // Only perform logging in non-production environments
if (process.env.NODE_ENV !== 'production') { if (!isProduction) {
logger.info('Error caught in boundary:', { error, errorInfo }); logger.info('Error caught in boundary:', { error, errorInfo });
} }
} }

View File

@ -1,12 +1,17 @@
export const isDevelopment = process.env.NODE_ENV === 'development';
export const isProduction = process.env.NODE_ENV === 'production';
export const frontendLogLevel = process.env.NEXT_PUBLIC_LOG_LEVEL || 'info';
export const hostApi = export const hostApi =
process.env.NODE_ENV === 'development' && !process.env.NEXT_PUBLIC_BACK_API isDevelopment && !process.env.NEXT_PUBLIC_BACK_API
? 'http://localhost' ? 'http://localhost'
: ''; : '';
export const portApi = export const portApi =
process.env.NODE_ENV === 'development' && !process.env.NEXT_PUBLIC_BACK_API isDevelopment && !process.env.NEXT_PUBLIC_BACK_API
? 3000 ? 3000
: ''; : '';
export const baseURLApi = `${hostApi}${portApi ? `:${portApi}` : ``}/api`; export const baseURLApi = `${hostApi}${portApi ? `:${portApi}` : ``}/api`;
export const apiBaseURL = process.env.NEXT_PUBLIC_BACK_API || baseURLApi;
export const swaggerDocsUrl = baseURLApi.replace(/\/api\/?$/, '/api-docs'); export const swaggerDocsUrl = baseURLApi.replace(/\/api\/?$/, '/api-docs');

View File

@ -1,5 +1,6 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import { isDevelopment } from '../config';
type CompilationStatus = 'ready' | 'compiling' | 'error' | 'initial'; type CompilationStatus = 'ready' | 'compiling' | 'error' | 'initial';
@ -8,7 +9,7 @@ const useDevCompilationStatus = (): CompilationStatus => {
const [status, setStatus] = useState<CompilationStatus>('initial'); const [status, setStatus] = useState<CompilationStatus>('initial');
useEffect(() => { useEffect(() => {
if (process.env.NODE_ENV !== 'development') { if (!isDevelopment) {
setStatus('ready'); setStatus('ready');
return; return;
} }

View File

@ -36,6 +36,7 @@ import {
getCrossfadeDuration, getCrossfadeDuration,
} from '../lib/browserUtils'; } from '../lib/browserUtils';
import { logger } from '../lib/logger'; import { logger } from '../lib/logger';
import { isDevelopment } from '../config';
import type { ResolvedTransitionSettings } from '../types/transition'; import type { ResolvedTransitionSettings } from '../types/transition';
// ============================================================================ // ============================================================================
@ -172,7 +173,7 @@ function navigationReducer(
action: NavigationAction, action: NavigationAction,
): NavigationState { ): NavigationState {
// DevTools logging in development // DevTools logging in development
if (process.env.NODE_ENV === 'development') { if (isDevelopment) {
logger.info('[NavigationState] Action:', { logger.info('[NavigationState] Action:', {
type: action.type, type: action.type,
currentPhase: state.phase, currentPhase: state.phase,

View File

@ -1,3 +1,5 @@
import { frontendLogLevel, isDevelopment } from '../config';
/** /**
* Frontend Logger * Frontend Logger
* *
@ -29,13 +31,22 @@ const LOG_LEVELS: Record<LogLevel, number> = {
error: 3, error: 3,
}; };
function toLogLevel(value: string): LogLevel {
return value === 'debug' ||
value === 'info' ||
value === 'warn' ||
value === 'error'
? value
: 'info';
}
class Logger { class Logger {
private config: LoggerConfig; private config: LoggerConfig;
constructor() { constructor() {
this.config = { this.config = {
level: (process.env.NEXT_PUBLIC_LOG_LEVEL as LogLevel) || 'info', level: toLogLevel(frontendLogLevel),
isDevelopment: process.env.NODE_ENV === 'development', isDevelopment,
service: 'tour-builder-frontend', service: 'tour-builder-frontend',
}; };
} }

View File

@ -13,7 +13,7 @@ import '@fontsource-variable/instrument-sans';
import '@fontsource-variable/instrument-sans/wdth.css'; // Condensed width axis import '@fontsource-variable/instrument-sans/wdth.css'; // Condensed width axis
import '../css/main.css'; import '../css/main.css';
import axios from 'axios'; import axios from 'axios';
import { baseURLApi } from '../config'; import { apiBaseURL, isProduction } from '../config';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import ErrorBoundary from '../components/ErrorBoundary'; import ErrorBoundary from '../components/ErrorBoundary';
import 'intro.js/introjs.css'; import 'intro.js/introjs.css';
@ -38,9 +38,7 @@ const isPresignedS3Url = (url: string): boolean => {
}; };
// Initialize axios // Initialize axios
axios.defaults.baseURL = process.env.NEXT_PUBLIC_BACK_API axios.defaults.baseURL = apiBaseURL;
? process.env.NEXT_PUBLIC_BACK_API
: baseURLApi;
axios.defaults.headers.common['Content-Type'] = 'application/json'; axios.defaults.headers.common['Content-Type'] = 'application/json';
@ -128,7 +126,7 @@ function MyApp({ Component, pageProps }: AppPropsWithLayout) {
return; return;
} }
if (process.env.NODE_ENV !== 'production') { if (!isProduction) {
navigator.serviceWorker.getRegistrations().then((registrations) => { navigator.serviceWorker.getRegistrations().then((registrations) => {
registrations.forEach((registration) => { registrations.forEach((registration) => {
registration.unregister(); registration.unregister();
@ -153,7 +151,7 @@ function MyApp({ Component, pageProps }: AppPropsWithLayout) {
return; return;
} }
if (process.env.NODE_ENV === 'production') { if (isProduction) {
navigator.serviceWorker navigator.serviceWorker
.register('/sw.js') .register('/sw.js')
.then((registration) => { .then((registration) => {