Shared building blocks for web services: Go packages and npm packages that several applications use the same way.
| Package | What it is |
|---|---|
problem (Go) |
RFC 9457 problem details with stable, localizable codes, and the pages their type URIs resolve to |
problem/humaproblem (Go) |
Makes huma produce problems, including for request validation |
problem/problemtest (Go) |
Checks that a web app's catalog has a message for every code |
problem/problemrules (Go) |
go-ruleguard rules that keep internal errors out of responses |
server (Go) |
A service's HTTP handler and server: health probes, security headers, CSRF protection, logging, panic recovery, graceful shutdown |
pgdb (Go) |
An application's own PostgreSQL schema: a pool scoped to it, hopper's job tables and goose migrations in it, and a fresh schema per test |
dev (Go, own module) |
go tool dev: an app's development setup in one terminal, with Postgres and S3 without Docker and a hot-reloading server, and the base of an app's own development command |
spa (Go) |
Serves a Vite app from the Go server: the embedded build in production, the Vite dev server in development |
@parallelworks/problem (npm) |
ApiError, useErrorMessage(), the shared codes' messages in five languages, and a Biome lint rule |
@parallelworks/ui (npm) |
React components on one theme contract: primitives, lists, forms, a job graph, a code editor, a log viewer, a file explorer and an AI chat, each on its own subpath |
Every error response is an RFC 9457
problem, served as application/problem+json. Its type says what kind of
problem it is, and resolves to a page documenting it. Clients show a message
for its code in the reader's language: a server with a problem.Localizer
writes detail in that language itself and says which with Content-Language,
so every client, a CLI as much as a web app, can show it. Without
Content-Language, detail is English, for developers and logs, and clients
show their own message for code instead.
There are three kinds of type:
type |
Code | Example |
|---|---|---|
about:blank |
none sent: the client derives it from the status | 404 → not_found |
/problems/validation |
validation; each entry in errors has its rule's type and code |
422 from a form |
/problems/<app>/<code> |
the application's own | /problems/shop/name_taken |
HTTP/1.1 422 Unprocessable Content
Content-Type: application/problem+json
{
"type": "/problems/validation",
"title": "Invalid request",
"status": 422,
"detail": "validation failed",
"code": "validation",
"errors": [
{"type": "/problems/too_long", "code": "too_long", "pointer": "#/name",
"params": {"max": 64}, "detail": "expected length <= 64"},
{"type": "/problems/required", "code": "required", "parameter": "team", "in": "query",
"detail": "required query parameter is missing"}
]
}Type URIs are relative, with the full path, so each deployment documents the types it actually sends. Namespacing them by application means a host that mounts several applications serves every page without collisions, wherever each one is mounted.
Codes are part of the API contract: never rename a code, a rule or a param.
Define an application's types once, in package variables:
var (
problems = problem.NewRegistry("shop")
NameTaken = problems.Define(problem.Type{
Code: "name_taken",
Status: http.StatusConflict,
Title: "Name taken",
Doc: "Another item in the workspace already has this name. Choose a different one.",
Params: []string{"name"},
})
)Return them from handlers, and use problem.Status for a failure the status
fully describes:
return nil, NameTaken.New("a product named Lamp exists").With("name", "Lamp")
return nil, problem.Status(http.StatusNotFound, "no order 1042")
return nil, problem.ValidationFailed(problem.TooLong.At(problem.Pointer("name"), "too long").With("max", 64))A problem missing a param its type declares is sent as the about:blank problem
for its status (a field error, as invalid), so clients never show a message
with an unfilled placeholder. Resolve returns a problem as it will be sent.
With huma, install the error builder before registering operations and serve the type pages:
humaproblem.Install(humaproblem.Options{
// Domain errors that are not problems, mapped in one place.
Map: func(err error) *problem.Problem {
if errors.Is(err, store.ErrNotFound) {
return problem.Status(http.StatusNotFound, "not found")
}
return nil
},
Debug: cfg.Development, // send a 5xx's cause as its detail
})
cfg.Transformers = append(cfg.Transformers, humaproblem.Report(func(ctx context.Context, p *problem.Problem) {
if p.Status >= 500 {
logger.FromContext(ctx).Error("request failed", "error", p.Unwrap())
}
}))
// ... huma.Register(...)
mux.Handle("/problems/", problem.Handler(problems))The OpenAPI document types code as an open string, not an enum: new codes
are not a breaking change, and clients fall back to the status for codes they
don't know. The catalog at /problems/ lists them.
Each page is also data: asked with Accept: application/json (and not
text/html), /problems/<...>/<code> returns its title, message, why,
fix and links in the reader's language, and /problems/ the list of types,
so a CLI can print how to fix a problem without a browser.
huma's request validation then becomes a validation problem whose entries name
the rule each field failed (required, too_long, below_minimum, ...).
problem.Shared holds the messages for the shared codes in English, Spanish,
Japanese, Korean and Chinese; problem.NewCatalog reads an app's own from one
JSON file per language (en.json, ja.json, ...), ICU MessageFormat strings
keyed by code. A problem.Localizer picks the reader's language with Locale
(such as spa.Locales.NegotiateHeader, the cookie then Accept-Language) and
rewrites each problem's detail, and each field error's, as its code's
message:
l := &problem.Localizer{Locale: locales.NegotiateHeader, Catalogs: []*problem.Catalog{catalog}}
cfg.Transformers = append(cfg.Transformers, humaproblem.Report(logProblem), humaproblem.Localize(l))
l.Write(w, r, p) // in a raw handlerAn about:blank problem keeps the detail the server wrote for English readers,
so specific text still reaches them; other languages get the status's message.
problemtest.CheckGoCatalog checks that a catalog covers a registry.
A problem's detail and params are sent, so they hold only what the client
may see. Everything else stays on the server:
- Attach the underlying error with
WithCause(err). It is never sent;Reporthands it to you with the request's context, to log. - An error a handler returns that is not a problem, and that
Mapdoes not recognize, becomes a 500about:blankwith no detail. AsDenial()marks a problem as a refusal to authorize, andproblem.Denied(err)finds it, so a caller never mistakes an unreachable database for "you may not". The marker is not sent either.problemrulesfails lint on an error, or itsError()text, passed toNew,Newf,At,AtParameter,StatusorWith. Import it into your go-ruleguard rules file:
//go:build ruleguard
package gorules
import (
"github.com/parallelworks/foundation/problem/problemrules"
"github.com/quasilyte/go-ruleguard/dsl"
)
func init() { dsl.ImportRules("", problemrules.Bundle) }Test that the web app has a message for every code:
func TestEveryCodeHasAMessage(t *testing.T) {
problemtest.CheckCatalog(t, "../web/src/i18n/locales/en.json", problems)
// or, with apiErrors in its own file, load the map and call
// problemtest.CheckMessages(t, messages, problems)
}import { toApiError, problemMiddleware } from '@parallelworks/problem'
import { useErrorMessage } from '@parallelworks/problem/react'
api.use(problemMiddleware) // openapi-fetch: ask for problem details
const errorMessage = useErrorMessage()
{save.isError && <p role="alert">{errorMessage(save.error)}</p>}useErrorMessage() shows the message for the error's code: the app's own at
apiErrors.<code> in its use-intl catalog, otherwise the shared message
shipped with this package (English, Spanish, Japanese, Korean and Chinese). A
validation problem with one invalid field shows that field's rule; network
and unknown cover failures that never reached the server. toApiError()
turns anything thrown into an ApiError with status, code, params and
fields, whose path (items[0].name) matches form field names.
lint/error-text.grit in @parallelworks/problem is a Biome plugin. It fails on an
error's .message rendered in JSX or passed to toast, and on text passed to
setters such as setError (a lowercase identifier like 'loadFailed' is a
code and passes):
{ "plugins": ["./node_modules/@parallelworks/problem/lint/error-text.grit"] }server.New assembles a service's handler around its own routes, and
server.Serve runs it until the context ends:
handler := server.New(server.Options{
Logger: logger,
Routes: func(mux *http.ServeMux) { api.Register(mux, deps) },
Problems: []*problem.Registry{problems},
Ready: map[string]server.Pinger{"database": pool},
Web: web.FS(),
DevServer: cfg.ViteURL, // development only: proxy the app from Vite
HSTS: cfg.Production,
Wrap: sessions.Middleware, // the application's own authentication
})
return server.Serve(ctx, server.Listen{Addr: ":8080", ShutdownTimeout: 20 * time.Second}, handler, logger)Serve binds before it logs, so a taken port is an error rather than a
"listening" line, and it logs the address it got with a url to reach it
there (http://localhost:8080, or https with TLS), which dev shows
as a link.
Besides the application's routes it serves /healthz, /readyz (the Ready
pingers), /problems/, a 404 problem for unknown paths under /api/, and the
single-page app. Every response gets security headers with a strict CSP, and a
state-changing request a browser sent from another site is refused. Requests
are logged; a panic is logged and answered with a 500, a problem under /api/.
In development the CSP accepts spa.DevNonce, which Vite puts on the scripts
and styles it injects when vite.config.ts sets it:
export default defineConfig(({ command }) => ({
...(command === 'serve' && { html: { cspNonce: 'vite-dev' } }),
build: { assetsDir: '_build' },
}))spa.Handler serves the app from the Go server, so it has one origin in
development and production: the same cookies, CSP and routes, no CORS, and no
proxy list in vite.config.ts.
//go:embed all:dist
var dist embed.FS
build, _ := fs.Sub(dist, "dist")
app, err := spa.Handler(build, spa.Options{DevServer: "http://localhost:5173"})
if err != nil {
return err
}
mux.Handle("/", app) // after the API routesWith a build embedded, it serves each file (preferring a .br or .gz
sibling the client accepts), then a
prerendered <path>/index.html, then index.html for client-side routes.
With DevServer set, it proxies everything to the Vite dev server instead,
including the HMR WebSocket, even when dist holds a build from an earlier
pnpm build. Set it only in development, and open the Go server's address
there, not Vite's. Without DevServer, dist must hold a build.
Put Vite's content-hashed output in /_build/, which spa.Handler caches as
immutable; files copied from public/ keep their names and stay revalidatable,
so a changed logo or font is never stuck in a browser cache:
// vite.config.ts
build: { assetsDir: '_build' }Options.Index rewrites index.html for each request (in development too),
for example to add <base href>; Options.NotFound sends the shell with a 404
for paths the app does not know.
Options.Locales sets <html lang> to the reader's language: the cookie's
choice, then Accept-Language, negotiated against the app's locales exactly as
negotiateLocale in @parallelworks/i18n does, so es-MX gets es. Pass it
to the client so the first paint is already in that language:
spa.Options{Locales: spa.Locales{Available: []string{"en", "es", "ja"}, Cookie: "locale"}}const locale = detectLocale(locales, {
fallback: defaultLocale,
injected: document.documentElement.lang,
cookie: 'locale',
})server.Options.Locales does the same for an app served by server.New.
Locales.Negotiate(r) and spa.NegotiateLocale are there for a server that
renders its own shell.
make check runs every linter and test. See CONTRIBUTING.md.