feat(spa-server): initial implementation — SPA/PWA container server (v1.0.0)
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.
This commit is contained in:
107
compliance_test.go
Normal file
107
compliance_test.go
Normal file
@@ -0,0 +1,107 @@
|
||||
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 }
|
||||
Reference in New Issue
Block a user