github.com/vugu/vugu@v0.3.6-0.20240430171613-3f6f402e014b/devutil/tinygo-compiler.go (about)

     1  package devutil
     2  
     3  import (
     4  	"bytes"
     5  	"errors"
     6  	"fmt"
     7  	"io"
     8  	"os"
     9  	"os/exec"
    10  	"path/filepath"
    11  	"strings"
    12  )
    13  
    14  // DefaultTinygoDockerImage is used as the docker image for Tinygo unless overridden.
    15  var DefaultTinygoDockerImage = "tinygo/tinygo:0.30.0"
    16  
    17  // MustNewTinygoCompiler is like NewTinygoCompiler but panics upon error.
    18  func MustNewTinygoCompiler() *TinygoCompiler {
    19  	c, err := NewTinygoCompiler()
    20  	if err != nil {
    21  		panic(err)
    22  	}
    23  	return c
    24  }
    25  
    26  // NewTinygoCompiler returns a new TinygoCompiler instance.
    27  func NewTinygoCompiler() (*TinygoCompiler, error) {
    28  	return &TinygoCompiler{
    29  		logWriter:         os.Stderr,
    30  		tinygoDockerImage: DefaultTinygoDockerImage,
    31  	}, nil
    32  }
    33  
    34  // TinygoCompiler provides a convenient way to build a program via Tinygo into Wasm.
    35  // This implementation by default uses Docker to download and run Tinygo, and provides methods
    36  // to handle mapping local directories into the Tinygo docker filesystem and for
    37  // making other dependencies available by calling `go get` on them.  This approach
    38  // might change once Tinygo has module support, but for now the idea is it
    39  // makes it reasonably convenient to integration Tinygo into the workflow for Vugu app.
    40  type TinygoCompiler struct {
    41  	beforeFunc         func() error
    42  	generateCmdFunc    func() *exec.Cmd
    43  	buildCmdFunc       func(outpath string) *exec.Cmd
    44  	dockerBuildCmdFunc func(outpath string) *exec.Cmd
    45  	afterFunc          func(outpath string, err error) error
    46  	logWriter          io.Writer
    47  	//nolint:golint,unused
    48  	dlTmpGopath       string   // temporary directory that we download dependencies into with go get
    49  	tinygoDockerImage string   // docker image name to use, if empty then it is run directly
    50  	wasmExecJS        []byte   // contents of wasm_exec.js
    51  	tinygoArgs        []string // additional arguments to pass to the tinygo build cmd
    52  }
    53  
    54  // Close performs any cleanup.  For now it removes the temporary directory created by NewTinygoCompiler.
    55  func (c *TinygoCompiler) Close() error {
    56  	return nil
    57  }
    58  
    59  // SetTinygoArgs sets arguments to be passed to tinygo, e.g. -no-debug
    60  func (c *TinygoCompiler) SetTinygoArgs(tinygoArgs ...string) *TinygoCompiler {
    61  	c.tinygoArgs = tinygoArgs
    62  	return c
    63  }
    64  
    65  // SetLogWriter sets the writer to use for logging output.  Setting it to nil disables logging.
    66  // The default from NewCompiler is os.Stderr
    67  func (c *TinygoCompiler) SetLogWriter(w io.Writer) *TinygoCompiler {
    68  	if w == nil {
    69  		w = io.Discard
    70  	}
    71  	c.logWriter = w
    72  	return c
    73  }
    74  
    75  // SetDir sets both the build and generate directories.
    76  func (c *TinygoCompiler) SetDir(dir string) *TinygoCompiler {
    77  	return c.SetBuildDir(dir).SetGenerateDir(dir)
    78  }
    79  
    80  // SetBuildDir sets the directory of the main package, where `go build` will be run.
    81  // Relative paths are okay and will be resolved with filepath.Abs.
    82  func (c *TinygoCompiler) SetBuildDir(dir string) *TinygoCompiler {
    83  	return c.SetBuildCmdFunc(func(outpath string) *exec.Cmd {
    84  		cmd := exec.Command("tinygo", "build", "-target=wasm", "-o", outpath, ".")
    85  		cmd.Dir = dir
    86  		return cmd
    87  	}).SetDockerBuildCmdFunc(func(outpath string) *exec.Cmd {
    88  		buildDir := dir
    89  		buildDirAbs, err := filepath.Abs(buildDir)
    90  		if err != nil {
    91  			panic(err)
    92  		}
    93  		buildDirAbs, err = filepath.EvalSymlinks(buildDirAbs) // Mac OS /var -> /var/private bs
    94  		if err != nil {
    95  			panic(err)
    96  		}
    97  
    98  		tinygoDockerImage := c.tinygoDockerImage
    99  
   100  		// run tinygo via docker
   101  		// example: docker run --rm \
   102  		// -v /:/src \
   103  		// -w /src/`pwd` \
   104  		// tinygo/tinygo:0.30.0 tinygo build -o /root/go/src/example.com/tgtest1/out.wasm \
   105  		// -target=wasm .
   106  
   107  		args := make([]string, 0, 20)
   108  		args = append(args, "run", "--rm")
   109  		args = append(args, "-v", "/:/src") // map dir for dependencies
   110  		args = append(args, "-e", "HOME=/tmp")
   111  		args = append(args, fmt.Sprintf("--user=%d", os.Getuid()))
   112  		args = append(args, "-w", "/src"+buildDirAbs)
   113  
   114  		args = append(args, tinygoDockerImage)
   115  		args = append(args, "tinygo", "build")
   116  		args = append(args, "-o", "/src/"+outpath)
   117  		args = append(args, "-target=wasm")
   118  		args = append(args, c.tinygoArgs...)
   119  		args = append(args, ".")
   120  
   121  		return exec.Command("docker", args...)
   122  	})
   123  }
   124  
   125  // SetBuildCmdFunc provides a function to create the exec.Cmd used when running `go build`.
   126  // It overrides any other build-related setting.
   127  func (c *TinygoCompiler) SetBuildCmdFunc(cmdf func(outpath string) *exec.Cmd) *TinygoCompiler {
   128  	c.buildCmdFunc = cmdf
   129  	return c
   130  }
   131  
   132  // SetDockerBuildCmdFunc provides a function to create the exec.Cmd used when running
   133  // `tinygo build` in docker.
   134  func (c *TinygoCompiler) SetDockerBuildCmdFunc(cmdf func(outpath string) *exec.Cmd) *TinygoCompiler {
   135  	c.dockerBuildCmdFunc = cmdf
   136  	return c
   137  }
   138  
   139  // SetGenerateDir sets the directory of where `go generate` will be run.
   140  // Relative paths are okay and will be resolved with filepath.Abs.
   141  func (c *TinygoCompiler) SetGenerateDir(dir string) *TinygoCompiler {
   142  	return c.SetGenerateCmdFunc(func() *exec.Cmd {
   143  		cmd := exec.Command("go", "generate")
   144  		cmd.Dir = dir
   145  		return cmd
   146  	})
   147  }
   148  
   149  // SetGenerateCmdFunc provides a function to create the exec.Cmd used when running `go generate`.
   150  // It overrides any other generate-related setting.
   151  func (c *TinygoCompiler) SetGenerateCmdFunc(cmdf func() *exec.Cmd) *TinygoCompiler {
   152  	c.generateCmdFunc = cmdf
   153  	return c
   154  }
   155  
   156  // SetBeforeFunc specifies a function to be executed before anything else during Execute().
   157  func (c *TinygoCompiler) SetBeforeFunc(f func() error) *TinygoCompiler {
   158  	c.beforeFunc = f
   159  	return c
   160  }
   161  
   162  // SetAfterFunc specifies a function to be executed after everthing else during Execute().
   163  func (c *TinygoCompiler) SetAfterFunc(f func(outpath string, err error) error) *TinygoCompiler {
   164  	c.afterFunc = f
   165  	return c
   166  }
   167  
   168  // NoDocker is an alias for SetTinygoDockerImage("") and will result in the tinygo
   169  // executable being run on the local system instead of via docker image.
   170  func (c *TinygoCompiler) NoDocker() *TinygoCompiler {
   171  	return c.SetTinygoDockerImage("")
   172  }
   173  
   174  // SetTinygoDockerImage will specify the docker image to use when invoking Tinygo.
   175  // The default value is the value of when NewTinygoCompiler was called.
   176  // If you specify an empty string then the "tinygo" command will be run directly
   177  // on the local system.
   178  func (c *TinygoCompiler) SetTinygoDockerImage(img string) *TinygoCompiler {
   179  	c.tinygoDockerImage = img
   180  	return c
   181  }
   182  
   183  // Execute runs the generate command (if any) and then invokes the Tinygo compiler
   184  // and produces a wasm executable (or an error).
   185  // The value of outpath is the absolute path to the output file on disk.
   186  // It will be created with a temporary name and if no error is returned
   187  // it is the caller's responsibility to delete the file when it is no longer needed.
   188  // If an error occurs during any of the steps it will be returned with (possibly multi-line)
   189  // descriptive output in it's error message, as produced by the underlying tool.
   190  func (c *TinygoCompiler) Execute() (outpath string, err error) {
   191  
   192  	logerr := func(e error) error {
   193  		if e == nil {
   194  			return nil
   195  		}
   196  		fmt.Fprintln(c.logWriter, e)
   197  		return e
   198  	}
   199  
   200  	if c.buildCmdFunc == nil {
   201  		return "", logerr(errors.New("TinygoCompiler: no build directory set, cannot continue (did you forget to call SetBulidDir?)"))
   202  	}
   203  
   204  	if c.beforeFunc != nil {
   205  		err := c.beforeFunc()
   206  		if err != nil {
   207  			return "", logerr(err)
   208  		}
   209  	}
   210  
   211  	if c.generateCmdFunc != nil {
   212  		cmd := c.generateCmdFunc()
   213  		b, err := cmd.CombinedOutput()
   214  		if err != nil {
   215  			return "", logerr(fmt.Errorf("TinygoCompiler: generate error: %w; full output:\n%s", err, b))
   216  		}
   217  		fmt.Fprintln(c.logWriter, "TinygoCompiler: Successful generate")
   218  	}
   219  
   220  	tmpf, err := os.CreateTemp("", "WasmCompiler")
   221  	if err != nil {
   222  		return "", logerr(fmt.Errorf("WasmCompiler: error creating temporary file: %w", err))
   223  	}
   224  
   225  	outpath = tmpf.Name()
   226  
   227  	err = tmpf.Close()
   228  	if err != nil {
   229  		return outpath, logerr(fmt.Errorf("WasmCompiler: error closing temporary file: %w", err))
   230  	}
   231  
   232  	os.Remove(outpath)
   233  
   234  	if c.tinygoDockerImage == "" {
   235  		cmd := c.buildCmdFunc(outpath)
   236  		b, err := cmd.CombinedOutput()
   237  		if err != nil {
   238  			return "", logerr(fmt.Errorf("TinygoCompiler: build error: %w; cmd.args: %v, full output:\n%s", err, cmd.Args, b))
   239  		}
   240  		fmt.Fprintf(c.logWriter, "TinygoCompiler: successful build\n")
   241  
   242  	} else {
   243  		cmd := c.dockerBuildCmdFunc(outpath)
   244  		b, err := cmd.CombinedOutput()
   245  		if err != nil {
   246  			return "", logerr(fmt.Errorf("TinygoCompiler: build error: %w; cmd.args: %v, full output:\n%s", err, cmd.Args, b))
   247  		}
   248  		fmt.Fprintf(c.logWriter, "TinygoCompiler: successful build. Output: %s\n", b)
   249  	}
   250  
   251  	return outpath, nil
   252  }
   253  
   254  // WasmExecJS returns the contents of the wasm_exec.js file bundled with Tinygo.
   255  func (c *TinygoCompiler) WasmExecJS() (r io.Reader, err error) {
   256  
   257  	if c.wasmExecJS != nil {
   258  		return bytes.NewReader(c.wasmExecJS), nil
   259  	}
   260  
   261  	tinygoDockerImage := c.tinygoDockerImage
   262  
   263  	// direct way, not via docker
   264  	if tinygoDockerImage == "" {
   265  
   266  		cmd := exec.Command("tinygo", "env", "TINYGOROOT")
   267  		resb, err := cmd.CombinedOutput()
   268  		if err != nil {
   269  			return nil, fmt.Errorf("TinygoCompiler: WasmExecJS error getting TINYGOROOT: %w; full output:\n%s", err, resb)
   270  		}
   271  
   272  		wasmExecJSPath := filepath.Join(strings.TrimSpace(string(resb)), "targets/wasm_exec.js")
   273  		b, err := os.ReadFile(wasmExecJSPath)
   274  		if err != nil {
   275  			return nil, fmt.Errorf("TinygoCompiler: WasmExecJS error reading %q: %w", wasmExecJSPath, err)
   276  		}
   277  
   278  		c.wasmExecJS = b
   279  
   280  		return bytes.NewReader(c.wasmExecJS), nil
   281  	}
   282  
   283  	// via docker
   284  
   285  	args := make([]string, 0, 20)
   286  	args = append(args, "run", "--rm", "-i")
   287  	args = append(args, tinygoDockerImage)
   288  	args = append(args, "/bin/bash", "-c")
   289  	// different locations between tinygo and tinygo-dev, check them both
   290  	args = append(args, "cat `tinygo env TINYGOROOT`/targets/wasm_exec.js")
   291  
   292  	cmd := exec.Command("docker", args...)
   293  	b, err := cmd.CombinedOutput()
   294  	if err != nil {
   295  		return nil, fmt.Errorf("TinygoCompiler: wasm_exec.js error (cmd=docker %v): %w; full output:\n%s", args, err, b)
   296  	}
   297  	// fmt.Fprintf(c.logWriter, "TinygoCompiler: successful wasm_exec.js: docker %v; output: %s\n", args, b)
   298  
   299  	c.wasmExecJS = b
   300  
   301  	return bytes.NewReader(c.wasmExecJS), nil
   302  
   303  }