added circuit breaker
This commit is contained in:
parent
57a705f3ba
commit
9e6e0e0dbe
@ -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}` : ''}/#`,
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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();
|
||||||
|
|||||||
@ -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();
|
||||||
|
|||||||
@ -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)}`,
|
||||||
}
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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';
|
||||||
}
|
}
|
||||||
|
|||||||
170
backend/src/utils/circuit-breaker.ts
Normal file
170
backend/src/utils/circuit-breaker.ts
Normal 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 };
|
||||||
@ -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',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
@ -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 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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');
|
||||||
|
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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) => {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user