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
importtype{NuxtDevContext}from'../dev/utils'
55

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

98
import{defineCommand}from'citty'
109
import{resolve}from'pathe'
1110
importcolorsfrom'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
constlistenOverrides=resolveListenOverrides(ctx.args)
136136

137137
// Start the initial dev server in-process with listener
138-
const{ listener, close, onRestart, onReady }=awaitinitialize({ cwd,args: ctx.args},{
138+
const{ listener, close,reload,onRestart, onReady }=awaitinitialize({ 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+
asyncfunctionrestart(){
196196
// Close the in-process dev server
197197
awaitclose()
198198
awaitrestartWithFork()
199-
})
199+
}
200+
201+
onRestart(restart)
200202

201203
asyncfunctioncloseAll(){
202204
cleanupCurrentFork?.()
203205
awaitclose()
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-
functionsetupQuitShortcut(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-
constrl=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-
awaitclose()
238-
}
239-
catch(error){
240-
console.error(error)
241-
process.exitCode=1
242-
}
243-
process.exit()
244-
})
245-
}
246-
247220
typeArgsT=Exclude<
248221
Awaited<typeofcommand.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
interfaceInitializeReturn{
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
exportinterfaceListener{
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
constANY_HOSTS=newSet(['','0.0.0.0','::'])
@@ -160,47 +166,41 @@ export async function listen(handler: RequestListener, options: ListenOptions =
160166

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

163-
if(options.showURL!==false){
164-
consturls=getURLs()
165-
constqrURL=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 }=awaitimport('uqr')
173-
// eslint-disable-next-line no-console
174-
console.log(`\n${centerBlock(renderUnicodeCompact(qrURL))}\n`)
175-
}
169+
constqrURL=options.qr===false
170+
? undefined
171+
: publicURL
172+
||getURLs().find(({ type })=>type==='network')?.url
173+
||(options.qr ? url : undefined)
176174

175+
functionshowURLs({ qr =false}: {qr?: boolean}={}): void{
176+
consturls=getURLs()
177177
constlabels={local: 'Local:',network: 'Network:',tunnel: 'Tunnel:',public: 'Public:'}asconst
178178
constlabelColors={local: colors.green,network: colors.magenta,tunnel: colors.cyan,public: colors.magenta}asconst
179179
constlines: string[]=[]
180180
constline=(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 }ofurls){
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 }=awaitimport('tinyclip')
198-
awaitwriteText(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+
awaitprintQRCode(qrURL)
203198
}
199+
showURLs({qr: !!qrURL})
200+
}
201+
202+
if(options.clipboard){
203+
awaitcopyURL(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
awaittunnel?.close()
218221
returnnewPromise<void>((resolve,reject)=>{
@@ -247,6 +250,24 @@ async function resolvePort(requestedPort: number | undefined, hostname: string,
247250
returnport
248251
}
249252

253+
exportasyncfunctionprintQRCode(url: string): Promise<void>{
254+
const{ renderUnicodeCompact }=awaitimport('uqr')
255+
// eslint-disable-next-line no-console
256+
console.log(`\n${centerBlock(renderUnicodeCompact(url))}\n`)
257+
}
258+
259+
exportasyncfunctioncopyURL(url: string): Promise<void>{
260+
try{
261+
const{ writeText }=awaitimport('tinyclip')
262+
awaitwriteText(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
functioncenterBlock(block: string): string{
251272
constlines=block.split('\n')
252273
constwidth=Math.max(...lines.map(line=>line.length))
@@ -303,7 +324,7 @@ export function resolveOpenCommand(
303324
return['xdg-open',[url]]
304325
}
305326

306-
functionopenBrowser(url: string): void{
327+
exportfunctionopenBrowser(url: string): void{
307328
constresolved=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+
importtype{Listener}from'./listen'
2+
3+
importprocessfrom'node:process'
4+
import{createInterface}from'node:readline'
5+
6+
importcolorsfrom'picocolors'
7+
import{isCI,isTest}from'std-env'
8+
9+
import{copyURL,openBrowser,printQRCode}from'./listen'
10+
11+
exportinterfaceShortcutContext{
12+
listener: Listener
13+
close: ()=>Promise<void>
14+
restart?: ()=>void|Promise<void>
15+
onReady: (callback: (address: string)=>void)=>void
16+
}
17+
18+
interfaceActionContextextendsShortcutContext{
19+
/** Stop reading shortcuts, so a quitting server does not keep stdin open. */
20+
closeInput: ()=>void
21+
}
22+
23+
interfaceShortcut{
24+
keys: string[]
25+
description: string
26+
isAvailable?: (context: ShortcutContext)=>boolean
27+
action: (context: ActionContext)=>void|Promise<void>
28+
}
29+
30+
constshortcuts: 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+
functionresolveShareableURL(listener: Listener): string{
79+
returnlistener.qrURL
80+
||listener.publicURL
81+
||listener.getURLs().find(({ type })=>type==='network')?.url
82+
||listener.url
83+
}
84+
85+
asyncfunctionquit(context: ActionContext): Promise<void>{
86+
context.closeInput()
87+
try{
88+
awaitcontext.close()
89+
}
90+
catch(error){
91+
console.error(error)
92+
process.exitCode=1
93+
}
94+
process.exit()
95+
}
96+
97+
functionprintHelp(context: ActionContext): void{
98+
constlines=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+
functionavailableShortcuts(context: ShortcutContext): Shortcut[]{
106+
returnshortcuts.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+
exportfunctionsetupShortcuts(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+
constrl=createInterface({input: process.stdin})
126+
rl.on('line',async(line)=>{
127+
constinput=line.trim().toLowerCase()
128+
constshortcut=availableShortcuts(context).find(({ keys })=>keys.includes(input))
129+
if(!shortcut){
130+
return
131+
}
132+
try{
133+
awaitshortcut.action({ ...context,closeInput: ()=>rl.close()})
134+
}
135+
catch(error){
136+
console.error(error)
137+
}
138+
})
139+
}

0 commit comments

Comments
 (0)