Files
kjol/go/wasmdevserver/devserver.go

373 lines
12 KiB
Go

// Package wasmdevserver is a reusable development server for gowasm apps: it serves
// the built web assets, renders routes server-side (SSR) at request time, hosts
// the /rsc server-component endpoint, and hot-swaps the freshly built wasm into
// the browser on change (no full reload, state preserved) — surfacing build
// failures as an in-page overlay.
//
// It never imports application code (per kjol's golden rule). The app injects
// everything specific to it through Config: how to build the wasm bundle
// (Build), how to render a route to HTML (Render), and how to wrap that HTML in
// a document (Document). The WebSocket hub and file watcher use only the
// standard library.
package wasmdevserver
import (
"crypto/sha1"
"encoding/base64"
"encoding/json"
"io"
"io/fs"
"log"
"net"
"net/http"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"time"
"kjol/rsc"
)
// Config wires an app into the dev server. Render and Document are called per
// request; Build is called for the initial build and on every source change.
type Config struct {
Addr string // listen address (default ":8085")
Dir string // static assets dir; Build writes app.wasm here (default "./wwwroot")
Watch bool // rebuild on change + hot reload
WatchDirs []string // source dirs to watch when Watch is set
Build func() ([]byte, error) // (re)build the wasm bundle; combined output on failure
Render func(path string) (inner string, ok bool) // SSR the #app inner HTML for a route (ok=false => client-rendered)
Document func(inner string) string // wrap #app inner HTML in a full HTML document
}
// Serve builds once (in watch mode), wires the routes, and blocks serving.
func Serve(cfg Config) error {
if cfg.Addr == "" {
cfg.Addr = ":8085"
}
if cfg.Dir == "" {
cfg.Dir = "./wwwroot"
}
mux := http.NewServeMux()
h := newHub()
if cfg.Watch {
if err := ensureShim(cfg.Dir); err != nil {
log.Printf("warning: could not stage wasm_exec.js: %v", err)
}
if cfg.Build != nil {
if out, err := cfg.Build(); err != nil {
log.Printf("initial build failed: %v\n%s", err, out)
h.setError(string(out)) // a browser opened now sees it via the overlay
}
}
mux.HandleFunc("/livereload", h.serveWS)
mux.HandleFunc("/livereload.js", serveClientJS)
go watchLoop(cfg, h)
log.Printf("hot reload enabled (watching %v)", cfg.WatchDirs)
}
mux.HandleFunc("POST /rsc", rsc.Handler) // server components
mux.HandleFunc("/", rootHandler(cfg))
log.Printf("serving %q on http://localhost%s", cfg.Dir, cfg.Addr)
return http.ListenAndServe(cfg.Addr, mux)
}
// ---- serving: assets, and dynamic SSR for HTML routes -------------------
func rootHandler(cfg Config) http.HandlerFunc {
live := ""
if cfg.Watch {
live = `<script src="/livereload.js"></script>`
}
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-store")
clean := filepath.Clean("/" + r.URL.Path)
fsPath := filepath.Join(cfg.Dir, clean)
if info, err := os.Stat(fsPath); err == nil && !info.IsDir() && clean != "/" {
if strings.HasSuffix(fsPath, ".wasm") {
w.Header().Set("Content-Type", "application/wasm")
}
http.ServeFile(w, r, fsPath)
return
}
inner := ""
if cfg.Render != nil {
inner, _ = cfg.Render(r.URL.Path)
}
html := inner
if cfg.Document != nil {
html = cfg.Document(inner)
}
if live != "" {
html = strings.Replace(html, "</body>", live+"\n</body>", 1)
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
io.WriteString(w, html)
}
}
// ---- build + watch ------------------------------------------------------
func watchLoop(cfg Config, h *hub) {
prev := fingerprint(cfg.WatchDirs)
for {
time.Sleep(300 * time.Millisecond)
fp := fingerprint(cfg.WatchDirs)
if fp == prev {
continue
}
prev = fp
log.Println("change detected, rebuilding…")
h.broadcast(`{"type":"building"}`)
if cfg.Build == nil {
continue
}
if out, err := cfg.Build(); err != nil {
log.Printf("build failed: %v\n%s", err, out)
h.setError(string(out)) // push the compiler output to the browser overlay
continue
}
log.Println("rebuild ok — reloading clients")
h.clearError()
h.broadcast(`{"type":"reload"}`)
}
}
// fingerprint changes whenever any .go file under dirs is modified.
func fingerprint(dirs []string) int64 {
var fp int64
for _, d := range dirs {
filepath.WalkDir(d, func(path string, e fs.DirEntry, err error) error {
if err != nil || e.IsDir() || !strings.HasSuffix(path, ".go") {
return nil
}
if info, err := e.Info(); err == nil {
fp += info.ModTime().UnixNano() + info.Size()
}
return nil
})
}
return fp
}
func jsonStr(s string) string { b, _ := json.Marshal(s); return string(b) }
// ensureShim copies Go's wasm_exec.js into dir if it isn't already there, so the
// server is self-sufficient without a separate build step first.
func ensureShim(dir string) error {
dst := filepath.Join(dir, "wasm_exec.js")
if _, err := os.Stat(dst); err == nil {
return nil
}
root, err := exec.Command("go", "env", "GOROOT").Output()
if err != nil {
return err
}
goroot := strings.TrimSpace(string(root))
for _, p := range []string{
filepath.Join(goroot, "lib", "wasm", "wasm_exec.js"), // Go >= 1.24
filepath.Join(goroot, "misc", "wasm", "wasm_exec.js"), // Go <= 1.23
} {
if b, err := os.ReadFile(p); err == nil {
if err := os.MkdirAll(dir, 0o755); err != nil {
return err
}
return os.WriteFile(dst, b, 0o644)
}
}
return os.ErrNotExist
}
// ---- injected livereload client -----------------------------------------
const clientJS = `// Injected by the dev server in watch mode.
(function () {
// Hot-swap the freshly built wasm in place — no full page reload and no blank
// flash. We fetch + compile the NEW module while the current page stays
// visible, then tear down the old instance and start the new one in the SAME
// task, so the browser never paints the intermediate empty #app. State is
// preserved: the old instance snapshots its signals on dispose; the new one
// restores them on boot.
async function hotSwap() {
if (!window.__gowasmPrepare) { location.reload(); return; } // fallback
var start;
try { start = await window.__gowasmPrepare(); } // network + compile; page still visible
catch (err) { console.error(err); location.reload(); return; }
hideOverlay();
try { if (window.__gowasmDispose) window.__gowasmDispose(); } catch (err) { console.error(err); }
start(); // renders synchronously — no await between dispose and first paint
}
// Full-screen overlay showing the Go compiler output when a build fails. The
// app underneath keeps running (and its state), so fixing the code and saving
// clears the overlay and hot-swaps without losing anything.
function ensureOverlay() {
var el = document.getElementById("__gowasm_error");
if (!el) {
el = document.createElement("div");
el.id = "__gowasm_error";
el.style.cssText = "position:fixed;inset:0;z-index:2147483647;margin:0;padding:24px 28px;" +
"background:rgba(24,24,27,0.97);color:#e4e4e7;overflow:auto;" +
"font:13px/1.55 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;";
document.body.appendChild(el);
}
return el;
}
function showOverlay(text) {
var el = ensureOverlay();
el.textContent = "";
var head = document.createElement("div");
head.style.cssText = "color:#f87171;font-weight:700;font-size:15px;margin-bottom:14px;";
head.textContent = "⚠ gowasm — build failed";
var pre = document.createElement("pre");
pre.style.cssText = "margin:0;white-space:pre-wrap;word-break:break-word;";
pre.textContent = text || "(no compiler output)";
el.appendChild(head);
el.appendChild(pre);
}
function hideOverlay() {
var el = document.getElementById("__gowasm_error");
if (el && el.parentNode) { el.parentNode.removeChild(el); }
}
window.__gowasmErrorOverlay = { show: showOverlay, hide: hideOverlay };
function connect() {
var proto = location.protocol === "https:" ? "wss://" : "ws://";
var ws = new WebSocket(proto + location.host + "/livereload");
ws.onmessage = function (e) {
var msg = {};
try { msg = JSON.parse(e.data); } catch (_) { return; }
if (msg.type === "reload") { hotSwap(); } // build ok: swap in place
else if (msg.type === "error") { showOverlay(msg.msg); } // build failed: show compiler output
else if (msg.type === "building") { console.log("[hot reload] rebuilding…"); }
};
ws.onclose = function () { setTimeout(connect, 1000); }; // reconnect after reload/restart
ws.onerror = function () { try { ws.close(); } catch (_) {} };
}
connect();
})();
`
func serveClientJS(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/javascript; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
io.WriteString(w, clientJS)
}
// ---- minimal WebSocket hub (stdlib only) --------------------------------
type hub struct {
mu sync.Mutex
clients map[*wsConn]struct{}
lastError string // most recent build-failure message (JSON), replayed to new clients
}
func newHub() *hub { return &hub{clients: map[*wsConn]struct{}{}} }
func (h *hub) add(c *wsConn) { h.mu.Lock(); h.clients[c] = struct{}{}; h.mu.Unlock() }
func (h *hub) remove(c *wsConn) { h.mu.Lock(); delete(h.clients, c); h.mu.Unlock() }
// setError records the current build failure (so it survives to new clients) and
// pushes it to everyone connected. clearError is called on the next good build.
func (h *hub) setError(out string) {
msg := `{"type":"error","msg":` + jsonStr(out) + `}`
h.mu.Lock()
h.lastError = msg
h.mu.Unlock()
h.broadcast(msg)
}
func (h *hub) clearError() { h.mu.Lock(); h.lastError = ""; h.mu.Unlock() }
func (h *hub) errorMessage() string { h.mu.Lock(); defer h.mu.Unlock(); return h.lastError }
func (h *hub) broadcast(msg string) {
h.mu.Lock()
defer h.mu.Unlock()
for c := range h.clients {
if err := c.sendText(msg); err != nil {
c.conn.Close()
delete(h.clients, c)
}
}
}
// serveWS upgrades the request to a WebSocket and keeps the connection until the
// client disconnects. We only ever push server->client, so incoming frames are
// drained (which also lets us detect disconnects).
func (h *hub) serveWS(w http.ResponseWriter, r *http.Request) {
if !strings.Contains(strings.ToLower(r.Header.Get("Upgrade")), "websocket") {
http.Error(w, "expected websocket upgrade", http.StatusBadRequest)
return
}
hj, ok := w.(http.Hijacker)
if !ok {
http.Error(w, "hijacking unsupported", http.StatusInternalServerError)
return
}
conn, brw, err := hj.Hijack()
if err != nil {
return
}
accept := acceptKey(r.Header.Get("Sec-WebSocket-Key"))
io.WriteString(brw, "HTTP/1.1 101 Switching Protocols\r\n"+
"Upgrade: websocket\r\nConnection: Upgrade\r\n"+
"Sec-WebSocket-Accept: "+accept+"\r\n\r\n")
if brw.Flush() != nil {
conn.Close()
return
}
c := &wsConn{conn: conn}
h.add(c)
if msg := h.errorMessage(); msg != "" {
c.sendText(msg) // opened after a failed build => show the overlay right away
}
// Drain incoming bytes; return (and clean up) when the client goes away.
go func() {
io.Copy(io.Discard, brw)
conn.Close()
h.remove(c)
}()
}
func acceptKey(key string) string {
h := sha1.New()
io.WriteString(h, key+"258EAFA5-E914-47DA-95CA-C5AB0DC85B11")
return base64.StdEncoding.EncodeToString(h.Sum(nil))
}
type wsConn struct {
mu sync.Mutex
conn net.Conn
}
// sendText writes a single unmasked text frame (server frames are never masked).
func (c *wsConn) sendText(msg string) error {
c.mu.Lock()
defer c.mu.Unlock()
payload := []byte(msg)
n := len(payload)
var header []byte
switch {
case n < 126:
header = []byte{0x81, byte(n)}
case n < 1<<16:
header = []byte{0x81, 126, byte(n >> 8), byte(n)}
default:
header = []byte{0x81, 127,
byte(n >> 56), byte(n >> 48), byte(n >> 40), byte(n >> 32),
byte(n >> 24), byte(n >> 16), byte(n >> 8), byte(n)}
}
if _, err := c.conn.Write(header); err != nil {
return err
}
_, err := c.conn.Write(payload)
return err
}