Files
linuxcncWebWASM/web/workspace/runtime.mjs

123 lines
5.7 KiB
JavaScript

import { fnv1a64 } from "../worker/replay.mjs";
export const WORKSPACE_SCHEMA = "workspace-runtime.v1";
export const WORKSPACE_DEFAULT_QUOTA = 64 * 1024 * 1024;
function clone(value) { return structuredClone(value); }
export function normalizeWorkspacePath(value) {
if (typeof value !== "string" || value.length === 0 || value.includes("\0") || value.includes("\\")) throw new Error("workspace_path_invalid");
let path = value;
if (path === "/workspace") return "";
if (path.startsWith("/workspace/")) path = path.slice("/workspace/".length);
else if (path.startsWith("/")) throw new Error("workspace_host_path");
const parts = path.split("/").filter(Boolean);
if (parts.some((part) => part === "." || part === "..")) throw new Error("workspace_path_escape");
return parts.join("/");
}
function encoded(data) {
if (typeof data !== "string") throw new Error("workspace_data_invalid");
const bytes = new TextEncoder().encode(data);
if (new TextDecoder("utf-8", { fatal: true }).decode(bytes) !== data) throw new Error("workspace_encoding_invalid");
return bytes;
}
export class WorkspaceRuntime {
constructor({ quotaBytes = WORKSPACE_DEFAULT_QUOTA, files = {} } = {}) {
if (!Number.isSafeInteger(quotaBytes) || quotaBytes < 1) throw new Error("workspace_quota_invalid");
this.quotaBytes = quotaBytes;
this.files = new Map();
this.transactions = new Map();
for (const [path, data] of Object.entries(files)) this.files.set(normalizeWorkspacePath(path), data);
this.assertQuota(this.files);
}
assertQuota(files) {
let total = 0;
for (const [path, data] of files) {
if (!path) throw new Error("workspace_path_invalid");
const size = encoded(data).byteLength;
if (size > this.quotaBytes) throw new Error("workspace_file_quota");
total += size;
}
if (total > this.quotaBytes) throw new Error("workspace_quota_exceeded");
}
read(path) {
const normalized = normalizeWorkspacePath(path);
if (!this.files.has(normalized)) throw new Error("workspace_file_missing");
return this.files.get(normalized);
}
stat(path) {
const normalized = normalizeWorkspacePath(path);
if (!this.files.has(normalized)) throw new Error("workspace_file_missing");
const data = this.files.get(normalized);
return { path: `/workspace/${normalized}`, bytes: encoded(data).byteLength, data_hash: fnv1a64(data) };
}
list(prefix = "/workspace") {
const normalized = normalizeWorkspacePath(prefix);
return [...this.files.entries()].filter(([path]) => !normalized || path === normalized || path.startsWith(`${normalized}/`)).sort(([a], [b]) => a.localeCompare(b)).map(([path, data]) => ({ path: `/workspace/${path}`, bytes: encoded(data).byteLength, data_hash: fnv1a64(data) }));
}
begin(operationId) {
if (typeof operationId !== "string" || operationId.length === 0 || this.transactions.has(operationId)) throw new Error("workspace_transaction_invalid");
this.transactions.set(operationId, new Map());
return { schema: WORKSPACE_SCHEMA, operation_id: operationId, status: "prepared", state_hash: this.hash() };
}
write(operationId, path, data) {
const transaction = this.transactions.get(operationId);
if (!transaction) throw new Error("workspace_transaction_missing");
const normalized = normalizeWorkspacePath(path);
if (!normalized) throw new Error("workspace_path_invalid");
encoded(data);
transaction.set(normalized, { kind: "write", data });
return { operation_id: operationId, path: `/workspace/${normalized}`, bytes: encoded(data).byteLength };
}
remove(operationId, path) {
const transaction = this.transactions.get(operationId);
if (!transaction) throw new Error("workspace_transaction_missing");
const normalized = normalizeWorkspacePath(path);
if (!normalized) throw new Error("workspace_path_invalid");
transaction.set(normalized, { kind: "delete" });
return { operation_id: operationId, path: `/workspace/${normalized}`, status: "deleted" };
}
commit(operationId) {
const transaction = this.transactions.get(operationId);
if (!transaction) throw new Error("workspace_transaction_missing");
const candidate = new Map(this.files);
for (const [path, change] of [...transaction.entries()].sort(([a], [b]) => a.localeCompare(b))) {
if (change.kind === "delete") candidate.delete(path);
else candidate.set(path, change.data);
}
this.assertQuota(candidate);
this.files = candidate;
this.transactions.delete(operationId);
return { schema: WORKSPACE_SCHEMA, operation_id: operationId, status: "committed", state_hash: this.hash(), files: this.list() };
}
abort(operationId) {
if (!this.transactions.has(operationId)) throw new Error("workspace_transaction_missing");
this.transactions.delete(operationId);
return { schema: WORKSPACE_SCHEMA, operation_id: operationId, status: "aborted", state_hash: this.hash() };
}
hash() { return fnv1a64(Object.fromEntries([...this.files.entries()].sort(([a], [b]) => a.localeCompare(b)))); }
export() { return { schema: WORKSPACE_SCHEMA, quota_bytes: this.quotaBytes, files: Object.fromEntries([...this.files.entries()].sort(([a], [b]) => a.localeCompare(b))), state_hash: this.hash() }; }
restore(value) {
if (!value || value.schema !== WORKSPACE_SCHEMA || !value.files || value.state_hash !== fnv1a64(Object.fromEntries(Object.entries(value.files).sort(([a], [b]) => a.localeCompare(b))))) throw new Error("workspace_restore_invalid");
const candidate = new WorkspaceRuntime({ quotaBytes: value.quota_bytes, files: value.files });
this.quotaBytes = candidate.quotaBytes;
this.files = candidate.files;
this.transactions.clear();
return this.export();
}
}