From 9e6e0e0dbe03e5f938b1f5ff4f0248cbe75a7b2f Mon Sep 17 00:00:00 2001 From: Dmitri Date: Fri, 3 Jul 2026 15:59:42 +0200 Subject: [PATCH] added circuit breaker --- backend/src/config.ts | 105 ++++++--- backend/src/helpers.ts | 2 +- backend/src/index.ts | 48 ++-- backend/src/middlewares/rateLimiter.ts | 3 +- backend/src/services/file.ts | 218 ++++++++++-------- backend/src/services/videoProcessing.ts | 91 +++++++- backend/src/types/config.ts | 29 +++ backend/src/types/env.ts | 23 ++ backend/src/utils/circuit-breaker.ts | 170 ++++++++++++++ backend/src/utils/env-validation.ts | 122 ++++++++++ backend/tests/file-service.test.ts | 11 +- backend/tests/helpers.test.ts | 55 +++++ frontend/src/components/ErrorBoundary.tsx | 7 +- frontend/src/config.ts | 9 +- frontend/src/hooks/useDevCompilationStatus.ts | 3 +- frontend/src/hooks/usePageNavigationState.ts | 3 +- frontend/src/lib/logger.ts | 15 +- frontend/src/pages/_app.tsx | 10 +- 18 files changed, 747 insertions(+), 177 deletions(-) create mode 100644 backend/src/utils/circuit-breaker.ts diff --git a/backend/src/config.ts b/backend/src/config.ts index cf7e016..890e134 100644 --- a/backend/src/config.ts +++ b/backend/src/config.ts @@ -5,14 +5,9 @@ import './load-env.ts'; import { validateEnv } from './utils/env-validation.ts'; import type { BackendConfig } from './types/index.ts'; -validateEnv(); +const env = validateEnv(); -function envNumber(name: string, fallback: number): number { - const parsed = Number.parseInt(process.env[name] || '', 10); - return Number.isFinite(parsed) ? parsed : fallback; -} - -const isProduction = process.env.NODE_ENV === 'production'; +const isProduction = env.NODE_ENV === 'production'; const remote = ''; const port = isProduction ? '' : '8080'; const hostUI = isProduction ? '' : 'http://localhost'; @@ -21,36 +16,77 @@ const swaggerUI = isProduction ? '' : 'http://localhost'; const swaggerPort = isProduction ? '' : ':8080'; 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 = { gcloud: { bucket: 'fldemo-files', hash: 'afeefb9d49f5b7977577876b99532ac7', + projectId: env.GC_PROJECT_ID, + clientEmail: env.GC_CLIENT_EMAIL, + privateKey: env.GC_PRIVATE_KEY, + }, + fileStorage: { + provider: env.FILE_STORAGE_PROVIDER, }, s3: { - bucket: process.env.AWS_S3_BUCKET || '', - region: process.env.AWS_S3_REGION || 'us-east-1', - accessKeyId: process.env.AWS_ACCESS_KEY_ID || '', - secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY || '', - prefix: process.env.AWS_S3_PREFIX || 'afeefb9d49f5b7977577876b99532ac7', - connectionTimeout: envNumber('AWS_S3_CONNECTION_TIMEOUT', 5000), - requestTimeout: envNumber('AWS_S3_REQUEST_TIMEOUT', 30000), - maxAttempts: envNumber('AWS_S3_MAX_ATTEMPTS', 3), - maxSockets: envNumber('AWS_S3_MAX_SOCKETS', 50), - keepAlive: process.env.AWS_S3_KEEP_ALIVE !== 'false', - presignExpirySeconds: envNumber('AWS_S3_PRESIGN_EXPIRY', 3600), + bucket: env.AWS_S3_BUCKET, + region: env.AWS_S3_REGION, + accessKeyId: env.AWS_ACCESS_KEY_ID, + secretAccessKey: env.AWS_SECRET_ACCESS_KEY, + prefix: env.AWS_S3_PREFIX, + connectionTimeout: env.AWS_S3_CONNECTION_TIMEOUT, + requestTimeout: env.AWS_S3_REQUEST_TIMEOUT, + maxAttempts: env.AWS_S3_MAX_ATTEMPTS, + maxSockets: env.AWS_S3_MAX_SOCKETS, + keepAlive: env.AWS_S3_KEEP_ALIVE !== 'false', + 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: { saltRounds: 12, }, - admin_pass: process.env.ADMIN_PASS || '88dbeaf8', - user_pass: process.env.USER_PASS || 'c3baadeda5c6', - admin_email: process.env.ADMIN_EMAIL || 'admin@flatlogic.com', + admin_pass: env.ADMIN_PASS, + user_pass: env.USER_PASS, + admin_email: env.ADMIN_EMAIL, providers: { LOCAL: 'local', GOOGLE: 'google', MICROSOFT: 'microsoft', }, - secret_key: process.env.SECRET_KEY || '88dbeaf8-e906-405e-9e41-c3baadeda5c6', + secret_key: env.SECRET_KEY, remote, port, host, @@ -60,33 +96,38 @@ const config: BackendConfig = { swaggerUI, swaggerPort, google: { - clientId: process.env.GOOGLE_CLIENT_ID || '', - clientSecret: process.env.GOOGLE_CLIENT_SECRET || '', + clientId: env.GOOGLE_CLIENT_ID, + clientSecret: env.GOOGLE_CLIENT_SECRET, }, microsoft: { - clientId: process.env.MS_CLIENT_ID || '', - clientSecret: process.env.MS_CLIENT_SECRET || '', + clientId: env.MS_CLIENT_ID, + clientSecret: env.MS_CLIENT_SECRET, }, uploadDir: os.tmpdir(), - s3CacheDir: process.env.S3_CACHE_DIR || path.join(os.tmpdir(), 's3-cache'), - s3CacheEnabled: process.env.S3_CACHE_ENABLED !== 'false', - s3CacheMaxAge: envNumber('S3_CACHE_MAX_AGE', 86400), + s3CacheDir: env.S3_CACHE_DIR || path.join(os.tmpdir(), 's3-cache'), + s3CacheEnabled: env.S3_CACHE_ENABLED !== 'false', + s3CacheMaxAge: env.S3_CACHE_MAX_AGE, email: { from: 'Tour Builder Platform ', host: 'email-smtp.us-east-1.amazonaws.com', port: 587, auth: { - user: process.env.EMAIL_USER || '', - pass: process.env.EMAIL_PASS || '', + user: env.EMAIL_USER, + pass: env.EMAIL_PASS, }, tls: { - rejectUnauthorized: process.env.EMAIL_TLS_REJECT_UNAUTHORIZED !== 'false', + rejectUnauthorized: env.EMAIL_TLS_REJECT_UNAUTHORIZED !== 'false', }, }, roles: { admin: 'Administrator', user: 'Analytics Viewer', }, + server: { + env: env.NODE_ENV, + port: serverPort, + swaggerServerUrl, + }, apiUrl: `${host}${port ? `:${port}` : ''}/api`, swaggerUrl: `${swaggerUI}${swaggerPort}`, uiUrl: `${hostUI}${portUI ? `:${portUI}` : ''}/#`, diff --git a/backend/src/helpers.ts b/backend/src/helpers.ts index bf68262..b627cb9 100644 --- a/backend/src/helpers.ts +++ b/backend/src/helpers.ts @@ -55,7 +55,7 @@ const commonErrorHandler: ErrorRequestHandler = ( if ( 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); } diff --git a/backend/src/index.ts b/backend/src/index.ts index 863c674..066f391 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -145,13 +145,8 @@ const searchRoutes = getExpressRouter('search', searchRoutesModule); const tourPagesRoutes = getExpressRouter('tour_pages', tourPagesRoutesModule); 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({ - serverUrl: getBaseUrl(process.env.NEXT_PUBLIC_BACK_API) || config.swaggerUrl, + serverUrl: config.server.swaggerServerUrl, }); app.use( @@ -271,7 +266,7 @@ app.get('/api/health', wrapAsync(async (_req, res) => { status: 'ok', timestamp: new Date().toISOString(), uptime: process.uptime(), - environment: process.env.NODE_ENV || 'development', + environment: config.server.env, }; try { @@ -370,39 +365,52 @@ if (fs.existsSync(publicDir)) { } // 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) => { if (!res.headersSent) { const requestLog = getRequestLogger(req) ?? logger; + const statusCode = + getNumericErrorProperty(err, 'code') ?? + getNumericErrorProperty(err, 'status') ?? + 500; + const safeStatusCode = statusCode === 503 ? 503 : 500; requestLog.error( { err, url: req.url, method: req.method }, '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); -function getServerPort(): number { - 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 PORT = config.server.port; const server = app.listen(PORT, () => { logger.info( - { port: PORT, env: process.env.NODE_ENV || 'development' }, + { port: PORT, env: config.server.env }, 'Server started', ); }); server.on('error', (err: NodeJS.ErrnoException) => { logger.error( - { err, port: PORT, env: process.env.NODE_ENV || 'development' }, + { err, port: PORT, env: config.server.env }, 'Server failed to start', ); exitAfterLogging(); diff --git a/backend/src/middlewares/rateLimiter.ts b/backend/src/middlewares/rateLimiter.ts index 84ed3a4..deb4216 100644 --- a/backend/src/middlewares/rateLimiter.ts +++ b/backend/src/middlewares/rateLimiter.ts @@ -12,6 +12,7 @@ import type { RequestHandler } from 'express'; +import config from '../config.ts'; import type { RateLimitEntry, RateLimiterOptions } from '../types/index.ts'; import { logger } from '../utils/logger.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) if ( - process.env.NODE_ENV === 'development' && + config.server.env === 'development' && (req.ip === '127.0.0.1' || req.ip === '::1') ) { return next(); diff --git a/backend/src/services/file.ts b/backend/src/services/file.ts index 748937d..2ff7d02 100644 --- a/backend/src/services/file.ts +++ b/backend/src/services/file.ts @@ -53,6 +53,7 @@ import type { UploadSessionInitRequest, UploadSessionRequest, } from '../types/index.ts'; +import { CircuitBreaker } from '../utils/circuit-breaker.ts'; import { logger } from '../utils/logger.ts'; import LocalStorageProvider from './file/LocalStorageProvider.ts'; import S3StorageProvider from './file/S3StorageProvider.ts'; @@ -232,10 +233,19 @@ let gcloudBucket: GCloudBucketState['bucket'] | null = null; let gcloudHash: string | 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 provider = (process.env.FILE_STORAGE_PROVIDER || '') - .trim() - .toLowerCase(); + const provider = config.fileStorage.provider; if (provider === 's3' || provider === 'gcloud' || provider === 'local') { return provider; } @@ -249,9 +259,9 @@ const getFileStorageProvider = (): FileStorageProviderName => { if (hasS3) return 's3'; const hasGCloud = Boolean( - process.env.GC_PROJECT_ID && - process.env.GC_CLIENT_EMAIL && - process.env.GC_PRIVATE_KEY && + config.gcloud.projectId && + config.gcloud.clientEmail && + config.gcloud.privateKey && config.gcloud.bucket && config.gcloud.hash, ); @@ -260,6 +270,21 @@ const getFileStorageProvider = (): FileStorageProviderName => { return 'local'; }; +const executeStorageOperation = async ( + provider: FileStorageProviderName, + operationName: string, + operation: () => Promise, +): Promise => { + if (provider === 'local') { + return operation(); + } + + return externalStorageBreaker.execute( + `${provider}.${operationName}`, + operation, + ); +}; + const getS3Provider = (): S3StorageProvider => { if (!s3Provider) { s3Provider = new S3StorageProvider({ @@ -300,16 +325,11 @@ const getLocalProvider = (): LocalStorageProvider => { const getGCloudBucket = (): GCloudBucketState => { if (!gcloudBucket) { - const privateKey = (process.env.GC_PRIVATE_KEY || '').replace( - /\\\n/g, - '\n', - ); - const clientEmail = process.env.GC_CLIENT_EMAIL || ''; - const projectId = process.env.GC_PROJECT_ID || ''; + const privateKey = config.gcloud.privateKey.replace(/\\\n/g, '\n'); const storage = new Storage({ - projectId, + projectId: config.gcloud.projectId, credentials: { - client_email: clientEmail, + client_email: config.gcloud.clientEmail, private_key: privateKey, }, }); @@ -918,18 +938,20 @@ const deleteFile = async ( const provider = getFileStorageProvider(); try { - if (provider === 's3') { - const s3 = getS3Provider(); - await s3.delete(privateUrl); - } else if (provider === 'gcloud') { - const { bucket, hash } = getGCloudBucket(); - const file = bucket.file(`${hash}/${privateUrl}`); - const [exists] = await file.exists(); - if (exists) await file.delete(); - } else { - const local = getLocalProvider(); - await local.delete(privateUrl); - } + await executeStorageOperation(provider, 'deleteFile', async () => { + if (provider === 's3') { + const s3 = getS3Provider(); + await s3.delete(privateUrl); + } else if (provider === 'gcloud') { + const { bucket, hash } = getGCloudBucket(); + const file = bucket.file(`${hash}/${privateUrl}`); + const [exists] = await file.exists(); + if (exists) await file.delete(); + } else { + const local = getLocalProvider(); + await local.delete(privateUrl); + } + }); logger.debug({ provider, privateUrl }, 'File deleted successfully'); return { success: true }; @@ -956,34 +978,36 @@ const deleteFile = async ( const downloadToBuffer = async (privateUrl: string): Promise => { const provider = getFileStorageProvider(); - if (provider === 's3') { - const s3 = getS3Provider(); - const result = await s3.download(privateUrl); + return executeStorageOperation(provider, 'downloadToBuffer', async () => { + if (provider === 's3') { + const s3 = getS3Provider(); + const result = await s3.download(privateUrl); - // Convert stream to buffer - if (hasByteArrayTransformer(result.body)) { - const bytes = await result.body.transformToByteArray(); - return Buffer.from(bytes); - } - // Handle readable stream - if (!isReadableStream(result.body)) { - return Buffer.from([]); - } + // Convert stream to buffer + if (hasByteArrayTransformer(result.body)) { + const bytes = await result.body.transformToByteArray(); + return Buffer.from(bytes); + } + // Handle readable stream + if (!isReadableStream(result.body)) { + return Buffer.from([]); + } - const chunks: Buffer[] = []; - for await (const chunk of result.body) { - chunks.push(toBufferChunk(chunk)); + const chunks: Buffer[] = []; + for await (const chunk of result.body) { + 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 { - if (provider === 's3') { - const s3 = getS3Provider(); - const result = await s3.download(privateUrl); - if (!result.body) { - throw new Error(`Empty S3 response body for ${privateUrl}`); - } - if (isReadableStream(result.body)) { - await pipeline(result.body, fs.createWriteStream(filePath)); - } else if (hasByteArrayTransformer(result.body)) { - const bytes = await result.body.transformToByteArray(); - await fs.promises.writeFile(filePath, Buffer.from(bytes)); + await executeStorageOperation(provider, 'downloadToTempFile', async () => { + if (provider === 's3') { + const s3 = getS3Provider(); + const result = await s3.download(privateUrl); + if (!result.body) { + throw new Error(`Empty S3 response body for ${privateUrl}`); + } + if (isReadableStream(result.body)) { + await pipeline(result.body, fs.createWriteStream(filePath)); + } else if (hasByteArrayTransformer(result.body)) { + 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 { - 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 }; } catch (error) { @@ -1054,30 +1080,32 @@ const uploadBuffer = async ( const provider = getFileStorageProvider(); const { contentType = 'application/octet-stream' } = options; - if (provider === 's3') { - const s3 = getS3Provider(); - const result = await s3.upload(privateUrl, buffer, { contentType }); - return { url: getStorageUploadUrl(result.url) }; - } else if (provider === 'gcloud') { - const { bucket, hash } = getGCloudBucket(); - const filePath = `${hash}/${privateUrl}`; - const blob = bucket.file(filePath); - await new Promise((resolve, reject) => { - const blobStream = blob.createWriteStream({ resumable: false }); - blobStream.on('error', reject); - blobStream.on('finish', resolve); - blobStream.end(buffer); - }); - return { - url: `https://storage.googleapis.com/${bucket.name}/${blob.name}`, - }; - } else { - const local = getLocalProvider(); - await local.upload(privateUrl, buffer); - return { - url: `/api/file/download?privateUrl=${encodeURIComponent(privateUrl)}`, - }; - } + return executeStorageOperation(provider, 'uploadBuffer', async () => { + if (provider === 's3') { + const s3 = getS3Provider(); + const result = await s3.upload(privateUrl, buffer, { contentType }); + return { url: getStorageUploadUrl(result.url) }; + } else if (provider === 'gcloud') { + const { bucket, hash } = getGCloudBucket(); + const filePath = `${hash}/${privateUrl}`; + const blob = bucket.file(filePath); + await new Promise((resolve, reject) => { + const blobStream = blob.createWriteStream({ resumable: false }); + blobStream.on('error', reject); + blobStream.on('finish', resolve); + blobStream.end(buffer); + }); + return { + url: `https://storage.googleapis.com/${bucket.name}/${blob.name}`, + }; + } else { + const local = getLocalProvider(); + await local.upload(privateUrl, buffer); + return { + url: `/api/file/download?privateUrl=${encodeURIComponent(privateUrl)}`, + }; + } + }); }; // ============================================================================ diff --git a/backend/src/services/videoProcessing.ts b/backend/src/services/videoProcessing.ts index 87ba3f2..6bfa961 100644 --- a/backend/src/services/videoProcessing.ts +++ b/backend/src/services/videoProcessing.ts @@ -13,7 +13,9 @@ import path from 'path'; import ffmpeg from 'fluent-ffmpeg'; import { path as ffprobePath } from 'ffprobe-static'; +import config from '../config.ts'; import type { FfmpegJobRunner, MediaMetadata } from '../types/index.ts'; +import { CircuitBreaker } from '../utils/circuit-breaker.ts'; import { logger } from '../utils/logger.ts'; const loadCommonJsModule = createRequire(import.meta.url); @@ -29,6 +31,16 @@ let ffmpegQueueTail: Promise = Promise.resolve(); let queuedFfmpegJobs = 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 { if (!value) { return null; @@ -115,7 +127,9 @@ async function reverseVideo( filename: string, ): Promise { return enqueueFfmpegJob('reverseVideo', () => - reverseVideoWithoutQueue(inputBuffer, filename), + reverseVideoBreaker.execute('reverseVideo', () => + reverseVideoWithoutQueue(inputBuffer, filename), + ), ); } @@ -131,10 +145,30 @@ async function reverseVideoWithoutQueue( try { 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((resolve, reject) => { - ffmpeg(inputPath) + const command = ffmpeg(inputPath) .outputOptions([ '-vf', 'reverse', @@ -161,17 +195,54 @@ async function reverseVideoWithoutQueue( } }) .on('end', () => { + clearTimeout(timeout); logger.info({ outputPath }, 'Video reversal complete'); resolve(); }) .on('error', (err, stdout, stderr) => { + clearTimeout(timeout); logger.error({ err, stdout, stderr }, 'FFmpeg error'); 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 { try { await fs.rm(tempDir, { recursive: true, force: true }); @@ -183,7 +254,15 @@ async function reverseVideoWithoutQueue( async function probeMediaMetadata(filePath: string): Promise { 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) => { + clearTimeout(timeout); if (err) { reject(toError(err)); return; diff --git a/backend/src/types/config.ts b/backend/src/types/config.ts index 4f697af..b04fcf8 100644 --- a/backend/src/types/config.ts +++ b/backend/src/types/config.ts @@ -11,6 +11,12 @@ export interface BackendConfig { gcloud: { bucket: string; hash: string; + projectId: string; + clientEmail: string; + privateKey: string; + }; + fileStorage: { + provider: '' | 's3' | 'gcloud' | 'local'; }; s3: { bucket: string; @@ -25,6 +31,24 @@ export interface BackendConfig { keepAlive: boolean; 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; admin_pass: string; user_pass: string; @@ -50,6 +74,11 @@ export interface BackendConfig { admin: string; user?: string; }; + server: { + env: string; + port: number; + swaggerServerUrl: string; + }; remote: string; port: string; host: string; diff --git a/backend/src/types/env.ts b/backend/src/types/env.ts index 139e87e..12f73ae 100644 --- a/backend/src/types/env.ts +++ b/backend/src/types/env.ts @@ -7,6 +7,7 @@ export type NodeEnvironment = export interface ValidatedEnvironment { NODE_ENV: NodeEnvironment; PORT: number; + NEXT_PUBLIC_BACK_API: string; DB_HOST: string; DB_PORT: number; DB_NAME: string; @@ -25,9 +26,31 @@ export interface ValidatedEnvironment { AWS_S3_BUCKET: string; AWS_S3_REGION: 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_PASS: string; EMAIL_TLS_REJECT_UNAUTHORIZED: 'true' | 'false'; PEXELS_KEY: string; LOG_LEVEL: 'fatal' | 'error' | 'warn' | 'info' | 'debug' | 'trace'; + LOG_PRETTY: 'true' | 'false'; } diff --git a/backend/src/utils/circuit-breaker.ts b/backend/src/utils/circuit-breaker.ts new file mode 100644 index 0000000..f2f4853 --- /dev/null +++ b/backend/src/utils/circuit-breaker.ts @@ -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( + operationName: string, + operation: () => Promise, + ): Promise { + 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 }; diff --git a/backend/src/utils/env-validation.ts b/backend/src/utils/env-validation.ts index 9257fe9..563ef2b 100644 --- a/backend/src/utils/env-validation.ts +++ b/backend/src/utils/env-validation.ts @@ -12,6 +12,7 @@ const envSchema = Joi.object({ .default('development'), PORT: Joi.number().default(8080), + NEXT_PUBLIC_BACK_API: Joi.string().uri().allow('').default(''), DB_HOST: Joi.string().default('localhost'), DB_PORT: Joi.number().default(5432), @@ -37,6 +38,52 @@ const envSchema = Joi.object({ AWS_S3_BUCKET: Joi.string().allow('').default(''), AWS_S3_REGION: Joi.string().default('us-east-1'), 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_PASS: Joi.string().allow('').default(''), @@ -49,6 +96,7 @@ const envSchema = Joi.object({ LOG_LEVEL: Joi.string() .valid('fatal', 'error', 'warn', 'info', 'debug', 'trace') .default('info'), + LOG_PRETTY: Joi.string().valid('true', 'false').default('false'), }).unknown(true); function isNodeEnvironment(value: unknown): value is NodeEnvironment { @@ -103,10 +151,15 @@ function toValidatedEnvironment( const nodeEnv = values.NODE_ENV; const tlsRejectUnauthorized = values.EMAIL_TLS_REJECT_UNAUTHORIZED; 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 { NODE_ENV: isNodeEnvironment(nodeEnv) ? nodeEnv : 'development', PORT: readNumber(values, 'PORT', 8080), + NEXT_PUBLIC_BACK_API: readString(values, 'NEXT_PUBLIC_BACK_API', ''), DB_HOST: readString(values, 'DB_HOST', 'localhost'), DB_PORT: readNumber(values, 'DB_PORT', 5432), DB_NAME: readString(values, 'DB_NAME', 'db_tour_builder_platform'), @@ -133,6 +186,74 @@ function toValidatedEnvironment( 'AWS_S3_PREFIX', '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_PASS: readString(values, 'EMAIL_PASS', ''), EMAIL_TLS_REJECT_UNAUTHORIZED: isEnvBooleanString(tlsRejectUnauthorized) @@ -140,6 +261,7 @@ function toValidatedEnvironment( : 'true', PEXELS_KEY: readString(values, 'PEXELS_KEY', ''), LOG_LEVEL: isEnvLogLevel(logLevel) ? logLevel : 'info', + LOG_PRETTY: isEnvBooleanString(logPretty) ? logPretty : 'false', }; } diff --git a/backend/tests/file-service.test.ts b/backend/tests/file-service.test.ts index 8a7b40e..3836c68 100644 --- a/backend/tests/file-service.test.ts +++ b/backend/tests/file-service.test.ts @@ -1,6 +1,7 @@ import assert from 'node:assert/strict'; import test from 'node:test'; +import config from '../src/config.ts'; import { createErrorResponse, 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 () => { - const previousProvider = process.env.FILE_STORAGE_PROVIDER; - process.env.FILE_STORAGE_PROVIDER = 'local'; + const previousProvider = config.fileStorage.provider; + config.fileStorage.provider = 'local'; try { assert.deepEqual( await generatePresignedUrls(['projects/demo/image 1.jpg']), @@ -48,10 +49,6 @@ void test('generatePresignedUrls returns backend download URLs for local storage }, ); } finally { - if (previousProvider === undefined) { - delete process.env.FILE_STORAGE_PROVIDER; - } else { - process.env.FILE_STORAGE_PROVIDER = previousProvider; - } + config.fileStorage.provider = previousProvider; } }); diff --git a/backend/tests/helpers.test.ts b/backend/tests/helpers.test.ts index 79a5037..477607f 100644 --- a/backend/tests/helpers.test.ts +++ b/backend/tests/helpers.test.ts @@ -2,6 +2,8 @@ import assert from 'node:assert/strict'; import test from 'node:test'; 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'; 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'); }); + +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, + ); +}); diff --git a/frontend/src/components/ErrorBoundary.tsx b/frontend/src/components/ErrorBoundary.tsx index 0b96131..20cd4d1 100644 --- a/frontend/src/components/ErrorBoundary.tsx +++ b/frontend/src/components/ErrorBoundary.tsx @@ -2,6 +2,7 @@ import React, { Component, ErrorInfo, ReactNode } from 'react'; import { mdiAlertCircle } from '@mdi/js'; import BaseIcon from './BaseIcon'; import { logger } from '../lib/logger'; +import { isProduction } from '../config'; // Define the props and state interfaces interface ErrorBoundaryProps { @@ -42,13 +43,13 @@ class ErrorBoundary extends Component { prevState: Readonly, snapshot?: any, ) { - if (process.env.NODE_ENV !== 'production') { + if (!isProduction) { logger.debug('componentDidUpdate'); } } componentWillUnmount() { - if (process.env.NODE_ENV !== 'production') { + if (!isProduction) { logger.debug('componentWillUnmount'); } } @@ -60,7 +61,7 @@ class ErrorBoundary extends Component { }); // Only perform logging in non-production environments - if (process.env.NODE_ENV !== 'production') { + if (!isProduction) { logger.info('Error caught in boundary:', { error, errorInfo }); } } diff --git a/frontend/src/config.ts b/frontend/src/config.ts index de0bd0a..429f0f2 100644 --- a/frontend/src/config.ts +++ b/frontend/src/config.ts @@ -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 = - process.env.NODE_ENV === 'development' && !process.env.NEXT_PUBLIC_BACK_API + isDevelopment && !process.env.NEXT_PUBLIC_BACK_API ? 'http://localhost' : ''; export const portApi = - process.env.NODE_ENV === 'development' && !process.env.NEXT_PUBLIC_BACK_API + isDevelopment && !process.env.NEXT_PUBLIC_BACK_API ? 3000 : ''; 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'); diff --git a/frontend/src/hooks/useDevCompilationStatus.ts b/frontend/src/hooks/useDevCompilationStatus.ts index c1b9356..3bb14c1 100644 --- a/frontend/src/hooks/useDevCompilationStatus.ts +++ b/frontend/src/hooks/useDevCompilationStatus.ts @@ -1,5 +1,6 @@ import { useState, useEffect } from 'react'; import { useRouter } from 'next/router'; +import { isDevelopment } from '../config'; type CompilationStatus = 'ready' | 'compiling' | 'error' | 'initial'; @@ -8,7 +9,7 @@ const useDevCompilationStatus = (): CompilationStatus => { const [status, setStatus] = useState('initial'); useEffect(() => { - if (process.env.NODE_ENV !== 'development') { + if (!isDevelopment) { setStatus('ready'); return; } diff --git a/frontend/src/hooks/usePageNavigationState.ts b/frontend/src/hooks/usePageNavigationState.ts index a8d0eaf..d4af567 100644 --- a/frontend/src/hooks/usePageNavigationState.ts +++ b/frontend/src/hooks/usePageNavigationState.ts @@ -36,6 +36,7 @@ import { getCrossfadeDuration, } from '../lib/browserUtils'; import { logger } from '../lib/logger'; +import { isDevelopment } from '../config'; import type { ResolvedTransitionSettings } from '../types/transition'; // ============================================================================ @@ -172,7 +173,7 @@ function navigationReducer( action: NavigationAction, ): NavigationState { // DevTools logging in development - if (process.env.NODE_ENV === 'development') { + if (isDevelopment) { logger.info('[NavigationState] Action:', { type: action.type, currentPhase: state.phase, diff --git a/frontend/src/lib/logger.ts b/frontend/src/lib/logger.ts index 5e2443a..afe968a 100644 --- a/frontend/src/lib/logger.ts +++ b/frontend/src/lib/logger.ts @@ -1,3 +1,5 @@ +import { frontendLogLevel, isDevelopment } from '../config'; + /** * Frontend Logger * @@ -29,13 +31,22 @@ const LOG_LEVELS: Record = { error: 3, }; +function toLogLevel(value: string): LogLevel { + return value === 'debug' || + value === 'info' || + value === 'warn' || + value === 'error' + ? value + : 'info'; +} + class Logger { private config: LoggerConfig; constructor() { this.config = { - level: (process.env.NEXT_PUBLIC_LOG_LEVEL as LogLevel) || 'info', - isDevelopment: process.env.NODE_ENV === 'development', + level: toLogLevel(frontendLogLevel), + isDevelopment, service: 'tour-builder-frontend', }; } diff --git a/frontend/src/pages/_app.tsx b/frontend/src/pages/_app.tsx index 945f758..c324387 100644 --- a/frontend/src/pages/_app.tsx +++ b/frontend/src/pages/_app.tsx @@ -13,7 +13,7 @@ import '@fontsource-variable/instrument-sans'; import '@fontsource-variable/instrument-sans/wdth.css'; // Condensed width axis import '../css/main.css'; import axios from 'axios'; -import { baseURLApi } from '../config'; +import { apiBaseURL, isProduction } from '../config'; import { useRouter } from 'next/router'; import ErrorBoundary from '../components/ErrorBoundary'; import 'intro.js/introjs.css'; @@ -38,9 +38,7 @@ const isPresignedS3Url = (url: string): boolean => { }; // Initialize axios -axios.defaults.baseURL = process.env.NEXT_PUBLIC_BACK_API - ? process.env.NEXT_PUBLIC_BACK_API - : baseURLApi; +axios.defaults.baseURL = apiBaseURL; axios.defaults.headers.common['Content-Type'] = 'application/json'; @@ -128,7 +126,7 @@ function MyApp({ Component, pageProps }: AppPropsWithLayout) { return; } - if (process.env.NODE_ENV !== 'production') { + if (!isProduction) { navigator.serviceWorker.getRegistrations().then((registrations) => { registrations.forEach((registration) => { registration.unregister(); @@ -153,7 +151,7 @@ function MyApp({ Component, pageProps }: AppPropsWithLayout) { return; } - if (process.env.NODE_ENV === 'production') { + if (isProduction) { navigator.serviceWorker .register('/sw.js') .then((registration) => {