MVP: Go daemon, password auth, persistent PTY terminals, React/xterm UI, PWA

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Mitchell-Johnson
2026-09-17 10:25:35 -07:00
co-authored by Claude Fable 5.1
parent 190604247f
commit 436732dac0
35 changed files with 3576 additions and 0 deletions
+326
View File
@@ -0,0 +1,326 @@
// Package api is the HTTP/WebSocket surface: auth, terminal CRUD, attach, static UI.
package api
import (
"context"
"encoding/json"
"errors"
"io"
"io/fs"
"log"
"net/http"
"os"
"strings"
"time"
"github.com/coder/websocket"
"hamlet/internal/auth"
"hamlet/internal/config"
"hamlet/internal/term"
)
const cookieName = "hamlet_session"
type Server struct {
cfg *config.Config
sessions *auth.Sessions
limiter auth.Limiter
mgr *term.Manager
version string
started time.Time
user string
host string
ui fs.FS
mux *http.ServeMux
}
func New(cfg *config.Config, sessions *auth.Sessions, mgr *term.Manager, ui fs.FS, version string) *Server {
host, _ := os.Hostname()
s := &Server{cfg: cfg, sessions: sessions, mgr: mgr, ui: ui, version: version,
started: time.Now(), host: host, user: os.Getenv("USER")}
if s.user == "" {
s.user = "core"
}
m := http.NewServeMux()
m.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(204) })
m.HandleFunc("POST /api/login", s.login)
m.HandleFunc("POST /api/logout", s.auth(s.logout))
m.HandleFunc("GET /api/me", s.auth(s.me))
m.HandleFunc("GET /api/terminals", s.auth(s.listTerminals))
m.HandleFunc("POST /api/terminals", s.auth(s.createTerminal))
m.HandleFunc("PATCH /api/terminals/{id}", s.auth(s.renameTerminal))
m.HandleFunc("DELETE /api/terminals/{id}", s.auth(s.deleteTerminal))
m.HandleFunc("GET /api/terminals/{id}/attach", s.auth(s.attach))
m.HandleFunc("/api/", func(w http.ResponseWriter, _ *http.Request) { jsonErr(w, 404, "not found") })
m.HandleFunc("/", s.static)
s.mux = m
return s
}
func (s *Server) Handler() http.Handler { return s.mux }
// ---- helpers ----
func jsonOK(w http.ResponseWriter, code int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
_ = json.NewEncoder(w).Encode(v)
}
func jsonErr(w http.ResponseWriter, code int, msg string) {
jsonOK(w, code, map[string]string{"error": msg})
}
func secure(r *http.Request) bool {
return r.TLS != nil || strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https")
}
func (s *Server) setCookie(w http.ResponseWriter, r *http.Request, tok string, maxAge int) {
http.SetCookie(w, &http.Cookie{
Name: cookieName, Value: tok, Path: "/", MaxAge: maxAge,
HttpOnly: true, Secure: secure(r), SameSite: http.SameSiteStrictMode,
})
}
func (s *Server) auth(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
c, err := r.Cookie(cookieName)
if err != nil || !s.sessions.Valid(c.Value) {
jsonErr(w, 401, "unauthenticated")
return
}
next(w, r)
}
}
// ---- auth ----
func (s *Server) login(w http.ResponseWriter, r *http.Request) {
if err := s.limiter.Allow(); err != nil {
jsonErr(w, 429, "too many attempts, try again later")
return
}
var body struct {
Password string `json:"password"`
}
if err := json.NewDecoder(io.LimitReader(r.Body, 4096)).Decode(&body); err != nil {
jsonErr(w, 400, "bad request")
return
}
if !auth.VerifyPassword(s.cfg.PasswordHash, body.Password) {
s.limiter.Fail()
log.Printf("login failed from %s", clientIP(r))
jsonErr(w, 401, "wrong password")
return
}
s.limiter.Reset()
tok, err := s.sessions.Create()
if err != nil {
jsonErr(w, 500, "session error")
return
}
s.setCookie(w, r, tok, int(auth.SessionTTL.Seconds()))
log.Printf("login ok from %s", clientIP(r))
w.WriteHeader(204)
}
func (s *Server) logout(w http.ResponseWriter, r *http.Request) {
if c, err := r.Cookie(cookieName); err == nil {
s.sessions.Delete(c.Value)
}
s.setCookie(w, r, "", -1)
w.WriteHeader(204)
}
func clientIP(r *http.Request) string {
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
return strings.TrimSpace(strings.Split(xff, ",")[0]) + " via " + r.RemoteAddr
}
return r.RemoteAddr
}
// ---- me ----
func (s *Server) me(w http.ResponseWriter, _ *http.Request) {
load := ""
if b, err := os.ReadFile("/proc/loadavg"); err == nil {
f := strings.Fields(string(b))
if len(f) >= 3 {
load = strings.Join(f[:3], " ")
}
}
uptime := 0.0
if b, err := os.ReadFile("/proc/uptime"); err == nil {
if f := strings.Fields(string(b)); len(f) > 0 {
uptime, _ = json.Number(f[0]).Float64()
}
}
jsonOK(w, 200, map[string]any{
"host": s.host, "user": s.user, "version": s.version,
"load": load, "uptime": uptime, "service_started": s.started,
})
}
// ---- terminals ----
func (s *Server) listTerminals(w http.ResponseWriter, _ *http.Request) {
jsonOK(w, 200, s.mgr.List())
}
func (s *Server) createTerminal(w http.ResponseWriter, r *http.Request) {
var body struct {
Name string `json:"name"`
}
_ = json.NewDecoder(io.LimitReader(r.Body, 4096)).Decode(&body)
name := strings.TrimSpace(body.Name)
if name == "" {
name = "shell " + time.Now().Format("15:04")
}
sess, err := s.mgr.Create("", name)
if err != nil {
log.Printf("create terminal: %v", err)
jsonErr(w, 500, err.Error())
return
}
jsonOK(w, 201, map[string]string{"id": sess.ID, "name": sess.Name})
}
func (s *Server) renameTerminal(w http.ResponseWriter, r *http.Request) {
var body struct {
Name string `json:"name"`
}
if err := json.NewDecoder(io.LimitReader(r.Body, 4096)).Decode(&body); err != nil || strings.TrimSpace(body.Name) == "" {
jsonErr(w, 400, "name required")
return
}
if err := s.mgr.Rename(r.PathValue("id"), strings.TrimSpace(body.Name)); err != nil {
jsonErr(w, 404, err.Error())
return
}
w.WriteHeader(204)
}
func (s *Server) deleteTerminal(w http.ResponseWriter, r *http.Request) {
if err := s.mgr.Close(r.PathValue("id")); err != nil {
jsonErr(w, 404, err.Error())
return
}
w.WriteHeader(204)
}
type resizeMsg struct {
Type string `json:"type"`
Cols uint16 `json:"cols"`
Rows uint16 `json:"rows"`
}
func (s *Server) attach(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
client, snapshot, running, err := s.mgr.Attach(id)
if err != nil {
jsonErr(w, 404, err.Error())
return
}
// Origin must match Host (coder/websocket default) — blocks cross-site attaches.
conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{CompressionMode: websocket.CompressionDisabled})
if err != nil {
s.mgr.Detach(client)
return
}
defer s.mgr.Detach(client)
defer conn.Close(websocket.StatusNormalClosure, "")
conn.SetReadLimit(1 << 20)
ctx, cancel := context.WithCancel(r.Context())
defer cancel()
if len(snapshot) > 0 {
if err := conn.Write(ctx, websocket.MessageBinary, snapshot); err != nil {
return
}
}
if !running {
_ = conn.Write(ctx, websocket.MessageText, []byte(`{"type":"exit"}`))
}
// writer: PTY → socket, plus keepalive pings so idle proxies don't cut us.
go func() {
defer cancel()
ping := time.NewTicker(25 * time.Second)
defer ping.Stop()
for {
select {
case <-ctx.Done():
return
case <-ping.C:
pctx, pc := context.WithTimeout(ctx, 10*time.Second)
err := conn.Ping(pctx)
pc()
if err != nil {
return
}
case msg, ok := <-client.Ch:
if !ok {
_ = conn.Write(ctx, websocket.MessageText, []byte(`{"type":"closed"}`))
return
}
if msg.Data != nil {
if err := conn.Write(ctx, websocket.MessageBinary, msg.Data); err != nil {
return
}
continue
}
if msg.Event == "exit" {
b, _ := json.Marshal(map[string]any{"type": "exit", "code": msg.Code})
_ = conn.Write(ctx, websocket.MessageText, b)
}
}
}
}()
// reader: socket → PTY (binary) or control (text JSON).
for {
typ, data, err := conn.Read(ctx)
if err != nil {
return
}
switch typ {
case websocket.MessageBinary:
if err := s.mgr.Write(id, data); err != nil && !errors.Is(err, term.ErrNotFound) {
log.Printf("pty write %s: %v", id, err)
}
case websocket.MessageText:
var m resizeMsg
if json.Unmarshal(data, &m) == nil && m.Type == "resize" {
_ = s.mgr.Resize(id, m.Cols, m.Rows)
}
}
}
}
// ---- static UI with SPA fallback ----
func (s *Server) static(w http.ResponseWriter, r *http.Request) {
p := strings.TrimPrefix(r.URL.Path, "/")
if p == "" {
p = "index.html"
}
f, err := s.ui.Open(p)
if err == nil {
st, _ := f.Stat()
f.Close()
if st != nil && !st.IsDir() {
if strings.HasPrefix(p, "assets/") {
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
} else {
w.Header().Set("Cache-Control", "no-cache")
}
http.ServeFileFS(w, r, s.ui, p)
return
}
}
// SPA fallback
w.Header().Set("Cache-Control", "no-cache")
http.ServeFileFS(w, r, s.ui, "index.html")
}
+160
View File
@@ -0,0 +1,160 @@
// Package auth: argon2id password hashing, cookie sessions, login rate limiting.
package auth
import (
"crypto/rand"
"crypto/subtle"
"encoding/base64"
"encoding/hex"
"errors"
"fmt"
"strings"
"sync"
"time"
"golang.org/x/crypto/argon2"
"hamlet/internal/store"
)
const (
argonTime = 3
argonMemory = 64 * 1024
argonThreads = 2
argonKeyLen = 32
SessionTTL = 30 * 24 * time.Hour
)
func HashPassword(pw string) (string, error) {
salt := make([]byte, 16)
if _, err := rand.Read(salt); err != nil {
return "", err
}
key := argon2.IDKey([]byte(pw), salt, argonTime, argonMemory, argonThreads, argonKeyLen)
enc := base64.RawStdEncoding.EncodeToString
return fmt.Sprintf("$argon2id$v=19$m=%d,t=%d,p=%d$%s$%s",
argonMemory, argonTime, argonThreads, enc(salt), enc(key)), nil
}
func VerifyPassword(hash, pw string) bool {
parts := strings.Split(hash, "$")
if len(parts) != 6 || parts[1] != "argon2id" {
return false
}
var m, t uint32
var p uint8
if _, err := fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &m, &t, &p); err != nil {
return false
}
salt, err := base64.RawStdEncoding.DecodeString(parts[4])
if err != nil {
return false
}
want, err := base64.RawStdEncoding.DecodeString(parts[5])
if err != nil {
return false
}
got := argon2.IDKey([]byte(pw), salt, t, m, p, uint32(len(want)))
return subtle.ConstantTimeCompare(got, want) == 1
}
// Sessions is a token store backed by the state file. Expiry is sliding.
type Sessions struct {
st *store.Store
}
func NewSessions(st *store.Store) *Sessions {
s := &Sessions{st: st}
_ = st.Update(func(state *store.State) { pruneExpired(state) })
return s
}
func pruneExpired(state *store.State) {
now := time.Now()
for tok, exp := range state.Sessions {
if exp.Before(now) {
delete(state.Sessions, tok)
}
}
}
func (s *Sessions) Create() (string, error) {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return "", err
}
tok := hex.EncodeToString(b)
err := s.st.Update(func(state *store.State) {
pruneExpired(state)
state.Sessions[tok] = time.Now().Add(SessionTTL)
})
return tok, err
}
// Valid reports whether tok is live, sliding its expiry (persisted at most hourly).
func (s *Sessions) Valid(tok string) bool {
if tok == "" {
return false
}
ok := false
extend := false
s.st.View(func(state *store.State) {
exp, found := state.Sessions[tok]
if found && exp.After(time.Now()) {
ok = true
extend = time.Until(exp) < SessionTTL-time.Hour
}
})
if ok && extend {
_ = s.st.Update(func(state *store.State) {
if _, found := state.Sessions[tok]; found {
state.Sessions[tok] = time.Now().Add(SessionTTL)
}
})
}
return ok
}
func (s *Sessions) Delete(tok string) {
_ = s.st.Update(func(state *store.State) { delete(state.Sessions, tok) })
}
// Limiter is a single global backoff: after 5 failures, each further failure
// doubles a lockout starting at 30s, capped at 10 minutes. Single-user box.
type Limiter struct {
mu sync.Mutex
failures int
lockUntil time.Time
}
var ErrLocked = errors.New("too many failed logins")
func (l *Limiter) Allow() error {
l.mu.Lock()
defer l.mu.Unlock()
if time.Now().Before(l.lockUntil) {
return ErrLocked
}
return nil
}
func (l *Limiter) Fail() {
l.mu.Lock()
defer l.mu.Unlock()
l.failures++
if l.failures >= 5 {
d := 30 * time.Second << uint(l.failures-5)
if d > 10*time.Minute {
d = 10 * time.Minute
}
l.lockUntil = time.Now().Add(d)
}
}
func (l *Limiter) Reset() {
l.mu.Lock()
defer l.mu.Unlock()
l.failures = 0
l.lockUntil = time.Time{}
}
+55
View File
@@ -0,0 +1,55 @@
// Package config loads and saves hamlet's JSON config file.
package config
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
)
type Config struct {
Listen string `json:"listen"`
PasswordHash string `json:"password_hash"`
path string
}
func Default(path string) *Config {
return &Config{Listen: "127.0.0.1:8100", path: path}
}
// Load reads the config at path. A missing file yields the defaults.
func Load(path string) (*Config, error) {
c := Default(path)
b, err := os.ReadFile(path)
if errors.Is(err, os.ErrNotExist) {
return c, nil
}
if err != nil {
return nil, err
}
if err := json.Unmarshal(b, c); err != nil {
return nil, fmt.Errorf("parse %s: %w", path, err)
}
return c, nil
}
func (c *Config) Path() string { return c.path }
func (c *Config) DataDir() string { return filepath.Dir(c.path) }
func (c *Config) Save() error {
if err := os.MkdirAll(c.DataDir(), 0o700); err != nil {
return err
}
b, err := json.MarshalIndent(c, "", " ")
if err != nil {
return err
}
tmp := c.path + ".tmp"
if err := os.WriteFile(tmp, append(b, '\n'), 0o600); err != nil {
return err
}
return os.Rename(tmp, c.path)
}
+71
View File
@@ -0,0 +1,71 @@
// Package store persists small runtime state (terminal names, login sessions)
// to a JSON file with atomic writes.
package store
import (
"encoding/json"
"errors"
"os"
"sync"
"time"
)
type Terminal struct {
ID string `json:"id"`
Name string `json:"name"`
}
type State struct {
Terminals []Terminal `json:"terminals"`
Sessions map[string]time.Time `json:"sessions"` // token -> expiry
}
type Store struct {
mu sync.Mutex
path string
state State
}
func Open(path string) (*Store, error) {
s := &Store{path: path}
b, err := os.ReadFile(path)
if err != nil && !errors.Is(err, os.ErrNotExist) {
return nil, err
}
if err == nil {
if err := json.Unmarshal(b, &s.state); err != nil {
return nil, err
}
}
if s.state.Sessions == nil {
s.state.Sessions = map[string]time.Time{}
}
return s, nil
}
// Update applies fn under the lock and writes the result to disk.
func (s *Store) Update(fn func(*State)) error {
s.mu.Lock()
defer s.mu.Unlock()
fn(&s.state)
return s.save()
}
// View reads state under the lock.
func (s *Store) View(fn func(*State)) {
s.mu.Lock()
defer s.mu.Unlock()
fn(&s.state)
}
func (s *Store) save() error {
b, err := json.MarshalIndent(&s.state, "", " ")
if err != nil {
return err
}
tmp := s.path + ".tmp"
if err := os.WriteFile(tmp, b, 0o600); err != nil {
return err
}
return os.Rename(tmp, s.path)
}
+287
View File
@@ -0,0 +1,287 @@
// Package term owns PTY-backed shell sessions that outlive any one browser tab.
package term
import (
"crypto/rand"
"encoding/hex"
"errors"
"os"
"os/exec"
"sort"
"sync"
"syscall"
"time"
"github.com/creack/pty"
)
const (
ringSize = 256 * 1024
clientBuf = 512
killGrace = 3 * time.Second
readChunk = 32 * 1024
defaultCol = 120
defaultRow = 30
)
var ErrNotFound = errors.New("terminal not found")
// Msg is one unit of output to a client: raw PTY bytes, or a control event.
type Msg struct {
Data []byte // non-nil for PTY output
Event string // "exit" when the shell has ended
Code int
}
type Client struct {
Ch chan Msg
session *Session
dropped bool
}
type Info struct {
ID string `json:"id"`
Name string `json:"name"`
Running bool `json:"running"`
ExitCode int `json:"exit_code"`
Created time.Time `json:"created"`
Clients int `json:"clients"`
}
type Session struct {
ID string
Name string
Created time.Time
mu sync.Mutex
ptmx *os.File
cmd *exec.Cmd
ring *Ring
clients map[*Client]struct{}
running bool
exitCode int
}
type Manager struct {
mu sync.RWMutex
sessions map[string]*Session
shell string
home string
OnChange func([]Info) // called (without locks held) when the list changes
}
func NewManager(shell, home string) *Manager {
return &Manager{sessions: map[string]*Session{}, shell: shell, home: home}
}
func newID() string {
b := make([]byte, 4)
_, _ = rand.Read(b)
return hex.EncodeToString(b)
}
// Create starts a new login shell. id may be empty.
func (m *Manager) Create(id, name string) (*Session, error) {
if id == "" {
id = newID()
}
cmd := exec.Command(m.shell, "-l")
cmd.Dir = m.home
cmd.Env = append(os.Environ(), "TERM=xterm-256color", "COLORTERM=truecolor", "HAMLET=1")
ptmx, err := pty.StartWithSize(cmd, &pty.Winsize{Cols: defaultCol, Rows: defaultRow})
if err != nil {
return nil, err
}
s := &Session{
ID: id, Name: name, Created: time.Now(),
ptmx: ptmx, cmd: cmd, ring: NewRing(ringSize),
clients: map[*Client]struct{}{}, running: true,
}
m.mu.Lock()
m.sessions[id] = s
m.mu.Unlock()
go m.pump(s)
m.changed()
return s, nil
}
func (m *Manager) pump(s *Session) {
buf := make([]byte, readChunk)
for {
n, err := s.ptmx.Read(buf)
if n > 0 {
chunk := make([]byte, n)
copy(chunk, buf[:n])
s.mu.Lock()
s.ring.Write(chunk)
s.broadcast(Msg{Data: chunk})
s.mu.Unlock()
}
if err != nil {
break // EIO when the slave side closes
}
}
_ = s.cmd.Wait()
code := s.cmd.ProcessState.ExitCode()
s.mu.Lock()
s.running = false
s.exitCode = code
s.broadcast(Msg{Event: "exit", Code: code})
s.mu.Unlock()
_ = s.ptmx.Close()
m.changed()
}
// broadcast requires s.mu. Slow clients are dropped rather than blocking the PTY.
func (s *Session) broadcast(msg Msg) {
for c := range s.clients {
select {
case c.Ch <- msg:
default:
if !c.dropped {
c.dropped = true
close(c.Ch)
}
delete(s.clients, c)
}
}
}
func (m *Manager) get(id string) (*Session, error) {
m.mu.RLock()
defer m.mu.RUnlock()
s, ok := m.sessions[id]
if !ok {
return nil, ErrNotFound
}
return s, nil
}
func (m *Manager) List() []Info {
m.mu.RLock()
out := make([]Info, 0, len(m.sessions))
for _, s := range m.sessions {
s.mu.Lock()
out = append(out, Info{ID: s.ID, Name: s.Name, Running: s.running, ExitCode: s.exitCode,
Created: s.Created, Clients: len(s.clients)})
s.mu.Unlock()
}
m.mu.RUnlock()
sort.Slice(out, func(i, j int) bool { return out[i].Created.Before(out[j].Created) })
return out
}
func (m *Manager) Rename(id, name string) error {
s, err := m.get(id)
if err != nil {
return err
}
s.mu.Lock()
s.Name = name
s.mu.Unlock()
m.changed()
return nil
}
// Attach subscribes a client and returns the scrollback snapshot to replay first.
func (m *Manager) Attach(id string) (*Client, []byte, bool, error) {
s, err := m.get(id)
if err != nil {
return nil, nil, false, err
}
c := &Client{Ch: make(chan Msg, clientBuf), session: s}
s.mu.Lock()
snap := s.ring.Bytes()
running := s.running
s.clients[c] = struct{}{}
s.mu.Unlock()
m.changed()
return c, snap, running, nil
}
func (m *Manager) Detach(c *Client) {
s := c.session
s.mu.Lock()
if _, ok := s.clients[c]; ok {
delete(s.clients, c)
if !c.dropped {
c.dropped = true
close(c.Ch)
}
}
s.mu.Unlock()
m.changed()
}
func (m *Manager) Write(id string, p []byte) error {
s, err := m.get(id)
if err != nil {
return err
}
s.mu.Lock()
running := s.running
s.mu.Unlock()
if !running {
return nil
}
_, err = s.ptmx.Write(p)
return err
}
func (m *Manager) Resize(id string, cols, rows uint16) error {
s, err := m.get(id)
if err != nil {
return err
}
if cols == 0 || rows == 0 {
return nil
}
return pty.Setsize(s.ptmx, &pty.Winsize{Cols: cols, Rows: rows})
}
// Close ends the shell (SIGHUP to its process group, SIGKILL after a grace
// period) and forgets the session.
func (m *Manager) Close(id string) error {
m.mu.Lock()
s, ok := m.sessions[id]
if ok {
delete(m.sessions, id)
}
m.mu.Unlock()
if !ok {
return ErrNotFound
}
s.mu.Lock()
running := s.running
for c := range s.clients {
if !c.dropped {
c.dropped = true
close(c.Ch)
}
delete(s.clients, c)
}
s.mu.Unlock()
if running && s.cmd.Process != nil {
pid := s.cmd.Process.Pid
_ = syscall.Kill(-pid, syscall.SIGHUP)
go func() {
time.Sleep(killGrace)
_ = syscall.Kill(-pid, syscall.SIGKILL)
}()
}
m.changed()
return nil
}
func (m *Manager) changed() {
if m.OnChange != nil {
m.OnChange(m.List())
}
}
// Shutdown hangs up every shell. Called on daemon exit.
func (m *Manager) Shutdown() {
for _, s := range m.List() {
_ = m.Close(s.ID)
}
}
+37
View File
@@ -0,0 +1,37 @@
package term
// Ring is a fixed-size byte ring buffer holding the most recent output.
type Ring struct {
buf []byte
start int
n int
}
func NewRing(size int) *Ring { return &Ring{buf: make([]byte, size)} }
func (r *Ring) Write(p []byte) {
size := len(r.buf)
if len(p) >= size {
copy(r.buf, p[len(p)-size:])
r.start, r.n = 0, size
return
}
end := (r.start + r.n) % size
first := copy(r.buf[end:], p)
copy(r.buf, p[first:])
if r.n+len(p) > size {
r.start = (end + len(p)) % size
r.n = size
} else {
r.n += len(p)
}
}
func (r *Ring) Bytes() []byte {
out := make([]byte, r.n)
first := copy(out, r.buf[r.start:])
if first < r.n {
copy(out[first:], r.buf[:r.n-first])
}
return out
}
+18
View File
@@ -0,0 +1,18 @@
// Package web embeds the built frontend (web/dist, produced by `make web`).
package web
import (
"embed"
"io/fs"
)
//go:embed all:dist
var dist embed.FS
func FS() fs.FS {
sub, err := fs.Sub(dist, "dist")
if err != nil {
panic(err)
}
return sub
}