Source

devsrvr / src / srvr.go

package main

import (
	"bytes"
	"flag"
	"io"
	"log"
	"net/http"
	"net/http/httputil"
	"net/url"
	"os"
	"os/exec"
	"path/filepath"
	"time"
)

type Reloader struct {
	reverseProxy *httputil.ReverseProxy
	config       *Config
	appRoot      string
	appCmd       *exec.Cmd
	appOutBuf    bytes.Buffer
}

func main() {
	var err error
	var approot string
	flag.StringVar(&approot, "approot", "", "your app root's directory path")
	flag.Parse()

	// if not specified, assume the current working directory
	if approot == "" {
		if approot, err = os.Getwd(); err != nil {
			log.Fatal("Getwd: ", err.Error())
		}
	}
	conf := LoadConfig(approot)

	// fire up the app under development
	proxy := httputil.NewSingleHostReverseProxy(
		&url.URL{
			Scheme: "http",
			Host:   conf.AppAddress,
			Path:   "/",
		},
	)
	reloader := &Reloader{
		config:       conf,
		reverseProxy: proxy,
		appRoot:      approot,
	}

	// and start proxying requests to it
	http.Handle("/", reloader)
	log.Println("serving", conf.DevServerAddress)
	if err := http.ListenAndServe(conf.DevServerAddress, nil); err != nil {
		log.Fatal("ListenAndServe: ", err.Error())
	}
}

// main entry point.
// if any files have changed, recompile
// and restart the app before proxying the request
func (r *Reloader) ServeHTTP(w http.ResponseWriter, req *http.Request) {
	appPath := filepath.Join(r.appRoot, r.config.App)
	if r.targetMustBeBuilt(appPath) {
		if output, err := r.buildWithGoMake(r.appRoot); err != nil {
			w.Write([]byte("so sorry, there appear to be some errors:\n\n"))
			w.Write(output)
			// TODO: a nicer template here
			return
		}
	}
	r.ensureTargetIsRunning(appPath)
	if !r.checkForTargetErrors(w) {
		r.reverseProxy.ServeHTTP(w, req)
	}

	// TODO - figure out how to write errors back to browser
	// after app fails while serving request. currently, fails because the content-length
	// header gets set somewhere, and we cann't write any more bytes to the connection.
	// r.checkForTargetErrors(w)
	// r.appOutBuf.Reset()
}

// build a project
// right now, we only support calling gomake
// but we could potentially call out to the compiler or some other build tool
// in the future if that seems useful
func (r *Reloader) buildWithGoMake(projectDir string) ([]byte, error) {
	gomake := exec.Command("gomake")
	gomake.Dir = projectDir
	return gomake.CombinedOutput()
}

// determine whether the target app needs to be built.
// if it doesn't exist, it must be built
// if it already exists, check last modified timestamps on source files,
// and if they're newer, it must be built
func (r *Reloader) targetMustBeBuilt(appPath string) bool {
	appFileInfo, err := os.Stat(appPath)
	if err != nil {
		// assume it doesn't exist - TODO: determine appropriate error type to check against...
		return true
	}
	// check last modified time for both the binary and all its sources
	if SourceModifiedMoreRecentlyThan(r.appRoot, appFileInfo.ModTime()) {
		println("modifications detected - recompiling!")
		if r.appCmd != nil {
			r.appCmd.Process.Kill()
			r.appCmd.Wait()
			r.appCmd = nil
		}
		return true
	}
	return false
}

// start up the target app, and keep a pointer to it on the Reloader
func (r *Reloader) ensureTargetIsRunning(appPath string) {
	if r.appCmd != nil {
		return
	}
	r.appCmd = exec.Command(appPath)
	r.appCmd.Dir = r.appRoot
	r.appCmd.Stdout = io.MultiWriter(os.Stdout, &r.appOutBuf)
	r.appCmd.Stderr = io.MultiWriter(os.Stderr, &r.appOutBuf)
	if err := r.appCmd.Start(); err != nil {
		log.Fatal("couldn't start app:", err.Error())
	}
	time.Sleep(r.config.StartupTime) // give it a moment to start up...surely there's a better way?
}

func (r *Reloader) checkForTargetErrors(errDest io.Writer) bool {
	// check for error
	waitmsg, err := r.appCmd.Process.Wait(os.WNOHANG)
	// TODO - on darwin, even if WaitStatus is 256, Stopped() returns false...
	// just check directly for now, but it's not nice
	if err != nil || waitmsg.WaitStatus != 0 { //}.Stopped()
		errDest.Write([]byte("so sorry, not running anymore:\n\n"))
		// NOTE: write outbuf bytes, but leave them in the buffer so we still
		// keep the same error message in case we see subsequent reloads before
		// the error is resolved
		errDest.Write(r.appOutBuf.Bytes())
		return true
	}
	r.appOutBuf.Reset()
	return false
}