-
Notifications
You must be signed in to change notification settings - Fork 111
feat(dev): add --portless support #1301
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,141 @@ | ||
| 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', 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 function resolvePortlessAliasName(url: string) { | ||
| const aliasName = new URL(url).hostname.replace(/\.[^.]+$/, '') | ||
| if (!aliasName) { | ||
| throw new Error('Portless returned an invalid hostname') | ||
| } | ||
| return aliasName | ||
| } | ||
|
|
||
| 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) | ||
| const result = runPortlessSync(cwd, ['alias', '--remove', name]) | ||
| if (result.error || result.status) { | ||
| const message = result.stderr?.trim() || result.error?.message || `portless exited with code ${result.status}` | ||
| process.stderr.write(`Failed to remove the portless alias for ${name}: ${message}\n`) | ||
| } | ||
| } | ||
|
|
||
| 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 configuredName = await readNameFromFile(cwd, 'portless.json') | ||
| || await readNameFromFile(cwd, 'package.json') | ||
| || basename(cwd) | ||
| return normalizePortlessName(configuredName) | ||
| } | ||
|
|
||
| function normalizePortlessName(value: string) { | ||
| const normalizedValue = value | ||
| .toLowerCase() | ||
| .replace(/[^a-z0-9-]+/g, '-') | ||
| .replace(/-+/g, '-') | ||
| .replace(/^-+|-+$/g, '') | ||
|
|
||
| return normalizedValue || DEFAULT_PORTLESS_NAME | ||
| } | ||
|
|
||
| function readNameFromFile(cwd: string, filename: string) { | ||
| return readFile(join(cwd, filename), 'utf8') | ||
| .then(contents => JSON.parse(contents)) | ||
| .then(config => typeof config.name === 'string' ? config.name : undefined) | ||
| .catch(() => undefined) | ||
| } | ||
|
Comment on lines
+118
to
+123
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Only ignore missing files when resolving the Portless name.
Suggested fix function readNameFromFile(cwd: string, filename: string) {
return readFile(join(cwd, filename), 'utf8')
.then(contents => JSON.parse(contents))
.then(config => typeof config.name === 'string' ? config.name : undefined)
- .catch(() => undefined)
+ .catch((error) => {
+ if (error instanceof Error && 'code' in error && error.code === 'ENOENT') {
+ return undefined
+ }
+ throw error
+ })
}🤖 Prompt for AI Agents |
||
|
|
||
| function runPortless(cwd: string, args: string[]) { | ||
| return x('portless', args, { | ||
| throwOnError: true, | ||
| nodeOptions: { | ||
| cwd, | ||
| stdio: 'pipe', | ||
| }, | ||
| }) | ||
| } | ||
|
|
||
| function runPortlessSync(cwd: string, args: string[]) { | ||
| return spawnSync('portless', args, { | ||
| cwd, | ||
| encoding: 'utf8', | ||
| stdio: 'pipe', | ||
| }) | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,70 @@ | ||
| 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', () => { | ||
| const stderrWrite = vi.spyOn(process.stderr, 'write').mockImplementation(() => true) | ||
|
|
||
| afterEach(() => { | ||
| spawnSync.mockReset() | ||
| stderrWrite.mockClear() | ||
| }) | ||
|
|
||
| it('removes the alias on process exit', () => { | ||
| spawnSync.mockReturnValue({ status: 0, stderr: '', error: undefined }) | ||
| 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', | ||
| encoding: 'utf8', | ||
| stdio: 'pipe', | ||
| }) | ||
| expect(stderrWrite).not.toHaveBeenCalled() | ||
|
|
||
| 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() | ||
| }) | ||
|
|
||
| it('reports cleanup failures on process exit', () => { | ||
| spawnSync.mockReturnValue({ status: 1, stderr: 'permission denied\n', error: undefined }) | ||
| 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)) | ||
|
|
||
| cleanup?.(0) | ||
|
|
||
| expect(stderrWrite).toHaveBeenCalledWith( | ||
| 'Failed to remove the portless alias for fixtures-dev: permission denied\n', | ||
| ) | ||
|
|
||
| dispose() | ||
| }) | ||
| }) |
Uh oh!
There was an error while loading. Please reload this page.