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 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 <app@flatlogic.app>',
|
||||
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}` : ''}/#`,
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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 <TResult>(
|
||||
provider: FileStorageProviderName,
|
||||
operationName: string,
|
||||
operation: () => Promise<TResult>,
|
||||
): Promise<TResult> => {
|
||||
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<Buffer> => {
|
||||
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<void>((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<void>((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)}`,
|
||||
};
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
|
||||
@ -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<unknown> = 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<Buffer> {
|
||||
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<void>((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<MediaMetadata> {
|
||||
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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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';
|
||||
}
|
||||
|
||||
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'),
|
||||
|
||||
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',
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
});
|
||||
|
||||
@ -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,
|
||||
);
|
||||
});
|
||||
|
||||
@ -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<ErrorBoundaryProps, ErrorBoundaryState> {
|
||||
prevState: Readonly<ErrorBoundaryState>,
|
||||
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<ErrorBoundaryProps, ErrorBoundaryState> {
|
||||
});
|
||||
|
||||
// Only perform logging in non-production environments
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
if (!isProduction) {
|
||||
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 =
|
||||
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');
|
||||
|
||||
|
||||
@ -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<CompilationStatus>('initial');
|
||||
|
||||
useEffect(() => {
|
||||
if (process.env.NODE_ENV !== 'development') {
|
||||
if (!isDevelopment) {
|
||||
setStatus('ready');
|
||||
return;
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
import { frontendLogLevel, isDevelopment } from '../config';
|
||||
|
||||
/**
|
||||
* Frontend Logger
|
||||
*
|
||||
@ -29,13 +31,22 @@ const LOG_LEVELS: Record<LogLevel, number> = {
|
||||
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',
|
||||
};
|
||||
}
|
||||
|
||||
@ -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) => {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user