Git Product home page Git Product logo

bubbletea-in-wasm's Introduction

Bubbletea in WASM

Screenshot of the example

This is a writeup on which changes to bubbletea are needed to make it work in WASM aka GOOS=js GOARCH=wasm go build and how to use it in the browser. main.go contains a working example with the bubbletea split-editor example.

Compile Problems

1. github.com/atotto/clipboard

As clipboard doesn't have function stubs for javascript it will fail to compile. This could simply solved by clipboard providing empty implementation for javascript. A simple solution would be to add a file clipboard_js.go with the following content:

clipboard_js.go

//go:build js
// +build js

package clipboard

import (
	"errors"
)

func readAll() (string, error) {
	return "", errors.New("not implemented")
}

func writeAll(text string) error {
	return errors.New("not implemented")
}

In this POC I solved this by the following replace in the go.mod to redirect the import to a local copy of clipboard which has the file.

replace github.com/atotto/clipboard => ./_vendor/clipboard

2. github.com/containerd/console

The version of this package that bubbletea uses will not compile because again it doesn't have function stubs for javascript. This can be solved by using a newer version of the package. In this POC I solved this by the following replace in the go.mod to use the current head of the master branch.

replace github.com/containerd/console => github.com/containerd/console v1.0.4-0.20230706203907-8f6c4e4faef5

3. Bubbletea itself

We need to provide javascript empty implementations for tty and signals. This can be done by adding the following files:

tty_js.go

//go:build js
// +build js

package tea

import (
	"errors"
	"os"
)

func (p *Program) initInput() error {
	return nil
}

func (p *Program) restoreInput() error {
	return nil
}

func openInputTTY() (*os.File, error) {
	return nil, errors.New("unavailable in js")
}

signals_js.go

//go:build js
// +build js

package tea

// listenForResize is a no-op on the web.
func (p *Program) listenForResize(done chan struct{}) {
	close(done)
}

In this POC I solved this by the following replace in the go.mod to redirect the import to a local copy of bubbletea which has the file.

replace github.com/charmbracelet/bubbletea => ./_vendor/bubbletea

Runtime Problems

Now that the code compiles we can run it but in the browser we don't have a terminal. So we need to provide one and redirect all the input / outputs. I used xterm.js as terminal in the browser.

1. Redirecting input and exposing JS functions

package main

import (
	"bytes"
	"fmt"
	tea "github.com/charmbracelet/bubbletea"
	"os"
	"syscall/js"
	"time"
)

type MinReadBuffer struct {
	buf *bytes.Buffer
}

// For some reason bubbletea doesn't like a Reader that will return 0 bytes instead of blocking,
// so we use this hacky workaround for now. As javascript is single threaded this should be fine
// with regard to concurrency.
func (b *MinReadBuffer) Read(p []byte) (n int, err error) {
	for b.buf.Len() == 0 {
		time.Sleep(100 * time.Millisecond)
	}
	return b.buf.Read(p)
}

func (b *MinReadBuffer) Write(p []byte) (n int, err error) {
	return b.buf.Write(p)
}

// Creates the bubbletea program and registers the necessary functions in javascript
func createTeaForJS(model tea.Model, option ...tea.ProgramOption) *tea.Program {
	// Create buffers for input and output
	fromJs := &MinReadBuffer{buf: bytes.NewBuffer(nil)}
	fromGo := bytes.NewBuffer(nil)

	prog := tea.NewProgram(model, append([]tea.ProgramOption{tea.WithInput(fromJs), tea.WithOutput(fromGo)}, option...)...)

	// Register write function in WASM
	js.Global().Set("bubbletea_write", js.FuncOf(func(this js.Value, args []js.Value) interface{} {
		fromJs.Write([]byte(args[0].String()))
		fmt.Println("Wrote to Go:", args[0].String())
		return nil
	}))

	// Register read function in WASM
	js.Global().Set("bubbletea_read", js.FuncOf(func(this js.Value, args []js.Value) interface{} {
		b := make([]byte, fromGo.Len())
		_, _ = fromGo.Read(b)
		fromGo.Reset()
		if len(b) > 0 {
			fmt.Println("Read from Go:", string(b))
		}
		return string(b)
	}))

	// Register resize function in WASM
	js.Global().Set("bubbletea_resize", js.FuncOf(func(this js.Value, args []js.Value) interface{} {
		width := args[0].Int()
		height := args[1].Int()
		prog.Send(tea.WindowSizeMsg{Width: width, Height: height})
		return nil
	}))

	return prog
}

func main() {
	// Init with some Model
	prog := createTeaForJS(newModel(), tea.WithAltScreen())

	fmt.Println("Starting program...")
	if _, err := prog.Run(); err != nil {
		fmt.Println("Error while running program:", err)
		os.Exit(1)
	}
}

2. Rendering the terminal

<html>
<head>
    <meta charset="utf-8">
    <script src="https://cdn.jsdelivr.net/npm/[email protected]/lib/xterm.min.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/[email protected]/lib/xterm-addon-fit.min.js"></script>
    <link href="https://cdn.jsdelivr.net/npm/[email protected]/css/xterm.min.css" rel="stylesheet">
    <script src="example/wasm_exec.js"></script>
    <style>
        html, body {
            height: 100%;
            margin: 0;
            padding: 0;
        }

        .terminal-container {
            /* this is important */
            overflow: hidden;
        }

        .xterm .xterm-viewport {
            /* see : https://github.com/xtermjs/xterm.js/issues/3564#issuecomment-1004417440 */
            width: initial !important;
        }
    </style>
</head>
<body>
    <div class="terminal-container" style="height: 100%; width: 100%;">
        <div id="terminal" style="height: 100%"></div>
    </div>
<script>
    function initTerminal() {
        // Check if bubbletea is initialized
        if (globalThis.bubbletea_resize === undefined || globalThis.bubbletea_read === undefined || globalThis.bubbletea_write === undefined) {
            setTimeout(() => {
                console.log("waiting for bubbletea");
                initTerminal();
            }, 500);
            return;
        }
        
        const term = new Terminal();
        const fitAddon = new FitAddon.FitAddon();
        term.loadAddon(fitAddon);
        term.open(document.getElementById('terminal'));

        // Register terminal resize
        fitAddon.fit();
        window.addEventListener('resize', () => (fitAddon.fit()));
        
        // Focus terminal
        term.focus();

        // Initial resize
        bubbletea_resize(term.cols, term.rows)

        // Read from bubbletea and write to xterm
        setInterval(() => {
            const read = bubbletea_read();
            if (read && read.length > 0) {
                term.write(read);
            }
        }, 100);

        // Resize on terminal resize
        term.onResize((size) => (bubbletea_resize(term.cols, term.rows)));

        // Write xterm output to bubbletea
        term.onData((data) => (bubbletea_write(data)));
    }

    function init() {
        const go = new Go();
        WebAssembly.instantiateStreaming(fetch("./bubbletea.wasm"), go.importObject).then((result) => {
            // Run wasm
            go.run(result.instance).then(() => {
                console.log("wasm finished");
            });

            // Init terminal
            initTerminal();
        })
    }

    init();
</script>
</body>
</html>

Building

Now that everything is in place we can build the WASM file. I use the following commands to build the WASM file:

cp "$(go env GOROOT)/misc/wasm/wasm_exec.js" ./example/wasm_exec.js # Optional as this is already in the example folder
GOOS=js GOARCH=wasm go build -o example/bubbletea.wasm .

After that we can start a webserver in the example folder and open the index.html in the browser. In case you have python3 installed you can use the following commands to quickly spin up a webserver:

cd example
python3 -m http.server

bubbletea-in-wasm's People

Contributors

bigjk avatar

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    ๐Ÿ–– Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. ๐Ÿ“Š๐Ÿ“ˆ๐ŸŽ‰

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google โค๏ธ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.