123 lines
5.7 KiB
JavaScript
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();
|
|
}
|
|
}
|