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
export interface ListenOptions {
@@ -32,7 +33,7 @@ export interface ListenOptions {
3233

3334
interface ListenURL {
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 = await startTunnel(`${protocol}://localhost:${address.port}`, !!certificate)
123124
}
124125

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

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

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

161-
const labels = { local: 'Local:', network: 'Network:', tunnel: 'Tunnel:' } as const
162-
const labelColors = { local: colors.green, network: colors.magenta, tunnel: colors.cyan } as const
169+
const labels = { local: 'Local:', network: 'Network:', tunnel: 'Tunnel:', public: 'Public:' } as const
170+
const labelColors = { local: colors.green, network: colors.magenta, tunnel: colors.cyan, public: colors.magenta } as const
163171
const lines: string[] = []
164172
const line = (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 } of urls) {
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+
import process from 'node:process'
2+
3+
export interface PortlessURLs {
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+
export function resolvePortlessURLs(env: NodeJS.ProcessEnv = process.env): PortlessURLs {
21+
const url = normalize(env.PORTLESS_URL)
22+
const shareURL = normalize(env.PORTLESS_NGROK_URL) || normalize(env.PORTLESS_TAILSCALE_URL)
23+
return {
24+
url,
25+
shareURL,
26+
all: [...new Set([url, shareURL].filter(Boolean) as string[])],
27+
}
28+
}
29+
30+
function normalize(value: string | undefined): string | undefined {
31+
if (!value) {
32+
return undefined
33+
}
34+
try {
35+
return new URL(value).origin
36+
}
37+
catch {
38+
return undefined
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
export type NuxtParentIPCMessage
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+
const portlessOrigins = resolvePortlessURLs().all
633+
if (portlessOrigins.length > 0) {
634+
defaultConfig.devServer = defu(defaultConfig.devServer, { cors: { origin: portlessOrigins } })
635+
}
636+
629637
return defaultConfig
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+
const result = 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+
const result = 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+
const result = 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)