From 978134cd834edb045ccebb302fead9dee2a80b9d Mon Sep 17 00:00:00 2001 From: Colin Adler Date: Thu, 12 Sep 2024 21:27:43 +0000 Subject: [PATCH 01/20] Log when port-forward server is enabled --- cmd/wush/serve.go | 1 + 1 file changed, 1 insertion(+) diff --git a/cmd/wush/serve.go b/cmd/wush/serve.go index 1f33d08..f28d135 100644 --- a/cmd/wush/serve.go +++ b/cmd/wush/serve.go @@ -169,6 +169,7 @@ func serveCmd() *serpent.Command { bicopy(ctx, src, dst) }, true }) + hlog("Port-forward server " + pretty.Sprint(cliui.DefaultStyles.Enabled, "enabled")) } else { hlog("Port-forward server " + pretty.Sprint(cliui.DefaultStyles.Disabled, "disabled")) } From 68257a6d6f17be086dbdf252f86b51fa4e9a5a3f Mon Sep 17 00:00:00 2001 From: Ilya Grigoriev Date: Sun, 15 Sep 2024 17:28:01 -0700 Subject: [PATCH 02/20] Use `WUSH_AUTH_KEY` instead of `WUSH_AUTH_key`, remove mentions of `wush receive` (#29) * cp.go & portforward.go: use `WUSH_AUTH_KEY`, not `WUSH_AUTH_key` * Replace `wush receive` with `wush serve` --- README.md | 2 +- cmd/wush/cp.go | 4 ++-- cmd/wush/portforward.go | 4 ++-- cmd/wush/rsync.go | 2 +- cmd/wush/ssh.go | 4 ++-- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 07c6a1a..bf8610a 100644 --- a/README.md +++ b/README.md @@ -95,7 +95,7 @@ coder@colin:~$ `wush` doesn't require you to trust any 3rd party authentication or relay servers, instead using x25519 keys to authenticate incoming connections. Auth -keys generated by `wush receive` are separated into a couple parts: +keys generated by `wush serve` are separated into a couple parts: ```text 112v1RyL5KPzsbMbhT7fkEGrcfpygxtnvwjR5kMLGxDHGeLTK1BvoPqsUcjo7xyMkFn46KLTdedKuPCG5trP84mz9kx diff --git a/cmd/wush/cp.go b/cmd/wush/cp.go index 4dfe1d2..42eaa8e 100644 --- a/cmd/wush/cp.go +++ b/cmd/wush/cp.go @@ -212,8 +212,8 @@ func cpCmd() *serpent.Command { Options: []serpent.Option{ { Flag: "auth-key", - Env: "WUSH_AUTH_key", - Description: "The auth key returned by " + cliui.Code("wush receive") + ". If not provided, it will be asked for on startup.", + Env: "WUSH_AUTH_KEY", + Description: "The auth key returned by " + cliui.Code("wush serve") + ". If not provided, it will be asked for on startup.", Default: "", Value: serpent.StringOf(&overlayOpts.authKey), }, diff --git a/cmd/wush/portforward.go b/cmd/wush/portforward.go index 7e333a1..2965e48 100644 --- a/cmd/wush/portforward.go +++ b/cmd/wush/portforward.go @@ -174,8 +174,8 @@ func portForwardCmd() *serpent.Command { Options: []serpent.Option{ { Flag: "auth-key", - Env: "WUSH_AUTH_key", - Description: "The auth key returned by " + cliui.Code("wush receive") + ". If not provided, it will be asked for on startup.", + Env: "WUSH_AUTH_KEY", + Description: "The auth key returned by " + cliui.Code("wush serve") + ". If not provided, it will be asked for on startup.", Default: "", Value: serpent.StringOf(&overlayOpts.authKey), }, diff --git a/cmd/wush/rsync.go b/cmd/wush/rsync.go index c54c4e1..9e876a7 100644 --- a/cmd/wush/rsync.go +++ b/cmd/wush/rsync.go @@ -75,7 +75,7 @@ func rsyncCmd() *serpent.Command { { Flag: "auth-key", Env: "WUSH_AUTH_KEY", - Description: "The auth key returned by " + cliui.Code("wush receive") + ". If not provided, it will be asked for on startup.", + Description: "The auth key returned by " + cliui.Code("wush serve") + ". If not provided, it will be asked for on startup.", Default: "", Value: serpent.StringOf(&overlayOpts.authKey), }, diff --git a/cmd/wush/ssh.go b/cmd/wush/ssh.go index 42a3bec..8117c24 100644 --- a/cmd/wush/ssh.go +++ b/cmd/wush/ssh.go @@ -33,7 +33,7 @@ func sshCmd() *serpent.Command { Aliases: []string{}, Short: "Open a shell.", Long: "Opens an SSH connection to a " + cliui.Code("wush") + " peer. " + - "Use " + cliui.Code("wush receive") + " on the computer you would like to connect to.", + "Use " + cliui.Code("wush serve") + " on the computer you would like to connect to.", Middleware: serpent.Chain( initLogger(&verbose, &quiet, logger, &logf), initAuth(&overlayOpts.authKey, &overlayOpts.clientAuth), @@ -91,7 +91,7 @@ func sshCmd() *serpent.Command { { Flag: "auth-key", Env: "WUSH_AUTH_KEY", - Description: "The auth key returned by " + cliui.Code("wush receive") + ". If not provided, it will be asked for on startup.", + Description: "The auth key returned by " + cliui.Code("wush serve") + ". If not provided, it will be asked for on startup.", Default: "", Value: serpent.StringOf(&overlayOpts.authKey), }, From 3dac91e6d59676054e477316d678db54bfe00fd2 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 16 Sep 2024 10:32:10 -0500 Subject: [PATCH 03/20] chore(deps): bump golang.org/x/net from 0.28.0 to 0.29.0 (#30) Bumps [golang.org/x/net](https://github.com/golang/net) from 0.28.0 to 0.29.0. - [Commits](https://github.com/golang/net/compare/v0.28.0...v0.29.0) --- updated-dependencies: - dependency-name: golang.org/x/net dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 006c371..6619304 100644 --- a/go.mod +++ b/go.mod @@ -27,7 +27,7 @@ require ( go4.org/mem v0.0.0-20220726221520-4f986261bf13 golang.org/x/crypto v0.27.0 golang.org/x/exp v0.0.0-20240823005443-9b4947da3948 - golang.org/x/net v0.28.0 + golang.org/x/net v0.29.0 golang.org/x/sys v0.25.0 golang.org/x/term v0.24.0 golang.org/x/xerrors v0.0.0-20240716161551-93cc26a95ae9 diff --git a/go.sum b/go.sum index d9ddda2..cb67a8a 100644 --- a/go.sum +++ b/go.sum @@ -644,8 +644,8 @@ golang.org/x/net v0.3.0/go.mod h1:MBQ8lrhLObU/6UmLb4fmbmk5OcyYmqtbGd/9yIeKjEE= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= golang.org/x/net v0.14.0/go.mod h1:PpSgVXXLK0OxS0F31C1/tv6XNguvCrnXIDrFMspZIUI= -golang.org/x/net v0.28.0 h1:a9JDOJc5GMUJ0+UDqmLT86WiEy7iWyIhz8gz8E4e5hE= -golang.org/x/net v0.28.0/go.mod h1:yqtgsTWOOnlGLG9GFRrK3++bGOUEkNBoHZc8MEDWPNg= +golang.org/x/net v0.29.0 h1:5ORfpBpCs4HzDYoodCDBbwHzdR5UrLBZ3sOnUJmFoHo= +golang.org/x/net v0.29.0/go.mod h1:gLkgy8jTGERgjzMic6DS9+SP0ajcu6Xu3Orq/SpETg0= golang.org/x/oauth2 v0.21.0 h1:tsimM75w1tF/uws5rbeHzIWxEqElMehnc+iW793zsZs= golang.org/x/oauth2 v0.21.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= From d6723bd04d116d4a578047cbfd6aeeb398ff0421 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 16 Sep 2024 10:32:35 -0500 Subject: [PATCH 04/20] chore(deps): bump github.com/schollz/progressbar/v3 from 3.14.6 to 3.15.0 (#31) Bumps [github.com/schollz/progressbar/v3](https://github.com/schollz/progressbar) from 3.14.6 to 3.15.0. - [Release notes](https://github.com/schollz/progressbar/releases) - [Commits](https://github.com/schollz/progressbar/compare/v3.14.6...v3.15.0) --- updated-dependencies: - dependency-name: github.com/schollz/progressbar/v3 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- go.mod | 2 +- go.sum | 6 ++---- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/go.mod b/go.mod index 6619304..36bea7a 100644 --- a/go.mod +++ b/go.mod @@ -21,7 +21,7 @@ require ( github.com/pion/stun/v3 v3.0.0 github.com/prometheus/client_golang v1.20.3 github.com/puzpuzpuz/xsync/v3 v3.4.0 - github.com/schollz/progressbar/v3 v3.14.6 + github.com/schollz/progressbar/v3 v3.15.0 github.com/spf13/afero v1.11.0 github.com/valyala/fasthttp v1.55.0 go4.org/mem v0.0.0-20220726221520-4f986261bf13 diff --git a/go.sum b/go.sum index cb67a8a..8967d53 100644 --- a/go.sum +++ b/go.sum @@ -469,8 +469,8 @@ github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= github.com/safchain/ethtool v0.3.0 h1:gimQJpsI6sc1yIqP/y8GYgiXn/NjgvpM0RNoWLVVmP0= github.com/safchain/ethtool v0.3.0/go.mod h1:SA9BwrgyAqNo7M+uaL6IYbxpm5wk3L7Mm6ocLW+CJUs= -github.com/schollz/progressbar/v3 v3.14.6 h1:GyjwcWBAf+GFDMLziwerKvpuS7ZF+mNTAXIB2aspiZs= -github.com/schollz/progressbar/v3 v3.14.6/go.mod h1:Nrzpuw3Nl0srLY0VlTvC4V6RL50pcEymjy6qyJAaLa0= +github.com/schollz/progressbar/v3 v3.15.0 h1:cNZmcNiVyea6oofBTg80ZhVXxf3wG/JoAhqCCwopkQo= +github.com/schollz/progressbar/v3 v3.15.0/go.mod h1:ncBdc++eweU0dQoeZJ3loXoAc+bjaallHRIm8pVVeQM= github.com/secure-systems-lab/go-securesystemslib v0.7.0 h1:OwvJ5jQf9LnIAS83waAjPbcMsODrTQUpJ02eNLUoxBg= github.com/secure-systems-lab/go-securesystemslib v0.7.0/go.mod h1:/2gYnlnHVQ6xeGtfIqFy7Do03K4cdCY0A/GlJLDKLHI= github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8= @@ -692,7 +692,6 @@ golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34= golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= @@ -702,7 +701,6 @@ golang.org/x/term v0.3.0/go.mod h1:q750SLmJuPmVoN1blW3UFBPREJfb1KmY3vwxfr+nFDA= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= golang.org/x/term v0.11.0/go.mod h1:zC9APTIj3jG3FdV/Ons+XE1riIZXG4aZ4GTHiPZJPIU= -golang.org/x/term v0.22.0/go.mod h1:F3qCibpT5AMpCRfhfT53vVJwhLtIVHhB9XDjfFvnMI4= golang.org/x/term v0.24.0 h1:Mh5cbb+Zk2hqqXNO7S1iTjEphVL+jb8ZWaqh/g+JWkM= golang.org/x/term v0.24.0/go.mod h1:lOBK/LVxemqiMij05LGJ0tzNr8xlmwBRJ81PX6wVLH8= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= From c43ea6c3c32e70ee43657e25c0472d2a38e8aec7 Mon Sep 17 00:00:00 2001 From: Colin Adler Date: Wed, 25 Sep 2024 19:14:14 -0500 Subject: [PATCH 05/20] feat: init wush.dev (#37) --- .gitignore | 3 + cmd/wasm/main.go | 308 + go.mod | 2 +- go.sum | 4 +- site/.gitignore | 5 + site/app/assets/wasm_exec.js | 668 +++ site/app/components/Terminal.client.tsx | 116 + site/app/context/wush.ts | 3 + site/app/entry.client.tsx | 7 + site/app/entry.server.tsx | 155 + site/app/root.tsx | 109 + site/app/routes/_index.tsx | 36 + site/app/routes/connect.tsx | 32 + site/app/tailwind.css | 10 + site/biome.json | 7 + site/cors-config.json | 8 + site/deploy.sh | 18 + site/package.json | 54 + site/pnpm-lock.yaml | 7002 +++++++++++++++++++++++ site/postcss.config.js | 6 + site/public/favicon.ico | Bin 0 -> 1053 bytes site/remix.config.ts | 4 + site/tailwind.config.ts | 66 + site/tsconfig.json | 32 + site/types/wush_js.d.ts | 37 + site/vite.config.ts | 31 + site/wrangler.toml | 85 + 27 files changed, 8805 insertions(+), 3 deletions(-) create mode 100644 cmd/wasm/main.go create mode 100644 site/.gitignore create mode 100644 site/app/assets/wasm_exec.js create mode 100644 site/app/components/Terminal.client.tsx create mode 100644 site/app/context/wush.ts create mode 100644 site/app/entry.client.tsx create mode 100644 site/app/entry.server.tsx create mode 100644 site/app/root.tsx create mode 100644 site/app/routes/_index.tsx create mode 100644 site/app/routes/connect.tsx create mode 100644 site/app/tailwind.css create mode 100644 site/biome.json create mode 100644 site/cors-config.json create mode 100755 site/deploy.sh create mode 100644 site/package.json create mode 100644 site/pnpm-lock.yaml create mode 100644 site/postcss.config.js create mode 100644 site/public/favicon.ico create mode 100644 site/remix.config.ts create mode 100644 site/tailwind.config.ts create mode 100644 site/tsconfig.json create mode 100644 site/types/wush_js.d.ts create mode 100644 site/vite.config.ts create mode 100644 site/wrangler.toml diff --git a/.gitignore b/.gitignore index ecfc81b..ea90573 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,5 @@ dist test + +main.wasm +test.txt diff --git a/cmd/wasm/main.go b/cmd/wasm/main.go new file mode 100644 index 0000000..b0560e6 --- /dev/null +++ b/cmd/wasm/main.go @@ -0,0 +1,308 @@ +//go:build js && wasm + +package main + +import ( + "bytes" + "context" + "fmt" + "log" + "log/slog" + "net" + "syscall/js" + "time" + + "github.com/coder/wush/overlay" + "github.com/coder/wush/tsserver" + "golang.org/x/crypto/ssh" + "golang.org/x/xerrors" + "tailscale.com/ipn/store" + "tailscale.com/net/netns" + "tailscale.com/tsnet" +) + +func main() { + fmt.Println("WebAssembly module initialized") + defer fmt.Println("WebAssembly module exited") + + js.Global().Set("newWush", js.FuncOf(func(this js.Value, args []js.Value) any { + handler := js.FuncOf(func(this js.Value, promiseArgs []js.Value) any { + if len(args) != 1 { + log.Fatal("Usage: newWush(config)") + return nil + } + + go func() { + w := newWush(args[0]) + promiseArgs[0].Invoke(w) + }() + + return nil + }) + + promiseConstructor := js.Global().Get("Promise") + return promiseConstructor.New(handler) + })) + js.Global().Set("exitWush", js.FuncOf(func(this js.Value, args []js.Value) any { + // close(ch) + return nil + })) + + // Keep the main function running + <-make(chan struct{}, 0) +} + +func newWush(jsConfig js.Value) map[string]any { + ctx := context.Background() + var authKey string + if jsAuthKey := jsConfig.Get("authKey"); jsAuthKey.Type() == js.TypeString { + authKey = jsAuthKey.String() + } + + logger := slog.New(slog.NewTextHandler(jsConsoleWriter{}, nil)) + hlog := func(format string, args ...any) { + fmt.Printf(format+"\n", args...) + } + dm, err := tsserver.DERPMapTailscale(ctx) + if err != nil { + panic(err) + } + + send := overlay.NewSendOverlay(logger, dm) + err = send.Auth.Parse(authKey) + if err != nil { + panic(err) + } + + s, err := tsserver.NewServer(ctx, logger, send) + if err != nil { + panic(err) + } + + go send.ListenOverlayDERP(ctx) + go s.ListenAndServe(ctx) + netns.SetDialerOverride(s.Dialer()) + + ts, err := newTSNet("send") + if err != nil { + panic(err) + } + + _, err = ts.Up(ctx) + if err != nil { + panic(err) + } + hlog("WireGuard is ready") + + return map[string]any{ + "stop": js.FuncOf(func(this js.Value, args []js.Value) any { + if len(args) != 0 { + log.Printf("Usage: stop()") + return nil + } + ts.Close() + return nil + }), + "ssh": js.FuncOf(func(this js.Value, args []js.Value) any { + if len(args) != 1 { + log.Printf("Usage: ssh({})") + return nil + } + + sess := &sshSession{ + ts: ts, + cfg: args[0], + } + + go sess.Run() + + return map[string]any{ + "close": js.FuncOf(func(this js.Value, args []js.Value) any { + return sess.Close() != nil + }), + "resize": js.FuncOf(func(this js.Value, args []js.Value) any { + rows := args[0].Int() + cols := args[1].Int() + return sess.Resize(rows, cols) != nil + }), + } + }), + } +} + +type sshSession struct { + ts *tsnet.Server + cfg js.Value + + session *ssh.Session + pendingResizeRows int + pendingResizeCols int +} + +func (s *sshSession) Close() error { + if s.session == nil { + // We never had a chance to open the session, ignore the close request. + return nil + } + return s.session.Close() +} + +func (s *sshSession) Resize(rows, cols int) error { + if s.session == nil { + s.pendingResizeRows = rows + s.pendingResizeCols = cols + return nil + } + return s.session.WindowChange(rows, cols) +} + +func (s *sshSession) Run() { + writeFn := s.cfg.Get("writeFn") + writeErrorFn := s.cfg.Get("writeErrorFn") + setReadFn := s.cfg.Get("setReadFn") + rows := s.cfg.Get("rows").Int() + cols := s.cfg.Get("cols").Int() + timeoutSeconds := 5.0 + if jsTimeoutSeconds := s.cfg.Get("timeoutSeconds"); jsTimeoutSeconds.Type() == js.TypeNumber { + timeoutSeconds = jsTimeoutSeconds.Float() + } + onConnectionProgress := s.cfg.Get("onConnectionProgress") + onConnected := s.cfg.Get("onConnected") + onDone := s.cfg.Get("onDone") + defer onDone.Invoke() + + writeError := func(label string, err error) { + writeErrorFn.Invoke(fmt.Sprintf("%s Error: %v\r\n", label, err)) + } + reportProgress := func(message string) { + onConnectionProgress.Invoke(message) + } + + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeoutSeconds*float64(time.Second))) + defer cancel() + reportProgress(fmt.Sprintf("Connecting...")) + c, err := s.ts.Dial(ctx, "tcp", net.JoinHostPort("100.64.0.0", "3")) + if err != nil { + writeError("Dial", err) + return + } + defer c.Close() + + config := &ssh.ClientConfig{ + HostKeyCallback: func(hostname string, remote net.Addr, key ssh.PublicKey) error { + // Host keys are not used with Tailscale SSH, but we can use this + // callback to know that the connection has been established. + reportProgress("SSH connection established…") + return nil + }, + } + + reportProgress("Starting SSH client…") + sshConn, _, _, err := ssh.NewClientConn(c, "100.64.0.0:3", config) + if err != nil { + writeError("SSH Connection", err) + return + } + defer sshConn.Close() + + sshClient := ssh.NewClient(sshConn, nil, nil) + defer sshClient.Close() + + session, err := sshClient.NewSession() + if err != nil { + writeError("SSH Session", err) + return + } + s.session = session + defer session.Close() + + stdin, err := session.StdinPipe() + if err != nil { + writeError("SSH Stdin", err) + return + } + + session.Stdout = termWriter{writeFn} + session.Stderr = termWriter{writeFn} + + setReadFn.Invoke(js.FuncOf(func(this js.Value, args []js.Value) any { + input := args[0].String() + _, err := stdin.Write([]byte(input)) + if err != nil { + writeError("Write Input", err) + } + return nil + })) + + // We might have gotten a resize notification since we started opening the + // session, pick up the latest size. + if s.pendingResizeRows != 0 { + rows = s.pendingResizeRows + } + if s.pendingResizeCols != 0 { + cols = s.pendingResizeCols + } + err = session.RequestPty("xterm", rows, cols, ssh.TerminalModes{}) + + if err != nil { + writeError("Pseudo Terminal", err) + return + } + + err = session.Shell() + if err != nil { + writeError("Shell", err) + return + } + + onConnected.Invoke() + err = session.Wait() + if err != nil { + writeError("Wait", err) + return + } +} + +type termWriter struct { + f js.Value +} + +func (w termWriter) Write(p []byte) (n int, err error) { + r := bytes.Replace(p, []byte("\n"), []byte("\n\r"), -1) + w.f.Invoke(string(r)) + return len(p), nil +} + +type jsConsoleWriter struct{} + +func (w jsConsoleWriter) Write(p []byte) (n int, err error) { + js.Global().Get("console").Call("log", string(p)) + return len(p), nil +} + +func newTSNet(direction string) (*tsnet.Server, error) { + var err error + // tmp := os.TempDir() + srv := new(tsnet.Server) + // srv.Dir = tmp + srv.Hostname = "wush-" + direction + srv.Ephemeral = true + srv.AuthKey = direction + srv.ControlURL = "http://127.0.0.1:8080" + // srv.Logf = func(format string, args ...any) {} + srv.Logf = func(format string, args ...any) { + fmt.Printf(format+"\n", args...) + } + // srv.UserLogf = func(format string, args ...any) {} + srv.UserLogf = func(format string, args ...any) { + fmt.Printf(format+"\n", args...) + } + // netns.SetEnabled(false) + + srv.Store, err = store.New(func(format string, args ...any) {}, "mem:wush") + if err != nil { + return nil, xerrors.Errorf("create state store: %w", err) + } + + return srv, nil +} diff --git a/go.mod b/go.mod index 36bea7a..f2c17c4 100644 --- a/go.mod +++ b/go.mod @@ -2,7 +2,7 @@ module github.com/coder/wush go 1.22.5 -replace tailscale.com => github.com/coadler/tailscale v1.1.1-0.20240815205130-c39ab8bcc2a9 +replace tailscale.com => github.com/coadler/tailscale v1.1.1-0.20240926000438-059d0c1039af replace github.com/gliderlabs/ssh => github.com/coder/ssh v0.0.0-20231128192721-70855dedb788 diff --git a/go.sum b/go.sum index 8967d53..4ca95a4 100644 --- a/go.sum +++ b/go.sum @@ -145,8 +145,8 @@ github.com/cilium/ebpf v0.15.0 h1:7NxJhNiBT3NG8pZJ3c+yfrVdHY8ScgKD27sScgjLMMk= github.com/cilium/ebpf v0.15.0/go.mod h1:DHp1WyrLeiBh19Cf/tfiSMhqheEiK8fXFZ4No0P1Hso= github.com/cloudflare/circl v1.3.7 h1:qlCDlTPz2n9fu58M0Nh1J/JzcFpfgkFHHX3O35r5vcU= github.com/cloudflare/circl v1.3.7/go.mod h1:sRTcRWXGLrKw6yIGJ+l7amYJFfAXbZG0kBSc8r4zxgA= -github.com/coadler/tailscale v1.1.1-0.20240815205130-c39ab8bcc2a9 h1:XLbLUULAjNzo8QOTqDPOIHegRNga3cgJg95srOmYM2Q= -github.com/coadler/tailscale v1.1.1-0.20240815205130-c39ab8bcc2a9/go.mod h1:rRq+xvgprFys8sZbJgcAMMqpiP6r+Y75CJRhCRmXrd0= +github.com/coadler/tailscale v1.1.1-0.20240926000438-059d0c1039af h1:7h0hQxaizCT3u7Fu9b6k1NgGj4EHxx/K3H7YBAFanVE= +github.com/coadler/tailscale v1.1.1-0.20240926000438-059d0c1039af/go.mod h1:rRq+xvgprFys8sZbJgcAMMqpiP6r+Y75CJRhCRmXrd0= github.com/coder/coder/v2 v2.14.2 h1:RNNDDwjNK5D1XMQlK7LWrS4niVclkl1FXoaOaW7N5rs= github.com/coder/coder/v2 v2.14.2/go.mod h1:dO79BI5XlP8rrtne1JpRcVehe27bNMXdZKyn1NsWbjA= github.com/coder/pretty v0.0.0-20230908205945-e89ba86370e0 h1:3A0ES21Ke+FxEM8CXx9n47SZOKOpgSE1bbJzlE4qPVs= diff --git a/site/.gitignore b/site/.gitignore new file mode 100644 index 0000000..80ec311 --- /dev/null +++ b/site/.gitignore @@ -0,0 +1,5 @@ +node_modules + +/.cache +/build +.env diff --git a/site/app/assets/wasm_exec.js b/site/app/assets/wasm_exec.js new file mode 100644 index 0000000..8bc1520 --- /dev/null +++ b/site/app/assets/wasm_exec.js @@ -0,0 +1,668 @@ +// Copyright 2018 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +"use strict"; + +(() => { + const enosys = () => { + const err = new Error("not implemented"); + err.code = "ENOSYS"; + return err; + }; + + if (!globalThis.fs) { + let outputBuf = ""; + globalThis.fs = { + constants: { + O_WRONLY: -1, + O_RDWR: -1, + O_CREAT: -1, + O_TRUNC: -1, + O_APPEND: -1, + O_EXCL: -1, + }, // unused + writeSync(fd, buf) { + outputBuf += decoder.decode(buf); + const nl = outputBuf.lastIndexOf("\n"); + if (nl != -1) { + console.log(outputBuf.substring(0, nl)); + outputBuf = outputBuf.substring(nl + 1); + } + return buf.length; + }, + write(fd, buf, offset, length, position, callback) { + if (offset !== 0 || length !== buf.length || position !== null) { + callback(enosys()); + return; + } + const n = this.writeSync(fd, buf); + callback(null, n); + }, + chmod(path, mode, callback) { + callback(enosys()); + }, + chown(path, uid, gid, callback) { + callback(enosys()); + }, + close(fd, callback) { + callback(enosys()); + }, + fchmod(fd, mode, callback) { + callback(enosys()); + }, + fchown(fd, uid, gid, callback) { + callback(enosys()); + }, + fstat(fd, callback) { + callback(enosys()); + }, + fsync(fd, callback) { + callback(null); + }, + ftruncate(fd, length, callback) { + callback(enosys()); + }, + lchown(path, uid, gid, callback) { + callback(enosys()); + }, + link(path, link, callback) { + callback(enosys()); + }, + lstat(path, callback) { + callback(enosys()); + }, + mkdir(path, perm, callback) { + callback(enosys()); + }, + open(path, flags, mode, callback) { + callback(enosys()); + }, + read(fd, buffer, offset, length, position, callback) { + callback(enosys()); + }, + readdir(path, callback) { + callback(enosys()); + }, + readlink(path, callback) { + callback(enosys()); + }, + rename(from, to, callback) { + callback(enosys()); + }, + rmdir(path, callback) { + callback(enosys()); + }, + stat(path, callback) { + callback(enosys()); + }, + symlink(path, link, callback) { + callback(enosys()); + }, + truncate(path, length, callback) { + callback(enosys()); + }, + unlink(path, callback) { + callback(enosys()); + }, + utimes(path, atime, mtime, callback) { + callback(enosys()); + }, + }; + } + + if (!globalThis.process) { + globalThis.process = { + getuid() { + return -1; + }, + getgid() { + return -1; + }, + geteuid() { + return -1; + }, + getegid() { + return -1; + }, + getgroups() { + throw enosys(); + }, + pid: -1, + ppid: -1, + umask() { + throw enosys(); + }, + cwd() { + throw enosys(); + }, + chdir() { + throw enosys(); + }, + }; + } + + if (!globalThis.crypto) { + throw new Error( + "globalThis.crypto is not available, polyfill required (crypto.getRandomValues only)" + ); + } + + if (!globalThis.performance) { + throw new Error( + "globalThis.performance is not available, polyfill required (performance.now only)" + ); + } + + if (!globalThis.TextEncoder) { + throw new Error( + "globalThis.TextEncoder is not available, polyfill required" + ); + } + + if (!globalThis.TextDecoder) { + throw new Error( + "globalThis.TextDecoder is not available, polyfill required" + ); + } + + const encoder = new TextEncoder("utf-8"); + const decoder = new TextDecoder("utf-8"); + + globalThis.Go = class { + constructor() { + this.argv = ["js"]; + this.env = {}; + this.exit = (code) => { + if (code !== 0) { + console.warn("exit code:", code); + } + }; + this._exitPromise = new Promise((resolve) => { + this._resolveExitPromise = resolve; + }); + this._pendingEvent = null; + this._scheduledTimeouts = new Map(); + this._nextCallbackTimeoutID = 1; + + const setInt64 = (addr, v) => { + this.mem.setUint32(addr + 0, v, true); + this.mem.setUint32(addr + 4, Math.floor(v / 4294967296), true); + }; + + const setInt32 = (addr, v) => { + this.mem.setUint32(addr + 0, v, true); + }; + + const getInt64 = (addr) => { + const low = this.mem.getUint32(addr + 0, true); + const high = this.mem.getInt32(addr + 4, true); + return low + high * 4294967296; + }; + + const loadValue = (addr) => { + const f = this.mem.getFloat64(addr, true); + if (f === 0) { + return undefined; + } + if (!isNaN(f)) { + return f; + } + + const id = this.mem.getUint32(addr, true); + return this._values[id]; + }; + + const storeValue = (addr, v) => { + const nanHead = 0x7ff80000; + + if (typeof v === "number" && v !== 0) { + if (isNaN(v)) { + this.mem.setUint32(addr + 4, nanHead, true); + this.mem.setUint32(addr, 0, true); + return; + } + this.mem.setFloat64(addr, v, true); + return; + } + + if (v === undefined) { + this.mem.setFloat64(addr, 0, true); + return; + } + + let id = this._ids.get(v); + if (id === undefined) { + id = this._idPool.pop(); + if (id === undefined) { + id = this._values.length; + } + this._values[id] = v; + this._goRefCounts[id] = 0; + this._ids.set(v, id); + } + this._goRefCounts[id]++; + let typeFlag = 0; + switch (typeof v) { + case "object": + if (v !== null) { + typeFlag = 1; + } + break; + case "string": + typeFlag = 2; + break; + case "symbol": + typeFlag = 3; + break; + case "function": + typeFlag = 4; + break; + } + this.mem.setUint32(addr + 4, nanHead | typeFlag, true); + this.mem.setUint32(addr, id, true); + }; + + const loadSlice = (addr) => { + const array = getInt64(addr + 0); + const len = getInt64(addr + 8); + return new Uint8Array(this._inst.exports.mem.buffer, array, len); + }; + + const loadSliceOfValues = (addr) => { + const array = getInt64(addr + 0); + const len = getInt64(addr + 8); + const a = new Array(len); + for (let i = 0; i < len; i++) { + a[i] = loadValue(array + i * 8); + } + return a; + }; + + const loadString = (addr) => { + const saddr = getInt64(addr + 0); + const len = getInt64(addr + 8); + return decoder.decode( + new DataView(this._inst.exports.mem.buffer, saddr, len) + ); + }; + + const timeOrigin = Date.now() - performance.now(); + this.importObject = { + _gotest: { + add: (a, b) => a + b, + }, + gojs: { + // Go's SP does not change as long as no Go code is running. Some operations (e.g. calls, getters and setters) + // may synchronously trigger a Go event handler. This makes Go code get executed in the middle of the imported + // function. A goroutine can switch to a new stack if the current stack is too small (see morestack function). + // This changes the SP, thus we have to update the SP used by the imported function. + + // func wasmExit(code int32) + "runtime.wasmExit": (sp) => { + sp >>>= 0; + const code = this.mem.getInt32(sp + 8, true); + this.exited = true; + delete this._inst; + delete this._values; + delete this._goRefCounts; + delete this._ids; + delete this._idPool; + this.exit(code); + }, + + // func wasmWrite(fd uintptr, p unsafe.Pointer, n int32) + "runtime.wasmWrite": (sp) => { + sp >>>= 0; + const fd = getInt64(sp + 8); + const p = getInt64(sp + 16); + const n = this.mem.getInt32(sp + 24, true); + fs.writeSync( + fd, + new Uint8Array(this._inst.exports.mem.buffer, p, n) + ); + }, + + // func resetMemoryDataView() + "runtime.resetMemoryDataView": (sp) => { + sp >>>= 0; + this.mem = new DataView(this._inst.exports.mem.buffer); + }, + + // func nanotime1() int64 + "runtime.nanotime1": (sp) => { + sp >>>= 0; + setInt64(sp + 8, (timeOrigin + performance.now()) * 1000000); + }, + + // func walltime() (sec int64, nsec int32) + "runtime.walltime": (sp) => { + sp >>>= 0; + const msec = new Date().getTime(); + setInt64(sp + 8, msec / 1000); + this.mem.setInt32(sp + 16, (msec % 1000) * 1000000, true); + }, + + // func scheduleTimeoutEvent(delay int64) int32 + "runtime.scheduleTimeoutEvent": (sp) => { + sp >>>= 0; + const id = this._nextCallbackTimeoutID; + this._nextCallbackTimeoutID++; + this._scheduledTimeouts.set( + id, + setTimeout( + () => { + this._resume(); + while (this._scheduledTimeouts.has(id)) { + // for some reason Go failed to register the timeout event, log and try again + // (temporary workaround for https://github.com/golang/go/issues/28975) + console.warn("scheduleTimeoutEvent: missed timeout event"); + this._resume(); + } + }, + getInt64(sp + 8) + ) + ); + this.mem.setInt32(sp + 16, id, true); + }, + + // func clearTimeoutEvent(id int32) + "runtime.clearTimeoutEvent": (sp) => { + sp >>>= 0; + const id = this.mem.getInt32(sp + 8, true); + clearTimeout(this._scheduledTimeouts.get(id)); + this._scheduledTimeouts.delete(id); + }, + + // func getRandomData(r []byte) + "runtime.getRandomData": (sp) => { + sp >>>= 0; + crypto.getRandomValues(loadSlice(sp + 8)); + }, + + // func finalizeRef(v ref) + "syscall/js.finalizeRef": (sp) => { + sp >>>= 0; + const id = this.mem.getUint32(sp + 8, true); + this._goRefCounts[id]--; + if (this._goRefCounts[id] === 0) { + const v = this._values[id]; + this._values[id] = null; + this._ids.delete(v); + this._idPool.push(id); + } + }, + + // func stringVal(value string) ref + "syscall/js.stringVal": (sp) => { + sp >>>= 0; + storeValue(sp + 24, loadString(sp + 8)); + }, + + // func valueGet(v ref, p string) ref + "syscall/js.valueGet": (sp) => { + sp >>>= 0; + const result = Reflect.get(loadValue(sp + 8), loadString(sp + 16)); + sp = this._inst.exports.getsp() >>> 0; // see comment above + storeValue(sp + 32, result); + }, + + // func valueSet(v ref, p string, x ref) + "syscall/js.valueSet": (sp) => { + sp >>>= 0; + Reflect.set( + loadValue(sp + 8), + loadString(sp + 16), + loadValue(sp + 32) + ); + }, + + // func valueDelete(v ref, p string) + "syscall/js.valueDelete": (sp) => { + sp >>>= 0; + Reflect.deleteProperty(loadValue(sp + 8), loadString(sp + 16)); + }, + + // func valueIndex(v ref, i int) ref + "syscall/js.valueIndex": (sp) => { + sp >>>= 0; + storeValue( + sp + 24, + Reflect.get(loadValue(sp + 8), getInt64(sp + 16)) + ); + }, + + // valueSetIndex(v ref, i int, x ref) + "syscall/js.valueSetIndex": (sp) => { + sp >>>= 0; + Reflect.set( + loadValue(sp + 8), + getInt64(sp + 16), + loadValue(sp + 24) + ); + }, + + // func valueCall(v ref, m string, args []ref) (ref, bool) + "syscall/js.valueCall": (sp) => { + sp >>>= 0; + try { + const v = loadValue(sp + 8); + const m = Reflect.get(v, loadString(sp + 16)); + const args = loadSliceOfValues(sp + 32); + const result = Reflect.apply(m, v, args); + sp = this._inst.exports.getsp() >>> 0; // see comment above + storeValue(sp + 56, result); + this.mem.setUint8(sp + 64, 1); + } catch (err) { + sp = this._inst.exports.getsp() >>> 0; // see comment above + storeValue(sp + 56, err); + this.mem.setUint8(sp + 64, 0); + } + }, + + // func valueInvoke(v ref, args []ref) (ref, bool) + "syscall/js.valueInvoke": (sp) => { + sp >>>= 0; + try { + const v = loadValue(sp + 8); + const args = loadSliceOfValues(sp + 16); + const result = Reflect.apply(v, undefined, args); + sp = this._inst.exports.getsp() >>> 0; // see comment above + storeValue(sp + 40, result); + this.mem.setUint8(sp + 48, 1); + } catch (err) { + sp = this._inst.exports.getsp() >>> 0; // see comment above + storeValue(sp + 40, err); + this.mem.setUint8(sp + 48, 0); + } + }, + + // func valueNew(v ref, args []ref) (ref, bool) + "syscall/js.valueNew": (sp) => { + sp >>>= 0; + try { + const v = loadValue(sp + 8); + const args = loadSliceOfValues(sp + 16); + const result = Reflect.construct(v, args); + sp = this._inst.exports.getsp() >>> 0; // see comment above + storeValue(sp + 40, result); + this.mem.setUint8(sp + 48, 1); + } catch (err) { + sp = this._inst.exports.getsp() >>> 0; // see comment above + storeValue(sp + 40, err); + this.mem.setUint8(sp + 48, 0); + } + }, + + // func valueLength(v ref) int + "syscall/js.valueLength": (sp) => { + sp >>>= 0; + setInt64(sp + 16, parseInt(loadValue(sp + 8).length)); + }, + + // valuePrepareString(v ref) (ref, int) + "syscall/js.valuePrepareString": (sp) => { + sp >>>= 0; + const str = encoder.encode(String(loadValue(sp + 8))); + storeValue(sp + 16, str); + setInt64(sp + 24, str.length); + }, + + // valueLoadString(v ref, b []byte) + "syscall/js.valueLoadString": (sp) => { + sp >>>= 0; + const str = loadValue(sp + 8); + loadSlice(sp + 16).set(str); + }, + + // func valueInstanceOf(v ref, t ref) bool + "syscall/js.valueInstanceOf": (sp) => { + sp >>>= 0; + this.mem.setUint8( + sp + 24, + loadValue(sp + 8) instanceof loadValue(sp + 16) ? 1 : 0 + ); + }, + + // func copyBytesToGo(dst []byte, src ref) (int, bool) + "syscall/js.copyBytesToGo": (sp) => { + sp >>>= 0; + const dst = loadSlice(sp + 8); + const src = loadValue(sp + 32); + if ( + !(src instanceof Uint8Array || src instanceof Uint8ClampedArray) + ) { + this.mem.setUint8(sp + 48, 0); + return; + } + const toCopy = src.subarray(0, dst.length); + dst.set(toCopy); + setInt64(sp + 40, toCopy.length); + this.mem.setUint8(sp + 48, 1); + }, + + // func copyBytesToJS(dst ref, src []byte) (int, bool) + "syscall/js.copyBytesToJS": (sp) => { + sp >>>= 0; + const dst = loadValue(sp + 8); + const src = loadSlice(sp + 16); + if ( + !(dst instanceof Uint8Array || dst instanceof Uint8ClampedArray) + ) { + this.mem.setUint8(sp + 48, 0); + return; + } + const toCopy = src.subarray(0, dst.length); + dst.set(toCopy); + setInt64(sp + 40, toCopy.length); + this.mem.setUint8(sp + 48, 1); + }, + + debug: (value) => { + console.log(value); + }, + }, + }; + } + + async run(instance) { + if (!(instance instanceof WebAssembly.Instance)) { + throw new Error("Go.run: WebAssembly.Instance expected"); + } + this._inst = instance; + this.mem = new DataView(this._inst.exports.mem.buffer); + this._values = [ + // JS values that Go currently has references to, indexed by reference id + NaN, + 0, + null, + true, + false, + globalThis, + this, + ]; + this._goRefCounts = new Array(this._values.length).fill(Infinity); // number of references that Go has to a JS value, indexed by reference id + this._ids = new Map([ + // mapping from JS values to reference ids + [0, 1], + [null, 2], + [true, 3], + [false, 4], + [globalThis, 5], + [this, 6], + ]); + this._idPool = []; // unused ids that have been garbage collected + this.exited = false; // whether the Go program has exited + + // Pass command line arguments and environment variables to WebAssembly by writing them to the linear memory. + let offset = 4096; + + const strPtr = (str) => { + const ptr = offset; + const bytes = encoder.encode(str + "\0"); + new Uint8Array(this.mem.buffer, offset, bytes.length).set(bytes); + offset += bytes.length; + if (offset % 8 !== 0) { + offset += 8 - (offset % 8); + } + return ptr; + }; + + const argc = this.argv.length; + + const argvPtrs = []; + this.argv.forEach((arg) => { + argvPtrs.push(strPtr(arg)); + }); + argvPtrs.push(0); + + const keys = Object.keys(this.env).sort(); + keys.forEach((key) => { + argvPtrs.push(strPtr(`${key}=${this.env[key]}`)); + }); + argvPtrs.push(0); + + const argv = offset; + argvPtrs.forEach((ptr) => { + this.mem.setUint32(offset, ptr, true); + this.mem.setUint32(offset + 4, 0, true); + offset += 8; + }); + + // The linker guarantees global data starts from at least wasmMinDataAddr. + // Keep in sync with cmd/link/internal/ld/data.go:wasmMinDataAddr. + const wasmMinDataAddr = 4096 + 8192; + if (offset >= wasmMinDataAddr) { + throw new Error( + "total length of command line and environment variables exceeds limit" + ); + } + + this._inst.exports.run(argc, argv); + if (this.exited) { + this._resolveExitPromise(); + } + await this._exitPromise; + } + + _resume() { + if (this.exited) { + throw new Error("Go program has already exited"); + } + this._inst.exports.resume(); + if (this.exited) { + this._resolveExitPromise(); + } + } + + _makeFuncWrapper(id) { + const go = this; + return function () { + const event = { id: id, this: this, args: arguments }; + go._pendingEvent = event; + go._resume(); + return event.result; + }; + } + }; +})(); diff --git a/site/app/components/Terminal.client.tsx b/site/app/components/Terminal.client.tsx new file mode 100644 index 0000000..b8a889c --- /dev/null +++ b/site/app/components/Terminal.client.tsx @@ -0,0 +1,116 @@ +import { useEffect, useRef, useState, useContext } from "react"; +import type React from "react"; +import { Terminal } from "@xterm/xterm"; +import { FitAddon } from "@xterm/addon-fit"; +import { CanvasAddon } from "@xterm/addon-canvas"; +// import { WebglAddon } from "@xterm/addon-webgl"; +import { WushContext } from "~/context/wush"; +import "@xterm/xterm/css/xterm.css"; + +interface WushTerminalProps { + authKey: string; +} + +const WushTerminal: React.FC = ({ authKey }) => { + const terminalRef = useRef(null); + const terminalInstance = useRef(null); + const fitAddonRef = useRef(); + const wushInitialized = useContext(WushContext); + const sshSessionRef = useRef(); + + useEffect(() => { + if (!wushInitialized) { + console.log("WASM not initialized, skipping terminal initialization"); + return; + } + + console.log("Terminal component mounted"); + + if (!terminalRef.current) { + console.log("Terminal ref is null, skipping terminal initialization"); + return; + } + + if (terminalInstance.current) { + console.log("Terminal already initialized, skipping"); + return; + } + + console.log("running wush"); + + console.log("Initializing terminal"); + + const term = new Terminal({ + cursorBlink: true, + theme: { + background: "#282a36", + foreground: "#f8f8f2", + }, + scrollback: 0, + }); + const fitAddon = new FitAddon(); + fitAddonRef.current = fitAddon; + term.loadAddon(fitAddon); + term.loadAddon(new CanvasAddon()); + // term.loadAddon(new WebglAddon()); + term.open(terminalRef.current); + fitAddon.fit(); + + let onDataHook: ((data: string) => void) | undefined; + term.onData((e) => { + onDataHook?.(e); + }); + + const resizeObserver = new window.ResizeObserver(() => fitAddon.fit()); + resizeObserver.observe(terminalRef.current); + + newWush({ authKey: authKey }).then((wush) => { + const sshSession = wush.ssh({ + writeFn(input) { + term.write(input); + }, + writeErrorFn(err) { + term.write(err); + }, + setReadFn(hook) { + onDataHook = hook; + }, + rows: term.rows, + cols: term.cols, + onConnectionProgress: (msg) => {}, + onConnected: () => {}, + onDone() { + resizeObserver?.disconnect(); + term.dispose(); + console.log("term done"); + sshSession.close(); + sshSessionRef.current = null; + }, + }); + + sshSessionRef.current = sshSession; + term.onResize(({ rows, cols }) => sshSession.resize(rows, cols)); + }); + + console.log("Terminal initialized and opened"); + terminalInstance.current = term; + fitAddon.fit(); + + return () => { + console.log("Disposing terminal"); + if (terminalInstance.current) { + resizeObserver.disconnect(); + terminalInstance.current.dispose(); + terminalInstance.current = null; + } + if (sshSessionRef.current) { + sshSessionRef.current.close(); + sshSessionRef.current = null; + } + }; + }, [authKey, wushInitialized]); + + return
; +}; + +export default WushTerminal; diff --git a/site/app/context/wush.ts b/site/app/context/wush.ts new file mode 100644 index 0000000..e6759ec --- /dev/null +++ b/site/app/context/wush.ts @@ -0,0 +1,3 @@ +import React from "react"; + +export const WushContext = React.createContext(false); diff --git a/site/app/entry.client.tsx b/site/app/entry.client.tsx new file mode 100644 index 0000000..1db62c7 --- /dev/null +++ b/site/app/entry.client.tsx @@ -0,0 +1,7 @@ +import { RemixBrowser } from "@remix-run/react"; +import { startTransition, StrictMode } from "react"; +import { hydrateRoot } from "react-dom/client"; + +startTransition(() => { + hydrateRoot(document, ); +}); diff --git a/site/app/entry.server.tsx b/site/app/entry.server.tsx new file mode 100644 index 0000000..65ad16e --- /dev/null +++ b/site/app/entry.server.tsx @@ -0,0 +1,155 @@ +import { PassThrough } from "node:stream"; + +import type { AppLoadContext, EntryContext } from "@remix-run/node"; +import { createReadableStreamFromReadable } from "@remix-run/node"; +import { RemixServer } from "@remix-run/react"; +import * as isbotModule from "isbot"; +import { renderToPipeableStream } from "react-dom/server"; + +const ABORT_DELAY = 5_000; + +export default function handleRequest( + request: Request, + responseStatusCode: number, + responseHeaders: Headers, + remixContext: EntryContext, + loadContext: AppLoadContext +) { + let prohibitOutOfOrderStreaming = + isBotRequest(request.headers.get("user-agent")) || remixContext.isSpaMode; + + return prohibitOutOfOrderStreaming + ? handleBotRequest( + request, + responseStatusCode, + responseHeaders, + remixContext + ) + : handleBrowserRequest( + request, + responseStatusCode, + responseHeaders, + remixContext + ); +} + +// We have some Remix apps in the wild already running with isbot@3 so we need +// to maintain backwards compatibility even though we want new apps to use +// isbot@4. That way, we can ship this as a minor Semver update to @remix-run/dev. +function isBotRequest(userAgent: string | null) { + if (!userAgent) { + return false; + } + + // isbot >= 3.8.0, >4 + if ("isbot" in isbotModule && typeof isbotModule.isbot === "function") { + return isbotModule.isbot(userAgent); + } + + // isbot < 3.8.0 + if ("default" in isbotModule && typeof isbotModule.default === "function") { + return isbotModule.default(userAgent); + } + + return false; +} + +function handleBotRequest( + request: Request, + responseStatusCode: number, + responseHeaders: Headers, + remixContext: EntryContext +) { + return new Promise((resolve, reject) => { + let shellRendered = false; + const { pipe, abort } = renderToPipeableStream( + , + { + onAllReady() { + shellRendered = true; + const body = new PassThrough(); + const stream = createReadableStreamFromReadable(body); + + responseHeaders.set("Content-Type", "text/html"); + + resolve( + new Response(stream, { + headers: responseHeaders, + status: responseStatusCode, + }) + ); + + pipe(body); + }, + onShellError(error: unknown) { + reject(error); + }, + onError(error: unknown) { + responseStatusCode = 500; + // Log streaming rendering errors from inside the shell. Don't log + // errors encountered during initial shell rendering since they'll + // reject and get logged in handleDocumentRequest. + if (shellRendered) { + console.error(error); + } + }, + } + ); + + setTimeout(abort, ABORT_DELAY); + }); +} + +function handleBrowserRequest( + request: Request, + responseStatusCode: number, + responseHeaders: Headers, + remixContext: EntryContext +) { + return new Promise((resolve, reject) => { + let shellRendered = false; + const { pipe, abort } = renderToPipeableStream( + , + { + onShellReady() { + shellRendered = true; + const body = new PassThrough(); + const stream = createReadableStreamFromReadable(body); + + responseHeaders.set("Content-Type", "text/html"); + + resolve( + new Response(stream, { + headers: responseHeaders, + status: responseStatusCode, + }) + ); + + pipe(body); + }, + onShellError(error: unknown) { + reject(error); + }, + onError(error: unknown) { + responseStatusCode = 500; + // Log streaming rendering errors from inside the shell. Don't log + // errors encountered during initial shell rendering since they'll + // reject and get logged in handleDocumentRequest. + if (shellRendered) { + console.error(error); + } + }, + } + ); + + setTimeout(abort, ABORT_DELAY); + }); +} diff --git a/site/app/root.tsx b/site/app/root.tsx new file mode 100644 index 0000000..b4fb738 --- /dev/null +++ b/site/app/root.tsx @@ -0,0 +1,109 @@ +import { + Links, + Meta, + Outlet, + Scripts, + ScrollRestoration, +} from "@remix-run/react"; +import type { LinksFunction, MetaFunction } from "@remix-run/node"; +import { useState, useEffect } from "react"; +import { WushContext } from "./context/wush"; +import wasmUrl from "~/assets/main.wasm?url"; +import goWasmUrl from "~/assets/wasm_exec.js?url"; + +import "./tailwind.css"; + +export const links: LinksFunction = () => [ + { + rel: "stylesheet", + href: "https://fonts.googleapis.com/css2?family=Fira+Code:wght@300;400;500;600;700&display=swap", + crossOrigin: "anonymous", + }, + { rel: "preconnect", href: "https://fonts.googleapis.com" }, + { + rel: "preconnect", + href: "https://fonts.gstatic.com", + crossOrigin: "anonymous", + }, + { + rel: "stylesheet", + href: "https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap", + }, +]; + +export const meta: MetaFunction = () => { + return [ + { title: "$ wush" }, + { name: "description", content: "wush - share terminals in the browser" }, + ]; +}; + +export function Layout({ children }: { children: React.ReactNode }) { + return ( + + + + + + +