dark

package module
v0.1.0 Latest Latest
Warning

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

Go to latest
Published: Apr 2, 2026 License: MIT Imports: 26 Imported by: 0

README

Dark

A Go SSR web framework powered by Preact, htmx, and Islands architecture.

Dark renders TSX components on the server using ramune (a JS/TS runtime for Go), fetches data with Go Loader/Action functions, and delivers interactive pages through htmx's HTML-over-the-wire approach with minimal client-side JavaScript.

Requirements

Dark uses ramune for SSR, which supports two JS engine backends:

JSC (default) QuickJS (-tags quickjs)
Engine Apple JavaScriptCore via purego modernc.org/quickjs (pure Go)
JIT Yes No
Platforms macOS, Linux macOS, Linux, Windows
System deps macOS: none. Linux: apt install libjavascriptcoregtk-4.1-dev None
Best for Production performance Portability, zero-dependency deploys

Both are pure Go builds -- no C compiler or Cgo required.

Default (JavaScriptCore)
# macOS — no extra dependencies
go build .

# Linux
sudo apt install libjavascriptcoregtk-4.1-dev
go build .
QuickJS backend
go build -tags quickjs .

No shared libraries needed. Works on all platforms including Windows. Trade-off: no JIT, so JS execution is slower (SSR render time increases). For most apps where the bottleneck is I/O (database, network), this is negligible.

Built on net/http

Dark follows standard net/http conventions. There are no external router dependencies.

  • Internal routing uses http.NewServeMux with Go 1.22+ enhanced patterns (GET /users/{id})
  • app.Handler() returns an http.Handler — plug it into any Go HTTP stack
  • Middleware is the standard func(http.Handler) http.Handler signature
  • Dark does not own the server — you start it yourself with http.ListenAndServe or http.Server
// Simple
http.ListenAndServe(":3000", app.Handler())

// With http.Server for full control
srv := &http.Server{
    Addr:         ":8080",
    Handler:      app.Handler(),
    ReadTimeout:  5 * time.Second,
    WriteTimeout: 10 * time.Second,
}
srv.ListenAndServe()

Any existing net/http middleware works with app.Use() out of the box.

Features

  • Server-side rendering — TSX templates rendered via Preact renderToString in a sandboxed JS runtime
  • Loader/Action pattern — Go functions for data fetching and mutations, props passed as JSON
  • htmx integration — HX-Request aware responses (full page vs HTML fragment)
  • Islands architecture — selective client-side hydration with lazy loading (load, idle, visible)
  • Streaming SSR — shell-first rendering for faster TTFB
  • Nested layouts — composable layouts via route groups
  • Form validation — field-level errors with form data preservation
  • Sessions — HMAC-signed cookie sessions with flash messages
  • AuthenticationRequireAuth middleware with htmx-aware redirects
  • Head management — per-page <title>, <meta>, and OpenGraph tags
  • API routes — JSON endpoints alongside page routes
  • Dev mode — hot reload, error overlay with source maps, TypeScript type generation
  • SSR caching — optional in-memory cache for rendered output

Quick Start

package main

import (
    "log"
    "net/http"

    "github.com/i2y/dark"
)

func main() {
    app, err := dark.New(
        dark.WithLayout("layouts/default.tsx"),
        dark.WithTemplateDir("views"),
        dark.WithDevMode(true),
    )
    if err != nil {
        log.Fatal(err)
    }
    defer app.Close()

    app.Use(dark.Logger())
    app.Use(dark.Recover())

    app.Get("/", dark.Route{
        Component: "pages/index.tsx",
        Loader: func(ctx dark.Context) (any, error) {
            return map[string]any{"message": "Hello, Dark!"}, nil
        },
    })

    log.Fatal(http.ListenAndServe(":3000", app.Handler()))
}

The layout wraps every page. Each page component's output is passed as children. On htmx requests (HX-Request header), the layout is skipped and only the page fragment is returned.

// views/layouts/default.tsx
import { h } from "preact"; // required — JSX transpiles to h() calls

export default function Layout({ children }) {
  return (
    <html lang="en">
      <head>
        <meta charset="UTF-8" />
        <title>My App</title>
        <script src="https://unpkg.com/htmx.org@2.0.4"></script>
      </head>
      <body>{children}</body>
    </html>
  );
}
// views/pages/index.tsx
import { h } from "preact"; // required — JSX transpiles to h() calls

export default function IndexPage({ message }) {
  return <h1>{message}</h1>;
}
go run main.go
# => Listening on http://localhost:3000

Routing

Routes use Go 1.22+ ServeMux patterns with {param} wildcards.

app.Get("/", dark.Route{...})
app.Get("/users/{id}", dark.Route{...})
app.Post("/users/{id}/orders", dark.Route{...})
app.Put("/posts/{id}", dark.Route{...})
app.Delete("/posts/{id}", dark.Route{...})
app.Patch("/settings", dark.Route{...})
Route struct
dark.Route{
    Component: "pages/show.tsx",   // TSX file (relative to template dir)
    Loader:    loaderFunc,          // data fetching (GET)
    Action:    actionFunc,          // mutations (POST/PUT/DELETE)
    Layout:    "layouts/extra.tsx", // per-route layout (nests inside global layout)
    Streaming: &boolVal,            // per-route streaming SSR override
    Props:     MyProps{},           // zero value for TypeScript type generation
}
API routes

JSON endpoints that bypass the TSX rendering pipeline:

app.APIGet("/api/status", dark.APIRoute{
    Handler: func(ctx dark.Context) error {
        return ctx.JSON(200, map[string]any{"status": "ok"})
    },
})

app.APIPost("/api/items", dark.APIRoute{
    Handler: func(ctx dark.Context) error {
        var input CreateItemRequest
        if err := ctx.BindJSON(&input); err != nil {
            return dark.NewAPIError(400, "invalid JSON")
        }
        // ...
        return ctx.JSON(201, item)
    },
})

Route Groups

Groups share a URL prefix, layout, and middleware:

app.Group("/admin", "layouts/admin.tsx", func(g *dark.Group) {
    g.Use(dark.RequireAuth())

    g.Get("/dashboard", dark.Route{
        Component: "pages/admin/dashboard.tsx",
        Loader:    dashboardLoader,
    })

    // Nested groups compose layouts
    g.Group("/settings", "layouts/settings.tsx", func(sg *dark.Group) {
        sg.Get("/profile", dark.Route{...})
    })
})

Context

dark.Context wraps the request and response:

ctx.Request() *http.Request
ctx.ResponseWriter() http.ResponseWriter
ctx.Param("id") string              // path parameter ({id})
ctx.Query("page") string            // query string
ctx.FormData() url.Values           // parsed form data
ctx.Redirect("/path") error         // redirect (htmx-aware)
ctx.SetHeader("X-Custom", "value")

// JSON
ctx.JSON(200, data) error
ctx.BindJSON(&input) error

// Validation
ctx.AddFieldError("email", "required")
ctx.HasErrors() bool
ctx.FieldErrors() []FieldError

// Head
ctx.SetTitle("Page Title")
ctx.AddMeta("description", "...")
ctx.AddOpenGraph("og:image", "...")

// Cookies
ctx.SetCookie("theme", "dark", dark.CookieMaxAge(86400))
ctx.GetCookie("theme") (string, error)
ctx.DeleteCookie("theme")

// Session (requires Sessions middleware)
ctx.Session() *Session

Sessions

HMAC-SHA256 signed cookie sessions:

app.Use(dark.Sessions([]byte("secret-key-at-least-32-bytes"),
    dark.SessionName("app_session"),
    dark.SessionMaxAge(86400),
    dark.SessionSecure(true),
))
// In a Loader/Action:
sess := ctx.Session()
sess.Set("user", username)
sess.Get("user")           // returns any
sess.Delete("user")
sess.Clear()

// Flash messages (available for one request)
sess.Flash("notice", "Saved!")
flashes := sess.Flashes()  // map[string]any

Authentication

// Basic usage — checks session key "user", redirects to "/login"
g.Use(dark.RequireAuth())

// Custom options
g.Use(dark.RequireAuth(
    dark.AuthSessionKey("account"),
    dark.AuthLoginURL("/auth/signin"),
    dark.AuthCheck(func(s *dark.Session) bool {
        return s.Get("role") == "admin"
    }),
))

Middleware

Standard func(http.Handler) http.Handler:

app.Use(dark.Logger())                 // request logging
app.Use(dark.Recover())                // panic recovery → 500
app.Use(app.RecoverWithErrorPage())    // panic recovery → custom error page
app.Use(dark.Sessions(secret))         // session management

Any existing net/http middleware works:

app.Use(func(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        w.Header().Set("X-Frame-Options", "DENY")
        next.ServeHTTP(w, r)
    })
})

Islands Architecture

Register interactive components for client-side hydration:

app.Island("counter", "islands/counter.tsx")
// views/islands/counter.tsx
import { h } from "preact";
import { useState } from "preact/hooks";

function Counter({ initial = 0 }) {
  const [count, setCount] = useState(initial);
  return <button onClick={() => setCount(count + 1)}>Count: {count}</button>;
}

// Wrap with dark.island() — loaded immediately by default
export default dark.island("counter", Counter);

// Lazy loading strategies:
// dark.island("counter", Counter, { load: "idle" })    — requestIdleCallback
// dark.island("counter", Counter, { load: "visible" }) — IntersectionObserver

Use in any page TSX:

import Counter from "../islands/counter.tsx";

export default function Page() {
  return (
    <div>
      <h1>My Page</h1>
      <Counter initial={5} />
    </div>
  );
}

Static Files

app.Static("/static/", "public")

Options

dark.New(
    dark.WithPoolSize(4),                    // ramune RuntimePool workers (default: runtime.NumCPU())
    dark.WithTemplateDir("views"),           // TSX file directory (default: "views")
    dark.WithLayout("layouts/default.tsx"),   // global layout
    dark.WithDependencies("lodash"),          // npm packages (preact is always included)
    dark.WithDevMode(true),                  // hot reload + error overlay
    dark.WithStreaming(true),                // streaming SSR globally
    dark.WithSSRCache(1000),                 // SSR output cache entries
    dark.WithErrorComponent("errors/500.tsx"),
    dark.WithNotFoundComponent("errors/404.tsx"),
)

Project Structure

myapp/
├── main.go
├── views/
│   ├── layouts/
│   │   └── default.tsx
│   ├── pages/
│   │   ├── index.tsx
│   │   └── users/
│   │       └── show.tsx
│   ├── islands/
│   │   └── counter.tsx
│   └── errors/
│       ├── 404.tsx
│       └── 500.tsx
└── public/
    └── style.css

Examples

  • hello — feature-rich demo: routing, layouts, sessions, islands, streaming SSR, form validation
  • database — SQLite CRUD with sessions and authentication
  • deploy — production setup with Dockerfile and Fly.io config

Deploy

See _examples/deploy for a production-ready setup with Docker multi-stage build and Fly.io configuration.

License

MIT

Documentation

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

This section is empty.

Types

type APIError

type APIError struct {
	Status  int
	Message string
}

APIError represents an error with an HTTP status code for API responses.

func NewAPIError

func NewAPIError(status int, message string) *APIError

NewAPIError creates an APIError with the given status code and message.

func (*APIError) Error

func (e *APIError) Error() string

type APIRoute

type APIRoute struct {
	Handler HandlerFunc
}

APIRoute defines a handler for a JSON API endpoint.

type ActionFunc

type ActionFunc func(ctx Context) error

ActionFunc handles mutations (e.g., form submissions).

type App

type App struct {
	// contains filtered or unexported fields
}

App is the main dark application.

func New

func New(opts ...Option) (*App, error)

New creates a new dark application.

func (*App) API

func (app *App) API(method, pattern string, route APIRoute)

API registers an API route for the given HTTP method and pattern.

func (*App) APIDelete

func (app *App) APIDelete(pattern string, route APIRoute)

APIDelete registers an API route for DELETE requests.

func (*App) APIGet

func (app *App) APIGet(pattern string, route APIRoute)

APIGet registers an API route for GET requests.

func (*App) APIPatch

func (app *App) APIPatch(pattern string, route APIRoute)

APIPatch registers an API route for PATCH requests.

func (*App) APIPost

func (app *App) APIPost(pattern string, route APIRoute)

APIPost registers an API route for POST requests.

func (*App) APIPut

func (app *App) APIPut(pattern string, route APIRoute)

APIPut registers an API route for PUT requests.

func (*App) Close

func (app *App) Close() error

Close releases all resources held by the application.

func (*App) Delete

func (app *App) Delete(pattern string, route Route)

Delete registers a page route for DELETE requests.

func (*App) GenerateTypes

func (app *App) GenerateTypes() error

GenerateTypes generates TypeScript type definitions from Props fields on registered routes. Output is written to <templateDir>/_generated/props.d.ts.

func (*App) Get

func (app *App) Get(pattern string, route Route)

Get registers a page route for GET requests.

func (*App) Group

func (app *App) Group(prefix, layout string, fn func(g *Group))

Group creates a route group with a shared URL prefix and layout. All routes registered within the group inherit the layout. Nested groups compose layouts from outer to inner.

func (*App) Handler

func (app *App) Handler() http.Handler

Handler returns the application as an http.Handler with middleware applied.

func (*App) Island

func (app *App) Island(name, tsxPath string)

Island registers a component for client-side hydration.

func (*App) Patch

func (app *App) Patch(pattern string, route Route)

Patch registers a page route for PATCH requests.

func (*App) Post

func (app *App) Post(pattern string, route Route)

Post registers a page route for POST requests.

func (*App) Put

func (app *App) Put(pattern string, route Route)

Put registers a page route for PUT requests.

func (*App) RecoverWithErrorPage

func (app *App) RecoverWithErrorPage() MiddlewareFunc

RecoverWithErrorPage returns a middleware that recovers from panics and renders the configured error page. Use this instead of Recover() to get custom error pages.

func (*App) Static

func (app *App) Static(urlPrefix, dir string)

Static registers a static file server for the given URL prefix and directory.

func (*App) Use

func (app *App) Use(mw MiddlewareFunc)

Use adds a middleware to the application.

type AuthOption

type AuthOption func(*authConfig)

AuthOption configures the RequireAuth middleware.

func AuthCheck

func AuthCheck(fn func(*Session) bool) AuthOption

AuthCheck sets a custom function to determine if a session is authenticated. When set, this overrides the default session key check.

func AuthLoginURL

func AuthLoginURL(url string) AuthOption

AuthLoginURL sets the URL to redirect unauthenticated users to (default "/login").

func AuthSessionKey

func AuthSessionKey(key string) AuthOption

AuthSessionKey sets the session key to check for authentication (default "user").

type Context

type Context interface {
	Request() *http.Request
	ResponseWriter() http.ResponseWriter
	Param(name string) string
	Query(name string) string
	FormData() url.Values
	Redirect(url string) error
	RenderError(err error) error
	SetHeader(key, value string)
	JSON(status int, data any) error
	BindJSON(v any) error
	AddFieldError(field, message string)
	HasErrors() bool
	FieldErrors() []FieldError
	SetTitle(title string)
	AddMeta(name, content string)
	AddOpenGraph(property, content string)
	SetCookie(name, value string, opts ...CookieOption)
	GetCookie(name string) (string, error)
	DeleteCookie(name string)
	Session() *Session
}

Context provides access to the request, response, and route parameters.

type CookieOption

type CookieOption func(*cookieConfig)

CookieOption configures a cookie set via Context.SetCookie.

func CookieHTTPOnly

func CookieHTTPOnly(enabled bool) CookieOption

CookieHTTPOnly sets the HttpOnly flag (default true).

func CookieMaxAge

func CookieMaxAge(seconds int) CookieOption

CookieMaxAge sets the cookie max age in seconds. 0 means session cookie.

func CookiePath

func CookiePath(path string) CookieOption

CookiePath sets the cookie path (default "/").

func CookieSameSite

func CookieSameSite(mode http.SameSite) CookieOption

CookieSameSite sets the SameSite attribute (default Lax).

func CookieSecure

func CookieSecure(enabled bool) CookieOption

CookieSecure sets the Secure flag.

type FieldError

type FieldError struct {
	Field   string `json:"field"`
	Message string `json:"message"`
}

FieldError represents a validation error for a specific form field.

type Group

type Group struct {
	// contains filtered or unexported fields
}

Group defines a set of routes that share a common URL prefix, layout, and middleware.

func (*Group) API

func (g *Group) API(method, pattern string, route APIRoute)

API registers an API route within the group.

func (*Group) APIDelete

func (g *Group) APIDelete(pattern string, route APIRoute)

APIDelete registers an API route for DELETE requests within the group.

func (*Group) APIGet

func (g *Group) APIGet(pattern string, route APIRoute)

APIGet registers an API route for GET requests within the group.

func (*Group) APIPatch

func (g *Group) APIPatch(pattern string, route APIRoute)

APIPatch registers an API route for PATCH requests within the group.

func (*Group) APIPost

func (g *Group) APIPost(pattern string, route APIRoute)

APIPost registers an API route for POST requests within the group.

func (*Group) APIPut

func (g *Group) APIPut(pattern string, route APIRoute)

APIPut registers an API route for PUT requests within the group.

func (*Group) Delete

func (g *Group) Delete(pattern string, route Route)

Delete registers a page route for DELETE requests within the group.

func (*Group) Get

func (g *Group) Get(pattern string, route Route)

Get registers a page route for GET requests within the group.

func (*Group) Group

func (g *Group) Group(prefix, layout string, fn func(g *Group))

Group creates a nested group within this group.

func (*Group) Patch

func (g *Group) Patch(pattern string, route Route)

Patch registers a page route for PATCH requests within the group.

func (*Group) Post

func (g *Group) Post(pattern string, route Route)

Post registers a page route for POST requests within the group.

func (*Group) Put

func (g *Group) Put(pattern string, route Route)

Put registers a page route for PUT requests within the group.

func (*Group) Use

func (g *Group) Use(mw MiddlewareFunc)

Use adds a middleware to the group. It applies only to routes in this group and any nested groups.

type HandlerFunc

type HandlerFunc func(ctx Context) error

HandlerFunc handles an API request.

type HeadData

type HeadData struct {
	Title string    `json:"title,omitempty"`
	Meta  []MetaTag `json:"meta,omitempty"`
}

HeadData holds metadata for the HTML <head> section.

type LoaderFunc

type LoaderFunc func(ctx Context) (any, error)

LoaderFunc fetches data for a route. The returned value is passed as props to the TSX component.

type MetaTag

type MetaTag struct {
	Name     string `json:"name,omitempty"`
	Property string `json:"property,omitempty"`
	Content  string `json:"content"`
}

MetaTag represents a <meta> element.

type MiddlewareFunc

type MiddlewareFunc func(http.Handler) http.Handler

MiddlewareFunc is a standard Go HTTP middleware.

func Logger

func Logger() MiddlewareFunc

Logger returns a middleware that logs each request.

func Recover

func Recover() MiddlewareFunc

Recover returns a middleware that recovers from panics and returns a 500 response.

func RequireAuth

func RequireAuth(opts ...AuthOption) MiddlewareFunc

RequireAuth returns a middleware that redirects unauthenticated users to the login page. It requires the Sessions middleware to be applied first.

Usage:

app.Group("/admin", "layouts/admin.tsx", func(g *dark.Group) {
    g.Use(dark.RequireAuth())
    g.Get("/dashboard", dark.Route{...})
})

func Sessions

func Sessions(secret []byte, opts ...SessionOption) MiddlewareFunc

Sessions returns a middleware that provides cookie-based sessions with HMAC signing. The secret is used to sign and verify session cookies.

type Option

type Option func(*config)

Option configures the dark application.

func WithDependencies

func WithDependencies(pkgs ...string) Option

WithDependencies adds additional npm dependencies beyond preact.

func WithDevMode

func WithDevMode(enabled bool) Option

WithDevMode enables development mode with cache invalidation on file changes.

func WithErrorComponent

func WithErrorComponent(file string) Option

WithErrorComponent sets a TSX component for rendering 500 error pages.

func WithLayout

func WithLayout(file string) Option

WithLayout sets the layout TSX file path relative to the template directory.

func WithNotFoundComponent

func WithNotFoundComponent(file string) Option

WithNotFoundComponent sets a TSX component for rendering 404 pages.

func WithPoolSize

func WithPoolSize(n int) Option

WithPoolSize sets the number of ramune RuntimePool workers.

func WithSSRCache

func WithSSRCache(maxEntries int) Option

WithSSRCache enables SSR output caching. maxEntries sets the maximum number of cached component+props combinations. When the cache is full, it is cleared. 0 (default) disables caching.

func WithStreaming

func WithStreaming(enabled bool) Option

WithStreaming enables streaming SSR (shell-first rendering for faster TTFB).

func WithTemplateDir

func WithTemplateDir(dir string) Option

WithTemplateDir sets the directory for TSX template files.

type Route

type Route struct {
	Component string     // TSX file path relative to the template directory
	Loader    LoaderFunc // data loader
	Action    ActionFunc // mutation handler
	Layout    string     // layout TSX file path relative to the template directory (nests inside global layout)
	Streaming *bool      // nil = use global default, true/false = per-route override
	Props     any        // optional: Go struct zero value for TypeScript type generation
}

Route defines a handler for a URL pattern with SSR rendering.

type Session

type Session struct {
	// contains filtered or unexported fields
}

Session holds per-request session data backed by a signed cookie.

func (*Session) Clear

func (s *Session) Clear()

Clear removes all session data.

func (*Session) Delete

func (s *Session) Delete(key string)

Delete removes a key from the session.

func (*Session) Flash

func (s *Session) Flash(key string, value any)

Flash sets a flash message that will be available in the next request.

func (*Session) Flashes

func (s *Session) Flashes() map[string]any

Flashes returns and clears all flash messages from the previous request. Returns nil if there are no flashes.

func (*Session) Get

func (s *Session) Get(key string) any

Get returns the session value for key, or nil if not set.

func (*Session) Set

func (s *Session) Set(key string, value any)

Set stores a value in the session.

type SessionOption

type SessionOption func(*sessionConfig)

SessionOption configures the session middleware.

func SessionHTTPOnly

func SessionHTTPOnly(enabled bool) SessionOption

SessionHTTPOnly sets the HttpOnly flag on the session cookie (default true).

func SessionMaxAge

func SessionMaxAge(seconds int) SessionOption

SessionMaxAge sets the session max age in seconds (default 86400 = 1 day).

func SessionName

func SessionName(name string) SessionOption

SessionName sets the session cookie name (default "_dark_session").

func SessionPath

func SessionPath(path string) SessionOption

SessionPath sets the session cookie path (default "/").

func SessionSameSite

func SessionSameSite(mode http.SameSite) SessionOption

SessionSameSite sets the SameSite attribute on the session cookie (default Lax).

func SessionSecure

func SessionSecure(enabled bool) SessionOption

SessionSecure sets the Secure flag on the session cookie.

Directories

Path Synopsis
_examples
database command
Example: dark + SQLite database integration
Example: dark + SQLite database integration
deploy/cmd/server command
Production-ready dark application entry point.
Production-ready dark application entry point.
hello command

Jump to

Keyboard shortcuts

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