1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
--- @module assay.shell
--- @description WebSocket ↔ PTY bridge for in-browser terminals (xterm.js, etc). Spawns a child on a fresh PTY, pipes raw bytes both directions, and interprets a small JSON resize control protocol on text frames.
--- @keywords shell, pty, websocket, terminal, xterm, browser, bridge
--- @quickref M.bridge(conn, opts) | Bridge a ws server conn to a `process.spawn_pty` child until either side closes
local M = {}
--- Bridge a WebSocket server connection to a fresh PTY child until either side closes.
---
--- @param conn ws server connection (the `conn` argument to an `http.serve` `ws =` handler)
--- @param opts table: { cmd=string, args?={...}, cwd?=string, env?={...}, cols?=int, rows?=int }
---
--- Wire format the bridge speaks with the browser:
--- - All binary frames are forwarded raw to the PTY's stdin.
--- - Text frames matching `{"resize":{"cols":N,"rows":M}}` trigger `pty:resize(N, M)`;
--- anything else is forwarded raw (so a plain text terminal client still works).
--- - PTY output goes back as binary frames.
---
--- Returns when either the PTY child exits or the websocket peer closes.
function M.bridge(conn, opts)
if type(conn) ~= "userdata" then
error("assay.shell.bridge: conn must be a ws server connection")
end
if type(opts) ~= "table" or type(opts.cmd) ~= "string" then
error("assay.shell.bridge: opts.cmd (string) is required")
end
local pty = process.spawn_pty({
cmd = opts.cmd,
args = opts.args,
cwd = opts.cwd,
env = opts.env,
cols = opts.cols or 80,
rows = opts.rows or 24,
})
-- PTY → WebSocket
local pty_to_ws = async.spawn(function()
while true do
local chunk = pty:read()
if chunk == nil then break end
local ok = pcall(function() conn:write(chunk, { binary = true }) end)
if not ok then break end
end
pcall(function() conn:close() end)
end)
-- WebSocket → PTY (with resize control-message detection)
local ws_to_pty = async.spawn(function()
while true do
local msg = conn:read()
if msg == nil then break end
-- Resize control message: {"resize":{"cols":N,"rows":M}}.
-- Only attempt JSON decode on payloads that look like one; anything
-- else is forwarded raw so terminals that send normal text still work.
if msg:sub(1, 1) == "{" and msg:find('"resize"', 1, true) then
local ok, decoded = pcall(json.parse, msg)
if ok and type(decoded) == "table" and type(decoded.resize) == "table" then
local cols = tonumber(decoded.resize.cols)
local rows = tonumber(decoded.resize.rows)
if cols and rows and cols > 0 and rows > 0 then
pcall(function() pty:resize(cols, rows) end)
goto continue
end
end
end
pcall(function() pty:write(msg) end)
::continue::
end
pcall(function() pty:close() end)
end)
pty_to_ws:await()
ws_to_pty:await()
end
return M