Portless 동작 원리
localhost:3000 대신 myapp.localhost — 포트 번호를 이름으로 바꾸는 프록시
로컬 개발을 하다 보면 localhost:3000, localhost:3001, localhost:5173... 포트 번호 지옥에 빠진다. 뭐가 몇번이었는지 기억이 안 나고, AI 에이전트한테 "3000번으로 접속해" 라고 말해봐야 다음에 바뀌면 또 알려줘야 한다.
Portless는 이 문제를 근본적으로 해결한다. portless myapp next dev 로 실행하면 http://myapp.localhost 로 접근 가능.
핵심 구조: 이름 → 포트 매핑 프록시
브라우저 Portless Proxy (443) 개발 서버
myapp.localhost ──────→ routes.json 조회 ──────→ localhost:4231
api.myapp.localhost ──→ 서브도메인 매칭 ──────→ localhost:4582
실행 흐름: portless myapp next dev
프록시 데몬 확인 —
~/.portless/proxy.pid로 기존 데몬 존재 여부 체크. 없으면 자동 시작 (detached child process). 항상 떠있는 시스템 서비스가 아니라 처음 사용할 때 자동 기동빈 포트 할당 — 4000~4999 범위에서 랜덤 포트 탐색.
net.createServer().listen()으로 실제 바인딩 테스트프레임워크 감지 + 포트 주입
Next.js, Express, Remix →
PORT환경변수를 자동 인식하므로 env 에 넣기만 하면 됨Vite, Astro, Angular →
PORT를 안 보는 프레임워크.--port,--host플래그를 CLI 인자에 자동 삽입
// Vite, Astro 등은 PORT env를 안 봐서 직접 플래그 주입
const FRAMEWORKS_NEEDING_PORT = {
vite: { strictPort: true },
astro: { strictPort: false },
ng: { strictPort: false }, // Angular CLI
};
라우트 등록 —
routes.json에{ hostname: 'myapp', port: 4231, pid: 12345 }기록자식 프로세스 실행 —
PORT=4231 HOST=127.0.0.1 next dev실행. 종료 시 라우트 자동 제거
프록시 서버 내부 (proxy.ts)
포트 443 에서 TCP 소켓의 첫 바이트를 peek 해서 TLS/평문 HTTP를 분기:
socket.once('readable', () => {
const buf = socket.read(1);
socket.unshift(buf); // 바이트를 다시 돌려놓음
if (buf[0] === 0x16) { // 0x16 = TLS ClientHello
h2Server.emit('connection', socket);
} else {
plainServer.emit('connection', socket);
}
});
하나의 포트에서 HTTPS/H2 와 평문 HTTP 를 동시에 처리.
라우팅 로직
function findRoute(routes, host, strict) {
return (
routes.find(r => r.hostname === host) || // 정확 매칭
(!strict &&
routes.find(r => host.endsWith('.' + r.hostname))) // 서브도메인
);
}
myapp.localhost→ 정확 매칭 → port 4231api.myapp.localhost→ 서브도메인 매칭 → port 4231 (같은 서버)루프 감지:
x-portless-hops헤더를 매 요청마다 +1, 5회 이상이면 508 에러. Vite 의 dev server proxy 가 Host 헤더를 안 바꾸고 다시 portless 로 보내는 실수를 방지
.localhost 가 작동하는 이유
.localhost 는 RFC 2606 예약 TLD. 대부분의 브라우저(Chrome, Firefox, Edge)가 *.localhost 를 /etc/hosts 수정 없이 자동으로 127.0.0.1 로 해석한다.
Safari 만 안 됨 → portless 가 /etc/hosts 에 # portless-start / # portless-end 마커 사이에 엔트리를 자동 동기화.
HTTPS 인증서 자동 생성
- 자체 CA 생성 — EC 키(prime256v1), SHA-256, 10년 유효.
~/.portless/에 저장 - SNI 콜백 — TLS 핸드셰이크 시 요청된 hostname 에 맞는 인증서를 동적 생성 + 캐싱
- 시스템 신뢰 저장소 등록 —
portless trust명령으로 CA를 macOS Keychain / Linux ca-certificates 에 설치 - 자식 프로세스에
NODE_EXTRA_CA_CERTS주입 → Node.js 개발 서버가 자동으로 CA 를 신뢰
상태 관리 (파일 기반)
~/.portless/
├── routes.json # 라이브 라우트 테이블
├── proxy.pid # 데몬 PID
├── proxy.port # 데몬 포트
├── tls # TLS 활성화 마커
└── tld # 현재 TLD (기본: localhost)
프록시는 매 요청마다 routes.json 을 다시 읽는다. 인메모리 캐시 없음. 자식 프로세스가 파일에 쓰면 즉시 반영. 파일 잠금은 디렉토리 생성(mkdir)으로 원자적 뮤텍스 구현.
좀비 라우트는 loadRoutes() 시 PID 생존 여부(kill(pid, 0))를 체크해서 자동 제거.
Worktree 지원 (v0.5.2+)
git worktree list 를 실행해서 현재 워크트리의 브랜치명을 추출, hostname 앞에 붙인다:
main 브랜치: myapp.localhost
feat-auth 브랜치: feat-auth.myapp.localhost
같은 프로젝트를 여러 워크트리로 동시 개발할 때 포트 충돌 없이 각각 접근 가능.
이 프로젝트의 Rails Start Template 에서의 의미
우리 프로젝트는 이미 SimpleHostConstraint + host_groups.rb 로 비슷한 구조를 쓰고 있다:
app5.localhost:30207 → Langstagram
app10.localhost:30207 → API Tester
app67.localhost:30207 → LanLan
Portless 와의 차이: 우리는 Rails 라우터 레벨에서 Host 기반 분기를 하지만, Portless 는 OS 레벨 리버스 프록시로 완전히 다른 프로세스(Node, Python, Go 등)까지 커버한다. 둘은 보완적 관계.
동작 흐름
CLI가 프록시 데몬 존재 확인 → 없으면 detached process 로 자동 기동 (포트 443)
4000~4999 범위에서 빈 포트 할당 + 프레임워크별 포트 주입 (PORT env 또는 --port 플래그)
routes.json 에 { hostname, port, pid } 등록 → 프록시가 매 요청마다 이 파일을 읽어 라우팅
TCP 첫 바이트 peek (0x16 = TLS) 으로 HTTPS/H2 vs 평문 HTTP 분기 — 포트 하나로 둘 다 처리
SNI 콜백으로 hostname 별 인증서 동적 생성 + 캐싱. 자체 CA 를 시스템 신뢰 저장소에 등록
자식 프로세스 종료 시 routes.json 에서 자동 제거 + PID 생존 체크로 좀비 라우트 GC
장점
- ✓ 포트 번호를 기억할 필요 없음 — 이름 기반 URL
- ✓ HTTPS + HTTP/2 자동 — 인증서 생성/설치까지 원스텝
- ✓ 프레임워크 무관 — Next.js, Vite, Express, Nuxt 모두 지원
- ✓ 항시 기동 아님 — 첫 사용 시 자동 시작, 시스템 서비스 등록 불필요
- ✓ Worktree + 서브도메인 지원으로 브랜치별 독립 접근
단점
- ✗ 포트 443 사용 시 macOS/Linux 에서 sudo 필요
- ✗ Safari 는 *.localhost 자동 해석 미지원 → /etc/hosts 동기화 필요
- ✗ routes.json 을 매 요청마다 읽음 — 초고빈도 요청 시 I/O 부하 가능
- ✗ Docker 컨테이너 내부에서는 추가 설정 필요 (PID 강제 지정)
- ✗ Node.js 20+ 필수, Windows 미지원