Skip to content

Commit 8280619

Browse files
authored
fix(dev): take the next registered port when 3000 is busy (#159)
* fix(dev): take the next registered port when 3000 is busy `npm run dev` refused to start when port 3000 was in use, telling the user to free it or re-provision against another port. Provisioning already registers an OAuth callback for every port from 3000 to 3009, precisely so a forwarded dev server can land anywhere in that range and still log in, so refusing was throwing away a fallback the backend was built to support. The dev server now takes the first free port in the range and prints where it landed. A PORT named in the environment is left alone: that one is a decision, so a busy one is still an error. Two guards get more accurate as a result: - The callback check no longer fails when OAUTH_CALLBACK names 3000 and the server runs on 3005. Both are registered, so login works. - Running out of ports is now its own message, rather than being reported as "port 3000 is in use". The port list lives in scripts/lib.mjs and is asserted against the `range()` call in drupal/.devtools/provision, so widening it on one side fails the tests rather than breaking login on the other. * fix(dev): refuse a frontend port with no registered OAuth callback `PORT=4000 npm run dev` started fine and then failed only at login. The browser builds its callback from the port it is on, and Drupal registers 3000-3009 plus whatever OAUTH_CALLBACK names, so 4000 was rejected as invalid_client with the rest of the site working - the exact failure mode the other guards in this script exist to prevent. The guard now asks whether anything registers the port, rather than whether OAUTH_CALLBACK happens to name it. A backend this repo did not provision is left alone: its consumer was registered somewhere this checkout cannot see, so `backendIsProvisionedHere` gates the check. Port selection also moves to the last step before Nuxt starts. Nuxt 2 answers a bind failure with a random port, so nothing can fully close the gap between finding a port free and Nuxt taking it, but none of the configuration checks need the port, and running them first makes the gap as small as this script can make it. The test backend now answers the OAuth check the way a provisioned Drupal does, so the port cases run through the whole script instead of stopping at the consumer check.
1 parent 745fd92 commit 8280619

6 files changed

Lines changed: 379 additions & 48 deletions

File tree

CHANGELOG.md

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -90,8 +90,17 @@ minor is for.
9090
that terminal corrupted `vendor/` and `node_modules/`.
9191
- `composer install` retries: a transient registry error no longer ends
9292
a first run.
93-
- The dev server refuses to start on a taken port. Nuxt falls back to a
94-
random one, which silently breaks the OAuth callback.
93+
- The dev server moves to the next free port between 3000 and 3009 when
94+
3000 is taken, and prints which one it took. Nuxt's own fallback picks a
95+
random port, which silently breaks the OAuth callback; every port in
96+
that range has a callback registered, so any of them is safe. A `PORT`
97+
you name is still yours - a busy one fails, rather than moving
98+
somewhere you did not ask for.
99+
- The dev server refuses a port with no registered OAuth callback. A
100+
`PORT` outside 3000-3009 that `OAUTH_CALLBACK` does not name started
101+
fine and then failed only at login, since the browser builds its
102+
callback from the port it is on. Backends this repo did not provision
103+
are left alone: their consumers were registered out of sight.
95104
- The dev container no longer leaves Xdebug active, which made every
96105
`php` and `composer` call wait for a debugger.
97106
- The druxt patch is described without a link to a private merge

README.md

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,8 @@ WSL2, or a container backend - see [Windows](#windows).
8181
```
8282

8383
- Drupal backend: http://127.0.0.1:8888
84-
- Nuxt frontend: http://localhost:3000
84+
- Nuxt frontend: http://localhost:3000 (or the next free port up to
85+
3009, which it prints)
8586
- One-time Drupal login: `npm run login`
8687

8788
`npm run dev` and `npm run start` automatically start the local backend
@@ -150,6 +151,23 @@ Then:
150151
3. `npm run dev` as above. `npm run drush -- <command>` is proxied
151152
through `lando drush`.
152153

154+
### Troubleshooting
155+
156+
#### Port 3000 is already in use
157+
158+
`npm run dev` takes the next free port between 3000 and 3009 and says
159+
which one it picked. Provisioning registers an OAuth callback for every
160+
port in that range, so login keeps working on whichever one it uses.
161+
162+
Naming a port yourself turns that off: `PORT=3005 npm run dev` uses
163+
3005 or fails, because a port you asked for is a decision rather than a
164+
default. If the whole range is busy, `npm run dev` says so instead of
165+
letting Nuxt fall back to a random port and break login.
166+
167+
A port outside 3000-3009 has no registered callback, so `npm run dev`
168+
refuses that too. To use one, set `OAUTH_CALLBACK` in `.env` to
169+
`http://localhost:<port>/callback` and re-run `npm run provision`.
170+
153171
#### Login fails with invalid_client in a dev container
154172

155173
The browser builds the OAuth callback from its own address. An IDE
@@ -198,7 +216,7 @@ npm run dev
198216
```
199217

200218
- Drupal backend: http://127.0.0.1:8888
201-
- Nuxt frontend: http://localhost:3000
219+
- Nuxt frontend: http://localhost:3000 (or the next free port up to 3009)
202220

203221
## How to use it
204222

@@ -210,7 +228,7 @@ In a Development Container (VS Code, Codespaces, DevPod), forwarded ports are ac
210228

211229
| Port | Service |
212230
| ------ | ------------------------------------------------------------------------------------- |
213-
| `3000` | Nuxt.js |
231+
| `3000` | Nuxt.js (3000-3009: `npm run dev` takes the first free one) |
214232
| `3003` | Storybook |
215233
| `8888` | Drupal (local `.devtools` backend - DDEV serves at its own `*.ddev.site` URL instead) |
216234

scripts/dev.mjs

Lines changed: 115 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -7,89 +7,164 @@
77

88
import { checkOauth } from './check-oauth.mjs'
99
import {
10+
FRONTEND_PORTS,
1011
NUXT_DIR,
12+
backendIsProvisionedHere,
1113
ensureBackend,
1214
ensureOauthClientId,
1315
exitWithError,
16+
firstFreePort,
1417
foregroundNpm,
1518
isPortOpen,
19+
portIsRegistered,
1620
readEnv,
1721
} from './lib.mjs'
1822

19-
const PORT = Number(process.env.PORT) || 3000
23+
// Nuxt binds 0.0.0.0 (see nuxt.config.js), which answers on loopback
24+
// too, so this is the probe for "is that port taken".
25+
const HOST = '127.0.0.1'
26+
const PORT_RANGE = `${FRONTEND_PORTS[0]}-${FRONTEND_PORTS[FRONTEND_PORTS.length - 1]}`
27+
const ENV_PORT = Number(process.env.PORT)
28+
// A usable PORT in the environment is a decision; the default is only a
29+
// starting point. An empty or unparsable one is neither.
30+
const PORT_IS_EXPLICIT = Number.isInteger(ENV_PORT) && ENV_PORT > 0
31+
const REQUESTED_PORT = PORT_IS_EXPLICIT ? ENV_PORT : FRONTEND_PORTS[0]
2032

2133
/**
22-
* Refuse to start when the frontend port is taken.
34+
* Pick the port to serve the frontend on.
2335
*
2436
* Nuxt's dev server does not fail on a busy port - it falls back to a
2537
* random one. The OAuth consumer in Drupal is registered against a fixed
26-
* callback URL, so the login round trip then fails with a bare
38+
* set of callback URLs, so the login round trip then fails with a bare
2739
* `invalid_client` from Drupal, pointing nowhere near the real cause.
40+
*
41+
* Provisioning registers all of FRONTEND_PORTS for exactly that reason,
42+
* which makes a busy default a choice rather than a failure: take the
43+
* next registered port and say so. A port the user named is theirs.
44+
*
45+
* Something else can still take the port between this check and Nuxt
46+
* binding it, which lands back on Nuxt's own random fallback - the same
47+
* place an unguarded start would have been anyway.
2848
*/
29-
async function ensureFrontendPortFree() {
30-
if (!(await isPortOpen('127.0.0.1', PORT))) {
31-
return
49+
async function resolveFrontendPort() {
50+
if (!(await isPortOpen(HOST, REQUESTED_PORT))) {
51+
return REQUESTED_PORT
3252
}
3353

34-
const callback = readEnv().OAUTH_CALLBACK || `http://localhost:${PORT}/callback`
35-
exitWithError(
36-
`Port ${PORT} is already in use.\n\n` +
37-
` Nuxt would fall back to a random port, and login would then fail with\n` +
38-
` {"error":"invalid_client"} - Drupal has the consumer registered for\n` +
39-
` ${callback}, which would no longer match.\n\n` +
40-
` Free the port (another dev server, or another copy of this project),\n` +
41-
` or commit to a different one: set OAUTH_CALLBACK in .env to the port\n` +
42-
` you want, re-run \`npm run provision\` to re-register the consumer,\n` +
43-
` then start with \`PORT=<port> npm run dev\`.`
44-
)
54+
if (PORT_IS_EXPLICIT) {
55+
exitWithError(
56+
`Port ${REQUESTED_PORT} is already in use, and PORT asks for it by name.\n\n` +
57+
` Nuxt would fall back to a random port, and login would then fail with\n` +
58+
` {"error":"invalid_client"} - Drupal only accepts a callback it has\n` +
59+
` registered.\n\n` +
60+
` Free the port (another dev server, or another copy of this project),\n` +
61+
` or drop PORT and let \`npm run dev\` take the first free one of\n` +
62+
` ${PORT_RANGE}.`
63+
)
64+
}
65+
66+
const port = await firstFreePort(HOST)
67+
if (port === null) {
68+
exitWithError(
69+
`Ports ${PORT_RANGE} are all in use.\n\n` +
70+
` Nuxt would fall back to a random port, and login would then fail with\n` +
71+
` {"error":"invalid_client"} - those are the only callbacks Drupal has\n` +
72+
` registered.\n\n` +
73+
` Free one of them, or commit to a port outside the range: set\n` +
74+
` OAUTH_CALLBACK in .env to that port, re-run \`npm run provision\` to\n` +
75+
` re-register the consumer, then start with \`PORT=<port> npm run dev\`.`
76+
)
77+
}
78+
79+
console.log(`Port ${REQUESTED_PORT} is in use - starting on ${port} instead.`)
80+
console.log(`Login still works: Drupal accepts the callback on any of ${PORT_RANGE}.`)
81+
console.log('')
82+
return port
83+
}
84+
85+
/** The port OAUTH_CALLBACK names, or null when it names nothing usable. */
86+
function callbackPort(callback) {
87+
if (!callback) {
88+
return null
89+
}
90+
try {
91+
const parsed = new URL(callback)
92+
return Number(parsed.port) || (parsed.protocol === 'https:' ? 443 : 80)
93+
} catch {
94+
return null
95+
}
4596
}
4697

4798
/**
48-
* The consumer is registered for one callback URL. Serving the frontend
49-
* on a different port than that URL names fails the same way a busy
50-
* port does, just without anything else looking wrong.
99+
* Refuse a frontend port Drupal has no callback registered for.
100+
*
101+
* The browser builds redirect_uri from its own origin, so serving on an
102+
* unregistered port fails login with `invalid_client` while the rest of
103+
* the site works. Provisioning registers FRONTEND_PORTS plus whatever
104+
* OAUTH_CALLBACK names, and those are the only safe ports.
105+
*
106+
* Checking REQUESTED_PORT covers the port actually served: resolution
107+
* either keeps that port or moves inside FRONTEND_PORTS, which is
108+
* registered either way.
51109
*/
52-
function ensureCallbackMatchesPort() {
53-
const callback = readEnv().OAUTH_CALLBACK
54-
if (!callback) {
110+
function ensurePortHasCallback(backend) {
111+
if (portIsRegistered(REQUESTED_PORT)) {
55112
return
56113
}
57114

58-
let parsed
59-
try {
60-
parsed = new URL(callback)
61-
} catch {
115+
const callback = readEnv().OAUTH_CALLBACK
116+
const port = callbackPort(callback)
117+
if (port === REQUESTED_PORT) {
62118
return
63119
}
64120

65-
const callbackPort = Number(parsed.port) || (parsed.protocol === 'https:' ? 443 : 80)
66-
if (callbackPort === PORT) {
121+
if (port !== null) {
122+
exitWithError(
123+
`OAUTH_CALLBACK names port ${port}, but the dev server would run on ${REQUESTED_PORT}.\n\n` +
124+
` Login would fail with {"error":"invalid_client"} - Drupal only accepts the\n` +
125+
` callback it has registered (${callback}).\n\n` +
126+
` Either start on that port with \`PORT=${port} npm run dev\`, or set\n` +
127+
` OAUTH_CALLBACK to port ${REQUESTED_PORT} and re-run \`npm run provision\` to\n` +
128+
` re-register the consumer.`
129+
)
130+
}
131+
132+
// Nothing registers this port. Only say so for a backend this repo
133+
// provisioned - a remote one registered its consumer out of sight.
134+
if (!backendIsProvisionedHere(backend)) {
67135
return
68136
}
69137

70138
exitWithError(
71-
`OAUTH_CALLBACK names port ${callbackPort}, but the dev server would run on ${PORT}.\n\n` +
72-
` Login would fail with {"error":"invalid_client"} - Drupal only accepts the\n` +
73-
` callback it has registered (${callback}).\n\n` +
74-
` Either start on that port with \`PORT=${callbackPort} npm run dev\`, or set\n` +
75-
` OAUTH_CALLBACK to port ${PORT} and re-run \`npm run provision\` to\n` +
76-
` re-register the consumer.`
139+
`PORT is ${REQUESTED_PORT}, which has no OAuth callback registered.\n\n` +
140+
` Login would fail with {"error":"invalid_client"} while the rest of the\n` +
141+
` site works - the browser builds its callback from the port it is on,\n` +
142+
` and Drupal registers ${PORT_RANGE} plus whatever OAUTH_CALLBACK names.\n\n` +
143+
` Use a port from ${PORT_RANGE}, or set OAUTH_CALLBACK in .env to\n` +
144+
` http://localhost:${REQUESTED_PORT}/callback and re-run \`npm run provision\`\n` +
145+
` to register it.`
77146
)
78147
}
79148

80149
async function main() {
81-
await ensureBackend()
150+
const backend = await ensureBackend()
82151
ensureOauthClientId()
83-
ensureCallbackMatchesPort()
84-
await ensureFrontendPortFree()
152+
ensurePortHasCallback(backend)
85153
// Confirm the backend will actually accept this consumer. Nuxt reads
86154
// OAUTH_CLIENT_ID once at startup, so a stale value - or a consumer
87155
// left over from an older provision - shows up only as a failed login
88156
// in the browser, with nothing in the terminal to explain it.
89157
await checkOauth()
90-
console.log(`Starting the Nuxt dev server -> http://localhost:${PORT}`)
158+
// Last thing before the spawn. Everything above is config, and none of
159+
// it needs the port, so choosing one here leaves the smallest window
160+
// for another process to take it in the meantime.
161+
const port = await resolveFrontendPort()
162+
console.log(`Starting the Nuxt dev server -> http://localhost:${port}`)
91163
console.log('')
92-
process.exitCode = await foregroundNpm(['run', 'dev'], { cwd: NUXT_DIR })
164+
process.exitCode = await foregroundNpm(['run', 'dev'], {
165+
cwd: NUXT_DIR,
166+
env: { PORT: String(port) },
167+
})
93168
}
94169

95170
main().catch((error) => exitWithError(error.message))

scripts/lib.mjs

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -225,6 +225,42 @@ export async function waitForPort(host, port, timeoutSeconds = 30) {
225225
return false
226226
}
227227

228+
/**
229+
* The ports the frontend may serve on. Provisioning registers an OAuth
230+
* callback for every one of them (drupal/.devtools/provision and
231+
* .ddev/commands/web/druxt-add-consumer), so moving between them never
232+
* breaks login. Anything outside the list does, because the browser
233+
* builds redirect_uri from its own origin and Drupal rejects an
234+
* unregistered one as, confusingly, invalid_client.
235+
*/
236+
export const FRONTEND_PORTS = Array.from({ length: 10 }, (_, index) => 3000 + index)
237+
238+
/**
239+
* True when this repo's own tooling provisioned the backend, and so
240+
* knows what its OAuth consumer has registered. A remote backend was
241+
* set up somewhere this checkout cannot see, so its registrations are
242+
* not this repo's to assert.
243+
*/
244+
export function backendIsProvisionedHere(backend) {
245+
return Boolean(backend.managed || backend.ddev || backend.lando)
246+
}
247+
248+
/** True when a port has an OAuth callback registered for it. */
249+
export function portIsRegistered(port) {
250+
return FRONTEND_PORTS.includes(port)
251+
}
252+
253+
/**
254+
* The first of `ports` nothing is listening on, or null when they are
255+
* all taken. Checked in order, so a free 3000 always wins.
256+
*/
257+
export async function firstFreePort(host, ports = FRONTEND_PORTS) {
258+
for (const port of ports) {
259+
if (!(await isPortOpen(host, port, 500))) return port
260+
}
261+
return null
262+
}
263+
228264
/**
229265
* Run a command to completion, inheriting stdio. Throws on failure.
230266
*/

0 commit comments

Comments
 (0)