dofs-term
Version:
A terminal emulator for Cloudflare Durable Objects.
549 lines (543 loc) • 21.5 kB
JavaScript
var __create = Object.create;
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __getOwnPropNames = Object.getOwnPropertyNames;
var __getProtoOf = Object.getPrototypeOf;
var __hasOwnProp = Object.prototype.hasOwnProperty;
var __commonJS = (cb, mod) => function __require() {
return mod || (0, cb[__getOwnPropNames(cb)[0]])((mod = { exports: {} }).exports, mod), mod.exports;
};
var __copyProps = (to, from, except, desc) => {
if (from && typeof from === "object" || typeof from === "function") {
for (let key of __getOwnPropNames(from))
if (!__hasOwnProp.call(to, key) && key !== except)
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
}
return to;
};
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
// If the importer is in node compatibility mode or this is not an ESM
// file that has been converted to a CommonJS file using a Babel-
// compatible transform (i.e. "__esModule" has not been set), then set
// "default" to the CommonJS "module.exports" for node compatibility.
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
mod
));
// ../../node_modules/@xterm/addon-fit/lib/addon-fit.js
var require_addon_fit = __commonJS({
"../../node_modules/@xterm/addon-fit/lib/addon-fit.js"(exports, module) {
"use strict";
!function(e, t) {
"object" == typeof exports && "object" == typeof module ? module.exports = t() : "function" == typeof define && define.amd ? define([], t) : "object" == typeof exports ? exports.FitAddon = t() : e.FitAddon = t();
}(self, () => (() => {
"use strict";
var e = {};
return (() => {
var t = e;
Object.defineProperty(t, "__esModule", { value: true }), t.FitAddon = void 0, t.FitAddon = class {
activate(e2) {
this._terminal = e2;
}
dispose() {
}
fit() {
const e2 = this.proposeDimensions();
if (!e2 || !this._terminal || isNaN(e2.cols) || isNaN(e2.rows)) return;
const t2 = this._terminal._core;
this._terminal.rows === e2.rows && this._terminal.cols === e2.cols || (t2._renderService.clear(), this._terminal.resize(e2.cols, e2.rows));
}
proposeDimensions() {
if (!this._terminal) return;
if (!this._terminal.element || !this._terminal.element.parentElement) return;
const e2 = this._terminal._core, t2 = e2._renderService.dimensions;
if (0 === t2.css.cell.width || 0 === t2.css.cell.height) return;
const r = 0 === this._terminal.options.scrollback ? 0 : e2.viewport.scrollBarWidth, i = window.getComputedStyle(this._terminal.element.parentElement), o = parseInt(i.getPropertyValue("height")), s = Math.max(0, parseInt(i.getPropertyValue("width"))), n = window.getComputedStyle(this._terminal.element), l = o - (parseInt(n.getPropertyValue("padding-top")) + parseInt(n.getPropertyValue("padding-bottom"))), a = s - (parseInt(n.getPropertyValue("padding-right")) + parseInt(n.getPropertyValue("padding-left"))) - r;
return { cols: Math.max(2, Math.floor(a / t2.css.cell.width)), rows: Math.max(1, Math.floor(l / t2.css.cell.height)) };
}
};
})(), e;
})());
}
});
// src/vanilla/DTermElement.ts
var import_addon_fit = __toESM(require_addon_fit());
import { Terminal } from "@xterm/xterm";
// src/vanilla/dterm.ts
import "@xterm/xterm/css/xterm.css";
var dterm = (term, options) => {
const {
extraCommands,
initialMessage = 'Connected to Durable Object File System! Type "help" for commands.',
prompt = "$ ",
url
} = options || {};
term.writeln(initialMessage);
let cwd = "/";
term.write(`${cwd} ${prompt}`);
let buffer = "";
let history = [];
let historyIndex = -1;
let autocompleteState = null;
function resolvePath(inputPath) {
if (!inputPath || inputPath === ".") return cwd;
if (inputPath.startsWith("/")) return inputPath.replace(/\/+/g, "/");
const parts = (cwd + "/" + inputPath).split("/").filter(Boolean);
const stack = [];
for (const part of parts) {
if (part === ".") continue;
if (part === "..") stack.pop();
else stack.push(part);
}
return "/" + stack.join("/");
}
const allowedCommands = {
...extraCommands,
help: () => "Allowed commands: help, ls, open, upload, rm, cd, pwd, cat, mkdir, rmdir, mv, ln -s, stat, df",
pwd: () => cwd,
cd: async (args) => {
const path = resolvePath(args[0]);
if (!args[0]) {
cwd = "/";
return;
}
const res = await fetch(`${url}/stat?path=${encodeURIComponent(path)}`);
if (!res.ok) return `cd: ${args[0]}: No such file or directory`;
const stat = await res.json();
if (!stat || typeof stat !== "object") {
return `cd: ${args[0]}: stat error`;
}
if (!stat.isDirectory) return `cd: ${args[0]}: Not a directory`;
cwd = path;
return;
},
ls: async (args) => {
const path = resolvePath(args[0] || ".");
try {
let modeStr2 = function(mode, isDir) {
if (mode == null) return "??????????";
const types = isDir ? "d" : "-";
const perms = [
mode & 256 ? "r" : "-",
mode & 128 ? "w" : "-",
mode & 64 ? "x" : "-",
mode & 32 ? "r" : "-",
mode & 16 ? "w" : "-",
mode & 8 ? "x" : "-",
mode & 4 ? "r" : "-",
mode & 2 ? "w" : "-",
mode & 1 ? "x" : "-"
].join("");
return types + perms;
}, humanSize2 = function(size) {
if (size == null) return "?";
if (size < 1024) return size + "B";
if (size < 1024 * 1024) return (size / 1024).toFixed(1).replace(/\.0$/, "") + "K";
if (size < 1024 * 1024 * 1024) return (size / (1024 * 1024)).toFixed(1).replace(/\.0$/, "") + "M";
return (size / (1024 * 1024 * 1024)).toFixed(1).replace(/\.0$/, "") + "G";
}, mtimeStr2 = function(mtime) {
if (!mtime) return "?";
const d = new Date(mtime);
const mon = d.toLocaleString("en-US", { month: "short" });
const day = d.getDate().toString().padStart(2, " ");
const time = d.toTimeString().slice(0, 5);
return `${mon} ${day} ${time}`;
};
var modeStr = modeStr2, humanSize = humanSize2, mtimeStr = mtimeStr2;
const res = await fetch(`${url}/ls?path=${encodeURIComponent(path)}`);
if (!res.ok) return "Error: could not list directory";
const stats = await res.json();
let maxMode = 10, maxNlink = 1, maxUid = 1, maxGid = 1, maxSize = 1, maxMtime = 12;
stats.forEach((f) => {
if (!f.error) {
maxMode = Math.max(maxMode, modeStr2(f.mode, f.isDirectory).length);
maxNlink = Math.max(maxNlink, (f.nlink ?? 1).toString().length);
maxUid = Math.max(maxUid, (f.uid ?? "?").toString().length);
maxGid = Math.max(maxGid, (f.gid ?? "?").toString().length);
maxSize = Math.max(maxSize, humanSize2(f.size).length);
maxMtime = Math.max(maxMtime, mtimeStr2(f.mtime).length);
}
});
const rows = stats.map((f) => {
if (f.error) return ["?", "?", "?", "?", "?", "?", f.name + " (error)"];
return [
modeStr2(f.mode, f.isDirectory),
(f.nlink ?? 1).toString(),
(f.uid ?? "?").toString(),
(f.gid ?? "?").toString(),
humanSize2(f.size),
mtimeStr2(f.mtime),
f.name + (f.isDirectory ? "/" : "")
];
});
const header = ["Mode", "Nlink", "Uid", "Gid", "Size", "Mtime", "Name"];
return formatColumns([header, ...rows]);
} catch (e) {
return "Error: " + e.message;
}
},
cat: async (args) => {
const path = resolvePath(args[0]);
if (!args[0]) return "Usage: cat <file>";
const res = await fetch(`${url}/file?path=${encodeURIComponent(path)}`);
if (!res.ok) return `cat: ${args[0]}: No such file`;
const text = await res.text();
return text;
},
open: (args) => {
const path = resolvePath(args[0]);
if (!args[0]) return "Usage: open <file>";
const _url = `${url}/file?path=${encodeURIComponent(path)}`;
window.open(_url, "_blank");
return `Opened ${path} in new window.`;
},
upload: () => {
return new Promise((resolve) => {
term.writeln("Uploading...");
const input = document.createElement("input");
input.type = "file";
input.style.display = "none";
document.body.appendChild(input);
input.addEventListener("change", () => {
const file = input.files && input.files[0];
if (!file) {
document.body.removeChild(input);
term.writeln("Upload cancelled.");
resolve("No file selected.");
return;
}
const formData = new FormData();
formData.append("file", file);
const xhr = new XMLHttpRequest();
xhr.open("POST", `${url}/upload?path=${encodeURIComponent(cwd)}`);
let lastPercent = -1;
xhr.upload.onprogress = (e) => {
if (e.lengthComputable) {
const percent = Math.floor(e.loaded / e.total * 100);
if (percent !== lastPercent) {
term.write(`\rUploading... ${percent}% `);
lastPercent = percent;
}
}
};
xhr.onload = () => {
document.body.removeChild(input);
term.writeln("\rUpload complete. ");
if (xhr.status === 200) {
resolve(`Uploaded: ${file.name}`);
} else {
resolve(`Upload failed: ${xhr.statusText}`);
}
};
xhr.onerror = () => {
document.body.removeChild(input);
term.writeln("\rUpload failed. ");
resolve("Upload failed: network error");
};
xhr.send(formData);
});
input.click();
});
},
rm: async (args) => {
const path = resolvePath(args[0]);
if (!args[0]) return "Usage: rm <file>";
const res = await fetch(`${url}/rm?path=${encodeURIComponent(path)}`, { method: "POST" });
if (!res.ok) return `Error: could not remove ${args[0]}`;
return `Removed ${args[0]}`;
},
mkdir: async (args) => {
let createParents = false;
let dirPath = "";
for (const arg of args) {
if (arg === "-p") {
createParents = true;
} else if (!dirPath && !arg.startsWith("-")) {
dirPath = arg;
}
}
if (!dirPath) return "Usage: mkdir [-p] <dir>";
const path = resolvePath(dirPath);
const url_params = new URLSearchParams({ path });
if (createParents) {
url_params.set("createParents", "true");
}
const res = await fetch(`${url}/mkdir?${url_params.toString()}`, { method: "POST" });
if (!res.ok) return `Error: could not create directory ${dirPath}`;
return `Created directory ${dirPath}`;
},
rmdir: async (args) => {
const path = resolvePath(args[0]);
if (!args[0]) return "Usage: rmdir <dir>";
const res = await fetch(`${url}/rmdir?path=${encodeURIComponent(path)}`, { method: "POST" });
if (!res.ok) return `Error: could not remove directory ${args[0]}`;
return `Removed directory ${args[0]}`;
},
mv: async (args) => {
if (args.length < 2) return "Usage: mv <src> <dest>";
const src = resolvePath(args[0]);
const dest = resolvePath(args[1]);
const res = await fetch(`${url}/mv?src=${encodeURIComponent(src)}&dest=${encodeURIComponent(dest)}`, {
method: "POST"
});
if (!res.ok) return `Error: could not move ${args[0]}`;
return `Moved ${args[0]} to ${args[1]}`;
},
"ln -s": async (args) => {
if (args.length < 2) return "Usage: ln -s <target> <link>";
const target = resolvePath(args[0]);
const path = resolvePath(args[1]);
const res = await fetch(`${url}/symlink?target=${encodeURIComponent(target)}&path=${encodeURIComponent(path)}`, {
method: "POST"
});
if (!res.ok) return `Error: could not symlink ${args[1]}`;
return `Symlinked ${args[1]} -> ${args[0]}`;
},
stat: async (args) => {
const path = resolvePath(args[0]);
if (!args[0]) return "Usage: stat <file|dir>";
const res = await fetch(`${url}/stat?path=${encodeURIComponent(path)}`);
if (!res.ok) return `Error: could not stat ${args[0]}`;
const stat = await res.json();
return JSON.stringify(stat, null, 2);
},
df: async () => {
const res = await fetch(`${url}/df`);
if (!res.ok) return "Error: could not get device stats";
const stats = await res.json();
function humanSize(size) {
if (size == null) return "?";
if (size < 1024) return size + "B";
if (size < 1024 * 1024) return (size / 1024).toFixed(1).replace(/\.0$/, "") + "K";
if (size < 1024 * 1024 * 1024) return (size / (1024 * 1024)).toFixed(1).replace(/\.0$/, "") + "M";
return (size / (1024 * 1024 * 1024)).toFixed(1).replace(/\.0$/, "") + "G";
}
const rows = [
["Filesystem", "Size", "Used", "Avail"],
["dofs", humanSize(stats.deviceSize), humanSize(stats.spaceUsed), humanSize(stats.spaceAvailable)]
];
return formatColumns(rows);
}
};
const allCommands = Object.keys(allowedCommands);
async function getCompletions(cmd, argPrefix, cwdArg = "/") {
let path = argPrefix;
let base = "";
if (!path || path.endsWith("/")) {
base = path || cwdArg;
path = "";
} else {
const idx = path.lastIndexOf("/");
if (idx >= 0) {
base = resolvePath(path.slice(0, idx + 1));
path = path.slice(idx + 1);
} else {
base = cwdArg;
}
}
try {
const res = await fetch(`${url}/ls?path=${encodeURIComponent(base || "/")}`);
if (!res.ok) return [];
const stats = await res.json();
return stats.filter((f) => !f.error && f.name.startsWith(path)).map((f) => f.name + (f.isDirectory ? "/" : "")).sort();
} catch {
return [];
}
}
term.onData(async (e) => {
if (e === "\r") {
const [cmd, ...args] = buffer.trim().split(" ");
term.writeln("");
if (buffer.trim().length > 0) {
history.push(buffer);
historyIndex = history.length;
}
if (allowedCommands[cmd]) {
const result = allowedCommands[cmd](args);
if (result instanceof Promise) {
const awaited = await result;
if (awaited !== void 0 && awaited !== null) term.writeln(awaited);
} else {
if (result !== void 0 && result !== null) term.writeln(result);
}
if (cmd === "cd") {
term.write(`${cwd} ${prompt}`);
buffer = "";
return;
}
} else if (cmd.length > 0) {
term.writeln("Command not found");
}
buffer = "";
term.write(`${cwd} ${prompt}`);
} else if (e === "\x7F") {
if (buffer.length > 0) {
buffer = buffer.slice(0, -1);
term.write("\b \b");
}
} else if (e === "\x1B[A") {
if (history.length > 0 && historyIndex > 0) {
historyIndex--;
term.write(`\r\x1B[K${prompt}`);
buffer = history[historyIndex];
term.write(buffer);
}
} else if (e === "\x1B[B") {
if (history.length > 0 && historyIndex < history.length - 1) {
historyIndex++;
term.write(`\r\x1B[K${prompt}`);
buffer = history[historyIndex];
} else if (historyIndex === history.length - 1) {
historyIndex++;
term.write(`\r\x1B[K${prompt}`);
buffer = "";
}
term.write(buffer);
} else if (e === " ") {
const parts = buffer.split(/ +/);
const isCmd = buffer.trim().length === 0 || buffer.match(/^\s*\S*$/);
let insertPos = buffer.length;
let prefix = "";
if (isCmd) {
prefix = parts[0] || "";
insertPos = buffer.length;
const candidates = allCommands.filter((c) => c.startsWith(prefix));
if (!candidates.length) return;
if (!autocompleteState || autocompleteState.lastBuffer !== buffer) {
autocompleteState = { candidates, prefix, insertPos, cycleIndex: 0, lastBuffer: buffer };
} else {
autocompleteState.cycleIndex = (autocompleteState.cycleIndex + 1) % candidates.length;
}
let common = candidates[0];
for (const c of candidates) {
let i = 0;
while (i < common.length && c[i] === common[i]) i++;
common = common.slice(0, i);
}
const toInsert = candidates.length === 1 ? candidates[0] : common;
term.write(`\r\x1B[K${prompt}` + toInsert);
buffer = toInsert;
if (candidates.length > 1 && autocompleteState.cycleIndex === 0) {
term.writeln("\r\n" + candidates.join(" "));
term.write(`${prompt}` + toInsert);
} else if (candidates.length > 1) {
const pick = candidates[autocompleteState.cycleIndex];
term.write(`\r\x1B[K${prompt}` + pick);
buffer = pick;
}
return;
} else {
const argIdx = parts.length - 1;
prefix = parts[argIdx] || "";
insertPos = buffer.lastIndexOf(prefix);
const candidates = await getCompletions(parts[0], prefix, cwd);
if (!candidates.length) return;
if (!autocompleteState || autocompleteState.lastBuffer !== buffer) {
autocompleteState = { candidates, prefix, insertPos, cycleIndex: 0, lastBuffer: buffer };
} else {
autocompleteState.cycleIndex = (autocompleteState.cycleIndex + 1) % candidates.length;
}
let common = candidates[0];
for (const c of candidates) {
let i = 0;
while (i < common.length && c[i] === common[i]) i++;
common = common.slice(0, i);
}
const toInsert = candidates.length === 1 ? candidates[0] : common;
const newBuffer = buffer.slice(0, insertPos) + toInsert;
term.write(`\r\x1B[K${prompt}` + newBuffer);
buffer = newBuffer;
if (candidates.length > 1 && autocompleteState.cycleIndex === 0) {
term.writeln("\r\n" + candidates.join(" "));
term.write(`${prompt}` + newBuffer);
} else if (candidates.length > 1) {
const pick = candidates[autocompleteState.cycleIndex];
const newBuffer2 = buffer.slice(0, insertPos) + pick;
term.write(`\r\x1B[K${prompt}` + newBuffer2);
buffer = newBuffer2;
}
return;
}
} else if (e === "") {
term.write(`^C\r
${prompt}`);
buffer = "";
} else if (e >= " " && e <= "~") {
buffer += e;
term.write(e);
}
autocompleteState = null;
});
function formatColumns(rows) {
if (!rows.length) return "";
const colCount = rows[0].length;
const colWidths = Array(colCount).fill(0);
for (const row of rows) {
for (let i = 0; i < colCount; ++i) {
colWidths[i] = Math.max(colWidths[i], String(row[i]).length);
}
}
return rows.map(
(row) => row.map((cell, i) => {
if (typeof cell === "number" || i > 0 && /^ *[0-9,.]+ *$/.test(cell)) {
return String(cell).padStart(colWidths[i], " ");
}
return String(cell).padEnd(colWidths[i], " ");
}).join(" ")
).join("\r\n");
}
};
// src/vanilla/DTermElement.ts
var DTermElement = class extends HTMLElement {
connectedCallback() {
this.style.cssText = `
display: flex;
flex-direction: column;
height: 100%;
width: 100%;
`;
const xtermAttr = this.getAttribute("xterm");
const url = this.getAttribute("url");
if (!url) {
throw new Error("d-term: url attribute is required");
}
let xtermInstance;
if (xtermAttr) {
xtermInstance = window[xtermAttr];
if (!xtermInstance) {
console.warn(`d-term: window.${xtermAttr} not found, falling back to default`);
xtermInstance = this.createDefaultTerminal();
}
} else {
xtermInstance = window.xterm || this.createDefaultTerminal();
}
this.terminal = xtermInstance;
this.fitAddon = new import_addon_fit.FitAddon();
xtermInstance.loadAddon(this.fitAddon);
dterm(xtermInstance, { url });
xtermInstance.open(this);
setTimeout(() => {
this.fitAddon?.fit();
}, 0);
this.resizeObserver = new ResizeObserver(() => {
this.fitAddon?.fit();
});
this.resizeObserver.observe(this);
}
disconnectedCallback() {
this.resizeObserver?.disconnect();
this.terminal?.dispose();
}
createDefaultTerminal() {
return new Terminal({
theme: { background: "#181818", foreground: "#e0e0e0" },
fontFamily: "monospace",
fontSize: 16,
cursorBlink: true
});
}
};
// src/vanilla/index.ts
customElements.define("d-term", DTermElement);
export {
dterm
};