feat: implement Layer 2 per-file transit, RAM session, mock buffer, and tests (v0.2.0)
This commit is contained in:
Vendored
+200
-1
@@ -805,6 +805,203 @@ function onFolderChange(state, cfg, elements) {
|
||||
};
|
||||
}
|
||||
|
||||
// upload/frontend/upload/put_to_vm.js
|
||||
function putToVm(file, url, options = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
if (options.onXHR) {
|
||||
options.onXHR(xhr);
|
||||
}
|
||||
const abortHandler = () => {
|
||||
xhr.abort();
|
||||
reject(new DOMException("Upload aborted", "AbortError"));
|
||||
};
|
||||
if (options.signal) {
|
||||
if (options.signal.aborted) {
|
||||
reject(new DOMException("Upload aborted", "AbortError"));
|
||||
return;
|
||||
}
|
||||
options.signal.addEventListener("abort", abortHandler, { once: true });
|
||||
}
|
||||
xhr.open("PUT", url);
|
||||
xhr.timeout = options.timeoutMs || 3e5;
|
||||
xhr.upload.onprogress = (e) => {
|
||||
if (e.lengthComputable && options.onProgress) {
|
||||
const pct = Math.round(e.loaded / e.total * 100);
|
||||
options.onProgress(pct, e.loaded, e.total);
|
||||
}
|
||||
};
|
||||
xhr.onload = () => {
|
||||
if (options.signal) {
|
||||
options.signal.removeEventListener("abort", abortHandler);
|
||||
}
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
resolve();
|
||||
} else {
|
||||
reject(new Error(`\u0411\u0443\u0444\u0435\u0440: HTTP ${xhr.status} ${xhr.statusText}`));
|
||||
}
|
||||
};
|
||||
xhr.onerror = () => {
|
||||
if (options.signal) {
|
||||
options.signal.removeEventListener("abort", abortHandler);
|
||||
}
|
||||
reject(new Error("\u0421\u0435\u0442\u0435\u0432\u0430\u044F \u043E\u0448\u0438\u0431\u043A\u0430 \u043F\u0440\u0438 \u043E\u0442\u043F\u0440\u0430\u0432\u043A\u0435 \u0432 \u0431\u0443\u0444\u0435\u0440"));
|
||||
};
|
||||
xhr.ontimeout = () => {
|
||||
if (options.signal) {
|
||||
options.signal.removeEventListener("abort", abortHandler);
|
||||
}
|
||||
reject(new Error(`\u0422\u0430\u0439\u043C\u0430\u0443\u0442 \u043E\u0436\u0438\u0434\u0430\u043D\u0438\u044F \u043E\u0442\u0432\u0435\u0442\u0430 \u0431\u0443\u0444\u0435\u0440\u0430 (${Math.round(xhr.timeout / 1e3)}\u0441)`));
|
||||
};
|
||||
xhr.onabort = () => {
|
||||
if (options.signal) {
|
||||
options.signal.removeEventListener("abort", abortHandler);
|
||||
}
|
||||
reject(new DOMException("Upload aborted", "AbortError"));
|
||||
};
|
||||
xhr.send(file);
|
||||
});
|
||||
}
|
||||
|
||||
// upload/frontend/upload/upload_via_vm.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 & 3 | 8;
|
||||
return v.toString(16);
|
||||
});
|
||||
}
|
||||
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("\u041D\u0435\u043A\u043E\u0440\u0440\u0435\u043A\u0442\u043D\u044B\u0439 \u043E\u0431\u044A\u0435\u043A\u0442 \u0444\u0430\u0439\u043B\u0430");
|
||||
}
|
||||
async function uploadViaVM(files, options = {}) {
|
||||
if (!options.vmUploadUrl) {
|
||||
return { ok: false, error: "\u041F\u0430\u0440\u0430\u043C\u0435\u0442\u0440 options.vmUploadUrl \u043E\u0431\u044F\u0437\u0430\u0442\u0435\u043B\u0435\u043D" };
|
||||
}
|
||||
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: "\u0417\u0430\u0433\u0440\u0443\u0437\u043A\u0430 \u043E\u0442\u043C\u0435\u043D\u0435\u043D\u0430", aborted: true, session: currentSession };
|
||||
}
|
||||
const { name, size, file } = normalizedFiles[k];
|
||||
const fileKey = `${generateUuid()}_${k}`;
|
||||
const fileUrl = `${vmBase}${fileKey}`;
|
||||
if (options.onStatus) {
|
||||
options.onStatus(`\u0417\u0430\u0433\u0440\u0443\u0437\u043A\u0430 \u0432 \u0431\u0443\u0444\u0435\u0440 (${k + 1}/${total}): ${name}`);
|
||||
}
|
||||
if (options.onFileStatus) {
|
||||
options.onFileStatus(k, "\u23F3 \u0412 \u0431\u0443\u0444\u0435\u0440...");
|
||||
}
|
||||
const t0 = performance.now();
|
||||
try {
|
||||
await putToVm(file, fileUrl, {
|
||||
signal: options.signal,
|
||||
onXHR: options.onXHR,
|
||||
onProgress(pct, loaded, fileTotal) {
|
||||
const elapsed = (performance.now() - t0) / 1e3;
|
||||
const speed = elapsed > 0 ? loaded / elapsed : 0;
|
||||
if (options.onFileStatus) {
|
||||
options.onFileStatus(k, `\u23F3 ${pct}% (${fs(speed)}/s)`);
|
||||
}
|
||||
if (options.onProgress) {
|
||||
options.onProgress({ index: k, total, pct, speed, loaded, fileTotal, name });
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (err2) {
|
||||
if (options.signal?.aborted) {
|
||||
return { ok: false, error: "\u0417\u0430\u0433\u0440\u0443\u0437\u043A\u0430 \u043E\u0442\u043C\u0435\u043D\u0435\u043D\u0430", aborted: true, session: currentSession };
|
||||
}
|
||||
if (options.onFileStatus) {
|
||||
options.onFileStatus(k, "\u2717 \u041E\u0448\u0438\u0431\u043A\u0430 \u0431\u0443\u0444\u0435\u0440\u0430");
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
error: `\u041E\u0448\u0438\u0431\u043A\u0430 \u043E\u0442\u043F\u0440\u0430\u0432\u043A\u0438 \u0444\u0430\u0439\u043B\u0430 ${name} \u0432 \u0431\u0443\u0444\u0435\u0440: ${err2.message}`,
|
||||
failedIndex: k,
|
||||
session: currentSession
|
||||
};
|
||||
}
|
||||
if (options.onStatus) {
|
||||
options.onStatus(`\u041F\u0440\u0438\u0451\u043C \u0441\u0435\u0440\u0432\u0438\u0441\u043E\u043C (${k + 1}/${total}): ${name}`);
|
||||
}
|
||||
if (options.onFileStatus) {
|
||||
options.onFileStatus(k, "\u26A1 \u0412 \u0441\u0435\u0441\u0441\u0438\u044E...");
|
||||
}
|
||||
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 || "\u0411\u044D\u043A\u0435\u043D\u0434 \u0432\u0435\u0440\u043D\u0443\u043B \u043E\u0448\u0438\u0431\u043A\u0443");
|
||||
}
|
||||
currentSession = data.session || currentSession;
|
||||
totalAdded = data.count ?? totalAdded + 1;
|
||||
if (options.onFileStatus) {
|
||||
options.onFileStatus(k, "\u2713 \u0414\u043E\u0441\u0442\u0430\u0432\u043B\u0435\u043D");
|
||||
}
|
||||
if (options.onFileComplete) {
|
||||
options.onFileComplete({
|
||||
fileIndex: k,
|
||||
name,
|
||||
size,
|
||||
session: currentSession,
|
||||
totalAdded
|
||||
});
|
||||
}
|
||||
} catch (err2) {
|
||||
if (options.signal?.aborted) {
|
||||
return { ok: false, error: "\u0417\u0430\u0433\u0440\u0443\u0437\u043A\u0430 \u043E\u0442\u043C\u0435\u043D\u0435\u043D\u0430", aborted: true, session: currentSession };
|
||||
}
|
||||
if (options.onFileStatus) {
|
||||
options.onFileStatus(k, "\u2717 \u041E\u0448\u0438\u0431\u043A\u0430 \u043F\u0440\u0438\u0451\u043C\u0430");
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
error: `\u041E\u0448\u0438\u0431\u043A\u0430 \u043F\u0440\u0438\u0451\u043C\u0430 \u0444\u0430\u0439\u043B\u0430 ${name} \u0431\u044D\u043A\u0435\u043D\u0434\u043E\u043C: ${err2.message}`,
|
||||
failedIndex: k,
|
||||
session: currentSession
|
||||
};
|
||||
}
|
||||
}
|
||||
if (options.onStatus) {
|
||||
options.onStatus(`\u0417\u0430\u0432\u0435\u0440\u0448\u0435\u043D\u043E. \u0423\u0441\u043F\u0435\u0448\u043D\u043E \u043F\u0435\u0440\u0435\u0434\u0430\u043D\u043E \u0444\u0430\u0439\u043B\u043E\u0432: ${total}`);
|
||||
}
|
||||
return { ok: true, session: currentSession, count: totalAdded };
|
||||
}
|
||||
|
||||
// upload/frontend/index.js
|
||||
var DEFAULTS = {
|
||||
allowedExt: [],
|
||||
@@ -991,5 +1188,7 @@ function initFilePicker(config) {
|
||||
}
|
||||
export {
|
||||
DEFAULTS,
|
||||
initFilePicker
|
||||
initFilePicker,
|
||||
putToVm,
|
||||
uploadViaVM
|
||||
};
|
||||
|
||||
Vendored
+200
-1
@@ -21,7 +21,9 @@ var FilePicker = (() => {
|
||||
var index_exports = {};
|
||||
__export(index_exports, {
|
||||
DEFAULTS: () => DEFAULTS,
|
||||
initFilePicker: () => initFilePicker
|
||||
initFilePicker: () => initFilePicker,
|
||||
putToVm: () => putToVm,
|
||||
uploadViaVM: () => uploadViaVM
|
||||
});
|
||||
|
||||
// node_modules/fflate/esm/browser.js
|
||||
@@ -831,6 +833,203 @@ var FilePicker = (() => {
|
||||
};
|
||||
}
|
||||
|
||||
// upload/frontend/upload/put_to_vm.js
|
||||
function putToVm(file, url, options = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
if (options.onXHR) {
|
||||
options.onXHR(xhr);
|
||||
}
|
||||
const abortHandler = () => {
|
||||
xhr.abort();
|
||||
reject(new DOMException("Upload aborted", "AbortError"));
|
||||
};
|
||||
if (options.signal) {
|
||||
if (options.signal.aborted) {
|
||||
reject(new DOMException("Upload aborted", "AbortError"));
|
||||
return;
|
||||
}
|
||||
options.signal.addEventListener("abort", abortHandler, { once: true });
|
||||
}
|
||||
xhr.open("PUT", url);
|
||||
xhr.timeout = options.timeoutMs || 3e5;
|
||||
xhr.upload.onprogress = (e) => {
|
||||
if (e.lengthComputable && options.onProgress) {
|
||||
const pct = Math.round(e.loaded / e.total * 100);
|
||||
options.onProgress(pct, e.loaded, e.total);
|
||||
}
|
||||
};
|
||||
xhr.onload = () => {
|
||||
if (options.signal) {
|
||||
options.signal.removeEventListener("abort", abortHandler);
|
||||
}
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
resolve();
|
||||
} else {
|
||||
reject(new Error(`\u0411\u0443\u0444\u0435\u0440: HTTP ${xhr.status} ${xhr.statusText}`));
|
||||
}
|
||||
};
|
||||
xhr.onerror = () => {
|
||||
if (options.signal) {
|
||||
options.signal.removeEventListener("abort", abortHandler);
|
||||
}
|
||||
reject(new Error("\u0421\u0435\u0442\u0435\u0432\u0430\u044F \u043E\u0448\u0438\u0431\u043A\u0430 \u043F\u0440\u0438 \u043E\u0442\u043F\u0440\u0430\u0432\u043A\u0435 \u0432 \u0431\u0443\u0444\u0435\u0440"));
|
||||
};
|
||||
xhr.ontimeout = () => {
|
||||
if (options.signal) {
|
||||
options.signal.removeEventListener("abort", abortHandler);
|
||||
}
|
||||
reject(new Error(`\u0422\u0430\u0439\u043C\u0430\u0443\u0442 \u043E\u0436\u0438\u0434\u0430\u043D\u0438\u044F \u043E\u0442\u0432\u0435\u0442\u0430 \u0431\u0443\u0444\u0435\u0440\u0430 (${Math.round(xhr.timeout / 1e3)}\u0441)`));
|
||||
};
|
||||
xhr.onabort = () => {
|
||||
if (options.signal) {
|
||||
options.signal.removeEventListener("abort", abortHandler);
|
||||
}
|
||||
reject(new DOMException("Upload aborted", "AbortError"));
|
||||
};
|
||||
xhr.send(file);
|
||||
});
|
||||
}
|
||||
|
||||
// upload/frontend/upload/upload_via_vm.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 & 3 | 8;
|
||||
return v.toString(16);
|
||||
});
|
||||
}
|
||||
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("\u041D\u0435\u043A\u043E\u0440\u0440\u0435\u043A\u0442\u043D\u044B\u0439 \u043E\u0431\u044A\u0435\u043A\u0442 \u0444\u0430\u0439\u043B\u0430");
|
||||
}
|
||||
async function uploadViaVM(files, options = {}) {
|
||||
if (!options.vmUploadUrl) {
|
||||
return { ok: false, error: "\u041F\u0430\u0440\u0430\u043C\u0435\u0442\u0440 options.vmUploadUrl \u043E\u0431\u044F\u0437\u0430\u0442\u0435\u043B\u0435\u043D" };
|
||||
}
|
||||
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: "\u0417\u0430\u0433\u0440\u0443\u0437\u043A\u0430 \u043E\u0442\u043C\u0435\u043D\u0435\u043D\u0430", aborted: true, session: currentSession };
|
||||
}
|
||||
const { name, size, file } = normalizedFiles[k];
|
||||
const fileKey = `${generateUuid()}_${k}`;
|
||||
const fileUrl = `${vmBase}${fileKey}`;
|
||||
if (options.onStatus) {
|
||||
options.onStatus(`\u0417\u0430\u0433\u0440\u0443\u0437\u043A\u0430 \u0432 \u0431\u0443\u0444\u0435\u0440 (${k + 1}/${total}): ${name}`);
|
||||
}
|
||||
if (options.onFileStatus) {
|
||||
options.onFileStatus(k, "\u23F3 \u0412 \u0431\u0443\u0444\u0435\u0440...");
|
||||
}
|
||||
const t0 = performance.now();
|
||||
try {
|
||||
await putToVm(file, fileUrl, {
|
||||
signal: options.signal,
|
||||
onXHR: options.onXHR,
|
||||
onProgress(pct, loaded, fileTotal) {
|
||||
const elapsed = (performance.now() - t0) / 1e3;
|
||||
const speed = elapsed > 0 ? loaded / elapsed : 0;
|
||||
if (options.onFileStatus) {
|
||||
options.onFileStatus(k, `\u23F3 ${pct}% (${fs(speed)}/s)`);
|
||||
}
|
||||
if (options.onProgress) {
|
||||
options.onProgress({ index: k, total, pct, speed, loaded, fileTotal, name });
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (err2) {
|
||||
if (options.signal?.aborted) {
|
||||
return { ok: false, error: "\u0417\u0430\u0433\u0440\u0443\u0437\u043A\u0430 \u043E\u0442\u043C\u0435\u043D\u0435\u043D\u0430", aborted: true, session: currentSession };
|
||||
}
|
||||
if (options.onFileStatus) {
|
||||
options.onFileStatus(k, "\u2717 \u041E\u0448\u0438\u0431\u043A\u0430 \u0431\u0443\u0444\u0435\u0440\u0430");
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
error: `\u041E\u0448\u0438\u0431\u043A\u0430 \u043E\u0442\u043F\u0440\u0430\u0432\u043A\u0438 \u0444\u0430\u0439\u043B\u0430 ${name} \u0432 \u0431\u0443\u0444\u0435\u0440: ${err2.message}`,
|
||||
failedIndex: k,
|
||||
session: currentSession
|
||||
};
|
||||
}
|
||||
if (options.onStatus) {
|
||||
options.onStatus(`\u041F\u0440\u0438\u0451\u043C \u0441\u0435\u0440\u0432\u0438\u0441\u043E\u043C (${k + 1}/${total}): ${name}`);
|
||||
}
|
||||
if (options.onFileStatus) {
|
||||
options.onFileStatus(k, "\u26A1 \u0412 \u0441\u0435\u0441\u0441\u0438\u044E...");
|
||||
}
|
||||
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 || "\u0411\u044D\u043A\u0435\u043D\u0434 \u0432\u0435\u0440\u043D\u0443\u043B \u043E\u0448\u0438\u0431\u043A\u0443");
|
||||
}
|
||||
currentSession = data.session || currentSession;
|
||||
totalAdded = data.count ?? totalAdded + 1;
|
||||
if (options.onFileStatus) {
|
||||
options.onFileStatus(k, "\u2713 \u0414\u043E\u0441\u0442\u0430\u0432\u043B\u0435\u043D");
|
||||
}
|
||||
if (options.onFileComplete) {
|
||||
options.onFileComplete({
|
||||
fileIndex: k,
|
||||
name,
|
||||
size,
|
||||
session: currentSession,
|
||||
totalAdded
|
||||
});
|
||||
}
|
||||
} catch (err2) {
|
||||
if (options.signal?.aborted) {
|
||||
return { ok: false, error: "\u0417\u0430\u0433\u0440\u0443\u0437\u043A\u0430 \u043E\u0442\u043C\u0435\u043D\u0435\u043D\u0430", aborted: true, session: currentSession };
|
||||
}
|
||||
if (options.onFileStatus) {
|
||||
options.onFileStatus(k, "\u2717 \u041E\u0448\u0438\u0431\u043A\u0430 \u043F\u0440\u0438\u0451\u043C\u0430");
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
error: `\u041E\u0448\u0438\u0431\u043A\u0430 \u043F\u0440\u0438\u0451\u043C\u0430 \u0444\u0430\u0439\u043B\u0430 ${name} \u0431\u044D\u043A\u0435\u043D\u0434\u043E\u043C: ${err2.message}`,
|
||||
failedIndex: k,
|
||||
session: currentSession
|
||||
};
|
||||
}
|
||||
}
|
||||
if (options.onStatus) {
|
||||
options.onStatus(`\u0417\u0430\u0432\u0435\u0440\u0448\u0435\u043D\u043E. \u0423\u0441\u043F\u0435\u0448\u043D\u043E \u043F\u0435\u0440\u0435\u0434\u0430\u043D\u043E \u0444\u0430\u0439\u043B\u043E\u0432: ${total}`);
|
||||
}
|
||||
return { ok: true, session: currentSession, count: totalAdded };
|
||||
}
|
||||
|
||||
// upload/frontend/index.js
|
||||
var DEFAULTS = {
|
||||
allowedExt: [],
|
||||
|
||||
Reference in New Issue
Block a user