server

package
v2.6.0-beta3 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jun 20, 2026 License: MPL-2.0 Imports: 16 Imported by: 0

Documentation

Overview

Package server provides an HTTP server for serving generated blog content with support for live content updates via atomic handler hot-swapping.

The server implements thread-safe handler replacement, allowing blog content to be regenerated and swapped in without dropping in-flight requests or requiring server restart.

Basic Usage

Create and start a server:

import (
    "context"
    "log/slog"
    "os"
    "github.com/harrydayexe/GoBlog/v2/pkg/server"
    "github.com/harrydayexe/GoBlog/v2/pkg/config"
)

// Create server with posts from directory
logger := slog.New(slog.NewTextHandler(os.Stdout, nil))
postsFS := os.DirFS("posts/")

cfg := config.ServerConfig{
    Server: []config.BaseServerOption{
        config.WithPort(8080),
        config.WithLogger(logger).AsServerOption(),
    },
}

srv, err := server.New(nil, postsFS, cfg)
if err != nil {
    log.Fatal(err)
}

// Start serving (blocks until interrupted)
if err := srv.Run(context.Background()); err != nil {
    log.Fatal(err)
}

Feed Routes

When the generator is configured with config.WithBaseURL (and config.WithDisableFeeds is not applied), the server exposes:

  • GET {root}rss.xml — site-wide RSS 2.0 feed
  • GET {root}atom.xml — site-wide Atom feed
  • GET {root}tags/{tag}.rss.xml — per-tag RSS 2.0 feed
  • GET {root}tags/{tag}.atom.xml — per-tag Atom feed

Feed routes always exist in the mux but return 404 when the generator did not produce feed content. This mirrors the static output written by pkg/outputter at rss.xml, atom.xml, tags/{tag}.rss.xml, tags/{tag}.atom.xml.

HTML Extension Handling

The server automatically accepts requests with or without .html suffixes. Requests for /posts/my-post.html are rewritten to /posts/my-post before routing, so both forms return the same content. This is handled by middleware.NewStripHTMLExtension from github.com/harrydayexe/GoWebUtilities, which is applied unconditionally inside Handler(). User-supplied middleware added via config.WithMiddleware sees the original .html URL before it is stripped.

Middleware

The server supports pluggable HTTP middleware for cross-cutting concerns like logging, metrics, authentication, or rate limiting. Middleware uses the standard pattern from github.com/harrydayexe/GoWebUtilities/middleware.

Adding middleware to a server:

import (
    "github.com/harrydayexe/GoBlog/v2/pkg/config"
    "github.com/harrydayexe/GoBlog/v2/pkg/server"
    "github.com/harrydayexe/GoWebUtilities/logging"
    "github.com/harrydayexe/GoWebUtilities/middleware"
)

// Create server with built-in logging middleware
cfg := config.ServerConfig{
    Server: []config.BaseServerOption{
        config.WithPort(8080),
        config.WithMiddleware(logging.New(logger)),
        config.WithLogger(logger).AsServerOption(),
    },
}

srv, err := server.New(nil, postsFS, cfg)

Custom middleware can be added following the standard pattern:

func customMiddleware(h http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        // Pre-request logic
        h.ServeHTTP(w, r)
        // Post-request logic
    })
}

cfg := config.ServerConfig{
    Server: []config.BaseServerOption{
        config.WithMiddleware(
            logging.New(logger),     // Built-in
            customMiddleware,        // Custom
        ),
    },
}

Middleware are applied in order: the first middleware in the list is executed first (outermost wrapper). The middleware chain is reapplied whenever the handler is refreshed via UpdatePosts().

Any middleware compatible with the standard http.Handler interface can be used, including third-party middleware packages following the func(http.Handler) http.Handler pattern.

Live Content Updates

Update blog content while server is running:

// Load updated posts
updatedFS := os.DirFS("posts/")

// Atomically swap in new content
if err := srv.UpdatePosts(updatedFS, context.Background()); err != nil {
    log.Printf("Update failed: %v", err)
}

The handler swap is atomic - in-flight requests complete with the old handler while new requests immediately see the updated content.

For automatic filesystem-triggered reloads, use pkg/watcher alongside UpdatePosts. The watcher watches a directory tree for changes and invokes a callback (debounced) on each change:

postsPath := "posts/"
w, err := watcher.New(postsPath, config.WithLogger(logger).AsWatcherOption())
if err != nil {
    log.Fatal(err)
}
go w.Run(ctx, func(ctx context.Context) {
    srv.UpdatePosts(os.DirFS(postsPath), ctx)
})

Health Checks

Enable health-check endpoints via config.WithHealthChecks():

cfg := config.ServerConfig{
    Server: []config.BaseServerOption{
        config.WithPort(8080),
        config.WithHealthChecks(),
    },
}
srv, err := server.New(nil, postsFS, cfg)

Three unauthenticated GET endpoints are exposed:

  • /healthz/live — always 200 OK ("ok"); confirms the process is alive.
  • /healthz/ready — 200 OK once posts and templates have loaded; 503 while starting up or if loading failed (body includes the reason).
  • /healthz/startup — same semantics as /healthz/ready; used as the startup probe in Kubernetes deployments.

When health checks are enabled the server binds the HTTP listener before loading posts, so probes can observe startup state. The endpoints bypass middleware (including authentication) and are intercepted in ServeHTTP before the content handler. The Docker image enables health checks by default.

Concurrency

All Server methods are safe for concurrent use by multiple goroutines. The handler is stored in an atomic.Value, providing lock-free reads during request serving and atomic writes during updates. This design supports high-concurrency request handling without lock contention.

Server implements http.Handler interface, delegating to the current handler via atomic load operations.

Index

Examples

Constants

This section is empty.

Variables

This section is empty.

Functions

func Handler deprecated

func Handler(blog *generator.GeneratedBlog, logger *slog.Logger, opts ...config.BaseOption) http.Handler

Handler creates an HTTP handler that serves the generated blog content. It accepts a GeneratedBlog and optional configuration options to customize the handler behavior, such as setting a custom blog root path or logger.

The handler serves the following routes (assuming default root "/"):

  • GET / and GET /posts - serves the blog index page
  • GET /posts/{postName} - serves individual blog posts
  • GET /tags - serves the tags index page (only if blog.TagsIndex is non-empty)
  • GET /tags/{tagName} - serves tag-specific pages (only if blog.Tags is non-empty)
  • GET /rss.xml - serves the site-wide RSS 2.0 feed (404 when feeds are disabled)
  • GET /atom.xml - serves the site-wide Atom feed (404 when feeds are disabled)
  • GET /tags/{tagName}.rss.xml - serves a per-tag RSS 2.0 feed (only if blog.Tags is non-empty)
  • GET /tags/{tagName}.atom.xml - serves a per-tag Atom feed (only if blog.Tags is non-empty)

Tag routes are registered only when the blog contains tag content. When the generator is configured with config.WithDisableTags(), blog.Tags and blog.TagsIndex will be empty and the tag routes will not be registered.

Feed routes are always registered, but return 404 when the generator did not produce feed content (i.e. when config.WithBaseURL was not set, or config.WithDisableFeeds was applied).

The returned handler automatically strips .html suffixes from incoming request paths via middleware.NewStripHTMLExtension, so both /posts/foo and /posts/foo.html are routed to the same handler.

The handler is safe for concurrent use by multiple goroutines. It does not modify the GeneratedBlog instance.

Logger

Supply a logger via config.WithLogger in opts:

h := server.Handler(blog, nil, config.WithLogger(myLogger), config.WithBlogRoot("/blog/"))

Deprecated: the positional logger parameter will be removed in v3.0.0. Pass nil and supply the logger via config.WithLogger in opts instead. When both are provided, the config.WithLogger option takes precedence.

Types

type HandlerConfig

type HandlerConfig struct {
	config.BlogRoot
	config.Logger
}

HandlerConfig holds configuration options for the blog HTTP handler. It embeds config.BlogRoot to specify the root path where the blog is served.

type Server

type Server struct {
	config.BlogRoot
	config.Port
	config.Host
	config.Logger
	config.CacheControlTTL
	config.HealthChecks
	// contains filtered or unexported fields
}

Server is an HTTP server that serves generated blog content with support for live content updates.

The server uses atomic.Value to store its HTTP handler, enabling thread-safe handler hot-swapping without locks. This allows blog content to be regenerated and swapped in while serving requests, without dropping connections or requiring server restart.

Server implements http.Handler interface, delegating requests to the current handler loaded atomically.

When health checks are enabled via config.WithHealthChecks, the server binds the HTTP listener before loading posts and templates. The three health-check endpoints (/healthz/live, /healthz/ready, /healthz/startup) are intercepted before the middleware stack and always available without auth.

All methods are safe for concurrent use by multiple goroutines.

Example (WithMiddleware)

ExampleServer_withMiddleware demonstrates using middleware with the server.

package main

import (
	"log/slog"
	"net/http"
	"os"
	"testing/fstest"

	"github.com/harrydayexe/GoBlog/v2/pkg/config"
	"github.com/harrydayexe/GoBlog/v2/pkg/server"
)

func main() {
	logger := slog.New(slog.NewTextHandler(os.Stdout, nil))
	postsFS := fstest.MapFS{
		"post1.md": &fstest.MapFile{
			Data: []byte("---\ntitle: Test Post\ndescription: A test post\ndate: 2024-01-01\n---\nContent"),
		},
	}

	// Custom middleware that adds a header
	customMiddleware := func(h http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			w.Header().Set("X-Powered-By", "GoBlog")
			h.ServeHTTP(w, r)
		})
	}

	cfg := config.ServerConfig{
		Server: []config.BaseServerOption{
			config.WithPort(8080),
			config.WithMiddleware(customMiddleware),
		},
	}

	srv, err := server.New(logger, postsFS, cfg)
	if err != nil {
		logger.Error("failed to create server", "error", err)
		return
	}

	// Server is ready with middleware applied
	_ = srv
}

func New deprecated

func New(logger *slog.Logger, posts fs.FS, opts config.ServerConfig) (*Server, error)

New creates a new Server instance with the specified configuration.

The posts filesystem contains the markdown blog posts to be served. The opts parameter configures server behavior via the functional options pattern.

When health checks are disabled (the default), New initializes the server's HTTP handler synchronously by generating blog content from the posts filesystem. If initial generation fails, an error is returned and the server is not started.

When health checks are enabled via config.WithHealthChecks, New skips content generation and returns immediately. The HTTP handler is initialized asynchronously when [Run] is called, so probes can observe the startup state via /healthz/ready and /healthz/startup. In this mode New does not return an error on content-loading failures; instead, the failure is surfaced through the /healthz/ready endpoint.

Returns an error if template rendering or initial blog generation fails (only in the synchronous / health-checks-disabled path).

Logger

Supply a logger via config.WithLogger in cfg.Server:

cfg.Server = append(cfg.Server, config.WithLogger(myLogger).AsServerOption())

Deprecated: the positional logger parameter will be removed in v3.0.0. Pass nil and supply the logger via config.WithLogger in cfg.Server instead. When both are provided, the config.WithLogger option takes precedence.

func (*Server) Run

func (s *Server) Run(ctx context.Context) error

Run starts the HTTP server and blocks until interrupted via context cancellation or OS signal (SIGINT, SIGTERM, SIGHUP). It handles graceful shutdown with a 10-second timeout.

When health checks are enabled, Run launches content initialisation in a background goroutine so that the HTTP listener is available immediately for probe traffic. The /healthz/ready and /healthz/startup endpoints return 503 until initialisation completes (or 503 with a reason if it fails).

The server uses atomic handler swapping, allowing UpdatePosts to be called while the server is running without interrupting in-flight requests.

Run is safe for concurrent use, though typically only called once per Server. It captures OS signals and initiates graceful shutdown.

Returns an error if the server fails to bind or listen. Shutdown errors are logged to stderr but don't prevent clean exit.

func (*Server) ServeHTTP

func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request)

ServeHTTP implements http.Handler, delegating requests to the current handler. The handler is loaded atomically, allowing it to be safely updated via UpdatePosts while requests are being served.

When health checks are enabled, ServeHTTP intercepts requests to /healthz/live, /healthz/ready, and /healthz/startup before the middleware stack, so they are always reachable without authentication and during async startup.

ServeHTTP is safe for concurrent use by multiple goroutines. It performs a lock-free atomic load of the current handler on each request.

If the handler has not been initialized (nil), ServeHTTP returns a 503 Service Unavailable error.

func (*Server) UpdatePosts

func (s *Server) UpdatePosts(posts fs.FS, ctx context.Context) error

UpdatePosts updates the posts directory and refreshes the HTTP handler with the new content. This triggers a complete regeneration of the blog and an atomic swap of the HTTP handler.

UpdatePosts is safe to call while the server is running and serving requests. The handler swap is atomic, ensuring that requests see either the old or new content without any intermediate inconsistent state.

If the server has not yet completed its initial content load (health-checks async path), UpdatePosts returns an error immediately rather than racing with the initialisation goroutine.

If handler refresh fails, an error is returned and the previous handler remains active, continuing to serve the old content.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL