Why a companion module¶
transport-openapi could have been a package inside
go/transport — it mounts on a transport
server's mux and depends on transport's security-header middleware. It is a
separate module for one concrete reason: the size of what it embeds.
The 2.4 MB embed¶
The interactive docs UI is Stoplight Elements, vendored as two files:
| Asset | Size |
|---|---|
web-components.min.js |
~2.08 MB |
styles.min.css |
~297 KB |
Both are //go:embed-ed into the binary. That is the price of shipping a
self-contained docs site — a project needs only its generated spec, never a
per-project copy of the UI front-end. It is a price worth paying when you serve
docs.
If this lived in go/transport core, that ~2.4 MB would be linked into every
transport-server binary — including the many services that expose no public API
docs at all. A background worker with a health endpoint would carry a 2.4 MB
Stoplight blob for no reason.
Opt-in by module boundary¶
Making it a separate import moves the cost to exactly the tools that want it:
- Import
go/transport→ you get the server stack, no docs UI, no embed. - Also import
go/transport-openapi→ you opt into the docs handler and its embed.
Go's linker only includes packages that are imported, so the boundary is the
switch. This mirrors the transport-metrics companion and the general
transport-<companion> pattern in the toolkit: the transport core stays lean,
and heavier or more specialised concerns live in companions that a tool adds only
when it needs them.
What it still shares¶
Being a separate module does not mean duplicating transport. transport-openapi
depends on go/transport and go/transit — it reuses their security-header
middleware and Middleware type rather than reimplementing them. It is a
companion, not a fork: lean core, opt-in extras, shared contracts.
Why embed the UI at all, rather than load it from a CDN¶
Two <script> and <link> tags pointing at unpkg or jsDelivr would make the
generated page a few hundred bytes and the module tiny. It would also make every
docs page load depend on a third party.
Embedding buys three things that matter more than the binary size:
- The docs work wherever the binary does. An air-gapped deployment, a private network with no egress, a laptop on a train — the page renders because the page, the assets and the spec all come from the process you started. A CDN reference turns your API docs into a blank page the moment the network says no.
- No third-party origin in the browser's view of your API console. The try-it
console holds credentials for your API. A script served from someone else's
domain, versioned by them, executes in that page. Vendoring the assets means the
supply chain is your
go.sumrather than a URL that resolves to whatever is there today. - The version is pinned by the module, not by a URL. The page always gets the Elements build this module was tested against — currently v9.0.0. It cannot change under you because upstream published a new major.
The cost is the one this whole module exists to isolate: 2.4 MB in the binary, and upgrading Elements means a change and a release here rather than editing a URL. That trade is why the boundary is a module rather than a flag — you pay it only by importing.
Why the docs handler and the transport server are separate concerns¶
They are separate because they are owned by different things.
transport-openapi decides what is served at the docs and spec paths; go/transport
decides how the server that carries them is configured, supervised and shut down.
Neither needs to know much about the other — the whole contract between them is a
*http.ServeMux and one middleware type.
That is why Register takes a mux rather than a server, and why it mounts routes
rather than starting anything. A docs handler that also owned a listener would be
two decisions welded together, and you would not be able to take one without the
other. As it stands you can mount the docs on a bare http.ServeMux in a test, on a
transport server in production, and on somebody else's router by nesting a mux
inside it.