Skip to content

Commit

Permalink
Clean up sse handler; add hotreload example
Browse files Browse the repository at this point in the history
  • Loading branch information
infogulch committed Oct 20, 2023
1 parent bd5bd3e commit 333e8d1
Show file tree
Hide file tree
Showing 4 changed files with 78 additions and 54 deletions.
10 changes: 7 additions & 3 deletions TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,12 @@

### Features

- [ ] Client side auto reload
- [ ] Build a way to send live updates to a page by rendering a template to an SSE stream. Maybe backed by NATS.io?

### Documentation

- [ ] Memes
- [ ] Highlight file server feature
- [ ] Highlight sse feature
- [ ] Organize docs according to https://diataxis.fr/
- [ ] Add explanation
- [ ] Document configuration
Expand All @@ -29,7 +28,10 @@
# BACKLOG

- [ ] Switch to using Go 1.22's new servemux
- [ ] Investigate integrating into another web framework (gox/gin etc)
- [ ] Add PathValue method to .Req (future proofing)
- Support SSE
- [ ] Integrate nats subscription
- [ ] Split caddy integration into a separate repo. Trying to shoehorn two modules into one repo just isn't working.


# DONE
Expand All @@ -52,3 +54,5 @@
- [x] Refactor router to return `http.Handler`, use custom handler for static files
- [x] Allow .ServeFile to serve files from contextfs
- [x] Switch to functional options pattern for configuration
- [x] Support SSE
- [x] Demo client side hot reload
6 changes: 6 additions & 0 deletions integration/templates/hotreload.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
<!DOCTYPE html>
<script src="https://unpkg.com/[email protected]/dist/htmx.js"></script>
<script src="https://unpkg.com/[email protected]/dist/ext/sse.js"></script>

<div hx-ext="sse" sse-connect="/reload" sse-swap="message">Waiting...</div>
{{- define "SSE /reload"}}{{.Block}}data: <div hx-on::load="location.reload()"></div>{{printf "\n\n"}}{{end}}
76 changes: 39 additions & 37 deletions serve.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,25 +20,25 @@ import (
"golang.org/x/exp/maps"
)

func (x *xtemplate) ServeHTTP(w http.ResponseWriter, r *http.Request) {
func (server *xtemplate) ServeHTTP(w http.ResponseWriter, r *http.Request) {
select {
case _, _ = <-x.ctx.Done():
x.log.Error("received request after xtemplate instance cancelled", slog.String("method", r.Method), slog.String("path", r.URL.Path))
case _, _ = <-server.ctx.Done():
server.log.Error("received request after xtemplate instance cancelled", slog.String("method", r.Method), slog.String("path", r.URL.Path))
http.Error(w, "server stopped", http.StatusInternalServerError)
return
default:
}

start := time.Now()

_, handler, params, _ := x.router.Find(r.Method, r.URL.Path)
_, handler, params, _ := server.router.Find(r.Method, r.URL.Path)
if handler == nil {
x.log.Debug("no handler for request", slog.String("method", r.Method), slog.String("path", r.URL.Path))
server.log.Debug("no handler for request", slog.String("method", r.Method), slog.String("path", r.URL.Path))
http.NotFound(w, r)
return
}

log := x.log.With(slog.Group("serving",
log := server.log.With(slog.Group("serving",
slog.String("requestid", getRequestId(r.Context())),
slog.String("method", r.Method),
slog.String("path", r.URL.Path),
Expand All @@ -49,7 +49,7 @@ func (x *xtemplate) ServeHTTP(w http.ResponseWriter, r *http.Request) {
slog.String("user-agent", r.Header.Get("User-Agent")),
)

ctx := context.WithValue(r.Context(), ctxKey{}, ctxValue{params: params, log: log, runtime: x})
ctx := context.WithValue(r.Context(), ctxKey{}, ctxValue{params, log, server})
handler.ServeHTTP(w, r.WithContext(ctx))

log.Debug("request served", slog.Duration("response-duration", time.Since(start)))
Expand All @@ -72,19 +72,19 @@ func getRequestId(ctx context.Context) string {
type ctxKey struct{}

type ctxValue struct {
params pathmatcher.Params
log *slog.Logger
runtime *xtemplate
params pathmatcher.Params
log *slog.Logger
server *xtemplate
}

func getContext(ctx context.Context) (pathmatcher.Params, *slog.Logger, *xtemplate) {
val := ctx.Value(ctxKey{}).(ctxValue)
return val.params, val.log, val.runtime
return val.params, val.log, val.server
}

func serveTemplateHandler(tmpl *template.Template) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
params, log, runtime := getContext(r.Context())
params, log, server := getContext(r.Context())

buf := bufPool.Get().(*bytes.Buffer)
buf.Reset()
Expand All @@ -96,18 +96,18 @@ func serveTemplateHandler(tmpl *template.Template) http.Handler {
requestContext
responseContext
}{
baseContext: baseContext{
baseContext{
log: log,
server: runtime,
server: server,
},
fsContext: fsContext{
fs: runtime.contextFS,
fsContext{
fs: server.contextFS,
},
requestContext: requestContext{
requestContext{
Req: r,
Params: params,
},
responseContext: responseContext{
responseContext{
status: 200,
Header: http.Header{},
},
Expand Down Expand Up @@ -139,46 +139,48 @@ func serveTemplateHandler(tmpl *template.Template) http.Handler {
})
}

type ResponseFlusher interface {
http.ResponseWriter
http.Flusher
}

func sseTemplateHandler(tmpl *template.Template) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
params, log, runtime := getContext(r.Context())
params, log, server := getContext(r.Context())

w.Header().Set("Content-Type", "text/event-stream")
if r.Header.Get("Accept") != "text/event-stream" {
http.Error(w, "SSE endpoint", http.StatusNotAcceptable)
return
}

flush, ok := w.(ResponseFlusher)
flusher, ok := w.(http.Flusher)
if !ok {
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}

w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")

context := &struct {
flushContext
fsContext
requestContext
}{
flushContext: flushContext{
Flusher: flush,
baseContext: baseContext{
flushContext{
flusher,
baseContext{
log: log,
server: runtime,
server: server,
requestCtx: r.Context(),
},
},
fsContext: fsContext{
fs: runtime.contextFS,
fsContext{
fs: server.contextFS,
},
requestContext: requestContext{
requestContext{
Req: r,
Params: params,
},
}

err := tmpl.Execute(flush, context)
err := tmpl.Execute(w, context)

var handlerErr HandlerError
if errors.As(err, &handlerErr) {
Expand All @@ -201,10 +203,10 @@ func sseTemplateHandler(tmpl *template.Template) http.Handler {
}

var serveFileHandler http.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, log, runtime := getContext(r.Context())
_, log, server := getContext(r.Context())

urlpath := path.Clean(r.URL.Path)
fileinfo, ok := runtime.files[urlpath]
fileinfo, ok := server.files[urlpath]
if !ok {
// should not happen; we only add handlers for existent files
log.Warn("tried to serve a file that doesn't exist", slog.String("path", urlpath), slog.String("urlpath", r.URL.Path))
Expand All @@ -231,7 +233,7 @@ var serveFileHandler http.Handler = http.HandlerFunc(func(w http.ResponseWriter,
}

log.Debug("serving file request", slog.String("path", urlpath), slog.String("encoding", encoding.encoding), slog.String("contenttype", fileinfo.contentType))
file, err := runtime.templateFS.Open(encoding.path)
file, err := server.templateFS.Open(encoding.path)
if err != nil {
log.Error("failed to open file", slog.Any("error", err), slog.String("encoding.path", encoding.path), slog.String("requestpath", r.URL.Path))
http.Error(w, "internal server error", 500)
Expand Down
40 changes: 26 additions & 14 deletions tplcontext.go
Original file line number Diff line number Diff line change
Expand Up @@ -369,26 +369,15 @@ func (h responseContext) SetStatus(status int) string {
}

type flushContext struct {
http.Flusher
flusher http.Flusher
baseContext
}

func (f flushContext) Flush() string {
f.Flusher.Flush()
f.flusher.Flush()
return ""
}

func (f flushContext) Sleep(ms int) (string, error) {
select {
case <-time.After(time.Duration(ms) * time.Millisecond):
case <-f.requestCtx.Done():
return "", ReturnError{}
case <-f.server.ctx.Done():
return "", ReturnError{}
}
return "", nil
}

func (f flushContext) Repeat(max_ ...int) <-chan int {
max := math.MaxInt64 // sorry you can only loop for 2^63-1 iterations max
if len(max_) > 0 {
Expand All @@ -400,11 +389,11 @@ func (f flushContext) Repeat(max_ ...int) <-chan int {
loop:
for {
select {
case c <- i:
case <-f.requestCtx.Done():
break loop
case <-f.server.ctx.Done():
break loop
case c <- i:
}
if i >= max {
break
Expand All @@ -416,6 +405,29 @@ func (f flushContext) Repeat(max_ ...int) <-chan int {
return c
}

// Sleep sleeps for ms millisecionds.
func (f flushContext) Sleep(ms int) (string, error) {
select {
case <-time.After(time.Duration(ms) * time.Millisecond):
case <-f.requestCtx.Done():
return "", ReturnError{}
case <-f.server.ctx.Done():
return "", ReturnError{}
}
return "", nil
}

// Block blocks execution until the request is canceled by the client or until
// the server closes.
func (f flushContext) Block() (string, error) {
select {
case <-f.requestCtx.Done():
return "", ReturnError{}
case <-f.server.ctx.Done():
return "", nil
}
}

var bufPool = sync.Pool{
New: func() any {
return new(bytes.Buffer)
Expand Down

0 comments on commit 333e8d1

Please sign in to comment.