From ac9337f4bddb3aac36c67cc062a76dab3b7b05b7 Mon Sep 17 00:00:00 2001 From: onmax Date: Thu, 30 Apr 2026 14:59:27 +0200 Subject: [PATCH] feat(dev): add --portless support --- packages/nuxi/src/commands/dev.ts | 55 ++++++- packages/nuxi/src/dev/portless.ts | 122 ++++++++++++++ .../nuxi/test/unit/dev/portless-exit.spec.ts | 49 ++++++ packages/nuxi/test/unit/dev/portless.spec.ts | 137 ++++++++++++++++ packages/nuxt-cli/test/e2e/dev.spec.ts | 151 +++++++++++++++++- 5 files changed, 507 insertions(+), 7 deletions(-) create mode 100644 packages/nuxi/src/dev/portless.ts create mode 100644 packages/nuxi/test/unit/dev/portless-exit.spec.ts create mode 100644 packages/nuxi/test/unit/dev/portless.spec.ts diff --git a/packages/nuxi/src/commands/dev.ts b/packages/nuxi/src/commands/dev.ts index 3a151800..a82d5cf1 100644 --- a/packages/nuxi/src/commands/dev.ts +++ b/packages/nuxi/src/commands/dev.ts @@ -12,6 +12,7 @@ import { isBun, isTest } from 'std-env' import { initialize } from '../dev' import { ForkPool } from '../dev/pool' +import { ensurePortlessAvailable, registerPortlessAlias, registerPortlessExitCleanup, removePortlessAlias, resolvePortlessName, resolvePortlessURL } from '../dev/portless' import { debug, logger } from '../utils/logger' import { cwdArgs, dotEnvArgs, envNameArgs, extendsArgs, legacyRootDirArgs, logLevelArgs, profileArgs } from './_shared' @@ -61,6 +62,11 @@ const command = defineCommand({ description: 'Host to listen on (default: `NUXT_HOST || NITRO_HOST || HOST || nuxtOptions.devServer?.host`)', }, clipboard: { ...listhenArgs.clipboard, default: false }, + portless: { + type: 'boolean', + description: 'Expose the dev server with the external `portless` CLI (https://portless.sh)', + default: false, + }, }, ...profileArgs, sslCert: { @@ -76,7 +82,18 @@ const command = defineCommand({ // Prepare const cwd = resolve(ctx.args.cwd || ctx.args.rootDir) - const listenOverrides = resolveListenOverrides(ctx.args) + if (ctx.args.portless && ctx.args.tunnel) { + throw new Error('`--portless` cannot be used with `--tunnel`.') + } + + const portlessName = ctx.args.portless ? await resolvePortlessName(cwd) : undefined + if (ctx.args.portless) { + await ensurePortlessAvailable(cwd) + } + + const portlessURL = ctx.args.portless ? await resolvePortlessURL(cwd, portlessName!) : undefined + const listenOverrides = resolveListenOverrides(ctx.args, portlessURL) + let closePortless: (() => Promise) | undefined // Start the initial dev server in-process with listener const { listener, close, onRestart, onReady } = await initialize({ cwd, args: ctx.args }, { @@ -85,11 +102,36 @@ const command = defineCommand({ showBanner: true, }) - // Disable forking when profiling to capture all activity in one process - if (!ctx.args.fork || ctx.args.profile) { + if (ctx.args.portless) { + const unregisterPortlessExitCleanup = registerPortlessExitCleanup(cwd, portlessName!) + try { + await registerPortlessAlias(cwd, portlessName!, listener.address.port) + closePortless = async () => { + unregisterPortlessExitCleanup() + await removePortlessAlias(cwd, portlessName!).catch((error) => { + logger.warn((error as Error).message) + }) + } + await listener.showURL() + } + catch (error) { + unregisterPortlessExitCleanup() + await removePortlessAlias(cwd, portlessName!).catch(() => {}) + await close() + throw error + } + } + + const closeDevServer = async () => { + await closePortless?.() + await close() + } + + if (ctx.args.portless || !ctx.args.fork || ctx.args.profile) { + // Disable forking when profiling or using portless to keep lifecycle local. return { listener, - close, + close: closeDevServer, } } @@ -144,6 +186,7 @@ const command = defineCommand({ return { async close() { cleanupCurrentFork?.() + await closePortless?.() await Promise.all([ listener.close(), close(), @@ -162,7 +205,7 @@ type ArgsT = Exclude< undefined | ((...args: unknown[]) => unknown) > -function resolveListenOverrides(args: ParsedArgs) { +function resolveListenOverrides(args: ParsedArgs, publicURL?: string) { // _PORT is used by `@nuxt/test-utils` to launch the dev server on a specific port if (process.env._PORT) { return { @@ -195,6 +238,8 @@ function resolveListenOverrides(args: ParsedArgs) { return { ...options, + publicURL, + showURL: !args.portless, // if the https flag is not present, https.xxx arguments are ignored. // override if https is enabled in devServer config. _https: args.https, diff --git a/packages/nuxi/src/dev/portless.ts b/packages/nuxi/src/dev/portless.ts new file mode 100644 index 00000000..842ae692 --- /dev/null +++ b/packages/nuxi/src/dev/portless.ts @@ -0,0 +1,122 @@ +import { spawnSync } from 'node:child_process' +import { readFile } from 'node:fs/promises' +import { basename, join } from 'node:path' +import process from 'node:process' + +import { x } from 'tinyexec' + +const DEFAULT_PORTLESS_NAME = 'nuxt-app' + +export async function ensurePortlessAvailable(cwd: string) { + try { + await runPortless(cwd, ['--version']) + } + catch (error) { + if (typeof error === 'object' && error && 'code' in error && error.code === 'ENOENT') { + throw new Error('Portless is required for `--portless`. Install it from https://portless.sh') + } + + throw createPortlessError('check portless availability', error) + } +} + +export async function resolvePortlessURL(cwd: string, name: string) { + try { + await runPortless(cwd, ['proxy', 'start']) + const result = await runPortless(cwd, ['get', '--no-worktree', name]) + const url = result.stdout.trim() + + if (!url) { + throw new Error('Portless returned an empty URL') + } + + return new URL(url).toString().replace(/\/$/, '') + } + catch (error) { + throw createPortlessError('resolve the portless URL', error) + } +} + +export async function registerPortlessAlias(cwd: string, name: string, port: number) { + try { + await runPortless(cwd, ['alias', name, `${port}`, '--force']) + } + catch (error) { + throw createPortlessError(`register the portless alias for port ${port}`, error) + } +} + +export async function removePortlessAlias(cwd: string, name: string) { + try { + await runPortless(cwd, ['alias', '--remove', name]) + } + catch (error) { + throw createPortlessError(`remove the portless alias for ${name}`, error) + } +} + +export function registerPortlessExitCleanup(cwd: string, name: string) { + let disposed = false + + const cleanup = () => { + if (disposed) { + return + } + + disposed = true + process.off('exit', cleanup) + runPortlessSync(cwd, ['alias', '--remove', name]) + } + + process.on('exit', cleanup) + + return () => { + disposed = true + process.off('exit', cleanup) + } +} + +function createPortlessError(action: string, error: unknown) { + const message = typeof error === 'object' && error && 'stderr' in error && typeof error.stderr === 'string' && error.stderr.trim() + ? error.stderr.trim() + : error instanceof Error && error.message + ? error.message + : 'Unknown portless error' + + return new Error(`Failed to ${action}: ${message}`) +} + +export async function resolvePortlessName(cwd: string) { + const packageName = await readFile(join(cwd, 'package.json'), 'utf8') + .then(contents => JSON.parse(contents)) + .then(pkg => typeof pkg.name === 'string' ? pkg.name : undefined) + .catch(() => undefined) + return normalizePortlessName(packageName || basename(cwd)) +} + +function normalizePortlessName(value: string) { + const normalizedValue = value + .toLowerCase() + .replace(/[^a-z0-9-]+/g, '-') + .replace(/-+/g, '-') + .replace(/^-+|-+$/g, '') + + return normalizedValue || DEFAULT_PORTLESS_NAME +} + +function runPortless(cwd: string, args: string[]) { + return x('portless', args, { + throwOnError: true, + nodeOptions: { + cwd, + stdio: 'pipe', + }, + }) +} + +function runPortlessSync(cwd: string, args: string[]) { + spawnSync('portless', args, { + cwd, + stdio: 'ignore', + }) +} diff --git a/packages/nuxi/test/unit/dev/portless-exit.spec.ts b/packages/nuxi/test/unit/dev/portless-exit.spec.ts new file mode 100644 index 00000000..cd73e16d --- /dev/null +++ b/packages/nuxi/test/unit/dev/portless-exit.spec.ts @@ -0,0 +1,49 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { registerPortlessExitCleanup } from '../../../src/dev/portless' + +const { spawnSync } = vi.hoisted(() => { + return { + spawnSync: vi.fn(), + } +}) + +vi.mock('node:child_process', () => { + return { + spawnSync, + } +}) + +describe('registerPortlessExitCleanup', () => { + afterEach(() => { + spawnSync.mockReset() + }) + + it('removes the alias on process exit', () => { + const existingListeners = new Set(process.listeners('exit')) + const dispose = registerPortlessExitCleanup('/tmp/fixtures-dev', 'fixtures-dev') + const cleanup = process.listeners('exit').find(listener => !existingListeners.has(listener)) + + expect(cleanup).toBeTypeOf('function') + + cleanup?.(0) + + expect(spawnSync).toHaveBeenCalledWith('portless', ['alias', '--remove', 'fixtures-dev'], { + cwd: '/tmp/fixtures-dev', + stdio: 'ignore', + }) + + dispose() + }) + + it('does nothing after the cleanup is disposed', () => { + const existingListeners = new Set(process.listeners('exit')) + const dispose = registerPortlessExitCleanup('/tmp/fixtures-dev', 'fixtures-dev') + const cleanup = process.listeners('exit').find(listener => !existingListeners.has(listener)) + + dispose() + cleanup?.(0) + + expect(spawnSync).not.toHaveBeenCalled() + }) +}) diff --git a/packages/nuxi/test/unit/dev/portless.spec.ts b/packages/nuxi/test/unit/dev/portless.spec.ts new file mode 100644 index 00000000..05bc058a --- /dev/null +++ b/packages/nuxi/test/unit/dev/portless.spec.ts @@ -0,0 +1,137 @@ +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { resolvePortlessName } from '../../../src/dev/portless' + +const tempDirs: string[] = [] + +async function createTempDir(name: string) { + const dir = await mkdtemp(join(tmpdir(), `${name}-`)) + tempDirs.push(dir) + return dir +} + +async function loadPortlessWithTinyexecMock(implementation: (command: string, args: string[]) => Promise) { + vi.doMock('tinyexec', () => ({ + x: vi.fn((command: string, args: string[]) => implementation(command, args)), + })) + + return await import('../../../src/dev/portless') +} + +describe('resolvePortlessName', () => { + afterEach(async () => { + await Promise.all(tempDirs.splice(0).map(dir => rm(dir, { recursive: true, force: true }))) + }) + + it('preserves package scope in normalized form', async () => { + const cwd = await createTempDir('portless-scoped') + await writeFile(join(cwd, 'package.json'), JSON.stringify({ name: '@acme/web' })) + + await expect(resolvePortlessName(cwd)).resolves.toBe('acme-web') + }) + + it('normalizes invalid characters', async () => { + const cwd = await createTempDir('portless-normalized') + await writeFile(join(cwd, 'package.json'), JSON.stringify({ name: 'My App/API' })) + + await expect(resolvePortlessName(cwd)).resolves.toBe('my-app-api') + }) + + it('falls back to the directory name when package name is missing', async () => { + const root = await createTempDir('portless-missing-name') + const cwd = join(root, 'Fancy Project') + await mkdir(cwd) + await writeFile(join(root, 'package.json'), JSON.stringify({ private: true })) + + await expect(resolvePortlessName(cwd)).resolves.toBe('fancy-project') + }) + + it('does not inherit a package name from parent directories', async () => { + const root = await createTempDir('portless-parent-name') + const cwd = join(root, 'apps', 'Web App') + await mkdir(cwd, { recursive: true }) + await writeFile(join(root, 'package.json'), JSON.stringify({ name: 'monorepo-root' })) + + await expect(resolvePortlessName(cwd)).resolves.toBe('web-app') + }) + + it('falls back to nuxt-app when normalization produces an empty name', async () => { + const cwd = await createTempDir('portless-empty') + await writeFile(join(cwd, 'package.json'), JSON.stringify({ name: '@@@' })) + + await expect(resolvePortlessName(cwd)).resolves.toBe('nuxt-app') + }) +}) + +describe('portless command failures', () => { + beforeEach(() => { + vi.resetModules() + }) + + afterEach(() => { + vi.doUnmock('tinyexec') + }) + + it('reports a missing portless binary with an install hint', async () => { + const { ensurePortlessAvailable } = await loadPortlessWithTinyexecMock(async () => { + const error = new Error('spawn portless ENOENT') as NodeJS.ErrnoException + error.code = 'ENOENT' + throw error + }) + + await expect(ensurePortlessAvailable('/tmp/fixtures-dev')).rejects.toThrow( + 'Portless is required for `--portless`. Install it from https://portless.sh', + ) + }) + + it('wraps stderr from portless get failures', async () => { + const { resolvePortlessURL } = await loadPortlessWithTinyexecMock(async (_command, args) => { + if (args[0] === 'proxy') { + return { stdout: '' } + } + + throw { stderr: 'permission denied\n' } + }) + + await expect(resolvePortlessURL('/tmp/fixtures-dev', 'fixtures-dev')).rejects.toThrow( + 'Failed to resolve the portless URL: permission denied', + ) + }) + + it('rejects empty portless URLs', async () => { + const { resolvePortlessURL } = await loadPortlessWithTinyexecMock(async () => ({ stdout: ' ' })) + + await expect(resolvePortlessURL('/tmp/fixtures-dev', 'fixtures-dev')).rejects.toThrow( + 'Failed to resolve the portless URL: Portless returned an empty URL', + ) + }) + + it('wraps portless alias failures with the action name', async () => { + const { registerPortlessAlias, removePortlessAlias } = await loadPortlessWithTinyexecMock(async () => { + throw new Error('alias command failed') + }) + + await expect(registerPortlessAlias('/tmp/fixtures-dev', 'fixtures-dev', 3000)).rejects.toThrow( + 'Failed to register the portless alias for port 3000: alias command failed', + ) + await expect(removePortlessAlias('/tmp/fixtures-dev', 'fixtures-dev')).rejects.toThrow( + 'Failed to remove the portless alias for fixtures-dev: alias command failed', + ) + }) + + it('registers portless aliases with force', async () => { + const calls: string[][] = [] + const { registerPortlessAlias } = await loadPortlessWithTinyexecMock(async (_command, args) => { + calls.push(args) + return { stdout: '' } + }) + + await registerPortlessAlias('/tmp/fixtures-dev', 'fixtures-dev', 3000) + + expect(calls).toEqual([['alias', 'fixtures-dev', '3000', '--force']]) + }) +}) diff --git a/packages/nuxt-cli/test/e2e/dev.spec.ts b/packages/nuxt-cli/test/e2e/dev.spec.ts index 64940b4b..34adb63e 100644 --- a/packages/nuxt-cli/test/e2e/dev.spec.ts +++ b/packages/nuxt-cli/test/e2e/dev.spec.ts @@ -1,5 +1,7 @@ -import { readFile, rm } from 'node:fs/promises' -import { join } from 'node:path' +import { chmod, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { request as httpRequest } from 'node:http' +import { tmpdir } from 'node:os' +import { delimiter, join } from 'node:path' import { fileURLToPath } from 'node:url' import { getPort } from 'get-port-please' import { afterEach, describe, expect, it, vi } from 'vitest' @@ -14,6 +16,104 @@ const httpsCert = join(certsDir, 'cert.dummy') const httpsKey = join(certsDir, 'key.dummy') const httpsPfx = join(certsDir, 'pfx.dummy') +async function createFakePortless(url: string) { + const binDir = await mkdtemp(join(tmpdir(), 'portless-bin-')) + const logFile = join(binDir, 'portless.log') + const unixBinary = join(binDir, 'portless') + const windowsBinary = join(binDir, 'portless.cmd') + + await writeFile(unixBinary, `#!/bin/sh +printf '%s\n' "$*" >> "$PORTLESS_LOG" +if [ "$1" = "--version" ]; then + printf '0.1.0\n' + exit 0 +fi +if [ "$1" = "get" ]; then + printf '%s\n' "$PORTLESS_URL_VALUE" + exit 0 +fi +exit 0 +`) + await chmod(unixBinary, 0o755) + + await writeFile(windowsBinary, `@echo off +echo %*>> "%PORTLESS_LOG%" +if "%1"=="--version" ( + echo 0.1.0 + exit /b 0 +) +if "%1"=="get" ( + echo %PORTLESS_URL_VALUE% + exit /b 0 +) +exit /b 0 +`) + + return { binDir, logFile, url } +} + +function requestWithHost(url: string, hostHeader: string) { + return new Promise((resolve, reject) => { + const req = httpRequest(url, { headers: { host: hostHeader } }, (res) => { + resolve(res.statusCode || 0) + res.resume() + }) + req.on('error', reject) + req.end() + }) +} + +async function waitFor(run: () => Promise, check: (value: T) => boolean, timeout = 15_000) { + const start = Date.now() + let lastError: unknown + + while (Date.now() - start < timeout) { + try { + const value = await run() + if (check(value)) { + return value + } + } + catch (error) { + lastError = error + } + + await new Promise(resolve => setTimeout(resolve, 100)) + } + + throw lastError instanceof Error ? lastError : new Error(`Timed out after ${timeout}ms`) +} + +async function readPortlessLogLines(logFile: string) { + try { + return await readFile(logFile, 'utf-8').then(content => content.trim().split(NEWLINE_RE).filter(Boolean)) + } + catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return [] + } + throw error + } +} + +function extractLoggedURLs(calls: unknown[][]) { + return calls.flatMap(call => call.flatMap((value) => { + if (typeof value !== 'string') { + return [] + } + + return (value.match(/https?:\/\/[^\s)]+/g) || []).map(normalizeLoggedURL) + })) +} + +function normalizeLoggedURL(url: string) { + return url + .replace(/\u001B\[[0-9;]*m/g, '') + .trim() + .replace(/[),.]+$/g, '') + .replace(/\/$/, '') +} + describe('dev server', () => { afterEach(() => { vi.unstubAllEnvs() @@ -65,6 +165,53 @@ describe('dev server', () => { }) }) + it('should expose the dev server through portless', { timeout: 50_000 }, async () => { + await rm(join(fixtureDir, '.nuxt'), { recursive: true, force: true }) + const host = '127.0.0.1' + const port = await getPort({ host, port: 3051 }) + const portlessURL = 'https://fixtures-dev.localhost' + const { binDir, logFile } = await createFakePortless(portlessURL) + let close: (() => Promise) | undefined + + vi.stubEnv('PATH', `${binDir}${delimiter}${process.env.PATH || ''}`) + vi.stubEnv('PORTLESS_LOG', logFile) + vi.stubEnv('PORTLESS_URL_VALUE', portlessURL) + + const consoleLog = vi.spyOn(console, 'log').mockImplementation(() => {}) + + try { + const result = await runCommand('dev', [`--host=${host}`, `--port=${port}`, `--cwd=${fixtureDir}`, '--portless']) as any + close = result.result.close + + expect(await waitFor( + () => requestWithHost(`http://${host}:${port}`, 'fixtures-dev.localhost'), + status => status === 200, + )).toBe(200) + + await close?.() + close = undefined + + const logLines = await readPortlessLogLines(logFile) + + expect(logLines[0]).toBe('--version') + expect(logLines[1]).toBe('proxy start') + expect(logLines[2]).toBe('get --no-worktree fixtures-dev') + expect(logLines[3]).toBe(`alias fixtures-dev ${port} --force`) + expect(logLines[4]).toBe('alias --remove fixtures-dev') + expect(extractLoggedURLs(consoleLog.mock.calls)).toContain(portlessURL) + expect(process.env.PORTLESS_URL).toBeUndefined() + } + finally { + await close?.() + consoleLog.mockRestore() + await rm(binDir, { recursive: true, force: true }) + } + }) + + it('should reject combining portless and tunnel', async () => { + await expect(runCommand('dev', ['--cwd', fixtureDir, '--portless', '--tunnel'])).rejects.toThrow('`--portless` cannot be used with `--tunnel`.') + }) + it('should handle multiple set-cookie headers correctly', { timeout: 50_000 }, async () => { await rm(join(fixtureDir, '.nuxt'), { recursive: true, force: true }) const host = '127.0.0.1'