UNPKG

dofs-term

Version:

A terminal emulator for Cloudflare Durable Objects.

549 lines (543 loc) 21.5 kB
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 };