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", "express": "^4.22.2",
"ffmpeg-static": "^5.2.0", "ffmpeg-static": "^5.2.0",
"ffprobe-static": "^3.1.0", "ffprobe-static": "^3.1.0",
"fluent-ffmpeg": "^2.1.3",
"helmet": "^8.0.0", "helmet": "^8.0.0",
"joi": "^17.13.0", "joi": "^17.13.0",
"json2csv": "^5.0.7", "json2csv": "^5.0.7",
@ -47,7 +46,6 @@
"@types/express": "^4.17.25", "@types/express": "^4.17.25",
"@types/express-serve-static-core": "^4.19.8", "@types/express-serve-static-core": "^4.19.8",
"@types/ffprobe-static": "^2.0.3", "@types/ffprobe-static": "^2.0.3",
"@types/fluent-ffmpeg": "^2.1.28",
"@types/json2csv": "^5.0.7", "@types/json2csv": "^5.0.7",
"@types/jsonwebtoken": "^9.0.10", "@types/jsonwebtoken": "^9.0.10",
"@types/multer": "^2.1.0", "@types/multer": "^2.1.0",
@ -2300,16 +2298,6 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/@types/http-errors": {
"version": "2.0.5", "version": "2.0.5",
"resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz",
@ -3517,11 +3505,6 @@
"node": ">=8" "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": { "node_modules/async-function": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz",
@ -5538,19 +5521,6 @@
"dev": true, "dev": true,
"license": "ISC" "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": { "node_modules/for-each": {
"version": "0.3.3", "version": "0.3.3",
"resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.3.tgz", "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.3.tgz",
@ -6667,6 +6637,7 @@
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
"dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/jju": { "node_modules/jju": {
@ -9831,18 +9802,6 @@
"webidl-conversions": "^3.0.0" "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": { "node_modules/which-boxed-primitive": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", "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", "express": "^4.22.2",
"ffmpeg-static": "^5.2.0", "ffmpeg-static": "^5.2.0",
"ffprobe-static": "^3.1.0", "ffprobe-static": "^3.1.0",
"fluent-ffmpeg": "^2.1.3",
"helmet": "^8.0.0", "helmet": "^8.0.0",
"joi": "^17.13.0", "joi": "^17.13.0",
"json2csv": "^5.0.7", "json2csv": "^5.0.7",
@ -85,7 +84,6 @@
"@types/express": "^4.17.25", "@types/express": "^4.17.25",
"@types/express-serve-static-core": "^4.19.8", "@types/express-serve-static-core": "^4.19.8",
"@types/ffprobe-static": "^2.0.3", "@types/ffprobe-static": "^2.0.3",
"@types/fluent-ffmpeg": "^2.1.28",
"@types/json2csv": "^5.0.7", "@types/json2csv": "^5.0.7",
"@types/jsonwebtoken": "^9.0.10", "@types/jsonwebtoken": "^9.0.10",
"@types/multer": "^2.1.0", "@types/multer": "^2.1.0",

View File

@ -6,11 +6,11 @@
*/ */
import { promises as fs } from 'fs'; import { promises as fs } from 'fs';
import { spawn } from 'node:child_process';
import { createRequire } from 'node:module'; import { createRequire } from 'node:module';
import os from 'os'; import os from 'os';
import path from 'path'; import path from 'path';
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 config from '../config.ts';
@ -22,11 +22,6 @@ const loadCommonJsModule = createRequire(import.meta.url);
const ffmpegStaticValue: unknown = loadCommonJsModule('ffmpeg-static'); const ffmpegStaticValue: unknown = loadCommonJsModule('ffmpeg-static');
const ffmpegPath = typeof ffmpegStaticValue === 'string' ? ffmpegStaticValue : null; const ffmpegPath = typeof ffmpegStaticValue === 'string' ? ffmpegStaticValue : null;
if (ffmpegPath) {
ffmpeg.setFfmpegPath(ffmpegPath);
}
ffmpeg.setFfprobePath(ffprobePath);
let ffmpegQueueTail: Promise<unknown> = Promise.resolve(); let ffmpegQueueTail: Promise<unknown> = Promise.resolve();
let queuedFfmpegJobs = 0; let queuedFfmpegJobs = 0;
let ffmpegJobSequence = 0; let ffmpegJobSequence = 0;
@ -41,6 +36,27 @@ const reverseVideoBreaker = new CircuitBreaker({
const FFMPEG_REVERSE_TIMEOUT_MS = config.resilience.ffmpeg.reverseTimeoutMs; const FFMPEG_REVERSE_TIMEOUT_MS = config.resilience.ffmpeg.reverseTimeoutMs;
const FFPROBE_TIMEOUT_MS = config.resilience.ffmpeg.ffprobeTimeoutMs; 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 { function parseFrameRate(value: unknown): number | null {
if (!value) { if (!value) {
return null; return null;
@ -86,6 +102,75 @@ function toError(value: unknown): Error {
return value instanceof Error ? value : new Error(String(value)); 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>( async function enqueueFfmpegJob<TResult>(
jobName: string, jobName: string,
runJob: FfmpegJobRunner<TResult>, runJob: FfmpegJobRunner<TResult>,
@ -167,59 +252,44 @@ async function reverseVideoWithoutQueue(
'Starting video reversal', 'Starting video reversal',
); );
await new Promise<void>((resolve, reject) => { const ffmpegArgs = [
const command = ffmpeg(inputPath) '-y',
.outputOptions([ '-i',
'-vf', inputPath,
'reverse', '-vf',
'-af', 'reverse',
'areverse', '-af',
'-c:v', 'areverse',
'libx264', '-c:v',
'-preset', 'libx264',
'fast', '-preset',
'-crf', 'fast',
'23', '-crf',
'-c:a', '23',
'aac', '-c:a',
'-threads', 'aac',
'1', '-threads',
'-movflags', '1',
'+faststart', '-movflags',
]) '+faststart',
.output(outputPath) 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 timeout = setTimeout(() => { logger.debug(
const error = new Error( { cmd: [getFfmpegExecutablePath(), ...ffmpegArgs].join(' ') },
`FFmpeg reverse video timed out after ${FFMPEG_REVERSE_TIMEOUT_MS}ms`, 'FFmpeg command',
); );
logger.error(
{ err: error, inputPath, outputPath },
'FFmpeg reverse video timeout',
);
command.kill('SIGKILL');
reject(error);
}, FFMPEG_REVERSE_TIMEOUT_MS);
timeout.unref();
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); const reversedBuffer = await fs.readFile(outputPath);
let outputMetadata: MediaMetadata | null = null; let outputMetadata: MediaMetadata | null = null;
@ -253,59 +323,74 @@ async function reverseVideoWithoutQueue(
} }
async function probeMediaMetadata(filePath: string): Promise<MediaMetadata> { async function probeMediaMetadata(filePath: string): Promise<MediaMetadata> {
return new Promise((resolve, reject) => { const result = await runProcess(
const timeout = setTimeout(() => { ffprobePath,
reject( [
new Error(`FFprobe timed out after ${FFPROBE_TIMEOUT_MS}ms: ${filePath}`), '-v',
); 'error',
}, FFPROBE_TIMEOUT_MS); '-print_format',
timeout.unref(); '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) => { let metadata: MediaProbeOutput;
clearTimeout(timeout); try {
if (err) { const parsedMetadata: unknown = JSON.parse(result.stdout);
reject(toError(err)); if (!isMediaProbeOutput(parsedMetadata)) {
return; 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 = const streams = metadata.streams ?? [];
metadata.streams.find((stream) => stream.codec_type === 'video') ?? const videoStream =
null; streams.find((stream) => stream.codec_type === 'video') ?? null;
const audioStream = const audioStream =
metadata.streams.find((stream) => stream.codec_type === 'audio') ?? streams.find((stream) => stream.codec_type === 'audio') ?? null;
null; const primaryStream = videoStream ?? audioStream;
const primaryStream = videoStream ?? audioStream; const formatDuration = Number(metadata.format?.duration);
const formatDuration = Number(metadata.format.duration); const streamDuration = Number(primaryStream?.duration);
const streamDuration = Number(primaryStream?.duration); const durationSec =
const durationSec = Number.isFinite(formatDuration) && formatDuration > 0
Number.isFinite(formatDuration) && formatDuration > 0 ? formatDuration
? formatDuration : Number.isFinite(streamDuration) && streamDuration > 0
: Number.isFinite(streamDuration) && streamDuration > 0 ? streamDuration
? streamDuration : null;
: null;
const widthPx = Number(videoStream?.width); const widthPx = Number(videoStream?.width);
const heightPx = Number(videoStream?.height); const heightPx = Number(videoStream?.height);
const frameRate = const frameRate =
parseFrameRate(videoStream?.avg_frame_rate) ?? parseFrameRate(videoStream?.avg_frame_rate) ??
parseFrameRate(videoStream?.r_frame_rate); parseFrameRate(videoStream?.r_frame_rate);
resolve({ return {
durationSec, durationSec,
widthPx: Number.isFinite(widthPx) && widthPx > 0 ? widthPx : null, widthPx: Number.isFinite(widthPx) && widthPx > 0 ? widthPx : null,
heightPx: Number.isFinite(heightPx) && heightPx > 0 ? heightPx : null, heightPx: Number.isFinite(heightPx) && heightPx > 0 ? heightPx : null,
frameRate, frameRate,
}); };
});
});
} }
async function isFFmpegAvailable(): Promise<boolean> { async function isFFmpegAvailable(): Promise<boolean> {
return new Promise((resolve) => { try {
ffmpeg.getAvailableFormats((err: unknown) => { await runProcess(getFfmpegExecutablePath(), ['-hide_banner', '-formats'], {
resolve(!err); 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 }; 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 ## Node Version
The project requirement is Node.js 20.x LTS. Some standard VMs may report The project requirement is Node.js 24.x LTS. Some standard VMs may report
`/usr/bin/node` as Node 22 in PM2. If startup fails after a system update, a different `/usr/bin/node` in PM2. If startup fails after a system update,
verify: verify:
```bash ```bash

View File

@ -42,53 +42,6 @@ TODO:
- Если нужен, оставить `localStorage`, но осознанно и с коротким TTL/refresh policy. - Если нужен, оставить `localStorage`, но осознанно и с коротким TTL/refresh policy.
- Заменить frontend `jsonwebtoken` decode на `jwt-decode` или `/auth/me`. - Заменить 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 ## P2 - Security hardening
### CSP ### CSP
@ -101,12 +54,6 @@ TODO:
- Если будет время, включить report-only CSP на stage. - Если будет время, включить report-only CSP на stage.
- Enforced CSP делать только после проверки runtime presentations. - Enforced CSP делать только после проверки runtime presentations.
## P2 - Документация
TODO:
- Обновить docs только для реально изменённых workflows/API/schema.
## Рекомендуемый порядок ## Рекомендуемый порядок
1. Добавить validation для самых рискованных endpoints и запретить новые routes без validation. 1. Добавить validation для самых рискованных endpoints и запретить новые routes без validation.

View File

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

View File

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