diff --git a/.gitignore b/.gitignore index 57f1cb2..1ac9a0a 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,3 @@ -/.idea/ \ No newline at end of file +/.idea/ +/browser/client.js +/node_modules/ diff --git a/browser/client.ts b/browser/client.ts index 0a5322e..6627cde 100644 --- a/browser/client.ts +++ b/browser/client.ts @@ -3,64 +3,64 @@ const DELIMITER = '\n'.charCodeAt(0); // Command describes initialization parameters for a remote command -export interface Command { +interface Command { command: string; args?: string[]; tty?: boolean; + rows?: number; + cols?: number; uid?: number; gid?: number; env?: string[]; working_dir?: string; } -export type ClientHeader = - | { type: 'start'; id: string; command: Command; cols: number; rows: number; } +type ClientHeader = + | { type: 'start'; id: string; command: Command; } | { type: 'stdin' } | { type: 'close_stdin' } | { type: 'resize'; cols: number; rows: number }; -export type ServerHeader = +type ServerHeader = | { type: 'stdout' } | { type: 'stderr' } | { type: 'pid'; pid: number } | { type: 'exit_code'; exit_code: number }; -export type Header = ClientHeader | ServerHeader; +type Header = ClientHeader | ServerHeader; -export const setBinaryType = (ws: WebSocket) => { +const setBinaryType = (ws: WebSocket) => { ws.binaryType = 'arraybuffer'; }; -export const sendStdin = (ws: WebSocket, data: Uint8Array) => { +const sendStdin = (ws: WebSocket, data: Uint8Array) => { if (data.byteLength < 1) return; const msg = joinMessage({ type: 'stdin' }, data); ws.send(msg.buffer); }; -export const closeStdin = (ws: WebSocket) => { +const closeStdin = (ws: WebSocket) => { const msg = joinMessage({ type: 'close_stdin' }); ws.send(msg.buffer); }; -export const startCommand = ( +const startCommand = ( ws: WebSocket, command: Command, id: string, - rows: number, - cols: number ) => { - const msg = joinMessage({ type: 'start', command, id, rows, cols }); + const msg = joinMessage({ type: 'start', command, id }); ws.send(msg.buffer); }; -export const parseServerMessage = ( +const parseServerMessage = ( ev: MessageEvent ): [ServerHeader, Uint8Array] => { const [header, body] = splitMessage(ev.data); return [header as ServerHeader, body]; }; -export const resizeTerminal = ( +const resizeTerminal = ( ws: WebSocket, rows: number, cols: number @@ -103,3 +103,82 @@ const splitMessage = (message: ArrayBuffer): [Header, Uint8Array] => { return [JSON.parse(new TextDecoder().decode(array)), new Uint8Array(0)]; }; + +const log = (output: HTMLElement | null, message: string) => { + if (output) { + output.innerText = message + "\n" + } + console.log(message) +} + +const main = () => { + const ws = new WebSocket(location.origin.replace(/^http/, "ws")) + setBinaryType(ws) + + const output = document.getElementById("output") + + // @ts-ignore Not sure how to make this work. + const term = new Terminal(); + // @ts-ignore Not sure how to make this work. + const fit = new FitAddon.FitAddon(); + term.loadAddon(fit); + term.open(document.getElementById("terminal")); + fit.fit(); + + ws.addEventListener("open", () => { + log(output, "sending start command...") + startCommand(ws, { + command: "bash", + tty: true, + rows: term.rows, + cols: term.cols, + env: ["TERM=xterm"], + }, "id") + }) + + ws.addEventListener("message", (ev) => { + const [header, body] = splitMessage(ev.data) + + switch (header.type) { + case "stdout": + case "stderr": + term.write(body) + if (output && body) { + output.innerText = new TextDecoder().decode(body) + } + break + case "pid": + if (output) { + log(output, "ready") + } + break + case "exit_code": + log(output, "exited") + break + default: + log(output, "unknown message type " + header.type) + break + } + }) + + const form = document.getElementById("input") as HTMLFormElement + if (form) { + form.addEventListener("submit", (ev) => { + ev.preventDefault() + const formData = new FormData(form); + const input = formData.get("input") as string + "\n" + if (input) { + const bytes = new TextEncoder().encode(input) + sendStdin(ws, bytes) + form.reset() + } + }) + } + + term.onData((data: string) => { + const bytes = new TextEncoder().encode(data) + sendStdin(ws, bytes) + }) +} + +main() diff --git a/browser/index.html b/browser/index.html new file mode 100644 index 0000000..2d1cd5a --- /dev/null +++ b/browser/index.html @@ -0,0 +1,16 @@ + + + + + +
+
+ + +
+
+ + + + + diff --git a/dev/server/main.go b/dev/server/main.go index fcd94bf..2f8db23 100644 --- a/dev/server/main.go +++ b/dev/server/main.go @@ -1,7 +1,10 @@ package main import ( + "fmt" "net/http" + "os" + "path/filepath" "time" "cdr.dev/wsep" @@ -9,6 +12,21 @@ import ( "nhooyr.io/websocket" ) +const ( + upgradeHeader = "Upgrade" + upgradeWebsocket = "websocket" +) + +func IsUpgradeRequest(r *http.Request) bool { + vs := r.Header.Values(upgradeHeader) + for _, v := range vs { + if v == upgradeWebsocket { + return true + } + } + return false +} + func main() { server := http.Server{ Addr: ":8080", @@ -19,18 +37,43 @@ func main() { } func serve(w http.ResponseWriter, r *http.Request) { - ws, err := websocket.Accept(w, r, &websocket.AcceptOptions{InsecureSkipVerify: true}) + if IsUpgradeRequest(r) { + ws, err := websocket.Accept(w, r, &websocket.AcceptOptions{InsecureSkipVerify: true}) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + return + } + err = wsep.Serve(r.Context(), ws, wsep.LocalExecer{}, &wsep.Options{ + SessionTimeout: 30 * time.Second, + }) + if err != nil { + flog.Error("failed to serve execer: %v", err) + ws.Close(websocket.StatusAbnormalClosure, "failed to serve execer") + return + } + ws.Close(websocket.StatusNormalClosure, "normal closure") + return + } + + fmt.Println("path:", r.URL.Path) + p := r.URL.Path + if r.URL.Path == "/" { + p = "./browser/index.html" + } + + dir, err := os.Getwd() if err != nil { w.WriteHeader(http.StatusInternalServerError) return } - err = wsep.Serve(r.Context(), ws, wsep.LocalExecer{}, &wsep.Options{ - SessionTimeout: 30 * time.Second, - }) + file := filepath.Join(dir, p) + + fmt.Println("serving: ", file) + b, err := os.ReadFile(file) if err != nil { - flog.Error("failed to serve execer: %v", err) - ws.Close(websocket.StatusAbnormalClosure, "failed to serve execer") + w.WriteHeader(http.StatusNotFound) return } - ws.Close(websocket.StatusNormalClosure, "normal closure") + + w.Write(b) } diff --git a/flake.nix b/flake.nix index 1b932a4..9cc8f06 100644 --- a/flake.nix +++ b/flake.nix @@ -11,6 +11,7 @@ devShells.default = pkgs.mkShell { buildInputs = with pkgs; [ go + nodejs screen ]; }; diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..f981a73 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,40 @@ +{ + "name": "wsep", + "lockfileVersion": 2, + "requires": true, + "packages": { + "": { + "name": "wsep", + "dependencies": { + "xterm": "^5.0.0", + "xterm-addon-fit": "^0.6.0" + } + }, + "node_modules/xterm": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xterm/-/xterm-5.0.0.tgz", + "integrity": "sha512-tmVsKzZovAYNDIaUinfz+VDclraQpPUnAME+JawosgWRMphInDded/PuY0xmU5dOhyeYZsI0nz5yd8dPYsdLTA==" + }, + "node_modules/xterm-addon-fit": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/xterm-addon-fit/-/xterm-addon-fit-0.6.0.tgz", + "integrity": "sha512-9/7A+1KEjkFam0yxTaHfuk9LEvvTSBi0PZmEkzJqgafXPEXL9pCMAVV7rB09sX6ATRDXAdBpQhZkhKj7CGvYeg==", + "peerDependencies": { + "xterm": "^5.0.0" + } + } + }, + "dependencies": { + "xterm": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xterm/-/xterm-5.0.0.tgz", + "integrity": "sha512-tmVsKzZovAYNDIaUinfz+VDclraQpPUnAME+JawosgWRMphInDded/PuY0xmU5dOhyeYZsI0nz5yd8dPYsdLTA==" + }, + "xterm-addon-fit": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/xterm-addon-fit/-/xterm-addon-fit-0.6.0.tgz", + "integrity": "sha512-9/7A+1KEjkFam0yxTaHfuk9LEvvTSBi0PZmEkzJqgafXPEXL9pCMAVV7rB09sX6ATRDXAdBpQhZkhKj7CGvYeg==", + "requires": {} + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..38a2ef2 --- /dev/null +++ b/package.json @@ -0,0 +1,10 @@ +{ + "name": "wsep", + "scripts": { + "build": "tsc" + }, + "dependencies": { + "xterm": "^5.0.0", + "xterm-addon-fit": "^0.6.0" + } +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..858a009 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,8 @@ +{ + "compilerOptions": { + "target": "esnext", + "module": "commonjs", + "strict": true + }, + "include": ["./browser"] +}