withastro / astro · Issue No. 18056
Astro v6.1.1
Adapter @astrojs/cloudflare (v13.1.7; also verified present in v14.3.2 and in `main`)
Node v24.19.0
System Linux (x64) — docker `catthehacker/ubuntu:act-24.04`
Output server
astro build dies immediately after prerendering static routes with:
fetch failed
Caused by:
connect ECONNREFUSED 127.0.0.1:36999
on a different random port every run. The same commit builds fine on macOS. This is a
follow-up to #15525, which was closed by its reporter ("after upgrading my OS, changing
networks, I'm not able to replicate") rather than fixed — and which @florian-lefebvre asked
to be re-filed with a minimal reproduction after @travisby
hit it again on
Astro 7.0.7 (request).
This is that re-file. The defect is still present in main.
packages/integrations/cloudflare/src/prerenderer.ts starts a Vite preview server bound by
hostname, then derives the fetch URL by re-stating that same hostname:
previewServer = await preview({
…,
preview: { host: 'localhost', port: 0, open: false }, // ← resolution #1 (bind)
…
});
const address = previewServer.httpServer.address();
if (address && typeof address === 'object') {
serverUrl = `http://localhost:${address.port}`; // ← resolution #2 (connect)
}
localhost is resolved twice, independently — once by listen() to choose a bind
address, once by fetch() to choose a connect address — and nothing makes the two agree.address.address and address.family, which say exactly what was bound, are discarded and
only .port is kept.
When the two resolutions land on different families, the preview server is listening on one
loopback address and fetch dials the other. Every serverUrl consumer is affected, not
just the first: getStaticPaths() (STATIC_PATHS_ENDPOINT), render()
(PRERENDER_ENDPOINT), collectStaticImages() (STATIC_IMAGES_ENDPOINT), and in 14.x alsocreateImageTransformUrl() (IMAGE_TRANSFORM_ENDPOINT).
No Astro needed — this is just what prerenderer.js does, in 15 lines:
import net from 'node:net';
import dns from 'node:dns';
const server = net.createServer((s) => s.end('HTTP/1.1 204 No Content\r\n\r\n'));
await new Promise((r) => server.listen(0, 'localhost', r)); // resolution #1
const { port } = server.address();
console.log('lookup all:', await dns.promises.lookup('localhost', { all: true }));
console.log('bound:', server.address());
for (const host of ['localhost', '127.0.0.1', '[::1]']) { // resolution #2
try { await fetch(`http://${host}:${port}/`); console.log(`connect ${host} -> OK`); }
catch (e) { console.log(`connect ${host} -> ${e.cause?.code}`); }
}
server.close();
Run it:
docker run --rm -v "$PWD/repro.mjs:/r.mjs" catthehacker/ubuntu:act-24.04 node /r.mjs
Output in that image (Node v24.19.0):
lookup all: [ { address: '::1', family: 6 }, { address: '127.0.0.1', family: 4 } ]
bound: { address: '::1', family: 'IPv6', port: 39545 }
connect localhost -> ECONNREFUSED
connect 127.0.0.1 -> ECONNREFUSED
connect [::1] -> OK
Same process, same hostname, opposite families: listen() bound IPv6-only, and
connecting by the name localhost went to IPv4. Note that dns.lookup(…, {all: true})
reporting ::1 first does not predict which address the connect path uses — I confirmed
the connect still goes to 127.0.0.1 with autoSelectFamily both true and false. So
this is not reliably fixable by reasoning about resolver order; the only robust fix is to
stop re-resolving the name.
On macOS the bind is also IPv6-only ({ address: '::1', family: 'IPv6' }, same verbatimdns.lookup order) — but there fetch('http://localhost:PORT') reaches ::1 and succeeds.
So bind and connect happen to agree and the bug never surfaces. It presents as a pure "works
on my machine": the code is equally wrong on both platforms, and only one of them tells you.
This also explains why #15525 looked like it went away for its reporter after an OS/network
change, and why it keeps resurfacing for other people. It is latent everywhere.
Build the URL from the address that was actually bound instead of re-resolving the
hostname, bracketing IPv6 literals. Then bind and connect agree by construction, on every
platform and under any resolver configuration:
const address = previewServer.httpServer.address();
if (address && typeof address === 'object') {
// Derive the URL from the address we actually bound, never by re-resolving
// "localhost" — a second resolution can pick a different family than listen()
// did, leaving the server on ::1 while fetch dials 127.0.0.1.
const host = address.family === 'IPv6' ? `[${address.address}]` : address.address;
serverUrl = `http://${host}:${address.port}`;
}
This is strictly better than pinning host: '127.0.0.1' on both sides (the fix suggested by
the triage bot on #15525): it keeps host: 'localhost''s existing behaviour of honouring
whatever the platform considers loopback, and it cannot drift again if that config is ever
changed, because the URL is derived from the socket rather than from a duplicated literal.
Happy to open a PR for this.
Until this lands, NODE_OPTIONS=--dns-result-order=ipv4first on the build step makes the
failure go away — verified single-variable in the container above, exit 1 without it and exit
0 with it.
Offered only as a stopgap, and with a caveat I'd rather state than let someone rely on: I
cannot fully explain why it works. As noted above, the connect path in that container does
not follow the order dns.lookup reports, so "it restores the resolution order the adapter
assumes" would be a tidier story than the measurements actually support. Treat it as an
empirical unblock for CI, not as a correct fix — which is the argument for landing the
address-derived change rather than documenting a flag.
Relay reads this issue against the repository's contribution signals: the files it is likely to touch, how the maintainers triage work this size, and what the first contribution would exercise.
The full analysis for this issue is still being assembled. Until then, the description above and the thread on GitHub are the most reliable context.