Introduces code.nochebuena.dev/einherjar/spa-server — a container-first HTTP
server for single-page applications and progressive web apps. Produces a Docker
base image; downstream SPA Dockerfiles require only a single COPY instruction.
Interfaces and types (CT-6: one TypeSpec per file):
- Config — Port (EINHERJAR_SPA_PORT, default 8080) and StaticDir
(EINHERJAR_SPA_STATIC_DIR, default /srv/www); parsed via os.Getenv
- Server — implements lifecycle.Component; registered with launcher.New
Implementation:
- NewServer(logger, cfg) *Server — returns a lifecycle-managed HTTP server
- OnInit: builds http.NewServeMux with GET /health and /* routes
- OnStart: binds TCP listener synchronously via net.Listen (port conflicts
surface immediately), then serves in a background goroutine
- OnStop: graceful shutdown via http.Server.Shutdown with 10s timeout
spa.NewHandler(logger, staticDir) http.Handler:
- Uses http.Dir.Open for path sanitisation (prevents directory traversal)
- File exists and is not a directory → served by http.FileServer
- File missing or is a directory → serves index.html (SPA router owns all routes)
- Directory listing is disabled
health.NewHandler(logger) http.Handler:
- Always responds 200 {"status":"UP","components":{}}
- Wire format matches web/health.Response without importing the web module
- No external checks — process reachability is the only meaningful health signal
Config (EINHERJAR_SPA_* env vars):
Port(8080), StaticDir(/srv/www)
Docker:
- Multi-stage: golang:1.26-alpine builder → alpine:3.21 runtime
- Image published as:
code.nochebuena.dev/einherjar/spa-server:v1.0.0
code.nochebuena.dev/einherjar/spa-server:latest
- VOLUME ["/srv/www"] declared; consumer overrides via COPY or volume mount
No external dependencies beyond contracts, core, and the Go standard library.
108 lines
2.9 KiB
Go
108 lines
2.9 KiB
Go
package spaserver
|
|
|
|
import (
|
|
"context"
|
|
"go/ast"
|
|
"go/parser"
|
|
"go/token"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"code.nochebuena.dev/einherjar/contracts/lifecycle"
|
|
"code.nochebuena.dev/einherjar/contracts/logging"
|
|
)
|
|
|
|
// Compile-time interface check (CT-5 / I-8).
|
|
var _ lifecycle.Component = (*Server)(nil)
|
|
|
|
// TestAtMostOneExportedTypePerFile enforces CT-6: at most one exported TypeSpec
|
|
// per non-test, non-doc .go file in the root package.
|
|
func TestAtMostOneExportedTypePerFile(t *testing.T) {
|
|
fset := token.NewFileSet()
|
|
pkgs, err := parser.ParseDir(fset, ".", func(fi os.FileInfo) bool {
|
|
name := fi.Name()
|
|
return !strings.HasSuffix(name, "_test.go") && name != "doc.go"
|
|
}, 0)
|
|
if err != nil {
|
|
t.Fatalf("parse: %v", err)
|
|
}
|
|
for _, pkg := range pkgs {
|
|
for path, file := range pkg.Files {
|
|
base := filepath.Base(path)
|
|
count := 0
|
|
for _, decl := range file.Decls {
|
|
gd, ok := decl.(*ast.GenDecl)
|
|
if !ok {
|
|
continue
|
|
}
|
|
for _, spec := range gd.Specs {
|
|
ts, ok := spec.(*ast.TypeSpec)
|
|
if ok && ts.Name.IsExported() {
|
|
count++
|
|
}
|
|
}
|
|
}
|
|
if count > 1 {
|
|
t.Errorf("%s: %d exported TypeSpecs (max 1)", base, count)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestDefaultConfig_Defaults verifies that DefaultConfig returns non-zero values (S-4).
|
|
func TestDefaultConfig_Defaults(t *testing.T) {
|
|
cfg := DefaultConfig()
|
|
if cfg.Port == 0 {
|
|
t.Error("Port must have a non-zero default")
|
|
}
|
|
if cfg.StaticDir == "" {
|
|
t.Error("StaticDir must have a non-empty default")
|
|
}
|
|
}
|
|
|
|
// TestDefaultConfig_EnvOverride verifies that environment variables are respected.
|
|
func TestDefaultConfig_EnvOverride(t *testing.T) {
|
|
t.Setenv("EINHERJAR_SPA_PORT", "9090")
|
|
t.Setenv("EINHERJAR_SPA_STATIC_DIR", "/tmp/www")
|
|
|
|
cfg := DefaultConfig()
|
|
if cfg.Port != 9090 {
|
|
t.Errorf("Port = %d, want 9090", cfg.Port)
|
|
}
|
|
if cfg.StaticDir != "/tmp/www" {
|
|
t.Errorf("StaticDir = %q, want /tmp/www", cfg.StaticDir)
|
|
}
|
|
}
|
|
|
|
// TestServer_Lifecycle verifies that OnInit/OnStart/OnStop complete without error
|
|
// on a free port.
|
|
func TestServer_Lifecycle(t *testing.T) {
|
|
cfg := Config{Port: 0, StaticDir: t.TempDir()}
|
|
srv := NewServer(newStubLogger(), cfg)
|
|
|
|
if err := srv.OnInit(); err != nil {
|
|
t.Fatalf("OnInit: %v", err)
|
|
}
|
|
if err := srv.OnStart(); err != nil {
|
|
t.Fatalf("OnStart: %v", err)
|
|
}
|
|
if err := srv.OnStop(); err != nil {
|
|
t.Fatalf("OnStop: %v", err)
|
|
}
|
|
}
|
|
|
|
// --- helpers ---
|
|
|
|
type stubLogger struct{}
|
|
|
|
func newStubLogger() *stubLogger { return &stubLogger{} }
|
|
|
|
func (s *stubLogger) Debug(msg string, args ...any) {}
|
|
func (s *stubLogger) Info(msg string, args ...any) {}
|
|
func (s *stubLogger) Warn(msg string, args ...any) {}
|
|
func (s *stubLogger) Error(msg string, err error, args ...any) {}
|
|
func (s *stubLogger) With(args ...any) logging.Logger { return s }
|
|
func (s *stubLogger) WithContext(ctx context.Context) logging.Logger { return s }
|