feat: implement Layer 2 per-file transit, RAM session, mock buffer, and tests (v0.2.0)

This commit is contained in:
“Naeel”
2026-09-06 12:01:56 +03:00
parent 0ea7ba058c
commit 112c84f11a
34 changed files with 2200 additions and 162 deletions
+200 -1
View File
@@ -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
};
+200 -1
View File
@@ -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: [],