router

package module
v0.1.18 Latest Latest
Warning

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

Go to latest
Published: Jul 19, 2026 License: MIT Imports: 1 Imported by: 0

README

tinywasm/router

Isomorphic routing contract — Context, Router, HandlerFunc identical on native and edge/wasm targets. Defines request handling, streaming (SSE), WebSocket upgrade, and middleware. Modules mount their API via APIModule. The router defines the shape; concrete servers implement it.

Quick Start

import "github.com/tinywasm/router"

type MyModule struct{ name string }

func (m MyModule) ModelName() string { return m.name }
func (m MyModule) MountAPI(r router.Router) {
    r.Get("/api/data", func(ctx router.Context) {
        ctx.WriteStatus(200)
        ctx.Write([]byte("hello"))
    })
}

Caller — the call-side contract

func (v *MyView) Refresh() {
    v.Caller.Call("list_services", nil, func(res []byte, err error) {
        if err != nil {
            v.HandleError(err)
            return
        }
        v.Update(res)
    })
}

Modules and views depend on Caller to invoke server operations without knowing the wire protocol or transport. Adapters live with each transport (e.g. mcp.NewCaller in tinywasm/mcp adapts a JSON-RPC client), while tests use a mock.Caller.

Contracts

  • Context: minimal I/O (read method/path/body, write headers/status) + cookies (SetCookie/Cookie) + identity (SetUserID/UserID) + typed codec (Decode/Encode)
  • Cookie: isomorphic HTTP cookie type with SameSite policy (SameSiteDefault/Lax/Strict/None)
  • HandlerFunc: func(Context) — the unit of dispatch
  • Route: registration token; supports Requires(resource, action) for RBAC, Public() for explicit public access, and Accepts(model.Fielder) to declare the request-body schema
  • RouteInfo: read-only view of a registered route with method, path, resource, action, public flag, and Args (the schema declared via Accepts)
  • Router: register HTTP routes (Get/Post/Put/Delete/Handle) returning Route + streaming (Stream/Socket) + middleware (Use) + Routes() for introspection
  • Streamer: Context + Flush() for SSE/streaming responses
  • Socket: bidirectional connection (WebSocket)
  • Middleware: func(HandlerFunc) HandlerFunc — transversal logic (auth, logging)
  • APIModule: transport module + MountAPI(Router) — registers HTTP routes (mcp endpoint, SSE, assets)
  • OpRegistry: transport-neutral surface — register operations by name (Op), no HTTP verb/path
  • OpModule: reusable domain module + MountOps(OpRegistry) — depends only on neutral contracts
  • Caller: call-side contract — how a client-side view invokes a named server operation
  • mock: subpackage with canonical test doubles (Router, Context, Route, Caller) — no net/http, WASM-safe

Op — transport-neutral operations, with a typed codec at the edge

OpRegistry.Op is the mount-side counterpart of Caller.Call(name, args, into, done): a domain module registers an operation by logical name, never a path or an HTTP verb. It is a separate interface from Router on purpose — a transport that only harvests operations (mcp turns each Op into a tool) must not be forced to impersonate an HTTP router. A reusable module implements OpModule and depends only on these neutral contracts:

func (m *Module) MountOps(r router.OpRegistry) {
    r.Op("upsert_catalog_item", m.upsert).
        Requires("catalog_item", model.Create).
        Accepts(&CatalogItem{})
}

func (m *Module) upsert(ctx router.Context) {
    var in CatalogItem
    if err := ctx.Decode(&in); err != nil {
        ctx.WriteStatus(400)
        return
    }
    // … domain logic …
    ctx.Encode(&out)
}
  • Op + Accepts let a transport (e.g. mcp) harvest the operation's name, RBAC and schema without the module ever importing that transport. OpModule makes "transport-agnostic" a compile-time fact, not a convention.
  • Context.Decode/Encode let the handler work in typed model.Decodable/Encodable values — it never imports a codec package (json, jsvalue) directly; the transport supplies the codec.
  • router/conformance covers all four additions (op_route_*, context_decodes_and_encodes_typed_payload); an HTTP router that also satisfies OpRegistry proves them the same way it proves the rest.

Design

No net/http in the public API. Handlers never import Go's standard library HTTP types. All routing is self-describing via signatures — no runtime type assertions, no hidden machinery.

Documentation

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

This section is empty.

Types

type APIModule added in v0.1.0

type APIModule interface {
	model.ModuleNaming // provides ModelName() — identity
	MountAPI(r Router)
}

APIModule is a module that exposes a server API. It is consumed by the server entry point (!wasm): which passes it the host's Router, and the module registers its own routes/handlers. Since Router is isomorphic, the module never imports net/http to describe its API. The concrete transport (binary upload, another protocol mounted as a route) is the module's internal decision.

type Caller added in v0.1.4

type Caller interface {
	// Call invokes op and DECODES the response into `into` using the transport's
	// codec — the call-side mirror of Context.Decode/Encode on the mount side. A
	// caller (a view, a module) works in typed model values and NEVER imports a
	// codec package (json, jsvalue); which codec runs is the transport's decision.
	//
	// into may be nil when the caller does not care about the response body (a
	// save/delete that only needs the error). done reports the outcome; result/err
	// arrive asynchronously (works for wasm fetch and for in-process test doubles
	// alike). Implementations MUST propagate every error — never swallow.
	Call(op string, args model.Encodable, into model.Decodable, done func(err error))

	// Dispatch is fire-and-forget (no response expected).
	Dispatch(op string, args model.Encodable)
}

Caller is how a client-side view invokes a named server operation without knowing the wire protocol or transport. It mirrors APIModule: APIModule is the mount-side contract, Caller is the call-side contract.

op is the logical operation name (e.g. "list_services") — NEVER a wire-level method. Translating op to the concrete envelope is the adapter's job (e.g. mcp.NewCaller adapts *mcp.Client). Test doubles satisfy Caller with canned results and no transport.

type Context added in v0.1.0

type Context interface {
	Method() string
	Path() string
	Body() []byte
	GetHeader(key string) string
	SetHeader(key, value string)
	WriteStatus(code int)
	Write(b []byte) (int, error)
	// Request-scoped values (middleware passes data to the next handler).
	SetValue(key string, v any)
	Value(key string) any
	// Isomorphic cookies.
	SetCookie(c Cookie)                // writes a cookie to the response
	Cookie(name string) (Cookie, bool) // reads a cookie from the request; ok=false if not found
	// Request-scoped identity. An auth middleware records the caller;
	// handlers and mounted modules read it.
	SetUserID(id string) // records the authenticated identity (id "" = anonymous)
	UserID() string      // reads the identity; "" if no valid session

	// Decode reads the request body through the transport's codec, into a typed
	// destination — the handler never imports a codec package directly. Decode
	// backed by JSON, jsvalue, or any other model.FieldReader implementation is
	// the transport's decision, not the handler's.
	Decode(into model.Decodable) error
	// Encode writes v through the transport's codec as the response body.
	// Same contract as Decode, the other direction.
	Encode(v model.Encodable) error
}

Context is the minimal abstraction seen by a handler: request → response. Same interface signature for both native (!wasm) and edge/wasm targets.

Ownership: a Context belongs to ONE goroutine (the handler's); implementations are not required to be safe for concurrent use (same contract as http.ResponseWriter). To feed it from other goroutines, send the data over a channel to the owning goroutine — never share the Context itself.

type Cookie struct {
	Name     string   // e.g. "session_id", "user_pref"
	Value    string   // e.g. "abc123xyz789"
	Path     string   // e.g. "/", "/api"; omit for "/"
	Domain   string   // e.g. "example.com"; omit for current domain
	MaxAge   int      // >0 seconds; 0 = session; <0 = delete now
	Secure   bool     // true = HTTPS only
	HttpOnly bool     // true = no JavaScript access
	SameSite SameSite // SameSiteLax, SameSiteStrict, SameSiteNone
}

Cookie is the isomorphic representation of an HTTP cookie. It does not reference net/http: each concrete implementer maps it to its transport (net/http.Cookie on native; Set-Cookie header on edge/wasm).

type HandlerFunc added in v0.1.0

type HandlerFunc func(Context)

HandlerFunc is the dispatch unit: receives a Context and responds to it.

type Middleware added in v0.1.0

type Middleware func(HandlerFunc) HandlerFunc

Middleware wraps a handler to add cross-cutting logic (auth, logging). Operate ONLY on Context — never on concrete transport types.

type OpModule added in v0.1.14

type OpModule interface {
	model.ModuleNaming // provides ModelName() — identity
	MountOps(reg OpRegistry)
}

OpModule is a reusable domain module: it exposes named operations and NOTHING transport-specific. Where APIModule registers HTTP routes and is therefore bound to an HTTP host, an OpModule depends only on this package's neutral contracts, so the SAME module serves any transport the composition root chooses to bind (mcp tools today). "Is this module transport-agnostic?" is a compile-time fact — whether it satisfies OpModule — not a convention to remember.

type OpRegistry added in v0.1.14

type OpRegistry interface {
	// Op registers an operation by LOGICAL NAME. Route.Accepts declares its arg schema
	// (what a transport advertising a catalogue, like mcp's tools/list, reads); the
	// same Route.Requires/Public/Authenticated gate applies as to any HTTP route.
	Op(name string, h HandlerFunc) Route
}

OpRegistry is the transport-neutral surface a reusable domain module registers its operations on. It carries ONLY named operations — no HTTP verb, no path — so one module description projects onto whatever transport the host binds: mcp harvests each Op as a tool; a future REST/gRPC/stdio binding would map the name its own way.

It is the mount-side MIRROR of Caller (the call-side, also transport-neutral): Caller.Call(name, args, into, done) invokes exactly what Op(name, h) registered. Both live here, next to Context and Route, because that is what an Op handler needs.

It is deliberately NOT a method on Router. Router is the HTTP-shaped surface (Get/Post/path/cookies/status); a transport that only harvests operations (mcp) must never be forced to impersonate an HTTP router — panicking on Get/Post it can neither honour nor need — just to be handed the operation list. A concrete HTTP router MAY also satisfy OpRegistry, but the domain module sees only this.

type Route added in v0.1.1

type Route interface {
	// Requires binds an RBAC permission to the route: the (resource, action) pair.
	//
	// Both are typed (model.Resource, model.Action). They used to be two bare strings in a
	// row, so swapping them compiled — and the failure was not an error but a SILENT denial
	// at runtime, in the one place where silence is unacceptable. Now it does not compile.
	//
	// The resource is open vocabulary: the app declares its own ("service_catalog"). The
	// action is a closed CRUD set (model.Read, model.Update, …): persistence has four verbs
	// and no tool in this ecosystem ever needed a fifth.
	Requires(resource model.Resource, action model.Action) Route

	// Authenticated marks the route as reachable by any identity, with no permission check.
	// For operations on the CALLER themselves, where authentication already is the check.
	Authenticated() Route

	// Public marks the route as reachable with no identity at all.
	Public() Route

	// Accepts declares the typed schema of the request body — the Args a caller
	// must send. It is the counterpart of RouteInfo.Args: a transport that needs
	// to advertise a schema (mcp's tools/list) reads it from here instead of the
	// module hand-rolling wire metadata. nil means "no args" (a Route that never
	// calls Accepts has Args == nil, the same as passing nil explicitly).
	Accepts(args model.Fielder) Route
}

Route describes a registered route and allows annotating it. It is returned by each Router registration method. Annotations are declarative: the contract does not enforce them — each concrete implementer (native server, edge runtime) enforces them.

type RouteInfo added in v0.1.1

type RouteInfo struct {
	Method   string         // e.g. "GET", "POST"
	Path     string         // e.g. "/api/users", "/api/orders/:id"
	Resource model.Resource // required by AccessGuarded; must be empty otherwise
	Action   model.Action   // e.g. model.Read; 0 = none
	// Access is what the route declared. The ZERO VALUE is model.AccessGuarded: a route that
	// annotates nothing is unreachable until it declares a Resource, and an enforcer must
	// reject it loudly at startup.
	//
	// It replaced a `Public bool` alongside an empty-or-not Resource. That encoding made an
	// illegal state writable — a route could be Public AND carry a Requires, and the gate
	// silently dropped the permission check: a route that looked protected and was not.
	Access model.Access
	// Dir is the directory served by PublicDir; "" for every other route.
	// It exists so a whole served directory is visible to introspection instead of
	// being smuggled past the router by a file-server fallback.
	Dir string
	// Args is the schema a caller must send, set via Route.Accepts; nil = no args.
	// It is a Go-side value read directly by a transport that needs it (mcp builds
	// its tools/list schema from it) — deliberately NOT part of EncodeFields: a
	// Fielder's schema is a different shape of data than this route-metadata wire
	// view, and serializing it is that transport's concern, not RouteInfo's.
	Args model.Fielder
}

RouteInfo is the read-only view of a registered route — for introspection.

func (RouteInfo) EncodeFields added in v0.1.11

func (r RouteInfo) EncodeFields(w model.FieldWriter)

EncodeFields makes RouteInfo a model.Encodable, so it is serialized by tinywasm/json through this DECLARED shape instead of by reflection over its Go fields.

Reflection got it actively wrong, and wrong in the worst direction. `Access` and `Action` are numeric types, so a reflection-based encoder emitted them as bare numbers: the ZERO value of Access is AccessGuarded, so the MOST protected route in the server reported itself as `"Access":0` — which any human or agent reading the routes endpoint takes for "nothing declared", the exact opposite of the truth. `"Action":6` was equally unreadable. An endpoint whose whole job is to expose the security posture of a server must not invert it.

Here the shape is stated, not guessed: the enums travel as the words they already know how to render, and `Dir` — an internal detail of PublicDir — stays out of the wire.

func (RouteInfo) IsNil added in v0.1.11

func (r RouteInfo) IsNil() bool

IsNil satisfies model.Encodable; a RouteInfo is a value and never nil.

func (RouteInfo) IsPublic added in v0.1.9

func (r RouteInfo) IsPublic() bool

IsPublic reports whether the route is reachable with no identity.

type Router

type Router interface {
	Get(path string, h HandlerFunc) Route
	Post(path string, h HandlerFunc) Route
	Put(path string, h HandlerFunc) Route
	Delete(path string, h HandlerFunc) Route
	Options(path string, h HandlerFunc) Route
	Handle(method, path string, h HandlerFunc) Route
	Stream(path string, h StreamFunc) Route
	Socket(path string, h SocketFunc) Route

	// PublicAsset registers ONE route serving ONE file to the browser: generated
	// content such as index.html, the stylesheet, the JS bundle or the wasm binary.
	//
	// It is public by construction — a browser fetching an asset has no identity
	// yet. It returns no Route: there is no permission to attach, so an asset can
	// neither be left private by accident (a silent 403 on a blank page) nor be
	// wrongly gated. Serving a file that DOES need permissions is a normal route:
	// Get(path, h).Requires(resource, action) — which fails closed if forgotten.
	PublicAsset(path string, h HandlerFunc)

	// PublicDir serves a whole directory under a prefix (e.g. "web/public").
	// Same contract as PublicAsset: public by construction, no Route to gate.
	PublicDir(prefix string, dir string)

	Use(m ...Middleware)
	// Routes enumerates the registered routes and their metadata.
	Routes() []RouteInfo
}

Router is what a module registers its routes on. A concrete implementer (native server, edge runtime) satisfies this interface; modules and hosts only consume it.

type SameSite added in v0.1.1

type SameSite int

SameSite types the SameSite policy — illegal state not representable (not a string).

const (
	SameSiteDefault SameSite = iota // browser default behavior
	SameSiteLax                     // cross-site requests send cookie (default modern behavior)
	SameSiteStrict                  // never send cookie cross-site
	SameSiteNone                    // send cookie in all contexts (requires Secure=true)
)

type Socket added in v0.1.0

type Socket interface {
	Read() ([]byte, error)
	Write(b []byte) error
	Close() error
}

Socket is the bidirectional upgraded connection (WebSocket). Isomorphic abstraction: does not touch concrete upgrade mechanisms.

type SocketFunc added in v0.1.0

type SocketFunc func(Socket)

SocketFunc is a handler that receives a typed Socket.

type StreamFunc added in v0.1.0

type StreamFunc func(Streamer)

StreamFunc is a handler that receives a typed Streamer.

type Streamer added in v0.1.0

type Streamer interface {
	Context
	Flush() // sends to the client what has been written so far, without closing the response
}

Streamer is a Context that also flushes writes immediately. Used for incremental responses (SSE, streaming).

Ownership: same single-goroutine contract as Context. A push loop (SSE hub, broker) must deliver messages to the handler's goroutine via a channel; only that goroutine calls Write/Flush.

Directories

Path Synopsis
Package conformance is the executable contract of router.Router.
Package conformance is the executable contract of router.Router.

Jump to

Keyboard shortcuts

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