gogo

package module
v1.4.0 Latest Latest
Warning

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

Go to latest
Published: Jun 14, 2026 License: Apache-2.0 Imports: 36 Imported by: 0

README

gogo~

A Go HTTP framework built on the uWebSockets C++ HTTP server, designed for low cgo overhead and high concurrency on real-world IO-bound workloads.

go get github.com/Snocko-main/gogo
import gogo "github.com/Snocko-main/gogo"

It is intentionally thin:

  • Go owns route registration and handlers.
  • C++ owns uWebSockets templates, the event loop, and response/request calls.
  • Async dispatch uses a shared-memory ring so the request hot path crosses cgo zero times for shared-mode handlers.

Benchmark Snapshot

gogo single-worker HTTP benchmark throughput

gogo leads every single-worker route in the local HTTP benchmark matrix, including static GETs, parameterized routes, SQLite reads, body echo, and body-parse + SQLite query paths.

Jump to benchmark details

Table of Contents

Requirements and Native Build

The native binding vendors the required uWebSockets/uSockets source inside this module:

internal/native/uwebsockets

Consumers do not need a third_party checkout or a prebuilt uSockets.a; go get github.com/Snocko-main/gogo fetches the native source that cgo compiles with the package.

The release install/build checklist is kept in docs/install-build.md.

To run a real gogo server, the machine building your app needs:

  • Go 1.24 or newer
  • cgo enabled (CGO_ENABLED=1)
  • a C compiler and C++20-capable compiler (clang or gcc/g++)
  • zlib headers/library from the host system

Install those native build dependencies:

# macOS: install Apple Command Line Tools
xcode-select --install

# Debian / Ubuntu
sudo apt-get update
sudo apt-get install -y build-essential zlib1g-dev

# Fedora
sudo dnf install -y gcc gcc-c++ zlib-devel

# Alpine
sudo apk add build-base zlib-dev

On macOS, Command Line Tools is the smallest supported setup; full Xcode also works. Homebrew LLVM/zlib can be used for a custom toolchain, but the Apple SDK and linker still need to come from Command Line Tools or Xcode.

Add gogo to your app:

go get github.com/Snocko-main/gogo@latest

Build, run, or test with the gogo build tag:

CGO_ENABLED=1 go build -tags gogo ./...
CGO_ENABLED=1 go run -tags gogo .
CGO_ENABLED=1 go test -tags gogo ./...

For a production binary:

CGO_ENABLED=1 go build -tags gogo -o my-server .

Without -tags gogo, the package builds a stub and NewApp returns a clear setup error. This keeps normal Go tooling usable, but it will not run a native uWebSockets server. The native build is currently intended for macOS and Linux.

The maintainer-only scripts/bootstrap_uwebsockets.sh script refreshes the vendored source from the pinned uWebSockets commit and reapplies patches/uSockets-kqueue-ready-polls.patch. Override UWEBSOCKETS_REF only when deliberately testing an upstream update.

Then run an example:

CGO_ENABLED=1 go run -tags gogo ./examples/hello
curl http://localhost:3000/hello/inon

Version Policy

gogo is currently on the v0.x public preview line.

  • v0.x: APIs may change, including breaking changes, when the change moves the project closer to a stable v1. Release notes should call out breaking changes and migration steps.
  • v0.9.x: planned release-candidate period. Breaking changes need a specific v1-readiness reason.
  • v1.0.0: routing, middleware, WebSocket, testing, and configuration APIs are expected to be stable except for backward-compatible additions.

Use pinned tags for applications that need repeatable builds:

go get github.com/Snocko-main/gogo@v0.1.0

Release maintainers should follow docs/release-checklist.md, docs/security-checklist.md, and docs/branch-protection.md before cutting public tags.

License

gogo is licensed under the Apache License, Version 2.0. See LICENSE.

Vendored uWebSockets/uSockets native sources retain their upstream Apache-2.0 license notices. See THIRD_PARTY_NOTICES.md.

Hello World

The smallest possible gogo~ server:

package main

import (
    "log"
    gogo "github.com/Snocko-main/gogo"
)

func main() {
    app, err := gogo.NewApp()
    if err != nil {
        log.Fatal(err)
    }
    defer app.Close()

    app.Get("/", func(res *gogo.Response, req *gogo.Request) {
        res.Send(200, "text/plain", "hello, world\n")
    })

    if !app.Listen(3000) {
        log.Fatal("listen :3000 failed")
    }
    log.Println("listening on http://localhost:3000")
    app.Run()
}

Run it:

CGO_ENABLED=1 go run -tags gogo ./yourapp
curl http://localhost:3000/
Static replies (zero cgo per request)

If the response never changes, register a gogo.Reply. When no matching sync middleware is installed and no typed-parameter constraint needs checking, it is served entirely from C++ with no cgo callback per request. If middleware such as auth, CORS, logging, or rate limiting matches the route, or the pattern uses a typed parameter like :id<int>, gogo automatically falls back to the dynamic path so the middleware/constraint still runs:

app.Get("/health", gogo.Reply{
    Status:      200,
    ContentType: "application/json",
    Body:        `{"ok":true}`,
})

Routing

Basic routes

Sync route handlers run on the uWS loop thread and should stay fast:

app.Get("/users", listUsers)
app.Post("/users", createUser)
app.Put("/users/:id", updateUser)
app.Patch("/users/:id", patchUser)
app.Delete("/users/:id", deleteUser)
app.Options("/users", optionsUsers)
app.Head("/users", headUsers)
app.Any("/echo", anyMethod)

Async route handlers run on a goroutine and receive a request snapshot:

app.GetAsync("/users/:id", showUserFromDB)
app.PostAsync("/uploads", 10<<20, uploadFile) // max body bytes, then handler
app.PutAsync("/users/:id", 1<<20, replaceUser)
app.PatchAsync("/users/:id", 1<<20, patchUser)
app.DeleteAsync("/users/:id", 64<<10, deleteUserWithBody)

The same route registration APIs are available on a *gogo.Router returned by Group or Mount, so scoped routes can use sync and async handlers:

api := app.Group("/api")
api.Get("/health", health)
api.GetAsync("/users/:id", showUserFromDB)
api.Post("/users", createUser)
api.PostAsync("/uploads", 10<<20, uploadFile)
api.PatchAsync("/users/:id", 1<<20, patchUser)

Route API surface:

API App Router handler / target
Get(pattern, target) yes yes Handler, func(*Response, *Request), Reply, string, or []byte
GetAsync(pattern, handler) yes yes AsyncHandler
Post(pattern, handler) yes yes Handler
PostAsync(pattern, maxBodyBytes, handler) yes yes BodyAsyncHandler / PostAsyncHandler with collected body
Put(pattern, handler) yes yes Handler
PutAsync(pattern, maxBodyBytes, handler) yes yes BodyAsyncHandler with collected body
Patch(pattern, handler) yes yes Handler
PatchAsync(pattern, maxBodyBytes, handler) yes yes BodyAsyncHandler with collected body
Delete(pattern, handler) yes yes Handler
DeleteAsync(pattern, maxBodyBytes, handler) yes yes BodyAsyncHandler with collected body
Options(pattern, handler) yes yes Handler
Head(pattern, handler) yes yes Handler
Any(pattern, handler) yes yes Handler for every HTTP method
WebSocket(pattern, behavior) yes yes WebSocketBehavior
Group(prefix, ...middleware) yes yes returns a scoped *Router
Use(...middleware) yes yes sync middleware; App.Use also supports a path prefix
UseAsync(...middleware) yes yes async middleware for GetAsync and body-async routes; App.UseAsync also supports a path prefix
Mount(prefix, func(*Router)) yes no callback sugar over Group
Name(name, pattern) yes yes names a route pattern for reverse routing
URL(name, params) yes no builds a URL for a named route
NotFound(handler) yes no fallback for unmatched routes
MethodNotAllowed(handler) yes no fallback for known path with unsupported method
Route pattern syntax and precedence

Route patterns are path patterns, not full URLs. Query strings are not part of matching; read them with req.QueryParam, req.QueryInt, or req.QueryBool. A pattern must be non-empty, start with /, and contain no NUL, CR, or LF bytes. Invalid patterns panic during registration. Matching is case-sensitive. Trailing slashes are significant for route patterns: /users and /users/ are different routes unless you register both.

Supported route segments:

Segment Meaning
users literal path segment; matches only users
:id one non-empty path segment; available with req.Parameter(i) or req.Param("id")
:id<int> named segment plus a gogo type constraint checked before middleware and the handler
trailing wildcard segment catch-all wildcard, for example /files/*, /files/**, or /*

Parameter names are metadata for request helpers and reverse routing; they do not make two otherwise identical native route shapes distinct. Avoid registering ambiguous patterns such as /users/:id and /users/:name for the same method.

For a request on a concrete HTTP method, the native router prefers literal segments over named parameters, and named parameters over wildcard catch-alls. Method-specific routes are tried before Any routes. gogo's own catch-all for NotFound, MethodNotAllowed, and middleware on unmatched paths is installed just before Listen, after user routes, so explicit routes keep precedence.

MethodNotAllowed distinguishes wrong-method requests for literal, named-parameter, typed-parameter, and terminal wildcard routes. Typed constraints must match before a route contributes to the Allow header; a typed mismatch is still a path miss and falls through to NotFound.

Route parameters

Read positional parameters with req.Parameter(i) or by name with req.Param(name).

app.Get("/users/:id", func(res *gogo.Response, req *gogo.Request) {
    id := req.Param("id")              // by name
    // or: id := req.Parameter(0)      // by index
    res.Send(200, "text/plain", "user="+id)
})

Integer convenience helpers skip strconv boilerplate:

app.Get("/posts/:id", func(res *gogo.Response, req *gogo.Request) {
    id := req.ParamInt("id", 0)        // default 0 on parse failure
    res.JSON(200, map[string]int{"id": id})
})

Use req.ParamInt64(name, def) or req.ParameterInt64(i, def) for larger integer identifiers.

Wildcards are supported via uWS pattern syntax:

app.Get("/files/*", serveFile)         // matches /files/anything/here
app.Any("/*", serveSPA)                // catch-all fallback

Wildcard text is not exposed as a route parameter. Use req.URL() if the handler needs to inspect the matched suffix.

Typed parameters

Add a <type> annotation to a named parameter — the framework refuses to call the handler if the value doesn't match the constraint and returns 404 instead:

app.Get("/users/:id<int>", showUser)          // /users/abc → 404
app.Get("/posts/:slug<slug>", showPost)        // /posts/My_Post → 404
app.Get("/blobs/:digest<uuid>", showBlob)

Built-in constraints: int, uint, uuid, alpha, alnum, slug. Register custom ones:

gogo.RegisterParamType("hex", func(s string) bool {
    for i := 0; i < len(s); i++ {
        c := s[i]
        if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F')) {
            return false
        }
    }
    return len(s) > 0
})

app.Get("/blobs/:digest<hex>", handler)

Malformed annotations, empty parameter names, and unknown type names panic at registration. Constraint checks run before middleware and before static replies are sent, so a typed route cannot bypass auth or serve a cached body for an invalid value.

Query strings
// /search?q=gogo&page=2&strict=true
app.Get("/search", func(res *gogo.Response, req *gogo.Request) {
    q := req.QueryParam("q")
    page := req.QueryInt("page", 1)
    strict := req.QueryBool("strict", false)
    _ = q; _ = page; _ = strict
    res.Send(200, "text/plain", "ok")
})
Named routes & reverse routing
app.Name("user.show", "/users/:id")
url, _ := app.URL("user.show", map[string]string{"id": "42"})
// url == "/users/42"

api := app.Group("/api/v1")
api.Get("/users/:id", showUser)
api.Name("api.user.show", "/users/:id")
url, _ = app.URL("api.user.show", map[string]string{"id": "42"})
// url == "/api/v1/users/42"

Named routes may use typed annotations; URL strips the annotation before substitution. Parameter values are path-escaped. Missing params, unknown route names, and wildcard patterns return errors.

Route registration methods intentionally do not return fluent route handles. Use Name(name, pattern) explicitly when a route needs reverse routing.

Route groups

App.Group and Router.Group bind middleware and a path prefix to a subtree of routes. Group-bound middleware composes at registration time, so there is no per-request URL check. A group prefix must start with /, may contain named or typed parameters, must not contain wildcards, and has trailing slashes stripped. Group("/") is equivalent to no prefix. Child route patterns must also start with /; gogo concatenates the group prefix and child pattern literally, then parses them as one route.

api := app.Group("/api/v1", authMW, loggerMW)
api.Get("/users", listUsers)               // → GET /api/v1/users
api.Post("/users", createUser)

admin := api.Group("/admin", adminMW)      // composes auth + logger + admin
admin.Delete("/users/:id", deleteUser)     // → DELETE /api/v1/admin/users/:id

Router.Use and Router.UseAsync append middleware to that router for routes registered afterward. Child groups created after the call inherit the new middleware. Execution is outermost-first: app middleware, parent group middleware, child group middleware, then the handler. Typed-parameter rejection happens before all middleware.

App.Mount is a tiny convenience for building a Router with a callback:

app.Mount("/api/v1", func(r *gogo.Router) {
    r.Use(authMW)
    r.Get("/users", listUsers)
    r.Post("/users", createUser)
})

Mount returns the *Router, so callers can keep registering on it after the callback. Calling Mount twice with the same prefix creates independent routers; there is no automatic merge.

Path-scoped app middleware uses the same segment-aware prefix rules without creating a router:

app.Use("/api/*", authMW)        // /api and /api/...; not /apiv2
app.Use("/api/**", auditMW)      // same scope as /api/*
app.Use("/", requestIDMW)        // global

Scoped App.Use still follows registration order: only later routes see the middleware. The prefix is matched against the live request URL at request time, not the registered route pattern, so dynamic and wildcard routes cannot bypass a scoped guard. Prefer Group for new subtrees when possible; it gives the same scope by router identity with registration-time composition.

Handler Styles

gogo offers three handler styles, picking the lowest-overhead dispatch path that still satisfies the constraints of the route:

// 1) Sync handler — runs on the uWS loop thread. MUST NOT block.
//    Use for fast in-memory replies, simple JSON, static lookups.
app.Get("/plain", func(res *gogo.Response, req *gogo.Request) {
    res.Send(200, "text/plain", "ok")
})

// 2) GetAsync — runs on a goroutine, free to block (DB, HTTP, sleep).
//    Without middleware, dispatched through a shared-memory ring with
//    ZERO cgo crossings per request.
app.GetAsync("/db", func(res *gogo.Response, req *gogo.Request) {
    var name string
    _ = db.QueryRow("SELECT name FROM users WHERE id=$1", 1).Scan(&name)
    res.Send(200, "text/plain", "hi "+name)
})

// 3) Body async — async handler with the body fully collected up to maxBodyBytes.
//    Oversize bodies → 413 automatically.
app.PostAsync("/upload", 1<<20, func(res *gogo.Response, req *gogo.Request, body []byte) {
    res.JSON(200, map[string]int{"size": len(body)})
})

Async family handlers receive a snapshot *Request that survives past the C-side request lifetime. Sync handlers receive a live request that is only valid during the callback.

Responses

JSON, redirect, file
app.Get("/json", func(res *gogo.Response, req *gogo.Request) {
    res.JSON(200, map[string]any{"ok": true, "n": 42})
})

// Optional at App construction time:
// app, _ := gogo.NewApp(gogo.Config{
//     JSONEncoder: sonic.Marshal,
//     JSONDecoder: sonic.Unmarshal,
// })

app.Get("/old", func(res *gogo.Response, req *gogo.Request) {
    res.Redirect("/new", 301)
})

// SendFile streams a file from disk with ETag, Last-Modified, and Range
// negotiation. Download forces an attachment Content-Disposition.
app.Get("/files/:name", func(res *gogo.Response, req *gogo.Request) {
    path, err := safePublicPath("./public", req.Param("name"))
    if err != nil {
        res.Send(404, "text/plain; charset=utf-8", "not found\n")
        return
    }
    _ = res.SendFile(req, path)
})
app.Get("/download/:name", func(res *gogo.Response, req *gogo.Request) {
    path, err := safePublicPath("./public", req.Param("name"))
    if err != nil {
        res.Send(404, "text/plain; charset=utf-8", "not found\n")
        return
    }
    _ = res.Download(req, path, safeDownloadName(req.Param("name")))
})

// JSONP — same as JSON but wrapped in a callback for legacy clients.
app.Get("/jsonp", func(res *gogo.Response, req *gogo.Request) {
    res.JSONP(req.QueryParam("callback"), map[string]any{"ok": true})
})

// Manual status + headers chained.
app.Get("/custom", func(res *gogo.Response, req *gogo.Request) {
    res.Status(202).
        Header("X-Custom", "yes").
        Send(202, "text/plain", "queued\n")
})

When the filename comes from the URL, do not concatenate it directly onto a directory. Clean it first and verify the result still lives under the intended root:

func safePublicPath(root, name string) (string, error) {
    cleanRoot, err := filepath.Abs(root)
    if err != nil {
        return "", err
    }
    cleanPath, err := filepath.Abs(filepath.Join(cleanRoot, filepath.Clean("/"+name)))
    if err != nil {
        return "", err
    }
    rel, err := filepath.Rel(cleanRoot, cleanPath)
    if err != nil || rel == "." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) || rel == ".." {
        return "", os.ErrPermission
    }
    return cleanPath, nil
}

func safeDownloadName(name string) string {
    name = filepath.Base(name)
    name = strings.Map(func(r rune) rune {
        switch {
        case r < 0x20 || r == 0x7f:
            return -1
        case r == '/' || r == '\\' || r == ':':
            return '_'
        default:
            return r
        }
    }, name)
    name = strings.TrimSpace(name)
    if name == "" || name == "." || name == ".." {
        return "download"
    }
    return name
}

SendFile intentionally opens the path you pass it; path allow-listing belongs in the route because different apps expose different roots. Large-file serving is governed by atomic knobs: SetMaxSendFileBytes, SetSendFileChunkBytes, and SetSendFileBackpressureBytes. The legacy package variables still work for startup-time configuration, but prefer the setters if the server may be serving requests. Use gogo.NoSendFileLimit only for trusted file-serving routes where path allow-listing, authorization, or an external layer already bounds what may be served.

See docs/file-serving.md for rooted path, symlink, download filename, and multipart upload safety guidance.

Streaming

Response.Stream writes a chunked-encoded response. Available from async handlers only.

app.GetAsync("/stream", func(res *gogo.Response, req *gogo.Request) {
    err := res.Stream(200, "text/plain", func(w io.Writer) error {
        for i := 0; i < 5; i++ {
            if _, err := fmt.Fprintf(w, "chunk %d\n", i); err != nil {
                return err
            }
            time.Sleep(200 * time.Millisecond)
        }
        return nil
    })
    if err != nil && !errors.Is(err, gogo.ErrStreamAborted) {
        log.Printf("stream error: %v", err)
    }
})

Response.AwaitDrain and stream writes return gogo.ErrStreamAborted when a client disconnects while the producer is waiting for backpressure to drain.

Templates

Install an html/template-backed engine via SetTemplateEngine:

app.SetTemplateEngine(gogo.NewHTMLTemplateEngine(gogo.HTMLTemplateOptions{
    Root:   "./views",     // directory containing template files
    Suffix: ".tmpl",        // file extension to register (default ".tmpl")
    Reload: false,          // re-parse on every Render during dev
}))

app.Get("/", func(res *gogo.Response, req *gogo.Request) {
    res.Render("index", map[string]any{
        "Title": "gogo~",
        "User":  "alice",
    })
})

Templates are named by their path relative to Root with the suffix stripped — views/user/profile.tmpl is rendered as user/profile. Render output is capped by gogo.GetMaxRenderBytes() (default 8 MiB; set to gogo.SetMaxRenderBytes(gogo.NoRenderLimit) to disable) before it is sent, so oversized templates fail with a generic 500 instead of staging unbounded memory. Bring your own engine by implementing TemplateEngine:

type TemplateEngine interface {
    Render(w *bytes.Buffer, name string, data any) error
}

Custom engines that can stop early may also implement LimitedTemplateEngine; the built-in HTML engine does this so the cap is enforced while rendering.

Request Body Parsing

Request.BodyParser deserializes the body into a struct based on Content-Type. Supported: application/json, application/x-www-form-urlencoded, multipart/form-data (value parts only). JSON uses Config.JSONDecoder when configured; Response.JSON and Response.JSONP use Config.JSONEncoder. Use it from body-async handlers such as PostAsync, PutAsync, PatchAsync, and DeleteAsync.

type CreateUser struct {
    Name  string `json:"name"  form:"name"`
    Email string `json:"email" form:"email"`
}

app.PostAsync("/users", 1<<20, func(res *gogo.Response, req *gogo.Request, body []byte) {
    var in CreateUser
    if err := req.BodyParser(&in); err != nil {
        if errors.Is(err, gogo.ErrUnsupportedMediaType) {
            res.Send(415, "text/plain", "unsupported media type\n")
            return
        }
        res.Send(400, "text/plain", "bad body\n")
        return
    }
    res.JSON(201, in)
})

Sync handlers that collect a body manually with Response.Body should call gogo.ParseBody(contentType, body, &out) inside the callback instead of Request.BodyParser.

If the client disconnects while Response.Body is still collecting, the body callback is not invoked. Use res.OnAborted() or req.Context() for abort cleanup and cancellation.

Multipart value parts parsed by BodyParser and ParseMultipart are capped by GetDefaultMultipartPartLimit() (8 MiB by default). Override the process default with SetDefaultMultipartPartLimit(n) before registering handlers, or pass MultipartOptions{MaxPartBytes: n} to multipart APIs for route-specific limits. Use gogo.NoMultipartPartLimit only for trusted upload flows where Config.BodyLimit or an external proxy still bounds total request size. The older DefaultMultipartPartLimit = n assignment style still works during startup, but the setter is preferred for runtime-safe updates.

Cookies

// Read
sid := req.Cookie("session")

// Write
res.SetCookie(gogo.Cookie{
    Name:     "session",
    Value:    "abc123",
    HttpOnly: true,
    Secure:   true,
    SameSite: gogo.SameSiteLax,
    MaxAge:   3600,
    Path:     "/",
})

// Signed cookies — tamper-evident with HMAC-SHA256.
cookieSecret := os.Getenv("COOKIE_SECRET") // at least 32 bytes
res.SetCookieSigned(gogo.Cookie{Name: "uid", Value: "42"}, cookieSecret)
uid, ok := req.CookieSigned("uid", cookieSecret)
if !ok {
    res.Send(401, "text/plain", "bad cookie\n")
    return
}
_ = uid

Signed-cookie secrets shorter than 32 bytes panic when signing and never verify when reading; use a secret manager or CSPRNG-generated value.

Middleware

Sync middleware

Sync middleware runs on the uWS loop thread and must not block. Use it for cheap cross-cutting work — auth header check, logging, CORS preflight.

authMW := func(next gogo.Handler) gogo.Handler {
    return func(res *gogo.Response, req *gogo.Request) {
        if req.Header("authorization") == "" {
            res.Send(401, "text/plain", "no token\n")
            return
        }
        next(res, req)
    }
}

app.Use(authMW)                                  // global
app.Use("/api/*", authMW, corsMW)                // scoped to /api/*
app.Get("/api/me", handleMe)

The first argument to Use may optionally be a path pattern that scopes the middleware to request URLs under that prefix. Trailing /* or /** is stripped, so /api/*, /api/**, and /api all mean exact /api plus children under /api/; /apiv2 does not match. /, /*, and /** mean global. The match uses the live request URL at request time, so parametric and wildcard routes cannot bypass a scoped guard.

Middleware runs left-to-right: the first middleware passed to Use is the outermost wrapper and sees the request first. A middleware that rejects a request should write the response and return without calling next. Middleware that observes final status, persists state, or records audit data should register res.OnFinish and then call next; see the cleanup section below for the panic-safe form.

Async middleware

AsyncMiddleware wraps GetAsync and body-async handlers and runs on the same goroutine as the user handler, so it IS free to block (DB lookups, remote calls). Typical use: resolve a user from a token, then pass it down via SetLocal.

app.Use("/api/*", func(next gogo.AsyncHandler) gogo.AsyncHandler {
    return func(res *gogo.Response, req *gogo.Request) {
        token := req.Header("authorization")
        user, err := db.LoadUserByToken(token)    // blocking — ok in async mw
        if err != nil {
            res.Send(401, "text/plain", "unauthorized\n")
            return
        }
        req.SetLocal("user", user)
        next(res, req)
    }
})

app.GetAsync("/api/me", func(res *gogo.Response, req *gogo.Request) {
    user := req.Local("user").(*User)
    res.JSON(200, user)
})

Bundled middleware declares its placement:

Placement Runs on Use for
Sync uWS loop thread cheap checks and headers: CORS preflight, BasicAuth, JWT verification, CSRF checks
Async worker goroutine blocking stores or network calls: Redis rate limits, database/session stores
Both sync routes on the loop, async routes in the worker cheap cross-cutting behavior that should apply everywhere: RequestID, Logger, Metrics, Helmet, Compress

middleware.Async(mw) forces a sync-shaped middleware into the async chain for GetAsync and body-async routes. Use it only when the middleware may block; sync routes will not see async-only middleware.

Post-handler cleanup with Response.OnFinish

Middleware that runs after the handler — saving session state, flushing metrics, closing a tracing span, recording an audit line — faces a subtle lifecycle bug if you write it as the naive next; cleanup pattern:

// ⚠️ Footgun — cleanup may run BEFORE the handler's real work
app.Use(func(next gogo.Handler) gogo.Handler {
    return func(res *gogo.Response, req *gogo.Request) {
        state := beginRequest(req)
        next(res, req)
        commit(state)                  // ← runs when next() returns
    }
})

The problem appears when the handler calls res.Async(fn):

app.Get("/job", func(res *gogo.Response, req *gogo.Request) {
    res.Async(func() {
        time.Sleep(50 * time.Millisecond)
        mutate(state)                  // happens AFTER commit(state) above
    })
})

res.Async spawns a goroutine and returns immediately. From the middleware's point of view, next has finished — but the user's real work hasn't started yet. commit(state) saves stale state.

Response.OnFinish(fn func()) is the safe hook for this pattern. It picks the right moment automatically:

  • Sync handler (no res.Async upgrade): fn runs inline when registered — equivalent to the original next; cleanup ordering.
  • Async handler (GetAsync / PostAsync, or sync handler that upgraded via res.Async): fn is queued and fires after the goroutine completes, via the framework's finishAsync cleanup.
  • Late registration (rare — goroutine finished before middleware reached OnFinish): fn runs inline so it's never orphaned.

Rewrite the custom middleware:

// ✅ Correct — cleanup runs after the handler (incl. any res.Async) finishes
app.Use(func(next gogo.Handler) gogo.Handler {
    return func(res *gogo.Response, req *gogo.Request) {
        state := beginRequest(req)
        next(res, req)
        res.OnFinish(func() { commit(state) })
    }
})

Real-world example — an audit middleware that records the final response status and the user ID set by an auth middleware deeper in the chain:

app.Use(func(next gogo.Handler) gogo.Handler {
    return func(res *gogo.Response, req *gogo.Request) {
        start := time.Now()
        next(res, req)
        res.OnFinish(func() {
            user, _ := req.Local("user").(*User)
            log.Printf("audit method=%s path=%s status=%d user=%v dur=%s",
                req.Method(), req.URL(), res.StatusCode(), user, time.Since(start))
        })
    }
})

The status / user ID / duration now reflect the post-handler state regardless of whether the handler upgraded to async.

Multiple registrations fire FIFO — middleware higher in the chain registers first and runs first. Panics inside an OnFinish callback are caught by the framework's panic handler and don't prevent later callbacks from firing, mirroring http.ResponseWriter recovery semantics.

Recording the panic case — if you want the cleanup to fire even when the handler panics, register via defer:

app.Use(func(next gogo.Handler) gogo.Handler {
    return func(res *gogo.Response, req *gogo.Request) {
        start := time.Now()
        defer res.OnFinish(func() {
            log.Printf("status=%d dur=%s", res.StatusCode(), time.Since(start))
        })
        next(res, req)
    }
})

A bare next(res, req); res.OnFinish(...) is skipped on panic because the panic unwinds past the registration. defer res.OnFinish(...) registers on the unwind, before the framework's outer panic handler catches and emits the 500. Use the defer form for observability middleware (metrics, audit, tracing) and for cleanup/state that must commit even when a handler fails.

Built-in middleware using this hook: mw.NewSession (commits session mutations and destroys even when a handler panics) and mw.NewMetrics (records the final status / duration even on panic). Custom middleware with the same requirements should use the defer form.

Bundled middleware

The middleware subpackage ships production-ready middleware:

import (
    gogo "github.com/Snocko-main/gogo"
    mw "github.com/Snocko-main/gogo/middleware"
)

app.Use(mw.RequestID())                              // X-Request-ID, 128-bit
app.Use(mw.Logger(mw.LoggerOptions{
    Format:    mw.JSONFormat,                        // structured logs
    SkipPaths: []string{"/healthz", "/metrics"},
}))
app.Use(mw.CORS(mw.CORSOptions{
    AllowOrigins:     []string{"https://app.example.com"},
    AllowCredentials: true,
    MaxAge:           86400,
}))
app.Use(mw.Helmet())                                  // common security headers
app.Use(mw.Compress())                                // gzip / deflate
app.Use(mw.RateLimit(mw.RateLimitOptions{
    Max:        100,
    Window:     time.Minute,                          // 100 req / IP / minute
    MaxBuckets: 100_000,                              // see "RateLimit memory cap" below
}))
app.Use("/admin/*", mw.BasicAuth(mw.BasicAuthOptions{
    Users:              map[string]string{"alice": "secret"},
    MaxCredentialBytes: 8 << 10, // default; mw.NoBasicAuthCredentialLimit disables the cap
}))

// JWT verification with HS256 (HMAC).
app.Use("/api/*", mw.JWT(mw.JWTOptions{
    Algorithm: mw.JWTHS256,
    Secret:    []byte(os.Getenv("JWT_SECRET")),
}))
app.GetAsync("/api/me", func(res *gogo.Response, req *gogo.Request) {
    claims := req.Local(mw.JWTLocalKey).(map[string]any)
    res.JSON(200, claims)
})

// CSRF — double-submit cookie pattern.
app.Use(mw.CSRF(mw.CSRFOptions{
    Secret:        []byte(os.Getenv("CSRF_SECRET")),
    MaxTokenBytes: 256, // default; mw.NoCSRFTokenLimit disables the cap
}))

// Prometheus-flavored metrics with /metrics handler.
metrics := mw.NewMetrics()
app.Use(metrics.Middleware())
app.Get("/metrics", metrics.Handler())

See Metrics And OpenTelemetry for the stable metric names, labels, bucket contract, and OpenTelemetry integration guidance.

Use AllowOrigins: []string{"*"} only by itself for public APIs. gogo panics at startup if "*" is mixed with explicit origins, or combined with AllowCredentials, so ambiguous CORS policy fails before serving traffic.

HMAC-backed middleware secrets (JWT with HS*, CSRF, and NewSession) must be at least 32 bytes. Generate them from a secret manager or a CSPRNG; short demo strings panic at startup instead of silently weakening token integrity.

Middleware failure policy

Middleware failures fall into three buckets:

Failure Default behavior Operator hook
Invalid configuration at startup panic before serving traffic fix config; tests should assert construction panics
Request authentication or validation failure fail closed with 401/403/429 or omit CORS allow headers custom OnLimit, SkipFunc, WebSocket Verify, or explicit route logic
Backend/store failure after startup middleware-specific; network stores should document fail-open/fail-closed behavior OnError where provided, plus metrics/logging around the store

Built-in auth middleware is fail-closed by default: BasicAuth and JWT return 401, CSRF returns 403, WebSocketAuth rejects the upgrade, and RateLimit returns 429 when a key exceeds its quota. CORS is different: it does not authenticate a request, so disallowed origins simply do not receive allow headers and browsers block the response.

Store-backed middleware must make outage behavior explicit. The bundled Redis rate-limit adapter defaults to fail-open and reports failures through OnError; set FailClosed when protecting scarce or expensive resources. Session persistence errors are not sent to clients after the response has started, so production SessionStore implementations should log or measure their own save/delete failures and should be paired with AsyncStore when they can block.

RequestID — 128-bit IDs

mw.RequestID() emits 32-hex-char IDs (128 bits of entropy) by default. Drop-in compatible with most tracing systems, but worth checking before upgrade if your downstream pipeline hard-codes ID length:

  • VARCHAR(16) / CHAR(16) columns will silently truncate — widen to VARCHAR(64) or TEXT.
  • ❌ Regex like ^[a-f0-9]{16}$ — drop the count or update to {32}.
  • ✅ Treating the ID as an opaque string anywhere (logs, JSON, headers).

If you must keep 16-char IDs for an existing parser, plug a custom generator:

import (
    "crypto/rand"
    "encoding/hex"
)

app.Use(mw.RequestID(mw.RequestIDOptions{
    Generator: func() string {
        var buf [8]byte
        if _, err := rand.Read(buf[:]); err != nil {
            panic("request id entropy unavailable: " + err.Error())
        }
        return hex.EncodeToString(buf[:])   // 16 chars, 64-bit entropy
    },
}))

The 64-bit variant has measurable collision risk past ~10⁹ IDs (≈ 30 req/s for a year). 128 bits keeps collision probability astronomically low — use the default for new systems.

RateLimit memory cap

MemoryRateLimitStore is capped at 100,000 buckets by default to protect against attacker-driven cardinality explosion. When the cap is hit the store evicts expired buckets first, then drops the oldest-resetAt bucket.

The cap binds tightly when your KeyFunc returns many distinct values per window — user IDs, API keys, tokens, headers. For KeyFunc = req.IP() behind a CDN it almost never binds.

The default KeyFunc is req.IP(), which is the immediate TCP peer. Behind a trusted proxy or CDN that usually means the proxy address, not the end client. To rate-limit by end-client IP, configure TrustedProxies and provide a KeyFunc that chooses from req.IPs() with a fallback to req.IP().

KeyFunc returns Typical cardinality 100k enough?
req.IP() behind a CDN 1 (the CDN's address) yes
req.IP() public-facing ~50k unique IPs/min yes
userID (SaaS, 10k DAU) ~10k yes
apiKey for a partner-heavy API 500k+ raise it
Header you don't control (User-Agent, etc.) unbounded the cap is the protection

Raise it when you know cardinality is high:

app.Use(mw.RateLimit(mw.RateLimitOptions{
    Max:        100,
    Window:     time.Minute,
    KeyFunc:    func(req *gogo.Request) string { return req.Local("userID").(string) },
    MaxBuckets: 2_000_000,                    // ~200 MiB worst case
}))

Example client-IP key behind trusted proxies:

app.Use(mw.RateLimit(mw.RateLimitOptions{
    Max:    100,
    Window: time.Minute,
    KeyFunc: func(req *gogo.Request) string {
        if ips := req.IPs(); len(ips) > 0 {
            return ips[0]
        }
        return req.IP()
    },
}))

Set MaxBuckets: mw.NoRateLimitBucketLimit to disable the cap entirely (tests only — re-introduces the OOM risk).

For multi-instance fleets, plug the bundled Redis-backed RateLimitStore from adapters/redis instead — the cap is irrelevant when state lives in Redis, and counters stay consistent across instances:

import redisadapter "github.com/Snocko-main/gogo/adapters/redis"

store, err := redisadapter.NewRateLimitStore(redisadapter.RateLimitOptions{
    URL: "redis://localhost:6379/0",
    // FailClosed: true,   // reject when Redis is unreachable (default fails open)
})
if err != nil {
    log.Fatal(err)
}
defer store.Close()

app.Use(mw.RateLimit(mw.RateLimitOptions{
    Max:        100,
    Window:     time.Minute,
    Store:      store,
    AsyncStore: true, // run the Redis round-trip off the event-loop thread
}))

It uses an atomic INCR + PEXPIRE Lua script for the same fixed-window algorithm as the in-memory store. To reuse an existing client, call redisadapter.NewRateLimitStoreClient(client, "gogo:rl:").

Side effect when the cap binds: eviction resets the rate-limit counter for the evicted key. An attacker spamming new keys to fill the cap will push legitimate users' buckets out faster than their window naturally expires, effectively weakening the rate limit for those users. The cap itself is a memory-safety bound — pair with KeyFunc choices that don't let unauthenticated clients invent unlimited keys.

Sessions
app.Use(mw.NewSession(mw.SessionOptions{
    Secret:       []byte(os.Getenv("SESSION_SECRET")),
    Store:        mw.NewMemorySessionStore(),    // swap for Redis in prod
    TTL:          24 * time.Hour,
    CookieSecure: true,
    MaxEntries:   100_000,                       // default; cap memory
}))

app.GetAsync("/login", func(res *gogo.Response, req *gogo.Request) {
    s := req.Local(mw.SessionLocalKey).(*mw.Session)
    s.Set("user_id", 42)
    res.Send(200, "text/plain", "logged in\n")
})
app.GetAsync("/me", func(res *gogo.Response, req *gogo.Request) {
    s := req.Local(mw.SessionLocalKey).(*mw.Session)
    uid := s.Get("user_id")
    if uid == nil {
        res.Send(401, "text/plain", "no session\n")
        return
    }
    res.JSON(200, map[string]any{"user_id": uid})
})

SESSION_SECRET must be at least 32 bytes; rotate it intentionally because rotation invalidates existing session cookies.

Sessions persist automatically at request completion via Response.OnFinish — that means mutations made inside a res.Async(...) goroutine are saved correctly (the persist call fires after the goroutine finishes, not after next returns). sess.Destroy() deletes the store row, expires the browser cookie when headers are still writable, and stale signed cookies are rotated to a fresh session ID before their next write instead of reusing the destroyed identifier.

For handlers that need an explicit mid-flight commit — checkpointing before launching a background job, persisting auth state before an SSE stream starts emitting events — call sess.Save():

app.Get("/checkpoint", func(res *gogo.Response, req *gogo.Request) {
    s := req.Local(mw.SessionLocalKey).(*mw.Session)
    s.Set("phase", "starting")
    s.Save()                          // commits to the store NOW

    res.Async(func() {
        // The background goroutine can rely on the row being
        // visible to other requests that arrive while it runs.
        runJob(s.ID)
        s.Set("phase", "done")
        res.Send(200, "text/plain", "ok\n")
    })
    // OnFinish still saves the "done" state when the goroutine
    // completes — no extra Save() needed at the end.
})

The default in-memory store is bounded at 100,000 entries. Expired sessions are evicted lazily on Load (the previous implementation kept expired rows alive until GC() was called manually). When the cap is hit on a fresh Save, the store sweeps expired entries first and otherwise drops the oldest-expires entry to make room. Raise the cap via MaxEntries if your workload legitimately keeps many concurrent sessions; mw.NoSessionEntryLimit disables the cap (not recommended outside tests).

For multi-instance fleets, implement SessionStore on top of Redis, Memcache, SQL, or another shared backend. A production store should:

  • apply the provided TTL on every Save
  • make Load, Save, and Delete safe for concurrent requests
  • copy maps at the boundary so request code cannot mutate shared store state
  • log or metric save/delete failures internally, because deferred persistence may run after headers are already committed
  • run with AsyncStore: true if it performs network, disk, or database I/O

Use the same rule for rate limiting: single-process memory stores are fine for one instance; Redis or another shared RateLimitStore is required when limits must be consistent across a fleet.

Database Connection

gogo handlers play nicely with the standard database/sql package. Create the pool once at startup and capture it into every handler closure — do NOT open a fresh *sql.DB per request. With RunMultiCore, the same pool is captured by every worker.

package main

import (
    "context"
    "database/sql"
    "log"
    "time"

    gogo "github.com/Snocko-main/gogo"
    _ "github.com/jackc/pgx/v5/stdlib"          // or your driver of choice
)

type User struct {
    ID   int    `json:"id"`
    Name string `json:"name"`
}

func main() {
    db, err := sql.Open("pgx", "postgres://user:pass@localhost/app?sslmode=disable")
    if err != nil {
        log.Fatal(err)
    }
    db.SetMaxOpenConns(100)
    db.SetMaxIdleConns(25)
    db.SetConnMaxLifetime(time.Hour)
    if err := db.Ping(); err != nil {
        log.Fatal(err)
    }
    defer db.Close()

    app, err := gogo.NewApp()
    if err != nil {
        log.Fatal(err)
    }
    defer app.Close()

    // GetAsync handler is free to block on the DB — it runs on a goroutine.
    app.GetAsync("/users/:id<int>", func(res *gogo.Response, req *gogo.Request) {
        id := req.ParamInt("id", 0)

        ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
        defer cancel()

        var u User
        err := db.QueryRowContext(ctx,
            "SELECT id, name FROM users WHERE id = $1", id).
            Scan(&u.ID, &u.Name)
        if err == sql.ErrNoRows {
            res.Send(404, "text/plain", "not found\n")
            return
        }
        if err != nil {
            log.Printf("db query: %v", err)
            res.Send(500, "text/plain", "db error\n")
            return
        }
        res.JSON(200, u)
    })

    // Body-async methods pre-collect the body; BodyParser deserializes JSON / form.
    app.PostAsync("/users", 1<<20, func(res *gogo.Response, req *gogo.Request, body []byte) {
        var in struct {
            Name string `json:"name"`
        }
        if err := req.BodyParser(&in); err != nil || in.Name == "" {
            res.Send(400, "text/plain", "bad body\n")
            return
        }
        var id int
        err := db.QueryRow(
            "INSERT INTO users (name) VALUES ($1) RETURNING id",
            in.Name,
        ).Scan(&id)
        if err != nil {
            log.Printf("db insert: %v", err)
            res.Send(500, "text/plain", "db error\n")
            return
        }
        res.JSON(201, User{ID: id, Name: in.Name})
    })

    if !app.Listen(3000) {
        log.Fatal("listen :3000 failed")
    }
    log.Println("listening on http://localhost:3000")
    app.Run()
}
Loading the user from a token (async middleware + DB)
app.Use("/api/*", func(next gogo.AsyncHandler) gogo.AsyncHandler {
    return func(res *gogo.Response, req *gogo.Request) {
        token := req.Header("authorization")
        var u User
        err := db.QueryRow(
            "SELECT id, name FROM users WHERE token = $1", token,
        ).Scan(&u.ID, &u.Name)
        if err == sql.ErrNoRows {
            res.Send(401, "text/plain", "unauthorized\n")
            return
        }
        if err != nil {
            res.Send(500, "text/plain", "db error\n")
            return
        }
        req.SetLocal("user", &u)
        next(res, req)
    }
})

Server-Sent Events (SSE)

SSE is async-only — register the route via GetAsync, a body-async route such as PostAsync, PutAsync, PatchAsync, or DeleteAsync, or call res.Async(...) before res.SSE(...). The framework installs the right headers (Content-Type: text/event-stream, Cache-Control: no-cache, X-Accel-Buffering: no) and hands you a *SSEStream.

app.GetAsync("/events", func(res *gogo.Response, req *gogo.Request) {
    // Honor Last-Event-ID for reconnects.
    resumeFrom := req.Header("last-event-id")

    res.SSE(func(s *gogo.SSEStream) error {
        if resumeFrom != "" {
            _ = s.Comment("resuming after id=" + resumeFrom)
        }
        ticker := time.NewTicker(time.Second)
        defer ticker.Stop()
        pinger := time.NewTicker(15 * time.Second)
        defer pinger.Stop()

        var n int64
        for {
            select {
            case t := <-ticker.C:
                n++
                if err := s.SendEvent(gogo.SSEEvent{
                    ID:    fmt.Sprintf("%d", n),
                    Event: "tick",
                    Data:  map[string]any{"n": n, "at": t.Format(time.RFC3339)},
                }); err != nil {
                    return err
                }
            case <-pinger.C:
                if err := s.Ping(); err != nil {
                    return err
                }
            }
        }
    })
})

SSE uses Response.Stream, so disconnects while waiting on backpressure return gogo.ErrStreamAborted.

Browser-side:

<script>
const ev = new EventSource('/events');
ev.addEventListener('tick', e => {
    console.log(JSON.parse(e.data));
});
</script>

Or from the CLI:

curl -N http://localhost:3000/events

WebSocket

Register a WebSocket route with app.WebSocket or router.WebSocket. Browser clients connect with the normal WebSocket API. Because browsers send an Origin header, browser-facing routes should include an Upgrade callback that explicitly accepts or rejects the handshake.

Echo server
app.WebSocket("/ws", gogo.WebSocketBehavior{
    Upgrade: func(ctx *gogo.UpgradeContext) {
        // Local dev page served from the same app.
        origin := ctx.Header("origin")
        if origin != "" && origin != "http://localhost:3000" {
            ctx.Reject(403, "bad origin")
            return
        }
        ctx.Accept("")
    },
    Open: func(ws *gogo.WebSocket) {
        log.Println("client connected")
        ws.SendText("welcome\n")
    },
    Message: func(ws *gogo.WebSocket, msg []byte, op gogo.OpCode) {
        ws.Send(msg, op)                         // echo back
    },
    Close: func(ws *gogo.WebSocket, code int, msg []byte) {
        log.Printf("client closed: %d %s", code, msg)
    },

    // Limits — set explicitly; do not leave at zero hoping for "unlimited".
    MaxPayloadLength: 1 << 20,                   // 1 MiB
    IdleTimeout:      120 * time.Second,
    MaxBackpressure:  64 * 1024,
})

Browser-side:

<script>
const ws = new WebSocket('ws://localhost:3000/ws');

ws.addEventListener('open', () => {
    ws.send('hello from the browser');
});

ws.addEventListener('message', e => {
    console.log('server:', e.data);
});
</script>

Or from the CLI:

websocat ws://localhost:3000/ws
Upgrade gate

For browser clients, add an Upgrade callback and explicitly accept or reject the handshake. This is where origin checks, token checks, subprotocol negotiation, and per-connection user data belong.

The order is:

  1. Client sends an HTTP GET request with Upgrade: websocket.
  2. gogo calls Upgrade while the request is still an HTTP handshake.
  3. ctx.Reject(...) returns an HTTP error response and no socket opens.
  4. ctx.Accept(...) completes the 101 Switching Protocols handshake.
  5. Open runs after the connection is established, then Message runs for frames from that client.
app.WebSocket("/ws", gogo.WebSocketBehavior{
    Upgrade: func(ctx *gogo.UpgradeContext) {
        if ctx.Header("origin") != "https://app.example.com" {
            ctx.Reject(403, "bad origin")
            return
        }

        user, ok := loadUserFromToken(ctx.QueryParam("token"))
        if !ok {
            ctx.Reject(401, "bad token")
            return
        }

        ctx.SetUserData(user) // available later via ws.UserData()
        ctx.Accept("")        // accept with no subprotocol
    },
    Open: func(ws *gogo.WebSocket) {
        user := ws.UserData().(*User)
        ws.SendText("welcome, " + user.Name + "\n")
    },
    Message: func(ws *gogo.WebSocket, msg []byte, op gogo.OpCode) {
        ws.Send(msg, op)
    },
})

Browser-side with a token:

<script>
const token = encodeURIComponent(window.localStorage.getItem('token') || '');
const ws = new WebSocket(`wss://api.example.com/ws?token=${token}`);
</script>
Pub/Sub

For app code, prefer WSHub. It keeps the fast uWS local path, fans out across every App attached in the current process, and can bridge multiple processes or hosts through an adapter such as Redis.

hub := gogo.NewWSHub()
defer hub.Close()

hub.WebSocket(app, "/chat", gogo.WebSocketBehavior{
    Upgrade: func(ctx *gogo.UpgradeContext) {
        ctx.Accept("")
    },
    Open: func(ws *gogo.WebSocket) {
        hub.Subscribe(ws, "room.general")
        ws.SendText("joined room.general\n")
    },
    Message: func(ws *gogo.WebSocket, msg []byte, op gogo.OpCode) {
        ws.SendText("you: " + string(msg))
        _ = hub.PublishFrom(ws, "room.general", msg, op)
    },
})

PublishFrom skips the sender and is safe to call from any goroutine. Register the route with hub.WebSocket or hub.Wrap so the hub can identify the socket; subscriptions made with either hub.Subscribe or raw ws.Subscribe are tracked. Adapter publishes are queued onto a hub worker, so Redis/network I/O never blocks the WebSocket loop. From worker goroutines, scheduled jobs, or HTTP handlers, use hub.Publish or hub.PublishBatch. A successful publish call means local fan-out completed and the adapter message was queued; Redis/network errors are reported through WithWSHubAdapterErrorHandler.

If you call raw ws.Publish from a WebSocket handler in RunMultiCore, gogo publishes locally on the current loop and schedules one copied publish on each peer loop. That keeps delivery correct across cores, but the peer part is O(worker count); use WSHub when you want the skip-sender semantics plus a clear place to add Redis/cluster fan-out.

go func() {
    for {
        time.Sleep(10 * time.Second)
        _ = hub.Publish("room.general", []byte("server tick"), gogo.Text)
    }
}()

For bursty fan-out, batch publishes keep the same one-cgo-crossing local fast path as App.PublishBatch:

_ = hub.PublishBatch([]gogo.PublishMessage{
    {Topic: "room.general", Message: []byte("hi 1"), OpCode: gogo.Text},
    {Topic: "room.general", Message: []byte("hi 2"), OpCode: gogo.Text},
    {Topic: "alerts",       Message: []byte("ping"), OpCode: gogo.Text},
})

For multi-process or multi-host deployments, add a Redis adapter. Redis Pub/Sub is best-effort realtime fan-out: fast and simple, but disconnected processes do not replay missed messages.

import redisadapter "github.com/Snocko-main/gogo/adapters/redis"

adapter, err := redisadapter.New(redisadapter.Options{
    URL: "redis://localhost:6379/0",
    // Optional for large fleets: subscribe Redis only to topics that have
    // local WebSocket subscribers. The hub updates Redis from its adapter
    // worker, so subscribe/unsubscribe never blocks the uWS loop.
    DynamicSubscriptions: true,
    // Optional: tune burst absorption before go-redis can drop Pub/Sub
    // messages because the receive channel is full.
    ChannelSize: 4096,
    // Defaults to 16 MiB, matching WebSocket MaxPayloadLength.
    MaxMessageSize: 16 << 20,
})
if err != nil {
    log.Fatal(err)
}
hub := gogo.NewWSHub(
    gogo.WithWSHubAdapter(adapter),
    gogo.WithWSHubCloseTimeout(5*time.Second),
    gogo.WithWSHubAdapterErrorHandler(func(err error) {
        log.Printf("websocket hub adapter: %v", err)
    }),
)
defer hub.Close()
if err := hub.Start(); err != nil {
    log.Fatal(err)
}

Keep the default one adapter worker when cross-process message order matters. If your workload can tolerate reordering, gogo.WithWSHubAdapterWorkers(4) can raise Redis publish throughput.

The public adapter contract is documented in docs/websocket-hub-adapter.md. In short, hub adapter fan-out is best-effort: the hub preserves adapter publish call order only with the default single worker, does not retry failed broker publishes after they leave the queue, and relies on Close/contexts to stop adapter receive loops and in-flight operations.

DynamicSubscriptions uses gogo's Go-side ws.Subscribe / ws.Unsubscribe tracking rather than a uWS subscription callback, so it does not add an extra C-to-Go callback on the WebSocket hot path. HTTP GetAsync / PostAsync handlers keep the same shared-memory zero-cgo dispatch path; the Redis work is isolated behind the hub's adapter queue. Subscription changes are reconciled to the latest local topic state, so rapid leave/join churn cannot leave Redis subscribed to the wrong final state. For very high subscription churn, gogo.WithWSHubAdapterTopicWorkers(2) can parallelize reconciliation.

With RunMultiCore, create one hub outside setup and register every worker's route through it:

hub := gogo.NewWSHub(gogo.WithWSHubAdapter(adapter))
handle, err := gogo.RunMultiCore(4, 3000, func(app *gogo.App) {
    hub.WebSocket(app, "/chat", behavior)
})
Upgrade-time auth and subprotocols

⚠️ CSWSH — the HTTP CORS middleware does NOT cover the WebSocket upgrade path. If a WebSocket endpoint accepts a browser handshake from https://evil.example, that page can open ws://yoursite/ws and ride the user's session cookies. Same idea as CSRF, different protocol.

Secure default: when Upgrade is nil, gogo accepts non-browser clients that omit Origin (CLI tools, service-to-service clients) and rejects browser-style handshakes that include Origin with 403 origin not allowed. For browser clients, install an explicit Upgrade callback that checks an origin allow-list.

Use middleware.WebSocketAuth for the common case — origin allow-list, optional Verify callback, optional subprotocol gating:

import mw "github.com/Snocko-main/gogo/middleware"

app.WebSocket("/ws", gogo.WebSocketBehavior{
    Upgrade: mw.WebSocketAuth(mw.WebSocketAuthOptions{
        AllowedOrigins: []string{"https://app.example.com"},
        Verify: func(ctx *gogo.UpgradeContext) (any, bool, int, string) {
            user, err := db.LoadUserByToken(ctx.QueryParam("token"))
            if err != nil {
                return nil, false, 401, "bad token"
            }
            return user, true, 0, ""           // userData = user
        },
        AllowedSubprotocols: []string{"chat.v2", "chat.v1"},
    }),
    Open: func(ws *gogo.WebSocket) {
        u := ws.UserData().(*User)             // set by Verify
        ws.SendText("welcome, " + u.Name + "\n")
        ws.Subscribe("user." + strconv.Itoa(u.ID))
    },
    Message: handleWSMessage,
})

Defaults:

  • Zero-value WebSocketAuth rejects every handshake until you opt into browser origins or missing-Origin CLI/service clients.
  • Empty AllowedOrigins + AllowMissingOrigin=false → reject browser handshakes because their Origin is not allow-listed, and reject non-browser handshakes because the Origin header is missing. Set AllowedOrigins explicitly before going to production.
  • AllowMissingOrigin=true → permit handshakes without an Origin (CLI tools like websocat). Safe IF you have no browser clients on this endpoint.
  • AllowedOrigins: []string{"*"} → accept any origin. Opt-in for public APIs that don't rely on ambient cookie auth. Use "*" only by itself; gogo panics at startup if it is mixed with explicit origins.

Legacy auto-accept — if you intentionally want the old uWS behavior of accepting every handshake when Upgrade is nil, set UnsafeAutoUpgrade: true. Use this only for public, non-cookie endpoints where cross-origin WebSocket access is expected:

app.WebSocket("/public-events", gogo.WebSocketBehavior{
    UnsafeAutoUpgrade: true,
    Open: func(ws *gogo.WebSocket) {
        ws.Subscribe("public.events")
    },
})

Roll-your-own — if WebSocketAuth doesn't fit, write the callback directly. The same hooks apply: inspect ctx.Header("origin"), ctx.QueryParam(...), ctx.Protocols(), call ctx.SetUserData(...)

  • ctx.Accept(protocol) or ctx.Reject(status, body):
app.WebSocket("/ws", gogo.WebSocketBehavior{
    Upgrade: func(ctx *gogo.UpgradeContext) {
        // YOU are responsible for the origin check here.
        if !allowOrigin(ctx.Header("origin")) {
            ctx.Reject(403, "bad origin")
            return
        }
        // ... rest of auth ...
        ctx.Accept("")
    },
    Open:    handleOpen,
    Message: handleMessage,
})

File Uploads

// 1) Pre-collected body (simplest). Oversize → 413 automatically.
app.PostAsync("/upload", 10<<20, func(res *gogo.Response, req *gogo.Request, body []byte) {
    sum := sha256.Sum256(body)
    res.JSON(200, map[string]any{
        "size":   len(body),
        "sha256": hex.EncodeToString(sum[:]),
    })
})

// 2) multipart/form-data with file parts.
app.PostAsync("/upload-form", 50<<20, func(res *gogo.Response, req *gogo.Request, body []byte) {
    err := req.Multipart(func(p *gogo.MultipartPart) error {
        if p.IsFile() {
            _, err := p.SaveInto("./uploads")
            return err
        }
        log.Printf("field %s = %s", p.Name, p.Data)
        return nil
    })
    if err != nil {
        res.Send(400, "text/plain", "bad multipart\n")
        return
    }
    res.Send(200, "text/plain", "ok\n")
})

// 3) Streaming with OnData — bytes never held all-at-once.
app.Post("/upload-stream", func(res *gogo.Response, req *gogo.Request) {
    var total int
    res.OnData(func(chunk []byte, isLast bool) {
        total += len(chunk)
        if isLast {
            res.Send(200, "text/plain", fmt.Sprintf("counted %d bytes\n", total))
        }
    })
})

Multi-core

Single-loop mode (NewApp + Run) caps throughput at one OS thread. RunMultiCore starts N independent App instances bound to the same port so the server can use more than one loop. By default, gogo uses the low-overhead SO_REUSEPORT path and lets the kernel place accepted sockets across loops.

func main() {
    runtime.GOMAXPROCS(runtime.NumCPU())

    // Shared resources — create ONCE outside setup so every worker captures
    // the same pointers.
    db := mustOpenDB()
    defer db.Close()

    handle, err := gogo.RunMultiCore(runtime.NumCPU(), 3000, func(app *gogo.App) {
        app.Get("/plain", func(res *gogo.Response, req *gogo.Request) {
            res.Send(200, "text/plain", "ok")
        })
        app.GetAsync("/db", func(res *gogo.Response, req *gogo.Request) {
            var n int
            _ = db.QueryRow("SELECT 1").Scan(&n)
            res.JSON(200, map[string]int{"n": n})
        })
    })
    if err != nil {
        log.Fatal(err)
    }

    sigCh := make(chan os.Signal, 1)
    signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
    <-sigCh
    log.Println("stopping workers")
    handle.Shutdown()
    handle.Wait()
}

When you need deterministic per-loop connection placement, use balanced mode:

handle, err := gogo.RunMultiCoreWithOptions(runtime.NumCPU(), 3000, setup,
    gogo.RunMultiCoreOptions{Mode: gogo.MultiCoreBalanced})

Balanced mode round-robins accepted sockets across App loops, which helps tests and low-cardinality client sets exercise every loop. It costs extra native handoff work on accepted sockets, so the default RunMultiCore path uses MultiCoreReusePort for lower accept-path overhead.

If your production kernel/load balancer distributes SO_REUSEPORT connections evenly and your async routes are IO-bound, keep reuseport mode and raise only the default async worker hint:

handle, err := gogo.RunMultiCoreWithOptions(runtime.NumCPU(), 3000, setup,
    gogo.RunMultiCoreOptions{
        Mode:            gogo.MultiCoreReusePort,
        WorkerHintLoops: runtime.NumCPU(),
    })

RunMultiCore currently has no Config parameter. Each worker App is created with the zero-value Config, so app-scoped fields such as BodyLimit, BodyReadTimeout, BindAddr, CapturePeerIP, TrustProxy, JSONEncoder, and JSONDecoder cannot be supplied through this helper today. Set process-wide knobs and RunMultiCoreOptions before starting workers, register per-route/per-middleware options inside setup, and create shared resources outside setup so every worker captures the same instance.

MultiCoreHandle.Shutdown is immediate: it calls Shutdown on every worker, which closes the listen socket and active connections. It is safe and idempotent, and Wait blocks until every worker loop exits and native resources are freed. There is not yet a multicore equivalent of App.ShutdownGracefully; use a single-loop App when you need the built-in graceful drain behavior.

Tuning knobs that actually matter:

  • GOMAXPROCS — pin to the same N you passed to RunMultiCore.
  • gogo.SetWorkerCount(n) — controls the GetAsync worker pool. Default is ceil(1.5 × worker-hint loops). A single App and default RunMultiCore reuseport mode use the one-loop default (2 workers) so async workers do not steal CPU when the kernel places many connections on one listener. MultiCoreBalanced uses the full loop count (so 8 loops get 12 workers). RunMultiCoreOptions.WorkerHintLoops overrides only that loop hint; SetWorkerCount still wins when you need an exact worker count. Raise either value for IO-bound handlers that keep many requests blocked at once.
  • Pin shared resources (DB pools, caches) to one allocation outside setup.
  • For strict CPU pinning, run under taskset -c 0-(N-1).

See examples/multicore for a full setup with /metrics.

Graceful Shutdown

app.OnShutdown(func() {
    log.Println("draining…")
    db.Close()
})

runDone := make(chan struct{})
go func() {
    app.Run()
    close(runDone)
}()

sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
<-sigCh

ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := app.ShutdownContext(ctx); err != nil {
    log.Printf("forced shutdown: %v", err)
}
<-runDone
app.Close() // free native resources after Run returns

ShutdownContext starts a graceful drain and returns nil after Run exits. If the context expires first, it force-closes active connections and returns the context error; wait for Run to return before calling Close. ShutdownGracefully starts the same graceful drain without blocking. Shutdown closes the listen socket and active connections immediately; Close frees native resources. Shutdown APIs are safe from any goroutine.

Native builds own the uWS loop on an internal locked goroutine. Application code does not need runtime.LockOSThread for the normal NewApp / route registration / Listen / Run / Close lifecycle. Register routes, middleware, and WebSocket behavior before Listen / Run; shutdown and publish APIs are the cross-goroutine entry points once the loop is running.

ShutdownGracefully is an App API. RunMultiCore currently exposes only MultiCoreHandle.Shutdown, which stops every worker immediately and may drop in-flight responses.

Configuration

app, _ := gogo.NewApp(gogo.Config{
    BodyLimit:       4 << 20,                    // 4 MiB; oversize -> 413
    BodyReadTimeout: 30 * time.Second,           // slow body upload deadline
    BindAddr:        "127.0.0.1",                // localhost only
    TrustedProxies:  []string{"10.0.0.0/8"},     // honor forwarded headers from these peers
    // JSONEncoder:  sonic.Marshal,    // optional: faster JSON responses
    // JSONDecoder:  sonic.Unmarshal,  // optional: faster BodyParser JSON
})

NewApp intentionally accepts either no argument or one Config: NewApp() uses safe defaults, and NewApp(gogo.Config{...}) applies overrides. Passing multiple configs returns an error so configuration stays unambiguous.

Field Default Notes
BodyLimit 4 MiB Reject Content-Length > limit with 413 on the C++ side
BodyReadTimeout 30s Deadline for Response.Body to finish reading the body
BindAddr "" Empty = all interfaces (0.0.0.0)
CapturePeerIP false Snapshot peer IP for async / shared-dispatch paths
TrustProxy false Compatibility shortcut: trust forwarded headers from any peer
TrustedProxies empty Trust forwarded headers only from listed IPs/CIDR ranges
JSONEncoder encoding/json.Marshal Encoder for Response.JSON and Response.JSONP
JSONDecoder encoding/json.Unmarshal Decoder for Request.BodyParser JSON bodies

See docs/configuration.md for local development, reverse proxy, and production configuration examples, and docs/ops.md for proxy, deployment, and logging guidance.

BodyReadTimeout protects Response.Body users from slow body uploads that drip bytes forever without exceeding BodyLimit. Keep the 30s default for ordinary APIs, lower it for small JSON-only endpoints, raise it for legitimate large uploads, or set BodyReadTimeout: gogo.NoBodyReadTimeout only when intentionally disabling the deadline for trusted traffic/tests.

BodyLimit: 0 uses the safe 4 MiB default. Set BodyLimit: gogo.NoBodyLimit only for trusted deployments that already enforce a request-body cap at an external layer such as a reverse proxy.

JSONEncoder and JSONDecoder let you plug in drop-in JSON libraries such as sonic, go-json, or jsoniter without adding a framework dependency. Leave them nil for the standard library defaults. JSONP still escapes script-breakout characters defensively even when a custom encoder does not mirror encoding/json's HTML escaping. JSONStream exposes *json.Encoder directly, so it intentionally keeps using encoding/json; use Response.Stream when you need to stream custom-encoded chunks.

Global state audit

Most configuration is per App, route, middleware, or hub instance. The remaining process-wide knobs and exported mutable sentinels are audited in docs/global-state.md, including panic handling, typed parameter registration, worker count settings, and package-level limits.

Test server helpers

NewTestServer starts a real loopback listener for integration-style tests. NewTestServerT is the testing.TB-friendly wrapper: it fails the test on startup errors and registers Close with t.Cleanup. See docs/testing.md for sync, async, body, middleware, WebSocket, graceful shutdown, and test-server serialization guidance.

func TestPing(t *testing.T) {
    ts := gogo.NewTestServerT(t, func(app *gogo.App) {
        app.Get("/ping", func(res *gogo.Response, req *gogo.Request) {
            res.Send(200, "text/plain", "pong")
        })
    })

    resp, err := ts.Get("/ping")
    if err != nil {
        t.Fatal(err)
    }
    defer resp.Body.Close()
    // assert status/body/headers
}
TrustProxy and client IPs

When TrustProxy and TrustedProxies are both unset (the default), the framework treats every X-Forwarded-* header as untrusted attacker input:

  • req.Protocol() / req.Secure() ignore X-Forwarded-Proto.
  • req.IPs() returns nil (the X-Forwarded-For chain is not exposed).
  • req.IP() returns the immediate TCP peer — the proxy itself if you have one.

Use TrustedProxies for production when only specific reverse proxies, load balancers, or private edge ranges should be trusted. Entries may be single IPs or CIDR ranges. When the immediate TCP peer matches the list, req.Protocol() / req.Secure() honor X-Forwarded-Proto and req.IPs() normalizes valid entries from X-Forwarded-For in order. The leftmost entry is the client IP as reported by your proxy chain.

The trusted edge must strip or overwrite incoming X-Forwarded-*, Forwarded, and X-Real-IP headers from untrusted clients before adding its own values. Otherwise an attacker can smuggle a spoofed client IP into the forwarded chain.

// Behind a private load balancer or CDN edge range.
app, _ := gogo.NewApp(gogo.Config{
    TrustedProxies: []string{"10.0.0.0/8", "127.0.0.1"},
})

app.Get("/whoami", func(res *gogo.Response, req *gogo.Request) {
    ips := req.IPs()
    client := req.IP()
    if len(ips) > 0 {
        client = ips[0]                      // leftmost = original client
    }
    res.Send(200, "text/plain", "you are "+client+"\n")
})

TrustedProxies automatically enables CapturePeerIP so async and shared-dispatch routes can evaluate the immediate peer before trusting forwarded headers. You may still set CapturePeerIP yourself when async handlers need req.IP() even without proxy headers.

TrustProxy: true remains available for deployments where every possible immediate peer is already trusted by the network boundary. Prefer TrustedProxies when the app can be reached from both trusted and untrusted peers, or when you want the application to enforce the proxy trust boundary.

Internet-facing servers that read X-Forwarded-For anyway (against recommendation) must call req.Header("x-forwarded-for") and parse it themselves, accepting that any client can forge the value.

CapturePeerIP and async routes

CapturePeerIP is separate from TrustProxy. It controls whether gogo copies the immediate TCP peer IP into the request snapshot used by async route helpers. Leave it off unless an async handler needs req.IP() for logging, audit, rate-limiting, or auth decisions.

Route style CapturePeerIP=false CapturePeerIP=true
Sync handlers req.IP() lazily reads the live uWS response and works normally Same behavior
GetAsync / Router.GetAsync req.IP() is "" in the async snapshot req.IP() is populated from the TCP peer
Body-async routes (PostAsync, PutAsync, PatchAsync, DeleteAsync) req.IP() is "" in the async snapshot req.IP() is populated from the TCP peer

req.IPs() reads X-Forwarded-For from request headers and is controlled by TrustProxy / TrustedProxies, not by CapturePeerIP. If an async route sits behind a trusted proxy and wants the original client, configure TrustedProxies and use req.IPs(). If it wants the proxy/socket peer, enable CapturePeerIP and use req.IP().

net/http adapter body cap

gogo.HTTPAdapter(h) and gogo.HTTPAdapterWithBody(h, body) are compatibility helpers for small stdlib handlers: use them as a route-by-route migration bridge, or as a testing/ops convenience for stdlib endpoints such as expvar and pprof. They stage the wrapped handler's response before sending it through gogo, so the staged body is capped by gogo.GetMaxHTTPAdapterBodyBytes() (default 8 MiB; set to gogo.SetMaxHTTPAdapterBodyBytes(gogo.NoHTTPAdapterBodyLimit) to disable). The adapter accepts http.Flusher for compatibility, but Flush() only commits the staged status code; it does not stream bytes to the client. Large or streaming routes should be ported to native gogo APIs instead of going through the adapter. See examples/httpadapter for expvar and pprof debug endpoints registered through HTTPAdapter.

Redirect and open redirects

res.Redirect(loc, code) writes any string into the Location header that gogo can validate is free of header-injection control characters (CR/LF/NUL — those return 500 without panicking). The framework does NOT validate that the target stays within your own host.

Passing user-controlled input straight to Redirect is an open-redirect bug — attackers craft links that look like your domain but bounce to a phishing page:

// ❌ Vulnerable
app.Get("/login", func(res *gogo.Response, req *gogo.Request) {
    res.Redirect(req.QueryParam("next"), 302)    // attacker: ?next=https://evil.com
})

// ✅ Safe — validate against an allow-list
var safeNextPaths = map[string]bool{"/": true, "/dashboard": true, "/profile": true}

app.Get("/login", func(res *gogo.Response, req *gogo.Request) {
    next := req.QueryParam("next")
    if !safeNextPaths[next] {
        next = "/"
    }
    res.Redirect(next, 302)
})

For more flexible targets, parse the URL and verify the host matches your own before redirecting.

Error Handling & Panic Recovery

Install a custom panic handler to ship recovery events to your error tracker:

gogo.SetPanicHandler(func(recovered any) {
    log.Printf("PANIC: %v\n%s", recovered, debug.Stack())
    sentry.CaptureException(fmt.Errorf("%v", recovered))
})

The framework catches handler panics across HTTP, async, WebSocket, defer, and body callbacks. It emits a best-effort 500 where an HTTP response is still available and keeps the server alive. SetPanicHandler is process-wide; install it during startup when a process hosts multiple App instances.

Custom 404 / 405:

app.NotFound(func(res *gogo.Response, req *gogo.Request) {
    res.JSON(404, map[string]string{"error": "not found", "path": req.URL()})
})

app.MethodNotAllowed(func(res *gogo.Response, req *gogo.Request) {
    allow := strings.Join(app.AllowedMethods(req.URL()), ", ")
    res.Status(405)
    res.Header("Allow", allow)
    res.Header("Content-Type", "application/json")
    res.End(`{"error":"method not allowed"}`)
})

Caveats

  • *Request and *Response from sync handlers are valid only during the handler callback. Async handlers receive a snapshot Request that survives past the C-side lifetime.
  • Sync handlers run on the uWS loop thread — never block them. Use GetAsync / PostAsync for anything that does IO.
  • Native lifecycle decisions from the v0.3 config pass are recorded in docs/native-lifecycle-v0.3.md.
  • net/http middleware is not directly compatible (different signature). Adapt with a small wrapper or use the bundled middleware package.
  • WebSocket pub/sub topics are exact-match strings — no MQTT-style wildcards.
  • WebSocket Upgrade == nil rejects browser handshakes with an Origin header, but accepts non-browser handshakes that omit it. Use an explicit Upgrade callback for browser clients. Set UnsafeAutoUpgrade: true only when cross-origin auto-accept is intentional and the endpoint does not rely on ambient cookies. HTTP CORS middleware does NOT cover the upgrade. See Upgrade-time auth and subprotocols for middleware.WebSocketAuth.
  • TLS / HTTP/2 are out of scope here; terminate at a reverse proxy (nginx, Caddy, an L7 load balancer).
  • req.IPs() returns nil unless Config.TrustProxy=true or the immediate peer matches Config.TrustedProxies — see TrustProxy and client IPs.
  • res.Redirect does not protect against open redirects — caller must allow-list targets. See Redirect and open redirects.
  • mw.RequestID() emits 32-hex-char (128-bit) IDs by default — see RequestID — 128-bit IDs if you have a downstream parser that hard-codes 16-char IDs.
  • mw.RateLimit() caps the in-memory store at 100k buckets — raise via MaxBuckets or plug a Redis store for high-cardinality keys. See RateLimit memory cap.
  • mw.RateLimit() defaults to the immediate peer IP. Behind trusted proxies, use a KeyFunc based on req.IPs() when you want end-client quotas.
  • Custom middleware that runs cleanup AFTER the handler must use Response.OnFinish (not next; cleanup directly) — otherwise a handler that upgrades via res.Async will run its real work after cleanup already fired. See Post-handler cleanup.

Examples

For a v1 production coverage map across HTTP, middleware, WebSocket, graceful shutdown, multicore operations, and install/build validation, see docs/production-examples.md.

Why There Is a C++ Bridge

uWebSockets is not a C library. Its public API is C++ template-heavy, so cgo cannot call it directly in a pleasant or stable way. The uws_bridge.cpp file turns the parts Go needs into a small C ABI.

Benchmarking

Detailed benchmark commands, the v0.7 HTTP baseline route set, and the gogo cgo crossing budget live in docs/performance.md.

There are six comparable HTTP benchmark servers, spanning Go, Node, Bun, and Rust:

benchmark dir framework language / runtime
benchmark/gogo this binding (uWebSockets) Go (cgo → C++)
benchmark/actix actix-web 4 (release + fat LTO) Rust
benchmark/fiber gofiber/fiber (fasthttp) Go
benchmark/node-uwebsockets uWebSockets.js JavaScript (Node)
benchmark/bun-elysia Elysia TypeScript (Bun)
benchmark/nethttp Go standard library net/http Go

scripts/bench_wrk.sh starts each server, hits the selected GET endpoints, then POSTs against any selected POST endpoints and tears it down. The default BENCH_ROUTE_SET=legacy route set matches the checked-in README snapshot: /hello, /hello/:name, /db, POST /echo, and POST /query. Use BENCH_ROUTE_SET=v07-http for the local gogo/Fiber/net/http coverage of plain, parameterized, JSON, middleware, and async SQLite GET routes.

Go benchmark dependencies live in the nested benchmark module so importing gogo does not pull benchmark-only frameworks or database drivers into your application module graph.

Results

Median across wrk -t {1,2,4,8} -c 500 -d 10s — this is not an average of hand-picked thread counts. Each result comes from the same four wrk thread counts, sorted, then medianed.

  • POST /echo — sync handler reads the body and writes it back unchanged (50-byte JSON). Exercises the pure body-collection + response-write path. gogo uses app.Post here (no goroutine handoff).
  • POST /query — body carries an integer id; handler parses it and runs SELECT … FROM users WHERE id = ? against SQLite, returns the row. Realistic API shape: body parse + blocking I/O + JSON response. gogo uses PostAsync here — the small body fits the shared-dispatch cap so the request crosses zero cgo callbacks on the hot path, and the handler runs on a worker goroutine so the blocking sql.DB.QueryRow doesn't pin the loop thread.

Hardware note — these numbers come from a local Apple M3 laptop (Darwin arm64, 4 performance cores + 4 efficiency cores, 16 GB RAM). The run used Go 1.26.3, Node 22.15.0, Bun 1.3.14, Rust 1.95.0, and wrk 4.2.0. Multi-worker mode is capped at 4 server workers/processes for every framework (MULTI_WORKERS=4), matching the performance-core count. Using all 8 logical CPUs put event loops on efficiency cores and made the server compete harder with wrk and async helper goroutines, which inflated tail latency and made the comparison less fair. Absolute rps is hardware-sensitive; compare the relative shape on your own target machine before making capacity decisions.

Highlights
  • gogo has the highest throughput in every single-worker workload in this matrix.
  • gogo keeps the top 4-worker throughput in every workload while staying inside the 4 performance-core cap.
  • Latency stays competitive while leading throughput: gogo p99 is within a few milliseconds of the best tail in most routes, and avoids the large SQLite p99 spikes seen in Actix and net/http.
Throughput summary
workload gogo single best non-gogo single gogo 4-worker best non-gogo 4-worker
GET /hello 269k uWS.js 206k 263k Actix 192k
GET /hello/:name 254k uWS.js 208k 248k uWS.js 206k
GET /db 167k uWS.js 140k 167k uWS.js 136k
POST /echo 205k Fiber 185k 199k Actix 186k
POST /query 153k uWS.js 127k 132k uWS.js 122k
Tail latency summary

Lower p99 is better.

workload gogo single p99 best non-gogo single p99 gogo 4-worker p99 best non-gogo 4-worker p99
GET /hello 3.6 ms uWS.js 3.3 ms 3.9 ms Fiber 5.4 ms
GET /hello/:name 3.6 ms uWS.js 3.8 ms 3.9 ms Fiber 4.9 ms
GET /db 7.5 ms uWS.js 5.1 ms 7.8 ms uWS.js 7.4 ms
POST /echo 3.6 ms uWS.js 3.7 ms 4.9 ms uWS.js 5.7 ms
POST /query 8.1 ms uWS.js 5.3 ms 10.9 ms uWS.js 7.3 ms
Full per-framework median results

Each cell shows req/s on the first line, p50 / p99 latency on the second.

Single worker (1 thread / event loop)
framework language /hello /hello/:name /db POST /echo POST /query
gogo Go (cgo) 269k rps
p50 1.6 / p99 3.6 ms
254k rps
p50 1.8 / p99 3.6 ms
167k rps
p50 2.7 / p99 7.5 ms
205k rps
p50 2.3 / p99 3.6 ms
153k rps
p50 3.0 / p99 8.1 ms
uwsjs JS (Node) 206k rps
p50 2.3 / p99 3.3 ms
208k rps
p50 2.3 / p99 3.8 ms
140k rps
p50 3.4 / p99 5.1 ms
181k rps
p50 2.7 / p99 3.7 ms
127k rps
p50 3.8 / p99 5.3 ms
fiber Go 203k rps
p50 2.3 / p99 4.0 ms
199k rps
p50 2.4 / p99 4.0 ms
87k rps
p50 5.5 / p99 7.8 ms
185k rps
p50 2.6 / p99 4.0 ms
88k rps
p50 5.5 / p99 8.3 ms
actix Rust 177k rps
p50 2.6 / p99 5.1 ms
185k rps
p50 2.6 / p99 5.5 ms
99k rps
p50 4.7 / p99 28.0 ms
170k rps
p50 2.8 / p99 5.2 ms
78k rps
p50 5.4 / p99 36.4 ms
bun+elysia TS (Bun) 155k rps
p50 3.1 / p99 6.4 ms
146k rps
p50 3.2 / p99 7.9 ms
101k rps
p50 4.8 / p99 11.1 ms
116k rps
p50 4.1 / p99 8.7 ms
87k rps
p50 5.5 / p99 12.5 ms
net/http Go 120k rps
p50 3.9 / p99 6.9 ms
123k rps
p50 3.8 / p99 6.1 ms
64k rps
p50 7.6 / p99 10.7 ms
102k rps
p50 4.5 / p99 7.6 ms
62k rps
p50 8.0 / p99 11.2 ms
Multi-worker (4 server workers / processes)
framework language /hello /hello/:name /db POST /echo POST /query
gogo Go (cgo) 263k rps
p50 1.7 / p99 3.9 ms
248k rps
p50 1.8 / p99 3.9 ms
167k rps
p50 2.7 / p99 7.8 ms
199k rps
p50 2.4 / p99 4.9 ms
132k rps
p50 3.4 / p99 10.9 ms
actix Rust 192k rps
p50 1.6 / p99 11.6 ms
194k rps
p50 1.6 / p99 8.0 ms
114k rps
p50 2.8 / p99 34.6 ms
186k rps
p50 1.7 / p99 10.3 ms
106k rps
p50 3.1 / p99 35.6 ms
uwsjs JS (Node) 181k rps
p50 2.5 / p99 5.5 ms
206k rps
p50 2.3 / p99 5.3 ms
136k rps
p50 3.5 / p99 7.4 ms
168k rps
p50 2.8 / p99 5.7 ms
122k rps
p50 3.9 / p99 7.3 ms
net/http Go 179k rps
p50 1.8 / p99 7.8 ms
161k rps
p50 2.1 / p99 10.3 ms
79k rps
p50 5.9 / p99 27.5 ms
153k rps
p50 2.3 / p99 9.5 ms
87k rps
p50 5.4 / p99 21.9 ms
fiber Go 174k rps
p50 2.7 / p99 5.4 ms
193k rps
p50 2.5 / p99 4.9 ms
86k rps
p50 5.6 / p99 10.4 ms
163k rps
p50 2.8 / p99 5.9 ms
71k rps
p50 6.7 / p99 13.2 ms
bun+elysia TS (Bun) 157k rps
p50 3.0 / p99 6.0 ms
160k rps
p50 2.9 / p99 5.5 ms
104k rps
p50 4.6 / p99 10.6 ms
124k rps
p50 3.8 / p99 8.0 ms
92k rps
p50 5.2 / p99 11.5 ms

/db reads one row from a 1000-row SQLite table with a random id — exercises the framework + driver, not just the HTTP layer.

Notes on the spread:

  • Throughput: gogo leads the single-worker table on all five endpoints and keeps the strongest 4-worker throughput on all five endpoints on this machine. uwsjs remains close on the uWebSockets-shaped routes, while Actix is competitive on pure GET/echo paths and does well on SQLite throughput.
  • Tail latency (p99): single-worker uwsjs has the tightest tail on /db and POST /query; gogo is close while carrying higher throughput. In 4-worker mode, gogo and uwsjs keep the tightest p99 on most routes. Actix and net/http have good median latency and throughput but still show wider p99 on the SQLite endpoints.
  • POST /echo (sync) — gogo's sync app.Post + Response.Body collects the body on the loop thread and writes it back without a goroutine handoff. It is the fastest single-worker echo result here; it also has the top 4-worker echo rps. Earlier versions of this benchmark used PostAsync for /echo and lost to actix here; the fair-comparison shape is sync for pure echo.
  • POST /query (PostAsync + SQLite) — gogo is the fastest single-worker result and remains near the top in 4-worker mode, because the small body hits the shared-dispatch fast path (zero cgo callbacks) and the blocking SQLite query runs on a worker goroutine without stalling the loop. uwsjs has the tightest 4-worker p99 here; Actix and net/http have higher throughput than before with the 4-worker cap, but wider tails.
  • Actix Rust posts strong GET and echo throughput, especially with multiple workers, but this run still shows wider SQLite p99 than the uWS-backed servers.
  • Fiber is a very strong pure-Go baseline on this Mac: fast on GET and echo, and with consistently tight p99. Its SQLite endpoints still trail gogo and uwsjs on throughput.
  • net/http — the standard-library baseline is much faster on this Apple Silicon run than the older Linux-container numbers suggested. The 4-worker cap removes the extreme SQLite p99 spikes seen when all 8 logical CPUs were used, though the SQLite tail is still wider than the uWS-backed servers.

To reproduce:

export CGO_ENABLED=1
# Pre-build the Actix release binary once (skip if you don't want
# to compare against Rust):
cargo build --release --manifest-path benchmark/actix/Cargo.toml
# On this Apple M3 machine, the default multi-worker cap is 4.
# Override with MULTI_WORKERS=N if your target host has a different shape.
./scripts/bench_wrk.sh

Documentation

Overview

Package gogo is the "gogo~" HTTP framework: a Go binding for the uWebSockets C++ HTTP server tuned for low cgo overhead.

Build

The native binding is opt-in because it compiles the vendored uWebSockets / uSockets sources and requires cgo. Native builds need Go 1.24+, CGO_ENABLED=1, a C compiler, a C++20-capable compiler, and the host zlib library/headers. Build with:

CGO_ENABLED=1 go build -tags gogo ./...
CGO_ENABLED=1 go run -tags gogo .
CGO_ENABLED=1 go test -tags gogo ./...

Without the gogo build tag the package compiles a stub that returns an error from NewApp — useful for tools that import the package but won't actually run a server. See docs/install-build.md for OS package prerequisites and downstream smoke build validation.

Hello world

import gogo "github.com/Snocko-main/gogo"

func main() {
    app, err := gogo.NewApp()
    if err != nil { panic(err) }
    defer app.Close()

    app.Get("/hello/:name", func(res *gogo.Response, req *gogo.Request) {
        res.Send(200, "text/plain", "hi " + req.Parameter(0))
    })
    if !app.Listen(3000) { panic("listen") }
    app.Run()
}

Routing

Route patterns are path-only patterns that must start with "/". gogo uses uWS route syntax for literals, named params such as "/users/:id", and trailing wildcards such as "/files/*", then adds typed annotations such as "/users/:id<int>". Typed annotations are stripped before registration and checked before middleware or handlers run; invalid values receive 404.

For a concrete HTTP method, literal segments take precedence over named params, and named params take precedence over wildcard catch-alls. Method-specific routes run before Any routes. Custom NotFound and MethodNotAllowed handlers are installed as a catch-all at Listen time after user routes, so explicit routes retain precedence. MethodNotAllowed distinguishes wrong-method requests for literal, named-param, typed-param, and terminal wildcard routes; typed-param constraints must match before the route contributes to the Allow header.

Use Group or Mount to bind a prefix and middleware to a router identity:

api := app.Group("/api", authMW)
api.Get("/users/:id", showUser)

Group prefixes may include named or typed params, reject wildcards, strip trailing slashes, and concatenate with child patterns that also start with "/". Router middleware composes at route registration with no per-request prefix check; parent middleware wraps child middleware.

Handler styles

gogo offers three handler styles, picking the lowest-overhead dispatch path that still satisfies the constraints of the route:

  • app.Get / app.Post / app.Any (Handler): synchronous handler invoked from the uWS loop thread. Must not block. One cgo callback per request. Use for fast in-memory replies and middleware that doesn't fan out to async work.

  • app.GetAsync (AsyncHandler): handler runs on a goroutine and is free to block. Without middleware, gogo dispatches through a shared-memory ring with ZERO cgo crossings per request — the C++ side snapshots the request into the AsyncCtx and pushes a pointer onto the ring; a pool of long-lived Go workers drains it. With middleware, the framework falls back to a sync callback that runs the chain with the live request, then captures a snapshot and spawns the user goroutine.

  • app.PostAsync / app.PutAsync / app.PatchAsync / app.DeleteAsync (BodyAsyncHandler): async handler that receives a fully-collected body up to maxBodyBytes. Oversize bodies return 413 automatically. Uses res.OnData internally and switches to async mode once the body is complete.

All async handlers receive a *Request snapshot (URL/method/query/params/ headers all captured before uWS freed the live request). Snapshot caps in the zero-cgo shared path: URL 256, query 512, params 8x64, headers 8 KB total; requests that exceed those caps are rejected with 431 rather than being silently truncated. The middleware/body-async paths copy headers exactly via cgo so they have no cap.

Responses

Use res.Send for one-shot replies with status, content-type, and body. The framework auto-picks the fastest path:

  • Sync handler: one cgo crossing into uWS.
  • Async handler with body ≤ 8 KB: ZERO cgo — written into shared-memory inline buffers and pushed onto the App's response ring; the loop drains it.
  • Async handler with body > 8 KB: cgo Loop::defer falls back.

res.JSON wraps Send with Config.JSONEncoder (encoding/json.Marshal by default) and Content-Type: application/json.

Middleware

Sync middleware runs on the uWS loop thread and must NOT block. Use it for cheap cross-cutting work (auth header check, logging, CORS).

app.Use(loggerMW)                    // every route registered later
app.Use("/api/*", authMW, corsMW)    // only routes under /api/
app.Get("/api/x", handleX)

authMW := func(next gogo.Handler) gogo.Handler {
    return func(res *gogo.Response, req *gogo.Request) {
        if req.Header("authorization") == "" {
            res.Send(401, "text/plain", "no")
            return
        }
        next(res, req)
    }
}

The first argument to Use may optionally be a path prefix that scopes the middleware to request URLs under that prefix. "/api/*", "/api/**", and "/api" mean exact "/api" plus children under "/api/"; "/", "/*", and "/**" mean global. Without a pattern, middleware applies to every later-registered route.

Global middleware composes at route registration with no per-request string comparison. Scoped middleware matches the live request URL so dynamic routes cannot bypass a scoped guard. Static replies (Reply, string, []byte targets of app.Get) use the zero-cgo fast path only when no matching sync middleware or typed-param constraint needs to run.

Async middleware

AsyncMiddleware wraps GetAsync and body-async handlers and runs on the same goroutine as the user handler, so it IS free to block — typical use: resolve a user from a session token via a DB lookup, then hand the loaded user to the handler.

app.Use("/api/*", func(next gogo.AsyncHandler) gogo.AsyncHandler {
    return func(res *gogo.Response, req *gogo.Request) {
        token := req.Header("authorization")
        user, err := db.LoadUserByToken(token)   // blocking — ok
        if err != nil {
            res.Send(401, "text/plain", "unauthorized\n")
            return
        }
        req.SetLocal("user", user)
        next(res, req)
    }
})

app.GetAsync("/api/me", func(res *gogo.Response, req *gogo.Request) {
    user := req.Local("user").(*User)
    res.JSON(200, user)
})

Async middleware applies only to GetAsync and body-async routes. If only async middleware matches a GetAsync route (no sync mw), the framework still uses the zero-cgo shared-memory dispatch path; the async chain composes inside the worker goroutine alongside the user handler. Sync routes (Get, Post, Put, Patch, Delete, Any) never see async middleware.

req.SetLocal / req.Local pass values from middleware to the handler; req.Body() returns the collected body for body-async routes (nil otherwise), so async middleware can inspect the body before the handler.

Cookies, JSON

req.Cookie("session")
res.SetCookie(gogo.Cookie{Name: "x", Value: "y", HttpOnly: true})
res.JSON(200, map[string]any{"ok": true})

Multi-core

Single-loop mode (NewApp + Run) caps throughput at one OS thread — uWebSockets is event-loop driven, not goroutine-per-request. To run multiple event loops, use RunMultiCore:

handle, err := gogo.RunMultiCore(runtime.NumCPU(), 3000, func(app *gogo.App) {
    app.Get("/plain", plainHandler)
    // … same routes / middleware as a single-loop app …
})
if err != nil { log.Fatal(err) }
// signal-driven immediate shutdown:
<-sigCh
handle.Shutdown()
handle.Wait()

RunMultiCore spawns N independent App instances, each bound to the same port. The default mode lets the kernel distribute accepted sockets with SO_REUSEPORT, which avoids cross-loop socket handoff overhead. If you need predictable per-loop connection placement, use RunMultiCoreWithOptions with MultiCoreBalanced; that mode round-robins accepted sockets across App loops at extra accept-path cost. If SO_REUSEPORT distributes well in your production environment but your async routes need a larger default worker budget, keep MultiCoreReusePort and set RunMultiCoreOptions.WorkerHintLoops. setup runs once per instance on the OS thread that instance will own. setup has no error return; do fallible shared initialization before RunMultiCore. A setup panic is recovered, converted to an error, and any created Apps are closed. RunMultiCore currently creates each worker with the zero-value Config; there is no Config parameter for app-scoped settings such as BodyLimit, BodyReadTimeout, BindAddr, CapturePeerIP, TrustProxy, or custom JSON codecs. Use process-wide knobs before RunMultiCore and per-route/per-middleware options inside setup.

MultiCoreHandle.Shutdown calls Shutdown on every worker App, so multicore shutdown is immediate and active connections are closed. There is no multicore equivalent of App.ShutdownGracefully yet.

Tuning knobs that actually matter:

  • GOMAXPROCS — pin to the same N you passed to RunMultiCore. The scheduler then has exactly one P per loop; oversubscribing wastes context-switch budget, undersubscribing starves loops.
  • SetWorkerCount — controls the GetAsync worker-goroutine pool. Default = ceil(1.5 × worker-hint loops). A single App and default RunMultiCore reuseport mode use the one-loop default so async workers do not steal CPU when the kernel places many connections on one listener. MultiCoreBalanced uses the full loop count. RunMultiCoreOptions.WorkerHintLoops overrides only that loop-count hint; SetWorkerCount still wins when you need an exact worker count. Raise either for IO-bound handlers that keep many requests blocked at once.
  • Shared resources (DB pools, caches) — create ONCE outside RunMultiCore and capture the pointers into the handler closures. setup runs once per loop; allocating fresh DB pools per loop wastes RAM and connection slots.
  • Per-loop CPU pinning — gogo does not pin to specific cores. Linux's scheduler typically keeps each loop on its initial CPU for cache locality. If you need stricter pinning run the server under `taskset -c 0-(N-1)` or wrap LockOSThread with a sched_setaffinity call.

On a 4 vCPU host the gogo bench /plain route scales from ~112 k RPS at 1 core to ~230 k RPS at 2 cores (~2.05× linear). Past 2 cores the same-host wrk client starts competing with the server for CPU, so the apparent 4-core number drops back to ~200 k — a true 4-core measurement needs a separate load-generator host. Either way, gogo per core consistently outruns fiber per core on this hardware (+86 % at 1 core, +48 % at 4 cores, both routes saturated).

See examples/multicore for a full setup with signal handling + a /metrics endpoint formatted as Prometheus text exposition. See docs/production-examples.md for the v1 production example coverage map.

Graceful shutdown

runDone := make(chan struct{})
go func() {
    app.Run()
    close(runDone)
}()

ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := app.ShutdownContext(ctx); err != nil {
    log.Printf("forced shutdown: %v", err)
}
<-runDone
app.Close() // free native resources after Run returns

ShutdownContext returns nil only after Run exits. If the context expires first, it force-closes active connections and returns the context error; wait for Run to return before calling Close.

Shutdown, ShutdownGracefully, and ShutdownContext are safe from any goroutine. Native builds own the uWS loop on an internal locked goroutine, so normal single-app code does not need runtime.LockOSThread. Register routes, middleware, and WebSocket behavior before Listen / Run; shutdown and publish APIs are the supported cross-goroutine entry points once the loop is running.

Performance characteristics

  • Per-request alloc on the GET fast path: dominated by Go runtime work, not framework.
  • statusLine for common HTTP codes (200/201/204/3xx/4xx/5xx) is precomputed and zero-alloc.
  • PanicHandler (see SetPanicHandler) catches handler panics across HTTP, async, WebSocket, defer, and body callbacks, emits a best-effort 500 where an HTTP response is still available, and keeps the server alive.

Index

Constants

View Source
const (
	// NoBodyLimit disables Config.BodyLimit. Use only behind an external
	// body-size limit, such as a trusted reverse proxy.
	NoBodyLimit = -1

	// NoBodyReadTimeout disables Config.BodyReadTimeout. Use only for tests,
	// trusted local traffic, or routes protected by an external upload
	// deadline.
	NoBodyReadTimeout time.Duration = -1
)
View Source
const NoHTTPAdapterBodyLimit int64 = -1

NoHTTPAdapterBodyLimit disables the HTTPAdapter response staging cap. Use only for trusted handlers; streaming or large downloads should use native gogo streaming APIs instead of the adapter.

View Source
const NoMultipartPartLimit int64 = -1

NoMultipartPartLimit disables the multipart per-part cap. Use only when Config.BodyLimit or an external proxy still bounds total request size.

View Source
const NoRenderLimit int64 = -1

NoRenderLimit disables the Response.Render staging cap. Use only for trusted templates where output size is bounded by the application.

View Source
const NoSendFileLimit int64 = -1

NoSendFileLimit disables the SendFile / Download file-size cap. Use only for trusted file-serving routes where path allow-listing, authorization, or an external layer already bounds what may be served.

Variables

View Source
var (
	// ErrWSHubClosed is returned when a publish or start is attempted after Close.
	ErrWSHubClosed = errors.New("gogo: websocket hub is closed")

	// ErrWSHubCloseTimeout is returned when Close cannot drain the adapter
	// worker within the configured close timeout.
	ErrWSHubCloseTimeout = errors.New("gogo: websocket hub close timeout")

	// ErrWSHubAdapterQueueFull is returned by hub publish calls when the
	// async adapter queue is full. Local subscribers have already been fanned
	// out.
	ErrWSHubAdapterQueueFull = errors.New("gogo: websocket hub adapter queue is full")

	// ErrWSHubUntrackedSocket is returned when PublishFrom cannot identify the
	// sender. Register routes with WSHub.WebSocket or WSHub.Wrap.
	ErrWSHubUntrackedSocket = errors.New("gogo: websocket hub socket is not tracked; register route with hub.WebSocket or hub.Wrap")

	// ErrWSHubInvalidOpCode is returned when a hub publish is called with an
	// opcode other than Text or Binary.
	ErrWSHubInvalidOpCode = errors.New("gogo: websocket hub opcode must be Text or Binary")

	// ErrWSHubTrackFailed is reported when a socket cannot be registered with
	// the hub during WebSocket open.
	ErrWSHubTrackFailed = errors.New("gogo: websocket hub could not track socket")
)
View Source
var DefaultMultipartPartLimit int64 = 8 << 20

DefaultMultipartPartLimit is the process default used by ParseMultipart when MultipartOptions.MaxPartBytes is zero. It remains assignable for backward compatibility with earlier versions; prefer SetDefaultMultipartPartLimit for runtime changes so readers observe the update atomically.

View Source
var ErrBodyTimeout = errFramework("body read deadline exceeded")

ErrBodyTimeout is reported by Body when the request body does not finish arriving within Config.BodyReadTimeout. The handler sees the error in its done callback exactly once; later chunks from the slow client are dropped on the floor.

View Source
var ErrBodyTooLarge = errFramework("body exceeds max size")

ErrBodyTooLarge is reported by Body when the request body exceeds the caller-supplied max size.

View Source
var ErrFileTooLarge = errors.New("gogo: file exceeds MaxSendFileBytes")

ErrFileTooLarge is returned by SendFile / Download when the target file is larger than MaxSendFileBytes.

View Source
var ErrHTTPAdapterBodyTooLarge = errors.New("gogo: HTTPAdapter response body exceeds MaxHTTPAdapterBodyBytes")

ErrHTTPAdapterBodyTooLarge is recorded when a wrapped stdlib handler writes more than MaxHTTPAdapterBodyBytes.

View Source
var ErrMultipartPartTooLarge = errors.New("gogo: multipart part exceeds max size")

ErrMultipartPartTooLarge is returned when a multipart part exceeds the configured per-part limit.

View Source
var ErrNoBody = errors.New("gogo: BodyParser requires a collected body; use a body-async route or ParseBody after Response.Body")

ErrNoBody is returned by Request.BodyParser when the request body has not been collected onto the Request. Body-async routes such as PostAsync, PutAsync, PatchAsync, and DeleteAsync pre-collect the body; sync handlers that collect manually with Response.Body should call ParseBody inside the callback instead.

View Source
var ErrRenderTooLarge = errors.New("gogo: rendered template exceeds MaxRenderBytes")

ErrRenderTooLarge is reported when rendered template output exceeds MaxRenderBytes.

View Source
var ErrStreamAborted = errors.New("gogo: stream aborted while waiting on backpressure drain")

ErrStreamAborted is returned by AwaitDrain, and by Stream writes that are parked on AwaitDrain, when the client disconnects before the response's backpressure buffer drains.

View Source
var ErrUnsupportedMediaType = errors.New("gogo: unsupported media type")

ErrUnsupportedMediaType is returned by ParseBody / BodyParser when the Content-Type header doesn't match any of the parser's supported media types. Handlers can map it to 415.

View Source
var MaxHTTPAdapterBodyBytes int64 = 8 << 20

MaxHTTPAdapterBodyBytes caps the response body staged by HTTPAdapter before it is copied into a gogo.Response. NoHTTPAdapterBodyLimit disables the cap.

Deprecated for runtime mutation: direct assignment remains supported for startup-time configuration. Use SetMaxHTTPAdapterBodyBytes / GetMaxHTTPAdapterBodyBytes for changes while requests may be running.

View Source
var MaxRenderBytes int64 = 8 << 20

MaxRenderBytes caps the bytes Response.Render will stage before sending the rendered body. NoRenderLimit disables the cap. The default bounds accidental or maliciously large template output while staying generous for normal pages.

Deprecated for runtime mutation: direct assignment remains supported for startup-time configuration. Use SetMaxRenderBytes / GetMaxRenderBytes for changes while requests may be running.

View Source
var MaxSendFileBytes int64 = 100 << 20

MaxSendFileBytes is a sanity cap on the largest file SendFile and Download will agree to serve — a misconfiguration guard, not a memory limit. The streaming body path uses an O(chunk) buffer regardless of file size, so memory pressure no longer scales with the file. Files larger than the cap return ErrFileTooLarge without touching the response; raise this at startup if your workload legitimately serves bigger blobs.

Default 100 MiB.

Deprecated for runtime mutation: direct assignment remains supported for startup-time configuration. Use SetMaxSendFileBytes / GetMaxSendFileBytes for changes while requests may be running.

View Source
var SendFileBackpressureBytes uint64 = 1 << 20

SendFileBackpressureBytes is the high-water mark for uWS's per-socket send buffer. When the buffer climbs above this value the streaming SendFile path parks on AwaitDrain until uWS notifies it the buffer has drained — this keeps a slow consumer from holding the goroutine hostage AND keeps the kernel buffer bounded at the same level regardless of file size.

Deprecated for runtime mutation: direct assignment remains supported for startup-time configuration. Use SetSendFileBackpressureBytes / GetSendFileBackpressureBytes for changes while requests may be running.

View Source
var SendFileChunkBytes int = 64 << 10

SendFileChunkBytes is the buffer size used for each disk read + stream write iteration. Memory used per concurrent SendFile call is bounded by this value plus uWS's internal write buffer (which grows up to SendFileBackpressureBytes before AwaitDrain parks the goroutine). 64 KiB matches the typical filesystem read-ahead granularity and uWS's default send-batch size.

Deprecated for runtime mutation: direct assignment remains supported for startup-time configuration. Use SetSendFileChunkBytes / GetSendFileChunkBytes for changes while requests may be running.

View Source
var StreamBackpressureBytes uint64 = 1 << 20

StreamBackpressureBytes is the high-water mark, in bytes of uWS's per-socket send buffer, at which streamWriter.Write automatically parks the calling goroutine on AwaitDrain. The check fires after every successful chunk write — a slow consumer therefore can't drive the Go producer faster than the kernel can flush.

Default 1 MiB. Set to 0 to disable the automatic check (the handler is then responsible for invoking BufferedAmount / AwaitDrain itself, the pre-default behavior).

The same threshold protects every Stream / SSE caller — file serving paths still use SendFileBackpressureBytes for its own reads-from-disk loop.

Deprecated for runtime mutation: direct assignment remains supported for startup-time configuration. Use SetStreamBackpressureBytes / GetStreamBackpressureBytes for changes while requests may be running.

Functions

func GetDefaultMultipartPartLimit

func GetDefaultMultipartPartLimit() int64

GetDefaultMultipartPartLimit returns the process-wide multipart per-part cap.

func GetMaxHTTPAdapterBodyBytes

func GetMaxHTTPAdapterBodyBytes() int64

GetMaxHTTPAdapterBodyBytes returns the current HTTPAdapter response staging cap.

func GetMaxRenderBytes

func GetMaxRenderBytes() int64

GetMaxRenderBytes returns the current Response.Render staging cap.

func GetMaxSendFileBytes

func GetMaxSendFileBytes() int64

GetMaxSendFileBytes returns the current SendFile / Download file-size cap.

func GetSendFileBackpressureBytes

func GetSendFileBackpressureBytes() uint64

GetSendFileBackpressureBytes returns the current SendFile backpressure high-water mark.

func GetSendFileChunkBytes

func GetSendFileChunkBytes() int

GetSendFileChunkBytes returns the current per-read SendFile buffer size.

func GetStreamBackpressureBytes

func GetStreamBackpressureBytes() uint64

GetStreamBackpressureBytes returns the current automatic Stream / SSE backpressure threshold.

func ParseBody

func ParseBody(contentType string, body []byte, out any) error

ParseBody is the lower-level helper behind Request.BodyParser, exposed so sync handlers that collect the body manually (via Response.Body) can deserialize without round-tripping through the Request wrapper.

contentType may include parameters (`application/json; charset=utf-8`) — they are stripped before matching. An empty Content-Type is treated as application/octet-stream and returns ErrUnsupportedMediaType. ParseBody has no App context, so JSON uses encoding/json.Unmarshal; use Request.BodyParser when you want Config.JSONDecoder.

func ParseMultipart

func ParseMultipart(contentType string, body []byte, fn func(*MultipartPart) error) error

ParseMultipart iterates every part of a multipart/form-data body, calling fn for each. Returning a non-nil error from fn stops iteration and surfaces the error verbatim to the caller of ParseMultipart; io.EOF specifically is treated as a graceful early exit and is swallowed.

contentType must include the boundary parameter ("multipart/form-data; boundary=...") — the same header the client sent. Returns ErrUnsupportedMediaType when contentType is not multipart/form-data; other parse errors surface verbatim from mime/multipart.

Memory: each part is read fully into memory before fn fires, capped by GetDefaultMultipartPartLimit unless options override it. Suitable for typical avatar / document uploads up to a few MiB. For large file parts, prefer ParseMultipartStream / Request.MultipartStream so the part can be copied without an extra Data allocation. The request body itself is still governed by Config.BodyLimit before multipart parsing begins.

func ParseMultipartStream

func ParseMultipartStream(contentType string, body []byte, opt MultipartOptions, fn func(*MultipartStreamPart) error) error

ParseMultipartStream iterates multipart parts without materializing each part into a Data slice. The request body is still the collected []byte supplied by the caller, but file parts can be copied directly from the multipart reader to disk or another writer.

func ParseMultipartWithOptions

func ParseMultipartWithOptions(contentType string, body []byte, opt MultipartOptions, fn func(*MultipartPart) error) error

ParseMultipartWithOptions is ParseMultipart with explicit per-part limits.

func RegisterParamType

func RegisterParamType(name string, check func(string) bool)

RegisterParamType makes the typed-param annotation <name> available in route patterns app-wide. The check function returns true for valid values and false for invalid ones; invalid values cause the route wrapper to respond 404 without calling the handler.

Registration is global (not per-App). Call it during process init — typically from an init() function or main() before any routes are registered. Re-registering an existing name (including the built-in types int/uint/uuid/alpha/alnum/slug) overwrites the prior check.

gogo.RegisterParamType("hex", func(s string) bool {
    for i := 0; i < len(s); i++ {
        c := s[i]
        if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F')) {
            return false
        }
    }
    return len(s) > 0
})

app.Get("/blobs/:digest<hex>", handler)

func SetDefaultMultipartPartLimit

func SetDefaultMultipartPartLimit(maxBytes int64)

SetDefaultMultipartPartLimit sets the process-wide multipart per-part cap used when MultipartOptions.MaxPartBytes is zero. Set to NoMultipartPartLimit to disable the default cap. Values at or below zero are kept as legacy opt-outs. Prefer explicit MultipartOptions for per-route policies.

func SetMaxHTTPAdapterBodyBytes

func SetMaxHTTPAdapterBodyBytes(maxBytes int64)

SetMaxHTTPAdapterBodyBytes updates the HTTPAdapter response staging cap atomically. Set to NoHTTPAdapterBodyLimit to disable the cap.

func SetMaxRenderBytes

func SetMaxRenderBytes(maxBytes int64)

SetMaxRenderBytes updates the Response.Render staging cap atomically. Set to NoRenderLimit to disable the cap.

func SetMaxSendFileBytes

func SetMaxSendFileBytes(maxBytes int64)

SetMaxSendFileBytes updates the SendFile / Download file-size cap atomically. Set to NoSendFileLimit to disable the cap.

func SetPanicHandler

func SetPanicHandler(fn PanicHandler)

SetPanicHandler registers fn as the global panic handler. Pass nil to restore the default stderr logger. The handler must not panic itself (any panic inside it is recovered silently).

func SetSendFileBackpressureBytes

func SetSendFileBackpressureBytes(backpressureBytes uint64)

SetSendFileBackpressureBytes updates the SendFile buffered-byte high-water mark atomically. Zero restores the default.

func SetSendFileChunkBytes

func SetSendFileChunkBytes(chunkBytes int)

SetSendFileChunkBytes updates the per-read SendFile buffer size atomically. Values at or below zero restore the default.

func SetStreamBackpressureBytes

func SetStreamBackpressureBytes(backpressureBytes uint64)

SetStreamBackpressureBytes updates the automatic Stream / SSE backpressure threshold atomically. Zero disables the automatic check.

func SignCookieValue

func SignCookieValue(value string, secrets ...string) string

SignCookieValue returns value + "." + base64url(HMAC-SHA256(value, secret)) using the FIRST entry of secrets as the signing key. Additional secrets are not used by this function; supply them to VerifyCookieValue instead so a rotation can accept the old key while new cookies are issued with the new one.

Panics on an empty secrets slice or a first secret shorter than 32 bytes — signing without a strong key is never what you want, and silently producing a weakly authenticated cookie would be a security footgun.

signed := gogo.SignCookieValue("alice:42", secret)
res.SetCookie(gogo.Cookie{Name: "session", Value: signed, HttpOnly: true})

func VerifyCookieValue

func VerifyCookieValue(signed string, secrets ...string) (string, bool)

VerifyCookieValue splits a signed cookie value at the final '.', recomputes the HMAC of the prefix under each supplied secret, and returns the prefix when any secret produces a matching signature. Returns ("", false) when:

  • signed is empty or contains no '.',
  • the signature segment is not valid base64url,
  • no strong secret produces a matching signature,
  • secrets is empty or all supplied secrets are shorter than 32 bytes.

Comparison is constant-time so a forged cookie cannot leak the expected signature byte-by-byte through timing.

val, ok := gogo.VerifyCookieValue(req.Cookie("session"), secret, oldSecret)
if !ok {
    res.Send(401, "text/plain", "bad session")
    return
}

func WaitForSharedWorkers

func WaitForSharedWorkers(time.Duration) bool

WaitForSharedWorkers always returns true in stub builds: there are no workers, so the pool is by definition drained the moment the caller asks. Keeps the public signature available across both builds so calling code doesn't have to use build tags.

Types

type Aborted

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

Aborted is a thread-safe flag set when a client aborts before the response is fully sent. Check Load before touching the Response from a deferred callback.

func (*Aborted) Load

func (a *Aborted) Load() bool

Load reports whether the response was aborted.

type App

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

App is a uWebSockets HTTP application.

func NewApp

func NewApp(cfg ...Config) (*App, error)

NewApp creates a non-TLS uWebSockets app. With no Config the app uses safe production defaults; pass at most one Config to override. The variadic shape is the compatibility contract: NewApp() remains valid, NewApp(Config{...}) applies overrides, and more than one Config returns an error.

func (*App) AllowedMethods

func (a *App) AllowedMethods(path string) []string

AllowedMethods returns the HTTP methods registered for path (uppercase, canonical order). Use it inside a MethodNotAllowed handler to build the standard Allow header for 405 responses:

app.MethodNotAllowed(func(res *gogo.Response, req *gogo.Request) {
    allow := strings.Join(app.AllowedMethods(req.URL()), ", ")
    res.Status(405)
    res.Header("Allow", allow)
    res.Header("Content-Type", "text/plain")
    res.End("no\n")
})

Returns nil if the path has no registered routes. Parametric and wildcard routes are included using the same route semantics described on MethodNotAllowed.

func (*App) Any

func (a *App) Any(pattern string, handler Handler)

Any registers a route for every HTTP method.

func (*App) Close

func (a *App) Close()

Close frees native resources. Call it only after Run has returned, or before Run if the app was never started. Waits for any in-flight ShutdownGracefully force-close goroutine to settle so a delayed timer can't make a cgo call against a freed app pointer.

Drops this App's reference on the shared-dispatch worker pool if it registered any shared fast-path routes. When the last shared App in the process is closed the worker goroutines exit cleanly so long-running supervisors (tests, hot-reload, multi-tenant hosts) don't accumulate dead workers spinning against a ring no app is feeding anymore.

func (*App) Delete

func (a *App) Delete(pattern string, handler Handler)

Delete registers a DELETE route. DELETE may carry a body per RFC 9110 §9.3.5 and is subject to BodyLimit.

func (*App) DeleteAsync

func (a *App) DeleteAsync(pattern string, maxBodyBytes int, handler BodyAsyncHandler)

DeleteAsync registers a DELETE route that collects the full request body up to maxBodyBytes, then invokes handler on a goroutine with the collected bytes plus a request snapshot. On bodies that exceed maxBodyBytes the framework sends 413 Payload Too Large automatically; on Config.BodyReadTimeout it sends 408 Request Timeout. In both cases the handler is not called.

func (*App) Get

func (a *App) Get(pattern string, target any)

Get registers a GET route. The target may be:

  • Handler / func(*Response, *Request) — dynamic, invoked per request via cgo
  • string — static body served by C++ (no cgo per request)
  • []byte — same as string
  • Reply — static body with explicit status and Content-Type

Static targets are served entirely in C++ with no Go work per request when no matching sync middleware or typed-param constraints need to run.

func (*App) GetAsync

func (a *App) GetAsync(pattern string, handler AsyncHandler)

GetAsync registers a GET route whose handler runs on a goroutine and receives a Response in async mode plus a snapshot Request (URL/method/ query/params/headers captured from the live uWS request before it was freed).

Without middleware, GetAsync uses the zero-cgo shared-memory dispatch path: C++ snapshots the request, pushes onto a lock-free ring, and a long-lived Go worker pool drains it. SendShared in the handler completes the response without any cgo crossing per request.

With middleware registered, GetAsync falls back to a sync cgo handler that runs the middleware chain with the live request, then captures a snapshot and switches to async mode for the user handler. One extra cgo callback per request only when middleware is in use.

func (*App) Group

func (a *App) Group(prefix string, mws ...any) *Router

Group returns a Router scoped to prefix with mws applied to every route subsequently registered through it. Prefix must start with '/' and contain no wildcards; trailing slash is stripped so Group("/api") and Group("/api/") behave identically. Group("/") is equivalent to no prefix.

func (*App) Head

func (a *App) Head(pattern string, handler Handler)

Head registers a HEAD route. HEAD is bodyless and skips the BodyLimit check. Per RFC 9110, HEAD responses must omit the body; the framework does not enforce this — handlers should call res.End("") after writing the headers.

func (*App) Listen

func (a *App) Listen(port int) bool

Listen binds the app to the given port and reports whether binding succeeded. The bind interface comes from Config.BindAddr; an empty BindAddr keeps the uWS default of all interfaces (0.0.0.0).

If a NotFound handler is registered, Listen wires it as the catch-all route immediately before binding so user-registered routes retain precedence.

func (*App) MethodNotAllowed

func (a *App) MethodNotAllowed(h Handler)

MethodNotAllowed sets the fallback handler for requests whose path matches a registered route but whose method has no registered handler, e.g. app.Get("/users", h) and the client sends POST /users. Literal, parametric, typed-param, and terminal wildcard routes participate in the lookup. A typed-param route counts only when the live path satisfies its constraint; /api/* matches /api/ and descendants, but not bare /api, matching uWS route behavior.

The default fallback writes a 405 response with the standard Allow header. Custom handlers are responsible for their own response and can call AllowedMethods(req.URL()) to build the Allow header.

Calling MethodNotAllowed(nil) clears the handler.

func (*App) Mount

func (a *App) Mount(prefix string, register func(r *Router)) *Router

Mount registers routes onto a sub-router rooted at prefix and runs the provided callback against it. It is sugar over App.Group plus the callback pattern that Express / Fiber users expect:

app.Mount("/api/v1", func(api *gogo.Router) {
    api.Use(middleware.JWT(opts))
    api.Get("/users", listUsers)
    api.Post("/users", createUser)
})

Equivalent to:

api := app.Group("/api/v1")
api.Use(middleware.JWT(opts))
api.Get("/users", listUsers)
api.Post("/users", createUser)

Mount returns the Router in case the caller wants to register more routes against it after the callback returns. Calling Mount twice with the same prefix creates two independent Routers — there is no merging across calls.

func (*App) Name

func (a *App) Name(name, pattern string)

Name tags a previously-registered route pattern with a name so App.URL can perform reverse routing. Route registration methods intentionally do not return fluent route handles; use Name explicitly when a route needs reverse routing. The pattern must be the same (post-strip) form that the route was registered with — typically the literal string you passed to Get / Post / etc., minus any <type> annotations. The simplest usage:

app.Get("/users/:id", showUser)
app.Name("user.show", "/users/:id")

url, _ := app.URL("user.show", map[string]string{"id": "42"})
// url == "/users/42"

Name overwrites any previous mapping for the same name.

func (*App) NotFound

func (a *App) NotFound(h Handler)

NotFound sets the fallback handler for requests that don't match any registered route. The framework registers it as the lowest-priority catch-all (any-method /*) just before Listen binds — explicit user routes always win. Without a registered NotFound handler uWS falls back to its built-in 404 reply, which has no body and no customisation. Calling NotFound(nil) clears the handler.

Middleware registered before Listen wraps the NotFound handler the same way it wraps any other dynamic route.

func (*App) OnListen

func (a *App) OnListen(fn func(port int))

OnListen registers a callback that fires synchronously after Listen binds the socket successfully, before Listen returns. Common uses: logging the bound address, registering with a service discovery agent, sending a "ready" signal to a supervisor. Hooks run in the order they were registered and panic-recover at framework level so a misbehaving hook can't block the rest. Safe to call before or after route registration; not safe to call concurrently with Listen. Calling OnListen(nil) is a no-op.

func (*App) OnShutdown

func (a *App) OnShutdown(fn func())

OnShutdown registers a callback that fires synchronously at the start of Shutdown, ShutdownGracefully, or ShutdownContext (before the C++ close is dispatched to the loop). Use it to flush logs, close DB pools, etc. Hooks run in registration order and run on whatever goroutine called the shutdown API. Hooks fire at most once per App lifecycle, even if multiple shutdown APIs are called. Calling OnShutdown(nil) is a no-op.

func (*App) Options

func (a *App) Options(pattern string, handler Handler)

Options registers an OPTIONS route. OPTIONS is bodyless and skips the BodyLimit check.

func (*App) Patch

func (a *App) Patch(pattern string, handler Handler)

Patch registers a PATCH route. PATCH requests carry bodies and are subject to BodyLimit, like Post.

func (*App) PatchAsync

func (a *App) PatchAsync(pattern string, maxBodyBytes int, handler BodyAsyncHandler)

PatchAsync registers a PATCH route that collects the full request body up to maxBodyBytes, then invokes handler on a goroutine with the collected bytes plus a request snapshot. On bodies that exceed maxBodyBytes the framework sends 413 Payload Too Large automatically; on Config.BodyReadTimeout it sends 408 Request Timeout. In both cases the handler is not called.

func (*App) Post

func (a *App) Post(pattern string, handler Handler)

Post registers a POST route.

func (*App) PostAsync

func (a *App) PostAsync(pattern string, maxBodyBytes int, handler PostAsyncHandler)

PostAsync registers a POST route that collects the full request body up to maxBodyBytes, then invokes handler on a goroutine with the collected bytes plus a request snapshot. On bodies that exceed maxBodyBytes the framework sends 413 Payload Too Large automatically; on Config.BodyReadTimeout it sends 408 Request Timeout. In both cases the handler is not called.

func (*App) Publish

func (a *App) Publish(topic string, message []byte, opcode OpCode)

Publish broadcasts a WebSocket message to every subscriber of topic. Use it from outside a WebSocket handler — typically a worker goroutine that finished some work and wants to notify connected clients — where calling WebSocket.Publish directly would touch uWS's loop-thread-local TopicTree from the wrong thread.

In RunMultiCore mode the publish is dispatched onto every peer App's loop so subscribers on all cores receive it. The topic + message bytes are copied before scheduling, so the caller's buffers can be reused or reclaimed as soon as Publish returns.

Topics are exact-match strings — uWS's TopicTree v20 does not support MQTT-style "+" / "#" wildcards. Publish to the same string each subscriber used in ws.Subscribe.

opcode picks the WebSocket frame type (Text / Binary). For JSON payloads use Text so browser clients receive them as strings via onmessage.data.

Returns immediately — delivery happens asynchronously on the loop(s). There is no error / delivery-count return because the loop may not have processed the publish yet when this returns; uWS itself does not surface that count back to the publisher. Calls after Shutdown, ShutdownGracefully, or Close are ignored.

Performance — pick the right entry point:

  • Inside an Open/Message/Close handler (loop thread): prefer WebSocket.Publish. On a single App it bypasses the cross-thread defer mutex and message copy. In RunMultiCore it still has to schedule one copied peer-loop publish per other App, so the cost is O(peer loops).
  • From a worker goroutine, single message: App.Publish. Measures ~750 ns/op on this VM end-to-end including cgo + heap copy + Loop::defer mutex + wakeup.
  • From a worker goroutine, two or more messages at once (fan-out, batch notification): App.PublishBatch — one cgo crossing + one defer mutex for the whole batch. Crossover is at N=2 on this VM (PublishBatch beats a Publish loop from there up), climbing to ~8x faster at N=100. See PublishBatch's godoc for the full measured curve.

func (*App) PublishBatch

func (a *App) PublishBatch(msgs []PublishMessage)

PublishBatch broadcasts N WebSocket messages in a single cgo crossing with one Loop::defer (one mutex acquire, one wakeup) on the loop side. The whole batch is packed into one contiguous Go buffer + a parallel POD-only metadata array, copied once into the loop's heap, then iterated under the defer.

Use this when a worker goroutine needs to push many messages at once — for example, a fan-out notification that has to land on multiple topics, or a periodic stats-tick that updates several dashboards. App.Publish in a loop pays the cgo + defer-mutex cost per call; PublishBatch pays it once for the whole batch.

Measured speedup over the equivalent App.Publish loop on this VM (128-byte payload, 5 counts each, median ns per batch):

N=1     0.67x  (batch SLOWER — packing overhead > savings)
N=2     1.49x
N=5     2.61x
N=10    2.92x
N=50    6.24x
N=100   8.41x

Crossover is at N=2 — below that, single App.Publish is faster. Per-publish cost drops from ~750 ns (App.Publish) to ~86 ns at N=100, so batching pays off hard for real fan-out workloads.

Each PublishMessage's Topic and Message bytes are copied before the loop sees them, so caller buffers can be reused immediately. Mixed Text/Binary opcodes in one batch are fine.

Returns immediately. Like Publish, delivery happens later on the loop(s) and there is no per-message delivery-count. In RunMultiCore mode the batch is dispatched once per peer App so subscribers on all cores receive it. Calls after Shutdown, ShutdownGracefully, or Close are ignored.

func (*App) Put

func (a *App) Put(pattern string, handler Handler)

Put registers a PUT route. PUT requests carry bodies and are subject to BodyLimit, like Post.

func (*App) PutAsync

func (a *App) PutAsync(pattern string, maxBodyBytes int, handler BodyAsyncHandler)

PutAsync registers a PUT route that collects the full request body up to maxBodyBytes, then invokes handler on a goroutine with the collected bytes plus a request snapshot. On bodies that exceed maxBodyBytes the framework sends 413 Payload Too Large automatically; on Config.BodyReadTimeout it sends 408 Request Timeout. In both cases the handler is not called.

func (*App) Run

func (a *App) Run()

Run starts the uWebSockets event loop and blocks. Before running, installs the shared-memory drain timer on this loop so SendShared responses can be flushed by the loop thread.

func (*App) SetTemplateEngine

func (a *App) SetTemplateEngine(e TemplateEngine)

SetTemplateEngine installs e as the active template engine for this App. Subsequent Response.Render calls use it. Passing nil clears the engine, which causes Render to respond with 500.

Engines are intended to be configured once at startup. Swapping engines at runtime is safe (the field is published atomically) but not a recommended pattern.

func (*App) Shutdown

func (a *App) Shutdown()

Shutdown stops the app immediately: the listen socket and every active connection are closed at once. Run returns as soon as the loop drains. In-flight responses are dropped — use ShutdownGracefully when you need to wait for active clients to finish.

Safe to call from any goroutine; idempotent. Returns immediately — call Close after Run returns to free native resources. Registered OnShutdown hooks fire synchronously before the close is dispatched.

func (*App) ShutdownContext added in v0.3.0

func (a *App) ShutdownContext(ctx context.Context) error

ShutdownContext starts a graceful shutdown and blocks until Run exits or ctx is done. It closes the listen socket immediately, lets accepted connections drain naturally, and returns nil when the loop exits.

If ctx is done before the loop exits, ShutdownContext force-closes active connections with Shutdown and returns ctx.Err(). Call Close after Run returns to free native resources. Passing a nil context returns an error.

func (*App) ShutdownGracefully

func (a *App) ShutdownGracefully(timeout time.Duration)

ShutdownGracefully closes only the listen socket so no new connections arrive, then waits up to timeout for the already-accepted connections to finish their in-flight responses naturally. If the timeout fires before everything drains, the remaining sockets are force-closed via Shutdown so the loop can exit. timeout = 0 disables the force-close (wait indefinitely).

Returns immediately — the wait + force-close run on a background goroutine. Call Close after Run returns. Registered OnShutdown hooks fire synchronously before the listen socket is closed. Close waits for the force-close goroutine to finish before freeing native resources, so it is always safe to call Close after Run returns regardless of how the loop exited.

func (*App) URL

func (a *App) URL(name string, params map[string]string) (string, error)

URL builds the path for a named route by substituting params into each :name segment of the pattern. Wildcards (`*`, `**`) cannot be reverse-routed — the function returns an error if the pattern contains them.

app.Get("/users/:userID/posts/:postID", showPost)
app.Name("post.show", "/users/:userID/posts/:postID")

url, err := app.URL("post.show", map[string]string{
    "userID": "alice",
    "postID": "42",
})
// url == "/users/alice/posts/42"

Returns an error when:

  • the name is unknown,
  • the pattern references a :param missing from the params map,
  • the pattern contains a wildcard (* or **).

func (*App) Use

func (a *App) Use(args ...any)

Use appends middleware to the chain. Each registered route that follows this call wraps its handler in the current chain.

The first argument may optionally be a path pattern (string), scoping the middleware to URLs that fall under that prefix at request time:

app.Use(authMW)                       // applies to every later route
app.Use("/api/*", authMW)             // applies to URLs under /api/
app.Use("/admin", auditMW, rateMW)    // /admin and URLs under /admin/

Trailing "/*" or "/**" on the prefix is stripped — "/api/*" and "/api" mean the same thing (prefix = "/api"). A pattern of "/*" or "/" means "every route" (equivalent to no pattern).

Scoped Use matches the live request URL via req.URL(), not the route pattern string. This makes it safe against parametric / wildcard routes (e.g. Get("/api/:section") serving /api/admin will go through a Use("/api/admin", auth) middleware). Cost: one URL string compare per scoped entry per request — global Use (no prefix) still composes at registration with zero per-request cost.

Prefer App.Group(prefix, mws...) for scoping new code: Group binds middleware by Router identity, so the chain composes at registration time and matching is unambiguous without the per-request URL check.

Middlewares run left-to-right — the first argument runs first (outermost). Safe to call multiple times.

func (*App) UseAsync

func (a *App) UseAsync(args ...any)

UseAsync is retained as a thin alias for App.Use to keep existing code compiling. App.Use now handles all middleware: bundled middleware carries its own placement, raw AsyncMiddleware values register in the async chain, and any custom middleware that must block on I/O can be wrapped via middleware.Async(...).

Prefer App.Use in new code — UseAsync exists only for backward compatibility and will be removed once callers migrate.

func (*App) WebSocket

func (a *App) WebSocket(pattern string, behavior WebSocketBehavior)

WebSocket registers a WebSocket route.

type AsyncHandler

type AsyncHandler func(*Response, *Request)

AsyncHandler handles a request on a goroutine that is free to block. The Response arrives in async mode with the abort context pre-attached. The Request is a snapshot copied from the live uWS request before it was freed. Shared zero-cgo routes reject snapshots past their fixed caps (URL 256, query 512, each param 64, headers buffer 8 KB); middleware fallback snapshots copy the live request fields via cgo before spawning.

type AsyncMiddleware

type AsyncMiddleware func(next AsyncHandler) AsyncHandler

AsyncMiddleware wraps an AsyncHandler the same way Middleware wraps a Handler, but executes on the goroutine that runs the user's async handler so it is free to block (DB queries, downstream HTTP calls, etc.).

Async middleware applies only to GetAsync and body-async routes such as PostAsync, PutAsync, PatchAsync, and DeleteAsync. Use it when the cross-cutting work itself needs to block; for cheap header / query inspection prefer sync Middleware (smaller per-request overhead and also applicable to sync routes).

Pass data through to the user handler via Request.SetLocal / Request.Local.

type BodyAsyncHandler

type BodyAsyncHandler func(res *Response, req *Request, body []byte)

BodyAsyncHandler is the handler signature for async routes that collect a request body before dispatching to a goroutine. It receives the response, a snapshot of the request (URL/query/params/headers all captured before uWS freed the live request), and the fully-collected body.

type Config

type Config struct {
	// BodyLimit caps the request-body bytes a Post / Any route will
	// accept. Enforced at three layers so every intake shape gets the
	// same upper bound:
	//
	//   - Content-Length declared: rejected with 413 at arrival on the
	//     C++ side before any cgo crossing — zero per-request cost
	//     beyond the existing header lookup.
	//   - Chunked transfer-encoded + Response.OnData: the Go-side
	//     accumulator inside OnData totals chunk sizes and emits
	//     413 + close as soon as the running total crosses the cap.
	//   - Chunked transfer-encoded + Response.Body: the caller-supplied
	//     maxBytes is clamped down by BodyLimit when BodyLimit is
	//     smaller, so handlers that ask Body(10 MiB) on an app capped
	//     at 1 MiB top out at 1 MiB.
	//
	// Zero uses the safe default of 4 MiB. Set to NoBodyLimit to disable
	// the cap entirely when an external layer enforces a trusted body-size
	// limit.
	BodyLimit int

	// BodyReadTimeout caps the wall-clock time the framework will
	// wait for a request body to finish arriving. Applied per call
	// to Response.Body: a timer starts when Body registers its
	// chunk listener and fires done(nil, ErrBodyTimeout) if the
	// last chunk hasn't landed by the deadline. Defeats slow-loris
	// drip uploads where the client keeps the request open but
	// sends bytes too slowly to ever exhaust BodyLimit.
	//
	// Zero uses the safe default of 30s. Reasonable production values
	// fall between 10s for API endpoints and 60s+ for legitimate
	// upload flows. Set to NoBodyReadTimeout to disable the timeout
	// explicitly (not recommended outside tests or trusted local traffic).
	// The timer fires on a goroutine that hands the
	// cancellation back to the loop thread via Loop.Defer so done()
	// and the connection close run serially with onData / onAborted
	// — callers don't have to think about races.
	BodyReadTimeout time.Duration

	// BindAddr is the local interface to bind on. Empty string means
	// "all interfaces" (uWS default 0.0.0.0). Use "127.0.0.1" for a
	// localhost-only service. Applied at Listen time.
	BindAddr string

	// CapturePeerIP enables snapshotting the peer IP on the C++ side
	// before shared-dispatch / async handlers run. When false (default),
	// req.IP() in shared-dispatch GetAsync handlers and in async handlers
	// that fell through to the snapshot path will return "". Sync
	// handlers always get a usable req.IP() — the lookup is lazy and
	// only pays cgo when actually called, so the flag has no effect
	// there.
	//
	// Cost when enabled: one std::string_view format + ~50-byte memcpy
	// per shared-dispatch request, plus 64 extra bytes on every
	// AsyncCtx. Measured at roughly 2–3% throughput on small responses
	// (e.g. /db at ~75K rps); negligible on routes with significant
	// per-request work. Enable it when handlers behind GetAsync need to
	// read the peer IP; otherwise leave it off.
	CapturePeerIP bool

	// TrustProxy declares that every immediate peer is a trusted reverse
	// proxy (e.g. nginx, an L7 load balancer, a CDN), so the X-Forwarded-*
	// headers used by gogo helpers are safe to surface to the application:
	//
	//   - req.Protocol() / req.Secure() honor X-Forwarded-Proto.
	//   - req.IPs() exposes the normalized X-Forwarded-For chain.
	//
	// Leave OFF when the server is directly internet-facing, and prefer
	// TrustedProxies when only specific proxy addresses should be trusted.
	// Otherwise any client can spoof their apparent protocol / origin by
	// sending X-Forwarded-* headers. Default false.
	TrustProxy bool

	// TrustedProxies narrows forwarded-header trust to requests whose
	// immediate TCP peer matches one of these IP addresses or CIDR ranges.
	// Entries may be single IPs ("127.0.0.1", "2001:db8::1") or CIDR
	// prefixes ("10.0.0.0/8", "2001:db8::/32"). When non-empty, this
	// allow-list is used instead of the broad TrustProxy switch; forwarded
	// headers are ignored for peers outside the list.
	//
	// Setting TrustedProxies enables CapturePeerIP automatically because
	// async/shared-dispatch routes must snapshot the immediate peer before
	// they can decide whether proxy headers are trusted.
	TrustedProxies []string

	// JSONEncoder is used by Response.JSON and Response.JSONP. Nil uses
	// encoding/json.Marshal. Override it with a faster compatible encoder
	// such as sonic.Marshal, go-json.Marshal, or jsoniter.Marshal when JSON
	// reflection cost dominates your handlers.
	JSONEncoder JSONEncoder

	// JSONDecoder is used by Request.BodyParser for application/json and
	// text/json request bodies. Nil uses encoding/json.Unmarshal.
	JSONDecoder JSONDecoder
}

Config tunes per-App behavior. All fields are optional; the zero value is a safe production default. Pass to NewApp; values are applied at app creation and bind time. The struct is intentionally narrow — knobs only get added here when they need a single, app-wide value.

Panic recovery is intentionally not part of Config. The supported panic hook is SetPanicHandler, which is process-wide because recovery sites include package-level workers and callbacks that are not owned by a single App.

Connection-level timeouts and limits

A few knobs that look like they belong here are deliberately not exposed:

  • HTTP idle timeout (TCP connection sits open without sending a request, or keep-alive between requests). Fixed by uWebSockets at 10 seconds via the HTTP_IDLE_TIMEOUT_S constant in HttpContext.h — a connection that goes silent for 10 s is closed automatically. Exposing this as a config knob would require patching vendored uWS source or wiring up the uWS filter mechanism through the C++ bridge; the default is aggressive enough that this hasn't been done yet. If you need a longer idle for a legitimate long-poll-style workload, use a WebSocket route (WebSocketBehavior.IdleTimeout is configurable).

  • Maximum concurrent connections. Not implemented in this framework because the bound that matters in practice is the OS file-descriptor limit (`ulimit -n`) and any reverse proxy in front (nginx `limit_conn_zone`, etc.). uWS holds an idle TCP connection in ~10 KB of RAM, so 100k connections is ~1 GB — usually the FD cap fires long before that. If you genuinely need application-level admission control, terminate at a proxy and apply limits there.

  • Slow-loris on the request body itself IS covered: see BodyReadTimeout below.

type Cookie struct {
	Name     string
	Value    string
	Path     string
	Domain   string
	MaxAge   int // seconds; <0 means delete, 0 means session, >0 explicit
	Expires  string
	Secure   bool
	HttpOnly bool
	SameSite SameSite
}

Cookie configures a Set-Cookie header. Zero-value fields are omitted — browsers fall back to their defaults (session cookie, no Path, etc.).

type HTMLTemplateOptions

type HTMLTemplateOptions struct {
	// Root is the directory containing template files. Required.
	// Walked once at startup so deeply-nested layouts work without
	// explicit registration.
	Root string

	// Suffix filters which files in Root are treated as templates.
	// Default ".tmpl". Files with other extensions are ignored
	// during the walk.
	Suffix string

	// Reload, when true, re-parses every template on each Render
	// call. Useful in development so edits show up without a
	// restart. Off by default — production deployments parse once
	// at startup.
	Reload bool

	// FuncMap registers helper functions exposed to every template.
	// Pair with the engine's auto-escaping by avoiding helpers that
	// return raw HTML; if you must, return template.HTML and
	// understand the XSS risk.
	FuncMap htmltmpl.FuncMap
}

HTMLTemplateOptions configures NewHTMLTemplateEngine.

type Handler

type Handler func(*Response, *Request)

Handler handles a single HTTP request.

The Request and Response values are only valid for the duration of the callback. Do not store them or use them from another goroutine.

func HTTPAdapter

func HTTPAdapter(h http.Handler) Handler

HTTPAdapter wraps a net/http.Handler so it can be registered on a gogo route. The adapter:

  • builds a net/http.Request from the gogo.Request (URL, method, headers; body left nil),
  • runs the handler against a httptest.ResponseRecorder,
  • copies the recorded status, headers, and body to the gogo.Response via res.Send.

Useful for migrating routes a-handler-at-a-time from a stdlib net/http codebase, or for serving stdlib-shaped handlers (expvar.Handler, net/http/pprof.Handler, …) under gogo. Responses are buffered before being sent through gogo. The adapter accepts http.Flusher for compatibility with stdlib handlers, but Flush only commits the staged status code; it does not stream bytes to the client. Port streaming or large-download handlers to native gogo APIs instead.

app.Get("/debug/vars", gogo.HTTPAdapter(expvar.Handler()))
app.Get("/debug/pprof/*", gogo.HTTPAdapter(http.HandlerFunc(pprof.Index)))

Body access: gogo's Get path does not collect request bodies, so the wrapped handler sees an empty body. For methods that carry payloads register via PostAsync and call HTTPAdapter from inside a wrapper that builds the http.Request with the collected body:

app.PostAsync("/upload", 8<<20, func(res *gogo.Response, req *gogo.Request, body []byte) {
    httpReq := httptest.NewRequest("POST", req.URL(), bytes.NewReader(body))
    copyHeadersIntoHTTPReq(httpReq, req)
    rec := httptest.NewRecorder()
    legacyHandler.ServeHTTP(rec, httpReq)
    res.Header("Content-Type", rec.Header().Get("Content-Type"))
    res.Send(rec.Code, "", rec.Body.String())
})

func HTTPAdapterWithBody

func HTTPAdapterWithBody(h http.Handler, body []byte) Handler

HTTPAdapterWithBody is the variant that ships the collected body into the wrapped handler. Use it from PostAsync (or any path where you have access to the full request body):

app.PostAsync("/legacy", 8<<20, func(res *gogo.Response, req *gogo.Request, body []byte) {
    gogo.HTTPAdapterWithBody(legacyHandler, body)(res, req)
})

The returned Handler is a closure that captures body; do not reuse it across requests.

type JSONDecoder

type JSONDecoder func(data []byte, v any) error

JSONDecoder is the unmarshaling function used by App-scoped JSON body parsing helpers. Its shape matches encoding/json.Unmarshal and common third-party drop-ins.

type JSONEncoder

type JSONEncoder func(v any) ([]byte, error)

JSONEncoder is the marshaling function used by App-scoped JSON helpers. Its shape matches encoding/json.Marshal and common third-party drop-ins.

type LimitedTemplateEngine

type LimitedTemplateEngine interface {
	TemplateEngine
	RenderLimited(w *bytes.Buffer, name string, data any, maxBytes int64) error
}

LimitedTemplateEngine is an optional extension for engines that can stop rendering before a response grows past a framework cap. Engines that do not implement it still work through TemplateEngine; Response.Render checks the final buffer size before sending.

type Loop

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

Loop is a uWebSockets event loop. Use Defer to schedule work back onto the loop thread from any goroutine.

func (*Loop) Defer

func (l *Loop) Defer(fn func())

Defer schedules fn to run on the loop thread. Safe to call from any goroutine. fn runs once, in FIFO order with other deferred callbacks.

type Middleware

type Middleware func(next Handler) Handler

Middleware wraps a Handler with cross-cutting behavior (auth, logging, CORS, etc.). The returned Handler is invoked per request; call next(res, req) to continue the chain or write a response and return to short-circuit.

Middleware composes at registration time, so there is no per-request allocation. Middleware applies to routes registered AFTER the call to App.Use that introduced it; reordering Use and route registration changes the effective chain for those routes.

Middleware runs on the uWS loop thread and MUST NOT block — no DB queries, no remote calls. For middleware that needs to block (e.g. resolving a user from a session token via a DB lookup), use AsyncMiddleware with UseAsync; it runs on a goroutine and is free to block.

Static replies (Reply, string, []byte targets of App.Get) use their C++ fast path only when no matching sync middleware or typed-param constraint needs a Go-side handler. GetAsync and small PostAsync routes keep their shared-memory fast path when only async-capable middleware applies; sync-only middleware makes them fall back to a wrapped sync entry point.

type MultiCoreHandle

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

MultiCoreHandle controls a group of App instances started by RunMultiCore. Shutdown stops all of them; Wait blocks until every Run loop has exited.

func RunMultiCore

func RunMultiCore(n int, port int, setup func(app *App)) (*MultiCoreHandle, error)

RunMultiCore spawns n independent App instances on dedicated OS threads. Each instance binds to the given port using SO_REUSEPORT and lets the kernel distribute accepted sockets across worker loops. This is the low-overhead default; use RunMultiCoreWithOptions and MultiCoreBalanced when predictable per-loop connection placement matters more than raw accept-path overhead.

setup is called once per App, on the thread that instance will run on, to register routes / middleware / etc.

setup MUST register the same routes on every App for consistent behavior; the framework just calls setup(app) and trusts user code to be deterministic. Heavy shared state (DB pools, caches) and any fallible initialization should be created ONCE outside RunMultiCore and captured into the handler closures so per-App initialization stays cheap. If setup panics, RunMultiCore converts it to an error and closes any Apps created so far.

Returns a MultiCoreHandle that can Shutdown or Wait. Returns an error if any App fails to start; in that case already-started Apps are shut down before returning.

func RunMultiCoreWithOptions added in v1.3.1

func RunMultiCoreWithOptions(n int, port int, setup func(app *App), opts RunMultiCoreOptions) (*MultiCoreHandle, error)

RunMultiCoreWithOptions is RunMultiCore with explicit listener distribution and worker-budget hint controls.

func (*MultiCoreHandle) Shutdown

func (h *MultiCoreHandle) Shutdown()

Shutdown initiates graceful stop on every App in the group. Idempotent; safe to call from any goroutine.

func (*MultiCoreHandle) Wait

func (h *MultiCoreHandle) Wait()

Wait blocks until every App in the group has exited its Run loop and freed native resources. Returns immediately once all loops have finished.

type MultiCoreMode added in v1.3.1

type MultiCoreMode int

MultiCoreMode selects how RunMultiCoreWithOptions spreads accepted connections across worker loops.

const (
	// MultiCoreAuto keeps the default RunMultiCore behavior. Today it maps to
	// MultiCoreReusePort because that avoids the cross-loop socket adoption
	// overhead of balanced mode.
	MultiCoreAuto MultiCoreMode = iota
	// MultiCoreReusePort lets every App bind the same port with uSockets'
	// default SO_REUSEPORT behavior. It is the lowest-overhead mode, but the
	// kernel decides connection placement and may not use every loop evenly for
	// short loopback benchmarks or low-cardinality client address sets.
	MultiCoreReusePort
	// MultiCoreBalanced round-robins accepted sockets across every App loop via
	// uWebSockets child Apps. It gives predictable per-loop connection
	// placement, at the cost of extra native handoff work on accepted sockets.
	MultiCoreBalanced
)

func (MultiCoreMode) String added in v1.3.1

func (mode MultiCoreMode) String() string

type MultipartOptions

type MultipartOptions struct {
	// MaxPartBytes caps bytes read for each individual part. Zero uses
	// GetDefaultMultipartPartLimit; NoMultipartPartLimit disables the
	// per-part cap.
	MaxPartBytes int64
}

MultipartOptions configures ParseMultipartWithOptions and ParseMultipartStream.

type MultipartPart

type MultipartPart struct {
	// Name is the form field name (the "name" attribute of the
	// originating <input>). Empty when the part has no
	// Content-Disposition name parameter.
	Name string

	// FileName is the original filename for file parts (the
	// "filename" attribute of Content-Disposition). Empty for
	// non-file parts.
	FileName string

	// ContentType is the value of the part's Content-Type header.
	// Defaults to "" when omitted by the client — handlers that
	// care should fall back to detecting from FileName or Data.
	ContentType string

	// Data is the raw bytes of this part. For file parts: the file
	// contents. For value parts: the field value bytes.
	Data []byte

	// Header carries every header the client sent on this part —
	// Content-Type, Content-Disposition, and any custom headers
	// like Content-Transfer-Encoding. Use for advanced inspection;
	// the convenience fields above cover the common cases.
	Header textproto.MIMEHeader
}

MultipartPart is one chunk of a parsed multipart/form-data body. Returned to the callback passed to ParseMultipart / Request.Multipart.

The Data slice is valid after the callback returns, but retaining it also retains that part's bytes. Use MultipartStream when file parts should be copied to disk or another writer without an extra per-part allocation.

func (*MultipartPart) IsFile

func (p *MultipartPart) IsFile() bool

IsFile reports whether this part carries a file upload (has a FileName). Convenience for the common dispatch on file-vs-value parts.

func (*MultipartPart) SaveAt

func (p *MultipartPart) SaveAt(dst string) error

SaveAt writes the part's Data to dst using os.WriteFile with mode 0o644. dst is opened with O_WRONLY|O_CREATE|O_TRUNC and is closed before SaveAt returns. Returns the error from os.WriteFile when the write fails.

Convenience for the common "save uploaded file to disk" path. SECURITY: SaveAt overwrites existing files and follows symlinks. Do not pass a path derived from client input unless you have already constrained it to a safe directory. Prefer SaveInto for file uploads, or SaveAtNew when the destination must not already exist.

func (*MultipartPart) SaveAtNew

func (p *MultipartPart) SaveAtNew(dst string) error

SaveAtNew writes the part's Data to dst only when dst does not already exist. The file is opened with O_CREATE|O_EXCL so existing files and symlinks are not overwritten.

func (*MultipartPart) SaveInto

func (p *MultipartPart) SaveInto(dir string) (string, error)

SaveInto writes the part's Data into dir, using the basename of p.FileName as the on-disk filename. Path components in FileName are stripped so a malicious client can't escape the target directory (".." or absolute paths are rejected). Returns the full path of the written file along with any os.WriteFile error.

Returns an error when FileName is empty (not a file part) or when the basename collapses to "." / "..".

type MultipartStreamPart

type MultipartStreamPart struct {
	Name        string
	FileName    string
	ContentType string
	Header      textproto.MIMEHeader
	Reader      io.Reader
}

MultipartStreamPart exposes a multipart part as a stream. It avoids the extra per-part allocation performed by ParseMultipart's Data field. The Reader is valid only during the callback.

func (*MultipartStreamPart) IsFile

func (p *MultipartStreamPart) IsFile() bool

func (*MultipartStreamPart) SaveInto

func (p *MultipartStreamPart) SaveInto(dir string) (string, error)

SaveInto streams the part into dir using the basename of FileName.

type OpCode

type OpCode int

OpCode identifies a WebSocket frame type.

const (
	// Text is a UTF-8 WebSocket message.
	Text OpCode = 1

	// Binary is a binary WebSocket message.
	Binary OpCode = 2
)

type PanicHandler

type PanicHandler func(recovered any)

PanicHandler is invoked when user code panics inside an HTTP, async, WebSocket, defer, or body callback. HTTP paths emit a best-effort 500 when a response is still available. The argument is the recovered value (the panic payload).

The framework ships a default handler that prints the panic value plus a goroutine stack trace to stderr, so production deployments never have a panic disappear silently. Call SetPanicHandler with a custom function to route panics elsewhere (structured logger, error tracker), or pass nil to restore the default.

type PostAsyncHandler

type PostAsyncHandler = BodyAsyncHandler

PostAsyncHandler is kept for source compatibility with earlier releases.

type PublishMessage

type PublishMessage struct {
	Topic   string
	Message []byte
	OpCode  OpCode
}

PublishMessage is one entry in an App.PublishBatch call.

Topic is the exact subscriber topic string (no wildcards — see App.Publish). Message is the payload bytes. OpCode picks Text vs Binary framing per-message, so a single batch can mix the two.

type Reply

type Reply struct {
	Status      int    // defaults to 200 when zero
	ContentType string // omits Content-Type header when empty
	Body        string
}

Reply is a static response captured once at registration time. Routes registered with this target are served entirely by the C++ event loop with no cgo callback per request — use it for /health, /version, cached config, or any constant response.

type Request

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

Request wraps a uWebSockets request. Sync handlers receive a Request backed by the live uWS HttpRequest; async/shared handlers receive a Request backed by a snapshot copied into the AsyncCtx before the original request was freed.

Both modes expose the same accessors. The shared snapshot has fixed capacity per field (URL 256, query 512, params 64 each up to 8, headers 8 KB total); requests past those caps are rejected with 431 before reaching user code.

func (*Request) Body

func (r *Request) Body() []byte

Body returns the fully collected request body for body-async routes such as PostAsync, PutAsync, PatchAsync, and DeleteAsync; for other routes it returns nil. The slice is owned by the framework — do not retain it past the handler call.

func (*Request) BodyParser

func (r *Request) BodyParser(out any) error

BodyParser deserializes the request body into out based on the request's Content-Type header. Supported media types:

  • application/json — Config.JSONDecoder, defaulting to encoding/json
  • application/x-www-form-urlencoded — form decoding into struct fields tagged with `form:"name"` (falls back to lower-cased field name when the tag is missing).
  • multipart/form-data — same form-field decoding for non-file parts. File parts are ignored by BodyParser; use ParseMultipart (separate helper) to iterate them.

out must be a non-nil pointer (typically to a struct). Returns ErrNoBody when the body has not been collected, or ErrUnsupportedMediaType for a media type the parser does not handle. JSON / form parse errors surface verbatim from their respective packages so handlers can inspect them. JSON bodies use the App's Config.JSONDecoder when one is configured.

func (*Request) Context

func (r *Request) Context() context.Context

Context returns a context.Context bound to this request's lifetime. It is canceled (with a non-nil Err) when the client aborts the connection before the response is sent, so downstream calls that accept a context — db.QueryContext, http.NewRequestWithContext, rate-limited goroutines — will short-circuit instead of doing wasted work on behalf of a vanished caller.

The context is lazy: created on the first call and reused for subsequent calls on the same request. Handlers that never touch it pay nothing. It is canceled both on client abort and on handler completion (via the pool reset), so callbacks registered via context.AfterFunc fire reliably even on the success path.

For requests constructed outside the dispatch pipeline (e.g. test fixtures that allocate a Request directly), Context() returns context.Background() — usable but non-cancelable.

func (*Request) Cookie

func (r *Request) Cookie(name string) string

Cookie returns the value of a named cookie from the Cookie header, or "" if the cookie is absent. Linear scan; cache the value if you need it more than once. Cookie names are case-sensitive per RFC 6265.

func (*Request) CookieSigned

func (r *Request) CookieSigned(name string, secrets ...string) (string, bool)

CookieSigned reads the named cookie and verifies its signature against secrets. Returns the original (pre-signing) value when any secret validates, ("", false) otherwise — the same way Cookie returns "" when a cookie is absent.

user, ok := req.CookieSigned("session", secret)
if !ok {
    res.Send(401, "text/plain; charset=utf-8", "unauthorized")
    return
}

func (*Request) Get

func (r *Request) Get(name string) string

Get is an alias for Header (case-insensitive header lookup). Mirrors the req.get(name) helper that fiber / express users reach for first.

func (*Request) Header

func (r *Request) Header(name string) string

Header returns a request header value. Header lookups in async/shared handlers parse the snapshot buffer on every call; cache the value if you need it multiple times.

In sync mode the C++ dispatcher packs request headers into a stack scratch blob before calling into Go, so this scan is allocation-free and pays zero cgo per call. Ordinary misses return from Go; only requests whose headers overflow the 8 KB scratch buffer fall back to the uWS getHeader helper via cgo.

func (*Request) Headers

func (r *Request) Headers(fn func(name, value string) bool) int

Headers iterates every request header pair, lowercase name first. fn returns false to stop early — same convention as sync.Map.Range. Returns the number of headers visited.

In sync mode this walks the per-call scratch blob the C++ dispatcher packs into the request when the blob is complete. Requests whose header set overflows that scratch space fall back to one native full-header dump so trailing headers are not silently hidden. Async handlers walk the snapshot blob captured before the live request was freed. Names are returned in the order uWS parsed them, which matches the order on the wire.

Useful for middleware that copies headers verbatim (tracing context propagation, raw audit logs, etc.) without paying one Header(name) call per known header.

req.Headers(func(name, value string) bool {
    out.Header(name, value)
    return true
})

func (*Request) Hostname

func (r *Request) Hostname() string

Hostname returns the host portion of the Host header, with any ":port" suffix stripped. Returns "" if the request has no Host header.

func (*Request) IP

func (r *Request) IP() string

IP returns the formatted peer IP for this connection. For routes behind a proxy use IPs() and pick from the X-Forwarded-For chain instead — this returns the immediate TCP peer, which will be the proxy itself.

Sync handlers lazily cgo into uWS on first read and cache the result for subsequent reads. Async / shared handlers serve from the snapshot captured at request arrival.

func (*Request) IPs

func (r *Request) IPs() []string

IPs parses the X-Forwarded-For header into a slice of normalized IPs in the order the proxies appended them (leftmost = original client). Returns nil if the header is absent, empty, or contains no valid IP entries. Empty and malformed entries are skipped; IPv4-mapped IPv6 addresses are unmapped.

Returns nil when the immediate peer is not trusted by Config.TrustProxy or Config.TrustedProxies. Without a trusted peer, the X-Forwarded-For header is attacker-controlled and any IP in it should be treated as untrusted input, not exposed via this helper. Callers that genuinely need the raw header value on an internet-facing server (rare, and almost always a logging mistake) can read it via req.Header("x-forwarded-for") and parse it themselves.

func (*Request) Local

func (r *Request) Local(key string) any

Local fetches a value previously stored with SetLocal. Returns nil if the key is absent.

func (*Request) Method

func (r *Request) Method() string

Method returns the HTTP method ("get", "post", ...). uWS lower-cases it during parsing. Sync handlers serve this from the pre-cached method pointer the bridge stashed at handler entry; first call allocates a Go string, subsequent calls return the cached value — no cgo on the hot path.

func (*Request) Multipart

func (r *Request) Multipart(fn func(*MultipartPart) error) error

Multipart is the Request-side wrapper for ParseMultipart. Use it from body-async handlers where the body is pre-collected; sync handlers should collect the body via Response.Body first and call ParseMultipart directly.

Returns ErrNoBody when the body has not been collected, or ErrUnsupportedMediaType when the request's Content-Type is not multipart/form-data.

func (*Request) MultipartStream

func (r *Request) MultipartStream(opt MultipartOptions, fn func(*MultipartStreamPart) error) error

MultipartStream iterates multipart parts as streams.

func (*Request) MultipartWithOptions

func (r *Request) MultipartWithOptions(opt MultipartOptions, fn func(*MultipartPart) error) error

MultipartWithOptions is Multipart with explicit limits.

func (*Request) Param

func (r *Request) Param(name string) string

Param looks up a route parameter by name. The name is the identifier written after ':' in the route pattern — e.g. for `/users/:id/posts/:postID` the names are "id" and "postID".

Returns "" if name was never declared in the route pattern (typo, or registered via a code path that bypasses the framework's wrapper). Use Parameter(index) for positional access when you know the index ahead of time.

app.Get("/users/:id/posts/:postID", func(res *gogo.Response, req *gogo.Request) {
    id := req.Param("id")
    post := req.Param("postID")
    ...
})

func (*Request) ParamInt

func (r *Request) ParamInt(name string, def int) int

ParamInt parses Param(name) as a signed decimal integer. Returns def when the param is missing or doesn't parse. Mirrors QueryInt.

func (*Request) ParamInt64 added in v0.2.0

func (r *Request) ParamInt64(name string, def int64) int64

ParamInt64 parses Param(name) as a signed decimal int64. Returns def when the param is missing or doesn't parse. Mirrors QueryInt64.

func (*Request) Parameter

func (r *Request) Parameter(index int) string

Parameter returns a route parameter by index. Returns "" for negative or out-of-range indices. Snapshot mode caps at 8 parameters; sync mode caches indices 0..3 inline (the bridge pre-fills them at handler entry — no cgo on first read) and falls back to the cgo getParameter helper for indices 4+.

func (*Request) ParameterInt

func (r *Request) ParameterInt(index int, def int) int

ParameterInt parses the route parameter at index as a base-10 int. Missing or non-numeric values fall back to def. Positional twin of ParamInt(name, def); use whichever matches your access style.

func (*Request) ParameterInt64

func (r *Request) ParameterInt64(index int, def int64) int64

ParameterInt64 parses the route parameter at index as a base-10 int64. Missing or non-numeric values fall back to def. Positional twin of ParamInt64(name, def); use whichever matches your access style.

func (*Request) Protocol

func (r *Request) Protocol() string

Protocol returns "http" or "https". The framework itself only speaks plaintext — gogo is intended to run behind a TLS-terminating gateway such as nginx, an L7 load balancer, or a CDN. When the request's immediate peer is trusted by Config.TrustProxy or Config.TrustedProxies, the X-Forwarded-Proto header from the gateway is honored so the application sees the original client protocol; otherwise the answer is always "http" so an untrusted client can't spoof its way to appearing as https.

func (*Request) Query

func (r *Request) Query() string

Query returns the raw query string portion of the URL with the leading '?' stripped. Returns "" if the request has no query string.

For parsed access, prefer QueryParam(key) for single keys or pass the result to net/url.ParseQuery for a full map.

Sync handlers serve from the pre-cached query pointer (no cgo) on first call; subsequent calls hit the cache.

func (*Request) QueryBool

func (r *Request) QueryBool(name string, def bool) bool

QueryBool parses the named query parameter as a boolean. Accepts "1", "true", "t", "yes", "y", "on" as true and "0", "false", "f", "no", "n", "off" as false (all case-insensitive). Missing or unrecognized values fall back to def. Empty string ("?flag&") is treated as missing — pass def=true if you want the bare-flag idiom.

func (*Request) QueryInt

func (r *Request) QueryInt(name string, def int) int

QueryInt parses the named query parameter as a base-10 int and returns it. Missing or non-numeric values fall back to def. Negative values are accepted.

func (*Request) QueryInt64

func (r *Request) QueryInt64(name string, def int64) int64

QueryInt64 parses the named query parameter as a base-10 int64. Missing or non-numeric values fall back to def.

func (*Request) QueryParam

func (r *Request) QueryParam(name string) string

QueryParam returns the value of a single query parameter. Returns "" if the key is absent. Snapshot mode parses the raw query string on each call; cache the result if you need it more than once. Returns "" for an empty key.

func (*Request) Secure

func (r *Request) Secure() bool

Secure reports whether the connection is encrypted (TLS / HTTPS). Honors X-Forwarded-Proto only when the immediate peer is trusted by Config.TrustProxy or Config.TrustedProxies.

func (*Request) SetLocal

func (r *Request) SetLocal(key string, value any)

SetLocal stores a request-scoped value under key. Intended for passing state from middleware down to the handler (e.g. an authenticated user resolved by async middleware). The value lives only as long as the request — the map is cleared when the Request returns to its pool.

SetLocal is not safe for concurrent use within a single request; treat the Request as owned by whatever goroutine is currently running it.

func (*Request) Truncated

func (r *Request) Truncated() bool

Truncated reports whether an async request snapshot exceeded one of gogo's fixed capture buffers. The shared fast path rejects truncated requests before invoking handlers; this remains useful for diagnostics and future snapshot paths.

func (*Request) URL

func (r *Request) URL() string

URL returns the request URL path. Query string is exposed separately via Query(); URL() does not include it.

In sync mode the bridge stashes the URL bytes uWS already parsed onto the Request at handler entry, so the first call materializes a Go string from those bytes (one allocation, no cgo). Subsequent calls return the cached string. Async/shared handlers read from the captured snapshot.

type Response

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

Response wraps a uWebSockets response.

func (*Response) Append

func (r *Response) Append(key, value string) *Response

Append adds a header value without replacing existing ones. Identical in effect to Header — included for parity with fiber/express idiom where Set replaces and Append accumulates. uWS doesn't support replace, so both helpers do the same thing.

Use for multi-value headers: Set-Cookie, Vary, Link, etc.

func (*Response) Async

func (r *Response) Async(fn func())

Async marks the response for asynchronous handling and runs fn on a new goroutine. After calling Async, subsequent Status/Header/Write calls buffer Go-side and End flushes the buffered response back onto the event loop with a single cork. fn may block freely.

Call Async at most once per response, and before any synchronous Status/Header/Write/End calls. The outer handler should return immediately after calling Async.

func (*Response) AwaitDrain

func (r *Response) AwaitDrain(threshold uint64) error

AwaitDrain blocks the caller until BufferedAmount falls below threshold, or returns nil immediately if it's already below. It samples uWS's buffered byte counter at a short interval from the producer goroutine. The polling fallback is intentional: uWS's onWritable signal is edge-triggered and can be missed when a buffer drains before the callback is armed, which would otherwise park the stream permanently.

Only valid while a Stream is in flight (r.async != nil); calling outside that scope returns nil with no work done.

Returns ErrStreamAborted when the client disconnects before the buffer drains. Callers in a streaming loop should propagate the error to break out of their generator.

func (*Response) Body

func (r *Response) Body(maxBytes int, done func(body []byte, err error))

Body collects the full request body and invokes done once it has arrived. If the body exceeds the effective limit, done is called with err = ErrBodyTooLarge and the response is closed without sending. If the client aborts before the body completes, done is not called; use OnAborted or Request.Context for abort cleanup. Call inside the route handler before it returns; done runs on the loop thread (spawn a goroutine for blocking work).

The effective limit is the LOWER of maxBytes and Config.BodyLimit when BOTH are positive. A handler that asks for 10 MiB on an app configured with BodyLimit=1 MiB tops out at 1 MiB — the app cap wins. This mirrors the OnData and Content-Length gates so all three intake paths enforce the same upper bound; without the clamp here, a chunked upload (which sidesteps the C++ Content-Length pre-check) could exceed the app's BodyLimit whenever the handler's local cap was larger.

maxBytes <= 0 is honored literally and NOT widened by Config.BodyLimit: Body(0, ...) accepts a zero-byte body and rejects everything else; Body(-1, ...) rejects every chunk. Clamping is one-directional — the app cap can tighten the caller's request, never loosen it.

func (*Response) BufferedAmount

func (r *Response) BufferedAmount() uint64

BufferedAmount returns the byte count uWS has accepted for sending but not yet flushed to the kernel socket. Grows when the client isn't draining fast enough — the standard backpressure signal — and shrinks as the OS acknowledges sends.

Intended use is from inside a Stream callback to decide whether to pause emitting more bytes:

res.Stream(200, "text/event-stream", func(w io.Writer) error {
    for event := range events {
        // Yield to the loop when uWS has > 1 MiB buffered;
        // otherwise a slow client can OOM the server with
        // queued chunks.
        if err := res.AwaitDrain(1 << 20); err != nil {
            return err
        }
        if _, err := w.Write([]byte(event)); err != nil {
            return err
        }
    }
    return nil
})

The read is sampled from a worker goroutine without a loop hop — uWS's internal counter is an atomic-aligned size_t in uSockets, so reads are coherent but may lag by a few microseconds. Adequate for throttling decisions; do not use for transactional accounting.

func (*Response) Cork

func (r *Response) Cork(fn func())

Cork batches all response writes inside fn into a single packet. Required when sending a response from a deferred callback to avoid uWebSockets warning about uncorked writes.

func (*Response) Download

func (r *Response) Download(req *Request, path, filename string) error

Download is SendFile plus Content-Disposition: attachment, which prompts browsers to save the body to disk instead of rendering it inline. filename overrides the suggested name in the header; pass "" to default to filepath.Base(path). The filename is wrapped in quoted-string form with quote / backslash escaped per RFC 6266; non-ASCII filenames receive an RFC 5987 filename* parameter so non-Latin-1 names survive transport.

SECURITY: path handling matches SendFile — no sanitization is performed, so never build path from untrusted request input; see the SendFile doc for the safe base-directory pattern.

func (*Response) End

func (r *Response) End(body string)

End finishes the response.

func (*Response) Header

func (r *Response) Header(key, value string) *Response

Header writes a response header.

Each Header call appends to the wire output; calling Header twice with the same key emits two header lines (no replace semantic — uWS does not support that). For multi-value headers (Set-Cookie, Vary, Link) call Header / Append repeatedly with the same key.

In async mode Content-Type is short-circuited onto the async fast path (stored on r.async directly), and any other header is buffered in pendingHeaders. flushAsync routes pendingHeaders through uwsgo_res_defer_send_with_headers, which writes them between the status line and the body. That means the shared-memory fast path (zero cgo) is only available for responses that set Content-Type alone; the moment a handler attaches any extra header the response goes through the cgo defer-send shim instead.

func (*Response) JSON

func (r *Response) JSON(code int, v any)

JSON marshals v and sends it with Content-Type: application/json. If marshalling fails the response is replaced with a generic 500 and the underlying marshal error is reported through the panic handler so the programmer sees it server-side without leaking type / package names to the network. With the default encoder, marshal failures happen for unsupported value shapes (channels, functions, cyclic structures), so failures here usually indicate a bug in caller code.

func (*Response) JSONBytes

func (r *Response) JSONBytes(code int, b []byte)

JSONBytes writes a pre-marshaled JSON body. Skips json.Marshal so handlers that already hold an encoded payload — cached responses, proxied bytes from another service, custom-encoder output (sonic, segmentio, etc.) — don't pay the reflection cost a second time.

The caller is responsible for the bytes being valid JSON; the framework does not validate. Content-Type is set to application/json automatically.

func (*Response) JSONP

func (r *Response) JSONP(callback string, v any)

JSONP writes a JSONP response — the JSON-encoded value v wrapped in a function call named callback, served with Content-Type application/javascript. Useful for cross-origin reads from older clients that pre-date CORS; most modern apps should prefer JSON + proper CORS configuration via middleware.CORS.

The callback name is validated to contain only the JavaScript identifier characters [A-Za-z0-9_$.] so an attacker can't slip `</script>` or a closing parenthesis into the response and pivot the JSONP payload into an XSS sink. An invalid callback (empty or containing other characters) returns 400 with no body.

The marshaled JSON is also escaped against U+2028 / U+2029 — line separators that are legal in JSON but break JavaScript parsing when not escaped (each becomes a literal newline outside a string).

app.Get("/api/users", func(res *gogo.Response, req *gogo.Request) {
    cb := req.QueryParam("callback")
    res.JSONP(cb, []User{...})
})

func (*Response) JSONStream

func (r *Response) JSONStream(code int, fn func(*json.Encoder) error) error

JSONStream emits a JSON body through a streaming encoder, avoiding the staging-buffer allocation that json.Marshal makes for the whole document. Useful for large arrays, NDJSON-style feeds, or any response whose JSON would otherwise dominate the handler's memory peak.

fn receives a *json.Encoder writing through a chunked response stream — each Encode call emits one JSON value followed by a newline (json.Encoder's default). For a single top-level array the caller is responsible for writing the framing characters themselves; for newline-delimited feeds Encode is enough. JSONStream intentionally uses encoding/json's streaming encoder rather than Config.JSONEncoder, whose contract is whole-value marshal. For custom codec output, marshal each value yourself and write through Response.Stream or send pre-marshaled bytes with Response.JSONBytes.

Async only — call from GetAsync, a body-async route, or wrap a sync handler in Response.Async. The underlying Response.Stream applies the default backpressure cap (StreamBackpressureBytes), so a slow consumer parks the producer goroutine rather than spiking memory.

func (*Response) Loop

func (r *Response) Loop() *Loop

Loop returns the event loop that owns this response. Capture it inside the handler before spawning a goroutine. The returned Loop is safe to use from any goroutine; the Response itself is not.

func (*Response) OnAborted

func (r *Response) OnAborted() *Aborted

OnAborted registers an abort callback and returns an atomic flag that is set to true if the client disconnects before the response is sent. Must be called synchronously inside the route handler when responding asynchronously.

func (*Response) OnData

func (r *Response) OnData(fn func(chunk []byte, isLast bool))

OnData registers a body-chunk callback. fn is invoked once per chunk uWS receives, with isLast == true on the final chunk. Must be called inside the route handler, before it returns. The callback runs on the loop thread — spawn a goroutine inside it if the work can block.

Each chunk slice is freshly allocated; the caller owns it and may retain references after fn returns. OnData takes one wrapper ref at registration. The ref is released on isLast — at which point the cgo handle for the lambda is also freed. Body's onAborted handler releases this ref on early abort (when the last chunk would never fire). OnData registers a body-chunk callback. Each chunk is freshly allocated; the caller owns it. fn runs on the loop thread.

OnData takes one wrapper ref at registration and releases it on the final chunk (isLast == true). When the connection aborts before isLast fires the ref is held until the response is destroyed; for the Body() collector that case is covered explicitly via its own onAborted.

func (*Response) OnFinish

func (r *Response) OnFinish(fn func())

OnFinish registers fn to fire after the response's handler — and any goroutine spawned via Response.Async — has fully completed. This is the safe hook for middleware that needs to persist or flush state derived from the request:

// Inside middleware, before calling next(res, req):
defer res.OnFinish(func() { saveSession(s) })
next(res, req)

The hook decides automatically which mode applies:

  • If the handler never upgraded to async (sync route, no Response.Async call), fn runs synchronously now — the handler has already returned so its state is stable.
  • If the handler went async via Response.Async or the route was GetAsync, fn is queued and runs inside finishAsync after the goroutine completes.
  • If the async goroutine finished BEFORE the middleware reached OnFinish (fast handlers), fn runs inline so it's never orphaned.

Multiple registrations fire in FIFO order. Panics inside fn are captured by the framework's panic handler and do not prevent later callbacks from running, mirroring net/http's http.ResponseWriter recovery posture.

Without this hook, middleware that does `next(res, req); persist(state)` and a handler that calls Response.Async would silently persist pre-async state — the goroutine's mutations land after persist already ran.

Drain timing:

  • Sync response: callbacks queue and fire from releaseRef, which runs from the framework's HTTP-handler defer AFTER any panic recovery and Send(500). This is what makes Logger / Metrics see status=500 when the handler panics rather than the pre-panic statusCode value.
  • Async response (Response.Async or GetAsync): callbacks fire from finishAsync as soon as the goroutine completes, then releaseRef finds the queue empty when it eventually recycles.
  • Late-registration race in async mode: if the goroutine drained and reset r.finished before the middleware reached OnFinish, the callback runs inline — the response wrapper is still alive because the caller still holds a ref.

func (*Response) Redirect

func (r *Response) Redirect(location string, code int)

Redirect sends an HTTP redirect to location with the given status code. Standard codes: 301 (moved permanently), 302 (found / temporary, common default), 303 (see other — POST → GET), 307 (temp, preserves method), 308 (permanent, preserves method). Status 0 defaults to 302.

CRLF / NUL in location is treated as a programming or input-validation bug: the framework refuses to write the bad header and responds 500 instead of panicking. This keeps a hostile request from amplifying into a panic-handler trigger when handler code passes user input straight to Redirect (e.g. res.Redirect(req.QueryParam("next"), 302)).

SECURITY: gogo does NOT validate that location stays within your own host. Passing user-controlled input here without a host allow-list is an open-redirect bug — attackers can craft links that look like they land on your site but bounce to a phishing page. Sanitize the target (compare against a known list of paths or hostnames) before calling Redirect.

In async mode this schedules a Cork on the loop so the status, Location header, and empty body go out as a single packet; do not call Send / End on the same response afterwards.

func (*Response) Render

func (r *Response) Render(name string, data any)

Render renders a named template with data and writes the result as a 200 text/html response. Set Content-Type via res.Header before calling Render if the engine emits something other than HTML (XML, plain text, etc.).

Errors from the engine — missing template, parse failure, execution failure — surface as a 500 response with no body detail leaked to the client; the error itself is routed to the panic logger so operators can diagnose.

app.Get("/users/:id", func(res *gogo.Response, req *gogo.Request) {
    u, err := db.LoadUser(req.Param("id"))
    if err != nil {
        res.Send(404, "text/plain", "not found")
        return
    }
    res.Render("user/profile", map[string]any{"user": u})
})

func (*Response) SSE

func (r *Response) SSE(fn func(*SSEStream) error) error

SSE prepares the response as a Server-Sent Events stream and runs fn against a writer that knows the SSE frame format. Auto- installed headers:

Content-Type:     text/event-stream
Cache-Control:    no-cache
Connection:       keep-alive
X-Accel-Buffering: no       (defeats nginx proxy_buffering)

Internally delegates to Response.Stream, so SSE inherits its constraints: async-only (call from GetAsync, a body-async route, or res.Async), streaming chunked-encoded body, panic-on-sync-handler.

fn returns when the stream is complete — typically because the upstream event source closed, a context cancellation fired, or the client disconnected while the producer was parked on backpressure. Use errors.Is(err, ErrStreamAborted) to treat disconnects as normal client churn. The error fn returns is the error SSE returns; the stream itself is always closed cleanly regardless.

func (*Response) Send

func (r *Response) Send(code int, contentType, body string)

Send writes status code, an optional Content-Type header, and body in one call. The framework picks the lowest-overhead path based on the handler's mode:

  • Sync handler (Get / Post / Any): one cgo crossing into uWS.
  • Async handler (GetAsync / small PostAsync) with body up to 8 KB: ZERO cgo per request — written to shared-memory inline buffers and pushed onto the App's response ring; the loop drains it.
  • Async handler with body > 8 KB: cgo Loop::defer with the full payload, status, and Content-Type.

Pass an empty contentType to omit the header.

func (*Response) SendFile

func (r *Response) SendFile(req *Request, path string) error

SendFile reads path from disk and writes it as the response body. Content-Type is picked from the file extension via mime.TypeByExtension; for unknown extensions the first 512 bytes are passed through http.DetectContentType. Last-Modified and a weak ETag derived from (size, mtime) are set automatically.

Conditional requests are honored: If-None-Match against the ETag and If-Modified-Since against the file mtime each short-circuit to 304 Not Modified with no body. Single-byte range requests on req (`Range: bytes=start-end`, `bytes=start-`, `bytes=-suffix`) return 206 Partial Content with the requested slice; multi-range and non-bytes units fall through to the full 200.

The helper refuses files larger than MaxSendFileBytes (default 100 MiB) and returns ErrFileTooLarge. Returns an error when path is missing, a directory, or unreadable; the response is left untouched in that case so the caller can decide what to send.

Memory usage is bounded by SendFileChunkBytes (default 64 KiB) + SendFileBackpressureBytes (default 1 MiB) per concurrent call, regardless of file size. The body is streamed via chunked transfer-encoding using Response.Stream, so a slow consumer applies backpressure to the read loop rather than buffering the entire file.

From a sync handler SendFile transparently upgrades to async via Response.Async so the uWS loop thread isn't blocked on disk I/O; the function returns nil immediately after the open/stat/range prep, and the body streams from a goroutine.

SECURITY: path is opened exactly as given — the framework performs no sanitization, so a path built from request input (route params, query strings, form fields) lets clients traverse outside the intended directory with ".." segments or absolute paths. Never pass untrusted input directly; resolve it against a fixed base directory first and verify the result stays inside it:

p := filepath.Join(base, filepath.Clean("/"+req.Param("name")))

Cleaning rooted at "/" strips every ".." before the join, so p cannot escape base. Symlinks under base are still followed; keep the tree free of links pointing outside it. See docs/file-serving.md for the full rooted-path and symlink guidance.

func (*Response) SetBodyEncoder

func (r *Response) SetBodyEncoder(encode func(body []byte, contentType string) (encoded []byte, contentEncoding string))

SetBodyEncoder installs a body transformer to apply right before the response body is flushed to uWS. The encoder receives the concatenated bytes from any Write / End / Send / JSON calls, and returns the encoded body together with a Content-Encoding value to attach (or empty string to skip encoding and emit the original bytes unchanged).

Compression middleware uses this hook to transparently gzip / brotli the handler's output without requiring handlers to be aware of the encoding. The encoder runs in both sync and async modes; when the encoder emits a Content-Encoding header, the response is routed through the defer-send-with-headers C shim (async fast shared-memory path is bypassed because it has no slot for extra headers).

Only one encoder per response — calling SetBodyEncoder a second time replaces the first. Once the encoder has run (on End or Send) it is consumed; subsequent writes go through the normal direct path.

func (*Response) SetBodyEncoderLimit

func (r *Response) SetBodyEncoderLimit(maxBytes int, encode func(body []byte, contentType string) (encoded []byte, contentEncoding string))

SetBodyEncoderLimit is SetBodyEncoder plus a staging-buffer cap. When maxBytes is positive and the buffered body would grow beyond it, the encoder is bypassed and the response continues unencoded from the original bytes. This lets middleware such as Compress bound both compression work and the pre-compression staging buffer.

func (*Response) SetCookie

func (r *Response) SetCookie(c Cookie)

SetCookie writes a Set-Cookie response header. Every field that reaches the wire is validated against the relevant grammar before serialization:

  • Name: RFC 6265 cookie-name (token grammar, no CTL/separator)
  • Value: RFC 6265 cookie-octet
  • Path / Domain / Expires: reject CTLs and ";" so user-controlled input cannot inject additional attributes by smuggling a semicolon (e.g. Path="/; Domain=evil.com; Secure=false")
  • SameSite: exact match against "", "Strict", "Lax", "None"

Invalid characters panic — they typically indicate a programming bug or untrusted input reaching a Cookie field unprotected. SetCookieSigned routes through SetCookie so this validation covers signed cookies too.

Multiple calls append multiple Set-Cookie headers; browsers handle them independently.

func (*Response) SetCookieSigned

func (r *Response) SetCookieSigned(c Cookie, secrets ...string)

SetCookieSigned signs c.Value with the first secret, replaces c.Value with the signed form, and writes the cookie via SetCookie. All of SetCookie's validation and header semantics apply (Path / Domain / Max-Age / Secure / HttpOnly / SameSite).

Pair with Request.CookieSigned on the read side to verify and recover the original value:

res.SetCookieSigned(gogo.Cookie{
    Name:     "session",
    Value:    "alice:42",
    Path:     "/",
    HttpOnly: true,
    Secure:   true,
    SameSite: gogo.SameSiteStrict,
    MaxAge:   3600,
}, secret)

func (*Response) Status

func (r *Response) Status(code int) *Response

Status sets the HTTP status code. The standard reason phrase from net/http is appended automatically (e.g. 200 → "200 OK", 404 → "404 Not Found"); unknown codes are written as the bare number.

func (*Response) StatusCode

func (r *Response) StatusCode() int

StatusCode returns the last status code passed to Status / Send / JSON. Returns 200 (the uWS default) if no status was ever set. Useful from middleware that needs to observe how a handler responded — e.g. a logger that records the final status.

func (*Response) Stream

func (r *Response) Stream(status int, contentType string, fn func(w io.Writer) error) error

Stream writes a chunked HTTP/1.1 response by handing the caller an io.Writer that buffers each Write call onto the uWS loop's deferred-write queue. Order is preserved (FIFO), so chunks reach the wire in the same order the writer emits them.

Stream is meant for Server-Sent Events, NDJSON feeds, log tails, and similar long-running responses where you don't know the full body up front and you don't want to materialize it before the first byte goes out. Call it from a GetAsync or body-async handler. On a sync route call res.Async() first; on a plain sync handler streaming would block the event-loop thread, which is almost never what you want.

app.GetAsync("/events", func(res *gogo.Response, req *gogo.Request) {
    err := res.Stream(200, "text/event-stream", func(w io.Writer) error {
        ticker := time.NewTicker(time.Second)
        defer ticker.Stop()
        for i := 0; i < 5; i++ {
            <-ticker.C
            if _, err := fmt.Fprintf(w, "data: tick %d\n\n", i); err != nil {
                return err
            }
        }
        return nil
    })
    if err != nil { /* logged via reportPanic */ }
})

Headers buffered via res.Header before Stream go out in the initial response frame. After Stream returns, the response is closed — do not call Send / End / Stream again on the same response.

uWS automatically applies HTTP/1.1 transfer-encoding: chunked when no Content-Length is set, which is the expected mode for streaming. If the client disconnects mid-stream the underlying AsyncCtx is marked aborted and subsequent Write calls become silent no-ops on the C side. If the stream is parked on backpressure, Write and AwaitDrain return ErrStreamAborted so callers can stop their generator early.

func (*Response) Write

func (r *Response) Write(body string) *Response

Write appends a response chunk without ending the response.

type Router

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

Router scopes middleware and a path prefix to a subtree of routes. Created by App.Group or Router.Group. Routes registered through a Router have the Router's prefix prepended and inherit the Router's middleware stack on top of the App's global / scoped middleware.

Group scope is identity-based — a route is wrapped because it is registered through this Router, not because its pattern string starts with some prefix. That avoids the pattern/URL mismatch that App.Use(prefix, mw) has to guard against at request time, so Group middleware composes at registration with zero per-request cost.

Prefer Group over App.Use(prefix, mw) for scoping new code.

func (*Router) Any

func (r *Router) Any(pattern string, handler Handler)

Any registers a route for every HTTP method under this Router.

func (*Router) Delete

func (r *Router) Delete(pattern string, handler Handler)

Delete registers a DELETE route under this Router.

func (*Router) DeleteAsync

func (r *Router) DeleteAsync(pattern string, maxBodyBytes int, handler BodyAsyncHandler)

DeleteAsync registers a DELETE route under this Router that collects the body up to maxBodyBytes then runs handler on a goroutine. On bodies over the cap the framework sends 413; on Config.BodyReadTimeout it sends 408. In both cases the handler is not called.

func (*Router) Get

func (r *Router) Get(pattern string, target any)

Get registers a GET route under this Router. Target follows the same rules as App.Get: Handler, func, Reply, string, []byte. Static targets take the zero-cgo path only when neither middleware nor typed-param constraints need to run; otherwise the static body is served by a synthetic dynamic handler.

func (*Router) GetAsync

func (r *Router) GetAsync(pattern string, handler AsyncHandler)

GetAsync registers a GET route under this Router that runs on a goroutine. Uses the zero-cgo shared-memory dispatch path only when no sync-only middleware (group or app) touches this route.

func (*Router) Group

func (r *Router) Group(prefix string, mws ...any) *Router

Group creates a nested Router. The child prefix is appended to the parent's prefix and middleware is inherited then extended — mws here run inside the parent group's middleware.

func (*Router) Head

func (r *Router) Head(pattern string, handler Handler)

Head registers a HEAD route under this Router.

func (*Router) Name

func (r *Router) Name(name, pattern string)

Name tags a route pattern under this Router's prefix for App.URL reverse routing. The pattern is the same one you pass to Router.Get/Post/etc.; the router prefix is applied automatically.

api := app.Group("/api/v1")
api.Get("/users/:id", showUser)
api.Name("api.user.show", "/users/:id")

url, _ := app.URL("api.user.show", map[string]string{"id": "42"})
// url == "/api/v1/users/42"

func (*Router) Options

func (r *Router) Options(pattern string, handler Handler)

Options registers an OPTIONS route under this Router.

func (*Router) Patch

func (r *Router) Patch(pattern string, handler Handler)

Patch registers a PATCH route under this Router.

func (*Router) PatchAsync

func (r *Router) PatchAsync(pattern string, maxBodyBytes int, handler BodyAsyncHandler)

PatchAsync registers a PATCH route under this Router that collects the body up to maxBodyBytes then runs handler on a goroutine. On bodies over the cap the framework sends 413; on Config.BodyReadTimeout it sends 408. In both cases the handler is not called.

func (*Router) Post

func (r *Router) Post(pattern string, handler Handler)

Post registers a POST route under this Router.

func (*Router) PostAsync

func (r *Router) PostAsync(pattern string, maxBodyBytes int, handler PostAsyncHandler)

PostAsync registers a POST route under this Router that collects the body up to maxBodyBytes then runs handler on a goroutine. On bodies over the cap the framework sends 413; on Config.BodyReadTimeout it sends 408. In both cases the handler is not called.

func (*Router) Put

func (r *Router) Put(pattern string, handler Handler)

Put registers a PUT route under this Router.

func (*Router) PutAsync

func (r *Router) PutAsync(pattern string, maxBodyBytes int, handler BodyAsyncHandler)

PutAsync registers a PUT route under this Router that collects the body up to maxBodyBytes then runs handler on a goroutine. On bodies over the cap the framework sends 413; on Config.BodyReadTimeout it sends 408. In both cases the handler is not called.

func (*Router) Use

func (r *Router) Use(args ...any)

Use appends middleware to this Router. Applies to every route subsequently registered through this Router (or any child Group created after this call). Bundled middleware uses its placement hint the same way it does with App.Use: sync-only middleware runs on the sync route path, async-only middleware runs only on async routes, and PlaceBoth middleware runs once in the right chain for each route type.

func (*Router) UseAsync

func (r *Router) UseAsync(mws ...AsyncMiddleware)

UseAsync appends async middleware to this Router. Applies only to GetAsync and body-async routes registered through this Router.

func (*Router) WebSocket

func (r *Router) WebSocket(pattern string, behavior WebSocketBehavior)

WebSocket registers a WebSocket route under this Router. Middleware does not run around WebSocket upgrade — uWS does not expose a chain at the upgrade boundary.

type RunMultiCoreOptions added in v1.3.1

type RunMultiCoreOptions struct {
	// Mode controls accepted-socket distribution. Zero selects
	// MultiCoreAuto.
	Mode MultiCoreMode

	// WorkerHintLoops overrides the loop count used to size the default
	// GetAsync worker pool. Zero keeps the mode default: reuseport uses one
	// loop, balanced uses n loops. Use SetWorkerCount for an exact worker
	// count instead of a loop-count hint.
	WorkerHintLoops int
}

RunMultiCoreOptions configures RunMultiCoreWithOptions.

type SSEEvent

type SSEEvent struct {
	// ID is the event identifier (the `id:` field). Browsers
	// stash the last received ID and re-send it as the
	// Last-Event-ID header when they reconnect after a network
	// blip, so handlers can resume from that point.
	ID string

	// Event names the event type (the `event:` field). Defaults
	// to "message" in browser EventSource listeners; set this
	// when you want client code to listen for a specific name
	// via addEventListener.
	Event string

	// Data is the event payload (the `data:` field). Accepted:
	//   nil          — empty data
	//   string       — sent verbatim (multi-line strings auto-
	//                  split into per-line data: emissions)
	//   []byte       — same as string
	//   error        — err.Error() text
	//   anything else — json.Marshal'd
	Data any

	// Retry sets the `retry:` field (milliseconds). Tells the
	// browser EventSource to wait this long before reconnecting
	// after a disconnect. 0 omits the field (browser default,
	// ~3 s).
	Retry int
}

SSEEvent is one Server-Sent Events frame. Every field is optional; an SSEEvent with only Data set behaves the same as Send(data).

type SSEStream

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

SSEStream is the writer handed to the SSE callback. Send / SendEvent / Comment / Ping all write directly to the underlying HTTP stream — there's no batching, so a Send call equals one SSE frame on the wire.

func (*SSEStream) Comment

func (s *SSEStream) Comment(text string) error

Comment writes a comment frame — lines starting with ':' that SSE-compliant clients silently consume. The canonical use is a keepalive ping every ~30 s so idle proxies / NAT tables don't time out the connection. Multi-line text is split into multiple `:` lines.

s.Comment("keepalive")    // : keepalive\n\n
s.Comment("multi\nline")  // : multi\n: line\n\n

func (*SSEStream) Ping

func (s *SSEStream) Ping() error

Ping is a zero-argument keepalive shorthand: ": ping\n\n". Suitable to call periodically from a ticker inside the SSE callback to keep the connection warm across proxies that time out idle TCP.

func (*SSEStream) Send

func (s *SSEStream) Send(data any) error

Send writes data as a one-line SSE frame with the default event name ("message"). Shorthand for SendEvent(SSEEvent{Data: data}).

s.Send("hello")                     // data: hello\n\n
s.Send(map[string]int{"n": 1})      // data: {"n":1}\n\n
s.Send([]byte("raw"))               // data: raw\n\n

Multi-line strings are emitted as one `data:` per line per the SSE spec — browsers re-assemble them into a single payload at the EventSource.onmessage boundary.

func (*SSEStream) SendEvent

func (s *SSEStream) SendEvent(e SSEEvent) error

SendEvent writes a full SSE frame, honoring every populated field of e. The output ordering matches the spec:

id: <ID>\n          (omitted when ID == "")
event: <Event>\n    (omitted when Event == "")
retry: <Retry>\n    (omitted when Retry == 0)
data: <line>\n      (one per line of Data)
\n                  (blank line terminates the frame)

Multi-line Data values produce one data: line each, exactly as EventSource expects.

type SameSite

type SameSite string

SameSite is the value of the SameSite cookie attribute. Empty means the attribute is not emitted (browser default applies).

const (
	SameSiteStrict SameSite = "Strict"
	SameSiteLax    SameSite = "Lax"
	SameSiteNone   SameSite = "None"
)

type TemplateEngine

type TemplateEngine interface {
	Render(w *bytes.Buffer, name string, data any) error
}

TemplateEngine is the contract every templating implementation satisfies. Render writes the named template, parameterized by data, into w. Returning an error tells the framework to surface a 500 to the client (with the error reported via the panic logger); nil means the bytes in w are the rendered response.

Engines are responsible for context-aware escaping; the framework performs no additional sanitization on the bytes that Render emits.

func NewHTMLTemplateEngine

func NewHTMLTemplateEngine(opt HTMLTemplateOptions) TemplateEngine

NewHTMLTemplateEngine builds an html/template-backed engine. Templates are named by their path relative to Root, with the suffix stripped — `views/user/profile.tmpl` is invoked as `user/profile`.

type TestServer

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

TestServer is a running App bound to a loopback port — built to be driven from unit tests. The constructor takes a setup callback that registers routes, middleware, and any other per-App state; Close stops the server and waits for the loop goroutine to exit.

The server runs on a real port so every cgo path the framework takes in production fires the same way under test (middleware chains, sync wrapping, snapshot-then-async dispatch, the zero-cgo shared-memory ring, …). The trade-off vs an in-process dispatcher is per-request latency (~50–100 µs vs ~1 µs) and the need for the OS to allocate one port per server — both acceptable for typical unit tests.

func NewTestServer

func NewTestServer(setup func(*App)) (*TestServer, error)

NewTestServer starts an App on a free port, runs the setup callback before Listen, and waits for the listening socket to accept connections before returning. Native builds own the uWS loop on an internal locked goroutine, so tests do not need runtime.LockOSThread. NewTestServer serializes TestServer lifetimes because the native binding has process-wide shared worker and ring state; this keeps parallel tests from running multiple native Apps in the same process at once. The returned TestServer drives the running App via its http.Client; Close shuts the App down cleanly.

ts, err := gogo.NewTestServer(func(app *gogo.App) {
    app.Get("/users/:id", showUser)
    app.Use(middleware.Logger())
})
if err != nil { t.Fatal(err) }
defer ts.Close()

req := httptest.NewRequest("GET", "/users/42", nil)
resp, err := ts.Do(req)
if err != nil { t.Fatal(err) }
body, _ := io.ReadAll(resp.Body)
// assert body / resp.StatusCode / resp.Header

func NewTestServerT

func NewTestServerT(tb testing.TB, setup func(*App)) *TestServer

NewTestServerT starts a TestServer and registers Close with tb.Cleanup. It fails the test immediately when setup, Listen, or readiness checks fail.

ts := gogo.NewTestServerT(t, func(app *gogo.App) {
    app.Get("/ping", func(res *gogo.Response, req *gogo.Request) {
        res.Send(200, "text/plain", "pong")
    })
})
resp, err := ts.Get("/ping")

func NewTestServerTWithOptions added in v0.6.0

func NewTestServerTWithOptions(tb testing.TB, setup func(*App) error, opts TestServerOptions) *TestServer

NewTestServerTWithOptions starts a TestServer with options and registers Close with tb.Cleanup. It fails the test immediately when setup returns an error, panics, Listen fails, or readiness checks fail.

func NewTestServerWithOptions added in v0.6.0

func NewTestServerWithOptions(setup func(*App) error, opts TestServerOptions) (*TestServer, error)

NewTestServerWithOptions starts a TestServer using opts and a setup callback that can fail without panicking. setup runs before Listen and is the place to register routes, middleware, fallback handlers, named routes, lifecycle hooks, and other startup-time App state.

ts, err := gogo.NewTestServerWithOptions(func(app *gogo.App) error {
    if err := installRoutes(app); err != nil {
        return err
    }
    return nil
}, gogo.TestServerOptions{StartupTimeout: 10 * time.Second})

func (*TestServer) App

func (ts *TestServer) App() *App

App exposes the underlying *App for runtime interactions and inspection, such as publishing to a topic, calling Shutdown, or reading registered route metadata. Route and middleware registration belongs in the setup callback before the helper calls Listen; registering routes or middleware through App after the TestServer has started is outside the TestServer contract.

func (*TestServer) Client

func (ts *TestServer) Client() *http.Client

Client returns the *http.Client wired to talk to this server. Cookies / redirects / other client-side behaviour can be tuned by setting fields on the returned client; the call site shares the same client across requests.

func (*TestServer) Close

func (ts *TestServer) Close()

Close shuts the App down gracefully and waits for the loop goroutine to return. Safe to call from any goroutine, but only the first call does work; subsequent calls are no-ops.

func (*TestServer) Do

func (ts *TestServer) Do(req *http.Request) (*http.Response, error)

Do rewrites the request URL to point at the test server, then sends it via the test client. The request method, headers, and body are sent unchanged; the URL's scheme and host are overwritten and the path / raw query are preserved.

Use this for requests built via httptest.NewRequest (which sets the URL to a placeholder host) or any custom *http.Request:

req := httptest.NewRequest("POST", "/login", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, err := ts.Do(req)

func (*TestServer) Get

func (ts *TestServer) Get(path string) (*http.Response, error)

Get is a convenience wrapper for a GET to the given path (relative to the server URL).

func (*TestServer) Port

func (ts *TestServer) Port() int

Port returns the loopback port the server is listening on.

func (*TestServer) Post

func (ts *TestServer) Post(path, contentType string, body io.Reader) (*http.Response, error)

Post is a convenience wrapper for a POST. body may be nil.

func (*TestServer) URL

func (ts *TestServer) URL() string

URL returns the http://host:port base for this server. Use it to build URLs manually when you need control over the path.

type TestServerOptions added in v0.6.0

type TestServerOptions struct {
	// Config is passed to NewApp after the helper fills BindAddr with
	// 127.0.0.1. TestServer always binds loopback; leave BindAddr empty, or
	// set it to 127.0.0.1 explicitly.
	Config Config

	// StartupTimeout bounds setup, Listen, and readiness checks. The zero value
	// uses the default five-second timeout.
	StartupTimeout time.Duration

	// Client is used by Do, Get, and Post. Nil installs the helper's default
	// client. Close calls Client.CloseIdleConnections before shutting down the
	// server.
	Client *http.Client
}

TestServerOptions configures NewTestServerWithOptions.

The zero value is ready to use: the server binds to 127.0.0.1, startup waits up to five seconds, and Client returns an http.Client with keep-alives disabled and a ten-second request timeout.

type UpgradeContext

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

UpgradeContext is passed to WebSocketBehavior.Upgrade for every incoming WebSocket handshake. It carries a snapshot of the request (URL, query, headers, peer IP, offered subprotocols) and exposes Accept / Reject to drive the response.

Lifetime is the duration of the Upgrade callback only. Do NOT store the pointer; the underlying C++ context is freed the moment the callback returns.

func (*UpgradeContext) Accept

func (c *UpgradeContext) Accept(protocol string)

Accept completes the WebSocket handshake. protocol is the subprotocol to echo back in the Sec-WebSocket-Protocol response header — pass one of Protocols() or "" to skip negotiation.

After Accept returns, the connection is live and the Open callback will fire shortly. Calling Accept more than once on the same context is a no-op.

func (*UpgradeContext) Header

func (c *UpgradeContext) Header(name string) string

Header reads a request header by name (case-insensitive). The header blob is parsed on every call — cache if you need a header multiple times.

func (*UpgradeContext) IP

func (c *UpgradeContext) IP() string

IP returns the canonical peer IP address as a printable string.

func (*UpgradeContext) Method

func (c *UpgradeContext) Method() string

Method returns the HTTP method of the upgrade request — always "get" in practice for a real WebSocket handshake, but exposed for completeness.

func (*UpgradeContext) Protocols

func (c *UpgradeContext) Protocols() []string

Protocols returns the list of subprotocols the client offered in the Sec-WebSocket-Protocol header. Whitespace around each entry is trimmed. Pick one with Accept(name) to echo it back; passing "" accepts without naming a protocol.

func (*UpgradeContext) Query

func (c *UpgradeContext) Query() string

Query returns the raw query string portion of the URL (no leading '?'). Use QueryParam to read a specific key.

func (*UpgradeContext) QueryParam

func (c *UpgradeContext) QueryParam(name string) string

QueryParam reads a named query parameter. Linear scan; cache the result if you need it more than once.

func (*UpgradeContext) Reject

func (c *UpgradeContext) Reject(status int, body string)

Reject refuses the upgrade with an HTTP status and plain-text body. The connection is closed; no open / message / close callbacks fire. Calling Reject more than once is a no-op.

if !validToken(ctx.Header("authorization")) {
    ctx.Reject(401, "unauthorized")
    return
}

func (*UpgradeContext) SetUserData

func (c *UpgradeContext) SetUserData(v any)

SetUserData stashes a Go value on the upgrade context. On Accept the framework wraps it in a cgo.Handle and attaches it to the resulting WebSocket so handlers can read it via ws.UserData(). The handle is released automatically when the WebSocket closes.

Calling SetUserData(nil) clears any previously-stashed value.

func (*UpgradeContext) URL

func (c *UpgradeContext) URL() string

URL returns the upgrade request URL path (no query string).

type WSHub

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

WSHub coordinates WebSocket topic publishes across all App instances attached to this process, and optionally across processes through an adapter such as RedisWSHubAdapter.

Register routes through hub.WebSocket so the hub can tell which App owns each socket. That lets PublishFrom skip the sender without calling WebSocket.Publish from a non-loop goroutine.

func NewWSHub

func NewWSHub(opts ...WSHubOption) *WSHub

NewWSHub creates a WebSocket hub. With no adapter, it still fans out across every App attached in the current process, which is enough for RunMultiCore.

func (*WSHub) Attach

func (h *WSHub) Attach(app *App)

Attach includes app in process-local fan-out. It is called automatically by WebSocket, but is useful when routes are registered manually. PublishFrom still needs sockets registered through WebSocket or Wrap. Attach does not start the adapter; call Start at boot when you want fail-fast behavior.

func (*WSHub) Close

func (h *WSHub) Close() error

Close stops the adapter subscription and adapter workers. It is idempotent and does not close any attached App.

func (*WSHub) Publish

func (h *WSHub) Publish(topic string, message []byte, opcode OpCode) error

Publish broadcasts to every local App attached to the hub, then queues the message for the adapter if present. It is safe to call from any goroutine. Adapter publish failures are reported asynchronously through WithWSHubAdapterErrorHandler.

func (*WSHub) PublishBatch

func (h *WSHub) PublishBatch(msgs []PublishMessage) error

PublishBatch broadcasts many messages with one loop-local batch per attached App, then queues each message for the adapter. For local fan-out this keeps the same batching advantage as App.PublishBatch without re-entering RunMultiCore peer fan-out. Adapter publish failures are reported asynchronously through WithWSHubAdapterErrorHandler.

func (*WSHub) PublishFrom

func (h *WSHub) PublishFrom(ws *WebSocket, topic string, message []byte, opcode OpCode) error

PublishFrom broadcasts from a WebSocket handler and skips the sender. It is safe to call from any goroutine, but ws must have been registered through WebSocket or Wrap so the hub can identify the sender. Adapter publish failures are reported asynchronously through WithWSHubAdapterErrorHandler.

func (*WSHub) Start

func (h *WSHub) Start() error

Start starts the adapter subscription, if an adapter is configured. It is idempotent after a successful start and can be retried after transient adapter failures. Publish, PublishBatch, PublishFrom, and topic reconciliation also start the adapter lazily.

func (*WSHub) Subscribe

func (h *WSHub) Subscribe(ws *WebSocket, topic string) bool

Subscribe enrolls ws in topic. It is a small convenience wrapper around WebSocket.Subscribe so user code can stay hub-centered.

func (*WSHub) Unsubscribe

func (h *WSHub) Unsubscribe(ws *WebSocket, topic string) bool

Unsubscribe removes ws from topic.

func (*WSHub) WebSocket

func (h *WSHub) WebSocket(app *App, pattern string, behavior WebSocketBehavior)

WebSocket attaches app to the hub and registers a WebSocket route whose callbacks are wrapped so PublishFrom can avoid duplicate local delivery.

func (*WSHub) Wrap

func (h *WSHub) Wrap(app *App, behavior WebSocketBehavior) WebSocketBehavior

Wrap returns a WebSocketBehavior that tracks socket ownership. Use this when a Router is registering the WebSocket route:

router.WebSocket("/ws", hub.Wrap(app, behavior))

type WSHubAdapter

type WSHubAdapter interface {
	Start(ctx context.Context, deliver func(WSHubMessage)) error
	Publish(ctx context.Context, msg WSHubMessage) error
	Close() error
}

WSHubAdapter bridges WSHub messages across processes or hosts.

Implementations call deliver for every message received from outside the hub. The hub ignores messages carrying its own NodeID, so adapters can use brokers such as Redis Pub/Sub that echo a process's publish back to all subscribers, including itself.

Start must be safe to call more than once. After it has successfully started receiving, later Start calls should return nil without creating duplicate receive loops. When Start returns an error, the hub treats the adapter as not started and may retry. The deliver callback is safe for adapters to call from adapter-owned goroutines, including concurrently; any ordering guarantee is provided by the adapter and broker.

Publish should respect ctx and return when the message has been accepted by the adapter or broker, or when delivery cannot be attempted. The hub does not retry failed Publish calls after they leave its queue. Close must be idempotent and should stop receive loops, unblock context-aware operations, and release adapter-owned resources.

type WSHubMessage

type WSHubMessage struct {
	NodeID  string
	Topic   string
	Message []byte
	OpCode  OpCode
}

WSHubMessage is the transport-neutral message shape used by WSHubAdapter.

type WSHubOption

type WSHubOption func(*WSHub)

WSHubOption customizes a WSHub.

func WithWSHubAdapter

func WithWSHubAdapter(adapter WSHubAdapter) WSHubOption

WithWSHubAdapter installs a distributed adapter, for example Redis. The adapter is started by Start or lazily by the first adapter publish or topic reconciliation.

func WithWSHubAdapterErrorHandler

func WithWSHubAdapterErrorHandler(fn func(error)) WSHubOption

WithWSHubAdapterErrorHandler receives asynchronous adapter errors from the hub worker, including Start, Publish, and topic reconciliation failures. The default reports rate-limited errors through SetPanicHandler.

func WithWSHubAdapterPublishTimeout

func WithWSHubAdapterPublishTimeout(timeout time.Duration) WSHubOption

WithWSHubAdapterPublishTimeout bounds each adapter Publish, Subscribe, and Unsubscribe operation. This keeps shutdown from waiting indefinitely on a slow or half-open Redis connection.

func WithWSHubAdapterQueueSize

func WithWSHubAdapterQueueSize(size int) WSHubOption

WithWSHubAdapterQueueSize sets the bounded async adapter queue used by Publish, PublishBatch, and PublishFrom. Larger queues absorb Redis/network bursts without blocking the WebSocket loop thread; a full queue returns ErrWSHubAdapterQueueFull after local fan-out has already happened.

func WithWSHubAdapterTopicWorkers

func WithWSHubAdapterTopicWorkers(workers int) WSHubOption

WithWSHubAdapterTopicWorkers sets how many goroutines reconcile adapter topic subscriptions. The default is 1. Multiple workers may reconcile different topics in parallel, but the same topic is never reconciled concurrently.

func WithWSHubAdapterWorkers

func WithWSHubAdapterWorkers(workers int) WSHubOption

WithWSHubAdapterWorkers sets how many goroutines call adapter.Publish for queued messages. The default is 1, which preserves adapter publish call order from the hub queue. Larger values can improve Redis throughput when cross-process message ordering is not required.

func WithWSHubCloseTimeout

func WithWSHubCloseTimeout(timeout time.Duration) WSHubOption

WithWSHubCloseTimeout bounds each Close wait phase for adapter workers. Close first lets queued adapter work drain, then cancels the hub context and waits again before returning ErrWSHubCloseTimeout.

func WithWSHubNodeID

func WithWSHubNodeID(id string) WSHubOption

WithWSHubNodeID sets the process/node identifier used to ignore messages that this hub already delivered locally before publishing to an adapter.

type WSHubTopicAdapter

type WSHubTopicAdapter interface {
	WSHubAdapter
	Subscribe(ctx context.Context, topic string) error
	Unsubscribe(ctx context.Context, topic string) error
}

WSHubTopicAdapter is an optional extension for adapters that can subscribe only to topics with local subscribers. WSHub calls these methods asynchronously from its adapter worker when the first local socket subscribes to a topic and when the last local socket leaves.

Topic operations are reconciled to the latest local state, not replayed as a subscribe/unsubscribe event log. The hub retries failed topic operations until they succeed or the hub closes.

type WebSocket

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

WebSocket wraps a uWebSockets WebSocket connection. Its methods touch uWS loop-thread-local state; use them only from WebSocket callbacks on the owning loop. Do not retain a WebSocket and call Send / SendText / End from worker goroutines. For cross-goroutine fan-out use App.Publish, App.PublishBatch, or WSHub.

func (*WebSocket) End

func (ws *WebSocket) End(code int, message string)

End closes the WebSocket connection from the owning uWS loop thread. Calling End from a worker goroutine is not supported.

func (*WebSocket) Publish

func (ws *WebSocket) Publish(topic string, message []byte, opcode OpCode) bool

Publish broadcasts message to every OTHER subscriber of topic, including subscribers on peer RunMultiCore loops. uWS deliberately excludes the publishing socket from its own broadcast — per WebSocket.h: "Publish as sender, does not receive its own messages even if subscribed to relevant topics" — so if you want every subscriber including the caller, use App.Publish instead.

opcode picks the WebSocket frame type (Text or Binary). Returns true when the message was queued for delivery to at least one subscriber on the publishing socket's local loop. In RunMultiCore mode, peer-loop fanout is fire-and-forget and is not reflected in this return value.

This is the fast path for the publishing socket's local loop: no cross-thread defer, no message copy, just a cgo crossing into uWS's TopicTree publish. In RunMultiCore, peer loops are reached by scheduling one copied publish per other App, so the peer fan-out cost is O(peer loops). App.Publish exists for the worker-goroutine case where you don't have a live WebSocket pointer on the loop thread.

Calling this from off the loop thread (e.g. a goroutine you spawned from a handler) corrupts uWS state — the WebSocket pointer is only valid on the loop.

func (*WebSocket) Send

func (ws *WebSocket) Send(message []byte, opcode OpCode) bool

Send sends a WebSocket message from the owning uWS loop thread. It returns false when uWS did not queue the message, for example because the socket is closing or already above its backpressure limit.

func (*WebSocket) SendText

func (ws *WebSocket) SendText(message string) bool

SendText sends a text WebSocket message from the owning uWS loop thread. It returns false under the same conditions as Send.

func (*WebSocket) SetUserData

func (w *WebSocket) SetUserData(v any)

SetUserData overwrites the per-socket user-data value after the connection is established (e.g. from Open). Releases the previous handle if there was one; pass nil to clear without installing a replacement.

func (*WebSocket) Subscribe

func (ws *WebSocket) Subscribe(topic string) bool

Subscribe enrolls this WebSocket as a subscriber to topic. Future App.Publish / WebSocket.Publish calls targeting the same topic string deliver to this connection. Returns true when the subscription is now active (either added by this call or already in place).

Topics are exact-match strings — uWS's TopicTree in v20 does not support MQTT-style "+" / "#" wildcards. Build your own fan-out scheme (e.g. subscribe to every relevant topic at connect time) if you need pattern matching.

Must be called from inside an Open / Message / Close handler — the underlying TopicTree is loop-thread-local; calling Subscribe from a worker goroutine corrupts uWS state.

func (*WebSocket) Unsubscribe

func (ws *WebSocket) Unsubscribe(topic string) bool

Unsubscribe removes this WebSocket's subscription to topic. Returns true when a subscription existed and was removed. Like Subscribe, must be called from a WebSocket handler.

func (*WebSocket) UserData

func (w *WebSocket) UserData() any

UserData reads back the value stashed by an upgrade callback via UpgradeContext.SetUserData. Returns nil when no user data was attached. Safe to call from Open / Message / Close.

type WebSocketBehavior

type WebSocketBehavior struct {
	// Open runs after the handshake succeeds.
	Open func(*WebSocket)

	// Message runs for incoming text and binary messages.
	Message func(*WebSocket, []byte, OpCode)

	// Close runs after the connection closes. Use it for cleanup only;
	// the socket is no longer a live send target.
	Close func(*WebSocket, int, []byte)

	// MaxPayloadLength is the largest single incoming message the
	// server will accept. Frames over this cap cause uWS to close the
	// connection. Default 16 MiB.
	MaxPayloadLength int

	// IdleTimeout is the maximum time a WebSocket may sit idle (no
	// frames in either direction) before uWS closes it. Default 120s.
	IdleTimeout time.Duration

	// MaxBackpressure is the bytes uWS will queue per-socket for a
	// slow consumer before closing the connection. Protects the
	// loop from being held hostage by a single non-draining client.
	// Default 64 KiB. The public WebSocket API exposes this cap and
	// the bool returned by Send / SendText; it does not expose a
	// per-socket BufferedAmount, AwaitDrain, or drain callback.
	MaxBackpressure int

	// DisablePings turns off uWS's built-in ping/pong keepalive.
	// Default (zero) leaves automatic pings ON so an idle connection
	// doesn't get reaped by NAT boxes; set true only if your client
	// drives its own heartbeat. Ping/pong callbacks are not part of
	// the public API; use Message for application-level heartbeats.
	DisablePings bool

	// UnsafeAutoUpgrade restores uWS's legacy default of accepting every
	// WebSocket handshake when Upgrade is nil. The secure default rejects
	// browser handshakes that carry an Origin header unless the app installs
	// an explicit Upgrade callback. Only set this for public, non-cookie
	// endpoints where cross-origin WebSocket access is intentional.
	UnsafeAutoUpgrade bool

	// Upgrade runs synchronously on the uWS loop thread for every
	// incoming WebSocket handshake before the connection is
	// established. The callback inspects request headers / query /
	// peer IP / offered subprotocols and MUST call ctx.Accept(...) or
	// ctx.Reject(...) before returning — failing to do either causes
	// the framework to refuse the upgrade with a 500 (and log the
	// misuse).
	//
	// Typical uses:
	//
	//   - subprotocol negotiation: pick one of ctx.Protocols()
	//   - cookie / token auth at handshake time, with the resolved
	//     identity stashed via ctx.SetUserData(user) for the rest
	//     of the connection's lifetime
	//   - rejecting based on origin / referrer / API quota
	//
	// SECURITY: when Upgrade is nil the framework accepts non-browser
	// handshakes that omit Origin, but rejects browser handshakes that
	// carry Origin. The HTTP middleware chain (CORS in particular) does
	// NOT cover the WebSocket upgrade path, so a Same-Origin Policy gate
	// that protects fetch() does NOT protect WebSocket(). If the endpoint
	// uses session cookies or any other ambient credential, an attacker
	// page can open ws://yoursite/... from the user's browser and ride the
	// user's session — Cross-Site WebSocket Hijacking (CSWSH).
	//
	// At minimum verify the Origin header against an allow-list. The
	// middleware package ships middleware.WebSocketAuth for the
	// common case (origin check + optional bearer / cookie /
	// subprotocol gating).
	Upgrade func(*UpgradeContext)
	// contains filtered or unexported fields
}

WebSocketBehavior contains callbacks and per-route limits for a WebSocket endpoint. Callbacks run on the owning uWS loop thread and must not block. Limit fields default to safe production values when zero; pick explicit numbers when you need different limits, do not leave them at zero hoping for "unlimited".

Directories

Path Synopsis
adapters
examples
authmw command
authmw demonstrates a production-ish middleware stack:
authmw demonstrates a production-ish middleware stack:
graceful command
graceful demonstrates signal-driven single-app graceful shutdown.
graceful demonstrates signal-driven single-app graceful shutdown.
hello command
hello demonstrates the smallest gogo~ server: one sync route and one async route, plus a static reply served entirely from C++.
hello demonstrates the smallest gogo~ server: one sync route and one async route, plus a static reply served entirely from C++.
httpadapter command
httpadapter demonstrates using stdlib net/http handlers inside gogo.
httpadapter demonstrates using stdlib net/http handlers inside gogo.
multicore command
restapi command
restapi demonstrates a tiny in-memory REST API with GET/POST/JSON + route params + query strings.
restapi demonstrates a tiny in-memory REST API with GET/POST/JSON + route params + query strings.
sse command
sse demonstrates the simplest SSE pattern in gogo: register an async route, install Server-Sent Events via res.SSE, and emit events on a timer with a keepalive ping every few seconds.
sse demonstrates the simplest SSE pattern in gogo: register an async route, install Server-Sent Events via res.SSE, and emit events on a timer with a keepalive ping every few seconds.
upload command
upload demonstrates POST body collection with a max-body limit.
upload demonstrates POST body collection with a max-body limit.
websocket command
websocket demonstrates a browser-friendly WebSocket route:
websocket demonstrates a browser-friendly WebSocket route:
internal
mwhint
Package mwhint carries the placement metadata that the bundled middleware in gogo/middleware uses to tell gogo.App.Use which chain a middleware belongs to.
Package mwhint carries the placement metadata that the bundled middleware in gogo/middleware uses to tell gogo.App.Use which chain a middleware belongs to.
Package middleware bundles common HTTP middleware ready to drop into any gogo App or Router.
Package middleware bundles common HTTP middleware ready to drop into any gogo App or Router.

Jump to

Keyboard shortcuts

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