Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 29 additions & 2 deletions docs/dev.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ links:

<!--dev-cmd-->
```bash [Terminal]
npx nuxt dev [ROOTDIR] [--cwd=<directory>] [--logLevel=<silent|info|verbose>] [--dotenv=<path>...] [--envName=<environment>] [-e, --extends=<layer-name>...] [--inspect] [--inspect-brk] [--tui] [--clear] [-f, --fork] [-p, --port=<port>] [--takeover] [--strictPort] [-h, --host=<host>] [-o, --open] [--open.url=<url|path>] [--clipboard] [--qr] [--tunnel] [--public] [--publicURL=<url>] [--https] [--https.cert=<path>] [--https.key=<path>] [--https.pfx=<path>] [--https.passphrase=<passphrase>] [--https.validityDays=<days>] [--https.domains=<domain>...] [--profile=<verbose>]
npx nuxt dev [ROOTDIR] [--cwd=<directory>] [--logLevel=<silent|info|verbose>] [--dotenv=<path>...] [--envName=<environment>] [-e, --extends=<layer-name>...] [--inspect] [--inspect-brk] [--tui] [--clear] [-f, --fork] [-p, --port=<port>] [--takeover] [--strictPort] [-h, --host=<host>] [-o, --open] [--open.url=<url|path>] [--clipboard] [--qr] [--tunnel=<cloudflare|opentunnel>] [--public] [--publicURL=<url>] [--https] [--https.cert=<path>] [--https.key=<path>] [--https.pfx=<path>] [--https.passphrase=<passphrase>] [--https.validityDays=<days>] [--https.domains=<domain>...] [--profile=<verbose>]
```
<!--/dev-cmd-->

Expand Down Expand Up @@ -52,7 +52,7 @@ The `dev` command starts a development server with hot module replacement at [ht
| `--open.url=<url\|path>` | | Path or URL to open instead of the dev server root |
| `--clipboard` | `false` | Copy the URL to the clipboard |
| `--qr` | | Print a QR code for the public URL (enabled by default when one is available) |
| `--tunnel` | | Expose the server via a Cloudflare quick tunnel |
| `--tunnel=<cloudflare\|opentunnel>` | | Expose the server through a public tunnel (asks which one when no value is given) |
| `--public` | | Listen on all network interfaces and allow any host to connect |
| `--publicURL=<url>` | | Public URL to display (used for QR code and clipboard) |
| `--https` | | Enable HTTPS with a locally-trusted development certificate |
Expand Down Expand Up @@ -122,6 +122,33 @@ The same lock file is how [`nuxt curl`](/docs/api/commands/curl) and [`nuxt task
Node does not read your system trust store, so requests made from Node to a server using a generated certificate will not trust it. Set `NODE_EXTRA_CA_CERTS` to the certificate authority that issued it.
::

## Tunnels

`--tunnel` gives the dev server a public HTTPS URL, so you can open it on another device or share it. You can choose between two providers:

| Provider | URL | Notes |
|--------------|--------------------------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `cloudflare` | A new `trycloudflare.com` URL on each run | Downloads `cloudflared` the first time, once you accept the Cloudflare terms. |
| `opentunnel` | The same `opentunnel.xyz` URL on each run, per project | TLS ends on your machine, so the relay can't read your traffic. Needs `@opentunnel/client` in your project. The first run creates your tunnel, which takes about 30 seconds. |

```bash [Terminal]
npx nuxt dev --tunnel=opentunnel
```

Without a value, `--tunnel` uses the provider you chose before. If you haven't chosen one yet, it asks and saves your answer as `tools.tunnel.provider` in `~/.nuxtrc`. When nobody can answer, for example in CI, it uses `cloudflare`.

OpenTunnel needs its SDK, `@opentunnel/client`, and its peer `effect` in your project. If they're missing, `nuxt dev` offers to install them as dev dependencies with your package manager. To install them yourself:

```bash [Terminal]
npm install -D @opentunnel/client@^0.4.0 effect@^4.0.0
```

OpenTunnel uses the `default` profile of the [`opentunnel` CLI](https://github.com/anomalyco/opentunnel), so both share one hostname. Each project gets its own route on that hostname. The route name comes from a random seed saved as `tools.opentunnel.seed` in `~/.nuxtrc`, so nobody can guess it from the project path.

::note
Anyone who has the URL can reach your dev server, because a tunnel URL is not authentication. OpenTunnel can't forward to an HTTPS dev server, so with `--https`, `--tunnel=opentunnel` uses `cloudflare` instead.
::

## Debugging

`--inspect` opens the Node.js inspector for your server code: server routes, middleware and server-side rendering, which run in a Nitro worker thread. It accepts an optional `[host:]port` and defaults to `127.0.0.1:9229`, so Chrome DevTools (`chrome://inspect`) and most editors find it without configuration. Your debugger reconnects automatically when the server reloads.
Expand Down
2 changes: 2 additions & 0 deletions packages/nuxt-cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -95,9 +95,11 @@
"@nuxt/kit": "^4.5.2",
"@nuxt/schema": "^4.5.2",
"@nuxt/test-utils": "^4.3.3",
"@opentunnel/client": "0.4.0",
"@speed-highlight/core": "^2.1.0",
"@types/node": "^24.19.1",
"citty": "^0.2.2",
"effect": "^4.0.2",
"giget": "^3.3.1",
"h3": "^1.15.11",
"jiti": "^2.7.0",
Expand Down
16 changes: 13 additions & 3 deletions packages/nuxt-cli/src/commands/dev.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,8 +113,9 @@ const command = defineCommand({
description: 'Print a QR code for the public URL (enabled by default when one is available)',
},
'tunnel': {
type: 'boolean',
description: 'Expose the server via a Cloudflare quick tunnel',
type: 'string',
description: 'Expose the server through a public tunnel (asks which one when no value is given)',
valueHint: 'cloudflare|opentunnel',
},
'public': {
type: 'boolean',
Expand Down Expand Up @@ -172,6 +173,7 @@ const command = defineCommand({
hidden: true,
},
},
/** Resolve startup options and run the dev server, managing forks and restarts when enabled. */
async run(ctx) {
const requestedCwd = resolveRootDir(ctx.args)
const cwd = await beforeServing(() => preflight({ cwd: requestedCwd }))
Expand All @@ -180,6 +182,14 @@ const command = defineCommand({
}

const listenOverrides = resolveListenOverrides(ctx.args)
// Resolved here, once: a bare `--tunnel` may prompt, which has to happen
// before the dev UI takes the terminal, and every fork must reuse the answer.
// Imported on demand, as it reads and writes the user `.nuxtrc`.
const tunnelFlag = ctx.args.tunnel as string | boolean | undefined
if (tunnelFlag !== undefined && tunnelFlag !== false) {
const { resolveTunnelOptions } = await import('../dev/tunnel/resolve')
listenOverrides.tunnel = await resolveTunnelOptions(tunnelFlag, cwd, listenOverrides.httpsEnabled)
}

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

Expand Down Expand Up @@ -500,6 +510,7 @@ export function parsePositiveInteger(value: string | undefined): number | undefi
return parsed
}

/** Build listener overrides from dev flags and host, port and TLS environment fallbacks. */
export function resolveListenOverrides(args: ParsedArgs<ArgsT>): DevListenOverrides {
const httpsOptions: HTTPSOptions = {
cert: args['https.cert']
Expand Down Expand Up @@ -535,7 +546,6 @@ export function resolveListenOverrides(args: ParsedArgs<ArgsT>): DevListenOverri
openURL: args['open.url'] || undefined,
clipboard: args.clipboard,
qr: args.qr,
tunnel: args.tunnel,
public: args.public,
publicURL: args.publicURL,
httpsEnabled: args.https,
Expand Down
8 changes: 4 additions & 4 deletions packages/nuxt-cli/src/dev/listen.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { Server as HttpServer, RequestListener } from 'node:http'
import type { AddressInfo } from 'node:net'
import type { HTTPSOptions, ResolvedCertificate } from './cert'
import type { Tunnel } from './tunnel'
import type { Tunnel, TunnelOptions } from './tunnel'

import { spawn } from 'node:child_process'
import { createServer as createHttpServer } from 'node:http'
Expand Down Expand Up @@ -38,7 +38,7 @@ export interface ListenOptions {
openURL?: string
clipboard?: boolean
qr?: boolean
tunnel?: boolean
tunnel?: false | TunnelOptions
public?: boolean
publicURL?: string
https?: boolean | HTTPSOptions
Expand Down Expand Up @@ -247,7 +247,7 @@ export async function createListener(bound: BoundServer, options: ListenOptions
const { server, address, hostname, https: certificate } = bound

// Set before anything that can throw, so a failure after the tunnel is up can
// still tear down the cloudflared process rather than leaking it until exit.
// still tear it down rather than leaking it until exit.
let tunnel: Tunnel | undefined

try {
Expand All @@ -268,7 +268,7 @@ export async function createListener(bound: BoundServer, options: ListenOptions

if (announce && options.tunnel) {
const { startTunnel } = await import('./tunnel')
tunnel = await startTunnel(`${protocol}://localhost:${address.port}`, !!certificate)
tunnel = await startTunnel(options.tunnel, { protocol, hostname, port: address.port, handover: options.handover })
}

const tunnelURL = tunnel?.url && tunnel.url + baseURL
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import type { Buffer } from 'node:buffer'
import type { Tunnel } from './types'

import { spawn } from 'node:child_process'
import { once } from 'node:events'
import process from 'node:process'

import { debug, logger } from '../utils/logger'
import { resolveTool } from './binaries'
import { debug, logger } from '../../utils/logger'
import { resolveTool } from '../binaries'

const TUNNEL_URL_RE = /https:\/\/(?!api\.)[\w-]+\.trycloudflare\.com/

Expand Down Expand Up @@ -34,12 +35,12 @@ export function resolveCloudflaredVersion(version = process.env.CLOUDFLARED_VERS
/** How long to wait for `cloudflared` to exit on `SIGINT` before `SIGKILL`. */
const SHUTDOWN_TIMEOUT_MS = 5000

export interface Tunnel {
url: string
close: () => Promise<void>
}

export async function startTunnel(localURL: string, insecure?: boolean): Promise<Tunnel | undefined> {
/**
* Expose `localURL` through a Cloudflare quick tunnel, returning its URL and
* shutdown handler, or `undefined` if startup fails. `insecure` disables TLS
* verification of the local server's certificate.
*/
export async function startCloudflaredTunnel(localURL: string, insecure?: boolean): Promise<Tunnel | undefined> {
const binary = await resolveCloudflared()
if (!binary) {
logger.warn('Could not find or download `cloudflared`. Install it to use `--tunnel`: https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/')
Expand Down Expand Up @@ -179,6 +180,7 @@ const CLOUDFLARED_SHA256: Record<string, string> = {
'cloudflared-windows-amd64.exe': '8635da433b6df8194746e88ed9d2589566c20e38bfc2a80e431a348b7c765841',
}

/** Find or download cloudflared for this platform, requesting consent when installation is needed. */
async function resolveCloudflared(): Promise<string | undefined> {
const CLOUDFLARED_VERSION = resolveCloudflaredVersion()
const base = CLOUDFLARED_VERSION === 'latest'
Expand Down
48 changes: 48 additions & 0 deletions packages/nuxt-cli/src/dev/tunnel/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import type { Tunnel, TunnelOptions } from './types'

import { logger } from '../../utils/logger'

export type { Tunnel, TunnelOptions } from './types'

export interface TunnelTarget {
protocol: 'http' | 'https'
/** The address the dev server is bound to: empty, `0.0.0.0` or `::` for every interface. */
hostname: string
port: number
/** The server is taking over from a process that still serves the port. */
handover?: boolean
}

const ANY_HOSTS = new Set(['', '0.0.0.0', '::'])

/**
* Where the tunnel reaches the dev server, as `host:port`: `localhost` when it
* listens on every interface, and otherwise the one address it is bound to, as
* `localhost` may not resolve to it.
*/
export function formatTunnelTarget({ hostname, port }: Pick<TunnelTarget, 'hostname' | 'port'>): string {
const host = ANY_HOSTS.has(hostname) ? 'localhost' : hostname
return `${host.includes(':') ? `[${host}]` : host}:${port}`
}

/**
* Start the tunnel `options` asks for, in front of the dev server at `target`.
* Each provider is imported on demand: `cloudflared` pulls in the download and
* consent prompts, and OpenTunnel loads its SDK from the project.
*/
export async function startTunnel(options: TunnelOptions, target: TunnelTarget): Promise<Tunnel | undefined> {
const local = formatTunnelTarget(target)
if (options.provider === 'opentunnel') {
// OpenTunnel terminates TLS itself and forwards plain TCP, which an HTTPS
// dev server cannot read.
if (target.protocol === 'https') {
logger.warn('OpenTunnel cannot forward to an HTTPS dev server. Using a Cloudflare quick tunnel instead.')
}
else {
const { startOpenTunnel } = await import('./opentunnel')
return startOpenTunnel(options.route, local, { handover: target.handover, rootDir: options.rootDir })
}
}
const { startCloudflaredTunnel } = await import('./cloudflared')
return startCloudflaredTunnel(`${target.protocol}://${local}`, target.protocol === 'https')
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
108 changes: 108 additions & 0 deletions packages/nuxt-cli/src/dev/tunnel/opentunnel-install.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import { styleText } from 'node:util'

import { confirm, isCancel } from '@clack/prompts'
import { resolveCommand } from 'package-manager-detector/commands'
import { satisfies } from 'verkit'

import { restoreRawMode, withDirectStdout } from '../../utils/console'
import { createInstallLog, runInstall, takeUnreportedIgnoredBuilds } from '../../utils/install'
import { logger } from '../../utils/logger'
import { defaultPackageManager, detectPackageManager } from '../../utils/package-managers'
import { createSpinner } from '../../utils/spinner'
import { withUserAttention } from '../../utils/startup-clock'
import { isInteractive } from '../../utils/stdout'
import { findOpenTunnelSDK, OPENTUNNEL_SDK, OPENTUNNEL_SDK_PEERS, OPENTUNNEL_SDK_RANGE, resolveProjectPackage } from './opentunnel-loader'

/**
* Make sure the project can load the OpenTunnel SDK, offering to install it as
* a dev dependency with the project's package manager, the way
* `nuxt module add` installs modules. Runs before the dev server takes the
* terminal. Returns `false` when the tunnel cannot start.
*/
export async function ensureOpenTunnelSDK(rootDir: string): Promise<boolean> {
const state = findOpenTunnelSDK(rootDir)
if (state.status === 'installed') {
return true
}

const peers = resolveMissingPeers(rootDir)
if (!peers) {
return false
}
// An incompatible copy is replaced by the range this CLI works with.
const dependencies = [`${OPENTUNNEL_SDK}@${OPENTUNNEL_SDK_RANGE}`, ...peers]
const packageManager = await detectPackageManager(rootDir) ?? defaultPackageManager
const install = resolveCommand(packageManager.agent, 'add', ['-D', ...dependencies])
const command = install ? [install.command, ...install.args].join(' ') : `npm install -D ${dependencies.join(' ')}`
const reason = state.status === 'incompatible'
? `\`--tunnel=opentunnel\` needs \`${OPENTUNNEL_SDK}@${OPENTUNNEL_SDK_RANGE}\`, and your project has ${state.version}.`
: `\`--tunnel=opentunnel\` needs \`${OPENTUNNEL_SDK}\` in your project.`

if (!isInteractive()) {
logger.warn(`${reason} Run \`${command}\` to install it. Starting without a tunnel.`)
return false
}

logger.info(reason)
const answer = await withUserAttention(() => withDirectStdout(() => confirm({
message: `Install ${dependencies.map(name => styleText('cyan', name)).join(', ')} as dev dependencies with ${styleText('cyan', packageManager.name)}?`,
initialValue: true,
})))
restoreRawMode()
if (isCancel(answer) || !answer) {
logger.info(`Starting without a tunnel. Run \`${command}\` to install it later.`)
return false
}

const controller = new AbortController()
const installLog = createInstallLog()
const spinner = createSpinner({ indicator: 'timer', onCancel: () => controller.abort() })
spinner.start(`Installing with ${styleText('cyan', packageManager.name)}`)
const result = await withUserAttention(() => runInstall({
cwd: rootDir,
packageManager,
dependencies,
dev: true,
onOutput: installLog.onOutput,
onStatus: message => spinner.message(message),
signal: controller.signal,
}))
if (!result.success) {
spinner.error(result.error ?? 'Install failed')
installLog.finish(result)
logger.info('Starting without a tunnel.')
return false
}
spinner.stop(`Installed ${styleText('cyan', OPENTUNNEL_SDK)}`)
installLog.finish(result)
const ignoredBuilds = takeUnreportedIgnoredBuilds(result.ignoredBuilds)
if (ignoredBuilds.length > 0 && packageManager.name === 'pnpm') {
logger.warn(`${styleText('cyan', 'pnpm')} did not run build scripts for ${ignoredBuilds.map(name => styleText('cyan', name)).join(', ')}. Run ${styleText('cyan', 'pnpm approve-builds')} if your project needs them.`)
}

if (findOpenTunnelSDK(rootDir).status !== 'installed') {
logger.warn(`\`${OPENTUNNEL_SDK}\` was installed but cannot be resolved from \`${rootDir}\`. Starting without a tunnel.`)
return false
}
return true
}

/**
* Required peers of the SDK the project does not already provide. Returns
* `undefined` when the project has a version that conflicts: installing ours
* would replace the one the app itself uses.
*/
function resolveMissingPeers(rootDir: string): string[] | undefined {
const missing: string[] = []
for (const [name, range] of Object.entries(OPENTUNNEL_SDK_PEERS)) {
const installed = resolveProjectPackage(name, rootDir)
if (!installed) {
missing.push(`${name}@${range}`)
}
else if (!satisfies(installed.version, range)) {
logger.warn(`\`--tunnel=opentunnel\` needs \`${name}@${range}\`, and your project uses ${installed.version || 'another version'}. Starting without a tunnel.`)
return undefined
}
}
return missing
}
Loading
Loading