SSE in working state

This commit is contained in:
Maximilian Wagner
2025-12-26 01:37:23 +01:00
parent aa76d9c721
commit a6214e022f
11 changed files with 116 additions and 71 deletions

5
.gitignore vendored
View File

@@ -15,6 +15,7 @@
# Custom Additions # Custom Additions
servtex servtex
servtex.log *.aux
output *.log
*.pdf

View File

@@ -7,8 +7,8 @@ import (
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"strings"
"time" "time"
"git.noctra.dev/noctra/servtex/globals" "git.noctra.dev/noctra/servtex/globals"
) )
@@ -32,8 +32,16 @@ func GetLocalTimePretty() string {
return GetLocalTime().Format("02.01.2006 15:04") return GetLocalTime().Format("02.01.2006 15:04")
} }
// Writes to log and prints to screen // Writes to log file and prints to screen
func LogLine(message string) { //
// Levels:
// 1 - Debug
// 2 - Info
// 3 - Warning
// 4 - Error
func LogLine(message string, level int) {
if level < globals.AppConfig.LogLevelNumeric { return }
logline := GetLocalTimeLog() + " ServTeX: " + message + "\n" logline := GetLocalTimeLog() + " ServTeX: " + message + "\n"
fmt.Print(logline) fmt.Print(logline)
globals.LogFile.WriteString(logline) globals.LogFile.WriteString(logline)
@@ -46,15 +54,31 @@ func configReaderParse(filePath string, configOptionStorage *globals.Config) err
jsonData, err := os.ReadFile(filePath) jsonData, err := os.ReadFile(filePath)
if err == nil { if err == nil {
if err = json.Unmarshal(jsonData, &configOptionStorage); err != nil { if err = json.Unmarshal(jsonData, &configOptionStorage); err != nil {
LogLine("Configuration file is invaldi JSON", 5)
return errors.New("Config file could not be read") return errors.New("Config file could not be read")
} }
} else { } else {
LogLine(fmt.Sprintf("Config at %s does not exist", filePath), 1)
return errors.New("Config file does not exist") return errors.New("Config file does not exist")
} }
switch strings.ToLower(configOptionStorage.LogLevel) {
case "debug":
configOptionStorage.LogLevelNumeric = 1
case "info":
configOptionStorage.LogLevelNumeric = 2
case "warning":
configOptionStorage.LogLevelNumeric = 3
case "error":
configOptionStorage.LogLevelNumeric = 4
default:
configOptionStorage.LogLevelNumeric = 3
LogLine("Defaulting to log level warning", 3)
}
return configValidator(*configOptionStorage) return configValidator(*configOptionStorage)
} }
// tbd what exactly happens here
func configValidator(config globals.Config) error { func configValidator(config globals.Config) error {
return nil return nil
} }
@@ -91,7 +115,7 @@ func ConfigReader(configFileName string, configOptionStorage *globals.Config) er
// //
// Optionally, a minimum time to wait for consecutive changes can be specified // Optionally, a minimum time to wait for consecutive changes can be specified
func ChangeWatch(path string, callOnChange func()) { func ChangeWatch(path string, callOnChange func()) {
LogLine("File change noticed") LogLine("File change noticed", 2)
} }
// Intended to be run as goroutine // Intended to be run as goroutine
@@ -99,11 +123,11 @@ func LatexCompile(config globals.Config, execution *globals.LatexExecution) erro
if execution.ExecutionLock.TryLock() { if execution.ExecutionLock.TryLock() {
defer execution.ExecutionLock.Unlock() defer execution.ExecutionLock.Unlock()
} else { } else {
LogLine("LaTeX execution already underway") LogLine("LaTeX execution already underway", 2)
return errors.New("Execution already in progress") return errors.New("Execution already in progress")
} }
LogLine("LaTeX execution started") LogLine("LaTeX execution started", 2)
execution.ExecutionState = "Started" execution.ExecutionState = "Started"
var latexCommand *exec.Cmd var latexCommand *exec.Cmd
@@ -115,11 +139,13 @@ func LatexCompile(config globals.Config, execution *globals.LatexExecution) erro
"-jobname=servtex", "-jobname=servtex",
config.LatexSourceFilePath, config.LatexSourceFilePath,
) )
LogLine(fmt.Sprintf("Command: %s", latexCommand.String()), 1)
default: default:
execution.ExecutionState = "Unknown Latex Engine" execution.ExecutionState = "Unknown Latex Engine"
execution.Output = []byte{} execution.Output = []byte{}
execution.Timestamp = GetLocalTimePretty() execution.Timestamp = GetLocalTimePretty()
return errors.New("Unknown Latex Engine") LogLine("Unsupported LaTeX Engine", 4)
return errors.New("Unsupported LaTeX Engine")
} }
execution.ExecutionState = "Running" execution.ExecutionState = "Running"
@@ -132,6 +158,7 @@ func LatexCompile(config globals.Config, execution *globals.LatexExecution) erro
execution.Output = stdout execution.Output = stdout
execution.Timestamp = GetLocalTimePretty() execution.Timestamp = GetLocalTimePretty()
execution.ExecutionState = "Done" execution.ExecutionState = "Done"
LogLine("LaTeX execution finished", 2)
return nil return nil
} }

View File

@@ -3,14 +3,14 @@
"logFilePath": "./servtex.log", "logFilePath": "./servtex.log",
"latexEngine": "lualatex", "latexEngine": "lualatex",
"latexSourceFilePath": "", "latexSourceFilePath": "./testfiles/Example.tex",
"latexOutputPath": "./output", "latexOutputPath": "./testfiles/output",
"webserverDomain": "localhost", "webserverDomain": "localhost",
"webserverPort": "8080", "webserverPort": "8080",
"webserverSecure": true, "webserverSecure": true,
"webserverPortSecure": "8443", "webserverPortSecure": "8443",
"certificatePath": "./testing.crt", "certificatePath": "./testfiles/tls/testing.crt",
"certificateKeyPath": "./testing.key", "certificateKeyPath": "./testfiles/tls/testing.key",
"timezone": "Europe/Berlin" "timezone": "Europe/Berlin"
} }

View File

@@ -3,6 +3,7 @@
} }
.left-sidebar { .left-sidebar {
padding: 15px; padding: 15px;
height: 100%;
} }
.pdf-frame { .pdf-frame {
width: 100%; width: 100%;

View File

@@ -12,26 +12,23 @@
<body> <body>
<div class="container-fluid h-100 d-flex flex-column"> <div class="container-fluid h-100 d-flex flex-column" hx-ext="sse" sse-connect="/sse">
<div class="row output flex-grow-1"> <div class="row output flex-grow-1">
<div class="col-3 left-sidebar" hx-sse="connect:/sse on:status" hx-swap="innerHTML"> <div class="col-3 left-sidebar d-flex flex-column justify-content-between">
Status <br> <div>
Last Compilation <br> <h4>ServTeX Status</h4> <br>
Such Info <br> <div sse-swap="status">
Much wow Compilation has not run yet.
</div>
</div>
<br>
<button class="btn btn-primary mt-2" hx-post="/compile" hx-trigger="click" hx-swap="none">Recompile</button>
</div> </div>
<div class="col-9 p-0"> <div class="col-9 p-0" sse-swap="pdf">
<iframe class="pdf-frame" src="/pdf?ts=0" hx-sse="connect:/sse on:pdf" hx-swap="outerHTML"></iframe> <iframe class="pdf-frame" src="/pdf?ts=0"></iframe>
</div> </div>
</div> </div>
<div class="command-out" hx-sse="connect:/sse on:output" hx-swap="innerHTML"> <div class="command-out" sse-swap="output">
compile start<br>
compile file-x<br>
compile file-y<br>
example output<br>
warning but who cares<br>
finished compilation<br>
output file created<br>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -1,12 +1,15 @@
package frontend package frontend
import ( import (
"fmt"
"time"
"embed" "embed"
"strings" "fmt"
"net/http" "net/http"
"net/url" "net/url"
"os"
"path/filepath"
"strings"
"time"
"git.noctra.dev/noctra/servtex/backend" "git.noctra.dev/noctra/servtex/backend"
"git.noctra.dev/noctra/servtex/globals" "git.noctra.dev/noctra/servtex/globals"
) )
@@ -18,17 +21,8 @@ import (
//go:embed templates/main.html //go:embed templates/main.html
var WebFiles embed.FS var WebFiles embed.FS
// Adds necessary Headers for SSE
func sseHeadersAdd(writer *http.ResponseWriter) {
(*writer).Header().Set("Content-Type", "text/event-stream")
(*writer).Header().Set("Cache-Control", "no-cache")
(*writer).Header().Set("Connection", "keep-alive")
}
// Sends a Ping to keep the connection alive // Sends a Ping to keep the connection alive
func ssePing(writer *http.ResponseWriter) { func ssePing(writer *http.ResponseWriter) {
sseHeadersAdd(writer)
fmt.Fprintf((*writer), ": ping\n\n") fmt.Fprintf((*writer), ": ping\n\n")
(*writer).(http.Flusher).Flush() (*writer).(http.Flusher).Flush()
@@ -38,52 +32,52 @@ func ssePing(writer *http.ResponseWriter) {
// //
// Reads globals.LatexExec // Reads globals.LatexExec
func sseStatusSend(writer *http.ResponseWriter) { func sseStatusSend(writer *http.ResponseWriter) {
sseHeadersAdd(writer)
fmt.Fprintf((*writer), "event: status\n") fmt.Fprintf((*writer), "event: status\n")
fmt.Fprintf((*writer), "data: Execution Time: %s\n", globals.LatexExec.Timestamp) fmt.Fprintf((*writer), "data: Execution Time: %s<br>\n", globals.LatexExec.Timestamp)
fmt.Fprintf((*writer), "data: Execution State: %s\n\n", globals.LatexExec.ExecutionState) fmt.Fprintf((*writer), "data: Execution State: %s<br>\n\n", globals.LatexExec.ExecutionState)
(*writer).(http.Flusher).Flush() (*writer).(http.Flusher).Flush()
backend.LogLine("Status Event has been sent", 1)
} }
// Sends new Event // Sends new Event
// //
// Reads globals.LatexExec // Reads globals.LatexExec
func ssePDFSend(writer *http.ResponseWriter) { func ssePDFSend(writer *http.ResponseWriter) {
sseHeadersAdd(writer)
fmt.Fprintf((*writer), "event: pdf\n") fmt.Fprintf((*writer), "event: pdf\n")
iframe := fmt.Sprintf( iframe := fmt.Sprintf(
`<iframe class="pdf-frame" src="servetex.pdf?ts=%s" hx-sse="connect:/sse/pdf" hx-swap="outerHTML"></iframe>`, `<iframe class="pdf-frame" src="/pdf?ts=%s"></iframe>`,
url.QueryEscape(backend.GetLocalTimeRFC()), url.QueryEscape(backend.GetLocalTimeRFC()),
) )
fmt.Fprintf((*writer), "data: %s\n\n", iframe) fmt.Fprintf((*writer), "data: %s\n\n", iframe)
(*writer).(http.Flusher).Flush() (*writer).(http.Flusher).Flush()
backend.LogLine("PDF Event has been sent", 1)
} }
// Sends new Event // Sends new Event
// //
// Reads globals.LatexExec // Reads globals.LatexExec
func sseOutputSend(writer *http.ResponseWriter) { func sseOutputSend(writer *http.ResponseWriter) {
sseHeadersAdd(writer)
fmt.Fprintf((*writer), "event: output\n") fmt.Fprintf((*writer), "event: output\n")
lines := strings.SplitSeq(string(globals.LatexExec.Output), "\n") lines := strings.SplitSeq(string(globals.LatexExec.Output), "\n")
for line := range lines { for line := range lines {
fmt.Fprintf((*writer), "data: %s\n", line) fmt.Fprintf((*writer), "data: %s<br>\n", line)
} }
fmt.Fprintf((*writer), "\n") fmt.Fprintf((*writer), "\n")
(*writer).(http.Flusher).Flush() (*writer).(http.Flusher).Flush()
backend.LogLine("Output Event has been sent", 1)
} }
// Server Side Event Handler // Server Side Event Handler
// //
// Sends a Ping instead of actual data when no new data available to save bandwidth // Sends a Ping instead of actual data when no new data available to save bandwidth
func SSEventHandler(writer http.ResponseWriter, request *http.Request) { func SSEventHandler(writer http.ResponseWriter, request *http.Request) {
lastExecution := "" writer.Header().Set("Content-Type", "text/event-stream")
writer.Header().Set("Cache-Control", "no-cache")
writer.Header().Set("Connection", "keep-alive")
lastExecution := "startup"
for range time.Tick(time.Second) { for range time.Tick(time.Second) {
if lastExecution == globals.LatexExec.Timestamp { if lastExecution == globals.LatexExec.Timestamp {
ssePing(&writer) ssePing(&writer)
@@ -97,9 +91,19 @@ func SSEventHandler(writer http.ResponseWriter, request *http.Request) {
} }
} }
// Serves the compiled PDF file
func PDFHandler(writer http.ResponseWriter, request *http.Request) { func PDFHandler(writer http.ResponseWriter, request *http.Request) {
http.NotFound(writer, request) pdfPath := filepath.Join(globals.AppConfig.LatexOutputPath, "servtex.pdf")
pdf, err := os.Open(pdfPath)
if err != nil {
backend.LogLine("PDF file could not be found or read", 1)
http.NotFound(writer, request)
return
}
defer pdf.Close()
writer.Header().Set("Content-Type", "application/pdf")
http.ServeFile(writer, request, pdfPath)
} }
// Serves the main page of ServTeX // Serves the main page of ServTeX
@@ -109,3 +113,7 @@ func MainHandler(writer http.ResponseWriter, request *http.Request) {
writer.Write(main) writer.Write(main)
} }
func PDFCompile(writer http.ResponseWriter, request *http.Request) {
backend.LatexCompile(globals.AppConfig, &globals.LatexExec)
}

View File

@@ -8,6 +8,7 @@ import (
type Config struct { type Config struct {
// general // general
LogLevel string `json:"logLevel"` LogLevel string `json:"logLevel"`
LogLevelNumeric int
LogFilePath string `json:"logFilePath"` LogFilePath string `json:"logFilePath"`
// latex // latex
@@ -37,4 +38,3 @@ var LatexExec LatexExecution
var LogFile *os.File var LogFile *os.File

39
main.go
View File

@@ -16,52 +16,59 @@ import (
// Exit Codes: // Exit Codes:
// 0 - ok // 0 - ok
// 1 - config file could not be read // 1 - log file could not be accessed
// 2 - log file could not be accessed // 2 - config file could not be read
func main() { func main() {
// application init // temporary logfile
err := backend.ConfigReader("config.json", &globals.AppConfig) var err error
globals.LogFile, err = os.OpenFile("./servtex.log", os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
defer globals.LogFile.Close()
// parse configuration
err = backend.ConfigReader("config.json", &globals.AppConfig)
if err != nil { os.Exit(1) } if err != nil { os.Exit(1) }
// user-chosen logfile
globals.LogFile, err = os.OpenFile(globals.AppConfig.LogFilePath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) globals.LogFile, err = os.OpenFile(globals.AppConfig.LogFilePath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
if err != nil { os.Exit(2) } if err != nil { os.Exit(2) }
globals.LatexExec.Timestamp = "mytimestamp"
// webserver init // webserver init
server := &http.Server{Addr: globals.AppConfig.WebserverDomain + ":" + globals.AppConfig.WebserverPort} server := &http.Server{Addr: globals.AppConfig.WebserverDomain + ":" + globals.AppConfig.WebserverPort}
serverSecure := &http.Server{Addr: globals.AppConfig.WebserverDomain + ":" + globals.AppConfig.WebserverPortSecure} serverSecure := &http.Server{Addr: globals.AppConfig.WebserverDomain + ":" + globals.AppConfig.WebserverPortSecure}
// website url paths
http.HandleFunc("/", frontend.MainHandler) http.HandleFunc("/", frontend.MainHandler)
http.HandleFunc("/sse", frontend.SSEventHandler) http.HandleFunc("/sse", frontend.SSEventHandler)
http.HandleFunc("/pdf", frontend.PDFHandler) http.HandleFunc("/pdf", frontend.PDFHandler)
http.HandleFunc("/compile", frontend.PDFCompile)
jscss, _ := fs.Sub(frontend.WebFiles, "jscss") jscss, _ := fs.Sub(frontend.WebFiles, "jscss")
http.Handle("/jscss/", http.StripPrefix("/jscss/", http.FileServer(http.FS(jscss)))) http.Handle("/jscss/", http.StripPrefix("/jscss/", http.FileServer(http.FS(jscss))))
// rocket
go server.ListenAndServe() go server.ListenAndServe()
if globals.AppConfig.WebserverSecure { if globals.AppConfig.WebserverSecure {
go serverSecure.ListenAndServeTLS(globals.AppConfig.CertificatePath, globals.AppConfig.CertificateKeyPath) go serverSecure.ListenAndServeTLS(globals.AppConfig.CertificatePath, globals.AppConfig.CertificateKeyPath)
} }
backend.LogLine("Started") backend.LogLine("Started", 2)
// shutdown logic
fmt.Println("Press CTRL-C to Exit ServTeX") fmt.Println("Press CTRL-C to Exit ServTeX")
// wait for signal to quit
stop := make(chan os.Signal, 1) stop := make(chan os.Signal, 1)
signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM) signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM)
<-stop <-stop
context, cancel := context.WithTimeout(context.Background(), 10*time.Second) // remove ^C from stdout
defer cancel()
fmt.Print("\r") fmt.Print("\r")
// shutdown
context, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
if err = server.Shutdown(context); err != nil { if err = server.Shutdown(context); err != nil {
backend.LogLine("Graceful Shutdown failed") backend.LogLine("Graceful Shutdown failed", 4)
} }
if err = serverSecure.Shutdown(context); err != nil { if err = serverSecure.Shutdown(context); err != nil {
backend.LogLine("Graceful Shutdown failed") backend.LogLine("Graceful Shutdown failed", 4)
} }
backend.LogLine("Stopped") backend.LogLine("Stopped", 2)
} }

4
testfiles/Example.tex Normal file
View File

@@ -0,0 +1,4 @@
\documentclass{article}
\begin{document}
(This is an Example Document)
\end{document}