From 061c0f135037f65d3ed7ee16671d79052d29f0b4 Mon Sep 17 00:00:00 2001 From: Matt Mueller Date: Mon, 30 Jan 2023 01:40:17 -0600 Subject: [PATCH] public: integrate transpiler support into public (#371) --- example/hn/go.sum | 2 + framework/app/loader.go | 8 +-- framework/public/loader.go | 18 +++++-- framework/public/public_test.go | 90 +++++++++++++++++++++++++++++++- internal/testcli/process.go | 71 +++++++++++++++++++++++++ internal/testcli/testcli.go | 28 +++++----- runtime/transpiler/transpiler.go | 24 ++++++++- 7 files changed, 217 insertions(+), 24 deletions(-) create mode 100644 internal/testcli/process.go diff --git a/example/hn/go.sum b/example/hn/go.sum index 97dce1c0..feb61f83 100644 --- a/example/hn/go.sum +++ b/example/hn/go.sum @@ -2,6 +2,7 @@ github.com/BurntSushi/toml v0.4.1/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbi github.com/OneOfOne/xxhash v1.2.2 h1:KMrpdQIwFcEqXDklaen+P1axHaj9BSKzvpUUfnHldSE= github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= github.com/PuerkitoBio/goquery v1.8.0/go.mod h1:ypIiRMtY7COPGk+I/YbZLbxsxn9g5ejnI2HSMtkjZvI= +github.com/RyanCarrier/dijkstra v1.1.0 h1:/NDihjfJA3CxFaZz8EdzTwdFKFZDvvB881OVLdakRcI= github.com/ajg/form v1.5.2-0.20200323032839-9aeb3cf462e1 h1:8Qzi+0Uch1VJvdrOhJ8U8FqoPLbUdETPgMqGJ6DSMSQ= github.com/ajg/form v1.5.2-0.20200323032839-9aeb3cf462e1/go.mod h1:uL1WgH+h2mgNtvBq0339dVnzXdBETtL2LeUXaIv25UY= github.com/andybalholm/cascadia v1.3.1/go.mod h1:R4bJ1UQfqADjvDa4P6HZHLh/3OxWWEqc0Sk8XGwHqvA= @@ -40,6 +41,7 @@ github.com/lithammer/dedent v1.1.0 h1:VNzHMVCBNG1j0fh3OrsFRkVUwStdDArbgBWoPAffkt github.com/lithammer/dedent v1.1.0/go.mod h1:jrXYCQtgg0nJiN+StA2KgR7w6CiQNv9Fd/Z9BP0jIOc= github.com/livebud/bud-test-nested-plugin v0.0.5/go.mod h1:M3QujkGG4ggZ6h75t5zF8MEJFrLTwa2USeIYHQdO2YQ= github.com/livebud/bud-test-plugin v0.0.9/go.mod h1:GTxMZ8W4BIyGIOgAA4hvPHMDDTkaZtfcuhnOcSu3y8M= +github.com/livebud/transpiler v0.0.1 h1:raG4W8qMq534VYrWv5sFQieQCa4McrJhQOy0YXk63kk= github.com/matryer/is v1.4.0 h1:sosSmIWwkYITGrxZ25ULNDeKiMNzFSr4V/eqBQP0PeE= github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU= github.com/matthewmueller/diff v0.0.0-20220104030700-cb2fe910d90c h1:yjGBNrCIE7IghJAwrFcyDzwzwJKf0oRPeOHx60wfkmA= diff --git a/framework/app/loader.go b/framework/app/loader.go index 2ff1b675..0348d9ce 100644 --- a/framework/app/loader.go +++ b/framework/app/loader.go @@ -56,6 +56,7 @@ func (l *loader) loadProvider() *di.Provider { // TODO: the public generator should be able to configure this publicFS := di.ToType("github.com/livebud/bud/framework/public/publicrt", "FS") viewFS := di.ToType("github.com/livebud/bud/framework/view/viewrt", "FS") + transpilerFS := di.ToType("github.com/livebud/bud/runtime/transpiler", "FS") fn := &di.Function{ Name: "loadWeb", Imports: l.imports, @@ -72,9 +73,10 @@ func (l *loader) loadProvider() *di.Provider { &di.Error{}, }, Aliases: di.Aliases{ - publicFS: di.ToType("github.com/livebud/bud/package/remotefs", "*Client"), - viewFS: di.ToType("github.com/livebud/bud/package/remotefs", "*Client"), - jsVM: di.ToType("github.com/livebud/bud/package/budhttp", "Client"), + transpilerFS: di.ToType("github.com/livebud/bud/package/remotefs", "*Client"), + publicFS: di.ToType("github.com/livebud/bud/runtime/transpiler", "*Proxy"), + viewFS: di.ToType("github.com/livebud/bud/package/remotefs", "*Client"), + jsVM: di.ToType("github.com/livebud/bud/package/budhttp", "Client"), }, } if l.flag.Embed { diff --git a/framework/public/loader.go b/framework/public/loader.go index eec86dde..029bfb92 100644 --- a/framework/public/loader.go +++ b/framework/public/loader.go @@ -1,11 +1,13 @@ package public import ( + "errors" "io/fs" "path" "strings" "github.com/livebud/bud/internal/valid" + "github.com/livebud/bud/runtime/transpiler" "github.com/livebud/bud/framework" "github.com/livebud/bud/package/finder" @@ -68,14 +70,20 @@ func (l *loader) loadFiles(paths []string) (files []*File) { return files } -func (l *loader) loadFile(path string) *File { +func (l *loader) loadFile(fpath string) *File { file := new(File) - file.Path = path - file.Route = strings.TrimPrefix(path, "public") + file.Path = fpath + file.Route = strings.TrimPrefix(fpath, "public") if l.flag.Embed { - data, err := fs.ReadFile(l.fsys, path) + data, err := transpiler.TranspileFile(l.fsys, fpath, path.Ext(fpath)) if err != nil { - l.Bail(err) + if !errors.Is(err, fs.ErrNotExist) { + l.Bail(err) + } + data, err = fs.ReadFile(l.fsys, fpath) + if err != nil { + l.Bail(err) + } } file.Data = data } diff --git a/framework/public/public_test.go b/framework/public/public_test.go index 5f356f88..56fda3b1 100644 --- a/framework/public/public_test.go +++ b/framework/public/public_test.go @@ -149,8 +149,48 @@ func TestGetChangeGet(t *testing.T) { is.NoErr(err) is.Equal(200, res.Status()) is.Equal(res.Body().Bytes(), favicon2) - // is.Equal(result.Stdout(), "") - // is.Equal(result.Stderr(), "") +} + +func TestTranspiledGetChangeGet(t *testing.T) { + is := is.New(t) + ctx := context.Background() + dir := t.TempDir() + td := testdir.New(dir) + favicon := []byte{0x01, 0x02, 0x03} + td.BFiles["public/favicon.ico"] = favicon + td.Files["transpiler/favicon/favicon.go"] = ` + package favicon + import "github.com/livebud/bud/runtime/transpiler" + type Transpiler struct{} + func (t *Transpiler) IcoToIco(file *transpiler.File) error { + for i, b := range file.Data { + file.Data[i] = b + 1 + } + return nil + } + ` + is.NoErr(td.Write(ctx)) + cli := testcli.New(dir) + app, err := cli.Start(ctx, "run") + is.NoErr(err) + defer app.Close() + res, err := app.Get("/favicon.ico") + is.NoErr(err) + is.Equal(200, res.Status()) + is.Equal(res.Body().Bytes(), []byte{0x02, 0x03, 0x04}) + is.NoErr(td.Exists("bud/internal/web/public/public.go")) + // Favicon2 + favicon2 := []byte{0x10, 0x11, 0x12} + td.BFiles["public/favicon.ico"] = favicon2 + is.NoErr(td.Write(ctx)) + readyCtx, cancel := context.WithTimeout(ctx, 15*time.Second) + is.NoErr(app.Ready(readyCtx)) + cancel() + is.NoErr(td.Exists("bud/internal/web/public/public.go")) + res, err = app.Get("/favicon.ico") + is.NoErr(err) + is.Equal(200, res.Status()) + is.Equal(res.Body().Bytes(), []byte{0x11, 0x12, 0x13}) } func TestEmbedFavicon(t *testing.T) { @@ -183,3 +223,49 @@ func TestEmbedFavicon(t *testing.T) { is.Equal(res.Body().Bytes(), favicon) is.NoErr(app.Close()) } + +func TestTranspiledEmbedFavicon(t *testing.T) { + is := is.New(t) + ctx := context.Background() + dir := t.TempDir() + td := testdir.New(dir) + td.BFiles["public/favicon.ico"] = favicon + td.Files["transpiler/favicon/favicon.go"] = ` + package favicon + import "github.com/livebud/bud/runtime/transpiler" + type Transpiler struct{} + func (t *Transpiler) IcoToIco(file *transpiler.File) error { + file.Data = []byte{0x01, 0x02, 0x03} + return nil + } + ` + is.NoErr(td.Write(ctx)) + cli := testcli.New(dir) + result, err := cli.Run(ctx, "build") + is.NoErr(err) + is.Equal(result.Stdout(), "") + is.Equal(result.Stderr(), "") + app, err := cli.StartApp(ctx) + is.NoErr(err) + defer app.Close() + res, err := app.Get("/favicon.ico") + is.NoErr(err) + is.Equal(200, res.Status()) + is.Equal(res.Body().Bytes(), []byte{0x01, 0x02, 0x03}) + // Replace favicon + favicon2 := []byte{0x00, 0x00, 0x01} + td.BFiles["public/favicon.ico"] = favicon2 + is.NoErr(td.Write(ctx)) + is.NoErr(app.Close()) + // Restart app + app, err = cli.StartApp(ctx) + is.NoErr(err) + defer app.Close() + // Favicon shouldn't have changed because non-Go files don't trigger + // full rebuilds and server restarts + res, err = app.Get("/favicon.ico") + is.NoErr(err) + is.Equal(200, res.Status()) + is.Equal(res.Body().Bytes(), []byte{0x01, 0x02, 0x03}) + is.NoErr(app.Close()) +} diff --git a/internal/testcli/process.go b/internal/testcli/process.go new file mode 100644 index 00000000..ea80f43a --- /dev/null +++ b/internal/testcli/process.go @@ -0,0 +1,71 @@ +package testcli + +import ( + "bytes" + "context" + "io" + "net/http" + "os" + "path/filepath" + + "github.com/livebud/bud/internal/extrafile" + + "github.com/livebud/bud/internal/once" + "github.com/livebud/bud/internal/shell" +) + +// StartApp starts bud/app. It's meant to be used after running `bud build`. +// TODO: integrate better with testcli +func (c *CLI) StartApp(ctx context.Context, args ...string) (*Process, error) { + webLn, webc, err := listen(":0") + if err != nil { + return nil, err + } + stdout := new(bytes.Buffer) + stderr := new(bytes.Buffer) + cmd := shell.Command{ + Dir: c.dir, + Env: c.Env.List(), + Stdin: c.Stdin, + Stdout: io.MultiWriter(os.Stdout, stdout), + Stderr: io.MultiWriter(os.Stderr, stderr), + } + closer := new(once.Closer) + webFile, err := webLn.File() + if err != nil { + return nil, err + } + closer.Add(webFile.Close) + extrafile.Inject(&cmd.ExtraFiles, &cmd.Env, "WEB", webFile) + args = append(args, "--listen", webLn.Addr().String()) + process, err := cmd.Start(ctx, filepath.Join("bud", "app"), prependFlags(args)...) + if err != nil { + return nil, err + } + closer.Add(process.Close) + return &Process{ + closer: closer, + stdout: stdout, + stderr: stderr, + webc: webc, + }, nil +} + +type Process struct { + closer *once.Closer + stdout *bytes.Buffer + stderr *bytes.Buffer + webc *http.Client +} + +func (p *Process) Close() error { + return p.closer.Close() +} + +func (p *Process) Get(path string) (*Response, error) { + req, err := getRequest(path) + if err != nil { + return nil, err + } + return do(p.webc, req) +} diff --git a/internal/testcli/testcli.go b/internal/testcli/testcli.go index ee080002..50ca2c9e 100644 --- a/internal/testcli/testcli.go +++ b/internal/testcli/testcli.go @@ -314,7 +314,11 @@ func coerceMimes(res *http.Response) error { } func (c *Client) Do(req *http.Request) (*Response, error) { - res, err := c.webc.Do(req) + return do(c.webc, req) +} + +func do(client *http.Client, req *http.Request) (*Response, error) { + res, err := client.Do(req) if err != nil { return nil, err } @@ -367,24 +371,24 @@ func (c *Client) Ready(ctx context.Context) error { func (c *Client) Get(path string) (*Response, error) { c.log.Debug("testcli: get request %q", path) - req, err := c.GetRequest(path) + req, err := getRequest(path) if err != nil { return nil, err } - return c.Do(req) + return do(c.webc, req) } func (c *Client) GetJSON(path string) (*Response, error) { c.log.Debug("testcli: get json request %q", path) - req, err := c.GetRequest(path) + req, err := getRequest(path) if err != nil { return nil, err } req.Header.Set("Accept", "application/json") - return c.Do(req) + return do(c.webc, req) } -func (c *Client) GetRequest(path string) (*http.Request, error) { +func getRequest(path string) (*http.Request, error) { return http.NewRequest(http.MethodGet, getURL(path), nil) } @@ -394,7 +398,7 @@ func (c *Client) Post(path string, body io.Reader) (*Response, error) { if err != nil { return nil, err } - return c.Do(req) + return do(c.webc, req) } func (c *Client) PostJSON(path string, body io.Reader) (*Response, error) { @@ -405,7 +409,7 @@ func (c *Client) PostJSON(path string, body io.Reader) (*Response, error) { } req.Header.Set("Content-Type", "application/json") req.Header.Set("Accept", "application/json") - return c.Do(req) + return do(c.webc, req) } func (c *Client) PostRequest(path string, body io.Reader) (*http.Request, error) { @@ -418,7 +422,7 @@ func (c *Client) Patch(path string, body io.Reader) (*Response, error) { if err != nil { return nil, err } - return c.Do(req) + return do(c.webc, req) } func (c *Client) PatchJSON(path string, body io.Reader) (*Response, error) { @@ -429,7 +433,7 @@ func (c *Client) PatchJSON(path string, body io.Reader) (*Response, error) { } req.Header.Set("Content-Type", "application/json") req.Header.Set("Accept", "application/json") - return c.Do(req) + return do(c.webc, req) } func (c *Client) PatchRequest(path string, body io.Reader) (*http.Request, error) { @@ -442,7 +446,7 @@ func (c *Client) Delete(path string, body io.Reader) (*Response, error) { if err != nil { return nil, err } - return c.Do(req) + return do(c.webc, req) } func (c *Client) DeleteJSON(path string, body io.Reader) (*Response, error) { @@ -453,7 +457,7 @@ func (c *Client) DeleteJSON(path string, body io.Reader) (*Response, error) { } req.Header.Set("Content-Type", "application/json") req.Header.Set("Accept", "application/json") - return c.Do(req) + return do(c.webc, req) } func (c *Client) DeleteRequest(path string, body io.Reader) (*http.Request, error) { diff --git a/runtime/transpiler/transpiler.go b/runtime/transpiler/transpiler.go index e0f1f8d1..0e52ca16 100644 --- a/runtime/transpiler/transpiler.go +++ b/runtime/transpiler/transpiler.go @@ -1,6 +1,7 @@ package transpiler import ( + "errors" "io/fs" "path" "strings" @@ -29,14 +30,17 @@ func splitRoot(fpath string) (rootDir, remainingPath string) { return parts[0], parts[1] } +// Aliasing allows us to target the transpiler filesystem directly +type FS = fs.FS + // TranspileFile transpiles a file from one extension to another. It assumes // the transpiler generator is hooked up and serving from the transpiler // directory. -func TranspileFile(fsys fs.FS, inputPath, toExt string) ([]byte, error) { +func TranspileFile(fsys FS, inputPath, toExt string) ([]byte, error) { return fs.ReadFile(fsys, path.Join(transpilerDir, toExt, inputPath)) } -func Serve(tr transpiler.Interface, fsys fs.FS, file *genfs.File) error { +func Serve(tr transpiler.Interface, fsys FS, file *genfs.File) error { toExt, inputPath := splitRoot(file.Relative()) input, err := fs.ReadFile(fsys, inputPath) if err != nil { @@ -49,3 +53,19 @@ func Serve(tr transpiler.Interface, fsys fs.FS, file *genfs.File) error { file.Data = output return nil } + +// Proxy a filesystem through the transpiler +type Proxy struct { + FS FS +} + +func (p *Proxy) Open(name string) (fs.File, error) { + file, err := p.FS.Open(path.Join(transpilerDir, path.Ext(name), name)) + if err != nil { + if !errors.Is(err, fs.ErrNotExist) { + return nil, err + } + return p.FS.Open(name) + } + return file, nil +}