aprot

package module
v0.44.0 Latest Latest
Warning

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

Go to latest
Published: May 11, 2026 License: MIT Imports: 31 Imported by: 0

README

aprot

API Protocol for Real-time Operations with TypeScript

A Go library for building type-safe real-time APIs with automatic TypeScript client generation. Supports both WebSocket and SSE+HTTP transports.

Warning This library is currently unstable and under active development. Breaking changes will occur between versions until v1.0.0 is released.

Showcase

A taste of what writing an aprot app looks like. Define typed handlers in Go, declare refresh triggers, and your React components auto-update — no event wiring, no manual refetch.

Go — api/handlers.go

// Enums are plain Go types. A Values() function exposes them to codegen,
// which emits a TypeScript const object + union type.
type JobStatus string

const (
    JobStatusPending JobStatus = "pending"
    JobStatusRunning JobStatus = "running"
    JobStatusDone    JobStatus = "done"
)

func JobStatusValues() []JobStatus {
    return []JobStatus{JobStatusPending, JobStatusRunning, JobStatusDone}
}

type Job struct {
    ID     string    `json:"id"`
    Title  string    `json:"title"`
    Status JobStatus `json:"status"`
}

type Handlers struct{ store *Store }

// Query — any client that calls useListJobs() is subscribed to the "jobs"
// trigger key and will auto-refresh whenever it fires.
func (h *Handlers) ListJobs(ctx context.Context) ([]Job, error) {
    aprot.RegisterRefreshTrigger(ctx, "jobs")
    return h.store.All(), nil
}

// Mutation — validation, typed errors, and a refresh that fans out to every
// subscribed client with zero client-side code.
func (h *Handlers) CreateJob(ctx context.Context, title string) (*Job, error) {
    if title == "" {
        return nil, aprot.ErrInvalidParams("title is required")
    }
    job := h.store.Add(title, JobStatusPending)
    aprot.TriggerRefresh(ctx, "jobs")
    return job, nil
}

// Long-running mutation. TriggerRefreshNow flushes the refresh queue
// mid-handler so subscribers observe the "running" state immediately;
// progress.Update streams progress to the caller via onProgress; and the
// final TriggerRefresh is batched to fire when the handler returns.
func (h *Handlers) RunJob(ctx context.Context, id string) error {
    progress := aprot.Progress(ctx)

    h.store.SetStatus(id, JobStatusRunning)
    aprot.TriggerRefreshNow(ctx, "jobs") // subscribers re-render with status=running

    for i := 1; i <= 5; i++ {
        progress.Update(i, 5, fmt.Sprintf("step %d/5", i))
        time.Sleep(200 * time.Millisecond)
    }

    h.store.SetStatus(id, JobStatusDone)
    aprot.TriggerRefresh(ctx, "jobs") // batched; flushed on return
    return nil
}

React — Jobs.tsx

import {
    useListJobs,
    createJob,
    runJob,
    JobStatus,
    type JobStatusType,
} from './api/handlers'

export function Jobs() {
    // Subscribed query. Re-renders automatically whenever the server calls
    // TriggerRefresh(ctx, "jobs") — no useEffect, no event listener, no refetch.
    // The hook also exposes `mutate(action)`: a helper that runs an async
    // action and refetches this query on completion, so we can wire the
    // "Add job" button without juggling a separate mutation hook.
    const { data: jobs, isLoading, mutate } = useListJobs()

    if (isLoading) return <p>Loading…</p>

    return (
        <div>
            <button onClick={() => mutate((client) => createJob(client, 'Write the README'))}>
                Add job
            </button>

            <ul>
                {jobs?.map((job) => (
                    <li key={job.id}>
                        <strong>{job.title}</strong> — {labelFor(job.status)}
                        {job.status === JobStatus.Pending && (
                            <button onClick={() => mutate((client) => runJob(client, job.id, {
                                onProgress: (cur, total, msg) => console.log(`${msg} (${cur}/${total})`),
                            }))}>
                                Run
                            </button>
                        )}
                    </li>
                ))}
            </ul>
        </div>
    )
}

function labelFor(status: JobStatusType) {
    switch (status) {
        case JobStatus.Pending: return 'pending'
        case JobStatus.Running: return 'running'
        case JobStatus.Done:    return 'done'
    }
}

Open the component in two browser tabs, click "Add job" in one, and the other updates instantly.

Features

  • Type-safe handlers — define handlers with any signature; parameters become TypeScript arguments
  • Automatic TypeScript generation — standalone functions, React hooks, typed errors, enum const objects
  • Streaming handlers — return iter.Seq[T] / iter.Seq2[K, V] from Go and the generated client exposes AsyncIterable<T>, so UIs can populate lists item-by-item as results arrive
  • Subscription refresh — server-driven auto-refresh: query handlers declare trigger keys, mutation handlers fire them to push updates to all subscribed clients
  • Query cache — multiple React components using the same hook share a single server subscription and receive data from a shared cache; configurable per-hook or globally via setQueryCacheEnabled
  • Middleware — server-level and per-handler middleware chains
  • Push events — broadcast to all clients or target specific users
  • Hierarchical tasks — nested task trees with progress tracking, streamed to clients (see tasks subpackage)
  • Shared tasks — server-wide tasks visible to all clients with typed metadata
  • Progress reporting — built-in support for long-running operations
  • Request cancellation — clients cancel via AbortController; handlers see cancel cause
  • Connection lifecycle — hooks for connect/disconnect, connection-scoped state, user targeting
  • Dual transport — WebSocket and SSE+HTTP with identical API
  • Automatic reconnection — page visibility + network-aware, with exponential backoff; supports dynamic URL functions for token refresh on reconnect
  • Struct validation — opt-in server-side validation via go-playground/validator struct tags, automatically enforced before handler dispatch
  • Input transformation — declarative transform struct tags (trim, trimleft, trimright, uppercase, lowercase, removeempty) normalize fields before validation runs
  • Zod schema generation — opt-in generation of Zod validation schemas alongside TypeScript interfaces
  • REST adapter — serve handlers as REST/HTTP endpoints alongside WebSocket, with convention-based HTTP method detection and path parameter mapping
  • OpenAPI generation — opt-in OpenAPI 3.0 spec generation from handler metadata, including validation constraints from struct tags

Installation

go get github.com/marrasen/aprot

Documentation

Go API — the full reference, usage patterns, and examples live on pkg.go.dev:

  • aprot — core library: handlers, registry, server, middleware, subscriptions, code generation
  • aprot/tasks — hierarchical task trees, shared tasks, output streaming

Generated TypeScript — the examples include committed generated code you can browse directly:

Quick Start

1. Define handlers

Handler methods accept context.Context as the first parameter and return either error or (T, error):

package api

import (
    "context"
    "github.com/marrasen/aprot"
)

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

type CreateUserResponse struct {
    ID    string `json:"id"`
    Name  string `json:"name"`
    Email string `json:"email"`
}

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

type Handlers struct {
    Broadcaster aprot.Broadcaster
}

func (h *Handlers) CreateUser(ctx context.Context, req *CreateUserRequest) (*CreateUserResponse, error) {
    resp := &CreateUserResponse{ID: "1", Name: req.Name, Email: req.Email}
    h.Broadcaster.Broadcast(&UserCreatedEvent{ID: resp.ID, Name: resp.Name})
    return resp, nil
}

Parameters are positional — each Go parameter becomes a separate TypeScript argument. Names are extracted from Go source via AST parsing.

2. Create the server
package main

import (
    "net/http"
    "github.com/marrasen/aprot"
    "myapp/api"
)

func main() {
    handlers := &api.Handlers{}

    registry := aprot.NewRegistry()
    registry.Register(handlers)
    registry.RegisterPushEventFor(handlers, api.UserCreatedEvent{})

    server := aprot.NewServer(registry)
    handlers.Broadcaster = server

    http.Handle("/ws", server)                    // WebSocket
    http.Handle("/sse", server.HTTPTransport())   // SSE+HTTP
    http.Handle("/sse/", server.HTTPTransport())
    http.ListenAndServe(":8080", nil)
}
3. Generate the TypeScript client
//go:build ignore

package main

import (
    "github.com/marrasen/aprot"
    "myapp/api"
)

func main() {
    handlers := &api.Handlers{}
    registry := aprot.NewRegistry()
    registry.Register(handlers)
    registry.RegisterPushEventFor(handlers, api.UserCreatedEvent{})

    gen := aprot.NewGenerator(registry).WithOptions(aprot.GeneratorOptions{
        OutputDir: "../../client/src/api",
        Mode:      aprot.OutputReact, // or aprot.OutputVanilla
    })
    gen.Generate()
}
4. Use from TypeScript

React:

import { ApiClient, ApiClientProvider, getWebSocketUrl } from './api/client';
import { useListUsers, createUser } from './api/handlers';

const client = new ApiClient(getWebSocketUrl());

function App() {
    return (
        <ApiClientProvider value={client}>
            <UsersList />
        </ApiClientProvider>
    );
}

function UsersList() {
    // useListUsers() returns the live query plus a `mutate(action)` helper.
    // mutate runs an async action and refetches this query on completion;
    // its error/loading state is shared with the query itself.
    const { data, isLoading, error, mutate } = useListUsers();

    if (error) return <div>Error: {error.message}</div>;
    if (isLoading) return <div>Loading...</div>;

    return (
        <>
            <button onClick={() => mutate((c) => createUser(c, { name: 'New User' }))}>
                Add User
            </button>
            <ul>{data?.users.map(u => <li key={u.id}>{u.name}</li>)}</ul>
        </>
    );
}

aprot does not generate per-handler mutation hooks. Either compose mutations through a query hook's mutate(action) helper (above), or call the generated function directly with useApiClient() (see TypeScript Mutation Patterns). If you're upgrading from a version that generated useXxxMutation(), see MIGRATION_MUTATION_HOOKS.md for an agent-runnable rewrite prompt.

React Suspense (useQuerySuspense) — pairs aprot's generated query functions with React 19's use() hook for components that prefer declarative loading boundaries:

import { Suspense } from 'react';
import { ApiClient, ApiClientProvider, getWebSocketUrl, useQuerySuspense } from './api/client';
import { listUsers, getUser } from './api/handlers';

function UsersList() {
    const data = useQuerySuspense(listUsers);            // no params
    return <ul>{data.users.map(u => <li key={u.id}>{u.name}</li>)}</ul>;
}

function UserView({ id }: { id: string }) {
    const user = useQuerySuspense(getUser, id);          // typed params
    return <h1>{user.name}</h1>;
}

function App() {
    return (
        <ApiClientProvider value={client}>
            <Suspense fallback={<p>Loading…</p>}>
                <UsersList />
            </Suspense>
        </ApiClientProvider>
    );
}

A single generic hook handles every handler — no per-method Suspense hook is generated. The hook opens a server subscription on first read (using the same TriggerRefresh machinery as useQuery), suspends until the first response arrives, then replaces the cached promise on each subsequent push so live updates flow without re-suspending. Errors propagate to the nearest error boundary. Requires React 19+.

With auth tokens (dynamic URL for automatic token refresh on reconnect):

const client = new ApiClient(async () => {
    const token = await getAuthToken();
    return `${getWebSocketUrl()}?token=${encodeURIComponent(token)}`;
});
await client.connect();

Vanilla:

import { ApiClient, getWebSocketUrl } from './api/client';
import { createUser, onUserCreatedEvent } from './api/public-handlers';

const client = new ApiClient(getWebSocketUrl());
await client.connect();

const user = await createUser(client, { name: 'Alice', email: 'alice@example.com' });

onUserCreatedEvent(client, (event) => {
    console.log('User created:', event);
});

TypeScript Mutation Patterns

aprot deliberately generates only query / stream / push hooks — there is no useXxxMutation() hook. Two patterns cover every mutation:

Pattern 1 — query-scoped mutate(action) (refetch on completion)

Every useXxx() query hook returns a mutate helper alongside data / isLoading / error. It accepts either a Promise or a (client: ApiClient) => Promise<unknown> thunk, runs the action, and refetches the query on success. The action's errors are captured in the hook's error field, and isLoading covers both the action and the subsequent refetch.

import { useListTodos } from './api/todos';
import { addTodo } from './api/todos';

function TodoList() {
    const { data, mutate, isLoading, error } = useListTodos();

    return (
        <>
            <button
                disabled={isLoading}
                onClick={() => mutate((client) => addTodo(client, { title: 'Buy milk' }))}
            >
                Add
            </button>
            {error && <p>{error.message}</p>}
            <ul>{data?.todos.map(t => <li key={t.id}>{t.title}</li>)}</ul>
        </>
    );
}

The thunk receives the ApiClient instance the hook is already bound to, so you don't need to call useApiClient() separately. A bare Promise is also accepted — useful when composing multiple operations: mutate(Promise.all([addTodo(c, a), addTodo(c, b)])).

Server-side aprot.TriggerRefresh(...) already pushes refreshed data to subscribed queries, so for many flows you don't need the explicit refetch. Reach for Pattern 1 when you want the same component's loading indicator to cover both the action and the refresh.

Pattern 2 — raw async function via useApiClient()

When there's no surrounding query (no list to refetch), call the generated function directly. Generated standalone functions are typed Promise<TRes> that throw ApiError (protocol error) or ConnectionError (transport error) on failure — no hidden state machine, no ambiguous return values.

import { useApiClient, ApiError } from './api/client';
import { addTodo } from './api/todos';

function AddTodoButton() {
    const client = useApiClient();
    const [isLoading, setIsLoading] = useState(false);
    const [error, setError] = useState<Error | null>(null);

    const onClick = async () => {
        setIsLoading(true);
        setError(null);
        try {
            const todo = await addTodo(client, { title: 'Buy milk' });
            toast(`Created ${todo.id}`);
        } catch (err) {
            setError(err as Error);
            if (err instanceof ApiError && err.isValidationFailed()) {
                // field-level error UI
            }
        } finally {
            setIsLoading(false);
        }
    };

    return <button onClick={onClick} disabled={isLoading}>Add</button>;
}

You manage isLoading / error / AbortController yourself. The trade-off is honest: the function does what its type says.

Why no mutation hooks?

A previous version of aprot generated useXxxMutation() hooks whose mutate() swallowed errors and returned undefined as TRes on failure. The Promise<TRes> type lied at runtime; void mutations couldn't distinguish success from "not yet called"; and the only correct after-success pattern (useEffect([data])) was non-obvious. The two patterns above cover the same ground without the foot-guns. See MIGRATION_MUTATION_HOOKS.md for a rewrite prompt that AI agents can run against an existing codebase.

Catching errors globally with <ApiClientErrorProvider>

Patterns 1 and 2 wire errors per call site. When a component (or app) fans out to many hooks and ad-hoc client calls and just wants a single place to surface failures, drop in <ApiClientErrorProvider> and read from useApiClientError():

import {
  ApiClient, ApiClientProvider, ApiClientErrorProvider,
  useApiClient, useApiClientError,
} from './api/client';

const client = new ApiClient(`ws://${location.host}/ws`);

function App() {
    return (
        <ApiClientProvider value={client}>
            <ApiClientErrorProvider>
                <ErrorBanner />
                <UsersPanel />
            </ApiClientErrorProvider>
        </ApiClientProvider>
    );
}

function ErrorBanner() {
    const { error, source, clear } = useApiClientError();
    if (!error) return null;
    const where = source ? `${source.struct}.${source.method}` : null;
    return (
        <div role="alert">
            {where ? <strong>Error in {where}: </strong> : null}
            {error.message} <button onClick={clear}>Dismiss</button>
        </div>
    );
}

Inside the provider, useApiClient() returns a Proxy-wrapped client whose request, subscribe, and requestStream calls report errors to the provider — in addition to throwing / re-surfacing them as before. Generated hooks (useListUsers, useQuery, mutate, useStream) all retrieve their client through useApiClient() internally, so their errors flow up too without any per-hook wiring.

Alongside error, useApiClientError() returns a source: { struct, method } | null parsed from the wire name (e.g. 'Todos.CreateTodo'{ struct: 'Todos', method: 'CreateTodo' }), so a banner can name the call that failed. source is null exactly when error is null. If a caller invokes the client with a wire name that has no dot, struct is '' and method holds the full name.

Only the latest error is held; clear() resets it. The provider observes errors but does not swallow them — wrapped client calls still throw, so per-hook error fields and explicit try/catch keep working. Without an <ApiClientErrorProvider> above, useApiClient() returns the raw client unchanged and useApiClientError() throws — adoption is fully opt-in.

Streaming Handlers

Handlers can return iter.Seq[T] (Go 1.23+) and each yielded value is delivered to the client as a separate websocket message. The generated TypeScript function returns an AsyncIterable<T>, so you can consume results with for await and render them as they arrive — ideal for progressive list population, large result sets, or any handler whose output is naturally lazy.

import "iter"

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

func (h *Handlers) ListUsers(ctx context.Context) (iter.Seq[*User], error) {
    return func(yield func(*User) bool) {
        for rows.Next() {
            var u User
            if err := rows.Scan(&u.ID, &u.Name); err != nil {
                return
            }
            if !yield(&u) {
                return // client canceled
            }
        }
    }, nil
}
import { listUsers } from './api/handlers';

for await (const user of listUsers(client)) {
    appendUserToList(user); // renders each row as it arrives
}

The generated function name and signature reflect the Go method's return type — iter.Seq[T] produces AsyncIterable<T> instead of Promise<T>. There's no naming prefix; consumers see listUsers() for both unary and streaming handlers, and TypeScript distinguishes them by return type.

React variant — the generated useListUsers() hook accumulates items into local state and restarts the stream on parameter change:

function UserList() {
    const { items, done, error, isLoading } = useListUsers();
    return (
        <ul>
            {items.map((u) => <li key={u.id}>{u.name}</li>)}
            {isLoading && <li>Loading more…</li>}
            {error && <li>Error: {error.message}</li>}
        </ul>
    );
}

Cancellation works exactly like any other request: break out of the for await loop, pass an AbortSignal, or unmount the React component, and the handler's ctx is canceled — the next yield returns false and the iterator stops. Streaming handlers also support iter.Seq2[K, V], which surfaces as AsyncIterable<[K, V]> on the TypeScript side.

Streaming handlers are WebSocket/SSE only. Registering one via RegisterREST panics at registration time since REST cannot deliver multi-message responses over a single HTTP request.

Middleware and streaming handlers

Middleware sees a streaming handler return as soon as the iterator value is in hand — not after every row has been streamed. For unary handlers that's the normal post-handler hook point; for streams, the duration and success you see at that moment reflect the time it took to produce the iterator, not the time to drain it. To observe the real end of a stream (duration, item count, cancellation cause, panic), register a callback via aprot.OnStreamComplete(ctx, fn):

func Logging(next aprot.Handler) aprot.Handler {
    return func(ctx context.Context, req *aprot.Request) (any, error) {
        start := time.Now()
        aprot.OnStreamComplete(ctx, func(err error, items int) {
            slog.Info("stream done",
                "method", req.Method,
                "dur", time.Since(start),
                "items", items,
                "err", err)
        })
        result, err := next(ctx, req)
        if info := aprot.HandlerInfoFromContext(ctx); info != nil &&
            info.Kind != aprot.HandlerKindUnary {
            // Streaming handler: the hook above will fire when iteration
            // finishes. Preflight errors (handler returned (nil, err)) never
            // invoke the hook — log them here instead.
            if err != nil {
                slog.Error("stream preflight error",
                    "method", req.Method, "dur", time.Since(start), "err", err)
            }
            return result, err
        }
        slog.Info("unary done",
            "method", req.Method, "dur", time.Since(start), "err", err)
        return result, err
    }
}

The err passed to the hook distinguishes every termination path:

Termination err value
Clean completion (iterator returned normally) nil
Client canceled (break, AbortSignal, cancel()) aprot.ErrClientCanceled
Client disconnected mid-stream aprot.ErrConnectionClosed
Server shutdown aprot.ErrServerShutdown
Handler panicked mid-stream wrapped recover value
Transport send failure underlying transport error

Use errors.Is(err, aprot.ErrClientCanceled) etc. to distinguish. The hook also receives items int — the number of elements successfully yielded to the client (for iter.Seq2, each (key, value) pair counts as one).

OnStreamComplete is a no-op on a unary handler's context — the hooks slot is only populated when the dispatcher sees a streaming return type. Middleware that calls it on every request (as in the example above) works uniformly across both.

Validation

Add validate struct tags to request structs and enable validation on the registry. Validation is opt-in — nothing changes unless you call SetValidator.

type CreateUserRequest struct {
    Name  string `json:"name"  validate:"required,min=2,max=100"`
    Email string `json:"email" validate:"required,email"`
    Age   int    `json:"age"   validate:"gte=13,lte=120"`
}

// Enable validation
registry.SetValidator(aprot.NewPlaygroundValidator())

Invalid requests are automatically rejected with structured errors (code -32604) before reaching your handler. Uses go-playground/validator — see their docs for the full tag reference.

The structured error payload (a []FieldError) flows through to the generated TypeScript client. Catch ApiError and use the exported getValidationErrors helper to map server-side failures to per-field UI state without re-implementing the type-narrowing dance:

import { ApiError, getValidationErrors } from "./api/client";

try {
    await api.users.update(input);
} catch (err) {
    const fields = getValidationErrors(err);
    if (fields) {
        for (const f of fields) setFieldError(f.field, f.message);
        return;
    }
    if (err instanceof ApiError) {
        // Other server error — generic toast, etc.
    }
    throw err;
}

getValidationErrors returns null for any error that isn't a CodeValidationFailed response, so the same form-submit catch block can handle both validation failures and other errors with a single check. The FieldError interface mirrors the Go FieldError struct in validate.go.

Input transformation

Normalize incoming fields with transform struct tags. Transforms run after JSON decoding and before validation, so rules like required,min=1 see the cleaned value. No registry setup required — a field is transformed iff it carries a transform:"" tag.

type SignupRequest struct {
    Email    string   `json:"email"    transform:"trim,lowercase" validate:"required,email"`
    Username string   `json:"username" transform:"trim"            validate:"required,min=3"`
    Slug     string   `json:"slug"     transform:"trimleft=/,lowercase"`
    Tags     []string `json:"tags"     transform:"trim,removeempty"`
}

Supported ops, applied in the order listed:

Op Type Behavior
trim string strings.TrimSpace
trimleft / trimright string TrimLeft / TrimRight; optional =cutset (defaults to whitespace)
uppercase / lowercase string strings.ToUpper / strings.ToLower
removeempty []string drop empty elements (apply after trim to also drop whitespace-only)

Transforms recurse into nested structs and slices of structs, and they handle *string fields (nil pointers are left alone).

Zod Schemas

Enable Zod to generate TypeScript validation schemas from the same struct tags:

gen := aprot.NewGenerator(registry).WithOptions(aprot.GeneratorOptions{
    OutputDir: "client/src/api",
    Mode:      aprot.OutputReact,
    Zod:       true, // generates .schema.ts files
})

This produces {handler}.schema.ts files with Zod schemas for any struct that has validate tags:

import { z } from 'zod';

export const CreateUserRequestSchema = z.object({
    name: z.string().min(2).max(100),
    email: z.string().min(1).email(),
    age: z.number().int().min(13).max(120),
});

A few validate-tag semantics worth knowing when designing your request structs:

  • omitempty on a string (validate:"omitempty,url,max=500") mirrors go-playground/validator's server behavior: the empty string is the Go zero value, so subsequent rules are skipped. The generated Zod schema wraps the chain in z.union([z.literal(""), ...]).optional(), so "" and undefined both pass — useful for optional form fields where the browser submits "" rather than omitting the key. omitempty on non-string kinds just appends .optional().
  • sql.NullString / sql.NullInt64 / sql.NullTime / sql.NullBool / sql.Null[T] are unwrapped on both sides: the TypeScript type becomes T | null and the Zod schema becomes z.<base>().nullable(), with any validate tag constraints still applied to the inner type.
  • url and email are absolute-only because that's how go-playground/validator defines them upstream. validate:"url" rejects app-internal paths like /images/123/mobile, and email is strict about the formats it accepts. Not an aprot choice — just a footgun to know about so you don't fight it for half an hour. If you need relative URLs, fall back to validate:"max=500" or a custom validator.
  • Slice and map element types are substituted into parent schemas. A field of type []EventLink whose element struct has its own validate tags becomes Links: z.array(EventLinkSchema) in the parent — not z.array(z.any()). Combined with validate:"dive" on the parent slice, this means the same dive tag drives both server-side element validation (via go-playground/validator) and client-side element validation (via the substituted Zod schema). Primitive elements ([]string, []int, map[string]bool) get a typed z.array(z.string()) / z.record(z.string(), z.boolean()) etc. Slices of slices, slices of maps, and anonymous-struct elements still fall through to z.any().

Connection Errors

Connection-level failures surface as a structured ConnectionError (separate from ApiError, which represents a structured server-side error response). It extends Error, so existing instanceof Error catches keep working — switch on err.reason to render appropriate UI:

import { ConnectionError } from './api/client';

try {
    await addTodo(client, { title: 'Buy milk' });
} catch (err) {
    if (err instanceof ConnectionError) {
        switch (err.reason) {
            case 'offline':         return showBanner('You appear to be offline.');
            case 'server-rejected': return showError('Session expired. Please sign in.');
            case 'server-closed':   return showError(`Server closed: ${err.closeReason ?? err.closeCode}`);
            case 'network-error':   return showError('Connection failed. Retrying…');
            case 'manual':          return; // we initiated the disconnect
        }
    }
    throw err;
}

Reasons:

Reason When
offline navigator.onLine was false at failure time.
server-rejected The server sent an ApiError with code ConnectionRejected (e.g. invalid session) before closing. The original ApiError is attached as err.cause.
server-closed Transport closed cleanly after the WebSocket upgrade completed. err.closeCode and err.closeReason carry the WebSocket CloseEvent fields.
network-error Pre-upgrade failure or close code 1006 — refused, unreachable, TLS, or HTTP error during the WebSocket upgrade. Browsers deliberately collapse these into one bucket; finer classification is not achievable from JS.
manual The caller invoked client.disconnect().

The same ConnectionError instance is delivered to:

  • In-flight request / requestStream calls — pending promises reject with it when the connection drops.
  • request() issued while disconnected — rejects synchronously with the most recent ConnectionError, falling back to 'offline' (when navigator.onLine === false) or 'manual'.
  • onConnectionError(listener) — register a listener to drive UI like an "Offline" banner. Multiple listeners supported; returns an unsubscribe function. getLastConnectionError() returns the most recently observed error or null.
const off = client.onConnectionError((err) => {
    if (err.isOffline()) showOfflineBanner();
    else hideOfflineBanner();
});

REST Adapter

Serve your handlers as REST/HTTP endpoints alongside WebSocket. Use RegisterREST for REST-only handlers, or EnableREST to expose a WebSocket handler via REST as well:

registry.Register(&handlers)          // WebSocket only
registry.RegisterREST(&todos)         // REST only
registry.Register(&shared)            // WebSocket...
registry.EnableREST(&shared)          // ...and also REST

rest := aprot.NewRESTAdapter(registry)
http.Handle("/api/", http.StripPrefix("/api", rest))

Mapping conventions:

  • Group name → path prefix: Users/users
  • Method name → path segment: UpdateUser/update-user
  • Primitive params → path parameters: func(ctx, id string, ...)/{id}
  • Struct param → JSON request body
  • HTTP method inferred from name prefix: Get/List → GET, Create/Add → POST, Update → PUT, Set → PATCH, Delete/Remove → DELETE

Example: func (h *Users) UpdateUser(ctx context.Context, id string, req *UpdateReq) error becomes PUT /users/update-user/{id} with JSON body.

Access the HTTP request in middleware via aprot.HTTPRequestFromContext(ctx).

OpenAPI Generation

Generate an OpenAPI 3.0 spec from your handlers. Only handlers registered with RegisterREST are included:

oag := aprot.NewOpenAPIGenerator(registry, "My API", "1.0.0")
spec, _ := oag.Generate()     // *OpenAPISpec
data, _ := oag.GenerateJSON() // []byte

Use WithBasePath when the API is mounted behind a proxy or at a non-root path:

oag := aprot.NewOpenAPIGenerator(registry, "My API", "1.0.0").
    WithBasePath("/rest/api/v1.0")
// paths: "/rest/api/v1.0/todos/create-todo", etc.

Validation tags flow into the spec: validate:"gte=12,lte=110"minimum: 12, maximum: 110, validate:"email"format: "email".

Project Structure

myapp/
├── api/                      # Shared Go types package
│   ├── types.go              # Request/response structs
│   ├── events.go             # Push event types
│   ├── handlers.go           # Handler implementations
│   ├── middleware.go          # Custom middleware (optional)
│   └── registry.go           # NewRegistry() function
├── server/
│   └── main.go               # Server entry point
├── client/                   # Frontend (separate npm project)
│   ├── package.json
│   ├── src/
│   │   └── api/              # Generated code destination
│   └── ...
└── tools/
    └── generate/
        ├── doc.go            # //go:generate directive
        └── main.go           # Generator script

Examples

Vanilla Example
cd example/vanilla
go mod tidy
cd tools/generate && go run main.go    # Generate TypeScript
cd ../../client && npm install && npm run build
cd ../server && go run .
React Example
cd example/react
go mod tidy
cd tools/generate && go run main.go    # Generate React hooks
cd ../../client && npm install
npm run dev                             # Start Vite dev server
# In another terminal:
cd ../server && go run .

Testing

Go Unit Tests
go test ./...
E2E Integration Tests

The e2e/ directory contains end-to-end tests that run the generated TypeScript client against a live Go server, covering both WebSocket and SSE transports.

cd e2e/generate && go run main.go       # Generate client
cd .. && npm install && npm test
Formatting

A pre-commit hook ensures Go files are formatted before each commit. Enable it once after cloning:

git config core.hooksPath .githooks
Linting
# Go (requires golangci-lint v2)
golangci-lint run ./...

# TypeScript (E2E tests)
cd e2e && npm run lint
CI

GitHub Actions runs five jobs on every push/PR to master:

  1. go-fmt — verifies all Go files are formatted with gofmt
  2. go-tests — Go unit and integration tests with race detection
  3. go-lint — runs golangci-lint across all Go modules
  4. typescript-compile — verifies generated TypeScript compiles for both vanilla and React modes
  5. e2e-tests — runs the full E2E suite (with ESLint) against a live server

License

MIT

Documentation

Overview

Package aprot is a Go library for building type-safe real-time APIs with automatic TypeScript client generation. It supports both WebSocket and SSE+HTTP transports.

Overview

aprot follows a define-register-serve-generate workflow:

  1. Define handler methods on Go structs — each becomes a callable RPC endpoint.
  2. Register handlers (with optional middleware) on a Registry.
  3. Create a Server and mount it on your HTTP router.
  4. Run the Generator to emit a fully typed TypeScript client.

The generated client includes standalone functions, React hooks (optional), typed error checking, enum const objects, and push event handlers — all derived from your Go source code. For details on the generated TypeScript API (hook behavior, cancellation, loading states), browse the committed example output at example/react/client/src/api/ and example/vanilla/client/static/api/ in the repository.

Handlers

Handler methods are ordinary Go methods on a struct. They must accept context.Context as the first parameter and return either error (void) or (T, error):

func (h *Handlers) CreateUser(ctx context.Context, req *CreateUserRequest) (*CreateUserResponse, error) { ... }
func (h *Handlers) DeleteUser(ctx context.Context, id string) error { ... }
func (h *Handlers) ListUsers(ctx context.Context) ([]User, error) { ... }
func (h *Handlers) Add(ctx context.Context, a int, b int) (*SumResult, error) { ... }

Parameters are positional — each Go parameter becomes a separate argument in the TypeScript client. Parameter names are extracted from Go source via AST parsing, so the names you choose in Go are the names your TypeScript client uses.

Streaming Handlers

Handlers may return iter.Seq or iter.Seq2 instead of a single value to stream results incrementally. Each yielded element is delivered to the client as a separate wire message, so UIs can render rows as they arrive instead of waiting for the full response:

func (h *Handlers) ListUsers(ctx context.Context) (iter.Seq[*User], error) {
    return func(yield func(*User) bool) {
        for cursor.Next(ctx) {
            var u User
            cursor.Scan(&u)
            if !yield(&u) {
                return // client canceled
            }
        }
    }, nil
}

func (h *Handlers) Prices(ctx context.Context) (iter.Seq2[string, float64], error) { ... }

The generator emits an AsyncIterable on the TypeScript side, so clients consume streams with a `for await` loop. Cancellation is bidirectional: breaking out of the loop (or calling the hook's cancel function) cancels the handler's context immediately, and the next `yield` returns false.

Streaming is WebSocket/SSE only. Registry.RegisterREST and Registry.EnableREST panic at registration time for streaming handlers because REST cannot deliver multi-message responses over a single HTTP request. See OnStreamComplete in the Middleware section for observing the real end of a stream from logging / metrics middleware.

Input Transformation

Request struct fields can be normalized before handler dispatch using "transform" struct tags. Transforms run after JSON decoding and before struct validation, so validator rules see the cleaned value:

type SignupRequest struct {
    Email    string   `json:"email"    transform:"trim,lowercase" validate:"required,email"`
    Username string   `json:"username" transform:"trim"            validate:"required,min=3"`
    Slug     string   `json:"slug"     transform:"trimleft=/,lowercase"`
    Tags     []string `json:"tags"     transform:"trim,removeempty"`
}

Supported ops (applied in the order listed in the tag):

  • trim strings.TrimSpace
  • trimleft[=cutset] TrimLeft; optional cutset (defaults to whitespace)
  • trimright[=cutset] TrimRight; optional cutset (defaults to whitespace)
  • uppercase strings.ToUpper
  • lowercase strings.ToLower
  • removeempty []string only — drop empty elements

Ops apply to string, *string (nil-safe), and []string fields, and the walker recurses into nested structs, *struct, and []struct so nested tags are picked up automatically. There is no registry opt-in — a field is transformed if and only if it carries a "transform" tag.

Every "transform" tag reachable from a handler's param types is statically checked at registration time via ValidateTransformTags. Unknown ops, "removeempty" on a non-[]string field, or a "transform" tag on an unsupported field type (int, bool, time.Time, …) cause Registry.Register to panic when the server boots, rather than turning every request into a CodeInvalidParams response at runtime. ApplyTransforms is also exposed so the same walker can be invoked on ad-hoc values outside the handler flow.

Request Validation

Request struct fields can declare validation rules via the "validate" struct tag, using the vocabulary from github.com/go-playground/validator. Validation is opt-in per registry: nothing happens until Registry.SetValidator is called with a StructValidator. The supplied NewPlaygroundValidator wraps the go-playground implementation and produces a structured error payload that flows through to the generated TypeScript client:

type CreateUserRequest struct {
    Name  string `json:"name"  validate:"required,min=2,max=100"`
    Email string `json:"email" validate:"required,email"`
    Age   int    `json:"age"   validate:"gte=13,lte=120"`
}

registry := aprot.NewRegistry()
registry.Register(&Handlers{})
registry.SetValidator(aprot.NewPlaygroundValidator())

Validation runs after ApplyTransforms inside HandlerInfo.Call, so rules like "required,min=1" observe the already-normalized value. Failures are returned as a ProtocolError with CodeValidationFailed and a []FieldError payload describing every rule that failed, which the generated TypeScript client exposes via its ApiError type.

Registry

A Registry collects handler groups, push events, enums, and custom errors for both server dispatch and code generation:

registry := aprot.NewRegistry()
registry.Register(&PublicHandlers{})
registry.Register(&AdminHandlers{}, authMiddleware)
registry.RegisterPushEventFor(&PublicHandlers{}, UserCreatedEvent{})
registry.RegisterEnumFor(&PublicHandlers{}, StatusValues())
registry.RegisterError(sql.ErrNoRows, "NotFound")

Each Registry.Register call creates a handler group with its own middleware chain and a corresponding TypeScript file.

Middleware

Middleware wraps handlers to add cross-cutting behavior. It follows the standard func(next) -> func pattern:

func LoggingMiddleware() aprot.Middleware {
    return func(next aprot.Handler) aprot.Handler {
        return func(ctx context.Context, req *aprot.Request) (any, error) {
            start := time.Now()
            result, err := next(ctx, req)
            log.Printf("[%s] %s took %v", req.ID, req.Method, time.Since(start))
            return result, err
        }
    }
}

Server-level middleware applies to all handlers. Per-handler middleware applies only to the handlers registered in the same Registry.Register call. Execution order: server middleware (outer) → handler middleware (inner) → handler.

server.Use(LoggingMiddleware())                                // all handlers
registry.Register(&ProtectedHandlers{}, AuthMiddleware())     // this group only

Middleware sees a streaming handler "return" as soon as its iter.Seq value is constructed — before any items have been yielded — so a naive `time.Since(start)` measurement logs 0ms for every stream. Call OnStreamComplete from middleware to register a callback that fires when the stream has actually terminated (via exhaustion, handler panic, or client cancellation), with the terminal error and the number of items delivered:

func LoggingMiddleware() aprot.Middleware {
    return func(next aprot.Handler) aprot.Handler {
        return func(ctx context.Context, req *aprot.Request) (any, error) {
            start := time.Now()
            aprot.OnStreamComplete(ctx, func(err error, items int) {
                log.Printf("stream %s done in %s items=%d err=%v",
                    req.Method, time.Since(start), items, err)
            })
            return next(ctx, req)
        }
    }
}

Calling OnStreamComplete on a unary-handler context is a no-op, so the same middleware can log both streaming and unary handlers without branching on handler kind.

Server

A Server handles WebSocket upgrades, SSE streams, and HTTP POST dispatch. Mount it directly for WebSocket, or use Server.HTTPTransport for SSE+HTTP:

server := aprot.NewServer(registry)
http.Handle("/ws", server)                   // WebSocket
http.Handle("/sse", server.HTTPTransport())  // SSE+HTTP
http.Handle("/sse/", server.HTTPTransport())

Both transports can run simultaneously and share connection tracking — Server.Broadcast, Server.PushToUser, and Server.ConnectionCount work across all connections regardless of transport.

Handlers can additionally be exposed over REST/HTTP alongside (or instead of) WebSocket. Use Registry.RegisterREST for REST-only handlers, or Registry.EnableREST to mark an existing WebSocket handler for REST as well. NewRESTAdapter returns an http.Handler that serves every REST-exposed handler in the registry:

registry.Register(&UserHandlers{})          // WebSocket only
registry.RegisterREST(&TodoHandlers{})      // REST only
registry.Register(&BothHandlers{})          // WebSocket...
registry.EnableREST(&BothHandlers{})        // ...and also REST
http.Handle("/api/", aprot.NewRESTAdapter(registry))

HTTP method and path are derived from the handler method name by convention (e.g. CreateUser → POST /users/create-user), and path parameters are mapped from the Go parameter list. Streaming handlers cannot be exposed via REST and will panic at registration — use WebSocket or SSE for those.

Use ServerOptions to configure client reconnection behavior. The server sends this configuration to clients on connect; TypeScript clients apply it automatically.

Connection Lifecycle

Server.OnConnect and Server.OnDisconnect hooks react to connection events. OnConnect hooks can reject connections by returning an error:

server.OnConnect(func(ctx context.Context, conn *aprot.Conn) error {
    session, err := validateSession(conn.Info().Cookies)
    if err != nil {
        return aprot.ErrConnectionRejected("invalid session")
    }
    conn.SetUserID(session.UserID)
    conn.Set(principalKey{}, session.User)
    return nil
})

Each Conn has a unique ID, HTTP request info captured at connection time (via Conn.Info), and key-value storage (via Conn.Set, Conn.Get, Conn.Load) for caching authentication state or other per-connection data.

Conn.SetUserID / Conn.UserID is a routing identity used for push targeting (Server.PushToUser). It is not a security boundary — use the stored principal for authorization decisions.

Push Events

Push events are server-to-client messages broadcast to all connected clients or targeted to specific users:

server.Broadcast(&UserCreatedEvent{ID: "1", Name: "Alice"})
server.PushToUser("user_123", &NotificationEvent{Message: "hello"})

Push event types must be registered with Registry.RegisterPushEventFor. The event name on the wire is derived from the Go type name.

Subscription Refresh

Subscription refresh automatically pushes updated query results to clients when related data changes. Query handlers declare trigger keys with RegisterRefreshTrigger, and mutation handlers fire them with TriggerRefresh:

// Query handler — declares dependency on "users" trigger key
func (h *H) ListUsers(ctx context.Context) ([]User, error) {
    aprot.RegisterRefreshTrigger(ctx, "users")
    return h.db.ListUsers(ctx)
}

// Mutation handler — fires trigger to refresh all subscribed clients
func (h *H) CreateUser(ctx context.Context, req *CreateReq) (*User, error) {
    user, err := h.db.CreateUser(ctx, req)
    if err != nil {
        return nil, err
    }
    aprot.TriggerRefresh(ctx, "users")
    return user, nil
}

Multiple TriggerRefresh calls within a single request are batched and deduplicated. TriggerRefreshNow flushes the queue immediately — use it in long-running handlers that make observable state transitions over time.

From background goroutines, cron jobs, webhook fan-in, or any other code path that runs outside of a request handler, use the Server.TriggerRefresh method instead — it flushes immediately and does not require a request context:

go func() {
    for range ticker.C {
        server.TriggerRefresh("users")
    }
}()

RegisterRefreshTrigger takes variadic strings that form a composite key. It is a no-op when called from a non-subscribe request. The package-level TriggerRefresh is a no-op outside a request context. Subscriptions are cleaned up automatically on client disconnect.

Error Handling

Return ProtocolError values from handlers to send structured errors to clients. Built-in helpers cover common cases:

aprot.ErrUnauthorized("invalid token")      // -32001
aprot.ErrForbidden("access denied")         // -32003
aprot.ErrInvalidParams("name is required")  // -32602
aprot.ErrInternal(err)                      // -32603

Register Go errors with Registry.RegisterError for automatic conversion. The generated TypeScript client includes typed error checking:

registry.RegisterError(sql.ErrNoRows, "NotFound")
// In TypeScript: err.isNotFound(), ErrorCode.NotFound

Connection Errors (TypeScript Client)

Connection-level failures surface as a structured ConnectionError on the generated TypeScript client — separate from ApiError, which represents a structured server-side error response. ConnectionError extends Error and exposes a typed `reason` field so apps can render appropriate UI:

'offline'         — navigator.onLine was false at failure time.
'server-rejected' — server sent ApiError with code ConnectionRejected
                    before closing; the original ApiError is attached as
                    err.cause.
'server-closed'   — transport closed cleanly after the WebSocket upgrade
                    completed; err.closeCode and err.closeReason carry
                    the WebSocket CloseEvent fields.
'network-error'   — pre-upgrade failure or close code 1006 (refused,
                    unreachable, TLS, HTTP error during upgrade).
                    Browsers deliberately collapse these into one bucket.
'manual'          — caller invoked client.disconnect().

In-flight request and requestStream calls reject with a ConnectionError when the connection drops. Calls issued while disconnected reject with the most recent ConnectionError, falling back to 'offline' or 'manual' when none is available. Use client.onConnectionError(listener) to drive UI like an "Offline" banner; client.getLastConnectionError() returns the most recently observed error or null.

The 'server-rejected' bucket is also surfaced via the existing onConnectionRejected ApiClientOption callback — both fire for the same underlying event, kept for backward compatibility.

Enum Support

Register Go enum types with Registry.RegisterEnumFor or Registry.RegisterEnum to generate TypeScript const objects with full type safety. String-based enums derive names by capitalizing values; int-based enums use the String() method:

type Status string
const (
    StatusActive  Status = "active"
    StatusExpired Status = "expired"
)
func StatusValues() []Status { return []Status{StatusActive, StatusExpired} }

registry.RegisterEnumFor(handler, StatusValues())

Struct fields with enum types generate TypeScript fields typed as the enum union (not raw string/number).

Code Generation

Generator reads a Registry and emits TypeScript client code. It supports two output modes: OutputVanilla (standalone functions + subscribe helpers) and OutputReact (adds React hooks with auto-refetch and mutation state):

gen := aprot.NewGenerator(registry).WithOptions(aprot.GeneratorOptions{
    OutputDir: "./client/src/api",
    Mode:      aprot.OutputReact,
})
gen.Generate()

The generator creates split files: client.ts (base client), one file per handler group, and optional shared type files for types used across groups. Use NamingPlugin to customize TypeScript name conventions.

Setting [GeneratorOptions.Zod] emits a companion `.schema.ts` file for every handler group whose request types carry "validate" tags. The resulting Zod schemas mirror the server-side validation rules field for field — so the TypeScript client can reject bad input before it hits the wire, using the same constraints the server will enforce on arrival:

gen := aprot.NewGenerator(registry).WithOptions(aprot.GeneratorOptions{
    OutputDir: "./client/src/api",
    Mode:      aprot.OutputReact,
    Zod:       true,
})

For REST-exposed handlers, NewOpenAPIGenerator produces an OpenAPI 3.0 document describing every REST endpoint in the registry. Go doc comments on handler methods become `summary` / `description`, struct and field doc comments flow into JSON Schema descriptions, and "validate" tags become JSON Schema constraints:

oag := aprot.NewOpenAPIGenerator(registry, "My API", "1.0.0")
spec, err := oag.Generate()
// or: jsonBytes, err := oag.GenerateJSON()

Use OpenAPIGenerator.WithBasePath when the API is mounted behind a proxy or at a non-root path.

TypeScript Mutation Patterns (React)

aprot does not generate per-handler mutation hooks (no useXxxMutation()). Mutations use one of two patterns:

Pattern 1 — query-scoped mutate(action) (refetch on completion). Every useXxx() query hook returns a `mutate` helper alongside data / isLoading / error. It accepts either a Promise or a (client: ApiClient) => Promise<unknown> thunk; it runs the action, captures any thrown error in `error`, and refetches the query on success. Loading / error state is shared with the query so a single indicator covers both the action and the refresh:

const { data, mutate, isLoading, error } = useListTodos();
<button onClick={() => mutate((client) => addTodo(client, { title: 'Buy milk' }))} />

The thunk receives the same ApiClient the hook is bound to, so callers don't need a separate useApiClient() at the call site. A bare Promise is also accepted, useful when composing operations:

mutate(Promise.all([addTodo(c, a), addTodo(c, b)]));

Pattern 2 — raw async function via useApiClient(). Generated standalone functions (addTodo, createUser, etc.) are typed Promise<TRes> that throw ApiError / ConnectionError on failure. Use them when there's no surrounding query (no list to refetch) or when you need conditional try/catch:

const client = useApiClient();
try {
    const todo = await addTodo(client, { title: 'Buy milk' });
} catch (err) {
    if (err instanceof ApiError && err.isValidationFailed()) { ... }
    else { throw err; }
}

You manage isLoading / error / AbortController yourself. The trade-off is honest: the function does what its type says — no hidden state machine.

Earlier versions of aprot generated useXxxMutation() hooks whose mutate() swallowed errors and returned `undefined as TRes` on failure. They were removed because the Promise<TRes> type lied at runtime, void mutations could not distinguish success from "not yet called", and the only correct after-success pattern (useEffect([data])) was non-obvious. See the repository's MIGRATION_MUTATION_HOOKS.md for a rewrite prompt.

Global Error Capture (TypeScript Client)

Wrap a region of the React tree in <ApiClientErrorProvider> to surface every API error inside a single hook, instead of wiring per-call try/catch. Errors from imperative client.request() / requestStream() / subscribe() calls AND from generated query / stream / mutate hooks all flow through the provider, because every hook retrieves its client via useApiClient() and useApiClient() returns a Proxy-wrapped client when the provider is mounted above it:

import { ApiClientProvider, ApiClientErrorProvider, useApiClient,
         useApiClientError } from './api/client';

<ApiClientProvider value={client}>
  <ApiClientErrorProvider>
    <App />
  </ApiClientErrorProvider>
</ApiClientProvider>

Read with useApiClientError():

const { error, source, clear } = useApiClientError();
// source is { struct, method } | null — null exactly when error is null.
// A failure from client.request('Todos.CreateTodo', …) yields
// source = { struct: 'Todos', method: 'CreateTodo' }, so a banner can
// name the failing call without each call site reporting itself.

The source is parsed from the wire name on the first dot. Calls whose wire name has no dot set struct to ” and put the full name in method.

Only the latest error is held (newer overrides older); clear() resets both error and source. The provider observes errors but does not swallow them — wrapped client calls still throw, so per-hook `error` fields and explicit try/catch keep working. Without <ApiClientErrorProvider> above, useApiClient() returns the raw client unchanged and useApiClientError() throws — adoption is opt-in.

React Suspense

In addition to per-handler hooks like `useListUsers()` that return `{data, isLoading, error}`, OutputReact also emits `useQuerySuspense` -- a single generic hook that pairs aprot's promise-returning query functions with React 19's `use()` and `<Suspense>` boundaries:

import { Suspense } from 'react'
import { useQuerySuspense } from './api/client'
import { listUsers, getUser } from './api/handlers'

function UsersList() {
    const data = useQuerySuspense(listUsers)        // no params
    return data.users.map(u => <li key={u.id}>{u.name}</li>)
}

function UserView({ id }: { id: string }) {
    const user = useQuerySuspense(getUser, id)      // typed params
    return <h1>{user.name}</h1>
}

<Suspense fallback={<Spinner />}>
    <ErrorBoundary fallback={<ErrorView />}>
        <UsersList />
    </ErrorBoundary>
</Suspense>

`useQuerySuspense` opens a server subscription on first read (using the same TriggerRefresh machinery as `useQuery`), suspends the component until the first response arrives, and replaces the cached promise with a new resolved one on each subsequent server push -- so live updates flow without re-suspending. Errors thrown by the handler are propagated to the nearest error boundary.

The hook works directly with the generated query functions because each one carries a `.method` property identifying its wire method:

export function listUsers(client: ApiClient, options?: RequestOptions): Promise<ListUsersResponse> { ... }
listUsers.method = 'PublicHandlers.ListUsers' as const;

No per-handler Suspense hook is generated; the single generic hook plus the metadata is enough. Requires React 19+. Streams continue to use `useStream`, and mutations use the patterns described above (query.mutate or the raw async function) — only queries fit the Suspense paradigm cleanly.

Context Helpers

Several functions extract request-scoped values from context:

Graceful Shutdown

Server.Stop rejects new connections (503), sends close frames, waits for in-flight requests to complete, and runs disconnect hooks. It is safe to call multiple times:

ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
server.Stop(ctx)

Type Mapping

Go types are mapped to TypeScript during generation:

  • string → string
  • int, float64, etc. → number
  • bool → boolean
  • []T → T[]
  • map[K]V → Record<K, V>
  • *T → T (optional field)
  • time.Time → string (RFC 3339)
  • sql.NullString → string | null (all sql.Null* types supported)
  • struct → interface
  • Registered enum → const object + union type

Wire Protocol

Messages are JSON objects with a "type" field. Client-to-server: request, cancel, subscribe, unsubscribe. Server-to-client: response, error, progress, push, config, connected (SSE only).

Example
package main

import (
	"context"
	"fmt"
	"net/http"

	"github.com/marrasen/aprot"
)

// Request and response types
type CreateUserRequest struct {
	Name  string `json:"name"`
	Email string `json:"email"`
}

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

type ProcessItemsRequest struct {
	Items []string `json:"items"`
}

type ProcessItemsResponse struct {
	Processed int `json:"processed"`
}

// Push event types
type UserUpdatedEvent struct {
	ID   string `json:"id"`
	Name string `json:"name"`
}

type ProcessingCompleteEvent struct {
	Count int `json:"count"`
}

// Handlers struct
type MyHandlers struct {
	server *aprot.Server
}

// CreateUser handles user creation
func (h *MyHandlers) CreateUser(ctx context.Context, req *CreateUserRequest) (*CreateUserResponse, error) {
	progress := aprot.Progress(ctx)
	progress.Update(1, 2, "Validating...")

	if req.Name == "" {
		return nil, aprot.ErrInvalidParams("name is required")
	}

	progress.Update(2, 2, "Creating user...")

	conn := aprot.Connection(ctx)
	if conn != nil {
		conn.Push(&UserUpdatedEvent{ID: "123", Name: req.Name})
	}

	return &CreateUserResponse{ID: "123", Name: req.Name}, nil
}

// ProcessItems demonstrates progress reporting with cancellation
func (h *MyHandlers) ProcessItems(ctx context.Context, req *ProcessItemsRequest) (*ProcessItemsResponse, error) {
	progress := aprot.Progress(ctx)
	total := len(req.Items)

	for i, item := range req.Items {
		select {
		case <-ctx.Done():
			return nil, aprot.ErrCanceled()
		default:
		}

		progress.Update(i+1, total, fmt.Sprintf("Processing %s...", item))
	}

	h.server.Broadcast(&ProcessingCompleteEvent{Count: total})

	return &ProcessItemsResponse{Processed: total}, nil
}

func main() {
	// Create registry and register handlers
	registry := aprot.NewRegistry()
	handlers := &MyHandlers{}
	registry.Register(handlers)
	registry.RegisterPushEventFor(handlers, UserUpdatedEvent{})
	registry.RegisterPushEventFor(handlers, ProcessingCompleteEvent{})

	// Create server
	server := aprot.NewServer(registry)
	handlers.server = server

	// Start HTTP server with WebSocket endpoint
	mux := http.NewServeMux()
	mux.Handle("/ws", server)
	// http.ListenAndServe(":8080", mux)

	fmt.Println("Server ready")
}
Output:
Server ready
Example (DualTransport)

This example shows how to set up a server with both WebSocket and SSE transports running simultaneously.

package main

import (
	"context"
	"fmt"
	"net/http"

	"github.com/marrasen/aprot"
)

// Request and response types
type CreateUserRequest struct {
	Name  string `json:"name"`
	Email string `json:"email"`
}

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

type ProcessItemsRequest struct {
	Items []string `json:"items"`
}

type ProcessItemsResponse struct {
	Processed int `json:"processed"`
}

// Push event types
type UserUpdatedEvent struct {
	ID   string `json:"id"`
	Name string `json:"name"`
}

type ProcessingCompleteEvent struct {
	Count int `json:"count"`
}

// Handlers struct
type MyHandlers struct {
	server *aprot.Server
}

// CreateUser handles user creation
func (h *MyHandlers) CreateUser(ctx context.Context, req *CreateUserRequest) (*CreateUserResponse, error) {
	progress := aprot.Progress(ctx)
	progress.Update(1, 2, "Validating...")

	if req.Name == "" {
		return nil, aprot.ErrInvalidParams("name is required")
	}

	progress.Update(2, 2, "Creating user...")

	conn := aprot.Connection(ctx)
	if conn != nil {
		conn.Push(&UserUpdatedEvent{ID: "123", Name: req.Name})
	}

	return &CreateUserResponse{ID: "123", Name: req.Name}, nil
}

// ProcessItems demonstrates progress reporting with cancellation
func (h *MyHandlers) ProcessItems(ctx context.Context, req *ProcessItemsRequest) (*ProcessItemsResponse, error) {
	progress := aprot.Progress(ctx)
	total := len(req.Items)

	for i, item := range req.Items {
		select {
		case <-ctx.Done():
			return nil, aprot.ErrCanceled()
		default:
		}

		progress.Update(i+1, total, fmt.Sprintf("Processing %s...", item))
	}

	h.server.Broadcast(&ProcessingCompleteEvent{Count: total})

	return &ProcessItemsResponse{Processed: total}, nil
}

func main() {
	registry := aprot.NewRegistry()
	registry.Register(&MyHandlers{})

	server := aprot.NewServer(registry)

	mux := http.NewServeMux()
	mux.Handle("/ws", server)                   // WebSocket
	mux.Handle("/sse", server.HTTPTransport())  // SSE+HTTP
	mux.Handle("/sse/", server.HTTPTransport()) // SSE sub-routes (rpc, cancel)

	fmt.Println("Dual transport ready")
}
Output:
Dual transport ready
Example (Generate)
package main

import (
	"context"
	"fmt"
	"io"

	"github.com/marrasen/aprot"
)

// Request and response types
type CreateUserRequest struct {
	Name  string `json:"name"`
	Email string `json:"email"`
}

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

type ProcessItemsRequest struct {
	Items []string `json:"items"`
}

type ProcessItemsResponse struct {
	Processed int `json:"processed"`
}

// Push event types
type UserUpdatedEvent struct {
	ID   string `json:"id"`
	Name string `json:"name"`
}

type ProcessingCompleteEvent struct {
	Count int `json:"count"`
}

// Handlers struct
type MyHandlers struct {
	server *aprot.Server
}

// CreateUser handles user creation
func (h *MyHandlers) CreateUser(ctx context.Context, req *CreateUserRequest) (*CreateUserResponse, error) {
	progress := aprot.Progress(ctx)
	progress.Update(1, 2, "Validating...")

	if req.Name == "" {
		return nil, aprot.ErrInvalidParams("name is required")
	}

	progress.Update(2, 2, "Creating user...")

	conn := aprot.Connection(ctx)
	if conn != nil {
		conn.Push(&UserUpdatedEvent{ID: "123", Name: req.Name})
	}

	return &CreateUserResponse{ID: "123", Name: req.Name}, nil
}

// ProcessItems demonstrates progress reporting with cancellation
func (h *MyHandlers) ProcessItems(ctx context.Context, req *ProcessItemsRequest) (*ProcessItemsResponse, error) {
	progress := aprot.Progress(ctx)
	total := len(req.Items)

	for i, item := range req.Items {
		select {
		case <-ctx.Done():
			return nil, aprot.ErrCanceled()
		default:
		}

		progress.Update(i+1, total, fmt.Sprintf("Processing %s...", item))
	}

	h.server.Broadcast(&ProcessingCompleteEvent{Count: total})

	return &ProcessItemsResponse{Processed: total}, nil
}

func main() {
	// Create registry with handlers
	registry := aprot.NewRegistry()
	myHandlers := &MyHandlers{}
	registry.Register(myHandlers)

	// Register push events on the registry
	registry.RegisterPushEventFor(myHandlers, UserUpdatedEvent{})

	// Generate TypeScript to a file (or any io.Writer)
	gen := aprot.NewGenerator(registry)
	gen.GenerateTo(io.Discard)

	fmt.Println("Generated")
}
Output:
Generated

Index

Examples

Constants

View Source
const (
	CodeParseError         = -32700
	CodeInvalidRequest     = -32600
	CodeMethodNotFound     = -32601
	CodeInvalidParams      = -32602
	CodeInternalError      = -32603
	CodeValidationFailed   = -32604
	CodeCanceled           = -32800
	CodeUnauthorized       = -32001
	CodeConnectionRejected = -32002
	CodeForbidden          = -32003
)

Standard error codes.

Variables

View Source
var (
	// ErrClientCanceled indicates the client explicitly canceled the request.
	ErrClientCanceled = &CancelReason{"client canceled"}
	// ErrConnectionClosed indicates the client disconnected.
	ErrConnectionClosed = &CancelReason{"connection closed"}
	// ErrServerShutdown indicates the server is shutting down.
	ErrServerShutdown = &CancelReason{"server shutdown"}
)

Functions

func ApplyTransforms added in v0.40.0

func ApplyTransforms(v any) error

ApplyTransforms walks v (a struct or pointer to struct) and applies the operations declared in `transform:""` tags on its exported fields. It mutates the value in place.

Supported ops:

  • trim strings.TrimSpace
  • trimleft[=cutset] TrimLeft (default cutset: whitespace)
  • trimright[=cutset] TrimRight (default cutset: whitespace)
  • uppercase strings.ToUpper
  • lowercase strings.ToLower
  • removeempty ([]string only) drop empty elements

Ops apply in the order listed in the tag, so `transform:"trim,removeempty"` on a []string first trims each element and then drops the empties.

Non-struct inputs are a no-op. Unknown op names or type mismatches (e.g. removeempty on a non-slice field) return an *ProtocolError with CodeInvalidParams.

func CancelCause

func CancelCause(ctx context.Context) error

CancelCause returns the reason the request context was canceled. Returns nil if the context has not been canceled or no cause was set. The returned error will be one of ErrClientCanceled, ErrConnectionClosed, or ErrServerShutdown.

func HTTPRequestFromContext added in v0.37.0

func HTTPRequestFromContext(ctx context.Context) *http.Request

HTTPRequestFromContext returns the *http.Request associated with a REST handler call. Returns nil if the context is not from a REST request.

func OnStreamComplete added in v0.39.0

func OnStreamComplete(ctx context.Context, hook StreamCompleteHook)

OnStreamComplete registers a callback that fires after a streaming handler's iterator has finished. Typically called from middleware before dispatching to the next handler, so the callback closure can capture the start time and emit a log entry once iteration finishes.

Example logging middleware that logs duration and item count for both unary and streaming handlers:

func Logging(next aprot.Handler) aprot.Handler {
    return func(ctx context.Context, req *aprot.Request) (any, error) {
        start := time.Now()
        aprot.OnStreamComplete(ctx, func(err error, items int) {
            slog.Info("stream done",
                "method", req.Method,
                "dur", time.Since(start),
                "items", items,
                "err", err)
        })
        result, err := next(ctx, req)
        if info := aprot.HandlerInfoFromContext(ctx); info != nil &&
            info.Kind != aprot.HandlerKindUnary {
            // Streaming handler: hook will fire later (unless this is
            // a preflight error, in which case log it here).
            if err != nil {
                slog.Error("stream preflight error",
                    "method", req.Method,
                    "dur", time.Since(start),
                    "err", err)
            }
            return result, err
        }
        slog.Info("unary done",
            "method", req.Method, "dur", time.Since(start), "err", err)
        return result, err
    }
}

Calling OnStreamComplete on a unary handler's context is a no-op — the hooks slot is only populated for requests whose handler returns an iter.Seq shape.

func RegisterRefreshTrigger

func RegisterRefreshTrigger(ctx context.Context, keys ...string)

RegisterRefreshTrigger registers trigger keys that this subscription depends on. When called from a regular (non-subscribe) request, this is a no-op. Keys are variadic strings that form a composite trigger key.

func SQLNullGoKind added in v0.37.1

func SQLNullGoKind(t reflect.Type, kindResolver func(reflect.Type) string) string

SQLNullGoKind returns the unwrapped Go kind string ("string", "int", "float", "bool") for database/sql nullable wrappers, parallel to SQLNullTSType. Returns "" if t is not a sql.Null type. kindResolver handles the generic Null[T] case and is typically goKindString.

This is the Zod-side companion to SQLNullTSType: rather than sniffing the TypeScript output for a " | null" suffix, we derive the unwrapped kind directly from reflection so fieldData carries the information explicitly.

func SQLNullTSType

func SQLNullTSType(t reflect.Type, typeResolver func(reflect.Type) string) string

SQLNullTSType checks whether t is a database/sql nullable type (NullString, NullInt64, NullBool, NullFloat64, NullInt32, NullInt16, NullByte, NullTime, or the generic Null[T]) and returns the corresponding TypeScript type (e.g. "string | null"). Returns "" if t is not a sql.Null type.

typeResolver is called for the generic Null[T] case to convert the inner Go type to its TypeScript representation.

func TriggerRefresh

func TriggerRefresh(ctx context.Context, keys ...string)

TriggerRefresh queues a refresh for all subscriptions matching the given keys. Called from mutation handlers to notify subscribed clients of data changes. Triggers are batched per-request and deduplicated by subscription when the request handler completes. This is a no-op outside a request context — for background goroutines, cron jobs, or other out-of-request callers, use Server.TriggerRefresh instead.

func TriggerRefreshNow

func TriggerRefreshNow(ctx context.Context, keys ...string)

TriggerRefreshNow is like TriggerRefresh but flushes the refresh queue immediately instead of deferring until the handler returns. Use this in long-running handlers when you want subscribers to observe intermediate state transitions before the handler completes.

TriggerRefreshNow flushes every key queued so far (including keys passed to prior TriggerRefresh calls), not just the keys in this call. Subsequent TriggerRefresh / TriggerRefreshNow calls start with an empty queue, so a handler can fire multiple rounds of refreshes by calling TriggerRefreshNow at each state transition.

This is a no-op outside a request context and during subscription re-execution (cascading refreshes are prevented).

Concurrency: triggered subscription handlers run in their own goroutines, concurrently with the rest of the calling handler. If the calling handler mutates shared state that the subscription handler reads, the subscription must return a defensive copy (or otherwise be safe to read without coordination) to avoid data races. The same applies to any response marshaling — a shared slice or map returned by the subscription may still be marshaled after the caller continues executing.

func ValidateTransformTags added in v0.40.0

func ValidateTransformTags(t reflect.Type) error

ValidateTransformTags statically checks every `transform:""` tag reachable from t (a struct type or pointer to one). It catches unknown op names, ops used on unsupported field kinds, and `removeempty` on anything other than `[]string` — all at registration time, so the problem surfaces when the server boots rather than on the first request that happens to hit the handler.

Non-struct types are a no-op; caller is responsible for passing the param type. Recursion into nested structs, *struct, and slices/arrays of struct (or *struct) elements mirrors the runtime walker. Cycles are broken by tracking visited types.

func WithTestConnection

func WithTestConnection(ctx context.Context, id uint64) context.Context

WithTestConnection returns a context carrying a minimal Conn with the given ID. The connection has no functioning transport and is intended exclusively for use in tests.

Types

type Broadcaster

type Broadcaster interface {
	Broadcast(data any)
}

Broadcaster is an interface for broadcasting push events to all clients. The event name is derived from the Go type of data, which must have been registered via RegisterPushEventFor.

type CancelReason

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

CancelReason represents why a request context was canceled.

func (*CancelReason) Error

func (r *CancelReason) Error() string

type Components added in v0.37.0

type Components struct {
	Schemas map[string]*JSONSchema `json:"schemas,omitempty"`
}

Components holds reusable schema definitions.

type ConfigMessage

type ConfigMessage struct {
	Type                 MessageType `json:"type"`
	ReconnectInterval    int         `json:"reconnectInterval,omitempty"`
	ReconnectMaxInterval int         `json:"reconnectMaxInterval,omitempty"`
	ReconnectMaxAttempts int         `json:"reconnectMaxAttempts,omitempty"`
}

ConfigMessage represents server-pushed configuration for the client.

type Conn

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

Conn represents a single client connection.

func Connection

func Connection(ctx context.Context) *Conn

Connection returns the Connection from the context. Returns nil if not present.

func (*Conn) Context

func (c *Conn) Context() context.Context

Context returns the context from the HTTP request. This is useful for accessing request-scoped values like zerolog loggers.

func (*Conn) Get

func (c *Conn) Get(key any) any

Get retrieves a value previously stored with Set. Returns nil if the key was never set (or was set to nil). Use Load to distinguish between an unset key and a key set to nil. Safe for concurrent use.

func (*Conn) ID

func (c *Conn) ID() uint64

ID returns the unique connection ID.

func (*Conn) Info

func (c *Conn) Info() ConnInfo

Info returns HTTP request information captured at connection time.

func (*Conn) Load

func (c *Conn) Load(key any) (value any, ok bool)

Load retrieves a value previously stored with Set. The ok result indicates whether the key was found. This follows the sync.Map convention and allows callers to distinguish between an unset key and a key explicitly set to nil. Safe for concurrent use.

func (*Conn) Push

func (c *Conn) Push(data any) error

Push sends a push message to this connection. The event name is derived from the Go type of data, which must have been registered via RegisterPushEventFor.

func (*Conn) RemoteAddr

func (c *Conn) RemoteAddr() string

RemoteAddr returns the remote address of the connection.

func (*Conn) ServerBroadcaster

func (c *Conn) ServerBroadcaster() Broadcaster

ServerBroadcaster returns the server as a Broadcaster. This allows external packages to broadcast push events without exposing the *Server type directly.

func (*Conn) Set

func (c *Conn) Set(key, value any)

Set stores a value on the connection, keyed by an arbitrary key. This is useful for caching connection-scoped data (e.g. an authenticated principal) that persists for the connection's lifetime. The map is lazily initialized on first call, so connections that never call Set pay no cost. Keep stored values small — they live for the entire connection lifetime. Safe for concurrent use.

Example

This example shows how to use connection-scoped state to store and retrieve per-connection data such as authenticated principals.

package main

import (
	"context"
	"fmt"

	"github.com/marrasen/aprot"
)

// PublicHandlers has endpoints that require no authentication.
type PublicHandlers struct{}

func (h *PublicHandlers) Ping(ctx context.Context) (string, error) { return "pong", nil }

func main() {
	type principalKey struct{}

	registry := aprot.NewRegistry()
	registry.Register(&PublicHandlers{})
	server := aprot.NewServer(registry)

	server.OnConnect(func(ctx context.Context, conn *aprot.Conn) error {
		conn.Set(principalKey{}, "user_123")
		return nil
	})

	// Later, in any handler or middleware:
	// val := conn.Get(principalKey{}).(string)

	// Use Load to distinguish "not set" from "set to nil":
	// v, ok := conn.Load(principalKey{})

	fmt.Println("Connection state configured")
}
Output:
Connection state configured

func (*Conn) SetUserID

func (c *Conn) SetUserID(userID string)

SetUserID associates this connection with a user ID. Call this from auth middleware after successful authentication. A user can have multiple connections (multiple tabs/devices).

func (*Conn) UserID

func (c *Conn) UserID() string

UserID returns the associated user ID, or empty string if not set.

type ConnInfo

type ConnInfo struct {
	RemoteAddr string
	Header     http.Header
	Cookies    []*http.Cookie
	URL        string
	Host       string
}

ConnInfo contains HTTP request information captured at connection time.

type ConnectHook

type ConnectHook func(ctx context.Context, conn *Conn) error

ConnectHook is called when a new connection is established. Return an error to reject the connection.

type ConnectedMessage

type ConnectedMessage struct {
	Type         MessageType `json:"type"`
	ConnectionID string      `json:"connectionId"`
}

ConnectedMessage is sent as the first SSE event to provide the connection ID.

type DefaultNaming

type DefaultNaming struct {
	FixAcronyms bool
}

DefaultNaming reproduces the current naming behavior. Set FixAcronyms to true to treat consecutive uppercase letters as a single word (e.g. "BulkXMLHandlers" → "bulk-xml-handlers" instead of "bulk-x-m-l-handlers").

func (DefaultNaming) ErrorMethodName

func (d DefaultNaming) ErrorMethodName(errorName string) string

func (DefaultNaming) FileName

func (d DefaultNaming) FileName(groupName string) string

func (DefaultNaming) HandlerName

func (d DefaultNaming) HandlerName(eventName string) string

func (DefaultNaming) HookName

func (d DefaultNaming) HookName(name string) string

func (DefaultNaming) MethodName

func (d DefaultNaming) MethodName(name string) string

func (DefaultNaming) PathPrefix added in v0.37.0

func (d DefaultNaming) PathPrefix(groupName string) string

func (DefaultNaming) PathSegment added in v0.37.0

func (d DefaultNaming) PathSegment(methodName string) string

type DisconnectHook

type DisconnectHook func(ctx context.Context, conn *Conn)

DisconnectHook is called when a connection is closed.

type EnumInfo

type EnumInfo struct {
	Name     string       // e.g., "StrState"
	Type     reflect.Type // the reflect.Type for lookup
	IsString bool         // true for string-based, false for int-based
	Values   []EnumValueInfo
}

EnumInfo describes a registered enum type.

type EnumValueInfo

type EnumValueInfo struct {
	Name  string // e.g., "Pending"
	Value any    // e.g., "pending" (string) or 0 (int)
}

EnumValueInfo describes a single enum value.

type ErrorCodeInfo

type ErrorCodeInfo struct {
	Name string // e.g., "EndOfFile"
	Code int    // e.g., 1000
}

ErrorCodeInfo describes a custom error code for code generation.

type ErrorMessage

type ErrorMessage struct {
	Type    MessageType `json:"type"`
	ID      string      `json:"id"`
	Code    int         `json:"code"`
	Message string      `json:"message"`
	Data    any         `json:"data,omitempty"`
}

ErrorMessage represents an error response from server to client.

type FieldError added in v0.37.0

type FieldError struct {
	Field   string `json:"field"`   // JSON field name
	Tag     string `json:"tag"`     // validation tag that failed, e.g. "required", "min"
	Value   any    `json:"value"`   // the rejected value
	Param   string `json:"param"`   // tag parameter, e.g. "3" for min=3
	Message string `json:"message"` // human-readable message
}

FieldError describes a single field-level validation failure.

type Generator

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

Generator generates TypeScript client code from a registry.

func NewGenerator

func NewGenerator(registry *Registry) *Generator

NewGenerator creates a new TypeScript generator.

func (*Generator) Generate

func (g *Generator) Generate() (map[string]string, error)

Generate writes TypeScript client code for all handler groups. Returns a map of filename to content, or writes to OutputDir if set. Generates:

  • client.ts: Base client with ApiClient, ApiError, ErrorCode, etc.
  • {handler-name}.ts: Handler-specific interfaces and methods for each handler group

func (*Generator) GenerateTo

func (g *Generator) GenerateTo(w io.Writer) error

GenerateTo writes TypeScript client code to a single writer. This combines all handler groups into one file (legacy behavior).

func (*Generator) WithOptions

func (g *Generator) WithOptions(opts GeneratorOptions) *Generator

WithOptions sets generator options.

type GeneratorOptions

type GeneratorOptions struct {
	// OutputDir is the directory to write generated files to.
	// If empty, files are written to current directory.
	OutputDir string

	// Mode specifies vanilla or react output.
	Mode OutputMode

	// Naming controls how Go names are transformed into TypeScript names.
	// If nil, DefaultNaming{} is used (preserving current behavior).
	Naming NamingPlugin

	// Zod enables generation of Zod validation schemas alongside TypeScript interfaces.
	// When enabled, {handler-name}.schema.ts files are generated for structs with validate tags.
	Zod bool
}

GeneratorOptions configures the code generator.

type HTTPMethod added in v0.37.0

type HTTPMethod string

HTTPMethod represents an HTTP method.

const (
	HTTPGet    HTTPMethod = "GET"
	HTTPPost   HTTPMethod = "POST"
	HTTPPut    HTTPMethod = "PUT"
	HTTPPatch  HTTPMethod = "PATCH"
	HTTPDelete HTTPMethod = "DELETE"
)

type Handler

type Handler func(ctx context.Context, req *Request) (any, error)

Handler represents the next step in the middleware chain.

type HandlerGroup

type HandlerGroup struct {
	Name       string
	Handlers   map[string]*HandlerInfo
	PushEvents []PushEventInfo
	Enums      []EnumInfo
	// contains filtered or unexported fields
}

HandlerGroup contains all methods from a single handler struct.

func (*HandlerGroup) SourceDir

func (g *HandlerGroup) SourceDir() string

SourceDir returns the directory containing the handler's Go source files. Discovered automatically via runtime.FuncForPC during Register().

type HandlerInfo

type HandlerInfo struct {
	Name         string
	Params       []ParamInfo // handler parameters (after ctx), empty for no-params handlers
	ResponseType reflect.Type
	StructName   string
	IsVoid       bool        // true when handler returns only error
	Kind         HandlerKind // unary, stream, or stream2
	// StreamKeyType is set only when Kind == HandlerKindStream2; it holds the
	// key type K of iter.Seq2[K, V]. ResponseType holds the value type V.
	StreamKeyType reflect.Type
	// contains filtered or unexported fields
}

HandlerInfo contains metadata about a registered handler method.

func HandlerInfoFromContext

func HandlerInfoFromContext(ctx context.Context) *HandlerInfo

HandlerInfoFromContext returns the HandlerInfo from the context. Returns nil if not present.

func (*HandlerInfo) Call

func (info *HandlerInfo) Call(ctx context.Context, params jsontext.Value) (any, error)

Call invokes a unary handler with the given context and JSON params and returns its (response, error) pair. Streaming handlers must use CallStream. Params must be a JSON array (positional arguments) or empty/nil for no-params handlers.

func (*HandlerInfo) CallStream added in v0.38.0

func (info *HandlerInfo) CallStream(ctx context.Context, params jsontext.Value) (reflect.Value, error)

CallStream invokes a streaming handler and returns the raw reflect.Value of the returned iter.Seq / iter.Seq2. The caller is responsible for driving the iterator (typically via reflect.MakeFunc). If the handler returns a preflight error it is returned without invoking the iterator.

type HandlerKind added in v0.38.0

type HandlerKind uint8

HandlerKind categorizes a handler by its return shape so the dispatcher knows how to invoke and marshal its output.

const (
	// HandlerKindUnary: func(ctx, ...) error | (*T, error) | (T, error)
	HandlerKindUnary HandlerKind = iota
	// HandlerKindStream: func(ctx, ...) (iter.Seq[T], error)
	HandlerKindStream
	// HandlerKindStream2: func(ctx, ...) (iter.Seq2[K, V], error)
	HandlerKindStream2
)

type IncomingMessage

type IncomingMessage struct {
	Type   MessageType    `json:"type"`
	ID     string         `json:"id,omitempty"`
	Method string         `json:"method,omitempty"`
	Params jsontext.Value `json:"params,omitempty"`
}

IncomingMessage represents a message from client to server. Method uses qualified "Group.Method" format (e.g., "PublicHandlers.CreateUser").

type JSONSchema added in v0.37.0

type JSONSchema struct {
	Type                 string                 `json:"type,omitempty"`
	Format               string                 `json:"format,omitempty"`
	Properties           map[string]*JSONSchema `json:"properties,omitempty"`
	Required             []string               `json:"required,omitempty"`
	Items                *JSONSchema            `json:"items,omitempty"`
	Enum                 []any                  `json:"enum,omitempty"`
	Ref                  string                 `json:"$ref,omitempty"`
	MinLength            *int                   `json:"minLength,omitempty"`
	MaxLength            *int                   `json:"maxLength,omitempty"`
	Minimum              *float64               `json:"minimum,omitempty"`
	Maximum              *float64               `json:"maximum,omitempty"`
	ExclusiveMinimum     *float64               `json:"exclusiveMinimum,omitempty"`
	ExclusiveMaximum     *float64               `json:"exclusiveMaximum,omitempty"`
	Pattern              string                 `json:"pattern,omitempty"`
	Description          string                 `json:"description,omitempty"`
	Nullable             bool                   `json:"nullable,omitempty"`
	AdditionalProperties *JSONSchema            `json:"additionalProperties,omitempty"`
}

JSONSchema represents a JSON Schema object (subset used by OpenAPI 3.0).

type MarshalTSType

type MarshalTSType struct {
	TSType string // e.g. "string", "number", "boolean", "Record<string, number>", "string[]"
}

MarshalTSType is the result of inferring a TypeScript type from a Go type's JSON marshaling behavior.

func InferTypeFromMarshal

func InferTypeFromMarshal(t reflect.Type) *MarshalTSType

InferTypeFromMarshal checks whether t implements json.Marshaler or encoding.TextMarshaler and, if so, marshals a zero value to determine the TypeScript type. Returns nil when the type does not implement either interface, when marshaling produces null, or when the type is an interface.

type MediaType added in v0.37.0

type MediaType struct {
	Schema *JSONSchema `json:"schema"`
}

MediaType describes a media type with schema.

type MessageType

type MessageType string

MessageType represents the type of protocol message.

const (
	TypeRequest     MessageType = "request"
	TypeCancel      MessageType = "cancel"
	TypeResponse    MessageType = "response"
	TypeError       MessageType = "error"
	TypeProgress    MessageType = "progress"
	TypePush        MessageType = "push"
	TypeConfig      MessageType = "config"
	TypeConnected   MessageType = "connected"
	TypeSubscribe   MessageType = "subscribe"
	TypeUnsubscribe MessageType = "unsubscribe"
	TypeStreamItem  MessageType = "stream_item"
	TypeStreamEnd   MessageType = "stream_end"
)

type Middleware

type Middleware func(next Handler) Handler

Middleware wraps a Handler to add cross-cutting behavior.

type NamingPlugin

type NamingPlugin interface {
	// FileName converts a handler group name (e.g. "PublicHandlers") to a
	// filename stem (without extension). The generator appends ".ts".
	FileName(groupName string) string

	// MethodName converts a Go handler method name (e.g. "CreateUser") to a
	// TypeScript function name (e.g. "createUser" or "CreateUser").
	MethodName(name string) string

	// HookName converts a Go handler/event name to a React hook name.
	// Default: "use" + name (e.g. "useCreateUser").
	HookName(name string) string

	// HandlerName converts a push event name to an event handler callback name.
	// Default: "on" + name (e.g. "onUserCreated").
	HandlerName(eventName string) string

	// ErrorMethodName converts a custom error code name to a type-guard method name.
	// Default: "is" + name (e.g. "isNotFound").
	ErrorMethodName(errorName string) string

	// PathPrefix converts a handler group name to a URL path prefix.
	// Default: "/" + kebab-case (e.g. "UserHandlers" → "/user-handlers").
	PathPrefix(groupName string) string

	// PathSegment converts a method name to a URL path segment.
	// Default: kebab-case (e.g. "UpdateUser" → "update-user").
	PathSegment(methodName string) string
}

NamingPlugin controls how Go names are transformed into TypeScript names during code generation and REST path construction.

type OpenAPIGenerator added in v0.37.0

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

OpenAPIGenerator generates an OpenAPI 3.0 spec from a Registry. Only handlers registered via RegisterREST are included in the spec.

func NewOpenAPIGenerator added in v0.37.0

func NewOpenAPIGenerator(registry *Registry, title, version string) *OpenAPIGenerator

NewOpenAPIGenerator creates an OpenAPI spec generator.

func (*OpenAPIGenerator) Generate added in v0.37.0

func (g *OpenAPIGenerator) Generate() (*OpenAPISpec, error)

Generate produces an OpenAPI 3.0 spec.

func (*OpenAPIGenerator) GenerateJSON added in v0.37.0

func (g *OpenAPIGenerator) GenerateJSON() ([]byte, error)

GenerateJSON produces the OpenAPI spec as formatted JSON bytes.

func (*OpenAPIGenerator) WithBasePath added in v0.37.0

func (g *OpenAPIGenerator) WithBasePath(path string) *OpenAPIGenerator

WithBasePath sets a prefix prepended to all paths in the generated spec. Use this when the API is mounted behind a proxy or at a non-root path.

Example:

oag.WithBasePath("/rest/api/v1.0")
// paths: "/rest/api/v1.0/todos/create-todo", etc.

func (*OpenAPIGenerator) WithNaming added in v0.37.0

WithNaming sets the naming plugin for path generation.

type OpenAPIInfo added in v0.37.0

type OpenAPIInfo struct {
	Title   string `json:"title"`
	Version string `json:"version"`
}

OpenAPIInfo describes the API metadata.

type OpenAPISpec added in v0.37.0

type OpenAPISpec struct {
	OpenAPI    string               `json:"openapi"`
	Info       OpenAPIInfo          `json:"info"`
	Paths      map[string]*PathItem `json:"paths"`
	Components *Components          `json:"components,omitempty"`
}

OpenAPISpec represents an OpenAPI 3.0 document.

type Operation added in v0.37.0

type Operation struct {
	OperationID string              `json:"operationId"`
	Tags        []string            `json:"tags,omitempty"`
	Summary     string              `json:"summary,omitempty"`
	Description string              `json:"description,omitempty"`
	Parameters  []Parameter         `json:"parameters,omitempty"`
	RequestBody *RequestBody        `json:"requestBody,omitempty"`
	Responses   map[string]Response `json:"responses"`
}

Operation represents a single API operation on a path.

type OutputMode

type OutputMode string

OutputMode specifies the type of client code to generate.

const (
	OutputVanilla OutputMode = "vanilla"
	OutputReact   OutputMode = "react"
)

type ParamInfo

type ParamInfo struct {
	Type     reflect.Type // the actual parameter type (e.g., string, *CreateUserRequest)
	Variadic bool         // true for the last param if the method is variadic
}

ParamInfo describes a single handler parameter (after context.Context).

type Parameter added in v0.37.0

type Parameter struct {
	Name     string      `json:"name"`
	In       string      `json:"in"` // "path", "query", "header"
	Required bool        `json:"required"`
	Schema   *JSONSchema `json:"schema"`
}

Parameter represents a single operation parameter.

type PathItem added in v0.37.0

type PathItem struct {
	Get    *Operation `json:"get,omitempty"`
	Post   *Operation `json:"post,omitempty"`
	Put    *Operation `json:"put,omitempty"`
	Patch  *Operation `json:"patch,omitempty"`
	Delete *Operation `json:"delete,omitempty"`
}

PathItem represents the operations on a single path.

type PlaygroundValidator added in v0.37.0

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

PlaygroundValidator wraps github.com/go-playground/validator/v10.

func NewPlaygroundValidator added in v0.37.0

func NewPlaygroundValidator() *PlaygroundValidator

NewPlaygroundValidator creates a StructValidator backed by go-playground/validator.

func (*PlaygroundValidator) Validate added in v0.37.0

func (p *PlaygroundValidator) Validate() *validator.Validate

Validate returns the underlying validator for registering custom validations.

func (*PlaygroundValidator) ValidateStruct added in v0.37.0

func (p *PlaygroundValidator) ValidateStruct(v any) error

ValidateStruct validates a struct and returns a *ProtocolError on failure.

type PreserveNaming

type PreserveNaming struct {
	FixAcronyms bool
}

PreserveNaming keeps Go PascalCase method names unchanged in the generated TypeScript. Filenames still use kebab-case (filesystem convention).

func (PreserveNaming) ErrorMethodName

func (p PreserveNaming) ErrorMethodName(errorName string) string

func (PreserveNaming) FileName

func (p PreserveNaming) FileName(groupName string) string

func (PreserveNaming) HandlerName

func (p PreserveNaming) HandlerName(eventName string) string

func (PreserveNaming) HookName

func (p PreserveNaming) HookName(name string) string

func (PreserveNaming) MethodName

func (p PreserveNaming) MethodName(name string) string

func (PreserveNaming) PathPrefix added in v0.37.0

func (p PreserveNaming) PathPrefix(groupName string) string

func (PreserveNaming) PathSegment added in v0.37.0

func (p PreserveNaming) PathSegment(methodName string) string

type ProgressMessage

type ProgressMessage struct {
	Type    MessageType `json:"type"`
	ID      string      `json:"id"`
	Current *int        `json:"current,omitempty"`
	Total   *int        `json:"total,omitempty"`
	Message string      `json:"message,omitempty"`
}

ProgressMessage represents a request-level progress update from server to client.

type ProgressReporter

type ProgressReporter interface {
	// Update sends a progress update to the client.
	Update(current, total int, message string)
}

ProgressReporter allows handlers to report progress during long operations.

func Progress

func Progress(ctx context.Context) ProgressReporter

Progress returns the ProgressReporter from the context. Returns a no-op reporter if not present.

type ProtocolError

type ProtocolError struct {
	Code    int
	Message string
	Cause   error
	Data    any // optional structured data (e.g., []FieldError for validation errors)
}

ProtocolError represents an error that can be sent to the client.

func ErrCanceled

func ErrCanceled() *ProtocolError

ErrCanceled returns a canceled error.

func ErrConnectionRejected

func ErrConnectionRejected(message string) *ProtocolError

ErrConnectionRejected returns a connection rejected error.

func ErrForbidden

func ErrForbidden(message string) *ProtocolError

ErrForbidden returns a forbidden error.

func ErrInternal

func ErrInternal(cause error) *ProtocolError

ErrInternal returns an internal error.

func ErrInvalidParams

func ErrInvalidParams(reason string) *ProtocolError

ErrInvalidParams returns an invalid params error.

func ErrMethodNotFound

func ErrMethodNotFound(method string) *ProtocolError

ErrMethodNotFound returns a method not found error.

func ErrUnauthorized

func ErrUnauthorized(message string) *ProtocolError

ErrUnauthorized returns an unauthorized error.

func NewError

func NewError(code int, message string) *ProtocolError

NewError creates a new protocol error.

func WrapError

func WrapError(code int, message string, cause error) *ProtocolError

WrapError creates a new protocol error wrapping an existing error.

func (*ProtocolError) Error

func (e *ProtocolError) Error() string

func (*ProtocolError) Unwrap

func (e *ProtocolError) Unwrap() error

type PushEventInfo

type PushEventInfo struct {
	Name       string
	DataType   reflect.Type
	StructName string
}

PushEventInfo describes a push event for code generation.

type PushMessage

type PushMessage struct {
	Type  MessageType `json:"type"`
	Event string      `json:"event"`
	Data  any         `json:"data"`
}

PushMessage represents a server-initiated push message.

type RESTAdapter added in v0.37.0

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

RESTAdapter serves registered handlers over HTTP/REST. Only handlers registered via RegisterREST are exposed. It implements http.Handler and can be mounted on any stdlib-compatible router.

func NewRESTAdapter added in v0.37.0

func NewRESTAdapter(registry *Registry, opts ...RESTOption) *RESTAdapter

NewRESTAdapter creates an HTTP/REST adapter from a registry. Handlers are mapped to REST endpoints using naming conventions.

func (*RESTAdapter) Routes added in v0.37.0

func (a *RESTAdapter) Routes() []RouteInfo

Routes returns all computed routes for inspection or documentation.

func (*RESTAdapter) ServeHTTP added in v0.37.0

func (a *RESTAdapter) ServeHTTP(w http.ResponseWriter, r *http.Request)

ServeHTTP implements http.Handler.

type RESTOption added in v0.37.0

type RESTOption func(*RESTAdapter)

RESTOption configures a RESTAdapter.

func WithRESTMiddleware added in v0.37.0

func WithRESTMiddleware(mw ...Middleware) RESTOption

WithRESTMiddleware adds middleware to all REST endpoints.

func WithRESTNaming added in v0.37.0

func WithRESTNaming(n NamingPlugin) RESTOption

WithRESTNaming sets the naming plugin for path generation.

type Registry

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

Registry holds registered handlers and their methods.

func NewRegistry

func NewRegistry() *Registry

NewRegistry creates a new handler registry.

func (*Registry) EnableREST added in v0.37.0

func (r *Registry) EnableREST(handler any)

EnableREST marks an already-registered handler for REST/HTTP exposure in addition to WebSocket. Streaming handlers cannot be exposed via REST and will panic at registration time.

func (*Registry) Enums

func (r *Registry) Enums() []EnumInfo

Enums returns all registered enum types across all handler groups and shared enums.

func (*Registry) ErrorCodes

func (r *Registry) ErrorCodes() []ErrorCodeInfo

ErrorCodes returns all registered custom error codes.

func (*Registry) GenerateHooks

func (r *Registry) GenerateHooks() []func(results map[string]string, mode OutputMode)

GenerateHooks returns the registered generation hooks.

func (*Registry) Get

func (r *Registry) Get(method string) (*HandlerInfo, bool)

Get returns the handler info for the given method name.

func (*Registry) GetEnum

func (r *Registry) GetEnum(t reflect.Type) *EnumInfo

GetEnum returns the EnumInfo for a registered enum type, or nil if not registered.

func (*Registry) GetMiddleware

func (r *Registry) GetMiddleware(method string) []Middleware

GetMiddleware returns the middleware for a specific handler method.

func (*Registry) GroupMiddleware added in v0.37.0

func (r *Registry) GroupMiddleware(groupName string) []Middleware

GroupMiddleware returns the middleware for a handler group by group name. Works for both WS-registered and REST-only handlers.

func (*Registry) Groups

func (r *Registry) Groups() map[string]*HandlerGroup

Groups returns all registered handler groups.

func (*Registry) Handlers

func (r *Registry) Handlers() map[string]*HandlerInfo

Handlers returns all registered handler infos.

func (*Registry) IsREST added in v0.37.0

func (r *Registry) IsREST(groupName string) bool

IsREST reports whether the named handler group was registered via RegisterREST.

func (*Registry) LookupError

func (r *Registry) LookupError(err error) (int, bool)

LookupError checks if an error matches any registered error mapping. Returns the code and true if found, or 0 and false if not.

func (*Registry) Methods

func (r *Registry) Methods() []string

Methods returns all registered method names.

func (*Registry) OnGenerate

func (r *Registry) OnGenerate(hook func(results map[string]string, mode OutputMode))

OnGenerate registers a hook called after code generation. The hook receives the results map (filename → content) and the output mode. Hooks can modify existing entries or add new files.

func (*Registry) OnServerInit

func (r *Registry) OnServerInit(hook func(s *Server))

OnServerInit registers a hook called during NewServer after the server is constructed but before the run loop starts. This lets subpackages like tasks/ defer server-side setup to server creation time.

func (*Registry) PushEvents

func (r *Registry) PushEvents() []PushEventInfo

PushEvents returns all registered push events.

func (*Registry) RESTGroups added in v0.37.0

func (r *Registry) RESTGroups() map[string]bool

RESTGroups returns the set of handler group names registered via RegisterREST.

func (*Registry) Register

func (r *Registry) Register(handler any, middleware ...Middleware)

Register registers all valid handler methods from the given struct. Optional middleware will be applied to all methods in this handler. A valid handler method must accept context.Context as its first parameter (after the receiver), followed by any number of additional parameters of any type. It must return either error or (*T, error).

Example signatures:

func(ctx context.Context) error
func(ctx context.Context) (*Resp, error)
func(ctx context.Context, req *T) (*Resp, error)
func(ctx context.Context, name string, age int) (*Resp, error)
func(ctx context.Context, items ...string) error

Example:

registry.Register(&PublicHandlers{})                    // No middleware
registry.Register(&UserHandlers{}, authMiddleware)      // With auth
registry.Register(&AdminHandlers{}, authMiddleware, adminMiddleware)

func (*Registry) RegisterEnum

func (r *Registry) RegisterEnum(values any)

RegisterEnum registers an enum type that is not tied to any handler group. The enum will be generated in a shared TypeScript file, importable by all handler files. Pass the result of a Values() function (e.g. EventTypeValues()).

Example:

registry.RegisterEnum(EventTypeValues())
registry.RegisterEnum(RSVPStatusValues())

func (*Registry) RegisterEnumFor

func (r *Registry) RegisterEnumFor(handler any, values any)

RegisterEnumFor registers an enum type associated with a handler group for TypeScript generation. The enum will be generated in the handler's TypeScript file. Pass the handler instance (same one used in Register) and the result of the Values() function.

Example:

handler := &MyHandlers{}
registry.Register(handler)
registry.RegisterEnumFor(handler, StrStateValues())   // string-based
registry.RegisterEnumFor(handler, IntStatusValues())  // int-based with Stringer
Example

This example shows how to register enums for TypeScript generation. String-based enums derive names by capitalizing values; int-based enums use the String() method.

package main

import (
	"context"
	"fmt"

	"github.com/marrasen/aprot"
)

// Request and response types
type CreateUserRequest struct {
	Name  string `json:"name"`
	Email string `json:"email"`
}

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

type ProcessItemsRequest struct {
	Items []string `json:"items"`
}

type ProcessItemsResponse struct {
	Processed int `json:"processed"`
}

// Push event types
type UserUpdatedEvent struct {
	ID   string `json:"id"`
	Name string `json:"name"`
}

type ProcessingCompleteEvent struct {
	Count int `json:"count"`
}

// Handlers struct
type MyHandlers struct {
	server *aprot.Server
}

// CreateUser handles user creation
func (h *MyHandlers) CreateUser(ctx context.Context, req *CreateUserRequest) (*CreateUserResponse, error) {
	progress := aprot.Progress(ctx)
	progress.Update(1, 2, "Validating...")

	if req.Name == "" {
		return nil, aprot.ErrInvalidParams("name is required")
	}

	progress.Update(2, 2, "Creating user...")

	conn := aprot.Connection(ctx)
	if conn != nil {
		conn.Push(&UserUpdatedEvent{ID: "123", Name: req.Name})
	}

	return &CreateUserResponse{ID: "123", Name: req.Name}, nil
}

// ProcessItems demonstrates progress reporting with cancellation
func (h *MyHandlers) ProcessItems(ctx context.Context, req *ProcessItemsRequest) (*ProcessItemsResponse, error) {
	progress := aprot.Progress(ctx)
	total := len(req.Items)

	for i, item := range req.Items {
		select {
		case <-ctx.Done():
			return nil, aprot.ErrCanceled()
		default:
		}

		progress.Update(i+1, total, fmt.Sprintf("Processing %s...", item))
	}

	h.server.Broadcast(&ProcessingCompleteEvent{Count: total})

	return &ProcessItemsResponse{Processed: total}, nil
}

func main() {
	type Status string
	const (
		StatusActive  Status = "active"
		StatusExpired Status = "expired"
	)

	registry := aprot.NewRegistry()
	handlers := &MyHandlers{}
	registry.Register(handlers)
	registry.RegisterEnumFor(handlers, []Status{StatusActive, StatusExpired})

	fmt.Println("Enum registered")
}
Output:
Enum registered

func (*Registry) RegisterError

func (r *Registry) RegisterError(err error, name string)

RegisterError registers a Go error with a name for code generation. When handlers return this error, it will be automatically converted to a ProtocolError with the assigned code. Codes are auto-assigned starting at 1000.

Example

This example shows how to register Go errors for automatic conversion to typed TypeScript error codes.

package main

import (
	"context"
	"fmt"

	"github.com/marrasen/aprot"
)

// Request and response types
type CreateUserRequest struct {
	Name  string `json:"name"`
	Email string `json:"email"`
}

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

type ProcessItemsRequest struct {
	Items []string `json:"items"`
}

type ProcessItemsResponse struct {
	Processed int `json:"processed"`
}

// Push event types
type UserUpdatedEvent struct {
	ID   string `json:"id"`
	Name string `json:"name"`
}

type ProcessingCompleteEvent struct {
	Count int `json:"count"`
}

// Handlers struct
type MyHandlers struct {
	server *aprot.Server
}

// CreateUser handles user creation
func (h *MyHandlers) CreateUser(ctx context.Context, req *CreateUserRequest) (*CreateUserResponse, error) {
	progress := aprot.Progress(ctx)
	progress.Update(1, 2, "Validating...")

	if req.Name == "" {
		return nil, aprot.ErrInvalidParams("name is required")
	}

	progress.Update(2, 2, "Creating user...")

	conn := aprot.Connection(ctx)
	if conn != nil {
		conn.Push(&UserUpdatedEvent{ID: "123", Name: req.Name})
	}

	return &CreateUserResponse{ID: "123", Name: req.Name}, nil
}

// ProcessItems demonstrates progress reporting with cancellation
func (h *MyHandlers) ProcessItems(ctx context.Context, req *ProcessItemsRequest) (*ProcessItemsResponse, error) {
	progress := aprot.Progress(ctx)
	total := len(req.Items)

	for i, item := range req.Items {
		select {
		case <-ctx.Done():
			return nil, aprot.ErrCanceled()
		default:
		}

		progress.Update(i+1, total, fmt.Sprintf("Processing %s...", item))
	}

	h.server.Broadcast(&ProcessingCompleteEvent{Count: total})

	return &ProcessItemsResponse{Processed: total}, nil
}

func main() {
	registry := aprot.NewRegistry()
	handlers := &MyHandlers{}
	registry.Register(handlers)

	// Registered errors are auto-converted when returned from handlers.
	// Codes are auto-assigned starting at 1000.
	registry.RegisterError(context.DeadlineExceeded, "Timeout")

	// Register code-only for manual use with NewError.
	_ = registry.RegisterErrorCode("InsufficientBalance")

	fmt.Println("Errors registered")
}
Output:
Errors registered

func (*Registry) RegisterErrorCode

func (r *Registry) RegisterErrorCode(name string) int

RegisterErrorCode registers a custom error code name without an error mapping. Use this for errors that will be created manually with NewError().

func (*Registry) RegisterPushEventFor

func (r *Registry) RegisterPushEventFor(handler any, dataType any)

RegisterPushEventFor registers a push event associated with a specific handler. The handler must have been previously registered via Register(). The event name is the Go type name (e.g., UserCreatedEvent → "UserCreatedEvent"). Broadcasting an unregistered push type will panic.

Example:

registry.RegisterPushEventFor(publicHandlers, UserCreatedEvent{})

func (*Registry) RegisterREST added in v0.37.0

func (r *Registry) RegisterREST(handler any, middleware ...Middleware)

RegisterREST registers a handler for REST/HTTP only. The handler is NOT available via WebSocket — only through the REST adapter and OpenAPI generator.

Streaming handlers (iter.Seq / iter.Seq2 return shapes) cannot be exposed via REST and will panic at registration time.

To expose a handler via both WebSocket and REST, use Register + EnableREST:

registry.Register(&UserHandlers{})          // WebSocket only
registry.RegisterREST(&TodoHandlers{})      // REST only
registry.Register(&BothHandlers{})          // WebSocket...
registry.EnableREST(&BothHandlers{})        // ...and also REST

func (*Registry) SetValidator added in v0.37.0

func (r *Registry) SetValidator(v StructValidator)

SetValidator sets the struct validator used for automatic parameter validation. When set, struct parameters are validated before handler dispatch. Pass nil to disable validation.

Example:

registry.SetValidator(aprot.NewPlaygroundValidator())

func (*Registry) SharedEnums

func (r *Registry) SharedEnums() []EnumInfo

SharedEnums returns enum types registered via RegisterEnum (not tied to any handler group).

type Request

type Request struct {
	ID     string         // Request ID for correlation
	Method string         // Method name being called
	Params jsontext.Value // Raw JSON parameters
}

Request contains information about the incoming request.

func RequestFromContext

func RequestFromContext(ctx context.Context) *Request

RequestFromContext returns the Request from the context. Returns nil if not present.

type RequestBody added in v0.37.0

type RequestBody struct {
	Required bool                 `json:"required"`
	Content  map[string]MediaType `json:"content"`
}

RequestBody represents the request body.

type Response added in v0.37.0

type Response struct {
	Description string               `json:"description"`
	Content     map[string]MediaType `json:"content,omitempty"`
}

Response represents a single response.

type ResponseMessage

type ResponseMessage struct {
	Type   MessageType `json:"type"`
	ID     string      `json:"id"`
	Result any         `json:"result"`
}

ResponseMessage represents a successful response from server to client.

type RouteInfo added in v0.37.0

type RouteInfo struct {
	HTTPMethod  HTTPMethod
	Pattern     string // e.g., "GET /users/update-user/{id}"
	Path        string // e.g., "/users/update-user/{id}"
	GroupName   string
	MethodName  string
	WireMethod  string // e.g., "Users.UpdateUser"
	PathParams  []routeParam
	BodyParam   *ParamInfo
	HandlerInfo *HandlerInfo
}

RouteInfo describes one HTTP endpoint derived from a handler method.

type Server

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

Server manages WebSocket connections and handler dispatch.

func NewServer

func NewServer(registry *Registry, opts ...ServerOptions) *Server

NewServer creates a new WebSocket server with the given registry. An optional ServerOptions can be passed to configure server behavior.

Example (Options)

This example shows how to configure client reconnection behavior. The server sends this configuration to clients on connect.

package main

import (
	"fmt"

	"github.com/marrasen/aprot"
)

func main() {
	registry := aprot.NewRegistry()
	server := aprot.NewServer(registry, aprot.ServerOptions{
		ReconnectInterval:    2000,  // initial delay (ms)
		ReconnectMaxInterval: 60000, // max delay (ms)
		ReconnectMaxAttempts: 10,    // 0 = unlimited
	})

	_ = server
	fmt.Println("Server with options")
}
Output:
Server with options

func (*Server) Broadcast

func (s *Server) Broadcast(data any)

Broadcast sends a push message to all connected clients. The event name is derived from the Go type of data, which must have been registered via RegisterPushEventFor.

func (*Server) ConnectionCount

func (s *Server) ConnectionCount() int

ConnectionCount returns the number of active connections.

func (*Server) ForEachConn

func (s *Server) ForEachConn(fn func(conn *Conn))

ForEachConn iterates over all active connections under a read lock. The callback must not block or call methods that acquire the server's write lock.

func (*Server) HTTPTransport

func (s *Server) HTTPTransport() http.Handler

HTTPTransport returns an http.Handler for SSE+HTTP transport. Routes:

  • GET / — SSE event stream
  • POST /rpc — RPC calls
  • POST /cancel — Request cancellation

func (*Server) OnConnect

func (s *Server) OnConnect(hook ConnectHook)

OnConnect registers a hook to be called when a new connection is established. Hooks are called in the order they are registered. If a hook returns an error, the connection is rejected and subsequent hooks are not called.

Example

This example shows how to validate connections and reject unauthorized clients using OnConnect. The stored principal can be checked in per-handler middleware.

package main

import (
	"context"
	"fmt"
	"log"

	"github.com/marrasen/aprot"
)

// Request and response types
type CreateUserRequest struct {
	Name  string `json:"name"`
	Email string `json:"email"`
}

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

type ProcessItemsRequest struct {
	Items []string `json:"items"`
}

type ProcessItemsResponse struct {
	Processed int `json:"processed"`
}

// Push event types
type UserUpdatedEvent struct {
	ID   string `json:"id"`
	Name string `json:"name"`
}

type ProcessingCompleteEvent struct {
	Count int `json:"count"`
}

// Handlers struct
type MyHandlers struct {
	server *aprot.Server
}

// CreateUser handles user creation
func (h *MyHandlers) CreateUser(ctx context.Context, req *CreateUserRequest) (*CreateUserResponse, error) {
	progress := aprot.Progress(ctx)
	progress.Update(1, 2, "Validating...")

	if req.Name == "" {
		return nil, aprot.ErrInvalidParams("name is required")
	}

	progress.Update(2, 2, "Creating user...")

	conn := aprot.Connection(ctx)
	if conn != nil {
		conn.Push(&UserUpdatedEvent{ID: "123", Name: req.Name})
	}

	return &CreateUserResponse{ID: "123", Name: req.Name}, nil
}

// ProcessItems demonstrates progress reporting with cancellation
func (h *MyHandlers) ProcessItems(ctx context.Context, req *ProcessItemsRequest) (*ProcessItemsResponse, error) {
	progress := aprot.Progress(ctx)
	total := len(req.Items)

	for i, item := range req.Items {
		select {
		case <-ctx.Done():
			return nil, aprot.ErrCanceled()
		default:
		}

		progress.Update(i+1, total, fmt.Sprintf("Processing %s...", item))
	}

	h.server.Broadcast(&ProcessingCompleteEvent{Count: total})

	return &ProcessItemsResponse{Processed: total}, nil
}

func main() {
	registry := aprot.NewRegistry()
	registry.Register(&MyHandlers{})

	server := aprot.NewServer(registry)

	type principalKey struct{}

	server.OnConnect(func(ctx context.Context, conn *aprot.Conn) error {
		for _, cookie := range conn.Info().Cookies {
			if cookie.Name == "session" {
				// validate the session...
				conn.SetUserID("user_123")
				conn.Set(principalKey{}, "authenticated-user")
				return nil
			}
		}
		return nil // allow unauthenticated for public endpoints
	})

	server.OnDisconnect(func(ctx context.Context, conn *aprot.Conn) {
		log.Printf("Client %d disconnected", conn.ID())
	})

	fmt.Println("Lifecycle hooks configured")
}
Output:
Lifecycle hooks configured

func (*Server) OnDisconnect

func (s *Server) OnDisconnect(hook DisconnectHook)

OnDisconnect registers a hook to be called when a connection is closed. Hooks are called in the order they are registered. The connection's UserID is still available when the hook is called.

func (*Server) OnStop

func (s *Server) OnStop(hook func())

OnStop registers a hook called during Server.Stop after in-flight requests drain. Used by the tasks package to shut down the taskManager.

func (*Server) PushToUser

func (s *Server) PushToUser(userID string, data any)

PushToUser sends a push message to all connections for a specific user. The event name is derived from the Go type of data, which must have been registered via RegisterPushEventFor.

func (*Server) Registry

func (s *Server) Registry() *Registry

Registry returns the server's handler registry.

func (*Server) ServeHTTP

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

ServeHTTP implements http.Handler for WebSocket upgrades.

func (*Server) SetCheckOrigin

func (s *Server) SetCheckOrigin(f func(r *http.Request) bool)

SetCheckOrigin sets the origin check function for the WebSocket upgrader.

func (*Server) Stop

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

Stop gracefully shuts down the server. It rejects new connections, closes existing connections with a close frame, waits for in-flight requests to complete, and waits for disconnect hooks to finish. Returns nil on clean shutdown, or ctx.Err() if the context expires.

Example

This example shows graceful shutdown with a timeout.

package main

import (
	"context"
	"fmt"
	"log"
	"time"

	"github.com/marrasen/aprot"
)

func main() {
	registry := aprot.NewRegistry()
	server := aprot.NewServer(registry)

	// In production, this would be triggered by a signal handler.
	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
	defer cancel()

	if err := server.Stop(ctx); err != nil {
		log.Printf("shutdown timed out: %v", err)
	}

	fmt.Println("Server stopped")
}
Output:
Server stopped

func (*Server) TriggerRefresh added in v0.39.0

func (s *Server) TriggerRefresh(keys ...string)

TriggerRefresh fires a refresh for all subscriptions matching the given keys, across every connection. Unlike the package-level TriggerRefresh — which batches within a request handler and flushes after the handler returns — this method flushes immediately and is safe to call from background goroutines, cron jobs, webhook fan-in, or any other out-of-request code path.

Matching subscriptions are re-executed once, each in its own goroutine, the same way request-scoped triggers are dispatched. Cascading refreshes remain prevented because subscription re-execution runs without a refresh queue in its context (TriggerRefresh calls from inside a re-executed subscription handler are no-ops).

Keys are variadic strings that form a single composite key, matching the convention used by RegisterRefreshTrigger and TriggerRefresh. To fire multiple distinct keys, call this method multiple times.

func (*Server) Use

func (s *Server) Use(mw ...Middleware)

Use adds middleware to the chain. Middleware is executed in the order it is added.

Example

This example shows how to define and apply middleware. Server-level middleware applies to all handlers; per-handler middleware applies only to the handlers registered in the same Register call.

package main

import (
	"context"
	"fmt"
	"log"
	"time"

	"github.com/marrasen/aprot"
)

// PublicHandlers has endpoints that require no authentication.
type PublicHandlers struct{}

func (h *PublicHandlers) Ping(ctx context.Context) (string, error) { return "pong", nil }

// ProtectedHandlers has endpoints that require authentication.
type ProtectedHandlers struct{}

func (h *ProtectedHandlers) Secret(ctx context.Context) (string, error) { return "classified", nil }

func main() {
	loggingMiddleware := func(next aprot.Handler) aprot.Handler {
		return func(ctx context.Context, req *aprot.Request) (any, error) {
			start := time.Now()
			result, err := next(ctx, req)
			log.Printf("[%s] %s took %v", req.ID, req.Method, time.Since(start))
			return result, err
		}
	}

	authMiddleware := func(next aprot.Handler) aprot.Handler {
		return func(ctx context.Context, req *aprot.Request) (any, error) {
			conn := aprot.Connection(ctx)
			if _, ok := conn.Load(struct{}{}); !ok {
				return nil, aprot.ErrUnauthorized("not authenticated")
			}
			return next(ctx, req)
		}
	}

	registry := aprot.NewRegistry()
	registry.Register(&PublicHandlers{})                                      // no handler middleware
	registry.Register(&ProtectedHandlers{}, aprot.Middleware(authMiddleware)) // with auth

	server := aprot.NewServer(registry)
	server.Use(aprot.Middleware(loggingMiddleware)) // applies to all handlers

	fmt.Println("Middleware configured")
}
Output:
Middleware configured

func (*Server) WebSocket

func (s *Server) WebSocket() http.Handler

WebSocket returns an http.Handler for WebSocket upgrades.

type ServerOptions

type ServerOptions struct {
	// ReconnectInterval is the initial reconnect delay in milliseconds. Default: 1000
	ReconnectInterval int
	// ReconnectMaxInterval is the maximum reconnect delay in milliseconds. Default: 30000
	ReconnectMaxInterval int
	// ReconnectMaxAttempts is the maximum number of reconnect attempts. 0 = unlimited. Default: 0
	ReconnectMaxAttempts int
}

ServerOptions configures the server behavior.

type StreamCompleteHook added in v0.39.0

type StreamCompleteHook func(err error, items int)

StreamCompleteHook is invoked once per streaming request after the handler's iterator has finished executing — whether through clean completion, client cancellation, connection loss, server shutdown, a mid-stream panic, or a transport send failure.

Parameters:

  • err is nil for clean completion, or the cause of termination otherwise. For cancellation-driven termination, err is the context cause sentinel: one of ErrClientCanceled, ErrConnectionClosed, or ErrServerShutdown — use errors.Is to distinguish. For panics it is a wrapped recover value. For transport failures it is the underlying transport error.
  • items is the number of elements successfully yielded to the client. For iter.Seq2, each (key, value) pair counts as one.

Preflight errors — i.e. handlers that return (nil, err) before any iterator is produced — do NOT invoke the hook. They travel as the regular error return from the middleware chain and should be logged there, the same way unary handler errors are today.

type StreamEndMessage added in v0.38.0

type StreamEndMessage struct {
	Type    MessageType `json:"type"`
	ID      string      `json:"id"`
	Code    int         `json:"code,omitempty"`
	Message string      `json:"message,omitempty"`
	Data    any         `json:"data,omitempty"`
}

StreamEndMessage terminates a server-streamed iterator for a request. Code/Message/Data are set only on abnormal termination (handler panic, unexpected error). Clean completion (including client cancellation) produces an empty StreamEndMessage.

type StreamItemMessage added in v0.38.0

type StreamItemMessage struct {
	Type MessageType `json:"type"`
	ID   string      `json:"id"`
	Item any         `json:"item"`
}

StreamItemMessage carries one element of a server-streamed iterator.

For HandlerKindStream handlers the Item holds the element value. For HandlerKindStream2 handlers the Item is a 2-element JSON array [K, V].

type StructValidator added in v0.37.0

type StructValidator interface {
	ValidateStruct(v any) error
}

StructValidator validates struct parameters before handler dispatch. Implementations must return a *ProtocolError with CodeValidationFailed and structured FieldError data when validation fails.

type TestPushConn

type TestPushConn struct {
	Conn *Conn
	// contains filtered or unexported fields
}

TestPushConn is a test-only Conn that records all messages sent via Push. Use NewTestPushConn to create one.

func NewTestPushConn

func NewTestPushConn(id uint64, pushEvents ...any) *TestPushConn

NewTestPushConn creates a Conn backed by a recording transport and a Server whose registry has the given push events registered. This allows Conn.Push to work in tests. The returned TestPushConn provides access to captured messages.

func (*TestPushConn) Messages

func (tc *TestPushConn) Messages() [][]byte

Messages returns all raw JSON messages sent through the connection.

func (*TestPushConn) WithContext

func (tc *TestPushConn) WithContext(ctx context.Context) context.Context

WithTestPushConn returns a context carrying the conn from tc.

type ValidateRule added in v0.37.0

type ValidateRule struct {
	Tag   string // e.g., "required", "min", "email"
	Param string // e.g., "3" for min=3, "" for email
}

ValidateRule represents a single parsed validation constraint.

func ParseValidateTag added in v0.37.0

func ParseValidateTag(tag string) []ValidateRule

ParseValidateTag splits a validate tag string into structured rules. For example: "required,gte=12,lte=110" -> [{Tag:"required"}, {Tag:"gte", Param:"12"}, {Tag:"lte", Param:"110"}]

Directories

Path Synopsis
internal
gentestpkg
Package gentestpkg exists only to host test fixture types in a Go package whose name is different from "aprot", so that generate_test.go can exercise code paths where shared types and shared enums live in separate per-package shared TypeScript files and reference each other across packages.
Package gentestpkg exists only to host test fixture types in a Go package whose name is different from "aprot", so that generate_test.go can exercise code paths where shared types and shared enums live in separate per-package shared TypeScript files and reference each other across packages.
Package tasks provides hierarchical task trees with progress tracking, output streaming, and both request-scoped and shared (broadcast) task systems for the github.com/marrasen/aprot framework.
Package tasks provides hierarchical task trees with progress tracking, output streaming, and both request-scoped and shared (broadcast) task systems for the github.com/marrasen/aprot framework.

Jump to

Keyboard shortcuts

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