diff --git a/app/routes/ssh/hp_ssh.d.ts b/app/routes/ssh/hp_ssh.d.ts index 8c43b4a..2bb73e8 100644 --- a/app/routes/ssh/hp_ssh.d.ts +++ b/app/routes/ssh/hp_ssh.d.ts @@ -43,9 +43,9 @@ interface XtermConfig { cols: number; timeout?: number; - onStdout: (data: string) => void; - onStderr: (data: string) => void; - onStdin: (func: (input: string) => void) => void; + onStdout: (data: Uint8Array) => void; + onStderr: (data: Uint8Array) => void; + onStdin: (func: (input: Uint8Array) => void) => void; onConnect: () => void; onDisconnect: () => void; diff --git a/app/routes/ssh/xterm.client.tsx b/app/routes/ssh/xterm.client.tsx index 6313c13..9e57061 100644 --- a/app/routes/ssh/xterm.client.tsx +++ b/app/routes/ssh/xterm.client.tsx @@ -2,153 +2,192 @@ import { ClipboardAddon } from '@xterm/addon-clipboard'; import { FitAddon } from '@xterm/addon-fit'; import { Unicode11Addon } from '@xterm/addon-unicode11'; import { WebLinksAddon } from '@xterm/addon-web-links'; -import { WebglAddon } from '@xterm/addon-webgl'; import * as xterm from '@xterm/xterm'; -import '@xterm/xterm/css/xterm.css'; import { Loader2 } from 'lucide-react'; import { useEffect, useRef, useState } from 'react'; import cn from '~/utils/cn'; import { useLiveData } from '~/utils/live-data'; import toast from '~/utils/toast'; +import '@xterm/xterm/css/xterm.css'; + interface XTermProps { ipn: TsWasmNet; username: string; hostname: string; } +// Go's WASM -> JS crosses realms so we might have to normalize the data under +// certain conditions. This also enforces bytes instead of strings being sent. +function normU8(data: unknown) { + if (data instanceof Uint8Array) { + return data; + } + + if (data && typeof data === 'object') { + const any = data as { + buffer?: ArrayBufferLike; + byteOffset?: number; + byteLength?: number; + }; + + if ( + any.buffer instanceof ArrayBuffer && + typeof any.byteLength === 'number' + ) { + return new Uint8Array( + any.buffer.slice( + any.byteOffset ?? 0, + (any.byteOffset ?? 0) + any.byteLength, + ), + ); + } + } + + throw new Error('Data is not a Uint8Array or ArrayBuffer-like object'); +} + export default function XTerm({ ipn, username, hostname }: XTermProps) { const { pause } = useLiveData(); - const container = useRef(null); - const term = useRef(null); - const inputRef = useRef<((input: string) => void) | null>(null); + const genRef = useRef(0); + const termRef = useRef(null); + const roRef = useRef(null); + const inputRef = useRef<(input: Uint8Array) => void>(null); + const sshRef = useRef(null); + const divRef = useRef(null); const [isResizing, setIsResizing] = useState(false); const [isLoading, setIsLoading] = useState(true); - const [isFailed, setIsFailed] = useState(false); + const [error, setError] = useState(null); useEffect(() => { pause(); - if (!container.current) { - console.error('Container ref is not set'); + }); + + useEffect(() => { + if (!divRef.current) { return; } - // Don't create a new terminal if one already exists - if (term.current) { - console.warn('Terminal already exists, skipping initialization'); - return; - } - - const terminal = new xterm.Terminal({ + const currentGen = ++genRef.current; + const term = new xterm.Terminal({ allowProposedApi: true, cursorBlink: true, convertEol: true, fontSize: 14, - cols: 80, - rows: 24, - theme: { - background: '#1e1e1e', - foreground: '#ffffff', - }, }); - terminal.loadAddon(new Unicode11Addon()); - terminal.loadAddon(new ClipboardAddon()); - terminal.loadAddon( + const fit = new FitAddon(); + term.loadAddon(fit); + + term.loadAddon(new Unicode11Addon()); + term.loadAddon(new ClipboardAddon()); + term.loadAddon( new WebLinksAddon((event, uri) => { event.view?.open(uri, '_blank', 'noopener noreferrer'); }), ); - terminal.unicode.activeVersion = '11'; - const fit = new FitAddon(); - terminal.loadAddon(fit); - - const gl = new WebglAddon(); - terminal.loadAddon(gl); - - gl.onContextLoss(() => { - console.warn('WebGL context lost, falling back to canvas rendering'); - gl.dispose(); - }); - + term.unicode.activeVersion = '11'; + termRef.current = term; + term.open(divRef.current!); fit.fit(); - term.current = terminal; - terminal.open(container.current!); - terminal.focus(); + term.focus(); - let onUnload: ((e: Event) => void) | null = null; const session = ipn.OpenSSH(hostname, username, { - rows: terminal.rows, - cols: terminal.cols, - - onStdout: (data) => terminal.write(data), - onStderr: (data) => { - terminal.write(data); - console.log('SSH stderr:', data); - toast(data); - }, - onStdin: (func) => { - console.log('SSH session is ready to receive input'); - inputRef.current = func; - console.log('Stdin handler set', func); - }, - onConnect: () => { - console.log('SSH session connected'); - setIsLoading(false); - }, - - onDisconnect: () => { - ro?.disconnect(); - terminal.dispose(); - if (onUnload) { - parent.removeEventListener('unload', onUnload); + rows: term.rows, + cols: term.cols, + onStdout: (data) => { + if (currentGen !== genRef.current || term !== termRef.current) { + console.warn('Stale terminal instance, ignoring stdout'); + return; } - console.log('SSH session disconnected'); - terminal.writeln('Disconnected from SSH session'); - setIsLoading(false); - setIsFailed(true); - term.current = null; + const text = normU8(data); + term.write(text); }, + onStderr: (data) => { + if (currentGen !== genRef.current || term !== termRef.current) { + console.warn('Stale terminal instance, ignoring stderr'); + return; + } + + const text = normU8(data); + term.write(text); + const str = new TextDecoder().decode(text); + setError(str); + }, + onStdin: (func) => { + inputRef.current = func; + }, + onConnect: () => { + if (currentGen !== genRef.current) { + console.warn('Stale terminal instance, ignoring onConnect'); + return; + } + + setIsLoading(false); + }, + onDisconnect: () => { + if (currentGen !== genRef.current) { + console.warn('Stale terminal instance, ignoring onDisconnect'); + return; + } + + roRef.current?.disconnect(); + term.dispose(); + termRef.current = null; + inputRef.current = null; + sshRef.current = null; + + setIsLoading(false); + }, + }); + + sshRef.current = session; + const enc = new TextEncoder(); + term.onData((data) => { + if (currentGen !== genRef.current) { + console.warn('Stale terminal instance, ignoring onData'); + return; + } + + const bytes = enc.encode(data); + inputRef.current?.(bytes); }); const ro = new ResizeObserver(() => { - if (term.current) { - setIsResizing(true); - fit.fit(); - setTimeout(() => setIsResizing(false), 100); + if (currentGen !== genRef.current || term !== termRef.current) { + console.warn('Stale terminal instance, ignoring resize'); + return; } + + setIsResizing(true); + fit.fit(); + sshRef.current?.Resize(term.cols, term.rows); + setTimeout(() => setIsResizing(false), 100); }); - ro.observe(container.current); - terminal.onResize(({ cols, rows }) => { - console.log(`Terminal resized to ${cols}x${rows}`); - session.Resize(cols, rows); - }); - - terminal.onData((data) => { - inputRef.current?.(data); - }); - - onUnload = (_) => session.Close(); - parent.addEventListener('unload', onUnload); + roRef.current = ro; + ro.observe(divRef.current!); return () => { - if (onUnload) { - parent.removeEventListener('unload', onUnload); + ++genRef.current; + roRef.current?.disconnect(); + roRef.current = null; + + sshRef.current?.Close(); + sshRef.current = null; + + term.dispose(); + if (termRef.current === term) { + termRef.current = null; } - session.Close(); - ro?.disconnect(); - terminal.dispose(); - term.current = null; inputRef.current = null; - console.log('SSH session closed and terminal disposed'); }; - }, []); + }, [ipn, username, hostname]); return ( <> @@ -159,19 +198,19 @@ export default function XTerm({ ipn, username, hostname }: XTermProps) { ) : undefined}
- {term.current && isResizing ? ( + {termRef.current && isResizing ? (
- {term.current.cols}x{term.current.rows} + {termRef.current.cols}x{termRef.current.rows}
) : undefined} - {isFailed ? ( + {error !== null ? (
Failed to connect to SSH session + {error}
) : undefined} diff --git a/internal/hp_ipn/jsmap.go b/internal/hp_ipn/jsmap.go index dff3046..3800873 100644 --- a/internal/hp_ipn/jsmap.go +++ b/internal/hp_ipn/jsmap.go @@ -4,7 +4,6 @@ package hp_ipn import ( "errors" - "log" "syscall/js" ) @@ -43,14 +42,14 @@ func ParseTsWasmNetOptions(obj js.Value) (*TsWasmNetOptions, error) { // Options passed from the JS side to pass data to xterm.js. type SSHXtermConfig struct { - Timeout int // Timeout in seconds for the PTY connection. - Rows int // Number of rows in the PTY. - Cols int // Number of columns in the PTY. - OnStdout func(data string) // Fires when the PTY has output. - OnStderr func(error string) // Fires when the PTY has an error. - OnStdin js.Value // Passes a function to the JS side to provide input. - OnConnect func() // Fires when the PTY is opened. - OnDisconnect func() // Fires when the PTY is closed. + Timeout int // Timeout in seconds for the PTY connection. + Rows int // Number of rows in the PTY. + Cols int // Number of columns in the PTY. + OnStdout func(data js.Value) // Fires when the PTY has output. + OnStderr func(error js.Value) // Fires when the PTY has an error. + OnStdin js.Value // Passes a function to the JS side to provide input. + OnConnect func() // Fires when the PTY is opened. + OnDisconnect func() // Fires when the PTY is closed. } // Parses the provided JS object to validate and extract SSHXtermConfig. @@ -82,7 +81,7 @@ func ParseSSHXtermConfig(obj js.Value) (*SSHXtermConfig, error) { return nil, errors.New("`onStdout` is required and must be a function") } - config.OnStdout = func(data string) { + config.OnStdout = func(data js.Value) { onStdout.Invoke(data) } @@ -91,7 +90,7 @@ func ParseSSHXtermConfig(obj js.Value) (*SSHXtermConfig, error) { return nil, errors.New("`onStderr` is required and must be a function") } - config.OnStderr = func(error string) { + config.OnStderr = func(error js.Value) { onStderr.Invoke(error) } @@ -123,20 +122,6 @@ func ParseSSHXtermConfig(obj js.Value) (*SSHXtermConfig, error) { return config, nil } -func (t *SSHXtermConfig) PassStdinHandler(fn func(string)) { - handler := js.FuncOf(func(this js.Value, args []js.Value) any { - if len(args) != 1 || args[0].Type() != js.TypeString { - return nil - } - - fn(args[0].String()) - return nil - }) - - log.Printf("Passing stdin handler to JS: %v", handler) - t.OnStdin.Invoke(handler) -} - // Retrieves a string value from a JS object safely. func safeString(key string, obj js.Value) string { if obj.IsUndefined() || obj.IsNull() { diff --git a/internal/hp_ipn/ssh.go b/internal/hp_ipn/ssh.go index 1728799..7fc5e74 100644 --- a/internal/hp_ipn/ssh.go +++ b/internal/hp_ipn/ssh.go @@ -3,11 +3,12 @@ package hp_ipn import ( - "bytes" "context" "fmt" + "io" "log" "net" + "syscall/js" "time" "golang.org/x/crypto/ssh" @@ -35,6 +36,9 @@ type SSHSession struct { // Tracks resize notifications for columns. ResizeCols int + + // Reference to our stdin handler, released on close. + stdinHandler *js.Func } // Creates a new SSH session given a hostname and username. @@ -92,24 +96,6 @@ func (s *SSHSession) ConnectAndRun() { defer pty.Close() s.Pty = pty - pty.Stdout = XtermPipe{s.TermConfig.OnStdout} - pty.Stderr = XtermPipe{s.TermConfig.OnStdout} - - // TODO: Set Stdin func - stdin, err := pty.StdinPipe() - if err != nil { - s.writeError("SSH", err) - return - } - - s.TermConfig.PassStdinHandler(func(input string) { - _, err := stdin.Write([]byte(input)) - if err != nil { - s.writeError("SSH", err) - return - } - }) - rows := s.TermConfig.Rows if s.ResizeRows != 0 { rows = s.ResizeRows @@ -120,12 +106,45 @@ func (s *SSHSession) ConnectAndRun() { cols = s.ResizeCols } - err = pty.RequestPty("xterm", rows, cols, ssh.TerminalModes{}) + err = pty.RequestPty("xterm", rows, cols, ssh.TerminalModes{ + ssh.ECHO: 1, // enable echoing + ssh.ICANON: 1, // canonical mode + ssh.ISIG: 1, // enable signals + ssh.ICRNL: 1, // map CR to NL on input + ssh.IUTF8: 1, // input is UTF-8 + ssh.TTY_OP_ISPEED: 14400, // input speed = 14.4kbaud + ssh.TTY_OP_OSPEED: 14400, // output speed = 14.4kbaud + }) + if err != nil { s.writeError("SSH", err) return } + stdin, err := pty.StdinPipe() + if err != nil { + s.writeError("SSH", err) + return + } + + s.wireStdinHandler(stdin) + + stdout, err := pty.StdoutPipe() + if err != nil { + s.writeError("SSH", err) + return + } + + stderr, err := pty.StderrPipe() + if err != nil { + s.writeError("SSH", err) + return + } + + go io.Copy(XtermPipe{s.TermConfig.OnStdout}, stdout) + go io.Copy(XtermPipe{s.TermConfig.OnStderr}, stderr) + + // Create our shell err = pty.Shell() if err != nil { s.writeError("SSH", err) @@ -155,29 +174,67 @@ func (s *SSHSession) Resize(rows, cols int) error { // Closes the SSH session. func (s *SSHSession) Close() error { - if s.Pty == nil { - return nil + if s.stdinHandler != nil { + s.stdinHandler.Release() + s.stdinHandler = nil } - return s.Pty.Close() + if s.Pty != nil { + err := s.Pty.Close() + if err != nil { + return err + } + } + + return nil +} + +// Wires up the stdin handler to pass data from JS to the SSH session. +func (s *SSHSession) wireStdinHandler(w io.Writer) { + if s.stdinHandler != nil { + s.stdinHandler.Release() + s.stdinHandler = nil + } + + cb := js.FuncOf(func(this js.Value, args []js.Value) any { + v := args[0] // This is ALWAYS a Uint8Array technically + len := v.Get("byteLength").Int() + buf := make([]byte, len) + js.CopyBytesToGo(buf, v) + + if _, err := w.Write(buf); err != nil { + s.writeError("SSH Stdin", err) + return nil + } + + // TODO: Remove debug log + log.Printf("SSH wrote %d bytes: %v (%q)", len, buf, string(buf)) + return nil + }) + + s.stdinHandler = &cb + s.TermConfig.OnStdin.Invoke(cb) } // Quick easy formatter for writing errors to the terminal. func (s *SSHSession) writeError(label string, err error) { o := fmt.Sprintf("%s error: %v\r\n", label, err) - s.TermConfig.OnStderr(o) + uint8Array := js.Global().Get("Uint8Array").New(len(o)) + + js.CopyBytesToJS(uint8Array, []byte(o)) + s.TermConfig.OnStderr(uint8Array) } // io.Writer "emulator" to pass to the ssh module. type XtermPipe struct { // Function to call when data is written. - Send func(data string) + Send func(data js.Value) } // Write implements the io.Writer interface for XtermPipe. func (x XtermPipe) Write(data []byte) (int, error) { - // Tailscale's webSSH does this to fix issues in xterm.js - res := bytes.Replace(data, []byte("\n"), []byte("\n\r"), -1) - x.Send(string(res)) + uint8Array := js.Global().Get("Uint8Array").New(len(data)) + js.CopyBytesToJS(uint8Array, data) + x.Send(uint8Array) return len(data), nil } diff --git a/package.json b/package.json index cfe3a58..b21f705 100644 --- a/package.json +++ b/package.json @@ -37,7 +37,6 @@ "@xterm/addon-fit": "^0.10.0", "@xterm/addon-unicode11": "^0.8.0", "@xterm/addon-web-links": "^0.11.0", - "@xterm/addon-webgl": "^0.18.0", "@xterm/xterm": "^5.5.0", "arktype": "^2.1.20", "clsx": "^2.1.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a55783f..f200cdb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -76,9 +76,6 @@ importers: '@xterm/addon-web-links': specifier: ^0.11.0 version: 0.11.0(@xterm/xterm@5.5.0) - '@xterm/addon-webgl': - specifier: ^0.18.0 - version: 0.18.0(@xterm/xterm@5.5.0) '@xterm/xterm': specifier: ^5.5.0 version: 5.5.0 @@ -2263,11 +2260,6 @@ packages: peerDependencies: '@xterm/xterm': ^5.0.0 - '@xterm/addon-webgl@0.18.0': - resolution: {integrity: sha512-xCnfMBTI+/HKPdRnSOHaJDRqEpq2Ugy8LEj9GiY4J3zJObo3joylIFaMvzBwbYRg8zLtkO0KQaStCeSfoaI2/w==} - peerDependencies: - '@xterm/xterm': ^5.0.0 - '@xterm/xterm@5.5.0': resolution: {integrity: sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==} @@ -6541,10 +6533,6 @@ snapshots: dependencies: '@xterm/xterm': 5.5.0 - '@xterm/addon-webgl@0.18.0(@xterm/xterm@5.5.0)': - dependencies: - '@xterm/xterm': 5.5.0 - '@xterm/xterm@5.5.0': {} acorn@8.15.0: