diff --git a/docs/dev.md b/docs/dev.md index 3a08f0397..91ac562cd 100644 --- a/docs/dev.md +++ b/docs/dev.md @@ -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 diff --git a/docs/preview.md b/docs/preview.md index 0d5c56bb9..233a0e7df 100644 --- a/docs/preview.md +++ b/docs/preview.md @@ -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`. diff --git a/packages/nuxt-cli/src/commands/dev.ts b/packages/nuxt-cli/src/commands/dev.ts index dc6be6091..9d1014c5a 100644 --- a/packages/nuxt-cli/src/commands/dev.ts +++ b/packages/nuxt-cli/src/commands/dev.ts @@ -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' } diff --git a/packages/nuxt-cli/src/commands/preview.ts b/packages/nuxt-cli/src/commands/preview.ts index d1b3a0214..af537aaaa 100644 --- a/packages/nuxt-cli/src/commands/preview.ts +++ b/packages/nuxt-cli/src/commands/preview.ts @@ -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 } diff --git a/packages/nuxt-cli/src/dev/listen.ts b/packages/nuxt-cli/src/dev/listen.ts index de0ebd440..2a395dfdc 100644 --- a/packages/nuxt-cli/src/dev/listen.ts +++ b/packages/nuxt-cli/src/dev/listen.ts @@ -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 { 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) + } + }) } diff --git a/packages/nuxt-cli/src/dev/takeover.ts b/packages/nuxt-cli/src/dev/takeover.ts index 118739874..e744c7530 100644 --- a/packages/nuxt-cli/src/dev/takeover.ts +++ b/packages/nuxt-cli/src/dev/takeover.ts @@ -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. */ @@ -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' @@ -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 + /** `--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 /** How long to wait for the outgoing server, in ms. Overridable for tests. */ timeouts?: { graceful?: number, force?: number } } @@ -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.`) @@ -293,7 +313,12 @@ function describeLocation(existing: LockInfo): string { return existing.url || 'starting up (no URL yet)' } -async function promptForTakeover(existing: LockInfo, defaultChoice: TakeoverChoice): Promise { +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 { logger.info(`A Nuxt ${describeServer(existing)} is already running here (PID ${existing.pid}, ${describeLocation(existing)}).`) const choice = await select({ message: 'What would you like to do?', @@ -301,6 +326,7 @@ async function promptForTakeover(existing: LockInfo, defaultChoice: TakeoverChoi 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 }, ], }) diff --git a/packages/nuxt-cli/src/dev/utils.ts b/packages/nuxt-cli/src/dev/utils.ts index 4a3217116..33c6ff676 100644 --- a/packages/nuxt-cli/src/dev/utils.ts +++ b/packages/nuxt-cli/src/dev/utils.ts @@ -1060,9 +1060,7 @@ export class NuxtDevServer extends EventEmitter { 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, @@ -1243,6 +1241,7 @@ export class NuxtDevServer extends EventEmitter { port: addr.port, hostname: addr.address, url: serverUrl, + baseURL: resolveAppBaseURL(this.#currentNuxt.options.app?.baseURL), }) this.#progress.setReady() @@ -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 diff --git a/packages/nuxt-cli/src/utils/dev-server.ts b/packages/nuxt-cli/src/utils/dev-server.ts index 0ba3eb364..c27efad7a 100644 --- a/packages/nuxt-cli/src/utils/dev-server.ts +++ b/packages/nuxt-cli/src/utils/dev-server.ts @@ -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' @@ -58,6 +60,16 @@ export async function findDevServer(cwd: string, buildDir?: string): Promise 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 } : {}, diff --git a/packages/nuxt-cli/test/unit/listen.spec.ts b/packages/nuxt-cli/test/unit/listen.spec.ts index b68081b23..60bd9be63 100644 --- a/packages/nuxt-cli/test/unit/listen.spec.ts +++ b/packages/nuxt-cli/test/unit/listen.spec.ts @@ -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 })) @@ -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() diff --git a/packages/nuxt-cli/test/unit/takeover.spec.ts b/packages/nuxt-cli/test/unit/takeover.spec.ts index 7c924f85b..f2faeb15e 100644 --- a/packages/nuxt-cli/test/unit/takeover.spec.ts +++ b/packages/nuxt-cli/test/unit/takeover.spec.ts @@ -12,6 +12,17 @@ const checkPort = vi.hoisted(() => vi.fn<(port: number, host?: string) => Promis vi.mock('get-port-please', () => ({ checkPort })) +const { canOpenBrowser, openBrowser } = vi.hoisted(() => ({ + canOpenBrowser: vi.fn(() => true), + openBrowser: vi.fn(async (_url: string) => {}), +})) + +vi.mock('../../src/dev/listen', async importOriginal => ({ + ...await importOriginal(), + canOpenBrowser, + openBrowser, +})) + const { formatTakeoverRefusal, takeOverServer } = await import('../../src/dev/takeover') const { logger } = await import('../../src/utils/logger') const { getTakeoverPid, markTakenOver, readLock, updateLock } = await import('../../src/utils/lockfile') @@ -68,6 +79,8 @@ describe('takeOverServer', () => { delete process.env.NUXT_LOCK // Port in use by default: an active dev server holds it. checkPort.mockResolvedValue(false) + canOpenBrowser.mockReturnValue(true) + openBrowser.mockClear() }) afterEach(async () => { @@ -174,7 +187,7 @@ describe('takeOverServer', () => { mockProcess() const prompt = vi.fn(async (_lock: LockInfo, fallback: TakeoverChoice) => fallback) const result = await takeOverServer(buildDir, { interactive: true, prompt }) - expect(prompt).toHaveBeenCalledWith(expect.objectContaining({ pid: HOLDER_PID }), 'takeover') + expect(prompt).toHaveBeenCalledWith(expect.objectContaining({ pid: HOLDER_PID }), 'takeover', 'http://127.0.0.1:3000/') expect(result).toEqual({ action: 'taken', port: 3000, pid: HOLDER_PID }) }) @@ -191,7 +204,7 @@ describe('takeOverServer', () => { const proc = mockProcess() const prompt = vi.fn(async (_lock: LockInfo, fallback: TakeoverChoice) => fallback) const result = await takeOverServer(buildDir, { interactive: true, prompt }) - expect(prompt).toHaveBeenCalledWith(expect.objectContaining({ pid: HOLDER_PID }), 'abort') + expect(prompt).toHaveBeenCalledWith(expect.objectContaining({ pid: HOLDER_PID }), 'abort', 'http://127.0.0.1:3000/') expect(result).toMatchObject({ action: 'refused', reason: 'declined' }) expect(proc.signals).toHaveLength(0) }) @@ -228,6 +241,46 @@ describe('takeOverServer', () => { }) }) + describe('opening the running server', () => { + it('opens it on loopback under its base url, without stopping it', async () => { + writeLock(buildDir, { interactive: true, hostname: '0.0.0.0', url: 'http://0.0.0.0:3000', baseURL: '/admin/' }) + const proc = mockProcess() + const prompt = vi.fn(async () => 'open' as const) + expect(await takeOverServer(buildDir, { interactive: true, prompt })).toMatchObject({ action: 'opened' }) + expect(openBrowser).toHaveBeenCalledWith('http://127.0.0.1:3000/admin/') + expect(prompt).toHaveBeenCalledWith(expect.anything(), 'abort', 'http://127.0.0.1:3000/admin/') + expect(proc.signals).toHaveLength(0) + expect(readLock(buildDir)?.pid).toBe(HOLDER_PID) + }) + + it('is the default with `--open`, resolving `--open.url` against the running server', async () => { + writeLock(buildDir, { interactive: false, baseURL: '/admin/' }) + const proc = mockProcess() + const prompt = vi.fn(async (_lock: LockInfo, fallback: TakeoverChoice) => fallback) + const result = await takeOverServer(buildDir, { interactive: true, open: true, openURL: 'settings', prompt }) + expect(result).toMatchObject({ action: 'opened' }) + expect(openBrowser).toHaveBeenCalledWith('http://127.0.0.1:3000/admin/settings') + expect(prompt).toHaveBeenCalledWith(expect.anything(), 'open', 'http://127.0.0.1:3000/admin/settings') + expect(proc.signals).toHaveLength(0) + }) + + it.each([ + ['the server has no url yet', { url: undefined }, true], + ['the url names another machine', { url: 'http://203.0.113.7:3000' }, true], + ['there is no browser to open', {}, false], + ])('is not offered when %s', async (_, lock: Partial, browser) => { + writeLock(buildDir, { interactive: true, ...lock }) + canOpenBrowser.mockReturnValue(browser) + const proc = mockProcess() + const prompt = vi.fn(async () => 'open' as const) + const result = await takeOverServer(buildDir, { interactive: true, open: true, prompt }) + expect(result).toMatchObject({ action: 'refused', reason: 'declined' }) + expect(openBrowser).not.toHaveBeenCalled() + expect(proc.signals).toHaveLength(0) + expect(prompt).toHaveBeenCalledWith(expect.anything(), 'abort', undefined) + }) + }) + describe('performing the takeover', () => { it('marks the lock so the outgoing process can explain itself', async () => { writeLock(buildDir) diff --git a/packages/nuxt-cli/test/unit/terminal-output.spec.ts b/packages/nuxt-cli/test/unit/terminal-output.spec.ts index 091e087b0..1169010d0 100644 --- a/packages/nuxt-cli/test/unit/terminal-output.spec.ts +++ b/packages/nuxt-cli/test/unit/terminal-output.spec.ts @@ -216,10 +216,7 @@ describe('dev server terminal output', () => { it('should say so when the browser launcher fails', async () => { const restore = stubDisplay({ browser: '/definitely/not/a/launcher' }) - const renderer = await render(async ({ waitForOutput }) => { - openBrowser('http://localhost:3000/') - await waitForOutput('Could not open') - }) + const renderer = await render(() => openBrowser('http://localhost:3000/')) restore() expect(screen(renderer)).toContain('Could not open http://localhost:/ in a browser.') @@ -229,10 +226,7 @@ describe('dev server terminal output', () => { // A launcher that fails, without depending on a platform-specific binary. const restore = stubDisplay({ browser: process.execPath, browserArgs: '-e process.exit(1)' }) - const renderer = await render(async ({ waitForOutput }) => { - openBrowser('http://localhost:3000/') - await waitForOutput('Could not open') - }) + const renderer = await render(() => openBrowser('http://localhost:3000/')) restore() expect(screen(renderer)).toContain('Could not open http://localhost:/ in a browser.') diff --git a/packages/nuxt-cli/test/unit/utils/untrusted-lock.spec.ts b/packages/nuxt-cli/test/unit/utils/untrusted-lock.spec.ts index 1217757e0..91e1be518 100644 --- a/packages/nuxt-cli/test/unit/utils/untrusted-lock.spec.ts +++ b/packages/nuxt-cli/test/unit/utils/untrusted-lock.spec.ts @@ -78,6 +78,12 @@ describe('parseLockInfo', () => { expect(parseLockInfo(baseLock({ url: 'http://localhost:3000' }))?.url).toBe('http://localhost:3000') }) + it('should drop a base url that is not a path', () => { + expect(parseLockInfo(baseLock({ baseURL: 'https://attacker.example/' }))?.baseURL).toBeUndefined() + expect(parseLockInfo(baseLock({ baseURL: '//attacker.example/' }))?.baseURL).toBeUndefined() + expect(parseLockInfo(baseLock({ baseURL: '/admin/' }))?.baseURL).toBe('/admin/') + }) + it('should strip control characters from displayed strings', () => { const info = parseLockInfo(baseLock({ cwd: '/tmp/\u001B[2Jproject\u0007' })) expect(info?.cwd).toBe('/tmp/[2Jproject')