Files
Piik/cmd/screener-server/main.go
T
AAA da47189fc3 refactor: unify Go runtime and audited frontend
Squash integration of the reviewed Go core, frontend audit fixes, release tooling, and deployment hygiene. CI passed all Server/Client jobs.
2026-09-06 23:26:01 +08:00

169 lines
5.4 KiB
Go

// Command screener-server is the Hosted Screener entry point: it loads the
// environment, serves the embedded Browser UI together with the signaling and
// room API, and stops on SIGINT or SIGTERM. It replaces src/server/index.ts.
//
// Two maintenance modes exit without serving: --check-config validates the
// environment (the release wrapper runs it as the service user before cutover)
// and --check-release compares the deployed revision with the latest published
// release (see release.go).
package main
import (
"context"
"errors"
"flag"
"fmt"
"io/fs"
"log/slog"
"os"
"os/signal"
"strings"
"syscall"
"time"
"github.com/TNTcraftHIM/Screener/internal/server/app"
"github.com/TNTcraftHIM/Screener/internal/server/config"
"github.com/TNTcraftHIM/Screener/internal/server/webassets"
)
// shutdownTimeout bounds the ordered stop (signaling, listener, room store).
// systemd's tracked TimeoutStopSec is 20s, so the process must be gone first.
const shutdownTimeout = 15 * time.Second
// environmentFile is read from the working directory, which systemd pins to the
// release directory. Hosted keeps its real values in the service secret store.
const environmentFile = ".env"
// BuildRevision is the full Git revision the release packager links in with
// -ldflags. It matches the REVISION file shipped beside the binary and is
// reported at startup so one journal line identifies the running release.
var BuildRevision = "development"
func main() {
checkConfig := flag.Bool("check-config", false, "validate the environment and exit")
checkRelease := flag.Bool("check-release", false,
"report whether a newer published release exists and exit")
currentFile := flag.String("current-file", "",
"path to the deployed REVISION file read by --check-release")
apiURL := flag.String("api-url", defaultReleaseAPIURL,
"release metadata endpoint used by --check-release")
flag.Parse()
if err := loadEnvironmentFile(environmentFile); err != nil {
fail(err)
}
switch {
case *checkRelease:
os.Exit(checkDeployedRelease(context.Background(), *currentFile, *apiURL, os.Stdout))
case *checkConfig:
if _, err := config.Load(environment()); err != nil {
fail(err)
}
fmt.Println("config=ok")
default:
if err := serve(); err != nil {
fail(err)
}
}
}
func fail(err error) {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
// serve runs the application until the first SIGINT or SIGTERM, then stops it
// with Close rather than End: Hosted rooms are leased in the database and must
// survive a restart (DECISIONS D6).
func serve() error {
configuration, err := config.Load(environment())
if err != nil {
return err
}
server, err := app.New(app.Options{
Config: configuration,
Assets: webassets.FS(),
Logger: slog.New(slog.NewTextHandler(os.Stderr, nil)),
})
if err != nil {
return err
}
// Startup does not take the stop signal: index.ts registered its SIGINT and
// SIGTERM handlers only after `await server.listen()` resolved, so a stop
// during the LiveKit reconciliation ended the process by the signal's
// default disposition. Cancelling startup instead would abort it and exit
// non-zero, which systemd records as a failed unit. Each LiveKit call keeps
// its own 5 s bound (D1).
port, err := server.Listen(context.Background())
if err != nil {
return err
}
fmt.Printf("Screener %s is listening on %s:%d; public URL %s\n",
BuildRevision, configuration.ListenHost, port, config.Origin(configuration.PublicBaseURL))
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
<-ctx.Done()
shutdownCtx, cancel := context.WithTimeout(context.Background(), shutdownTimeout)
defer cancel()
return server.Close(shutdownCtx)
}
// environment is the process environment in the form config.Load expects.
func environment() map[string]string {
values := make(map[string]string)
for _, entry := range os.Environ() {
if name, value, found := strings.Cut(entry, "="); found {
values[name] = value
}
}
return values
}
// loadEnvironmentFile ports the src/server/index.ts loadEnvFile() call: an
// absent file is the ordinary case and every other read failure stops startup.
// Like Node, a name the process already carries is never overwritten, so a
// systemd EnvironmentFile still wins over a stale file in the release
// directory. The accepted syntax is DECISIONS D8: KEY=value lines, whole-line
// '#' comments, an optional 'export ' prefix, and a single- or double-quoted
// value. Quoted values keep their content verbatim; nothing is expanded.
func loadEnvironmentFile(name string) error {
content, err := os.ReadFile(name)
if errors.Is(err, fs.ErrNotExist) {
return nil
}
if err != nil {
return err
}
for _, line := range strings.Split(string(content), "\n") {
line = strings.TrimSpace(line)
line = strings.TrimSpace(strings.TrimPrefix(line, "export "))
if line == "" || strings.HasPrefix(line, "#") {
continue
}
key, value, assigned := strings.Cut(line, "=")
key = strings.TrimSpace(key)
if !assigned || key == "" {
continue
}
if _, present := os.LookupEnv(key); present {
continue
}
if err := os.Setenv(key, unquote(strings.TrimSpace(value))); err != nil {
return err
}
}
return nil
}
// unquote removes one matching pair of surrounding single or double quotes.
func unquote(value string) string {
if len(value) < 2 || value[0] != value[len(value)-1] {
return value
}
if value[0] != '"' && value[0] != '\'' {
return value
}
return value[1 : len(value)-1]
}