portless

命令行工具

vercel-labs/portless

用稳定的本地域名替代端口号进行开发。

项目简介

Portless 是一款命令行工具,为本地开发服务器提供如 myapp.localhost 的稳定 HTTPS 域名,替代随机端口。自动启动代理、处理 TLS 证书信任,并为常见框架注入端口参数,支持子域名、Monorepo、Turbo 流水线与 Git Worktree。

README 预览

# portless\n\nReplace port numbers with stable, named .localhost URLs for local development. For humans and agents.\n\n```diff\n- "dev": "next dev"                  # http://localhost:3000\n+ "dev": "portless run next dev"     # https://myapp.localhost\n```\n\n## Install\n\n**Global (recommended):**\n\n```bash\nnpm install -g portless\n```\n\n**Or as a project dev dependency:**\n\n```bash\nnpm install -D portless\n```\n\n> portless is pre-1.0. When installed per-project, different contributors may run different versions. The state directory format may change between releases, which can require re-running `portless trust`.\n\n## Run your app\n\n```bash\nportless myapp next dev\n# -> https://myapp.localhost\n```\n\nHTTPS with HTTP/2 is enabled by default. On first run, portless generates a local CA, trusts it, and binds port 443 (auto-elevates with sudo on macOS/Linux). Use `--no-tls` for plain HTTP.\n\nThe proxy auto-starts when you run an app. A random port (4000-4999) is assigned via the `PORT` environment variable. Most frameworks (Next.js, Express, Nuxt, etc.) respect this automatically. For frameworks that ignore `PORT` (Vite, VitePlus, Astro, React Router, Angular, Expo, React Native), portless auto-injects the right `--port` flag and, when needed, a matching `--host` flag. Injection reaches through a package script whose command starts with the framework or a known runner (`"dev": "vite"`, `"dev": "bunx vite"`). Only the framework's server commands get the flags (`dev`, `serve`, `preview`, `start`, a bare `vite`, or `vite [root]`); a command that does not serve, such as `vite build`, `vite optimize`, `vp test` or `astro check`, rejects them and is left alone. Expo connection modes (`--localhost`, `--lan`, `--tunnel`) are preserved while the assigned port is still injected. A script portless cannot classify is left alone too: a flag before the subcommand on a CLI whose flag grammar it does not track (`vp --mode dev build`). Portless also leaves a script alone w

常见问题 (5)

故障排除
当后端在 keep-alive 请求之后重启时,如何修复 Node.js 代理中未处理的 'read ECONNRESET' 崩溃?

此问题影响 portless 0.10.3–0.15.6。代理会将客户端的 Connection: keep-alive 头部转发到后端,但响应完成后,后端 socket 上既没有 agent,也没有 error 监听器。如果后端重启并重置该 socket,Node 会发出未处理的 ECONNRESET 并退出。

通过在调用 http.request() 之前移除 hop-by-hop 头部,修复常规的 HTTP 代理路径:

for (const name of String(proxyReqHeaders.connection || '').split(',')) {
  const header = name.trim().toLowerCase();
  if (header) delete proxyReqHeaders[header];
}

for (const header of HOP_BY_HOP_HEADERS) {
  delete proxyReqHeaders[header];
}

典型的 hop-by-hop 头部包括:

const HOP_BY_HOP_HEADERS = [
  'connection',
  'keep-alive',
  'proxy-authenticate',
  'proxy-authorization',
  'te',
  'trailer',
  'transfer-encoding',
  'upgrade',
];

不要将此改动应用到 WebSocket 升级路径:该路径有意发送 Connection: Upgrade,并且已经附加了 socket error 监听器。

作为临时解决方案,可以为 createLoopbackConnection() 返回的 socket 附加一个空操作(no-op)的错误处理器:

createConnection: () => {
  const socket = createLoopbackConnection(route.port);
  socket.on('error', () => {});
  return socket;
}

待包含 issue #434 修复的版本发布后,将 portless 升级到该版本。

来源 Issue #434
故障排除
如何修复 Portless 通配符子域名路由到错误父应用的问题?

在设置 PORTLESS_WILDCARD=1 时,Portless 0.15.3/0.15.5 使用 routes.find() 进行通配符回退,因此 routes.json 中第一个匹配的父路由会生效,而不是最具体的那个。请修复 findRoute,使其选择匹配最长的父主机名:

routes
  .filter((r) => hostname.endsWith("." + r.hostname.toLowerCase()))
  .sort((a, b) => b.hostname.length - a.hostname.length)[0]

例如,这样 admin.api.acme.localhost 就会先匹配 api.acme.localhost,再轮到 acme.localhost。作为临时变通方案,可以将更具体的路由放在 routes.json 的前面,但这依赖于顺序,不如该补丁可靠。修复版本应包含标题为 "fix(proxy): pick the most specific parent route in wildcard fallback" 的 PR。

来源 Issue #380
故障排除
如何修复 Portless 覆盖 /etc/hosts 并删除 localhost 和其他系统条目的问题?

升级到包含 PR #424 的 Portless 版本(修复了 issue #419)。在受影响的版本中,当无法读取 /etc/hosts 时,readHostsFile() 会返回空字符串,因此 syncHostsFile() 仅写入 Portless 管理的块。该修复使得在初始读取失败时同步和清理操作会中止,并在写入后验证读取失败时返回失败。

要恢复损坏的 hosts 文件,请使用特权编辑它:

sudo nano /etc/hosts

至少恢复标准的环回(loopback)条目,例如:

127.0.0.1	localhost
127.0.0.1	myhost
::1		localhost ip6-localhost ip6-loopback
ff02::1		ip6-allnodes
ff02::2		ip6-allrouters

如果存在备份,请改为恢复备份:

sudo cp /etc/hosts.bak /etc/hosts

作为临时解决方法,当 /etc/hosts 可能被锁定、不可用或无法读取时,请避免运行受影响的 Portless 版本,并确保进程具有读取和写入该文件所需的权限。

来源 Issue #419
故障排除
为什么 Portless 在 macOS 上会分配已被占用的端口,并将就绪检查路由到错误的进程?

这是由 Portless 0.12.0 在代理到 127.0.0.1:<port> 时,使用不指定主机地址的 net.createServer().listen(port) 绑定来探测空闲端口所导致的。在 macOS 上,即使另一个进程已经在 IPv4 环回地址上监听,该探测仍可能成功。

临时解决方法:
1. 找到残留的监听进程:
lsof -nP -iTCP:<port> -sTCP:LISTEN
2. 使用 kill <PID> 停止该进程,或使用 PORT=<free-port> 显式指定一个已知空闲的端口。
3. 在重启之前,验证该端口在两个环回协议栈上均为空闲:
lsof -nP -iTCP@127.0.0.1:<port> -sTCP:LISTEN
lsof -nP -iTCP@[::1]:<port> -sTCP:LISTEN

永久修复:升级到包含 issue #288 修复的 Portless 版本(PR #441,基于 #302 构建)。修正后的端口探测会在相关本地地址(包括 127.0.0.1、::1 和 0.0.0.0)上检查候选端口,而不是使用不指定主机地址的绑定。

来源 Issue #288
故障排除
如何修复 portless 使用 Homebrew Node 25 而非我的 asdf、nvm、fnm 或 mise Node 版本的问题?

出现此问题是因为受影响的 portless 版本会将 path.dirname(process.execPath) 前置到子进程的 PATH 中,导致运行 portless 的 Node 遮蔽了由你的版本管理器所选择的版本。

请将 portless 升级到包含 issue #241 修复的版本。该修复会将用户现有的 PATH 置于 portless 自身的 Node 目录之前(或仅在 Windows 上确有需要时才注入该目录),使版本管理器的 shim 能够被优先解析。

brew upgrade portless
# 或者,如果是在受管理的 Node 下通过 npm 安装的:
npm update -g portless

portless myapp sh -c 'node --version'

如果暂时无法升级,可使用临时变通方法:卸载通过 Homebrew 安装的 portless,改用由 asdf/nvm/fnm/mise 管理的 Node 来安装它:

brew uninstall portless
nvm use 24          # 或者:fnm use 24
# asdf: asdf shell nodejs 24.13.0
# mise: mise use -g node@24
npm install -g portless
portless myapp sh -c 'node --version'

来源 Issue #241