portlessは、http://localhost:3000 のようなポート番号つきURLを https://myapp.localhost という固定の名前付きURLに置き換えるVercel Labs製のCLIだ。GitHubで★11,300を集めている。そして2026-08-25 01:40(JST)に v0.15.6 が公開され、それまで実務でいちばん引っかかっていた挙動が2つ変わった。本記事は0.15.6と旧0.15.5を同じマシン・同じ検証用プロジェクトで並べて走らせ、直接指定19コマンド+package.jsonスクリプト10パターン=計29パターンを両バージョンで実測した記録である(計58実行)——--portの自動注入がどこまで届くようになったか、Ctrl+Cのあとに開発サーバーが残るかどうか、そしてNode.js 24要件が実際どこで効くのか。

portless 0.15.6 の実測値:両バージョンで29パターンを測定、--port注入の有無が反転したのは8パターン、Ctrl+C後に孫プロセスが消えた回数は3回中3回、mainとv0.15.6の差は0行
portless 0.15.6 と 0.15.5 を同一の検証用プロジェクトで実行して測定した結果(2026-08-25 実測)

この記事は開発サーバー向けリバースプロキシ「portless」を解説します。自動化ツール全体の地図はAI自動化ツール|ノーコードからコードまで2026年版の比較と選び方をご覧ください。

30秒でわかる portless

portless myapp next dev を実行すると、開発サーバーが https://myapp.localhost で開く。ポート番号は4000-4999からランダムに割り当てられ、開発者は一切意識しない
・git worktreeを検出してブランチ名をサブドメインに付ける(fix-ui.myapp.localhost)ので、複数ブランチの同時起動でポートが衝突しない
・HTTPSが既定。初回起動時にローカルCAを生成してOSの信頼ストアに登録するため、「マシンに何が入るか」を理解してから入れるべきツールでもある
・Apache-2.0/実行時依存ゼロ/Node.js 24以上を要求(ただし実測では22でも動いた)/npm週間ダウンロード881,666(2026-08-17〜23)

この記事のポイント(0.15.6 実測)

--portの自動注入がpackage.jsonのスクリプト経由でも届くようになった"dev": "vite" と書いてportlessを単体実行したとき、0.15.5では素通しだったが0.15.6ではnpm run dev -- --port <n> --strictPort --host 127.0.0.1として渡る
逆に、サーバーを立てないコマンドには付かなくなったvite buildvite optimizeastro checkvp testvp --mode dev buildは0.15.5では全部に--portが付いていたが、0.15.6ではすべて素通しになる
Ctrl+Cの後始末が変わった。SIGINTを無視する子プロセスの下にいる孫は、0.15.5では3回中3回とも生き残ったが、0.15.6では3回中3回とも終了した
READMEとnpm公開版のズレは、いったん解消した。mainとv0.15.6タグの差は0コミットで、cli-utils.tsもREADMEも両者一致している

portlessとは——ポート番号を名前付きURLに置き換えるリバースプロキシ

portlessの仕組みはシンプルで、ローカルに常駐するリバースプロキシが1つあり、そこへ各アプリが「この名前で登録してほしい」と申告する構造になっている。ブラウザは常にプロキシ(既定では443番ポート)へ接続し、プロキシがHostヘッダを見て転送先の開発サーバーを選ぶ。

flowchart LR B["ブラウザ
myapp.localhost"] --> P["portless proxy
443番ポートで待機"] B2["ブラウザ
api.myapp.localhost"] --> P P -->|"Hostヘッダで振り分け"| A1["開発サーバー :4375
myapp"] P -->|"Hostヘッダで振り分け"| A2["開発サーバー :4001
api"] R["routes.json
~/.portless/"] -.->|"登録内容を参照"| P

ポート番号が消える仕掛けは、開発サーバー側のポートをportlessが決めることにある。portless run はアプリを起動する前に4000-4999の範囲から空きポートを選び、PORT 環境変数として子プロセスに渡す。今回の検証で観測した割り当ては 4140・4622・4530・4595・4358・4020・4415・4679・4502 などで、実行のたびに変わっていた。開発者はこの数字を知る必要がない——ブラウザで開くのは常に myapp.localhost だからだ。

実行時に何が起きているかは、portless自身が出力する。次は実際に手元で portless run を実行したときの出力で、割り当てポートと子プロセスへ渡される環境変数がそのまま見える。

portless

-- Proxy is running
-- c01.localhost (auto-resolves to 127.0.0.1)
-- Name "c01" (from --name flag)
-- Using port 4140

  -> http://c01.localhost:1355

Running: PORT=4140 HOST=127.0.0.1 PORTLESS_URL=http://c01.localhost:1355 vite --port 4140 --strictPort --host 127.0.0.1

このRunning:の行が、以降のセクションで使う観測点になる。portlessが最終的にどんなコマンドを組み立てたかがそのまま出るので、フラグが注入されたかどうかを目視で確認できる(ポート1355になっているのはHTTPモードで検証したためで、既定のHTTPSモードでは443になる)。

.localhost というTLDが使われているのは、Chrome・Firefox・Edgeがこのドメインを問答無用で127.0.0.1に解決するためだ。DNS設定も/etc/hostsの編集も要らない。ただしSafariはOSのリゾルバに従うため、環境によっては解決できない。その場合の逃げ道としてportless hosts syncが用意されており、登録済みルートを/etc/hostsへ書き出す。

読者の3問への答え

何ができるのか:ローカル開発サーバーに、ポート番号を含まない固定URLを与える。サブドメインで複数サービスを並べられる(api.myapp.localhost
何を解決するのか:ポート衝突(EADDRINUSE)、どのアプリが何番かを覚える負担、ポート使い回しによる「リロードしたら別アプリが出る」事故、cookieやlocalStorageがlocalhost上で混ざる問題
何を代替するのかmkcert+nginx/Caddyでローカル用のドメインとHTTPSを自前構築していた手順一式を、CLI 1本に畳む。Docker Composeを使っているならTraefikのラベル駆動ルーティングが近い立ち位置にある

モノレポでは命名規則が自動で決まる

モノレポでの挙動も押さえておきたい。portlessはリポジトリ直下のportless.jsonを1つ置くだけで、pnpm-workspace.yamlまたはpackage.jsonworkspacesフィールドからワークスペースを検出する。リポジトリのルートでportlessを実行すると、devスクリプトを持つ全パッケージが一斉に起動する。

名前を明示しない場合のホスト名は<パッケージ名>.<プロジェクト名>.localhostという規則に従う。プロジェクト名はワークスペース全体で最も多く使われているnpmスコープから決まる仕組みで、@myorg/web@myorg/apiがあればmyorgが採用され、スコープが無ければワークスペースのルートディレクトリ名にフォールバックする。パッケージの短縮名がプロジェクト名と一致する場合は重複を避けて<プロジェクト名>.localhostになる。個別に名前を変えたいときだけappsマップで上書きすればよく、列挙しなかったパッケージも自動検出の対象から外れるわけではない。

turborepoと併用する場合は少し書き方が変わる。devスクリプトにportlessを置き、本来のコマンドを別スクリプト(例dev:app)へ逃がす形にする。turboが各パッケージのdevを叩き、それがportlessを呼び、portlessが設定を読んでパッケージマネージャ経由でdev:appをプロキシ配下で実行する。turbo.jsonの変更は不要で、portlessを入れていないメンバーはpnpm run dev:appを直接叩けば従来どおり動く。

リポジトリはVercel Labs配下だが、Next.js専用ツールではない。PORT環境変数を尊重するフレームワーク(Next.js・Express・Nuxtなど)はそのまま動き、無視するフレームワーク(Vite・Astroなど)にはフラグを注入して対応する。Vercelというプラットフォーム自体の全体像はVercelとは?Next.js最適化サーバーレスからAI SDK・BotID・AI Gatewayまで2026年最新ガイドにまとめてある。

インストールと基本コマンド——Node.js 24要件を実測で確かめる

READMEには Requirements: Node.js 24+ と書かれている。手元の常用環境はNode.js 22.13.1だったので、まず「24未満だと本当に動かないのか」を測った。結論から言うと、止まる場所は1か所だけだった。なお0.15.6でもpackage.jsonengines>=24のままで、0.15.5からの差分はバージョン番号の行だけである(git diff v0.15.5...v0.15.6 で確認)。

インストールはグローバルが推奨だが、検証では環境を汚さないようローカルのprefixへ入れた。

# インストール(推奨はグローバル)
npm install -g portless

# 動作確認
portless --version
portless doctor

Node.js 22.13.1でのインストール結果は次のとおり。警告は出るが、終了コードは0で成功している

npm warn EBADENGINE Unsupported engine {
npm warn EBADENGINE   package: 'portless@0.15.6',
npm warn EBADENGINE   required: { node: '>=24' },
npm warn EBADENGINE   current: { node: 'v22.13.1', npm: '11.1.0' }
npm warn EBADENGINE }

added 1 package in 823ms

これはnpmの仕様どおりの挙動だ。enginesフィールドはengine-strictを設定していない限り助言的で、インストールを止めない。つまり「Node 24が必要」と書かれていても、入れる段階では弾かれない。added 1 package という行にも意味がある——依存パッケージが1つも引かれておらず、実行時の外部依存はportless本体だけということだ。

続けて段階ごとに測ると、次のように分かれた。

実行内容 Node.js 22.13.1での結果(0.15.6)
npm install portless@0.15.6 成功(EBADENGINE警告のみ、終了コード0)
portless --version 0.15.6 を出力。正常
portless --help ヘルプ全文を出力。正常
portless doctor 実行できるが fail Node.js 22.13.1 is unsupported. を報告し、終了コード1
portless(devスクリプト経由・HTTPモード) 正常動作。0.15.6で追加されたフラグ注入まで含めて完走
portless run <cmd>(HTTPモード) 正常動作。プロキシ起動・ポート割当・子プロセス起動まで完走

--version--helpが通るのは当然で、これらは引数を見て即座に返すだけだから実装の大半に触れない。バージョン要件の検証で「--helpが動いたから大丈夫」と判断すると必ず間違う。重要なのは実際にプロキシを起動して子プロセスを走らせるところまで通ったという点だ。今回はNode 22.13.1でも、0.15.6の目玉である「package.jsonスクリプト経由の--port注入」がそのまま機能した。

portless doctorはv0.15.0で追加された読み取り専用の診断コマンドで、状態を変更せずに環境を点検する。Node 22での出力は以下のとおりで、不合格の理由と対処が明示される。

portless doctor

Version: 0.15.6
Node.js: 22.13.1
Platform: darwin arm64
State dir: /path/to/state
Proxy target: http://127.0.0.1:1355
Mode: HTTP, .localhost

fail  Node.js 22.13.1 is unsupported.
      Install Node.js 24 or newer.
info  State directory has not been created yet: /path/to/state
ok    Proxy is responding on port 1355.
warn  Proxy is running but the PID file is missing: /path/to/state/proxy.pid
info  HTTPS is disabled, so OpenSSL is not required for this run.
info  HTTPS is disabled for the current proxy state.
info  No routes are registered.

Summary: 1 failure, 1 warning.

ok Proxy is responding on port 1355.warn ... PID file is missing の2行は検証手順の副産物だ。環境を汚さないようPORTLESS_STATE_DIRで状態ディレクトリを隔離しており、doctorは「別の状態ディレクトリから起動されたプロキシがポート1355で応答している」状態を見ている。通常の使い方では出ない組み合わせなので、この2行は読み飛ばしてよい。)

このdoctorは終了コードを返す。同じ0.15.6でNode 22.13.1では終了コード1、Node 24.19.0では Summary: 0 failures, 0 warnings. とともに終了コード0だった。つまりCIやセットアップスクリプトの中でportless doctorを素直にゲートとして使える。「診断コマンドは常に0を返す」タイプのCLIもあるので、ここは実際にecho $?まで確認する価値がある(パイプでheadなどに繋ぐと$?がそちらの終了コードになるため、単独で実行して測った)。

「動いた」と「サポートされている」は別物

Node.js 22で完走したのは、あくまでHTTPモードの単純なケースだ。doctorが明示的に不合格を出している以上、サポート対象外の構成であることに変わりはなく、TLSや証明書まわりで24以降のAPIに依存する経路が今後増える可能性もある。常用するなら24以上を入れるべきで、ここで示したのは「要件表の数字が、どの段階で、どう効くか」を正確に把握するための実測である。

主要なコマンドは次のように整理できる。portless listで現在のルート、portless pruneでクラッシュしたセッションの残骸を掃除する、という運用系が一通り揃っている。

コマンド 役割
portless package.jsonのdevスクリプトをプロキシ経由で実行
portless <name> <cmd> https://<name>.localhost でアプリを起動
portless run [cmd] 名前をプロジェクトから推測して実行
portless alias <name> <port> 既存のポートに静的ルートを割り当て(Docker等)
portless list 有効なルートの一覧
portless doctor プロキシ・ルート・DNS・CA信頼状態の診断
portless trust ローカルCAをOSの信頼ストアへ登録
portless clean 状態・CA登録・hostsブロックをまとめて撤去
portless prune 落ちたセッションが残した開発サーバーを終了
PORTLESS=0 <cmd> portlessを迂回して素の状態で実行

rungetaliashostslistdoctortrustcleanpruneproxyservice はサブコマンドとして予約されており、アプリ名には使えない。この名前を使いたい場合は portless --name <name> <cmd> で明示する。

portlessがマシンに入れるもの——ローカルCA・信頼ストア・/etc/hosts

portlessは既定でHTTPSを有効にする。ブラウザ警告を出さずにHTTPSを成立させるには、マシンが信頼する認証局から証明書を発行するしかない。つまりportlessは自前のCAを作り、それをOSの信頼ストアに入れる。これはmkcertと同じ発想だが、導入前に把握しておくべき部分なので0.15.6で実物を確認した(環境を汚さないようPORTLESS_STATE_DIRで状態ディレクトリを隔離し、確認後に撤去している)。

HTTPSモードで起動すると、状態ディレクトリに次のファイルが生成された。

-rw-------  ca-key.pem      # CAの秘密鍵(パーミッション600)
-rw-r--r--  ca.pem          # CA証明書
-rw-r--r--  ca.srl          # シリアル番号
-rw-------  server-key.pem  # サーバー証明書の秘密鍵(600)
-rw-r--r--  server.pem      # サーバー証明書

これはプロキシを起動しただけの状態で、アプリをまだ登録していないため、ルート一覧(routes.json)はまだ作られていない。既定の状態ディレクトリは~/.portless/である。

秘密鍵はいずれも 600(所有者のみ読み書き)で作られていた。生成されたCA証明書の中身をopensslで確認すると次のようになる。

Subject / IssuerCN=portless Local CA(自己署名)
有効期間:2026-08-25 から 2036-08-22 まで(10年
Basic ConstraintsCA:TRUE(critical)
鍵と署名:NIST P-256の楕円曲線鍵/ecdsa-with-SHA256

サーバー証明書のほうは CN=localhost、SANに DNS:localhost, DNS:*.localhost, DNS:*.local を持ち、有効期間は1年だった。*.local が入っているのはLANモード(mDNSで実機に配信する機能)のためだ。

ここで実測して分かった挙動がある。TTYの無い非対話環境でHTTPSモードのプロキシを起動すると、出力は次の3行で止まった。

Ensuring TLS certificates...
Generated local CA certificate.
Adding CA to system trust store...

「信頼ストアへの追加」の途中で停止する(30秒待っても戻らず、こちらから止めた)。ところがこの時点でCA証明書はすでにログインキーチェーンへ登録されている——中断後に検索すると1件ヒットし、フィンガープリントは生成されたばかりのca.pemと一致していた。

一方で、登録されただけで信頼はされていなかった。macOSの信頼設定は空のままで、証明書の検証も失敗する。

security dump-trust-settings
# SecTrustSettingsCopyCertificates: No Trust Settings were found.

security verify-cert -c portless_ca.der
# Cert Verify Result: CSSMERR_TP_NOT_TRUSTED

つまり「キーチェーンに入っている」と「信頼されている」は別段階で、信頼設定の付与には管理者パスワードの入力=対話的な認証が要る。非対話環境ではそこまで到達せず、証明書だけが宙ぶらりんで残る。これがportless trustという独立したコマンドが用意されている理由でもある(初回に信頼のプロンプトをスキップした場合、後から実行して信頼を付与する)。CI・リモートシェル・エージェント経由でportlessを初回起動する構成では、先に対話ターミナルで一度HTTPSを立ち上げておくか、--no-tlsでHTTPモードにするのが現実的だ。

撤去も実測した:portless clean は入れたものを戻せる

導入を検討するうえで「元に戻せるか」は判断材料になる。0.15.6でも portless clean は期待どおりに機能した。実行後、ログインキーチェーンの portless Local CA1件→0件になり、状態ディレクトリの中身も空になった。出力は「Removed local CA from the system trust store.」「Removed portless state files from known state directories.」「Removed portless entries from /etc/hosts.」と、撤去した対象を明示する。ポート上に残っていたプロキシプロセスも「Killed process ####. Proxy stopped.」として停止された。なお--cert/--keyで自前の証明書を渡していた場合、そのファイルは削除されない。

/etc/hosts については、.localhost だけを使っている限り書き込まれなかった。今回の一連の検証を通して/etc/hostsにportlessのブロックは現れていない。.localhostはブラウザ側が解決するので書く必要がないためで、書き込みが発生するのはカスタムTLD(.testなど)やLANモードの.local、あるいはSafari対策で明示的にportless hosts syncを実行したときだ。

ネットワーク的な露出範囲も確認しておく価値がある。CHANGELOGによればv0.15.4でプロキシのバインド先が明示的にループバックのみ(127.0.0.1::1)に限定された。LANモード以外では、LAN・VPN・その他のインターフェース経由では到達できない。ローカル開発ツールをネットワークに晒さないという意味で妥当な既定値だ。プロキシ型のツールが通信経路に入る構成という点では、sql-tapとは|アプリを無改修でSQLトラフィックをリアルタイムに覗くGo製プロキシ型TUI/Web監視ツールと同じ設計思想の系譜にある。

portlessの--port自動注入はどこまで効くか——0.15.6と0.15.5で29パターンを実測

portlessは割り当てたポートをPORT環境変数で子プロセスに渡す。Next.jsやExpressはこれを読むので何もしなくていい。問題はPORTを無視するフレームワーク(Vite・Astro・React Router・Angular・Expoなど)で、これらにはportlessが--portフラグを注入する。

この注入がどこまで効くのかを、引数をそのまま報告する偽のフレームワークバイナリをnode_modules/.bin/に置いて実測した。実際のViteを入れる必要がなく、注入されたフラグを正確に観測できる。まず陽性対照として素のviteで注入が起きることを確認し、そのうえで各ケースを比較している(対照が発火しなければ、他の結果は「注入されなかった」のか「そもそも認識されていない」のか区別できない)。0.15.5と0.15.6は同じfixture・同じ偽バイナリに対して走らせ、プロキシだけ別ポート・別状態ディレクトリに分けた。下の2つの表は、すべてのセルをこの手順で両バージョンとも実測した値である(直接指定19コマンド+スクリプト10パターン、各2回で計58実行)。

まずコマンドを直接渡す形portless run <cmd>)の結果。

実行したコマンド 0.15.5 0.15.6
vite(陽性対照) --port <n> --strictPort --host 127.0.0.1 同じ(注入あり)
vite dev / vite serve / vite preview 注入あり 注入あり
vite start(未分類のサブコマンド) 注入あり 注入あり(既定でサーバー扱い)
vite ./src(ルートを位置引数で渡す) 注入あり 注入あり
vite build 注入あり 素通し(buildのみ)
vite optimize 注入あり 素通し
astro dev --port <n> --host 127.0.0.1 同じ(--strictPortなし)
astro check 注入あり 素通し
vp dev / ng serve / react-router dev 注入あり 注入あり
vp test 注入あり 素通し
vp --mode dev build 注入あり 素通し(分類できないため)
expo start --tunnel --port <n> --host localhost --port <n> のみ--hostを足さない)
vite --port 9999 既存の--portを尊重し--hostだけ追加 同じ
next dev / next build 注入なし(PORTで足りる) 同じ
portless 0.15.5 と 0.15.6 の --port 自動注入の比較:0.15.5ではvite build・vite optimize・astro check・vp test・vp --mode dev buildに--portが付き、devスクリプト経由は10パターンすべて素通し。0.15.6では前者5つが素通しになり、devスクリプト経由で注入されるようになった
偽のフレームワークバイナリをPATHに置き、子プロセスが実際に受け取った引数をそのまま観測した(2026-08-25 実測)

読み取れることが4つある。

1つ目:既に--port 9999が指定されている場合、portlessは上書きしない。--hostだけを足す。ユーザー指定を尊重する設計で、これは両バージョンで共通だった。

2つ目:Next.jsには何も注入されない。PORT環境変数を読むフレームワークとして扱われているためで、next buildのようなサーバーを立てないコマンドにも余計なフラグが付かない。

3つ目——ここが0.15.6でもっとも大きく変わった点だ——サーバーを立てないコマンドへの注入が止まったvite buildvite optimizeastro checkvp testvp --mode dev buildは、0.15.5では全部に--portが付いていた。これらは本来サーバーを起動しないコマンドで、--portを受け取ると通常はエラーになる。0.15.6では5件すべてが素通しになり、子プロセスはサブコマンドだけを受け取った。最後のvp --mode dev buildは「サブコマンドの前にフラグが来ていて分類できない」ケースで、0.15.6は分からないものには触らないという方向に倒している。

ソースを見ると、判定のしかた自体が入れ替わっている。0.15.5のフレームワーク定義は{ strictPort: boolean }だけの単純な表で、サブコマンドを見る余地がなかった。0.15.6では同じ8フレームワーク(vite / vp / react-router / rsbuild / astro / ng / react-native / expo)の各エントリにserverSubcommandsnonServerSubcommandsdefaultIsServerpositionalRootIsServervalueFlagsが追加され、「そのサブコマンドはサーバーを立てるか」で分岐する構造になった。

4つ目:Expoの接続モードが保たれるようになった。expo start --tunnelに対して0.15.5は--host localhostまで足していたが、tunnelモードと--host localhostは本来かみ合わない。0.15.6は--portだけを注入し、--tunnelをそのまま残す。

package.jsonのスクリプト経由——ここが0.15.6の本命

もう1つ、0.15.5ではまったく効かなかった経路がある。package.jsonのdevスクリプト経由だ。"dev": "vite" と書いてportlessを単体実行すると、0.15.5では次のようになっていた。

Running: PORT=4476 ... npm run dev
PROBE_ARGV=[]

npm run dev がそのまま実行され、--portは一切付かないPORT環境変数は設定されているが、Viteはそれを読まない。結果としてViteは自分の既定ポート(5173)で起動し、portlessは別のポートへ転送しようとする——画面は開かない。

0.15.6で同じことをすると、こうなる。

Running: PORT=4643 ... npm run dev -- --port 4643 --strictPort --host 127.0.0.1
PROBE_ARGV=[--port 4643 --strictPort --host 127.0.0.1]

-- を挟んでフラグがスクリプトへ渡っている。ただしどんなスクリプトでも通るわけではないdevスクリプトを10パターン書き換えながら、両バージョンで測った結果が次の表だ。0.15.5は10パターンすべてで素通し——この経路の注入機構そのものが存在しない。

package.json の dev 0.15.5 0.15.6 0.15.6でそうなる理由
"vite" 素通し 注入あり フレームワーク名で始まる
"bunx vite" 素通し 注入あり 既知のランナー(npx/bunx/pnpx/yarn dlx/pnpm dlx)を透過
"astro dev" 素通し 注入あり サーバー系サブコマンド
"vite build" 素通し 素通し サーバーを立てないサブコマンド
"NODE_ENV=production vite" 素通し 素通し 環境変数プレフィックス
"vite && echo done" 素通し 素通し 複合コマンド(&&|;
"npm run dev:vite" 素通し 素通し 別スクリプトへの委譲
"vite -- --foo" 素通し 素通し スクリプト自身が--を持っている
"vite # comment" 素通し 素通し 末尾のコメント
"next dev" 素通し 素通し PORTを読むので注入不要

2つの表を突き合わせると、29パターンのうち注入の有無が反転したのは8パターンだと分かる——直接指定の5件(vite buildvite optimizeastro checkvp testvp --mode dev build)が「注入あり→素通し」へ、スクリプト経由の3件("vite""bunx vite""astro dev")が「素通し→注入あり」へ動いた。残る21パターンは両バージョンで同じ結果で、うちexpo start --tunnelだけは注入自体は続くものの付与されるフラグが変わっている。

素通しになる5パターンはバグではなく設計で、READMEにも「フラグを足しても機能しない形なので触らない」と明記されている。ただし読者にとって重要なのは、素通しになったスクリプトは自分でポートを指定しないと画面が開かないという点だ。&&で繋いだdevスクリプトを使っているプロジェクトは、portlessを単体実行しても注入されないので、スクリプト側で--port $PORTを書くか、portless run viteの形に変える必要がある。

0.15.5を使い続けているなら、この2点だけは把握しておく

グローバルインストールを更新していない環境や、package-lock.jsonで0.15.5に固定しているプロジェクトでは、上の表の左列がいまも現実だ。"dev": "vite" 経由では--portが注入されない ②vite buildにも--portが付いてしまう——この2つは0.15.6で解消しているので、Viteベースのプロジェクトで動かなかった経験があるなら、まず npm install -g portless@latest で更新して再試行する価値がある。

portless 0.15.6で変わったもう2つの点——Ctrl+Cの後始末とREADMEのズレ

--portの注入以外にも、0.15.6は実務で体感できる変更を2つ含んでいる。1つは「Ctrl+Cを押したのに開発サーバーが死んでいない」問題、もう1つは長らくこのリポジトリを扱いにくくしていた「READMEが先行している」状態の解消だ。

Ctrl+Cを押したあと開発サーバーは本当に死ぬのか

この変更は、リリースノートに「Ctrl+C process cleanup」として1行だけ書かれている地味な項目だ。だがローカル開発で「ポートが埋まったまま」になる事故の原因はたいていここなので、実際に測る価値がある。

問題が起きるのは、portless run が起動したコマンドがさらに別のプロセスを産んでいて、そのコマンド自身がSIGINTを無視するときだ。よくあるのはシェルスクリプトのラッパー、concurrentlyのような多重起動ツール、独自にシグナルを握るCLIなどである。親がSIGINTを飲み込んでしまうと、その下にいる開発サーバー(=孫プロセス)は誰からも終了を告げられない。

リポジトリのE2Eテストにこの状況を再現するフィクスチャ(stubborn-wrapper.js)があるので、同じ構造を手元で組んで測った。SIGINT・SIGTERMを両方とも無視するラッパーが、detached: true で子を起動し、その子がポートを掴んで待ち受ける。この状態でportlessにSIGINTを送り、孫プロセスが生き残るかどうかをPIDで直接確認する。

結果は明快だった。0.15.5では3回中3回とも孫プロセスが生き残り、0.15.6では3回中3回とも終了した。

[0.15.5] RESULT: grandchild SURVIVED (orphan) pid=72048
[0.15.6] RESULT: grandchild TERMINATED
portless 0.15.6が追加したCtrl+C後の4段階:SIGINT受信、コマンドへ転送して終了を待つ、プロセスツリーを追跡して子孫を列挙、猶予後に残った子孫を終了。0.15.5には後半2段が無く孫プロセスが3回中3回残った
SIGINTを無視するラッパーの下に孫プロセスを置き、Ctrl+C相当のシグナル送出後にPIDの生存を確認した(各3回実行)

生き残った側は、割り当てられたポートを握ったまま残る。次に同じアプリを起動するとportlessは別の空きポートを選ぶのでEADDRINUSEにはならないが、プロセスだけが積み上がっていくportless pruneという「落ちたセッションが残した開発サーバーを終了する」コマンドが用意されているのは、まさにこの後始末のためだ。0.15.6ではその出番が減る。

実装側の値も確認できる。0.15.6のcli-utils.tsには COMMAND_SHUTDOWN_GRACE_MS = 5000 という定数と、trackProcessTree / signalTrackedProcesses / isProcessRunning / hasRunningProcesses という関数が新しく入っている。リリースノートの「a short grace period」は5秒のことだ。0.15.5のソースにはこれらの関数がいずれも存在しない。

この検証を自分の環境で再現するには

必要なのは3つだけだ。①SIGINT/SIGTERMを無視し、detached: trueで子プロセスを起動するラッパースクリプト ②その子がPIDをファイルへ書き出すこと ③portlessのプロセスへkill -INTを送ったあと、そのPIDが生きているかをkill -0で確認すること。ターミナルで実際にCtrl+Cを押す場合はフォアグラウンドのプロセスグループ全体にSIGINTが飛ぶが、detachedな孫は自分のプロセスグループを持つのでどちらにせよ届かない——そこがこの機能の対象範囲になる。

READMEとnpm公開版のズレは解消した——ただし構造は残っている

このリポジトリを扱ううえで長らく厄介だったのが、GitHubのREADMEが説明しているのはmainブランチの挙動で、npmで配布されている最新版はそれより後ろにいるという状態だった。0.15.5の時期には、READMEが説明する「package.jsonスクリプトへのフラグ注入」「vite buildにはフラグを付けない」という挙動が、npm installで手に入る版にはまだ存在していなかった

2026-08-25時点では、このズレは解消している。両者を実際に取得して比較した結果が次の表だ。

比較対象 v0.15.5タグ v0.15.6タグ(現在のnpm公開版) mainブランチ
cli-utils.ts の行数 1,188行 1,727行 1,727行
README のバイト数 24,986 26,272 26,272
injectPackageScriptFrameworkFlags 存在しない 実装済み 実装済み
isSafeToInjectIntoScript 存在しない 実装済み 実装済み
サーバー系サブコマンドの判定 なし serverSubcommands/nonServerSubcommands 同左
v0.15.6タグとの差分 0コミット 0コミット

v0.15.6...main の比較は ahead_by: 0——つまりmainのHEADはv0.15.6のタグそのものPrepare v0.15.6 release (#394))で、READMEを読んで期待した挙動とnpm installで入る挙動が、いまは一致している。

時系列で追うと、この解消は直前に起きたことが分かる。

flowchart TD A["2026-07-30 14:09 JST
v0.15.5 を npm 公開"] --> B["2026-08 前半
main にスクリプト注入・
プロセスツリー追跡が入る"] B --> C["この期間
README は main の姿・
npm は 0.15.5
=説明と挙動がズレる"] C --> D["2026-08-25 01:19 JST
Prepare v0.15.6 release を main へ"] D --> E["2026-08-25 01:40 JST
v0.15.6 を npm 公開"] E --> F["main と v0.15.6 の差 0 コミット
=ズレが解消"]

ただし、これは構造そのものが直ったわけではない。GitHubのトップページに表示されるのは常にmainのREADMEで、npm installで入るのは最新リリースだ。次の機能がmainへマージされた瞬間に、また差が開き始める。プレ1.0のOSSでREADMEどおりに動かないときは、まずリリースタグ側のドキュメントとソースを見るのが早い(gh api repos/<owner>/<repo>/contents/<path>?ref=<tag> でタグ指定して取得できる)。

なお、公平を期すために付け加えると、これはドキュメントの誤りではなかった。v0.15.5タグのREADMEには「vite buildは対象外」といった記述がそもそも含まれておらず、リリース単位で見ればコードとREADMEは常に整合している。問題は「利用者が読むのはほぼ常にmainのREADME」という導線のほうにある。

リポジトリ自体の開発は活発だ。npmには41バージョンが公開されており、CHANGELOGの直近では 0.15.6 で本記事が測った3点+Windowsの起動サービス修正、0.15.5 で多段TLD対応とHTTP/2上のWebSocket(RFC 8441の拡張CONNECT)対応、0.15.4 でループバック限定バインド、と実務的な修正が続いている。バージョン間の差分を確認する習慣は、プレ1.0のツールを扱ううえではGit 2.54の新機能まとめ:git historyコマンドと設定ベースフックで開発ワークフローが変わるで触れたようなバージョン追跡の作法と地続きだ。

エージェント向けの作り込み——llms.txt・同梱スキル・OAuthとカスタムTLD

0.15.6の新機能としてリリースノートの先頭に書かれているのが「Agent-readable documentation」だ。ドキュメントサイトが、同じページをHTMLと素のMarkdownの両方で配信するようになったhttps://portless.sh/configuration.md を付けて https://portless.sh/configuration.md を叩くと、text/markdown が返る。加えて https://portless.sh/llms.txt(425バイト)が、収録6ページの一覧をリンク集として提供する。

これは体感でなく数字で差が出る。同じ6ページをHTMLと.mdの両方で取得してバイト数を比べた。

ページ HTML .md 比率
/(Getting Started) 97,042 B 5,993 B 16.2倍
/why 36,003 B 1,670 B 21.6倍
/commands 140,832 B 9,042 B 15.6倍
/https 36,340 B 1,063 B 34.2倍
/configuration 106,404 B 8,466 B 12.6倍
/changelog 205,120 B 24,098 B 8.5倍
合計 621,741 B 50,332 B 12.4倍
portless.shの6ページについてHTMLとMarkdownのバイト数比:/httpsが34.2倍、/whyが21.6倍、トップが16.2倍、/commandsが15.6倍、/configurationが12.6倍、/changelogが8.5倍
2026-08-25 に portless.sh の各ページを HTML と .md で取得し、転送バイト数を比較した

エージェントにドキュメントを読ませる場面では、この差がそのままコンテキスト消費の差になる。ドキュメント全体を読ませても50KB弱という規模なので、「必要そうなページだけ探す」より「llms.txtから全部引いて渡す」ほうが素直だ。なおHTML側にはナビゲーション・検索インデックス・スクリプトが含まれるため、この比率はページの本文量ではなくフレームワークのオーバーヘッドを反映している点に注意したい(/changelog のように本文自体が長いページほど比率は小さくなる)。

同梱スキルとOAuthのリダイレクトURI問題

もう1つ見落とされがちなのが、skills/ ディレクトリにエージェント向けのスキルファイルが2本入っている点だ。README冒頭の「For humans and agents」はキャッチコピーではなく、実体を伴っている。

skills/portless/SKILL.md(v0.15.6時点で29,127バイト)——portlessの導入・設定・トラブルシュート手順
skills/oauth/SKILL.md(7,465バイト)——OAuthプロバイダをportlessのローカルURLで動かす手順

前者には、人間向けREADMEには書かれていない運用指示が含まれる。たとえば実行方法について「npxpnpm dlx でのワンショット実行は使うな」と明示している。プロキシが常駐する性質のツールなので、都度ダウンロードして実行する形と相性が悪いという判断だろう。

後者のOAuthスキルは、実務上の価値が高い情報を表にまとめている。.localhost はOAuthのリダイレクトURIとして多くのプロバイダに拒否されるという問題への対処だ。

プロバイダ localhost .localhost のサブドメイン 理由
Google 許可 拒否 同梱のPublic Suffix Listに含まれない
Apple 拒否 拒否 localhost自体を許可しない
Microsoft 許可 許可 localhostの扱いが寛容
Facebook 許可 場合による URIを個別に登録する必要がある
GitHub 許可 許可 寛容

GoogleとAppleが厳しい。この対策としてportlessが用意しているのが --tld で、任意のDNS名をTLDとして使える。自分が所有するドメインを指定すれば、ローカル開発URLが本番と同じ構造になる。

# 所有しているドメインをローカル開発のTLDとして使う
portless proxy start --tld dev.example.com
portless myapp next dev
# -> https://myapp.dev.example.com

こうすると https://myapp.dev.example.com/api/auth/callback/google をリダイレクトURIとして登録でき、GoogleやAppleの検証を通せる。cookieのサブドメイン共有やHostベースのルーティングも本番と同じ挙動になるため、環境差に起因するバグを減らせる。プロキシは登録済みホスト名を/etc/hostsへ自動同期するので、名前解決も手当てされる。ただしこの構成はループバック限定で、他の端末からは到達できない(LANモードは.local固定でカスタムTLDと併用できない)。

同梱ドキュメント間で推奨が食い違っている箇所がある

READMEは「.dev は避けろ(Googleが所有し、HSTSでHTTPSを強制する)」と明記し、推奨として.test(IANA予約済み)を挙げている。一方でOAuthスキルの最初の例は portless proxy start --tld dev と、まさにその.devを使っている。スキル側も直後の段落で「myapp.devは実在ドメインと衝突しうるので、自分が所有するドメインを多段TLDとして使え」と軌道修正しているため実害は小さいが、最初のコード例だけを模倣すると推奨から外れる。自分が所有するドメインを使う形が最も安全だ。

エージェントとの相性という文脈では、そもそもの動機が「AIエージェントがポートを推測して間違える」問題にある。固定URLならエージェントに渡す情報が決定的になる。加えてgit worktreeの自動検出——リンクされたworktreeではブランチ名がサブドメインとして前置され、fix-ui.myapp.localhost になる——により、ブランチごとに並行してエージェントを走らせても、それぞれが自分のURLを持つ。設定変更は不要で、portless run をpackage.jsonに一度書けば全worktreeで機能する。

類似ツールとの比較——portlessを選ぶ基準

ローカル開発でHTTPSと名前付きドメインを得る手段は以前から存在する。portlessが埋めているのは「セットアップの手数」の部分だ。

手段 名前付きURL HTTPS ポート自動割当 前提
portless ○(.localhost/任意TLD) ○(CA自動生成・自動信頼) ○(4000-4999) Node.js 24+
mkcert + nginx/Caddy ○(要hosts編集) ○(CAはmkcertが生成) ×(自分で管理) 各コンポーネントの設定
Traefik(Docker) ○(ラベル駆動) ○(設定次第) ○(コンテナ前提) Docker Compose
ngrok / Tailscale ○(外部ドメイン) アカウント・CLI
素のlocalhost:3000 × ×(要手動設定) × なし

選択の目安は次のように整理できる。

Node.jsのフロントエンド開発でworktreeやモノレポを並行で回すなら、portlessが最も手数が少ない。設定ファイルなしで動き、worktree対応が組み込みである点は他にない
Docker Composeで全サービスを動かしているなら、Traefikのほうが素直だ。コンテナのラベルでルーティングが完結し、Node.jsへの依存も増えない。portlessもportless alias <name> <port>で既存ポートに静的ルートを張れるので併用は可能
外部に見せたい(モバイル実機確認・Webhook受信・チーム共有)なら、ngrokやTailscaleが本命。portlessは--ngrok--tailscale--funnelフラグでこれらを内蔵しており、ローカルURLと公開URLを同時に持てる
Node.js 24を入れられない環境なら、mkcert+リバースプロキシの従来構成のほうが安全

導入時のつまずきどころも押さえておきたい。フロントエンドの開発サーバーが別のportlessアプリへAPIをプロキシする構成では、Hostヘッダを書き換えないと無限ループになる。ViteならchangeOrigin: trueを指定する。portless側はこの誤設定を検出して508 Loop Detectedを返し、対処法を示すようになっている。

もう1点、プロジェクトのdevDependencyとして入れると、コントリビュータごとにバージョンが分かれる。portlessはプレ1.0で状態ディレクトリの形式が変わることがあり、その場合portless trustのやり直しが必要になる。README自身が注意しているとおり、グローバルインストールのほうが運用は安定する。

このリスクは今回の0.15.5→0.15.6で具体化した。同じ"dev": "vite"というスクリプトが、0.15.5の人の手元では画面が開かず、0.15.6の人の手元では開く——「自分の環境だけ動かない」の典型的な原因になる。チームで使うなら、まずportless --versionを揃えるところから確認したい。

実測のまとめ(0.15.6・2026-08-25)

--portの自動注入がpackage.jsonのスクリプト経由でも届くようになった。0.15.5では"dev": "vite"が素通しで画面が開かなかったが、0.15.6ではnpm run dev -- --port <n> ...として渡る
・逆にサーバーを立てないコマンドには付かなくなったvite buildvite optimizeastro checkvp testvp --mode dev buildの5件が素通しに変わった
Ctrl+C後のゾンビ化が止まった。SIGINTを無視する親の下の孫プロセスは、0.15.5では3回中3回残り、0.15.6では3回中3回終了した(猶予は5秒)
READMEとnpm公開版のズレは解消(main と v0.15.6 の差は0コミット)。ただし構造は残るので、次の機能がmainへ入れば再び開く
・Node.js 24要件はインストールを止めない。22.13.1でも警告のみで入り、0.15.6の新機能まで含めて実行できた。明示的に止まるのはdoctor(終了コード1)だけ
・HTTPS既定のため、10年有効の自己署名CA(P-256)がOSの信頼ストアに入る。非対話環境では登録だけされて信頼は付かず、撤去はportless cleanで可能

参照ソース