feat(plugins): api.serverInfo — the server's own origin (#601)#605
Conversation
A plugin minting absolute URLs or calling the host over loopback had to
be told its origin via config, and a wrong value fails quietly (nip05
serving an empty identity map). ~16 of the 33 out-of-tree plugins
repeat baseUrl/loopbackUrl in config for exactly this.
api.serverInfo() -> { baseUrl, protocol, host, port, listening }.
A function, not a snapshot: with port 0 the real port exists only once
the server is listening, so the seam is honest at both moments —
before listen it reports the configured values (an explicit port 0
stays 0 rather than being masked by the default), after listen the
resolved bind. 0.0.0.0 reports localhost as the callable host and an
explicit idpIssuer wins as the canonical public baseUrl, both
mirroring the existing pod-seeding logic.
Three tests: port-0 resolution live vs activate-time, 0.0.0.0 ->
localhost, idpIssuer override. Full suite 1034/1034.
There was a problem hiding this comment.
Pull request overview
Adds a new plugin API seam (api.serverInfo()) so plugins can reliably discover the server’s own origin (including port-0 resolution once listening), reducing the need for config-supplied baseUrl values that can silently drift.
Changes:
- Wire
origininputs into the plugin loader context fromcreateServer()options. - Add
api.serverInfo()to the plugin API surface and document it. - Add a focused test suite covering port-0 behavior,
0.0.0.0→localhost, andidpIssueroverride semantics.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
src/server.js |
Passes normalized origin inputs (ssl/host/port/baseUrl override) into plugin-loader context for api.serverInfo(). |
src/plugins.js |
Introduces api.serverInfo() on the plugin API object. |
test/plugin-serverinfo.test.js |
Adds tests exercising activate-time vs listening-time serverInfo values and override behavior. |
docs/configuration.md |
Documents the new api.serverInfo() plugin API surface and its lazy usage expectations. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| const o = ctx.origin ?? {}; | ||
| const addr = fastify.server?.listening ? fastify.server.address() : null; | ||
| const port = (typeof addr === 'object' && addr?.port) || (o.port ?? null); | ||
| const host = !o.host || o.host === '0.0.0.0' ? 'localhost' : o.host; | ||
| const protocol = o.ssl ? 'https' : 'http'; | ||
| const baseUrl = o.baseUrl || `${protocol}://${host}:${port}`; | ||
| return { baseUrl, protocol, host, port, listening: !!addr }; |
There was a problem hiding this comment.
Both confirmed, fixed together in 8f6dad8 — once listening, server.address() wins for host AND port (unspecified binds still map to localhost; IPv6 literals bracketed in baseUrl), and the test harness now binds the host under test — the 0.0.0.0 case was indeed binding 127.0.0.1 and masking exactly this. New test pins configured-vs-live divergence. 1035/1035.
| await server.listen({ port: 0, host: '127.0.0.1' }); | ||
| baseUrl = `http://127.0.0.1:${server.server.address().port}`; | ||
| } |
There was a problem hiding this comment.
Both confirmed, fixed together in 8f6dad8 — once listening, server.address() wins for host AND port (unspecified binds still map to localhost; IPv6 literals bracketed in baseUrl), and the test harness now binds the host under test — the 0.0.0.0 case was indeed binding 127.0.0.1 and masking exactly this. New test pins configured-vs-live divergence. 1035/1035.
listen() may be called with a different host than createServer() was given, so once listening the address from server.address() wins over options for both host and port. Unspecified binds (0.0.0.0, ::) still map to localhost; IPv6 literals get bracketed in baseUrl. The test harness now binds the host under test (the 0.0.0.0 case previously bound 127.0.0.1, masking exactly this gap) and a new test pins configured-vs-live divergence.
Four plugin-API seams land, all surfaced by the out-of-tree plugin
experiment (github.com/JavaScriptSolidServer/plugins), each with a
consumer proving the need:
- api.mountApp(handler, { prefix }) (#583, #590): mount a wrapped
node-style (req, res) app — a reverse proxy or framework adapter —
under the plugin's prefix. Bundles the appPaths WAC exemption, a
scoped pass-through parser that hands the app an undrained body
stream, and reply.hijack(). The raw-body-mode answer to #583.
- api.serverInfo() -> { baseUrl, protocol, host, port, listening }
(#601, #605): a plugin learns the server's own origin instead of
repeating baseUrl/loopbackUrl in config, where a wrong value fails
quietly. A function, not a snapshot — with port 0 the real port
exists only once listening; an explicit idpIssuer wins as the
canonical baseUrl.
- api.reservePath(path, opts?) (#602, #607): claim and WAC-exempt a
route outside the plugin's prefix. Literal paths ('/xrpc') own their
subtree; parameterized paths ('/:user/did.json') match an exact
shape (read-only by default) for pinned documents inside pod
namespaces. Two plugins reserving the same path fail the boot with
both names — loud beats the silent-loser outcome.
- api.plugins -> [{ id, prefix, module }] (#610, #612): a read-only,
frozen boot-time roster of every loaded entry, so a plugin
describing the deployment (a status dashboard, an admin console)
enumerates its co-loaded siblings instead of being hand-fed a
duplicate of the operator's plugins array.
All documented in docs/configuration.md App Plugins. Remaining plugin
seams tracked upstream: api.events.onResourceChange (#603),
api.authorize (#604).
Closes #601 — the first (broadest, cheapest) of the four plugin-API seams from the 33-plugin exercise.
What
api.serverInfo()→{ baseUrl, protocol, host, port, listening }.Design notes
activate()runs beforelisten(), so withport: 0the real port doesn't exist yet. Called lazily (per request or in anonListenhook viaapi.fastify) it reports the resolved bind; called during activate it reports the configured values honestly — an explicitport: 0stays0(??not||in the ctx wiring) instead of being masked by the default, andlistening: falsesays why.0.0.0.0reportslocalhostas the callable host and an explicitidpIssuerwins asbaseUrl— both mirror the existing pod-seeding logic inserver.js, so a plugin's idea of the origin can't drift from the host's.port,listening) remain available alongside anidpIssueroverride for loopback use.Tests
test/plugin-serverinfo.test.js: port-0 resolution (live value has the bound port, activate-time capture has0/listening: false),0.0.0.0→localhost,idpIssueroverride. Full suite 1034/1034. Documented indocs/configuration.mdand the loader header.