feat(preview): track and allow taking over another preview process (#… · nuxt/cli@435c41f · GitHub
Skip to content

Commit 435c41f

Browse files
feat(preview): track and allow taking over another preview process (#1572)
1 parent 4d0d3e9 commit 435c41f

12 files changed

Lines changed: 672 additions & 91 deletions

File tree

‎docs/preview.md‎

Lines changed: 21 additions & 1 deletion

‎packages/nuxt-cli/src/commands/dev.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ import { preflight } from '../dev/preflight'
2020
import { formatRestartReason } from '../dev/reason'
2121
import { devShortcutContext } from '../dev/shortcut-context'
2222
import { handleShutdownSignals } from '../dev/shutdown'
23-
import { formatTakeoverRefusal, takeOverDevServer } from '../dev/takeover'
23+
import { formatTakeoverRefusal, takeOverServer } from '../dev/takeover'
2424
import { beginDevUI, setupDevUI, teardownDevUI } from '../dev/tui/controller'
2525
import { replaceCwdArg } from '../utils/args'
2626
import { resolveLockDir } from '../utils/dev-server'
@@ -183,7 +183,7 @@ const command = defineCommand({
183183

184184
const buildDir = await beforeServing(() => resolveLockDir(cwd))
185185

186-
const takeover = await beforeServing(() => takeOverDevServer(buildDir, {
186+
const takeover = await beforeServing(() => takeOverServer(buildDir, {
187187
requestedPort: parsePort(listenOverrides.port),
188188
takeover: ctx.args.takeover,
189189
}))

‎packages/nuxt-cli/src/commands/preview.ts‎

Lines changed: 100 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -6,18 +6,27 @@ import { styleText } from 'node:util'
66
import { box } from '@clack/prompts'
77
import { tokenizeArgs } from 'args-tokenizer'
88
import { defineCommand } from 'citty'
9+
import { checkPort } from 'get-port-please'
910
import { resolve } from 'pathe'
1011
import { x } from 'tinyexec'
1112

13+
import { parsePort, resolvePort } from '../dev/listen'
14+
import { formatTakeoverRefusal, takeOverServer } from '../dev/takeover'
1215
import { resolveDotenvFileNames } from '../utils/args'
16+
import { ActionableError } from '../utils/errors'
1317
import { loadKit } from '../utils/kit'
18+
import { acquireLock, previewLockDir, updateLock } from '../utils/lockfile'
1419
import { logger, outro } from '../utils/logger'
1520
import { withPrependedPath } from '../utils/path-env'
1621
import { relativeToProcess, resolveRootDir } from '../utils/paths'
1722
import { resolveServerBuild } from '../utils/server-build'
1823
import { findStaticEntry, formatServerURL, previewStaticOutput } from '../utils/static-preview'
1924
import { dotEnvArgs, envNameArgs, extendsArgs, logLevelArgs, rootDirArgs } from './_shared'
2025

26+
// Other preview commands (such as `wrangler dev`) choose their own port.
27+
const PORT_AWARE_RUNTIMES = new Set(['node', 'bun', 'deno'])
28+
const DEFAULT_PORT = 3000
29+
2130
const command = defineCommand({
2231
meta: {
2332
name: 'preview',
@@ -40,6 +49,16 @@ const command = defineCommand({
4049
valueHint: 'host',
4150
alias: ['h'],
4251
},
52+
takeover: {
53+
type: 'boolean',
54+
description: 'Stop a preview server already running on this project and take its place',
55+
negativeDescription: 'Never stop a preview server already running on this project',
56+
},
57+
strictPort: {
58+
type: 'boolean',
59+
description: 'Exit if the requested port is unavailable instead of using another one',
60+
default: false,
61+
},
4362
...dotEnvArgs,
4463
},
4564
async run(ctx) {
@@ -106,6 +125,31 @@ const command = defineCommand({
106125
|| process.env.NITRO_HOST
107126
|| process.env.HOST
108127

128+
async function claimPort(): Promise<number> {
129+
const requestedPort = parsePort(port)
130+
const takeover = await takeOverServer(previewLockDir(cwd), {
131+
command: 'preview',
132+
requestedPort,
133+
takeover: ctx.args.takeover,
134+
})
135+
if (takeover.action === 'refused') {
136+
logger.error(formatTakeoverRefusal(takeover.existing, takeover.reason))
137+
process.exit(1)
138+
}
139+
if (takeover.action === 'taken') {
140+
return takeover.port
141+
}
142+
143+
const listenPort = requestedPort ?? DEFAULT_PORT
144+
if (!ctx.args.strictPort) {
145+
return resolvePort(listenPort, host || '')
146+
}
147+
if (listenPort !== 0 && await checkPort(listenPort, host || undefined) === false) {
148+
throw new ActionableError(`Port ${listenPort} is already in use (\`--strictPort\` is enabled).`)
149+
}
150+
return listenPort
151+
}
152+
109153
let previewCommand: string | undefined
110154
let outputPath: string | undefined
111155
let target: readonly [label: string, value: string] | undefined
@@ -154,7 +198,9 @@ const command = defineCommand({
154198
const entry = findStaticEntry(dir)
155199
if (entry) {
156200
logger.info(`This build has no server, so ${styleText('cyan', relativeToProcess(dir))} is being served statically.`)
157-
const server = await previewStaticOutput({ dir, entry, port, hostname: host })
201+
const listenPort = await claimPort()
202+
const server = await previewStaticOutput({ dir, entry, port: String(listenPort), hostname: host })
203+
recordPreview(cwd, listenPort, host)
158204
outro(`Previewing ${styleText('cyan', relativeToProcess(dir))} at ${styleText('cyan', formatServerURL(server.url))}`)
159205
return
160206
}
@@ -174,6 +220,10 @@ const command = defineCommand({
174220
// `outputPath` is set whenever a preview command was found.
175221
const previewDir = outputPath!
176222

223+
const [command, ...commandArgs] = tokenizeArgs(previewCommand) as [string, ...string[]]
224+
const listenPort = PORT_AWARE_RUNTIMES.has(command) ? await claimPort() : undefined
225+
const serverPort = listenPort === undefined ? port : String(listenPort)
226+
177227
const info = [
178228
['Node.js:', `v${process.versions.node}`],
179229
...(target ? [target] : []),
@@ -227,25 +277,57 @@ const command = defineCommand({
227277

228278
outro(`Running ${styleText('cyan', previewCommand)} in ${styleText('cyan', relativeToProcess(previewDir))}`)
229279

230-
const [command, ...commandArgs] = tokenizeArgs(previewCommand) as [string, ...string[]]
231-
await x(command, commandArgs, {
232-
throwOnError: true,
233-
nodeOptions: {
234-
stdio: 'inherit',
235-
cwd: previewDir,
236-
env: {
237-
...withPrependedPath(process.env, [
238-
resolve(previewDir, 'node_modules/.bin'),
239-
resolve(cwd, 'node_modules/.bin'),
240-
]),
241-
NUXT_PORT: port,
242-
NITRO_PORT: port,
243-
NUXT_HOST: host,
244-
NITRO_HOST: host,
280+
const previewLock = listenPort === undefined ? undefined : recordPreview(cwd, listenPort, host)
281+
try {
282+
const server = x(command, commandArgs, {
283+
throwOnError: true,
284+
nodeOptions: {
285+
stdio: 'inherit',
286+
cwd: previewDir,
287+
env: {
288+
...withPrependedPath(process.env, [
289+
resolve(previewDir, 'node_modules/.bin'),
290+
resolve(cwd, 'node_modules/.bin'),
291+
]),
292+
NUXT_PORT: serverPort,
293+
NITRO_PORT: serverPort,
294+
NUXT_HOST: host,
295+
NITRO_HOST: host,
296+
},
245297
},
246-
},
247-
})
298+
})
299+
if (server.pid) {
300+
previewLock?.update(server.pid)
301+
}
302+
await server
303+
}
304+
finally {
305+
previewLock?.release()
306+
}
248307
},
249308
})
250309

251310
export default command
311+
312+
function recordPreview(rootDir: string, port: number, hostname: string | undefined): { update: (serverPid: number) => void, release: () => void } | undefined {
313+
if (port === 0) {
314+
return
315+
}
316+
const lockDir = previewLockDir(rootDir)
317+
const host = hostname || 'localhost'
318+
const info = {
319+
command: 'preview' as const,
320+
cwd: rootDir,
321+
port,
322+
hostname,
323+
url: `http://${host.includes(':') && !host.startsWith('[') ? `[${host}]` : host}:${port}`,
324+
}
325+
const { release } = acquireLock(lockDir, info)
326+
if (!release) {
327+
return
328+
}
329+
return {
330+
release,
331+
update: serverPid => updateLock(lockDir, { ...info, serverPid }),
332+
}
333+
}

‎packages/nuxt-cli/src/dev/listen.ts‎

Lines changed: 1 addition & 1 deletion

0 commit comments

Comments
 (0)