├── .gitattributes ├── .gitignore ├── .travis.yml ├── CHANGELOG.md ├── CONTRIBUTORS ├── LICENSE ├── README.md ├── certgen.go ├── certgen_test.go ├── cmd └── devd │ └── devd.go ├── common_test.go ├── docs └── devd-terminal.png ├── fileserver ├── LICENSE ├── fileserver.go ├── fileserver_test.go ├── testdata │ ├── file │ ├── index.html │ └── style.css └── z_last_test.go ├── go.mod ├── go.sum ├── httpctx └── httpctx.go ├── inject ├── inject.go └── inject_test.go ├── livereload ├── livereload.go ├── rice-box.go └── static │ ├── LICENSE │ └── client.js ├── logheader.go ├── modd.conf ├── responselogger.go ├── reverseproxy ├── LICENSE ├── reverseproxy.go └── reverseproxy_test.go ├── rice-box.go ├── ricetemp └── ricetemp.go ├── route.go ├── route_test.go ├── routespec └── routespec.go ├── scripts └── mkbrew ├── server.go ├── server_test.go ├── slowdown ├── slowdown.go └── slowdown_test.go ├── templates ├── 404.html └── dirlist.html ├── testdata ├── certbundle.pem ├── index.html └── style.css ├── timer └── timer.go ├── watch.go └── watch_test.go /.gitattributes: -------------------------------------------------------------------------------- 1 | rice-box.go binary 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | tmp 2 | ./devd 3 | ./devd.exe 4 | .DS_Store 5 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: go 2 | 3 | go: 4 | - 1.12.x 5 | 6 | matrix: 7 | include: 8 | - os: linux 9 | - os: windows 10 | - os: osx 11 | osx_image: xcode11 12 | 13 | install: 14 | - go get -t -v ./... 15 | - go install ./cmd/devd 16 | 17 | script: 18 | - go test -v -race ./... 19 | 20 | notifications: 21 | email: 22 | - aldo@corte.si 23 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Unreleased 2 | 3 | * Improves CORS support. Allows connections with credentials that were 4 | previously refused. See 5 | 6 | 7 | # v0.9: 21 January 2019 8 | 9 | * Fix live reload issues on Linux (Delyan Angelov) 10 | * Only inject livereload content if content type is text/html (Mattias Wadman) 11 | * Fix treatment of X-Forwarded-Proto (Marvin Frick) 12 | * Dependency updates and test improvements 13 | 14 | 15 | # v0.8: 8 January 2018 16 | 17 | * Improvements in file change monitoring, fixing a number of bugs and 18 | reliability issues, and improving the way we handle symlinks (via the 19 | moddwatch repo). 20 | * Fix handling of the X-Forwarded-Proto header in reverse proxy (thanks to Bernd 21 | Haug ). 22 | * Various other minor fixes and documentation updates. 23 | 24 | 25 | # v0.7: 8 December 2016 26 | 27 | * Add the --notfound flag, which specifies over-rides when the static file sever can't find a file. This is useful for single-page JS app development. 28 | * Improved directory listings, nicer 404 pages. 29 | * X-Forwarded-Proto is now added to reverse proxied requests. 30 | 31 | 32 | # v0.6: 24 September 2016 33 | 34 | * Fix support for MacOS Sierra. This just involved a recompile to fix a compatibility issue between older versions of the Go toolchain and Sierra. 35 | * Fix an issue that caused a slash to be added to some URLs forwarded to reverse proxied hosts. 36 | * livereload: endpoints now run on all domains, fixing livereload on subdomain endpoints. 37 | * livereload: fix support of IE11 (thanks thomas@houseofcode.io). 38 | * Sort directory list entries (thanks @Schnouki). 39 | * Improved route parsing and clarity - (thanks to @aellerton). 40 | 41 | 42 | # v0.5: 8 April 2016 43 | 44 | * Increase the size of the initial file chunk we inspect or a tag for 45 | livereload injection. Fixes some rare cases where pages with a lot of header 46 | data didn't trigger livereload. 47 | * Request that upstream servers do not return compressed data, allowing 48 | livereload script injection. (thanks Thomas B Homburg ) 49 | * Bugfix: Fix recursive file monitoring for static routes 50 | 51 | 52 | # v0.4: 12 February 2016 53 | 54 | * Add support for [modd](https://github.com/cortesi/modd), with the -m flag. 55 | * Add -X flag to set Access-Control-Allow-Origin: * on all responses, allowing 56 | the use of multiple .devd.io domains in testing. 57 | * Add -L flag, which turns on livereload but doesn't trigger on modification, 58 | allowing livereload to be driven by external tools. 59 | * Add -C flag to force colour output, even if we're not attachd to a terminal. 60 | * Add -t flag to disable timestamps. 61 | * Silence console errors due to a stupid long-standing Firefox bug. 62 | * Fix throttling of data upload. 63 | * Improve display of content sizes. 64 | * Add distributions for OpenBSD and NetBSD. 65 | 66 | 67 | # v0.3: 12 November 2015 68 | 69 | * -s (--tls) Generate a self-signed certificate, and enable TLS. The cert 70 | bundle is stored in ~/.devd.cert 71 | * Add the X-Forwarded-Host header to reverse proxied traffic. 72 | * Disable upstream cert validation for reverse proxied traffic. This makes 73 | using self-signed certs for development easy. Devd shoudn't be used in 74 | contexts where this might pose a security risk. 75 | * Bugfix: make CSS livereload work in Firefox 76 | * Bugfix: make sure the Host header and SNI host matches for reverse proxied 77 | traffic. 78 | 79 | 80 | # v0.2 81 | 82 | * -x (--exclude) flag to exclude files from livereload. 83 | * -P (--password) flag for quick HTTP Basic password protection. 84 | * -q (--quiet) flag to suppress all output from devd. 85 | * Humanize file sizes in console logs. 86 | * Improve directory indexes - better formatting, they now also livereload. 87 | * Devd's built-in livereload URLs are now less likely to clash with user URLs. 88 | * Internal 404 pages are now included in logs, timing measurement, and 89 | filtering. 90 | * Improved heuristics for livereload file change detection. We now handle 91 | things like transient files created by editors better. 92 | * A Linux ARM build will now be distributed with each release. 93 | -------------------------------------------------------------------------------- /CONTRIBUTORS: -------------------------------------------------------------------------------- 1 | 40 Aldo Cortesi 2 | 6 Aldo Cortesi 3 | 3 Barret Rennie 4 | 1 Bill Mill 5 | 1 Judson Mitchell 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Aldo Cortesi 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | [![Travis Build Status](https://travis-ci.org/cortesi/devd.svg?branch=master)](https://travis-ci.org/cortesi/devd) 3 | 4 | 5 | 6 | # devd: a local webserver for developers 7 | 8 | ![screenshot](docs/devd-terminal.png "devd in action") 9 | 10 | # Install 11 | 12 | Go to the [releases page](https://github.com/cortesi/devd/releases/latest), download the package for your OS, and copy the binary to somewhere on your PATH. 13 | 14 | If you have a working Go installation, you can also say 15 | 16 | go get github.com/cortesi/devd/cmd/devd 17 | 18 | # Quick start 19 | 20 | Serve the current directory, open it in the browser (**-o**), and livereload when files change (**-l**): 21 | 22 | ```bash 23 | devd -ol . 24 | ``` 25 | 26 | Reverse proxy to http://localhost:8080, and livereload when any file in the **src** directory changes: 27 | 28 | ```bash 29 | devd -w ./src http://localhost:8080 30 | ``` 31 | 32 | 33 | # Using devd with modd 34 | 35 | [Modd](https://github.com/cortesi/modd) is devd's sister project - a dev tool 36 | that runs commands and manages daemons in response to filesystem changes. Devd 37 | can be used with modd to rebuild a project and reload the browser when 38 | filesystem changes are detected. 39 | 40 | Here's a quick example of a simple *modd.conf* file to illustrate. 41 | 42 | ``` 43 | src/** { 44 | prep: render ./src ./rendered 45 | } 46 | 47 | rendered/*.css ./rendered/*.html { 48 | daemon: devd -m ./rendered 49 | } 50 | ``` 51 | 52 | The first block runs the *render* script whenever anything in the *src* 53 | directory changes. The second block starts a devd instance, and triggers 54 | livereload with a signal whenever a .css or .html file in the *rendered* 55 | directory changes. 56 | 57 | See the [modd](https://github.com/cortesi/modd) project page for details. 58 | 59 | 60 | # Features 61 | 62 | ### Cross-platform and self-contained 63 | 64 | Devd is a single statically compiled binary with no external dependencies, and 65 | is released for macOS, Linux and Windows. Don't want to install Node or Python 66 | in that light-weight Docker instance you're hacking in? Just copy over the devd 67 | binary and be done with it. 68 | 69 | 70 | ### Designed for the terminal 71 | 72 | This means no config file, no daemonization, and logs that are designed to be 73 | read in the terminal by a developer. Logs are colorized and log entries span 74 | multiple lines. Devd's logs are detailed, warn about corner cases that other 75 | daemons ignore, and can optionally include things like detailed timing 76 | information and full headers. 77 | 78 | 79 | ### Convenient 80 | 81 | To make quickly firing up an instance as simple as possible, devd automatically 82 | chooses an open port to run on (unless it's specified), and can open a browser 83 | window pointing to the daemon root for you (the **-o** flag in the example 84 | above). It also has utility features like the **-s** flag, which auto-generates 85 | a self-signed certificate for devd, stores it in ~/.devd.certs and enables TLS 86 | all in one step. 87 | 88 | 89 | ### Livereload 90 | 91 | When livereload is enabled, devd injects a small script into HTML pages, just 92 | before the closing *head* tag. The script listens for change notifications over 93 | a websocket connection, and reloads resources as needed. No browser addon is 94 | required, and livereload works even for reverse proxied apps. If only changes 95 | to CSS files are seen, devd will only reload external CSS resources, otherwise 96 | a full page reload is done. This serves the current directory with livereload 97 | enabled: 98 | 99 |
devd -l .
100 | 101 | You can also trigger livereload for files that are not being served, letting 102 | you reload reverse proxied applications when source files change. So, this 103 | command watches the *src* directory tree, and reverse proxies to a locally 104 | running application: 105 | 106 |
devd -w ./src http://localhost:8888
107 | 108 | The **-x** flag excludes files from triggering livereload based on a [pattern 109 | specification](#excluding-files-from-livereload). The following command 110 | disables livereload for all files with the ".less" extension: 111 | 112 |
devd -x "**.less" -l .
113 | 114 | When livereload is enabled (with the **-L**, **-l** or **-w** flags), devd 115 | responds to a SIGHUP by issuing a livereload notice to all connected browsers. 116 | This allows external tools, like devd's sister project **modd**, to trigger 117 | livereload. If livereload is not enabled, SIGHUP causes the daemon to exit. 118 | 119 | The closing *head* tag must be found within the first 30kb of the remote file, 120 | otherwise livereload is disabled for the file. 121 | 122 | 123 | ### Reverse proxy + static file server + flexible routing 124 | 125 | Modern apps tend to be collections of web servers, and devd caters for this 126 | with flexible reverse proxying. You can use devd to overlay a set of services 127 | on a single domain, add livereload to services that don't natively support it, 128 | add throttling and latency simulation to existing services, and so forth. 129 | 130 | Here's a more complicated example showing how all this ties together - it 131 | overlays two applications and a tree of static files. Livereload is enabled for 132 | the static files (**-l**) and also triggered whenever source files for reverse 133 | proxied apps change (**-w**): 134 | 135 |
136 | devd -l \
137 | -w ./src/ \
138 | /=http://localhost:8888 \
139 | /api/=http://localhost:8889 \
140 | /static/=./assets
141 | 
142 | 143 | The [route specification syntax](#routes) is compact but powerful enough to cater for most use cases. 144 | 145 | ### Light-weight virtual hosting 146 | 147 | Devd uses a dedicated domain - **devd.io** - to do simple virtual hosting. This 148 | domain and all its subdomains resolve to 127.0.0.1, which we use to set up 149 | virtual hosting without any changes to */etc/hosts* or other local 150 | configuration. Route specifications that don't start with a leading **/** are 151 | taken to be subdomains of **devd.io**. So, the following command serves a 152 | static site from devd.io, and reverse proxies a locally running app on 153 | api.devd.io: 154 | 155 |
156 | devd ./static api=http://localhost:8888
157 | 
158 | 159 | 160 | ### Latency and bandwidth simulation 161 | 162 | Want to know what it's like to use your fancy 5mb HTML5 app from a mobile phone 163 | in Botswana? Look up the bandwidth and latency 164 | [here](http://www.cisco.com/c/en/us/solutions/collateral/service-provider/global-cloud-index-gci/CloudIndex_Supplement.html), 165 | and invoke devd like so (making sure to convert from kilobits per second to 166 | kilobytes per second and account for the location of your server): 167 | 168 |
devd -d 114 -u 51 -n 275 .
169 | 170 | Devd tries to be reasonably accurate in simulating bandwidth and latency - it 171 | uses a token bucket implementation for throttling, properly handles concurrent 172 | requests, and chunks traffic up so data flow is smooth. 173 | 174 | 175 | ## Routes 176 | 177 | The devd command takes one or more route specifications as arguments. Routes 178 | have the basic format **root=endpoint**. Roots can be fixed, like 179 | "/favicon.ico", or subtrees, like "/images/" (note the trailing slash). 180 | Endpoints can be filesystem paths or URLs to upstream HTTP servers. 181 | 182 | Here's a route that serves the directory *./static* under */assets* on the server: 183 | 184 | ``` 185 | /assets/=./static 186 | ``` 187 | 188 | To use a **devd.io** subdomain (which will resolve to 127.0.0.1), just add it 189 | to the the front of the root specification. We recognize subdomains by the fact 190 | that they don't start with a leading **/**. So, this route serves the 191 | **/static** directory under **static.devd.io/assets**: 192 | 193 | ``` 194 | static/assets=./static 195 | ``` 196 | 197 | Reverse proxy specifications are similar, but the endpoint specification is a 198 | URL. The following serves a local URL from the root **app.devd.io/login**: 199 | 200 | ``` 201 | app/login=http://localhost:8888 202 | ``` 203 | 204 | If the **root** specification is omitted, it is assumed to be "/", i.e. a 205 | pattern matching all paths. So, a simple directory specification serves the 206 | directory tree directly under **devd.io**: 207 | 208 | ``` 209 | devd ./static 210 | ``` 211 | 212 | Similarly, a simple reverse proxy can be started like this: 213 | 214 | ``` 215 | devd http://localhost:8888 216 | ``` 217 | 218 | There is also a shortcut for reverse proxying to localhost: 219 | 220 | ``` 221 | devd :8888 222 | 223 | ``` 224 | 225 | ### Serving default content for files not found 226 | 227 | The **--notfound** flag can be passed multiple times, and specifies a set of 228 | routes that are consulted when a requested file is not found by the static file 229 | server. The basic syntax is **root=path**, where **root** has the same 230 | semantics as route specification. As with routes, the **root=** component is 231 | optional, and if absent is taken to be equal to **/**. The **path** is always 232 | relative to the static directory being served. When it starts with a leading 233 | slash (**/**), devd will only look for a replacement file in a single location 234 | relative to the root of the tree. Otherwise, it will search for a matching file 235 | by joining the specified **path** with all path components up to the root of 236 | the tree. 237 | 238 | Let's illustrate this with an example. Say we have a */static* directory as 239 | follows: 240 | 241 | ``` 242 | ./static 243 | ├── bar 244 | │   └── index.html 245 | └── index.html 246 | ``` 247 | 248 | We can specify that devd should look for an *index.html* anywhere on the path 249 | to the root of the static tree as follows: 250 | 251 | ``` 252 | devd --notfound index.html /static 253 | ``` 254 | 255 | Now, the following happens: 256 | 257 | * A request for */nonexistent.html* returns the contents of */index.html* 258 | * A request for */bar/nonexistent.html* returns the contents of */bar/index.html* 259 | * A request for */foo/bar/voing/index.html* returns the contents of */index.html* 260 | 261 | We could instead specify an absolute path in the route, in which case the 262 | contents of */index.html* would be returned for all the examples above: 263 | 264 | ``` 265 | devd --notfound /index.html /static 266 | ``` 267 | 268 | Devd won't serve an over-ride page if the expected type of the incoming request 269 | doesn't match that of the override specification. We do this by looking at the 270 | file extension and expected MIME types of the over-ride and request, defaulting 271 | to *text/html* if the type couldn't be positively established. This prevents 272 | issues where, for instance, an HTML over-ride page might be served where images 273 | are expected. 274 | 275 | 276 | ## Excluding files from livereload 277 | 278 | The **-x** flag supports the following terms: 279 | 280 | Term | Meaning 281 | ------------- | ------- 282 | `*` | matches any sequence of non-path-separators 283 | `**` | matches any sequence of characters, including path separators 284 | `?` | matches any single non-path-separator character 285 | `[class]` | matches any single non-path-separator character against a class of characters 286 | `{alt1,...}` | matches a sequence of characters if one of the comma-separated alternatives matches 287 | 288 | Any character with a special meaning can be escaped with a backslash (`\`). Character classes support the following: 289 | 290 | Class | Meaning 291 | ---------- | ------- 292 | `[abc]` | matches any single character within the set 293 | `[a-z]` | matches any single character in the range 294 | `[^class]` | matches any single character which does *not* match the class 295 | 296 | 297 | ## About reverse proxying 298 | 299 | Devd does not validate upstream SSL certificates when reverse proxying. For our 300 | use case, development servers will usually be running locally, often with 301 | self-signed certificates for testing. You shouldn't use devd in cases where 302 | upstream cert validation matters. 303 | 304 | The *X-Forwarded-Host* and *X-Forwarded-Proto* headers are set to the devd 305 | server's address and protocol for reverse proxied traffic. You might need to 306 | enable support for this in your application for redirects and the like to work 307 | correctly. 308 | 309 | 310 | # Development 311 | 312 | The scripts used to build this package for distribution can be found 313 | [here](https://github.com/cortesi/godist). External packages are vendored using 314 | [dep](https://github.com/golang/dep). 315 | -------------------------------------------------------------------------------- /certgen.go: -------------------------------------------------------------------------------- 1 | package devd 2 | 3 | import ( 4 | "crypto/rand" 5 | "crypto/rsa" 6 | "crypto/x509" 7 | "crypto/x509/pkix" 8 | "encoding/pem" 9 | "fmt" 10 | "math/big" 11 | "os" 12 | "time" 13 | ) 14 | 15 | // GenerateCert generates a self-signed certificate bundle for devd 16 | func GenerateCert(dst string) error { 17 | priv, err := rsa.GenerateKey(rand.Reader, 2048) 18 | if err != nil { 19 | return err 20 | } 21 | notBefore := time.Now() 22 | notAfter := notBefore.Add(365 * 24 * time.Hour * 3) 23 | 24 | serialNumberLimit := new(big.Int).Lsh(big.NewInt(1), 128) 25 | serialNumber, err := rand.Int(rand.Reader, serialNumberLimit) 26 | if err != nil { 27 | return err 28 | } 29 | 30 | template := x509.Certificate{ 31 | SerialNumber: serialNumber, 32 | Subject: pkix.Name{ 33 | Organization: []string{"Acme Co"}, 34 | }, 35 | NotBefore: notBefore, 36 | NotAfter: notAfter, 37 | 38 | KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature, 39 | ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, 40 | BasicConstraintsValid: true, 41 | } 42 | template.DNSNames = append(template.DNSNames, "devd.io") 43 | template.DNSNames = append(template.DNSNames, "*.devd.io") 44 | 45 | derBytes, err := x509.CreateCertificate( 46 | rand.Reader, 47 | &template, 48 | &template, 49 | &priv.PublicKey, 50 | priv, 51 | ) 52 | if err != nil { 53 | return fmt.Errorf("Could not create cert: %s", err) 54 | } 55 | 56 | certOut, err := os.Create(dst) 57 | if err != nil { 58 | return fmt.Errorf("Could not open %s for writing: %s", dst, err) 59 | } 60 | err = pem.Encode(certOut, &pem.Block{Type: "CERTIFICATE", Bytes: derBytes}) 61 | if err != nil { 62 | return err 63 | } 64 | 65 | err = certOut.Close() 66 | if err != nil { 67 | return err 68 | } 69 | 70 | keyOut, err := os.OpenFile(dst, os.O_WRONLY|os.O_APPEND, 0600) 71 | if err != nil { 72 | return fmt.Errorf("Could not open %s for writing: %s", dst, err) 73 | } 74 | err = pem.Encode( 75 | keyOut, 76 | &pem.Block{ 77 | Type: "RSA PRIVATE KEY", 78 | Bytes: x509.MarshalPKCS1PrivateKey(priv), 79 | }, 80 | ) 81 | if err != nil { 82 | return err 83 | } 84 | 85 | err = keyOut.Close() 86 | if err != nil { 87 | return err 88 | } 89 | return nil 90 | } 91 | -------------------------------------------------------------------------------- /certgen_test.go: -------------------------------------------------------------------------------- 1 | package devd 2 | 3 | import ( 4 | "io/ioutil" 5 | "os" 6 | "path" 7 | "testing" 8 | ) 9 | 10 | func TestGenerateCert(t *testing.T) { 11 | d, err := ioutil.TempDir("", "devdtest") 12 | if err != nil { 13 | t.Error(err) 14 | return 15 | } 16 | defer func() { _ = os.Remove(d) }() 17 | dst := path.Join(d, "certbundle") 18 | err = GenerateCert(dst) 19 | if err != nil { 20 | t.Error(err) 21 | } 22 | 23 | _, err = getTLSConfig(dst) 24 | if err != nil { 25 | t.Error(err) 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /cmd/devd/devd.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "net/http" 5 | "os" 6 | "path" 7 | 8 | "github.com/cortesi/devd" 9 | "github.com/cortesi/termlog" 10 | "github.com/mitchellh/go-homedir" 11 | "github.com/toqueteos/webbrowser" 12 | "gopkg.in/alecthomas/kingpin.v2" 13 | ) 14 | 15 | func main() { 16 | address := kingpin.Flag("address", "Address to listen on"). 17 | Short('A'). 18 | Default("127.0.0.1"). 19 | String() 20 | 21 | allInterfaces := kingpin.Flag("all", "Listen on all addresses"). 22 | Short('a'). 23 | Bool() 24 | 25 | certFile := kingpin.Flag("cert", "Certificate bundle file - enables TLS"). 26 | Short('c'). 27 | PlaceHolder("PATH"). 28 | ExistingFile() 29 | 30 | forceColor := kingpin.Flag("color", "Enable colour output, even if devd is not connected to a terminal"). 31 | Short('C'). 32 | Bool() 33 | 34 | downKbps := kingpin.Flag( 35 | "down", 36 | "Throttle downstream from the client to N kilobytes per second", 37 | ). 38 | PlaceHolder("N"). 39 | Short('d'). 40 | Default("0"). 41 | Uint() 42 | 43 | notfound := kingpin.Flag("notfound", "Default when a static file is not found"). 44 | PlaceHolder("PATH"). 45 | Short('f'). 46 | Strings() 47 | 48 | logHeaders := kingpin.Flag("logheaders", "Log headers"). 49 | Short('H'). 50 | Default("false"). 51 | Bool() 52 | 53 | ignoreLogs := kingpin.Flag( 54 | "ignore", 55 | "Disable logging matching requests. Regexes are matched over 'host/path'", 56 | ). 57 | Short('I'). 58 | PlaceHolder("REGEX"). 59 | Strings() 60 | 61 | livereloadNaked := kingpin.Flag("livereload", "Enable livereload"). 62 | Short('L'). 63 | Default("false"). 64 | Bool() 65 | 66 | livereloadRoutes := kingpin.Flag("livewatch", "Enable livereload and watch for static file changes"). 67 | Short('l'). 68 | Default("false"). 69 | Bool() 70 | 71 | moddMode := kingpin.Flag("modd", "Modd is our parent - synonym for -LCt"). 72 | Short('m'). 73 | Bool() 74 | 75 | latency := kingpin.Flag("latency", "Add N milliseconds of round-trip latency"). 76 | PlaceHolder("N"). 77 | Short('n'). 78 | Default("0"). 79 | Int() 80 | 81 | openBrowser := kingpin.Flag("open", "Open browser window on startup"). 82 | Short('o'). 83 | Default("false"). 84 | Bool() 85 | 86 | port := kingpin.Flag( 87 | "port", 88 | "Port to listen on - if not specified, devd will auto-pick a sensible port", 89 | ). 90 | Short('p'). 91 | Int() 92 | 93 | credspec := kingpin.Flag( 94 | "password", 95 | "HTTP basic password protection", 96 | ). 97 | PlaceHolder("USER:PASS"). 98 | Short('P'). 99 | String() 100 | 101 | quiet := kingpin.Flag("quiet", "Silence all logs"). 102 | Short('q'). 103 | Default("false"). 104 | Bool() 105 | 106 | tls := kingpin.Flag("tls", "Serve TLS with auto-generated self-signed certificate (~/.devd.cert)"). 107 | Short('s'). 108 | Default("false"). 109 | Bool() 110 | 111 | noTimestamps := kingpin.Flag("notimestamps", "Disable timestamps in output"). 112 | Short('t'). 113 | Default("false"). 114 | Bool() 115 | 116 | logTime := kingpin.Flag("logtime", "Log timing"). 117 | Short('T'). 118 | Default("false"). 119 | Bool() 120 | 121 | upKbps := kingpin.Flag( 122 | "up", 123 | "Throttle upstream from the client to N kilobytes per second", 124 | ). 125 | PlaceHolder("N"). 126 | Short('u'). 127 | Default("0"). 128 | Uint() 129 | 130 | watch := kingpin.Flag("watch", "Watch path to trigger livereload"). 131 | PlaceHolder("PATH"). 132 | Short('w'). 133 | Strings() 134 | 135 | cors := kingpin.Flag("crossdomain", "Set the CORS headers to allow everything (origin, credentials, headers, methods)"). 136 | Short('X'). 137 | Default("false"). 138 | Bool() 139 | 140 | excludes := kingpin.Flag("exclude", "Glob pattern for files to exclude from livereload"). 141 | PlaceHolder("PATTERN"). 142 | Short('x'). 143 | Strings() 144 | 145 | debug := kingpin.Flag("debug", "Debugging for devd development"). 146 | Default("false"). 147 | Bool() 148 | 149 | routes := kingpin.Arg( 150 | "route", 151 | `Routes have the following forms: 152 | [SUBDOMAIN]/= 153 | [SUBDOMAIN]/= 154 | 155 | 156 | `, 157 | ).Required().Strings() 158 | 159 | kingpin.CommandLine.HelpFlag.Short('h') 160 | kingpin.Version(devd.Version) 161 | 162 | kingpin.Parse() 163 | 164 | if *moddMode { 165 | *forceColor = true 166 | *noTimestamps = true 167 | *livereloadNaked = true 168 | } 169 | 170 | realAddr := *address 171 | if *allInterfaces { 172 | realAddr = "0.0.0.0" 173 | } 174 | 175 | var creds *devd.Credentials 176 | if *credspec != "" { 177 | var err error 178 | creds, err = devd.CredentialsFromSpec(*credspec) 179 | if err != nil { 180 | kingpin.Fatalf("%s", err) 181 | return 182 | } 183 | } 184 | 185 | hdrs := make(http.Header) 186 | if *cors { 187 | hdrs.Set("Access-Control-Allow-Credentials", "true") 188 | } 189 | 190 | var servingScheme string 191 | if *tls { 192 | servingScheme = "https" 193 | } else { 194 | servingScheme = "http" 195 | } 196 | 197 | dd := devd.Devd{ 198 | // Shaping 199 | Latency: *latency, 200 | DownKbps: *downKbps, 201 | UpKbps: *upKbps, 202 | ServingScheme: servingScheme, 203 | 204 | AddHeaders: &hdrs, 205 | 206 | // Livereload 207 | LivereloadRoutes: *livereloadRoutes, 208 | Livereload: *livereloadNaked, 209 | WatchPaths: *watch, 210 | Excludes: *excludes, 211 | 212 | Cors: *cors, 213 | 214 | Credentials: creds, 215 | } 216 | 217 | if err := dd.AddRoutes(*routes, *notfound); err != nil { 218 | kingpin.Fatalf("%s", err) 219 | } 220 | 221 | if err := dd.AddIgnores(*ignoreLogs); err != nil { 222 | kingpin.Fatalf("%s", err) 223 | } 224 | 225 | logger := termlog.NewLog() 226 | if *quiet { 227 | logger.Quiet() 228 | } 229 | if *debug { 230 | logger.Enable("debug") 231 | } 232 | if *logTime { 233 | logger.Enable("timer") 234 | } 235 | if *logHeaders { 236 | logger.Enable("headers") 237 | } 238 | if *forceColor { 239 | logger.Color(true) 240 | } 241 | if *noTimestamps { 242 | logger.TimeFmt = "" 243 | } 244 | 245 | for _, i := range dd.Routes { 246 | logger.Say("Route %s -> %s", i.MuxMatch(), i.Endpoint.String()) 247 | } 248 | 249 | if *tls { 250 | home, err := homedir.Dir() 251 | if err != nil { 252 | kingpin.Fatalf("Could not get user's homedir: %s", err) 253 | } 254 | dst := path.Join(home, ".devd.cert") 255 | if _, err := os.Stat(dst); os.IsNotExist(err) { 256 | err := devd.GenerateCert(dst) 257 | if err != nil { 258 | kingpin.Fatalf("Could not generate cert: %s", err) 259 | } 260 | } 261 | *certFile = dst 262 | } 263 | 264 | err := dd.Serve( 265 | realAddr, 266 | *port, 267 | *certFile, 268 | logger, 269 | func(url string) { 270 | if *openBrowser { 271 | err := webbrowser.Open(url) 272 | if err != nil { 273 | kingpin.Fatalf("Failed to open browser: %s", err) 274 | } 275 | } 276 | }, 277 | ) 278 | if err != nil { 279 | kingpin.Fatalf("%s", err) 280 | } 281 | } 282 | -------------------------------------------------------------------------------- /common_test.go: -------------------------------------------------------------------------------- 1 | package devd 2 | 3 | import ( 4 | "net/http" 5 | "net/http/httptest" 6 | "net/url" 7 | "strings" 8 | "testing" 9 | ) 10 | 11 | type handlerTester struct { 12 | t *testing.T 13 | h http.Handler 14 | } 15 | 16 | // Request makes a test request 17 | func (ht *handlerTester) Request(method string, url string, params url.Values) *httptest.ResponseRecorder { 18 | req, err := http.NewRequest(method, url, strings.NewReader(params.Encode())) 19 | if err != nil { 20 | ht.t.Errorf("%v", err) 21 | } 22 | if params != nil { 23 | req.Header.Set( 24 | "Content-Type", 25 | "application/x-www-form-urlencoded; param=value", 26 | ) 27 | } 28 | w := httptest.NewRecorder() 29 | ht.h.ServeHTTP(w, req) 30 | return w 31 | } 32 | 33 | // AssertCode asserts that the HTTP return code matches an expected value 34 | func AssertCode(t *testing.T, resp *httptest.ResponseRecorder, code int) { 35 | if resp.Code != code { 36 | t.Errorf("Expected code %d, got %d", code, resp.Code) 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /docs/devd-terminal.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cortesi/devd/c1a3bfba27d8e028de90fb24452374412a4cffb3/docs/devd-terminal.png -------------------------------------------------------------------------------- /fileserver/LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2012 The Go Authors. All rights reserved. 2 | 3 | Redistribution and use in source and binary forms, with or without 4 | modification, are permitted provided that the following conditions are 5 | met: 6 | 7 | * Redistributions of source code must retain the above copyright 8 | notice, this list of conditions and the following disclaimer. 9 | * Redistributions in binary form must reproduce the above 10 | copyright notice, this list of conditions and the following disclaimer 11 | in the documentation and/or other materials provided with the 12 | distribution. 13 | * Neither the name of Google Inc. nor the names of its 14 | contributors may be used to endorse or promote products derived from 15 | this software without specific prior written permission. 16 | 17 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 18 | "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 19 | LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 20 | A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 21 | OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 22 | SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT 23 | LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 24 | DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 25 | THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 26 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 27 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 28 | -------------------------------------------------------------------------------- /fileserver/fileserver.go: -------------------------------------------------------------------------------- 1 | // Package fileserver provides a filesystem HTTP handler, based on the built-in 2 | // Go FileServer. Extensions include better directory listings, support for 3 | // injection, better and use of Context. 4 | package fileserver 5 | 6 | import ( 7 | "errors" 8 | "fmt" 9 | "html/template" 10 | "io" 11 | "mime" 12 | "net/http" 13 | "os" 14 | "path" 15 | "path/filepath" 16 | "sort" 17 | "strconv" 18 | "strings" 19 | "time" 20 | 21 | "golang.org/x/net/context" 22 | 23 | "github.com/cortesi/devd/inject" 24 | "github.com/cortesi/devd/routespec" 25 | "github.com/cortesi/termlog" 26 | ) 27 | 28 | const sniffLen = 512 29 | 30 | func rawHeaderGet(h http.Header, key string) string { 31 | if v := h[key]; len(v) > 0 { 32 | return v[0] 33 | } 34 | return "" 35 | } 36 | 37 | // fileSlice implements sort.Interface, which allows to sort by file name with 38 | // directories first. 39 | type fileSlice []os.FileInfo 40 | 41 | func (p fileSlice) Len() int { 42 | return len(p) 43 | } 44 | 45 | func (p fileSlice) Less(i, j int) bool { 46 | a, b := p[i], p[j] 47 | if a.IsDir() && !b.IsDir() { 48 | return true 49 | } 50 | if b.IsDir() && !a.IsDir() { 51 | return false 52 | } 53 | if strings.HasPrefix(a.Name(), ".") && !strings.HasPrefix(b.Name(), ".") { 54 | return false 55 | } 56 | if strings.HasPrefix(b.Name(), ".") && !strings.HasPrefix(a.Name(), ".") { 57 | return true 58 | } 59 | return a.Name() < b.Name() 60 | } 61 | 62 | func (p fileSlice) Swap(i, j int) { 63 | p[i], p[j] = p[j], p[i] 64 | } 65 | 66 | type dirData struct { 67 | Version string 68 | Name string 69 | Files fileSlice 70 | } 71 | 72 | type fourohfourData struct { 73 | Version string 74 | } 75 | 76 | func stripPrefix(prefix string, path string) string { 77 | if prefix == "" { 78 | return path 79 | } 80 | if p := strings.TrimPrefix(path, prefix); len(p) < len(path) { 81 | return p 82 | } 83 | return path 84 | } 85 | 86 | // errSeeker is returned by ServeContent's sizeFunc when the content 87 | // doesn't seek properly. The underlying Seeker's error text isn't 88 | // included in the sizeFunc reply so it's not sent over HTTP to end 89 | // users. 90 | var errSeeker = errors.New("seeker can't seek") 91 | 92 | // if name is empty, filename is unknown. (used for mime type, before sniffing) 93 | // if modtime.IsZero(), modtime is unknown. 94 | // content must be seeked to the beginning of the file. 95 | // The sizeFunc is called at most once. Its error, if any, is sent in the HTTP response. 96 | func serveContent(ci inject.CopyInject, w http.ResponseWriter, r *http.Request, name string, modtime time.Time, sizeFunc func() (int64, error), content io.ReadSeeker) error { 97 | if checkLastModified(w, r, modtime) { 98 | return nil 99 | } 100 | done := checkETag(w, r) 101 | if done { 102 | return nil 103 | } 104 | 105 | code := http.StatusOK 106 | 107 | // If Content-Type isn't set, use the file's extension to find it, but 108 | // if the Content-Type is unset explicitly, do not sniff the type. 109 | ctypes, haveType := w.Header()["Content-Type"] 110 | var ctype string 111 | if !haveType { 112 | ctype = mime.TypeByExtension(filepath.Ext(name)) 113 | if ctype == "" { 114 | // read a chunk to decide between utf-8 text and binary 115 | var buf [sniffLen]byte 116 | n, _ := io.ReadFull(content, buf[:]) 117 | ctype = http.DetectContentType(buf[:n]) 118 | _, err := content.Seek(0, os.SEEK_SET) // rewind to output whole file 119 | if err != nil { 120 | http.Error(w, "seeker can't seek", http.StatusInternalServerError) 121 | return err 122 | } 123 | } 124 | w.Header().Set("Content-Type", ctype) 125 | } else if len(ctypes) > 0 { 126 | ctype = ctypes[0] 127 | } 128 | 129 | injector, err := ci.Sniff(content, ctype) 130 | if err != nil { 131 | http.Error(w, err.Error(), http.StatusInternalServerError) 132 | return err 133 | } 134 | 135 | size, err := sizeFunc() 136 | if err != nil { 137 | http.Error(w, err.Error(), http.StatusInternalServerError) 138 | return err 139 | } 140 | 141 | if injector.Found() { 142 | size = size + int64(injector.Extra()) 143 | } 144 | 145 | if size >= 0 { 146 | if w.Header().Get("Content-Encoding") == "" { 147 | w.Header().Set("Content-Length", strconv.FormatInt(size, 10)) 148 | } 149 | } 150 | 151 | w.WriteHeader(code) 152 | if r.Method != "HEAD" { 153 | _, err := injector.Copy(w) 154 | if err != nil { 155 | return err 156 | } 157 | } 158 | return nil 159 | } 160 | 161 | // modtime is the modification time of the resource to be served, or IsZero(). 162 | // return value is whether this request is now complete. 163 | func checkLastModified(w http.ResponseWriter, r *http.Request, modtime time.Time) bool { 164 | if modtime.IsZero() { 165 | return false 166 | } 167 | 168 | // The Date-Modified header truncates sub-second precision, so 169 | // use mtime < t+1s instead of mtime <= t to check for unmodified. 170 | if t, err := time.Parse(http.TimeFormat, r.Header.Get("If-Modified-Since")); err == nil && modtime.Before(t.Add(1*time.Second)) { 171 | h := w.Header() 172 | delete(h, "Content-Type") 173 | delete(h, "Content-Length") 174 | w.WriteHeader(http.StatusNotModified) 175 | return true 176 | } 177 | w.Header().Set("Last-Modified", modtime.UTC().Format(http.TimeFormat)) 178 | return false 179 | } 180 | 181 | // checkETag implements If-None-Match checks. 182 | // The ETag must have been previously set in the ResponseWriter's headers. 183 | // 184 | // The return value is whether this request is now considered done. 185 | func checkETag(w http.ResponseWriter, r *http.Request) (done bool) { 186 | etag := rawHeaderGet(w.Header(), "Etag") 187 | if inm := rawHeaderGet(r.Header, "If-None-Match"); inm != "" { 188 | // Must know ETag. 189 | if etag == "" { 190 | return false 191 | } 192 | 193 | // TODO(bradfitz): non-GET/HEAD requests require more work: 194 | // sending a different status code on matches, and 195 | // also can't use weak cache validators (those with a "W/ 196 | // prefix). But most users of ServeContent will be using 197 | // it on GET or HEAD, so only support those for now. 198 | if r.Method != "GET" && r.Method != "HEAD" { 199 | return false 200 | } 201 | 202 | // TODO(bradfitz): deal with comma-separated or multiple-valued 203 | // list of If-None-match values. For now just handle the common 204 | // case of a single item. 205 | if inm == etag || inm == "*" { 206 | h := w.Header() 207 | delete(h, "Content-Type") 208 | delete(h, "Content-Length") 209 | w.WriteHeader(http.StatusNotModified) 210 | return true 211 | } 212 | } 213 | return false 214 | } 215 | 216 | // localRedirect gives a Moved Permanently response. 217 | // It does not convert relative paths to absolute paths like Redirect does. 218 | func localRedirect(w http.ResponseWriter, r *http.Request, newPath string) { 219 | if q := r.URL.RawQuery; q != "" { 220 | newPath += "?" + q 221 | } 222 | w.Header().Set("Location", newPath) 223 | w.WriteHeader(http.StatusMovedPermanently) 224 | } 225 | 226 | // FileServer returns a handler that serves HTTP requests 227 | // with the contents of the file system rooted at root. 228 | // 229 | // To use the operating system's file system implementation, 230 | // use http.Dir: 231 | // 232 | // http.Handle("/", &fileserver.FileServer{Root: http.Dir("/tmp")}) 233 | type FileServer struct { 234 | Version string 235 | Root http.FileSystem 236 | Inject inject.CopyInject 237 | Templates *template.Template 238 | NotFoundRoutes []routespec.RouteSpec 239 | Prefix string 240 | } 241 | 242 | func (fserver *FileServer) ServeHTTP(w http.ResponseWriter, r *http.Request) { 243 | fserver.ServeHTTPContext(context.Background(), w, r) 244 | } 245 | 246 | // ServeHTTPContext is like ServeHTTP, but with added context 247 | func (fserver *FileServer) ServeHTTPContext( 248 | ctx context.Context, w http.ResponseWriter, r *http.Request, 249 | ) { 250 | logger := termlog.FromContext(ctx) 251 | logger.SayAs("debug", "debug fileserver: serving with FileServer...") 252 | 253 | upath := stripPrefix(fserver.Prefix, r.URL.Path) 254 | if !strings.HasPrefix(upath, "/") { 255 | upath = "/" + upath 256 | } 257 | fserver.serveFile(logger, w, r, path.Clean(upath), true) 258 | } 259 | 260 | // Given a path and a "not found" over-ride specification, return an array of 261 | // over-ride paths that should be considered for serving, in priority order. We 262 | // assume that path is a sub-path above a certain root, and we never return 263 | // paths that would fall outside this. 264 | // 265 | // We also sanity check file extensions to make sure that the expected file 266 | // type matches what we serve. This prevents an over-ride for *.html files from 267 | // serving up data when, say, a missing .png is requested. 268 | func notFoundSearchPaths(pth string, spec string) []string { 269 | var ret []string 270 | if strings.HasPrefix(spec, "/") { 271 | ret = []string{path.Clean(spec)} 272 | } else { 273 | for { 274 | pth = path.Dir(pth) 275 | if pth == "/" { 276 | ret = append(ret, path.Join(pth, spec)) 277 | break 278 | } 279 | ret = append(ret, path.Join(pth, spec)) 280 | } 281 | } 282 | return ret 283 | } 284 | 285 | // Get the media type for an extension, via a MIME lookup, defaulting to 286 | // "text/html". 287 | func _getType(ext string) string { 288 | typ := mime.TypeByExtension(ext) 289 | if typ == "" { 290 | return "text/html" 291 | } 292 | smime, _, err := mime.ParseMediaType(typ) 293 | if err != nil { 294 | return "text/html" 295 | } 296 | return smime 297 | } 298 | 299 | // Checks whether the incoming request has the same expected type as an 300 | // over-ride specification. 301 | func matchTypes(spec string, req string) bool { 302 | smime := _getType(path.Ext(spec)) 303 | rmime := _getType(path.Ext(req)) 304 | if smime == rmime { 305 | return true 306 | } 307 | return false 308 | } 309 | 310 | func (fserver *FileServer) serve404(w http.ResponseWriter) error { 311 | d := fourohfourData{ 312 | Version: fserver.Version, 313 | } 314 | err := fserver.Inject.ServeTemplate( 315 | http.StatusNotFound, 316 | w, 317 | fserver.Templates.Lookup("404.html"), 318 | &d, 319 | ) 320 | if err != nil { 321 | return err 322 | } 323 | return nil 324 | } 325 | 326 | func (fserver *FileServer) dirList(logger termlog.Logger, w http.ResponseWriter, name string, f http.File) { 327 | w.Header().Set("Cache-Control", "no-store, must-revalidate") 328 | files, err := f.Readdir(0) 329 | if err != nil { 330 | logger.Shout("Error reading directory for listing: %s", err) 331 | return 332 | } 333 | sortedFiles := fileSlice(files) 334 | sort.Sort(sortedFiles) 335 | data := dirData{ 336 | Version: fserver.Version, 337 | Name: name, 338 | Files: sortedFiles, 339 | } 340 | err = fserver.Inject.ServeTemplate( 341 | http.StatusOK, 342 | w, 343 | fserver.Templates.Lookup("dirlist.html"), 344 | data, 345 | ) 346 | if err != nil { 347 | logger.Shout("Failed to generate dir listing: %s", err) 348 | } 349 | } 350 | 351 | func (fserver *FileServer) notFound( 352 | logger termlog.Logger, 353 | w http.ResponseWriter, 354 | r *http.Request, 355 | name string, 356 | dir *http.File, 357 | ) (err error) { 358 | sm := http.NewServeMux() 359 | seen := make(map[string]bool) 360 | for _, nfr := range fserver.NotFoundRoutes { 361 | seen[nfr.MuxMatch()] = true 362 | sm.HandleFunc( 363 | nfr.MuxMatch(), 364 | func(nfr routespec.RouteSpec) func(w http.ResponseWriter, r *http.Request) { 365 | return func(w http.ResponseWriter, r *http.Request) { 366 | if matchTypes(nfr.Value, r.URL.Path) { 367 | for _, pth := range notFoundSearchPaths(name, nfr.Value) { 368 | next, err := fserver.serveNotFoundFile(w, r, pth) 369 | if err != nil { 370 | logger.Shout("Unable to serve not-found override: %s", err) 371 | } 372 | if !next { 373 | return 374 | } 375 | } 376 | } 377 | err = fserver.serve404(w) 378 | if err != nil { 379 | logger.Shout("Internal error: %s", err) 380 | } 381 | } 382 | }(nfr), 383 | ) 384 | } 385 | if _, exists := seen["/"]; !exists { 386 | sm.HandleFunc( 387 | "/", 388 | func(response http.ResponseWriter, request *http.Request) { 389 | if dir != nil { 390 | d, err := (*dir).Stat() 391 | if err != nil { 392 | logger.Shout("Internal error: %s", err) 393 | return 394 | } 395 | if checkLastModified(response, request, d.ModTime()) { 396 | return 397 | } 398 | fserver.dirList(logger, response, name, *dir) 399 | return 400 | } 401 | err = fserver.serve404(w) 402 | if err != nil { 403 | logger.Shout("Internal error: %s", err) 404 | } 405 | }, 406 | ) 407 | } 408 | handle, _ := sm.Handler(r) 409 | handle.ServeHTTP(w, r) 410 | return err 411 | } 412 | 413 | // If the next return value is true, the caller should proceed to the next 414 | // over-ride path if there is one. If the err return value is non-nil, serving 415 | // should stop. 416 | func (fserver *FileServer) serveNotFoundFile( 417 | w http.ResponseWriter, 418 | r *http.Request, 419 | name string, 420 | ) (next bool, err error) { 421 | f, err := fserver.Root.Open(name) 422 | if err != nil { 423 | return true, nil 424 | } 425 | defer func() { _ = f.Close() }() 426 | 427 | d, err := f.Stat() 428 | if err != nil || d.IsDir() { 429 | return true, nil 430 | } 431 | 432 | // serverContent will check modification time 433 | sizeFunc := func() (int64, error) { return d.Size(), nil } 434 | err = serveContent(fserver.Inject, w, r, d.Name(), d.ModTime(), sizeFunc, f) 435 | if err != nil { 436 | return false, fmt.Errorf("Error serving file: %s", err) 437 | } 438 | return false, nil 439 | } 440 | 441 | // name is '/'-separated, not filepath.Separator. 442 | func (fserver *FileServer) serveFile( 443 | logger termlog.Logger, 444 | w http.ResponseWriter, 445 | r *http.Request, 446 | name string, 447 | redirect bool, 448 | ) { 449 | const indexPage = "/index.html" 450 | 451 | // redirect .../index.html to .../ 452 | // can't use Redirect() because that would make the path absolute, 453 | // which would be a problem running under StripPrefix 454 | if strings.HasSuffix(r.URL.Path, indexPage) { 455 | logger.SayAs( 456 | "debug", "debug fileserver: redirecting %s -> ./", indexPage, 457 | ) 458 | localRedirect(w, r, "./") 459 | return 460 | } 461 | 462 | f, err := fserver.Root.Open(name) 463 | if err != nil { 464 | logger.WarnAs("debug", "debug fileserver: %s", err) 465 | if err := fserver.notFound(logger, w, r, name, nil); err != nil { 466 | logger.Shout("Internal error: %s", err) 467 | } 468 | return 469 | } 470 | defer func() { _ = f.Close() }() 471 | 472 | d, err1 := f.Stat() 473 | if err1 != nil { 474 | logger.WarnAs("debug", "debug fileserver: %s", err) 475 | if err := fserver.notFound(logger, w, r, name, nil); err != nil { 476 | logger.Shout("Internal error: %s", err) 477 | } 478 | return 479 | } 480 | 481 | if redirect { 482 | // redirect to canonical path: / at end of directory url 483 | url := r.URL.Path 484 | if !strings.HasPrefix(url, "/") { 485 | url = "/" + url 486 | } 487 | if d.IsDir() { 488 | if url[len(url)-1] != '/' { 489 | localRedirect(w, r, path.Base(url)+"/") 490 | return 491 | } 492 | } else if url[len(url)-1] == '/' { 493 | localRedirect(w, r, "../"+path.Base(url)) 494 | return 495 | } 496 | } 497 | 498 | // use contents of index.html for directory, if present 499 | if d.IsDir() { 500 | index := name + indexPage 501 | ff, err := fserver.Root.Open(index) 502 | if err == nil { 503 | defer func() { _ = ff.Close() }() 504 | dd, err := ff.Stat() 505 | if err == nil { 506 | name = index 507 | d = dd 508 | f = ff 509 | } 510 | } 511 | } 512 | 513 | // Still a directory? (we didn't find an index.html file) 514 | if d.IsDir() { 515 | if err := fserver.notFound(logger, w, r, name, &f); err != nil { 516 | logger.Shout("Internal error: %s", err) 517 | } 518 | return 519 | } 520 | 521 | // serverContent will check modification time 522 | sizeFunc := func() (int64, error) { return d.Size(), nil } 523 | err = serveContent(fserver.Inject, w, r, d.Name(), d.ModTime(), sizeFunc, f) 524 | if err != nil { 525 | logger.Warn("Error serving file: %s", err) 526 | } 527 | } 528 | -------------------------------------------------------------------------------- /fileserver/fileserver_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2010 The Go Authors. All rights reserved. 2 | // Use of this source code is governed by a BSD-style 3 | // license that can be found in the LICENSE file. 4 | 5 | package fileserver 6 | 7 | import ( 8 | "bytes" 9 | "errors" 10 | "io" 11 | "io/ioutil" 12 | "net/http" 13 | "net/http/httptest" 14 | "net/url" 15 | "os" 16 | "path" 17 | "path/filepath" 18 | "reflect" 19 | "runtime" 20 | "strconv" 21 | "strings" 22 | "sync" 23 | "testing" 24 | "time" 25 | 26 | rice "github.com/GeertJohan/go.rice" 27 | "github.com/cortesi/devd/inject" 28 | "github.com/cortesi/devd/ricetemp" 29 | "github.com/cortesi/devd/routespec" 30 | "github.com/cortesi/termlog" 31 | ) 32 | 33 | // ServeFile replies to the request with the contents of the named file or directory. 34 | func ServeFile(w http.ResponseWriter, r *http.Request, name string) { 35 | dir, file := filepath.Split(name) 36 | logger := termlog.NewLog() 37 | logger.Quiet() 38 | 39 | fs := FileServer{ 40 | "version", 41 | http.Dir(dir), 42 | inject.CopyInject{}, 43 | ricetemp.MustMakeTemplates(rice.MustFindBox("../templates")), 44 | []routespec.RouteSpec{}, 45 | "", 46 | } 47 | fs.serveFile(logger, w, r, file, false) 48 | } 49 | 50 | func ServeContent(w http.ResponseWriter, req *http.Request, name string, modtime time.Time, content io.ReadSeeker) error { 51 | sizeFunc := func() (int64, error) { 52 | size, err := content.Seek(0, os.SEEK_END) 53 | if err != nil { 54 | return 0, errSeeker 55 | } 56 | _, err = content.Seek(0, os.SEEK_SET) 57 | if err != nil { 58 | return 0, errSeeker 59 | } 60 | return size, nil 61 | } 62 | return serveContent(inject.CopyInject{}, w, req, name, modtime, sizeFunc, content) 63 | } 64 | 65 | const ( 66 | testFile = "testdata/file" 67 | testFileLen = 11 68 | ) 69 | 70 | type wantRange struct { 71 | start, end int64 // range [start,end) 72 | } 73 | 74 | var itoa = strconv.Itoa 75 | 76 | var notFoundSearchPathsSpecs = []struct { 77 | path string 78 | spec string 79 | result []string 80 | }{ 81 | {"/index.html", "/foo.html", []string{"/foo.html"}}, 82 | {"/dir/index.html", "/", []string{"/"}}, 83 | {"/dir/index.html", "foo.html", []string{"/dir/foo.html", "/foo.html"}}, 84 | {"/", "foo.html", []string{"/foo.html"}}, 85 | {"/", "../../foo.html", []string{"/foo.html"}}, 86 | {"/", "/../../foo.html", []string{"/foo.html"}}, 87 | } 88 | 89 | func TestNotFoundSearchPaths(t *testing.T) { 90 | for _, tt := range notFoundSearchPathsSpecs { 91 | paths := notFoundSearchPaths(tt.path, tt.spec) 92 | if !reflect.DeepEqual(paths, tt.result) { 93 | t.Errorf("Wanted %#v, got %#v", tt.result, paths) 94 | } 95 | } 96 | } 97 | 98 | var matchTypesSpecs = []struct { 99 | spec string 100 | path string 101 | result bool 102 | }{ 103 | {"/index.html", "/foo.png", false}, 104 | {"/index.html", "/foo.html", true}, 105 | {"/index/", "/foo.html", true}, 106 | {"/index", "/foo.html", true}, 107 | {"/index.unknown", "/foo.unknown", true}, 108 | {"/index.html", "/foo/", true}, 109 | {"/index.html", "/foo/bar.htm", true}, 110 | {"/index", "/foo/bar.html", true}, 111 | {"/index", "/foo/bar.htm", true}, 112 | {"/index", "/foo", true}, 113 | {"/usr/bob.foo", "/foo", true}, 114 | } 115 | 116 | func TestMatchTypes(t *testing.T) { 117 | for _, tt := range matchTypesSpecs { 118 | m := matchTypes(tt.spec, tt.path) 119 | if m != tt.result { 120 | t.Errorf("Wanted %#v, got %#v", tt.result, m) 121 | } 122 | } 123 | } 124 | 125 | func TestServeFile(t *testing.T) { 126 | defer afterTest(t) 127 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 128 | ServeFile(w, r, "testdata/file") 129 | })) 130 | defer ts.Close() 131 | 132 | var err error 133 | 134 | file, err := ioutil.ReadFile(testFile) 135 | if err != nil { 136 | t.Fatal("reading file:", err) 137 | } 138 | 139 | // set up the Request (re-used for all tests) 140 | var req http.Request 141 | req.Header = make(http.Header) 142 | if req.URL, err = url.Parse(ts.URL); err != nil { 143 | t.Fatal("ParseURL:", err) 144 | } 145 | req.Method = "GET" 146 | 147 | // straight GET 148 | _, body := getBody(t, "straight get", req) 149 | if !bytes.Equal(body, file) { 150 | t.Fatalf("body mismatch: got %q, want %q", body, file) 151 | } 152 | } 153 | 154 | var fsRedirectTestData = []struct { 155 | original, redirect string 156 | }{ 157 | {"/test/index.html", "/test/"}, 158 | {"/test/testdata", "/test/testdata/"}, 159 | {"/test/testdata/file/", "/test/testdata/file"}, 160 | } 161 | 162 | func TestFSRedirect(t *testing.T) { 163 | defer afterTest(t) 164 | ts := httptest.NewServer( 165 | http.StripPrefix( 166 | "/test", 167 | &FileServer{ 168 | "version", 169 | http.Dir("."), 170 | inject.CopyInject{}, 171 | ricetemp.MustMakeTemplates(rice.MustFindBox("../templates")), 172 | []routespec.RouteSpec{}, 173 | "", 174 | }, 175 | ), 176 | ) 177 | defer ts.Close() 178 | 179 | for _, data := range fsRedirectTestData { 180 | res, err := http.Get(ts.URL + data.original) 181 | if err != nil { 182 | t.Fatal(err) 183 | } 184 | _ = res.Body.Close() 185 | if g, e := res.Request.URL.Path, data.redirect; g != e { 186 | t.Errorf("redirect from %s: got %s, want %s", data.original, g, e) 187 | } 188 | } 189 | } 190 | 191 | type testFileSystem struct { 192 | open func(name string) (http.File, error) 193 | } 194 | 195 | func (fs *testFileSystem) Open(name string) (http.File, error) { 196 | return fs.open(name) 197 | } 198 | 199 | func _TestFileServerCleans(t *testing.T) { 200 | defer afterTest(t) 201 | ch := make(chan string, 1) 202 | fs := &FileServer{ 203 | "version", 204 | &testFileSystem{ 205 | func(name string) (http.File, error) { 206 | ch <- name 207 | return nil, errors.New("file does not exist") 208 | }, 209 | }, 210 | inject.CopyInject{}, 211 | ricetemp.MustMakeTemplates(rice.MustFindBox("../templates")), 212 | []routespec.RouteSpec{}, 213 | "", 214 | } 215 | tests := []struct { 216 | reqPath, openArg string 217 | }{ 218 | {"/foo.txt", "/foo.txt"}, 219 | {"/../foo.txt", "/foo.txt"}, 220 | } 221 | req, _ := http.NewRequest("GET", "http://example.com", nil) 222 | for n, test := range tests { 223 | rec := httptest.NewRecorder() 224 | req.URL.Path = test.reqPath 225 | fs.ServeHTTP(rec, req) 226 | if got := <-ch; got != test.openArg { 227 | t.Errorf("test %d: got %q, want %q", n, got, test.openArg) 228 | } 229 | } 230 | } 231 | 232 | func mustRemoveAll(dir string) { 233 | err := os.RemoveAll(dir) 234 | if err != nil { 235 | panic(err) 236 | } 237 | } 238 | 239 | func TestFileServerImplicitLeadingSlash(t *testing.T) { 240 | defer afterTest(t) 241 | tempDir, err := ioutil.TempDir("", "") 242 | if err != nil { 243 | t.Fatalf("TempDir: %v", err) 244 | } 245 | defer mustRemoveAll(tempDir) 246 | if err := ioutil.WriteFile(filepath.Join(tempDir, "foo.txt"), []byte("Hello world"), 0644); err != nil { 247 | t.Fatalf("WriteFile: %v", err) 248 | } 249 | fs := &FileServer{ 250 | "version", 251 | http.Dir(tempDir), 252 | inject.CopyInject{}, 253 | ricetemp.MustMakeTemplates(rice.MustFindBox("../templates")), 254 | []routespec.RouteSpec{}, 255 | "", 256 | } 257 | 258 | ts := httptest.NewServer(http.StripPrefix("/bar/", fs)) 259 | defer ts.Close() 260 | get := func(suffix string) string { 261 | res, err := http.Get(ts.URL + suffix) 262 | if err != nil { 263 | t.Fatalf("Get %s: %v", suffix, err) 264 | } 265 | b, err := ioutil.ReadAll(res.Body) 266 | if err != nil { 267 | t.Fatalf("ReadAll %s: %v", suffix, err) 268 | } 269 | _ = res.Body.Close() 270 | return string(b) 271 | } 272 | if s := get("/bar/"); !strings.Contains(s, ">foo.txt<") { 273 | t.Logf("expected a directory listing with foo.txt, got %q", s) 274 | } 275 | if s := get("/bar/foo.txt"); s != "Hello world" { 276 | t.Logf("expected %q, got %q", "Hello world", s) 277 | } 278 | } 279 | 280 | func TestDirJoin(t *testing.T) { 281 | if runtime.GOOS == "windows" { 282 | t.Skip("skipping test on windows") 283 | } 284 | wfi, err := os.Stat("/etc/hosts") 285 | if err != nil { 286 | t.Skip("skipping test; no /etc/hosts file") 287 | } 288 | test := func(d http.Dir, name string) { 289 | f, err := d.Open(name) 290 | if err != nil { 291 | t.Fatalf("open of %s: %v", name, err) 292 | } 293 | defer func() { _ = f.Close() }() 294 | gfi, err := f.Stat() 295 | if err != nil { 296 | t.Fatalf("stat of %s: %v", name, err) 297 | } 298 | if !os.SameFile(gfi, wfi) { 299 | t.Errorf("%s got different file", name) 300 | } 301 | } 302 | test(http.Dir("/etc/"), "/hosts") 303 | test(http.Dir("/etc/"), "hosts") 304 | test(http.Dir("/etc/"), "../../../../hosts") 305 | test(http.Dir("/etc"), "/hosts") 306 | test(http.Dir("/etc"), "hosts") 307 | test(http.Dir("/etc"), "../../../../hosts") 308 | 309 | // Not really directories, but since we use this trick in 310 | // ServeFile, test it: 311 | test(http.Dir("/etc/hosts"), "") 312 | test(http.Dir("/etc/hosts"), "/") 313 | test(http.Dir("/etc/hosts"), "../") 314 | } 315 | 316 | func TestEmptyDirOpenCWD(t *testing.T) { 317 | test := func(d http.Dir) { 318 | name := "fileserver_test.go" 319 | f, err := d.Open(name) 320 | if err != nil { 321 | t.Fatalf("open of %s: %v", name, err) 322 | } 323 | defer func() { _ = f.Close() }() 324 | } 325 | test(http.Dir("")) 326 | test(http.Dir(".")) 327 | test(http.Dir("./")) 328 | } 329 | 330 | func TestServeFileContentType(t *testing.T) { 331 | defer afterTest(t) 332 | const ctype = "icecream/chocolate" 333 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 334 | switch r.FormValue("override") { 335 | case "1": 336 | w.Header().Set("Content-Type", ctype) 337 | case "2": 338 | // Explicitly inhibit sniffing. 339 | w.Header()["Content-Type"] = []string{} 340 | } 341 | ServeFile(w, r, "testdata/file") 342 | })) 343 | defer ts.Close() 344 | get := func(override string, want []string) { 345 | resp, err := http.Get(ts.URL + "?override=" + override) 346 | if err != nil { 347 | t.Fatal(err) 348 | } 349 | if h := resp.Header["Content-Type"]; !reflect.DeepEqual(h, want) { 350 | t.Errorf("Content-Type mismatch: got %v, want %v", h, want) 351 | } 352 | _ = resp.Body.Close() 353 | } 354 | get("0", []string{"text/plain; charset=utf-8"}) 355 | get("1", []string{ctype}) 356 | get("2", nil) 357 | } 358 | 359 | func TestServeFileMimeType(t *testing.T) { 360 | defer afterTest(t) 361 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 362 | ServeFile(w, r, "testdata/style.css") 363 | })) 364 | defer ts.Close() 365 | resp, err := http.Get(ts.URL) 366 | if err != nil { 367 | t.Fatal(err) 368 | } 369 | _ = resp.Body.Close() 370 | want := "text/css; charset=utf-8" 371 | if h := resp.Header.Get("Content-Type"); h != want { 372 | t.Errorf("Content-Type mismatch: got %q, want %q", h, want) 373 | } 374 | } 375 | 376 | func TestServeFileFromCWD(t *testing.T) { 377 | defer afterTest(t) 378 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 379 | ServeFile(w, r, "fileserver_test.go") 380 | })) 381 | defer ts.Close() 382 | r, err := http.Get(ts.URL) 383 | if err != nil { 384 | t.Fatal(err) 385 | } 386 | _ = r.Body.Close() 387 | if r.StatusCode != 200 { 388 | t.Fatalf("expected 200 OK, got %s", r.Status) 389 | } 390 | } 391 | 392 | func TestServeFileWithContentEncoding(t *testing.T) { 393 | defer afterTest(t) 394 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 395 | w.Header().Set("Content-Encoding", "foo") 396 | ServeFile(w, r, "testdata/file") 397 | })) 398 | defer ts.Close() 399 | resp, err := http.Get(ts.URL) 400 | if err != nil { 401 | t.Fatal(err) 402 | } 403 | _ = resp.Body.Close() 404 | if g, e := resp.ContentLength, int64(-1); g != e { 405 | t.Errorf("Content-Length mismatch: got %d, want %d", g, e) 406 | } 407 | } 408 | 409 | func TestServeIndexHtml(t *testing.T) { 410 | defer afterTest(t) 411 | const want = "index.html says hello" 412 | 413 | fs := &FileServer{ 414 | "version", 415 | http.Dir("."), 416 | inject.CopyInject{}, 417 | ricetemp.MustMakeTemplates(rice.MustFindBox("../templates")), 418 | []routespec.RouteSpec{}, 419 | "", 420 | } 421 | ts := httptest.NewServer(fs) 422 | defer ts.Close() 423 | 424 | for _, path := range []string{"/testdata/", "/testdata/index.html"} { 425 | res, err := http.Get(ts.URL + path) 426 | if err != nil { 427 | t.Fatal(err) 428 | } 429 | b, err := ioutil.ReadAll(res.Body) 430 | if err != nil { 431 | t.Fatal("reading Body:", err) 432 | } 433 | if s := strings.TrimSpace(string(b)); s != want { 434 | t.Errorf("for path %q got %q, want %q", path, s, want) 435 | } 436 | _ = res.Body.Close() 437 | } 438 | } 439 | 440 | func TestFileServerZeroByte(t *testing.T) { 441 | defer afterTest(t) 442 | fs := &FileServer{ 443 | "version", 444 | http.Dir("."), 445 | inject.CopyInject{}, 446 | ricetemp.MustMakeTemplates(rice.MustFindBox("../templates")), 447 | []routespec.RouteSpec{}, 448 | "", 449 | } 450 | ts := httptest.NewServer(fs) 451 | defer ts.Close() 452 | 453 | res, err := http.Get(ts.URL + "/" + url.PathEscape("..\x00")) 454 | if err != nil { 455 | t.Fatal(err) 456 | } 457 | b, err := ioutil.ReadAll(res.Body) 458 | if err != nil { 459 | t.Fatal("reading Body:", err) 460 | } 461 | if res.StatusCode == 200 { 462 | t.Errorf("got status 200; want an error. Body is:\n%s", string(b)) 463 | } 464 | } 465 | 466 | type fakeFileInfo struct { 467 | dir bool 468 | basename string 469 | modtime time.Time 470 | ents []*fakeFileInfo 471 | contents string 472 | } 473 | 474 | func (f *fakeFileInfo) Name() string { return f.basename } 475 | func (f *fakeFileInfo) Sys() interface{} { return nil } 476 | func (f *fakeFileInfo) ModTime() time.Time { return f.modtime } 477 | func (f *fakeFileInfo) IsDir() bool { return f.dir } 478 | func (f *fakeFileInfo) Size() int64 { return int64(len(f.contents)) } 479 | func (f *fakeFileInfo) Mode() os.FileMode { 480 | if f.dir { 481 | return 0755 | os.ModeDir 482 | } 483 | return 0644 484 | } 485 | 486 | type fakeFile struct { 487 | io.ReadSeeker 488 | fi *fakeFileInfo 489 | path string // as opened 490 | } 491 | 492 | func (f *fakeFile) Close() error { return nil } 493 | func (f *fakeFile) Stat() (os.FileInfo, error) { return f.fi, nil } 494 | func (f *fakeFile) Readdir(count int) ([]os.FileInfo, error) { 495 | if !f.fi.dir { 496 | return nil, os.ErrInvalid 497 | } 498 | var fis []os.FileInfo 499 | for _, fi := range f.fi.ents { 500 | fis = append(fis, fi) 501 | } 502 | return fis, nil 503 | } 504 | 505 | type fakeFS map[string]*fakeFileInfo 506 | 507 | func (fs fakeFS) Open(name string) (http.File, error) { 508 | name = path.Clean(name) 509 | f, ok := fs[name] 510 | if !ok { 511 | return nil, os.ErrNotExist 512 | } 513 | return &fakeFile{ReadSeeker: strings.NewReader(f.contents), fi: f, path: name}, nil 514 | } 515 | 516 | func TestNotFoundOverride(t *testing.T) { 517 | defer afterTest(t) 518 | ffile := &fakeFileInfo{ 519 | basename: "foo.html", 520 | modtime: time.Unix(1000000000, 0).UTC(), 521 | contents: "I am a fake file", 522 | } 523 | fsys := fakeFS{ 524 | "/": &fakeFileInfo{ 525 | dir: true, 526 | modtime: time.Unix(123, 0).UTC(), 527 | ents: []*fakeFileInfo{}, 528 | }, 529 | "/one": &fakeFileInfo{ 530 | dir: true, 531 | modtime: time.Unix(123, 0).UTC(), 532 | ents: []*fakeFileInfo{ffile}, 533 | }, 534 | "/one/foo.html": ffile, 535 | } 536 | 537 | fs := &FileServer{ 538 | "version", 539 | fsys, 540 | inject.CopyInject{}, 541 | ricetemp.MustMakeTemplates(rice.MustFindBox("../templates")), 542 | []routespec.RouteSpec{ 543 | {Host: "", Path: "/", Value: "foo.html"}, 544 | }, 545 | "", 546 | } 547 | 548 | ts := httptest.NewServer(fs) 549 | defer ts.Close() 550 | 551 | res, err := http.Get(ts.URL + "/one/nonexistent.html") 552 | if err != nil { 553 | t.Fatal(err) 554 | } 555 | _ = res.Body.Close() 556 | if res.StatusCode != 200 { 557 | t.Error("Expected to find over-ride file.") 558 | } 559 | 560 | res, err = http.Get(ts.URL + "/one/two/nonexistent.html") 561 | if err != nil { 562 | t.Fatal(err) 563 | } 564 | _ = res.Body.Close() 565 | if res.StatusCode != 200 { 566 | t.Error("Expected to find over-ride file.") 567 | } 568 | 569 | res, err = http.Get(ts.URL + "/nonexistent.html") 570 | if err != nil { 571 | t.Fatal(err) 572 | } 573 | _ = res.Body.Close() 574 | if res.StatusCode != 404 { 575 | t.Error("Expected to find over-ride file.") 576 | } 577 | 578 | res, err = http.Get(ts.URL + "/two/nonexistent.html") 579 | if err != nil { 580 | t.Fatal(err) 581 | } 582 | _ = res.Body.Close() 583 | if res.StatusCode != 404 { 584 | t.Error("Expected to find over-ride file.") 585 | } 586 | 587 | } 588 | 589 | func TestDirectoryIfNotModified(t *testing.T) { 590 | defer afterTest(t) 591 | const indexContents = "I am a fake index.html file" 592 | fileMod := time.Unix(1000000000, 0).UTC() 593 | fileModStr := fileMod.Format(http.TimeFormat) 594 | dirMod := time.Unix(123, 0).UTC() 595 | indexFile := &fakeFileInfo{ 596 | basename: "index.html", 597 | modtime: fileMod, 598 | contents: indexContents, 599 | } 600 | fsys := fakeFS{ 601 | "/": &fakeFileInfo{ 602 | dir: true, 603 | modtime: dirMod, 604 | ents: []*fakeFileInfo{indexFile}, 605 | }, 606 | "/index.html": indexFile, 607 | } 608 | 609 | fs := &FileServer{ 610 | "version", 611 | fsys, 612 | inject.CopyInject{}, 613 | ricetemp.MustMakeTemplates(rice.MustFindBox("../templates")), 614 | []routespec.RouteSpec{}, 615 | "", 616 | } 617 | 618 | ts := httptest.NewServer(fs) 619 | defer ts.Close() 620 | 621 | res, err := http.Get(ts.URL) 622 | if err != nil { 623 | t.Fatal(err) 624 | } 625 | b, err := ioutil.ReadAll(res.Body) 626 | if err != nil { 627 | t.Fatal(err) 628 | } 629 | if string(b) != indexContents { 630 | t.Fatalf("Got body %q; want %q", b, indexContents) 631 | } 632 | _ = res.Body.Close() 633 | 634 | lastMod := res.Header.Get("Last-Modified") 635 | if lastMod != fileModStr { 636 | t.Fatalf("initial Last-Modified = %q; want %q", lastMod, fileModStr) 637 | } 638 | 639 | req, _ := http.NewRequest("GET", ts.URL, nil) 640 | req.Header.Set("If-Modified-Since", lastMod) 641 | 642 | res, err = http.DefaultClient.Do(req) 643 | if err != nil { 644 | t.Fatal(err) 645 | } 646 | if res.StatusCode != 304 { 647 | t.Fatalf("Code after If-Modified-Since request = %v; want 304", res.StatusCode) 648 | } 649 | _ = res.Body.Close() 650 | 651 | // Advance the index.html file's modtime, but not the directory's. 652 | indexFile.modtime = indexFile.modtime.Add(1 * time.Hour) 653 | 654 | res, err = http.DefaultClient.Do(req) 655 | if err != nil { 656 | t.Fatal(err) 657 | } 658 | if res.StatusCode != 200 { 659 | t.Fatalf("Code after second If-Modified-Since request = %v; want 200; res is %#v", res.StatusCode, res) 660 | } 661 | _ = res.Body.Close() 662 | } 663 | 664 | func mustStat(t *testing.T, fileName string) os.FileInfo { 665 | fi, err := os.Stat(fileName) 666 | if err != nil { 667 | t.Fatal(err) 668 | } 669 | return fi 670 | } 671 | 672 | func TestServeContent(t *testing.T) { 673 | defer afterTest(t) 674 | type serveParam struct { 675 | name string 676 | modtime time.Time 677 | content io.ReadSeeker 678 | contentType string 679 | etag string 680 | } 681 | servec := make(chan serveParam, 1) 682 | lock := sync.Mutex{} 683 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 684 | p := <-servec 685 | if p.etag != "" { 686 | w.Header().Set("ETag", p.etag) 687 | } 688 | if p.contentType != "" { 689 | w.Header().Set("Content-Type", p.contentType) 690 | } 691 | lock.Lock() 692 | defer lock.Unlock() 693 | err := ServeContent(w, r, p.name, p.modtime, p.content) 694 | if err != nil { 695 | t.Fail() 696 | } 697 | })) 698 | defer ts.Close() 699 | 700 | type testCase struct { 701 | // One of file or content must be set: 702 | file string 703 | content io.ReadSeeker 704 | 705 | modtime time.Time 706 | serveETag string // optional 707 | serveContentType string // optional 708 | reqHeader map[string]string 709 | wantLastMod string 710 | wantContentType string 711 | wantStatus int 712 | } 713 | htmlModTime := mustStat(t, "testdata/index.html").ModTime() 714 | tests := map[string]testCase{ 715 | "no_last_modified": { 716 | file: "testdata/style.css", 717 | wantContentType: "text/css; charset=utf-8", 718 | wantStatus: 200, 719 | }, 720 | "with_last_modified": { 721 | file: "testdata/index.html", 722 | wantContentType: "text/html; charset=utf-8", 723 | modtime: htmlModTime, 724 | wantLastMod: htmlModTime.UTC().Format(http.TimeFormat), 725 | wantStatus: 200, 726 | }, 727 | "not_modified_modtime": { 728 | file: "testdata/style.css", 729 | modtime: htmlModTime, 730 | reqHeader: map[string]string{ 731 | "If-Modified-Since": htmlModTime.UTC().Format(http.TimeFormat), 732 | }, 733 | wantStatus: 304, 734 | }, 735 | "not_modified_modtime_with_contenttype": { 736 | file: "testdata/style.css", 737 | serveContentType: "text/css", // explicit content type 738 | modtime: htmlModTime, 739 | reqHeader: map[string]string{ 740 | "If-Modified-Since": htmlModTime.UTC().Format(http.TimeFormat), 741 | }, 742 | wantStatus: 304, 743 | }, 744 | "not_modified_etag": { 745 | file: "testdata/style.css", 746 | serveETag: `"foo"`, 747 | reqHeader: map[string]string{ 748 | "If-None-Match": `"foo"`, 749 | }, 750 | wantStatus: 304, 751 | }, 752 | "not_modified_etag_no_seek": { 753 | content: panicOnSeek{nil}, // should never be called 754 | serveETag: `"foo"`, 755 | reqHeader: map[string]string{ 756 | "If-None-Match": `"foo"`, 757 | }, 758 | wantStatus: 304, 759 | }, 760 | // An If-Range resource for entity "A", but entity "B" is now current. 761 | // The Range request should be ignored. 762 | "range_no_match": { 763 | file: "testdata/style.css", 764 | serveETag: `"A"`, 765 | reqHeader: map[string]string{ 766 | "Range": "bytes=0-4", 767 | "If-Range": `"B"`, 768 | }, 769 | wantStatus: 200, 770 | wantContentType: "text/css; charset=utf-8", 771 | }, 772 | } 773 | for testName, tt := range tests { 774 | var content io.ReadSeeker 775 | if tt.file != "" { 776 | f, err := os.Open(tt.file) 777 | if err != nil { 778 | t.Fatalf("test %q: %v", testName, err) 779 | } 780 | defer func() { 781 | lock.Lock() 782 | defer lock.Unlock() 783 | _ = f.Close() 784 | }() 785 | content = f 786 | } else { 787 | content = tt.content 788 | } 789 | 790 | servec <- serveParam{ 791 | name: filepath.Base(tt.file), 792 | content: content, 793 | modtime: tt.modtime, 794 | etag: tt.serveETag, 795 | contentType: tt.serveContentType, 796 | } 797 | req, err := http.NewRequest("GET", ts.URL, nil) 798 | if err != nil { 799 | t.Fatal(err) 800 | } 801 | for k, v := range tt.reqHeader { 802 | req.Header.Set(k, v) 803 | } 804 | res, err := http.DefaultClient.Do(req) 805 | if err != nil { 806 | t.Fatal(err) 807 | } 808 | _, err = io.Copy(ioutil.Discard, res.Body) 809 | if err != nil { 810 | t.Fatal(err) 811 | } 812 | _ = res.Body.Close() 813 | if res.StatusCode != tt.wantStatus { 814 | t.Errorf("test %q: status = %d; want %d", testName, res.StatusCode, tt.wantStatus) 815 | } 816 | if g, e := res.Header.Get("Content-Type"), tt.wantContentType; g != e { 817 | t.Errorf("test %q: content-type = %q, want %q", testName, g, e) 818 | } 819 | if g, e := res.Header.Get("Last-Modified"), tt.wantLastMod; g != e { 820 | t.Errorf("test %q: last-modified = %q, want %q", testName, g, e) 821 | } 822 | } 823 | } 824 | 825 | func getBody(t *testing.T, testName string, req http.Request) (*http.Response, []byte) { 826 | r, err := http.DefaultClient.Do(&req) 827 | if err != nil { 828 | t.Fatalf("%s: for URL %q, send error: %v", testName, req.URL.String(), err) 829 | } 830 | b, err := ioutil.ReadAll(r.Body) 831 | if err != nil { 832 | t.Fatalf("%s: for URL %q, reading body: %v", testName, req.URL.String(), err) 833 | } 834 | return r, b 835 | } 836 | 837 | type panicOnSeek struct{ io.ReadSeeker } 838 | -------------------------------------------------------------------------------- /fileserver/testdata/file: -------------------------------------------------------------------------------- 1 | 0123456789 2 | -------------------------------------------------------------------------------- /fileserver/testdata/index.html: -------------------------------------------------------------------------------- 1 | index.html says hello 2 | -------------------------------------------------------------------------------- /fileserver/testdata/style.css: -------------------------------------------------------------------------------- 1 | body {} 2 | -------------------------------------------------------------------------------- /fileserver/z_last_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2013 The Go Authors. All rights reserved. 2 | // Use of this source code is governed by a BSD-style 3 | // license that can be found in the LICENSE file. 4 | 5 | package fileserver 6 | 7 | import ( 8 | "net/http" 9 | "runtime" 10 | "sort" 11 | "strings" 12 | "testing" 13 | "time" 14 | ) 15 | 16 | func interestingGoroutines() (gs []string) { 17 | buf := make([]byte, 2<<20) 18 | buf = buf[:runtime.Stack(buf, true)] 19 | for _, g := range strings.Split(string(buf), "\n\n") { 20 | sl := strings.SplitN(g, "\n", 2) 21 | if len(sl) != 2 { 22 | continue 23 | } 24 | stack := strings.TrimSpace(sl[1]) 25 | if stack == "" || 26 | strings.Contains(stack, "created by net.startServer") || 27 | strings.Contains(stack, "created by testing.RunTests") || 28 | strings.Contains(stack, "closeWriteAndWait") || 29 | strings.Contains(stack, "testing.Main(") || 30 | // These only show up with GOTRACEBACK=2; Issue 5005 (comment 28) 31 | strings.Contains(stack, "runtime.goexit") || 32 | strings.Contains(stack, "created by runtime.gc") || 33 | strings.Contains(stack, "runtime.MHeap_Scavenger") { 34 | continue 35 | } 36 | gs = append(gs, stack) 37 | } 38 | sort.Strings(gs) 39 | return 40 | } 41 | 42 | func afterTest(t *testing.T) { 43 | http.DefaultTransport.(*http.Transport).CloseIdleConnections() 44 | if testing.Short() { 45 | return 46 | } 47 | var bad string 48 | badSubstring := map[string]string{ 49 | ").readLoop(": "a Transport", 50 | ").writeLoop(": "a Transport", 51 | "created by net/http/httptest.(*Server).Start": "an httptest.Server", 52 | "timeoutHandler": "a TimeoutHandler", 53 | "net.(*netFD).connect(": "a timing out dial", 54 | ").noteClientGone(": "a closenotifier sender", 55 | } 56 | var stacks string 57 | for i := 0; i < 4; i++ { 58 | bad = "" 59 | stacks = strings.Join(interestingGoroutines(), "\n\n") 60 | for substr, what := range badSubstring { 61 | if strings.Contains(stacks, substr) { 62 | bad = what 63 | } 64 | } 65 | if bad == "" { 66 | return 67 | } 68 | // Bad stuff found, but goroutines might just still be 69 | // shutting down, so give it some time. 70 | time.Sleep(250 * time.Millisecond) 71 | } 72 | t.Errorf("Test appears to have leaked %s:\n%s", bad, stacks) 73 | } 74 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/cortesi/devd 2 | 3 | go 1.12 4 | 5 | require ( 6 | github.com/GeertJohan/go.rice v1.0.0 7 | github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751 8 | github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d 9 | github.com/bmatcuk/doublestar v1.3.0 10 | github.com/cortesi/moddwatch v0.0.0-20190809041828-239a95c12d84 11 | github.com/cortesi/termlog v0.0.0-20190809035425-7871d363854c 12 | github.com/daaku/go.zipexe v1.0.1 13 | github.com/dustin/go-humanize v1.0.0 14 | github.com/fatih/color v1.9.0 15 | github.com/goji/httpauth v0.0.0-20160601135302-2da839ab0f4d 16 | github.com/google/go-cmp v0.4.0 // indirect 17 | github.com/gorilla/websocket v1.4.2 18 | github.com/juju/ratelimit v1.0.1 19 | github.com/kardianos/osext v0.0.0-20190222173326-2bc1f35cddc0 20 | github.com/kr/text v0.2.0 // indirect 21 | github.com/mattn/go-colorable v0.1.6 22 | github.com/mattn/go-isatty v0.0.12 23 | github.com/mitchellh/go-homedir v1.1.0 24 | github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e // indirect 25 | github.com/nkovacs/streamquote v1.0.0 // indirect 26 | github.com/rjeczalik/notify v0.0.0-20181126183243-629144ba06a1 27 | github.com/stretchr/testify v1.5.1 // indirect 28 | github.com/toqueteos/webbrowser v1.2.0 29 | golang.org/x/crypto v0.0.0-20200423211502-4bdfaf469ed5 30 | golang.org/x/net v0.0.0-20200425230154-ff2c4b7c35a0 31 | golang.org/x/sys v0.0.0-20200420163511-1957bb5e6d1f 32 | golang.org/x/tools v0.0.0-20190815232600-256244171580 // indirect 33 | gopkg.in/alecthomas/kingpin.v2 v2.2.6 34 | gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f // indirect 35 | gopkg.in/yaml.v2 v2.2.8 // indirect 36 | ) 37 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/GeertJohan/go.incremental v1.0.0/go.mod h1:6fAjUhbVuX1KcMD3c8TEgVUqmo4seqhv0i0kdATSkM0= 2 | github.com/GeertJohan/go.rice v0.0.0-20170420135705-c02ca9a983da h1:UVU3a9pRUyLdnBtn60WjRl0s4SEyJc2ChCY56OAR6wI= 3 | github.com/GeertJohan/go.rice v0.0.0-20170420135705-c02ca9a983da/go.mod h1:DgrzXonpdQbfN3uYaGz1EG4Sbhyum/MMIn6Cphlh2bw= 4 | github.com/GeertJohan/go.rice v1.0.0 h1:KkI6O9uMaQU3VEKaj01ulavtF7o1fWT7+pk/4voiMLQ= 5 | github.com/GeertJohan/go.rice v1.0.0/go.mod h1:eH6gbSOAUv07dQuZVnBmoDP8mgsM1rtixis4Tib9if0= 6 | github.com/akavel/rsrc v0.8.0/go.mod h1:uLoCtb9J+EyAqh+26kdrTgmzRBFPGOolLWKpdxkKq+c= 7 | github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc h1:cAKDfWh5VpdgMhJosfJnn5/FoN2SRZ4p7fJNX58YPaU= 8 | github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= 9 | github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751 h1:JYp7IbQjafoB+tBA3gMyHYHrpOtNuDiK/uB5uXxq5wM= 10 | github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= 11 | github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf h1:qet1QNfXsQxTZqLG4oE62mJzwPIB8+Tee4RNCL9ulrY= 12 | github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= 13 | github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4 h1:Hs82Z41s6SdL1CELW+XaDYmOH4hkBN4/N9og/AsOv7E= 14 | github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= 15 | github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d h1:UQZhZ2O0vMHr2cI+DC1Mbh0TJxzA3RcLoMsFw+aXw7E= 16 | github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk541a8SKzHPHnH3zbiI+7dagKZ0cgpgrD7Fyho= 17 | github.com/bmatcuk/doublestar v1.1.1 h1:YroD6BJCZBYx06yYFEWvUuKVWQn3vLLQAVmDmvTSaiQ= 18 | github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w= 19 | github.com/bmatcuk/doublestar v1.1.5 h1:2bNwBOmhyFEFcoB3tGvTD5xanq+4kyOZlB8wFYbMjkk= 20 | github.com/bmatcuk/doublestar v1.1.5/go.mod h1:wiQtGV+rzVYxB7WIlirSN++5HPtPlXEo9MEoZQC/PmE= 21 | github.com/bmatcuk/doublestar v1.3.0 h1:1jLE2y0VpSrOn/QR9G4f2RmrCtkM3AuATcWradjHUvM= 22 | github.com/bmatcuk/doublestar v1.3.0/go.mod h1:wiQtGV+rzVYxB7WIlirSN++5HPtPlXEo9MEoZQC/PmE= 23 | github.com/cortesi/moddwatch v0.0.0-20181223233523-0a1e0881aa88 h1:Kxz5+1NaF2eZYpFEVM2VScumPI18VetEGySRLAmhnDc= 24 | github.com/cortesi/moddwatch v0.0.0-20181223233523-0a1e0881aa88/go.mod h1:UXc2CZTlqaY7wvJBbpDaAsRNU0Wv/D/XbN9Pcam1kkU= 25 | github.com/cortesi/moddwatch v0.0.0-20190809034736-2411614b3ac7 h1:YEFi+H/Z45AjwHaRsR50cl6IXjSg8EDzTjc05XK4Auw= 26 | github.com/cortesi/moddwatch v0.0.0-20190809034736-2411614b3ac7/go.mod h1:g60iWp/lO/DUFRg1vnpwDysHGZrMqqzdFV5Py25oqvQ= 27 | github.com/cortesi/moddwatch v0.0.0-20190809041828-239a95c12d84 h1:isRVsIi4gwygChA9pK1eOhf8JrUC2XoC0L6iThqOIkY= 28 | github.com/cortesi/moddwatch v0.0.0-20190809041828-239a95c12d84/go.mod h1:g60iWp/lO/DUFRg1vnpwDysHGZrMqqzdFV5Py25oqvQ= 29 | github.com/cortesi/termlog v0.0.0-20171116205515-87cefd5ac843 h1:sz+t+nXcEBP+hvtorswkEXc3xkXEAkU6AI2i4eii2sQ= 30 | github.com/cortesi/termlog v0.0.0-20171116205515-87cefd5ac843/go.mod h1:Ko6mGAZIfPhPee+oBtJuuF6S31ag18BtmK9PewP/T5w= 31 | github.com/cortesi/termlog v0.0.0-20190809035425-7871d363854c h1:D5UylL3xKRrrqZKk/NhrOhoQVdCQwuEeyFgTfN9n9O4= 32 | github.com/cortesi/termlog v0.0.0-20190809035425-7871d363854c/go.mod h1:gh6GQA3zOsGU4pz+X6ZHqW63KxI/V7KLmBCG9ODJ+l4= 33 | github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= 34 | github.com/daaku/go.zipexe v0.0.0-20150329023125-a5fe2436ffcb h1:tUf55Po0vzOendQ7NWytcdK0VuzQmfAgvGBUOQvN0WA= 35 | github.com/daaku/go.zipexe v0.0.0-20150329023125-a5fe2436ffcb/go.mod h1:U0vRfAucUOohvdCxt5MWLF+TePIL0xbCkbKIiV8TQCE= 36 | github.com/daaku/go.zipexe v1.0.0/go.mod h1:z8IiR6TsVLEYKwXAoE/I+8ys/sDkgTzSL0CLnGVd57E= 37 | github.com/daaku/go.zipexe v1.0.1 h1:wV4zMsDOI2SZ2m7Tdz1Ps96Zrx+TzaK15VbUaGozw0M= 38 | github.com/daaku/go.zipexe v1.0.1/go.mod h1:5xWogtqlYnfBXkSB1o9xysukNP9GTvaNkqzUZbt3Bw8= 39 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 40 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 41 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 42 | github.com/dustin/go-humanize v1.0.0 h1:VSnTsYCnlFHaM2/igO1h6X3HA71jcobQuxemgkq4zYo= 43 | github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= 44 | github.com/fatih/color v0.0.0-20181010231311-3f9d52f7176a h1:uGz8bS2tdMYpIjzS/ccMHV4H127Wz//pxlx7dN5qHB4= 45 | github.com/fatih/color v0.0.0-20181010231311-3f9d52f7176a/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= 46 | github.com/fatih/color v1.7.0 h1:DkWD4oS2D8LGGgTQ6IvwJJXSL5Vp2ffcQg58nFV38Ys= 47 | github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= 48 | github.com/fatih/color v1.9.0 h1:8xPHl4/q1VyqGIPif1F+1V3Y3lSmrq01EabUW3CoW5s= 49 | github.com/fatih/color v1.9.0/go.mod h1:eQcE1qtQxscV5RaZvpXrrb8Drkc3/DdQ+uUYCNjL+zU= 50 | github.com/goji/httpauth v0.0.0-20160601135302-2da839ab0f4d h1:lBXNCxVENCipq4D1Is42JVOP4eQjlB8TQ6H69Yx5J9Q= 51 | github.com/goji/httpauth v0.0.0-20160601135302-2da839ab0f4d/go.mod h1:nnjvkQ9ptGaCkuDUx6wNykzzlUixGxvkme+H/lnzb+A= 52 | github.com/google/go-cmp v0.3.1 h1:Xye71clBPdm5HgqGwUkwhbynsUJZhDbS20FvLhQ2izg= 53 | github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= 54 | github.com/google/go-cmp v0.4.0 h1:xsAVV57WRhGj6kEIi8ReJzQlHHqcBYCElAvkovg3B/4= 55 | github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 56 | github.com/gorilla/websocket v1.4.0 h1:WDFjx/TMzVgy9VdMMQi2K2Emtwi2QcUQsztZ/zLaH/Q= 57 | github.com/gorilla/websocket v1.4.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ= 58 | github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc= 59 | github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= 60 | github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= 61 | github.com/juju/ratelimit v1.0.1 h1:+7AIFJVQ0EQgq/K9+0Krm7m530Du7tIz0METWzN0RgY= 62 | github.com/juju/ratelimit v1.0.1/go.mod h1:qapgC/Gy+xNh9UxzV13HGGl/6UXNN+ct+vwSgWNm/qk= 63 | github.com/kardianos/osext v0.0.0-20170510131534-ae77be60afb1 h1:PJPDf8OUfOK1bb/NeTKd4f1QXZItOX389VN3B6qC8ro= 64 | github.com/kardianos/osext v0.0.0-20170510131534-ae77be60afb1/go.mod h1:1NbS8ALrpOvjt0rHPNLyCIeMtbizbir8U//inJ+zuB8= 65 | github.com/kardianos/osext v0.0.0-20190222173326-2bc1f35cddc0/go.mod h1:1NbS8ALrpOvjt0rHPNLyCIeMtbizbir8U//inJ+zuB8= 66 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 67 | github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= 68 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 69 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 70 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 71 | github.com/mattn/go-colorable v0.0.9 h1:UVL0vNpWh04HeJXV0KLcaT7r06gOH2l4OW6ddYRUIY4= 72 | github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= 73 | github.com/mattn/go-colorable v0.1.2 h1:/bC9yWikZXAL9uJdulbSfyVNIR3n3trXl+v8+1sx8mU= 74 | github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= 75 | github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= 76 | github.com/mattn/go-colorable v0.1.6 h1:6Su7aK7lXmJ/U79bYtBjLNaha4Fs1Rg9plHpcH+vvnE= 77 | github.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= 78 | github.com/mattn/go-isatty v0.0.4 h1:bnP0vzxcAdeI1zdubAl5PjU6zsERjGZb7raWodagDYs= 79 | github.com/mattn/go-isatty v0.0.4/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= 80 | github.com/mattn/go-isatty v0.0.8 h1:HLtExJ+uU2HOZ+wI0Tt5DtUDrx8yhUqDcp7fYERX4CE= 81 | github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= 82 | github.com/mattn/go-isatty v0.0.11/go.mod h1:PhnuNfih5lzO57/f3n+odYbM4JtupLOxQOAqxQCu2WE= 83 | github.com/mattn/go-isatty v0.0.12 h1:wuysRhFDzyxgEmMf5xjvJ2M9dZoWAXNNr5LSBS7uHXY= 84 | github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= 85 | github.com/mitchellh/go-homedir v1.0.0 h1:vKb8ShqSby24Yrqr/yDYkuFz8d0WUjys40rvnGC8aR0= 86 | github.com/mitchellh/go-homedir v1.0.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= 87 | github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= 88 | github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= 89 | github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs= 90 | github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= 91 | github.com/nkovacs/streamquote v0.0.0-20170412213628-49af9bddb229/go.mod h1:0aYXnNPJ8l7uZxf45rWW1a/uME32OF0rhiYGNQ2oF2E= 92 | github.com/nkovacs/streamquote v1.0.0/go.mod h1:BN+NaZ2CmdKqUuTUXUEm9j95B2TRbpOWpxbJYzzgUsc= 93 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 94 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 95 | github.com/rjeczalik/notify v0.0.0-20181126183243-629144ba06a1 h1:FLWDC+iIP9BWgYKvWKKtOUZux35LIQNAuIzp/63RQJU= 96 | github.com/rjeczalik/notify v0.0.0-20181126183243-629144ba06a1/go.mod h1:aErll2f0sUX9PXZnVNyeiObbmTlk5jnMoCa4QEjJeqM= 97 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 98 | github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= 99 | github.com/stretchr/testify v1.5.1 h1:nOGnQDM7FYENwehXlg/kFVnos3rEvtKTjRvOWSzb6H4= 100 | github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= 101 | github.com/toqueteos/webbrowser v0.0.0-20171128075006-43eedf9c266f h1:Oool8pJEkNHHvlAvrnWJJem2kXRi1g5raLI3bmic7Ho= 102 | github.com/toqueteos/webbrowser v0.0.0-20171128075006-43eedf9c266f/go.mod h1:Hqqqmzj8AHn+VlZyVjaRWY20i25hoOZGAABCcg2el4A= 103 | github.com/toqueteos/webbrowser v1.2.0 h1:tVP/gpK69Fx+qMJKsLE7TD8LuGWPnEV71wBN9rrstGQ= 104 | github.com/toqueteos/webbrowser v1.2.0/go.mod h1:XWoZq4cyp9WeUeak7w7LXRUQf1F1ATJMir8RTqb4ayM= 105 | github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= 106 | github.com/valyala/fasttemplate v1.0.1/go.mod h1:UQGH1tvbgY+Nz5t2n7tXsz52dQxojPUpymEIMZ47gx8= 107 | golang.org/x/crypto v0.0.0-20181203042331-505ab145d0a9 h1:mKdxBk7AujPs8kU4m80U72y/zjbZ3UcXC7dClwKbUI0= 108 | golang.org/x/crypto v0.0.0-20181203042331-505ab145d0a9/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= 109 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 110 | golang.org/x/crypto v0.0.0-20190701094942-4def268fd1a4 h1:HuIa8hRrWRSrqYzx1qI49NNxhdi2PrY7gxVSq1JjLDc= 111 | golang.org/x/crypto v0.0.0-20190701094942-4def268fd1a4/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 112 | golang.org/x/crypto v0.0.0-20200423211502-4bdfaf469ed5 h1:Q7tZBpemrlsc2I7IyODzhtallWRSm4Q0d09pL6XbQtU= 113 | golang.org/x/crypto v0.0.0-20200423211502-4bdfaf469ed5/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= 114 | golang.org/x/net v0.0.0-20181220203305-927f97764cc3 h1:eH6Eip3UpmR+yM/qI9Ijluzb1bNv/cAU/n+6l8tRSis= 115 | golang.org/x/net v0.0.0-20181220203305-927f97764cc3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 116 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 117 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 118 | golang.org/x/net v0.0.0-20190724013045-ca1201d0de80 h1:Ao/3l156eZf2AW5wK8a7/smtodRU+gha3+BeqJ69lRk= 119 | golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 120 | golang.org/x/net v0.0.0-20190813141303-74dc4d7220e7 h1:fHDIZ2oxGnUZRN6WgWFCbYBjH9uqVPRCUVUDhs0wnbA= 121 | golang.org/x/net v0.0.0-20190813141303-74dc4d7220e7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 122 | golang.org/x/net v0.0.0-20200425230154-ff2c4b7c35a0 h1:Jcxah/M+oLZ/R4/z5RzfPzGbPXnVDPkEDtf2JnuxN+U= 123 | golang.org/x/net v0.0.0-20200425230154-ff2c4b7c35a0/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= 124 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 125 | golang.org/x/sys v0.0.0-20180926160741-c2ed4eda69e7/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 126 | golang.org/x/sys v0.0.0-20181221143128-b4a75ba826a6 h1:IcgEB62HYgAhX0Nd/QrVgZlxlcyxbGQHElLUhW2X4Fo= 127 | golang.org/x/sys v0.0.0-20181221143128-b4a75ba826a6/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 128 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 129 | golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 130 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 131 | golang.org/x/sys v0.0.0-20190804053845-51ab0e2deafa h1:KIDDMLT1O0Nr7TSxp8xM5tJcdn8tgyAONntO829og1M= 132 | golang.org/x/sys v0.0.0-20190804053845-51ab0e2deafa/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 133 | golang.org/x/sys v0.0.0-20190813064441-fde4db37ae7a h1:aYOabOQFp6Vj6W1F80affTUvO9UxmJRx8K0gsfABByQ= 134 | golang.org/x/sys v0.0.0-20190813064441-fde4db37ae7a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 135 | golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 136 | golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 137 | golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 138 | golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 139 | golang.org/x/sys v0.0.0-20200420163511-1957bb5e6d1f h1:gWF768j/LaZugp8dyS4UwsslYCYz9XgFxvlgsn0n9H8= 140 | golang.org/x/sys v0.0.0-20200420163511-1957bb5e6d1f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 141 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 142 | golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= 143 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 144 | golang.org/x/tools v0.0.0-20190808195139-e713427fea3f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 145 | golang.org/x/tools v0.0.0-20190815232600-256244171580/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 146 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 147 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= 148 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 149 | gopkg.in/alecthomas/kingpin.v2 v2.2.6 h1:jMFz6MfLP0/4fUyZle81rXUoxOBFi19VUFKVDOQfozc= 150 | gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= 151 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 152 | gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU= 153 | gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 154 | gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw= 155 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 156 | gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10= 157 | gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 158 | -------------------------------------------------------------------------------- /httpctx/httpctx.go: -------------------------------------------------------------------------------- 1 | // Package httpctx provides a context-aware HTTP handler adaptor 2 | package httpctx 3 | 4 | import ( 5 | "net/http" 6 | "strings" 7 | 8 | "golang.org/x/net/context" 9 | ) 10 | 11 | // Handler is a request handler with an added context 12 | type Handler interface { 13 | ServeHTTPContext(context.Context, http.ResponseWriter, *http.Request) 14 | } 15 | 16 | // A HandlerFunc is an adaptor to turn a function in to a Handler 17 | type HandlerFunc func(context.Context, http.ResponseWriter, *http.Request) 18 | 19 | // ServeHTTPContext calls the underlying handler function 20 | func (h HandlerFunc) ServeHTTPContext( 21 | ctx context.Context, rw http.ResponseWriter, req *http.Request, 22 | ) { 23 | h(ctx, rw, req) 24 | } 25 | 26 | // Adapter turns a context.Handler to an http.Handler 27 | type Adapter struct { 28 | Ctx context.Context 29 | Handler Handler 30 | } 31 | 32 | func (ca *Adapter) ServeHTTP( 33 | rw http.ResponseWriter, req *http.Request, 34 | ) { 35 | ca.Handler.ServeHTTPContext(ca.Ctx, rw, req) 36 | } 37 | 38 | // StripPrefix strips a prefix from the request URL 39 | func StripPrefix(prefix string, h Handler) Handler { 40 | if prefix == "" { 41 | return h 42 | } 43 | return HandlerFunc(func(ctx context.Context, w http.ResponseWriter, r *http.Request) { 44 | if p := strings.TrimPrefix(r.URL.Path, prefix); len(p) < len(r.URL.Path) { 45 | r.URL.Path = p 46 | h.ServeHTTPContext(ctx, w, r) 47 | } 48 | }) 49 | } 50 | -------------------------------------------------------------------------------- /inject/inject.go: -------------------------------------------------------------------------------- 1 | // Package inject gives the ability to copy data and inject a payload before a 2 | // specified marker. In order to let the user respond to the change in length, 3 | // the API is split into two parts - Sniff checks whether the marker occurs 4 | // within a specified number of initial bytes, and Copy sends the data to the 5 | // destination. 6 | // 7 | // The package tries to avoid double-injecting a payload by checking whether 8 | // the payload occurs within the first Within + len(Payload) bytes. 9 | package inject 10 | 11 | import ( 12 | "bytes" 13 | "fmt" 14 | "html/template" 15 | "io" 16 | "net/http" 17 | "regexp" 18 | "strings" 19 | ) 20 | 21 | // CopyInject copies data, and injects a payload before a specified marker 22 | type CopyInject struct { 23 | // Number of initial bytes within which to search for marker 24 | Within int 25 | // Only inject in responses with this content type 26 | ContentType string 27 | // A marker, BEFORE which the payload is inserted 28 | Marker *regexp.Regexp 29 | // The payload to be inserted 30 | Payload []byte 31 | } 32 | 33 | type Injector interface { 34 | Copy(dst io.Writer) (int64, error) 35 | Extra() int 36 | Found() bool 37 | } 38 | 39 | // realInjector keeps injection state 40 | type realInjector struct { 41 | // Has the marker been found? 42 | found bool 43 | conf *CopyInject 44 | src io.Reader 45 | offset int 46 | sniffedData []byte 47 | } 48 | 49 | type nopInjector struct { 50 | src io.Reader 51 | } 52 | 53 | func (injector *nopInjector) Copy(dst io.Writer) (int64, error) { 54 | return io.Copy(dst, injector.src) 55 | } 56 | 57 | func (injector *nopInjector) Extra() int { 58 | return 0 59 | } 60 | 61 | func (injector *nopInjector) Found() bool { 62 | return false 63 | } 64 | 65 | // Extra reports the number of extra bytes that will be injected 66 | func (injector *realInjector) Extra() int { 67 | if injector.found { 68 | return len(injector.conf.Payload) 69 | } 70 | return 0 71 | } 72 | 73 | func (injector *realInjector) Found() bool { 74 | return injector.found 75 | } 76 | 77 | func min(a int, b int) int { 78 | if a > b { 79 | return b 80 | } 81 | return a 82 | } 83 | 84 | // Sniff reads the first SniffLen bytes of the source, and checks for the 85 | // marker. Returns an Injector instance. 86 | func (ci *CopyInject) Sniff(src io.Reader, contentType string) (Injector, error) { 87 | if !strings.Contains(contentType, ci.ContentType) { 88 | return &nopInjector{src: src}, nil 89 | } 90 | 91 | injector := &realInjector{ 92 | conf: ci, 93 | src: src, 94 | } 95 | if ci.Within == 0 || ci.Marker == nil { 96 | return injector, nil 97 | } 98 | buf := make([]byte, ci.Within+len(ci.Payload)) 99 | n, err := io.ReadFull(src, buf) 100 | if err != nil && err != io.ErrUnexpectedEOF && err != io.EOF { 101 | return nil, fmt.Errorf("inject could not read data to sniff: %s", err) 102 | } 103 | injector.sniffedData = buf[:n] 104 | if bytes.Index(buf, ci.Payload) > -1 { 105 | return injector, nil 106 | } 107 | loc := ci.Marker.FindIndex(injector.sniffedData[:min(n, ci.Within)]) 108 | if loc != nil { 109 | injector.found = true 110 | injector.offset = loc[0] 111 | } 112 | return injector, nil 113 | } 114 | 115 | // ServeTemplate renders and serves a template to an http.ResponseWriter 116 | func (ci *CopyInject) ServeTemplate(statuscode int, w http.ResponseWriter, t *template.Template, data interface{}) error { 117 | buff := bytes.NewBuffer(make([]byte, 0, 0)) 118 | err := t.Execute(buff, data) 119 | if err != nil { 120 | return err 121 | } 122 | 123 | length := buff.Len() 124 | inj, err := ci.Sniff(buff, "text/html") 125 | if err != nil { 126 | return err 127 | } 128 | w.Header().Set( 129 | "Content-Length", fmt.Sprintf("%d", length+inj.Extra()), 130 | ) 131 | w.WriteHeader(statuscode) 132 | _, err = inj.Copy(w) 133 | if err != nil { 134 | return err 135 | } 136 | return nil 137 | } 138 | 139 | // Copy copies the data from src to dst, injecting the Payload if Sniff found 140 | // the marker. 141 | func (injector *realInjector) Copy(dst io.Writer) (int64, error) { 142 | var preludeLen int64 143 | if injector.found { 144 | startn, err := io.Copy( 145 | dst, 146 | bytes.NewBuffer( 147 | injector.sniffedData[:injector.offset], 148 | ), 149 | ) 150 | if err != nil { 151 | return startn, err 152 | } 153 | payloadn, err := io.Copy(dst, bytes.NewBuffer(injector.conf.Payload)) 154 | if err != nil { 155 | return startn + payloadn, err 156 | } 157 | endn, err := io.Copy( 158 | dst, bytes.NewBuffer(injector.sniffedData[injector.offset:]), 159 | ) 160 | if err != nil { 161 | return startn + payloadn + endn, err 162 | } 163 | preludeLen = startn + payloadn + endn 164 | } else { 165 | n, err := io.Copy(dst, bytes.NewBuffer(injector.sniffedData)) 166 | if err != nil { 167 | return n, err 168 | } 169 | preludeLen = int64(len(injector.sniffedData)) 170 | } 171 | n, err := io.Copy(dst, injector.src) 172 | return n + preludeLen, err 173 | } 174 | -------------------------------------------------------------------------------- /inject/inject_test.go: -------------------------------------------------------------------------------- 1 | package inject 2 | 3 | import ( 4 | "bytes" 5 | "regexp" 6 | "strings" 7 | "testing" 8 | ) 9 | 10 | func inject(ci CopyInject, data string, contentType string) (found bool, dstdata string, err error) { 11 | src := bytes.NewBuffer([]byte(data)) 12 | dst := bytes.NewBuffer(make([]byte, 0)) 13 | injector, err := ci.Sniff(src, contentType) 14 | if err != nil { 15 | return false, "", err 16 | } 17 | _, err = injector.Copy(dst) 18 | if err != nil { 19 | return false, "", err 20 | } 21 | return injector.Found(), string(dst.Bytes()), nil 22 | } 23 | 24 | func TestReverseProxyNoInject(t *testing.T) { 25 | ci := CopyInject{ 26 | Within: 100, 27 | ContentType: "text/html", 28 | Marker: regexp.MustCompile("mark"), 29 | Payload: []byte("inject"), 30 | } 31 | found, dst, err := inject(ci, "imark", "text/plain") 32 | if err != nil || found || dst != "imark" { 33 | t.Errorf("Unexpected, found:%v dst:%v error:%v", dst, found, err) 34 | } 35 | } 36 | 37 | func TestReverseProxy(t *testing.T) { 38 | var sniffTests = []struct { 39 | snifflen int 40 | marker string 41 | payload string 42 | 43 | src string 44 | result string 45 | }{ 46 | {0, "mark", "inject", "nomatch", "nomatch"}, 47 | {1, "mark", "inject", "nomatch", "nomatch"}, 48 | {10, "mark", "inject", "nomatch", "nomatch"}, 49 | {100, "mark", "inject", "nomatch", "nomatch"}, 50 | {10, "mark", "inject", "imarki", "iinjectmarki"}, 51 | {5, "mark", "inject", "imarki", "iinjectmarki"}, 52 | {4, "mark", "inject", "marki", "injectmarki"}, 53 | {10, "mark", "inject", "imark", "iinjectmark"}, 54 | {5, "mark", "inject", "imark", "iinjectmark"}, 55 | {100, "mark", "inject", "imark", "iinjectmark"}, 56 | } 57 | for i, tt := range sniffTests { 58 | ci := CopyInject{ 59 | Within: tt.snifflen, 60 | ContentType: "text/html", 61 | Marker: regexp.MustCompile(tt.marker), 62 | Payload: []byte(tt.payload), 63 | } 64 | found, dst, err := inject(ci, tt.src, "text/html") 65 | 66 | // Sanity checkss 67 | if err != nil { 68 | t.Errorf("Test %d, unexpected error:\n%s\n", i, err) 69 | } 70 | if found && strings.Index(dst, tt.payload) == -1 { 71 | t.Errorf( 72 | "Test %d, payload not found.", i, 73 | ) 74 | } 75 | var expected int 76 | if found { 77 | expected = len(tt.src) + len(tt.payload) 78 | } else { 79 | expected = len(tt.src) 80 | } 81 | if len(dst) != expected { 82 | t.Errorf( 83 | "Test %d, expected %d bytes copied, found %d", i, len(dst), expected, 84 | ) 85 | } 86 | if dst != tt.result { 87 | t.Errorf("Test %d, expected '%v', got '%v'", i, tt.result, dst) 88 | } 89 | 90 | // Idempotence 91 | found, dst2, err := inject(ci, dst, "text/html") 92 | if err != nil { 93 | t.Errorf("Test %d, unexpected error:\n%s\n", i, err) 94 | } 95 | if found { 96 | t.Errorf("Test %d, idempotence violation", i) 97 | } 98 | if dst != dst2 { 99 | t.Errorf("Test %d, idempotence violation", i) 100 | } 101 | } 102 | } 103 | 104 | func TestNil(t *testing.T) { 105 | ci := CopyInject{} 106 | val := "onetwothree" 107 | src := bytes.NewBuffer([]byte(val)) 108 | injector, err := ci.Sniff(src, "") 109 | if injector.Found() || err != nil { 110 | t.Error("Unexpected") 111 | } 112 | dst := bytes.NewBuffer(make([]byte, 0)) 113 | injector.Copy(dst) 114 | if string(dst.Bytes()) != val { 115 | t.Errorf("Expected %s, got %s", val, string(dst.Bytes())) 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /livereload/livereload.go: -------------------------------------------------------------------------------- 1 | // Package livereload allows HTML pages to be dynamically reloaded. It includes 2 | // both the server and client implementations required. 3 | package livereload 4 | 5 | import ( 6 | "net/http" 7 | "regexp" 8 | "strings" 9 | "sync" 10 | 11 | "github.com/GeertJohan/go.rice" 12 | "github.com/cortesi/devd/inject" 13 | "github.com/cortesi/termlog" 14 | "github.com/gorilla/websocket" 15 | ) 16 | 17 | // Reloader triggers a reload 18 | type Reloader interface { 19 | Reload(paths []string) 20 | Watch(ch chan []string) 21 | } 22 | 23 | const ( 24 | cmdPage = "page" 25 | cmdCSS = "css" 26 | // EndpointPath is the path to the websocket endpoint 27 | EndpointPath = "/.devd.livereload" 28 | // ScriptPath is the path to the livereload JavaScript asset 29 | ScriptPath = "/.devd.livereload.js" 30 | ) 31 | 32 | // Injector for the livereload script 33 | var Injector = inject.CopyInject{ 34 | Within: 1024 * 30, 35 | ContentType: "text/html", 36 | Marker: regexp.MustCompile(`<\/head>`), 37 | Payload: []byte(``), 38 | } 39 | 40 | // Server implements a Livereload server 41 | type Server struct { 42 | sync.Mutex 43 | broadcast chan<- string 44 | 45 | logger termlog.Logger 46 | name string 47 | connections map[*websocket.Conn]bool 48 | } 49 | 50 | // NewServer createss a Server instance 51 | func NewServer(name string, logger termlog.Logger) *Server { 52 | broadcast := make(chan string, 50) 53 | s := &Server{ 54 | name: name, 55 | broadcast: broadcast, 56 | connections: make(map[*websocket.Conn]bool), 57 | logger: logger, 58 | } 59 | go s.run(broadcast) 60 | return s 61 | } 62 | 63 | func (s *Server) run(broadcast <-chan string) { 64 | for m := range broadcast { 65 | s.Lock() 66 | for conn := range s.connections { 67 | if conn == nil { 68 | continue 69 | } 70 | err := conn.WriteMessage(websocket.TextMessage, []byte(m)) 71 | if err != nil { 72 | s.logger.Say("Error: %s", err) 73 | delete(s.connections, conn) 74 | } 75 | } 76 | s.Unlock() 77 | } 78 | s.Lock() 79 | defer s.Unlock() 80 | for conn := range s.connections { 81 | delete(s.connections, conn) 82 | conn.Close() 83 | } 84 | } 85 | 86 | var upgrader = websocket.Upgrader{ 87 | ReadBufferSize: 1024, 88 | WriteBufferSize: 1024, 89 | CheckOrigin: func(r *http.Request) bool { return true }, 90 | } 91 | 92 | func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { 93 | if r.Method != "GET" { 94 | http.Error(w, "Method not allowed", 405) 95 | return 96 | } 97 | conn, err := upgrader.Upgrade(w, r, nil) 98 | if err != nil { 99 | s.logger.Say("Error: %s", err) 100 | http.Error(w, "Can't upgrade.", 500) 101 | return 102 | } 103 | s.Lock() 104 | s.connections[conn] = true 105 | s.Unlock() 106 | } 107 | 108 | // Reload signals to connected clients that a given resource should be 109 | // reloaded. 110 | func (s *Server) Reload(paths []string) { 111 | cmd := cmdCSS 112 | for _, path := range paths { 113 | if !strings.HasSuffix(path, ".css") { 114 | cmd = cmdPage 115 | } 116 | } 117 | s.logger.SayAs("debug", "livereload %s, files changed: %s", cmd, paths) 118 | s.broadcast <- cmd 119 | } 120 | 121 | // Watch montors a channel of lists of paths for reload requests 122 | func (s *Server) Watch(ch chan []string) { 123 | for ei := range ch { 124 | if len(ei) > 0 { 125 | s.Reload(ei) 126 | } 127 | } 128 | } 129 | 130 | // ServeScript is a handler function that serves the livereload JavaScript file 131 | func (s *Server) ServeScript(rw http.ResponseWriter, req *http.Request) { 132 | rw.Header().Set("Content-Type", "application/javascript") 133 | clientBox := rice.MustFindBox("static") 134 | _, err := rw.Write(clientBox.MustBytes("client.js")) 135 | if err != nil { 136 | s.logger.Warn("Error serving livereload script: %s", err) 137 | } 138 | } 139 | -------------------------------------------------------------------------------- /livereload/rice-box.go: -------------------------------------------------------------------------------- 1 | package livereload 2 | 3 | import ( 4 | "github.com/GeertJohan/go.rice/embedded" 5 | "time" 6 | ) 7 | 8 | func init() { 9 | 10 | // define files 11 | file2 := &embedded.EmbeddedFile{ 12 | Filename: "LICENSE", 13 | FileModTime: time.Unix(1503017339, 0), 14 | Content: string("// reconnecting-websocket - MIT License:\n//\n// Copyright (c) 2010-2012, Joe Walnes\n//\n// Permission is hereby granted, free of charge, to any person obtaining a copy\n// of this software and associated documentation files (the \"Software\"), to deal\n// in the Software without restriction, including without limitation the rights\n// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell\n// copies of the Software, and to permit persons to whom the Software is\n// furnished to do so, subject to the following conditions:\n//\n// The above copyright notice and this permission notice shall be included in\n// all copies or substantial portions of the Software.\n//\n// THE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\n// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\n// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\n// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\n// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\n// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN\n// THE SOFTWARE.\n"), 15 | } 16 | file3 := &embedded.EmbeddedFile{ 17 | Filename: "client.js", 18 | FileModTime: time.Unix(1503017339, 0), 19 | Content: string("(function() {\n if (!('WebSocket' in window)) {\n return;\n }\n\n function DevdReconnectingWebSocket(url, protocols, options) {\n\n // Default settings\n var settings = {\n\n /** Whether this instance should log debug messages. */\n debug: false,\n\n /** Whether or not the websocket should attempt to connect immediately upon instantiation. */\n automaticOpen: true,\n\n /** The number of milliseconds to delay before attempting to reconnect. */\n reconnectInterval: 1000,\n /** The maximum number of milliseconds to delay a reconnection attempt. */\n maxReconnectInterval: 30000,\n /** The rate of increase of the reconnect delay. Allows reconnect attempts to back off when problems persist. */\n reconnectDecay: 1.5,\n\n /** The maximum time in milliseconds to wait for a connection to succeed before closing and retrying. */\n timeoutInterval: 2000,\n\n /** The maximum number of reconnection attempts to make. Unlimited if null. */\n maxReconnectAttempts: null,\n\n /** The binary type, possible values 'blob' or 'arraybuffer', default 'blob'. */\n binaryType: 'blob'\n }\n if (!options) {\n options = {};\n }\n\n // Overwrite and define settings with options if they exist.\n for (var key in settings) {\n if (typeof options[key] !== 'undefined') {\n this[key] = options[key];\n } else {\n this[key] = settings[key];\n }\n }\n\n // These should be treated as read-only properties\n\n /** The URL as resolved by the constructor. This is always an absolute URL. Read only. */\n this.url = url;\n\n /** The number of attempted reconnects since starting, or the last successful connection. Read only. */\n this.reconnectAttempts = 0;\n\n /**\n * The current state of the connection.\n * Can be one of: WebSocket.CONNECTING, WebSocket.OPEN, WebSocket.CLOSING, WebSocket.CLOSED\n * Read only.\n */\n this.readyState = WebSocket.CONNECTING;\n\n /**\n * A string indicating the name of the sub-protocol the server selected; this will be one of\n * the strings specified in the protocols parameter when creating the WebSocket object.\n * Read only.\n */\n this.protocol = null;\n\n // Private state variables\n\n var self = this;\n var ws;\n var forcedClose = false;\n var timedOut = false;\n var eventTarget = document.createElement('div');\n\n // Wire up \"on*\" properties as event handlers\n\n eventTarget.addEventListener('open', function(event) {\n self.onopen(event);\n });\n eventTarget.addEventListener('close', function(event) {\n self.onclose(event);\n });\n eventTarget.addEventListener('connecting', function(event) {\n self.onconnecting(event);\n });\n eventTarget.addEventListener('message', function(event) {\n self.onmessage(event);\n });\n eventTarget.addEventListener('error', function(event) {\n self.onerror(event);\n });\n\n // Expose the API required by EventTarget\n\n this.addEventListener = eventTarget.addEventListener.bind(eventTarget);\n this.removeEventListener = eventTarget.removeEventListener.bind(eventTarget);\n this.dispatchEvent = eventTarget.dispatchEvent.bind(eventTarget);\n\n /**\n * This function generates an event that is compatible with standard\n * compliant browsers and IE9 - IE11\n *\n * This will prevent the error:\n * Object doesn't support this action\n *\n * http://stackoverflow.com/questions/19345392/why-arent-my-parameters-getting-passed-through-to-a-dispatched-event/19345563#19345563\n * @param s String The name that the event should use\n * @param args Object an optional object that the event will use\n */\n function generateEvent(s, args) {\n var evt = document.createEvent(\"CustomEvent\");\n evt.initCustomEvent(s, false, false, args);\n return evt;\n };\n\n this.open = function(reconnectAttempt) {\n ws = new WebSocket(self.url, protocols || []);\n ws.binaryType = this.binaryType;\n\n if (reconnectAttempt) {\n if (this.maxReconnectAttempts && this.reconnectAttempts > this.maxReconnectAttempts) {\n return;\n }\n } else {\n eventTarget.dispatchEvent(generateEvent('connecting'));\n this.reconnectAttempts = 0;\n }\n\n if (self.debug || DevdReconnectingWebSocket.debugAll) {\n console.debug('DevdReconnectingWebSocket', 'attempt-connect', self.url);\n }\n\n var localWs = ws;\n var timeout = setTimeout(function() {\n if (self.debug || DevdReconnectingWebSocket.debugAll) {\n console.debug('DevdReconnectingWebSocket', 'connection-timeout', self.url);\n }\n timedOut = true;\n localWs.close();\n timedOut = false;\n }, self.timeoutInterval);\n\n ws.onopen = function(event) {\n clearTimeout(timeout);\n if (self.debug || DevdReconnectingWebSocket.debugAll) {\n console.debug('DevdReconnectingWebSocket', 'onopen', self.url);\n }\n self.protocol = ws.protocol;\n self.readyState = WebSocket.OPEN;\n self.reconnectAttempts = 0;\n var e = generateEvent('open');\n e.isReconnect = reconnectAttempt;\n reconnectAttempt = false;\n eventTarget.dispatchEvent(e);\n };\n\n ws.onclose = function(event) {\n clearTimeout(timeout);\n ws = null;\n if (forcedClose) {\n self.readyState = WebSocket.CLOSED;\n eventTarget.dispatchEvent(generateEvent('close'));\n } else {\n self.readyState = WebSocket.CONNECTING;\n var e = generateEvent('connecting');\n e.code = event.code;\n e.reason = event.reason;\n e.wasClean = event.wasClean;\n eventTarget.dispatchEvent(e);\n if (!reconnectAttempt && !timedOut) {\n if (self.debug || DevdReconnectingWebSocket.debugAll) {\n console.debug('DevdReconnectingWebSocket', 'onclose', self.url);\n }\n eventTarget.dispatchEvent(generateEvent('close'));\n }\n\n var timeout = self.reconnectInterval * Math.pow(self.reconnectDecay, self.reconnectAttempts);\n setTimeout(function() {\n self.reconnectAttempts++;\n self.open(true);\n }, timeout > self.maxReconnectInterval ? self.maxReconnectInterval : timeout);\n }\n };\n ws.onmessage = function(event) {\n if (self.debug || DevdReconnectingWebSocket.debugAll) {\n console.debug('DevdReconnectingWebSocket', 'onmessage', self.url, event.data);\n }\n var e = generateEvent('message');\n e.data = event.data;\n eventTarget.dispatchEvent(e);\n };\n ws.onerror = function(event) {\n if (self.debug || DevdReconnectingWebSocket.debugAll) {\n console.debug('DevdReconnectingWebSocket', 'onerror', self.url, event);\n }\n eventTarget.dispatchEvent(generateEvent('error'));\n };\n }\n\n // Whether or not to create a websocket upon instantiation\n if (this.automaticOpen == true) {\n this.open(false);\n }\n\n /**\n * Transmits data to the server over the WebSocket connection.\n *\n * @param data a text string, ArrayBuffer or Blob to send to the server.\n */\n this.send = function(data) {\n if (ws) {\n if (self.debug || DevdReconnectingWebSocket.debugAll) {\n console.debug('DevdReconnectingWebSocket', 'send', self.url, data);\n }\n return ws.send(data);\n } else {\n throw 'INVALID_STATE_ERR : Pausing to reconnect websocket';\n }\n };\n\n /**\n * Closes the WebSocket connection or connection attempt, if any.\n * If the connection is already CLOSED, this method does nothing.\n */\n this.close = function(code, reason) {\n // Default CLOSE_NORMAL code\n if (typeof code == 'undefined') {\n code = 1000;\n }\n forcedClose = true;\n if (ws) {\n ws.close(code, reason);\n }\n };\n\n /**\n * Additional public API method to refresh the connection if still open (close, re-open).\n * For example, if the app suspects bad data / missed heart beats, it can try to refresh.\n */\n this.refresh = function() {\n if (ws) {\n ws.close();\n }\n };\n }\n\n /**\n * An event listener to be called when the WebSocket connection's readyState changes to OPEN;\n * this indicates that the connection is ready to send and receive data.\n */\n DevdReconnectingWebSocket.prototype.onopen = function(event) {};\n /** An event listener to be called when the WebSocket connection's readyState changes to CLOSED. */\n DevdReconnectingWebSocket.prototype.onclose = function(event) {};\n /** An event listener to be called when a connection begins being attempted. */\n DevdReconnectingWebSocket.prototype.onconnecting = function(event) {};\n /** An event listener to be called when a message is received from the server. */\n DevdReconnectingWebSocket.prototype.onmessage = function(event) {};\n /** An event listener to be called when an error occurs. */\n DevdReconnectingWebSocket.prototype.onerror = function(event) {};\n\n /**\n * Whether all instances of DevdReconnectingWebSocket should log debug messages.\n * Setting this to true is the equivalent of setting all instances of DevdReconnectingWebSocket.debug to true.\n */\n DevdReconnectingWebSocket.debugAll = false;\n\n DevdReconnectingWebSocket.CONNECTING = WebSocket.CONNECTING;\n DevdReconnectingWebSocket.OPEN = WebSocket.OPEN;\n DevdReconnectingWebSocket.CLOSING = WebSocket.CLOSING;\n DevdReconnectingWebSocket.CLOSED = WebSocket.CLOSED;\n\n window.DevdReconnectingWebSocket = DevdReconnectingWebSocket;\n\n var proto = \"ws://\";\n if (window.location.protocol == \"https:\") {\n proto = \"wss://\";\n }\n\n ws = new DevdReconnectingWebSocket(\n proto + window.location.host + \"/.devd.livereload\",\n null,\n {\n debug: true,\n maxReconnectInterval: 3000,\n }\n )\n ws.onmessage = function(event) {\n if (event.data == \"page\") {\n ws.close();\n location.reload();\n } else if (event.data == \"css\") {\n // This snippet pinched from quickreload, under the MIT license:\n // https://github.com/bjoerge/quickreload/blob/master/client.js\n var killcache = '__devd=' + new Date().getTime();\n var stylesheets = Array.prototype.slice.call(\n document.querySelectorAll('link[rel=\"stylesheet\"]')\n );\n stylesheets.forEach(function (el) {\n var href = el.href.replace(/(&|\\?)__devd\\=\\d+/, '');\n el.href = '';\n el.href = href + (href.indexOf(\"?\") == -1 ? '?' : '&') + killcache;\n });\n }\n }\n window.addEventListener(\"beforeunload\", function(e) {\n ws.close();\n delete e.returnValue;\n return;\n });\n})();\n"), 20 | } 21 | 22 | // define dirs 23 | dir1 := &embedded.EmbeddedDir{ 24 | Filename: "", 25 | DirModTime: time.Unix(1503017339, 0), 26 | ChildFiles: []*embedded.EmbeddedFile{ 27 | file2, // "LICENSE" 28 | file3, // "client.js" 29 | 30 | }, 31 | } 32 | 33 | // link ChildDirs 34 | dir1.ChildDirs = []*embedded.EmbeddedDir{} 35 | 36 | // register embeddedBox 37 | embedded.RegisterEmbeddedBox(`static`, &embedded.EmbeddedBox{ 38 | Name: `static`, 39 | Time: time.Unix(1503017339, 0), 40 | Dirs: map[string]*embedded.EmbeddedDir{ 41 | "": dir1, 42 | }, 43 | Files: map[string]*embedded.EmbeddedFile{ 44 | "LICENSE": file2, 45 | "client.js": file3, 46 | }, 47 | }) 48 | } 49 | -------------------------------------------------------------------------------- /livereload/static/LICENSE: -------------------------------------------------------------------------------- 1 | // reconnecting-websocket - MIT License: 2 | // 3 | // Copyright (c) 2010-2012, Joe Walnes 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 13 | // all 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 21 | // THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /livereload/static/client.js: -------------------------------------------------------------------------------- 1 | (function() { 2 | if (!('WebSocket' in window)) { 3 | return; 4 | } 5 | 6 | function DevdReconnectingWebSocket(url, protocols, options) { 7 | 8 | // Default settings 9 | var settings = { 10 | 11 | /** Whether this instance should log debug messages. */ 12 | debug: false, 13 | 14 | /** Whether or not the websocket should attempt to connect immediately upon instantiation. */ 15 | automaticOpen: true, 16 | 17 | /** The number of milliseconds to delay before attempting to reconnect. */ 18 | reconnectInterval: 1000, 19 | /** The maximum number of milliseconds to delay a reconnection attempt. */ 20 | maxReconnectInterval: 30000, 21 | /** The rate of increase of the reconnect delay. Allows reconnect attempts to back off when problems persist. */ 22 | reconnectDecay: 1.5, 23 | 24 | /** The maximum time in milliseconds to wait for a connection to succeed before closing and retrying. */ 25 | timeoutInterval: 2000, 26 | 27 | /** The maximum number of reconnection attempts to make. Unlimited if null. */ 28 | maxReconnectAttempts: null, 29 | 30 | /** The binary type, possible values 'blob' or 'arraybuffer', default 'blob'. */ 31 | binaryType: 'blob' 32 | } 33 | if (!options) { 34 | options = {}; 35 | } 36 | 37 | // Overwrite and define settings with options if they exist. 38 | for (var key in settings) { 39 | if (typeof options[key] !== 'undefined') { 40 | this[key] = options[key]; 41 | } else { 42 | this[key] = settings[key]; 43 | } 44 | } 45 | 46 | // These should be treated as read-only properties 47 | 48 | /** The URL as resolved by the constructor. This is always an absolute URL. Read only. */ 49 | this.url = url; 50 | 51 | /** The number of attempted reconnects since starting, or the last successful connection. Read only. */ 52 | this.reconnectAttempts = 0; 53 | 54 | /** 55 | * The current state of the connection. 56 | * Can be one of: WebSocket.CONNECTING, WebSocket.OPEN, WebSocket.CLOSING, WebSocket.CLOSED 57 | * Read only. 58 | */ 59 | this.readyState = WebSocket.CONNECTING; 60 | 61 | /** 62 | * A string indicating the name of the sub-protocol the server selected; this will be one of 63 | * the strings specified in the protocols parameter when creating the WebSocket object. 64 | * Read only. 65 | */ 66 | this.protocol = null; 67 | 68 | // Private state variables 69 | 70 | var self = this; 71 | var ws; 72 | var forcedClose = false; 73 | var timedOut = false; 74 | var eventTarget = document.createElement('div'); 75 | 76 | // Wire up "on*" properties as event handlers 77 | 78 | eventTarget.addEventListener('open', function(event) { 79 | self.onopen(event); 80 | }); 81 | eventTarget.addEventListener('close', function(event) { 82 | self.onclose(event); 83 | }); 84 | eventTarget.addEventListener('connecting', function(event) { 85 | self.onconnecting(event); 86 | }); 87 | eventTarget.addEventListener('message', function(event) { 88 | self.onmessage(event); 89 | }); 90 | eventTarget.addEventListener('error', function(event) { 91 | self.onerror(event); 92 | }); 93 | 94 | // Expose the API required by EventTarget 95 | 96 | this.addEventListener = eventTarget.addEventListener.bind(eventTarget); 97 | this.removeEventListener = eventTarget.removeEventListener.bind(eventTarget); 98 | this.dispatchEvent = eventTarget.dispatchEvent.bind(eventTarget); 99 | 100 | /** 101 | * This function generates an event that is compatible with standard 102 | * compliant browsers and IE9 - IE11 103 | * 104 | * This will prevent the error: 105 | * Object doesn't support this action 106 | * 107 | * http://stackoverflow.com/questions/19345392/why-arent-my-parameters-getting-passed-through-to-a-dispatched-event/19345563#19345563 108 | * @param s String The name that the event should use 109 | * @param args Object an optional object that the event will use 110 | */ 111 | function generateEvent(s, args) { 112 | var evt = document.createEvent("CustomEvent"); 113 | evt.initCustomEvent(s, false, false, args); 114 | return evt; 115 | }; 116 | 117 | this.open = function(reconnectAttempt) { 118 | ws = new WebSocket(self.url, protocols || []); 119 | ws.binaryType = this.binaryType; 120 | 121 | if (reconnectAttempt) { 122 | if (this.maxReconnectAttempts && this.reconnectAttempts > this.maxReconnectAttempts) { 123 | return; 124 | } 125 | } else { 126 | eventTarget.dispatchEvent(generateEvent('connecting')); 127 | this.reconnectAttempts = 0; 128 | } 129 | 130 | if (self.debug || DevdReconnectingWebSocket.debugAll) { 131 | console.debug('DevdReconnectingWebSocket', 'attempt-connect', self.url); 132 | } 133 | 134 | var localWs = ws; 135 | var timeout = setTimeout(function() { 136 | if (self.debug || DevdReconnectingWebSocket.debugAll) { 137 | console.debug('DevdReconnectingWebSocket', 'connection-timeout', self.url); 138 | } 139 | timedOut = true; 140 | localWs.close(); 141 | timedOut = false; 142 | }, self.timeoutInterval); 143 | 144 | ws.onopen = function(event) { 145 | clearTimeout(timeout); 146 | if (self.debug || DevdReconnectingWebSocket.debugAll) { 147 | console.debug('DevdReconnectingWebSocket', 'onopen', self.url); 148 | } 149 | self.protocol = ws.protocol; 150 | self.readyState = WebSocket.OPEN; 151 | self.reconnectAttempts = 0; 152 | var e = generateEvent('open'); 153 | e.isReconnect = reconnectAttempt; 154 | reconnectAttempt = false; 155 | eventTarget.dispatchEvent(e); 156 | }; 157 | 158 | ws.onclose = function(event) { 159 | clearTimeout(timeout); 160 | ws = null; 161 | if (forcedClose) { 162 | self.readyState = WebSocket.CLOSED; 163 | eventTarget.dispatchEvent(generateEvent('close')); 164 | } else { 165 | self.readyState = WebSocket.CONNECTING; 166 | var e = generateEvent('connecting'); 167 | e.code = event.code; 168 | e.reason = event.reason; 169 | e.wasClean = event.wasClean; 170 | eventTarget.dispatchEvent(e); 171 | if (!reconnectAttempt && !timedOut) { 172 | if (self.debug || DevdReconnectingWebSocket.debugAll) { 173 | console.debug('DevdReconnectingWebSocket', 'onclose', self.url); 174 | } 175 | eventTarget.dispatchEvent(generateEvent('close')); 176 | } 177 | 178 | var timeout = self.reconnectInterval * Math.pow(self.reconnectDecay, self.reconnectAttempts); 179 | setTimeout(function() { 180 | self.reconnectAttempts++; 181 | self.open(true); 182 | }, timeout > self.maxReconnectInterval ? self.maxReconnectInterval : timeout); 183 | } 184 | }; 185 | ws.onmessage = function(event) { 186 | if (self.debug || DevdReconnectingWebSocket.debugAll) { 187 | console.debug('DevdReconnectingWebSocket', 'onmessage', self.url, event.data); 188 | } 189 | var e = generateEvent('message'); 190 | e.data = event.data; 191 | eventTarget.dispatchEvent(e); 192 | }; 193 | ws.onerror = function(event) { 194 | if (self.debug || DevdReconnectingWebSocket.debugAll) { 195 | console.debug('DevdReconnectingWebSocket', 'onerror', self.url, event); 196 | } 197 | eventTarget.dispatchEvent(generateEvent('error')); 198 | }; 199 | } 200 | 201 | // Whether or not to create a websocket upon instantiation 202 | if (this.automaticOpen == true) { 203 | this.open(false); 204 | } 205 | 206 | /** 207 | * Transmits data to the server over the WebSocket connection. 208 | * 209 | * @param data a text string, ArrayBuffer or Blob to send to the server. 210 | */ 211 | this.send = function(data) { 212 | if (ws) { 213 | if (self.debug || DevdReconnectingWebSocket.debugAll) { 214 | console.debug('DevdReconnectingWebSocket', 'send', self.url, data); 215 | } 216 | return ws.send(data); 217 | } else { 218 | throw 'INVALID_STATE_ERR : Pausing to reconnect websocket'; 219 | } 220 | }; 221 | 222 | /** 223 | * Closes the WebSocket connection or connection attempt, if any. 224 | * If the connection is already CLOSED, this method does nothing. 225 | */ 226 | this.close = function(code, reason) { 227 | // Default CLOSE_NORMAL code 228 | if (typeof code == 'undefined') { 229 | code = 1000; 230 | } 231 | forcedClose = true; 232 | if (ws) { 233 | ws.close(code, reason); 234 | } 235 | }; 236 | 237 | /** 238 | * Additional public API method to refresh the connection if still open (close, re-open). 239 | * For example, if the app suspects bad data / missed heart beats, it can try to refresh. 240 | */ 241 | this.refresh = function() { 242 | if (ws) { 243 | ws.close(); 244 | } 245 | }; 246 | } 247 | 248 | /** 249 | * An event listener to be called when the WebSocket connection's readyState changes to OPEN; 250 | * this indicates that the connection is ready to send and receive data. 251 | */ 252 | DevdReconnectingWebSocket.prototype.onopen = function(event) {}; 253 | /** An event listener to be called when the WebSocket connection's readyState changes to CLOSED. */ 254 | DevdReconnectingWebSocket.prototype.onclose = function(event) {}; 255 | /** An event listener to be called when a connection begins being attempted. */ 256 | DevdReconnectingWebSocket.prototype.onconnecting = function(event) {}; 257 | /** An event listener to be called when a message is received from the server. */ 258 | DevdReconnectingWebSocket.prototype.onmessage = function(event) {}; 259 | /** An event listener to be called when an error occurs. */ 260 | DevdReconnectingWebSocket.prototype.onerror = function(event) {}; 261 | 262 | /** 263 | * Whether all instances of DevdReconnectingWebSocket should log debug messages. 264 | * Setting this to true is the equivalent of setting all instances of DevdReconnectingWebSocket.debug to true. 265 | */ 266 | DevdReconnectingWebSocket.debugAll = false; 267 | 268 | DevdReconnectingWebSocket.CONNECTING = WebSocket.CONNECTING; 269 | DevdReconnectingWebSocket.OPEN = WebSocket.OPEN; 270 | DevdReconnectingWebSocket.CLOSING = WebSocket.CLOSING; 271 | DevdReconnectingWebSocket.CLOSED = WebSocket.CLOSED; 272 | 273 | window.DevdReconnectingWebSocket = DevdReconnectingWebSocket; 274 | 275 | var proto = "ws://"; 276 | if (window.location.protocol == "https:") { 277 | proto = "wss://"; 278 | } 279 | 280 | ws = new DevdReconnectingWebSocket( 281 | proto + window.location.host + "/.devd.livereload", 282 | null, 283 | { 284 | debug: true, 285 | maxReconnectInterval: 3000, 286 | } 287 | ) 288 | ws.onmessage = function(event) { 289 | if (event.data == "page") { 290 | ws.close(); 291 | location.reload(); 292 | } else if (event.data == "css") { 293 | // This snippet pinched from quickreload, under the MIT license: 294 | // https://github.com/bjoerge/quickreload/blob/master/client.js 295 | var killcache = '__devd=' + new Date().getTime(); 296 | var stylesheets = Array.prototype.slice.call( 297 | document.querySelectorAll('link[rel="stylesheet"]') 298 | ); 299 | stylesheets.forEach(function (el) { 300 | var href = el.href.replace(/(&|\?)__devd\=\d+/, ''); 301 | el.href = ''; 302 | el.href = href + (href.indexOf("?") == -1 ? '?' : '&') + killcache; 303 | }); 304 | } 305 | } 306 | window.addEventListener("beforeunload", function(e) { 307 | ws.close(); 308 | delete e.returnValue; 309 | return; 310 | }); 311 | })(); 312 | -------------------------------------------------------------------------------- /logheader.go: -------------------------------------------------------------------------------- 1 | package devd 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | 7 | "github.com/cortesi/termlog" 8 | "github.com/fatih/color" 9 | ) 10 | 11 | // LogHeader logs a header 12 | func LogHeader(log termlog.Logger, h http.Header) { 13 | max := 0 14 | for k := range h { 15 | if len(k) > max { 16 | max = len(k) 17 | } 18 | } 19 | for k, vals := range h { 20 | for _, v := range vals { 21 | pad := fmt.Sprintf(fmt.Sprintf("%%%ds", max-len(k)+1), " ") 22 | log.SayAs( 23 | "headers", 24 | "\t%s%s%s", 25 | color.BlueString(k)+":", 26 | pad, 27 | v, 28 | ) 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /modd.conf: -------------------------------------------------------------------------------- 1 | 2 | templates/*.html { 3 | prep: " 4 | rice embed-go 5 | " 6 | } 7 | 8 | livereload/static/*.js { 9 | indir: ./livereload 10 | prep: " 11 | # rice embed-go livereload 12 | rice embed-go 13 | " 14 | } 15 | 16 | **/*.go !vendor/** { 17 | prep: go test @dirmods 18 | } 19 | 20 | **/*.go !**/*_test.go { 21 | prep: go install ./cmd/devd 22 | daemon +sigterm: devd -ml ./tmp 23 | } 24 | -------------------------------------------------------------------------------- /responselogger.go: -------------------------------------------------------------------------------- 1 | package devd 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "strconv" 7 | 8 | "github.com/cortesi/devd/timer" 9 | "github.com/cortesi/termlog" 10 | "github.com/dustin/go-humanize" 11 | "github.com/fatih/color" 12 | ) 13 | 14 | // ResponseLogWriter is a ResponseWriter that logs 15 | type ResponseLogWriter struct { 16 | Log termlog.Logger 17 | Resp http.ResponseWriter 18 | Flusher http.Flusher 19 | Timer *timer.Timer 20 | wroteHeader bool 21 | } 22 | 23 | func (rl *ResponseLogWriter) logCode(code int, status string) { 24 | var codestr string 25 | switch { 26 | case code >= 200 && code < 300: 27 | codestr = color.GreenString("%d %s", code, status) 28 | case code >= 300 && code < 400: 29 | codestr = color.BlueString("%d %s", code, status) 30 | case code >= 400 && code < 500: 31 | codestr = color.YellowString("%d %s", code, status) 32 | case code >= 500 && code < 600: 33 | codestr = color.RedString("%d %s", code, status) 34 | default: 35 | codestr = fmt.Sprintf("%d %s", code, status) 36 | } 37 | cl := rl.Header().Get("content-length") 38 | clstr := "" 39 | if cl != "" { 40 | cli, err := strconv.Atoi(cl) 41 | if err != nil { 42 | rl.Log.Warn("Invalid content-length header") 43 | } else if cli > 0 { 44 | clstr = fmt.Sprintf("%s", humanize.Bytes(uint64(cli))) 45 | } 46 | } 47 | rl.Log.Say("<- %s %s", codestr, clstr) 48 | } 49 | 50 | // Header returns the header map that will be sent by WriteHeader. 51 | // Changing the header after a call to WriteHeader (or Write) has 52 | // no effect. 53 | func (rl *ResponseLogWriter) Header() http.Header { 54 | return rl.Resp.Header() 55 | } 56 | 57 | // Write writes the data to the connection as part of an HTTP reply. 58 | // If WriteHeader has not yet been called, Write calls WriteHeader(http.StatusOK) 59 | // before writing the data. If the Header does not contain a 60 | // Content-Type line, Write adds a Content-Type set to the result of passing 61 | // the initial 512 bytes of written data to DetectContentType. 62 | func (rl *ResponseLogWriter) Write(data []byte) (int, error) { 63 | if !rl.wroteHeader { 64 | rl.WriteHeader(http.StatusOK) 65 | } 66 | ret, err := rl.Resp.Write(data) 67 | rl.Timer.ResponseDone() 68 | return ret, err 69 | } 70 | 71 | // WriteHeader sends an HTTP response header with status code. 72 | // If WriteHeader is not called explicitly, the first call to Write 73 | // will trigger an implicit WriteHeader(http.StatusOK). 74 | // Thus explicit calls to WriteHeader are mainly used to 75 | // send error codes. 76 | func (rl *ResponseLogWriter) WriteHeader(code int) { 77 | rl.wroteHeader = true 78 | rl.logCode(code, http.StatusText(code)) 79 | LogHeader(rl.Log, rl.Resp.Header()) 80 | rl.Timer.ResponseHeaders() 81 | rl.Resp.WriteHeader(code) 82 | rl.Timer.ResponseDone() 83 | } 84 | 85 | func (rl *ResponseLogWriter) Flush() { 86 | if rl.Flusher != nil { 87 | rl.Flusher.Flush() 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /reverseproxy/LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2012 The Go Authors. All rights reserved. 2 | 3 | Redistribution and use in source and binary forms, with or without 4 | modification, are permitted provided that the following conditions are 5 | met: 6 | 7 | * Redistributions of source code must retain the above copyright 8 | notice, this list of conditions and the following disclaimer. 9 | * Redistributions in binary form must reproduce the above 10 | copyright notice, this list of conditions and the following disclaimer 11 | in the documentation and/or other materials provided with the 12 | distribution. 13 | * Neither the name of Google Inc. nor the names of its 14 | contributors may be used to endorse or promote products derived from 15 | this software without specific prior written permission. 16 | 17 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 18 | "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 19 | LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 20 | A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 21 | OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 22 | SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT 23 | LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 24 | DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 25 | THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 26 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 27 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 28 | -------------------------------------------------------------------------------- /reverseproxy/reverseproxy.go: -------------------------------------------------------------------------------- 1 | // Package reverseproxy is a reverse proxy implementation based on the built-in 2 | // httuptil.Reverseproxy. Extensions include better logging and support for 3 | // injection. 4 | package reverseproxy 5 | 6 | import ( 7 | "fmt" 8 | "io" 9 | "net" 10 | "net/http" 11 | "net/url" 12 | "strconv" 13 | "strings" 14 | "sync" 15 | "time" 16 | 17 | "golang.org/x/net/context" 18 | 19 | "github.com/cortesi/devd/inject" 20 | "github.com/cortesi/termlog" 21 | humanize "github.com/dustin/go-humanize" 22 | ) 23 | 24 | // onExitFlushLoop is a callback set by tests to detect the state of the 25 | // flushLoop() goroutine. 26 | var onExitFlushLoop func() 27 | 28 | // ReverseProxy is an HTTP Handler that takes an incoming request and 29 | // sends it to another server, proxying the response back to the 30 | // client. 31 | type ReverseProxy struct { 32 | // Director must be a function which modifies 33 | // the request into a new request to be sent 34 | // using Transport. Its response is then copied 35 | // back to the original client unmodified. 36 | Director func(*http.Request) 37 | 38 | // The transport used to perform proxy requests. 39 | // If nil, http.DefaultTransport is used. 40 | Transport http.RoundTripper 41 | 42 | // FlushInterval specifies the flush interval 43 | // to flush to the client while copying the 44 | // response body. 45 | // If zero, no periodic flushing is done. 46 | FlushInterval time.Duration 47 | 48 | Inject inject.CopyInject 49 | } 50 | 51 | func singleJoiningSlash(a, b string) string { 52 | if b == "" { 53 | return a 54 | } 55 | 56 | aslash := strings.HasSuffix(a, "/") 57 | bslash := strings.HasPrefix(b, "/") 58 | switch { 59 | case aslash && bslash: 60 | return a + b[1:] 61 | case !aslash && !bslash: 62 | return a + "/" + b 63 | } 64 | return a + b 65 | } 66 | 67 | // NewSingleHostReverseProxy returns a new ReverseProxy that rewrites 68 | // URLs to the scheme, host, and base path provided in target. If the 69 | // target's path is "/base" and the incoming request was for "/dir", 70 | // the target request will be for /base/dir. 71 | func NewSingleHostReverseProxy(target *url.URL, ci inject.CopyInject) *ReverseProxy { 72 | targetQuery := target.RawQuery 73 | director := func(req *http.Request) { 74 | req.URL.Host = target.Host 75 | req.URL.Path = singleJoiningSlash(target.Path, req.URL.Path) 76 | if req.Header.Get("X-Forwarded-Host") == "" { 77 | req.Header.Set("X-Forwarded-Host", req.Host) 78 | } 79 | if req.Header.Get("X-Forwarded-Proto") == "" { 80 | req.Header.Set("X-Forwarded-Proto", req.URL.Scheme) 81 | } 82 | req.URL.Scheme = target.Scheme 83 | 84 | // Set "identity"-only content encoding, in order for injector to 85 | // work on text response 86 | req.Header.Set("Accept-Encoding", "identity") 87 | 88 | req.Host = req.URL.Host 89 | if targetQuery == "" || req.URL.RawQuery == "" { 90 | req.URL.RawQuery = targetQuery + req.URL.RawQuery 91 | } else { 92 | req.URL.RawQuery = targetQuery + "&" + req.URL.RawQuery 93 | } 94 | } 95 | return &ReverseProxy{Director: director, Inject: ci} 96 | } 97 | 98 | func copyHeader(dst, src http.Header) { 99 | for k, vv := range src { 100 | for _, v := range vv { 101 | dst.Add(k, v) 102 | } 103 | } 104 | } 105 | 106 | // Hop-by-hop headers. These are removed when sent to the backend. 107 | // http://www.w3.org/Protocols/rfc2616/rfc2616-sec13.html 108 | var hopHeaders = []string{ 109 | "Connection", 110 | "Keep-Alive", 111 | "Proxy-Authenticate", 112 | "Proxy-Authorization", 113 | "Te", // canonicalized version of "TE" 114 | "Trailers", 115 | "Transfer-Encoding", 116 | "Upgrade", 117 | } 118 | 119 | // ServeHTTPContext serves HTTP with a context 120 | func (p *ReverseProxy) ServeHTTPContext( 121 | ctx context.Context, rw http.ResponseWriter, req *http.Request, 122 | ) { 123 | log := termlog.FromContext(ctx) 124 | transport := p.Transport 125 | if transport == nil { 126 | transport = http.DefaultTransport 127 | } 128 | 129 | outreq := new(http.Request) 130 | *outreq = *req // includes shallow copies of maps, but okay 131 | 132 | p.Director(outreq) 133 | outreq.Proto = "HTTP/1.1" 134 | outreq.ProtoMajor = 1 135 | outreq.ProtoMinor = 1 136 | outreq.Close = false 137 | 138 | // Remove hop-by-hop headers to the backend. Especially 139 | // important is "Connection" because we want a persistent 140 | // connection, regardless of what the client sent to us. This 141 | // is modifying the same underlying map from req (shallow 142 | // copied above) so we only copy it if necessary. 143 | copiedHeaders := false 144 | for _, h := range hopHeaders { 145 | if outreq.Header.Get(h) != "" { 146 | if !copiedHeaders { 147 | outreq.Header = make(http.Header) 148 | copyHeader(outreq.Header, req.Header) 149 | copiedHeaders = true 150 | } 151 | outreq.Header.Del(h) 152 | } 153 | } 154 | 155 | if clientIP, _, err := net.SplitHostPort(req.RemoteAddr); err == nil { 156 | // If we aren't the first proxy retain prior 157 | // X-Forwarded-For information as a comma+space 158 | // separated list and fold multiple headers into one. 159 | if prior, ok := outreq.Header["X-Forwarded-For"]; ok { 160 | clientIP = strings.Join(prior, ", ") + ", " + clientIP 161 | } 162 | outreq.Header.Set("X-Forwarded-For", clientIP) 163 | } 164 | 165 | res, err := transport.RoundTrip(outreq) 166 | if err != nil { 167 | log.Shout("reverse proxy error: %v", err) 168 | rw.WriteHeader(http.StatusInternalServerError) 169 | return 170 | } 171 | defer res.Body.Close() 172 | if req.ContentLength > 0 { 173 | log.Say(fmt.Sprintf("%s uploaded", humanize.Bytes(uint64(req.ContentLength)))) 174 | } 175 | 176 | inject, err := p.Inject.Sniff(res.Body, res.Header.Get("Content-Type")) 177 | if err != nil { 178 | log.Shout("reverse proxy error: %v", err) 179 | rw.WriteHeader(http.StatusInternalServerError) 180 | return 181 | } 182 | 183 | if inject.Found() { 184 | cl, err := strconv.ParseInt(res.Header.Get("Content-Length"), 10, 32) 185 | if err == nil { 186 | cl = cl + int64(inject.Extra()) 187 | res.Header.Set("Content-Length", strconv.FormatInt(cl, 10)) 188 | } 189 | } 190 | copyHeader(rw.Header(), res.Header) 191 | rw.WriteHeader(res.StatusCode) 192 | p.copyResponse(ctx, rw, inject) 193 | } 194 | 195 | func (p *ReverseProxy) ServeHTTP(w http.ResponseWriter, r *http.Request) { 196 | p.ServeHTTPContext(context.Background(), w, r) 197 | } 198 | 199 | func (p *ReverseProxy) copyResponse(ctx context.Context, dst io.Writer, inject inject.Injector) { 200 | log := termlog.FromContext(ctx) 201 | if p.FlushInterval != 0 { 202 | if wf, ok := dst.(writeFlusher); ok { 203 | mlw := &maxLatencyWriter{ 204 | dst: wf, 205 | latency: p.FlushInterval, 206 | done: make(chan bool), 207 | } 208 | go mlw.flushLoop() 209 | defer mlw.stop() 210 | dst = mlw 211 | } 212 | } 213 | _, err := inject.Copy(dst) 214 | if err != nil { 215 | log.Shout("Error forwarding data: %s", err) 216 | } 217 | } 218 | 219 | type writeFlusher interface { 220 | io.Writer 221 | http.Flusher 222 | } 223 | 224 | type maxLatencyWriter struct { 225 | sync.Mutex // protects Write + Flush 226 | 227 | dst writeFlusher 228 | latency time.Duration 229 | 230 | done chan bool 231 | } 232 | 233 | func (m *maxLatencyWriter) Write(p []byte) (int, error) { 234 | m.Lock() 235 | defer m.Unlock() 236 | return m.dst.Write(p) 237 | } 238 | 239 | func (m *maxLatencyWriter) flushLoop() { 240 | t := time.NewTicker(m.latency) 241 | defer t.Stop() 242 | for { 243 | select { 244 | case <-m.done: 245 | if onExitFlushLoop != nil { 246 | onExitFlushLoop() 247 | } 248 | return 249 | case <-t.C: 250 | m.Lock() 251 | m.dst.Flush() 252 | m.Unlock() 253 | } 254 | } 255 | } 256 | 257 | func (m *maxLatencyWriter) stop() { m.done <- true } 258 | -------------------------------------------------------------------------------- /reverseproxy/reverseproxy_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2011 The Go Authors. All rights reserved. 2 | // Use of this source code is governed by a BSD-style 3 | // license that can be found in the LICENSE file. 4 | 5 | // Reverse proxy tests. 6 | 7 | package reverseproxy 8 | 9 | import ( 10 | "io/ioutil" 11 | "net/http" 12 | "net/http/httptest" 13 | "net/url" 14 | "strings" 15 | "testing" 16 | "time" 17 | 18 | "github.com/cortesi/devd/inject" 19 | ) 20 | 21 | func TestReverseProxy(t *testing.T) { 22 | const backendResponse = "I am the backend" 23 | const backendStatus = 404 24 | backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 25 | if len(r.TransferEncoding) > 0 { 26 | t.Errorf("backend got unexpected TransferEncoding: %v", r.TransferEncoding) 27 | } 28 | if r.Header.Get("X-Forwarded-For") == "" { 29 | t.Errorf("didn't get X-Forwarded-For header") 30 | } 31 | if c := r.Header.Get("Connection"); c != "" { 32 | t.Errorf("handler got Connection header value %q", c) 33 | } 34 | if c := r.Header.Get("Upgrade"); c != "" { 35 | t.Errorf("handler got Upgrade header value %q", c) 36 | } 37 | if g, e := r.Host, "some-name"; g == e { 38 | t.Errorf("backend got original Host header %q, expected over-written", g) 39 | } 40 | if acceptEncoding := r.Header.Get("Accept-Encoding"); acceptEncoding != "identity" { 41 | t.Errorf( 42 | "backend got unexpected or no Accept-Encoding header: %q, expected \"identity\"", 43 | acceptEncoding, 44 | ) 45 | } 46 | w.Header().Set("X-Foo", "bar") 47 | http.SetCookie(w, &http.Cookie{Name: "flavor", Value: "chocolateChip"}) 48 | w.WriteHeader(backendStatus) 49 | w.Write([]byte(backendResponse)) 50 | })) 51 | defer backend.Close() 52 | backendURL, err := url.Parse(backend.URL) 53 | if err != nil { 54 | t.Fatal(err) 55 | } 56 | proxyHandler := NewSingleHostReverseProxy(backendURL, inject.CopyInject{}) 57 | frontend := httptest.NewServer(proxyHandler) 58 | defer frontend.Close() 59 | 60 | getReq, _ := http.NewRequest("GET", frontend.URL, nil) 61 | getReq.Host = "some-name" 62 | getReq.Header.Set("Connection", "close") 63 | getReq.Header.Set("Upgrade", "foo") 64 | getReq.Close = true 65 | res, err := http.DefaultClient.Do(getReq) 66 | if err != nil { 67 | t.Fatalf("Get: %v", err) 68 | } 69 | if g, e := res.StatusCode, backendStatus; g != e { 70 | t.Errorf("got res.StatusCode %d; expected %d", g, e) 71 | } 72 | if g, e := res.Header.Get("X-Foo"), "bar"; g != e { 73 | t.Errorf("got X-Foo %q; expected %q", g, e) 74 | } 75 | if g, e := len(res.Header["Set-Cookie"]), 1; g != e { 76 | t.Fatalf("got %d SetCookies, want %d", g, e) 77 | } 78 | if cookie := res.Cookies()[0]; cookie.Name != "flavor" { 79 | t.Errorf("unexpected cookie %q", cookie.Name) 80 | } 81 | bodyBytes, _ := ioutil.ReadAll(res.Body) 82 | if g, e := string(bodyBytes), backendResponse; g != e { 83 | t.Errorf("got body %q; expected %q", g, e) 84 | } 85 | } 86 | 87 | func TestXForwardedFor(t *testing.T) { 88 | const prevForwardedFor = "client ip" 89 | const backendResponse = "I am the backend" 90 | const backendStatus = 404 91 | backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 92 | if r.Header.Get("X-Forwarded-For") == "" { 93 | t.Errorf("didn't get X-Forwarded-For header") 94 | } 95 | if !strings.Contains(r.Header.Get("X-Forwarded-For"), prevForwardedFor) { 96 | t.Errorf("X-Forwarded-For didn't contain prior data") 97 | } 98 | w.WriteHeader(backendStatus) 99 | w.Write([]byte(backendResponse)) 100 | })) 101 | defer backend.Close() 102 | backendURL, err := url.Parse(backend.URL) 103 | if err != nil { 104 | t.Fatal(err) 105 | } 106 | proxyHandler := NewSingleHostReverseProxy(backendURL, inject.CopyInject{}) 107 | frontend := httptest.NewServer(proxyHandler) 108 | defer frontend.Close() 109 | 110 | getReq, _ := http.NewRequest("GET", frontend.URL, nil) 111 | getReq.Host = "some-name" 112 | getReq.Header.Set("Connection", "close") 113 | getReq.Header.Set("X-Forwarded-For", prevForwardedFor) 114 | getReq.Close = true 115 | res, err := http.DefaultClient.Do(getReq) 116 | if err != nil { 117 | t.Fatalf("Get: %v", err) 118 | } 119 | if g, e := res.StatusCode, backendStatus; g != e { 120 | t.Errorf("got res.StatusCode %d; expected %d", g, e) 121 | } 122 | bodyBytes, _ := ioutil.ReadAll(res.Body) 123 | if g, e := string(bodyBytes), backendResponse; g != e { 124 | t.Errorf("got body %q; expected %q", g, e) 125 | } 126 | } 127 | 128 | var proxyQueryTests = []struct { 129 | baseSuffix string // suffix to add to backend URL 130 | reqSuffix string // suffix to add to frontend's request URL 131 | want string // what backend should see for final request URL (without ?) 132 | }{ 133 | {"", "", ""}, 134 | {"?sta=tic", "?us=er", "sta=tic&us=er"}, 135 | {"", "?us=er", "us=er"}, 136 | {"?sta=tic", "", "sta=tic"}, 137 | } 138 | 139 | func TestReverseProxyQuery(t *testing.T) { 140 | backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 141 | w.Header().Set("X-Got-Query", r.URL.RawQuery) 142 | w.Write([]byte("hi")) 143 | })) 144 | defer backend.Close() 145 | 146 | for i, tt := range proxyQueryTests { 147 | backendURL, err := url.Parse(backend.URL + tt.baseSuffix) 148 | if err != nil { 149 | t.Fatal(err) 150 | } 151 | frontend := httptest.NewServer(NewSingleHostReverseProxy(backendURL, inject.CopyInject{})) 152 | req, _ := http.NewRequest("GET", frontend.URL+tt.reqSuffix, nil) 153 | req.Close = true 154 | res, err := http.DefaultClient.Do(req) 155 | if err != nil { 156 | t.Fatalf("%d. Get: %v", i, err) 157 | } 158 | if g, e := res.Header.Get("X-Got-Query"), tt.want; g != e { 159 | t.Errorf("%d. got query %q; expected %q", i, g, e) 160 | } 161 | res.Body.Close() 162 | frontend.Close() 163 | } 164 | } 165 | 166 | func TestReverseProxyFlushInterval(t *testing.T) { 167 | const expected = "hi" 168 | backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 169 | w.Write([]byte(expected)) 170 | })) 171 | defer backend.Close() 172 | 173 | backendURL, err := url.Parse(backend.URL) 174 | if err != nil { 175 | t.Fatal(err) 176 | } 177 | 178 | proxyHandler := NewSingleHostReverseProxy(backendURL, inject.CopyInject{}) 179 | proxyHandler.FlushInterval = time.Microsecond 180 | 181 | done := make(chan bool) 182 | onExitFlushLoop = func() { done <- true } 183 | defer func() { onExitFlushLoop = nil }() 184 | 185 | frontend := httptest.NewServer(proxyHandler) 186 | defer frontend.Close() 187 | 188 | req, _ := http.NewRequest("GET", frontend.URL, nil) 189 | req.Close = true 190 | res, err := http.DefaultClient.Do(req) 191 | if err != nil { 192 | t.Fatalf("Get: %v", err) 193 | } 194 | defer res.Body.Close() 195 | if bodyBytes, _ := ioutil.ReadAll(res.Body); string(bodyBytes) != expected { 196 | t.Errorf("got body %q; expected %q", bodyBytes, expected) 197 | } 198 | 199 | select { 200 | case <-done: 201 | // OK 202 | case <-time.After(5 * time.Second): 203 | t.Error("maxLatencyWriter flushLoop() never exited") 204 | } 205 | } 206 | -------------------------------------------------------------------------------- /rice-box.go: -------------------------------------------------------------------------------- 1 | package devd 2 | 3 | import ( 4 | "github.com/GeertJohan/go.rice/embedded" 5 | "time" 6 | ) 7 | 8 | func init() { 9 | 10 | // define files 11 | file2 := &embedded.EmbeddedFile{ 12 | Filename: "404.html", 13 | FileModTime: time.Unix(1503017339, 0), 14 | Content: string("\n \n \n \n \n

404: Page not found

\n
\n {{ .Version }}\n
\n \n\n"), 15 | } 16 | file3 := &embedded.EmbeddedFile{ 17 | Filename: "dirlist.html", 18 | FileModTime: time.Unix(1503017339, 0), 19 | Content: string("\n \n \n \n \n

{{.Name}}

\n \n {{ range .Files }}\n \t\t\t\n \n \n \n \n {{ else }}\n \n {{ end }}\n
\n {{.Name}}{{ if .IsDir }}/{{ end }}\n {{ .Size | bytes }}{{ .ModTime | reltime }}
No files found.
\n
\n {{ .Version }}\n
\n \n\n"), 20 | } 21 | 22 | // define dirs 23 | dir1 := &embedded.EmbeddedDir{ 24 | Filename: "", 25 | DirModTime: time.Unix(1503017339, 0), 26 | ChildFiles: []*embedded.EmbeddedFile{ 27 | file2, // "404.html" 28 | file3, // "dirlist.html" 29 | 30 | }, 31 | } 32 | 33 | // link ChildDirs 34 | dir1.ChildDirs = []*embedded.EmbeddedDir{} 35 | 36 | // register embeddedBox 37 | embedded.RegisterEmbeddedBox(`templates`, &embedded.EmbeddedBox{ 38 | Name: `templates`, 39 | Time: time.Unix(1503017339, 0), 40 | Dirs: map[string]*embedded.EmbeddedDir{ 41 | "": dir1, 42 | }, 43 | Files: map[string]*embedded.EmbeddedFile{ 44 | "404.html": file2, 45 | "dirlist.html": file3, 46 | }, 47 | }) 48 | } 49 | -------------------------------------------------------------------------------- /ricetemp/ricetemp.go: -------------------------------------------------------------------------------- 1 | // Package ricetemp makes templates from a ricebox. 2 | package ricetemp 3 | 4 | import ( 5 | "html/template" 6 | "os" 7 | "strings" 8 | 9 | "github.com/GeertJohan/go.rice" 10 | "github.com/dustin/go-humanize" 11 | ) 12 | 13 | func bytes(size int64) string { 14 | return humanize.Bytes(uint64(size)) 15 | } 16 | 17 | func fileType(f os.FileInfo) string { 18 | if f.IsDir() { 19 | return "dir" 20 | } 21 | if strings.HasPrefix(f.Name(), ".") { 22 | return "hidden" 23 | } 24 | return "file" 25 | } 26 | 27 | // MustMakeTemplates makes templates, and panic on error 28 | func MustMakeTemplates(rb *rice.Box) *template.Template { 29 | templates, err := MakeTemplates(rb) 30 | if err != nil { 31 | panic(err) 32 | } 33 | return templates 34 | } 35 | 36 | // MakeTemplates takes a rice.Box and returns a html.Template 37 | func MakeTemplates(rb *rice.Box) (*template.Template, error) { 38 | tmpl := template.New("") 39 | 40 | funcMap := template.FuncMap{ 41 | "bytes": bytes, 42 | "reltime": humanize.Time, 43 | "fileType": fileType, 44 | } 45 | tmpl.Funcs(funcMap) 46 | 47 | err := rb.Walk("", func(path string, info os.FileInfo, err error) error { 48 | if !info.IsDir() { 49 | _, err := tmpl.New(path).Parse(rb.MustString(path)) 50 | if err != nil { 51 | return err 52 | } 53 | } 54 | return nil 55 | }) 56 | return tmpl, err 57 | } 58 | -------------------------------------------------------------------------------- /route.go: -------------------------------------------------------------------------------- 1 | package devd 2 | 3 | import ( 4 | "crypto/tls" 5 | "errors" 6 | "fmt" 7 | "html/template" 8 | "net/http" 9 | "net/url" 10 | "time" 11 | 12 | "github.com/cortesi/devd/fileserver" 13 | "github.com/cortesi/devd/httpctx" 14 | "github.com/cortesi/devd/inject" 15 | "github.com/cortesi/devd/reverseproxy" 16 | "github.com/cortesi/devd/routespec" 17 | ) 18 | 19 | // Endpoint is the destination of a Route - either on the filesystem or 20 | // forwarding to another URL 21 | type endpoint interface { 22 | Handler(prefix string, templates *template.Template, ci inject.CopyInject) httpctx.Handler 23 | String() string 24 | } 25 | 26 | // An endpoint that forwards to an upstream URL 27 | type forwardEndpoint url.URL 28 | 29 | func (ep forwardEndpoint) Handler(prefix string, templates *template.Template, ci inject.CopyInject) httpctx.Handler { 30 | u := url.URL(ep) 31 | rp := reverseproxy.NewSingleHostReverseProxy(&u, ci) 32 | rp.Transport = &http.Transport{ 33 | TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, 34 | } 35 | rp.FlushInterval = 200 * time.Millisecond 36 | return httpctx.StripPrefix(prefix, rp) 37 | } 38 | 39 | func newForwardEndpoint(path string) (*forwardEndpoint, error) { 40 | url, err := url.Parse(path) 41 | if err != nil { 42 | return nil, fmt.Errorf("Could not parse route URL: %s", err) 43 | } 44 | f := forwardEndpoint(*url) 45 | return &f, nil 46 | } 47 | 48 | func (ep forwardEndpoint) String() string { 49 | return "forward to " + ep.Scheme + "://" + ep.Host + ep.Path 50 | } 51 | 52 | // An enpoint that serves a filesystem location 53 | type filesystemEndpoint struct { 54 | Root string 55 | notFoundRoutes []routespec.RouteSpec 56 | } 57 | 58 | func newFilesystemEndpoint(path string, notfound []string) (*filesystemEndpoint, error) { 59 | rparts := []routespec.RouteSpec{} 60 | for _, p := range notfound { 61 | rp, err := routespec.ParseRouteSpec(p) 62 | if err != nil { 63 | return nil, err 64 | } 65 | if rp.IsURL { 66 | return nil, fmt.Errorf("Not found over-ride target cannot be a URL.") 67 | } 68 | rparts = append(rparts, *rp) 69 | } 70 | return &filesystemEndpoint{path, rparts}, nil 71 | } 72 | 73 | func (ep filesystemEndpoint) Handler(prefix string, templates *template.Template, ci inject.CopyInject) httpctx.Handler { 74 | return &fileserver.FileServer{ 75 | Version: "devd " + Version, 76 | Root: http.Dir(ep.Root), 77 | Inject: ci, 78 | Templates: templates, 79 | NotFoundRoutes: ep.notFoundRoutes, 80 | Prefix: prefix, 81 | } 82 | } 83 | 84 | func (ep filesystemEndpoint) String() string { 85 | return "reads files from " + ep.Root 86 | } 87 | 88 | // Route is a mapping from a (host, path) tuple to an endpoint. 89 | type Route struct { 90 | Host string 91 | Path string 92 | Endpoint endpoint 93 | } 94 | 95 | // Constructs a new route from a string specifcation. Specifcations are of the 96 | // form ANCHOR=VALUE. 97 | func newRoute(s string, notfound []string) (*Route, error) { 98 | rp, err := routespec.ParseRouteSpec(s) 99 | if err != nil { 100 | return nil, err 101 | } 102 | 103 | var ep endpoint 104 | 105 | if rp.IsURL { 106 | ep, err = newForwardEndpoint(rp.Value) 107 | } else { 108 | ep, err = newFilesystemEndpoint(rp.Value, notfound) 109 | } 110 | if err != nil { 111 | return nil, err 112 | } 113 | return &Route{rp.Host, rp.Path, ep}, nil 114 | } 115 | 116 | // MuxMatch produces a match clause suitable for passing to a Mux 117 | func (f Route) MuxMatch() string { 118 | // Path is guaranteed to start with / 119 | return f.Host + f.Path 120 | } 121 | 122 | // RouteCollection is a collection of routes 123 | type RouteCollection map[string]Route 124 | 125 | func (f *RouteCollection) String() string { 126 | return fmt.Sprintf("%v", *f) 127 | } 128 | 129 | // Add a route to the collection 130 | func (f RouteCollection) Add(value string, notfound []string) error { 131 | s, err := newRoute(value, notfound) 132 | if err != nil { 133 | return err 134 | } 135 | if _, exists := f[s.MuxMatch()]; exists { 136 | return errors.New("Route already exists.") 137 | } 138 | f[s.MuxMatch()] = *s 139 | return nil 140 | } 141 | -------------------------------------------------------------------------------- /route_test.go: -------------------------------------------------------------------------------- 1 | package devd 2 | 3 | import ( 4 | "fmt" 5 | "reflect" 6 | "strings" 7 | "testing" 8 | 9 | "github.com/GeertJohan/go.rice" 10 | "github.com/cortesi/devd/inject" 11 | "github.com/cortesi/devd/ricetemp" 12 | ) 13 | 14 | func tFilesystemEndpoint(s string) *filesystemEndpoint { 15 | e, _ := newFilesystemEndpoint(s, []string{}) 16 | return e 17 | } 18 | 19 | func tForwardEndpoint(s string) *forwardEndpoint { 20 | e, _ := newForwardEndpoint(s) 21 | return e 22 | } 23 | 24 | func within(s string, e error) bool { 25 | s = strings.ToLower(s) 26 | estr := strings.ToLower(fmt.Sprint(e)) 27 | return strings.Contains(estr, s) 28 | } 29 | 30 | var newSpecTests = []struct { 31 | raw string 32 | spec *Route 33 | err string 34 | }{ 35 | { 36 | "/one=two", 37 | &Route{"", "/one", tFilesystemEndpoint("two")}, 38 | "", 39 | }, 40 | { 41 | "/one=two=three", 42 | &Route{"", "/one", tFilesystemEndpoint("two=three")}, 43 | "", 44 | }, 45 | { 46 | "one", 47 | &Route{"", "/", tFilesystemEndpoint("one")}, 48 | "invalid spec", 49 | }, 50 | {"=one", nil, "invalid spec"}, 51 | {"one=", nil, "invalid spec"}, 52 | { 53 | "one/two=three", 54 | &Route{"one.devd.io", "/two", tFilesystemEndpoint("three")}, 55 | "", 56 | }, 57 | { 58 | "one=three", 59 | &Route{"one.devd.io", "/", tFilesystemEndpoint("three")}, 60 | "", 61 | }, 62 | { 63 | "one=http://three", 64 | &Route{"one.devd.io", "/", tForwardEndpoint("http://three")}, 65 | "", 66 | }, 67 | { 68 | "one=localhost:1234", 69 | nil, 70 | "Unknown scheme 'localhost': did you mean http or https?: localhost:1234", 71 | }, 72 | { 73 | "one=localhost:1234/abc", 74 | nil, 75 | "Unknown scheme 'localhost': did you mean http or https?: localhost:1234/abc", 76 | }, 77 | { 78 | "one=ws://three", 79 | nil, 80 | "Websocket protocol not supported: ws://three", 81 | }, 82 | { 83 | "one=:1234", 84 | &Route{"one.devd.io", "/", tForwardEndpoint("http://localhost:1234")}, 85 | "", 86 | }, 87 | } 88 | 89 | func TestParseSpec(t *testing.T) { 90 | for i, tt := range newSpecTests { 91 | s, err := newRoute(tt.raw, []string{}) 92 | if tt.spec != nil { 93 | if err != nil { 94 | t.Errorf("Test %d, error:\n%s\n", i, err) 95 | continue 96 | } 97 | if !reflect.DeepEqual(s, tt.spec) { 98 | t.Errorf("Test %d, expecting:\n%s\nGot:\n%s\n", i, tt.spec, s) 99 | continue 100 | } 101 | } else if tt.err != "" { 102 | if err == nil { 103 | t.Errorf("Test %d, expected error:\n%s\n", i, tt.err) 104 | continue 105 | } 106 | if !within(tt.err, err) { 107 | t.Errorf( 108 | "Test %d, expected error:\n%s\nGot error:%s\n", 109 | i, 110 | tt.err, 111 | err, 112 | ) 113 | continue 114 | } 115 | } 116 | } 117 | } 118 | 119 | func TestForwardEndpoint(t *testing.T) { 120 | f, err := newForwardEndpoint("http://foo") 121 | if err != nil { 122 | t.Errorf("Unexpected error: %s", err) 123 | } 124 | rb, err := rice.FindBox("templates") 125 | if err != nil { 126 | t.Error(err) 127 | } 128 | templates, err := ricetemp.MakeTemplates(rb) 129 | if err != nil { 130 | panic(err) 131 | } 132 | 133 | f.Handler("", templates, inject.CopyInject{}) 134 | 135 | f, err = newForwardEndpoint("%") 136 | if err == nil { 137 | t.Errorf("Expected error, got %s", f) 138 | } 139 | } 140 | 141 | func TestNewRoute(t *testing.T) { 142 | r, err := newRoute("foo=http://%", []string{}) 143 | if err == nil { 144 | t.Errorf("Expected error, got %s", r) 145 | } 146 | } 147 | 148 | func TestRouteHandler(t *testing.T) { 149 | var routeHandlerTests = []struct { 150 | spec string 151 | }{ 152 | {"/one=two"}, 153 | } 154 | for i, tt := range routeHandlerTests { 155 | r, err := newRoute(tt.spec, []string{}) 156 | if err != nil { 157 | t.Errorf( 158 | "Test %d, unexpected error:\n%s\n", 159 | i, 160 | err, 161 | ) 162 | } 163 | 164 | rb, err := rice.FindBox("templates") 165 | if err != nil { 166 | t.Error(err) 167 | } 168 | templates, err := ricetemp.MakeTemplates(rb) 169 | if err != nil { 170 | panic(err) 171 | } 172 | 173 | r.Endpoint.Handler("", templates, inject.CopyInject{}) 174 | } 175 | } 176 | 177 | func TestRouteCollection(t *testing.T) { 178 | var m = make(RouteCollection) 179 | _ = m.String() 180 | err := m.Add("foo=bar", []string{}) 181 | if err != nil { 182 | t.Error(err) 183 | } 184 | err = m.Add("foo", []string{}) 185 | if err != nil { 186 | t.Error(err) 187 | } 188 | 189 | err = m.Add("xxx=bar", []string{}) 190 | if err != nil { 191 | t.Errorf("Set error: %s", err) 192 | } 193 | 194 | err = m.Add("xxx=bar", []string{}) 195 | if err == nil { 196 | t.Errorf("Expected error, got: %s", m) 197 | } 198 | } 199 | 200 | func TestNotFound(t *testing.T) { 201 | e, _ := newFilesystemEndpoint("/test", []string{}) 202 | fmt.Println(e) 203 | } 204 | -------------------------------------------------------------------------------- /routespec/routespec.go: -------------------------------------------------------------------------------- 1 | package routespec 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "net/url" 7 | "strings" 8 | ) 9 | 10 | const defaultDomain = "devd.io" 11 | 12 | func checkURL(s string) (isURL bool, err error) { 13 | var parsed *url.URL 14 | 15 | parsed, err = url.Parse(s) 16 | if err != nil { 17 | return 18 | } 19 | 20 | switch { 21 | case parsed.Scheme == "": // No scheme means local file system 22 | isURL = false 23 | case parsed.Scheme == "http", parsed.Scheme == "https": 24 | isURL = true 25 | case parsed.Scheme == "ws": 26 | err = fmt.Errorf("Websocket protocol not supported: %s", s) 27 | default: 28 | // A route of "localhost:1234/abc" without the "http" or "https" triggers this case. 29 | // Unfortunately a route of "localhost/abc" just looks like a file and is not caught here. 30 | err = fmt.Errorf("Unknown scheme '%s': did you mean http or https?: %s", parsed.Scheme, s) 31 | } 32 | return 33 | } 34 | 35 | // A RouteSpec is a parsed route specification 36 | type RouteSpec struct { 37 | Host string 38 | Path string 39 | Value string 40 | IsURL bool 41 | } 42 | 43 | // MuxMatch produces a match clause suitable for passing to a Mux 44 | func (rp *RouteSpec) MuxMatch() string { 45 | // Path is guaranteed to start with / 46 | return rp.Host + rp.Path 47 | } 48 | 49 | // ParseRouteSpec parses a string route specification 50 | func ParseRouteSpec(s string) (*RouteSpec, error) { 51 | seq := strings.SplitN(s, "=", 2) 52 | var path, value, host string 53 | if len(seq) == 1 { 54 | path = "/" 55 | value = seq[0] 56 | } else { 57 | path = seq[0] 58 | value = seq[1] 59 | } 60 | if path == "" || value == "" { 61 | return nil, errors.New("Invalid specification") 62 | } 63 | if path[0] != '/' { 64 | seq := strings.SplitN(path, "/", 2) 65 | host = seq[0] + "." + defaultDomain 66 | switch len(seq) { 67 | case 1: 68 | path = "/" 69 | case 2: 70 | path = "/" + seq[1] 71 | } 72 | } 73 | if value[0] == ':' { 74 | value = "http://localhost" + value 75 | } 76 | isURL, err := checkURL(value) 77 | if err != nil { 78 | return nil, err 79 | } 80 | return &RouteSpec{host, path, value, isURL}, nil 81 | } 82 | -------------------------------------------------------------------------------- /scripts/mkbrew: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python2 2 | import subprocess 3 | import sys 4 | import requests 5 | import hashlib 6 | 7 | TEMPLATE = """ 8 | require "language/go" 9 | 10 | class Devd < Formula 11 | desc "Local webserver for developers" 12 | homepage "https://github.com/cortesi/devd" 13 | url "https://github.com/cortesi/devd/archive/{version}.tar.gz" 14 | sha256 "{hash}" 15 | head "https://github.com/cortesi/devd.git" 16 | 17 | bottle do 18 | cellar :any_skip_relocation 19 | sha256 "3b7c357c44ec47b77d5ad89ff929b38447cb87b1b5698e0efa1d558cb22c7b26" => :el_capitan 20 | sha256 "3a91f99b6136a401cd5551d0ed2c06e100bb80e7a844478096fff9ee944934b3" => :yosemite 21 | sha256 "6e160b2d36c713c3dce3342f30c7ea2e81b6ec449719e01781c4ca5b21bf3e9e" => :mavericks 22 | end 23 | 24 | depends_on "go" => :build 25 | {resources} 26 | 27 | def install 28 | ENV["GOOS"] = "darwin" 29 | ENV["GOARCH"] = MacOS.prefer_64_bit? ? "amd64" : "386" 30 | ENV["GOPATH"] = buildpath 31 | (buildpath/"src/github.com/cortesi/devd").install buildpath.children 32 | Language::Go.stage_deps resources, buildpath/"src" 33 | cd "src/github.com/cortesi/devd" do 34 | system "go", "build", "-o", bin/"devd", ".../cmd/devd" 35 | prefix.install_metafiles 36 | end 37 | end 38 | 39 | test do 40 | begin 41 | io = IO.popen("#{{bin}}/devd #{{testpath}}") 42 | sleep 2 43 | ensure 44 | Process.kill("SIGINT", io.pid) 45 | Process.wait(io.pid) 46 | end 47 | 48 | assert_match "Listening on http://devd.io", io.read 49 | end 50 | end 51 | """ 52 | 53 | 54 | def main(version): 55 | url = "https://github.com/cortesi/devd/archive/%s.tar.gz"%version 56 | print >> sys.stderr, "Calculating hash from %s..."%url 57 | resp = requests.get(url) 58 | if resp.status_code != 200: 59 | print "ERROR" 60 | return 61 | 62 | hash = hashlib.sha256(resp.content).hexdigest() 63 | 64 | print >> sys.stderr, "Generating external resources" 65 | goresources = subprocess.check_output( 66 | ["homebrew-go-resources", "./cmd/devd"] 67 | ) 68 | print TEMPLATE.format( 69 | resources=goresources, 70 | version=version, 71 | hash=hash 72 | ) 73 | 74 | 75 | if __name__ == "__main__": 76 | if len(sys.argv) != 2: 77 | print >> sys.stderr, "Please specify version" 78 | sys.exit(1) 79 | main(sys.argv[1]) 80 | -------------------------------------------------------------------------------- /server.go: -------------------------------------------------------------------------------- 1 | package devd 2 | 3 | import ( 4 | "crypto/tls" 5 | "fmt" 6 | "html/template" 7 | "net" 8 | "net/http" 9 | "os" 10 | "os/signal" 11 | "regexp" 12 | "strings" 13 | "syscall" 14 | "time" 15 | 16 | "golang.org/x/net/context" 17 | 18 | rice "github.com/GeertJohan/go.rice" 19 | "github.com/goji/httpauth" 20 | 21 | "github.com/cortesi/devd/httpctx" 22 | "github.com/cortesi/devd/inject" 23 | "github.com/cortesi/devd/livereload" 24 | "github.com/cortesi/devd/ricetemp" 25 | "github.com/cortesi/devd/slowdown" 26 | "github.com/cortesi/devd/timer" 27 | "github.com/cortesi/termlog" 28 | ) 29 | 30 | const ( 31 | // Version is the current version of devd 32 | Version = "0.9" 33 | portLow = 8000 34 | portHigh = 10000 35 | ) 36 | 37 | func pickPort(addr string, low int, high int, tls bool) (net.Listener, error) { 38 | firstTry := 80 39 | if tls { 40 | firstTry = 443 41 | } 42 | hl, err := net.Listen("tcp", fmt.Sprintf("%v:%d", addr, firstTry)) 43 | if err == nil { 44 | return hl, nil 45 | } 46 | for i := low; i < high; i++ { 47 | hl, err := net.Listen("tcp", fmt.Sprintf("%v:%d", addr, i)) 48 | if err == nil { 49 | return hl, nil 50 | } 51 | } 52 | return nil, fmt.Errorf("Could not find open port.") 53 | } 54 | 55 | func getTLSConfig(path string) (t *tls.Config, err error) { 56 | config := &tls.Config{} 57 | if config.NextProtos == nil { 58 | config.NextProtos = []string{"http/1.1"} 59 | } 60 | config.Certificates = make([]tls.Certificate, 1) 61 | config.Certificates[0], err = tls.LoadX509KeyPair(path, path) 62 | if err != nil { 63 | return nil, err 64 | } 65 | return config, nil 66 | } 67 | 68 | // This filthy hack works in conjunction with hostPortStrip to restore the 69 | // original request host after mux match. 70 | func revertOriginalHost(r *http.Request) { 71 | original := r.Header.Get("_devd_original_host") 72 | if original != "" { 73 | r.Host = original 74 | r.Header.Del("_devd_original_host") 75 | } 76 | } 77 | 78 | // We can remove the mangling once this is fixed: 79 | // https://github.com/golang/go/issues/10463 80 | func hostPortStrip(next http.Handler) http.Handler { 81 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 82 | host, _, err := net.SplitHostPort(r.Host) 83 | if err == nil { 84 | original := r.Host 85 | r.Host = host 86 | r.Header.Set("_devd_original_host", original) 87 | } 88 | next.ServeHTTP(w, r) 89 | }) 90 | } 91 | 92 | func matchStringAny(regexps []*regexp.Regexp, s string) bool { 93 | for _, r := range regexps { 94 | if r.MatchString(s) { 95 | return true 96 | } 97 | } 98 | return false 99 | } 100 | 101 | func formatURL(tls bool, httpIP string, port int) string { 102 | proto := "http" 103 | if tls { 104 | proto = "https" 105 | } 106 | host := httpIP 107 | if httpIP == "0.0.0.0" || httpIP == "127.0.0.1" { 108 | host = "devd.io" 109 | } 110 | if port == 443 && tls { 111 | return fmt.Sprintf("https://%s", host) 112 | } 113 | if port == 80 && !tls { 114 | return fmt.Sprintf("http://%s", host) 115 | } 116 | return fmt.Sprintf("%s://%s:%d", proto, host, port) 117 | } 118 | 119 | // Credentials is a simple username/password pair 120 | type Credentials struct { 121 | username string 122 | password string 123 | } 124 | 125 | // CredentialsFromSpec creates a set of credentials from a spec 126 | func CredentialsFromSpec(spec string) (*Credentials, error) { 127 | parts := strings.SplitN(spec, ":", 2) 128 | if len(parts) != 2 || parts[0] == "" || parts[1] == "" { 129 | return nil, fmt.Errorf("Invalid credential spec: %s", spec) 130 | } 131 | return &Credentials{parts[0], parts[1]}, nil 132 | } 133 | 134 | // Devd represents the devd server options 135 | type Devd struct { 136 | Routes RouteCollection 137 | 138 | // Shaping 139 | Latency int 140 | DownKbps uint 141 | UpKbps uint 142 | ServingScheme string 143 | 144 | // Add headers 145 | AddHeaders *http.Header 146 | 147 | // Livereload and watch static routes 148 | LivereloadRoutes bool 149 | // Livereload, but don't watch static routes 150 | Livereload bool 151 | WatchPaths []string 152 | Excludes []string 153 | 154 | // Add Access-Control-Allow-Origin header 155 | Cors bool 156 | 157 | // Logging 158 | IgnoreLogs []*regexp.Regexp 159 | 160 | // Password protection 161 | Credentials *Credentials 162 | 163 | lrserver *livereload.Server 164 | } 165 | 166 | // WrapHandler wraps an httpctx.Handler in the paraphernalia needed by devd for 167 | // logging, latency, and so forth. 168 | func (dd *Devd) WrapHandler(log termlog.TermLog, next httpctx.Handler) http.Handler { 169 | h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 170 | r.URL.Scheme = dd.ServingScheme 171 | revertOriginalHost(r) 172 | timr := timer.Timer{} 173 | sublog := log.Group() 174 | defer func() { 175 | timing := termlog.DefaultPalette.Timestamp.SprintFunc()("timing: ") 176 | sublog.SayAs("timer", timing+timr.String()) 177 | sublog.Done() 178 | }() 179 | if matchStringAny(dd.IgnoreLogs, fmt.Sprintf("%s%s", r.URL.Host, r.RequestURI)) { 180 | sublog.Quiet() 181 | } 182 | timr.RequestHeaders() 183 | time.Sleep(time.Millisecond * time.Duration(dd.Latency)) 184 | 185 | dpath := r.RequestURI 186 | if !strings.HasPrefix(dpath, "/") { 187 | dpath = "/" + dpath 188 | } 189 | sublog.Say("%s %s", r.Method, dpath) 190 | LogHeader(sublog, r.Header) 191 | ctx := timr.NewContext(context.Background()) 192 | ctx = termlog.NewContext(ctx, sublog) 193 | if dd.AddHeaders != nil { 194 | for h, vals := range *dd.AddHeaders { 195 | for _, v := range vals { 196 | w.Header().Set(h, v) 197 | } 198 | } 199 | } 200 | if dd.Cors { 201 | origin := r.Header.Get("Origin") 202 | if origin == "" { 203 | origin = "*" 204 | } 205 | w.Header().Set("Access-Control-Allow-Origin", origin) 206 | requestHeaders := r.Header.Get("Access-Control-Request-Headers") 207 | if requestHeaders != "" { 208 | w.Header().Set("Access-Control-Allow-Headers", requestHeaders) 209 | } 210 | requestMethod := r.Header.Get("Access-Control-Request-Method") 211 | if requestMethod != "" { 212 | w.Header().Set("Access-Control-Allow-Methods", requestMethod) 213 | } 214 | } 215 | flusher, _ := w.(http.Flusher) 216 | next.ServeHTTPContext( 217 | ctx, 218 | &ResponseLogWriter{Log: sublog, Resp: w, Flusher: flusher, Timer: &timr}, 219 | r, 220 | ) 221 | }) 222 | return h 223 | } 224 | 225 | // HasLivereload tells us if livereload is enabled 226 | func (dd *Devd) HasLivereload() bool { 227 | if dd.Livereload || dd.LivereloadRoutes || len(dd.WatchPaths) > 0 { 228 | return true 229 | } 230 | return false 231 | } 232 | 233 | // AddRoutes adds route specifications to the server 234 | func (dd *Devd) AddRoutes(specs []string, notfound []string) error { 235 | dd.Routes = make(RouteCollection) 236 | for _, s := range specs { 237 | err := dd.Routes.Add(s, notfound) 238 | if err != nil { 239 | return fmt.Errorf("Invalid route specification: %s", err) 240 | } 241 | } 242 | return nil 243 | } 244 | 245 | // AddIgnores adds log ignore patterns to the server 246 | func (dd *Devd) AddIgnores(specs []string) error { 247 | dd.IgnoreLogs = make([]*regexp.Regexp, 0, 0) 248 | for _, expr := range specs { 249 | v, err := regexp.Compile(expr) 250 | if err != nil { 251 | return fmt.Errorf("%s", err) 252 | } 253 | dd.IgnoreLogs = append(dd.IgnoreLogs, v) 254 | } 255 | return nil 256 | } 257 | 258 | // HandleNotFound handles pages not found. In particular, this handler is used 259 | // when we have no matching route for a request. This also means it's not 260 | // useful to inject the livereload paraphernalia here. 261 | func HandleNotFound(templates *template.Template) httpctx.Handler { 262 | return httpctx.HandlerFunc(func(ctx context.Context, w http.ResponseWriter, r *http.Request) { 263 | w.WriteHeader(http.StatusNotFound) 264 | err := templates.Lookup("404.html").Execute(w, nil) 265 | if err != nil { 266 | logger := termlog.FromContext(ctx) 267 | logger.Shout("Could not execute template: %s", err) 268 | } 269 | }) 270 | } 271 | 272 | // Router constructs the main Devd router that serves all requests 273 | func (dd *Devd) Router(logger termlog.TermLog, templates *template.Template) (http.Handler, error) { 274 | mux := http.NewServeMux() 275 | hasGlobal := false 276 | 277 | ci := inject.CopyInject{} 278 | if dd.HasLivereload() { 279 | ci = livereload.Injector 280 | } 281 | 282 | for match, route := range dd.Routes { 283 | if match == "/" { 284 | hasGlobal = true 285 | } 286 | handler := dd.WrapHandler( 287 | logger, 288 | route.Endpoint.Handler(route.Path, templates, ci), 289 | ) 290 | mux.Handle(match, handler) 291 | } 292 | if dd.HasLivereload() { 293 | lr := livereload.NewServer("livereload", logger) 294 | mux.Handle(livereload.EndpointPath, lr) 295 | mux.Handle(livereload.ScriptPath, http.HandlerFunc(lr.ServeScript)) 296 | seen := make(map[string]bool) 297 | for _, route := range dd.Routes { 298 | if _, ok := seen[route.Host]; route.Host != "" && ok == false { 299 | mux.Handle(route.Host+livereload.EndpointPath, lr) 300 | mux.Handle( 301 | route.Host+livereload.ScriptPath, 302 | http.HandlerFunc(lr.ServeScript), 303 | ) 304 | seen[route.Host] = true 305 | } 306 | } 307 | if dd.LivereloadRoutes { 308 | err := WatchRoutes(dd.Routes, lr, dd.Excludes, logger) 309 | if err != nil { 310 | return nil, fmt.Errorf("Could not watch routes for livereload: %s", err) 311 | } 312 | } 313 | if len(dd.WatchPaths) > 0 { 314 | err := WatchPaths(dd.WatchPaths, dd.Excludes, lr, logger) 315 | if err != nil { 316 | return nil, fmt.Errorf("Could not watch path for livereload: %s", err) 317 | } 318 | } 319 | dd.lrserver = lr 320 | } 321 | if !hasGlobal { 322 | mux.Handle( 323 | "/", 324 | dd.WrapHandler(logger, HandleNotFound(templates)), 325 | ) 326 | } 327 | var h = http.Handler(mux) 328 | if dd.Credentials != nil { 329 | h = httpauth.SimpleBasicAuth( 330 | dd.Credentials.username, dd.Credentials.password, 331 | )(h) 332 | } 333 | return hostPortStrip(h), nil 334 | } 335 | 336 | // Serve starts the devd server. The callback is called with the serving URL 337 | // just before service starts. 338 | func (dd *Devd) Serve(address string, port int, certFile string, logger termlog.TermLog, callback func(string)) error { 339 | templates, err := ricetemp.MakeTemplates(rice.MustFindBox("templates")) 340 | if err != nil { 341 | return fmt.Errorf("Error loading templates: %s", err) 342 | } 343 | mux, err := dd.Router(logger, templates) 344 | if err != nil { 345 | return err 346 | } 347 | var tlsConfig *tls.Config 348 | var tlsEnabled bool 349 | if certFile != "" { 350 | tlsConfig, err = getTLSConfig(certFile) 351 | if err != nil { 352 | return fmt.Errorf("Could not load certs: %s", err) 353 | } 354 | tlsEnabled = true 355 | } 356 | 357 | var hl net.Listener 358 | if port > 0 { 359 | hl, err = net.Listen("tcp", fmt.Sprintf("%v:%d", address, port)) 360 | } else { 361 | hl, err = pickPort(address, portLow, portHigh, tlsEnabled) 362 | } 363 | if err != nil { 364 | return err 365 | } 366 | 367 | if tlsConfig != nil { 368 | hl = tls.NewListener(hl, tlsConfig) 369 | } 370 | 371 | hl = slowdown.NewSlowListener(hl, dd.UpKbps*1024, dd.DownKbps*1024) 372 | url := formatURL(tlsEnabled, address, hl.Addr().(*net.TCPAddr).Port) 373 | logger.Say("Listening on %s (%s)", url, hl.Addr().String()) 374 | server := &http.Server{Addr: hl.Addr().String(), Handler: mux} 375 | callback(url) 376 | 377 | if dd.HasLivereload() { 378 | c := make(chan os.Signal, 1) 379 | signal.Notify(c, syscall.SIGHUP) 380 | go func() { 381 | for { 382 | <-c 383 | logger.Say("Received signal - reloading") 384 | dd.lrserver.Reload([]string{"*"}) 385 | } 386 | }() 387 | } 388 | 389 | err = server.Serve(hl) 390 | logger.Shout("Server stopped: %v", err) 391 | return nil 392 | } 393 | -------------------------------------------------------------------------------- /server_test.go: -------------------------------------------------------------------------------- 1 | package devd 2 | 3 | import ( 4 | "reflect" 5 | "testing" 6 | 7 | "github.com/GeertJohan/go.rice" 8 | "github.com/cortesi/devd/inject" 9 | "github.com/cortesi/devd/ricetemp" 10 | "github.com/cortesi/termlog" 11 | ) 12 | 13 | var formatURLTests = []struct { 14 | tls bool 15 | addr string 16 | port int 17 | output string 18 | }{ 19 | {true, "127.0.0.1", 8000, "https://devd.io:8000"}, 20 | {false, "127.0.0.1", 8000, "http://devd.io:8000"}, 21 | {false, "127.0.0.1", 80, "http://devd.io"}, 22 | {true, "127.0.0.1", 443, "https://devd.io"}, 23 | {false, "127.0.0.1", 443, "http://devd.io:443"}, 24 | } 25 | 26 | func TestFormatURL(t *testing.T) { 27 | for i, tt := range formatURLTests { 28 | url := formatURL(tt.tls, tt.addr, tt.port) 29 | if url != tt.output { 30 | t.Errorf("Test %d, expected \"%s\" got \"%s\"", i, tt.output, url) 31 | } 32 | } 33 | } 34 | 35 | func TestPickPort(t *testing.T) { 36 | _, err := pickPort("127.0.0.1", 8000, 10000, true) 37 | if err != nil { 38 | t.Errorf("Could not bind to any port: %s", err) 39 | } 40 | _, err = pickPort("127.0.0.1", 8000, 8000, true) 41 | if err == nil { 42 | t.Errorf("Expected not to be able to bind to any port") 43 | } 44 | 45 | } 46 | 47 | func fsEndpoint(s string) *filesystemEndpoint { 48 | e, _ := newFilesystemEndpoint(s, []string{}) 49 | return e 50 | } 51 | 52 | func TestDevdRouteHandler(t *testing.T) { 53 | logger := termlog.NewLog() 54 | logger.Quiet() 55 | r := Route{"", "/", fsEndpoint("./testdata")} 56 | templates := ricetemp.MustMakeTemplates(rice.MustFindBox("templates")) 57 | ci := inject.CopyInject{} 58 | 59 | devd := Devd{LivereloadRoutes: true} 60 | h := devd.WrapHandler(logger, r.Endpoint.Handler("", templates, ci)) 61 | ht := handlerTester{t, h} 62 | 63 | AssertCode(t, ht.Request("GET", "/", nil), 200) 64 | } 65 | 66 | func TestDevdHandler(t *testing.T) { 67 | logger := termlog.NewLog() 68 | logger.Quiet() 69 | templates := ricetemp.MustMakeTemplates(rice.MustFindBox("templates")) 70 | 71 | devd := Devd{LivereloadRoutes: true, WatchPaths: []string{"./"}} 72 | err := devd.AddRoutes([]string{"./"}, []string{}) 73 | if err != nil { 74 | t.Error(err) 75 | } 76 | h, err := devd.Router(logger, templates) 77 | if err != nil { 78 | t.Error(err) 79 | } 80 | ht := handlerTester{t, h} 81 | 82 | AssertCode(t, ht.Request("GET", "/", nil), 200) 83 | AssertCode(t, ht.Request("GET", "/nonexistent", nil), 404) 84 | } 85 | 86 | func TestGetTLSConfig(t *testing.T) { 87 | _, err := getTLSConfig("nonexistent") 88 | if err == nil { 89 | t.Error("Expected failure, found success.") 90 | } 91 | _, err = getTLSConfig("./testdata/certbundle.pem") 92 | if err != nil { 93 | t.Errorf("Could not get TLS config: %s", err) 94 | } 95 | } 96 | 97 | var credentialsTests = []struct { 98 | spec string 99 | creds *Credentials 100 | }{ 101 | {"foo:bar", &Credentials{"foo", "bar"}}, 102 | {"foo:", nil}, 103 | {":bar", nil}, 104 | {"foo:bar:voing", &Credentials{"foo", "bar:voing"}}, 105 | {"foo", nil}, 106 | } 107 | 108 | func TestCredentials(t *testing.T) { 109 | for i, data := range credentialsTests { 110 | got, _ := CredentialsFromSpec(data.spec) 111 | if !reflect.DeepEqual(data.creds, got) { 112 | t.Errorf("%d: got %v, expected %v", i, got, data.creds) 113 | } 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /slowdown/slowdown.go: -------------------------------------------------------------------------------- 1 | // Package slowdown provides an implementation of net.Listener that limits 2 | // bandwidth. 3 | package slowdown 4 | 5 | import ( 6 | "io" 7 | "net" 8 | "time" 9 | 10 | "github.com/juju/ratelimit" 11 | ) 12 | 13 | // The maximum rate you should specify for readrate or writerate.If this is too 14 | // high, the token bucket implementation seems to break down. 15 | var MaxRate uint = (1024 * 1024) * 1000 16 | 17 | var blockSize = int64(1024) 18 | var capacity = int64(blockSize * 4) 19 | 20 | type slowReader struct { 21 | reader io.Reader 22 | bucket *ratelimit.Bucket 23 | } 24 | 25 | func (sr *slowReader) Read(b []byte) (n int, err error) { 26 | read := 0 27 | for read < len(b) { 28 | sr.bucket.Wait(blockSize) 29 | upper := int64(read) + blockSize 30 | if upper > int64(len(b)) { 31 | upper = int64(len(b)) 32 | } 33 | slice := b[read:upper] 34 | n, err := sr.reader.Read(slice) 35 | read += n 36 | if err != nil || n < len(slice) { 37 | return read, err 38 | } 39 | } 40 | return read, nil 41 | } 42 | 43 | type slowWriter struct { 44 | writer io.Writer 45 | bucket *ratelimit.Bucket 46 | } 47 | 48 | func (w *slowWriter) Write(b []byte) (n int, err error) { 49 | written := 0 50 | for written < len(b) { 51 | w.bucket.Wait(blockSize) 52 | 53 | upper := int64(written) + blockSize 54 | if upper > int64(len(b)) { 55 | upper = int64(len(b)) 56 | } 57 | n, err := w.writer.Write(b[written:upper]) 58 | written += n 59 | if err != nil { 60 | return written, err 61 | } 62 | } 63 | return written, nil 64 | } 65 | 66 | // SlowConn is a slow connection 67 | type SlowConn struct { 68 | conn net.Conn 69 | listener *SlowListener 70 | reader *slowReader 71 | writer *slowWriter 72 | } 73 | 74 | func newSlowConn(conn net.Conn, listener *SlowListener) *SlowConn { 75 | return &SlowConn{ 76 | conn, 77 | listener, 78 | &slowReader{conn, listener.readbucket}, 79 | &slowWriter{conn, listener.writebucket}, 80 | } 81 | } 82 | 83 | // Read reads data from the connection. 84 | // Read can be made to time out and return a Error with Timeout() == true 85 | // after a fixed time limit; see SetDeadline and SetReadDeadline. 86 | func (sc *SlowConn) Read(b []byte) (n int, err error) { 87 | return sc.reader.Read(b) 88 | } 89 | 90 | // Write writes data to the connection. 91 | // Write can be made to time out and return a Error with Timeout() == true 92 | // after a fixed time limit; see SetDeadline and SetWriteDeadline. 93 | func (sc *SlowConn) Write(b []byte) (n int, err error) { 94 | return sc.writer.Write(b) 95 | } 96 | 97 | // Close closes the connection. 98 | // Any blocked Read or Write operations will be unblocked and return errors. 99 | func (sc *SlowConn) Close() error { 100 | return sc.conn.Close() 101 | } 102 | 103 | // LocalAddr returns the local network address. 104 | func (sc *SlowConn) LocalAddr() net.Addr { 105 | return sc.conn.LocalAddr() 106 | } 107 | 108 | // RemoteAddr returns the remote network address. 109 | func (sc *SlowConn) RemoteAddr() net.Addr { 110 | return sc.conn.RemoteAddr() 111 | } 112 | 113 | // SetDeadline sets the read and write deadlines associated 114 | // with the connection. It is equivalent to calling both 115 | // SetReadDeadline and SetWriteDeadline. 116 | // 117 | // A deadline is an absolute time after which I/O operations 118 | // fail with a timeout (see type Error) instead of 119 | // blocking. The deadline applies to all future I/O, not just 120 | // the immediately following call to Read or Write. 121 | // 122 | // An idle timeout can be implemented by repeatedly extending 123 | // the deadline after successful Read or Write calls. 124 | // 125 | // A zero value for t means I/O operations will not time out. 126 | func (sc *SlowConn) SetDeadline(t time.Time) error { 127 | return sc.conn.SetDeadline(t) 128 | } 129 | 130 | // SetReadDeadline sets the deadline for future Read calls. 131 | // A zero value for t means Read will not time out. 132 | func (sc *SlowConn) SetReadDeadline(t time.Time) error { 133 | return sc.conn.SetReadDeadline(t) 134 | } 135 | 136 | // SetWriteDeadline sets the deadline for future Write calls. 137 | // Even if write times out, it may return n > 0, indicating that 138 | // some of the data was successfully written. 139 | // A zero value for t means Write will not time out. 140 | func (sc *SlowConn) SetWriteDeadline(t time.Time) error { 141 | return sc.conn.SetWriteDeadline(t) 142 | } 143 | 144 | // SlowListener is a listener that limits global IO over all connections 145 | type SlowListener struct { 146 | listener net.Listener 147 | readbucket *ratelimit.Bucket 148 | writebucket *ratelimit.Bucket 149 | } 150 | 151 | // NewSlowListener creates a SlowListener with specified read and write rates. 152 | // Both the readrate and the writerate are specified in bytes per second. A 153 | // value of 0 disables throttling. 154 | func NewSlowListener(listener net.Listener, readrate uint, writerate uint) net.Listener { 155 | if readrate == 0 { 156 | readrate = MaxRate 157 | } 158 | if writerate == 0 { 159 | writerate = MaxRate 160 | } 161 | return &SlowListener{ 162 | listener: listener, 163 | readbucket: ratelimit.NewBucketWithRate(float64(readrate), capacity), 164 | writebucket: ratelimit.NewBucketWithRate(float64(writerate), capacity), 165 | } 166 | } 167 | 168 | // Accept waits for and returns the next connection to the listener. 169 | func (l *SlowListener) Accept() (net.Conn, error) { 170 | conn, err := l.listener.Accept() 171 | if err != nil { 172 | return nil, err 173 | } 174 | return newSlowConn(conn, l), nil 175 | } 176 | 177 | // Close closes the listener. 178 | func (l *SlowListener) Close() error { 179 | return l.listener.Close() 180 | } 181 | 182 | // Addr returns the listener's network address. 183 | func (l *SlowListener) Addr() net.Addr { 184 | return l.listener.Addr() 185 | } 186 | -------------------------------------------------------------------------------- /slowdown/slowdown_test.go: -------------------------------------------------------------------------------- 1 | package slowdown 2 | 3 | import ( 4 | "bytes" 5 | "crypto/rand" 6 | "testing" 7 | 8 | "github.com/juju/ratelimit" 9 | ) 10 | 11 | func TestWriter(t *testing.T) { 12 | sizes := []int64{0, 1, capacity, blockSize, 4096, 99, 100} 13 | for _, size := range sizes { 14 | b := &bytes.Buffer{} 15 | sw := slowWriter{b, ratelimit.NewBucketWithRate(1024*1024, capacity)} 16 | 17 | data := make([]byte, size) 18 | _, err := rand.Read(data) 19 | if err != nil { 20 | t.Errorf("Could not read random data") 21 | } 22 | len, err := sw.Write(data) 23 | if err != nil { 24 | t.Errorf("Write error: %s", err) 25 | } 26 | if int64(len) != size { 27 | t.Errorf("Expected to write %d bytes, wrote %d", size, len) 28 | } 29 | 30 | if bytes.Equal(data, b.Bytes()) != true { 31 | t.Fail() 32 | } 33 | 34 | } 35 | } 36 | 37 | func TestReader(t *testing.T) { 38 | sizes := []int64{0, 1, capacity, blockSize, 4096, 99, 100} 39 | for _, size := range sizes { 40 | src := make([]byte, size) 41 | _, err := rand.Read(src) 42 | if err != nil { 43 | t.Errorf("Could not read random data") 44 | } 45 | sr := slowReader{ 46 | bytes.NewBuffer(src), 47 | ratelimit.NewBucketWithRate(1024*1024, capacity), 48 | } 49 | 50 | dst := make([]byte, size) 51 | len, err := sr.Read(dst) 52 | if err != nil { 53 | t.Errorf("Read error: %s", err) 54 | } 55 | if int64(len) != size { 56 | t.Errorf("Expected %d bytes, got %d", size, len) 57 | } 58 | 59 | if bytes.Equal(dst, src) != true { 60 | t.Fail() 61 | } 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /templates/404.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 15 | 16 | 17 |

404: Page not found

18 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /templates/dirlist.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 35 | 36 | 37 |

{{.Name}}

38 | 39 | {{ range .Files }} 40 | 41 | 44 | 45 | 46 | 47 | {{ else }} 48 | 49 | {{ end }} 50 |
42 | {{.Name}}{{ if .IsDir }}/{{ end }} 43 | {{ .Size | bytes }}{{ .ModTime | reltime }}
No files found.
51 | 54 | 55 | 56 | -------------------------------------------------------------------------------- /testdata/certbundle.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN RSA PRIVATE KEY----- 2 | MIICXAIBAAKBgQCyUlRJfct0FEmLl0x9FIwTj0IEtJx5jVno/YY6pwNClrxJxmPn 3 | OskHj/65sLMJSsNYlVeaPJESvVno/PGFkJf4Zf8a7sniYBonNPsqmPxXLnmcUbnc 4 | AXFrtu/uOmhH7cCy0RdBsB2cV/x8FjBPk3AlxqVxkhtqrPdtTOIlXgZ+YQIDAQAB 5 | AoGAQEpGcSiVTYhy64zk2sOprPOdTa0ALSK1I7cjycmk90D5KXAJXLho+f0ETVZT 6 | dioqO6m8J7NmamcyHznyqcDzyNRqD2hEBDGVRJWmpOjIER/JwWLNNbpeVjsMHV8I 7 | 40P5rZMOhBPYlwECSC5NtMwaN472fyGNNze8u37IZKiER/ECQQDe1iY5AG3CgkP3 8 | tEZB3Vtzcn4PoOr3Utyn1YER34lPqAmeAsWUhmAVEfR3N1HDe1VFD9s2BidhBn1a 9 | /Bgqxz4DAkEAzNw0m+uO0WkD7aEYRBW7SbXCX+3xsbVToIWC1jXFG+XDzSWn++c1 10 | DMXEElzEJxPDA+FzQUvRTml4P92bTAbGywJAS9H7wWtm7Ubbj33UZfbGdhqfz/uF 11 | 109naufXedhgZS0c0JnK1oV+Tc0FLEczV9swIUaK5O/lGDtYDcw3AN84NwJBAIw5 12 | /1jrOOtm8uVp6+5O4dBmthJsEZEPCZtLSG/Qhoe+EvUN3Zq0fL+tb7USAsKs6ERz 13 | wizj9PWzhDhTPMYhrVkCQGIponZHx6VqiFyLgYUH9+gDTjBhYyI+6yMTYzcRweyL 14 | 9Suc2NkS3X2Lp+wCjvVZdwGtStp6Vo8z02b3giIsAIY= 15 | -----END RSA PRIVATE KEY----- 16 | -----BEGIN CERTIFICATE----- 17 | MIICOzCCAaQCCQDC7f5GsEpo9jANBgkqhkiG9w0BAQUFADBiMQswCQYDVQQGEwJO 18 | WjEOMAwGA1UECBMFT3RhZ28xEDAOBgNVBAcTB0R1bmVkaW4xDzANBgNVBAoTBm5l 19 | dGxpYjEPMA0GA1UECxMGbmV0bGliMQ8wDQYDVQQDEwZuZXRsaWIwHhcNMTIwNjI0 20 | MjI0MTU0WhcNMjIwNjIyMjI0MTU0WjBiMQswCQYDVQQGEwJOWjEOMAwGA1UECBMF 21 | T3RhZ28xEDAOBgNVBAcTB0R1bmVkaW4xDzANBgNVBAoTBm5ldGxpYjEPMA0GA1UE 22 | CxMGbmV0bGliMQ8wDQYDVQQDEwZuZXRsaWIwgZ8wDQYJKoZIhvcNAQEBBQADgY0A 23 | MIGJAoGBALJSVEl9y3QUSYuXTH0UjBOPQgS0nHmNWej9hjqnA0KWvEnGY+c6yQeP 24 | /rmwswlKw1iVV5o8kRK9Wej88YWQl/hl/xruyeJgGic0+yqY/FcueZxRudwBcWu2 25 | 7+46aEftwLLRF0GwHZxX/HwWME+TcCXGpXGSG2qs921M4iVeBn5hAgMBAAEwDQYJ 26 | KoZIhvcNAQEFBQADgYEAODZCihEv2yr8zmmQZDrfqg2ChxAoOXWF5+W2F/0LAUBf 27 | 2bHP+K4XE6BJWmadX1xKngj7SWrhmmTDp1gBAvXURoDaScOkB1iOCOHoIyalscTR 28 | 0FvSHKqFF8fgSlfqS6eYaSbXU3zQolvwP+URzIVnGDqgQCWPtjMqLD3Kd5tuwos= 29 | -----END CERTIFICATE----- 30 | -------------------------------------------------------------------------------- /testdata/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 |

This is a test

7 | 8 | 9 | -------------------------------------------------------------------------------- /testdata/style.css: -------------------------------------------------------------------------------- 1 | 2 | h1 { 3 | color: red; 4 | } 5 | -------------------------------------------------------------------------------- /timer/timer.go: -------------------------------------------------------------------------------- 1 | // Package timer adds HTTP request and response timing information to a 2 | // context. 3 | package timer 4 | 5 | import ( 6 | "fmt" 7 | "time" 8 | 9 | "golang.org/x/net/context" 10 | ) 11 | 12 | // Timer collects request and response timing information 13 | type Timer struct { 14 | // When the request headers have been received - earliest timing point can get 15 | // right now. 16 | tsRequestHeaders int64 17 | // When the response headers have been received 18 | tsResponseHeaders int64 19 | // When the response is completely written 20 | tsResponseDone int64 21 | } 22 | 23 | func (t Timer) String() string { 24 | if t.tsRequestHeaders == 0 { 25 | return "timer" 26 | } 27 | return fmt.Sprintf( 28 | "%.2fms total, %.2fms to response headers, %.2fms sending response body", 29 | float64(t.tsResponseDone-t.tsRequestHeaders)/1000000.0, 30 | float64(t.tsResponseHeaders-t.tsRequestHeaders)/1000000.0, 31 | float64(t.tsResponseDone-t.tsResponseHeaders)/1000000.0, 32 | ) 33 | } 34 | 35 | // RequestHeaders sets the time at which request headers were received 36 | func (t *Timer) RequestHeaders() { 37 | t.tsRequestHeaders = time.Now().UnixNano() 38 | } 39 | 40 | // ResponseHeaders sets the time at which request headers were received 41 | func (t *Timer) ResponseHeaders() { 42 | t.tsResponseHeaders = time.Now().UnixNano() 43 | } 44 | 45 | // ResponseDone sets the time at which request headers were received 46 | func (t *Timer) ResponseDone() { 47 | t.tsResponseDone = time.Now().UnixNano() 48 | } 49 | 50 | // NewContext creates a new context with the timer included 51 | func (t *Timer) NewContext(ctx context.Context) context.Context { 52 | return context.WithValue(ctx, "timer", t) 53 | } 54 | 55 | // FromContext creates a new context with the timer included 56 | func FromContext(ctx context.Context) *Timer { 57 | timer, ok := ctx.Value("timer").(*Timer) 58 | if !ok { 59 | // Return a dummy timer 60 | return &Timer{} 61 | } 62 | return timer 63 | } 64 | -------------------------------------------------------------------------------- /watch.go: -------------------------------------------------------------------------------- 1 | package devd 2 | 3 | import ( 4 | "os" 5 | "time" 6 | 7 | "github.com/cortesi/devd/livereload" 8 | "github.com/cortesi/moddwatch" 9 | "github.com/cortesi/termlog" 10 | ) 11 | 12 | const batchTime = time.Millisecond * 200 13 | 14 | // Watch watches an endpoint for changes, if it supports them. 15 | func (r Route) Watch( 16 | ch chan []string, 17 | excludePatterns []string, 18 | log termlog.Logger, 19 | ) (*moddwatch.Watcher, error) { 20 | wd, err := os.Getwd() 21 | if err != nil { 22 | return nil, err 23 | } 24 | var watcher *moddwatch.Watcher 25 | switch r.Endpoint.(type) { 26 | case *filesystemEndpoint: 27 | ep := *r.Endpoint.(*filesystemEndpoint) 28 | modchan := make(chan *moddwatch.Mod, 1) 29 | watcher, err = moddwatch.Watch( 30 | wd, 31 | []string{ep.Root + "/...", "**"}, 32 | excludePatterns, 33 | batchTime, 34 | modchan, 35 | ) 36 | if err != nil { 37 | return nil, err 38 | } 39 | go func() { 40 | for mod := range modchan { 41 | if !mod.Empty() { 42 | ch <- mod.All() 43 | } 44 | } 45 | }() 46 | } 47 | return watcher, nil 48 | } 49 | 50 | // WatchPaths watches a set of paths, and broadcasts changes through reloader. 51 | func WatchPaths(paths, excludePatterns []string, reloader livereload.Reloader, log termlog.Logger) error { 52 | wd, err := os.Getwd() 53 | if err != nil { 54 | return err 55 | } 56 | ch := make(chan []string, 1) 57 | for _, path := range paths { 58 | modchan := make(chan *moddwatch.Mod, 1) 59 | _, err := moddwatch.Watch( 60 | wd, 61 | []string{path}, 62 | excludePatterns, 63 | batchTime, 64 | modchan, 65 | ) 66 | if err != nil { 67 | return err 68 | } 69 | go func() { 70 | for mod := range modchan { 71 | if !mod.Empty() { 72 | ch <- mod.All() 73 | } 74 | } 75 | }() 76 | } 77 | go reloader.Watch(ch) 78 | return nil 79 | } 80 | 81 | // WatchRoutes watches the route collection, and broadcasts changes through reloader. 82 | func WatchRoutes(routes RouteCollection, reloader livereload.Reloader, excludePatterns []string, log termlog.Logger) error { 83 | c := make(chan []string, 1) 84 | for i := range routes { 85 | _, err := routes[i].Watch(c, excludePatterns, log) 86 | if err != nil { 87 | return err 88 | } 89 | } 90 | go reloader.Watch(c) 91 | return nil 92 | } 93 | -------------------------------------------------------------------------------- /watch_test.go: -------------------------------------------------------------------------------- 1 | package devd 2 | 3 | import ( 4 | "fmt" 5 | "io/ioutil" 6 | "os" 7 | "sync" 8 | "testing" 9 | "time" 10 | 11 | "github.com/cortesi/moddwatch" 12 | "github.com/cortesi/termlog" 13 | ) 14 | 15 | func addTempFile(t *testing.T, tmpFolder string, fname string, content string) { 16 | if err := ioutil.WriteFile(tmpFolder+"/"+fname, []byte(content), 0644); err != nil { 17 | t.Error(err) 18 | } 19 | } 20 | 21 | func TestRouteWatch(t *testing.T) { 22 | logger := termlog.NewLog() 23 | logger.Quiet() 24 | 25 | tmpFolder, err := ioutil.TempDir("", "") 26 | if err != nil { 27 | t.Error(err) 28 | } 29 | defer os.RemoveAll(tmpFolder) 30 | 31 | // Ensure that using . for the path works: 32 | os.Chdir(tmpFolder) 33 | routes := make(RouteCollection) 34 | routes.Add(".", nil) 35 | 36 | changedFiles := make(map[string]int) 37 | ch := make(chan []string, 1024) 38 | 39 | var exited sync.WaitGroup 40 | exited.Add(1) 41 | var lck sync.Mutex 42 | go func() { 43 | for { 44 | data, more := <-ch 45 | if more { 46 | for i := range data { 47 | lck.Lock() 48 | fmt.Println(data) 49 | if _, ok := changedFiles[data[i]]; !ok { 50 | changedFiles[data[i]] = 1 51 | } 52 | lck.Unlock() 53 | } 54 | } else { 55 | exited.Done() 56 | return 57 | } 58 | } 59 | }() 60 | watchers := make([]*moddwatch.Watcher, len(routes)) 61 | i := 0 62 | for r := range routes { 63 | watcher, err := routes[r].Watch(ch, nil, logger) 64 | watchers[i] = watcher 65 | if err != nil { 66 | t.Error(err) 67 | } 68 | i++ 69 | } 70 | 71 | addTempFile(t, tmpFolder, "a.txt", "foo\n") 72 | addTempFile(t, tmpFolder, "c.txt", "bar\n") 73 | addTempFile(t, tmpFolder, "another.file.txt", "bar\n") 74 | 75 | for i := 0; i < 100; i++ { 76 | lck.Lock() 77 | if len(changedFiles) >= 3 { 78 | lck.Unlock() 79 | break 80 | } 81 | lck.Unlock() 82 | time.Sleep(50 * time.Millisecond) 83 | } 84 | 85 | for _, v := range watchers { 86 | v.Stop() 87 | } 88 | close(ch) 89 | 90 | exited.Wait() 91 | 92 | if len(changedFiles) != 3 { 93 | t.Errorf("wanted 3 changed files, got %d", len(changedFiles)) 94 | } 95 | } 96 | --------------------------------------------------------------------------------