diff --git a/tool/net/demo/hitcounter.lua b/tool/net/demo/hitcounter.lua
index 657ca3ccb..6a052c927 100644
--- a/tool/net/demo/hitcounter.lua
+++ b/tool/net/demo/hitcounter.lua
@@ -29,7 +29,9 @@ t = DecodeJson(s)
Unlock()
for k,v in pairs(t) do
- Write('
%s
%d\n' % {EscapeHtml(k), v})
+ if type(v) == 'number' then
+ Write('
%s
%d\n' % {EscapeHtml(k), v})
+ end
end
Write('')
diff --git a/tool/net/demo/xterm.lua b/tool/net/demo/xterm.lua
new file mode 100755
index 000000000..b83b477cf
--- /dev/null
+++ b/tool/net/demo/xterm.lua
@@ -0,0 +1,351 @@
+--[[
+XTERM Proof of concept
+This shows how redbean can be used to launch a powershell (windows)
+or bash (other OS) xterm session
+
+The demo loads an HTML page that loads https://xtermjs.org/ and
+defines a unique xtermid. An SSE EventSource is then defined which
+starts a SSEServer that takes care of starting the shell command
+As SSE is mostly a Server=>Client channel, the keyboard events/commands
+are sent via side-channel POST requests
+
+SSE Events :
+ - noname : stdout from the shell, encodeURIComponent encoded
+ - prompt : a way for the server to write a prompt on the client
+ (different shells can have different interactive behavior)
+ - exit : a way to ask the client to stop its EventSource.
+ this will trigger a POST.exit to close the SSEServer
+
+POST Events :
+ - command : a command that was typed in the xterm.js
+ - exit : a way to ask the SSE server to shut down
+ - ping : a way to tell the server that the xterm.js session is still
+ alive. A vanished browser session will lead to the closing of
+ the SSE server
+
+In order to share and reconcile the POSTed Events between the different
+processes (POST processes and SSE server), a json shm is used where
+the xtermid is used as a key to store the queue of commands.
+
+Notes:
+- security: Use at your own risk as it opens a shell on the server and
+ makes it available on the listening ip/ports
+- limitations: the demo does not use a real pty so there are certainly
+ a lot of limitations and it is not made to be keyboard spammed !
+- special chars are currently not handled. Things like CTRL-C to stop
+ a command is not implemented
+- CTRL-V is not implemented
+- history is not implemented
+- tab completion is not implemented
+- some mechanisms have been put in place to try to garbage collect the
+ shm (tab closing, sending the 'exit' command, .. but there are probably
+ a lot of other corner cases that need to be tested and handled
+- the session state management and supervision could certainly be improved
+
+]]--
+
+ipcat = CategorizeIp(GetServerAddr())
+if ipcat ~= 'PRIVATE' and ipcat ~= 'LOOPBACK' then
+ Write('As a first level of security, the xterm/shell demo is only available on IPs categorized as private or loopback')
+ Write('The current category is ' .. ipcat)
+ return
+end
+
+
+
+
+function HTMLPage()
+
+Write('\n')
+Write('\n')
+Write('\n')
+Write('
Redbean xterm.js/shell demo
\n')
+Write('
you should be able to interact with powershell on windows or bash on other OSs
')
+Write('Try usual commands like:')
+Write('
')
+Write('
ls
')
+Write('
whoami
')
+Write('
ps
')
+Write('
or any command you dare try ;-)
')
+
+Write('
')
+Write('
\n')
+Write('\n')
+
+script = [[
+var baseTheme = {
+ foreground: '#F8F8F8',
+ background: '#2D2E2C',
+ selection: '#5DA5D533',
+ black: '#1E1E1D',
+ brightBlack: '#262625',
+ red: '#CE5C5C',
+ brightRed: '#FF7272',
+ green: '#5BCC5B',
+ brightGreen: '#72FF72',
+ yellow: '#CCCC5B',
+ brightYellow: '#FFFF72',
+ blue: '#5D5DD3',
+ brightBlue: '#7279FF',
+ magenta: '#BC5ED1',
+ brightMagenta: '#E572FF',
+ cyan: '#5DA5D5',
+ brightCyan: '#72F0FF',
+ white: '#F8F8F8',
+ brightWhite: '#FFFFFF'
+ };
+var isBaseTheme = true;
+var term = new window.Terminal({
+ fontFamily: '"Cascadia Code", Menlo, monospace',
+ theme: baseTheme,
+ cursorBlink: true,
+ allowProposedApi: true,
+ cols: 150,
+ convertEol: true
+ });
+term.open(document.querySelector('.demo .inner'));
+var isWebglEnabled = false;
+try {
+ const webgl = new window.WebglAddon.WebglAddon();
+ term.loadAddon(webgl);
+ isWebglEnabled = true;
+} catch (e) {
+ console.warn('WebGL addon threw an exception during load', e);
+}
+
+
+prompt_str = 'redbean shell \ud83e\udd9e > '
+function prompt(term, str) {
+ command = '';
+ prompt_str = str || prompt_str;
+ term.write(prompt_str);
+ }
+
+var command = '';
+var running = true;
+
+function sendCommand(type, data) {
+ return fetch('xterm.lua?xtermid='+xtermid, {
+ method: "POST",
+ body: JSON.stringify({ type: type, data: data })
+ });
+}
+
+function runCommand(term, text) {
+ const command = text.trim();
+ if (command.length > 0) {
+ term.writeln('');
+ sendCommand("command", command);
+ } else {
+ term.write("\r\n");
+ prompt(term);
+ }
+}
+
+term.onData(e => {
+ if (!running) return;
+ switch (e) {
+ case '\u0003': // Ctrl+C
+ term.write('^C');
+ prompt(term);
+ break;
+ case '\r': // Enter
+ runCommand(term, command);
+ command = '';
+ break;
+ case '\u007F': // Backspace (DEL)
+ // Do not delete the prompt
+ if (term._core.buffer.x > 2) {
+ term.write('\b \b');
+ if (command.length > 0) {
+ command = command.substr(0, command.length - 1);
+ }
+ }
+ break;
+ default: // Print all other characters for demo
+ if (e >= String.fromCharCode(0x20) && e <= String.fromCharCode(0x7E) || e >= '\u00a0') {
+ command += e;
+ term.write(e);
+ }
+ }
+});
+
+
+
+var evtSource = new EventSource('xterm.lua?xtermid='+xtermid+'&sse');
+evtSource.onmessage = (event) => {
+ term.write(decodeURIComponent(event.data))
+};
+evtSource.addEventListener("prompt", (event) => {
+ prompt(term, decodeURIComponent(event.data))
+});
+evtSource.addEventListener("exit", (event) => {
+ console.log("exit", event.data);
+ evtSource.close();
+ running = false;
+ term.write('..TERMINATED..');
+ sendCommand('exit', null);
+});
+
+
+function ping() {
+ if (running) {
+ sendCommand('ping', null);
+ }
+}
+
+// we ping the SSE server to avoid a dangling a SSE process on the server
+window.addEventListener('load', function () {
+ var fetchInterval = 2000;
+ setInterval(ping, fetchInterval);
+});
+
+]]
+
+Write('')
+
+end
+
+-- SSE Events needs yielding to be sent as event-stream
+local function streamWrap(func)
+ return function(...) return coroutine.yield(func(...)) or true end
+end
+local function writeEvent(event, data)
+ if event then Write("event: " .. event .. "\n") end
+ if data then Write("data: " .. EscapeParam(data) .. "\n") end
+ Write("\n")
+end
+local streamEvent = streamWrap(writeEvent)
+
+function SSEServer()
+ xtermid = GetParam('xtermid')
+ if GetHostOs() == 'WINDOWS' then
+ cmd = { 'pwsh.exe', "-Interactive", "-Command", "-" }
+ eol = '\r\n'
+ else
+ cmd = { '/bin/bash' }
+ eol = '\n'
+ end
+ cmd[1] = assert(unix.commandv(cmd[1]))
+
+ readerIn, writerIn = assert(unix.pipe(unix.O_CLOEXEC))
+ readerOut, writerOut = assert(unix.pipe(unix.O_CLOEXEC))
+ child = assert(unix.fork())
+
+ if child == 0 then
+ unix.close(0)
+ unix.dup(readerIn)
+ unix.close(1)
+ unix.dup(writerOut)
+ unix.close(2)
+ unix.dup(writerOut)
+ unix.close(writerIn)
+ unix.close(readerOut)
+ unix.execve(cmd[1], cmd)
+ unix.exit(127)
+ else
+ SetHeader('Content-Type', 'text/event-stream')
+ SetHeader('Connection','Close')
+ unix.close(readerIn)
+ unix.close(writerOut)
+
+ pollfds = {}
+ pollfds[readerOut] = unix.POLLIN
+ polling = true
+ promptblock = GetTime()
+ needprompt = true
+ lastping = GetTime()
+
+ while polling do
+ evs, errno = unix.poll(pollfds, 100)
+ if not evs then
+ break
+ end
+
+ hasStdout = false
+ for fd,revents in pairs(evs) do
+ if fd == readerOut then
+ hasStdout =true
+ data, errno = unix.read(fd)
+ if data ~= '' then
+ streamEvent(nil, data)
+ promptblock = GetTime() + 0.1
+ end
+ end
+ end
+ command = nextCommand(xtermid)
+ if command then lastping = GetTime() end
+ if command and command.type == "command" then
+ if command.data == 'exit' then
+ -- ask browser to close its SSE EventSource
+ streamEvent('exit', '')
+ end
+ unix.write(writerIn, command.data .. eol)
+ promptblock = GetTime() + 1
+ needprompt = true
+ end
+ if (command and command.type == "exit") or lastping < GetTime() - 5 then
+ polling = false
+ end
+ if needprompt and not hasStdout and GetTime() > promptblock then
+ streamEvent('prompt', 'redbean shell '..utf8.char(129438)..' > ')
+ needprompt = false
+ end
+ end
+ unix.close(readerOut)
+ unix.close(writerIn)
+ deleteSession(xtermid)
+ unix.wait(-1)
+ Log(kLogWarn, 'xterm SSE server '..xtermid.. 'has been closed')
+ return
+ end
+end
+
+function registerCommand(xtermid)
+ posted = GetPayload()
+ local s, t, k
+ Lock()
+ s = shm:read(SHM_JSON)
+ if s == '' then s = '{}' end
+ t = DecodeJson(s)
+ k = xtermid
+ if not t[k] then t[k] = {} end
+ table.insert(t[k], 1, DecodeJson(posted))
+ shm:write(SHM_JSON, EncodeJson(t))
+ Unlock()
+end
+function nextCommand(xtermid)
+ local s, t, k
+ Lock()
+ s = shm:read(SHM_JSON)
+ if s == '' then s = '{}' end
+ t = DecodeJson(s)
+ k = xtermid
+ if not t[k] then t[k] = {} end
+ command = table.remove(t[k])
+ shm:write(SHM_JSON, EncodeJson(t))
+ Unlock()
+ return command
+end
+function deleteSession(xtermid)
+ local s, t, k
+ Lock()
+ s = shm:read(SHM_JSON)
+ if s == '' then s = '{}' end
+ t = DecodeJson(s)
+ k = xtermid
+ t[k] = nil
+ shm:write(SHM_JSON, EncodeJson(t))
+ Unlock()
+end
+
+
+if HasParam('sse') then
+ SSEServer()
+elseif GetMethod() == "POST" then
+ registerCommand(GetParam('xtermid'))
+else
+ HTMLPage()
+end
+
+