updated dependencies

This commit is contained in:
Dmitri 2026-07-07 10:44:51 +02:00
parent d081be19a1
commit 7bfdae0519
7 changed files with 206 additions and 207 deletions

View File

@ -20,7 +20,6 @@
"express": "^4.22.2",
"ffmpeg-static": "^5.2.0",
"ffprobe-static": "^3.1.0",
"fluent-ffmpeg": "^2.1.3",
"helmet": "^8.0.0",
"joi": "^17.13.0",
"json2csv": "^5.0.7",
@ -47,7 +46,6 @@
"@types/express": "^4.17.25",
"@types/express-serve-static-core": "^4.19.8",
"@types/ffprobe-static": "^2.0.3",
"@types/fluent-ffmpeg": "^2.1.28",
"@types/json2csv": "^5.0.7",
"@types/jsonwebtoken": "^9.0.10",
"@types/multer": "^2.1.0",
@ -2300,16 +2298,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/fluent-ffmpeg": {
"version": "2.1.28",
"resolved": "https://registry.npmjs.org/@types/fluent-ffmpeg/-/fluent-ffmpeg-2.1.28.tgz",
"integrity": "sha512-5ovxsDwBcPfJ+eYs1I/ZpcYCnkce7pvH9AHSvrZllAp1ZPpTRDZAFjF3TRFbukxSgIYTTNYePbS0rKUmaxVbXw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/http-errors": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz",
@ -3517,11 +3505,6 @@
"node": ">=8"
}
},
"node_modules/async": {
"version": "0.2.10",
"resolved": "https://registry.npmjs.org/async/-/async-0.2.10.tgz",
"integrity": "sha512-eAkdoKxU6/LkKDBzLpT+t6Ff5EtfSF4wx1WfJiPEEV7WNLnDaRXk0oVysiEPm262roaachGexwUv94WhSgN5TQ=="
},
"node_modules/async-function": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz",
@ -5538,19 +5521,6 @@
"dev": true,
"license": "ISC"
},
"node_modules/fluent-ffmpeg": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/fluent-ffmpeg/-/fluent-ffmpeg-2.1.3.tgz",
"integrity": "sha512-Be3narBNt2s6bsaqP6Jzq91heDgOEaDCJAXcE3qcma/EJBSy5FB4cvO31XBInuAuKBx8Kptf8dkhjK0IOru39Q==",
"license": "MIT",
"dependencies": {
"async": "^0.2.9",
"which": "^1.1.1"
},
"engines": {
"node": ">=18"
}
},
"node_modules/for-each": {
"version": "0.3.3",
"resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.3.tgz",
@ -6667,6 +6637,7 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
"dev": true,
"license": "ISC"
},
"node_modules/jju": {
@ -9831,18 +9802,6 @@
"webidl-conversions": "^3.0.0"
}
},
"node_modules/which": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz",
"integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==",
"license": "ISC",
"dependencies": {
"isexe": "^2.0.0"
},
"bin": {
"which": "bin/which"
}
},
"node_modules/which-boxed-primitive": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz",

View File

@ -43,7 +43,6 @@
"express": "^4.22.2",
"ffmpeg-static": "^5.2.0",
"ffprobe-static": "^3.1.0",
"fluent-ffmpeg": "^2.1.3",
"helmet": "^8.0.0",
"joi": "^17.13.0",
"json2csv": "^5.0.7",
@ -85,7 +84,6 @@
"@types/express": "^4.17.25",
"@types/express-serve-static-core": "^4.19.8",
"@types/ffprobe-static": "^2.0.3",
"@types/fluent-ffmpeg": "^2.1.28",
"@types/json2csv": "^5.0.7",
"@types/jsonwebtoken": "^9.0.10",
"@types/multer": "^2.1.0",

View File

@ -6,11 +6,11 @@
*/
import { promises as fs } from 'fs';
import { spawn } from 'node:child_process';
import { createRequire } from 'node:module';
import os from 'os';
import path from 'path';
import ffmpeg from 'fluent-ffmpeg';
import { path as ffprobePath } from 'ffprobe-static';
import config from '../config.ts';
@ -22,11 +22,6 @@ const loadCommonJsModule = createRequire(import.meta.url);
const ffmpegStaticValue: unknown = loadCommonJsModule('ffmpeg-static');
const ffmpegPath = typeof ffmpegStaticValue === 'string' ? ffmpegStaticValue : null;
if (ffmpegPath) {
ffmpeg.setFfmpegPath(ffmpegPath);
}
ffmpeg.setFfprobePath(ffprobePath);
let ffmpegQueueTail: Promise<unknown> = Promise.resolve();
let queuedFfmpegJobs = 0;
let ffmpegJobSequence = 0;
@ -41,6 +36,27 @@ const reverseVideoBreaker = new CircuitBreaker({
const FFMPEG_REVERSE_TIMEOUT_MS = config.resilience.ffmpeg.reverseTimeoutMs;
const FFPROBE_TIMEOUT_MS = config.resilience.ffmpeg.ffprobeTimeoutMs;
interface MediaProbeStream {
codec_type?: string | undefined;
duration?: string | number | undefined;
width?: string | number | undefined;
height?: string | number | undefined;
avg_frame_rate?: string | number | undefined;
r_frame_rate?: string | number | undefined;
}
interface MediaProbeOutput {
streams?: MediaProbeStream[] | undefined;
format?: {
duration?: string | number | undefined;
} | undefined;
}
interface ProcessResult {
stdout: string;
stderr: string;
}
function parseFrameRate(value: unknown): number | null {
if (!value) {
return null;
@ -86,6 +102,75 @@ function toError(value: unknown): Error {
return value instanceof Error ? value : new Error(String(value));
}
function isMediaProbeOutput(value: unknown): value is MediaProbeOutput {
return Boolean(value) && typeof value === 'object';
}
function getFfmpegExecutablePath(): string {
if (!ffmpegPath) {
throw new Error('Bundled FFmpeg binary is unavailable');
}
return ffmpegPath;
}
async function runProcess(
executablePath: string,
args: string[],
options: { timeoutMs: number; timeoutMessage: string },
): Promise<ProcessResult> {
return new Promise((resolve, reject) => {
const child = spawn(executablePath, args, {
stdio: ['ignore', 'pipe', 'pipe'],
});
let stdout = '';
let stderr = '';
let settled = false;
const finish = (error: Error | null, result?: ProcessResult): void => {
if (settled) {
return;
}
settled = true;
clearTimeout(timeout);
if (error) {
reject(error);
return;
}
resolve(result ?? { stdout, stderr });
};
const timeout = setTimeout(() => {
const error = new Error(options.timeoutMessage);
child.kill('SIGKILL');
finish(error);
}, options.timeoutMs);
timeout.unref();
child.stdout.setEncoding('utf8');
child.stderr.setEncoding('utf8');
child.stdout.on('data', (chunk: string) => {
stdout += chunk;
});
child.stderr.on('data', (chunk: string) => {
stderr += chunk;
});
child.on('error', (error) => finish(error));
child.on('close', (code, signal) => {
if (code === 0) {
finish(null, { stdout, stderr });
return;
}
finish(
new Error(
`Process failed: ${executablePath} exited with code ${code ?? 'null'} and signal ${signal ?? 'null'}`,
),
);
});
});
}
async function enqueueFfmpegJob<TResult>(
jobName: string,
runJob: FfmpegJobRunner<TResult>,
@ -167,59 +252,44 @@ async function reverseVideoWithoutQueue(
'Starting video reversal',
);
await new Promise<void>((resolve, reject) => {
const command = ffmpeg(inputPath)
.outputOptions([
'-vf',
'reverse',
'-af',
'areverse',
'-c:v',
'libx264',
'-preset',
'fast',
'-crf',
'23',
'-c:a',
'aac',
'-threads',
'1',
'-movflags',
'+faststart',
])
.output(outputPath)
.on('start', (cmd) => logger.debug({ cmd }, 'FFmpeg command'))
.on('progress', (progress) => {
if (progress.percent) {
logger.debug({ percent: progress.percent }, 'FFmpeg progress');
}
})
.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);
});
const ffmpegArgs = [
'-y',
'-i',
inputPath,
'-vf',
'reverse',
'-af',
'areverse',
'-c:v',
'libx264',
'-preset',
'fast',
'-crf',
'23',
'-c:a',
'aac',
'-threads',
'1',
'-movflags',
'+faststart',
outputPath,
];
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();
logger.debug(
{ cmd: [getFfmpegExecutablePath(), ...ffmpegArgs].join(' ') },
'FFmpeg command',
);
command.run();
});
try {
await runProcess(getFfmpegExecutablePath(), ffmpegArgs, {
timeoutMs: FFMPEG_REVERSE_TIMEOUT_MS,
timeoutMessage: `FFmpeg reverse video timed out after ${FFMPEG_REVERSE_TIMEOUT_MS}ms`,
});
logger.info({ outputPath }, 'Video reversal complete');
} catch (err) {
logger.error({ err, inputPath, outputPath }, 'FFmpeg error');
throw err;
}
const reversedBuffer = await fs.readFile(outputPath);
let outputMetadata: MediaMetadata | null = null;
@ -253,59 +323,74 @@ 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();
const result = await runProcess(
ffprobePath,
[
'-v',
'error',
'-print_format',
'json',
'-show_format',
'-show_streams',
filePath,
],
{
timeoutMs: FFPROBE_TIMEOUT_MS,
timeoutMessage: `FFprobe timed out after ${FFPROBE_TIMEOUT_MS}ms: ${filePath}`,
},
);
ffmpeg.ffprobe(filePath, (err: unknown, metadata) => {
clearTimeout(timeout);
if (err) {
reject(toError(err));
return;
}
let metadata: MediaProbeOutput;
try {
const parsedMetadata: unknown = JSON.parse(result.stdout);
if (!isMediaProbeOutput(parsedMetadata)) {
throw new Error('FFprobe output is not a JSON object');
}
metadata = parsedMetadata;
} catch (error) {
throw new Error(`Failed to parse FFprobe metadata: ${toError(error).message}`);
}
const videoStream =
metadata.streams.find((stream) => stream.codec_type === 'video') ??
null;
const audioStream =
metadata.streams.find((stream) => stream.codec_type === 'audio') ??
null;
const primaryStream = videoStream ?? audioStream;
const formatDuration = Number(metadata.format.duration);
const streamDuration = Number(primaryStream?.duration);
const durationSec =
Number.isFinite(formatDuration) && formatDuration > 0
? formatDuration
: Number.isFinite(streamDuration) && streamDuration > 0
? streamDuration
: null;
const streams = metadata.streams ?? [];
const videoStream =
streams.find((stream) => stream.codec_type === 'video') ?? null;
const audioStream =
streams.find((stream) => stream.codec_type === 'audio') ?? null;
const primaryStream = videoStream ?? audioStream;
const formatDuration = Number(metadata.format?.duration);
const streamDuration = Number(primaryStream?.duration);
const durationSec =
Number.isFinite(formatDuration) && formatDuration > 0
? formatDuration
: Number.isFinite(streamDuration) && streamDuration > 0
? streamDuration
: null;
const widthPx = Number(videoStream?.width);
const heightPx = Number(videoStream?.height);
const frameRate =
parseFrameRate(videoStream?.avg_frame_rate) ??
parseFrameRate(videoStream?.r_frame_rate);
const widthPx = Number(videoStream?.width);
const heightPx = Number(videoStream?.height);
const frameRate =
parseFrameRate(videoStream?.avg_frame_rate) ??
parseFrameRate(videoStream?.r_frame_rate);
resolve({
durationSec,
widthPx: Number.isFinite(widthPx) && widthPx > 0 ? widthPx : null,
heightPx: Number.isFinite(heightPx) && heightPx > 0 ? heightPx : null,
frameRate,
});
});
});
return {
durationSec,
widthPx: Number.isFinite(widthPx) && widthPx > 0 ? widthPx : null,
heightPx: Number.isFinite(heightPx) && heightPx > 0 ? heightPx : null,
frameRate,
};
}
async function isFFmpegAvailable(): Promise<boolean> {
return new Promise((resolve) => {
ffmpeg.getAvailableFormats((err: unknown) => {
resolve(!err);
try {
await runProcess(getFfmpegExecutablePath(), ['-hide_banner', '-formats'], {
timeoutMs: FFPROBE_TIMEOUT_MS,
timeoutMessage: `FFmpeg availability check timed out after ${FFPROBE_TIMEOUT_MS}ms`,
});
});
return true;
} catch (err) {
logger.warn({ err }, 'FFmpeg availability check failed');
return false;
}
}
export { isFFmpegAvailable, probeMediaMetadata, reverseVideo };

View File

@ -294,8 +294,8 @@ goal is only to restore the web app. Use PM2/systemd for process recovery.
## Node Version
The project requirement is Node.js 20.x LTS. Some standard VMs may report
`/usr/bin/node` as Node 22 in PM2. If startup fails after a system update,
The project requirement is Node.js 24.x LTS. Some standard VMs may report
a different `/usr/bin/node` in PM2. If startup fails after a system update,
verify:
```bash

View File

@ -42,53 +42,6 @@ TODO:
- Если нужен, оставить `localStorage`, но осознанно и с коротким TTL/refresh policy.
- Заменить frontend `jsonwebtoken` decode на `jwt-decode` или `/auth/me`.
## P1 - Build и зависимости
### Package manager
Решение: для root/frontend/backend используется только `npm`.
Источник истины для dependency graph: `package-lock.json` в `frontend/` и `backend/`.
Команды установки/deploy остаются на `npm ci` / `npm install` и `npm run ...`.
TODO:
- [x] Выбрать один package manager для frontend/backend: `npm`.
- [x] Удалить лишний lockfile после проверки: лишних `yarn.lock` / `pnpm-lock.yaml` нет, остаются только `package-lock.json`.
- [x] Обновить docs/deployment commands: текущие docs уже используют `npm`.
### Dependencies LTS alignment и obsolete package replacement
Зависимости нужно обновлять осознанно, чтобы не закреплять старые toolchain limitations и устаревшие библиотеки.
TODO:
- Проверить текущие frontend/backend dependencies и devDependencies на совместимость с выбранным Node LTS.
- Обновить TypeScript, ESLint, `@typescript-eslint/*`, Next.js/React tooling и backend runtime tooling до актуальных LTS/stable versions.
- Найти deprecated, unsupported и obsolete packages через package manager audit/outdated/deprecation warnings, npm metadata, release notes и project documentation.
- Для deprecated/unsupported/obsolete packages выбрать actively maintained mainstream alternatives с широкой adoption, свежими releases, нормальной TypeScript/ESM support и понятным migration path.
- Для каждой замены зафиксировать причину: что именно deprecated/unsupported/obsolete, какой replacement выбран, какие breaking changes ожидаются.
- Проверить DB scripts, migrations/seeders, test runner и production start после dependency updates.
- Не делать blind major upgrades без changelog/release notes и rollback plan.
### Node version
TODO:
- Зафиксировать выбранный Node LTS через `backend/package.json` `engines.node`.
- Согласовать `engines.node`.
- Обновить `@types/node` во frontend, когда будет удобно.
### CI/minimal checks
Для небольшого проекта достаточно минимального gate:
- frontend build;
- frontend lint/typecheck, если проходит;
- backend lint;
- smoke checklist перед deploy.
Audit/outdated/license checks делать периодически, не обязательно blocking на каждый PR.
## P2 - Security hardening
### CSP
@ -101,12 +54,6 @@ TODO:
- Если будет время, включить report-only CSP на stage.
- Enforced CSP делать только после проверки runtime presentations.
## P2 - Документация
TODO:
- Обновить docs только для реально изменённых workflows/API/schema.
## Рекомендуемый порядок
1. Добавить validation для самых рискованных endpoints и запретить новые routes без validation.

View File

@ -47,7 +47,7 @@
"@tailwindcss/forms": "^0.5.7",
"@types/file-saver": "^2.0.7",
"@types/lodash": "^4.17.24",
"@types/node": "18.7.16",
"@types/node": "^24.13.2",
"@types/react-dom": "^19.2.3",
"@typescript-eslint/eslint-plugin": "^8.62.1",
"@typescript-eslint/parser": "^8.62.1",
@ -2610,11 +2610,14 @@
"license": "MIT"
},
"node_modules/@types/node": {
"version": "18.7.16",
"resolved": "https://registry.npmjs.org/@types/node/-/node-18.7.16.tgz",
"integrity": "sha512-EQHhixfu+mkqHMZl1R2Ovuvn47PUw18azMJOTwSZr9/fhzHNGXAJ0ma0dayRVchprpCj0Kc1K1xKoWaATWF1qg==",
"version": "24.13.2",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.2.tgz",
"integrity": "sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA==",
"dev": true,
"license": "MIT"
"license": "MIT",
"dependencies": {
"undici-types": "~7.18.0"
}
},
"node_modules/@types/parse-json": {
"version": "4.0.2",
@ -9065,6 +9068,13 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/undici-types": {
"version": "7.18.2",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
"dev": true,
"license": "MIT"
},
"node_modules/unrs-resolver": {
"version": "1.12.2",
"resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.12.2.tgz",

View File

@ -60,7 +60,7 @@
"@tailwindcss/forms": "^0.5.7",
"@types/file-saver": "^2.0.7",
"@types/lodash": "^4.17.24",
"@types/node": "18.7.16",
"@types/node": "^24.13.2",
"@types/react-dom": "^19.2.3",
"@typescript-eslint/eslint-plugin": "^8.62.1",
"@typescript-eslint/parser": "^8.62.1",