Portable Web Applications

trb/web provides compile-time file-based routing and the same request, response, middleware, and server behavior in Go, Ruby, and TypeScript modes. Create a Go project with:

trb init --mode go --module example.com/api --template web api
cd api
trb run

The generated server listens on http://localhost:3000. Open that URL to see {"message":"Hello, TypeRB!"}, then press Ctrl-C to stop the server. The same template supports Ruby and TypeScript when their corresponding mode and toolchain are selected instead; one project needs only its selected target toolchain.

Route files live below src/routes. File names determine paths, and exported functions determine HTTP methods. For example, src/routes/todos/[id].trb can contain:

import { Context, Response, json } from trb/web
import trb/std/result

record UpdateTodo
	title: String
end

record Todo
	id: String
	title: String
end

def post(context: Context): Response
	id := context.path_value("id")
	case context.request.json<UpdateTodo>()
	when Result::Ok(input)
		return json(Todo.new(id: id, title: input.title), 201)
	when Result::Err(_error)
		return json({"error" => "invalid_request"}, 400)
	end
end

Static route segments take precedence over parameter segments. For example, src/routes/todos/new.trb and src/routes/todos/[id].trb can coexist: /todos/new selects the static route, while /todos/42 binds id in the parameter route. Routing selects the most specific path before its HTTP handler, so an unsupported method on /todos/new returns 405 instead of falling through to [id].trb. Patterns that cannot be ordered consistently, such as sibling [id].trb and [slug].trb files, remain build errors.

Request, Response, and Context are immutable classes. Request methods handle query parameters, headers, cookies, text, bytes, and typed JSON. Response methods change status, headers, Vary, and cookies by returning a new response:

response := json({"ok" => true})
	.with_status(202)
	.with_header("cache-control", "no-store")
	.vary("accept")

Path and query values can be bound to records without giving the target runtime permission to reflect over application types:

import { Context, Response, text } from trb/web
import trb/std/result

record TodoParams
	id: Integer
end

record TodoQuery
	page: Integer?
	tag: Array<String>
end

def get(context: Context): Response
	case context.params<TodoParams>()
	when Result::Err(_error)
		return text("invalid path", 400)
	when Result::Ok(params)
		case context.request.query<TodoQuery>()
		when Result::Err(_error)
			return text("invalid query", 400)
		when Result::Ok(query)
			return text(params.id.to_s() + ":" + query.tag.size().to_s())
		end
	end
end

Record field names are wire names. A path record must contain exactly the parameters declared by its route file, which is checked during the build. Query scalars accept one value, nullable fields use nil when missing, and arrays preserve repeated keys and use an empty array when missing. Unknown query keys are ignored. Boolean, numeric, raw-value enum, and date/time fields use their portable parsers. Malformed encoding, missing or duplicate scalar values, and invalid conversions return ParameterError; applications retain control over the corresponding error response.

An endpoint can combine those explicit bindings into one optional endpoint input record. The record's supported fields are params, query, and body; each field may be omitted when the endpoint does not use that input source. params and query name binding records, while body names the typed JSON value:

import { Context, EndpointInputError, Response, text } from trb/web

record TodoInput
	params: TodoParams
	query: TodoQuery
	body: UpdateTodo
end

def invalid_input(error: EndpointInputError): Response
	case error
	when EndpointInputError::Params(_error)
		return text("invalid path", 400)
	when EndpointInputError::Query(_error)
		return text("invalid query", 400)
	when EndpointInputError::Body(_error)
		return text("invalid body", 400)
	end
end

def post(context: Context): Response
	input := context.bind<TodoInput>() catch |error|
		return invalid_input(error)
	end
	return text(input.params.id.to_s() + ":" + input.body.title)
end

bind<T>() checks path parameters, query parameters, then the JSON body and returns the first failure as EndpointInputError::Params, Query, or Body. Each variant preserves the original ParameterError or RequestError, so the application still chooses its validation response and logging policy through an ordinary mapper function; trb/web does not install a global error mapper or choose an application response body. The compiler validates an endpoint input record's params field against the file-based route in the same way as a direct params<T>() call. Combined input records are optional; handlers may continue to use the individual request methods. bind<T>() is most useful when one handler consumes more than one input source, or when the application wants a named endpoint input. A handler that reads only path, query, or body input does not need a wrapper record merely for uniformity.

File routes may also publish an optional typed endpoint contract for tooling. The route file remains the source of the HTTP method and path. A contract class in that same module connects the route handler to its input and status-specific response types:

import { Context, Endpoint, Response, handles, input, json, response } from trb/web

record CreateTodoBody
	title: String
end

record CreateTodoInput
	body: CreateTodoBody
end

record CreateTodoResponse
	id: Integer
	title: String
end

record ErrorResponse
	message: String
end

def post(context: Context): Response
	request := context.bind<CreateTodoInput>() catch |_error|
		return json(ErrorResponse.new(message: "invalid request"), 400)
	end
	return json(CreateTodoResponse.new(id: 42, title: request.body.title), 202)
end

class CreateTodoEndpoint < Endpoint
	handles(post)
	input<CreateTodoInput>()
	response<CreateTodoResponse>(status: 202)
	response<ErrorResponse>(status: 400)
end

handles accepts the actual top-level function rather than its name as a string, so the ordinary (Context) -> Response signature is checked. A contract directly inherits Endpoint, declares exactly one local file-route handler, may declare one input<T>(), and declares one or more unique literal HTTP statuses from 100 through 599. These calls are compile-time declarations: Go, Ruby, and TypeScript output does not execute them. The resulting versioned endpoint catalog retains portable type identities for downstream tooling.

The contract does not call bind<T>(), validate request data, infer schemas from the handler body, or prove that every runtime response has the documented status and payload. Those remain explicit application behavior. Routes and handlers without a contract continue to compile; contracts are useful when an application wants generated descriptions or clients without making that tooling mandatory for every route.

Generate an OpenAPI 3.1 JSON document from the contracts in the configured project with:

# Write deterministic JSON to standard output.
trb web openapi

# Write below the project root and override document metadata.
trb web openapi \
  --output api/openapi.json \
  --title "Todo API" \
  --api-version 2026-08

The project name and version are the default OpenAPI title and API version. Generation compiles and checks the project but does not start or invoke the selected Go, Ruby, or TypeScript toolchain. The same endpoint contracts therefore produce the same document in every mode.

An endpoint input remains the Context#bind<T>() envelope rather than a wire object. Its params fields become required OpenAPI path parameters, query fields become query parameters, and body becomes a required application/json request body. Path fields must match the dynamic file-route segments exactly. A non-nullable query scalar is required; a nullable scalar is optional; and an Array is a repeated optional query parameter because a missing value binds to an empty Array. JSON @json names apply to body and response records, not URL parameters.

The initial schema generator supports Boolean, portable Integer, Float, String, Array, Hash<String, V>, records, String- or Integer-backed raw enums, transparent aliases, nominal newtypes, nullable values, and the portable time types. It rejects unsupported JSON shapes, generic schemas, recursive records, and catch-all routes with source-located diagnostics when this command is invoked. Those OpenAPI-only restrictions do not make trb check fail. Unit declares a response without content and is required for 1xx, 204, 205, and 304 statuses.

The generator deliberately does not inspect handler bodies, infer undocumented responses, run validation, or invent summaries, tags, authentication, or application error schemas. Only routes with an explicit endpoint contract are published.

Generate a checked browser client from the same endpoint catalog with:

# Write formatted TypeRB source to standard output.
trb web client

# Write below the project root and choose the exported class name.
trb web client \
  --output generated/todo_api_client.trb \
  --name TodoApiClient

The generated source targets trb/platform/typescript/browser and wraps its existing HttpClient; it does not introduce another transport abstraction. It serializes declared path, query, and JSON body input, decodes every declared status into an endpoint-specific enum variant, and returns Result<EndpointResult, RequestError>. Undeclared statuses are explicit RequestErrorKind::Contract failures that retain the original response. Callers can still supply headers and an optional timeout to every generated method.

Client-visible user-defined input and response types must be imported into the route from a shared module. The generated source imports those declarations rather than copying records, enums, aliases, or newtypes. A route-local contract type continues to work for endpoint checking and OpenAPI generation but is rejected by trb web client, because a separate browser project cannot import that type without also importing the route handler. The command produces identical source when the server project is configured for Go, Ruby, or TypeScript; the resulting client itself is intentionally browser-target-specific.

Middleware can attach request-scoped values without a string-keyed cast at the handler boundary. Create one ContextKey<T> and share that key between the producer and consumer:

import { Context, ContextKey, Next, Response, text } from trb/web
import trb/std/result

record CurrentUser
	id: Integer
	name: String
end

CURRENT_USER := ContextKey<CurrentUser>.new("current_user")

def authenticate(context: Context, next_handler: Next): Response
	user := CurrentUser.new(id: 42, name: "Ada")
	return next_handler.call(context.with(CURRENT_USER, user))
end

def get(context: Context): Response
	case context.fetch(CURRENT_USER)
	when Result::Ok(user)
		return text(user.name)
	when Result::Err(_error)
		return text("unauthorized", 401)
	end
end

The key supplies the value type to both operations, so with rejects a value of another type and fetch infers Result<CurrentUser, ContextValueError>. Keys use instance identity: two keys with the same diagnostic name remain independent. Context stays immutable; with returns a new context, replacing the value only for that key, and with_request retains attached values.

Use src/routes/_middleware.trb for root middleware and nested _middleware.trb files for route-scoped middleware. trb/web/testing exposes the same dispatcher without opening a socket, so route and middleware tests can construct a Request from the shared trb/http values. A request test that imports dispatch belongs in a *_test.trb file at the configured source root, which owns the complete file-route manifest. Tests inside a subpackage should call that package's ordinary functions instead of importing the project dispatcher.

Response compression is opt-in middleware:

import { Context, Next, Response } from trb/web
import trb/web/middleware/compression

def call(context: Context, next_handler: Next): Response
	return Compression.call(context, next_handler)
end

The default compresses eligible responses of at least 1 KiB with gzip when the request's Accept-Encoding allows it. CompressionOptions can change the minimum size. The shared implementation respects quality values, Cache-Control: no-transform, partial and bodyless responses, existing content encodings, and compressible media types. It also maintains Vary and removes representation metadata invalidated by compression.

Request deadlines are also opt-in middleware:

import { Context, Next, Response } from trb/web
import trb/web/middleware/timeout
import { TimeoutOptions } from trb/web/middleware/timeout

OPTIONS := TimeoutOptions.new(milliseconds: 5000)

def call(context: Context, next_handler: Next): Response
	return Timeout.call(context, next_handler, OPTIONS)
end

The default deadline is 30 seconds. An expired request returns a portable JSON 504 response with {"error":"gateway_timeout"}. TypeRB keeps cancellation out of application signatures: the compiler forwards a hidden execution scope to downstream handlers, ORM operations, and browser HTTP requests. Generated loop checkpoints stop TypeRB CPU work cooperatively. Native operations receive the target runtime's cancellation signal when their API supports one and otherwise observe cancellation at the next generated boundary.

text, bytes, json, empty, and redirect remain the standard response builders. Server host, port, body limit, and shutdown timeout are configured with configure_server.