Install
$ agentstack add mcp-i2y-dark ✓ scanned · ✓ verified — works with Claude Code, Cursor, and more.
Security review
✓ PassedNo issues found. Passed automated security review. · v0.1.0 How review works →
- ✓ Prompt-injection patterns
- ✓ Secret / credential exfiltration
- ✓ Dangerous shell & filesystem operations
- ✓ Untrusted network calls
- ✓ Known-malicious package signatures
What it can access
- ● Network access Used
- ✓ Filesystem access No
- ✓ Shell / process execution No
- ✓ Environment & secrets No
- ✓ Dynamic code execution No
From automated source analysis of v0.1.0. “Used” means the capability is present in the source — more access means more to trust, not that it’s unsafe.
About
Dark
A Go SSR web framework powered by Preact or React, 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-NG (-tags qjswasm) | |---|---|---| | Engine | Apple JavaScriptCore via purego | QuickJS-NG compiled to WebAssembly, driven by wazero (pure Go) | | JIT | Yes | wazero compiler-mode (AOT WASM→native) | | Platforms | macOS, Linux | macOS, Linux, Windows, FreeBSD | | 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-NG backend (qjswasm)
go build -tags qjswasm .
No shared libraries needed. Works on all platforms including Windows. Trade-off: slower than JSC's JIT, but the wazero compiler-mode AOT path closes much of the gap. For most apps where the bottleneck is I/O (database, network), the difference is negligible.
Built on net/http
Dark follows standard net/http conventions. There are no external router dependencies.
- Internal routing uses
http.NewServeMuxwith Go 1.22+ enhanced patterns (GET /users/{id}) app.Handler()returns(http.Handler, error)— plug it into any Go HTTP stack- Middleware is the standard
func(http.Handler) http.Handlersignature - Dark does not own the server — you start it yourself with
http.ListenAndServeorhttp.Server
// Simple — MustHandler() panics on error (convenient for main)
http.ListenAndServe(":3000", app.MustHandler())
// With error handling
handler, err := app.Handler()
if err != nil {
log.Fatal(err)
}
http.ListenAndServe(":3000", handler)
// With http.Server for full control
srv := &http.Server{
Addr: ":8080",
Handler: app.MustHandler(),
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 or React
renderToStringin 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
- Authentication —
RequireAuthmiddleware with htmx-aware redirects - Head management — per-page `
,`, and OpenGraph tags - API routes — JSON endpoints alongside page routes
- Dev mode — hot reload, error overlay with source maps, TypeScript type generation
- SSR caching — LRU in-memory cache with ETag / 304 Not Modified
- CSRF protection — session-based tokens with automatic htmx/TSX integration
- Concurrent loaders — parallel data fetching with result merging
- Embedded views — load TSX files from
embed.FSfor single-binary deployment - StaticFS — serve static assets from any
fs.FS(embed.FS, os.DirFS) - JSX automatic transform — no
import { h }orimport Reactboilerplate needed - Desktop apps — native window via WebView with Go↔JS bindings and bidirectional events
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.MustHandler()))
}
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
export default function Layout({ children }) {
return (
My App
{children}
);
}
// views/pages/index.tsx
export default function IndexPage({ message }) {
return {message};
}
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 (single)
Loaders: []dark.LoaderFunc{...}, // concurrent data fetching (merged)
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
// Request-scoped values (set by middleware, read by loaders)
ctx.Set("key", value)
ctx.Get("key") any
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"
}),
))
CSRF Protection
Session-based CSRF tokens with automatic htmx integration:
app.Use(dark.Sessions(secret))
app.Use(dark.CSRF())
The middleware automatically:
- Generates a per-session token
- Injects `
into` - Injects an htmx config script that attaches
X-CSRF-Tokento all htmx requests - Adds
_csrfTokento Loader props (use in hidden form fields) - Validates
X-CSRF-Tokenheader or_csrfform field on POST/PUT/DELETE/PATCH
export default function Form({ _csrfToken }) {
return (
Submit
);
}
htmx forms require no extra setup — the token header is attached automatically.
Concurrent Loaders
Fetch data from multiple sources in parallel:
app.Get("/dashboard", dark.Route{
Component: "pages/dashboard.tsx",
Loaders: []dark.LoaderFunc{
func(ctx dark.Context) (any, error) {
return map[string]any{"user": fetchUser(ctx.Param("id"))}, nil
},
func(ctx dark.Context) (any, error) {
return map[string]any{"activity": fetchActivity(ctx.Param("id"))}, nil
},
func(ctx dark.Context) (any, error) {
return map[string]any{"notifications": fetchNotifications()}, nil
},
},
})
Results are merged into a single props map. If any loader returns an error, the request fails immediately.
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")
Island components are plain Preact/React components with a default export:
// views/islands/counter.tsx
import { useState } from "preact/hooks";
export default function Counter({ initial = 0 }) {
const [count, setCount] = useState(initial);
return setCount(count + 1)}>Count: {count};
}
In a page, wrap with island() from the dark module to mark it for client-side hydration:
// views/pages/index.tsx
import { island } from "dark";
import Counter from "../islands/counter.tsx";
const InteractiveCounter = island("counter", Counter);
// Lazy loading strategies:
// island("counter", Counter, { load: "idle" }) — requestIdleCallback
// island("counter", Counter, { load: "visible" }) — IntersectionObserver
export default function Page() {
return (
My Page
);
}
Static Files
Serve from a directory on disk:
app.Static("/static/", "public")
Or from an fs.FS (embed.FS, os.DirFS, etc.):
//go:embed public
var publicFS embed.FS
sub, _ := fs.Sub(publicFS, "public")
app.StaticFS("/static/", sub)
Options
dark.New(
dark.WithPoolSize(4), // ramune RuntimePool workers (default: runtime.NumCPU())
dark.WithTemplateDir("views"), // TSX file directory (default: "views")
dark.WithViewsFS(viewsFS), // load views from fs.FS (for embed.FS)
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), // LRU SSR output cache (enables ETag)
dark.WithLogger(slog.Default()), // structured logger for framework internals
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
React Support
Dark defaults to Preact but also supports React. Pass WithUILibrary(dark.React) to switch:
app, err := dark.New(
dark.WithUILibrary(dark.React),
dark.WithLayout("layouts/default.tsx"),
dark.WithTemplateDir("views"),
)
Components are written the same way — no framework-specific imports needed:
// views/pages/index.tsx
export default function IndexPage({ message }) {
return {message};
}
Islands use React hooks directly:
// views/islands/counter.tsx
import { useState } from 'react';
export default function Counter({ initial }) {
const [count, setCount] = useState(initial || 0);
return setCount(c => c + 1)}>{count};
}
MCP Apps also support React via WithMCPUILibrary(dark.React).
MCP Apps (experimental)
> Note: This feature has not been fully tested yet. The API may change.
Dark supports MCP Apps — interactive HTML UIs returned by MCP tools, rendered inside host sandboxed iframes. Built on the official Go SDK github.com/modelcontextprotocol/go-sdk.
mcpApp, err := dark.NewMCPApp("my-server", "1.0.0",
dark.WithMCPTemplateDir("views"),
)
defer mcpApp.Close()
// UI tool: returns an interactive TSX component
if err := dark.AddUITool(mcpApp, "dashboard", dark.UIToolDef{
Description: "Show analytics dashboard",
Component: "mcp/dashboard.tsx",
}, func(ctx context.Context, args DashboardArgs) (map[string]any, error) {
return map[string]any{"data": fetchData(args.Period)}, nil
}); err != nil {
log.Fatal(err)
}
// Text tool: standard MCP tool returning plain text
dark.AddTextTool(mcpApp, "stats", "Get statistics",
func(ctx context.Context, args StatsArgs) (string, error) {
return "Stats: ...", nil
})
mcpApp.RunStdio(ctx) // stdio transport
// or
mcpApp.StreamableHTTPHandler() // HTTP transport
The server declares the io.modelcontextprotocol/ui extension and registers each UI tool as a resource (ui://{server}/{tool}.html). Rendering pipeline:
- At registration: esbuild bundles the component with UI library inlined → static app shell HTML
- On tool call: Go handler returns props → sent as JSON text in the tool result
- Host reads the resource → renders the HTML in a sandboxed iframe
- MCP App Bridge (postMessage JSON-RPC, protocol
2026-01-26) delivers tool results to the iframe - Component renders client-side with the received props
Example: [examples/mcp-app/](examples/mcp
…
Source & license
This open-source MCP server is cataloged on AgentStack and links to its original source — we do not rehost the code.
Install and usage instructions live in the source repository linked above.
Reviews
No reviews yet — be the first.
Write a review
Versions
- v0.1.0 Imported from the upstream source.