├── .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 ;
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 |
--------------------------------------------------------------------------------