Skip to content

Commit a0eace5

Browse files
committed
feat(dev): detect portless proxy urls
#1300
1 parent 0027fb2 commit a0eace5

4 files changed

Lines changed: 97 additions & 7 deletions

File tree

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

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import colors from 'picocolors'
1414

1515
import{debug,logger}from'../utils/logger'
1616
import{resolveCertificate}from'./cert'
17+
import{resolvePortlessURLs}from'./portless'
1718
import{startTunnel}from'./tunnel'
1819

1920
exportinterfaceListenOptions{
@@ -32,7 +33,7 @@ export interface ListenOptions {
3233

3334
interfaceListenURL{
3435
url: string
35-
type: 'local'|'network'|'tunnel'
36+
type: 'local'|'network'|'tunnel'|'public'
3637
}
3738

3839
/**
@@ -122,11 +123,18 @@ export async function listen(handler: RequestListener, options: ListenOptions =
122123
tunnel=awaitstartTunnel(`${protocol}://localhost:${address.port}`,!!certificate)
123124
}
124125

126+
constportless=resolvePortlessURLs()
127+
constportlessURL=portless.url&&portless.url+baseURL
128+
constportlessShareURL=portless.shareURL&&portless.shareURL+baseURL
129+
125130
functiongetURLs(): ListenURL[]{
126131
consturls: ListenURL[]=[]
127132
if(tunnel){
128133
urls.push({url: tunnel.url,type: 'tunnel'})
129134
}
135+
for(constportlessURLofportless.all){
136+
urls.push({url: portlessURL+baseURL,type: 'public'})
137+
}
130138
if(anyHost){
131139
urls.push({url: formatURL('localhost'),type: 'local'})
132140
for(constinfoofObject.values(networkInterfaces()).flat()){
@@ -142,7 +150,7 @@ export async function listen(handler: RequestListener, options: ListenOptions =
142150
returnurls
143151
}
144152

145-
constpublicURL=options.publicURL||tunnel?.url
153+
constpublicURL=options.publicURL||tunnel?.url||portlessShareURL||portlessURL
146154

147155
if(options.showURL!==false){
148156
consturls=getURLs()
@@ -158,19 +166,19 @@ export async function listen(handler: RequestListener, options: ListenOptions =
158166
console.log(`\n${centerBlock(renderUnicodeCompact(qrURL))}\n`)
159167
}
160168

161-
constlabels={local: 'Local:',network: 'Network:',tunnel: 'Tunnel:'}asconst
162-
constlabelColors={local: colors.green,network: colors.magenta,tunnel: colors.cyan}asconst
169+
constlabels={local: 'Local:',network: 'Network:',tunnel: 'Tunnel:',public: 'Public:'}asconst
170+
constlabelColors={local: colors.green,network: colors.magenta,tunnel: colors.cyan,public: colors.magenta}asconst
163171
constlines: string[]=[]
164172
constline=(color: (text: string)=>string,label: string,value: string,isQR: boolean)=>
165173
` ${color('➜')}${colors.bold(color(label.padEnd(10)))}${value}${isQR ? colors.gray(' [QR code]') : ''}`
166174
for(const{url: displayURL, type }ofurls){
167175
lines.push(line(labelColors[type],labels[type],colors.cyan(displayURL),displayURL===qrURL))
168176
}
169-
if(!anyHost&&!tunnel){
177+
if(!anyHost&&!tunnel&&!portless.url){
170178
lines.push(line(colors.magenta,'Network:',colors.gray(`use ${colors.white('--host')} to expose`),false))
171179
}
172-
if(options.publicURL&&options.publicURL!==url){
173-
lines.push(line(colors.magenta,'Public:',colors.cyan(options.publicURL),options.publicURL===qrURL))
180+
if(publicURL&&publicURL!==url&&!urls.some(entry=>entry.url===publicURL)){
181+
lines.push(line(colors.magenta,'Public:',colors.cyan(publicURL),publicURL===qrURL))
174182
}
175183
// eslint-disable-next-line no-console
176184
console.log(`${qrURL ? '' : '\n'}${lines.join('\n')}\n`)
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
importprocessfrom'node:process'
2+
3+
exportinterfacePortlessURLs{
4+
/** Stable proxy URL for the app, e.g. `https://myapp.localhost`. */
5+
url?: string
6+
/** Publicly reachable URL, when sharing via Tailscale Funnel or ngrok. */
7+
shareURL?: string
8+
/** Every portless-provided URL, for host allowlists and CORS origins. */
9+
all: string[]
10+
}
11+
12+
/**
13+
* Resolve the URLs of the [portless](https://portless.sh) proxy, if the dev
14+
* server was started through it (`portless myapp nuxt dev`).
15+
*
16+
* portless runs as the parent process: it assigns a random `PORT`, proxies a
17+
* stable hostname to it, and passes the resulting URLs down in the environment.
18+
* Requests therefore arrive with a `Host` header we would otherwise reject.
19+
*/
20+
exportfunctionresolvePortlessURLs(env: NodeJS.ProcessEnv=process.env): PortlessURLs{
21+
consturl=normalize(env.PORTLESS_URL)
22+
constshareURL=normalize(env.PORTLESS_NGROK_URL)||normalize(env.PORTLESS_TAILSCALE_URL)
23+
return{
24+
url,
25+
shareURL,
26+
all: [...newSet([url,shareURL].filter(Boolean)asstring[])],
27+
}
28+
}
29+
30+
functionnormalize(value: string|undefined): string|undefined{
31+
if(!value){
32+
returnundefined
33+
}
34+
try{
35+
returnnewURL(value).origin
36+
}
37+
catch{
38+
returnundefined
39+
}
40+
}

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import { loadNuxtManifest, resolveNuxtManifest, writeNuxtManifest } from '../uti
3030
import{withNodePath}from'../utils/paths'
3131
import{renderError,renderErrorAnsi}from'./error'
3232
import{listen}from'./listen'
33+
import{resolvePortlessURLs}from'./portless'
3334

3435
exporttypeNuxtParentIPCMessage
3536
=|{type: 'nuxt:internal:dev:context',context: NuxtDevContext,listenOverrides: DevListenOverrides}
@@ -626,6 +627,13 @@ function resolveDevServerDefaults(listenOptions: { hostname?: string, https: boo
626627
defaultConfig.vite=defu(defaultConfig.vite,{server: {allowedHosts: [listenOptions.hostname]}})
627628
}
628629

630+
// Browser requests reach us through the portless proxy, so its origins have
631+
// to be allowed even when the server itself is bound to localhost.
632+
constportlessOrigins=resolvePortlessURLs().all
633+
if(portlessOrigins.length>0){
634+
defaultConfig.devServer=defu(defaultConfig.devServer,{cors: {origin: portlessOrigins}})
635+
}
636+
629637
returndefaultConfig
630638
}
631639

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import{describe,expect,it}from'vitest'
2+
3+
import{resolvePortlessURLs}from'../../src/dev/portless'
4+
5+
describe('resolvePortlessURLs',()=>{
6+
it('should return no urls when not running under portless',()=>{
7+
expect(resolvePortlessURLs({})).toStrictEqual({url: undefined,shareURL: undefined,all: []})
8+
})
9+
10+
it('should normalise the proxy url to an origin',()=>{
11+
constresult=resolvePortlessURLs({PORTLESS_URL: 'https://myapp.localhost/'})
12+
expect(result.url).toBe('https://myapp.localhost')
13+
expect(result.all).toStrictEqual(['https://myapp.localhost'])
14+
})
15+
16+
it('should prefer ngrok over tailscale for the share url',()=>{
17+
constresult=resolvePortlessURLs({
18+
PORTLESS_URL: 'https://myapp.localhost',
19+
PORTLESS_NGROK_URL: 'https://abc123.ngrok.app',
20+
PORTLESS_TAILSCALE_URL: 'https://devbox.tail1234.ts.net',
21+
})
22+
expect(result.shareURL).toBe('https://abc123.ngrok.app')
23+
expect(result.all).toStrictEqual(['https://myapp.localhost','https://abc123.ngrok.app'])
24+
})
25+
26+
it('should fall back to tailscale for the share url',()=>{
27+
constresult=resolvePortlessURLs({PORTLESS_TAILSCALE_URL: 'https://devbox.tail1234.ts.net:8443'})
28+
expect(result.shareURL).toBe('https://devbox.tail1234.ts.net:8443')
29+
})
30+
31+
it('should ignore malformed urls',()=>{
32+
expect(resolvePortlessURLs({PORTLESS_URL: 'myapp.localhost'}).all).toStrictEqual([])
33+
})
34+
})

0 commit comments

Comments
 (0)