Skip to content

Address each component the way its reader can reach it (AP-3) - #117

Merged
DKoenig9 merged 1 commit into
mainfrom
feat/windows-dev-urls
Sep 22, 2026
Merged

DKoenig9 merged 1 commit into
mainfrom
feat/windows-dev-urls

Conversation

@DKoenig9

Copy link
Copy Markdown
Contributor

AP-3 of the lt dev Windows port. The largest package, and the one that decides whether lt dev test runs there at all.

The problem, and why the obvious fix is wrong

lt dev is URL-first: a project lives at https://<slug>.localhost, Caddy proxies to an opaque internal port. That promise holds for browsers. It does not hold for Node: on Windows *.localhost does not resolve at all (dns.lookup → ENOTFOUND, curl agrees), while Chromium resolves it internally without asking a resolver.

The tempting fix — "rewrite every internal URL to 127.0.0.1" — breaks the product. The question at each call site is not which URL but who reads it, and there are four answers:

example address
a browser resolves it Playwright baseURL, a printed link the public name
Node resolves it readiness probes, the SSR / proxy target loopback
another binary resolves it cloudflared loopback (see below)
nobody resolves it — it is COMPARED APP_URL, the Caddy vhost matcher, the tunnel Host: header the public name, untouched

APP_URL is the CORS allow-list and Better-Auth's trustedOrigins. It is matched as a string against the Origin header a browser sends, and the browser arrives from https://<slug>.localhost. Rewriting it to loopback would break every login — on a platform where only a health probe was broken before. A test pins it, and one of the mutations below is exactly that mistake.

What changed

  • internalUrl(port) — loopback on every platform, not behind an isWindows() branch. 127.0.0.1 works everywhere, so the path Windows depends on is the one macOS exercises daily. A branch only the other platform runs is an unchecked branch.
  • NUXT_API_URL → loopback, NUXT_PUBLIC_API_URL stays the public name. The two already existed side by side carrying the same value; nuxt-extensions' buildLtApiUrl() already prefers the server-only one during SSR, so nothing outside this repo needs to change.
  • The lt dev test readiness probes probe loopback. This is the one that blocked the command outright: Node asking a name it cannot resolve timed out against a stack that was up, and bring-up aborted before Playwright started. Probing the port also measures the component rather than Caddy — the better question for "is it alive". The failure messages keep the public name, because that is what a developer opens.
  • LT_DEV_API_INTERNAL_URL / LT_DEV_APP_INTERNAL_URL are published and written to the .lt-dev/.env bridge, so an external runner's Node-side helper has an address it can reach.
  • The consumer CLAUDE.md URL block names both, with who each is for. The block tells an agent never to assume localhost:3000/3001 — so naming only the *.localhost host left an agent with a name Node cannot resolve. This required moving the patch after port allocation; it ran before, where only the name existed, which is why the ports were never passed.

Not changed, deliberately

tunnel.ts's upstreamUrl is dialled by cloudflared and has the same problem. But pointing it at a component's port would bypass Caddy, which is the entire point of the tunnel. The right value is https://127.0.0.1 with the Host: header unchanged (--no-tls-verify is already passed). I cannot verify that here — it needs cloudflared and a live tunnel — so it is recorded in the inventory rather than changed on a hunch.

Two residuals in the same class, both recorded and both project-dependent rather than framework-driven:

  • Playwright's request fixture resolves baseURL from Node.
  • nuxt-extensions' validateSession() has no SSR guard and fetches the public URL. Its own docblock anticipates projects calling it during SSR.

Checks

tsc --noEmit, npm run lint clean. Jest: 77 suites / 1198 tests.

Mutations, each turning tests red:

  1. NUXT_API_URL back to the public name
  2. APP_URL rewritten to loopback — the mistake this change argues against
  3. the readiness probe back to the name

🤖 Generated with Claude Code

https://claude.ai/code/session_01N8cvaEziSrKGHv3Jcp59JH

`lt dev` is URL-first: a project lives at `https://<slug>.localhost` and Caddy
proxies to an opaque internal port. That promise holds for **browsers**. It does
not hold for Node — on Windows `*.localhost` subdomains do not resolve at all
(`dns.lookup` → ENOTFOUND, `curl` agrees), while Chromium resolves them
internally without asking a resolver.

So the question at each call site is not "which URL" but **"who reads it"**, and
there are four answers, not two:

| | example | address |
|---|---|---|
| a browser resolves it | Playwright `baseURL`, a printed link | the public name |
| Node resolves it | readiness probes, the SSR/proxy target | **loopback** |
| another binary resolves it | `cloudflared` | loopback (see "not changed") |
| nobody resolves it — it is COMPARED | `APP_URL`, the Caddy vhost matcher, the tunnel `Host:` header | the public name, **untouched** |

That last row is why "rewrite every internal URL to 127.0.0.1" would be wrong.
`APP_URL` is the CORS allow-list and Better-Auth's `trustedOrigins` — matched as a
string against the `Origin` header a browser sends, and the browser arrives from
`https://<slug>.localhost`. Rewriting it would break **every login**, on a platform
where only a health probe was broken before. A test pins that.

## What changed

- **`internalUrl(port)`** (`dev-env.ts`) — loopback on EVERY platform, not behind
  an `isWindows()` branch. 127.0.0.1 works everywhere, so the path Windows depends
  on is the one macOS exercises daily. A branch only the other platform runs is an
  unchecked branch.
- **`NUXT_API_URL` → loopback**, `NUXT_PUBLIC_API_URL` stays the public name. The
  two already existed side by side and carried the same value; nuxt-extensions'
  `buildLtApiUrl()` already prefers the server-only one during SSR, so no change is
  needed there.
- **The `lt dev test` readiness probes** (`dev-test-session.ts`) probe loopback.
  This is the one that blocked the command outright: Node asking a name it cannot
  resolve timed out against a stack that was up, and bring-up aborted before
  Playwright started. Probing the port also measures the component rather than
  Caddy — the better question for "is it alive". The failure MESSAGES keep the
  public name, because that is what a developer opens.
- **`LT_DEV_API_INTERNAL_URL` / `LT_DEV_APP_INTERNAL_URL`** are published and
  written to the `.lt-dev/.env` bridge, so an external runner's Node-side helper
  has an address it can reach.
- **The consumer CLAUDE.md URL block names both**, with who each is for. It tells
  an agent never to assume `localhost:3000/3001`, so naming only the `*.localhost`
  host left an agent with a name that does not resolve for Node. This moved the
  patch after port allocation — it ran before, where only the name existed, which
  is why the ports were never passed.

## Not changed, deliberately

`tunnel.ts`'s `upstreamUrl` is dialled by `cloudflared`, so it has the same
problem. But pointing it at a component's port would **bypass Caddy**, which is
the whole point of the tunnel. The right value is `https://127.0.0.1` with the
`Host:` header unchanged (`--no-tls-verify` is already passed). I cannot verify
that here — it needs cloudflared and a live tunnel — so it is recorded in the
inventory rather than changed on a hunch.

Two residuals in the same class, both recorded: Playwright's `request` fixture
resolves `baseURL` from Node, and nuxt-extensions' `validateSession()` has no SSR
guard and fetches the public URL. Both are project-dependent, neither is triggered
by the framework itself.

Tests: 77 suites / 1198 tests. Mutations, each turning tests red: `NUXT_API_URL`
back to the public name; `APP_URL` rewritten to loopback (the mistake this change
argues against); the readiness probe back to the name.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N8cvaEziSrKGHv3Jcp59JH
@DKoenig9
DKoenig9 marked this pull request as ready for review September 22, 2026 09:49
@DKoenig9
DKoenig9 merged commit 59d8ed4 into main Sep 22, 2026
2 checks passed
@DKoenig9
DKoenig9 deleted the feat/windows-dev-urls branch September 22, 2026 09:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant