├── .gitignore ├── LICENSE ├── Makefile ├── README.md ├── _example ├── index.html ├── lib │ ├── cta.jsx │ ├── features.jsx │ ├── footer.jsx │ ├── hero.jsx │ ├── nav.js │ └── page.js ├── main.css ├── main.mjs └── style │ └── hero.css ├── cmd └── hotweb │ └── main.go ├── go.mod ├── go.sum └── pkg ├── esbuild ├── esbuild.go └── esbuild_test.go ├── hotweb ├── hotweb.go ├── hotweb_test.go ├── js_client.go └── js_proxy.go ├── jsexports └── jsexports.go └── makefs ├── makefs.go └── makefs_test.go /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.exe~ 4 | *.dll 5 | *.so 6 | *.dylib 7 | 8 | # Test binary, built with `go test -c` 9 | *.test 10 | 11 | # Output of the go coverage tool, specifically when used with LiteIDE 12 | *.out 13 | 14 | # Dependency directories (remove the comment below to include it) 15 | local/ 16 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Jeff Lindsay 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | 2 | install: 3 | go install ./cmd/hotweb 4 | 5 | test: 6 | go test ./... -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # hotweb 2 | Live reloading and ES6 hot module replacement for plain old JavaScript 3 | 4 | Although a number of tools exist for live development, this tool was 5 | created for per module reloading, specifically ES6 modules. 6 | It also reloads CSS and full pages on HTML changes if desired. 7 | 8 | When used with plain old JavaScript component frameworks like 9 | [Mithril](https://mithril.js.org/), you can finally have modern, component-based frontend 10 | development **without a compile step, without Node.js or any node_modules, 11 | and without Webpack.** 12 | 13 | **NEW** Supports on-the-fly conversion of JSX, see `_example` 14 | 15 | ## Getting hotweb 16 | ``` 17 | $ go get -U github.com/progrium/hotweb/cmd/hotweb 18 | ``` 19 | 20 | ## Quickstart with example 21 | The `_example` directory contains a small Mithril+Bulma application. I took a free 22 | layout and broke it into Mithril components. You can use this to test the reloading 23 | capabilities. Just run `hotweb` in the `_example` directory and start changing HTML, CSS, 24 | or JavaScript. You'll notice changing the JavaScript updates the browser without reloading 25 | the page. 26 | 27 | ## Using hotweb 28 | 29 | ### Setting up the hotweb JS client 30 | Add this line to your main Javascript module: 31 | ```javascript 32 | import * as hotweb from '/.hotweb/client.mjs'; 33 | ``` 34 | Now any JavaScript loaded will be reloaded when their files are changed. 35 | There is a callback for when a reload occurs so you can trigger whatever needs 36 | to be re-evaluated with the reloaded modules. For example, with Mithril this 37 | is where you would call `m.redraw()`: 38 | ```javascript 39 | hotweb.refresh(() => m.redraw()); 40 | ``` 41 | To enable full page reloads on HTML changes: 42 | ```javascript 43 | hotweb.watchHTML(); 44 | ``` 45 | To enable CSS hot reloads: 46 | ```javascript 47 | hotweb.watchCSS(); 48 | ``` 49 | 50 | ### Running the hotweb server 51 | Run hotweb in the web root you'd like to serve: 52 | ``` 53 | $ hotweb 54 | ``` 55 | It will open a browser to the index and files in the directory will be watched. 56 | You can also specify a different path to serve or a different port. See `hotweb -h`. 57 | 58 | ### Using the hotweb package 59 | The hotweb server is just a little command line tool wrapping the hotweb package, 60 | which you can use directly in Go to customize or integrate hotweb with your tooling. 61 | 62 | [GoDocs](https://godoc.org/github.com/progrium/hotweb/pkg/hotweb) 63 | 64 | ## Notes 65 | 66 | ### Stateful JS modules 67 | You may experience weird bugs if you try to hot replace stateful modules. You can 68 | mark a module to reload the whole page instead of trying to hot replace by exporting 69 | a field named `noHMR`. The type and value are ignored. Example: 70 | ```javascript 71 | export const noHMR = true; 72 | ``` 73 | 74 | ### Root components 75 | The way we implement HMR with on-the-fly generated proxy modules means in order to pick 76 | up the new module exports, you need to access them through the imported names. When we 77 | redraw in Mithril it will render the top level component, and as it references 78 | subcomponents they will resolve to the updated references. However, because the root component is not 79 | re-evaluated, it will not update with changes unless you wrap it in a callback so it 80 | gets evaluated again. 81 | 82 | For example, say you mount your top level component like this: 83 | ```javascript 84 | import * as app from '/lib/app.mjs'; 85 | 86 | m.mount(document.body, app.Page)); 87 | ``` 88 | To get `Page` to update when `app.mjs` is modified we have to wrap it so accessing 89 | `Page` happens with every render. Something like this: 90 | ```javascript 91 | import * as app from '/lib/app.mjs'; 92 | 93 | m.mount(document.body, wrap(() => app.Page)); 94 | 95 | function wrap(cb) { 96 | return {view: () => m(cb())}; 97 | } 98 | ``` 99 | 100 | ## License 101 | MIT -------------------------------------------------------------------------------- /_example/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Hello Hotweb! 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /_example/lib/cta.jsx: -------------------------------------------------------------------------------- 1 | export const CTA = { 2 | view: function (vnode) { 3 | return
4 |

5 | New 6 |   Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. 7 |

8 |
; 9 | } 10 | } -------------------------------------------------------------------------------- /_example/lib/features.jsx: -------------------------------------------------------------------------------- 1 | export const Features = { 2 | view: function (vnode) { 3 | return
4 |
5 |
6 |
7 |
8 |
9 |

Tristique senectus et netus et.

10 |

Purus semper eget duis at tellus at urna condimentum mattis. Non blandit massa enim nec. Integer enim neque volutpat ac tincidunt vitae semper quis. Accumsan tortor posuere ac ut consequat semper viverra nam.

11 |

Learn more

12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |

Tempor orci dapibus ultrices in.

21 |

Ut venenatis tellus in metus vulputate. Amet consectetur adipiscing elit pellentesque. Sed arcu non odio euismod lacinia at quis risus. Faucibus turpis in eu mi bibendum neque egestas cmonsu songue. Phasellus vestibulum lorem 22 | sed risus.

23 |

Learn more

24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |

Leo integer malesuada nunc vel risus.

33 |

Imperdiet dui accumsan sit amet nulla facilisi morbi. Fusce ut placerat orci nulla pellentesque dignissim enim. Libero id faucibus nisl tincidunt eget nullam. Commodo viverra maecenas accumsan lacus vel facilisis.

34 |

Learn more

35 |
36 |
37 |
38 |
39 |
40 |
; 41 | } 42 | } -------------------------------------------------------------------------------- /_example/lib/footer.jsx: -------------------------------------------------------------------------------- 1 | export const Footer = { 2 | view: function (vnode) { 3 | return 17 | } 18 | } -------------------------------------------------------------------------------- /_example/lib/hero.jsx: -------------------------------------------------------------------------------- 1 | export const HeroSection = { 2 | view: function (vnode) { 3 | return
{vnode.children}
; 4 | } 5 | } 6 | 7 | export const HeroHead = { 8 | view: function(vnode) { 9 | return
{vnode.children}
; 10 | } 11 | } 12 | 13 | export const HeroBody = { 14 | view: function(vnode) { 15 | return
{vnode.children}
; 16 | } 17 | } 18 | 19 | -------------------------------------------------------------------------------- /_example/lib/nav.js: -------------------------------------------------------------------------------- 1 | export const NavBar = { 2 | view: function(vnode) { 3 | return m("nav.navbar", 4 | m("div.container", 5 | [ 6 | m("div.navbar-brand", 7 | [ 8 | m("a.navbar-item[id='logo'][href='https://github.com/progrium/hotweb']", 9 | " HOTWEB " 10 | ), 11 | m("span.navbar-burger.burger[data-target='navbarMenu']", 12 | [ 13 | m("span"), 14 | m("span"), 15 | m("span") 16 | ] 17 | ) 18 | ] 19 | ), 20 | m(".navbar-menu[id='navbarMenu']", 21 | m("div.navbar-end", 22 | m("div.tabs.is-right", 23 | [ 24 | m("ul", 25 | [ 26 | m("li.is-active", 27 | m("a", 28 | "Home" 29 | ) 30 | ), 31 | m("li", 32 | m("a[href='']", 33 | "Examples" 34 | ) 35 | ), 36 | m("li", 37 | m("a[href='']", 38 | "Features" 39 | ) 40 | ), 41 | m("li", 42 | m("a[href='']", 43 | "Team" 44 | ) 45 | ), 46 | m("li", 47 | m("a[href='']", 48 | "Help" 49 | ) 50 | ) 51 | ] 52 | ), 53 | m("span.navbar-item", 54 | m("a.button.is-white.is-outlined[href='https://github.com/progrium/hotweb/blob/master/_example']", 55 | [ 56 | m("span.icon", 57 | m("i.fa.fa-github") 58 | ), 59 | m("span[title='Hello from the other side']", 60 | "View Source" 61 | ) 62 | ] 63 | ) 64 | ) 65 | ] 66 | ) 67 | ) 68 | ) 69 | ] 70 | ) 71 | ) 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /_example/lib/page.js: -------------------------------------------------------------------------------- 1 | import * as hero from "/lib/hero.js"; 2 | import * as cta from "/lib/cta.js"; 3 | import * as footer from "/lib/footer.js"; 4 | import * as nav from "/lib/nav.js"; 5 | import * as features from "/lib/features.js"; 6 | 7 | export const Page = { 8 | view: function (vnode) { 9 | return m("main", [ 10 | m(hero.HeroSection, [ 11 | m(hero.HeroHead, m(nav.NavBar)), 12 | m(hero.HeroBody, m("div.container.has-text-centered", 13 | [ 14 | m("h1.title", 15 | [ 16 | " The new standard in ", 17 | m("span[id='new-standard']", 18 | "hot reloading" 19 | ) 20 | ] 21 | ), 22 | m("h2.subtitle", 23 | " Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. " 24 | ) 25 | ] 26 | )) 27 | ]), 28 | m(cta.CTA), 29 | m(features.Features), 30 | m(footer.Footer), 31 | ]); 32 | } 33 | } -------------------------------------------------------------------------------- /_example/main.css: -------------------------------------------------------------------------------- 1 | @import url(https://fonts.googleapis.com/css?family=Righteous|Shadows+Into+Light&display=swap); 2 | @import url(https://cdn.jsdelivr.net/npm/bulma@0.8.0/css/bulma.min.css); 3 | 4 | @import "/style/hero.css"; 5 | 6 | #logo { 7 | font-family: 'Righteous'; 8 | font-size: 50px; 9 | color: white; 10 | } 11 | 12 | #new-standard { 13 | background-color: white; 14 | padding: 0px 10px 0px 10px; 15 | color: black; 16 | font-family: 'Shadows Into Light'; 17 | } 18 | 19 | footer.footer { 20 | 21 | } 22 | -------------------------------------------------------------------------------- /_example/main.mjs: -------------------------------------------------------------------------------- 1 | import "https://use.fontawesome.com/releases/v5.3.1/js/all.js"; 2 | import "https://cdnjs.cloudflare.com/ajax/libs/mithril/2.0.4/mithril.min.js"; 3 | 4 | import * as hotweb from '/.hotweb/client.mjs'; 5 | import * as page from '/lib/page.js'; 6 | 7 | hotweb.watchCSS(); 8 | hotweb.watchHTML(); 9 | hotweb.refresh(() => m.redraw()) 10 | m.mount(document.body, wrap(() => page.Page)); 11 | 12 | function wrap(cb) { 13 | return { view: () => m(cb()) }; 14 | } -------------------------------------------------------------------------------- /_example/style/hero.css: -------------------------------------------------------------------------------- 1 | html,body { 2 | background: #EFF3F4; 3 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif; 4 | } 5 | .hero-body .container { 6 | max-width: 700px; 7 | } 8 | .hero-body .title { 9 | color: hsl(192,17%,99%) !important; 10 | } 11 | .hero-body .subtitle { 12 | color: hsl(192,17%,99%) !important; 13 | padding-top: 2rem; 14 | line-height: 1.5; 15 | } 16 | .features { 17 | padding: 5rem 0; 18 | } 19 | .box.cta { 20 | border-radius: 0; 21 | border-left: none; 22 | border-right: none; 23 | } 24 | .card-image > .fa { 25 | font-size: 8rem; 26 | padding-top: 2rem; 27 | padding-bottom: 2rem; 28 | color: #209cee; 29 | } 30 | .card-content .content { 31 | font-size: 14px; 32 | margin: 1rem 1rem; 33 | } 34 | .card-content .content h4 { 35 | font-size: 16px; 36 | font-weight: 700; 37 | } 38 | .card { 39 | box-shadow: 0px 2px 4px rgba(0, 0, 0, 0.18); 40 | margin-bottom: 2rem; 41 | } 42 | .intro { 43 | padding: 5rem 0; 44 | text-align: center; 45 | } 46 | .sandbox { 47 | padding: 5rem 0; 48 | } 49 | .tile.notification { 50 | display: flex; 51 | justify-content: center; 52 | flex-direction: column; 53 | } 54 | .is-shady { 55 | animation: flyintoright .4s backwards; 56 | background: #fff; 57 | box-shadow: rgba(0, 0, 0, .1) 0 1px 0; 58 | border-radius: 4px; 59 | display: inline-block; 60 | margin: 10px; 61 | position: relative; 62 | transition: all .2s ease-in-out; 63 | } 64 | .is-shady:hover { 65 | box-shadow: 0 10px 16px rgba(0, 0, 0, .13), 0 6px 6px rgba(0, 0, 0, .19); 66 | } 67 | /*adds font awesome stars*/ 68 | footer li:before { 69 | content: '\f1b2'; 70 | font-family: 'FontAwesome'; 71 | float: left; 72 | margin-left: -1.5em; 73 | color: #147efb; 74 | } -------------------------------------------------------------------------------- /cmd/hotweb/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "log" 6 | "net/http" 7 | "os" 8 | "path/filepath" 9 | "strings" 10 | 11 | "github.com/gorilla/handlers" 12 | "github.com/progrium/hotweb/pkg/hotweb" 13 | "github.com/skratchdot/open-golang/open" 14 | "github.com/spf13/afero" 15 | ) 16 | 17 | var ( 18 | Port string 19 | Dir string 20 | Ignore string 21 | ) 22 | 23 | func init() { 24 | flag.StringVar(&Port, "port", "8080", "port to listen on") 25 | flag.StringVar(&Dir, "dir", ".", "directory to serve") 26 | flag.StringVar(&Ignore, "ignore", "", "directories to not proxy for, comma delimited") 27 | } 28 | 29 | func main() { 30 | flag.Parse() 31 | 32 | var err error 33 | if Dir == "." { 34 | Dir, err = os.Getwd() 35 | if err != nil { 36 | panic(err) 37 | } 38 | } 39 | 40 | fs := afero.NewOsFs() 41 | cfg := hotweb.Config{ 42 | Filesystem: fs, 43 | ServeRoot: filepath.Clean(Dir), 44 | } 45 | hw := hotweb.New(cfg) 46 | hw.IgnoreDirs = strings.Split(Ignore, ",") 47 | 48 | go func() { 49 | log.Printf("watching %#v\n", Dir) 50 | log.Fatal(hw.Watch()) 51 | }() 52 | 53 | listenAddr := "0.0.0.0:" + Port 54 | url := "http://" + listenAddr 55 | open.Start(url) 56 | 57 | log.Printf("serving at %s\n", url) 58 | http.ListenAndServe(listenAddr, handlers.LoggingHandler(os.Stdout, hw)) 59 | } 60 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/progrium/hotweb 2 | 3 | go 1.13 4 | 5 | require ( 6 | github.com/gorilla/handlers v1.4.2 7 | github.com/gorilla/websocket v1.4.1 8 | github.com/progrium/esbuild v0.0.0-20200327212623-fae14fb26173 9 | github.com/progrium/watcher v1.0.8-0.20200403214642-88c0f931de38 10 | github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 11 | github.com/spf13/afero v1.2.2 12 | golang.org/x/text v0.3.2 // indirect 13 | ) 14 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/gorilla/handlers v1.4.2 h1:0QniY0USkHQ1RGCLfKxeNHK9bkDHGRYGNDFBCS+YARg= 2 | github.com/gorilla/handlers v1.4.2/go.mod h1:Qkdc/uu4tH4g6mTK6auzZ766c4CA0Ng8+o/OAirnOIQ= 3 | github.com/gorilla/websocket v1.4.1 h1:q7AeDBpnBk8AogcD4DSag/Ukw/KV+YhzLj2bP5HvKCM= 4 | github.com/gorilla/websocket v1.4.1/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= 5 | github.com/progrium/esbuild v0.0.0-20200327212623-fae14fb26173 h1:q4QMKSrYQstPheCBkYDBvECSgaQqeK4rHaOoZjRdZgc= 6 | github.com/progrium/esbuild v0.0.0-20200327212623-fae14fb26173/go.mod h1:JWvqYJaHSrk+OnKTRemRXjGixls7mWM9xHwXSW5Qzro= 7 | github.com/progrium/watcher v1.0.8-0.20200403214642-88c0f931de38 h1:HpY7P6KAfacRCsWh+XwT4pKArLqRilfOMeLnSX423r0= 8 | github.com/progrium/watcher v1.0.8-0.20200403214642-88c0f931de38/go.mod h1:JQYLbWpkvkx6EDYKgqn4Tv5zwhRXe9a+MQw1wnmcyvI= 9 | github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 h1:JIAuq3EEf9cgbU6AtGPK4CTG3Zf6CKMNqf0MHTggAUA= 10 | github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966/go.mod h1:sUM3LWHvSMaG192sy56D9F7CNvL7jUJVXoqM1QKLnog= 11 | github.com/spf13/afero v1.2.2 h1:5jhuqJyZCZf2JRofRvN/nIFgIWNzPa3/Vz8mYylgbWc= 12 | github.com/spf13/afero v1.2.2/go.mod h1:9ZxEEn6pIJ8Rxe320qSDBk6AsU0r9pR7Q4OcevTdifk= 13 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 14 | golang.org/x/text v0.3.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs= 15 | golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= 16 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 17 | -------------------------------------------------------------------------------- /pkg/esbuild/esbuild.go: -------------------------------------------------------------------------------- 1 | package esbuild 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "os" 7 | "path/filepath" 8 | "strings" 9 | 10 | "github.com/progrium/esbuild/pkg/ast" 11 | "github.com/progrium/esbuild/pkg/bundler" 12 | "github.com/progrium/esbuild/pkg/fs" 13 | "github.com/progrium/esbuild/pkg/logging" 14 | "github.com/progrium/esbuild/pkg/parser" 15 | "github.com/progrium/esbuild/pkg/resolver" 16 | "github.com/spf13/afero" 17 | ) 18 | 19 | var JsxFactory = "m" 20 | 21 | func jsxFactory() string { 22 | if os.Getenv("JSX_FACTORY") != "" { 23 | return os.Getenv("JSX_FACTORY") 24 | } 25 | return JsxFactory 26 | } 27 | 28 | func BuildFile(fs afero.Fs, filepath string) ([]byte, error) { 29 | parseOptions := parser.ParseOptions{ 30 | Defines: make(map[string]ast.E), 31 | JSX: parser.JSXOptions{ 32 | Factory: []string{jsxFactory()}, 33 | }, 34 | } 35 | bundleOptions := bundler.BundleOptions{} 36 | logOptions := logging.StderrOptions{ 37 | IncludeSource: true, 38 | ErrorLimit: 10, 39 | ExitWhenLimitIsHit: true, 40 | } 41 | 42 | wrapfs := &FS{fs} 43 | resolver := resolver.NewResolver(wrapfs, []string{".jsx", ".js", ".mjs"}) 44 | logger, join := logging.NewStderrLog(logOptions) 45 | bundle := bundler.ScanBundle(logger, wrapfs, resolver, []string{filepath}, parseOptions) 46 | if join().Errors != 0 { 47 | log.Println("[WARNING] ScanBundle failed") 48 | return nil, nil 49 | } 50 | result := bundle.Compile(logger, bundleOptions) 51 | 52 | for _, item := range result { 53 | if strings.Contains(item.JsAbsPath+"x", filepath) { 54 | return item.JsContents, nil 55 | } 56 | } 57 | return nil, fmt.Errorf("no result from esbuild") 58 | } 59 | 60 | type FS struct { 61 | afero.Fs 62 | } 63 | 64 | func (f *FS) ReadDirectory(path string) map[string]fs.Entry { 65 | dir, err := afero.ReadDir(f, path) 66 | if err != nil { 67 | return map[string]fs.Entry{} 68 | } 69 | m := make(map[string]fs.Entry) 70 | for _, fi := range dir { 71 | if fi.IsDir() { 72 | m[fi.Name()] = fs.DirEntry 73 | } else { 74 | m[fi.Name()] = fs.FileEntry 75 | } 76 | } 77 | return m 78 | } 79 | 80 | func (f *FS) ReadFile(path string) (string, bool) { 81 | buffer, err := afero.ReadFile(f, path) 82 | return string(buffer), err == nil 83 | } 84 | 85 | func (f *FS) Dir(path string) string { 86 | return filepath.Dir(path) 87 | } 88 | 89 | func (f *FS) Base(path string) string { 90 | return filepath.Base(path) 91 | } 92 | 93 | func (f *FS) Join(parts ...string) string { 94 | return filepath.Clean(filepath.Join(parts...)) 95 | } 96 | 97 | func (f *FS) RelativeToCwd(path string) (string, bool) { 98 | return path, true 99 | } 100 | -------------------------------------------------------------------------------- /pkg/esbuild/esbuild_test.go: -------------------------------------------------------------------------------- 1 | package esbuild 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | 7 | "github.com/spf13/afero" 8 | ) 9 | 10 | const TestFile = "file.jsx" 11 | 12 | func TestBuildFile(t *testing.T) { 13 | var tests = []struct { 14 | in string 15 | out string 16 | }{ 17 | { 18 | "const html = ;\n", 19 | "const html = m(\"html\", null);\n", 20 | }, 21 | } 22 | for idx, tt := range tests { 23 | t.Run(fmt.Sprintf("test%d", idx), func(t *testing.T) { 24 | fs := afero.NewMemMapFs() 25 | err := afero.WriteFile(fs, TestFile, []byte(tt.in), 0644) 26 | if err != nil { 27 | t.Fatal(err) 28 | } 29 | got, err := BuildFile(fs, TestFile) 30 | if err != nil { 31 | t.Fatal(err) 32 | } 33 | if string(got) != tt.out { 34 | t.Errorf("got %q, want %q", got, tt.out) 35 | } 36 | }) 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /pkg/hotweb/hotweb.go: -------------------------------------------------------------------------------- 1 | package hotweb 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "net/http" 7 | "os" 8 | "path" 9 | "strings" 10 | "sync" 11 | "text/template" 12 | "time" 13 | 14 | "github.com/gorilla/websocket" 15 | "github.com/progrium/hotweb/pkg/esbuild" 16 | "github.com/progrium/hotweb/pkg/jsexports" 17 | "github.com/progrium/hotweb/pkg/makefs" 18 | "github.com/progrium/watcher" 19 | "github.com/spf13/afero" 20 | ) 21 | 22 | const ( 23 | DefaultWatchInterval = time.Millisecond * 100 24 | ) 25 | 26 | var ( 27 | InternalPath = "/.hotweb" 28 | ReloadExport = "noHMR" 29 | ) 30 | 31 | func debug(args ...interface{}) { 32 | if os.Getenv("HOTWEB_DEBUG") != "" { 33 | log.Println(append([]interface{}{"hotweb:"}, args...)...) 34 | } 35 | } 36 | 37 | type Handler struct { 38 | Fs *makefs.Fs 39 | ServeRoot string 40 | Prefix string 41 | IgnoreDirs []string 42 | WatchInterval time.Duration 43 | 44 | Upgrader websocket.Upgrader 45 | Watcher *watcher.Watcher 46 | 47 | fileserver http.Handler 48 | clients sync.Map 49 | mux http.Handler 50 | muxOnce sync.Once 51 | } 52 | 53 | func newWriteWatcher(fs afero.Fs, root string) (*watcher.Watcher, error) { 54 | w := watcher.New() 55 | w.SetFileSystem(fs) 56 | w.SetMaxEvents(1) 57 | w.FilterOps(watcher.Write) 58 | return w, w.AddRecursive(root) 59 | } 60 | 61 | type Config struct { 62 | Filesystem afero.Fs 63 | ServeRoot string // abs path in filesystem to serve 64 | Prefix string // optional http path prefix 65 | JsxFactory string 66 | InternalPath string 67 | ReloadExport string 68 | WatchInterval time.Duration 69 | IgnoreDirs []string 70 | } 71 | 72 | func New(cfg Config) *Handler { 73 | if cfg.WatchInterval == 0 { 74 | cfg.WatchInterval = DefaultWatchInterval 75 | } 76 | if cfg.Filesystem == nil { 77 | cfg.Filesystem = afero.NewOsFs() 78 | } 79 | if cfg.ServeRoot == "" { 80 | cfg.ServeRoot = "/" 81 | } 82 | 83 | // TODO: short term config setup, refactor 84 | fs := cfg.Filesystem 85 | serveRoot := cfg.ServeRoot 86 | prefix := cfg.Prefix 87 | if cfg.JsxFactory != "" { 88 | esbuild.JsxFactory = cfg.JsxFactory 89 | } 90 | if cfg.InternalPath != "" { 91 | InternalPath = cfg.InternalPath 92 | } 93 | if cfg.ReloadExport != "" { 94 | ReloadExport = cfg.ReloadExport 95 | } 96 | 97 | cache := afero.NewMemMapFs() 98 | mfs := makefs.New(fs, cache) 99 | 100 | var watcher *watcher.Watcher 101 | var err error 102 | watcher, err = newWriteWatcher(fs, serveRoot) 103 | if err != nil { 104 | panic(err) 105 | } 106 | 107 | mfs.Register(".js", ".jsx", func(fs afero.Fs, dst, src string) ([]byte, error) { 108 | debug("building", dst) 109 | return esbuild.BuildFile(fs, src) 110 | }) 111 | 112 | httpFs := afero.NewHttpFs(mfs).Dir(serveRoot) 113 | prefix = path.Join("/", prefix) 114 | return &Handler{ 115 | Fs: mfs, 116 | ServeRoot: serveRoot, 117 | Prefix: prefix, 118 | IgnoreDirs: cfg.IgnoreDirs, 119 | Upgrader: websocket.Upgrader{ 120 | ReadBufferSize: 1024, 121 | WriteBufferSize: 1024, 122 | }, 123 | Watcher: watcher, 124 | WatchInterval: cfg.WatchInterval, 125 | fileserver: http.StripPrefix(prefix, http.FileServer(httpFs)), 126 | } 127 | } 128 | 129 | func (m *Handler) MatchHTTP(r *http.Request) bool { 130 | if strings.HasPrefix(r.URL.Path, path.Join(m.Prefix, InternalPath)) { 131 | return true 132 | } 133 | if strings.HasPrefix(r.URL.Path, m.Prefix) { 134 | fsPath := path.Join(m.ServeRoot, strings.TrimPrefix(r.URL.Path, m.Prefix)) 135 | if ok, _ := afero.Exists(m.Fs, fsPath); ok { 136 | return true 137 | } 138 | } 139 | return false 140 | } 141 | 142 | func (m *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 143 | m.muxOnce.Do(m.buildMux) 144 | m.mux.ServeHTTP(w, r) 145 | } 146 | 147 | func (m *Handler) buildMux() { 148 | mux := http.NewServeMux() 149 | mux.HandleFunc(path.Join(m.Prefix, InternalPath, ClientFilename), m.handleClientModule) 150 | mux.HandleFunc(path.Join(m.Prefix, InternalPath), m.handleWebSocket) 151 | if len(m.Prefix) > 1 { 152 | mux.HandleFunc(m.Prefix+"/", m.handleFileProxy) 153 | } else { 154 | mux.HandleFunc(m.Prefix, m.handleFileProxy) 155 | } 156 | m.mux = mux 157 | } 158 | 159 | func (m *Handler) isValidJS(r *http.Request) bool { 160 | return !m.isIgnored(r) && isJavaScript(r) && !hiddenFilePrefix(r) 161 | } 162 | 163 | func (m *Handler) isIgnored(r *http.Request) bool { 164 | for _, path := range m.IgnoreDirs { 165 | if path != "" && strings.HasPrefix(r.URL.Path, path) { 166 | return true 167 | } 168 | } 169 | return false 170 | } 171 | 172 | func (m *Handler) handleFileProxy(w http.ResponseWriter, r *http.Request) { 173 | if m.isValidJS(r) && r.URL.RawQuery == "" { 174 | m.handleModuleProxy(w, r) 175 | return 176 | } 177 | m.fileserver.ServeHTTP(w, r) 178 | } 179 | 180 | func (m *Handler) handleClientModule(w http.ResponseWriter, r *http.Request) { 181 | tmpl := template.Must(template.New("client").Parse(ClientSourceTmpl)) 182 | 183 | w.Header().Set("content-type", "text/javascript") 184 | tmpl.Execute(w, map[string]interface{}{ 185 | "Debug": os.Getenv("HOTWEB_DEBUG") != "", 186 | "Endpoint": fmt.Sprintf("ws://%s%s", r.Host, path.Dir(r.URL.Path)), 187 | }) 188 | } 189 | 190 | func (m *Handler) handleModuleProxy(w http.ResponseWriter, r *http.Request) { 191 | tmpl := template.Must(template.New("proxy").Parse(ModuleProxyTmpl)) 192 | 193 | fsPath := path.Join(m.ServeRoot, strings.TrimPrefix(r.URL.Path, m.Prefix)) 194 | src, err := afero.ReadFile(m.Fs, fsPath) 195 | if err != nil { 196 | http.Error(w, err.Error(), http.StatusInternalServerError) 197 | debug(err) 198 | return 199 | } 200 | 201 | exports, err := jsexports.Exports(src) 202 | if err != nil { 203 | http.Error(w, err.Error(), http.StatusInternalServerError) 204 | debug(err) 205 | return 206 | } 207 | 208 | w.Header().Set("content-type", "text/javascript") 209 | tmpl.Execute(w, map[string]interface{}{ 210 | "Path": r.URL.Path, 211 | "Exports": exports, 212 | "Reload": contains(exports, ReloadExport), 213 | "ClientPath": path.Join(m.Prefix, InternalPath, ClientFilename), 214 | }) 215 | } 216 | 217 | func (m *Handler) handleWebSocket(w http.ResponseWriter, r *http.Request) { 218 | conn, err := m.Upgrader.Upgrade(w, r, nil) 219 | if err != nil { 220 | http.Error(w, err.Error(), http.StatusBadRequest) 221 | return 222 | } 223 | defer conn.Close() 224 | ch := make(chan string) 225 | m.clients.Store(ch, struct{}{}) 226 | debug("new websocket connection") 227 | 228 | for filepath := range ch { 229 | err := conn.WriteJSON(map[string]interface{}{ 230 | "path": path.Join(m.Prefix, strings.TrimPrefix(filepath, m.ServeRoot)), 231 | }) 232 | if err != nil { 233 | m.clients.Delete(ch) 234 | if !strings.Contains(err.Error(), "broken pipe") { 235 | debug(err) 236 | } 237 | return 238 | } 239 | } 240 | } 241 | 242 | func (m *Handler) Watch() error { 243 | if m.Watcher == nil { 244 | return fmt.Errorf("hotweb: no watcher to watch filesystem") 245 | } 246 | go func() { 247 | for { 248 | select { 249 | case event := <-m.Watcher.Event: 250 | debug("detected change", event.Path) 251 | m.clients.Range(func(k, v interface{}) bool { 252 | k.(chan string) <- event.Path 253 | return true 254 | }) 255 | case err := <-m.Watcher.Error: 256 | debug(err) 257 | case <-m.Watcher.Closed: 258 | return 259 | } 260 | } 261 | }() 262 | return m.Watcher.Start(m.WatchInterval) 263 | } 264 | 265 | func isJavaScript(r *http.Request) bool { 266 | return contains([]string{".mjs", ".js", ".jsx"}, path.Ext(r.URL.Path)) 267 | } 268 | 269 | func hiddenFilePrefix(r *http.Request) bool { 270 | return path.Base(r.URL.Path)[0] == '_' || path.Base(r.URL.Path)[0] == '.' 271 | } 272 | 273 | func contains(s []string, e string) bool { 274 | for _, a := range s { 275 | if a == e { 276 | return true 277 | } 278 | } 279 | return false 280 | } 281 | -------------------------------------------------------------------------------- /pkg/hotweb/hotweb_test.go: -------------------------------------------------------------------------------- 1 | package hotweb 2 | 3 | import ( 4 | "net/http" 5 | "net/http/httptest" 6 | "testing" 7 | 8 | "github.com/spf13/afero" 9 | ) 10 | 11 | func TestHotweb(t *testing.T) { 12 | existFile := []byte("foo") 13 | srcFile := []byte("\n") 14 | f := afero.NewMemMapFs() 15 | if err := afero.WriteFile(f, "/root/sub/exists", existFile, 0644); err != nil { 16 | t.Fatal(err) 17 | } 18 | if err := afero.WriteFile(f, "/root/exists.js", existFile, 0644); err != nil { 19 | t.Fatal(err) 20 | } 21 | if err := afero.WriteFile(f, "/root/html.jsx", srcFile, 0644); err != nil { 22 | t.Fatal(err) 23 | } 24 | 25 | hw := New(Config{ 26 | Filesystem: f, 27 | ServeRoot: "/root", 28 | }) 29 | 30 | t.Run("existing file in subdir, no proxy", func(t *testing.T) { 31 | req, err := http.NewRequest("GET", "/sub/exists", nil) 32 | if err != nil { 33 | t.Fatal(err) 34 | } 35 | 36 | rr := httptest.NewRecorder() 37 | match := hw.MatchHTTP(req) 38 | if !match { 39 | t.Fatal("no match") 40 | } 41 | 42 | hw.ServeHTTP(rr, req) 43 | expected := string(existFile) 44 | if rr.Body.String() != expected { 45 | t.Errorf("got %v want %v", rr.Body.String(), expected) 46 | } 47 | }) 48 | 49 | t.Run("existing file, no proxy", func(t *testing.T) { 50 | req, err := http.NewRequest("GET", "/exists.js?0", nil) 51 | if err != nil { 52 | t.Fatal(err) 53 | } 54 | 55 | rr := httptest.NewRecorder() 56 | match := hw.MatchHTTP(req) 57 | if !match { 58 | t.Fatal("no match") 59 | } 60 | 61 | hw.ServeHTTP(rr, req) 62 | 63 | expected := string(existFile) 64 | if rr.Body.String() != expected { 65 | t.Errorf("got %v want %v", rr.Body.String(), expected) 66 | } 67 | }) 68 | 69 | t.Run("made file, no proxy", func(t *testing.T) { 70 | req, err := http.NewRequest("GET", "/html.js?0", nil) 71 | if err != nil { 72 | t.Fatal(err) 73 | } 74 | 75 | rr := httptest.NewRecorder() 76 | match := hw.MatchHTTP(req) 77 | if !match { 78 | t.Fatal("no match") 79 | } 80 | 81 | hw.ServeHTTP(rr, req) 82 | expected := "m(\"html\", null);\n" 83 | if rr.Body.String() != expected { 84 | t.Errorf("got %v want %v", rr.Body.String(), expected) 85 | } 86 | }) 87 | 88 | hwp := New(Config{ 89 | Filesystem: f, 90 | ServeRoot: "/root", 91 | Prefix: "/prefix", 92 | }) 93 | 94 | t.Run("existing file, no proxy, prefixed", func(t *testing.T) { 95 | req, err := http.NewRequest("GET", "/prefix/exists.js?0", nil) 96 | if err != nil { 97 | t.Fatal(err) 98 | } 99 | 100 | rr := httptest.NewRecorder() 101 | match := hwp.MatchHTTP(req) 102 | if !match { 103 | t.Fatal("no match") 104 | } 105 | 106 | hwp.ServeHTTP(rr, req) 107 | expected := string(existFile) 108 | if rr.Body.String() != expected { 109 | t.Errorf("got %v want %v", rr.Body.String(), expected) 110 | } 111 | }) 112 | 113 | t.Run("made file, no proxy, prefixed", func(t *testing.T) { 114 | req, err := http.NewRequest("GET", "/prefix/html.js?0", nil) 115 | if err != nil { 116 | t.Fatal(err) 117 | } 118 | 119 | rr := httptest.NewRecorder() 120 | match := hwp.MatchHTTP(req) 121 | if !match { 122 | t.Fatal("no match") 123 | } 124 | 125 | hwp.ServeHTTP(rr, req) 126 | expected := "m(\"html\", null);\n" 127 | if rr.Body.String() != expected { 128 | t.Errorf("got %v want %v", rr.Body.String(), expected) 129 | } 130 | }) 131 | 132 | t.Run("existing file in subdir, no proxy, prefixed", func(t *testing.T) { 133 | req, err := http.NewRequest("GET", "/prefix/sub/exists", nil) 134 | if err != nil { 135 | t.Fatal(err) 136 | } 137 | 138 | rr := httptest.NewRecorder() 139 | match := hwp.MatchHTTP(req) 140 | if !match { 141 | t.Fatal("no match") 142 | } 143 | 144 | hwp.ServeHTTP(rr, req) 145 | expected := string(existFile) 146 | if rr.Body.String() != expected { 147 | t.Errorf("got %v want %v", rr.Body.String(), expected) 148 | } 149 | }) 150 | 151 | } 152 | -------------------------------------------------------------------------------- /pkg/hotweb/js_client.go: -------------------------------------------------------------------------------- 1 | package hotweb 2 | 3 | var ClientFilename = "client.mjs" 4 | var ClientSourceTmpl = ` 5 | let listeners = {}; 6 | let refreshers = []; 7 | let ws = undefined; 8 | let debug = {{if .Debug}}true{{else}}false{{end}}; 9 | 10 | 11 | (function connect() { 12 | ws = new WebSocket("{{.Endpoint}}"); 13 | if (debug) { 14 | ws.onopen = () => console.debug("hotweb websocket open"); 15 | ws.onclose = () => console.debug("hotweb websocket closed"); 16 | } 17 | ws.onerror = (err) => console.debug("hotweb websocket error: ", err); 18 | ws.onmessage = async (event) => { 19 | let msg = JSON.parse(event.data); 20 | if (debug) { 21 | console.debug("hotweb trigger:", msg.path); 22 | } 23 | let paths = Object.keys(listeners); 24 | paths.sort((a, b) => b.length - a.length); 25 | for (const idx in paths) { 26 | let path = paths[idx]; 27 | if (msg.path.startsWith(path)) { 28 | for (const i in listeners[path]) { 29 | await listeners[path][i]((new Date()).getTime(), msg.path); 30 | } 31 | } 32 | } 33 | // wtf why aren't refreshers consistently 34 | // run after listeners are called. 35 | // setTimeout workaround seems ok for now 36 | setTimeout(() => refreshers.forEach((cb) => cb()), 20); 37 | }; 38 | })(); 39 | 40 | export function accept(path, cb) { 41 | if (listeners[path] === undefined) { 42 | listeners[path] = []; 43 | } 44 | listeners[path].push(cb); 45 | } 46 | 47 | export function refresh(cb) { 48 | refreshers.push(cb); 49 | cb(); 50 | } 51 | 52 | export function watchHTML() { 53 | let withIndex = ""; 54 | if (location.pathname[location.pathname.length-1] == "/") { 55 | withIndex = location.pathname + "index.html"; 56 | } else { 57 | withIndex = location.pathname + "/index.html"; 58 | } 59 | accept(location.pathname, (ts, path) => { 60 | if (path == location.pathname || path == withIndex) { 61 | location.reload(); 62 | } 63 | }); 64 | } 65 | 66 | export function watchCSS() { 67 | accept("", (ts, path) => { 68 | if (path.endsWith(".css")) { 69 | let link = document.createElement('link'); 70 | link.setAttribute('rel', 'stylesheet'); 71 | link.setAttribute('type', 'text/css'); 72 | link.setAttribute('href', path+'?'+(new Date()).getTime()); 73 | document.getElementsByTagName('head')[0].appendChild(link); 74 | let styles = document.getElementsByTagName("link"); 75 | for (let i=0; i { 10 | {{ if .Reload }} location.reload(); 11 | {{ else }} let newMod = await import("{{.Path}}?"+ts); 12 | {{range .Exports}} {{.}}Proxy = newMod.{{.}}; 13 | {{end}} 14 | {{- end -}} 15 | }); 16 | 17 | export { 18 | {{range .Exports}} {{.}}Proxy as {{.}}, 19 | {{end}} 20 | }; 21 | ` 22 | -------------------------------------------------------------------------------- /pkg/jsexports/jsexports.go: -------------------------------------------------------------------------------- 1 | package jsexports 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | "unicode" 7 | "unicode/utf8" 8 | ) 9 | 10 | const eof = rune(-1) 11 | 12 | type itemType string 13 | 14 | var keywords = []string{ 15 | "as", 16 | "let", 17 | "const", 18 | "var", 19 | "function", 20 | "class", 21 | "default", 22 | } 23 | 24 | const ( 25 | itemError itemType = "err" 26 | itemEOF itemType = "eof" 27 | itemExport itemType = "export" 28 | itemIdentifier itemType = "ident" 29 | itemText itemType = "text" 30 | itemString itemType = "string" 31 | itemKeyword itemType = "keyword" 32 | itemNumber itemType = "number" 33 | ) 34 | 35 | type item struct { 36 | typ itemType 37 | val string 38 | } 39 | 40 | func (i item) String() string { 41 | switch i.typ { 42 | case itemEOF: 43 | return "EOF" 44 | case itemError: 45 | return i.val 46 | } 47 | return fmt.Sprintf("%q %s", i.val, i.typ) 48 | } 49 | 50 | type stateFn func(*lexer) stateFn 51 | 52 | type lexer struct { 53 | name string 54 | input string 55 | start int 56 | pos int 57 | width int 58 | state stateFn 59 | items chan item 60 | 61 | isFunc bool 62 | } 63 | 64 | func lex(name, input string) *lexer { 65 | l := &lexer{ 66 | name: name, 67 | input: input, 68 | state: lexText, 69 | items: make(chan item, 2), 70 | } 71 | return l 72 | } 73 | 74 | func (l *lexer) nextItem() item { 75 | for { 76 | select { 77 | case item := <-l.items: 78 | return item 79 | default: 80 | l.state = l.state(l) 81 | } 82 | } 83 | } 84 | 85 | func (l *lexer) emit(t itemType) { 86 | l.items <- item{t, l.input[l.start:l.pos]} 87 | l.start = l.pos 88 | } 89 | 90 | func (l *lexer) ignore() { 91 | l.start = l.pos 92 | } 93 | 94 | func (l *lexer) backup() { 95 | l.pos -= l.width 96 | } 97 | 98 | func (l *lexer) peek() rune { 99 | r := l.next() 100 | l.backup() 101 | return r 102 | } 103 | 104 | func (l *lexer) peekNextWord() string { 105 | oldStart := l.start 106 | oldPos := l.pos 107 | for strings.IndexRune(" ", l.next()) >= 0 { 108 | } 109 | l.backup() 110 | l.start = l.pos 111 | for isAlphaNumeric(l.next()) { 112 | } 113 | l.backup() 114 | ident := l.input[l.start:l.pos] 115 | l.start = oldStart 116 | l.pos = oldPos 117 | return ident 118 | } 119 | 120 | func (l *lexer) accept(valid string) bool { 121 | if strings.IndexRune(valid, l.next()) >= 0 { 122 | return true 123 | } 124 | l.backup() 125 | return false 126 | } 127 | 128 | func (l *lexer) acceptRun(valid string) { 129 | for strings.IndexRune(valid, l.next()) >= 0 { 130 | } 131 | l.backup() 132 | } 133 | 134 | func (l *lexer) next() (r rune) { 135 | if l.pos >= len(l.input) { 136 | l.width = 0 137 | return eof 138 | } 139 | r, l.width = utf8.DecodeRuneInString(l.input[l.pos:]) 140 | l.pos += l.width 141 | return r 142 | } 143 | 144 | func (l *lexer) errorf(format string, args ...interface{}) stateFn { 145 | l.items <- item{ 146 | itemError, 147 | fmt.Sprintf(format, args...), 148 | } 149 | return nil 150 | } 151 | 152 | func lexText(l *lexer) stateFn { 153 | for { 154 | if strings.HasPrefix(l.input[l.pos:], "export") { 155 | if l.pos > l.start { 156 | l.emit(itemText) 157 | } 158 | return lexExport 159 | } 160 | if l.next() == eof { 161 | break 162 | } 163 | } 164 | if l.pos > l.start { 165 | l.emit(itemText) 166 | } 167 | l.emit(itemEOF) 168 | return nil 169 | } 170 | 171 | func lexNumber(l *lexer) stateFn { 172 | l.accept("+-") 173 | digits := "0123456789" 174 | l.acceptRun(digits) 175 | if l.accept(".") { 176 | l.acceptRun(digits) 177 | } 178 | if isAlphaNumeric(l.peek()) { 179 | l.next() 180 | return l.errorf("bad number syntax: %q", l.input[l.start:l.pos]) 181 | } 182 | l.emit(itemNumber) 183 | return lexInsideExport 184 | } 185 | 186 | func lexExport(l *lexer) stateFn { 187 | l.pos += len("export") 188 | l.emit(itemExport) 189 | l.isFunc = false 190 | return lexInsideExport 191 | } 192 | 193 | func lexInsideExport(l *lexer) stateFn { 194 | for { 195 | switch r := l.next(); { 196 | case r == ';', r == '\n', r == '=': 197 | l.backup() 198 | return lexText 199 | case r == eof: 200 | return l.errorf("unexpected end of export statement") 201 | case unicode.IsSpace(r), r == ',': 202 | l.ignore() 203 | case r == '(': 204 | return lexQuoted(l, ')', lexInsideExport) 205 | case r == '"': 206 | return lexQuoted(l, '"', lexInsideExport) 207 | case r == '`': 208 | return lexQuoted(l, '`', lexInsideExport) 209 | case r == '\'': 210 | return lexQuoted(l, '\'', lexInsideExport) 211 | case r == '{': 212 | if l.isFunc { 213 | l.backup() 214 | return lexText 215 | } 216 | return lexInsideBraces 217 | case isAlphaNumeric(r): 218 | l.backup() 219 | return lexIdentifier(l, lexInsideExport) 220 | } 221 | } 222 | } 223 | 224 | func lexInsideBraces(l *lexer) stateFn { 225 | foundColon := false 226 | for { 227 | switch r := l.next(); { 228 | case r == '}': 229 | return lexText 230 | case r == eof: 231 | return l.errorf("unexpected end of braced block") 232 | case unicode.IsSpace(r), r == ',', r == '=': 233 | l.ignore() 234 | case r == ':': 235 | foundColon = true 236 | l.ignore() 237 | case r == '"': 238 | return lexQuoted(l, '"', lexInsideBraces) 239 | case r == '`': 240 | return lexQuoted(l, '`', lexInsideBraces) 241 | case r == '\'': 242 | return lexQuoted(l, '\'', lexInsideBraces) 243 | case isAlphaNumeric(r): 244 | if foundColon { 245 | foundColon = false 246 | for isAlphaNumeric(l.next()) { 247 | } 248 | break 249 | } 250 | l.backup() 251 | return lexIdentifier(l, lexInsideBraces) 252 | } 253 | } 254 | } 255 | 256 | func lexIdentifier(l *lexer, nextState stateFn) stateFn { 257 | return func(l *lexer) stateFn { 258 | for isAlphaNumeric(l.next()) { 259 | } 260 | l.backup() 261 | isKeyword := false 262 | for _, keyword := range keywords { 263 | if l.input[l.start:l.pos] == keyword { 264 | if keyword == "function" { 265 | l.isFunc = true 266 | } 267 | l.emit(itemKeyword) 268 | isKeyword = true 269 | } 270 | } 271 | if !isKeyword { 272 | if l.peekNextWord() != "as" { 273 | l.emit(itemIdentifier) 274 | } 275 | } 276 | return nextState 277 | } 278 | } 279 | 280 | func lexQuoted(l *lexer, quote rune, nextState stateFn) stateFn { 281 | return func(l *lexer) stateFn { 282 | for { 283 | switch r := l.next(); { 284 | case r == quote: 285 | l.emit(itemString) 286 | return nextState 287 | case r == eof || r == '\n': 288 | return l.errorf("unexpected end of quoted string") 289 | } 290 | } 291 | } 292 | } 293 | 294 | func isAlphaNumeric(r rune) bool { 295 | if unicode.IsDigit(r) || unicode.IsLetter(r) { 296 | return true 297 | } 298 | return false 299 | } 300 | 301 | func Exports(src []byte) ([]string, error) { 302 | l := lex("", string(src)) 303 | i := l.nextItem() 304 | set := make(map[string]struct{}) 305 | for i.typ != itemEOF { 306 | if i.typ == itemIdentifier { 307 | set[strings.Trim(i.val, "{}()-_;,.$!")] = struct{}{} 308 | } 309 | i = l.nextItem() 310 | } 311 | var exports []string 312 | for n, _ := range set { 313 | exports = append(exports, n) 314 | } 315 | return exports, nil 316 | } 317 | -------------------------------------------------------------------------------- /pkg/makefs/makefs.go: -------------------------------------------------------------------------------- 1 | package makefs 2 | 3 | import ( 4 | "os" 5 | "path" 6 | "strings" 7 | 8 | "github.com/spf13/afero" 9 | "github.com/spf13/afero/mem" 10 | ) 11 | 12 | type Fs struct { 13 | afero.Fs 14 | transforms map[string][]transform 15 | } 16 | 17 | type transform struct { 18 | srcExt string 19 | fn transformFn 20 | } 21 | 22 | type transformFn func(fs afero.Fs, dst, src string) ([]byte, error) 23 | 24 | func New(readFs, writeFs afero.Fs) *Fs { 25 | return &Fs{ 26 | Fs: afero.NewCopyOnWriteFs( 27 | afero.NewReadOnlyFs(readFs), 28 | writeFs, 29 | ), 30 | transforms: make(map[string][]transform), 31 | } 32 | } 33 | 34 | func (f *Fs) Register(dstExt, srcExt string, fn transformFn) { 35 | f.transforms[dstExt] = append(f.transforms[dstExt], transform{ 36 | srcExt: srcExt, 37 | fn: fn, 38 | }) 39 | } 40 | 41 | func (f *Fs) ensureTransforms(name string) afero.File { 42 | transforms, ok := f.transforms[path.Ext(name)] 43 | if !ok { 44 | return nil 45 | } 46 | for _, transform := range transforms { 47 | srcFile := strings.ReplaceAll(name, path.Ext(name), transform.srcExt) 48 | srcExists, err := afero.Exists(f.Fs, srcFile) 49 | if err != nil { 50 | panic(err) 51 | } 52 | if srcExists { 53 | b, err := transform.fn(f.Fs, name, srcFile) 54 | if err != nil { 55 | panic(err) 56 | } 57 | f := mem.NewFileHandle(mem.CreateFile(name)) 58 | _, err = f.Write(b) 59 | if err != nil { 60 | panic(err) 61 | } 62 | f.Seek(0, 0) 63 | return f 64 | } 65 | } 66 | return nil 67 | } 68 | 69 | func (f *Fs) Open(name string) (afero.File, error) { 70 | if tf := f.ensureTransforms(name); tf != nil { 71 | return tf, nil 72 | } 73 | return f.Fs.Open(name) 74 | } 75 | 76 | func (f *Fs) OpenFile(name string, flag int, perm os.FileMode) (afero.File, error) { 77 | if tf := f.ensureTransforms(name); tf != nil { 78 | return tf, nil 79 | } 80 | return f.Fs.OpenFile(name, flag, perm) 81 | } 82 | 83 | func (f *Fs) Stat(name string) (os.FileInfo, error) { 84 | if tf := f.ensureTransforms(name); tf != nil { 85 | return tf.Stat() 86 | } 87 | return f.Fs.Stat(name) 88 | } 89 | -------------------------------------------------------------------------------- /pkg/makefs/makefs_test.go: -------------------------------------------------------------------------------- 1 | package makefs 2 | 3 | import ( 4 | "bytes" 5 | "testing" 6 | 7 | "github.com/progrium/hotweb/pkg/esbuild" 8 | "github.com/spf13/afero" 9 | ) 10 | 11 | func TestMakefs(t *testing.T) { 12 | existFile := []byte("foo") 13 | srcFile := []byte("\n") 14 | dstFile := []byte("m(\"html\", null);\n") 15 | f := afero.NewMemMapFs() 16 | if err := afero.WriteFile(f, "exists.js", existFile, 0644); err != nil { 17 | t.Fatal(err) 18 | } 19 | if err := afero.WriteFile(f, "html.jsx", srcFile, 0644); err != nil { 20 | t.Fatal(err) 21 | } 22 | mfs := New(f, f) 23 | mfs.Register(".js", ".jsx", func(fs afero.Fs, dst, src string) ([]byte, error) { 24 | return esbuild.BuildFile(fs, src) 25 | }) 26 | 27 | t.Run("made file", func(t *testing.T) { 28 | got, err := afero.ReadFile(mfs, "html.js") 29 | if err != nil { 30 | t.Fatal(err) 31 | } 32 | if !bytes.Equal(got, dstFile) { 33 | t.Errorf("got %q, want %q", got, dstFile) 34 | } 35 | }) 36 | 37 | t.Run("existing file", func(t *testing.T) { 38 | got, err := afero.ReadFile(mfs, "exists.js") 39 | if err != nil { 40 | t.Fatal(err) 41 | } 42 | if !bytes.Equal(got, existFile) { 43 | t.Errorf("got %q, want %q", got, existFile) 44 | } 45 | }) 46 | } 47 | --------------------------------------------------------------------------------