Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/dev.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,8 @@ When the dev server reloads or restarts it says what caused it, and a change to

A dev server records itself in `nuxt.lock` inside your [build directory](/docs/directory-structure/nuxt), so a second `nuxt dev` for the same project reports the one already running instead of racing it for the port. Pass `--takeover` to stop it and start in its place, or set `NUXT_IGNORE_LOCK=1` to run a second server anyway (unsupported).

When you are asked what to do, you can also open the running server in your browser without stopping it. That is the default choice with `--open`, and `--open.url` is resolved against the running server's URL.

The same lock file is how [`nuxt curl`](/docs/api/commands/curl) and [`nuxt task`](/docs/api/commands/task) find the server to talk to.

## HTTPS
Expand Down
2 changes: 1 addition & 1 deletion docs/preview.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ A preview records itself in `node_modules/.cache/nuxt/preview`, so a second `nux
| in a terminal | without a terminal | The new one exits, saying where the running one is. |
| in a terminal | in a terminal | You are asked, defaulting to not starting. |

Pass `--takeover` to always stop it and start in its place, or `--no-takeover` to never do so. Choosing "Start anyway" at the prompt, or passing a different `--port`, runs a second preview alongside it instead.
Pass `--takeover` to always stop it and start in its place, or `--no-takeover` to never do so. Choosing "Start anyway" at the prompt, or passing a different `--port`, runs a second preview alongside it instead. Choosing "Open in browser" opens the running preview without stopping it.

When another server, including `nuxt dev`, is using the requested port, the preview automatically uses another free port without stopping that server. Pass `--strictPort` to exit instead. The requested port comes from `--port`, `NUXT_PORT`, `NITRO_PORT`, or `PORT`, defaulting to `3000`.

Expand Down
6 changes: 6 additions & 0 deletions packages/nuxt-cli/src/commands/dev.ts
Original file line number Diff line number Diff line change
Expand Up @@ -186,12 +186,18 @@ const command = defineCommand({
const takeover = await beforeServing(() => takeOverServer(buildDir, {
requestedPort: parsePort(listenOverrides.port),
takeover: ctx.args.takeover,
open: listenOverrides.open,
openURL: listenOverrides.openURL,
}))
if (takeover.action === 'refused') {
await teardownDevUI()
logger.error(formatTakeoverRefusal(takeover.existing, takeover.reason))
process.exit(1)
}
if (takeover.action === 'opened') {
await teardownDevUI()
process.exit(0)
}
if (takeover.action === 'start-anyway') {
process.env.NUXT_IGNORE_LOCK = '1'
}
Expand Down
3 changes: 3 additions & 0 deletions packages/nuxt-cli/src/commands/preview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,9 @@ const command = defineCommand({
logger.error(formatTakeoverRefusal(takeover.existing, takeover.reason))
process.exit(1)
}
if (takeover.action === 'opened') {
process.exit(0)
}
if (takeover.action === 'taken') {
return takeover.port
}
Expand Down
67 changes: 44 additions & 23 deletions packages/nuxt-cli/src/dev/listen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -647,38 +647,59 @@ export function resolveOpenCommand(
/** How long a launcher has to fail before we assume the browser did open. */
const BROWSER_LAUNCH_TIMEOUT_MS = 3000

export function openBrowser(url: string): void {
/** Whether {@link openBrowser} has a browser to launch in this environment. */
export function canOpenBrowser(): boolean {
const resolved = resolveOpenCommand('')
return !!resolved && (resolved[0] !== 'xdg-open' || hasDisplayServer())
}

/**
* Open `url` in a browser. The returned promise resolves once the launcher has
* exited or had time to fail, after any failure has been reported.
*/
export function openBrowser(url: string): Promise<void> {
const resolved = resolveOpenCommand(url)
if (!resolved) {
return
return Promise.resolve()
}
const [command, args] = resolved
if (command === 'xdg-open' && !hasDisplayServer()) {
logger.warn(`No browser is available in this environment. Open ${styleText('cyan', url)} manually.`)
return
return Promise.resolve()
}

const onFailure = (error: unknown) => {
debug('Failed to open browser:', error)
logger.warn(`Could not open ${styleText('cyan', url)} in a browser.`)
}
return new Promise((resolve) => {
const onFailure = (error: unknown) => {
debug('Failed to open browser:', error)
logger.warn(`Could not open ${styleText('cyan', url)} in a browser.`)
resolve()
}

try {
const child = spawn(command, args, { stdio: 'ignore', detached: true })
child.once('error', onFailure)
const onExit = (code: number | null) => {
if (code) {
onFailure(new Error(`\`${command}\` exited with code ${code}`))
try {
const child = spawn(command, args, { stdio: 'ignore', detached: true })
const onExit = (code: number | null) => {
if (code) {
onFailure(new Error(`\`${command}\` exited with code ${code}`))
}
settle()
}
function settle() {
child.off('exit', onExit)
child.unref()
resolve()
}
child.once('error', (error) => {
onFailure(error)
settle()
})
child.once('exit', onExit)
// `BROWSER` may point at the browser itself rather than a launcher, in which
// case the process lives as long as the browser does and its eventual exit
// code says nothing about whether the URL opened.
setTimeout(settle, BROWSER_LAUNCH_TIMEOUT_MS).unref()
}
child.once('exit', onExit)
// `BROWSER` may point at the browser itself rather than a launcher, in which
// case the process lives as long as the browser does and its eventual exit
// code says nothing about whether the URL opened.
setTimeout(() => child.off('exit', onExit), BROWSER_LAUNCH_TIMEOUT_MS).unref()
child.unref()
}
catch (error) {
onFailure(error)
}
catch (error) {
onFailure(error)
}
})
}
36 changes: 31 additions & 5 deletions packages/nuxt-cli/src/dev/takeover.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,12 @@ import { checkPort } from 'get-port-please'
import { isCI } from 'std-env'

import { restoreRawMode, withDirectStdout } from '../utils/console'
import { lockAppURL } from '../utils/dev-server'
import { clearStaleLock, clearTakeover, isLockEnabled, isProcessAlive, markTakenOver, readLock } from '../utils/lockfile'
import { logger } from '../utils/logger'
import { withUserAttention } from '../utils/startup-clock'
import { isInteractiveSession } from '../utils/stdout'
import { canOpenBrowser, openBrowser, resolveOpenURL } from './listen'
import { DEV_SHUTDOWN_TIMEOUT_MS } from './shutdown'

/** How long a `SIGKILL`ed process has to disappear before we give up. */
Expand Down Expand Up @@ -39,8 +41,10 @@ export type TakeoverResult
| { action: 'refused', existing: LockInfo, reason: TakeoverRefusalReason }
/** The user chose to run a second server anyway, without the lock. */
| { action: 'start-anyway', existing: LockInfo }
/** The user opened the running server in a browser; the caller should exit cleanly. */
| { action: 'opened', existing: LockInfo }

export type TakeoverChoice = 'takeover' | 'abort' | 'start-anyway'
export type TakeoverChoice = 'takeover' | 'abort' | 'open' | 'start-anyway'

type ServerCommand = 'dev' | 'preview'

Expand All @@ -66,8 +70,15 @@ export interface TakeoverOptions {
takeover?: boolean
/** Whether this invocation can prompt. Defaults to the current terminal. */
interactive?: boolean
/** Overridable so the decision table can be tested without a TTY. */
prompt?: (existing: LockInfo, defaultChoice: TakeoverChoice) => Promise<TakeoverChoice>
/** `--open`; makes opening the running server the default choice. */
open?: boolean
/** `--open.url`; resolved against the running server's URL. */
openURL?: string
/**
* Overridable so the decision table can be tested without a TTY. `openURL` is
* what the `open` choice would open, and is absent when it is unavailable.
*/
prompt?: (existing: LockInfo, defaultChoice: TakeoverChoice, openURL: string | undefined) => Promise<TakeoverChoice>
/** How long to wait for the outgoing server, in ms. Overridable for tests. */
timeouts?: { graceful?: number, force?: number }
}
Expand Down Expand Up @@ -142,12 +153,21 @@ async function resolveTakeover(lockDir: string, options: TakeoverOptions): Promi
}
else {
const prompt = options.prompt ?? promptForTakeover
const openURL = resolveBrowserURL(existing, options.openURL)
// Nothing ends an interactive session on a bare enter, so the default
// depends on whether a person is watching the other server.
const choice = await withUserAttention(() => prompt(existing, existing.interactive ? 'abort' : 'takeover'))
const defaultChoice = options.open && openURL ? 'open' : existing.interactive ? 'abort' : 'takeover'
const choice = await withUserAttention(() => prompt(existing, defaultChoice, openURL))
if (choice === 'abort') {
return { action: 'refused', existing, reason: 'declined' }
}
if (choice === 'open') {
if (!openURL) {
return { action: 'refused', existing, reason: 'declined' }
}
await openBrowser(openURL)
return { action: 'opened', existing }
}
if (choice === 'start-anyway') {
if (existing.command === 'dev') {
logger.warn(`Starting a second dev server: both will write to ${styleText('cyan', lockDir)}, which is unsupported and may corrupt the build.`)
Expand Down Expand Up @@ -293,14 +313,20 @@ function describeLocation(existing: LockInfo): string {
return existing.url || 'starting up (no URL yet)'
}

async function promptForTakeover(existing: LockInfo, defaultChoice: TakeoverChoice): Promise<TakeoverChoice> {
function resolveBrowserURL(existing: LockInfo, target: string | undefined): string | undefined {
const url = canOpenBrowser() ? lockAppURL(existing) : undefined
return url && target ? resolveOpenURL(target, url) : url
}

async function promptForTakeover(existing: LockInfo, defaultChoice: TakeoverChoice, openURL: string | undefined): Promise<TakeoverChoice> {
logger.info(`A Nuxt ${describeServer(existing)} is already running here (PID ${existing.pid}, ${describeLocation(existing)}).`)
const choice = await select<TakeoverChoice>({
message: 'What would you like to do?',
initialValue: defaultChoice,
options: [
{ value: 'takeover', label: `Take over port ${existing.port}`, hint: 'stops the running server (--takeover)' },
{ value: 'abort', label: 'Do not start', hint: '--no-takeover' },
...(openURL ? [{ value: 'open' as const, label: `Open ${openURL} in browser`, hint: 'keeps the running server' }] : []),
{ value: 'start-anyway', label: 'Start anyway', hint: serverKind(existing).startAnywayHint },
],
})
Expand Down
9 changes: 6 additions & 3 deletions packages/nuxt-cli/src/dev/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1060,9 +1060,7 @@ export class NuxtDevServer extends EventEmitter<DevServerEventMap> {
const https = (httpsEnabled ?? !!nuxtConfig.devServer?.https)
&& defu(typeof overrides.https === 'object' ? overrides.https : {}, httpsFromConfig)

const baseURL = nuxtConfig.app?.baseURL?.startsWith?.('./')
? nuxtConfig.app.baseURL.slice(1)
: nuxtConfig.app?.baseURL
const baseURL = resolveAppBaseURL(nuxtConfig.app?.baseURL)

return {
...overrides,
Expand Down Expand Up @@ -1243,6 +1241,7 @@ export class NuxtDevServer extends EventEmitter<DevServerEventMap> {
port: addr.port,
hostname: addr.address,
url: serverUrl,
baseURL: resolveAppBaseURL(this.#currentNuxt.options.app?.baseURL),
})

this.#progress.setReady()
Expand Down Expand Up @@ -1386,6 +1385,10 @@ function isInteractive(): boolean {
return !!process.stdout.isTTY || !!process.env.__NUXT_DEV_PIPED_TTY__
}

function resolveAppBaseURL(baseURL: string | undefined): string | undefined {
return baseURL?.startsWith?.('./') ? baseURL.slice(1) : baseURL
}

function getAddressURL(addr: { address: string, port: number }, https: boolean) {
const proto = https ? 'https' : 'http'
let host = addr.address.includes(':') ? `[${addr.address}]` : addr.address
Expand Down
12 changes: 12 additions & 0 deletions packages/nuxt-cli/src/utils/dev-server.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import type { LockInfo } from './lockfile'

import { existsSync, readFileSync } from 'node:fs'
import { isIP } from 'node:net'
import { networkInterfaces } from 'node:os'
Expand Down Expand Up @@ -58,6 +60,16 @@ export async function findDevServer(cwd: string, buildDir?: string): Promise<Run
}
}

/**
* Where to open the app served by the server recorded in `lock`, on loopback
* and under its `app.baseURL`. Nothing for a lock that names another machine.
*/
export function lockAppURL(lock: LockInfo): string | undefined {
if (lock.url && isOwnAddress(lock.url)) {
return toLoopback(lock.url) + (lock.baseURL || '/')
}
}

/**
* Whether `url` names this machine. A dev server records the address it bound,
* which is always local, but the lock file it is read from lives in the project
Expand Down
9 changes: 9 additions & 0 deletions packages/nuxt-cli/src/utils/lockfile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ export interface LockInfo {
port?: number
hostname?: string
url?: string
/** `app.baseURL` of a dev server, which `url` does not include. */
baseURL?: string
/**
* PID of the process supervising the holder, when the holder is a dev fork.
* Signalling the holder alone would leave its supervisor running.
Expand Down Expand Up @@ -162,6 +164,11 @@ function lockURL(value: unknown): string | undefined {
}
}

function lockURLPath(value: unknown): string | undefined {
const text = lockText(value)
return text?.startsWith('/') && !text.startsWith('//') ? text : undefined
}

/**
* Validate a parsed `nuxt.lock` document.
*
Expand Down Expand Up @@ -189,6 +196,7 @@ export function parseLockInfo(raw: unknown): LockInfo | undefined {

const port = input.port
const hostname = lockText(input.hostname)
const baseURL = lockURLPath(input.baseURL)
const parentPid = lockPid(input.parentPid)
const serverPid = lockPid(input.serverPid)
const takenOverBy = lockPid(input.takenOverBy)
Expand All @@ -202,6 +210,7 @@ export function parseLockInfo(raw: unknown): LockInfo | undefined {
...typeof port === 'number' && Number.isInteger(port) && port > 0 && port <= 65_535 ? { port } : {},
...hostname && LOCK_HOSTNAME_RE.test(hostname) ? { hostname } : {},
...lockURL(input.url) ? { url: lockURL(input.url) } : {},
...baseURL ? { baseURL } : {},
...parentPid ? { parentPid } : {},
...serverPid ? { serverPid } : {},
...takenOverBy ? { takenOverBy } : {},
Expand Down
19 changes: 18 additions & 1 deletion packages/nuxt-cli/test/unit/listen.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { networkInterfaces } from 'node:os'

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'

import { copyURL, formatDisplayURL, getNetworkAddresses, isReusePortSupported, listen, matchesBoundTarget, openBrowser, parsePort, resolveOpenCommand, validateHostname, writeClipboard } from '../../src/dev/listen'
import { canOpenBrowser, copyURL, formatDisplayURL, getNetworkAddresses, isReusePortSupported, listen, matchesBoundTarget, openBrowser, parsePort, resolveOpenCommand, validateHostname, writeClipboard } from '../../src/dev/listen'

const writeText = vi.hoisted(() => vi.fn())
const isolatedEnvironment = vi.hoisted(() => ({ current: undefined as string | undefined }))
Expand Down Expand Up @@ -495,6 +495,23 @@ describe('copyURL', () => {
})
})

describe('canOpenBrowser', () => {
afterEach(restoreEnvironment)

it('should be false only when `openBrowser` has nothing to launch', () => {
stubEnvironment('linux')
expect(canOpenBrowser()).toBe(false)
stubEnvironment('linux', { DISPLAY: ':0' })
expect(canOpenBrowser()).toBe(true)
stubEnvironment('linux', { BROWSER: '/home/user/.vscode-server/bin/helpers/browser.sh' })
expect(canOpenBrowser()).toBe(true)
stubEnvironment('darwin', { BROWSER: 'none' })
expect(canOpenBrowser()).toBe(false)
stubEnvironment('darwin')
expect(canOpenBrowser()).toBe(true)
})
})

describe('openBrowser', () => {
afterEach(() => {
restoreEnvironment()
Expand Down
Loading
Loading