Source

devsrvr / main.go

Full commit
package main

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

const (
	processName = "devsrvr-slave.exe"
)

var addr = flag.String("addr", ":8000", "devsrvr address")
var startup = flag.Duration("startup", time.Second*1, "time to wait for app to start before checking for errors")

type Target struct {
	cmd     *exec.Cmd
	outbuf  bytes.Buffer
	addr    string
	approot string
	modtime time.Time
}

var target Target
var proxy *httputil.ReverseProxy

func usage() {
	fmt.Fprintf(os.Stderr, `usage: devsrvr [-addr :8000] approot

devsrvr proxies all requests for the given address to the
target web app named by approot.  It recompiles
and restarts the target process as needed.

The target web app must also have a -addr argument that
specifies the address to listen on.

Additional options:
`)
	flag.PrintDefaults()
}

func main() {

	flag.StringVar(&target.addr, "targetaddr", ":8888", "target web app address")
	flag.Parse()
	args := flag.Args()

	if len(args) != 1 {
		usage()
		return
	}

	target.approot = args[0]

	proxy = httputil.NewSingleHostReverseProxy(
		&url.URL{
			Scheme: "http",
			Host:   target.addr,
			Path:   "/",
		},
	)

	announce()
	log.Fatal(http.ListenAndServe(*addr, http.HandlerFunc(serve)))
}

func announce() {
	abspath, err := filepath.Abs(target.approot)
	if err != nil {
		log.Fatal(err)
	}
	log.Println("devsrvr, serving", abspath, "on", *addr)
}

// main entry point.
// if any files have changed, recompile
// and restart the app before proxying the request
func serve(w http.ResponseWriter, req *http.Request) {

	if target.IsStale() {
		if output, err := target.Build(); err != nil {
			w.Write([]byte("so sorry, there appear to be some errors:\n\n"))
			w.Write(output)
			// TODO: a nicer template here
			return
		}
	}

	target.EnsureRunning()
	if !target.checkForErrors(w) {
		proxy.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()
}

func (t *Target) ProcessName() string {
	return filepath.Join(t.approot, processName)
}

// build the target via `go build`
func (t *Target) Build() ([]byte, error) {
	gobuild := exec.Command("go", "build", "-o", t.ProcessName())
	gobuild.Dir = t.approot
	return gobuild.CombinedOutput()
}

func (t *Target) EditsDetected() bool {
	currentModTime := t.modtime
	filepath.Walk(t.approot, func(path string, info os.FileInfo, err error) error {

		if err != nil {
			log.Fatal(err)
		}

		// search for everything except directories,
		// the process executable we've created,
		// and any . files or dirs.
		// this allows us to rebuild even when there are changes to files
		// that `go build` wouldn't detect as deps, such as templates
		name := info.Name()
		if info.IsDir() {
			if strings.HasPrefix(name, ".") {
				return filepath.SkipDir
			} else {
				return nil
			}
		}

		thistime := info.ModTime()
		if thistime.After(currentModTime) &&
			!strings.HasPrefix(name, ".") &&
			name != processName {
			currentModTime = thistime
		}

		return nil
	})

	if currentModTime.After(t.modtime) {
		t.modtime = currentModTime
		return true
	}

	return false
}

// determine whether the target app needs to be built.
func (t *Target) IsStale() bool {

	// if we've just started up, inherit the modtime from the binary
	// produced by a previous run if possible
	if t.modtime.IsZero() {
		if fi, err := os.Stat(t.ProcessName()); err == nil {
			t.modtime = fi.ModTime()
		}
	}

	// check last modified time for both the binary and all its sources
	if t.EditsDetected() {
		log.Println("modifications detected - recompiling!")
		if t.cmd != nil {
			t.cmd.Process.Kill()
			t.cmd.Wait()
			t.cmd = nil
		}
		return true
	}

	return false
}

func (t *Target) EnsureRunning() {

	// already running?
	if t.cmd != nil {
		return
	}

	t.cmd = exec.Command(t.ProcessName(), "-addr", t.addr)
	t.cmd.Dir = t.approot
	t.cmd.Stdout = io.MultiWriter(os.Stdout, &t.outbuf)
	t.cmd.Stderr = io.MultiWriter(os.Stderr, &t.outbuf)
	if err := t.cmd.Start(); err != nil {
		log.Fatal("couldn't start app:", err)
	}

	time.Sleep(*startup)
}

func (t *Target) checkForErrors(errDest io.Writer) bool {

	// still alive?
	pid := t.cmd.Process.Pid
	proc, err := os.FindProcess(pid)
	if err != nil || proc.Pid != pid {
		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(t.outbuf.Bytes())
		return true
	}

	t.outbuf.Reset()
	return false
}