Skip to content

Commit bf72c89

Browse files
committed
feat(dev): add restart, url, qr and copy shortcuts
1 parent f693d33 commit bf72c89

5 files changed

Lines changed: 339 additions & 61 deletions

File tree

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

Lines changed: 9 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -4,16 +4,16 @@ import type { DevListenOverrides } from '../dev/listen'
44
import type { NuxtDevContext } from '../dev/utils'
55

66
import process from 'node:process'
7-
import { createInterface } from 'node:readline'
87

98
import { defineCommand } from 'citty'
109
import { resolve } from 'pathe'
1110
import colors from 'picocolors'
12-
import { isBun, isCI, isTest } from 'std-env'
11+
import { isBun, isTest } from 'std-env'
1312
import { satisfies } from 'verkit'
1413

1514
import { initialize } from '../dev'
1615
import { ForkPool } from '../dev/pool'
16+
import { setupShortcuts } from '../dev/shortcuts'
1717
import { debug, logger } from '../utils/logger'
1818
import { cwdArgs, dotEnvArgs, envNameArgs, extendsArgs, legacyRootDirArgs, logLevelArgs, profileArgs } from './_shared'
1919

@@ -135,15 +135,15 @@ const command = defineCommand({
135135
const listenOverrides = resolveListenOverrides(ctx.args)
136136

137137
// Start the initial dev server in-process with listener
138-
const { listener, close, onRestart, onReady } = await initialize({ cwd, args: ctx.args }, {
138+
const { listener, close, reload, onRestart, onReady } = await initialize({ cwd, args: ctx.args }, {
139139
data: ctx.data,
140140
listenOverrides,
141141
showBanner: true,
142142
})
143143

144144
// Disable forking when profiling to capture all activity in one process
145145
if (!ctx.args.fork || ctx.args.profile) {
146-
setupQuitShortcut(close, onReady)
146+
setupShortcuts({ listener, close, onReady, restart: () => reload('Restart requested') })
147147
return {
148148
listener,
149149
close,
@@ -192,18 +192,20 @@ const command = defineCommand({
192192
})
193193
}
194194

195-
onRestart(async () => {
195+
async function restart() {
196196
// Close the in-process dev server
197197
await close()
198198
await restartWithFork()
199-
})
199+
}
200+
201+
onRestart(restart)
200202

201203
async function closeAll() {
202204
cleanupCurrentFork?.()
203205
await close()
204206
}
205207

206-
setupQuitShortcut(closeAll, onReady)
208+
setupShortcuts({ listener, close: closeAll, onReady, restart })
207209

208210
return {
209211
close: closeAll,
@@ -215,35 +217,6 @@ export default command
215217

216218
// --- Internal ---
217219

218-
function setupQuitShortcut(close: () => Promise<void>, onReady: (callback: (address: string) => void) => void) {
219-
if (!process.stdin.isTTY || isCI || isTest) {
220-
return
221-
}
222-
223-
onReady(() => {
224-
// eslint-disable-next-line no-console
225-
console.log(`\n ${colors.dim('press')} ${colors.bold('q + enter')} ${colors.dim('to quit')}\n`)
226-
})
227-
228-
// No output stream is passed, so readline stays in non-terminal mode and
229-
// does not intercept Ctrl-C or put stdin into raw mode
230-
const rl = createInterface({ input: process.stdin })
231-
rl.on('line', async (line) => {
232-
if (!['q', 'quit', 'exit'].includes(line.trim().toLowerCase())) {
233-
return
234-
}
235-
rl.close()
236-
try {
237-
await close()
238-
}
239-
catch (error) {
240-
console.error(error)
241-
process.exitCode = 1
242-
}
243-
process.exit()
244-
})
245-
}
246-
247220
type ArgsT = Exclude<
248221
Awaited<typeof command.args>,
249222
undefined | ((...args: unknown[]) => unknown)

packages/nuxt-cli/src/dev/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,8 @@ const ipc = new IPC()
4949
interface InitializeReturn {
5050
listener: Listener
5151
close: () => Promise<void>
52+
/** Reload Nuxt in place, keeping the current listener. */
53+
reload: (reason?: string) => Promise<void>
5254
onReady: (callback: (address: string) => void) => void
5355
onRestart: (callback: (devServer: NuxtDevServer) => void) => void
5456
}
@@ -134,6 +136,7 @@ export async function initialize(devContext: NuxtDevContext, ctx: InitializeOpti
134136

135137
return {
136138
listener: devServer.listener,
139+
reload: (reason?: string) => devServer.load(true, reason),
137140
close: () => {
138141
closePromise ??= (async () => {
139142
devServer.closeWatchers()

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

Lines changed: 46 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -50,11 +50,17 @@ export interface DevListenOverrides extends ListenOptions {
5050

5151
export interface Listener {
5252
url: string
53+
/** Explicit public URL, or the tunnel or portless URL when there is one. */
54+
publicURL?: string
55+
/** URL the startup QR code was (or would have been) rendered for. */
56+
qrURL?: string
5357
address: AddressInfo
5458
server: HttpServer
5559
https: false | ResolvedCertificate
5660
close: () => Promise<void>
5761
getURLs: () => ListenURL[]
62+
/** Reprint the URL block, optionally flagging which URL a QR code refers to. */
63+
showURLs: (options?: { qr?: boolean }) => void
5864
}
5965

6066
const ANY_HOSTS = new Set(['', '0.0.0.0', '::'])
@@ -160,47 +166,41 @@ export async function listen(handler: RequestListener, options: ListenOptions =
160166

161167
const publicURL = options.publicURL || tunnel?.url || portlessShareURL || portlessURL
162168

163-
if (options.showURL !== false) {
164-
const urls = getURLs()
165-
const qrURL = options.qr === false
166-
? undefined
167-
: publicURL
168-
|| urls.find(({ type }) => type === 'network')?.url
169-
|| (options.qr ? url : undefined)
170-
171-
if (qrURL) {
172-
const { renderUnicodeCompact } = await import('uqr')
173-
// eslint-disable-next-line no-console
174-
console.log(`\n${centerBlock(renderUnicodeCompact(qrURL))}\n`)
175-
}
169+
const qrURL = options.qr === false
170+
? undefined
171+
: publicURL
172+
|| getURLs().find(({ type }) => type === 'network')?.url
173+
|| (options.qr ? url : undefined)
176174

175+
function showURLs({ qr = false }: { qr?: boolean } = {}): void {
176+
const urls = getURLs()
177177
const labels = { local: 'Local:', network: 'Network:', tunnel: 'Tunnel:', public: 'Public:' } as const
178178
const labelColors = { local: colors.green, network: colors.magenta, tunnel: colors.cyan, public: colors.magenta } as const
179179
const lines: string[] = []
180180
const line = (color: (text: string) => string, label: string, value: string, isQR: boolean) =>
181181
` ${color('➜')} ${colors.bold(color(label.padEnd(10)))}${value}${isQR ? colors.gray(' [QR code]') : ''}`
182182
for (const { url: displayURL, type } of urls) {
183-
lines.push(line(labelColors[type], labels[type], colors.cyan(displayURL), displayURL === qrURL))
183+
lines.push(line(labelColors[type], labels[type], colors.cyan(displayURL), qr && displayURL === qrURL))
184184
}
185185
if (!anyHost && !tunnel && !portless.url) {
186186
lines.push(line(colors.magenta, 'Network:', colors.gray(`use ${colors.white('--host')} to expose`), false))
187187
}
188188
if (publicURL && publicURL !== url && !urls.some(entry => entry.url === publicURL)) {
189-
lines.push(line(colors.magenta, 'Public:', colors.cyan(publicURL), publicURL === qrURL))
189+
lines.push(line(colors.magenta, 'Public:', colors.cyan(publicURL), qr && publicURL === qrURL))
190190
}
191191
// eslint-disable-next-line no-console
192-
console.log(`${qrURL ? '' : '\n'}${lines.join('\n')}\n`)
192+
console.log(`${qr ? '' : '\n'}${lines.join('\n')}\n`)
193193
}
194194

195-
if (options.clipboard) {
196-
try {
197-
const { writeText } = await import('tinyclip')
198-
await writeText(publicURL || url)
199-
logger.info('URL copied to clipboard.')
200-
}
201-
catch (error) {
202-
debug('Failed to copy URL to clipboard:', error)
195+
if (options.showURL !== false) {
196+
if (qrURL) {
197+
await printQRCode(qrURL)
203198
}
199+
showURLs({ qr: !!qrURL })
200+
}
201+
202+
if (options.clipboard) {
203+
await copyURL(publicURL || url)
204204
}
205205

206206
if (options.open) {
@@ -209,10 +209,13 @@ export async function listen(handler: RequestListener, options: ListenOptions =
209209

210210
return {
211211
url,
212+
publicURL,
213+
qrURL,
212214
address,
213215
server,
214216
https: certificate,
215217
getURLs,
218+
showURLs,
216219
close: async () => {
217220
await tunnel?.close()
218221
return new Promise<void>((resolve, reject) => {
@@ -247,6 +250,24 @@ async function resolvePort(requestedPort: number | undefined, hostname: string,
247250
return port
248251
}
249252

253+
export async function printQRCode(url: string): Promise<void> {
254+
const { renderUnicodeCompact } = await import('uqr')
255+
// eslint-disable-next-line no-console
256+
console.log(`\n${centerBlock(renderUnicodeCompact(url))}\n`)
257+
}
258+
259+
export async function copyURL(url: string): Promise<void> {
260+
try {
261+
const { writeText } = await import('tinyclip')
262+
await writeText(url)
263+
logger.info('URL copied to clipboard.')
264+
}
265+
catch (error) {
266+
debug('Failed to copy URL to clipboard:', error)
267+
logger.warn('Could not copy the URL to the clipboard.')
268+
}
269+
}
270+
250271
function centerBlock(block: string): string {
251272
const lines = block.split('\n')
252273
const width = Math.max(...lines.map(line => line.length))
@@ -303,7 +324,7 @@ export function resolveOpenCommand(
303324
return ['xdg-open', [url]]
304325
}
305326

306-
function openBrowser(url: string): void {
327+
export function openBrowser(url: string): void {
307328
const resolved = resolveOpenCommand(url)
308329
if (!resolved) {
309330
return
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
import type { Listener } from './listen'
2+
3+
import process from 'node:process'
4+
import { createInterface } from 'node:readline'
5+
6+
import colors from 'picocolors'
7+
import { isCI, isTest } from 'std-env'
8+
9+
import { copyURL, openBrowser, printQRCode } from './listen'
10+
11+
export interface ShortcutContext {
12+
listener: Listener
13+
close: () => Promise<void>
14+
restart?: () => void | Promise<void>
15+
onReady: (callback: (address: string) => void) => void
16+
}
17+
18+
interface ActionContext extends ShortcutContext {
19+
/** Stop reading shortcuts, so a quitting server does not keep stdin open. */
20+
closeInput: () => void
21+
}
22+
23+
interface Shortcut {
24+
keys: string[]
25+
description: string
26+
isAvailable?: (context: ShortcutContext) => boolean
27+
action: (context: ActionContext) => void | Promise<void>
28+
}
29+
30+
const shortcuts: Shortcut[] = [
31+
{
32+
keys: ['r', 'restart'],
33+
description: 'restart the dev server',
34+
isAvailable: context => !!context.restart,
35+
action: context => context.restart?.(),
36+
},
37+
{
38+
keys: ['o', 'open'],
39+
description: 'open in browser',
40+
action: context => openBrowser(context.listener.url),
41+
},
42+
{
43+
keys: ['u', 'urls'],
44+
description: 'show server URLs',
45+
action: context => context.listener.showURLs(),
46+
},
47+
{
48+
keys: ['qr'],
49+
description: 'show a QR code for the server URL',
50+
action: context => printQRCode(resolveShareableURL(context.listener)),
51+
},
52+
{
53+
keys: ['copy'],
54+
description: 'copy the server URL to the clipboard',
55+
action: context => copyURL(resolveShareableURL(context.listener)),
56+
},
57+
{
58+
keys: ['c', 'clear'],
59+
description: 'clear the console',
60+
action: (context) => {
61+
process.stdout.write('\u001B[2J\u001B[3J\u001B[H')
62+
context.listener.showURLs()
63+
},
64+
},
65+
{
66+
keys: ['q', 'quit', 'exit'],
67+
description: 'quit',
68+
action: quit,
69+
},
70+
{
71+
keys: ['h', 'help', '?'],
72+
description: 'show this help',
73+
action: printHelp,
74+
},
75+
]
76+
77+
/** The URL most likely to work on another device, for QR codes and sharing. */
78+
function resolveShareableURL(listener: Listener): string {
79+
return listener.qrURL
80+
|| listener.publicURL
81+
|| listener.getURLs().find(({ type }) => type === 'network')?.url
82+
|| listener.url
83+
}
84+
85+
async function quit(context: ActionContext): Promise<void> {
86+
context.closeInput()
87+
try {
88+
await context.close()
89+
}
90+
catch (error) {
91+
console.error(error)
92+
process.exitCode = 1
93+
}
94+
process.exit()
95+
}
96+
97+
function printHelp(context: ActionContext): void {
98+
const lines = availableShortcuts(context).map(({ keys, description }) =>
99+
` ${colors.dim('press')} ${colors.bold(`${keys[0]} + enter`)} ${colors.dim(`to ${description}`)}`,
100+
)
101+
// eslint-disable-next-line no-console
102+
console.log(`\n${lines.join('\n')}\n`)
103+
}
104+
105+
function availableShortcuts(context: ShortcutContext): Shortcut[] {
106+
return shortcuts.filter(shortcut => shortcut.isAvailable?.(context) !== false)
107+
}
108+
109+
/**
110+
* Bind `<command> + enter` shortcuts to stdin.
111+
*
112+
* No output stream is passed to readline, so it stays in non-terminal mode and
113+
* does not intercept Ctrl-C or put stdin into raw mode.
114+
*/
115+
export function setupShortcuts(context: ShortcutContext): void {
116+
if (!process.stdin.isTTY || isCI || isTest) {
117+
return
118+
}
119+
120+
context.onReady(() => {
121+
// eslint-disable-next-line no-console
122+
console.log(`\n ${colors.dim('press')} ${colors.bold('h + enter')} ${colors.dim('to see available shortcuts')}\n`)
123+
})
124+
125+
const rl = createInterface({ input: process.stdin })
126+
rl.on('line', async (line) => {
127+
const input = line.trim().toLowerCase()
128+
const shortcut = availableShortcuts(context).find(({ keys }) => keys.includes(input))
129+
if (!shortcut) {
130+
return
131+
}
132+
try {
133+
await shortcut.action({ ...context, closeInput: () => rl.close() })
134+
}
135+
catch (error) {
136+
console.error(error)
137+
}
138+
})
139+
}

0 commit comments

Comments
 (0)