189 lines
7.5 KiB
JavaScript
189 lines
7.5 KiB
JavaScript
/**
|
||
* uploadViaVM — Слой 2 (Фронтенд): пофайловый транзит через ВМ-буфер в сессию бэкенда.
|
||
*
|
||
* Архитектура потокового транзита:
|
||
* Для каждого файла k:
|
||
* 1. Браузер отправляет файл k методом PUT в буфер на ВМ.
|
||
* 2. Браузер сразу делает POST /api/upload_refs для одного файла k.
|
||
* 3. Бэкенд забирает файл исходящим GET в память RAM и сразу шлёт DELETE в буфер.
|
||
* 4. Файл удалён из буфера ВМ, память чиста.
|
||
* 5. Браузер переходит к файлу k+1.
|
||
*/
|
||
|
||
import { putToVm } from './put_to_vm.js';
|
||
import { fs } from '../table/fs.js';
|
||
|
||
function generateUuid() {
|
||
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
||
return crypto.randomUUID();
|
||
}
|
||
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
|
||
const r = (Math.random() * 16) | 0;
|
||
const v = c === 'x' ? r : (r & 0x3) | 0x8;
|
||
return v.toString(16);
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Нормализует элемент списка файлов (поддерживает как File, так и объект из FilePicker.getFiles()).
|
||
*/
|
||
function normalizeFileEntry(item) {
|
||
if (item instanceof File || item instanceof Blob) {
|
||
return { name: item.name || 'unnamed', size: item.size, file: item, path: item.name || '' };
|
||
}
|
||
if (item && item.file) {
|
||
return {
|
||
name: item.name || item.file.name || 'unnamed',
|
||
size: item.size ?? item.file.size ?? 0,
|
||
file: item.file,
|
||
path: item.path || item.name || '',
|
||
};
|
||
}
|
||
throw new Error('Некорректный объект файла');
|
||
}
|
||
|
||
/**
|
||
* Выполняет пофайловую транзитную загрузку файлов в сессию бэкенда через буфер.
|
||
*
|
||
* @param {Array<File|{name: string, size: number, file: File, path?: string}>} files Список файлов.
|
||
* @param {Object} options Параметры загрузки:
|
||
* @param {string} options.vmUploadUrl Базовый URL буфера (обязателен).
|
||
* @param {string} [options.backendUploadUrl='/api/upload_refs'] URL эндпоинта приёма ссылок.
|
||
* @param {string} [options.session=''] Идентификатор существующей сессии (если есть).
|
||
* @param {AbortSignal} [options.signal] Сигнал отмены загрузки.
|
||
* @param {function(string): void} [options.onStatus] Колбэк общего текстового статуса.
|
||
* @param {function(number, string): void} [options.onFileStatus] Колбэк обновления статуса конкретного файла (индекс, текст/html).
|
||
* @param {function(Object): void} [options.onProgress] Колбэк прогресса текущего файла ({ index, total, pct, speed }).
|
||
* @param {function(Object): void} [options.onFileComplete] Колбэк успешной доставки файла ({ index, name, session, totalAdded }).
|
||
* @returns {Promise<{ok: boolean, session?: string, count?: number, error?: string, aborted?: boolean}>}
|
||
*/
|
||
export async function uploadViaVM(files, options = {}) {
|
||
if (!options.vmUploadUrl) {
|
||
return { ok: false, error: 'Параметр options.vmUploadUrl обязателен' };
|
||
}
|
||
|
||
const normalizedFiles = Array.from(files).map(normalizeFileEntry);
|
||
const total = normalizedFiles.length;
|
||
if (total === 0) {
|
||
return { ok: true, session: options.session || '', count: 0 };
|
||
}
|
||
|
||
const backendUrl = options.backendUploadUrl || (options.apiBase || '') + '/api/upload_refs';
|
||
const vmBase = options.vmUploadUrl.endsWith('/') ? options.vmUploadUrl : `${options.vmUploadUrl}/`;
|
||
let currentSession = options.session || '';
|
||
let totalAdded = 0;
|
||
|
||
for (let k = 0; k < total; k++) {
|
||
if (options.signal?.aborted) {
|
||
return { ok: false, error: 'Загрузка отменена', aborted: true, session: currentSession };
|
||
}
|
||
|
||
const { name, size, file } = normalizedFiles[k];
|
||
const fileKey = `${generateUuid()}_${k}`;
|
||
const fileUrl = `${vmBase}${fileKey}`;
|
||
|
||
if (options.onStatus) {
|
||
options.onStatus(`Загрузка в буфер (${k + 1}/${total}): ${name}`);
|
||
}
|
||
if (options.onFileStatus) {
|
||
options.onFileStatus(k, '⏳ В буфер...');
|
||
}
|
||
|
||
// Этап 1: Отправка одного файла в буфер
|
||
const t0 = performance.now();
|
||
try {
|
||
await putToVm(file, fileUrl, {
|
||
signal: options.signal,
|
||
onXHR: options.onXHR,
|
||
onProgress(pct, loaded, fileTotal) {
|
||
const elapsed = (performance.now() - t0) / 1000;
|
||
const speed = elapsed > 0 ? (loaded / elapsed) : 0;
|
||
if (options.onFileStatus) {
|
||
options.onFileStatus(k, `⏳ ${pct}% (${fs(speed)}/s)`);
|
||
}
|
||
if (options.onProgress) {
|
||
options.onProgress({ index: k, total, pct, speed, loaded, fileTotal, name });
|
||
}
|
||
},
|
||
});
|
||
} catch (err) {
|
||
if (options.signal?.aborted) {
|
||
return { ok: false, error: 'Загрузка отменена', aborted: true, session: currentSession };
|
||
}
|
||
if (options.onFileStatus) {
|
||
options.onFileStatus(k, '✗ Ошибка буфера');
|
||
}
|
||
return {
|
||
ok: false,
|
||
error: `Ошибка отправки файла ${name} в буфер: ${err.message}`,
|
||
failedIndex: k,
|
||
session: currentSession,
|
||
};
|
||
}
|
||
|
||
// Этап 2: Вызов /api/upload_refs для немедленного переноса файла с буфера в RAM сессии
|
||
if (options.onStatus) {
|
||
options.onStatus(`Приём сервисом (${k + 1}/${total}): ${name}`);
|
||
}
|
||
if (options.onFileStatus) {
|
||
options.onFileStatus(k, '⚡ В сессию...');
|
||
}
|
||
|
||
try {
|
||
const resp = await fetch(backendUrl, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
session: currentSession,
|
||
files: [{ name, size, url: fileUrl }],
|
||
}),
|
||
signal: options.signal,
|
||
});
|
||
|
||
if (!resp.ok) {
|
||
throw new Error(`HTTP ${resp.status} ${resp.statusText}`);
|
||
}
|
||
|
||
const data = await resp.json();
|
||
if (!data.ok) {
|
||
throw new Error(data.error || 'Бэкенд вернул ошибку');
|
||
}
|
||
|
||
currentSession = data.session || currentSession;
|
||
totalAdded = data.count ?? (totalAdded + 1);
|
||
|
||
if (options.onFileStatus) {
|
||
options.onFileStatus(k, '✓ Доставлен');
|
||
}
|
||
if (options.onFileComplete) {
|
||
options.onFileComplete({
|
||
fileIndex: k,
|
||
name,
|
||
size,
|
||
session: currentSession,
|
||
totalAdded,
|
||
});
|
||
}
|
||
} catch (err) {
|
||
if (options.signal?.aborted) {
|
||
return { ok: false, error: 'Загрузка отменена', aborted: true, session: currentSession };
|
||
}
|
||
if (options.onFileStatus) {
|
||
options.onFileStatus(k, '✗ Ошибка приёма');
|
||
}
|
||
return {
|
||
ok: false,
|
||
error: `Ошибка приёма файла ${name} бэкендом: ${err.message}`,
|
||
failedIndex: k,
|
||
session: currentSession,
|
||
};
|
||
}
|
||
}
|
||
|
||
if (options.onStatus) {
|
||
options.onStatus(`Завершено. Успешно передано файлов: ${total}`);
|
||
}
|
||
|
||
return { ok: true, session: currentSession, count: totalAdded };
|
||
}
|