From 5da8850a3665db6e90a0468c39d14b0827e9e445 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Chopin?= Date: Fri, 9 Oct 2026 13:57:23 +0200 Subject: [PATCH 1/5] feat(dev): add OpenTunnel as a `--tunnel` provider `--tunnel` now takes a provider: `cloudflare` (the existing quick tunnel) or `opentunnel`, which gives each project the same end-to-end encrypted URL on every run. A bare `--tunnel` asks once and saves the choice in `~/.nuxtrc`. The OpenTunnel SDK is not a dependency of the CLI: it is resolved from the project and, when missing, installed there as a dev dependency with the project's package manager after a prompt. --- docs/dev.md | 31 ++- packages/nuxt-cli/package.json | 2 + packages/nuxt-cli/src/commands/dev.ts | 14 +- packages/nuxt-cli/src/dev/listen.ts | 8 +- .../dev/{tunnel.ts => tunnel/cloudflared.ts} | 12 +- packages/nuxt-cli/src/dev/tunnel/index.ts | 33 +++ .../src/dev/tunnel/opentunnel-install.ts | 108 ++++++++++ .../src/dev/tunnel/opentunnel-loader.ts | 92 ++++++++ .../nuxt-cli/src/dev/tunnel/opentunnel.ts | 200 ++++++++++++++++++ packages/nuxt-cli/src/dev/tunnel/resolve.ts | 138 ++++++++++++ packages/nuxt-cli/src/dev/tunnel/types.ts | 24 +++ packages/nuxt-cli/src/main.ts | 6 + packages/nuxt-cli/src/run.ts | 9 +- packages/nuxt-cli/src/utils/optional-flag.ts | 22 ++ .../nuxt-cli/test/unit/dev/binaries.spec.ts | 2 +- .../test/unit/dev/opentunnel-sdk.spec.ts | 174 +++++++++++++++ .../test/unit/dev/tunnel-lazy.spec.ts | 28 +++ .../test/unit/dev/tunnel-resolve.spec.ts | 168 +++++++++++++++ .../nuxt-cli/test/unit/dev/tunnel.spec.ts | 176 +++++++++++++++ packages/nuxt-cli/test/unit/help.spec.ts | 2 +- packages/nuxt-cli/test/unit/listen.spec.ts | 21 +- .../test/unit/utils/optional-flag.spec.ts | 44 ++++ pnpm-lock.yaml | 31 +++ pnpm-workspace.yaml | 2 + 24 files changed, 1324 insertions(+), 23 deletions(-) rename packages/nuxt-cli/src/dev/{tunnel.ts => tunnel/cloudflared.ts} (96%) create mode 100644 packages/nuxt-cli/src/dev/tunnel/index.ts create mode 100644 packages/nuxt-cli/src/dev/tunnel/opentunnel-install.ts create mode 100644 packages/nuxt-cli/src/dev/tunnel/opentunnel-loader.ts create mode 100644 packages/nuxt-cli/src/dev/tunnel/opentunnel.ts create mode 100644 packages/nuxt-cli/src/dev/tunnel/resolve.ts create mode 100644 packages/nuxt-cli/src/dev/tunnel/types.ts create mode 100644 packages/nuxt-cli/src/utils/optional-flag.ts create mode 100644 packages/nuxt-cli/test/unit/dev/opentunnel-sdk.spec.ts create mode 100644 packages/nuxt-cli/test/unit/dev/tunnel-lazy.spec.ts create mode 100644 packages/nuxt-cli/test/unit/dev/tunnel-resolve.spec.ts create mode 100644 packages/nuxt-cli/test/unit/dev/tunnel.spec.ts create mode 100644 packages/nuxt-cli/test/unit/utils/optional-flag.spec.ts diff --git a/docs/dev.md b/docs/dev.md index 3a08f0397..3c04587dc 100644 --- a/docs/dev.md +++ b/docs/dev.md @@ -10,7 +10,7 @@ links: ```bash [Terminal] -npx nuxt dev [ROOTDIR] [--cwd=] [--logLevel=] [--dotenv=...] [--envName=] [-e, --extends=...] [--inspect] [--inspect-brk] [--tui] [--clear] [-f, --fork] [-p, --port=] [--takeover] [--strictPort] [-h, --host=] [-o, --open] [--open.url=] [--clipboard] [--qr] [--tunnel] [--public] [--publicURL=] [--https] [--https.cert=] [--https.key=] [--https.pfx=] [--https.passphrase=] [--https.validityDays=] [--https.domains=...] [--profile=] +npx nuxt dev [ROOTDIR] [--cwd=] [--logLevel=] [--dotenv=...] [--envName=] [-e, --extends=...] [--inspect] [--inspect-brk] [--tui] [--clear] [-f, --fork] [-p, --port=] [--takeover] [--strictPort] [-h, --host=] [-o, --open] [--open.url=] [--clipboard] [--qr] [--tunnel=] [--public] [--publicURL=] [--https] [--https.cert=] [--https.key=] [--https.pfx=] [--https.passphrase=] [--https.validityDays=] [--https.domains=...] [--profile=] ``` @@ -52,7 +52,7 @@ The `dev` command starts a development server with hot module replacement at [ht | `--open.url=` | | 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=` | | 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=` | | Public URL to display (used for QR code and clipboard) | | `--https` | | Enable HTTPS with a locally-trusted development certificate | @@ -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. diff --git a/packages/nuxt-cli/package.json b/packages/nuxt-cli/package.json index dca6ce89e..6f2721fc6 100644 --- a/packages/nuxt-cli/package.json +++ b/packages/nuxt-cli/package.json @@ -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", diff --git a/packages/nuxt-cli/src/commands/dev.ts b/packages/nuxt-cli/src/commands/dev.ts index dc6be6091..0c6570376 100644 --- a/packages/nuxt-cli/src/commands/dev.ts +++ b/packages/nuxt-cli/src/commands/dev.ts @@ -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', @@ -180,6 +181,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) + } const buildDir = await beforeServing(() => resolveLockDir(cwd)) @@ -535,7 +544,6 @@ export function resolveListenOverrides(args: ParsedArgs): DevListenOverri openURL: args['open.url'] || undefined, clipboard: args.clipboard, qr: args.qr, - tunnel: args.tunnel, public: args.public, publicURL: args.publicURL, httpsEnabled: args.https, diff --git a/packages/nuxt-cli/src/dev/listen.ts b/packages/nuxt-cli/src/dev/listen.ts index de0ebd440..aba4dfb78 100644 --- a/packages/nuxt-cli/src/dev/listen.ts +++ b/packages/nuxt-cli/src/dev/listen.ts @@ -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' @@ -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 @@ -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 { @@ -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, port: address.port, handover: options.handover }) } const tunnelURL = tunnel?.url && tunnel.url + baseURL diff --git a/packages/nuxt-cli/src/dev/tunnel.ts b/packages/nuxt-cli/src/dev/tunnel/cloudflared.ts similarity index 96% rename from packages/nuxt-cli/src/dev/tunnel.ts rename to packages/nuxt-cli/src/dev/tunnel/cloudflared.ts index 25380e371..17581d376 100644 --- a/packages/nuxt-cli/src/dev/tunnel.ts +++ b/packages/nuxt-cli/src/dev/tunnel/cloudflared.ts @@ -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/ @@ -34,12 +35,7 @@ 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 -} - -export async function startTunnel(localURL: string, insecure?: boolean): Promise { +export async function startCloudflaredTunnel(localURL: string, insecure?: boolean): Promise { 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/') diff --git a/packages/nuxt-cli/src/dev/tunnel/index.ts b/packages/nuxt-cli/src/dev/tunnel/index.ts new file mode 100644 index 000000000..c2bd33084 --- /dev/null +++ b/packages/nuxt-cli/src/dev/tunnel/index.ts @@ -0,0 +1,33 @@ +import type { Tunnel, TunnelOptions } from './types' + +import { logger } from '../../utils/logger' + +export type { Tunnel, TunnelOptions } from './types' + +export interface TunnelTarget { + protocol: 'http' | 'https' + port: number + /** The server is taking over from a process that still serves the port. */ + handover?: boolean +} + +/** + * Start the tunnel `options` asks for, in front of the dev server on `port`. + * 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 { + 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, target.port, { handover: target.handover, rootDir: options.rootDir }) + } + } + const { startCloudflaredTunnel } = await import('./cloudflared') + return startCloudflaredTunnel(`${target.protocol}://localhost:${target.port}`, target.protocol === 'https') +} diff --git a/packages/nuxt-cli/src/dev/tunnel/opentunnel-install.ts b/packages/nuxt-cli/src/dev/tunnel/opentunnel-install.ts new file mode 100644 index 000000000..484be76c8 --- /dev/null +++ b/packages/nuxt-cli/src/dev/tunnel/opentunnel-install.ts @@ -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 { + 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 +} diff --git a/packages/nuxt-cli/src/dev/tunnel/opentunnel-loader.ts b/packages/nuxt-cli/src/dev/tunnel/opentunnel-loader.ts new file mode 100644 index 000000000..d938ca394 --- /dev/null +++ b/packages/nuxt-cli/src/dev/tunnel/opentunnel-loader.ts @@ -0,0 +1,92 @@ +import type * as OpenTunnelSDK from '@opentunnel/client' + +import { readFileSync } from 'node:fs' +import { pathToFileURL } from 'node:url' + +import { resolveModulePath } from 'exsolve' +import { dirname, join } from 'pathe' +import { satisfies } from 'verkit' + +import { debug, logger } from '../../utils/logger' + +/** + * The OpenTunnel SDK is not a dependency of the CLI: with `effect` it is most of + * a megabyte, which nobody who never runs `--tunnel=opentunnel` should install. + * It is resolved from the project instead, and installed there as a dev + * dependency the first time it is needed (see `ensureOpenTunnelSDK`). + */ +export const OPENTUNNEL_SDK = '@opentunnel/client' + +/** The SDK releases this CLI works with. A `0.x` minor may break its API. */ +export const OPENTUNNEL_SDK_RANGE = '^0.4.0' + +/** Required peers, installed with the SDK as `nuxt module add` does for modules. */ +export const OPENTUNNEL_SDK_PEERS: Readonly> = { effect: '^4.0.0' } + +export interface ResolvedPackage { + version: string + /** The module `import` resolves to. */ + entry: string +} + +/** + * The copy of `name` that `rootDir` resolves, found through its main entry + * rather than `/package.json`, which a package may not export. + */ +export function resolveProjectPackage(name: string, rootDir: string): ResolvedPackage | undefined { + // Uncached: the answer changes once `ensureOpenTunnelSDK` has installed it. + const entry = resolveModulePath(name, { from: join(rootDir, '/'), try: true, cache: false }) + if (!entry) { + return undefined + } + for (let dir = dirname(entry); dir !== dirname(dir); dir = dirname(dir)) { + try { + const manifest = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')) as { name?: string, version?: string } + if (manifest.name === name) { + return { version: manifest.version ?? '', entry } + } + } + catch { + // no manifest at this level + } + } + debug(`Could not find the package.json of ${name} above ${entry}.`) + return undefined +} + +export type OpenTunnelSDKState + = | { status: 'installed', version: string, entry: string } + | { status: 'incompatible', version: string } + | { status: 'missing' } + +/** Whether the project in `rootDir` has a usable copy of the SDK. */ +export function findOpenTunnelSDK(rootDir: string): OpenTunnelSDKState { + const sdk = resolveProjectPackage(OPENTUNNEL_SDK, rootDir) + if (!sdk) { + return { status: 'missing' } + } + return satisfies(sdk.version, OPENTUNNEL_SDK_RANGE) + ? { status: 'installed', ...sdk } + : { status: 'incompatible', version: sdk.version } +} + +/** + * Import the SDK installed in the project. Returns `undefined`, with a warning, + * when it is missing or outside {@link OPENTUNNEL_SDK_RANGE}. + */ +export async function loadOpenTunnelSDK(rootDir: string): Promise { + const state = findOpenTunnelSDK(rootDir) + if (state.status !== 'installed') { + const found = state.status === 'incompatible' ? ` (found ${state.version})` : '' + logger.warn(`\`--tunnel=opentunnel\` needs \`${OPENTUNNEL_SDK}@${OPENTUNNEL_SDK_RANGE}\` in your project${found}.`) + return undefined + } + try { + return await import(pathToFileURL(state.entry).href) as typeof OpenTunnelSDK + } + catch (error) { + debug(`Failed to load ${state.entry}:`, error) + logger.warn(`Could not load \`${OPENTUNNEL_SDK}@${state.version}\` from your project.`) + return undefined + } +} diff --git a/packages/nuxt-cli/src/dev/tunnel/opentunnel.ts b/packages/nuxt-cli/src/dev/tunnel/opentunnel.ts new file mode 100644 index 000000000..843c7c0ad --- /dev/null +++ b/packages/nuxt-cli/src/dev/tunnel/opentunnel.ts @@ -0,0 +1,200 @@ +import type { OpenTunnelConnection, OpenTunnelPromiseClient, OpenTunnelProvisionStage } from '@opentunnel/client' +import type { Tunnel } from './types' + +import process from 'node:process' + +import { spinner } from '@clack/prompts' + +import { restoreRawMode, withDirectStdout } from '../../utils/console' +import { debug, logger } from '../../utils/logger' +import { withUserAttention } from '../../utils/startup-clock' +import { loadOpenTunnelSDK } from './opentunnel-loader' + +/** How long the bridge has to attach once the tunnel has a certificate. */ +const CONNECT_TIMEOUT_MS = 20_000 +/** A first run waits for a certificate to be issued, which takes about half a minute. */ +const PROVISION_TIMEOUT_MS = 120_000 +/** How long to wait for the bridge to close on shutdown. */ +const SHUTDOWN_TIMEOUT_MS = 5000 + +const STAGE_MESSAGES: Partial> = { + 'creating-tunnel': 'Creating your OpenTunnel hostname', + 'generating-key': 'Generating the tunnel private key', + 'generating-csr': 'Generating the tunnel private key', + 'resuming-certificate': 'Waiting for the TLS certificate', + 'requesting-certificate': 'Waiting for the TLS certificate', + 'waiting-certificate': 'Waiting for the TLS certificate', + 'saving-identity': 'Saving the tunnel identity', +} + +export interface OpenTunnelOptions { + /** The project the SDK is installed in. */ + rootDir: string + /** + * The process being taken over still holds the route, and the relay only + * attaches it here once that process lets go, which it does only after this + * one is serving. Waiting for the attach would deadlock the handover, so the + * URL is returned straight away and the bridge attaches in the background. + */ + handover?: boolean +} + +/** + * Expose `localhost:` as `https://.` through the user's + * default OpenTunnel profile, the one the `opentunnel` CLI also uses. + * + * TLS terminates in this process, so the relay never sees plaintext. The route + * only exists while the bridge is attached: nothing keeps running once the dev + * server stops. + */ +export async function startOpenTunnel(route: string, port: number, options: OpenTunnelOptions): Promise { + const sdk = await loadOpenTunnelSDK(options.rootDir) + if (!sdk) { + return undefined + } + const client = sdk.create() + const routes = { [route]: `localhost:${port}` } + try { + const identity = await client.tunnel.get() + if (identity && options.handover) { + return attachInBackground(client, routes, `https://${route}.${identity.hostname}`) + } + if (!identity) { + await withTimeout(provision(client), PROVISION_TIMEOUT_MS, 'Timed out waiting for the tunnel certificate') + } + const connection = await withTimeout(client.tunnel.connect({ routes }), CONNECT_TIMEOUT_MS, 'Timed out connecting to the OpenTunnel relay') + watchEvents(connection) + return { + url: `https://${route}.${connection.tunnel.hostname}`, + close: () => closeConnection(client, connection), + } + } + catch (error) { + await client.dispose().catch(() => {}) + debug('OpenTunnel failed:', error) + logger.warn(`Could not open an OpenTunnel: ${describeError(error)}`) + return undefined + } +} + +function attachInBackground(client: OpenTunnelPromiseClient, routes: Record, url: string): Tunnel { + let closing = false + const connecting = client.tunnel.connect({ routes }).then( + (connection) => { + if (closing) { + void connection.close().catch(() => {}) + return undefined + } + debug('OpenTunnel attached after handover') + watchEvents(connection) + return connection + }, + (error) => { + if (!closing) { + logger.warn(`Could not reattach the OpenTunnel: ${describeError(error)}`) + } + return undefined + }, + ) + return { + url, + close: async () => { + closing = true + // A bridge still waiting for the route has nothing to close yet. + const connection = await Promise.race([connecting, new Promise(resolve => setTimeout(resolve, 0))]) + await closeConnection(client, connection) + }, + } +} + +async function closeConnection(client: OpenTunnelPromiseClient, connection: OpenTunnelConnection | undefined): Promise { + try { + await withTimeout( + Promise.resolve(connection?.close()).finally(() => client.dispose()), + SHUTDOWN_TIMEOUT_MS, + 'Timed out closing the tunnel', + ) + } + catch (error) { + debug('Failed to close OpenTunnel:', error) + } +} + +function watchEvents(connection: OpenTunnelConnection): void { + void (async () => { + for await (const event of connection.events) { + if (event.type === 'stopped' && event.error) { + logger.warn(`OpenTunnel stopped: ${event.error}`) + } + else { + debug('OpenTunnel event:', event) + } + } + })().catch(error => debug('OpenTunnel event stream failed:', error)) +} + +/** + * Create the profile's tunnel, or finish one an earlier run left waiting for + * its certificate, with progress, as a first run takes a while. + */ +function provision(client: OpenTunnelPromiseClient): Promise { + return withUserAttention(() => withDirectStdout(async () => { + const interactive = !!process.stdout.isTTY + const indicator = interactive ? spinner() : undefined + let last: string | undefined + const onProgress = (stage: OpenTunnelProvisionStage) => { + const message = STAGE_MESSAGES[stage] + if (!message || message === last) { + return + } + if (!indicator) { + logger.info(message) + } + else if (last) { + indicator.message(message) + } + else { + indicator.start(message) + } + last = message + } + try { + const identity = await client.tunnel.pending() + ? await client.tunnel.resume({ onProgress }) + : await client.tunnel.create({ onProgress }) + const done = identity ? `Created ${identity.hostname}` : 'Created your OpenTunnel' + if (indicator && last) { + indicator.stop(done) + } + else { + logger.success(done) + } + return identity + } + catch (error) { + if (indicator && last) { + indicator.error('Could not create your OpenTunnel') + } + throw error + } + finally { + restoreRawMode() + } + })) +} + +function withTimeout(promise: Promise, ms: number, message: string): Promise { + let timer: ReturnType | undefined + const timeout = new Promise((_, reject) => { + timer = setTimeout(reject, ms, new Error(message)) + }) + return Promise.race([promise, timeout]).finally(() => clearTimeout(timer)) +} + +function describeError(error: unknown): string { + if (error instanceof Error) { + const cause = error.cause instanceof Error ? ` (${error.cause.message})` : '' + return `${error.message}${cause}` + } + return String(error) +} diff --git a/packages/nuxt-cli/src/dev/tunnel/resolve.ts b/packages/nuxt-cli/src/dev/tunnel/resolve.ts new file mode 100644 index 000000000..dcef37b5b --- /dev/null +++ b/packages/nuxt-cli/src/dev/tunnel/resolve.ts @@ -0,0 +1,138 @@ +import type { TunnelOptions, TunnelProvider } from './types' + +import { createHash, randomBytes } from 'node:crypto' + +import { isCancel, select } from '@clack/prompts' +import { resolve } from 'pathe' +import { readUser, updateUser } from 'rc9' + +import { restoreRawMode, withDirectStdout } from '../../utils/console' +import { debug, logger } from '../../utils/logger' +import { withUserAttention } from '../../utils/startup-clock' +import { isInteractive } from '../../utils/stdout' + +/** + * Kept in the user `.nuxtrc` under `tools`, like the other CLI-owned settings: + * Nuxt also reads that file as config (`globalRc`), and `tools` keeps these + * keys out of the way of anything a project configures. + */ +const RC_FILE = '.nuxtrc' +const SEED_RE = /^[0-9a-f]{32}$/ + +interface TunnelRC { + tools?: { + tunnel?: { provider?: unknown } + opentunnel?: { seed?: unknown } + } +} + +export function parseTunnelProvider(value: unknown): TunnelProvider | undefined { + if (value === 'cloudflare' || value === 'cloudflared') { + return 'cloudflare' + } + if (value === 'opentunnel') { + return 'opentunnel' + } + return undefined +} + +/** + * Resolve `--tunnel` into the provider to start, prompting (once) when it is + * passed without a value. Must run before the dev server takes the terminal. + */ +export async function resolveTunnelOptions(flag: string | boolean | undefined, rootDir: string): Promise { + if (flag === undefined || flag === false) { + return undefined + } + const provider = await resolveTunnelProvider(flag) + if (provider === 'opentunnel') { + // The SDK lives in the project, and installing it may prompt. Imported on + // demand, as it pulls in the package manager helpers. + const { ensureOpenTunnelSDK } = await import('./opentunnel-install') + if (!await ensureOpenTunnelSDK(rootDir)) { + return undefined + } + return { provider, route: deriveRouteName(resolveSeed(), rootDir), rootDir } + } + return provider ? { provider } : undefined +} + +async function resolveTunnelProvider(flag: string | true): Promise { + if (flag !== true && flag !== '') { + const provider = parseTunnelProvider(flag) + if (provider) { + return provider + } + logger.warn(`Unknown tunnel provider \`${flag}\`: use \`--tunnel=cloudflare\` or \`--tunnel=opentunnel\`. Using a Cloudflare quick tunnel.`) + return 'cloudflare' + } + + const saved = parseTunnelProvider(readRC().tools?.tunnel?.provider) + if (saved) { + return saved + } + + // Nothing is saved, so the question is still asked once a terminal is there. + if (!isInteractive()) { + return 'cloudflare' + } + + const choice = await withUserAttention(() => withDirectStdout(() => select({ + message: 'Which tunnel do you want to use?', + options: [ + { value: 'cloudflare', label: 'Cloudflare quick tunnel', hint: 'a new URL on each run, downloads cloudflared' }, + { value: 'opentunnel', label: 'OpenTunnel', hint: 'the same URL on each run, end-to-end encrypted' }, + ], + }))) + restoreRawMode() + if (isCancel(choice)) { + logger.info('No tunnel selected. Starting without a tunnel.') + return undefined + } + + if (writeRC({ tools: { tunnel: { provider: choice } } })) { + logger.info(`Saved \`${choice}\` as your tunnel provider in \`~/${RC_FILE}\`. Pass \`--tunnel=\` to use another one.`) + } + return choice +} + +/** + * A route name that is stable for a project but cannot be guessed from its + * path: route names never appear in certificate transparency logs, so this is + * what keeps the URL private. It is a lowercase DNS label, as routes must be. + */ +export function deriveRouteName(seed: string, rootDir: string): string { + return createHash('sha256').update(`${seed}\0${resolve(rootDir)}`).digest('hex').slice(0, 16) +} + +function resolveSeed(): string { + const saved = readRC().tools?.opentunnel?.seed + if (typeof saved === 'string' && SEED_RE.test(saved)) { + return saved + } + const seed = randomBytes(16).toString('hex') + // Without a saved seed the URL still works, but changes on the next run. + writeRC({ tools: { opentunnel: { seed } } }) + return seed +} + +function readRC(): TunnelRC { + try { + return readUser(RC_FILE) as TunnelRC + } + catch (error) { + debug(`Failed to read user ${RC_FILE}:`, error) + return {} + } +} + +function writeRC(config: TunnelRC): boolean { + try { + updateUser(config, RC_FILE) + return true + } + catch (error) { + debug(`Failed to update user ${RC_FILE}:`, error) + return false + } +} diff --git a/packages/nuxt-cli/src/dev/tunnel/types.ts b/packages/nuxt-cli/src/dev/tunnel/types.ts new file mode 100644 index 000000000..a579a1cd8 --- /dev/null +++ b/packages/nuxt-cli/src/dev/tunnel/types.ts @@ -0,0 +1,24 @@ +export interface Tunnel { + url: string + close: () => Promise +} + +const TUNNEL_PROVIDERS = ['cloudflare', 'opentunnel'] as const + +export type TunnelProvider = typeof TUNNEL_PROVIDERS[number] + +/** Values `--tunnel` accepts, with `cloudflared` as an alias of `cloudflare`. */ +export const TUNNEL_FLAG_VALUES: readonly string[] = [...TUNNEL_PROVIDERS, 'cloudflared'] + +/** + * How `--tunnel` was resolved. The OpenTunnel route name is resolved once, in + * the process that parsed the flag, so every fork serves the same URL. + */ +export type TunnelOptions + = | { provider: 'cloudflare' } + | { + provider: 'opentunnel' + route: string + /** The project the OpenTunnel SDK is installed in. */ + rootDir: string + } diff --git a/packages/nuxt-cli/src/main.ts b/packages/nuxt-cli/src/main.ts index 29d557f77..7d21bb526 100644 --- a/packages/nuxt-cli/src/main.ts +++ b/packages/nuxt-cli/src/main.ts @@ -11,10 +11,12 @@ import { provider } from 'std-env' import { description, name, version } from '../package.json' import { commands } from './commands' import { cwdArgs, globalCwdArgs } from './commands/_shared' +import { TUNNEL_FLAG_VALUES } from './dev/tunnel/types' import { LONG_RUNNING_COMMANDS, runCommand, setCurrentCommand } from './run' import { setupGlobalConsole } from './utils/console' import { debug, logger } from './utils/logger' import { setupProxySupport } from './utils/network' +import { normalizeOptionalValueFlag } from './utils/optional-flag' import { findInPath, withLocalBinPath } from './utils/path-env' import { suggestCommand } from './utils/suggest' import { templateNames } from './utils/templates/names' @@ -63,6 +65,10 @@ const _main = defineCommand({ process.exit(0) } + if (command === 'dev') { + normalizeOptionalValueFlag(ctx.rawArgs, '--tunnel', TUNNEL_FLAG_VALUES) + } + if (command && Object.hasOwn(commands, command)) { await warnUnknownFlags(command, ctx.rawArgs) } diff --git a/packages/nuxt-cli/src/run.ts b/packages/nuxt-cli/src/run.ts index e7de0268c..ab74fae68 100644 --- a/packages/nuxt-cli/src/run.ts +++ b/packages/nuxt-cli/src/run.ts @@ -8,8 +8,10 @@ import { runCommand as _runCommand, runMain as _runMain, renderUsage } from 'cit import { commands } from './commands' import { globalCwdArgs } from './commands/_shared' +import { TUNNEL_FLAG_VALUES } from './dev/tunnel/types' import { main } from './main' import { warnOnHang } from './utils/hang' +import { normalizeOptionalValueFlag } from './utils/optional-flag' globalThis.__nuxt_cli__ = globalThis.__nuxt_cli__ || { // Programmatic usage fallback @@ -53,8 +55,13 @@ export async function runCommand( throw new Error(`Invalid command ${name}`) } + const rawArgs = [...argv] + if (name === 'dev') { + normalizeOptionalValueFlag(rawArgs, '--tunnel', TUNNEL_FLAG_VALUES) + } + return await _runCommand(await commands[name as keyof typeof commands](), { - rawArgs: [...argv], + rawArgs, inheritedArgs: globalCwdArgs, data: { overrides: data.overrides || {}, diff --git a/packages/nuxt-cli/src/utils/optional-flag.ts b/packages/nuxt-cli/src/utils/optional-flag.ts new file mode 100644 index 000000000..99735c67e --- /dev/null +++ b/packages/nuxt-cli/src/utils/optional-flag.ts @@ -0,0 +1,22 @@ +/** + * Give a string flag an optional value. Node's `parseArgs` (with `strict: false`) + * takes the next token as a string flag's value even when it is another flag or + * the project directory, so `--tunnel --port 3000` would read `--port` as the + * tunnel and `3000` as the project. A bare `flag`, or one followed by anything + * but one of `values`, is rewritten in place to `flag=` (an empty value). + */ +export function normalizeOptionalValueFlag(rawArgs: string[], flag: string, values: readonly string[]): void { + for (let index = 0; index < rawArgs.length; index++) { + const arg = rawArgs[index] + if (arg === '--') { + return + } + if (arg !== flag) { + continue + } + const next = rawArgs[index + 1] + if (next === undefined || !values.includes(next)) { + rawArgs[index] = `${flag}=` + } + } +} diff --git a/packages/nuxt-cli/test/unit/dev/binaries.spec.ts b/packages/nuxt-cli/test/unit/dev/binaries.spec.ts index 869e5c50d..b6579c963 100644 --- a/packages/nuxt-cli/test/unit/dev/binaries.spec.ts +++ b/packages/nuxt-cli/test/unit/dev/binaries.spec.ts @@ -6,7 +6,7 @@ import { join } from 'pathe' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { resolveTool } from '../../../src/dev/binaries' -import { resolveCloudflaredVersion } from '../../../src/dev/tunnel' +import { resolveCloudflaredVersion } from '../../../src/dev/tunnel/cloudflared' import { getCacheDir } from '../../../src/utils/cache' let cacheHome: string diff --git a/packages/nuxt-cli/test/unit/dev/opentunnel-sdk.spec.ts b/packages/nuxt-cli/test/unit/dev/opentunnel-sdk.spec.ts new file mode 100644 index 000000000..df81b7fef --- /dev/null +++ b/packages/nuxt-cli/test/unit/dev/opentunnel-sdk.spec.ts @@ -0,0 +1,174 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' + +import { join } from 'pathe' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { logger } from '../../../src/utils/logger' + +const confirm = vi.fn() +const isInteractive = vi.fn(() => false) +const runInstall = vi.fn() + +vi.mock('@clack/prompts', async importOriginal => ({ + ...await importOriginal(), + confirm: (...args: unknown[]) => confirm(...args), +})) +vi.mock('../../../src/utils/stdout', async importOriginal => ({ + ...await importOriginal(), + isInteractive: () => isInteractive(), +})) +vi.mock('../../../src/utils/install', async importOriginal => ({ + ...await importOriginal(), + runInstall: (...args: unknown[]) => runInstall(...args), +})) +vi.mock('../../../src/utils/package-managers', async importOriginal => ({ + ...await importOriginal(), + detectPackageManager: async () => ({ name: 'pnpm', agent: 'pnpm' }), +})) + +const { findOpenTunnelSDK, loadOpenTunnelSDK, OPENTUNNEL_SDK } = await import('../../../src/dev/tunnel/opentunnel-loader') +const { ensureOpenTunnelSDK } = await import('../../../src/dev/tunnel/opentunnel-install') + +let project: string + +/** + * A package in the project's `node_modules`. Like `@opentunnel/client`, it does + * not export its `package.json`, so it is found through its main entry. + */ +function installPackage(name: string, version: string): void { + const dir = join(project, 'node_modules', name) + mkdirSync(join(dir, 'dist'), { recursive: true }) + writeFileSync(join(dir, 'package.json'), JSON.stringify({ name, version, type: 'module', exports: { '.': { default: './dist/index.js' } } })) + writeFileSync(join(dir, 'dist/index.js'), `export const create = () => ${JSON.stringify(`${name}@${version}`)}\n`) +} + +beforeEach(() => { + project = mkdtempSync(join(tmpdir(), 'nuxt-opentunnel-sdk-')) + writeFileSync(join(project, 'package.json'), JSON.stringify({ name: 'app', private: true })) + isInteractive.mockReturnValue(false) + vi.spyOn(logger, 'info').mockImplementation(() => {}) + vi.spyOn(logger, 'warn').mockImplementation(() => {}) +}) + +afterEach(() => { + vi.restoreAllMocks() + confirm.mockReset() + runInstall.mockReset() + rmSync(project, { recursive: true, force: true }) +}) + +describe('findOpenTunnelSDK', () => { + it('should report a missing SDK', () => { + expect(findOpenTunnelSDK(project)).toEqual({ status: 'missing' }) + }) + + it('should find a compatible SDK through its main entry', () => { + installPackage(OPENTUNNEL_SDK, '0.4.3') + expect(findOpenTunnelSDK(project)).toMatchObject({ status: 'installed', version: '0.4.3' }) + }) + + it('should reject a release outside the supported range', () => { + installPackage(OPENTUNNEL_SDK, '0.3.0') + expect(findOpenTunnelSDK(project)).toEqual({ status: 'incompatible', version: '0.3.0' }) + installPackage(OPENTUNNEL_SDK, '0.5.0') + expect(findOpenTunnelSDK(project)).toEqual({ status: 'incompatible', version: '0.5.0' }) + }) +}) + +describe('loadOpenTunnelSDK', () => { + it('should import the SDK from the project', async () => { + installPackage(OPENTUNNEL_SDK, '0.4.0') + const sdk = await loadOpenTunnelSDK(project) + expect((sdk!.create as unknown as () => string)()).toBe(`${OPENTUNNEL_SDK}@0.4.0`) + }) + + it('should warn when the project has no usable SDK', async () => { + await expect(loadOpenTunnelSDK(project)).resolves.toBeUndefined() + expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining(`needs \`${OPENTUNNEL_SDK}@^0.4.0\``)) + }) +}) + +describe('ensureOpenTunnelSDK', () => { + function installSucceeds() { + runInstall.mockImplementation(async () => { + installPackage(OPENTUNNEL_SDK, '0.4.0') + installPackage('effect', '4.0.2') + return { success: true, output: '', command: 'pnpm add', ignoredBuilds: [] } + }) + } + + it('should do nothing when the SDK is already installed', async () => { + installPackage(OPENTUNNEL_SDK, '0.4.0') + await expect(ensureOpenTunnelSDK(project)).resolves.toBe(true) + expect(confirm).not.toHaveBeenCalled() + expect(runInstall).not.toHaveBeenCalled() + }) + + it('should print the install command instead of installing when nobody can answer', async () => { + await expect(ensureOpenTunnelSDK(project)).resolves.toBe(false) + expect(runInstall).not.toHaveBeenCalled() + expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('`pnpm add -D @opentunnel/client@^0.4.0 effect@^4.0.0`')) + }) + + it('should install the SDK and its peer as dev dependencies once the user agrees', async () => { + isInteractive.mockReturnValue(true) + confirm.mockResolvedValue(true) + installSucceeds() + + await expect(ensureOpenTunnelSDK(project)).resolves.toBe(true) + + expect(runInstall).toHaveBeenCalledWith(expect.objectContaining({ + cwd: project, + dependencies: [`${OPENTUNNEL_SDK}@^0.4.0`, 'effect@^4.0.0'], + dev: true, + })) + expect(logger.warn).not.toHaveBeenCalled() + }) + + it('should replace an incompatible SDK with a supported release', async () => { + isInteractive.mockReturnValue(true) + confirm.mockResolvedValue(true) + installPackage(OPENTUNNEL_SDK, '0.3.0') + installSucceeds() + + await expect(ensureOpenTunnelSDK(project)).resolves.toBe(true) + expect(runInstall).toHaveBeenCalledWith(expect.objectContaining({ dependencies: [`${OPENTUNNEL_SDK}@^0.4.0`, 'effect@^4.0.0'] })) + }) + + it('should reuse an `effect` the project already has', async () => { + isInteractive.mockReturnValue(true) + confirm.mockResolvedValue(true) + installPackage('effect', '4.1.0') + installSucceeds() + + await ensureOpenTunnelSDK(project) + expect(runInstall).toHaveBeenCalledWith(expect.objectContaining({ dependencies: [`${OPENTUNNEL_SDK}@^0.4.0`] })) + }) + + it('should not replace the `effect` the app uses with another major', async () => { + isInteractive.mockReturnValue(true) + installPackage('effect', '3.18.0') + + await expect(ensureOpenTunnelSDK(project)).resolves.toBe(false) + expect(confirm).not.toHaveBeenCalled() + expect(runInstall).not.toHaveBeenCalled() + expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('your project uses 3.18.0')) + }) + + it('should start without a tunnel when the user declines', async () => { + isInteractive.mockReturnValue(true) + confirm.mockResolvedValue(false) + + await expect(ensureOpenTunnelSDK(project)).resolves.toBe(false) + expect(runInstall).not.toHaveBeenCalled() + }) + + it('should start without a tunnel when the install fails', async () => { + isInteractive.mockReturnValue(true) + confirm.mockResolvedValue(true) + runInstall.mockResolvedValue({ success: false, output: 'ERR_PNPM_FETCH_404', command: 'pnpm add', ignoredBuilds: [], error: 'failed with exit code 1' }) + + await expect(ensureOpenTunnelSDK(project)).resolves.toBe(false) + }) +}) diff --git a/packages/nuxt-cli/test/unit/dev/tunnel-lazy.spec.ts b/packages/nuxt-cli/test/unit/dev/tunnel-lazy.spec.ts new file mode 100644 index 000000000..a0a9bea6a --- /dev/null +++ b/packages/nuxt-cli/test/unit/dev/tunnel-lazy.spec.ts @@ -0,0 +1,28 @@ +import { describe, expect, it, vi } from 'vitest' + +const evaluated = vi.hoisted(() => new Set()) + +vi.mock('../../../src/dev/tunnel/cloudflared', () => { + evaluated.add('cloudflared') + return { startCloudflaredTunnel: vi.fn(async () => ({ url: 'https://example.test', close: async () => {} })) } +}) + +// The OpenTunnel SDK and `effect` are most of the CLI's install size, so only +// `--tunnel=opentunnel` may load them. +vi.mock('../../../src/dev/tunnel/opentunnel', () => { + evaluated.add('opentunnel') + return { startOpenTunnel: vi.fn(async () => ({ url: 'https://example.test', close: async () => {} })) } +}) + +describe('tunnel module graph', () => { + it('should load only the provider that was asked for', async () => { + const { startTunnel } = await import('../../../src/dev/tunnel') + expect([...evaluated]).toEqual([]) + + await startTunnel({ provider: 'cloudflare' }, { protocol: 'http', port: 3000 }) + expect([...evaluated]).toEqual(['cloudflared']) + + await startTunnel({ provider: 'opentunnel', route: 'route', rootDir: '/app' }, { protocol: 'http', port: 3000 }) + expect([...evaluated]).toEqual(['cloudflared', 'opentunnel']) + }) +}) diff --git a/packages/nuxt-cli/test/unit/dev/tunnel-resolve.spec.ts b/packages/nuxt-cli/test/unit/dev/tunnel-resolve.spec.ts new file mode 100644 index 000000000..2fbe14457 --- /dev/null +++ b/packages/nuxt-cli/test/unit/dev/tunnel-resolve.spec.ts @@ -0,0 +1,168 @@ +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' + +import { join } from 'pathe' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { logger } from '../../../src/utils/logger' + +const select = vi.fn() +const isInteractive = vi.fn(() => false) +/** clack's own cancel symbol is private, so the mock recognises this one too. */ +const CANCEL = Symbol('cancel') + +vi.mock('@clack/prompts', async (importOriginal) => { + const original = await importOriginal() + return { + ...original, + select: (...args: unknown[]) => select(...args), + isCancel: (value: unknown) => value === CANCEL || original.isCancel(value), + } +}) +const ensureOpenTunnelSDK = vi.fn(async (_rootDir: string) => true) +vi.mock('../../../src/dev/tunnel/opentunnel-install', () => ({ ensureOpenTunnelSDK })) +vi.mock('../../../src/utils/stdout', async importOriginal => ({ + ...await importOriginal(), + isInteractive: () => isInteractive(), +})) + +const { deriveRouteName, parseTunnelProvider, resolveTunnelOptions } = await import('../../../src/dev/tunnel/resolve') + +const ROUTE_LABEL_RE = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/ + +let home: string + +function readNuxtRC(): string { + try { + return readFileSync(join(home, '.nuxtrc'), 'utf8') + } + catch { + return '' + } +} + +beforeEach(() => { + // `rc9` prefers `XDG_CONFIG_HOME` (set on some CI images), then the home directory. + home = mkdtempSync(join(tmpdir(), 'nuxt-tunnel-home-')) + vi.stubEnv('HOME', home) + vi.stubEnv('USERPROFILE', home) + vi.stubEnv('XDG_CONFIG_HOME', home) + isInteractive.mockReturnValue(false) + ensureOpenTunnelSDK.mockClear() + ensureOpenTunnelSDK.mockResolvedValue(true) +}) + +afterEach(() => { + vi.unstubAllEnvs() + vi.restoreAllMocks() + select.mockReset() + rmSync(home, { recursive: true, force: true }) +}) + +describe('parseTunnelProvider', () => { + it('should accept each provider, and `cloudflared` as an alias', () => { + expect(parseTunnelProvider('cloudflare')).toBe('cloudflare') + expect(parseTunnelProvider('cloudflared')).toBe('cloudflare') + expect(parseTunnelProvider('opentunnel')).toBe('opentunnel') + }) + + it('should reject anything else', () => { + for (const value of [undefined, '', 'ngrok', true, 1]) { + expect(parseTunnelProvider(value), String(value)).toBeUndefined() + } + }) +}) + +describe('deriveRouteName', () => { + const seed = 'a'.repeat(32) + + it('should be stable for a project', () => { + expect(deriveRouteName(seed, '/projects/app')).toBe(deriveRouteName(seed, '/projects/app/')) + }) + + it('should differ between projects and between seeds', () => { + expect(deriveRouteName(seed, '/projects/app')).not.toBe(deriveRouteName(seed, '/projects/other')) + expect(deriveRouteName(seed, '/projects/app')).not.toBe(deriveRouteName('b'.repeat(32), '/projects/app')) + }) + + it('should be a 16-character DNS label, as OpenTunnel routes must be', () => { + const route = deriveRouteName(seed, '/projects/my.app') + expect(route).toHaveLength(16) + expect(route).toMatch(ROUTE_LABEL_RE) + }) +}) + +describe('resolveTunnelOptions', () => { + it('should start no tunnel when the flag is absent or negated', async () => { + await expect(resolveTunnelOptions(undefined, '/app')).resolves.toBeUndefined() + await expect(resolveTunnelOptions(false, '/app')).resolves.toBeUndefined() + }) + + it('should use an explicit provider', async () => { + await expect(resolveTunnelOptions('cloudflare', '/app')).resolves.toEqual({ provider: 'cloudflare' }) + await expect(resolveTunnelOptions('opentunnel', '/app')).resolves.toMatchObject({ provider: 'opentunnel', route: expect.stringMatching(ROUTE_LABEL_RE) }) + expect(select).not.toHaveBeenCalled() + }) + + it('should make sure the project has the OpenTunnel SDK, and start no tunnel without it', async () => { + await expect(resolveTunnelOptions('opentunnel', '/app')).resolves.toMatchObject({ provider: 'opentunnel', rootDir: '/app' }) + expect(ensureOpenTunnelSDK).toHaveBeenCalledWith('/app') + + ensureOpenTunnelSDK.mockResolvedValue(false) + await expect(resolveTunnelOptions('opentunnel', '/app')).resolves.toBeUndefined() + }) + + it('should not touch the OpenTunnel SDK for a Cloudflare tunnel', async () => { + await resolveTunnelOptions('cloudflare', '/app') + expect(ensureOpenTunnelSDK).not.toHaveBeenCalled() + }) + + it('should warn about an unknown provider and use Cloudflare', async () => { + const warn = vi.spyOn(logger, 'warn').mockImplementation(() => {}) + await expect(resolveTunnelOptions('./app', '/app')).resolves.toEqual({ provider: 'cloudflare' }) + expect(warn).toHaveBeenCalledWith(expect.stringContaining('Unknown tunnel provider `./app`')) + }) + + it('should keep the OpenTunnel route between runs by saving a seed once', async () => { + const first = await resolveTunnelOptions('opentunnel', '/app') + const rc = readNuxtRC() + expect(rc).toMatch(/^tools\.opentunnel\.seed="[0-9a-f]{32}"$/m) + + await expect(resolveTunnelOptions('opentunnel', '/app')).resolves.toEqual(first) + expect(readNuxtRC()).toBe(rc) + }) + + it('should use the saved provider for a bare --tunnel', async () => { + writeFileSync(join(home, '.nuxtrc'), 'tools.tunnel.provider="opentunnel"\n') + await expect(resolveTunnelOptions('', '/app')).resolves.toMatchObject({ provider: 'opentunnel' }) + expect(select).not.toHaveBeenCalled() + }) + + it('should use Cloudflare without asking or saving when nobody can answer', async () => { + await expect(resolveTunnelOptions('', '/app')).resolves.toEqual({ provider: 'cloudflare' }) + expect(select).not.toHaveBeenCalled() + expect(readNuxtRC()).not.toContain('tools.tunnel.provider') + }) + + it('should ask once for a bare --tunnel and remember the answer', async () => { + isInteractive.mockReturnValue(true) + vi.spyOn(logger, 'info').mockImplementation(() => {}) + select.mockResolvedValue('opentunnel') + + await expect(resolveTunnelOptions('', '/app')).resolves.toMatchObject({ provider: 'opentunnel' }) + expect(select).toHaveBeenCalledTimes(1) + expect(readNuxtRC()).toContain('tools.tunnel.provider="opentunnel"') + + await expect(resolveTunnelOptions(true, '/app')).resolves.toMatchObject({ provider: 'opentunnel' }) + expect(select).toHaveBeenCalledTimes(1) + }) + + it('should start no tunnel and save nothing when the prompt is cancelled', async () => { + isInteractive.mockReturnValue(true) + vi.spyOn(logger, 'info').mockImplementation(() => {}) + select.mockResolvedValue(CANCEL) + + await expect(resolveTunnelOptions('', '/app')).resolves.toBeUndefined() + expect(readNuxtRC()).not.toContain('tools.tunnel.provider') + }) +}) diff --git a/packages/nuxt-cli/test/unit/dev/tunnel.spec.ts b/packages/nuxt-cli/test/unit/dev/tunnel.spec.ts new file mode 100644 index 000000000..b37244f4d --- /dev/null +++ b/packages/nuxt-cli/test/unit/dev/tunnel.spec.ts @@ -0,0 +1,176 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { logger } from '../../../src/utils/logger' + +const identity = { id: 'id', hostname: 'abc123.opentunnel.xyz', token: 't', privateKey: 'k', certificate: 'c', chain: 'ch', certificateExpiry: new Date(0) } + +function createConnection() { + return { + tunnel: identity, + events: (async function* () {})(), + closed: new Promise(() => {}), + close: vi.fn(async () => {}), + } +} + +const client = { + tunnel: { + get: vi.fn(), + pending: vi.fn(), + resume: vi.fn(), + create: vi.fn(), + connect: vi.fn(), + }, + dispose: vi.fn(async () => {}), +} + +const loadOpenTunnelSDK = vi.fn(async (_rootDir: string) => ({ create: () => client }) as unknown) +vi.mock('../../../src/dev/tunnel/opentunnel-loader', () => ({ loadOpenTunnelSDK })) + +const startCloudflaredTunnel = vi.fn(async (url: string) => ({ url: `cloudflared:${url}`, close: async () => {} })) +vi.mock('../../../src/dev/tunnel/cloudflared', () => ({ startCloudflaredTunnel })) + +const { startOpenTunnel } = await import('../../../src/dev/tunnel/opentunnel') +const { startTunnel } = await import('../../../src/dev/tunnel') + +beforeEach(() => { + client.tunnel.get.mockResolvedValue(identity) + client.tunnel.pending.mockResolvedValue(undefined) + client.tunnel.create.mockResolvedValue(identity) + client.tunnel.connect.mockImplementation(async () => createConnection()) +}) + +afterEach(() => { + vi.useRealTimers() + vi.restoreAllMocks() + for (const fn of [...Object.values(client.tunnel), client.dispose, startCloudflaredTunnel, loadOpenTunnelSDK]) { + fn.mockClear() + } +}) + +describe('startOpenTunnel', () => { + it('should route the project to the dev server and close the bridge on close', async () => { + const tunnel = await startOpenTunnel('route', 3000, { rootDir: '/app' }) + + expect(client.tunnel.connect).toHaveBeenCalledWith({ routes: { route: 'localhost:3000' } }) + expect(client.tunnel.create).not.toHaveBeenCalled() + expect(tunnel?.url).toBe('https://route.abc123.opentunnel.xyz') + + const connection = await client.tunnel.connect.mock.results[0]!.value + await tunnel!.close() + expect(connection.close).toHaveBeenCalledTimes(1) + expect(client.dispose).toHaveBeenCalledTimes(1) + }) + + it('should create the tunnel on the first run, with progress', async () => { + vi.spyOn(logger, 'info').mockImplementation(() => {}) + vi.spyOn(logger, 'success').mockImplementation(() => {}) + client.tunnel.get.mockResolvedValue(undefined) + client.tunnel.create.mockImplementation(async ({ onProgress }: { onProgress: (stage: string) => void }) => { + onProgress('creating-tunnel') + onProgress('waiting-certificate') + return identity + }) + + const tunnel = await startOpenTunnel('route', 3000, { rootDir: '/app' }) + + expect(client.tunnel.create).toHaveBeenCalledTimes(1) + expect(tunnel?.url).toBe('https://route.abc123.opentunnel.xyz') + }) + + it('should finish a tunnel an earlier run left waiting for its certificate', async () => { + vi.spyOn(logger, 'success').mockImplementation(() => {}) + client.tunnel.get.mockResolvedValue(undefined) + client.tunnel.pending.mockResolvedValue({ id: 'id', hostname: identity.hostname }) + client.tunnel.resume.mockResolvedValue(identity) + + await startOpenTunnel('route', 3000, { rootDir: '/app' }) + + expect(client.tunnel.resume).toHaveBeenCalledTimes(1) + expect(client.tunnel.create).not.toHaveBeenCalled() + }) + + it('should keep serving without a tunnel when the client cannot be loaded', async () => { + loadOpenTunnelSDK.mockResolvedValueOnce(undefined) + + await expect(startOpenTunnel('route', 3000, { rootDir: '/app' })).resolves.toBeUndefined() + + expect(client.tunnel.connect).not.toHaveBeenCalled() + }) + + it('should warn and keep serving without a tunnel when it cannot connect', async () => { + const warn = vi.spyOn(logger, 'warn').mockImplementation(() => {}) + client.tunnel.connect.mockRejectedValue(new Error('invalid token')) + + await expect(startOpenTunnel('route', 3000, { rootDir: '/app' })).resolves.toBeUndefined() + + expect(warn).toHaveBeenCalledWith(expect.stringContaining('invalid token')) + expect(client.dispose).toHaveBeenCalledTimes(1) + }) + + it('should give up on a bridge that never attaches', async () => { + vi.useFakeTimers() + const warn = vi.spyOn(logger, 'warn').mockImplementation(() => {}) + client.tunnel.connect.mockReturnValue(new Promise(() => {})) + + const started = startOpenTunnel('route', 3000, { rootDir: '/app' }) + await vi.advanceTimersByTimeAsync(20_000) + + await expect(started).resolves.toBeUndefined() + expect(warn).toHaveBeenCalledWith(expect.stringContaining('Timed out connecting')) + }) + + it('should not wait for the route during a handover, as the outgoing server still holds it', async () => { + let attach: ((connection: ReturnType) => void) | undefined + client.tunnel.connect.mockReturnValue(new Promise((resolve) => { + attach = resolve + })) + + const tunnel = await startOpenTunnel('route', 3000, { rootDir: '/app', handover: true }) + expect(tunnel?.url).toBe('https://route.abc123.opentunnel.xyz') + + const connection = createConnection() + attach!(connection) + await vi.waitFor(() => expect(client.tunnel.connect).toHaveBeenCalledTimes(1)) + await tunnel!.close() + expect(connection.close).toHaveBeenCalledTimes(1) + expect(client.dispose).toHaveBeenCalledTimes(1) + }) + + it('should drop a bridge that attaches after a handover tunnel was closed', async () => { + let attach: ((connection: ReturnType) => void) | undefined + client.tunnel.connect.mockReturnValue(new Promise((resolve) => { + attach = resolve + })) + + const tunnel = await startOpenTunnel('route', 3000, { rootDir: '/app', handover: true }) + await tunnel!.close() + expect(client.dispose).toHaveBeenCalledTimes(1) + + const connection = createConnection() + attach!(connection) + await vi.waitFor(() => expect(connection.close).toHaveBeenCalledTimes(1)) + }) +}) + +describe('startTunnel', () => { + it('should start a Cloudflare quick tunnel', async () => { + await startTunnel({ provider: 'cloudflare' }, { protocol: 'https', port: 3000 }) + expect(startCloudflaredTunnel).toHaveBeenCalledWith('https://localhost:3000', true) + }) + + it('should start OpenTunnel with the resolved route', async () => { + const tunnel = await startTunnel({ provider: 'opentunnel', route: 'route', rootDir: '/app' }, { protocol: 'http', port: 3000 }) + expect(tunnel?.url).toBe('https://route.abc123.opentunnel.xyz') + expect(loadOpenTunnelSDK).toHaveBeenCalledWith('/app') + expect(startCloudflaredTunnel).not.toHaveBeenCalled() + }) + + it('should fall back to Cloudflare for an HTTPS dev server, which OpenTunnel cannot forward to', async () => { + const warn = vi.spyOn(logger, 'warn').mockImplementation(() => {}) + await startTunnel({ provider: 'opentunnel', route: 'route', rootDir: '/app' }, { protocol: 'https', port: 3000 }) + expect(warn).toHaveBeenCalledWith(expect.stringContaining('cannot forward to an HTTPS dev server')) + expect(startCloudflaredTunnel).toHaveBeenCalledWith('https://localhost:3000', true) + expect(client.tunnel.connect).not.toHaveBeenCalled() + }) +}) diff --git a/packages/nuxt-cli/test/unit/help.spec.ts b/packages/nuxt-cli/test/unit/help.spec.ts index 5180522c8..8f12a35d9 100644 --- a/packages/nuxt-cli/test/unit/help.spec.ts +++ b/packages/nuxt-cli/test/unit/help.spec.ts @@ -234,7 +234,7 @@ describe('help', () => { --open.url= Path or URL to open instead of the dev server root --clipboard Copy the URL to the clipboard (Default: false) --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= 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= Public URL to display (used for QR code and clipboard) --https Enable HTTPS with a locally-trusted development certificate diff --git a/packages/nuxt-cli/test/unit/listen.spec.ts b/packages/nuxt-cli/test/unit/listen.spec.ts index b68081b23..e05dfd4ea 100644 --- a/packages/nuxt-cli/test/unit/listen.spec.ts +++ b/packages/nuxt-cli/test/unit/listen.spec.ts @@ -359,7 +359,7 @@ describe('listener.close', () => { }), })) - const listener = await listen((_req, res) => res.end('ok'), { port: 0, hostname: '127.0.0.1', baseURL: '/dashboard/', showURL: false, tunnel: true }) + const listener = await listen((_req, res) => res.end('ok'), { port: 0, hostname: '127.0.0.1', baseURL: '/dashboard/', showURL: false, tunnel: { provider: 'cloudflare' } }) expect(listener.publicURL).toBe('https://example.test/dashboard/') expect(listener.getURLs()).toContainEqual({ url: 'https://example.test/dashboard/', type: 'tunnel' }) @@ -371,6 +371,21 @@ describe('listener.close', () => { vi.doUnmock('../../src/dev/tunnel') }) + it('should hand the provider, port and handover state to the tunnel', async () => { + const startTunnel = vi.fn(async () => ({ url: 'https://route.example.test', close: async () => {} })) + vi.doMock('../../src/dev/tunnel', () => ({ startTunnel })) + + try { + const listener = await listen((_req, res) => res.end('ok'), { port: 0, hostname: '127.0.0.1', showURL: false, handover: true, tunnel: { provider: 'opentunnel', route: 'route', rootDir: '/app' } }) + expect(startTunnel).toHaveBeenCalledWith({ provider: 'opentunnel', route: 'route', rootDir: '/app' }, { protocol: 'http', port: listener.address.port, handover: true }) + expect(listener.publicURL).toBe('https://route.example.test/') + await listener.close() + } + finally { + vi.doUnmock('../../src/dev/tunnel') + } + }) + it('should close the tunnel when setup fails after it started', async () => { const closeTunnel = vi.fn(() => Promise.resolve()) vi.doMock('../../src/dev/tunnel', () => ({ @@ -385,7 +400,7 @@ describe('listener.close', () => { Object.defineProperty(process.stdout, 'isTTY', { value: true, configurable: true }) try { - await expect(listen((_req, res) => res.end('ok'), { port: 0, hostname: '127.0.0.1', tunnel: true, qr: true })).rejects.toThrow('qr unavailable') + await expect(listen((_req, res) => res.end('ok'), { port: 0, hostname: '127.0.0.1', tunnel: { provider: 'cloudflare' }, qr: true })).rejects.toThrow('qr unavailable') expect(closeTunnel).toHaveBeenCalledTimes(1) } @@ -407,7 +422,7 @@ describe('listener.close', () => { const port = probe.address.port await probe.close() - await expect(listen((_req, res) => res.end('ok'), { port, hostname: '127.0.0.1', showURL: false, strictPort: true, tunnel: true })).rejects.toThrow('tunnel unavailable') + await expect(listen((_req, res) => res.end('ok'), { port, hostname: '127.0.0.1', showURL: false, strictPort: true, tunnel: { provider: 'cloudflare' } })).rejects.toThrow('tunnel unavailable') vi.doUnmock('../../src/dev/tunnel') const listener = await listen((_req, res) => res.end('ok'), { port, hostname: '127.0.0.1', showURL: false, strictPort: true }) diff --git a/packages/nuxt-cli/test/unit/utils/optional-flag.spec.ts b/packages/nuxt-cli/test/unit/utils/optional-flag.spec.ts new file mode 100644 index 000000000..d4937fffb --- /dev/null +++ b/packages/nuxt-cli/test/unit/utils/optional-flag.spec.ts @@ -0,0 +1,44 @@ +import { parseArgs } from 'citty' +import { describe, expect, it } from 'vitest' + +import { TUNNEL_FLAG_VALUES } from '../../../src/dev/tunnel/types' +import { normalizeOptionalValueFlag } from '../../../src/utils/optional-flag' + +function parse(argv: string[]) { + const rawArgs = [...argv] + normalizeOptionalValueFlag(rawArgs, '--tunnel', TUNNEL_FLAG_VALUES) + const { _, ...args } = parseArgs(rawArgs, { + rootDir: { type: 'positional', required: false }, + tunnel: { type: 'string' }, + port: { type: 'string' }, + }) + return { tunnel: args.tunnel as string | boolean | undefined, rootDir: args.rootDir, port: args.port } +} + +describe('normalizeOptionalValueFlag', () => { + it('should leave a bare flag empty instead of taking the next flag as its value', () => { + expect(parse(['--tunnel', '--port', '3000'])).toEqual({ tunnel: '', rootDir: undefined, port: '3000' }) + }) + + it('should not take the project directory as the value', () => { + expect(parse(['--tunnel', './app'])).toEqual({ tunnel: '', rootDir: './app', port: undefined }) + expect(parse(['./app', '--tunnel'])).toEqual({ tunnel: '', rootDir: './app', port: undefined }) + }) + + it('should keep a known value, given either way', () => { + expect(parse(['--tunnel', 'opentunnel']).tunnel).toBe('opentunnel') + expect(parse(['--tunnel=opentunnel']).tunnel).toBe('opentunnel') + expect(parse(['--tunnel', 'cloudflared']).tunnel).toBe('cloudflared') + }) + + it('should leave an explicit unknown value for the caller to report', () => { + expect(parse(['--tunnel=ngrok']).tunnel).toBe('ngrok') + }) + + it('should leave negation and arguments after `--` alone', () => { + expect(parse(['--no-tunnel']).tunnel).toBe(false) + const rawArgs = ['--', '--tunnel', './app'] + normalizeOptionalValueFlag(rawArgs, '--tunnel', TUNNEL_FLAG_VALUES) + expect(rawArgs).toEqual(['--', '--tunnel', './app']) + }) +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9dc24fd82..c425f6a66 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -470,6 +470,9 @@ importers: '@nuxt/test-utils': specifier: ^4.3.3 version: 4.3.3(@oxc-project/types@0.152.0)(esbuild@0.28.2)(h3@1.15.11)(magicast@0.5.5)(nuxt@4.5.2(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7(supports-color@10.2.2)))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7(supports-color@10.2.2)))(@oxc-project/types@0.152.0)(@types/node@24.19.1)(@vitejs/devtools@0.7.6)(@vue/compiler-sfc@3.5.43)(db0@0.3.4)(esbuild@0.28.2)(eslint@10.12.0(jiti@2.7.0)(supports-color@10.2.2))(ioredis@5.11.1(supports-color@10.2.2))(lightningcss@1.33.0)(magicast@0.5.5)(optionator@0.9.4)(oxc-parser@0.150.0)(rolldown@1.2.12)(rollup-plugin-visualizer@7.1.1(rolldown@1.2.12)(rollup@4.63.5))(rollup@4.63.5)(srvx@1.0.5)(supports-color@10.2.2)(terser@5.50.0)(typescript@6.0.3)(vite@8.3.1)(vue-tsc@3.3.12(typescript@6.0.3))(yaml@2.9.1))(oxc-parser@0.150.0)(rolldown@1.2.12)(rollup@4.63.5)(vite@8.3.1)(vitest@4.1.11)(vue@3.5.43(typescript@6.0.3)) + '@opentunnel/client': + specifier: 0.4.0 + version: 0.4.0(effect@4.0.2) '@speed-highlight/core': specifier: ^2.1.0 version: 2.1.0 @@ -479,6 +482,9 @@ importers: citty: specifier: ^0.2.2 version: 0.2.2(patch_hash=24c57c383378e5de0f0b955a987077b0d3ab2cc2f1d3919c5193e801c7afadac) + effect: + specifier: ^4.0.2 + version: 4.0.2 giget: specifier: ^3.3.1 version: 3.3.1 @@ -1596,6 +1602,17 @@ packages: nuxt: npm:nuxt-nightly@5.0.0-2610071710-0296ca4 vite: ^8.3.1 + '@opentunnel/client@0.4.0': + resolution: {integrity: sha512-Y6UiO/ouJ/vtdIy4QmCTRY2DPTRpHy2+jR0F6+AKXoD57ozd8W9ZTTP2J1KDqTb7ZfJmRTuqtx1ZR2TnejlZGQ==} + engines: {bun: '>=1.4.0', node: '>=22'} + peerDependencies: + effect: ^4.0.0 + + '@opentunnel/protocol@0.4.0': + resolution: {integrity: sha512-rxmfgJ5O1EYxgzawkOti9DyiM44l0Bkeue5KuHpCFaPAP2n9i4xDYCU8XQp9WAEFt7ifZZqI9giOyzJE61faaA==} + peerDependencies: + effect: ^4.0.0 + '@ota-meshi/ast-token-store@0.3.0': resolution: {integrity: sha512-XRO0zi2NIUKq2lUk3T1ecFSld1fMWRKE6naRFGkgkdeosx7IslyUKNv5Dcb5PJTja9tHJoFu0v/7yEpAkrkrTg==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} @@ -3495,6 +3512,9 @@ packages: ee-first@1.1.1: resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + effect@4.0.2: + resolution: {integrity: sha512-YeYnLqSFrL4+tCOXRrfNP03w8FILoF/wDl+ukraJAGzXrjcS383W7LAAvGBbLatEyktmLxRq+9xJinx7QzXW5A==} + electron-to-chromium@1.5.412: resolution: {integrity: sha512-z4rMe3esBzlzovKHj4gxJnsCGZRK5l4baUvm+gCGJBPE+gsyUMKsuU9tnEUtI1dOebXz1ytAPGjvXhmQ7rIPwA==} @@ -8914,6 +8934,15 @@ snapshots: - rolldown - unplugin + '@opentunnel/client@0.4.0(effect@4.0.2)': + dependencies: + '@opentunnel/protocol': 0.4.0(effect@4.0.2) + effect: 4.0.2 + + '@opentunnel/protocol@0.4.0(effect@4.0.2)': + dependencies: + effect: 4.0.2 + '@ota-meshi/ast-token-store@0.3.0': {} '@oxc-parser/binding-android-arm-eabi@0.150.0': @@ -10699,6 +10728,8 @@ snapshots: ee-first@1.1.1: {} + effect@4.0.2: {} + electron-to-chromium@1.5.412: {} emoji-regex@10.6.0: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 0e3958e3f..5e3c096d3 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -8,6 +8,8 @@ minimumReleaseAgeExclude: - fuzzysort@4.0.1 - errx - my-bad + - '@opentunnel/client@0.4.0' + - '@opentunnel/protocol@0.4.0' verifyDepsBeforeRun: install From 7f0584b6b32b59798ec8c457f4761bd54eda7082 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Chopin?= Date: Fri, 9 Oct 2026 16:16:21 +0200 Subject: [PATCH 2/5] test(dev): stop building module source from data in the OpenTunnel SDK fixture --- .../nuxt-cli/test/unit/dev/opentunnel-sdk.spec.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/packages/nuxt-cli/test/unit/dev/opentunnel-sdk.spec.ts b/packages/nuxt-cli/test/unit/dev/opentunnel-sdk.spec.ts index df81b7fef..7eeed0dcf 100644 --- a/packages/nuxt-cli/test/unit/dev/opentunnel-sdk.spec.ts +++ b/packages/nuxt-cli/test/unit/dev/opentunnel-sdk.spec.ts @@ -1,7 +1,7 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' -import { join } from 'pathe' +import { basename, join } from 'pathe' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { logger } from '../../../src/utils/logger' @@ -34,13 +34,14 @@ let project: string /** * A package in the project's `node_modules`. Like `@opentunnel/client`, it does - * not export its `package.json`, so it is found through its main entry. + * not export its `package.json`, so it is found through its main entry. Its + * `create` returns the URL of the module, so a test can tell which copy loaded. */ function installPackage(name: string, version: string): void { const dir = join(project, 'node_modules', name) mkdirSync(join(dir, 'dist'), { recursive: true }) writeFileSync(join(dir, 'package.json'), JSON.stringify({ name, version, type: 'module', exports: { '.': { default: './dist/index.js' } } })) - writeFileSync(join(dir, 'dist/index.js'), `export const create = () => ${JSON.stringify(`${name}@${version}`)}\n`) + writeFileSync(join(dir, 'dist/index.js'), 'export const create = () => import.meta.url\n') } beforeEach(() => { @@ -80,7 +81,9 @@ describe('loadOpenTunnelSDK', () => { it('should import the SDK from the project', async () => { installPackage(OPENTUNNEL_SDK, '0.4.0') const sdk = await loadOpenTunnelSDK(project) - expect((sdk!.create as unknown as () => string)()).toBe(`${OPENTUNNEL_SDK}@0.4.0`) + // Compared by its end: the resolver returns the real path, and the temporary + // directory sits behind a symlink on macOS. + expect((sdk!.create as unknown as () => string)()).toContain(`/${basename(project)}/node_modules/${OPENTUNNEL_SDK}/dist/index.js`) }) it('should warn when the project has no usable SDK', async () => { From e2aa90a91a2c2f239a1268e2f10137bd1e5f0bb3 Mon Sep 17 00:00:00 2001 From: "coderabbitai[bot]" <136622811+coderabbitai[bot]@users.noreply.github.com> Date: Fri, 9 Oct 2026 17:37:08 +0000 Subject: [PATCH 3/5] docs: add JSDoc for CLI startup and dev tunnel helpers --- packages/nuxt-cli/src/commands/dev.ts | 2 ++ packages/nuxt-cli/src/dev/tunnel/cloudflared.ts | 6 ++++++ packages/nuxt-cli/src/dev/tunnel/opentunnel.ts | 5 +++++ packages/nuxt-cli/src/dev/tunnel/resolve.ts | 9 +++++++++ packages/nuxt-cli/src/main.ts | 1 + packages/nuxt-cli/src/run.ts | 5 +++++ 6 files changed, 28 insertions(+) diff --git a/packages/nuxt-cli/src/commands/dev.ts b/packages/nuxt-cli/src/commands/dev.ts index 0c6570376..135e0398f 100644 --- a/packages/nuxt-cli/src/commands/dev.ts +++ b/packages/nuxt-cli/src/commands/dev.ts @@ -173,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 })) @@ -509,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): DevListenOverrides { const httpsOptions: HTTPSOptions = { cert: args['https.cert'] diff --git a/packages/nuxt-cli/src/dev/tunnel/cloudflared.ts b/packages/nuxt-cli/src/dev/tunnel/cloudflared.ts index 17581d376..0c0dc2105 100644 --- a/packages/nuxt-cli/src/dev/tunnel/cloudflared.ts +++ b/packages/nuxt-cli/src/dev/tunnel/cloudflared.ts @@ -35,6 +35,11 @@ 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 +/** + * 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 { const binary = await resolveCloudflared() if (!binary) { @@ -175,6 +180,7 @@ const CLOUDFLARED_SHA256: Record = { 'cloudflared-windows-amd64.exe': '8635da433b6df8194746e88ed9d2589566c20e38bfc2a80e431a348b7c765841', } +/** Find or download cloudflared for this platform, requesting consent when installation is needed. */ async function resolveCloudflared(): Promise { const CLOUDFLARED_VERSION = resolveCloudflaredVersion() const base = CLOUDFLARED_VERSION === 'latest' diff --git a/packages/nuxt-cli/src/dev/tunnel/opentunnel.ts b/packages/nuxt-cli/src/dev/tunnel/opentunnel.ts index 843c7c0ad..92ddec55c 100644 --- a/packages/nuxt-cli/src/dev/tunnel/opentunnel.ts +++ b/packages/nuxt-cli/src/dev/tunnel/opentunnel.ts @@ -77,6 +77,7 @@ export async function startOpenTunnel(route: string, port: number, options: Open } } +/** Return the handover URL immediately and attach the bridge when the route becomes available. */ function attachInBackground(client: OpenTunnelPromiseClient, routes: Record, url: string): Tunnel { let closing = false const connecting = client.tunnel.connect({ routes }).then( @@ -107,6 +108,7 @@ function attachInBackground(client: OpenTunnelPromiseClient, routes: Record { try { await withTimeout( @@ -120,6 +122,7 @@ async function closeConnection(client: OpenTunnelPromiseClient, connection: Open } } +/** Consume bridge events in the background, warning on failure and logging other events for debugging. */ function watchEvents(connection: OpenTunnelConnection): void { void (async () => { for await (const event of connection.events) { @@ -183,6 +186,7 @@ function provision(client: OpenTunnelPromiseClient): Promise { })) } +/** Reject with `message` after `ms`, clearing the timer without cancelling the underlying operation. */ function withTimeout(promise: Promise, ms: number, message: string): Promise { let timer: ReturnType | undefined const timeout = new Promise((_, reject) => { @@ -191,6 +195,7 @@ function withTimeout(promise: Promise, ms: number, message: string): Promi return Promise.race([promise, timeout]).finally(() => clearTimeout(timer)) } +/** Format an error with its immediate cause, or stringify a non-Error value. */ function describeError(error: unknown): string { if (error instanceof Error) { const cause = error.cause instanceof Error ? ` (${error.cause.message})` : '' diff --git a/packages/nuxt-cli/src/dev/tunnel/resolve.ts b/packages/nuxt-cli/src/dev/tunnel/resolve.ts index dcef37b5b..d6fbb043b 100644 --- a/packages/nuxt-cli/src/dev/tunnel/resolve.ts +++ b/packages/nuxt-cli/src/dev/tunnel/resolve.ts @@ -26,6 +26,7 @@ interface TunnelRC { } } +/** Normalize a supported provider or its `cloudflared` alias, returning `undefined` for unknown values. */ export function parseTunnelProvider(value: unknown): TunnelProvider | undefined { if (value === 'cloudflare' || value === 'cloudflared') { return 'cloudflare' @@ -57,6 +58,11 @@ export async function resolveTunnelOptions(flag: string | boolean | undefined, r return provider ? { provider } : undefined } +/** + * Use an explicit or saved provider, otherwise prompt and save the choice. + * Fall back to Cloudflare for unknown values or a non-interactive first run; + * return `undefined` when the prompt is cancelled. + */ async function resolveTunnelProvider(flag: string | true): Promise { if (flag !== true && flag !== '') { const provider = parseTunnelProvider(flag) @@ -105,6 +111,7 @@ export function deriveRouteName(seed: string, rootDir: string): string { return createHash('sha256').update(`${seed}\0${resolve(rootDir)}`).digest('hex').slice(0, 16) } +/** Reuse a valid saved seed or generate one, attempting to persist it for stable project URLs. */ function resolveSeed(): string { const saved = readRC().tools?.opentunnel?.seed if (typeof saved === 'string' && SEED_RE.test(saved)) { @@ -116,6 +123,7 @@ function resolveSeed(): string { return seed } +/** Read the user tunnel settings, returning an empty object if reading fails. */ function readRC(): TunnelRC { try { return readUser(RC_FILE) as TunnelRC @@ -126,6 +134,7 @@ function readRC(): TunnelRC { } } +/** Merge tunnel settings into the user `.nuxtrc`, returning whether the update succeeded. */ function writeRC(config: TunnelRC): boolean { try { updateUser(config, RC_FILE) diff --git a/packages/nuxt-cli/src/main.ts b/packages/nuxt-cli/src/main.ts index 7d21bb526..03be91553 100644 --- a/packages/nuxt-cli/src/main.ts +++ b/packages/nuxt-cli/src/main.ts @@ -39,6 +39,7 @@ const _main = defineCommand({ }, }, subCommands: commands, + /** Prepare the CLI environment and arguments, check flags and dispatch custom or legacy commands. */ async setup(ctx) { const command = ctx.args._[0] setCurrentCommand(command) diff --git a/packages/nuxt-cli/src/run.ts b/packages/nuxt-cli/src/run.ts index ab74fae68..e444708fa 100644 --- a/packages/nuxt-cli/src/run.ts +++ b/packages/nuxt-cli/src/run.ts @@ -46,6 +46,11 @@ export async function runMain(): Promise { } } +/** + * Run a registered command with a copy of `argv` and the supplied config + * overrides, normalizing optional dev tunnel values before argument parsing. + * Throws when `name` is not a registered command. + */ export async function runCommand( name: string, argv: string[] = process.argv.slice(2), From ccc482454fc58d0f471b9e72386b808762a3c6ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Chopin?= Date: Fri, 9 Oct 2026 20:50:13 +0200 Subject: [PATCH 4/5] fix(dev): forward tunnels to the address the dev server is bound to Both providers forwarded to `localhost:`, which does not reach a server bound to one non-loopback address (`--host 192.168.1.5`), nor one bound to `::1` when `localhost` resolves to 127.0.0.1. `localhost` is kept for servers on every interface. --- packages/nuxt-cli/src/dev/listen.ts | 2 +- packages/nuxt-cli/src/dev/tunnel/index.ts | 21 ++++++-- .../nuxt-cli/src/dev/tunnel/opentunnel.ts | 9 ++-- .../test/unit/dev/tunnel-lazy.spec.ts | 4 +- .../nuxt-cli/test/unit/dev/tunnel.spec.ts | 48 ++++++++++++++----- packages/nuxt-cli/test/unit/listen.spec.ts | 2 +- 6 files changed, 63 insertions(+), 23 deletions(-) diff --git a/packages/nuxt-cli/src/dev/listen.ts b/packages/nuxt-cli/src/dev/listen.ts index aba4dfb78..f25faea03 100644 --- a/packages/nuxt-cli/src/dev/listen.ts +++ b/packages/nuxt-cli/src/dev/listen.ts @@ -268,7 +268,7 @@ export async function createListener(bound: BoundServer, options: ListenOptions if (announce && options.tunnel) { const { startTunnel } = await import('./tunnel') - tunnel = await startTunnel(options.tunnel, { protocol, port: address.port, handover: options.handover }) + tunnel = await startTunnel(options.tunnel, { protocol, hostname, port: address.port, handover: options.handover }) } const tunnelURL = tunnel?.url && tunnel.url + baseURL diff --git a/packages/nuxt-cli/src/dev/tunnel/index.ts b/packages/nuxt-cli/src/dev/tunnel/index.ts index c2bd33084..81c7e8271 100644 --- a/packages/nuxt-cli/src/dev/tunnel/index.ts +++ b/packages/nuxt-cli/src/dev/tunnel/index.ts @@ -6,17 +6,32 @@ 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): 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 on `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 { + const local = formatTunnelTarget(target) if (options.provider === 'opentunnel') { // OpenTunnel terminates TLS itself and forwards plain TCP, which an HTTPS // dev server cannot read. @@ -25,9 +40,9 @@ export async function startTunnel(options: TunnelOptions, target: TunnelTarget): } else { const { startOpenTunnel } = await import('./opentunnel') - return startOpenTunnel(options.route, target.port, { handover: target.handover, rootDir: options.rootDir }) + return startOpenTunnel(options.route, local, { handover: target.handover, rootDir: options.rootDir }) } } const { startCloudflaredTunnel } = await import('./cloudflared') - return startCloudflaredTunnel(`${target.protocol}://localhost:${target.port}`, target.protocol === 'https') + return startCloudflaredTunnel(`${target.protocol}://${local}`, target.protocol === 'https') } diff --git a/packages/nuxt-cli/src/dev/tunnel/opentunnel.ts b/packages/nuxt-cli/src/dev/tunnel/opentunnel.ts index 92ddec55c..fce393c8d 100644 --- a/packages/nuxt-cli/src/dev/tunnel/opentunnel.ts +++ b/packages/nuxt-cli/src/dev/tunnel/opentunnel.ts @@ -40,20 +40,21 @@ export interface OpenTunnelOptions { } /** - * Expose `localhost:` as `https://.` through the user's - * default OpenTunnel profile, the one the `opentunnel` CLI also uses. + * Expose the dev server at `target` (`host:port`) as `https://.` + * through the user's default OpenTunnel profile, the one the `opentunnel` CLI + * also uses. * * TLS terminates in this process, so the relay never sees plaintext. The route * only exists while the bridge is attached: nothing keeps running once the dev * server stops. */ -export async function startOpenTunnel(route: string, port: number, options: OpenTunnelOptions): Promise { +export async function startOpenTunnel(route: string, target: string, options: OpenTunnelOptions): Promise { const sdk = await loadOpenTunnelSDK(options.rootDir) if (!sdk) { return undefined } const client = sdk.create() - const routes = { [route]: `localhost:${port}` } + const routes = { [route]: target } try { const identity = await client.tunnel.get() if (identity && options.handover) { diff --git a/packages/nuxt-cli/test/unit/dev/tunnel-lazy.spec.ts b/packages/nuxt-cli/test/unit/dev/tunnel-lazy.spec.ts index a0a9bea6a..ea69a94e5 100644 --- a/packages/nuxt-cli/test/unit/dev/tunnel-lazy.spec.ts +++ b/packages/nuxt-cli/test/unit/dev/tunnel-lazy.spec.ts @@ -19,10 +19,10 @@ describe('tunnel module graph', () => { const { startTunnel } = await import('../../../src/dev/tunnel') expect([...evaluated]).toEqual([]) - await startTunnel({ provider: 'cloudflare' }, { protocol: 'http', port: 3000 }) + await startTunnel({ provider: 'cloudflare' }, { protocol: 'http', hostname: '', port: 3000 }) expect([...evaluated]).toEqual(['cloudflared']) - await startTunnel({ provider: 'opentunnel', route: 'route', rootDir: '/app' }, { protocol: 'http', port: 3000 }) + await startTunnel({ provider: 'opentunnel', route: 'route', rootDir: '/app' }, { protocol: 'http', hostname: '', port: 3000 }) expect([...evaluated]).toEqual(['cloudflared', 'opentunnel']) }) }) diff --git a/packages/nuxt-cli/test/unit/dev/tunnel.spec.ts b/packages/nuxt-cli/test/unit/dev/tunnel.spec.ts index b37244f4d..d47daf190 100644 --- a/packages/nuxt-cli/test/unit/dev/tunnel.spec.ts +++ b/packages/nuxt-cli/test/unit/dev/tunnel.spec.ts @@ -31,7 +31,7 @@ const startCloudflaredTunnel = vi.fn(async (url: string) => ({ url: `cloudflared vi.mock('../../../src/dev/tunnel/cloudflared', () => ({ startCloudflaredTunnel })) const { startOpenTunnel } = await import('../../../src/dev/tunnel/opentunnel') -const { startTunnel } = await import('../../../src/dev/tunnel') +const { formatTunnelTarget, startTunnel } = await import('../../../src/dev/tunnel') beforeEach(() => { client.tunnel.get.mockResolvedValue(identity) @@ -50,7 +50,7 @@ afterEach(() => { describe('startOpenTunnel', () => { it('should route the project to the dev server and close the bridge on close', async () => { - const tunnel = await startOpenTunnel('route', 3000, { rootDir: '/app' }) + const tunnel = await startOpenTunnel('route', 'localhost:3000', { rootDir: '/app' }) expect(client.tunnel.connect).toHaveBeenCalledWith({ routes: { route: 'localhost:3000' } }) expect(client.tunnel.create).not.toHaveBeenCalled() @@ -72,7 +72,7 @@ describe('startOpenTunnel', () => { return identity }) - const tunnel = await startOpenTunnel('route', 3000, { rootDir: '/app' }) + const tunnel = await startOpenTunnel('route', 'localhost:3000', { rootDir: '/app' }) expect(client.tunnel.create).toHaveBeenCalledTimes(1) expect(tunnel?.url).toBe('https://route.abc123.opentunnel.xyz') @@ -84,7 +84,7 @@ describe('startOpenTunnel', () => { client.tunnel.pending.mockResolvedValue({ id: 'id', hostname: identity.hostname }) client.tunnel.resume.mockResolvedValue(identity) - await startOpenTunnel('route', 3000, { rootDir: '/app' }) + await startOpenTunnel('route', 'localhost:3000', { rootDir: '/app' }) expect(client.tunnel.resume).toHaveBeenCalledTimes(1) expect(client.tunnel.create).not.toHaveBeenCalled() @@ -93,7 +93,7 @@ describe('startOpenTunnel', () => { it('should keep serving without a tunnel when the client cannot be loaded', async () => { loadOpenTunnelSDK.mockResolvedValueOnce(undefined) - await expect(startOpenTunnel('route', 3000, { rootDir: '/app' })).resolves.toBeUndefined() + await expect(startOpenTunnel('route', 'localhost:3000', { rootDir: '/app' })).resolves.toBeUndefined() expect(client.tunnel.connect).not.toHaveBeenCalled() }) @@ -102,7 +102,7 @@ describe('startOpenTunnel', () => { const warn = vi.spyOn(logger, 'warn').mockImplementation(() => {}) client.tunnel.connect.mockRejectedValue(new Error('invalid token')) - await expect(startOpenTunnel('route', 3000, { rootDir: '/app' })).resolves.toBeUndefined() + await expect(startOpenTunnel('route', 'localhost:3000', { rootDir: '/app' })).resolves.toBeUndefined() expect(warn).toHaveBeenCalledWith(expect.stringContaining('invalid token')) expect(client.dispose).toHaveBeenCalledTimes(1) @@ -113,7 +113,7 @@ describe('startOpenTunnel', () => { const warn = vi.spyOn(logger, 'warn').mockImplementation(() => {}) client.tunnel.connect.mockReturnValue(new Promise(() => {})) - const started = startOpenTunnel('route', 3000, { rootDir: '/app' }) + const started = startOpenTunnel('route', 'localhost:3000', { rootDir: '/app' }) await vi.advanceTimersByTimeAsync(20_000) await expect(started).resolves.toBeUndefined() @@ -126,7 +126,7 @@ describe('startOpenTunnel', () => { attach = resolve })) - const tunnel = await startOpenTunnel('route', 3000, { rootDir: '/app', handover: true }) + const tunnel = await startOpenTunnel('route', 'localhost:3000', { rootDir: '/app', handover: true }) expect(tunnel?.url).toBe('https://route.abc123.opentunnel.xyz') const connection = createConnection() @@ -143,7 +143,7 @@ describe('startOpenTunnel', () => { attach = resolve })) - const tunnel = await startOpenTunnel('route', 3000, { rootDir: '/app', handover: true }) + const tunnel = await startOpenTunnel('route', 'localhost:3000', { rootDir: '/app', handover: true }) await tunnel!.close() expect(client.dispose).toHaveBeenCalledTimes(1) @@ -155,12 +155,12 @@ describe('startOpenTunnel', () => { describe('startTunnel', () => { it('should start a Cloudflare quick tunnel', async () => { - await startTunnel({ provider: 'cloudflare' }, { protocol: 'https', port: 3000 }) + await startTunnel({ provider: 'cloudflare' }, { protocol: 'https', hostname: '', port: 3000 }) expect(startCloudflaredTunnel).toHaveBeenCalledWith('https://localhost:3000', true) }) it('should start OpenTunnel with the resolved route', async () => { - const tunnel = await startTunnel({ provider: 'opentunnel', route: 'route', rootDir: '/app' }, { protocol: 'http', port: 3000 }) + const tunnel = await startTunnel({ provider: 'opentunnel', route: 'route', rootDir: '/app' }, { protocol: 'http', hostname: '', port: 3000 }) expect(tunnel?.url).toBe('https://route.abc123.opentunnel.xyz') expect(loadOpenTunnelSDK).toHaveBeenCalledWith('/app') expect(startCloudflaredTunnel).not.toHaveBeenCalled() @@ -168,9 +168,33 @@ describe('startTunnel', () => { it('should fall back to Cloudflare for an HTTPS dev server, which OpenTunnel cannot forward to', async () => { const warn = vi.spyOn(logger, 'warn').mockImplementation(() => {}) - await startTunnel({ provider: 'opentunnel', route: 'route', rootDir: '/app' }, { protocol: 'https', port: 3000 }) + await startTunnel({ provider: 'opentunnel', route: 'route', rootDir: '/app' }, { protocol: 'https', hostname: '', port: 3000 }) expect(warn).toHaveBeenCalledWith(expect.stringContaining('cannot forward to an HTTPS dev server')) expect(startCloudflaredTunnel).toHaveBeenCalledWith('https://localhost:3000', true) expect(client.tunnel.connect).not.toHaveBeenCalled() }) + + it('should reach a server bound to one address through that address', async () => { + await startTunnel({ provider: 'opentunnel', route: 'route', rootDir: '/app' }, { protocol: 'http', hostname: '192.168.1.5', port: 3000 }) + expect(client.tunnel.connect).toHaveBeenCalledWith({ routes: { route: '192.168.1.5:3000' } }) + + await startTunnel({ provider: 'cloudflare' }, { protocol: 'http', hostname: '192.168.1.5', port: 3000 }) + expect(startCloudflaredTunnel).toHaveBeenCalledWith('http://192.168.1.5:3000', false) + }) +}) + +describe('formatTunnelTarget', () => { + it('should use localhost for a server on every interface', () => { + for (const hostname of ['', '0.0.0.0', '::']) { + expect(formatTunnelTarget({ hostname, port: 3000 }), hostname).toBe('localhost:3000') + } + }) + + it('should use the address a server is bound to, with IPv6 in brackets', () => { + expect(formatTunnelTarget({ hostname: 'localhost', port: 3000 })).toBe('localhost:3000') + expect(formatTunnelTarget({ hostname: '192.168.1.5', port: 3000 })).toBe('192.168.1.5:3000') + // `localhost` can resolve to 127.0.0.1, where a server bound to `::1` does not listen. + expect(formatTunnelTarget({ hostname: '::1', port: 3000 })).toBe('[::1]:3000') + expect(formatTunnelTarget({ hostname: 'fe80::1', port: 3000 })).toBe('[fe80::1]:3000') + }) }) diff --git a/packages/nuxt-cli/test/unit/listen.spec.ts b/packages/nuxt-cli/test/unit/listen.spec.ts index e05dfd4ea..5d4df0302 100644 --- a/packages/nuxt-cli/test/unit/listen.spec.ts +++ b/packages/nuxt-cli/test/unit/listen.spec.ts @@ -377,7 +377,7 @@ describe('listener.close', () => { try { const listener = await listen((_req, res) => res.end('ok'), { port: 0, hostname: '127.0.0.1', showURL: false, handover: true, tunnel: { provider: 'opentunnel', route: 'route', rootDir: '/app' } }) - expect(startTunnel).toHaveBeenCalledWith({ provider: 'opentunnel', route: 'route', rootDir: '/app' }, { protocol: 'http', port: listener.address.port, handover: true }) + expect(startTunnel).toHaveBeenCalledWith({ provider: 'opentunnel', route: 'route', rootDir: '/app' }, { protocol: 'http', hostname: '127.0.0.1', port: listener.address.port, handover: true }) expect(listener.publicURL).toBe('https://route.example.test/') await listener.close() } From 4b7736aa9ab71613366fd6b94c4344e544f94cc9 Mon Sep 17 00:00:00 2001 From: "coderabbitai[bot]" <136622811+coderabbitai[bot]@users.noreply.github.com> Date: Sun, 11 Oct 2026 18:24:19 +0000 Subject: [PATCH 5/5] fix(dev): fall back to Cloudflare for HTTPS before installing the OpenTunnel SDK --- packages/nuxt-cli/src/commands/dev.ts | 2 +- packages/nuxt-cli/src/dev/tunnel/resolve.ts | 7 ++++++- .../nuxt-cli/test/unit/commands/dev-run.spec.ts | 13 +++++++++++++ .../nuxt-cli/test/unit/dev/tunnel-resolve.spec.ts | 13 +++++++++++++ 4 files changed, 33 insertions(+), 2 deletions(-) diff --git a/packages/nuxt-cli/src/commands/dev.ts b/packages/nuxt-cli/src/commands/dev.ts index 135e0398f..0924d733e 100644 --- a/packages/nuxt-cli/src/commands/dev.ts +++ b/packages/nuxt-cli/src/commands/dev.ts @@ -188,7 +188,7 @@ const command = defineCommand({ 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.tunnel = await resolveTunnelOptions(tunnelFlag, cwd, listenOverrides.httpsEnabled) } const buildDir = await beforeServing(() => resolveLockDir(cwd)) diff --git a/packages/nuxt-cli/src/dev/tunnel/resolve.ts b/packages/nuxt-cli/src/dev/tunnel/resolve.ts index d6fbb043b..dd7533311 100644 --- a/packages/nuxt-cli/src/dev/tunnel/resolve.ts +++ b/packages/nuxt-cli/src/dev/tunnel/resolve.ts @@ -40,13 +40,18 @@ export function parseTunnelProvider(value: unknown): TunnelProvider | undefined /** * Resolve `--tunnel` into the provider to start, prompting (once) when it is * passed without a value. Must run before the dev server takes the terminal. + * Choose the HTTPS fallback before preparing the OpenTunnel SDK. */ -export async function resolveTunnelOptions(flag: string | boolean | undefined, rootDir: string): Promise { +export async function resolveTunnelOptions(flag: string | boolean | undefined, rootDir: string, httpsEnabled = false): Promise { if (flag === undefined || flag === false) { return undefined } const provider = await resolveTunnelProvider(flag) if (provider === 'opentunnel') { + if (httpsEnabled) { + logger.warn('OpenTunnel cannot forward to an HTTPS dev server. Using a Cloudflare quick tunnel instead.') + return { provider: 'cloudflare' } + } // The SDK lives in the project, and installing it may prompt. Imported on // demand, as it pulls in the package manager helpers. const { ensureOpenTunnelSDK } = await import('./opentunnel-install') diff --git a/packages/nuxt-cli/test/unit/commands/dev-run.spec.ts b/packages/nuxt-cli/test/unit/commands/dev-run.spec.ts index 133704176..7c817d250 100644 --- a/packages/nuxt-cli/test/unit/commands/dev-run.spec.ts +++ b/packages/nuxt-cli/test/unit/commands/dev-run.spec.ts @@ -9,6 +9,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const { close, createFork, + ensureOpenTunnelSDK, getFork, initialize, onFileChange, @@ -22,6 +23,7 @@ const { } = vi.hoisted(() => ({ close: vi.fn(() => Promise.resolve()), createFork: vi.fn(), + ensureOpenTunnelSDK: vi.fn(async () => false), getFork: vi.fn(), initialize: vi.fn(), isReusePortSupported: vi.fn(() => Promise.resolve(true)), @@ -35,6 +37,7 @@ const { })) vi.mock('../../../src/dev/index', () => ({ initialize })) +vi.mock('../../../src/dev/tunnel/opentunnel-install', () => ({ ensureOpenTunnelSDK })) // Whether a restart hands over or is serialised depends on `SO_REUSEPORT`, which // Linux has and macOS and Windows do not, so the probe is stubbed rather than left // to whichever platform the suite runs on. @@ -138,6 +141,16 @@ describe('dev command startup', () => { expect(initialize.mock.calls[0]![1].listenOverrides).toMatchObject({ port: '4000' }) }) + it('should pass a Cloudflare tunnel to the listener for --https --tunnel=opentunnel without the SDK', async () => { + await runDev(['--no-fork', '--https', '--tunnel=opentunnel']) + + expect(initialize.mock.calls[0]![1].listenOverrides).toMatchObject({ + httpsEnabled: true, + tunnel: { provider: 'cloudflare' }, + }) + expect(ensureOpenTunnelSDK).not.toHaveBeenCalled() + }) + it('should keep the port unresolved when none was requested', async () => { await runDev(['--no-fork']) diff --git a/packages/nuxt-cli/test/unit/dev/tunnel-resolve.spec.ts b/packages/nuxt-cli/test/unit/dev/tunnel-resolve.spec.ts index 2fbe14457..cd3c798b8 100644 --- a/packages/nuxt-cli/test/unit/dev/tunnel-resolve.spec.ts +++ b/packages/nuxt-cli/test/unit/dev/tunnel-resolve.spec.ts @@ -117,6 +117,19 @@ describe('resolveTunnelOptions', () => { expect(ensureOpenTunnelSDK).not.toHaveBeenCalled() }) + it.each(['opentunnel', '', true])('should resolve %s to Cloudflare for HTTPS before preparing the SDK', async (flag) => { + const warn = vi.spyOn(logger, 'warn').mockImplementation(() => {}) + writeFileSync(join(home, '.nuxtrc'), 'tools.tunnel.provider="opentunnel"\n') + ensureOpenTunnelSDK.mockResolvedValue(false) + + await expect(resolveTunnelOptions(flag, '/app', true)).resolves.toEqual({ provider: 'cloudflare' }) + + expect(ensureOpenTunnelSDK).not.toHaveBeenCalled() + expect(warn).toHaveBeenCalledWith(expect.stringContaining('cannot forward to an HTTPS dev server')) + expect(readNuxtRC()).not.toContain('tools.opentunnel.seed') + expect(readNuxtRC()).toContain('tools.tunnel.provider="opentunnel"') + }) + it('should warn about an unknown provider and use Cloudflare', async () => { const warn = vi.spyOn(logger, 'warn').mockImplementation(() => {}) await expect(resolveTunnelOptions('./app', '/app')).resolves.toEqual({ provider: 'cloudflare' })